Skip to content

Oliver Renderer Contract

The supported contract between Rotkeeper and the native Oliver HTML renderer: executable discovery, input format and output profile, output streams, exit codes, the adapter boundary, and the stable template/input contract.

Browse Docs pages

This page is the authoritative reference for how Rotkeeper drives the native Oliver renderer โ€” a small, freestanding CommonMark/Textile/Cooklang parsing and rendering library in Zig, and the successor to the Apex renderer. It records the stable contract between the Oliver binary and rc-oliver-adapter.sh.

Executable discovery

rc-render.sh locates Oliver in this order:

  1. RK_OLIVER_BIN environment variable (explicit override).
  2. oliver resolved via PATH.

If neither yields an executable file, render fails with exit 1 and prints a setup message covering installation and the environment variable.

The binary contract

Oliver is invoked once per source file, reading the file on stdin. The input language comes from input_format in bones/config/rotkeeper.yaml (markdown default, textile and cooklang alternatives), overridden to textile for any source whose extension is .textile and to cooklang for any source whose extension is .cook; the adapter and preflight pass the resulting format through on every invocation. The output profile comes from render_profile in bones/config/rotkeeper.yaml (html default, xhtml opt-in), overridden per page by a render_profile frontmatter key; only an xhtml profile appends --to xhtml, so the default invocation is byte-identical to the html-only contract:

oliver render --from markdown < file.md > body.html 2> warnings.log
oliver render --from textile < file.md > body.html 2> warnings.log
oliver render --from textile < file.textile > body.html 2> warnings.log
oliver render --from cooklang < file.md > body.html 2> warnings.log
oliver render --from cooklang < file.cook > body.html 2> warnings.log
oliver render --from markdown --to xhtml < file.md > body.xhtml 2> warnings.log
Aspect Contract
Invocation oliver render --from <markdown|textile|cooklang> [--to <html|xhtml>] โ€” one file per invocation, stdin โ†’ stdout; format comes from input_format in bones/config/rotkeeper.yaml (default markdown, validated to markdown/textile/cooklang at environment load; anything else warns and falls back to markdown). A source with a .textile extension always invokes --from textile and one with a .cook extension always invokes --from cooklang, overriding the config default for that file. The output profile comes from render_profile in bones/config/rotkeeper.yaml (default html, validated to html/xhtml at environment load; anything else warns and falls back to html), overridden per page by a render_profile frontmatter key; --to xhtml is appended only when the effective profile is xhtml, so the default invocation carries no --to flag and is byte-identical to the pre-XHTML contract. --to is rejected by Oliver on serialize/scale/menu
Input Markdown, Textile, or Cooklang on stdin โ€” a leading YAML frontmatter block is stripped by Oliver (oliver meta --from <fmt> --format json extracts it; oliver render auto-strips)
stdout Rendered body HTML fragment (no full page wrapping) โ€” an XHTML fragment under --to xhtml (no DOCTYPE, no document wrappers; see XHTML output profile)
stderr Non-fatal renderer warnings; forwarded through the adapter as warnings, never into the page body. Under --to xhtml, raw HTML fails closed on stderr with error.RawHtmlNotXmlWellFormed and an actionable hint, and the page aborts with exit 1
Exit 0 Success
Exit 1 Render failure (e.g. missing input, or raw HTML under --to xhtml); page is aborted
Version Oliver's CLI is provisional and has no stable release yet, so Rotkeeper pins an exact source revision: CI and scripts/setup.sh install the binary published by the upstream rolling builds release and verify it reports exactly commit <OLIVER_PIN>; the Zig source build remains the fallback (the OLIVER_PIN variable in setup.sh). Moving the pin is a deliberate act โ€” upgrade it, re-run the harness, and update this table. The pin moved 2026-08-21 from 9ad86a3 (9ad86a3763b8bd2f227fd5da94be9fc8ea5fa5fc) via 06dd640 (06dd6403c505b4863a54c548c978e494b55eb759) to land wrap fix #115 (PR #116, wrap parseArgs); the same-day move from 6edb520c to 9ad86a3 landed Phase 6 S1+S2+S3+S4+S5 (oliver meta #107 9ad86a3, wrap #108, render links #109, plan+manifest #110) โ€” frontmatter, template, link rewriting, output planning, manifest now Oliver-owned with Bash dispatch/env/packaging retained. The prior move (2026-08-15) from c8a8e06 to 6edb520c adopted the upstream builds release (prebuilt binaries + published sha256sums.txt, download-first with checksum and --version commit verification), plus the --version stdout fix, GFM footnote reference/backref fixes, and the XHTML footnote-attribute serialization fix โ€” Markdown/Textile/Cooklang parsing untouched, footnote/task-list literals remain out of scope. The move before that (2026-08-14) from e314dbbe to c8a8e06 had picked up the XHTML output profile (--to html|xhtml, oliver PR #54, docs/XHTML.md) โ€” same semantics, different serialization bytes; Markdown/Textile/Cooklang parsing untouched, CommonMark 652/652 and Cooklang 60/60 gates unchanged โ€” plus the audit fixes #55โ€“#58 (NUL โ†’ U+FFFD under the XHTML profile, CLI subcommand grammar with --to render-only). The move before that (2026-08-13) from 22b3c779 to e314dbbe had added the Cooklang frontend (CK1) plus CK2โ€“CK5. The 2026-08-27 bump from 06dd640 to 8460f28 landed the shared template contract v2 (rotkeeper #244): wrap interpolates the extended metadata tokens (version, subtitle, tags, asset_meta, navigation, warnings) from --meta-json (html-escaped, $if$-gated), with the adapter feeding version from bones/config/version and subtitle/tags/asset_meta from the source frontmatter. 8460f28 is the merge commit of oliver PR #126 (feature commit 6db830e) โ€” the builds release embeds the merge SHA. The same-day bump from 8460f28 to 3f05bac landed the v3 generic hook (rotkeeper #269, oliver #127, feature commit 84d0d6d): wrap interpolates any key present in --meta-json (strings html-escaped, null โ†’ empty, other scalars stringified, objects/arrays compact-JSON), so adding a frontmatter field needs no upstream change โ€” the adapter merges it into wrap_meta with typed keys winning. preflight's live smoke render (in the configured format and profile) remains the behavioral safety net on top of the pin

