Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,11 @@ edit, and feeds lint failures back to you. Do not reach for the other toolchain
(see the docstring). `clsx`, `classnames`, `tailwind-merge` are banned imports.
- No new dependencies without an ADR in `docs/adr/`.
- No client-side frameworks, no framework islands.
- Content changes go in `src/content/` — see `docs/content.md`.
- Content changes go in `src/content/` — see `docs/content.md`. **Never inline a content array
in a page** where a collection exists (the legacy site's habit): query the collection. New
repeating content earns a collection, not a `const` in frontmatter.
- Collection schemas stay flat (strings, enums, booleans, dates, numbers, images) so a git-backed
CMS stays a later addition. A schema change needs an ADR.
- Visual decisions come from `DESIGN.md`. When code and the doc disagree, the doc wins; when
the doc is silent, add to it before building (its §11 change process).
- Implementation plan and phase acceptance criteria live in `plan/`.
Expand Down
6 changes: 5 additions & 1 deletion astro.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,12 @@ import sitemap from "@astrojs/sitemap";
import tailwindcss from "@tailwindcss/vite";
import { defineConfig } from "astro/config";

// Loaded through jiti, and this module imports nothing from `astro:*`, so the config can read the
// same origin everything else derives canonical and OG URLs from.
import { site } from "./src/data/site";

export default defineConfig({
site: "https://scstem.org",
site: site.url,
output: "static",
outDir: "./dist",
// /styleguide is a noindex review artifact; a URL that is both in the sitemap and
Expand Down
54 changes: 54 additions & 0 deletions docs/adr/0003-sharp-direct-dependency.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
# 0003 — Depend on `sharp` directly for `astro:assets`

- **Status:** accepted
- **Date:** 2026-08-28

## Context

Phase 04 moves 33 images out of `public/` — where they were served untouched — into `src/assets/`
behind `image()` in the content schemas. That commits every one of them to `astro:assets`, whose
default image service is Sharp.

Astro ships `sharp` as its own dependency, so nothing had to be declared. But pnpm's isolated
layout keeps it only under `node_modules/.pnpm/astro@7.2.4*/node_modules/`, and Astro bundles its
Sharp service into `dist/.prerender/chunks/sharp_*.mjs`, from which `await import("sharp")` cannot
reach it:

```
$ node -e "import('sharp')" # repo root
ERR_MODULE_NOT_FOUND
```

The first page that renders a collection image gets one warning per asset and an unoptimized
passthrough:

```
[WARN] Unable to generate optimized image for /_astro/jlg.DO8rjwK4.svg:
MissingSharp: Could not find Sharp.
```

This was invisible while no page consumed a collection, and would have surfaced in Phase 05 on the
first `<Image>`.

## Decision

Declare `sharp` in `dependencies`, pinned to the version Astro already resolves (`0.35.3`), so it
sits at the root of the dependency graph where the bundled service can resolve it.
`pnpm-workspace.yaml` already allows its install script.

## Alternatives considered

- **A passthrough or noop `image.service` in `astro.config.ts`.** Keeps the dependency count flat
but gives up optimization on every asset this phase just moved into the pipeline — responsive
widths, AVIF/WebP derivatives, and the fingerprinted `_astro/` URLs — which is the entire reason
for moving them out of `public/`. Rejected.
- **Hoisting via `nodeLinker: hoisted` or a `publicHoistPattern`.** Fixes resolution by weakening
the isolation that keeps undeclared imports from working repo-wide. A one-line dependency is the
narrower change.

## Consequences

- Sharp's platform binaries are part of every install and CI run. The pnpm store cache added in
Phase 01 keeps that off the network on repeat runs.
- The version is pinned separately from Astro's, so an Astro bump that changes its Sharp range
needs this pin re-checked. `pnpm peers check` reports the mismatch if one appears.
176 changes: 176 additions & 0 deletions docs/content.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
# Editing content

Everything on this site that changes over time is a markdown file in `src/content/`. You do not
need to touch TypeScript to add a sponsor, publish an event, or answer a new question.

Frontmatter is validated on build: a typo in a field name or a value that is not allowed fails
`pnpm build` with a message naming the file and the field. That is the safety net — if it builds,
the content is well-formed.

```sh
mise install && pnpm install # once
pnpm dev # then edit files and watch the browser
```

## Add a sponsor

Two steps, about two minutes.

1. Put the logo in `src/assets/sponsors/`. SVG if you have it, otherwise PNG at roughly 400px
wide. Prefer a version that reads on a dark background.
2. Create `src/content/sponsors/<name>.md`:

```md
---
name: Acme Manufacturing
level: Gold
url: https://www.acme.example/
logo: ../../assets/sponsors/acme.png
since: 2026
---
```

That is the whole file — no body text. Levels are `Platinum`, `Gold`, `Silver`, `Bronze`, and
`Friend`, and the sponsors page groups by them automatically.

Optional fields: `sub` for a qualifier shown under the name ("Chambersburg VFW Post 1599"),
`notes` for a reminder to yourself (never displayed).

### Retire a sponsor

**Do not delete the file.** Add one line:

```md
active: false
```

They drop off the sponsors page and the homepage strip, and the record stays in git — which is how
we can still say who supported us in 2022.

## Add or update an FAQ answer

Create `src/content/faq/<slug>.md`. The question is frontmatter; the answer is the body, so it can
use markdown — links, bold, lists:

```md
---
question: Do I need my own tools?
tags: [joining]
---

No. The workspace has everything you need, and we will show you how to use it safely.
```

Optional `program: frc | fll | sc2` if the answer only applies to one program.

The `slug` — the filename without `.md` — is how events refer to an answer, so keep it
descriptive and stable. Renaming a file means updating any event that lists it.

## Update the open house for a new season

Edit `src/content/events/openhouse.md`. The dates live in frontmatter; the page copy is the body
below it.

```md
---
title: Open house
program: sc2
start: 2026-08-01T13:00:00-04:00
end: 2026-08-01T16:00:00-04:00
description: Shown in search results and when the page is shared. One or two sentences.
ctaLabel: Get involved
ctaHref: /get-involved
faq:
- when-is-the-open-house
- what-age-to-join
---
```

`start` and `end` are full timestamps **with the timezone offset** — `-04:00` in summer,
`-05:00` in winter. That offset is what makes the date correct for someone reading in another
timezone, and it feeds the event's structured data.

**Never write the date in prose as well.** The displayed date is formatted from `start`/`end`, so
changing the season is one edit. A date typed into the body — or into an FAQ answer — is a second
copy that will go stale.

Location is **omitted** for anything at the workspace: it defaults to the address in
`src/data/site.ts`. Set `locationName` / `locationAddress` / `directionsUrl` only for an off-site
event, which also makes "off-site" visible at a glance.

`faq` lists FAQ slugs to show on the page, in the order given. A slug that does not match a file
fails `pnpm check` (`tools/checks/content-references.mjs`) — Astro alone only logs it.

### Hide an event after it passes

```md
hidden: true
```

The page redirects to its parent and disappears from the sitemap. Flip it back next season.

### Create a new event

Copy an existing file in `src/content/events/`, then add a route file for it — a thin page under
`src/pages/` that renders the entry. Phase 08 adds those routes. Two events do not yet justify a
dynamic route; when there are several, that is worth revisiting.

## Add a robot

`src/content/frc/robots/<year>-<name>.md`, with the photo in `src/assets/frc/robots/`:

```md
---
year: 2026
name: Example
image: ../../../assets/frc/robots/2026-robot-field.webp
imageAlt: Example on the competition field
achievements:
- Industrial Design Award, Pittsburgh Regional
---

A paragraph or two about the robot: what it does, what makes it unusual, how the season went.
```

The robots page orders by `year`, newest first. `image` and `achievements` are optional — a robot
with no photo yet still gets a page entry.

## Add a team photo

`src/content/frc/team-photos/<year>.md` (or `fll/`), photo in `src/assets/team/frc/`:

```md
---
year: 2026
photo: ../../../assets/team/frc/2026.webp
alt: The Biohazard team in 2026
caption: '"Biohazard" - 2026'
---
```

`alt` describes the photo for someone who cannot see it; `caption` is the visible label. They are
different jobs, so they are different fields.

## News posts

The `news` collection is scaffolded but has no routes yet (that is phase-2 work). Copy
`src/content/news/template.md`, and leave the template itself as `draft: true`.

## Where things live

| What | Where |
| ---------------------------------------------------- | ------------------------------- |
| Sponsors, events, FAQ, robots, team photos, news | `src/content/` |
| Images those files point at | `src/assets/` |
| Org facts, external URLs, calendar and analytics IDs | `src/data/site.ts` |
| Page structure and copy that is not content | `src/pages/`, `src/components/` |

## Sharp edges

- **Image paths are relative to the markdown file**, which is why they start with `../../`. If the
path is wrong the build fails and names the file — it will not ship a broken image.
- **A mistyped FAQ slug in an event logs an error but does not currently fail the build**, so
check the page renders the answers you expect after editing an event's `faq` list.
- **Do not add fields the schema does not define** — the build rejects them. If you need a new
field, that is a schema change in `src/content.config.ts` and needs an ADR (`docs/adr/`), since
the schemas are kept flat on purpose so a git-backed CMS can be added later.
4 changes: 4 additions & 0 deletions docs/tooling.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,10 @@ Consequence: **pins are the newest version that is at least a week old**, not th

Install scripts are denied by default; `allowBuilds` lists the exceptions (`sharp`, `esbuild`).

`sharp` is a direct dependency rather than one inherited from Astro: pnpm's isolated layout keeps
Astro's copy where the bundled image service cannot resolve it, so `astro:assets` falls back to
unoptimized passthrough with one warning per image. See `docs/adr/0003-sharp-direct-dependency.md`.

## Toolchain ownership

| Extensions | Linter | Formatter |
Expand Down
4 changes: 4 additions & 0 deletions knip.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@
"functions/**",
// Exports the font URLs BaseLayout preloads; wired up in Phase 05.
"src/styles/fonts.ts",
// Formats an event's date from `start`/`end`, which replaced the `displayDate` string every
// event used to restate. Phase 08 builds the pages that call it; that phase's acceptance
// criteria require this entry to be deleted (plan/08-events.md).
"src/lib/event-date.ts",
],
"project": ["**/*.{ts,tsx,js,mjs,cjs,astro}"],
"ignore": ["legacy/**", "tools/lint/anti-slop/**"],
Expand Down
7 changes: 5 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,10 @@
"fmt": "oxfmt --ignore-path .gitignore && prettier --write \"**/*.{astro,md}\"",
"fmt:check": "oxfmt --check --ignore-path .gitignore && prettier --check \"**/*.{astro,md}\"",
"knip": "knip",
"check": "pnpm run typecheck && pnpm run lint && pnpm run fmt:check && pnpm run knip && pnpm run check:tokens",
"check:tokens": "node tools/checks/cn-font-size-group.mjs"
"check": "pnpm run typecheck && pnpm run lint && pnpm run fmt:check && pnpm run knip && pnpm run check:tokens && pnpm run check:content",
"check:tokens": "node tools/checks/cn-font-size-group.mjs",
"check:content": "node tools/checks/content-references.mjs",
"assets:optimize": "node tools/assets/optimize-sources.mjs"
},
"dependencies": {
"@astrojs/sitemap": "3.7.3",
Expand All @@ -26,6 +28,7 @@
"astro": "7.2.4",
"class-variance-authority": "0.7.1",
"cnfast": "0.1.0",
"sharp": "0.35.3",
"tailwindcss": "4.3.3"
},
"devDependencies": {
Expand Down
76 changes: 70 additions & 6 deletions plan/04-content-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,9 +87,73 @@ Add a lint restriction (oxlint `no-restricted-imports` or a small check script i

## Acceptance criteria

- [ ] `astro check`/build validates all collections; a deliberately broken frontmatter field fails the build with a readable zod error.
- [ ] Sponsor migration is lossless: every active + commented legacy sponsor is represented; count and names listed in PR description.
- [ ] Robots, team photos, FAQ, and both events fully migrated; no content invented — copy comes verbatim from legacy (D8: revision is Phase 12).
- [ ] `src/data/site.ts` replaces every hardcoded constant found in legacy pages (config URLs, calendar IDs, kickoff config).
- [ ] `docs/content.md` written; a non-author following it can add a sponsor without touching TS.
- [ ] `pnpm check && pnpm build` green.
- [x] `astro check`/build validates all collections; a deliberately broken frontmatter field fails the build with a readable zod error — verified both halves: a bad **value** (`level: Titanium`) exits 1 with `Invalid option: expected one of "Platinum"|"Gold"|"Silver"|"Bronze"|"Friend"`, and a bad **field name** (`sinceYear` for `since`) exits 1 with `Unrecognized key`. Field names need `z.strictObject`; plain `z.object` strips unknown keys and builds clean.
- [x] Sponsor migration is lossless: **10 files, 7 active + 3 `active: false`.** Active — JLG (Platinum), The WorkShope, Y.B. Welding, Journalytic, Volvo (Gold), Orrstown, Manitowoc (Bronze). Retired — Wellspan, VFW, Fives (all Platinum, `active: false`).
- [x] Robots (6), team photos (13 FRC + 1 FLL), FAQ (6), and both events migrated; copy is verbatim from legacy, typos included (D8: revision is Phase 12).
- [x] `src/data/site.ts` replaces every hardcoded constant found in legacy: `data/config.ts`'s four URLs, both calendar IDs, the GA4 measurement ID, the kickoff location and directions link, footer socials and contact email.
- [x] `docs/content.md` written — add/retire a sponsor, add an FAQ, update or hide an event, add a robot and a team photo, each with a copy-paste template.
- [x] `pnpm check && pnpm build` green.

### Notes and deviations

- **Collection names avoid slashes** (`frcRobots`, not `frc/robots`) while the content still
nests under `frc/`/`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 for those
three collections.
- **"Test Sponsor" was not migrated.** It is the one commented-out legacy entry that is a test
fixture rather than a former sponsor (no logo, `example.com` URL). Migrating it would have
invented a sponsor; the other three commented entries are real and came across as
`active: false`.
- **`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 leaves the collection empty, which
warns on every build. As an entry it is schema-validated and still never renders.
- **`tools/checks/content-references.mjs` enforces reference integrity.** Astro reports
`Invalid content reference: ... references "x" ... but that entry does not exist` and then exits
0, so a typo'd FAQ slug would silently drop that answer from the page that lists it. "A
reference resolves" belongs to the collection that declares it, not to each future consumer, so
it is a `pnpm check` step rather than something Phase 08 has to remember to throw about.
- **All seven schemas are `z.strictObject`.** `z.object` strips unknown keys, so a typo'd field
name built clean and dropped the value — the one thing `docs/content.md` promises it does not
do. Bad values already failed; bad field names did not.
- **`events` carries flat `locationName`/`locationAddress`** rather than the brief's nested
`location: { name, address }`, per D2's flat-schema rule and to keep the fields CMS-editable.
Both are **optional** and default to the workspace in `src/data/site.ts`: the address was
otherwise written into every event's frontmatter alongside the copy in `site.ts`, and only an
off-site event has anything to say here.
- **`displayDate` is gone, and no date is written in prose.** Every event stated its date twice —
as timestamps and as a hand-written string — and the open house stated it twice more, in body
copy and in an FAQ answer. `src/lib/event-date.ts` formats `start`/`end`, so rescheduling is one
edit. Its `knip.jsonc` entry is a seam Phase 08's acceptance criteria require closing.
- **Kickoff teasers and hints came across.** Legacy's `KICKOFF_CONFIG.media` carried two season
teaser URLs and two game-hint links behind `hasTeasers`/`hasHints`. No schema field could hold
them, so they would have vanished when Phase 08 rebuilt the page. `teaserUrls`, `hintUrls` and
`hintLabels` are flat parallel arrays per D2. The kickoff `description` is also legacy's own
metadata description again rather than newly written prose (D8: revision is Phase 12).
- **`subtitle` on `events`.** Legacy's kickoff hero had an `<h2>` subtitle under the `<h1>`. As
markdown that became an `##` with no section under it, which lands in the heading outline and
restates the title one line above.
- **`yearLabel` and `logo` on `frcRobots`.** Robo Fett was "2020-2021" on the legacy page — the
COVID two-season robot — which a single `year` cannot hold; `year` stays the sort key. Legacy
also overlaid a per-robot wordmark on the Viper and TroubleClef slides; both images are now in
`src/assets/frc/robots/`.
- **`programs` covers `sc2`.** The `program` enum accepted `sc2` (the open house uses it) while the
map had only `frc` and `fll`, so `programs[entry.data.program]` was `undefined` for that event.
The enum is now derived from `PROGRAM_KEYS` and the map `satisfies` a total record over it, so a
program the schema accepts but the map lacks is a compile error.
- **`astro.config.ts` reads `site.url`.** The canonical origin was declared in both places, so a
domain change could leave canonical/OG tags disagreeing with the sitemap, silently.
- **`sharp` is a direct dependency** (`docs/adr/0003`). This phase moves 33 images behind
`image()`, and pnpm's isolated layout keeps Astro's own copy where the bundled image service
cannot resolve it — every collection image would have fallen back to unoptimized passthrough.
- **Assets moved only as far as the collections need,** and the moved ones were re-encoded.
Sponsor logos, team photos, the five robot photos, two event heroes and the two robot wordmarks
live in `src/assets/`. `openhouse-header.webp` went back to `public/image`: nothing references
it, and Phase 09's prune works from that inventory. The 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, `pnpm assets:optimize`) capped them at 2560px: **17.8 MB saved across 12 files.**
The full `public/` inventory and prune is still Phase 09's job.
- The **schema guardrail is review guidance**, not a lint rule (the brief allowed either): the
rule now lives in `AGENTS.md` next to the content-editing pointer. A lint rule that recognizes
"an inline array that should be a collection" would be guesswork.
Loading
Loading