Skip to content
Leader

Documentation

Content

Three collections, all typed with zod in src/content.config.ts. A file that breaks its schema fails the build with the field and the file named, rather than shipping a half-empty row.

src/content/
  work/<slug>.md      one product
  notes/<slug>.md     one note   (.mdx when it imports a component)
  authors/<slug>.md   one person (Pro)

The filename is the URL: work/pennant.md is /work/pennant, notes/one-tab-one-glance.md is /notes/one-tab-one-glance.

Categories come first

Both products and notes require a category, and it has to be one of the slugs in categories in src/config.ts. That is not bureaucracy — the category is the colour, the colour is what makes a group readable down the right edge of every list, and a row without one has nothing on one of its two ends.

Four categories is the ceiling: the palette has four accents and past four the colours stop telling groups apart. Rename one in config.ts and the schema enum changes with it, so a file still using the old slug is a build error rather than a silent colourless row.

Products — src/content/work/

Field Type Required Notes
title string yes The left end of the row
category a slug from config.ts yes The right end, in that category’s colour
summary string, max 160 yes The line under the row, and the meta description when the body offers nothing better
status live | building | archived no Default live. building dims the row; archived drops it to its own group on /work
started date yes Only the year is ever printed
url URL no The product itself
repo URL no Source, if it is public
order integer no Shelf order, ascending. Files without one follow, newest first
tags string[] (Pro) no The second axis. Default []
changelog {date, version?, note}[] (Pro) no Printed under the product page, newest first — the page sorts, so the order here is free
cover {src, alt} (Pro) no One screenshot at the top of the product page. Never on a row. src is relative to the file
offers {name, price, url, demo?, note?}[] (Pro) no Editions and what each costs. price is a number in work.currency; 0 is free. Printed as a price list on the product page, summarised on the row (free edition · from $29), and published as schema.org Product
offersNote string (Pro) no The terms a buyer reads before paying — updates, refunds. Printed once under the editions
draft boolean no Default false. true leaves it out of the build entirely
---
title: 'Pennant'
category: 'tooling'
summary: 'A status page that fits in one browser tab.'
status: 'live'
started: 2024-03-11
url: 'https://pennant.example.com'
repo: 'https://git.example.com/ada/pennant'
order: 1
tags: ['uptime', 'on-call']
changelog:
  - date: 2026-08-19
    version: '2.1'
    note: 'Incident notes accept Markdown.'
  - date: 2024-06-13
    version: '1.0'
    note: 'First version anyone else ran.'
# A product you sell (Pro). The image path is relative to this file.
cover:
  src: '../../assets/work/pennant.png'
  alt: 'Pennant showing three checks and ninety days of history.'
offers:
  - name: 'Self-hosted'
    price: 0
    url: 'https://git.example.com/ada/pennant'
  - name: 'Hosted'
    price: 9
    url: 'https://pennant.example.com/buy'
    demo: 'https://demo.pennant.example.com'
    note: 'a month'
offersNote: 'Cancel any time; the self-hosted edition is yours to keep.'
---

The body is the product page: what it does, what it does not do, why it exists.

The file name is the URL: work/cusp.md is /work/cusp. Folders are part of it, so notes/2026/04/one-tab.md is /notes/2026/04/one-tab — useful if you have enough notes that one flat folder stops being a folder you can read, and necessary if you are moving a site that already has dated addresses and wants to keep them.

A product with status: building is dimmed rather than hidden, because a shelf that shows what is half-built is more interesting than one that does not. If you disagree, set work.showBuilding: false in src/config.ts and it is left out until its status changes.

Notes — src/content/notes/

Field Type Required Notes
title string yes
category a slug from config.ts yes Same list as products
pubDate date yes Sorting, the date column, the feed
updatedDate date no Printed beside the byline when present
description string, max 160 no Without it the first paragraph of the body is used, cut at a word
work a file id in work/ no Files the note under a product: it appears on that product’s page, and vice versa
author string (Pro) no Falls back to site.author; matched to an author file by name
tags string[] (Pro) no Default []
series string (Pro) no The name of a run of notes; every note in it prints the whole run at its foot
draft boolean no Default false
---
title: 'Why the queue got expensive'
category: 'data'
pubDate: 2026-08-14
description: 'A retry policy I wrote in ten minutes cost me four months of database bill.'
work: marlin
tags: ['cost', 'queues']
series: 'Building Marlin'
---

The body. Headings become the contents rail; footnotes are ordinary GFM.

Dates are written plainly (2026-08-14) and read as UTC, so a date-only pubDate never slips a day west of Greenwich.

Authors (Pro) — src/content/authors/

---
name: 'Ada Example'
role: 'Maker'
url: 'https://example.com'
---

