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