andromeda_cms 0.3.0 → 0.4.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: 44592cbc430b9bf1022ac0627a2cc6d76aa8e3f4834e6bc13873b95674862e02
4
- data.tar.gz: 3908dd979cc31bb8c71b7db2886dcb86c3199d12214a41547ae22609db064e71
3
+ metadata.gz: 2702633c9baaedc1b40c9a7bf767fe41dd472276bd1f279cabcea893746b9924
4
+ data.tar.gz: 6c4c0463b1709aa434a11ce3700f67a4b2d573237353cef8f2ed87e034b8b742
5
5
  SHA512:
6
- metadata.gz: aa22ba028964288a581eb0e24df49a744a5726ce3ad5ce1fe922c7c4c66f7a18d5973194fd08696b1473b0437e25ac9fd156027605273b0773803ee2261cd515
7
- data.tar.gz: b8ae98566b309a8cd25e4740bee5c61e0b31e9a8d2678a70969c70821a240a41f1a38e253cd3da793ebdfd3f44a2072ad734e4af7cc610d4774f0099c885d3df
6
+ metadata.gz: a6dae30e5bcd69aeb8665d1991be26ec0705471d8fe592c703f0824441adfa0b40dd69de95b6480616fe49348aebc6127ba2051611dc19156f62ea66192b396e
7
+ data.tar.gz: 856b571be0856f01afe55c14661e5ac9f49eb1b67b7329e712ebc077fa413a2b961a95832ee38374fa768b688779cbbc4ce705ab3bddccc9008d7cab0374b41a
data/CHANGELOG.md CHANGED
@@ -5,6 +5,34 @@ 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.4.0] - 2026-09-29
9
+
10
+ ### Added
11
+
12
+ - An image imported in MDX (`import photo from './photo.png'`) can be passed
13
+ to a component as a prop (`<Figure src={photo} />`). It is published like
14
+ an `image` attribute, and the partial receives an `Andromeda::Image`.
15
+ - `rails g andromeda:component` writes a strict locals declaration that
16
+ includes `content: nil`, since `content` is passed even to a self-closing
17
+ tag.
18
+
19
+ ### Fixed
20
+
21
+ - A component that queries entries (`Content::Post.find(slug)`) during
22
+ `andromeda:build` in production reads source files instead of the
23
+ previous build's `_index.json`, so an entry not converted yet is found.
24
+ - `andromeda_image_url` called inside a component while converting returns
25
+ an asset marker instead of a URL without a digest, which 404'd in
26
+ production. `andromeda_content` leaves markers in place there too.
27
+ - A component that renders another entry's HTML during `andromeda:build`
28
+ converts that entry on the spot when it has not been converted yet,
29
+ instead of raising `Andromeda::BuildMissing`. Entries whose components
30
+ render each other fail with a "circular conversion" error.
31
+ - An `Andromeda::Error` raised inside a component partial (such as
32
+ `EntryNotFound` from `find`) is no longer wrapped in
33
+ `ActionView::Template::Error`, so the build lists it with the other
34
+ problems instead of aborting with a stack trace.
35
+
8
36
  ## [0.3.0] - 2026-09-29
9
37
 
10
38
  ### Added
@@ -93,6 +121,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
93
121
  - Generators: `andromeda:install`, `andromeda:collection`,
94
122
  `andromeda:component` and `andromeda:import_astro`.
95
123
 
124
+ [0.4.0]: https://github.com/AndromedaCMS/andromeda_cms/releases/tag/v0.4.0
96
125
  [0.3.0]: https://github.com/AndromedaCMS/andromeda_cms/releases/tag/v0.3.0
97
126
  [0.2.0]: https://github.com/AndromedaCMS/andromeda_cms/releases/tag/v0.2.0
98
127
  [0.1.2]: https://github.com/AndromedaCMS/andromeda_cms/releases/tag/v0.1.2
data/README.md CHANGED
@@ -168,8 +168,33 @@ import Callout from 'content_components/callout';
168
168
 
169
169
  Props arrive as locals (`camelCase` becomes `snake_case`), the tag's children
170
170
  arrive as `content`, and `<Fragment slot="header">` becomes a `header` local.
171
+ `content` is passed even to a self-closing tag (`<Callout />`), so a partial
172
+ using strict locals must declare it:
173
+
174
+ ```erb
175
+ <%# locals: (type: "note", title: nil, content: nil) %>
176
+ ```
177
+
171
178
  `bin/rails generate andromeda:component Callout type title` writes the stub.
