diff --git a/.github/workflows/lighthouse.yml b/.github/workflows/lighthouse.yml index d2cdde6..d7fe39b 100644 --- a/.github/workflows/lighthouse.yml +++ b/.github/workflows/lighthouse.yml @@ -10,8 +10,30 @@ permissions: contents: read jobs: - lighthouse: + # The 6 pages are split across two shards that run on separate runners. + # Lighthouse measures timing under CPU/network throttling, so its numbers are + # only accurate when a single Lighthouse instance has the machine to itself; + # never run the URLs concurrently on one runner. Sharding across isolated + # runner VMs keeps each measurement contention-free while roughly halving + # wall-clock time. Assertions/settings stay in lighthouserc.json; each shard + # overrides only the URL list via repeated --collect.url flags. + lighthouse-shard: + name: Lighthouse shard ${{ matrix.name }} runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - name: A + urls: >- + --collect.url=http://localhost:8299/index.html + --collect.url=http://localhost:8299/work.html + --collect.url=http://localhost:8299/ideas.html + - name: B + urls: >- + --collect.url=http://localhost:8299/speak.html + --collect.url=http://localhost:8299/about.html + --collect.url=http://localhost:8299/contact.html steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 @@ -26,4 +48,21 @@ jobs: - name: Build site (Astro -> dist/) run: npm run build - name: Run Lighthouse CI (asserts performance/SEO/a11y budgets) - run: npx lhci autorun + run: npx lhci autorun ${{ matrix.urls }} + + # Single required-status-friendly gate: stays green only if every shard + # passed. Keep this job named "lighthouse" so branch protection that requires + # a "lighthouse" check keeps working after the split. + lighthouse: + name: lighthouse + needs: lighthouse-shard + if: always() + runs-on: ubuntu-latest + steps: + - name: Require all Lighthouse shards to pass + run: | + if [ "${{ needs.lighthouse-shard.result }}" != "success" ]; then + echo "One or more Lighthouse shards failed." + exit 1 + fi + echo "All Lighthouse shards passed." diff --git a/CLAUDE.md b/CLAUDE.md index ac1c4cf..8a05b73 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -28,8 +28,13 @@ else, end to end, including getting it live. Follow this loop for every change, without being asked for each step: -1. **Work on a branch.** Never push to `main` directly: it is protected and rejects - direct pushes. Changes reach it only through a pull request. +1. **Work on a branch, always cut from the latest `main`.** Never push to `main` + directly: it is protected and rejects direct pushes. Changes reach it only through a + pull request. Keeping current with `main` is your job, never the owner's to ask + about: `git fetch origin main` before you start, and before you merge, rebase your + branch onto the latest `main` (`git rebase origin/main`) and resolve any conflicts + yourself so the PR always merges cleanly. If `main` moves while a PR is open, rebase + again rather than letting it go stale. 2. **Validate thoroughly before showing it.** `npm run build`, then `python3 tools/validate_site.py dist` and `python3 tools/seo_check.py dist`; both must pass clean. Re-run until green. For anything visible, look at the built page, @@ -80,9 +85,11 @@ This depends on two things staying set up (see [docs/hosted-admin.md](docs/hoste - **The design system and behavior are unchanged and self-contained.** CSS in `public/assets/css/style.css`; behavior in `public/assets/js/main.js` (mobile nav, scroll-reveal, gallery lightbox, contact-form FormSubmit AJAX handler, footer - year). No external fonts, scripts, or CDNs — - it must keep working offline and under a strict CSP. Pages stay legible with - JavaScript disabled. Everything in `public/` ships verbatim into `dist/`. + year). No external fonts, scripts, or CDNs, with **one sanctioned exception: + Google Analytics** (see "Analytics" below). Everything else must keep working + offline and CSP-safe. Pages stay legible with JavaScript disabled, and they + render fine even when the GA request is blocked. Everything in `public/` ships + verbatim into `dist/`. - **Keep links relative.** Every internal link and asset reference is relative (`work`, `assets/css/style.css`, `favicon.svg`) so the site works under both the GitHub Pages subpath and the apex domain. `base` stays `/`; do not switch to @@ -170,6 +177,45 @@ consistent. Editable per page: `seo.title`, `seo.description`, `seo.ogTitle`, Everything is keyed to `https://quentinfears.com` as the single source of truth. +## Analytics + +The site uses **Google Analytics 4** (`gtag.js`, stream `Quentin Fears`, +measurement ID `G-1WEVVZN8TV`). This is the **one deliberate exception** to the +no-external-scripts rule: an owner-approved third-party tag, wired so it stays +contained. + +- **Single source of truth:** `GA_MEASUREMENT_ID` in + [src/lib/content.ts](src/lib/content.ts). To change the ID, update it there + *and* `GA_MEASUREMENT_ID` in [tools/seo_check.py](tools/seo_check.py). +- **Emitted by [BaseLayout.astro](src/layouts/BaseLayout.astro), not per page,** + so every page (current and future) gets the tag automatically. Do not paste + the snippet into individual pages. +- **Production only.** The tag is gated behind `import.meta.env.PROD`, so + `npm run build` (and `build:admin`) include it but `npm run dev` does not, and + local editing never sends hits to the live property. +- **CI enforces it.** `tools/seo_check.py` fails the build if any page ships + without both halves of the snippet (the `gtag/js?id=…` loader and the + `gtag('config', …)` call). New pages/layouts must keep analytics wired; do not + remove or gut the tag. This is the intended behaviour even though it loads a + CDN script, so do not "fix" it back out to restore the offline guarantee. +- **Lazy-loaded on purpose.** The `gtag.js` library is injected only after the + page paints (`load` → `requestIdleCallback`), so it stays off the main thread + during render and the strict `total-blocking-time ≤ 200ms` gate in + [lighthouserc.json](lighthouserc.json) still passes. The `gtag('config', …)` call + queues in `dataLayer` immediately and fires when the library arrives, so no + pageview is lost. Do not "simplify" this back to an eager ` + {gaEnabled && } {title} {author && } diff --git a/src/lib/content.ts b/src/lib/content.ts index 38988a0..cbbf384 100644 --- a/src/lib/content.ts +++ b/src/lib/content.ts @@ -9,3 +9,10 @@ export const reader = createReader(process.cwd(), keystaticConfig); // sync with tools/seo_check.py (BASE), robots.txt, and sitemap.xml. export const SITE_ORIGIN = 'https://quentinfears.com'; export const OG_IMAGE = `${SITE_ORIGIN}/assets/img/og-cover.jpg`; + +// Google Analytics 4 measurement ID. This is the one deliberate exception to +// the "no external scripts/CDN" rule: BaseLayout loads gtag.js from Google on +// production builds only (import.meta.env.PROD), so `npm run dev` never sends +// hits. tools/seo_check.py (GA_MEASUREMENT_ID) enforces that every built page +// carries this tag. Keep the two IDs in sync. +export const GA_MEASUREMENT_ID = 'G-1WEVVZN8TV'; diff --git a/tools/seo_check.py b/tools/seo_check.py index 28aadd6..cddf901 100644 --- a/tools/seo_check.py +++ b/tools/seo_check.py @@ -24,6 +24,12 @@ # changes, update this and find-and-replace across the HTML / robots / sitemap. BASE = "https://quentinfears.com" +# Google Analytics 4 measurement ID. BaseLayout emits the gtag.js snippet on +# every production page; this check fails CI if a page ever ships without it, so +# new pages/layouts keep analytics wired. Keep in sync with GA_MEASUREMENT_ID in +# src/lib/content.ts. +GA_MEASUREMENT_ID = "G-1WEVVZN8TV" + # This is a personal site. It must not present Quentin as an official # representative or spokesperson of an employer in machine-readable metadata. # These terms are allowed in visible body copy (his own first-person words) @@ -234,6 +240,17 @@ def check_page(page, html, sitemap_locs): f"tags (body copy is fine)", ) + # Google Analytics must be wired on every page. Check both halves of the + # gtag snippet: the async loader from Google and the config call that fires + # the pageview, so a half-removed tag is caught too. + if f"gtag/js?id={GA_MEASUREMENT_ID}" not in head: + err(page, f"missing Google Analytics loader for {GA_MEASUREMENT_ID} in ") + if not re.search( + r"gtag\(\s*['\"]config['\"]\s*,\s*['\"]" + re.escape(GA_MEASUREMENT_ID) + r"['\"]", + head, + ): + err(page, f"missing gtag('config', '{GA_MEASUREMENT_ID}') call in ") + # Page must be listed in the sitemap if exp not in sitemap_locs: err(page, f"URL '{exp}' is not listed in sitemap.xml")