资讯动态

marked 相邻列表解析原理解读:无序列表与有序列表如何各自独立成块

发布时间:2026/9/19 16:39:30 来源:尧图企业网站定制
marked 相邻列表解析原理解读无序列表与有序列表如何各自独立成块【免费下载链接】markedA markdown parser and compiler. Built for speed.项目地址: https://gitcode.com/gh_mirrors/ma/marked本篇技术指南围绕 marked 仓库中的规范测试用例test/specs/new/adjacent_lists展开通过剖析词法分析器Lexer与分词器Tokenizer对相邻的无序列表 有序列表的完整处理链路解释为什么*开头的列表与1.开头的列表会被识别为两个独立的块级元素并分别渲染为ul与ol。读完本文你将掌握 marked 列表标记bullet的身份固定机制、列表项匹配器的边界判定逻辑以及空行、pedantic 模式在列表拆分中的实际作用。一、测试用例速览四行输入两个列表块该用例由一对文件组成位于test/specs/new/目录下输入文件与期望输出文件同名配对这正是 marked 规范测试spec test的标准组织方式。输入内容test/specs/new/adjacent_lists.md* This should be * An unordered list 1. This should be 2. An unordered list期望输出test/specs/new/adjacent_lists.htmlul liThis should be/li liAn unordered list/li /ul ol liThis should be/li liAn ordered list/li /ol从这组配对可以看出本用例验证的核心行为输入包含两个列表一个*标记、含两个列表项的无序列表以及一个1.标记、含两个列表项的有序列表两者之间以空行分隔期望输出是两个完全独立的块级元素ul与ol既没有发生两个列表被错误合并成一个列表也没有发生第二个列表被吞并进第一个列表的列表项文本的情况。该用例由 test/run-spec-tests.js 统一调度脚本通过markedjs/testutils的getTests加载commonmark、gfm、new、original、redos五个目录下的全部配对文件再调用runTests逐一对每个用例执行parse将结果与.html期望文件比对见 test/run-spec-tests.js。new目录存放的是 marked 针对自身语义扩展与边界场景新增的用例不直接取自 CommonMark 官方套件因此adjacent_lists这类用例是理解 marked 实现细节的第一手资料。二、为什么这值得一个专门测试列表的标记身份语义在 CommonMark 语义下一个列表本质上是一组标记类型一致的连续列表项。标记类型包括无序类*、-、与有序类1.、1)等数字加点或右括号。当文本中出现标记类型的切换——例如从*切换到1.——解析器应当结束当前列表并以新标记开启一个新的列表块。marked 的块级语法规则在 src/rules.ts 中定义const list edit(/^(bull)([ \t][^\n]*?)?(?:\n|$)/) .replace(/bull/g, bullet) .getRegex();这里的bull是列表起始标记的抽象占位最终会替换为实际的标记正则。此外列表还参与段落打断paragraph interrupt判定src/rules.ts 中定义了顶层段落仅能被非空且从 1 开始的列表打断{0,3}(?:[*-]|1[.)])[ \t][^ \t\n]而 blockquote 内部则放宽为任意数字标记。这意味着列表是否成立、在哪里终结直接影响后续段落的归属因此标记切换必须产生新列表这一边界行为值得用专门用例锁定。三、源码级拆解list() 如何把相邻列表拆开3.1 词法入口块级主循环的顺序判定marked 的块级词法分析在Lexer.blockTokens中按固定顺序尝试各类型 token。当一段文本既不是代码块、fences、heading也不是 blockquote 时会命中列表分支src/Lexer.ts// list if (token this.tokenizer.list(src)) { src src.substring(token.raw.length); tokens.push(token); continue; }关键在于每次list()成功返回后src会被裁剪掉该 token 的原始文本随后continue回到循环开头重新判定剩余文本。正是这个裁剪 → 重新判定的主循环保证了相邻列表能够依次各生成一个独立 token。3.2 标记固定第一个列表项决定整个列表的身份Tokenizer.list()是拆解相邻列表的核心实现src/Tokenizer.tslist(src: string): Tokens.List | undefined { let cap this.rules.block.list.exec(src); if (cap) { let bull cap[1].trim(); const isordered bull.length 1; const list: Tokens.List { type: list, raw: , ordered: isordered, start: isordered ? bull.slice(0, -1) : , loose: false, items: [], }; bull isordered ? \\d{1,9}\\${bull.slice(-1)} : \\${bull}; ...这里有三点值得注意bull取自第一个列表项的标记后续整个列表的所有列表项都围绕它进行匹配isordered通过bull.length 1判定*、-、这类单字符标记是无序列表1.这类多字符标记是有序列表有序列表的start字段会保留起始数字此处为1渲染时对应ol start...的能力start为 1 时通常省略同时bull会被泛化为\d{1,9}[.)]即任意 19 位数字加点或右括号都能匹配有序列表项。3.3 列表项匹配器只认自家标记list()随后用一个基于bull动态生成的itemRegex在while循环中逐个匹配列表项。该正则定义于 src/rules.tslistItemRegex: (bull: string) new RegExp(^( {0,3}${bull})((?:[\t ][^\n]*)?(?:\n|$))),对adjacent_lists用例而言第一个列表的bull被固定为\*因此itemRegex只匹配以*开头允许 03 个前导空格的行。在 src/Tokenizer.ts 的列表项循环中while (src) { ... if (!(cap itemRegex.exec(src))) { break; } ...当源文本推进到1. This should be时itemRegex\*匹配失败while循环立即break第一个列表宣告结束。这就是不同标记的相邻列表被拆开的第一道闸门列表项的追加只认与第一个标记同类的行。3.4 内层行循环防止不同标记变成惰性续行list()内部还有一个处理当前列表项后续行的内层循环src/Tokenizer.ts它会依次检查后续行是否命中 fences、heading、html、blockquote、新 bullet 或水平线等列表项终结条件。其中新 bullet 的判定正则定义于 src/rules.tsnextBulletRegex: cachedIndentRegex((indent: number) new RegExp(^ {0,${indent}}(?:[*-]|\\d{1,9}[.)])((?:[ \t][^\n]*)?(?:\n|$)))),注意nextBulletRegex同时覆盖无序[*-]与有序\d{1,9}[.)]两类标记。因此在处理* This should be这个列表项时紧随其后的1. This should be行会在此处命中并breaksrc/Tokenizer.ts从而绝不会被当作第一个列表项的惰性续行lazy continuation文本吞并。这道检查与 3.3 节的itemRegex检查形成双重保险内层行循环先把不同标记挡在列表项之外外层列表项循环再因itemRegex不匹配而结束整个列表。3.5 列表收尾与主循环续解析一个列表结束时list()会对原始文本做收尾处理src/Tokenizer.ts最后一个列表项的raw与text会被trimEnd()列表整体的raw同样被修剪避免把结尾空行吞进 token源码注释也明确说明不在最终列表项末尾消费换行。回到Lexer.blockTokens主循环src裁剪掉第一个列表的raw后剩余文本恰好是1. This should be\n2. An unordered list。主循环重新走一遍判定顺序this.tokenizer.list(src)再次命中生成一个ordered: true的列表 token。两个 token 依次进入 tokens 数组最终渲染为adjacent_lists.html中期望的ul与ol两个独立块。3.6 渲染端的呼应列表 token 在 src/Tokenizer.ts 中携带了ordered是否有序与start起始序号字段渲染器据此决定输出ul还是ol。从测试期望输出可以看到第一个 tokenordered: false渲染为ul第二个 tokenordered: true、start: 1渲染为ol两个块之间以空行分隔与输入的排版一一对应。四、空行在相邻列表中的角色adjacent_lists的输入在两个列表之间放置了空行这是理解列表合并/拆分行为的另一把钥匙。结合 src/Tokenizer.ts 的内层行循环可以推断同标记 空行 合并为同一列表空行会被内层循环作为去缩进分支吞入!nextLine.trim()分支随后源文本继续推进若下一行仍是同一标记外层itemRegex依然匹配于是新列表项追加进原列表。此时空行只影响loose属性的判定src/Tokenizer.ts 及 src/Tokenizer.ts 通过spacetoken 与anyLine正则判断列表是否为宽松列表。这正是test/specs/new/list_loose.md等用例验证的场景。不同标记 空行 拆分为两个列表即使中间没有空行nextBulletRegex也会让1.行终结前一个列表项见 3.4 节空行的存在则进一步保证了两个列表块在输出 HTML 中由空行清晰分隔且不会因为空行而触发任何跨列表合并的逻辑。换言之空行对标记切换 → 新列表这一规则并非必要条件但它是书写者表达两个列表是独立块的最直观手段而空行 同标记才会触发合并与 loose 语义两者不可混淆。五、pedantic 模式的例外same_bullet 对照adjacent_lists验证的是 marked 默认CommonMark语义下的行为。若切换到 pedantic 模式兼容早期 Markdown 规范无序列表的标记判定会发生显著变化。在 src/Tokenizer.ts 中if (this.options.pedantic) { bull isordered ? bull : [*-]; }pedantic 模式下无序列表的bull从固定第一个标记字符放宽为字符类[*-]意味着*、、-三种标记会被视为同一个列表。仓库中 test/specs/new/same_bullet.md 正是这一行为的测试用例--- pedantic: true --- * test test - test其期望输出是一个包含三个li的单一ul。对照可见默认模式下*与1.的切换产生两个列表adjacent_lists的语义而 pedantic 模式下无序标记内部的切换反而合并为一个列表。这种默认严格、pedantic 宽松的差异正是adjacent_lists用例需要被单独锁定的原因——任何对标记泛化逻辑的修改都可能破坏相邻列表的拆分。六、实战验证与写作建议6.1 运行该用例该用例随 marked 的规范测试套件一起执行。先构建出测试所需的lib/产物test/run-spec-tests.js 从../lib/marked.esm.js导入构建产物再运行npm run build npm run test:specs其中test:specs定义于 package.json对应node --test --test-reporterspec test/run-spec-tests.js若只想跑规范与单元测试可使用npm run test:only。测试通过时adjacent_lists用例的输出即与 test/specs/new/adjacent_lists.html 完全一致。6.2 用 API 直接验证也可以在构建完成后用 marked 的公开 API 单独解析这段输入观察输出const { Marked } require(./lib/marked.cjs); const md * This should be\n* An unordered list\n\n1. This should be\n2. An unordered list; console.log(new Marked().parse(md));输出应为两个相邻的ul与ol块与期望文件一致若传入{ pedantic: true }再解析* test\n test\n- test则会得到单一ul直观对比两种模式下的列表语义差异。6.3 编写相邻列表的实用建议结合本用例与源码行为在 markdown 中书写相邻列表时建议不同类型*与1.的列表之间保留空行即使不保留空行解析器也会拆分但空行使文档更清晰且输出 HTML 中两个块之间会以空行分隔同类型列表若要表达两个独立块仅靠空行是不够的——同标记 空行会被合并为同一个可能是 loose 的列表此时应改用不同标记类型或用标题、引用、围栏代码块等块级元素阻断不要依赖某个具体数字2.、3.等有序列表项同样受\d{1,9}[.)]匹配与无序列表相邻时同样会被拆分adjacent_lists的机制对所有有序标记一视同仁。小结adjacent_lists是一个看似简单、实则直击 marked 列表解析核心边界的规范测试它锁定了标记切换必然终结当前列表这一 CommonMark 语义。从 src/Lexer.ts 的主循环裁剪续解析到 src/Tokenizer.ts 的bull固定与 src/rules.ts 的双正则判定再到 pedantic 模式下 src/Tokenizer.ts 的标记泛化整条链路展示了 marked 如何在同标记合并与异标记拆分之间保持精确平衡。理解这一机制有助于你在自定义扩展、选项调优或排查列表渲染异常时快速定位问题根源。【免费下载链接】markedA markdown parser and compiler. Built for speed.项目地址: https://gitcode.com/gh_mirrors/ma/marked创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价