172
179
 
180
+ A component can query other entries, e.g. a card linking to another post:
181
+
182
+ ```erb
183
+ <%# app/views/content_components/_post_card.html.erb %>
184
+ <%# locals: (slug:, content: nil) %>
185
+ <% post = Content::Blog.find(slug) %>
186
+ <a href="<%= blog_path(post.id) %>">
187
+ <img src="<%= andromeda_image_url(post.cover_image) %>" alt="">
188
+ <%= post.title %>
189
+ </a>
190
+ ```
191
+
192
+ During `andromeda:build` such queries read the source files, so an entry not
193
+ converted yet is still found, and `andromeda_content(post)` converts it on
194
+ the spot. Component HTML is rendered once at build time
195
+ and stored, so `andromeda_image_url` emits a placeholder there that
196
+ `andromeda_content` turns into the digest-stamped URL when the page is served.
197
+
173
198
  ### Images
174
199
 
175
200
  Put an image next to the entry and reference it relatively:
@@ -191,6 +216,24 @@ through the pipeline too; anything under `public/` or on another host is left
191
216
  untouched. Declare a frontmatter image with `attribute :hero_image, :image`
192
217
  and render it with `andromeda_image_url`.
193
218
 
219
+ To pass an image to a component, import it and use it as a prop, as in Astro:
220
+
221
+ ```mdx
222
+ import photo from './photo.png';
223
+
224
+ <Figure src={photo} caption="The view from the top" />
225
+ ```
226
+
227
+ The partial receives the same `Andromeda::Image` an `image` attribute holds:
228
+
229
+ ```erb
230
+ <%# locals: (src:, caption: nil, content: nil) %>
231
+ <figure>
232
+ <img src="<%= andromeda_image_url(src) %>" class="w-full" alt="">
233
+ <figcaption><%= caption %></figcaption>
234
+ </figure>
235
+ ```
236
+
194
237
  ## Development and deployment
195
238
 
196
239
  ```
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Andromeda
4
+ # What the current thread is doing on Andromeda's behalf, for code that
5
+ # runs *inside* a conversion but cannot be told so directly: a component
6
+ # partial rendered into an entry's HTML calls the same `Entry.find` and
7
+ # `andromeda_image_url` a request-time view does, yet both must behave
8
+ # differently while the output is being produced.
9
+ #
10
+ # Thread-local because a build or a development request converts on one
11
+ # thread, and another thread serving requests at the same time must keep
12
+ # seeing the built index.
13
+ module BuildContext
14
+ BUILD_KEY = :andromeda_build_source_entries
15
+ CONVERTING_KEY = :andromeda_converting
16
+
17
+ module_function
18
+
19
+ # Marks the block as an `andromeda:build`. Queries made during it read
20
+ # source files instead of `_index.json`, which is still the previous
21
+ # build's (or absent) until each collection finishes -- so a component
22
+ # that looks up another entry would otherwise miss one that has not been
23
+ # converted yet. Each collection is loaded once per build and shared.
24
+ #
25
+ # Nested calls reuse the outer build's cache.
26
+ def building
27
+ return yield if building?
28
+
29
+ Thread.current[BUILD_KEY] = {}
30
+ begin
31
+ yield
32
+ ensure
33
+ Thread.current[BUILD_KEY] = nil
34
+ end
35
+ end
36
+
37
+ def building?
38
+ !Thread.current[BUILD_KEY].nil?
39
+ end
40
+
41
+ # @param entry_class [Class] an Andromeda::Entry subclass.
42
+ # @return [Array<Andromeda::Entry>] the block's result, cached for the
43
+ # rest of the build.
44
+ def source_entries(entry_class)
45
+ cache = Thread.current[BUILD_KEY]
46
+ return yield if cache.nil?
47
+
48
+ cache[entry_class] ||= yield
49
+ end
50
+
51
+ # Marks the block as rendering an entry's body into stored HTML. Anything
52
+ # that would bake a final asset URL into it must emit a marker instead:
53
+ # the digest is only known once `assets:precompile` runs, after the build.
54
+ #
55
+ # A component can ask another entry for its HTML, which converts that
56
+ # entry inside this one; the stack of entries being converted is what
57
+ # stops two entries whose components show each other from recursing
58
+ # forever.
59
+ #
60
+ # @param label [String] identifies the entry, e.g. "blog/hello-world".
61
+ # @raise [Andromeda::Error] if `label` is already being converted.
62
+ def converting(label)
63
+ stack = Thread.current[CONVERTING_KEY] ||= []
64
+ if stack.include?(label)
65
+ raise Andromeda::Error, "circular conversion: #{[*stack.drop_while { |l| l != label }, label].join(' -> ')} " \
66
+ "-- a component renders the HTML of an entry that renders this one"
67
+ end
68
+
69
+ stack.push(label)
70
+ begin
71
+ yield
72
+ ensure
73
+ stack.pop
74
+ end
75
+ end
76
+
77
+ def converting?
78
+ !(Thread.current[CONVERTING_KEY] || []).empty?
79
+ end
80
+ end
81
+ end
@@ -71,11 +71,30 @@ module Andromeda
71
71
  location = [path, line].compact.join(":")
72
72
  super(
73
73
  "#{location}: unsupported MDX expression `{#{source}}` -- only literals, " \
74
- "`frontmatter.x` references, and comments are supported"
74
+ "`frontmatter.x` references, imported images, and comments are supported"
75
75
  )
