Skip to content

overhaul: SEO completion, AI SEO, analytics - #26

Open
CS-5 wants to merge 2 commits into
overhaul/09-assets-performancefrom
overhaul/10-seo
Open

overhaul: SEO completion, AI SEO, analytics#26
CS-5 wants to merge 2 commits into
overhaul/09-assets-performancefrom
overhaul/10-seo

Conversation

@CS-5

@CS-5 CS-5 commented Sep 1, 2026

Copy link
Copy Markdown
Member

Phase 10 of the stacked overhaul (plan/10-seo.md), stacked on #25 per D23.

What this does

Sitemap & robots. The /styleguide special case in astro.config.ts becomes one rule: a page that carries noindex is not in the sitemap. That covers a retired event too — its route is a redirect page Astro emits with the same tag — so there is no second list of what is hidden. robots.txt is allow-all with a Sitemap: directive; _headers is down to the three preview/staging X-Robots-Tag rules (D25).

verify-meta.mjs, run in CI after the build: every built page gets exactly one unique <title>, one unique 50–160 character description, one canonical, one <h1>, an absolute og:image that resolves to a real file plus its alt, and JSON-LD that parses with a schema.org @type. Redirect pages are skipped.

/llms.txt, collection-backed, so a retired event leaves it the same build it leaves the sitemap. CI link-checks dist/llms.txt alongside the HTML.

Seven social cards from one template (photo, scrim, eyebrow, Orbitron title, lockup), replacing the single 456 KB PNG every page shared with 34–52 KB per section.

Analytics (D21). GA4 via a plain gtag snippet after load, behind both import.meta.env.PROD and a production-hostname check, with Consent Mode v2 defaults set before config. Cloudflare Web Analytics is wired but has no token yet — see the gaps below.

Events retire themselves once their end has passed — requested during review, see below.

Things worth a reviewer's attention

§1's getVisibleEvents() filter is not possible. 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, which is a single source of truth (the page's own noindex) and covers anything that grows the tag later. Safe because @astrojs/sitemap filters in astro:build:done, after every page is on disk.

verify-meta failed four pages on its first run, which is the point of it: /donate/ (173 chars), /programs/frc/ (162), /sponsors/ (195), /openhouse/ (288). Three were trimmed. /sponsors/ is the one to look at — its description was also its hero copy, which is legacy's verbatim under D8, so the two are now separate strings: one written for the page, one for a search result.

Analytics reads link destinations, not data-track everywhere. §7 specifies data-track attributes. Twenty call sites link to /get-involved alone, and tagging each 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 → event name from a table built out of site.ts; 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.

/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 id to path and the endpoint throws at build on an entry with no route; the other direction is covered by the link check.

The social cards are committed artifacts, not a build step (ADR 0010). 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, Cloudflare's included, to render files that change about never. pnpm assets:og-fonts then pnpm assets:og, by hand.

Review outcomes

Two things the project owner settled after the first push:

1. Events retire on date. inService() in src/lib/events.ts now also excludes an entry whose end has passed, so last season's page stops being live content on the first deploy after the event rather than waiting for someone to set hidden: true. Everything downstream already handled it: the route redirects to its parent, the sitemap filter drops the redirect page because it 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 sitting 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 handled — lighthouserc.json budgeted /openhouse/ as its event-landing page shape and now budgets /about/, because a seasonal URL cannot be a stable budget target; and the countdown's "passed" state is now only reachable between an event ending and the next deploy.

2. The client-JS budget excludes analytics. plan/00-overview.md budgeted "< 35 KB gzipped per page including analytics", which gtag.js cannot fit alone — that wording and D21 could not both hold. The overview now reads "first-party", and docs/analytics.md states what the Lighthouse gate does and does not measure: first-party JS is 0.7–3.1 KB per page, inline in the document; production adds gtag.js after load, where it never touches LCP or TBT.

Intentional gaps — owner tasks, tracked in plan/todo.md

