andromeda_cms 0.1.0-aarch64-linux-musl

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.
Files changed (53) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +30 -0
  3. data/CONTRIBUTING.md +47 -0
  4. data/LICENSE.txt +21 -0
  5. data/README.md +276 -0
  6. data/lib/andromeda/assets.rb +128 -0
  7. data/lib/andromeda/check.rb +71 -0
  8. data/lib/andromeda/components/errors.rb +97 -0
  9. data/lib/andromeda/components/import_scanner.rb +72 -0
  10. data/lib/andromeda/components/static_expression.rb +79 -0
  11. data/lib/andromeda/components.rb +348 -0
  12. data/lib/andromeda/configuration.rb +91 -0
  13. data/lib/andromeda/entry.rb +294 -0
  14. data/lib/andromeda/errors.rb +173 -0
  15. data/lib/andromeda/fix.rb +71 -0
  16. data/lib/andromeda/frontmatter.rb +327 -0
  17. data/lib/andromeda/helpers.rb +84 -0
  18. data/lib/andromeda/id.rb +58 -0
  19. data/lib/andromeda/loader.rb +96 -0
  20. data/lib/andromeda/parser.rb +75 -0
  21. data/lib/andromeda/pipeline.rb +296 -0
  22. data/lib/andromeda/railtie.rb +47 -0
  23. data/lib/andromeda/registry.rb +127 -0
  24. data/lib/andromeda/relation.rb +125 -0
  25. data/lib/andromeda/renderer/code_highlighter.rb +53 -0
  26. data/lib/andromeda/renderer/errors.rb +52 -0
  27. data/lib/andromeda/renderer/literal_expression.rb +201 -0
  28. data/lib/andromeda/renderer.rb +411 -0
  29. data/lib/andromeda/schema.rb +383 -0
  30. data/lib/andromeda/slugger.rb +68 -0
  31. data/lib/andromeda/store.rb +286 -0
  32. data/lib/andromeda/tasks/andromeda.rake +56 -0
  33. data/lib/andromeda/version.rb +5 -0
  34. data/lib/andromeda_cms/3.2/andromeda_cms.so +0 -0
  35. data/lib/andromeda_cms/3.3/andromeda_cms.so +0 -0
  36. data/lib/andromeda_cms/3.4/andromeda_cms.so +0 -0
  37. data/lib/andromeda_cms/4.0/andromeda_cms.so +0 -0
  38. data/lib/andromeda_cms.rb +41 -0
  39. data/lib/generators/andromeda/collection/USAGE +25 -0
  40. data/lib/generators/andromeda/collection/collection_generator.rb +239 -0
  41. data/lib/generators/andromeda/component/USAGE +15 -0
  42. data/lib/generators/andromeda/component/component_generator.rb +75 -0
  43. data/lib/generators/andromeda/import_astro/USAGE +21 -0
  44. data/lib/generators/andromeda/import_astro/astro_schema_json.rb +80 -0
  45. data/lib/generators/andromeda/import_astro/balanced_scanner.rb +168 -0
  46. data/lib/generators/andromeda/import_astro/content_config_converter.rb +232 -0
  47. data/lib/generators/andromeda/import_astro/import_astro_generator.rb +313 -0
  48. data/lib/generators/andromeda/import_astro/mdx_content_scanner.rb +91 -0
  49. data/lib/generators/andromeda/import_astro/mdx_import_rewriter.rb +91 -0
  50. data/lib/generators/andromeda/install/USAGE +11 -0
  51. data/lib/generators/andromeda/install/install_generator.rb +77 -0
  52. data/lib/generators/andromeda/install/templates/initializer.rb +30 -0
  53. 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