Skip to content
Grimoire

Configuration

grimoire.toml sits next to the book, and every key in it is optional - a directory of Markdown files builds with no configuration file at all. Point --config elsewhere to use a different one.

Two rules hold throughout:

  • A command-line flag wins over the file, so a one-off build needs no edit.
  • A key of the wrong type keeps its default rather than failing the build, and an unknown key is ignored. A half-written table degrades to the defaults instead of aborting a build that would otherwise have succeeded.

The whole file

Every key, with its default:

toml
[book]
title = "Documentation"
description = ""
authors = []
authorsLabel = "Written by"
language = "en"
src = "docs"

[build]
out = "site"
clean = false           # true empties the output directory first
jobs = 0                # 0 = one render task per CPU

[html]
theme = "grimoire"
mode = "auto"           # auto | light | dark
uiLanguage = "en"       # defaults to book.language
rawHtml = true          # false escapes hand-written HTML blocks
navPosition = "left"    # left | right | off
tocPosition = "right"   # left | right | off
tocDepth = 3
sectionNumbers = true
footer = "Rendered with <a href=\"...\">Grimoire</a>"
titleUrl = ""           # where the title links; "" is the book itself
repoUrl = ""
repoLabel = "Source"
editUrl = ""
favicon = ""
logo = ""
keywords = true
keywordStopwords = []

[highlight]
enabled = false

[highlightjs]
enabled = false
cdn = "https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.11.1"
style = "github"
styleDark = "github-dark"
languages = ["bash", "go", "json", "yaml", "xml", "ini", "nginx"]

[search]
enabled = true
bodyChars = 1200

[agents]
enabled = true

[pdf]
enabled = false
output = "book.pdf"
paper = "a4"            # a4 | letter
bookmarkLevel = 3
pageNumbers = false
footerLeft = ""
titlePage = true
imageDpi = 96
exclude = []

[book]

KeyTypeDefaultMeaning
titlestring"Documentation"shown in the sidebar and in every page title; also the PDF cover and Title
descriptionstring""emitted as a description meta tag, and as the PDF Subject
authorslist of string[]the author meta tag, the PDF Author field, and the reader-facing credit
authorsLabelstring"Written by"what introduces those names where a reader sees them; "" prints them alone
languagestring"en"the BCP 47 tag on the html element, and the language Grimoire's own words are printed in
srcstring"docs"the directory of Markdown to build, relative to the working directory

src is where the outline comes from. If it holds a SUMMARY.md, that file is the outline; if not, the directory tree is walked instead.

Every non-Markdown file under src is copied into the site at the same path, so images and downloads sit beside the pages that use them. src = "." is allowed - a project whose repository root is the book writes it - and it is worth knowing what a whole repository then publishes: everything that is not Markdown, dotfiles included. Three things are never copied, whatever src is:

the output directoryso a build never copies the last build into this one
version control metadata.git, .hg, .svn, .bzr, .jj, .sl - never a book's asset, and a published .git is the whole history and any credential in its config. .github is not version control and is copied
the manifest this build readgrimoire.toml is Grimoire's input, not the book's content

Anything else in a repository root - a Makefile, a node_modules, a directory of spikes - is published, because src = "." says the book is the repository. A book that wants a subset should say so with a directory.

A symlink is not followed. The walk that copies assets does not report symlinks at all, so a directory of links into other trees builds its chapters - they are read by path, and a link resolves - and copies none of their images. One src is one directory; a book assembled from several needs its sources staged into one tree, which is a build step of its own.

authors is used two ways, and only one of them is labelled. The author meta tag and the PDF Author field want the names alone, because those are read by software. The page footer and the PDF cover want a credit, because a name standing on its own says nothing about what it is - so authorsLabel goes in front of it:

toml
authors = ["Ada Lovelace", "Grace Hopper"]
authorsLabel = "Built with love by"   # -> Built with love by Ada Lovelace, Grace Hopper
authorsLabel = ""                     # -> Ada Lovelace, Grace Hopper

[build]

KeyTypeDefaultMeaning
outstring"site"where the site is written
cleanboolfalseempty the output directory before building
jobsint0chapters rendered in parallel; 0 means one task per CPU

jobs above the chapter count simply idles the extra workers. See Performance for what raising it actually buys.

