andromeda_cms 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +30 -0
  3. data/CONTRIBUTING.md +47 -0
  4. data/Cargo.lock +1182 -0
  5. data/Cargo.toml +12 -0
  6. data/LICENSE.txt +21 -0
  7. data/README.md +276 -0
  8. data/ext/andromeda_cms/Cargo.toml +18 -0
  9. data/ext/andromeda_cms/extconf.rb +6 -0
  10. data/ext/andromeda_cms/src/errors.rs +55 -0
  11. data/ext/andromeda_cms/src/lib.rs +143 -0
  12. data/ext/andromeda_cms/src/nodes.rs +400 -0
  13. data/lib/andromeda/assets.rb +128 -0
  14. data/lib/andromeda/check.rb +71 -0
  15. data/lib/andromeda/components/errors.rb +97 -0
  16. data/lib/andromeda/components/import_scanner.rb +72 -0
  17. data/lib/andromeda/components/static_expression.rb +79 -0
  18. data/lib/andromeda/components.rb +348 -0
  19. data/lib/andromeda/configuration.rb +91 -0
  20. data/lib/andromeda/entry.rb +294 -0
  21. data/lib/andromeda/errors.rb +173 -0
  22. data/lib/andromeda/fix.rb +71 -0
  23. data/lib/andromeda/frontmatter.rb +327 -0
  24. data/lib/andromeda/helpers.rb +84 -0
  25. data/lib/andromeda/id.rb +58 -0
  26. data/lib/andromeda/loader.rb +96 -0
  27. data/lib/andromeda/parser.rb +75 -0
  28. data/lib/andromeda/pipeline.rb +296 -0
  29. data/lib/andromeda/railtie.rb +47 -0
  30. data/lib/andromeda/registry.rb +127 -0
  31. data/lib/andromeda/relation.rb +125 -0
  32. data/lib/andromeda/renderer/code_highlighter.rb +53 -0
  33. data/lib/andromeda/renderer/errors.rb +52 -0
  34. data/lib/andromeda/renderer/literal_expression.rb +201 -0
  35. data/lib/andromeda/renderer.rb +411 -0
  36. data/lib/andromeda/schema.rb +383 -0
  37. data/lib/andromeda/slugger.rb +68 -0
  38. data/lib/andromeda/store.rb +286 -0
  39. data/lib/andromeda/tasks/andromeda.rake +56 -0
  40. data/lib/andromeda/version.rb +5 -0
  41. data/lib/andromeda_cms.rb +41 -0
  42. data/lib/generators/andromeda/collection/USAGE +25 -0
  43. data/lib/generators/andromeda/collection/collection_generator.rb +239 -0
  44. data/lib/generators/andromeda/component/USAGE +15 -0
  45. data/lib/generators/andromeda/component/component_generator.rb +75 -0
  46. data/lib/generators/andromeda/import_astro/USAGE +21 -0
  47. data/lib/generators/andromeda/import_astro/astro_schema_json.rb +80 -0
  48. data/lib/generators/andromeda/import_astro/balanced_scanner.rb +168 -0
  49. data/lib/generators/andromeda/import_astro/content_config_converter.rb +232 -0
  50. data/lib/generators/andromeda/import_astro/import_astro_generator.rb +313 -0
  51. data/lib/generators/andromeda/import_astro/mdx_content_scanner.rb +91 -0
  52. data/lib/generators/andromeda/import_astro/mdx_import_rewriter.rb +91 -0
  53. data/lib/generators/andromeda/install/USAGE +11 -0
  54. data/lib/generators/andromeda/install/install_generator.rb +77 -0
  55. data/lib/generators/andromeda/install/templates/initializer.rb +30 -0
  56. metadata +161 -0
