← Back to site

Content Collections

All website content is managed through Astro content collections with Zod schemas that validate frontmatter at build time. Schemas are defined in src/content/config.ts.

Collections Overview

Collection Folder Localized Key Fields
events src/content/events/{en,de}/ Yes title, description, date, categoryEvent, coverImage (+ optional coverImageDescription for /media — captures caption + credit in one field), isDraft. Inline <Img /> body images surface on /media too (unless displayInMedia: false per-image).
projects src/content/projects/{en,de}/ Yes title, description, date, categoryProject, coverImage (+ optional coverImageDescription for /media — captures caption + credit in one field), isDraft, isProjectCompleted, person (→ people). Inline <Img /> body images surface on /media too (unless displayInMedia: false per-image).
people src/content/people/ No (roles inline per locale) name, roleEn, roleDe, coverImage (optional — falls back to defaultFaceImage), coverImageDescription? (caption + credit in one field), showInFaces (consent toggle for both Faces of BEARS and the Media-page People accordion), order
testimonials src/content/testimonials/list.mdx (single-entry collection, body empty) No (quotes inline per locale) items: array of { person (→ people), quoteEn, quoteDe } — array order IS display order
page-text src/content/page-text/{en,de}/ Yes title, subtitle, description, ctas, items, address, socialLinks, navLinks, navColumns, faqs, mediaCategories, tierDescriptions, donate fields
sponsors src/content/sponsors/<tier>/ No name, order, logo, alt? (falls back to name), url, bgColor (tier from folder)
hero-slides src/content/hero-slides/ No order, alt, shownText (corner overlay on homepage hero AND /media caption when surfaced), media (conditional: {discriminant: image|video, value}), displayInMedia
instagram src/content/instagram/ No url, date, isDraft
docs src/content/docs/ No title, description, order, group
landing-section-visibility, about-section-visibility, sponsors-section-visibility, contact-section-visibility, media-section-visibility src/content/<page>-section-visibility/settings.yaml (one YAML file each) No Boolean show* toggles per page, edited via the <page> — segments singletons. Pages read these to skip whole sections at render time.

Localized collections use en/ and de/ subfolders with identical filenames in each. Content queries filter by locale and fall back to English if a German translation is missing.

Image Validation

All image filename fields (coverImage, logo) are validated against IMAGE_EXTENSION_REGEX from src/utils/imageConstants.ts. Only .jpg, .jpeg, .png, .webp, and .svg are accepted. The validation runs through a shared validateImageExtension helper in the config file.

Schema Patterns

isDraft Filtering

Events, projects, and Instagram posts support an isDraft field:

isDraft: z.boolean().default(false).optional()

In development mode, all entries are visible. In production, entries with isDraft: true are filtered out by filterDrafts() in src/utils/contentQueries.ts.

coverImageType Derivation

Events and projects schemas use a .transform() to derive a coverImageType field:

.transform((data) => {
  const coverImageType = data.coverImage ? "CUSTOM" : "DEFAULT";
  return { ...data, coverImageType };
})

This discriminator is used by the image loader to decide whether to load a custom image or fall back to the default placeholder. Since coverImage is a required field, coverImageType will always be "CUSTOM" in practice. The "DEFAULT" path exists as a safety fallback but won’t trigger under normal validation.

Conditional Validation (Projects)

Projects support a “Meet the Team” feature linked to the people collection. When displayMeetTheTeam is true, the person reference must be set:

.refine(
  (data) => !(data.displayMeetTheTeam === true && !data.person),
  { message: "person is required when displayMeetTheTeam is true", path: ["person"] }
)

Cross-Collection References

The projects.person field is a reference into the people collection, declared with Astro’s reference() helper:

person: reference('people').optional()

Keystatic’s matching field is fields.relationship({ collection: 'people' }). The frontmatter stores the slug as a plain string (e.g. person: "jane-doe"); at runtime it surfaces as { collection: 'people', id: 'jane-doe' }. Resolve via getEntry('people', slug) or by indexing a pre-loaded getCollection('people') map (the latter is what getMeetTheTeamProjectsWithPeople does in src/utils/contentQueries.ts).

