Bangladesh countries/bd.yaml
BDT
label Driving after intoxicants, Section 92(1) · treatment criminal · prison 3 months · fine 10000 · note Up to three months, up to Tk10,000, or both, plus one driver point
Changelog 54d3cc5tree, with Claude Opus 5.5
- scripts/profile-audit.cjs (npm run profiles:audit): what the schema cannot see - tables on the wrong segment, minimums or caning left in notes, steps out of order, punishments on legal steps, one-line sections, statuses contradicting their text, Research without science, too many spotlights, uncited sentences, avoided words. - Penalty steps take fine_min; the Fine column shows ranges and minimums. Tables are allowed on possession, cultivation, sharing and consumption, never on enforcement or in context sections. The industry status gains "Hemp only". - DATA-MAINTENANCE.md and both expansion guides: every figure in its field, "prison or a fine" alternatives, what notes and terms are for, statuses must match their text and are never filled in bulk, and the audit before every commit. - Fixes: Bangladesh and Sri Lanka had penalty tables inside Driving, which broke the build (their texts state the figures); industry statuses corrected for Nigeria, Taiwan, Palestine and Northern Cyprus (none) and Belize (hemp); Singapore's cultivation minimums in fields and a consumption table with the LT1 and LT2 steps.
8 country profiles edited9 files of code, design or docs +229 −49 lines
BDT
label Driving after intoxicants, Section 92(1) · treatment criminal · prison 3 months · fine 10000 · note Up to three months, up to Tk10,000, or both, plus one driver point
medicalhemp
LKR
label Ordinary driving after consumption · treatment criminal · prison 3 months · fine 30000 · note LKR25,000–30,000 or up to three months or both, plus licence suspension up to twelve months, label Passenger vehicle after consumption · treatment criminal · prison 6 months · fine 30000 · note Same fine range or up to six months or both, plus licence cancellation
medicalnone
medicalnone
upto 15 g · treatment criminal · term Offence under the Misuse of Drugs Act · prison 10 years · fine 20000 · note Using it is a separate offence, proved by a urine test, with at least a year in prison, over 15 g · upto 330 g · treatment criminal · term Presumed trafficking · prison min 5 yea … ue · note If the presumption is rebutted, possession carries 20–30 years and 10–15 strokes
over 0 plants · treatment criminal · term Cultivation · prison min 3 years · prison 20 years · fine min 5000 · fine 40000 · note At least three yearsPrison or a fine of at least S$5,000 or both
SGD
label Using cannabis, a first conviction · treatment criminal · term Consumption, proved by a urine test · prison min 1 year · prison 10 years · fine 20000, label Repeat use after qualifying earlier convictions (LT1, section 33A) · treatment criminal · term Long-term imprisonment 1 · prison min 5 years · prison 7 years · caning 3, 6 · note Women and men over 50 are not caned, label Repeat use after an LT1 term (LT2, section 33A) · treatment criminal · term Long-term imprisonment 2 · prison min 7 years · prison 13 years · caning 6, 12
medicalnone
medicalnone
docs/DATA-MAINTENANCE.md +60 @@ -399,7 +399,9 @@ for overall 100%; full context coverage alone is insufficient. Run ### Statuses for hemp, industry and research -`hemp`, `production` and `research` may carry a `status`, like `clubs` or `driving`. It shows as a -chip on the country page, colours a world-map view and fills a rankings column. It is optional -while countries are researched; set it only when the section's text states the fact it rests on. +`hemp`, `production` and `research` carry a `status`, like `clubs` or `driving`: a filled section +must have one. It shows as a chip on the country page, colours a world-map view and fills a +rankings column. **The status must say what the section's text says.** When you are not sure, read +the text again; don't pick the nearest-sounding value, and never fill statuses in bulk without +reading each text. ```yaml @@ -407,5 +409,5 @@ while countries are researched; set it only when the section's text states the f status: standard # high (up to 1% THC) | standard (0.3%) | strict (below 0.3%) | none (no exception for hemp) production: - status: medical # open (licensed adult market) | state (state-run) | pilot (trials only) | medical (medicine, research or export only) | none + status: medical # open (licensed adult market) | state (state-run) | pilot (trials only) | medical (medicine, research or export only) | hemp (licensed low-THC hemp only) | none research: status: licensed # open | licensed (special licence or permit) | restricted (synthetic or narrow routes only) | blocked @@ -414,5 +416,8 @@ while countries are researched; set it only when the section's text states the f - `hemp` is the THC line in law, not how big the hemp industry is. - `production` describes who may produce for sale. A country with both a pilot for adults and a - medical industry is `pilot`; one with only medical licences is `medical`. + medical industry is `pilot`; one with only medical licences is `medical`; one where only + low-THC hemp may be grown or processed is `hemp`; one whose text begins "None" is `none`. + Licences that exist on paper but have never been issued still count as `medical` if the law + provides them; say so in the text. - `research` needs a stated route: a permit, a licence, a state agency that supplies researchers. If the text does not say how researchers get cannabis, leave the status out. @@ -550,4 +555,14 @@ names. "Not documented" never means "does not exist", so leave the section out r writing "none". +### Checking a profile + +`npm run profiles:audit` checks every profile (or `node scripts/profile-audit.cjs ke de` some of +them) for what the schema cannot see: one-line sections ("None."), statuses that contradict their +text, a Research section with no science in it, more than three spotlights, sentences without a +citation in a text that uses marks, words WRITING.md asks to avoid, GOV.UK travel advice outside +the UK. Errors must be fixed before committing; `--warnings` also shows the softer warnings. The +one-line texts in the four original sections (`products`, `production`, `medical`, +`consequences`) are old debt: rewrite them when you work on a country. + ### YAML pitfalls @@ -560,7 +575,19 @@ writing "none". ## Penalty steps -Possession and cultivation may carry a small table of what happens at each amount, under the +A segment may carry a small table of what happens at each amount or for each offence, under the segment's text. It never changes a score; it makes the text checkable and lets countries be -compared (`src/lib/penalties.js`). +compared (`src/lib/penalties.js`). Tables belong on four segments only: + +| Segment | What its table holds | +|---|---| +| `possession` | steps by amount held (grams), from legal or tolerated to criminal | +| `cultivation` | steps by plants grown | +| `sharing` | supply, sale and trafficking: by amount, or labelled steps ("supply to a minor") | +| `consumption` | only where using cannabis is itself an offence (Japan, Singapore, the Philippines) | + +Never on `enforcement` or `access`: those segments describe practice and supply routes, not +offences. A trafficking ladder goes on `sharing`. Never in a context section either (`driving`, +`visitors` and the others): the schema rejects it there, and the section's text already states +the penalty. ```yaml @@ -582,4 +609,5 @@ segments: prison_min: 5 days # a minimum sentence, where the law sets one; alone it reads "at least" fine: 5500 # the maximum, in `currency` + fine_min: 500 # a minimum fine, where the law sets one; alone it reads "at least" - label: Growing as a business # a step that is not about an amount treatment: criminal @@ -606,8 +634,21 @@ segments: - **Steps not about an amount** ("growing commercially", "a large quantity as set by the courts") get a `label` and no amount; they show in the table but don't count in the comparison figures. -- **Minimums, caning and death.** Where the law sets a minimum sentence, give it as `prison_min` - ("6 months – 5 years"). Corporal punishment goes in `caning: [fewest, most]` strokes, and a step - that carries the death penalty gets `death: true` (no `prison` needed). They show in the - Punishment column, and `penalties.summary` records whether a ladder reaches caning or death. +- **Every figure in its field, never only in the note.** Minimum prison: `prison_min`; minimum + fine: `fine_min`; strokes of the cane or lashes: `caning: [fewest, most]`; the death penalty: + `death: true` (no `prison` needed). The table then shows "6 months – 5 years", "At least + S$5,000", "and 3–6 strokes of the cane". `penalties.summary` records whether a ladder reaches + caning or death. The audit (below) fails on minimums or caning written only in a note. +- **Prison or a fine.** When the law gives alternatives ("at least three years or a fine of at + least S$5,000"), fill both minimums and say in `note`: "Prison or a fine, or both" (or "Prison + or a fine"). Without that note the table reads as prison and a fine together. +- **Notes are for conditions and exceptions** ("not prosecuted; police may seize it", "women and + men over 50 are not caned", "at home, over 60 g"), in one short line. Don't repeat figures the + fields already show; longer explanations go in the segment text. +- **`term` is the law's own word** for the offence ("Ordnungswidrigkeit", "misdemeanor", + "Consumption, proved by a urine test"). Give it for every civil and criminal step; it is shown, + never compared. +- **Steps run in order and join up.** Each amount step starts where the one before ends + (`over:` equals the previous `upto:`), treatments get no milder as amounts grow, and one ladder + counts either grams or plants, never both. - **Presumptions.** Where an amount is presumed to be for trafficking (Singapore above 15 g), give the trafficking punishment in the step, call it "Presumed trafficking" in `term`, and say @@ -617,5 +658,12 @@ segments: - Write the steps for a first offender aged over the legal age. Different rules for young people or repeat offences go in a spotlight, in the text, or in rows with `repeat`. -- The Netherlands, Czechia and Germany have them; use those as models. +- **Repeat offences with their own punishment** (Singapore's LT1 and LT2) get labelled steps of + their own, as in Singapore's consumption table. +- Models: the Netherlands, Czechia and Germany (amount ladders), Singapore (presumptions, caning, + the death penalty, a consumption table), Kenya and Tanzania once their notes are moved into + fields. +- **Check before committing:** `npm run profiles:audit` (or `node scripts/profile-audit.cjs <id>`) + lists mistakes the schema cannot catch: tables on the wrong segment, figures left in notes, + steps out of order, a punishment on a legal step. ## Research scope beyond law
docs/EXPAND-COUNTRIES.md +2 @@ -31,5 +31,6 @@ For each country in the batch: 3. Work through `docs/EXPAND-COUNTRY.md` in its order: current law and penalty tables, history, articles, context sections, culture, links. -4. Build (`npm run build`; wait if another build is running), then read the built page in +4. Build (`npm run build`; wait if another build is running), run + `node scripts/profile-audit.cjs <id>` (no errors allowed), then read the built page in `dist/country/<id>/`. 5. **Commit that country before starting the next**, with only your own files, and a message that
docs/EXPAND-COUNTRY.md +7 @@ -44,7 +44,8 @@ today: changed, **change the score too**, calibrated against comparable countries (look at a peer with a similar law and its scores), and add the change as a history entry with `before`. -- Add the **penalty tables** for possession and cultivation from the statute - (DATA-MAINTENANCE.md, "Penalty steps"): amounts, treatment, the law's term, minimum and maximum - prison, caning or death where they apply, and the fine. +- Add the **penalty tables** from the statute (DATA-MAINTENANCE.md, "Penalty steps"): possession + and cultivation by amount, supply and trafficking on `sharing`, and `consumption` only where using + is itself an offence. Put every figure in its field (minimum and maximum prison, minimum and + maximum fine, caning, death), the law's word in `term`, and only conditions in `note`. ## 3. History @@ -80,6 +81,6 @@ sections well"): `research` (science, not policy counting), `genetics` (real local plants only), `spotlights` (one to three things the scores do not show). -- Set the statuses where the text states the fact: `hemp`, `production`, `research`, `clubs`, - `driving`, `visitors`. +- Set the statuses: `hemp`, `production`, `research`, `clubs`, `driving`, `visitors`. Each must + say what its text says; read the text before choosing, and never fill statuses in bulk. How every section reads: @@ -115,4 +116,5 @@ How every section reads: ```bash npm run build # must pass; it checks schemas, citations and internal links +npm run profiles:audit # must report no errors for your country (node scripts/profile-audit.cjs <id>) npm run seo:audit # what the deploy runs after the build node scripts/country-gaps.cjs ke # the list should be shorter
package.json +1 @@ -15,4 +15,5 @@ "research:test": "node --test scripts/context-coverage.test.mjs", "seo:audit": "node scripts/seo-audit.mjs", + "profiles:audit": "node scripts/profile-audit.cjs", "answers:test": "node --test scripts/answers.test.mjs scripts/profile-claims.test.mjs scripts/use-freedom-ratio.test.mjs", "survey:test": "node --test scripts/survey.test.mjs",
scripts/profile-audit.cjs +122 @@ -0,0 +1,122 @@ +// Checks the penalty tables and context sections of every profile against the rules in +// docs/DATA-MAINTENANCE.md ("Penalty steps", "Writing context sections well") for mistakes the +// schema cannot catch. Prints each problem with its file; exits with 1 when it finds errors. +// +// node scripts/profile-audit.cjs every profile +// node scripts/profile-audit.cjs ke de some +// node scripts/profile-audit.cjs --warnings also the softer warnings (style, length) +const fs = require("fs"), y = require("js-yaml"); + +const args = process.argv.slice(2); +const warnings = args.includes("--warnings"); +const only = args.filter((a) => !a.startsWith("--")); +const fileOf = (id) => (fs.existsSync(`countries/${id}.yaml`) ? `countries/${id}.yaml` : `countries/${id}/index.yaml`); +const ids = (only.length ? only : fs.readdirSync("countries").map((f) => f.replace(/\.yaml$/, ""))).filter((id) => fs.existsSync(fileOf(id))); + +const GRAMS = { g: 1, kg: 1000, oz: 28.3495, lb: 453.592 }; +const amount = (t) => { const m = /^(\d+(?:\.\d+)?)\s*(g|kg|oz|lb|plants?)$/.exec(String(t ?? "").trim()); return m ? { v: m[2].startsWith("plant") ? +m[1] : +m[1] * GRAMS[m[2]], unit: m[2].startsWith("plant") ? "plants" : "g" } : null; }; +const DAYS = { day: 1, week: 7, month: 30, year: 365 }; +const days = (t) => { const m = /^(\d+(?:\.\d+)?)\s*(day|week|month|year)s?$/.exec(String(t ?? "").trim()); return m ? +m[1] * DAYS[m[2]] : null; }; +const RANK = { legal: 0, tolerated: 1, civil: 2, criminal: 3 }; +// words from docs/WRITING.md, "Words to avoid" +const AVOID = /\b(constitutes?|subsequently|thereby|in respect of|the instrument|the provision|operative (order|part)|establishes)\b/i; +const SCIENCE = /research|stud(y|ies)|scien|universit|institut|trial|laborator|paper|journal|discover/i; + +const errors = [], warns = []; +const err = (id, where, msg) => errors.push(`${fileOf(id)} ${where}: ${msg}`); +const warn = (id, where, msg) => warns.push(`${fileOf(id)} ${where}: ${msg}`); +const sentences = (t) => String(t).replace(/\[@[^\]]*\]/g, "").replace(/\[([^\]]+)\]\([^)]+\)/g, "$1").split(/(?<=[.!?])\s+(?=[A-Z0-9"'‘“(])/).filter((s) => s.trim().length > 2); + +for (const id of ids) { + const p = y.load(fs.readFileSync(fileOf(id), "utf8")); + const unit = id.includes("/"); + + // ── penalty tables ── + for (const [seg, block] of Object.entries(p.segments ?? {})) { + const rows = block?.penalties; + if (!rows) continue; + const where = `segments.${seg}.penalties`; + if (!["possession", "cultivation", "sharing", "consumption"].includes(seg)) err(id, where, `penalty tables belong on possession, cultivation, sharing (supply and trafficking) or consumption (where using is itself an offence), not ${seg}`); + let prevUpto = null, prevRank = -1, units = new Set(); + rows.forEach((r, i) => { + const at = `${where}[${i}]`; + const up = amount(r.upto), ov = amount(r.over); + [up, ov].filter(Boolean).forEach((a) => units.add(a.unit)); + const first = !r.repeat || r.repeat === 1; + // amount ladder: each step starts where the last one ended + if (first && !r.label && (up || ov)) { + if (ov && up && ov.v >= up.v) err(id, at, `over (${r.over}) is not below upto (${r.upto})`); + if (prevUpto !== null && ov && Math.abs(ov.v - prevUpto) > 0.5) warn(id, at, `starts at ${r.over} but the step before ends at ${prevUpto} ${[...units][0] ?? ""}: a gap or an overlap`); + if (prevUpto !== null && !ov && up) err(id, at, `a step with only upto after another step: give it over: (the previous upto)`); + prevUpto = up ? up.v : null; + } + // treatment should not get milder as amounts grow + if (first && !r.label && RANK[r.treatment] !== undefined) { + if (RANK[r.treatment] < prevRank) warn(id, at, `${r.treatment} after a harsher step; check the order`); + prevRank = Math.max(prevRank, RANK[r.treatment]); + } + if ((r.treatment === "legal" || r.treatment === "tolerated") && (r.prison || r.prison_min || r.fine || r.fine_min || r.caning || r.death)) err(id, at, `${r.treatment} with a punishment; a punishment means civil or criminal`); + if (r.treatment === "criminal" && !r.prison && !r.prison_min && !r.fine && !r.fine_min && !r.death && !r.note) warn(id, at, "criminal step with no punishment and no note saying why"); + if ((r.treatment === "criminal" || r.treatment === "civil") && !r.term) warn(id, at, "no term: give the law's own word for the offence"); + if (r.prison_min && r.prison && days(r.prison_min) > days(r.prison)) err(id, at, `prison_min ${r.prison_min} is longer than prison ${r.prison}`); + if (r.caning && r.caning[0] > r.caning[1]) err(id, at, "caning range is reversed"); + // facts that have a field of their own but were left in the note + const note = String(r.note ?? ""); + // a minimum prison term or a minimum fine written in the note instead of its field + if (/\b(at least|minimum of|not less than)\s+(\w+\s+){0,2}(day|week|month|year)s?\b/i.test(note) && !r.prison_min) err(id, at, `the note gives a minimum prison term ("${note.slice(0, 60)}"); use prison_min`); + const CUR = "(shillings|dollars|euros|pounds|francs|rupees|rupiah|pesos|naira|cedis|dinars|dirhams|riyals|ringgit|baht|yen|won|yuan|[A-Z]{3}|[$€£¥₹]|[A-Z]{1,2}\\$)"; + if (new RegExp(`\\b(at least|minimum of|not less than)\\s+(a fine of\\s+)?(${CUR}\\s?[\\d][\\d,.]*|[\\d][\\d,.]*(\\s?(million|thousand))?\\s*${CUR})`, "i").test(note) && !r.fine_min) err(id, at, `the note gives a minimum fine ("${note.slice(0, 60)}"); use fine_min`); + if (/\bstrokes?\b|\bcan(e|ing)\b|\blash(es)?\b/i.test(note) && !/\b(no|without|not)\b[^.;]{0,20}\b(can(e|ing)|lash|strokes?)/i.test(note) && !r.caning && !r.death) err(id, at, "the note mentions caning or lashes; use caning: [fewest, most]"); + if (r.prison && r.prison_min && /\b\w+ (months?|years?) (to|and|–|-) \w+ (months?|years?)\b/i.test(note)) warn(id, at, "the note repeats the prison range the table already shows; keep notes for exceptions and conditions"); + if (/\bdeath (penalty|sentence)\b|\bexecution\b/i.test(note) && !r.death) warn(id, at, "the note mentions the death penalty; if this step carries it, set death: true"); + if (note.length > 160) warn(id, at, `long note (${note.length} characters): put the explanation in the segment text`); + }); + if (units.size > 1) err(id, where, "mixes grams and plants in one ladder; possession counts weight, cultivation plants"); + if (block.text && !/\[@/.test(block.text)) warn(id, `segments.${seg}`, "has a penalty table but its text has no inline citations; the figures need their source"); + } + + // ── 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"); + for (const [key, b] of Object.entries(ctx)) { + const items = Array.isArray(b) ? b.map((x, i) => [`context.${key}[${i}]`, x]) : [[`context.${key}`, b]]; + for (const [where, it] of items) { + const text = it?.text ?? ""; + if (!text) continue; + const ss = sentences(text); + // a one-liner: an error in the newer sections, old debt (a warning) in the original four + const thin = /^none\.?$/i.test(text.trim()) || (ss.length === 1 && ss[0].trim().length < 40); + if (thin) (["products", "production", "medical", "consequences"].includes(key) ? warn : err)(id, where, `a one-line text ("${text.trim().slice(0, 50)}"); say what the situation is and cite it, or leave the section out`); + if (["recreational", "medical", "hemp", "production", "culture", "research", "genetics"].includes(key) && ss.length > 10) warn(id, where, `${ss.length} sentences; three to seven read best`); + if (/\[@/.test(text)) { + // with inline marks, every sentence should carry one (or share the next sentence's) + const raw = text.split(/(?<=[.!?](?:\[@[^\]]*\])?)\s+(?=[A-Z0-9"'‘“(\[])/); + const bare = raw.filter((s, i) => !/\[@/.test(s) && !(raw[i + 1] && /\[@/.test(raw[i + 1]) && s.length < 120)).length; + if (bare > 1) warn(id, where, `${bare} sentences without a citation mark`); + } + 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 (ctx.research?.text && !SCIENCE.test(ctx.research.text)) err(id, "context.research", "does not mention any science (studies, universities, trials, discoveries, research permits): it is about scientific research, not policy"); + if (Array.isArray(ctx.spotlights)) { + if (ctx.spotlights.length > 3) err(id, "context.spotlights", `${ctx.spotlights.length} spotlights; at most three`); + ctx.spotlights.forEach((s, i) => { if (s.title.split(/\s+/).length > 8) warn(id, `context.spotlights[${i}]`, `long title ("${s.title}"): a few plain words`); }); + } + if (ctx.culture?.text && !/\]\(\/(culture|history)\//.test(ctx.culture.text)) warn(id, "context.culture", "links no Culture or History page"); + if (ctx.production?.status === "none" && /licen[cs]|permit|authori[sz]ed (grow|produc|cultiv)/i.test(ctx.production.text ?? "") && !/no (licen|permit)|not (licen|permit)|none/i.test(ctx.production.text ?? "")) + warn(id, "context.production", "status none, but the text mentions licences or permits; check whether it is medical"); + const prod = (ctx.production?.text ?? "").trim(); + if (ctx.production?.status && !["none", "hemp"].includes(ctx.production.status) && /^none\b|^no (legal|licensed|lawful) (cannabis )?(production|cultivation)/i.test(prod)) + err(id, "context.production", `status ${ctx.production.status}, but the text says there is no legal production`); + if (ctx.production?.status === "medical" && /hemp/i.test(prod) && !/medic|pharma|patient|export|research/i.test(prod)) + warn(id, "context.production", "status medical, but the text describes only hemp: is it hemp (Hemp only)?"); + if (ctx.hemp?.status === "none" && /\b0[.,]\d+\s?%|\b1\s?%\s?THC|hemp (may|can) be grown|licensed hemp/i.test(ctx.hemp.text ?? "")) warn(id, "context.hemp", "status none, but the text gives a hemp THC limit or licensed hemp; check"); + } +} + +for (const e of errors) console.log("✗ " + e); +if (warnings) for (const w of warns) console.log("· " + w); +console.log(`\n${ids.length} profiles: ${errors.length} errors, ${warns.length} warnings${warnings ? "" : " (show them with --warnings)"}`); +process.exit(errors.length ? 1 : 0);
src/components/CountryArticle.astro +2 @@ -350,5 +350,5 @@ const culture = cultureOf(c); {seg.penalties && ( <table class="pen"> - <thead><tr><td>Amount</td><td>What happens</td><td>Punishment</td><td>Fine, at most</td></tr></thead> + <thead><tr><td>Amount</td><td>What happens</td><td>Punishment</td><td>Fine</td></tr></thead> <tbody> {seg.penalties.rows.map((r) => ( @@ -357,5 +357,5 @@ const culture = cultureOf(c); <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, at most">{r.fine ? money(r.fine, seg.penalties.currency) : "—"}</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> ))}
src/content.config.ts +2 @@ -41,9 +41,10 @@ const penalty = z.object({ death: z.literal(true).optional(), fine: z.number().positive().optional(), + fine_min: z.number().positive().optional(), // a minimum fine; alone it reads "at least" note: z.string().min(1).optional(), }).strict().refine((r) => r.upto || r.over || r.label, { message: "a penalty step needs upto, over or a label" }); // a segment: its cited text, and for possession and cultivation optionally the penalty steps const segment = cited.extend({ currency: z.string().regex(/^[A-Z]{3}$/).optional(), penalties: z.array(penalty).min(1).optional() }) - .refine((s) => !s.penalties?.some((r) => r.fine) || s.currency, { message: "penalties with a fine need a currency" }); + .refine((s) => !s.penalties?.some((r) => r.fine || r.fine_min) || s.currency, { message: "penalties with a fine need a currency" }); // a country spotlight: one thing that makes cannabis different there, with a short title const spotlight = z.object({ title: z.string().min(1), text: z.string().min(1), sources: z.array(z.string()) }).strict();
src/lib/penalties.js +1 @@ -48,4 +48,5 @@ export function penaltyRow(row) { death: row.death === true, fine: row.fine ?? null, + fineMin: row.fine_min ?? null, note: row.note ?? null, };
src/lib/segment-ids.js +3 @@ -67,4 +67,5 @@ export const INDUSTRY_STATUS = { pilot: { label: "Pilots only", tone: "decrim", help: "Legal supply for adults exists only in trials or experiments; elsewhere only medical production is licensed" }, medical: { label: "Medical only", tone: "partial", help: "Licensed production exists for medicine, research or export, but not for adults' recreational use" }, + hemp: { label: "Hemp only", tone: "partial", help: "Only low-THC hemp may be grown or processed under licence; no cannabis is produced legally for medicine or adults" }, none: { label: "None", tone: "illegal", help: "No legal cannabis production of any kind" }, }; @@ -92,6 +93,6 @@ export const STATUS_SECTIONS = { sub: "Green means plants with up to 1% THC count as hemp, light green the usual 0.3% line, orange a stricter line, red no exception for hemp at all.", note: "The THC limit under which a cannabis plant counts as legal hemp." }, - production: { name: "Cannabis industry", chip: "Industry", statuses: INDUSTRY_STATUS, tiers: { open: 5, state: 4, pilot: 3, medical: 2, none: 0 }, optional: true, - sub: "Green means a licensed market for adults, light green a state-run or pilot supply, orange licensed production for medicine only, red no legal production.", + production: { name: "Cannabis industry", chip: "Industry", statuses: INDUSTRY_STATUS, tiers: { open: 5, state: 4, pilot: 3, medical: 2, hemp: 1, none: 0 }, optional: true, + sub: "Green means a licensed market for adults, light green a state-run or pilot supply, orange licensed production for medicine only, darker orange licensed hemp only, red no legal production.", note: "Who may produce cannabis legally for sale." }, research: { name: "Cannabis research", chip: "Research", statuses: RESEARCH_STATUS, tiers: { open: 5, licensed: 4, restricted: 2, blocked: 0 }, optional: true,