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— requiresalt(string)video—altis optional
Both share media (filename) and shownText (optional overlay text). A separate validateMediaExtension helper additionally allows .mp4, .webm, and .ogg extensions.
Sponsor Tier from Folder Structure
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
- Define the Zod schema in
src/content/config.ts - Add it to the
collectionsexport at the bottom of the file - Create the content folder under
src/content/<name>/(useen/andde/subfolders if the content needs translation) - If images are needed, create an asset folder in
src/assets/<name>/and add a glob pattern insrc/utils/imageGlobs.ts - Add query functions in
src/utils/contentQueries.ts(usefilterByLocale()for localized collections) - If the collection has categories, add a Zod enum in
src/types/content.tsand labels insrc/utils/i18n.ts(categoryLabels)