diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e1cfde1..cef64b0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -46,13 +46,20 @@ jobs: - name: Build run: pnpm build + # Needs dist/, so it cannot live in `pnpm check`: uniqueness of titles and descriptions, + # and whether an og:image resolves, are only answerable across the whole built site. + - name: Verify page metadata + run: pnpm run check:meta + # --root-dir is required for the root-relative hrefs Astro emits (/_astro/...); # without it lychee cannot resolve them in local files and errors on every page. + # llms.txt is in scope because its links are hand-written paths in src/data/events.ts, + # which nothing else would catch drifting from the routes they name. - name: Link check uses: lycheeverse/lychee-action@v2 with: args: >- --offline --include-fragments --no-progress --root-dir ${{ github.workspace }}/dist - 'dist/**/*.html' + 'dist/**/*.html' 'dist/llms.txt' fail: true diff --git a/astro.config.ts b/astro.config.ts index f075272..e0ce731 100644 --- a/astro.config.ts +++ b/astro.config.ts @@ -1,3 +1,5 @@ +import { readFileSync } from "node:fs"; + import sitemap from "@astrojs/sitemap"; import tailwindcss from "@tailwindcss/vite"; import { defineConfig, envField } from "astro/config"; @@ -6,6 +8,25 @@ import { defineConfig, envField } from "astro/config"; // same origin everything else derives canonical and OG URLs from. import { site } from "./src/data/site"; +const outDir = "./dist"; + +/** + * A URL that is both in the sitemap and `noindex` is a "Submitted URL marked 'noindex'" error in + * Search Console, so the two have to agree — and the page itself is the only honest source of + * which it is. `/styleguide` sets `noindex` through the `Seo` prop; an event retired with + * `hidden: true` (D17) becomes a redirect page Astro emits with the same tag. Reading the emitted + * HTML covers both, and anything else that grows a `noindex`, without a second list to maintain. + * + * Safe to read here: `@astrojs/sitemap` filters in `astro:build:done`, after every page is on + * disk. `astro:content` is *not* reachable from the config, which is why the events collection + * cannot be consulted directly. + */ +const isIndexable = (page: string): boolean => { + const { pathname } = new URL(page); + const html = readFileSync(`${outDir}${pathname}index.html`, "utf8"); + return !/]*\bname="robots")(?=[^>]*noindex)[^>]*>/u.test(html); +}; + export default defineConfig({ /** * Typed environment, so a page reads a variable rather than an untyped `import.meta.env` @@ -25,12 +46,21 @@ export default defineConfig({ context: "client", default: "1x00000000000000000000AA", }), + /** + * Cloudflare Web Analytics' beacon token (D21). Public by design — it ships in the page — + * and empty by default, which is how a preview or a fresh clone runs with no beacon at all. + * The production value is set in the Pages dashboard, beside the Turnstile keys; until it + * is, GA4 is the only analytics the site has (`docs/analytics.md`). + */ + PUBLIC_CF_BEACON_TOKEN: envField.string({ + access: "public", + context: "client", + default: "", + }), }, }, - // /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", + integrations: [sitemap({ filter: isIndexable })], + outDir, output: "static", site: site.url, vite: { diff --git a/docs/adr/0009-open-crawling-posture.md b/docs/adr/0009-open-crawling-posture.md new file mode 100644 index 0000000..ea56eb6 --- /dev/null +++ b/docs/adr/0009-open-crawling-posture.md @@ -0,0 +1,62 @@ +# 0009 — Everything is crawlable, AI crawlers included + +- **Status:** accepted +- **Date:** 2026-09-01 + +## Context + +The legacy site shipped this `robots.txt`: + +``` +User-agent: * +Disallow: /image +Disallow: /video +``` + +and these per-path headers on the production host: + +``` +https://scstem.org/image/* X-Robots-Tag: noindex +https://scstem.org/video/* X-Robots-Tag: noindex +``` + +Nobody now remembers what those were guarding. What they actually did was keep every photograph +of the team, and the hero footage, out of Google Images and video search — the two surfaces where +a robotics nonprofit's own photography is its best organic reach. + +Two questions had to be settled together: whether anything on the domain justifies blocking a +crawler, and what to say to the AI crawlers that did not exist when the legacy file was written. + +## Decision + +**Nothing on `scstem.org` is disallowed.** `robots.txt` is an allow-all with a `Sitemap:` +directive. The `/image` and `/video` rules are gone from both the file and `_headers`, per D25. + +**No AI crawler is blocked** — not GPTBot, ClaudeBot, PerplexityBot, CCBot, or any successor. +Being cited in an AI answer is objective 4 of this overhaul, not a leak to defend against. That +is also what `/llms.txt` and the structured-data work in this phase are for; blocking the crawlers +while publishing a manifest for them would be incoherent. + +**The precondition for both is D25: no private or internal content lives on this domain.** Checked +before landing this — `src/content/` and `src/pages/` hold marketing copy, sponsor records, robot +history, a published FAQ, and a public event's published schedule. Rosters, meeting notes, and +anything else internal live on the wiki, off this domain, and must stay there. + +`_headers` keeps only the three preview/staging `X-Robots-Tag: noindex` rules, so a +`*.pages.dev` or `staging.scstem.org` copy of the site never competes with production. + +## Alternatives considered + +- **Keep blocking AI crawlers, allow search crawlers.** A defensible posture for a publisher whose + business is its text. This organization's interest is being found by a parent in Franklin County + who asks an assistant where their kid can do robotics. +- **Keep the media `Disallow` rules "just in case".** They cost image and video search traffic + today for a risk nobody could name. If a specific asset ever must not be indexed, the answer is + not to publish it here. + +## Consequences + +- Team photography is indexable. Everything under `src/assets/` should be looked at as public + material, because it is. +- Reversing this for a specific crawler is a `robots.txt` edit, but reversing it for the media + paths would undo the reach this is meant to gain. Revisit only with a named reason. diff --git a/docs/adr/0010-og-cards.md b/docs/adr/0010-og-cards.md new file mode 100644 index 0000000..c5eeff2 --- /dev/null +++ b/docs/adr/0010-og-cards.md @@ -0,0 +1,57 @@ +# 0010 — Social cards are committed artifacts from one template + +- **Status:** accepted +- **Date:** 2026-09-01 + +## Context + +Every page shared a single 456 KB PNG of the logo over a team photo. DESIGN.md §7 specifies the +card: "photo + scrim + Orbitron title + lockup; one template, per-section variants." So the +question was only where the rendering happens. + +A per-page pipeline (satori, or a Cloudflare Function) is explicitly out of scope for this +overhaul (`plan/10-seo.md` §3, noted as future work in `plan/12-content-strategy.md`). That +leaves rendering at build time or rendering once, by hand. + +Build time is not free here. The cards need Orbitron, and `sharp` resolves an SVG's +`font-family` through **fontconfig**, which reads neither the variable woff2 the site ships nor a +weight axis. A build-time pipeline would therefore have to instance the font and populate a font +cache on every machine that builds the site — including Cloudflare Pages' builder — to produce +seven files that change when the photography or the section list changes, which is roughly never. + +## Decision + +`tools/assets/og-cards.mjs` renders all seven cards from one template and is **run by hand**; +the output is committed. `pnpm assets:og-fonts` is the one-time font step, and +`pnpm assets:og` renders. + +- One default at `public/og/default.jpg` — it is named by `site.ogImage`, which + `astro.config.ts` loads through jiti and so cannot import an asset. +- Six section cards in `src/assets/og/`, imported by the pages that pass them to `Seo` as + `ogImage`. Importing rather than pathing is what gets `og:image:width`/`height` emitted, since + `Seo` reads them off the `ImageMetadata`. +- **JPEG at q82, mozjpeg.** 34–52 KB per card against 456 KB for the PNG it replaces. No scraper + has ever wanted a photographic card in PNG. +- The **light monochrome lockup** (`logo-white-full.svg`), not the colour one: the card ground is + the dark scrim, which is the case DESIGN.md §7 gives the monochrome variants for. The colour + lockup's wordmark is drawn for light grounds and reads as grey mud on this one. +- The accent rule down the left edge takes the program's colour where a card has one — Hazard + Green for FRC, Danger Orange for FLL — from the same `[data-theme]` values `global.css` ships. + +## Alternatives considered + +- **A build-time step in `astro:build:start`.** Same template, but it makes every build depend on + a font cache the build machine has to have. The cards change when the photography changes; the + photography changes by hand. +- **`getImage` crops of each page's hero photo.** Free, correctly sized, and no font problem — + but no title, no lockup, and no consistent brand ground, which is most of what a card is for. +- **Keeping the PNG.** 456 KB to say nothing per-section. + +## Consequences + +- Regenerating needs Python with `fonttools` and `brotli`, plus fontconfig. Both scripts document + it and neither runs in CI. +- A new section wants a new card: add it to the `cards` list, run `pnpm assets:og`, and pass it + as `ogImage` with an `ogImageAlt` — `Seo` throws at build without the alt. +- `tools/checks/verify-meta.mjs` asserts every page's `og:image` is absolute and resolves to a + built file, so a card that is renamed and not rewired fails CI rather than a card debugger. diff --git a/docs/analytics.md b/docs/analytics.md new file mode 100644 index 0000000..133cd1c --- /dev/null +++ b/docs/analytics.md @@ -0,0 +1,119 @@ +# Analytics + +Two collectors, both cookieless-by-default, both loading after `load` (D21): + +| What | Where it comes from | Status | +| ------------------------ | -------------------------------------------------- | -------------------------------- | +| GA4 (`gtag.js`) | `site.analytics.ga4MeasurementId` — `G-3TPD3DLYBR` | live | +| Cloudflare Web Analytics | `PUBLIC_CF_BEACON_TOKEN` (Pages dashboard) | **unset — beacon does not load** | + +`src/components/Analytics.astro` renders both, from `BaseLayout`, at the end of ``. + +## When it loads, and when it does not + +Two gates, and both are load-bearing: + +1. **`import.meta.env.PROD`** — the snippet is absent from `pnpm dev` output entirely. +2. **`location.hostname === "scstem.org"`** — evaluated in the browser, first line of the snippet. + A `*.pages.dev` preview and `staging.scstem.org` serve _production_ builds from real hostnames, + so gate 1 alone would send every preview deploy's traffic to the live property. This is also + why the Lighthouse budgets never see `gtag.js`. + +Everything else happens inside an `addEventListener("load")`, and `gtag.js` is injected `async`. +Nothing analytics-related is on the critical path, which is what keeps LCP and TBT clean. + +**Consent Mode v2 defaults** are set before `config`: + +```js +gtag("consent", "default", { + ad_storage: "denied", + ad_user_data: "denied", + ad_personalization: "denied", + analytics_storage: "granted", +}); +``` + +`analytics_storage: "granted"` is the default because this is a US nonprofit with no ad products +and no EEA audience to speak of. Every advertising signal is denied outright rather than left +unset. Adding a banner later means changing these four values and calling `gtag("consent", +"update", …)` — the structure does not have to change. + +## Event taxonomy + +| Event | Fires when | +| ------------------------- | ----------------------------------------------------------- | +| `get_involved_click` | a link to `/get-involved` is clicked, anywhere on the site | +| `donate_click` | a link to `/donate` or to the PayPal fundraiser is clicked | +| `wishlist_click` | a link to the wiki wishlist is clicked | +| `sponsor_packet_download` | a link to the sponsorship packet is clicked | +| `outbound_sponsor_click` | a sponsor's own site is opened from a card or the strip | +| `contact_submit` | the contact form gets a successful response — not on submit | + +One delegated `click` listener on `document` covers the links. It recognizes the first five by +**destination**, from a table `Analytics.astro` builds out of `src/data/site.ts`: a route is named +once in this codebase, and tagging twenty call sites with `data-track` would be a second list to +keep in step with it. `data-track` is still read first, for the clicks a URL cannot identify — a +sponsor's href is a per-entry value, so `SponsorCard` and `SponsorStrip` carry +`data-track="outbound_sponsor_click"`. + +`contact_submit` has no click and no destination, so `ContactForm` dispatches it: +`document.dispatchEvent(new CustomEvent(TRACK_EVENT, { detail: "contact_submit" }))`, with the +event name imported from `src/lib/analytics.ts` by both ends. + +**To add an event:** a link destination goes in `destinations` in `Analytics.astro`; anything else +dispatches `TRACK_EVENT` with its name. Then add the row above, and mark it a key event in GA4. + +## Verifying it + +The snippet is gated on the production hostname, so a local check has to say it is on that host. +Against a production build served by `astro preview`, with the hostname in the emitted snippet +temporarily rewritten to `localhost`, the following was observed in Chromium: + +- `dataLayer` after `load`: `["consent","default",{…}]`, `["js",Date]`, `["config","G-3TPD3DLYBR"]` + — in that order, with the consent defaults first. +- `gtag.js` injected `async` into ``. +- Clicking the header CTA pushed `["event","get_involved_click"]`; a `/donate` link pushed + `["event","donate_click"]`; an `/about` link pushed nothing. +- Dispatching `sc2:track` with `detail: "contact_submit"` pushed `["event","contact_submit"]`. +- On `localhost` without that rewrite: no `dataLayer`, no tag request, on any page. + +In production, GA4's **DebugView** is the equivalent check; `?gtm_debug=x` on a real page turns it +on for that session. + +## Owner tasks + +These need account access this repository does not have. Tracked in `plan/todo.md`. + +- **Cloudflare Web Analytics.** Create the site in the Cloudflare dashboard, copy its beacon + token, and set `PUBLIC_CF_BEACON_TOKEN` in the Pages project's build environment (both + production and preview — the hostname gate is what keeps previews out of GA4, and CF Analytics + is per-site anyway). Until then the beacon is simply absent: the snippet skips it on an empty + token. +- **GA4 property review.** Data retention (14 months is the default; 26 is available and worth + taking), an internal-traffic filter if the workshop has a static IP, and unwanted-referral + exclusions for `paypal.com` and `docs.google.com` so a returning donor is not re-attributed to + a referral. +- **Key events.** Mark all six events above as key events in GA4 → Admin → Events. +- **Search Console.** Verify `scstem.org`, submit `https://scstem.org/sitemap-index.xml`, and link + the property to GA4. +- **Bing Webmaster Tools.** Verify, and import from Search Console rather than re-verifying. +- **Structured data.** Run the five JSON-LD shapes (`NGO`, `WebSite`, `BreadcrumbList`, `Event`, + `FAQPage`) through the Rich Results Test and the schema.org validator once the site is on a + reachable URL. `tools/checks/verify-meta.mjs` covers parse-and-type in CI; it cannot cover + Google's own eligibility rules. + +## The JS budget covers first-party script only + +`plan/00-overview.md` originally budgeted "total client JS < 35 KB gzipped per page **including +analytics**". `gtag.js` is about 35 KB gzipped on its own, so that wording and D21 could not both +hold. Settled by the project owner during Phase 10: **the budget excludes analytics**, and the +overview now says so. + +What that means in practice. The Lighthouse gate measures the site without GA4 — not by omission +but by design, since the production-hostname check keeps the tag out of every local run, every +preview deploy, and CI. First-party JS is 0.7–3.1 KB per page, inline in the document, against a +35 KB budget. Production adds `gtag.js` after `load`, so it never touches LCP or TBT; it does add +to total transfer, which the 1 MB page-weight assertion has ample room for. + +If that trade ever stops being worth it, Cloudflare Web Analytics' ~5 KB cookieless beacon would +satisfy the original literal reading on its own. diff --git a/docs/content.md b/docs/content.md index f085d4f..ab1e844 100644 --- a/docs/content.md +++ b/docs/content.md @@ -78,7 +78,7 @@ 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. +description: Shown in search results and when the page is shared. 50-160 characters. heroImage: ../../assets/events/morethanrobots.webp heroImageAlt: What the photo shows, for someone who cannot see it. ctaLabel: Get involved @@ -93,6 +93,22 @@ 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. +`description` has a length budget: `tools/checks/verify-meta.mjs` fails the build over 160 +characters, because that is roughly where Google stops printing one, and under 50 because a +one-liner tells a searcher nothing. It also has to be **unique across the site** — two pages with +the same description is the same check's other failure. Write it for someone deciding whether to +click, not as a summary of the page. + +**An event retires itself once its `end` has passed.** The first deploy after the event ends is +the one that does it: the page redirects to its parent, and the URL leaves the sitemap and +`/llms.txt` together. Nothing to remember, and nothing to clean up — dating next season's entry +forward brings the page straight back. + +`hidden: true` is still there, for retiring one _early_ or one that has no `end` at all. An entry +with no `end` never retires on its own, for the same reason the countdown never calls it passed: +nothing in the entry says when the event is over, and picking a duration would invent one. That is +why `end` is worth always setting. + 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. diff --git a/docs/tooling.md b/docs/tooling.md index 2cdaab0..2cd7504 100644 --- a/docs/tooling.md +++ b/docs/tooling.md @@ -181,9 +181,14 @@ vendored rule directory are skipped. | Variable | Where | Purpose | | --------------------------- | --------------------- | ------------------------------------- | | `PUBLIC_TURNSTILE_SITE_KEY` | build (public) | Turnstile widget on the contact form | +| `PUBLIC_CF_BEACON_TOKEN` | build (public) | Cloudflare Web Analytics beacon (D21) | | `TS_SECRET_KEY` | Pages Function secret | Turnstile server-side verification | | `SLACK_FORM_POST_GENERIC` | Pages Function secret | Slack webhook for contact submissions | +`PUBLIC_CF_BEACON_TOKEN` defaults to empty, and an empty token means the beacon is simply not +injected — so a fresh clone and every preview run with GA4 alone. Setting it is an owner task in +`docs/analytics.md`. + `PUBLIC_TURNSTILE_SITE_KEY` is declared in `astro.config.ts`'s `env.schema`, so pages import it from `astro:env/client` rather than reaching into an untyped `import.meta.env`. It **defaults to Cloudflare's documented always-passes test key** (`1x00000000000000000000AA`), which is why a diff --git a/knip.jsonc b/knip.jsonc index 4426a2c..f77a5b0 100644 --- a/knip.jsonc +++ b/knip.jsonc @@ -3,6 +3,9 @@ "entry": ["src/pages/**", "functions/**"], "project": ["**/*.{ts,tsx,js,mjs,cjs,astro}"], "ignore": ["legacy/**", "tools/lint/anti-slop/**"], + // fontconfig's cache tool, called by the one-time OG font setup. Present on any machine + // with fontconfig, which is the only kind that can render the cards at all. + "ignoreBinaries": ["fc-cache"], // Referenced only from CSS — `tailwindcss` via `@import`, three of the four faces via `url()` // in src/styles/fonts.css — and knip does not follow imports inside compiled extensions. Inter // is there for a different reason: fonts.css points at the trimmed copies in src/styles/fonts/, diff --git a/lighthouserc.json b/lighthouserc.json index f64fb66..bccc238 100644 --- a/lighthouserc.json +++ b/lighthouserc.json @@ -10,7 +10,7 @@ "http://localhost:4321/programs/frc/", "http://localhost:4321/programs/frc/robots/", "http://localhost:4321/sponsors/", - "http://localhost:4321/openhouse/", + "http://localhost:4321/about/", "http://localhost:4321/contact/" ] }, diff --git a/package.json b/package.json index 4c01c66..f7b6bc0 100644 --- a/package.json +++ b/package.json @@ -16,8 +16,11 @@ "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", + "check:meta": "node tools/checks/verify-meta.mjs", "assets:optimize": "node tools/assets/optimize-sources.mjs", - "assets:fonts": "node tools/assets/font-subset.mjs" + "assets:fonts": "node tools/assets/font-subset.mjs", + "assets:og": "node tools/assets/og-cards.mjs", + "assets:og-fonts": "node tools/assets/og-fonts.mjs" }, "dependencies": { "@astrojs/sitemap": "3.7.3", diff --git a/plan/00-overview.md b/plan/00-overview.md index 1386991..4e20a2e 100644 --- a/plan/00-overview.md +++ b/plan/00-overview.md @@ -173,5 +173,5 @@ one is a review blocker: - Lighthouse (mobile emulation, throttled): Performance ≥ 95, Accessibility = 100, SEO = 100, Best Practices ≥ 95. - LCP < 2.0 s, CLS < 0.05, TBT < 100 ms on every page. -- Total client JS < 35 KB gzipped per page including analytics; zero framework runtime. +- Total **first-party** client JS < 35 KB gzipped per page; zero framework runtime. **Amended in Phase 10** (owner-settled): this budget excludes analytics. `gtag.js` is about 35 KB gzipped on its own, so the original "including analytics" wording made the budget and D21 mutually exclusive. GA4 loads after `load` and only on the production hostname, so it is on top of this figure in production and absent from every Lighthouse run — see `docs/analytics.md`. - No image served larger than 300 KB at any rendered size; hero video ≤ 3 MB, never blocks LCP. diff --git a/plan/10-seo.md b/plan/10-seo.md index 0f4629c..2f965bc 100644 --- a/plan/10-seo.md +++ b/plan/10-seo.md @@ -53,10 +53,129 @@ Finish everything discoverability: sitemap/robots, per-page OG images, structure ## Acceptance criteria -- [ ] Sitemap live on preview, excludes styleguide/hidden events; robots.txt points at it. -- [ ] `verify-meta` script green across the full build and wired into CI. -- [ ] Every distinct JSON-LD shape validates; OG images resolve absolute and render in a card debugger (e.g. opengraph.xyz) on preview. -- [ ] `/llms.txt` serves accurate, collection-backed content. -- [ ] All legacy redirects verified working on preview; `_headers` reduced to preview/staging noindex only (D25) and verified. -- [ ] GA4 + CF Analytics fire on production hostname only (verified via GA DebugView on a hostname-spoofed check or temporary debug flag), events in taxonomy fire; `docs/analytics.md` written with audit results. -- [ ] `pnpm check && pnpm build` green; Lighthouse SEO = 1.0 holds. +- [x] Sitemap live on preview, excludes styleguide/hidden events; robots.txt points at it. Verified by flipping `openhouse.md` to `hidden: true` and watching the URL leave `sitemap-0.xml`. +- [x] `verify-meta` script green across the full build and wired into CI. It failed on four pages when first run; those descriptions are fixed below. +- [~] Every distinct JSON-LD shape validates; OG images resolve absolute and render in a card debugger. **Partly blocked** — see "Intentional gaps". +- [x] `/llms.txt` serves accurate, collection-backed content, and CI link-checks it. +- [~] All legacy redirects verified working on preview; `_headers` reduced to preview/staging noindex only (D25) and verified. `_headers`/`_redirects` are done in the repo; the preview spot-check is an owner task. +- [~] GA4 + CF Analytics fire on production hostname only, events in taxonomy fire; `docs/analytics.md` written with audit results. GA4 and the taxonomy are verified in a real browser (below); the Cloudflare beacon has no token yet and the property-side audit needs account access. +- [x] `pnpm check && pnpm build` green; Lighthouse SEO = 1.0 holds (100 on all six budgeted URLs). + +Everything marked `~` is finished as far as this repository can take it, with the remainder in +`plan/todo.md`. + +## Intentional gaps + +Each of these is a task for the project owner, not unfinished work; `plan/todo.md` is the list. + +- **Cloudflare Web Analytics has no token.** §7 asks for `cloudflareBeaconToken` in `site.analytics` + "with its real value" — there is no value to commit. It is a `PUBLIC_CF_BEACON_TOKEN` env field + instead, defaulting to empty, with the beacon skipped on an empty token: the same shape as + `PUBLIC_TURNSTILE_SITE_KEY`, so the owner sets it in the Pages dashboard rather than in a commit. + D21 is not fully satisfied until they do. +- **Google's validators are unreachable from this environment.** The Rich Results Test and + `validator.schema.org` are both Google-hosted, and this development environment's network policy + returns 403 for `*.google.com` (confirmed against the agent proxy's own status endpoint). The + five shapes were inventoried and asserted locally instead — every block parses, carries + `@context: https://schema.org`, and has an `@type`, now checked in CI by `verify-meta`. +- **The card debuggers and the preview deploy** need a deployed URL. `verify-meta` proves every + `og:image` is absolute and resolves to a built file; how a given network crops it is a look. +- **GA4 property settings, Search Console, Bing Webmaster Tools, key events** all need account + access. `docs/analytics.md` lists what to change and why. +- **Apex/www canonical behaviour** is Cloudflare dashboard configuration, as §6 says. + +**Two gaps closed during review**, both by the project owner's decision: + +- **The JS budget excludes analytics.** `plan/00-overview.md` budgeted "< 35 KB gzipped per page + *including* analytics", which `gtag.js` cannot fit alone. The overview now reads "first-party", + and `docs/analytics.md` states what the Lighthouse gate does and does not measure. +- **Events retire on date**, rather than waiting for someone to set `hidden: true`. See below. + +## As built + +### The sitemap filter is "noindex pages are not in the sitemap" + +§1 asks for the filter to be extended with `getVisibleEvents()`. It cannot be: `astro:content` is +a virtual module of the build's own module graph and `astro.config.ts` is loaded before it exists. + +The filter reads the emitted page instead. A retired event's route is a redirect page, which Astro +writes with ``; `/styleguide` sets the same tag through its +`Seo` prop. One predicate covers both — and anything that grows a `noindex` later — with no second +list of what is hidden, and the sitemap integration filters in `astro:build:done`, after every page +is on disk. The `/styleguide` special case is gone. + +### Four meta descriptions were too long + +`verify-meta`'s first run failed on `/donate/` (173), `/programs/frc/` (162), `/sponsors/` (195) +and `/openhouse/` (288). Three were trimmed. `/sponsors/` was the interesting one: its description +was also its hero copy, which is legacy's verbatim (D8) — so the two are now separate strings, one +written for the page and one for a search result. + +### `/llms.txt` needs a route manifest + +An `events` entry cannot know its own URL: `/openhouse` and `/programs/frc/kickoff` are +hand-written routes, not a `[slug]`. `src/data/events.ts` pairs entry id to path, and the endpoint +throws at build on an entry with no route. The other direction — a path that stops matching its +route — is covered by adding `dist/llms.txt` to the CI link check. + +No `llms-full.txt`: §5 makes it optional, and it would be a second copy of every page's prose to +keep in sync. + +### Analytics reads destinations, not `data-track` everywhere + +§7 specifies "`data-track` attributes". Twenty call sites link to `/get-involved` alone, and +tagging each one would be a second list to keep in step with `src/data/site.ts` — the exact +pattern the midpoint review's first standing rule exists to prevent. The delegated listener maps +**destination to event name** from a table built out of `site.ts`, and `data-track` is still read +first, for the two clicks a URL cannot identify (a sponsor's own site, from `SponsorCard` and +`SponsorStrip`). `contact_submit` has neither a click nor a destination, so `ContactForm` +dispatches a custom event whose name both ends import from `src/lib/analytics.ts`. + +Verified in Chromium against a production build with the emitted hostname rewritten to +`localhost`: consent defaults, `js`, and `config` land in that order; `gtag.js` is injected async; +the CTA and a `/donate` link push their events and an `/about` link pushes nothing; the dispatched +event arrives. Without the rewrite, on `localhost`, there is no `dataLayer` and no tag request on +any page. Full transcript in `docs/analytics.md`. + +### The social cards are committed artifacts + +Seven cards from one template, `docs/adr/0010-og-cards.md`. `sharp` resolves an SVG `font-family` +through fontconfig, which reads neither the variable woff2 the site ships nor a weight axis, so a +build-time pipeline would need a font cache on every build machine to render files that change +about never. `pnpm assets:og-fonts` then `pnpm assets:og`, by hand, output committed. 34–52 KB +each, against the 456 KB PNG they replace. + +### Events retire themselves (owner-requested, after review) + +Asked for during PR review: an event dated in the past should drop out of generated content on the +next build rather than sit there as live copy. `inService()` in `src/lib/events.ts` now also +excludes an entry whose `end` has passed, which flows through every consumer already built — the +route redirects to its parent, the sitemap filter drops it because the redirect page is `noindex`, +and `/llms.txt` drops it because it reads `getVisibleEvents()`. + +The rule is `hasPassed(end)`, moved into `src/lib/event-date.ts` and **shared with `Countdown`**, +which already had exactly this definition. Two copies would have let a page say "this event has +passed" while still being in the sitemap. An entry with no `end` never retires on its own, which is +the rule `Countdown` established in Phase 08 and the reason `docs/content.md` tells editors to +always set one. `hidden: true` remains the way to retire one early. + +Both current entries are dated in the past, so this ships with **no live event page** — verified: +`/openhouse/` and `/programs/frc/kickoff/` are redirect stubs, both are absent from +`sitemap-0.xml`, `/llms.txt` omits the Events section rather than printing a bare heading, and +nothing in the built HTML links to either. Two knock-ons: `lighthouserc.json` budgeted `/openhouse/` +as its event-landing page shape and now budgets `/about/` instead (a seasonal URL cannot be a +stable budget target — noted in `plan/todo.md`), and the countdown's "passed" state is now only +reachable in the window between an event ending and the next deploy. + +### Agent-readability audit + +Driven across all fourteen routes in Chromium: exactly one `h1` each, no skipped heading level on +any page, no bare "click here"/"read more" link text, no page errors. `