andromeda_cms 0.1.1 → 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: e0bf0f98d84d4c550f9767a856abdc93167d42cb183a737321db4ff195baa871
4
- data.tar.gz: a54266f37539d9b89907bcc67c9011546c94299c7d29e30487ece2b887a2ae99
3
+ metadata.gz: fa08db7b74aca56bfdc4d9f07e16e0c0bea58955ee94b1ec949503c1a99450b9
4
+ data.tar.gz: e0f58387fd0f941d04287bcad45bad36d1cac7e4959e4c7cb37d242d06aeb0d1
5
5
  SHA512:
6
- metadata.gz: 11abb00fcd58c65c95568093a399684110affa71e756cad1abd4e4ecdf83c3c22a2f9ef6b7628de05fc49eb2d93056b4fe7c116be2b3bd8eafbc92be2297124f
7
- data.tar.gz: 5134eef7dbb6fe51deb56e9e7eddeb346a5982b81cb6de4e3ac9bf37ca57f00d0709e60dfe63da4197c5b931b3047ce242ee691d783e28983e620d2d70c8465f
6
+ metadata.gz: a560a7bfec8de8698fb1a3aee14443f4d7ed801944dc3552f98c027bbedc90746c7f151a639fc863e085db43eadce21357f59fca070f21a52ec7efabaa788dbb
7
+ data.tar.gz: 06f7ab5e0887284207fee9c51f083acc8abbb8ea488ad1e9d6dfab3df737fa97d11c5c06a094023f5759efbebc3ccb463b80a8097e939ed935a428a9b5f315e9
data/CHANGELOG.md CHANGED
@@ -5,6 +5,53 @@ 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
+
44
+ ## [0.1.2] - 2026-09-27
45
+
46
+ ### Fixed
47
+
48
+ - `Entry#body` returned `nil` in production. Entries there are read from the
49
+ built index, which leaves bodies out, so anything using the raw Markdown --
50
+ a reading-time estimate, a Markdown export -- failed with `NoMethodError`.
51
+ The build now stores each entry's body in its own file (not the index), and
52
+ `Entry#body` reads it from there when needed, the same way `html` works.
53
+ Existing builds pick this up on the next `andromeda:build`.
54
+
8
55
  ## [0.1.1] - 2026-09-27
9
56
 
10
57
  ### Fixed
@@ -38,5 +85,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
38
85
  - Generators: `andromeda:install`, `andromeda:collection`,
39
86
  `andromeda:component` and `andromeda:import_astro`.
40
87
 
88
+ [0.2.0]: https://github.com/AndromedaCMS/andromeda_cms/releases/tag/v0.2.0
89
+ [0.1.2]: https://github.com/AndromedaCMS/andromeda_cms/releases/tag/v0.1.2
41
90
  [0.1.1]: https://github.com/AndromedaCMS/andromeda_cms/releases/tag/v0.1.1
42
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
@@ -230,9 +233,6 @@ module Andromeda
230
233
  # @return [Hash] Symbol-keyed validated frontmatter (may also carry
231
234
  # String keys for unrecognized frontmatter -- see Schema::Result).
232
235
  attr_reader :data
233
- # @return [String] raw Markdown/MDX body, frontmatter stripped but with
234
- # original line numbers preserved (Andromeda::Frontmatter#parse).
235
- attr_reader :body
236
236
  # @return [String] absolute path to the source file.
237
237
  attr_reader :file_path
238
238
  # @return [String] SHA-256 of the whole source file, for cache keys.
@@ -262,6 +262,20 @@ module Andromeda
262
262
  converted[:html]
263
263
  end
264
264
 
