mdbook-swirly
An mdBook preprocessor that renders Swirly diagrams to SVG while your book builds.
Write a diagram in a fenced block tagged swirly:
```swirly
@ t | 0 | 1 | 2 | 3 | 4 | 5
= c | 'a' | | 'b' | | 'c' |
to = 5
> s1 | | 'b' | | 'c' | |
```
and the page gets this in its place:
No client-side JavaScript, no image files to keep in step with the prose, and nothing fetched when the page loads. The diagram above is an SVG that was rendered when this page was built — and it follows your theme, so try the paint roller in the toolbar.
What Swirly draws
Swirly has two diagram species:
- Marble diagrams — the familiar timeline of events flowing through an operator, from the RxJS tradition.
- Grid diagrams — rows sharing one discrete, labelled transaction axis, mixing event streams with held-value cells. These are the diagrams the Functional Reactive Programming book uses, and the reason this fork exists.
Both are written as plain text you can diff, review and edit in place.
Where to start
This documentation follows Diátaxis, which sorts documentation by what you are trying to do:
| Tutorial | Never used it? Start here and build a book with a diagram in it. |
| How-to guides | You know what you want; these are the recipes. |
| Reference | Every syntax form, configuration key and command. |
| Explanation | Why it works the way it does. |
| Examples | Every example in the Swirly repository, rendered. |
Install
cargo install mdbook-swirly
cd my-book
mdbook-swirly install
The tutorial walks through that from an empty directory.
Your first diagram
This tutorial takes you from an empty directory to a book with a working diagram in it. It should take about ten minutes, and you will end up with something you can keep building on.
You need Rust installed. Everything else, we install as we go.
By the end you will have:
- a book that renders
swirlycode blocks to SVG, - a stream diagram and a cell diagram you wrote yourself,
- and diagrams that follow the reader’s theme.
We are going to build things up one piece at a time, and some steps will look like more typing than they need to be. That is on purpose — the point is to see each part before it gets hidden behind a shortcut.
Start with Setting up.
Setting up
We need two programs: mdBook itself, and this preprocessor.
cargo install mdbook
cargo install mdbook-swirly
Now make a book to put diagrams in:
mdbook init frp-notes --title "FRP notes"
cd frp-notes
Answer n when it offers to create a .gitignore, and y to the title it
suggests. You should have a directory holding book.toml and a src/ with two
markdown files in it.
Check it works before we change anything:
mdbook serve --open
That builds the book and opens it in a browser, and it keeps running — it will rebuild every time you save a file. Leave it going; we will lean on that for the rest of the tutorial.
Wiring in the preprocessor
In a second terminal, from the same directory:
mdbook-swirly install
It prints what it did:
Wrote ./assets/swirly.css
Updated ./book.toml (previous copy at ./book.toml.bak)
Two things happened. book.toml gained the entries that make mdBook run us:
[preprocessor.swirly]
command = "mdbook-swirly"
after = ["links"]
[output.html]
additional-css = ["./assets/swirly.css"]
And a stylesheet appeared in assets/. That stylesheet is not decoration — it
is what lets a diagram follow the reader’s theme. We come back to it in
Themes.
The after = ["links"] is there so that mdBook expands any {{#include}}
before we go looking for diagrams. It matters later, when you want to keep a
diagram in its own file.
Checking it took
Open src/chapter_1.md and replace its contents with:
# Chapter 1
```swirly
@ t | 0 | 1 | 2
> s | | |
```
Save. The browser should reload and show a line with three dashed dividers crossing it:
If you see that, everything is connected. If you still see the source text in a
grey code box, the preprocessor is not running — check that
mdbook-swirly --version works and that book.toml has the
[preprocessor.swirly] section.
That diagram is an SVG in the page. View source and you will find it inline: no image file was written, and nothing is fetched when the page loads.
Next: what that diagram actually says.
A stream
The diagram you just made has two lines in it, and each one does a different job. Here it is again with the pieces named:
@ t | 0 | 1 | 2 <- the axis: what the columns are
(blank line separates blocks)
> s | | | | <- a stream row
Every block is separated by a blank line, the way paragraphs are. That is how Swirly knows where one row ends and the next begins.
The axis
@ t | 0 | 1 | 2
@ declares the time axis. The first cell, t, is the label that appears in
the gutter on the left. Everything after it is a column, and each column is one
transaction — a single instant in which the whole system updates at once.
This is the part that makes a grid diagram different from a marble diagram. Time is not a continuous line each row measures for itself; it is a set of labelled columns that every row shares. Read a column downwards and you see what happened in that instant.
A stream row
> s | | |
> declares a stream row, s is its gutter label, and then there is one slot
per column. All three slots are empty, so the stream never fires — the line
just runs through:
Count the pipes. One | is one slot, and the axis line above has exactly the
same number — three pipes on both. An empty last slot is a bare trailing |,
not an extra one. Get the count wrong and the build stops with a message saying
so, which you will see for yourself in
Diagnose a failing diagram.
Making it fire
Put values in the slots:
> s | 'a' | | 'b'
The stream fires 'a' in transaction 0, nothing in 1, and 'b' in 2. Notice
that the value sits on the line rather than in a bubble on it, and that the
line runs through it. An empty slot is not a gap in the line; it just means
nothing happened.
Notice too that the quotes are yours. A slot is drawn exactly as you typed it,
so 'a' is a string, 42 is a number, and return 'a' is an expression. Try
replacing 'a' with something longer and watch the column widen to fit.
Two streams
Add a second row. Remember the blank line between blocks:
@ t | 0 | 1 | 2
> s1 | 'a' | | 'b'
> s2 | | 'x' | 'y'
Both rows hang off the same axis, so transaction 2 is a single column and you
can see at a glance that s1 and s2 both fire there. That alignment is the
whole point of the grid.
The extra spaces are for you, not the parser — slots are trimmed, so line them up however reads best in the source.
Next: a cell, which remembers.
A cell
A stream fires and is gone. A cell holds a value — it always has one, and it keeps it until something replaces it. That difference is most of what FRP diagrams are about, and Swirly draws it as a box.
Replace your chapter’s diagram with this:
@ t | 0 | 1 | 2 | 3 | 4 | 5
= c | 'a' | | 'b' | | 'c' |
= declares a cell row. The slots work exactly as they did for a stream —
one per column — but they mean something different. A value changes what the
cell holds, and a blank slot means keep holding what you had.
So the box is divided in three, and the dividers fall at 0, 2 and 4: exactly where you wrote a value. You did not draw those dividers, and you could not have put them in the wrong place. They are derived from the slots.
Notice the box opens before column 0. The cell already held 'a' when the
diagram starts, because a cell always has a value — there is no moment where it
has none.
Where the value comes from
A cell usually gets its values from a stream. Put both rows in and you can see the relationship:
@ t | 0 | 1 | 2 | 3 | 4 | 5
= c | 'a' | | 'b' | | 'c' |
> s | | 'b' | | 'c' | |
Read a column at a time. s fires 'b' in transaction 1, and c changes to
'b' in transaction 2 — one column later. Same for 'c' at 3 and 4.
That one-column lag is not a drawing convention; it is the thing being drawn. A cell updated by a stream takes its new value in the next transaction, so that everything reading the cell during transaction 1 agrees on what it holds. Lining the rows up on a shared axis is what makes that visible.
Stopping early
A cell does not have to run to the end of the diagram. Add a to line directly
beneath the row — no blank line, because it belongs to the same block:
= c | 'a' | | 'b' | | 'c' |
to = 5
The box now closes at transaction 5 and the line carries on to the same
arrowhead every other row reaches. from does the same at the other end, for a
cell that does not exist until partway through.
What you have
You can now draw the two things FRP is made of and the relationship between them. That is most of the notation. The syntax reference has the rest — annotation rows, references between rows, nested transactions — and it is short.
One thing left: making diagrams follow the reader’s theme.
Themes
Find the paint roller in the toolbar at the top of this page and switch to Coal or Ayu. The diagram below changes with it:
Your own book does the same. Switch themes in the browser tab you left running and the diagram you just drew will follow.
This is worth a moment, because it should not be possible. The SVG was rendered once, when the book was built, long before anyone chose a theme. It cannot know what colours the page is using.
How it does it
The trick is to not name any colours. The default theme, adaptive, draws
every stroke and every glyph in currentColor — a CSS keyword meaning
“whatever the surrounding text colour is” — and paints no background at all.
The stylesheet mdbook-swirly install dropped in your assets/ directory is
what supplies that colour, and it does so from mdBook’s own variables:
svg.swirly { color: var(--fg); }
svg.swirly rect { fill: var(--bg); }
Those two variables are how mdBook itself paints every page, so the diagram is
now using the same palette as the prose around it. Custom themes work too, as
long as they set --fg and --bg.
The second rule is why cell boxes stay opaque: without it the dashed grid would show straight through them.
Picking a different one
If you would rather have a fixed look, set it in book.toml:
[preprocessor.swirly]
theme = "sodium"
sodium is black-on-white line art, matching the printed figures in the
Functional Reactive Programming book:
It stays black on white whatever the reader picks, which is right for print and
wrong for a reader using Coal — try switching now and see. That is the trade:
adaptive follows the page, the others are fixed.
You can also override a single diagram without changing the book default, which is what produced the one above:
```swirly theme=sodium
@ t | 0 | 1
```
The full list is in Themes and styles.
Done
You have a book that renders diagrams, you can draw streams and cells, and you know why the colours behave the way they do.
From here:
- The how-to guides cover adding this to a book you already have, keeping diagrams in separate files, and building in CI.
- The syntax reference has the forms this tutorial skipped.
- The examples are every diagram in the Swirly repository, which is the fastest way to see what the notation can do.
How-to guides
Recipes for specific jobs, assuming you already know roughly what you want.
If you are new to mdbook-swirly, the tutorial is a
better starting point.
- Add diagrams to an existing book
- Choose a theme
- Restyle diagrams
- Diagnose a failing diagram
- Build a book in CI
Add diagrams to an existing book
Install into the book
From the book’s root — the directory holding book.toml:
mdbook-swirly install
Or name the directory:
mdbook-swirly install path/to/book
This writes assets/swirly.css and adds two sections to book.toml. It is
additive and safe to re-run: comments, key order and any existing
additional-css entries survive, an existing stylesheet is left alone unless
you pass --force, and the previous book.toml is kept as book.toml.bak.
To see the changes without making them:
mdbook-swirly install --print
If your book disables the default preprocessors
A book with
[build]
use-default-preprocessors = false
must list every preprocessor it wants, including links — the one that handles
{{#include}}. install adds after = ["links"] to our section, which is a
declaration of ordering, not a dependency: if links is not enabled, ours
simply runs whenever it likes. But {{#include}} will not be expanded at all,
so the technique below will not work.
Including a diagram from a file
Keeping a diagram in its own file means you can render it with the CLI, keep it
under test, or share it between books. Point {{#include}} at it from inside
a swirly fence:
```swirly
{{#include ../diagrams/hold.txt}}
```
Paths are relative to the markdown file doing the including, and they may reach
outside src/.
The ordering is what makes this work. mdBook runs links first, which replaces
the {{#include}} with the file’s contents; by the time we look at the
chapter, the fence contains a diagram specification. That is exactly what
after = ["links"] buys you, and it is why install sets it.
You can show the same file as source and as a picture by including it twice:
```swirly
{{#include ../diagrams/hold.txt}}
```
```text
{{#include ../diagrams/hold.txt}}
```
Every page under Examples is built this way, from files in the Swirly repository.
Migrating from images
If the book currently has diagrams as checked-in PNGs or SVGs, you can replace
them one at a time — there is no flag day. A swirly block and an
 can sit in the same chapter indefinitely.
What you get for converting: the diagram is diffable, it follows the reader’s theme, and it cannot fall out of step with the prose without the build noticing.
Choose a theme
Set the book’s default
[preprocessor.swirly]
theme = "adaptive"
| Theme | Looks like | Use it when |
|---|---|---|
adaptive | follows the page | the default; the reader can switch mdBook themes |
sodium | black on white line art | you want the printed-book look, fixed |
light | Swirly’s light palette, coloured marbles | marble diagrams where colour distinguishes values |
dark | Swirly’s dark palette | a book that is only ever dark |
An unknown name stops the build and lists the valid ones, so a typo cannot quietly give you the default.
Override one diagram
Put the option in the fence’s info string:
```swirly theme=sodium
@ t | 0 | 1
```
This wins over the book default for that block only. It is useful when most of
a book is adaptive but one figure is meant to match a printed original.
Which to pick
Use adaptive unless you have a reason not to. It is the only one that stays
legible when a reader switches to Coal or Ayu, and it is the only one that
follows a custom theme.
Its one limitation is that it is monochrome. It renders every stroke and glyph
in the page’s text colour, so it cannot distinguish values by colour the way
light does with marble diagrams. For grid diagrams that costs you nothing —
they are line art either way. For marble diagrams with many distinct values,
light may read better, at the price of looking wrong on dark themes.
You can have both: set theme = "adaptive" as the book default and tag the few
marble diagrams that need colour with theme=light.
Making adaptive match a custom mdBook theme
adaptive reads two CSS variables, --fg and --bg, which every built-in
mdBook theme defines. If you ship a custom theme that defines them too, the
diagrams follow it with no further work. If it uses different variable names,
override the rules in assets/swirly.css — see
Restyle diagrams.
Restyle diagrams
There are two levels to reach for, and they solve different problems.
Level 1: CSS, for anything about colour
assets/swirly.css is yours to edit — install will not overwrite it unless
you pass --force. Every diagram carries class="swirly" on its <svg>, so
you have a handle on all of them at once.
The stylesheet as installed is only a few rules:
svg.swirly {
display: block;
margin: 1.25em auto;
max-width: 100%;
height: auto;
color: var(--fg);
}
svg.swirly rect {
fill: var(--bg);
}
CSS rules beat the presentation attributes on the elements, so you can override anything the renderer emitted. To tint diagrams away from the body text colour:
svg.swirly { color: var(--links); }
To make them full-bleed rather than centred:
svg.swirly { margin-inline: 0; max-width: none; }
To leave cell boxes transparent, so the transaction grid shows through:
svg.swirly rect { fill: none; }
This level only works with the adaptive theme, which emits currentColor
rather than literal colours. The fixed themes bake their palette into the SVG,
and while you can still override it with CSS, you are then fighting the theme
rather than using it.
Level 2: style keys, for anything about geometry
Sizes, spacings, stroke widths, fonts and column sizing are decided when the
SVG is generated, so CSS cannot reach them. Those come from Swirly’s style
keys, set in a [styles] block inside the diagram itself:
That block applies to the diagram it appears in. The full list of keys is in Themes and styles.
Common ones:
| Key | Does |
|---|---|
axis_column_sizing | uniform (default), content or fixed |
axis_column_width | column width when sizing is fixed |
axis_column_min_width | floor for measured columns |
grid_row_height | height of a stream row |
grid_cell_height | height of a cell box |
row_label_width | minimum gutter width |
Which level
If you are changing a colour, use CSS — it survives a theme switch and applies to the whole book at once. If you are changing a size, use style keys. If you find yourself setting the same style keys on every diagram, that is a sign the book wants its own theme, which is a Swirly-side change rather than one you can make from here.
Diagnose a failing diagram
The build stopped
A diagram that cannot be rendered stops the build by default, naming the chapter and the line the fence starts on:
mdbook-swirly: a swirly diagram failed to render (set on-error = "warn" to continue): appendix/semantics.md:42: Grid row `s1` has 5 slot(s) but the axis has 6 column(s); they must correspond one to one.
This is deliberate. The alternative — dropping the diagram and carrying on — produces a book that builds green with a figure silently missing, which is a worse day than a failed build.
To keep going anyway, leaving the block as source and warning on stderr:
[preprocessor.swirly]
on-error = "warn"
That is useful while converting a pile of diagrams at once, when you want to see all the failures rather than the first.
Common causes
Slot count. The most frequent by far. Every row must have exactly one slot
per axis column. Count the pipes — a row carries the same number as the axis
above it. With three columns you want > s | a | b | c, and if the last slot is
empty, > s | a | b |, which is still three pipes.
from or to naming a column that does not exist. These take a column
label, not an index. If your axis is @ t | zero | one, then to = 1 is an
error and to = one is what you meant.
A marble row in a grid diagram. A block containing an @ axis puts the
whole diagram in grid mode, where --a--b--| has no meaning and is rejected
rather than half-parsed.
A theme name typo. Caught before any rendering starts, with the valid names listed.
Rendering one diagram on its own
The fastest way to iterate is to take mdBook out of the loop:
mdbook-swirly render diagram.txt > /tmp/out.svg
It reads stdin if you give it no file, so you can paste a block straight in:
mdbook-swirly render <<'EOF'
@ t | 0 | 1 | 2
> s | 'a' | | 'b'
EOF
Add --theme to check how it looks under a different palette.
Getting more out of an error
Errors from the renderer carry a JavaScript stack, hidden by default because the bundle is minified and the trace is one uninformative offset. If a message looks like a bug in the renderer rather than a mistake in your diagram, turn it on:
MDBOOK_SWIRLY_DEBUG=1 mdbook build
It works for render too, which is usually the quicker way to look at one
diagram.
The diagram did not render, but nothing failed
If a swirly block comes out as a grey code box, the preprocessor never ran.
Check, in order:
book.tomlhas a[preprocessor.swirly]section.- The
commandin it resolves — try running it by hand. - The command answers the handshake:
mdbook-swirly supports htmlmust exit 0. mdBook asks this before every build, and if the answer is anything but a clean exit 0 it skips the preprocessor without saying so. A wrapper script that prints a banner, or acargo runthat emits a warning, will do this to you.
That last one is the failure mode worth remembering, because the symptom is silence.
The diagram rendered but looks wrong on a dark theme
The stylesheet is missing. Check that book.toml has
[output.html]
additional-css = ["./assets/swirly.css"]
and that the file exists. Without it, adaptive diagrams fall back to black
ink on a white box. Re-run mdbook-swirly install to restore it.
Build a book in CI
mdbook-swirly is a single self-contained binary with no runtime
dependencies, so CI needs the binary and nothing else. No Node, no browser, no
network access at build time.
GitHub Actions
name: Book
on:
push:
branches: [main]
pull_request:
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install mdBook and the preprocessor
run: |
cargo install mdbook --locked
cargo install mdbook-swirly --locked
- run: mdbook build
cargo install compiles from source, which is slow on a cold runner. Two ways
to avoid that.
Prebuilt binaries. Every release ships binaries for Linux, macOS and Windows:
- name: Install mdbook-swirly
run: |
VERSION=0.1.0
curl -sSL "https://github.com/RadicalZephyr/mdbook-swirly/releases/download/v${VERSION}/mdbook-swirly-v${VERSION}-x86_64-unknown-linux-gnu.tar.gz" \
| tar xz --strip-components=1 -C /usr/local/bin --wildcards '*/mdbook-swirly'
Or cache the cargo install:
- uses: Swatinem/rust-cache@v2
- uses: taiki-e/install-action@v2
with:
tool: mdbook,mdbook-swirly
Failing the build on a broken diagram
This is the default and you should keep it. A broken diagram exits non-zero with the chapter and line in the message, so CI catches a diagram that stopped parsing the same way it catches a broken link.
If you have set on-error = "warn" for local work, make sure CI does not
inherit it.
Diagrams in a submodule
If your diagram files live in another repository pulled in as a submodule — which is how the examples in this book work — CI needs to check it out:
- uses: actions/checkout@v4
with:
submodules: true
Without that, {{#include}} finds an empty directory, and mdBook reports a
missing file rather than a missing submodule, which is a confusing five minutes.
Publishing to GitHub Pages
The workflow this book is built with is in
.github/workflows/pages.yml
and is a working example of all of the above: submodule checkout, a cached
build of the preprocessor, and deployment to Pages.
Reference
Complete descriptions of what exists. For how to use these, see the how-to guides.
- Diagram syntax — every form a
swirlyblock can take - Configuration —
book.tomlkeys and per-block options - Command line — every subcommand and flag
- Themes and styles — the built-in themes and the style keys
Diagram syntax
A specification is a sequence of blocks separated by blank lines. A line
beginning with % is a comment and is discarded before parsing.
The first line of a block decides what it is. Blocks are matched in this order, and the first match wins:
| First line matches | Block is |
|---|---|
[styles] | diagram styles |
[styles.X] | message styles |
@ followed by space or end of line | a time axis |
>, = or ., a single-token label, then | | a grid row |
> | an operator |
| anything else | a marble stream |
Lines after the first in a block are configuration, written key = value.
Two modes
A diagram containing a time axis is in grid mode; one without it is in frame mode. The difference is what a horizontal position means: grid mode looks up a discrete labelled column shared by every row, frame mode scales a continuous frame number.
Marble rows are rejected in grid mode rather than half-parsed, so you cannot mix the two by accident.
Time axis
@ <label> | <column> | <column> | ...
The segment before the first | is the gutter label and may be empty. Each
segment after it is one column — one transaction.
One | declares one column, and the axis draws one dashed boundary per
column with none after the last, so the line above is three pipes, three
columns and three dashed lines. There is no special case for a trailing pipe:
the count of pipes on a line is the count of columns it declares, and a grid
row carries exactly the same number.
A final | with nothing after it therefore declares an unlabelled column,
which is how a diagram asks for a closing boundary:
A column label may be prefixed with one > per level of nesting, for diagrams
that split a transaction:
@ t | [0] | >[0,0] | >[0,1] | [1]
Nesting lightens the grid line that opens the column.
A diagram may declare at most one axis, and it need not come first.
Configuration: title overrides the gutter label.
Grid rows
All three kinds share a shape: a sigil, a single-token label, then one slot per column.
<sigil> <label> | <slot> | <slot> | ...
Slots are trimmed, so you may pad them to line up with the axis. A slot’s text
is drawn verbatim — the quotes in 'a' are yours, not the notation’s.
Every row must have exactly as many slots as the axis has columns, which means exactly as many pipes. A mismatch stops the build.
A row whose last slot is empty ends in a bare | and needs no extra one, so an
all-empty three-column row is > s | | |.
Stream rows — >
A line running the width of the axis, ending in an arrowhead. An empty slot means the stream did not fire in that transaction.
> s1 | 'a' | | 'b'
Cell rows — =
A box holding a value across an interval. A non-empty slot changes the held value; an empty one keeps it. Dividers are derived from that, not written.
= c | 'a' | | 'b'
Configuration:
| Key | Meaning |
|---|---|
from | column label whose opening boundary the box starts at. Default: before column 0 |
to | column label whose opening boundary the box closes at. Default: after the last column |
Both take a column label, not an index. The box overhangs the boundary that bounds it rather than sitting flush against it, so a cell reads as holding its value through that transaction.
Annotation rows — .
A label and values with no line of their own, for commenting on a transaction.
. a1 | | 'a' |
References
A slot whose text matches another row’s label is a reference to that row
rather than a literal — the switch case, where a cell holds another cell or a
stream.
Resolution happens after the whole diagram is parsed, so the row being named does not have to be declared first. A row naming itself stays a literal.
Marble streams
Frame mode uses RxJS marble-testing
syntax — - for a frame of
time, a letter or digit for a value, | for completion, # for an error, and
() to group events into one frame.
--a--b--|
Configuration:
| Key | Meaning |
|---|---|
title | the label drawn to the left of the line |
ghosts | comma-separated value names to draw faded |
X := value | draw marble X with this text instead of X |
Leading whitespace offsets the stream in time.
Named streams
A block of the form x = <marbles>, where x is one character, defines a
stream instead of drawing one. Using that character in a later marble line
nests the stream inside the event, which is how higher-order diagrams are
drawn:
x = --a--b--|
-x----|
Operators
A line beginning with > that is not a grid row — that is, one without a
pipe right after its first word — is an operator band:
> concatAll
Backticks inside the title embed a marble diagram in it:
> debounce(() => `--|`)
Configuration: X := value, as for marble streams.
Diagram styles
[styles]
axis_column_sizing = fixed
axis_column_width = 160
Applies to the diagram it appears in. See Themes and styles for the keys.
Message styles
[styles.a]
fill_color = red
Applies to one marble value — the single character after the dot — in frame mode.
Configuration
book.toml
[preprocessor.swirly]
command = "mdbook-swirly"
after = ["links"]
theme = "adaptive"
on-error = "fail"
[output.html]
additional-css = ["./assets/swirly.css"]
mdbook-swirly install writes all of this.
Preprocessor keys
| Key | Values | Default | Meaning |
|---|---|---|---|
theme | adaptive, sodium, light, dark | adaptive | palette for every diagram in the book |
on-error | fail, warn | fail | what to do when a diagram will not render |
on_error is accepted as a spelling of on-error.
An unrecognised theme name stops the build and lists the valid ones. An
unrecognised value for on-error does the same.
mdBook keys that matter
| Key | Why |
|---|---|
command | how mdBook invokes the preprocessor |
after = ["links"] | makes {{#include}} expand before we look for diagrams |
additional-css | loads the stylesheet the adaptive theme needs |
after is an ordering declaration, not a dependency: if the links
preprocessor is disabled, ours still runs, but {{#include}} will not have been
expanded.
Per-block options
Options go in the fence’s info string, after the word swirly:
```swirly theme=sodium
@ t | 0 | 1
```
| Option | Values | Meaning |
|---|---|---|
theme | a theme name | overrides the book default for this block |
An unrecognised option is an error rather than being ignored, so a typo does not silently do nothing.
The first word of the info string must be exactly swirly. A block tagged
swirlyish or rust is left alone.
Per-diagram styles
Geometry is decided when the SVG is generated, so it cannot be changed with
CSS. Use a [styles] block inside the diagram:
[styles]
axis_column_sizing = fixed
axis_column_width = 160
See Themes and styles for the full key list.
Precedence
For a given diagram, lowest to highest:
- the theme’s defaults,
- the book’s
themeinbook.toml, - the block’s
theme=in the info string, - a
[styles]block inside the diagram.
CSS applies over all of it for anything colour-related, because a CSS rule beats a presentation attribute on the element.
Command line
mdbook-swirly run as a preprocessor (mdBook does this)
mdbook-swirly install [DIR] add the preprocessor to a book
mdbook-swirly render [FILE] render one specification to stdout
mdbook-swirly supports <RENDERER> preprocessor protocol handshake
mdbook-swirly --help
mdbook-swirly --version
No arguments — preprocess
Reads the mdBook preprocessor payload ([context, book] as JSON) on stdin and
writes the modified book on stdout. mdBook invokes this; you would not normally
run it yourself, and it reports as much if you do.
Exits non-zero if a diagram fails to render, unless on-error = "warn".
supports <renderer>
Exits 0 for every renderer. mdBook asks this before each build.
Answering anything but a clean exit 0 makes mdBook skip the preprocessor without reporting anything, so this arm is deliberately incapable of failing — it returns before reading configuration, starting the engine, or touching stdin.
install [DIR]
Writes assets/swirly.css and adds the [preprocessor.swirly] and
additional-css entries to book.toml. DIR defaults to the current
directory and must contain a book.toml.
| Flag | Effect |
|---|---|
--force | replace assets/swirly.css if it already exists |
--print | write the resulting book.toml and stylesheet to stdout, change nothing |
--dry-run | same as --print |
Additive and idempotent. Comments, key order and existing additional-css
entries are preserved; the previous book.toml is kept as book.toml.bak. A
command you have already set is left alone, so pointing it at a local build
survives a re-run.
render [FILE]
Renders one specification to SVG on stdout. Reads stdin if FILE is omitted.
| Flag | Effect |
|---|---|
--theme <NAME> | adaptive (default), sodium, light or dark |
mdbook-swirly render diagram.txt > diagram.svg
mdbook-swirly render --theme sodium diagram.txt
echo '@ t | 0 | 1' | mdbook-swirly render
The SVG carries class="swirly", so dropping it into a page that has
swirly.css loaded gives the same theme-following behaviour as inside a book.
Without that stylesheet an adaptive rendering inherits whatever color is in
effect where you put it.
Environment
| Variable | Effect |
|---|---|
MDBOOK_SWIRLY_DEBUG | append the JavaScript stack to errors raised by the renderer |
Set to anything other than empty or 0. It is an environment variable rather
than a flag because mdBook owns the invocation, so this way one build can be
debugged without editing book.toml:
MDBOOK_SWIRLY_DEBUG=1 mdbook build
The stack is hidden by default because the bundled renderer is minified: the trace is a single offset into one very long line, which tells a reader nothing and makes an ordinary message — a slot count, an unknown theme — look like a crash. It is still the only view into the bundle when something there genuinely breaks.
Exit codes
| Code | Meaning |
|---|---|
| 0 | success |
| 1 | anything else — the message is on stderr, prefixed mdbook-swirly: |
Themes and styles
Themes
| Name | Colours | Background | Column sizing |
|---|---|---|---|
adaptive | currentColor throughout | none | uniform |
sodium | black, everything italic | white | content |
light | Swirly’s light palette | white | uniform |
dark | Swirly’s dark palette | black | uniform |
adaptive is the default. It is derived from sodium by replacing every
colour with currentColor and dropping the background, which is what lets one
rendering follow the reader’s mdBook theme — see
How adaptive theming works.
The stylesheet
mdbook-swirly install writes assets/swirly.css:
svg.swirly {
display: block;
margin: 1.25em auto;
max-width: 100%;
height: auto;
color: var(--fg);
}
svg.swirly rect {
fill: var(--bg);
}
@media print {
svg.swirly { color: #000; }
svg.swirly rect { fill: #fff; }
}
--fg and --bg are mdBook’s own variables, defined by every built-in theme.
The rect rule keeps cell boxes opaque so the dashed grid stops at their edges;
without it the grid shows through.
The file is yours to edit and is not overwritten unless you pass --force.
Style keys
Set in a [styles] block inside a diagram. Values are numbers, colours or
font values, depending on the key.
Naming is regular: a key is <group>_<property>. These are the groups a grid
diagram uses.
Axis and columns — axis_
| Key | |
|---|---|
axis_column_sizing | uniform (default), content or fixed |
axis_column_width | the width used when sizing is fixed |
axis_column_min_width | floor for a measured column |
axis_column_padding | added to measured content to give the column width |
axis_header_height | height of the label row |
axis_label_color, axis_label_font_{family,size,style,weight} | the column labels |
Sizing decides how a column’s width is found. uniform measures every
column’s contents and gives them all the widest; content gives each its own,
which is what varies column widths within one diagram; fixed ignores the
measurements.
Grid lines — grid_line_
grid_line_color, grid_line_stroke_width, grid_line_dash_width,
grid_line_bleed, grid_line_depth_stroke_width_step
bleed is how far the dashes extend past the first and last row.
depth_stroke_width_step thins the line for each level of column nesting.
Stream rows — grid_row_
grid_row_height, grid_row_lead, grid_row_tail, grid_row_value_color,
grid_row_value_font_{family,size,style,weight}
lead and tail are how far the line runs before the first column boundary
and past the last.
Cell rows — grid_cell_
grid_cell_height, grid_cell_overhang, grid_cell_fill_color,
grid_cell_stroke_color, grid_cell_stroke_width,
grid_cell_divider_stroke_width, grid_cell_value_padding,
grid_cell_value_color, grid_cell_value_font_{family,size,style,weight}
overhang is how far the box extends past the last column when to is unset.
value_padding insets a held value from the edge of its run.
Annotation rows — grid_annotation_
grid_annotation_height, grid_annotation_value_color,
grid_annotation_value_font_{family,size,style,weight}
Row labels — row_label_
row_label_width, row_label_gap, row_label_color,
row_label_font_{family,size,style,weight}
width is a minimum: the gutter grows to fit the widest label.
Frame-mode groups
Marble diagrams use arrow_, event_, operator_, stream_, range_,
barrier_, completion_ and error_, plus frame_width, stacking_height,
higher_order_angle and ghost_opacity.
Diagram-wide
background_color, canvas_padding, minimum_width, minimum_height
Setting background_color to an empty string suppresses the background
rectangle entirely, which is what adaptive does.
Getting the exhaustive list
There are 103 keys. They are the fields of DiagramStyles in the Swirly
sources, which is the only place guaranteed to be current:
git submodule update --init
awk '/^export type DiagramStyles = \{/,/^\}/' \
vendor/swirly/packages/swirly-types/src/styles.ts \
| grep -oE '^ [a-z_]+' | tr -d ' ' | sort
The awk range matters: the same file declares a smaller type per style group,
and a plain grep over the whole file returns those too.
Explanation
Background and design reasoning. None of this is needed to use the tool; it is here for when you want to know why it behaves as it does, or you are about to change it.
Why render at build time
There are three places a diagram can be turned into pixels, and the choice shapes everything else.
The three options
In the browser. Ship the specification to the reader along with a renderer, and draw the diagram after the page loads. This is how mdbook-mermaid works — and why a book using it loads a couple of megabytes of JavaScript before any diagram appears.
By hand, ahead of time. Run a tool, commit the SVG, reference it with
. No runtime cost, but the picture and the prose are now two
files that can disagree, and nothing notices when they do.
At build time. Turn the specification into an SVG while the book is being built, and inline it into the page.
What build time buys
The page is finished when it arrives. No JavaScript, no layout shift, no second request. A diagram is as cheap as the paragraph next to it.
The source is the artifact. There is no generated file to regenerate, forget to regenerate, or commit stale. The specification in the markdown is the diagram; they cannot drift because there is only one of them.
A broken diagram breaks the build. This is the one that matters most in
practice. If the notation changes, or a diagram is edited into something that
no longer parses, you find out from a failed build with a chapter and line
number — not from a reader, months later, looking at a blank space. It is the
same argument as compiling rather than discovering type errors at runtime, and
it is why on-error defaults to fail.
Diagrams are reviewable. A change to a diagram shows up in a diff as the lines that changed, in a notation a reviewer can read. A change to a committed SVG shows up as several thousand unreadable lines.
What it costs
The theme problem. A diagram rendered at build time cannot know what colours the reader will choose, and mdBook lets them choose at runtime. Solving that is the whole of adaptive theming, and it is the single most interesting constraint in this tool.
No interactivity. A build-time SVG cannot animate, respond to hover, or let the reader scrub a timeline. For marble diagrams that is no loss; if you wanted an interactive playground, you would want something else entirely.
A renderer in the build. Something has to run Swirly, which is JavaScript, inside a process that mdBook can invoke. That is the subject of Architecture, and it is the reason this tool is 1.7 MB rather than a shell script.
Why not just commit the SVGs
This is the closest competitor, and it deserves a straight answer: committing SVGs works, and for a handful of diagrams it is completely reasonable.
It stops being reasonable at the point where you have enough diagrams that nobody can remember which ones are current. A book with twenty figures and a notation still under development — which is exactly the situation this was built for — regenerates all of them every time the renderer improves. Doing that by hand is a chore that gets skipped, and a skipped chore is a book with some figures from an older renderer and no way to tell which.
Moving the render into the build makes that failure impossible rather than merely unlikely.
How adaptive theming works
A diagram is rendered once, when the book is built. The reader picks a theme later, in the browser, and can change it at any time. Those two facts look irreconcilable: the renderer cannot know what colours the page will be using.
Here is the diagram from the tutorial. Switch themes with the paint roller and watch it follow:
The move
Do not name any colours.
Every stroke and glyph is drawn in currentColor, a CSS keyword meaning
“whatever the surrounding text colour happens to be”. No background rectangle
is drawn at all. The result is a diagram with no opinion about its own palette
— it inherits one from wherever it lands.
The stylesheet then supplies that colour from mdBook’s own variables:
svg.swirly { color: var(--fg); }
svg.swirly rect { fill: var(--bg); }
--fg and --bg are what mdBook paints every page with, and each built-in
theme redefines them. So the diagram is not following our idea of what Coal
looks like; it is using the same two values the prose around it is using. A
custom theme that defines them works with no further effort.
Why it is a theme, not a feature
adaptive is not special-cased anywhere in the renderer. It is an ordinary
Swirly theme whose colour values happen to all be the string currentColor:
const ink = Object.fromEntries(
Object.keys(base)
.filter((key) => /_color$/.test(key) && key !== 'background_color')
.map((key) => [key, 'currentColor'])
)
Twenty-two colour keys, rewritten mechanically. The renderer writes them into
the SVG exactly as it would write #333, and the browser resolves them. No
renderer change was needed to make any of this work, which is a good sign that
currentColor is the right level to be solving the problem at.
The one thing that cannot be currentColor
A cell box has to be opaque, so the dashed transaction grid stops at its edges rather than showing through. Opaque means the page background — which is the one colour that is emphatically not the ink colour.
That is what the second CSS rule is for. The <rect> carries a literal #fff
as a presentation attribute, and the stylesheet overrides it with var(--bg).
A CSS rule beats a presentation attribute in the cascade, so the rule wins
whenever the stylesheet is loaded, and the literal is what you fall back to if
it is not.
You might reasonably ask why the attribute is not var(--bg) directly. It
could be: var() does resolve inside presentation attributes in current
browsers. But that support arrived late and unevenly, whereas var() in an
ordinary CSS rule has been universal for years. Putting the variable in the
stylesheet and a plain colour in the attribute means the diagram degrades to
legible rather than to invisible.
What it costs
adaptive is monochrome. It has exactly one ink colour, because
currentColor is exactly one colour.
For grid diagrams that is free — they were line art to begin with. For marble
diagrams it is a real loss: Swirly’s light theme gives each value its own
marble colour, derived from a hash of the value, and that is genuinely useful
when a diagram has a dozen distinct events. adaptive renders those as
outlines.
If a particular diagram needs colour, tag it:
```swirly theme=light
--a--b--c--|
```
and accept that it will look like a light-theme diagram sitting in a dark page. The book default and the per-block override exist precisely so you can make that trade one diagram at a time.
Architecture
Swirly is JavaScript. mdBook preprocessors are programs that speak JSON on stdin and stdout. Getting from one to the other is most of what this tool is.
mdBook ──[context, book] as JSON──> mdbook-swirly (Rust)
│
├── pulldown-cmark: find `swirly` fences
├── QuickJS: evaluate the Swirly bundle
│ └── swirlyRender(spec, theme) -> SVG
└── splice the SVG back into the markdown
│
<──────── modified book ────────┘
Rust outside, JavaScript inside
The split is: Rust owns the command line, the preprocessor protocol, the
markdown and book.toml; JavaScript owns turning a specification into an SVG.
That boundary is chosen so the JavaScript side is a pure, synchronous
string -> string function. It does no I/O, starts no timers, loads no
modules and reads no arguments. Everything stateful is on the Rust side.
This matters more than it sounds, because it decides what has to run the JavaScript.
Why QuickJS and not a JavaScript runtime
The obvious move is to embed a runtime — Node, or Deno as a library. Both were measured before this was built:
deno compile | Rust + QuickJS | |
|---|---|---|
| Binary | 81.3 MB | 1.7 MB |
| Startup and render | 88 ms | 46 ms |
A runtime’s value is its event loop, module system, Node compatibility, permissions and networking. A pure function needs none of that. Embedding Deno would have meant shipping eighty megabytes of infrastructure to call one function that does not use any of it — and, because the function is synchronous, would not have been faster either.
QuickJS is about a megabyte of C implementing ES2020, which is precisely the job. The bundle is evaluated once when the preprocessor starts and the engine is kept warm, so a book with twenty diagrams pays for the engine once.
The bundle
src/swirly-bundle.js is Swirly’s parser, renderer and themes compiled by
esbuild into one file with no imports and no Node built-ins, embedded into the
binary with include_str!. It needs exactly one thing from its host —
globalThis.self — which Rust supplies in a one-line shim before evaluating
it.
It is a generated file that is committed, which is a trade: users get
cargo install with no Node anywhere, at the cost of an artifact that can go
stale. Two things guard it. The Swirly submodule in vendor/ pins the exact
revision it was built from, and CI rebuilds it there and fails if the result
differs byte for byte.
Making that check possible required the bundle to be reproducible, which it initially was not: unminified esbuild output records each module’s path as a comment, so the bytes depended on where the Swirly checkout happened to sit. Minifying removes the paths — and takes the bundle from 286 KB to 111 KB.
Finding the diagrams
Fences are located with pulldown-cmark, the same CommonMark parser mdBook itself uses, rather than a regular expression. Tilde fences, indented fences inside list items, and longer fences containing shorter ones all then behave the way mdBook will treat them, because it is the same code making the decision.
Replacements are spliced back in from the end of the chapter forwards, so each byte range stays valid as earlier ones are rewritten.
Walking the book
The book payload is traversed as generic JSON, looking for objects with a
Chapter key, rather than deserialised into typed models. mdBook renamed the
root collection from sections to items in 0.5 and has added chapter fields
over time; walking structurally means both spellings work and unknown fields
are carried through untouched.
The handshake
mdBook runs mdbook-swirly supports <renderer> before each build. If that
exits non-zero it skips the preprocessor and says nothing — the book builds
successfully with every diagram missing.
Because the cost of getting this wrong is silent and the benefit of nuance is
nil, the supports arm is the first thing main matches and it returns before
reading configuration, starting the engine or touching stdin. There is nothing
in it that can fail.
Examples
Every example in the Swirly repository, rendered by this preprocessor.
These pages are not copies. The Swirly repository is a git submodule of this
one, and each example is pulled in with mdBook’s {{#include}} — so what you
see below is the current contents of those files, rendered by the version of
Swirly this crate embeds. If an example changes upstream, this page changes
with it.
- Grid diagrams — the transaction-aligned species
- Marble diagrams — the RxJS-style timelines
The mechanism is worth knowing about in its own right; see Add diagrams to an existing book.
Grid diagrams
The transaction-aligned species: rows sharing one discrete, labelled time axis, mixing event streams with held-value cells. See the syntax reference for the notation.
Each diagram below is rendered from the file named beneath it, pulled straight out of the Swirly submodule.
gridAxis
The apparatus on its own: a labelled transaction axis, a dashed boundary opening each column, and a stream that never fires.
examples/gridAxis.txt
% A transaction grid. Grid mode replaces the continuous marble timeline with a
% discrete, labelled axis that every row shares: `@` declares the columns, and
% one dashed boundary opens each of them.
%
% `>` declares a stream row. Its slots are empty here, so the line just runs
% through — Sodium streams never complete, so there is no `|` to write.
@ t | 0 | 1 | 2
> s | | |
gridStreams
Three streams sharing one axis. Where two of them fire in the same transaction, that is a single column, so you can see it at a glance.
examples/gridStreams.txt
% Stream rows carry one slot per transaction, so the source lines up with the
% diagram. A value is typeset on the line rather than inside a marble, and a
% blank slot means the stream did not fire in that transaction.
@ t | 0 | 1 | 2 | 3 | 4
> s1 | 0 | | 2 | |
> s2 | | 10 | 20 | 30 |
gridCell
A cell holds a value until something replaces it. The dividers are derived from the slots, falling wherever a slot carries a value.
examples/gridCell.txt
% A cell row holds a value across an interval. `=` declares one, and its box is
% divided wherever the held value changes: a non-empty slot opens a new run and
% a blank one extends the run before it, so the dividers are derived rather
% than written out.
@ t | 0 | 1 | 2 | 3 | 4 | 5
= c | 'a' | | 'b' | | 'c' |
gridHold
hold turns a stream into a cell, one transaction behind, and to closes the box before the axis ends.
examples/gridHold.txt
% `hold` turns a stream into a cell, one transaction behind: s1 fires 'b' in
% transaction 1 and c starts holding it in transaction 2.
%
% `to` closes the box early, at the boundary that opens the column it names.
% The line carries on to the same arrowhead every other row reaches.
@ t | 0 | 1 | 2 | 3 | 4 | 5
= c | 'a' | | 'b' | | 'c' |
to = 5
> s1 | | 'b' | | 'c' | |
gridSwitch
switch: a slot naming another row refers to that row rather than being a literal, so c3 holds first c1 and then c2.
examples/gridSwitch.txt
% `switch` — a cell whose value is another cell. A slot naming another row in
% the diagram resolves to a reference to that row rather than to a literal, so
% c3 holds c1 and then c2, and c4 is what the switched cell yields.
@ t | 0 | 1 | 2 | 3 | 4
= c1 | 'a' | 'b' | 'c' | 'd' | 'e'
= c2 | 'V' | 'W' | 'X' | 'Y' | 'Z'
= c3 | c1 | | c2 | |
= c4 | 'a' | 'b' | 'X' | 'Y' | 'Z'
gridAnnotations
Annotation rows address the same columns as everything else but draw no line, for commenting on a transaction.
examples/gridAnnotations.txt
% Annotation rows, declared with `.`, address the same columns as every other
% row but draw no line of their own — they comment on a transaction rather than
% carrying a stream or a cell.
@ t | 0 | 1 | 2 | 3 | 4 | 5
= c | 'a' | | 'b' | | |
to = 3
. a1 | | 'a' | | | |
. a2 | | | 'b' | | |
gridNested
Split transactions. A column label can name a nested transaction, and each level of nesting lightens the line that opens it.
examples/gridNested.txt
% Split transactions. A column label may name a nested transaction, and each
% leading `>` on it marks one more level of nesting, which lightens the grid
% line that opens that column.
@ t | [0] | >[0,0] | >[0,1] | [1] | >[1,0]
> s1 | ['a','b'] | | | ['c'] |
> s2 | | 'a' | 'b' | | 'c'
Marble diagrams
The RxJS-style timelines Swirly started as. These use marble-testing syntax with a few additions of Swirly’s own – named streams, operator bands, value substitutions – described in the syntax reference.
They are drawn here with the adaptive theme, so they are monochrome. Swirly’s
light theme gives every value its own marble colour, which reads better when
a diagram has many distinct events; see Choose a theme.
concatAll
Higher-order: each event on the outer stream is itself a stream, drawn skewed, and concatAll plays them one after another.
examples/concatAll.txt
% An example application of the concatAll operator.
% Showcases styles and higher-order observables.
% Based on RxJS's concatAll diagram:
% https://github.com/ReactiveX/rxjs/blob/fc3d4264395d88887cae1df2de1b931964f3e684/spec/operators/concatAll-spec.ts
[styles]
frame_width = 20
completion_height = 20
higher_order_angle = 30
arrow_fill_color = black
x = ----a------b------|
y = ---c-d---|
z = ---e--f-|
-x---y----z------|
> concatAll
-----a------b---------c-d------e--f-|
debounce
An operator whose title carries a marble diagram of its own, written in backticks.
examples/debounce.txt
% An example application of the debounce operator.
% Showcases marble diagrams inside operators.
% Based on RxJS's debounce diagram:
% https://github.com/ReactiveX/rxjs/blob/fc3d4264395d88887cae1df2de1b931964f3e684/spec/operators/debounce-spec.ts
-a--bc--d---|
> debounce(() => `--|`)
---a---c--d-|
exhaustAll
Events dropped while an inner stream is still running are drawn faded, via the ghosts configuration key.
examples/exhaustAll.txt
% An example application of the exhaustAll operator.
% Showcases ghost notifications.
x = --a---b---c--|
y = ---d--e---f---|
z = ---g--h---i---|
------x-------y------z--|
ghosts = y
> exhaustAll
--------a---b---c-------g--h---i---|
onErrorResumeNext
# marks an error. The title key labels each input line.
examples/onErrorResumeNext.txt
% An example application of the onErrorResumeNext operator.
% Showcases titles and error notifications.
% Based on RxJS's onErrorResumeNext diagram:
% https://github.com/ReactiveX/rxjs/blob/86dfe3c78dd37c6828a08b45364f030796879cc0/spec/operators/onErrorResumeNext-spec.ts
--a--b--#
title = source
--c--d--|
title = next
> onErrorResumeNext
--a--b----c--d--|
title = output
pluck
Values given longer names with :=, so a marble can display something wider than one character.
examples/pluck.txt
% An example application of the pluck operator.
% Showcases styles and notification values.
% Based on RxJS's pluck diagram:
% https://github.com/ReactiveX/rxjs/blob/fc3d4264395d88887cae1df2de1b931964f3e684/spec/operators/pluck-spec.ts
[styles]
event_radius = 30
operator_height = 60
--a--b--c--|
a := {v:1}
b := {v:2}
c := {v:3}
> pluck('v')
--x--y--z--|
x := 1
y := 2
z := 3
skipUntil
Two inputs and one output, with the second stream deciding when the first starts passing through.
examples/skipUntil.txt
% An example application of the skipUntil operator.
% Showcases notification-level styling.
% Based on RxJS's skipUntil diagram:
% https://github.com/ReactiveX/rxjs/blob/fc3d4264395d88887cae1df2de1b931964f3e684/spec/operators/skipUntil-spec.ts
[styles]
event_value_color = white
[styles.a]
fill_color = #FF0000
[styles.b]
fill_color = green
[styles.c]
fill_color = rgb(0, 0, 255)
[styles.d]
fill_color = yellow
value_color = black
[styles.e]
fill_color = magenta
[styles.x]
fill_color = rgb(63, 63, 63)
--a--b--c--d--e----|
---------x------|
> skipUntil
-----------d--e----|
zipAll
Named streams defined with x = ... and nested into the outer line, then combined pairwise.
examples/zipAll.txt
% An example application of the zipAll operator.
% Showcases higher-order observables and notification values.
% Based on RxJS's zipAll diagram:
% https://github.com/ReactiveX/rxjs/blob/fc3d4264395d88887cae1df2de1b931964f3e684/spec/operators/zipAll-spec.ts
x = -a-----b-|
y = --1-2-----
-x----y--------|
> zipAll
-----------------A----B-|
A := a1
B := b2