Same shape as the eBible finding, same cause. Arclight was ALREADY a checked pool and
only 306 language codes carried a film link, while the catalogue lists 2,330 media
languages with 2,087 carrying a feature film. Being inside a pool is not the same as
having been read.
COVERAGE: pins with a real URL 6,985 -> 8,935, 56.4% -> 72.2%. Language codes 2,068 ->
3,196. 1,950 pins lift off "nothing found" and 1,214 move to Scripture AND film. The
resource table grows 1,497 -> 3,043 rows across 2,307 languages.
★ THE VERIFICATION HAD TO BE REDONE, and that is the part worth keeping. The obvious
check is to sample watch URLs and confirm 200. Ten of ten passed. The check was
worthless: https://www.jesusfilm.org/watch/?languageId=<id> is a single-page app and
answers 200 for EVERY id — measured, `0` and `abcdef` both return 200. A check that
cannot fail proves nothing, and that one would have certified 1,781 links on a status
code that is always 200.
The API discriminates where the page does not: media-languages/<id> returns a real
counts.featureFilm for a genuine language and 404 for a bogus one. So existence is
established from the API and the watch URL is the deep link for a language already
confirmed. Re-verified that way: 10/10 sampled languages carry featureFilm >= 1, and
the negative control — a bogus id — returns featureFilm 0, so the check can fail and
does, on the case that should fail.
featureFilm >= 1 IS THE COVERAGE TEST, not roster membership: 21 of the 2,330 listed
media languages have no feature film and are dropped. Only featureFilm is taken;
Arclight also carries short films and other media that were not screened.
FILL, NEVER OVERWRITE, as with eBible — a curated film_url stands, and the catalogue
only supplies a link where there was none. 1,695 of the codes had no row in the
resources table at all.
Verified: all browser probes pass — pins, table, deep link — 12,379 pins rendered, and
the map now reads green across Papua New Guinea, Indonesia, Brazil and Central Africa
where it was red, leaving the remaining red concentrated in the Sahel, the Horn, north
India and Central Asia. The pin table is 1.1MB but 281KB gzipped; the four repeated
pool strings compress 4:1, so interning them would buy nothing on the wire.
eBible was ALREADY one of the two pools this repo checked, and that is what made the
gap invisible: 120 language codes carried an eBible link while the catalogue lists
1,550 translations across 1,239 codes with canonical books. The pool had been read at
roughly a tenth of its content, and ~1,600 people groups were being told "nothing
found" about a published translation in their own language.
COVERAGE: pins carrying a real URL 5,349 -> 6,985, 43.2% -> 56.4%. Language codes
978 -> 2,068. The resource table grows 581 -> 1,497 rows across 1,314 languages. 1,090
of the new codes had no row in the resources table at all, which is why filling gaps
in the existing 862 would have missed most of it.
FILL, NEVER OVERWRITE. An existing scripture_url was curated and verified by an earlier
pass; the catalogue is broad but not hand-checked, so it only supplies a link where
there was none. Where the two disagree, curation wins.
CANON TEST: OTbooks + NTbooks > 0. A deuterocanonical-only publication is not the
66-book canon and is dropped; DC books ALONGSIDE OT/NT are not a reason to drop, since
the 66 are present either way. Measured on this snapshot: 3 rows carry no books at all.
DOMAINS ARE ALLOW-LISTED AFTER A SCREEN, because publicationURL does not always point
at ebible.org. Four domains appear and each was fetched and read: ebible.org (1,242),
png.bible (302, "Papua New Guinea Scriptures"), alkitab.pw (3, an eBible mirror), and
inscript.org (1) — EXCLUDED, because its page establishes no publisher or doctrinal
provenance and an unclear source is left out. A domain not on the list is dropped, so a
future catalogue update cannot introduce a publisher nobody screened.
TWO BROKEN LINKS CAUGHT STRUCTURALLY: nko and nlx publish 'http:///nkoNT/' — a
malformed URL with an EMPTY host, broken in eBible's own data. Requiring a non-empty
host rejects them; a looser check for the 'http://' prefix passes them happily, which
is how a "real" link that resolves nowhere gets shipped. badUrl and badDomain are
counted separately for the same reason — folded together, this hid.
Verified: the CSV parser was diffed against a reference parser across all 1,550 rows,
0 mismatches, so no URL is attached to the wrong language. A seeded random sample of 20
catalogue URLs resolved 20/20 at 200. All five browser probes still pass — pins, table,
deep link, Door43 lift, and 12,379 pins rendered — and the map now shows Papua New
Guinea, Indonesia and South America carrying resource colour where they were solid red.
The table listed every link but had no idea what you had just tapped, so answering
"what exists for THIS group" meant reading a language name off the map and typing it
into a filter box. The map and the table are separate PAGES, so the selection travels
as a URL: ?tab=links&iso=<code> from a people group, &country=<ISO3> from a country.
FILTERED IN THE PROFILE, NOT IN THE SHARED COMPONENT. DataTable has other consumers
and none of them need a filter prop; teaching it one would put a change with repo-wide
blast radius behind a feature only this bundle uses. The rows are filtered before they
are handed over, which is invisible to every other caller.
COUNTRY FILTERING WITHOUT A SECOND FETCH. Rows carry country_codes, the ISO3 set the
language is spoken in, so a country filter is a membership test on data already loaded
rather than pulling the 679KB pin table just to resolve which languages a country has.
+23KB for the column, against 679KB avoided.
THE EMPTY CASE SAYS WHY. A selection with no match renders "nothing found for this
selection in the pools checked, which is not proof that nothing exists" rather than an
empty grid, which would read as a broken filter or, worse, as proof of absence.
Switching to a tab that has no language key says so too instead of showing zero rows.
Verified end to end on the served tree: clicked a real pin, followed the link THE SHEET
RENDERED rather than one built in the test, and landed on 2 rows both carrying cmn with
"Showing 2 of 581 resources for Chinese, Mandarin". Country path checked the same way —
Nepal 51, Chad 22, unfiltered 581 with no banner, and an unmatched code showing the
honest empty state.
Every table in this bundle is keyed by PEOPLE GROUP and answers "who has nothing".
None of them answered the question a mobiliser asks first: what exists, what is it
called, and where do I open it. Answering it from a group-grained table means reading
the same JESUS Film link off forty rows.
581 rows, one per language x resource, across 413 languages, together reaching 5,349
people groups — the same 5,349 the pin build counts as carrying a real URL, so the two
tables cannot drift without one of them being wrong.
Reach is MEASURED, not assumed: people_groups, speakers and countries are aggregated
across the pins that actually speak that language, so a row says how many people that
one link serves. Sorted by reach, because the link serving the most people is the one
to open first.
The column is named resource_url because DataTable keys on /_url$/ to render a real
anchor instead of printed text. That is a contract with the shared component, not a
naming preference — rename it and every link silently becomes a string.
TYPES STAY HONEST. 146 Scripture, 306 Film, 129 Bible Stories. Open Bible Stories is
typed as Bible Stories and never as Scripture, and the meta says why. The pools caveat
and the doctrine boundary both render under the table rather than living in a file
nobody opens: only 66-book-canon publishers are included, and the excluded ones are
named so their absence reads as a rule rather than an oversight.
Verified at 1280x900 on the served tree: the tab loads first, renders all ten columns
and 50 rows a page, and 50 rows carry real anchors to ebible.org, jesusfilm.org and
door43 — clicked through from the page, not read out of the JSON.
Two pools had ever been checked — eBible and JESUS Film — and the map said "nothing
found" for everyone outside them. For 240 people groups that claim was simply untrue:
unfoldingWord publishes a released resource in their language and we were not looking.
WHAT WAS ADDED. A dated snapshot of the Door43 catalog (stage=prod only) keyed by ISO
639-3, 165 codes across Open Bible Stories, Bible and Aligned Bible. Cached rather than
fetched live so the pin table is reproducible offline and does not depend on someone
else's uptime; the source URLs travel in the file.
stage=prod IS THE WHOLE CARE. Door43 LISTS more languages than it HOSTS, and this repo
already recorded that trap — "roster membership is not coverage". Released entries only,
and the result reconciles cleanly against what was already known: of 38 languages
previously flagged as having Open Bible Stories, 38 are confirmed and 0 contradicted.
It also found 11 flagged false that do have a published resource.
THE BIGGER HALF WAS INVISIBLE. 116 of the 165 codes have no row in
people-group-resources.json at all, so there was no record to hang a link on and their
pins stayed "nothing found" no matter what Door43 published. Those codes are now created
from the catalog, which is where 213 of the 240 lifted pins come from.
COVERAGE: pins carrying a real URL 5,109 -> 5,349 (41.3% -> 43.2%); codes 862 -> 978.
Tier 1 now means SOMETHING was found rather than "one of scripture/film", because that
is what the colour has to mean once a third pool exists. Tier 2 still means Scripture
AND film — a story set does not get promoted into a Bible.
OBS IS LABELLED AS STORIES. The chip reads "Bible Stories · Door43", never Scripture:
Open Bible Stories is 50 illustrated narratives drawn from the canon, and telling a
reader they have a translation when they have a story set is the one lie this map
cannot afford. 2-letter and region-suffixed tags are DROPPED rather than guessed into
639-3 — a wrong language match ships a real link to the wrong people.
Verified on the served tree: the Kir people group (Mandeali, mjl) previously showed
nothing and now renders a real anchor to git.door43.org/OBS-TLF/mjl_obs, which answers
200. The existing pin probe still passes 12,379 rendered with both Han Chinese links
at 200, so the new pool added coverage without disturbing the old.
The map shaded COUNTRIES and stopped there, so the one question it could not answer
was the only one that matters in the field: for THIS group, what exists right now?
12,379 people-group pins now sit on it, each one clickable into the resources for
its language.
THE JOIN. people-groups-slim.json carries Lat/Long per people-group-in-country;
people-group-resources.json carries the real scripture_url/film_url per LANGUAGE.
The key is ISO 639-3 (slim.LanguageCode == resources.iso639_3), never the group
name — a group is reached in its language, not its nationality, and names collide
across unrelated languages.
MEASURED at build time: 5,981 of 12,379 pins (48.3%) join a resource record and
5,109 (41.3%) join one carrying a real URL, across 862 language codes. The other
59% is not a gap in the data to be hidden — it IS the subject. A pin with nothing
found sorts ABOVE a covered one, so the gap wins the z-fight at world zoom rather
than disappearing under a green dot.
NO URL IS INVENTED. Every link is copied from the resources table, which records
its own checked_pools/unchecked_pools, and the sheet keeps saying "none found
means absent from the pools checked, never proof that nothing exists". has_audio
keeps its third state: absent means the pool was never queried, not a no.
PIN COLOURS READ OFF BANDS rather than declaring a fourth palette, so a pin and
the country under it can never disagree about what "nothing found" looks like.
The table is generated into data/tables/ — the REPO ROOT copy, which is what
generate-tree publishes. The plugin-local data/tables/ is a stale divergent
duplicate that nothing serves; building into it ships a file no page fetches.
Verified on the served tree at 1280x800: 12,379 pins loaded AND rendered, layer
and source present, and a click on Han Chinese returns ebible.org/cmn2006/ plus
jesusfilm.org languageId=53352, both rendered as real anchors in the sheet and
both answering 200. window.__grState() exposes those counts so the next check does
not have to guess at screen coordinates to hit a 3px dot.
generate-gallery.mjs:542 requests assets/vendor/vue/vue.global.prod.js, and
generate-tree.mjs staged only vendor/tabulator -- so that request has 404'd
on every build there has ever been.
It survived because loadVue() degrades in three rungs: self-hosted, then the
jsDelivr CDN, then a plain-DOM render. On any networked machine rung 2
silently covers for rung 1, and the only symptom was a 404 in `bun run
smoke`, a gate nobody was running. Good degradation hid a missing build step.
It mattered anyway: the comment above loadVue promises "self-hosted first
(portable / offline / no-CDN)". Measured in a browser with no outbound
network, the gallery fell all the way to the plain-DOM degrade -- usable, but
not the Vue shell, and the offline claim was false. A tree dropped on a
foreign host or opened over file:// is exactly what that rung is for.
Vendored from node_modules/vue 3.5.42, placed in data/vendor/ (the real
source -- generate-tree rmSyncs public/assets/vendor and re-copies from
data/), tracked in both places exactly as vendor/tabulator already is.
Verified: build exit 0 with zero missing-asset warnings; the gallery now
loads Vue 3.5.42 FROM THE VENDORED COPY (not the CDN), the Vue app mounts,
nav renders 5 rows. `bun run smoke` went from 4 failures to 2.
A correction en route: I first blamed `bun run clean` for deleting the file.
clean.mjs only wipes public/doxa-maps, never public/assets -- the real cause
was generate-tree.mjs:1189 rmSyncing public/assets/vendor and re-copying it
from data/vendor, which is why tabulator persists and a hand-placed file
does not.
- data/ tables and the people-groups file: meta blocks reduced to provenance (source, url,
downloaded, rows, grain, attribution); the link-verification table keeps only state and detail
per link; generators write the same shape
- dropin.exclude in the build config: people_groups.csv, open-doors.json, platform-reachability.json
and tables/index.json are not shipped (nothing loads them); sync-dropin honours it
- public-hygiene --strict flags note-style meta keys in shipped data
- Rebuilt bundles and gallery
- index.html is generated by internal/generate-spa.mjs from doxa-maps/manifest.json: one
self-contained page that lists every bundle, profile and instance, mounts the chosen one,
and offers Open page / Copy embed; build ends with `bun run spa` (the material shell is no
longer installed; nav.js, icons.js, languages.js, pane-wrapper/, external/ and _headers
leave public/)
- The website drop-in follows the same contract: dropin.include in the build config,
sync-dropin --from-config --prune, public-hygiene --strict fails on any other root entry
- Production bundles no longer carry CSS comments: a build-only Vite plugin strips them from
every shadow-style block (about 140 per map bundle, 5% smaller); remaining comments reworded
- Share pages and the pin renderer drop their note-style comments
data-private/ is born: datasets whose publication could help close doors stay
out of the web-root and out of public repos, permanently. field-access-visas
moved there; the tables index no longer lists it; the key-cities Field Access
tab is removed from the public build; the table generator routes it (and any
future PRIVATE_TABLES entry) to data-private/ and excludes it from the index.
Public-good data (internet gaps, gospel-resource gaps, people-group pairings)
ships as before.
posters/ (42M webp set the poster walls fetch) and tables/ (the merged served
table set — its index.json is the merge; per-profile data/tables remain the
build-time sources) now live in data/ with build copy-steps. Byte parity
verified; served paths unchanged. public/ is now FULLY regenerable — every
source-of-truth folder has a home outside the web-root.
public/ was confusing because three source-of-truth folders lived ONLY inside it.
They now live in data/ (people-groups, vendor, external-bundles) and the build
copies them into the served tree (same pattern as boundaries), so served paths
are unchanged and public/ is fully regenerable. data/README.md states the law.