76
76
  end
77
77
  end
78
78
 
79
+ # Raised when a prop passes an imported image (`import photo from
80
+ # './photo.png'` then `src={photo}`) that cannot be published: the file
81
+ # is missing, lies outside the project, or was not imported by a
82
+ # relative path. Raising at build time keeps a typo from shipping as a
83
+ # 404.
84
+ class InvalidImageImportError < Andromeda::Error
85
+ attr_reader :name, :specifier, :path, :line
86
+
87
+ def initialize(name, specifier, reason, path:, line:)
88
+ @name = name
89
+ @specifier = specifier
90
+ @path = path
91
+ @line = line
92
+
93
+ location = [path, line].compact.join(":")
94
+ super("#{location}: image `#{name}` imported from #{specifier.inspect} #{reason}")
95
+ end
96
+ end
97
+
79
98
  # Raised when there is no Rails renderer to hand a partial to -- either
80
99
  # `ApplicationController` is not defined (Rails is not loaded / not
81
100
  # booted yet) and no `view:` was passed explicitly to
@@ -29,9 +29,8 @@ module Andromeda
29
29
  # @param tree [Hash] an mdast root node.
30
30
  # @return [Hash{String => String}] the name a component tag would use
31
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.
32
+ # `import cover from './cover.png'` ends up here too, which is how
33
+ # Components resolves `{cover}` passed as a prop to an image.
35
34
  def scan(tree)
36
35
  imports = {}
37
36
 
@@ -36,9 +36,14 @@ module Andromeda
36
36
  # the original contract this class was written against, so it defaults
37
37
  # to `{}` and callers that don't pass it simply get `nil` back for
38
38
  # every `frontmatter.x` reference instead of an error.
39
- def initialize(tree:, path: nil, view: nil, frontmatter: {})
39
+ # @param content_root [String, nil] the collection's base directory, which
40
+ # decides where an imported image is published (see
41
+ # `Andromeda::Assets.publish`). Defaults to the content file's own
42
+ # directory.
43
+ def initialize(tree:, path: nil, view: nil, frontmatter: {}, content_root: nil)
40
44
  @tree = tree
41
45
  @path = path
46
+ @content_root = content_root
42
47
  @frontmatter = frontmatter || {}
43
48
  @view = view || default_view
44
49
  @imports = ImportScanner.scan(tree)
@@ -197,12 +202,48 @@ module Andromeda
197
202
  end
198
203
 
199
204
  def evaluate_prop_expression(source, node)
205
+ name = source.to_s.strip
206
+ return imported_image(name, node) if image_import?(name)
207
+
200
208
  StaticExpression.evaluate(source, frontmatter: @frontmatter)
201
209
  rescue StaticExpression::UnsupportedExpressionError
202
210
  position = node_position(node)
203
211
  raise UnsupportedExpressionError.new(source, path: @path, line: position[:line], column: position[:column])
204
212
  end
205
213
 
