Internals
Source layout
deck.toml the deck manifest: name, version, capabilities, engine floor
Dockerfile Grimoire on top of the official Jennifer image
grimoire.toml the configuration that builds docs/ into site/
bin/
grimoire the launcher (a Jennifer script with a shebang)
src/
grimoire.j the CLI, and the entry module the deck is named after
build.j the build: render, write, report; parallel across chapters
config.j grimoire.toml -> Config, with defaults for everything
summary.j SUMMARY.md parser, and the directory-tree fallback
content.j Markdown -> HTML: anchors, link rewriting, code blocks
highlight.j the built-in, build-time Jennifer syntax highlighter
layout.j the page shell: top bar, sidebar, contents, pager, search
palette.j the theme model and the stylesheet generator
theme.j the theme registry
themes/*.j the ten shipped themes, each with its own _test.j
locale.j Grimoire's own words, in eleven languages
keywords.j the per-page keyword meta tag
stopwords.j the words that carry no subject, in nine languages
watch.j the rebuild-on-change loop behind serve --watch
assets.j the client runtime (mode selector, search, copy buttons)
assets/ vendored: the Jennifer highlight.js grammar
search.j the search index
agents.j llms.txt and the JSON index, for a reader that is a program
plugin.j the plugin contract: run a program over the book
pdfbook.j the printable build
serve.j the local preview server
util.j slugs, paths, text helpers
version.j the version number, and the only copy of it in the sources
*_test.j one white-box test overlay per module, run by jennifer test
plugins/ the nine that ship, found by name beside Grimoire
grimoire-NAME the launcher: read stdin, run the module, exit its status
NAME.j the module, where the work is
NAME_test.j its overlay, run by scripts/test.sh with the rest
plugins-src/ third-party plugins to bake into the image; empty here
examples/
NAME/ one book per shipped plugin, built by the pipeline
packaging/
OVERVIEW.md the few lines a release page opens with
arch/ a PKGBUILD, and notes on building it
docker-entrypoint.sh enables third-party plugins, then execs the launcher
scripts/
check-style.sh no typographic characters, anywhere
check-print.j what the printable book would lose to WinAnsi
check-plugins.sh the shape every shipped plugin has to have
test.sh run every unit test, plugins included
screenshots.sh regenerate the theme gallery
theme-css.j write one theme's stylesheet to a path
bench.sh time a build, and the modules under it
bench-md.j the markdown / pdf modules on their own
docs/ this documentation, and the book this repository buildsThree of those are new enough to be worth a sentence each.
plugins/ is what an install carries beside the launcher, and what a bare table name in grimoire.toml resolves against before PATH is consulted. All twelve are a launcher, a module and an overlay each, with no exceptions.
plugins-src/ is a hole in the container build context, not a source directory: whatever is in it is copied to /opt/grimoire-plugins in the image, where the entrypoint links the plugins GRIMOIRE_PLUGINS names onto PATH. It holds nothing but its own README here, so the official image carries no third-party plugin at all.
examples/ is one book per shipped plugin, each small enough to read in one screen. They are the only end-to-end coverage the plugins have - the overlays cover the pieces - and they are built and asserted on every push.
bin/grimoire is a Jennifer program with a #!/usr/bin/env -S jennifer run shebang - the same language as the rest of the tool - and it is deliberately three lines of work. src/grimoire.j is the program; the launcher only says where Grimoire is installed and hands over the command line:
exit grimoire.run(appDir(os.ARGS[0]), os.ARGS); # ../src, from bin/That app directory is how the build finds the assets Grimoire ships - the highlight.js grammar - without ever consulting the working directory. grimoire.j is a module rather than a script, so it takes the directory as an argument: modules hold no mutable state in Jennifer, so there is nowhere for a program-wide value like this to sit except a parameter.
Two things make that work from anywhere. The interpreter resolves the import relative to the launcher's own file, so the working directory never matters. And the launcher runs os.ARGS[0] through fs.realpath before taking its directory, so a symlink on PATH - which is how a command normally gets installed - finds the assets beside the real file rather than beside the link:
ln -s "$PWD/bin/grimoire" ~/.local/bin/grimoireAn unresolvable path falls back to the invocation path. A book still builds then; only the bundled highlight.js grammar would be missed, and the build says so when it is.
The deck
deck.toml publishes Grimoire to the registry, and the specification is what decides three things about the layout above:
src/is the tree a consumer vendors, and everything in it is a module. That is why the launcher moved tobin/: a program with a shebang is not a module, and it has no business in the directory that gets installed into somebody else's project.- The entry module is named after the deck.
@jennifer/grimoireneedssrc/grimoire.j, so that is what the CLI module is called. capabilitiesdeclares what the code actually reaches for.netforserve, which binds a port throughhttpd;execfor the oneos.runthat asks git for the PDF footer's version stamp. Neither is needed to build a book, which is whyjennifer-tiny- which has neither - runs everything exceptserve.
Grimoire is a program rather than a library: nothing in src/ is meant to be imported by another deck. The manifest is how it is published and installed, not an API.
Notes on the Markdown
Rendering goes through the markdown module's document tree rather than its toHtml, because a documentation site needs more than the plain translation: stable heading anchors, .md links rewritten to .html, code blocks wrapped with a language tag and a copy button, and scroll containers around tables.
The walk hands each block kind to its own renderer, and two of them are worth calling out:
- An
html_blockis emitted verbatim, because writing one is a deliberate act by the book's author. It is the single exception: everything on the page that comes from anywhere else is escaped, and every link goes throughhtml.safeUrl- so ajavascript:target dies whatever its casing, however it is entity-encoded, and adata:URL with it.[html] rawHtml = falsecloses that exception too, for a book assembled from Markdown its author did not write; the block is then escaped and shown rather than run.
Inline HTML needs no setting and never did. The parser hands a <b>bold</b> c back as a single text node, so the inline path escapes it like any other text - which is why rawHtml is threaded through three functions rather than through the whole renderer.
- A
page_breakrenders as nothing. It is a directive for the printable build, and has nothing to draw on a web page.
Inline spans nest, so the children of a **...**, an emphasis, or a link label are rendered as the nodes they are - which is what keeps json.Value bold and monospaced, and what gets a link inside bold its .md rewritten to .html like any other link.
Anchors
Heading anchors follow GitHub and mdBook exactly, which is what lets a book migrate without rewriting its cross-references. Punctuation is dropped rather than folded to a dash, so
### REPL (`cmd/jennifer/repl.go`)anchors at #repl-cmdjenniferreplgo, not #repl-cmd-jennifer-repl-go. Getting this wrong is quiet - the page still builds, and only the links break - so it is worth stating: whitespace becomes a dash, runs of it are preserved, letters and digits (including non-ASCII) are kept, and everything else vanishes. A repeated heading gets a -1, -2 suffix in document order.
Links are rewritten to match: [x](guide/syntax.md#anchor) becomes guide/syntax.html#anchor, and a directory's README.md folds onto its index.html, with the fragment carried through untouched.
Search
The index is built from sections, not pages: a heading and the body text under it up to search.bodyChars, so a hit lands on the paragraph rather than the top of a long chapter. Both the index and the scorer are Grimoire's own - there is no search library to load - and the index is delivered as a script rather than fetched, because fetch on a file:// page is blocked by every browser and a book that cannot be read off a USB stick is a book with a dependency it does not need.
Records are grouped per chapter and reassembled in outline order after the parallel render, so the index is byte-identical no matter what --jobs was.
The PDF
The printable build assembles every chapter into one Markdown document and hands it to the pdf module, with a cover page, a nested bookmark outline, and every chapter opening a page of its own.
That last part takes two mechanisms, because chapters are not all at the same level. A chapter outside the parts keeps its level-one heading, and the layout breaks the page at every level-one heading - which is also what gives the cover a page to itself. A chapter under a part is demoted one level so the part heading can own the top of the outline, and a demoted heading breaks nothing; those chapters ask for the break explicitly with a <!-- pagebreak --> directive, which the module parses into a page_break node - the authoring side of it is in Markdown. The chapter that opens a part is the exception: the part heading has just broken the page, and a second break would leave the part title alone on a sheet.
Writing the directive as an HTML comment is deliberate - the same combined source still renders as HTML, where a browser shows nothing at all.
A part runs from its heading to the next separator. That is the only mark a SUMMARY.md has for saying the parts are over. Without one, an appendix listed after the parts is demoted as though it sat inside the last of them, and prints as a subsection of a part it has nothing to do with. A --- between the parts and the suffix chapters ends the part and puts those chapters back at level one, where the sidebar shows them:
# Part One
- [Chapter](one.md)
---
[Appendix](appendix.md)It picks up the book's theme throughout. Heading bars, the table header band, the panel behind a code block, and the tint and rule on a blockquote are all drawn from the selected theme's light palette - paper is white, so the dark palette would print as slabs of toner - with heading bars taking the accent mixed toward white, deepest at level one. So terminal prints with green heading bars and a green quote rule, sepia with brown ones, and the hierarchy still reads at a glance.
The tool credit lives in the document metadata rather than on the title page, in Creator and Producer, where a reader's document properties show it.
Print differs from the site in three deliberate ways:
- Raw HTML is dropped, by the layout. A hand-written block has no rendering on paper, and left in place the Jennifer introduction's inline SVG wordmark typesets as a page and a half of path data before the reader reaches a sentence.
- Links are resolved for paper. A cross-reference is not clickable in print, so it reads as its label alone, while an external URL keeps its address in parentheses.
- Pictures are drawn. A paragraph that is a single image - a PNG or a JPEG inside the book - is embedded and scaled to the text column, never wider than the measure and never taller than a page. An image inline in a sentence, one in a format the PDF cannot carry (an SVG), one that is not in the source tree, and one on another host all fall back to the alt text in brackets,
[a green rectangle], or[image]where there is no alt text. Nothing is fetched: a build makes no network request, so a picture on another host is a caption on paper.
Image targets are written relative to the chapter that uses them, and the printable book is one document built from every chapter, so each target is resolved against its own chapter's directory before the book is assembled - two chapters that both write images/plot.png mean two different files, and they stay two.
Beyond those, the print path passes each line through untouched, indentation included - which matters more than it sounds. Reflowing a paragraph here, by gathering its lines and trimming each one, would strip the indent from a continuation line, and an indented continuation that loses its indent stops belonging to its list item and becomes a stranded paragraph between the items. The layout reflows paragraphs itself, so there is nothing to gain by trying.
Characters the standard-14 fonts cannot encode are transliterated before the layout sees them: a rightwards arrow (U+2192) to ->, box drawing to - and |. The character is named rather than shown here, so this page obeys the punctuation rule it describes. unencodable is the last resort for whatever the table has no reading for, and a ? says less than -> does.
Most of the table is letters rather than symbols. WinAnsi covers the French, German and Spanish alphabets and stops there, which leaves Polish, Czech, Hungarian, Turkish, Croatian, Romanian, Latvian and Lithuanian - eight of them in a tool whose interface speaks eleven languages. Latin Extended-A holds those alphabets, and every character in it is a Latin letter with a diacritic, so the reading is the letter without it: Zwykły tekst prints as Zwykly tekst, which is what a passport does with the same name. A letter WinAnsi does carry is left alone, so most of a Czech sentence survives as written.
A non-Latin alphabet has no letter to fall back to, so Cyrillic, Greek prose and the CJK scripts print as question marks. Romanising one is a different job from dropping a diacritic. scripts/check-print.j reports which characters a given book would lose.
What the layout does, and what it does not
Grimoire leans on the markdown module for more than the parse, and it is worth knowing where the line falls - several things that look like they need code here do not:
thematic_break, html_block | a rule and a raw block are parsed, not guessed at from source |
| nested inline spans | json.Value keeps its code formatting |
| indented list continuations | a soft-wrapped item stays one item |
| autolinks | <https://example.com> is a link node |
pdf.foldLine in the layout | a long code line folds to the column by itself |
quoteFill, quoteRule, codeFill, codeBorder | themed panels in print |
creator, producer, unencodable | metadata, and the encoding fallback |
page_break / <!-- pagebreak --> | a demoted chapter can still open a page |
admonition nodes | > [!NOTE] is parsed, marker and title separated; the label and the markup are ours |
admonitionLabels | what the printable build calls a callout, in the book's language |
One thing the printable build cannot do: running headers and a "page N of M" footer. pdf.setHeader / setFooter and the %page% / %pages% placeholders exist, but markdown.renderPdf returns bytes rather than a pdf.Document, so the document-level hooks are out of reach from the Markdown path. Having them would mean Grimoire laying the book out page by page itself.
The plugins that ship
plugins/ holds twelve: a launcher, the module beside it, and a test overlay for that module, each with a book of its own under examples/. They reach Grimoire through the same JSON contract a third-party plugin uses - shipping them in-tree is how they are distributed, not a shortcut around the extension point - and scripts/test.sh and the pipeline treat them as Grimoire's own code, because they are released together and break together.
Two are maintained in their own repositories instead: grimoire-mermaid and grimoire-katex each drive a program (mmdc, katex) that nobody has installed by default, and a plugin that is useless without a tool does not belong in an install that promises to need none. Both are Jennifer apps - jvc app install <url> puts the command where Grimoire looks - which is the same shape jvc itself ships in, and the reason is the same: a program is installed and run, a deck is vendored and imported.
Building this repository
grimoire.toml here builds docs/ - the pages you are reading - so the repository is its own worked example, and a change to the renderer shows up in the next build of this manual:
bin/grimoire build # the site and the PDF, into site/
bin/grimoire serve # read it at http://127.0.0.1:8080/[pdf] enabled is on, so a plain build produces site/grimoire.pdf as well - which is what the download link on the introduction points at. Turn it off and that link goes dead.
.github/workflows/pages.yml runs exactly that build on every push, in the official Jennifer image, and publishes site/ to GitHub Pages. It works the PDF footer's version stamp out on the runner and passes it in as GRIMOIRE_VERSION / GRIMOIRE_COMMIT, because the image has no git of its own. It asserts the pieces a reader needs - the landing page, the stylesheet, the search index, a non-empty PDF, and the link between the last two - so an empty publish fails in CI rather than on the live site. A pull request builds and checks without publishing.
One version number, checked before anything ships
The number lives in four places that cannot see each other: deck.toml, which the registry publishes; src/version.j, which --version prints; packaging/arch/PKGBUILD, which builds from a release tarball; and the tag itself. Each is edited for its own unrelated reason, so nothing but a check keeps them together.
.github/workflows/version.yml is that check, and it is a callable workflow rather than a step, which is the whole point. A tag push starts test.yml, release.yml and docker.yml at the same moment, with no ordering between them. A check inside test.yml therefore gates nothing: it reports the drift some minutes after the release page exists and the image is in the registry, and a pushed tag is not a thing to move. Both publishing workflows now call version.yml first and stop when it fails, so a wrong number costs a deleted tag rather than a retracted release and an image nobody can unpublish.
workflow_run would not do: it fires only once the other run has finished, and resolves github.ref to the default branch rather than to the tag being released. A called workflow inherits the caller's event, ref and sha, so it checks out the tag that triggered the run - or, for a release composed by hand from workflow_dispatch, the tag named in the input, which is passed through as a parameter for exactly that reason.
The short version, for pages that are not this one
A container registry and a release page are read by someone deciding whether to install a version, not by someone learning what the project is. Both get packaging/OVERVIEW.md instead of the repository README: what it does in a paragraph, the command that runs it, and links out.
It reaches them two different ways, because the two places accept different things.
.github/workflows/release.yml uses the file as-is for a v* tag, adding the pull line for that version and the commit log since the previous tag.
The package page cannot take a file. It shows org.opencontainers.image.description, and falls back to rendering the whole repository README when that label is empty - which is what it did, because docker/metadata-action fills the label from the repository's GitHub "About" text and overwrites whatever the Dockerfile said. So docker.yml now states the label itself. One line: that input is newline-delimited key=value pairs, so a value cannot contain a newline, and one sentence with a link is the right length for the box it lands in anyway.
The same labels are repeated in the Dockerfile, so that a docker build . by hand produces the same image. They are meant to stay in step.
docs/CNAME is what binds the custom domain, and it needs no special handling: it is a non-Markdown file under src, so the ordinary asset copy puts it at site/CNAME. It has to be in the site rather than only in the repository settings, because a Pages deployment from an artifact publishes exactly what the artifact holds - a build that dropped the file would quietly unbind the domain. The pipeline asserts its contents for that reason.
The plugins the image carries
The nine that ship are in plugins/, copied into the image with the rest of Grimoire and found by name. Nothing about the container enables them; a book's own table does, as everywhere else.
/opt/grimoire-plugins is the other half: a place for third-party plugins, filled from plugins-src/ in the build context and switched off. packaging/docker-entrypoint.sh reads GRIMOIRE_PLUGINS, links the plugins it names into a directory on PATH, then execs the launcher. Two decisions, neither a default, because an image that mounts your directory and runs somebody else's program over it should ask twice.
The links are made under /tmp rather than in the image, because the container runs as whatever uid the caller passes and nothing else is writable for all of them. GRIMOIRE_PLUGIN_DIR moves that directory, which is what a --read-only container needs. An unknown name is refused before the build starts, with the list of names the image does carry: it is a typo, and a build that silently skipped the plugin would report something less useful several minutes later.
A plugin's NEEDS file lists the commands it needs, one per line, and the entrypoint warns about the ones the image has not got - git for grimoire-feed and grimoire-lastmod, and the base image carries none. That is a layer of the reader's own, documented in installation.
Sources are formatted with jennifer fmt and clean under jennifer lint:
jennifer fmt --write bin/grimoire src/*.j src/themes/*.j scripts/*.j
jennifer lint bin/grimoire src/*.j src/themes/*.j scripts/*.jTests
Every module has a src/NAME_test.j beside it, and scripts/test.sh runs the lot:
scripts/test.sh # everything
scripts/test.sh config util # just these modulesA _test.j is a white-box overlay, which is Jennifer's own arrangement rather than anything Grimoire invented: jennifer test src/config_test.j loads src/config.j and splices the overlay into it, so a test sees the module's private functions unqualified and shares its imports. That is why none of these files import the module they test - and why importing it anyway is an error, the alias already being bound.
The consequence worth knowing is that the private helpers are where most of the testing happens. assignWork, stripScripts, foldPlurals, pageFile and printLine are none of them exported, and all of them are places where a wrong answer produces a plausible-looking book rather than an error.
What the tests are for, beyond the obvious:
- The determinism promise.
assignWorkandplaceRecordsboth have cases that fail if a tie-break disappears - a failure a single-threaded build would never show. - The catalogs. Eleven parallel maps of twenty-seven keys rot quietly; a key added to English and forgotten in Polish shows up as a raw key name on a Polish reader's page and nowhere else.
locale_test.jcompares all eleven on every run.stopwords_test.jchecks the shape every entry has to have to work at all: lowercase, no spaces, no apostrophes, since the scoring lowercases before it looks and the term pattern breaks on both. - The runtime.
assets.jholds JavaScript in a raw Jennifer string, which ends at the first apostrophe with no escape available. One in a comment would truncate the runtime and the build would carry on quite happily.
The end-to-end behaviour - a real book built, byte-identical output at any --jobs, the PDF, the links - is not here. That is what a build is for, and the PKGBUILD's check() runs one.
Possible extensions
Not built, but the shape is there for them: a link checker over the resolved outline (the build already knows every output path and anchor), and multi-language books.
A grimoire-plugins repository is the next piece of the plugin story, and deliberately not a page in this manual. A list of other people's programs maintained by hand inside the tool's own documentation goes stale between releases, ties a third-party plugin's visibility to a Grimoire release, and puts this repository in the position of curating software it does not maintain. A separate project takes a plugin by merge request, carries its own tests against the current api, and can say what it needs at build time in its own README. This manual then documents the contract and the two plugins that ship, which is all it can keep true on its own.
An MCP server is a third. grimoire mcp over stdio - the transport the protocol leads with, so a subprocess the agent spawns rather than a daemon with a port - reads a built site and answers outline, page, search, and the anchor for a heading from llms.txt and assets/search-index.json. Nothing renders. The system mcp module supplies the server side.
Two constraints shape it. A tool handler is a bare top-level func taking only its JSON arguments, so it cannot close over the book and re-reads the site per call, which matches the stateless profile that module targets. And the test to apply is whether it beats grep -rn docs/: on a checkout it does not, which is what agents.j and its static files are for - the reader with no checkout to grep.