Cannabis Freedom Index

Changelog 2268d4ctree

Publish shared cannabis glossary and update YAML compatibility

1 atlas entry edited12 files of code, design or docs +367 −249 lines

Articles and atlas entries 1

Hashish culture/language/hashish.md

also

hash, haschisch, hachisch, hasch, hashish, hachís, jachís

sources

Wiktionary — hachís (Spanish), including jachís 9 unchanged

Code, design and documents 12 files

README.md +6 −0
@@ -82,2 +82,8 @@ counts are published.
 - Data from others keeps its own terms, listed on the
   [data page](https://cannabisfreedom.fyi/data/), which also explains the downloads and how to cite.
+
+`/api/glossary.json` publishes the glossary, language-atlas spellings and country `words` with
+source references. Its versioned `recognition` section contains cannabis-related spellings and
+inflection prefixes for consumers such as the Cannabis Enforcement Tracker. Ambiguous everyday
+words are identified separately; general legal glossary terms are not cannabis keyword matches.
+This vocabulary is a recognition aid, not an exhaustive language classifier or evidence of an event.

package-lock.json +279 −237

Not shown: a picture, a generated file, or past the length shown per change.

package.json +8 −4
@@ -27,5 +27,5 @@
   },
   "dependencies": {
-    "@astrojs/markdown-remark": "^7.3.2",
+    "@astrojs/markdown-remark": "^7.3.5",
     "@fontsource-variable/inter": "^5.3.0",
     "@fontsource-variable/jetbrains-mono": "^5.3.0",
@@ -34,5 +34,5 @@
     "@fontsource/special-elite": "^5.3.0",
     "@iconify-json/iconoir": "^1.2.12",
-    "astro": "^7.3.5",
+    "astro": "^7.4.4",
     "astro-icon": "^1.2.0",
     "d3-geo": "^3.1.1",
@@ -51,8 +51,12 @@
     "@fontsource/jetbrains-mono": "^5.3.0",
     "@resvg/resvg-js": "^2.6.2",
-    "flag-icons": "^7.5.0",
-    "js-yaml": "^4.3.2",
+    "flag-icons": "^7.6.0",
+    "js-yaml": "^5.5.0",
     "playwright-core": "^1.64.1",
     "x-satori": "^0.5.1"
+  },
+  "allowScripts": {
+    "esbuild@0.28.2": true,
+    "fsevents@2.3.3": true
   }
 }

scripts/answers.test.mjs +14 −1
@@ -11,5 +11,5 @@ import fs from "node:fs";
 import path from "node:path";
 import { fileURLToPath } from "node:url";
-import yaml from "js-yaml";
+import * as yaml from "js-yaml";
 import { answersFor } from "../src/lib/answers.js";
 import { SEGMENT_IDS } from "../src/lib/segment-ids.js";
@@ -135,2 +135,15 @@ test('custodial enforcement band does not manufacture an imprisonment frequency
   assert.equal(placeOf('kh').result.caught.label, 'Prison');
 });
+
+// The public glossary also feeds the enforcement tracker's multilingual source check.
+test("glossary API exports sourced multilingual cannabis vocabulary and inflection rules", async () => {
+  const { glossaryAPI } = await import("../src/lib/glossary-api.js");
+  const data = glossaryAPI(path.resolve(root, ".."));
+  assert.equal(data.version, 1);
+  assert.ok(data.entries.some(e => e.kind === "glossary" && e.id === "THC"));
+  assert.ok(data.entries.some(e => e.kind === "country-word" && e.word === "konopa" && e.sources.length));
+  for (const word of ["marihuana", "konopí", "大麻", "กัญชา"]) assert.ok(data.recognition.words.includes(word), word);
+  assert.ok(data.recognition.prefixes.includes("konop"));
+  assert.ok(data.recognition.prefixes.includes("marihuan"));
+  assert.ok(!data.recognition.words.includes("decriminalised"));
+});

scripts/osm-shops.mjs +1 −1
@@ -16,5 +16,5 @@ import fs from "node:fs";
 import { geoContains, geoBounds, geoDistance, geoCentroid } from "d3-geo";
 import { feature } from "topojson-client";
-import yaml from "js-yaml";
+import * as yaml from "js-yaml";
 
 const SERVERS = ["https://overpass-api.de/api/interpreter", "https://overpass.private.coffee/api/interpreter", "https://maps.mail.ru/osm/tools/overpass/api/interpreter"];

scripts/profile-claims.test.mjs +1 −1
@@ -36,5 +36,5 @@ test("no profile, article or history entry repeats a sentence known to be false"
 });
 
-import yaml from 'js-yaml';
+import * as yaml from 'js-yaml';
 const country = (id) => yaml.load(fs.readFileSync(path.join(root, 'countries', `${id}.yaml`), 'utf8'));
 const events = () => yaml.load(fs.readFileSync(path.join(root, 'changes.yaml'), 'utf8'));

scripts/sync-tracker.mjs +1 −1
@@ -10,5 +10,5 @@
 import fs from "node:fs";
 import path from "node:path";
