red_quilt 0.7.2 → 0.9.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 +99 -0
- data/README.md +38 -0
- data/docs/api.md +33 -1
- data/lib/red_quilt/arena.rb +117 -0
- data/lib/red_quilt/block_parser.rb +50 -389
- data/lib/red_quilt/blockquote.rb +15 -2
- data/lib/red_quilt/cli.rb +8 -2
- data/lib/red_quilt/code_block.rb +145 -0
- data/lib/red_quilt/document.rb +18 -4
- data/lib/red_quilt/footnote_anchors.rb +24 -0
- data/lib/red_quilt/footnote_pass.rb +6 -2
- data/lib/red_quilt/frontmatter.rb +54 -0
- data/lib/red_quilt/html_block.rb +161 -0
- data/lib/red_quilt/indentation.rb +35 -0
- data/lib/red_quilt/inline/builder.rb +16 -193
- data/lib/red_quilt/inline/emphasis_resolver.rb +184 -0
- data/lib/red_quilt/inline/lexer.rb +4 -4
- data/lib/red_quilt/inline/url_sanitizer.rb +64 -0
- data/lib/red_quilt/inline_pass.rb +15 -2
- data/lib/red_quilt/line.rb +6 -1
- data/lib/red_quilt/lint_pass.rb +2 -2
- data/lib/red_quilt/list.rb +13 -4
- data/lib/red_quilt/node_ref.rb +106 -11
- data/lib/red_quilt/renderer/html.rb +32 -20
- data/lib/red_quilt/renderer/mdast.rb +19 -15
- data/lib/red_quilt/source_map.rb +45 -5
- data/lib/red_quilt/table.rb +97 -0
- data/lib/red_quilt/version.rb +1 -1
- data/lib/red_quilt.rb +19 -4
- data/sig/red_quilt.rbs +35 -0
- metadata +10 -3
|
@@ -14,6 +14,9 @@ module RedQuilt
|
|
|
14
14
|
@list_parser = List::Parser.new(self)
|
|
15
15
|
@blockquote_parser = Blockquote::Parser.new(self)
|
|
16
16
|
@footnote_parser = FootnoteDefinition::Parser.new(self)
|
|
17
|
+
@code_block_parser = CodeBlock::Parser.new(self)
|
|
18
|
+
@html_block_parser = HtmlBlock::Parser.new(self)
|
|
19
|
+
@table_parser = Table::Parser.new(self)
|
|
17
20
|
end
|
|
18
21
|
|
|
19
22
|
attr_reader :references, :arena, :diagnostics
|
|
@@ -60,29 +63,33 @@ module RedQuilt
|
|
|
60
63
|
next
|
|
61
64
|
end
|
|
62
65
|
|
|
63
|
-
if (fence =
|
|
64
|
-
index =
|
|
66
|
+
if (fence = CodeBlock.fenced_start(content))
|
|
67
|
+
index = @code_block_parser.parse_fenced(parent_id, lines, index, fence)
|
|
65
68
|
elsif (heading = atx_heading(content))
|
|
66
69
|
append_heading(parent_id, line, heading, transformed)
|
|
67
70
|
index += 1
|
|
68
71
|
elsif thematic_break?(content)
|
|
69
|
-
|
|
72
|
+
break_start = line.start_byte + Indentation.leading_ws_bytes(content)
|
|
73
|
+
@arena.append_child(parent_id,
|
|
74
|
+
@arena.add_node(NodeType::THEMATIC_BREAK,
|
|
75
|
+
source_start: break_start,
|
|
76
|
+
source_len: line.end_byte - break_start))
|
|
70
77
|
index += 1
|
|
71
78
|
elsif @footnotes && (footnote = FootnoteDefinition.match(content))
|
|
72
79
|
index = @footnote_parser.parse(lines, index, footnote, @footnotes, @root_id)
|
|
73
80
|
elsif (reference = ReferenceDefinition.consume(lines, index))
|
|
74
81
|
store_reference(reference[:reference], reference[:source_span])
|
|
75
82
|
index += reference[:consumed]
|
|
76
|
-
elsif
|
|
77
|
-
index =
|
|
78
|
-
elsif
|
|
79
|
-
index =
|
|
83
|
+
elsif Table.start?(lines, index)
|
|
84
|
+
index = @table_parser.parse(parent_id, lines, index)
|
|
85
|
+
elsif HtmlBlock.start?(content)
|
|
86
|
+
index = @html_block_parser.parse(parent_id, lines, index)
|
|
80
87
|
elsif Blockquote.match?(content)
|
|
81
88
|
index = @blockquote_parser.parse(parent_id, lines, index)
|
|
82
89
|
elsif List.match(content)
|
|
83
90
|
index = @list_parser.parse(parent_id, lines, index)
|
|
84
|
-
elsif
|
|
85
|
-
index =
|
|
91
|
+
elsif CodeBlock.indented_line?(content)
|
|
92
|
+
index = @code_block_parser.parse_indented(parent_id, lines, index)
|
|
86
93
|
else
|
|
87
94
|
index = parse_paragraph(parent_id, lines, index, transformed)
|
|
88
95
|
end
|
|
@@ -101,16 +108,16 @@ module RedQuilt
|
|
|
101
108
|
line = lines[index]
|
|
102
109
|
return true if atx_heading(line.content)
|
|
103
110
|
return true if thematic_break?(line.content)
|
|
104
|
-
return true if
|
|
111
|
+
return true if CodeBlock.fenced_start(line.content)
|
|
105
112
|
# HTML type 7 doesn't break lazy continuation either.
|
|
106
|
-
if (type =
|
|
113
|
+
if (type = HtmlBlock.type(line.content)) && type != 7
|
|
107
114
|
return true
|
|
108
115
|
end
|
|
109
116
|
return true if Blockquote.match?(line.content)
|
|
110
117
|
if (li = List.match(line.content)) && List.interrupts_paragraph?(li)
|
|
111
118
|
return true
|
|
112
119
|
end
|
|
113
|
-
return true if
|
|
120
|
+
return true if Table.start?(lines, index)
|
|
114
121
|
|
|
115
122
|
false
|
|
116
123
|
end
|
|
@@ -128,11 +135,11 @@ module RedQuilt
|
|
|
128
135
|
end
|
|
129
136
|
|
|
130
137
|
def paragraph_eligible_line?(content)
|
|
131
|
-
return false if
|
|
132
|
-
return false if
|
|
138
|
+
return false if CodeBlock.indented_line?(content)
|
|
139
|
+
return false if CodeBlock.fenced_start(content)
|
|
133
140
|
return false if atx_heading(content)
|
|
134
141
|
return false if thematic_break?(content)
|
|
135
|
-
return false if
|
|
142
|
+
return false if HtmlBlock.start?(content)
|
|
136
143
|
return false if List.match(content)
|
|
137
144
|
return false if Blockquote.match?(content)
|
|
138
145
|
|
|
@@ -183,180 +190,19 @@ module RedQuilt
|
|
|
183
190
|
true
|
|
184
191
|
end
|
|
185
192
|
|
|
186
|
-
def parse_fenced_code(parent_id, lines, index, fence)
|
|
187
|
-
start_line = lines[index]
|
|
188
|
-
content_lines = []
|
|
189
|
-
index += 1
|
|
190
|
-
while index < lines.length
|
|
191
|
-
break if fenced_code_close?(lines[index].content, fence[:char], fence[:count])
|
|
192
|
-
|
|
193
|
-
content_lines << lines[index]
|
|
194
|
-
index += 1
|
|
195
|
-
end
|
|
196
|
-
index += 1 if index < lines.length
|
|
197
|
-
|
|
198
|
-
# Each content line is stripped of up to the fence's own leading
|
|
199
|
-
# indent (CommonMark spec: a fence indented by N spaces strips up
|
|
200
|
-
# to N spaces from every content line, but never more). Manual
|
|
201
|
-
# byte scan beats compiling an interpolated regex per block and
|
|
202
|
-
# short-circuits when the fence had no indent (the common case).
|
|
203
|
-
indent_n = fence[:indent] || 0
|
|
204
|
-
code = content_lines.map { |l| strip_leading_spaces(l.content, indent_n) }.join("\n")
|
|
205
|
-
code << "\n" unless content_lines.empty?
|
|
206
|
-
source_start = content_lines.empty? ? start_line.start_byte : content_lines.first.start_byte
|
|
207
|
-
source_end = content_lines.empty? ? start_line.end_byte : content_lines.last.end_byte
|
|
208
|
-
code_id = @arena.add_node(NodeType::CODE_BLOCK,
|
|
209
|
-
source_start: source_start,
|
|
210
|
-
source_len: source_end - source_start,
|
|
211
|
-
str1: code,
|
|
212
|
-
str2: fence[:info])
|
|
213
|
-
@arena.append_child(parent_id, code_id)
|
|
214
|
-
index
|
|
215
|
-
end
|
|
216
|
-
|
|
217
|
-
def parse_indented_code(parent_id, lines, index)
|
|
218
|
-
start_index = index
|
|
219
|
-
code_lines = []
|
|
220
|
-
while index < lines.length
|
|
221
|
-
line = lines[index]
|
|
222
|
-
break unless line.blank || indented_code_line?(line.content)
|
|
223
|
-
|
|
224
|
-
# CommonMark: strip up to 4 columns of leading whitespace
|
|
225
|
-
# (tab-aware) from every line, including blank lines whose
|
|
226
|
-
# content beyond column 4 must be preserved verbatim.
|
|
227
|
-
code_lines << Indentation.strip_columns(line.content, 4)
|
|
228
|
-
index += 1
|
|
229
|
-
end
|
|
230
|
-
|
|
231
|
-
# Trailing blank lines are not part of the code block.
|
|
232
|
-
while !code_lines.empty? && code_lines.last.strip.empty?
|
|
233
|
-
code_lines.pop
|
|
234
|
-
index -= 1
|
|
235
|
-
end
|
|
236
|
-
|
|
237
|
-
start_byte = lines[start_index].start_byte
|
|
238
|
-
end_byte = lines[index - 1].end_byte
|
|
239
|
-
code = code_lines.empty? ? "" : code_lines.join("\n") + "\n"
|
|
240
|
-
|
|
241
|
-
code_id = @arena.add_node(NodeType::CODE_BLOCK,
|
|
242
|
-
source_start: start_byte,
|
|
243
|
-
source_len: end_byte - start_byte,
|
|
244
|
-
str1: code)
|
|
245
|
-
@arena.append_child(parent_id, code_id)
|
|
246
|
-
index
|
|
247
|
-
end
|
|
248
|
-
|
|
249
|
-
HTML_BLOCK_FIXED_TERMINATORS = {
|
|
250
|
-
2 => "-->",
|
|
251
|
-
3 => "?>",
|
|
252
|
-
4 => ">",
|
|
253
|
-
5 => "]]>",
|
|
254
|
-
}.freeze
|
|
255
|
-
|
|
256
|
-
private_constant :HTML_BLOCK_FIXED_TERMINATORS
|
|
257
|
-
|
|
258
|
-
def parse_html_block(parent_id, lines, index)
|
|
259
|
-
start_index = index
|
|
260
|
-
type = html_block_type(lines[index].content)
|
|
261
|
-
end_index = locate_html_block_end(lines, index, type)
|
|
262
|
-
|
|
263
|
-
start_byte = lines[start_index].start_byte
|
|
264
|
-
end_byte = lines[end_index].end_byte
|
|
265
|
-
html_lines = (start_index..end_index).map { |i| lines[i].content }
|
|
266
|
-
html_id = @arena.add_node(NodeType::HTML_BLOCK,
|
|
267
|
-
source_start: start_byte,
|
|
268
|
-
source_len: end_byte - start_byte,
|
|
269
|
-
str1: html_lines.join("\n"))
|
|
270
|
-
@arena.append_child(parent_id, html_id)
|
|
271
|
-
end_index + 1
|
|
272
|
-
end
|
|
273
|
-
|
|
274
|
-
def locate_html_block_end(lines, index, type)
|
|
275
|
-
terminator = html_block_terminator(type, lines[index].content)
|
|
276
|
-
|
|
277
|
-
if terminator
|
|
278
|
-
case_insensitive = (type == 1)
|
|
279
|
-
while index < lines.length
|
|
280
|
-
line = lines[index].content
|
|
281
|
-
haystack = case_insensitive ? line.downcase : line
|
|
282
|
-
return index if haystack.include?(terminator)
|
|
283
|
-
|
|
284
|
-
index += 1
|
|
285
|
-
end
|
|
286
|
-
lines.length - 1
|
|
287
|
-
else
|
|
288
|
-
# Types 6 & 7: terminated by blank line (or end of input)
|
|
289
|
-
index += 1 while index < lines.length && !lines[index].blank
|
|
290
|
-
index - 1
|
|
291
|
-
end
|
|
292
|
-
end
|
|
293
|
-
|
|
294
|
-
def html_block_terminator(type, first_line)
|
|
295
|
-
case type
|
|
296
|
-
when 1
|
|
297
|
-
"</#{extract_closing_tag_name(first_line)}>"
|
|
298
|
-
when 2..5
|
|
299
|
-
HTML_BLOCK_FIXED_TERMINATORS[type]
|
|
300
|
-
end
|
|
301
|
-
end
|
|
302
|
-
|
|
303
|
-
def extract_closing_tag_name(text)
|
|
304
|
-
match = /\A<(script|pre|style|textarea)/i.match(text)
|
|
305
|
-
match ? match[1].downcase : "script"
|
|
306
|
-
end
|
|
307
|
-
|
|
308
|
-
def parse_table(parent_id, lines, index)
|
|
309
|
-
# Caller must have verified table_start?(lines, index), which validates
|
|
310
|
-
# both the delimiter pattern and the header/separator column count match.
|
|
311
|
-
start_index = index
|
|
312
|
-
header_cells = split_table_row(lines[index].content)
|
|
313
|
-
row_lines = [lines[index]]
|
|
314
|
-
index += 2
|
|
315
|
-
while index < lines.length
|
|
316
|
-
break if lines[index].blank
|
|
317
|
-
break unless table_row?(lines[index].content)
|
|
318
|
-
|
|
319
|
-
row_lines << lines[index]
|
|
320
|
-
index += 1
|
|
321
|
-
end
|
|
322
|
-
|
|
323
|
-
table_id = @arena.add_node(NodeType::TABLE,
|
|
324
|
-
source_start: lines[start_index].start_byte,
|
|
325
|
-
source_len: row_lines.last.end_byte - lines[start_index].start_byte)
|
|
326
|
-
@arena.append_child(parent_id, table_id)
|
|
327
|
-
|
|
328
|
-
append_table_row(table_id, lines[start_index], header_cells, true)
|
|
329
|
-
row_lines.drop(1).each do |row_line|
|
|
330
|
-
append_table_row(table_id, row_line, split_table_row(row_line.content), false)
|
|
331
|
-
end
|
|
332
|
-
|
|
333
|
-
index
|
|
334
|
-
end
|
|
335
|
-
|
|
336
|
-
def append_table_row(table_id, line, cells, header)
|
|
337
|
-
row_id = @arena.add_node(NodeType::TABLE_ROW,
|
|
338
|
-
source_start: line.start_byte,
|
|
339
|
-
source_len: span_len(line),
|
|
340
|
-
int1: header ? 1 : 0)
|
|
341
|
-
@arena.append_child(table_id, row_id)
|
|
342
|
-
cells.each do |cell_text|
|
|
343
|
-
stripped = cell_text.strip
|
|
344
|
-
cell_id = @arena.add_node(NodeType::TABLE_CELL,
|
|
345
|
-
source_start: line.start_byte,
|
|
346
|
-
source_len: span_len(line),
|
|
347
|
-
int1: header ? 1 : 0,
|
|
348
|
-
str1: stripped)
|
|
349
|
-
@arena.append_child(row_id, cell_id)
|
|
350
|
-
end
|
|
351
|
-
end
|
|
352
|
-
|
|
353
193
|
def append_heading(parent_id, line, heading, transformed)
|
|
354
194
|
content = heading[:content].to_s.rstrip
|
|
355
|
-
|
|
195
|
+
# The span covers the heading as written — `#` marker, content, and any
|
|
196
|
+
# closing hashes — matching cmark sourcepos and mdast. Leading indent is
|
|
197
|
+
# excluded: ` # H1` starts at the `#`, not at the line start.
|
|
198
|
+
source_start = line.start_byte + heading[:marker_start].to_i
|
|
199
|
+
content_start = line.start_byte + heading[:content_start]
|
|
356
200
|
node_id = @arena.add_node(NodeType::HEADING,
|
|
357
201
|
source_start: source_start,
|
|
358
|
-
source_len:
|
|
202
|
+
source_len: line.end_byte - source_start,
|
|
359
203
|
int1: heading[:level],
|
|
204
|
+
int2: content_start,
|
|
205
|
+
int3: content.bytesize,
|
|
360
206
|
str1: transformed ? content : nil)
|
|
361
207
|
@arena.append_child(parent_id, node_id)
|
|
362
208
|
end
|
|
@@ -365,6 +211,7 @@ module RedQuilt
|
|
|
365
211
|
paragraph_lines = []
|
|
366
212
|
start_index = index
|
|
367
213
|
setext_level = nil
|
|
214
|
+
setext_underline = nil
|
|
368
215
|
while index < lines.length
|
|
369
216
|
line = lines[index]
|
|
370
217
|
break if line.blank
|
|
@@ -376,6 +223,7 @@ module RedQuilt
|
|
|
376
223
|
# thematic break.
|
|
377
224
|
if paragraph_lines.any? && !line.lazy_continuation && (level = setext_underline_level(line.content))
|
|
378
225
|
setext_level = level
|
|
226
|
+
setext_underline = line
|
|
379
227
|
index += 1
|
|
380
228
|
break
|
|
381
229
|
end
|
|
@@ -403,20 +251,28 @@ module RedQuilt
|
|
|
403
251
|
# reaches this branch). Continuation lines have no fixed indent
|
|
404
252
|
# cap — all leading whitespace is stripped before joining.
|
|
405
253
|
stripped = paragraph_lines.map.with_index do |l, i|
|
|
406
|
-
i.zero? ? strip_leading_spaces(l.content, 3) : strip_leading_whitespace(l.content)
|
|
254
|
+
i.zero? ? Indentation.strip_leading_spaces(l.content, 3) : Indentation.strip_leading_whitespace(l.content)
|
|
407
255
|
end
|
|
408
256
|
# Trailing whitespace on the last line is dropped (no hard-break
|
|
409
257
|
# without a following content line).
|
|
410
258
|
stripped[-1] = stripped[-1].sub(/[ \t]+\z/, "") if stripped.any?
|
|
411
259
|
indent_was_stripped = stripped.zip(paragraph_lines).any? { |s, l| s.length != l.content.length }
|
|
412
260
|
text = stripped.join("\n")
|
|
413
|
-
|
|
261
|
+
# Spans start at the first non-whitespace byte: leading indent is not
|
|
262
|
+
# part of the block as authored. Both cmark and mdast report ` text`
|
|
263
|
+
# as starting at column 3.
|
|
264
|
+
first_line = paragraph_lines.first
|
|
265
|
+
start_byte = first_line.start_byte + Indentation.leading_ws_bytes(first_line.content)
|
|
414
266
|
end_byte = paragraph_lines.last.end_byte
|
|
415
267
|
|
|
416
268
|
if setext_level
|
|
269
|
+
# The span runs through the `===` / `---` underline, which is part of
|
|
270
|
+
# the heading as authored — cmark and mdast both report it that way.
|
|
271
|
+
# The text is carried by str1, so widening the span does not leak the
|
|
272
|
+
# underline into the heading's content.
|
|
417
273
|
heading_id = @arena.add_node(NodeType::HEADING,
|
|
418
274
|
source_start: start_byte,
|
|
419
|
-
source_len: end_byte - start_byte,
|
|
275
|
+
source_len: setext_underline.end_byte - start_byte,
|
|
420
276
|
int1: setext_level,
|
|
421
277
|
str1: text.strip)
|
|
422
278
|
@arena.append_child(parent_id, heading_id)
|
|
@@ -454,54 +310,21 @@ module RedQuilt
|
|
|
454
310
|
return false unless index > 0
|
|
455
311
|
return true if atx_heading(line.content)
|
|
456
312
|
return true if thematic_break?(line.content)
|
|
457
|
-
return true if
|
|
313
|
+
return true if CodeBlock.fenced_start(line.content)
|
|
458
314
|
# CommonMark: HTML block types 1–6 interrupt paragraphs; type 7
|
|
459
315
|
# (a bare valid tag on its own line) does not.
|
|
460
|
-
if (type =
|
|
316
|
+
if (type = HtmlBlock.type(line.content)) && type != 7
|
|
461
317
|
return true
|
|
462
318
|
end
|
|
463
319
|
return true if Blockquote.match?(line.content)
|
|
464
320
|
if (li = List.match(line.content)) && List.interrupts_paragraph?(li)
|
|
465
321
|
return true
|
|
466
322
|
end
|
|
467
|
-
return true if
|
|
323
|
+
return true if Table.start?(lines, index)
|
|
468
324
|
|
|
469
325
|
false
|
|
470
326
|
end
|
|
471
327
|
|
|
472
|
-
# Strips up to `max` leading 0x20 bytes from `text`. Returns the
|
|
473
|
-
# original string when nothing changed, so callers avoid an
|
|
474
|
-
# allocation in the common no-indent case.
|
|
475
|
-
def strip_leading_spaces(text, max)
|
|
476
|
-
return text if max <= 0
|
|
477
|
-
|
|
478
|
-
bytes = text.bytesize
|
|
479
|
-
i = 0
|
|
480
|
-
while i < max && i < bytes && text.getbyte(i) == 0x20
|
|
481
|
-
i += 1
|
|
482
|
-
end
|
|
483
|
-
return text if i.zero?
|
|
484
|
-
|
|
485
|
-
text.byteslice(i..)
|
|
486
|
-
end
|
|
487
|
-
|
|
488
|
-
# Strips all leading 0x20 / 0x09 bytes from `text`. Same no-alloc
|
|
489
|
-
# return as `strip_leading_spaces` when the string already starts
|
|
490
|
-
# at a non-whitespace byte.
|
|
491
|
-
def strip_leading_whitespace(text)
|
|
492
|
-
bytes = text.bytesize
|
|
493
|
-
i = 0
|
|
494
|
-
while i < bytes
|
|
495
|
-
b = text.getbyte(i)
|
|
496
|
-
break unless b == 0x20 || b == 0x09
|
|
497
|
-
|
|
498
|
-
i += 1
|
|
499
|
-
end
|
|
500
|
-
return text if i.zero?
|
|
501
|
-
|
|
502
|
-
text.byteslice(i..)
|
|
503
|
-
end
|
|
504
|
-
|
|
505
328
|
def build_lines(source)
|
|
506
329
|
# split("\n", -1) avoids the extra slice/allocation that
|
|
507
330
|
# each_line + chomp incurs per line. The blank-line check uses
|
|
@@ -537,168 +360,10 @@ module RedQuilt
|
|
|
537
360
|
|
|
538
361
|
content = match[2].to_s
|
|
539
362
|
content_index = content.empty? ? text.length : (text.index(content) || text.bytesize)
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
match = /\A( {0,3})(`{3,}|~{3,})[ \t]*(.*?)\s*\z/.match(text)
|
|
545
|
-
return unless match
|
|
546
|
-
|
|
547
|
-
info = match[3]
|
|
548
|
-
# CommonMark: a backtick-style fence cannot have backticks in its
|
|
549
|
-
# info string (they'd be ambiguous with the fence itself).
|
|
550
|
-
return if match[2].start_with?("`") && info.include?("`")
|
|
551
|
-
|
|
552
|
-
{
|
|
553
|
-
char: match[2][0],
|
|
554
|
-
count: match[2].length,
|
|
555
|
-
info: ReferenceDefinition.unescape_text(info),
|
|
556
|
-
indent: match[1].length,
|
|
557
|
-
}
|
|
558
|
-
end
|
|
559
|
-
|
|
560
|
-
def fenced_code_close?(text, char, count)
|
|
561
|
-
# Manual byte scan beats compiling a per-(char,count) regex on
|
|
562
|
-
# every line of a fenced block. Pattern: 0-3 spaces, >=count of
|
|
563
|
-
# `char`, optional trailing spaces/tabs, end-of-line.
|
|
564
|
-
bytes = text.bytesize
|
|
565
|
-
i = 0
|
|
566
|
-
# CommonMark spec: at most 3 spaces of indent.
|
|
567
|
-
while i < 3 && i < bytes && text.getbyte(i) == 0x20
|
|
568
|
-
i += 1
|
|
569
|
-
end
|
|
570
|
-
char_byte = char.getbyte(0)
|
|
571
|
-
fence_start = i
|
|
572
|
-
while i < bytes && text.getbyte(i) == char_byte
|
|
573
|
-
i += 1
|
|
574
|
-
end
|
|
575
|
-
return false if i - fence_start < count
|
|
576
|
-
|
|
577
|
-
while i < bytes
|
|
578
|
-
b = text.getbyte(i)
|
|
579
|
-
return false unless b == 0x20 || b == 0x09
|
|
580
|
-
|
|
581
|
-
i += 1
|
|
582
|
-
end
|
|
583
|
-
true
|
|
584
|
-
end
|
|
585
|
-
|
|
586
|
-
def indented_code_line?(text)
|
|
587
|
-
# CommonMark: 4+ columns of leading whitespace, where tabs expand
|
|
588
|
-
# virtually to a tab stop of 4 columns.
|
|
589
|
-
Indentation.leading_columns(text) >= 4
|
|
590
|
-
end
|
|
591
|
-
|
|
592
|
-
# Returns the column count of leading whitespace, treating tabs as
|
|
593
|
-
# advancing to the next multiple-of-4 column.
|
|
594
|
-
def html_block_start?(text)
|
|
595
|
-
# Indented code block takes precedence (4+ spaces)
|
|
596
|
-
return false if text.start_with?(" ")
|
|
597
|
-
|
|
598
|
-
!html_block_type(text).nil?
|
|
599
|
-
end
|
|
600
|
-
|
|
601
|
-
def html_block_type(text)
|
|
602
|
-
# Fast reject: every HTML block starts with `<`. lstrip strips
|
|
603
|
-
# 0-3 indent spaces (more would already be indented code), so peek
|
|
604
|
-
# the leading non-space byte before doing any allocations.
|
|
605
|
-
i = 0
|
|
606
|
-
# CommonMark: HTML block lines may have 0-3 spaces of indent.
|
|
607
|
-
while i < 3 && i < text.length && text.getbyte(i) == 0x20
|
|
608
|
-
i += 1
|
|
609
|
-
end
|
|
610
|
-
return nil unless i < text.length && text.getbyte(i) == 0x3C
|
|
611
|
-
|
|
612
|
-
stripped = i.zero? ? text : text[i..]
|
|
613
|
-
|
|
614
|
-
# Type 1: <script|pre|style|textarea (case-insensitive) followed by
|
|
615
|
-
# space/tab/end-of-line or `>`. CommonMark restricts the separator
|
|
616
|
-
# to space, tab, or a line ending (not any whitespace class).
|
|
617
|
-
return 1 if stripped.match?(%r{\A<(script|pre|style|textarea)(?:[ \t]|>|$)}i)
|
|
618
|
-
|
|
619
|
-
# Type 2: <!--
|
|
620
|
-
return 2 if stripped.start_with?("<!--")
|
|
621
|
-
|
|
622
|
-
# Type 3: <?
|
|
623
|
-
return 3 if stripped.start_with?("<?")
|
|
624
|
-
|
|
625
|
-
# Type 4: <! followed by uppercase ASCII letter
|
|
626
|
-
return 4 if stripped.match?(%r{\A<![A-Z]})
|
|
627
|
-
|
|
628
|
-
# Type 5: <![CDATA[
|
|
629
|
-
return 5 if stripped.start_with?("<![CDATA[")
|
|
630
|
-
|
|
631
|
-
# Type 6: line opens with one of the listed block-level tags.
|
|
632
|
-
return 6 if stripped.match?(HTML_BLOCK_TYPE_6_RE)
|
|
633
|
-
|
|
634
|
-
# Type 7: a complete open or closing tag spanning the line.
|
|
635
|
-
return 7 if valid_html_tag?(stripped)
|
|
636
|
-
|
|
637
|
-
nil
|
|
638
|
-
end
|
|
639
|
-
|
|
640
|
-
HTML_BLOCK_TYPE_6_NAMES = %w[
|
|
641
|
-
address article aside base basefont blockquote body caption center
|
|
642
|
-
col colgroup dd details dialog dir div dl dt fieldset figcaption
|
|
643
|
-
figure footer form frame frameset h1 h2 h3 h4 h5 h6 head header
|
|
644
|
-
hr html iframe legend li link main menu menuitem nav noframes ol
|
|
645
|
-
optgroup option p param search section summary table tbody td
|
|
646
|
-
tfoot th thead title tr track ul
|
|
647
|
-
].freeze
|
|
648
|
-
HTML_BLOCK_TYPE_6_RE =
|
|
649
|
-
%r{\A</?(?:#{HTML_BLOCK_TYPE_6_NAMES.join('|')})(?:[ \t]|>|/>|\z)}i
|
|
650
|
-
|
|
651
|
-
private_constant :HTML_BLOCK_TYPE_6_NAMES, :HTML_BLOCK_TYPE_6_RE
|
|
652
|
-
|
|
653
|
-
def table_start?(lines, index)
|
|
654
|
-
return false if index + 1 >= lines.length
|
|
655
|
-
return false unless table_row?(lines[index].content)
|
|
656
|
-
|
|
657
|
-
header_cells = split_table_row(lines[index].content)
|
|
658
|
-
separators = split_table_row(lines[index + 1].content)
|
|
659
|
-
return false if separators.empty?
|
|
660
|
-
|
|
661
|
-
# GFM spec: separator row must have valid delimiters AND match header column count.
|
|
662
|
-
# "The header row must match the delimiter row in the number of cells.
|
|
663
|
-
# If not, a table will not be recognized."
|
|
664
|
-
return false unless header_cells.length == separators.length
|
|
665
|
-
|
|
666
|
-
separators.all? { |cell| cell.strip.match?(/\A:?-+:?\z/) }
|
|
667
|
-
end
|
|
668
|
-
|
|
669
|
-
def table_row?(text)
|
|
670
|
-
text.include?("|")
|
|
671
|
-
end
|
|
672
|
-
|
|
673
|
-
def split_table_row(text)
|
|
674
|
-
body = text.strip
|
|
675
|
-
body = body[1..] if body.start_with?("|")
|
|
676
|
-
body = body[0...-1] if body.end_with?("|")
|
|
677
|
-
body.split("|", -1)
|
|
678
|
-
end
|
|
679
|
-
|
|
680
|
-
# Type 7: a complete open or closing tag on its own line.
|
|
681
|
-
# Closing tags must not have attributes.
|
|
682
|
-
#
|
|
683
|
-
# HTML tag separators per CommonMark 6.6 are space, tab, or up to one
|
|
684
|
-
# line ending -- not the broader \s class (which would include form
|
|
685
|
-
# feed and vertical tab).
|
|
686
|
-
HTML_TYPE_7_OPEN_TAG_RE = %r{
|
|
687
|
-
\A
|
|
688
|
-
<[A-Za-z][A-Za-z0-9-]*
|
|
689
|
-
(?:[ \t\r\n]+[A-Za-z_:][A-Za-z0-9_.:-]*(?:[ \t\r\n]*=[ \t\r\n]*(?:"[^"\n]*"|'[^'\n]*'|[^ \t\r\n"'=<>`]+))?)*
|
|
690
|
-
[ \t\r\n]*/?>
|
|
691
|
-
\z
|
|
692
|
-
}x
|
|
693
|
-
HTML_TYPE_7_CLOSING_TAG_RE = %r{\A</[A-Za-z][A-Za-z0-9-]*[ \t\r\n]*>\z}
|
|
694
|
-
|
|
695
|
-
private_constant :HTML_TYPE_7_OPEN_TAG_RE, :HTML_TYPE_7_CLOSING_TAG_RE
|
|
696
|
-
|
|
697
|
-
def valid_html_tag?(text)
|
|
698
|
-
# Fast reject: every type-7 tag must begin with `<`.
|
|
699
|
-
return false unless text.start_with?("<")
|
|
700
|
-
|
|
701
|
-
HTML_TYPE_7_OPEN_TAG_RE.match?(text) || HTML_TYPE_7_CLOSING_TAG_RE.match?(text)
|
|
363
|
+
# Offset of the `#` run itself. Only spaces may precede it (at most 3),
|
|
364
|
+
# so the character index doubles as a byte offset.
|
|
365
|
+
{ level: match[1].length, content: content, content_start: content_index,
|
|
366
|
+
marker_start: match.begin(1) }
|
|
702
367
|
end
|
|
703
368
|
|
|
704
369
|
def store_reference(reference, source_span)
|
|
@@ -716,9 +381,5 @@ module RedQuilt
|
|
|
716
381
|
title: reference[:title],
|
|
717
382
|
}
|
|
718
383
|
end
|
|
719
|
-
|
|
720
|
-
def span_len(line)
|
|
721
|
-
line.end_byte - line.start_byte
|
|
722
|
-
end
|
|
723
384
|
end
|
|
724
385
|
end
|
data/lib/red_quilt/blockquote.rb
CHANGED
|
@@ -17,6 +17,14 @@ module RedQuilt
|
|
|
17
17
|
text.match?(BLOCKQUOTE_PREFIX_RE)
|
|
18
18
|
end
|
|
19
19
|
|
|
20
|
+
# Byte offset of the `>` within the line, or nil when there is no prefix.
|
|
21
|
+
# Only spaces may precede it (at most 3), so the character index returned
|
|
22
|
+
# by the match doubles as a byte offset.
|
|
23
|
+
def marker_offset(text)
|
|
24
|
+
match = BLOCKQUOTE_PREFIX_RE.match(text)
|
|
25
|
+
match && (match.end(0) - 1)
|
|
26
|
+
end
|
|
27
|
+
|
|
20
28
|
# Strip the leading `>` (and at most one column of whitespace after
|
|
21
29
|
# it) from a blockquote line. Returns a new Line whose
|
|
22
30
|
# content is the inner text. If the line has no `>` prefix, the
|
|
@@ -75,6 +83,11 @@ module RedQuilt
|
|
|
75
83
|
end
|
|
76
84
|
|
|
77
85
|
def parse(parent_id, lines, index)
|
|
86
|
+
# Captured before the loop: block_lines holds prefix-stripped lines,
|
|
87
|
+
# whose start_byte sits after the `>`. The span must start at the
|
|
88
|
+
# marker itself (excluding indent), as cmark and mdast both report.
|
|
89
|
+
first_line = lines[index]
|
|
90
|
+
source_start = first_line.start_byte + (Blockquote.marker_offset(first_line.content) || 0)
|
|
78
91
|
block_lines = []
|
|
79
92
|
paragraph_open = false
|
|
80
93
|
|
|
@@ -113,8 +126,8 @@ module RedQuilt
|
|
|
113
126
|
end
|
|
114
127
|
|
|
115
128
|
block_id = @arena.add_node(NodeType::BLOCKQUOTE,
|
|
116
|
-
source_start:
|
|
117
|
-
source_len: block_lines.last.end_byte -
|
|
129
|
+
source_start: source_start,
|
|
130
|
+
source_len: block_lines.last.end_byte - source_start)
|
|
118
131
|
@arena.append_child(parent_id, block_id)
|
|
119
132
|
@block_parser.parse_lines(block_id, block_lines, transformed: true)
|
|
120
133
|
index
|
data/lib/red_quilt/cli.rb
CHANGED
|
@@ -33,12 +33,13 @@ module RedQuilt
|
|
|
33
33
|
standalone: true,
|
|
34
34
|
auto_title: false,
|
|
35
35
|
title: nil,
|
|
36
|
-
lang:
|
|
36
|
+
lang: nil,
|
|
37
37
|
css: nil,
|
|
38
38
|
theme: :default,
|
|
39
39
|
output: nil,
|
|
40
40
|
open: false,
|
|
41
41
|
mermaid: false,
|
|
42
|
+
frontmatter: false,
|
|
42
43
|
}.freeze
|
|
43
44
|
|
|
44
45
|
THEMES = %i[none default].freeze
|
|
@@ -63,7 +64,8 @@ module RedQuilt
|
|
|
63
64
|
allow_html: options[:allow_html],
|
|
64
65
|
disallow_raw_html: options[:disallow_raw_html],
|
|
65
66
|
extended_autolinks: options[:extended_autolinks],
|
|
66
|
-
lint: options[:lint]
|
|
67
|
+
lint: options[:lint],
|
|
68
|
+
frontmatter: options[:frontmatter])
|
|
67
69
|
|
|
68
70
|
unless options[:diagnostics_only]
|
|
69
71
|
emit_output(doc, options, source_path: source_path, stdout: stdout, stderr: stderr)
|
|
@@ -159,6 +161,10 @@ module RedQuilt
|
|
|
159
161
|
"Render `mermaid` code blocks as diagrams (loads mermaid.js from a CDN in standalone output)") do
|
|
160
162
|
options[:mermaid] = true
|
|
161
163
|
end
|
|
164
|
+
opts.on("--frontmatter",
|
|
165
|
+
"Parse leading YAML frontmatter (---) as metadata; fills <title>/lang in standalone output") do
|
|
166
|
+
options[:frontmatter] = true
|
|
167
|
+
end
|
|
162
168
|
opts.on("--diagnostics", "Also print diagnostics to stderr") do
|
|
163
169
|
options[:diagnostics] = true
|
|
164
170
|
end
|