Cannabis Freedom Index

Changelog 0856c30tree, with Claude Opus 5.5

Term layer: explain cannabis words and jargon in every text; build fixes

- Words for cannabis with a Culture/Language entry get a dashed link to it, and jargon from the new glossary.yaml a dashed underline with its explanation on hover or tap. Marked at build time (src/lib/terms.js) in profile sections, history entries, articles and atlas entries, once per page (src/middleware.js); source texts are unchanged. - Glossary starts with 30 words, including rongony, prensado and chira; Diamba gains yamba and Kif gains kef. - Penalty steps accept a minimum sentence without a maximum ("At least 5 years"). - Changelog pages 2 and on get sitemap entries, share images and their own descriptions, so the SEO audit passes. - Grenada's recreational section cited an undefined source; Wikipedia is added.

1 country profile edited2 atlas entries edited13 files of code, design or docs +322 −14 lines

Countries and states 1 file

Grenada countries/gd.yaml

sources

Wikipedia — Cannabis in Grenada 11 unchanged

Articles and atlas entries 2

Diamba culture/language/diamba.md

also

liamba, riamba, yamba

Kif culture/language/kif.md

also

kief, keef, kef

Code, design and documents 13 files

astro.config.mjs +30 −1
@@ -2,4 +2,5 @@ import { defineConfig } from "astro/config";
 import { unified } from "@astrojs/markdown-remark";
 import icon from "astro-icon";
+import { markTerms } from "./src/lib/terms.js";
 
 // Fully static output: one HTML file per page, including one per country.
@@ -31,4 +32,32 @@ function markdownLinks() {
 }
 
+// Words for cannabis and jargon in articles and culture entries, marked once per text as the page
+// is built (src/lib/terms.js): a word with a Culture/Language entry links to it, a glossary word gets
+// a dashed underline and its explanation on hover. Links, headings, code and citations are left alone,
+// and an atlas entry's own word is not marked on its own page.
+function markdownTerms() {
+  const SKIP = new Set(["a", "code", "pre", "sup", "h1", "h2", "h3", "h4", "h5", "h6", "figcaption", "button"]);
+  return (tree, file) => {
+    const src = String(file?.path ?? file?.history?.[0] ?? "").replace(/\\/g, "/");
+    const m = src.match(/culture\/(language|traditions|people)\/([^/]+)\.md$/);
+    const here = m ? `culture/${m[1]}/${m[2]}/` : "";
+    const seen = new Set();
+    const walk = (node) => {
+      if (node.type === "element" && SKIP.has(node.tagName)) return;
+      if (!node.children) return;
+      node.children = node.children.flatMap((child) => {
+        if (child.type !== "text") { walk(child); return [child]; }
+        const parts = markTerms(child.value, seen, here);
+        if (parts.length === 1 && !parts[0].term) return [child];
+        return parts.map((w) => !w.term ? { type: "text", value: w.t }
+          : w.term.kind === "language"
+            ? { type: "element", tagName: "a", properties: { href: base + w.term.url, className: ["term", "term-l"] }, children: [{ type: "text", value: w.t }] }
+            : { type: "element", tagName: "span", properties: { className: ["term", "term-g"], tabIndex: 0, role: "button", dataTitle: w.term.title, dataText: w.term.text }, children: [{ type: "text", value: w.t }] });
+      });
+    };
+    walk(tree);
+  };
+}
+
 // A picture in Markdown whose title has the form "Caption | Credit | https://link-to-file" becomes
 // a figure with that caption and credit underneath. The image itself stays a Markdown image, so
