notabene
Publish a public site
Guide
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.notabenestore. 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), plusrobots.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
- Configuring
publish—site,base,exclude, with examples. - Keep content private — space / sub-tree / page scoping.
- Domain managed server-side — omit
site, stay origin-agnostic. - Deploy via GitHub Pages — the ready-made workflow.
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-tree — publish.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.
One caveat: links into private content
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
| Surface | With site | Without |
|---|---|---|
llms.txt / llms-full.txt / .md twins | absolute URLs | root-relative paths (agents resolve them against the origin they fetched from) |
hreflang alternates | absolute | path-based |
canonical, og:url, JSON-LD | emitted | not emitted — they only mean something with an origin |
sitemap + robots.txt Sitemap: line | emitted | not 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/--baseflags can be dropped entirely. - Hosting at a custom domain or a user/org root site? Drop
--baseand set--siteto your domain. - The artifact ships a
.nojekyll, so_astro/assets survive even classic gh-pages-branch hosting. npm i -D pagefindin your repo and this exact workflow ships full-text search too —npm ciinstalls it, the build picks it up. Nothing to change here.- This documentation site is deployed by exactly this workflow — see it in the repo.