Cannabis Freedom Index

Changelog 7f97abb

improvement on /sources page

21 files of code, design or docs +360 −59 lines

Code, design and documents 21 files

scripts/book-covers.cjs +76 −0
@@ -0,0 +1,76 @@
+// Fetches the covers of the books the index cites, for the Books list on /sources/.
+//   node scripts/book-covers.cjs           fetch the covers that are still missing
+//   node scripts/book-covers.cjs --list    only print which books have a cover and which do not
+// Covers come from Open Library (by ISBN, else by title and author) and are saved as
+// src/assets/book-covers/<slug>.jpg, where the page finds them by the same slug. A book without
+// one gets a printed-style cover drawn by the page. A wrong cover is fixed by replacing or
+// deleting its file; a deleted one is fetched again on the next run unless listed in SKIP.
+// Some covers were taken by hand from the EPUBs in the private books/ folder (Global Histories,
+// Cannabis: non solo fumo, História da maconha no Brasil, The Flowers of Good), 180 pixels wide.
+const fs = require("node:fs");
+const path = require("node:path");
+const yaml = require("js-yaml");
+const ROOT = path.resolve(__dirname, "..");
+const OUT = path.join(ROOT, "src/assets/book-covers");
+// books whose only cover found is wrong or blank, and that are better with the drawn cover
+const SKIP = new Set(["cannabis-britannica-empire-trade-and-prohibition-1800-1928"]);
+
+// the same slug as bookSlug() in src/pages/sources/index.astro
+const slug = (t) => t.normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 80);
+
+// every source in the countries, the history and the articles, found by its shape
+function sourcesIn(node, out = []) {
+  if (Array.isArray(node)) node.forEach((x) => sourcesIn(x, out));
+  else if (node && typeof node === "object") {
+    if (typeof node.title === "string" && (node.type === "book" || node.type === "chapter")) out.push(node);
+    else Object.values(node).forEach((x) => sourcesIn(x, out));
+  }
+  return out;
+}
+const files = [
+  ...fs.readdirSync(path.join(ROOT, "countries"), { recursive: true }).filter((f) => f.endsWith(".yaml")).map((f) => path.join(ROOT, "countries", f)),
+  path.join(ROOT, "changes.yaml"),
+  ...fs.readdirSync(path.join(ROOT, "articles")).filter((f) => f.endsWith(".md")).map((f) => path.join(ROOT, "articles", f)),
+];
+const books = new Map(); // slug → { title, isbn, author }
+for (const f of files) {
+  const text = fs.readFileSync(f, "utf8");
+  const data = f.endsWith(".md") ? yaml.load(text.split(/^---$/m)[1] ?? "") : yaml.load(text);
+  for (const s of sourcesIn(data)) {
+    const title = s.container ?? s.title;
+    const b = books.get(slug(title)) ?? { title };
+    b.isbn ??= s.isbn;
+    b.author ??= (s.container ? s.editors : s.authors)?.[0];
+    books.set(slug(title), b);
+  }
+}
+
+async function fetchCover(b) {
+  const tries = [];
+  if (b.isbn) tries.push(`https://covers.openlibrary.org/b/isbn/${b.isbn.replace(/[^0-9X]/gi, "")}-M.jpg?default=false`);
+  const q = new URLSearchParams({ title: b.title.replace(/\s*\(.*?\)\s*$/, "").split(":")[0], limit: "1", fields: "cover_i,title" });
+  if (b.author) q.set("author", b.author.split(" ").pop());
+  const found = await fetch(`https://openlibrary.org/search.json?${q}`).then((r) => r.json()).catch(() => null);
+  if (found?.docs?.[0]?.cover_i) tries.push(`https://covers.openlibrary.org/b/id/${found.docs[0].cover_i}-M.jpg?default=false`);
+  for (const url of tries) {
+    const r = await fetch(url, { redirect: "follow" });
+    const type = r.headers.get("content-type") ?? "";
+    if (!r.ok || !type.startsWith("image/")) continue;
+    const buf = Buffer.from(await r.arrayBuffer());
+    if (buf.length > 2000) return buf; // Open Library's empty placeholder is a few hundred bytes
+  }
+  return null;
+}
+
+(async () => {
+  fs.mkdirSync(OUT, { recursive: true });
+  const listOnly = process.argv.includes("--list");
+  for (const [s, b] of [...books].sort((x, y) => x[1].title.localeCompare(y[1].title))) {
+    const file = path.join(OUT, `${s}.jpg`);
+    if (fs.existsSync(file)) { console.log(`have     ${b.title}`); continue; }
+    if (listOnly || SKIP.has(s)) { console.log(`drawn    ${b.title}`); continue; }
+    const buf = await fetchCover(b);
+    if (buf) fs.writeFileSync(file, buf);
+    console.log(`${buf ? "fetched " : "none    "} ${b.title}`);
+  }
+})();

