Read this first if you're an AI agent picking up work on the Nimbus codebase. Sister file
CLAUDE.mdmirrors this content — keep them in sync.
Nimbus builds documentation sites on Astro. The architecture splits into three tiers:
- User-owned starter files — visible UI components, layouts, and styling. Copied into the user's repo by
create-nimbus-docsand edited freely from then on. nimbus-docsnpm package — invisible plumbing (data helpers, validation, integration wiring, behavior primitives). Imported, not forked.- Registry — optional components, utilities, and agent-handoff features installed on demand via
nimbus-docs add <slug>.
Cloudflare is a first-class deploy target (the scaffolder defaults to it and ships wrangler.jsonc), but the framework is deploy-target agnostic — static output runs anywhere.
monorepo/
├── packages/
│ ├── nimbus-docs/ framework — integration, helpers, schemas, types, `nimbus` CLI
│ ├── nimbus-starter-source/ canonical source — fat tree; doubles as kitchen-sink dev app
│ │ ├── src/ components, layouts, pages, demo content
│ │ ├── templates/ per-variant content overrides (empty/, …)
│ │ └── starter.manifest.mjs declarative generation policy (registry-only slugs, dev-only paths, variants)
│ └── create-nimbus-docs/ scaffolder (`npx @cloudflare/create-nimbus-docs`) — CLI only, no templates
│ └── scripts/copy-template.mjs generator: canonical source + manifest → variant dirs (--out)
├── apps/
│ └── www/ docs site + registry hosting
│ └── registry/ manifests.ts (source), components/, features/, registry.json
├── examples/
│ └── local/ local sandbox (not drift-mirrored)
├── scripts/
│ ├── release.mjs release orchestration (detect → generate → verify → sync+tag → publish)
│ ├── sync-templates-repo.mjs sync generator output to the orphan templates branch + tag templates-v<version> (idempotent)
│ ├── templates-check.mjs PR CI: generate + scaffold + build
│ ├── check-no-major.mjs / freshness-guard.mjs release guards
│ ├── local.mjs / local-add.mjs local sandbox helpers
├── .generated/ gitignored generator output (templates); scratch for local/CI/release
├── pnpm-workspace.yaml
└── tsconfig.base.json
pnpm -r build # build all packages and apps
pnpm --filter nimbus-docs build # framework only
pnpm --filter nimbus-docs typecheck # tsc --noEmit
pnpm --filter nimbus-starter-source build # build the canonical source (kitchen-sink)
pnpm --filter nimbus-starter-source dev # run kitchen-sink dev server (every component visible)
pnpm dev # alias for the above
pnpm build:templates # generate template variants into .generated/templates
pnpm templates:check # generate + scaffold + build one variant (CI runs on relevant PRs)
pnpm local # spin up the local sandbox (generates + scaffolds offline)Root build runs at default concurrency; pnpm -r topo order builds nimbus-docs first. apps/www's build no longer builds nimbus-docs, so a bare pnpm --filter @nimbus/www build on a clean checkout fails — deploy via pnpm run deploy (its predeploy builds the framework) or root pnpm build.
The architecture splits into three tiers, one test per tier:
| Tier | Lives in | Test |
|---|---|---|
| Framework | packages/nimbus-docs/ |
"If I edit this, am I changing taste or fixing a bug?" Bug = framework. |
| Starter source | packages/nimbus-starter-source/ |
"Do edits change Tailwind classes or layout, or do they change call signatures?" Tailwind/layout = starter source. |
| Registry | apps/www/registry/ |
"Does every docs site need this on day 1?" No = registry, install via nimbus-docs add. |
When in doubt, default to framework; the starter should grow slowly.
The sharper cut — when a test above is ambiguous, or a single file mixes both: one correct answer → framework; a user might reasonably want a different answer → their repo. A scroll-spy's active-heading math has exactly one right answer (framework); the rail's look does not (starter). When a .client.ts inlines both mechanism and taste, split it at that line — expose the mechanism as a nimbus-docs/client primitive and keep the taste in the user file — rather than filing the whole file under one tier. tabs.client.ts (thin glue over initTabs) is the target shape; a fat, all-mechanism .client.ts in the starter is the smell.
Drift discipline: canonical source → generator → orphan branch → tagged. Hand-edits happen in one place, packages/nimbus-starter-source/. The generator (packages/create-nimbus-docs/scripts/copy-template.mjs) emits one directory per variant from that source plus the manifest. The CLI tarball carries no templates; distribution lives in this repo — the variants live on an orphan templates branch (no shared history with main), synced and tagged templates-v<create-nimbus-docs version> by the release job. At scaffold time create-nimbus-docs fetches its matching tag via giget (github:cloudflare/nimbus/<variant>#templates-v<version>); the tag's tree is templates-only, so the tarball stays small even though the repo also holds all of main. A starter edit therefore still produces a diff touching only packages/nimbus-starter-source/**; the templates branch is sync output, never hand-edited (a branch ruleset rejects human pushes, and templates-v* tags are immutable for everyone — including the bot).
The scaffolder never fetches a branch — every fetch is pinned to #templates-v<own version>, so create-nimbus-docs@0.2.0 fetches templates tagged templates-v0.2.0, reproducibly. --template-dir <path> bypasses the network entirely (offline dev, and how pnpm local works).
The generation policy is declarative — packages/nimbus-starter-source/starter.manifest.mjs declares:
registryOnlyComponents— UI slugs present in the fat tree but stripped from generated templates; users install on demand vianimbus-docs add <slug>.devOnlyPaths— path prefixes stripped from generated templates.templates— one entry per variant, with its content override.template/reuses the canonicalsrc/content/docs/;template-empty/swaps intemplates/empty/content/docs/. Adding a variant is one manifest entry + one content dir — the generator iterates this map.
Workflow when editing:
# 1. Make the edit in packages/nimbus-starter-source/
# 2. Generate the variants (into .generated/templates)
pnpm build:templates
# 3. Generate + scaffold + build one variant end to end (CI runs this on relevant PRs)
pnpm templates:check
# 4. Record a create-nimbus-docs changeset — a starter edit reaches users ONLY
# through a CLI release that re-syncs + re-tags the templates branch. The
# freshness guard fails the PR without it.
pnpm changesetexamples/local/ is a sandbox — scaffolded by pnpm local, not part of template generation. .generated/ is gitignored scratch.
| File | What it does |
|---|---|
packages/nimbus-docs/src/integration.ts |
Astro integration entry — wires MDX, sitemap, Sätteri, MDX validator, Pagefind hook, virtual config module |
packages/nimbus-docs/src/index.ts |
Public API — data helpers (getSidebar, getPrevNext, getTOC, getBreadcrumbs, getEditUrl), page composition helpers (getDocsStaticPaths, getDocsPageProps), defineConfig, renderEntryAsMarkdown |
packages/nimbus-docs/src/types.ts |
Public types — NimbusConfig, SidebarItem, etc. Imports must come from nimbus-docs/types (never from main entry) |
packages/nimbus-docs/src/schemas.ts |
Content-collection schemas — docsSchema, partialsSchema, defineDocSchema |
packages/nimbus-docs/src/content.ts |
docsCollection(), partialsCollection() factories |
packages/nimbus-docs/src/_internal/validate.ts |
Zod config validation — content-author-friendly errors, offending-value echo, editPattern {path} enforcement |
packages/nimbus-docs/src/_internal/validate-mdx-content.ts |
Pre-build MDX PascalCase validator (content pass; see Sätteri note below) |
packages/nimbus-docs/src/_internal/parse-components-registry.ts |
Parses user's src/components.ts for the MDX globals registry |
packages/nimbus-docs/src/_internal/sidebar.ts |
Sidebar tree building, cross-collection refs, sidebarHash |
packages/nimbus-starter-source/src/components.ts |
User-side MDX globals registry — parsed by validator at build time |
packages/nimbus-starter-source/starter.manifest.mjs |
Declarative generation policy (registry-only slugs, dev-only paths, template variants) |
packages/create-nimbus-docs/scripts/copy-template.mjs |
Generator — canonical source + manifest → variant dirs (--out, or generateTemplates()) |
packages/create-nimbus-docs/src/scaffold.ts |
Scaffolder — giget fetch pinned to #templates-v<version>, plus the --template-dir offline path |
apps/www/registry/manifests.ts |
Registry source of truth — 33 component/utility/feature entries |
scripts/release.mjs |
Release orchestration — detect → generate → verify → sync+tag → publish (the changesets publish command) |
scripts/sync-templates-repo.mjs |
Idempotent sync of generator output to the orphan templates branch + templates-v<version> tag |
scripts/templates-check.mjs |
PR CI — generate + scaffold + build a variant |
scripts/check-no-major.mjs, scripts/freshness-guard.mjs |
Release guards (no unattended 1.0.0; CLI changeset required when templates change) |
scripts/local.mjs, scripts/local-add.mjs |
Local sandbox helpers |
The integration sets markdown.processor = satteri() (Rust-based, fast) instead of unified. Consequence: remark plugins attached via mdx({ remarkPlugins }) silently no-op. The MDX validator hit this and now runs as a pre-build content pass at astro:config:setup (see validate-mdx-content.ts for the pattern).
If you need framework-side validation/transformation, use the content-pass pattern, not remark plugins. User-facing remark plugins (Mermaid, diagrams, math, custom callouts) are not currently supported.
Short imperative phrases, sentence case. Examples from git log:
- Ship MDX PascalCase validator and small polish improvements
- Add starter polish utilities
- Refresh registry output and sidebar collection docs
- Keep static markdown route in Cloudflare scaffolds
No conventional-commit prefixes (no feat:/fix:). Each commit targets one cohesive change. Use a body paragraph to explain why when non-obvious.
When committing inside a session where other unrelated WIP exists (the user's working tree may have in-progress changes), use git commit --only -- <paths> to commit only the listed files without disturbing other staged work.
Releases are automated with Changesets. nimbus-docs and create-nimbus-docs version independently; the private packages (nimbus-starter-source, @nimbus/www) are never versioned or published.
- In your PR, record user-facing changes with a changeset — this is the only human step, no hand-editing of
versionfields orCHANGELOG.md:
pnpm changeset # pick the package(s) + bump, write a summaryCommit the generated .changeset/*.md file alongside your change.
- On merge to
main,.github/workflows/release.ymlopens or updates a "chore: bump package versions" PR (branchchangeset-release/main) that applies the pending changesets (bumps versions, writes each package'sCHANGELOG.md). - Merging that PR runs
scripts/release.mjs publish, which: detects what's in the release, generates + verifies the templates against the exactnimbus-docsbits, syncs + tags the orphantemplatesbranch (templates-v<version>) before publishing, publishesnimbus-docsbefore the CLI (so a live CLI never pins an unpublished dep), then dispatches the in-repo verify smoke — all unattended, with npm provenance. - A half-failed release reruns safely. If npm succeeds before package tags are created, create the missing tags manually at the original release commit; the release script refuses to backfill them at a newer commit.
- Hard requirement: the monorepo must be public before the first release. Unauthenticated giget scaffolds only work against a public repo, and this repo can't expose templates without exposing source. Until the flip, scaffolds need
GIGET_AUTH. Run a full-history secret scan before going public. Thetemplatesbranch andtemplates-v*tags are protected by repo rulesets (branch: bot-App-only updates; tag: App-only creation + empty-bypass update/delete, so published tags are immutable for everyone).
The root CHANGELOG.md is frozen; per-release notes live in packages/*/CHANGELOG.md.
If you're picking up a new piece of work:
- This file +
README.md— architecture, the boundary rule, and the build/dev/test workflows - Key files to know (above) and the package source under
packages/nimbus-docs/src/ - Public feature docs under
apps/www/src/content/docs/
- Edits to UI / starter content happen in
packages/nimbus-starter-source/, never on thetemplatesbranch. Thetemplatesbranch is sync output; direct edits are rejected by its branch ruleset and would be clobbered by the next release sync. A starter edit needs acreate-nimbus-docschangeset to reach users (the freshness guard enforces this). - Run the build before claiming work is done.
pnpm --filter nimbus-docs buildfor framework changes,pnpm --filter nimbus-starter-source buildfor end-to-end verification of the canonical source,pnpm templates:checkto confirm the generator + scaffolder + template build still work, andpnpm -r testfor the registry tier-invariant guard. - Don't add
nimbus-docs addrecipes for things that belong in the framework. The boundary test applies to feature placement, not just file placement. - Prefer asking about design intent over inferring it from code. The framework's decisions are explicit and often non-obvious, and the rationale isn't always in the codebase.