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" } }); +}