Astro Remark 插件开发:构建类型安全的 frontmatter

·14min·技术·#Astro#Remark#TypeScript

Astro 对 TypeScript 提供了优秀的支持,但仍存在一些不足之处。例如,对于 frontmatter 而言,虽然提供了内容集合(Content Collection)提供了一定的类型安全,但在使用 Remark 插件修改 frontmatter 时,类型信息仍会丢失。为此,本文提出一种思路:单独定义扩展的 frontmatter 类型,通过类型推导扩展 Remark 插件类型,在组件中使用类型断言,实现了前后类型一致。

阅读本文章你需要了解:

太长不看 / TL;DR

问题背景

在使用基于 Markdown 的内容生成工具时,我们经常会遇到这样一个熟悉的结构:

src/pages/page.md
---
title: Foo
description: Bar
---
# Bar
Hello, world!

在文件开头,被 --- 包裹的内容显然不属于标准的 Markdown 语法。这部分内容就是我们熟悉的 frontmatter,它用于定义 Markdown 文档的元数据,通常采用 YAML 格式[1]。理论上,frontmatter 内部可以包含任何内容,然后被解析为一个对象,供后续使用。

Astro 提供了优秀的 TypeScript 开发体验,提供了丰富的工具类型。由于 frontmatter 解析后的类型不确定性,在作为 Markdown Layout 的 Astro 组件中,我们只能获得一个 any 对象,这大大影响了开发体验。早期 Astro 提供的解决方案是使用工具类型 MarkdownLayoutProps 标注 frontmatter 的类型。

对于我们的 Post Layout,可以通过如下方法定义 frontmatter 的类型(来自官方示例):

src/layouts/PostLayout.astro
---
import type { MarkdownLayoutProps } from 'astro';
type Props = MarkdownLayoutProps<{
title: string;
author: string;
date: string;
}>;
// 此处的 frontmatter 是类型安全的
const { frontmatter, url } = Astro.props;
---
<h1>{frontmatter.title} by {frontmatter.author}</h1>
<slot />
<p>Written on: {frontmatter.date}</p>

由此,我们可以在 Astro.props 中解构出类型安全的 frontmatter。然而,这种方案存在一个明显的缺陷:因为它只是在组件中单向的定义。即当你在编写 Markdown 时,frontmatter 的类型仍然是未知的。并且,当因为缺少某个属性导致报错时,错误来源则出现在组件中,当逻辑变的复杂的时候,你可能很难找到问题所在。

…查找这个 bug 可能会涉及一些堆栈跟踪、反复试验、几条吵闹的 console.log 语句,以及一点运气。

Ben Holmes, Introducing Content Collections: Type-Safe Markdown in Astro 2.0

在这一背景下,Astro 在 2.0 版本中引入了内容集合(Content Collections),通过 Zod 的模式定义和类型校验功能,实现了 frontmatter 的类型安全,并且能提供精确的错误定位。此外,Astro 还在 VSCode 插件中内置了对 frontmatter 的 Intellisense(智能提示)[2]支持。

NOTE

Astror 5.0 更新了更为灵活的 Content Layer 机制,允许创建不同内容来源的 Content Collection,甚至是非 Markdown 类型的内容。

因此,为了防止噪声,在后面的内容中,我们仍会使用 Legacy Content Collection API 作为示例。

src/content/posts.ts
const post = defineCollection({
schema: z.object({
title: z.string(),
date: z.string().transform((val) => new Date(val))
description: z.string().max(160, 'Short descriptions have better SEO!')
}),
})
src/[...slug].astro
---
import { getCollection } from 'astro:content'
export async function getStaticPaths() {
const posts = await getCollection('blog')
return posts.map((post) => ({
params: { slug: post.slug },
props: post,
}))
}
const post = Astro.props
const { Content, headings } = await post.render()
---
<h1>{frontmatter.title} by {frontmatter.author}</h1>
<Content />
<p>Written on: {frontmatter.date}</p>

以上代码在 getStaticPaths() 中使用 const posts = await getCollection('post') 获取所有集合中的内容,并基于这些数据动态生成路由。在具体的路由页面中,则可通过 post.data 访问类型安全的 frontmatter。

问题并未完全解决,回到我们的主题:Remark 插件开发,什么是 Remark 插件呢?

Astro 使用 Remark 和 Rehype[3]来解析和渲染 Markdown,同时允许开发者自定义 Remark 插件介入这一流程,从而实现对 AST(抽象语法树)和 frontmatter[4] 的修改。然而,这引发了一个新问题:即修改后的 frontmatter 其类型是无法被确定的。这其实是可以理解的:首先,在 Remark 插件的处理过程中,并不存在内容集合的上下文;其次,Remark 插件可能会对 frontmatter 进行任意的修改或扩展,我们也不应对此加以限制。