265
+ # The raw Markdown/MDX body, frontmatter stripped but with original line
266
+ # numbers preserved (Andromeda::Frontmatter#parse). Entries loaded from
267
+ # source carry it already; in production they come from the index, which
268
+ # leaves bodies out, so it is read from the entry's built file the same
269
+ # way `html` is.
270
+ #
271
+ # @return [String, nil] nil only for a build made before bodies were
272
+ # stored (andromeda_cms < 0.1.2); rebuilding fixes it.
273
+ # @raise [Andromeda::BuildMissing] in production, if this entry has
274
+ # never been built.
275
+ def body
276
+ @body || converted[:body]
277
+ end
278
+
265
279
  # @return [Array<Hash>] `{depth:, slug:, text:}` per heading, in
266
280
  # document order (Andromeda::Renderer::Result#headings).
267
281
  # @raise [Andromeda::BuildMissing] in production, if this entry has
@@ -302,5 +316,12 @@ module Andromeda
302
316
  # referencing an entry never forces every other collection to load.
303
317
  Andromeda.get_entry(reference.collection, reference.id)
304
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
305
326
  end
306
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
 
@@ -49,7 +53,7 @@ module Andromeda
49
53
  # `data`/`body`/`digest`/`file_path` all come straight from the
50
54
  # source file, not from a previous build).
51
55
  # @return [Hash] the stored payload (Symbol-keyed: `id`, `collection`,
52
- # `data`, `html`, `headings`, `digest`, `file_path`).
56
+ # `data`, `html`, `headings`, `body`, `digest`, `file_path`).
53
57
  # @raise [Andromeda::SyntaxError, Andromeda::ParserError,
54
58
  # Andromeda::Renderer::MissingComponentError] on a bad file; callers
55
59
  # that want every problem across a whole collection reported together
@@ -69,6 +73,10 @@ module Andromeda
69
73
  data: publish_data_images(entry),
70
74
  html: result.html,
71
75
  headings: result.headings,
76
+ # Kept so `Entry#body` works in production, where entries come from
77
+ # the index and the source file is never read. The index itself
78
+ # leaves it out (Store#index_summary), so listings stay small.
79
+ body: entry.body,
72
80
  digest: entry.digest,
73
81
  render_key: render_key,
74
82
  file_path: entry.file_path
@@ -190,7 +198,7 @@ module Andromeda
190
198
  end
191
199
 
192
200
  @store.replace_collection!(entry_class.collection_name, payloads)
193
- 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)
194
202
  end
195
203
 
196
204
  # Converts every registered collection, reporting every problem found
@@ -205,10 +213,13 @@ module Andromeda
205
213
  # `Andromeda::Store.new`'s own `Andromeda.config.build_path` default.
206
214
  # @return [Array<BuildResult>] one per collection, on success.
207
215
  # @raise [Andromeda::BuildError] listing every problem found, across
208
- # 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.
209
218
  def self.build_all(entry_classes = default_entry_classes, store: Andromeda::Store.new)
210
219
  results = entry_classes.map { |entry_class| new(store: store).build_collection(entry_class) }
211
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))
212
223
  raise Andromeda::BuildError, errors unless errors.empty?
213
224
 
214
225
  results
@@ -266,7 +277,7 @@ module Andromeda
266
277
  content_root = File.dirname(entry.file_path)
267
278
  base_dir = Andromeda::Registry[entry.collection]&.base_dir || content_root
268
279
 
269
- lambda do |url, _node|
280
+ lambda do |url, node|
270
281
  # Absolute URLs point at something already being served -- a file in
271
282
  # `public/`, another host, or an inline data URI -- so they are left
272
283
  # exactly as the author wrote them.
@@ -278,10 +289,27 @@ module Andromeda
278
289
  next Andromeda::Assets.marker_for(logical) if logical
279
290
  end
280
291
 
281
- # 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
282
305
  # images the application already ships (`app/assets/images/logo.png`,
283
306
  # written as `logo.png`) resolve through the asset pipeline and get
284
- # 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
+
285
313
  Andromeda::Assets.marker_for(url)
286
314
  end
287
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.1"
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.1
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