source-archive.json +10 −10

Not shown: a picture, a generated file, or past the length shown per change.

src/assets/book-covers/cannabis-a-history.jpg +0 −0

Not shown: a picture, a generated file, or past the length shown per change.

src/assets/book-covers/cannabis-and-culture.jpg +0 −0

Not shown: a picture, a generated file, or past the length shown per change.

src/assets/book-covers/cannabis-global-histories.jpg +0 −0

Not shown: a picture, a generated file, or past the length shown per change.

src/assets/book-covers/cannabis-non-solo-fumo.jpg +0 −0

Not shown: a picture, a generated file, or past the length shown per change.

src/assets/book-covers/ganja-in-jamaica-the-effects-of-marijuana-use.jpg +0 −0

Not shown: a picture, a generated file, or past the length shown per change.

src/assets/book-covers/historia-da-maconha-no-brasil.jpg +0 −0

Not shown: a picture, a generated file, or past the length shown per change.

src/assets/book-covers/home-grown-marijuana-and-the-origins-of-mexico-s-war-on-drugs.jpg +0 −0

Not shown: a picture, a generated file, or past the length shown per change.

src/assets/book-covers/intoxicating-zion-a-social-history-of-hashish-in-mandatory-palestine-and-israel.jpg +0 −0

Not shown: a picture, a generated file, or past the length shown per change.

src/assets/book-covers/marihuana-the-first-twelve-thousand-years.jpg +0 −0

Not shown: a picture, a generated file, or past the length shown per change.

src/assets/book-covers/the-african-roots-of-marijuana.jpg +0 −0

Not shown: a picture, a generated file, or past the length shown per change.

src/assets/book-covers/the-emperor-wears-no-clothes-supplied-epub-edition-unspecified.jpg +0 −0

Not shown: a picture, a generated file, or past the length shown per change.

src/assets/book-covers/the-flowers-of-good-the-science-and-the-history-of-marijuana-liberation.jpg +0 −0

Not shown: a picture, a generated file, or past the length shown per change.

src/assets/book-covers/the-marijuana-conviction-a-history-of-marijuana-prohibition-in-the-united-states.jpg +0 −0

Not shown: a picture, a generated file, or past the length shown per change.

src/assets/book-covers/the-pursuit-of-pleasure-drugs-and-stimulants-in-iranian-history-1500-1900.jpg +0 −0

Not shown: a picture, a generated file, or past the length shown per change.

