Content

Four collections, defined in src/content.config.ts and validated at build. Articles are Markdown or MDX files in src/content/articles/; desks, authors and series are JSON lists.

A build fails, loudly and with the file named, when a field is missing or the wrong shape. That is the point of the schema: a magazine with a headline and no photo credit should not ship.

desks

src/content/desks.json. A desk is a section of the magazine and the axis the whole site turns on. The id is the route segment (/world) and the value an article puts in desk, so there is no second slug field to fall out of sync with it.

{
  "id": "ports",
  "name": "Ports",
  "blurb": "The edge where a country meets its freight: quays, cranes, and the weather that stops them.",
  "order": 1
}
Field Type Notes
id string The route segment. Lowercase, hyphenated
name string Printed on the desk bar and, by default, in an article’s kicker box
blurb string? The standing line on the desk front. Absent: the front opens into the list
order positive int Navigation and home order

Adding a desk is a row here plus a row in nav.header. The header list is written by hand rather than looped, so a desk you do not want in the bar can exist without appearing in it.

articles

One file per article, in src/content/articles/. The filename is the slug, and the route is /<desk>/<filename>.

---
title: 'The cranes at Kieldrecht'
deck: 'A row of ship-to-shore gantries stands on the left bank of the Scheldt, above a polder.'
standfirst: 'The MSC PSA European Terminal is the newest edge of the Port of Antwerp.'
desk: 'ports'
kicker: 'Dispatch'
byline: ['linnea-holm']
date: 2026-09-18
hero: '../../assets/sample/the-cranes-at-kieldrecht.avif'
heroAlt: 'A long row of container gantry cranes silhouetted above flat ground at dusk'
heroCaption: 'Container cranes at the MSC PSA European Terminal, Kieldrecht.'
heroCredit: 'Benoit Brummer / CC BY 4.0'
heroFocus: 'right'
format: 'report'
featured: true
series: 'the-left-bank'
tags: ['ports', 'shipping', 'infrastructure']
---
Field Type Required Notes
title string yes The headline
deck string yes Two or three lines. Carries the cover line and the desk list
standfirst string The lead paragraph under the headline. Falls back to deck
desk desk id yes Must exist in desks.json
kicker string The label box. Falls back to the desk name
byline author ids, at least one yes Must exist in authors.json
date date yes YYYY-MM-DD. The dateline, and the sort order everywhere
hero image yes Relative path into src/assets/. The picture leads here, so it is not optional
heroAlt string yes And neither is its alt text
heroCaption string Printed under the picture on the article page
heroCredit string yes A photograph that ships without its source is a liability
heroFocus left center right Where the plate crops when cover lines sit over the picture. Default center
format report photoessay brief Default report. The layout branches on this, and on nothing else. See below
featured boolean Candidate for the home plate’s cover lines. Default false
rail block list See below
series series id Pro. Must exist in series.json
tags string list Pro. Free-form; the tag index is built from what is used
lang BCP 47 code The language this piece is written in, where it is not the site’s
draft boolean Default false. true keeps it out of the build

format decides the shape of the page. A report runs a wide picture with the rail as a column beside the body. A brief opens at the text measure and prints its rail under the body, because a thousand words cannot hold a column alongside. A photo essay puts the headline on the picture, gives the page over to it, and carries no rail at all.

The rail

rail is the column beside the body — from 64rem up, and under the body below that. It is a list of blocks, in the order you want them, each tagged by kind:

rail:
  - kind: stat
    value: '3×'
    label: 'Passengers per sailing since March'
    source: 'Wikimedia Commons'
  - kind: toc
    label: 'In this crossing'
Block Fields Notes
stat value label source? value is set in tabular figures, so keep it short
toc label? Pro. Built from the subheads the piece already has; it takes no entries

The contents block takes no entries of its own on purpose: a hand-kept list of links goes stale the first time a subhead is reworded. A section prints where its first block does, so putting toc above the figures gets you that, and below gets you that instead.

Adding a rail block of your own means a new member of the union in content.config.ts and a case in src/components/article/Rail.astro — a second field beside rail would lose the author’s order.

authors

src/content/authors.json. The id is the value articles use in byline, and the route segment of the contributor page (/by/dessie-okonkwo).

Field Type Notes
id string Lowercase, hyphenated
name string As it is printed in a byline
role string? Printed in the colophon masthead
order positive int Masthead running order. Default 99
bio string? Printed at the foot of an article and on the contributor page
portrait image? Relative path into src/assets/
links list? { label, href }. href must be a full URL, mailto: included

order exists because getCollection returns authors alphabetically by id, which is not an order any masthead was ever set in.

series

Pro. src/content/series.json. A running series, with the same id-is-the-route rule as desks: /series/leaving-the-plain.

Field Type Notes
id string The route segment, and the value articles put in series
name string Printed over the part list and in the series navigation
blurb string Required: a series without a premise is just a tag
order positive int The order series print in on the index

There is no part number on an article. The parts are numbered by date, the way they were published, so a reordering cannot disagree with the dateline.

What is computed, and so is not a field

Reading time, the related pieces at the foot of an article, the desk’s date span, a series part number, the tag counts and the archive’s year groups are all derived at build from what is above. None of them is a frontmatter field, and none of them can drift from the content.

A piece in another language

lang sets the document language and the zone-free date and byline formatting for that piece, so a single article filed in Korean reads correctly without turning the site bilingual.

With features.i18nRouting on, lang is also what gathers pieces onto a /<code>/ front. The site’s own language stays at the root. A language nothing has been filed in gets no front and no entry in the switch.

MDX

A .md file is Markdown. Rename it .mdx and you can import components into the body:

import Callout from '../../components/mdx/Callout.astro';
import Figures from '../../components/mdx/Figures.astro';

<Figures
  caption="Sailings and passengers, by month"
  rows={[
    { label: 'March', value: 214 },
    { label: 'June', value: 602 },
  ]}
  source="Wikimedia Commons"
/>

<Callout label="How this was reported">
  Three crossings, and the terminal log for the eleven months before them.
</Callout>

The three body components are in src/components/mdx/: Callout (an editor’s note or a method box), Pullquote (a line set beside the column) and Figures (a table with the proportions drawn in). What each takes is in components.md.

For three headline numbers, use the rail’s stat blocks instead of Figures. The table is for a set that only means something side by side.

Adding a field

Edit the schema in src/content.config.ts, then read it in the component that prints it.

Make it optional, or give it a default. A required field invalidates every file that does not have it yet, and the build stops on the first one. Adding .optional() now and tightening it later, once the content has caught up, costs nothing.