214
+ IMAGE_EXTENSIONS = %w[.png .jpg .jpeg .gif .webp .avif .svg].freeze
215
+ IDENTIFIER_RE = /\A[A-Za-z_$][\w$]*\z/.freeze
216
+
217
+ # `{photo}` where `photo` was bound by `import photo from './photo.png'`
218
+ # -- Astro's way of passing an image to a component.
219
+ def image_import?(name)
220
+ return false unless IDENTIFIER_RE.match?(name)
221
+
222
+ specifier = @imports[name]
223
+ !specifier.nil? && IMAGE_EXTENSIONS.include?(File.extname(specifier).downcase)
224
+ end
225
+
226
+ # Publishes the imported file the same way an `image` attribute is, so
227
+ # the partial receives an Andromeda::Image and `andromeda_image_url`
228
+ # works on it unchanged. Containment is checked before existence so a
229
+ # crafted path never learns whether something outside the project exists.
230
+ #
231
+ # @return [Andromeda::Image]
232
+ # @raise [InvalidImageImportError]
233
+ def imported_image(name, node)
234
+ specifier = @imports[name]
235
+ invalid = ->(reason) { InvalidImageImportError.new(name, specifier, reason, path: @path, line: node_position(node)[:line]) }
236
+ raise invalid.call("must be a relative path (./ or ../)") unless specifier.start_with?("./", "../") && @path
237
+
238
+ source = File.expand_path(specifier, File.dirname(File.expand_path(@path)))
239
+ content_root = @content_root || File.dirname(source)
240
+ raise invalid.call("resolves outside the project") unless Andromeda::Assets.logical_path(source, content_root: content_root)
241
+ raise invalid.call("does not exist") unless File.file?(source)
242
+
243
+ logical = Andromeda::Assets.publish(source, content_root: content_root)
244
+ Andromeda::Image.new(path: source, relative_path: specifier, asset: Andromeda::Assets.marker_for(logical))
245
+ end
246
+
206
247
  # @return [Hash{Symbol => String}] `{content: ...}` plus one entry per
207
248
  # named slot. When no child carries a `slot` attribute this
208
249
  # is just `{content: children_html}` -- the common case reuses the
@@ -259,6 +300,14 @@ module Andromeda
259
300
  @view.render(partial: partial, locals: locals).html_safe
260
301
  rescue ActionView::MissingTemplate
261
302
  raise missing_partial_error(name, node, partial)
303
+ rescue ActionView::Template::Error => e
304
+ # A partial that queries entries (`Content::Post.find(slug)`) can
305
+ # raise Andromeda's own errors; ActionView wraps them, which would
306
+ # slip past the build's `rescue Andromeda::Error` and abort it with a
307
+ # stack trace instead of listing the problem with the others.
308
+ raise e.cause if e.cause.is_a?(Andromeda::Error)
309
+
310
+ raise
262
311
  ensure
263
312
  restore_view_annotation(annotate_before)
264
313
  end
@@ -170,7 +170,7 @@ module Andromeda
170
170
  # @return [Array<Andromeda::Entry>]
171
171
  # @raise [Andromeda::LoaderError] listing every file that failed.
172
172
  def source_entries
173
- load_entries_from_source
173
+ Andromeda::BuildContext.source_entries(self) { load_entries_from_source }
174
174
  end
175
175
 
176
176
  # Drops the in-memory cache, forcing the next query to re-run the
@@ -186,8 +186,13 @@ module Andromeda
186
186
  # needs was validated and written to `_index.json` at build time, so
187
187
  # reading it back is both faster and immune to a content file that
188
188
  # changed on disk after the build. Development goes to the Loader so
189
- # edits are visible without a build step.
189
+ # edits are visible without a build step. A build is the exception:
190
+ # the index is only rewritten once a collection finishes converting,
191
+ # so a component querying entries mid-build reads source instead
192
+ # (see Andromeda::BuildContext).
190
193
  def entries
194
+ return source_entries if Andromeda::BuildContext.building?
195
+
191
196
  @entries ||= production? ? entries_from_index : load_entries_from_source
192
197
  end
193
198
 
@@ -8,19 +8,28 @@ module Andromeda
8
8
  # Outputs an entry's converted body. Marked html_safe because the build
9
9
  # produced this markup from content the application's own authors wrote,
10
10
  # the same trust model Astro applies to `.md`/`.mdx` (see 13_security).
11
+ # Inside a component rendered at conversion time the asset markers are
12
+ # left in place, for the same reason `andromeda_image_url` leaves them.
11
13
  #
12
14
  # @param entry [Andromeda::Entry]