@@ -112,5 +141,5 @@ export default defineConfig({
   integrations: [icon()],
   markdown: {
-    processor: unified({ remarkPlugins: [markdownFigures, markdownCitations], rehypePlugins: [markdownLinks] }),
+    processor: unified({ remarkPlugins: [markdownFigures, markdownCitations], rehypePlugins: [markdownLinks, markdownTerms] }),
   },
   // pictures in Markdown get a set of widths, so a phone does not download the 1280-pixel file

docs/DATA-MAINTENANCE.md +20 −1
@@ -432,4 +432,23 @@ Rules:
 - When you revise any section, look for names, bodies and dated changes that could be linked.
 
+### Words explained automatically
+
+Don't link or explain words for cannabis or common jargon by hand. When the pages are built, the
+first appearance on a page of a word with a Culture/Language entry (bhang, dagga, ganja) becomes a
+dashed link to that entry, and the first appearance of a word from `glossary.yaml`
+(decriminalised, per se limit, Single Convention) gets a dashed underline with its explanation on
+hover or tap (`src/lib/terms.js`). This covers profile sections, history entries, articles and
+atlas entries; the source texts stay as they are.
+
+- To explain a new word, add it to `glossary.yaml` with its other forms in `also` and one or two
+  plain sentences. A new language entry in `culture/language/` is picked up by itself, with its
+  `also` forms.
+- Everyday words that would light up in unrelated sentences (pot, grass, joint, "Sensi" in Sensi
+  Seeds) are listed in `SKIP_ENTRIES` and `SKIP_FORMS` in `src/lib/terms.js`; add a form there
+  when it marks the wrong thing.
+- Locally, Astro keeps rendered Markdown in `.astro/data-store.json`; after changing the glossary
+  or the skip lists, delete that file (and `node_modules/.astro/data-store.json`) so articles and
+  atlas entries are marked again. The deploy builds from scratch.
+
 ### Country spotlights
 
@@ -522,5 +541,5 @@ segments:
         term: Minor offence (overtreding)   # the law's own word, shown but never compared
         prison: 1 month         # the maximum: days, weeks, months or years
-        prison_min: 5 days      # a minimum sentence, where the law sets one (needs prison)
+        prison_min: 5 days      # a minimum sentence, where the law sets one; alone it reads "at least"
         fine: 5500              # the maximum, in `currency`
       - label: Growing as a business      # a step that is not about an amount

glossary.yaml +99 −0
@@ -0,0 +1,99 @@
+# Words the site uses that a reader may not know. When the pages are built, the first time one of
+# them appears in a text (a profile section, a history entry, an article, an atlas entry) it gets a
+# dashed underline and this explanation on hover, focus or tap (src/lib/terms.js). The source texts
+# are not changed.
+#   id:   the word as it is usually written; `also` lists other forms that should match
+#   text: one or two plain sentences, no citations: what the word means on this site
+# Words for cannabis itself belong in Culture (culture/language/), not here; those are underlined
+# too and lead to their atlas entry.
+
+# Local words for cannabis that have no atlas entry yet (a language entry in culture/language/
+# takes over a word once it is written, and the word should then be removed from here)
+- id: rongony
+  text: The Malagasy word for cannabis, used in Madagascar for the plant and for what is smoked.
+- id: prensado
+  text: Spanish for "pressed". Herbal cannabis compressed into hard blocks for transport, typical of the cannabis grown in Paraguay and sold across South America.
+- id: chira
+  text: A North African name for hashish, used in Morocco and in the colonial drug laws of France and Belgium.
+
+- id: decriminalised
+  also: [decriminalized, decriminalisation, decriminalization, decriminalise, decriminalize]
+  text: Still against the law, but no longer a crime. Having a small amount brings at most a fine or a warning, and no criminal record.
+- id: depenalised
+  also: [depenalized, depenalisation, depenalization]
+  text: Still a crime on paper, but in practice no punishment is imposed for small amounts.
+- id: toleration policy
+  also: [tolerated, gedoogbeleid]
+  text: Illegal, but the authorities have decided not to prosecute, as with the Dutch coffeeshops.
+- id: administrative offence
+  also: [administrative offense, administrative offences, Ordnungswidrigkeit, přestupek]
+  text: A breach handled by an authority instead of a criminal court, usually with a fine and no criminal record.
+- id: misdemeanour
+  also: [misdemeanor, misdemeanours, misdemeanors]
+  text: A minor crime, below a felony. It can bring a short jail term and leaves a criminal record.
+- id: felony
+  also: [felonies]
+  text: A serious crime, usually one that can bring more than a year in prison.
+- id: trafficking
+  also: [drug trafficking]
+  text: In drug law, supplying drugs to anyone else, sold or given away, not only smuggling them across borders.
+- id: presumption of trafficking
+  also: [presumed trafficking, presumed to be trafficking]
+  text: A rule that treats anyone holding more than a set amount as a dealer, unless they prove otherwise.
+- id: mandatory minimum
+  also: [mandatory minimums, minimum sentence, minimum sentences]
+  text: A prison term the judge must impose at least, whatever the circumstances of the case.
+- id: caning
+  also: [strokes of the cane]
+  text: Corporal punishment with a rattan cane, still imposed for drug crimes in Singapore and Malaysia.
+- id: fixed penalty notice
+  also: [fixed penalty, fixed-penalty notice]
+  text: An on-the-spot fine, like a parking ticket, paid without going to court.
+- id: diversion
+  also: [diverted, diversion programme, diversion program]
+  text: Sending someone to treatment, education or a warning instead of to court.
+- id: expungement
+  also: [expunged, expunge, record clearing, records cleared]
+  text: Removing an old conviction from a person's criminal record.
+- id: Single Convention
+  also: [Single Convention on Narcotic Drugs]
+  text: The United Nations treaty of 1961 that obliges countries to control cannabis and to limit it to medical and scientific use.
+- id: Schedule I
+  also: [Schedule IV, Schedule 1]
+  text: The strictest classes in a drug law's lists. Schedule I in the United States is for drugs said to have no accepted medical use; Schedule IV of the 1961 UN treaty was for drugs considered especially dangerous.
+- id: THC
+  also: [tetrahydrocannabinol, delta-9-THC, Δ9-THC]
+  text: Tetrahydrocannabinol, the main substance in cannabis that makes people high.
+- id: HHC
+  also: [hexahydrocannabinol]
+  text: Hexahydrocannabinol, a semi-synthetic cannabinoid usually made from CBD and sold as a legal high, until many countries banned it.
+- id: cannabinoid
+  also: [cannabinoids]
+  text: A substance that acts on the body's cannabinoid receptors, such as THC or CBD.
+- id: endocannabinoid
+  also: [endocannabinoids, endocannabinoid system]
+  text: A cannabinoid the body makes itself, such as anandamide.
+- id: per se limit
+  also: [per-se limit, fixed THC limit, fixed limit]
+  text: A driving rule under which a set level of THC in the blood is an offence by itself, whether or not the driver is impaired.
+- id: ng/ml
+  also: [ng/mL, nanograms per millilitre]
+  text: Nanograms per millilitre, the unit for THC in blood or serum. A nanogram is a billionth of a gram.
+- id: edibles
+  also: [edible]
+  text: Food or sweets made with cannabis, such as cookies, chocolate or gummies.
+- id: concentrates
+  also: [concentrate, cannabis concentrates]
+  text: Strong extracts of cannabis, such as oil, wax or shatter, with far more THC than the flower.
+- id: cultivation association
+  also: [cultivation associations, growing association, growing associations, Anbauvereinigung]
+  text: A non-profit club whose members grow cannabis together and share the harvest only among themselves.
+- id: regulated market
+  also: [regulated cannabis market, legal market]
+  text: A legal market in which the state licenses growers and sellers and sets rules on products, prices or buyers.
+- id: home growing
+  also: [home grow, home cultivation]
+  text: Growing a few cannabis plants at home for one's own use, legal in some countries within a set number of plants.
+- id: telemedicine
+  also: [telemedicine platforms, online clinics]
+  text: Doctors' consultations held online, through which some countries' patients get cannabis prescriptions without a visit.

src/components/ChangelogPage.astro +1 −1
@@ -24,5 +24,5 @@ const first = (n - 1) * PAGE_SIZE + 1, last = Math.min(n * PAGE_SIZE, total);
 const row = (c) => ({ c, url: `${base}changelog/${c.short}/`, sum: summaryOf(c) });
 ---
-<Base title={n === 1 ? "Changelog" : `Changelog, page ${n}`} description="Every change made to the Cannabis Freedom Index, its data and its texts, newest first, each with exactly what changed.">
+<Base title={n === 1 ? "Changelog" : `Changelog, page ${n}`} description={n === 1 ? "Every change made to the Cannabis Freedom Index, its data and its texts, newest first, each with exactly what changed." : `Changes to the Cannabis Freedom Index, page ${n} of ${pages}: commits ${first} to ${last}, from ${when(items.at(-1).date)} to ${when(items[0].date)}, each with exactly what changed.`}>
   <main class="changes-page gl">
     <section class="hero hero-sm">

src/components/Cited.astro +9 −1
@@ -3,7 +3,13 @@
 // `parts` comes from citeParts(). The text is plain apart from Markdown links, [name](url): a full
 // URL opens in a new tab, and one starting with "/" is a page of this site and gets the base path.
+// Words for cannabis and jargon are marked as the page is built (src/lib/terms.js), once per page:
+// the set of words already marked lives in Astro.locals for the page being rendered.
 import { linkParts } from "../lib/cite.js";
+import { markTerms } from "../lib/terms.js";
 const { parts } = Astro.props;
 const base = import.meta.env.BASE_URL.replace(/\/?$/, "/");
+const seen = (Astro.locals.termsSeen ??= new Set());
+const here = Astro.url.pathname.startsWith(base) ? Astro.url.pathname.slice(base.length) : "";
+const words = (t) => markTerms(t, seen, here);
 ---
 {parts.map((p) => p.refs
@@ -13,3 +19,5 @@ const base = import.meta.env.BASE_URL.replace(/\/?$/, "/");
       ? <a href={base + x.href.slice(1)}>{x.t}</a>
       : <a href={x.href} target="_blank" rel="noopener noreferrer">{x.t}</a>)
-    : x.t))}
+    : words(x.t).map((w) => !w.term ? w.t
+      : w.term.kind === "language" ? <a class="term term-l" href={base + w.term.url}>{w.t}</a>
+      : <span class="term term-g" tabindex="0" role="button" data-title={w.term.title} data-text={w.term.text}>{w.t}</span>)))}

