From 3b0bf8eddd6c39f9fc055f82787e78c960cfdd15 Mon Sep 17 00:00:00 2001 From: Bryan Fawcett Date: Thu, 15 Oct 2026 05:08:16 +0000 Subject: [PATCH] Release v0.4.0 staging's tree exactly (at 763a2a3), released to main as the next minor. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CoDNYz27iJ7o8PPztCzTpq --- .claude/settings.json | 14 + .githooks/post-merge | 5 + .github/dependabot.yml | 20 + .github/workflows/build.yml | 48 ++- .github/workflows/main-version.yml | 5 +- .github/workflows/staging-version.yml | 5 +- CHANGELOG.md | 106 +++++ CONTRIBUTING.md | 73 +++- README.md | 120 +++--- docs/toolchain.md | 119 +++--- package-lock.json | 44 +-- package.json | 7 +- public/llms.txt | 46 ++- scripts/audit.mjs | 16 +- scripts/clean.mjs | 251 ++++++++++++ scripts/install-hooks.mjs | 82 ++++ scripts/probe-consent.mjs | 74 +++- scripts/qa.mjs | 6 +- src/assets/lingo/icon.png | Bin 0 -> 49079 bytes src/components/Analytics.astro | 110 +++--- src/components/CookieBanner.astro | 45 +-- src/components/SupportWidget.astro | 10 +- src/data/consent.ts | 35 +- src/data/legal.ts | 26 +- src/data/releases.ts | 78 +++- src/data/site.ts | 62 ++- src/pages/index.astro | 63 +-- src/pages/legal/cookies.astro | 12 +- src/pages/legal/data.astro | 4 +- src/pages/legal/privacy.astro | 18 +- src/pages/legal/security.astro | 15 +- src/pages/mukoko-lingo.astro | 373 ++++++++++++++++++ src/pages/toddle-enhancement-extension.astro | 11 +- .../releases.astro | 2 +- tests/consent.test.ts | 190 +++++++++ tests/links.test.ts | 3 +- tests/security.test.ts | 45 ++- tests/seo.test.ts | 103 ++++- tests/toolchain.test.ts | 29 ++ vercel.json | 2 +- 40 files changed, 1865 insertions(+), 412 deletions(-) create mode 100755 .githooks/post-merge create mode 100644 .github/dependabot.yml create mode 100644 scripts/clean.mjs create mode 100644 scripts/install-hooks.mjs create mode 100644 src/assets/lingo/icon.png create mode 100644 src/pages/mukoko-lingo.astro create mode 100644 tests/consent.test.ts create mode 100644 tests/toolchain.test.ts diff --git a/.claude/settings.json b/.claude/settings.json index 6407e7c..8712d0b 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -25,5 +25,19 @@ "mcp__claude_ai_Postiz__integrationList", "mcp__Mzizi__mzizi_get_tokens" ] + }, + "hooks": { + "PostToolUse": [ + { + "matcher": "mcp__github__merge_pull_request", + "hooks": [ + { + "type": "command", + "command": "node \"$CLAUDE_PROJECT_DIR/scripts/clean.mjs\" --hook", + "timeout": 120 + } + ] + } + ] } } diff --git a/.githooks/post-merge b/.githooks/post-merge new file mode 100755 index 0000000..cd0ff5e --- /dev/null +++ b/.githooks/post-merge @@ -0,0 +1,5 @@ +#!/bin/sh +# After a `git pull` that merges: keep the clone light (scripts/clean.mjs). +# Turned on by `npm ci` (scripts/install-hooks.mjs). It never fails a pull. +command -v node >/dev/null 2>&1 || exit 0 +node scripts/clean.mjs --quiet || true diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..797053a --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,20 @@ +# Keeps the actions pinned in .github/workflows current. They are pinned to +# full commit SHAs, with the version in a comment, so a moved tag cannot +# change what runs; Dependabot updates the SHA and the comment together. +# +# npm dependencies are not listed here: no entry existed before, and adding +# one is a separate decision about how many pull requests this repo wants. +version: 2 +updates: + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + # A release has a week to be pulled or found bad before a pin moves to it. + cooldown: + default-days: 7 + groups: + actions: + patterns: ["*"] + commit-message: + prefix: ci diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index f63f453..0f0b301 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -5,8 +5,9 @@ on: push: branches: [main, staging] -permissions: - contents: read +# Nothing at the workflow level: each job asks for what it uses, and no job +# here pushes, tags or comments, so each reads the repository and no more. +permissions: {} concurrency: group: ${{ github.workflow }}-${{ github.ref }} @@ -15,11 +16,17 @@ concurrency: jobs: check: name: astro check + tests + build + permissions: + contents: read runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + with: + # Nothing here pushes, so the token is not left in .git/config for + # every later step (and every npm install script) to read. + persist-credentials: false - - uses: actions/setup-node@v4 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version: "22" cache: "npm" @@ -30,20 +37,26 @@ jobs: - name: Type-check run: npm run check - # `npm test` runs vitest, builds the site, and then verifies that every - # inline script in the build is covered by the CSP hash allowlist in - # vercel.json — so an edit to the theme bootstrap cannot ship a page the - # browser will refuse to run. + # `npm test` builds the site, runs the tests (vp test), and then verifies + # that every inline script in the build is covered by the CSP hash + # allowlist in vercel.json — so an edit to an inline script cannot ship + # a page the browser will refuse to run. - name: Tests, build, and CSP check run: npm test audit: name: npm audit + permissions: + contents: read runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + with: + # Nothing here pushes, so the token is not left in .git/config for + # every later step (and every npm install script) to read. + persist-credentials: false - - uses: actions/setup-node@v4 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version: "22" cache: "npm" @@ -62,16 +75,11 @@ jobs: - name: Audit what ships run: node scripts/audit.mjs - # Build and test tooling, reported but not blocking. - # - # vitest currently carries an advisory with no installable fix: every - # published version up to 4.1.10 is affected, and 4.1.11 — the fix — - # cannot be installed, because it trips a resolver bug in npm 10.9 - # ("Cannot read properties of null (reading 'edgesOut')") that aborts - # `npm install` outright. Blocking on it would pin this job red until - # npm ships a fix, which trains everyone to ignore it. So it is surfaced - # in the log instead, and this step goes back to blocking the moment - # vitest 4.1.11 installs cleanly. + # Build and test tooling, reported but not blocking. None of it ships: + # the site is static HTML. Blocking on an advisory in a build tool with + # no non-breaking fix would pin this job red and train everyone to + # ignore it, so it is surfaced in the log instead. Today that is + # postcss-selector-parser, through @tailwindcss/typography. - name: Report advisories in build and test tooling continue-on-error: true run: npm audit --audit-level=moderate diff --git a/.github/workflows/main-version.yml b/.github/workflows/main-version.yml index 68e591a..c255f94 100644 --- a/.github/workflows/main-version.yml +++ b/.github/workflows/main-version.yml @@ -14,8 +14,9 @@ on: options: [minor, major] default: minor -permissions: - contents: read +# Nothing at the workflow level: the one job below asks for the write access +# that tagging needs, and only it. +permissions: {} jobs: version: diff --git a/.github/workflows/staging-version.yml b/.github/workflows/staging-version.yml index 76bfb08..f6e5e70 100644 --- a/.github/workflows/staging-version.yml +++ b/.github/workflows/staging-version.yml @@ -13,8 +13,9 @@ on: options: [patch, minor, major] default: patch -permissions: - contents: read +# Nothing at the workflow level: the one job below asks for the write access +# that tagging needs, and only it. +permissions: {} jobs: version: diff --git a/CHANGELOG.md b/CHANGELOG.md index 51ce917..60f3e97 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,8 +8,66 @@ numbers below were the site's earlier, hand-kept numbering and are retired; the ## Unreleased +### Added + +- **Mukoko Lingo has its own page, `/mukoko-lingo`.** Mukoko Lingo is a + partner app developed by Nyuchi Learning, part of Mukoko, Africa's super + app. The page says what it does today, who it is for, what schools get + and how to start, in the extension page's style, with the app's own icon. + Every claim is from Lingo's public repository (mukoko-dev/mukoko-lingo: + README.md, app.json, BRANDING.md and the app's own copy). It sits beside + the Toddle Enhancement Extension wherever the extension appears: a second + product card on the home page, "Mukoko Lingo" in the header (where + "Tools" is now "Toddle extension"), and the footer's "Tools" column, now + "Products". It has a SoftwareApplication node in its JSON-LD, Open Graph + tags, a sitemap entry and an entry under Products in llms.txt. + `mukokoLingo` in `src/data/site.ts` holds its links. + `tests/seo.test.ts` checks all of that. +- **`npm run clean`, and after every merge.** `scripts/clean.mjs` removes + the build output (`dist/`, `.astro/`, `.vercel/output/`), the QA report, + the tools' caches, finished Claude Code worktrees and local branches + already in `main` or `staging`, touching only what Git ignores. It runs + after a `git pull` that merges (`.githooks/post-merge`, turned on by + `npm ci` through the new `prepare` script) and after Claude Code merges a + pull request (`.claude/settings.json`). See CONTRIBUTING.md. + ### Changed +- **Mukoko Lingo is written for organisations first.** The page, the + homepage card and `llms.txt` now lead with schools, businesses and + government, then single learners. The "For schools" section is "For + schools, businesses and government" (anchor `#organisations`), and the + enquiry button reads "Talk to us about your organisation". Lingo is + described as "Part of Mukoko by Nyuchi", and Swahili is shown as beta + (owner decisions, 2026-10-15). + +- **Mukoko Lingo, described as it is.** The home page card and llms.txt + said Lingo was "free for personal use", "Shona and Ndebele primary" and + taught French and Portuguese. Its repository says it teaches Shona, + Ndebele, English and Chinese, and that starting is free; the copy now + says that, and links to the live app at lingo.mukoko.com rather than + mukoko.com/lingo. The home page's "three surfaces" are now the two + products and two destinations. +- **0.11.0 is on the Chrome Web Store, and is the current version (#85).** + It is 0.10.0 with a security fix to how licence keys are checked. The + Releases page lists it first, marked on the store, from the extension's + CHANGELOG; the test builds tagged since 0.10.0 (0.10.1 to 0.10.5, 0.11.1 + and 0.11.2) open its notes, and 0.12.0, not released, is not listed. + `extensionVersions.current` is 0.11.0; the new `changedIn` (0.9.0) keeps + "versions before 0.9.0" true where the old licence host and flags key are + named. The product page, the Security page (which now describes the + 0.11.0 fix), the Releases description and llms.txt name 0.11.0. The + anchors for 0.9.6 and 0.9.7 the issue asked for were already there; the + test that every tagged test build has one now covers the 0.10.x and + 0.11.x builds too. +- **`webStore`'s type is a named `Url`**, so Prettier 3.3 and 3.9 format + `src/data/site.ts` the same way; the org lint gate (3.9.4) had flagged it. +- **After paying: the checkout note describes today's email, a licence + key.** The claim-link wording (#89) is held back until the licence server + sends claim links, which ships with extension 0.12.0. Until then buyers + still receive the key by email, so the note says that. Restore #89 when + 0.12.0 and the server's claim links go live. +- **0.10.0 is on the Chrome Web Store.** The Releases page shows it as on the store since 13 October 2026, and the test builds 0.9.9 and 0.9.10 open its notes. - **The Toddle Enhancement Extension 0.9.0 is on the Chrome Web Store**, published on 7 October 2026. The Releases page marks it "On the Chrome Web Store", dated 7 October 2026. `extensionVersions.current` is 0.9.0 and @@ -22,6 +80,54 @@ numbers below were the site's earlier, hand-kept numbering and are retired; the from 0.8.2 stays, as history: flags it hid stay concealed, and versions before 0.9.0 used the storage key `gbx-hide-flags` and asked the licence host `licences.nyuchi.dev`. +- **Google Analytics loads only after consent (#83).** Google's tag is no + longer on the page behind Consent Mode set to denied, which still sent + Google a cookieless ping; it is not requested at all until the visitor + agrees. `Analytics.astro` queues the denied defaults and holds the only + loader, which runs on a stored yes from the current banner version or when + `CookieBanner.astro` records one. A record from an older version, the first + banner's `analytics-consent` key, or anything malformed counts as no answer, + and the banner asks again; `SupportWidget.astro` applies the same check + through `parseConsent` in `src/data/consent.ts`. The privacy and cookie + policies now say that Google Analytics contacts Google only after you agree + (Google Fonts is still named separately), dated 13 October 2026, as is + llms.txt. The CSP has one inline-script hash fewer. `tests/consent.test.ts` + runs the built script against current, old, legacy and malformed records, + and `npm run consent:probe` now fails on any Google Analytics request + before consent. +- **CI asks for less (#84).** Every workflow grants nothing at the top level; + each job asks for its own permissions (`contents: read` for the build and + audit jobs, `contents: write` only for the two tagging jobs). The build + workflow's checkouts set `persist-credentials: false`, since nothing there + pushes. `actions/checkout` and `actions/setup-node` are pinned to full + commit SHAs (v4.4.0 each), with the version in a comment, and the new + `.github/dependabot.yml` keeps those pins current, a week after each + release (`cooldown`). The reusable workflows + from `nyuchi/.github` were already pinned to a commit. +- **Docs match the site as it is (#86).** README, CONTRIBUTING and + `docs/toolchain.md` describe the Astro site's pages, the staging → main + release flow, Vite+ as adopted (no longer "cannot be adopted"), the inline + scripts the CSP hashes, and the current audit; the dead Lint badge, the + vitest pin, `master` and "two pages" are gone. `react` and `react-dom` are + no longer dev dependencies: nothing imports them, and `@bundu/ui` declares + no peer dependencies. Prettier and `vp fmt` both stay, because each is run + by a required org gate; `tests/toolchain.test.ts` fails if their settings + differ. + +### Fixed + +- **Dependency advisories.** `http-cache-semantics` takes its patched + release (GHSA-ch52-4w7c-c8xp), so its dated exception in + `scripts/audit.mjs` is gone. `postcss-selector-parser` is held at 7.1.6 or + later through an npm `overrides` entry (GHSA-rj75-hqrm-r3gf): the + typography plugin pins 6.0.10, and the built CSS is byte-for-byte the same + with the newer version. +- **Anchors for the extension's test builds 0.9.6, 0.9.7 and 0.9.8 (#85)** + on the Releases page, under 0.10.0 beside 0.9.1 to 0.9.5, so the links from + those builds' menus land on the release they lead to. 0.10.0 is still + "Coming soon to the Chrome Web Store". `taggedTestBuilds` in + `src/data/releases.ts` lists every tagged test build, and a test fails the + build if one has no anchor, or more than one. ## 0.1.0 — 2026-10-07 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6767e67..29accf8 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -5,15 +5,19 @@ three predictable places. ## What gets changed where -| Change | File | -| --------------------------------------------------------- | ----------------------------------------------------- | -| Nav links, footer columns, legal links, the wordmark | `src/data/site.ts` | -| Links to the extension (repo, releases, deployment guide) | `src/data/site.ts` (`toddleExtension`) | -| The three destination cards | the `surfaces` array in `src/pages/index.astro` | -| Home page copy | `src/pages/index.astro` | -| Extension page copy | `src/pages/toddle-enhancement-extension.astro` | -| Header / footer structure | `src/components/SiteHeader.astro`, `SiteFooter.astro` | -| ``, metadata, theme bootstrap | `src/layouts/BaseLayout.astro` | +| Change | File | +| ------------------------------------------------------- | ------------------------------------------------------------------- | +| Nav links, footer columns, legal links, the wordmark | `src/data/site.ts` | +| Links to the extension (store listing, releases, guide) | `src/data/site.ts` (`toddleExtension`) | +| The extension's release notes | `src/data/releases.ts` | +| Facts on the legal pages (versions, prices, contacts) | `src/data/legal.ts` | +| Consent categories and the banner's version | `src/data/consent.ts` | +| The destination cards | the `surfaces` array in `src/pages/index.astro` | +| Home page copy | `src/pages/index.astro` | +| Extension page copy | `src/pages/toddle-enhancement-extension.astro` | +| Mukoko Lingo's links / page copy | `src/data/site.ts` (`mukokoLingo`) / `src/pages/mukoko-lingo.astro` | +| Header / footer structure | `src/components/SiteHeader.astro`, `SiteFooter.astro` | +| ``, metadata, theme bootstrap | `src/layouts/BaseLayout.astro` | ## What must not be changed here @@ -38,16 +42,38 @@ lowercase spelling anywhere in `src/` fails that test too. npm install npm run dev # http://localhost:4321 — hot reload npm run check # astro check -npm test # vitest, then build, then the CSP hash check +npm test # build, then the tests (vp test), then the CSP hash check +npm run lint # vp lint npm run format # prettier --write npm run format:check # the org lint gate runs this +npm run fmt # vp fmt; the org vite-plus gate runs `vp fmt --check` ``` -## If you touch the theme bootstrap +## Keeping the clone light -The one inline script in `BaseLayout.astro` is allowed by a hash in the -Content-Security-Policy in `vercel.json`. Change the script and the hash no -longer matches, so: +`npm run clean` (`scripts/clean.mjs`) removes what the build and the tools +generate (`dist/`, `.astro/`, `.vercel/output/`, `qa-report.json`, the tools' caches), Claude Code worktrees with nothing uncommitted, and local +branches that are done: everything on them is already in `origin/main` or +`origin/staging` (a squash merge included), or their remote branch was +deleted after the pull request merged while they held nothing unpushed. It +touches only what Git ignores, never `main`, `staging`, the branch checked +out or one in a worktree, and names anything it leaves. `--dry-run` lists, +`--deep` also removes `node_modules` and npm's cache, `--no-fetch` works +offline. + +It runs on its own after a `git pull` that merges (`.githooks/post-merge`, +turned on by `npm ci`), and after Claude Code merges a pull request +(`.claude/settings.json`, a `PostToolUse` hook) in whichever of the three +clones (`toddle-enhancement-extension`, `learning`, `nyuchi-licence-server`) +the pull request belongs to. The same script is in all three; only its list +of generated paths differs. + +## If you touch an inline script + +The inline scripts (the theme bootstrap in `BaseLayout.astro`, the consent +check in `Analytics.astro`, and the small modules Astro inlines) are allowed by +hashes in the Content-Security-Policy in `vercel.json`. Change one and its hash +no longer matches, so: ```sh npm run build @@ -56,8 +82,12 @@ npm run csp:fix # rewrite vercel.json with the new hashes Then review the diff — a changed hash should correspond to a change you meant to make. `npm test` fails if you skip this, which is the point: a stale hash -would mean the browser refuses to run the script and the theme silently breaks -in production. +would mean the browser refuses to run the script and it silently breaks in +production. + +If you touch the consent banner or analytics, run `npm run consent:probe` too: +Google's tag must not be requested before the visitor agrees, and the privacy +policy says so. ## Adding a page @@ -68,9 +98,14 @@ in production. ## Pull requests -CI runs `astro check`, the tests, the build and the CSP check, plus the org -lint gate (actionlint, JSON validity, Prettier, markdownlint, yamllint). All of -it has to be green. Merging to `master` deploys to Vercel. +Branch from `staging` and open the pull request against `staging`. CI runs +`astro check`, the build, the tests and the CSP check, plus the org gates: the +lint gate (actionlint, JSON validity, Prettier, markdownlint, yamllint) and +`vite-plus / check` (`astro check`, `vp fmt --check`). All of it has to be +green. Each merge into `staging` is tagged as the next patch. A release is a +pull request from `staging` to `main`: production deploys from `main`, which +is tagged as the next minor. Add a line under "Unreleased" in CHANGELOG.md +for anything a reader of the site, or of this repo, would notice. Do not add a link to the extension's source repository — it is private, and a test fails the build on any `github.com` link for that reason. diff --git a/README.md b/README.md index 7b69a4c..d96c6f7 100644 --- a/README.md +++ b/README.md @@ -4,23 +4,25 @@ > surface, and the home of the classroom tools Nyuchi Web Services builds for > schools. -[![Lint](https://github.com/nyuchi/learning/actions/workflows/lint.yml/badge.svg)](https://github.com/nyuchi/learning/actions/workflows/lint.yml) [![build](https://github.com/nyuchi/learning/actions/workflows/build.yml/badge.svg)](https://github.com/nyuchi/learning/actions/workflows/build.yml) ![Astro](https://img.shields.io/badge/Astro-7-BC52EE?style=flat-square&logo=astro&logoColor=white) ![Vercel](https://img.shields.io/badge/Vercel-deployed-000000?style=flat-square&logo=vercel&logoColor=white) -**Version:** the latest git tag (see [CHANGELOG.md](CHANGELOG.md)) | **Live:** [learning.nyuchi.com](https://learning.nyuchi.com) | **Default branch:** `main` | **Deploy:** Vercel +**Version:** the latest git tag (see [CHANGELOG.md](CHANGELOG.md)) | **Live:** [learning.nyuchi.com](https://learning.nyuchi.com) | **Work lands on:** `staging` | **Production:** `main` | **Deploy:** Vercel --- ## What it is -A small static Astro site with two pages: +A small static Astro site: -| Route | What it does | -| ------------------------------- | -------------------------------------------------------------------------------------------------------- | -| `/` | Points visitors at the three surfaces the learning content lives on, and at the tool they can use today. | -| `/toddle-enhancement-extension` | The product page for the Toddle Enhancement Extension. | +| Route | What it does | +| ---------------------------------------- | --------------------------------------------------------------------------------------------------------- | +| `/` | Points visitors at the three surfaces the learning content lives on, and at the tool they can use today. | +| `/toddle-enhancement-extension` | The product page for the Toddle Enhancement Extension. | +| `/toddle-enhancement-extension/releases` | The extension's release notes, for teachers, from `src/data/releases.ts`. | +| `/feedback`, `/feedback/thanks` | The feedback form, posted to Formspree. | +| `/legal/*`, `/accessibility` | Privacy, cookies, terms, data handling, student privacy, security and the rest, from `src/data/legal.ts`. | The redirect job has not gone away — the content split described in [CHANGELOG.md](CHANGELOG.md) still holds — but this domain is now also where @@ -54,8 +56,14 @@ every Nyuchi surface at once. ## Hosting -Vercel, from `main`. The site is fully static — every page is known at build -time, so nothing renders per request. +Vercel. The site is fully static — every page is known at build time, so +nothing renders per request. + +Work goes to `staging` by pull request, and each merge there is tagged as the +next patch (`.github/workflows/staging-version.yml`). A release is a pull +request from `staging` to `main`; production deploys from `main`, which is +tagged as the next minor with a GitHub release +(`.github/workflows/main-version.yml`). See [CHANGELOG.md](CHANGELOG.md). Response headers, including the CSP, are declared in `vercel.json`. @@ -63,54 +71,58 @@ Response headers, including the CSP, are declared in `vercel.json`. ```sh npm install -npm run dev # http://localhost:4321 — hot reload -npm run check # astro check (TypeScript across .astro files) -npm test # build, then vitest, then the CSP hash check -npm run qa # drive a real browser: accessibility + layout, 18 combinations -npm run format # prettier --write -npm run build # produces .vercel/output for the Vercel adapter -npm run preview # serve the built output locally +npm run dev # http://localhost:4321 — hot reload +npm run check # astro check (TypeScript across .astro files) +npm test # build, then the tests (vp test), then the CSP hash check +npm run lint # vp lint (oxlint, type-aware) +npm run format # prettier --write; format:check is what CI runs +npm run fmt # vp fmt; `vp fmt --check` is the org's vite-plus gate +npm run qa # drive a real browser: accessibility + layout, 18 combinations +npm run consent:probe # drive a real browser through the cookie banner +npm run csp:probe # check the Intercom messenger against the CSP in a browser +npm run build # produces .vercel/output for the Vercel adapter +npm run clean # remove build output, caches and finished branches ``` ## Security headers `vercel.json` carries a real Content-Security-Policy, not just the framing and -sniffing headers. `script-src` is `'self'` plus a hash for each inline script, -so nothing inline runs unless it was in the build that was reviewed. +sniffing headers. `script-src` is `'self'`, the hosts of the two third parties +(Google's analytics tag, after consent, and the Intercom messenger, when asked +for), and a hash for each inline script, so nothing inline runs unless it was +in the build that was reviewed. -There is exactly one inline script — the theme bootstrap, which has to run -before the first paint or the page flashes light before going dark. A hash -allowlist is only worth something if it cannot silently fall out of date, so -`npm test` verifies it: +The inline scripts are the theme bootstrap, which has to run before the first +paint or the page flashes light before going dark; the analytics consent check +in `Analytics.astro`, which loads Google's tag only after the visitor agrees; +and the small modules Astro inlines. A hash allowlist is only worth something +if it cannot silently fall out of date, so `npm test` verifies it: ```sh npm run csp # fail if a built inline script is not in the CSP npm run csp:fix # rewrite vercel.json with the current hashes, then review ``` -Edit the bootstrap without running `csp:fix` and CI fails with the missing -hash, rather than the theme quietly breaking in production. +Edit an inline script without running `csp:fix` and CI fails with the missing +hash, rather than the script quietly breaking in production. ## Notes on the toolchain -Vite+ was evaluated and cannot be adopted yet — npm cannot install it, and both -CI and Vercel install with npm. The evaluation, and the conditions for -revisiting it, are in [docs/toolchain.md](docs/toolchain.md). +npm, Astro 7, Tailwind 4, Vite+ (`vp test`, `vp lint`, `vp fmt`), Prettier. +More in [docs/toolchain.md](docs/toolchain.md). -- **Astro 7 with Tailwind 4.** `@bundu/ui@0.1.1` ships a Tailwind 3 style - config, which Tailwind 4 consumes through `@config` — that is what makes - `text-h2`, `ease-soft`, `max-w-narrow` and the mineral colours resolve, both - as utilities here and inside the kit's own `@apply` rules. Verified in the - build output: every component class, the full token set, and the `dark:` - variants are all present. +- **Astro 7 with Tailwind 4.** `@bundu/ui` ships a Tailwind 3 style preset, + which Tailwind 4 consumes through `@config` — that is what makes `text-h2`, + `ease-soft`, `max-w-narrow` and the mineral colours resolve, both as + utilities here and inside the kit's own `@apply` rules. - **`overrides` pins `path-to-regexp` to `^6.3.0`.** `@astrojs/vercel` pulls in a version with a high-severity advisory and there is no fixed release upstream; the override is within the same major, so it is a patch rather than a behaviour change. -- **`vitest` is pinned to `~4.0.18`.** `4.1.11` trips a resolver bug in npm - 10.9 (`Cannot read properties of null (reading 'edgesOut')`) and - `npm install` cannot complete at all. This is why the advisory on vitest is - reported rather than blocking in CI — see below. +- **Two formatters, one set of settings.** The org runs two required gates: + a lint gate that runs Prettier, and `vite-plus / check`, which runs + `vp fmt --check`. `.prettierrc` and the `fmt` block in `vite.config.ts` hold + the same settings, and `tests/toolchain.test.ts` fails if they drift. - **`*.astro` is in `.prettierignore`.** The org lint gate runs Prettier from `nyuchi/.github`, which has no `prettier-plugin-astro`, so it cannot parse those files. They are still type-checked by `npm run check`. @@ -136,28 +148,34 @@ CI splits the audit in two: | Audit what ships | `--omit=dev`, high and above | yes — expected to stay at zero | | Report advisories in build and test tooling | everything, moderate and above | no | -The split exists because of one specific, temporary situation: every published -vitest up to `4.1.10` carries an advisory, and `4.1.11` — the fix — cannot be -installed under npm 10.9. Blocking on it would pin the job red indefinitely, -which teaches people to ignore a red audit. Surfacing it in the log keeps it -visible without that cost. The second step goes back to blocking as soon as -vitest `4.1.11` installs cleanly. +The blocking step is `scripts/audit.mjs`: `npm audit --audit-level=high +--omit=dev`, with a dated list of advisories set aside by ID, each with the +reason it cannot reach a visitor and a date to recheck. Past that date the step +fails again. The second step keeps advisories in the tooling visible in the log +without pinning the job red over something that never ships. ## Repo layout ``` src/ - data/site.ts nav, footer, wordmark, extension links + data/site.ts nav, footer, wordmark, extension links, analytics id + data/legal.ts the facts the legal pages are built from + data/consent.ts consent categories, storage key, version + data/releases.ts the extension's release notes layouts/BaseLayout.astro head, theme bootstrap, header + footer - components/ Wordmark, SiteHeader, SiteFooter, MineralStrip - pages/index.astro the home page - pages/toddle-enhancement-extension.astro + components/ Wordmark, SiteHeader, SiteFooter, Analytics, + CookieBanner, SupportWidget + pages/ home, the extension and its releases, + feedback, accessibility, legal/* styles/global.css @bundu/ui imports and nothing else -scripts/check-csp.mjs CSP hash verification -tests/ links, branding, security +public/llms.txt the site, summarised for language models +scripts/ CSP check and fix, audit, browser QA and + probes, clean +tests/ links, branding, security, consent, SEO, + toolchain +docs/toolchain.md how the tools fit together ``` ## Licence -See [LICENSE](LICENSE) if present; otherwise all rights reserved by Nyuchi -Africa. +Proprietary. See [LICENSE](LICENSE). diff --git a/docs/toolchain.md b/docs/toolchain.md index 9e46aaf..0148765 100644 --- a/docs/toolchain.md +++ b/docs/toolchain.md @@ -1,71 +1,52 @@ # Toolchain -npm, Astro 7, Tailwind 4, Vitest, Prettier. Deploys to Vercel. - -## Why not Vite+ - -[Vite+](https://viteplus.dev) was evaluated on 2026-09-28 and **cannot be -adopted yet**. Not for lack of merit — it works well — but because it cannot -be installed by npm, and both CI and Vercel install with npm. - -### What was tried - -`vp migrate --no-interactive` on a copy of this repo, on Vite+ 1.0.0: - -| Step | Result | -| -------------------------------- | ----------------------------------------------------------- | -| `vp migrate` | clean; rewrote 5 files' imports, 2 configs, added git hooks | -| `vp install` | clean, **0 vulnerabilities** | -| `vp test run` | all 41 tests pass | -| `vp run build` | builds the site correctly | -| Oxfmt vs the org Prettier config | near-identical output; only `README.md` differed | - -So the tool itself is fine. The problem is upstream of it. - -### The blocker - -``` -$ npm install vite-plus@1.0.0 -npm error Cannot read properties of null (reading 'edgesOut') -``` - -npm 10.9.7 crashes resolving `vite-plus`'s dependency graph. This is the same -resolver bug that stops `vitest@4.1.11` installing here, which is why this repo -pins `vitest` to `~4.0.18`. - -Installing through Vite+'s own installer works, but produces a lockfile that -`npm ci` then rejects: - -``` -$ npm ci -npm error `npm ci` can only install packages when your package.json and -npm error package-lock.json are in sync. -npm error Missing: lightningcss-android-arm64@1.33.0 from lock file -``` - -The optional platform binaries are resolved differently by the two installers. - -### Why that is decisive - -**Vercel builds this site with npm.** If npm cannot install the tree, the site -stops deploying. Adopting Vite+ therefore means changing the install command in -CI _and_ in the Vercel project settings to run Vite+'s installer first — a -`curl … | bash` step in front of every build, and a change in a dashboard -outside this repo. - -The org lint gate is a second problem: it is a reusable workflow in -`nyuchi/.github` that this repo does not control, and `vp migrate` deletes -`.prettierrc` in favour of Oxfmt. That file has to stay for the gate to pass. - -### When to revisit - -- npm ships a fix for the `edgesOut` resolver crash — then `npm install -vite-plus` works and most of this goes away. Worth retesting on any npm - newer than 10.9.7; it could not be tested here. -- Or Vite+ publishes a lockfile that `npm ci` accepts. -- Or the estate moves off `npm ci` deliberately, for reasons bigger than this - repo. - -If you adopt it anyway, the safe shape is: Vite+ for `test`, `build` and `dev`; -**keep Prettier and `.prettierrc`** so the org gate still passes; and change -Vercel's install command in the same sitting, not afterwards. +npm, Astro 7, Tailwind 4, Vite+ and Prettier. Deploys to Vercel. + +## What runs what + +| Command | Tool | Where it runs | +| ----------------------- | ---------------------------------- | ---------------------------------- | +| `npm run check` | `astro check` | `build` workflow, `vite-plus` gate | +| `npm test` | `astro build`, `vp test run`, CSP | `build` workflow | +| `npm run lint` | `vp lint` (oxlint, type-aware) | locally | +| `npm run format:check` | Prettier with `.prettierrc` | the org lint gate | +| `npm run ci:check` | `astro check` and `vp fmt --check` | the org `vite-plus / check` gate | +| `npm run qa` | Playwright and axe-core, Chromium | locally, before a visual change | +| `npm run consent:probe` | Playwright, Chromium | locally, before a consent change | + +The `build` workflow is this repo's own (`.github/workflows/build.yml`). The +lint gate and the `vite-plus` gate are reusable workflows in `nyuchi/.github` +that this repo does not control. + +## Vite+ + +`vite-plus` 1.0 is a dev dependency and npm installs it; CI and Vercel both +install with `npm ci`. It replaced `vitest` (tests import from +`vite-plus/test`), and `vite.config.ts` carries the test settings, type-aware +linting and the org's format settings. Astro does not read `vite.config.ts`; +it builds with its own Vite config from `astro.config.mjs`, so nothing there +can change the site. + +`vp check` would run lint, format and type checks in one, but the +`vite-plus / check` gate runs the transitional `ci:check` instead, because +`vp check` reports one type error in `astro.config.mjs` (a `Date` assigned to +the sitemap's string `lastmod`). That is site config, left for its own change. + +## Two formatters + +Prettier and Vite+'s formatter (Oxfmt, through `vp fmt`) both format this +repo, because both org gates are required: the lint gate runs Prettier, and +the `vite-plus` gate runs `vp fmt --check`. Dropping either would fail a gate +this repo cannot change, so both stay, with one set of settings: + +- `.prettierrc` mirrors the org's Prettier config, and the lint gate needs it; +- the `fmt` block in `vite.config.ts` mirrors `nyuchi/.github`'s + `.oxfmtrc.json` (`vp fmt` reads only that block); +- `tests/toolchain.test.ts` fails if the two differ. + +Edit one, edit the other in the same change. If the org retires either gate, +remove that formatter's config and the test with it. + +`*.astro` stays in `.prettierignore`: the org lint gate runs Prettier without +`prettier-plugin-astro`, so it cannot parse those files. `npm run check` +type-checks them. diff --git a/package-lock.json b/package-lock.json index fff65c8..013476a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,8 +22,6 @@ "axe-core": "^4.12.1", "playwright": "^1.63.0", "prettier": "3.3.3", - "react": "^19.2.0", - "react-dom": "^19.2.0", "tailwindcss": "^4.1.18", "typescript": "^5.9.3", "vite-plus": "^1.0.0" @@ -5669,9 +5667,9 @@ } }, "node_modules/http-cache-semantics": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", - "integrity": "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.3.0.tgz", + "integrity": "sha512-M5t5LlJpS1UHMjvwRQVdFHvPISGeLAxNcrWuJkeGh0KxsqCHZ1O3NXZU/8x7cD0BDcGW8kapxMKTvwlqrNkHkA==", "license": "BSD-2-Clause" }, "node_modules/https-proxy-agent": { @@ -6847,9 +6845,9 @@ } }, "node_modules/postcss-selector-parser": { - "version": "6.0.10", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.10.tgz", - "integrity": "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==", + "version": "7.1.6", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.6.tgz", + "integrity": "sha512-7qASPzhKF2l2KLboRZux8CCTRMdGiV08vWmyKzPz22qZ7ZjQBOeY7rNzNoCLSUiftJ7HUq0GERHmxw/t0dCdMw==", "dev": true, "license": "MIT", "dependencies": { @@ -6959,29 +6957,6 @@ "integrity": "sha512-b484I/7b8rDEdSDKckSSBA8knMpcdsXudlE/LNL639wFoHKwLbEkQFZHWEYwDC0wa0FKUcCY+GAF73Z7wxNVFA==", "license": "MIT" }, - "node_modules/react": { - "version": "19.3.0", - "resolved": "https://registry.npmjs.org/react/-/react-19.3.0.tgz", - "integrity": "sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/react-dom": { - "version": "19.3.0", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.3.0.tgz", - "integrity": "sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "scheduler": "^0.28.0" - }, - "peerDependencies": { - "react": "^19.3.0" - } - }, "node_modules/react-is": { "version": "17.0.2", "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", @@ -7143,13 +7118,6 @@ "node": ">=11.0.0" } }, - "node_modules/scheduler": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.28.0.tgz", - "integrity": "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==", - "dev": true, - "license": "MIT" - }, "node_modules/semver": { "version": "7.8.5", "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", diff --git a/package.json b/package.json index b108052..86f767a 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,8 @@ "type": "module", "description": "learning.nyuchi.com — the Nyuchi Learning surface and the home of the Toddle Enhancement Extension.", "scripts": { + "clean": "node scripts/clean.mjs", + "prepare": "node scripts/install-hooks.mjs", "dev": "astro dev", "build": "astro build", "preview": "astro preview", @@ -37,14 +39,13 @@ "axe-core": "^4.12.1", "playwright": "^1.63.0", "prettier": "3.3.3", - "react": "^19.2.0", - "react-dom": "^19.2.0", "tailwindcss": "^4.1.18", "typescript": "^5.9.3", "vite-plus": "^1.0.0" }, "overrides": { - "path-to-regexp": "^6.3.0" + "path-to-regexp": "^6.3.0", + "postcss-selector-parser": "^7.1.6" }, "packageManager": "npm@11.12.1" } diff --git a/public/llms.txt b/public/llms.txt index d70c1d8..8254b81 100644 --- a/public/llms.txt +++ b/public/llms.txt @@ -1,8 +1,9 @@ # Nyuchi Learning -> learning.nyuchi.com — the Nyuchi Learning surface of Nyuchi Africa. Two -> things live here: signposts to where the learning content moved, and the -> classroom software Nyuchi Web Services builds for schools. +> learning.nyuchi.com — the Nyuchi Learning surface of Nyuchi Africa. It is +> the home of Nyuchi Learning's two products, the Toddle Enhancement +> Extension and Mukoko Lingo, and of signposts to where the learning content +> moved. Nyuchi Africa is a technology company in Zimbabwe building open-source and community-based platforms. Nyuchi Web Services is its development division. @@ -106,8 +107,10 @@ community-based platforms. Nyuchi Web Services is its development division. a copy. Schools deploy it from the Chrome Web Store by extension ID, with a Google Admin console force-install (or Group Policy, Intune or a macOS profile); an organisation key is pasted in once by each teacher, since it - cannot be pushed by policy. Teachers get each minor release (0.9.0 is - the current one) from the Chrome Web Store; versions 0.8.3 to 0.8.13 were + cannot be pushed by policy. Teachers get each minor release (0.11.0 is + the current one: 0.10.0 with a security fix to how licence keys are + checked, so that a cancelled key cannot be made to work again by + writing it differently) from the Chrome Web Store; versions 0.8.3 to 0.8.13 were test builds for schools piloting it, and their changes arrived together in 0.9.0. Release notes for every version, in plain words: @@ -123,6 +126,31 @@ community-based platforms. Nyuchi Web Services is its development division. https://support.nyuchi.com/en/collections/19764027-how-to-use-the-toddle-enhancement-extension Security: https://learning.nyuchi.com/legal/security +- [Mukoko Lingo](https://learning.nyuchi.com/mukoko-lingo): a partner app + developed by Nyuchi Learning. Part of Mukoko by Nyuchi. Language + training for schools, businesses and government, and for individual + learners: AI-assisted, skills-based language learning across Africa. Use it in a web browser at + https://lingo.mukoko.com; organisations and their admins use the console + at https://lingo.nyuchi.com/console. It teaches Shona, Ndebele, Chinese + and English, with Swahili in beta. A learner gets five phrases a day, drilled with + flash cards and a short quiz, with spaced repetition scheduling what comes + back and when. Shamwari ("friend" in Shona), the AI tutor, answers + questions in chat and adapts to the learner's measured proficiency rather + than a level they picked; Shamwari never claims to be human. Pronunciation + guides, cultural context, and progress tracking (streaks, XP, bookmarks, + skill proficiency). For schools, businesses and government: teachers and trainers create classes, set assignments + against specific phrases and skills, and see progress per student; + assessments are diagnostic, formative and summative, graded on the server; + rosters sync over OneRoster from Clever, ClassLink and PowerSchool; + organisation admins manage users, content, moderation, guardrails, + analytics and API keys. Moderation runs in three layers: rule-based + guardrails, an AI-based check, and prompt-injection detection on anything + a learner types to the tutor. Sign-in is WorkOS AuthKit; the tutor runs on + Cloudflare Workers AI, called from Lingo's servers. Starting is free. + Open source under the MIT licence, (c) Nyuchi Learning: + https://github.com/mukoko-dev/mukoko-lingo. Enquiries: + support@nyuchi.com. + ## Where the learning content went - [Bundu Education](https://bundu.org/education): open, evidence-based K-12 @@ -130,20 +158,18 @@ community-based platforms. Nyuchi Web Services is its development division. - [Nyuchi Learning programmes](https://nyuchi.com/learning): commercial cohort-based and self-paced training in software, AI and digital skills, localised for Africa with offline study packs. -- [Mukoko Lingo](https://mukoko.com/lingo): African language learning, free for - personal use. Shona and Ndebele primary. ## Legal -All dated 7 October 2026. Operated by Nyuchi Web Services, the development +All dated 13 October 2026. Operated by Nyuchi Web Services, the development division of Nyuchi Africa (Private) Limited, Harare, Zimbabwe. - [Privacy policy](https://learning.nyuchi.com/legal/privacy): what the extension and the site do with data. In short, nothing the extension reads leaves the browser; it sends only feedback a person chooses to send and, while a licence is entered, a data-free daily check for cancelled keys; this website - uses Google Analytics only with consent, and sets no cookies unless the - visitor agrees. Nyuchi Africa (Private) Limited is the controller for the + loads Google Analytics only after the visitor agrees to it, and sets no + cookies unless the visitor agrees. Nyuchi Africa (Private) Limited is the controller for the data it holds (purchases, feedback, support, site analytics), under Zimbabwe's Cyber and Data Protection Act [Chapter 12:07]; requests to privacy@nyuchi.com. diff --git a/scripts/audit.mjs b/scripts/audit.mjs index f8a7907..6fdab7f 100644 --- a/scripts/audit.mjs +++ b/scripts/audit.mjs @@ -18,21 +18,7 @@ const LEVEL = "high"; /* Every entry: the advisory, why it is safe here, when it was set aside and when it must be looked at again. Remove an entry as soon as a fix installs. */ -const IGNORED = [ - { - id: "GHSA-ch52-4w7c-c8xp", - package: "http-cache-semantics", - added: "2026-10-03", - recheck: "2026-11-03", - why: - "http-cache-semantics <=4.2.0 (no patched release) can serve another " + - "user's cached response through max-stale handling. astro 7.3.5 uses " + - "it only in assets/build/remote.js, which caches remote images during " + - "`astro build` (core/build/static-build -> generate). This site is " + - 'output: "static": no server code ships, no shared cache answers ' + - "users, so the vulnerable path never runs outside our own build.", - }, -]; +const IGNORED = []; let raw; try { diff --git a/scripts/clean.mjs b/scripts/clean.mjs new file mode 100644 index 0000000..82e4204 --- /dev/null +++ b/scripts/clean.mjs @@ -0,0 +1,251 @@ +#!/usr/bin/env node +/** + * Keeps this clone light: `npm run clean`, and run on its own after a pull + * request merges (the Claude Code hook in .claude/settings.json) and after + * every `git pull` that merges (.githooks/post-merge). + * + * The same script is in nyuchi/toddle-enhancement-extension, nyuchi/learning + * and nyuchi/nyuchi-licence-server; only GENERATED differs. It removes: + * + * 1. what the build, the tests and the tools generate (GENERATED below), + * and only where Git ignores it, so a tracked or new file is never + * touched; + * 2. Claude Code worktrees (.claude/worktrees/) with nothing uncommitted; + * one with changes is left, and named; + * 3. local branches that are done: everything on them is already in + * origin/main or origin/staging (merged, or squash-merged: merging the + * branch in would change nothing), or their remote branch was deleted + * after its pull request merged (GitHub deletes head branches on merge) + * while the local branch held nothing that had not been pushed. main, staging, the branch + * checked out and any branch in a worktree stay. + * + * --deep also removes node_modules and empties npm's cache (`npm ci` brings + * both back). --dry-run lists what it would remove. --no-fetch skips + * `git fetch --prune` (offline). --quiet prints the summary line only. + * --hook is for Claude Code's PostToolUse hook: it reads the hook's JSON on + * stdin and cleans the repository whose pull request was merged, when that + * clone sits next to this one. It never fails: a hook or a pull must not + * break on a cleanup. + */ +import { execFileSync, spawnSync } from "node:child_process"; +import { existsSync, readFileSync, rmSync } from "node:fs"; +import { basename, dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +/* this repository's generated paths, relative to its root */ +const GENERATED = [ + "dist", + ".astro", + ".vercel/output", + "qa-report.json", + "node_modules/.cache", + "node_modules/.vite", + "node_modules/.astro", +]; +const KEEP = new Set(["main", "staging"]); + +const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const args = new Set(process.argv.slice(2)); +const dry = args.has("--dry-run"); +const quiet = args.has("--quiet"); +const say = (line) => { + if (!quiet) console.log(`clean: ${line}`); +}; + +function git(...a) { + return execFileSync("git", a, { + cwd: root, + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + timeout: 60_000, + }).trim(); +} +function tryGit(...a) { + try { + return git(...a); + } catch { + return null; + } +} +const lines = (s) => (s ? s.split("\n").filter(Boolean) : []); + +/* --hook: clean the repository the merged pull request belongs to */ +function fromHook() { + let input = {}; + try { + input = JSON.parse(readFileSync(0, "utf8") || "{}"); + } catch { + /* no input: clean this clone */ + } + const out = JSON.stringify(input.tool_response ?? ""); + if (!/merged\\?"\s*:\s*true/.test(out)) return; // not merged: nothing to do + const repo = String(input.tool_input?.repo ?? ""); + const project = process.env.CLAUDE_PROJECT_DIR || root; + const candidates = [ + project, + join(project, repo), + join(dirname(project), repo), + join(dirname(root), repo), + ]; + const target = + candidates.find( + (d) => + repo && + basename(d) === repo && + existsSync(join(d, "scripts", "clean.mjs")), + ) ?? (repo ? null : root); + if (!target) return; // that repository is not cloned here + spawnSync(process.execPath, [join(target, "scripts", "clean.mjs")], { + cwd: target, + stdio: ["ignore", "inherit", "inherit"], + timeout: 120_000, + }); +} + +function sizeOf(path) { + const r = spawnSync("du", ["-sk", path], { encoding: "utf8" }); + return r.status === 0 ? Number(r.stdout.split("\t")[0]) * 1024 : 0; +} +const mb = (bytes) => `${(bytes / 1024 / 1024).toFixed(0)} MB`; + +function clean() { + if (tryGit("rev-parse", "--show-toplevel") === null) + return say("not a Git clone; nothing to do"); + let freed = 0; + const removed = []; + const left = []; + + /* 1. generated paths, only where Git ignores them */ + const paths = [...GENERATED]; + if (args.has("--deep")) paths.push("node_modules"); + for (const rel of paths) { + const abs = join(root, rel); + if (!existsSync(abs)) continue; + if (tryGit("check-ignore", "-q", rel) === null) { + left.push(`${rel} (not ignored by Git)`); + continue; + } + freed += sizeOf(abs); + if (!dry) rmSync(abs, { recursive: true, force: true }); + removed.push(rel); + } + + /* 2. Claude Code worktrees with nothing uncommitted */ + const inWorktree = new Set(); + const trees = lines(tryGit("worktree", "list", "--porcelain")).reduce( + (all, l) => { + if (l.startsWith("worktree ")) all.push({ path: l.slice(9) }); + else if (l.startsWith("branch ") && all.length) + all[all.length - 1].branch = l.slice(7).replace("refs/heads/", ""); + return all; + }, + [], + ); + for (const t of trees) { + const ours = t.path.includes(join(".claude", "worktrees")); + if (!ours) { + if (t.branch) inWorktree.add(t.branch); + continue; + } + const dirty = spawnSync("git", ["status", "--porcelain"], { + cwd: t.path, + encoding: "utf8", + }); + if (dirty.status !== 0 || dirty.stdout.trim()) { + if (t.branch) inWorktree.add(t.branch); + left.push(`${t.path} (uncommitted changes)`); + continue; + } + freed += sizeOf(t.path); + if (!dry) tryGit("worktree", "remove", t.path); + removed.push(`worktree ${basename(t.path)}`); + } + if (!dry) tryGit("worktree", "prune"); + + /* 3. local branches that are done */ + const before = new Map( + lines( + tryGit( + "for-each-ref", + "--format=%(refname:strip=3) %(objectname)", + "refs/remotes/origin", + ), + ).map((l) => l.split(" ")), + ); + if (!args.has("--no-fetch")) + tryGit( + "fetch", + "--quiet", + "--prune", + "origin", + "+refs/heads/*:refs/remotes/origin/*", + ); + const after = new Set( + lines( + tryGit( + "for-each-ref", + "--format=%(refname:strip=3)", + "refs/remotes/origin", + ), + ), + ); + const current = tryGit("branch", "--show-current"); + const bases = ["origin/main", "origin/staging"].filter( + (b) => tryGit("rev-parse", "--verify", "-q", b) !== null, + ); + const isAncestor = (a, b) => + tryGit("merge-base", "--is-ancestor", a, b) !== null; + /* nothing to bring in: merging the branch into the base changes nothing, + which holds for a squash merge too */ + const alreadyIn = (a, b) => { + const tree = tryGit("merge-tree", "--write-tree", b, a); + return tree !== null && tree === tryGit("rev-parse", `${b}^{tree}`); + }; + for (const l of lines( + tryGit( + "for-each-ref", + "--format=%(refname:short) %(upstream:short) %(upstream:track)", + "refs/heads", + ), + )) { + const [branch, upstream = "", ...track] = l.split(" "); + if (KEEP.has(branch) || branch === current || inWorktree.has(branch)) + continue; + const remote = upstream.replace(/^origin\//, ""); + const pushedThenDeleted = + upstream.startsWith("origin/") && + before.has(remote) && + !after.has(remote) && + isAncestor(branch, before.get(remote)); + const merged = bases.some( + (b) => isAncestor(branch, b) || alreadyIn(branch, b), + ); + if (pushedThenDeleted || merged) { + if (!dry) tryGit("branch", "-D", branch); + removed.push(`branch ${branch}`); + } else if (track.join(" ").includes("gone")) { + left.push( + `branch ${branch} (its remote branch is gone; check it holds nothing unpushed)`, + ); + } + } + + if (args.has("--deep") && !dry) { + spawnSync("npm", ["cache", "clean", "--force"], { stdio: "ignore" }); + removed.push("npm cache"); + } + + const verb = dry ? "would remove" : "removed"; + console.log( + `clean (${basename(root)}): ${removed.length ? `${verb} ${removed.join(", ")}` : "nothing to remove"}${freed ? `, ${mb(freed)}` : ""}`, + ); + for (const l of left) say(`left ${l}`); +} + +try { + if (args.has("--hook")) fromHook(); + else clean(); +} catch (e) { + console.log(`clean: stopped (${e?.message ?? e}); nothing else was changed`); +} +process.exit(0); diff --git a/scripts/install-hooks.mjs b/scripts/install-hooks.mjs new file mode 100644 index 0000000..3609c1f --- /dev/null +++ b/scripts/install-hooks.mjs @@ -0,0 +1,82 @@ +#!/usr/bin/env node +/** + * Turns on this repository's Git hooks (`.githooks/`) in this clone. + * `npm ci` and `npm install` run it, as `prepare`. + * + * All it does is set `core.hooksPath` to `.githooks` in this clone's own + * `.git/config`: it enables hooks and bypasses nothing. Because the hooks + * are tracked files, Git runs the version on the branch you have + * checked out, as `npm test` and `npm ci` already run that branch's code: + * check out a branch you don't trust only in a clone you'd run its tests + * in (CONTRIBUTING.md). It changes nothing, + * and says so, where that would silence hooks a developer already has: + * `core.hooksPath` set at any level (a global secret scanner, say), or a + * hook of their own in `.git/hooks/`. Outside a Git clone of this + * repository (a packed copy, or no git at all) it does nothing, and it + * never fails an install. + * + * The one hook, .githooks/post-merge, runs scripts/clean.mjs after a + * `git pull` that merges. + */ +import { execFileSync } from "node:child_process"; +import { existsSync, readdirSync, realpathSync } from "node:fs"; +import { dirname, isAbsolute, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const HOOKS = ".githooks"; + +const git = (...args) => + execFileSync("git", args, { + cwd: root, + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + }).trim(); +const quiet = (fn) => { + try { + return fn(); + } catch { + return ""; + } +}; +const note = (text) => + console.log(`git hooks: ${text} This repository's hooks are in ${HOOKS}/; + to use them: git config core.hooksPath ${HOOKS} (CONTRIBUTING.md).`); + +function install() { + const top = quiet(() => git("rev-parse", "--show-toplevel")); + /* not a Git clone, no git, or a clone of something else around us + (native realpath: the case on disk, so the two spellings agree) */ + if (!top || realpathSync.native(top) !== realpathSync.native(root)) return; + + /* core.hooksPath from any level: system, global or this clone */ + const current = quiet(() => git("config", "--get", "core.hooksPath")); + if (current === HOOKS) return; + if (current) return note(`core.hooksPath is ${current}; left as it is.`); + + /* hooks of a developer's own in .git/hooks/ (Git's *.sample files are + not hooks) */ + const dir = quiet(() => git("rev-parse", "--git-path", "hooks")); + const hooksDir = dir && (isAbsolute(dir) ? dir : join(root, dir)); + const own = + hooksDir && existsSync(hooksDir) + ? readdirSync(hooksDir).filter((n) => !n.endsWith(".sample")) + : []; + if (own.length) + return note( + `${hooksDir} has hooks of its own (${own.join(", ")}); left as it is.`, + ); + + try { + git("config", "--local", "core.hooksPath", HOOKS); + console.log(`git hooks: on (${HOOKS}/, see CONTRIBUTING.md)`); + } catch { + note("could not set core.hooksPath (is .git/config writable?)."); + } +} + +try { + install(); +} catch { + /* an install never fails on an optional hook */ +} diff --git a/scripts/probe-consent.mjs b/scripts/probe-consent.mjs index 533c2e0..2c635f2 100644 --- a/scripts/probe-consent.mjs +++ b/scripts/probe-consent.mjs @@ -9,9 +9,11 @@ * reason. Nothing about the page would look broken; the cookie would simply * start appearing, and the policy would quietly become false. * - * So this drives it: load the page, assert no analytics cookie; decline, assert - * still none and the banner is gone; reload, assert it stays gone and still no - * cookie; then accept in a fresh context and assert the cookie appears. + * So this drives it: load the page, assert no analytics cookie and no request to + * Google Analytics at all; decline, assert still neither and the banner is gone; + * reload, assert it stays gone and still nothing; then accept in a fresh context + * and assert Google's tag is requested and the cookie appears. A record from an + * older banner version is planted too, and must load nothing. * * npm run consent:probe */ @@ -33,6 +35,21 @@ const browser = await chromium.launch({ executablePath: findChromium() }); const failures = []; const notes = []; +/* Google Analytics' own hosts. Google Fonts is a different host and is not + covered by the analytics choice, so it is not counted here. */ +const ANALYTICS_HOST = + /(^|\.)(googletagmanager|google-analytics)\.com$|(^|\.)analytics\.google\.com$/; + +/** Every request this page makes to Google Analytics, recorded as it happens. */ +function watchAnalytics(page) { + const seen = []; + page.on("request", (request) => { + const host = new URL(request.url()).host; + if (ANALYTICS_HOST.test(host)) seen.push(request.url()); + }); + return seen; +} + async function analyticsCookies(context) { return (await context.cookies()) .map((cookie) => cookie.name) @@ -51,10 +68,16 @@ function check(label, condition, detail = "") { { const context = await browser.newContext(); const page = await context.newPage(); + const requests = watchAnalytics(page); await page.goto(PAGE, { waitUntil: "networkidle" }); const banner = page.locator(".consent"); check("banner is shown on a first visit", await banner.isVisible()); + check( + "no request to Google Analytics before a choice", + requests.length === 0, + requests.join(", "), + ); /* Equal prominence is a requirement, not a preference: a reject button that is smaller or quieter than accept is the pattern regulators have been fining. @@ -138,6 +161,11 @@ function check(label, condition, detail = "") { cookies.length === 0, cookies.join(", "), ); + check( + "no request to Google Analytics after rejecting, across a reload", + requests.length === 0, + requests.join(", "), + ); /* Withdrawal must be as easy as giving it. */ const reopen = page.locator("#cookie-choices"); @@ -190,11 +218,38 @@ function check(label, condition, detail = "") { await context.close(); } +/* --- a yes given to an older banner version --- */ +{ + const context = await browser.newContext(); + await context.addInitScript(() => { + localStorage.setItem( + "nyuchi-consent", + JSON.stringify({ v: 0, at: "2026-01-01T00:00:00.000Z", analytics: true }), + ); + localStorage.setItem("analytics-consent", "granted"); + }); + const page = await context.newPage(); + const requests = watchAnalytics(page); + await page.goto(PAGE, { waitUntil: "networkidle" }); + await page.waitForTimeout(1000); + check( + "an older version's yes, or the first banner's key, loads nothing", + requests.length === 0, + requests.join(", "), + ); + check( + "and the banner asks again", + await page.locator(".consent").isVisible(), + ); + await context.close(); +} + /* --- accepting, in a clean profile --- */ let acceptPath = "verified"; { const context = await browser.newContext(); const page = await context.newPage(); + const requests = watchAnalytics(page); await page.goto(PAGE, { waitUntil: "networkidle" }); await page.locator('[data-consent-action="accept"]').click(); @@ -211,6 +266,14 @@ let acceptPath = "verified"; ); const cookies = await analyticsCookies(context); + /* The request is made whether or not this browser can complete it, so this + holds even where the bundle itself cannot arrive. */ + check( + "accepting requests Google's tag", + requests.some((url) => url.includes("googletagmanager.com/gtag/js")), + requests.join(", ") || "no request", + ); + if (cookies.length > 0) { notes.push("ok accepting does set the analytics cookie"); notes.push(` cookies after accept: ${cookies.join(", ")}`); @@ -251,8 +314,9 @@ if (acceptPath === "unverifiable") { "measurement. Google's tag never loaded in this browser", "(ERR_CERT_AUTHORITY_INVALID — this sandbox does not trust the local TLS", "proxy, and it fails Google Fonts the same way), so there was no GA to set", - "a cookie either way. The promise the privacy policy makes — no cookie", - "before consent — is proven above and does not depend on it.", + "a cookie either way. The promise the privacy policy makes — no request", + "to Google Analytics and no cookie before consent — is proven above and", + "does not depend on it.", "", "Re-run against the deployed site to close that gap:", " QA_BASE_URL=https://learning.nyuchi.com node scripts/probe-consent.mjs", diff --git a/scripts/qa.mjs b/scripts/qa.mjs index 0d1d273..3782b5d 100644 --- a/scripts/qa.mjs +++ b/scripts/qa.mjs @@ -67,8 +67,10 @@ for (const scheme of ["light", "dark"]) { * and Google Analytics are there because a commit put them there; anything * else arriving is the regression this exists to catch. * - * Analytics loading eagerly is not a licence for the messenger to, which - * is why these are two assertions rather than one "no third parties". */ + * Google Analytics loads only after consent, which this run never gives; + * scripts/probe-consent.mjs is what asserts that. Either way, analytics + * is not a licence for the messenger to load, which is why these are two + * assertions rather than one "no third parties". */ const APPROVED = /(^|\.)(googleapis|gstatic|googletagmanager|google-analytics)\.com$|(^|\.)analytics\.google\.com$/; const unapproved = []; diff --git a/src/assets/lingo/icon.png b/src/assets/lingo/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..006d0e943927a2fc431fce97be7539a91a5554dd GIT binary patch literal 49079 zcmYhjcRbba8$bRM4W&3J?W1ESp<$#@osu0wM)b}myNo!FqB%R8&yid?wEtP}&kMD;d=%DHyv@Qg(fq$|= z`}Tmpu&2ZWz+dbxcZ}R2=l~D=hXwkOa1?_0AyxGCdtM1kW8Mk+R^bM& zlGlFDi&)PZpxi#+^JeIQh-?9<$+MM^hGivFNiE2N9-D}I%XG;QNSoGR(W zw+OdUwOoGP@e0uFiFXY5dr7%|5MHz>w0$LF#V{^VsZVg5B|}|{vsY;2$8h;6bRYid zvzgzlvQ#=0v7!=}R(FdA?FLIeUVAL>!Z~s6=qS%`A6C?xyX1I9>U;AZq$M|AFk;=| zhRXyCJgw6~PKxL+``dDB=&F$Nv~gm_Q;ymW^=I0<72JCq29&;lNsvoGV(72hBMH4j zmhZ3KrjlRb=*3QW6^;6;Zb9EJL7FZt0KRl+PN+JERU6?$(=1PLFLn08{fU1-7noPF z=Ekd$SXsdg|D!7gv4OI`o}BjkL?&?Qtg9blc&!jdo*Pkay1-jNTiRFCA!>&mWK+Xbd5=Wx@5A6gaVP0l%tTO(dB z4${x#FAL9W|JR}jf2RA-<@i8Z`HH#14MlWeUWDfhRWF)Gy?Jh(9&>r4WvF;%xClfHV*#ic3z6x_Z|I>iC+aij8?@|Ru1yNAo2(b@eHbE6ZD$yVF& z$j2FTHXvvA+|G-XW)`*gy%`JKo0t9X{mC94b)my zFXxvJU&Dj`xV3@Uz?gK*i94=sT55=P-Rv6pS|R08uPtGR&8^|`m%OB+#?uzzbPI6Q zdFOR_lxuE_rTV$>h=MUyCM@NCJtJCmGq+Fn2|V^qp}JhNhmk}kpl+qNsUV%h@p{6O zvJ1|m@TmXB>Icmpl7Sls&L{gYFarmVbNFxfMxv6nXh6%`;b(618wb2p;0s zJFC&Nn2VvR8UsbVAF;wX^5P%@9{F{en|?Qfa4Hj{phhqP!us8UfA|FROCK>p)k5DL zCU^t5EW7-FU68$wD|) zK4SfCpR6!Eq+B7i?U?a);}&7$zFNAamQBREdcQ0R9`ZlIZB{w|$FsjSF?}F%#qV*r zUk#;^Nr=n4!H3~^!f<(MUpP0NhjM}`r{7!yN}QeNLt7BQ;+ZZi*#%Lr5&HN7w`N*EdUiXohGSKz@|tOAl`rgOcP^ZtPW*`RIkh^yFJfi2 zPwCvwGRM#rw*Vj0D`-`s4vCX_7yg#Vfx#?sF8*P3A+uO@^Zf!6=sLm`9W7~IX;ZYl zgpFx2Z*mF`dh6MmpzmYZJwml??cqKqlYS@ZnZ@UIdWWw(F{5x{YB$91#_boO>IH1t z$YhR5XQl8Y(SQN$8P31A;FN@RZLRTs^_;ZJgi%9*0shJkNsfF+714K=^3# z5w8+PmRt?rQ8jTXm1Cyn$Nw#UxN7d&)*q()bWFme|&YVaJ1DyrE$S(HyS74-bMl~ zb(c40_=ug;k*KA>7cqEQO&Sr1ki}We!O3Kq!W~C|F~#dN=RJ^eme96v#Cn|#>VXZ> zGGb+aKjSwz*GG%;lk<%F3t|OM)FMBE5$0wDhuCjXre zuwgBMKPl*?^;%i|7gv!$`}2oSvLMvL=iOa~&o1a;z3Ak@i(%)0b)_KrApcN>RL@$D zc-=IaDy;DEeN(Y($oN!xIk^6WYH^LUuPEZriGO=Yu*gk50a7HZSffmi>{q=P|MhW< zFP$8;fOUW%jSAk~8|`6YlS+Iql+HrXCrjryb9uu8u34W7jrPoaj)}-`)nin#g3qHS z56Xmzif^1Cw!368RKSPO!Hd#2_8c@3dU{ZkyWC?bmp#er7aiQa)PE0&aaGg4BXSSQ zJWF6l!T?$p*lxwC74vkYRcLu=aM$??t7^&|3*@ge_gUJ=B1F0~dgb&WLtq!gb=t$O z`cuS#QexFj{ihEsRs?@a$IRtmfzLmx$?p8o6%GjE0}XTOdjSjC{z$c$oA~&cyxe~H zjf2z$yg{nak4Rm>0-qIms{fkdp6U`)`^70)u=R+cjtFg!j+13)y;5#a*LFkSN=^_xcox3adE}^WR*@KYA*iH| zh*x@_PP_2?O^4NLzmIS(wiAM4$)PKraasb}(w`*e8P{ncY>={oLb_oR5U-;e4^`qm z|L^{$3XXoFSAzSyKs?6Ndc|k%%Mudecgm-ct`1ce+@{vbd3+wyJwIOSnM0Z5g#26b zjz>iKM6*9&XEbnayF!pHpAGq#KxJ{c-T2<7E7YGaF{^(9lLY$9_X6pez3O~J-)*x= zv%cWKcjO+(|J{`f;v)Bx@~gjIr_y8ac59t#^V`2TNRGfaMI0k#y!A|C*HmQ?&upeb zP^b>yxU|k}!B8%yPW`pl@rQq>Z){CmPVFlP%<17fv-`u;5v4vv?B$SnO*%An0L_*re=-G|%ft@ib&Uy$Se)n^(q^9t=2H*(nXK zT02Gc7OCVqgAgnBiK^F+C(iT;`YjN}WHZiy=%HB8=lvr)r6%zqbMYbVPQCGHAjR{~ z_Vb6W9cz$;stx-Yz)q#b)!2>d8|PveYIbL_euchyz?>0)#Pu3@kgi2tsq+)Q$Y6z1 zrg<<;t%0``N{NPItb~FrP6Ov(c;G9g9K%-ZH9-JCVC+`zVSJ`2ore4`{rxSWLaFZY9)3-p)ab2=Vp~g79VocUvX^*ut0ZIH_SVu>%-3ysM`Cl_dng6wCC@ys7&CC zYt?0TZ_FMmcBUhzJTsFjcmUjX+J^G+I%p+&o)G}N&BPxC;nsME4f}Cc?v_6! z>Wuh?hQYW91QnKxK9FVbf$Ly}{QsX4-5(CAFSndv*Wlg<-8rk~mHe2>2eA_k7Z_ZS z^3qWH?*T{g!P-HAE0*yd_tXYeY_f^skblLhUs$jtWU#=v2|=!;?+&+jtA8mhLRMZ< zoiG{<(HRGEgzJ=q!&C>h(`KE404rMk+>(~g=l0ho3#fUAD=QeKll|BrTv+E1Ne$Wo z|1?G{1?VN0!raCw5gtBbU+rrFdv|vOLKy5`G0D52pMjW{N+c0zTYrH80^`R|mZEvK z?Tw9o^!V{6#tL#l4~Nz3&6ECgFjk8D_i{qG_mbn6#qR6A7tL`zmmis@0?(PSF|Z*f z48W2}yo;2B_*b>Qp4FybcouL;x((ToHkfgw28Ry3ethM|o41^G7@s;7`nRNrsSjhhbn%P4lP<481Z zb^!XO8h>7X+;IyiGSQC2!U~~g-U7v!9bE3O9YpPStUdx;sPGkR@2VsOC-3l-$^WP7 z!dn9Ns?14_Q7h3UOS24WLGmZX`;fAw@Rs=5|H7401{&e-^a`65S8kW~8n0~DVN2-` zS)hFF)wzo;NS$G`13>o&K`!mLD7f8bvby`*#Rb;gkX%b`)njLa*y#{aTET8#Dn5IG zDhGY@Y;iVOr8BCzIC!CnSjjPY)gyT$nV4iW?~s=SFwn*VlU-M~JFsP*396e9 z8U|YeN<7}dU&#>SHv8Oi+^2o=G^6ewCKQ5fU_}}#%QU9?;Y6b)#=e->`Y7dsfOPxE zO09QA69d_UG;isBopa`<+AuEm{Qxt=w$(nUFGvHdEL4?t}J; z1Fr11Asy5NhN33H_h}Nv6~r6D_~swiSib0E{ULv;M}VA_^31&8Ex?;qN%n%3yRr=A zJ>*G<|H+c%8|<-~>mQbQr_s}BXUDTqNRHS~ejwl9Y^}ELwVM{(8xO7cP(YLzs2cLT z*lN)Ue9)~_Rb6d5#gC^i1mvUUz3F?YyCyQv96btx7saP*a&cd5Gs!gv5b1V){%3Zd zm?>H6UT8W3`H$0!sueh8SSH>su(Co~SMr5`dE4>1fO8=IH zpx3#a`=C%n%;+^EylIso)$axFgYEucArbS$Og~drXw&ZgeyMq4AIQr1Pc2ndkx${nX{Ey(2hqHrKv=z7 z{57yiseTDy=I2t{w;d^e)&cMQ@%_>Ve6itjcda&m%D!?Bhe}H&d2O24UTDSSyMs)k znfyR3u!Ma`aA^`+OY*vY$yHj%k9&_MT^WM1 z7o=Uvc%$ywjUV9B;mF0>_(4>mG;$%Zb9Kq;?^UPQ_kRb|K3ihF<6!|KDIvlhkLXF% zU@w}}D)vDuoDyNZy|ICV+8BPw|A+$p4dR0B@3YyToqK9$MNrSQFGdp&&7!&$wRS<< zjOdxMN9HVE_nC8*md%@|=xE+QOQwQ1_sg`tE>g)xUSxsjbwb-Lje^_bxxm@F+)w`M zo$wBXGt{q1xU2?(gwmJC6JbW@PV9q(Ik@S>cdE|{a^Q(~uP$q&^UMDdFsmKOn)*sf zyZnQ$5rwF1-;UCrLlB*(n98Gb&)(_Cu`88#6@ABSNfzQzY=zop>JlI1-oJMjw8>`; zZY-;;E=AEx;-iJE8Sst0%hf~V!~RwjRu(AXiGI?yqUVDz`7#KdNRy)f>U>E_EelN# zASu`ImFVA+&_EWT6_@y}jZWV}IJJ9`z^(Bzy=MSnBR?w@J?-Wev>i zz`qN2szKzZa)>bF1|Ns%#!S8}WC+VQ^!PIiZu|fj2iR+7I(Kb4TXsP06xa{3d!|b= z+TB_7u970Fe^5S3fqf#MIc*YmPH8jUVXpQ#!NC!g@=Jdwpz9QT4u!Vk5*r0+J@7mf z5jOrq;v2T+<~)d(wN@w8XBKIhS;%tmRn_`&n`9`<0J#rB{{~@;r3?O<-DCTK+6-Zo zo3JoD8>CdjZ5Bn_^-%}hAJ=3PO`SYwZ?3|FkUtT@w#z^HG#PJ>;exoXn^Wg`<2PP8VbUzb`nSzEfl>vM59RpsQo%u zLE+u6Een(dPP*RP7@gSV?s=?@f>>nPb1UJ{>E1>MZr1%B~#4RXtSoeu!Gk%4Qep~Z$ZiLQ<<3Lii{>tmpPO?I`<0&8) z5rlJ*XvcH?wOT@`6pj)*m2}@V8@>Ln%h1D1+29C~iBD-qy)7CI2o8EERI3ZfsofZF zou#kyLqQVo!MUr-QGce}~;&Z|A#1c$pQ zl&-y}(PvX>mte})K1jm{K21sSa$UkH(@g|Ir`o~ki1&GHz{wGdKuRS$m~a=KkV=<4 z7a{D^pn^uPYpKgF#b~de%JmE01x46_{rx?D6`BNBUn#sxJsyF>-T08mnX#B!+}C9Y z(o0pqiO~N*WZJBh!E0x30@%=8%!#{3YBV-rhxo(N0i&D0#56hsu@pq1UcQeDi1;$CNS$Sd}JJGD=FXuB7NiXW!bh6Y~5Yj_8urhg85;oCvKm}r6>gA);X%> zkbX#qzETs5ziJeGt*p1(Cuvv|N)3DY>3g?%OmAP8JM>WZ6Z5Og!`>D|rQi(i>F#)v zeAXQm@aL#l>ryW%-~`{Szm{Bgoo2ch$|z-i&&8N*%QVA^@Y1r(JgxuVARM@mNoN2t zUuLbtPWY7r7X5!K-Fo!Bzx#YZqtWxG>>tM`8W!f{AgOHlYAt`=q6yz&sw8}pk6&L2 zSCR+U`nntexjT2$b~@qwO#i{*!S`tNvRk;6>iH1cQ4FIfj||$2U`_bJU;%H`$9DV zhr=Bj{Mn!Yo5{Ye2OxdF1qA5hx7sE<QsXll5+iOf%s{`;@x1lt5IU~ z6Xv}t8hs|G(&&XqLrIqokiF~h(!aQK=5QJfvdfYJ`5@e#N>Herr^RnA%n3tMaVda+ zeJy8MKq_Nz?gL~@x2ny4W@JN4UkSa5I@dgk zH&CDqSWs?kswtl;VB&)sy-!qu~!KS zI?UV^6i<_?6&>G_NkDJYD;qPckFE5$k=8gs87BiKs}i;z`lauO2XoQr$XBhoC#K&# zckk-qv^wVO2QenPL;HI9v3$1%Uo`#{R zq0wTOEVi8B+gO<6hNM2gQWZQI1))&o$92Diwa}q`n;_iQT^$+`Zf>o=KyWYy4A%hp zv)hKddVklk{~5N4ERgV8f^{jOuoRy6arsKA+y!aGGHGfOXF;PU?YCxinT6-Mz5lbO zIDa_wA8;=CRXZz$dV|>wA{bS}ne1df=v!bItTZ+Pb;fcr5eZLL}fsz*<4zel}Nq_QnnvZ%qbMS?K?B}73)JCi2~g%td-(kj z!ND0YJZJHEfJn+xgwAl21tg^dEB?2-6>z48!rhDHmfhv=43+kP2;~YmFt@!FsDkZa z0U&ytCox%ggnZAzJc0*ddWK;&4O@t9@wEp$G1kzrN`A^tA)_^9C>F2$3tEfEAOp?YTODUI6KUqX z&~yXx$J#RK&}?$5=2k`%On{Xh-CW;AnnFU~uvjqmXz6E?zzoPBr^Bm6+A^I^^Nm*9 zg|K(KY!z6y;a9H^sJl>ayg#Kfq1yM8c>N85%w9okt9OpBZ zz5TKi{0O$B!g|NK(YyR>155p1B|&OpIQ~nJv!)sqi@Qg?4`(M@7)Y7pcU&mpO7mx< zq7F0DgNdf1e4vt!*>>Rr+Nbl?q&WSmmzySaa}SuV4WuiSjj)vZbAxwlI04pL#$lSKu0g3U|xoRHOWo5Yf)Lq}u4<>Er8 zo~1Dw8A>Kb_l3v*D&3-u@0T5t45dewD_V?*AZ``B*sU=A4!^a~B#EAip-=y~CdTv; zEwzAUt(f1gowEG*EYUFrSD$#TK$=}*xHX^Kbp*+$`k$(PW0}5!VNWj40i$5-{my&vT??6VS%vRkUax4u`JHw)8;MY zFWTuiq%Rb6P3{5Bi){nTUr5|^urf4Tpok9M^q1!aw&YMvoyS`7fq=NsAKPsmz4WgW zh7SmJe4}gsPBgV{U-l`LwLt0qP3|pcGk0-7zMjaBDCmrsTa~>7+9ZKzs=h+%qO5c* zP~{1K_2r~dCpime{g=fYm`4sHA; z6wt|sZw?ll+bbp4or$fuOC|1S#v=S|eo_x#<$;cnYcpM;&P$|@{*k0r=TQPD^6;?? z%G}N0o6>WK7Mi#!7OUeMTi0j0pIzy(uKV2ys=SnOJ}Cl71^ibPI&X^bsf`wC%X4_y z$rt}YrEf_uy=x^M?8fN65>=b�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 B%3uHh literal 0 HcmV?d00001 diff --git a/src/components/Analytics.astro b/src/components/Analytics.astro index d9a4b2e..5a91f10 100644 --- a/src/components/Analytics.astro +++ b/src/components/Analytics.astro @@ -1,30 +1,28 @@ --- /** - * Google Analytics 4, behind Google Consent Mode v2. + * Google Analytics 4 — not loaded at all until the visitor agrees to it. * - * The order of the three scripts below is the whole design, and it is fragile in - * one direction only: the consent defaults must be set BEFORE gtag/js loads. Set - * them after and there is a window in which GA has already written a cookie, the - * banner is still asking, and the privacy policy is wrong. That is why this is - * one `is:inline` block emitted ahead of the loader rather than something tidier, - * and why tests/security.test.ts asserts the ordering. + * What a visitor gets before they choose is nothing from Google Analytics: no + * request to Google, no cookie, no tag. This page defines a local `gtag()` queue + * and a loader function; the loader is what fetches Google's tag, and it runs + * only when analytics consent exists. Two things call it: * - * What a visitor gets before they choose: + * - this script, on page load, when a stored consent record says yes AND was + * written against the current banner (CONSENT_VERSION). A record from an + * older banner, the first banner's LEGACY_CONSENT_KEY, or anything that + * does not parse counts as no answer — which means no Google; + * - CookieBanner.astro, the moment someone agrees. * - * analytics_storage: denied → no GA cookie, nothing kept on the device - * ad_storage, ad_user_data, ad_personalization: denied + * This used to load the tag on every page behind Consent Mode v2 set to denied. + * That kept the "no cookies until you agree" promise but not the stronger one: a + * denied tag still sends Google a cookieless ping. The privacy policy now makes + * the stronger promise, and tests/security.test.ts and scripts/probe-consent.mjs + * hold it to it. * - * Consent Mode v2 requires the three ad_* signals to be declared even though - * this site runs no advertising, so they are declared and denied permanently — - * the banner never grants them, because there is nothing here to grant them for. - * Only `analytics_storage` is ever flipped, by CookieBanner.astro. - * - * An honest caveat, because a data protection lead will find it: with consent - * denied, GA still loads and still sends Google a cookieless ping. That is how - * Consent Mode is designed to work, and it means "no cookies until you agree" is - * exactly true while "no contact with Google until you agree" is not. If you need - * the stronger claim, do not render this component until consent exists; you lose - * Consent Mode's modelling and gain a cleaner sentence. + * Consent Mode is still declared, and still before the loader can run: the + * defaults deny everything, and only `analytics_storage` is ever granted. The + * three ad_* signals are declared because Consent Mode v2 requires them and are + * denied permanently — this site runs no advertising. * * Nothing here is in the extension, which sends nothing it reads anywhere * (its only outside connections are feedback a teacher chooses to send and a @@ -32,57 +30,59 @@ * the marketing site measuring the marketing site. */ import { analytics } from "../data/site"; -import { CONSENT_KEY, LEGACY_CONSENT_KEY } from "../data/consent"; +import { CONSENT_KEY, CONSENT_VERSION } from "../data/consent"; --- { analytics.measurementId && ( - <> -