-import yaml from "js-yaml";
+import * as yaml from "js-yaml";
 
 const tracker = path.resolve(process.argv[2] || process.env.TRACKER_DIR || "../cannabis-freedom-index-tracker");

src/lib/gitlog.js +1 −1
@@ -12,5 +12,5 @@ import { execFileSync } from "node:child_process";
 import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
 import path from "node:path";
-import yaml from "js-yaml";
+import * as yaml from "js-yaml";
 import { diffWords } from "diff";
 

src/lib/glossary-api.js +46 −0
@@ -0,0 +1,46 @@
+// Public vocabulary from the same sourced glossary, atlas and country data as the site.
+import fs from "node:fs";
+import path from "node:path";
+import * as yaml from "js-yaml";
+
+const frontmatter = (file) => yaml.load(fs.readFileSync(file, "utf8").match(/^---\r?\n([\s\S]*?)\r?\n---/)?.[1] ?? "") ?? {};
+const clean = (value) => String(value ?? "").replace(/\s*\([^)]*\)$/, "").trim();
+const ambiguous = new Set(["pot", "grass", "high", "joint", "flower", "tea", "j", "jay", "dope", "accession", "hybrid", "bang", "resin", "résine"]);
+const cannabisGlossary = new Set(["prensado", "chira", "THC", "HHC", "cannabinoid", "edibles", "concentrates"]);
+
+export function glossaryAPI(root = process.cwd()) {
+  const entries = [], words = new Set();
+  const add = (forms) => {
+    for (const value of forms) {
+      const word = clean(value);
+      if (word.length > 1 && !ambiguous.has(word.toLowerCase())) words.add(word);
+    }
+  };
+  const glossary = yaml.load(fs.readFileSync(path.join(root, "glossary.yaml"), "utf8")) ?? [];
+  for (const item of glossary) {
+    entries.push({ kind: "glossary", ...item });
+    if (cannabisGlossary.has(item.id)) add([item.id, ...(item.also ?? [])]);
+  }
+  const dir = path.join(root, "culture/language");
+  for (const file of fs.readdirSync(dir).filter(f => f.endsWith(".md")).sort()) {
+    const item = frontmatter(path.join(dir, file));
+    entries.push({ kind: "language", id: file.slice(0, -3), title: item.title, also: item.also ?? [], spelling: item.spelling ?? [], sources: item.sources ?? [], url: `/culture/language/${file.slice(0, -3)}/` });
+    add([item.title, ...(item.also ?? []), ...(item.spelling ?? []).flatMap(s => [s.text, ...(!/^[\[/]/.test(s.say ?? "") ? [s.say] : [])])]);
+  }
+  function countries(dir, prefix = "") {
+    for (const file of fs.readdirSync(dir, { withFileTypes: true }).sort((a,b) => a.name.localeCompare(b.name))) {
+      if (file.isDirectory()) { countries(path.join(dir, file.name), prefix + file.name + "/"); continue; }
+      if (!file.name.endsWith(".yaml")) continue;
+      const item = yaml.load(fs.readFileSync(path.join(dir, file.name), "utf8"));
+      for (const word of item?.words ?? []) {
+        entries.push({ kind: "country-word", country: (prefix + file.name.slice(0,-5)).replace(/\/index$/, ""), ...word, sources: (word.sources ?? []).map(id => ({ id, ...item.sources?.[id] })) });
+        add([word.word]);
+      }
+    }
+  }
+  countries(path.join(root, "countries"));
+  // Productive inflections: konopy/konopí/konopím, marihuanu/marihuany, etc.
+  // A prefix matches only at a Unicode word start; it never matches inside another word.
+  const prefixes = ["konop", "marihuan", "marijuan", "mariguan", "cannabi", "kanabi", "коноп", "марихуан", "гашиш"];
+  return { version: 1, entries, recognition: { words: [...words].sort((a,b) => a.localeCompare(b)), prefixes, ambiguous: [...ambiguous].sort() } };
+}

src/lib/survey.js +1 −1
@@ -2,5 +2,5 @@
 // results (survey/results-<year>.json, written by scripts/survey-mock.cjs or survey-collect.cjs).
 // The form and the results page are both built from these two files.
-import yaml from "js-yaml";
+import * as yaml from "js-yaml";
 import raw from "../../survey/2026.yaml?raw";
 import results from "../../survey/results-2026.json";

src/lib/terms.js +1 −1
@@ -10,5 +10,5 @@
 import { readFileSync, readdirSync } from "node:fs";
 import path from "node:path";
-import yaml from "js-yaml";
+import * as yaml from "js-yaml";
 
 const ROOT = process.cwd();

src/pages/api/glossary.json.js +5 −0
@@ -0,0 +1,5 @@
+import { glossaryAPI } from "../../lib/glossary-api.js";
+
+export function GET() {
+  return new Response(JSON.stringify(glossaryAPI()), { headers: { "Content-Type": "application/json; charset=utf-8" } });
+}

    ↑↓ to move, ↵ to open.