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.
Files changed (56) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +30 -0
  3. data/CONTRIBUTING.md +47 -0
  4. data/Cargo.lock +1182 -0
  5. data/Cargo.toml +12 -0
  6. data/LICENSE.txt +21 -0
  7. data/README.md +276 -0
  8. data/ext/andromeda_cms/Cargo.toml +18 -0
  9. data/ext/andromeda_cms/extconf.rb +6 -0
  10. data/ext/andromeda_cms/src/errors.rs +55 -0
  11. data/ext/andromeda_cms/src/lib.rs +143 -0
  12. data/ext/andromeda_cms/src/nodes.rs +400 -0
  13. data/lib/andromeda/assets.rb +128 -0
  14. data/lib/andromeda/check.rb +71 -0
  15. data/lib/andromeda/components/errors.rb +97 -0
  16. data/lib/andromeda/components/import_scanner.rb +72 -0
  17. data/lib/andromeda/components/static_expression.rb +79 -0
  18. data/lib/andromeda/components.rb +348 -0
  19. data/lib/andromeda/configuration.rb +91 -0
  20. data/lib/andromeda/entry.rb +294 -0
  21. data/lib/andromeda/errors.rb +173 -0
  22. data/lib/andromeda/fix.rb +71 -0
  23. data/lib/andromeda/frontmatter.rb +327 -0
  24. data/lib/andromeda/helpers.rb +84 -0
  25. data/lib/andromeda/id.rb +58 -0
  26. data/lib/andromeda/loader.rb +96 -0
  27. data/lib/andromeda/parser.rb +75 -0
  28. data/lib/andromeda/pipeline.rb +296 -0
  29. data/lib/andromeda/railtie.rb +47 -0
  30. data/lib/andromeda/registry.rb +127 -0
  31. data/lib/andromeda/relation.rb +125 -0
  32. data/lib/andromeda/renderer/code_highlighter.rb +53 -0
  33. data/lib/andromeda/renderer/errors.rb +52 -0
  34. data/lib/andromeda/renderer/literal_expression.rb +201 -0
  35. data/lib/andromeda/renderer.rb +411 -0
  36. data/lib/andromeda/schema.rb +383 -0
  37. data/lib/andromeda/slugger.rb +68 -0
  38. data/lib/andromeda/store.rb +286 -0
  39. data/lib/andromeda/tasks/andromeda.rake +56 -0
  40. data/lib/andromeda/version.rb +5 -0
  41. data/lib/andromeda_cms.rb +41 -0
  42. data/lib/generators/andromeda/collection/USAGE +25 -0
  43. data/lib/generators/andromeda/collection/collection_generator.rb +239 -0
  44. data/lib/generators/andromeda/component/USAGE +15 -0
  45. data/lib/generators/andromeda/component/component_generator.rb +75 -0
  46. data/lib/generators/andromeda/import_astro/USAGE +21 -0
  47. data/lib/generators/andromeda/import_astro/astro_schema_json.rb +80 -0
  48. data/lib/generators/andromeda/import_astro/balanced_scanner.rb +168 -0
  49. data/lib/generators/andromeda/import_astro/content_config_converter.rb +232 -0
  50. data/lib/generators/andromeda/import_astro/import_astro_generator.rb +313 -0
  51. data/lib/generators/andromeda/import_astro/mdx_content_scanner.rb +91 -0
  52. data/lib/generators/andromeda/import_astro/mdx_import_rewriter.rb +91 -0
  53. data/lib/generators/andromeda/install/USAGE +11 -0
  54. data/lib/generators/andromeda/install/install_generator.rb +77 -0
  55. data/lib/generators/andromeda/install/templates/initializer.rb +30 -0
  56. 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
+ ![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,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,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "mkmf"
4
+ require "rb_sys/mkmf"
5
+
6
+ create_rust_makefile("andromeda_cms/andromeda_cms")
@@ -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
+ }