news 2026/9/20 3:44:46

markdown-it 内联规则实战:利用 Tokenization 与 Post Processing 两步为 Markdown 添加 `^^small^^` 文本装饰

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
markdown-it 内联规则实战:利用 Tokenization 与 Post Processing 两步为 Markdown 添加 `^^small^^` 文本装饰
  • 开发工具
  • CLI

【免费下载链接】markdown-it

Markdown 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>标签。文章将覆盖内联解析的两阶段模型、rulerruler2的注册机制、delimiter配对流程以及孤立标记的边界处理,并结合本仓库的 state_inline.ts、balance_pairs.ts、strikethrough.ts 等源码给出底层原理印证。读完本文,你将具备编写 markdown-it 成对内联标记插件(如上下标、颜色、删除线等)的完整能力。

目标(Goal)

本文要实现的插件非常简单清晰:

被双脱字符包围的文本(例如^^like this^^)将在输出的 HTML 中被打上<small>标签。

即输入:

^^like this^^

输出:

<p><small>like 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_pairsstrikethroughemphasisfragments_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.ts:after(afterName, ruleName, fn)会先在内部规则数组中按名字找到emphasis的位置,然后把新规则插入其后并清空规则链缓存(__cache__),下次调用getRules时重新编译。同名规则可以分别存在于rulerruler2两条链中,互不干扰。

插件通过 markdown-it 的 use 方法装载:

import MarkdownIt from 'markdown-it' import smalltext from './smalltext_plugin.mjs' const md = new MarkdownIt().use(smalltext)

Tokenization:识别标记并产出分隔符

分词阶段要做的只有三件事:

  1. 识别字符串^^
  2. state.tokens添加 Token;
  3. 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是匹配到的结束分隔符下标(未匹配时为 -1),open/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 it's 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 didn't 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_meta
  • nesting < 0(闭标签)时:this.delimiters = this._prev_delimiters.pop()!从栈中恢复上一层的数组。

因此,balance_pairs 的link_pairs也会采用与smalltext_postProcess完全相同的遍历结构:先处理state.delimiters,再遍历tokens_meta中每个非空的delimiters数组,保证每个嵌套层级都能完成配对。

主逻辑:把文本 token 改写成开闭标签

如前所述,balance_pairs已经负责构建并清理 delimiter 数据,本规则的后处理主要就是读取数据、按需改写 token

function 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 it's 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 } } }

逐段拆解这段逻辑:

  1. 筛选属于自己的分隔符marker !== 0x5e的直接跳过。因为balance_pairs会在同一个数组里处理所有 emphasis 类标记(*_~以及第三方标记),后处理规则必须只关心自己负责的标记字符。
  2. 检查配对结果startDelim.end === -1表示balance_pairs未能为它找到匹配的关闭分隔符,直接忽略。
  3. 改写开标签:把开分隔符指向的文本 token 原地改成smalltext_opentag设为"small"nesting = 1markup = "^^",内容清空。
  4. 改写闭标签:对endDelim指向的 token 做对称处理,nesting = -1
  5. 记录孤立标记:如果闭标签前一个 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.ts:

  • strikethrough_tokenize识别0x7E~),我们的规则识别0x5E^);
  • 两者都要求长度至少为 2、奇数长度时先剥离一个纯文本标记、每两个字符压入一个length: 0的 delimiter;
  • strikethrough_postProcesssmalltext_postProcesstokens_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,这是规则链末尾的收尾环节。

完整插件代码与运行示例

将上述片段整合成一个可独立运行的插件文件(TypeScript):

import 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')) // <p>This is <small>small</small> text</p>

几个值得验证的边界输入:

输入行为
^^small^^正常产出<small>small</small>
^single^长度不足 2,^保持为纯文本
^^^odd^^^开头/结尾序列各剥离一个^,剩余^^配对成<small>
^^^^^five^^^^^各序列剥离一个^后成对,孤立^被搬运到标签外
^^unclosed找不到匹配的关闭分隔符(end保持 -1),^^保持为纯文本

注意事项与结论

[!CAUTION]

如果正在开发的插件是没有开/闭配对的"独立"内联元素(想想链接text或图片alt text),完全可以放心忽略这套后处理基础设施! Markdown 解析已经足够复杂,请不要引入任何不必要的复杂度!

链接与图片规则之所以不需要后处理,是因为它们的结构是自包含的(方括号 + 圆括号一次性解析完毕),不需要跨位置配对。而 emphasis 类规则必须延迟到balance_pairs完成跨 token 的全局匹配后才能确定边界,这正是"分词 + 后处理"两阶段设计的根本原因。

回顾整个实现,最关键的三点结论:

  1. 成对内联标记 = 两条规则ruler中注册分词规则负责"识别",ruler2中注册后处理规则负责"配对后改写";
  2. 信任框架:分词阶段只需要构造好delimiters数组(正确设置markeropen/closelength: 0),配对工作完全交给核心的balance_pairs
  3. 边界情况不可忽略:奇数长度的标记序列会产生孤立标记,需要像 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-it

Markdown parser, done right. 100% CommonMark support, extensions, syntax plugins & high speed

项目地址:https://gitcode.com/gh_mirrors/ma/markdown-it
点击查看免费下载
上一篇:Slacker Socket Mode深度解析:如何实现实时Slack事件处理
下一篇:终极指南:无需模拟器在Windows电脑上直接安装安卓APK应用

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

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/20 3:44:03

微信小程序招聘系统开发实战:表结构、登录鉴权与部署排查全解

2. 数据库设计与表结构规划人才招聘系统的表结构&#xff0c;直接决定了后面接口好不好写、统计好不好做。我在设计这套系统时&#xff0c;采用的方案是&#xff1a;用户中心独立两张表、职位与简历分离、投递记录用状态机驱动&#xff0c;审批流单独建表。下面把核心表结构展开…

作者头像 李华
网站建设 2026/9/20 3:42:24

5 次查询看懂韩国法院不动产拍卖:court-auction-notice-search 完全指南

5 次查询看懂韩国法院不动产拍卖&#xff1a;court-auction-notice-search 完全指南 【免费下载链接】k-skill 한국인을 위한 스킬 모음집 - 에이전트를 한국인으로 项目地址: https://gitcode.com/GitHub_Trending/ks/k-skill 假设你在首尔打算参与不动产拍卖&#xff…

作者头像 李华
网站建设 2026/9/20 3:40:23

Windows 11开始菜单失灵?从重启Shell到重建索引的完整修复方案

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华