Content Collections
8-BitQuest stores its long-form content in three Astro content collections, defined in src/content.config.ts and validated with Zod. Bad frontmatter fails the build with the offending entry named — that is the feature, not a limitation. There is no CMS behind these; content is authored as files, and the schema is the only contract.
The three collections
All three use Astro’s glob loader over ./src/data/<collection>, with the pattern **/[^_]*{md,mdx} — so a file or folder prefixed with _ is ignored, which is how you keep a draft or a scratch entry out of the build without deleting it.
blog
The posts behind /blog/ and /blog/<slug>/. The frontmatter carries only what the layout lays out in fixed slots; the article itself is the MDX body.
{
title: z.string(),
description: z.string(),
authors: z.array(reference("authors")).min(1, "a post needs at least one author reference"),
pubDate: z.coerce.date(),
updatedDate: z.coerce.date().optional(),
heroImage: image(),
heroImageAlt: z.string(),
category: z.string(), // the retro tag: Quest / Lore / Tech / Guide / Dev Log
tags: z.array(z.string()).default([]),
draft: z.boolean().optional(),
}
Four things are worth knowing here. authors references the authors collection and requires at least one entry (.min(1)), so a referenced slug that does not exist fails the build — a broken byline can never ship. pubDate uses z.coerce.date(), which runs new Date(value) and rejects an Invalid Date at build time, so a typo’d date is caught rather than rendered as “Invalid Date”. heroImage is required — every post has a card thumbnail and an OG image, no exceptions. And category is the string that drives the retro badge tone on both the card and the article (the mapping lives in @js/postCards’s categoryMeta).
projects
The entries behind /projects/ and /projects/<slug>/. Unlike a blog post, a project’s detail page is laid out in fixed slots — a spec table, a feature list, a challenge/solution pair — so everything the layout positions is structured frontmatter, and only the free-form “Project Overview” prose is the MDX body.
{
title: z.string(),
cardTitle: z.string().optional(), // listing card title, if it differs from the H1
description: z.string(),
tagline: z.string(), // detail hero intro line
status: z.enum(["complete", "in-progress"]),
moduleId: z.string(), // shown on the detail hero, e.g. "#01_CHAT"
order: z.number(), // listing sort key, ascending
thumbnail: image(),
thumbnailAlt: z.string(),
tech: z.array(z.string()), // flat tag pills on the card
specs: z.array(z.object({ label: z.string(), value: z.string() })),
features: z.array(z.object({ lead: z.string(), text: z.string() })),
archCaption: z.string(),
challenge: z.object({ title: z.string(), body: z.string() }),
solution: z.object({ title: z.string(), body: z.string() }),
draft: z.boolean().optional(),
}
status drives the retro card badge (complete vs in-progress), and order sorts the listing ascending. The structured slots — specs, features, challenge, solution — validate and render without ever parsing prose, which is why a malformed spec row is a build error rather than a broken table. See Projects for how the detail page uses each field.
authors
The byline data referenced by posts.
{
name: z.string(),
avatar: image().optional(),
authorLink: z.string(), // the author's public URL — becomes the JSON-LD Article author.url
}
Small on purpose. authorLink becomes the author.url in a post’s BlogPosting structured data, so it should be a real profile URL.
Where entries live
Each entry is a folder whose name is the URL slug, holding an index.mdx and its own images. Authors are the exception — a single markdown file, since an author has no body:
src/data/blog/<slug>/index.mdx → entry id "<slug>"
src/data/projects/<slug>/index.mdx → entry id "<slug>"
src/data/authors/<slug>.md → entry id "<slug>" (admin.md → "admin")
The collections ship populated: six blog posts, six projects, and one author (admin). The samples are retro-flavoured placeholders — replace them with your own. Because the glob loader produces single-segment ids, the listing pages and the dynamic routes key off the same entry.id, so a card link and its generated page can never drift apart.
MDX rendering
The glob pattern accepts both .md and .mdx, and rendering .mdx needs the @astrojs/mdx integration, which is installed. Every blog post and project ships as .mdx, its free-form body rendered via Astro’s render() into a <Content /> component. A blog post’s body is genuine prose — headings, lists, blockquotes, fenced code — styled by the global .blog-prose class (see Typography). A project’s body is the shorter “Project Overview” narrative that sits above its structured detail.
What fails the build
Everything the schema can catch, it catches at build time with the entry named:
- A
pubDate(orupdatedDate) that is not a real date. - A
heroImage,thumbnailoravatarpath that does not resolve to an image. - A post whose
authorslist is empty, or references an author slug that has no file. - A project
statusoutsidecomplete | in-progress, or a missingspecs/features/challenge/solution.
Run pnpm check for a fast type pass and pnpm build for the full validation — the build is the one that reads every entry.