carve-lang 0.1.5 → 0.1.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e85e18bc41b0f896bb4ec2fc821c61a96b29b707ec1ef9bd3279d45d01150d9f
4
- data.tar.gz: 67f1b5a6638be12cd89c34eab2809e94c6b8b4719ea3d6710cc6aaa3eceaf5ee
3
+ metadata.gz: e1129dd70ab09658be69bfbbf0158a02d599d9e814fb5e3f015be6d1a6cc5621
4
+ data.tar.gz: bc87753903ba603238e2c766b20c8384259dc8f44e87da90af4ef3f6d26ac613
5
5
  SHA512:
6
- metadata.gz: d80a8c11324fc602247a5780f19f07662bb77eb4482b352c84e7f4e684896466672b84dd4011e1c3f29a134da723c2decaf66255472ffb57860876c3d82aeb0b
7
- data.tar.gz: fd1fdb9b4a222d6b7e0a558c4733a07f745e8a45bbe73bbd2aa5fc4850727c8af1937a331ed305d3569626b912a113420f45263bd8bd8ccb759acc57453f0dbb
6
+ metadata.gz: f4f5180ccfda64b312e035b7302db7de20b52293466dd1719715d7ab9fcdfa06ba61b23e993d042df746ced604219bdb78cd5931e09a6dd25388ee9fffcac102
7
+ data.tar.gz: c1276c1504398c7be637fc9e6699b42217e0a3845f49e7647348650701abdc25148fe2138c49643171154d8834c184ef711b294bd3f3215db29de509299e127c
data/CHANGELOG.md CHANGED
@@ -7,6 +7,78 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.1.7] - 2026-10-07
11
+
12
+ ### Changed
13
+
14
+ - The engine is the published `carve-lang` 0.1.8 crate, up from 0.1.7. It closes
15
+ every row `resources/spec-drift.txt` declared: the gem renders all 2225 corpus
16
+ documents spec main declares byte-identically, where 0.1.7 rendered 30 of them
17
+ by a superseded rule. The ledger is empty again, which is the only state a tag
18
+ may ship.
19
+ - **Breaking for a `Carve.to_html` consumer:** a heading cross-reference, a
20
+ numbered caption or equation reference, and a collapsed reference falling back
21
+ to heading text compare their target case exactly. `</#target>` against a
22
+ `# Target` heading no longer resolves and stays literal text; link-definition
23
+ labels, footnote labels and include fragment selectors already matched case
24
+ exactly. `carve fmt --migrate` in carve-rs repairs unambiguous case-only misses
25
+ (markup-carve/carve#2732, markup-carve/carve-rs#2320).
26
+ - **Breaking for a `Carve.to_html` consumer:** a glossary reference matches its
27
+ term exactly and links to the matched entry, and two terms differing only in
28
+ case take two ids (markup-carve/carve#2739).
29
+ - A render that blanks a denied destination scheme reports one
30
+ `destination-denied` loss, so a checked render of a `javascript:` destination
31
+ refuses where it used to pass. The emitted `href=""` does not move
32
+ (markup-carve/carve#2679).
33
+
34
+ ### Added
35
+
36
+ - `Carve::EnginePanic`, a `StandardError` subclass raised when the engine
37
+ panics, so a host rendering untrusted input can rescue one and keep serving.
38
+ The message carries the panic text and its location in the engine source, and
39
+ the usual panic report still reaches stderr (#170).
40
+
41
+ ### Fixed
42
+
43
+ - An engine panic arrives as a rescuable Ruby exception rather than `fatal`.
44
+ magnus catches the unwind but raises it as `fatal`, which no host can stop,
45
+ not even with `rescue Exception`, so any panic ended the process. The binding
46
+ now converts a caught panic itself (#170).
47
+ - A line holding a single `|` followed by an attribute block, such as `|{.r}`,
48
+ renders as paragraph text. Under the 0.1.7 engine it panicked inside the table
49
+ check, and a panic crossing the FFI boundary reached Ruby as `fatal`, which
50
+ `rescue` cannot catch, so five bytes of input terminated the host process
51
+ (markup-carve/carve-rs#2341).
52
+
53
+ ## [0.1.6] - 2026-09-29
54
+
55
+ ### Changed
56
+
57
+ - The engine is the published `carve-lang` 0.1.7 crate, up from 0.1.6. It closes
58
+ every row `resources/spec-drift.txt` declared: the gem renders all 2134 corpus
59
+ documents spec main declares byte-identically, where 0.1.6 rendered 158 of them
60
+ by a superseded rule. The ledger is empty again, which is the only state a tag
61
+ may ship (#151).
62
+ - **Breaking for a `Carve.parse` consumer:** a footnote reference node spells its
63
+ target as `label`, where it spelled it `id`. PART 12 section 25 settles that
64
+ name on the definition, and every node also carries `attrs[:id]` for an
65
+ authored `{#x}`, so the old name stood for two unrelated values on one object.
66
+ `attrs[:id]` is untouched (markup-carve/carve-rs#1853).
67
+ - **Breaking for a `Carve.parse` consumer:** a code block's `content` is the literal
68
+ payload text, so `a`, `a\n` and `a\n\n` stay distinct in the tree where they
69
+ collapsed to one shape, and an empty fence holds no newline. Rendered HTML is
70
+ unchanged (markup-carve/carve-rs#2191, markup-carve/carve-rs#2195).
71
+ - `Carve.parse` publishes twelve further fields the tree gained: a fenced
72
+ blockquote, a loose definition list, a directive's kind and children, a line
73
+ block's attributes, an authored task state, a lone-image paragraph, a table's
74
+ columns and row groups, and a cell's colspan, rowspan and vertical alignment.
75
+ - **Breaking for a `Carve.to_markdown` consumer:** the Markdown target follows
76
+ PART 11 section 11 for a GFM reader. A heading takes no `{#id}` suffix, and a
77
+ resolved cross-reference is written with the heading's GFM slug, so
78
+ `lowercase_heading_ids` no longer changes that anchor - the slug is the one a
79
+ GFM reader computes for itself. `Carve.to_html` still answers to the option
80
+ (markup-carve/carve-rs#2014).
81
+
10
82
  ## [0.1.5] - 2026-09-21
11
83
 
12
84
  ### Changed
@@ -287,7 +359,7 @@ are not listed, because no release ever shipped them.
287
359
  Frontmatter and footnote definitions are block nodes in `children` rather
288
360
  than root fields, which PART 12 §7 requires: a root field cannot carry the
289
361
  position §4 requires of every node, and both are source an editor navigates
290
- to (carve#411, carve#418). Frontmatter is the first child, carrying `format`
362
+ to (markup-carve/carve#411, markup-carve/carve#418). Frontmatter is the first child, carrying `format`
291
363
  and **raw** `content` - not parsed key/values, which could not represent a
292
364
  `---toml` block at all. Anything reading `ast[:frontmatter]` or
293
365
  `ast[:footnoteDefs]` breaks.
@@ -296,9 +368,9 @@ are not listed, because no release ever shipped them.
296
368
  backslash escape is `{"type":"escaped_text","value":"-"}` instead of being
297
369
  folded into surrounding text - the backslash carries intent the character does
298
370
  not, since an author writes `\-\-` precisely so a consumer will not render an
299
- en dash (carve#350). A `::: |` fence is `line_block` instead of a `div` with a
371
+ en dash (markup-carve/carve#350). A `::: |` fence is `line_block` instead of a `div` with a
300
372
  `.line-block` class, because inside it every newline is a hard break and a
301
- class alone could not say which one a node was (carve#359). A block
373
+ class alone could not say which one a node was (markup-carve/carve#359). A block
302
374
  extension's `summary` is a list of inline nodes rather than a plain string,
303
375
  matching the admonition `title` shape. Definition lists publish
304
376
  `definition_term` and `definition_description` nodes
@@ -372,7 +444,9 @@ are not listed, because no release ever shipped them.
372
444
  Arrays (every AST node type is covered), enabling custom renderers such as
373
445
  [carve-hexapdf](https://github.com/markup-carve/carve-hexapdf).
374
446
 
375
- [Unreleased]: https://github.com/markup-carve/carve-rb/compare/v0.1.5...HEAD
447
+ [Unreleased]: https://github.com/markup-carve/carve-rb/compare/v0.1.7...HEAD
448
+ [0.1.7]: https://github.com/markup-carve/carve-rb/compare/v0.1.6...v0.1.7
449
+ [0.1.6]: https://github.com/markup-carve/carve-rb/compare/v0.1.5...v0.1.6
376
450
  [0.1.5]: https://github.com/markup-carve/carve-rb/compare/v0.1.4...v0.1.5
377
451
  [0.1.4]: https://github.com/markup-carve/carve-rb/compare/v0.1.3...v0.1.4
378
452
  [0.1.3]: https://github.com/markup-carve/carve-rb/compare/v0.1.2...v0.1.3
data/README.md CHANGED
@@ -1,387 +1,110 @@
1
1
  # carve (Ruby)
2
2
 
3
- Native Ruby bindings for the [Carve](https://github.com/markup-carve/carve)
4
- markup language. This gem is a thin native extension built with
5
- [magnus](https://github.com/matsadler/magnus) + [rb-sys](https://github.com/oxidize-rb/rb-sys)
6
- over the [carve-rs](https://github.com/markup-carve/carve-rs) engine. The parser
7
- is not reimplemented in Ruby; it calls into the Rust crate directly, mirroring
8
- how Djot's `djotter` gem wraps the `jotdown` crate.
3
+ Native Ruby bindings for the
4
+ [Carve](https://github.com/markup-carve/carve) markup language. The gem wraps
5
+ the [carve-rs](https://github.com/markup-carve/carve-rs) engine through magnus
6
+ and rb-sys rather than reimplementing the parser.
9
7
 
10
8
  ## Install
11
9
 
12
10
  ```ruby
13
- # Gemfile
14
11
  gem "carve-lang"
15
12
  ```
16
13
 
17
- ```sh
14
+ ```bash
18
15
  bundle install
19
16
  ```
20
17
 
21
- Or install directly:
18
+ The distribution name is `carve-lang`, while the require path is `carve`.
19
+ Building from source requires Rust 1.75 or newer and Ruby development headers.
22
20
 
23
- ```sh
24
- gem install carve-lang
25
- ```
26
-
27
- Then `require "carve"` as normal - the gem distribution name is `carve-lang`
28
- but the require path stays `carve`.
29
-
30
- Building from source requires a **Rust toolchain** (`cargo`, Rust >= 1.75) and
31
- Ruby development headers. RubyGems compiles the native extension at install
32
- time via `rb_sys`.
33
-
34
- ## Usage
21
+ ## Render Carve
35
22
 
36
23
  ```ruby
37
24
  require "carve"
38
25
 
39
26
  Carve.to_html("# Hello *world*")
40
- # => "<section id=\"Hello-world\">\n <h1>Hello <strong>world</strong></h1>\n</section>"
41
-
42
- # Every core engine target is available from the binding.
43
27
  Carve.to_markdown(source)
44
28
  Carve.to_plain_text(source)
45
29
  Carve.to_ansi(source)
46
30
  Carve.to_carve(source)
47
- # Import reports use schema version 2, with fidelity and confidence per finding.
48
- # Markdown emits fidelity-unverified/dropped/fallback until its engine path
49
- # exposes construct-level fidelity.
50
- Carve.from_html('<p>Hello <strong>world</strong></p>')
51
- Carve.from_markdown('*em* and **strong**')
52
-
53
- # Carve syntax note: *...* is STRONG (bold), /.../ is EMPHASIS (italic).
54
- Carve.to_html("*bold* and /italic/")
55
-
56
- # Enable opt-in extensions (Symbols or Strings, snake_case or hyphenated):
57
- Carve.to_html(<<~CRV, extensions: [:math_block])
58
- ```math
59
- a^2 + b^2 = c^2
60
- ```
61
- CRV
62
-
63
- Carve.to_html(src, extensions: %w[math-block list-table])
64
- ```
65
-
66
- ### Recognized extensions
67
-
68
- `Carve::EXTENSIONS` is the list, and it comes from the engine rather than from
69
- a copy kept here that could fall behind it:
70
-
71
- ```ruby
72
- Carve::EXTENSIONS
73
- # => [:autolink, :citations, :"code-callouts", :"code-group", ...]
74
- ```
75
-
76
- The canonical names are kebab-case (`:"math-block"`, `:"table-of-contents"`).
77
- Snake_case spellings (`:math_block`) work as arguments, as do the short aliases
78
- this binding has always taken: `:math`, `:permalinks`, `:mermaid`, `:dot`,
79
- `:graphviz`, `:chart`, `:toc`.
80
-
81
- An unknown extension name raises `ArgumentError`.
82
-
83
- ### File includes
84
-
85
- Includes are opt-in and require an absolute containment root plus the absolute
86
- path of the source document. The result carries the rendered value, warnings,
87
- and root-relative dependencies. Other render methods leave include directives
88
- literal.
89
-
90
- ```ruby
91
- result = Carve.to_html_with_includes(
92
- File.read("book.crv"),
93
- root: File.expand_path("."),
94
- source_path: File.expand_path("book.crv"),
95
- max_depth: 16,
96
- )
97
- puts result[:value]
98
31
  ```
99
32
 
100
- Every render option `Carve.to_html` takes is accepted here and reaches the
101
- included children too, so a document renders the same way whether or not it
102
- went through the include path: `extensions:`, `symbols:`, `profile:`, `mode:`,
103
- `renderers:`, `safe:` and `sections:`.
104
-
105
- `Carve.parse_with_includes` gives the expanded document as an AST instead, in
106
- the shape `Carve.parse` returns, for a host that walks the tree rather than
107
- rendering HTML:
33
+ Options configure extensions, safe rendering, profiles, symbols, section
34
+ wrappers, and custom renderers:
108
35
 
109
36
  ```ruby
110
- result = Carve.parse_with_includes(
111
- File.read("book.crv"),
112
- root: File.expand_path("."),
113
- source_path: File.expand_path("book.crv"),
37
+ Carve.to_html(
38
+ source,
39
+ extensions: %w[autolink list-table],
40
+ safe: true,
41
+ profile: "comment",
114
42
  )
115
- result[:value][:children]
116
43
  ```
117
44
 
118
- Its nodes carry no `:pos`, unlike `Carve.parse`. Spec I4 leaves position
119
- remapping out of scope in every engine, so a span on an included node would name
120
- an offset in a document the caller never passed.
121
-
122
- `Carve.render_with_includes` takes the same arguments plus `target:`, one of
123
- `"html"`, `"markdown"`, `"plain"`, `"ansi"` or `"ast"`. There is no `"carve"`
124
- target: spec I15 excludes the Carve writer from expansion, because inlining a
125
- child into the formatter's output rewrites the author's document rather than
126
- formatting it.
127
-
128
- The budgets `max_depth:`, `max_bytes:`, `max_resolver_calls:` and
129
- `max_warnings:` pass through to the engine; omit one to keep its default. A
130
- target refused by the byte budget still reports `resolved: true`, because
131
- section 19 charges the budget for what the resolver handed back and a target is
132
- resolved before its size is known.
133
-
134
- ## Parsing to an AST
135
-
136
- `Carve.parse` returns the parsed document as a tree of Ruby Hashes and Arrays,
137
- for consumers that want to walk or transform the document rather than render
138
- HTML - for example a custom PDF renderer (see
139
- [`carve-hexapdf`](https://github.com/markup-carve/carve-hexapdf)).
140
-
141
- ```ruby
142
- Carve.parse("# Hello *world*")
143
- # => {type: "document", frontmatter: {}, footnote_defs: {},
144
- # children: [{type: "heading", level: 1,
145
- # children: [{type: "text", value: "Hello "},
146
- # {type: "emphasis", kind: "strong",
147
- # children: [{type: "text", value: "world"}], attrs: nil}],
148
- # attrs: nil}],
149
- # source_len: 15}
150
- ```
151
-
152
- Every node is a Hash with a `:type` key plus its fields; child collections are
153
- Arrays; `:attrs` is `nil` or a Hash of `{id:, classes:, key_values:}`. Keys are
154
- symbols. This is the raw parse tree (default profile, no extensions), so
155
- render-stage extension rewrites are not applied.
156
-
157
- ## Static render mode + renderers
158
-
159
- By default `Carve.to_html` renders **interactive** HTML: client-script
160
- constructs (Mermaid/Graphviz/Chart diagrams, math) emit hydration elements
161
- (`<pre class="mermaid">`, ...) and disclosure stays collapsed (`<details>`).
162
-
163
- Pass `mode: :static` to emit **self-contained** HTML for print, PDF, or
164
- archival. Static mode forces disclosure (`<details open>`) and pre-renders
165
- client-script constructs through the `renderers:` callables you supply.
166
-
167
- ```ruby
168
- Carve.to_html(<<~CRV, extensions: [:fenced_render], mode: :static,
169
- renderers: { mermaid: ->(src) { "<svg>#{src}</svg>" } })
170
- ```mermaid
171
- graph TD; A-->B
172
- ```
173
- CRV
174
- ```
175
-
176
- ### Renderer callable signatures
177
-
178
- The `renderers:` Hash is keyed by Symbol or String (see
179
- `Carve::RENDERER_KEYS`):
180
-
181
- | Key | Callable signature | Receives |
182
- | --- | ------------------ | -------- |
183
- | `:mermaid` | `(String) -> String` | the diagram source |
184
- | `:chart` | `(String) -> String` | the chart JSON source |
185
- | `:graphviz` | `(String) -> String` | the DOT / Graphviz source |
186
- | `:math` | `(String, display) -> String` | the TeX source and a `display` boolean (`true` for block / display math, `false` for inline) |
45
+ `Carve::EXTENSIONS` reports the names accepted by the bundled engine. Unknown
46
+ names raise `ArgumentError`.
187
47
 
188
- Each callable returns a self-contained HTML string (an `<svg>` / `<img>` for a
189
- diagram, MathML / HTML for math) that the engine emits **verbatim** on the
190
- static path.
48
+ ## Errors
191
49
 
192
- ### Source fallback (graceful degradation)
50
+ An invalid argument raises `ArgumentError`, and a render the engine refuses -
51
+ input past a profile's `max_length`, or a denied construct - also raises
52
+ `ArgumentError` with the engine's reason.
193
53
 
194
- When the renderer a construct needs is **absent**, or a supplied renderer
195
- **raises** or returns a **non-String**, the construct degrades to its source -
196
- never blank, and never raw HTML. The fallback source is **HTML-escaped**, so a
197
- construct body containing markup (e.g. `<img onerror=...>`) can never inject raw
198
- HTML. This is part of the cross-implementation graceful-degradation rollout
199
- (spec carve #205; siblings carve-js #242, carve-php #240, carve-rs #143,
200
- carve-py #1).
201
-
202
- An unknown `mode:` value or an unknown `renderers:` key raises `ArgumentError`.
203
-
204
- ## Symbols
205
-
206
- A `:name:` symbol renders its literal `:name:` source unless the name is in the
207
- **symbols map** passed as `symbols:` (String or Symbol keys, String values):
208
-
209
- ```ruby
210
- Carve.to_html("Ship it :rocket: :shrug:", symbols: { "rocket" => "🚀" })
211
- # => "<p>Ship it 🚀 :shrug:</p>" (an unmapped name stays literal)
212
- ```
213
-
214
- The leading word-boundary guard is unaffected by an active map: `a:b:c`,
215
- `10:30:` and `me@example.com` never become symbols. A non-String value raises
216
- `TypeError`.
217
-
218
- > **Security: symbol values are TRUSTED RAW output.**
219
- > A mapped value is inserted into the output **unescaped** - the same trust
220
- > class as a `renderers:` callable. `{ "b" => "<b>x</b>" }` emits a real `<b>`
221
- > element, not escaped text. This is deliberate (processor configuration is
222
- > trusted). **Never build a symbols map out of untrusted / user-supplied
223
- > input.**
224
-
225
- ## Section wrappers
226
-
227
- A top-level heading is wrapped, along with the content following it up to the
228
- next same-or-shallower heading, in a `<section>` carrying the heading's id (spec
229
- PART 9 §13). Only the id moves - `{#install .featured}` gives
230
- `<section id="install"><h2 class="featured">` - and a heading inside a
231
- blockquote, div or list item is not wrapped at all.
232
-
233
- Pass `sections: false` to render headings flat, with the id back on the `<h*>`:
234
-
235
- ```ruby
236
- Carve.to_html("# A\n\np\n")
237
- # => "<section id=\"A\">\n <h1>A</h1>\n <p>p</p>\n</section>"
238
-
239
- Carve.to_html("# A\n\np\n", sections: false)
240
- # => "<h1 id=\"A\">A</h1>\n<p>p</p>"
241
- ```
242
-
243
- This is for a host whose CSS or JS assumes rendered blocks are direct children
244
- of the content container - the `.stack > * + *` spacing idiom, `:first-child`,
245
- `nth-child()` counting, DOM child walks - all of which stop matching once a
246
- wrapper sits in between. It is the one output change that breaks a document
247
- whose *source* migrated cleanly.
248
-
249
- Nothing else changes: ids, collision dedup, `</#id>` cross-references, implicit
250
- `[Heading][]` references and heading numbering all resolve against the slug
251
- rather than the element carrying it. The endnotes
252
- `<section role="doc-endnotes">` is a separate construct and is still emitted.
253
-
254
- ## Untrusted input
255
-
256
- Carve's normative hardening is always on and needs no option: dangerous URL
257
- schemes are blanked, event-handler attributes like `onclick` are dropped, and the
258
- bidi override/isolate characters behind Trojan Source are removed from rendered
259
- text.
260
-
261
- Raw passthrough is the deliberate exception. A ` ```=html ` block or a
262
- `` `…`{=html} `` span renders **verbatim** by design, so it is the one thing
263
- input you did not author has to switch off:
54
+ If the engine panics, the call raises `Carve::EnginePanic`, a `StandardError`
55
+ subclass carrying the panic message and its location in the engine source. A
56
+ panic means the engine reached a state it believed impossible, so the document
57
+ is not renderable, but the process survives and a host can serve an error
58
+ instead of losing the worker:
264
59
 
265
60
  ``` ruby
266
- Carve.to_html(user_input, safe: true, profile: :comment)
61
+ begin
62
+ Carve.to_html(untrusted_source)
63
+ rescue Carve::EnginePanic => e
64
+ logger.error("carve engine panic: #{e.message}")
65
+ "<p>This document could not be rendered.</p>"
66
+ end
267
67
  ```
268
68
 
269
- `safe:` escapes those raw blocks and spans instead of emitting them. `profile:`
270
- restricts which constructs are allowed at all and caps input length -
271
- `:full`, `:article`, `:comment` or `:minimal`, String or Symbol. An unknown name
272
- raises `ArgumentError` rather than being ignored.
273
-
274
- A profile **rejection** raises too, rather than returning something that looks
275
- like output:
69
+ Report any input that raises it: a panic is an engine defect, not a rejection.
276
70
 
277
- ``` ruby
278
- Carve.to_html("x" * 20_000, profile: :minimal)
279
- # ArgumentError: Profile violations: 'document' is not allowed: max_length_exceeded (...)
280
- ```
71
+ ## Migration and AST access
281
72
 
282
- That matters for untrusted input: the engine's infallible entry point answers a
283
- rejection with an empty String, which a caller cannot tell from a document that
284
- legitimately rendered to nothing.
73
+ `Carve.from_html` and `Carve.from_markdown` return canonical Carve with
74
+ structured fidelity reports. `Carve.parse` exposes a Ruby hash representation
75
+ of the AST, and static rendering produces self-contained output for documents
76
+ that do not load client-side renderers.
285
77
 
286
- Full recipe, defaults and threat model:
287
- [Security](https://markup-carve.github.io/carve/security).
78
+ The [usage reference](docs/reference.md) documents AST fields, static
79
+ rendering, symbols, wrappers, profiles, and the main methods.
288
80
 
289
- ## Stored documents and spec versions
81
+ ## Includes
290
82
 
291
- `carve fmt --stamp` (in any Carve engine) records the spec version a document was
292
- last processed under. This gem reads that marker back, so a repository of stored
293
- `.crv` files can be checked for documents predating a breaking spec change:
83
+ Includes are opt-in and require an absolute containment root plus the source
84
+ document's absolute path:
294
85
 
295
- ``` ruby
296
- Carve.read_stamp(source)
297
- # => {version: "0.1", generated_by: "carve-php 0.1.0"}
298
-
299
- Carve.needs_review?(source) # true when the document predates this engine
300
- ```
301
-
302
- An **unstamped** document answers `true`: its provenance is unknown, and assuming
303
- it is current is the unsafe direction. Both marker forms are read, and a marker
304
- written by any engine reads the same - the format is the contract, not any one
305
- API - so the answer matches carve-php, carve-js, carve-rs and carve-go on the
306
- same document.
307
-
308
- What a version difference means is the
309
- [versioning contract](https://markup-carve.github.io/carve/versioning): only
310
- `[behavior]` changelog entries between the stamped version and yours can require
311
- a document change.
312
-
313
- ## API
314
-
315
- | Method | Description |
316
- | ------ | ----------- |
317
- | `Carve.to_html(source)` | Render Carve source to HTML. |
318
- | `Carve.parse(source)` | Parse Carve source into an AST (tree of Ruby Hashes/Arrays). |
319
- | `Carve.to_html(source, extensions: [...])` | Render with the named extensions enabled. |
320
- | `Carve.to_html(source, mode: :static, renderers: {...})` | Render self-contained static HTML with build-time renderers. |
321
- | `Carve.to_html(source, symbols: {...})` | Render with a `:name:` -> value symbol map (values are raw, see above). |
322
- | `Carve.to_html(source, safe: true, profile: :comment)` | Render untrusted input: escape `=html` raw blocks/spans, restrict constructs. |
323
- | `Carve.to_html(source, sections: false)` | Render headings flat, with the id on the `<h*>` instead of a `<section>` wrapper. |
324
- | `Carve.to_html_with_includes(source, root:, source_path:)` | Render contained file includes and return warnings and dependencies. |
325
- | `Carve.parse_with_includes(source, root:, source_path:)` | The same expansion, published as an AST instead of HTML. |
326
- | `Carve.render_with_includes(source, root:, source_path:, target:)` | The expansion over any render target: html, markdown, plain, ansi or ast. |
327
- | `Carve.read_stamp(source)` | Read a document's provenance marker: `{version:, generated_by:}` or `nil`. |
328
- | `Carve.needs_review?(source)` | Whether a document predates this engine's spec version (unstamped counts as yes). |
329
- | `Carve.to_html_with_extensions(source, names_array)` | Native primitive (Array of Strings). |
330
- | `Carve.to_html_full(source, names_array, mode_string, renderers_hash)` | Native static-mode primitive. |
331
- | `Carve.to_html_full_with_symbols(source, names_array, mode_string, renderers_hash, symbols_hash)` | Native primitive, static mode + symbol map. |
332
- | `Carve::VERSION` | Gem version. |
333
- | `Carve::EXTENSIONS` | Array of recognized extension symbols. |
334
- | `Carve::MODES` | Array of recognized render modes (`:interactive`, `:static`). |
335
- | `Carve::RENDERER_KEYS` | Array of recognized `renderers:` keys. |
336
-
337
- ## Develop
338
-
339
- ```sh
340
- bundle install
341
- rake compile # builds the Rust extension into lib/carve/carve.so
342
- rake test # runs the minitest suite
343
- ```
344
-
345
- > [!NOTE]
346
- > The native build uses `rb_sys` + `bindgen` (libclang) to read Ruby's
347
- > headers. On systems where libclang cannot find its builtin C headers (the
348
- > `'stdarg.h' file not found` error), point it at the GCC builtin include dir:
349
- >
350
- > ```sh
351
- > export BINDGEN_EXTRA_CLANG_ARGS="-I/usr/lib/gcc/x86_64-linux-gnu/13/include"
352
- > ```
353
- >
354
- > (Adjust the GCC version directory to match your toolchain.)
355
-
356
- ## carve-rs dependency pin
357
-
358
- `ext/carve/Cargo.toml` pins a specific carve-rs commit for reproducible gem
359
- builds:
360
-
361
- ```toml
362
- carve_rs = { package = "carve-lang", git = "https://github.com/markup-carve/carve-rs", rev = "..." }
86
+ ```ruby
87
+ result = Carve.to_html_with_includes(
88
+ File.read("book.crv"),
89
+ root: File.expand_path("."),
90
+ source_path: File.expand_path("book.crv"),
91
+ )
363
92
  ```
364
93
 
365
- Read the current revision out of `ext/carve/Cargo.toml` rather than from a copy
366
- here. This section used to quote one, and it drifted three bumps behind the
367
- manifest before anyone noticed - a duplicated value goes stale the first time
368
- someone edits the original, and a stale one here is worse than none because it
369
- reads as authoritative.
94
+ The result contains the value, warnings, and root-relative dependencies.
95
+ Other render methods leave directives literal. See the
96
+ [include reference](docs/reference.md#file-includes) for budgets, AST expansion,
97
+ targets, and containment behavior.
370
98
 
371
- The crate is imported under the alias `carve_rs`. It is published as `carve-lang`
372
- (carve-rs renamed it from `carve`), so a pin at any revision past that rename
373
- needs `package = "carve-lang"` as above.
99
+ ## Security
374
100
 
375
- When bumping the `rev`, run `rake compile` and commit the resulting
376
- `ext/carve/Cargo.lock` in the same change. The lock records the resolved
377
- revision, so leaving it behind means every fresh clone gets a dirty working tree
378
- on its first build and the gem can resolve to a different engine than the one
379
- that was tested.
101
+ Use `safe: true` and a restrictive profile for untrusted documents. Custom
102
+ renderer callbacks and symbol values are trusted output and may emit raw HTML.
103
+ The [untrusted-input reference](docs/reference.md#untrusted-input) describes
104
+ resource limits and URL handling.
380
105
 
381
- Whether the pin is current is not a judgment call: CI runs the mandatory spec
382
- corpus through the compiled extension and requires byte-identical HTML, so a pin
383
- that has fallen behind fails a build. Locally:
106
+ ## Development
384
107
 
385
- ```sh
386
- CARVE_SPEC_CORPUS=/path/to/carve/tests/corpus bundle exec rake test
387
- ```
108
+ Source builds, tests, and the carve-rs dependency pin are documented under
109
+ [Development](docs/reference.md#develop) and
110
+ [carve-rs dependency pin](docs/reference.md#carve-rs-dependency-pin).
data/ext/carve/Cargo.lock CHANGED
@@ -37,22 +37,24 @@ checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8"
37
37
 
38
38
  [[package]]
39
39
  name = "carve-lang"
40
- version = "0.1.6"
40
+ version = "0.1.8"
41
41
  source = "registry+https://github.com/rust-lang/crates.io-index"
42
- checksum = "87fdad4ca9cefc502ad43595c578e09031d07be56280881ff0eb6c3cc59ca9fd"
42
+ checksum = "0889683244be278df2ff8e1cd88e9f932036a424bb8d838546636b7f11e93903"
43
43
  dependencies = [
44
44
  "html5ever",
45
45
  "markup5ever_rcdom",
46
+ "memchr",
46
47
  "pulldown-cmark",
47
48
  "regex",
48
49
  "serde",
49
50
  "serde_json",
51
+ "smallvec",
50
52
  "unicode-normalization",
51
53
  ]
52
54
 
53
55
  [[package]]
54
56
  name = "carve-rb"
55
- version = "0.1.5"
57
+ version = "0.1.7"
56
58
  dependencies = [
57
59
  "carve-lang",
58
60
  "magnus",
data/ext/carve/Cargo.toml CHANGED
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "carve-rb"
3
- version = "0.1.5"
3
+ version = "0.1.7"
4
4
  edition = "2021"
5
5
  rust-version = "1.75"
6
6
  publish = false
@@ -22,4 +22,4 @@ serde_json = "1"
22
22
  # The Carve engine. Imported under the Rust crate alias `carve_rs` so the
23
23
  # binding's own crate name (`carve-rb`) does not collide with the engine's
24
24
  # package name (`carve`) in the Cargo lockfile.
25
- carve_rs = { package = "carve-lang", version = "=0.1.6" }
25
+ carve_rs = { package = "carve-lang", version = "=0.1.8" }
data/ext/carve/src/lib.rs CHANGED
@@ -27,8 +27,12 @@
27
27
  use carve_rs::extensions::registry;
28
28
  use carve_rs::{CarveExtension, Mode, Options, Profile, StaticRenderers};
29
29
  use magnus::value::{InnerValue, Opaque};
30
- use magnus::{function, prelude::*, Error, RArray, RHash, Ruby, Value};
30
+ use magnus::{function, prelude::*, Error, ExceptionClass, RArray, RHash, Ruby, Value};
31
+ use std::any::Any;
32
+ use std::cell::RefCell;
33
+ use std::panic::{self, AssertUnwindSafe};
31
34
  use std::path::{Path, PathBuf};
35
+ use std::sync::Once;
32
36
 
33
37
  /// HTML-escape a string for the renderer-failure fallback path.
34
38
  ///
@@ -732,6 +736,192 @@ fn stamp_needs_review(source: String, current_version: Option<String>) -> bool {
732
736
  carve_rs::needs_review(&source, &current)
733
737
  }
734
738
 
739
+ /// Panic deliberately inside the extension, so a test can observe the FFI
740
+ /// panic contract rather than assuming it.
741
+ ///
742
+ /// Nothing but a test calls this. It exists because no Carve input panics the
743
+ /// pinned engine: the one that did (`|{.r}`) is fixed, and a test that cannot
744
+ /// reach a panic cannot tell a working safety net from a missing one.
745
+ fn panic_probe() -> String {
746
+ panic!("deliberate panic from the Carve extension panic probe");
747
+ }
748
+
749
+ // ---------------------------------------------------------------------------
750
+ // FFI panic safety
751
+ // ---------------------------------------------------------------------------
752
+ //
753
+ // magnus wraps every exposed call in catch_unwind, but `Error::from_panic`
754
+ // raises the caught panic as Ruby's `fatal`, and Ruby does not let a host stop
755
+ // a `fatal` -- not with `rescue Exception`, not with anything. So the unwind
756
+ // that was supposed to protect the host still ends the process, and magnus
757
+ // offers no way to choose a different class.
758
+ //
759
+ // Everything Ruby can reach therefore runs inside `guard`, which catches the
760
+ // unwind FIRST and raises `Carve::EnginePanic` (a StandardError) instead. The
761
+ // `panic = "unwind"` compile flag is still what makes any of this possible,
762
+ // which is why `scripts/check-panic-unwind.sh` stays.
763
+
764
+ thread_local! {
765
+ /// Where the most recent panic on this thread came from.
766
+ ///
767
+ /// The payload `catch_unwind` hands back carries the message but not the
768
+ /// location, and the location is the half that identifies the engine bug.
769
+ static PANIC_LOCATION: RefCell<Option<String>> = const { RefCell::new(None) };
770
+ }
771
+
772
+ /// Record panic locations without taking over panic reporting.
773
+ ///
774
+ /// The previous hook still runs, so the `thread '<unnamed>' panicked at ...`
775
+ /// line and any `RUST_BACKTRACE` output a developer relies on are unchanged.
776
+ fn install_panic_hook() {
777
+ static ONCE: Once = Once::new();
778
+ ONCE.call_once(|| {
779
+ let previous = panic::take_hook();
780
+ panic::set_hook(Box::new(move |info| {
781
+ let location = info
782
+ .location()
783
+ .map(|l| format!("{}:{}:{}", l.file(), l.line(), l.column()));
784
+ PANIC_LOCATION.with(|slot| *slot.borrow_mut() = location);
785
+ previous(info);
786
+ }));
787
+ });
788
+ }
789
+
790
+ /// `Carve::EnginePanic`, or `RuntimeError` if the class cannot be looked up.
791
+ ///
792
+ /// The fallback is still rescuable, which is the property that matters; losing
793
+ /// the specific class is better than losing the process.
794
+ fn engine_panic_class(ruby: &Ruby) -> ExceptionClass {
795
+ ruby.define_module("Carve")
796
+ .ok()
797
+ .and_then(|module| module.const_get::<_, ExceptionClass>("EnginePanic").ok())
798
+ .unwrap_or_else(|| ruby.exception_runtime_error())
799
+ }
800
+
801
+ /// Turn a caught unwind payload into a rescuable Ruby exception.
802
+ fn engine_panic_error(payload: Box<dyn Any + Send>) -> Error {
803
+ let message = if let Some(m) = payload.downcast_ref::<&'static str>() {
804
+ (*m).to_string()
805
+ } else if let Some(m) = payload.downcast_ref::<String>() {
806
+ m.clone()
807
+ } else {
808
+ "panic".to_string()
809
+ };
810
+ let message = match PANIC_LOCATION.with(|slot| slot.borrow_mut().take()) {
811
+ Some(at) => format!("the Carve engine panicked at {at}: {message}"),
812
+ None => format!("the Carve engine panicked: {message}"),
813
+ };
814
+
815
+ // Unchecked for the same reason magnus does it in `Error::from_panic`: this
816
+ // only runs while a Ruby thread is calling into the extension.
817
+ let ruby = unsafe { Ruby::get_unchecked() };
818
+ Error::new(engine_panic_class(&ruby), message)
819
+ }
820
+
821
+ /// Run an exposed call with the panic net in front of magnus's.
822
+ fn guard<T>(f: impl FnOnce() -> Result<T, Error>) -> Result<T, Error> {
823
+ match panic::catch_unwind(AssertUnwindSafe(f)) {
824
+ Ok(result) => result,
825
+ Err(payload) => Err(engine_panic_error(payload)),
826
+ }
827
+ }
828
+
829
+ /// Declare the guarded wrapper Ruby is given for an implementation function.
830
+ ///
831
+ /// `=>` wraps an infallible implementation, `=>?` one that already returns
832
+ /// `Result`. Registering a bare implementation instead of its wrapper would
833
+ /// leave that one call raising `fatal` again, so `test/panic_unwind_test.rb`
834
+ /// reads the `function!` registrations below and fails on any name that is not
835
+ /// a `g_` wrapper.
836
+ macro_rules! guarded {
837
+ ($wrapper:ident ( $($arg:ident : $ty:ty),* ) -> $ret:ty => $inner:ident) => {
838
+ #[allow(clippy::too_many_arguments)]
839
+ fn $wrapper($($arg: $ty),*) -> Result<$ret, Error> {
840
+ guard(|| Ok($inner($($arg),*)))
841
+ }
842
+ };
843
+ ($wrapper:ident ( $($arg:ident : $ty:ty),* ) -> $ret:ty =>? $inner:ident) => {
844
+ #[allow(clippy::too_many_arguments)]
845
+ fn $wrapper($($arg: $ty),*) -> Result<$ret, Error> {
846
+ guard(|| $inner($($arg),*))
847
+ }
848
+ };
849
+ }
850
+
851
+ guarded!(g_to_html(source: String) -> String => to_html);
852
+ guarded!(g_to_markdown(source: String) -> String => to_markdown);
853
+ guarded!(g_to_plain_text(source: String) -> String => to_plain_text);
854
+ guarded!(g_to_ansi(source: String) -> String => to_ansi);
855
+ guarded!(g_to_carve(source: String) -> String => to_carve);
856
+ guarded!(g_from_markdown_json(source: String) -> String => from_markdown_json);
857
+ guarded!(g_to_ast_json(source: String) -> String => to_ast_json);
858
+ guarded!(g_extension_names() -> Vec<String> => extension_names);
859
+ guarded!(g_panic_probe() -> String => panic_probe);
860
+ guarded!(
861
+ g_stamp_needs_review(source: String, current_version: Option<String>) -> bool
862
+ => stamp_needs_review
863
+ );
864
+ guarded!(
865
+ g_from_html_json(ruby: &Ruby, source: String, mode: String) -> String =>? from_html_json
866
+ );
867
+ guarded!(g_read_stamp(ruby: &Ruby, source: String) -> Value =>? read_stamp);
868
+ guarded!(
869
+ g_to_html_with_extensions(ruby: &Ruby, source: String, names: RArray) -> String
870
+ =>? to_html_with_extensions
871
+ );
872
+ guarded!(
873
+ g_to_html_full(
874
+ ruby: &Ruby,
875
+ source: String,
876
+ names: RArray,
877
+ mode: String,
878
+ renderers: RHash
879
+ ) -> String =>? to_html_full
880
+ );
881
+ guarded!(
882
+ g_to_html_full_with_symbols(
883
+ ruby: &Ruby,
884
+ source: String,
885
+ names: RArray,
886
+ mode: String,
887
+ renderers: RHash,
888
+ symbols: RHash
889
+ ) -> String =>? to_html_full_with_symbols
890
+ );
891
+ guarded!(
892
+ g_to_html_safe(
893
+ ruby: &Ruby,
894
+ source: String,
895
+ names: RArray,
896
+ mode: String,
897
+ renderers: RHash,
898
+ symbols: RHash,
899
+ safe: bool,
900
+ profile: Option<String>,
901
+ sections: bool
902
+ ) -> String =>? to_html_safe
903
+ );
904
+ guarded!(
905
+ g_render_with_includes_json(
906
+ ruby: &Ruby,
907
+ source: String,
908
+ root: String,
909
+ source_path: String,
910
+ target: String,
911
+ names: RArray,
912
+ mode: String,
913
+ renderers: RHash,
914
+ symbols: RHash,
915
+ safe: bool,
916
+ profile: Option<String>,
917
+ sections: bool,
918
+ max_depth: Option<usize>,
919
+ max_bytes: Option<usize>,
920
+ max_resolver_calls: Option<usize>,
921
+ max_warnings: Option<usize>
922
+ ) -> String =>? render_with_includes_json
923
+ );
924
+
735
925
  /// Entry point invoked by Ruby when the extension is loaded.
736
926
  ///
737
927
  /// `name = "carve"` makes the macro emit the `Init_carve` symbol that matches
@@ -739,35 +929,43 @@ fn stamp_needs_review(source: String, current_version: Option<String>) -> bool {
739
929
  /// package is named `carve-rb`.
740
930
  #[magnus::init(name = "carve")]
741
931
  fn init(ruby: &Ruby) -> Result<(), Error> {
932
+ install_panic_hook();
933
+
742
934
  let module = ruby.define_module("Carve")?;
935
+ // Raised when the engine panics. A StandardError subclass on purpose:
936
+ // magnus would otherwise surface the panic as `fatal`, which no host can
937
+ // rescue, so an embedder taking untrusted input had no defense at all
938
+ // (markup-carve/carve-rb#170).
939
+ module.define_error("EnginePanic", ruby.exception_standard_error())?;
743
940
  // Native primitives. The pure-Ruby wrapper in lib/carve.rb defines the
744
941
  // public `Carve.to_html(source, extensions:, mode:, renderers:)` on top of
745
942
  // these. `_to_html` is the no-extension fast path; the wrapper owns the
746
943
  // bare `to_html` name.
747
- module.define_singleton_method("_to_html", function!(to_html, 1))?;
748
- module.define_singleton_method("to_markdown", function!(to_markdown, 1))?;
749
- module.define_singleton_method("to_plain_text", function!(to_plain_text, 1))?;
750
- module.define_singleton_method("to_ansi", function!(to_ansi, 1))?;
751
- module.define_singleton_method("to_carve", function!(to_carve, 1))?;
944
+ module.define_singleton_method("_to_html", function!(g_to_html, 1))?;
945
+ module.define_singleton_method("to_markdown", function!(g_to_markdown, 1))?;
946
+ module.define_singleton_method("to_plain_text", function!(g_to_plain_text, 1))?;
947
+ module.define_singleton_method("to_ansi", function!(g_to_ansi, 1))?;
948
+ module.define_singleton_method("to_carve", function!(g_to_carve, 1))?;
752
949
  module.define_singleton_method(
753
950
  "_render_with_includes_json",
754
- function!(render_with_includes_json, 15),
951
+ function!(g_render_with_includes_json, 15),
755
952
  )?;
756
- module.define_singleton_method("_from_html_json", function!(from_html_json, 2))?;
757
- module.define_singleton_method("_from_markdown_json", function!(from_markdown_json, 1))?;
758
- module.define_singleton_method("_to_ast_json", function!(to_ast_json, 1))?;
953
+ module.define_singleton_method("_from_html_json", function!(g_from_html_json, 2))?;
954
+ module.define_singleton_method("_from_markdown_json", function!(g_from_markdown_json, 1))?;
955
+ module.define_singleton_method("_to_ast_json", function!(g_to_ast_json, 1))?;
759
956
  module.define_singleton_method(
760
957
  "to_html_with_extensions",
761
- function!(to_html_with_extensions, 2),
958
+ function!(g_to_html_with_extensions, 2),
762
959
  )?;
763
- module.define_singleton_method("to_html_full", function!(to_html_full, 4))?;
960
+ module.define_singleton_method("to_html_full", function!(g_to_html_full, 4))?;
764
961
  module.define_singleton_method(
765
962
  "to_html_full_with_symbols",
766
- function!(to_html_full_with_symbols, 5),
963
+ function!(g_to_html_full_with_symbols, 5),
767
964
  )?;
768
- module.define_singleton_method("_to_html_safe", function!(to_html_safe, 8))?;
769
- module.define_singleton_method("_extension_names", function!(extension_names, 0))?;
770
- module.define_singleton_method("_read_stamp", function!(read_stamp, 1))?;
771
- module.define_singleton_method("_stamp_needs_review", function!(stamp_needs_review, 2))?;
965
+ module.define_singleton_method("_to_html_safe", function!(g_to_html_safe, 8))?;
966
+ module.define_singleton_method("_extension_names", function!(g_extension_names, 0))?;
967
+ module.define_singleton_method("_read_stamp", function!(g_read_stamp, 1))?;
968
+ module.define_singleton_method("_stamp_needs_review", function!(g_stamp_needs_review, 2))?;
969
+ module.define_singleton_method("_panic_probe", function!(g_panic_probe, 0))?;
772
970
  Ok(())
773
971
  }
data/lib/carve/version.rb CHANGED
@@ -8,5 +8,5 @@ module Carve
8
8
  # compares it against ext/carve/Cargo.toml and against the newest cut
9
9
  # CHANGELOG section on every run, and .github/workflows/release.yml refuses to
10
10
  # publish a gem whose version is not the tag being released.
11
- VERSION = "0.1.5"
11
+ VERSION = "0.1.7"
12
12
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: carve-lang
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.5
4
+ version: 0.1.7
5
5
  platform: ruby
6
6
  authors:
7
7
  - markup-carve
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-09-23 00:00:00.000000000 Z
11
+ date: 2026-10-07 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rb_sys