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
|
@@ -9,22 +9,10 @@ module RedQuilt
|
|
|
9
9
|
# 1. linear_pass — code spans, brackets (link/image), autolinks,
|
|
10
10
|
# HTML, simple inlines. Emphasis delimiter runs are added as
|
|
11
11
|
# provisional TEXT nodes and pushed onto a delimiter stack.
|
|
12
|
-
# 2.
|
|
13
|
-
# delimiter stack entries into EMPHASIS / STRONG nodes
|
|
12
|
+
# 2. EmphasisResolver#resolve — CommonMark spec 6.2 algorithm pairs
|
|
13
|
+
# up delimiter stack entries into EMPHASIS / STRONG nodes
|
|
14
|
+
# (delegated to Inline::EmphasisResolver).
|
|
14
15
|
class Builder
|
|
15
|
-
SAFE_SCHEMES = %w[http https mailto ftp tel ssh].freeze
|
|
16
|
-
# Autolinks (`<scheme:...>`) are not run through the SAFE_SCHEMES
|
|
17
|
-
# allowlist: CommonMark permits arbitrary schemes there (e.g.
|
|
18
|
-
# `<made-up-scheme://x>`), and an allowlist would break that
|
|
19
|
-
# conformance. Only the schemes that execute script when the link
|
|
20
|
-
# is navigated are denied.
|
|
21
|
-
UNSAFE_AUTOLINK_SCHEMES = %w[javascript vbscript data].freeze
|
|
22
|
-
|
|
23
|
-
# `count` is the CommonMark delimiter-run length; a Delimiter is
|
|
24
|
-
# never enumerated, so shadowing Struct#count (from Enumerable) is
|
|
25
|
-
# intentional rather than a footgun.
|
|
26
|
-
Delimiter = Struct.new(:node_id, :char, :count, :can_open, :can_close) # rubocop:disable Lint/StructNewOverride
|
|
27
|
-
|
|
28
16
|
Bracket = Struct.new(:token_id, :node_id, :image, :active, :delim_stack_size)
|
|
29
17
|
|
|
30
18
|
# track_source: when true, arena nodes carry the byte ranges supplied
|
|
@@ -39,16 +27,17 @@ module RedQuilt
|
|
|
39
27
|
def initialize(arena, source, references, track_source: true, diagnostics: nil, footnotes: nil)
|
|
40
28
|
@arena = arena
|
|
41
29
|
@source = source
|
|
42
|
-
# Binary view of the source for String#
|
|
43
|
-
#
|
|
44
|
-
#
|
|
45
|
-
# as its own character.
|
|
30
|
+
# Binary view of the source for String#index hot paths: on a
|
|
31
|
+
# UTF-8 string, index counts characters, so the offsets would not
|
|
32
|
+
# line up with our byte positions. The binary view treats every
|
|
33
|
+
# byte as its own character, making index a byte-offset search.
|
|
46
34
|
@source_b = source.b
|
|
47
35
|
@references = references
|
|
48
36
|
@track_source = track_source
|
|
49
37
|
@diagnostics = diagnostics
|
|
50
38
|
@footnotes = footnotes
|
|
51
39
|
@link_scanner = LinkScanner.new(source)
|
|
40
|
+
@emphasis = EmphasisResolver.new(arena, track_source: track_source)
|
|
52
41
|
end
|
|
53
42
|
|
|
54
43
|
def build(parent_id, tokens)
|
|
@@ -58,7 +47,7 @@ module RedQuilt
|
|
|
58
47
|
@bracket_stack = []
|
|
59
48
|
@provisional_nodes = {}
|
|
60
49
|
linear_pass
|
|
61
|
-
|
|
50
|
+
@emphasis.resolve(@delimiter_stack, @provisional_nodes)
|
|
62
51
|
end
|
|
63
52
|
|
|
64
53
|
private
|
|
@@ -228,43 +217,26 @@ module RedQuilt
|
|
|
228
217
|
link_id = add_arena_node(
|
|
229
218
|
NodeType::LINK,
|
|
230
219
|
@tokens.start_byte(id), @tokens.end_byte(id),
|
|
231
|
-
str1: block_unsafe_autolink(@link_scanner.normalize_uri(destination)),
|
|
220
|
+
str1: UrlSanitizer.block_unsafe_autolink(@link_scanner.normalize_uri(destination), @diagnostics),
|
|
232
221
|
)
|
|
233
222
|
@arena.append_child(@parent_id, link_id)
|
|
234
223
|
@arena.append_child(link_id, @arena.add_node(NodeType::TEXT, str1: label))
|
|
235
224
|
end
|
|
236
225
|
|
|
237
|
-
# Returns "" (blocking the href) for autolink destinations whose
|
|
238
|
-
# scheme executes script on navigation; otherwise the destination
|
|
239
|
-
# is returned unchanged. Unlike sanitize_destination this is a
|
|
240
|
-
# denylist, to stay CommonMark-conformant for benign custom schemes.
|
|
241
|
-
def block_unsafe_autolink(destination)
|
|
242
|
-
scheme = destination[%r{\A([a-zA-Z][a-zA-Z0-9+\-.]*):}, 1]
|
|
243
|
-
return destination if scheme.nil?
|
|
244
|
-
return destination unless UNSAFE_AUTOLINK_SCHEMES.include?(scheme.downcase)
|
|
245
|
-
|
|
246
|
-
report_diagnostic(
|
|
247
|
-
severity: :warning,
|
|
248
|
-
rule: :unsafe_url,
|
|
249
|
-
message: "Unsafe URL scheme #{scheme.downcase.inspect} blocked",
|
|
250
|
-
)
|
|
251
|
-
""
|
|
252
|
-
end
|
|
253
|
-
|
|
254
226
|
# --------------------------- code spans -----------------------------
|
|
255
227
|
|
|
256
228
|
# Find the closing backtick run for a code span by scanning the
|
|
257
229
|
# source bytes directly. CommonMark: backslash escapes do not
|
|
258
230
|
# apply inside a code span, so once we're past the opener every
|
|
259
231
|
# backtick run is a real candidate (token-level ESCAPED_CHAR is
|
|
260
|
-
# ignored).
|
|
261
|
-
# C speed.
|
|
232
|
+
# ignored). index on the binary view jumps over non-backtick byte
|
|
233
|
+
# stretches at C speed.
|
|
262
234
|
def resolve_code_span(opener_id)
|
|
263
235
|
run_len = @tokens.int1(opener_id)
|
|
264
236
|
pos = @tokens.end_byte(opener_id)
|
|
265
237
|
bytesize = @source_b.bytesize
|
|
266
238
|
while pos < bytesize
|
|
267
|
-
run_start = @source_b.
|
|
239
|
+
run_start = @source_b.index(BACKTICK_BYTE, pos)
|
|
268
240
|
break if run_start.nil?
|
|
269
241
|
|
|
270
242
|
pos = run_start + 1
|
|
@@ -400,7 +372,7 @@ module RedQuilt
|
|
|
400
372
|
link_kind = opener.image ? NodeType::IMAGE : NodeType::LINK
|
|
401
373
|
link_id = add_arena_node(
|
|
402
374
|
link_kind, opener_start, match[:end_byte],
|
|
403
|
-
str1: sanitize_destination(match[:destination]),
|
|
375
|
+
str1: UrlSanitizer.sanitize_destination(match[:destination], @diagnostics),
|
|
404
376
|
str2: match[:title],
|
|
405
377
|
)
|
|
406
378
|
|
|
@@ -416,7 +388,7 @@ module RedQuilt
|
|
|
416
388
|
@arena.detach(opener.node_id)
|
|
417
389
|
|
|
418
390
|
inner_delims = @delimiter_stack.slice!(opener.delim_stack_size..) || []
|
|
419
|
-
|
|
391
|
+
@emphasis.resolve(inner_delims, @provisional_nodes)
|
|
420
392
|
|
|
421
393
|
@bracket_stack.delete_at(opener_index)
|
|
422
394
|
|
|
@@ -489,22 +461,6 @@ module RedQuilt
|
|
|
489
461
|
last
|
|
490
462
|
end
|
|
491
463
|
|
|
492
|
-
def sanitize_destination(destination)
|
|
493
|
-
return "" if destination.nil?
|
|
494
|
-
return destination if destination.start_with?("/", "#")
|
|
495
|
-
|
|
496
|
-
scheme = destination[%r{\A([a-zA-Z][a-zA-Z0-9+\-.]*):}, 1]
|
|
497
|
-
return destination if scheme.nil?
|
|
498
|
-
return destination if SAFE_SCHEMES.include?(scheme.downcase)
|
|
499
|
-
|
|
500
|
-
report_diagnostic(
|
|
501
|
-
severity: :warning,
|
|
502
|
-
rule: :unsafe_url,
|
|
503
|
-
message: "Unsafe URL scheme #{scheme.downcase.inspect} blocked",
|
|
504
|
-
)
|
|
505
|
-
""
|
|
506
|
-
end
|
|
507
|
-
|
|
508
464
|
def report_diagnostic(severity:, rule:, message:, source_span: nil)
|
|
509
465
|
return unless @diagnostics
|
|
510
466
|
|
|
@@ -530,145 +486,12 @@ module RedQuilt
|
|
|
530
486
|
@arena.append_child(@parent_id, node_id)
|
|
531
487
|
@provisional_nodes[node_id] = true
|
|
532
488
|
|
|
533
|
-
@delimiter_stack << Delimiter.new(
|
|
489
|
+
@delimiter_stack << EmphasisResolver::Delimiter.new(
|
|
534
490
|
node_id, char, count,
|
|
535
491
|
(flags & 0b10) != 0,
|
|
536
492
|
(flags & 0b01) != 0,
|
|
537
493
|
)
|
|
538
494
|
end
|
|
539
|
-
|
|
540
|
-
def process_emphasis(stack)
|
|
541
|
-
# NB: the CommonMark spec describes an `openers_bottom`
|
|
542
|
-
# optimization keyed by closer character / length / flanking
|
|
543
|
-
# flags. Implementing that correctly is subtle (a single
|
|
544
|
-
# per-character bottom blocks valid matches like
|
|
545
|
-
# `*foo**bar**baz*`), so the implementation here just walks
|
|
546
|
-
# back to the start of the stack for every closer. This is
|
|
547
|
-
# O(stack^2) in the worst case but stacks are tiny in practice.
|
|
548
|
-
closer_idx = 0
|
|
549
|
-
|
|
550
|
-
while closer_idx < stack.length
|
|
551
|
-
closer = stack[closer_idx]
|
|
552
|
-
unless closer.can_close
|
|
553
|
-
closer_idx += 1
|
|
554
|
-
next
|
|
555
|
-
end
|
|
556
|
-
|
|
557
|
-
opener_idx = closer_idx - 1
|
|
558
|
-
found = false
|
|
559
|
-
while opener_idx >= 0
|
|
560
|
-
opener = stack[opener_idx]
|
|
561
|
-
if opener.can_open && opener.char == closer.char
|
|
562
|
-
skip = false
|
|
563
|
-
if (opener.can_close || closer.can_open) &&
|
|
564
|
-
((opener.count + closer.count) % 3).zero? &&
|
|
565
|
-
!((opener.count % 3).zero? && (closer.count % 3).zero?)
|
|
566
|
-
skip = true
|
|
567
|
-
end
|
|
568
|
-
unless skip
|
|
569
|
-
found = true
|
|
570
|
-
break
|
|
571
|
-
end
|
|
572
|
-
end
|
|
573
|
-
opener_idx -= 1
|
|
574
|
-
end
|
|
575
|
-
|
|
576
|
-
unless found
|
|
577
|
-
unless closer.can_open
|
|
578
|
-
@provisional_nodes.delete(closer.node_id)
|
|
579
|
-
stack.delete_at(closer_idx)
|
|
580
|
-
end
|
|
581
|
-
closer_idx += 1
|
|
582
|
-
next
|
|
583
|
-
end
|
|
584
|
-
|
|
585
|
-
opener = stack[opener_idx]
|
|
586
|
-
strength = [opener.count, closer.count].min >= 2 ? 2 : 1
|
|
587
|
-
if closer.char == "~"
|
|
588
|
-
# GFM strikethrough only forms on `~~` runs. A single `~`
|
|
589
|
-
# leaves the delimiter as text; advance the cursor so future
|
|
590
|
-
# `~~` pairs can still match.
|
|
591
|
-
if strength < 2
|
|
592
|
-
closer_idx += 1
|
|
593
|
-
next
|
|
594
|
-
end
|
|
595
|
-
kind = NodeType::STRIKETHROUGH
|
|
596
|
-
else
|
|
597
|
-
kind = strength == 2 ? NodeType::STRONG : NodeType::EMPHASIS
|
|
598
|
-
end
|
|
599
|
-
|
|
600
|
-
# CommonMark spec: any delimiters strictly between this opener and
|
|
601
|
-
# closer can't open or close anything in this scope, so drop them
|
|
602
|
-
# from the stack before we rebuild the tree. Their arena nodes
|
|
603
|
-
# stay where they are (they'll be reparented into the new emphasis
|
|
604
|
-
# alongside the surrounding content), but they must no longer be
|
|
605
|
-
# candidates for future iterations. Without this, the next
|
|
606
|
-
# iteration would try to pair stranded delimiters that have
|
|
607
|
-
# already been moved into a different parent, which corrupts the
|
|
608
|
-
# sibling chain (Arena#reparent walks into @parent[-1]).
|
|
609
|
-
if closer_idx > opener_idx + 1
|
|
610
|
-
removed = stack.slice!((opener_idx + 1)...closer_idx)
|
|
611
|
-
removed.each { |e| @provisional_nodes.delete(e.node_id) }
|
|
612
|
-
closer_idx = opener_idx + 1
|
|
613
|
-
closer = stack[closer_idx]
|
|
614
|
-
end
|
|
615
|
-
|
|
616
|
-
opener_node = opener.node_id
|
|
617
|
-
closer_node = closer.node_id
|
|
618
|
-
|
|
619
|
-
if @track_source
|
|
620
|
-
opener_match_start = @arena.source_end(opener_node) - strength
|
|
621
|
-
closer_match_end = @arena.source_start(closer_node) + strength
|
|
622
|
-
else
|
|
623
|
-
opener_match_start = -1
|
|
624
|
-
closer_match_end = 0
|
|
625
|
-
end
|
|
626
|
-
emphasis_id = add_arena_node(kind, opener_match_start, closer_match_end)
|
|
627
|
-
|
|
628
|
-
first_inside = @arena.raw_next_sibling_id(opener_node)
|
|
629
|
-
last_inside = @arena.raw_prev_sibling_id(closer_node)
|
|
630
|
-
if first_inside != -1 && last_inside != -1 &&
|
|
631
|
-
first_inside != closer_node && last_inside != opener_node
|
|
632
|
-
@arena.reparent(emphasis_id, first_inside, last_inside)
|
|
633
|
-
end
|
|
634
|
-
|
|
635
|
-
parent_id = @arena.raw_parent_id(opener_node)
|
|
636
|
-
@arena.insert_before(parent_id, closer_node, emphasis_id)
|
|
637
|
-
|
|
638
|
-
if opener.count == strength
|
|
639
|
-
@provisional_nodes.delete(opener_node)
|
|
640
|
-
@arena.detach(opener_node)
|
|
641
|
-
stack.delete_at(opener_idx)
|
|
642
|
-
closer_idx -= 1
|
|
643
|
-
else
|
|
644
|
-
opener.count -= strength
|
|
645
|
-
str = @arena.str1(opener_node)
|
|
646
|
-
@arena.update_str1(opener_node, str[0...-strength])
|
|
647
|
-
if @track_source
|
|
648
|
-
new_end = @arena.source_end(opener_node) - strength
|
|
649
|
-
@arena.update_span(opener_node, @arena.source_start(opener_node), new_end)
|
|
650
|
-
end
|
|
651
|
-
end
|
|
652
|
-
|
|
653
|
-
if closer.count == strength
|
|
654
|
-
@provisional_nodes.delete(closer_node)
|
|
655
|
-
@arena.detach(closer_node)
|
|
656
|
-
stack.delete_at(closer_idx)
|
|
657
|
-
else
|
|
658
|
-
closer.count -= strength
|
|
659
|
-
str = @arena.str1(closer_node)
|
|
660
|
-
@arena.update_str1(closer_node, str[strength..])
|
|
661
|
-
if @track_source
|
|
662
|
-
new_start = @arena.source_start(closer_node) + strength
|
|
663
|
-
new_end = @arena.source_end(closer_node)
|
|
664
|
-
@arena.update_span(closer_node, new_start, new_end)
|
|
665
|
-
end
|
|
666
|
-
end
|
|
667
|
-
end
|
|
668
|
-
|
|
669
|
-
stack.each { |e| @provisional_nodes.delete(e.node_id) }
|
|
670
|
-
stack.clear
|
|
671
|
-
end
|
|
672
495
|
end
|
|
673
496
|
end
|
|
674
497
|
end
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RedQuilt
|
|
4
|
+
module Inline
|
|
5
|
+
# CommonMark emphasis algorithm (spec 6.2). Phase 2 of inline parsing:
|
|
6
|
+
# given the delimiter stack the linear pass collected (provisional TEXT
|
|
7
|
+
# nodes for each `*` / `_` / `~` run), it pairs openers with closers and
|
|
8
|
+
# rebuilds the arena subtree into EMPHASIS / STRONG / STRIKETHROUGH
|
|
9
|
+
# nodes.
|
|
10
|
+
#
|
|
11
|
+
# Kept separate from Builder because it is a closed algorithm with a
|
|
12
|
+
# narrow interface: it only needs the arena, the set of still-provisional
|
|
13
|
+
# nodes (so consumed delimiters can be unmarked), and whether source
|
|
14
|
+
# spans are tracked. Builder owns the linear pass and bracket handling;
|
|
15
|
+
# it hands this resolver a delimiter stack to collapse.
|
|
16
|
+
class EmphasisResolver
|
|
17
|
+
# `count` is the CommonMark delimiter-run length; a Delimiter is
|
|
18
|
+
# never enumerated, so shadowing Struct#count (from Enumerable) is
|
|
19
|
+
# intentional rather than a footgun.
|
|
20
|
+
Delimiter = Struct.new(:node_id, :char, :count, :can_open, :can_close) # rubocop:disable Lint/StructNewOverride
|
|
21
|
+
|
|
22
|
+
def initialize(arena, track_source:)
|
|
23
|
+
@arena = arena
|
|
24
|
+
@track_source = track_source
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Collapses `stack` (an Array of Delimiter) in place, removing
|
|
28
|
+
# consumed entries from `provisional_nodes`. Used both for the
|
|
29
|
+
# document-level stack and for the inner delimiters of a resolved
|
|
30
|
+
# link/image (see Builder#finalize_link).
|
|
31
|
+
def resolve(stack, provisional_nodes)
|
|
32
|
+
# NB: the CommonMark spec describes an `openers_bottom`
|
|
33
|
+
# optimization keyed by closer character / length / flanking
|
|
34
|
+
# flags. Implementing that correctly is subtle (a single
|
|
35
|
+
# per-character bottom blocks valid matches like
|
|
36
|
+
# `*foo**bar**baz*`), so the implementation here just walks
|
|
37
|
+
# back to the start of the stack for every closer. This is
|
|
38
|
+
# O(stack^2) in the worst case but stacks are tiny in practice.
|
|
39
|
+
closer_idx = 0
|
|
40
|
+
|
|
41
|
+
while closer_idx < stack.length
|
|
42
|
+
closer = stack[closer_idx]
|
|
43
|
+
unless closer.can_close
|
|
44
|
+
closer_idx += 1
|
|
45
|
+
next
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
opener_idx = closer_idx - 1
|
|
49
|
+
found = false
|
|
50
|
+
while opener_idx >= 0
|
|
51
|
+
opener = stack[opener_idx]
|
|
52
|
+
if opener.can_open && opener.char == closer.char
|
|
53
|
+
skip = false
|
|
54
|
+
if (opener.can_close || closer.can_open) &&
|
|
55
|
+
((opener.count + closer.count) % 3).zero? &&
|
|
56
|
+
!((opener.count % 3).zero? && (closer.count % 3).zero?)
|
|
57
|
+
skip = true
|
|
58
|
+
end
|
|
59
|
+
unless skip
|
|
60
|
+
found = true
|
|
61
|
+
break
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
opener_idx -= 1
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
unless found
|
|
68
|
+
unless closer.can_open
|
|
69
|
+
provisional_nodes.delete(closer.node_id)
|
|
70
|
+
stack.delete_at(closer_idx)
|
|
71
|
+
end
|
|
72
|
+
closer_idx += 1
|
|
73
|
+
next
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
opener = stack[opener_idx]
|
|
77
|
+
strength = [opener.count, closer.count].min >= 2 ? 2 : 1
|
|
78
|
+
if closer.char == "~"
|
|
79
|
+
# GFM strikethrough only forms on `~~` runs. A single `~`
|
|
80
|
+
# leaves the delimiter as text; advance the cursor so future
|
|
81
|
+
# `~~` pairs can still match.
|
|
82
|
+
if strength < 2
|
|
83
|
+
closer_idx += 1
|
|
84
|
+
next
|
|
85
|
+
end
|
|
86
|
+
kind = NodeType::STRIKETHROUGH
|
|
87
|
+
else
|
|
88
|
+
kind = strength == 2 ? NodeType::STRONG : NodeType::EMPHASIS
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# CommonMark spec: any delimiters strictly between this opener and
|
|
92
|
+
# closer can't open or close anything in this scope, so drop them
|
|
93
|
+
# from the stack before we rebuild the tree. Their arena nodes
|
|
94
|
+
# stay where they are (they'll be reparented into the new emphasis
|
|
95
|
+
# alongside the surrounding content), but they must no longer be
|
|
96
|
+
# candidates for future iterations. Without this, the next
|
|
97
|
+
# iteration would try to pair stranded delimiters that have
|
|
98
|
+
# already been moved into a different parent, which corrupts the
|
|
99
|
+
# sibling chain (Arena#reparent walks into @parent[-1]).
|
|
100
|
+
if closer_idx > opener_idx + 1
|
|
101
|
+
removed = stack.slice!((opener_idx + 1)...closer_idx)
|
|
102
|
+
removed.each { |e| provisional_nodes.delete(e.node_id) }
|
|
103
|
+
closer_idx = opener_idx + 1
|
|
104
|
+
closer = stack[closer_idx]
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
opener_node = opener.node_id
|
|
108
|
+
closer_node = closer.node_id
|
|
109
|
+
|
|
110
|
+
if @track_source
|
|
111
|
+
opener_match_start = @arena.source_end(opener_node) - strength
|
|
112
|
+
closer_match_end = @arena.source_start(closer_node) + strength
|
|
113
|
+
else
|
|
114
|
+
opener_match_start = -1
|
|
115
|
+
closer_match_end = 0
|
|
116
|
+
end
|
|
117
|
+
emphasis_id = add_node(kind, opener_match_start, closer_match_end)
|
|
118
|
+
|
|
119
|
+
first_inside = @arena.raw_next_sibling_id(opener_node)
|
|
120
|
+
last_inside = @arena.raw_prev_sibling_id(closer_node)
|
|
121
|
+
if first_inside != -1 && last_inside != -1 &&
|
|
122
|
+
first_inside != closer_node && last_inside != opener_node
|
|
123
|
+
@arena.reparent(emphasis_id, first_inside, last_inside)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
parent_id = @arena.raw_parent_id(opener_node)
|
|
127
|
+
@arena.insert_before(parent_id, closer_node, emphasis_id)
|
|
128
|
+
|
|
129
|
+
# Consume `strength` characters from the inner end of each
|
|
130
|
+
# delimiter. The opener is trimmed on its right (trailing) end,
|
|
131
|
+
# the closer on its left (leading) end; removing the opener from
|
|
132
|
+
# the stack shifts the closer one slot left.
|
|
133
|
+
closer_idx -= 1 if consume_delimiter(opener, opener_idx, stack, strength, provisional_nodes, from_start: false)
|
|
134
|
+
consume_delimiter(closer, closer_idx, stack, strength, provisional_nodes, from_start: true)
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
stack.each { |e| provisional_nodes.delete(e.node_id) }
|
|
138
|
+
stack.clear
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
private
|
|
142
|
+
|
|
143
|
+
# Mirrors Builder#add_arena_node for the nodes this resolver creates
|
|
144
|
+
# (emphasis wrappers only ever take a type and a span).
|
|
145
|
+
def add_node(type, start_byte, end_byte)
|
|
146
|
+
if @track_source
|
|
147
|
+
@arena.add_node(type, source_start: start_byte, source_len: end_byte - start_byte)
|
|
148
|
+
else
|
|
149
|
+
@arena.add_node(type, source_start: -1, source_len: 0)
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# Removes `strength` characters from one end of a delimiter run. When
|
|
154
|
+
# the whole run is consumed the node is detached and dropped from the
|
|
155
|
+
# stack (returns true); otherwise its count, str1, and — in
|
|
156
|
+
# source-tracking mode — its span are trimmed on the requested side
|
|
157
|
+
# (`from_start` trims the leading end, used for closers; trailing for
|
|
158
|
+
# openers) and it stays on the stack (returns false).
|
|
159
|
+
def consume_delimiter(entry, index, stack, strength, provisional_nodes, from_start:)
|
|
160
|
+
node = entry.node_id
|
|
161
|
+
if entry.count == strength
|
|
162
|
+
provisional_nodes.delete(node)
|
|
163
|
+
@arena.detach(node)
|
|
164
|
+
stack.delete_at(index)
|
|
165
|
+
return true
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
entry.count -= strength
|
|
169
|
+
str = @arena.str1(node)
|
|
170
|
+
@arena.update_str1(node, from_start ? str[strength..] : str[0...-strength])
|
|
171
|
+
if @track_source
|
|
172
|
+
start_byte = @arena.source_start(node)
|
|
173
|
+
end_byte = @arena.source_end(node)
|
|
174
|
+
if from_start
|
|
175
|
+
@arena.update_span(node, start_byte + strength, end_byte)
|
|
176
|
+
else
|
|
177
|
+
@arena.update_span(node, start_byte, end_byte - strength)
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
false
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
end
|
|
@@ -22,8 +22,8 @@ module RedQuilt
|
|
|
22
22
|
[0x2A, 0x5F, 0x60, 0x5B, 0x5D, 0x21, 0x3C, 0x26, 0x5C, 0x0A, 0x7E].each { |b| a[b] = true }
|
|
23
23
|
a.freeze
|
|
24
24
|
end
|
|
25
|
-
# Same set as SPECIAL_BYTES, for String#
|
|
26
|
-
# plain-text runs at C speed.
|
|
25
|
+
# Same set as SPECIAL_BYTES, for String#index on the binary view to
|
|
26
|
+
# jump over long plain-text runs at C speed.
|
|
27
27
|
SPECIAL_BYTE_RE = /[*_`\[\]!<&\\\n~]/
|
|
28
28
|
|
|
29
29
|
# Anchored regexes for StringScanner#scan (still used by
|
|
@@ -116,12 +116,12 @@ module RedQuilt
|
|
|
116
116
|
pos = @ss.pos
|
|
117
117
|
else
|
|
118
118
|
# Inlined scan_text. Always make progress: consume the
|
|
119
|
-
# current byte, then
|
|
119
|
+
# current byte, then index against the binary view to
|
|
120
120
|
# leap to the next special byte at C speed.
|
|
121
121
|
start = pos
|
|
122
122
|
pos += 1
|
|
123
123
|
if pos < end_pos
|
|
124
|
-
next_special = @source_b.
|
|
124
|
+
next_special = @source_b.index(SPECIAL_BYTE_RE, pos)
|
|
125
125
|
pos = next_special.nil? || next_special >= end_pos ? end_pos : next_special
|
|
126
126
|
end
|
|
127
127
|
tokens.emit(TokenKind::TEXT, start_byte: start, end_byte: pos)
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RedQuilt
|
|
4
|
+
module Inline
|
|
5
|
+
# URL-scheme security policy for inline link / image / autolink
|
|
6
|
+
# destinations. Kept separate from Builder so the "which schemes are
|
|
7
|
+
# safe and how blocking is reported" concern has a single home and can
|
|
8
|
+
# change without touching the inline construction logic.
|
|
9
|
+
#
|
|
10
|
+
# Stateless (module_function); diagnostics are appended to the caller's
|
|
11
|
+
# array (or skipped when it is nil), so there is no per-call allocation.
|
|
12
|
+
module UrlSanitizer
|
|
13
|
+
module_function
|
|
14
|
+
|
|
15
|
+
SAFE_SCHEMES = %w[http https mailto ftp tel ssh].freeze
|
|
16
|
+
|
|
17
|
+
# Autolinks (`<scheme:...>`) are not run through the SAFE_SCHEMES
|
|
18
|
+
# allowlist: CommonMark permits arbitrary schemes there (e.g.
|
|
19
|
+
# `<made-up-scheme://x>`), and an allowlist would break that
|
|
20
|
+
# conformance. Only the schemes that execute script when the link
|
|
21
|
+
# is navigated are denied.
|
|
22
|
+
UNSAFE_AUTOLINK_SCHEMES = %w[javascript vbscript data].freeze
|
|
23
|
+
|
|
24
|
+
SCHEME_RE = /\A([a-zA-Z][a-zA-Z0-9+\-.]*):/
|
|
25
|
+
|
|
26
|
+
# Link / image destinations: allowlist. Relative URLs (starting `/`
|
|
27
|
+
# or `#`) and scheme-less URLs pass; an unknown scheme is blocked
|
|
28
|
+
# (href emptied) and a diagnostic is recorded.
|
|
29
|
+
def sanitize_destination(destination, diagnostics)
|
|
30
|
+
return "" if destination.nil?
|
|
31
|
+
return destination if destination.start_with?("/", "#")
|
|
32
|
+
|
|
33
|
+
scheme = destination[SCHEME_RE, 1]
|
|
34
|
+
return destination if scheme.nil?
|
|
35
|
+
return destination if SAFE_SCHEMES.include?(scheme.downcase)
|
|
36
|
+
|
|
37
|
+
report_blocked(diagnostics, scheme)
|
|
38
|
+
""
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Autolink destinations: denylist. The destination is returned
|
|
42
|
+
# unchanged unless its scheme executes script on navigation, in which
|
|
43
|
+
# case the href is emptied and a diagnostic is recorded.
|
|
44
|
+
def block_unsafe_autolink(destination, diagnostics)
|
|
45
|
+
scheme = destination[SCHEME_RE, 1]
|
|
46
|
+
return destination if scheme.nil?
|
|
47
|
+
return destination unless UNSAFE_AUTOLINK_SCHEMES.include?(scheme.downcase)
|
|
48
|
+
|
|
49
|
+
report_blocked(diagnostics, scheme)
|
|
50
|
+
""
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def report_blocked(diagnostics, scheme)
|
|
54
|
+
return unless diagnostics
|
|
55
|
+
|
|
56
|
+
diagnostics << Diagnostic.new(
|
|
57
|
+
severity: :warning,
|
|
58
|
+
rule: :unsafe_url,
|
|
59
|
+
message: "Unsafe URL scheme #{scheme.downcase.inspect} blocked",
|
|
60
|
+
)
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -35,8 +35,7 @@ module RedQuilt
|
|
|
35
35
|
diagnostics: @document.diagnostics,
|
|
36
36
|
footnotes: @document.footnotes).build(node_id, @tokens)
|
|
37
37
|
else
|
|
38
|
-
start_byte =
|
|
39
|
-
end_byte = @arena.source_end(node_id)
|
|
38
|
+
start_byte, end_byte = inline_range(node_id)
|
|
40
39
|
@lexer.lex_into(@tokens, start_byte, end_byte)
|
|
41
40
|
@builder.build(node_id, @tokens)
|
|
42
41
|
end
|
|
@@ -49,5 +48,19 @@ module RedQuilt
|
|
|
49
48
|
child_id = @arena.raw_next_sibling_id(child_id)
|
|
50
49
|
end
|
|
51
50
|
end
|
|
51
|
+
|
|
52
|
+
# The byte range to lex as inline content. For most nodes that is the
|
|
53
|
+
# source span, but an ATX heading's span covers the `#` marker and any
|
|
54
|
+
# closing hashes so that positions point at the heading as authored;
|
|
55
|
+
# lexing that range would turn the marker into text. Setext headings
|
|
56
|
+
# leave the content range zeroed and fall back to the span.
|
|
57
|
+
def inline_range(node_id)
|
|
58
|
+
if @arena.type(node_id) == NodeType::HEADING && @arena.heading_content_start(node_id).positive?
|
|
59
|
+
start_byte = @arena.heading_content_start(node_id)
|
|
60
|
+
return [start_byte, start_byte + @arena.heading_content_len(node_id)]
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
[@arena.source_start(node_id), @arena.source_end(node_id)]
|
|
64
|
+
end
|
|
52
65
|
end
|
|
53
66
|
end
|
data/lib/red_quilt/line.rb
CHANGED
|
@@ -10,5 +10,10 @@ module RedQuilt
|
|
|
10
10
|
# Positional (not keyword_init): one Line is built per source line, so
|
|
11
11
|
# the ~2.5x faster positional constructor matters on large documents.
|
|
12
12
|
# Argument order: content, start_byte, end_byte, blank, lazy_continuation.
|
|
13
|
-
Line = Struct.new(:content, :start_byte, :end_byte, :blank, :lazy_continuation)
|
|
13
|
+
Line = Struct.new(:content, :start_byte, :end_byte, :blank, :lazy_continuation) do
|
|
14
|
+
# Byte length of the line's span in the original source.
|
|
15
|
+
def span_len
|
|
16
|
+
end_byte - start_byte
|
|
17
|
+
end
|
|
18
|
+
end
|
|
14
19
|
end
|
data/lib/red_quilt/lint_pass.rb
CHANGED
|
@@ -22,7 +22,7 @@ module RedQuilt
|
|
|
22
22
|
walk(@document.root_id) do |id|
|
|
23
23
|
case @arena.type(id)
|
|
24
24
|
when NodeType::HEADING
|
|
25
|
-
level = @arena.
|
|
25
|
+
level = @arena.heading_level(id)
|
|
26
26
|
if last_heading_level.positive? && level > last_heading_level + 1
|
|
27
27
|
push(:info, :heading_level_skip,
|
|
28
28
|
"Heading jumps from h#{last_heading_level} to h#{level}",
|
|
@@ -45,7 +45,7 @@ module RedQuilt
|
|
|
45
45
|
end
|
|
46
46
|
|
|
47
47
|
def check_empty_link(node_id)
|
|
48
|
-
return unless @arena.
|
|
48
|
+
return unless @arena.link_destination(node_id).to_s.empty?
|
|
49
49
|
|
|
50
50
|
push(:warning, :empty_link,
|
|
51
51
|
"Link has no destination",
|
data/lib/red_quilt/list.rb
CHANGED
|
@@ -146,15 +146,19 @@ module RedQuilt
|
|
|
146
146
|
|
|
147
147
|
def parse(parent_id, lines, index)
|
|
148
148
|
first_match = List.match(lines[index].content)
|
|
149
|
+
# Leading indent is excluded, so ` - item` starts at the bullet.
|
|
150
|
+
list_start_byte = lines[index].start_byte + Indentation.leading_ws_bytes(lines[index].content)
|
|
149
151
|
list_id = @arena.add_node(NodeType::LIST,
|
|
150
|
-
source_start:
|
|
152
|
+
source_start: list_start_byte,
|
|
151
153
|
source_len: 0,
|
|
152
154
|
int1: first_match[:ordered] ? 1 : 0,
|
|
153
155
|
int2: first_match[:start_number],
|
|
154
156
|
int3: 1,
|
|
155
157
|
str1: first_match[:marker])
|
|
156
158
|
@arena.append_child(parent_id, list_id)
|
|
157
|
-
|
|
159
|
+
# update_span below rewrites the span once the list's extent is
|
|
160
|
+
# known, so it has to carry the indent-stripped start too.
|
|
161
|
+
start_byte = list_start_byte
|
|
158
162
|
end_byte = lines[index].end_byte
|
|
159
163
|
loose = false
|
|
160
164
|
|
|
@@ -167,11 +171,16 @@ module RedQuilt
|
|
|
167
171
|
break unless match
|
|
168
172
|
break unless List.same_group?(first_match, match)
|
|
169
173
|
|
|
174
|
+
# collect_item strips the bullet, so item_lines start at the item's
|
|
175
|
+
# content. The span starts at the marker instead — an item is
|
|
176
|
+
# authored as "- text", not "text".
|
|
177
|
+
marker_line = lines[index]
|
|
178
|
+
item_start = marker_line.start_byte + Indentation.leading_ws_bytes(marker_line.content)
|
|
170
179
|
item_lines, index = collect_item(lines, index, match)
|
|
171
180
|
end_byte = item_lines.last.end_byte
|
|
172
181
|
item_id = @arena.add_node(NodeType::LIST_ITEM,
|
|
173
|
-
source_start:
|
|
174
|
-
source_len: item_lines.last.end_byte -
|
|
182
|
+
source_start: item_start,
|
|
183
|
+
source_len: item_lines.last.end_byte - item_start)
|
|
175
184
|
@arena.append_child(list_id, item_id)
|
|
176
185
|
# CommonMark: an item is loose when "two block-level elements
|
|
177
186
|
# with a blank line between them" appear at its top level.
|