Project

carve-lang

0.0
The project is in a healthy, maintained state
Parse and render Carve markup to HTML from Ruby. A native extension (magnus + rb-sys) over the carve-rs engine, mirroring how Djot's djotter gem wraps the jotdown crate. No parser is reimplemented in Ruby.
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
2026
 Dependencies

Development

>= 5.0
~> 13.0

Runtime

~> 0.9
 Project Readme

carve (Ruby)

Native Ruby bindings for the Carve markup language. This gem is a thin native extension built with magnus + rb-sys over the carve-rs engine. The parser is not reimplemented in Ruby; it calls into the Rust crate directly, mirroring how Djot's djotter gem wraps the jotdown crate.

Install

# Gemfile
gem "carve-lang"
bundle install

Or install directly:

gem install carve-lang

Then require "carve" as normal - the gem distribution name is carve-lang but the require path stays carve.

Building from source requires a Rust toolchain (cargo, Rust >= 1.75) and Ruby development headers. RubyGems compiles the native extension at install time via rb_sys.

Usage

require "carve"

Carve.to_html("# Hello *world*")
# => "<section id=\"Hello-world\">\n  <h1>Hello <strong>world</strong></h1>\n</section>"

# Carve syntax note: *...* is STRONG (bold), /.../ is EMPHASIS (italic).
Carve.to_html("*bold* and /italic/")

# Enable opt-in extensions (Symbols or Strings, snake_case or hyphenated):
Carve.to_html(<<~CRV, extensions: [:math_block])
  ```math
  a^2 + b^2 = c^2

CRV

Carve.to_html(src, extensions: %w[math-block list-table])


### Recognized extensions

`Carve::EXTENSIONS` is the list, and it comes from the engine rather than from
a copy kept here that could fall behind it:

```ruby
Carve::EXTENSIONS
# => [:autolink, :citations, :"code-callouts", :"code-group", ...]

The canonical names are kebab-case (:"math-block", :"table-of-contents"). Snake_case spellings (:math_block) work as arguments, as do the short aliases this binding has always taken: :math, :permalinks, :mermaid, :dot, :graphviz, :chart, :toc.

An unknown extension name raises ArgumentError.

Parsing to an AST

Carve.parse returns the parsed document as a tree of Ruby Hashes and Arrays, for consumers that want to walk or transform the document rather than render HTML - for example a custom PDF renderer (see carve-hexapdf).

Carve.parse("# Hello *world*")
# => {type: "document", frontmatter: {}, footnote_defs: {},
#     children: [{type: "heading", level: 1,
#                 children: [{type: "text", value: "Hello "},
#                            {type: "emphasis", kind: "strong",
#                             children: [{type: "text", value: "world"}], attrs: nil}],
#                 attrs: nil}],
#     source_len: 15}

Every node is a Hash with a :type key plus its fields; child collections are Arrays; :attrs is nil or a Hash of {id:, classes:, key_values:}. Keys are symbols. This is the raw parse tree (default profile, no extensions), so render-stage extension rewrites are not applied.

Static render mode + renderers

By default Carve.to_html renders interactive HTML: client-script constructs (Mermaid/Graphviz/Chart diagrams, math) emit hydration elements (<pre class="mermaid">, ...) and disclosure stays collapsed (<details>).

Pass mode: :static to emit self-contained HTML for print, PDF, or archival. Static mode forces disclosure (<details open>) and pre-renders client-script constructs through the renderers: callables you supply.

