Cannabis Freedom Index

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&#39;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" } });

    ↑↓ to move, ↵ to open.