Horopter

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 here

The 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 Change

No Standalone Numbered Headings

## 1. Changes                       ← wrong
## 2. Lifecycle                     ← wrong

## Changes                          ← right: flat parallel topics
## Lifecycle

If 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.                        ← right

Components

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.

optiondefaulteffect
timeout=<seconds>120The 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>noneRepeats 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 expect block 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: 600

Setup 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.

On this page