A documented ledger of corporate & political harm, with primary sources. Built with Eleventy — a static site generator. No database, no server. You author Markdown + JSON; the build compiles it into a fast static site with client-side filtering and search, ready for translation.
- Install Node.js 18+ (for global
fetch; LTS is fine). - In this folder, run:
npm install
-
Preview locally (auto-reloads as you edit):
npm run serveOpen the URL it prints (usually http://localhost:8080).
-
Build for deploy:
npm run buildThe finished site lands in
_site/. -
Sanity-check the client JS before committing (catches syntax errors that only surface in the browser):
node --check src/assets/filter.jsMake this a habit. A syntax error here silently halts the search/filter script — the page loads but interactivity dies.
node --checkcatches it in one second; the browser console catches it only after you've deployed.
- Push this repo to GitHub (or GitLab/Bitbucket).
- Go to vercel.com, import the repo.
- Vercel auto-detects settings from
vercel.json:- Build command:
npx @11ty/eleventy - Output directory:
_site
- Build command:
- Every push to
mainauto-deploys.
Manual deploy with the Vercel CLI: npx vercel --prod. The site also works on
Cloudflare Pages, Netlify, or any static host (build npm run build, output
_site).
The project separates three kinds of data so it can support multiple languages without duplicating sources or drifting out of sync. This is the Option B i18n model — understand it before refactoring, because several non-obvious decisions here are load-bearing.
src/
├── entries/
│ ├── _meta/{slug}.json # LANGUAGE-NEUTRAL metadata — the single source of truth
│ │ # { slug, section, region, status, date,
│ │ # sources:[{label,url,archive_url,video?}], last_verified }
│ └── en/{slug}.md # TRANSLATABLE text only — frontmatter { title, tag } + Markdown body
│ # (de/{slug}.md, es/{slug}.md added during i18n rollout)
├── _data/
│ ├── site.js # language-NEUTRAL config: section/region/status KEYS + order,
│ │ # form keys, site URL. NO display labels here.
│ ├── locales/en.js # ENGLISH UI strings: section/region/status LABELS, tagline,
│ │ # kicker, disclaimer, all chrome, pullquotes, fallback note
│ ├── eleventyComputed.js # aliases the active language's strings to a bare `i18n`
│ └── resolvedEntries.js # the RESOLVER (see below)
├── _includes/
│ └── layout.njk # the ONE live HTML shell (head, body, search-index inline)
├── en.njk # builds "/" — sets lang=en, pulls resolved entries
├── assets/
│ ├── style.css # all styling
│ ├── filter.js # client-side filters + search (one IIFE)
│ ├── markdown-blocks.js # Markdown→typed-blocks compiler + shared HTML renderer
│ └── fonts/ # self-hosted woff2 (you add these)
└── scripts/
└── archive-sources.js # Wayback snapshots → _meta (run locally, see below)
- Sources live once in
_meta/{slug}.json— never duplicated per language, so an archive URL or a corrected source is fixed in exactly one place. - Metadata is keys, labels are i18n.
_metastoressection: "climate"; the human label "Climate Deception" lives inlocales/en.js. A new language is a new locale file, not re-tagged entries. - Bodies are authored in Markdown, compiled to data at build time. You write
prose;
markdown-blocks.jsturns it into a typed-block array that both the static page and the future lazy-loader/JSON consume. No Markdown parser runs in the browser.
For a given language it walks every _meta/{slug}.json, finds the matching
entries/{lang}/{slug}.md (falling back to en/ if absent, flagging
translated: false), compiles the body to blocks, merges metadata + text, and
sorts newest-first. Templates render from its output. Adding a language is a
content task, not a logic change — the fallback path already exists.
Parses via markdown-it's AST (NOT regex) with linkify:false (every link must
be deliberate — no auto-linkifying bare domains in an accountability ledger).
Any Markdown token it doesn't explicitly handle throws a build error naming
the entry. This is intentional: silent content loss is the worst failure mode
here, so the build fails loudly instead of dropping text. If you use a new
Markdown construct in a body and the build errors, add explicit handling to the
compiler — don't work around it.
An entry is two files sharing one slug:
1. src/entries/_meta/{slug}.json — the neutral metadata:
{
"slug": "company-x-case",
"section": "corruption",
"region": "us",
"status": "settlement",
"date": "2024-06-01",
"sources": [
{ "label": "Reuters — headline of the source", "url": "https://example.com/article" },
{ "label": "Documentary title (official)", "url": "https://www.youtube.com/watch?v=XXXX", "video": true }
]
}2. src/entries/en/{slug}.md — the translatable text:
---
title: Company X — What They Did
tag: "Settlement · 2024"
---
The body text goes here, in plain prose. You can use **bold**, *italics*,
inline `code`, and links. Keep it tight. Paraphrase sources; don't paste long
quotes. State plainly what tier of proof this is (see Editorial standard below).Then run npm run archive (to snapshot the new sources), npm run build, and it
appears in the right section, sorted by date, with working filters and search.
The slug must be identical in both filenames — it's the unique key that pairs
metadata, text, and (at runtime) the DOM element for search.
| Field | File | Required | What it does |
|---|---|---|---|
slug |
_meta |
yes | Unique ID. Must match the en/ filename. The pairing key for everything. |
section |
_meta |
yes | Section key. Must exist in site.js sections. |
region |
_meta |
yes | Region key. Must exist in site.js regions. |
status |
_meta |
yes | Tier/credibility key. Must exist in site.js statuses. See Editorial standard. |
date |
_meta |
yes | ISO YYYY-MM-DD. Drives sort (newest first) + date filter. |
sources |
_meta |
no | [{ label, url, video?, archive_url? }]. archive_url is written by the archive script. |
last_verified |
_meta |
auto | Written by npm run archive. |
title |
en/*.md |
yes | The entry headline. |
tag |
en/*.md |
no | Small red label above the title, e.g. "Settlement · 2008". |
Valid section / region / status keys live in src/_data/site.js;
their human labels live in src/_data/locales/en.js. To add a new section,
region, or status: add the KEY (+ order/num) in site.js AND the LABEL in every
locales/*.js. Miss the label and it renders blank.
Every entry declares how settled its claims are, via status. Keeping these
honest is what makes the ledger credible and hard to dismiss. Never inflate a
tier upward.
status |
Meaning | Use for |
|---|---|---|
ruling |
A court convicted or formally ruled | ICTR/ICTY judgments, criminal convictions |
finding |
A state or official body formally determined | UN commission findings, recognized genocides |
warrant |
Formal charge filed, not yet a conviction | ICC arrest warrants, indictments |
settlement |
Plea or settlement (often "no admission of liability") | DOJ pleas, civil settlements |
report |
Investigation / journalism / audit | IG reports, investigative findings |
alleged |
Serious allegation, in court, not adjudicated | active lawsuits |
ongoing |
Live, evolving situation | document releases, in-progress cases |
context |
Genuine debate, two defensible sides | structural critiques, contested theories |
law / win |
Legislation / accountability success | the "Accountability & Wins" section |
Rules of thumb: "plausible" is not "ruled"; "alleged" is not "proven"; "mentioned in a file" is not "guilty." Paraphrase sources, link primary documents, lead with the perpetrator's own admitted record where it exists, and never overstate what a court actually decided. For the highest-stakes categories (genocide, abuse, naming individuals) the tier label is the firewall that protects the whole project — get it exactly right or don't publish the entry.
Each content section folds/expands by clicking (or Enter/Space on) its heading. All start expanded — nothing hidden from readers, crawlers, or no-JS users.
- The "Collapse all / Expand all" button in the sticky filter bar flips every section; its label reflects the dominant state.
- Collapsed state is session-only — never written to storage or the URL.
- Filters and search force matching sections open, so results are never hidden behind a folded heading.
- A section's pullquote hides if the section has zero visible entries.
- With JS disabled the collapse UI doesn't appear and everything renders expanded — progressive enhancement throughout.
Instant, client-side full-text search in the filter bar. The index is emitted by
the build as /search-index.json and inlined into the page as
window.__SEARCH_INDEX__; filter.js reads that (falling back to fetching the
JSON, then to DOM-scraping). Each row is paired to its DOM element by unique
slug — never by section+year (that key is not unique and silently mis-pairs
entries; see Pitfalls).
Behaviour:
- Tokenized on whitespace; every token must match somewhere (AND semantics).
- Active query → flat-results mode: section headings + pullquotes hide, entries sort by relevance, a red section eyebrow labels each card. Clearing restores the grouped layout exactly.
- Matches are wrapped in
<mark>in BOTH title and body (title = strong red, body = lighter tint). Highlighting uses DOM text-node replacement across all text nodes (neverinnerHTMLwith raw input), so every match in every result is marked. - Facet chips compose with search (must pass chips AND query).
- Query reflected in URL as
?q=…viahistory.replaceState(shareable). - Keyboard:
/orCmd/Ctrl-Kfocuses;Escclears and blurs.
Scoring weights (tunable via the W object atop src/assets/filter.js):
| Signal | Points |
|---|---|
| Full query phrase in title | +100 |
| Each token in title | +25 |
| Title starts with a token | +10 bonus |
| Each token in tag | +8 |
| Each token in section label | +5 |
| Each token in body | +2 |
Score 0 = hidden. Ties broken by original document order (stable).
Progressive enhancement: with JS off the search box is hidden (.js-only);
the grouped ledger renders normally.
Dead links read like vanished evidence. npm run archive captures a permanent
Wayback snapshot of every source and writes
archive_url + last_verified back into entries/_meta/{slug}.json (never
per-language — sources are neutral). The site renders each source as
source · archived.
npm run archive # snapshot any source missing an archive_url
npm run archive -- --force # re-snapshot everything (yearly refresh)
Idempotent (already-archived sources skipped). Some publishers block the Wayback
crawler; those are left as-is and reported — re-run later, or prefer an
archivable primary source (a court PDF over a paywalled write-up). Commit the
_meta changes the script makes.
Run this locally, not on Vercel — the deploy filesystem is ephemeral so writes wouldn't persist, and Save Page Now is too slow for a build step.
The "Suggest an Entry" section uses Web3Forms (free, no account, submissions go to your email).
- Get an access key at web3forms.com (enter your email).
- Paste it into
src/_data/site.js→web3formsKey: "your-key-here". - Rebuild — the form activates. Leave it empty and a placeholder shows instead.
Spam protection: a hidden honeypot field (bots fill it, humans don't, filled =
dropped). Optionally add a free Cloudflare Turnstile
site key to turnstileSiteKey in site.js.
The design uses self-hosted JetBrains Mono and Newsreader specifically so
visitor IPs never leak to Google Fonts — a deliberate choice for a site about
surveillance. Do not reintroduce fonts.googleapis.com / gstatic.com
links or preconnects in layout.njk; an external font CDN contradicts the
project's privacy stance and has been removed before.
Place the woff2 files in src/assets/fonts/. The @font-face rules in
layout.njk's <head> declare the family names and file paths. Two ways to
supply files:
- Static per-weight (what you likely have): one
@font-faceper weight/style you use — JetBrains Mono 400/700/800, Newsreader 400/600 + italic — each pointing at its own woff2. Match filenames to thesrc:URLs. - Variable font (one file per family covering all weights): name them so the
existing weight-range
@font-facerules resolve.
JetBrains Mono is Apache-2.0, Newsreader is OFL — both freely self-hostable. Until the files exist the page falls back to system mono/serif (and the console shows harmless 404s for the missing woff2).
These all bit during development. Each costs hours if rediscovered cold.
_data/locales/is namespaced by folder. A file at_data/locales/en.jsis exposed to templates aslocales.en, noti18n. Templates use a barei18n.*, aliased via_data/eleventyComputed.jsbased on the page'slang. Don't re-nest or rename without updating the alias.- Nunjucks auto-escapes — inlined JSON must be emitted raw + escaped. The
window.__SEARCH_INDEX__JSON contains&/</>(e.g. "Bayer & Monsanto"). Output via| safewith&/</>pre-escaped as\u0026/\u003c/\u003e(done in the data layer). Plain{{ searchIndex }}turns&into&and throwsUnexpected token '&'. - Search pairs index rows to DOM by unique
slug(data-slugon each entry, matched inbuildIndexFromData). NEVER pair by section+year — it's not unique and silently shows the wrong entry / drops highlights. _site/index.html(and the repo-rootindex.html) are build artifacts. Never hand-edit them; the build overwrites them. Editsrc/.- One live layout only. There is a single HTML shell:
layout.njk. If a stalebase.njkever reappears, delete it — a dead template that still contains correct-looking markup causes false debugging leads. - The body block compiler fails loud. A build error from
markdown-blocks.jsnaming an entry means that entry uses an unhandled Markdown construct — add handling to the compiler; never silence it by dropping content.
A 60-second smoke test that catches the regressions seen during development:
node --check src/assets/filter.jspasses (no JS syntax error).- Count match: number of
entries/_meta/*.json=== number ofentries/en/*.md. - View built
_site/index.htmlsource:- the page title, section headings, search placeholder, filter labels all
render (not blank → confirms
i18naliasing works); window.__SEARCH_INDEX__ =is valid JSON with literal&/</>or\u00xxescapes — never&;- NO
fonts.googleapis.com/gstatic.comanywhere in<head>; - the favicon, canonical, OG and Twitter tags are present.
- the page title, section headings, search placeholder, filter labels all
render (not blank → confirms
- In the browser: search a distinctive term (e.g. a company name) → only entries that actually contain it appear, each with visible title/body highlights, and the browser's own Ctrl+F finds the term in every shown result.
- Search → clear → different search: no stale results or leftover highlights.
- Collapse a section, run a search that matches inside it → it auto-opens; clear → returns to its prior state.
- FLIP reflow
[Violation]console warnings during search at the current entry count are cosmetic (30–56ms). They scale with DOM size and are slated to be addressed alongside the planned lazy-load / endless-scroll work, which changes the performance model (a screenful in the DOM instead of everything). - woff2 404s until the font files are added (see Fonts) — system-font fallback works meanwhile.
- i18n: English-only output ships today; the German/Spanish rollout is the
next phase. See
docs/I18N.md. The resolver fallback path already exists, so adding a language is content + a locale file, not new logic.
This project documents wrongdoing and links to evidence, distinguishing what a
court ruled from what a body found from what remains alleged or contested (the
status tiers above). Keep that discipline when adding entries — it is what
makes the page credible and hard to dismiss. Paraphrase sources, link primary
documents, and never overstate what has actually been decided.