Astro 对 TypeScript 提供了优秀的支持,但仍存在一些不足之处。例如,对于 frontmatter 而言,虽然提供了内容集合(Content Collection)提供了一定的类型安全,但在使用 Remark 插件修改 frontmatter 时,类型信息仍会丢失。为此,本文提出一种思路:单独定义扩展的 frontmatter 类型,通过类型推导扩展 Remark 插件类型,在组件中使用类型断言,实现了前后类型一致。
阅读本文章你需要了解:
问题背景
在使用基于 Markdown 的内容生成工具时,我们经常会遇到这样一个熟悉的结构:
---title: Foodescription: Bar---
# Bar
Hello, world!
在文件开头,被 ---
包裹的内容显然不属于标准的 Markdown 语法。这部分内容就是我们熟悉的 frontmatter,它用于定义 Markdown 文档的元数据,通常采用 YAML 格式[1]。理论上,frontmatter 内部可以包含任何内容,然后被解析为一个对象,供后续使用。
Astro 提供了优秀的 TypeScript 开发体验,提供了丰富的工具类型。由于 frontmatter 解析后的类型不确定性,在作为 Markdown Layout 的 Astro 组件中,我们只能获得一个 any
对象,这大大影响了开发体验。早期 Astro 提供的解决方案是使用工具类型 MarkdownLayoutProps
标注 frontmatter 的类型。
对于我们的 Post Layout,可以通过如下方法定义 frontmatter 的类型(来自官方示例):
---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 作为示例。
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!') }),})
---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.propsconst { 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 的类型:
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
但 VFile
、Tree
这些类型并没有被导出,所以我决定通过类型推导提取,免得再装些依赖。(也增加一些趣味)
定义如下文件:
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 插件类型。我们可以这样使用它:
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
合并:
---// ...import type { Remarkfrontmatter } from '@/utils/types'// ...const post = Astro.propsconst { 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 中指定是否加载某个插件的资源,从而避免不必要的资源浪费。在这里,这一过程可以自动化完成了。
例如,一个常见需求是:仅在文章中使用了数学公式时,才加载 或 MathJax 等依赖。为此,我们需要在组件中检测文章中是否包含数学公式,并据此设置一个标志。我们可以设计一个自定义的 frontmatter 属性 math,并在 Remark 插件中进行处理,如下所示:
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-markdown
或 vite-plugin-mdx
进行文件处理,并利用其内置的 frontmatter 解析器解析 frontmatter,然后将解析结果传递给 @astrojs/markdown-remark
中的渲染方法。在这个过程中,插件创建了一个 VFile 对象,并将 frontmatter 存储在 vfile.data.astro
属性中。因此,在 Remark 插件中,我们可以从 vfile.data.astro
中获取相应的 frontmatter 信息。