jekyll-carve
August 21, 2026 ยท View on GitHub
A Jekyll converter plugin for the
Carve markup language. It renders
.crv pages to HTML by delegating to the native
carve-lang gem (Carve.to_html). No
parser is reimplemented here; this is a thin Jekyll::Converter adapter over
the engine.
Install
Add both gems to your site's Gemfile:
# Gemfile
gem "jekyll-carve"
gem "carve-lang" # the native Carve engine (a runtime dependency, listed for clarity)
bundle install
Then enable the plugin in _config.yml:
plugins:
- jekyll-carve
Note
The carve-lang gem ships a Rust native extension and is compiled at install
time. It requires a Rust toolchain (cargo) and Ruby development headers.
See the carve-rb README for build notes (including the libclang/stdarg.h
workaround on some systems).
Configuration
Carve engine options are read from _config.yml under the carve key:
carve:
extensions:
- heading_permalinks
- math_block
- autolink
carve.extensions is an array of opt-in Carve extension names (Strings or
hyphenated/underscored forms, passed straight through to the engine). When the
key is absent, no extensions are enabled. The recognized extensions are listed
in Carve::EXTENSIONS; an unknown name raises ArgumentError at build time.
carve.symbols - what :smile: renders as
Carve parses :name: in core, no extension needed, but what a name renders as
is a render option. With no map configured a shortcode renders as its own
source text:
Ship it :smile:
<p>Ship it :smile:</p>
carve.symbols supplies the map. It takes a mapping written inline:
carve:
symbols:
smile: "๐"
ship: "๐"
or a path to a JSON object, so a large map does not have to live in
_config.yml:
carve:
symbols: _data/symbols.json
{
"smile": "๐",
"ship": "๐"
}
or a list mixing both, merged left to right, so a generated map can carry a few site-specific overrides:
carve:
symbols:
- _data/emoji.json
- ship: "๐"
A path is resolved against the site source and confined to it, symlinks
included - a link inside the source pointing at a file outside it is refused
rather than followed. A name that
is not in the map keeps rendering as its own text, and the map does not loosen
Carve's word-boundary rule: 10:30: and a:smile:b are not shortcodes, and
`:smile:` inside a code span stays code.
No emoji table ships with this plugin. Jekyll has no emoji database in core, so
bundling one here would be a second source of truth next to whatever your
Markdown pages already use (jemoji, for instance) - and the two would drift.
Point carve.symbols at a JSON file you generate from that same source and
both page types resolve one map.
A misconfigured map fails the build rather than being warned past: a missing
file, invalid JSON, a JSON top level that is not an object, or a value that is
not a string each raise ArgumentError naming the file or the key. A value is
not coerced, because it reaches the page raw - count: 1 is a mistake worth
stopping for, not something to stringify and emit. A NAME is coerced, since
YAML hands back a Symbol or a boolean for some unquoted keys and a name never
reaches the output.
Warning
A symbol value is inserted as trusted raw output. It is not escaped, so
smile: "<img src='/s.svg'>" emits a real <img> element rather than
escaped text. This is deliberate in the engine - carve-lang documents it as
"NEVER build a symbols map out of untrusted / user-supplied input" - and it
is why this plugin reads the map only from _config.yml and from files at
paths named there. Never generate carve.symbols from page content, from
front matter, from a comment system, or from anything else a visitor can
influence.
Usage
Create a page with a .crv extension. It MUST begin with Jekyll
YAML front matter, and the Carve body follows below:
---
layout: default
title: Home
---
# Welcome to *Carve*
This is /italic/ and *bold* text.
- Apple
- Banana
Note Carve's inline syntax: *...* is strong (bold) and /.../ is
emphasis (italic) - the opposite of Markdown.
Important: Jekyll front matter vs. Carve frontmatter
This is the one nuance to understand:
- Jekyll only runs a file through a converter if it has YAML front matter
(a
---...---block at the very top). A.crvfile with no front matter is treated as a static file and copied verbatim, NOT converted. - Jekyll strips that front matter before calling the converter. So the converter (and therefore the Carve engine) receives only the document body, never the Jekyll front matter.
- Carve itself also uses
---for its own frontmatter. In a Jekyll site the Jekyll front matter wins: the top---...---block is consumed by Jekyll, and whatever remains is handed to Carve as the body.
Practical rule: a .crv page in Jekyll needs at least an empty front matter
block so Jekyll processes it:
---
---
# Your Carve content here
Put page metadata (title, layout, etc.) in the Jekyll front matter. Do not
add a second Carve --- frontmatter block expecting Carve to parse it; Jekyll
has already removed the leading block by the time Carve runs.
Converter API
Jekyll::Carve::Converter < Jekyll::Converter:
| Method | Behavior |
|---|---|
matches(ext) | true for .crv (with or without the leading dot, case-insensitive), false otherwise. |
output_ext(ext) | ".html" (includes the dot so Jekyll emits page.html). |
convert(content) | Carve.to_html(content, extensions: configured, symbols: configured) - the rendered HTML. |
carve_extensions | The extension list read from carve.extensions in _config.yml (empty by default). |
carve_symbols | The :name: map read from carve.symbols in _config.yml, or nil when the key is absent. Resolved once per build. |
reset_symbols | Drops the cached map. Called from the site, after_reset hook this plugin registers, which is what scopes the cache to a build so jekyll serve --watch picks up an edit to a symbol file. |
Development
bundle install
bundle exec rspec # run the converter unit tests
Which engine you are testing against
bundle install here does not resolve carve-lang from RubyGems. Gemfile
pins the engine to a carve-rb revision, so a development run measures one exact
engine build rather than whatever the registry serves that day, and
script/verify_engine_pin.rb refuses when the Gemfile, the resolved bundle and
the loaded library disagree. CI runs it before the suite:
bundle exec ruby script/verify_engine_pin.rb
# carve-lang 0.1.1 from carve-rb f15f30a2... (Gemfile pins f15f30a21e7a)
An installed copy of this gem resolves differently, through the range
jekyll-carve.gemspec declares - today >= 0.1.1, < 0.2.0. The two are
deliberately not the same engine, and both are checked:
spec/engine_floor_spec.rb asks whether the engine under bundle exec rspec
still does what the floor claims, and script/consumer_engine_probe.rb asks the
same of an actual gem install, daily in Engine drift and again before every
release.
There is no committed Gemfile.lock, and that is deliberate - the reasoning,
including why a lockfile would be a weaker pin here and what it would cost the
Ruby 3.1 job, is written at the top of Gemfile.