
TinaCMS 中 Hugo 风格 Shortcode 的嵌套富文本子内容从解析到序列化的完整实现解读【免费下载链接】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 仓库内packages/tinacms/mdx/src/next/tests/markdown-shortcodes-nested-rich-text-children测试套件深入讲解 TinaCMS 的 MDX 解析器next 模块如何支持 Hugo 风格 shortcode形如{{ some-feature }}作为富文本模板以及当这些模板内部再嵌套另一层 shortcode 并承载 rich-text 子内容时其底层 AST 结构与双向转换Markdown → AST → Markdown的实现原理。读完本文你将掌握在 TinaCMS 富文本字段中定义可嵌套短代码模板的完整配置方式并理解其解析与序列化的工作机制。一、背景TinaCMS 的 shortcode 与 next MDX 模块TinaCMS 是一个开源的无头 CMS内容以 Markdown/MDX 形式存放在 Git 仓库中。为了让内容创作者在富文本中使用可复用的组件例如提示框、图片组、内嵌特性展示块TinaCMS 引入了shortcode机制这是一种 Hugo 风格的语法形如{{ some-feature }} {{ /some-feature }}它并非标准 MDX 的 JSX 写法而是更贴近内容编辑者习惯的短标签形式。在仓库中这套能力由packages/tinacms/mdx/src/next/目录下的解析parse/、序列化stringify/与短代码核心shortcodes/三个子模块共同实现。其中shortcodes/模块定义了短代码的语法规则与 micromark 扩展parse/负责把 Markdown 文本解析为 mdast 语法树stringify/负责把语法树还原回 Markdown 文本。本文所依据的测试目录packages/tinacms/mdx/src/next/tests/markdown-shortcodes-nested-rich-text-children/正是这一能力的最小可运行示范它用一份 11 行的输入文档、一份字段配置和一个期望的 AST 快照完整演示了两层 shortcode 嵌套且内层承载富文本段落的场景。二、测试用例全貌输入、配置与期望结果整个测试套件由四个文件组成构成一个闭环的输入 → 解析 → 断言 → 序列化演示。1. 输入文档in.mdpackages/tinacms/mdx/src/next/tests/markdown-shortcodes-nested-rich-text-children/in.md全文如下# hello {{ some-feature }} {{ other-feature }} Testing this thing {{ /other-feature }} {{ /some-feature }}这份文档结构非常清晰一个h1标题hello随后是一个{{ some-feature }}开标签内部又包含一个{{ other-feature }}开标签其内是一个普通段落Testing this thing最后按相反顺序闭合other-feature与some-feature。这是一个典型的嵌套 shortcode输入且内层 shortcode 的子内容是一段富文本段落而不是普通的字符串属性。2. 字段配置field.tspackages/tinacms/mdx/src/next/tests/markdown-shortcodes-nested-rich-text-children/field.ts定义了承载该内容的富文本字段 schemaimport { RichTextField } from tinacms/schema-tools; export const field: RichTextField { name: body, type: rich-text, parser: { type: markdown }, templates: [ { name: someFeature, label: Some feature, match: { start: {{, end: }}, name: some-feature }, fields: [ { name: _value, type: string }, { name: children, type: rich-text, templates: [ { name: otherFeature, label: Other feature, match: { start: {{, end: }}, name: other-feature }, fields: [ { name: _value, type: string }, { name: children, type: rich-text }, ], }, ], }, ], }, ], };这段配置揭示了 shortcode 模板的几个关键约定match对象start与end定义了短代码的定界符此处为{{与}}name指定了文档中实际书写的标签名some-feature/other-feature。注意 schema 中的name如someFeature与文档中书写的标签名如some-feature可以不同二者通过match.name建立关联。_value字段类型为string用于承载短代码开标签中的无键属性unkeyed attribute例如{{ some-feature value }}中的裸值。children字段类型为rich-text表示短代码内部可以再放置富文本内容而children内部又声明了templates从而实现了嵌套模板的递归定义——这正是本测试用例的核心someFeature的children里嵌套定义了otherFeature模板。3. 期望 ASTnode.jsonpackages/tinacms/mdx/src/next/tests/markdown-shortcodes-nested-rich-text-children/node.json是解析器对in.md处理后应得到的完整语法树已剔除position信息{ type: root, children: [ { type: h1, children: [{ type: text, text: hello }] }, { type: mdxJsxFlowElement, name: someFeature, children: [{ type: text, text: }], props: { children: { type: root, children: [ { type: mdxJsxFlowElement, name: otherFeature, children: [{ type: text, text: }], props: { children: { type: root, children: [ { type: p, children: [{ type: text, text: Testing this thing }] } ] } } } ] } } } ] }这个 AST 蕴含了重要的设计信息外层 shortcode 被解析为mdxJsxFlowElement流式 JSX 元素name为模板名someFeature而非文档中的some-feature短代码内部的富文本子内容并非直接平铺在元素children中而是以root为根节点、存放在props.children属性下。可以看到props.children是一个类型为root的子树外层节点通过props与内层内容解耦嵌套是递归的otherFeature同样是一个mdxJsxFlowElement它的props.children又是一个root其内才是普通段落节点p最终文本为Testing this thing每个mdxJsxFlowElement自身的children中保留了一个空文本节点{type: text, text: }这是标签自身的占位内容。从源码结构看props.children存放子内容、而元素children仅保留占位文本的设计是为了让短代码的内容承载与 mdast 的常规节点层级彼此独立便于后续在编辑器中按模板字段_value、children分别读写。4. 测试逻辑index.test.tspackages/tinacms/mdx/src/next/tests/markdown-shortcodes-nested-rich-text-children/index.test.ts是标准的快照测试import { expect, it } from vitest; 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); expect(util.print(tree)).toMatchFile(util.nodePath(__dirname)); const string stringifyMDX(tree, field, (v) v); expect(string).toMatchFile(util.mdPath(__dirname)); });测试做了两件互为逆运算的断言解析方向调用parseMDX(input, field, (v) v)将in.md解析为语法树经util.print内部通过removePosition剔除position字段后 JSON 序列化与node.json快照比对序列化方向调用stringifyMDX(tree, field, (v) v)将语法树还原为 Markdown与out.md快照比对目录中未单独提供out.md说明序列化输出与in.md输入一致测试名 matches input 即表达此意。测试文件还展示了通用工具packages/tinacms/mdx/src/next/tests/util.ts的用法nodePath/mdPath分别指向同目录下的node.json与out.mdprint负责输出无位置信息的规范 JSON。这意味着该测试族packages/tinacms/mdx/src/next/tests/下所有markdown-*/mdx-*目录都遵循同一套输入 Markdown → 期望 AST → 期望输出 Markdown的三角验证模式。三、解析器入口parseMDX 的处理管线从源码看packages/tinacms/mdx/src/next/parse/index.ts是 next 解析器的公开入口源码注释说明该模块是在提交651b6b53b引入的 next module for mdx behavior面向 Markdown 内容的公开parseMDX会委托到这里export const parseMDX ( value: string, field: RichTextField, imageCallback?: (s: string) string ) { const backup (v: string) v; const callback imageCallback || backup; const tree fromMarkdown(value, field); return postProcess(tree, field, callback); };其处理管线分三步fromMarkdown(value, field)实现在packages/tinacms/mdx/src/next/parse/markdown.ts调用 micromark/mdast 生态的mdast-util-from-markdown并注入shortcodes/mdast提供的扩展将 Markdown 文本转换为 mdast 语法树。此阶段负责识别{{ some-feature }}这样的短代码标签并构建mdxJsxFlowElement节点compact(tree)通过mdast-util-compact压缩/清理语法树中的冗余节点postProcessor(compact(tree), field, imageCallback)实现在packages/tinacms/mdx/src/next/parse/post-processing.ts结合field中的模板定义进行后处理包括将短代码子内容整理进props.children、把无键属性映射到_value字段等。值得强调的是整个管线都以field即RichTextField为配置依据同一个 Markdown 文档在不同的 field 配置下会解析出不同的结构这正是field.ts中模板声明的意义所在。四、短代码语法引擎Pattern 与匹配规则packages/tinacms/mdx/src/next/shortcodes/index.ts仅做了两件事导出mdxJsx扩展与Pattern/Options类型。真正的实现在shortcodes/lib/syntax.ts中。Pattern类型完整定义了短代码模板在解析层面的全部属性export type Pattern { start: string; // 开标签起始定界符如 {{ end: string; // 结束定界符如 }} name: string; // 文档中书写的标签名如 some-feature templateName: string; // 对应的 schema 模板名如 someFeature type: inline | flow; // 内联或流式块级 leaf: boolean; // 是否为叶子节点无子内容、自闭合 };mdxJsx(options)函数基于patterns数组构建 micromark 扩展它按每个 pattern 的start[0]起始定界符的第一个字符此处即{将规则归类到flowRules或textRules对应type: flow与type: inline分别用于流式与内联场景的 token 识别若传入acorn会为其配置{ ecmaVersion: 2020, sourceType: module, locations: true }等默认项用于解析标签中的表达式属性若options.skipHTML为真会禁用htmlFlow/htmlTexttoken避免 HTML 语法与短代码语法冲突。在field.ts中声明match: { start: {{, end: }}, name: some-feature }后schema-tools 层会将其编译为上述PatterntemplateName取 schema 中的namesomeFeature。这也是为什么最终 AST 里节点name是someFeature而非some-feature。五、嵌套 rich-text children 的解析机制标签栈与 fallback短代码标签的识别与嵌套匹配实现在packages/tinacms/mdx/src/next/shortcodes/mdast/index.ts的mdxJsxFromMarkdown函数中。该函数返回一组enter/exit处理器挂接到mdast-util-from-markdown的 token 事件上。与本测试用例直接相关的机制有三处1. 标签栈mdxJsxTagStack维护嵌套关系enterMdxJsxTag在进入每个标签 token 时创建Tag对象并推入mdxJsxTagStackexitMdxJsxTag在退出标签时检查栈顶如果当前是闭合标签且与栈顶标签名不匹配会抛出Unexpected closing tag ... expected corresponding closing tag for ...rule id 为mdast-util-mdx-jsx:end-tag-mismatch错误if (tail tag.close tail.name ! tag.name) { throw new VFileMessage( Unexpected closing tag serializeAbbreviatedTag(tag) , expected corresponding closing tag for serializeAbbreviatedTag(tail) ..., { start: token.start, end: token.end }, mdast-util-mdx-jsx:end-tag-mismatch ); }这正是in.md中{{ /other-feature }}必须写在{{ /some-feature }}之前的原因——标签按栈结构严格配对交叉闭合会被判定为非法输入。同时enterMdxJsxTagClosingMarker还处理了一种兜底情形当闭合标记出现时栈为空即没有对应的开标签会将shouldFallback置为 true此时该段文本不再作为 JSX 元素处理而是退化为普通文本/段落节点避免让整个解析崩溃。2. 开放标签的提前关闭onErrorRightIsTag由于someFeature模板声明了children富文本字段允许块级子内容它不会被标记为自闭合。但若文档缺少闭合标签解析器会通过(left, right) { this.exit(right); }回调在合适位置自动关闭节点源码注释明确说明This template allows block children, so we didnt mark it as self-closing. But we didnt receive a closing tag, so close it now. Without this, we would be calling onErrorRightIsTag。这一设计保证了嵌套场景下每个开放节点都能获得确定性的关闭时机为后续props.children的组装提供完整边界。3. 无键属性映射为_valueexitMdxJsxTagAttributeValueLiteral中有一段关键逻辑// Support for unkeyed attributes if (attribute.name ) { attribute.name _value; }也就是说{{ some-feature foo }}中不带键名的裸值会被统一命名为_value属性——这与field.ts中声明的{ name: _value, type: string }字段一一对应。在本测试的输入中没有出现裸值因此 AST 中未见_value属性但该机制是 shortcode 模板字段映射的基础约定。六、反向序列化mdxJsxToMarkdown 还原短代码语法编辑保存后语法树需要还原回 Markdown 文本这一方向由packages/tinacms/mdx/src/next/shortcodes/mdast/index.ts中的mdxJsxToMarkdown完成。mdxElement处理器对每个mdxJsxFlowElement/mdxJsxTextElement节点按以下顺序拼接输出通过patterns.find((p) p.templateName node.name)找到对应 pattern若找不到则返回空串源码中以 FIXME 标注输出pattern.start patternName即{{ some-feature序列化属性普通属性输出namevalue表达式属性输出{value}_value属性被特殊处理为不带键名的裸值if (left _value) { result right; }从而保证{{ some-feature value }}这种写法能原样还原输出pattern.end即}}若节点含子内容流式元素通过containerFlow递归序列化子节点并做 2 空格缩进内联元素通过containerPhrasing序列化若children仅是一个空文本段落value 则跳过子内容输出保持紧凑格式输出闭合标签pattern.start / patternName pattern.end即{{ /some-feature }}。此外mdxJsxToMarkdown强制设置fences: true与resourceLink: true保证代码块使用围栏式、链接使用资源式从而在不同内容之间保持一致的输出风格。对于本文的嵌套场景序列化过程是递归的外层someFeature节点调用containerFlow时其子内容props.children对应的子树中的otherFeature节点再次进入mdxElement输出{{ other-feature }}及其内部段落最后输出{{ /other-feature }}再由外层补上{{ /some-feature }}。这正是测试名 matches input 所验证的往返一致性round-trip。七、相关测试族与边界场景在packages/tinacms/mdx/src/next/tests/下存在一整个 shortcode 测试族与本文用例形成互补可用于理解嵌套能力的边界markdown-shortcodes-rich-text-children、markdown-shortcodes-rich-text-children-2、markdown-shortcodes-rich-text-children-3单层 shortcode 携带 rich-text children 的基础场景markdown-shortcodes-inline、markdown-shortcodes-inline-with-children内联type: inline短代码及其子内容markdown-shortcodes-unclosed、markdown-shortcodes-invalid含-2/-3/-4缺闭合标签、格式错误等非法输入的兜底行为markdown-shortcodes-with-duplicates同名标签多次出现的处理markdown-shortcodes-markdoc、wordpress-style、wordpress-style-2-with-children对其他短代码方言Markdoc 风格、WordPress 风格的兼容尝试markdown-shortcodes-block-with-html-children-1/-2children 内部包含 HTML 的复杂场景mdx-blocks-rich-text-children系列非 shortcode 语法的标准 MDX JSX 块携带 rich-text children 的对照实现。这些用例共同验证了 shortcode 引擎在嵌套深度、子内容类型文本/段落/HTML/块级、闭合完整性、多方言兼容等多个维度的行为为在真实项目中安全使用嵌套 shortcode 提供了回归保障。八、实战要点如何在 TinaCMS 中定义可嵌套短代码模板综合以上实现细节要在自己的 TinaCMS 配置中复现shortcode 内嵌 shortcode 且携带富文本段落的能力需要满足以下条件在rich-text字段的templates中声明外层模板为其添加名为children、类型为rich-text的字段并在该字段的templates中继续声明内层模板——嵌套层级由 schema 的递归结构天然表达对照 field.ts为每个模板提供match对象其中start/end统一使用同一对定界符如{{与}}name使用文档中书写的短标签名kebab-caseschema 的name使用模板标识camelCase文档中严格按栈序配对开闭标签内层标签必须在内层闭合之后才能闭合外层交叉闭合会触发end-tag-mismatch错误若模板需要接收参数通过{ name: _value, type: string }字段承接无键参数或使用普通字段接收namevalue形式的有键属性保持往返一致性由于解析与序列化共用同一套Pattern定义packages/tinacms/mdx/src/next/shortcodes/lib/syntax.ts只要 schema 与文档书写都遵循上述约定内容经保存 → 解析 → 再序列化后不会发生结构漂移。从源码结构看这套 shortcode 引擎被组织在 shortcodes/ 目录内解析侧由 parse/index.ts 编排序列化侧由 stringify/ 承载而 markdown-shortcodes-nested-rich-text-children/index.test.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),仅供参考