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 0000000..006d0e9 Binary files /dev/null and b/src/assets/lingo/icon.png differ 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 && ( - <> -