Changelog 7c8affetree
Speed up agent reviews with selected-page builds and history caching
12 files of code, design or docs +202 −32 lines
Code, design and documents 12 files
.github/workflows/deploy.yml +2 −5
@@ -32,9 +32,5 @@ jobs: uses: actions/configure-pages@v6 - run: npm ci - # Every test (npm test): each profile's quick answers ("Can I carry it?") against its own text - # and no sentence known to be false, the facts earlier issues corrected, research coverage, - # chart wording, the survey's safety. A failure stops the deploy instead of going live. - # (`npm run build` runs them again first, as everywhere: a few seconds.) - - run: npm test + # npm run build runs the full test suite through prebuild; a failure stops deployment. # The social preview images (one per page) take minutes to draw and rarely change. Each is # kept under a hash of its own content, so restoring the folder redraws only what is new. @@ -45,4 +41,5 @@ jobs: .cache/changelog .cache/progress + node_modules/.astro key: og-${{ hashFiles('src/og/**', 'package-lock.json') }}-${{ github.run_id }} restore-keys: |
AGENTS.md +8 −2
@@ -14,6 +14,12 @@ cannabis law (`changes.yaml`, `articles/`) and a cultural atlas (`culture/`), bu - Others work here at the same time: check `git status` first, don't touch files with someone else's uncommitted changes, and commit only your own files. -- Don't start dev or preview servers; check with `npm run build` (one build at a time) and the - HTML in `dist/`. For a small frontend change, `npm run build:quick` skips the preview images. +- Don't start dev or preview servers. For country/content work and local frontend review, use + `node scripts/build-pages.mjs country/<id> history/<entry-id> culture/<section>/<slug>` with + the URL prefixes you changed, then inspect the HTML in `.cache/page-review/`. This runs all + tests, validates all collections/citations and renders the selected pages. See `docs/BUILDS.md`. +- Use `npm run build` for a complete site and `npm run seo:audit` for site-wide links/SEO + (required before deployment, or when changing routing/shared links). Full HTML is in `dist/`. + `npm run build:quick` builds all pages without social cards. Builds lock automatically; + if another build is running, wait and retry. Never delete another process's lock. - Both builds run `npm test` first (answers, profile claims, history, charts, research, survey; a few seconds) and stop if a test fails. Run `npm test` alone for a quick check. A new test file
astro.config.mjs +3 −2
@@ -3,4 +3,5 @@ import { unified } from "@astrojs/markdown-remark"; import icon from "astro-icon"; import { markTerms } from "./src/lib/terms.js"; +import { buildLock } from "./scripts/build-lock.mjs"; // Fully static output: one HTML file per page, including one per country. @@ -139,5 +140,5 @@ export default defineConfig({ // Icons are Iconoir (https://iconoir.com/), through astro-icon and Iconify: `<Icon name="iconoir:…" />` // inlines the SVG at build time, so only the icons a page uses end up in it. - integrations: [icon()], + integrations: [buildLock(), icon()], markdown: { processor: unified({ remarkPlugins: [markdownFigures, markdownCitations], rehypePlugins: [markdownLinks, markdownTerms] }), @@ -149,5 +150,5 @@ export default defineConfig({ // until it is restarted. vite: { - cacheDir: process.argv.includes("build") ? "node_modules/.vite-build" : "node_modules/.vite", + cacheDir: process.argv.includes("build") || process.argv[1]?.endsWith("build-pages.mjs") ? "node_modules/.vite-build" : "node_modules/.vite", server: { watch: { ignored: ["**/tmp/**"] } }, },
docs/BUILDS.md +34 −0
@@ -0,0 +1,34 @@ +# Fast local checks + +Most edits do not need to render the entire site. Choose the check that covers the change: + +| Work | Command | Output | +| --- | --- | --- | +| Test assertions while editing | `npm test` | Test results | +| Country, article, atlas entry or a few frontend pages | `node scripts/build-pages.mjs country/de history/germany-cannabis-act-2024` | `.cache/page-review/` | +| Shared layout or broad frontend work | `npm run build:quick` | `dist/`, without social cards | +| Complete site, routing/link changes or deployment | `npm run build && npm run seo:audit` | Complete `dist/` | + +The review command takes **URL prefixes**, without the domain. `country/us` includes its state +pages; `history` includes the whole history; `/` selects only the homepage. Add every page or +section you need to inspect. A prefix that matches nothing fails instead of silently passing. +For a history edit, include both the country and the entry; for an atlas entry, include its +`culture/<section>/<slug>` path. It runs `npm test` once, then Astro's normal build pipeline: +all content collections, schemas and Markdown citations are synced, and all route data is +evaluated, but only selected pages are rendered. It therefore catches global data/reference +errors as well as rendering errors on the selected pages. + +Review output is a **partial site** in `.cache/page-review/`. Inspect its HTML directly; +links to unselected pages need not exist there. It does not replace a full build for routing, +shared link changes or site-wide SEO checks. It never empties or overwrites `dist/`, and CI +rejects the review command. Each review build replaces the previous review output. + +All builds take an automatic process lock, because Astro's generated content and caches are +shared even with different output folders. If it reports another build, wait for that process +to finish and retry. A lock left by a terminated process is recovered automatically. + +Keep `.cache/`: +commit statistics, changelog diffs, research counts, social cards and map posters are cached +there by their inputs. Branch changes and new commits update the history caches; poster inputs +include its template, renderer, fonts, data and dependency lockfile. Removing caches is safe +but makes the next build slower.
docs/DATA-MAINTENANCE.md +8 −4
@@ -43,6 +43,8 @@ driving rules, access for visitors and arrest figures (see "Driving, visitors an | `scripts/score-triage.cjs` | Context entries whose category says the law changed (ban, tightening, easing, …): each still to be scored or marked `unscored` (see "The score timeline must make sense") | -Run everything from the project root. `npm run build` validates all data; run it after every -batch. The dev server (`npm run dev`) does **not** pick up edits to `changes.yaml` or the schema +Run everything from the project root. After each batch, use `node scripts/build-pages.mjs` +with the changed country/history/atlas URL prefixes; it runs all tests and validates all data +while rendering only the pages you review. See `docs/BUILDS.md` for full-build requirements. +The dev server (`npm run dev`) does **not** pick up edits to `changes.yaml` or the schema until it is restarted. @@ -144,5 +146,6 @@ by the ECB, cite the alternative exchange-rate source in the observation note. duplicate. - Never mix street and medical observations when deciding which value is newest. -- Run `npm run build` after editing. The schema rejects invalid markets, dates and values. +- Run `node scripts/build-pages.mjs country/<id> data/prices.json api/<id>.json` after editing + (add affected ranking/map URL prefixes). The schema rejects invalid markets, dates and values. - Spot-check `/data/prices.json`, `/api/<country>.json`, the country page, rankings and both price map modes. @@ -590,5 +593,6 @@ one-line texts in the four original sections (`products`, `production`, `medical `title: "ABDA — Die Apotheke: Zahlen, Daten, Fakten 2026"`. - Texts use `>-` folded blocks; a line inside must not start with `- ` or it becomes a list. -- After editing, run `npm run build`; the schema rejects unknown sections, uncited sources and +- After editing, run `node scripts/build-pages.mjs country/<id>` (add changed history/atlas URL + prefixes); the schema rejects unknown sections, uncited sources and malformed penalty steps.
docs/EXPAND-COUNTRIES.md +4 −2
@@ -31,7 +31,9 @@ For each country in the batch: 3. Work through `docs/EXPAND-COUNTRY.md` in its order: current law and penalty tables, history, articles, context sections, culture, links. -4. Build (`npm run build`, which runs `npm test` first; wait if another build is running), run +4. Build (`node scripts/build-pages.mjs country/<id> history/<changed-entry-id>`, adding changed + atlas URL prefixes; this runs all tests and content validation; wait if another build holds + the lock), run `node scripts/profile-audit.cjs <id>` (no errors allowed), then read the built page in - `dist/country/<id>/`. + `.cache/page-review/country/<id>/`. See `docs/BUILDS.md` for when a full build is needed. 5. **Commit that country before starting the next**, with only your own files, and a message that names the country and what changed ("Morocco: penalty tables, hemp and industry sections, 4
docs/EXPAND-COUNTRY.md +9 −8
@@ -16,9 +16,8 @@ atlas entry. `git log --oneline -5` first. Don't edit files that someone else has uncommitted changes in, and never revert or "fix" their half-finished work unless you were asked to. -- **Don't start dev or preview servers.** Check your work with `npm run build` and by reading the - built HTML in `dist/`. -- **One build at a time.** If another `astro build` is running (`pgrep -f "astro build"`), wait - for it to finish; two builds at once delete each other's files and fail with "Cannot find - module … dist/.prerender". +- **Don't start dev or preview servers.** Use the selected-page build in `docs/BUILDS.md` and + read the built HTML in `.cache/page-review/`. +- **One build at a time.** Builds lock automatically. If another build holds the lock, wait + for it to finish and retry; never delete its lock. ## 1. See what is missing @@ -128,11 +127,13 @@ covers well need not be repeated. `node scripts/country-gaps.cjs us/ca` is a sta ```bash -npm run build # must pass; runs npm test first (answers, claims, history), then checks schemas, citations and links +node scripts/build-pages.mjs country/ke history/<changed-entry-id> # use your country's and changed entries' URL prefixes; runs all tests and content validation npm run profiles:audit # must report no errors for your country (node scripts/profile-audit.cjs <id>) -npm run seo:audit # what the deploy runs after the build node scripts/country-gaps.cjs ke # the list should be shorter ``` -- Look at the built page in `dist/country/<id>/index.html` for the sections, tables and links. +- Include changed atlas entries' `culture/<section>/<slug>` paths in the review build too. +- Look at `.cache/page-review/country/<id>/index.html` and the changed entries for the sections, + tables and links. For routing or shared-link changes, run `npm run build && npm run seo:audit`; + routine country work does not need a full build. See `docs/BUILDS.md`. - Set the profile's `updated:` to today. - **Commit only your own files**, with a message that says what changed for the country. Leave
docs/SOLVE-ISSUES.md +6 −5
@@ -9,5 +9,5 @@ agent or person; the data rules live in `docs/DATA-MAINTENANCE.md` and `docs/WRI - Run `git status` and `git log --oneline -5`. Others work here at the same time: don't edit files with someone else's uncommitted changes, and commit only your own lines. -- Don't start dev or preview servers. One `npm run build` at a time (`pgrep -f "astro build"`). +- Don't start dev or preview servers. Builds lock automatically; wait and retry if one is running. ## 1. Pick the issues @@ -60,10 +60,11 @@ overreach (a proposed new score, a "binding" law that is only a draft). ```bash node scripts/profile-audit.cjs <id> # no errors -npm test # answers, profile claims, history, charts (the build runs it too) -npm run build # npm test, then schemas, citations, links -npm run seo:audit +node scripts/build-pages.mjs country/<id> history/<changed-entry-id> # all tests/content validation, then selected pages; omit history if unchanged ``` -Read the built page in `dist/country/<id>/index.html` and confirm the corrected wording is there +For routing or shared-link changes, also run `npm run build && npm run seo:audit`. See +`docs/BUILDS.md`; routine data fixes need only the selected-page build. + +Read the built page in `.cache/page-review/country/<id>/index.html` and confirm the corrected wording is there and the old one is gone. If an issue shows a check the tests could have caught, add a test for it in `scripts/profile-claims.test.mjs`.
scripts/build-lock.mjs +37 −0
@@ -0,0 +1,37 @@ +// All Astro builds share generated content and caches, even with different outDirs. +import { mkdirSync, openSync, closeSync, readFileSync, writeFileSync, unlinkSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +export function buildLock() { + return { + name: "one-build-at-a-time", + hooks: { + "astro:config:setup": ({ command, config }) => { + if (command !== "build") return; + const dir = path.join(fileURLToPath(config.root), ".cache"); + mkdirSync(dir, { recursive: true }); + const file = path.join(dir, "build.lock"); + const acquire = () => { + const fd = openSync(file, "wx"); + writeFileSync(fd, String(process.pid)); + closeSync(fd); + }; + try { acquire(); } catch (error) { + if (error.code !== "EEXIST") throw error; + const pid = Number(readFileSync(file, "utf8")); + let running = true; + if (pid > 0) { + try { process.kill(pid, 0); } catch (e) { if (e.code === "ESRCH") running = false; } + } + if (running) throw new Error(`Another build holds ${file} (PID ${pid || "starting"}). Wait for it to finish, then retry.`); + unlinkSync(file); + acquire(); + } + process.once("exit", () => { + try { if (readFileSync(file, "utf8") === String(process.pid)) unlinkSync(file); } catch {} + }); + }, + }, + }; +}
scripts/build-pages.mjs +51 −0
@@ -0,0 +1,51 @@ +// A real Astro build of selected URL prefixes, with the full tests and content sync. +// node scripts/build-pages.mjs country/de history/germany-cannabis-act-2024 +// Output is deliberately separate from the deployable dist/ tree. +import { spawnSync } from "node:child_process"; +import { build } from "astro"; + +const prefixes = process.argv.slice(2).map((s) => `/${s.replace(/^\/+|\/+$/g, "")}`); +if (!prefixes.length || prefixes.some((s) => /[?#*]|\.\./.test(s))) { + console.error("Usage: node scripts/build-pages.mjs <URL prefix> [more prefixes]\nExample: node scripts/build-pages.mjs country/de history/germany-cannabis-act-2024"); + process.exit(1); +} +if (process.env.CI) throw new Error("Selected-page builds are for local review. CI must build the complete site."); +const test = spawnSync("npm", ["test"], { stdio: "inherit" }); +if (test.status !== 0) process.exit(test.status ?? 1); + +const base = (process.env.BASE_PATH || "").replace(/^\/+|\/+$/g, ""); +await build({ + outDir: "./.cache/page-review/", + integrations: [{ + name: "selected-page-review", + hooks: { + "astro:build:start": ({ setPrerenderer }) => { + setPrerenderer((renderer) => ({ + ...renderer, + async getStaticPaths() { + const result = await renderer.getStaticPaths(); + const all = Array.isArray(result) ? result : result.paths; + const matched = new Set(); + const paths = all.filter(({ pathname }) => { + const url = `/${pathname.replace(/^\/+|\/+$/g, "")}`; + let keep = false; + for (const prefix of prefixes) { + const target = base ? `/${base}${prefix === "/" ? "" : prefix}` : prefix; + if (url === target || (prefix !== "/" && url.startsWith(`${target}/`))) { + matched.add(prefix); + keep = true; + } + } + return keep; + }); + const missing = prefixes.filter((p) => !matched.has(p)); + if (missing.length) throw new Error(`No pages matched: ${missing.join(", ")}`); + console.log(`Reviewing ${paths.length} of ${all.length} outputs.`); + return Array.isArray(result) ? paths : { ...result, paths }; + }, + })); + }, + }, + }], +}); +console.log("Review HTML: .cache/page-review/ (partial site; use npm run build for deployment and site-wide link/SEO checks).");
src/lib/gitlog.js +24 −2
@@ -170,8 +170,24 @@ function workOut(c) { // `coauthors` are the names in the commit's Co-Authored-By lines, without their addresses function readLog() { + // Listing commit IDs is cheap; recomputing --numstat over the entire history is not + // (even when all the detailed diffs below are cached). Fetch only missing commits. + // Enumerate IDs every time so branch switches and deepened shallow clones stay correct. + const refs = (git("log", "--format=%H %P") ?? "").trim().split("\n").filter(Boolean).map((line) => line.split(" ")); + const hashes = refs.map(([hash]) => hash); + const parents = new Map(refs.map(([hash, parent]) => [hash, parent || null])); + const cached = new Map(); + for (const hash of hashes) { + try { + const c = JSON.parse(readFileSync(path.join(CACHE, `log-1-${hash}.json`), "utf8")); + // A former shallow boundary acquires a parent when the checkout is deepened. + if (c.hash === hash && c.parent === parents.get(hash)) cached.set(hash, c); + } catch {} + } + const missing = hashes.filter((hash) => !cached.has(hash)); + if (!missing.length) return hashes.map((hash) => cached.get(hash)); const SEP = "\u001e", END = "\u001f"; - const out = git("log", "--no-renames", "--numstat", `--format=${END}%H${SEP}%P${SEP}%aI${SEP}%an${SEP}%s${SEP}%b${SEP}`); + const out = git("log", "--no-walk=unsorted", "--no-renames", "--numstat", `--format=${END}%H${SEP}%P${SEP}%aI${SEP}%an${SEP}%s${SEP}%b${SEP}`, ...missing, "--"); if (!out) return []; - return out.split(END).filter(Boolean).map((chunk) => { + const fresh = out.split(END).filter(Boolean).map((chunk) => { const [hash, parents, date, author, subject, body, stat] = chunk.split(SEP); const coauthors = [...new Set([...(body ?? "").matchAll(/^Co-Authored-By:\s*(.+?)\s*(?:<[^>]*>)?\s*$/gim)].map((m) => m[1]))]; @@ -186,4 +202,10 @@ function readLog() { }; }); + mkdirSync(CACHE, { recursive: true }); + for (const c of fresh) { + cached.set(c.hash, c); + writeFileSync(path.join(CACHE, `log-1-${c.hash}.json`), JSON.stringify(c)); + } + return hashes.map((hash) => cached.get(hash)); }
src/og/render-poster.js +16 −2
@@ -2,5 +2,6 @@ // social previews but at sizes of their own, from src/og/poster.astro. Kept apart from // render.js because every image that file draws is cached under a hash of the file itself. -import { readFileSync } from "node:fs"; +import { readFileSync, existsSync, mkdirSync, writeFileSync } from "node:fs"; +import { createHash } from "node:crypto"; import path from "node:path"; import { satoriAstro } from "x-satori/astro"; @@ -12,4 +13,10 @@ const fonts = [ ["Instrument Serif", "instrument-serif", 400], ["Inter", "inter", 400], ["Inter", "inter", 600], ["JetBrains Mono", "jetbrains-mono", 400], ["JetBrains Mono", "jetbrains-mono", 500], ].flatMap(([name, pkg, weight]) => ["latin", "latin-ext"].map((set) => ({ name, data: font(pkg, `${pkg}-${set}-${weight}-normal.woff`), weight, style: "normal" }))); +const CACHE = path.join(ROOT, ".cache", "og"); +const rendererHash = createHash("sha256") + .update(readFileSync(path.join(ROOT, "src", "og", "render-poster.js"))) + .update(readFileSync(path.join(ROOT, "package-lock.json"))); +for (const f of fonts) rendererHash.update(f.data); +const rendererKey = rendererHash.digest("hex"); // The map goes through the template as this one-pixel stand-in and is put into the finished SVG @@ -24,5 +31,12 @@ const plain = (v, key) => key === "flag" ? v : typeof v === "string" ? v.replace export async function renderPoster(props, map, width, height) { const source = readFileSync(path.join(ROOT, "src", "og", "poster.astro"), "utf8"); + const key = createHash("sha256").update(rendererKey).update(source) + .update(JSON.stringify({ props, map, width, height })).digest("hex"); + const file = path.join(CACHE, `poster-${key}.png`); + if (existsSync(file)) return readFileSync(file); const svg = (await satoriAstro({ width, height, fonts, props: { ...plain(props), map: STAND_IN } }, source)).replaceAll(STAND_IN, map); - return new Resvg(svg, { fitTo: { mode: "width", value: width } }).render().asPng(); + const png = new Resvg(svg, { fitTo: { mode: "width", value: width } }).render().asPng(); + mkdirSync(CACHE, { recursive: true }); + writeFileSync(file, png); + return png; }