Cannabis Freedom Index

Changelog b199703

add seo

9 files of code, design or docs +142 −9 lines

Code, design and documents 9 files

astro.config.mjs +2 −1
@@ -10,5 +10,6 @@ const base = (process.env.BASE_PATH || "").replace(/\/+$/, "") + "/";
 
 export default defineConfig({
-  site: process.env.SITE || undefined,
+  // also the address used in canonical links, the sitemap and structured data
+  site: process.env.SITE || "https://cannabisfreedom.fyi",
   base,
 });

src/layouts/Base.astro +21 −2
@@ -3,8 +3,14 @@ import "../styles/global.css";
 import Leaf from "../components/Leaf.astro";
 
-const { title, description } = Astro.props;
+// `seoTitle` replaces the whole <title> (search-result wording); `jsonld` is one schema.org
+// object or a list of them; `type` is the Open Graph type of the page.
+const { title, seoTitle, description, jsonld, type = "website" } = Astro.props;
 const base = import.meta.env.BASE_URL;
 const repo = "https://github.com/burningtree/cannabis-freedom-index";
-const fullTitle = title ? `${title} — Cannabis Freedom Index` : "Cannabis Freedom Index";
+const siteName = "Cannabis Freedom Index";
+const fullTitle = seoTitle ?? (title ? `${title} — ${siteName}` : `${siteName}: cannabis laws and legality in every country`);
+// the address search engines should index: no query string, no hash
+const canonical = new URL(Astro.url.pathname, Astro.site ?? "https://cannabisfreedom.fyi").href;
+const schemas = jsonld ? [jsonld].flat() : [];
 ---
 <!doctype html>
@@ -15,4 +21,17 @@ const fullTitle = title ? `${title} — Cannabis Freedom Index` : "Cannabis Free
   <title>{fullTitle}</title>
   <meta name="description" content={description}>
+  <link rel="canonical" href={canonical}>
+  <meta name="robots" content="index, follow, max-snippet:-1">
+  <meta property="og:site_name" content={siteName}>
+  <meta property="og:type" content={type}>
+  <meta property="og:title" content={seoTitle ?? title ?? fullTitle}>
+  <meta property="og:description" content={description}>
+  <meta property="og:url" content={canonical}>
+  <meta property="og:locale" content="en">
+  <meta name="twitter:card" content="summary">
+  <meta name="twitter:title" content={seoTitle ?? title ?? fullTitle}>
+  <meta name="twitter:description" content={description}>
+  <link rel="sitemap" type="application/xml" href={`${base}sitemap.xml`}>
+  {schemas.map((x) => <script type="application/ld+json" is:inline set:html={JSON.stringify(x).replace(/</g, "\\u003c")} />)}
   <link rel="icon" type="image/svg+xml" href={`${base}favicon.svg`}>
   <link rel="alternate" type="application/rss+xml" title="Cannabis Freedom Index: score changes" href={`${base}rss.xml`}>

