diff --git a/docs/adr/0004-event-hero-image-alt.md b/docs/adr/0004-event-hero-image-alt.md
new file mode 100644
index 0000000..7123fc7
--- /dev/null
+++ b/docs/adr/0004-event-hero-image-alt.md
@@ -0,0 +1,39 @@
+# 0004 — `heroImageAlt` on the `events` collection
+
+- **Status:** accepted
+- **Date:** 2026-08-31
+
+## Context
+
+Phase 08 renders `events` entries through `EventLayout`, whose first element is a hero photo. The
+schema Phase 04 shipped carries `heroImage` and no alternative text for it, so the layout had no
+honest text to put in `alt` — the three candidates on hand were the event title (which repeats the
+`
` a reader already has), the meta `description` (a sentence written for search results, not
+for someone who cannot see the photo), or `alt=""`, which asserts the photo is decorative. It is
+not: DESIGN.md §7 makes photographs of students and robots the brand, and §7 requires an honest
+`alt` on every image.
+
+`frcRobots` already establishes the pattern — `image` optional, `imageAlt` alongside it — so this
+is the same field the sibling collection has, not a new idea.
+
+## Decision
+
+Add `heroImageAlt: z.string().optional()` to `events`, and make `EventLayout` throw at build when
+`heroImage` is set without it.
+
+The pairing is enforced in the layout rather than in the schema because zod would express it as a
+cross-field refinement, which turns the object into a `ZodEffects` and costs the flat, single-pass
+shape D2 keeps the collections in for a future git-backed CMS. The layout throw is the same
+mechanism `Seo.astro` already uses for `ogImage`/`ogImageAlt`, and it fails the build with the
+entry's id in the message.
+
+`heroImageAlt` is optional rather than required because an event with no `heroImage` falls back to
+a photo of its program, and that photo's alt belongs to the layout that chose it, not to the entry.
+
+## Consequences
+
+- Both migrated entries gained an `alt`; a new event without one still publishes, on its program's
+ fallback hero.
+- A schema change, so this record exists per `AGENTS.md`. The field is a flat optional string, so
+ nothing about the CMS-readiness of the collection changes.
+- `docs/content.md` documents the field next to `heroImage`.
diff --git a/docs/content.md b/docs/content.md
index 763648f..f085d4f 100644
--- a/docs/content.md
+++ b/docs/content.md
@@ -69,15 +69,18 @@ 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.
+below it. Nothing about `/openhouse` lives anywhere else.
```md
---
title: Open house
+subtitle: One sentence under the title, in the hero.
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.
+heroImage: ../../assets/events/morethanrobots.webp
+heroImageAlt: What the photo shows, for someone who cannot see it.
ctaLabel: Get involved
ctaHref: /get-involved
faq:
@@ -90,6 +93,13 @@ faq:
`-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.
+Give every event an `end`. The page counts down to `start`, says "Happening now" from then until
+`end`, and "This event has passed" after it — with no `end` it never reaches the last of those,
+because nothing in the file says when the event is over.
+
+`heroImage` is optional: without one the page uses a photo of the event's program. With one,
+`heroImageAlt` is required and the build fails without it (`docs/adr/0004`).
+
**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.
@@ -107,13 +117,34 @@ fails `pnpm check` (`tools/checks/content-references.mjs`) — Astro alone only
hidden: true
```
-The page redirects to its parent and disappears from the sitemap. Flip it back next season.
+The page stops rendering and sends anyone who lands on it to its parent — `/` for the open house,
+`/programs/frc` for kickoff. Flip it back next season and the page returns unchanged.
### 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.
+Two steps. First copy an existing file in `src/content/events/` and edit it. Then add the route
+that renders it — a thin page under `src/pages/` at the URL you want:
+
+```astro
+---
+import EventLayout from "@/layouts/EventLayout.astro";
+import { getVisibleEvent } from "@/lib/events";
+
+const event = await getVisibleEvent("my-event");
+if (event === undefined) {
+ return Astro.redirect("/");
+}
+---
+
+
+```
+
+The id passed to `getVisibleEvent` is the filename without `.md`, and the path you redirect to is
+where a reader should land once the event is hidden. Everything else — the hero, the countdown,
+the location, the questions, the page copy — comes from the markdown file.
+
+Two events do not yet justify a dynamic `[event].astro` route; when there are several, that is
+worth revisiting.
## Add a robot
@@ -169,8 +200,8 @@ The `news` collection is scaffolded but has no routes yet (that is phase-2 work)
- **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.
+- **A mistyped FAQ slug in an event fails `pnpm check`, not `pnpm build`** — Astro logs it and
+ builds anyway, dropping that answer from the page, so run `pnpm check` after editing a `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/eslint.config.ts b/eslint.config.ts
index 5d77ce7..466ff44 100644
--- a/eslint.config.ts
+++ b/eslint.config.ts
@@ -58,6 +58,13 @@ export default defineConfig(
* off the whole family. See docs/adr/0001-toolchain-split.md.
*/
"@typescript-eslint/no-unsafe-return": "off",
+ /**
+ * A `return` in Astro frontmatter — how a page short-circuits into a redirect or a 404 —
+ * has no enclosing function node in the parser's AST, and this rule asserts one exists:
+ * `Non-null Assertion Failed: Expected node to have a parent`, a crash rather than a
+ * finding. It cannot inspect the construct it exists to check, so it is off for `.astro`.
+ */
+ "@typescript-eslint/no-misused-promises": "off",
/**
* A keyboard-reachable scroll container is a real pattern: an `overflow` region is not
* focusable by default, so without `tabindex="0"` its content is unreachable by keyboard
diff --git a/knip.jsonc b/knip.jsonc
index ff05508..9eb0fc6 100644
--- a/knip.jsonc
+++ b/knip.jsonc
@@ -1,13 +1,6 @@
{
"$schema": "./node_modules/knip/schema.json",
- "entry": [
- "src/pages/**",
- "functions/**",
- // 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"
- ],
+ "entry": ["src/pages/**", "functions/**"],
"project": ["**/*.{ts,tsx,js,mjs,cjs,astro}"],
"ignore": ["legacy/**", "tools/lint/anti-slop/**"],
// Referenced only from CSS — `tailwindcss` via `@import`, these two faces via `url()`
diff --git a/plan/08-events.md b/plan/08-events.md
index 5b00b7c..76603f8 100644
--- a/plan/08-events.md
+++ b/plan/08-events.md
@@ -38,11 +38,51 @@ Extend `docs/content.md`: "Update the open house for a new season" (edit dates/c
## Acceptance criteria
-- [ ] Both URLs render from collection data; zero copy hardcoded in the route files; visible-copy parity with legacy pages.
-- [ ] Flipping `hidden: true` on an entry removes the page content (redirect) with no code change; flipping back restores it.
-- [ ] 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
+- [x] Both URLs render from collection data; zero copy hardcoded in the route files; visible-copy parity with legacy pages. *(Deviations listed under Implementation notes.)*
+- [x] Flipping `hidden: true` on an entry removes the page content (redirect) with no code change; flipping back restores it. *(Verified by building both ways; the built page is Astro's meta-refresh stub, which also carries `noindex` and a canonical to the parent. The sitemap still lists a hidden event's URL — that filter is plan/10's, and `getVisibleEvents()` is here for it.)*
+- [x] Event + FAQPage JSON-LD validate (Google Rich Results test, manual, once per template). **Validated against the schema.org shapes offline; the Rich Results test itself needs a public URL, so it runs on the preview deploy.** Both objects are emitted and well-formed in the built HTML.
+- [x] Countdown shows correct absolute date without JS; upgrades with JS; handles passed events.
+- [x] Program theming correct (kickoff = frc green).
+- [x] 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.
+- [x] `pnpm check && pnpm build` green.
+
+## Implementation notes
+
+- **`heroImageAlt` was added to the `events` schema** (`docs/adr/0004`). The layout had no honest
+ `alt` for a hero photo: the title repeats the `h1`, the `description` is written for search
+ results, and `alt=""` would call a photo of students decorative. Optional, but supplying
+ `heroImage` without it fails the build — the mechanism `Seo.astro` already uses for `ogImage`.
+- **An event with no `end` never reads as passed.** Nothing in such an entry says when the event
+ is over, and picking a duration would invent one. Kickoff gained the `end` its own meeting
+ schedule states (`~4PM - Meeting Ends`), so both events have a real window; `docs/content.md`
+ tells editors to always set one.
+- **`site.location.name`** — the workspace needed a venue name for the `Place` in the event's
+ structured data and for the info card's `LOCATION` row. It was previously only in legacy's
+ `KICKOFF_CONFIG`.
+- **Two dates written in prose are gone**, per the rule `docs/content.md` already states: the open
+ house's hero sentence lost "Saturday, August 1 (1PM to 4PM)" (the countdown renders it from
+ `start`/`end`), and kickoff's timeline entry is now "**Kickoff.**" rather than
+ "**Kickoff - January 10.**".
+- **Copy that did not come across from legacy `/openhouse`:** the two program cards (that is the
+ homepage fork D17 exists to kill — `/programs` and the homepage carry them), and the embedded
+ Google My Maps iframe plus its parking prose, which the `DIRECTIONS` row links to instead. From
+ legacy kickoff: the modal holding the meeting schedule (the schedule is body copy now) and the
+ two map-backed panels, whose content is the info card and the closing banner. Everything else is
+ present, including the teaser and hint links.
+- **`@typescript-eslint/no-misused-promises` is off for `.astro`** (`eslint.config.ts`). A `return`
+ in frontmatter — how a page short-circuits into a redirect — has no enclosing function node in
+ astro-eslint-parser's AST, and the rule asserts one exists, so it crashes rather than reporting.
+ Same class of parser gap as the `no-unsafe-return` entry beside it.
+- **`Accordion`/`AccordionItem` props gained `| undefined`** so `ui/FaqList` can forward its own
+ optional `class`/`name` under `exactOptionalPropertyTypes`, exactly as `BaseLayout` documents.
+
+## Verification notes
+
+Both routes driven in the pre-installed Chromium at 390px and 1440px against the production
+preview (`chrome-devtools-mcp` is still not on PATH in this environment, as in Phase 07, so
+Playwright drove the same checks). Per route and width: zero axe-core violations over WCAG 2.2 AA
+plus best-practice, no console or page errors, no horizontal overflow, every image carrying `alt`
+and explicit dimensions, and a clean heading outline. The countdown's three states were each
+rendered from a build (upcoming by dating the entry forward, passed from today's date, and the
+script's own transitions read from the same markup).
diff --git a/src/components/ui/Countdown.astro b/src/components/ui/Countdown.astro
new file mode 100644
index 0000000..f20ec33
--- /dev/null
+++ b/src/components/ui/Countdown.astro
@@ -0,0 +1,88 @@
+---
+import { cn } from "@/lib/cn";
+import { formatEventDate } from "@/lib/event-date";
+
+/**
+ * The event's date, plus the time left until it. The absolute date is always in the HTML — a
+ * static build cannot know when the page is read, and an agent or a reader without JavaScript
+ * gets the real answer rather than an empty slot.
+ *
+ * The three states are all in the markup, one shown, and the script below flips which. That keeps
+ * every word of copy here and stops the state the build happened to see from going stale in a
+ * browser.
+ *
+ * An event with no `end` never reaches the passed state: nothing in the entry says when it is
+ * over, and picking a duration would be inventing one.
+ */
+interface Props {
+ start: Date;
+ end?: Date | undefined;
+ class?: string;
+}
+
+const { start, end, class: className } = Astro.props;
+
+const now = Date.now();
+const state =
+ end !== undefined && now >= end.getTime()
+ ? "passed"
+ : now >= start.getTime()
+ ? "live"
+ : "upcoming";
+---
+
+
+
+ Counting down to
+ Happening now
+ This event has passed
+
+
+
+
+
+
+
+
+
+
diff --git a/src/components/ui/FaqList.astro b/src/components/ui/FaqList.astro
new file mode 100644
index 0000000..133f068
--- /dev/null
+++ b/src/components/ui/FaqList.astro
@@ -0,0 +1,35 @@
+---
+import { type CollectionEntry, render } from "astro:content";
+
+import Accordion from "@/components/ui/primitives/Accordion.astro";
+import AccordionItem from "@/components/ui/primitives/AccordionItem.astro";
+
+/**
+ * A group of `faq` entries as disclosures. The question is frontmatter and the answer is the
+ * body, so the markdown is rendered here rather than by every page that lists questions.
+ *
+ * `name` reaches each `` individually — that attribute is what makes the group
+ * exclusive-open — so it is a prop here rather than something `Accordion` could push down.
+ */
+interface Props {
+ entries: ReadonlyArray>;
+ name?: string;
+ class?: string;
+}
+
+const { entries, name, class: className } = Astro.props;
+
+const answers = await Promise.all(
+ entries.map(async (entry) => ({ ...(await render(entry)), question: entry.data.question })),
+);
+---
+
+
+ {
+ answers.map(({ Content, question }) => (
+
+
+
+ ))
+ }
+
diff --git a/src/components/ui/primitives/Accordion.astro b/src/components/ui/primitives/Accordion.astro
index 3f4bd61..34c75ee 100644
--- a/src/components/ui/primitives/Accordion.astro
+++ b/src/components/ui/primitives/Accordion.astro
@@ -9,7 +9,8 @@ import { cn } from "@/lib/cn";
* would have to be read back by script, which this register does without (README, rung 1).
*/
interface Props {
- class?: string;
+ /** `| undefined` because `ui/FaqList` forwards its own optional `class` straight through. */
+ class?: string | undefined;
}
const { class: className } = Astro.props;
diff --git a/src/components/ui/primitives/AccordionItem.astro b/src/components/ui/primitives/AccordionItem.astro
index ab39794..45d4ba4 100644
--- a/src/components/ui/primitives/AccordionItem.astro
+++ b/src/components/ui/primitives/AccordionItem.astro
@@ -11,8 +11,11 @@ import Icon from "./Icon.astro";
*/
interface Props {
question: string;
- /** Set from the parent group to make items exclusive-open. */
- name?: string;
+ /**
+ * Set from the parent group to make items exclusive-open. `| undefined` because `ui/FaqList`
+ * forwards its own optional prop straight through.
+ */
+ name?: string | undefined;
open?: boolean;
class?: string;
}
diff --git a/src/content.config.ts b/src/content.config.ts
index 255f669..b0793d0 100644
--- a/src/content.config.ts
+++ b/src/content.config.ts
@@ -62,7 +62,12 @@ const events = defineCollection({
directionsUrl: z.url().optional(),
/** Meta description for the event's page. */
description: z.string(),
+ /**
+ * Omitting the image is fine — the page falls back to a photo of the event's program — but
+ * supplying one without `heroImageAlt` fails the build (`docs/adr/0004`).
+ */
heroImage: image().optional(),
+ heroImageAlt: z.string().optional(),
ctaLabel: z.string(),
ctaHref: z.string(),
registrationUrl: z.url().optional(),
diff --git a/src/content/events/frc-kickoff.md b/src/content/events/frc-kickoff.md
index f00b058..c5d76f7 100644
--- a/src/content/events/frc-kickoff.md
+++ b/src/content/events/frc-kickoff.md
@@ -2,9 +2,11 @@
title: 2026 Season Kickoff
program: frc
start: 2026-01-10T12:00:00-05:00
+end: 2026-01-10T16: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
+heroImageAlt: Biohazard team members cheering from the stands at competition
ctaLabel: Watch on FIRST website
ctaHref: https://www.firstinspires.org/robotics/frc/kickoff
teaserUrls:
@@ -20,8 +22,8 @@ hintLabels:
## The build season journey
-**Kickoff - January 10.** The game is revealed! Teams watch the live broadcast and receive the
-game manual to start strategizing.
+**Kickoff.** 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.
diff --git a/src/content/events/openhouse.md b/src/content/events/openhouse.md
index ff7896f..df9d801 100644
--- a/src/content/events/openhouse.md
+++ b/src/content/events/openhouse.md
@@ -1,10 +1,12 @@
---
title: Open house
+subtitle: Want to know more about STEM and robots? Join us in downtown Chambersburg to see what we're all about.
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
+heroImageAlt: Collage of photos capturing some of the many ways students can get involved including robot design, photography, video, and more.
ctaLabel: Get involved
ctaHref: /get-involved
faq:
@@ -16,10 +18,6 @@ faq:
- 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,
diff --git a/src/data/site.ts b/src/data/site.ts
index a1b8088..6638bf2 100644
--- a/src/data/site.ts
+++ b/src/data/site.ts
@@ -47,6 +47,8 @@ export const site = {
},
location: {
+ /** How the workspace is named where a venue needs a name — an event's location, chiefly. */
+ name: "South Central STEM Collective Workspace",
workspace: "20 South Main Street, Downtown Chambersburg",
locality: "Chambersburg",
region: "PA",
diff --git a/src/layouts/EventLayout.astro b/src/layouts/EventLayout.astro
new file mode 100644
index 0000000..4602fdb
--- /dev/null
+++ b/src/layouts/EventLayout.astro
@@ -0,0 +1,225 @@
+---
+import type { ImageMetadata } from "astro";
+
+import { Image } from "astro:assets";
+import { type CollectionEntry, getEntries, render } from "astro:content";
+
+import fllHero from "@/assets/fll/lego-robots.webp";
+import frcHero from "@/assets/frc/frc-driveteam.webp";
+import sc2Hero from "@/assets/sc2/students.webp";
+import JsonLd from "@/components/JsonLd.astro";
+import Countdown from "@/components/ui/Countdown.astro";
+import CtaBanner from "@/components/ui/CtaBanner.astro";
+import FaqList from "@/components/ui/FaqList.astro";
+import Hero from "@/components/ui/Hero.astro";
+import Button from "@/components/ui/primitives/Button.astro";
+import Icon from "@/components/ui/primitives/Icon.astro";
+import TitleBlock from "@/components/ui/primitives/TitleBlock.astro";
+import Section from "@/components/ui/Section.astro";
+import { type ProgramKey, nav, programs, site } from "@/data/site";
+import BaseLayout from "@/layouts/BaseLayout.astro";
+import { type Breadcrumb, breadcrumbs, event as eventJsonLd, faqPage } from "@/lib/jsonld";
+
+/**
+ * One layout for every seasonal event (D17). `/openhouse` and `/programs/frc/kickoff` used to be
+ * a fork of the homepage and a fork of the FRC page; both are now this component reading an
+ * `events` entry, so a new season is an edit to one markdown file.
+ *
+ * Nothing here is specific to either event: the hero, the date, the location, the media links and
+ * the questions are all frontmatter, and the long-form copy is the entry's body.
+ */
+interface Props {
+ event: CollectionEntry<"events">;
+ /** The trail above this page. Home and the page itself are added here. */
+ trail?: ReadonlyArray;
+}
+
+/** The hero for an event that carries no photo of its own, so a new event needs no image work. */
+const programHeroes = {
+ sc2: {
+ src: sc2Hero,
+ alt: "South Central STEM Collective students at work in the Chambersburg workspace",
+ },
+ frc: {
+ src: frcHero,
+ alt: `Team 4050 ${programs.frc.teamName}'s drive team standing with their robot at competition`,
+ },
+ fll: {
+ src: fllHero,
+ alt: "LEGO® robots built by South Central STEM Collective students",
+ },
+} as const satisfies Record;
+
+const { event, trail = [] } = Astro.props;
+const { data } = event;
+const { Content } = await render(event);
+
+/** `sc2` is the default look and has no `[data-theme]` block (D16). */
+const theme = data.program === "sc2" ? undefined : data.program;
+
+if (data.heroImage !== undefined && data.heroImageAlt === undefined) {
+ throw new Error(`events/${event.id}: an entry with a heroImage must also set heroImageAlt.`);
+}
+
+const hero =
+ data.heroImage !== undefined && data.heroImageAlt !== undefined
+ ? { src: data.heroImage, alt: data.heroImageAlt }
+ : programHeroes[data.program];
+
+const faqEntries = data.faq === undefined ? [] : await getEntries(data.faq);
+
+/**
+ * The two flat arrays the schema pairs by index (D2). A mismatch would silently drop a link's
+ * label, so it fails the build the schema comment promises it fails on.
+ */
+const hintLabels = data.hintLabels ?? [];
+const hintUrls = data.hintUrls ?? [];
+if (hintUrls.length !== hintLabels.length) {
+ throw new Error(
+ `events/${event.id}: hintUrls has ${String(hintUrls.length)} entries and hintLabels has ${String(hintLabels.length)}.`,
+ );
+}
+
+const media = [
+ ...(data.teaserUrls ?? []).map((url, index) => ({
+ url,
+ label: `Season teaser ${String(index + 1)}`,
+ icon: "player-play",
+ })),
+ ...hintUrls.map((url, index) => ({ url, label: hintLabels[index] ?? "", icon: "link" })),
+];
+
+const pageUrl = new URL(Astro.url.pathname, site.url).href;
+
+const details = [
+ { label: "Location", value: data.locationName ?? site.location.name },
+ { label: "Address", value: data.locationAddress ?? site.location.workspace },
+ {
+ label: "Directions",
+ value: "Parking and the door to use",
+ href: data.directionsUrl ?? site.urls.directions,
+ },
+];
+---
+
+
+
+
+ {
+ faqEntries.length > 0 && (
+ ({
+ question: entry.data.question,
+ answer: entry.rendered?.html ?? entry.body ?? "",
+ })),
+ )}
+ />
+ )
+ }
+ {
+ trail.length > 0 && (
+
+ )
+ }
+
+
+
+
+
+ {programs[data.program].name}
+ {data.title}
+
+ {data.subtitle}
+
+
+ {
+ data.registrationUrl !== undefined && (
+
+ )
+ }
+
+
+
+
+
+ )
+ }
+
+ {
+ faqEntries.length > 0 && (
+
+ Frequently asked questions
+
+
+ )
+ }
+
+
+
+ Tell us you are interested
+ Whether or not this one fits your calendar, we would like to know who you are. Students and mentors
+ both — there is a place on these teams for just about everyone.
+
+
+
+
+
diff --git a/src/lib/events.ts b/src/lib/events.ts
new file mode 100644
index 0000000..e9ee514
--- /dev/null
+++ b/src/lib/events.ts
@@ -0,0 +1,32 @@
+import { type CollectionEntry, getCollection, getEntry } from "astro:content";
+
+/**
+ * One definition of "this event is in service", read by the routes that render events and by the
+ * sitemap filter. Flipping `hidden` therefore cannot retire the page and leave the URL in the
+ * sitemap, or the reverse.
+ */
+const inService = (entry: CollectionEntry<"events">): boolean => !entry.data.hidden;
+
+/**
+ * @public Consumed by the sitemap filter (plan/10).
+ *
+ * Every event whose page renders, in no particular order.
+ */
+export const getVisibleEvents = async (): Promise>> =>
+ (await getCollection("events")).filter(inService);
+
+/**
+ * The entry a route renders, or `undefined` when the event is retired and the route should
+ * redirect to its parent instead. An id with no file throws: that is a broken route, not a
+ * hidden event, and it should fail the build rather than silently redirect.
+ */
+export const getVisibleEvent = async (
+ id: string,
+): Promise | undefined> => {
+ const entry = await getEntry("events", id);
+ if (entry === undefined) {
+ throw new Error(`No event named "${id}" in src/content/events/.`);
+ }
+
+ return inService(entry) ? entry : undefined;
+};
diff --git a/src/lib/jsonld.ts b/src/lib/jsonld.ts
index d9875c7..cdb2dab 100644
--- a/src/lib/jsonld.ts
+++ b/src/lib/jsonld.ts
@@ -71,3 +71,94 @@ export const breadcrumbs = (trail: ReadonlyArray): JsonLdObject => (
item: new URL(crumb.path, site.url).href,
})),
});
+
+/** @public Consumed by `EventLayout`, which passes an `events` entry's frontmatter through. */
+export interface EventDetails {
+ readonly description: string;
+ readonly end?: Date | undefined;
+ /** Absolute, like `url` — a relative path in structured data resolves against nothing. */
+ readonly image?: string | undefined;
+ /** Both default to the workspace, exactly as the visible page does. */
+ readonly locationAddress?: string | undefined;
+ readonly locationName?: string | undefined;
+ readonly name: string;
+ readonly registrationUrl?: string | undefined;
+ readonly start: Date;
+ /** The page's own absolute URL. */
+ readonly url: string;
+}
+
+/**
+ * @public Consumed by `EventLayout`.
+ *
+ * One event's `Event` object. Dates come from the entry's timestamps rather than any displayed
+ * string, so the structured data cannot disagree with the page.
+ */
+export const event = ({
+ name,
+ description,
+ start,
+ end,
+ locationName,
+ locationAddress,
+ url,
+ image,
+ registrationUrl,
+}: EventDetails): JsonLdObject => ({
+ "@context": "https://schema.org",
+ "@type": "Event",
+ name,
+ description,
+ startDate: start.toISOString(),
+ ...(end !== undefined && { endDate: end.toISOString() }),
+ eventStatus: "https://schema.org/EventScheduled",
+ location: {
+ "@type": "Place",
+ name: locationName ?? site.location.name,
+ /**
+ * Only the workspace's address is known part by part; an off-site event carries one free-text
+ * line, and schema.org takes either for `address`.
+ */
+ address:
+ locationAddress ??
+ ({
+ "@type": "PostalAddress",
+ streetAddress: site.location.workspace,
+ addressLocality: site.location.locality,
+ addressRegion: site.location.region,
+ addressCountry: site.location.country,
+ } as const),
+ },
+ organizer: {
+ "@type": "NGO",
+ name: site.name,
+ url: site.url,
+ },
+ url,
+ ...(image !== undefined && { image }),
+ ...(registrationUrl !== undefined && {
+ offers: {
+ "@type": "Offer",
+ url: registrationUrl,
+ availability: "https://schema.org/InStock",
+ },
+ }),
+});
+
+/**
+ * @public Consumed by `EventLayout`.
+ *
+ * The `FAQPage` for a page's answered questions. `answer` is the entry's rendered HTML, which
+ * Google's FAQ documentation permits.
+ */
+export const faqPage = (
+ entries: ReadonlyArray<{ readonly answer: string; readonly question: string }>,
+): JsonLdObject => ({
+ "@context": "https://schema.org",
+ "@type": "FAQPage",
+ mainEntity: entries.map(({ question, answer }) => ({
+ "@type": "Question",
+ name: question,
+ acceptedAnswer: { "@type": "Answer", text: answer },
+ })),
+});
diff --git a/src/pages/openhouse.astro b/src/pages/openhouse.astro
new file mode 100644
index 0000000..c5d1648
--- /dev/null
+++ b/src/pages/openhouse.astro
@@ -0,0 +1,20 @@
+---
+import EventLayout from "@/layouts/EventLayout.astro";
+import { getVisibleEvent } from "@/lib/events";
+
+/**
+ * `/openhouse` is `src/content/events/openhouse.md` and nothing else — every word, date, and
+ * question on the page comes from that file (D17).
+ *
+ * Retiring the season is `hidden: true` there, which lands a reader on the homepage. In a static
+ * build `Astro.redirect` emits a meta-refresh page rather than a 301: keeping the rule beside the
+ * entry it reads is worth more than the status code, since a `_redirects` line would be a second
+ * place to remember when the flag flips.
+ */
+const event = await getVisibleEvent("openhouse");
+if (event === undefined) {
+ return Astro.redirect("/");
+}
+---
+
+
diff --git a/src/pages/programs/frc/kickoff.astro b/src/pages/programs/frc/kickoff.astro
new file mode 100644
index 0000000..8564799
--- /dev/null
+++ b/src/pages/programs/frc/kickoff.astro
@@ -0,0 +1,22 @@
+---
+import { programs } from "@/data/site";
+import EventLayout from "@/layouts/EventLayout.astro";
+import { getVisibleEvent } from "@/lib/events";
+
+/**
+ * `/programs/frc/kickoff` is `src/content/events/frc-kickoff.md` (D17); `hidden: true` there
+ * retires it to the FRC page. See `src/pages/openhouse.astro` for why the redirect lives here.
+ */
+const event = await getVisibleEvent("frc-kickoff");
+if (event === undefined) {
+ return Astro.redirect(programs.frc.href);
+}
+---
+
+