src/content.config.ts +9 −4
@@ -36,11 +36,10 @@ const penalty = z.object({
   term: z.string().min(1).optional(),
   prison: z.string().refine((s) => parseDuration(s), { message: 'a duration such as "5 days" or "2 years"' }).optional(),
-  prison_min: z.string().refine((s) => parseDuration(s), { message: 'a duration such as "6 months"' }).optional(),
+  prison_min: z.string().refine((s) => parseDuration(s), { message: 'a duration such as "6 months"' }).optional(), // alone: "at least"
   caning: z.tuple([z.number().int().positive(), z.number().int().positive()]).optional(), // [fewest, most] strokes
   death: z.literal(true).optional(),
   fine: z.number().positive().optional(),
   note: z.string().min(1).optional(),
-}).strict().refine((r) => r.upto || r.over || r.label, { message: "a penalty step needs upto, over or a label" })
-  .refine((r) => !r.prison_min || r.prison, { message: "prison_min needs prison, the maximum" });
+}).strict().refine((r) => r.upto || r.over || r.label, { message: "a penalty step needs upto, over or a label" });
 // a segment: its cited text, and for possession and cultivation optionally the penalty steps
 const segment = cited.extend({ currency: z.string().regex(/^[A-Z]{3}$/).optional(), penalties: z.array(penalty).min(1).optional() })
