Code, design and documents 8 files
docs/DATA-MAINTENANCE.md +42 −0
@@ -573,4 +573,46 @@ one-line texts in the four original sections (`products`, `production`, `medical
malformed penalty steps.
+## State and province files
+
+The United States, Canada and Australia have a file per state, province or territory
+(`countries/us/ca.yaml`) besides the national profile (`countries/us/index.yaml`). A state file
+can hold everything a country profile holds:
+
+```yaml
+segments:
+ possession: Adults 21+ may hold 28.5 g of flower. # plain text, as before, or:
+ cultivation:
+ text: >-
+ Six plants per household. More is a misdemeanour with up to six months and a $500 fine.[@1]
+ currency: USD
+ penalties: [...] # exactly as in a country profile
+context:
+ recreational:
+ text: >-
+ Since Proposition 64 in 2016, adults 21 and over may grow six plants…[@1]
+ sources: [1] # numbers into the file's `sources` list
+ clubs:
+ status: tolerated # statuses as in a country profile
+ text: >-
+ …
+ sources: [2, 3]
+sources:
+ - title: NORML — California laws and penalties
+ url: https://norml.org/laws/california-penalties/
+```
+
+- **Sources are numbered**: a state file keeps its `sources` as a list, and both its segments and
+ its context sections cite them by number (`[@2]`, `sources: [2]`).
+- **Write only what differs.** A section a state does not write is shown from the national
+ profile, marked "For all of the United States; nothing written for California yet". Write the
+ state's own section where its law or practice differs: recreational use, medical cannabis,
+ clubs, driving, visitors, arrests are the usual ones; hemp and industry often too.
+- **Penalty tables** go on the state's segments as in a country (possession, cultivation, sharing,
+ consumption), with the state's own figures.
+- A state page shows its country's datasets, marked as national, until a dataset has rows for
+ states.
+- `node scripts/country-gaps.cjs us/ca` lists a state's own sections and those it borrows;
+ `node scripts/profile-audit.cjs us/ca` checks it. California is the model.
+
## Penalty steps
docs/EXPAND-COUNTRY.md +7 −0
@@ -96,4 +96,11 @@ How every section reads:
jargon to `glossary.yaml`, or write a Culture/Language entry for a local word for cannabis.
+## 5b. States and provinces (United States, Canada, Australia)
+
+For a federation, expand the national profile first, then its states or provinces one by one:
+penalty tables from each state's law, and context sections where the state differs from the
+country (DATA-MAINTENANCE.md, "State and province files"). A state section the country already
+covers well need not be repeated. `node scripts/country-gaps.cjs us/ca` is a state's work list.
+
## 6. Culture and the atlas
scripts/country-gaps.cjs +10 −3
@@ -61,8 +61,11 @@ function gapsOf(id) {
missingResearched: unit ? [] : RESEARCHED.filter((k) => !ctx[k]),
missingOptional: unit ? [] : OPTIONAL.filter((k) => !ctx[k]),
+ // a state's own sections; the rest are shown from its country
+ unitOwn: unit ? Object.keys(ctx) : [],
+ unitBorrowed: unit ? [...REQUIRED, ...RESEARCHED].filter((k) => !ctx[k]) : [],
deathWithoutSection: p.recreational === "death" && !ctx.death_penalty,
products: !unit && Boolean(ctx.products),
statuses: ["hemp", "production", "research"].filter((k) => ctx[k] && !ctx[k].status).map((k) => `${k}.status`),
- penalties: unit ? [] : ["possession", "cultivation"].filter((k) => p.segments?.[k] && !p.segments[k].penalties),
+ penalties: ["possession", "cultivation"].filter((k) => p.segments?.[k] && !(typeof p.segments[k] === "object" && p.segments[k].penalties)),
uncited: blocks.filter(([, b]) => b?.text && !/\[@/.test(b.text)).map(([k]) => k),
unsourced: blocks.filter(([, b]) => b?.text && !(b.sources ?? []).length).map(([k]) => k),
@@ -99,10 +102,14 @@ function printOne(g) {
out();
out("PROFILE");
- if (g.unit) out(` a state or province file: context sections live in countries/${g.id.split("/")[0]}/index.yaml`);
+ if (g.unit) {
+ out(` a state or province file: sections it does not write are shown from countries/${g.id.split("/")[0]}/index.yaml`);
+ list("its own context sections", g.unitOwn);
+ list("shown from the country (write where the state differs)", g.unitBorrowed);
+ }
if (!g.unit) { list("missing standard sections", g.missingRequired); list("missing researched sections", g.missingResearched); list("optional sections not written", g.missingOptional); }
if (g.deathWithoutSection) out(" ! recreational is \"death\" but there is no death_penalty section");
if (g.products) out(" products still present: fold it into recreational, medical and hemp, then delete it");
list("statuses not set", g.statuses);
- if (!g.unit) list("penalty tables missing", g.penalties);
+ list("penalty tables missing", g.penalties);
list("texts without inline citations", g.uncited);
list("texts with no source at all", g.unsourced);
scripts/profile-audit.cjs +4 −4
@@ -34,5 +34,5 @@ for (const id of ids) {
// ── penalty tables ──
for (const [seg, block] of Object.entries(p.segments ?? {})) {
- const rows = block?.penalties;
+ const rows = typeof block === "object" ? block?.penalties : null; // a state's segment may be plain text
if (!rows) continue;
const where = `segments.${seg}.penalties`;
@@ -78,6 +78,6 @@ for (const id of ids) {
// ── context sections ──
const ctx = p.context ?? {};
- if (!unit) {
- if (ctx.products && ctx.recreational && ctx.hemp) warn(id, "context.products", "recreational and hemp exist: fold products into them and delete it");
+ {
+ if (!unit && ctx.products && ctx.recreational && ctx.hemp) warn(id, "context.products", "recreational and hemp exist: fold products into them and delete it");
for (const [key, b] of Object.entries(ctx)) {
const items = Array.isArray(b) ? b.map((x, i) => [`context.${key}[${i}]`, x]) : [[`context.${key}`, b]];
@@ -97,5 +97,5 @@ for (const id of ids) {
}
if (AVOID.test(text)) warn(id, where, `uses a word WRITING.md asks to avoid ("${text.match(AVOID)[0]}")`);
- if (/gov\.uk\/foreign-travel-advice/.test(JSON.stringify(it.sources?.map((k) => p.sources?.[k])))) warn(id, where, "rests on GOV.UK travel advice, meant only for the UK and closed countries");
+ if (!unit && /gov\.uk\/foreign-travel-advice/.test(JSON.stringify(it.sources?.map((k) => p.sources?.[k])))) warn(id, where, "rests on GOV.UK travel advice, meant only for the UK and closed countries");
}
}
src/components/CountryArticle.astro +25 −26
@@ -23,4 +23,5 @@ import { TREATMENT, punishmentText, punishmentExtra } from "../lib/penalties.js"
import { viewByMetric } from "../lib/map-views.js";
import { Icon } from "astro-icon/components";
+import PenaltyTable from "./PenaltyTable.astro";
import { answersFor } from "../lib/answers.js";
import CultureList from "./CultureList.astro";
@@ -59,5 +60,9 @@ const unitParts = sub ? sub.segCite.map((text, i) => citeParts(text, unitRefs,
citedKeys(text) ?? sub.sources.map((_, i) => String(i + 1)),
`countries/${c.slug}/${sub.id}.yaml: ${SEGMENTS[i].id}`)) : [];
-const ctxParts = d ? Object.fromEntries(d.context.filter(Boolean).map((x) => [x.key, x.items ? x.items.map((item, i) => partsOf(item, ctxRefs, `${x.key}[${i}]`)) : partsOf(x, ctxRefs, x.key)])) : {};
+// The context shown: a country's sections, or for a state its own, with its country's sections in
+// place of those it does not write (marked `national`). Its sources: the state's numbered list.
+const ctxList = sub ? c.details.context.map((nat, i) => sub.context[i] ?? (nat && { ...nat, national: true })) : d?.context ?? null;
+const srcList = sub ? sub.sources : d?.sources ?? [];
+const ctxParts = ctxList ? Object.fromEntries(ctxList.filter(Boolean).map((x) => [x.key, x.items ? x.items.map((item, i) => partsOf(item, ctxRefs, `${x.key}[${i}]`)) : partsOf(x, ctxRefs, x.key)])) : {};
// a maximum fine in the profile's currency: "€5,500", "$300"
const money = (n, currency) => new Intl.NumberFormat("en-GB", { style: "currency", currency, maximumFractionDigits: 0 }).format(n);
@@ -66,5 +71,5 @@ const mapOf = (metric) => (viewByMetric[metric] ? `${base}map/${viewByMetric[met
const onMap = (help) => `${help} · See the world map`;
// the recreational and medical status chips lead to their own context sections, where those exist
-const ctxHas = (key) => Boolean(d?.context.some((x) => x?.key === key));
+const ctxHas = (key) => Boolean(ctxList?.some((x) => x?.key === key));
const INCB = ["International Narcotics Control Board — Travellers carrying medicines containing controlled substances", "https://www.incb.org/incb/en/travellers/index.html", null, "https://www.incb.org/incb/en/travellers/index.html"];
// 48,000,000 → "48 million", 770,000 → "770,000"
@@ -91,5 +96,6 @@ const method = (segId) => `${base}#${segId ? `method-${segId}` : "methodology"}`
// outside datasets with figures for the country, listed in their own tab
-const datasetCount = sub ? 0 : Object.keys(datasetsOf(c.slug)).length;
+// a state shows its country's datasets, marked as national
+const datasetCount = Object.keys(datasetsOf(c.slug)).length;
// The right-hand side is split into tabs so that the history is one click away instead of at
// the bottom of a long page: [id, label, count]. Labels are kept short so that the row fits; the
@@ -100,5 +106,8 @@ const tabs = (sub
? [
["score", "Score breakdown"],
+ ["context", "Context"],
history.length > 0 && ["history", "History", history.length],
+ datasetCount > 0 && ["datasets", "Datasets", datasetCount],
+ ["sources", "Sources", srcList.length],
["units", `Other ${group.plural.split(" ")[0]}`, group.units.length - 1],
]
@@ -158,7 +167,7 @@ const culture = cultureOf(c);
: <span class={`chip s-${v.med}`} title={MED_HELP[v.med]}>Medical: {MED_LABEL[v.med]}</span>}
</div>
- {d && d.context.some((x) => x && (x.status || x.seeds)) && (
+ {ctxList && ctxList.some((x) => x && (x.status || x.seeds)) && (
<div class="d-chips d-chips-ctx">
- {d.context.filter((x) => x && (x.status || x.seeds)).map((x) => x.seeds ? (
+ {ctxList.filter((x) => x && (x.status || x.seeds)).map((x) => x.seeds ? (
<>
<a class={`chip s-${PLANTING_STATUS[x.seeds].tone}`} href={`#seg-ctx-${x.key}`} data-seg-link={`ctx-${x.key}`} title={PLANTING_STATUS[x.seeds].help[0]}>Seeds: {PLANTING_STATUS[x.seeds].label}</a>
@@ -322,4 +331,5 @@ const culture = cultureOf(c);
<div class="why">
{sub.seg && <p><Cited parts={unitParts[i]} /></p>}
+ {sub.segPenalties[i] && <PenaltyTable penalties={sub.segPenalties[i]} />}
</div>
</details>
@@ -348,19 +358,5 @@ const culture = cultureOf(c);
<div class="why">
<p><Cited parts={segParts[i]} /></p>
- {seg.penalties && (
- <table class="pen">
- <thead><tr><td>Amount</td><td>What happens</td><td>Punishment</td><td>Fine</td></tr></thead>
- <tbody>
- {seg.penalties.rows.map((r) => (
- <tr>
- <td data-label="Amount">{r.amount}{r.note && <span class="pen-note">{r.note}</span>}</td>
- <td data-label="What happens"><span class={`pen-t s-${TREATMENT[r.treatment].tone}`} title={TREATMENT[r.treatment].help}>{TREATMENT[r.treatment].label}</span>{r.term && <span class="pen-note">{r.term}</span>}</td>
- <td data-label="Punishment" class:list={{ "pen-death": r.death }}>{punishmentText(r) ?? "—"}{punishmentExtra(r) && <span class="pen-note">{punishmentExtra(r)}</span>}</td>
- <td data-label="Fine">{r.fine && r.fineMin ? `${money(r.fineMin, seg.penalties.currency)} – ${money(r.fine, seg.penalties.currency)}` : r.fine ? `Up to ${money(r.fine, seg.penalties.currency)}` : r.fineMin ? `At least ${money(r.fineMin, seg.penalties.currency)}` : "—"}</td>
- </tr>
- ))}
- </tbody>
- </table>
- )}
+ {seg.penalties && <PenaltyTable penalties={seg.penalties} />}
</div>
)}
@@ -372,10 +368,12 @@ const culture = cultureOf(c);
</div>
- {d && (
+ {ctxList && (
<div class="c-panel" id="panel-context" data-panel="context" role="tabpanel" aria-labelledby="tab-context" hidden>
<h2 class="d-h">More context <span>not part of the score</span></h2>
- {d.context.filter(Boolean).map((x) => (
+ {sub && <p class="d-hint">Sections written for {sub.name} come first in their place; the others hold for all of {c.name} and are marked so.</p>}
+ {ctxList.filter(Boolean).map((x) => (
<div class="segrow" data-seg={`ctx-${x.key}`} id={`seg-ctx-${x.key}`}>
<div class="segrow-top"><span>{CONTEXT_ICONS[x.key] && <Icon name={CONTEXT_ICONS[x.key]} class="ctx-icon" aria-hidden="true" />}{x.name}<SourceTip label="What this covers" inline>{x.help}</SourceTip></span></div>
+ {x.national && <p class="ctx ctx-general">For all of {c.name}; nothing written for {sub.name} yet.</p>}
{x.key === "recreational" && (
<div class="d-chips"><a class={`chip s-${v.rec}`} href={mapOf("status:rec")} title={onMap(REC_HELP[v.rec])}>Recreational: {REC_LABEL[v.rec]}</a></div>
@@ -423,15 +421,16 @@ const culture = cultureOf(c);
</div>
)}
- {d && datasetCount > 0 && (
+ {datasetCount > 0 && (
<div class="c-panel" id="panel-datasets" data-panel="datasets" role="tabpanel" aria-labelledby="tab-datasets" hidden>
+ {sub && <p class="d-hint">These figures are for all of {c.name}; no dataset here is broken down by {group.label.toLowerCase()} yet.</p>}
<CountryDatasetTab c={c} />
</div>
)}
- {d && (
+ {(d || sub) && (
<div class="c-panel" id="panel-sources" data-panel="sources" role="tabpanel" aria-labelledby="tab-sources" hidden>
<section class="c-sources">
- <h2 class="d-h">All sources <span>{d.sources.length} cited in this profile</span></h2>
+ <h2 class="d-h">All sources <span>{srcList.length} cited in this profile</span></h2>
<ol>
- {d.sources.map((source) => <SourceCitation source={source} />)}
+ {srcList.map((source) => <SourceCitation source={source} />)}
</ol>
</section>
src/components/PenaltyTable.astro +22 −0
@@ -0,0 +1,22 @@
+---
+// A segment's penalty steps as a plain table (src/lib/penalties.js), under its text on a country's or
+// a state's page. `penalties` is { rows, currency, summary }.
+import { TREATMENT, punishmentText, punishmentExtra } from "../lib/penalties.js";
+const { penalties } = Astro.props;
+// a fine in the profile's currency: "€5,500", "$300"
+const money = (n) => new Intl.NumberFormat("en-GB", { style: "currency", currency: penalties.currency, maximumFractionDigits: 0 }).format(n);
+const fine = (r) => (r.fine && r.fineMin ? `${money(r.fineMin)} – ${money(r.fine)}` : r.fine ? `Up to ${money(r.fine)}` : r.fineMin ? `At least ${money(r.fineMin)}` : "—");
+---
+<table class="pen">
+ <thead><tr><td>Amount</td><td>What happens</td><td>Punishment</td><td>Fine</td></tr></thead>
+ <tbody>
+ {penalties.rows.map((r) => (
+ <tr>
+ <td data-label="Amount">{r.amount}{r.note && <span class="pen-note">{r.note}</span>}</td>
+ <td data-label="What happens"><span class={`pen-t s-${TREATMENT[r.treatment].tone}`} title={TREATMENT[r.treatment].help}>{TREATMENT[r.treatment].label}</span>{r.term && <span class="pen-note">{r.term}</span>}</td>
+ <td data-label="Punishment" class:list={{ "pen-death": r.death }}>{punishmentText(r) ?? "—"}{punishmentExtra(r) && <span class="pen-note">{punishmentExtra(r)}</span>}</td>
+ <td data-label="Fine">{fine(r)}</td>
+ </tr>
+ ))}
+ </tbody>
+</table>
src/content.config.ts +25 −1
@@ -156,4 +156,27 @@ const countries = defineCollection({
});
+// A state's or province's own context: the same sections as a country's, each optional. Its sources
+// are numbers into the file's numbered `sources` list (as its segments cite [@2]); a section it does
+// not write is shown from its country, marked as national. Statuses use the country vocabularies.
+const unitBlock = z.object({
+ text: z.string().min(1),
+ sources: z.array(z.number().int().positive()).optional(),
+ status: z.string().optional(),
+ seeds: plantingStatus.optional(), seeds_trade: plantingTrade.optional(), clones: plantingStatus.optional(), clones_trade: plantingTrade.optional(),
+}).strict();
+const UNIT_SECTIONS = ["death_penalty", "planting", ...Object.keys(STATUS_SECTIONS), ...OPTIONAL_CONTEXT_IDS, ...CONTEXT_IDS];
+const unitContext = z.object({
+ ...Object.fromEntries([...new Set(UNIT_SECTIONS)].map((k) => [k, unitBlock.optional()])),
+ spotlights: z.array(z.object({ title: z.string().min(1), text: z.string().min(1), sources: z.array(z.number().int().positive()).optional() }).strict()).min(1).optional(),
+}).strict()
+ .superRefine((ctx, issue) => {
+ for (const [key, section] of Object.entries(STATUS_SECTIONS)) {
+ const st = (ctx as Record<string, { status?: string } | undefined>)[key]?.status;
+ if (st && !(st in section.statuses)) issue.addIssue({ code: "custom", message: `context.${key}.status "${st}" is not one of ${Object.keys(section.statuses).join(", ")}` });
+ }
+ });
+// a state's segment: its plain text, or the text with a penalty table
+const unitSegment = z.union([z.string().min(1), z.object({ text: z.string().min(1), currency: z.string().regex(/^[A-Z]{3}$/).optional(), penalties: z.array(penalty).min(1).optional() }).strict()]);
+
// countries/us/ca.yaml → id "us/ca"
const units = defineCollection({
@@ -172,6 +195,7 @@ const units = defineCollection({
scores: perSegment(score),
answers,
- segments: perSegment(z.string().min(1)),
+ segments: perSegment(unitSegment),
sources: z.array(source),
+ context: unitContext.optional(),
// optional: how many people used cannabis in the past year, from the country's own survey.
// The age band is the survey's (NSDUH 12+, Canadian Cannabis Survey 16+, NDSHS 14+), so it is
src/lib/countries.js +23 −3
@@ -198,4 +198,21 @@ export function subunitsOf(id) {
const s = scoreList(data.scores);
const raw = scoreOf(s);
+ const file = `countries/${path}.yaml`;
+ const sources = [...data.sources.map(pair), ...groupSources]; // the unit's own sources first
+ // a segment is its text, or { text, currency, penalties }
+ const segText = (seg) => (typeof seg === "string" ? seg : seg.text);
+ // a context section cited by number into the unit's sources; with no `sources`, all of them
+ const numbered = (block, where) => {
+ const keys = (block.sources ?? []).map(String);
+ for (const k of keys) if (!sources[+k - 1]) throw new Error(`${file}: ${where} cites source ${k}, but the file lists ${data.sources.length}`);
+ return { t: stripCites(block.text), src: keys.map((k) => sources[+k - 1]), cite: { text: block.text, keys, of: (k) => /^[1-9]\d*$/.test(k) && sources[+k - 1] } };
+ };
+ const ctx = data.context ?? {};
+ const context = CONTEXT.map(([key, name, help]) => {
+ const b = ctx[key];
+ if (!b) return null;
+ if (key === "spotlights") return { key, name, help, ...numbered({ text: b.map((x) => `${x.title}. ${x.text}`).join(" "), sources: [...new Set(b.flatMap((x) => x.sources ?? []))] }, key), items: b.map((x, i) => ({ title: x.title, ...numbered(x, `${key}[${i}]`) })) };
+ return { key, name, help, ...numbered(b, `context.${key}`), ...(key === "planting" && b.seeds && plantingOf(b)), ...(STATUS_SECTIONS[key] && b.status && { kind: key, status: b.status }) };
+ });
return {
id: path.split("/")[1], raw, s,
@@ -209,7 +226,10 @@ export function subunitsOf(id) {
reviewed: isoDate(data.updated),
worldRank: 1 + countries.filter((c) => c.raw > raw).length,
- seg: SEGMENTS.map((seg) => stripCites(data.segments[seg.id])),
- segCite: SEGMENTS.map((seg) => data.segments[seg.id]), // six explanations, in SEGMENTS order
- sources: [...data.sources.map(pair), ...groupSources], // the unit's own source first
+ seg: SEGMENTS.map((seg) => stripCites(segText(data.segments[seg.id]))),
+ segCite: SEGMENTS.map((seg) => segText(data.segments[seg.id])), // six explanations, in SEGMENTS order
+ // penalty steps of a segment, as for a country (penalties.js); null where it has none
+ segPenalties: SEGMENTS.map((seg) => { const b = data.segments[seg.id], rows = typeof b === "object" ? b.penalties?.map(penaltyRow) : null; return rows ? { rows, currency: b.currency ?? null, summary: penaltySummary(rows) } : null; }),
+ context, // the unit's own context sections, in CONTEXT order; null where it writes none
+ sources,
use: data.use ? { ...data.use, source: pair(data.use.source) } : null, // past-year use from its country's survey
};