Warning

本文档的这部分将假定您对TypeScript有实际了解,并将包含描述Quartz插件接口的代码片段。

Quartz的插件是对内容进行的一系列转换。下图展示了处理流程的示意图:

所有插件都被定义为一个函数,该函数接受一个选项参数type OptionType = object | undefined,并返回一个与其插件类型对应的对象。

type OptionType = object | undefined
type QuartzPlugin<Options extends OptionType = undefined> = (opts?: Options) => QuartzPluginInstance
type QuartzPluginInstance =
  | QuartzTransformerPluginInstance
  | QuartzFilterPluginInstance
  | QuartzEmitterPluginInstance

以下部分将详细介绍每种插件类型可以实现的方法。在此之前,我们先澄清几个较为模糊的类型:

  • BuildCtx 定义于 quartz/ctx.ts,包含:
    • argv:传递给 Quartz build 命令的命令行参数
    • cfg:完整的 Quartz 配置
    • allSlugs:所有有效内容 slug 的列表(关于 ServerSlug 的定义详见路径
  • StaticResources 定义于 quartz/resources.tsx,包含:
    • css:需要加载的 CSS 样式定义列表。CSS 样式由同样定义在 quartz/resources.tsx 中的 CSSResource 类型描述,可接受样式表源 URL 或内联内容。
    • js:需要加载的脚本列表。脚本由同样定义在 quartz/resources.tsx 中的 JSResource 类型描述,允许定义加载时机(DOM 加载前或加载后)、是否为模块类型,以及脚本源 URL 或内联内容。

转换器

转换器对内容进行映射处理,接收 Markdown 文件后输出修改后的内容,或为文件本身添加元数据。

export type QuartzTransformerPluginInstance = {
  name: string
  textTransform?: (ctx: BuildCtx, src: string | Buffer) => string | Buffer
  markdownPlugins?: (ctx: BuildCtx) => PluggableList
  htmlPlugins?: (ctx: BuildCtx) => PluggableList
  externalResources?: (ctx: BuildCtx) => Partial<StaticResources>
}

所有转换器插件必须至少定义一个 name 字段来注册插件,以及几个可选函数用于挂钩到单个 Markdown 文件转换的不同阶段:

  • textTransform 在文件被解析为 Markdown AST 之前 执行文本到文本的转换
  • markdownPlugins 定义 remark 插件列表。remark 是一个以结构化方式转换 Markdown 到 Markdown 的工具
  • htmlPlugins 定义 rehype 插件列表。与 remark 类似,rehype 是以结构化方式转换 HTML 到 HTML 的工具
  • externalResources 定义插件正常工作可能需要客户端加载的外部资源

通常情况下,对于 remarkrehype 都可以找到现有的插件直接使用。如果你想创建自己的 remarkrehype 插件,可以通过 unified(底层 AST 解析器和转换库)的创建插件指南进行学习。

Latex 插件是一个很好的转换器插件示例,它借鉴了 remarkrehype 生态系统:

quartz/plugins/transformers/latex.ts
import remarkMath from "remark-math"
import rehypeKatex from "rehype-katex"
import rehypeMathjax from "rehype-mathjax/svg"
import { QuartzTransformerPlugin } from "../types"
 
interface Options {
  renderEngine: "katex" | "mathjax"
}
 
export const Latex: QuartzTransformerPlugin<Options> = (opts?: Options) => {
  const engine = opts?.renderEngine ?? "katex"
  return {
    name: "Latex",
    markdownPlugins() {
      return [remarkMath]
    },
    htmlPlugins() {
      if (engine === "katex") {
        // if you need to pass options into a plugin, you
        // can use a tuple of [plugin, options]
        return [[rehypeKatex, { output: "html" }]]
      } else {
        return [rehypeMathjax]
      }
    },
    externalResources() {
      if (engine === "katex") {
        return {
          css: [
            {
              // base css
              content: "https://cdnjs.cloudflare.com/ajax/libs/KaTeX/0.16.9/katex.min.css",
            },
          ],
          js: [
            {
              // fix copy behaviour: https://github.com/KaTeX/KaTeX/blob/main/contrib/copy-tex/README.md
              src: "https://cdnjs.cloudflare.com/ajax/libs/KaTeX/0.16.9/contrib/copy-tex.min.js",
              loadTime: "afterDOMReady",
              contentType: "external",
            },
          ],
        }
      } else {
        return {}
      }
    },
  }
}

转换器插件的另一个常见功能是解析文件并为该文件添加额外数据:

export const AddWordCount: QuartzTransformerPlugin = () => {
  return {
    name: "AddWordCount",
    markdownPlugins() {
      return [
        () => {
          return (tree, file) => {
            // tree is an `mdast` root element
            // file is a `vfile`
            const text = file.value
            const words = text.split(" ").length
            file.data.wordcount = words
          }
        },
      ]
    },
  }
}
 
// tell typescript about our custom data fields we are adding
// other plugins will then also be aware of this data field
declare module "vfile" {
  interface DataMap {
    wordcount: number
  }
}

最后,你还可以使用 unist-util-visit 包中的 visit 函数或 mdast-util-find-and-replace 包中的 findAndReplace 函数对 Markdown 或 HTML 的抽象语法树(AST)进行转换操作。

export const TextTransforms: QuartzTransformerPlugin = () => {
  return {
    name: "TextTransforms",
    markdownPlugins() {
      return [() => {
        return (tree, file) => {
          // replace _text_ with the italics version
          findAndReplace(tree, /_(.+)_/, (_value: string, ...capture: string[]) => {
            // inner is the text inside of the () of the regex
            const [inner] = capture
            // return an mdast node
            // https://github.com/syntax-tree/mdast
            return {
              type: "emphasis",
              children: [{ type: 'text', value: inner }]
            }
          })
 
         // remove all links (replace with just the link content)
         // match by 'type' field on an mdast node
         // https://github.com/syntax-tree/mdast#link in this example
          visit(tree, "link", (link: Link) => {
            return {
              type: "paragraph"
              children: [{ type: 'text', value: link.title }]
            }
          })
        }
      }]
    }
  }
}

所有转换器插件均位于 quartz/plugins/transformers 目录下。如果您决定编写自己的转换器插件,请记得在 quartz/plugins/transformers/index.ts 中重新导出该插件。

最后提醒:转换器插件较为复杂,如果初次接触不必着急。建议参考内置转换器的实现方式,观察它们如何处理内容,这将帮助您更好地理解如何实现所需功能。

过滤器

过滤器的主要作用是筛选内容,它会接收所有转换器的输出结果,并决定实际保留哪些文件、舍弃哪些文件。

export type QuartzFilterPlugin<Options extends OptionType = undefined> = (
  opts?: Options,
) => QuartzFilterPluginInstance
 
export type QuartzFilterPluginInstance = {
  name: string
  shouldPublish(ctx: BuildCtx, content: ProcessedContent): boolean
}

过滤器插件必须定义一个 name字段和一个 shouldPublish 函数。该函数接收经过所有转换器处理后的内容片段,并根据是否应该将其传递给发射器插件返回 truefalse

例如,以下是用于移除草稿的内置插件:

const draftFilterPlugin = {
    name: "draft-filter",
    shouldPublish(content) {
        return !content.frontmatter.draft;
    },
};
quartz/plugins/filters/draft.ts
import { QuartzFilterPlugin } from "../types"
 
export const RemoveDrafts: QuartzFilterPlugin<{}> = () => ({
  name: "RemoveDrafts",
  shouldPublish(_ctx, [_tree, vfile]) {
    // uses frontmatter parsed from transformers
    const draftFlag: boolean = vfile.data?.frontmatter?.draft ?? false
    return !draftFlag
  },
})

发射器

发射器对内容进行归约处理,接收所有经过转换和过滤的内容列表,并生成输出文件。

export type QuartzEmitterPlugin<Options extends OptionType = undefined> = (
  opts?: Options,
) => QuartzEmitterPluginInstance
 
export type QuartzEmitterPluginInstance = {
  name: string
  emit(ctx: BuildCtx, content: ProcessedContent[], resources: StaticResources): Promise<FilePath[]>
  getQuartzComponents(ctx: BuildCtx): QuartzComponent[]
}

发射器插件必须定义以下内容:一个 name 字段、一个 emit 函数和一个 getQuartzComponents 函数。其中 emit 函数负责检查所有解析和过滤后的内容,并据此创建文件,最后返回该插件创建的文件路径列表。

创建新文件可以通过常规的 Node fs 模块(例如使用 fs.cpfs.writeFile)实现。若创建的是包含文本内容的文件,也可以通过 quartz/plugins/emitters/helpers.ts 中的 write 函数来实现。write 函数的签名如下:

export type WriteOptions = (data: {
  // the build context
  ctx: BuildCtx
  // the name of the file to emit (not including the file extension)
  slug: ServerSlug
  // the file extension
  ext: `.${string}` | ""
  // the file content to add
  content: string
}) => Promise<FilePath>

这是一个轻量级封装,主要用于写入正确的输出文件夹并确保中间目录存在。如果您选择使用原生的 Node fs API,请确保也将内容输出到 argv.output 文件夹。

如果您需要创建一个需要渲染组件的发射器插件,还需要注意以下三点:

  • 您的组件应使用 getQuartzComponents 来声明用于构建页面的 QuartzComponents 列表。更多信息请参阅创建组件页面。
  • 您可以使用 quartz/components/renderPage.tsx 中定义的 renderPage 函数将 Quartz 组件渲染为 HTML。
  • 如果需要将 HTML AST 渲染为 JSX,可以使用 quartz/util/jsx.ts 中的 htmlToJsx 函数。相关示例可在 quartz/components/pages/Content.tsx 中找到。

例如,以下是渲染每个页面的内容页插件的简化版本。

quartz/plugins/emitters/contentPage.tsx
export const ContentPage: QuartzEmitterPlugin = () => {
  // construct the layout
  const layout: FullPageLayout = {
    ...sharedPageComponents,
    ...defaultContentPageLayout,
    pageBody: Content(),
  }
  const { head, header, beforeBody, pageBody, afterBody, left, right, footer } = layout
  return {
    name: "ContentPage",
    getQuartzComponents() {
      return [head, ...header, ...beforeBody, pageBody, ...afterBody, ...left, ...right, footer]
    },
    async emit(ctx, content, resources, emit): Promise<FilePath[]> {
      const cfg = ctx.cfg.configuration
      const fps: FilePath[] = []
      const allFiles = content.map((c) => c[1].data)
      for (const [tree, file] of content) {
        const slug = canonicalizeServer(file.data.slug!)
        const externalResources = pageResources(slug, file.data, resources)
        const componentData: QuartzComponentProps = {
          fileData: file.data,
          externalResources,
          cfg,
          children: [],
          tree,
          allFiles,
        }
 
        const content = renderPage(cfg, slug, componentData, opts, externalResources)
        const fp = await emit({
          content,
          slug: file.data.slug!,
          ext: ".html",
        })
 
        fps.push(fp)
      }
      return fps
    },
  }
}

注意,它接受一个 FullPageLayout 作为选项。该布局由通过 quartz.layout.ts 文件提供的 SharedLayoutPageLayout 组合而成。

[!提示] 查看 quartz/plugins 目录以获取 Quartz 中更多插件示例,作为您自己开发插件时的参考!