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.
@@ -0,0 +1,145 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RedQuilt
4
+ # Fenced and indented code blocks (CommonMark 4.4 / 4.5). The module
5
+ # functions detect a code-block start; the nested Parser builds the arena
6
+ # node, mirroring the List / Blockquote split (detection used by the
7
+ # block dispatch, construction by a cached collaborator).
8
+ module CodeBlock
9
+ module_function
10
+
11
+ # Detects a fenced code opener. Returns a Hash describing the fence
12
+ # ({ char:, count:, info:, indent: }) or nil.
13
+ def fenced_start(text)
14
+ match = /\A( {0,3})(`{3,}|~{3,})[ \t]*(.*?)\s*\z/.match(text)
15
+ return unless match
16
+
17
+ info = match[3]
18
+ # CommonMark: a backtick-style fence cannot have backticks in its
19
+ # info string (they'd be ambiguous with the fence itself).
20
+ return if match[2].start_with?("`") && info.include?("`")
21
+
22
+ {
23
+ char: match[2][0],
24
+ count: match[2].length,
25
+ info: ReferenceDefinition.unescape_text(info),
26
+ indent: match[1].length,
27
+ }
28
+ end
29
+
30
+ # True when `text` is an indented code line: 4+ columns of leading
31
+ # whitespace (tabs expand to a 4-column tab stop).
32
+ def indented_line?(text)
33
+ Indentation.leading_columns(text) >= 4
34
+ end
35
+
36
+ # Cached collaborator for BlockParser. A single instance is created in
37
+ # BlockParser#initialize and reused; per-call state lives in method
38
+ # locals so reentrant calls are safe.
39
+ class Parser
40
+ def initialize(block_parser)
41
+ @arena = block_parser.arena
42
+ end
43
+
44
+ # Parses a fenced block. `fence` is CodeBlock.fenced_start's result
45
+ # for lines[index]. Returns the index past the block.
46
+ def parse_fenced(parent_id, lines, index, fence)
47
+ start_line = lines[index]
48
+ content_lines = []
49
+ index += 1
50
+ while index < lines.length
51
+ break if fence_close?(lines[index].content, fence[:char], fence[:count])
52
+
53
+ content_lines << lines[index]
54
+ index += 1
55
+ end
56
+ index += 1 if index < lines.length
57
+
58
+ # Each content line is stripped of up to the fence's own leading
59
+ # indent (CommonMark spec: a fence indented by N spaces strips up
60
+ # to N spaces from every content line, but never more). Manual
61
+ # byte scan beats compiling an interpolated regex per block and
62
+ # short-circuits when the fence had no indent (the common case).
63
+ indent_n = fence[:indent] || 0
64
+ code = content_lines.map { |l| Indentation.strip_leading_spaces(l.content, indent_n) }.join("\n")
65
+ code << "\n" unless content_lines.empty?
66
+ # The span covers the fences too, as cmark and mdast both report — a
67
+ # span of the content alone points a line below the block. Leading
68
+ # indent is excluded, so ` ```rb` starts at the backticks. `index`
69
+ # now sits past the closing fence, or past the last line when the
70
+ # fence was never closed, so lines[index - 1] is the block's last line
71
+ # either way.
72
+ source_start = start_line.start_byte + indent_n
73
+ source_end = lines[index - 1].end_byte
74
+ code_id = @arena.add_node(NodeType::CODE_BLOCK,
75
+ source_start: source_start,
76
+ source_len: source_end - source_start,
77
+ str1: code,
78
+ str2: fence[:info])
79
+ @arena.append_child(parent_id, code_id)
80
+ index
81
+ end
82
+
83
+ # Parses an indented code block. Returns the index past the block.
84
+ def parse_indented(parent_id, lines, index)
85
+ start_index = index
86
+ code_lines = []
87
+ while index < lines.length
88
+ line = lines[index]
89
+ break unless line.blank || CodeBlock.indented_line?(line.content)
90
+
91
+ # CommonMark: strip up to 4 columns of leading whitespace
92
+ # (tab-aware) from every line, including blank lines whose
93
+ # content beyond column 4 must be preserved verbatim.
94
+ code_lines << Indentation.strip_columns(line.content, 4)
95
+ index += 1
96
+ end
97
+
98
+ # Trailing blank lines are not part of the code block.
99
+ while !code_lines.empty? && code_lines.last.strip.empty?
100
+ code_lines.pop
101
+ index -= 1
102
+ end
103
+
104
+ start_byte = lines[start_index].start_byte
105
+ end_byte = lines[index - 1].end_byte
106
+ code = code_lines.empty? ? "" : code_lines.join("\n") + "\n"
107
+
108
+ code_id = @arena.add_node(NodeType::CODE_BLOCK,
109
+ source_start: start_byte,
110
+ source_len: end_byte - start_byte,
111
+ str1: code)
112
+ @arena.append_child(parent_id, code_id)
113
+ index
114
+ end
115
+
116
+ private
117
+
118
+ def fence_close?(text, char, count)
119
+ # Manual byte scan beats compiling a per-(char,count) regex on
120
+ # every line of a fenced block. Pattern: 0-3 spaces, >=count of
121
+ # `char`, optional trailing spaces/tabs, end-of-line.
122
+ bytes = text.bytesize
123
+ i = 0
124
+ # CommonMark spec: at most 3 spaces of indent.
125
+ while i < 3 && i < bytes && text.getbyte(i) == 0x20
126
+ i += 1
127
+ end
128
+ char_byte = char.getbyte(0)
129
+ fence_start = i
130
+ while i < bytes && text.getbyte(i) == char_byte
131
+ i += 1
132
+ end
133
+ return false if i - fence_start < count
134
+
135
+ while i < bytes
136
+ b = text.getbyte(i)
137
+ return false unless b == 0x20 || b == 0x09
138
+
139
+ i += 1
140
+ end
141
+ true
142
+ end
143
+ end
144
+ end
145
+ end
@@ -2,9 +2,9 @@
2
2
 
3
3
  module RedQuilt
4
4
  class Document
5
- attr_reader :source, :arena, :root_id, :references, :footnotes
5
+ attr_reader :source, :arena, :root_id, :references, :footnotes, :frontmatter
6
6
 
7
- def initialize(source, arena, root_id, allow_html: false, disallow_raw_html: false, references: {}, footnotes: nil)
7
+ def initialize(source, arena, root_id, allow_html: false, disallow_raw_html: false, references: {}, footnotes: nil, frontmatter: nil)
8
8
  @source = source
9
9
  @arena = arena
10
10
  @root_id = root_id
@@ -12,6 +12,7 @@ module RedQuilt
12
12
  @disallow_raw_html = disallow_raw_html
13
13
  @references = references
14
14
  @footnotes = footnotes
15
+ @frontmatter = frontmatter
15
16
  end
16
17
 
17
18
  def allow_html?
@@ -51,11 +52,18 @@ module RedQuilt
51
52
  # `<pre class="mermaid">` containers instead of `<pre><code>`. In
52
53
  # standalone mode the mermaid.js runtime is also loaded from a CDN so
53
54
  # the diagrams render in the browser without further setup.
54
- def to_html(standalone: false, title: nil, lang: "en", css: nil, theme: :none, heading_ids: false, mermaid: false)
55
+ #
56
+ # When standalone and the document was parsed with `frontmatter: true`,
57
+ # the frontmatter's `title` / `lang` keys fill in the corresponding
58
+ # `<title>` / `<html lang>` if no explicit argument was given
59
+ # (explicit argument > frontmatter > default).
60
+ def to_html(standalone: false, title: nil, lang: nil, css: nil, theme: :none, heading_ids: false, mermaid: false)
55
61
  body = Renderer::HTML.new(self, heading_ids: heading_ids, mermaid: mermaid).render
56
62
  return body unless standalone
57
63
 
58
- wrap_standalone_html(body, title: title.to_s, lang: lang.to_s, css: css, theme: Theme.css(theme), mermaid: mermaid)
64
+ effective_title = title || frontmatter_value("title")
65
+ effective_lang = lang || frontmatter_value("lang") || "en"
66
+ wrap_standalone_html(body, title: effective_title.to_s, lang: effective_lang.to_s, css: css, theme: Theme.css(theme), mermaid: mermaid)
59
67
  end
60
68
 
61
69
  def to_ast
@@ -91,6 +99,12 @@ module RedQuilt
91
99
 
92
100
  private
93
101
 
102
+ def frontmatter_value(key)
103
+ return nil unless @frontmatter.is_a?(Hash)
104
+
105
+ @frontmatter[key]
106
+ end
107
+
94
108
  # Self-contained assets embedded in standalone output when mermaid
95
109
  # support is enabled. Loads the mermaid.js runtime from a CDN as an ES
96
110
  # module, renders every `<pre class="mermaid">` container, then makes
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RedQuilt
4
+ # Single source of truth for the HTML element ids used to wire footnote
5
+ # references to their definitions and back. Both the reference (`<sup>`),
6
+ # the definition (`<li>`), and the back-reference links must agree on
7
+ # these strings, so they live in one place rather than being rebuilt at
8
+ # each call site.
9
+ module FootnoteAnchors
10
+ module_function
11
+
12
+ # Id of the definition `<li>` and the target of a reference link.
13
+ def definition_id(number)
14
+ "fn-#{number}"
15
+ end
16
+
17
+ # Id of a reference `<sup>` and the target of a back-reference link.
18
+ # A repeated reference (occurrence > 1) gets a `-N` suffix so every
19
+ # back-reference has a unique anchor.
20
+ def reference_id(number, occurrence)
21
+ occurrence > 1 ? "fnref-#{number}-#{occurrence}" : "fnref-#{number}"
22
+ end
23
+ end
24
+ end
@@ -29,10 +29,14 @@ module RedQuilt
29
29
 
30
30
  # Re-append referenced definitions in first-reference order; detaching
31
31
  # all current children first means unreferenced definitions are left
32
- # orphaned (and so never rendered).
32
+ # orphaned (and so never rendered). The number and reference count are
33
+ # materialized onto each definition node so the renderer reads them off
34
+ # the arena rather than consulting the registry.
33
35
  @arena.child_ids(section_id).to_a.each { |child| @arena.detach(child) }
34
36
  @registry.referenced_labels.each do |label|
35
- @arena.append_child(section_id, @registry.definition_node(label))
37
+ def_id = @registry.definition_node(label)
38
+ @arena.resolve_footnote_definition(def_id, @registry.number(label), @registry.occurrences(label))
39
+ @arena.append_child(section_id, def_id)
36
40
  end
37
41
  end
38
42
  end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "psych"
4
+ require "date"
5
+
6
+ module RedQuilt
7
+ # Extracts a leading YAML frontmatter block from a Markdown source.
8
+ module Frontmatter
9
+ # Matches a frontmatter block at the very start of the document.
10
+ PATTERN = /\A---\n(.*?)\n(?:---|\.\.\.)[ \t]*(?:\n|\z)/m
11
+ private_constant :PATTERN
12
+
13
+ module_function
14
+
15
+ # Extracts frontmatter from +source+, returning a two-element array:
16
+ # [data, body]. +data+ is the parsed Hash (or nil when there is no
17
+ # frontmatter), and +body+ is the source with the frontmatter region
18
+ # blanked out.
19
+ #
20
+ # +diagnostics+ is an optional array; on a YAML syntax error a warning
21
+ # Diagnostic is appended and +data+ is returned as nil.
22
+ def extract(source, diagnostics: nil)
23
+ match = PATTERN.match(source)
24
+ return [nil, source] unless match
25
+
26
+ data = parse_yaml(match[1], diagnostics: diagnostics)
27
+ body = blank_out(source, match.end(0))
28
+ [data, body]
29
+ end
30
+
31
+ # Parses the YAML body with a restricted loader (no arbitrary object
32
+ # instantiation; Date / Time permitted for common frontmatter dates).
33
+ # Returns the parsed value, or nil on a syntax error.
34
+ def parse_yaml(yaml, diagnostics: nil)
35
+ Psych.safe_load(yaml, permitted_classes: [Date, Time], aliases: false)
36
+ rescue Psych::SyntaxError => e
37
+ diagnostics&.push(
38
+ Diagnostic.new(
39
+ severity: :warning,
40
+ rule: :frontmatter,
41
+ message: "invalid YAML frontmatter: #{e.message}",
42
+ ),
43
+ )
44
+ nil
45
+ end
46
+
47
+ # Replaces every character before +offset+ with a blank line for each
48
+ # consumed source line, keeping later line numbers intact.
49
+ def blank_out(source, offset)
50
+ consumed = source[0, offset]
51
+ ("\n" * consumed.count("\n")) + source[offset..]
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,161 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RedQuilt
4
+ # CommonMark HTML-block classification (spec 4.6). Pure functions over a
5
+ # line's text: given the raw line they decide whether it opens an HTML
6
+ # block and of which of the seven types. No arena or parser state is
7
+ # involved, so this lives apart from BlockParser's node construction.
8
+ module HtmlBlock
9
+ module_function
10
+
11
+ # True when `text` opens an HTML block (any of the 7 types). Indented
12
+ # code (4+ leading spaces) takes precedence and is never an HTML block.
13
+ def start?(text)
14
+ return false if text.start_with?(" ")
15
+
16
+ !type(text).nil?
17
+ end
18
+
19
+ # The HTML block type (1..7) opened by `text`, or nil if it opens none.
20
+ def type(text)
21
+ # Fast reject: every HTML block starts with `<`. lstrip strips
22
+ # 0-3 indent spaces (more would already be indented code), so peek
23
+ # the leading non-space byte before doing any allocations.
24
+ i = 0
25
+ # CommonMark: HTML block lines may have 0-3 spaces of indent.
26
+ while i < 3 && i < text.length && text.getbyte(i) == 0x20
27
+ i += 1
28
+ end
29
+ return nil unless i < text.length && text.getbyte(i) == 0x3C
30
+
31
+ stripped = i.zero? ? text : text[i..]
32
+
33
+ # Type 1: <script|pre|style|textarea (case-insensitive) followed by
34
+ # space/tab/end-of-line or `>`. CommonMark restricts the separator
35
+ # to space, tab, or a line ending (not any whitespace class).
36
+ return 1 if stripped.match?(%r{\A<(script|pre|style|textarea)(?:[ \t]|>|$)}i)
37
+
38
+ # Type 2: <!--
39
+ return 2 if stripped.start_with?("<!--")
40
+
41
+ # Type 3: <?
42
+ return 3 if stripped.start_with?("<?")
43
+
44
+ # Type 4: <! followed by uppercase ASCII letter
45
+ return 4 if stripped.match?(%r{\A<![A-Z]})
46
+
47
+ # Type 5: <![CDATA[
48
+ return 5 if stripped.start_with?("<![CDATA[")
49
+
50
+ # Type 6: line opens with one of the listed block-level tags.
51
+ return 6 if stripped.match?(TYPE_6_RE)
52
+
53
+ # Type 7: a complete open or closing tag spanning the line.
54
+ return 7 if valid_tag?(stripped)
55
+
56
+ nil
57
+ end
58
+
59
+ TYPE_6_NAMES = %w[
60
+ address article aside base basefont blockquote body caption center
61
+ col colgroup dd details dialog dir div dl dt fieldset figcaption
62
+ figure footer form frame frameset h1 h2 h3 h4 h5 h6 head header
63
+ hr html iframe legend li link main menu menuitem nav noframes ol
64
+ optgroup option p param search section summary table tbody td
65
+ tfoot th thead title tr track ul
66
+ ].freeze
67
+ TYPE_6_RE = %r{\A</?(?:#{TYPE_6_NAMES.join('|')})(?:[ \t]|>|/>|\z)}i
68
+ private_constant :TYPE_6_NAMES, :TYPE_6_RE
69
+
70
+ # Type 7: a complete open or closing tag on its own line.
71
+ # Closing tags must not have attributes.
72
+ #
73
+ # HTML tag separators per CommonMark 6.6 are space, tab, or up to one
74
+ # line ending -- not the broader \s class (which would include form
75
+ # feed and vertical tab).
76
+ TYPE_7_OPEN_TAG_RE = %r{
77
+ \A
78
+ <[A-Za-z][A-Za-z0-9-]*
79
+ (?:[ \t\r\n]+[A-Za-z_:][A-Za-z0-9_.:-]*(?:[ \t\r\n]*=[ \t\r\n]*(?:"[^"\n]*"|'[^'\n]*'|[^ \t\r\n"'=<>`]+))?)*
80
+ [ \t\r\n]*/?>
81
+ \z
82
+ }x
83
+ TYPE_7_CLOSING_TAG_RE = %r{\A</[A-Za-z][A-Za-z0-9-]*[ \t\r\n]*>\z}
84
+ private_constant :TYPE_7_OPEN_TAG_RE, :TYPE_7_CLOSING_TAG_RE
85
+
86
+ def valid_tag?(text)
87
+ # Fast reject: every type-7 tag must begin with `<`.
88
+ return false unless text.start_with?("<")
89
+
90
+ TYPE_7_OPEN_TAG_RE.match?(text) || TYPE_7_CLOSING_TAG_RE.match?(text)
91
+ end
92
+
93
+ # Closing-condition strings for HTML block types 2-5 (types 1, 6, 7 use
94
+ # dynamic / blank-line termination).
95
+ FIXED_TERMINATORS = { 2 => "-->", 3 => "?>", 4 => ">", 5 => "]]>" }.freeze
96
+ private_constant :FIXED_TERMINATORS
97
+
98
+ # Cached collaborator for BlockParser. A single instance is created in
99
+ # BlockParser#initialize and reused; per-call state lives in method
100
+ # locals so reentrant calls are safe.
101
+ class Parser
102
+ def initialize(block_parser)
103
+ @arena = block_parser.arena
104
+ end
105
+
106
+ # Parses the HTML block starting at lines[index] (its type already
107
+ # confirmed by HtmlBlock.start?). Returns the index past the block.
108
+ def parse(parent_id, lines, index)
109
+ start_index = index
110
+ type = HtmlBlock.type(lines[index].content)
111
+ end_index = locate_end(lines, index, type)
112
+
113
+ start_byte = lines[start_index].start_byte
114
+ end_byte = lines[end_index].end_byte
115
+ html_lines = (start_index..end_index).map { |i| lines[i].content }
116
+ html_id = @arena.add_node(NodeType::HTML_BLOCK,
117
+ source_start: start_byte,
118
+ source_len: end_byte - start_byte,
119
+ str1: html_lines.join("\n"))
120
+ @arena.append_child(parent_id, html_id)
121
+ end_index + 1
122
+ end
123
+
124
+ private
125
+
126
+ def locate_end(lines, index, type)
127
+ terminator = terminator_for(type, lines[index].content)
128
+
129
+ if terminator
130
+ case_insensitive = (type == 1)
131
+ while index < lines.length
132
+ line = lines[index].content
133
+ haystack = case_insensitive ? line.downcase : line
134
+ return index if haystack.include?(terminator)
135
+
136
+ index += 1
137
+ end
138
+ lines.length - 1
139
+ else
140
+ # Types 6 & 7: terminated by blank line (or end of input)
141
+ index += 1 while index < lines.length && !lines[index].blank
142
+ index - 1
143
+ end
144
+ end
145
+
146
+ def terminator_for(type, first_line)
147
+ case type
148
+ when 1
149
+ "</#{closing_tag_name(first_line)}>"
150
+ when 2..5
151
+ FIXED_TERMINATORS[type]
152
+ end
153
+ end
154
+
155
+ def closing_tag_name(text)
156
+ match = /\A<(script|pre|style|textarea)/i.match(text)
157
+ match ? match[1].downcase : "script"
158
+ end
159
+ end
160
+ end
161
+ end
@@ -57,6 +57,41 @@ module RedQuilt
57
57
  end
58
58
  end
59
59
 
60
+ # Strips up to `max` leading 0x20 (space) bytes from `text`, returning
61
+ # the rest. Unlike #strip_columns this is a plain byte strip (tabs are
62
+ # not expanded); used where the spec counts literal spaces, e.g. a
63
+ # fenced code block stripping its own opening indent. No-alloc return
64
+ # when `text` already starts at a non-space byte.
65
+ def strip_leading_spaces(text, max)
66
+ return text if max <= 0
67
+
68
+ bytes = text.bytesize
69
+ i = 0
70
+ while i < max && i < bytes && text.getbyte(i) == 0x20
71
+ i += 1
72
+ end
73
+ return text if i.zero?
74
+
75
+ text.byteslice(i..)
76
+ end
77
+
78
+ # Strips all leading 0x20 / 0x09 bytes from `text` (spaces and tabs,
79
+ # no column cap). Same no-alloc return as #strip_leading_spaces when
80
+ # `text` already starts at a non-whitespace byte.
81
+ def strip_leading_whitespace(text)
82
+ bytes = text.bytesize
83
+ i = 0
84
+ while i < bytes
85
+ b = text.getbyte(i)
86
+ break unless b == 0x20 || b == 0x09
87
+
88
+ i += 1
89
+ end
90
+ return text if i.zero?
91
+
92
+ text.byteslice(i..)
93
+ end
94
+
60
95
  # Bytes of literal leading 0x20 / 0x09 in `text`.
61
96
  def leading_ws_bytes(text)
62
97
  i = 0