← Tech specs
toolshed

Content collections

How the eight content collections are defined, validated, and loaded using Astro 5's content layer and Zod schemas.

Published Maturity 🪴 Plant AI Co-created with AI Written by AI based on my ideas and direction.

All content in the garden is stored as Markdown files. Astro’s content collections give each file a typed, validated schema. The configuration lives in src/content.config.ts.

The base schema

All collections share a common base schema defined with Zod:

const baseSchema = z.object({
  title: z.string(),
  date: z.coerce.date(),
  updated: z.preprocess(
    (val) => (val === '' || val === null ? undefined : val),
    z.coerce.date().optional(),
  ),
  maturity: z.enum(['draft', 'developing', 'solid', 'complete', 'compost']).default('draft'),
  tags: z.array(z.string()).default([]),
  triples: z.array(z.tuple([z.string(), z.string(), z.string()])).optional(),
  themes: z.array(z.string()).optional(),
  description: z.string().optional(),
  draft: z.boolean().default(false),
  ai: z.enum(['100% Maai', 'assisted', 'co-created', 'generated']).optional(),
  hub: z.boolean().optional(),
  develops: z.string().optional(),
});

The updated field uses a preprocess step to coerce empty strings and null to undefined, this prevents Zod from rejecting a YAML field that’s been cleared to an empty value in Typora.

Per-collection extensions

Each collection extends baseSchema with its own additional fields:

CollectionExtra fields
articlespruning?: string, notes on what to cut · image?: string, path to featured image, drives split OG card
weblinksurl: string (required URL)
videosurl: string (required URL)
libraryauthor, cover?, status, genre?, book_type?, purpose?, reason?, notes?, rating?, review?, recommended?, recommended_score?
jottingstype: 'note' | 'quote' | 'event' | 'link' | 'post', source?, page?, url?
toolshedcategory: 'design' | 'technical', section?: string

The glob loader

Astro 5 uses the content layer API. Each collection uses a glob loader:

const fieldNotes = defineCollection({
  loader: glob({ pattern: '**/*.md', base: 'src/content/field-notes' }),
  schema: baseSchema,
});

The glob loader scans the base directory recursively for .md files. The id of each entry is the filename without extension (e.g., my-post.mdid: 'my-post').

Draft filtering

draft: true in frontmatter marks a post as unpublished. Filtering happens in each page that fetches content:

.filter((e) => !e.data.draft)

There is no global draft filter, each route is responsible for its own exclusion.

Collection name mapping

Astro uses camelCase for collection names internally (fieldNotes, not field-notes). The getAllContent() utility in src/utils/collections.ts normalizes these to URL-friendly slugs with hyphens:

...fieldNotes.map((e) => ({ ...e, collection: 'field-notes' as const })),

This means routes use /field-notes/[slug] while the Astro API uses getCollection('fieldNotes').

Dev server restart

When adding new .md files to a collection, the Astro dev server must be restarted. The content store is built at startup, new files are not picked up by hot reload.

Mycelium tags, relations, arguments & questions