The binary is deliberately narrow: it converts Markdown, Textile, or Cooklang to a body fragment. Phase 6 complete (S1 meta, S2 wrap, S3 links, S4 plan, S5 manifest): frontmatter, template, link rewriting, output planning, manifest are Oliver-owned; Bash retains dispatch, environment, filesystem boundaries, orchestration, packaging.

Rendered Markdown surface

Oliver implements the CommonMark 0.31.2 specification; its own conformance harness scores 652/652 on the normative corpus (see the Oliver docs). That is the contract for what render produces:

Rendered Cooklang surface

Oliver implements Cooklang per the official spec and canonical corpus (60/60 on its conformance wall). That is the contract for what render produces for .cook sources and input_format: cooklang:

XHTML output profile (opt-in)

Oliver ships an explicit, deterministic, XML-compatible XHTML serialization of the same rendered document โ€” same normalized IR, same semantics, different bytes (oliver PR #54, docs/XHTML.md upstream). Rotkeeper exposes it as an opt-in page render mode; the default (html) path is byte-identical to the pre-XHTML contract.

Frontmatter โ€” Phase 6 S1 (Oliver-owned)

Frontmatter is parsed by Oliver via oliver meta --from <markdown|textile|cooklang> --format json < file.md (stdin โ†’ stdout JSON). Example:

oliver meta --from markdown --format json < file.md > meta.json  # {"title":"โ€ฆ","description":"โ€ฆ",โ€ฆ}
oliver meta --from textile --format json < file.textile > meta.json
oliver meta --from cooklang --format json < file.cook > meta.json
# Render auto-strips the same block:
oliver render --from markdown < file.md > body.html

The fields Rotkeeper reads are:

All other frontmatter keys pass through untouched. Values are HTML-escaped on template insertion. Rules retained: block must start on line 1 --- (no BOM, no leading blank line), close at next ---, ... not honored, scalar strings only (lists/maps ignored), null/empty treated as "". For the v2 template tokens (subtitle/tags/asset_meta) the adapter reads the raw source frontmatter directly with yq --front-matter extract โ€” see Extended metadata provenance.

Template and input contract (stable)

This section is the stable contract that Phase 6 ("Rationalize the Oliver boundary") uses as its definition of truth before any renderer-adjacent responsibility moves out of Bash. It is derived from rc-oliver-adapter.sh; if the two ever disagree, the script is authoritative and this document must be updated.

Input side

Template dialect

Templates are HTML files containing the following tokens:

Token Source Insertion
$title$ frontmatter title (sidecar wins) HTML-escaped
$description$ frontmatter description (sidecar wins) HTML-escaped
$author$ frontmatter author (sidecar wins) HTML-escaped
$date$ frontmatter date (sidecar wins) HTML-escaped
$palette$ frontmatter palette (sidecar wins) HTML-escaped
$version$ bones/config/version via the adapter ($VERSION, rk_load_version) HTML-escaped
$subtitle$ frontmatter subtitle (sidecar wins) HTML-escaped
$tags$ frontmatter tags list, adapter-joined with , HTML-escaped
$asset_meta$ frontmatter asset_meta map, adapter-serialized (below) HTML-escaped
$navigation$ site nav from bones/config/rotkeeper.yaml raw HTML via <site-nav> placeholder (post-wrap)
$warnings$ per-page renderer warnings HTML-escaped
$assets_root$ render batch (path prefix to the layout's assets dir) literal, not escaped
$body$ Oliver-rendered body fragment literal, not escaped (it is trusted rendered HTML)

Escaping is & < > " ' โ†’ &amp; &lt; &gt; &quot; &#39;. $assets_root$ and $body$ are the only tokens exempt from escaping โ€” templates must never place untrusted values there, and $body$ must never be escaped.

Conditionals: $if(name)$ โ€ฆ $endif$ for every metadata token (title, description, author, date, palette, version, subtitle, tags, asset_meta, navigation, warnings). When the value is empty (or null, or the key is absent) the whole block โ€” including its interior newlines โ€” is removed; otherwise the interior text is kept.

This dialect โ€” the typed metadata tokens (v1 five + v2 six) plus $assets_root$/$body$, escape/raw split, and the v3 generic hook for any other meta-json key โ€” is Phase 6 S2: Oliver wrap --template <file> --meta-json <json> --assets-root <prefix> --body <file> is authoritative (direct on pin 3f05bac, wrap dialect v3, oliver #127). Example:

oliver wrap --template bones/templates/theme-spooky-dark.html \
  --meta-json meta.json --assets-root ./assets/ --body body.html > page.html
# or: oliver render --template <file> --meta-json <json> (alternative, feature-detected)

The extended tokens joined the dialect with the shared template contract v2 (rotkeeper #244, pin move 06dd640 โ†’ 8460f28, oliver #126). $version$ is not frontmatter โ€” the adapter injects it from the canonical version source, the same single source --version and @HELP {VERSION} use. $subtitle$/$tags$/$asset_meta$ are read from the source frontmatter by the adapter with yq --front-matter extract (see provenance below); $warnings$ is reserved โ€” no adapter feed exists yet, so it substitutes empty. Site navigation is delivered as raw HTML through a literal <site-nav></site-nav> placeholder (below), not an escaped token, because every non-literal wrap token html-escapes and a real nav is markup. The v3 generic hook (rotkeeper #269, pin move 8460f28 โ†’ 3f05bac, oliver #127): the adapter merges every other scalar frontmatter key into wrap_meta (typed keys win the merge), so templates can reference any frontmatter field as $field$ โ€” $page_type$ on the necropolis 404 theme is the first consumer. Bash keeps TEMPLATE_DIR boundary checks; Oliver handles interpolation when wrap is present.

Extended metadata provenance (v2 + v3, implemented)

The extended tokens are live since the shared template contract v2 landed (rotkeeper #244): oliver wrap interpolates them on pins 8460f28/3f05bac (oliver #126/#127, the latter adding the v3 generic hook), and the adapter feeds them into wrap_meta:

Site navigation (<site-nav> raw-HTML slot)

$navigation$ is the config-driven site nav (topic #244). Because the recalled dialect html-escapes every non-literal token โ€” and a nav is markup, not text โ€” the adapter does not feed it through wrap. Instead a template declares a literal <site-nav></site-nav> placeholder and the adapter replaces it after wrap with nav rendered from bones/config/rotkeeper.yaml:

# bones/config/rotkeeper.yaml
navigation:
  label: "Primary Navigation"
  items:
    - label: "Home"
      target: "index.html"      # site-root-relative; the adapter prefixes the
      class: "txp-nav-link"     # current page's depth (./ or ../) + optional class
    - label: "Docs"
      target: "docs/index.html"
      class: "txp-nav-link"

rc-utils.sh's rk_render_navigation "$assets_root" builds <nav aria-label="โ€ฆ"><ul><li><a href="โ€ฆ">โ€ฆ</a></li></ul></nav> with hrefs resolved to the current page's depth (./assets/ โ†’ ./; ../assets/ โ†’ ../), then the adapter swaps <site-nav></site-nav> for it in the wrapped page (multi-line-safe via an ENVIRON AWK variable โ€” awk -v errors on newlines). Omit the config block and the placeholder is left untouched, so unaffected themes never see it. theme-textpattern is the first consumer, replacing its hardcoded tabs. ($navigation$ is not this slot; because it is a reserved token it currently substitutes empty.)

Documentation navigation and headings

For output pages under docs/ or help/, the adapter adds a breadcrumb, a collapsible section-page list, and previous/next navigation to the literal $body$ fragment before wrap. Each navigation has a distinct <nav> landmark label. This works with every shipped theme, including the XHTML wrapper, without adding an Oliver token or changing the renderer pin.

The template golden fixtures render under docs/, so they cover the shared navigation and title normalization through spooky-dark, brutal, and XHTML. The isolated documentation harness also checks every shipped template and all three layouts, escaped labels, nested paths, sidecar precedence, untitled pages, preserved anchors, and singleton navigation.

Sidecar precedence

A .soul.md sidecar next to a source file (under bones/meta) may override frontmatter fields. Sidecar wins over source frontmatter for the fields listed above. Without a sidecar, source frontmatter applies.

The adapter boundary (temporary, recorded)

rc-oliver-adapter.sh is a pure Bash batch adapter (zero Python). It currently owns:

  1. Batch manifest (TSV) iteration with boundary assertions (source under content, destination under output).
  2. Frontmatter extraction and sidecar merge โ€” S1: Oliver meta --from <fmt> --format json extracts 7 scalar fields; oliver render auto-strips.
  3. Template resolution โ€” Bash retains TEMPLATE_DIR boundary; Oliver wrap --template <file> --meta-json <json> --assets-root <prefix> --body <file> interpolates.
  4. Oliver invocation and stderr forwarding (including the --to xhtml flag when the effective render_profile is xhtml).
  5. Internal .md/.textile/.cook โ†’ .html link rewriting โ€” S3: Oliver render AST rewrites *.md|*.textile|*.cook โ†’ *.html.
  6. Template interpolation โ€” S2: Oliver wrap handles the v2 dialect (11 metadata tokens + html_escape + $if$/$endif$ + $assets_root$/$body$ literals).

rc-render.sh owns output planning. S4: Oliver plan --content-dir <dir> --output-dir <dir> --template-dir <dir> --meta-dir <dir> --default-template <file> --oliver-bin <bin> --root-dir <dir> --dry-run <bool> --verbose <bool> maps content โ†’ output.

rc-render.sh log_manifest owns manifest. S5: Oliver manifest --manifest <file> --add <rel> (dedup) + --verify for bones/manifest.txt.

Per the stabilization roadmap, these responsibilities are candidates for incremental movement into Oliver only after the contract above is stable. Bash keeps dispatch, environment setup, filesystem boundaries (rk_canonical_path, is_within_boundary, validate_layout_alignment, output_is_generated), orchestration, and packaging. S1+S2+S3+S4+S5 complete Phase 6; Bash retains dispatch/env/packaging.

Install paths

Oliver has no stable release yet, but upstream publishes a rolling builds release with prebuilt binaries (oliver-<os>-<arch> for linux/macos ร— x86_64/aarch64) plus a published sha256sums.txt. scripts/setup.sh is download-first: it fetches the platform binary, verifies it against the published checksum, and asserts oliver --version reports exactly commit <OLIVER_PIN> before installing to /usr/local/bin/oliver. Any failure falls back to building the pinned commit from source with Zig 0.16.0:

git clone https://github.com/drawmeanelephant/oliver.git /tmp/oliver
cd /tmp/oliver
git checkout --quiet <OLIVER_PIN>   # exact commit, never unpinned main
zig build                           # builds the library and CLI into zig-out/
install -m 0755 zig-out/bin/oliver /usr/local/bin/oliver

Then either put oliver on PATH or set RK_OLIVER_BIN=/path/to/oliver. CI environments (see .github/workflows/ci.yml) run scripts/setup.sh, which prefers the builds release (checksum + commit-version verified) and falls back to the Zig 0.16.0 source build when the download path is unavailable.

Smoke paths