Guide: Content authoring
Reference for authoring case studies and blog posts as MDX in the git-based content collections (ADR 0004). For the workflow (git-CMS vs MDX, publishing), see ../runbooks/content-editing.md.
The Zod schemas in
apps/web/srcare authoritative. This guide documents the intended shape; if a field here disagrees with the schema, the schema wins (and this guide should be updated). Required fields are enforced at build time — missing/invalid frontmatter fails the build.
Where things live
Section titled “Where things live”apps/web/src/content/ cases/<slug>/index.mdx -> /work/<slug> (folder-per-entry; images sit beside index.mdx) posts/<slug>/index.mdx -> /blog/<slug> (folder-per-entry; images sit beside index.mdx) roles/<slug>.mdx -> /careers/<slug> (flat MDX) policies/<slug>.mdx -> /policies/<slug> (flat MDX; governance + legal documents) services/services.json (one data file, not per-service MDX) team/team.json (one data file, not per-member MDX)Cases and posts are folder-per-entry so their images live beside the entry; the folder
name is the URL slug. services and team are data collections (one JSON file each).
Keep slugs lowercase, hyphenated, and stable: changing a published slug breaks links and SEO.
Frontmatter reference
Section titled “Frontmatter reference”Case study (cases/<slug>/index.mdx)
Section titled “Case study (cases/<slug>/index.mdx)”---title: 'Rebuilding checkout for Acme'client: 'Acme, Inc.'summary: 'A short, outcome-led one-liner shown on the work index. When no seo.description is set it also becomes the meta description, so keep it 70-160 characters with no trailing ellipsis.'disciplines: ['engineering', 'data'] # any of: strategy | design | engineering | ecommerce | dataindustry: 'Fintech' # optional# The outcome model: a declarative headline, an optional 1-2 word kicker, and# genuine numerals ONLY (no words-as-stats). `supporting` requires `stat`.outcome: kicker: 'Acquired.' # optional 1-2 word opener with real punctuation headline: 'A falsifiable, declarative sentence sourced from the body.' # required stat: { value: '10×', label: 'revenue growth' } # optional; genuine numerals only supporting: # optional, max 2 - { value: '500k', label: 'avg weekly pageviews' }role: 'Embedded product & engineering team' # optionalduration: '5 years' # optionalteam: 'Two engineers, one designer' # optionalreleasedDate: 2026-05-01 # requiredupdatedDate: 2026-06-01 # optionalhero: ./hero.jpg # optional; a local image beside index.mdx (run through the image pipeline)heroAlt: "Acme's redesigned checkout on mobile" # required when hero is setheroCredit: 'Photo by Someone on Unsplash' # optional caption rendered under the herofeatured: trueorder: 6 # sort weight on the work indexcomingSoon: falsedraft: falseseo: # optional; falls back to title + summary title: 'Acme checkout rebuild — Quarry' description: "How we rebuilt Acme's checkout as an embedded team, in 70-160 characters."---
Body in MDX. Lead with the outcome, then the approach.Blog post (posts/<slug>/index.mdx)
Section titled “Blog post (posts/<slug>/index.mdx)”---title: 'Why we ship zero JavaScript by default'description: 'The required meta description shown on the index and as the SERP snippet. Must be 70-160 characters with no trailing ellipsis.'publishedDate: 2026-05-15updatedDate: 2026-05-20 # optionalauthor: 'Quarry' # a plain string; defaults to 'Quarry'category: 'Engineering' # optionaltags: ['engineering', 'performance']hero: ./hero.jpg # optional; a local image beside index.mdxheroAlt: 'Descriptive alt text' # required when hero is setheroCredit: 'Photo by Someone on Unsplash' # optionaldraft: false---
Body in MDX.Blog slugs migrated from the legacy site must match the existing
/blog/<slug>to preserve SEO (ADR 0011).
Role (roles/<slug>.mdx)
Section titled “Role (roles/<slug>.mdx)”Open roles for the careers page. The MDX body is the full description.
---title: 'Senior Full-Stack Engineer'team: 'Engineering'location: 'Remote — global' # optional; this is the defaulttype: 'Full-time' # optional; this is the defaultsummary: 'One-line teaser for the careers index.'datePosted: 2026-05-01validThrough: 2026-07-01 # optionalorder: 1draft: false---
Body in MDX.Policy (policies/<slug>.mdx)
Section titled “Policy (policies/<slug>.mdx)”Governance and legal documents — the /policies section (ADR 0016). The MDX body is the document; the shared Policy layout renders the category, title, last-updated stamp, and owning role.
---title: 'Accessibility Statement'summary: 'The index blurb and meta description: 70-160 characters, no trailing ellipsis, saying what the document is.'category: governance # governance | legalupdated: 2026-09-05 # the "last updated" stamp; bump it with every substantive editowner: 'Grievance owner (Principal)' # optional; the accountable ROLE, never a person's nameorder: 5 # sort weight within its category on /policiesinteractive: false # true = rendered by its own on-demand route (see grievances.mdx), not the static template; a drafted interactive entry 404s and leaves the sitemapsitemap: true # false keeps thin legal boilerplate (privacy, terms) out of the sitemapdraft: false---
import Email from '@/components/Email.astro';import { site } from '@/lib/site';
Body in MDX. Plain language; `##` sections; dates stated, not implied.
interactive: trueis for a document that carries a live process (the grievance form). Its page lives atapps/web/src/pages/policies/<slug>.astrowithprerender = false, renders the entry’s body through the same layout, and owns the behaviour. The prose still lives in the collection.
Service (services/services.json)
Section titled “Service (services/services.json)”Services are a data collection — one JSON array, not per-service MDX. Each entry:
{ "id": "engineering", "title": "Full-Stack Engineering", "summary": "One-line description of the capability.", "capabilities": ["Product ownership & discovery", "UX research & UI design"], "discipline": "engineering", "order": 2}discipline is optional, one of strategy | design | engineering | ecommerce | data.
Team (team/team.json)
Section titled “Team (team/team.json)”Team members are a data collection — one JSON file, not per-member MDX:
{ "id": "jane-doe", "name": "Jane Doe", "role": "Principal Engineer", "location": "Portland, OR", "bio": "Short bio.", "avatar": "./avatars/jane-doe.jpg", "lat": 45.5, "lng": -122.7, "order": 1}lat/lng place the globe pin and are city-level only: at most one decimal place (a
~10 km privacy floor enforced at build, so a finer coordinate fails the build). Never commit
a geocoded address for a named person.
avatar is a path relative to team.json — put the photo in
apps/web/src/content/team/avatars/ (not public/). It runs through Astro’s
image pipeline and is rendered with the <Image> component at display size,
so commit a reasonably sized original and let the build do the rest.
Roster avatars are black-and-white. Fully desaturate every avatar before
committing (e.g. sharp(src).grayscale()) — the whole roster carries the same
monochrome treatment, and a color avatar is a visual defect. Treat it as a
checklist item on every roster change. A face-centered square crop (~800×800
JPEG) keeps the 72px roster circle sharp at 2× density.
Image handling
Section titled “Image handling”- Images live beside the entry and are committed to the repo: put them in the case/post folder and reference them relatively (
hero: ./hero.jpg,gallerysrc: ./image-1.jpg, avatars./avatars/<id>.jpg). Astro’s image pipeline generates optimized, responsive variants at build (ADR 0006). - Every image needs meaningful
alttext — accessibility (WCAG 2.2 AA) is a CI gate. Usealt: ""only for purely decorative images. - Provide appropriately sized sources; let the image pipeline generate responsive variants. Committed content images are held to a budget (≤2560px long edge, ≤900 KB) — the pipeline only downscales, so bigger sources just weigh down the repo. The pre-commit hook auto-normalizes staged images;
pnpm images:fixfixes anything the CIImage budgetscheck flags. - Motion uses animated WebP, not GIF, and the reader starts it. The same clip as GIF costs materially more bytes at lower fidelity (a 256-colour palette, 1-bit transparency), and the motion contract below is defined on
.webp, so theImage budgetscheck rejects any.gifinapps/web/src/{content,assets}. Author motion as animated WebP (e.g.sharp(src, { animated: true }).webp()orgif2webp), held to the same ≤900 KB byte budget as any other raster, and place it with the<Motion>component (below). Never reach it with![]()syntax, as ahero, or through a plain<Image>: each of those autoplays it. Motion that starts on its own and runs longer than five seconds fails WCAG 2.2.2 (Pause, Stop, Hide), a Level A criterion beneath the site’s AA bar.pnpm images:fixwrites the clip’s still beside it as<name>.poster.webp; commit both. TheImage budgetscheck enforces the pairing, that the poster matches the clip’s dimensions, and that nothing but<Motion>reaches either file.
Using components in MDX
Section titled “Using components in MDX”MDX entries embed a constrained subset of design-system components: <Motion> places an animated clip in a post (below), and <Email> renders an address and its mailto: link as HTML character references in a policy (<Email address={site.email} />). Keep editorial content mostly prose; reach for components for structure, not decoration. Components inherit the design tokens — never hardcode colors or spacing.
Motion
Section titled “Motion”An animated WebP renders as its still with a play control; the animation is fetched only when the reader asks for it, and the same control returns to the still. Import the clip and its poster beside the entry:
import Motion from '@/components/Motion.astro';import clip from './image-3.webp';import clipPoster from './image-3.poster.webp';
<Motion src={clip} poster={clipPoster} alt="A hand waving a small white flag" caption="Surrender" />alt describes the clip as it would any image: what a reader who cannot see it would need, including any words burned into the frames. It is not a label for the section it sits in, and it is not the caption. caption is optional and renders as the figure’s <figcaption> — use it for the line that would otherwise sit loose under the image, so the caption is not read out twice.
The still is displayed at the clip’s own size and never upscaled, so commit a clip at the size it should appear (these are small, 178–500px wide). The control stays under prefers-reduced-motion: motion the reader starts is theirs to start.
pnpm images:check enforces the pairing and the poster’s dimensions, not its currency: re-encode a clip and re-run pnpm images:fix, which rewrites the poster and, when the pre-commit hook passes it a clip, stages it too.
ScrollTable
Section titled “ScrollTable”A wide markdown table (more than two or three columns of real prose) overflows a phone screen. Wrap it in <ScrollTable> so it scrolls inside its own bordered box instead of pushing the page sideways. The wrapper is a labelled region, keyboard-focusable when it can scroll, so keyboard users can scroll it too; the table keeps native table semantics. Leave a blank line after the opening tag and before the closing one, or MDX won’t parse the table as markdown:
import ScrollTable from '@/components/ScrollTable.astro';
<ScrollTable label="Reduction actions" wide>
| Action | Owner | Timeline || ------ | ----- | -------- || ... | ... | ... |
</ScrollTable>label is the region’s accessible name: name what the table is about, as you would a caption. The first row is the header row; keep it.
Add wide when the table has more than two columns or its rows are long: the box breaks out of the reading measure to the site container width (centred on the text column, inside the page gutters), so cells wrap at sensible points instead of a word or two per line. At phone widths it falls back to the column and scrolls. Without wide, the table fits the reading column at every width and its cells wrap; it never scrolls, so reserve it for tables of two or three short columns that read well at that width. Keep short labels that must not split (Scope 1, Not started, Q1 2027) on one line with between the words.
Do’s and don’ts
Section titled “Do’s and don’ts”Do
- Lead case studies with the outcome, then the approach.
- Keep slugs stable; set
draft: truewhile drafting. - Write specific
alttext and accuratesummary/description(they become meta descriptions, held to 70-160 characters). - Run
pnpm typecheckto validate frontmatter before opening a PR.
Don’t
- Don’t commit oversized images — keep them within the budget (the pre-commit hook and
pnpm images:fixnormalize staged images). - Don’t change a published slug without a redirect.
- Don’t hardcode brand colors/spacing in MDX — use components/tokens.
- Don’t ship
draft: truecontent expecting it to appear (it’s excluded from build, sitemap, and search). - Don’t rely on frontmatter fields the schema doesn’t define: Zod silently strips unknown keys, so they do nothing (no build error, but no effect either). Missing or malformed required fields do fail the build.
Punctuation: the em-dash rule
Section titled “Punctuation: the em-dash rule”The rule governs editorial content: posts, cases and policies. Heavy em-dash use is a calling card of machine-written prose.
- An em dash must mark a true interruption or pivot that no other mark can carry. If the dash could be deleted or swapped for a period, comma, colon, or parentheses without loss, swap it.
- Never more than one em dash per screen of copy.
- Never end a sentence with a dash-coda (“…— and it shows.”). Give the payoff its own sentence.
- Page
<title>separators (Page — Quarry) are exempt; that is hardware, not prose.