TinaCMS 富文本 Shortcode 嵌套与 Rich-Text Children:从 match 模板定义到 Markdown 无损往返
2026/9/15 18:01:27 网站建设 项目流程

TinaCMS 富文本 Shortcode 嵌套与 Rich-Text Children:从 match 模板定义到 Markdown 无损往返

【免费下载链接】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 的@tinacms/mdx包在 Markdown 解析层引入了"短代码(shortcode)"支持,让内容作者可以用{{% feature-panel "Test" %}}这类 Hugo 风格语法,在富文本正文中嵌入结构化的可编辑块。本文以仓库中packages/@tinacms/mdx/src/next/tests/markdown-shortcodes-rich-text-children-3测试目录为解剖样本,完整拆解 shortcode 模板的字段契约、带children的富文本嵌套解析、序列化还原链路,以及快照测试如何保证"解析 → 序列化"的无损往返——读完你既能照抄配置写出可运行的模板,也能理解底层实现原理。

一、本文主角:一个测试夹具 out.md

关联文档 out.md 全文只有 7 行,但它不是一篇说明文档,而是一份快照测试的期望输出

Testing again {{% feature-panel "Test" %}} {{% pull-quote foo="Testing" %}} Things {{% /pull-quote %}}

它属于@tinacms/mdx包中src/next新一代 Markdown 解析器(注释表明该实现引入自 commit 651b6b53b "Add next module for mdx behavior")的测试体系。同一目录下还有四个配套文件,共同组成一个完整的往返测试用例:

文件作用
in.md测试输入,与 out.md 内容逐字节一致
out.md快照期望输出
node.json解析后的结构化节点树快照
field.ts定义该富文本字段与 shortcode 模板的 schema
index.test.tsvitest 测试入口

in.mdout.md完全一致,本身就是一个重要结论:这段包含嵌套 shortcode 的 Markdown,经过解析再序列化后可以原样还原。而 out.md 中隐藏的技术要点有三层:

  1. {{% feature-panel "Test" %}}是一个块级(flow)shortcode,带一个无键值参数"Test"
  2. {{% pull-quote foo="Testing" %}} ... {{% /pull-quote %}}带富文本子节点(rich-text children)的嵌套 shortcode,子内容Things会被解析成一个独立的嵌套富文本树;
  3. 两个相邻的块级 shortcode 之间保留空行,说明块级元素序列化时使用了容器语义(containerFlow)。

二、模板即语法契约:field.ts 中的 match 配置

shortcode 并不是解析器硬编码识别的,而是由富文本字段上的templates通过match属性声明的。看 field.ts:

import { RichTextField } from '@tinacms/schema-tools'; export const field: RichTextField = { name: 'body', type: 'rich-text', parser: { type: 'markdown' }, templates: [ { name: 'featurePanel', label: 'Feature Panel', match: { start: '{{%', end: '%}}', name: 'feature-panel', }, fields: [ { name: '_value', required: true, isTitle: true, label: 'Value', type: 'string', }, ], }, { name: 'pullQuote', label: 'Pull Quote', match: { start: '{{%', name: 'pull-quote', end: '%}}', }, fields: [ { name: 'foo', label: 'foo label', type: 'string' }, { name: 'children', label: 'Children', type: 'rich-text', }, ], }, ], };

逐个拆解这里的契约:

  • match.start/match.end:定义 shortcode 的左右定界符,这里使用 Hugo 风格的{{%%}}@tinacms/mdx的测试套件还覆盖了{{</>}}(见 markdown-shortcodes-rich-text-children/in.md)、WordPress 风格、Markdoc 风格等其他定界符组合(见下文第七节),说明定界符是完全可配置的。
  • match.name:shortcode 在 Markdown 文本中的语法名。注意它在序列化时使用({{% feature-panel %}}),而模板的namefeaturePanel)是内部节点名。
  • _value字段:这是 TinaCMS 的一个约定——当 shortcode 携带无键值参数(如"Test")时,解析器会自动把它映射到名为_value的字段上。在 mdast 处理实现中,exitMdxJsxTagAttributeValueLiteral明确做了if (attribute.name === '') { attribute.name = '_value'; }的归一化处理。
  • children字段(rich-text 类型):模板字段列表中一旦出现名为children的富文本字段,该 shortcode 就不是叶子节点,其内部内容会被解析为嵌套的富文本子树。这正是 util.ts 中计算leaf标志的依据:leaf: !template.fields.some((f) => f.name === 'children')——没有 children 字段的模板是叶子,序列化时采用自闭合写法;有 children 的模板则需要成对的开闭标签。

三、解析链路:从 Markdown 文本到结构化节点树

先看 index.test.ts 如何驱动这条链路:

import { parseMDX } from '../../parse'; import { stringifyMDX } from '../../stringify'; import * as util from '../util'; import { field } from './field'; import input from './in.md?raw'; it('matches input', () => { const tree = parseMDX(input, field, (v) => v); const string = stringifyMDX(tree, field, (v) => v); expect(util.print(tree)).toMatchFile(util.nodePath(__dirname)); expect(string).toMatchFile(util.mdPath(__dirname)); });

