Skip to content
Merged
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
43 changes: 41 additions & 2 deletions .github/workflows/lighthouse.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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."
63 changes: 55 additions & 8 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 `<script async src>` in
`<head>`: that puts the third-party cost on the main thread during render and can
fail the TBT gate.
- **Perf budget accepts GA's cost.** The owner accepts the small performance hit
analytics brings, so `categories:performance` and `largest-contentful-paint` in
[lighthouserc.json](lighthouserc.json) are **warnings**, not errors (they also
swing widely on shared CI runners, so they were noisy gates anyway). The
deterministic gates stay hard errors: `seo = 1.0`, `accessibility ≥ 0.95`,
`best-practices ≥ 0.95` (still guards third-party/cookie issues), `CLS ≤ 0.05`,
`TBT ≤ 200ms`, and the image audits. Keep it that way.
- Pages still render and stay legible if the GA request is blocked (no dependency),
so the offline-friendly experience holds.

### When you add a new page

1. Add a singleton to `keystatic.config.ts` (reuse the `seo()` helper) and a
Expand Down Expand Up @@ -213,8 +259,9 @@ Two dependency-free validators guard the repo; they read the build output. Run
- `python3 tools/seo_check.py [dir]` — the SEO invariants (one `<title>`, meta
description, canonical, required `og:*`/`name` meta, `og:image` resolves, favicon /
manifest / stylesheet links, one `<h1>`, `alt` on every `<img>`, valid JSON-LD keyed
to the canonical origin, sitemap membership, gate ↔ indexing coupling, and no employer
name in `<head>`), plus `robots.txt` → sitemap and manifest/asset existence.
to the canonical origin, sitemap membership, gate ↔ indexing coupling, the Google
Analytics tag on every page, and no employer name in `<head>`), plus `robots.txt` →
sitemap and manifest/asset existence.

Both default to `dist/` when it exists (else the repo root). CI passes `dist`
explicitly.
Expand Down Expand Up @@ -373,7 +420,7 @@ for unattended growth, not targets to fill:

Quick guardrail recap; the reasoning is in the sections above.

- No external fonts, scripts, or CDNs (breaks the offline / CSP guarantee). Build-time deps are fine.
- No external fonts, scripts, or CDNs (breaks the offline / CSP guarantee). Build-time deps are fine. The one sanctioned exception is Google Analytics (see "Analytics"); do not add others, and do not remove GA to "restore" the offline rule.
- No root-absolute internal links or assets; keep them relative.
- No em dashes in anything shipped to `dist/`; restructure instead. CI fails on them.
- Do not hand-write or desync the `<head>`, JSON-LD, or sitemap; `BaseLayout` generates them.
Expand Down
4 changes: 2 additions & 2 deletions lighthouserc.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,11 @@
},
"assert": {
"assertions": {
"categories:performance": ["error", { "minScore": 0.95 }],
"categories:performance": ["warn", { "minScore": 0.9 }],
"categories:seo": ["error", { "minScore": 1.0 }],
"categories:accessibility": ["error", { "minScore": 0.95 }],
"categories:best-practices": ["error", { "minScore": 0.95 }],
"largest-contentful-paint": ["error", { "maxNumericValue": 2000 }],
"largest-contentful-paint": ["warn", { "maxNumericValue": 2500 }],
"cumulative-layout-shift": ["error", { "maxNumericValue": 0.05 }],
"total-blocking-time": ["error", { "maxNumericValue": 200 }],
"modern-image-formats": "error",
Expand Down
25 changes: 24 additions & 1 deletion src/layouts/BaseLayout.astro
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
---
import Header from '../components/Header.astro';
import Footer from '../components/Footer.astro';
import { SITE_ORIGIN, OG_IMAGE } from '../lib/content';
import { SITE_ORIGIN, OG_IMAGE, GA_MEASUREMENT_ID } from '../lib/content';
import variants from '../data/img-variants.json';

interface Props {
Expand Down Expand Up @@ -52,13 +52,36 @@ const heroAvifSrcset = heroInfo
// domain. Passing the src as an expression keeps Vite's dev-time module scanner
// from trying to resolve it as a bundled import; the emitted HTML is identical.
const mainSrc = 'assets/js/main.js';

// Google Analytics 4. Enabled on production builds only, so local `astro dev`
// never sends hits. This is the site's one sanctioned third-party script (see
// CLAUDE.md). The gtag.js library is loaded lazily, after the page has painted
// (load -> requestIdleCallback), so it stays off the main thread during the
// initial render and the strict total-blocking-time budget (<= 200ms in
// lighthouserc.json) still passes; owner accepts the small residual performance
// cost, so the perf-score and LCP assertions are warnings. The config/js
// commands queue in dataLayer immediately and fire once the library arrives, so
// the pageview is not lost. Passed via set:html so
// the braces emit verbatim; the loader URL and gtag('config', ...) stay as
// literals in the markup so tools/seo_check.py can enforce the tag on every
// page. Pages render and stay legible even if the request is blocked.
const gaEnabled = import.meta.env.PROD;
const gaInline =
`window.dataLayer=window.dataLayer||[];function gtag(){dataLayer.push(arguments);}` +
`gtag('js',new Date());gtag('config','${GA_MEASUREMENT_ID}');` +
`(function(){function l(){var s=document.createElement('script');s.async=1;` +
`s.src='https://www.googletagmanager.com/gtag/js?id=${GA_MEASUREMENT_ID}';` +
`document.head.appendChild(s);}function w(){'requestIdleCallback'in window?` +
`requestIdleCallback(l):setTimeout(l,2000);}` +
`document.readyState==='complete'?w():addEventListener('load',w);})();`;
---
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<script is:inline>document.documentElement.classList.add('js');</script>
{gaEnabled && <script is:inline set:html={gaInline}></script>}
<title>{title}</title>
<meta name="description" content={description}>
{author && <meta name="author" content={author}>}
Expand Down
7 changes: 7 additions & 0 deletions src/lib/content.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
17 changes: 17 additions & 0 deletions tools/seo_check.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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 <head>")
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 <head>")

# Page must be listed in the sitemap
if exp not in sitemap_locs:
err(page, f"URL '{exp}' is not listed in sitemap.xml")
Expand Down
Loading