out may sit inside src - src = "docs" with out = "docs/site" is a tidy layout, and it is the one serve --watch is built around. Everything under the output directory is skipped by the pass that copies images and downloads across, so a build never copies the last build into this one. The two paths are compared as directories rather than as strings, so an absolute --out under a relative --src is recognised as well.

Emptying the output directory

The output directory is created if it is missing, and by default nothing in it is removed: a file already there survives unless the build writes over it. That has two consequences, and which one matters depends on the book.

An unrelated file in site/ survives, which is what you want when the output directory holds something that was never Grimoire's - a .well-known/, a hand-written robots.txt, a directory another tool publishes into. But a chapter you deleted from the book survives too, still reachable at its old URL and still in whatever the last build's search index said, which is not what anyone wants before publishing.

clean = true picks the other behaviour:

toml
[build]
clean = true

It is off by default because both readings are reasonable and only one of them deletes files. A book opts in; nothing opts in on its behalf.

What a prune keeps. The directory itself, because it may be a mount, a symlink, or the root a serve is already answering from. And every entry whose name begins with a dot - a .git for an output directory that is a publishing worktree, a .nojekyll, a .gitignore. None of those is Grimoire's output, all of them are painful to lose, and nothing in a build puts them back. Anything Grimoire does put there comes from the source tree and is copied in again by the same build.

What a prune refuses. Emptying a directory is the one thing a build does that running it again will not undo, so three configurations are rejected outright rather than obeyed:

a filesystem rootout = "/"
the working directory--out ., usually meant as --out ./site
anything holding the sourcesout = "." with src = "docs", or out and src the same

Each of those is a plausible slip rather than a hypothesis, and each would delete the book. The build stops with a message naming the directory, and nothing is removed or written.

On the command line, build and serve both take --clean and --no-clean. The pair exists because this is the one setting where a book may reasonably say one thing and a single run the other; --no-clean wins if both are given, since the reading that deletes nothing is the right one to take when it is not clear which was meant.

serve --watch prunes only the build it does on the way in. A rebuild never does: it would empty the directory the server is answering from on every save, and a reader who reloaded at the wrong moment would get a 404 rather than a page.

[html]

KeyTypeDefaultMeaning
themestring"grimoire"one of the ten themes; an unknown name falls back to grimoire
modestring"auto"the colour mode a first-time reader gets: auto, light, dark
uiLanguagestringfrom book.languagethe language Grimoire's own words are printed in, when it differs from the book's
rawHtmlbooltrueemit a hand-written HTML block from the Markdown as written; false escapes it
navPositionstring"left"which side the book-contents sidebar sits on: left, right, off
tocPositionstring"right"which side the on-this-page column sits on: left, right, off
tocDepthint3deepest heading level in the per-page contents; clamped to 1-6
sectionNumbersbooltruenumber the chapters in the sidebar
footerstringthe Grimoire creditHTML placed in the page footer; "" for no footer
titleUrlstring""where the title in the top bar links; "" is the book's own landing page
repoUrlstring""a source-repository link in the top bar; "" for none
repoLabelstring"Source"the label on that link
editUrlstring""an edit-this-page URL with a {path} slot; "" for none
faviconstring""a favicon path, copied into the site
logostring""a logo shown beside the title, relative to src
keywordsbooltruederive a keywords meta tag for each page from the page itself
keywordStopwordslist of string[]further words the keyword pass should ignore, on top of the list for book.language

mode only decides the first visit. Once a reader touches the selector, their choice is remembered and this setting no longer applies to them. Whatever it resolves to is stamped on the document before the first paint, so navigating a dark site never flashes white.

The book's title sits in the top-left corner, and by default it links to the book's own landing page - the same place the first chapter of the contents goes. For a book that is one part of a larger site, that is a dead end: the obvious way back is the obvious thing to click, and it goes nowhere new.

toml
[html]
titleUrl = "https://example.com/"

Now the title is the way out, and the contents is the way in. The two stop duplicating each other.

