Skip to content
AstroCraft Docs
On this theme

Content Collections

TVfolio has three content collections, defined in src/content.config.ts with Zod schemas, so bad frontmatter fails the build with the offending entry named rather than rendering a blank block. Entries live one folder deep, and the folder name is the slug:

src/data/
├── blog/<slug>/index.md      # + hero.webp, preview.webp, figure.webp
├── work/<slug>/index.md      # + preview.webp, hero.webp, still-*.webp
└── authors/<name>/index.md

The theme ships eight bulletins, six transmissions and one byline. Both content collections accept .md and .mdx@astrojs/mdx is already wired in astro.config.mjs — and the glob pattern is **/[^_]*{md,mdx}, so an underscore prefix keeps a draft file out of the collection entirely.

The one-list rule

This is the most important thing in the chapter, and it is two files of about fifteen lines each.

Every surface that reads a collection goes through one helper. @js/blog exports getBulletins(); @js/work exports getWork(). There is no second query anywhere in the theme.

export async function getBulletins(): Promise<CollectionEntry<"blog">[]> {
  return (await getCollection("blog", ({ data }) => data.draft !== true)).sort(
    (a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf(),
  );
}

The consequence is that an entry’s number is its position in that list. “BULLETIN 01” is the first element of getBulletins(); “TRANSMISSION 01” is the first element of getWork(). The index page, each detail page, the home page’s recent rows and the RSS feed all read the same array, so no two surfaces can number, order or filter an entry differently.

The rule the source states for maintaining this: grow the list’s meaning here, never in a route. A draft flag or a featured filter added to one caller is exactly how two pages start disagreeing about what is on air.

tubeFigure — the shape written once

Three fields across two collections need the same thing: a bordered still, its FIG caption line, and a right-hand note. It is defined once at the top of the config and reused:

const tubeFigure = (image: SchemaContext["image"]) =>
  z.object({
    src: image(),
    caption: z.string(),   // the FIG.01 line under the frame
    note: z.string().optional(),  // the right-hand end of the caption row
  });

Sections/Global/TubeFigure.astro renders it. The work hero, the blog hero and the blog figure all consume it, so a change to how a captured figure is drawn is one edit.

The blog collection

The LOG channel — CH 04 for the index, CH 05 for each bulletin. Unlike work, the markdown body is the content: it renders as the // BULLETIN column.

Field Type Notes
title string required
description string the standfirst — featured-card copy and the line under the post title
authors reference("authors")[] non-empty; the first is the byline
pubDate string or date coerced to a Date
updatedDate string optional; drives article:modified_time
categories string[] non-empty — every frame draws a FILED UNDER line; joined with ·
hero tubeFigure required
preview image optional; only the latest post draws one, and it falls back to hero
contents string[] the // IN THIS BULLETIN rows; defaults to [], which drops the block
related reference("blog")[] the // RELATED rows
quote { text, attribution? } the page-level pull quote
figure tubeFigure optional FIG.02 block after the quote
draft boolean optional; a draft is filtered out of getBulletins()

hero is required for a specific reason: it is the OG image. Making it mandatory is what lets the layout pass an image unconditionally, so every post ships a correct social card with real dimensions rather than falling back to the site-wide default.

Note where the page furniture lives. The pull quote, the FIG.02 figure, the contents list and the related links are all frontmatter, not body markdown. That keeps the body as plain paragraphs, which is what keeps every string inside the tube’s own type scale — a heading typed into the body would render in document type, not screen type.

related uses reference("blog"), so a typo in a slug is a build error rather than a dead link at runtime.

The work collection

The WORK channel — CH 02 for the index, CH 03 for each project page.

Field Type Notes
title, description string the card copy and the standfirst
year string drawn, not computed — a project’s year is editorial, so "2024–25" is legal
order positive int the explicit sort key
stack string[] non-empty; joined with · wherever a stack line is drawn
role, timeline, status string the spec strip
preview image the card capture; also the hero fallback
hero tubeFigure optional
gallery image[] defaults to []
demoUrl, sourceUrl url optional — omit and the page draws one button, not a dead one
briefing string[] non-empty — the BRIEFING paragraphs
highlights string[] defaults to []

Two calls here run against the grain of most content setups, and both are deliberate.

order is explicit rather than derived from year. Because year is a free string, ties in it cannot be resolved reliably, and a sort on a string year would silently reorder the archive the day someone writes a range.

The markdown body is deliberately unused. The BRIEFING and HIGHLIGHTS copy lives in frontmatter arrays instead, for the same reason the blog keeps its furniture out of the body: every string on a project page is drawn inside the tube’s type scale, and structured fields keep it there. The source names the point at which to change this — a long-form case study is when you would start rendering the body.

The authors collection

Small, and referenced rather than duplicated: name, about, email, authorLink, and an optional avatar. The bulletin byline and the BlogPosting JSON-LD both read the resolved entry, so an author’s name lives in one file.

Adding an entry

Create a folder, add an index.md, drop its images beside it:

src/data/work/my-project/
├── index.md
├── preview.webp
└── hero.webp
---
title: MY_PROJECT
description: One line, the card copy and the page standfirst.
year: "2026"
order: 7
stack: [Astro, TypeScript]
role: Solo — design & build
timeline: Jan — Mar 2026
status: Shipped · Live
preview: ./preview.webp
briefing:
  - The problem, in a paragraph.
  - What was built, in a paragraph.
---

That is all. The entry appears in the /work/ grid, gets its own /work/my-project/ route, and lands in the home page’s recent-transmissions rows — because all three read getWork(). You do not register it anywhere.

Images are relative paths resolved through image() in the schema, which means they go through astro:assets: optimized at build, given real dimensions, and typed. A missing file is a build error.

Re-adding i18n

The theme was single-language by removal, not by omission — the i18n layer was taken out deliberately. If you want it back, nest per-locale folders so ids become <locale>/<slug>, restore a language filter in the two list helpers, and add the i18n block to astro.config.mjs. The old shape is recorded in the theme’s own wiki/subsystems/i18n.md, and the helpers are in git history.

NEXT STEPWork & Transmissions