red_quilt 0.6.1
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 +7 -0
- data/.rspec +3 -0
- data/.rubocop.yml +109 -0
- data/.rubocop_todo.yml +7 -0
- data/CHANGELOG.md +57 -0
- data/README.md +284 -0
- data/Rakefile +8 -0
- data/ast-spec.md +1227 -0
- data/docs/architecture.md +81 -0
- data/docs/arena-usage.md +363 -0
- data/docs/commonmark-conformance.md +241 -0
- data/exe/redquilt +7 -0
- data/lib/red_quilt/arena.rb +366 -0
- data/lib/red_quilt/block_parser.rb +724 -0
- data/lib/red_quilt/blockquote.rb +151 -0
- data/lib/red_quilt/cli.rb +182 -0
- data/lib/red_quilt/diagnostic.rb +47 -0
- data/lib/red_quilt/document.rb +126 -0
- data/lib/red_quilt/extended_autolink_pass.rb +185 -0
- data/lib/red_quilt/footnote_definition.rb +147 -0
- data/lib/red_quilt/footnote_pass.rb +39 -0
- data/lib/red_quilt/footnote_registry.rb +68 -0
- data/lib/red_quilt/indentation.rb +73 -0
- data/lib/red_quilt/inline/builder.rb +674 -0
- data/lib/red_quilt/inline/flanking.rb +120 -0
- data/lib/red_quilt/inline/html_entities.rb +2180 -0
- data/lib/red_quilt/inline/lexer.rb +280 -0
- data/lib/red_quilt/inline/link_scanner.rb +315 -0
- data/lib/red_quilt/inline/token_kind.rb +39 -0
- data/lib/red_quilt/inline/tokens.rb +73 -0
- data/lib/red_quilt/inline.rb +34 -0
- data/lib/red_quilt/inline_pass.rb +53 -0
- data/lib/red_quilt/line.rb +14 -0
- data/lib/red_quilt/lint_pass.rb +71 -0
- data/lib/red_quilt/list.rb +317 -0
- data/lib/red_quilt/node_ref.rb +114 -0
- data/lib/red_quilt/node_type.rb +66 -0
- data/lib/red_quilt/plain_text.rb +46 -0
- data/lib/red_quilt/reference_definition.rb +309 -0
- data/lib/red_quilt/renderer/html.rb +279 -0
- data/lib/red_quilt/renderer/mdast.rb +152 -0
- data/lib/red_quilt/source_map.rb +29 -0
- data/lib/red_quilt/source_span.rb +26 -0
- data/lib/red_quilt/theme.rb +28 -0
- data/lib/red_quilt/themes/default.css +87 -0
- data/lib/red_quilt/version.rb +5 -0
- data/lib/red_quilt.rb +86 -0
- data/mise.toml +2 -0
- data/sig/red_quilt.rbs +45 -0
- metadata +91 -0
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RedQuilt
|
|
4
|
+
# GitHub-style footnote definitions: `[^label]: content`. `match` handles
|
|
5
|
+
# the opener line; `Parser` (a cached BlockParser collaborator, like
|
|
6
|
+
# List::Parser) collects the optionally-indented, multi-paragraph
|
|
7
|
+
# continuation and parses it into a FOOTNOTE_DEFINITION node. Label
|
|
8
|
+
# normalization is shared with link reference definitions.
|
|
9
|
+
#
|
|
10
|
+
# NOTE: ReferenceDefinition's REF_DEF_RE also matches `[^label]:` (treating
|
|
11
|
+
# `^label` as an ordinary label), so the block dispatch must try this
|
|
12
|
+
# matcher BEFORE the reference-definition branch when footnotes are on.
|
|
13
|
+
module FootnoteDefinition
|
|
14
|
+
# Up to 3 spaces of indent, then `[^label]:`. The label is non-empty and
|
|
15
|
+
# contains no whitespace or `]` (GFM rule).
|
|
16
|
+
RE = /\A {0,3}\[\^([^\]\s]+)\]:(.*)\z/m
|
|
17
|
+
|
|
18
|
+
module_function
|
|
19
|
+
|
|
20
|
+
# Returns { label:, content_start:, content: } for a footnote-definition
|
|
21
|
+
# opener, or nil. `content_start` is the byte offset (within `text`)
|
|
22
|
+
# where the content begins, after `]:` and an optional single separating
|
|
23
|
+
# space/tab; `content` is that text (possibly empty).
|
|
24
|
+
def match(text)
|
|
25
|
+
m = RE.match(text)
|
|
26
|
+
return nil unless m
|
|
27
|
+
|
|
28
|
+
rest = m[2]
|
|
29
|
+
lead = rest.match?(/\A[ \t]/) ? 1 : 0
|
|
30
|
+
{
|
|
31
|
+
label: m[1],
|
|
32
|
+
content_start: text.bytesize - rest.bytesize + lead,
|
|
33
|
+
content: lead.zero? ? rest : rest[lead..],
|
|
34
|
+
}
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Cached collaborator for BlockParser (created once per document and
|
|
38
|
+
# reused for every definition). Footnote definitions are document-global:
|
|
39
|
+
# the parser lazily creates a single FOOTNOTES_SECTION under the root and
|
|
40
|
+
# memoizes it; per-call state otherwise lives in locals so the recursive
|
|
41
|
+
# parse_lines call is safe.
|
|
42
|
+
class Parser
|
|
43
|
+
# GFM footnote continuation indent (columns). Lines indented at least
|
|
44
|
+
# this much (plus blank lines between them) belong to the definition.
|
|
45
|
+
CONTENT_INDENT = 4
|
|
46
|
+
|
|
47
|
+
def initialize(block_parser)
|
|
48
|
+
@block_parser = block_parser
|
|
49
|
+
@arena = block_parser.arena
|
|
50
|
+
@section_id = nil
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Consumes the definition opening at `lines[index]` (its `match`
|
|
54
|
+
# already parsed), registers it in `registry`, and returns the next
|
|
55
|
+
# unconsumed line index.
|
|
56
|
+
def parse(lines, index, match, registry, root_id)
|
|
57
|
+
first = lines[index]
|
|
58
|
+
content_lines = [content_line(match[:content], first.start_byte + match[:content_start], first.end_byte)]
|
|
59
|
+
consumed_index = collect_continuation(lines, index + 1, content_lines)
|
|
60
|
+
|
|
61
|
+
label = ReferenceDefinition.normalize_label(match[:label])
|
|
62
|
+
span = SourceSpan.new(first.start_byte, lines[consumed_index - 1].end_byte)
|
|
63
|
+
if registry.defined?(label)
|
|
64
|
+
@block_parser.diagnostics << Diagnostic.new(
|
|
65
|
+
severity: :warning, rule: :duplicate_footnote,
|
|
66
|
+
message: "Duplicate footnote definition #{label.inspect} — keeping the first",
|
|
67
|
+
source_span: span,
|
|
68
|
+
)
|
|
69
|
+
return consumed_index
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def_id = @arena.add_node(NodeType::FOOTNOTE_DEFINITION,
|
|
73
|
+
source_start: span.start_byte, source_len: span.length,
|
|
74
|
+
str1: label)
|
|
75
|
+
@arena.append_child(section_id(root_id), def_id)
|
|
76
|
+
registry.define(label, def_id)
|
|
77
|
+
@block_parser.parse_lines(def_id, content_lines, transformed: true)
|
|
78
|
+
consumed_index
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Make the footnotes section root's last child so it renders last and
|
|
82
|
+
# the inline pass numbers body references before any nested references
|
|
83
|
+
# inside the definitions. No-op when no definition was found.
|
|
84
|
+
def move_section_to_end(root_id)
|
|
85
|
+
return if @section_id.nil?
|
|
86
|
+
|
|
87
|
+
@arena.detach(@section_id)
|
|
88
|
+
@arena.append_child(root_id, @section_id)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
private
|
|
92
|
+
|
|
93
|
+
def content_line(content, start_byte, end_byte)
|
|
94
|
+
Line.new(content, start_byte, end_byte, !content.match?(/[^ \t]/))
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Appends continuation lines to `content_lines` and returns the first
|
|
98
|
+
# line index NOT consumed (trailing blank lines are left for the
|
|
99
|
+
# surrounding flow). A line continues the definition when it is blank,
|
|
100
|
+
# indented >= CONTENT_INDENT columns (a fresh/continued paragraph), or
|
|
101
|
+
# — GFM treats footnote definitions like list items — a lazy
|
|
102
|
+
# continuation: an unindented non-blank line directly following open
|
|
103
|
+
# paragraph content that doesn't itself start a block or a new footnote
|
|
104
|
+
# definition.
|
|
105
|
+
def collect_continuation(lines, index, content_lines)
|
|
106
|
+
pending_blanks = []
|
|
107
|
+
while index < lines.length
|
|
108
|
+
current = lines[index]
|
|
109
|
+
if current.blank
|
|
110
|
+
pending_blanks << current
|
|
111
|
+
index += 1
|
|
112
|
+
next
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
if Indentation.leading_columns(current.content) >= CONTENT_INDENT
|
|
116
|
+
pending_blanks.each { |b| content_lines << Line.new("", b.start_byte, b.end_byte, true) }
|
|
117
|
+
pending_blanks = []
|
|
118
|
+
stripped = Indentation.strip_columns(current.content, CONTENT_INDENT)
|
|
119
|
+
advance = [Indentation.leading_ws_bytes(current.content), current.content.bytesize - stripped.bytesize].min
|
|
120
|
+
advance = 0 if advance.negative?
|
|
121
|
+
content_lines << Line.new(stripped, current.start_byte + advance, current.end_byte, false)
|
|
122
|
+
index += 1
|
|
123
|
+
next
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
break unless pending_blanks.empty?
|
|
127
|
+
break if content_lines.last.nil? || content_lines.last.blank
|
|
128
|
+
break if @block_parser.lazy_break?(lines, index) || FootnoteDefinition.match(current.content)
|
|
129
|
+
|
|
130
|
+
stripped = current.content.sub(/\A[ \t]+/, "")
|
|
131
|
+
strip_len = current.content.length - stripped.length
|
|
132
|
+
content_lines << Line.new(stripped, current.start_byte + strip_len, current.end_byte, false, true)
|
|
133
|
+
index += 1
|
|
134
|
+
end
|
|
135
|
+
index - pending_blanks.length
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def section_id(root_id)
|
|
139
|
+
@section_id ||= begin
|
|
140
|
+
id = @arena.add_node(NodeType::FOOTNOTES_SECTION, source_start: -1, source_len: 0)
|
|
141
|
+
@arena.append_child(root_id, id)
|
|
142
|
+
id
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RedQuilt
|
|
4
|
+
# Post-inline pass for the footnotes extension. After the inline pass has
|
|
5
|
+
# resolved `[^label]` references (assigning numbers in first-reference
|
|
6
|
+
# order on the shared FootnoteRegistry), this reorders the definition
|
|
7
|
+
# nodes under the document-level footnotes section into that order, drops
|
|
8
|
+
# definitions that were never referenced, and removes the whole section
|
|
9
|
+
# when nothing referenced it.
|
|
10
|
+
class FootnotePass
|
|
11
|
+
def initialize(document)
|
|
12
|
+
@document = document
|
|
13
|
+
@arena = document.arena
|
|
14
|
+
@registry = document.footnotes
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def apply
|
|
18
|
+
return if @registry.nil?
|
|
19
|
+
|
|
20
|
+
# BlockParser moves the footnotes section to be root's last child, so
|
|
21
|
+
# that's where it is (if any definitions were collected at all).
|
|
22
|
+
section_id = @arena.raw_last_child_id(@document.root_id)
|
|
23
|
+
return if section_id == -1 || @arena.type(section_id) != NodeType::FOOTNOTES_SECTION
|
|
24
|
+
|
|
25
|
+
unless @registry.any_referenced?
|
|
26
|
+
@arena.detach(section_id)
|
|
27
|
+
return
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Re-append referenced definitions in first-reference order; detaching
|
|
31
|
+
# all current children first means unreferenced definitions are left
|
|
32
|
+
# orphaned (and so never rendered).
|
|
33
|
+
@arena.child_ids(section_id).to_a.each { |child| @arena.detach(child) }
|
|
34
|
+
@registry.referenced_labels.each do |label|
|
|
35
|
+
@arena.append_child(section_id, @registry.definition_node(label))
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RedQuilt
|
|
4
|
+
# Shared state for the footnotes extension, created once per parse when
|
|
5
|
+
# `footnotes: true` and threaded (by reference) through the block parser,
|
|
6
|
+
# the inline builders, the FootnotePass, and the renderer. A single shared
|
|
7
|
+
# object is required because the inline pass builds a fresh Builder per
|
|
8
|
+
# materialized target, so the first-reference numbering counter cannot live
|
|
9
|
+
# on a Builder instance.
|
|
10
|
+
#
|
|
11
|
+
# `nil` is used in place of a registry when footnotes are disabled, so the
|
|
12
|
+
# collectors/resolvers can cheaply opt out.
|
|
13
|
+
class FootnoteRegistry
|
|
14
|
+
def initialize
|
|
15
|
+
@definitions = {} # normalized label => FOOTNOTE_DEFINITION node id
|
|
16
|
+
@numbers = {} # normalized label => footnote number
|
|
17
|
+
@occurrences = Hash.new(0) # normalized label => reference count
|
|
18
|
+
@order = [] # normalized labels in first-reference order
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Records a definition node for a label during block parsing. Returns
|
|
22
|
+
# false when the label is already defined (duplicate), true otherwise.
|
|
23
|
+
def define(label, node_id)
|
|
24
|
+
return false if @definitions.key?(label)
|
|
25
|
+
|
|
26
|
+
@definitions[label] = node_id
|
|
27
|
+
true
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def defined?(label)
|
|
31
|
+
@definitions.key?(label)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def definition_node(label)
|
|
35
|
+
@definitions[label]
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Records an inline reference to a label. Returns [number, occurrence]
|
|
39
|
+
# (assigning the number on first reference, in encounter order), or nil
|
|
40
|
+
# when the label has no definition.
|
|
41
|
+
def reference(label)
|
|
42
|
+
return nil unless @definitions.key?(label)
|
|
43
|
+
|
|
44
|
+
unless @numbers.key?(label)
|
|
45
|
+
@order << label
|
|
46
|
+
@numbers[label] = @order.length
|
|
47
|
+
end
|
|
48
|
+
[@numbers[label], @occurrences[label] += 1]
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def number(label)
|
|
52
|
+
@numbers[label]
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def occurrences(label)
|
|
56
|
+
@occurrences[label]
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Referenced labels in first-reference order (the render order).
|
|
60
|
+
def referenced_labels
|
|
61
|
+
@order
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def any_referenced?
|
|
65
|
+
!@order.empty?
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RedQuilt
|
|
4
|
+
# Stateless CommonMark indentation / column arithmetic, shared by the
|
|
5
|
+
# block parser and its collaborator parsers (List, Blockquote, Footnote).
|
|
6
|
+
# Tabs expand to the next multiple of 4 columns.
|
|
7
|
+
module Indentation
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
# Leading-whitespace width of `text` in columns (tabs expanded to the
|
|
11
|
+
# next tab stop of 4).
|
|
12
|
+
def leading_columns(text)
|
|
13
|
+
col = 0
|
|
14
|
+
i = 0
|
|
15
|
+
bytes = text.bytesize
|
|
16
|
+
while i < bytes
|
|
17
|
+
b = text.getbyte(i)
|
|
18
|
+
if b == 0x20
|
|
19
|
+
col += 1
|
|
20
|
+
elsif b == 0x09
|
|
21
|
+
col = ((col / 4) + 1) * 4
|
|
22
|
+
else
|
|
23
|
+
break
|
|
24
|
+
end
|
|
25
|
+
i += 1
|
|
26
|
+
end
|
|
27
|
+
col
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Strips up to `n` columns of leading whitespace from `text` and
|
|
31
|
+
# returns the rest. Leading whitespace is normalised to spaces in the
|
|
32
|
+
# returned string so subsequent strips compose correctly regardless of
|
|
33
|
+
# where they land relative to the original tab stops.
|
|
34
|
+
def strip_columns(text, n)
|
|
35
|
+
return text if n <= 0
|
|
36
|
+
|
|
37
|
+
col = 0
|
|
38
|
+
i = 0
|
|
39
|
+
bytes = text.bytesize
|
|
40
|
+
while i < bytes
|
|
41
|
+
b = text.getbyte(i)
|
|
42
|
+
if b == 0x20
|
|
43
|
+
col += 1
|
|
44
|
+
elsif b == 0x09
|
|
45
|
+
col = ((col / 4) + 1) * 4
|
|
46
|
+
else
|
|
47
|
+
break
|
|
48
|
+
end
|
|
49
|
+
i += 1
|
|
50
|
+
end
|
|
51
|
+
# text[0...i] is all leading whitespace representing `col` cols.
|
|
52
|
+
if n >= col
|
|
53
|
+
i.zero? ? text : text.byteslice(i..)
|
|
54
|
+
else
|
|
55
|
+
# Keep the unstripped portion as a run of spaces.
|
|
56
|
+
(" " * (col - n)) + text.byteslice(i..)
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Bytes of literal leading 0x20 / 0x09 in `text`.
|
|
61
|
+
def leading_ws_bytes(text)
|
|
62
|
+
i = 0
|
|
63
|
+
bytes = text.bytesize
|
|
64
|
+
while i < bytes
|
|
65
|
+
b = text.getbyte(i)
|
|
66
|
+
break unless b == 0x20 || b == 0x09
|
|
67
|
+
|
|
68
|
+
i += 1
|
|
69
|
+
end
|
|
70
|
+
i
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|