Writing docs
The house style for this site — where a page lives, what shape it takes, which components are available, and what fails the build.
Add a docs page is the procedure: create the file, fill the frontmatter, order the sidebar, run the gates. This page is the standard the result is held to.
Every doc lives in apps/statshub-docs/content/docs
There is no docs/ at the repository root and none inside an app. A README may
introduce a directory in a paragraph; anything longer belongs here, with the
README linking to it. Two copies of an instruction means one of them is wrong
within a month.
Pick one shape per page
Mixing a tutorial into a reference weakens both — the reader looking something up has to skip the teaching, and the reader learning has to skip the tables.
| Shape | The reader's stance | Examples here |
|---|---|---|
| Tutorial | "I'm new — walk me through it" | Getting started |
| How-to | "I have a goal, get me there" | Guides |
| Reference | "I'm working, let me look something up" | Endpoint reference, Reference |
| Explanation | "Help me understand why" | Architecture, OpenAPI and /api/v2 |
Voice
Say why, not just what, and put the failure mode next to the rule. A guide that lists commands without saying what breaks when you skip one is the kind of doc nobody reads twice.
Ground every instruction in what the repository does today. Read the
package.json scripts and the config you are describing rather than writing the
generic version. If a command in a guide does not run, the guide is wrong, not
the reader.
Write "you", keep the active voice, and cut the words that carry nothing — "in order to" is "to", "it is necessary to" is "you must".
Three habits worth naming because they keep appearing:
- Do not restate the title as an
# H1. The title comes from frontmatter, and a heading repeats it. - Do not open with the description. It is printed under the title already, so the first paragraph should add something.
- Date anything that will go stale. A plan, an audit, a status note — put the date in the text, not only in the filename.
Components
These are what the page can reach for. All of them work in .mdx; the ones
built from a fence or a heading work in plain .md too, which is why they are
preferred.
Directory trees
<Files> beats a table when the shape of the tree is the point — what nests
inside what.
<Files>
<Folder name="content/docs" defaultOpen>
<File name="meta.json" />
</Folder>
</Files>Use a table instead when the reader wants "what is this directory for" rather than "what is the shape" — the tables on web and api are that case.
Callouts
:::note, :::tip, :::info, :::warning, :::danger, :::success, with an
optional title in brackets.
Reserve these for consequences
A callout that says something ordinary trains people to skip callouts. Use one where ignoring the paragraph costs something.
Steps
Headings numbered ## 1. Do the thing render as a numbered list automatically —
add a docs page is doing it. Use it for a
procedure with an order; do not use it for a list of options.
Diagrams
A ```mermaid fence renders a diagram, lazily on the client. Reach for one
when the relationship is hard to hold in a sentence, as on
Architecture — not to restate a list.
Code blocks
Shiki highlights every fence. The meta after the language and the [!code ...]
comments do the rest — all of it works in .md too.
export const source = loader(docsSource, {
baseUrl: docsRoute,
icon: resolveIcon,
plugins: [openapiPlugin()],
});```ts title="src/lib/source.tsx" lineNumbers
icon: resolveIcon,
```| Write | Effect |
|---|---|
title="src/x.ts" | A filename header, with a language icon |
lineNumbers | Line numbers; lineNumbers=12 starts the count at 12 |
noCopy | Drops the copy button — use it for output, not for commands |
tab="Label" | Groups consecutive fences into tabs |
// [!code highlight] | Marks that line |
// [!code word:resolveIcon] | Marks every occurrence of a word |
// [!code ++] / // [!code --] | Renders the line as an addition or a removal |
// [!code focus] | Dims everything except the marked lines |
Two of those earn their place more than the rest. Highlight the line the
paragraph is about — a twenty-line config block with the one line marked is
read; the same block unmarked is skipped. And use ++/-- rather than
prose when the instruction is "change this to that":
{
"scripts": {
"dev": "bun run openapi && next dev --port 3001",
"predev": "bun run openapi",
"dev": "next dev --port 3001"
}
}noCopy matters for a different reason: a copy button on a block of terminal
output invites someone to paste it back into a shell.
[openapi] 355 files in content/docs/api/reference
348 pages, every internal /docs link resolves.The comment syntax follows the fence's language, so it is # [!code highlight]
in bash and <!-- [!code highlight] --> in HTML.
Maths
$...$ and $$...$$, through KaTeX. The odds and model pages use it.
Inlining a real file
A doc-gen:file block reads a file from the repository at build time, so the
snippet cannot drift from the code it quotes. Prefer it to pasting.
```json doc-gen:file
{ "file": "../../../../../turbo.json", "codeblock": { "lang": "json" } }
```The app pages: <TechStack> and <Feature>
Each page under Apps opens the same way: a sentence on what the app is, a strip of the technologies in it, then two grids — what it does for whoever uses it, and what is worth knowing before working on it. Keep to that shape when you add an app, so the pages can be read against each other.
<TechStack>
<Tech name="nextdotjs" label="Next.js 16">App Router</Tech>
<Tech name="go" />
<Tech name="Route" label="chi">routing</Tech>
</TechStack>
<Cards>
<Feature icon="Lock" title="Writes to production are blocked">
One or two sentences, and the reason rather than the rule.
</Feature>
</Cards><Tech name> is a Simple Icons slug — nextdotjs,
postgresql, revenuecat. Anything with no brand mark of its own falls back to
a Hugeicons name, which is how chi and nuqs get a glyph. label overrides the
brand's own title; the children are the four-word note on what it does here.
A mark too dark to see on the dark theme is swapped for the foreground colour,
so a black logo does not leave a hole in the strip.
<Feature icon> is a Hugeicons name, the same vocabulary as the icon:
frontmatter field. Both throw on a name that does not resolve, so a typo fails
build:docs rather than rendering an empty box.
Everything else
<Cards>, <Tabs>, <Accordions>, <TypeTable>, <AutoTypeTable>,
<Banner>, <InlineTOC>, <ImageZoom>, <GithubInfo>, <DynamicCodeBlock>,
<Mermaid>, <OpenAPIPage>, <DocsGraph>.
apps/statshub-docs/src/components/mdx.tsx is the list that ships.
How the pages link up
<DocsGraph /> draws the site from its own link references — an edge exists
because one page actually links to another. It is the fastest way to spot a page
nothing points at, which is how a doc gets lost without ever being deleted.
The ~294 generated endpoint pages are excluded; none of them links to another,
and they would bury everything else in a ring of unconnected dots. See
src/lib/build-graph.ts.
Two rules follow from looking at it:
- Every page should be reachable from an index. A page missing from its
directory's
meta.jsonstill builds and still answers its URL — it just vanishes from the sidebar. - Link sideways, not only down. A page that explains a decision should link to the page that applies it, and back.
What fails the build
| Command | What it catches |
|---|---|
bun run build:docs | A frontmatter icon that is not a Hugeicons name — src/lib/icons.tsx throws rather than resolving it to nothing |
bun run lint in apps/statshub-docs | An internal /docs/... link pointing at no page, via scripts/check-links.ts |
| both | A status outside new, beta, draft, deprecated; invalid YAML in frontmatter |
dev serves all of these without complaint, which is why the gates exist. Run
them before you push.