13
15
  # @return [ActiveSupport::SafeBuffer]
14
16
  def andromeda_content(entry)
15
- Andromeda::Assets.resolve(entry.html).html_safe # rubocop:disable Rails/OutputSafety
17
+ html = Andromeda::BuildContext.converting? ? entry.html : Andromeda::Assets.resolve(entry.html)
18
+ html.html_safe # rubocop:disable Rails/OutputSafety
16
19
  end
17
20
 
21
+ # Inside a component partial rendered at conversion time, this returns
22
+ # the asset marker instead of a URL: the digest does not exist until
23
+ # `assets:precompile` runs, after the build, and `andromeda_content`
24
+ # resolves the marker when the page is served.
25
+ #
18
26
  # @param image [Andromeda::Image, nil] an `image` attribute's value.
19
27
  # @return [String, nil] its URL, or nil when the attribute is unset.
20
28
  def andromeda_image_url(image)
21
29
  return nil if image.nil?
22
30
 
23
- Andromeda::Assets.resolve(image.asset || image.relative_path)
31
+ source = image.asset || image.relative_path
32
+ Andromeda::BuildContext.converting? ? source : Andromeda::Assets.resolve(source)
24
33
  end
25
34
 
26
35
  # Renders a nested table of contents from `entry.headings`.
@@ -62,10 +62,12 @@ module Andromeda
62
62
  def convert(entry)
63
63
  mdx = Andromeda::Parser.mdx?(entry.file_path)
64
64
  tree = Andromeda::Parser.parse(entry.body, mdx: mdx, path: entry.file_path)
65
- result = Andromeda::Renderer.new(
66
- components: components_for(entry, tree),
67
- image_resolver: image_resolver_for(entry)
68
- ).render(tree)
65
+ result = Andromeda::BuildContext.converting("#{entry.collection}/#{entry.id}") do
66
+ Andromeda::Renderer.new(
67
+ components: components_for(entry, tree),
68
+ image_resolver: image_resolver_for(entry)
69
+ ).render(tree)
70
+ end
69
71
 
70
72
  payload = {
71
73
  id: entry.id,
@@ -105,7 +107,11 @@ module Andromeda
105
107
  def fetch(entry)
106
108
  stored = @store.read_entry(entry.collection, entry.id)
107
109
 
108
- if production?
110
+ # During a build a component may ask for an entry that has not been
111
+ # converted yet (or only by the previous build), so it is converted
112
+ # the development way. Once done, the digest and render key match and
113
+ # a second request reuses it.
114
+ if production? && !Andromeda::BuildContext.building?
109
115
  return stored if stored
110
116
 
111
117
  raise Andromeda::BuildMissing.new(collection: entry.collection, id: entry.id, file_path: entry.file_path)
@@ -177,28 +183,7 @@ module Andromeda
177
183
  # @param entry_class [Class]
178
184
  # @return [BuildResult]
179
185
  def build_collection(entry_class)
180
- entries =
181
- begin
182
- entry_class.source_entries
183
- rescue Andromeda::LoaderError => e
184
- return BuildResult.new(
185
- collection: entry_class.collection_name, converted: 0,
186
- errors: e.messages.map { |message| "[#{entry_class.collection_name}] #{message}" }
187
- )
188
- end
189
-
190
- errors = []
191
- payloads = entries.filter_map do |entry|
192
- begin
193
- convert(entry)
194
- rescue Andromeda::Error => e
195
- errors << "[#{entry_class.collection_name}] #{entry.file_path}: #{e.message}"
196
- nil
197
- end
198
- end
199
-
200
- @store.replace_collection!(entry_class.collection_name, payloads)
201
- BuildResult.new(collection: entry_class.collection_name, converted: payloads.size, errors: errors, entries: entries)
186
+ Andromeda::BuildContext.building { build_collection_from_source(entry_class) }
202
187
  end
203
188
 
204
189
  # Converts every registered collection, reporting every problem found
@@ -216,13 +201,15 @@ module Andromeda
216
201
  # every collection, if any entry failed to convert or references an
217
202
  # entry that does not exist.
218
203
  def self.build_all(entry_classes = default_entry_classes, store: Andromeda::Store.new)
219
- results = entry_classes.map { |entry_class| new(store: store).build_collection(entry_class) }
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))
223
- raise Andromeda::BuildError, errors unless errors.empty?
224
-
225
- results
204
+ Andromeda::BuildContext.building do
205
+ results = entry_classes.map { |entry_class| new(store: store).build_collection(entry_class) }
206
+ errors = results.flat_map(&:errors)
207
+ loaded = entry_classes.zip(results).filter_map { |entry_class, result| [entry_class, result.entries] if result.entries }
208
+ errors.concat(Andromeda::References.problems(loaded.to_h))
209
+ raise Andromeda::BuildError, errors unless errors.empty?
210
+
211
+ results
212
+ end
226
213
  end
