andromeda_cms 0.1.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 +7 -0
- data/CHANGELOG.md +30 -0
- data/CONTRIBUTING.md +47 -0
- data/Cargo.lock +1182 -0
- data/Cargo.toml +12 -0
- data/LICENSE.txt +21 -0
- data/README.md +276 -0
- data/ext/andromeda_cms/Cargo.toml +18 -0
- data/ext/andromeda_cms/extconf.rb +6 -0
- data/ext/andromeda_cms/src/errors.rs +55 -0
- data/ext/andromeda_cms/src/lib.rs +143 -0
- data/ext/andromeda_cms/src/nodes.rs +400 -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.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 +161 -0
data/Cargo.toml
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# rb-sys resolves crate metadata from the gem root, so the extension lives in a
|
|
2
|
+
# workspace rather than standing alone under ext/.
|
|
3
|
+
[workspace]
|
|
4
|
+
members = ["ext/andromeda_cms"]
|
|
5
|
+
resolver = "2"
|
|
6
|
+
|
|
7
|
+
# Profiles only take effect at the workspace root; in the member crate Cargo
|
|
8
|
+
# ignored them, which left Windows builds unstripped at four times the size.
|
|
9
|
+
[profile.release]
|
|
10
|
+
opt-level = 3
|
|
11
|
+
strip = true
|
|
12
|
+
lto = true
|
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,18 @@
|
|
|
1
|
+
[package]
|
|
2
|
+
name = "andromeda_cms"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
edition = "2021"
|
|
5
|
+
publish = false
|
|
6
|
+
|
|
7
|
+
[lib]
|
|
8
|
+
crate-type = ["cdylib"]
|
|
9
|
+
|
|
10
|
+
[dependencies]
|
|
11
|
+
magnus = "0.8"
|
|
12
|
+
# Sätteri is the Markdown/MDX engine Astro 7 uses by default, so parsing here
|
|
13
|
+
# matches what the content author saw in Astro. It is pre-1.0: versions are
|
|
14
|
+
# pinned and a corpus regression test guards upgrades.
|
|
15
|
+
satteri-ast = "0.5"
|
|
16
|
+
satteri-arena = "0.3"
|
|
17
|
+
satteri-pulldown-cmark = "0.6"
|
|
18
|
+
serde_json = "1"
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
//! Converts Sätteri parse failures into the `Andromeda::*` exception classes
|
|
2
|
+
//! that Ruby callers already know about (see `lib/andromeda/errors.rb`).
|
|
3
|
+
//!
|
|
4
|
+
//! Rust raises the real Ruby classes directly (rather than a generic
|
|
5
|
+
//! `RuntimeError`) so `rescue Andromeda::SyntaxError` works without the Ruby
|
|
6
|
+
//! wrapper having to re-wrap every native call. The classes are looked up by
|
|
7
|
+
//! name through `Ruby#eval` instead of being cached, because construction
|
|
8
|
+
//! only happens on the (rare) error path — the ~0.3ms/file hot path never
|
|
9
|
+
//! touches this module.
|
|
10
|
+
|
|
11
|
+
use magnus::{kwargs, Class, Error, ExceptionClass, Ruby};
|
|
12
|
+
use satteri_arena::LineIndex;
|
|
13
|
+
use std::any::Any;
|
|
14
|
+
|
|
15
|
+
/// Builds an `Andromeda::SyntaxError` from a Sätteri MDX error.
|
|
16
|
+
///
|
|
17
|
+
/// Sätteri reports errors as `(byte_offset, message)` pairs with no line/
|
|
18
|
+
/// column of their own, so we re-derive them from the same `LineIndex` the
|
|
19
|
+
/// parser itself uses (1-based line/column, matching `ArenaNode`'s fields).
|
|
20
|
+
///
|
|
21
|
+
/// `path` is intentionally not set here: the native layer has no notion of
|
|
22
|
+
/// "which file is this" — `Andromeda::Parser.parse` (Ruby) re-raises with
|
|
23
|
+
/// `path:` attached once it knows it.
|
|
24
|
+
pub fn syntax_error(
|
|
25
|
+
ruby: &Ruby,
|
|
26
|
+
source: &str,
|
|
27
|
+
offset: usize,
|
|
28
|
+
message: &str,
|
|
29
|
+
) -> Result<Error, Error> {
|
|
30
|
+
let index = LineIndex::from_source(source);
|
|
31
|
+
let mut cursor = index.cursor();
|
|
32
|
+
let (line, column) = cursor.offset_to_line_col(offset as u32);
|
|
33
|
+
|
|
34
|
+
let class: ExceptionClass = ruby.eval("Andromeda::SyntaxError")?;
|
|
35
|
+
let exception =
|
|
36
|
+
class.new_instance((message, kwargs!(ruby, "line" => line, "column" => column)))?;
|
|
37
|
+
Ok(Error::from(exception))
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/// Builds an `Andromeda::ParserError` from a caught Rust panic payload.
|
|
41
|
+
///
|
|
42
|
+
/// Sätteri is pre-1.0 (see Cargo.toml comment); a panic here must not abort
|
|
43
|
+
/// the whole Ruby process, so callers wrap the parse in `catch_unwind` and
|
|
44
|
+
/// hand the payload to this function instead of letting it unwind past the
|
|
45
|
+
/// FFI boundary (which would be undefined behavior).
|
|
46
|
+
pub fn parser_error(ruby: &Ruby, payload: &(dyn Any + Send)) -> Result<Error, Error> {
|
|
47
|
+
let message = payload
|
|
48
|
+
.downcast_ref::<&str>()
|
|
49
|
+
.map(|s| s.to_string())
|
|
50
|
+
.or_else(|| payload.downcast_ref::<String>().cloned())
|
|
51
|
+
.unwrap_or_else(|| "the native Markdown/MDX parser panicked".to_string());
|
|
52
|
+
|
|
53
|
+
let class: ExceptionClass = ruby.eval("Andromeda::ParserError")?;
|
|
54
|
+
Ok(Error::new(class, message))
|
|
55
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
//! Native Markdown/MDX parser for the `andromeda_cms` gem.
|
|
2
|
+
//!
|
|
3
|
+
//! Wraps Sätteri (the Rust engine Astro 7+ uses by default) to extract an
|
|
4
|
+
//! mdast tree as JSON. See `Cargo.toml` for why Sätteri.
|
|
5
|
+
//!
|
|
6
|
+
//! Parsing happens fully in Rust; the mdast tree crosses the FFI boundary as
|
|
7
|
+
//! a single JSON string rather than as nested Ruby objects, because building
|
|
8
|
+
//! Ruby `Hash`/`Array` objects node-by-node through the Ruby C API is far
|
|
9
|
+
//! slower than one `serde_json::to_string` + one `JSON.parse` on the Ruby
|
|
10
|
+
//! side (roughly comparable either way; JSON avoids also
|
|
11
|
+
//! having to hold the GVL while walking the arena).
|
|
12
|
+
|
|
13
|
+
mod errors;
|
|
14
|
+
mod nodes;
|
|
15
|
+
|
|
16
|
+
use magnus::{function, prelude::*, Error, Ruby};
|
|
17
|
+
use satteri_pulldown_cmark::{Options, DEFAULT_OPTIONS};
|
|
18
|
+
use std::panic;
|
|
19
|
+
|
|
20
|
+
/// Identifies the engine backing `Parser.parse`, so host apps and tests can
|
|
21
|
+
/// assert on it without depending on Sätteri internals directly.
|
|
22
|
+
fn parser_backend() -> String {
|
|
23
|
+
"satteri".to_string()
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
fn options_for(mdx: bool) -> Options {
|
|
27
|
+
// satteri-pulldown-cmark's own MDX_OPTIONS constant is just
|
|
28
|
+
// `DEFAULT_OPTIONS | ENABLE_MDX` (arena_build.rs); it is not used
|
|
29
|
+
// directly below because ENABLE_SMART_PUNCTUATION also needs adding to
|
|
30
|
+
// both the MDX and non-MDX cases. DEFAULT_OPTIONS already turns on GFM (tables,
|
|
31
|
+
// strikethrough, task lists, autolinks), footnotes, math, and YAML
|
|
32
|
+
// frontmatter, matching Astro's `markdown.gfm` default of `true`
|
|
33
|
+
// (`@astrojs/markdown-satteri`'s `features: { gfm: gfm !== false, ... }`).
|
|
34
|
+
//
|
|
35
|
+
// Deliberately NOT enabled, to stay compatible with what Astro actually
|
|
36
|
+
// ships: ENABLE_HEADING_ATTRIBUTES (remark doesn't parse `# H {#id}`,
|
|
37
|
+
// and Astro derives heading ids via a separate github-slugger pass, not
|
|
38
|
+
// Sätteri), ENABLE_DIRECTIVE / ENABLE_DEFINITION_LIST / ENABLE_SUPERSCRIPT
|
|
39
|
+
// / ENABLE_SUBSCRIPT (no remark-directive/remark-supersub equivalent in
|
|
40
|
+
// Astro's default pipeline).
|
|
41
|
+
//
|
|
42
|
+
// ENABLE_SMART_PUNCTUATION:
|
|
43
|
+
// Astro enables remark-smartypants unconditionally
|
|
44
|
+
// (packages/internal-helpers/src/markdown.ts's `markdownConfigDefaults`),
|
|
45
|
+
// but DEFAULT_OPTIONS does *not*
|
|
46
|
+
// include it -- confirmed by reading satteri-pulldown-cmark's
|
|
47
|
+
// arena_build.rs (`DEFAULT_OPTIONS` bitset has no
|
|
48
|
+
// `ENABLE_SMART_PUNCTUATION`). It has to be turned on explicitly here so
|
|
49
|
+
// straight quotes/dashes/ellipses in Markdown text get the same
|
|
50
|
+
// curly-quote/em-dash/ellipsis substitution Astro's output has by
|
|
51
|
+
// default; `Options::ENABLE_SMART_PUNCTUATION` is itself just
|
|
52
|
+
// `ENABLE_SMART_QUOTES | ENABLE_SMART_DASHES | ENABLE_SMART_ELLIPSES`
|
|
53
|
+
// (satteri-pulldown-cmark's lib.rs), which is exactly what
|
|
54
|
+
// retext-smartypants' default options transform.
|
|
55
|
+
let base = DEFAULT_OPTIONS | Options::ENABLE_SMART_PUNCTUATION;
|
|
56
|
+
|
|
57
|
+
if mdx {
|
|
58
|
+
base | Options::ENABLE_MDX
|
|
59
|
+
} else {
|
|
60
|
+
base
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/// `Andromeda::Parser.native_parse(source, mdx)` -> mdast JSON string.
|
|
65
|
+
///
|
|
66
|
+
/// Ruby-facing errors: `Andromeda::SyntaxError` (source could not be parsed,
|
|
67
|
+
/// carries line/column) or `Andromeda::ParserError` (Sätteri itself panicked).
|
|
68
|
+
/// See `lib/andromeda/parser.rb` for the public wrapper that attaches `path`.
|
|
69
|
+
///
|
|
70
|
+
/// Nesting is capped at `MAX_DEPTH` (see there for why).
|
|
71
|
+
/// Deeper trees are rejected as a syntax error instead of converted.
|
|
72
|
+
///
|
|
73
|
+
/// Converting the tree to JSON, and rendering it to HTML on the Ruby side,
|
|
74
|
+
/// are both recursive over nesting depth. A few thousand nested `>` or
|
|
75
|
+
/// `<div>` overflow the native stack, and on a Ruby thread other than the
|
|
76
|
+
/// main one -- which is where Puma serves development requests -- the stack
|
|
77
|
+
/// is small enough that 5,000 levels already fail. Real content nests a handful of levels; 256 leaves ample room for it
|
|
78
|
+
/// while keeping every recursive step far from any stack limit.
|
|
79
|
+
const MAX_DEPTH: usize = 256;
|
|
80
|
+
|
|
81
|
+
/// Longest run of a single `*` or `_` accepted.
|
|
82
|
+
///
|
|
83
|
+
/// Sätteri resolves emphasis recursively while parsing, before there is a
|
|
84
|
+
/// tree for `MAX_DEPTH` to inspect, so its depth has to be bounded from the
|
|
85
|
+
/// source instead. On a Puma thread a run of about 50,000 exhausts the
|
|
86
|
+
/// stack; no real document has a run anywhere near 10,000.
|
|
87
|
+
const MAX_DELIMITER_RUN: usize = 10_000;
|
|
88
|
+
|
|
89
|
+
/// Byte offset where the first over-long `*`/`_` run starts, if any.
|
|
90
|
+
fn overlong_delimiter_run(source: &str) -> Option<usize> {
|
|
91
|
+
let bytes = source.as_bytes();
|
|
92
|
+
let mut start = 0;
|
|
93
|
+
for (index, &byte) in bytes.iter().enumerate() {
|
|
94
|
+
if index == 0 || byte != bytes[index - 1] {
|
|
95
|
+
start = index;
|
|
96
|
+
}
|
|
97
|
+
if (byte == b'*' || byte == b'_') && index - start + 1 > MAX_DELIMITER_RUN {
|
|
98
|
+
return Some(start);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
None
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
fn rb_native_parse(ruby: &Ruby, source: String, mdx: bool) -> Result<String, Error> {
|
|
105
|
+
if let Some(offset) = overlong_delimiter_run(&source) {
|
|
106
|
+
let message = format!("a run of more than {MAX_DELIMITER_RUN} `*` or `_` characters is nested too deeply to parse");
|
|
107
|
+
return Err(errors::syntax_error(ruby, &source, offset, &message)?);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
let parsed = panic::catch_unwind(panic::AssertUnwindSafe(|| {
|
|
111
|
+
satteri_pulldown_cmark::parse(&source, options_for(mdx))
|
|
112
|
+
}));
|
|
113
|
+
|
|
114
|
+
let (arena, mdx_errors) = match parsed {
|
|
115
|
+
Ok(pair) => pair,
|
|
116
|
+
// Sätteri is pre-1.0 (see Cargo.toml); a panic must not abort the
|
|
117
|
+
// whole Ruby process, so it is converted into a normal exception.
|
|
118
|
+
Err(payload) => return Err(errors::parser_error(ruby, payload.as_ref())?),
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
if let Some((offset, message)) = mdx_errors.first() {
|
|
122
|
+
return Err(errors::syntax_error(ruby, &source, *offset, message)?);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if let Some(id) = nodes::first_node_deeper_than(&arena, 0, MAX_DEPTH) {
|
|
126
|
+
let offset = arena.get_node(id).start_offset as usize;
|
|
127
|
+
let message = format!("content is nested more than {MAX_DEPTH} levels deep");
|
|
128
|
+
return Err(errors::syntax_error(ruby, &source, offset, &message)?);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
let json = nodes::node_to_json(&arena, 0);
|
|
132
|
+
serde_json::to_string(&json)
|
|
133
|
+
.map_err(|e| Error::new(ruby.exception_runtime_error(), e.to_string()))
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
#[magnus::init]
|
|
137
|
+
fn init(ruby: &Ruby) -> Result<(), Error> {
|
|
138
|
+
let namespace = ruby.define_module("Andromeda")?;
|
|
139
|
+
let parser = namespace.define_module("Parser")?;
|
|
140
|
+
parser.define_singleton_method("backend", function!(parser_backend, 0))?;
|
|
141
|
+
parser.define_singleton_method("native_parse", function!(rb_native_parse, 2))?;
|
|
142
|
+
Ok(())
|
|
143
|
+
}
|