← Back to site Loading…

notabene

Guide

note this well

Generated on August 8, 2026

What is notabene?

What is notabene?

nota bene — the margin mark that means “note this well.”

notabene renders your repo’s Markdown/MDX as a navigable site with review comments right on the page, and ships the human↔agent review protocol that turns those comments into edits. The viewer is the support — the protocol is the product.

notabene demo: comment a passage, the agent applies the edit, you approve the real diff

The problem it solves

Fixing or writing docs with an AI agent means turning every change into prose: quote the passage, name the section — “can you fix the wording in section 3” — then hope the agent re-finds the exact spot in the source. Past a couple of changes it’s a wall of instructions in one chat input. The real instruction was always simpler: this passage — change it like so.

notabene makes that the interface. Select the exact text on the rendered page — or a whole diagram or image — and leave a comment right there. The anchored comment is the instruction: located, unambiguous, nothing to quote. The agent reads the comments, edits the source faithfully, marks each resolved, and journals what changed & why.

The loop, in 30 seconds

  1. npx notabene dev → open the site, select any text → leave a comment (or comment a whole page, diagram or image). Threads, resolve, hold, a global /comments view.
  2. Tell your agent: “address the doc comments.”
  3. The agent reads .notabene/, edits the docs faithfully, marks each comment resolved, and appends a journal entry (what / why / which comments).
  4. Read the trail at /journal — or validate each diff yourself in approve mode.

Everything is in your git

Comments and journal are plain JSON files under .notabene/ — no SaaS, no database, no account. They travel with your repo, diff in PRs, and are readable by any agent: the review protocol is file-I/O-first (no server, no port, no MCP required).

Where to go next

Looking for exhaustive tables instead? Head to the Reference space: the CLI, every config key, the frontmatter, the store contract and the safety model.

Install

Install

notabene is two installable pieces: the Claude Code plugin (turnkey setup + the review loop) and the renderer (an npm package + CLI). They install independently — the plugin does not need the npm package: it fetches the renderer on its own.

The Claude Code plugin — setup + review

In Claude Code:

/plugin marketplace add z29k/notabene
/plugin install notabene@z29k

That’s the whole install — no npm install required. The plugin fetches and runs a pinned version of the renderer itself via npx (the first run downloads it, ~30 s); nothing is scaffolded into your repo, and your repo doesn’t even need a package.json. The only prerequisite is Node (see requirements below).

Then just say “set up notabene” (fresh repo) or “address the doc comments” (already set up) — the right skill triggers on its own.

Prefer manual install? Copy packages/claude-plugin/skills/notabene/ into your project’s .claude/skills/. Using another agent entirely? You don’t need the plugin at all: notabene init writes the same protocol to <store>/protocol.md and points at it from AGENTS.md (see using it from any agent).

The renderer — npm package (without Claude)

To drive the CLI yourself — by hand, in CI, or from another tool — install the npm package:

npm install -D @z29k/notabene   # or: pnpm add -D @z29k/notabene · bun add -d @z29k/notabene
npx notabene init               # writes notabene.config.mjs + creates the .notabene store
npx notabene dev                # → http://localhost:3009

The npm package is scoped (@z29k/notabene); the CLI command it installs is just notabene, so npx notabene … works as-is.

init is the only thing that touches your repo — it writes notabene.config.mjs and creates the .notabene/ store (init --detect prefills roots[] from the doc folders it finds). The renderer itself runs from the package: nothing is scaffolded or copied into your repo, and upgrading is just npm update.

The full command surface (build, pdf, status, migrate, comments, journal…) is in the CLI reference.

Installing both is fine: the plugin always runs its own pinned renderer version (matched to the plugin’s version), independent of the one in your package.json — the two never conflict.

Requirements

Both routes share the same prerequisites:

  • Node ≥ 22.12 and npx on your PATH (both ship with Node).
  • Any repo with Markdown or MDX files — see MDX and CommonMark for how the two formats are handled.

Next: your first review.

Your first review

Your first review

You have installed the renderer and run npx notabene dev. Here’s the whole loop once, end to end.

1 · Comment the rendered page

Open http://localhost:3009, browse to any page, and select a passage — an action bar appears; leave your comment right there. You can also:

  • comment a whole page (the comment box at the bottom of each page);

  • comment a whole diagram or image — hover it and use the 💬 in the toolbar (the ⤢ next to it opens a pan/zoom lightbox);

  • reply in threads, put a comment on hold (⏸ — the agent will skip it), and see everything across pages at /comments.

On a phone or tablet the same loop works touch-first: the nav folds into a drawer, comments become bottom sheets, and you can select text and comment with your thumb. Drag a sheet’s handle up to expand it, down to dismiss it.

2 · Hand the comments to your agent

Tell your agent — with the Claude Code plugin it’s just:

address the doc comments

The agent reads the .notabene/ store directly (no server needed), locates each commented passage in the source file, applies the feedback faithfully, marks the comment resolved, and appends a journal entry linking what changed to why. It then verifies: the renderer build always runs, plus any checks you list in verify[]. It never commits without asking.

3 · Read the trail

  • /journal — every pass, with what/why/which comments per page.
  • Each resolved comment links its journal entry.

Want to validate each edit yourself before it counts as resolved — with the real git diff? That’s approve mode: see the review loop.

Working with several reviewers

Comments carry an identity (name + optional email), set per browser via the 👤 chip in the header — so threads attribute per person, not per machine. The store is one JSON file per comment, so parallel branches merge without conflicts.

Configuration

Configuration

notabene.config.mjs at your repo root is the only wiring. Paths are repo-relative. notabene init scaffolds a commented template; notabene init --detect prefills roots[] from the doc folders it finds. Every key is optional — this page shows the ones you’ll actually touch; the config reference lists them all.

// notabene.config.mjs
export default {
  siteName: "My Project",
  tagline: "docs",
  locale: "en",

  // Input format: "commonmark" (lenient, zero MDX) or "mdx" (.mdx strict + .md lenient).
  format: "commonmark",

  // Doc spaces. `key` = URL slug + store space; `path` = repo-relative folder.
  roots: [
    { key: "docs", label: "Docs", path: "docs", exclude: [".notabene/**"] },
    { key: "adr", label: "Decisions", path: "docs/adr", description: "Architecture decision records" },
  ],

  store: "docs/.notabene",   // comments + journal (commit this folder)
  review: "auto",            // "approve" = you validate each edit with a diff
  verify: [],                // your own post-edit checks (the renderer build always runs)
};

