mdom 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2c46bd566d0a5adabb0b78754b949f0bed6dfa1bf1e1dd09b03a0d3f36ab1e63
4
- data.tar.gz: 563befb0a98299ded7d5036d2ce18681a2433bba8a7f213175242c3dd206fa04
3
+ metadata.gz: d730da5c27658d1147ed74e4b371ee3244c1172fc72779441a1374f159c9a94a
4
+ data.tar.gz: d52ba53cb04bd5c41c1fe8c6007031709c88650f4b9469e3f0daccaadd131d88
5
5
  SHA512:
6
- metadata.gz: 92de7744eea2e3304013ea24576bdd718df6ee63ce251ce03dce5e675754fca90f6f2f9228e5018d4c830da79d460e018ea9c32ba04603814af1275a5608aa60
7
- data.tar.gz: 643519a175794000d681d1efad3cc7a684b6aa8be4983054a0384f16e842942399e1b13c7ec6a63b195cdd72b7fe8db7e0a8786453d9bd26d29f8362ed36b2a0
6
+ metadata.gz: 2ca7c37e09668a3f6b9e2db4c31129daa7d24221c9f98391ef4a54ecaa284dc6abc8e9421d0d0f4404ee9677ae801af5bfd3e37b1b80c583350035cccad0af05
7
+ data.tar.gz: 3f3f00a30b22419db135255f0135a2d2d3e5bac5e96c85a16563fa401f96ed6eb32d76a2ec2f201b5c592de0dda35ee5b41632514fd366099efad1cce9807503
data/CHANGELOG.md CHANGED
@@ -1,4 +1,168 @@
1
- ## [Unreleased]
1
+ ## [0.4.0] - 2026-10-30
2
+
3
+ ### Changed
4
+
5
+ - **A 类自由块的候选行开头扩展(C1-min)**:除 `[A-Za-z]` 外,`(`、`[`、`{`、`%`、`_`、
6
+ `::` 开头的行也进入 Ruby 候选。`[[` 优先按 WikiLink 处理,不进入 Ruby 候选;因此
7
+ `[[target]].each do ... end` 仍是 WikiLink + 文本。`free_blocks_allowed` 的 `String`
8
+ 白名单仍只匹配行首标识符,`(` / `[` / `{` / `%` / `::` 需要使用 `Regexp`。同步更新
9
+ CSM §3.5、IMPLEMENTATION §2.2.1/§2.2.4,并给 Markdown 回写补上 `(` / `{` / `%`
10
+ 的行首转义。
11
+ - **自由块判定的性能**:`BlockDetector` 改为惰性缩进边界 + 前 4 行无条件探针 +
12
+ 闭合候选行分治。`docs/*.md`(217 KB)的默认 `Parser.parse` 从约 8.1 s 降到约
13
+ 0.08 s,Prism 调用从约 1900 次降到约 41 次,`FREE_BLOCKS=1 benchmark/linear.rb`
14
+ 的单位 KB 耗时基本恒定。判定结果不变:现有测试、free-block 对拍语料与
15
+ `benchmark/equivalence.rb` 均保持一致。
16
+ - **行首 `\` 只跳过块级判定**(CSM §3.11):该行不会被识别为围栏/表格/公式/Ruby
17
+ 嵌入/标题/列表/引用等块级构造。`\` 后若是行内可转义字符(`*`、`` ` ``、`#`、`|`
18
+ 等),保留给行内解析(`\*` 是字面 `*`);否则剥离(`\while` → `while`,
19
+ `\begin` → `begin`)。修复了上一版把 `\while` 渲染成带可见反斜杠文本的问题。
20
+ - **表格的 HTML 输出改用 `<thead>` / `<tbody>`**(CSM §1.12):开头的连续表头行包进
21
+ `<thead>`,其余行包进 `<tbody>`;**零数据行时不存在 `<tbody>`**(与 GFM 一致)。
22
+ 分隔线可以把相邻的**任意**一行提升为表头,而 HTML 只允许 `<thead>` 位于表格最前——
23
+ 所以出现在中途的表头行留在 `<tbody>` 里(仍输出 `<th>`),行顺序不被重排。此前所有行
24
+ 都平铺成裸露的 `<tr>`:虽然合法(HTML 解析器会隐式补 `<tbody>`),但样式与脚本无法
25
+ 直接选中表头区。文档同步:CSM §1.12(示例 + HTML 输出契约)、IMPLEMENTATION §7 与
26
+ 附录 I、USAGE §14.3。测试:`html_serializer_test` 新增无表头 / 仅表头 / 连续表头 /
27
+ 中途表头四个用例。
28
+ - **翻译产物改为结构化**:行内内容不再回吐成 CSM 标记文本,而是逐条翻译成 builder 行内
29
+ 方法(`builder.text` / `builder.strong` / `builder.link`……),行内容器在产物里是
30
+ `do ... end` 块(`builder.paragraph do ... end`)。因此**执行产物不需要 `InlineParser`、
31
+ 也不需要 prism**——「排版部分的产物保证为 mruby 子集」(CSM §3.1) 在行内层才真正成立:
32
+ 此前只要文档里有段落,产物就会在运行时调用 `InlineParser` 重新解析。
33
+ - `builder.rb` 补齐行内方法:`text` / `del` / `ins` / `inline_quote` / `wiki_link` /
34
+ `mention` / `meter` / `progress` / `math` / `interpolation`;`header_cell` / `cell`
35
+ 与其它 PHRASING 容器一致,接受**节点参数与块**(此前只收字符串,与
36
+ `PHRASING_CONTAINERS` 里「单元格是能收行内节点的容器」的声明矛盾)。
37
+ - 插值改走嵌入通道:`unsafe: true` 时是 `builder.interpolation { expr }`(表达式交
38
+ Ruby 求值,结果按 §3.3 的分派表插入);安全模式是 `builder.interpolation("expr")`,
39
+ 构造**未求值**的插值节点。
40
+ - 块形式构造的行内容器用 `MDOM::MarkdownSerializer` 把子节点重建进
41
+ `attributes[:text]`,所以 `paragraph.text` / `blockquote.cite` 这类读取对程序化构造
42
+ 的节点照常可用(值是**重建的** CSM 源,不再逐字等于输入原文)。builder 因此
43
+ `require` 同为零 gem 依赖的 `markdown_serializer`。
44
+ - 翻译器只剩**一层转义**(Ruby 双引号字面量);为「让 `InlineParser` 把回吐的文本
45
+ 当字面」而设的 CSM 标记转义(`escape_markup` / `MARKUP_CHARS`)随之删除。字面 `#{`
46
+ 与真插值的区分现在完全在解析期完成(`Text` vs `Interpolation`)。
47
+ - 唯一仍走字符串参数(因而需要 `InlineParser`)的形态是 `inline: false` 解析出的
48
+ AST——树里只有原文、没有行内节点。
49
+ - 代价:产物显著变长(每个段落至少 3 行)。这是有意的取舍——换取「产物可否在 mruby
50
+ 上跑」可静态判断。
51
+ 测试:`translator_test` 新增「产物不依赖 `InlineParser`」(把 `MDOM::InlineParser` 换成
52
+ 会爆的桩后执行产物)与 `inline: false` 回落用例;`builder_test` 新增结构化行内方法与
53
+ 单元格块形式用例。文档同步:CSM §3.1/§3.13、IMPLEMENTATION §3.1/§3.2/§3.3/§4.1/§6.3、
54
+ README、USAGE。
55
+ - 解析性能:行内解析与表格单元格切分改用 Ruby 标准库的 `StringScanner`(`strscan`)
56
+ 扫描,取代 Ruby 层的逐字符 `while` 循环(docs/IMPLEMENTATION.md §2.4)。
57
+ 主循环先用一条正则成段收下普通字面,只在标记字符上分派;查配对定界符
58
+ (`` ` ``/`*`/`[]`/`()`)与转义序列也走正则搜索。实测(4 篇 `docs/*.md`、172 KB、
59
+ `free_blocks: false`):`MDOM::Parser.parse` 每轮 0.067s → 0.041s(-39%),其中
60
+ `InlineParser` 单独计时 -52%、`Tokenizer` -30%。AST 输出与改造前**逐节点一致**
61
+ (对拍 `docs/*.md` 全文 + 边界样例共 3988 条:行内节点树与 token 序列 0 差异;
62
+ `split_cells` 另对拍 23991 例,0 差异)。`strscan` 是 default gem,只在解析侧
63
+ 使用,builder 侧的 mruby 子集约束不受影响。
64
+ - 同一轮改造的收尾(都是「少分配 / 少扫描」,逐条可测,见 docs/IMPLEMENTATION.md §2.4):
65
+ - 查找改用 `StringScanner#exist?` 而不是 `skip_until`——后者会把「跳过的整段」建成
66
+ 字符串返回,而这里只需要位置;
67
+ - 「提及」的正文匹配改为就地 `scan`(原先每次分配一份「剩余文本」);
68
+ - `matching_bracket` / `matching_paren` 增加**乐观预筛**,修掉了**预先存在**的 O(n²)
69
+ 退化:`"[(" * 5000` 1.55s → 0.003s。预筛是两级的——先用 C 层的 `include?` 排除
70
+ 「整段根本没有 `]` / `)`」(一次、极廉),只有 ≥ 4 KB 的文本才进一步建「最后一个
71
+ 位置」的索引,因此短行内段(绝大多数)不付这个固定成本。
72
+ 过程中的一次试错:曾让主循环维护「上一个字符」(`@prev_char`)以省去按需取字符的
73
+ 分配,实测在常规文档上是**净亏**(`InlineParser#parse` self 0.410s → 0.524s、
74
+ `String#[]` 调用 +29%)——已回滚,改回按需 `char_before`。这也说明这类微观改动必须逐条
75
+ 量过再留。
76
+ 净效果(交替 A/B、各取 min,同一语料):`parse` 0.0642s → 0.0592s、`to_markdown`
77
+ 0.0839s → 0.0777s,`InlineParser` 单独计时 +7%(预筛的固定成本,换来上述 O(n²) 修复)。
78
+ - `Node::Predicates#ancestor_of?` 改为沿父链走,不再先建一份 `ancestors` 数组:挂载
79
+ 路径上它调用极频繁(每次 `<<` / `prepend` 都要查一次环),而答案通常在前一两跳就
80
+ 确定。实测 `parse` **−8%**(4 轮交替 A/B);`sync_depth!` 里的 `children.each(&:...)`
81
+ 同时改成显式块,少一层间接调用。
82
+ 量过但**未采纳**:`resolve_inline` 的「批量前插」只能省 4%,代价是给 `node.rb` 开一个
83
+ 「跳过环检查、由调用方自己保证」的内部后门——与「所有状态改写都由节点自己在公开方法
84
+ 里完成」的既有契约冲突,不值当。
85
+ - 行处理去掉每行的多余分配(实测 tokenize −9%、整体 parse −7%,4 轮交替 A/B):
86
+ `normalize` 在没有 `\r` 时不再跑一遍全量 `gsub`;`expand_leading_tabs` 对不含制表符的
87
+ 行直接返回;`indent_width` 改用 `index(/[^ \t]/)`(少一次子串分配,`BlockDetector`
88
+ 里同名的辅助同步);`drop_indent` 在缩进为 0 时短路。
89
+ - `link` / `image` 也纳入同一套预筛:`[` 之后必须存在「未被转义、且**后面紧跟 `(`**」的
90
+ `]`,否则不可能是链接/图片。只靠「有没有 `]`」拦不住 `"[(" * 2000 + ")]" * 2000`
91
+ 这类输入(`]` 很多,但没有一个成链接)——它从 0.64s 降到毫秒级。实测这一层在常规
92
+ 文档上**零成本**(交替 A/B 持平)。
93
+
94
+ ### Added
95
+
96
+ - `benchmark/`:性能与回归工具。零 gem 依赖,语料**确定**(`docs/*.md` + 边界样例 +
97
+ 固定种子的随机串),所以「能测能退」在仓库里可复现:
98
+ - `bench.rb` 分项基准(解析 / 行内 / 分类 / 序列化)、`linear.rb` 线性度、
99
+ `pathological.rb` 病态输入体检(超阈值退出码 1)、`equivalence.rb` 与某个 revision
100
+ 对拍解析输出(行内节点树 + Token 序列逐行比较)、`snapshot.rb` 供对拍;
101
+ - 对应的 rake 任务 `bench` / `linear` / `pathological` / `equivalence`(**不进**默认
102
+ 任务,它们要跑几十秒到几分钟);
103
+ - 用法与「为什么取最小值、为什么等价性先于速度」见 `benchmark/README.md`。
104
+ - A 类(自由块)判定的**性能闸门**(CSM §3.5,docs/IMPLEMENTATION.md §2.2.4)。
105
+ 自由块没有引导关键字,只能靠「行首是字母」进入 Prism 判定,而英文散文里这样的行
106
+ 比比皆是——一篇普通英文文档会产生几百到上万次 Prism 调用。现在 `MDOM::Parser.parse`
107
+ / `MDOM.parse` / `MDOM.compile(parser: …)` 接受两个选项:
108
+ - `free_blocks: false` —— 不做任何 A 类判定,命中行按排版处理(CSM §3.5.3,**不报
109
+ 错**);B 类(引导关键字)与 `~` 行**不受影响**;
110
+ - `free_blocks_allowed: [...]` —— 白名单,只对**行首标识符命中**的行尝试 A 类判定:
111
+ `String` 元素精确相等(`"list"` 命中 `list.each`,不命中 `lists.each`),`Regexp`
112
+ 元素对已剥离 CSM 缩进的整行做 `match?`(锚定与否由 `\A` 写法决定);`[]` 等价于
113
+ `free_blocks: false`。
114
+ 门控发生在**碰 Prism 之前**(`BlockDetector#candidate?` 供 Tokenizer 预筛),未命中
115
+ 的行零次 Prism 调用。配置落在判定器上,因此 `detector:` 与这两个选项**互斥**——同时
116
+ 给出会抛 `ArgumentError`。代价:被挡下的行也不再触发 `CrossLevelError` 这类缩进校验
117
+ (它属于「已判定为块」之后的检查)。
118
+
119
+ ### Changed
120
+
121
+ - heredoc(`<<~SQL` 等)在**块内**不再被拒绝:它本身就位于 Ruby 代码中,**自然可用**
122
+ (CSM §3.12)。仍然**不能单独成块**——单独的起始行既不是引导块关键字,也不构成
123
+ `CallNode → BlockNode`,按排版处理(§3.5.3);但 body 要随块一起缩进,推荐 Squiggly
124
+ Heredoc(`<<~`),它剥掉公共缩进,因此对齐缩进不会改变字符串内容。
125
+
126
+ ### Removed
127
+
128
+ - `MDOM::UnsupportedHeredocError`:块内 heredoc 不再报错,该错误类因此没有消费者
129
+ (CSM §3.12 的规格已从「不支持 heredoc」改为「heredoc 不能单独成块」)。
130
+
131
+ ### Fixed
132
+
133
+ - 自由块(A 类)现在要求 `Prism::CallNode#message_loc` 非空:错误恢复可能把
134
+ `I do this till end.` 这类散文解析成 message_loc 长度为 0 的调用,以前在后续行
135
+ 出现 `end` 时可能被误判为 Ruby 块;`foo.() do ... end` 这种无方法名的调用也不再
136
+ 算自由块。
137
+ - 分治闭合搜索修正:块已闭合、但后续散文仍是一个未完成构造时,整段 parse 会报
138
+ incomplete;现在检查**第一个顶层语句**是否闭合,避免向右误跳过真正的块尾。
139
+ - `Translator#inline: false` 的产物在带关键字参数的节点上生成了非法 Ruby:
140
+ `builder.heading(2, closed: true, "标题")` / `builder.header_cell(align: :left, "x")`。
141
+ 文本现在插在关键字参数之前。
142
+ - `MarkdownSerializer` 的表格保真:
143
+ - 单元格里的字面 `|` 现在会转义成奇数个 `\` + `|`,不再被切成新单元格;
144
+ - 连续表头、中途表头与“无表头但带对齐”的表格现在逐行输出分隔行,`th` / `td`
145
+ 与 `align` 都能往返;
146
+ - 文本开头的 `#` / `>` / `-` / `:` / `|` / `__END__`、有序列表的 `N.`,以及行内的
147
+ `<` / `@` / `!` 都补进了保守转义,回写后不会被重新解析成别的节点。
148
+ - `Builder` 的任务列表:`list(task: true)` 的项目现在继承任务状态;不带块的
149
+ `list("a", task: true)` 也会生成任务项;`l.task` 会把普通列表提升为任务列表。
150
+ 翻译器对任务列表中的普通项显式输出 `task: false`。
151
+ - `Object#render`:签名从 `(**options)` 改为 `(value = self, **options)`——**给了位置
152
+ 参数就用它,否则用接收者**。CSM §3.3 写的「块内直接调用 `render(value)`」(如
153
+ `render "- #{item}", parse: true`)以前会 `ArgumentError: wrong number of arguments
154
+ (given 1, expected 0)`,现在与 `值.render` 等价;`render(nil)` 仍是「渲染 nil」,
155
+ 多余的位置参数由 Ruby 报错。
156
+ - 块判定:**空行不再中断** Ruby 嵌入块(`begin...end` / 自由块)的增量 parse——
157
+ 块内的空行是 Ruby 的普通空行,收尾交由 prism 决定(CSM §1.1、§3.5.4)。
158
+ 为此增量过程补了早停:prism 报行内硬语法错误、或已解析出两条以上顶层语句时
159
+ 就地收手,避免散文候选把范围拖到文末(不做早停时 `docs/CSM.md` 全文要
160
+ 11264 次 prism 调用、20 MB 源码;实际只需 19 次)。
161
+ - `BlockDetector#covers?`:prism 的行内列号按**字节**计,原来拿字符长度比,
162
+ 末行含 CJK 时会误判为「未覆盖整段」。
163
+ - `test/parser_test.rb` 的 `test_blank_line_in_ruby_block` 断言写成了 Tokenizer 的
164
+ token 类型 `:ruby_block`(节点类型是 `:ruby_embed`),改为按节点断言并补齐
165
+ `form` / `lines` / `source`。
2
166
 
3
167
  ## [0.3.0] - 2026-09-26
4
168
 
data/README.md CHANGED
@@ -44,7 +44,7 @@ CSM 源 ───────────> AST1 ──────────
44
44
  | 模块 | 职责 | 状态 |
45
45
  |------|------|------|
46
46
  | `lib/mdom/tokenizer.rb` | 逐行分类:围栏 → 表格 → 块级公式 → `\` 转义 → `~` → 候选嵌入 → 排版 | ✅ |
47
- | `lib/mdom/block_detector.rb` | 用 Prism 增量 parse 判定 Ruby 块(A/B 两类) | ✅ |
47
+ | `lib/mdom/block_detector.rb` | 用 Prism 增量 parse 判定 Ruby 块(A/B 两类);A 类的候选资格可关、可白名单(`free_blocks:` / `free_blocks_allowed:`) | ✅ |
48
48
  | `lib/mdom/parser.rb` | Token Stream → AST1(按 CSM 缩进建层级;按内容策略区分 FLOW / PHRASING 容器) | ✅ |
49
49
  | `lib/mdom/inline_parser.rb` | 行内解析:强调/代码/链接/增删/进度条/公式/插值/转义/换行 | ✅ |
50
50
  | `lib/mdom/node.rb`、`node/nodes.rb`、`node/inline_nodes.rb` | 块级与行内节点定义、树操作(挂载/遍历/导航/深度) | ✅ |
@@ -59,7 +59,7 @@ CSM 源 ───────────> AST1 ──────────
59
59
 
60
60
  `require "mdom"` 是一个可用入口:`MDOM.parse` 给 AST1,`MDOM.compile` 走完 (1)(2)(6) 并在五个点上触发钩子,返回 `MDOM::Compiled`(`document` / `ruby` / `html` / `meta`)。回写 CSM 用 `node.to_markdown`(≡ `MDOM::MarkdownSerializer.serialize`)——AST1 保真往返,AST2 只能降级。旧版 `MDOM.serialize` 未恢复,`MDOM.compile` 也暂不产出 Markdown。
61
61
 
62
- `rake`(默认任务 = `test` + `rubocop`)全绿:343 例、0 skip、32 个文件无 rubocop 告警。指标类 cop 在 `.rubocop.yml` 里关掉了,理由见该文件的注释。
62
+ `rake`(默认任务 = `test` + `rubocop`)全绿:409 例、0 skip,`lib/` 与 `test/` 共 29 个文件无 rubocop 告警。指标类 cop 在 `.rubocop.yml` 里关掉了,理由见该文件的注释。
63
63
 
64
64
  ## 用法
65
65
 
@@ -134,16 +134,38 @@ MDOM::Parser.parse(" list.each do |x|\n y\n end\n")
134
134
  # but closes with 2 (line 3)
135
135
  ```
136
136
 
137
+ **A 类(自由块)的判定最贵**:它没有引导关键字,只能靠「行首是字母」进入 Prism 判定,
138
+ 而英文散文里这样的行比比皆是。所以它可关、可白名单——被挡下的行按排版处理(同样是
139
+ §3.5.3,不报错),B 类(引导关键字)与 `~` 行不受影响:
140
+
141
+ ```ruby
142
+ src = "list.each do |x|\n y\nend\n"
143
+
144
+ MDOM::Parser.parse(src, free_blocks: false).children.map(&:type)
145
+ # => [:paragraph, :paragraph, :paragraph]
146
+
147
+ MDOM::Parser.parse(src, free_blocks_allowed: ["list"]).children.map(&:type) # => [:ruby_embed]
148
+ MDOM::Parser.parse(src, free_blocks_allowed: [/^\w+\.each\b/]).children.map(&:type)
149
+ ```
150
+
151
+ 开关与白名单也走总入口:`MDOM.parse(src, free_blocks: false)`、
152
+ `MDOM.compile(src, parser: { free_blocks_allowed: ["list"] })`。
153
+
137
154
  ### 翻译为 Ruby
138
155
 
139
156
  `MDOM::Translator` 实现 (2):把 AST1 翻成一段顶层顺序执行的 Ruby 程序。排版节点
140
157
  变成 `builder.*` 调用——产物不绑定执行方式,`eval`、落盘 `require`、交给 mruby
141
158
  都行。
142
159
 
160
+ **行内内容是结构化的**:段落、标题、单元格这些行内容器在产物里是 `do ... end` 块,
161
+ 每个行内子节点对应一个 builder 行内方法(`text` / `strong` / `link`……)。产物因此
162
+ **不经过 `InlineParser`**,也不需要 prism,排版段真的落在 mruby 子集里(CSM §3.1)。
163
+
143
164
  **`unsafe` 是翻译期参数,默认安全**(CSM §3.14)。`to_ruby(node)` 默认
144
- `unsafe: false`:嵌入段**不写进产物**——块级嵌入只留一行注释占位,行内插值退化为
145
- 字面 `#{expr}`,因此产物静态上不含用户代码。`to_ruby(node, unsafe: true)` 才把
146
- 嵌入块(`begin...end` / `~` 行)原样注入、把行内插值交 Ruby 求值。
165
+ `unsafe: false`:嵌入段**不写进产物**——块级嵌入只留一行注释占位,行内插值变成
166
+ `builder.interpolation("expr")`(未求值的插值节点),因此产物静态上不含用户代码。
167
+ `to_ruby(node, unsafe: true)` 才把嵌入块(`begin...end` / `~` 行)原样注入、把行内
168
+ 插值写成 `builder.interpolation { expr }` 交 Ruby 求值。
147
169
 
148
170
  安全渲染不需要拿产物去执行:未求值的嵌入还留在 AST1 里,直接交给 `to_html` 即可。
149
171
  (执行本身只是宿主的一行 `eval`,mdom 不含求值器。)
@@ -152,11 +174,17 @@ MDOM::Parser.parse(" list.each do |x|\n y\n end\n")
152
174
  require "mdom/parser"
153
175
  require "mdom/translator"
154
176
 
155
- puts MDOM::Translator.to_ruby(MDOM::Parser.parse("- 一\n- 二\n"))
177
+ puts MDOM::Translator.to_ruby(MDOM::Parser.parse("# 标题\n\n正文 **粗**。\n"))
156
178
  # builder = MDOM::Builder.new
157
- # builder.list(ordered: false, task: false, marker: "-") do
158
- # builder.item("一", marker: "-")
159
- # builder.item("二", marker: "-")
179
+ # builder.heading(1) do
180
+ # builder.text("标题")
181
+ # end
182
+ # builder.paragraph do
183
+ # builder.text("正文 ")
184
+ # builder.strong do
185
+ # builder.text("粗")
186
+ # end
187
+ # builder.text("。")
160
188
  # end
161
189
  # builder.to_document
162
190
  ```
@@ -223,7 +251,7 @@ end
223
251
 
224
252
  `render(value, parse: false)` 把值挂到调用位置:字符串按**当前容器**包装(行内容器 → 文本节点,块级容器 → 段落),`Array` / `Enumerable` 逐项递归,节点直接插入;`parse: true` 时字符串按 CSM 递归解析。
225
253
 
226
- builder 属**零 gem 依赖**的一侧:它不 `require` 任何东西,字符串参数的行内解析与 `render(parse: true)` 的 CSM 解析都通过构造参数注入(默认为 `MDOM::InlineParser` / `MDOM::Parser`)。
254
+ builder 属**零 gem 依赖**的一侧:它不 `require` 任何外部 gem。行内内容的**结构化构造**(节点参数 / 块)完全不需要解析器;字符串参数的行内解析与 `render(parse: true)` 的 CSM 解析都通过构造参数注入(默认为 `MDOM::InlineParser` / `MDOM::Parser`)。块形式的内容会用同侧的 `MDOM::MarkdownSerializer` 重建 `attributes[:text]` 原文缓存,所以 `paragraph.text` / `blockquote.cite` 这类读取对程序化构造的节点同样成立。
227
255
 
228
256
  ## 文档
229
257
 
@@ -247,6 +275,15 @@ ruby -Ilib test/parser_test.rb # 运行单个测试文件
247
275
  ruby -Ilib -e 'require "mdom/parser"; p MDOM::Parser.parse("# T\n").children'
248
276
  ```
249
277
 
278
+ 性能与回归工具在 [`benchmark/`](benchmark/README.md),零 gem 依赖、语料确定:
279
+
280
+ ```sh
281
+ rake bench # 分项基准:解析 / 行内 / 分类 / 序列化
282
+ rake linear # 线性度:语料放大后单位 KB 耗时是否恒定
283
+ rake pathological # 病态输入体检:回溯与二次方退化(超阈值退出码 1)
284
+ rake equivalence # 与某个 revision 对拍解析输出(改解析器时先跑它,再看数字)
285
+ ```
286
+
250
287
  ## 目录结构
251
288
 
252
289
  ```
@@ -269,12 +306,13 @@ lib/
269
306
  version.rb
270
307
  test/ # minitest 用例
271
308
  docs/ # 语言规格与实现文档
309
+ benchmark/ # 性能与回归工具(bench / linear / pathological / equivalence)
272
310
  ```
273
311
 
274
312
  ## 设计边界
275
313
 
276
- - **两个目标平台**。builder 侧(节点、builder、钩子、HTML 序列化)只用 mruby 子集,可在 WASM 等受限环境运行;解析侧真正硬依赖 `prism` 的是 `block_detector`(块判定)与 `inline_parser`(行内),`tokenizer` / `parser` 经由它们间接依赖,只在 CRuby 上运行。两侧没有硬调用关系——builder 要重解析字符串参数时靠 `MDOM::InlineParser` 的**依赖注入**,本侧不 require 它。
277
- - **`prism` 只在解析期使用**。块判定必须交给 Ruby 原装解析器:`do` / `end` 在英语里也是常见单词,纯词法无法区分「Ruby 块」与「英文散文」。AST1 一旦产出,后续阶段不再需要词法能力。
314
+ - **两个目标平台**。builder 侧(节点、builder、标记序列化、钩子、HTML 序列化)只用 mruby 子集,可在 WASM 等受限环境运行;解析侧真正硬依赖 `prism` 的是 `block_detector`(块判定)与 `inline_parser`(行内),`tokenizer` / `parser` 经由它们间接依赖,只在 CRuby 上运行。两侧没有硬调用关系——builder 要重解析字符串参数时靠 `MDOM::InlineParser` 的**依赖注入**,本侧不 require 它。结构化产物同理不碰 `InlineParser`:行内内容由 builder 的行内方法逐条构造。
315
+ - **`prism` 只在解析期使用**。块判定必须交给 Ruby 原装解析器:`do` / `end` 在英语里也是常见单词,纯词法无法区分「Ruby 块」与「英文散文」。AST1 一旦产出,后续阶段不再需要词法能力。解析侧同时用 Ruby 标准库的 `strscan`(`StringScanner`)做行内与单元格扫描——它同样不进入 builder 侧,mruby 子集约束不受影响。
278
316
  - **注入文档树的唯一通道是 `render`**。裸 `end` 与不带 `render` 的 `~` 行都只是执行,不产生输出;默认**安全渲染**——`to_ruby` 的 `unsafe` 默认 `false`,此时产物不含嵌入段(CSM §3.14)。
279
317
  - **「当前文档」是类级状态**。`值.render` 与裸 `render` 靠 `MDOM::Builder.current`(文档栈)定位目标文档,所以并行请用 **Ractor**(类实例变量天然隔离),**`Thread` 并行不支持**。`render` 内部持互斥锁,无 `Mutex` 的平台(mruby 默认构建)退化为无锁。
280
318
 
data/USAGE.md CHANGED
@@ -97,10 +97,14 @@ puts MDOM::Translator.to_ruby(MDOM::Parser.parse("- 一\n- 二\n"))
97
97
  # builder = MDOM::Builder.new
98
98
  # builder.list(ordered: false, task: false, marker: "-") do
99
99
  # builder.item(marker: "-") do
100
- # builder.paragraph("一")
100
+ # builder.paragraph do
101
+ # builder.text("一")
102
+ # end
101
103
  # end
102
104
  # builder.item(marker: "-") do
103
- # builder.paragraph("二")
105
+ # builder.paragraph do
106
+ # builder.text("二")
107
+ # end
104
108
  # end
105
109
  # end
106
110
  # builder.to_document
@@ -155,9 +159,34 @@ quote.text # => "引用 **粗**" (派生的
155
159
  # 只做块结构,跳过行内解析(快;代价是行内容器只剩 attributes[:text])
156
160
  MDOM::Parser.parse("# 标题\n", inline: false).children.first.children # => []
157
161
 
158
- # 也可以换掉块判定器:Parser.parse(source, detector: MDOM::BlockDetector.new)
162
+ # 关掉 A 类(自由块)判定:英文散文文档里逐行交给 Prism 增量 parse 很贵
163
+ MDOM::Parser.parse("list.each do |x|\n y\nend\n", free_blocks: false)
164
+ .children.map(&:type) # => [:paragraph, :paragraph, :paragraph]
165
+
166
+ # 只对行首标识符命中的行尝试 A 类判定(String 精确相等,Regexp 自己写规则)
167
+ MDOM::Parser.parse(src, free_blocks_allowed: ["list", /^\w+\.each\b/])
168
+
169
+ # 也可以换掉块判定器——此时开关要配在判定器自己身上
170
+ MDOM::Parser.parse(src, detector: MDOM::BlockDetector.new(free_blocks: false))
159
171
  ```
160
172
 
173
+ **A 类(自由块)判定的开销与闸门。** `list.each do |x| … end` 这类块没有引导
174
+ 关键字,只能交给 Prism 判定(CSM §3.5);而英文散文里「以字母开头的行」比比皆是,
175
+ 逐行做增量 parse 会让一篇普通文档产生几百到上万次 Prism 调用。于是 A 类判定可关、
176
+ 可白名单:**关掉或未命中白名单的行按排版处理**(CSM §3.5.3,不报错),B 类(引导
177
+ 关键字)与 `~` 行**不受影响**。
178
+
179
+ | 选项 | 默认 | 语义 |
180
+ |------|------|------|
181
+ | `free_blocks:` | `true` | `false` 时不做任何 A 类判定 |
182
+ | `free_blocks_allowed:` | `nil` | 白名单(`Array<String \| Regexp>`);`nil` 表示不限制,`[]` 等价于 `false` |
183
+
184
+ 白名单的 `String` 元素与**行首标识符**精确相等(大小写严格:`"list"` 命中
185
+ `list.each do`,不命中 `lists.each do`);`Regexp` 元素对**已剥离 CSM 缩进的整行**
186
+ 做 `match?`,要不要锚定行首由自己写的 `\A` 决定。缩进不参与门控——任意层级都按
187
+ 同一规则判定。代价是:被关掉的行不再触发 `CrossLevelError` 之类的缩进校验(那是
188
+ 「已判定为块」之后的检查,见下一节 §3.4)。
189
+
161
190
  ### 3.4 解析期报错
162
191
 
163
192
  非法输入会抛 `MDOM::ParseError` 的子类,消息里带**文档行号**:
@@ -308,6 +337,21 @@ document.children.map(&:type) # => [:heading, :paragraph, :list]
308
337
  块参数就是 builder 自身(**显式传参**,不用 `instance_eval`),所以块里既可以用参数
309
338
  也可以用外层的 `b`,它们指向同一个对象。
310
339
 
340
+ 行内内容有两种给法:**字符串参数**(走 `InlineParser`,见 §5),或**结构化构造**
341
+ ——给节点参数,或在块里调行内方法。后者不经过解析器,正是翻译产物的形态:
342
+
343
+ ```ruby
344
+ MDOM::Builder.build do |b|
345
+ b.p do |p|
346
+ p.text("正文 ")
347
+ p.strong { |s| s.text("粗") }
348
+ end
349
+ end.children.first.children.map(&:type) # => [:text, :strong]
350
+ ```
351
+
352
+ 结构化构造的行内容器(段落、标题、术语、引用、单元格)会用 `MDOM::MarkdownSerializer`
353
+ 把子节点重建进 `attributes[:text]`,所以 `text` / `cite` 这类读取照常可用。
354
+
311
355
  ### 6.1 方法一览
312
356
 
313
357
  | 组 | 方法 |
@@ -320,9 +364,9 @@ document.children.map(&:type) # => [:heading, :paragraph, :list]
320
364
  | 引用 | `blockquote(*parts, cite:)`、`quote`、`cite(*parts)` |
321
365
  | 代码 | `code_block(code, lang:, params:, fence:)` |
322
366
  | 公式 | `math_block(tex)` |
323
- | 表格 | `table { }`、`row { }`、`header_cell(text, align:)`、`cell(text, align:)` |
367
+ | 表格 | `table { }`、`row { }`、`header_cell(*parts, align:)`、`cell(*parts, align:)` |
324
368
  | 其它块 | `hrule` / `hr`、`division { }` / `div`、`definition_list { }` / `dl`、`term(*parts)`、`definition(*parts)` |
325
- | 行内 | `em` / `emphasis`、`strong`、`code_span(value)`、`link(uri, *label, title:)`、`image(uri, alt:, title:)`、`hardbreak` |
369
+ | 行内 | `text(value)`、`em` / `emphasis`、`strong`、`code_span(value)`、`del`、`ins`、`link(uri, *label, title:)`、`inline_quote(uri, *parts)`、`wiki_link(target, label = nil, *parts)`、`mention(name, kind:)`、`image(uri, *parts, alt:, title:)`、`meter(value, max, *parts)`、`progress(value, max, *parts)`、`math(tex, *parts)`、`hardbreak`、`interpolation(expr)` |
326
370
  | 注入 | `render(value, parse:)` |
327
371
 
328
372
  ### 6.2 参数怎么包装
@@ -374,6 +418,21 @@ MDOM::Builder.current.equal?(builder) # => true
374
418
  builder.to_document # 出栈,current 恢复为 nil
375
419
  ```
376
420
 
421
+ **`render` 有没有位置参数都行**(CSM §3.3):给了位置参数就用它,否则用接收者本身。
422
+ 所以块内可以像 `Builder#render` 一样裸写,这正是 `begin ... end` 里最顺手的写法
423
+ (`#{}` 交给 Ruby):
424
+
425
+ ```ruby
426
+ begin
427
+ ["a", "b"].each do |item|
428
+ render "- #{item}", parse: true
429
+ end
430
+ end
431
+ ```
432
+
433
+ `render(nil)` 仍然是「渲染 nil」(被忽略);多余的位置参数会抛 `ArgumentError`
434
+ (不静默取第一个)。
435
+
377
436
  `Builder.with(builder) { ... }` 提供显式作用域,`Builder.current` 用来读:
378
437
 
379
438
  ```ruby
@@ -426,22 +485,32 @@ ast = MDOM::Parser.parse(source)
426
485
  MDOM::Translator.to_ruby(ast)
427
486
  # builder = MDOM::Builder.new
428
487
  # # [csm] ruby 嵌入已省略(unsafe: false)
429
- # builder.paragraph("合计 \#{total} 元。")
488
+ # builder.paragraph do
489
+ # builder.text("合计 ")
490
+ # builder.interpolation("total")
491
+ # builder.text(" 元。")
492
+ # end
430
493
  # builder.to_document
431
494
 
432
495
  MDOM::Translator.to_ruby(ast, unsafe: true)
433
496
  # builder = MDOM::Builder.new
434
497
  # total = 1
435
- # builder.paragraph("合计 #{total} 元。")
498
+ # builder.paragraph do
499
+ # builder.text("合计 ")
500
+ # builder.interpolation { total }
501
+ # builder.text(" 元。")
502
+ # end
436
503
  # builder.to_document
437
504
  ```
438
505
 
439
506
  - **默认 `unsafe: false`(安全)**:嵌入段**不写进产物**——块级嵌入只留一行注释占位,
440
- 行内插值退化为**字面** `#{expr}`。产物静态上不含用户代码。
441
- - **`unsafe: true`**:嵌入块(`begin…end` / `~` 行)原样注入,行内插值交 Ruby 求值。
507
+ 行内插值变成 `builder.interpolation("expr")`(**未求值**的插值节点)。产物静态上
508
+ 不含用户代码。
509
+ - **`unsafe: true`**:嵌入块(`begin…end` / `~` 行)原样注入,行内插值写成
510
+ `builder.interpolation { expr }`,表达式交 Ruby 求值。
442
511
 
443
- 安全模式的产物是**自洽**的:直接执行它不需要外部变量,插值会落回字面 `#{expr}`,
444
- 由 builder 重新解析成**未求值**的插值节点,于是 HTML 侧照样按嵌入样式输出。
512
+ 安全模式的产物是**自洽**的:直接执行它不需要外部变量,插值节点保持未求值,于是 HTML
513
+ 侧照样按嵌入样式输出(CSM §3.14)。
445
514
 
446
515
  ### 7.2 产物的结构约束
447
516
 
@@ -665,9 +734,11 @@ end
665
734
  **尚未实现。** Linter、跨文档组合;CSM §5.1 的注册宏与 §5.2 的查询引擎。
666
735
  `MDOM.compile` 也暂不产出 Markdown(需要时自己调 `node.to_markdown`)。
667
736
 
668
- **明确不支持。** heredoc(CSM §3.12 的取舍,非 mruby 限制)。被判定为 Ruby 块的
669
- 内容里出现 `<<~` / `<<-` 等就会抛 `MDOM::UnsupportedHeredocError`,而不是被当普通块
670
- 收下(左移 `<<` 不受影响)。
737
+ **heredoc 不能单独成块。** CSM §3.12 的取舍(非 mruby 限制):写成自由块或在
738
+ `begin...end` 内时,它本身就位于 Ruby 代码中,**自然可用**;单独一行(`sql = <<~SQL`、
739
+ `puts <<~SQL`)则不构成块,按排版处理、不报错。但 heredoc **不能破坏块的缩进**——
740
+ 把 body 也缩进到块内,并推荐 Squiggly Heredoc(`<<~`):它剥掉 body 的公共缩进,
741
+ 因此对齐缩进不会改变字符串内容。
671
742
 
672
743
  ## 14. 配方
673
744
 
@@ -706,9 +777,13 @@ table.children.last.children.map(&:align) # => [:left, :right]
706
777
 
707
778
  MDOM::HtmlSerializer.serialize(table)
708
779
  # <table>
780
+ # <thead>
709
781
  # <tr>
710
782
  # <th style="text-align: left;">a</th>
711
783
  # <th style="text-align: right;">b</th>
784
+ # </tr>
785
+ # </thead>
786
+ # <tbody>
712
787
  # …
713
788
  ```
714
789