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.
@@ -0,0 +1,390 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "error"
4
+ require_relative "node/inline_nodes"
5
+ require_relative "node/nodes"
6
+
7
+ module MDOM
8
+ # (3)/(5) AST → CSM 源(CSM §6;docs/IMPLEMENTATION.md §6)。
9
+ #
10
+ # 同一个方法服务两种输入状态:
11
+ #
12
+ # | 输入 | 嵌入节点 | 输出 | 保真 |
13
+ # |------|----------|------|------|
14
+ # | AST1 | 源码原文 | 原样回吐源码 | **保真往返** |
15
+ # | AST2 | 已被结果替换 | 只有结果 | **降级**(无法还原成 Ruby) |
16
+ #
17
+ # MDOM::MarkdownSerializer.serialize(MDOM::Parser.parse("# 标题\n"))
18
+ # # => "# 标题"
19
+ #
20
+ # 序列化**从节点结构重建**,不读 +attributes[:text]+ 那类原文缓存——树被改过之后
21
+ # 输出反映改动。唯一的例外是嵌入节点:它本来就承载 +source+ 原文,AST1 因此保真。
22
+ #
23
+ # == 缩进
24
+ #
25
+ # 输出统一用 4 个空格作 1 个缩进单位(CSM §1.1:1 个制表符或 4 个空格)。嵌入块
26
+ # 在 AST 里只存「已剥离块首缩进」的字符串(CSM §3.6),回吐时按**节点在树中的
27
+ # 深度**重新缩进——层级由树位置决定,不由字符串承载。
28
+ #
29
+ # == 转义
30
+ #
31
+ # 行内文本采取**保守转义**:凡是可能重新触发解析的字符(`\` `` ` `` `*` `~` `[`
32
+ # `]` `$`)以及插值起始的 `#{` 一律转义(CSM §3.11 的 `\` 规则)。代价是输出比
33
+ # 手写的 CSM 更谨慎,换来的是「不会被自己的内容重新解析」这一稳定性质。
34
+ #
35
+ # == 依赖边界
36
+ #
37
+ # 本文件属 **builder 侧**(docs/IMPLEMENTATION.md §1.1):零 gem 依赖、不
38
+ # `require` prism、不使用正则字面量,因此 mruby / WASM 也能跑。
39
+ class MarkdownSerializer
40
+ # 一个缩进单位(CSM §1.1)。
41
+ INDENT = " "
42
+
43
+ # 行内文本的单字符转义表(保守策略)。
44
+ TEXT_ESCAPES = {
45
+ "\\" => "\\\\",
46
+ "`" => "\\`",
47
+ "*" => "\\*",
48
+ "~" => "\\~",
49
+ "[" => "\\[",
50
+ "]" => "\\]",
51
+ "$" => "\\$",
52
+ "<" => "\\<",
53
+ "@" => "\\@",
54
+ "!" => "\\!"
55
+ }.freeze
56
+
57
+ # 出现在文本节点**开头**时可能开启块级构造的字符。行首 `\` 只跳过块级判定,
58
+ # 随后的 `\` 仍按行内转义处理,所以这里加一个 `\` 就能让整行保持为段落。
59
+ BLOCK_START_ESCAPES = ["#", ">", "-", ":", "|", "_", "(", "{", "%"].freeze
60
+
61
+ # 分隔线(CSM §1.6:3–72 个 `-`)。
62
+ HRULE = "---"
63
+
64
+ # 行内节点的类型名,用来区分 #serialize 收到的是一棵行内子树还是块。
65
+ INLINE_TYPES = %i[
66
+ text emphasis strong code_span link inline_quote image wiki_link mention
67
+ del ins hard_break meter progress math interpolation
68
+ ].freeze
69
+
70
+ def self.serialize(node)
71
+ new.serialize(node)
72
+ end
73
+
74
+ # 序列化一棵树(+Node::Document+ / 任意块级节点 / 任意行内节点),返回 CSM 源码。
75
+ # 块级输出**不带**末尾换行。
76
+ def serialize(node)
77
+ case node.type
78
+ when :document then blocks(node.children, 0).join("\n")
79
+ when *INLINE_TYPES then inline(node)
80
+ else block(node, 0).join("\n")
81
+ end
82
+ end
83
+
84
+ private
85
+
86
+ # -- 块级 -----------------------------------------------------------------
87
+
88
+ # 一串同级块,行数组。块之间空一行;**节块**(+Div+,CSM §1.1 的 `\n\n\n\n`)
89
+ # 与邻居之间空三行,这样往返时 +Div+ 还能重建出来。
90
+ def blocks(children, depth)
91
+ buffer = []
92
+ previous = nil
93
+ children.each do |child|
94
+ lines = block(child, depth)
95
+ next if lines.empty?
96
+
97
+ buffer.concat(gap_for(previous, child.type)) if previous
98
+ buffer.concat(lines)
99
+ previous = child.type
100
+ end
101
+ buffer
102
+ end
103
+
104
+ def gap_for(previous, current)
105
+ previous == :div || current == :div ? ["", "", ""] : [""]
106
+ end
107
+
108
+ # 单个块 → 行数组(每行已含 +depth+ 级缩进)。
109
+ def block(node, depth)
110
+ pad = INDENT * depth
111
+ case node.type
112
+ when :paragraph then indented_text(pad, inline_children(node))
113
+ when :heading then ["#{pad}#{heading_text(node)}"]
114
+ when :hrule then ["#{pad}#{HRULE}"]
115
+ when :code_block then code_block(node, pad)
116
+ when :math_block then math_block(node, pad)
117
+ when :list then list(node, depth)
118
+ when :definition_list then definition_list(node, depth)
119
+ when :blockquote then blockquote(node, depth)
120
+ when :table then table(node, pad)
121
+ when :div then blocks(node.children, depth)
122
+ when :ruby_embed then embed(node, pad)
123
+ when :end_marker then end_marker(node)
124
+ else indented_text(pad, inline_children(node))
125
+ end
126
+ end
127
+
128
+ # 段落内容可能因 #HardBreak 而断开,所以按行铺开。
129
+ def indented_text(pad, content)
130
+ "#{pad}#{content}".split("\n", -1)
131
+ end
132
+
133
+ def heading_text(node)
134
+ hashes = "#" * node.level.to_i
135
+ line = "#{hashes} #{inline_children(node)}"
136
+ node.closed? ? "#{line} #{hashes}" : line
137
+ end
138
+
139
+ def code_block(node, pad)
140
+ fence = "`" * (node.fence || 3).to_i
141
+ header = [node.lang, node.params].compact.reject(&:empty?).join(" ")
142
+ opening = header.empty? ? fence : "#{fence}#{header}"
143
+ body = node.code.to_s.split("\n", -1).map { |line| line.empty? ? "" : "#{pad}#{line}" }
144
+ ["#{pad}#{opening}", *body, "#{pad}#{fence}"]
145
+ end
146
+
147
+ # 块级公式:单行形式(`$$…$$`)能装下就用单行,否则退回 `$$` 独占一行的包围式。
148
+ def math_block(node, pad)
149
+ tex = node.tex.to_s
150
+ return ["#{pad}$$#{tex}$$"] unless tex.include?("\n")
151
+
152
+ ["#{pad}$$", *tex.split("\n", -1).map { |line| line.empty? ? "" : "#{pad}#{line}" }, "#{pad}$$"]
153
+ end
154
+
155
+ # -- 列表 -----------------------------------------------------------------
156
+
157
+ def list(node, depth)
158
+ node.children.flat_map.with_index do |item, index|
159
+ list_item(item, depth, marker_for(node, item, index))
160
+ end
161
+ end
162
+
163
+ def marker_for(list_node, item, index)
164
+ return "#{index + 1}. " if list_node.ordered?
165
+ return "- [#{item.checked ? "x" : " "}] " if list_node.task?
166
+
167
+ "#{item.marker || "-"} "
168
+ end
169
+
170
+ # 列表项的首个子块接在标记后面,其余子块整块缩进一级。
171
+ def list_item(item, depth, marker)
172
+ pad = INDENT * depth
173
+ body = item.children.flat_map { |child| block(child, depth + 1) }
174
+ return ["#{pad}#{marker.strip}"] if body.empty?
175
+
176
+ ["#{pad}#{marker}#{body.first.strip}", *body.drop(1)]
177
+ end
178
+
179
+ def definition_list(node, depth)
180
+ pad = INDENT * depth
181
+ node.children.flat_map do |child|
182
+ if child.type == :term
183
+ ["#{pad}#{inline_children(child)}"]
184
+ else
185
+ body = child.children.flat_map { |grandchild| block(grandchild, depth) }
186
+ first = body.first || ""
187
+ ["#{pad}: #{first.strip}", *body.drop(1)]
188
+ end
189
+ end
190
+ end
191
+
192
+ # -- 引用 -----------------------------------------------------------------
193
+
194
+ # 正文各行加 `> ` 前缀;最后一行以 `>-` 收尾时提取为 `cite`(CSM §1.4)。
195
+ def blockquote(node, depth)
196
+ pad = INDENT * depth
197
+ body = node.children.reject { |child| child.type == :cite }
198
+ quoted = body.flat_map { |child| block(child, depth) }.map { |line| quote(line, pad) }
199
+ cite = node.children.find { |child| child.type == :cite }
200
+ quoted << "#{pad}>- #{inline_children(cite)}" if cite
201
+ quoted
202
+ end
203
+
204
+ # 空行保留成 `>`,其余行剥掉公共缩进后加 `> `(不重复缩进)。
205
+ def quote(line, pad)
206
+ rest = line.start_with?(pad) ? line[pad.length..] : line
207
+ rest.empty? ? "#{pad}>" : "#{pad}> #{rest}"
208
+ end
209
+
210
+ # -- 表格 -----------------------------------------------------------------
211
+
212
+ def table(node, pad)
213
+ rows = node.children.reject { |row| row.children.empty? }
214
+ return [] if rows.empty?
215
+
216
+ lines = []
217
+ first = rows.first
218
+ # 没有表头但需要对齐时,用开头的分隔行表达(CSM §1.12);它不产生行。
219
+ lines << separator_row(first, pad) if !header_row?(first) && first.children.any?(&:align)
220
+
221
+ rows.each do |row|
222
+ lines << table_row(row, pad)
223
+ # 分隔行既把前一行提升为表头,也设定它自己与后续行的对齐。每个表头行后面
224
+ # 都要跟一条,否则连续表头/中途表头会在往返时退化成数据行。
225
+ lines << separator_row(row, pad) if header_row?(row)
226
+ end
227
+ lines
228
+ end
229
+
230
+ def header_row?(row)
231
+ row.children.all? { |cell| cell.type == :table_header_cell }
232
+ end
233
+
234
+ def table_row(row, pad)
235
+ cells = row.children.map { |cell| " #{escape_table_pipes(inline_children(cell))} " }
236
+ "#{pad}|#{cells.join("|")}|"
237
+ end
238
+
239
+ # 表格在行内解析**之前**按未转义的 `|` 切格,因此单元格里的字面 `|` 必须
240
+ # 变成奇数个反斜杠 + `|`,切格器才会把它当内容而不是分隔符。
241
+ def escape_table_pipes(text)
242
+ out = +""
243
+ slashes = +""
244
+ text.each_char do |char|
245
+ if char == "\\"
246
+ slashes << char
247
+ next
248
+ end
249
+
250
+ out << slashes
251
+ out << "\\" if char == "|" && slashes.length.even?
252
+ slashes.clear
253
+ out << char
254
+ end
255
+ out << slashes unless slashes.empty?
256
+ out
257
+ end
258
+
259
+ def separator_row(row, pad)
260
+ cells = row.children.map { |cell| separator_cell(cell.align) }
261
+ "#{pad}|#{cells.join("|")}|"
262
+ end
263
+
264
+ def separator_cell(align)
265
+ case align
266
+ when :left then ":--"
267
+ when :right then "--:"
268
+ when :center then ":-:"
269
+ else "---"
270
+ end
271
+ end
272
+
273
+ # -- 嵌入与元数据 ---------------------------------------------------------
274
+
275
+ # 回吐嵌入源码(AST1 保真);按节点深度重新缩进(CSM §3.6)。`~` 形式每行都
276
+ # 要重新加 `~ ` 前缀(CSM §3.4)。
277
+ def embed(node, pad)
278
+ lines = node.source.to_s.split("\n", -1)
279
+ return lines.map { |line| line.empty? ? "" : "#{pad}~ #{line}" } if node.form == :tilde
280
+
281
+ lines.map { |line| line.empty? ? "" : "#{pad}#{line}" }
282
+ end
283
+
284
+ def end_marker(node)
285
+ data = node.data.to_s
286
+ return ["__END__"] if data.empty?
287
+
288
+ ["__END__", *data.split("\n", -1)]
289
+ end
290
+
291
+ # -- 行内 -----------------------------------------------------------------
292
+
293
+ def inline_children(node)
294
+ node.children.map { |child| inline(child) }.join
295
+ end
296
+
297
+ def inline(node)
298
+ case node.type
299
+ when :text then escape_text(node.text)
300
+ when :emphasis then "*#{inline_children(node)}*"
301
+ when :strong then "**#{inline_children(node)}**"
302
+ when :code_span then code_span(node)
303
+ when :link then "[#{inline_children(node)}](#{node.uri}#{title_suffix(node.title)})"
304
+ when :inline_quote then "[#{inline_children(node)}](@#{node.uri})"
305
+ when :image then "![#{node.alt}](#{node.uri}#{title_suffix(node.title)})"
306
+ when :wiki_link then wiki_link(node)
307
+ when :mention then mention(node)
308
+ when :del then "~~#{inline_children(node)}~~"
309
+ when :ins then "~+#{inline_children(node)}+~"
310
+ when :hard_break then "\\\n"
311
+ when :meter then "[=#{node.value}/#{node.max}]"
312
+ when :progress then "[->#{node.value}/#{node.max}]"
313
+ when :math then "$#{node.tex}$"
314
+ when :interpolation then "\#{#{node.expr}}"
315
+ else ""
316
+ end
317
+ end
318
+
319
+ def code_span(node)
320
+ code = node.code.to_s
321
+ fence = code.include?("`") ? "``" : "`"
322
+ "#{fence}#{code}#{fence}"
323
+ end
324
+
325
+ def wiki_link(node)
326
+ label = node.label
327
+ return "[[#{node.target}]]" if label.nil? || label.to_s.empty?
328
+
329
+ "[[#{node.target}|#{label}]]"
330
+ end
331
+
332
+ def mention(node)
333
+ kind = node.kind
334
+ kind.nil? || kind.to_s.empty? ? "@#{node.name}" : "@#{kind}:#{node.name}"
335
+ end
336
+
337
+ # `[文本](uri "title")` 的可选 title 部分。
338
+ def title_suffix(title)
339
+ title.nil? || title.to_s.empty? ? "" : " \"#{title}\""
340
+ end
341
+
342
+ # 保守转义:单字符查表 + `#{` 的起始 `#`(CSM §3.11、§3.13)。
343
+ #
344
+ # 文本节点可能正好落在输出行的开头(段落第一段,或硬换行之后)。这时再补一个
345
+ # 行首 `\`,避免 `#` / `>` / `-` / `:` / `|` / `__END__` 被重新识别为块级构造。
346
+ # 行首 `\` 不参与块级判定,但仍按行内转义处理,所以不会污染文本内容。
347
+ def escape_text(text)
348
+ source = text.to_s
349
+ characters = source.chars
350
+ ordered_dot = ordered_list_dot_index(characters)
351
+ out = +""
352
+
353
+ characters.each_with_index do |char, index|
354
+ if char == "#" && characters[index + 1] == "{"
355
+ out << "\\#"
356
+ else
357
+ out << "\\" if index.zero? && block_start?(char)
358
+ out << (index == ordered_dot ? "\\." : (TEXT_ESCAPES[char] || char))
359
+ end
360
+ end
361
+ out
362
+ end
363
+
364
+ def block_start?(char)
365
+ BLOCK_START_ESCAPES.include?(char)
366
+ end
367
+
368
+ # 行首的 `N.` 会被识别为有序列表;反斜杠不能转义数字,改为转义那个点。
369
+ def ordered_list_dot_index(characters)
370
+ cursor = 0
371
+ cursor += 1 while cursor < characters.length && cursor < 9 &&
372
+ characters[cursor] >= "0" && characters[cursor] <= "9"
373
+
374
+ return cursor if cursor.positive? && characters[cursor] == "."
375
+
376
+ nil
377
+ end
378
+ end
379
+ end
380
+
381
+ module MDOM
382
+ module Node
383
+ # 便捷入口:+node.to_markdown+ ≡ +MDOM::MarkdownSerializer.serialize(node)+。
384
+ class BaseNode
385
+ def to_markdown
386
+ MarkdownSerializer.serialize(self)
387
+ end
388
+ end
389
+ end
390
+ end
@@ -0,0 +1,239 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../node"
4
+
5
+ # 行内节点:由 +InlineParser+ 从一段行内文本产出,只出现在行内容器
6
+ # (段落、标题、列表项、引用、定义……)的 +children+ 里。
7
+ #
8
+ # 与块级节点一样,每个类只固定自己的 +type+ 与语义属性。产出的是**未求值**的
9
+ # 节点:`#{}` 只保存表达式原文,公式只保存 TeX 原文。
10
+ module MDOM
11
+ module Node
12
+ # 行内节点的标记模块,便于与块级节点区分。
13
+ module Inline; end
14
+
15
+ # 行内纯文本。
16
+ class Text < BaseNode
17
+ include Inline
18
+
19
+ def initialize(text = "", attributes: {}, children: [])
20
+ super(:text, attributes: { text: text }.merge(attributes), children: children)
21
+ end
22
+
23
+ def text
24
+ attributes[:text]
25
+ end
26
+
27
+ def text=(value)
28
+ attributes[:text] = value
29
+ end
30
+ end
31
+
32
+ # `*文本*` → `<em>`。
33
+ class Emphasis < BaseNode
34
+ include Inline
35
+
36
+ def initialize(children: [], attributes: {})
37
+ super(:emphasis, attributes: attributes, children: children)
38
+ end
39
+ end
40
+
41
+ # `**文本**` → `<strong>`。
42
+ class Strong < BaseNode
43
+ include Inline
44
+
45
+ def initialize(children: [], attributes: {})
46
+ super(:strong, attributes: attributes, children: children)
47
+ end
48
+ end
49
+
50
+ # `` `代码` `` → `<code>`。内容按字面处理,不再解析。
51
+ class CodeSpan < BaseNode
52
+ include Inline
53
+
54
+ def initialize(code = "", attributes: {}, children: [])
55
+ super(:code_span, attributes: { code: code }.merge(attributes), children: children)
56
+ end
57
+
58
+ def code
59
+ attributes[:code]
60
+ end
61
+ end
62
+
63
+ # `[文本](uri "title")`、`<uri>` → `<a>`。
64
+ class Link < BaseNode
65
+ include Inline
66
+
67
+ def initialize(uri, title: nil, children: [], attributes: {})
68
+ super(:link, attributes: { uri: uri, title: title }.merge(attributes), children: children)
69
+ end
70
+
71
+ def uri
72
+ attributes[:uri]
73
+ end
74
+
75
+ def title
76
+ attributes[:title]
77
+ end
78
+ end
79
+
80
+ # `[文本](@uri)` → `<q cite="uri">`。
81
+ class InlineQuote < BaseNode
82
+ include Inline
83
+
84
+ def initialize(uri, children: [], attributes: {})
85
+ super(:inline_quote, attributes: { uri: uri }.merge(attributes), children: children)
86
+ end
87
+
88
+ def uri
89
+ attributes[:uri]
90
+ end
91
+ end
92
+
93
+ # `![alt](uri "title")` → `<img>`(空元素)。+alt+ 保持原文,+children+ 是它
94
+ # 解析出的行内节点(含 `#{}` 插值)——`<img>` 没有子内容,这里只承载替代文本。
95
+ class Image < BaseNode
96
+ include Inline
97
+
98
+ def initialize(uri, alt: "", title: nil, children: [], attributes: {})
99
+ super(:image, attributes: { uri: uri, alt: alt, title: title }.merge(attributes), children: children)
100
+ end
101
+
102
+ def uri
103
+ attributes[:uri]
104
+ end
105
+
106
+ def alt
107
+ attributes[:alt]
108
+ end
109
+
110
+ def title
111
+ attributes[:title]
112
+ end
113
+ end
114
+
115
+ # `[[target|label]]` → `<a>`。+label+ 省略时用 +target+ 作显示文本。
116
+ # +children+ 是显示文本解析出的行内节点(含 `#{}` 插值)。
117
+ class WikiLink < BaseNode
118
+ include Inline
119
+
120
+ def initialize(target, label: nil, children: [], attributes: {})
121
+ super(:wiki_link, attributes: { target: target, label: label }.merge(attributes), children: children)
122
+ end
123
+
124
+ def target
125
+ attributes[:target]
126
+ end
127
+
128
+ def label
129
+ attributes[:label]
130
+ end
131
+ end
132
+
133
+ # `@user` / `@project:x` / `@tag:kw` → `<a>`。+kind+ 是 `:` 前的作用域。
134
+ class Mention < BaseNode
135
+ include Inline
136
+
137
+ def initialize(name, kind: nil, attributes: {})
138
+ super(:mention, attributes: { name: name, kind: kind }.merge(attributes))
139
+ end
140
+
141
+ def name
142
+ attributes[:name]
143
+ end
144
+
145
+ def kind
146
+ attributes[:kind]
147
+ end
148
+ end
149
+
150
+ # `~~文本~~` → `<del>`。
151
+ class Del < BaseNode
152
+ include Inline
153
+
154
+ def initialize(children: [], attributes: {})
155
+ super(:del, attributes: attributes, children: children)
156
+ end
157
+ end
158
+
159
+ # `~+文本+~` → `<ins>`。
160
+ class Ins < BaseNode
161
+ include Inline
162
+
163
+ def initialize(children: [], attributes: {})
164
+ super(:ins, attributes: attributes, children: children)
165
+ end
166
+ end
167
+
168
+ # 行尾 `\` → `<br>`(空元素)。
169
+ class HardBreak < BaseNode
170
+ include Inline
171
+
172
+ def initialize(attributes: {})
173
+ super(:hard_break, attributes: attributes)
174
+ end
175
+ end
176
+
177
+ # `[=m/n]` → `<meter value="m" max="n">`(空元素)。+value+ / +max+ 保持原文,
178
+ # +children+ 是 `m/n` 解析出的行内节点(含 `#{}` 插值)。
179
+ class Meter < BaseNode
180
+ include Inline
181
+
182
+ def initialize(value, max, children: [], attributes: {})
183
+ super(:meter, attributes: { value: value, max: max }.merge(attributes), children: children)
184
+ end
185
+
186
+ def value
187
+ attributes[:value]
188
+ end
189
+
190
+ def max
191
+ attributes[:max]
192
+ end
193
+ end
194
+
195
+ # `[->m/n]` → `<progress value="m" max="n">`(空元素)。语义同 +Meter+。
196
+ class Progress < BaseNode
197
+ include Inline
198
+
199
+ def initialize(value, max, children: [], attributes: {})
200
+ super(:progress, attributes: { value: value, max: max }.merge(attributes), children: children)
201
+ end
202
+
203
+ def value
204
+ attributes[:value]
205
+ end
206
+
207
+ def max
208
+ attributes[:max]
209
+ end
210
+ end
211
+
212
+ # `$\TeX$` → 行内 `<span class="math">`(TeX 原文,不渲染成 SVG)。+tex+ 保持
213
+ # 原文,+children+ 是 TeX 解析出的行内节点(含 `#{}` 插值)。
214
+ class Math < BaseNode
215
+ include Inline
216
+
217
+ def initialize(tex = "", children: [], attributes: {})
218
+ super(:math, attributes: { tex: tex }.merge(attributes), children: children)
219
+ end
220
+
221
+ def tex
222
+ attributes[:tex]
223
+ end
224
+ end
225
+
226
+ # `#{expr}`:交给 Ruby 求值,此处只保存表达式原文、不求值。
227
+ class Interpolation < BaseNode
228
+ include Inline
229
+
230
+ def initialize(expr = "", attributes: {}, children: [])
231
+ super(:interpolation, attributes: { expr: expr }.merge(attributes), children: children)
232
+ end
233
+
234
+ def expr
235
+ attributes[:expr]
236
+ end
237
+ end
238
+ end
239
+ end