Changelog a80d1f7
og banners
10 files of code, design or docs +795 −1 lines
Code, design and documents 10 files
.github/workflows/deploy.yml +9 −0
@@ -29,4 +29,13 @@ jobs: uses: actions/configure-pages@v6 - run: npm ci + # The social preview images (one per page) take minutes to draw and rarely change. Each is + # kept under a hash of its own content, so restoring the folder redraws only what is new. + - uses: actions/cache@v4 + with: + path: .cache/og + key: og-${{ hashFiles('src/og/**', 'package-lock.json') }}-${{ github.run_id }} + restore-keys: | + og-${{ hashFiles('src/og/**', 'package-lock.json') }}- + og- # The site URL and sub-path come from the repository's Pages settings, so the # same build works for user.github.io/repo and for a custom domain.
.gitignore +1 −0
@@ -5,2 +5,3 @@ dist/ .env tmp/ +.cache/
package-lock.json +531 −0
Not shown: a picture, a generated file, or past the length shown per change.
package.json +9 −0
@@ -19,4 +19,13 @@ "topojson-client": "^3.1.0", "world-atlas": "^2.0.2" + }, + "devDependencies": { + "@astrojs/compiler": "^4.0.0", + "@fontsource/instrument-serif": "^5.3.0", + "@fontsource/inter": "^5.3.0", + "@fontsource/jetbrains-mono": "^5.3.0", + "@resvg/resvg-js": "^2.6.2", + "flag-icons": "^7.5.0", + "x-satori": "^0.5.1" } }
src/layouts/Base.astro +10 −1
@@ -13,4 +13,8 @@ const fullTitle = seoTitle ?? (title ? `${title} — ${siteName}` : `${siteName} // the address search engines should index: no query string, no hash const canonical = new URL(Astro.url.pathname, Astro.site ?? "https://cannabisfreedom.fyi").href; +// the page's social preview image, drawn at build time by src/pages/og/[...path].png.js: +// /country/de/ → /og/country/de.png, the home page → /og/index.png +const ogPath = Astro.url.pathname.slice(base.length).replace(/\/$/, "") || "index"; +const ogImage = new URL(`${base}og/${ogPath}.png`, Astro.site ?? "https://cannabisfreedom.fyi").href; const schemas = jsonld ? [jsonld].flat() : []; --- @@ -30,5 +34,10 @@ const schemas = jsonld ? [jsonld].flat() : []; <meta property="og:url" content={canonical}> <meta property="og:locale" content="en"> - <meta name="twitter:card" content="summary"> + <meta property="og:image" content={ogImage}> + <meta property="og:image:width" content="1200"> + <meta property="og:image:height" content="630"> + <meta property="og:image:alt" content={seoTitle ?? title ?? fullTitle}> + <meta name="twitter:card" content="summary_large_image"> + <meta name="twitter:image" content={ogImage}> <meta name="twitter:title" content={seoTitle ?? title ?? fullTitle}> <meta name="twitter:description" content={description}>
src/og/country.astro +43 −0
@@ -0,0 +1,43 @@ +--- +// Social preview of a country (or one of its states): 1200 × 630, drawn by satori through x-satori. +// Satori lays out with flexbox only, so every box with several children says `display:flex`. +const { eyebrow, name, flag, score, tier, color, rank, segments, statuses } = Astro.props; +--- +<div style="display:flex;flex-direction:column;width:1200px;height:630px;background:#0c0c0b;color:#f2f0e9;padding:52px 64px 48px;font-family:Inter"> + <div style="display:flex;justify-content:space-between;align-items:center;font-family:'JetBrains Mono';font-size:19px;letter-spacing:2px;color:#a9a79d"> + <div style="display:flex">{eyebrow}</div> + <div style="display:flex">CANNABISFREEDOM.FYI</div> + </div> + + <div style="display:flex;justify-content:space-between;align-items:flex-end;margin-top:34px"> + <div style="display:flex;flex-direction:column;max-width:780px"> + <div style="display:flex;align-items:center"> + {flag && <img src={flag} style="width:92px;height:69px;margin-right:26px;border-radius:4px" />} + <div style={`display:flex;font-family:'Instrument Serif';font-size:${name.length > 22 ? 62 : name.length > 14 ? 78 : 104}px;line-height:1;letter-spacing:-2px`}>{name}</div> + </div> + <div style="display:flex;margin-top:22px;font-size:27px;color:#a9a79d">{statuses}</div> + </div> + <div style="display:flex;flex-direction:column;align-items:flex-end"> + <div style="display:flex;align-items:flex-end;font-family:'JetBrains Mono'"> + <div style="display:flex;font-size:150px;line-height:0.9;font-weight:500">{score}</div> + <div style="display:flex;font-size:30px;color:#a9a79d;margin-left:8px;margin-bottom:10px">/100</div> + </div> + <div style={`display:flex;width:100%;height:8px;margin-top:14px;background:${color}`}></div> + <div style="display:flex;margin-top:12px;font-family:'JetBrains Mono';font-size:19px;letter-spacing:2px;color:#f2f0e9">{tier}{rank ? ` · ${rank}` : ""}</div> + </div> + </div> + + <div style="display:flex;flex-wrap:wrap;margin-top:auto;border-top:1px solid #f2f0e9;padding-top:22px"> + {segments.map((s) => ( + <div style="display:flex;flex-direction:column;width:33.33%;padding-right:36px;margin-bottom:16px"> + <div style="display:flex;justify-content:space-between;font-family:'JetBrains Mono';font-size:17px;letter-spacing:1px;color:#a9a79d"> + <div style="display:flex">{s.name}</div> + <div style="display:flex;color:#f2f0e9">{s.value}/10</div> + </div> + <div style="display:flex;width:100%;height:10px;margin-top:8px;background:#2a2a27"> + <div style={`display:flex;width:${s.value * 10}%;height:10px;background:${s.color}`}></div> + </div> + </div> + ))} + </div> +</div>
src/og/history.astro +40 −0
@@ -0,0 +1,40 @@ +--- +// Social preview of a history entry: where and when, what happened, and how the score moved. +const { eyebrow, flag, place, date, kind, title, score, article } = Astro.props; +const size = title.length > 90 ? 50 : title.length > 60 ? 60 : title.length > 36 ? 72 : 86; +--- +<div style="display:flex;flex-direction:column;width:1200px;height:630px;background:#0c0c0b;color:#f2f0e9;padding:52px 64px 48px;font-family:Inter"> + <div style="display:flex;justify-content:space-between;align-items:center;font-family:'JetBrains Mono';font-size:19px;letter-spacing:2px;color:#a9a79d"> + <div style="display:flex">{eyebrow}</div> + <div style="display:flex">CANNABISFREEDOM.FYI</div> + </div> + + <div style="display:flex;align-items:center;margin-top:38px;font-family:'JetBrains Mono';font-size:22px;letter-spacing:2px;color:#a9a79d"> + {flag && <img src={flag} style="width:44px;height:33px;margin-right:16px;border-radius:2px" />} + <div style="display:flex;color:#f2f0e9">{place}</div> + <div style="display:flex;margin:0 14px">·</div> + <div style="display:flex">{date}</div> + <div style="display:flex;margin:0 14px">·</div> + <div style="display:flex">{kind}</div> + {article && <div style="display:flex;margin-left:18px;padding:3px 10px;border:1px solid #a9a79d;font-size:16px">ARTICLE</div>} + </div> + + <div style={`display:flex;margin-top:22px;font-family:'Instrument Serif';font-size:${size}px;line-height:1.05;letter-spacing:-1px`}>{title}</div> + + <div style="display:flex;align-items:center;margin-top:auto;border-top:1px solid #f2f0e9;padding-top:24px;font-family:'JetBrains Mono'"> + {score ? ( + <div style="display:flex;align-items:center"> + <div style="display:flex;flex-direction:column;margin-right:36px;font-size:17px;letter-spacing:2px;color:#a9a79d"> + <div style="display:flex">INDEX SCORE</div> + <div style="display:flex">OUT OF 100</div> + </div> + <div style={`display:flex;font-size:76px;font-weight:500;padding-bottom:4px;border-bottom:6px solid ${score.beforeColor}`}>{score.before}</div> + <svg width="64" height="40" viewBox="0 0 64 40" style="margin:0 26px"><path d="M2 20h56M42 5l17 15-17 15" fill="none" stroke="#a9a79d" stroke-width="4" /></svg> + <div style={`display:flex;font-size:76px;font-weight:500;padding-bottom:4px;border-bottom:6px solid ${score.afterColor}`}>{score.after}</div> + <div style={`display:flex;font-size:34px;margin-left:34px;color:${score.delta >= 0 ? "#9ccc55" : "#dc4a2a"}`}>{score.label}</div> + </div> + ) : ( + <div style="display:flex;font-size:22px;letter-spacing:2px;color:#a9a79d">NO CHANGE TO THE SCORE</div> + )} + </div> +</div>
src/og/page.astro +21 −0
@@ -0,0 +1,21 @@ +--- +// Social preview of any other page: its name and one line about it. +const { eyebrow, title, text, facts = [] } = Astro.props; +const size = title.length > 48 ? 70 : title.length > 28 ? 92 : 116; +--- +<div style="display:flex;flex-direction:column;width:1200px;height:630px;background:#0c0c0b;color:#f2f0e9;padding:52px 64px 48px;font-family:Inter"> + <div style="display:flex;justify-content:space-between;align-items:center;font-family:'JetBrains Mono';font-size:19px;letter-spacing:2px;color:#a9a79d"> + <div style="display:flex">{eyebrow}</div> + <div style="display:flex">CANNABISFREEDOM.FYI</div> + </div> + <div style={`display:flex;margin-top:54px;font-family:'Instrument Serif';font-size:${size}px;line-height:1;letter-spacing:-2px`}>{title}</div> + <div style="display:flex;margin-top:26px;max-width:960px;font-size:30px;line-height:1.35;color:#a9a79d">{text}</div> + <div style="display:flex;margin-top:auto;border-top:1px solid #f2f0e9;padding-top:22px;font-family:'JetBrains Mono'"> + {facts.map((f) => ( + <div style="display:flex;flex-direction:column;margin-right:64px"> + <div style="display:flex;font-size:46px;font-weight:500">{f[0]}</div> + <div style="display:flex;margin-top:4px;font-size:16px;letter-spacing:2px;color:#a9a79d">{f[1]}</div> + </div> + ))} + </div> +</div>
src/og/render.js +58 −0
@@ -0,0 +1,58 @@ +// Draws the social preview images (Open Graph, 1200 × 630 PNG) at build time. +// x-satori turns one of the .astro templates beside this file into SVG with satori, and resvg +// turns the SVG into a PNG. Fonts are the site's own, read from the @fontsource packages; flags +// come from flag-icons, since satori cannot draw flag emoji. +import { readFile } from "node:fs/promises"; +import { readFileSync, existsSync, mkdirSync, writeFileSync } from "node:fs"; +import { createHash } from "node:crypto"; +import path from "node:path"; +import { satoriAstro } from "x-satori/astro"; +import { Resvg } from "@resvg/resvg-js"; + +const ROOT = process.cwd(); +const font = (pkg, file) => readFileSync(path.join(ROOT, "node_modules", "@fontsource", pkg, "files", file)); +// the Latin files first, then Latin Extended for names such as Łódź or Türkiye +const fonts = [ + ["Instrument Serif", "instrument-serif", 400], ["Inter", "inter", 400], ["Inter", "inter", 600], ["JetBrains Mono", "jetbrains-mono", 400], ["JetBrains Mono", "jetbrains-mono", 500], +].flatMap(([name, pkg, weight]) => ["latin", "latin-ext"].map((set) => ({ name, data: font(pkg, `${pkg}-${set}-${weight}-normal.woff`), weight, style: "normal" }))); +const templates = new Map(); +const template = async (name) => { + if (!templates.has(name)) templates.set(name, await readFile(path.join(ROOT, "src", "og", `${name}.astro`), "utf8")); + return templates.get(name); +}; + +// the tier colours of the dark theme, as plain values (satori knows no CSS variables) +export const TIER_HEX = ["#a3121f", "#dc4a2a", "#f08c3a", "#f0cf4a", "#9ccc55", "#2e9e4f"]; + +// a country's flag as an image address satori can embed; nothing for places without one +const flags = new Map(); +export function flagOf(id) { + const code = id.toLowerCase(); + if (!flags.has(code)) { + const file = path.join(ROOT, "node_modules", "flag-icons", "flags", "4x3", `${code}.svg`); + flags.set(code, existsSync(file) ? `data:image/svg+xml;base64,${readFileSync(file).toString("base64")}` : null); + } + return flags.get(code); +} + +// Drawing 1,500 images takes minutes, and between two builds almost none of them change. Each +// finished image is kept in .cache/og (git-ignored; the deploy workflow saves and restores it) under a hash of everything that went into +// it (the template, the props, this file), and reused while that hash stays the same. +const CACHE = path.join(ROOT, ".cache", "og"); +const self = readFileSync(new URL(import.meta.url)); +// The template's text reaches satori as HTML, where ' " & < > arrive as entities that satori +// draws letter for letter ("d'Ivoire"). Typographic forms of the same characters pass through. +const plain = (v) => typeof v === "string" ? v.replace(/'/g, "\u2019").replace(/"([^"]*)"/g, "\u201C$1\u201D").replace(/"/g, "\u201D").replace(/ & /g, " and ").replace(/&/g, "+").replace(/</g, "\u2039").replace(/>/g, "\u203A") + : Array.isArray(v) ? v.map(plain) : v && typeof v === "object" ? Object.fromEntries(Object.entries(v).map(([k, x]) => [k, k === "flag" ? x : plain(x)])) : v; + +export async function renderOg(kind, rawProps) { + const props = plain(rawProps); + const source = await template(kind); + const file = path.join(CACHE, `${createHash("sha1").update(self).update(source).update(JSON.stringify(props)).digest("hex")}.png`); + if (existsSync(file)) return readFileSync(file); + const svg = await satoriAstro({ width: 1200, height: 630, fonts, props }, source); + const png = new Resvg(svg, { fitTo: { mode: "width", value: 1200 } }).render().asPng(); + mkdirSync(CACHE, { recursive: true }); + writeFileSync(file, png); + return png; +}
src/pages/og/[...path].png.js +73 −0
@@ -0,0 +1,73 @@ +// The social preview image of every page, at /og/<page path>.png (the home page is /og/index.png). +// Base.astro points each page's og:image here. Countries and states get their score, rank and +// six segment bars; history entries get the date, the place and how the score moved; every other +// page gets its name and a line about it. Drawn by src/og/render.js. +import { SEGMENTS, DATA_SNAPSHOT, REC_LABEL, MED_LABEL, countries, places, changes, articles, subunitsOf, worldAvg, changeDate, historyPeriods, CHANGE_CATEGORIES } from "../../lib/countries.js"; +import { tierOf, tierIndex } from "../../lib/tiers.js"; +import { MAP_VIEWS } from "../../lib/map-views.js"; +import { survey, results } from "../../lib/survey.js"; +import { getFacts } from "../../lib/facts.js"; +import { renderOg, flagOf, TIER_HEX } from "../../og/render.js"; + +const BRAND = `CANNABIS FREEDOM INDEX · ${DATA_SNAPSHOT}`; +const num = (n) => n.toLocaleString("en-GB"); +const hex = (score) => TIER_HEX[tierIndex(score)]; +const bars = (s) => SEGMENTS.map((seg, i) => ({ name: seg.name.replace(/^Punishment & /, "").replace(/^Personal /, "").replace(/ & gifting$/, "").toUpperCase(), value: s[i], color: hex(s[i] * 10) })); + +function countryCard(c, sub) { + const x = sub ?? c; + return ["country", { + eyebrow: sub ? `${c.name.toUpperCase()} · ${BRAND}` : BRAND, + name: x.name, flag: flagOf(c.id), score: x.score, tier: tierOf(x.raw).name.toUpperCase(), color: hex(x.raw), + rank: sub ? "" : c.rank ? `#${c.rank} OF ${countries.length}` : "DISPUTED TERRITORY", + statuses: `Recreational: ${REC_LABEL[x.rec].toLowerCase()} · Medical: ${MED_LABEL[x.med].toLowerCase()}`, + segments: bars(x.s), + }]; +} +function historyCard(ch) { + const delta = ch.score ? ch.score.after - ch.score.before : 0; + return ["history", { + eyebrow: "HISTORY OF CANNABIS LAW", + flag: ch.country ? flagOf(ch.country.id) : null, place: ch.place.name.toUpperCase(), + date: changeDate(ch).toUpperCase(), kind: ch.kind.label.toUpperCase(), + title: ch.title, article: articles.has(ch.id), + score: ch.score && { ...ch.score, delta, beforeColor: hex(ch.score.before), afterColor: hex(ch.score.after), label: `${delta >= 0 ? "+" : "−"}${Math.abs(delta)} ${Math.abs(delta) === 1 ? "point" : "points"}` }, + }]; +} +const page = (title, text, facts = [], eyebrow = BRAND) => ["page", { eyebrow, title, text, facts }]; + +const dated = changes.filter((ch) => !ch.estimate); +const scored = dated.filter((ch) => ch.score).length; +const indexFacts = [[String(places.length), "COUNTRIES AND TERRITORIES"], [worldAvg.toFixed(1), "WORLD AVERAGE OF 100"], [num(dated.length), "HISTORY ENTRIES"]]; +const historyFacts = [[num(dated.length), "ENTRIES"], [num(scored), "MOVED A SCORE"], [num(articles.size), "ARTICLES"]]; +const surveyFacts = results.mock ? [] : [[num(results.respondents), "RESPONDENTS"], [num(results.observations), "PLACE REPORTS"], [num(results.price_reports), "PRICE REPORTS"]]; + +// path (as in the address, without slashes at the ends) → [template, props] +const cards = { + index: page("How free is cannabis in every country?", "Every country scored from 0 to 100 for how free you are to carry, grow, share and use cannabis.", indexFacts), + history: page("A history of cannabis law", "The laws, court rulings and treaties that made cannabis legal or illegal, and how each moved a country's score.", historyFacts), + "can-i": page("Can I?", "Pick a country and get plain answers: can I carry it, grow it, buy it, use it in public, and what happens if I am caught?"), + compare: page("Compare countries", "Up to six countries side by side: their scores, exact rules and sources in one table."), + changelog: page("Changelog", "Every change to a score, newest first.", [[num(scored), "SCORE CHANGES"]]), + data: page("Download the data", "Scores, history and a yearly panel since 1900 as CSV and JSON, free to reuse with attribution."), + "did-you-know": page("Did you know?", "Strange rules, forgotten laws and what the scores show.", [[String(getFacts().length), "CURIOUS CASES"]]), + articles: page("Articles", "Moments from the history of cannabis law told in full.", [[num(articles.size), "ARTICLES"]]), + culture: page("The people behind the laws", "A participatory anthropology project on how cannabis shapes, and is shaped by, society."), + sources: page("What the index is built on", "Every source the index cites, counted by website and by country."), + survey: page("State of Cannabis", "A yearly survey of what people do with cannabis, what they believe about it, and what they see in the countries they know.", surveyFacts, `SURVEY · ${survey.edition} EDITION`), + "survey/take": page("Take the survey", "State of Cannabis: about ten minutes, no account, answers encrypted.", [], `STATE OF CANNABIS · ${survey.edition}`), + "survey/me": page("Your submissions", "State of Cannabis: read, change or delete what you sent.", [], `STATE OF CANNABIS · ${survey.edition}`), + "survey/scan": page("Submission scanner", "What the survey's public relays hold, as anyone can see it.", [], `STATE OF CANNABIS · ${survey.edition}`), +}; +for (const v of MAP_VIEWS) cards[v.slug ? `map/${v.slug}` : "map"] = page(v.title, v.lede, [], `MAP · ${BRAND}`); +for (const [kind, k] of Object.entries(CHANGE_CATEGORIES)) cards[`history/kind/${kind}`] = page(k.label, `${k.help}. Every such entry in the history of cannabis law.`, [[num(dated.filter((ch) => ch.category === kind).length), "ENTRIES"]], "HISTORY OF CANNABIS LAW"); +for (const p of historyPeriods.slice(1)) cards[`history/period/${p.slug}`] = page(`History, ${p.label}`, `Cannabis laws, court rulings and treaties from ${p.from} to ${p.to}.`, [], "HISTORY OF CANNABIS LAW"); +for (const ch of changes) cards[`history/${ch.id}`] = historyCard(ch); // estimated first bans have a page too +for (const c of places) { + cards[`country/${c.slug}`] = countryCard(c); + for (const sub of subunitsOf(c.id)?.units ?? []) cards[`country/${c.slug}/${sub.id}`] = countryCard(c, sub); +} + +export const getStaticPaths = () => Object.entries(cards).map(([path, card]) => ({ params: { path }, props: { card } })); + +export const GET = async ({ props }) => new Response(await renderOg(...props.card), { headers: { "Content-Type": "image/png", "Cache-Control": "public, max-age=86400" } });