Skip to content

overhaul: 04 content model - #18

Open
CS-5 wants to merge 2 commits into
overhaul/03-primitivesfrom
overhaul/04-content-model
Open

overhaul: 04 content model#18
CS-5 wants to merge 2 commits into
overhaul/03-primitivesfrom
overhaul/04-content-model

Conversation

@CS-5

@CS-5 CS-5 commented Aug 28, 2026

Copy link
Copy Markdown
Member

Layer 4 of the overhaul stack, on overhaul/03-primitives. plan/04-content-model.md.

Content moves out of code into zod-validated markdown. This is the objective-2 layer: adding a sponsor should take two minutes and one small PR — it now takes a logo file and five lines of frontmatter.

Migrated

Collection Count From
sponsors 10 (7 active, 3 retired) legacy/data/sponsors.ts
faq 6 the openhouse page's inline array
events 2 /openhouse and /programs/frc/kickoff
frcRobots 6 the robots page's JSX slides
frcTeamPhotos / fllTeamPhotos 13 + 1 14 static imports in about/components.tsx
news scaffold new (D18)

Sponsor migration is lossless. Active: JLG (Platinum), The WorkShope, Y.B. Welding, Journalytic, Volvo (Gold), Orrstown, Manitowoc (Bronze). Retired as active: false: Wellspan, VFW, Fives — all three were commented-out blocks in the legacy file, so we can still say who supported us in 2022 instead of losing it to a code comment.

One entry I deliberately did not migrate: Test Sponsor. It's the fourth commented-out block, but it's a test fixture — no logo, example.com URL. Migrating it would have invented a sponsor. Flagging in case you'd rather it exist as a fixture.

Copy comes across verbatim, typos included ("beings", "atriculate", "visibilty") — revision is Phase 12 per D8.

src/data/site.ts replaces every hardcoded constant the legacy pages carried: the four URLs from data/config.ts, both Google Calendar IDs, the GA4 measurement ID, the workspace address and directions link, socials, contact email, plus a programs map keyed by theme name.

docs/content.md is the editing guide — add and retire a sponsor, add an FAQ, update or hide an event, add a robot and a team photo, each with a copy-paste template, plus a "sharp edges" section.

Verified

Validation genuinely bites. Setting a sponsor's level to Titanium:

[InvalidContentEntryDataError] sponsors → jlg data does not match collection schema.
  level: Invalid option: expected one of "Platinum"|"Gold"|"Silver"|"Bronze"|"Friend"

…and pnpm build exits 1. pnpm check && pnpm build green.

Three things I changed from the brief, each for a reason

1. Collection names avoid slashesfrcRobots, not frc/robots. The content still nests under frc/ and fll/ on disk exactly as D18 requires; only the collection key changed. Astro writes each collection's editor JSON schema to .astro/collections/<name>.schema.json without creating intermediate directories, so a slashed name warned on every build and silently lost frontmatter autocomplete for those three collections.

2. news/template.md is a real entry with draft: true, not a glob-excluded _TEMPLATE.md. An excluded template drifts from the schema unnoticed, and it leaves the collection empty — which warns on every build. As an entry it's schema-validated and still never renders.

3. events carries flat locationName/locationAddress instead of the brief's nested location: { name, address }, per D2's flat-schema rule. Also added displayDate (legacy's kickoff page kept a hand-written date string next to the timestamp, and that reads better in copy than anything we'd format) and directionsUrl.

One gap worth knowing about

A mistyped content reference logs an error but exits 0. Astro reports:

[ERROR] [content] Invalid content reference: entry "openhouse" in collection "events"
(field: faq[0]) references "nonexistent-faq-entry" in collection "faq", but that entry does not exist.

…and then builds successfully. So a typo'd FAQ slug in an event would silently drop that answer from the page. It's documented as a sharp edge in docs/content.md, and Phase 08 — which actually consumes these references — should throw on a missing one rather than rendering a short list.

Assets

Moved only what the collections reference: sponsor logos, team photos, the five robot photos, two event heroes. public/image is down from 36 MB to 13 MB as a side effect. The full inventory and prune is Phase 09's job.

Also carried the Phase 04 guardrail into AGENTS.md as review guidance rather than a lint rule (the brief allowed either): never inline a content array where a collection exists, and new repeating content earns a collection. A lint rule that recognizes "an array that should be a collection" would be guesswork.


Generated by Claude Code

CS-5 commented Aug 28, 2026

Copy link
Copy Markdown
Member Author

