RELEASE.md described six ways to build an image and the pin that made one live, and none of that decides what docs.hanzo.ai serves: the host is a route onto s3://hanzo-sites/hanzo/docs and the publish is the deploy. It is one lane now, so the runbook is one page: what fires it, what the job proves before it publishes, how to check the live site, and which parts of the image lane are still here and cold. Co-authored-by: Hanzo Dev <[email protected]>
22 KiB
Hanzo Docs Framework
Fork of Hanzo Docs with all packages renamed to @hanzo/docs-* namespace.
How this ships
Runbook: RELEASE.md. One lane:
push -> github.com/hanzo-docs/docs origin; GitHub Actions is OFF here
-> git.hanzo.ai/hanzoai/docs a pull mirror, 10-minute interval
.github/workflows/deploy.yml the forge reads this directory natively
-> pnpm build --filter=docs NEXT_EXPORT=1 -> apps/docs/out
-> hanzoai/ci .github/actions/site Sites project `docs`
-> s3://hanzo-sites/hanzo/docs what docs.hanzo.ai serves
The host is a route, not a workload: Host(docs.hanzo.ai) -> Middleware
docs-static -> that S3 prefix, declared in hanzoai/universe
charts/app/values/hanzo/static-sites.yaml. Nothing is pinned and nothing rolls;
the publish IS the deploy.
The other workflows in .github/workflows/ run on the same forge: lint.yml and
test.yml on pull requests, release.yml on push (the npm packages),
sync-zen-pricing.yml daily, deploy-cloud.yml for apps/cloud (Sites project
hanzo-cloud), and eight deploy-*-docs.yml that still wrangler pages deploy
their app. Each wrangler one is the ONLY deploy of its host, so it stays until
that host has a Sites project: give the app output: 'export', publish a slug,
move the DNS, delete the workflow.
The image lane is cold. ghcr.io/hanzoai/docs, the root Dockerfile,
hanzo.yml's images: and the docs app in universe served this host before
the Sites plane did. The pod still runs and no router names it. Building or
pinning it publishes nothing.
The export gate is in scripts/check-export.sh, which the deploy runs on
apps/docs/out and the Dockerfile runs in its build stage. This site fails by
exporting nothing — a valid layer, a valid image, a 404 — and apps/<app>/ export.require names the sections that must not silently vanish (docs/studio/
is a submodule; a checkout that does not recurse drops it while page count and
nav stay green).
Canonical model — the one way to do docs
Full ADR: apps/docs/content/docs/contributing/docs-architecture.mdx (rendered
at docs.hanzo.ai/docs/contributing/docs-architecture). Summary:
- docs.hanzo.ai is the hub. ONE Fumadocs build (
apps/docs) → ONE Sites projectdocs. The federated per-section deploy is retired (stale origins 530'd). Do not reintroduce per-section builds. - IA is two levels of separator, and one word each. The ROOT sidebar
(
content/docs/meta.json) is nine sections a developer walks in order — Get started · Concepts · Capabilities · Reference · Build with · Surfaces · Programs · Integrate · Open source. Inside Capabilities (content/docs/openapi/meta.json, generated) is the second level: the nine DOMAINS — Identity & Trust · Intelligence · Data · Streams · Observability · Commerce · Platform · Applications · Chain — fromapps/docs/openapi-specs/ capabilities.yaml, which is tracked HERE and is the one editorial decision left in this repo. Both levels are icon-bearing separators (---[Icon]Name---, resolved bylucideIconsPlugin), NOT folder moves — files stay flat so URLs/openapi back-links don't break. Counts are never written twice. How many capabilities there are is a fact about cloud's document, printed by the generator on/docs/openapi; authored prose links to it rather than restating a number that goes stale the next time cloud ships.check-capabilitiesis what holds the taxonomy, the document and the pages to one set. No stutter. A folder's ownindex.mdxis already its landing page — the tree builder resolves it before it readspages. Namingindexinpagesdemotes the page to a child of the folder carrying the folder's own name, so the sidebar readsAgents > Agents; 191 folders named it and 188 of them carried the same title, so that is how many rows said it twice. Same rule one level down: a folder wrapping exactly ONE page is a folder that should not exist, which is why a single-tool MCP product is published as a page and not a directory. API reference and SDKs are separated surfaces (/docs/openapi,/docs/sdks) reached via the top nav (components/layouts/shared.tsx), deliberately NOT in the root sidebar descent. The landing (app/(landing)/page.tsx— its own route group, because it carries the docs chrome fromcomponents/layouts/docs.tsxwhile(home)keeps the marketing chrome for /blog) + docs home (content/docs/index.mdx) lead with the decentralized spine ("the AI cloud you can run yourself");content/docs/network.mdx= self-host/hanzo.network,content/docs/architecture/philosophy.mdx= the engineering pedagogy. Landing breadth stats trace to real sources: the domains fromcapabilities.yamland the capability count from cloud's document, 157 models (ai/conf/models.yaml), 706 connectors (cloud/clients/automations/catalog/ catalog.json), 6 SDK langs (openapi/CLAUDE.md). Count from data, never invent. - One canonical home per doc. Content enters the single build via exactly one
of three orthogonal mechanisms, chosen by kind:
- Authored (first-party prose) → MDX under
content/docs/<section>/. Small/core sections live in-repo; team-owned sections live in ahanzo-docs/<team>content repo mounted as a git submodule atcontent/docs/<team>/. Exemplar:hanzo-docs/studio-docs→content/docs/studio/. - Generated (never hand-written,
.gitignored) → API reference fromhanzoai/cloud'sopenapi.yamlat the releaseopenapi-specs/.spec-lockpins, viascripts/sync-openapi.sh+scripts/gen-openapi-pages.ts(source-derived: an endpoint's sentence is written next to its handler and travels here unaltered); SDK reference from the ZAP SDK generator intocontent/docs/sdks/<lang>/. Every reader of the document goes throughscripts/openapi-doc.ts, which owns the path — no script names the file. A generated MDX fragment is the third shape here, for a fact an authored page needs mid-sentence:scripts/gen-key-types.tswritesgenerated/key-types.mdxandcontent/docs/api-keys.mdxpulls it in with<include>. It sits outsidecontent/because source.config globs**/*.mdxthere and would give a fragment its own URL — and the path is relative to the INCLUDING file, so fromcontent/docs/it is../../generated/. One directory short renders the page body empty with the frontmatter and chrome intact, which looks like a working page. - Ported (upstream OSS, mirrored with attribution, tracked) →
content/docs/projects/<upstream>/. Port, don't re-author. Carry upstream LICENSE + NOTICE. GPL stays GPL. This is a committed snapshot, not build output:scripts/sync-project-docs.tsrefreshes it on demand (pnpm --filter docs sync:projects) and you read the diff before it ships, the same contractopenapi-specs/has. It used to be BOTH — gitignored and re-mirrored by every build, so a build rewrote 965 committed files, put 298 repos' docs on disk that production never had, and died on 54 imports (@site/...) no alias resolves. The builder alone skipped the sync, which meant the command a person runs and the command that ships the site compiled different sites.
- Authored (first-party prose) → MDX under
- Repos:
hanzo-docs/docs= framework + hub (canonical;hanzoai/docsredirects here).hanzo-docs/<team>= authored content only, NO framework.hanzoai/cloudopenapi.yaml= the API source of truth;hanzoai/openapi'shanzo.yamlwas a projection of it on its own clock and this repo no longer reads it. All docs live in thehanzo-docsorg: the hub athanzo-docs/docs, each team's content athanzo-docs/<team>. - Standalone vs hub: default is a hub section. Standalone deploy only if ALL of: ≈150+ pages or fast OSS-upstream churn, independent versioning, direct audience. Standalone runs its own copy of this framework; the hub links out, never copies.
- Serving: the Sites plane —
s3://hanzo-sites/hanzo/docs, served by the ingress staticFiles middleware onHost(docs.hanzo.ai). The sibling hosts below are still CF Pageshanzo-docs(token from KMS, never hard-coded). No nginx/caddy, and no pod for a static export.
Known dedup debt (rollout, not done): the apps/*-docs legacy apps
(base-docs, bootnode-docs, bot-docs, cloud, dev-docs, dns-docs, flow, gui-docs,
insights-docs, platform, pulsar-docs, spec, tasks-docs, team, visor, zen-docs,
zt-docs) predate the unified content/docs/ model — migrate their content into
content/docs/<section>/ (or a hanzo-docs/<team> submodule) then archive the
app.
Two build guards, and why prose needs them
scripts/pre-build.ts ends with two checks that fail the build. Both exist for
the same reason: a generated page cannot be wrong about the API, an authored one
can, and a reader cannot tell which kind of page they are on.
check-endpoints.ts— no page may name a/v1/…route onapi.hanzo.aithat the document does not serve.check-keys.ts— no page may spell an API key a way cloud does not mint, andapi-keys.mdxmust teach every class the document carries. Both directions, so renaming, adding or removing a key class in cloud stops the build here instead of publishing a stale page.
The key classes come from Document.keys (openapi-doc.ts), read out of the
/v1/keys prose — cloud's own Go doc comments, lifted by zipdoc. secretKey(doc)
is the one accessor generators use for "the key a server presents"; three of them
had it as a literal, which is how three GENERATED pages came to teach hk-.
What was wrong: docs.hanzo.ai documented three key types — hk- "API Key",
sk- "Secret Key", hz- "Widget Key". Cloud mints two (cloud.APIKeyPrefixes:
pk-, sk-) and refuses anything else, so a reader's first call failed asking
for a key nobody can issue. hk-/hz- were on 149 files. Note the shape of the
mistake: sk- was present and described wrongly (as an org-level provider
credential; it resolves to the USER), so a find-and-replace would have left the
page confidently wrong. Cloud's apps/platform/secretshape.go still lists hk-
in its secret-DETECTION table — harmless, it only scans, but it is where the
invention came from.
Branch Convention
main— Production branch. A push here builds the export and publishes it to the Sites plane, so landing onmainIS publishing (RELEASE.md). All Hanzo work lands here.dev— Tracks upstreamHanzo Docs/dev. Used for upstream sync merges only.upstreamremote — points tohanzoai/docs
Architecture
pnpm workspace monorepo with turbo. Two apps, 24 packages, 24 examples.
~/work/hanzo/docs/
├── apps/
│ ├── docs/ # Main docs site (hanzoai.github.io/docs)
│ └── zap-docs/ # Zap protocol docs
├── packages/
│ ├── core/ # @hanzo/docs-core - source loading, search, i18n
│ ├── mdx/ # @hanzo/docs-mdx - MDX processing, collections
│ ├── base-ui/ # @hanzo/docs-base-ui - headless UI (@base-ui/react)
│ ├── radix-ui/ # @hanzo/docs-ui - full UI with Radix primitives
│ ├── openapi/ # @hanzo/docs-openapi - OpenAPI docs generation
│ ├── typescript/ # @hanzo/docs-typescript - auto type tables
│ ├── twoslash/ # @hanzo/docs-twoslash - TypeScript code hints
│ ├── cli/ # @hanzo/docs-cli - scaffolding & customization
│ ├── story/ # @hanzo/docs-story - component stories
│ ├── tailwind/ # @hanzo/docs-tailwind - Tailwind CSS utils
│ ├── press/ # @hanzo/docs-press - minimal setup
│ ├── hanzo-docs/ # @hanzo/docs - unified wrapper re-exporting all
│ ├── content-collections/ # @hanzo/docs-content-collections
│ ├── mdx-remote/ # @hanzo/docs-mdx-remote - remote MDX
│ ├── obsidian/ # @hanzo/docs-obsidian - Obsidian vault adapter
│ ├── python/ # @hanzo/docs-python - Python docgen
│ ├── doc-gen/ # @hanzo/docs-docgen - doc generation
│ ├── create-app/ # @hanzo/docs-create-app - project scaffolding
│ ├── create-app-versions/ # version tracking for create-app
│ ├── shared/ # shared utilities
│ ├── stf/ # @hanzo/docs-stf (upstream dependency)
│ ├── mdx-runtime/ # @hanzo/mdx-runtime
│ ├── eslint-config-custom/ # shared ESLint config
│ └── tsconfig/ # shared TypeScript config
└── examples/ # 24 example apps (Next.js, Astro, React Router, etc.)
Package Naming Convention
One brand: every workspace package publishes under @hanzo/docs-*. The
rename rule (applied when merging from the upstream fork) drops the upstream
prefix and re-scopes to @hanzo/docs-:
<basename>(unscoped upstream) →@hanzo/docs-<basename>@<scope>/<basename>(scoped upstream) →@hanzo/docs-<basename>
Canonical workspace packages and their paths:
| Hanzo name | Path |
|---|---|
@hanzo/docs-core |
packages/core |
@hanzo/docs-mdx |
packages/mdx |
@hanzo/docs-ui |
packages/radix-ui |
@hanzo/docs-base-ui |
packages/base-ui |
@hanzo/docs-openapi |
packages/openapi |
@hanzo/docs-preview |
packages/preview |
@hanzo/docs-typescript |
packages/typescript |
@hanzo/docs-twoslash |
packages/twoslash |
@hanzo/docs-cli |
packages/cli |
@hanzo/docs-story |
packages/story |
@hanzo/docs-tailwind |
packages/tailwind |
@hanzo/docs-language |
packages/language |
@hanzo/docs-local-md |
packages/local-md |
@hanzo/docs-sanity |
packages/sanity |
@hanzo/docs-vite |
packages/vite |
@hanzo/docs-basehub |
packages/basehub |
@hanzo/docs-mdx-remote |
packages/mdx-remote |
@hanzo/docs-stf |
packages/stf |
@hanzo/create-docs |
packages/create-app |
@hanzo/docs-create-versions |
packages/create-app-versions |
Important: packages/radix-ui publishes as @hanzo/docs-ui (Radix
variant). The base-ui variant (@hanzo/docs-base-ui) uses @base-ui/react
instead of Radix.
External deps kept verbatim (real upstream npm packages, NOT renamed):
fuma-cli, fuma-content, @fumari/json-schema-ts, and the third-party
search adapters (typesense / trieve) documented under apps/docs.
Upstream Sync
Remote upstream points to the upstream fork. Local dev tracks upstream/dev.
To merge upstream changes:
git checkout dev && git pull upstream dev
git checkout -b merge-upstream-YYYY-MM-DD main
git merge dev
# Resolve conflicts, then re-apply the package rename (table above)
# Merge into main when ready
After merge, re-apply the rename with the canonical script
(scripts/rebrand-packages.mjs — masks the external KEEP-list, then maps
each upstream name to its @hanzo/docs-* form).
Key Patterns
Source Config (source.config.ts)
import { defineConfig, defineDocs } from '@hanzo/docs-mdx/config';
Source Loader (lib/source.ts)
import { docs } from '@/.source';
import { loader } from '@hanzo/docs-core/source';
export const source = loader({
baseUrl: '/docs',
source: docs.toHanzoDocsSource(), // API name kept from upstream
});
Page Component
import { DocsPage, DocsBody } from '@hanzo/docs-ui/layouts/docs/page';
import defaultMdxComponents from '@hanzo/docs-ui/mdx';
The docs grid is three columns
layouts/docs/slots/container.tsx (both UI packages — they are parallel forks,
so a change to one without the other diverges them): var(--fd-sidebar-col) minmax(0, 1fr) var(--fd-toc-width). Rails pin to the edges, the page takes what
is between them.
It was five columns, with minmax(min-content, 1fr) gutters centring a band
capped at --fd-layout-width, and the sidebar AREA spanned the leading gutter as
well as its own column. Measured on the live site at /docs/api-keys: at 1920
the columns were 176.5 232 1052 268 176.5 and the nav — 232px, right-aligned in
its area by items-end — began 176px from the left edge; at 2560 it began 496px
in. The empty strip was inside the sidebar's own bordered, filled card, so every
pixel a wider display gained went there. Now 232 1405 268 and 232 2045 268,
nav at x=0.
--fd-layout-width no longer bounds this grid. It was doing two jobs at once —
bounding the reading measure and bounding the band — and the page slot already
owns the first (max-w-[900px]), so what was left was only the job that made the
gap. The article therefore stays 900px and does not move; what moved is the nav
(flush left) and the toc (flush right), and the page COLUMN absorbed the gutters.
Widening the article past 900px is a separate typography decision, untaken.
Narrow viewports are unchanged by construction: below md, --fd-sidebar-width
is 0 and the sidebar is a fixed drawer outside the grid, and --fd-toc-width is
0 until a toc exists.
Layouts
@hanzo/docs-ui/layouts/docs- Standard docs layout@hanzo/docs-ui/layouts/home- Homepage layout@hanzo/docs-ui/layouts/notebook- Notebook layout@hanzo/docs-ui/layouts/flux- Alternative flux layout (new)
Build
pnpm install # Install deps
pnpm build # Build all packages + apps
pnpm dev # Dev server
# Individual packages
pnpm build --filter @hanzo/docs-core
pnpm build --filter @hanzo/docs-ui
Build tool: tsdown (all packages except hanzo-docs wrapper which uses tsup).
Compatibility
- Next.js 15-16+ with App Router
- React 19+
- Tailwind CSS 4+ (see "UI framework debt" — this is the thing being removed)
- pnpm 10+
UI framework debt — where the tailwind actually is
House rule is that @hanzo/gui is the only UI framework: it compiles to React
Native, so an app built on it runs on web, iOS and Android. Tailwind classes and
Radix primitives are DOM-only and cap an app at the browser. This repo is the
furthest thing from compliant, and the reason is structural, so measure before
planning anything.
Tailwind here is LIVE, not inert. 21 apps each carry a real
postcss.config.mjs with @tailwindcss/postcss, 60 CSS entrypoints begin
@import "tailwindcss", and 66 package.json files declare a tailwind dep. Two of
those entrypoints — packages/radix-ui/css/style.css and
packages/base-ui/css/style.css — ship inside the published npm tarballs, so the
framework's product is tailwind. Counted with a utility-token regex over tracked
files: 39,433 class tokens across 537 source files.
The blocker is that the UI layer is two parallel forks of Fumadocs.
packages/radix-ui publishes as @hanzo/docs-ui (Radix) and packages/base-ui
as @hanzo/docs-base-ui (Base UI) — ~110 files / 13.5k lines each, the same
components twice, kept in step by .cursor/skills/radix-base-ui-sync. Both names
are banned by the house rule, apps/docs depends on both at once, and every
app and example in the workspace consumes them. Complying means rebuilding that
layer once on @hanzo/gui and deleting both — a Fumadocs rewrite, not a patch.
Do not start it piecemeal: swapping one primitive inside a component whose markup
is still tailwind lowers a grep count and changes nothing real.
Radix → @hanzo/gui primitive map (checked against ~/work/hanzo/gui/pkgs/ui,
not guessed). Present: presence→animate-presence, popover, select,
dialog, accordion, tabs, collapsible, tooltip, direction→
core/use-direction. Genuinely missing: navigation-menu and scroll-area
(scroll-view is the React Native scroller, not a custom-scrollbar area). Those
two must be built in @hanzo/gui before packages/radix-ui can be retired.
collapsible exists but apps/gui-docs has no page for it — a docs gap here,
not a missing primitive.
@zenlm/ui ships classes that style nothing. packages/zenlm-ui publishes
dist/ as JS + d.ts with no CSS at all, declares no tailwind dep and has no
postcss config, yet styles its four components entirely with tailwind utilities
(and shadcn's bg-muted / text-muted-foreground tokens). Inside apps/zen-docs
they happen to resolve because that app compiles tailwind; for any other npm
consumer they are dead strings. Note apps/zen-docs pins @zenlm/ui: ^1.0.6 from
the registry, not workspace:*, so pnpm 11 (link-workspace-packages defaults to
false) serves the published tarball — editing packages/zenlm-ui does not change
what zen-docs renders until a release. Converting it to @hanzo/gui style props
is the highest-value next increment and fixes the inertness by construction,
because style props travel with the component.
Out of scope when counting: content/docs/projects/** is a snapshot of
upstream repos, so tailwind there is upstream's and a refresh overwrites edits.
Two more grep hits are not tailwind at all —
the Java/Spring IAM guide uses Bootstrap in a Thymeleaf template, and the IAM
login-customization guide uses custom class names with their own <style> blocks.
Shadcn is gone (packages/shadcn + examples/next-shadcn deleted). It
existed to emit npx shadcn@latest add ... into rendered docs, which puts a
DOM-only library in the reader's app.
Authored docs are converted. The commerce recipes/storefront pages and
zen5.mdx teach @hanzo/gui now. Keep it that way: a snippet teaching tailwind
produces tailwind in someone's app, so examples count as shipping surface.
Baseline when touching this repo
pnpm types:check is not green and never was: 50 of 72 tasks fail at rest.
npx vitest run is 13 failed / 203 passed. Judge a change by the delta against
those sets, not by a clean run you will never get. And docs#types:check failing
tells you nothing about whether a page renders — MDX compile errors do not surface
in tsc. zen5.mdx typechecked clean while the loader skipped the entire page
(<50ms in a table parses as a JSX tag; fence such values in backticks). Run the
app and look at it.
Landing Apps (Moved Out)
Landing page apps were moved to their ecosystem repos:
- Hanzo apps →
~/work/hanzo/apps/ - Lux apps →
~/work/lux/apps/ - Zoo apps →
~/work/zoo/apps/
apps/zap-docs and apps/liquid are gone; their deploy workflows were deleted
because they built directories that do not exist. See apps/ for what is here.