Carve.to_html(<<~CRV, extensions: [:fenced_render], mode: :static,
              renderers: { mermaid: ->(src) { "<svg>#{src}</svg>" } })
  ```mermaid
  graph TD; A-->B

CRV


### Renderer callable signatures

The `renderers:` Hash is keyed by Symbol or String (see
`Carve::RENDERER_KEYS`):

| Key | Callable signature | Receives |
| --- | ------------------ | -------- |
| `:mermaid` | `(String) -> String` | the diagram source |
| `:chart` | `(String) -> String` | the chart JSON source |
| `:graphviz` | `(String) -> String` | the DOT / Graphviz source |
| `:math` | `(String, display) -> String` | the TeX source and a `display` boolean (`true` for block / display math, `false` for inline) |

Each callable returns a self-contained HTML string (an `<svg>` / `<img>` for a
diagram, MathML / HTML for math) that the engine emits **verbatim** on the
static path.

### Source fallback (graceful degradation)

When the renderer a construct needs is **absent**, or a supplied renderer
**raises** or returns a **non-String**, the construct degrades to its source -
never blank, and never raw HTML. The fallback source is **HTML-escaped**, so a
construct body containing markup (e.g. `<img onerror=...>`) can never inject raw
HTML. This is part of the cross-implementation graceful-degradation rollout
(spec carve #205; siblings carve-js #242, carve-php #240, carve-rs #143,
carve-py #1).

An unknown `mode:` value or an unknown `renderers:` key raises `ArgumentError`.

## Symbols

A `:name:` symbol renders its literal `:name:` source unless the name is in the
**symbols map** passed as `symbols:` (String or Symbol keys, String values):

```ruby
Carve.to_html("Ship it :rocket: :shrug:", symbols: { "rocket" => "๐Ÿš€" })
# => "<p>Ship it ๐Ÿš€ :shrug:</p>"   (an unmapped name stays literal)

The leading word-boundary guard is unaffected by an active map: a:b:c, 10:30: and me@example.com never become symbols. A non-String value raises TypeError.

Security: symbol values are TRUSTED RAW output. A mapped value is inserted into the output unescaped - the same trust class as a renderers: callable. { "b" => "<b>x</b>" } emits a real <b> element, not escaped text. This is deliberate (processor configuration is trusted). Never build a symbols map out of untrusted / user-supplied input.

Section wrappers

A top-level heading is wrapped, along with the content following it up to the next same-or-shallower heading, in a <section> carrying the heading's id (spec PART 9 ยง13). Only the id moves - {#install .featured} gives <section id="install"><h2 class="featured"> - and a heading inside a blockquote, div or list item is not wrapped at all.

Pass sections: false to render headings flat, with the id back on the <h*>:

Carve.to_html("# A\n\np\n")
# => "<section id=\"A\">\n  <h1>A</h1>\n  <p>p</p>\n</section>"

Carve.to_html("# A\n\np\n", sections: false)
# => "<h1 id=\"A\">A</h1>\n<p>p</p>"

This is for a host whose CSS or JS assumes rendered blocks are direct children of the content container - the .stack > * + * spacing idiom, :first-child, nth-child() counting, DOM child walks - all of which stop matching once a wrapper sits in between. It is the one output change that breaks a document whose source migrated cleanly.

Nothing else changes: ids, collision dedup, </#id> cross-references, implicit [Heading][] references and heading numbering all resolve against the slug rather than the element carrying it. The endnotes <section role="doc-endnotes"> is a separate construct and is still emitted.

Untrusted input

Carve's normative hardening is always on and needs no option: dangerous URL schemes are blanked, event-handler attributes like onclick are dropped, and the bidi override/isolate characters behind Trojan Source are removed from rendered text.

Raw passthrough is the deliberate exception. A ```=html block or a `โ€ฆ`{=html} span renders verbatim by design, so it is the one thing input you did not author has to switch off:

Carve.to_html(user_input, safe: true, profile: :comment)

safe: escapes those raw blocks and spans instead of emitting them. profile: restricts which constructs are allowed at all and caps input length - :full, :article, :comment or :minimal, String or Symbol. An unknown name raises ArgumentError rather than being ignored.

A profile rejection raises too, rather than returning something that looks like output:

Carve.to_html("x" * 20_000, profile: :minimal)
# ArgumentError: Profile violations: 'document' is not allowed: max_length_exceeded (...)

That matters for untrusted input: the engine's infallible entry point answers a rejection with an empty String, which a caller cannot tell from a document that legitimately rendered to nothing.

Full recipe, defaults and threat model: Security.

Stored documents and spec versions

carve fmt --stamp (in any Carve engine) records the spec version a document was last processed under. This gem reads that marker back, so a repository of stored .crv files can be checked for documents predating a breaking spec change:

Carve.read_stamp(source)
# => {version: "0.1", generated_by: "carve-php 0.1.0"}

Carve.needs_review?(source)   # true when the document predates this engine

An unstamped document answers true: its provenance is unknown, and assuming it is current is the unsafe direction. Both marker forms are read, and a marker written by any engine reads the same - the format is the contract, not any one API - so the answer matches carve-php, carve-js, carve-rs and carve-go on the same document.

What a version difference means is the versioning contract: only [behavior] changelog entries between the stamped version and yours can require a document change.

API

Method Description
Carve.to_html(source) Render Carve source to HTML.
Carve.parse(source) Parse Carve source into an AST (tree of Ruby Hashes/Arrays).
Carve.to_html(source, extensions: [...]) Render with the named extensions enabled.
Carve.to_html(source, mode: :static, renderers: {...}) Render self-contained static HTML with build-time renderers.
Carve.to_html(source, symbols: {...}) Render with a :name: -> value symbol map (values are raw, see above).
Carve.to_html(source, safe: true, profile: :comment) Render untrusted input: escape =html raw blocks/spans, restrict constructs.
Carve.to_html(source, sections: false) Render headings flat, with the id on the <h*> instead of a <section> wrapper.
Carve.read_stamp(source) Read a document's provenance marker: {version:, generated_by:} or nil.
Carve.needs_review?(source) Whether a document predates this engine's spec version (unstamped counts as yes).
Carve.to_html_with_extensions(source, names_array) Native primitive (Array of Strings).
Carve.to_html_full(source, names_array, mode_string, renderers_hash) Native static-mode primitive.
Carve.to_html_full_with_symbols(source, names_array, mode_string, renderers_hash, symbols_hash) Native primitive, static mode + symbol map.
Carve::VERSION Gem version.
Carve::EXTENSIONS Array of recognized extension symbols.
Carve::MODES Array of recognized render modes (:interactive, :static).
Carve::RENDERER_KEYS Array of recognized renderers: keys.

Develop

bundle install
rake compile   # builds the Rust extension into lib/carve/carve.so
rake test      # runs the minitest suite

Note

The native build uses rb_sys + bindgen (libclang) to read Ruby's headers. On systems where libclang cannot find its builtin C headers (the 'stdarg.h' file not found error), point it at the GCC builtin include dir:

export BINDGEN_EXTRA_CLANG_ARGS="-I/usr/lib/gcc/x86_64-linux-gnu/13/include"

(Adjust the GCC version directory to match your toolchain.)

carve-rs dependency pin

ext/carve/Cargo.toml pins a specific carve-rs commit for reproducible gem builds:

carve_rs = { package = "carve-lang", git = "https://github.com/markup-carve/carve-rs", rev = "..." }

Read the current revision out of ext/carve/Cargo.toml rather than from a copy here. This section used to quote one, and it drifted three bumps behind the manifest before anyone noticed - a duplicated value goes stale the first time someone edits the original, and a stale one here is worse than none because it reads as authoritative.

The crate is imported under the alias carve_rs. It is published as carve-lang (carve-rs renamed it from carve), so a pin at any revision past that rename needs package = "carve-lang" as above.

When bumping the rev, run rake compile and commit the resulting ext/carve/Cargo.lock in the same change. The lock records the resolved revision, so leaving it behind means every fresh clone gets a dirty working tree on its first build and the gem can resolve to a different engine than the one that was tested.

Whether the pin is current is not a judgment call: CI runs the mandatory spec corpus through the compiled extension and requires byte-identical HTML, so a pin that has fallen behind fails a build. Locally:

CARVE_SPEC_CORPUS=/path/to/carve/tests/corpus bundle exec rake test

License

MIT, markup-carve.