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
data/lib/red_quilt/node_ref.rb
CHANGED
|
@@ -46,6 +46,83 @@ module RedQuilt
|
|
|
46
46
|
text
|
|
47
47
|
end
|
|
48
48
|
|
|
49
|
+
# Node attributes, by NodeType.
|
|
50
|
+
#
|
|
51
|
+
# Each returns nil when the node does not carry the attribute, so callers
|
|
52
|
+
# can walk the tree branching on #type and read the type's own fields
|
|
53
|
+
# without a Hash allocation per node. The Arena's accessors are the raw
|
|
54
|
+
# layer and deliberately skip that check: several attributes share a
|
|
55
|
+
# storage column, so reading one off a mismatched node yields another
|
|
56
|
+
# field's value rather than nil (e.g. Arena#link_destination on a
|
|
57
|
+
# paragraph returns the paragraph's text). These wrappers are the safe
|
|
58
|
+
# way to read attributes.
|
|
59
|
+
|
|
60
|
+
# CODE_BLOCK: the fence info string, e.g. "ruby" or 'vtt audio="x.mp3"'.
|
|
61
|
+
# A code block written without one has an empty info string, so "" means
|
|
62
|
+
# "no info given" while nil means "not a code block".
|
|
63
|
+
def info
|
|
64
|
+
return nil unless type?(NodeType::CODE_BLOCK)
|
|
65
|
+
|
|
66
|
+
@arena.code_block_info(@node_id).to_s
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# HEADING: nesting level (1..6).
|
|
70
|
+
def heading_level
|
|
71
|
+
@arena.heading_level(@node_id) if type?(NodeType::HEADING)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# LIST: ordered (`1.`) vs bullet (`-`).
|
|
75
|
+
def list_ordered?
|
|
76
|
+
@arena.list_ordered?(@node_id) if type?(NodeType::LIST)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# LIST: start number of an ordered list.
|
|
80
|
+
def list_start
|
|
81
|
+
@arena.list_start(@node_id) if type?(NodeType::LIST)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# LIST: tight (no blank lines between items) vs loose.
|
|
85
|
+
def list_tight?
|
|
86
|
+
@arena.list_tight?(@node_id) if type?(NodeType::LIST)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# LIST: the item delimiter as authored, e.g. "-" or "1.".
|
|
90
|
+
def list_delimiter
|
|
91
|
+
@arena.list_delimiter(@node_id) if type?(NodeType::LIST)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# LINK / IMAGE: the destination URL.
|
|
95
|
+
def link_destination
|
|
96
|
+
@arena.link_destination(@node_id) if link_like?
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# LINK / IMAGE: the optional title, nil when absent.
|
|
100
|
+
def link_title
|
|
101
|
+
@arena.link_title(@node_id) if link_like?
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# FOOTNOTE_DEFINITION / FOOTNOTE_REFERENCE: the label as authored.
|
|
105
|
+
def footnote_label
|
|
106
|
+
@arena.footnote_label(@node_id) if footnote_like?
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# FOOTNOTE_DEFINITION / FOOTNOTE_REFERENCE: the resolved 1-based number.
|
|
110
|
+
def footnote_number
|
|
111
|
+
@arena.footnote_number(@node_id) if footnote_like?
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# TABLE_ROW / TABLE_CELL: whether this belongs to the header row.
|
|
115
|
+
#
|
|
116
|
+
# nil rather than false outside a table, matching the other attribute
|
|
117
|
+
# accessors: a paragraph is not a non-header row, it has no header-ness
|
|
118
|
+
# at all. Both answers are falsy, so `if node.header?` reads the same.
|
|
119
|
+
def header?
|
|
120
|
+
return @arena.table_row_header?(@node_id) if type?(NodeType::TABLE_ROW)
|
|
121
|
+
return @arena.table_cell_header?(@node_id) if type?(NodeType::TABLE_CELL)
|
|
122
|
+
|
|
123
|
+
nil # rubocop:disable Style/ReturnNilInPredicateMethodDefinition
|
|
124
|
+
end
|
|
125
|
+
|
|
49
126
|
def source_span
|
|
50
127
|
@arena.source_span(@node_id)
|
|
51
128
|
end
|
|
@@ -83,29 +160,47 @@ module RedQuilt
|
|
|
83
160
|
|
|
84
161
|
private
|
|
85
162
|
|
|
163
|
+
def type?(node_type)
|
|
164
|
+
@arena.type(@node_id) == node_type
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# Kept allocation-free (no splat) because these accessors are meant for
|
|
168
|
+
# per-node use while walking a whole document.
|
|
169
|
+
def link_like?
|
|
170
|
+
node_type = @arena.type(@node_id)
|
|
171
|
+
node_type == NodeType::LINK || node_type == NodeType::IMAGE
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def footnote_like?
|
|
175
|
+
node_type = @arena.type(@node_id)
|
|
176
|
+
node_type == NodeType::FOOTNOTE_DEFINITION || node_type == NodeType::FOOTNOTE_REFERENCE
|
|
177
|
+
end
|
|
178
|
+
|
|
86
179
|
def ast_attributes
|
|
87
180
|
case @arena.type(@node_id)
|
|
88
181
|
when NodeType::HEADING
|
|
89
|
-
{ level: @arena.
|
|
182
|
+
{ level: @arena.heading_level(@node_id), text: text }
|
|
90
183
|
when NodeType::LIST
|
|
91
184
|
{
|
|
92
|
-
ordered: @arena.
|
|
93
|
-
start_number: @arena.
|
|
94
|
-
tight: @arena.
|
|
95
|
-
delimiter: @arena.
|
|
185
|
+
ordered: @arena.list_ordered?(@node_id),
|
|
186
|
+
start_number: @arena.list_start(@node_id),
|
|
187
|
+
tight: @arena.list_tight?(@node_id),
|
|
188
|
+
delimiter: @arena.list_delimiter(@node_id),
|
|
96
189
|
}
|
|
97
|
-
when NodeType::TABLE_ROW
|
|
98
|
-
{ header: @arena.
|
|
190
|
+
when NodeType::TABLE_ROW
|
|
191
|
+
{ header: @arena.table_row_header?(@node_id), text: text }
|
|
192
|
+
when NodeType::TABLE_CELL
|
|
193
|
+
{ header: @arena.table_cell_header?(@node_id), text: text }
|
|
99
194
|
when NodeType::TEXT, NodeType::CODE_SPAN, NodeType::HTML_BLOCK, NodeType::HTML_INLINE, NodeType::PARAGRAPH
|
|
100
195
|
{ text: text }
|
|
101
196
|
when NodeType::CODE_BLOCK
|
|
102
|
-
{ text: @arena.text(@node_id), info: @arena.
|
|
197
|
+
{ text: @arena.text(@node_id), info: @arena.code_block_info(@node_id) }
|
|
103
198
|
when NodeType::LINK, NodeType::IMAGE
|
|
104
|
-
{ destination: @arena.
|
|
199
|
+
{ destination: @arena.link_destination(@node_id), title: @arena.link_title(@node_id), text: text }
|
|
105
200
|
when NodeType::FOOTNOTE_REFERENCE
|
|
106
|
-
{ label: @arena.
|
|
201
|
+
{ label: @arena.footnote_label(@node_id), number: @arena.footnote_number(@node_id) }
|
|
107
202
|
when NodeType::FOOTNOTE_DEFINITION
|
|
108
|
-
{ label: @arena.
|
|
203
|
+
{ label: @arena.footnote_label(@node_id) }
|
|
109
204
|
else
|
|
110
205
|
{}
|
|
111
206
|
end
|
|
@@ -16,6 +16,18 @@ module RedQuilt
|
|
|
16
16
|
@out
|
|
17
17
|
end
|
|
18
18
|
|
|
19
|
+
# Renders the given nodes (an Array of NodeRef) in order and returns
|
|
20
|
+
# the resulting HTML fragment, without affecting the main render
|
|
21
|
+
# output.
|
|
22
|
+
def render_fragment(nodes)
|
|
23
|
+
saved = @out
|
|
24
|
+
@out = +""
|
|
25
|
+
nodes.each { |node| render_node(node.node_id) }
|
|
26
|
+
@out
|
|
27
|
+
ensure
|
|
28
|
+
@out = saved
|
|
29
|
+
end
|
|
30
|
+
|
|
19
31
|
private
|
|
20
32
|
|
|
21
33
|
# CommonMark-compliant HTML escape: only `&`, `<`, `>`, `"` are
|
|
@@ -46,7 +58,7 @@ module RedQuilt
|
|
|
46
58
|
render_children(node_id)
|
|
47
59
|
@out << "</p>\n"
|
|
48
60
|
when NodeType::HEADING
|
|
49
|
-
level = @arena.
|
|
61
|
+
level = @arena.heading_level(node_id)
|
|
50
62
|
if @slugger
|
|
51
63
|
id = @slugger.generate(PlainText.from(@arena, node_id))
|
|
52
64
|
@out << %(<h#{level} id="#{escape_html(id)}">)
|
|
@@ -62,9 +74,9 @@ module RedQuilt
|
|
|
62
74
|
render_children(node_id)
|
|
63
75
|
@out << "</blockquote>\n"
|
|
64
76
|
when NodeType::LIST
|
|
65
|
-
ordered = @arena.
|
|
77
|
+
ordered = @arena.list_ordered?(node_id)
|
|
66
78
|
tag = ordered ? "ol" : "ul"
|
|
67
|
-
start_number = @arena.
|
|
79
|
+
start_number = @arena.list_start(node_id)
|
|
68
80
|
attrs = ordered && start_number != 1 ? %( start="#{start_number}") : ""
|
|
69
81
|
@out << "<#{tag}#{attrs}>\n"
|
|
70
82
|
render_children(node_id)
|
|
@@ -74,7 +86,7 @@ module RedQuilt
|
|
|
74
86
|
render_list_item(node_id)
|
|
75
87
|
@out << "</li>\n"
|
|
76
88
|
when NodeType::CODE_BLOCK
|
|
77
|
-
info_word = @arena.
|
|
89
|
+
info_word = @arena.code_block_info(node_id).to_s.split.first.to_s
|
|
78
90
|
if @mermaid && info_word == "mermaid"
|
|
79
91
|
# Emit a container mermaid.js recognizes via class="mermaid".
|
|
80
92
|
# The diagram source is still HTML-escaped; the browser decodes
|
|
@@ -116,7 +128,7 @@ module RedQuilt
|
|
|
116
128
|
when NodeType::CODE_SPAN
|
|
117
129
|
@out << "<code>#{escape_html(@arena.text(node_id).to_s)}</code>"
|
|
118
130
|
when NodeType::LINK
|
|
119
|
-
dest = escape_html(@arena.
|
|
131
|
+
dest = escape_html(@arena.link_destination(node_id).to_s)
|
|
120
132
|
@out << %(<a href="#{dest}")
|
|
121
133
|
append_title_attribute(node_id)
|
|
122
134
|
@out << ">"
|
|
@@ -124,7 +136,7 @@ module RedQuilt
|
|
|
124
136
|
@out << "</a>"
|
|
125
137
|
when NodeType::IMAGE
|
|
126
138
|
alt = PlainText.from(@arena, node_id)
|
|
127
|
-
dest = escape_html(@arena.
|
|
139
|
+
dest = escape_html(@arena.link_destination(node_id).to_s)
|
|
128
140
|
@out << %(<img src="#{dest}" alt="#{escape_html(alt)}")
|
|
129
141
|
append_title_attribute(node_id)
|
|
130
142
|
@out << " />"
|
|
@@ -141,10 +153,11 @@ module RedQuilt
|
|
|
141
153
|
# element ids use the footnote number; a second+ reference to the
|
|
142
154
|
# same footnote gets a `-M` suffix so each backref has a unique target.
|
|
143
155
|
def render_footnote_reference(node_id)
|
|
144
|
-
number = @arena.
|
|
145
|
-
occurrence = @arena.
|
|
146
|
-
ref_id =
|
|
147
|
-
|
|
156
|
+
number = @arena.footnote_number(node_id)
|
|
157
|
+
occurrence = @arena.footnote_occurrence(node_id)
|
|
158
|
+
ref_id = FootnoteAnchors.reference_id(number, occurrence)
|
|
159
|
+
def_id = FootnoteAnchors.definition_id(number)
|
|
160
|
+
@out << %(<sup><a href="##{def_id}" id="#{ref_id}">#{number}</a></sup>)
|
|
148
161
|
end
|
|
149
162
|
|
|
150
163
|
def render_footnotes_section(node_id)
|
|
@@ -154,10 +167,9 @@ module RedQuilt
|
|
|
154
167
|
end
|
|
155
168
|
|
|
156
169
|
def render_footnote_definition(def_id)
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
@out << %(<li id="fn-#{number}">\n)
|
|
170
|
+
number = @arena.footnote_number(def_id)
|
|
171
|
+
occurrences = @arena.footnote_total_references(def_id)
|
|
172
|
+
@out << %(<li id="#{FootnoteAnchors.definition_id(number)}">\n)
|
|
161
173
|
|
|
162
174
|
# Append the backref(s) inside the definition's last paragraph (GFM);
|
|
163
175
|
# if the last block isn't a paragraph, emit a standalone one.
|
|
@@ -184,7 +196,7 @@ module RedQuilt
|
|
|
184
196
|
def footnote_backrefs(number, occurrences)
|
|
185
197
|
out = +""
|
|
186
198
|
(1..occurrences).each do |occ|
|
|
187
|
-
ref_id =
|
|
199
|
+
ref_id = FootnoteAnchors.reference_id(number, occ)
|
|
188
200
|
suffix = occ > 1 ? "<sup>#{occ}</sup>" : ""
|
|
189
201
|
out << %( <a href="##{ref_id}">↩#{suffix}</a>)
|
|
190
202
|
end
|
|
@@ -193,8 +205,8 @@ module RedQuilt
|
|
|
193
205
|
|
|
194
206
|
def render_table(table_id)
|
|
195
207
|
rows = @arena.child_ids(table_id).to_a
|
|
196
|
-
header_rows = rows.select { |row_id| @arena.
|
|
197
|
-
body_rows = rows.reject { |row_id| @arena.
|
|
208
|
+
header_rows = rows.select { |row_id| @arena.table_row_header?(row_id) }
|
|
209
|
+
body_rows = rows.reject { |row_id| @arena.table_row_header?(row_id) }
|
|
198
210
|
|
|
199
211
|
unless header_rows.empty?
|
|
200
212
|
@out << "<thead>\n"
|
|
@@ -210,7 +222,7 @@ module RedQuilt
|
|
|
210
222
|
|
|
211
223
|
def render_list_item(node_id)
|
|
212
224
|
parent_id = @arena.raw_parent_id(node_id)
|
|
213
|
-
tight = parent_id != -1 && @arena.type(parent_id) == NodeType::LIST && @arena.
|
|
225
|
+
tight = parent_id != -1 && @arena.type(parent_id) == NodeType::LIST && @arena.list_tight?(parent_id)
|
|
214
226
|
|
|
215
227
|
first_child_id = @arena.raw_first_child_id(node_id)
|
|
216
228
|
first_is_para = first_child_id != -1 &&
|
|
@@ -249,7 +261,7 @@ module RedQuilt
|
|
|
249
261
|
def render_table_row(row_id)
|
|
250
262
|
@out << "<tr>"
|
|
251
263
|
@arena.each_child(row_id) do |cell_id|
|
|
252
|
-
tag = @arena.
|
|
264
|
+
tag = @arena.table_cell_header?(cell_id) ? "th" : "td"
|
|
253
265
|
@out << "<#{tag}>"
|
|
254
266
|
render_children(cell_id)
|
|
255
267
|
@out << "</#{tag}>"
|
|
@@ -285,7 +297,7 @@ module RedQuilt
|
|
|
285
297
|
end
|
|
286
298
|
|
|
287
299
|
def append_title_attribute(node_id)
|
|
288
|
-
title = @arena.
|
|
300
|
+
title = @arena.link_title(node_id).to_s
|
|
289
301
|
return if title.empty?
|
|
290
302
|
|
|
291
303
|
@out << %( title="#{escape_html(title)}")
|
|
@@ -57,19 +57,19 @@ module RedQuilt
|
|
|
57
57
|
|
|
58
58
|
case type_int
|
|
59
59
|
when NodeType::HEADING
|
|
60
|
-
result["depth"] = @arena.
|
|
60
|
+
result["depth"] = @arena.heading_level(node_id)
|
|
61
61
|
result["children"] = children(node_id)
|
|
62
62
|
when NodeType::LIST
|
|
63
|
-
result["ordered"] = @arena.
|
|
64
|
-
tight = @arena.
|
|
65
|
-
result["start"] = @arena.
|
|
63
|
+
result["ordered"] = @arena.list_ordered?(node_id)
|
|
64
|
+
tight = @arena.list_tight?(node_id)
|
|
65
|
+
result["start"] = @arena.list_start(node_id) if result["ordered"]
|
|
66
66
|
result["spread"] = !tight
|
|
67
67
|
result["children"] = children(node_id, parent_spread: !tight)
|
|
68
68
|
when NodeType::LIST_ITEM
|
|
69
69
|
result["spread"] = parent_spread
|
|
70
70
|
result["children"] = children(node_id)
|
|
71
71
|
when NodeType::CODE_BLOCK
|
|
72
|
-
info = @arena.
|
|
72
|
+
info = @arena.code_block_info(node_id)
|
|
73
73
|
lang = info && !info.empty? ? info.split.first : nil
|
|
74
74
|
result["lang"] = lang
|
|
75
75
|
result["value"] = @arena.text(node_id).to_s
|
|
@@ -81,21 +81,21 @@ module RedQuilt
|
|
|
81
81
|
when NodeType::CODE_SPAN
|
|
82
82
|
result["value"] = @arena.text(node_id).to_s
|
|
83
83
|
when NodeType::LINK
|
|
84
|
-
result["url"] = @arena.
|
|
85
|
-
title = @arena.
|
|
84
|
+
result["url"] = @arena.link_destination(node_id).to_s
|
|
85
|
+
title = @arena.link_title(node_id)
|
|
86
86
|
result["title"] = title && !title.empty? ? title : nil
|
|
87
87
|
result["children"] = children(node_id)
|
|
88
88
|
when NodeType::IMAGE
|
|
89
|
-
result["url"] = @arena.
|
|
90
|
-
title = @arena.
|
|
89
|
+
result["url"] = @arena.link_destination(node_id).to_s
|
|
90
|
+
title = @arena.link_title(node_id)
|
|
91
91
|
result["title"] = title && !title.empty? ? title : nil
|
|
92
92
|
result["alt"] = NodeRef.new(@document, node_id).text.to_s
|
|
93
93
|
when NodeType::FOOTNOTE_REFERENCE
|
|
94
|
-
label = @arena.
|
|
94
|
+
label = @arena.footnote_label(node_id).to_s
|
|
95
95
|
result["identifier"] = label
|
|
96
96
|
result["label"] = label
|
|
97
97
|
when NodeType::FOOTNOTE_DEFINITION
|
|
98
|
-
label = @arena.
|
|
98
|
+
label = @arena.footnote_label(node_id).to_s
|
|
99
99
|
result["identifier"] = label
|
|
100
100
|
result["label"] = label
|
|
101
101
|
result["children"] = children(node_id)
|
|
@@ -127,19 +127,23 @@ module RedQuilt
|
|
|
127
127
|
result
|
|
128
128
|
end
|
|
129
129
|
|
|
130
|
+
# unist Point: line and column are 1-based, offset is a 0-based
|
|
131
|
+
# *character* index. SourceSpan carries bytes, so offset is converted
|
|
132
|
+
# rather than passed through — the two diverge on multibyte sources.
|
|
130
133
|
def position(span)
|
|
131
|
-
|
|
132
|
-
|
|
134
|
+
source_map = @document.source_map
|
|
135
|
+
start_loc = source_map.line_column(span.start_byte)
|
|
136
|
+
end_loc = source_map.line_column(span.end_byte)
|
|
133
137
|
{
|
|
134
138
|
"start" => {
|
|
135
139
|
"line" => start_loc[:line],
|
|
136
140
|
"column" => start_loc[:column],
|
|
137
|
-
"offset" => span.start_byte,
|
|
141
|
+
"offset" => source_map.char_offset(span.start_byte),
|
|
138
142
|
},
|
|
139
143
|
"end" => {
|
|
140
144
|
"line" => end_loc[:line],
|
|
141
145
|
"column" => end_loc[:column],
|
|
142
|
-
"offset" => span.end_byte,
|
|
146
|
+
"offset" => source_map.char_offset(span.end_byte),
|
|
143
147
|
},
|
|
144
148
|
}
|
|
145
149
|
end
|
data/lib/red_quilt/source_map.rb
CHANGED
|
@@ -1,29 +1,69 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module RedQuilt
|
|
4
|
+
# Translates byte offsets from SourceSpan into human-facing positions.
|
|
5
|
+
#
|
|
6
|
+
# Positions follow the unist Point convention, which cmark sourcepos and
|
|
7
|
+
# mdast both use: `line` and `column` are 1-based, while character offsets
|
|
8
|
+
# are 0-based. Columns and offsets are counted in characters, not bytes, so
|
|
9
|
+
# a multibyte source reports the position a reader would count.
|
|
4
10
|
class SourceMap
|
|
5
11
|
def initialize(source)
|
|
6
12
|
@source = source
|
|
7
13
|
@line_starts = build_line_starts(source)
|
|
14
|
+
@line_char_starts = build_line_char_starts(source, @line_starts)
|
|
8
15
|
end
|
|
9
16
|
|
|
17
|
+
# Returns { line:, column: }, both 1-based.
|
|
10
18
|
def line_column(byte_offset)
|
|
11
|
-
line = (
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
19
|
+
line = line_index(byte_offset)
|
|
20
|
+
{ line: line + 1, column: chars_from_line_start(line, byte_offset) + 1 }
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Returns the 0-based character offset, as unist Point#offset requires.
|
|
24
|
+
# Byte offsets are what SourceSpan carries, and the two differ as soon as
|
|
25
|
+
# the source contains a multibyte character.
|
|
26
|
+
def char_offset(byte_offset)
|
|
27
|
+
line = line_index(byte_offset)
|
|
28
|
+
@line_char_starts[line] + chars_from_line_start(line, byte_offset)
|
|
15
29
|
end
|
|
16
30
|
|
|
17
31
|
private
|
|
18
32
|
|
|
33
|
+
def line_index(byte_offset)
|
|
34
|
+
(@line_starts.bsearch_index { |s| s > byte_offset } || @line_starts.length) - 1
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def chars_from_line_start(line, byte_offset)
|
|
38
|
+
line_start = @line_starts[line]
|
|
39
|
+
@source.byteslice(line_start, byte_offset - line_start).to_s.length
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Line starts are byte offsets, matching SourceSpan#start_byte / #end_byte
|
|
43
|
+
# and the argument of #line_column. Scanning the binary view is what makes
|
|
44
|
+
# them bytes: index on a UTF-8 string counts characters, so a multibyte
|
|
45
|
+
# source would yield offsets that line_column then misreads as bytes.
|
|
19
46
|
def build_line_starts(source)
|
|
20
47
|
starts = [0]
|
|
48
|
+
source_b = source.b
|
|
21
49
|
pos = 0
|
|
22
|
-
while (idx =
|
|
50
|
+
while (idx = source_b.index("\n", pos))
|
|
23
51
|
starts << (idx + 1)
|
|
24
52
|
pos = idx + 1
|
|
25
53
|
end
|
|
26
54
|
starts
|
|
27
55
|
end
|
|
56
|
+
|
|
57
|
+
# Character offset of each line start, so char_offset stays O(1) per call
|
|
58
|
+
# instead of recounting the source prefix every time.
|
|
59
|
+
def build_line_char_starts(source, line_starts)
|
|
60
|
+
chars = 0
|
|
61
|
+
prev_byte = 0
|
|
62
|
+
line_starts.map do |byte_start|
|
|
63
|
+
chars += source.byteslice(prev_byte, byte_start - prev_byte).to_s.length
|
|
64
|
+
prev_byte = byte_start
|
|
65
|
+
chars
|
|
66
|
+
end
|
|
67
|
+
end
|
|
28
68
|
end
|
|
29
69
|
end
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RedQuilt
|
|
4
|
+
# GFM table detection (spec 4.10). Pure functions over line text: whether
|
|
5
|
+
# a line could be a table row and whether a header+delimiter pair starts a
|
|
6
|
+
# table. Cell splitting lives here too so the recognition rules and the
|
|
7
|
+
# splitting rules they depend on stay together. Node construction stays in
|
|
8
|
+
# BlockParser.
|
|
9
|
+
module Table
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
# True when lines[index] / lines[index+1] form a header + delimiter pair
|
|
13
|
+
# that starts a GFM table.
|
|
14
|
+
def start?(lines, index)
|
|
15
|
+
return false if index + 1 >= lines.length
|
|
16
|
+
return false unless row?(lines[index].content)
|
|
17
|
+
|
|
18
|
+
header_cells = split_row(lines[index].content)
|
|
19
|
+
separators = split_row(lines[index + 1].content)
|
|
20
|
+
return false if separators.empty?
|
|
21
|
+
|
|
22
|
+
# GFM spec: separator row must have valid delimiters AND match header column count.
|
|
23
|
+
# "The header row must match the delimiter row in the number of cells.
|
|
24
|
+
# If not, a table will not be recognized."
|
|
25
|
+
return false unless header_cells.length == separators.length
|
|
26
|
+
|
|
27
|
+
separators.all? { |cell| cell.strip.match?(/\A:?-+:?\z/) }
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def row?(text)
|
|
31
|
+
text.include?("|")
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def split_row(text)
|
|
35
|
+
body = text.strip
|
|
36
|
+
body = body[1..] if body.start_with?("|")
|
|
37
|
+
body = body[0...-1] if body.end_with?("|")
|
|
38
|
+
body.split("|", -1)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Cached collaborator for BlockParser. A single instance is created in
|
|
42
|
+
# BlockParser#initialize and reused; per-call state lives in method
|
|
43
|
+
# locals so reentrant calls are safe.
|
|
44
|
+
class Parser
|
|
45
|
+
def initialize(block_parser)
|
|
46
|
+
@arena = block_parser.arena
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Parses the table starting at lines[index] (already confirmed by
|
|
50
|
+
# Table.start?). Returns the index past the table.
|
|
51
|
+
def parse(parent_id, lines, index)
|
|
52
|
+
start_index = index
|
|
53
|
+
header_cells = Table.split_row(lines[index].content)
|
|
54
|
+
row_lines = [lines[index]]
|
|
55
|
+
index += 2
|
|
56
|
+
while index < lines.length
|
|
57
|
+
break if lines[index].blank
|
|
58
|
+
break unless Table.row?(lines[index].content)
|
|
59
|
+
|
|
60
|
+
row_lines << lines[index]
|
|
61
|
+
index += 1
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
table_id = @arena.add_node(NodeType::TABLE,
|
|
65
|
+
source_start: lines[start_index].start_byte,
|
|
66
|
+
source_len: row_lines.last.end_byte - lines[start_index].start_byte)
|
|
67
|
+
@arena.append_child(parent_id, table_id)
|
|
68
|
+
|
|
69
|
+
append_row(table_id, lines[start_index], header_cells, true)
|
|
70
|
+
row_lines.drop(1).each do |row_line|
|
|
71
|
+
append_row(table_id, row_line, Table.split_row(row_line.content), false)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
index
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
private
|
|
78
|
+
|
|
79
|
+
def append_row(table_id, line, cells, header)
|
|
80
|
+
row_id = @arena.add_node(NodeType::TABLE_ROW,
|
|
81
|
+
source_start: line.start_byte,
|
|
82
|
+
source_len: line.span_len,
|
|
83
|
+
int1: header ? 1 : 0)
|
|
84
|
+
@arena.append_child(table_id, row_id)
|
|
85
|
+
cells.each do |cell_text|
|
|
86
|
+
stripped = cell_text.strip
|
|
87
|
+
cell_id = @arena.add_node(NodeType::TABLE_CELL,
|
|
88
|
+
source_start: line.start_byte,
|
|
89
|
+
source_len: line.span_len,
|
|
90
|
+
int1: header ? 1 : 0,
|
|
91
|
+
str1: stripped)
|
|
92
|
+
@arena.append_child(row_id, cell_id)
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
data/lib/red_quilt/version.rb
CHANGED
data/lib/red_quilt.rb
CHANGED
|
@@ -15,17 +15,24 @@ require_relative "red_quilt/theme"
|
|
|
15
15
|
require_relative "red_quilt/document"
|
|
16
16
|
require_relative "red_quilt/inline"
|
|
17
17
|
require_relative "red_quilt/inline/html_entities"
|
|
18
|
+
require_relative "red_quilt/frontmatter"
|
|
18
19
|
require_relative "red_quilt/reference_definition"
|
|
19
20
|
require_relative "red_quilt/footnote_registry"
|
|
21
|
+
require_relative "red_quilt/footnote_anchors"
|
|
20
22
|
require_relative "red_quilt/footnote_definition"
|
|
21
23
|
require_relative "red_quilt/list"
|
|
22
24
|
require_relative "red_quilt/blockquote"
|
|
25
|
+
require_relative "red_quilt/html_block"
|
|
26
|
+
require_relative "red_quilt/table"
|
|
27
|
+
require_relative "red_quilt/code_block"
|
|
23
28
|
require_relative "red_quilt/block_parser"
|
|
24
29
|
require_relative "red_quilt/inline/token_kind"
|
|
25
30
|
require_relative "red_quilt/inline/tokens"
|
|
26
31
|
require_relative "red_quilt/inline/flanking"
|
|
27
32
|
require_relative "red_quilt/inline/lexer"
|
|
28
33
|
require_relative "red_quilt/inline/link_scanner"
|
|
34
|
+
require_relative "red_quilt/inline/url_sanitizer"
|
|
35
|
+
require_relative "red_quilt/inline/emphasis_resolver"
|
|
29
36
|
require_relative "red_quilt/inline/builder"
|
|
30
37
|
require_relative "red_quilt/inline_pass"
|
|
31
38
|
require_relative "red_quilt/footnote_pass"
|
|
@@ -38,8 +45,13 @@ module RedQuilt
|
|
|
38
45
|
class Error < StandardError; end
|
|
39
46
|
|
|
40
47
|
class << self
|
|
41
|
-
def parse(source, allow_html: false, disallow_raw_html: false, extended_autolinks: false, footnotes: false, lint: false)
|
|
48
|
+
def parse(source, allow_html: false, disallow_raw_html: false, extended_autolinks: false, footnotes: false, lint: false, frontmatter: false)
|
|
42
49
|
normalized = normalize_input(source)
|
|
50
|
+
frontmatter_diagnostics = []
|
|
51
|
+
if frontmatter
|
|
52
|
+
frontmatter_data, normalized =
|
|
53
|
+
Frontmatter.extract(normalized, diagnostics: frontmatter_diagnostics)
|
|
54
|
+
end
|
|
43
55
|
arena = Arena.new(normalized)
|
|
44
56
|
footnote_registry = FootnoteRegistry.new if footnotes
|
|
45
57
|
block_parser = BlockParser.new(arena, footnotes: footnote_registry)
|
|
@@ -48,7 +60,9 @@ module RedQuilt
|
|
|
48
60
|
allow_html: allow_html,
|
|
49
61
|
disallow_raw_html: disallow_raw_html,
|
|
50
62
|
references: block_parser.references,
|
|
51
|
-
footnotes: footnote_registry
|
|
63
|
+
footnotes: footnote_registry,
|
|
64
|
+
frontmatter: frontmatter_data)
|
|
65
|
+
document.diagnostics.concat(frontmatter_diagnostics)
|
|
52
66
|
document.diagnostics.concat(block_parser.diagnostics)
|
|
53
67
|
InlinePass.new(document).apply
|
|
54
68
|
FootnotePass.new(document).apply if footnote_registry
|
|
@@ -57,13 +71,14 @@ module RedQuilt
|
|
|
57
71
|
document
|
|
58
72
|
end
|
|
59
73
|
|
|
60
|
-
def render_html(source, allow_html: false, disallow_raw_html: false, extended_autolinks: false, footnotes: false, lint: false, heading_ids: false, mermaid: false)
|
|
74
|
+
def render_html(source, allow_html: false, disallow_raw_html: false, extended_autolinks: false, footnotes: false, lint: false, frontmatter: false, heading_ids: false, mermaid: false)
|
|
61
75
|
parse(source,
|
|
62
76
|
allow_html: allow_html,
|
|
63
77
|
disallow_raw_html: disallow_raw_html,
|
|
64
78
|
extended_autolinks: extended_autolinks,
|
|
65
79
|
footnotes: footnotes,
|
|
66
|
-
lint: lint
|
|
80
|
+
lint: lint,
|
|
81
|
+
frontmatter: frontmatter).to_html(heading_ids: heading_ids, mermaid: mermaid)
|
|
67
82
|
end
|
|
68
83
|
|
|
69
84
|
private
|
data/sig/red_quilt.rbs
CHANGED
|
@@ -65,6 +65,19 @@ module RedQuilt
|
|
|
65
65
|
def find_all: (Symbol type) -> Array[NodeRef]
|
|
66
66
|
def source_location: () -> { start_line: Integer, start_column: Integer, end_line: Integer, end_column: Integer }?
|
|
67
67
|
def to_h: () -> Hash[Symbol, untyped]
|
|
68
|
+
|
|
69
|
+
# Node attributes: nil when the node's type does not carry the attribute.
|
|
70
|
+
def info: () -> String?
|
|
71
|
+
def heading_level: () -> Integer?
|
|
72
|
+
def list_ordered?: () -> bool?
|
|
73
|
+
def list_start: () -> Integer?
|
|
74
|
+
def list_tight?: () -> bool?
|
|
75
|
+
def list_delimiter: () -> String?
|
|
76
|
+
def link_destination: () -> String?
|
|
77
|
+
def link_title: () -> String?
|
|
78
|
+
def footnote_label: () -> String?
|
|
79
|
+
def footnote_number: () -> Integer?
|
|
80
|
+
def header?: () -> bool?
|
|
68
81
|
end
|
|
69
82
|
|
|
70
83
|
# Low-level parallel-array storage for the AST. See docs/arena-usage.ja.md.
|
|
@@ -115,6 +128,25 @@ module RedQuilt
|
|
|
115
128
|
def source_span: (Integer id) -> SourceSpan?
|
|
116
129
|
def text: (Integer id) -> String?
|
|
117
130
|
|
|
131
|
+
# Semantic payload accessors (per-NodeType column conventions)
|
|
132
|
+
def heading_level: (Integer id) -> Integer
|
|
133
|
+
def heading_content_start: (Integer id) -> Integer
|
|
134
|
+
def heading_content_len: (Integer id) -> Integer
|
|
135
|
+
def list_ordered?: (Integer id) -> bool
|
|
136
|
+
def list_start: (Integer id) -> Integer
|
|
137
|
+
def list_tight?: (Integer id) -> bool
|
|
138
|
+
def list_delimiter: (Integer id) -> String?
|
|
139
|
+
def table_row_header?: (Integer id) -> bool
|
|
140
|
+
def table_cell_header?: (Integer id) -> bool
|
|
141
|
+
def code_block_info: (Integer id) -> String?
|
|
142
|
+
def link_destination: (Integer id) -> String?
|
|
143
|
+
def link_title: (Integer id) -> String?
|
|
144
|
+
def footnote_number: (Integer id) -> Integer
|
|
145
|
+
def footnote_occurrence: (Integer id) -> Integer
|
|
146
|
+
def footnote_label: (Integer id) -> String?
|
|
147
|
+
def footnote_total_references: (Integer id) -> Integer
|
|
148
|
+
def resolve_footnote_definition: (Integer id, Integer number, Integer total_references) -> void
|
|
149
|
+
|
|
118
150
|
# Traversal
|
|
119
151
|
def each_child: (Integer id) { (Integer child_id) -> void } -> self
|
|
120
152
|
def child_ids: (Integer id) -> Enumerator[Integer, void]
|
|
@@ -132,9 +164,12 @@ module RedQuilt
|
|
|
132
164
|
def to_h: () -> { start_byte: Integer, end_byte: Integer }
|
|
133
165
|
end
|
|
134
166
|
|
|
167
|
+
# Positions follow the unist Point convention: line and column are 1-based
|
|
168
|
+
# and counted in characters, while char_offset is 0-based.
|
|
135
169
|
class SourceMap
|
|
136
170
|
def initialize: (String source) -> void
|
|
137
171
|
def line_column: (Integer byte_offset) -> { line: Integer, column: Integer }
|
|
172
|
+
def char_offset: (Integer byte_offset) -> Integer
|
|
138
173
|
end
|
|
139
174
|
|
|
140
175
|
class Diagnostic
|