Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
38 changes: 34 additions & 4 deletions astro.config.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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 !/<meta(?=[^>]*\bname="robots")(?=[^>]*noindex)[^>]*>/u.test(html);
};

export default defineConfig({
/**
* Typed environment, so a page reads a variable rather than an untyped `import.meta.env`
Expand All @@ -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: {
Expand Down
62 changes: 62 additions & 0 deletions docs/adr/0009-open-crawling-posture.md
Original file line number Diff line number Diff line change
@@ -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.
57 changes: 57 additions & 0 deletions docs/adr/0010-og-cards.md
Original file line number Diff line number Diff line change
@@ -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.
119 changes: 119 additions & 0 deletions docs/analytics.md
Original file line number Diff line number Diff line change
@@ -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 `<body>`.

## 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 `<head>`.
- 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.
18 changes: 17 additions & 1 deletion docs/content.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand Down
5 changes: 5 additions & 0 deletions docs/tooling.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading