Grenada countries/gd.yaml
Wikipedia — Cannabis in Grenada 11 unchanged
Changelog 0856c30tree, with Claude Opus 5.5
- 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
Wikipedia — Cannabis in Grenada 11 unchanged
liamba, riamba, yamba
kief, keef, kef
astro.config.mjs +30 @@ -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 @@ -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 +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 @@ -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 @@ -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 @@ -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 @@ -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 { "&": "&", "<": "<", ">": ">", '"': """ }[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 @@ -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 +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 +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 @@ -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 @@ -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 @@ -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; }