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.
data/USAGE.md ADDED
@@ -0,0 +1,864 @@
1
+ # mdom 使用指南
2
+
3
+ `mdom` 是 [CSM(Complex Simple Markup)](docs/CSM.md) 的 Ruby 工具链:把 CSM 文档
4
+ 解析成一棵文档树(AST),再翻译为 Ruby 源码或 HTML。
5
+
6
+ 本文只讲**怎么用**。语言规格见 [docs/CSM.md](docs/CSM.md),实现方案见
7
+ [docs/IMPLEMENTATION.md](docs/IMPLEMENTATION.md),项目状态见 [README.md](README.md)。
8
+
9
+ > **当前可用范围**:(1) 解析、(2) 翻译、(6) HTML 序列化已经实现;(3)(5) `to_markdown`
10
+ > 未实现;(4) 执行不在 mdom 范围内——它就是宿主的一行 `eval`。
11
+
12
+ ## 目录
13
+
14
+ - [1. 引入](#1-引入)
15
+ - [2. 五分钟上手](#2-五分钟上手)
16
+ - [3. 解析((1)):`MDOM::Parser`](#3-解析1mdomparser)
17
+ - [4. 读树与改树:`MDOM::Node`](#4-读树与改树mdomnode)
18
+ - [5. 行内解析:`MDOM::InlineParser`](#5-行内解析mdominelineparser)
19
+ - [6. 构造:`MDOM::Builder`](#6-构造mdombuilder)
20
+ - [7. 翻译成 Ruby((2)):`MDOM::Translator`](#7-翻译成-ruby2mdomtranslator)
21
+ - [8. 执行((4)):宿主的一行 `eval`](#8-执行4宿主的一行-eval)
22
+ - [9. 序列化成 HTML((6)):`MDOM::HtmlSerializer`](#9-序列化成-html6mdomhtmlserializer)
23
+ - [10. 序列化成 CSM((3)(5)):`MDOM::MarkdownSerializer`](#10-序列化成-csm35mdommarkdownserializer)
24
+ - [11. 钩子与回调(CSM §5.3)](#11-钩子与回调csm-53)
25
+ - [12. 错误](#12-错误)
26
+ - [13. 边界与注意事项](#13-边界与注意事项)
27
+ - [14. 配方](#14-配方)
28
+
29
+ ## 1. 引入
30
+
31
+ 可以引总入口,也可以按阶段引:
32
+
33
+ ```ruby
34
+ require "mdom" # 总入口:加载全部,并给出 MDOM.parse / MDOM.compile
35
+
36
+ # 只用到某一段时,用哪个引哪个:
37
+ require "mdom/parser" # (1) 解析:Parser / Tokenizer / BlockDetector / InlineParser
38
+ require "mdom/translator" # (2) 翻译
39
+ require "mdom/builder" # 构造 DSL + 钩子(零 gem 依赖,可在 mruby/WASM 上跑)
40
+ require "mdom/html_serializer" # (6) HTML 序列化(零 gem 依赖)
41
+ ```
42
+
43
+ 依赖上分两侧:**解析侧**(`parser` / `translator`)硬依赖
44
+ [`prism`](https://github.com/ruby/prism)(Ruby 3.3 起随发行版提供);**builder 侧**
45
+ (`builder` / `html_serializer` 及其依赖的节点定义)零 gem 依赖。
46
+
47
+ ## 2. 五分钟上手
48
+
49
+ ```ruby
50
+ require "mdom/parser"
51
+ require "mdom/html_serializer"
52
+
53
+ document = MDOM::Parser.parse(<<~CSM)
54
+ # 标题
55
+
56
+ 正文 **加粗** 与 [链接](https://example.com)。
57
+
58
+ - 一
59
+ - 二
60
+ CSM
61
+
62
+ document.class # => MDOM::Node::Document
63
+ document.children.map(&:type)
64
+ # => [:heading, :paragraph, :list]
65
+
66
+ puts MDOM::HtmlSerializer.serialize(document)
67
+ # <article>
68
+ # <h1 id="标题">标题</h1>
69
+ # <p>正文 <strong>加粗</strong> 与 <a href="https://example.com">链接</a>。</p>
70
+ # <ul>
71
+ # <li>
72
+ # <p>一</p>
73
+ # </li>
74
+ # <li>
75
+ # <p>二</p>
76
+ # </li>
77
+ # </ul>
78
+ # </article>
79
+ ```
80
+
81
+ 想让一次调用把三份产物都拿到(并顺便跑钩子),用总入口:
82
+
83
+ ```ruby
84
+ result = MDOM.compile("- 一\n")
85
+ result.class # => MDOM::Compiled
86
+ result.ruby # (2) 的 Ruby 源码
87
+ result.html # (6) 的 HTML
88
+ result.meta # 文档元数据(Hash)
89
+ ```
90
+
91
+ 想要 Ruby 源码,把 `HtmlSerializer` 换成 `Translator`:
92
+
93
+ ```ruby
94
+ require "mdom/translator"
95
+
96
+ puts MDOM::Translator.to_ruby(MDOM::Parser.parse("- 一\n- 二\n"))
97
+ # builder = MDOM::Builder.new
98
+ # builder.list(ordered: false, task: false, marker: "-") do
99
+ # builder.item(marker: "-") do
100
+ # builder.paragraph do
101
+ # builder.text("一")
102
+ # end
103
+ # end
104
+ # builder.item(marker: "-") do
105
+ # builder.paragraph do
106
+ # builder.text("二")
107
+ # end
108
+ # end
109
+ # end
110
+ # builder.to_document
111
+ ```
112
+
113
+ ## 3. 解析((1)):`MDOM::Parser`
114
+
115
+ ```ruby
116
+ document = MDOM::Parser.parse("# 标题\n")
117
+ document.class # => MDOM::Node::Document
118
+ ```
119
+
120
+ ### 3.1 树长什么样
121
+
122
+ 根是 `Document`,`children` 是顶层块。段落、标题等**行内容器**的行内节点挂在
123
+ `children` 上:
124
+
125
+ ```ruby
126
+ heading = MDOM::Parser.parse("# 标题 **粗**\n").children.first
127
+
128
+ heading.type # => :heading
129
+ heading.level # => 1
130
+ heading.text # => "标题 **粗**" (原文)
131
+ heading.children.map(&:type) # => [:text, :strong]
132
+ heading.attributes[:text] # => "标题 **粗**" (与 text 同源)
133
+ ```
134
+
135
+ `attributes[:text]` 保留**未做行内解析的原文**,行内节点才是给下游用的结构。
136
+
137
+ ### 3.2 FLOW / PHRASING 边界
138
+
139
+ 节点直接承载什么,取决于 CSM 元素表里的**内容策略**:
140
+
141
+ - **PHRASING 容器**(段落 `paragraph`、标题 `heading`、定义术语 `term`、引用来源
142
+ `cite`、表格单元格 `table_header_cell` / `table_cell`)直接收**行内节点**;
143
+ - **FLOW 容器**(引用 `blockquote`、列表项 `list_item`、定义 `definition`、节块
144
+ `div`)只收**块级节点**——正文先成为一个段落。
145
+
146
+ ```ruby
147
+ quote = MDOM::Parser.parse("> 引用 **粗**\n").children.first
148
+
149
+ quote.children.map(&:type) # => [:paragraph]
150
+ quote.children.first.children.map(&:type) # => [:text, :strong]
151
+ quote.text # => "引用 **粗**" (派生的便捷读法)
152
+ ```
153
+
154
+ 列表项同理,所以 `- 项` 的结构是 `list → list_item → paragraph → text`。
155
+
156
+ ### 3.3 几个有用的选项
157
+
158
+ ```ruby
159
+ # 只做块结构,跳过行内解析(快;代价是行内容器只剩 attributes[:text])
160
+ MDOM::Parser.parse("# 标题\n", inline: false).children.first.children # => []
161
+
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))
171
+ ```
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
+
190
+ ### 3.4 解析期报错
191
+
192
+ 非法输入会抛 `MDOM::ParseError` 的子类,消息里带**文档行号**:
193
+
194
+ ~~~ruby
195
+ MDOM::Parser.parse(" list.each do |x|\n y\n end\n")
196
+ # => MDOM::CrossLevelError: ruby block opens with 4 space(s) of indentation (line 1)
197
+ # but closes with 2 (line 3)
198
+
199
+ MDOM::Parser.parse("```ruby\nx = 1\n")
200
+ # => MDOM::UnclosedFenceError: unclosed code fence opened at line 1
201
+ ~~~
202
+
203
+ ## 4. 读树与改树:`MDOM::Node`
204
+
205
+ 所有节点都继承 `MDOM::Node::BaseNode`,能力分散在若干 mixin 里(见
206
+ `lib/mdom/node.rb` 的模块说明)。三条**不变式**由变更方法共同维护:
207
+
208
+ 1. 一个子节点在同一时刻只属于一个父节点;
209
+ 2. `parent` 与 `children` 双向一致;
210
+ 3. 子树的 `depth` 始终与实际位置一致。
211
+
212
+ ### 4.1 导航
213
+
214
+ ```ruby
215
+ document = MDOM::Parser.parse("- 一\n- 二\n")
216
+ list = document.children.first
217
+ item = list.children.last
218
+
219
+ document.root.equal?(document) # => true (根节点)
220
+ item.ancestors # => [list, document](由近到远)
221
+ item.position # => 1 (在父节点子列表中的下标)
222
+ list.index(item) # => 1 (某个子节点的下标;不是子节点则为 nil)
223
+ list.first_child.type # => :list_item
224
+ list.last_child.equal?(item) # => true
225
+ item.siblings.map(&:type) # => [:list_item, :list_item](含自身的副本)
226
+ item.previous_sibling.equal?(list.first_child) # => true
227
+
228
+ list.leaf? # => false
229
+ list.children? # => true
230
+ item.leaf? # => false (它有段落)
231
+ document.ancestor_of?(item) # => true (自身不算自己的祖先)
232
+ ```
233
+
234
+ ### 4.2 遍历
235
+
236
+ `each` 是**先序深度优先**,并且**含自身**——所以 `Enumerable` 的 `map` / `select` /
237
+ `count` 覆盖的是**整棵子树**,而不是直接子节点:
238
+
239
+ ```ruby
240
+ document = MDOM::Parser.parse("- 一\n- 二\n")
241
+
242
+ document.count # => 整棵树的节点数
243
+ document.map(&:type).uniq # => [:document, :list, :list_item, :paragraph, :text]
244
+ document.select(&:leaf?) # => 所有叶子
245
+ ```
246
+
247
+ 只想访问直接子节点时,直接用 `children`。
248
+
249
+ ### 4.3 改树
250
+
251
+ 下面这些是**方法清单**(`node` / `relative` 等是占位符,不是变量):
252
+
253
+ ~~~text
254
+ doc << para # 追加(返回 self,可链式)
255
+ doc.append(a, b) # 依次追加多个
256
+ doc.prepend(node) # 插到最前
257
+ doc.insert(0, node) # 指定位置:支持负索引,0 是最前,children.size 是末尾
258
+ doc.insert_before(node, relative) # 插到某个**直接子节点**之前
259
+ doc.insert_after(node, relative) # 之后
260
+
261
+ doc.remove(node) # 摘除直接子节点(不是子节点或 nil 时什么都不做)
262
+ doc.extract_at(-1) # 按下标摘除并返回;越界返回 nil
263
+ node.unlink! # 让**自己**从父节点完整脱离并重算 depth
264
+
265
+ doc.replace(child, replacement) # 顶替一个子节点,保持兄弟顺序
266
+ node.replace_with(other) # 在父节点里把自己换成 other(返回 other)
267
+ node.wrap_with(wrapper) # 用 wrapper 包住自己(返回 wrapper)
268
+ node.slide_prev / node.slide_next # 与前/后一个兄弟交换位置
269
+ ~~~
270
+
271
+ 几种常见的错误:
272
+
273
+ ~~~text
274
+ doc << doc # => MDOM::Node::CycleError(不能挂到自身或祖先之下)
275
+ doc << nil # => ArgumentError
276
+ doc.insert(99, node) # => IndexError(合法范围是 -children.size-1..children.size)
277
+ MDOM::Node::Document.new.position # => MDOM::Node::RootError(根节点没有位置)
278
+ doc.first_child.slide_prev # => MDOM::Node::BoundaryError(已是第一个)
279
+ ~~~
280
+
281
+ 全部树操作的错误都继承 `MDOM::Node::Error`,可以一次 rescue。
282
+
283
+ ### 4.4 直接用节点类
284
+
285
+ 不想走解析和 DSL 时,各节点类型都能直接构造:
286
+
287
+ ```ruby
288
+ MDOM::Node::Paragraph.new("正文")
289
+ MDOM::Node::Heading.new(2, "标题")
290
+ MDOM::Node::List.new(ordered: true, marker: "1.")
291
+ MDOM::Node::CodeBlock.new("x = 1", lang: "ruby")
292
+ MDOM::Node::TableCell.new("值", align: :right)
293
+ MDOM::Node::Document.new(children: [MDOM::Node::Hrule.new])
294
+ ```
295
+
296
+ 完整清单见 `lib/mdom/node/nodes.rb`(块级)与 `lib/mdom/node/inline_nodes.rb`(行内)。
297
+
298
+ ## 5. 行内解析:`MDOM::InlineParser`
299
+
300
+ 把一段**行内文本**解析成节点数组。`Parser` 内部就是用它,也可以单独调用:
301
+
302
+ ```ruby
303
+ MDOM::InlineParser.parse("**粗** 与 `code` 与 [标签](https://a.b)")
304
+ # => [Strong, Text, CodeSpan, Text, Link]
305
+
306
+ MDOM::InlineParser.parse(%q{[文本](@note) #{Date.today}}).map(&:type)
307
+ # => [:inline_quote, :text, :interpolation]
308
+ ```
309
+
310
+ 两个选项:
311
+
312
+ ```ruby
313
+ MDOM::InlineParser.parse("*a*", styles: false) # 不识别强调/加粗/增删 → 全按字面
314
+ MDOM::InlineParser.parse("a *b*", literal: true) # 只认转义与插值,其余字面
315
+ ```
316
+
317
+ 注意它只产出**未求值**的节点:`#{}` 只保存表达式原文,公式只保存 TeX 原文。
318
+
319
+ ## 6. 构造:`MDOM::Builder`
320
+
321
+ `Builder` 是文档构造 DSL,也可以用来程序化生成文档。
322
+
323
+ ```ruby
324
+ require "mdom/parser" # 字符串参数要走行内解析,需要解析侧
325
+ require "mdom/builder"
326
+
327
+ document = MDOM::Builder.build do |b|
328
+ b.h1 "标题"
329
+ b.p "正文 *强调*。"
330
+ b.list do |l|
331
+ %w[一 二].each { |item| l.item { |i| i.render(item) } }
332
+ end
333
+ end
334
+ document.children.map(&:type) # => [:heading, :paragraph, :list]
335
+ ```
336
+
337
+ 块参数就是 builder 自身(**显式传参**,不用 `instance_eval`),所以块里既可以用参数
338
+ 也可以用外层的 `b`,它们指向同一个对象。
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
+
355
+ ### 6.1 方法一览
356
+
357
+ | 组 | 方法 |
358
+ |----|------|
359
+ | 结果 | `to_document`(别名 `finish`)、`document { }` |
360
+ | 标题 | `heading(level, *parts, closed:)`、`h1`–`h6` |
361
+ | 段落 | `paragraph(*parts)`、`p` |
362
+ | 列表 | `list(*items, ordered:, task:, marker:)`、`ul`、`ol` |
363
+ | 列表项 | `item(*parts, marker:, task:, checked:)`、`li`、`task(checked, *parts)` |
364
+ | 引用 | `blockquote(*parts, cite:)`、`quote`、`cite(*parts)` |
365
+ | 代码 | `code_block(code, lang:, params:, fence:)` |
366
+ | 公式 | `math_block(tex)` |
367
+ | 表格 | `table { }`、`row { }`、`header_cell(*parts, align:)`、`cell(*parts, align:)` |
368
+ | 其它块 | `hrule` / `hr`、`division { }` / `div`、`definition_list { }` / `dl`、`term(*parts)`、`definition(*parts)` |
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)` |
370
+ | 注入 | `render(value, parse:)` |
371
+
372
+ ### 6.2 参数怎么包装
373
+
374
+ 字符串参数的包装方式**取决于容器**(CSM §3.10):
375
+
376
+ ```ruby
377
+ MDOM::Builder.build { |b| b.p "文本" }.children.first.children.map(&:type)
378
+ # => [:text] (行内容器 → 文本节点)
379
+
380
+ MDOM::Builder.build { |b| b.render "文本" }.children.map(&:type)
381
+ # => [:paragraph] (块级容器 → 段落)
382
+ ```
383
+
384
+ FLOW 容器的 `*parts` 各自成为**一个段落**:
385
+
386
+ ```ruby
387
+ MDOM::Builder.build { |b| b.blockquote("第一段", "第二段", cite: "出处") }
388
+ # blockquote → [paragraph, paragraph, cite]
389
+ ```
390
+
391
+ ### 6.3 `render`:值的分派
392
+
393
+ | 值 | 行内容器中 | 块级容器中 |
394
+ |----|-----------|-----------|
395
+ | `nil` | 忽略 | 忽略 |
396
+ | 节点 | 直接插入(自动从原父脱离) | 同左 |
397
+ | 字符串 | 文本节点 | 段落 |
398
+ | `Array` / `Enumerable` | 逐项递归 | 逐项递归 |
399
+ | 其它 | `to_s` 后同上 | `to_s` 后同上 |
400
+
401
+ ```ruby
402
+ MDOM::Builder.build { |b| b.render %w[a b] }.children.map(&:type) # => [:paragraph, :paragraph]
403
+ MDOM::Builder.build { |b| b.p { |p| p.render nil } }.children.first.children # => []
404
+
405
+ # parse: true 时字符串按 CSM 递归解析,结果的**子节点**逐个插入
406
+ MDOM::Builder.build { |b| b.render "- a\n", parse: true }.children.map(&:type) # => [:list]
407
+ ```
408
+
409
+ ### 6.4 `值.render` 与「当前文档」
410
+
411
+ CSM §3.3 允许在**值**上直接调用 `.render`(`begin ... end.render`)。它靠
412
+ `MDOM::Builder.current` 找到「当前文档」:
413
+
414
+ ```ruby
415
+ builder = MDOM::Builder.new # 自动成为当前文档
416
+ "文本".render # 等价于 builder.render("文本")
417
+ MDOM::Builder.current.equal?(builder) # => true
418
+ builder.to_document # 出栈,current 恢复为 nil
419
+ ```
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
+
436
+ `Builder.with(builder) { ... }` 提供显式作用域,`Builder.current` 用来读:
437
+
438
+ ```ruby
439
+ builder = MDOM::Builder.new
440
+ builder.to_document
441
+
442
+ MDOM::Builder.with(builder) { MDOM::Builder.current.equal?(builder) } # => true
443
+ MDOM::Builder.current # => nil
444
+ ```
445
+
446
+ 没有当前文档时:
447
+
448
+ ```ruby
449
+ begin
450
+ "x".render
451
+ rescue MDOM::Error => e
452
+ e.message # => "render outside of a document"
453
+ end
454
+ ```
455
+
456
+ > **代价与并发**:`值.render` 定义在 `Object` 上,会给宿主的每个对象加一个 `render`
457
+ > (并覆盖同名方法);「当前文档」是**类级状态**,所以**并行请用 Ractor**
458
+ > (类实例变量天然隔离),**`Thread` 并行会串上下文**。详见 [13. 边界](#13-边界与注意事项)。
459
+
460
+ ## 7. 翻译成 Ruby((2)):`MDOM::Translator`
461
+
462
+ 把 AST 翻成**一段顶层顺序执行的 Ruby 程序字符串**,不绑定执行方式(`eval`、落盘
463
+ `require`、交给 mruby 都行)。
464
+
465
+ ```ruby
466
+ require "mdom/parser"
467
+ require "mdom/translator"
468
+
469
+ document = MDOM::Parser.parse("- a\n")
470
+ MDOM::Translator.to_ruby(document) == document.to_ruby # => true
471
+ ```
472
+
473
+ 选项:`indent:`(产物缩进宽度,默认 2)、`builder:`(产物里 builder 变量的名字,
474
+ 默认 `"builder"`)、`unsafe:`(见下)。
475
+
476
+ ### 7.1 `unsafe`:默认安全
477
+
478
+ `unsafe` 是**翻译期**参数(CSM §3.14):
479
+
480
+ ```ruby
481
+ # 源里的 `#{total}` 是 CSM 插值;在 Ruby 字符串里写成 \#{total} 表示「字面的 #」
482
+ source = "~ total = 1\n\n合计 \#{total} 元。\n"
483
+ ast = MDOM::Parser.parse(source)
484
+
485
+ MDOM::Translator.to_ruby(ast)
486
+ # builder = MDOM::Builder.new
487
+ # # [csm] ruby 嵌入已省略(unsafe: false)
488
+ # builder.paragraph do
489
+ # builder.text("合计 ")
490
+ # builder.interpolation("total")
491
+ # builder.text(" 元。")
492
+ # end
493
+ # builder.to_document
494
+
495
+ MDOM::Translator.to_ruby(ast, unsafe: true)
496
+ # builder = MDOM::Builder.new
497
+ # total = 1
498
+ # builder.paragraph do
499
+ # builder.text("合计 ")
500
+ # builder.interpolation { total }
501
+ # builder.text(" 元。")
502
+ # end
503
+ # builder.to_document
504
+ ```
505
+
506
+ - **默认 `unsafe: false`(安全)**:嵌入段**不写进产物**——块级嵌入只留一行注释占位,
507
+ 行内插值变成 `builder.interpolation("expr")`(**未求值**的插值节点)。产物静态上
508
+ 不含用户代码。
509
+ - **`unsafe: true`**:嵌入块(`begin…end` / `~` 行)原样注入,行内插值写成
510
+ `builder.interpolation { expr }`,表达式交 Ruby 求值。
511
+
512
+ 安全模式的产物是**自洽**的:直接执行它不需要外部变量,插值节点保持未求值,于是 HTML
513
+ 侧照样按嵌入样式输出(CSM §3.14)。
514
+
515
+ ### 7.2 产物的结构约束
516
+
517
+ 排版段一律经由 `builder.*` 接收者,形式可辨识;嵌入段保持原样、边界明确。所以宿主
518
+ 可以判断「这份产物能否在 mruby 上跑」,或者把嵌入段剥离/替换。
519
+
520
+ ## 8. 执行((4)):宿主的一行 `eval`
521
+
522
+ **mdom 不提供求值器**——(4) 就是一次 `eval`:
523
+
524
+ ```ruby
525
+ require "mdom/parser"
526
+ require "mdom/translator"
527
+ require "mdom/builder" # 产物会用到 MDOM::Builder,也要它来补 Object#render
528
+
529
+ ast1 = MDOM::Parser.parse("正文\n")
530
+ ast2 = eval(MDOM::Translator.to_ruby(ast1, unsafe: true), binding, "doc.csm", 1)
531
+
532
+ ast2.class # => MDOM::Node::Document
533
+ ast2.children.first.type # => :paragraph
534
+ ```
535
+
536
+ 其它同样合法的途径:写入 `.rb` 后 `require` / `load`;编译成字节码后
537
+ `mrb_load_irep`;或纳入宿主的构建流程。
538
+
539
+ 产物第一行是 `builder = MDOM::Builder.new`,最后一行是 `builder.to_document`,所以
540
+ `eval` 的返回值就是 `Node::Document`(AST2)。
541
+
542
+ > **安全渲染不需要执行**:默认(`unsafe: false`)时直接拿 AST1 去 `to_html` 就行,
543
+ > 未求值的嵌入会以独立样式输出。
544
+
545
+ ## 9. 序列化成 HTML((6)):`MDOM::HtmlSerializer`
546
+
547
+ ```ruby
548
+ require "mdom/parser"
549
+ require "mdom/html_serializer"
550
+
551
+ document = MDOM::Parser.parse("# 标题\n")
552
+ MDOM::HtmlSerializer.serialize(document) == document.to_html # => true
553
+ ```
554
+
555
+ AST1 与 AST2 都能喂给它。
556
+
557
+ ### 9.1 自动包裹(CSM §1.2)
558
+
559
+ - 文档里有 `h1` → 整篇包进 `<article>`;
560
+ - `h2` 把自身到下一个 `h2`(或文末)的内容包进 `<section>`;
561
+ - 标题以**等长** `#` 收尾时(`## 标题 ##`),紧跟的段落与它同处一个 `<hgroup>`。
562
+
563
+ ```ruby
564
+ MDOM::HtmlSerializer.serialize(MDOM::Parser.parse("# 一 #\n\n段落\n\n## 二\n\ntext\n"))
565
+ # <article>
566
+ # <hgroup>
567
+ # <h1 id="一">一</h1>
568
+ # <p>段落</p>
569
+ # </hgroup>
570
+ # <section>
571
+ # <h2 id="二">二</h2>
572
+ # <p>text</p>
573
+ # </section>
574
+ # </article>
575
+ ```
576
+
577
+ 标题的 `id` 锚点来自标题的纯文本,空白连成 `-`。
578
+
579
+ ### 9.2 WikiLink 与 `known_targets`
580
+
581
+ ```ruby
582
+ MDOM::HtmlSerializer.serialize(MDOM::Parser.parse("[[目标]]\n"))
583
+ # => "<p><a href="目标">目标</a></p>"
584
+
585
+ # 给出已知目标集合后,不在其中的链接才带 orphan-link
586
+ MDOM::HtmlSerializer.serialize(MDOM::Parser.parse("[[缺失]]\n"), known_targets: ["别的"])
587
+ # => "<p><a href="缺失" class="orphan-link">缺失</a></p>"
588
+ ```
589
+
590
+ **省略 `known_targets` 时不做判定**(也就不会产生 `.orphan-link`)——目标的“存在与否”
591
+ 属于宿主的知识,mdom 不猜。
592
+
593
+ ### 9.3 未求值的嵌入
594
+
595
+ 安全模式下没有求值的嵌入仍然会出现在 HTML 里,用的是**与相应代码块同构**的样式,
596
+ 另加 `csm-ruby` class:
597
+
598
+ ```ruby
599
+ MDOM::HtmlSerializer.serialize(MDOM::Parser.parse("~ total = 1\n"))
600
+ # => "<pre><code class=\"csm-ruby\">total = 1</code></pre>"
601
+
602
+ MDOM::HtmlSerializer.serialize(MDOM::Parser.parse("合计 \#{total} 元。\n"))
603
+ # => "<p>合计 <code class=\"csm-ruby\">total</code> 元。</p>"
604
+ ```
605
+
606
+ `__END__` 之后的 Back Matter 不参与 HTML 输出(CSM §4)。
607
+
608
+ ## 10. 序列化成 CSM((3)(5)):`MDOM::MarkdownSerializer`
609
+
610
+ 同一个方法服务两种输入状态(CSM §6):
611
+
612
+ | 输入 | 嵌入节点 | 输出 | 保真 |
613
+ |------|----------|------|------|
614
+ | **AST1** | 还留着源码原文 | 原样回吐源码 | **保真往返** |
615
+ | **AST2** | 已被求值结果替换 | 只有结果 | **降级**(无法还原成 Ruby) |
616
+
617
+ ```ruby
618
+ require "mdom/parser"
619
+ require "mdom/markdown_serializer"
620
+
621
+ document = MDOM::Parser.parse("# 标题\n\n- 一\n- 二\n")
622
+ document.to_markdown
623
+ # => "# 标题\n\n- 一\n- 二"
624
+
625
+ MDOM::MarkdownSerializer.serialize(document) # 与上面等价
626
+ ```
627
+
628
+ **从结构重建,不读原文缓存。** `attributes[:text]` 只是缓存,序列化看的是行内节点——
629
+ 所以改过的树,输出会反映改动:
630
+
631
+ ```ruby
632
+ heading = document.children.first
633
+ heading.children.replace(MDOM::InlineParser.parse("改过的"))
634
+ document.to_markdown
635
+ # => "# 改过的\n\n- 一\n- 二"
636
+ ```
637
+
638
+ **保守转义。** 文本里凡是可能重新触发解析的字符都会被转义:
639
+
640
+ ```ruby
641
+ MDOM::Parser.parse("含 * 与 ` 与 [ 的文本\n").to_markdown
642
+ # => "含 \\* 与 \\\` 与 \\[ 的文本"
643
+ ```
644
+
645
+ 代价是输出比手写的 CSM 更谨慎,换来的是「内容不会被自己的标记重新解析」这一稳定性质。
646
+
647
+ **缩进按节点深度重建**(4 空格一级)。嵌入块在 AST 里只存「已剥离块首缩进」的字符串
648
+ (CSM §3.6),回吐时按树中位置重新缩进——所以块内相对缩进保留,CSM 层级被剥掉:
649
+
650
+ ```ruby
651
+ MDOM::Parser.parse(" begin\n q = \"Hi\"\n end.render\n").to_markdown
652
+ # => "begin\n q = \"Hi\"\nend.render"
653
+ ```
654
+
655
+ **往返验收是「树形相同 + 序列化幂等」**,不是逐字节还原——软换行、多余缩进本来就不
656
+ 承载语义。输出**不带**末尾换行。
657
+
658
+ ## 11. 钩子与回调(CSM §5.3)
659
+
660
+ `MDOM.compile` 会在**五个点**上触发注册的回调:
661
+
662
+ | 点 | 时机 | 载荷 |
663
+ |----|------|------|
664
+ | `:after_parse` | 解析完成、行内已解析 | `doc` |
665
+ | `:before_compile` | 翻译之前——**树仍可改写** | `doc` |
666
+ | `:after_compile` | 翻译完成 | `doc`、`ruby` |
667
+ | `:before_serialize` | 序列化之前 | `doc` |
668
+ | `:after_serialize` | 序列化之后 | `html`、`meta` |
669
+
670
+ ```ruby
671
+ require "mdom"
672
+
673
+ MDOM::Node::Document.register_hook :before_compile do |doc|
674
+ doc.meta[:generated_at] = "2026-09-25"
675
+ end
676
+
677
+ MDOM::Node::Document.register_hook :after_serialize do |html, meta|
678
+ html << "\n<!-- generated_at: #{meta[:generated_at]} -->"
679
+ end
680
+
681
+ result = MDOM.compile("# 标题\n")
682
+ result.meta # => { generated_at: "2026-09-25" }
683
+ result.html # 结尾多出那行注释
684
+ ```
685
+
686
+ **回调的返回值被忽略**——要改东西就往载荷里的可变对象上改(节点树、`ruby` / `html`
687
+ 字符串)。`meta` 就是文档的元数据 Hash(`doc.meta`)。
688
+
689
+ **只想影响一次调用**时用 `hooks:`:
690
+
691
+ ```ruby
692
+ seen = []
693
+ MDOM.compile("正文\n", hooks: { after_parse: [->(doc) { seen << doc.type }] })
694
+ seen # => [:document]
695
+ MDOM::Hooks.registered(:after_parse) # => [] (临时钩子不进全局注册表)
696
+ ```
697
+
698
+ 执行顺序是**全局(按注册顺序)→ 临时**;未知的点名抛 `ArgumentError`;某个回调抛异常
699
+ 会直接传播,其后的回调不再执行。注册表本身也可直接操作:
700
+ `MDOM::Hooks.register` / `unregister` / `clear` / `registered`。
701
+
702
+ ## 12. 错误
703
+
704
+ | 命名空间 | 基类 | 子类 |
705
+ |----------|------|------|
706
+ | 解析 / 翻译 | `MDOM::Error` | `MDOM::ParseError` → `CrossLevelError`、`UnclosedFenceError`、`UnclosedMathError` |
707
+ | 树操作 | `MDOM::Node::Error` | `RootError`、`BoundaryError`、`CycleError` |
708
+
709
+ ~~~ruby
710
+ begin
711
+ MDOM::Parser.parse("```ruby\nx = 1\n")
712
+ rescue MDOM::ParseError => e
713
+ warn e.message # => unclosed code fence opened at line 1
714
+ end
715
+ ~~~
716
+
717
+ ## 13. 边界与注意事项
718
+
719
+ **两个目标平台。** builder 侧(`builder` / `nodes` / `node` / `html_serializer`)
720
+ 只用 mruby 子集、零 gem 依赖,可在 WASM 等受限环境运行;解析侧(`parser` /
721
+ `translator` 等)硬依赖 `prism`,只在 CRuby 上运行。两侧没有调用关系。
722
+
723
+ **「当前文档」是类级状态。** 见 [6.4](#64-值render-与当前文档):并行请用 **Ractor**
724
+ (类实例变量按 Ractor 隔离);**`Thread` 并行会串上下文**,不支持。跨文档的并行最终
725
+ 应由「按文档依赖关系解算的渲染 DAG」承担,**尚未实现**。
726
+
727
+ **`render` 的锁。** `Builder#render` 内部持一把互斥锁(CSM §3.3),只保证结构不被
728
+ 撕裂、**不保证挂载位置**(「当前容器」是共享的游标栈);mruby 的默认构建没有
729
+ `Thread`/`Mutex`,那边退化为无锁。
730
+
731
+ **UTF-8 / CJK。** CSM 的软换行折叠依赖字符级判断,mruby 需以 `MRB_UTF8_STRING` 构建;
732
+ 按字节构建时该规则会降级,属已知限制。
733
+
734
+ **尚未实现。** Linter、跨文档组合;CSM §5.1 的注册宏与 §5.2 的查询引擎。
735
+ `MDOM.compile` 也暂不产出 Markdown(需要时自己调 `node.to_markdown`)。
736
+
737
+ **heredoc 不能单独成块。** CSM §3.12 的取舍(非 mruby 限制):写成自由块或在
738
+ `begin...end` 内时,它本身就位于 Ruby 代码中,**自然可用**;单独一行(`sql = <<~SQL`、
739
+ `puts <<~SQL`)则不构成块,按排版处理、不报错。但 heredoc **不能破坏块的缩进**——
740
+ 把 body 也缩进到块内,并推荐 Squiggly Heredoc(`<<~`):它剥掉 body 的公共缩进,
741
+ 因此对齐缩进不会改变字符串内容。
742
+
743
+ ## 14. 配方
744
+
745
+ ### 14.1 只做块结构,稍后再解析行内
746
+
747
+ ```ruby
748
+ document = MDOM::Parser.parse("# 标题\n", inline: false) # 快
749
+ document.children.first.children # => [](还没做行内解析)
750
+ document.children.first.attributes[:text] # => "标题"(原文仍在)
751
+
752
+ # 需要行内节点时:重新解析一次原文(或直接用 InlineParser)
753
+ MDOM::Parser.parse("# 标题\n").children.first.children.map(&:type) # => [:text]
754
+ ```
755
+
756
+ ### 14.2 任务列表
757
+
758
+ ```ruby
759
+ ast = MDOM::Parser.parse("- [ ] 待办\n- [x] 完成\n")
760
+ ast.children.first.task? # => true
761
+ ast.children.first.children.map(&:checked) # => [false, true]
762
+
763
+ MDOM::HtmlSerializer.serialize(ast)
764
+ # <ul class="task">
765
+ # <li>
766
+ # <input type="checkbox" />
767
+ # <p>待办</p>
768
+ # …
769
+ ```
770
+
771
+ ### 14.3 表格
772
+
773
+ ```ruby
774
+ table = MDOM::Parser.parse("| a | b |\n|:--|--:|\n| 1 | 2 |\n").children.first
775
+ table.children.first.children.map(&:type) # => [:table_header_cell, :table_header_cell]
776
+ table.children.last.children.map(&:align) # => [:left, :right]
777
+
778
+ MDOM::HtmlSerializer.serialize(table)
779
+ # <table>
780
+ # <thead>
781
+ # <tr>
782
+ # <th style="text-align: left;">a</th>
783
+ # <th style="text-align: right;">b</th>
784
+ # </tr>
785
+ # </thead>
786
+ # <tbody>
787
+ # …
788
+ ```
789
+
790
+ ### 14.4 公式
791
+
792
+ ```ruby
793
+ # 行内
794
+ MDOM::Parser.parse("$x^2$\n").children.first.children.first.type # => :math
795
+ # 块级(`$$` 独占一行的包围式也可以)
796
+ MDOM::Parser.parse("$$\nx = 1\n$$\n").children.first.tex # => "x = 1"
797
+ ```
798
+
799
+ ### 14.5 嵌入代码
800
+
801
+ ```ruby
802
+ require "mdom/parser"
803
+ require "mdom/translator"
804
+ require "mdom/builder"
805
+
806
+ ast = MDOM::Parser.parse(<<~'CSM')
807
+ ~ total = 1 + 2
808
+
809
+ 合计 #{total} 元。
810
+ CSM
811
+
812
+ total = 3
813
+ eval(MDOM::Translator.to_ruby(ast, unsafe: true), binding).to_html
814
+ # => "<p>合计 3 元。</p>"
815
+ ```
816
+
817
+ ### 14.6 从已有文档继续构造
818
+
819
+ ```ruby
820
+ document = MDOM::Parser.parse("# 标题\n")
821
+ builder = MDOM::Builder.new(document: document)
822
+ builder.p "追加的段落"
823
+ builder.to_document.children.size # => 2
824
+ ```
825
+
826
+ ### 14.7 程序化生成目录
827
+
828
+ 在 `:before_compile` 里扫标题、造一个列表、插到文档开头——此时树还没被翻译,所以改动能
829
+ 同时进 `ruby` 与 `html`:
830
+
831
+ ```ruby
832
+ require "mdom"
833
+
834
+ MDOM::Node::Document.register_hook :before_compile do |doc|
835
+ headings = doc.select { |node| node.type == :heading }
836
+ next if headings.empty?
837
+
838
+ toc = MDOM::Builder.build do |b|
839
+ b.list { |l| headings.each { |h| l.item { |i| i.render(h.text) } } }
840
+ end
841
+ doc.prepend(toc.children.first)
842
+ end
843
+
844
+ result = MDOM.compile("# 一\n\n## 二\n\n正文\n")
845
+ puts result.html
846
+ # <article>
847
+ # <ul>
848
+ # <li>
849
+ # <p>一</p>
850
+ # </li>
851
+ # <li>
852
+ # <p>二</p>
853
+ # </li>
854
+ # </ul>
855
+ # <h1 id="一">一</h1>
856
+ # <section>
857
+ # <h2 id="二">二</h2>
858
+ # <p>正文</p>
859
+ # </section>
860
+ # </article>
861
+ ```
862
+
863
+ `Builder.build` 会返回一个**新文档**,所以取它的 `children.first`(那个列表)再
864
+ `prepend` 到目标文档上;`doc.select` 来自 `Enumerable`,扫的是整棵子树。