Skip to content
AstroCraft Docs
On this theme

Content Collections

Medice has exactly one content collection, and the interesting decisions are as much about the one it does not have as about the one it does.

Collections are defined in src/content.config.ts with Astro’s glob loader and Zod, so bad frontmatter fails the build with the offending entry named rather than rendering a hole in a page.

The blog collection

It backs the health library at /resources/health-library/. Ten articles ship.

loader: glob({ pattern: "**/[^_]*{md,mdx}", base: "./src/data/blog" })

The [^_] in the pattern means a file prefixed with _ is ignored, which gives you a scratch draft that never enters the build at all.

Field Type Notes
title string required
description string required — also the OG description
author enum a doctor slug from src/config/doctors/
category enum one of five, imported from blogData
pubDate string or date transformed to a Date
updatedDate string, optional the “Last reviewed” fact; falls back to pubDate
heroImage image() required
heroCaption string the line under the hero photograph
sources array, optional { title, publication } — the “what this is based on” band
draft boolean, optional dropped from every list

Two enums that are really references

author and category are z.enum() over lists imported from blogData.json.ts rather than free strings, and that is what turns a class of silent mistakes into build failures naming the legal values.

category matters because a sixth category appearing in a filter with one article behind it is the kind of bug nobody notices for months. It is deliberately one category, not an array: the design draws a single chip on the card and a single chip in the post header, and the index’s category select is a one-of choice. An array would let a post carry three and leave the template to pick one, which is a decision no data should defer to a template.

author matters more. The list is derived from the nine clinicians the directory ships, so a post naming nobody fails the build, and a clinician who leaves the roster breaks the build on every article they wrote — which is exactly when you want to know.

There is no authors collection

This is the authorship decision, and it was made by deletion rather than by filling one in.

The health library is written and reviewed by the practice’s own clinicians. src/config/doctors/ already carries their name, credentials, specialty, clinic and photograph. A second collection would have been a second roster for the same nine people — and a second roster is a roster that can disagree with the first.

So a post’s byline has no frontmatter fields at all. It reads the clinician’s roster entry, which is why the second line reads credentials · specialty · clinic and cannot be missing, wrong, or out of step with the directory. The “medically reviewed by” note comes from the same place.

The cover lives with the post

src/data/blog/<slug>/index.mdx     → entry id "<slug>"
src/data/blog/<slug>/cover.jpg     → resolved by the schema's image()

Add a folder with an index.mdx and a cover beside it and the article exists. The image() helper resolves the path relative to the entry, so the cover is a sibling file rather than an entry in a central map.

That is a deliberate contrast with photography elsewhere in the theme. src/config/siteImages.ts exists precisely because config data files cannot import an image — pnpm test loads them as plain Node modules, which resolve neither the @images/* alias nor a .jpg. A collection has no such constraint, and image() is the platform feature for exactly this case. See Images.

heroImage is required rather than optional, which is a change from the shape a skeleton would ship. Every post is drawn as a card with a cover on the index and as an og:image in the head, so a post without one is a hole in two places at once. Making it required lets the build say so.

One helper between the collection and the routes

src/js/blog.ts is the only thing that calls getCollection. Five callers use it — the index, the article route, the homepage band, llms.txt and the RSS endpoint — and none of them touches the collection directly.

It exports getPosts() (drafts dropped, newest first), postView() (the derived fields), postFacetValue and facetOptions().

The failure this prevents is specific and easy to ship: a draft that never appears on the index but turns up in the related-articles list at the bottom of a real post, because the two callers each wrote their own filter and only one of them read draft. Keeping the draft filter and the sort in one place is what stops the index, the related list, the feed and llms.txt from disagreeing about what “published” means.

The helper is split from blogData.json.ts for the reason that recurs throughout this codebase: this half imports astro:content, which no plain-Node check can load, so the pure half stays checkable.

Derived facts stay derived

Reading time is computed from the article body, never stored in frontmatter. A hand-written readTime: 4 is a second copy of something the body already knows, and it is wrong the first time anybody edits a paragraph.

The “On this page” list is built from the article’s own ## headings, so it cannot list a section that is not there.

The featured post is the head of the date sort, not a featured: true flag two entries could set at once.

The featured card’s “N sources cited” fact counts the sources array rather than storing the number beside it.

sources itself is optional and deliberately absent on most posts: a fabricated citation is worse than no citation, so the band only renders where a real, checkable reference list exists.

MDX

The glob pattern accepts both .md and .mdx, and @astrojs/mdx is installed and wired in astro.config.mjs. The shipped articles are .mdx. Plain .md works identically for an article that needs no components.

Adding an article

  1. Create src/data/blog/my-article/index.mdx.
  2. Put a cover image beside it and point heroImage at it.
  3. Set author to a slug that exists in src/config/doctors/, and category to one of the five in blogData.json.ts.
  4. Write heroCaption — it is required, and it is the line under the photograph.
  5. Run pnpm build. Anything wrong is a build error naming your entry and the legal values.
NEXT STEPHealth Library