/simplify pass — quality review (reuse · simplification · efficiency · altitude)

Quality only — no correctness findings here; a separate /code-review pass follows.

The asset moves out of public/ into src/assets/ are the right call and the schemas are in good shape. Four findings, and the first is the one I'd act on before any page consumes these images.


1. Un-downscaled image masters make Astro's pipeline emit derivatives larger than the source

src/assets/frc/robots/2019-robot-field.webp (11.3 MB, 4560×2515)
src/assets/frc/robots/2021-robot-field.webp (4.1 MB, 6016×4000)
src/assets/team/frc/2023.webp (2.5 MB, 6000×4000)
src/assets/events/openhouse-header.webp (930 KB, 13750×6232)

Moving these into src/assets/ is correct — but they're lossless/oversized camera masters, and <Image> with no widths uses the source's own dimensions (resolvedOptions.width = originalWidth, astro/dist/assets/internal.js). I referenced each from a scratch page and built:

▶ /_astro/2019-robot-field...webp   (before: 11003kB, after:  853kB) (+2.61s)
▶ /_astro/2021-robot-field...webp   (before:  3978kB, after: 1207kB) (+4.35s)
▶ /_astro/2023...webp               (before:  2464kB, after: 2637kB) (+4.65s)
▶ /_astro/openhouse-header...webp   (before:   908kB, after:  979kB) (+10.30s)

Cost: ~22 s of sharp time for four images, repeated on every CI/Pages build — the node_modules/.astro image cache is cold there. And two of the four "optimized" outputs are bigger than the input, so a page using the 2023 team photo or the open-house header as-is would ship a single 1–2.6 MB image.

This PR is where it's cheap. Once Phases 06–08 reference them at call sites, fixing it means touching both the assets and every consumer.

Fix: downscale the masters to a sane long edge (~2560 px) and re-encode at q≈80 before committing, and/or pass explicit widths/sizes at every call site so the full-resolution variant is never generated. openhouse-header.webp at 13750 px wide is ~5× any useful display width.


2. The workspace address and directions URL are hardcoded in event frontmatter while site.ts holds both

src/content/events/openhouse.md:7 (and frc-kickoff.md:6-8)

locationName: South Central STEM Collective Workspace
locationAddress: 20 South Main Street, Downtown Chambersburg
directionsUrl: https://wiki.scstem.org/workspace/#directions

Byte-identical to src/data/site.ts:24 (workspace: "20 South Main Street, Downtown Chambersburg") and :46 (directions: "https://wiki.scstem.org/workspace/#directions"). So the workspace address now lives in three places — in the same PR that created the file whose own docstring says "if it is an identifier, a URL, or an org fact, it belongs here" — and every future event adds a fourth. AGENTS.md: "src/data/site.ts — org facts, external URLs, calendar and analytics IDs. No hardcoded constants."

Fix: make locationName / locationAddress / directionsUrl optional in the events schema (src/content.config.ts:51) and default them from site.location.workspace / site.urls.directions at render time. Then only genuinely off-site events carry a location, which is also the more honest signal.


3. displayDate restates start/end as prose — and the open house date is now in three places

src/content.config.ts:49-50

      /** Human-readable date, for copy that should not be machine-formatted. */
      displayDate: z.string().optional(),
start: 2026-08-01T13:00:00-04:00
end: 2026-08-01T16:00:00-04:00
displayDate: Saturday, August 1 (1PM to 4PM)

The field is described as an escape hatch, but both seed events use it, and in both it's exactly what Intl.DateTimeFormat would produce from start/end — so the derivation path it exists to override has no callers and will never be exercised. docs/content.md:80 puts it in the standard event template, so every future event carries the duplicate.

Then the same fact appears twice more as free text: openhouse.md:25 repeats it in body copy ("Saturday, August 1 (1PM to 4PM)"), and src/content/faq/when-is-the-open-house.md:6 states it a third time —

Saturday, August 1 from 1PM to 4PM.

— on an entry that openhouse.md itself lists in its faq array. So rescheduling the event leaves a stale answer on the very page that renders it.

Fix: format start/end once in a formatEventDate(start, end) helper (Intl.DateTimeFormat, timeZone: "America/New_York"), drop displayDate from both entries and from the docs template, and render the FAQ answer from the referenced event rather than restating it. If a genuinely non-derivable label ever appears, re-add the field then — as the exception rather than the default.


4. Reference integrity is delegated to every future consumer instead of enforced once at the content layer

