andromeda_cms 0.1.0-x64-mingw-ucrt

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
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 01dfc41ae83699ed764ba2c2b3596f5182a20645d28bd3a45feb0c91bcdf8025
4
+ data.tar.gz: a21237782aeb616c1cc910c8c647e9360617dfc366edcf81b5648af2cc8f5aa8
5
+ SHA512:
6
+ metadata.gz: d5cdb5f3a5f65ab404c1c2dca2e4a26add8490242458e2b214fab2a425c742f3fd2b9151b289c61852b2989c232036407bf9cf879da896ed254752d97d5a9c43
7
+ data.tar.gz: fb49701ec535bb29ad80b744d7dfac5c1261214e1e29832a489d0d9576383064ac08a5da5a623fd4eb5d6290170f9a58087963d26d24ea2a1b5c324e4e64b377
data/CHANGELOG.md ADDED
@@ -0,0 +1,30 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [0.1.0] - 2026-09-27
9
+
10
+ ### Added
11
+
12
+ - Astro-compatible content collections: entry classes with a schema DSL, an
13
+ Astro-compatible id scheme, and queries (`all`/`find`/`where`/`order`/scopes).
14
+ - Markdown and MDX conversion through Sätteri, the engine Astro 7 uses by
15
+ default, with GFM, smart punctuation, github-slugger heading ids and Rouge
16
+ syntax highlighting.
17
+ - MDX components rendered as Rails partials, including props, children and
18
+ named slots.
19
+ - Frontmatter parsing with Astro-compatible semantics: YAML 1.2 scalars, TOML
20
+ (`+++`), and line numbers preserved for error messages.
21
+ - Build pipeline: `andromeda:build` (hooked into `assets:precompile`),
22
+ `andromeda:check`, `andromeda:fix` and `andromeda:clobber`, writing to
23
+ `.andromeda/`; on-demand conversion in development.
24
+ - Images referenced from content published through the asset pipeline.
25
+ - View helpers: `andromeda_content`, `andromeda_toc`, `andromeda_meta_tags`,
26
+ `andromeda_image_url`.
27
+ - Generators: `andromeda:install`, `andromeda:collection`,
28
+ `andromeda:component` and `andromeda:import_astro`.
29
+
30
+ [0.1.0]: https://github.com/AndromedaCMS/andromeda_cms/releases/tag/v0.1.0
data/CONTRIBUTING.md ADDED
@@ -0,0 +1,47 @@
1
+ # Contributing
2
+
3
+ **Andromeda is open source, but not open contribution.**
4
+
5
+ The code is MIT licensed: use it, fork it, change it, ship it. What this
6
+ project does not do is take patches into its own implementation. That keeps the
7
+ copyright in one place, which in turn keeps relicensing, redistribution and
8
+ long-term maintenance decisions simple — the same reasoning
9
+ [SQLite](https://www.sqlite.org/copyright.html) gives for its own policy.
10
+
11
+ So pull requests are generally closed unfixed, and that is not a judgement on
12
+ the code in them. Please do not spend an afternoon on a patch expecting it to be
13
+ merged.
14
+
15
+ ## What helps most
16
+
17
+ **Bug reports.** They are the highest-value contribution here, and they are
18
+ always welcome. A good one includes:
19
+
20
+ - the versions of Ruby, Rails and `andromeda_cms`
21
+ - the content file that triggers it, reduced to the smallest thing that still
22
+ fails (frontmatter plus a few lines of body is usually enough)
23
+ - what you expected, and what happened, including the full error message
24
+ - for a compatibility problem: what Astro does with the same file
25
+
26
+ **Compatibility gaps.** Content that works in Astro and does not work here is a
27
+ bug, even if the error message looks reasonable. Say which Astro version the
28
+ content came from.
29
+
30
+ **Documentation that is wrong or missing.** Point at the page and say what was
31
+ confusing; that is enough.
32
+
33
+ **Ideas.** Open an issue describing the problem you hit rather than the
34
+ implementation you have in mind — the problem is the part that is hard to
35
+ guess from the outside.
36
+
37
+ ## What to expect
38
+
39
+ Issues are read. Not every one becomes a change: this gem deliberately keeps a
40
+ small surface, and compatibility with Astro's behaviour outranks new options.
41
+ When something is declined, the issue will say why.
42
+
43
+ ## Forking
44
+
45
+ Forking is a legitimate outcome, not a hostile one. If this project will not go
46
+ where you need it to go, the MIT licence exists precisely so you can take it
47
+ there yourself.
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 LIMHAUS Inc.
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,276 @@
1
+ # Andromeda
2
+
3
+ Astro-compatible content collections for Ruby on Rails.
4
+
5
+ [Documentation](https://www.andromedacms.dev/docs) · [Changelog](CHANGELOG.md)
6
+
7
+ Copy the `.md` and `.mdx` files from an [Astro](https://astro.build) project
8
+ into `app/content/`, and Andromeda validates their frontmatter against a schema
9
+ and renders them as ordinary Rails pages. The heavy work — parsing Markdown and
10
+ MDX and expanding components — happens once at deploy time; requests only read
11
+ the result.
12
+
13
+ > **Status: pre-release.** The API is not stable yet.
14
+
15
+ ## Why
16
+
17
+ Adding a blog, docs or a marketing site to a Rails app has usually meant one of
18
+ three things: standing up a separate Astro or Next.js project, installing a CMS
19
+ gem with its own tables and admin screens, or wiring up a headless CMS over
20
+ HTTP. Andromeda takes Astro's content model — collections, schemas, MDX
21
+ components — and makes it a gem, so the content lives next to the application
22
+ that already has your authentication, your layouts and your deploy pipeline.
23
+
24
+ ### Files are the interface
25
+
26
+ Those three options all put content behind a database and a screen. That made
27
+ sense while people were the only ones writing. They are not any more: much of
28
+ the writing, editing and restructuring now happens with an assistant in the
29
+ loop, and an assistant is good at exactly one thing here — reading and writing
30
+ files in a repository it can already see. Pointing it at a CMS means first
31
+ building it an API or an MCP server, giving that its own authentication, and
32
+ keeping it in sync with the schema. A second system, to serve the first.
33
+
34
+ Content as files needs none of that. Adding a post is adding a file, editing
35
+ one is an edit, removing one is `rm`. The change arrives as a diff, is reviewed
36
+ like any other, and ships with the deploy that carried it — and the tool doing
37
+ the writing needs no integration at all, because it already has the repository.
38
+ Of everything in Astro's design, this is the part that looks most like where
39
+ things are going.
40
+
41
+ ### Alongside Astro, not against it
42
+
43
+ I like [Astro](https://astro.build), and I have a great deal of respect for the
44
+ team behind it. For a static site I still reach for it. But when a site needs
45
+ sessions, forms, background jobs and a database, I want Rails. Andromeda exists
46
+ so that this is not a choice between the two: the collection stays the same
47
+ directory of files, parsed by the same engine Astro 7 uses, and moving it into
48
+ Rails is a copy.
49
+
50
+ ## Installation
51
+
52
+ ```ruby
53
+ # Gemfile
54
+ gem "andromeda_cms"
55
+ ```
56
+
57
+ ```bash
58
+ bundle install
59
+ bin/rails generate andromeda:install
60
+ ```
61
+
62
+ Ruby 3.2+ and Rails 8.0+. The gem ships precompiled binaries for common
63
+ platforms, so no Rust toolchain is needed to install it.
64
+
65
+ ## Getting started
66
+
67
+ ```bash
68
+ bin/rails generate andromeda:collection blog title:string pub_date:date
69
+ ```
70
+
71
+ That writes an entry class, a controller, views, a route and a first post:
72
+
73
+ ```ruby
74
+ # app/models/content/blog.rb
75
+ module Content
76
+ class Blog < Andromeda::Entry
77
+ collection :blog, base: "app/content/blog", pattern: "**/*.{md,mdx}"
78
+
79
+ attribute :title, :string
80
+ attribute :pub_date, :date
81
+
82
+ # scope :published, -> { where(draft: false) }
83
+ end
84
+ end
85
+ ```
86
+
87
+ ```ruby
88
+ # app/controllers/blog_controller.rb
89
+ class BlogController < ApplicationController
90
+ def index
91
+ @entries = Content::Blog.all.order(pub_date: :desc)
92
+ end
93
+
94
+ def show
95
+ @entry = Content::Blog.find(params[:slug])
96
+ end
97
+ end
98
+ ```
99
+
100
+ ```erb
101
+ <%# app/views/blog/show.html.erb %>
102
+ <article>
103
+ <h1><%= @entry.title %></h1>
104
+ <%= andromeda_content @entry %>
105
+ </article>
106
+ ```
107
+
108
+ From there it is your code. A typical next step is to make fields mandatory,
109
+ add a draft flag, and add a table of contents:
110
+
111
+ ```ruby
112
+ attribute :title, :string, required: true
113
+ attribute :pub_date, :date, required: true
114
+ attribute :draft, :boolean, default: false
115
+
116
+ scope :published, -> { where(draft: false) }
117
+ ```
118
+
119
+ ```erb
120
+ <%= andromeda_toc @entry %>
121
+ ```
122
+
123
+ An unknown slug raises `Andromeda::EntryNotFound`, which Rails answers with a
124
+ 404.
125
+
126
+ Pages are ordinary Rails, so layouts, `current_user`, CSRF tokens, caching and
127
+ authorization all behave exactly as they normally do.
128
+
129
+ ## Content
130
+
131
+ ```markdown
132
+ ---
133
+ title: Hello world
134
+ pub_date: 2026-09-01
135
+ tags: [rails, astro]
136
+ ---
137
+
138
+ ## Getting started
139
+
140
+ Ordinary GFM Markdown, with smart punctuation and syntax highlighting.
141
+ ```
142
+
143
+ Frontmatter is validated against the entry class: a missing `title` or an
144
+ unparseable date fails the build with the file name and line number, rather
145
+ than surfacing as `nil` in a view.
146
+
147
+ ### MDX components
148
+
149
+ MDX components map to Rails partials — no ViewComponent dependency:
150
+
151
+ ```mdx
152
+ import Callout from 'content_components/callout';
153
+
154
+ <Callout type="tip" title="Note">
155
+ The body is **Markdown** too.
156
+ </Callout>
157
+ ```
158
+
159
+ ```erb
160
+ <%# app/views/content_components/_callout.html.erb %>
161
+ <aside class="callout callout--<%= local_assigns.fetch(:type, "note") %>">
162
+ <% if local_assigns[:title] %><p class="callout__title"><%= title %></p><% end %>
163
+ <%= content %>
164
+ </aside>
165
+ ```
166
+
167
+ Props arrive as locals (`camelCase` becomes `snake_case`), the tag's children
168
+ arrive as `content`, and `<Fragment slot="header">` becomes a `header` local.
169
+ `bin/rails generate andromeda:component Callout type title` writes the stub.
170
+
171
+ ### Images
172
+
173
+ Put an image next to the entry and reference it relatively:
174
+
175
+ ```
176
+ app/content/blog/my-post/
177
+ ├── index.md
178
+ └── cover.png
179
+ ```
180
+
181
+ ```markdown
182
+ ![Cover](./cover.png)
183
+ ```
184
+
185
+ Referenced images are copied into `app/assets/builds/andromeda/` during the
186
+ build and served, digest-stamped, by the asset pipeline. Images the app
187
+ already ships (`app/assets/images/logo.png`, written as `logo.png`) resolve
188
+ through the pipeline too; anything under `public/` or on another host is left
189
+ untouched. Declare a frontmatter image with `attribute :hero_image, :image`
190
+ and render it with `andromeda_image_url`.
191
+
192
+ ## Development and deployment
193
+
194
+ ```
195
+ development: request → entry is converted on demand when its source changed
196
+ production: deploy → andromeda:build writes .andromeda/, requests only read it
197
+ ```
198
+
199
+ There is no watcher process to run: in development a file is converted the
200
+ first time something asks for it after it changes. In production the parser
201
+ never runs while serving a request, and an unbuilt entry raises an error that
202
+ names the fix instead of silently converting.
203
+
204
+ `andromeda:build` is hooked into `assets:precompile`, so the Rails 8 Dockerfile
205
+ (and Kamal, and Heroku) already runs it. `.andromeda/` is a build artifact:
206
+ the installer adds it to `.gitignore` and `.dockerignore`.
207
+
208
+ | Task | What it does |
209
+ |------|--------------|
210
+ | `andromeda:build` | Convert every entry into `.andromeda/` (runs during `assets:precompile`) |
211
+ | `andromeda:check` | Report schema problems, missing components and non-snake_case keys; writes nothing (for CI) |
212
+ | `andromeda:fix` | Rewrite non-snake_case frontmatter keys in place |
213
+ | `andromeda:clobber` | Remove `.andromeda/` (runs during `assets:clobber`) |
214
+
215
+ ## Migrating from Astro
216
+
217
+ ```bash
218
+ bin/rails generate andromeda:import_astro ../my-astro-site
219
+ ```
220
+
221
+ It copies `src/content` and `src/assets`, rewrites frontmatter keys to
222
+ snake_case and MDX imports to partial paths, converts `content.config.ts` into
223
+ entry classes, generates stubs for the components your content imports, and
224
+ prints what still needs a human. `--dry-run` shows the plan without writing.
225
+
226
+ Astro's own [`examples/blog`](https://github.com/withastro/astro/tree/main/examples/blog)
227
+ imports and renders unchanged; that is a test in this repository.
228
+
229
+ ## Compatibility
230
+
231
+ Andromeda targets Astro 7 and later and uses
232
+ [Sätteri](https://github.com/bruits/satteri) — the Markdown and MDX engine
233
+ Astro 7 uses by default — so content is parsed by the same engine that parsed
234
+ it in Astro. Heading ids use github-slugger, `headings` has the same shape as
235
+ Astro's `render()`, and entry ids follow Astro's `glob()` loader rules.
236
+
237
+ The goal is that content copied from an Astro project loads without errors and
238
+ displays. Byte-identical output is a non-goal: syntax highlighting uses Rouge
239
+ rather than Shiki (same markup shape, approximate colours), and images are
240
+ served by the asset pipeline rather than Astro's image service.
241
+
242
+ Not supported: Astro's pages and routing, islands and `client:*` directives,
243
+ remark/rehype plugins, and arbitrary JavaScript in MDX expressions (literals,
244
+ `{frontmatter.x}` and comments are evaluated; anything else is an error rather
245
+ than a silent drop).
246
+
247
+ ## Development
248
+
249
+ ```bash
250
+ bin/setup # or: bundle install
251
+ bundle exec rake # compiles the Rust extension, then runs the tests
252
+ bundle exec rake test:corpus # after test/fetch_corpus.sh: parse ~370 real Astro files
253
+ ```
254
+
255
+ ## Troubleshooting
256
+
257
+ **`ArgumentError: wrong number of arguments (given 2, expected 1)` from `JSON.parse`.**
258
+ Not this gem: Rails 8.1 calls `JSON.parse` with a positional options hash, which
259
+ version 3 of the `json` gem no longer accepts, and decrypting a session cookie
260
+ hits that path — so pages fail only once a session exists. Pin the `json` gem
261
+ until Rails ships a fix:
262
+
263
+ ```ruby
264
+ # Gemfile
265
+ gem "json", "~> 2.9"
266
+ ```
267
+
268
+ ## Contributing
269
+
270
+ Bug reports are welcome; patches are not. Andromeda is open source but not open
271
+ contribution — see [CONTRIBUTING.md](CONTRIBUTING.md) for why, and for what a
272
+ useful bug report contains.
273
+
274
+ ## License
275
+
276
+ Copyright (c) 2026 [LIMHAUS Inc.](https://www.limhaus.com/). Released under the [MIT License](LICENSE.txt).
@@ -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