andromeda_cms 0.1.0-aarch64-linux

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,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