因此,为保证类型安全,当使用内容集合时,Astro 会首先将 frontmatter 经过 Zod 解析后放到 post.data 中,并在渲染过程(这个过程中 Remark 插件才会被运行)暴露一个单独的 remarkPluginfrontmatter。理所当然地,它的类型定义为 Record<string, any>,这也意味着类型不安全的问题再次回到了组件层面。

解决方案

为解决「Remark 插件」与「Astro 组件」中修改 frontmatter 类型不安全和不一致问题,这里我们提出一个思路:

在一个中心化的地方定义扩展的 frontmatter 的类型,然后在需要的地方引入,进行类型断言。

通过这种方式,可以在组件中获得类型安全的 frontmatter,同时 Remark 插件也能保持灵活性。

当然,直接类型断言未免有些粗犷,我们可以通过一些类型推断的小技巧,使得这个过程更加优雅,具体如下:

定义中心化类型映射

以开发一个添加「阅读时间」的 Remark 插件为例:

首先,我们定义一个类型映射,存储对于某个 content 所需要扩展的 frontmatter 的类型:

src/utils/types.ts
interface FrontmatterMap {
posts: {
minutesRead: number
}
}
export type RemarkFrontmatter<T extends keyof FrontmatterMap> = FrontmatterMap[T]

正如前面提到的,Remark 插件本身并不包含内容集合的上下文。我们大可为每个集合添加一个统一的属性来加以区分——或许,还有更优雅的解决方案。考虑到大多数博客通常只有单一的 Content,这里我们选择简化模型。

RemarkPlugin

Remark 提供了插件的类型标注,一个Remark 插件的定义如下(简化后):

type RemarkPlugin = (options?: Options) =>
/* Transformer */(tree: Root, file: VFile) => void

即一个返回 Transformer 的函数,Transformer 接收 tree (AST,包括解析后的 Markdown 节点)和 file(VFile,可以简单理解为对输入文件的抽象)两个参数,进行处理。

我们可以直接从 @astrojs/markdown-remark 中拿到这个类型:

import type { RemarkPlugin } from '@astrojs/markdown-remark'

Astro 也对 VFile 进行了类型扩展:

declare module 'vfile' {
interface DataMap {
astro: {
headings?: MarkdownHeading[];
imagePaths?: string[];
frontmatter?: Record<string, any>; // 实际上在 Remark 插件运行时,我们只能得到它
};
}
}

我们的目的就是给这个 DataMap 添加一个泛型,但很可惜的是,对于 declare module 的类型扩展,我们无法直接添加泛型。因此我们选择自己定义一个类型。

理想上,我们需要得到一个这样的东西,从而可以传入我们自定义的 frontmatter 类型作为参数:

type AstroVFile<T extends Record<string, any>> = VFile & {
data: {
astro: {
frontmatter: T
}
}
}
export type RemarkPlugin<T extends Record<string, any> = Record<string, never>> =
(options?: Options) => (tree: Tree, file: AstroVFile<T>) => void

