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.
@@ -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.int1(@node_id), text: text }
182
+ { level: @arena.heading_level(@node_id), text: text }
90
183
  when NodeType::LIST
91
184
  {
92
- ordered: @arena.int1(@node_id) == 1,
93
- start_number: @arena.int2(@node_id),
94
- tight: @arena.int3(@node_id) == 1,
95
- delimiter: @arena.str1(@node_id),
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, NodeType::TABLE_CELL
98
- { header: @arena.int1(@node_id) == 1, text: text }
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.str2(@node_id) }
197
+ { text: @arena.text(@node_id), info: @arena.code_block_info(@node_id) }
103
198
  when NodeType::LINK, NodeType::IMAGE
104
- { destination: @arena.str1(@node_id), title: @arena.str2(@node_id), text: text }
199
+ { destination: @arena.link_destination(@node_id), title: @arena.link_title(@node_id), text: text }
105
200
  when NodeType::FOOTNOTE_REFERENCE
106
- { label: @arena.str1(@node_id), number: @arena.int1(@node_id) }
201
+ { label: @arena.footnote_label(@node_id), number: @arena.footnote_number(@node_id) }
107
202
  when NodeType::FOOTNOTE_DEFINITION
108
- { label: @arena.str1(@node_id) }
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.int1(node_id)
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.int1(node_id) == 1
77
+ ordered = @arena.list_ordered?(node_id)
66
78
  tag = ordered ? "ol" : "ul"
67
- start_number = @arena.int2(node_id)
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.str2(node_id).to_s.split.first.to_s
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.str1(node_id).to_s)
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.str1(node_id).to_s)
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.int1(node_id)
145
- occurrence = @arena.int2(node_id)
146
- ref_id = occurrence > 1 ? "fnref-#{number}-#{occurrence}" : "fnref-#{number}"
147
- @out << %(<sup><a href="#fn-#{number}" id="#{ref_id}">#{number}</a></sup>)
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
- label = @arena.str1(def_id).to_s
158
- number = @document.footnotes.number(label)
159
- occurrences = @document.footnotes.occurrences(label)
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 = occ > 1 ? "fnref-#{number}-#{occ}" : "fnref-#{number}"
199
+ ref_id = FootnoteAnchors.reference_id(number, occ)
188
200
  suffix = occ > 1 ? "<sup>#{occ}</sup>" : ""
189
201
  out << %( <a href="##{ref_id}">&#8617;#{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.int1(row_id) == 1 }
197
- body_rows = rows.reject { |row_id| @arena.int1(row_id) == 1 }
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.int3(parent_id) == 1
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.int1(cell_id) == 1 ? "th" : "td"
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.str2(node_id).to_s
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.int1(node_id)
60
+ result["depth"] = @arena.heading_level(node_id)
61
61
  result["children"] = children(node_id)
62
62
  when NodeType::LIST
63
- result["ordered"] = @arena.int1(node_id) == 1
64
- tight = @arena.int3(node_id) == 1
65
- result["start"] = @arena.int2(node_id) if result["ordered"]
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.str2(node_id)
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.str1(node_id).to_s
85
- title = @arena.str2(node_id)
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.str1(node_id).to_s
90
- title = @arena.str2(node_id)
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.str1(node_id).to_s
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.str1(node_id).to_s
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
- start_loc = @document.source_map.line_column(span.start_byte)
132
- end_loc = @document.source_map.line_column(span.end_byte)
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
@@ -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 = (@line_starts.bsearch_index { |s| s > byte_offset } || @line_starts.length) - 1
12
- line_start = @line_starts[line]
13
- column = @source.byteslice(line_start, byte_offset - line_start).to_s.length
14
- { line: line + 1, column: column }
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 = source.index("\n", pos))
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RedQuilt
4
- VERSION = "0.7.2"
4
+ VERSION = "0.9.0"
5
5
  end
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).to_html(heading_ids: heading_ids, mermaid: mermaid)
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