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
|
@@ -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
|
data/lib/red_quilt/document.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|