src/lib/seo.js +76 −0
@@ -0,0 +1,76 @@
+// Wording and structured data for search engines: titles that match what people type
+// ("is weed legal in Germany"), and schema.org objects for the pages.
+import { REC_LABEL, REC_HELP, MED_LABEL } from "./countries.js";
+import { SEGMENT_IDS } from "./segment-ids.js";
+import { answersFor } from "./answers.js";
+import { SITE } from "./exports.js";
+
+const THE = /^(United |Netherlands|Bahamas|Philippines|Maldives|Gambia|Dominican Republic|Central African Republic|Comoros|Seychelles|Solomon Islands|Marshall Islands|DR Congo)/;
+// "in the Netherlands", "in Germany"
+export const inPlace = (name) => `in ${THE.test(name) ? "the " : ""}${name}`;
+
+const root = (site) => (site?.href ?? SITE + "/").replace(/\/$/, "") + import.meta.env.BASE_URL;
+const QUESTION = {
+  carry: (where) => `Can I carry cannabis ${where}?`,
+  grow: (where) => `Can I grow cannabis ${where}?`,
+  buy: (where) => `Can I buy cannabis ${where}?`,
+  public: (where) => `Can I use cannabis in public ${where}?`,
+  caught: (where) => `What happens if I'm caught with cannabis ${where}?`,
+};
+const crumbs = (site, trail) => ({
+  "@context": "https://schema.org",
+  "@type": "BreadcrumbList",
+  itemListElement: trail.map(([name, path], i) => ({ "@type": "ListItem", position: i + 1, name, item: root(site) + path })),
+});
+
+// A country page: title, description and structured data (the questions the page answers).
+export function countrySeo(c, site) {
+  const where = inPlace(c.name);
+  const year = c.details.reviewed.slice(0, 4);
+  const status = `Recreational cannabis: ${REC_LABEL[c.rec].toLowerCase()}. Medical: ${MED_LABEL[c.med].toLowerCase()}.`;
+  const faq = {
+    "@context": "https://schema.org",
+    "@type": "FAQPage",
+    dateModified: c.details.reviewed,
+    mainEntity: [
+      { q: `Is weed legal ${where}?`, a: `${REC_LABEL[c.rec]}: ${REC_HELP[c.rec].toLowerCase()}. ${c.note}` },
+      ...answersFor(c.s).map((x) => ({ q: QUESTION[x.id](where), a: `${x.label}. ${x.text}. ${c.details.segments[SEGMENT_IDS.indexOf(x.seg)].t}` })),
+    ].map(({ q, a }) => ({ "@type": "Question", name: q, acceptedAnswer: { "@type": "Answer", text: a } })),
+  };
+  return {
+    seoTitle: `Is weed legal ${where}? Cannabis laws ${year}`,
+    description: `${status} ${c.note} Cannabis freedom score: ${c.score} out of 100${c.rank ? `, #${c.rank} in the world` : ""}.`,
+    jsonld: [faq, crumbs(site, [["Cannabis Freedom Index", ""], [c.name, `country/${c.slug}/`]])],
+  };
+}
+
+// A state or province page.
+export function subunitSeo(c, sub, site) {
+  return {
+    seoTitle: `Is weed legal in ${sub.name}? Cannabis laws in ${sub.name}, ${c.name}`,
+    jsonld: [crumbs(site, [["Cannabis Freedom Index", ""], [c.name, `country/${c.slug}/`], [sub.name, `country/${c.slug}/${sub.id}/`]])],
+  };
+}
+
+// The home page: the site itself and the dataset behind it.
+export function homeSeo(site, count) {
+  const url = root(site);
+  return [
+    { "@context": "https://schema.org", "@type": "WebSite", name: "Cannabis Freedom Index", url, description: `Cannabis laws and legality in ${count} countries, scored from 0 to 100.` },
+    {
+      "@context": "https://schema.org",
+      "@type": "Dataset",
+      name: "Cannabis Freedom Index",
+      description: `A score from 0 to 100 for how free cannabis is in each of ${count} countries, covering possession, home growing, enforcement, sharing, consumption and legal access, with sources and the history of each country's law.`,
+      url,
+      license: "https://creativecommons.org/licenses/by/4.0/",
+      isAccessibleForFree: true,
+      keywords: ["cannabis laws", "cannabis legality by country", "marijuana legalization", "decriminalization", "medical cannabis"],
+      creator: { "@type": "Organization", name: "Cannabis Freedom Index", url },
+      distribution: [
+        { "@type": "DataDownload", encodingFormat: "text/csv", contentUrl: `${url}data/scores.csv` },
+        { "@type": "DataDownload", encodingFormat: "application/json", contentUrl: `${url}data/scores.json` },
+      ],
+    },
+  ];
+}

src/pages/country/[id].astro +3 −3
@@ -3,5 +3,5 @@ import Base from "../../layouts/Base.astro";
 import CountryArticle from "../../components/CountryArticle.astro";
 import { countries, places } from "../../lib/countries.js";
