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