markdown-it 内联规则实战:利用 Tokenization 与 Post Processing 两步为 Markdown 添加 `^^small^^` 文本装饰
开发工具CLI【免费下载链接】markdown-itMarkdown parser, done right. 100% CommonMark support, extensions, syntax plugins high speed项目地址https://gitcode.com/gh_mirrors/ma/markdown-it点击查看免费下载本篇教程以 markdown-it 的官方示例文档为主线完整讲解如何通过**内联规则inline rules**为解析器添加一种全新的文本装饰语法被双脱字符^^...^^包裹的文本渲染为 HTML 的small标签。文章将覆盖内联解析的两阶段模型、ruler与ruler2的注册机制、delimiter配对流程以及孤立标记的边界处理并结合本仓库的 state_inline.ts、balance_pairs.ts、strikethrough.ts 等源码给出底层原理印证。读完本文你将具备编写 markdown-it 成对内联标记插件如上下标、颜色、删除线等的完整能力。目标Goal本文要实现的插件非常简单清晰被双脱字符包围的文本例如^^like this^^将在输出的 HTML 中被打上small标签。即输入^^like this^^输出psmalllike this/small/p这里选择small作为示例输出是因为该标签的样式语义恰好与小号文本装饰对应而实现它的思路可以无缝迁移到任何一对一的成对内联装饰。内联规则的两阶段模型markdown-it 对内联文本序列的处理分为两趟two passes每一趟各自维护一套规则列表Tokenization分词负责识别内联标记例如**加粗、^^我们新定义的小号文本分隔符。这一阶段不关心标记是否嵌套、是否构成匹配对。Post Processing后处理负责匹配成对的 token把开闭标记改写为真正的开闭标签 token。后处理阶段隐藏着大量复杂性。基础 Markdown 用一个星号表示斜体、两个星号表示加粗、三个星号表示两者叠加这背后是 CommonMark 规范中一整套规则之三rule of 3与 flanking 判定逻辑。即使新插件不需要实现如此精细的分隔符理解这层复杂性也有助于开发者把代码注入到正确的位置。[!IMPORTANT] 每一个成对匹配的内联标记都必须同时提供分词规则和后处理规则二者缺一不可。在源码层面这套双规则链的结构可以从 parser_inline.ts 中直接看到_rules数组存放分词规则text、linkify、newline、escape、backticks、strikethrough、emphasis、link、image、autolink、html_inline、entity_rules2数组则存放后处理规则balance_pairs、strikethrough、emphasis、fragments_join。而 ParserInline.parse 的执行顺序是先tokenize(state)完成分词再遍历ruler2的规则链依次后处理。注释中特别强调rule2ruleset 是专门为 emphasis/strikethrough 这类成对规则的后处理创建的除与balance_pairs协作的插件外不要用于其他用途。见 parser_inline.ts入口点在两条规则链中注册新规则我们的新规则命名为smalltext。插件入口代码如下export default function smalltext_plugin(md: MarkdownIt) { md.inline.ruler.after(emphasis, smalltext, smalltext_tokenize) md.inline.ruler2.after(emphasis, smalltext, smalltext_postProcess) } function smalltext_tokenize(state: StateInline, silent: boolean) { return false } function smalltext_postProcess(state: StateInline) { return false }注意这里使用了ruler2来注册后处理步骤。这种双链注册模式是成对内联标记规则独有的在库的其他位置例如块级规则、核心规则都看不到ruler2的身影。注册 API 的底层实现位于 ruler.tsafter(afterName, ruleName, fn)会先在内部规则数组中按名字找到emphasis的位置然后把新规则插入其后并清空规则链缓存__cache__下次调用getRules时重新编译。同名规则可以分别存在于ruler与ruler2两条链中互不干扰。插件通过 markdown-it 的 use 方法装载import MarkdownIt from markdown-it import smalltext from ./smalltext_plugin.mjs const md new MarkdownIt().use(smalltext)Tokenization识别标记并产出分隔符分词阶段要做的只有三件事识别字符串^^向state.tokens添加 Token向state.delimiters添加 Delimiter。function smalltext_tokenize(state: StateInline, silent: boolean) { const start state.pos const marker state.src.charCodeAt(start) if (silent) { return false } if (marker ! 0x5e /* ^ */) { return false } const scanned state.scanDelims(state.pos, true) let len scanned.length const ch String.fromCharCode(marker) if (len 2) { return false } let token if (len % 2) { token state.push(text, , 0) token.content ch len-- } for (let i 0; i len; i 2) { token state.push(text, , 0) token.content ch ch state.delimiters.push({ marker, length: 0, // disable rule of 3 length checks meant for emphasis token: state.tokens.length - 1, end: -1, // This pointer is filled in by the core balance_pairs post-processing rule open: scanned.can_open, close: scanned.can_close, jump: 0 }) } state.pos scanned.length return true }关于 delimiter[!TIP] 一个delimiter指向一个 token并提供额外的信息该 token 是否可以作为开启/关闭装饰文本的有效候选指向匹配结束 token 的指针该 token 包含多少个字符用于区分斜体与加粗。这些信息中的大部分都在balance_pairs后处理规则中被使用。只要在分词阶段把delimiters数组构建好开发者就不需要操心balance_pairs内部的复杂性。Delimiter的字段定义可以直接在 types.ts 中查到marker是起始标记的字符码length是这一串分隔符的总长度可选token是对应 token 在state.tokens中的下标end是匹配到的结束分隔符下标未匹配时为 -1open/close表示能否开/闭装饰jump表示一个分隔符代表几个字符默认一个分隔符代表两个字符。scanDelims 做了什么注意scanDelims这个调用。它负责判断给定的一串字符此处是^能否开启或结束一段内联样式序列。从 state_inline.ts 的实现看它扫描从start开始连续出现的相同标记字符统计长度count然后依据前一个字符和后一个字符计算 flanking 属性left_flanking!isNextWhiteSpace (!isNextPunctChar || isLastWhiteSpace || isLastPunctChar)right_flanking!isLastWhiteSpace (!isLastPunctChar || isNextWhiteSpace || isNextPunctChar)can_open left_flanking (canSplitWord || !right_flanking || isLastPunctChar)can_close right_flanking (canSplitWord || !left_flanking || isNextPunctChar)。即^^左侧的^后面不能紧跟空白、右侧的^前面不能是空白并参考两侧标点情况决定其是否能开/闭。行首行尾会被当作空白处理lastChar 0x20/nextChar 0x20代理对astral 字符也会被安全地合并成完整码点避免崩溃。为什么这个规则如此简洁单个脱字符^在本插件中没有含义因此分词规则的大部分复杂性都被去除了对于奇数长度的脱字符序列第一个^作为纯文本加入token.content ch然后len--delimiter 的length属性始终置为零从而跳过balance_pairs中针对 emphasis 的规则之三长度检查。关于第二点balance_pairs.ts 源码中有明确注释Length is only used for emphasis-specific rule of 3, if its not defined (in strikethrough or 3rd party plugins), we can default it to 0 to disable those checks.即length仅服务于 emphasis 的长度模 3规则对删除线或第三方插件置 0 即可禁用这些检查。这正是把length: 0写死的原因。另外请注意分词阶段不做任何配对尝试。end属性始终是-1真正的配对工作全部由balance_pairs规则在后台完成。silent 模式与 state.pos 推进silent参数是 markdown-it 分词框架的一部分当解析器需要在不产出 token的情况下探测当前位置能否被某条规则识别例如 ParserInline.skipToken 用于链接解析的探索会以silent true调用规则。因此规则开头直接if (silent) return false即可安全退出。规则成功识别后必须推进state.pos scanned.length并返回true否则 ParserInline.tokenize 会抛出 inline rule didnt increment state.pos 错误——这是内联规则框架的硬性契约。Post Processing读取配对结果并改写 token顶层函数的苦力活规则的主逻辑放在工具函数postProcess中而顶层规则函数smalltext_postProcess要做一段容易令人困惑的苦力活function smalltext_postProcess(state: StateInline) { const tokens_meta state.tokens_meta const max state.tokens_meta.length postProcess(state, state.delimiters) for (let curr 0; curr max; curr) { if (tokens_meta[curr]?.delimiters) { postProcess(state, tokens_meta[curr]?.delimiters || []) } } // post-process return value is unused return false } function postProcess(state: StateInline, delimiters: StateInline.Delimiter[]) { return }tokens_meta 到底是什么[!TIP] 什么是tokens_meta每当一个nesting为正值的 token即开标签被压入内联状态的 tokens 时内联状态会执行如下操作把当前的delimiters数组压入一个栈中新建一个空的delimiters数组并暴露为state.delimiters给开标签 token 附加一个token_meta对象内含这个新的delimiters数组同时把该token_meta对象存入state.tokens_meta。细心的读者会发现在分词规则执行期间新创建的 delimiter 很可能被压入了不同的数组。而在后处理阶段每个delimiters数组只包含处于同一嵌套层级的分隔符。这套机制的源码实现就在 state_inline.ts 的push方法中nesting 0开标签时this._prev_delimiters.push(this.delimiters)把当前数组压栈随后this.delimiters []新建空数组并生成token_meta { delimiters: this.delimiters }同时追加到tokens_metanesting 0闭标签时this.delimiters this._prev_delimiters.pop()!从栈中恢复上一层的数组。因此balance_pairs 的link_pairs也会采用与smalltext_postProcess完全相同的遍历结构先处理state.delimiters再遍历tokens_meta中每个非空的delimiters数组保证每个嵌套层级都能完成配对。主逻辑把文本 token 改写成开闭标签如前所述balance_pairs已经负责构建并清理 delimiter 数据本规则的后处理主要就是读取数据、按需改写 tokenfunction postProcess(state: StateInline, delimiters: StateInline.Delimiter[]) { let token const loneMarkers [] const max delimiters.length for (let i 0; i max; i) { const startDelim delimiters[i] if (startDelim.marker ! 0x5e /* ^ */) { continue } // balance_pairs wrote the appropriate end pointer value here. // If its still -1, there was a balancing problem, // and the delimiter can be ignored. if (startDelim.end -1) { continue } const endDelim delimiters[startDelim.end] token state.tokens[startDelim.token] token.type smalltext_open token.tag small token.nesting 1 token.markup ^^ token.content token state.tokens[endDelim.token] token.type smalltext_close token.tag small token.nesting -1 token.markup ^^ token.content if ( state.tokens[endDelim.token - 1].type text state.tokens[endDelim.token - 1].content ^ ) { loneMarkers.push(endDelim.token - 1) } } // If a marker sequence has an odd number of characters, it is split // like this: ^^^^^ - ^ ^^ ^^, leaving one marker at the // start of the sequence. // // So, we have to move all those markers after subsequent closing tags. // while (loneMarkers.length) { const i loneMarkers.pop() || 0 let j i 1 while (j state.tokens.length state.tokens[j].type smalltext_close) { j } j-- if (i ! j) { token state.tokens[j] state.tokens[j] state.tokens[i] state.tokens[i] token } } }逐段拆解这段逻辑筛选属于自己的分隔符marker ! 0x5e的直接跳过。因为balance_pairs会在同一个数组里处理所有 emphasis 类标记*、_、~以及第三方标记后处理规则必须只关心自己负责的标记字符。检查配对结果startDelim.end -1表示balance_pairs未能为它找到匹配的关闭分隔符直接忽略。改写开标签把开分隔符指向的文本 token 原地改成smalltext_opentag设为smallnesting 1markup ^^内容清空。改写闭标签对endDelim指向的 token 做对称处理nesting -1。记录孤立标记如果闭标签前一个 token 恰好是内容为^的文本 token即奇数长度序列拆分后留下的那个^把它记入loneMarkers等待后续搬运。这里要解释一个关键点为什么balance_pairs已经配对成功了后处理还要动 token因为balance_pairs只负责在delimiters数组内部写end指针、更新open/close标志它不修改state.tokens。真正把文本 token 变成开闭标签、从而影响最终 HTML 输出的是各装饰规则自己的后处理函数。渲染环节small如何变成 HTML本规则产出的 token 类型是smalltext_open/smalltext_close而 markdown-it 的默认渲染器规则表中并没有这两个名字。根据 renderer_rules.md 的说明凡是未在renderer.rules中显式列出的 token 类型都会回退到通用的Renderer.prototype.renderToken后者直接依据token.tag此处为small与nesting生成small//small。因此本插件无需编写任何渲染器规则开闭标签的产出是自动完成的。孤立标记lone marker的边界处理孤立标记的处理是本规则中最值得玩味的点。五连或七连的脱字符序列虽然罕见但它们仍可能与行内其他位置的脱字符串发生匹配。由于分词的方式开头和结尾的序列都会被拆开把孤立的^留在序列最前面^^^^^^^hey this text would actually be small^^^^^^^ gets parsed somewhat like this: ^ ^^ ^^ ^^ hey this text would actually be small ^ ^^ ^^ ^^ | | | | | | | | opening tag | | open and close | open and close | balanced closing tag lone caret lone caret因为开头序列中的第一个^不在small标签内部所以结尾序列中的第一个^也不应该被放进标签内。上面的while (loneMarkers.length)循环就是处理这个边界情况的把所有孤立^依次搬运到紧邻的smalltext_close之后使其在最终 HTML 中落在标签外部保持两侧的视觉对称。与核心库 strikethrough 规则逐行对照官方文档指出这个规则几乎是核心库中 strikethrough 规则的逐字拷贝。这个论断在当前仓库中可以直接验证——对比 strikethrough.tsstrikethrough_tokenize识别0x7E~我们的规则识别0x5E^两者都要求长度至少为 2、奇数长度时先剥离一个纯文本标记、每两个字符压入一个length: 0的 delimiterstrikethrough_postProcess与smalltext_postProcess的tokens_meta遍历结构完全一致后处理主循环中仅 token 类型s_open/s_closevssmalltext_open/smalltext_close、tagsvssmall、markup~~vs^^以及孤立标记的判断字符~vs^不同连孤立标记的搬运循环都如出一辙跳过连续的s_close/smalltext_close后交换 token 位置。区别仅在于strikethrough 的核心规则还会在 tokenization 时把state.scanDelims(state.pos, true)的第二个参数canSplitWord传true允许标记出现在单词内部如le~~ve~~l本插件也沿用了这一行为。如果希望实现一个完整的 emphasis 风格规则支持嵌套、长度模 3 规则等可以参考 emphasis.ts。它其实也没有长多少——isStrong的判断相邻两个同标记 delimiter 合并为strong加上markup的拼接其余复杂逻辑都依赖balance_pairs承担。此外后处理把文本 token 改写成开闭标签后level数值与相邻文本节点可能出现错乱fragments_join见 fragments_join.ts会负责重新计算层级并合并相邻文本 token这是规则链末尾的收尾环节。完整插件代码与运行示例将上述片段整合成一个可独立运行的插件文件TypeScriptimport type MarkdownIt from markdown-it import type StateInline from markdown-it/lib/rules_inline/state_inline.mjs export default function smalltext_plugin(md: MarkdownIt) { md.inline.ruler.after(emphasis, smalltext, smalltext_tokenize) md.inline.ruler2.after(emphasis, smalltext, smalltext_postProcess) } function smalltext_tokenize(state: StateInline, silent: boolean): boolean { const start state.pos const marker state.src.charCodeAt(start) if (silent) return false if (marker ! 0x5e /* ^ */) return false const scanned state.scanDelims(state.pos, true) let len scanned.length const ch String.fromCharCode(marker) if (len 2) return false let token if (len % 2) { token state.push(text, , 0) token.content ch len-- } for (let i 0; i len; i 2) { token state.push(text, , 0) token.content ch ch state.delimiters.push({ marker, length: 0, token: state.tokens.length - 1, end: -1, open: scanned.can_open, close: scanned.can_close, jump: 0 }) } state.pos scanned.length return true } function smalltext_postProcess(state: StateInline): boolean { const tokens_meta state.tokens_meta const max state.tokens_meta.length postProcess(state, state.delimiters) for (let curr 0; curr max; curr) { if (tokens_meta[curr]?.delimiters) { postProcess(state, tokens_meta[curr]?.delimiters || []) } } return false } function postProcess(state: StateInline, delimiters: StateInline.Delimiter[]): void { let token const loneMarkers: number[] [] const max delimiters.length for (let i 0; i max; i) { const startDelim delimiters[i] if (startDelim.marker ! 0x5e /* ^ */) continue if (startDelim.end -1) continue const endDelim delimiters[startDelim.end] token state.tokens[startDelim.token] token.type smalltext_open token.tag small token.nesting 1 token.markup ^^ token.content token state.tokens[endDelim.token] token.type smalltext_close token.tag small token.nesting -1 token.markup ^^ token.content if ( state.tokens[endDelim.token - 1].type text state.tokens[endDelim.token - 1].content ^ ) { loneMarkers.push(endDelim.token - 1) } } while (loneMarkers.length) { const i loneMarkers.pop() || 0 let j i 1 while (j state.tokens.length state.tokens[j].type smalltext_close) { j } j-- if (i ! j) { token state.tokens[j] state.tokens[j] state.tokens[i] state.tokens[i] token } } }使用方式import MarkdownIt from markdown-it import smalltext from ./smalltext_plugin.mjs const md new MarkdownIt().use(smalltext) console.log(md.render(This is ^^small^^ text)) // pThis is smallsmall/small text/p几个值得验证的边界输入输入行为^^small^^正常产出smallsmall/small^single^长度不足 2^保持为纯文本^^^odd^^^开头/结尾序列各剥离一个^剩余^^配对成small^^^^^five^^^^^各序列剥离一个^后成对孤立^被搬运到标签外^^unclosed找不到匹配的关闭分隔符end保持 -1^^保持为纯文本注意事项与结论[!CAUTION]如果正在开发的插件是没有开/闭配对的独立内联元素想想链接text或图片alt text完全可以放心忽略这套后处理基础设施 Markdown 解析已经足够复杂请不要引入任何不必要的复杂度链接与图片规则之所以不需要后处理是因为它们的结构是自包含的方括号 圆括号一次性解析完毕不需要跨位置配对。而 emphasis 类规则必须延迟到balance_pairs完成跨 token 的全局匹配后才能确定边界这正是分词 后处理两阶段设计的根本原因。回顾整个实现最关键的三点结论成对内联标记 两条规则ruler中注册分词规则负责识别ruler2中注册后处理规则负责配对后改写信任框架分词阶段只需要构造好delimiters数组正确设置marker、open/close、length: 0配对工作完全交给核心的balance_pairs边界情况不可忽略奇数长度的标记序列会产生孤立标记需要像 strikethrough 那样在标签外重新安放才能保证渲染结果与书写直觉一致。本教程对应的官方示例位于 docs/examples/text_decoration.md核心实现可对照 src/rules_inline/strikethrough.ts 与 src/rules_inline/emphasis.ts框架机制可阅读 src/rules_inline/state_inline.ts、src/rules_inline/balance_pairs.ts 与 src/parser_inline.ts。照此模式你可以轻松拓展出^^上标^^、高亮、--删除线--等各种成对内联装饰语法。赞分享开发工具CLI【免费下载链接】markdown-itMarkdown parser, done right. 100% CommonMark support, extensions, syntax plugins high speed项目地址https://gitcode.com/gh_mirrors/ma/markdown-it点击查看免费下载相关推荐React-PDF文本装饰动画为文本装饰添加动画效果React PDF文本装饰动画为文本装饰添加动画效果 在现代Web应用开发中PDF文档的动态效果越来越受到重视。React PDF作为一个强大的PDF生成库PDF生成后端前端5分钟入门Post Processing让Three.js场景瞬间提升视觉质感的终极指南5分钟入门Post Processing让Three.js场景瞬间提升视觉质感的终极指南 Post Processing是一款专为Three.js打造的强大后Slidev 如何启用 Comark 语法给 Markdown 添加内联组件与样式Slidev 如何启用 Comark 语法给 Markdown 添加内联组件与样式 如果你用 Slidev 写演示文稿想在 Markdown 正文里直接给文本前端开发工具上一篇Slacker Socket Mode深度解析如何实现实时Slack事件处理下一篇终极指南无需模拟器在Windows电脑上直接安装安卓APK应用创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考