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
seriesstring and each of them prints the whole run at its foot. /archiveis 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 noteat 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
Theoremand write<Theorem kind="definition" title="Optional name">…</Theorem>.kindistheorem,lemma,proposition,corollary,definition,example,remarkorproof; the first six share one count per page, and a proof ends with ∎. The words arestatementsinsrc/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
.mdxand 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
.mdxbody and places it where it should sit. Wrap it in.not-proseso 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>fromastro:assetsin an.mdxfile, 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 throughcolor-schemerather thanprefers-color-scheme, so a<picture>with a media query will not follow the switcher. Importing an.svggives you a component instead, and because the markup is inlined,currentColorand 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>arole="img"and a<title>— that title is its alt text — and set its ownwidthandheightso 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.xmlcarries 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, notpnpm 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
.mdto 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.mdfor the front page. They are static files — nothing negotiates a header — and the wording is insrc/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