Writing
Single-page view · as markdown
Articles and frontmatter
Trail / Writing
An article is a single .md file named <order>--<slug>.md: a YAML frontmatter block, then markdown.
- -
The frontmatter is required and must be the first thing in the file: a line of exactly ---, the YAML, and a closing ---. A file that does not start with ---, or whose block is never closed, is a build error. So is any key not listed below — a typo'd descripton: fails the build rather than silently doing nothing.
The keys #
| Key | Required | |
|---|---|---|
title | yes | The page's name: its h1, its breadcrumb, its sidebar entry, its search-result heading, its og:title. |
type | in topics | The learn taxonomy — concept, how-to, reference on this site. An error inside a book. |
description | no | One sentence. Becomes the page's meta description and social-preview text. |
related | no | Page references shown as a "Related Content" list at the foot of the page. |
inline_ref | no | Phrases that auto-link to this page wherever prose states them. |
updated | no | An ISO YYYY-MM-DD date. |
reading_minutes | no | Overrides the estimated reading time. |
title #
The only key every article must have, in topics and books alike. It is not derived from the slug — unlike a topic or chapter title, an article states its own.
type #
Required on every article in a topic, and rejected on every article in a book.
Trail does not constrain the value or display it on the page; it is carried through to site.json for tooling that wants to filter by it. What it is really for is the author: deciding whether a page is a concept (explaining how something works), a how-to (walking through a task) or a reference (exhaustive, looked-up rather than read) is a genuinely useful discipline, and pages that cannot answer the question are usually two pages.
Inside a book the question is already answered — a specification is read in order — so trail rejects the key rather than let it sit there meaning nothing.
description #
Optional, but write one. It becomes:
- the page's
<meta name="description">andog:description, which is what search engines and chat apps show; - the standfirst line in the page's markdown mirror;
- the
descriptionfield insite.json.
It is not displayed on the page itself, and it is not the in-site search snippet — Pagefind builds those from the body text around the match.
Build with --strict to make a missing description a build failure. Alias pages are exempt, since the original carries it.
related #
A list of page references, in the same grammar body links use but without the leading ~:
related:
- pekit/running/invocation
- peios/package-management/overview
They render as a "Related Content" list under the article, and they appear in the page's markdown mirror and in site.json. Resolution is as strict as a body link: an ambiguous reference always fails the build, and a missing one fails unless --allow-dangling-links is set, in which case it warns and drops from the list.
inline_ref #
Phrases that should become links to this article wherever they appear in the site's prose:
inline_ref:
- PCDS GUID
- PCDS GUIDs
Every occurrence in ordinary prose, anywhere on the site, links here — except on this page itself. Phrases are claimed site-wide and exclusively; two pages claiming the same phrase is a build error. See Inline references.
updated #
An ISO date, and validated as one — 2026-05-01, not May 2026 and not 01/05/2026.
updated:
It shows in the page's meta line, and it becomes the page's <lastmod> in sitemap.xml. It is deliberately manual: file modification times are noise (a whitespace fix is not an update, and a checkout rewrites them all), and trail does not read git history. An article with no updated: simply does not show one.
reading_minutes #
Trail estimates reading time from the body's word count at 200 words a minute, rounded up, with a floor of one minute. Code blocks count — they are read, just differently.
Set reading_minutes: when the estimate is wrong: a dense reference table reads far slower than its word count suggests, and a page that is mostly a long code listing far faster.
reading_minutes: 30
Roll-ups #
Reading times and dates add up the tree. A subfolder totals its articles; a topic totals its articles and subfolders; a book totals its chapters; an anthology totals its topics and books; a product totals everything in it.
- Reading time is the sum, so a book's cover can say how long the whole book takes and a topic card can say how long the topic is.
updatedis the most recent date anywhere beneath, so a container reflects its freshest page.
Roll-ups are computed after .link aliases are resolved, so an aliased article counts in the totals of the place it was linked into.
The body #
Everything after the closing --- is CommonMark with GitHub extensions: tables, strikethrough, footnotes, and > [!NOTE]-style admonitions. On top of that, trail adds:
~pagereferences — link by page identity instead of URL.- Co-located images —
beside the article, with dimensions filled in and captions turned into figures. - Syntax-highlighted code blocks with copy buttons.
- Tab groups and mermaid diagrams.
- Inline references,
§section citations and RFC-2119 keyword highlighting.
A few things happen to headings automatically: h2 and h3 become the "On this page" list, every heading gets a stable id and a permalink anchor, and inside a book they carry their section numbers as real text. Ids come from the heading text, with a numeric suffix if two headings on a page would otherwise collide.
Tables are wrapped in their own horizontally-scrolling container, so a wide table never makes the whole page scroll sideways.
Naming and ordering #
The filename is not decoration:
200--quick-start.md
│ └── the URL segment, and what you write in a ~link
└── the sort key among siblings — never shown, never in a URL
Two files sharing an order in the same directory is an error. Numbering in hundreds leaves room to insert later; inside a book the prefix is the published section number, so number from 1 there instead.
print is a reserved slug — every container publishes a /print page — so 100--print.md is rejected.
Links and references
Trail / Writing
Trail links pages by identity, not by URL. Instead of writing a path that breaks the moment a page moves, you write a ~ reference naming the product and enough of the page's slugs to be unambiguous:
Reorganise the tree — move that topic into an anthology, wrap it in a shelf, renumber it — and the link still resolves. Delete the target, and the build fails and tells you every page that pointed at it.
The grammar #
~<product-slug>[/<segment>]…[#<fragment>]
- The first segment is always a product slug. There is no relative form and no site-wide search; every reference says which product it means.
- The remaining segments match the end of a page's path. Trail compares them against each page's slugs — the anthology slugs, the topic slug, any subfolder or chapter slugs, and the page's own — and matches if your segments are the tail of that list.
- Exactly one page must match. Zero or several is an error.
- A fragment may follow, and is appended to the resolved URL:
~pekit/reference/cli#global-flags.
The rule of thumb is: write the shortest thing that is unambiguous, and add segments from the left only when you have to.
A worked example #
Given this page:
/peios/security-fundamentals/identity/tokens/issuing
└ anthology ───────┘ └ topic ┘ └folder┘ └page┘
all of these resolve to it:
~peios/issuing
~peios/tokens/issuing
~peios/identity/tokens/issuing
~peios/security-fundamentals/identity/tokens/issuing
~peios/issuing is the one to write — until a second page in peios is also called issuing, at which point that reference becomes ambiguous, the build fails, and you add a segment.
Note that the deeper segments are for disambiguation only. A chapter or subfolder slug can appear in a reference even though it is not a page you could link to on its own.
What can be a target #
| Targetable | Reference | |
|---|---|---|
| A product | yes | ~pekit |
| An anthology | yes | ~peios/security-fundamentals |
| A book cover | yes | ~peios/pgss |
| An article | yes | ~pekit/reference/cli |
| A topic | no | link to an article in it |
| A topic subfolder | no | (except as a .link target) |
| A book chapter | no | link to an article in it |
| A shelf | no | it has no page at all |
Topics, subfolders and chapters have no page of their own, so there is nothing for a reference to land on. Link to the article you actually mean.
Ordinary links still work #
A ~ at the start of a destination is what makes it a reference. Everything else is left alone:
Root-relative links are the escape hatch for the handful of URLs that are not pages — /print views, /llms.txt, /site.json. They are not checked, so they are also the way to ship a broken link. Prefer ~ wherever the target is a page.
~ is only meaningful in a link destination. In an image destination it is an error, with a message saying so: images are files, addressed relative to the article.
Where else references are used #
The same grammar appears in three other places:
# an article's frontmatter — no leading ~
related:
- pekit/running/invocation
# a .link alias file — with the ~
= "~pekit/running/signing-and-provenance"
# the root trail.toml's nav — with the ~, or an external URL
[[]]
= "Specs"
= "~peios/pgss"
The related: list is the odd one out in dropping the ~; everything else keeps it.
When a link breaks #
Every link in the site is checked on every build, and all the failures are reported together — one build tells you the whole story rather than stopping at the first bad reference.
Error: 3 broken links:
in article '/pekit/recipes/anatomy': link '~pekit/running/invokation' matches no page in product 'pekit'
in article '/peios/identity/sids': link '~pekti/overview' names unknown product 'pekti'
in article '/pekit/reference/cli': link '~pekit/targets' is ambiguous; candidates:
~pekit/running/commands-and-targets/targets
~pekit/reference/recipe-format/targets
There are three failure kinds, and they are not treated alike:
| Means | With --allow-dangling-links | |
|---|---|---|
| Unknown product | the first segment is not a product slug | warning |
| No match | nothing in that product ends with those segments | warning |
| Ambiguous | two or more pages match | still fatal |
Ambiguity is never downgradable. A missing target is a link that does nothing; an ambiguous one is a link that goes somewhere specific and possibly wrong, and only the author knows which page was meant.
--allow-dangling-links is for the middle of a refactor, when you have moved a hundred pages and want to see the site before fixing every reference. It is not a setting to leave on.
References in print views and mirrors #
The single-page and markdown views rewrite references so they still work away from the site:
- In a
/printbundle, a reference to a page inside the same bundle becomes an in-document anchor, so the whole document navigates itself. A reference to a page outside it becomes an absolute URL when the site config has aurl, and stays root-relative otherwise. - In a
.mdmirror, a reference becomes the target's own.mdURL, so a reader following links through the markdown surface stays in it.
Images
Trail / Writing
Images live beside the articles that use them. There is no assets directory to maintain and no separate URL scheme to remember: drop the file in the same folder as the .md, and reference it by name.
2--logon.topic/
├── 100--overview.md
└── logon-flow.svg
The image publishes at the URL of the directory it was found in, keeping its own file name — here /peios/logon/logon-flow.svg. Move the folder and the image moves with it.
Referencing #
Destinations resolve relative to the article's own .md file, exactly as an editor's markdown preview resolves them. Relative paths may climb out of a subfolder with .., so a diagram shared by a folder's worth of articles can sit one level up:
2--concepts.topic/
├── shared-diagram.svg
└── 300--replication/
├── 100--topology.md 
└── 200--conflicts.md 
Three kinds of destination are left exactly as written and never checked: absolute URLs (https://…), root-relative paths (/…) and data: URIs.
A ~ reference in an image destination is a build error. ~ names pages, not files.
Where images may sit #
Anywhere articles may sit: a topic, a topic subfolder, a book, or a book chapter. Not in a product, anthology or shelf directory — those hold only trail.toml and subdirectories.
The file name must be a valid slug plus a lowercase extension: logon-flow.svg, not Logon Flow.SVG. Recognised extensions are:
png jpg jpeg gif svg webp avif
Anything else in an article directory is an "unexpected file" error — which is the point. A .pdf or a .xlsx sitting in a content folder is almost always a mistake, and trail would rather say so than quietly ignore it.
Dimensions #
Trail reads each raster image's header at build time and emits real width and height attributes:
The browser can then reserve the right space before the file arrives, so text does not jump around as images load. SVGs get no dimensions — they scale — and neither does any file whose header cannot be read.
Captions and figures #
An image with a title — the quoted string after the destination — that sits alone in its own paragraph becomes a <figure> with a <figcaption>:
How a logon token reaches the session service
The two strings do different jobs and should say different things. The alt text describes the image for someone who cannot see it. The caption is prose everyone reads, and can say what the diagram is for.
An image without a title, or one sharing its paragraph with other text, renders as an ordinary inline <img>.
Unreferenced images #
Only images some article actually references are copied into the output. One that nothing references prints a warning and is left out:
warning: image '/…/2--logon.topic/old-diagram.png' is not referenced by any article and was not published
A warning rather than an error, deliberately: adding the file before the article that will use it is a normal way to work. But a warning that stays warning is a file to delete.
A referenced image that does not exist is the other way round — a broken link, fatal unless --allow-dangling-links downgrades it:
in article '/peios/logon/overview': image 'logon-flow.svg' not found (resolved relative to the article's folder)
In the other views #
Images work in the markdown mirrors and /print bundles too: since those serve an article's body away from its own folder, relative destinations are rewritten to the image's published URL.
Diagrams #
For flowcharts, sequence diagrams and the like, consider a mermaid code block instead of an image file. Mermaid diagrams are text — diffable, editable, and they follow the reader's light or dark theme, which a checked-in PNG cannot.
Code blocks
Trail / Writing
Fenced code blocks work as they do in any markdown, with an info string naming the language:
Trail highlights that at build time and wraps it with a copy button:
…
fn …
Highlighting is classes, not colours #
Highlighting happens once, when the site is built — there is no highlighter shipped to the browser and no flash of unstyled code.
What is emitted is class names, not inline colours: hl-keyword, hl-string, hl-comment and so on, prefixed so they cannot collide with the theme's own classes. Those classes resolve through the same CSS custom properties as everything else on the page, which is why code follows the reader's light or dark choice instead of freezing one palette into the markup. Theming covers the tokens involved.
The copy button #
Every code block gets one. It sits in the top-right corner, appears on hover (and is always visible on touch devices), copies the block's text to the clipboard, and briefly says so.
Nothing is stripped from what it copies, so a $ prompt in a shell example is copied along with the command. Write shell examples without prompts if you would rather they paste cleanly.
Which languages are highlighted #
Trail bundles syntect as its highlighter, with the extended syntax collection from two-face — the same definitions bat ships, a little over two hundred of them. The info string is matched against a syntax's name or one of its file extensions, case-insensitively, so both rust and rs work.
Everything you are likely to reach for is covered:
bash / sh / fish / zsh asm awk cmd / bat c / c++ / c#
clojure cmake coffeescript crystal css / scss / sass / less / stylus
d dart diff dockerfile dotenv elixir elm
erlang f# f90 gd gitconfig / gitignore
glsl go / gomod graphql dot groovy haskell hcl / terraform
html http idris ini java js jq
json / jsonnet julia kotlin latex / bibtex lean
lisp llvm lua make markdown matlab nginx
nim nix objective-c ocaml odin orgmode pascal
perl php protobuf puppet purescript python qml
r racket rego robot ruby / haml / slim rust
scala solidity sql svelte swift systemverilog / verilog / vhdl
tcl toml typescript / tsx typst vim vue
wgsl xml / svg yaml zig
…alongside the config and system formats that turn up in documentation: crontab, fstab, passwd, resolv, syslog, ssh_config, known_hosts, .htaccess, requirements.txt, csv / tsv, manpage, strace, email.
Still absent: powershell, and the shell-session forms (console, shell-session). A console block renders as plain text — which is usually what you want anyway, since a $ prompt is not shell syntax.
An info string the set does not recognise is not an error and does not fail the build: the block renders as plain escaped code, with the same wrapper, the same copy button, and its language still recorded on it as data-language.
The definitions' licences ship with every build at /assets/LICENSE-Syntaxes.txt.
Blocks with no language #
A fence with no info string renders as escaped plain text, with the wrapper and copy button intact. Use one for output, trees and anything that is not a language:
Across this site the convention is text for that. The highlighter does not recognise text as a language, so it renders identically to a bare fence while reading more explicitly in the source.
Indented code blocks (four spaces) are supported too, and are treated as having no language.
Inline code #
Inline `code` is ordinary markdown and is not highlighted. It is also excluded from inline reference matching: a claimed phrase inside backticks stays plain text, so writing `PCDS GUID` when you mean the literal string does not produce a link.
In the other views #
Code blocks survive into the markdown mirrors and /print bundles as ordinary fenced blocks with their info strings intact — the mirrors reproduce your source, not the rendered HTML.
Admonitions, tabs and diagrams
Trail / Writing
Three constructs on top of ordinary markdown handle the things prose alone does badly: setting something apart, showing the same instruction several ways, and drawing.
Admonitions #
Admonitions use GitHub's alert syntax — a blockquote whose first line is a marker:
Five kinds exist, and no others:
| Marker | Renders as | For |
|---|---|---|
> [!NOTE] | Note | An aside worth knowing, not worth interrupting for. |
> [!TIP] | Tip | A shortcut or a better way. |
> [!IMPORTANT] | Important | Something a reader who skims will regret missing. |
> [!WARNING] | Warning | Something that will go wrong. |
> [!CAUTION] | Caution | Something that will go irreversibly wrong. |
The body is full markdown — lists, code blocks, links, all of it.
A blockquote whose first line looks like a marker but is not one of the five is a build error, not silently-rendered text:
unknown admonition type '[!WARN]' (expected NOTE, TIP, IMPORTANT, WARNING or CAUTION)
That is deliberate. A typo'd marker renders as a quotation with [!WARN] sitting at the top of it, which nobody proofreading their own page ever notices.
An ordinary blockquote — no marker — is still an ordinary blockquote.
Tab groups #
Tab groups show the same instruction several ways, so a page does not have to repeat itself per platform, per shell, or per package manager.
The grammar is three markers, each alone on its own line:
| Marker | |
|---|---|
:::tabs | Opens a group. |
:::tab <label> | Starts a tab. The label is everything after the marker. |
::: | Closes the group. |
The first tab is shown; the rest are hidden until selected. Each panel's body is full markdown, so a tab can hold code blocks, lists, admonitions and images.
Rules, all of them enforced at build time:
- A group must be closed — an unclosed
:::tabsis an error. - A group must contain at least one
:::tab, and each:::tabneeds a label. - Nothing but blank lines may appear between
:::tabsand its first:::tab. - Groups cannot nest.
:::taboutside a group, or:::with no group open, is an error.
Markers inside fenced code blocks are left alone, which is how this page can show the syntax without triggering it.
Diagrams #
A code block tagged mermaid is rendered as a diagram in the browser:
Any mermaid diagram type works — flowcharts, sequence diagrams, state diagrams, ER diagrams, Gantt charts, and the rest.
The mermaid library is a large script, so it is only shipped to pages that actually contain a diagram. A site with no diagrams anywhere never emits it at all.
The diagram is drawn in the reader's current theme, light or dark, chosen when the page loads. Switching theme afterwards re-styles the rest of the page but leaves already-drawn diagrams as they were until the page is reloaded.
Diagram or image? #
Prefer mermaid when the diagram is boxes, arrows and labels. It is plain text in your source: diffable, reviewable, editable by whoever is next in the file, and it follows the reader's theme.
Reach for an image when the thing is genuinely pictorial — a screenshot, a photograph, an illustration, or a diagram whose layout matters more than mermaid will let you control.
Inline references
Trail / Writing
Some terms should always be links. If there is one page explaining what a PCDS GUID is, then every "PCDS GUID" written anywhere on the site ought to point at it — and nobody wants to write that link by hand a hundred times, or notice the ninety-nine places it was forgotten.
An inline reference is a phrase a page claims. Trail then links every occurrence of that phrase in the site's prose to the claiming page, automatically.
Declaring a phrase #
Declaration is self-service: a thing declares the phrases that mean it, in its own file. There is no central glossary to maintain.
An article declares in its frontmatter:
---
title: Globally unique identifiers
type: reference
inline_ref:
- PCDS GUID
- PCDS GUIDs
---
Everything else declares in its trail.toml:
# 2--pgss.book/trail.toml
= "Peios Generic System Standards"
= "PGSS"
= "Cross-platform system standards from the Peios Project"
= ["PGSS", "Peios Generic System Standards"]
Phrases are matched literally, so plurals and alternative spellings are separate claims. List them all.
Where a phrase lands #
| Declared on | Links to |
|---|---|
| An article | that article |
| A product | the product's landing page |
| An anthology | the anthology's landing page |
| A book | the book's cover — and § may reach a section |
| A topic | the topic's first article |
| A topic subfolder | the folder's first article |
| A book chapter | the chapter's first article |
The pattern is: the declarer's own page, or — for the containers that have no page — the first article inside it. A container that declares a phrase but holds no articles to land on is a build error.
A shelf may not declare a phrase at all, since it has neither a page nor articles of its own. Declare it on the shelf's items.
Where phrases are matched #
In ordinary prose, and only there. Matching skips:
- Headings, whose ids are derived from their text;
- Link text — a phrase inside an existing link is left alone, so a deliberate link is never rewritten;
- Image alt text;
- Code blocks and inline code — writing
`PCDS GUID`when you mean the literal string produces no link; - The claiming page itself, which never links to itself.
Two more rules make matching predictable:
- Whole words only. A phrase surrounded by letters, digits or underscores does not match. "PCDS GUIDs" does not match inside "PCDS GUIDsomething".
- Longest wins. When two claimed phrases overlap at the same position, the longer one is used — so "PCDS GUID" beats "PCDS", and a phrase does not get chopped up by a shorter one inside it.
Phrases are exclusive #
A phrase belongs to exactly one declarer, site-wide. Two things claiming the same phrase is a build error:
inline_ref phrase 'PCDS' is claimed by both book '/peios/pcds' and article '/peios/formats/pcds-overview'
That is the whole point: if two pages both want to own a term, the ambiguity is real and a reader would have been sent to the wrong one. Decide which page is the destination.
Empty phrases, phrases with leading or trailing whitespace, and phrases containing § are also errors. Link aliases never claim phrases — the original keeps them.
Choosing phrases well #
Inline references are powerful in the way find-and-replace is powerful. A phrase that is too common turns the site into a sea of blue.
- Claim terms of art, not ordinary words. "PCDS GUID" is a term. "identity" is a word.
- Claim what a reader would want defined. The test is whether someone meeting the phrase mid-sentence would benefit from a link.
- Prefer the specific form. Claiming "GUID" catches every passing mention; claiming "PCDS GUID" catches the ones that mean your page.
- Remember plurals and expansions — they are separate claims, and a term claimed in only one of its forms links inconsistently, which reads worse than not linking at all.
Section citations with § #
A phrase claimed by a book can be extended with a section number in prose:
"PGSS §2.1" becomes one link to that exact section. The suffix is a single space (or non-breaking space), the § sign, and a section label — digits, dots and appendix letters: §2, §2.1.3, §A, §B.2. A sentence-ending full stop is not part of the label.
Every book carries an index of its section numbers, covering:
- articles, by their section number (
§2.1→ that article's page); - chapters, by theirs (
§2→ the chapter's first article); - headings inside articles — the
h2andh3numbers trail generates (§2.1.3→ that article, at that heading).
So a citation can be as precise as a subsection of a page.
A § label that does not resolve is a build error — with the phrase still linked to the book, so the page is not broken while you fix it:
'PGSS §9.9' does not match any section of '/peios/pgss'
Like a broken ~link, it is downgraded to a warning by --allow-dangling-links.
Bare § inside a book #
Within a book's own pages, a bare § reference resolves against the surrounding book, with no phrase needed:
Unlike the phrase-qualified form, a bare § that does not resolve stays plain text and says nothing. That is not an oversight: specifications routinely cite sections of other documents, and a build that failed on every "§9.9 of RFC 3986" would be unusable. The qualified form names its book and so can insist; the bare form cannot, so it does not.
RFC-2119 keywords #
Requirement keywords are highlighted everywhere on the site, with no configuration and nothing to declare:
MUST · MUST NOT · REQUIRED · SHALL · SHALL NOT · SHOULD · SHOULD NOT · RECOMMENDED · NOT RECOMMENDED · MAY · OPTIONAL
They must be written in capitals, as RFC 2119 intends — the capitals are what distinguishes a requirement from the ordinary English word. Matching is whole-word and longest-first, so "MUST NOT" highlights as one keyword rather than as "MUST" followed by a stray "NOT", and it skips the same places phrase matching skips: headings, links, and code.
The styling is a weight and the product's accent colour, not a badge. It is meant to make a requirement scannable in a page of prose, and it works in ordinary articles as well as in books.