andromeda_cms 0.1.2 → 0.2.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 662abf6c10c57c55c586ed4251c2c0401ce77cac5f76a0974d4cdce33f1fd62f
4
- data.tar.gz: dc74176a48e4cc35b04359e81ced65fca9c511239a9d97548243ab260cd961e6
3
+ metadata.gz: fa08db7b74aca56bfdc4d9f07e16e0c0bea58955ee94b1ec949503c1a99450b9
4
+ data.tar.gz: e0f58387fd0f941d04287bcad45bad36d1cac7e4959e4c7cb37d242d06aeb0d1
5
5
  SHA512:
6
- metadata.gz: dd1c410bad45f6ab02a83066fabcd7e2d16e7e4e2b1f7875c9744439e787b15a42544b8b755eb59c53365fd8f1d1fb0d0b17dabb6f1089dc513b15d0835f13fb
7
- data.tar.gz: 9db77b986bacd294a491d76929c213d43591d4926aaaaef96885255dca7b2747fe4f56d23856028917f314cf094f0cd8c9c960ab0cb73fa76ecb979e02ff09c8
6
+ metadata.gz: a560a7bfec8de8698fb1a3aee14443f4d7ed801944dc3552f98c027bbedc90746c7f151a639fc863e085db43eadce21357f59fca070f21a52ec7efabaa788dbb
7
+ data.tar.gz: 06f7ab5e0887284207fee9c51f083acc8abbb8ea488ad1e9d6dfab3df737fa97d11c5c06a094023f5759efbebc3ccb463b80a8097e939ed935a428a9b5f315e9
data/CHANGELOG.md CHANGED
@@ -5,6 +5,42 @@ All notable changes to this project are documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.2.0] - 2026-09-29
9
+
10
+ ### Changed
11
+
12
+ - The reader for an `:array` attribute declared `of: :reference` now resolves
13
+ each element to its entry, the way a single `:reference` reader already
14
+ did: `post.tags` returns the tag entries, in frontmatter order, and raises
15
+ `Andromeda::EntryNotFound` for an id with no entry. It used to return the
16
+ `Andromeda::Reference` pointers, which are still in `post.data[:tags]`.
17
+ - `andromeda:build` and `andromeda:check` now fail on content that used to
18
+ pass and then broke when served. Run `andromeda:check` before upgrading a
19
+ deploy to see what needs fixing:
20
+ - a `:reference`, alone or in an array, whose id has no entry in the target
21
+ collection, or that names a collection nobody registered;
22
+ - an image in the body written as a bare path (`blog/posts/x.png`) that
23
+ Propshaft's load path cannot find. These used to fall back to a plain
24
+ `/assets/...` URL and 404 in production. Without Propshaft the path is
25
+ still used as written.
26
+ - Declaring `:array` with `of: :reference` and no `collection:`, or with
27
+ `of: :enum` and no `values:`, now raises `ArgumentError` at definition time.
28
+
29
+ ### Fixed
30
+
31
+ - An `:array` of `:reference` built its elements without the declared
32
+ `collection:`, so each `Andromeda::Reference` had a `nil` collection. The
33
+ first process worked; the next one to read `.andromeda/` failed with
34
+ `NoMethodError: undefined method 'to_sym' for nil`. `of: :enum` lost its
35
+ `values:` the same way and raised `NoMethodError` on any value.
36
+ - An image in the body written as `./` or `../` with no file behind it was
37
+ treated as an asset pipeline path and rendered as a broken `/assets/./...`
38
+ URL. It now fails the build, as the documentation says, and so does a
39
+ relative path that leaves the project.
40
+ - `andromeda:install` appended its ignore lines to the last line of a
41
+ `.gitignore` or `.dockerignore` that did not end with a newline, breaking
42
+ both that entry and `/.andromeda/`.
43
+
8
44
  ## [0.1.2] - 2026-09-27
9
45
 
10
46
  ### Fixed
@@ -49,6 +85,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
49
85
  - Generators: `andromeda:install`, `andromeda:collection`,
50
86
  `andromeda:component` and `andromeda:import_astro`.
51
87
 
88
+ [0.2.0]: https://github.com/AndromedaCMS/andromeda_cms/releases/tag/v0.2.0
52
89
  [0.1.2]: https://github.com/AndromedaCMS/andromeda_cms/releases/tag/v0.1.2
53
90
  [0.1.1]: https://github.com/AndromedaCMS/andromeda_cms/releases/tag/v0.1.1
54
91
  [0.1.0]: https://github.com/AndromedaCMS/andromeda_cms/releases/tag/v0.1.0
data/README.md CHANGED
@@ -2,6 +2,8 @@
2
2
 
3
3
  Astro-compatible content collections for Ruby on Rails.
4
4
 