VFileTree 这些类型并没有被导出,所以我决定通过类型推导提取,免得再装些依赖。(也增加一些趣味

定义如下文件:

plugins/extendPluginTypes.ts
29 collapsed lines
import type { RemarkPlugin as NativeRemarkPlugin } from '@astrojs/markdown-remark'
/**
* 从 NativeRemarkPlugin 中提取函数类型
*/
type RemarkPluginFn = Extract<NativeRemarkPlugin, (...args: any) => any>
/**
* 提取从 RemarkPluginFn 返回的非空函数类型
*/
type ResolvedPluginFn = Extract<NonNullable<ReturnType<RemarkPluginFn>>, (...args: any) => any>
/**
* 提取已解析的插件函数的参数和返回类型。
*/
type PluginParams = Parameters<ResolvedPluginFn>
type PluginReturn = ReturnType<ResolvedPluginFn>
/**
* 扩展 VFile 类型
*/
type AstroVFile<T extends Record<string, any>> = PluginParams[1] & {
data: {
astro: {
frontmatter: T
}
}
}
/**
* 扩展 Remark 插件类型
*/
export type RemarkPlugin<T extends Record<string, any> = Record<string, never>> = (
...args: Parameters<RemarkPluginFn>
) => (tree: PluginParams[0], file: AstroVFile<T>, next: PluginParams[2]) => PluginReturn

这样,我们就得到了一个可以自定义 frontmatter 类型的 Remark 插件类型。我们可以这样使用它:

plugins/remarkReadingTime.ts
import type { CollectionEntry } from 'astro:content'
import type { RemarkFrontMatter } from '@/utils/types'
import type { RemarkPlugin } from './extendPluginTypes'
// 与类型集合中的 frontmatter 类型进行合并
type PostFrontmatter = CollectionEntry<'posts'>['data'] & RemarkFrontMatter<'posts'>
// | 传入自定义的 frontmatter 类型
export const remarkReadingTime: RemarkPlugin<PostFrontmatter> = () => {
return (tree, { data: { astro } }) => {
const textOnPage = toString(tree) + (astro.frontmatter.description ?? '')
const readingTime = getReadingTime(textOnPage)
// 添加阅读时间
astro.frontmatter.minutesRead = Math.floor(readingTime.minutes)
}
}

remarkPluginfrontmatter

在组件渲染过程中,将 remarkPluginfrontmatter 进行类型断言后与 post.data 合并:

src/[...slug].astro
---
// ...
import type { Remarkfrontmatter } from '@/utils/types'
// ...
const post = Astro.props
const { Content, remarkPluginfrontmatter, headings } = await render(post)
const frontmatter = {
...(remarkPluginfrontmatter as Remarkfrontmatter<'posts'>),
...post.data
}
---
<h1>{frontmatter.title}</h1>
<p>{frontmatter.description}</p>
<p>{frontmatter.minutesRead}</p> <!-- 此处我们可以得到类型提示 -->
<Content />

这样我们就得到了一个类型安全的 frontmatter

更细化一些,我们可以对 render 函数封装后扩展类型,此处不再赘述。

扩展技巧

按需加载资源

除了在 Remark 插件中添加自定义信息外,还可以根据文章内容或 frontmatter 动态调整元数据,以实现更精细的资源管理。

如果你曾使用过 Hexo,会发现很多扩展 Markdown 的插件都提供了一个选项,让你在 frontmatter 中指定是否加载某个插件的资源,从而避免不必要的资源浪费。在这里,这一过程可以自动化完成了。

例如,一个常见需求是:仅在文章中使用了数学公式时,才加载 KaTeX\KaTeX 或 MathJax 等依赖。为此,我们需要在组件中检测文章中是否包含数学公式,并据此设置一个标志。我们可以设计一个自定义的 frontmatter 属性 math,并在 Remark 插件中进行处理,如下所示:

plugins/remarkMath.ts
import { CONTINUE, EXIT, visit } from 'unist-util-visit'
/**
* 此插件用于检测文章是否包含数学内容。
* 如果是,则在文章的 frontmatter 中设置 `math` 为 `true`。
*/
export const remarkPostMath: RemarkPlugin<PostFrontmatter> = () => (tree, { data: { astro } }) => {
visit(tree, ['math', 'inlineMath'], (_) => {
astro.frontmatter.math = true
return EXIT
})
}

在上述代码中,我们遍历 Markdown 语法树,当遇到数学公式时,立即在 astro.frontmatter 中添加 math: true 属性,并终止后续遍历。这样,我们的组件就可以依据该属性,按需加载相应的库,从而优化资源加载效率。

为了提高灵活性,你还可以将 math 属性添加到内容集合的类型定义中,并优先使用内容集合中的定义。

总结回顾

以上是我对如何构建类型安全的 frontmatter 处理插件的一些思考,希望可以为你提供一些帮助。

当然,以上方法毕竟是一种折中的方案,想要完全解决,可能需对 Astro 的内部机制进行一定的调整,幻想一下:

  • Content Loader API 加载数据的过程中,应该注入 Content Collection 的相关信息到 metadata
  • 当 md 文件渲染调用 @astrojs/markdown-remark 时,获取到 metadata,并传递到 Remark 插件中。
  • 直接暴露 RemarkPlugin 类型,提供针对 Content 或通用的 Remark 插件构造函数。

如果你有更好的想法,欢迎在评论区(好的,目前还不存在)分享。

附录

[1] 从 @astrojs/markdown-remark@6.1.0 开始,也支持 TOML,使用 +++ 包裹。

[2] Astro 4.14.0 引入,目前依旧是实验性功能,需要手工开启。

[3] 具体来说,Remark 和 Rehype 都构建于 unified 之上,这是一个通用 AST 处理框架。Remark 负责将 Markdown 解析为 mdast(Markdown AST),随后通过 remark-rehype 转换为 hast(HTML AST),最终由 Rehype 渲染为 HTML。在此过程中,Remark 插件可以用于修改 mdast,而 Rehype 插件则可以对 hast 进行调整,实现更灵活的 Markdown 处理与扩展。

[4] 具体来说,在处理 md 或 mdx 文件时,Astro 会使用内部的 vite-plugin-markdownvite-plugin-mdx 进行文件处理,并利用其内置的 frontmatter 解析器解析 frontmatter,然后将解析结果传递给 @astrojs/markdown-remark 中的渲染方法。在这个过程中,插件创建了一个 VFile 对象,并将 frontmatter 存储在 vfile.data.astro 属性中。因此,在 Remark 插件中,我们可以从 vfile.data.astro 中获取相应的 frontmatter 信息。