Style Guide
Page archetypes, heading rules, and the markers that let CI run a tutorial's steps.
Every page on this site fits one of three archetypes. Pick the one your page is, follow its heading shape, and stop. This page is an explainer, the second archetype below.
Archetypes
Procedure
A recipe the reader follows in order: every tutorial, every how-to page, and "Testing a tutorial".
Heading shape: a group heading (## Steps, or whatever names the group) followed by
unnumbered ### Title sub-headings. The order is the heading sequence, so a heading is the
title alone: never ### Step 1 — Title, ### Option 1 — Title or ### 1. Title.
When every step is a single line, typically one command, use a numbered list under
## Steps instead of sub-headings. The split is by step length: steps with paragraphs of
explanation get sub-headings, one-liners get a list.
A how-to page has this section order:
(intro paragraph, optional)
## Goal
## Prerequisites
## Steps
## Verification
## Notes (optional)
(trailing topical sections, optional, such as "## Why does this happen?")A tutorial has ## Prerequisites, then ## Steps with unnumbered ### sub-headings, then
any trailing sections such as ## What's next?. It has no ## Goal: its title and
introduction frame it. A contributing page has the same shape, without the version line.
Explainer
A conceptual page of parallel topics with no inherent order. The reader arrives, reads the section they need, and leaves. Examples: every concepts page, and this one.
Heading shape: flat, unnumbered ## headings, each a parallel topic. ### sub-headings are
fine where one topic decomposes naturally, and are not numbered either.
Reference
Data the reader looks up rather than reads top to bottom, to verify or to copy: the HTTP API, configuration, the CLI.
Heading shape: sibling pages use the same ## sequence in the same order, so the reader
navigates by habit. A page where a section does not apply omits it rather than leaving it
empty.
Heading Rules
Four rules apply to every archetype. pnpm test fails on a page under content/docs/ with
an H1 in its body, a heading that starts N., Step N or Option N, or a title, heading or
card title with a lowercase major word.
Title Case
Titles, headings, and the names of pages in links and cards are in title case, following the
Chicago Manual of Style. Capitalize the first and last words and every major word: nouns,
pronouns, verbs (including Is), adjectives and adverbs. Lowercase articles, the coordinating
conjunctions and, but, for, nor and or, and prepositions of up to four letters,
unless one starts or ends the title.
## Lifecycle and status ← wrong
## Lifecycle and Status ← right
## Set Up a Clone ← right: "Up" is an adverb hereThe test checks titles, headings and <Card> titles, not link text, and only that major words
are capitalized: whether a word such as up is a preposition or an adverb depends on the
sentence, so it accepts either case for the minor words. Inline code is left as written.
No H1 in the Body
The front matter's title: is rendered as the page's H1. The body starts at ##; a # Title
line in it makes a second H1.
No Step or Option Numbers in Headings
Use a group heading with unnumbered sub-headings instead:
## Step 1 — Start the Stack ← wrong
## Step 2 — Record a Change ← wrong
## Steps ← right
### Start the Stack
### Record a ChangeNo Standalone Numbered Headings
## 1. Changes ← wrong
## 2. Lifecycle ← wrong
## Changes ← right: flat parallel topics
## LifecycleIf the topics are ordered, the page is a procedure; if they are parallel, an explainer. Numbered headings imply an order with no group heading to frame it.
Describing a Change
Horopter serves changes to many tools, and they display a change in different ways, or not at all. Describe a change by what Horopter stores and returns: its start, its end if it has one, its title and its subject. Say how a tool displays a change only on that tool's own page, such as a how-to for that tool.
An open change is drawn as a region growing to the present. ← wrong
An open change has a start and no end. ← rightComponents
Fumadocs' components are available on every page without an import. Use Callout for a
warning the reader must not miss, and Cards with Card for navigation, as on the landing
page. This site adds two, <HoropterVersion/> and <Version name="…"/>; see "The version
line" below.
Runnable Steps
A tutorial's own code blocks are its test. The harness in scripts/tutorial/ parses a page
with the MDX parser the site uses, so what it runs is what the reader sees. A marker is a word
after a block's language, on the opening fence. CI finds tutorials by a line starting
```bash run, or an <include>, so open every run block with exactly that, options
after it, fenced with backticks and not indented.
A block with no marker is shown and never run, and the report does not mention it. A step the
reader is asked to do is therefore either run or manual, never unmarked, so the report
lists every step it did not run.
run
```bash run blocks run in page order in one bash session, under set -euo pipefail, in a
throwaway directory. Variables a block exports and directories it enters carry to later
blocks, as they do for the reader, and stderr joins stdout in the order they were written. A
block that exits non-zero stops the session and fails the page, and the blocks after it do
not run. Only bash blocks run; a block of any other language marked run fails the page.
Two options follow run on the fence, each a positive number of seconds; any other value
fails the page.
| option | default | effect |
|---|---|---|
timeout=<seconds> | 120 | The block's limit. When it ends, the session's processes are killed and the page fails at this block. A container a step started keeps running; remove it with cleanup:. |
retry=<seconds> | none | Repeats the block at this interval until an attempt succeeds or its timeout ends, for a step that waits on something. |
For example, ```bash run timeout=300 retry=5 retries every five seconds for up to five
minutes.
Each retry attempt runs in a subshell under set -e, so a retried block's variables and
cd do not carry forward. Its expect block is checked against the attempt that succeeded.
An attempt succeeds by its exit status, so a readiness check matches exact values with
jq -e, which exits non-zero on false, null or no input:
jq -e '.status.conditions[] | select(.type == "Ready") | .status == "True"' \
< <(kubectl get kustomization app -o json)expect
A ```text expect block after a run block is that block's output, as the reader sees
it, line for line. Unmarked blocks between the two do not separate them; a manual block
does. The harness reads only the expect marker, not the language, so write text.
...within a line matches any text in that line.- A line of only
...matches exactly one line. - A final
...line allows any further output. Without it, an extra line fails the page. - An empty
expectblock allows no output.
A mismatch fails the page but does not stop the session: the blocks after it still run, and
the report names the first block that failed. A run block without an expect block is
checked only by its exit status. A run block has at most one expect block, and an expect
block before the first run block fails the page.
manual
```<lang> manual is a step the harness cannot run, in any language. It is not run, and
the report lists it as untested, by line. An expect block after it is untested with it. A
block cannot be both run and manual.
setup
setup: in the front matter names a script, with any arguments, that builds what the reader
already has before the page starts, such as a cluster running Flux. The harness sources it in
the steps' shell before the first run block, so the variables it exports and the directory it
enters are the steps' own. Name the script through $DOCS_ROOT, the checkout's absolute path,
since the session runs in a throwaway directory:
setup: $DOCS_ROOT/journeys/flux/setup.sh
setup-timeout: 600Setup has its own limit, setup-timeout:, a positive whole number of seconds, 120 when absent.
A setup that exits non-zero or runs past its limit fails the page before any step runs, and the
report names it as a setup failure. Its output goes to the report whether it passed or failed.
setup: must be a string; anything else fails the page.
Where a reader sets a value by hand, in a manual block, setup exports the same variable, so
the steps after it read one name for both.
cleanup
cleanup: in the front matter is one shell command, run after the steps whether they passed
or failed, so a failed tutorial does not leave containers or images behind:
cleanup: docker image rm --force "ghcr.io/horopter-dev/horoctl:$HOROPTER_VERSION"It runs in the directory the session started in, with a 120-second limit, in its own shell: it
does not see variables setup or the steps exported, but sees the pins and $DOCS_ROOT. It runs
after a failed setup too, so it must cope with a world setup only half built. A failing cleanup
fails the page. It must be a string; anything else fails the page.
Shared Blocks
Steps that several pages share, such as installing horo, live once in content/snippets/,
outside the page tree, and each page includes them where they stand, on a line of their own:
<include>../../snippets/install-horo.mdx</include>. CI lists a page by a line starting
<include>, as it does by one starting ```bash run.
The path is relative to the page, as Fumadocs resolves it. The harness expands each
<include> the same way, so a snippet's run, expect and manual blocks are the including
page's, and the report names one as line N of content/snippets/install-horo.mdx. It does not
expand an include of one section, file.mdx#section, and fails the page on one; include the
whole file. A snippet sets no pins: each page that includes it sets the ones it reads.
Steps That Cannot Run
Before marking a step manual, rewrite it as something the harness can check. "The change
appears on the graph" becomes the same annotation queried through Grafana's API, in a run
block with an expect block. Mark a step manual only where no check exists, such as opening
a page in a browser. The report keeps every manual step visible, so what is untested is
never silently trusted.
The Version Line
The versions file at the repository root pins every version the docs name, one KEY=value
line each. HOROPTER_VERSION is the Horopter release the docs describe: v0.14.2
today. Every tutorial states the release it is tested against by rendering <HoropterVersion/>
in its introduction:
Each step below is tested against Horopter <HoropterVersion/>.pnpm test fails on a page under content/docs/ with a bash run block that does not render
it anywhere. <Version name="FLUX_VERSION"/> renders any other pin by its key, and a name the
file does not pin fails the build.
Setup, the steps and cleanup: see every pin as a variable of the same name, so a step names
an image as ghcr.io/horopter-dev/horoctl:$HOROPTER_VERSION rather than writing the tag out,
and a bump in versions changes the page and its test together.
A reader's shell has no pins, so a page whose steps read a pin, such as $HOROPTER_VERSION,
has the reader set it first, in a manual block of export KEY=value lines, one per pin, such
as export HOROPTER_VERSION=<release>. That is the one place a pin's value is written out:
pnpm test fails on a page where it differs from the versions file, and Renovate updates
both.
A value a shell variable cannot reach, such as a plugin's id@version in an environment file
or Helm values, is written out too, in a manual block. Give it a test that holds it equal to
the pin, and a match string in that pin's Renovate manager, as the Grafana how-to does for
INFINITY_VERSION.