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.
@@ -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 = [table_row(rows.first, pad)]
210
- lines << separator_row(rows.first, pad) if header_row?(rows.first)
211
- rows.drop(1).each { |row| lines << table_row(row, pad) }
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
- characters = text.to_s.chars
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
- out << if char == "#" && characters[index + 1] == "{"
313
- "\\#"
314
- else
315
- TEXT_ESCAPES[char] || char
316
- end
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
- other.ancestors.include?(self)
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
- def initialize(detector: BlockDetector.new, inline: true)
35
- @tokenizer = Tokenizer.new(detector: detector)
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
@@ -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
- # `\` 行首转义:剥离 `\`,强制按 Markdown 文本(CSM §3.11)。
104
+ # `\` 行首转义:只跳过块级判定。若 `\` 后是行内可转义字符,则把它留给
105
+ # InlineParser(`\*` ≠ 强调);否则 `\` 只是块级转义标记,剥离
106
+ # (`\while` → `while`)。CSM §3.11。
96
107
  if content.start_with?("\\")
97
- tokens << text_token(content[1..], line_number, indent)
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 content.match?(/\A[A-Za-z]/)
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.gsub(/\r\n?/, "\n").sub(/\n+\z/, "")
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
- 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
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
- cells[-1] = cells[-1] + char
276
- index += 1
307
+ break # 到不了:CELL_RUN 覆盖除 `|` 之外的所有字符
277
308
  end
278
309
  end
279
310
 
280
- cells.shift if line.start_with?("|")
281
- cells.pop if line.end_with?("|")
282
- cells.map(&:strip)
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[/\A[ \t]*/].to_s.length
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..] || ""