解析入口是 parse/index.ts 中的parseMDX(value, field, imageCallback),内部流程为:

fromMarkdown(value, field) // 基于 micromark / mdast 的解析 → compact(tree) // 压缩相邻同类节点 → postProcessor(tree, field, imageCallback) → remarkToSlate(...) // 转换为 Tina 内部富文本表示

3.1 模式如何变成解析规则

fromMarkdown在 markdown.ts 中把mdxJsx扩展接入 micromark。扩展构造逻辑在 shortcodes/lib/syntax.ts:

  • 将每个模板的match归一化为Pattern结构:{ start, end, name, templateName, type: 'inline' | 'flow', leaf }
  • pattern.start首字符建立索引:flowRules[firstCharacter]textRules[firstCharacter],分别挂载jsxFlow/jsxText构造器,因此多个 shortcode 共用同一前缀时会被追加到同一规则数组;
  • 若开启skipHTML,会禁用htmlFlow/htmlTexttoken,避免原生 HTML 解析与 shortcode 语法冲突。

3.2 标签解析与 _value 映射

真正的标签级解析在 shortcodes/mdast/index.ts 的mdxJsxFromMarkdown中完成。与本文案例直接相关的关键点:

  • 开标签进入enterMdxJsxTag用栈结构跟踪标签,读到属性时按mdxJsxAttribute压入attributes数组;
  • 无键值属性归一化exitMdxJsxTagAttributeValueLiteral中,无名属性被改写为_value(见上文第二节的源码引用),并把字面量经parseEntities解析为字符串;
  • 闭标签校验exitMdxJsxTag中若tag.close && tail.name !== tag.name,会抛出end-tag-mismatchVFileMessage,即标签名不匹配是硬错误
  • 节点命名:找到匹配的 pattern 后,节点名取pattern.templateName || tag.name(mdast/index.ts),所以 node.json 中显示的是featurePanel/pullQuote这样的模板名;
  • 容错降级shouldFallback机制会把无法配对的开闭标记还原为普通文本节点,而不是直接报错中断(这是markdown-shortcodes-invalid-*系列用例的行为基础)。

3.3 嵌套富文本子节点如何收敛为树

解析出mdxJsxFlowElement后,真正的"富文本子节点"魔法发生在 parse/post-processing.ts:

if (node.children.length) { let tree; if (node.type === 'mdxJsxTextElement') { tree = postProcessor( { type: 'root', children: [{ type: 'paragraph', children: node.children }] }, field, imageCallback ); } else { tree = postProcessor( { type: 'root', children: node.children }, field, imageCallback ); } props.children = tree; } node.props = props; delete node.attributes; node.children = [{ type: 'text', text: '' }];

要点是:对 shortcode 内部的子节点,以root为根递归调用postProcessor再做一遍完整后处理,得到的整棵子树被放进props.children(即children字段的值),而节点自身的children被重置为一个空文本节点。这样就形成了"shortcode 属性里套一棵富文本文档树"的嵌套结构——正是 node.json 中所呈现的样子:

{ "type": "root", "children": [ { "type": "p", "children": [{ "type": "text", "text": "Testing again" }] }, { "type": "mdxJsxFlowElement", "name": "featurePanel", "children": [{ "type": "text", "text": "" }], "props": { "_value": "Test" } }, { "type": "mdxJsxFlowElement", "name": "pullQuote", "children": [{ "type": "text", "text": "" }], "props": { "foo": "Testing", "children": { "type": "root", "children": [ { "type": "p", "children": [{ "type": "text", "text": "Things" }] } ] } } } ] }

可以看到:"Test"进入props._valuefoo="Testing"进入props.foo,而Things则完整地变成了props.children下的一棵root → p → text子树。此外,util.ts 的hoistAllTemplates会把字段树中所有嵌套 rich-text 字段上的模板递归打平,统一参与顶层解析——也就是说,即使children子树里又声明了自己的模板,也能被同一套模式表识别。

四、序列化链路:从节点树还原 shortcode 语法

序列化入口是 stringify/index.ts 的stringifyMDX

preProcess(value, field, imageCallback) → normalizeMarkWhitespace(...) → toTinaMarkdown(mdTree, field)