A paragraph or two, printed on /authors/<slug> above everything they have written.

name has to match what a note’s author field says, exactly. A note naming someone with no file keeps the plain byline.

The second axis (Pro)

  • Tags are free text and cross categories: /tag/<slug> lists the products and the notes that carry one. Two spellings that slug the same (On-call, on call) become one tag, printed as the first spelling seen.
  • Series are a run of notes in date order. Give the notes the same series string and each of them prints the whole run at its foot.
  • /archive is products and notes in one timeline, by year.

None of the three takes a colour. Colour means category; the second axis is set in the mono face instead.

Inside a note

  • Headings become the contents rail. ## and ### are what it reads.

  • Footnotes are ordinary GFM: text[^1] in the body, [^1]: the note at the end.

  • Math is LaTeX between dollar signs: $…$ inside a sentence, $$…$$ on lines of its own, in notes, products and author pages. KaTeX sets it at build time, so the page ships no script for it, and only a page with math loads the KaTeX stylesheet. Chemical equations use mhchem: $\ce{2H2 + O2 -> 2H2O}$. Two dollar signs in one paragraph ($20 and $30) read as a formula between them; write \$ for a literal one.

  • Numbered equations (Pro): \label{eq:name} in a $$…$$ formula numbers it, and $\eqref{eq:name}$ anywhere in the entry prints (1) as a link to it ($\ref{eq:name}$ the bare number), even before the equation. Copying a formula copies its LaTeX source.

  • Statements (Pro) need MDX. Import Theorem and write <Theorem kind="definition" title="Optional name">…</Theorem>. kind is theorem, lemma, proposition, corollary, definition, example, remark or proof; the first six share one count per page, and a proof ends with ∎. The words are statements in src/i18n/<locale>.ts.

  • Code blocks get a copy button, and are highlighted with the theme’s own colours rather than an imported IDE theme.

  • Callouts (Pro) need MDX. Rename the file to .mdx and import the component:

    import Callout from '../../components/Callout.astro';
    
    <Callout label="If you are copying this">
      The aside for something true that is not the argument.
    </Callout>
  • An app goes in the same way a callout does. A product page that is the front of something you can actually use — a converter, a generator — imports its component at the top of an .mdx body and places it where it should sit. Wrap it in .not-prose so the body styles stop at its edge:

    import Encoder from '../../components/Encoder.astro';
    
    <div class="not-prose">
      <Encoder />
    </div>

    The component is yours, not the theme’s, and its <script> is hoisted and bundled like any other. Nothing about it reaches the lists: a row is a row.

  • Images are the author’s business — the theme is finished without any. There is no src/assets/ until you make one: create it, put the file there, and use <Image> from astro:assets in an .mdx file, or a plain Markdown image for something already sized. Every one needs alt text.

  • A figure that should follow the palette is an SVG, not a picture of one. A photograph is the same in both modes, so <Image> is right for it. A chart or a diagram is not: a light-mode PNG is unreadable on the dark canvas, and the theme switches through color-scheme rather than prefers-color-scheme, so a <picture> with a media query will not follow the switcher. Importing an .svg gives you a component instead, and because the markup is inlined, currentColor and the theme’s own tokens work inside it:

    import Chart from '../../assets/monthly-cost.svg';
    
    <figure>
      <Chart />
      <figcaption>What the caption says.</figcaption>
    </figure>

    The sample note “What the shelf costs to run” does exactly this. Give the <svg> a role="img" and a <title> — that title is its alt text — and set its own width and height so it renders at its drawn size rather than stretching to the measure.

Drafts

draft: true excludes a file from the build everywhere: the lists, the feeds, the search index and the share cards. There is no preview URL — run pnpm dev, where drafts are also excluded, and flip the flag when it is ready.

Feeds, search and the Markdown twins

  • /rss.xml carries every note. Each category has its own feed at /category/<slug>/rss.xml (Pro).
  • Search (Pro) indexes the built HTML, so it only works on a built site: pnpm build && pnpm preview, not pnpm dev. Pages that are lists of other pages — /work, /notes, the category and tag pages — are excluded so a search result is always a thing to read rather than an index of things to read.
  • /llms.txt (Pro) is the whole site on one page, in the form llmstxt.org describes: every product and every note as a line with its address and its summary. An assistant reads it in one request instead of crawling the HTML for the same list.
  • Every page has a Markdown twin (Pro): append .md to its address and you get the Markdown it was written in, with a front matter block naming its category and dates. /work/cusp.md, /notes/one-tab-one-glance.md, /index.md for the front page. They are static files — nothing negotiates a header — and the wording is in src/lib/agents.ts. Pages written as HTML rather than Markdown (/about, the legal set) have no twin: copying their prose into one would leave the same words in two places to drift apart.

Next: Customization