carve-lang 0.1.3 → 0.1.5

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: '092afb8611f9491f14b1bdc5e74730dc30fd5efd269a3cf153246d8181e0e94c'
4
- data.tar.gz: 57b9ce435040eb96ff5148a2ce15e0369bcb6f696bbf22422cb76ce42c60d1a9
3
+ metadata.gz: e85e18bc41b0f896bb4ec2fc821c61a96b29b707ec1ef9bd3279d45d01150d9f
4
+ data.tar.gz: 67f1b5a6638be12cd89c34eab2809e94c6b8b4719ea3d6710cc6aaa3eceaf5ee
5
5
  SHA512:
6
- metadata.gz: e8bfb7fea75aa1819fd2bde47740d428ba1c3846d004f5ca4e7fb11e89041750ecd7992ed3913fad42c4a627bbc5842e9e0adf66369f45c71499e34d623a3158
7
- data.tar.gz: 18f13425d6701f44da146ee93c312acd0e8dec16a76fb55ab7995deadcf7931fbdd9fa8c666d19e7f153b7cd8b19d490cbe42e4389a0bfa9fe3be5183c65c64a
6
+ metadata.gz: d80a8c11324fc602247a5780f19f07662bb77eb4482b352c84e7f4e684896466672b84dd4011e1c3f29a134da723c2decaf66255472ffb57860876c3d82aeb0b
7
+ data.tar.gz: fd1fdb9b4a222d6b7e0a558c4733a07f745e8a45bbe73bbd2aa5fc4850727c8af1937a331ed305d3569626b912a113420f45263bd8bd8ccb759acc57453f0dbb
data/CHANGELOG.md CHANGED
@@ -7,6 +7,54 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.1.5] - 2026-09-21
11
+
12
+ ### Changed
13
+
14
+ - The engine comes from the published `carve-lang` 0.1.6 crate on crates.io
15
+ instead of a carve-rs git revision. `gem install carve-lang` no longer
16
+ fetches from GitHub while it compiles the extension. The engine code is the
17
+ same carve-rs 0.1.6 the previous release pinned by revision (#136).
18
+
19
+ ## [0.1.4] - 2026-09-19
20
+
21
+ ### Added
22
+
23
+ - `Carve.to_html_with_includes` renders a file-backed document with its
24
+ `{{ path }}` directives expanded, contained to an absolute root the caller
25
+ names. It returns the rendered HTML alongside sanitized warnings and
26
+ root-relative dependencies, and forwards extension and include budget
27
+ settings to the engine. The binding canonicalizes the root and the source
28
+ path and requires the source to sit inside the root (#123, #125).
29
+ - `Carve.parse_with_includes` publishes the expanded document as a tree, in the
30
+ shape `Carve.parse` returns, and the include path takes the same render
31
+ options the string entry points take. A consumer that draws from the tree
32
+ rather than from HTML, such as carve-hexapdf, was blocked without it. Its
33
+ nodes carry no `pos`: spec I4 leaves position remapping across an include out
34
+ of scope in every engine (#126, #127).
35
+
36
+ ### Changed
37
+
38
+ - HTML and Markdown migration reports use schema version 2 with shared
39
+ preserved, normalized, degraded, and dropped fidelity plus confidence.
40
+ Markdown retains the existing `source_format` key as a compatibility alias
41
+ for the shared `sourceFormat` spelling and now emits a conservative
42
+ `fidelity-unverified` dropped/fallback finding on every import instead of
43
+ the previous empty diagnostics array (#117).
44
+ - **Breaking:** A substitution node in the tree carries `old` and `new` as
45
+ arrays of inline nodes, where it carried the strings `oldText` and `newText`.
46
+ A caller reading that node walks the halves instead of reading them (#130,
47
+ markup-carve/carve-rs#1756).
48
+ - The engine moves from carve-rs `42df4092` to released 0.1.6 (`d7837249`).
49
+ Beyond the substitution change, the range brings the include pass this
50
+ release exposes, mention and tag handling (a nameless mention or tag is
51
+ dropped and reported, an unspellable name is refused, attributes survive the
52
+ editor bridge), and writer and parser fixes: the Markdown and Carve writers
53
+ escape what would reopen a construct on the way back in, and parsing tightens
54
+ around braced inlines, forced closers, escaped markers, adjacent links, blank
55
+ table rows and a code span's closer. 1740 of 1740 corpus documents render
56
+ byte-identically, and `resources/spec-drift.txt` is empty.
57
+
10
58
  ## [0.1.3] - 2026-09-08
11
59
 
12
60
  ### Changed
@@ -324,7 +372,9 @@ are not listed, because no release ever shipped them.
324
372
  Arrays (every AST node type is covered), enabling custom renderers such as
325
373
  [carve-hexapdf](https://github.com/markup-carve/carve-hexapdf).
326
374
 
327
- [Unreleased]: https://github.com/markup-carve/carve-rb/compare/v0.1.3...HEAD
375
+ [Unreleased]: https://github.com/markup-carve/carve-rb/compare/v0.1.5...HEAD
376
+ [0.1.5]: https://github.com/markup-carve/carve-rb/compare/v0.1.4...v0.1.5
377
+ [0.1.4]: https://github.com/markup-carve/carve-rb/compare/v0.1.3...v0.1.4
328
378
  [0.1.3]: https://github.com/markup-carve/carve-rb/compare/v0.1.2...v0.1.3
329
379
  [0.1.2]: https://github.com/markup-carve/carve-rb/compare/v0.1.1...v0.1.2
330
380
  [0.1.1]: https://github.com/markup-carve/carve-rb/compare/v0.1.0...v0.1.1
data/README.md CHANGED
@@ -37,15 +37,18 @@ time via `rb_sys`.
37
37
  require "carve"
38
38
 
39
39
  Carve.to_html("# Hello *world*")
40
+ # => "<section id=\"Hello-world\">\n <h1>Hello <strong>world</strong></h1>\n</section>"
40
41
 
41
42
  # Every core engine target is available from the binding.
42
43
  Carve.to_markdown(source)
43
44
  Carve.to_plain_text(source)
44
45
  Carve.to_ansi(source)
45
46
  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.
46
50
  Carve.from_html('<p>Hello <strong>world</strong></p>')
47
51
  Carve.from_markdown('*em* and **strong**')
48
- # => "<section id=\"Hello-world\">\n <h1>Hello <strong>world</strong></h1>\n</section>"
49
52
 
50
53
  # Carve syntax note: *...* is STRONG (bold), /.../ is EMPHASIS (italic).
51
54
  Carve.to_html("*bold* and /italic/")
@@ -77,6 +80,57 @@ this binding has always taken: `:math`, `:permalinks`, `:mermaid`, `:dot`,
77
80
 
78
81
  An unknown extension name raises `ArgumentError`.
79
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
+ ```
99
+
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:
108
+
109
+ ```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"),
114
+ )
115
+ result[:value][:children]
116
+ ```
117
+
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
+
80
134
  ## Parsing to an AST
81
135
 
82
136
  `Carve.parse` returns the parsed document as a tree of Ruby Hashes and Arrays,
@@ -267,6 +321,9 @@ a document change.
267
321
  | `Carve.to_html(source, symbols: {...})` | Render with a `:name:` -> value symbol map (values are raw, see above). |
268
322
  | `Carve.to_html(source, safe: true, profile: :comment)` | Render untrusted input: escape `=html` raw blocks/spans, restrict constructs. |
269
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. |
270
327
  | `Carve.read_stamp(source)` | Read a document's provenance marker: `{version:, generated_by:}` or `nil`. |
271
328
  | `Carve.needs_review?(source)` | Whether a document predates this engine's spec version (unstamped counts as yes). |
272
329
  | `Carve.to_html_with_extensions(source, names_array)` | Native primitive (Array of Strings). |
data/ext/carve/Cargo.lock CHANGED
@@ -37,8 +37,9 @@ checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8"
37
37
 
38
38
  [[package]]
39
39
  name = "carve-lang"
40
- version = "0.1.4"
41
- source = "git+https://github.com/markup-carve/carve-rs?rev=42df409227dc260cec336a6569f4f41c712cdc22#42df409227dc260cec336a6569f4f41c712cdc22"
40
+ version = "0.1.6"
41
+ source = "registry+https://github.com/rust-lang/crates.io-index"
42
+ checksum = "87fdad4ca9cefc502ad43595c578e09031d07be56280881ff0eb6c3cc59ca9fd"
42
43
  dependencies = [
43
44
  "html5ever",
44
45
  "markup5ever_rcdom",
@@ -51,7 +52,7 @@ dependencies = [
51
52
 
52
53
  [[package]]
53
54
  name = "carve-rb"
54
- version = "0.1.3"
55
+ version = "0.1.5"
55
56
  dependencies = [
56
57
  "carve-lang",
57
58
  "magnus",
data/ext/carve/Cargo.toml CHANGED
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "carve-rb"
3
- version = "0.1.3"
3
+ version = "0.1.5"
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", git = "https://github.com/markup-carve/carve-rs", rev = "42df409227dc260cec336a6569f4f41c712cdc22" }
25
+ carve_rs = { package = "carve-lang", version = "=0.1.6" }
data/ext/carve/src/lib.rs CHANGED
@@ -28,6 +28,7 @@ use carve_rs::extensions::registry;
28
28
  use carve_rs::{CarveExtension, Mode, Options, Profile, StaticRenderers};
29
29
  use magnus::value::{InnerValue, Opaque};
30
30
  use magnus::{function, prelude::*, Error, RArray, RHash, Ruby, Value};
31
+ use std::path::{Path, PathBuf};
31
32
 
32
33
  /// HTML-escape a string for the renderer-failure fallback path.
33
34
  ///
@@ -215,6 +216,218 @@ fn to_carve(source: String) -> String {
215
216
  carve_rs::to_carve(&source)
216
217
  }
217
218
 
219
+ /// Which renderer runs over the expanded document.
220
+ ///
221
+ /// `carve` is absent on purpose: spec I15 excludes the Carve writer from
222
+ /// expansion, because inlining a child into the formatter's output rewrites the
223
+ /// author's document rather than formatting it.
224
+ #[derive(Clone, Copy, PartialEq)]
225
+ enum IncludeTarget {
226
+ Html,
227
+ Markdown,
228
+ Plain,
229
+ Ansi,
230
+ Ast,
231
+ }
232
+
233
+ fn parse_include_target(ruby: &Ruby, name: &str) -> Result<IncludeTarget, Error> {
234
+ match name {
235
+ "html" => Ok(IncludeTarget::Html),
236
+ "markdown" => Ok(IncludeTarget::Markdown),
237
+ "plain" => Ok(IncludeTarget::Plain),
238
+ "ansi" => Ok(IncludeTarget::Ansi),
239
+ "ast" => Ok(IncludeTarget::Ast),
240
+ other => Err(Error::new(
241
+ ruby.exception_arg_error(),
242
+ format!(
243
+ "Unknown Carve include target: {other:?} \
244
+ (supported: \"html\", \"markdown\", \"plain\", \"ansi\", \"ast\")"
245
+ ),
246
+ )),
247
+ }
248
+ }
249
+
250
+ #[allow(clippy::too_many_arguments)]
251
+ fn render_with_includes_json(
252
+ ruby: &Ruby,
253
+ source: String,
254
+ root: String,
255
+ source_path: String,
256
+ target: String,
257
+ names: RArray,
258
+ mode: String,
259
+ renderers: RHash,
260
+ symbols: RHash,
261
+ safe: bool,
262
+ profile: Option<String>,
263
+ sections: bool,
264
+ max_depth: Option<usize>,
265
+ max_bytes: Option<usize>,
266
+ max_resolver_calls: Option<usize>,
267
+ max_warnings: Option<usize>,
268
+ ) -> Result<String, Error> {
269
+ let which = parse_include_target(ruby, &target)?;
270
+ let root_path = Path::new(&root);
271
+ let source_path = Path::new(&source_path);
272
+ if !root_path.is_absolute() || !source_path.is_absolute() {
273
+ return Err(Error::new(
274
+ ruby.exception_arg_error(),
275
+ "include root and source_path must be absolute".to_string(),
276
+ ));
277
+ }
278
+ let root_real = std::fs::canonicalize(root_path).map_err(|_| {
279
+ Error::new(
280
+ ruby.exception_arg_error(),
281
+ "include root is not a readable directory".to_string(),
282
+ )
283
+ })?;
284
+ let source_real = std::fs::canonicalize(source_path).map_err(|_| {
285
+ Error::new(
286
+ ruby.exception_arg_error(),
287
+ "include source_path is not a readable file".to_string(),
288
+ )
289
+ })?;
290
+ if !source_real.starts_with(&root_real) || !source_real.is_file() {
291
+ return Err(Error::new(
292
+ ruby.exception_arg_error(),
293
+ "include source_path must be a file inside the include root".to_string(),
294
+ ));
295
+ }
296
+
297
+ let resolver = carve_rs::FileSystemResolver::new(&root_real).map_err(|_| {
298
+ Error::new(
299
+ ruby.exception_arg_error(),
300
+ "include root is not usable".to_string(),
301
+ )
302
+ })?;
303
+ // The same options `to_html` builds. A document rendered through the
304
+ // include path renders the way it would without one: jekyll-carve passes a
305
+ // symbol map on every conversion, and carve-hexapdf asks for one profile
306
+ // and extension set across parent and child alike.
307
+ let parsed_mode = parse_mode(ruby, &mode)?;
308
+ let parsed_profile = match profile.as_deref() {
309
+ None => None,
310
+ Some(name) => Some(parse_profile(ruby, name)?),
311
+ };
312
+ let static_renderers = build_renderers(ruby, renderers)?;
313
+ let boxed = boxed_extensions(ruby, names)?;
314
+ let symbol_pairs = build_symbols(symbols)?;
315
+ let mut render_options = Options::new()
316
+ .with_mode(parsed_mode)
317
+ .with_renderers(static_renderers);
318
+ for extension in &boxed {
319
+ render_options = render_options.with_extension(extension.as_ref());
320
+ }
321
+ for (name, value) in &symbol_pairs {
322
+ render_options = render_options.with_symbol(name.clone(), value.clone());
323
+ }
324
+ if safe {
325
+ render_options = render_options.with_raw_html(false);
326
+ }
327
+ if let Some(preset) = parsed_profile {
328
+ render_options = render_options.with_profile(preset);
329
+ }
330
+ if !sections {
331
+ render_options = render_options.with_sections(false);
332
+ }
333
+ let mut include_options = carve_rs::IncludeOptions::new()
334
+ .with_resolver(&resolver)
335
+ .with_source_path(source_real.to_string_lossy());
336
+ if let Some(value) = max_depth {
337
+ include_options = include_options.with_max_depth(value);
338
+ }
339
+ if let Some(value) = max_bytes {
340
+ include_options = include_options.with_max_bytes(value);
341
+ }
342
+ if let Some(value) = max_resolver_calls {
343
+ include_options = include_options.with_max_resolver_calls(value);
344
+ }
345
+ if let Some(value) = max_warnings {
346
+ include_options = include_options.with_max_warnings(value);
347
+ }
348
+
349
+ let target_is_html = which == IncludeTarget::Html;
350
+ let prepared = carve_rs::prepare_doc_with_includes(
351
+ &source,
352
+ &render_options,
353
+ &include_options,
354
+ // Every target but HTML is inherently static, so it prepares under the
355
+ // interactive mode the other renderers assume.
356
+ if target_is_html {
357
+ render_options.mode
358
+ } else {
359
+ Mode::Interactive
360
+ },
361
+ target_is_html,
362
+ )
363
+ .map_err(|error| Error::new(ruby.exception_arg_error(), error.to_string()))?;
364
+ let rendered = match which {
365
+ IncludeTarget::Html => carve_rs::render_html_with_options(&prepared.doc, &render_options),
366
+ IncludeTarget::Markdown => {
367
+ carve_rs::render_markdown_with_options(&prepared.doc, &render_options)
368
+ }
369
+ IncludeTarget::Plain => {
370
+ carve_rs::render_plain_text_with_options(&prepared.doc, &render_options)
371
+ }
372
+ IncludeTarget::Ansi => carve_rs::render_ansi_with_options(&prepared.doc, &render_options),
373
+ // The AST is published as a tree, not as a string, so a caller does not
374
+ // parse JSON twice. Positions stay OFF, unlike `to_ast_json`: spec I4
375
+ // leaves position remapping out of scope in every engine, so a child's
376
+ // spans would point into a document the caller never passed.
377
+ IncludeTarget::Ast => Ok(carve_rs::to_json(&prepared.doc)),
378
+ }
379
+ .map_err(|error| Error::new(ruby.exception_runtime_error(), error.to_string()))?;
380
+ let value: serde_json::Value = if which == IncludeTarget::Ast {
381
+ serde_json::from_str(&rendered).map_err(|error| {
382
+ Error::new(
383
+ ruby.exception_runtime_error(),
384
+ format!("the engine's AST JSON did not parse: {error}"),
385
+ )
386
+ })?
387
+ } else {
388
+ serde_json::Value::String(rendered)
389
+ };
390
+ let safe_path = |value: &str| {
391
+ let path = PathBuf::from(value);
392
+ if path.is_absolute() {
393
+ path.strip_prefix(&root_real)
394
+ .map(|relative| relative.to_string_lossy().replace('\\', "/"))
395
+ .unwrap_or_else(|_| "[outside-root]".to_string())
396
+ } else {
397
+ value.to_string()
398
+ }
399
+ };
400
+ let warnings = prepared
401
+ .warnings
402
+ .iter()
403
+ .map(|warning| {
404
+ serde_json::json!({
405
+ "rule": warning.rule,
406
+ "message": warning.message,
407
+ "file": warning.file.as_deref().map(&safe_path),
408
+ })
409
+ })
410
+ .collect::<Vec<_>>();
411
+ let dependencies = prepared
412
+ .dependencies
413
+ .iter()
414
+ .map(|dependency| {
415
+ serde_json::json!({
416
+ "path": safe_path(&dependency.id),
417
+ "resolved": dependency.resolved,
418
+ "denial": dependency.denial.map(|denial| denial.as_str()),
419
+ })
420
+ })
421
+ .collect::<Vec<_>>();
422
+ Ok(serde_json::json!({
423
+ "value": value,
424
+ "warnings": warnings,
425
+ "dependencies": dependencies,
426
+ "suppressedWarnings": prepared.suppressed_warnings,
427
+ })
428
+ .to_string())
429
+ }
430
+
218
431
  fn from_html_json(ruby: &Ruby, source: String, mode: String) -> Result<String, Error> {
219
432
  let mode = carve_rs::HtmlImportMode::from_name(&mode).ok_or_else(|| {
220
433
  Error::new(
@@ -222,7 +435,7 @@ fn from_html_json(ruby: &Ruby, source: String, mode: String) -> Result<String, E
222
435
  "mode must be safe, semantic, or roundtrip".to_string(),
223
436
  )
224
437
  })?;
225
- let result = carve_rs::html_to_carve(
438
+ let result = carve_rs::migrate_html(
226
439
  &source,
227
440
  &carve_rs::HtmlImportOptions {
228
441
  mode,
@@ -239,31 +452,44 @@ fn from_html_json(ruby: &Ruby, source: String, mode: String) -> Result<String, E
239
452
  .report
240
453
  .diagnostics
241
454
  .iter()
242
- .map(|diagnostic| {
243
- let mut value = serde_json::json!({
244
- "code": diagnostic.code.as_str(),
245
- "message": diagnostic.message,
246
- "severity": diagnostic.severity.as_str(),
247
- });
248
- if let Some(path) = &diagnostic.path {
249
- value["path"] = serde_json::json!(path);
250
- }
251
- value
252
- })
455
+ .map(migration_diagnostic_json)
253
456
  .collect::<Vec<_>>();
254
457
  Ok(serde_json::json!({
255
458
  "value": result.value,
256
459
  "report": {
257
- "mode": result.report.mode.as_str(),
258
- "adapter": result.report.adapter.as_str(),
460
+ "schemaVersion": result.report.schema_version,
461
+ "sourceFormat": result.report.source_format.as_str(),
462
+ "mode": result.report.mode.map(|value| value.as_str()),
463
+ "adapter": result.report.adapter.map(|value| value.as_str()),
259
464
  "diagnostics": diagnostics,
260
465
  }
261
466
  })
262
467
  .to_string())
263
468
  }
264
469
 
265
- fn from_markdown(source: String) -> String {
266
- carve_rs::markdown_to_carve(&source)
470
+ fn from_markdown_json(source: String) -> String {
471
+ let result = carve_rs::migrate_markdown(&source);
472
+ let diagnostics = result
473
+ .report
474
+ .diagnostics
475
+ .iter()
476
+ .map(migration_diagnostic_json)
477
+ .collect::<Vec<_>>();
478
+ serde_json::json!({"value": result.value, "report": {"schemaVersion": result.report.schema_version, "sourceFormat": result.report.source_format.as_str(), "diagnostics": diagnostics}}).to_string()
479
+ }
480
+
481
+ fn migration_diagnostic_json(diagnostic: &carve_rs::MigrationDiagnostic) -> serde_json::Value {
482
+ let mut value = serde_json::json!({
483
+ "code": diagnostic.code,
484
+ "message": diagnostic.message,
485
+ "severity": diagnostic.severity.as_str(),
486
+ "fidelity": diagnostic.fidelity.as_str(),
487
+ "confidence": diagnostic.confidence.as_str(),
488
+ });
489
+ if let Some(path) = &diagnostic.path {
490
+ value["path"] = serde_json::json!(path);
491
+ }
492
+ value
267
493
  }
268
494
 
269
495
  /// Parse Carve source and return its AST as a JSON string.
@@ -523,8 +749,12 @@ fn init(ruby: &Ruby) -> Result<(), Error> {
523
749
  module.define_singleton_method("to_plain_text", function!(to_plain_text, 1))?;
524
750
  module.define_singleton_method("to_ansi", function!(to_ansi, 1))?;
525
751
  module.define_singleton_method("to_carve", function!(to_carve, 1))?;
752
+ module.define_singleton_method(
753
+ "_render_with_includes_json",
754
+ function!(render_with_includes_json, 15),
755
+ )?;
526
756
  module.define_singleton_method("_from_html_json", function!(from_html_json, 2))?;
527
- module.define_singleton_method("_from_markdown", function!(from_markdown, 1))?;
757
+ module.define_singleton_method("_from_markdown_json", function!(from_markdown_json, 1))?;
528
758
  module.define_singleton_method("_to_ast_json", function!(to_ast_json, 1))?;
529
759
  module.define_singleton_method(
530
760
  "to_html_with_extensions",
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.3"
11
+ VERSION = "0.1.5"
12
12
  end
data/lib/carve.rb CHANGED
@@ -42,6 +42,94 @@ module Carve
42
42
  RENDERER_KEYS = %i[mermaid chart graphviz math].freeze
43
43
 
44
44
  class << self
45
+ # Render +source+ to HTML with its <tt>{{ path }}</tt> includes expanded,
46
+ # contained to +root:+.
47
+ #
48
+ # Carve.to_html_with_includes(File.read(page), root: "/srv/site",
49
+ # source_path: page, symbols: {smile: "\u{1F604}"})
50
+ #
51
+ # +root:+ and +source_path:+ are absolute, and the source file lives inside
52
+ # the root. A relative value raises ArgumentError rather than rooting
53
+ # containment at the process working directory, which is arbitrary with
54
+ # respect to the document.
55
+ #
56
+ # Returns +{value:, warnings:, dependencies:, suppressedWarnings:}+. Paths
57
+ # in +warnings+ and +dependencies+ are relative to the root, so a report can
58
+ # be shown to a reader as it stands.
59
+ #
60
+ # Every render option +to_html+ takes is accepted and reaches the included
61
+ # children too: an extension, a symbol map or a profile means the same thing
62
+ # in a child as in the parent.
63
+ def to_html_with_includes(source, root:, source_path:, extensions: nil, mode: nil,
64
+ renderers: nil, symbols: nil, safe: false, profile: nil,
65
+ sections: true, max_depth: nil, max_bytes: nil,
66
+ max_resolver_calls: nil, max_warnings: nil)
67
+ render_with_includes(
68
+ source, target: "html", root: root, source_path: source_path,
69
+ extensions: extensions, mode: mode, renderers: renderers, symbols: symbols,
70
+ safe: safe, profile: profile, sections: sections, max_depth: max_depth,
71
+ max_bytes: max_bytes, max_resolver_calls: max_resolver_calls,
72
+ max_warnings: max_warnings
73
+ )
74
+ end
75
+
76
+ # The expanded document as an AST, in the shape +.parse+ returns.
77
+ #
78
+ # Carve.parse_with_includes(File.read(page), root: root, source_path: page)
79
+ # # => {value: {type: "document", ...}, warnings: [], dependencies: [...], ...}
80
+ #
81
+ # For a host that walks the tree rather than rendering HTML - carve-hexapdf
82
+ # draws its PDF from this shape.
83
+ #
84
+ # Nodes carry NO +:pos+, unlike +.parse+. Spec I4 leaves position remapping
85
+ # out of scope in every engine, so a span on an included node would name an
86
+ # offset in a document the caller never passed.
87
+ def parse_with_includes(source, root:, source_path:, extensions: nil, profile: nil,
88
+ max_depth: nil, max_bytes: nil, max_resolver_calls: nil,
89
+ max_warnings: nil)
90
+ render_with_includes(
91
+ source, target: "ast", root: root, source_path: source_path,
92
+ extensions: extensions, profile: profile, max_depth: max_depth,
93
+ max_bytes: max_bytes, max_resolver_calls: max_resolver_calls,
94
+ max_warnings: max_warnings
95
+ )
96
+ end
97
+
98
+ # The include pass over any render target: +"html"+, +"markdown"+,
99
+ # +"plain"+, +"ansi"+ or +"ast"+.
100
+ #
101
+ # There is no +"carve"+ target. Spec I15 excludes the Carve writer from
102
+ # expansion, because inlining a child into the formatter's output rewrites
103
+ # the author's document rather than formatting it.
104
+ def render_with_includes(source, root:, source_path:, target: "html", extensions: nil,
105
+ mode: nil, renderers: nil, symbols: nil, safe: false,
106
+ profile: nil, sections: true, max_depth: nil, max_bytes: nil,
107
+ max_resolver_calls: nil, max_warnings: nil)
108
+ JSON.parse(
109
+ _render_with_includes_json(
110
+ source.to_s,
111
+ root.to_s,
112
+ source_path.to_s,
113
+ target.to_s,
114
+ Array(extensions).map(&:to_s),
115
+ (mode || :interactive).to_s,
116
+ renderers || {},
117
+ symbols || {},
118
+ !!safe,
119
+ profile&.to_s,
120
+ !!sections,
121
+ max_depth,
122
+ max_bytes,
123
+ max_resolver_calls,
124
+ max_warnings,
125
+ ),
126
+ symbolize_names: true,
127
+ # The engine bounds nesting itself, above Ruby JSON's default of 100, so
128
+ # a legal document would otherwise raise here. Same reason as .parse.
129
+ max_nesting: false,
130
+ )
131
+ end
132
+
45
133
  # Import HTML or Markdown into canonical Carve. Both methods return the
46
134
  # shared migration shape `{ value:, report: }`.
47
135
  def from_html(source, mode: :safe)
@@ -49,10 +137,10 @@ module Carve
49
137
  end
50
138
 
51
139
  def from_markdown(source)
52
- {
53
- value: _from_markdown(source),
54
- report: { source_format: "markdown", diagnostics: [] }
55
- }
140
+ result = JSON.parse(_from_markdown_json(source), symbolize_names: true)
141
+ # Keep the 0.1.x spelling while exposing the shared cross-language key.
142
+ result[:report][:source_format] = result[:report][:sourceFormat]
143
+ result
56
144
  end
57
145
 
58
146
  # Render Carve +source+ to an HTML string.
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.3
4
+ version: 0.1.5
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-08 00:00:00.000000000 Z
11
+ date: 2026-09-23 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rb_sys