mdom 0.3.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 +4 -4
- data/CHANGELOG.md +165 -1
- data/README.md +50 -12
- data/USAGE.md +89 -14
- data/lib/mdom/block_detector.rb +293 -60
- data/lib/mdom/builder.rb +151 -27
- data/lib/mdom/error.rb +0 -4
- data/lib/mdom/html_serializer.rb +19 -1
- data/lib/mdom/inline_parser.rb +402 -169
- data/lib/mdom/markdown_serializer.rb +69 -11
- data/lib/mdom/node.rb +9 -1
- data/lib/mdom/parser.rb +30 -2
- data/lib/mdom/tokenizer.rb +64 -21
- data/lib/mdom/translator.rb +148 -148
- data/lib/mdom/version.rb +1 -1
- metadata +5 -5
|
@@ -48,9 +48,16 @@ module MDOM
|
|
|
48
48
|
"~" => "\\~",
|
|
49
49
|
"[" => "\\[",
|
|
50
50
|
"]" => "\\]",
|
|
51
|
-
"$" => "\\$"
|
|
51
|
+
"$" => "\\$",
|
|
52
|
+
"<" => "\\<",
|
|
53
|
+
"@" => "\\@",
|
|
54
|
+
"!" => "\\!"
|
|
52
55
|
}.freeze
|
|
53
56
|
|
|
57
|
+
# 出现在文本节点**开头**时可能开启块级构造的字符。行首 `\` 只跳过块级判定,
|
|
58
|
+
# 随后的 `\` 仍按行内转义处理,所以这里加一个 `\` 就能让整行保持为段落。
|
|
59
|
+
BLOCK_START_ESCAPES = ["#", ">", "-", ":", "|", "_", "(", "{", "%"].freeze
|
|
60
|
+
|
|
54
61
|
# 分隔线(CSM §1.6:3–72 个 `-`)。
|
|
55
62
|
HRULE = "---"
|
|
56
63
|
|
|
@@ -206,9 +213,17 @@ module MDOM
|
|
|
206
213
|
rows = node.children.reject { |row| row.children.empty? }
|
|
207
214
|
return [] if rows.empty?
|
|
208
215
|
|
|
209
|
-
lines = [
|
|
210
|
-
|
|
211
|
-
|
|
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
|
|
212
227
|
lines
|
|
213
228
|
end
|
|
214
229
|
|
|
@@ -217,10 +232,30 @@ module MDOM
|
|
|
217
232
|
end
|
|
218
233
|
|
|
219
234
|
def table_row(row, pad)
|
|
220
|
-
cells = row.children.map { |cell| " #{inline_children(cell)} " }
|
|
235
|
+
cells = row.children.map { |cell| " #{escape_table_pipes(inline_children(cell))} " }
|
|
221
236
|
"#{pad}|#{cells.join("|")}|"
|
|
222
237
|
end
|
|
223
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
|
+
|
|
224
259
|
def separator_row(row, pad)
|
|
225
260
|
cells = row.children.map { |cell| separator_cell(cell.align) }
|
|
226
261
|
"#{pad}|#{cells.join("|")}|"
|
|
@@ -305,18 +340,41 @@ module MDOM
|
|
|
305
340
|
end
|
|
306
341
|
|
|
307
342
|
# 保守转义:单字符查表 + `#{` 的起始 `#`(CSM §3.11、§3.13)。
|
|
343
|
+
#
|
|
344
|
+
# 文本节点可能正好落在输出行的开头(段落第一段,或硬换行之后)。这时再补一个
|
|
345
|
+
# 行首 `\`,避免 `#` / `>` / `-` / `:` / `|` / `__END__` 被重新识别为块级构造。
|
|
346
|
+
# 行首 `\` 不参与块级判定,但仍按行内转义处理,所以不会污染文本内容。
|
|
308
347
|
def escape_text(text)
|
|
309
|
-
|
|
348
|
+
source = text.to_s
|
|
349
|
+
characters = source.chars
|
|
350
|
+
ordered_dot = ordered_list_dot_index(characters)
|
|
310
351
|
out = +""
|
|
352
|
+
|
|
311
353
|
characters.each_with_index do |char, index|
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
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
|
|
317
360
|
end
|
|
318
361
|
out
|
|
319
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
|
|
320
378
|
end
|
|
321
379
|
end
|
|
322
380
|
|
data/lib/mdom/node.rb
CHANGED
|
@@ -515,7 +515,15 @@ module MDOM
|
|
|
515
515
|
def ancestor_of?(other)
|
|
516
516
|
raise ArgumentError, "other cannot be nil" if other.nil?
|
|
517
517
|
|
|
518
|
-
|
|
518
|
+
# 沿父链走而不是先建一份 +ancestors+ 数组:挂载路径上这个方法调用极频繁
|
|
519
|
+
# (每次 +<<+ / +prepend+ 都要查一次环),而答案通常在前一两跳就确定。
|
|
520
|
+
node = other.parent
|
|
521
|
+
while node
|
|
522
|
+
return true if node.equal?(self)
|
|
523
|
+
|
|
524
|
+
node = node.parent
|
|
525
|
+
end
|
|
526
|
+
false
|
|
519
527
|
end
|
|
520
528
|
end
|
|
521
529
|
|
data/lib/mdom/parser.rb
CHANGED
|
@@ -31,8 +31,21 @@ module MDOM
|
|
|
31
31
|
new(**options).parse(source)
|
|
32
32
|
end
|
|
33
33
|
|
|
34
|
-
|
|
35
|
-
|
|
34
|
+
# 参数:
|
|
35
|
+
# * +detector:+ —— 块判定器,默认 +BlockDetector.new+。**自定义判定器时,A 类的
|
|
36
|
+
# 开关与白名单要配在判定器自己身上**,不要再传下面两个选项(同时给出会报错)。
|
|
37
|
+
# * +inline:+ —— 是否做行内解析;+false+ 只做块结构。
|
|
38
|
+
# * +free_blocks:+ —— **A 类(自由块)判定开关**(CSM §3.5),默认 +true+。
|
|
39
|
+
# +false+ 时不做任何 A 类判定,命中行按**排版**处理(CSM §3.5.3,不报错);
|
|
40
|
+
# B 类(引导关键字)与 `~` 行不受影响。
|
|
41
|
+
# * +free_blocks_allowed:+ —— A 类判定的**白名单**,默认 +nil+(不限制)。给出后
|
|
42
|
+
# 只有行首标识符命中的行才尝试 A 类判定:+String+ 精确相等,+Regexp+ 对已剥离
|
|
43
|
+
# 缩进的整行匹配。空数组等价于 +free_blocks: false+。
|
|
44
|
+
#
|
|
45
|
+
# 后两个选项服务于英文散文文档:那里「以字母开头的行」比比皆是,逐一交给 Prism
|
|
46
|
+
# 增量 parse 会产生几百到上万次调用(docs/IMPLEMENTATION.md §2.2.4)。
|
|
47
|
+
def initialize(detector: nil, inline: true, free_blocks: true, free_blocks_allowed: nil)
|
|
48
|
+
@tokenizer = Tokenizer.new(detector: resolve_detector(detector, free_blocks, free_blocks_allowed))
|
|
36
49
|
@inline = inline
|
|
37
50
|
end
|
|
38
51
|
|
|
@@ -59,6 +72,21 @@ module MDOM
|
|
|
59
72
|
|
|
60
73
|
private
|
|
61
74
|
|
|
75
|
+
# 组装块判定器:没给 +detector+ 就把两个 A 类选项用在默认判定器上;给了就要求
|
|
76
|
+
# 那两个选项保持默认——配置的**单一来源**,避免「传了却不生效」的静默失效。
|
|
77
|
+
def resolve_detector(detector, free_blocks, free_blocks_allowed)
|
|
78
|
+
if detector.nil?
|
|
79
|
+
BlockDetector.new(free_blocks: free_blocks, free_blocks_allowed: free_blocks_allowed)
|
|
80
|
+
else
|
|
81
|
+
unless free_blocks == true && free_blocks_allowed.nil?
|
|
82
|
+
raise ArgumentError,
|
|
83
|
+
"`detector:` cannot be combined with `free_blocks:` / `free_blocks_allowed:` " \
|
|
84
|
+
"— configure the detector itself instead"
|
|
85
|
+
end
|
|
86
|
+
detector
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
62
90
|
def dispatch(token, root, stack)
|
|
63
91
|
case token.type
|
|
64
92
|
when :blank
|
data/lib/mdom/tokenizer.rb
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "strscan"
|
|
4
|
+
|
|
3
5
|
require_relative "error"
|
|
4
6
|
require_relative "block_detector"
|
|
5
7
|
|
|
@@ -39,6 +41,9 @@ module MDOM
|
|
|
39
41
|
TABLE_ROW_RE = /\A\|.*\|\z/
|
|
40
42
|
# 分隔行的单元格:一个或多个 `-`,可选 `:` 前缀 / 后缀。
|
|
41
43
|
SEPARATOR_CELL_RE = /\A:?-+:?\z/
|
|
44
|
+
# 单元格内容:一个转义对(`\` + 任意字符)或一个非 `|` 字符,成段出现。
|
|
45
|
+
CELL_RUN = /(?:\\.|[^|])+/m
|
|
46
|
+
CELL_SEPARATOR = /\|/
|
|
42
47
|
|
|
43
48
|
# CSM §1.11:块级公式。`$$…$$` 一行成块,或 `$$` 独占一行的包围式。
|
|
44
49
|
MATH_FENCE = "$$"
|
|
@@ -46,6 +51,10 @@ module MDOM
|
|
|
46
51
|
|
|
47
52
|
END_MARKER = "__END__"
|
|
48
53
|
|
|
54
|
+
# 行首 `\` 后若是这些字符之一,`\` 同时承担行内转义,必须留给 InlineParser。
|
|
55
|
+
# 与 InlineParser::ESCAPABLE 保持一致(tokenizer_test 有同步断言)。
|
|
56
|
+
INLINE_ESCAPABLE = %w[` * _ { } [ ] ( ) # + - . ! " $ % & ' , / : ; < = > ? @ ^ | ~ \\].freeze
|
|
57
|
+
|
|
49
58
|
def initialize(detector: BlockDetector.new)
|
|
50
59
|
@detector = detector
|
|
51
60
|
end
|
|
@@ -92,9 +101,12 @@ module MDOM
|
|
|
92
101
|
next
|
|
93
102
|
end
|
|
94
103
|
|
|
95
|
-
# `\`
|
|
104
|
+
# `\` 行首转义:只跳过块级判定。若 `\` 后是行内可转义字符,则把它留给
|
|
105
|
+
# InlineParser(`\*` ≠ 强调);否则 `\` 只是块级转义标记,剥离
|
|
106
|
+
# (`\while` → `while`)。CSM §3.11。
|
|
96
107
|
if content.start_with?("\\")
|
|
97
|
-
|
|
108
|
+
escaped = content.length > 1 && INLINE_ESCAPABLE.include?(content[1])
|
|
109
|
+
tokens << text_token(escaped ? content : content[1..], line_number, indent)
|
|
98
110
|
index += 1
|
|
99
111
|
next
|
|
100
112
|
end
|
|
@@ -110,7 +122,7 @@ module MDOM
|
|
|
110
122
|
next
|
|
111
123
|
end
|
|
112
124
|
|
|
113
|
-
if
|
|
125
|
+
if BlockDetector.candidate_start?(content) && candidate?(content)
|
|
114
126
|
detection = @detector.detect(lines, index, indent)
|
|
115
127
|
if detection
|
|
116
128
|
index = emit_ruby_block(tokens, lines, index, indent, line_number, detection)
|
|
@@ -135,8 +147,24 @@ module MDOM
|
|
|
135
147
|
|
|
136
148
|
private
|
|
137
149
|
|
|
150
|
+
# 块判定前的预筛(CSM §3.5.4 的收窄 + A 类门控)。
|
|
151
|
+
#
|
|
152
|
+
# 判定器响应 +candidate?+ 时先问一句,从而对注定不会被判定的行——英文散文,
|
|
153
|
+
# 或不在 A 类白名单里的行——连 +detect+ 都不必调用,**零次 Prism 调用**。
|
|
154
|
+
# 自定义判定器没有这个谓词时按「值得判定」处理,行为与从前一致。
|
|
155
|
+
#
|
|
156
|
+
# +content+ 是**已剥离 CSM 缩进**的首行内容。
|
|
157
|
+
def candidate?(content)
|
|
158
|
+
return true unless @detector.respond_to?(:candidate?)
|
|
159
|
+
|
|
160
|
+
@detector.candidate?(content)
|
|
161
|
+
end
|
|
162
|
+
|
|
138
163
|
def normalize(source)
|
|
139
|
-
text = source.to_s
|
|
164
|
+
text = source.to_s
|
|
165
|
+
# 绝大多数源没有 `\r`:省掉一次全量 gsub 与它那份中间串。
|
|
166
|
+
text = text.gsub(/\r\n?/, "\n") if text.include?("\r")
|
|
167
|
+
text = text.sub(/\n+\z/, "")
|
|
140
168
|
return [] if text.empty?
|
|
141
169
|
|
|
142
170
|
text.split("\n", -1).map { |line| expand_leading_tabs(line) }
|
|
@@ -144,6 +172,8 @@ module MDOM
|
|
|
144
172
|
|
|
145
173
|
# 把行首的制表符展开为空格(制表位每 4 列一个),行中内容原样保留。
|
|
146
174
|
def expand_leading_tabs(line)
|
|
175
|
+
return line unless line.include?("\t")
|
|
176
|
+
|
|
147
177
|
width = 0
|
|
148
178
|
index = 0
|
|
149
179
|
while index < line.length
|
|
@@ -259,27 +289,36 @@ module MDOM
|
|
|
259
289
|
|
|
260
290
|
# 按**未转义的** `|` 切出单元格;首尾的 `|` 不产生空单元格。`\|` 原样留在
|
|
261
291
|
# 单元格里,交给 InlineParser 按 CSM §3.11 的转义语义处理。
|
|
292
|
+
#
|
|
293
|
+
# 用 StringScanner 一次收下一整段单元格内容(`\\.` 或非 `|` 的连续串),
|
|
294
|
+
# 而不是逐字符判断——表格行的单元格往往很长,这一点很划算。
|
|
262
295
|
def split_cells(line)
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
if
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
index += 1
|
|
296
|
+
scanner = StringScanner.new(line)
|
|
297
|
+
cells = []
|
|
298
|
+
cell = line[0, 0] # 空字符串,继承 line 的编码
|
|
299
|
+
|
|
300
|
+
until scanner.eos?
|
|
301
|
+
if (run = scanner.scan(CELL_RUN))
|
|
302
|
+
cell << run
|
|
303
|
+
elsif scanner.skip(CELL_SEPARATOR)
|
|
304
|
+
cells << cell
|
|
305
|
+
cell = line[0, 0]
|
|
274
306
|
else
|
|
275
|
-
|
|
276
|
-
index += 1
|
|
307
|
+
break # 到不了:CELL_RUN 覆盖除 `|` 之外的所有字符
|
|
277
308
|
end
|
|
278
309
|
end
|
|
279
310
|
|
|
280
|
-
cells
|
|
281
|
-
|
|
282
|
-
|
|
311
|
+
cells << cell
|
|
312
|
+
|
|
313
|
+
# 移除尾部未转义 | 产生的空单元格。
|
|
314
|
+
# 注意:先判断尾部,再处理首部,避免 "|" 这种输入留下多余空串。
|
|
315
|
+
cells.pop if cells.size > 1 && cells.last.empty?
|
|
316
|
+
|
|
317
|
+
# 行首的 | 不可能被前面的反斜杠转义
|
|
318
|
+
cells.shift if line.start_with?("|") && !cells.empty? && cells.first.empty?
|
|
319
|
+
|
|
320
|
+
cells.map!(&:strip)
|
|
321
|
+
cells
|
|
283
322
|
end
|
|
284
323
|
|
|
285
324
|
# -- 块级公式(CSM §1.11)-----------------------------------------------------
|
|
@@ -402,11 +441,15 @@ module MDOM
|
|
|
402
441
|
drop_indent(line, indent_width(line))
|
|
403
442
|
end
|
|
404
443
|
|
|
444
|
+
# 前导空白(空格或制表符)的宽度。用 +index+ 找第一个非空白字符——比
|
|
445
|
+
# 「匹配一段空白再取长度」少一次子串分配。
|
|
405
446
|
def indent_width(line)
|
|
406
|
-
line[
|
|
447
|
+
line.index(/[^ \t]/) || line.length
|
|
407
448
|
end
|
|
408
449
|
|
|
409
450
|
def drop_indent(line, count)
|
|
451
|
+
return line if count.zero?
|
|
452
|
+
|
|
410
453
|
index = 0
|
|
411
454
|
index += 1 while index < count && index < line.length && line[index] == " "
|
|
412
455
|
line[index..] || ""
|