diff --git a/AGENTS.md b/AGENTS.md index fab842d..9ae5f20 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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/`. diff --git a/astro.config.ts b/astro.config.ts index 99e231e..38c8250 100644 --- a/astro.config.ts +++ b/astro.config.ts @@ -2,13 +2,17 @@ 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({ // /styleguide is a noindex review artifact; a URL that is both in the sitemap and // noindex is a "Submitted URL marked 'noindex'" error in Search Console. integrations: [sitemap({ filter: (page) => !page.includes("/styleguide") })], outDir: "./dist", output: "static", - site: "https://scstem.org", + site: site.url, vite: { plugins: [tailwindcss()], }, diff --git a/docs/adr/0003-sharp-direct-dependency.md b/docs/adr/0003-sharp-direct-dependency.md new file mode 100644 index 0000000..f621b91 --- /dev/null +++ b/docs/adr/0003-sharp-direct-dependency.md @@ -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 ``. + +## 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. diff --git a/docs/content.md b/docs/content.md new file mode 100644 index 0000000..763648f --- /dev/null +++ b/docs/content.md @@ -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/.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/.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/-.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/.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. diff --git a/docs/tooling.md b/docs/tooling.md index a0c63a6..219520c 100644 --- a/docs/tooling.md +++ b/docs/tooling.md @@ -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 | diff --git a/knip.jsonc b/knip.jsonc index fde84c7..cc8a1e7 100644 --- a/knip.jsonc +++ b/knip.jsonc @@ -4,7 +4,11 @@ "src/pages/**", "functions/**", // Exports the font URLs BaseLayout preloads; wired up in Phase 05. - "src/styles/fonts.ts" + "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/**"], diff --git a/package.json b/package.json index d159e10..a4bef1b 100644 --- a/package.json +++ b/package.json @@ -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", @@ -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": { diff --git a/plan/04-content-model.md b/plan/04-content-model.md index 0b44789..dbcf4e0 100644 --- a/plan/04-content-model.md +++ b/plan/04-content-model.md @@ -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/.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 `

` subtitle under the `

`. 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 `` 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. diff --git a/plan/08-events.md b/plan/08-events.md index 8a62595..2b97b8b 100644 --- a/plan/08-events.md +++ b/plan/08-events.md @@ -37,4 +37,6 @@ Extend `docs/content.md`: "Update the open house for a new season" (edit dates/c - [ ] Event + FAQPage JSON-LD validate (Google Rich Results test, manual, once per template). - [ ] Countdown shows correct absolute date without JS; upgrades with JS; handles passed events. - [ ] Program theming correct (kickoff = frc green). +- [ ] The `src/lib/event-date.ts` entry is deleted from `knip.jsonc` — the event pages added here + are its real consumers, so the seam has to close with them. - [ ] `pnpm check && pnpm build` green. diff --git a/plan/09-assets-performance.md b/plan/09-assets-performance.md index c64d896..8d1584b 100644 --- a/plan/09-assets-performance.md +++ b/plan/09-assets-performance.md @@ -9,7 +9,9 @@ Close the biggest mobile-perf gaps (72 MB `public/`, 22 MB hero video, 11 MB bac ## Current worst offenders (from audit) -`public/video/biohazard/home-video.mp4` 22.1 MB, `home-video.webm` 14.7 MB, `image/biohazard/2019-robot-field.webp` 11.3 MB, `2021-robot-field.webp` 4.1 MB, `team/frc/2023.webp` 2.5 MB, `2021-robot-pits.webp` 2.2 MB, `electronics.webp` 1.0 MB, `openhouse-header.webp` 931 KB, `legos.webp` 808 KB — 26 files > 300 KB total. +`public/video/biohazard/home-video.mp4` 22.1 MB, `home-video.webm` 14.7 MB, `image/biohazard/2021-robot-pits.webp` 2.2 MB, `electronics.webp` 1.0 MB, `openhouse-header.webp` 931 KB, `legos.webp` 808 KB — 26 files > 300 KB total. + +Phase 04 already re-encoded the sources it moved into `src/assets/` (`tools/assets/optimize-sources.mjs`, §2 below, 17.8 MB saved) — `2019-robot-field.webp` was the worst at 11.3 MB. The video, and everything still in `public/image`, is untouched. ## Tasks @@ -21,7 +23,7 @@ Close the biggest mobile-perf gaps (72 MB `public/`, 22 MB hero video, 11 MB bac ### 2. Source re-encoding - Script `tools/assets/optimize-sources.mjs` (sharp, run manually, documented): re-export any source > 500 KB — max dimension 2560px, quality-tuned WebP/JPEG sources (astro:assets derives AVIF/WebP variants at build). Commit re-encoded sources; keep originals only if under size, otherwise they're gone (git history preserves them). -- Verify astro:assets output: every ``/`` use has `widths` + `sizes` appropriate to its rendered layout, AVIF+WebP formats, explicit dimensions (CLS = 0). LCP image per page: `loading="eager"` + `fetchpriority="high"`; all others lazy. +- Verify astro:assets output: every ``/`` use has `widths` + `sizes` appropriate to its rendered layout, AVIF+WebP formats, explicit dimensions (CLS = 0). Note that with the Phase 04 sources already at q80/2560px, a variant generated at Astro's default quality comes out slightly *larger* than the source (+6 to +89 KB across the team photos) — `widths` plus an explicit `quality` at each call site is what closes that, and it needs the call sites this phase finally has. LCP image per page: `loading="eager"` + `fetchpriority="high"`; all others lazy. ### 3. Hero video (D20) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 162eb4a..73ea71a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -35,6 +35,9 @@ importers: cnfast: specifier: 0.1.0 version: 0.1.0 + sharp: + specifier: 0.35.3 + version: 0.35.3(@types/node@26.2.0) tailwindcss: specifier: 4.3.3 version: 4.3.3 @@ -4104,8 +4107,7 @@ snapshots: '@humanwhocodes/retry@0.4.3': {} - '@img/colour@1.1.0': - optional: true + '@img/colour@1.1.0': {} '@img/sharp-darwin-arm64@0.35.3': optionalDependencies: @@ -6546,7 +6548,6 @@ snapshots: '@img/sharp-win32-ia32': 0.35.3 '@img/sharp-win32-x64': 0.35.3 '@types/node': 26.2.0 - optional: true shebang-command@2.0.0: dependencies: diff --git a/public/image/biohazard/2019-robot-field.webp b/public/image/biohazard/2019-robot-field.webp deleted file mode 100644 index 4ed0621..0000000 Binary files a/public/image/biohazard/2019-robot-field.webp and /dev/null differ diff --git a/public/image/biohazard/2021-robot-field.webp b/public/image/biohazard/2021-robot-field.webp deleted file mode 100644 index 397adce..0000000 Binary files a/public/image/biohazard/2021-robot-field.webp and /dev/null differ diff --git a/public/image/biohazard/2022-robot-field-3.webp b/public/image/biohazard/2022-robot-field-3.webp deleted file mode 100644 index 1ee0b2e..0000000 Binary files a/public/image/biohazard/2022-robot-field-3.webp and /dev/null differ diff --git a/public/image/biohazard/2024-robot-field.webp b/public/image/biohazard/2024-robot-field.webp deleted file mode 100644 index 19055ea..0000000 Binary files a/public/image/biohazard/2024-robot-field.webp and /dev/null differ diff --git a/public/image/biohazard/trouble-logo.webp b/public/image/biohazard/trouble-logo.webp deleted file mode 100644 index 7c376aa..0000000 Binary files a/public/image/biohazard/trouble-logo.webp and /dev/null differ diff --git a/public/image/cheering.webp b/public/image/cheering.webp deleted file mode 100644 index db34be9..0000000 Binary files a/public/image/cheering.webp and /dev/null differ diff --git a/public/image/morethanrobots.webp b/public/image/morethanrobots.webp deleted file mode 100644 index 075547d..0000000 Binary files a/public/image/morethanrobots.webp and /dev/null differ diff --git a/public/image/team/fll/2024.webp b/public/image/team/fll/2024.webp deleted file mode 100644 index e747c2a..0000000 Binary files a/public/image/team/fll/2024.webp and /dev/null differ diff --git a/public/image/team/frc/2023.webp b/public/image/team/frc/2023.webp deleted file mode 100644 index 91eaa84..0000000 Binary files a/public/image/team/frc/2023.webp and /dev/null differ diff --git a/public/image/team/frc/2024.webp b/public/image/team/frc/2024.webp deleted file mode 100644 index 418f8be..0000000 Binary files a/public/image/team/frc/2024.webp and /dev/null differ diff --git a/public/image/team/frc/2025.webp b/public/image/team/frc/2025.webp deleted file mode 100644 index c303d07..0000000 Binary files a/public/image/team/frc/2025.webp and /dev/null differ diff --git a/public/image/team/frc/2026.webp b/public/image/team/frc/2026.webp deleted file mode 100644 index 202f044..0000000 Binary files a/public/image/team/frc/2026.webp and /dev/null differ diff --git a/src/assets/events/cheering.webp b/src/assets/events/cheering.webp new file mode 100644 index 0000000..37b2a8a Binary files /dev/null and b/src/assets/events/cheering.webp differ diff --git a/src/assets/events/morethanrobots.webp b/src/assets/events/morethanrobots.webp new file mode 100644 index 0000000..b9ad300 Binary files /dev/null and b/src/assets/events/morethanrobots.webp differ diff --git a/src/assets/frc/robots/2019-robot-field.webp b/src/assets/frc/robots/2019-robot-field.webp new file mode 100644 index 0000000..63cd6e7 Binary files /dev/null and b/src/assets/frc/robots/2019-robot-field.webp differ diff --git a/src/assets/frc/robots/2021-robot-field.webp b/src/assets/frc/robots/2021-robot-field.webp new file mode 100644 index 0000000..b9ff11c Binary files /dev/null and b/src/assets/frc/robots/2021-robot-field.webp differ diff --git a/src/assets/frc/robots/2022-robot-field-3.webp b/src/assets/frc/robots/2022-robot-field-3.webp new file mode 100644 index 0000000..bdef59e Binary files /dev/null and b/src/assets/frc/robots/2022-robot-field-3.webp differ diff --git a/public/image/biohazard/2023-robot-field.webp b/src/assets/frc/robots/2023-robot-field.webp similarity index 100% rename from public/image/biohazard/2023-robot-field.webp rename to src/assets/frc/robots/2023-robot-field.webp diff --git a/public/image/biohazard/viper-logo.webp b/src/assets/frc/robots/2023-viper-logo.webp similarity index 100% rename from public/image/biohazard/viper-logo.webp rename to src/assets/frc/robots/2023-viper-logo.webp diff --git a/src/assets/frc/robots/2024-robot-field.webp b/src/assets/frc/robots/2024-robot-field.webp new file mode 100644 index 0000000..b191c55 Binary files /dev/null and b/src/assets/frc/robots/2024-robot-field.webp differ diff --git a/src/assets/frc/robots/2024-troubleclef-logo.webp b/src/assets/frc/robots/2024-troubleclef-logo.webp new file mode 100644 index 0000000..434f42f Binary files /dev/null and b/src/assets/frc/robots/2024-troubleclef-logo.webp differ diff --git a/public/image/sponsor/fives.svg b/src/assets/sponsors/fives.svg similarity index 100% rename from public/image/sponsor/fives.svg rename to src/assets/sponsors/fives.svg diff --git a/public/image/sponsor/jlg.svg b/src/assets/sponsors/jlg.svg similarity index 100% rename from public/image/sponsor/jlg.svg rename to src/assets/sponsors/jlg.svg diff --git a/public/image/sponsor/journalytic.svg b/src/assets/sponsors/journalytic.svg similarity index 100% rename from public/image/sponsor/journalytic.svg rename to src/assets/sponsors/journalytic.svg diff --git a/public/image/sponsor/manitowoc.png b/src/assets/sponsors/manitowoc.png similarity index 100% rename from public/image/sponsor/manitowoc.png rename to src/assets/sponsors/manitowoc.png diff --git a/public/image/sponsor/orrstown.png b/src/assets/sponsors/orrstown.png similarity index 100% rename from public/image/sponsor/orrstown.png rename to src/assets/sponsors/orrstown.png diff --git a/public/image/sponsor/vfw.png b/src/assets/sponsors/vfw.png similarity index 100% rename from public/image/sponsor/vfw.png rename to src/assets/sponsors/vfw.png diff --git a/public/image/sponsor/volvo.png b/src/assets/sponsors/volvo.png similarity index 100% rename from public/image/sponsor/volvo.png rename to src/assets/sponsors/volvo.png diff --git a/public/image/sponsor/wellspan.png b/src/assets/sponsors/wellspan.png similarity index 100% rename from public/image/sponsor/wellspan.png rename to src/assets/sponsors/wellspan.png diff --git a/public/image/sponsor/workshope.png b/src/assets/sponsors/workshope.png similarity index 100% rename from public/image/sponsor/workshope.png rename to src/assets/sponsors/workshope.png diff --git a/public/image/sponsor/yb-dark.png b/src/assets/sponsors/yb-dark.png similarity index 100% rename from public/image/sponsor/yb-dark.png rename to src/assets/sponsors/yb-dark.png diff --git a/src/assets/team/fll/2024.webp b/src/assets/team/fll/2024.webp new file mode 100644 index 0000000..736fc09 Binary files /dev/null and b/src/assets/team/fll/2024.webp differ diff --git a/public/image/team/frc/2013.webp b/src/assets/team/frc/2013.webp similarity index 100% rename from public/image/team/frc/2013.webp rename to src/assets/team/frc/2013.webp diff --git a/public/image/team/frc/2014.webp b/src/assets/team/frc/2014.webp similarity index 100% rename from public/image/team/frc/2014.webp rename to src/assets/team/frc/2014.webp diff --git a/public/image/team/frc/2015.webp b/src/assets/team/frc/2015.webp similarity index 100% rename from public/image/team/frc/2015.webp rename to src/assets/team/frc/2015.webp diff --git a/public/image/team/frc/2016.webp b/src/assets/team/frc/2016.webp similarity index 100% rename from public/image/team/frc/2016.webp rename to src/assets/team/frc/2016.webp diff --git a/public/image/team/frc/2017.webp b/src/assets/team/frc/2017.webp similarity index 100% rename from public/image/team/frc/2017.webp rename to src/assets/team/frc/2017.webp diff --git a/public/image/team/frc/2018.webp b/src/assets/team/frc/2018.webp similarity index 100% rename from public/image/team/frc/2018.webp rename to src/assets/team/frc/2018.webp diff --git a/public/image/team/frc/2019.webp b/src/assets/team/frc/2019.webp similarity index 100% rename from public/image/team/frc/2019.webp rename to src/assets/team/frc/2019.webp diff --git a/public/image/team/frc/2020.webp b/src/assets/team/frc/2020.webp similarity index 100% rename from public/image/team/frc/2020.webp rename to src/assets/team/frc/2020.webp diff --git a/public/image/team/frc/2022.webp b/src/assets/team/frc/2022.webp similarity index 100% rename from public/image/team/frc/2022.webp rename to src/assets/team/frc/2022.webp diff --git a/src/assets/team/frc/2023.webp b/src/assets/team/frc/2023.webp new file mode 100644 index 0000000..88daad7 Binary files /dev/null and b/src/assets/team/frc/2023.webp differ diff --git a/src/assets/team/frc/2024.webp b/src/assets/team/frc/2024.webp new file mode 100644 index 0000000..04c4a2d Binary files /dev/null and b/src/assets/team/frc/2024.webp differ diff --git a/src/assets/team/frc/2025.webp b/src/assets/team/frc/2025.webp new file mode 100644 index 0000000..f658d1a Binary files /dev/null and b/src/assets/team/frc/2025.webp differ diff --git a/src/assets/team/frc/2026.webp b/src/assets/team/frc/2026.webp new file mode 100644 index 0000000..57764a7 Binary files /dev/null and b/src/assets/team/frc/2026.webp differ diff --git a/src/content.config.ts b/src/content.config.ts new file mode 100644 index 0000000..f89a461 --- /dev/null +++ b/src/content.config.ts @@ -0,0 +1,163 @@ +import { glob } from "astro/loaders"; +import { z } from "astro/zod"; +import { defineCollection, reference, type SchemaContext } from "astro:content"; + +import { PROGRAM_KEYS } from "@/data/site"; + +/** + * Every maintainable fact about this site lives in `src/content/` as markdown with validated + * frontmatter. Editing content means editing one small file — see `docs/content.md`. + * + * Schemas stay deliberately **flat** (D2): strings, enums, booleans, dates, numbers, and image + * paths. No nested objects beyond one level, no discriminated unions. That is what keeps a + * git-backed CMS (Keystatic and friends) a later addition rather than a restructuring, and it + * is why `location` below is two flat fields instead of an object. Schema changes need an ADR. + */ + +/** Legacy's `SponsorLevel`, preserved exactly (`legacy/data/sponsors.ts`). */ +const SPONSOR_LEVELS = ["Platinum", "Gold", "Silver", "Bronze", "Friend"] as const; + +const sponsors = defineCollection({ + loader: glob({ base: "src/content/sponsors", pattern: "**/*.md" }), + schema: ({ image }) => + z.strictObject({ + name: z.string(), + level: z.enum(SPONSOR_LEVELS), + url: z.url().optional(), + logo: image().optional(), + /** + * `false` retires a sponsor without deleting the record — former sponsors stay in git as + * data rather than as commented-out code. + */ + active: z.boolean().default(true), + /** First year of support, as shown on the sponsors page. */ + since: z.number().int().optional(), + /** A qualifier shown under the name, e.g. a specific chapter or post. */ + sub: z.string().optional(), + /** Maintainer notes. Never rendered. */ + notes: z.string().optional(), + }), +}); + +const events = defineCollection({ + loader: glob({ base: "src/content/events", pattern: "**/*.md" }), + schema: ({ image }) => + z.strictObject({ + title: z.string(), + /** Hero subtitle, sitting directly under the title. Not a heading — it has no section. */ + subtitle: z.string().optional(), + program: z.enum(PROGRAM_KEYS), + start: z.date(), + end: z.date().optional(), + /** + * Location is optional and defaults to the workspace from `src/data/site.ts` — only an + * off-site event carries these, which also makes "off-site" a visible signal. + */ + locationName: z.string().optional(), + locationAddress: z.string().optional(), + directionsUrl: z.url().optional(), + /** Meta description for the event's page. */ + description: z.string(), + heroImage: image().optional(), + ctaLabel: z.string(), + ctaHref: z.string(), + registrationUrl: z.url().optional(), + /** + * Season teaser videos, and this year's game hints, as flat parallel arrays (D2 — no + * nested objects). `hintLabels[i]` names `hintUrls[i]`; a length mismatch fails the build. + */ + teaserUrls: z.array(z.url()).optional(), + hintUrls: z.array(z.url()).optional(), + hintLabels: z.array(z.string()).optional(), + /** FAQ entries to render on the page, by their file id. */ + faq: z.array(reference("faq")).optional(), + /** + * `true` takes the page out of service: it redirects to its parent and drops out of the + * sitemap. This is how a past season is retired — one field, no code change. + */ + hidden: z.boolean().default(false), + }), +}); + +const faq = defineCollection({ + loader: glob({ base: "src/content/faq", pattern: "**/*.md" }), + schema: z.strictObject({ + question: z.string(), + program: z.enum(PROGRAM_KEYS).optional(), + tags: z.array(z.string()).optional(), + }), +}); + +/** + * Scaffolded now so Phase 12 needs no code (D18). `template.md` is a real entry carrying + * `draft: true`, deliberately: an excluded-by-glob template drifts from the schema silently and + * leaves the collection empty, which warns on every build. As an entry it is schema-validated + * and still never renders. + */ +const news = defineCollection({ + loader: glob({ base: "src/content/news", pattern: "**/*.md" }), + schema: ({ image }) => + z.strictObject({ + title: z.string(), + date: z.date(), + description: z.string(), + heroImage: image().optional(), + draft: z.boolean().default(false), + }), +}); + +const frcRobots = defineCollection({ + loader: glob({ base: "src/content/frc/robots", pattern: "**/*.md" }), + schema: ({ image }) => + z.strictObject({ + year: z.number().int(), + /** + * Overrides `year` in display copy where a robot spans two seasons — Robo Fett was + * "2020-2021" on the legacy page. `year` stays the sort key. + */ + yearLabel: z.string().optional(), + name: z.string(), + /** The season's game, as FIRST named it. */ + game: z.string().optional(), + image: image().optional(), + imageAlt: z.string(), + /** Per-robot wordmark overlaid on the photo, where a season had one. */ + logo: image().optional(), + achievements: z.array(z.string()).optional(), + }), +}); + +/** FRC and FLL team photos are the same shape; the split is by directory (D18). */ +const teamPhotoSchema = ({ image }: SchemaContext) => + z.strictObject({ + year: z.number().int(), + photo: image(), + alt: z.string(), + caption: z.string().optional(), + }); + +const frcTeamPhotos = defineCollection({ + loader: glob({ base: "src/content/frc/team-photos", pattern: "**/*.md" }), + schema: teamPhotoSchema, +}); + +const fllTeamPhotos = defineCollection({ + loader: glob({ base: "src/content/fll/team-photos", pattern: "**/*.md" }), + schema: teamPhotoSchema, +}); + +/** + * Collection *names* avoid slashes even though the content nests under `frc/` and `fll/` on disk + * (D18): Astro writes each collection's editor JSON schema to + * `.astro/collections/.schema.json` without creating intermediate directories, so a name + * like `frc/robots` warns on every build and silently loses frontmatter autocomplete. + */ +export const collections = { + sponsors, + events, + faq, + news, + frcRobots, + frcTeamPhotos, + fllTeamPhotos, +}; diff --git a/src/content/events/frc-kickoff.md b/src/content/events/frc-kickoff.md new file mode 100644 index 0000000..f00b058 --- /dev/null +++ b/src/content/events/frc-kickoff.md @@ -0,0 +1,63 @@ +--- +title: 2026 Season Kickoff +program: frc +start: 2026-01-10T12:00:00-05:00 +subtitle: Join Biohazard as we unveil this year's challenge +description: Join Biohazard for the 2026 FIRST Robotics Competition kickoff +heroImage: ../../assets/events/cheering.webp +ctaLabel: Watch on FIRST website +ctaHref: https://www.firstinspires.org/robotics/frc/kickoff +teaserUrls: + - https://www.youtube.com/watch?v=2AEdAID2U2o + - https://www.youtube.com/watch?v=0JaguGctF1A +hintUrls: + - https://www.youtube.com/watch?v=0JaguGctF1A + - https://community.firstinspires.org/2025-game-hint-3-for-2026 +hintLabels: + - "Hint #1" + - "Hint #2" +--- + +## The build season journey + +**Kickoff - January 10.** The game is revealed! Teams watch the live broadcast and receive the +game manual to start strategizing. + +**Build Season - 6 Weeks.** Design, prototype, build, and program the robot to meet the game +challenges. + +**Competition - March & April.** Compete in regional events, form alliances, and work toward +qualifying for the World Championship. + +**Ages 14-18.** High school students work alongside adult mentors from industry to tackle real +engineering challenges. + +## What is _FIRST®_ Robotics Competition? + +_FIRST®_ Robotics Competition (FRC) is a high-school program for robotics competition in the same +way as traditional sports. Creativity, determination, and teamwork are the keys to success as we +compete with teams from around the world. + +**Innovation.** Design and build complex robots to compete in _FIRST®_, using real-world skills to +build the best robot possible. + +**Hands-on Learning.** Gain valuable experience in engineering, programming, and problem solving +that will help you in your future career. + +**Collaboration.** Work closely with a team of students and mentors, who will help turn your ideas +into reality. + +**Fun.** We compete hard, but we also have a lot of fun while doing it. Saturday lunches, team +picnic, off-season events, and more. + +## Meeting schedule + +- 10AM - Parent's meeting & Safety meeting +- 11AM - Lunch break and prepare for game reveal +- 12PM - Game reveal live stream beings +- ~4PM - Meeting Ends + +## Can't make it? + +Interested in joining Biohazard but can't attend kickoff? We'd still love to have you on the team! +Fill out the [get involved](/get-involved) form and we'll be in touch. diff --git a/src/content/events/openhouse.md b/src/content/events/openhouse.md new file mode 100644 index 0000000..ff7896f --- /dev/null +++ b/src/content/events/openhouse.md @@ -0,0 +1,27 @@ +--- +title: Open house +program: sc2 +start: 2026-08-01T13:00:00-04:00 +end: 2026-08-01T16:00:00-04:00 +description: Are you or is someone you know interested in LEGO®, science, technology, engineering, math, business, marketing, video production, software development, web design, carpentry, or leadership? Find out more and get a chance to speak with our students and mentors at our upcoming open house. +heroImage: ../../assets/events/morethanrobots.webp +ctaLabel: Get involved +ctaHref: /get-involved +faq: + - when-is-the-open-house + - what-age-to-join + - what-does-it-cost + - no-robotics-experience + - not-interested-in-robots + - cant-make-the-open-house +--- + +## Want to know more about STEM and Robots? + +Join us for our open house in downtown Chambersburg to see what we're all about. + +## Meet the teams, see the robots, and learn more about our programs. + +Are you or is someone you know interested in LEGO®, science, technology, engineering, math, +business, marketing, video production, software development, web design, carpentry, or leadership? +Find out more and get a chance to speak with our students and mentors at our upcoming open house. diff --git a/src/content/faq/cant-make-the-open-house.md b/src/content/faq/cant-make-the-open-house.md new file mode 100644 index 0000000..65dfbf9 --- /dev/null +++ b/src/content/faq/cant-make-the-open-house.md @@ -0,0 +1,8 @@ +--- +question: I can't make it to the open house, is there another time I can visit or learn about what you do? +program: sc2 +tags: [openhouse, joining] +--- + +Yes! You can fill out our [Get Involved](/get-involved) form with your information and we will +reach out. There's a good chance we have a meeting coming up you're welcome to visit! diff --git a/src/content/faq/no-robotics-experience.md b/src/content/faq/no-robotics-experience.md new file mode 100644 index 0000000..a061feb --- /dev/null +++ b/src/content/faq/no-robotics-experience.md @@ -0,0 +1,7 @@ +--- +question: What if I don't know anything about robotics? +tags: [joining] +--- + +That's okay! We have a wide range of experience levels and are always looking for new members to +join any of our teams. We will teach you everything you need to know. diff --git a/src/content/faq/not-interested-in-robots.md b/src/content/faq/not-interested-in-robots.md new file mode 100644 index 0000000..01526ab --- /dev/null +++ b/src/content/faq/not-interested-in-robots.md @@ -0,0 +1,7 @@ +--- +question: What if I am not interested in robots, engineering, or computers? +tags: [joining] +--- + +You should definitely still check us out! We have many students who are interested in art, +business, video production, and more - there is a place on our teams for just about everyone. diff --git a/src/content/faq/what-age-to-join.md b/src/content/faq/what-age-to-join.md new file mode 100644 index 0000000..2020f63 --- /dev/null +++ b/src/content/faq/what-age-to-join.md @@ -0,0 +1,7 @@ +--- +question: What age do I have to be to join? +tags: [joining] +--- + +We have 2 primary programs, covering ages 9 to 18. We are also always looking for adult mentors +willing to share their skills with our students. diff --git a/src/content/faq/what-does-it-cost.md b/src/content/faq/what-does-it-cost.md new file mode 100644 index 0000000..1f1c1e0 --- /dev/null +++ b/src/content/faq/what-does-it-cost.md @@ -0,0 +1,7 @@ +--- +question: What does it cost to participate? +tags: [joining, cost] +--- + +Membership dues or registration fees are up to the individual teams depending on age group, but +most are free or very inexpensive. diff --git a/src/content/faq/when-is-the-open-house.md b/src/content/faq/when-is-the-open-house.md new file mode 100644 index 0000000..4bff2d4 --- /dev/null +++ b/src/content/faq/when-is-the-open-house.md @@ -0,0 +1,8 @@ +--- +question: When is the open house? +program: sc2 +tags: [openhouse] +--- + +See the date and time at the top of this page — it comes from the event itself, so it is +never out of step with the schedule. diff --git a/src/content/fll/team-photos/2024.md b/src/content/fll/team-photos/2024.md new file mode 100644 index 0000000..2c9cf39 --- /dev/null +++ b/src/content/fll/team-photos/2024.md @@ -0,0 +1,6 @@ +--- +year: 2024 +photo: ../../../assets/team/fll/2024.webp +alt: The FIRST LEGO League teams in 2024 +caption: '"Team Jeremy" & "Toxic Musicians" - 2024' +--- diff --git a/src/content/frc/robots/2019-toxic-silver.md b/src/content/frc/robots/2019-toxic-silver.md new file mode 100644 index 0000000..a032790 --- /dev/null +++ b/src/content/frc/robots/2019-toxic-silver.md @@ -0,0 +1,13 @@ +--- +year: 2019 +name: Toxic Silver +image: ../../../assets/frc/robots/2019-robot-field.webp +imageAlt: Toxic Silver on the competition field +achievements: + - Excellence in Engineering Award, Greater Pittsburgh Regional + - Rank 8, Battle O' Baltimore +--- + +With its shining aluminum lift and maneuverable mecanum drivetrain, Toxic Silver won us an +Excellence in Engineering Award at the Greater Pittsburgh Regional and Rank 8 at the Battle +O' Baltimore. diff --git a/src/content/frc/robots/2021-robo-fett.md b/src/content/frc/robots/2021-robo-fett.md new file mode 100644 index 0000000..7d02f02 --- /dev/null +++ b/src/content/frc/robots/2021-robo-fett.md @@ -0,0 +1,11 @@ +--- +year: 2021 +yearLabel: 2020-2021 +name: Robo Fett +image: ../../../assets/frc/robots/2021-robot-field.webp +imageAlt: Robo Fett on the competition field +--- + +Armed with a giant scissor-lift climber and an inline shooting mechanism, Robo Fett is one of two +Biohazard robots to use pneumatic wheels. Along one side, a large plexiglass panel protects the +electronics while proudly displaying the 2020 team logo. diff --git a/src/content/frc/robots/2022-omega.md b/src/content/frc/robots/2022-omega.md new file mode 100644 index 0000000..7b52c04 --- /dev/null +++ b/src/content/frc/robots/2022-omega.md @@ -0,0 +1,13 @@ +--- +year: 2022 +name: O'Mega +image: ../../../assets/frc/robots/2022-robot-field-3.webp +imageAlt: O'Mega on the competition field +achievements: + - Finalist + - Industrial Design Award +--- + +One of our heavier robots, O'Mega boasts a giant cannon, a sizeable intake, and two climbing arms. +An adjustable firing mechanism allowed it to shoot into both hoops in the 2022 game, and won us a +spot in the finals and an Industrial Design Award. diff --git a/src/content/frc/robots/2023-viper.md b/src/content/frc/robots/2023-viper.md new file mode 100644 index 0000000..c202014 --- /dev/null +++ b/src/content/frc/robots/2023-viper.md @@ -0,0 +1,14 @@ +--- +year: 2023 +name: Viper +image: ../../../assets/frc/robots/2023-robot-field.webp +imageAlt: Viper on the competition field +logo: ../../../assets/frc/robots/2023-viper-logo.webp +achievements: + - Industrial Design Award, Pittsburgh Regional +--- + +Competing in the 2023 Pittsburgh Regional Competition, Viper won us the Industrial Design Award, +and a few curious looks from fellow teams. Its main features are the three foot metal arm and 3d +printed pneumatic claw, used to atriculate inflatable cubes. Mecanum wheels allow it to fine-tune +its position, and dual cameras gave our drivers lots of visibilty. diff --git a/src/content/frc/robots/2024-troubleclef.md b/src/content/frc/robots/2024-troubleclef.md new file mode 100644 index 0000000..9973c55 --- /dev/null +++ b/src/content/frc/robots/2024-troubleclef.md @@ -0,0 +1,16 @@ +--- +year: 2024 +name: Troubleclef +image: ../../../assets/frc/robots/2024-robot-field.webp +imageAlt: Troubleclef on the competition field +logo: ../../../assets/frc/robots/2024-troubleclef-logo.webp +achievements: + - 15th place, Buckeye Valley Regional + - Imagery Award, 2023-2024 season +--- + +Amid more conventional and exposed designs, TroubleClef sticks out like a sore thumb with its +sleek gunmetal sides and concealed electronics. A 3d printed chain carries game pieces from the +ground up to the peak of the ramp, and a pair of motors shoot it forward. Coming back from a loss +in Pittsburgh, this machine got us 15th place at the Buckeye Valley Regional and helped us win our +Imagery Award in the 2023-2024 season. diff --git a/src/content/frc/robots/2025.md b/src/content/frc/robots/2025.md new file mode 100644 index 0000000..98f8ba9 --- /dev/null +++ b/src/content/frc/robots/2025.md @@ -0,0 +1,7 @@ +--- +year: 2025 +name: Coming soon +imageAlt: Photo not yet available +--- + +Coming soon... diff --git a/src/content/frc/team-photos/2013.md b/src/content/frc/team-photos/2013.md new file mode 100644 index 0000000..bea4d71 --- /dev/null +++ b/src/content/frc/team-photos/2013.md @@ -0,0 +1,6 @@ +--- +year: 2013 +photo: ../../../assets/team/frc/2013.webp +alt: The Biohazard team in 2013 +caption: '"Biohazard" - 2013' +--- diff --git a/src/content/frc/team-photos/2014.md b/src/content/frc/team-photos/2014.md new file mode 100644 index 0000000..c32724f --- /dev/null +++ b/src/content/frc/team-photos/2014.md @@ -0,0 +1,6 @@ +--- +year: 2014 +photo: ../../../assets/team/frc/2014.webp +alt: The Biohazard team in 2014 +caption: '"Biohazard" - 2014' +--- diff --git a/src/content/frc/team-photos/2015.md b/src/content/frc/team-photos/2015.md new file mode 100644 index 0000000..cd1c815 --- /dev/null +++ b/src/content/frc/team-photos/2015.md @@ -0,0 +1,6 @@ +--- +year: 2015 +photo: ../../../assets/team/frc/2015.webp +alt: The Biohazard team in 2015 +caption: '"Biohazard" - 2015' +--- diff --git a/src/content/frc/team-photos/2016.md b/src/content/frc/team-photos/2016.md new file mode 100644 index 0000000..8be3b02 --- /dev/null +++ b/src/content/frc/team-photos/2016.md @@ -0,0 +1,6 @@ +--- +year: 2016 +photo: ../../../assets/team/frc/2016.webp +alt: The Biohazard team in 2016 +caption: '"Biohazard" - 2016' +--- diff --git a/src/content/frc/team-photos/2017.md b/src/content/frc/team-photos/2017.md new file mode 100644 index 0000000..6d97e67 --- /dev/null +++ b/src/content/frc/team-photos/2017.md @@ -0,0 +1,6 @@ +--- +year: 2017 +photo: ../../../assets/team/frc/2017.webp +alt: The Biohazard team in 2017 +caption: '"Biohazard" - 2017' +--- diff --git a/src/content/frc/team-photos/2018.md b/src/content/frc/team-photos/2018.md new file mode 100644 index 0000000..f87049a --- /dev/null +++ b/src/content/frc/team-photos/2018.md @@ -0,0 +1,6 @@ +--- +year: 2018 +photo: ../../../assets/team/frc/2018.webp +alt: The Biohazard team in 2018 +caption: '"Biohazard" - 2018' +--- diff --git a/src/content/frc/team-photos/2019.md b/src/content/frc/team-photos/2019.md new file mode 100644 index 0000000..7e2871f --- /dev/null +++ b/src/content/frc/team-photos/2019.md @@ -0,0 +1,6 @@ +--- +year: 2019 +photo: ../../../assets/team/frc/2019.webp +alt: The Biohazard team in 2019 +caption: '"Biohazard" - 2019' +--- diff --git a/src/content/frc/team-photos/2020.md b/src/content/frc/team-photos/2020.md new file mode 100644 index 0000000..53e0234 --- /dev/null +++ b/src/content/frc/team-photos/2020.md @@ -0,0 +1,6 @@ +--- +year: 2020 +photo: ../../../assets/team/frc/2020.webp +alt: The Biohazard team in 2020 +caption: '"Biohazard" - 2020' +--- diff --git a/src/content/frc/team-photos/2022.md b/src/content/frc/team-photos/2022.md new file mode 100644 index 0000000..10b2f9e --- /dev/null +++ b/src/content/frc/team-photos/2022.md @@ -0,0 +1,6 @@ +--- +year: 2022 +photo: ../../../assets/team/frc/2022.webp +alt: The Biohazard team in 2022 +caption: '"Biohazard" - 2022' +--- diff --git a/src/content/frc/team-photos/2023.md b/src/content/frc/team-photos/2023.md new file mode 100644 index 0000000..7aa4361 --- /dev/null +++ b/src/content/frc/team-photos/2023.md @@ -0,0 +1,6 @@ +--- +year: 2023 +photo: ../../../assets/team/frc/2023.webp +alt: The Biohazard team in 2023 +caption: '"Biohazard" - 2023' +--- diff --git a/src/content/frc/team-photos/2024.md b/src/content/frc/team-photos/2024.md new file mode 100644 index 0000000..4ec6107 --- /dev/null +++ b/src/content/frc/team-photos/2024.md @@ -0,0 +1,6 @@ +--- +year: 2024 +photo: ../../../assets/team/frc/2024.webp +alt: The Biohazard team in 2024 +caption: '"Biohazard" - 2024' +--- diff --git a/src/content/frc/team-photos/2025.md b/src/content/frc/team-photos/2025.md new file mode 100644 index 0000000..aef7933 --- /dev/null +++ b/src/content/frc/team-photos/2025.md @@ -0,0 +1,6 @@ +--- +year: 2025 +photo: ../../../assets/team/frc/2025.webp +alt: The Biohazard team in 2025 +caption: '"Biohazard" - 2025' +--- diff --git a/src/content/frc/team-photos/2026.md b/src/content/frc/team-photos/2026.md new file mode 100644 index 0000000..dcb7526 --- /dev/null +++ b/src/content/frc/team-photos/2026.md @@ -0,0 +1,6 @@ +--- +year: 2026 +photo: ../../../assets/team/frc/2026.webp +alt: The Biohazard team in 2026 +caption: '"Biohazard" - 2026' +--- diff --git a/src/content/news/template.md b/src/content/news/template.md new file mode 100644 index 0000000..0b9618e --- /dev/null +++ b/src/content/news/template.md @@ -0,0 +1,13 @@ +--- +# Copy this file, rename it to a url-friendly slug (my-post-title.md), delete these comments, +# and set draft: false when it is ready. Leave this template file itself as draft: true. +title: A short, specific headline +date: 2026-01-01 +description: One or two sentences. This is the meta description and the card summary, so write it for someone deciding whether to click. +# Optional. Put the image in src/assets/news/ first. +# heroImage: ../../assets/news/my-post.webp +draft: true +--- + +The post body, in markdown. `draft: true` keeps it out of the build — flip it to `false` to +publish. diff --git a/src/content/sponsors/fives.md b/src/content/sponsors/fives.md new file mode 100644 index 0000000..c769a9b --- /dev/null +++ b/src/content/sponsors/fives.md @@ -0,0 +1,8 @@ +--- +name: Fives +level: Platinum +url: https://www.fivesgroup.com/ +logo: ../../assets/sponsors/fives.svg +active: false +since: 2022 +--- diff --git a/src/content/sponsors/jlg.md b/src/content/sponsors/jlg.md new file mode 100644 index 0000000..84cdb05 --- /dev/null +++ b/src/content/sponsors/jlg.md @@ -0,0 +1,7 @@ +--- +name: JLG +level: Platinum +url: https://www.jlg.com/ +logo: ../../assets/sponsors/jlg.svg +since: 2013 +--- diff --git a/src/content/sponsors/journalytic.md b/src/content/sponsors/journalytic.md new file mode 100644 index 0000000..9b80cf6 --- /dev/null +++ b/src/content/sponsors/journalytic.md @@ -0,0 +1,7 @@ +--- +name: Journalytic +level: Gold +url: https://journalytic.com/ +logo: ../../assets/sponsors/journalytic.svg +since: 2024 +--- diff --git a/src/content/sponsors/manitowoc.md b/src/content/sponsors/manitowoc.md new file mode 100644 index 0000000..375da17 --- /dev/null +++ b/src/content/sponsors/manitowoc.md @@ -0,0 +1,7 @@ +--- +name: Manitowoc +level: Bronze +url: https://www.manitowoc.com/ +logo: ../../assets/sponsors/manitowoc.png +since: 2024 +--- diff --git a/src/content/sponsors/orrstown.md b/src/content/sponsors/orrstown.md new file mode 100644 index 0000000..e66a649 --- /dev/null +++ b/src/content/sponsors/orrstown.md @@ -0,0 +1,7 @@ +--- +name: Orrstown +level: Bronze +url: https://www.orrstown.com/ +logo: ../../assets/sponsors/orrstown.png +since: 2024 +--- diff --git a/src/content/sponsors/the-workshope.md b/src/content/sponsors/the-workshope.md new file mode 100644 index 0000000..2dae0ce --- /dev/null +++ b/src/content/sponsors/the-workshope.md @@ -0,0 +1,7 @@ +--- +name: The WorkShope +level: Gold +url: https://www.theworkshope.com/ +logo: ../../assets/sponsors/workshope.png +since: 2021 +--- diff --git a/src/content/sponsors/vfw.md b/src/content/sponsors/vfw.md new file mode 100644 index 0000000..4f70753 --- /dev/null +++ b/src/content/sponsors/vfw.md @@ -0,0 +1,9 @@ +--- +name: VFW +sub: Chambersburg VFW Post 1599 +level: Platinum +url: https://www.vfw1599.org/ +logo: ../../assets/sponsors/vfw.png +active: false +since: 2022 +--- diff --git a/src/content/sponsors/volvo.md b/src/content/sponsors/volvo.md new file mode 100644 index 0000000..c493d65 --- /dev/null +++ b/src/content/sponsors/volvo.md @@ -0,0 +1,7 @@ +--- +name: Volvo +level: Gold +url: https://www.volvogroup.com/ +logo: ../../assets/sponsors/volvo.png +since: 2024 +--- diff --git a/src/content/sponsors/wellspan.md b/src/content/sponsors/wellspan.md new file mode 100644 index 0000000..f168b15 --- /dev/null +++ b/src/content/sponsors/wellspan.md @@ -0,0 +1,8 @@ +--- +name: Wellspan +level: Platinum +url: https://www.wellspan.org/ +logo: ../../assets/sponsors/wellspan.png +active: false +since: 2022 +--- diff --git a/src/content/sponsors/yb-welding.md b/src/content/sponsors/yb-welding.md new file mode 100644 index 0000000..6a09c77 --- /dev/null +++ b/src/content/sponsors/yb-welding.md @@ -0,0 +1,7 @@ +--- +name: Y.B. Welding +level: Gold +url: https://www.ybwelding.com/ +logo: ../../assets/sponsors/yb-dark.png +since: 2018 +--- diff --git a/src/data/site.ts b/src/data/site.ts new file mode 100644 index 0000000..82860f6 --- /dev/null +++ b/src/data/site.ts @@ -0,0 +1,104 @@ +/** + * Every fact about the organization that is not page content, in one place. Replaces + * `legacy/data/config.ts` and the constants that were scattered through legacy pages. + * + * Rule of thumb: if it belongs in prose or would change per season, it belongs in + * `src/content/`; if it is an identifier, a URL, or an org fact, it belongs here. + */ + +export const site = { + /** Never abbreviated in headings, and `STEM` is always capitalized (DESIGN.md §1). */ + name: "South Central STEM Collective", + /** Prose only, and only where the full name also appears on the page. */ + shortName: "SC2", + url: "https://scstem.org", + + description: + "The South Central STEM Collective is a non-profit organization focused on building the future of STEM, right here in Franklin County, Pennsylvania.", + /** `%s` is the page title. The homepage uses the bare site name instead. */ + titleTemplate: "%s | South Central STEM Collective", + + email: "info@scstem.org", + + location: { + workspace: "20 South Main Street, Downtown Chambersburg", + locality: "Chambersburg", + region: "PA", + country: "US", + /** The service area, as used in copy and structured data. */ + areaServed: "Franklin County, Pennsylvania", + }, + + social: { + facebook: "https://go.scstem.tech/facebook", + linkedin: "https://go.scstem.tech/linkedin", + github: "https://go.scstem.tech/github", + }, + + /** External destinations, from `legacy/data/config.ts`. */ + urls: { + donate: "https://www.paypal.com/US/fundraiser/charity/4486755", + wishlist: "https://wiki.scstem.org/donations/wishlist", + sponsorPacket: "https://wiki.scstem.org/donations/packet", + getInvolvedForm: + "https://docs.google.com/forms/d/e/1FAIpQLScTjT3LHFAq1mOfKFztgMOpUT8hFWz81dYlaaDa4B8lG6yr2Q/viewform?embedded=true", + wiki: "https://wiki.scstem.org", + directions: "https://wiki.scstem.org/workspace/#directions", + }, + + /** Public Google Calendar IDs, from `legacy/src/app/calendar/[name]/page.tsx`. */ + calendars: { + frc: "Y19hYjljNWJlYTEwODgyYzAxYTAxOGNiZDUxYWIyMzcwYmY4NDk5NDZiZTRlMjUzNTAwZmZmMWQxMGZkY2M4NjFhQGdyb3VwLmNhbGVuZGFyLmdvb2dsZS5jb20", + sc2: "Y19wcDlkOXRrbGRrbThmdXZtcjMyZTBwZTgxc0Bncm91cC5jYWxlbmRhci5nb29nbGUuY29t", + }, + + analytics: { + /** GA4. Wired as a plain deferred gtag snippet in Phase 10 (D21). */ + ga4MeasurementId: "G-3TPD3DLYBR", + /** Cloudflare Web Analytics beacon token, added in Phase 10. */ + cloudflareBeaconToken: undefined, + }, +} as const; + +/** A program's facts. Flat, like the content schemas (D2). */ +interface Program { + ages?: string; + href: string; + name: string; + shortName: string; + teamName?: string; +} + +/** + * The programs the site is organized around; the key doubles as the theme name (D16) and is the + * `program` enum in `src/content.config.ts`. Typed as a total record over `ProgramKey`, so a + * program accepted by the schema but missing here is a compile error rather than an `undefined` + * that surfaces as a runtime throw on a page. + */ +export const PROGRAM_KEYS = ["sc2", "frc", "fll"] as const; + +/** @public Consumed by the app shell and program pages from Phase 05 onward. */ +export type ProgramKey = (typeof PROGRAM_KEYS)[number]; + +/** @public Consumed by the app shell and program pages from Phase 05 onward. */ +export const programs = { + /** The org-wide program: events and pages that are not FRC- or FLL-specific. */ + sc2: { + name: "South Central STEM Collective", + shortName: "SC2", + href: "/programs", + }, + frc: { + name: "FIRST Robotics Competition", + shortName: "FRC", + teamName: "Biohazard", + ages: "14–18", + href: "/programs/frc", + }, + fll: { + name: "FIRST LEGO League", + shortName: "FLL", + ages: "9–16", + href: "/programs/fll", + }, +} as const satisfies Record; diff --git a/src/lib/event-date.ts b/src/lib/event-date.ts new file mode 100644 index 0000000..0ef5d6e --- /dev/null +++ b/src/lib/event-date.ts @@ -0,0 +1,35 @@ +/** + * Formats an event's date range from `start`/`end` (DESIGN.md §8's data voice is applied at the + * call site; this returns plain text). + * + * Events used to carry a `displayDate` string alongside the timestamps, which meant every event + * stated its date twice and rescheduling one left stale prose behind. Timestamps are the only + * source now. + */ +const ZONE = "America/New_York"; + +const dayFormat = new Intl.DateTimeFormat("en-US", { + weekday: "long", + month: "long", + day: "numeric", + timeZone: ZONE, +}); + +const timeFormat = new Intl.DateTimeFormat("en-US", { + hour: "numeric", + minute: "2-digit", + timeZone: ZONE, +}); + +/** Drops `:00` so 1:00 PM reads as "1PM", matching how the site has always written times. */ +const compactTime = (date: Date): string => + timeFormat.format(date).replace(":00", "").replace(" ", ""); + +/** `Saturday, August 1 (1PM to 4PM)`, or `Saturday, January 10 (12PM)` with no end. */ +export const formatEventDate = (start: Date, end?: Date): string => { + const day = dayFormat.format(start); + const times = + end === undefined ? compactTime(start) : `${compactTime(start)} to ${compactTime(end)}`; + + return `${day} (${times})`; +}; diff --git a/tools/assets/optimize-sources.mjs b/tools/assets/optimize-sources.mjs new file mode 100644 index 0000000..cd1ba12 --- /dev/null +++ b/tools/assets/optimize-sources.mjs @@ -0,0 +1,111 @@ +/* oxlint-disable eslint/no-await-in-loop -- deliberately sequential: sharp holds a decoded + bitmap per in-flight image, and these sources run to 6000x4000, so encoding them in parallel + spikes memory for no wall-clock gain on a script that is run by hand. */ +/** + * Re-encodes oversized source images in `src/assets/` (plan/09 §2). + * + * These are camera masters — up to 6000px wide and losslessly encoded. `` with no explicit + * `widths` derives its largest variant from the source's own dimensions, so an un-downscaled + * master makes the build spend seconds per image producing a derivative that can come out *larger + * than the input*. Downscaling the source caps that and costs nothing visually: no layout on the + * site is wider than ~1600 CSS px, so 2560px still covers 2x on the widest hero. + * + * Run manually, from the repo root, and commit the result: + * + * node tools/assets/optimize-sources.mjs # report only + * node tools/assets/optimize-sources.mjs --write # re-encode in place + * + * Git history keeps the originals. + */ +import { readdir, stat, writeFile } from "node:fs/promises"; +import { extname, join } from "node:path"; + +import sharp from "sharp"; + +/** plan/09 §2: max dimension 2560px, quality-tuned WebP/JPEG. */ +const MAX_DIMENSION = 2560; +const QUALITY = 80; +/** Below this, re-encoding is not worth the churn in git. */ +const SIZE_THRESHOLD = 300 * 1024; +/** + * Re-encoding an already-optimized WebP always shaves a little off, so without a floor this + * script would never reach a fixpoint and repeat runs would slowly degrade every image. + */ +const MIN_GAIN = 0.05; + +const ROOT = "src/assets"; +const RASTER = new Set([".webp", ".jpg", ".jpeg", ".png"]); + +const walk = async (dir) => { + const entries = await readdir(dir, { withFileTypes: true }); + const nested = await Promise.all( + entries.map(async (entry) => { + const path = join(dir, entry.name); + if (entry.isDirectory()) { + return walk(path); + } + return RASTER.has(extname(entry.name).toLowerCase()) ? [path] : []; + }), + ); + return nested.flat(); +}; + +const write = process.argv.includes("--write"); +let touched = 0; +let savedBytes = 0; + +for (const path of (await walk(ROOT)).toSorted()) { + const before = (await stat(path)).size; + const image = sharp(path); + const { width, height, format } = await image.metadata(); + if (width === undefined || height === undefined) { + continue; + } + + const tooLarge = Math.max(width, height) > MAX_DIMENSION; + if (!tooLarge && before < SIZE_THRESHOLD) { + continue; + } + + const resized = tooLarge + ? sharp(path) + .rotate() + .resize({ + width: width >= height ? MAX_DIMENSION : undefined, + height: height > width ? MAX_DIMENSION : undefined, + withoutEnlargement: true, + }) + : sharp(path).rotate(); + + const encoded = + format === "png" + ? await resized.png({ quality: QUALITY, compressionLevel: 9 }).toBuffer() + : await resized.webp({ quality: QUALITY }).toBuffer(); + + if (encoded.length > before * (1 - MIN_GAIN)) { + console.log(`skip ${path} — no meaningful gain (${fmt(before)} -> ${fmt(encoded.length)})`); + continue; + } + + const meta = await sharp(encoded).metadata(); + console.log( + `${write ? "write" : "would"} ${path} ${String(width)}x${String(height)} ${fmt(before)}` + + ` -> ${String(meta.width ?? 0)}x${String(meta.height ?? 0)} ${fmt(encoded.length)}`, + ); + if (write) { + await writeFile(path, encoded); + } + touched += 1; + savedBytes += before - encoded.length; +} + +function fmt(bytes) { + return `${(bytes / 1024 / 1024).toFixed(2)} MB`; +} + +console.log( + `\n${String(touched)} file(s) ${write ? "re-encoded" : "would change"}, ${fmt(savedBytes)} saved`, +); +if (!write && touched > 0) { + console.log("re-run with --write to apply"); +} diff --git a/tools/checks/content-references.mjs b/tools/checks/content-references.mjs new file mode 100644 index 0000000..a6d6c19 --- /dev/null +++ b/tools/checks/content-references.mjs @@ -0,0 +1,107 @@ +/** + * Verifies every `reference()` in a content collection resolves. + * + * Astro logs a dangling reference and still exits 0, so a mistyped `faq` id silently drops an + * answer from the page that lists it. "A reference resolves" is an invariant of the collection + * that declares the reference, not of each page that reads it — enforcing it here means no + * consumer has to remember to throw. + * + * Reads the markdown directly rather than going through `astro:content`, so it runs as a plain + * `pnpm check` step with no build. + */ +import { readdir, readFile } from "node:fs/promises"; +import { basename, extname, join } from "node:path"; + +/** `collection: { field: referencedCollection }` — mirrors `src/content.config.ts`. */ +const REFERENCE_FIELDS = { + events: { faq: "faq" }, +}; + +const COLLECTION_DIRS = { + events: "src/content/events", + faq: "src/content/faq", +}; + +const ids = async (collection) => { + const dir = COLLECTION_DIRS[collection]; + const entries = await readdir(dir, { withFileTypes: true }); + return new Set( + entries + .filter((entry) => entry.isFile() && extname(entry.name) === ".md") + .map((entry) => basename(entry.name, ".md")), + ); +}; + +/** The frontmatter block, as raw text. */ +const frontmatter = (source) => { + const match = /^---\r?\n([\s\S]*?)\r?\n---/.exec(source); + return match?.[1] ?? ""; +}; + +/** Values of a YAML block-sequence field: `faq:` followed by ` - id` lines. */ +const listField = (block, field) => { + const lines = block.split(/\r?\n/); + const start = lines.findIndex((line) => line.trimEnd() === `${field}:`); + if (start === -1) { + return []; + } + + const values = []; + for (const line of lines.slice(start + 1)) { + const item = /^\s+-\s+(.+?)\s*$/.exec(line); + if (item?.[1] === undefined) { + break; + } + values.push(item[1].replaceAll(/^["']|["']$/g, "")); + } + return values; +}; + +/** Every collection's ids, and every entry's frontmatter, read once up front. */ +const knownIds = new Map( + await Promise.all(Object.keys(COLLECTION_DIRS).map(async (name) => [name, await ids(name)])), +); + +const entriesByCollection = new Map( + await Promise.all( + Object.entries(REFERENCE_FIELDS).map(async ([collection]) => { + const dir = COLLECTION_DIRS[collection]; + const files = (await readdir(dir)).filter((name) => extname(name) === ".md"); + const entries = await Promise.all( + files.map(async (file) => ({ + id: basename(file, ".md"), + block: frontmatter(await readFile(join(dir, file), "utf8")), + })), + ); + return [collection, entries]; + }), + ), +); + +const failures = []; + +for (const [collection, fields] of Object.entries(REFERENCE_FIELDS)) { + for (const [field, target] of Object.entries(fields)) { + const known = knownIds.get(target) ?? new Set(); + for (const entry of entriesByCollection.get(collection) ?? []) { + for (const id of listField(entry.block, field)) { + if (!known.has(id)) { + failures.push( + `${collection}/${entry.id}: ${field} references "${id}", ` + + `which is not an entry in the ${target} collection`, + ); + } + } + } + } +} + +if (failures.length > 0) { + console.error("tools/checks/content-references: dangling content references"); + for (const failure of failures) { + console.error(` ${failure}`); + } + process.exit(1); +} + +console.log("all content references resolve");