@@ -362,3 +361,9 @@ const datasets = defineCollection({
 });
 
-export const collections = { countries, units, segments, changes, prices, articles, culture, datasets };
+// glossary.yaml: words a reader may not know, explained on hover in every text (src/lib/terms.js)
+const glossary = defineCollection({
+  loader: file("glossary.yaml"),
+  schema: z.object({ id: z.string().min(1), also: z.array(z.string().min(1)).optional(), text: z.string().min(10) }).strict(),
+});
+
+export const collections = { countries, units, segments, changes, prices, articles, culture, datasets, glossary };

src/layouts/Base.astro +28 −0
@@ -467,4 +467,32 @@ const schemas = jsonld ? [jsonld].flat() : [];
     })();
   </script>
+  <script is:inline>
+    // The term layer's explanations. Words for cannabis and jargon are marked as the page is built
+    // (src/lib/terms.js); a jargon word (.term-g) carries its explanation, shown here in a card in the
+    // style of the preview cards: at once on hover or focus, and on a tap for touch screens.
+    (function () {
+      var html = function (t) { return String(t).replace(/[&<>"]/g, function (c) { return { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[c]; }); };
+      var tip = null, current = null;
+      function show(el) {
+        if (!tip) { tip = document.createElement("div"); tip.className = "hc hc-term"; tip.setAttribute("role", "tooltip"); document.body.appendChild(tip); }
+        current = el;
+        tip.innerHTML = '<p class="hc-eyebrow">Explained</p><b class="hc-title">' + html(el.dataset.title) + '</b><p class="hc-text">' + html(el.dataset.text) + "</p>";
+        tip.hidden = false;
+        var r = el.getBoundingClientRect(), m = 10, w = tip.offsetWidth, h = tip.offsetHeight;
+        var below = r.bottom + 8 + h < window.innerHeight || r.top - 8 - h < 0;
+        tip.style.left = Math.min(Math.max(m, r.left), window.innerWidth - w - m) + "px";
+        tip.style.top = (below ? r.bottom + 8 : r.top - 8 - h) + "px";
+      }
+      function hide() { current = null; if (tip) tip.hidden = true; }
+      var termOf = function (e) { return e.target.closest && e.target.closest(".term-g"); };
+      document.addEventListener("pointerover", function (e) { var el = termOf(e); if (el && e.pointerType !== "touch") show(el); });
+      document.addEventListener("pointerout", function (e) { var el = termOf(e); if (el && !el.contains(e.relatedTarget)) hide(); });
+      document.addEventListener("click", function (e) { var el = termOf(e); if (el) { if (current === el) hide(); else show(el); } else if (current) hide(); });
+      document.addEventListener("focusin", function (e) { var el = termOf(e); if (el) show(el); });
+      document.addEventListener("focusout", function (e) { if (termOf(e)) hide(); });
+      document.addEventListener("keydown", function (e) { if (e.key === "Escape") hide(); });
+      window.addEventListener("scroll", hide, { passive: true, capture: true });
+    })();
+  </script>
   <script is:inline>
     // Side panes stick beside the text only when the whole pane fits the window; otherwise its end

src/lib/penalties.js +3 −3
@@ -52,9 +52,9 @@ export function penaltyRow(row) {
 }
 
-// How the punishment reads in the table: "Up to 3 years", "6 months – 5 years", "10–20 years",
+// How the punishment reads in the table: "Up to 3 years", "At least 5 years", "6 months – 5 years", "10–20 years",
 // "Death penalty"; strokes of the cane go on their own line (punishmentExtra)
 export function punishmentText(r) {
   if (r.death) return "Death penalty";
-  if (!r.prison) return null;
+  if (!r.prison) return r.prisonMin ? `At least ${r.prisonMin.text}` : null; // a minimum with no maximum set
   if (!r.prisonMin) return `Up to ${r.prison.text}`;
   const [a, ua] = r.prisonMin.text.split(" "), [b, ub] = r.prison.text.split(" ");
@@ -78,5 +78,5 @@ export function penaltySummary(rows) {
     freeUpTo: free.length ? Math.max(...free) : 0,
     crimeFrom: crime ? size(crime.over) ?? 0 : null,
-    smallPrisonDays: first.filter(covers).reduce((m, r) => Math.max(m, r.prison?.days ?? 0), 0),
+    smallPrisonDays: first.filter(covers).reduce((m, r) => Math.max(m, r.prison?.days ?? r.prisonMin?.days ?? 0), 0),
     // whether any step of the ladder allows caning or the death penalty
     caning: rows.some((r) => r.caning),

src/lib/terms.js +85 −0
@@ -0,0 +1,85 @@
+// The term layer: words a reader may not know, marked in running text when the pages are built.
+// The source texts are not changed. Two kinds:
+//   language: a word for cannabis with an entry in Culture/Language (bhang, dagga): it links to the
+//             entry, which brings the usual preview card
+//   glossary: jargon from glossary.yaml (decriminalised, per se limit): a dashed word that shows
+//             its explanation on hover, focus or tap (the small script in src/layouts/Base.astro)
+// Each word is marked once per page, where it first appears. Used by Cited.astro (profile sections,
+// history entries) and by the Markdown plugin in astro.config.mjs (articles, atlas entries).
+// Reads the files directly, so the Markdown plugin, which runs outside Astro's collections, can use it.
+import { readFileSync, readdirSync } from "node:fs";
+import path from "node:path";
+import yaml from "js-yaml";
+
+const ROOT = process.cwd();
+// language entries too common to mark, and forms of the others that are everyday words
+const SKIP_ENTRIES = new Set(["cannabis", "hemp", "grass", "pot", "joint", "dope", "marijuana"]);
+const SKIP_FORMS = new Set(["bang", "hemp", "hanf", "flower", "jay", "j", "tea", "viper", "hybrid", "weed", "hash", "accession",
+  "novel food", "hemp extract", "doped", "dispo", "maría", "juana", "pacheco", "toque", "churro", "grifo", "moto", "beng", "sır",
+  "ciga", "chimique", "candia", "hindu kush", "roach", "mezz", "d8", "geras", "alaf", "sensi", "sinse"]);
+const clean = (w) => String(w).replace(/\s*\(.*\)$/, "").trim();
+
+function load() {
+  const dir = path.join(ROOT, "culture", "language");
+  const language = readdirSync(dir).filter((f) => f.endsWith(".md")).map((f) => {
+    const fm = yaml.load(readFileSync(path.join(dir, f), "utf8").match(/^---\n([\s\S]*?)\n---/)?.[1] ?? "") ?? {};
+    const slug = f.slice(0, -3);
+    return { slug, title: clean(fm.title ?? slug), url: `culture/language/${slug}/`, forms: [fm.title, ...(fm.also ?? [])] };
+  }).filter((e) => !SKIP_ENTRIES.has(e.slug));
+  const glossary = (yaml.load(readFileSync(path.join(ROOT, "glossary.yaml"), "utf8")) ?? []).map((g) => ({ title: g.id, text: g.text, forms: [g.id, ...(g.also ?? [])] }));
+  // every form once, to the first entry that claims it as its own title, else to the first that lists it
+  const owner = new Map();
+  const titles = new Set(language.map((e) => e.title.toLowerCase()));
+  const add = (form, item) => {
+    const w = clean(form ?? "");
+    if (w.length < 2 || SKIP_FORMS.has(w.toLowerCase())) return;
+    const key = w.length < 4 ? w : w.toLowerCase();
+    if (titles.has(w.toLowerCase()) && item.kind === "language" && item.title.toLowerCase() !== w.toLowerCase()) return;
+    if (!owner.has(key)) owner.set(key, { w, item });
+  };
+  language.forEach((e) => e.forms.forEach((f) => add(f, { kind: "language", key: `l:${e.slug}`, title: e.title, url: e.url })));
+  glossary.forEach((g) => g.forms.forEach((f) => add(f, { kind: "glossary", key: `g:${g.title}`, title: g.title, text: g.text })));
+  const all = [...owner.values()].sort((a, b) => b.w.length - a.w.length);
+  const esc = (t) => t.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
+  // forms of four letters or more match in any case; shorter ones (CBD, 420) only as written
+  const long = all.filter((x) => x.w.length >= 4).map((x) => esc(x.w)), short = all.filter((x) => x.w.length < 4).map((x) => esc(x.w));
+  const edge = "(^|[^\\p{L}\\p{N}])", end = "(?![\\p{L}\\p{N}])";
+  return {
+    owner,
+    res: [long.length && new RegExp(`${edge}(${long.join("|")})${end}`, "giu"), short.length && new RegExp(`${edge}(${short.join("|")})${end}`, "gu")].filter(Boolean),
+  };
+}
+let cache = null;
+const terms = () => (cache ??= load());
+
+// A plain text split into parts: { t } plain, { t, term } a marked word, where term is
+// { kind: "language", url, title } or { kind: "glossary", title, text }. `seen` holds the keys of
+// the words already marked on this page; `here` is the page's own path ("culture/language/bhang/"),
+// whose own word is not marked.
+export function markTerms(text, seen, here = "") {
+  const { owner, res } = terms();
+  const parts = [];
+  let rest = text ?? "";
+  for (;;) {
+    let best = null;
+    for (const re of res) {
+      re.lastIndex = 0;
+      let m;
+      while ((m = re.exec(rest))) {
+        const hit = owner.get(m[2].length < 4 ? m[2] : m[2].toLowerCase());
+        if (hit && !seen.has(hit.item.key) && hit.item.url !== here) {
+          const at = m.index + m[1].length;
+          if (!best || at < best.at) best = { at, form: m[2], item: hit.item };
+          break;
+        }
+      }
+    }
+    if (!best) break;
+    seen.add(best.item.key);
+    if (best.at > 0) parts.push({ t: rest.slice(0, best.at) });
+    parts.push({ t: best.form, term: best.item });
+    rest = rest.slice(best.at + best.form.length);
+  }
+  if (rest) parts.push({ t: rest });
+  return parts;
+}

src/middleware.js +21 −0
@@ -0,0 +1,21 @@
+// Runs over every page as it is built. The term layer (src/lib/terms.js) marks a word once per text,
+// but a page can hold several separately rendered texts: a history entry's summary and its article,
+// say. Here every word keeps only its first mark on the page; later marks become plain text again.
+import { defineMiddleware } from "astro:middleware";
+
+const TERM = /<(a|span)\b([^>]*\bclass="term term-([lg])"[^>]*)>([^<]*)<\/\1>/g;
+const keyOf = (attrs, kind) => (kind === "l" ? attrs.match(/\bhref="([^"]*)"/)?.[1] : attrs.match(/\bdata-title="([^"]*)"/)?.[1]);
+
+export const onRequest = defineMiddleware(async (_context, next) => {
+  const response = await next();
+  if (!response.headers.get("content-type")?.includes("text/html")) return response;
+  const html = await response.text();
+  const seen = new Set();
+  const out = html.replace(TERM, (all, _tag, attrs, kind, text) => {
+    const key = `${kind}:${keyOf(attrs, kind)}`;
+    if (seen.has(key)) return text;
+    seen.add(key);
+    return all;
+  });
+  return new Response(out, { status: response.status, statusText: response.statusText, headers: response.headers });
+});

src/pages/og/[...path].png.js +3 −1
@@ -12,5 +12,5 @@ import { getFacts } from "../../lib/facts.js";
 import { renderOg, flagOf, photoOf, collageOf, TIER_HEX } from "../../og/render.js";
 import { openExhibitions, exhibitionPath } from "../../lib/exhibitions.js";
-import { commits, summaryOf } from "../../lib/gitlog.js";
+import { commits, summaryOf, pageCount, pageOf, PAGE_SIZE } from "../../lib/gitlog.js";
 
 const BRAND = `CANNABIS FREEDOM INDEX · ${DATA_SNAPSHOT}`;
@@ -84,4 +84,6 @@ for (const e of openExhibitions) if (e.og) cards[exhibitionPath(e).slice(0, -1)]
 }];
 // each change in the changelog
+// the changelog's later pages
+for (let n = 2; n <= pageCount(); n++) cards[`changelog/page/${n}`] = page(`Changelog, page ${n}`, `Commits ${(n - 1) * PAGE_SIZE + 1}–${(n - 1) * PAGE_SIZE + pageOf(n).length}, from ${pageOf(n).at(-1)?.date.slice(0, 10)} to ${pageOf(n)[0]?.date.slice(0, 10)}.`, [], `CHANGELOG · ${BRAND}`);
 for (const c of commits) cards[`changelog/${c.short}`] = page(c.subject, summaryOf(c).map(([n, w]) => `${n} ${w}`).join(" · ") || "Changes to the site.", [], `CHANGELOG · ${c.date.slice(0, 10)}`);
 for (const v of MAP_VIEWS) cards[v.slug ? `map/${v.slug}` : "map"] = page(v.title, v.lede, [], `MAP · ${BRAND}`);

src/pages/sitemap.xml.js +3 −1
@@ -15,5 +15,5 @@ import { SITE } from "../lib/exports.js";
 import { MAP_VIEWS } from "../lib/map-views.js";
 import { CULTURE_SECTIONS, cultureEntries } from "../lib/culture.js";
-import { commits } from "../lib/gitlog.js";
+import { commits, pageCount, pageOf } from "../lib/gitlog.js";
 import { lastChanged } from "../lib/lastmod.js";
 
@@ -61,4 +61,6 @@ export function GET({ site }) {
     // the changelog
     ["changelog/", committed[0]?.date.slice(0, 10) ?? null],
+    // its later pages, each dated by the newest commit it lists
+    ...Array.from({ length: pageCount() - 1 }, (_, i) => [`changelog/page/${i + 2}/`, pageOf(i + 2)[0]?.date.slice(0, 10) ?? null]),
     ...committed.map((c) => [`changelog/${c.short}/`, c.date.slice(0, 10)]),
   ];

src/styles/global.css +6 −0
@@ -753,4 +753,10 @@ a.chip:hover { border-color: var(--muted); color: var(--text); }
 /* the icon beside a context section's heading (CONTEXT_ICONS) */
 .ctx-icon { width: .8em; height: .8em; margin-right: 10px; vertical-align: -1px; color: var(--muted); }
+/* the term layer (src/lib/terms.js): words for cannabis and jargon, marked in running text as the pages are built */
+.term { color: inherit; text-decoration: underline dashed; text-decoration-color: var(--muted); text-decoration-thickness: 1px; text-underline-offset: 3px; }
+.term:hover, .term:focus-visible { text-decoration-color: currentColor; }
+.term-g { cursor: help; }
+/* stronger than the solid underline of ordinary links in these texts */
+:is(.prose, .lede, .ctx, .why p) a.term:not(.cite a) { color: inherit; text-decoration: underline dashed; text-decoration-color: var(--muted); text-decoration-thickness: 1px; }
 /* one country spotlight inside its section */
 .ctx-spot + .ctx-spot { margin-top: 14px; }

    ↑↓ to move, ↵ to open.