TinaCMS MDX 序列化管线解析:从 imageCallback 到测试夹具验证(markdown-basic-image-json-as-top-level 深度解读)
2026/9/15 16:38:32 网站建设 项目流程

TinaCMS MDX 序列化管线解析:从 imageCallback 到测试夹具验证(markdown-basic-image-json-as-top-level 深度解读)

【免费下载链接】tinacmsTinaCMS is the leading open-source headless CMS that supports Markdown and Visual Editing. Your content is stored in your own GitHub repo 🦙 ❤️项目地址: https://gitcode.com/GitHub_Trending/ti/tinacms

TinaCMS 的 MDX 解析器采用「Markdown 文本 → 结构化 JSON AST(Plate/Slate 风格)→ Markdown 文本」的双向转换架构,本篇文章围绕测试夹具 out.md 展开,深入讲解序列化(stringify)阶段imageCallback的作用机制、调用链路与测试验证方式。读完本文,你将掌握 TinaCMS MDX 中图像 URL 的变换与持久化原理,理解这类夹具在回归测试中的定位,并能在自己的 rich-text 字段上正确接入图像回调。

一、测试夹具是什么:out.md 的三行内容与它的任务

该目录下的 out.md 是 TinaCMS MDX 包中一个测试夹具的「期望输出文件」,全文仅三行:

Image callback should persist ![](/my-pic.jpg)

它由两个关键部分组成:

  1. 首行文本Image callback should persist——这是测试用例的自述,直接点明本夹具要验证的核心行为:图像回调(image callback)在序列化后应当被持久化保留,即回调对图像 URL 的变换结果必须真实写入最终的 Markdown 输出,而不是被丢弃或忽略。
  2. Markdown 图片语法![](/my-pic.jpg)——这是经过序列化后期望得到的最终 Markdown 图片节点,其中/my-pic.jpg回调处理之后的 URL

也就是说,这份out.md不是给人阅读的手册,而是 vitest 测试中断言匹配的"黄金文件"(golden file)。它属于@tinacms/mdx包在next/目录下的 MDX 转换实现测试体系,与 index.test.ts、node.json、field.ts 共同组成一个完整的测试用例。

二、输入侧拆解:node.json 中的图像 AST 节点

序列化(stringify)的输入是一棵结构化的 JSON AST。本夹具的输入 node.json 如下:

{ "type": "root", "children": [ { "type": "p", "children": [ { "type": "text", "text": "Image callback should persist" } ] }, { "type": "img", "url": "http://some-url/my-pic.jpg", "caption": null, "children": [ { "type": "text", "text": "" } ] }, { "type": "p", "children": [ { "type": "text", "text": "" } ] } ] }

几个值得注意的结构事实:

  • 顶层类型为root,子节点包含段落(p)、图像(img)与一个内容为空的段落。这正是该用例命名为markdown-basic-image-json-as-top-level的原因——输入直接是"顶层 JSON",而非先经过 parse 再 stringify 的完整链路,从而可以单独、隔离地验证序列化分支
  • 图像节点是块级元素imgp平级,位于root.children中,其 URL 为http://some-url/my-pic.jpg,并带有caption: null。这与 TinaCMS 富文本编辑器(基于 Plate/Slate)对图像的块级处理方式一致。
  • 尾部存在一个空段落:它的 children 只有一个texttext为空字符串。这个节点在输出中没有出现,因为序列化器会主动忽略"空段落"(详见下文第三节)。

字段定义 field.ts 声明了序列化所用的富文本字段:

import { RichTextField } from '@tinacms/schema-tools'; export const field: RichTextField = { name: 'body', type: 'rich-text', };

字段名为body、类型为rich-text,它是 TinaCMS 集合(collection)schema 中富文本字段的典型写法,也是stringifyMDX在遍历 AST 时用于判断上下文(例如段落、列表、表格内嵌结构)的输入参数。

三、测试逻辑:stringifyImageCallback 如何把 URL 还原成期望值

测试用例 index.test.ts 的完整逻辑:

import { expect, it } from 'vitest'; import { stringifyMDX } from '../../stringify'; import * as util from '../util'; import { field } from './field'; import node from './node.json'; it('matches input', () => { const stringifyImageCallback = (v: string) => v.replace('http://some-url', ''); // @ts-ignore const string = stringifyMDX(node, field, stringifyImageCallback); expect(string).toMatchFile(util.mdPath(__dirname)); });

关键点逐一解读:

  1. 回调的定义stringifyImageCallback = (v: string) => v.replace('http://some-url', ''),接收一个 URL 字符串,返回去掉http://some-url前缀后的新字符串。也就是说,输入 AST 中的http://some-url/my-pic.jpg经回调处理后应变为/my-pic.jpg
  2. 调用方式stringifyMDX(node, field, stringifyImageCallback)接受三个参数——AST(node.json解析出的 JSON)、字段定义(field)、图像回调(第三个参数)。回调以第三个参数传入,而非散落在 AST 各处。
  3. 断言方式expect(string).toMatchFile(util.mdPath(__dirname))是 vitest 的快照式断言,将序列化结果与同目录下的 out.md 逐字比对。测试通过即证明:回调对图像 URL 的变换结果被完整写入了最终 Markdown,回调行为"持久化"成功。

对比同目录下的兄弟用例可以看到完整回环。例如 markdown-basic-image-callback/index.test.ts 同时定义了parseImageCallbackstringifyImageCallback

const parseImageCallback = (v: string) => `http://some-url${v}`; const stringifyImageCallback = (v: string) => v.replace('http://some-url', ''); const tree = parseMDX(input, field, parseImageCallback); const string = stringifyMDX(tree, field, stringifyImageCallback);

这展示了完整的双向契约:解析(parse)时回调给相对路径补上http://some-url前缀存入 AST,序列化(stringify)时回调再把前缀去掉还原为 Markdown 中的相对路径。而本用例(json-as-top-level跳过了 parse 步骤,直接以 JSON 为输入,专门验证 stringify 一侧的行为,因此是更聚焦的单元级回归测试。

四、源码级原理:imageCallback 在 stringify 管线中的传递链路

序列化入口定义在 stringify/index.ts:

export const stringifyMDX = ( value: Plate.RootElement, field: RichTextField, imageCallback: (url: string) => string ) => { if (!value) { return; } const mdTree = normalizeMarkWhitespace( preProcess(value, field, imageCallback) ); return toTinaMarkdown(mdTree, field); };

整个管线分三步:

  1. preProcess(value, field, imageCallback):把 Plate/Slate 风格的输入 AST 转换为 mdast(Markdown Abstract Syntax Tree)风格的中间树。imageCallback在这一步被逐层传入(rootElementblockElementeat),最终在图像节点处被实际调用。
  2. normalizeMarkWhitespace(...):对中间树做空白归一化,保证输出的 Markdown 空白格式稳定(该实现位于 stringify/mark-whitespace 相关模块)。
  3. toTinaMarkdown(mdTree, field):将 mdast 中间树最终渲染为 Markdown 字符串,实现在 to-markdown.ts。

4.1 图像节点的核心处理分支

回调真正生效的位置在 pre-processing.ts 的blockElement函数中,针对img类型的 case:

case 'img': // Slate editor treats `img` as a block-level element, wrap // it in an empty paragraph return { type: 'paragraph', children: [ { type: 'image', url: imageCallback(content.url), alt: content.alt, title: content.caption, }, ], };

这里可以观察到三个实现事实:

  • URL 变换点url: imageCallback(content.url)——AST 中图像节点的url字段被传入回调,回调返回值作为 mdast 图片节点的新url。本用例中输入http://some-url/my-pic.jpg,经replace('http://some-url', '')后得到/my-pic.jpg,与out.md一致。
  • 块级包装:注释明确说明 "Slate editor treatsimgas a block-level element, wrap it in an empty paragraph"——由于编辑器把img视为块级元素,序列化时会被包进一个paragraph,最终产出 Markdown 的图片语法行。
  • alt 与 caption 的映射alt直接透传,而caption被映射为 mdast 图片节点的title字段。node.jsoncaption: null,因此最终输出的 Markdown 图片![](/my-pic.jpg)没有 alt 与 title。

4.2 空段落如何被丢弃

node.json末尾的空段落p → [text ""]未出现在out.md中,其依据同样在blockElementcase 'p'分支:

case 'p': // Ignore empty blocks if (content.children.length === 1) { const onlyChild = content.children[0]; if ( onlyChild && // Slate text nodes don't get a `type` property for text nodes (onlyChild.type === 'text' || !onlyChild.type) && (onlyChild as { text: string }).text === '' ) { return null; } } return { type: 'paragraph', children: eat(content.children, field, imageCallback), };

当段落只有一个子节点、且该子节点是空文本时,blockElement返回nullrootElement中的if (value)判断会将其过滤掉。这正是输出中看不到尾部空行的原因,也体现了序列化器对"编辑器残留空块"的容错处理。

五、从夹具到实践:如何在自己的 rich-text 字段中使用 imageCallback

综合以上分析,imageCallback是 TinaCMS MDX 序列化 API 的强制第三参数,签名为(url: string) => string。在实际项目中,你可以这样理解与使用:

  • 回调的定位:它负责在 AST 与 Markdown 文本之间转换图像 URL 的形态。典型场景包括:去掉/补全域名前缀、统一路径规范(如相对路径转绝对路径)、针对不同媒体存储(如 Cloudinary、S3)做 URL 改写。
  • 必须保持可逆:从兄弟用例可见,TinaCMS 的 parse 与 stringify 两侧各自持有回调。若 parse 侧给 URL 加了前缀,stringify 侧通常要提供对称的还原逻辑,否则会出现「编辑一次后 URL 形态漂移」的问题。本用例json-as-top-level之所以被单独保留,正是为了锁定 stringify 侧行为不被破坏。
  • 字段定义不可省略field(如{ name: 'body', type: 'rich-text' })参与序列化过程中的上下文判断,因此在调用stringifyMDX时需传入与集合 schema 一致的字段定义。
  • 回归测试的范式:如果你在自己的项目中扩展了 MDX 序列化行为,可以仿照本夹具——准备一份最小node.json输入、一个字段定义、一段带断言的回调,再用toMatchFile锁定期望输出,防止后续改动悄悄改变序列化结果。

六、延伸阅读

  • 序列化入口与三步管线:stringify/index.ts
  • 块级元素转换与img/空段落分支:pre-processing.ts
  • mdast 中间树转 Markdown 文本:to-markdown.ts
  • 同主题兄弟用例(含 parse 回环):markdown-basic-image-callback/index.test.ts、markdown-basic-image-in-link/index.test.ts

总而言之,这份三行的out.md并非表面看起来的简单文本,而是 TinaCMS MDX 序列化管线中"图像回调持久化"这一关键行为的契约载体:它以 JSON 为输入、以回调为变换器、以黄金文件为断言,精确锁定了img块级节点从 AST 到 Markdown 的转换语义,是理解stringifyMDX内部机制最直接、最精简的入口。

【免费下载链接】tinacmsTinaCMS is the leading open-source headless CMS that supports Markdown and Visual Editing. Your content is stored in your own GitHub repo 🦙 ❤️项目地址: https://gitcode.com/GitHub_Trending/ti/tinacms

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询