Spaces (roots[])

Each entry becomes a space: its own section in the sidebar, its own card on the home page, its own prefix in comment ids. A nested root (like docs/adr above) wins over its parent for the pages it contains — the most specific path rules. label and description accept a per-locale map when i18n is on.

MDX and CommonMark/GFM

The renderer picks the processor by file extension:

  • .md → CommonMark/GFM, lenient. <email@x>, Promise<T>, {var}, raw HTML and GFM tables all render without a crash.
  • .mdxstrict MDX (JSX/expressions) — importable components, but </{ outside code fences must be escaped.

format: "mdx" (the config default) enables both, mixable in one repo. format: "commonmark" (what init scaffolds) drops the MDX dependency entirely — the safe, most-lenient starting point for a plain-Markdown repo.

Branding

Point the header, browser tab and social cards at your own assets — repo-relative files, served by the renderer (nothing to copy anywhere):

branding: {
  logo: "assets/logo.svg",           // topbar image, next to the site name
  logoDark: "assets/logo-dark.svg",  // optional dark-mode variant (else logo everywhere)
  favicon: "assets/favicon.svg",     // .svg / .ico / .png — unset → a built-in default mark
  socialImage: "assets/og.png",      // og:image / twitter:image of PUBLIC builds
},

socialImage needs publish.site — crawlers require an absolute URL. The favicon also covers the print/PDF views.

Once a reader is inside a page, nothing leads back to your repo, your product or your releases. nav adds those outbound links in three places — one item shape everywhere:

nav: {
  // Topbar, right-hand group. Mirrored automatically in the mobile drawer.
  header: [
    { label: "GitHub", href: "https://github.com/you/repo", icon: "github", iconOnly: true },
    { label: { en: "Product", fr: "Produit" }, href: "https://example.com" },
  ],
  // A titled block under the space tree (the mobile drawer shows it too).
  sidebar: {
    title: { en: "Resources", fr: "Ressources" },
    links: [
      { label: "Releases", href: "https://github.com/you/repo/releases", icon: "star" },
      { label: "npm", href: "https://www.npmjs.com/package/your-pkg", icon: "npm" },
    ],
  },
  // The site footer. Nothing configured → no footer element at all.
  footer: {
    links: [{ label: "Licence", href: "/reference/licence" }],
    text: { en: "© 2026 you — MIT", fr: "© 2026 vous — MIT" },
    poweredBy: false,
  },
},

notabene navigation links: the repo icon in the topbar, a Resources block under the sidebar tree, a site footer, and a footer link back to the home page

FieldMeaning
labelRequired. A string, or a { <locale>: string } map like roots[].label. Doubles as the accessible name when iconOnly
hrefRequired. An https:///http:///mailto: URL (opened in a new tab, rel="noopener"), or a site path /… (your publish.base is applied for you)
iconOne of github, gitlab, npm, discord, slack, x, mastodon, rss, mail, book, home, star, download, external. Monochrome — it follows the link color, so it follows your theme
iconOnlyTopbar only: show the icon alone (the label becomes its aria-label/tooltip). Ignored elsewhere
publishfalse keeps the link out of public builds — same idea as roots[].publish
  • Everything is validated when the config loads: an unknown key, an unknown icon, a duplicate href or a javascript: URL throws immediately rather than shipping a broken — or booby-trapped — link into a published site.
  • These links are identity, not review tooling: unlike Comments/Review/Journal they show in dev and in public builds. They never appear in the print/PDF views, and they are outside the search index.
  • Keep the topbar to three or four entries — iconOnly exists precisely because that row is crowded. Long lists belong in the sidebar block or the footer.

Custom home page

By default the landing page (/) shows the site name and one card per space. Point home at a Markdown file to render your own welcome above those cards — the classic move is a README-like intro written for the site, with relative links that become routes:

home: "docs/home.md",
  • Full pipeline: Mermaid, code highlighting, and inter-doc links rewritten to site routes — link straight into your spaces ([install](./guide/install.md)).
  • Best kept outside your spaces (a dedicated doc): inside a space it would also render as a normal page of that space.
  • With i18n, pass a per-locale map: home: { en: "docs/home.md", fr: "docs/home.fr.md" } — each locale’s landing (/, /fr) renders its own file.
  • This site’s home page is exactly that — see docs/home.md.

Two zero-config touches under every doc page:

  • Last updated — the page’s git author date (one streamed git log per build; a lastUpdated frontmatter date overrides it; silently absent outside a git repo). Public builds also emit it as article:modified_time.
  • Edit this page — set editPattern and every page links to its source:
editPattern: "https://github.com/you/repo/edit/main/{path}",

It is rendered only where the in-page editor is not available — that is, in builds and published sites. Under notabene dev the editor is the better way to do the same thing.

In-page editing

Under notabene dev, a ✎ in the margin opens any block for editing right in the page — see Editing in the page. It is on by default and needs no configuration; these are the knobs if you want them:

edit: {
  enabled: true,      // false hides the editor entirely
  requireGit: true,   // false allows writing a file git isn't tracking
},
roots: [
  { key: "reference", path: "docs/reference", edit: false },  // read-only space
],

By default a page’s sidebar entry is its humanized file name and siblings sort alphabetically. Override either per page with frontmatter — no numeric file-name prefixes needed:

---
title: Internal network map      # page <title> + breadcrumb (overrides the H1)
sidebar:
  label: Network map             # sidebar text (else title, else file name)
  order: 9                       # position among siblings (ascending)
---
  • order sorts ascending; entries without one keep sorting alphabetically, after the ordered ones. Groups and pages share one ordering.
  • A folder is named and positioned by its landing page — <folder>/index.md (or readme.md) — whose sidebar frontmatter applies to the whole group; that page shows as a localized Overview entry (rename via sidebar.indexLabel).
  • These labels flow through to breadcrumbs and PDF export.

The full frontmatter surface (including description and publish for public sites) is in the frontmatter reference.

Customize the look

Customize the look

notabene runs from the package — you never fork its UI. Everything below is config-driven instead:

  1. Branding — logo, favicon, social image.
  2. Design tokens — override the --nb-* custom properties (below).
  3. Your own stylesheet — a CSS file loaded after the renderer’s styles.
  4. Fonts and images — a folder of your repo, served for that stylesheet.
  5. Code and diagrams — a Shiki theme for code blocks; Mermaid follows the tokens.

Outbound links (topbar, sidebar, footer) are not part of this: they’re repo data, not appearance — see navigation links.

theme: {
  // Quick overrides, no file needed. A plain value applies to BOTH color schemes;
  // a light-dark() pair customizes each: light on the left, dark on the right.
  tokens: { accent: "light-dark(#7c3aed, #b79bff)", radius: "4px" },
  css: "docs/notabene-theme.css",                 // or/and a full stylesheet
},

Both target the same contract; a typo’d token name throws at startup (never a silent no-op). The renderer’s own styles live in CSS cascade layers, so your un-layered CSS always wins — no specificity war, no ordering luck.

Everything follows that palette — the chrome, the code blocks and the diagrams — and the header’s scheme toggle switches it live, with no rebuild:

notabene theming: one click on the header scheme toggle flips the palette — chrome, code block and Mermaid diagram all follow, with no rebuild

Diagrams

Mermaid diagrams follow the tokens out of the box: nodes are filled with accent-soft and outlined with accent, labels use the prose color, edges text-soft — and they re-render on the scheme toggle, so a dark-mode diagram is a real dark diagram, not an inverted image. Nothing to configure.

If a diagram looks better with Mermaid’s own palette, opt out:

theme: { mermaid: false },   // back to Mermaid's built-in default/dark themes

Syntax highlighting (theme.code)

Code blocks are highlighted at build time, so their colors are baked in — a light theme with dark code blocks is the usual mismatch. Name a Shiki theme and that changes:

theme: {
  code: "github-light",                            // same theme in both schemes
  // or one per scheme:
  code: { light: "github-light", dark: "vesper" },
},
  • With code set, both palettes ship as CSS variables and the scheme toggle recolors code instantly — no rebuild, no flash.
  • The code theme then owns the block background too (a light theme’s tokens on the default dark slab would be unreadable). --nb-code-bg stays the background for everyone who sets no theme.code.
  • PDF/print forces the light scheme, so your light code theme is what lands on paper.
  • Unknown theme name → an error at startup, like every other theme knob.

Fonts and images (theme.assets)

A stylesheet usually needs files: a web font, a background, a texture. Point theme.assets at a folder of your repo and it is served at the fixed path /_nb/assets/… — in dev, in builds, and inside public artifacts, so a published site stays self-contained (no CDN):

theme: { css: "docs/theme/site.css", assets: "docs/theme/assets" },
/* docs/theme/site.css */
@font-face {
  font-family: "Inter";
  src: url("./assets/fonts/Inter.woff2") format("woff2");   /* ← relative, always */
}
:root { --nb-sans: "Inter", system-ui, sans-serif; }
  • Write url() relative, never root-absolute. URLs resolve against the served stylesheet (/_nb/theme.css), not your source file — so ./assets/… is the correct form, and it absorbs a base sub-path (GitHub Pages project site) for free, where /_nb/… would break.
  • Declare a dedicated folder, not docs/: everything servable in it is emitted, referenced or not.
  • Only assets are served — fonts (woff2, woff, ttf, otf), images (svg, png, jpg, webp, avif, gif, ico) and css. Anything else (.md, .env, scripts), every dot-file, and any symlink pointing outside the folder is refused.

The token contract (--nb-*)

These custom properties are the public theming surface — stable across versions. Color defaults are given as their light-dark(light, dark) pair:

TokenDefault (light / dark)Role
bg#ffffff / #0e1116Page background
bg-soft#f6f7f9 / #151a21Panels, inputs
bg-elev#ffffff / #161b22Elevated surfaces (topbar, popovers)
border#e4e7ec / #272e38Hairlines
text#1c2024 / #e7ebf0Primary foreground
text-soft#5b6470 / #aab2bdSecondary foreground
text-faint#8a929e / #768091Tertiary foreground
accent#2f6feb / #6ea0ffLinks, focus, highlights
accent-soft#e8f0ff / #182539Accent backgrounds
ref / work#2f6feb / #6ea0ff · #b5651d / #e0a060Space chips in search results
code-bg#0d1117 / #0b0e13Code block background — the default, when no theme.code is set (Shiki’s colors are then baked light-on-dark, so code stays dark in both schemes)
topbar-h / sidebar-w / toc-w / content-max52px / 290px / 320px / responsiveLayout (scheme-invariant)
radius8pxCorner rounding
sans / monosystem stacksFont families

Every color token is a light-dark() pair — one declaration covers both schemes, and the header’s scheme toggle (auto / light / dark, persisted per browser) flips them all at once. A theme that changes colors does the same:

/* docs/notabene-theme.css */
:root {
  --nb-accent: light-dark(#7c3aed, #b79bff);
  --nb-accent-soft: light-dark(#f1e9ff, #241a3d);
}

The toggle works through color-scheme + a data-scheme attribute the renderer sets on <html> — a theme must not set that attribute (or color-scheme on :root); override tokens, and the toggle keeps working for free. light-dark() covers colors only; for the rare non-color per-scheme styling, selecting on :root[data-scheme="dark"] in your stylesheet is fine — it’s setting the attribute that’s reserved.

Beyond tokens

Your stylesheet can also target a small set of stable hooks: .topbar, .brand, .sidebar, .prose (the rendered content), .home-cards, .rail, plus the navigation links.nb-nav-link (any outbound link), .nb-sidebar-links (the block under the space tree) and .site-footer. Everything else — and every un-prefixed CSS variable — is internal and may change between versions.

A theme styles those links; it never declares one. Nav entries, the footer text and the branding assets are repo data (nav, branding at the top level of the config), not appearance: a stylesheet you install must not be able to inject outbound links into a published site, nor to carry labels in languages it can’t know.

Two rules keep you safe:

  • Only override --nb-* tokens and the hooks above. In particular, never touch the un-prefixed variables: the print/PDF views force a light palette through them, so a token-only theme can restyle the whole site without ever breaking the PDF export.
  • Check both color schemes — the header toggle makes that a two-click test.

Themes apply everywhere: dev, normal builds, public sites and the print views (colors excepted, by design).

The review loop

The review loop

The loop is the product: comments in, faithful edits out, everything journaled. It’s designed so any agent can run it — the protocol is a plain-text skill file that reads and writes the store directly.

How the agent works

Everything is discovered from notabene.config.mjs — nothing hardcoded, no server or port required:

  1. Read the open, non-held comments from <store>/ (one JSON file per comment).
  2. Locate the source page via roots[], then resolve the text anchor tolerantly (the anchor stores the quoted text + surrounding context + nearest heading).
  3. Edit the docs faithfully — a comment is a user decision.
  4. Mark the comment resolved (or addressed in approve mode) and append the journal: one entry per pass, one change record per page touched, linked back to the comment ids.
  5. Verify: the renderer build always runs, then notabene lint (inter-doc links validated against the routes that build just emitted), then your verify[] checks.
  6. Report and ask before committing — never a silent commit, never a bulk delete.

Comments a reviewer puts on hold (⏸) are skipped — they’re your work-in-progress.

Steps 4 and 5 have CLI primitives so an agent never hand-edits the store JSON: notabene comments done <id…> --note … --journal <entryId> picks the right status from your review mode, and notabene comments verify audits what it wrote — statuses, comment↔journal links in both directions, layout. It exits non-zero on a real problem, so it doubles as a CI gate on the store your agents commit.

Approve mode: humans validate every edit

By default (review: "auto") the agent resolves comments directly. Set review: "approve" for a human-in-the-loop flow:

  • the agent edits and marks each comment addressed instead of resolved;
  • you validate at /review (or the To validate filter on /comments);
  • you see the real git diff of everything that changed for that comment — cascades included (one comment can touch several pages);
  • approve → resolved, or reject → reopened with your reason, which the agent reads on its next pass;
  • the diff renders unified or side-by-side, and a Review badge in the header counts what’s waiting.

approve mode: the agent proposes, you validate the real diff

Using it from any agent

The protocol is a plain-text spec, and notabene init installs it in your repo so no agent has to go looking for it:

  • <store>/protocol.md — the full spec, committed next to the comments it describes. Offline, no npm, no network. This is what you point any agent at.
  • AGENTS.md — a bounded <!-- notabene:begin -->…<!-- notabene:end --> block that tells the agents which read it at startup (Codex CLI, Cursor, Gemini CLI, Zed, Amp…) where the comments and the protocol live. Nothing outside the markers is ever touched; opt out with init --no-agents-md.

Both are refreshed by re-running notabene init (idempotent) — do that after moving the store or renaming a space, and notabene doctor will tell you when they drift. The text itself is this site’s agent protocol page — the canonical one, with a Markdown twin in public builds for agents that browse; npx -y @z29k/notabene@latest protocol prints the same thing offline.

In Claude Code the plugin skill is that protocol — it triggers on “address the doc comments” and needs no AGENTS.md. The store shape itself is a versioned public contract — see the store reference.

Editing in the page

Editing in the page

The review loop gives an agent a way to write. This gives you one, without leaving the page you are reading.

Hover a paragraph and three handles appear in the margin — edits the block, + adds one below, ⋮⋮ opens the block menu. Click the pencil and you are editing that block, in place: it keeps the page’s own typography and does not move, it just takes on a tinted background so you can see which one is live. Only that block’s source is rewritten — the rest of the file is not touched, so the diff your teammates review is the one line you actually changed.

It is a dev-only tool, exactly like commenting: the write API exists under notabene dev and nowhere else. A built or published site has no editor, no endpoint, and no trace of one.

notabene in-page editing: hover shows the gutter handles, the pencil opens the block in place, a selection gets the formatting toolbar, and the save closes the comment it answers with a journal note

The gestures

Two intentions, two gestures — which is why there is no mode switch:

You doYou get
in the marginyou are editing that block
⋮⋮ in the marginthe block menu — add below, duplicate, copy link, comment, delete
Select text, anywherethe comment popover, exactly as before
Select text while editingthe formatting toolbar, at the selection — Turn into first
+ in the margina new block under this one
/ while editingthe block palette — it inserts below; type to filter
Done, or ⌘↵save — the change is written
Click elsewherean untouched block closes; a modified one stays open and asks
Cancel, or Escapediscard — the block goes back as it was
⌘Zundo your typing, as anywhere else
⌘⇧Mswap to raw Markdown for that block, and back

Reading and editing never fight over the same gesture: selecting text always means “comment on this”, and editing always starts from the pencil. Writing is explicit: only Done (or ⌘↵) touches the repo. Clicking elsewhere closes an untouched block — moving around the page never writes — but a block with changes stays open and asks, so a stray click can neither write your edit nor lose it.

Discarding is the one way out that throws work away, so when the block has unsaved changes it asks once: press again — or click Cancel — to confirm, right where you are looking. On an untouched block it just closes. Nothing is sent to the server either way, and the draft is dropped, so re-opening the block gives you the file’s own text back.

While a block is live, one compact card sits directly under it: Done, Cancel, undo and the Markdown toggle on its first row, and — once you have changed something — everything the save can carry (see Closing the loop). There is no other chrome: no mode, no rail, no panel somewhere else on the screen.

Tables carry their controls on the grid itself, not on a bar that follows the caret. Hovering a cell raises a handle on its row and its column; the column handle opens alignment (:--, :-:, --: — a column property in Markdown) and delete, the row handle opens delete, and the table’s edges grow + buttons for a new row or column. Rows and columns drag to reorder — all of it inside the one block the editor owns.

Two toolbar actions make a whole row or a whole column read as a header — they appear when your selection is inside a table. They exist because Markdown carries no styling: the only thing they can write is bold, so they bold every cell of that row or column, and the renderer gives a fully-bold row or column the header’s own surface. Press again to undo it. The header row itself is left out — it is already a header.

That threshold is deliberate. A lone bold cell stays plain emphasis: | **✎** in the margin | … | is not a label, and tinting it would be guessing. Only a complete run is treated as a decision, which is exactly what the buttons produce. The file stays portable either way — on GitHub, or in any editor, those cells simply read as bold.

There is no header or footer option beyond that, and that is the format rather than an omission: a GFM table has exactly one header row, always, and Markdown has no concept of a footer row or of a header column. Offering them would mean emitting raw HTML tables — which stop being Markdown, stop round-tripping through the containment check, and stop rendering anywhere else your .md files are read.

The toolbar itself appears only at a text selection — a bare caret gets nothing. A bar that follows the caret sits on the very text being edited, so structure lives elsewhere: tables on their grid, lists on the keyboard (Tab / ⇧Tab to indent and outdent) and on the toolbar when text is selected inside one. Tab moves between table cells, and Tab in the last cell adds a row rather than dropping you out of the block.

An edited table comes back in the file’s own convention, down to the delimiter row: a compact | --- | file stays compact, an aligned one stays aligned. That matters more than it sounds — a table that could not round-trip would be rewritten in full by someone who only opened it and pressed Done.

Blocks

An empty block tells you so itself — it carries a Type ’/’ for commands placeholder, the way Notion’s does. A gesture you have to be told about in documentation is a gesture most people never find.

/ opens the palette Notion trained everyone to reach for, with Notion’s verb: it inserts a new block below the one you are in — Text, Heading 1–4, Bulleted list, Numbered list, To-do list, Quote, Code, Table, Divider, Image, each with its Markdown shortcut shown beside it. Only an empty block is typed in place instead, which is the one case where inserting and transforming mean the same thing. Type to filter, / to move, to apply; the /query you typed is swallowed.

Changing what an existing block is lives on the toolbar instead: select text and the bar leads with Turn into — the current block type, with a menu of everything GFM can turn it into. Two verbs, two surfaces, never confused.

Managing the block is the ⋮⋮ menu, and it needs no editing session at all: duplicate and delete are one-shot range writes (a duplicate writes the block twice, a delete writes nothing and takes one blank-line separator with it — neighbours come back byte-identical either way, and delete asks once before acting); copy link to block puts the nearest heading anchor on your clipboard; comment hands the block straight to the selection-comment flow.

What Notion offers and Markdown cannot carry: colour, block alignment (there is no text-align in Markdown; only table columns have alignment, set from the column handle), move up/down (it rewrites two blocks at once, which the containment check refuses by design), callouts, toggles and columns.

The formatting toolbar covers what GFM has: bold, italic, code, strikethrough, links — the link button opens a small input for the URL, and hovering an existing link offers edit, copy and remove — and a clear formatting button that strips every mark from the selection. Buttons light up when the selection already carries their mark. Underline, colour and highlight have no Markdown syntax, so they are not offered rather than silently written as HTML.

The Markdown shortcuts work too, and always did: - , 1. , # , > , ```, ![alt](src), and |3x2| for a 3×2 table. The palette exists because a shortcut you have to already know is not an interface.

+ in the margin, next to the ✎, starts a new block under this one. The block you clicked beside stays rendered — it is not what you are editing — and an empty surface opens beneath it, ready for /. Leave it empty and nothing at all is written, so clicking + and changing your mind costs nothing.

Under the hood the save rewrites that one block’s range with two blocks, which is why the neighbours still come back byte-identical. (Editing the original in order to type below it was the first attempt, and it read as adding a line break to it.)

Images: paste one, or pick Image… from the palette. Either way the file is written into the repo next to the page with a content-hashed name and linked for you, so it lands in the same commit as the prose that references it.

What we deliberately did not take from Notion is drag-to-reorder. Moving a block past its neighbour rewrites two blocks at once, which is precisely what the containment check refuses — and that check is what keeps your diffs down to the line you changed.

Prose blocks open as rich text: what you type looks like what the page will render, and selecting inside shows the formatting toolbar. Code fences (and anything else the renderer cannot represent as prose) open as plain Markdown instead — a WYSIWYG view of a code block would be a worse code editor than a text area, and the page re-renders the real thing the moment you save. ⌘⇧M swaps either way.

If a reload interrupts you — HMR fires on every save, and whenever the agent writes — the text you had typed is kept and restored when you reopen that block.

On a phone the same two steps survive, with the gesture a phone can spare: a tap arms the block — it outlines it and raises two buttons under the topbar, Edit this block and for the block menu (add a block below, duplicate, copy link, comment, delete — as a bottom sheet). A tap alone never edits anything, because on a phone the tap is how you read: you tap while scrolling, aiming at a link, or on your way to a long-press. Long-press still selects, and still offers to comment.

notabene mobile editing: a tap arms the block, the chip opens it, Done reveals the journal note and the comment closures above the keyboard bar and becomes Confirm

While you edit, the writing tools live in one bar riding the top of the keyboard, where your thumbs already are — Notion’s shape. The scrolling zone acts on the content: + (the block palette, as a sheet), Turn into, bold, italic, strikethrough, code, link, outdent and indent, undo and the Markdown toggle. The session’s two exits — Cancel and Done — sit together at the right end, behind a light divider. There is no floating toolbar on touch — it would sit under the native selection callout, and every mark is on the bar full-time. The session card keeps only its body and docks just above the bar — and while you type it stays out of the way: warnings and errors surface on their own, but the paperwork waits for the save. Saving is a two-step: on a changed block, pressing Done reveals the journal note and the comments this save closes just above the bar, and the button becomes Confirm — press it again and the save is written, with the note if you filled one in. Typing again (or ✕) folds the question back down. On touch, leaving is always explicit: scroll and tap around freely — only the bar’s ✕ and Done end the session.

Everything around the block stays rendered while you type: the comment rail, the highlights, the table of contents, the diagrams. That is the point — you are meant to be reading a comment while you fix the sentence it is about.

Closing the loop

Once you have actually changed something, the card under the block grows: the open comments on that page, and a place to describe the change. Tick the comments your edit answers and they are resolved by the same save, linked to a journal entry — the same registry an agent pass writes to. Nothing appears until there is something to attach it to.

In review: "approve" mode, a comment you close this way goes straight to resolved, not addressed: you edited the page, so you are the validator that mode is waiting for. On the /review page, every card carries a Fix in page link that drops you directly onto the block the comment is about, in edit mode — approve by correcting, in one click.

notabene comments verify audits what you wrote exactly as it audits an agent’s pass.

What it refuses to do

The editor writes into your content, so it is deliberately hard to make it do something you did not mean:

  • It will not disturb a neighbouring block. After splicing your text in, it re-parses the file and checks that every other top-level block comes back byte-identical. Turning a paragraph into a list next to an existing list would merge the two; an unterminated code fence would swallow the rest of the page. Both are refused, and nothing is written. When the merge is what you actually wanted, the refusal offers to include the next block and retry.

  • It will not edit what it cannot represent. Only blocks the renderer could tag are editable; raw HTML and JSX blocks stay read-only and simply never light up. .mdx files are not editable at all: their offsets are not in the same coordinate system, so the editor declines rather than guess.

  • It will not touch a space you closed. roots[].edit: false makes a space read-only, and edit: { enabled: false } removes the editor everywhere.

  • It will not write a file git is not tracking, because then the edit could not be undone. The message tells you to git add it. Set edit: { requireGit: false } if you really want to edit outside version control.

  • It warns before it breaks an anchor. If your edit removes the text a comment is quoting, the card under the block says so while you type — that comment would be orphaned.

Images

Paste an image into the editor and it is written into the repo next to the page, with a content-hashed name, and the Markdown link is inserted for you. It lands in the same commit as the prose that references it. PNG, JPEG, GIF, WebP, AVIF and SVG, up to 8 MB — anything else is refused; pasting the same screenshot twice reuses one file.

What a save cannot check for you

An agent pass ends with a build, notabene lint and your verify[] commands. A human edit ends with none of that — and the save does not pretend otherwise: it answers saved or it refuses, nothing in between. The exhaustive checks live where they always did — notabene lint for links, your verify[] in CI and in every agent pass. The editor deliberately does not execute your commands from the dev server.

Configuration

export default {
  edit: {
    enabled: true,      // default — set false to hide the editor entirely
    requireGit: true,   // default — refuse to write a file git isn't tracking
  },
  roots: [
    { key: "reference", path: "docs/reference", edit: false },  // read-only space
  ],
};

notabene doctor reports the state, including the one combination that would refuse every save: editing on, requireGit on, and no git repository.

What it is not

Not a CMS. There is no editor in a deployed site, no media library, no frontmatter editing, and no real-time collaboration — git is the merge layer. Editing a page in one language does not touch its translations; those stay an explicit act.

Authoring docs

Authoring notabene docs — the rendering palette

What actually renders in a notabene site, so you can write a complete doc with every tool available and nothing that silently degrades to plain text. Docs are plain files in the repo (Markdown/MDX), rendered by the notabene renderer (Astro + GFM + Shiki + Mermaid).

Applying review comments is also writing docs: use this palette for those edits — the loop itself is the review protocol.

First: know the format

Read format in notabene.config.mjs (or run npx -y @z29k/notabene@latest doctor --json). It decides the pipeline:

  • commonmark (the init default) — globs .md + .markdown, lenient CommonMark/GFM, no MDX. <, {, Promise<T>, raw HTML, GFM tables all render without a crash. Simplest.
  • mdx — globs .md + .mdx. .md stays lenient; .mdx is strict (JSX/expressions): a stray { or < outside a code fence is a build error.

Everything below works in both formats. The MDX-only extras (components/expressions) are called out at the end.

The palette (all verified to render)

  • Prose + CommonMark: headings, lists, **bold**, _italic_, > blockquotes, --- rules, inline `code`, links.
  • GFM: tables, task lists (- [ ] todo / - [x] done), ~~strikethrough~~, autolinks, footnotes (text[^1][^1]: note).
  • Code blocks with syntax highlighting — fenced with a language, highlighted by Shiki (github-dark unless the site sets its own code theme, soft-wrap on). Any Shiki-supported language:
    ```ts
    export const x: number = 1;
    ```
  • Mermaid diagrams — see the next section (the reason this palette exists).
  • Inter-doc links: link between docs with relative .md/.mdx paths ([see setup](../guide/setup.md)) — they’re auto-rewritten to site routes. External/absolute/ anchor links are left as-is.
  • Images: standard Markdown ![alt](path) (also good for embedding a pre-rendered SVG — see MCD below).
  • Headings drive the page: the first # H1 becomes the page title (unless frontmatter title overrides it — see Page metadata below), and headings build the table of contents + anchor links. Use one H1 per page.

Page metadata: title, sidebar label & order (frontmatter)

Optional YAML frontmatter at the very top of a page controls how it appears in the sidebar, breadcrumb and page <title> — so you don’t have to encode ordering as numeric file-name prefixes:

---
title: Cartographie du réseau interne   # page <title> + breadcrumb (overrides the H1)
description: Plan des segments et VLANs # public builds: meta description + OpenGraph
publish: false                          # public builds: keep this page OUT of `build --public`
sidebar:
  label: Cartographie                   # sidebar text (else title, else humanized file name)
  order: 9                              # position among siblings (ascending)
---
  • Sidebar label resolves sidebar.labeltitle → humanized file name. Set sidebar.label to keep a short sidebar entry while the H1 / title stays verbose.
  • order sorts siblings ascending. Entries without order keep sorting alphabetically, after the ordered ones — and groups and pages share one ordering, so a numbered folder slots into a numbered page sequence without any file-name prefix.
  • A folder is named and ordered by its landing page<folder>/index.md (whose id collapses to the folder path) or <folder>/readme.md. Put the sidebar frontmatter there and it applies to the whole group; that page becomes the group’s Overview entry (label localized per UI language, e.g. FR Aperçu — override it with sidebar.indexLabel).
  • description feeds the meta description / OpenGraph / JSON-LD of a public build (notabene build --public) — one plain sentence summarizing the page.
  • publish: false keeps the page out of public builds entirely (route, nav, search, llms.txt, Markdown twin, sitemap) — the dev/review site always shows it. Preserve this key when editing a page that carries it. Whole spaces (roots[].publish: false) and sub-trees (publish.exclude globs in the config) scope the same way. Don’t link from a public page to private content — the link 404s in the public artifact and the build won’t warn; check the target’s frontmatter (and the config’s publish.exclude / roots[].publish) before adding an inter-doc link.
  • lastUpdated overrides the Updated on date in the page footer (normally the page’s git author date). Set it only when git history misleads — imported or generated content; any date-parsable value.
  • Frontmatter is optional: with none, the sidebar shows humanized file names sorted alphabetically (unchanged). Only title, description, publish, lastUpdated and sidebar are interpreted — any other keys pass through untouched.

Mermaid diagrams (logigrammes, séquences, ER…)

Write a fenced ```mermaid block — it renders to an SVG in the browser (client-side). Because it’s a code fence, it’s MDX-safe: the diagram’s -->, {, |, < are never parsed as JSX, even in strict .mdx.

```mermaid
flowchart TD
  A[Start] --> B{OK?}
  B -->|yes| C[Done]
  B -->|no| A
```

Supported (Mermaid v11) — the common set: flowchart (logigramme), sequenceDiagram, classDiagram, stateDiagram-v2, erDiagram (entity-relationship), gantt, gitGraph, journey, pie, mindmap, timeline. Diagram source is versioned/diffable like the rest of the doc.

Data models — read this before drawing an “MCD”:

  • erDiagram gives crow’s-foot ER with attributes + keys (PK/FK/UK) and cardinalities (||--o{, }o--||, …). It maps to a relational / MLD-level model — great for most data docs:
    ```mermaid
    erDiagram
      CLIENT ||--o{ COMMANDE : passe
      COMMANDE {
        int id PK
        int client_id FK
      }
    ```
  • It is NOT Merise MCD notation (no associations-in-diamonds, no 0,n/1,1 legs, no n-ary associations). For a strict Merise MCD, draw it with Mocodo (open source, dedicated to Merise) and embed the exported SVG as an image: ![MCD](./mcd.svg). Model n-ary relations as an associative entity in erDiagram if you stay in Mermaid.

Two caveats:

  • Diagrams render client-side (need JS in the browser). In the static build the block ships as its source text and becomes an SVG on load. Fine for the review UI and normal hosting.
  • A rendered diagram is an SVG, not prose. Reviewers can comment the whole diagram (a block comment) and enlarge it via the toolbar that appears on hover/tap — the same block comment + enlarge works on images too — but text-anchoring a comment inside the SVG isn’t possible. Put explanatory prose around a diagram if a reviewer might want to annotate a detail.

MDX-safety (only when format: "mdx", editing a .mdx file)

  • Don’t leave a bare { or < outside a code fence — MDX reads them as expression/JSX. Escape as \{ / \<, wrap in `code`, or put it in a fence.
  • .md files are always lenient — no such constraint. When unsure, prefer .md.

Not available (don’t write it — it degrades to plain text)

  • Admonitions / callouts — there’s no :::note or GitHub > [!NOTE] styling. > [!NOTE] renders as a plain blockquote with the literal text. Use a normal > blockquote (or bold lead-in).
  • Math — no KaTeX/MathJax; $…$ renders literally.
  • Custom components in .md — only .mdx (in mdx format) can use JSX/expressions, and only for components that resolve in the repo. Keep to the portable palette above unless you know a component exists.

Multi-language docs

Multi-language docs (i18n)

Add i18n to serve the same docs in several languages with clean prefixed URLs (the default locale unprefixed, others /<locale>/…), a language switcher in the header, hreflang alternates, and per-page chrome — a French page renders French nav, buttons and dates.

i18n: { locales: ["en", "fr"], defaultLocale: "en", strategy: "directory" },

notabene i18n: pick a language from the header switcher, docs and chrome switch

Two authoring layouts

Pick how the files are laid out with strategy:

  • directory (default) — a folder per locale: docs/en/guide.md · docs/fr/guide.md.
  • suffix — one tree, translated per file: docs/guide.md (default) · docs/guide.fr.md. Best for adding languages to an existing doc: the default-language files don’t move, so their URLs and their comment threads are preserved.

Language preference & fallback

The switcher records the visitor’s chosen language; from then on, landing on a page written in another language that has a translation redirects to it — following any link keeps you in your language. A page with no translation falls back to the source language and shows a discreet banner. The pages not tied to a content language — /comments, /journal, /review, the home page and 404 — follow your current language client-side and carry the same switcher.

Per-language everything

  • Comments are per language — a comment on the FR page is its own thread, mapping to the FR source file.
  • Search and PDF export (notabene pdf --locale fr) are scoped to one language; a public site ships per-locale llms.txt and Markdown twins.
  • Every human string of the config accepts a per-locale map: a space’s label/description (label: { en: "Docs", fr: "Documentation" }), the custom home page (home: { en: …, fr: … }), and every navigation link label, sidebar block title and footer line. Unset for a locale → it falls back to the default one.

Omit i18n for a single language — behavior is unchanged.

PDF export

PDF export

Turn any page, folder, space, or the whole doc into a polished document. Two paths, same print-optimized rendering (cover page + clickable table of contents, light-forced palette so dark-mode diagrams stay readable on paper).

Export PDF menu: pick a scope, get a print-ready view with cover and clickable TOC

In the browser — zero dependencies

The header’s Export PDF menu offers the current page, its folder, its space, or the whole doc. It opens a /print view in a new tab and triggers your browser’s Save as PDF automatically. The /print routes are static — they exist in dev and in any build (including public sites).

notabene pdf — the high-fidelity artifact

notabene pdf --scope space:docs --out docs.pdf

Builds the site, drives headless Chromium, and writes a PDF with a real bookmark outline (the navigable side panel) and running page numbers. Flags: --scope doc|space:K|folder:K/P|page:K/I, --locale, --out, --chrome.

Requires the optional puppeteer peer dependency (or puppeteer-core plus --chrome <path> / PUPPETEER_EXECUTABLE_PATH pointing at a system Chrome):

npm i -D puppeteer

Tuning

pdf: { enabled: true, pageSize: "A4", margin: "18mm" },

enabled: false hides the Export menu and drops the /print routes. pageSize/margin feed the @page CSS box. Covers and section titles reuse the sidebar’s labels and ordering, so the PDF reads in the same order as the site.

Publish a public site

Publish a public site

The review app is a dev-local tool — but the docs it renders often deserve a public home. build --public produces a pure-static, read-only artifact made for that:

notabene build --public --site https://you.github.io --base /your-repo --out ./_site

This very site is built that way — notabene’s docs, rendered and published by notabene.

What’s in, what’s out

  • Everything interactive is gone — structurally. No comment UI, no identity prompt, no /comments / /review / /journal, no API, and nothing from the .notabene store. The routes are not gated; they are not built.
  • What remains is the full reading experience: nav, search, Mermaid + image lightbox, dark mode, i18n (per-locale pages, switcher, hreflang), print/PDF routes, 404.
  • Born agent-readable. Every page ships a Markdown twin at <page>/index.md (advertised via <link rel="alternate" type="text/markdown">), the site ships /llms.txt (a machine index of every page, per locale) and /llms-full.txt (the whole doc as one Markdown document in reading order), plus robots.txt, a sitemap, canonical URLs, OpenGraph/Twitter meta and JSON-LD. Try it here: /llms.txt.

Full-text search (optional)

The public site inherits the built-in search as-is. Install Pagefind as a dev dependency and build --public upgrades it to static full-text search:

npm i -D pagefind

The build indexes the final artifact: per-language stemming (a /fr/ visitor searches a French index with French word forms), highlighted excerpts under each result, and a payload that stays small as the docs grow — the browser fetches only the index chunks a query needs. Zero configuration, same search box. Not installed → the public site keeps the built-in JSON search. And private content cannot leak into the index: indexing runs on the artifact, where scoped pages don’t exist.

The same dependency upgrades the notabene dev review app too: its index is built live from your Markdown sources and refreshed as they change — no build involved. Two dev-specific differences: the dev site (and so its index) includes your private pages, and per-section deep results (heading anchors) are public-only.

Where next

The dev loop is untouched: notabene dev and plain notabene build behave exactly as before — publishing is opt-in, per build.

Configuring publish

Configuring publish

Everything lives under one optional config key — the CLI flags (--site/--base) override it, and --out copies the artifact to a stable path (it refuses to overwrite anything it didn’t generate):

// notabene.config.mjs
export default {
  // …
  publish: {
    // Deployed ORIGIN. Bakes absolute URLs into canonical, og:url, JSON-LD,
    // hreflang, llms.txt, the sitemap and robots.txt's Sitemap line.
    // OPTIONAL — omit it to keep the domain out of the repo (see
    // "Domain managed server-side"). Origin only, no path: a sub-path goes in `base`.
    site: "https://you.github.io",

    // Sub-path when the site is served under a prefix (GitHub Pages project
    // site → "/<repo>"). Prefixes every link and asset URL — unlike the domain,
    // a sub-path always affects rendering, it can't be server-side.
    base: "/your-repo",

    // Sub-trees to keep out of public builds — globs matched against
    // `<space key>/<page id>` (locale-independent: one pattern hides every
    // translation of a page). `*` = one path segment, `**` = any depth.
    exclude: ["docs/internal/**", "docs/*/draft"],
  },
};

Three typical setups

publish: { site: "https://you.github.io", base: "/my-repo" }  // GitHub Pages, project site
publish: { site: "https://docs.example.com" }                 // custom domain at the root
publish: { exclude: ["docs/internal/**"] }                    // domain kept out of the repo

The third one is the origin-agnostic mode — same artifact behind any domain.

Per-page metadata

Two frontmatter keys feed the public surfaces (see the full frontmatter reference):

---
description: One-line summary — becomes the meta description / OpenGraph.
publish: false   # this page never ships in a public build
---

Scoping content out of the build has its own page: keep content private.

Keep content private

Keep content private

Three levels, from coarse to fine — each lives where the thing it scopes lives.

A whole space — flag the roots[] entry:

roots: [
  { key: "docs",  label: "Docs",  path: "docs" },
  { key: "notes", label: "Notes", path: "notes", publish: false },  // whole space stays private
],

A sub-treepublish.exclude globs on <space key>/<page id> (that’s the page’s URL path without any locale prefix, so one pattern hides every translation):

publish: { exclude: ["docs/internal/**", "docs/*/draft"] },

A single page — its own frontmatter:

---
publish: false   # this page never ships in a public build
---

A navigation link — not content, but the same idea: a nav entry marked publish: false stays in dev and never reaches the artifact (a dashboard, an internal wiki):

nav: { header: [{ label: "Ops dashboard", href: "https://ops.internal", publish: false }] },

The guarantee

Private content isn’t hidden, it’s not built — no route (the URL 404s), no sidebar entry, no search hit, no llms.txt line, no .md twin, no sitemap entry, no print/PDF inclusion, and the space’s name and path never reach the public HTML.

notabene dev and normal builds always show everything — you review your private docs exactly like the rest. The publish notion exists only for public builds.

If a public page links to a private one, that link 404s in the public artifact — the build doesn’t rewrite or warn about it. notabene lint catches exactly this: run it after build --public and every link from a public page into scoped-out content is reported (the public route truth simply doesn’t contain those pages — see the CLI reference).

Domain managed server-side

Domain managed server-side? Omit site

When the public domain is the server’s business — a vhost or reverse proxy in front, several mirrors, or a domain that isn’t chosen yet — just don’t set site (and pass no --site). The artifact becomes origin-agnostic: not one absolute URL is baked in, so the same output works behind any domain, and changing domains never requires a rebuild.

What changes

SurfaceWith siteWithout
llms.txt / llms-full.txt / .md twinsabsolute URLsroot-relative paths (agents resolve them against the origin they fetched from)
hreflang alternatesabsolutepath-based
canonical, og:url, JSON-LDemittednot emitted — they only mean something with an origin
sitemap + robots.txt Sitemap: lineemittednot emitted — the specs require absolute URLs

What that costs, concretely

Search-engine visibility — nothing else. Without a sitemap, canonical URLs or valid hreflang, crawlers only discover pages by following links, nothing consolidates duplicates if the docs answer on several domains, and multilingual pages send no language signals to search engines. Social link previews keep their title/description but lose the URL card. Human readers and AI agents lose nothing — every page, twin and llms file works identically.

Rule of thumb: internal hosting, a mirror, or a domain that isn’t settled → omit site; a public site whose search ranking matters → set site.

base stays independent

Set base whenever the site lives under a sub-path, with or without a domain — a sub-path always affects the rendered links, so it can’t be left to the server.

Deploy via GitHub Pages

Deploy via GitHub Pages

Deploy anywhere static. For GitHub Pages: Settings → Pages → Source = GitHub Actions, then:

# .github/workflows/docs.yml
name: docs
on:
  push:
    branches: [main]
permissions:
  contents: read
  pages: write
  id-token: write
concurrency:
  group: pages
  cancel-in-progress: false
jobs:
  publish:
    runs-on: ubuntu-latest
    environment:
      name: github-pages
      url: ${{ steps.deployment.outputs.page_url }}
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: 22 }
      - run: npm ci
      - run: npx notabene build --public
          --site https://${{ github.repository_owner }}.github.io
          --base /${{ github.event.repository.name }}
          --out ./_site
      - uses: actions/upload-pages-artifact@v3
        with: { path: ./_site }
      - id: deployment
        uses: actions/deploy-pages@v4

Notes:

  • With publish: { site, base } set in your config, the --site/--base flags can be dropped entirely.
  • Hosting at a custom domain or a user/org root site? Drop --base and set --site to your domain.
  • The artifact ships a .nojekyll, so _astro/ assets survive even classic gh-pages-branch hosting.
  • npm i -D pagefind in your repo and this exact workflow ships full-text search too — npm ci installs it, the build picks it up. Nothing to change here.
  • This documentation site is deployed by exactly this workflow — see it in the repo.