← Tech specs
toolshed

Content collections

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

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']).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
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