StatsHub Docs

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.

ShapeThe reader's stanceExamples 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.

index.mdx
writing-docs.mdx
meta.json
meta.json
<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.

src/lib/source.tsx
export const source = loader(docsSource, {
  baseUrl: docsRoute,
  icon: resolveIcon, 
  plugins: [openapiPlugin()],
});
```ts title="src/lib/source.tsx" lineNumbers
  icon: resolveIcon, 
```
WriteEffect
title="src/x.ts"A filename header, with a language icon
lineNumbersLine numbers; lineNumbers=12 starts the count at 12
noCopyDrops 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":

apps/statshub-docs/package.json
{
  "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.

<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.json still 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

CommandWhat it catches
bun run build:docsA 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-docsAn internal /docs/... link pointing at no page, via scripts/check-links.ts
bothA 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.

On this page