mdom 0.2.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: f5fd39950b36dde37a473d2ed62086a94a218bf3d7fc17bfc107fe2b443c2003
4
- data.tar.gz: 15d24864d48fe97e4d82e5990f9163578b6a4b55104d195e87c4e24f14d26f1f
3
+ metadata.gz: d730da5c27658d1147ed74e4b371ee3244c1172fc72779441a1374f159c9a94a
4
+ data.tar.gz: d52ba53cb04bd5c41c1fe8c6007031709c88650f4b9469e3f0daccaadd131d88
5
5
  SHA512:
6
- metadata.gz: 995dfd2579600430baedb7592b22ef2c94908c2bf3e16b1daa33550f3058d25783275af0237d00e7152876e14156befe18a2894d087f9cc556acdc7fe36f32ad
7
- data.tar.gz: f286b92331b99bb57250df4d13ea1d4e5e08dc9020b2c8cb3e90e5f5522430897cf112c0ffce0dd05f280a63de5f560869c8a8dda444946d072a0296977cb0c6
6
+ metadata.gz: 2ca7c37e09668a3f6b9e2db4c31129daa7d24221c9f98391ef4a54ecaa284dc6abc8e9421d0d0f4404ee9677ae801af5bfd3e37b1b80c583350035cccad0af05
7
+ data.tar.gz: 3f3f00a30b22419db135255f0135a2d2d3e5bac5e96c85a16563fa401f96ed6eb32d76a2ec2f201b5c592de0dda35ee5b41632514fd366099efad1cce9807503
data/CHANGELOG.md ADDED
@@ -0,0 +1,235 @@
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`。
166
+
167
+ ## [0.3.0] - 2026-09-26
168
+
169
+ 从 0.2.0 的「Markdown DOM」重写为「CSM 工具链」:(1) 解析、(2) 翻译、(3)(5)
170
+ `to_markdown`、(6) HTML 序列化四段可用;(4) 执行归宿主一行 `eval`。
171
+
172
+ ### Added
173
+
174
+ - `MDOM::Hooks` + `Document.register_hook`:钩子 / 回调系统(CSM §5.3)——五个点
175
+ `:after_parse` / `:before_compile` / `:after_compile` / `:before_serialize` /
176
+ `:after_serialize`,类级全局与调用时临时两种作用域。
177
+ - `MDOM.compile(source, ...)`:统一入口——(1)(2)(6) 全链路并触发钩子,返回
178
+ `MDOM::Compiled`(`document` / `ruby` / `html` / `meta`);`MDOM.parse` 一并接线。
179
+ - `MDOM::Node::Document#meta`:文档级元数据。
180
+ - `MDOM::HtmlSerializer`:(6) AST → HTML(零 gem 依赖)——含 CSM §1.2 的
181
+ article/section 自动包裹、`hgroup`、WikiLink 的 `known_targets`、未求值嵌入的
182
+ `csm-ruby` 样式;公式输出 `math` 类元素(TeX 原文,不渲染 SVG)。
183
+ - `MDOM::MarkdownSerializer`:(3)(5) AST → CSM 源——同一方法服务 AST1(保真往返)
184
+ 与 AST2(降级还原);从节点结构重建、保守转义、按节点深度重建缩进。`node.to_markdown`
185
+ 是便捷入口。`MDOM.compile` 暂不产出 Markdown。
186
+ - `USAGE.md`:使用指南(各阶段 API、可运行示例、配方,含程序化生成目录)。
187
+
188
+ ### Changed
189
+
190
+ - 解析与翻译按 CSM 重写:`Tokenizer`(表格、块级公式)、`BlockDetector`(Prism)、
191
+ `Parser`(FLOW / PHRASING 内容策略)、`InlineParser`、`Translator`(`unsafe:` 默认
192
+ 安全,安全时不写嵌入段)、`Builder`(游标栈 + `render` / `值.render` + 「当前文档」)。
193
+ - `lib/mdom.rb` 从脚手架空壳改为真正的入口。
194
+ - `mdom.gemspec`:摘要与描述改为 CSM 工具链(原先还是 0.2.0 的「Markdown DOM」,
195
+ 且声称能序列化回 Markdown);补上 `prism` 运行时依赖。`Gemfile` 补 `rubocop`
196
+ (`Rakefile` 一直在 require 它)。
197
+ - `.rubocop.yml`:指标类 cop 改为非闸门(lib 侧是刻意的宽分类器与扫描循环,见该文件
198
+ 注释);`TargetRubyVersion` 与 gemspec 的 `required_ruby_version` 统一为 3.0。
199
+ - `rake` 默认任务(`test` + `rubocop`)现在全绿。
200
+
201
+ ### Removed
202
+
203
+ - `MDOM.serialize`:它指向已被移除的 `Serializer`,等 (3) `to_markdown` 落地再恢复。
204
+ - `lib/mdom/scanner.rb`(没有消费者的空壳,块配对与插值边界早已交给 Prism)、
205
+ `lib/mdom/node.rb` 里零使用的 `ALLOWED_CHILD_TYPES`、空目录 `sig/`。
206
+
207
+ ### Fixed
208
+
209
+ - 表格:`| a | b |` 行 + 分隔行 → `Table > TableRow > TableHeaderCell` / `TableCell`
210
+ (`align` 记在单元格上)。
211
+ - 块级公式:单行 `$$…$$` 与 `$$` 独占一行的多行包围式两种写法。
212
+ - 标题收尾的 `#` 只有与引导 `#` **等长**才算闭合。
213
+ - heredoc(`<<~SQL` / `<<-SQL` 等)现在按 CSM §3.12 / 附录 E **明确报错**
214
+ (`MDOM::UnsupportedHeredocError`),不再被当作普通 Ruby 块收下;左移 `<<`
215
+ 不受影响。
216
+ - 标题锚点与 WikiLink 目标的 slug 统一为**小写**(CSM §2.1 要求 WikiLink 目标不区分
217
+ 大小写),因此 `[[笔记#锚点]]` 能对上标题的 `id`。
218
+ - `InlineParser::ESCAPABLE`:`%w[` * …]` 的首个元素 `\`` 在 `%w` 里其实是**两个
219
+ 字符**(`\` + `` ` ``),于是集合里根本没有反引号,取而代之一个永远匹配不上的双字符
220
+ 项——CSM §3.11 明列可转义的 `` \` `` 一直转不了。已改为 `` ` ``。
221
+
222
+ ## [0.2.0] - 2026-09-10
223
+
224
+ - Add `MDOM::Builder` and the `MDOM.build` DSL for constructing trees
225
+ programmatically, with block nesting, chaining and whole-tree return.
226
+ - Fix `ArgumentError: missing keyword: :marker` when serializing a `ListItem`
227
+ outside of its `List`; `Serializer#serialize` and `Serializer.serialize` now
228
+ accept a `marker:` keyword (default `"-"`).
229
+ - Fill in real RBS signatures in `sig/mdom.rbs` (previously `module Mdom`
230
+ scaffolding only).
231
+ - Fix the scaffold test referencing `Mdom::VERSION` instead of `MDOM::VERSION`.
232
+
233
+ ## [0.1.0] - 2026-09-07
234
+
235
+ - Initial release
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 TODO: Write your name
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,323 @@
1
+ # mdom
2
+
3
+ **mdom** 是 [CSM](docs/CSM.md)(Complex Simple Markup)的 Ruby 工具链:把 CSM 文档解析成一棵文档树(AST),再翻译为 Ruby 源码、HTML 或 Markdown。
4
+
5
+ CSM 把 Markdown 视为 **Ruby 的语法糖**——一份 `.rb.md` 文档既是可读的 Markdown,也是一段可执行的 Ruby 程序:
6
+
7
+ ```markdown
8
+ # 销售报表
9
+
10
+ ~ total = orders.sum(&:amount)
11
+
12
+ 本月合计 #{total} 元。
13
+ ```
14
+
15
+ > **仓库状态**:已从 0.2.0 的「Markdown DOM」重写为「CSM 工具链」。(1) 解析、(2) 翻译、(3)(5) `to_markdown`、(6) HTML 序列化四段可用,入口 `MDOM.parse` / `MDOM.compile` 已接线;(4) 执行归宿主一行 `eval`。见[当前状态](#当前状态)。
16
+
17
+ ## 处理模型
18
+
19
+ CSM 的处理由六个转换关系定义,围绕**一棵树的两种状态**展开:
20
+
21
+ ```
22
+ (1) parse (2) to_ruby
23
+ CSM 源 ───────────> AST1 ──────────────────> Ruby 源
24
+ │ │
25
+ (3) to_markdown (4) eval
26
+ │ │
27
+ v v
28
+ CSM 源 AST2 ──(6) to_html──> HTML
29
+ ```
30
+
31
+ | # | 转换 | 说明 | 状态 |
32
+ |---|------|------|------|
33
+ | (1) | CSM → AST1 | 解析。嵌入部分以**源码原文 + 位置**存于树中,尚未求值 | ✅ 已实现 |
34
+ | (2) | AST1 → Ruby | 翻译。排版变 builder 调用;嵌入块在 `unsafe: true` 下保留为 Ruby | ✅ 已实现 |
35
+ | (3) | AST1 → CSM | `to_markdown`,不执行代码,嵌入部分原样回吐 → 保真往返 | ✅ 已实现 |
36
+ | (4) | Ruby → AST2 | 执行(`eval`)。嵌入部分被替换为求值产出的子树 | ➖ 由宿主 `eval`,mdom 不含求值器 |
37
+ | (5) | AST2 → CSM | `to_markdown`,已无源码,**降级还原** | ✅ 已实现(降级是设计后果) |
38
+ | (6) | AST2 → HTML | `to_html` 序列化(AST1 / AST2 均可) | ✅ 已实现 |
39
+
40
+ **AST1 与 AST2 是同一棵树类型**,区别只在于是否还残留未求值的嵌入节点。对一棵 AST1 直接 `to_html` 也是允许的——未求值的嵌入部分会以独立的嵌入样式输出。
41
+
42
+ ## 当前状态
43
+
44
+ | 模块 | 职责 | 状态 |
45
+ |------|------|------|
46
+ | `lib/mdom/tokenizer.rb` | 逐行分类:围栏 → 表格 → 块级公式 → `\` 转义 → `~` → 候选嵌入 → 排版 | ✅ |
47
+ | `lib/mdom/block_detector.rb` | 用 Prism 增量 parse 判定 Ruby 块(A/B 两类);A 类的候选资格可关、可白名单(`free_blocks:` / `free_blocks_allowed:`) | ✅ |
48
+ | `lib/mdom/parser.rb` | Token Stream → AST1(按 CSM 缩进建层级;按内容策略区分 FLOW / PHRASING 容器) | ✅ |
49
+ | `lib/mdom/inline_parser.rb` | 行内解析:强调/代码/链接/增删/进度条/公式/插值/转义/换行 | ✅ |
50
+ | `lib/mdom/node.rb`、`node/nodes.rb`、`node/inline_nodes.rb` | 块级与行内节点定义、树操作(挂载/遍历/导航/深度) | ✅ |
51
+ | `lib/mdom/error.rb` | 解析错误(含文档行号) | ✅ |
52
+ | `lib/mdom/builder.rb` | 文档构造 DSL + 游标栈 + `render` / `值.render` + 「当前文档」栈(零 gem 依赖) | ✅ |
53
+ | `lib/mdom/translator.rb` | AST1 → Ruby 源码(`unsafe:` 默认安全,安全时不写嵌入段) | ✅ |
54
+ | `lib/mdom/html_serializer.rb` | AST → HTML(零 gem 依赖,安全模式下嵌入按独立样式输出) | ✅ |
55
+ | `lib/mdom/hooks.rb` | 钩子 / 回调注册表:五个点、类级全局 + 调用时临时(CSM §5.3) | ✅ |
56
+ | `lib/mdom.rb` | 总入口:`MDOM.parse` / `MDOM.compile`((1)(2)(6) + 钩子) | ✅ |
57
+ | `lib/mdom/markdown_serializer.rb` | AST → CSM((3)(5);零 gem 依赖,保守转义 + 按深度重建缩进) | ✅ |
58
+ | `test/` | minitest 用例(`rake test`,覆盖 tokenizer / block_detector / parser / inline_parser / builder / translator / html_serializer / markdown_serializer / node / hooks / compile) | ✅ |
59
+
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
+
62
+ `rake`(默认任务 = `test` + `rubocop`)全绿:409 例、0 skip,`lib/` 与 `test/` 共 29 个文件无 rubocop 告警。指标类 cop 在 `.rubocop.yml` 里关掉了,理由见该文件的注释。
63
+
64
+ ## 用法
65
+
66
+ 各阶段可以单独 `require`;想一次走完 (1)(2)(6)(并触发钩子)用总入口 `MDOM.compile`,细节见 [USAGE.md](USAGE.md)。下面按阶段演示:
67
+
68
+ ```ruby
69
+ require "mdom/parser"
70
+
71
+ doc = MDOM::Parser.parse(<<~CSM)
72
+ # 标题
73
+
74
+ - 一
75
+ - 二
76
+
77
+ ~ total = 1 + 2
78
+ CSM
79
+
80
+ doc.children.map(&:type) # => [:heading, :list, :ruby_embed]
81
+ ```
82
+
83
+ 正文按 CSM 元素表的内容策略落在 `children` 上:**PHRASING** 容器(段落、标题、定义术语)直接收行内节点;**FLOW** 容器(引用、列表项、定义)只收块级节点,正文先成为一个段落。原文仍保留在 `attributes[:text]`:
84
+
85
+ ```ruby
86
+ heading = doc.children.first
87
+ heading.level # => 1
88
+ heading.text # => "标题"
89
+ heading.children.map(&:type) # => [:text]
90
+
91
+ paragraph = MDOM::Parser.parse("**粗** 与 *斜* 与 `code`\n").children.first
92
+ paragraph.children.map(&:type) # => [:strong, :text, :emphasis, :text, :code_span]
93
+ paragraph.text # => "**粗** 与 *斜* 与 `code`"
94
+
95
+ quote = MDOM::Parser.parse("> 引用 **粗**\n>- 出处\n").children.first
96
+ quote.children.map(&:type) # => [:paragraph, :cite]
97
+ quote.children.first.children.map(&:type) # => [:text, :strong]
98
+ quote.cite # => "出处" (`>-` 收尾,内容策略 PHRASING)
99
+
100
+ table = MDOM::Parser.parse("| a | b |\n|:--|--:|\n| 1 | 2 |\n").children.first
101
+ table.children.map(&:type) # => [:table_row, :table_row]
102
+ table.children.first.children.map(&:type) # => [:table_header_cell, :table_header_cell]
103
+ table.children.first.children.map(&:align) # => [:left, :right]
104
+
105
+ MDOM::Parser.parse("$$x^2$$\n").children.first.type # => :math_block
106
+ ```
107
+
108
+ 行内节点覆盖 CSM 的全部行内语法,`MDOM::InlineParser.parse` 可单独调用:
109
+
110
+ ```ruby
111
+ MDOM::InlineParser.parse(%q{[文本](@note) #{Date.today}}).map(&:type)
112
+ # => [:inline_quote, :text, :interpolation]
113
+ ```
114
+
115
+ Ruby 嵌入节点保存源码原文、文档行号区间、CSM 缩进与形态:
116
+
117
+ ```ruby
118
+ embed = MDOM::Parser.parse("list.each do |item|\n puts item\nend.render\n").children.first
119
+
120
+ embed.type # => :ruby_embed
121
+ embed.form # => :free (:begin / :free / :tilde)
122
+ embed.render? # => true (以 `.render` 收尾)
123
+ embed.lines # => 1..3
124
+ embed.source # => "list.each do |item|\n puts item\nend.render"
125
+ ```
126
+
127
+ 块判定的失败方式遵循 CSM §3.5.3——散文不会被误判为代码:
128
+
129
+ ```ruby
130
+ MDOM::Parser.parse("time flies\n").children.first.type # => :paragraph
131
+
132
+ MDOM::Parser.parse(" list.each do |x|\n y\n end\n")
133
+ # => MDOM::CrossLevelError: ruby block opens with 4 space(s) of indentation (line 1)
134
+ # but closes with 2 (line 3)
135
+ ```
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
+
154
+ ### 翻译为 Ruby
155
+
156
+ `MDOM::Translator` 实现 (2):把 AST1 翻成一段顶层顺序执行的 Ruby 程序。排版节点
157
+ 变成 `builder.*` 调用——产物不绑定执行方式,`eval`、落盘 `require`、交给 mruby
158
+ 都行。
159
+
160
+ **行内内容是结构化的**:段落、标题、单元格这些行内容器在产物里是 `do ... end` 块,
161
+ 每个行内子节点对应一个 builder 行内方法(`text` / `strong` / `link`……)。产物因此
162
+ **不经过 `InlineParser`**,也不需要 prism,排版段真的落在 mruby 子集里(CSM §3.1)。
163
+
164
+ **`unsafe` 是翻译期参数,默认安全**(CSM §3.14)。`to_ruby(node)` 默认
165
+ `unsafe: false`:嵌入段**不写进产物**——块级嵌入只留一行注释占位,行内插值变成
166
+ `builder.interpolation("expr")`(未求值的插值节点),因此产物静态上不含用户代码。
167
+ `to_ruby(node, unsafe: true)` 才把嵌入块(`begin...end` / `~` 行)原样注入、把行内
168
+ 插值写成 `builder.interpolation { expr }` 交 Ruby 求值。
169
+
170
+ 安全渲染不需要拿产物去执行:未求值的嵌入还留在 AST1 里,直接交给 `to_html` 即可。
171
+ (执行本身只是宿主的一行 `eval`,mdom 不含求值器。)
172
+
173
+ ```ruby
174
+ require "mdom/parser"
175
+ require "mdom/translator"
176
+
177
+ puts MDOM::Translator.to_ruby(MDOM::Parser.parse("# 标题\n\n正文 **粗**。\n"))
178
+ # builder = MDOM::Builder.new
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("。")
188
+ # end
189
+ # builder.to_document
190
+ ```
191
+
192
+ `Translator.to_ruby(node, **options)` 与 `node.to_ruby(**options)` 等价。
193
+
194
+ ### 序列化为 HTML
195
+
196
+ `MDOM::HtmlSerializer` 实现 (6)。AST1 与 AST2 都能喂给它——未求值的嵌入(块级
197
+ `RubyEmbed` / 行内插值)按独立的嵌入样式输出(与代码块同构,另加 `csm-ruby`
198
+ class),所以「安全渲染」与「不安全渲染」的差别只在于嵌入代码是否执行。
199
+
200
+ ```ruby
201
+ require "mdom/parser"
202
+ require "mdom/html_serializer"
203
+
204
+ puts MDOM::HtmlSerializer.serialize(MDOM::Parser.parse("## 标题\n\n正文 **粗**。\n"))
205
+ # <section>
206
+ # <h2 id="标题">标题</h2>
207
+ # <p>正文 <strong>粗</strong>。</p>
208
+ # </section>
209
+ ```
210
+
211
+ `HtmlSerializer.serialize(node)` 与 `node.to_html` 等价;给 `known_targets:` 时,
212
+ 不在其中的 WikiLink 会带上 `orphan-link` class。
213
+
214
+ ### 序列化为 CSM
215
+
216
+ `MDOM::MarkdownSerializer` 实现 (3)/(5),把树写回 CSM 源。同一个方法服务两种输入
217
+ 状态:**AST1**(嵌入还留着源码)逐字保真往返;**AST2**(嵌入已被求值结果替换)只能
218
+ 降级——源码位置只剩结果,无法还原成 Ruby。
219
+
220
+ ```ruby
221
+ require "mdom/parser"
222
+ require "mdom/markdown_serializer"
223
+
224
+ MDOM::MarkdownSerializer.serialize(MDOM::Parser.parse("# 标题\n"))
225
+ # => "# 标题"
226
+
227
+ MDOM::Parser.parse("**粗** 与 `code`\n").to_markdown
228
+ # => "**粗** 与 `code`"
229
+ ```
230
+
231
+ 序列化**从节点结构重建**(不读 `attributes[:text]` 那类原文缓存,所以改树之后输出
232
+ 反映改动),并采取**保守转义**:文本里凡是可能重新触发解析的字符都会被转义,代价是
233
+ 输出比手写的 CSM 更谨慎,换来的是「内容不会被自己的标记重新解析」。
234
+
235
+ ### 用 DSL 构造
236
+
237
+ 除了解析,也可以用 builder 从零构造文档。节点挂到「当前容器」;块参数就是 builder 自身(显式传参,不用 `instance_eval`):
238
+
239
+ ```ruby
240
+ require "mdom/parser" # 字符串参数要走行内解析,需要解析侧
241
+ require "mdom/builder"
242
+
243
+ doc = MDOM::Builder.build do |b|
244
+ b.heading(1, "标题")
245
+ b.paragraph("正文 *强调*。")
246
+ b.list do |l|
247
+ %w[一 二].each { |item| l.item { |i| i.render(item) } } # render 是唯一的注入通道
248
+ end
249
+ end
250
+ ```
251
+
252
+ `render(value, parse: false)` 把值挂到调用位置:字符串按**当前容器**包装(行内容器 → 文本节点,块级容器 → 段落),`Array` / `Enumerable` 逐项递归,节点直接插入;`parse: true` 时字符串按 CSM 递归解析。
253
+
254
+ builder 属**零 gem 依赖**的一侧:它不 `require` 任何外部 gem。行内内容的**结构化构造**(节点参数 / 块)完全不需要解析器;字符串参数的行内解析与 `render(parse: true)` 的 CSM 解析都通过构造参数注入(默认为 `MDOM::InlineParser` / `MDOM::Parser`)。块形式的内容会用同侧的 `MDOM::MarkdownSerializer` 重建 `attributes[:text]` 原文缓存,所以 `paragraph.text` / `blockquote.cite` 这类读取对程序化构造的节点同样成立。
255
+
256
+ ## 文档
257
+
258
+ | 文件 | 内容 |
259
+ |------|------|
260
+ | [USAGE.md](USAGE.md) | **使用指南**:各阶段的 API、可运行示例、常见配方 |
261
+ | [docs/CSM.md](docs/CSM.md) | **语言规格**:语法、嵌入机制、处理模型(`docs/CSM.md` 自身就是一份合法 CSM 文档) |
262
+ | [docs/IMPLEMENTATION.md](docs/IMPLEMENTATION.md) | 实现方案:管线、模块划分、算法要点、风险清单、测试用例清单 |
263
+ | [docs/GFM-NOTES.md](docs/GFM-NOTES.md) | GitHub Flavored Markdown 规范阅读笔记(CSM 语法的来源之一) |
264
+ | [docs/STRICT-GFM.md](docs/STRICT-GFM.md) | SMD:基于 GFM 裁剪的确定性优先标记语言 |
265
+ | [docs/ROBUSTNESS.md](docs/ROBUSTNESS.md) | 元数据索引鲁棒性的纸面推演(非实测) |
266
+ | [CHANGELOG.md](CHANGELOG.md) | 版本记录 |
267
+
268
+ ## 开发
269
+
270
+ 需要 **Ruby 3.0+**;解析侧硬依赖 [`prism`](https://github.com/ruby/prism)(Ruby 3.3 起随发行版作为 default gem 提供)。builder 侧不依赖 prism,目标是 mruby 子集。
271
+
272
+ ```sh
273
+ rake test # 运行全部测试
274
+ ruby -Ilib test/parser_test.rb # 运行单个测试文件
275
+ ruby -Ilib -e 'require "mdom/parser"; p MDOM::Parser.parse("# T\n").children'
276
+ ```
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
+
287
+ ## 目录结构
288
+
289
+ ```
290
+ lib/
291
+ mdom.rb # 入口:MDOM.parse / MDOM.compile
292
+ mdom/
293
+ parser.rb # Token Stream → AST1 (解析侧)
294
+ tokenizer.rb # 逐行分类 (解析侧)
295
+ block_detector.rb # Prism 块判定 (解析侧)
296
+ inline_parser.rb # 行内解析 (解析侧)
297
+ translator.rb # AST1 → Ruby 源码 (解析侧)
298
+ error.rb # 解析错误
299
+ node.rb # 节点基类与树操作
300
+ node/nodes.rb # 块级节点定义
301
+ node/inline_nodes.rb # 行内节点定义
302
+ builder.rb # 构建 DSL + 游标栈 + render
303
+ hooks.rb # 钩子 / 回调注册表(CSM §5.3)
304
+ html_serializer.rb # AST → HTML(零 gem 依赖)
305
+ markdown_serializer.rb # AST → CSM 源((3)(5),零 gem 依赖)
306
+ version.rb
307
+ test/ # minitest 用例
308
+ docs/ # 语言规格与实现文档
309
+ benchmark/ # 性能与回归工具(bench / linear / pathological / equivalence)
310
+ ```
311
+
312
+ ## 设计边界
313
+
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 子集约束不受影响。
316
+ - **注入文档树的唯一通道是 `render`**。裸 `end` 与不带 `render` 的 `~` 行都只是执行,不产生输出;默认**安全渲染**——`to_ruby` 的 `unsafe` 默认 `false`,此时产物不含嵌入段(CSM §3.14)。
317
+ - **「当前文档」是类级状态**。`值.render` 与裸 `render` 靠 `MDOM::Builder.current`(文档栈)定位目标文档,所以并行请用 **Ractor**(类实例变量天然隔离),**`Thread` 并行不支持**。`render` 内部持互斥锁,无 `Mutex` 的平台(mruby 默认构建)退化为无锁。
318
+
319
+ 细节见 [docs/CSM.md](docs/CSM.md) 与 [docs/IMPLEMENTATION.md](docs/IMPLEMENTATION.md)。
320
+
321
+ ## License
322
+
323
+ MIT