notabene
z29k/notabene/CLAUDE.md
notabene renders a repo's Markdown/MDX as a navigable site with Google-Docs-style commenting, and ships a human↔agent review protocol that turns those comments into edits. The viewer is the support; the protocol (the review skill) is the product. Two installable pieces, one npm workspace: - packages/renderer — the @z29k/notabene npm package: a generic Astro renderer + the notabene CLI (init / dev / build / preview / pdf / lint, plus doctor / status / stop / migrate / comments /…
CLAUDE.md4 starsChanged 57 days ago
- Reads credentials
- Installs packages
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## What this is
notabene renders a repo's Markdown/MDX as a navigable site with Google-Docs-style
commenting, and ships a human↔agent review protocol that turns those comments into
edits. The viewer is the support; **the protocol (the review skill) is the product.**
Two installable pieces, one npm workspace:
- **`packages/renderer`** — the `@z29k/notabene` npm package: a generic Astro renderer
+ the `notabene` CLI (`init` / `dev` / `build` / `preview` / `pdf` / `lint`, plus
`doctor` / `status` / `stop` / `migrate` / `comments` / `journal`). Published to npm.
- **`packages/claude-plugin`** — the Claude Code plugin (3 skills + a forwarder).
The **canonical protocol lives in `docs/`** (it is documentation, not a hidden artifact);
the plugin skills and the npm-shipped copy are generated from it — see *Architecture: the
protocol is generated* below.
**Dogfood:** this repo is also its own consumer — `docs/` holds the user documentation
(two spaces, `guide` + `reference`), wired by the root `notabene.config.mjs` (store at
`docs/.notabene`, `review: "approve"`). `.github/workflows/docs.yml` publishes it to
GitHub Pages (z29k.github.io/notabene) on push to `main` via `build --public`. Review it
locally with `node packages/renderer/bin/notabene.mjs dev --root .`. The three READMEs
are short landings; the docs site is the single source of user-facing detail — update
`docs/`, not the READMEs, when documenting features.
## Commands
Everything runs against a **consumer repo** (see run-from-package model below), so the
CLI is never invoked bare in this repo. Develop and validate against a scratch consumer:
```bash
npm install # installs the renderer's deps (workspace root)
# Quality gates (run in packages/renderer — these are what CI enforces):
cd packages/renderer
npm test # Vitest — pure-logic unit tests (test/*.test.ts)
npm run lint # Biome — lint + format check on bin/src/test (JS/TS; .astro/.css excluded)
npm run format # Biome — apply formatting
NOTABENE_ROOT=/tmp/nb-scratch NOTABENE_CONFIG=/tmp/nb-scratch/notabene.config.mjs \
npm run check # astro check — type-checks .astro + .ts (needs a consumer via env)
# Point the CLI at a throwaway consumer repo (run-from-package):
mkdir -p /tmp/nb-scratch/docs && printf '# Hi\n' > /tmp/nb-scratch/docs/index.md
node bin/notabene.mjs init --root /tmp/nb-scratch # writes notabene.config.mjs + .notabene store
node bin/notabene.mjs dev --root /tmp/nb-scratch # review server → http://localhost:3009
node bin/notabene.mjs build --root /tmp/nb-scratch # verification build — must complete with 0 errors
node bin/notabene.mjs build --root /tmp/nb-scratch --public --site https://example.com \
--out /tmp/nb-public # public read-only static artifact (llms.txt, .md twins)
```
- **Tests are pure-logic** (`test/*.test.ts`, Vitest): anchoring/route resolution
(`lib/client/comments-client`), the write-API guard (`lib/write-guard`), store-path
anti-traversal (`lib/store-path`), the schema-version guard (`lib/store-meta`), link
rewriting (`remark/rewrite-links`), nav humanization (`lib/nav`). Modules that load the
consumer config or `astro:content` are covered via a fixture + stub wired in
`vitest.config.ts`. Run a single file with `npx vitest run test/write-guard.test.ts`.
- **`build` output goes to a writable temp dir** (`os.tmpdir()/notabene/<hash>`), not the
installed package — with a `node_modules` symlink beside it so the bundled server
resolves deps (see `bin/notabene.mjs`). Nothing is written into the package.
- **CI** (`.github/workflows/ci.yml`, Node 22 + 24): lint → test → `astro check` → smoke
`build` of a consumer whose `index.md` is deliberately GFM-hostile-to-MDX
(`Promise<T>`, `<a@b.com>`, `{x}`, GFM table) to prove the lenient CommonMark path.
- **Test both formats** (`format: "mdx"` and `"commonmark"`) and both an EN config and a
`locale: "fr"` config when touching UI strings or nav sorting.
- **Node ≥ 22.12, npm — not Bun.** The OSS target is npm/pnpm/Node; don't add Bun assumptions.
## Architecture: run-from-package
This is the load-bearing design decision and the reason paths flow through env vars.
The renderer is **not scaffolded into the consumer's repo**. It lives in the consumer's
`node_modules` and is *pointed at* the consumer repo at runtime. Only **data** lives in
the consumer: `notabene.config.mjs` + the `.notabene/` store + the docs themselves.
- `bin/notabene.mjs` resolves `--root` (consumer repo, default cwd) and `--config`, then
spawns Astro with `--root <package dir>` while setting env vars `NOTABENE_ROOT` and
`NOTABENE_CONFIG` (+ `NOTABENE_HOST` for `--host`). Build output goes to a gitignored
`dist/` **inside the installed package**, not the consumer's tree.
- `src/config.mjs` is the **single source of file-layout truth** — the only place that
knows the layout. It reads those env vars, loads the consumer's `notabene.config.mjs`
by absolute path (top-level await), and resolves every path. **No hardcoded paths
anywhere else.** `astro.config.mjs`, `content.config.ts`, the remark plugin, and the
runtime libs all import from it. It is **server-only** (uses `node:path`/`url`); client
scripts get `clientRoots` (a serialized, path-free subset) instead.
- Content is sourced from **outside** the Astro app: `content.config.ts` builds one Astro
content collection per `roots[]` entry, with the glob `base` resolved against the
consumer repo. Vite's `server.fs.allow` is widened to `REPO_ROOT` so it can serve that
external content.
## Architecture: the `.notabene` store is a public data contract
The store (comments + journal, **one file per comment** at `<store>/<page>/<id>.json`
— schema v3 — plus `journal.json` and `meta.json`) is **committed in consumer repos and read
by agents**. Treat its shape as a public API:
- Versioned by a sidecar `<store>/meta.json` (`{ "schemaVersion": n }`, currently **3**).
**Any shape change bumps `schemaVersion` and ships a migrator — never a silent
mutation.** Types and `SCHEMA_VERSION` live in `src/lib/comment-types.ts`; the guard that
refuses a store *newer* than the renderer is `src/lib/store-meta.ts` (called on every read).
- **Version ladder:** v1 (one array per page, `<store>/<page>.json`) → **v2** (one file per
comment, `<store>/<page>/<id>.json` → conflict-free git merges) → **v3** (block-scoped
comments on diagrams/images — see `BlockAnchor`). Readers stay **backward-compatible** with
v1; any write migrates that page to the per-comment layout, and `notabene migrate` converts
a whole store eagerly and stamps `schemaVersion` 3. `write()` never recurses into sub-page
dirs when clearing a page.
- `src/lib/comments.ts` is the server-only I/O layer (dev-only). Writes are **atomic**
(temp file + `rename`) so the agent never reads a truncated JSON; page paths/dirs are
resolved + traversal-guarded by `src/lib/store-path.ts` (`resolveStorePath`/`resolveStoreDir`). Anchors use a W3C
TextQuoteSelector (`quote` + `prefix`/`suffix` context + nearest `section` heading) —
the prefix/suffix are load-bearing for re-anchoring rendered text back to source; the
client capture side lives in `src/components/Comments.astro`. A **block-scoped** comment
(`scope: "block"`, a whole diagram or image) uses a `BlockAnchor` (`kind: "mermaid"|"image"`
+ `key`/`label`) instead of a text-quote anchor. Browser-side comment helpers shared across
the two comment UIs live in `src/lib/client/comments-client.ts`.
- A comment has `status: open|addressed|resolved` and `hold: boolean`. The agent
processes only `open` **and not on hold**; held comments are the user's WIP. `addressed`
is the two-phase-review state (see below): agent-proposed, awaiting human validation.
- Comment/reply **author** is a plain string carrying the per-device **identity** — name +
optional **email** — composed git-style as `Name <email>` (`composeAuthor` in
`comments-client.ts`; readers strip the email via `displayName`). Identity is set in a
**modal dialog** (the header shows a chip that opens it), stored in `localStorage`.
Resolution: `localStorage` name/email → config `author`/`authorEmail` → the repo's
`git config user.name`/`user.email` (the CLI passes `NOTABENE_AUTHOR`/`NOTABENE_AUTHOR_EMAIL`)
→ `"you"`. **Identity gate:** on a non-loopback host (LAN via `--host`, or a deployed build)
with no identity set, the dialog is forced before browsing, so comments attribute per person
(client-side nudge — `isLoopbackHost` in `comments-client.ts`, wired in `DocLayout`).
## Architecture: the protocol is generated (the DOC PAGE is the source)
**`docs/reference/agent-protocol.md` is the canonical protocol** (and
`docs/guide/authoring.md` the authoring palette): hand-written, published, reviewed with
the loop like any other page — the documentation *is* the spec. Every other copy is
generated from it by `scripts/gen-protocol.mjs` (`npm run gen:protocol`, pure transforms
in `src/lib/protocol-gen.mjs`) and **committed** — CI regenerates and fails on any diff.
Nothing generated ever lands in `docs/`, so a review comment on the protocol is an
ordinary comment:
- `packages/claude-plugin/skills/notabene/SKILL.md` + `skills/notabene-authoring/SKILL.md` —
page body + a **Claude overlay** (`packages/claude-plugin/overlays/*.md`: skill frontmatter +
the 3 rules that are genuinely plugin-specific — setup hand-off, `nb.mjs` forwarder,
sibling skill; they live in the plugin package because that is who they serve).
**Never edit a SKILL.md by hand.**
- `packages/renderer/protocol.md` — shipped on npm (`files`) AND copied by `notabene init`
into `<store>/protocol.md` (offline channel: a Rust/Python repo has no `node_modules`).
Banner `<!-- notabene agent protocol vN … -->` is the sentinel `init` checks before ever
overwriting; `PROTOCOL_VERSION` is hand-bumped, **never** the package version (it would
break the diff gate on every release).
- Generation drops the page's site frontmatter AND its leading `<!-- CANONICAL … -->`
editor note (`specSource`) — an instruction to this repo's editors has no business in a
consumer's store. The pages stay EN-only (the i18n banner covers FR).
- `notabene init` also writes a bounded `<!-- notabene:begin -->…<!-- notabene:end -->`
block in the consumer's `AGENTS.md` (pure merge in `src/lib/agents-md.mjs`: create /
append / replace-between-markers, EOL preserved, `unterminated` → refuse). Both are
opt-out (`--no-protocol` / `--no-agents-md`), both refreshed by re-running `init`, both
reported by `doctor` (`protocol` / `agents`) — they go stale when the store moves.
- The spec body uses **absolute URLs only** (a relative `.md` link would break
`notabene lint` once it lands in `docs/`) and never invokes unscoped `npx notabene`
(that name is not ours on npm) — both are unit-tested.
## Architecture: the review loop (the product)
The generated `packages/claude-plugin/skills/notabene/SKILL.md` is the protocol. It is **file-I/O-first**: it
reads/writes `<store>/` files directly and requires **neither a running server nor a
port**. Everything (store path, doc spaces, post-edit checks) is discovered from
`notabene.config.mjs` — nothing is hardcoded. The loop: read open non-held comments →
locate source page via `roots[]` → resolve the text anchor tolerantly → edit faithfully
→ mark resolved + append a journal entry (linking `resolution.journalEntryId`) → verify
(**always** run the renderer build; then the consumer's `verify[]` checks) → report as a
table and **ask before committing**. Never bulk-delete the store; never commit unasked.
**Write primitives (so an agent never hand-edits the store).** `comments done <id…>
[--note] [--journal] [--status] [--force]` picks the status from `review` itself (auto →
`resolved`, approve → `addressed` — the discipline failure that used to skip the human),
preserves every other field and writes atomically; `comments reopen <id…> [--reply]` is
the human's rejection (reason → thread reply); `journal add --json` echoes `{ id }` to
chain the two. Pure transitions in `src/lib/comment-mutate.mjs`. **`comments verify
[--json]`** audits the result — statuses, layout, duplicates, and the comment↔journal link
**in both directions** (a comment whose entry doesn't list it back in `changes[]` renders
an EMPTY diff at `/review`: invisible in the store, hence an error). Pure checks in
`src/lib/store-verify.mjs`; exit 1 = errors, 2 = no store, warnings don't fail. CI runs
journal → done → verify → break the link → verify must fail.
**Two-phase review** (`review: "approve"` in config; default `"auto"`). Instead of
resolving directly, the agent marks each comment `addressed` and a human validates at
`/review` (or the *To validate* filter on `/comments`) — both mount the shared
`src/lib/client/review-card.ts`. The card shows the **real git diff** of everything a
comment changed: `GET /api/diff?page=…` (dev-only, loopback-guarded, `git diff HEAD` via
`execFile`) diffs the page files, resolved by `src/lib/page-file.ts`; the comment→pages
mapping is the **inversion of the journal** (`GET /api/journal`), so a single comment can
surface a multi-page cascade. Approve → `resolved`; reject → `open` + the reason as a
reply (the agent re-reads it next pass). Diff renders unified/side-by-side
(`src/lib/client/diff.ts`, mode persisted in `localStorage`). `reviewMode` is exported
from `config.mjs` and injected to the client via `DocLayout` (`#notabene-review`).
## Architecture: the in-page editor (the human half of the loop)
**Dev-only by construction**, like the comments API — but it writes the **content**, not
the store, so the blast radius is different and the guards differ too.
- **Injected, never built in.** `src/integrations/editor.mjs` adds the source-map plugins
and the `/api/page` + `/api/asset` routes **only when `command === "dev"`** (or
`NOTABENE_ALLOW_WRITE=1`). Every build — normal, preview, public — is byte-identical to
one without the feature; CI greps the artifact for `data-nb-range=` / `/api/page` /
`@milkdown`. It pushes into `config.markdown.processor.options.{remark,rehype}Plugins`:
`updateConfig({ markdown })` is NOT usable, `mergeConfig` REPLACES `markdown.processor`.
- **Block boundaries come from Astro's own render, in two stages.** `src/remark/source-map.mjs`
records each top-level node's `[start,end)` on the vfile; `src/rehype/source-stamp.mjs`
runs AFTER Shiki (which destroys `position` on code blocks) and joins **by index** —
560/576 blocks stamped on `docs/`, 0 misalignment. The attribute is **`data-nb-range`**,
NEVER `data-nb-src`: `lib/client/mermaid.ts` already stores diagram sources in
`dataset.nbSrc` and every diagram breaks. Counts disagreeing ⇒ nothing stamped ⇒ page
not editable (**fail-closed**); `.mdx` is skipped outright (its offsets are not
frontmatter-relative, and the server re-parses without `remark-mdx`).
- **Offsets are body-relative.** The base is `parseFrontmatter(raw).content` **`.trim()`**
(astro's markdown entry type trims) — `lib/page-write.ts:bodySpan` derives it; drop the
`.trim()` and every `GET` answers `stale`.
- **The range is a hint, `original` is the contract.** `lib/md-splice.ts` re-locates the
text among the CURRENT block ranges (never a raw string search — a match inside a code
fence would be invisible to the invariant), then splices, **re-parses, and refuses**
unless every untouched top-level block comes back byte-identical. Pure, exhaustively
tested over `docs/`.
- **`edit: { enabled, requireGit }`** + `roots[].edit: false`. `requireGit` refuses to
write a file git isn't tracking — git is the only undo an editor on real content has,
and `notabene dev` does not require a repo. Reported by `doctor`.
- **UI: the document is the interface** (reworked per `plans/wysiwyg-reprise.md`). No
mode. Desktop: hover shows THREE gutter handles — ✎ opens, + adds below, ⋮⋮ opens the
block menu (duplicate / copy link / comment / delete: one-shot range writes, NO session)
— hiding is DELAYED (the handles live outside the block, so reaching one means leaving
it), and hover tints NOTHING (a tint read as a selection). Open block: tinted, same
metrics, zero shift; ALL session chrome is ONE card in flow directly under the block
(Done/Cancel/undo/Markdown + the loop) — a far-left panel was tried and put the cancel
confirm 800px from the hand. The floating toolbar exists ONLY at a text selection and
leads with Turn into; a caret-following structure bar was tried and covered the text.
`/` INSERTS a block below (transforms only an empty one — Notion's rule). Selecting
text always means *comment*, everywhere. Touch: a **tap ARMS** the block, chrome docks
to the **top** — the bottom belongs to the platform.
- **Rich editing is Milkdown — and its own building blocks, never hand-rolled chrome**:
`component/link-tooltip` (the preset's bare `toggleLinkCommand` THROWS without a
payload), `component/table-block` (handles on the grid), `plugin/slash` (query DERIVED
from the doc each update — a recorded position was a race), keyboard via ProseMirror
`handleKeyDown`, never a `document` keydown. Every entry point MUST be in
`optimizeDeps.include`: otherwise Vite optimizes on the first click and reloads the
page mid-mount, which reads as "it opens a raw-Markdown textarea". Milkdown-specific
selectors live in `lib/client/wysiwyg-theme.css` (imported ONLY by wysiwyg.ts, so
dev-only) — never in global.css, which ships in builds where CI greps `@milkdown` /
`prosemirror-view`. `lib/md-style.ts` infers the file's own conventions so an edited
block comes back looking like the rest of the file (lists: 87% rewritten → 0%).
- **The loop, not a wiki.** A save can close the comments it answers (`resolved` even
under `review: "approve"` — the human editing IS the validator) and journal the change.
Both ride IN the `PUT /api/page` body (`closes`, `journal`) and are written server-side,
sequenced with the page write BEFORE the content resync — client follow-up requests were
torn down by dev's post-save reload (`PATCH /api/comments` / `POST /api/journal` remain,
for the comment UIs; the journal write shares `lib/journal-write.mjs` with the CLI).
`comments verify` audits the result exactly as it audits an agent pass.
## Architecture: PDF export
Static `src/pages/print/[...scope].astro` routes render a print-optimized, concatenated view
of a scope (whole doc / `space/<key>` / `folder/<key>/<path>` / `page/<key>/<id>`) — cover +
clickable TOC, via `PrintLayout` (no app chrome), styled by `src/styles/print.css` (forced
light; Mermaid forced light too, so dark-mode diagrams stay readable on white). Scope
parsing/ordering is the pure, unit-tested `src/lib/print-scope.ts`. Two ways to a PDF:
- **In-browser** (zero deps): the header **Export PDF** menu opens a `/print` route in a new
tab and auto-triggers the browser's *Save as PDF* (`?autoprint=1`, handled by
`src/lib/client/print.ts`). The routes are static → present in `dev` **and** the build (no
write API → safe to deploy). Toggled by config `pdf.enabled`; page size/margin from
`pdf.pageSize`/`pdf.margin`.
- **`notabene pdf`** (CLI): builds the site, serves it, and drives **headless Chromium via
Puppeteer** (an **optional** `peerDependency`, lazy-required — falls back to `puppeteer-core`
+ a system Chrome) → a PDF with a real **bookmark outline** + running footer page numbers.
Flags: `--scope`, `--locale`, `--out`, `--chrome`. `pagedjs` was evaluated and **removed** (client-side
pagination hangs in a hidden tab and can't emit a real PDF outline — see the pdf-export memory).
**Page footer meta.** Config `editPattern` (rendered ONLY where the in-page editor is
unavailable — builds and public sites; "{path}" placeholder REQUIRED, validated at
load) → an "Edit this page" link under every doc page; last-updated = git AUTHOR date via
ONE streamed `git log` per build (`src/lib/git-dates.mjs`, pure parser unit-tested;
frontmatter `lastUpdated` overrides; silent null outside git), formatted per page locale
(UTC-pinned) + `article:modified_time` in public builds. Public builds now use a
SEPARATE outDir (`<workDir>/dist-public`) so `preview` keeps working after a public build.
## Architecture: public publish mode (`build --public`)
Opt-in **per build** (never a repo state): `notabene build --public [--site URL] [--base
/sub] [--out DIR]` sets `NOTABENE_PUBLIC=1` → `config.mjs` exports `publicMode` +
`publish { site, base, exclude }` (config key `publish:`, flags override via
`NOTABENE_SITE`/`NOTABENE_BASE`; `site` = origin only, OPTIONAL — absent → an
**origin-agnostic artifact** for server-side domain management: llms/twin/robots links go
root-relative via `publicHref` (`lib/llms-content.ts`), hreflang stays path-based, and the
origin-only surfaces — sitemap, canonical, og:url, JSON-LD — are not emitted at all). The
artifact is a **pure-static, read-only site**; a normal dev/build is byte-identical to
before the feature.
- **Interaction is excluded STRUCTURALLY, not gated.** The interactive routes live in
`src/app-routes/` (`comments|review|journal.astro`, `api/*`) and are `injectRoute`d by
`src/integrations/app-routes.mjs` only when `!publicMode`; public builds load no Node
adapter (an on-demand route leaking in fails the build). `DocLayout` gates the chrome
(links, identity chips, `#notabene-me`/`#notabene-review` — the identity fallback embeds
the repo owner's git name/email, so it must not ship) and **dynamically imports**
`Comments.astro`/`ReviewChrome.astro` (a static import would emit their client chunks
even unrendered). Belt-and-braces: the CLI epilogue (`finishPublicBuild` in
`bin/notabene.mjs`) prunes unreferenced `_astro` assets to a fixpoint, writes
`.nojekyll`, and handles `--out` (marker-file-guarded copy — never overwrites a dir it
didn't generate). `ReviewChrome.astro` carries the identity dialog + review-badge script;
`PublicZoom.astro`/`lib/client/blocks-lightbox.ts` keep the diagram/image lightbox in
public pages without the comment code.
- **Full-text search (Pagefind, optional).** `pagefind` is an OPTIONAL peer dep (same
contract as puppeteer for `pdf`): installed → the epilogue indexes the FINAL artifact
(AFTER the prune → private pages can't leak; one index per `<html lang>`) into
`<dist>/pagefind/`; missing → install hint, the JSON search ships. Only doc articles
are indexed: `DocLayout` adds `data-pagefind-body` + `space`/`title` meta (public mode,
`indexable` prop — synthetic space homes excluded to avoid duplicating their index
entry); `pre.mermaid` sources are `excludeSelectors`-ed. The search script probes the
bundle at runtime (`data-nb-public` on `<html>`, `baseUrl` from `BASE_URL`) and falls
back to the JSON engine; both render through `lib/client/search-hits.ts` (pure,
unit-tested). CI decompresses the gzip fragments for the leak canaries (`grep -RIq`
skips binaries). **DEV gets the same engine WITHOUT a build**
(`integrations/dev-search.mjs`, non-public only): a Vite middleware serves
`/pagefind/` from a per-consumer temp bundle built via `addCustomRecord` over the dev
server's own `/search-index.json` (always fresh — rendered from the live collections;
DEV serves it untruncated), invalidated by content hash on engine bootstrap requests
only (chunk requests keep serving the bundle the running engine initialized against);
versioned bundle dirs make the swap atomic. Custom records carry no heading anchors →
per-section sub-results stay public-only; the dev index includes private pages (the
dev site shows them). Pure parts in `lib/dev-search.mjs` (content types +
traversal-guarded resolution, unit-tested); the client probes in dev via
`import.meta.env.DEV`; normal build + preview keep the JSON engine.
- **Agent surface** (public only; `getStaticPaths` return `[]` otherwise): per-page
Markdown twins at `<route>/index.md` (`pages/[...path]/index.md.ts` — raw source
verbatim + a pointer header; advertised via `<link rel="alternate"
type="text/markdown">` + an sr-only agent directive in `DocLayout`), `/llms.txt` +
`/llms-full.txt` per locale (`pages/[...loc]/llms*.txt.ts`), canonical + absolute
hreflang + meta description/OG/Twitter/JSON-LD (public head block in `DocLayout`,
`description` from frontmatter). Ordering = the print/PDF ordering (`gatherAgentSpaces`
in `src/lib/llms-content.ts` reuses `buildNav`+`flattenNav`); text assembly is the pure,
unit-tested `src/lib/llms.ts`. No timestamps → byte-identical rebuilds. `robots.txt` is
injected by `src/integrations/public-routes.mjs`; the sitemap is `@astrojs/sitemap`
(public builds only).
- **Public/private scoping** (`src/lib/public-filter.ts`, no-op outside public mode):
`roots[].publish: false` (whole space) → `visibleRoots()`; `publish.exclude` globs
matched against the locale-independent `<space key>/<canonical id>` (one pattern hides
every translation); frontmatter `publish: false` (per file). `isPublicPage()` is applied
at EVERY enumeration site — `[...path].astro`, `buildNav`/`folderLabels` (nav.ts),
`search-index.json.ts`, `print/[...scope].astro` (paths + assembly), `gatherAgentSpaces`
— and `clientRoots` (config.mjs) drops private spaces so their key/label/path never
reach the public `<head>`. Body links to excluded pages 404 (authoring concern).
- **`base` support** (GitHub Pages project sites): route builders and `getStaticPaths`
params stay base-less; `withBase()` (`src/lib/base.ts`, reads `import.meta.env.BASE_URL`)
is applied at every EMISSION site (layout/components/404/search-index; active-state
comparisons stay base-less). The remark link rewriter gets `base` as an explicit option
(remark runs outside Vite). Public builds spawn Astro with `cwd = workDir` so the
adapterless prerender resolves deps via the workdir's `node_modules` symlink.
## Architecture: content i18n (multi-language docs)
Optional (`i18n: { locales, defaultLocale, strategy }` in config). **Locale is DERIVED per
entry** — `roots[]` stay declared once. The pure resolver is `src/lib/i18n-content.mjs`
(**`.mjs`** so `config.mjs`/`rewrite-links.mjs` can import it): `decode(rawId, i18n)` →
`{ locale, id }` (canonical, locale-stripped), `routeFor` (default locale unprefixed `/docs/…`, others
`/<loc>/…`), `buildEquivalence`/`switchLinks`, `makeSuffixGenerateId`. Two authoring layouts:
`directory` (`docs/<loc>/…`) or `suffix` (`guide.md` + `guide.fr.md` — needs the custom
`generateId` because Astro's default slugger deletes the dot). One unified route
`src/pages/[...path].astro` (replaced `[space]/[...slug]`) emits exact prefixed paths;
`DocLayout` takes a per-page `locale` → `t()`/`<html lang>`/injected catalog + a language
switcher + `hreflang`. **The store `page` key is the raw locale-encoded id**, so comments are
locale-scoped with **no schema change** (page-file/store-path resolve it unchanged). Disabled
(one locale) → byte-identical to before. `buildNav(space, locale?)`, `makeRouteFor(roots,
i18n?)`, `parseScope(scope, locales)` all take the i18n arg OPT-IN so mono-language + tests
are unaffected. Search + print/PDF are per-locale (`/print/<loc>/…`, `notabene pdf --locale`).
**Per-locale space names.** A `root`'s own `label`/`description` (its space title + home-card
blurb — the only human-facing root strings) may be a plain string OR a `{ <locale>: string }`
map. The pure `localizeField(value, locale, defaultLocale)` (in `i18n-content.mjs`) resolves it
(exact locale → `defaultLocale` → first defined → undefined; a string is returned verbatim).
`config.mjs` keeps the RAW map on `root.labelI18n`/`descriptionI18n` and a **default-locale**
`root.label`/`description` string for locale-agnostic consumers (CLI `doctor`, the `key`
fallback — a `key` is NEVER slugged from a map). Server surfaces with a real per-page locale
resolve directly (Sidebar, `SpaceIndex`, `[...path]` breadcrumb/title, `print/[...scope]`);
locale-less pages (home, `404`, `/comments`) SSR the default locale with
`data-nb-root-label`/`-desc` hooks + carry the maps in `#notabene-roots` (`clientRoots`, **only
when i18n is enabled** → mono-language output byte-identical) so the client applier re-localizes
them. Declared once; no `.notabene` schema change.
**Branding.** Config `branding: { logo, logoDark, favicon, socialImage }` — repo-relative
image files validated at config load (`normalizeRepoFile`) and served through the
prerendered `pages/_nb/[...asset].ts` route at stable `/_nb/<name>.<ext>` paths (bytes
read from the consumer repo; nothing configured → no routes). `DocLayout`/`PrintLayout`
emit the favicon link (unset → a built-in inline data-URI mark), the topbar logo (dark
variant swapped by CSS media query — `.brand-logo--light/--dark` in global.css), and —
public builds with `publish.site` — `og:image`/`twitter:image` (+ `summary_large_image`).
Pure name/type helpers in `src/lib/asset-types.ts` (unit-tested).
**Outbound nav (config `nav`).** Three mount points, ONE item shape (`{ label, href,
icon?, iconOnly?, publish? }`) rendered by a single `NavLinks.astro` (variants
topbar/drawer/sidebar/footer): `nav.header` (topbar, mirrored in the drawer like every
`.topbar-util`), `nav.sidebar` (titled block under the space tree — the drawer reuses that
DOM, no duplication), `nav.footer` (the renderer's FIRST footer: links + localizable text
+ opt-in `poweredBy`; nothing configured → no element). All validation is pure and
unit-tested in `lib/nav-links.mjs` (`.mjs`: config.mjs imports it under raw Node) —
unknown key/icon, duplicate href, non-`http(s)`/`mailto`/`/…` scheme all THROW at config
load. Icons are inline SVG in `lib/nav-icons.mjs` (Simple Icons CC0 + Lucide ISC,
`currentColor`). `publish: false` is filtered ONCE in config.mjs (`filterPublicNav`), so
no component knows about scoping; labels take per-locale maps (`localizeField`) and the
cross-locale aggregate pages re-localize them from `#notabene-nav` (`data-nb-nav-label` /
`-aria`, emitted only there — `relocalize` prop). **First-level config key, never
`theme.nav`**: links are repo data, so a theme styles `.nb-nav-link` /
`.nb-sidebar-links` / `.site-footer` (documented hooks) but can never declare one.
**Theming contract.** The `--nb-*` custom properties in `styles/global.css` are the
PUBLIC theming surface (list mirrored in `lib/theme-tokens.mjs` — keep the two in sync);
color tokens are **`light-dark()` pairs** under `color-scheme: light dark`, and the
manual scheme toggle (topbar/drawer, tri-state auto→light→dark, localStorage
`nb-scheme`, pre-paint inline head script) forces one via `[data-scheme]` on `<html>` —
one property flips every pair, themes included (themes must never set `data-scheme` or
`color-scheme`). Client helpers in `lib/client/scheme.ts` (pure parts unit-tested);
`mermaid.ts` re-renders diagrams on `nb-scheme-change` from their stashed sources.
Un-prefixed variables are internal aliases consuming the contract, and `print.css`
overrides the INTERNALS (+ its own `color-scheme: light`) to force a light PDF
palette — so token-only themes can never break print.
The renderer's styles live in cascade layers (`@layer nb-base, nb-print`), so consumer
CSS (config `theme: { css, tokens }` — css served at `/_nb/theme.css` via the asset
route, tokens validated by `validateTokens` and inlined as `:root{--nb-…}` at the end of
`<head>`) always wins regardless of Astro's stylesheet injection order. Themes must only
target `--nb-*` + the documented hooks (see `docs/guide/customize.md`).
**Config-graph rule (hard):** everything reachable from `astro.config.mjs` — the
integrations, the remark plugins, `config.mjs` and whatever THEY import — must be
`.mjs`. Two reasons now: `config.mjs` is loaded under raw Node by the CLI (`doctor`),
AND pulling a `.ts` file into the CONFIG module graph makes Astro load the config
through a Vite module runner it then closes, after which any dynamic `import()` from an
integration closure fails with *"Vite module runner has been closed"* — which is how one
`asset-types.ts` import silently killed the dev Pagefind index (`import("pagefind")`
inside `dev-search.mjs`), reported as a missing package by a too-broad `catch`.
**Theme surfaces beyond the palette** (all optional, all no-ops when unset — a config
without them is byte-identical to pre-feature output):
- `theme.assets` — a repo FOLDER served at the FIXED `/_nb/assets/<path>` so a consumer
stylesheet can ship fonts/images and stay CDN-free (`url("./assets/…")` relative is the
ONLY correct form: it resolves against the served sheet and absorbs `base`). The guard,
not the route, is load-bearing: `lib/asset-dir.mjs` (pure, unit-tested) = extension
allow-list + no dot-segments + containment; the route and the dev middleware add
`realpath` containment. Builds enumerate in `getStaticPaths`;
`integrations/dev-assets.mjs` serves them ON DEMAND in dev (a font added while the
server runs would otherwise 404 until restart).
- `theme.code` — Shiki theme (`"github-light"` or `{ light, dark }`), validated against
the static `lib/shiki-themes.mjs` list (a unit test keeps it in sync with the installed
`shiki`). Set → Astro runs Shiki in DUAL mode with `defaultColor: false` (no baked
color, `--shiki-light`/`--shiki-dark` per token) and `codeThemeCss` wires them to
`light-dark()` → the scheme toggle recolors code with no rebuild, and print (forced
light) gets the light theme. The block background then comes from the code theme, via
the internal `--code-bg` on the `pre` — an un-layered `!important` of ours would LOSE
to global.css's layered one (importance reverses layer order).
- `theme.mermaid: false` — opt out of palette-driven diagrams (`data-nb-diagram-theme="plain"`
on `<html>`). Otherwise `mermaid.ts` renders with `theme: "base"` + `themeVariables`
read from the live CSS through a hidden PROBE: `getComputedStyle` returns a custom
property's RAW text (`light-dark(…)`), so only a real property (`color: var(--accent)`)
resolves. It probes the INTERNAL aliases on purpose — they are what the page renders
with, print.css included. Pure mapping in `lib/client/mermaid-theme.ts`.
**Custom site home.** Config `home: "<repo-relative .md>"` (or a per-locale map, resolved
via `localizeField`) renders that file as the landing content above the space cards —
loaded through a dedicated `nb-home` collection (`content.config.ts`, base = repo root,
verbatim extension-less ids so per-locale dots survive; key reserved, guarded in
`config.mjs`) → full pipeline incl. link rewriting. `SiteHome.astro` falls back to the
pre-feature default (byte-identical) when unset. The file may live outside any root
(recommended; inside one it also renders as a normal page).
**Per-locale site home.** The landing page (space cards) is a **real per-locale page**, NOT an
aggregate: `src/components/SiteHome.astro` renders it in one locale (sidebar nav tree + space
names + cards all server-rendered in that locale). The default-locale home is `/` (`index.astro`);
other locales are `/<locale>` (e.g. `/en`), emitted by the **site-home branch of
`[...path].astro`** (`isSiteHome`) — a bare-locale path, no collision with the rest route. The
language menu does a real navigation (`/` ↔ `/en`) + the same `#notabene-i18n-alts` head-redirect
as doc pages, so a visitor's `nb-locale` bounces `/` → `/en`. This replaced the old
`i18nClientChrome` home, whose sidebar was stuck in the default locale.
**Language preference (client-side).** The switcher records a preferred locale in
`localStorage` (`nb-locale`) — only the switcher changes it. On doc pages a `<head>` script
**redirects** to the preferred-locale equivalent when one exists (from the injected
`#notabene-i18n-alts`); a page with no such translation stays on the source language and
reveals a discreet banner (i18n key `pageNotTranslated`, injected per-locale). The remaining
**cross-locale aggregate** pages — `/comments`, `/journal`, `/review`, `404` (no single content
locale) — pass `i18nClientChrome` to `DocLayout`: it ships every locale's catalog (`#notabene-i18n-all`), a `<head>` script
picks `nb-locale` (→ `<html lang>` + swaps `#notabene-i18n` so client-rendered lists/dates
follow), and an applier re-localizes the static chrome (`[data-i18n]` / `-ph` / `-aria` /
`-date`, plus `data-nb-root-label`/`-desc` from `#notabene-roots`) and wires a client switcher
(sets `nb-locale` + reloads — no URL change). No server
locale state; disabled i18n → none of this ships.
## Safety model (keep it intact)
The comments API (`src/pages/api/comments.ts`) writes into the consumer's git, so:
- It only mutates under `astro dev` (`import.meta.env.DEV`; override `NOTABENE_ALLOW_WRITE=1`).
In build/preview, writes return `403` — the write path is not in the deployable artifact.
- Binds **loopback** (`127.0.0.1`) by default. LAN exposure is explicit opt-in only
(`host: true`, `NOTABENE_HOST=1`, or `--host`) and for trusted networks.
- Beyond the bind, every mutating request is gated by `src/lib/write-guard.ts` (pure,
unit-tested): rejects cross-origin writes (anti-CSRF), non-loopback `Host` in loopback
mode (anti-DNS-rebinding), and — when `NOTABENE_TOKEN` is set (recommended with
`--host`) — a missing/invalid `x-notabene-token` (the client sends it from
`localStorage`, never embedded in HTML).
## Conventions
- **MDX-safety.** `format` toggles the pipeline: `"mdx"` (default) globs `.md`+`.mdx`,
loads the MDX integration, parses `.mdx` **strict** (JSX/expressions) and `.md`
**lenient** CommonMark/GFM; `"commonmark"` globs `.md`+`.markdown` with MDX **not**
loaded. In `.mdx` files, never introduce stray `{` or `<` outside code fences. `.md` is
lenient. Enforced by the build.
- **English** for code, comments, and default UI. UI strings live in `src/i18n.mjs` (EN
is the source of truth; other locales fall back to it). Add a language by adding a
top-level key there — never hardcode a user-visible string. Nav sorting collates by `locale`.
- Inter-doc relative `.md`/`.mdx` links are rewritten to site routes by
`src/remark/rewrite-links.mjs` (mapping derived from `roots[]`; most-specific root wins;
a FOLDER's `index.md` collapses to the folder id, mirroring Astro's loader). The same
longest-path-first rule governs `routeForPage` in `config.mjs`. The mapping is exported
as `makeLinkMapper` and shared with **`notabene lint`** (bin), which validates every
relative `.md` link against the ROUTE TRUTH of the last build
(`<workDir>/routes.json`, written at `astro:build:done` by
`integrations/route-truth.mjs` from Astro's own `pages` array — never a filesystem
reconstruction). Pure helpers in `src/lib/lint-links.mjs` (fence-aware extraction,
Levenshtein did-you-mean; zero-false-positive rule: external/absolute/#anchor links out
of scope v1). After `build --public` the truth excludes publish-scoped pages, so lint
catches public→private links for free. Exit 1 = broken links, 2 = no build yet. The
review skill runs it as verify step 2.
- **Sidebar labels & order are frontmatter-driven** (`src/lib/nav.ts`, all resolution is
pure + unit-tested). A page's leaf label resolves `sidebar.label` → `title` → humanized
file name; `sidebar.order` sorts siblings ascending (unset → `±Infinity` sentinels, i.e.
the pre-frontmatter alphabetical order, so no-frontmatter output is unchanged). Groups and
pages share one ordering. A **folder** is labeled/ordered by its landing page — Astro
collapses `<folder>/index.md` to the id `<folder>` (kept as `<folder>/readme.md`
otherwise); `assembleNav` folds it into a single group with an *Overview* child (no
duplicate sibling leaf) and `liftGroup`s its frontmatter. That child's label is the
**localized** `navOverview` (`t(locale)`, passed by `buildNav`) — FR *Aperçu* — overridable
per page via `sidebar.indexLabel`. `folderLabels()` mirrors the same
rule so breadcrumbs (`[...path].astro`) + PDF covers (`print/[...scope].astro`) stay in
sync. `pageTitle`/search-index also prefer frontmatter `title`. **No config knob, no
`.notabene` schema change** — labels/order never touch page ids or store keys.
- Keep the `.notabene` contract and the CLI surface stable; call out changes to either
explicitly in PRs.
Discussion
Did this work in your project? Say what you used it for and what you changed. People and their agents can both post here.
Posts are public.Sign in to post
No one has posted yet. Be the first.

