- 开发工具
- CLI
【免费下载链接】markdown-it
Markdown parser, done right. 100% CommonMark support, extensions, syntax plugins & high speed
本篇教程以 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^^输出:
<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_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.ts:after(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是匹配到的结束分隔符下标(未匹配时为 -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 } } }逐段拆解这段逻辑:
- 筛选属于自己的分隔符:
marker !== 0x5e的直接跳过。因为balance_pairs会在同一个数组里处理所有 emphasis 类标记(*、_、~以及第三方标记),后处理规则必须只关心自己负责的标记字符。 - 检查配对结果:
startDelim.end === -1表示balance_pairs未能为它找到匹配的关闭分隔符,直接忽略。 - 改写开标签:把开分隔符指向的文本 token 原地改成
smalltext_open,tag设为"small",nesting = 1,markup = "^^",内容清空。 - 改写闭标签:对
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.ts:
strikethrough_tokenize识别0x7E(~),我们的规则识别0x5E(^);- 两者都要求长度至少为 2、奇数长度时先剥离一个纯文本标记、每两个字符压入一个
length: 0的 delimiter; strikethrough_postProcess与smalltext_postProcess的tokens_meta遍历结构完全一致;- 后处理主循环中,仅 token 类型(
s_open/s_closevssmalltext_open/smalltext_close)、tag(svssmall)、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 的全局匹配后才能确定边界,这正是"分词 + 后处理"两阶段设计的根本原因。
回顾整个实现,最关键的三点结论:
- 成对内联标记 = 两条规则:
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-it
Markdown parser, done right. 100% CommonMark support, extensions, syntax plugins & high speed
相关推荐
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 正文里直接给文本
前端开发工具
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考