These are finished as far as this repository can take them.

  • 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 it is set in the Pages dashboard rather than in a commit. D21 is not fully satisfied until that happens.
  • Google's validators are unreachable from the development environment. The Rich Results Test and validator.schema.org are both Google-hosted, and this environment's network policy returns 403 for *.google.com (confirmed against the agent proxy's status endpoint). The five shapes — NGO, WebSite, BreadcrumbList, Event, FAQPage — were inventoried and asserted locally instead: every block parses, carries @context: https://schema.org, and has an @type, now checked in CI. Google's own eligibility rules still need a run.
  • Card debuggers, the preview redirect spot-check, and the preview-host noindex header need a deployed URL.
  • GA4 property settings, Search Console, Bing Webmaster Tools, and marking the six taxonomy events as key events need account access. docs/analytics.md says what to change and why.
  • Apex/www canonical behaviour is Cloudflare dashboard configuration, as §6 notes.
  • The OG cards' photography is a taste call — the template is fixed, but which photo each section gets was picked from what exists in src/assets/. Swap a path in tools/assets/og-cards.mjs and run pnpm assets:og.

Verification

  • pnpm check && pnpm build green; verify-meta green across all 16 pages; Lighthouse gate green on all six URLs with SEO 100 and every LCP run under budget.
  • Analytics, 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 header CTA pushes get_involved_click, a /donate link pushes donate_click, an /about link pushes nothing; the dispatched event arrives as contact_submit. Without the rewrite, on localhost, there is no dataLayer and no tag request on any page. Transcript in docs/analytics.md.
  • Agent-readability audit across all fourteen routes: exactly one h1 each, no skipped heading level anywhere, no bare "click here"/"read more" link text, no page errors. <time datetime> was missing only from the surface that builds its dates in the browser — the calendar agenda — and now carries it on the day heading and each entry's time range, with an all-day entry stamped as a date rather than a midnight timestamp. Verified against a stubbed feed.
  • D25's precondition checked: nothing private is on the domain. src/content/ and src/pages/ hold marketing copy, sponsor records, robot history, a published FAQ, and a public event's published schedule. ADR 0009 records the posture — AI crawlers explicitly not blocked, and why the legacy /image and /video Disallow rules were costing image and video search traffic for a risk nobody could name.

Sitemap: the /styleguide special case becomes one rule — a page that carries
noindex is not in the sitemap. That covers a retired event too, since its route
is a redirect page Astro writes with the same tag, and needs no second list of
what is hidden. astro:content is unreachable from astro.config.ts, so the
filter reads the emitted HTML; the sitemap integration runs after every page is
on disk.

robots.txt is allow-all with a Sitemap directive, and _headers keeps only the
three preview/staging noindex rules (D25). ADR 0009 records the posture, AI
crawlers included, and the check that nothing private is on the domain.

verify-meta.mjs asserts every built page's head: one unique title, one unique
50-160 character description, one canonical, one h1, a resolvable absolute
og:image with alt, and JSON-LD that parses with a schema.org @type. It failed
four pages on its first run; /sponsors/ needed its description separated from
its hero copy, which is legacy's verbatim.

/llms.txt is collection-backed, so a retired event leaves it the same build it
leaves the sitemap. CI link-checks it, which is what keeps src/data/events.ts
honest about where each entry is published.

Seven social cards from one template (ADR 0010), replacing a 456 KB PNG shared
by every page with 34-52 KB per section.

Analytics: GA4 via gtag after load, behind both a PROD gate and a
production-hostname check, with Consent Mode v2 defaults set before config. The
event taxonomy is a delegated listener that reads link destinations from
site.ts rather than twenty data-track attributes; data-track still covers a
sponsor's own URL, and the contact form dispatches its own event. Cloudflare
Web Analytics is wired but has no token yet — PUBLIC_CF_BEACON_TOKEN, empty by
default, and the beacon is skipped without it.

Owner tasks that need account or dashboard access are in plan/todo.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CxssDiw8XBERsuHMQZTtCr
Review outcomes from the project owner.

An event now goes out of service once its `end` has passed, so last season's
page stops being live content on the first deploy after the event rather than
waiting for someone to set `hidden: true`. The rule is `hasPassed`, moved into
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 sitting in the sitemap. Everything downstream already handled it: the
route redirects, the sitemap filter drops the redirect page because it is
noindex, and /llms.txt drops it because it reads getVisibleEvents.

Both current entries are dated in the past, so this ships with no live event
page. Two knock-ons handled: /llms.txt omits an empty section rather than
printing a bare heading, and lighthouserc.json budgets /about/ where it
budgeted /openhouse/, because a seasonal URL cannot be a stable budget target.

plan/00-overview.md's client-JS budget now reads "first-party" rather than
"including analytics" — gtag.js is about 35 KB gzipped on its own, so the
original wording and D21 could not both hold. docs/analytics.md says what the
Lighthouse gate does and does not measure.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CxssDiw8XBERsuHMQZTtCr
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants