Distinguishing Collection Sets from Category Collections

Category: Development

Tags: astro, photography


When I started building the archives page, two kinds of nodes in the photo tree suddenly had to be told apart — and in the filesystem they looked identical. Without an explicit signal, the archive would end up listing navigation aids as if they were finished works.

This post documents the choice I made: a single boolean flag in the frontmatter, why I didn’t reuse publishDate as a proxy, and how the filter ends up reading.

The problem

The collection tree has two kinds of nodes that look identical in the filesystem but serve completely different purposes.

A node like travels/asia/ is purely organisational. It groups cities under a region but isn’t something I’d point someone to as a finished work. A node like travels/asia/chiang-mai/ is a real photo set — curated, dated, and ready to stand on its own.

Both are index.md files. Both live in the same hierarchy. Without an explicit signal, there’s no clean way to tell them apart.

The archives page aggregates all published content into a chronological timeline — articles, notes, links, and now photo collections. The intent is to show things worth revisiting, not every internal navigation node. Showing Travels or Asia in the archive alongside Chiang Mai would be noise. Those nodes don’t have standalone value; they’re structure.

Implementation

Problem: The schema for collections_photos didn’t distinguish navigation nodes from real sets.

Implementation: A single boolean field in the schema:

const collections_photos = defineCollection({
  schema: z.object({
    title: z.string(),
    description: z.string(),
    location: z.string().optional(),
    publishDate: z.coerce.date().optional(),
    draft: z.boolean().default(false),
    coverImage: z.string().optional(),
    order: z.number().int().default(0),
    set: z.boolean().default(false),
  }),
});

It defaults to false, so existing category nodes need no changes. Only the actual photo sets carry set: true:

---
title: Chiang Mai
description: Street scenes and quiet moments from Chiang Mai.
location: Chiang Mai, Thailand
publishDate: 2025-10-06T11:04:00+01:00
set: true
---

travels/index.md and asia/index.md stay untouched. No set field, no publishDate — they remain what they are: navigation aids.

Solution: The filter in the archives page is unambiguous:

const photoSets = (
  await getCollection(
    "collections_photos",
    ({ data }) => data.set && !data.draft && !!data.publishDate,
  )
).map((c) => ({
  title: c.data.title,
  date: c.data.publishDate!,
  url: `/photos/collections/${collectionSlug(c)}`,
  type: "photos" as const,
}));

Both conditions must hold: the node must be flagged as a set, and it must have a publish date. Either alone isn’t enough.

Why not infer from publishDate?

The publishDate field was already present on leaf collections and absent on category nodes, which makes it a tempting proxy. Filter on !!publishDate and you’d get the same result today.

The problem is semantic drift. publishDate means “when this was published”. Using its presence as a proxy for “this is a real set” gives the field two meanings. It also creates a subtle trap — adding a date to a category node for some future purpose would silently promote it to the archive.

An explicit set: true says what it means. The two flags also serve different authors in different moments: you might create a collection node and start adding photos over weeks before it’s ready to publish. set declares intent; publishDate declares readiness. Keeping them separate makes the draft workflow cleaner.

What changed

Photo sets appear in the archive alongside articles and notes, grouped by year, with a Photo Collection type badge. Category collections never appear. New collections added in the future are opt-in — the default is invisible until explicitly marked as a set.

What to take away

  • One boolean beats overloading an existing field. set: true means exactly one thing; repurposing publishDate would mean two.
  • Default to the safer value. With set defaulting to false, a forgotten flag keeps a node out of the archive rather than accidentally promoting it.
  • Intent and readiness are different axes. Declaring that something is a set is not the same as declaring it’s publishable — set and publishDate compose cleanly.
  • The filter reads like the requirement. data.set && !data.draft && !!data.publishDate is the whole rule, in one line, and matches how I’d describe it in prose.