Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,9 @@ Next.js 16 (App Router) + fumadocs site for [bitrouter.ai](https://bitrouter.ai)
- **English only.** The site no longer ships localized docs — do not create `<name>.zh.md` translation files or reintroduce i18n plumbing. Old `/zh/*` URLs 301 to the English pages via `next.config.ts` redirects.
- Docs are **`.mdx`**, import-free, using only the whitelisted global components — see `docs/CONTRIBUTING.md` for the full authoring contract. Write Markdown; the extension is what makes `<Callout>` / `<Cards>` / `<Tabs>` actually render (fumadocs-mdx picks its processor by extension, and `.md` silently drops those blocks).
- Lint with `pnpm lint:docs` (`scripts/check-docs.mjs`) after editing docs.
- Documentation is one unified Fumadocs page tree with no layout tabs. Its six user-facing groups are **Overview, Usage, Configuration, Customization, Reference, Development**. The meta-only `content/docs/(overview-nav)/`, `(usage-nav)/`, `(configuration-nav)/`, `(customization-nav)/`, and `(development-nav)/` folders keep the public sidebar flat; `content/docs/reference/` remains the generated native folder. Changelog is a separate top-level `/changelog` section with its own `content/changelog/` source and release-version sidebar.
- The docs layout uses Fumadocs' native `defaultOpenLevel: 1`, so all six top-level groups are visible together. Reference flattens single-operation groups into direct links; only multi-operation groups such as Models & providers and Cloud management add another collapsible level. Preserve that shape in `scripts/generate-openapi.mjs`.
- **Keep the sidebar task-level and shallow.** The `*-nav` files should expose compact task overviews, not every product-specific recipe. Detailed Claude Code, Codex, model-source, tool, and observability pages are reached from cards on their section overview pages.
- Documentation is one unified Fumadocs page tree with no layout tabs. Its six user-facing groups are **Overview, Usage, Configuration, Customization, Reference, Development**. In `content/docs/meta.json`, each group name is a native Fumadocs separator and each meta-only `*-nav` folder is extracted into the root, so group labels are not clickable or collapsible. Changelog is a separate top-level `/changelog` section with its own `content/changelog/` source and release-version sidebar.
- **Show every non-Reference page in the sidebar exactly once.** Overview, Usage, Configuration, Customization, and Development are flat link lists beneath their separators; do not hide detailed pages behind cards or reintroduce nested folders. `pnpm lint:docs` enforces this coverage against the `*-nav/meta.json` files.
- Reference is the deliberate exception: extract `content/docs/reference/` beneath the **Reference** separator, but preserve its generated native API-family folders and endpoint children. Make Reference navigation changes in `scripts/generate-openapi.mjs`, never in generated `meta.json` files.
- **Usage means operation; Configuration means built-in behavior; Customization means adding capabilities.** CLI/TUI, coding agents, MCP, ACP, Agent Skills, and model sources belong under Usage. Routing behavior, guardrails, and observability belong under Configuration. Models/providers and router-owned tool capabilities belong under Customization. Enterprise is the Overview decision entry; self-hosting remains the OSS operational deep link.
- Retiring or moving a page means adding a 301 to the `pairs` list in `next.config.ts` — that file is the URL history of the docs.
- **Always use the scripts — never hand-edit generated output.** Anything produced by a generator is regenerated at `prebuild`, so manual edits are silently lost:
Expand Down
2 changes: 1 addition & 1 deletion app/docs/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ export default function Layout({ children }: { children: React.ReactNode }) {
<DocsLayout
tree={source.pageTree}
tabs={false}
sidebar={{ defaultOpenLevel: 1 }}
sidebar={{ prefetch: false }}
nav={{ mode: "top" }}
slots={{ header: DocsHeader }}
>
Expand Down
3 changes: 2 additions & 1 deletion content/docs/(configuration-nav)/meta.json
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
{
"title": "Configuration",
"pagesIndex": "[Configuration](/docs/configuration)",
"pagesIndex": "[Overview](/docs/configuration)",
"pages": [
"[Config file](/docs/configuration/config-file)",
"[Routing](/docs/configuration/routing)",
"[Structured outputs](/docs/configuration/structured-outputs)",
"[Guardrails](/docs/configuration/guardrails)",
"[Observability](/docs/configuration/observability)"
]
Expand Down
7 changes: 5 additions & 2 deletions content/docs/(customization-nav)/meta.json
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
{
"title": "Customization",
"pagesIndex": "[Customization](/docs/customization)",
"pagesIndex": "[Overview](/docs/customization)",
"pages": [
"[Models & providers](/docs/customization/models)",
"[Tool calling](/docs/customization/tools)"
"[Tool calling](/docs/customization/tools)",
"[Server tools](/docs/customization/tools/server-tools)",
"[Model-backed tools](/docs/customization/tools/model-backed-tools)",
"[Web tools](/docs/customization/tools/web-tools)"
]
}
11 changes: 9 additions & 2 deletions content/docs/(overview-nav)/meta.json
Original file line number Diff line number Diff line change
@@ -1,10 +1,17 @@
{
"title": "Overview",
"pagesIndex": "[Overview](/docs/overview/what-is-bitrouter)",
"pagesIndex": "[What is BitRouter?](/docs/overview/what-is-bitrouter)",
"pages": [
"[Quickstart](/docs/overview/quickstart)",
"[Supported Models](/docs/overview/supported-models)",
"[Enterprise](/docs/enterprise)",
"[Comparisons](/docs/overview/comparisons)"
"[Self-hosting](/docs/self-hosting)",
"[Install](/docs/self-hosting/install)",
"[Deploy](/docs/self-hosting/deploy)",
"[Secure](/docs/self-hosting/secure)",
"[Operate](/docs/self-hosting/operate)",
"[Comparisons](/docs/overview/comparisons)",
"[BitRouter vs OpenRouter](/docs/overview/comparisons/openrouter)",
"[BitRouter vs LiteLLM](/docs/overview/comparisons/litellm)"
]
}
11 changes: 9 additions & 2 deletions content/docs/(usage-nav)/meta.json
Original file line number Diff line number Diff line change
@@ -1,13 +1,20 @@
{
"title": "Usage",
"pagesIndex": "[Usage](/docs/usage)",
"pagesIndex": "[Overview](/docs/usage)",
"pages": [
"[CLI](/docs/usage/cli)",
"[TUI](/docs/usage/tui)",
"[Coding agents](/docs/usage/coding-agents)",
"[Claude Code](/docs/usage/coding-agents/claude-code)",
"[Codex](/docs/usage/coding-agents/codex)",
"[MCP Support](/docs/usage/mcp)",
"[MCP gateway](/docs/usage/mcp-gateway)",
"[ACP Support](/docs/usage/acp)",
"[Agent Skills](/docs/usage/skills)",
"[Model sources](/docs/usage/model-sources)"
"[Model sources](/docs/usage/model-sources)",
"[Claude subscription](/docs/usage/model-sources/claude-subscription)",
"[Codex subscription](/docs/usage/model-sources/codex-subscription)",
"[Local inference](/docs/usage/model-sources/local-inference)",
"[BitRouter Agent](/docs/usage/agent)"
]
}
18 changes: 12 additions & 6 deletions content/docs/meta.json
Original file line number Diff line number Diff line change
@@ -1,11 +1,17 @@
{
"title": "Documentation",
"pages": [
"(overview-nav)",
"(usage-nav)",
"(configuration-nav)",
"(customization-nav)",
"reference",
"(development-nav)"
"---Overview---",
"...(overview-nav)",
"---Usage---",
"...(usage-nav)",
"---Configuration---",
"...(configuration-nav)",
"---Customization---",
"...(customization-nav)",
"---Reference---",
"...reference",
"---Development---",
"...(development-nav)"
]
}
14 changes: 7 additions & 7 deletions content/docs/reference/meta.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,15 @@
"pagesIndex": "index",
"pages": [
"---Inference---",
"[OpenAI Chat Completions](/docs/reference/openai-compatible/createChatCompletion)",
"[OpenAI Responses](/docs/reference/openai-responses/createResponse)",
"[Anthropic Messages](/docs/reference/anthropic-compatible/createMessage)",
"[Google GenerateContent](/docs/reference/google-compatible/googleGenerateContent)",
"openai-compatible",
"openai-responses",
"anthropic-compatible",
"google-compatible",
"---Platform---",
"discovery",
"[BYOK encryption](/docs/reference/byok/getEncryptionPubkey)",
"byok",
"management",
"[Metrics](/docs/reference/observability/getMetrics)",
"[Health](/docs/reference/health/ping)"
"observability",
"health"
]
}
52 changes: 22 additions & 30 deletions docs/CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,20 +18,19 @@ top-level order is the `pages` list in `content/docs/meta.json`:
5. **Reference** — the generated Cloud API reference.
6. **Development** — source and contribution entry points.

Five meta-only folders — `content/docs/(overview-nav)/`, `(usage-nav)/`,
`(configuration-nav)/`, `(customization-nav)/`, and `(development-nav)/` — define
the first, second, third, fourth, and sixth groups. They contain links to the
canonical pages rather than copies of those pages. `content/docs/reference/`
is the only native content folder in the top-level list because the OpenAPI
generator owns its nested endpoint tree.

The notebook layout sets Fumadocs' native `defaultOpenLevel` to `1`, so all six
top-level groups are presented together while remaining user-collapsible.
Reference keeps that same top-level presentation, flattens single-operation
protocol groups into direct links, and reserves nested folders for genuine
multi-operation groups such as Models & providers and Cloud management. Make
Reference navigation changes in `scripts/generate-openapi.mjs`; generated
`meta.json` files are replaced during `prebuild`.
Each group name in `content/docs/meta.json` is a native Fumadocs separator, not
a page or collapsible folder. Five meta-only folders —
`content/docs/(overview-nav)/`, `(usage-nav)/`, `(configuration-nav)/`,
`(customization-nav)/`, and `(development-nav)/` — are extracted into the root
with Fumadocs' `...folder` syntax. They contain links to canonical pages rather
than copies of those pages, so every non-Reference page appears directly below
its section label.

Reference is the deliberate exception. `content/docs/reference/` is extracted
below the **Reference** separator, but the generated API families remain native
folders with endpoint children. Make Reference navigation changes in
`scripts/generate-openapi.mjs`; generated `meta.json` files are replaced during
`prebuild`.

**Changelog is not part of the documentation tree.** It is a top-level `/changelog`
section with a separate `content/changelog/` source. It still uses the native
Expand All @@ -48,13 +47,12 @@ Three rules keep the unified navigation intact:
3. A page should appear in one user-facing group. Cross-link it from content
when another journey needs it instead of duplicating the navigation entry.

### Keep Usage shallow
### Keep non-Reference sections flat

Usage source pages live together under `content/docs/(guide)/usage/`. The
sidebar shows task-level entry points only: CLI, TUI, coding agents, MCP Support,
ACP Support, Agent Skills, and model sources. Product-specific recipes live one level
deeper and are discovered from cards on those overview pages, not by expanding
another sidebar tree.
Source pages can remain nested where that keeps related files together, but the
public sidebar is a flat list beneath each section separator. Every page must be
listed exactly once in the matching `*-nav/meta.json`; cards and inline links
provide additional discovery, not a substitute for sidebar visibility.

Use this boundary when classifying new pages:

Expand Down Expand Up @@ -82,14 +80,8 @@ Self-hosting page about config covers only the operational contract around it

Folders under `content/docs/(guide)/` own their routes and local source
ordering. Public sidebar order comes from the `*-nav/meta.json` files, which
intentionally expose fewer entries than the complete source tree.

### Unlisted pages are details, not orphans

A page left out of a public `*-nav/meta.json` still builds and answers at its
URL. It must be linked from its task overview page and included in its source
folder's `meta.json`. This is how detailed recipes stay discoverable without
making the sidebar a complete file browser.
must expose the complete non-Reference source tree. `pnpm lint:docs` fails when
a page is missing, duplicated, or points to a route that does not exist.

## Authoring contract (import-free MDX)

Expand Down Expand Up @@ -128,8 +120,8 @@ beyond the whitelisted components. The build enforces this:

1. Create `content/docs/(guide)/<section>/<name>.mdx`.
2. Add it to the source section's `meta.json` when that local ordering is used.
3. Add a canonical URL link to the appropriate `*-nav/meta.json` if it should
appear in the public sidebar.
3. Add its canonical URL link to the appropriate `*-nav/meta.json`; every
non-Reference page must appear in the public sidebar exactly once.
4. Run `pnpm lint:docs` to check the authoring contract.

## Adding a section
Expand Down
83 changes: 78 additions & 5 deletions scripts/check-docs.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@
// - an `import`/`export` statement appears outside a fenced code block
// - a GitHub-style alert (`> [!NOTE]`) is used: the site has no alerts remark
// plugin, so it renders as a blockquote with a literal `[!NOTE]` visible
// - a non-Reference page is missing from the public sidebar, appears more
// than once, or a sidebar link points at a missing page
import { readdir, readFile } from "node:fs/promises";
import { join, relative } from "node:path";
import {
Expand All @@ -29,13 +31,34 @@ const SECTIONS = [
"(guide)/development",
"self-hosting",
];
// Reference endpoint pages are generated, but its overview is hand-authored
// and must obey the same import-free contract.
const STANDALONE_DOCS = ["reference/index.mdx"];
// Reference endpoint pages are generated, but its overview and the root-level
// Enterprise page are hand-authored and must obey the same import-free contract.
const STANDALONE_DOCS = ["enterprise.mdx", "reference/index.mdx"];
const ROOT = "content/docs";
// Generated output, exempt from the hand-authoring contract: it is emitted by
// scripts/generate-cli.mjs from the binary's own `--help`.
const GENERATED = new Set(["(guide)/usage/cli.mdx"]);
const NAV_FILES = [
"(overview-nav)/meta.json",
"(usage-nav)/meta.json",
"(configuration-nav)/meta.json",
"(customization-nav)/meta.json",
"(development-nav)/meta.json",
];
const ROOT_NAV_PAGES = [
"---Overview---",
"...(overview-nav)",
"---Usage---",
"...(usage-nav)",
"---Configuration---",
"...(configuration-nav)",
"---Customization---",
"...(customization-nav)",
"---Reference---",
"...reference",
"---Development---",
"...(development-nav)",
];

async function walk(dir) {
const out = [];
Expand All @@ -49,6 +72,21 @@ async function walk(dir) {

const isDoc = (p) => /\.mdx?$/.test(p);

function docRoute(abs) {
const parts = relative(ROOT, abs)
.replace(/\\/g, "/")
.replace(/\.mdx?$/, "")
.split("/")
.filter((part) => !/^\(.+\)$/.test(part));
if (parts.at(-1) === "index") parts.pop();
return `/docs/${parts.join("/")}`.replace(/\/$/, "");
}

function navRoute(value) {
if (typeof value !== "string") return null;
return value.match(/^\[[^\]]+\]\((\/docs(?:\/[^)#]*)?)(?:#[^)]+)?\)$/)?.[1] ?? null;
}

// Lines consumed by the frontmatter block plus the blank run splitFrontmatter
// strips after it. Added to body-relative line numbers so a reported error
// points at the real line in the file.
Expand Down Expand Up @@ -84,9 +122,43 @@ async function main() {
for (const s of SECTIONS) files.push(...(await walk(join(ROOT, s))));
files.push(...STANDALONE_DOCS.map((path) => join(ROOT, path)));
const docs = files.filter(isDoc).filter((p) => !GENERATED.has(relative(ROOT, p)));

const errors = [];

const rootMeta = JSON.parse(await readFile(join(ROOT, "meta.json"), "utf8"));
if (JSON.stringify(rootMeta.pages) !== JSON.stringify(ROOT_NAV_PAGES)) {
errors.push(
"meta.json top-level navigation must use the six section separators and extracted folders",
);
}

const sidebarDocs = files
.filter(isDoc)
.filter((path) => !relative(ROOT, path).replace(/\\/g, "/").startsWith("reference/"));
const expectedRoutes = new Set(sidebarDocs.map(docRoute));
const seenRoutes = new Map();

for (const navFile of NAV_FILES) {
const meta = JSON.parse(await readFile(join(ROOT, navFile), "utf8"));
const entries = [meta.pagesIndex, ...(meta.pages ?? [])];
for (const entry of entries) {
const route = navRoute(entry);
if (!route) {
errors.push(`${navFile} every sidebar entry must be a direct /docs link: ${entry}`);
continue;
}
seenRoutes.set(route, (seenRoutes.get(route) ?? 0) + 1);
if (!expectedRoutes.has(route)) {
errors.push(`${navFile} sidebar link has no non-Reference MDX page: ${route}`);
}
}
}

for (const route of expectedRoutes) {
const count = seenRoutes.get(route) ?? 0;
if (count === 0) errors.push(`${route} page is hidden from the public sidebar`);
if (count > 1) errors.push(`${route} page appears ${count} times in the public sidebar`);
}

for (const abs of docs) {
const rel = relative(ROOT, abs);
const raw = await readFile(abs, "utf8");
Expand Down Expand Up @@ -123,7 +195,8 @@ async function main() {
}
console.log(
`check-docs: OK — ${docs.length} doc(s) across ${SECTIONS.length} sections ` +
`and ${STANDALONE_DOCS.length} standalone overview(s) pass the authoring contract`,
`and ${STANDALONE_DOCS.length} standalone page(s) pass the authoring contract; ` +
`${expectedRoutes.size} non-Reference page(s) appear exactly once in the sidebar`,
);
}

Expand Down
24 changes: 12 additions & 12 deletions scripts/generate-openapi.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,9 @@
// regenerated by the `beforeWrite` hook below — keep REFERENCE_META in sync
// with the hand-authored index and the desired sidebar structure.
//
// `content/docs/reference` is a regular folder in the unified documentation
// sidebar. The `beforeWrite` hook must preserve that structure on every build.
// `content/docs/reference` is extracted below the Reference separator in the
// unified sidebar. Its API-family folders remain native Fumadocs folders. The
// `beforeWrite` hook must preserve that structure on every build.
// CLI and MCP live under `content/docs/(guide)/usage/` and are generated by
// scripts/generate-cli.mjs.

Expand All @@ -34,9 +35,8 @@ const OUTPUT_DIR = "./content/docs/reference";
// component can look the spec up in the preloaded map.
const DOCUMENT_ID = "./openapi.yaml";

// Static part of the section meta. The Reference section itself opens with the
// other top-level docs groups. Single-operation protocol folders are flattened
// into direct links below; only multi-operation groups keep another tree level.
// Static part of the section meta. Reference is the one docs section that keeps
// native child folders because API families are a useful level of hierarchy.
const REFERENCE_META = {
title: "Reference",
pagesIndex: "index",
Expand All @@ -59,16 +59,16 @@ const TAG_TITLES = {

const ROOT_PAGES = [
"---Inference---",
"[OpenAI Chat Completions](/docs/reference/openai-compatible/createChatCompletion)",
"[OpenAI Responses](/docs/reference/openai-responses/createResponse)",
"[Anthropic Messages](/docs/reference/anthropic-compatible/createMessage)",
"[Google GenerateContent](/docs/reference/google-compatible/googleGenerateContent)",
"openai-compatible",
"openai-responses",
"anthropic-compatible",
"google-compatible",
"---Platform---",
"discovery",
"[BYOK encryption](/docs/reference/byok/getEncryptionPubkey)",
"byok",
"management",
"[Metrics](/docs/reference/observability/getMetrics)",
"[Health](/docs/reference/health/ping)",
"observability",
"health",
];

const MANAGEMENT_GROUPS = [
Expand Down
Loading