-import { tierOf } from "../../lib/tiers.js";
+import { countrySeo } from "../../lib/seo.js";
 
 export function getStaticPaths() {
@@ -14,7 +14,7 @@ const base = import.meta.env.BASE_URL;
 const prev = c.rank ? countries[c.rank - 2] : null;
 const next = c.rank ? countries[c.rank] : null;
-const description = `${c.name} scores ${c.score} out of 100 on the Cannabis Freedom Index (${tierOf(c.raw).name}${c.rank ? `, #${c.rank} of ${countries.length}` : ""}). ${c.note}`;
+const seo = countrySeo(c, Astro.site);
 ---
-<Base title={c.name} description={description}>
+<Base title={c.name} seoTitle={seo.seoTitle} description={seo.description} jsonld={seo.jsonld} type="article">
   <main class="country-page">
     <div class="page-bar">

src/pages/country/[id]/[sub].astro +2 −1
@@ -5,4 +5,5 @@ import CountryArticle from "../../../components/CountryArticle.astro";
 import { countries, subunitsOf } from "../../../lib/countries.js";
 import { tierOf } from "../../../lib/tiers.js";
+import { subunitSeo } from "../../../lib/seo.js";
 
 export function getStaticPaths() {
@@ -16,5 +17,5 @@ const base = import.meta.env.BASE_URL;
 const description = `${sub.name} (${c.name}) scores ${sub.score} out of 100 on the Cannabis Freedom Index (${tierOf(sub.raw).name}). ${sub.note}`;
 ---
-<Base title={`${sub.name}, ${c.name}`} description={description}>
+<Base title={`${sub.name}, ${c.name}`} description={description} {...subunitSeo(c, sub, Astro.site)} type="article">
   <main class="country-page">
     <div class="page-bar">

src/pages/history/[id].astro +1 −1
@@ -20,5 +20,5 @@ const r = 30;
 const circ = 2 * Math.PI * r;
 ---
-<Base title={ch.title} description={ch.score ? `${ch.title}: ${c.name} moved from ${ch.score.before} to ${ch.score.after} on the Cannabis Freedom Index. ${ch.description}` : `${ch.title}. ${ch.description}`}>
+<Base type="article" title={ch.title} description={ch.score ? `${ch.title}: ${c.name} moved from ${ch.score.before} to ${ch.score.after} on the Cannabis Freedom Index. ${ch.description}` : `${ch.title}. ${ch.description}`}>
   <main class="change-page">
     <div class="page-bar">

src/pages/index.astro +2 −1
@@ -8,4 +8,5 @@ import { PLANTING_STATUS, PLANTING_TRADE, STATUS_SECTIONS } from "../lib/segment
 import { PRICE_MARKETS, PRICE_MARKET_LABEL, flatLatestPricesFor, latestPricesFor } from "../lib/prices.js";
 import { SHOW_PRICES } from "../lib/config.js";
+import { homeSeo } from "../lib/seo.js";
 
 const base = import.meta.env.BASE_URL;
@@ -102,5 +103,5 @@ const EXTRA_COLUMNS = [
 const columnGroups = [["Shown by default", BASE_COLUMNS], ...["Score segments", "People", "Context"].map((g) => [g, EXTRA_COLUMNS.filter((x) => x.group === g)])];
 ---
-<Base description={`A score from 0 to 100 for how free cannabis is in every country, with a map, rankings${SHOW_PRICES ? ", retail prices" : ""} and legal history.`}>
+<Base description={`Where is cannabis legal? Cannabis laws in ${countries.length} countries, each scored from 0 to 100 for possession, home growing, penalties and legal access, with a world map, rankings${SHOW_PRICES ? ", retail prices" : ""} and legal history.`} jsonld={homeSeo(Astro.site, countries.length)}>
   <main id="top">
     <section class="hero">

src/pages/robots.txt.js +7 −0
@@ -0,0 +1,7 @@
+// Everything may be crawled; the sitemap lists every page.
+import { SITE } from "../lib/exports.js";
+
+export function GET({ site }) {
+  const root = (site?.href ?? SITE + "/").replace(/\/$/, "") + import.meta.env.BASE_URL;
+  return new Response(`User-agent: *\nAllow: /\n\nSitemap: ${root}sitemap.xml\n`, { headers: { "Content-Type": "text/plain; charset=utf-8" } });
+}

src/pages/sitemap.xml.js +28 −0
@@ -0,0 +1,28 @@
+// Every page of the site, for search engines. Linked from robots.txt.
+import { places, countries, changes, subunitsOf, historyPeriods, CHANGE_CATEGORIES } from "../lib/countries.js";
+import { SITE } from "../lib/exports.js";
+
+export function GET({ site }) {
+  const root = (site?.href ?? SITE + "/").replace(/\/$/, "") + import.meta.env.BASE_URL;
+  const latest = places.map((c) => c.details.reviewed).sort().at(-1);
+  // [path, last change (YYYY-MM-DD, optional), priority]
+  const pages = [
+    ["", latest, 1],
+    ["can-i/", latest, 0.8],
+    ["history/", latest, 0.8],
+    ["compare/", latest, 0.6],
+    ["changelog/", latest, 0.5],
+    ["data/", latest, 0.5],
+    ...places.map((c) => [`country/${c.slug}/`, c.details.reviewed, 0.9]),
+    ...countries.flatMap((c) => (subunitsOf(c.id)?.units ?? []).map((sub) => [`country/${c.slug}/${sub.id}/`, c.details.reviewed, 0.7])),
+    ...historyPeriods.slice(1).map((p) => [`history/period/${p.slug}/`, null, 0.4]),
+    ...Object.keys(CHANGE_CATEGORIES).map((kind) => [`history/kind/${kind}/`, null, 0.4]),
+    ...changes.map((ch) => [`history/${ch.id}/`, null, 0.5]),
+  ];
+  const xml = `<?xml version="1.0" encoding="UTF-8"?>
+<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
+${pages.map(([path, mod, pri]) => `  <url><loc>${root}${path}</loc>${mod ? `<lastmod>${mod}</lastmod>` : ""}<priority>${pri}</priority></url>`).join("\n")}
+</urlset>
+`;
+  return new Response(xml, { headers: { "Content-Type": "application/xml; charset=utf-8" } });
+}

    ↑↓ to move, ↵ to open.