5
+ https://github.com/user-attachments/assets/7bde3b4f-7bdb-49f9-9646-cd981846a451
6
+
5
7
  [Documentation](https://www.andromedacms.dev/docs) · [Changelog](CHANGELOG.md)
6
8
 
7
9
  Copy the `.md` and `.mdx` files from an [Astro](https://astro.build) project
@@ -72,6 +72,28 @@ module Andromeda
72
72
  File.join("/assets", logical_path)
73
73
  end
74
74
 
75
+ # Whether the asset pipeline can serve `logical_path`. `url_for` falls
76
+ # back to a plain /assets path for anything it cannot find, so without
77
+ # this check a typo only shows up as a 404 in production.
78
+ #
79
+ # @return [Boolean, nil] nil when there is no Propshaft load path to ask
80
+ # (no asset pipeline, or Sprockets), so callers can skip the check.
81
+ def exists?(logical_path)
82
+ load_path = pipeline_load_path
83
+ return nil if load_path.nil?
84
+
85
+ !load_path.find(logical_path).nil?
86
+ end
87
+
88
+ def pipeline_load_path
89
+ return nil unless defined?(::Rails) && ::Rails.respond_to?(:application) && ::Rails.application
90
+
91
+ assets = ::Rails.application.assets
92
+ assets.respond_to?(:load_path) && assets.load_path.respond_to?(:find) ? assets.load_path : nil
93
+ rescue StandardError
94
+ nil
95
+ end
96
+
75
97
  def logical_path(source, content_root:)
76
98
  source = File.expand_path(source)
77
99
  root = File.expand_path(content_root)
@@ -15,18 +15,20 @@ module Andromeda
15
15
  # @return [Array<String>] human-readable problems; empty when content is healthy.
16
16
  def run(entry_classes = Andromeda::Pipeline.default_entry_classes)
17
17
  problems = []
18
+ loaded = {}
18
19
 
19
20
  entry_classes.each do |entry_class|
20
21
  entries = load_entries(entry_class, problems)
21
22
  next if entries.nil?
22
23
 
24
+ loaded[entry_class] = entries
23
25
  entries.each do |entry|
24
26
  problems.concat(frontmatter_style_problems(entry))
25
27
  problems.concat(conversion_problems(entry))
26
28
  end
27
29
  end
28
30
 
29
- problems
31
+ problems.concat(Andromeda::References.problems(loaded))
30
32
  end
31
33
 
32
34
  def load_entries(entry_class, problems)
@@ -58,7 +58,8 @@ module Andromeda
58
58
  # stored `Andromeda::Reference` to the actual target entry (lazily,
59
59
  # via the registry) rather than returning the pointer itself --
60
60
  # `post.data[:author]` still holds the raw Reference for callers that
61
- # want it.
61
+ # want it. An `:array` of `:reference` resolves each element the same
62
+ # way, so `post.tags` returns the tag entries.
62
63
  #
63
64
  # @see Andromeda::Schema#attribute for the full parameter list.
64
65
  def attribute(name, type, **options)
@@ -67,6 +68,8 @@ module Andromeda
67
68
 
68
69
  if type == :reference
69
70
  define_method(attr_name) { resolve_reference(attr_name) }
71
+ elsif type == :array && options[:of] == :reference
72
+ define_method(attr_name) { resolve_references(attr_name) }
70
73
  else
71
74
  define_method(attr_name) { data[attr_name] }
72
75
  end
@@ -313,5 +316,12 @@ module Andromeda
313
316
  # referencing an entry never forces every other collection to load.
314
317
  Andromeda.get_entry(reference.collection, reference.id)
315
318
  end
319
+
320
+ # @param attr_name [Symbol] an `:array` attribute declared `of: :reference`.
321
+ # @return [Array<Andromeda::Entry>] in the order the frontmatter lists them.
322
+ # @raise [Andromeda::EntryNotFound] for the first id with no entry.
323
+ def resolve_references(attr_name)
324
+ Array(data[attr_name]).map { |reference| Andromeda.get_entry(reference.collection, reference.id) }
325
+ end
316
326
  end
317
327
  end
@@ -22,7 +22,11 @@ module Andromeda
22
22
  # components, syntax errors) -- collected rather than raised
23
23
  # individually, so a build reports everything in one pass (same
24
24
  # principle as Andromeda::LoaderError).
25
- BuildResult = Struct.new(:collection, :converted, :errors, keyword_init: true) do
25
+ #
26
+ # `entries` holds what was read from source (nil when the collection
27
+ # failed to load), so `.build_all` can check references across
28
+ # collections without reading every file a second time.
29
+ BuildResult = Struct.new(:collection, :converted, :errors, :entries, keyword_init: true) do
26
30
  def success? = errors.empty?
27
31
  end
28
32
 
@@ -194,7 +198,7 @@ module Andromeda
194
198
  end
195
199
 
196
200
  @store.replace_collection!(entry_class.collection_name, payloads)
197
- BuildResult.new(collection: entry_class.collection_name, converted: payloads.size, errors: errors)
201
+ BuildResult.new(collection: entry_class.collection_name, converted: payloads.size, errors: errors, entries: entries)
198
202
  end
199
203
 
200
204
  # Converts every registered collection, reporting every problem found
@@ -209,10 +213,13 @@ module Andromeda
209
213
  # `Andromeda::Store.new`'s own `Andromeda.config.build_path` default.
210
214
  # @return [Array<BuildResult>] one per collection, on success.
211
215
  # @raise [Andromeda::BuildError] listing every problem found, across
212
- # every collection, if any entry failed to convert.
216
+ # every collection, if any entry failed to convert or references an
217
+ # entry that does not exist.
213
218
  def self.build_all(entry_classes = default_entry_classes, store: Andromeda::Store.new)
214
219
  results = entry_classes.map { |entry_class| new(store: store).build_collection(entry_class) }
215
220
  errors = results.flat_map(&:errors)
221
+ loaded = entry_classes.zip(results).filter_map { |entry_class, result| [entry_class, result.entries] if result.entries }
222
+ errors.concat(Andromeda::References.problems(loaded.to_h))
216
223
  raise Andromeda::BuildError, errors unless errors.empty?
217
224
 
218
225
  results
@@ -270,7 +277,7 @@ module Andromeda
270
277
  content_root = File.dirname(entry.file_path)
271
278
  base_dir = Andromeda::Registry[entry.collection]&.base_dir || content_root
272
279
 
273
- lambda do |url, _node|
280
+ lambda do |url, node|
274
281
  # Absolute URLs point at something already being served -- a file in
275
282
  # `public/`, another host, or an inline data URI -- so they are left
276
283
  # exactly as the author wrote them.
@@ -282,10 +289,27 @@ module Andromeda
282
289
  next Andromeda::Assets.marker_for(logical) if logical
283
290
  end
284
291
 
285
- # Not a file next to the content: treat it as a logical asset path so
292
+ # `./` and `../` can only mean "relative to this file", so falling
293
+ # back to the asset pipeline would just turn a typo into a silent 404.
294
+ # Containment is checked first so a crafted path never learns whether
295
+ # something outside the project exists.
296
+ start = (node && node[:position] || {})[:start] || {}
297
+ location = start[:line] ? "#{start[:line]}:#{start[:column]}: " : ""
298
+ if url.start_with?("./", "../")
299
+ inside = Andromeda::Assets.logical_path(source, content_root: base_dir)
300
+ reason = inside ? "does not exist" : "resolves outside the project"
301
+ raise Andromeda::Error, "#{location}image #{url.inspect} #{reason}"
302
+ end
303
+
304
+ # A bare path is not a file next to the content: treat it as a logical asset path so
286
305
  # images the application already ships (`app/assets/images/logo.png`,
287
306
  # written as `logo.png`) resolve through the asset pipeline and get
288
- # their digest, instead of 404ing in production.
307
+ # their digest, instead of 404ing in production. Without Propshaft
308
+ # there is nothing to ask, so the path is trusted as written.
309
+ if Andromeda::Assets.exists?(url) == false
310
+ raise Andromeda::Error, "#{location}image #{url.inspect} is not in the asset pipeline"
311
+ end
312
+
289
313
  Andromeda::Assets.marker_for(url)
290
314
  end
291
315
  end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "set"
4
+
5
+ module Andromeda
6
+ # Checks that every `:reference` attribute -- on its own or as the
7
+ # elements of an `:array` -- names an entry that actually exists.
8
+ #
9
+ # Entries resolve references lazily (Entry#resolve_reference), so without
10
+ # this a typo in an id only surfaces when a page asks for the target. It
11
+ # needs every target collection loaded, which is why only the whole-site
12
+ # build (Pipeline.build_all) and Andromeda::Check run it.
13
+ module References
14
+ module_function
15
+
16
+ # @param entries_by_class [Hash{Class => Array<Andromeda::Entry>}]
17
+ # entries already loaded, reused as reference targets. A target
18
+ # collection missing from here is loaded from source.
19
+ # @return [Array<String>] human-readable problems; empty when every
20
+ # reference resolves.
21
+ def problems(entries_by_class)
22
+ ids = Hash.new { |cache, name| cache[name] = ids_for(name, entries_by_class) }
23
+
24
+ entries_by_class.flat_map do |entry_class, entries|
25
+ attributes = reference_attributes(entry_class)
26
+ next [] if attributes.empty?
27
+
28
+ entries.flat_map do |entry|
29
+ attributes.flat_map do |attr|
30
+ references_in(entry.data[attr.name]).filter_map do |reference|
31
+ problem_for(reference, ids[reference.collection])&.then do |message|
32
+ "[#{entry_class.collection_name}] #{entry.file_path}: #{attr.name}: #{message}"
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end
39
+
40
+ def reference_attributes(entry_class)
41
+ entry_class.schema.attributes.values.select do |attr|
42
+ attr.type == :reference || (attr.type == :array && attr.of == :reference)
43
+ end
44
+ end
45
+
46
+ # `Array(value)` would splat a single Reference (a Struct) into its
47
+ # fields, so the two shapes are told apart explicitly.
48
+ def references_in(value)
49
+ (value.is_a?(Array) ? value : [value]).grep(Andromeda::Reference)
50
+ end
51
+
52
+ def problem_for(reference, known)
53
+ return "unknown collection #{reference.collection.inspect}" if known == :unknown
54
+ # The target collection failed to load; that failure is reported on
55
+ # its own, and every reference into it would only repeat it.
56
+ return nil if known.nil?
57
+ return nil if known.include?(reference.id.to_s)
58
+
59
+ "no #{reference.collection.inspect} entry with id #{reference.id.to_s.inspect}"
60
+ end
61
+
62
+ def ids_for(name, entries_by_class)
63
+ entry_class = Andromeda::Registry[name]
64
+ return :unknown if entry_class.nil?
65
+
66
+ entries = entries_by_class.fetch(entry_class) { entry_class.source_entries }
67
+ entries.map(&:id).to_set
68
+ rescue Andromeda::LoaderError
69
+ nil
70
+ end
71
+ end
72
+ end
@@ -108,6 +108,12 @@ module Andromeda
108
108
  end
109
109
  raise ArgumentError, "#{name}: :enum requires values:" if type == :enum && (values.nil? || values.empty?)
110
110
  raise ArgumentError, "#{name}: :reference requires collection:" if type == :reference && collection.nil?
111
+ if type == :array && of == :reference && collection.nil?
112
+ raise ArgumentError, "#{name}: :array of :reference requires collection:"
113
+ end
114
+ if type == :array && of == :enum && (values.nil? || values.empty?)
115
+ raise ArgumentError, "#{name}: :array of :enum requires values:"
116
+ end
111
117
  if type == :array && of && !TYPES.include?(of)
112
118
  raise ArgumentError, "#{name}: unknown element type #{of.inspect} for of:"
113
119
  end
@@ -291,7 +297,9 @@ module Andromeda
291
297
  return [nil, "#{value.inspect} is not an array"] unless value.is_a?(Array)
292
298
  return [value, nil] unless attr.of
293
299
 
294
- element_attr = Attribute.new(name: attr.name, type: attr.of)
300
+ # `values:` and `collection:` describe the elements, not the array, so
301
+ # they have to travel with the element type.
302
+ element_attr = Attribute.new(name: attr.name, type: attr.of, values: attr.values, collection: attr.collection)
295
303
  errors = []
296
304
  elements = value.each_with_index.map do |element, index|
297
305
  coerced, error = coerce(element_attr, element, entry_path: entry_path, content_root: content_root)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Andromeda
4
- VERSION = "0.1.2"
4
+ VERSION = "0.2.0"
5
5
  end
data/lib/andromeda_cms.rb CHANGED
@@ -19,6 +19,7 @@ require_relative "andromeda/loader"
19
19
  require_relative "andromeda/entry"
20
20
  require_relative "andromeda/store"
21
21
  require_relative "andromeda/pipeline"
22
+ require_relative "andromeda/references"
22
23
 
23
24
  module Andromeda
24
25
  # The native extension is built per Ruby minor version, so prefer the
@@ -70,7 +70,11 @@ module Andromeda
70
70
  return
71
71
  end
72
72
 
73
- append_to_file filename, "#{missing.join("\n")}\n"
73
+ # Without this, a file whose last line has no newline gets the first
74
+ # ignore line glued onto it, breaking both entries.
75
+ content = File.read(path)
76
+ separator = content.empty? || content.end_with?("\n") ? "" : "\n"
77
+ append_to_file filename, "#{separator}#{missing.join("\n")}\n"
74
78
  end
75
79
  end
76
80
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: andromeda_cms
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.2
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - LIMHAUS Inc.
@@ -105,6 +105,7 @@ files:
105
105
  - lib/andromeda/parser.rb
106
106
  - lib/andromeda/pipeline.rb
107
107
  - lib/andromeda/railtie.rb
108
+ - lib/andromeda/references.rb
108
109
  - lib/andromeda/registry.rb
109
110
  - lib/andromeda/relation.rb
110
111
  - lib/andromeda/renderer.rb