docs/content.md:166-167, schema at src/content.config.ts:61 (faq: z.array(reference("faq")).optional())

A mistyped faq slug logs an Astro error and still exits 0. The PR's response is a documented sharp edge plus a note that "Phase 08 consumes these references and should throw there."

But "a reference resolves" is an invariant of the collection that declares the reference, not of each page that reads it. As written, Phase 08's event page has to remember to throw — and so does every later consumer; docs/content.md already tells editors to eyeball the rendered page. A silently dropped FAQ answer on a live event page is the exact class of error the zod-schema layer was chosen to eliminate, and D13 makes content schemas one of the four substitutes for having no test suite.

Fix: one build-time check over the collections with reference() fields — a small script in tools/checks/ wired into pnpm check, or a getCollection pass in the events page's shared data loader — that throws naming the entry and the bad id. Phase 08 then inherits it instead of re-deciding.


Notes

  • Simplification and reuse are otherwise clean. The schema fragments in src/content.config.ts don't duplicate each other in a way worth factoring, and the markdown content is appropriately thin.
  • The asset reorganization itself (public/image/**src/assets/**, sponsors/robots/team-photos split by collection) matches what the collections need and puts everything on the fingerprinted, optimized path. Only the source dimensions are the problem.

Generated by Claude Code

@CS-5 CS-5 left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code review of the content model layer. Verified the migration against legacy first: sponsors are lossless (all 7 active + the 3 real commented entries, levels and supportSince values all match legacy/data/sponsors.ts), the 6 FAQ answers are verbatim, both calendar IDs match legacy/src/app/calendar/[name]/page.tsx byte for byte, team photos are the full 13 FRC + 1 FLL set with captions intact, and every one of the 33 relative image paths in frontmatter resolves. pnpm check && pnpm build are green as claimed, and the level: Titanium failure reproduces exactly as described. Skipping "Test Sponsor" was the right call.

Eleven findings, two of which I'd want resolved before this merges:

  1. sharp is unresolvable, so the first page to render a collection image fails the build (src/content.config.ts) — reproduced with a throwaway page: pnpm build exits 1 with MissingSharp. This PR is what makes it load-bearing, moving 33 images from public/ into src/assets/ behind image(). Invisible today only because no page consumes a collection yet.
  2. A typo'd frontmatter field name is silently dropped (src/content.config.ts) — sinceYear: 2013 builds clean and exits 0. docs/content.md twice promises the opposite, and the acceptance criterion was verified with a bad value rather than a bad field name. .strict() closes it.

Then: the programs map is missing sc2 while the enum accepts it (and openhouse.md uses it); z from astro:content is deprecated for removal in the major already pinned; the canonical origin is now duplicated with astro.config.ts; a comment points at a functions/api/calendar/ that doesn't exist; a dead docs link; four kickoff links and two robot logos dropped without a note; Robo Fett's "2020-2021" label lost to the single year; an empty h2 in the kickoff body; one orphaned event hero.

The disclosed dangling-content-reference gap I'd leave as-is — flagging it and deferring the throw to Phase 08 is the right shape, and it's documented as a sharp edge.

The three deviations from the brief are all well-argued; the slashed-collection-name finding in particular is a good catch worth the deviation.


Generated by Claude Code

Comment thread src/content.config.ts
Comment thread src/content.config.ts Outdated
Comment thread src/data/site.ts
Comment thread src/content.config.ts Outdated
Comment thread src/content/events/frc-kickoff.md Outdated
Comment thread src/data/site.ts Outdated
Comment thread docs/content.md Outdated
Comment thread src/content/frc/robots/2021-robo-fett.md
Comment thread src/content/events/frc-kickoff.md
Comment thread plan/04-content-model.md Outdated
@CS-5
CS-5 force-pushed the overhaul/04-content-model branch from 04662c7 to 43c4be8 Compare August 28, 2026 15:08
@CS-5
CS-5 force-pushed the overhaul/04-content-model branch from 43c4be8 to ca44bb8 Compare August 28, 2026 15:22
@CS-5
CS-5 force-pushed the overhaul/04-content-model branch from ca44bb8 to c5744f1 Compare August 28, 2026 15:37
CS-5 and others added 2 commits August 28, 2026 16:15
Everything that changes over time moves out of code and into zod-validated
markdown. Adding a sponsor is now a logo file plus five lines of frontmatter.

Migrated: 10 sponsors (7 active, 3 as active: false), 6 FAQ answers, both
seasonal events, 6 robots, 14 team photos. Copy comes across verbatim, typos
included — revision is Phase 12 (D8). Former sponsors are records with
active: false rather than commented-out code, so we can still say who
supported us in 2022.

src/data/site.ts replaces every hardcoded constant the legacy pages carried:
the four external URLs from data/config.ts, both Google Calendar IDs, the GA4
measurement ID, the workspace address and directions link, socials and contact.

Schemas stay flat on purpose (D2) — strings, enums, booleans, dates, numbers,
images — which is what keeps a git-backed CMS a later addition rather than a
restructuring. events therefore carries locationName/locationAddress instead
of a nested object.

Two things worth knowing:

Collection names avoid slashes (frcRobots, not frc/robots) while the content
still nests under frc/ and fll/ on disk as D18 requires. Astro writes each
collection's editor JSON schema to .astro/collections/<name>.schema.json
without creating intermediate directories, so a slashed name warned on every
build and silently dropped frontmatter autocomplete.

news/template.md is a real entry with draft: true rather than a glob-excluded
_TEMPLATE.md. An excluded template drifts from the schema unnoticed and leaves
the collection empty, which warns on every build; as an entry it is validated
and still never renders.

"Test Sponsor" was deliberately not migrated — it is the one commented-out
legacy entry that is a test fixture, not a former sponsor.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YRfxMh7FLjQtDbb1BEsCbR
`image()` in the schemas commits every moved asset to astro:assets, but pnpm's
isolated layout keeps Astro's own `sharp` where the bundled image service cannot
resolve it — the first page to render a collection image gets one
`MissingSharp` warning per asset and an unoptimized passthrough. Reproduced, then
declared `sharp` in dependencies with ADR 0003.

`z.object` strips unknown keys, so a typo'd field name built clean and silently
dropped the value — the one thing `docs/content.md` promises it does not do. The
acceptance criterion had been verified with a bad *value*, which always failed
correctly. All seven schemas are `z.strictObject`; `sinceYear` for `since` now
exits 1 with `Unrecognized key`.

The `program` enum accepted `sc2` (the open house uses it) while `programs` had
only `frc` and `fll`, so `programs[entry.data.program]` was undefined for that
event. The enum derives from `PROGRAM_KEYS` and the map `satisfies` a total
record over it. `astro.config.ts` reads `site.url` rather than declaring the
canonical origin a second time. `z` from `astro:content` is deprecated with
removal slated for the major already pinned — switched to `astro/zod`, and
`z.string().url()` to Zod 4's `z.url()`, clearing 53 check hints.

Content that legacy had and no schema field could hold: the kickoff's two season
teaser URLs and two game-hint links (`teaserUrls`/`hintUrls`/`hintLabels`, flat
per D2), its hero subtitle, which had become an `##` with no section under it,
Robo Fett's "2020-2021" two-season label, and the Viper and TroubleClef wordmark
overlays. The kickoff `description` is legacy's own again rather than new prose.

Reference integrity is now enforced where the reference is declared:
`tools/checks/content-references.mjs` fails `pnpm check` on a dangling `faq`
slug, which Astro only logs before exiting 0.

Quality: the workspace address and directions URL were written into every event's
frontmatter next to the copies in `site.ts`; location is optional and defaults
from there, so only an off-site event carries one. `displayDate` restated
`start`/`end` as prose, and the open house stated its date twice more — in body
copy and in an FAQ answer; `src/lib/event-date.ts` formats the timestamps, and
Phase 08's acceptance criteria require closing its knip seam. Dropping the
blanket `src/data/site.ts` knip entry restored export-level checking there, with
`@public` on the two exports that have a known future consumer.

The moved masters were camera-resolution — up to 6000px and near-lossless — so
`<Image>` derived variants from them that came out larger than the source and
cost ~22s of sharp time per build. `tools/assets/optimize-sources.mjs`
(plan/09 §2) caps sources at 2560px: 17.8 MB saved across 12 files.
`openhouse-header.webp` went back to `public/image`, where Phase 09's
inventory-based prune can still see it — nothing references it.

Also: `src/data/site.ts` described a `functions/api/calendar/` that does not
exist, and `docs/content.md` linked to a route that does not exist.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BX5PrKuYNRLVxiEj3eejhs
@CS-5
CS-5 force-pushed the overhaul/04-content-model branch from c5744f1 to 1963831 Compare August 28, 2026 16:17
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants