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"; +--- + +
+

+ + + +

+ +

+ +

+ + +
+ + 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 && ( + + ) + } + + + + {hero.alt} + + {programs[data.program].name} + {data.title} + + {data.subtitle} + + + { + data.registrationUrl !== undefined && ( + + ) + } + + +
+

When and where

+ +
+ + +
+
+ +
+
+ +
+
+ + { + media.length > 0 && ( +
+ Teasers and hints + + +
+ ) + } + + { + 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); +} +--- + +