- 开发工具
- CLI
【免费下载链接】markdown-it
Markdown parser, done right. 100% CommonMark support, extensions, syntax plugins & high speed
导读
本文以仓库基准样本 benchmark/samples/inline-em-nested.md 为线索,深入剖析 markdown-it 如何处理*、_多层嵌套的强调标记:从行内分词(tokenize)、分隔符配对(balance pairs)到最终标签生成(post-process)的完整调用链,并给出通过 benchmark/benchmark.mjs 对本样本做性能验证的实战方法。读完本文,你将掌握强调语法在 markdown-it 内部的判定规则(左右翼规则、Rule of 3、强强调合并),以及如何用仓库自带基准工具对比不同解析器在嵌套场景下的吞吐表现。
一、样本文件:三层嵌套强调的结构
benchmark/samples/inline-em-nested.md全文仅 3 行,是一个刻意构造的"嵌套强调"基准样本:
*this *is *a *bunch* of* nested* emphases* __this __is __a __bunch__ of__ nested__ emphases__ ***this ***is ***a ***bunch*** of*** nested*** emphases***它对应仓库基准样本集中的三组变体(见 benchmark/samples 目录下的姊妹样本):
- inline-em-flat.md:
*this* *is* *your* *basic* *boring* *emphasis*——多个互不嵌套的独立强调,是解析器最容易处理的情况; - inline-em-nested.md:强调标记层层嵌套(
*a *b *c* b* a*),且每层都带前后置文本; - inline-em-worst.md:
*this *is *a *worst *case *for *em *backtracking——名称即表明这是针对回溯(backtracking)的最坏情况构造。
三个样本分别用单星号、双下划线、三星号三种标记书写,覆盖了单层强调(em)、双层强调(strong)以及混合长度的分隔符序列,是衡量解析器强调处理能力的三档难度标尺。
二、强调解析的完整调用链
嵌套强调的处理横跨 markdown-it 行内解析的三个阶段,全部位于 src/rules_inline 目录:
- 分词阶段(tokenize):emphasis.ts 中的
emphasis_tokenize把连续的*/_标记逐个拆成独立的texttoken,同时把"能否开/关强调"的判定结果压入state.delimiters分隔符栈; - 配对阶段(balance pairs):balance_pairs.ts 中的
processDelimiters为每个开标记寻找匹配的闭标记,处理嵌套与 Rule of 3 冲突,复杂度被约束为线性; - 后处理阶段(post-process):emphasis.ts 中的
postProcess把已配对的texttoken 改写为em_open/em_close或strong_open/strong_close标签 token。
从源码结构看,这三步正是 markdown-it 行内解析器对嵌套强调的完整内部流水线,任何第三方强调类插件(如strikethrough,见 src/rules_inline/strikethrough.ts)都复用同一套processDelimiters配对机制。
2.1 分词:分隔符栈的构建
emphasis_tokenize只处理*(0x2A)和_(0x5F)两种标记。对每一处标记序列,它调用state.scanDelims()一次性算出该连续段的长度与开/关能力,然后把每一个标记分别压入分隔符栈:
// src/rules_inline/emphasis.ts(结构示意) for (let i = 0; i < scanned.length; i++) { const token = state.push('text', '', 0) token.content = String.fromCharCode(marker) state.delimiters.push({ marker, // 标记字符码 length: scanned.length, // 该连续标记段总长 token: state.tokens.length - 1, end: -1, // 配对后指向闭合分隔符下标 open: scanned.can_open, close: scanned.can_close }) }注意length保存的是整段标记的总长度(如***段的三个分隔符共享length: 3),它在 Rule of 3 判定中起关键作用。
2.2 scanDelims:左右翼规则(Flanking Rules)
state.scanDelims()(见 state_inline.ts)决定一个标记段能否开/关强调,依据是 CommonMark 的左右翼规则:
const left_flanking = !isNextWhiteSpace && (!isNextPunctChar || isLastWhiteSpace || isLastPunctChar) const right_flanking = !isLastWhiteSpace && (!isLastPunctChar || isNextWhiteSpace || isNextPunctChar) const can_open = left_flanking && (canSplitWord || !right_flanking || isLastPunctChar) const can_close = right_flanking && (canSplitWord || !left_flanking || isNextPunctChar)canSplitWord由标记类型决定:*允许拆分单词(marker === 0x2A时传true),_不允许,因此foo_bar_baz中的_不会产生强调;- 行首、行尾被当作空白处理,避免边界处的误判;
- 源码还处理了 Unicode 增补平面字符(代理对)与孤立代理项,保证标点/空白判定不会因码点截断而崩溃。
对本样本而言,*this *is *a ...中每个*的前后都是空白或字母,满足can_open与can_close同时为真的条件,这正是嵌套场景复杂性的来源——一个分隔符既可开也可关。
三、配对算法:Rule of 3 与线性复杂度
processDelimiters(balance_pairs.ts)从右向左为每个可能的闭合器寻找开放器。核心约束是 CommonMark 的Rule of 3:
若一个分隔符既可开又可关,则其所在分隔符段的长度与闭段长度之和不能是 3 的倍数,除非两段长度都恰好是 3 的倍数。
对应实现:
if (opener.close || closer.open) { if ((opener.length! + closer.length) % 3 === 0) { if (opener.length! % 3 !== 0 || closer.length % 3 !== 0) { isOddMatch = true // 触发规则,禁止本次配对 } } }这正是本样本中***this ***is ***a ***bunch*** ...这类 3 长度段能被正确处理的原因:段长模 3 的余数参与索引计算(openersBottom[marker][(closer.open ? 3 : 0) + (closer.length % 3)]),length为 0(如第三方插件)时则跳过这些检查。
3.1 保证线性的两个优化
源码注释明确说明,为了让算法保持线性复杂度(而非暴力回溯),实现了两个关键机制:
- jumps 跳表:配对成功后记录
jumps[closerIdx],后续扫描可直接跳过已知不可能成功的序列——注释点名的*_*_*_*_*_...正是典型的交替开放/闭合序列; - openersBottom 下界缓存:每次配对失败都会记录该标记、该模 3 余数下的最小可尝试开放器位置,避免重复失败的查找;源码引用 commonmark/cmark 的提交记录与 issue 作为依据。
这两个机制保证了即使面对inline-em-nested.md这种多层级嵌套、以及inline-em-worst.md这种回溯最坏样本,解析时间仍与输入长度近似线性。
四、后处理:em 与 strong 的生成与合并
postProcess(emphasis.ts)倒序遍历分隔符栈,把配好对的texttoken 改写成标签 token。它还有一个值得注意的优化——相邻同标记合并为 strong:
// `<em><em>whatever</em></em>` -> `<strong>whatever</strong>` const isStrong = i > 0 && delimiters[i - 1].end === startDelim.end + 1 && delimiters[i - 1].marker === startDelim.marker && delimiters[i - 1].token === startDelim.token - 1 && delimiters[startDelim.end + 1].token === endDelim.token + 1当两个同标记的em配对在相邻 token 上时,直接合并为strong_open/strong_close(markup变为双标记),并清空中间 token 内容。因此本样本中__this __is __a __bunch__ of__ nested__ emphases__这类双下划线嵌套会被解析为strong结构,而***三段会先按 Rule of 3 判定优先级再决定生成em+strong的组合。
五、病态输入防护:测试如何保证正确性
嵌套强调是路径病态输入(pathological input)的高发区,仓库在 test/markdown-it/pathological.test.mjs 中专门守护:
nested inlines:'*'.repeat(60000) + 'a' + '*'.repeat(60000),6 万层嵌套的开放性测试;nested strong emph:'*a **a '.repeat(5000) + ...,交替嵌套的 em/strong 压力测试;mismatched openers and closers:'*a_ '.repeat(50000),开放/闭合错配;emphasis **_* pattern(markdown-it 自研用例):'**_* '.repeat(50000),星号与下划线混用。
这些用例通过 pathological.test.mjs 的test_pattern在独立 Worker 线程中渲染,5 秒超时即判定失败,从工程上保证了嵌套强调解析不会退化为指数级回溯。
六、用基准工具实测嵌套强调性能
6.1 样本与实现的组织方式
benchmark/benchmark.mjs启动时会自动扫描两个目录:
- benchmark/samples:全部
.md样本(含inline-em-nested.md),每个样本包装成一个tinybench基准任务; - benchmark/implementations:全部实现(如
current、commonmark-reference、marked等),每个实现导出一个run(data)函数。
其中 implementations/current/index.mjs 以html: true, linkify: true, typographer: true的完整配置运行当前仓库的 markdown-it,代表本项目在"全功能开启"下的真实表现。
6.2 运行命令与结果解读
先安装依赖(基准依赖tinybench属于 devDependencies):
npm install然后按名称过滤样本运行(process.argv中的参数会被当作大小写不敏感的正则,见 benchmark.mjs):
node benchmark/benchmark.mjs inline-em-nested输出形式类似(以 README 中的spec样本为格式参考):
Selected samples: (1 of 27) > inline-em-nested Sample: inline-em-nested.md (158 bytes) > current x ... ops/sec ±...% (... samples sampled) > commonmark-reference x ... ops/sec ...- 每个实现的
ops/sec即每秒可解析的次数,±%是相对标准误差(RME); - 不传参数则依次跑完全部样本;传多个正则可同时对比多组样本;
- 如需对比,可运行
node benchmark/benchmark.mjs inline-em一次覆盖flat/nested/worst三档样本,观察嵌套深度与回溯构造对吞吐量的实际影响。
需要说明的是:本仓库当前未安装node_modules时直接运行会报Cannot find package 'tinybench',因此请先执行npm install;实际数值依运行机器 CPU 与 Node 版本而定,本仓库只保证算法复杂度的线性上界,不承诺任何具体 ops/sec 数值。
七、小结
inline-em-nested.md虽只有三行,却浓缩了 markdown-it 行内强调解析的三大设计要点:
- 分隔符栈 + 单遍配对:分词阶段把标记拆为独立栈元素,配对阶段用 jumps 与 openersBottom 保证线性复杂度;
- Rule of 3 与左右翼规则:精确复刻 CommonMark 规范,正确处理"既可开又可关"的嵌套歧义;
- em/strong 合并优化:相邻同标记自动合并为
<strong>,输出更紧凑的 HTML。
配合 benchmark/benchmark.mjs 与 pathological.test.mjs,你可以对任意嵌套强调写法既验证正确性、又量化性能,这也是评估任何 markdown-it 强调类插件(如自定义 strikethrough)时值得沿用的完整流程。
- 开发工具
- CLI
【免费下载链接】markdown-it
Markdown parser, done right. 100% CommonMark support, extensions, syntax plugins & high speed
相关推荐
markdown-it 深层嵌套列表基准样本剖析:block-list-nested.md 的用例设计与列表解析原理
markdown it 深层嵌套列表基准样本剖析:block list nested.md 的用例设计与列表解析原理 导读 :本文以 benchmark/sam
开发工具CLIeslint-plugin-unicorn max-nested-calls 规则深度解析:嵌套调用深度限制的原理、配置与测试快照验证
eslint plugin unicorn max nested calls 规则深度解析:嵌套调用深度限制的原理、配置与测试快照验证 本篇文章以 eslint
Lint代码质量markdown-it 基准测试工具使用:量化解析性能的实用指南
markdown it 基准测试工具使用:量化解析性能的实用指南 1. 基准测试工具概述 markdown it 作为一款高性能的 Markdown 解析器(P
开发工具CLI
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考