Files
zeekay 8fc53c5986 docs: every page as Markdown, and what an agent reads first
Each page's Markdown twin is served at <page>.md, lowered from its MDX
(lib/markdown.ts) and named in its head as rel=alternate text/markdown;
llms.txt links the twins. robots.txt carries Content-Signal and the AI
crawlers by name, /auth.md is the shared text, /.well-known/api-catalog
lists the API, the MCP endpoint and the agents guide, and every page
registers search_docs and get_page as WebMCP tools.
2026-10-27 01:30:42 -07:00

34 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; sync + dispatch (RELEASE.md)
      .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.

No Turbopack filesystem cache in the export build. hanzoai/ci carries apps/docs/.next/cache between runs, and every job checks out under a new ~/.cache/act/<hash>/hostexecutor. Turbopack's cache records paths under the directory that wrote it, so a warm build died "leaves the filesystem root" (run 16992). next.config.ts sets turbopackFileSystemCacheForBuild: false for the export; run 16996 then compiled in 70 s and passed every gate.

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 project docs. 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 — from apps/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 by lucideIconsPlugin), 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-capabilities is what holds the taxonomy, the document and the pages to one set. No stutter. A folder's own index.mdx is already its landing page — the tree builder resolves it before it reads pages. Naming index in pages demotes the page to a child of the folder carrying the folder's own name, so the sidebar reads Agents > 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 (lib/nav.ts), deliberately NOT in the root sidebar descent. The landing (app/(landing)/page.tsx — its own route group, because it carries the docs chrome from components/shell.tsx while (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 from capabilities.yaml and 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:
    1. Authored (first-party prose) → MDX under content/docs/<section>/. Small/core sections live in-repo; team-owned sections live in a hanzo-docs/<team> content repo mounted as a git submodule at content/docs/<team>/. Exemplar: hanzo-docs/studio-docs → content/docs/studio/.
    2. Generated (never hand-written, .gitignored) → API reference from hanzoai/cloud's openapi.yaml at the release openapi-specs/.spec-lock pins, via scripts/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 into content/docs/sdks/<lang>/. Every reader of the document goes through scripts/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.ts writes generated/key-types.mdx and content/docs/api-keys.mdx pulls it in with <include>. It sits outside content/ because source.config globs **/*.mdx there and would give a fragment its own URL — and the path is relative to the INCLUDING file, so from content/docs/ it is ../../generated/. One directory short renders the page body empty with the frontmatter and chrome intact, which looks like a working page.
    3. 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.ts refreshes it on demand (pnpm --filter docs sync:projects) and you read the diff before it ships, the same contract openapi-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.
  • Repos: hanzo-docs/docs = framework + hub (canonical; hanzoai/docs redirects here). hanzo-docs/<team> = authored content only, NO framework. hanzoai/cloud openapi.yaml = the API source of truth; hanzoai/openapi's hanzo.yaml was a projection of it on its own clock and this repo no longer reads it. All docs live in the hanzo-docs org: the hub at hanzo-docs/docs, each team's content at hanzo-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 on Host(docs.hanzo.ai). The sibling hosts below are still CF Pages hanzo-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.

Funnel

  • No page sends a visitor to hanzo.id. Sign-in is https://hanzo.ai/login (components/account.tsx; /login is a rule in public/_redirects). Sign-out clears the session in this browser and navigates nowhere.
  • The conversion CTA reads "Try Hanzo" and opens https://hanzo.ai/login; payment comes after sign-in (components/landing.tsx). It sends no event of its own: it is not a plan choice, and the page view and click cover it.
  • Tracking is @hanzo/docs-analytics: page views (first load and each route change) to api.hanzo.ai/v1/event, and <Hanzo> (@hanzo/ui) autocaptures $click onto the same client. The key is the one @hanzo/event's keyring resolves from the page's host: docs.hanzo.ai is the hanzo org's, localhost resolves none. An event this site names itself comes from the @hanzo/events catalog. The cold image lane's PUBLISHABLE_KEY build arg is no longer read by any app.

Agents section

The way in for an agent, second in the root sidebar.

  • Where it lives. content/docs/agents/index.mdx (/docs/agents: the builder-agent path, then the owner path, then Q&A; one primary CTA, "Get an API key, free"), agents/prompt.mdx (/docs/agents/prompt: the standard prompt, one fenced block, byte for byte the owner's text) and agents/campaign.mdx (measure, write, draft, owner launches, read). app/llms.txt/route.ts opens with the same facts (HEAD: positioning, "For agents", "Company") before the index. public/_redirects: /agents, /agents/prompt, /about/company land here; /docs/agents is a page now and points at /docs/openapi/agent.
  • Headless first. Every agent surface opens on the owner's words, verbatim: "Sign up free with a wallet. Get an API key free. A small free allowance to try it. Top up in USDC or USDT over the API to raise your limits and pay as you go. No human required." Step 1 is wallet sign-in with no human: headless.mts, byte for byte the file that was run (hanzo-inc/app lib/agents.ts HEADLESS), and its real output. agents/headless.mdx (/docs/agents/headless) is the same flow one request at a time, each with its answer: nonce (a live answer), verify, token (IAM tokenResponse), key, limits (/v1/billing/plans, /v1/billing/tier, /v1/allowance), the 429 body (hanzoai/ai routers/filter_balance.go limitReached), crypto options, deposit, deposit by id and balance (cloud apps/billing/ledger.go commerceBalance). Seven chains sign in (evm, solana, bitcoin, ton, xrp, polkadot, cardano); the file shows EVM.
  • No wallet-allowance number. Cloud is changing what a wallet-only account gets free. Until that deploys, pages say "a small free allowance to try it" and point at GET /v1/allowance / GET /v1/billing/tier; 50 a day is printed only as a person's Free plan. The run output's remaining count is elided for the same reason.
  • Every page points agents in. app/layout.tsx puts rel=alternate (/llms.txt), the page's Markdown twin and rel=help (/docs/agents) in each head (Agent readiness, below), and lib/nav.ts leads the top bar with Agents. The label is one word because "Agents: get an API key in one call" wraps in the bar from 768 to 1023px; the long phrase is the rel=help title.
  • Source of every number. A facts file measured on 2026-10-26 (each line VERIFIED by a run or READ from code at a cited commit). Print no number it does not carry; re-measure before changing one.
  • Not published, and why. x402 as a way to pay for the API, or any 402 flow (it settles only priced marketplace tools via /v1/tool/call, from the prepaid balance, and no listing is priced today). Cash App Pay (accepted by the route, not verified on the live checkout). Bank account numbers (GET /v1/billing/wire returns them to a signed-in caller only). [email protected] (nothing reads it). Any response-time promise (none exists). SMS as a campaign channel (not an API channel). POST /v1/email (served by cloud main, absent from the pinned document, so check-endpoints refuses it). The hanzoskills.com Kai skill (404 when written).
  • /v1/research/runs is the one STAGED path in check-endpoints.ts. research is alpha, so the public document omits it, yet the read is open and is how a reader checks Kai's published runs. Exact path only.
  • The hosted POST /v1/mcp speaks protocol 2026-07-28 only, so the page sends clients to the local @hanzo/mcp server.

Agent readiness

What an agent reads before, beside and instead of a page. Scanner: POST https://isitagentready.com/api/scan.

  • Every page has a Markdown twin at <page>.md (/ -> /index.md, /docs/a -> /docs/a.md). lib/markdown.ts lowers the page's processed MDX to plain CommonMark + GFM: a callout a quote, cards a list of links, tabs labelled sections, an Example a fenced block, live widgets out, every link absolute. The twins prerender at llms.mdx/<page>/content.md (app/llms.mdx: docs, blog, the front page from lib/landing.ts, /reference) because the docs route cannot also answer <page>.md; scripts/twins.ts (post-build) copies each to <page>.md, gives an alias page its target's twin, and names any HTML page left without one. The old llms.mdx/... addresses stay served.
  • The front page's words live in lib/landing.ts, which components/landing.tsx draws and /index.md writes, so they cannot differ.
  • Each page's head carries <link rel="alternate" type="text/markdown"> (lib/metadata.ts alternates). next dev serves <page>.md through proxy.ts; the export never runs it.
  • llms.txt links every twin, absolute; /auth.md and llms-full.txt are named at its top. API calls are written as METHOD /v1/... in code, never as bare URLs, because a crawler that follows a POST-only URL gets a 405.
  • public/auth.md is hanzoai/skills public/auth.md, byte for byte: one text on every Hanzo host. This host is neither an authorization server nor a resource server, so it publishes no OAuth discovery or PRM of its own.
  • public/.well-known/api-catalog (RFC 9727). The static plane serves an extensionless file with no Content-Type; application/linkset+json needs an edge header rule.
  • public/robots.txt: Content-Signal: search=yes, ai-input=yes, ai-train=yes in User-agent: * and in the named AI crawlers' group (a crawler named in a group reads only that group). Bytespider is not named, as before.
  • WebMCP (lib/webmcp.ts, mounted by components/webmcp.tsx in the root layout): search_docs (the ⌘K dialog's own flexsearch index at /v1/search, loaded on the first call) and get_page (a page's twin), read-only, on document.modelContext or navigator.modelContext. No <form toolname>: the search is a dialog with no form, and a declarative tool named search_docs would collide with the imperative one.
  • export.require names the twins and agent files, so a post-build that stops running fails the gate instead of shipping rel=alternate links to 404s.

Company

content/docs/about/company.mdx (/docs/about/company; inlined into the sidebar by ...about under the Company separator, so no one-page folder shows).

  • Source: the founder's account (owner override, 2026-10-26: no claim ledger; the founder is the authority on Hanzo's history). Print only its numbers.
  • Positioning, verbatim: "The AI operating system for business. Building business software since 2014. Techstars '17."
  • Dates: founded as Crowdstart, renamed Hanzo in 2016; first commit 2014-09-29; SKULLY 2014 (TechCrunch 2014-08-14: $1M in about 45 hours, not "first day"); Unikrn 2017 (Crunchbase News: over 120,000 ETH; it does not name Hanzo, so hanzo.ai/customers#unikrn carries our part); Techstars '17 (hanzo.blog 2017-06-08, Startland News 2017-10-10); Damon CES 2020 (case study: $8.4M in 30 days, 541:1 ROAS, "AI-generated ads").
  • Damon and Triller "went public on Nasdaq in 2024" (merger and direct listing; both later delisted), never "IPO".
  • People as hanzo.ai/about names them: Antje Worring, CEO; Zach Kelling, CTO. Nothing else about Antje.
  • Contacts: https://cal.com/hanzo, https://hanzo.ai/contact (hello@, sales enterprise@, security@), investors [email protected]. Never [email protected].
  • Never printed: "original AI OS", "first AI chat app", "LLM chat before ChatGPT", "Go native since day 1" (the 2014-09-29 commit is a CoffeeScript prototype; the platform is Go-native since October 2014, first Go file 2014-10-20). GPT models appear only as the founder's "commercial use by 2019", never in a case study's numbers. crowdstart.com is a parked domain, so it is named, not linked.
  • Unproven until an artifact exists: "hundreds of startups", "billions in client revenue", "two companies past 100 million users" rest on the founder's word; a client list with revenue figures, or the case studies on hanzo.ai/customers, are what would prove them.

Build guards, and why prose needs them

scripts/pre-build.ts ends with checks that fail the build. The first two 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 on api.hanzo.ai that the document does not serve.
  • check-keys.ts — no page may spell an API key a way cloud does not mint, and api-keys.mdx must 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.
  • check-zen.ts — a Zen page (models/index.mdx, models/zen*) names no upstream base model outside a data-upstream element. Two places carry one: the Architecture value (the loader string from the weights' own config) and the License & attribution section. The names live in apps/docs/zen.upstream and only there; title and description are read too, since they become the meta and OG tags.

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 on main IS publishing (RELEASE.md). All Hanzo work lands here.
  • dev — Tracks upstream Hanzo Docs/dev. Used for upstream sync merges only.
  • upstream remote — points to hanzoai/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
});

The chrome is @hanzo/gui (apps/docs)

Every docs page sits in components/shell.tsx: the rail (rail.tsx — brand, search, tree.tsx, account and theme at the foot), and a column holding the bar (bar.tsx) and the page (page.tsx — breadcrumb, title, description, the "Use agent" menu agent.tsx, body, previous/next; toc.tsx beside it). All of it is gui stacks and text on theme tokens; no class names.

  • Widths are media props, never JS. $max-md, $xl compile to CSS media rules, so the markup a phone hydrates is the markup the export wrote. Rail 264px, bar 56px, article 880px with the outline 240px beside it from 1280px; below 768px the rail is a Sheet opened from the bar.
  • First paint is styled with no script. gui emits each component's atomic rules as <style precedence> during SSR, React hoists them, and <html> ships t_dark. Check it: render any page with JavaScript off.
  • One theme authority: t_dark/t_light on <html>. gui's NextThemeProvider (@hanzogui/next-theme) writes it before paint and <Hanzo theme> follows it (app/provider.tsx). The only CSS that reads it is color-scheme in app/global.css, which is what picks the code palette.
  • Muted text is lib/ink.ts. gui's grey ramp is not symmetric (light step 10 is #333), so muted names step 10 dark / 9 light. Chrome text measures >= 7.5:1 in both themes.
  • gui v5 is shorthand-only. bg p px items justify rounded minW maxH t z exist; w/h do NOT (use width/height) — an unknown prop is dropped silently and the box takes its content's size.
  • One title per page. The header prints the frontmatter title; lib/remark-title.ts drops a body's leading # heading.
  • Page bodies are gui too. MDX hands each markdown tag to components/mdx.tsx; blocks are spaced by Body's gap, never their own margins, so a list inside a callout keeps its own rhythm. Running text sets whiteSpace: normal (gui text keeps whitespace, markdown hard-wraps). Tables keep the browser's table layout through style.display. Code tokens carry color: light-dark(…) (lib/shiki.ts), so color-scheme picks the palette and no stylesheet knows the theme. Steps are the Steps/Step parts (lib/remark-parts.ts renames remark-steps' marked divs).
  • One code theme: codeTheme from @hanzo/ui/core (Dracula dark, GitHub Light light), the same on hanzo.ai, platform and ui.hanzo.ai. lib/shiki.ts passes it to rehype-code and Example; every app's source.config.ts imports it, never a theme literal. A block's code area paints the theme's own ground and ink from the pre shiki wrote (#282a36 dark, #fff light). A client part never runs shiki: it takes the code already highlighted — snippet and request are <Example lang code /> props in the MDX, and the hero is components/hero.tsx, a server part that highlights its three doors.
  • The tree narrows by path, not address. lib/tree.tsx resolves an operation page (not in the tree) to its product page, so the rail opens the reference it belongs to.

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+
  • apps/docs renders no Tailwind; the framework packages still ship it ("Moving onto @hanzo/gui")
  • pnpm 10+

Moving onto @hanzo/gui — the plan

House rule: @hanzo/gui is the only UI framework (it renders on web, iOS and Android; Tailwind classes and Radix/Base UI are DOM-only). docs.hanzo.ai is being moved onto it in four phases, each shipped to main when verified live.

Inventory of what docs.hanzo.ai renders (Tailwind's own scanner, counting only tokens Tailwind would emit CSS for, in class contexts; content/docs/projects/** excluded — it is upstream's):

Area Tailwind tokens / files Radix files Base UI files
apps/docs app+components+lib, before phase 1 2,189 / 52 2 0
apps/docs after phase 1 1,906 / 37 2 0
apps/docs after phase 3 0 / 0 0 0
packages/base-ui (@hanzo/docs-base-ui) 2,774 / 76 0 10
packages/radix-ui (@hanzo/docs-ui, via openapi/twoslash/typescript) 2,568 / 71 9 0
packages/openapi 794 / 23 3 0
packages/story / twoslash 212 / 4, 3 / 1 1, 1 0
authored MDX (content/docs, mostly code samples) 63 / 56 0 0

Plus the CSS: app/global.css imports tailwindcss, the base-ui, openapi, story and twoslash presets, and tailwindcss-animate.

What replaces what. Layout, rail, bar, outline and page header: our own gui stacks (done). Drawer: @hanzo/ui Sheet. Menus: @hanzo/ui DropdownMenu. Folds: plain state + gui stacks. Icons: @hanzogui/lucide-icons-2. Theme: @hanzogui/next-theme. Tree/TOC logic stays in @hanzo/docs-core (headless).

Phase Scope Replaces Status
1 Chrome on every page: rail, tree, bar, outline, page header, search trigger, account, theme base-ui DocsLayout/DocsPage/sidebar/toc, next-themes, the corner dock shipped (f11eb49dc2)
2 Page bodies: markdown's elements (components/mdx/prose.tsx), the parts content names — Callout, Cards, Tabs, Steps, Accordion, Files, code blocks and code tabs (blocks.tsx, tabs.tsx, code.tsx) — and Example; content imports none of them; ported and studio pages reach them through the parts aliases in next.config Tailwind typography, base-ui MDX components, shiki.css, the HoverCard link preview (a title now) shipped
3 App pages and app parts: landing (on the theme now, no forced dark), hero, product sections, provider strip, catalogs, search (search.tsx + finder.tsx, ⌘K), feedback (a real docs.feedback event, not a console stub), 404, blog (in the shell, on the page frame), sign-in screens base-ui search dialog, RootProvider, HomeLayout, the registry build and the dead preview / AI-search / API-page modules shipped
4 The build: app/global.css is plain CSS (faces, ground, color-scheme, the shell footer); no @import 'tailwindcss', no presets, no postcss.config.mjs; apps/docs no longer depends on tailwindcss, @tailwindcss/postcss, tailwindcss-animate, tailwind-merge, class-variance-authority, any @radix-ui/*, @hanzo/docs-base-ui, @hanzo/docs-ui, lucide-react, or the build plugins that emitted their parts (twoslash, auto type table, ts2js) — shipped

No framework package was deleted, because none is dead. packages/base-ui, radix-ui, tailwind, story, twoslash, typescript, openapi and the packages/gui stub each still have consumers in apps/*, examples/* or the @hanzo/docs wrapper. They leave when those apps move onto the gui parts in apps/docs/components/ — lift those into a package first, so every docs app renders one set.

Known gap in @hanzo/ui (8.27.18): its .d.ts augments @hanzogui/web without depending on it, so in this workspace the augmentation lands on the hoisted 8.1.1 copy while @hanzo/gui 8.3.5 uses 8.3.5 — every gui shorthand fails typecheck here (runtime is correct; ignoreBuildErrors is on). The fix is @hanzo/ui declaring @hanzogui/web/core as dependencies of the same train.

Out of scope when counting: content/docs/projects/** is a snapshot of upstream repos. The Java/Spring IAM guide uses Bootstrap in a Thymeleaf template and the IAM login-customization guide uses its own <style> blocks — not Tailwind.

@zenlm/ui ships classes that style nothing (packages/zenlm-ui: Tailwind utilities, no CSS, no Tailwind dep); apps/zen-docs pins the published tarball. Converting it to gui style props fixes it by construction.

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.

remark-math is on, so two $ in one paragraph or list item typeset the text between them as math: migrate/stripe.mdx's "floor $1 and ceiling $5,000" renders as KaTeX on the live site. Write each amount as \$0.021 where a paragraph holds two; a table cell, a code span and frontmatter are each their own context and need no escape.

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.