rheo
CLAUDE.md201 starsChanged 4 months agoApache-2.0View on GitHub
rheo compiles Typst documents to PDF, HTML, and EPUB. Written in Rust using the Typst compiler as a library. A FormatPlugin does format transport — compile, write, serve — and nothing else. Derived artifacts (feeds, sitemaps, search indexes, generated index pages) belong in Typst, minted from a .marrow.typ; rheo's job is only to provide the primitives that make them expressible (see "Marrow-authored derived artifacts" below). Source structure: - crates/cli/ — CLI entry point, resolve_assets, compilation orchestration - crates/core/ — Config,…
We split this CLAUDE.md into the rules it gives an agent
6 rules. About 0 are common, 1 is distinctive, and 5 are very specific to this project.
≈0
Common · estimate
Many files like this have a rule of this kind.
≈1
Distinctive · estimate
This author's own choices. Worth a look.
5
Very specific
Tied to their project's names, files or tools. Probably skip.
- Common
- Distinctive
- Very specific
- Very specific: line 130
Nested files: use : as path separator: content/chapters/intro.typ → <chapters:intro>.
- Very specific: line 134
Canonical-skip rule: if a user-authored label in the project already uses the canonical name, rheo silently skips injecting it — the …
- Very specific: line 136
Escape-collision error: if the escape label (<handle.typ>) collides with any user-authored label or another vertebra's escape label, the …
- Very specific: line 241
Prefer a data-structure-first approach over standalone functions: model the thing as a type and hang behaviour off it as methods (e.g.
- Distinctive: line 245
Terse and DRY: Write the smallest code that does the job.
- Very specific: line 264
Update version in Cargo.toml
≈ “Common” is an estimate: at least 1 in 5 of the 25,680 CLAUDE.md files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.
Files
CLAUDE.md
Project
rheo compiles Typst documents to PDF, HTML, and EPUB. Written in Rust using the Typst compiler as a library.
A FormatPlugin does format transport — compile, write, serve — and nothing else. Derived artifacts (feeds, sitemaps, search indexes, generated index pages) belong in Typst, minted from a .marrow.typ; rheo's job is only to provide the primitives that make them expressible (see "Marrow-authored derived artifacts" below).
Source structure:
crates/cli/— CLI entry point,resolve_assets, compilation orchestrationcrates/core/— Config, plugin trait (FormatPlugin),PluginContext,AssetConfig, world, spine, compilationcrates/html/— HTML plugin (dev server, CSS/JS injection)crates/pdf/— PDF plugincrates/epub/— EPUB pluginsrc/typ/rheo.typ— Core Typst template (auto-injected)build/— Output dir (gitignored):pdf/,html/,epub/
Integration tests and examples live in freecomputinglab/rheo-tests, cloned side-by-side at ../rheo-tests for sibling path dependencies.
Development Commands
cargo build
cargo run -- compile <project-path> # all formats
cargo run -- compile <path> --pdf|--html|--epub
cargo run -- compile <file.typ> # single file
cargo run -- watch <project-path> --open # dev server at localhost:3000
cargo run -- clean <project-path>
cargo run -- clean <path> --packages # also drop cached repo checkouts for [packages] namespaces
RUST_LOG=rheo=trace cargo run -- compile ... # debug logging
RUST_LOG=rheo=debug cargo run -- compile ... 2>&1 | grep 'phase=' # per-phase build timings
# Tests
cargo test # run unit tests only
# Integration tests run from ../rheo-tests with:
RHEO_MANIFEST=../rheo/Cargo.toml cargo test --test harness
See [TESTING.md](TESTING.md) for more test commands and options.
cargo fmt && cargo clippy -- -D warningsrheo.toml
version = "0.2.1" # required, must match CLI version
content_dir = "content" # optional
build_dir = "build" # optional
formats = ["html", "pdf", "epub"] # default formats
font_dirs = ["fonts"] # optional; replaces autoscan of fonts/ directory
copy = ["*.txt"] # optional; glob patterns copied to every plugin output dir
[html.assets]
copy = ["images/**"] # optional; glob patterns copied to html output dir only
css_stylesheet = "custom.css" # optional; path override for AssetConfig name
# Multiple asset blocks: each [[html.assets]] contributes its own
# overrides and copy patterns. All sources are copied verbatim by default.
[[html.assets]]
css_stylesheet = "one.css"
js_scripts = "one.js"
dest = "subdir" # optional; output subdirectory for this block's files
[[html.assets]]
css_stylesheet = "two.css"
js_scripts = "two.js"
[spine]
exclude = ["drafts/**"] # optional; glob patterns (relative to content_dir) omitted from every format's scan
prelude = "_lib/prelude.typ" # optional; Typst prepended INSIDE every vertebra, after its rheo-context()
auto_index = false # optional; default true — false restores pre-auto_index behavior (no synthesized directory-index pages)
[[spine.section]]
name = "chapters" # optional; virtual-directory regrouping without moving files on disk
include = ["ch-*.typ"]
[pdf.spine]
title = "My Book" # per-format override; only the fields set here replace the global [spine]'s
[epub]
identifier = "urn:uuid:..." # optional, auto-generated
date = 2025-01-15T00:00:00Z
[epub.spine]
title = "My Book"
[marrow]
file = "bundle-root.typ" # optional; overrides the bare `.marrow.typ` filename
position = "prologue" # optional; "epilogue" (default) or "prologue" — see "Marrow filenames" below
# Where a package namespace resolves from. Optional and rarely needed: with no
# [packages] table, @rheo resolves from its built-in releases host and every
# other namespace goes to Typst universe.
[packages.rookery]
releases = "freecomputinglab/rookery" # <owner>/<repo>, or a URL template
# carrying {name} and {version}
[packages.rheo] # overrides the built-in @rheo
repo = "https://github.com/freecomputinglab/rheo-packages" # any URL git accepts
branch = "feat-x" # or tag = "...", or rev = "<sha>"
subdir = "" # optional path prefix inside the repo
[packages.demo] # resolves from a directory on disk
path = "../pkgs" # a package's own working tree, read in place[packages.<namespace>] declares where one namespace comes from. Set exactly one of repo, releases or path — switching a project between a release, a branch and a local directory is an explicit edit, not a precedence rule.
releasestakes an<owner>/<repo>shorthand (detected by having no scheme), expanded to GitHub's download base, or a full URL template containing both{name}and{version}for any other forge. Assets are<name>-<version>.tar.gzunder the tag<name>-<version>.repotakes any URL thegitbinary accepts — https, ssh, or a local path.branch(defaultmain),tagandrevselect the ref; when more than one is set the precedence isrev, thentag, thenbranch, and the losing keys are warned about rather than silently dropped.subdir(default empty) is a path prefix inside the repository, so@<ns>/<name>:<version>lives at<subdir>/<name>/<version>/.pathpoints straight at a directory holding<name>/<version>/package trees — no git, no clone, no cache, so an edit to the package shows up on the very next build. A relativepathresolves againstrheo.toml's own directory. There is nobranch/tag/revalongside it (there is no ref to select);subdirstill applies. This is machine-local — it is a line you flip on your own checkout, not something a teammate's build can see, and rheo has no local-override config file to keep it out of a committedrheo.toml.
The namespace key must be a Typst identifier, since it appears in every import spec as @<namespace>/name:1.0.0.
Precedence: CLI flags > rheo.toml > built-in defaults. Without rheo.toml, title and spine are inferred from filename/directory.
Font directory resolution: Without font_dirs in config, fonts/ at project root is auto-discovered. Setting font_dirs replaces autoscan (include "fonts" explicitly if desired). --font-dir CLI flag always appends.
Publication facts live in Typst, not rheo.toml. A feed's title, author, and site base URL are deliberately not config keys — a package minting a derived artifact from marrow (e.g. @rheo/feeds) takes them as literal Typst values or its own package-level config.
Cross-file references
rheo assigns each vertebra a canonical label derived from its path relative to the content directory. Link to it with standard Typst anchor syntax:
#link(<intro>)[Link text]
#link(<chapters:intro>)[nested page]Root-level files (content/intro.typ) get a bare label: <intro>.
Nested files use : as path separator: content/chapters/intro.typ → <chapters:intro>. : and . are valid Typst label characters; / is not.
Escape form: <handle.typ> is always available as an alias (e.g. <intro.typ>, <chapters:intro.typ>). Useful when the canonical label is taken by a user-authored label.
Canonical-skip rule: if a user-authored label in the project already uses the canonical name, rheo silently skips injecting it — the vertebra is still reachable via its escape form.
Escape-collision error: if the escape label (<handle.typ>) collides with any user-authored label or another vertebra's escape label, the build fails with an error naming the offending file and label.
rheo-context
rheo injects a per-vertebra Typst binding rheo-context() into every spine file, exposing rheo's view of the project to authored Typst and to packages. It is a zero-arg function prepended to each file's source, returning a dictionary composed of this file's own handle, a metadata-of closure, and the format-global values spread from sys.inputs.rheo-context: #let rheo-context() = (handle: <handle>, metadata-of: rheo-metadata, ..sys.inputs.rheo-context). Only the per-file handle is baked per vertebra; the shared (potentially large) spine is stored once on sys.inputs, not duplicated into every file. sys.inputs reads need no #context, so reading its fields (rheo-context().handle, .spine, .spine-flat, .target, .ext) does not require the #context keyword. The function form also lets an author mock it under vanilla Typst.
Fields (the returned dictionary may gain fields later):
handle— this file's:-separated handle (its ID; the same handle used for the cross-file links above). The only per-file field.spine— the structured spine tree, mirroring directory/section nesting. Each node is a dict(title, handle, path, children): a leaf (a vertebra) carries its ownhandle/path/title; a group node (a directory or[[spine.section]]with no landing file) carrieshandle: none,path: none, and its own group title, nesting its children.titlehere is always path-derived (the file/directory name, prettified) — it does not reflect any#set document(title: ...)a vertebra authors, literal or otherwise (seedocs/limitations.md).spine-flat— the flat pre-order list of every clickable vertebra (groups excluded), each an entry(handle, path, title). Same path-derived-only caveat as the tree'stitle.metadata-of— a function value,(handle) => dict, that reads another vertebra's resolved#set document(...)values live off the compiled bundle (a "metadata beacon" each vertebra publishes after its own body). Returns a dict with whichever oftitle/author/description/keywords/datethe vertebra actually set (absent keys are omitted, notnone) — most authoring forms work (literal, via an imported#show:template, non-literal expressions, multiple#set document(...)rules), but a value set inside a bounded#{ }/#[ ]code block does not (Typst's own block scoping hides it from the beacon's#contextread, even though the vertebra's own compiled<title>/DocumentInfo is unaffected — seedocs/limitations.md).title/descriptioncome back as real Typst content, not flattened strings;author/keywordsare always arrays;dateis a realdatetime. Returns(:)for a handle with no beacon (combined PDF layouts never emit one). Since it's a dict field rather than a method, call it as(rheo-context().metadata-of)(handle), notrheo-context().metadata-of(handle). Requires#context— it callsquery(...)internally, unlike every otherrheo-context()field, which reads straight offsys.inputsand needs no#contextat all. The same underlying helper (rheo-metadata(handle), plus arheo-metadata-all()companion returning one entry per vertebra) is also in scope directly inside a.marrow.typat the bundle root, for building a feed/sitemap/index over every vertebra's metadata at once.target— the rheo output-format name ("epub"/"html"/…). Present only for formats that set one; absent for PDF, where documents fall back to Typst's nativetarget()=="paged".ext— the output file extension ("html"/"xhtml"), gated liketarget(present for html/epub, absent for PDF). The value core reads to build depth-relative cross-vertebra link hrefs.
spine/spine-flat/target/ext are format-global (identical across vertebrae), stored once on sys.inputs.rheo-context; rheo-context() composes them with the per-file handle and metadata-of.
This is #rheo-context().handle of #rheo-context().spine-flat.len() pages.
// Reading another vertebra's real (not path-derived) title needs #context:
#context [#(rheo-context().metadata-of)("chapters:intro").at("title", default: [Untitled])]Passing it to packages: a package template can't read the file's local rheo-context() implicitly — Typst functions capture their definition scope, not the call site — so hand it in explicitly:
#import "@rheo/somepackage": template
#show: template.with(ctx: rheo-context())Detecting a rheo build (for a friendly native-Typst error). rheo also seeds sys.inputs with a rheo-context key (a plain data dict, not a function) carrying the file-independent context (spine/spine-flat/target/ext). Because sys.inputs is global to the whole bundle compile, it does not carry the per-file handle — that comes from calling the per-vertebra rheo-context(). A package (or author) uses sys.inputs to detect a rheo build and turn native-Typst compilation into a friendly message:
#show: template.with(ctx: if "rheo-context" in sys.inputs { rheo-context() } else {
panic("This document must be compiled with Rheo — https://rheo.ohrg.org")
})A package needing only the shared spine can read sys.inputs.rheo-context.spine directly and never call the per-file rheo-context(). rheo-context is no longer the only key on sys.inputs: a project seeds its own via a rheo.toml [inputs] table and --input KEY=VALUE (values always strings, rheo-context itself reserved), which is how a build script parameterises a compile — see docs/contract.md's "Project-supplied sys.inputs". (rheo migrate rewrites the old bare rheo-context binding to the rheo-context() call form.)
Output format. rheo injects a target() polyfill into every file so Typst's own target() returns the output format ("epub"/"html", or native "paged" for PDF), reading it from sys.inputs.rheo-context.target. Authored files should detect the format with target() (e.g. target() == "epub"); it is the only per-file API. The underlying sys.inputs.rheo-context.target is the same value for every vertebra but is not in scope where the polyfill isn't (it is global, so reachable via sys.inputs). The older sys.inputs.rheo-target key has been removed.
Section-label namespacing is a package concern, not core. rheo does not rewrite or prefix authored labels; label semantics stay exactly as Typst defines them (two vertebrae defining the same label collide as an ordinary Typst duplicate-label error). Packages such as @rheo/notebox use rheo-context() to additively synthesize globally-unique <handle:label> section anchors alongside the author's own labels.
Spine configuration
Directory-scan default: with no [spine]/[<format>.spine] at all, the spine is every .typ file under content_dir, recursively, ordered alphabetically per directory level. A directory whose landing file is index.typ or <dirname>.typ gets that directory's own handle (e.g. chapters/chapters.typ → <chapters>, not <chapters:chapters>); a directory with no landing file and no children is dropped entirely, and one with children gets a synthesized landing page (see [spine] auto_index below) instead of becoming a non-clickable group node.
[spine] auto_index: true by default. A directory with children and no landing file (index.typ/<dirname>.typ) gets a synthesized one instead of becoming a non-clickable group node: an empty page whose whole body is a call to rheo-index() (docs/contract.md's "Directory-index helper"), the default renderer for a plain list of links to that directory's own children. A project styles its own directory indexes once by binding #let rheo-index() = ... in [spine] prelude, which — spliced after the default — shadows it for every vertebra, rather than hand-writing an index.typ per directory. Set false to restore the pre-auto_index behavior exactly: such a directory becomes a non-clickable group node with a prettified title (01-intro/ → "Intro"), with no page of its own.
[spine] exclude: glob patterns (relative to content_dir) for files/folders to omit from the scan.
[spine] include: an ordered glob list that replaces this spine's scan order outright, dropping any leaf it matches no pattern for.
[[spine.section]]: groups matched files under a virtual directory without moving them on disk (e.g. include = ["ch-*.typ"] under name = "chapters" gives those files handles like <chapters:ch-1>). Nests via [[spine.section.section]].
include is a flat reorder and section nests into virtual directories, so one table sets one or the other: both at once is a parse error, since the two knobs have no combined meaning.
[spine] prelude: a path (relative to content_dir) to a Typst file prepended inside every vertebra, after its rheo-context() binding. Unlike marrow it is part of the vertebra's own source, so a #let in it binds a name the page can use — a vertebra is #included, and Typst scopes an included file's bindings to itself, which is why marrow can only reach pages through #show/#set. Because it follows the context binding it can call rheo-context(), so a project derives per-page facts once instead of restating them per file:
[spine]
prelude = "_lib/prelude.typ"// content/_lib/prelude.typ
#import "/_lib/template.typ": constructors
#let (page, note) = constructors(ctx: rheo-context())Imports in it must be root-absolute — the same text lands in vertebrae at every depth. The splice is keyed per vertebra, so it reaches neither a partial pulled in by #include nor the library file it imports (which would recurse). The imported library belongs outside content_dir: only the prelude's own path leaves the scan, so a library beside it under content_dir compiles as a vertebra, gets the prelude spliced in, and imports itself — a cyclic import that fails the build. An unreadable path is fatal.
The prelude's own text is inlined verbatim into every vertebra, so its cost is page count times its own size — a 10 KB prelude on a 5000-page site is 50 MB of synthesized source, parsed once per page, where marrow is parsed once for the whole build. Keep it to a few #let bindings that import from a root-absolute module (evaluated once by Typst), rather than putting the template's own code inline here.
Precedence — field-by-field, not whole-table: a per-format [<format>.spine] table can set title, exclude, prelude, auto_index and its layering (include or section) independently; any field it leaves unset falls back to the matching field on the global [spine] table (not the whole table at once). Layering falls back as one unit — a per-format include replaces a global section, rather than joining it. For example, [pdf.spine] title = "My Book" with no exclude of its own still inherits the global [spine] exclude — it does not silently drop it just because [pdf.spine] exists.
The retired vertebrae glob-list key (pre-0.5.0) is no longer read; rheo migrate converts an old inclusion-filter vertebrae list into an equivalent exclude.
PDF combines its spine into a single document by default; HTML and EPUB always produce per-page outputs. A merge key left in an old rheo.toml has no effect and warns on every build; rheo migrate reports and removes it.
Marrow-authored derived artifacts
A .marrow.typ at the bundle root (a project's own, or one shipped by a package) mints extra output files from Typst, outside every vertebra's own #document(...) block. Three primitives make it possible to build a feed, sitemap, search index, or generated index page there instead of in a plugin crate:
Transclusion. A bundle-emitted asset may contain <rheo-content page="..." select="..." as="escaped|raw"/>, replaced after compilation with the inner HTML of the selected region of that compiled page. select defaults to a cascade: the first <main> element, else the first element carrying the rheo-content class, else the whole <body>; select can also name a bare tag or a leading-dot class explicitly. as is escaped (default, for <content type="html">) or raw (verbatim, for <content type="xhtml">). This exists because marrow runs inside the Typst compile, before any page HTML exists, and Typst's html module exposes only element/frame construction — no content-to-HTML-string function.
Head contributions. A <rheo-head> wrapper anywhere in a page's body has its children hoisted into that page's own <head>. A .rheo/head.html control asset minted from marrow instead appends to every page's <head>, after each page's own hoisted content. Both exist because Typst builds <head> solely from the compiled DocumentInfo — there is no other author hook into it.
Control assets. The .rheo/ bundle-output prefix is reserved: an asset minted under it (e.g. .rheo/head.html) is a message from the bundle to rheo, consumed during compilation and never written to the actual build output.
Marrow filenames. .marrow.prologue.typ splices before every #document(...) (so a #show/#set in it reaches pre-existing vertebrae) and .marrow.epilogue.typ after. A bare .marrow.typ is a fallback for whichever position the other two leave open: either explicit name outranks it and it is then not read at all. A project's bare marrow takes the position [marrow] position names (default "epilogue"); a package's is always epilogue, since one project's setting has no business moving a dependency's splice.
@rheo/feeds (in ../rheo-packages) is where Atom feed generation now lives, built on these three primitives plus rheo-metadata-all() (see rheo-context above) — no Rust code, no plugin, no rheo.toml keys.
Code Style
cargo fmtbefore committingcargo clippy— fix all warnings- Errors via
thiserror, logging viatracingmacros - INFO logs: natural language. DEBUG: implementation details.
- Doc comments on general abstractions (traits, generic types, module docs) describe the abstraction, not its implementors. Avoid "the first impl", "X is the first", or naming concrete impls (e.g. a trait doc referencing a specific type that implements it). Concrete examples belong on the concrete type's own docs.
- Prefer a data-structure-first approach over standalone functions: model the
thing as a type and hang behaviour off it as methods (e.g.
TypstLiteralwith aserializemethod,LabelSites::from_source), rather than free functions that pass data around. Reach for a struct/enum +implbefore a barefn. - Terse and DRY. Write the smallest code that does the job. Two call sites
needing the same logic means extracting it, not copying it with a comment
saying it mirrors the other — if an issue's steps describe logic that already
exists somewhere ("does X exactly as
foodoes"), factor out the shared part instead. - Comments earn their place. A comment states what the code cannot: a
non-obvious invariant, a required ordering, a deliberate omission that would
otherwise read as an oversight. It does not restate the line below it,
narrate the steps, mark sections, cite the line numbers of similar code, or
explain why the change was made — that belongs in the commit message, the
bead, or
docs/. Doc comments on public items stay to a line or two unless the abstraction genuinely needs more. Prose pasted from an issue body into the source is not documentation. - Deleting a feature means deleting it. No commented-out code, no tombstone
comment explaining what used to be there; git history and
rheo migratecarry that.
Release
- Update version in
Cargo.toml - PR title = version tag (e.g.
v0.2.0) +releaselabel - Merge triggers automated build, crates.io publish, GitHub Release
Workflow (jj · beads · Plan Mode)
These shared processes live in the computer-wide ~/.claude/CLAUDE.md. For the churn/pair
"when done" step, this project's formatter/linter is cargo fmt && cargo clippy -- -D warnings.
Does it work?
Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.
Reviews can't be read right now.
Your trail
Files you open will collect here, so you can find them again.
Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.
Now do this for your own.
Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.

