andromeda_cms 0.1.0-x86_64-darwin
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 +7 -0
- data/CHANGELOG.md +30 -0
- data/CONTRIBUTING.md +47 -0
- data/LICENSE.txt +21 -0
- data/README.md +276 -0
- data/lib/andromeda/assets.rb +128 -0
- data/lib/andromeda/check.rb +71 -0
- data/lib/andromeda/components/errors.rb +97 -0
- data/lib/andromeda/components/import_scanner.rb +72 -0
- data/lib/andromeda/components/static_expression.rb +79 -0
- data/lib/andromeda/components.rb +348 -0
- data/lib/andromeda/configuration.rb +91 -0
- data/lib/andromeda/entry.rb +294 -0
- data/lib/andromeda/errors.rb +173 -0
- data/lib/andromeda/fix.rb +71 -0
- data/lib/andromeda/frontmatter.rb +327 -0
- data/lib/andromeda/helpers.rb +84 -0
- data/lib/andromeda/id.rb +58 -0
- data/lib/andromeda/loader.rb +96 -0
- data/lib/andromeda/parser.rb +75 -0
- data/lib/andromeda/pipeline.rb +296 -0
- data/lib/andromeda/railtie.rb +47 -0
- data/lib/andromeda/registry.rb +127 -0
- data/lib/andromeda/relation.rb +125 -0
- data/lib/andromeda/renderer/code_highlighter.rb +53 -0
- data/lib/andromeda/renderer/errors.rb +52 -0
- data/lib/andromeda/renderer/literal_expression.rb +201 -0
- data/lib/andromeda/renderer.rb +411 -0
- data/lib/andromeda/schema.rb +383 -0
- data/lib/andromeda/slugger.rb +68 -0
- data/lib/andromeda/store.rb +286 -0
- data/lib/andromeda/tasks/andromeda.rake +56 -0
- data/lib/andromeda/version.rb +5 -0
- data/lib/andromeda_cms/3.2/andromeda_cms.bundle +0 -0
- data/lib/andromeda_cms/3.3/andromeda_cms.bundle +0 -0
- data/lib/andromeda_cms/3.4/andromeda_cms.bundle +0 -0
- data/lib/andromeda_cms/4.0/andromeda_cms.bundle +0 -0
- data/lib/andromeda_cms.rb +41 -0
- data/lib/generators/andromeda/collection/USAGE +25 -0
- data/lib/generators/andromeda/collection/collection_generator.rb +239 -0
- data/lib/generators/andromeda/component/USAGE +15 -0
- data/lib/generators/andromeda/component/component_generator.rb +75 -0
- data/lib/generators/andromeda/import_astro/USAGE +21 -0
- data/lib/generators/andromeda/import_astro/astro_schema_json.rb +80 -0
- data/lib/generators/andromeda/import_astro/balanced_scanner.rb +168 -0
- data/lib/generators/andromeda/import_astro/content_config_converter.rb +232 -0
- data/lib/generators/andromeda/import_astro/import_astro_generator.rb +313 -0
- data/lib/generators/andromeda/import_astro/mdx_content_scanner.rb +91 -0
- data/lib/generators/andromeda/import_astro/mdx_import_rewriter.rb +91 -0
- data/lib/generators/andromeda/install/USAGE +11 -0
- data/lib/generators/andromeda/install/install_generator.rb +77 -0
- data/lib/generators/andromeda/install/templates/initializer.rb +30 -0
- metadata +163 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 7f68ee6edd705507759d73adf1053b7fd365b16b5189f8556f7481f8c85e6da3
|
|
4
|
+
data.tar.gz: 6d39ddf7cc77ab9b486350b1aa20b91e3a129dfb72ebe30b70a6a9f798546204
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 9aa17d085177c1cca62daf1969b2dd1c2db6f19485c31381dea832b24073f24bba4e680c7c6361f32a31297bc54b7e27e5fcae9d48e26f72de8bdee664fdb2d0
|
|
7
|
+
data.tar.gz: 7b314110d6f9674f6f0be66269dd286282fa0af5ef6ba670980719ef9a65760d8137a496c956c22a5def3666149091e63408b9ac38626062eceddd649f00c539
|
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
|
+

|
|
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
|