/ 高级 / 多样化的插件 / 外部插件协议

本节详细说明外部预处理器、转换器程序需要遵循的标准输入、标准输出通信协议。无论用什么语言(TypeScript、Python、Go、JavaScript、PHP、Lua、Teal、Gleam、Haskell、Idris、Ocaml、Rust、C、Swift、Scala、Kotlin、Scheme、Koka 等)编写这些程序,只要遵循本节描述的协议,就可以被 MoPress 正确调用。

外部程序分为两类,分别在构建管线的不同阶段被调用:预处理器在 Markdown 文本被解析为语法树之前运行,操作对象是原始文本;转换器在 Markdown 文本被解析为语法树之后运行,操作对象是语法树。两者的通信协议结构一致,仅在 type 字段与 data 字段的内容上有区别。

每次调用外部程序时,MoPress 会以 JSON 结构写入该进程的标准输入:

type Request = {
  config: BookConfig;
  type: "markdown-text";
  data: string;
  target: string;
} |
{
  config: BookConfig;
  type: "markdown-ast";
  data: Markdown;
  target: string;
}

config 字段是当前站点的完整配置,对应 BookConfig,供程序按需读取。type 字段固定为两个取值之一:预处理器收到的是 markdown-text,转换器收到的是 markdown-astdata 字段是待处理的实际内容:预处理器收到的是纯文本字符串,转换器收到的是 Markdown 语法树的 JSON 表示。target 字段是当前 MarkDown 文件的相对路径(相对于 config.src)。

程序处理完成后,需要将结果写入标准输出,有两种合法的返回形式。

返回处理结果

interface Response {
  data: string | Markdown;
}

data 字段的类型与请求中的 data 字段保持一致。这个结果会成为下一个程序(如果配置了多个)的输入,或者成为该阶段的最终输出。

返回空输出:如果标准输出为空字符串,表示该程序判断当前内容不需要处理,数据将原样传递给下一个程序,不做任何修改。

如果处理过程中出现错误,程序应当以非零状态码退出,并且向标准错误写入错误信息。

MoPress 在解析结果时会依次检查:

  • 退出码:非零退出码会被视为该程序执行失败,无论标准错误中写了什么内容,构建会中止并报告这次失败,标准错误的内容会一并附带,用于辅助排查。

  • 标准错误内容:即使退出码为零,只要标准错误非空,也会被视为程序主动上报的业务错误,同样会中止构建。MoPress 会尝试将标准错误按上述结构解析;如果无法解析,会将标准错误的原始文本整体作为错误信息。

  • 标准输出内容:只有在退出码为零、且标准错误为空的前提下,才会去解析标准输出。空字符串意味着不处理、原样传递;非空字符串会被按上述结构解析,如果不是合法的 JSON,或者不符合预期结构,同样会被视为错误并中止构建。

预处理器、转换器都是以数组的形式配置多个程序,程序会按数组中声明的顺序依次执行,前一个程序的输出(或者,如果它选择不处理,则是它收到的原始输入)会作为下一个程序的输入。任意一个程序在链条中失败,都会导致整个链条中止,不再执行后续程序。

用 TypeScript 编写、通过 Bun 运行的预处理器,检查内容中是否包含某个占位符文本,如果没有则不做任何处理:

const req = JSON.parse(await Bun.stdin.text());

if (!req.data.includes("{{BUILD_TIME}}")) {
  process.stdout.write("");
} else {
  const data = req.data.replaceAll("{{BUILD_TIME}}", new Date().toISOString());
  process.stdout.write(JSON.stringify({ data }));
}

项目中提供的 github-alerts 插件就是基于 TypeScript 编写、通过 Bun 运行的预处理器:

# ...
[extensions]
# ...
preprocessors = ["bun plugins/preprocessers/github-alert"]
interface Request {
  type: 'markdown-text'
  data: string
}

const req: Request = JSON.parse(await Bun.stdin.text())

if (req?.type !== 'markdown-text') {
  process.stderr.write('Invalid data type. This is a preprocessor, not a transformer')
  process.exit(1)
}

const COLORS: Record<string, string> = {
  NOTE: '#0969da',
  TIP: '#1a7f37',
  IMPORTANT: '#8250df',
  WARNING: '#9a6700',
  CAUTION: '#cf222e'
}

const re = /^> \[!(NOTE|TIP|IMPORTANT|WARNING|CAUTION)\][ \t]*\r?\n((?:>.*(?:\r?\n|$))*)/gim

let matched = false
const data = req.data.replace(re, (_m: string, type: string, block: string) => {
  matched = true
  type = type.toUpperCase()
  const content = block
    .split(/\r?\n/)
    .map((l) => l.replace(/^>\s?/, ''))
    .join('\n')
    .trim()
  const color = COLORS[type]
  const title = type[0] + type.slice(1).toLowerCase()
  return `<div style="border-left:4px solid ${color};padding:8px 16px;margin:16px 0;background:${color}1a">
<p style="font-weight:600;color:${color};margin:0 0 4px">${title}</p>
<p style="margin:0;white-space:pre-wrap">${content}</p>
</div>\n`
})

if (!matched) {
  process.stdout.write('')
} else {
  process.stdout.write(JSON.stringify({ data }))
}

export {}

项目中提供的 link-checker 插件会检查 MarkDown 文件中的所有相对路径链接是否有效:

[extensions]
transformers = ["bun plugins/transformers/link-checker"]
import { existsSync } from 'node:fs'
import { dirname, isAbsolute, resolve } from 'node:path'

const req = JSON.parse(await Bun.stdin.text())
const sourcePath = resolve(req.config.src, req.target)

const errors: [string, string][] = []

function walkInline(nodes: any[]): void {
  for (const node of nodes) {
    const [tag, ...rest] = node
    switch (tag) {
      case 'Link': {
        const [label, url] = rest
        if (!/^[a-z][a-z0-9+.-]*:/i.test(url) && !url.startsWith('//') && !url.startsWith('#') && !isAbsolute(url)) {
          const [pathPart] = url.split('#')
          if (pathPart === '') continue
          const resolved = resolve(dirname(sourcePath), pathPart)
          if (!existsSync(resolved)) {
            errors.push([url, resolved])
            continue
          }
        }
        walkInline(label)
        break
      }
      case 'Emphasis':
      case 'Strong':
        walkInline(rest[0])
        break
    }
  }
}

function walkBlocks(blocks: any[]): void {
  for (const block of blocks) {
    if (typeof block === 'string') continue // ThematicBreak
    const [tag, ...rest] = block
    switch (tag) {
      case 'Paragraph':
        walkInline(rest[0])
        break
      case 'Heading':
        walkInline(rest[1])
        break
      case 'BlockQuote':
        walkBlocks(rest[0])
        break
      case 'List':
        for (const item of rest[0].items) walkBlocks(item)
        break
      case 'Table': {
        const { headers, rows } = rest[0]
        for (const row of [headers, ...rows]) for (const cell of row) walkInline(cell)
        break
      }
    }
  }
}

walkBlocks(req.data)
if (errors.length > 0) {
  process.stderr.write(
    errors
      .map(
        ([url, resolved]) => `Broken link: the target of link "${url}" does not exist: ${resolved}, at ${sourcePath}`
      )
      .join('\n')
  )
  process.exit(1)
}
process.stdout.write('')