andromeda_cms 0.1.0-x64-mingw-ucrt
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.so +0 -0
- data/lib/andromeda_cms/3.3/andromeda_cms.so +0 -0
- data/lib/andromeda_cms/3.4/andromeda_cms.so +0 -0
- data/lib/andromeda_cms/4.0/andromeda_cms.so +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,72 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Andromeda
|
|
4
|
+
class Components
|
|
5
|
+
# Reads `mdxjsEsm` nodes out of an mdast tree and extracts a
|
|
6
|
+
# `name -> import path` map, so `Andromeda::Components` can resolve a
|
|
7
|
+
# component tag by the path it was imported from (second in the
|
|
8
|
+
# precedence order: explicit registration > import path >
|
|
9
|
+
# naming convention).
|
|
10
|
+
#
|
|
11
|
+
# This is deliberately not a JS parser: MDX import statements are a tiny,
|
|
12
|
+
# regular subset of ES module syntax (default imports and named imports,
|
|
13
|
+
# optionally `as`-renamed), and Sätteri has already validated the source
|
|
14
|
+
# is syntactically valid MDX by the time an `mdxjsEsm` node exists at
|
|
15
|
+
# all. A handful of regexes over that node's raw text is enough to cover
|
|
16
|
+
# every import shape Astro's compiler recognizes, without carrying a JS
|
|
17
|
+
# grammar around for it.
|
|
18
|
+
module ImportScanner
|
|
19
|
+
# `import Foo from '...'` / `import Foo from "..."`.
|
|
20
|
+
DEFAULT_IMPORT_RE = /import\s+([A-Za-z_$][\w$]*)\s+from\s+(['"])(.*?)\2/.freeze
|
|
21
|
+
|
|
22
|
+
# `import { A, B as C } from '...'`.
|
|
23
|
+
NAMED_IMPORT_RE = /import\s*\{([^}]*)\}\s*from\s+(['"])(.*?)\2/.freeze
|
|
24
|
+
|
|
25
|
+
NAMED_BINDING_RE = /\A([A-Za-z_$][\w$]*)(?:\s+as\s+([A-Za-z_$][\w$]*))?\z/.freeze
|
|
26
|
+
|
|
27
|
+
module_function
|
|
28
|
+
|
|
29
|
+
# @param tree [Hash] an mdast root node.
|
|
30
|
+
# @return [Hash{String => String}] the name a component tag would use
|
|
31
|
+
# in the document, mapped to the raw import path/specifier. An
|
|
32
|
+
# `import cover from './cover.png'` (image imports, deferred) ends
|
|
33
|
+
# up here too -- harmless, since it is only ever consulted when that name is
|
|
34
|
+
# later used as a component tag, and image imports never are.
|
|
35
|
+
def scan(tree)
|
|
36
|
+
imports = {}
|
|
37
|
+
|
|
38
|
+
each_esm_source(tree) do |source|
|
|
39
|
+
source.scan(DEFAULT_IMPORT_RE) { |name, _quote, path| imports[name] = path }
|
|
40
|
+
|
|
41
|
+
source.scan(NAMED_IMPORT_RE) do |bindings, _quote, path|
|
|
42
|
+
bindings.split(",").each do |binding|
|
|
43
|
+
binding = binding.strip
|
|
44
|
+
next if binding.empty?
|
|
45
|
+
|
|
46
|
+
match = NAMED_BINDING_RE.match(binding)
|
|
47
|
+
next unless match
|
|
48
|
+
|
|
49
|
+
local_name = match[2] || match[1]
|
|
50
|
+
imports[local_name] = path
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
imports
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Walks the whole tree (not just `root.children`) so this keeps
|
|
59
|
+
# working even if a future Sätteri version nests `mdxjsEsm` nodes
|
|
60
|
+
# somewhere other than the top level; valid MDX only ever has them at
|
|
61
|
+
# the top level today, so in practice this is a single pass over a
|
|
62
|
+
# short list.
|
|
63
|
+
def each_esm_source(node, &block)
|
|
64
|
+
return unless node.is_a?(Hash)
|
|
65
|
+
|
|
66
|
+
yield node[:value].to_s if node[:type] == "mdxjsEsm"
|
|
67
|
+
(node[:children] || []).each { |child| each_esm_source(child, &block) }
|
|
68
|
+
end
|
|
69
|
+
private_class_method :each_esm_source
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../renderer/literal_expression"
|
|
4
|
+
|
|
5
|
+
module Andromeda
|
|
6
|
+
class Components
|
|
7
|
+
# The v0 "static expression" evaluator: the only
|
|
8
|
+
# `{...}` expressions Andromeda ever evaluates are bare literals, a
|
|
9
|
+
# `frontmatter.x` (or `frontmatter.a.b`) property chain, and MDX
|
|
10
|
+
# comments. Everything else raises `UnsupportedExpressionError` rather
|
|
11
|
+
# than silently degrading -- unlike
|
|
12
|
+
# `Andromeda::Renderer::LiteralExpression`, which exists specifically to
|
|
13
|
+
# give the *renderer* something to fall back to when it has no
|
|
14
|
+
# frontmatter/error-reporting context of its own: `.map()`, ternaries,
|
|
15
|
+
# `await`, arrow functions, and template interpolation are all
|
|
16
|
+
# unsupported and deliberately out of scope for both.
|
|
17
|
+
module StaticExpression
|
|
18
|
+
UnsupportedExpressionError = Class.new(StandardError)
|
|
19
|
+
|
|
20
|
+
FRONTMATTER_PATH_RE = /\Afrontmatter(\.[A-Za-z_$][\w$]*)+\z/.freeze
|
|
21
|
+
|
|
22
|
+
module_function
|
|
23
|
+
|
|
24
|
+
# @param source [String] raw text between `{` and `}`.
|
|
25
|
+
# @param frontmatter [Hash] the entry's validated frontmatter data
|
|
26
|
+
# (symbol or string keyed; `Andromeda::Schema#validate!` produces
|
|
27
|
+
# symbol keys -- see lib/andromeda/schema.rb -- but string keys are
|
|
28
|
+
# accepted too so this doesn't care which the caller has on hand).
|
|
29
|
+
# @return [Object] the evaluated value.
|
|
30
|
+
# @raise [UnsupportedExpressionError] if `source` is not a literal, a
|
|
31
|
+
# `frontmatter.x` reference, or a comment.
|
|
32
|
+
def evaluate(source, frontmatter: {})
|
|
33
|
+
trimmed = source.to_s.strip
|
|
34
|
+
return "" if comment?(trimmed)
|
|
35
|
+
|
|
36
|
+
literal, matched = try_literal(source)
|
|
37
|
+
return literal if matched
|
|
38
|
+
|
|
39
|
+
return dig_frontmatter(frontmatter, trimmed.split(".")[1..]) if FRONTMATTER_PATH_RE.match?(trimmed)
|
|
40
|
+
|
|
41
|
+
raise UnsupportedExpressionError, source
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def comment?(trimmed)
|
|
45
|
+
trimmed.start_with?("/*") && trimmed.end_with?("*/")
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Runs the same recursive-descent grammar `LiteralExpression` uses,
|
|
49
|
+
# but -- unlike `LiteralExpression.evaluate` -- reports whether the
|
|
50
|
+
# whole source was consumed as a literal instead of silently handing
|
|
51
|
+
# back the original string on failure; that distinction is exactly
|
|
52
|
+
# what tells this module whether to move on to the frontmatter/error
|
|
53
|
+
# path instead of treating a non-literal string as if it evaluated to
|
|
54
|
+
# itself.
|
|
55
|
+
def try_literal(source)
|
|
56
|
+
parser = Andromeda::Renderer::LiteralExpression::Parser.new(source)
|
|
57
|
+
value = parser.parse_value
|
|
58
|
+
parser.skip_ws
|
|
59
|
+
parser.eof? ? [value, true] : [nil, false]
|
|
60
|
+
rescue Andromeda::Renderer::LiteralExpression::Parser::Error
|
|
61
|
+
[nil, false]
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# @param frontmatter [Hash]
|
|
65
|
+
# @param segments [Array<String>] property names after `frontmatter.`.
|
|
66
|
+
# @return [Object, nil] `nil` for a missing key, mirroring JS
|
|
67
|
+
# `undefined` property access rather than raising -- `{frontmatter.x}`
|
|
68
|
+
# for an optional field that is absent should render as empty, not
|
|
69
|
+
# blow up the whole page.
|
|
70
|
+
def dig_frontmatter(frontmatter, segments)
|
|
71
|
+
segments.reduce(frontmatter) do |value, segment|
|
|
72
|
+
break nil if value.nil? || !value.is_a?(Hash)
|
|
73
|
+
|
|
74
|
+
value.key?(segment.to_sym) ? value[segment.to_sym] : value[segment]
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "components/errors"
|
|
4
|
+
require_relative "components/import_scanner"
|
|
5
|
+
require_relative "components/static_expression"
|
|
6
|
+
|
|
7
|
+
module Andromeda
|
|
8
|
+
# The `components:` collaborator `Andromeda::Renderer` delegates
|
|
9
|
+
# MDX component tags to (`#render(name, attributes, children_html, node)`,
|
|
10
|
+
# see renderer.rb's `render_component`). Resolves a tag name to a Rails
|
|
11
|
+
# partial under `Andromeda.config.components_path` and
|
|
12
|
+
# renders it with `ApplicationController.renderer`, turning props
|
|
13
|
+
# into locals (camelCase -> snake_case), the already-rendered
|
|
14
|
+
# children into a `content` local, and named slots into locals of their
|
|
15
|
+
# own.
|
|
16
|
+
#
|
|
17
|
+
# One instance is scoped to a single content file: `tree` is needed to
|
|
18
|
+
# collect its import bindings once up front (rather than re-scanning per
|
|
19
|
+
# tag), and `path`/`frontmatter` exist purely to make error messages and
|
|
20
|
+
# `{frontmatter.x}` expressions file-specific.
|
|
21
|
+
class Components
|
|
22
|
+
# @param tree [Hash] the mdast root for the file being rendered, so
|
|
23
|
+
# import bindings (`mdxjsEsm` nodes) can be collected once up front.
|
|
24
|
+
# @param path [String, nil] the content file's path, for error messages.
|
|
25
|
+
# @param view [#render, nil] anything responding to
|
|
26
|
+
# `#render(partial:, locals:)`. Defaults to
|
|
27
|
+
# `ApplicationController.renderer` (a fresh instance per `Components`,
|
|
28
|
+
# since renderer instances are not meant to be reused across
|
|
29
|
+
# concurrent builds) when
|
|
30
|
+
# Rails is loaded; `nil` outside Rails unless the caller supplies one,
|
|
31
|
+
# in which case rendering a component raises `NoRendererAvailableError`
|
|
32
|
+
# (see components/errors.rb -- there is no non-Rails ActionView
|
|
33
|
+
# fallback in v0).
|
|
34
|
+
# @param frontmatter [Hash, nil] the entry's validated frontmatter data,
|
|
35
|
+
# consulted for `{frontmatter.x}` expressions. Not part of
|
|
36
|
+
# the original contract this class was written against, so it defaults
|
|
37
|
+
# to `{}` and callers that don't pass it simply get `nil` back for
|
|
38
|
+
# every `frontmatter.x` reference instead of an error.
|
|
39
|
+
def initialize(tree:, path: nil, view: nil, frontmatter: {})
|
|
40
|
+
@tree = tree
|
|
41
|
+
@path = path
|
|
42
|
+
@frontmatter = frontmatter || {}
|
|
43
|
+
@view = view || default_view
|
|
44
|
+
@imports = ImportScanner.scan(tree)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# @param name [String] the JSX tag name, e.g. `"Callout"` or
|
|
48
|
+
# `"Tabs.Item"` (member expressions reach here too -- see
|
|
49
|
+
# `resolve_partial`).
|
|
50
|
+
# @param _attributes [Hash] the renderer's own already-evaluated
|
|
51
|
+
# attribute hash. Unused here: it cannot tell a genuine string prop
|
|
52
|
+
# apart from a non-literal expression's raw-source fallback (both are
|
|
53
|
+
# plain Ruby Strings by the time they reach it), so props are instead
|
|
54
|
+
# computed straight from `node[:attributes]` below, which still has
|
|
55
|
+
# that distinction (`mdxJsxAttributeValueExpression` vs a bare
|
|
56
|
+
# String). Kept in the signature only to match the contract the
|
|
57
|
+
# renderer already calls this with.
|
|
58
|
+
# @param children_html [String] the tag's children, already rendered to
|
|
59
|
+
# HTML by the renderer (Markdown included).
|
|
60
|
+
# @param node [Hash] the raw `mdxJsxFlowElement`/`mdxJsxTextElement` mdast
|
|
61
|
+
# node, needed for its attributes, children (for slot-splitting), and
|
|
62
|
+
# source position (for error messages).
|
|
63
|
+
# @return [String] the rendered partial's HTML.
|
|
64
|
+
def render(name, _attributes, children_html, node)
|
|
65
|
+
# `<Fragment>` (explicit tag, not the `<>...</>` shorthand the renderer
|
|
66
|
+
# already unwraps on its own) is JSX's no-op grouping wrapper -- most
|
|
67
|
+
# commonly seen as `<Fragment slot="header">...</Fragment>`.
|
|
68
|
+
# It never resolves to a partial; it passes its children through
|
|
69
|
+
# unchanged. This also matters structurally: the renderer has already
|
|
70
|
+
# recursively rendered every nested component tag (including this
|
|
71
|
+
# one) via `render_children` *before* calling the parent's
|
|
72
|
+
# `#render` below, so by the time `build_slots_and_content` inspects
|
|
73
|
+
# a slotted `<Fragment>` child for its own raw nodes, this tag itself
|
|
74
|
+
# must not have tried (and failed) to resolve a "Fragment" partial.
|
|
75
|
+
return html_safe(children_html) if name == "Fragment"
|
|
76
|
+
|
|
77
|
+
raise NoRendererAvailableError, @path unless @view
|
|
78
|
+
|
|
79
|
+
partial = resolve_partial(name, node)
|
|
80
|
+
locals = build_props(node).merge(build_slots_and_content(node, children_html))
|
|
81
|
+
|
|
82
|
+
render_partial(partial, locals, name: name, node: node)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# The renderer's `render_expression` calls this for every
|
|
86
|
+
# `mdxFlowExpression`/`mdxTextExpression` it cannot resolve as a bare
|
|
87
|
+
# literal or comment on its own (see renderer.rb) -- i.e. anything left
|
|
88
|
+
# that specifically needs frontmatter or file/line context to evaluate
|
|
89
|
+
# or to error out on, which only this class (not the stateless
|
|
90
|
+
# `Renderer`) has.
|
|
91
|
+
#
|
|
92
|
+
# @param source [String] raw text between `{` and `}`.
|
|
93
|
+
# @param line [Integer, nil] / @param column [Integer, nil] the
|
|
94
|
+
# expression node's source position, for the error message.
|
|
95
|
+
# @return [Object] the evaluated value.
|
|
96
|
+
# @raise [UnsupportedExpressionError]
|
|
97
|
+
def evaluate_expression(source, line: nil, column: nil)
|
|
98
|
+
StaticExpression.evaluate(source, frontmatter: @frontmatter)
|
|
99
|
+
rescue StaticExpression::UnsupportedExpressionError
|
|
100
|
+
raise UnsupportedExpressionError.new(source, path: @path, line: line, column: column)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
class << self
|
|
104
|
+
# Explicit registration (top precedence tier): `name` can be
|
|
105
|
+
# any tag spelling, including a member expression (`"Tabs.Item"`)
|
|
106
|
+
# that naming-convention resolution refuses to guess at.
|
|
107
|
+
#
|
|
108
|
+
# @param name [String, Symbol]
|
|
109
|
+
# @param partial [String] a Rails partial path, e.g.
|
|
110
|
+
# `"content_components/callout"`.
|
|
111
|
+
def register(name, partial)
|
|
112
|
+
registrations[name.to_s] = partial.to_s
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# @return [String, nil]
|
|
116
|
+
def registered(name)
|
|
117
|
+
registrations[name.to_s]
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Test-only reset, mirroring `Andromeda::Registry.clear!`.
|
|
121
|
+
def clear_registrations!
|
|
122
|
+
@registrations = {}
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# camelCase -> snake_case, used for prop names, slot names, and
|
|
126
|
+
# the naming-convention partial lookup (`YouTube` -> `you_tube`).
|
|
127
|
+
# Hand-rolled rather than pulled in from ActiveSupport's
|
|
128
|
+
# `String#underscore` so this file has no load-order dependency on
|
|
129
|
+
# `active_support/core_ext` being required anywhere -- the algorithm
|
|
130
|
+
# is the same one ActiveSupport::Inflector uses for the common case.
|
|
131
|
+
#
|
|
132
|
+
# @param name [String, Symbol]
|
|
133
|
+
# @return [String]
|
|
134
|
+
def underscore(name)
|
|
135
|
+
name.to_s
|
|
136
|
+
.gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
|
|
137
|
+
.gsub(/([a-z0-9])([A-Z])/, '\1_\2')
|
|
138
|
+
.tr("-", "_")
|
|
139
|
+
.downcase
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
private
|
|
143
|
+
|
|
144
|
+
def registrations
|
|
145
|
+
@registrations ||= {}
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
private
|
|
150
|
+
|
|
151
|
+
# @return [String, nil]
|
|
152
|
+
def resolve_partial(name, node)
|
|
153
|
+
return self.class.registered(name) if self.class.registered(name)
|
|
154
|
+
|
|
155
|
+
if name.include?(".")
|
|
156
|
+
position = node_position(node)
|
|
157
|
+
raise InvalidComponentNameError.new(name, path: @path, line: position[:line])
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
import_path = @imports[name]
|
|
161
|
+
return import_path if import_path && partial_exists?(import_path)
|
|
162
|
+
|
|
163
|
+
convention_partial(name)
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def convention_partial(name)
|
|
167
|
+
base = Andromeda.config.components_path.sub(%r{\Aapp/views/}, "")
|
|
168
|
+
"#{base}/#{self.class.underscore(name)}"
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# @return [Hash{Symbol => Object}] props, camelCase -> snake_case,
|
|
172
|
+
# values evaluated per `StaticExpression` (raising for a
|
|
173
|
+
# non-literal/non-frontmatter expression, per the components
|
|
174
|
+
# contract -- unlike `Renderer#jsx_attributes`, which silently falls
|
|
175
|
+
# back to the raw source for the renderer's own, frontmatter-less use
|
|
176
|
+
# case).
|
|
177
|
+
def build_props(node)
|
|
178
|
+
(node[:attributes] || []).each_with_object({}) do |attribute, props|
|
|
179
|
+
next unless attribute[:type] == "mdxJsxAttribute"
|
|
180
|
+
|
|
181
|
+
key = self.class.underscore(attribute[:name]).to_sym
|
|
182
|
+
props[key] = attribute_value(attribute[:value], node)
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def attribute_value(value, node)
|
|
187
|
+
case value
|
|
188
|
+
when nil then true # bare boolean prop: <Toggle enabled />
|
|
189
|
+
when String then value
|
|
190
|
+
when Hash
|
|
191
|
+
return value unless value[:type] == "mdxJsxAttributeValueExpression"
|
|
192
|
+
|
|
193
|
+
evaluate_prop_expression(value[:value], node)
|
|
194
|
+
else
|
|
195
|
+
value
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def evaluate_prop_expression(source, node)
|
|
200
|
+
StaticExpression.evaluate(source, frontmatter: @frontmatter)
|
|
201
|
+
rescue StaticExpression::UnsupportedExpressionError
|
|
202
|
+
position = node_position(node)
|
|
203
|
+
raise UnsupportedExpressionError.new(source, path: @path, line: position[:line], column: position[:column])
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
# @return [Hash{Symbol => String}] `{content: ...}` plus one entry per
|
|
207
|
+
# named slot. When no child carries a `slot` attribute this
|
|
208
|
+
# is just `{content: children_html}` -- the common case reuses the
|
|
209
|
+
# renderer's already-rendered `children_html` instead of re-rendering
|
|
210
|
+
# anything. Slotted content needs its own render pass because
|
|
211
|
+
# `children_html` is one flattened string with no seam between "goes
|
|
212
|
+
# in a slot" and "goes in `content`".
|
|
213
|
+
def build_slots_and_content(node, children_html)
|
|
214
|
+
children = node[:children] || []
|
|
215
|
+
slotted, rest = children.partition { |child| slot_name_of(child) }
|
|
216
|
+
return { content: html_safe(children_html) } if slotted.empty?
|
|
217
|
+
|
|
218
|
+
locals = slotted.each_with_object({}) do |child, out|
|
|
219
|
+
key = self.class.underscore(slot_name_of(child)).to_sym
|
|
220
|
+
out[key] = render_subtree(child[:children] || [])
|
|
221
|
+
end
|
|
222
|
+
locals[:content] = render_subtree(rest)
|
|
223
|
+
locals
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
# `<Fragment slot="header">`/`<div slot="footer">`: any direct JSX child
|
|
227
|
+
# element carrying a `slot="..."` attribute names a slot; the wrapper
|
|
228
|
+
# element itself (Fragment or otherwise) is not rendered, only its
|
|
229
|
+
# children -- matching Astro's named-slot semantics, where `slot` is a
|
|
230
|
+
# routing instruction, not a real DOM node.
|
|
231
|
+
def slot_name_of(child)
|
|
232
|
+
return nil unless %w[mdxJsxFlowElement mdxJsxTextElement].include?(child[:type])
|
|
233
|
+
|
|
234
|
+
attribute = (child[:attributes] || []).find { |a| a[:type] == "mdxJsxAttribute" && a[:name] == "slot" }
|
|
235
|
+
attribute && attribute[:value].is_a?(String) ? attribute[:value] : nil
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
# A fresh child-nodes-only "document" handed back through the full
|
|
239
|
+
# renderer (with `self` as its `components:` collaborator, so nested
|
|
240
|
+
# components inside a slot still resolve). Note this resets heading/
|
|
241
|
+
# footnote bookkeeping for the subtree (`Renderer#render` is
|
|
242
|
+
# per-call-stateless by design -- see renderer.rb) -- slot content
|
|
243
|
+
# containing a heading would get a slug numbered from scratch rather
|
|
244
|
+
# than continuing the parent document's sequence. Slots holding
|
|
245
|
+
# headings are rare enough in practice that this is an acceptable v0
|
|
246
|
+
# gap rather than a reason to restructure the renderer's per-document
|
|
247
|
+
# state around mid-tree re-entrancy.
|
|
248
|
+
def render_subtree(children_nodes)
|
|
249
|
+
html_safe(sub_renderer.render({ type: "root", children: children_nodes }).html)
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
def sub_renderer
|
|
253
|
+
@sub_renderer ||= Andromeda::Renderer.new(components: self)
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
def render_partial(partial, locals, name:, node:)
|
|
257
|
+
annotate_before = suppress_view_annotation
|
|
258
|
+
begin
|
|
259
|
+
@view.render(partial: partial, locals: locals).html_safe
|
|
260
|
+
rescue ActionView::MissingTemplate
|
|
261
|
+
raise missing_partial_error(name, node, partial)
|
|
262
|
+
ensure
|
|
263
|
+
restore_view_annotation(annotate_before)
|
|
264
|
+
end
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
# Rails' dev-mode "annotate rendered views with
|
|
268
|
+
# filenames" feature wraps every partial (including ones rendered
|
|
269
|
+
# through `ApplicationController.renderer`, with no live request) in
|
|
270
|
+
# `<!-- BEGIN ... -->`/`<!-- END ... -->` HTML comments. Fine for a
|
|
271
|
+
# normal request/response cycle; fatal here, since this HTML is baked
|
|
272
|
+
# once into `.andromeda/*.json` and served forever after -- the comments
|
|
273
|
+
# would leak into production output. Toggling the *class* attribute
|
|
274
|
+
# (`ActionView::Base.annotate_rendered_view_with_filenames=`) is what
|
|
275
|
+
# actually controls this, not
|
|
276
|
+
# `Rails.application.config.action_view.annotate_rendered_view_with_filenames`
|
|
277
|
+
# (that config value is only read once, at initialization, to set the
|
|
278
|
+
# class attribute's *initial* value).
|
|
279
|
+
def suppress_view_annotation
|
|
280
|
+
return nil unless defined?(ActionView::Base)
|
|
281
|
+
|
|
282
|
+
previous = ActionView::Base.annotate_rendered_view_with_filenames
|
|
283
|
+
ActionView::Base.annotate_rendered_view_with_filenames = false
|
|
284
|
+
previous
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
def restore_view_annotation(previous)
|
|
288
|
+
return unless defined?(ActionView::Base)
|
|
289
|
+
|
|
290
|
+
ActionView::Base.annotate_rendered_view_with_filenames = previous
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
def missing_partial_error(name, node, partial)
|
|
294
|
+
position = node_position(node)
|
|
295
|
+
MissingPartialError.new(name, path: @path, line: position[:line], expected_path: expected_partial_path(partial))
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
def expected_partial_path(partial)
|
|
299
|
+
dir = File.dirname(partial)
|
|
300
|
+
base = File.basename(partial)
|
|
301
|
+
dir == "." ? "app/views/_#{base}.html.erb" : "app/views/#{dir}/_#{base}.html.erb"
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
# File-existence check under the conventional Rails view root, used
|
|
305
|
+
# only to decide whether an import path "points at a partial"
|
|
306
|
+
# and should therefore win over naming-convention resolution. This
|
|
307
|
+
# deliberately doesn't ask `@view`/`ActionView::LookupContext` (which
|
|
308
|
+
# would also honor custom `prepend_view_path`/engine view paths) --
|
|
309
|
+
# `andromeda:import_astro` always rewrites import paths to plain
|
|
310
|
+
# `app/views`-relative partial paths, so the simpler disk check is
|
|
311
|
+
# enough for what v0 needs to resolve, and an actually-missing partial
|
|
312
|
+
# still gets a clear error at render time either way.
|
|
313
|
+
def partial_exists?(partial)
|
|
314
|
+
dir = File.dirname(partial)
|
|
315
|
+
base = File.basename(partial)
|
|
316
|
+
views_root = File.join(Andromeda.config.project_root, "app/views")
|
|
317
|
+
|
|
318
|
+
%w[html.erb erb html.slim slim html.haml haml].any? do |ext|
|
|
319
|
+
candidate = File.expand_path(File.join(views_root, dir, "_#{base}.#{ext}"))
|
|
320
|
+
# ActionView would refuse to render a partial outside app/views
|
|
321
|
+
# anyway; checking first keeps an `import` of `../../config/x` from
|
|
322
|
+
# probing whether arbitrary files exist.
|
|
323
|
+
candidate.start_with?(views_root + File::SEPARATOR) && File.exist?(candidate)
|
|
324
|
+
end
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
def node_position(node)
|
|
328
|
+
position = node[:position] || {}
|
|
329
|
+
start = position[:start] || {}
|
|
330
|
+
{ line: start[:line], column: start[:column] }
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
def html_safe(string)
|
|
334
|
+
string.to_s.html_safe
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
# `ApplicationController.renderer`. A fresh instance (rather
|
|
338
|
+
# than reusing a shared one) per `Components`, and `http_host` set
|
|
339
|
+
# explicitly, because there is no live request to supply one -- a
|
|
340
|
+
# partial calling a URL helper without it can raise or build a bogus
|
|
341
|
+
# host otherwise.
|
|
342
|
+
def default_view
|
|
343
|
+
return nil unless defined?(::ApplicationController)
|
|
344
|
+
|
|
345
|
+
::ApplicationController.renderer.new(http_host: "andromeda-build.internal")
|
|
346
|
+
end
|
|
347
|
+
end
|
|
348
|
+
end
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Andromeda
|
|
4
|
+
# Settings a host application may override, all of them paths or names that
|
|
5
|
+
# only the application knows. Anything that would change how content is
|
|
6
|
+
# interpreted (the parser, GFM, smart punctuation) is deliberately absent:
|
|
7
|
+
# those follow Astro's defaults so copied content behaves the same, and a
|
|
8
|
+
# knob here would quietly break that promise.
|
|
9
|
+
class Configuration
|
|
10
|
+
# Where content files live, relative to the application root.
|
|
11
|
+
attr_accessor :content_path
|
|
12
|
+
|
|
13
|
+
# Namespace of the entry classes, so an app that already defines `Content`
|
|
14
|
+
# can move them aside.
|
|
15
|
+
attr_accessor :entry_namespace
|
|
16
|
+
|
|
17
|
+
# Directory holding the partials MDX components resolve to.
|
|
18
|
+
attr_accessor :components_path
|
|
19
|
+
|
|
20
|
+
# Where `andromeda:build` writes converted content. Hidden and ignored by
|
|
21
|
+
# git, like Astro's own `.astro/`.
|
|
22
|
+
attr_accessor :build_path
|
|
23
|
+
|
|
24
|
+
# Rouge theme used for code blocks; the default mirrors the Shiki theme
|
|
25
|
+
# Astro ships with so copied content keeps roughly the same look.
|
|
26
|
+
attr_accessor :highlight_theme
|
|
27
|
+
|
|
28
|
+
# `:development` (convert on demand, checking each source file's digest)
|
|
29
|
+
# or `:production` (read-only; `Andromeda::BuildMissing` if
|
|
30
|
+
# `andromeda:build` never ran) -- see Andromeda::Pipeline.
|
|
31
|
+
#
|
|
32
|
+
# This has to be decidable without Rails at all, so it is a plain
|
|
33
|
+
# setting rather than something inferred from
|
|
34
|
+
# `Rails.env`. `:development` is the sane default for that
|
|
35
|
+
# Rails-less case -- a `bin/rails runner` script or this gem's own test
|
|
36
|
+
# suite would otherwise get `BuildMissing` for every fixture unless it
|
|
37
|
+
# remembered to build first. The Railtie sets this explicitly from
|
|
38
|
+
# `Rails.env` at boot, so a real app never relies on this default.
|
|
39
|
+
attr_accessor :mode
|
|
40
|
+
|
|
41
|
+
# The boundary an image path may not escape, and the base every relative
|
|
42
|
+
# path is resolved against. Defaults to the Rails root at boot; settable
|
|
43
|
+
# so a non-Rails script (or a test importing into a scratch directory)
|
|
44
|
+
# can point the gem at a different tree.
|
|
45
|
+
attr_writer :project_root
|
|
46
|
+
|
|
47
|
+
# Resolved on every call rather than memoized: code that runs before
|
|
48
|
+
# Rails has set its root (a gem loaded early, a rake task) would otherwise
|
|
49
|
+
# pin the working directory for the rest of the process.
|
|
50
|
+
def project_root
|
|
51
|
+
File.expand_path(@project_root || rails_root || Dir.pwd)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def rails_root
|
|
55
|
+
::Rails.root.to_s if defined?(::Rails) && ::Rails.respond_to?(:root) && ::Rails.root
|
|
56
|
+
end
|
|
57
|
+
private :rails_root
|
|
58
|
+
|
|
59
|
+
def initialize
|
|
60
|
+
@content_path = "app/content"
|
|
61
|
+
@entry_namespace = "Content"
|
|
62
|
+
@components_path = "app/views/content_components"
|
|
63
|
+
@build_path = ".andromeda"
|
|
64
|
+
@highlight_theme = "github.dark"
|
|
65
|
+
@mode = :development
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Entry classes live under the namespace, mirroring Rails' own mapping of
|
|
69
|
+
# `Content::Post` to `app/models/content/post.rb`.
|
|
70
|
+
def entry_class_path
|
|
71
|
+
File.join("app/models", entry_namespace.gsub("::", "/").downcase)
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
class << self
|
|
76
|
+
def config
|
|
77
|
+
@config ||= Configuration.new
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def configure
|
|
81
|
+
yield config
|
|
82
|
+
config
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Test suites need a way back to a known state; applications configure once
|
|
86
|
+
# at boot and never call this.
|
|
87
|
+
def reset_config!
|
|
88
|
+
@config = Configuration.new
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|