核心的 Markdown 输出逻辑在 stringify/to-markdown.ts:

  • 通过getFieldPatterns(field)重新收集模式表,传给mdxJsxToMarkdown({ patterns })扩展;
  • 转义策略与match的关系:源码注释明确说明,一旦模板声明了match,就假定用户需要默认转义(保证{{<不会被转义成{{\<这类形式);而parser.skipEscaping提供了'all'(完全不转义)与'html'(放行<)两个可选档位,供那些由其他工具负责解析 Markdown 的场景使用。

shortcode 的具体还原逻辑同样在 shortcodes/mdast/index.ts 的mdxElement处理器中:

  • 开标签输出为pattern.start + ' ' + patternName,即{{% feature-panel
  • _value字段还原为裸值:序列化属性时if (left === '_value') { result = right; },因此_value: "Test"输出为"Test"而不是_value="Test"(mdast/index.ts);
  • 普通键值属性输出为key="value"(默认双引号,支持quoteSmart智能切换引号);
  • 属性较多或超出行宽时支持按行缩进换行(attributesOnTheirOwnLine);
  • 子节点输出:对mdxJsxFlowElement,先输出开标签与pattern.end,再以containerFlow输出子内容(Things),前后各补一个换行;对行内元素则用containerPhrasing
  • 闭标签输出pattern.start + ' /' + patternName + ' ' + pattern.end,即{{% /pull-quote %}}(mdast/index.ts);
  • 叶子模板(无 children)在自闭合后不再输出闭标签。

正是这些规则,保证了 out.md 中{{% feature-panel "Test" %}}{{% pull-quote foo="Testing" %}} Things {{% /pull-quote %}}的精确还原。

五、无损往返如何被快照测试锁定

往返测试的根基是 tests/util.ts 提供的快照机制:

  • print(tree)先把树中的position字段递归剔除,再输出格式化 JSON,从而让快照与源码位置信息解耦;
  • nodePath/mdPath分别指向用例目录下的node.jsonout.md
  • 通过expect.extend({ toMatchFile })(基于jest-file-snapshot)将实际结果与快照文件逐字节比对。

这意味着out.md不仅是文档,更是一份可回归验证的契约:只要未来修改了解析器或序列化器,导致{{% ... %}}的任一输出细节(定界符、属性引号、空行、闭标签格式)发生漂移,index.test.ts就会立刻失败。这解释了为什么 out.md 的每一行、每一个空行都值得认真对待——它们都是经过测试背书的行为规范。

六、边界与变体:同族测试用例的横向印证

src/next/tests目录下围绕 shortcode 能力形成了一个完整的用例族,可与本文案例互相印证:

  • markdown-shortcodes-rich-text-children / -2:使用{{</>}}定界符 + children 富文本。对比两者 in.md 与 markdown-shortcodes-rich-text-children-2/in.md 可见:即使输入写成紧凑的{{<some-feature>}},两者最终的 out.md 都会统一还原为带空格的规范写法{{< some-feature >}},说明输出格式是规范化的,而不是对输入的机械复刻
  • markdown-shortcodes-inline:其 field.ts 中模板声明了inline: true,对应行内 shortcode 模式(text 规则);
  • markdown-shortcodes-invalid / -invalid-2 / -3 / -4 / -unclosed:从用例内容看,这些用例覆盖"声明允许 children 却未提供""标签未闭合"等异常输入,验证了shouldFallback降级为普通文本、或直接丢弃无效 shortcode 的行为(例如 markdown-shortcodes-invalid-4/out.md 中 shortcode 本身未出现在输出中);
  • unrecognized-shortcodes:未注册的{{< some-other "shortcode" >}}会被原样保留为普通文本,不会静默丢弃(见 unrecognized-shortcodes/out.md);
  • markdown-shortcodes-markdoc / wordpress-style / wordpress-style-2:从测试目录命名可以推断,这些用例分别验证 Markdoc 风格({% %})与 WordPress 风格短代码的兼容解析,佐证定界符完全由match驱动。

七、实战落地要点

要在自己的 TinaCMS 项目中使用这套能力,配置要点归纳如下:

  1. 字段声明:富文本字段必须声明parser: { type: 'markdown' }并在templates中注册 shortcode 模板,每个模板给出match.start/match.end/match.name
  2. 无键值参数:需要支持{{% name "value" %}}这种写法时,务必在模板fields中定义名为_value的字段(可按需设置requiredisTitlelabel);
  3. 嵌套富文本:需要包裹正文内容(如本文的 pull-quote 引用块)时,在模板中定义{ name: 'children', type: 'rich-text' }字段,解析器会自动完成嵌套树的收敛与还原;
  4. 命名约定match.name决定 Markdown 文本中的语法名(feature-panel),模板name是内部节点名(featurePanel),两者可以不同但建议保持语义一致;
  5. 转义档位:若你的内容还要经过其他 Markdown 工具链,可按需使用parser.skipEscaping: 'all' | 'html'控制输出转义;
  6. 回归保障:任何对 shortcode 解析/序列化行为的改动,都应参照markdown-shortcodes-rich-text-children-3/index.test.ts的模式补充"输入 → 树快照 → 输出快照"三件套,用快照测试锁住往返一致性。

结语

一份只有 7 行的 out.md,背后是一条完整的"模板契约 → micromark 解析 → 嵌套树收敛 → 规范化序列化 → 快照回归"链路。理解它,你就同时掌握了 TinaCMS 富文本 shortcode 的配置语法与其底层实现原理:_value的无键值映射、children的递归子树、leaf的自闭合判定,以及{{% /pull-quote %}}这类闭标签的精确还原规则。后续在项目里新增或排查 shortcode 问题时,可顺着 parse/index.ts、post-processing.ts、mdast/index.ts 与 to-markdown.ts 这几条主线逐层定位。

【免费下载链接】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),仅供参考

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

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

立即咨询