The testimonials collection uses the same pattern — person: reference('people') — to attach each quote to a person.

Follow the same pattern (reference() + fields.relationship()) for any future cross-collection references.

Discriminated Unions (Hero Slides)

Hero slides use a z.discriminatedUnion on the type field to support two media types:

  • image — requires alt (string)
  • video — alt is optional

Both share media (filename) and shownText (optional overlay text). A separate validateMediaExtension helper additionally allows .mp4, .webm, and .ogg extensions.

Sponsors don’t declare a tier in frontmatter. The tier is derived from the subfolder name:

src/content/sponsors/
├── diamond/
├── platinum/
├── gold/
├── silver/
└── bronze/

The query function getSponsorsByTier() in contentQueries.ts groups entries by extracting the first path segment of their slug (e.g., gold/acme-corp → tier gold).

Page Text Flexible Schema

The page-text collection uses a flexible schema with title as the only required field. All other fields are optional, used by different page sections as needed:

Field Type Purpose
title string Section heading (required)
subtitle string? Secondary heading
description string? Body copy
seoDescription string? Meta description override
buttonText / buttonHref string? Primary CTA (must be set together)
secondButtonText / secondButtonHref string? Secondary CTA (must be set together)
ctas array (max 4)? Multiple CTAs with title, description, href
items string[]? Simple list items
address string? Multi-line text block (footer address)
room / schedule string? About Us “Where to find us” room label and meeting time
mapLat / mapLng number? About Us “Where to find us” map pin coordinates (lat −90…90, lng −180…180)
socialLinks array? platform (reference to the social-platforms collection) + URL + optional hoverColor override
navLinks array? Label + href pairs
navColumns array? Grouped navigation (heading + links)
faqs array? Question + answer pairs
instagramButtonText string? Instagram CTA button label
mediaCategories array? Media page categories, each with id and label
tierDescriptions object? Sponsor tier descriptions, keyed by tier name (diamond?, platinum?, gold?, silver?, bronze?)
accountHolder / bankName / iban / bic / reference string? Donation card bank-transfer details
paypalUrl / paypalButtonText string? Donation card PayPal button (must be set together)
rememberLabel string? About Us “Where to find us” small uppercase label above the room/schedule
emailLabel / addressLabel / mapLinkText string? Contact page email / address card headings and the view-on-map link text
followLabel string? Contact page “Follow Us” card heading
showMoreText / showLessText string? Landing page Latest News mobile toggle labels
orDividerText / bankToggleText string? Sponsors page “Or” divider and bank-details toggle label

The schema uses .refine() to enforce that button fields always come in pairs — setting buttonText without buttonHref (or vice versa) will fail validation at build time. The same applies to secondButtonText / secondButtonHref.

Most pages share the flat schema and ignore the fields they don’t use. The “outlier” shapes — hero CTAs, FAQ, social links, footer nav columns, donate card, sponsor tier descriptions — live in dedicated files at the locale root (hero.mdx, faq.mdx, social.mdx, nav-columns.mdx, donate.mdx) so each one can be edited through its own focused Keystatic singleton. The Media page splits into two files under the media/ subfolder (media/media-title.mdx for the page header, media/media-categories.mdx for the category list), each driven by its own singleton.

Adding a New Collection

  1. Define the Zod schema in src/content/config.ts
  2. Add it to the collections export at the bottom of the file
  3. Create the content folder under src/content/<name>/ (use en/ and de/ subfolders if the content needs translation)
  4. If images are needed, create an asset folder in src/assets/<name>/ and add a glob pattern in src/utils/imageGlobs.ts
  5. Add query functions in src/utils/contentQueries.ts (use filterByLocale() for localized collections)
  6. If the collection has categories, add a Zod enum in src/types/content.ts and labels in src/utils/i18n.ts (categoryLabels)