Give it an absolute or a root-relative URL (https://example.com/, or /). Unlike every other link Grimoire writes, this one is not rewritten per page: it names somewhere outside the book, so the book has no path to resolve it against. A page-relative value like ../ would mean a different place on every page, which is never what anyone means.

An external target is marked rel="noopener noreferrer", the same as the repository link. The value goes through the same URL gate as every other href, so a javascript: scheme does not survive it.

--title-url on build and serve overrides it for one run, which is the quick way to see it before writing it down. --title-url "" goes the other way and points the title back at the book.

The title's text is always book.title, and it is always text - escaped here, in the <title> element, in the og:title tag, in the alt of a logo, on the PDF cover, and in the PDF's own Title field. That is why this is a separate setting rather than markup allowed inside the title: a title carrying <a href="..."> would put a tag into all six of those, and five of them cannot use it. If you want a different mark beside the title, that is logo.

Hand-written HTML

A Markdown file may hold a block of HTML, and by default it reaches the page as written:

markdown
<div class="callout">
Something the Markdown subset cannot say.
</div>

That is what every comparable generator does, and it is the point of writing such a block: the author is reaching past Markdown on purpose, in a file they control, the same way [html] footer reaches past it in grimoire.toml.

It is also the only thing on a page that is not escaped. Prose, headings, code spans, code blocks and attribute values all go through the escaper, and every link target goes through a URL gate that removes a javascript: scheme in any casing, entity-encoded, and a data: URL with it. Inline HTML is not an exception either - a <b>bold</b> c inside a paragraph is escaped and shown, because the Markdown parser hands it back as text rather than as markup.

So the setting is about one case, and it is a real one:

toml
[html]
rawHtml = false

Turn it off for a book you are building out of Markdown you did not write - a generated API reference, contributed chapters, a vendored README, anything assembled by a tool from somewhere else. Those blocks are then escaped and shown as markup rather than run as markup. Nothing else about the page changes.

Leave it on for an ordinary book. Turning it off would show the source of a callout a chapter wrote on purpose, which is not an improvement.

--raw-html and --no-raw-html on build and serve override it for one run. As with --clean, giving both is a mistake, and the safer reading wins: --no-raw-html escapes, and escaping shows the markup where the other way runs it.

The two navigation columns

A page carries two of them, and each is placed independently:

  • navPosition is the book contents - the whole outline, the same on every page, with the current chapter marked.
  • tocPosition is on this page - the headings of the chapter being read, which is what tocDepth above sets the depth of.

Either takes left, right, or off, so all nine arrangements are available:

toml
[html]
navPosition = "left"     # the default: outline left, headings right
tocPosition = "right"

navPosition = "right"    # mirrored, for a right-to-left reading order
tocPosition = "left"

navPosition = "left"     # both together, down one side
tocPosition = "left"

navPosition = "off"      # a single column of text and nothing else
tocPosition = "off"

When both share a side the book contents sits outside the page contents, nearer the edge: it is the wider scope of the two, and it is the one that does not change as the reader moves through the book.

off leaves the column out of the HTML rather than hiding it with a stylesheet, so the pages are smaller and the work behind them is skipped - navPosition = "off" renders no outline at all, on any page. Nothing else is lost: turning the page contents off does not affect the search index or the PDF bookmarks, which are built from the same headings by a different path.

Below 1080px wide there is no room for a second column, so the book contents becomes a drawer behind the menu button in the top bar - hinged on whichever edge navPosition names. The page contents appears at 1400px, or at 1080px when there is no sidebar taking up the width.

--nav and --toc on grimoire build override both, which is the quick way to see an arrangement before committing to it:

sh
grimoire build --nav right --toc left --out /tmp/mirrored

The interface language

Grimoire puts about twenty words of its own on a page - "Search", "On this page", "Previous", the colour-mode buttons, the keyboard hints in the search dialog, the label on the copy-code button - and they follow book.language:

de Germanes Spanishfr Frenchit Italian
ja Japanesenl Dutchpl Polishpt Portuguese
ru Russianzh Chineseen English

A region is read as its base language, so pt-BR gets Portuguese and de-AT German. A book in a language not on that list still builds - the interface stays English, and the build says so once on stderr rather than filling the page with untranslated key names.

book.language does one other thing: it picks the stop list the keyword pass uses, which is nine of these eleven languages. See keywords below.

uiLanguage is for when the two should differ: a book written in German whose readers expect English furniture, or the reverse.

toml
[book]
language = "de"     # what the chapters are written in, and the html lang attribute

[html]
uiLanguage = "en"   # but Search and On this page stay English

None of this touches the book's own text, and none of it reaches the PDF - the printed book carries the author's words and the two strings configured around them, authorsLabel and [pdf] footerLeft.

editUrl substitutes the chapter's source path for {path}:

toml
editUrl = "https://github.com/me/book/edit/main/docs/{path}"

logo pointing at an SVG gets inlined into the page, so it inherits the colour mode through currentColor and costs no extra request; any other format is linked as an img. The path is resolved against src, and ../ out of it is fine:

toml
logo = "../assets/wordmark.svg"

keywords gives each page a <meta name="keywords"> worked out from its own content. The scoring is structural rather than statistical - a term is worth the sum of its weighted appearances:

where it appearsweight
the page title8
a level-2 heading4
a deeper heading3
a code span3
body text1

So a word in the title outranks eight mentions in prose, and an identifier the page discusses outranks three - which is the right answer for a reference page whose subject is named twice and used everywhere. The top ten win.

There is no TF-IDF here, and that is deliberate twice over. Corpus-wide document frequencies are not available: chapters render in parallel and are written as they finish, so no worker knows about the others' text. And a documentation page already declares its subject in places prose statistics cannot see - its title, its headings, the identifiers it puts in code spans - so weighting those beats counting words, in one pass over one page.

Three details make the difference on technical prose:

  • Qualified names survive whole. A term may contain ., -, and _, so strings.join, jennifer-tiny, and snake_case stay single terms instead of being shredded into fragments. On the Jennifer library reference this is most of the value: strings.substring, task.waitany, task.discard.
  • Plurals fold into singulars when the page uses both, so module and modules do not take two of the ten slots. Only an exact trailing s counts, and only when the singular is a term the page actually used.
  • Boolean literals are stopped. Code spans score 3, so a configuration page full of enabled = false would otherwise rank false above the settings it is describing.
  • Every alphabet is read. A term is a run of letters and digits in any script that writes spaces between its words, so a German or French word carrying an umlaut or an accent is one term rather than the two fragments an ASCII-only pattern leaves behind.

The stop list follows the book's language. and, the, and of carry no subject on their own, and neither do und, die, and das - but a list that only knows English leaves a German book tagging every chapter with its grammar. book.language picks the second list:

de Germanes Spanishfr Frenchit Italian
nl Dutchpl Polishpt Portugueseru Russian

The English list is applied to every book on top of it, because technical writing quotes identifiers whatever language it is written in - and so are the boolean literals. A region tag counts as its language, so de-AT is German. A language with no list of its own is not an error and is not reported: the book keeps the English one, and keywordStopwords is where its author fills the gap.

Japanese and Chinese have an interface translation but no stop list, and get no keywords from their prose either. Both write without spaces between words, so what a list would have to match is a clause rather than a word; separating one into words needs a segmenter Grimoire does not have. Such a book still gets keywords - from its title, its headings, and the identifiers in its code spans - rather than a meta tag full of sentence fragments.

keywordStopwords adds to whichever lists are in force. A list can only know what is furniture in a language; a book knows what is furniture in its own subject. Those terms are exactly the ones that describe every chapter equally, and so describe none. A language manual is the clearest case:

toml
keywordStopwords = ["def", "init", "return", "int"]

Without it, Jennifer's concurrency chapter offers task, spawn, task.discard, task.wait, return, task.waitall, def, init, int, task.waitany; with it, the three keywords give way to concurrency, error, catch, try. Entries are lowercased and trimmed, so case in the config does not matter.

Only the first 600 characters of each section's body are read. Body text is the weakest signal here - one point against a title's eight - and a section states its subject in its opening sentences or not at all, so reading further buys ranking that does not change and costs a pass over the whole book. With that cap the whole pass is close to free - on a 155-chapter book, turning it off changes the build by less than the difference between two runs of the same build. It rides along with a render that has already parsed the chapter. keywords = false turns it off, but there is little to save.

Ties break alphabetically, so the tag is byte-identical no matter what --jobs was.

One caveat worth stating: Google has ignored this tag since 2009. It is still read by some other engines, by site-internal search, and by tooling that inventories a documentation set - which is what it is here. If none of those apply to your book, keywords = false and the tag is not emitted at all.

footer is emitted verbatim, so it can carry HTML:

toml
footer = "&copy; 2026 Example Ltd &middot; <a href=\"/imprint\">Imprint</a>"

That is deliberate - the footer is your own configuration, not untrusted input. Everything else that renders text, including the book and chapter titles, goes through the Markdown renderer and is escaped.

[highlight] and [highlightjs]

Highlighting comes in two layers, and they are two tables because they have very different consequences.

[highlight] is the switch; [highlightjs] describes the CDN layer, so every key that only means something to highlight.js lives there.

KeyTypeDefaultMeaning
highlight.enabledboolfalsethe master switch. On its own: the built-in Jennifer highlighter
highlightjs.enabledboolfalseadditionally load highlight.js from a CDN
highlightjs.cdnstringcdnjs 11.11.1the CDN base URL, with no trailing slash
highlightjs.stylestring"github"the highlight.js stylesheet used in light mode
highlightjs.styleDarkstring"github-dark"the stylesheet used in dark mode
highlightjs.languageslist of string["bash", "go", "json", "yaml", "xml", "ini", "nginx"]extra highlight.js language packs to load

[highlight] alone highlights Jennifer code while the site is built. The spans are in the HTML that gets written, so there is no CDN, no JavaScript, and nothing to load; it works with scripting off and over file://. This is the layer to enable for a book that must make no third-party requests.

[highlightjs] on top pulls highlight.js from cdn and highlights the languages the built-in highlighter does not know, loading the languages packs and swapping style and styleDark with the mode selector. Blocks Grimoire already highlighted are marked so highlight.js leaves them alone.

With highlightjs.enabled = false, the other three keys do nothing - there is no stylesheet to choose and no grammar to fetch - which is exactly why they sit in this table rather than beside the master switch.

highlight.enabled is the master switch:

highlighthighlightjsResult
offoffno highlighting (the default)
onoffJennifer, at build time; nothing fetched
ononJennifer at build time, everything else from the CDN
offonno highlighting, and the build says so

The last row is a contradiction rather than an intent, and it resolves to off: a book that says "no highlighting" should not start making third-party requests because a second table was left enabled. The build reports the combination on stderr instead of silently picking one of the two readings.

The default CDN URL pins a version rather than tracking latest. A documentation build should render the same today and in a year, and a silent major-version bump on a CDN is exactly the kind of change that breaks a language grammar.

KeyTypeDefaultMeaning
enabledbooltruebuild the search index and ship the search UI
bodyCharsint1200body text kept per indexed section; floored at 120

Indexing is per section, not per page, so a hit lands on the paragraph rather than at the top of a long chapter. bodyChars trades index size against how deep into a section a match can still be found. --no-search turns it off for a single build without touching the file.

[agents]

KeyTypeDefaultMeaning
enabledbooltruewrite the two files a program reads rather than a reader

Two files, written at build time and served as static files, make a published book readable by a program:

llms.txtat the site root, following the llms.txt convention: the title, the description as a summary, and every chapter as a link, grouped by the parts of SUMMARY.md
assets/search-index.jsonthe search index as JSON - the book's own metadata, then one named object per indexed section: path, title, heading, anchor, body

llms.txt is the entry point and links to the index, so no path has to be guessed. Paths in both are relative to the site root: a book does not know where it is published, and a fetcher resolves them against the URL it read the file from.

The index carries the same records as the browser search in a different shape. assets/search-index.js assigns a global and packs each record as a positional array, which suits the code shipped beside it; the JSON names its fields for readers that have never seen Grimoire. [search] enabled = false does not suppress it - the records are collected for either consumer.

There is no daemon, no protocol and no capability here. A book on a static host is machine-readable the moment it is deployed. On a checkout the files are worth little, since the Markdown is right there and grep beats any index; the audience is the published book, where the alternative is fetching every page to re-derive what the build already knew.

Drafts are left out, and so is any chapter whose source is missing. A link in llms.txt is meant to be followed, so a dead one is worse than a short list.

[pdf]

KeyTypeDefaultMeaning
enabledboolfalsegrimoire build also renders the PDF
outputstring"book.pdf"the PDF path, relative to build.out
paperstring"a4"a4 or letter
bookmarkLevelint3bookmark headings down to this level; 0 disables the outline
pageNumbersboolfalseprint page/total at the outside edge of every page footer
footerLeftstring""a template for the other side of that footer; "" leaves it empty
titlePagebooltrueopen the book with a title page; off starts it at the first chapter
imageDpiint96the resolution a drawn picture's pixels are read at
excludelist of string[]chapters to leave out of the PDF; the site still carries them

enabled is what --pdf sets, and grimoire pdf renders the PDF regardless of it. Expect the PDF to dominate the build - see Performance.

pageNumbers needs the total page count, which does not exist until the whole book has been laid out, so the build renders the document and then stamps the footer on every page. The number is drawn inside the bottom margin in the theme's muted colour - a footer is for placing yourself, not for reading.

footerLeft fills the other side, and carries two slots read from the book's own git checkout:

{version}the tag the build sits on, with any leading v removed
{commit}the short commit id - only when there is no tag

Each is read from the environment first - GRIMOIRE_VERSION and GRIMOIRE_COMMIT - and only then from git. That order matters for containers: the official Jennifer image carries no git, so a build inside it would print the template with both slots empty, while the CI job outside it knows the answer and can pass it in.

Exactly one of the two is ever filled, which is what lets a single template cover a release and a working build. This manual uses:

toml
footerLeft = "Grimoire {version} Manual {commit}"

giving Grimoire 0.1.0 Manual on a tagged commit and Grimoire Manual 0e173c1 on anything else. The gap the empty slot leaves is closed before the line is drawn. Where neither the environment nor git can answer - outside a checkout, or on jennifer-tiny, which ships no os/exec - both slots come back empty and the rest of the template still prints.

titlePage off drops the cover entirely and starts the PDF at the first chapter, for a book that would rather supply its own front matter as a prefix chapter.

imageDpi decides how big a picture is on the page. A PNG or a JPEG on a line of its own is drawn into the printable book, and its pixels are read at this resolution: 96 puts a 480-pixel-wide screenshot in a 360-point box, 192 puts the same screenshot in half that. It only has the last word on a picture narrower than the text column, because a wider one is scaled down to the column whatever its dpi - so raising it is how a small diagram stops filling the measure, and lowering it is how one stops being a postage stamp. A value below 1 falls back to

  1. Everything else keeps its alt text: an image inline in a sentence, a format

the PDF cannot carry, a file that is not in the source tree, and a picture on another host, which is not fetched.

exclude names chapters that belong on the site but not on paper. Each entry is a source path relative to src; one ending in / excludes everything beneath it:

toml
[pdf]
exclude = [
    "api/",              # a whole generated section
    "technical/coverage.md",
]

The case this exists for is a section worth publishing and not worth printing - a generated API reference that runs to hundreds of pages of tables, a coverage report - where the alternative is building the book twice and rendering the PDF from the smaller one.

Two details worth knowing. Matching is by path, not by glob, because the thing being named is a chapter or a branch of the outline and both are already paths. And a part whose chapters are all excluded is dropped with them: the heading is held back until a chapter survives to sit under it, so the printed book never carries a part title with nothing beneath it. --verbose reports each chapter it skips.

A worked example

The grimoire.toml in this repository builds these pages - Grimoire renders its own documentation:

toml
[book]
title = "Grimoire"
description = "Build a documentation website, and a printable PDF, from a directory of Markdown files."
authors = ["mplx <jennifer@mplx.dev>"]
authorsLabel = "Written by"
language = "en"
src = "docs"

[build]
out = "site"

[html]
theme = "grimoire"
mode = "auto"
footer = 'Rendered with <a href="https://grimoire.jennifer-lang.dev/">Grimoire</a>, by itself.'
repoUrl = "https://github.com/jennifer-language/grimoire"
repoLabel = "Source"
editUrl = "https://github.com/jennifer-language/grimoire/edit/main/docs/{path}"
favicon = "favicon.ico"

[highlight]
enabled = true

[pdf]
enabled = true
output = "grimoire.pdf"
paper = "a4"
pageNumbers = true
footerLeft = "Grimoire {version} Manual {commit}"
titlePage = true

Note the single-quoted footer: TOML's literal strings take no escapes, which makes an HTML attribute far easier to write than the \" of a basic string.