src/lib/citations.js +50 −0
@@ -0,0 +1,50 @@
+// Every citation on the site, with who cites it and where. Used by /sources/ for its counts and by
+// /sources/cited.json for the "cited in" lists under each book and website.
+// One citation = one source listed once by an owner: a country profile (one of its sections, or its
+// list of states), a state or province, a history entry, an article or a culture entry.
+//   s: the source as written   where: the country it was cited for, "" for international ones
+//   owner: { key, url, title, sub, flag, kind, order }   at: the sections of a country profile that cite it
+import { getCollection } from "astro:content";
+import { places, changes, SEGMENTS, CONTEXT } from "./countries.js";
+import { cultureEntries, cultureSection } from "./culture.js";
+
+const placeOf = new Map(places.map((c) => [c.slug, c]));
+const changeOf = new Map(changes.map((ch) => [ch.id, ch]));
+const SECTION_NAME = new Map([...SEGMENTS.map((s) => [s.id, s.name]), ...CONTEXT]);
+
+const owners = new Map();
+const owner = (key, make) => owners.get(key) ?? owners.set(key, { key, ...make() }).get(key);
+const countryOwner = (c) => owner(`c:${c.slug}`, () => ({ kind: "country", url: `country/${c.slug}/`, title: c.name, flag: c.flag, order: c.name }));
+const changeOwner = (ch, article) => owner(`${article ? "a" : "h"}:${ch.id}`, () => ({ kind: article ? "article" : "history", url: `history/${ch.id}/`, title: ch.title, sub: ch.date.slice(0, 4), flag: ch.place.flag, order: ch.date }));
+
+export const cites = [];
+for (const { id, data } of await getCollection("countries")) {
+  const slug = id.split("/")[0], c = placeOf.get(slug);
+  // which sections cite each source key
+  const at = new Map();
+  for (const [name, block] of Object.entries({ ...data.segments, ...data.context })) for (const key of block?.sources ?? []) at.set(key, [...(at.get(key) ?? []), SECTION_NAME.get(name) ?? name]);
+  for (const [key, s] of Object.entries(data.sources)) cites.push({ s, where: slug, owner: c && countryOwner(c), at: at.get(key) ?? [] });
+  for (const s of data.subunits?.sources ?? []) cites.push({ s, where: slug, owner: c && countryOwner(c), at: [`${data.subunits.plural.replace(/^./, (x) => x.toUpperCase())} overview`] });
+}
+for (const { id, data } of await getCollection("units")) {
+  const [slug, unit] = id.split("/"), c = placeOf.get(slug);
+  const o = c && owner(`u:${id}`, () => ({ kind: "country", url: `country/${slug}/${unit}/`, title: `${data.name}, ${c.name}`, flag: c.flag, order: `${c.name} ${data.name}` }));
+  for (const s of data.sources) cites.push({ s, where: slug, owner: o, at: [] });
+}
+// the sources as written; `changes` holds them already shortened for display
+for (const { data } of await getCollection("changes")) {
+  const ch = changeOf.get(data.id);
+  for (const s of data.sources) cites.push({ s, where: ch.country?.slug ?? "", owner: changeOwner(ch, false), at: [] });
+}
+for (const a of await getCollection("articles")) {
+  const ch = changeOf.get(a.id);
+  for (const s of a.data.sources) cites.push({ s, where: ch.country?.slug ?? "", owner: changeOwner(ch, true), at: [] });
+}
+for (const e of cultureEntries) {
+  const o = owner(`u:${e.url}`, () => ({ kind: "culture", url: e.url, title: e.title, sub: cultureSection(e.section).name, flag: e.countries[0].flag, order: e.title }));
+  for (const s of e.entry.data.sources) cites.push({ s, where: e.countries.length === 1 ? e.countries[0].slug : "", owner: o, at: [] });
+}
+export const isBook = (s) => s.type === "book" || s.type === "chapter" || Boolean(s.container);
+// the same slug as in scripts/book-covers.cjs, which names the cover files by it
+export const bookSlug = (t) => t.normalize("NFD").replace(/[̀-ͯ]/g, "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 80);
+export const domainOf = (url) => new URL(url).hostname.replace(/^www\./, "");

src/lib/countries.js +1 −1
@@ -26,5 +26,5 @@ export const MED_HELP = {
 // "Death penalty" comes first and exists only for countries whose law provides for it;
 // "Seeds and clones" exists only where it has been researched and carries a status for each.
-const CONTEXT = [["death_penalty", "Death penalty"], ["production", "Commercial production"], ["products", "Products"], ["planting", "Seeds and clones"], ["medical", "Medical use"], ["clubs", "Cannabis clubs"], ["black_market", "Black market"], ["driving", "Driving"], ["visitors", "Visitors and foreigners"], ["arrests", "Arrests and prisoners"], ["consequences", "Other consequences"]];
+export const CONTEXT = [["death_penalty", "Death penalty"], ["production", "Commercial production"], ["products", "Products"], ["planting", "Seeds and clones"], ["medical", "Medical use"], ["clubs", "Cannabis clubs"], ["black_market", "Black market"], ["driving", "Driving"], ["visitors", "Visitors and foreigners"], ["arrests", "Arrests and prisoners"], ["consequences", "Other consequences"]];
 export const CONTEXT_NAMES = CONTEXT.map(([, name]) => name);
 

src/pages/sources/cited-[part].json.js +37 −0
@@ -0,0 +1,37 @@
+// Where each book and each website is cited, for the "cited in" lists on /sources/: cited-books.json
+// and cited-sites.json. Each is fetched the first time one of its lists is opened.
+//   o: the owners, [address under the site root, title, small line, flag, kind]
+//   k: book slug or website domain → [[owner, sections, details]]
+// sections: the parts of a country profile that cite it, joined by " · "; details: the chapters
+// and pages of a book, or the addresses of a website's pages. Owners come in the order
+// countries, history, articles, culture, each sorted by name or date.
+import { cites, isBook, bookSlug, domainOf } from "../../lib/citations.js";
+import { file } from "../../lib/exports.js";
+
+const KIND_ORDER = { country: 0, history: 1, article: 2, culture: 3 };
+const PARTS = {
+  books: { key: (x) => isBook(x.s) && bookSlug(x.s.container ?? x.s.title), detail: (s) => [s.container && s.title, s.locator].filter(Boolean).join(", ") },
+  sites: { key: (x) => !isBook(x.s) && x.s.url && domainOf(x.s.url), detail: (s) => s.url },
+};
+
+export const getStaticPaths = () => Object.keys(PARTS).map((part) => ({ params: { part } }));
+
+export function GET({ params }) {
+  const { key, detail } = PARTS[params.part];
+  const mine = cites.filter((x) => x.owner && key(x));
+  const owners = [...new Set(mine.map((x) => x.owner))].sort((a, b) => KIND_ORDER[a.kind] - KIND_ORDER[b.kind] || a.order.localeCompare(b.order));
+  const index = new Map(owners.map((o, i) => [o, i]));
+  const out = {};
+  for (const x of mine) {
+    const rows = (out[key(x)] ??= new Map());
+    const row = rows.get(x.owner) ?? { at: new Set(), details: new Set() };
+    x.at.forEach((a) => row.at.add(a));
+    const d = detail(x.s);
+    if (d) row.details.add(d);
+    rows.set(x.owner, row);
+  }
+  const k = Object.fromEntries(Object.entries(out).map(([name, rows]) => [name,
+    [...rows].sort((a, b) => index.get(a[0]) - index.get(b[0])).map(([o, r]) => [index.get(o), [...r.at].join(" · "), [...r.details]]),
+  ]));
+  return file(JSON.stringify({ o: owners.map((o) => [o.url, o.title, o.sub ?? "", o.flag ?? "", o.kind]), k }), "application/json");
+}

src/pages/sources/index.astro +105 −40
@@ -4,7 +4,8 @@
 // under that country. The page is not in the menu.
 import Base from "../../layouts/Base.astro";
-import { getCollection } from "astro:content";
 import { places } from "../../lib/countries.js";
 import { archivedUrl } from "../../lib/archive.js";
+import { cites, isBook, bookSlug, domainOf } from "../../lib/citations.js";
+import { Image } from "astro:assets";
 
 const base = import.meta.env.BASE_URL;
@@ -12,24 +13,11 @@ const num = (n) => n.toLocaleString("en-GB");
 const placeOf = new Map(places.map((c) => [c.slug, c]));
 
-// One citation = one source listed by one owner (a country profile, a history entry or an article).
-// `where` is the country it was cited for, or "" for international entries.
-const cites = [];
-for (const { id, data } of await getCollection("countries")) {
-  const slug = id.split("/")[0];
-  for (const s of Object.values(data.sources)) cites.push({ s, where: slug });
-  for (const s of data.subunits?.sources ?? []) cites.push({ s, where: slug });
-}
-const changes = (await getCollection("changes")).map((x) => x.data);
-const countryOfChange = new Map(changes.map((ch) => [ch.id, ch.country ?? ""]));
-for (const ch of changes) for (const s of ch.sources) cites.push({ s, where: ch.country ?? "" });
-for (const a of await getCollection("articles")) for (const s of a.data.sources) cites.push({ s, where: countryOfChange.get(a.id) ?? "" });
-
 // ── Books: anything with a container or typed as a book or chapter ──
-const isBook = (s) => s.type === "book" || s.type === "chapter" || Boolean(s.container);
 const books = new Map(); // book title → { meta, chapters: Map(chapter title → { authors, locator, n }), n }
 for (const { s } of cites.filter((x) => isBook(x.s))) {
   const title = s.container ?? s.title;
-  const b = books.get(title) ?? { title, editors: s.editors, authors: s.container ? undefined : s.authors, publisher: s.publisher, year: s.year, isbn: s.isbn, chapters: new Map(), n: 0 };
+  const b = books.get(title) ?? { title, editors: s.editors, authors: s.container ? undefined : s.authors, publisher: s.publisher, year: s.year, isbn: s.isbn, types: new Set(), chapters: new Map(), n: 0 };
   b.n++;
+  b.types.add(s.type ?? "book");
   if (s.container) {
     const ch = b.chapters.get(s.title) ?? { title: s.title, authors: s.authors ?? [], page: Infinity, n: 0 };
@@ -42,7 +30,13 @@ for (const { s } of cites.filter((x) => isBook(x.s))) {
 }
 const bookList = [...books.values()].sort((x, y) => y.n - x.n);
+// Covers fetched by scripts/book-covers.cjs, found by the same slug of the title. A book without one,
+// and every journal, gazette and report, gets a plain printed cover drawn here instead.
+const covers = Object.fromEntries(Object.entries(import.meta.glob("../../assets/book-covers/*.jpg", { eager: true, import: "default" })).map(([f, img]) => [f.split("/").pop().replace(/\.jpg$/, ""), img]));
+const KIND_LABEL = { journal: "Journal", law: "Law", report: "Report", other: "Print" };
+const kindOf = (b) => (b.types.has("book") || b.types.has("chapter") ? "Book" : KIND_LABEL[[...b.types][0]] ?? "Print");
+// the drawn covers take one of a few muted cloth colours, the same one for a title every time
+const cloth = (t) => [...t].reduce((h, ch) => (h * 31 + ch.charCodeAt(0)) >>> 0, 7) % 6;
 
 // ── Websites, by domain ──
-const domainOf = (url) => new URL(url).hostname.replace(/^www\./, "");
 const domains = new Map(); // domain → { urls: Set, n, where: Map(slug → n) }
 for (const { s, where } of cites.filter((x) => !isBook(x.s) && x.s.url)) {
@@ -74,14 +68,17 @@ const urlCount = urls.size;
 const archivedOf = (set) => [...set].filter((u) => archivedUrl(u)).length;
 const archived = archivedOf(urls);
-const stats = [
-  [num(cites.length), "Citations"],
-  [num(urlCount), "Web pages cited"],
-  [`${num(archived)}`, `Archived (${Math.round((archived / urlCount) * 100)}%)`],
-  [num(all.length), "Websites"],
-  [num(wide.length), "Cited for several countries"],
-  [num(all.length - wide.length), "Local, for one country"],
-  [num(bookList.length), bookList.length === 1 ? "Book" : "Books"],
+const pct = Math.round((archived / urlCount) * 100);
+// the whole: citations and web pages; then each part of the page, as a link down to it
+const totals = [
+  [num(cites.length), "Citations", "of books and websites, by every page of the site"],
+  [num(urlCount), "Web pages cited", `on ${num(all.length)} websites`],
+  [`${pct}%`, "Archived", `${num(archived)} pages have a copy in the Internet Archive, so a dead link keeps its evidence; the rest are saved in batches`],
 ];
----
+const parts = [
+  ["#books", num(bookList.length), "Books", `and other printed sources, ${num(bookList.reduce((a, b) => a + b.n, 0))} citations`],
+  ["#websites", num(wide.length), "Websites for several countries", `or for international entries, ${num(wide.reduce((a, r) => a + r.n, 0))} citations`],
+  ["#local", num(all.length - wide.length), "Local websites", `each cited for one country, across ${num(localRows.length)} countries`],
+];
+------
 <Base title="Sources" description={`The ${num(urlCount)} web pages and ${bookList.length} books cited by the Cannabis Freedom Index, counted by website and by country.`}>
   <main class="changes-page sources-page">
@@ -89,16 +86,20 @@ const stats = [
       <p class="eyebrow">Sources</p>
       <h1>What the index is built on</h1>
-      <p class="lede">Every score, explanation and history entry cites where it comes from. This page counts those sources: books first, then websites, with the local ones grouped under the country they were used for. A citation is one source listed once by a country profile, a history entry or an article.</p>
-      <p class="lede">{num(archived)} of the {num(urlCount)} web pages have a copy saved in the Internet Archive, so a dead link does not take the evidence with it. The rest are being saved in batches.</p>
+      <p class="lede">Every score, explanation and history entry cites where it comes from. This page counts those sources: books first, then websites, with the local ones grouped under the country they were used for. A citation is one source listed once by a country profile, a state or province, a history entry, an article or a culture entry. Open a book or a website to see exactly where it is cited.</p>
     </section>
 
-    <dl class="hist-stats">
-      {stats.map(([n, label]) => <div><dt>{label}</dt><dd>{n}</dd></div>)}
+    <dl class="src-totals">
+      {totals.map(([n, label, sub]) => <div><dt>{label}</dt><dd>{n}</dd><dd class="src-totals-sub">{sub}</dd></div>)}
     </dl>
+    <nav class="src-parts" aria-label="Parts of this page">
+      {parts.map(([href, n, label, sub]) => (
+        <a href={href}><b>{n}</b><span class="src-parts-label">{label} <span aria-hidden="true">↓</span></span><small>{sub}</small></a>
+      ))}
+    </nav>
 
     <section class="section" id="books">
       <div class="section-head"><div>
         <h2>Books</h2>
-        <p class="sub">Printed sources, most cited first. Open a book to see which chapters are cited and how often.</p>
+        <p class="sub">Printed sources, most cited first. Open a book to see which chapters are cited, and where.</p>
       </div></div>
       <div class="src-books">
@@ -106,7 +107,15 @@ const stats = [
           <details class="src-book">
             <summary>
-              <span class="src-book-title">{b.title}</span>
-              <span class="src-book-by">{[b.editors && `Ed. ${b.editors.join(" and ")}`, b.authors && b.authors.join(", "), b.publisher, b.year].filter(Boolean).join(" · ")}</span>
-              <span class="src-book-n">{b.chapters.size > 0 && `${b.chapters.size} chapters · `}{b.n} {b.n === 1 ? "citation" : "citations"}</span>
+              {covers[bookSlug(b.title)]
+                ? <Image class="src-cover" src={covers[bookSlug(b.title)]} alt="" width={48} height={72} fit="cover" loading="lazy" />
+                : <span class={`src-cover src-cover-drawn cloth-${cloth(b.title)}`} aria-hidden="true"><small>{kindOf(b)}</small><span>{b.title.replace(/^(The|A|An|La|Le|Les|Il) /, "")[0]}</span></span>}
+              <span class="src-book-main">
+                <span class="src-book-title">{b.title}</span>
+                <span class="src-book-by">{[b.editors && `Ed. ${b.editors.join(" and ")}`, b.authors && b.authors.join(", "), b.publisher, b.year].filter(Boolean).join(" · ")}</span>
+              </span>
+              <span class="src-book-n">
+                <span class="src-book-cites"><b>{b.n}</b> {b.n === 1 ? "citation" : "citations"}</span>
+                {b.chapters.size > 1 && <span>{b.chapters.size} chapters</span>}
+              </span>
             </summary>
             {b.isbn && <p class="src-meta">ISBN {b.isbn}</p>}
@@ -121,4 +130,5 @@ const stats = [
               </table>
             )}
+            <div class="cited" data-cited="b" data-key={bookSlug(b.title)}></div>
           </details>
         ))}
@@ -132,8 +142,8 @@ const stats = [
       </div></div>
       <table class="src-table">
-        <thead><tr><th>Website</th><th class="num">Pages</th><th class="num">Archived</th><th class="num">Citations</th><th class="num">Countries</th></tr></thead>
+        <thead><tr><th>Website</th><th class="num opt">Pages</th><th class="num opt">Archived</th><th class="num">Citations</th><th class="num">Countries</th><th><span class="visually-hidden">Where</span></th></tr></thead>
         <tbody>
           {wide.map((r) => (
-            <tr><td><a href={`https://${r.domain}/`} target="_blank" rel="noopener noreferrer"><img class="src-ico" src={icon(r.domain)} alt="" width="16" height="16" loading="lazy" decoding="async" referrerpolicy="no-referrer" onerror="this.style.visibility='hidden'" />{r.domain}</a></td><td class="num">{num(r.urls.size)}</td><td class="num">{num(archivedOf(r.urls))}</td><td class="num">{num(r.n)}</td><td class="num">{[...r.where.keys()].filter(Boolean).length}</td></tr>
+            <tr class="cited-tr"><td><a href={`https://${r.domain}/`} target="_blank" rel="noopener noreferrer"><img class="src-ico" src={icon(r.domain)} alt="" width="16" height="16" loading="lazy" decoding="async" referrerpolicy="no-referrer" onerror="this.style.visibility='hidden'" />{r.domain}</a></td><td class="num opt">{num(r.urls.size)}</td><td class="num opt">{num(archivedOf(r.urls))}</td><td class="num">{num(r.n)}</td><td class="num">{[...r.where.keys()].filter(Boolean).length}</td><td class="cited-cell"><button type="button" class="cited-toggle" data-cited="w" data-key={r.domain} aria-expanded="false" aria-label={`Where ${r.domain} is cited`} title="Where it is cited">+</button></td></tr>
           ))}
         </tbody>
@@ -147,13 +157,14 @@ const stats = [
       </div></div>
       <table class="src-table src-local">
-        <thead><tr><th>Country</th><th>Websites</th><th class="num">Pages</th><th class="num">Archived</th><th class="num">Citations</th></tr></thead>
+        <thead><tr><th>Country</th><th>Websites</th><th class="num opt">Pages</th><th class="num opt">Archived</th><th class="num">Citations</th><th><span class="visually-hidden">Where</span></th></tr></thead>
         <tbody>
           {localRows.map(({ c, rows, n }) => (
-            <tr>
+            <tr class="cited-tr">
               <th scope="row"><a href={`${base}country/${c.slug}/`}>{c.flag} {c.name}</a></th>
               <td>{rows.map((r, i) => <><a class="src-site" href={`https://${r.domain}/`} target="_blank" rel="noopener noreferrer"><img class="src-ico" src={icon(r.domain)} alt="" width="16" height="16" loading="lazy" decoding="async" referrerpolicy="no-referrer" onerror="this.style.visibility='hidden'" />{r.domain}</a>{r.n > 1 && <small> {r.n}</small>}{i < rows.length - 1 && <span class="sep"> · </span>}</>)}</td>
-              <td class="num">{rows.reduce((a, r) => a + r.urls.size, 0)}</td>
-              <td class="num">{rows.reduce((a, r) => a + archivedOf(r.urls), 0)}</td>
+              <td class="num opt">{rows.reduce((a, r) => a + r.urls.size, 0)}</td>
+              <td class="num opt">{rows.reduce((a, r) => a + archivedOf(r.urls), 0)}</td>
               <td class="num">{n}</td>
+              <td class="cited-cell"><button type="button" class="cited-toggle" data-cited="w" data-key={rows.map((r) => r.domain).join(" ")} aria-expanded="false" aria-label={`Where ${c.name}’s sites is cited`} title="Where it is cited">+</button></td>
             </tr>
           ))}
… 59 more lines
src/styles/global.css +81 −8
@@ -1484,18 +1484,44 @@ details.segrow .segrow-top > span, .segrow:not(details) .segrow-top > span { fon
 .src-books { border-top: 1px solid var(--text); }
 .src-book { border-bottom: 1px solid var(--border); }
-.src-book summary { display: grid; grid-template-columns: minmax(0, 5fr) minmax(0, 4fr) auto; gap: 4px 24px; align-items: baseline; padding: 10px 0; cursor: pointer; list-style: none; }
+.src-book summary { display: grid; grid-template-columns: 48px minmax(0, 1fr) auto; gap: 18px; align-items: center; padding: 12px 0; cursor: pointer; list-style: none; }
 .src-book summary::-webkit-details-marker { display: none; }
+.src-cover { display: block; width: 48px; height: 72px; object-fit: cover; border-radius: 2px 4px 4px 2px; background: var(--surface-2); box-shadow: inset 3px 0 0 rgb(0 0 0 / .18), 0 1px 3px rgb(0 0 0 / .3); }
+/* a drawn cover: cloth colour, the kind of print above, the first letter of the title below */
+.src-cover-drawn { display: flex; flex-direction: column; justify-content: space-between; padding: 6px 5px 5px 8px; color: rgb(255 255 255 / .85); }
+.src-cover-drawn small { font: 400 7px var(--mono); text-transform: uppercase; letter-spacing: .08em; opacity: .8; }
+.src-cover-drawn span { font: 400 26px/1 var(--display); }
+.cloth-0 { background: #5a3d33; } .cloth-1 { background: #34483c; } .cloth-2 { background: #2f3f52; }
+.cloth-3 { background: #5b5032; } .cloth-4 { background: #4a3550; } .cloth-5 { background: #45474a; }
+.src-book-main { display: flex; flex-direction: column; gap: 4px; min-width: 0; }
 .src-book-title { font: 400 20px/1.2 var(--display); }
 .src-book summary:hover .src-book-title { text-decoration: underline; text-underline-offset: 4px; text-decoration-thickness: 1px; }
-.src-book-by { color: var(--text-2); font-size: 13.5px; }
-.src-book-n { font: 400 10.5px var(--mono); text-transform: uppercase; letter-spacing: .06em; color: var(--muted); white-space: nowrap; text-align: right; }
-.src-book-n::after { content: " +"; }
-.src-book[open] .src-book-n::after { content: " −"; }
-.src-book .src-table { border-top: 0; margin: 0 0 14px; font-size: 13.5px; }
+.src-book-by { color: var(--muted); font-size: 13.5px; }
+.src-book-n { display: flex; flex-direction: column; align-items: flex-end; gap: 6px; padding-right: 0; font: 400 10.5px var(--mono); text-transform: uppercase; letter-spacing: .06em; color: var(--muted); white-space: nowrap; text-align: right; }
+.src-book-n b { font: 400 20px/1 var(--display); letter-spacing: 0; color: var(--text); margin-right: 2px; }
+.src-book-cites::after { content: "+"; display: inline-block; width: 1.4em; text-align: right; font-size: 13px; }
+.src-book[open] .src-book-cites::after { content: "−"; }
+.src-book[open] summary { padding-bottom: 8px; }
+/* an opened book: its ISBN and chapters line up with the title, right of the cover */
+.src-book > .src-meta { margin-left: 66px; }
+.src-book .src-table { border-top: 0; margin: 0 0 14px 66px; width: calc(100% - 66px); font-size: 13.5px; }
 .src-book .src-table tr:last-child td { border-bottom: 0; }
 .src-meta { margin: 0 0 6px; color: var(--text-2); font-size: 13px; }
-@media (max-width: 760px) { .src-book summary { grid-template-columns: 1fr; } .src-book-n { text-align: left; } }
+@media (max-width: 760px) { .src-book summary { grid-template-columns: 40px minmax(0, 1fr); gap: 14px; } .src-cover { width: 40px; height: 60px; } .src-book-n { grid-column: 2; flex-direction: row; align-items: baseline; gap: 8px; } .src-book-n b { font-size: 15px; } .src-book > .src-meta { margin-left: 0; } .src-book .src-table { margin-left: 0; width: 100%; } }
 .src-meta b { font: 400 10.5px var(--mono); text-transform: uppercase; letter-spacing: .06em; color: var(--muted); margin-left: 10px; }
-.sources-page .hist-stats { margin-top: 8px; }
+/* /sources/: the totals, then a card for each part of the page, linking down to it */
+.src-totals, .src-parts { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 0 24px; }
+.src-totals { margin: 8px 0 0; border-top: 1px solid var(--text); }
+.src-totals div { display: grid; grid-template-rows: auto auto auto; align-content: start; gap: 6px; padding: 14px 0 18px; }
+.src-totals dt { order: 2; font: 400 10.5px var(--mono); text-transform: uppercase; letter-spacing: .06em; color: var(--text-2); }
+.src-totals dd { order: 1; margin: 0; font: 400 clamp(30px, 3.4vw, 44px)/1 var(--mono); letter-spacing: -.05em; }
+.src-totals .src-totals-sub { order: 3; font-size: 13px; line-height: 1.4; font-family: inherit; letter-spacing: 0; color: var(--muted); }
+.src-parts { margin-top: 6px; }
+.src-parts a { display: grid; gap: 6px; align-content: start; padding: 16px 18px 18px; border: 1px solid var(--border); border-radius: var(--r); color: var(--text); background: var(--surface); transition: border-color .15s; }
+.src-parts a:hover { border-color: var(--muted); }
+.src-parts b { font: 400 30px/1 var(--display); }
+.src-parts-label { font: 400 10.5px var(--mono); text-transform: uppercase; letter-spacing: .06em; }
+.src-parts-label span { color: var(--muted); }
+.src-parts small { color: var(--muted); font-size: 13px; line-height: 1.4; }
+.sources-page .section[id] { scroll-margin-top: 56px; }
 
 /* Search dialog (⌘K): a flat ruled sheet near the top of the window */
@@ -2066,2 +2092,49 @@ h1 .ico { width: .75em; height: .75em; vertical-align: -.02em; }
 /* a history entry: its kind's icon, with the country's flag small in the bottom right corner */
 .sr-badge { position: absolute; right: -5px; bottom: -4px; font-size: 11px; line-height: 1; }
+
+/* /sources/: where a book or website is cited */
+/* a row that opens: click anywhere on it; the sign at its end is the same as on the books */
+.cited-tr { cursor: pointer; }
+.cited-tr:hover > *, .cited-tr.is-open > * { background: var(--surface); }
+.cited-tr.is-open > * { border-bottom-color: transparent; }
+.src-table .cited-cell { width: 1%; padding: 9px 0 9px 14px; text-align: right; }
+.cited-toggle { border: 0; background: none; padding: 0; width: 1.4em; color: var(--muted); font: 400 13px/1.4 var(--mono); cursor: pointer; }
+.cited-tr:hover .cited-toggle, .cited-toggle[aria-expanded="true"] { color: var(--text); }
+.cited-row > td { padding: 6px 0 20px; background: var(--surface); }
+.cited-row .cited { padding: 0 16px; }
+/* the two site tables: room around the text of a shaded row; on a phone, only the counts that matter */
+#websites .src-table :is(th, td):first-child, #local .src-table :is(th, td):first-child { padding-left: 10px; }
+@media (max-width: 760px) { .src-totals, .src-parts { grid-template-columns: 1fr; gap: 8px; } .src-totals div { grid-template-columns: 96px 1fr; grid-template-rows: auto auto; column-gap: 14px; padding: 12px 0; border-bottom: 1px solid var(--border); } .src-totals dd:first-of-type { grid-row: 1 / 3; font-size: 28px; align-self: center; } .src-totals dt, .src-totals .src-totals-sub { grid-column: 2; } }
+@media (max-width: 760px) {
+  .sources-page .src-table .opt { display: none; }
+  .src-table .num { padding-left: 12px; }
+  .cited-row .cited { padding: 0 10px; }
+  /* local sources: the country and its count on one line, its sites across the width below */
+  .src-local thead { display: none; }
+  .src-local tr.cited-tr { display: grid; grid-template-columns: minmax(0, 1fr) auto auto; border-bottom: 1px solid var(--border); }
+  .src-local tr.cited-tr > * { border-bottom: 0; }
+  .src-local tr.cited-tr > th { width: auto; white-space: normal; padding-bottom: 2px; }
+  .src-local tr.cited-tr > td:nth-child(2) { grid-column: 1 / -1; order: 1; padding: 0 10px 10px; line-height: 1.6; }
+  .src-local tr.cited-tr > .num { padding-bottom: 2px; }
+  .src-local .cited-row > td { display: block; }
+  .src-local .src-site { white-space: normal; overflow-wrap: anywhere; }
+  /* a long domain is cut short rather than pushed under its numbers */
+  #websites tbody td:first-child { width: 100%; max-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
+}
+
+.src-book .cited { margin: 4px 0 18px 66px; }
+.cited { columns: 2 320px; column-gap: 40px; font-size: 13.5px; }
+.cited-title { column-span: all; margin: 0 0 6px; font: 400 10.5px var(--mono); text-transform: uppercase; letter-spacing: .06em; color: var(--muted); }
+.cited-domain { column-span: all; margin: 14px 0 4px; font: 400 15px var(--display); }
+.cited-domain:first-child { margin-top: 0; }
+.cited-h { break-after: avoid; margin: 12px 0 4px; font: 400 10.5px var(--mono); text-transform: uppercase; letter-spacing: .06em; color: var(--muted); }
+.cited-h small { font-size: inherit; opacity: .7; }
+.cited ul { list-style: none; margin: 0; padding: 0; }
+.cited li { break-inside: avoid; padding: 4px 0; border-bottom: 1px dotted var(--border); line-height: 1.4; }
+.cited li > a { color: var(--text); }
+.cited-sub, .cited-at { color: var(--muted); font-size: 12.5px; }
+.cited-at::before { content: "· "; }
+.cited-d { display: block; color: var(--muted); font-size: 12px; overflow-wrap: anywhere; }
+.cited-page { color: var(--muted); margin-right: 8px; }
+.cited-page:hover { color: var(--text); }
+@media (max-width: 760px) { .src-book .cited { margin-left: 0; } }

    ↑↓ to move, ↵ to open.