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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +71 -0
- data/LICENSE.txt +21 -0
- data/README.md +285 -0
- data/USAGE.md +789 -0
- data/lib/mdom/block_detector.rb +200 -0
- data/lib/mdom/builder.rb +486 -0
- data/lib/mdom/error.rb +22 -0
- data/lib/mdom/hooks.rb +81 -0
- data/lib/mdom/html_serializer.rb +330 -0
- data/lib/mdom/inline_parser.rb +541 -142
- data/lib/mdom/markdown_serializer.rb +332 -0
- data/lib/mdom/node/inline_nodes.rb +239 -0
- data/lib/mdom/node/nodes.rb +336 -0
- data/lib/mdom/node.rb +677 -199
- data/lib/mdom/parser.rb +257 -235
- data/lib/mdom/tokenizer.rb +415 -0
- data/lib/mdom/translator.rb +379 -0
- data/lib/mdom/version.rb +1 -1
- data/lib/mdom.rb +59 -7
- metadata +41 -7
- data/lib/mdom/nodes.rb +0 -214
- data/lib/mdom/serializer.rb +0 -165
|
@@ -0,0 +1,415 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "error"
|
|
4
|
+
require_relative "block_detector"
|
|
5
|
+
|
|
6
|
+
module MDOM
|
|
7
|
+
# 行级 Tokenizer:按 CSM §3 的分类规则逐行分类(docs/IMPLEMENTATION.md §2.1)。
|
|
8
|
+
#
|
|
9
|
+
# 分类顺序(优先级从高到低):**围栏 → 表格 → 块级公式 → `\` 转义 → `~` →
|
|
10
|
+
# 候选嵌入 → 排版**。
|
|
11
|
+
# `~` 的判定必须先于候选嵌入——`~ list.each do |item|` 是 `~` 行,不能按候选
|
|
12
|
+
# 行交给 Prism。
|
|
13
|
+
#
|
|
14
|
+
# 产出的最小 Token 集合见 docs/IMPLEMENTATION.md §2.3。对嵌入,Tokenizer 只
|
|
15
|
+
# 交出**源码原文 + 位置 + 形态**,不判断哪行是赋值、哪行要渲染——「是否注入」
|
|
16
|
+
# 完全由嵌入代码是否调用 `render` 决定(CSM §3.3)。
|
|
17
|
+
class Tokenizer
|
|
18
|
+
# 一个行级 token。+indent+ 是该行的 CSM 缩进(前导空格数)。
|
|
19
|
+
Token = Struct.new(:type, :line, :indent, :value, keyword_init: true)
|
|
20
|
+
|
|
21
|
+
# 缩进单位:1 个制表符或 4 个空格(CSM §1.1)。制表符按下表展开为空格。
|
|
22
|
+
INDENT_UNIT = 4
|
|
23
|
+
|
|
24
|
+
FENCE_RE = /\A(`{3,6})(.*)\z/
|
|
25
|
+
TILDE_RE = /\A~ (.*)\z/
|
|
26
|
+
# ATX 标题。第 3 组捕获可选的收尾 `#`(含其前导空白),CSM §1.2 的 `<hgroup>`
|
|
27
|
+
# 用法要求收尾 `#` 与引导 `#` 数量相同。
|
|
28
|
+
HEADING_RE = /\A(\#{1,6})[ \t]+(.*?)([ \t]+\#{1,6})?[ \t]*\z/
|
|
29
|
+
HRULE_RE = /\A-{3,72}\z/
|
|
30
|
+
# CSM §1.3 只定义一种无序列表标记(`- `)与一种有序形式(`N. `)。
|
|
31
|
+
ULIST_RE = /\A-[ \t]+(.*)\z/
|
|
32
|
+
OLIST_RE = /\A(\d{1,9})\.[ \t]+(.*)\z/
|
|
33
|
+
TASK_RE = /\A\[([ xX])\][ \t]+(.*)\z/
|
|
34
|
+
QUOTE_RE = /\A> ?(.*)\z/
|
|
35
|
+
CITE_RE = /\A>- ?(.*)\z/
|
|
36
|
+
DEF_RE = /\A:[ \t]+(.*)\z/
|
|
37
|
+
|
|
38
|
+
# CSM §1.12:表格行以 `|` 开头和结尾;单元格由未转义的 `|` 分割。
|
|
39
|
+
TABLE_ROW_RE = /\A\|.*\|\z/
|
|
40
|
+
# 分隔行的单元格:一个或多个 `-`,可选 `:` 前缀 / 后缀。
|
|
41
|
+
SEPARATOR_CELL_RE = /\A:?-+:?\z/
|
|
42
|
+
|
|
43
|
+
# CSM §1.11:块级公式。`$$…$$` 一行成块,或 `$$` 独占一行的包围式。
|
|
44
|
+
MATH_FENCE = "$$"
|
|
45
|
+
FULL_LINE_MATH_RE = /\A\$\$(.+)\$\$\z/
|
|
46
|
+
|
|
47
|
+
END_MARKER = "__END__"
|
|
48
|
+
|
|
49
|
+
def initialize(detector: BlockDetector.new)
|
|
50
|
+
@detector = detector
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# 把 CSM 源码切成 token 数组。
|
|
54
|
+
def tokenize(source)
|
|
55
|
+
lines = normalize(source)
|
|
56
|
+
tokens = []
|
|
57
|
+
index = 0
|
|
58
|
+
|
|
59
|
+
while index < lines.size
|
|
60
|
+
line = lines[index]
|
|
61
|
+
indent = indent_width(line)
|
|
62
|
+
content = line[indent..] || ""
|
|
63
|
+
line_number = index + 1
|
|
64
|
+
|
|
65
|
+
if content.empty?
|
|
66
|
+
index = emit_blank(tokens, lines, index)
|
|
67
|
+
next
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
fence = FENCE_RE.match(content)
|
|
71
|
+
if fence && indent.zero?
|
|
72
|
+
index = emit_fence(tokens, lines, index, indent, line_number, fence)
|
|
73
|
+
next
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# 表格:连续的同缩进表格行合成一个 token(CSM §1.12)。
|
|
77
|
+
if table_row?(content)
|
|
78
|
+
index = emit_table(tokens, lines, index, indent, line_number)
|
|
79
|
+
next
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# 块级公式(CSM §1.11):`$$…$$` 一行,或 `$$` 独占一行的包围式。
|
|
83
|
+
# 与围栏一样,「层级方式」不允许缩进,因此只在顶层识别。
|
|
84
|
+
if indent.zero? && content == MATH_FENCE
|
|
85
|
+
index = emit_fenced_math(tokens, lines, index, line_number)
|
|
86
|
+
next
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
if indent.zero? && full_line_math?(content)
|
|
90
|
+
tokens << math_token(content, line_number)
|
|
91
|
+
index += 1
|
|
92
|
+
next
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# `\` 行首转义:剥离 `\`,强制按 Markdown 文本(CSM §3.11)。
|
|
96
|
+
if content.start_with?("\\")
|
|
97
|
+
tokens << text_token(content[1..], line_number, indent)
|
|
98
|
+
index += 1
|
|
99
|
+
next
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
tilde = TILDE_RE.match(content)
|
|
103
|
+
if tilde
|
|
104
|
+
tokens << Token.new(
|
|
105
|
+
type: :ruby_block, line: line_number, indent: indent,
|
|
106
|
+
value: { source: tilde[1], form: :tilde, lines: (line_number..line_number),
|
|
107
|
+
indent: indent, render: false }
|
|
108
|
+
)
|
|
109
|
+
index += 1
|
|
110
|
+
next
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
if content.match?(/\A[A-Za-z]/)
|
|
114
|
+
detection = @detector.detect(lines, index, indent)
|
|
115
|
+
if detection
|
|
116
|
+
index = emit_ruby_block(tokens, lines, index, indent, line_number, detection)
|
|
117
|
+
next
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
if indent.zero? && content == END_MARKER
|
|
122
|
+
tokens << Token.new(
|
|
123
|
+
type: :end_marker, line: line_number, indent: indent,
|
|
124
|
+
value: { line: line_number, data: join_tail(lines, index + 1) }
|
|
125
|
+
)
|
|
126
|
+
break
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
tokens << layout_token(content, line_number, indent)
|
|
130
|
+
index += 1
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
tokens
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
private
|
|
137
|
+
|
|
138
|
+
def normalize(source)
|
|
139
|
+
text = source.to_s.gsub(/\r\n?/, "\n").sub(/\n+\z/, "")
|
|
140
|
+
return [] if text.empty?
|
|
141
|
+
|
|
142
|
+
text.split("\n", -1).map { |line| expand_leading_tabs(line) }
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# 把行首的制表符展开为空格(制表位每 4 列一个),行中内容原样保留。
|
|
146
|
+
def expand_leading_tabs(line)
|
|
147
|
+
width = 0
|
|
148
|
+
index = 0
|
|
149
|
+
while index < line.length
|
|
150
|
+
case line[index]
|
|
151
|
+
when " " then width += 1
|
|
152
|
+
when "\t" then width += INDENT_UNIT - (width % INDENT_UNIT)
|
|
153
|
+
else break
|
|
154
|
+
end
|
|
155
|
+
index += 1
|
|
156
|
+
end
|
|
157
|
+
return line if index.zero?
|
|
158
|
+
|
|
159
|
+
(" " * width) + (line[index..] || "")
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# 合并连续空行,count 是空行行数(1 → 新块,3 → 新节块)。
|
|
163
|
+
def emit_blank(tokens, lines, index)
|
|
164
|
+
cursor = index
|
|
165
|
+
cursor += 1 while cursor < lines.size && content_of(lines[cursor]).empty?
|
|
166
|
+
tokens << Token.new(type: :blank, line: index + 1, indent: 0, value: { count: cursor - index })
|
|
167
|
+
cursor
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# 围栏代码块整体跳过,不做任何嵌入判定(CSM §1.5)。
|
|
171
|
+
def emit_fence(tokens, lines, index, indent, line_number, match)
|
|
172
|
+
marker = match[1]
|
|
173
|
+
info = match[2].strip
|
|
174
|
+
code = []
|
|
175
|
+
cursor = index + 1
|
|
176
|
+
closed = false
|
|
177
|
+
|
|
178
|
+
while cursor < lines.size
|
|
179
|
+
if content_of(lines[cursor]) == marker
|
|
180
|
+
closed = true
|
|
181
|
+
break
|
|
182
|
+
end
|
|
183
|
+
code << lines[cursor]
|
|
184
|
+
cursor += 1
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
raise UnclosedFenceError, "unclosed code fence opened at line #{line_number}" unless closed
|
|
188
|
+
|
|
189
|
+
lang, params = split_info(info)
|
|
190
|
+
tokens << Token.new(
|
|
191
|
+
type: :fence, line: line_number, indent: indent,
|
|
192
|
+
value: { fence: marker.length, lang: lang, params: params, code: code.join("\n") }
|
|
193
|
+
)
|
|
194
|
+
cursor + 1
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# -- 表格(CSM §1.12)---------------------------------------------------------
|
|
198
|
+
|
|
199
|
+
# 一行是否属于表格:以 `|` 开头和结尾。
|
|
200
|
+
def table_row?(content)
|
|
201
|
+
content.rstrip.match?(TABLE_ROW_RE)
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
# 收集连续的同缩进表格行,解析成行数组后产出一个 +:table+ token。
|
|
205
|
+
#
|
|
206
|
+
# 行的载荷是 +{ rows: [...] }+,每行 +{ cells:, header:, align: }+:
|
|
207
|
+
# * +cells+ —— 单元格原文(交给 Parser 做行内解析);
|
|
208
|
+
# * +header+ —— 是否表头(由分隔行把前一行提升而来);
|
|
209
|
+
# * +align+ —— 该行各列的对齐(+nil+ / +:left+ / +:center+ / +:right+)。
|
|
210
|
+
def emit_table(tokens, lines, index, indent, line_number)
|
|
211
|
+
cursor = index
|
|
212
|
+
cursor += 1 while cursor < lines.size && indent_width(lines[cursor]) == indent &&
|
|
213
|
+
table_row?(content_of(lines[cursor]))
|
|
214
|
+
|
|
215
|
+
source = (index...cursor).map { |i| content_of(lines[i]).rstrip }
|
|
216
|
+
tokens << Token.new(type: :table, line: line_number, indent: indent,
|
|
217
|
+
value: { rows: parse_table(source) })
|
|
218
|
+
cursor
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
# 逐行解析表格:分隔行把**前面那一行**提升为表头,并更新其后各行的对齐。
|
|
222
|
+
# 分隔行自身不产生数据行(CSM §1.12)。
|
|
223
|
+
def parse_table(lines)
|
|
224
|
+
rows = []
|
|
225
|
+
align = []
|
|
226
|
+
|
|
227
|
+
lines.each do |line|
|
|
228
|
+
if separator_row?(line)
|
|
229
|
+
align = separator_aligns(line)
|
|
230
|
+
rows.last[:header] = true if rows.any?
|
|
231
|
+
rows.last[:align] = align if rows.any?
|
|
232
|
+
next
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
rows << { cells: split_cells(line), header: false, align: align }
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
rows
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
# 每个单元格都是 `-` 串(可带 `:`)的行是分隔行。
|
|
242
|
+
def separator_row?(line)
|
|
243
|
+
cells = split_cells(line)
|
|
244
|
+
cells.any? && cells.all? { |cell| cell.match?(SEPARATOR_CELL_RE) }
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
def separator_aligns(line)
|
|
248
|
+
split_cells(line).map { |cell| alignment_of(cell) }
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
# `:---` → 左;`---:` → 右;`:--:` → 居中;`---` → 默认。
|
|
252
|
+
def alignment_of(cell)
|
|
253
|
+
return :center if cell.start_with?(":") && cell.end_with?(":")
|
|
254
|
+
return :left if cell.start_with?(":")
|
|
255
|
+
return :right if cell.end_with?(":")
|
|
256
|
+
|
|
257
|
+
nil
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
# 按**未转义的** `|` 切出单元格;首尾的 `|` 不产生空单元格。`\|` 原样留在
|
|
261
|
+
# 单元格里,交给 InlineParser 按 CSM §3.11 的转义语义处理。
|
|
262
|
+
def split_cells(line)
|
|
263
|
+
cells = [""]
|
|
264
|
+
index = 0
|
|
265
|
+
|
|
266
|
+
while index < line.length
|
|
267
|
+
char = line[index]
|
|
268
|
+
if char == "\\"
|
|
269
|
+
cells[-1] = cells[-1] + line[index, 2]
|
|
270
|
+
index += 2
|
|
271
|
+
elsif char == "|"
|
|
272
|
+
cells << ""
|
|
273
|
+
index += 1
|
|
274
|
+
else
|
|
275
|
+
cells[-1] = cells[-1] + char
|
|
276
|
+
index += 1
|
|
277
|
+
end
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
cells.shift if line.start_with?("|")
|
|
281
|
+
cells.pop if line.end_with?("|")
|
|
282
|
+
cells.map(&:strip)
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
# -- 块级公式(CSM §1.11)-----------------------------------------------------
|
|
286
|
+
|
|
287
|
+
def full_line_math?(content)
|
|
288
|
+
content.strip.match?(FULL_LINE_MATH_RE)
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
def math_token(content, line_number)
|
|
292
|
+
Token.new(type: :math_block, line: line_number, indent: 0,
|
|
293
|
+
value: { tex: full_line_math(content) })
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
def full_line_math(content)
|
|
297
|
+
content.strip[FULL_LINE_MATH_RE, 1].strip
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
# `$$` 独占一行的包围式:收集到下一个独占一行的 `$$`。行的内容原样保留
|
|
301
|
+
# (字面,与围栏一致);CSM §1.11。
|
|
302
|
+
def emit_fenced_math(tokens, lines, index, line_number)
|
|
303
|
+
body = []
|
|
304
|
+
cursor = index + 1
|
|
305
|
+
|
|
306
|
+
while cursor < lines.size && content_of(lines[cursor]) != MATH_FENCE
|
|
307
|
+
body << lines[cursor]
|
|
308
|
+
cursor += 1
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
raise UnclosedMathError, "unclosed block math opened at line #{line_number}" if cursor >= lines.size
|
|
312
|
+
|
|
313
|
+
tokens << Token.new(type: :math_block, line: line_number, indent: 0,
|
|
314
|
+
value: { tex: body.join("\n").strip })
|
|
315
|
+
cursor + 1
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
def emit_ruby_block(tokens, lines, index, indent, line_number, detection)
|
|
319
|
+
last = detection.end_index
|
|
320
|
+
source = (index..last).map { |i| drop_indent(lines[i], indent) }.join("\n")
|
|
321
|
+
tokens << Token.new(
|
|
322
|
+
type: :ruby_block, line: line_number, indent: indent,
|
|
323
|
+
value: { source: source, form: detection.form, lines: (line_number..(last + 1)),
|
|
324
|
+
indent: indent, render: detection.render }
|
|
325
|
+
)
|
|
326
|
+
last + 1
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
def layout_token(content, line_number, indent)
|
|
330
|
+
if (match = HEADING_RE.match(content))
|
|
331
|
+
return Token.new(type: :heading, line: line_number, indent: indent,
|
|
332
|
+
value: heading_value(match))
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
return Token.new(type: :hrule, line: line_number, indent: indent, value: {}) if HRULE_RE.match?(content)
|
|
336
|
+
|
|
337
|
+
if (match = CITE_RE.match(content))
|
|
338
|
+
return Token.new(type: :cite, line: line_number, indent: indent, value: { text: match[1] })
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
if (match = QUOTE_RE.match(content))
|
|
342
|
+
return Token.new(type: :blockquote, line: line_number, indent: indent, value: { text: match[1] })
|
|
343
|
+
end
|
|
344
|
+
|
|
345
|
+
if (match = ULIST_RE.match(content))
|
|
346
|
+
return list_item_token(match[1], line_number, indent, "-")
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
if (match = OLIST_RE.match(content))
|
|
350
|
+
return list_item_token(match[2], line_number, indent, "#{match[1]}.")
|
|
351
|
+
end
|
|
352
|
+
|
|
353
|
+
if (match = DEF_RE.match(content))
|
|
354
|
+
return Token.new(type: :definition, line: line_number, indent: indent, value: { text: match[1] })
|
|
355
|
+
end
|
|
356
|
+
|
|
357
|
+
text_token(content, line_number, indent)
|
|
358
|
+
end
|
|
359
|
+
|
|
360
|
+
# 标题载荷:收尾 `#` 与引导 `#` 数量相同才算「收尾」(CSM §1.2),否则那些
|
|
361
|
+
# `#` 只是标题正文的一部分。
|
|
362
|
+
def heading_value(match)
|
|
363
|
+
level = match[1].length
|
|
364
|
+
closer = match[3]
|
|
365
|
+
closed = !closer.nil? && closer.strip.length == level
|
|
366
|
+
text = match[2]
|
|
367
|
+
text = "#{text}#{closer}" if !closed && !closer.nil?
|
|
368
|
+
{ level: level, text: text, closed: closed }
|
|
369
|
+
end
|
|
370
|
+
|
|
371
|
+
def list_item_token(text, line_number, indent, marker)
|
|
372
|
+
ordered = marker.end_with?(".")
|
|
373
|
+
task = TASK_RE.match(text)
|
|
374
|
+
value =
|
|
375
|
+
if task
|
|
376
|
+
{ text: task[2], marker: marker, ordered: ordered, task: true, checked: task[1].casecmp?("x") }
|
|
377
|
+
else
|
|
378
|
+
{ text: text, marker: marker, ordered: ordered, task: false, checked: nil }
|
|
379
|
+
end
|
|
380
|
+
Token.new(type: :list_item, line: line_number, indent: indent, value: value)
|
|
381
|
+
end
|
|
382
|
+
|
|
383
|
+
def text_token(text, line_number, indent)
|
|
384
|
+
Token.new(type: :text, line: line_number, indent: indent, value: { text: text })
|
|
385
|
+
end
|
|
386
|
+
|
|
387
|
+
# 围栏 info string:第一个词是语言,其余是参数。
|
|
388
|
+
def split_info(info)
|
|
389
|
+
return [nil, nil] if info.empty?
|
|
390
|
+
|
|
391
|
+
lang, _separator, params = info.partition(/\s+/)
|
|
392
|
+
[lang.empty? ? nil : lang, params.empty? ? nil : params]
|
|
393
|
+
end
|
|
394
|
+
|
|
395
|
+
def join_tail(lines, from)
|
|
396
|
+
return nil if from >= lines.size
|
|
397
|
+
|
|
398
|
+
lines[from..].join("\n")
|
|
399
|
+
end
|
|
400
|
+
|
|
401
|
+
def content_of(line)
|
|
402
|
+
drop_indent(line, indent_width(line))
|
|
403
|
+
end
|
|
404
|
+
|
|
405
|
+
def indent_width(line)
|
|
406
|
+
line[/\A[ \t]*/].to_s.length
|
|
407
|
+
end
|
|
408
|
+
|
|
409
|
+
def drop_indent(line, count)
|
|
410
|
+
index = 0
|
|
411
|
+
index += 1 while index < count && index < line.length && line[index] == " "
|
|
412
|
+
line[index..] || ""
|
|
413
|
+
end
|
|
414
|
+
end
|
|
415
|
+
end
|