Cannabis Freedom Index

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;
 }

    ↑↓ to move, ↵ to open.