@@ -0,0 +1,400 @@
1
+ //! Walks a Sätteri `Arena<Mdast>` and renders it as mdast-spec-shaped JSON.
2
+ //!
3
+ //! This hand-written decode (rather than `#[derive(Serialize)]`) is
4
+ //! necessary: Sätteri stores node-specific fields as a binary
5
+ //! `#[repr(C)]` blob (`type_data`) rather than an idiomatic Rust enum, so
6
+ //! each node type needs its own `XxxData::from_bytes` call
7
+ //! (`satteri-ast`'s design targets its own napi/TS layer, not
8
+ //! third-party Rust embedders).
9
+ //!
10
+ //! Every node here also carries a unist `position`
11
+ //! (start/end line, column, offset). The gem needs
12
+ //! line numbers to build `path:line:column: message` errors, so positions
13
+ //! are not optional here.
14
+
15
+ use satteri_arena::{decode_string_ref_data, Arena, Mdast, StringRef};
16
+ use satteri_ast::mdast::codec::*;
17
+ use satteri_ast::mdast::MdastNodeType;
18
+ use serde_json::{json, Map, Value};
19
+
20
+ fn get_str(arena: &Arena<Mdast>, sr: StringRef) -> String {
21
+ arena.get_str(sr).to_string()
22
+ }
23
+
24
+ fn opt_str(arena: &Arena<Mdast>, sr: StringRef) -> Value {
25
+ if sr.is_empty() {
26
+ Value::Null
27
+ } else {
28
+ Value::String(get_str(arena, sr))
29
+ }
30
+ }
31
+
32
+ /// unist `Position`: 1-based line/column (as Sätteri's `LineIndex` computes
33
+ /// them — UTF-16-code-unit columns, matching what remark/unist report) and a
34
+ /// byte offset (Sätteri does not track a separate UTF-16 offset per node, so
35
+ /// `offset` is a byte index here; this is the one field that does not match
36
+ /// mdast-util-from-markdown exactly, and only matters for tooling that
37
+ /// slices `source[start.offset...end.offset]` on non-ASCII input).
38
+ fn position(arena: &Arena<Mdast>, id: u32) -> Value {
39
+ let node = arena.get_node(id);
40
+ json!({
41
+ "start": {
42
+ "line": node.start_line,
43
+ "column": node.start_column,
44
+ "offset": node.start_offset,
45
+ },
46
+ "end": {
47
+ "line": node.end_line,
48
+ "column": node.end_column,
49
+ "offset": node.end_offset,
50
+ },
51
+ })
52
+ }
53
+
54
+ /// Whether mdast-util-from-markdown would emit a `children` array for this
55
+ /// node type, even when empty (a unist `Parent`) as opposed to leaving it
56
+ /// out entirely (a unist `Literal`/leaf). Kept as an explicit allow-list
57
+ /// rather than "non-empty children" so an empty paragraph/root/etc still
58
+ /// round-trips as `children: []` instead of silently losing the field.
59
+ fn is_parent(nt: MdastNodeType) -> bool {
60
+ matches!(
61
+ nt,
62
+ MdastNodeType::Root
63
+ | MdastNodeType::Paragraph
64
+ | MdastNodeType::Heading
65
+ | MdastNodeType::Emphasis
66
+ | MdastNodeType::Strong
67
+ | MdastNodeType::Delete
68
+ | MdastNodeType::Blockquote
69
+ | MdastNodeType::List
70
+ | MdastNodeType::ListItem
71
+ | MdastNodeType::Link
72
+ | MdastNodeType::LinkReference
73
+ | MdastNodeType::Table
74
+ | MdastNodeType::TableRow
75
+ | MdastNodeType::TableCell
76
+ | MdastNodeType::FootnoteDefinition
77
+ | MdastNodeType::Superscript
78
+ | MdastNodeType::Subscript
79
+ | MdastNodeType::ContainerDirective
80
+ | MdastNodeType::LeafDirective
81
+ | MdastNodeType::TextDirective
82
+ | MdastNodeType::DescriptionList
83
+ | MdastNodeType::DescriptionTerm
84
+ | MdastNodeType::DescriptionDetails
85
+ | MdastNodeType::MdxJsxFlowElement
86
+ | MdastNodeType::MdxJsxTextElement
87
+ )
88
+ }
89
+
90
+ /// Best-effort decode of a node's `type_data` into JSON fields, keyed by
91
+ /// mdast-spec field names. Node types not covered here (e.g. `thematicBreak`,
92
+ /// `break`) have no extra fields in the spec either, so they fall through to
93
+ /// `{}`.
94
+ fn type_fields(arena: &Arena<Mdast>, id: u32, nt: MdastNodeType) -> Value {
95
+ let data = arena.get_type_data(id);
96
+ match nt {
97
+ MdastNodeType::Text
98
+ | MdastNodeType::InlineCode
99
+ | MdastNodeType::Html
100
+ | MdastNodeType::Yaml
101
+ | MdastNodeType::Toml
102
+ | MdastNodeType::MdxFlowExpression
103
+ | MdastNodeType::MdxTextExpression
104
+ | MdastNodeType::MdxjsEsm => {
105
+ if data.is_empty() {
106
+ json!({ "value": "" })
107
+ } else {
108
+ let sr = decode_string_ref_data(data);
109
+ json!({ "value": get_str(arena, sr) })
110
+ }
111
+ }
112
+ MdastNodeType::Math | MdastNodeType::InlineMath => {
113
+ if data.len() >= 16 {
114
+ let d = MathData::from_bytes(data);
115
+ json!({
116
+ "meta": opt_str(arena, d.meta),
117
+ "value": get_str(arena, d.value),
118
+ })
119
+ } else {
120
+ json!({})
121
+ }
122
+ }
123
+ MdastNodeType::Heading => {
124
+ if !data.is_empty() {
125
+ json!({ "depth": data[0] })
126
+ } else {
127
+ json!({})
128
+ }
129
+ }
130
+ MdastNodeType::Link => {
131
+ if !data.is_empty() {
132
+ let d = LinkData::from_bytes(data);
133
+ json!({
134
+ "url": get_str(arena, d.url),
135
+ "title": opt_str(arena, d.title),
136
+ })
137
+ } else {
138
+ json!({})
139
+ }
140
+ }
141
+ MdastNodeType::Image => {
142
+ if !data.is_empty() {
143
+ let d = ImageData::from_bytes(data);
144
+ json!({
145
+ "url": get_str(arena, d.url),
146
+ "alt": opt_str(arena, d.alt),
147
+ "title": opt_str(arena, d.title),
148
+ })
149
+ } else {
150
+ json!({})
151
+ }
152
+ }
153
+ MdastNodeType::Code => {
154
+ if !data.is_empty() {
155
+ let d = CodeData::from_bytes(data);
156
+ json!({
157
+ "lang": opt_str(arena, d.lang),
158
+ "meta": opt_str(arena, d.meta),
159
+ "value": get_str(arena, d.value),
160
+ })
161
+ } else {
162
+ json!({})
163
+ }
164
+ }
165
+ MdastNodeType::List => {
166
+ if !data.is_empty() {
167
+ let d = ListData::from_bytes(data);
168
+ json!({
169
+ "ordered": d.ordered,
170
+ "start": if d.ordered { Value::from(d.start) } else { Value::Null },
171
+ "spread": d.spread,
172
+ })
173
+ } else {
174
+ json!({})
175
+ }
176
+ }
177
+ MdastNodeType::ListItem => {
178
+ if !data.is_empty() {
179
+ let d = ListItemData::from_bytes(data);
180
+ json!({
181
+ "checked": match d.checked { 0 => Value::Bool(false), 1 => Value::Bool(true), _ => Value::Null },
182
+ "spread": d.spread,
183
+ })
184
+ } else {
185
+ json!({})
186
+ }
187
+ }
188
+ MdastNodeType::DescriptionDetails => {
189
+ if !data.is_empty() {
190
+ let d = DescriptionDetailsData::from_bytes(data);
191
+ json!({ "spread": d.spread })
192
+ } else {
193
+ json!({})
194
+ }
195
+ }
196
+ MdastNodeType::LinkReference
197
+ | MdastNodeType::ImageReference
198
+ | MdastNodeType::FootnoteReference => {
199
+ if !data.is_empty() {
200
+ let d = ReferenceData::from_bytes(data);
201
+ let kind = match d.reference_kind {
202
+ 0 => "shortcut",
203
+ 1 => "collapsed",
204
+ _ => "full",
205
+ };
206
+ let mut fields = Map::new();
207
+ fields.insert(
208
+ "identifier".into(),
209
+ Value::String(get_str(arena, d.identifier)),
210
+ );
211
+ fields.insert("label".into(), opt_str(arena, d.label));
212
+ // mdast spec: only link/imageReference carry referenceType,
213
+ // not footnoteReference.
214
+ if nt != MdastNodeType::FootnoteReference {
215
+ fields.insert("referenceType".into(), Value::String(kind.into()));
216
+ }
217
+ // imageReference alone also carries `alt`, stored as a trailing
218
+ // StringRef appended after the shared ReferenceData header (see
219
+ // satteri_ast::mdast::codec::encode_image_reference_data).
220
+ if nt == MdastNodeType::ImageReference {
221
+ let alt = decode_image_reference_alt(data);
222
+ fields.insert("alt".into(), opt_str(arena, alt));
223
+ }
224
+ Value::Object(fields)
225
+ } else {
226
+ json!({})
227
+ }
228
+ }
229
+ MdastNodeType::FootnoteDefinition => {
230
+ if !data.is_empty() {
231
+ let d = FootnoteDefinitionData::from_bytes(data);
232
+ json!({
233
+ "identifier": get_str(arena, d.identifier),
234
+ "label": opt_str(arena, d.label),
235
+ })
236
+ } else {
237
+ json!({})
238
+ }
239
+ }
240
+ MdastNodeType::Definition => {
241
+ if !data.is_empty() {
242
+ let d = DefinitionData::from_bytes(data);
243
+ json!({
244
+ "url": get_str(arena, d.url),
245
+ "title": opt_str(arena, d.title),
246
+ "identifier": get_str(arena, d.identifier),
247
+ "label": opt_str(arena, d.label),
248
+ })
249
+ } else {
250
+ json!({})
251
+ }
252
+ }
253
+ MdastNodeType::Table => {
254
+ // TableData header (align_count: u32) followed by align_count
255
+ // ColumnAlign bytes (see satteri_ast::mdast::codec::TableData).
256
+ if data.len() >= 4 {
257
+ let count = u32::from_le_bytes(data[0..4].try_into().unwrap()) as usize;
258
+ let mut aligns = vec![];
259
+ for i in 0..count {
260
+ let b = *data.get(4 + i).unwrap_or(&0);
261
+ aligns.push(match b {
262
+ 1 => Value::String("left".into()),
263
+ 2 => Value::String("right".into()),
264
+ 3 => Value::String("center".into()),
265
+ _ => Value::Null,
266
+ });
267
+ }
268
+ json!({ "align": aligns })
269
+ } else {
270
+ json!({})
271
+ }
272
+ }
273
+ MdastNodeType::ContainerDirective
274
+ | MdastNodeType::LeafDirective
275
+ | MdastNodeType::TextDirective => {
276
+ if data.len() >= 12 {
277
+ let name = decode_directive_name(data);
278
+ let attr_count = decode_directive_attr_count(data);
279
+ let mut attrs = Map::new();
280
+ for i in 0..attr_count {
281
+ let (key, value) = decode_directive_attr(data, i);
282
+ attrs.insert(get_str(arena, key), Value::String(get_str(arena, value)));
283
+ }
284
+ json!({
285
+ "name": get_str(arena, name),
286
+ "attributes": attrs,
287
+ })
288
+ } else {
289
+ json!({})
290
+ }
291
+ }
292
+ MdastNodeType::MdxJsxFlowElement | MdastNodeType::MdxJsxTextElement => {
293
+ if data.len() >= 16 {
294
+ let name_sr = decode_mdx_jsx_element_name(data);
295
+ let attr_count = decode_mdx_jsx_attr_count(data);
296
+ let name = if name_sr.is_empty() {
297
+ Value::Null // fragment (<>...</>)
298
+ } else {
299
+ Value::String(get_str(arena, name_sr))
300
+ };
301
+ let mut attrs = vec![];
302
+ for i in 0..attr_count {
303
+ let (kind, attr_name, attr_value) = decode_mdx_jsx_attr(data, i);
304
+ if kind == 3 {
305
+ // spread ({...expr}): no name, value is the raw
306
+ // expression text.
307
+ attrs.push(json!({
308
+ "type": "mdxJsxExpressionAttribute",
309
+ "value": get_str(arena, attr_value),
310
+ }));
311
+ continue;
312
+ }
313
+ let value = match kind {
314
+ 0 => Value::Null, // boolean prop
315
+ 1 => Value::String(get_str(arena, attr_value)), // literal
316
+ _ => {
317
+ json!({ "type": "mdxJsxAttributeValueExpression", "value": get_str(arena, attr_value) })
318
+ } // expression prop
319
+ };
320
+ attrs.push(json!({
321
+ "type": "mdxJsxAttribute",
322
+ "name": get_str(arena, attr_name),
323
+ "value": value,
324
+ }));
325
+ }
326
+ json!({ "name": name, "attributes": attrs })
327
+ } else {
328
+ json!({ "name": Value::Null, "attributes": Value::Array(vec![]) })
329
+ }
330
+ }
331
+ MdastNodeType::Custom => {
332
+ if data.len() >= 16 {
333
+ let name = StringRef::from_bytes(&data[0..8]);
334
+ let value = StringRef::from_bytes(&data[8..16]);
335
+ json!({
336
+ "customType": get_str(arena, name),
337
+ "value": opt_str(arena, value),
338
+ })
339
+ } else {
340
+ json!({})
341
+ }
342
+ }
343
+ _ => json!({}),
344
+ }
345
+ }
346
+
347
+ /// The first node (in document order) that sits more than `limit` levels
348
+ /// below `root`, if any.
349
+ ///
350
+ /// Deliberately iterative: it runs before the recursive `node_to_json`, so
351
+ /// it must not be the thing that overflows the stack on the input it exists
352
+ /// to reject.
353
+ pub fn first_node_deeper_than(arena: &Arena<Mdast>, root: u32, limit: usize) -> Option<u32> {
354
+ let mut pending = vec![(root, 0usize)];
355
+ while let Some((id, depth)) = pending.pop() {
356
+ if depth > limit {
357
+ return Some(id);
358
+ }
359
+ let parent = MdastNodeType::from_u8(arena.get_node(id).node_type).is_some_and(is_parent);
360
+ if parent {
361
+ pending.extend(
362
+ arena
363
+ .get_children(id)
364
+ .iter()
365
+ .rev()
366
+ .map(|&child| (child, depth + 1)),
367
+ );
368
+ }
369
+ }
370
+ None
371
+ }
372
+
373
+ /// Recursively renders one arena node (and its subtree) as mdast JSON.
374
+ pub fn node_to_json(arena: &Arena<Mdast>, id: u32) -> Value {
375
+ let node = arena.get_node(id);
376
+ let nt = MdastNodeType::from_u8(node.node_type);
377
+ let type_name = nt.map(|t| t.name()).unwrap_or("unknown").to_string();
378
+
379
+ let mut obj = Map::new();
380
+ obj.insert("type".into(), Value::String(type_name));
381
+ if let Some(nt) = nt {
382
+ if let Value::Object(fields) = type_fields(arena, id, nt) {
383
+ for (k, v) in fields {
384
+ obj.insert(k, v);
385
+ }
386
+ }
387
+ }
388
+
389
+ if let Some(nt) = nt {
390
+ if is_parent(nt) {
391
+ let children = arena.get_children(id);
392
+ let arr: Vec<Value> = children.iter().map(|&c| node_to_json(arena, c)).collect();
393
+ obj.insert("children".into(), Value::Array(arr));
394
+ }
395
+ }
396
+
397
+ obj.insert("position".into(), position(arena, id));
398
+
399
+ Value::Object(obj)
400
+ }
@@ -0,0 +1,128 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+
5
+ module Andromeda
6
+ # Publishes images that live next to content so the asset pipeline can serve
7
+ # them.
8
+ #
9
+ # Adding `app/content` to Propshaft's load path would be shorter, but
10
+ # Propshaft serves *every* file in a load path — the Markdown sources
11
+ # themselves would become public URLs. Copying only the referenced images
12
+ # into `app/assets/builds/andromeda/` keeps the content directory private and
13
+ # mirrors how tailwindcss-rails treats `app/assets/builds`.
14
+ module Assets
15
+ # Relative to `app/assets/builds`, and therefore also the prefix of the
16
+ # logical asset path the view helpers resolve.
17
+ DIRECTORY = "andromeda"
18
+
19
+ module_function
20
+
21
+ # Copies `source` under the builds directory and returns its logical path
22
+ # (what `asset_path` takes), or nil when the file is not a publishable
23
+ # asset under the content root.
24
+ #
25
+ # @param source [String] absolute path to a file inside the content root.
26
+ # @param content_root [String] the collection's base directory.
27
+ # @return [String, nil] e.g. "andromeda/blog/post/cover.png".
28
+ def publish(source, content_root:)
29
+ logical = logical_path(source, content_root: content_root)
30
+ return nil if logical.nil?
31
+
32
+ return logical if asset_pipeline_path(File.expand_path(source))
33
+
34
+ destination = File.join(builds_root, logical)
35
+ FileUtils.mkdir_p(File.dirname(destination))
36
+ # Copying only when the content changed keeps `andromeda:build` cheap on
37
+ # image-heavy sites, where the copy would otherwise dominate the run.
38
+ FileUtils.cp(source, destination) unless identical?(source, destination)
39
+ logical
40
+ end
41
+
42
+ # Conversion cannot know an image's final URL: Propshaft only learns the
43
+ # digest when `assets:precompile` runs, which is *after* `andromeda:build`
44
+ # (it depends on it), and in development the load path is indexed at boot.
45
+ # So the stored HTML carries this marker and the view resolves it, which
46
+ # costs one gsub per render and keeps digests correct in both environments.
47
+ MARKER = "andromeda-asset:"
48
+
49
+ def marker_for(logical_path)
50
+ "#{MARKER}#{logical_path}"
51
+ end
52
+
53
+ # @param html [String] stored HTML, possibly containing markers.
54
+ # @return [String] the same HTML with every marker replaced by a real URL.
55
+ def resolve(html)
56
+ return html unless html.include?(MARKER)
57
+
58
+ html.gsub(/#{Regexp.escape(MARKER)}([^"'\s)]+)/) { url_for(Regexp.last_match(1)) }
59
+ end
60
+
61
+ # @return [String] a URL for a logical path, digest-stamped when the asset
62
+ # pipeline is available and a plain /assets path otherwise.
63
+ def url_for(logical_path)
64
+ if defined?(::ActionController::Base)
65
+ ::ActionController::Base.helpers.asset_path(logical_path)
66
+ else
67
+ File.join("/assets", logical_path)
68
+ end
69
+ rescue StandardError
70
+ # An image that has not been indexed yet (a fresh copy in development,
71
+ # or a precompile that has not run) must not take the whole page down.
72
+ File.join("/assets", logical_path)
73
+ end
74
+
75
+ def logical_path(source, content_root:)
76
+ source = File.expand_path(source)
77
+ root = File.expand_path(content_root)
78
+ project = project_root
79
+
80
+ # Nothing outside the project (or the content root, when a collection
81
+ # lives elsewhere) may be published: a `../` chain in a content file
82
+ # must not expose an arbitrary file on the machine.
83
+ inside = [project, root].any? { |boundary| source.start_with?("#{boundary}#{File::SEPARATOR}") }
84
+ return nil unless inside
85
+
86
+ if source.start_with?("#{root}#{File::SEPARATOR}")
87
+ File.join(DIRECTORY, File.basename(root), source.delete_prefix("#{root}#{File::SEPARATOR}"))
88
+ elsif (asset_relative = asset_pipeline_path(source))
89
+ asset_relative
90
+ else
91
+ # Somewhere else in the project (Astro's own example keeps hero
92
+ # images in `src/assets`): mirrored under the build directory so it
93
+ # still gets a digest, with the project-relative path kept to avoid
94
+ # collisions between same-named files.
95
+ File.join(DIRECTORY, "project", source.delete_prefix("#{project}#{File::SEPARATOR}"))
96
+ end
97
+ end
98
+
99
+ # A file the asset pipeline already serves needs no copy -- returning its
100
+ # logical path lets `asset_path` digest the original in place.
101
+ def asset_pipeline_path(source)
102
+ assets_root = File.join(project_root, "app/assets")
103
+ return nil unless source.start_with?("#{assets_root}#{File::SEPARATOR}")
104
+
105
+ relative = source.delete_prefix("#{assets_root}#{File::SEPARATOR}")
106
+ # Propshaft's load path is each directory under app/assets, so the
107
+ # first segment (images/, stylesheets/, builds/, ...) is not part of
108
+ # the logical path -- except for files sitting directly in app/assets.
109
+ relative.include?(File::SEPARATOR) ? relative.split(File::SEPARATOR, 2).last : relative
110
+ end
111
+
112
+ def project_root
113
+ Andromeda.config.project_root
114
+ end
115
+
116
+ def builds_root
117
+ File.join(project_root, "app/assets/builds")
118
+ end
119
+
120
+ # Compares bytes, not timestamps: a checkout or an extracted archive can
121
+ # give a changed image an old mtime, and a same-sized edit would then
122
+ # never be copied. The size check keeps the common case cheap.
123
+ def identical?(source, destination)
124
+ File.exist?(destination) && File.size(source) == File.size(destination) &&
125
+ FileUtils.compare_file(source, destination)
126
+ end
127
+ end
128
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Andromeda
4
+ # The read-only counterpart of `andromeda:build`, for CI.
5
+ #
6
+ # It deliberately runs the *same* conversion the build runs, into a
7
+ # throwaway store, rather than re-implementing a subset of the checks: a
8
+ # check that passes while the build fails would be worse than no check at
9
+ # all. Everything it finds is reported together, because fixing content
10
+ # one error per CI run is miserable.
11
+ module Check
12
+ module_function
13
+
14
+ # @param entry_classes [Array<Class>] defaults to every registered collection.
15
+ # @return [Array<String>] human-readable problems; empty when content is healthy.
16
+ def run(entry_classes = Andromeda::Pipeline.default_entry_classes)
17
+ problems = []
18
+
19
+ entry_classes.each do |entry_class|
20
+ entries = load_entries(entry_class, problems)
21
+ next if entries.nil?
22
+
23
+ entries.each do |entry|
24
+ problems.concat(frontmatter_style_problems(entry))
25
+ problems.concat(conversion_problems(entry))
26
+ end
27
+ end
28
+
29
+ problems
30
+ end
31
+
32
+ def load_entries(entry_class, problems)
33
+ entry_class.reload!
34
+ entry_class.all.to_a
35
+ rescue Andromeda::LoaderError => e
36
+ problems.concat(e.messages.map { |message| "[#{entry_class.collection_name}] #{message}" })
37
+ nil
38
+ rescue Andromeda::Error => e
39
+ problems << "[#{entry_class.collection_name}] #{e.message}"
40
+ nil
41
+ end
42
+
43
+ # Non-snake_case keys are legal YAML but will not reach a declared
44
+ # attribute, so they are reported here with the command that rewrites
45
+ # them instead of failing later as a missing required field.
46
+ def frontmatter_style_problems(entry)
47
+ offenders = Andromeda::Frontmatter.non_snake_case_keys(entry.data)
48
+ return [] if offenders.empty?
49
+
50
+ offenders.map do |key|
51
+ "#{entry.file_path}: frontmatter key #{key} is not snake_case; run `bin/rails andromeda:fix`"
52
+ end
53
+ end
54
+
55
+ def conversion_problems(entry)
56
+ Andromeda::Pipeline.new(store: NullStore.new, mode: :development).convert(entry)
57
+ []
58
+ rescue Andromeda::Error => e
59
+ ["#{entry.file_path}: #{e.message}"]
60
+ end
61
+
62
+ # Swallows the writes so a check can never leave `.andromeda/` in a
63
+ # half-built state that a later production boot would happily serve.
64
+ class NullStore
65
+ def write_entry(*) = nil
66
+ def read_entry(*) = nil
67
+ def read_index(*) = nil
68
+ def replace_collection!(*) = nil
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Andromeda
4
+ class Components
5
+ # Raised when a component tag resolves to a partial name (by explicit
6
+ # registration, import path, or naming convention) but no such partial
7
+ # exists on disk. Names the tag, the content file, the line, and the
8
+ # exact path to create so the error is actionable straight out
9
+ # of a build log or a development error page, without having to go
10
+ # spelunking through the resolution rules to figure out what Andromeda
11
+ # was even looking for.
12
+ class MissingPartialError < Andromeda::Error
13
+ attr_reader :tag_name, :path, :line, :expected_path
14
+
15
+ def initialize(tag_name, path:, line:, expected_path:)
16
+ @tag_name = tag_name
17
+ @path = path
18
+ @line = line
19
+ @expected_path = expected_path
20
+
21
+ location = [path, line].compact.join(":")
22
+ super(
23
+ "#{location}: <#{tag_name}> has no matching partial. Create #{expected_path} " \
24
+ "(e.g. `rails g andromeda:component #{tag_name}`), register it explicitly with " \
25
+ "`Andromeda::Components.register(#{tag_name.to_s.inspect}, \"partial/path\")`, or " \
26
+ "import it from a path that points at an existing partial."
27
+ )
28
+ end
29
+ end
30
+
31
+ # Raised for a member-expression tag name (`<Tabs.Item>`) that
32
+ # was not explicitly registered. Convention-based resolution can't turn
33
+ # a dotted name into a partial path, and guessing would be more
34
+ # surprising than just saying so -- named-slot components (the
35
+ # `Tabs`/`Fragment slot=` pattern) cover the common case this syntax is
36
+ # usually reaching for.
37
+ class InvalidComponentNameError < Andromeda::Error
38
+ attr_reader :tag_name, :path, :line
39
+
40
+ def initialize(tag_name, path:, line:)
41
+ @tag_name = tag_name
42
+ @path = path
43
+ @line = line
44
+
45
+ location = [path, line].compact.join(":")
46
+ super(
47
+ "#{location}: <#{tag_name}> looks like a member expression (e.g. `Tabs.Item`), " \
48
+ "which Andromeda does not resolve by naming convention in v0 -- register it " \
49
+ "explicitly with `Andromeda::Components.register(#{tag_name.to_s.inspect}, " \
50
+ "\"partial/path\")`"
51
+ )
52
+ end
53
+ end
54
+
55
+ # Raised for a `{...}` expression that is neither a literal,
56
+ # a `frontmatter.x` reference, nor a comment -- the
57
+ # v0 static-expression subset. Everything else (`.map()`,
58
+ # ternaries, template interpolation, arrow functions, `await`, ...) is
59
+ # explicitly out of scope, and silently dropping it would hide content
60
+ # bugs, so this raises naming the file/line rather than degrading
61
+ # quietly.
62
+ class UnsupportedExpressionError < Andromeda::Error
63
+ attr_reader :source, :path, :line, :column
64
+
65
+ def initialize(source, path:, line:, column:)
66
+ @source = source
67
+ @path = path
68
+ @line = line
69
+ @column = column
70
+
71
+ location = [path, line].compact.join(":")
72
+ super(
73
+ "#{location}: unsupported MDX expression `{#{source}}` -- only literals, " \
74
+ "`frontmatter.x` references, and comments are supported"
75
+ )
76
+ end
77
+ end
78
+
79
+ # Raised when there is no Rails renderer to hand a partial to -- either
80
+ # `ApplicationController` is not defined (Rails is not loaded / not
81
+ # booted yet) and no `view:` was passed explicitly to
82
+ # `Andromeda::Components.new`. Components render through
83
+ # `ApplicationController.renderer`; there is no non-Rails fallback in
84
+ # v0 (a plain ActionView::Base renderer setup is enough of a project on
85
+ # its own -- view paths, compiled template caching, helpers -- that it
86
+ # is deferred rather than half-done here).
87
+ class NoRendererAvailableError < Andromeda::Error
88
+ def initialize(path)
89
+ super(
90
+ "no Rails renderer available to render MDX components in " \
91
+ "#{path || "(unknown file)"} -- define ApplicationController, or pass `view:` " \
92
+ "explicitly to Andromeda::Components.new"
93
+ )
94
+ end
95
+ end
96
+ end
97
+ end