andromeda_cms 0.1.0-arm64-darwin
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +30 -0
- data/CONTRIBUTING.md +47 -0
- data/LICENSE.txt +21 -0
- data/README.md +276 -0
- data/lib/andromeda/assets.rb +128 -0
- data/lib/andromeda/check.rb +71 -0
- data/lib/andromeda/components/errors.rb +97 -0
- data/lib/andromeda/components/import_scanner.rb +72 -0
- data/lib/andromeda/components/static_expression.rb +79 -0
- data/lib/andromeda/components.rb +348 -0
- data/lib/andromeda/configuration.rb +91 -0
- data/lib/andromeda/entry.rb +294 -0
- data/lib/andromeda/errors.rb +173 -0
- data/lib/andromeda/fix.rb +71 -0
- data/lib/andromeda/frontmatter.rb +327 -0
- data/lib/andromeda/helpers.rb +84 -0
- data/lib/andromeda/id.rb +58 -0
- data/lib/andromeda/loader.rb +96 -0
- data/lib/andromeda/parser.rb +75 -0
- data/lib/andromeda/pipeline.rb +296 -0
- data/lib/andromeda/railtie.rb +47 -0
- data/lib/andromeda/registry.rb +127 -0
- data/lib/andromeda/relation.rb +125 -0
- data/lib/andromeda/renderer/code_highlighter.rb +53 -0
- data/lib/andromeda/renderer/errors.rb +52 -0
- data/lib/andromeda/renderer/literal_expression.rb +201 -0
- data/lib/andromeda/renderer.rb +411 -0
- data/lib/andromeda/schema.rb +383 -0
- data/lib/andromeda/slugger.rb +68 -0
- data/lib/andromeda/store.rb +286 -0
- data/lib/andromeda/tasks/andromeda.rake +56 -0
- data/lib/andromeda/version.rb +5 -0
- data/lib/andromeda_cms/3.2/andromeda_cms.bundle +0 -0
- data/lib/andromeda_cms/3.3/andromeda_cms.bundle +0 -0
- data/lib/andromeda_cms/3.4/andromeda_cms.bundle +0 -0
- data/lib/andromeda_cms/4.0/andromeda_cms.bundle +0 -0
- data/lib/andromeda_cms.rb +41 -0
- data/lib/generators/andromeda/collection/USAGE +25 -0
- data/lib/generators/andromeda/collection/collection_generator.rb +239 -0
- data/lib/generators/andromeda/component/USAGE +15 -0
- data/lib/generators/andromeda/component/component_generator.rb +75 -0
- data/lib/generators/andromeda/import_astro/USAGE +21 -0
- data/lib/generators/andromeda/import_astro/astro_schema_json.rb +80 -0
- data/lib/generators/andromeda/import_astro/balanced_scanner.rb +168 -0
- data/lib/generators/andromeda/import_astro/content_config_converter.rb +232 -0
- data/lib/generators/andromeda/import_astro/import_astro_generator.rb +313 -0
- data/lib/generators/andromeda/import_astro/mdx_content_scanner.rb +91 -0
- data/lib/generators/andromeda/import_astro/mdx_import_rewriter.rb +91 -0
- data/lib/generators/andromeda/install/USAGE +11 -0
- data/lib/generators/andromeda/install/install_generator.rb +77 -0
- data/lib/generators/andromeda/install/templates/initializer.rb +30 -0
- metadata +163 -0
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Andromeda
|
|
4
|
+
class Renderer
|
|
5
|
+
# Raised when MDX source uses a capitalized tag (an MDX *component*, not
|
|
6
|
+
# a lowercase HTML element) but no `components:` collaborator was given
|
|
7
|
+
# to render it. Kept in this file rather than `lib/andromeda/errors.rb`
|
|
8
|
+
# so the renderer's error surface stays independent from the schema /
|
|
9
|
+
# parser error hierarchy being developed alongside it; it still inherits
|
|
10
|
+
# from `Andromeda::Error` so host apps can rescue everything the gem
|
|
11
|
+
# raises with one class -- the error must name the tag and where
|
|
12
|
+
# to create the missing partial, which is the Rails-side collaborator's
|
|
13
|
+
# job once one exists -- this class carries the tag name and source
|
|
14
|
+
# location, since that is all the renderer itself knows).
|
|
15
|
+
class MissingComponentError < Andromeda::Error
|
|
16
|
+
attr_reader :tag_name, :line, :column
|
|
17
|
+
|
|
18
|
+
def initialize(tag_name, line:, column:)
|
|
19
|
+
@tag_name = tag_name
|
|
20
|
+
@line = line
|
|
21
|
+
@column = column
|
|
22
|
+
location = line ? "#{line}:#{column}: " : ""
|
|
23
|
+
super("#{location}no component collaborator registered for <#{tag_name}> " \
|
|
24
|
+
"(pass `components:` to Andromeda::Renderer.new to handle MDX component tags)")
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Raised if a `yaml`/`toml` mdast node ever reaches the renderer.
|
|
29
|
+
# `Andromeda::Frontmatter` is supposed to strip frontmatter *before* the
|
|
30
|
+
# body is handed to `Andromeda::Parser` (see frontmatter.rb), so these
|
|
31
|
+
# node types should be structurally impossible here; this exists so a
|
|
32
|
+
# future change that skips that step fails loudly instead of silently
|
|
33
|
+
# rendering (or silently dropping) a frontmatter block as content.
|
|
34
|
+
class UnexpectedFrontmatterNodeError < Andromeda::Error
|
|
35
|
+
def initialize(node_type)
|
|
36
|
+
super("unexpected #{node_type.inspect} node reached Renderer -- frontmatter must be " \
|
|
37
|
+
"stripped by Andromeda::Frontmatter before the body is parsed")
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Raised for mdast node types the native parser is configured to never
|
|
42
|
+
# emit (directives, description lists, superscript/subscript -- see the
|
|
43
|
+
# `options_for` comment in ext/andromeda_cms/src/lib.rs) but that
|
|
44
|
+
# `nodes.rs` can still decode. Guards against a future options change
|
|
45
|
+
# silently reaching a renderer branch that was never written for it.
|
|
46
|
+
class UnsupportedNodeError < Andromeda::Error
|
|
47
|
+
def initialize(node_type)
|
|
48
|
+
super("no renderer for mdast node type #{node_type.inspect}")
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Andromeda
|
|
4
|
+
class Renderer
|
|
5
|
+
# Minimal evaluator for MDX/JSX attribute *expressions* that are plain
|
|
6
|
+
# literals: numbers, booleans, `null`, strings, arrays, and objects --
|
|
7
|
+
# covering prop values that are themselves literals. This is
|
|
8
|
+
# deliberately not a JS expression evaluator -- no identifiers, member
|
|
9
|
+
# access, calls, operators, or template literals. Those are handled
|
|
10
|
+
# elsewhere (`{frontmatter.x}`, `.map()`, etc.); here, anything that is not
|
|
11
|
+
# a bare literal is left as the original source text so callers can
|
|
12
|
+
# still render *something* (e.g. `Counter count={1 + 1}` becomes the
|
|
13
|
+
# attribute value `"1 + 1"`, not a raised error) while a fuller
|
|
14
|
+
# evaluator can upgrade this later.
|
|
15
|
+
module LiteralExpression
|
|
16
|
+
module_function
|
|
17
|
+
|
|
18
|
+
# @param source [String] the raw text between `{` and `}` in
|
|
19
|
+
# `mdxJsxAttributeValueExpression`/`mdxFlowExpression`/etc (i.e. what
|
|
20
|
+
# `nodes.rs` already stripped the braces from).
|
|
21
|
+
# @return [Object] the evaluated Ruby value (String/Integer/Float/
|
|
22
|
+
# true/false/nil/Array/Hash) if `source` is a bare literal;
|
|
23
|
+
# otherwise `source` itself, unchanged.
|
|
24
|
+
def evaluate(source)
|
|
25
|
+
parser = Parser.new(source)
|
|
26
|
+
value = parser.parse_value
|
|
27
|
+
parser.skip_ws
|
|
28
|
+
parser.eof? ? value : source
|
|
29
|
+
rescue Parser::Error
|
|
30
|
+
source
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Hand-rolled recursive-descent parser over a small literal grammar.
|
|
34
|
+
# Not built on Ruby's JSON parser because object keys here are often
|
|
35
|
+
# unquoted JS identifiers (`{a: 1}`), which is invalid JSON.
|
|
36
|
+
class Parser
|
|
37
|
+
Error = Class.new(StandardError)
|
|
38
|
+
|
|
39
|
+
IDENTIFIER_START = /[A-Za-z_$]/
|
|
40
|
+
IDENTIFIER_CHAR = /[A-Za-z0-9_$]/
|
|
41
|
+
NUMBER = /\A-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?/
|
|
42
|
+
|
|
43
|
+
def initialize(source)
|
|
44
|
+
@s = source
|
|
45
|
+
@i = 0
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def eof?
|
|
49
|
+
@i >= @s.length
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def skip_ws
|
|
53
|
+
@i += 1 while !eof? && @s[@i].match?(/\s/)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def parse_value
|
|
57
|
+
skip_ws
|
|
58
|
+
raise Error, "unexpected end of expression" if eof?
|
|
59
|
+
|
|
60
|
+
case @s[@i]
|
|
61
|
+
when "{" then parse_object
|
|
62
|
+
when "[" then parse_array
|
|
63
|
+
when '"', "'" then parse_string
|
|
64
|
+
else
|
|
65
|
+
parse_keyword || parse_number || (raise Error, "unexpected #{@s[@i].inspect} at #{@i}")
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def parse_keyword
|
|
70
|
+
%w[true false null undefined].each do |word|
|
|
71
|
+
next unless @s[@i, word.length] == word
|
|
72
|
+
# Guard against matching a prefix of a longer identifier, e.g.
|
|
73
|
+
# `truest` should not parse as `true` followed by garbage.
|
|
74
|
+
after = @s[@i + word.length]
|
|
75
|
+
next if after && after.match?(IDENTIFIER_CHAR)
|
|
76
|
+
|
|
77
|
+
@i += word.length
|
|
78
|
+
return { "true" => true, "false" => false, "null" => nil, "undefined" => nil }[word]
|
|
79
|
+
end
|
|
80
|
+
nil
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def parse_number
|
|
84
|
+
match = NUMBER.match(@s[@i..])
|
|
85
|
+
return nil unless match
|
|
86
|
+
|
|
87
|
+
@i += match[0].length
|
|
88
|
+
text = match[0]
|
|
89
|
+
text.include?(".") || text.include?("e") || text.include?("E") ? text.to_f : text.to_i
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def parse_string
|
|
93
|
+
quote = @s[@i]
|
|
94
|
+
@i += 1
|
|
95
|
+
buf = +""
|
|
96
|
+
while true
|
|
97
|
+
raise Error, "unterminated string" if eof?
|
|
98
|
+
|
|
99
|
+
char = @s[@i]
|
|
100
|
+
if char == quote
|
|
101
|
+
@i += 1
|
|
102
|
+
return buf
|
|
103
|
+
elsif char == "\\"
|
|
104
|
+
@i += 1
|
|
105
|
+
raise Error, "unterminated string" if eof?
|
|
106
|
+
|
|
107
|
+
buf << unescape(@s[@i])
|
|
108
|
+
@i += 1
|
|
109
|
+
else
|
|
110
|
+
buf << char
|
|
111
|
+
@i += 1
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def unescape(char)
|
|
117
|
+
{ "n" => "\n", "t" => "\t", "r" => "\r", "b" => "\b", "f" => "\f" }.fetch(char, char)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def parse_array
|
|
121
|
+
@i += 1 # consume '['
|
|
122
|
+
values = []
|
|
123
|
+
skip_ws
|
|
124
|
+
if peek == "]"
|
|
125
|
+
@i += 1
|
|
126
|
+
return values
|
|
127
|
+
end
|
|
128
|
+
loop do
|
|
129
|
+
values << parse_value
|
|
130
|
+
skip_ws
|
|
131
|
+
case peek
|
|
132
|
+
when ","
|
|
133
|
+
@i += 1
|
|
134
|
+
skip_ws
|
|
135
|
+
# Trailing comma before ']'.
|
|
136
|
+
if peek == "]"
|
|
137
|
+
@i += 1
|
|
138
|
+
return values
|
|
139
|
+
end
|
|
140
|
+
when "]"
|
|
141
|
+
@i += 1
|
|
142
|
+
return values
|
|
143
|
+
else
|
|
144
|
+
raise Error, "expected ',' or ']' at #{@i}"
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def parse_object
|
|
150
|
+
@i += 1 # consume '{'
|
|
151
|
+
object = {}
|
|
152
|
+
skip_ws
|
|
153
|
+
if peek == "}"
|
|
154
|
+
@i += 1
|
|
155
|
+
return object
|
|
156
|
+
end
|
|
157
|
+
loop do
|
|
158
|
+
skip_ws
|
|
159
|
+
key = parse_object_key
|
|
160
|
+
skip_ws
|
|
161
|
+
raise Error, "expected ':' at #{@i}" unless peek == ":"
|
|
162
|
+
|
|
163
|
+
@i += 1
|
|
164
|
+
object[key] = parse_value
|
|
165
|
+
skip_ws
|
|
166
|
+
case peek
|
|
167
|
+
when ","
|
|
168
|
+
@i += 1
|
|
169
|
+
skip_ws
|
|
170
|
+
if peek == "}"
|
|
171
|
+
@i += 1
|
|
172
|
+
return object
|
|
173
|
+
end
|
|
174
|
+
when "}"
|
|
175
|
+
@i += 1
|
|
176
|
+
return object
|
|
177
|
+
else
|
|
178
|
+
raise Error, "expected ',' or '}' at #{@i}"
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def parse_object_key
|
|
184
|
+
case peek
|
|
185
|
+
when '"', "'" then parse_string
|
|
186
|
+
else
|
|
187
|
+
raise Error, "expected object key at #{@i}" unless peek&.match?(IDENTIFIER_START)
|
|
188
|
+
|
|
189
|
+
start = @i
|
|
190
|
+
@i += 1 while !eof? && @s[@i].match?(IDENTIFIER_CHAR)
|
|
191
|
+
@s[start...@i]
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def peek
|
|
196
|
+
eof? ? nil : @s[@i]
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
end
|
|
@@ -0,0 +1,411 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "cgi"
|
|
4
|
+
require_relative "slugger"
|
|
5
|
+
require_relative "renderer/errors"
|
|
6
|
+
require_relative "renderer/code_highlighter"
|
|
7
|
+
require_relative "renderer/literal_expression"
|
|
8
|
+
|
|
9
|
+
module Andromeda
|
|
10
|
+
# Turns an mdast tree (as produced by `Andromeda::Parser.parse`) into HTML,
|
|
11
|
+
# matching Astro's default rendering choices closely enough to display
|
|
12
|
+
# copied-over content correctly -- displaying it correctly is the goal,
|
|
13
|
+
# not a byte-exact match.
|
|
14
|
+
#
|
|
15
|
+
# One instance is stateless across calls to `#render` (each call resets
|
|
16
|
+
# its own heading/footnote/definition bookkeeping), so a single Renderer
|
|
17
|
+
# can be reused for many documents; what varies per host app is the
|
|
18
|
+
# `components:`/`link_resolver:`/`image_resolver:` collaborators, not
|
|
19
|
+
# per-document state.
|
|
20
|
+
class Renderer
|
|
21
|
+
# `render` returns one of these: `html` is the full rendered document,
|
|
22
|
+
# `headings` is `[{depth:, slug:, text:}, ...]` in document order --
|
|
23
|
+
# the same shape Astro's `render()` result carries, for a
|
|
24
|
+
# future `toc` helper to walk without re-parsing the tree.
|
|
25
|
+
Result = Struct.new(:html, :headings, keyword_init: true)
|
|
26
|
+
|
|
27
|
+
# @param components [#render, nil] receives `(tag_name, attributes,
|
|
28
|
+
# children_html, node)` for every MDX component tag (capitalized, or
|
|
29
|
+
# containing a `.`) and must return an HTML string. `nil` (the
|
|
30
|
+
# default) means MDX component tags raise `MissingComponentError`
|
|
31
|
+
# instead -- the real Rails-partial-backed collaborator is wired in
|
|
32
|
+
# elsewhere; tests here use a stub that records calls.
|
|
33
|
+
# @param link_resolver [#call, nil] `(url, node) -> url`, applied to
|
|
34
|
+
# every `link`/`linkReference` URL. Identity by default; relative-link
|
|
35
|
+
# and Propshaft resolution are plugged in here elsewhere.
|
|
36
|
+
# @param image_resolver [#call, nil] `(url, node) -> url`, same shape,
|
|
37
|
+
# applied to every `image`/`imageReference` URL.
|
|
38
|
+
def initialize(components: nil, link_resolver: nil, image_resolver: nil)
|
|
39
|
+
@components = components
|
|
40
|
+
@link_resolver = link_resolver || ->(url, _node) { url }
|
|
41
|
+
@image_resolver = image_resolver || ->(url, _node) { url }
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# @param tree [Hash] an mdast root node (symbol keys, as
|
|
45
|
+
# `Andromeda::Parser.parse` returns).
|
|
46
|
+
# @return [Result]
|
|
47
|
+
def render(tree)
|
|
48
|
+
# Per-render state. A fresh Slugger means heading ids restart at
|
|
49
|
+
# `-1`/`-2` for every document, matching Astro's "one Slugger per
|
|
50
|
+
# file" behaviour rather than accumulating across renders.
|
|
51
|
+
@slugger = Slugger.new
|
|
52
|
+
@headings = []
|
|
53
|
+
@definitions = {}
|
|
54
|
+
@footnote_definitions = {}
|
|
55
|
+
@footnote_order = []
|
|
56
|
+
@footnote_ref_counts = Hash.new(0)
|
|
57
|
+
|
|
58
|
+
# `definition`/`footnoteDefinition` nodes can appear anywhere in the
|
|
59
|
+
# tree (often after the text that references them), so references
|
|
60
|
+
# need every definition collected before the single rendering pass
|
|
61
|
+
# below reaches them.
|
|
62
|
+
collect_definitions_and_footnotes(tree)
|
|
63
|
+
|
|
64
|
+
html = render_node(tree)
|
|
65
|
+
html += render_footnotes_section if @footnote_order.any?
|
|
66
|
+
|
|
67
|
+
Result.new(html: html, headings: @headings)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# @param text [String, nil]
|
|
71
|
+
# @return [String] `text` with `&`, `<`, `>`, `"`, `'` entity-escaped.
|
|
72
|
+
# Shared by text content and attribute values -- escaping quotes in
|
|
73
|
+
# plain text is harmless, and using one helper everywhere means a raw
|
|
74
|
+
# node's `:value` is never accidentally escaped twice.
|
|
75
|
+
def self.escape_html(text)
|
|
76
|
+
CGI.escapeHTML(text.to_s)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
class << self
|
|
80
|
+
alias_method :escape_attr, :escape_html
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
private
|
|
84
|
+
|
|
85
|
+
def collect_definitions_and_footnotes(node)
|
|
86
|
+
case node[:type]
|
|
87
|
+
when "definition"
|
|
88
|
+
@definitions[node[:identifier].to_s.downcase] = node
|
|
89
|
+
when "footnoteDefinition"
|
|
90
|
+
@footnote_definitions[node[:identifier].to_s.downcase] = node
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
(node[:children] || []).each { |child| collect_definitions_and_footnotes(child) }
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def render_children(node)
|
|
97
|
+
(node[:children] || []).map { |child| render_node(child) }.join
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def render_node(node)
|
|
101
|
+
case node[:type]
|
|
102
|
+
when "root" then render_children(node)
|
|
103
|
+
when "paragraph" then "<p>#{render_children(node)}</p>\n"
|
|
104
|
+
when "heading" then render_heading(node)
|
|
105
|
+
when "thematicBreak" then "<hr />\n"
|
|
106
|
+
when "blockquote" then "<blockquote>\n#{render_children(node)}</blockquote>\n"
|
|
107
|
+
when "list" then render_list(node)
|
|
108
|
+
# Reachable only if a `listItem` is ever walked outside `render_list`
|
|
109
|
+
# (e.g. a future caller rendering a subtree directly); `spread: true`
|
|
110
|
+
# is the safe default since it never drops a `<p>` that should stay.
|
|
111
|
+
when "listItem" then render_list_item(node, spread: true)
|
|
112
|
+
when "code" then CodeHighlighter.render(node[:value].to_s, node[:lang])
|
|
113
|
+
when "inlineCode" then "<code>#{Renderer.escape_html(node[:value])}</code>"
|
|
114
|
+
# No sanitization: raw HTML is passed through unchanged, matching
|
|
115
|
+
# Astro -- content is trusted,
|
|
116
|
+
# developer-authored input, not user-submitted.
|
|
117
|
+
when "html" then node[:value].to_s
|
|
118
|
+
when "yaml", "toml" then raise UnexpectedFrontmatterNodeError, node[:type]
|
|
119
|
+
when "text" then Renderer.escape_html(node[:value])
|
|
120
|
+
when "emphasis" then "<em>#{render_children(node)}</em>"
|
|
121
|
+
when "strong" then "<strong>#{render_children(node)}</strong>"
|
|
122
|
+
when "delete" then "<del>#{render_children(node)}</del>"
|
|
123
|
+
when "break" then "<br />\n"
|
|
124
|
+
when "link" then render_link(node)
|
|
125
|
+
when "image" then render_image(node)
|
|
126
|
+
when "definition" then "" # not visible output; only a reference target
|
|
127
|
+
when "linkReference" then render_link_reference(node)
|
|
128
|
+
when "imageReference" then render_image_reference(node)
|
|
129
|
+
when "footnoteReference" then render_footnote_reference(node)
|
|
130
|
+
when "footnoteDefinition" then "" # rendered once, at the end -- see render_footnotes_section
|
|
131
|
+
when "table" then render_table(node)
|
|
132
|
+
# Only reached if walked outside render_table (which renders rows/
|
|
133
|
+
# cells directly to apply column alignment); dispatch here still
|
|
134
|
+
# produces sane output rather than raising.
|
|
135
|
+
when "tableRow", "tableCell" then render_children(node)
|
|
136
|
+
when "math" then %(<div class="math math-display">#{Renderer.escape_html(node[:value])}</div>)
|
|
137
|
+
when "inlineMath" then %(<span class="math math-inline">#{Renderer.escape_html(node[:value])}</span>)
|
|
138
|
+
when "mdxJsxFlowElement", "mdxJsxTextElement" then render_jsx_element(node)
|
|
139
|
+
when "mdxFlowExpression", "mdxTextExpression" then render_expression(node)
|
|
140
|
+
when "mdxjsEsm" then "" # import/export bindings consumed elsewhere; nothing to render here
|
|
141
|
+
else
|
|
142
|
+
raise UnsupportedNodeError, node[:type]
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def render_heading(node)
|
|
147
|
+
text = heading_text(node)
|
|
148
|
+
slug = @slugger.slug(text)
|
|
149
|
+
@headings << { depth: node[:depth], slug: slug, text: text }
|
|
150
|
+
depth = node[:depth]
|
|
151
|
+
"<h#{depth} id=\"#{Renderer.escape_attr(slug)}\">#{render_children(node)}</h#{depth}>\n"
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# Only `text` nodes
|
|
155
|
+
# contribute characters; other leaf nodes (`html`, `break`, `inlineCode`,
|
|
156
|
+
# ...) contribute nothing even though they carry a `:value`, and other
|
|
157
|
+
# parent nodes (`emphasis`, `strong`, ...) contribute their descendant
|
|
158
|
+
# text nodes by recursing into `:children`.
|
|
159
|
+
def heading_text(node)
|
|
160
|
+
return node[:value].to_s if node[:type] == "text"
|
|
161
|
+
|
|
162
|
+
(node[:children] || []).map { |child| heading_text(child) }.join
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def render_list(node)
|
|
166
|
+
tag = node[:ordered] ? "ol" : "ul"
|
|
167
|
+
start_attr = node[:ordered] && node[:start] && node[:start] != 1 ? %( start="#{node[:start]}") : ""
|
|
168
|
+
items = (node[:children] || []).map { |item| render_list_item(item, spread: node[:spread]) }.join
|
|
169
|
+
"<#{tag}#{start_attr}>\n#{items}</#{tag}>\n"
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def render_list_item(node, spread:)
|
|
173
|
+
checked = node[:checked]
|
|
174
|
+
class_attr = checked.nil? ? "" : ' class="task-list-item"'
|
|
175
|
+
checkbox =
|
|
176
|
+
if checked.nil?
|
|
177
|
+
""
|
|
178
|
+
else
|
|
179
|
+
%(<input type="checkbox" disabled=""#{checked ? ' checked=""' : ""} /> )
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
children = node[:children] || []
|
|
183
|
+
body =
|
|
184
|
+
if spread
|
|
185
|
+
render_children(node)
|
|
186
|
+
else
|
|
187
|
+
# Tight lists (CommonMark): a listItem's direct paragraph children
|
|
188
|
+
# render without a `<p>` wrapper; any other child (nested list,
|
|
189
|
+
# blockquote, ...) still renders normally.
|
|
190
|
+
children.map { |child| child[:type] == "paragraph" ? render_children(child) : render_node(child) }.join
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
"<li#{class_attr}>#{checkbox}#{body}</li>\n"
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def render_link(node)
|
|
197
|
+
url = @link_resolver.call(node[:url].to_s, node)
|
|
198
|
+
title_attr = node[:title] ? %( title="#{Renderer.escape_attr(node[:title])}") : ""
|
|
199
|
+
%(<a href="#{Renderer.escape_attr(url)}"#{title_attr}>#{render_children(node)}</a>)
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def render_image(node)
|
|
203
|
+
url = @image_resolver.call(node[:url].to_s, node)
|
|
204
|
+
title_attr = node[:title] ? %( title="#{Renderer.escape_attr(node[:title])}") : ""
|
|
205
|
+
%(<img src="#{Renderer.escape_attr(url)}" alt="#{Renderer.escape_attr(node[:alt])}"#{title_attr} />)
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def render_link_reference(node)
|
|
209
|
+
definition = @definitions[node[:identifier].to_s.downcase]
|
|
210
|
+
return render_children(node) unless definition
|
|
211
|
+
|
|
212
|
+
url = @link_resolver.call(definition[:url].to_s, node)
|
|
213
|
+
title_attr = definition[:title] ? %( title="#{Renderer.escape_attr(definition[:title])}") : ""
|
|
214
|
+
%(<a href="#{Renderer.escape_attr(url)}"#{title_attr}>#{render_children(node)}</a>)
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def render_image_reference(node)
|
|
218
|
+
definition = @definitions[node[:identifier].to_s.downcase]
|
|
219
|
+
return "" unless definition
|
|
220
|
+
|
|
221
|
+
url = @image_resolver.call(definition[:url].to_s, node)
|
|
222
|
+
title_attr = definition[:title] ? %( title="#{Renderer.escape_attr(definition[:title])}") : ""
|
|
223
|
+
%(<img src="#{Renderer.escape_attr(url)}" alt="#{Renderer.escape_attr(node[:alt])}"#{title_attr} />)
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
def render_footnote_reference(node)
|
|
227
|
+
id = node[:identifier].to_s.downcase
|
|
228
|
+
@footnote_order << id unless @footnote_order.include?(id)
|
|
229
|
+
index = @footnote_order.index(id) + 1
|
|
230
|
+
|
|
231
|
+
@footnote_ref_counts[id] += 1
|
|
232
|
+
suffix = @footnote_ref_counts[id] == 1 ? "" : "-#{@footnote_ref_counts[id]}"
|
|
233
|
+
|
|
234
|
+
%(<sup><a href="#user-content-fn-#{id}" id="user-content-fnref-#{id}#{suffix}" ) +
|
|
235
|
+
%(data-footnote-ref="" aria-describedby="footnote-label">#{index}</a></sup>)
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
def render_footnotes_section
|
|
239
|
+
items = @footnote_order.filter_map { |id|
|
|
240
|
+
definition = @footnote_definitions[id]
|
|
241
|
+
render_footnote_definition_li(id, definition) if definition
|
|
242
|
+
}.join
|
|
243
|
+
|
|
244
|
+
"<section data-footnotes=\"\" class=\"footnotes\">\n" \
|
|
245
|
+
"<h2 id=\"footnote-label\" class=\"sr-only\">Footnotes</h2>\n" \
|
|
246
|
+
"<ol>\n#{items}</ol>\n</section>\n"
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
def render_footnote_definition_li(id, node)
|
|
250
|
+
children = node[:children] || []
|
|
251
|
+
backrefs = footnote_backrefs(id)
|
|
252
|
+
body_parts = children.map { |child| render_node(child) }
|
|
253
|
+
|
|
254
|
+
if children.last && children.last[:type] == "paragraph" && body_parts.last.end_with?("</p>\n")
|
|
255
|
+
body_parts[-1] = "#{body_parts.last.sub(/<\/p>\n\z/, "")}#{backrefs}</p>\n"
|
|
256
|
+
else
|
|
257
|
+
body_parts << "<p>#{backrefs}</p>\n"
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
"<li id=\"user-content-fn-#{id}\">\n#{body_parts.join}</li>\n"
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
def footnote_backrefs(id)
|
|
264
|
+
count = @footnote_ref_counts[id]
|
|
265
|
+
return "" if count.nil? || count.zero?
|
|
266
|
+
|
|
267
|
+
(1..count).map { |n|
|
|
268
|
+
suffix = n == 1 ? "" : "-#{n}"
|
|
269
|
+
label = n == 1 ? "Back to reference 1" : "Back to reference 1-#{n}"
|
|
270
|
+
%( <a href="#user-content-fnref-#{id}#{suffix}" data-footnote-backref="" ) +
|
|
271
|
+
%(aria-label="#{label}" class="data-footnote-backref">↩</a>)
|
|
272
|
+
}.join
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
def render_table(node)
|
|
276
|
+
aligns = node[:align] || []
|
|
277
|
+
rows = node[:children] || []
|
|
278
|
+
return "<table></table>\n" if rows.empty?
|
|
279
|
+
|
|
280
|
+
header = render_table_row(rows[0], aligns, header: true)
|
|
281
|
+
body = rows[1..].map { |row| render_table_row(row, aligns, header: false) }.join
|
|
282
|
+
"<table>\n<thead>\n#{header}</thead>\n<tbody>\n#{body}</tbody>\n</table>\n"
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
def render_table_row(node, aligns, header:)
|
|
286
|
+
tag = header ? "th" : "td"
|
|
287
|
+
cells = (node[:children] || []).each_with_index.map { |cell, index|
|
|
288
|
+
align = aligns[index]
|
|
289
|
+
style_attr = align ? %( style="text-align:#{align}") : ""
|
|
290
|
+
"<#{tag}#{style_attr}>#{render_children(cell)}</#{tag}>"
|
|
291
|
+
}.join
|
|
292
|
+
"<tr>#{cells}</tr>\n"
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
def render_jsx_element(node)
|
|
296
|
+
name = node[:name]
|
|
297
|
+
return render_children(node) if name.nil? # fragment: <>...</>
|
|
298
|
+
|
|
299
|
+
component_tag?(name) ? render_component(node, name) : render_html_element(node, name)
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
# MDX's own rule: a tag is a component reference (not a plain HTML
|
|
303
|
+
# element) if its name starts with an uppercase letter or contains a
|
|
304
|
+
# `.` (member expression, e.g. `<Tabs.Item>`).
|
|
305
|
+
def component_tag?(name)
|
|
306
|
+
name.start_with?(/[A-Z]/) || name.include?(".")
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
def render_component(node, name)
|
|
310
|
+
unless @components
|
|
311
|
+
position = node[:position] || {}
|
|
312
|
+
start = position[:start] || {}
|
|
313
|
+
raise MissingComponentError.new(name, line: start[:line], column: start[:column])
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
@components.render(name, jsx_attributes(node), render_children(node), node)
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
def render_html_element(node, name)
|
|
320
|
+
attrs = jsx_attributes(node).map { |key, value| html_attribute(key, value) }.join
|
|
321
|
+
"<#{name}#{attrs}>#{render_children(node)}</#{name}>"
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
def html_attribute(name, value)
|
|
325
|
+
case value
|
|
326
|
+
when true then %( #{name}="")
|
|
327
|
+
when false, nil then "" # JSX/HTML convention: a falsy prop means "omit the attribute"
|
|
328
|
+
else %( #{name}="#{Renderer.escape_attr(value)}")
|
|
329
|
+
end
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
# @return [Hash{String => Object}] attribute name -> evaluated value.
|
|
333
|
+
# Values are already evaluated where they are literals; a
|
|
334
|
+
# non-literal expression's raw source string is used as a fallback
|
|
335
|
+
# (see `LiteralExpression`).
|
|
336
|
+
def jsx_attributes(node)
|
|
337
|
+
(node[:attributes] || []).each_with_object({}) do |attribute, out|
|
|
338
|
+
case attribute[:type]
|
|
339
|
+
when "mdxJsxAttribute"
|
|
340
|
+
out[attribute[:name]] = jsx_attribute_value(attribute[:value])
|
|
341
|
+
when "mdxJsxExpressionAttribute"
|
|
342
|
+
# Spread ({...props}): expanding it needs a bound `props` value,
|
|
343
|
+
# which does not exist until the components collaborator resolves
|
|
344
|
+
# imports/frontmatter bindings. Skipped rather than raised so the
|
|
345
|
+
# component still
|
|
346
|
+
# renders with its explicit props in the meantime.
|
|
347
|
+
next
|
|
348
|
+
end
|
|
349
|
+
end
|
|
350
|
+
end
|
|
351
|
+
|
|
352
|
+
def jsx_attribute_value(value)
|
|
353
|
+
case value
|
|
354
|
+
when nil then true # bare boolean prop: <Toggle enabled />
|
|
355
|
+
when String then value
|
|
356
|
+
when Hash
|
|
357
|
+
value[:type] == "mdxJsxAttributeValueExpression" ? LiteralExpression.evaluate(value[:value]) : value
|
|
358
|
+
else
|
|
359
|
+
value
|
|
360
|
+
end
|
|
361
|
+
end
|
|
362
|
+
|
|
363
|
+
def render_expression(node)
|
|
364
|
+
value = node[:value].to_s
|
|
365
|
+
trimmed = value.strip
|
|
366
|
+
return "" if trimmed.start_with?("/*") && trimmed.end_with?("*/")
|
|
367
|
+
|
|
368
|
+
literal, matched = literal_expression(value)
|
|
369
|
+
return Renderer.escape_html(stringify_expression_value(literal)) if matched
|
|
370
|
+
|
|
371
|
+
# The `Andromeda::Components` collaborator knows how to evaluate
|
|
372
|
+
# `{frontmatter.x}` and raise a clear error for anything
|
|
373
|
+
# else in the static-expression subset -- it has file/line
|
|
374
|
+
# context and frontmatter data this stateless renderer does not carry.
|
|
375
|
+
# Without a components collaborator that understands expressions
|
|
376
|
+
# (e.g. a test double that only implements `#render`), fall back to
|
|
377
|
+
# a visible marker rather than raising, so plain
|
|
378
|
+
# Markdown/MDX rendering still works without wiring one up.
|
|
379
|
+
if @components.respond_to?(:evaluate_expression)
|
|
380
|
+
position = node[:position] || {}
|
|
381
|
+
start = position[:start] || {}
|
|
382
|
+
evaluated = @components.evaluate_expression(value, line: start[:line], column: start[:column])
|
|
383
|
+
return Renderer.escape_html(stringify_expression_value(evaluated))
|
|
384
|
+
end
|
|
385
|
+
|
|
386
|
+
"<!-- andromeda: unevaluated MDX expression: #{Renderer.escape_html(value)} -->"
|
|
387
|
+
end
|
|
388
|
+
|
|
389
|
+
# Tries the same literal grammar `LiteralExpression` uses, but reports
|
|
390
|
+
# whether the whole source parsed as a literal instead of silently
|
|
391
|
+
# handing back the original string on failure -- unlike
|
|
392
|
+
# `LiteralExpression.evaluate` (kept as-is for attribute values, whose
|
|
393
|
+
# existing callers/tests rely on "fall back to raw source" rather than
|
|
394
|
+
# raising or marking).
|
|
395
|
+
def literal_expression(source)
|
|
396
|
+
parser = LiteralExpression::Parser.new(source)
|
|
397
|
+
value = parser.parse_value
|
|
398
|
+
parser.skip_ws
|
|
399
|
+
parser.eof? ? [value, true] : [nil, false]
|
|
400
|
+
rescue LiteralExpression::Parser::Error
|
|
401
|
+
[nil, false]
|
|
402
|
+
end
|
|
403
|
+
|
|
404
|
+
def stringify_expression_value(value)
|
|
405
|
+
case value
|
|
406
|
+
when nil then ""
|
|
407
|
+
else value.to_s
|
|
408
|
+
end
|
|
409
|
+
end
|
|
410
|
+
end
|
|
411
|
+
end
|