227
214
 
228
215
  def self.default_entry_classes
@@ -231,6 +218,31 @@ module Andromeda
231
218
 
232
219
  private
233
220
 
221
+ def build_collection_from_source(entry_class)
222
+ entries =
223
+ begin
224
+ entry_class.source_entries
225
+ rescue Andromeda::LoaderError => e
226
+ return BuildResult.new(
227
+ collection: entry_class.collection_name, converted: 0,
228
+ errors: e.messages.map { |message| "[#{entry_class.collection_name}] #{message}" }
229
+ )
230
+ end
231
+
232
+ errors = []
233
+ payloads = entries.filter_map do |entry|
234
+ begin
235
+ convert(entry)
236
+ rescue Andromeda::Error => e
237
+ errors << "[#{entry_class.collection_name}] #{entry.file_path}: #{e.message}"
238
+ nil
239
+ end
240
+ end
241
+
242
+ @store.replace_collection!(entry_class.collection_name, payloads)
243
+ BuildResult.new(collection: entry_class.collection_name, converted: payloads.size, errors: errors, entries: entries)
244
+ end
245
+
234
246
  # What the stored HTML depends on besides the entry's own source.
235
247
  # Component partials are rendered into the HTML at conversion time, so
236
248
  # without this an edited partial would keep serving the old markup until
@@ -318,7 +330,10 @@ module Andromeda
318
330
  return nil unless Andromeda::Parser.mdx?(entry.file_path)
319
331
  return nil unless defined?(Andromeda::Components)
320
332
 
321
- Andromeda::Components.new(tree: tree, path: entry.file_path, view: nil, frontmatter: entry.data)
333
+ Andromeda::Components.new(
334
+ tree: tree, path: entry.file_path, view: nil, frontmatter: entry.data,
335
+ content_root: Andromeda::Registry[entry.collection]&.base_dir
336
+ )
322
337
  end
323
338
  end
324
339
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Andromeda
4
- VERSION = "0.3.0"
4
+ VERSION = "0.4.0"
5
5
  end
data/lib/andromeda_cms.rb CHANGED
@@ -3,6 +3,7 @@
3
3
  require_relative "andromeda/version"
4
4
  require_relative "andromeda/errors"
5
5
  require_relative "andromeda/configuration"
6
+ require_relative "andromeda/build_context"
6
7
  require_relative "andromeda/assets"
7
8
  require_relative "andromeda/helpers"
8
9
  require_relative "andromeda/fix"
@@ -44,12 +44,20 @@ module Andromeda
44
44
  def partial_content
45
45
  <<~ERB
46
46
  #{usage_comment}
47
+ #{strict_locals}
47
48
  <div class="#{underscored_name.dasherize}">
48
49
  #{prop_lines.join("\n")}#{"\n" unless prop_lines.empty?} <%= content %>
49
50
  </div>
50
51
  ERB
51
52
  end
52
53
 
54
+ # Every prop is optional, and `content` is declared too: it is passed
55
+ # even to a self-closing tag (`<Callout />`), where it is empty.
56
+ def strict_locals
57
+ locals = props.map { |prop| "#{prop.underscore}: nil" } << "content: nil"
58
+ "<%# locals: (#{locals.join(', ')}) %>"
59
+ end
60
+
53
61
  def prop_lines
54
62
  props.map do |prop|
55
63
  local = prop.underscore
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.3.0
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - LIMHAUS Inc.
@@ -89,6 +89,7 @@ files:
89
89
  - ext/andromeda_cms/src/lib.rs
90
90
  - ext/andromeda_cms/src/nodes.rs
91
91
  - lib/andromeda/assets.rb
92
+ - lib/andromeda/build_context.rb
92
93
  - lib/andromeda/check.rb
93
94
  - lib/andromeda/components.rb
94
95
  - lib/andromeda/components/errors.rb