Merge commit '9df086e5' into bundler-1/work

# Conflicts:
#	internal/check-optional-t-equivalence.mjs
This commit is contained in:
Doxa Maps 2026-09-27 02:00:00 +00:00
commit bdff90ce82
38 changed files with 25444 additions and 9000 deletions

File diff suppressed because one or more lines are too long

View file

@ -59,7 +59,7 @@
"meta": {
"title": "The Deaf Gap",
"grain": "one row per Deaf people group",
"generated": "2026-09-25",
"generated": "2026-09-27",
"measured": "150 sign languages · 234 people groups · 84,159,050 people. 136 languages / 183 groups / 55,199,840 people with NOTHING found.",
"bridge_rule": "A sign language is bridged ONLY by another sign language. Text and audio in a spoken language do not reach a Deaf community.",
"compounding_problem": "40 of these languages carry PRIVATE-USE (qaa-qtz) codes outside ISO 639-3. No resource pool indexes a private-use code, so those groups cannot be matched to a resource even if one exists.",
@ -2621,7 +2621,7 @@
"iso639_3": "jos",
"countries": "SYR",
"population": 221000,
"signed_film": "https://www.jesusfilm.org/watch/?languageId=7138",
"signed_film": "https://arc.gt/g8wmw",
"has_signed_film": true,
"has_any_resource": true,
"code_unmatchable": false,
@ -2660,7 +2660,7 @@
"iso639_3": "jos",
"countries": "JOR",
"population": 116000,
"signed_film": "https://www.jesusfilm.org/watch/?languageId=7138",
"signed_film": "https://arc.gt/g8wmw",
"has_signed_film": true,
"has_any_resource": true,
"code_unmatchable": false,
@ -2738,7 +2738,7 @@
"iso639_3": "jos",
"countries": "LBN",
"population": 51000,
"signed_film": "https://www.jesusfilm.org/watch/?languageId=7138",
"signed_film": "https://arc.gt/g8wmw",
"has_signed_film": true,
"has_any_resource": true,
"code_unmatchable": false,

View file

@ -3,7 +3,7 @@
"title": "Doxa people groups — what the gospel is available in, and what is missing",
"grain": "one row per people group on Doxa's roster",
"source": "pray.doxa.life roster joined to the resource index by ISO 639-3",
"generated": "2026-09-26",
"generated": "2026-09-27",
"groups": 2107,
"by_access": {
"nothing found": 369,
@ -19768,7 +19768,7 @@
"translated_by": "",
"translated_year": "",
"scripture_url": "",
"film_url": "https://www.jesusfilm.org/watch/?languageId=7138",
"film_url": "https://arc.gt/g8wmw",
"audio_url": "",
"bible_video_url": "",
"stories_url": "",
@ -20048,7 +20048,7 @@
"translated_by": "",
"translated_year": "",
"scripture_url": "",
"film_url": "https://www.jesusfilm.org/watch/?languageId=7138",
"film_url": "https://arc.gt/g8wmw",
"audio_url": "",
"bible_video_url": "",
"stories_url": "",
@ -21928,7 +21928,7 @@
"translated_by": "",
"translated_year": "",
"scripture_url": "",
"film_url": "https://www.jesusfilm.org/watch/?languageId=7138",
"film_url": "https://arc.gt/g8wmw",
"audio_url": "",
"bible_video_url": "",
"stories_url": "",

View file

@ -9,7 +9,7 @@
"bridge_strength_note": "MEASURED: 24% of aligned rows rest on a bridge shared by only 2-3 groups. bridge_strength grades this (strong >=8 groups, moderate >=4, weak <4). A WEAK bridge is a hypothesis, not a route — e.g. \"Gawri\" was offered as the bridge for 10.3M Pakistanis where the real lingua franca is Urdu, which no unengaged group here claims as primary and which this method therefore cannot see. Filter on bridge_strength before acting.",
"bridge_caveat": "A bridge candidate is measured from THIS corpus (a language primary to >1 DX group in the country). It is NOT proof the group speaks it, and it is NOT a national trade language — a lingua franca no unengaged group claims as primary is invisible here.",
"pools_caveat": "Resource status is from eBible + JESUS Film. GRN and FCBH audio are key-blocked and unchecked — absence is never proof.",
"resource_flags_refreshed": "2026-09-25",
"resource_flags_refreshed": "2026-09-27",
"resource_flags_source": "data/tables/people-group-pins.json — the same index the map draws. Curated bridge, internet and channel columns are untouched."
},
"columns": [

File diff suppressed because it is too large Load diff

View file

@ -2,11 +2,12 @@
"meta": {
"title": "Gospel access by province — India and China",
"why": "These two countries hold 47.9% of Doxa's roster (855 + 154 of 2,107). No other country is close; the third is Sudan at 105. A national fill cannot answer \"where would a team go\" for half the map.",
"generated": "2026-09-26",
"generated": "2026-09-27",
"provinces_with_groups": 46,
"provinces_available": 48,
"groups_assigned": 1005,
"groups_unassigned": 4,
"per_group_file": "gospel-by-province-groups.json — one row per placed group, reconciled with these counts at build time",
"india_note": "India is read from the COMPLETE ADM1 (36 states, geoBoundaries via DataMeet India, CC BY 2.5 IN). The first build of this table read gb-IND-ADM1.geojson, which is the UPG-100 list's curated 34-unit partition, and eleven groups came out with no province because they stood in states that partition does not draw. Switching the file moved 998 assigned to 1,005 and 44 province rows to 46, and turned the province names into India's own states.",
"unassigned_note": "Four remain, each one legible rather than lost. Great Andamanese and the Sentinelese sit on small islands whose simplified coastline the point falls just outside — Andaman and Nicobar IS in the file, so this is geometry precision, not a missing province. Jing and Pana are on China's Vietnam and Laos borders, outside the twelve-province subset. They are listed in `unassigned` with their coordinates.",
"china_note": "China's ADM1 file carries 12 provinces, not 31+. Measured rather than assumed: 152 of 154 Chinese groups fall inside them and all twelve are used, so the file is cut to the provinces where the unreached peoples are.",

View file

@ -1,7 +1,7 @@
{
"meta": {
"title": "Gospel resource link verification",
"generated": "2026-09-25T09:56:59.860Z",
"generated": "2026-09-27T01:21:18.374Z",
"method": "HEAD, redirects followed. verified = HTTP 200. broken = 404/410. unknown = unreachable, an ambiguous status, or a URL shape that cannot be checked by fetch.",
"scope": "Every URL in data/tables/people-group-pins.json, deduplicated.",
"states": {
@ -96,12 +96,12 @@
]
},
"summary": {
"records": 8524,
"checkedThisPass": 8,
"records": 8525,
"checkedThisPass": 1,
"carriedForward": 74,
"pruned": 0,
"broken": 74,
"verified": 8445,
"verified": 8446,
"unknown": 5
},
"byHost": {
@ -123,7 +123,7 @@
"broken": 1
},
"arc.gt": {
"verified": 33,
"verified": 34,
"broken": 1
},
"zume.training": {
@ -42766,6 +42766,11 @@
"state": "verified",
"detail": "5fish redirected to its language page https://fivefish.org/15537?language=Pashai%2C%20Southeast (the site answers 200 for any path)",
"at": "2026-09-25T09:56:59.503Z"
},
"https://arc.gt/g8wmw": {
"state": "verified",
"detail": "HTTP 200 (via https://www.jesusfilm.org/watch/jesus.html/jordanian-sign-language.html)",
"at": "2026-09-27T01:21:17.988Z"
}
}
}

File diff suppressed because one or more lines are too long

View file

@ -11,7 +11,7 @@
"honest_limit": "This is NOT the same as a national trade language. It finds languages widely used AMONG THE UNENGAGED GROUPS TRACKED HERE, and will MISS a lingua franca that no unengaged group claims as primary (e.g. Hindi in India). Named bridge_candidates, not trade_languages, for that reason.",
"why_not_a_list": "No open machine-readable list of national trade languages was available; bridge candidates are derived from this corpus."
},
"resource_flags_refreshed": "2026-09-25",
"resource_flags_refreshed": "2026-09-27",
"resource_flags_source": "data/tables/people-group-pins.json. prevalence_rank, bridge_candidate, groups_speaking and full_bible are curated and untouched."
},
"columns": [

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,201 @@
/**
* check-link-verification-coverage.mjs — rule 4 of the brief, as a gate instead of a good habit.
*
* "Verify a URL resolves before shipping it." verify-gospel-links.mjs does the verifying and writes
* the record; nothing asserted that the record COVERS what the build actually ships. So the honest
* state of the corpus was a number somebody had to go and measure, and a number nobody re-measures
* is a number that was true once.
*
* ⚠ THE URLS ARE DISCOVERED, NOT LISTED. Every other place in this bundle that walks the pin index
* names the fields it reads — and that list has gone stale twice: `ga` (Words of Life) and `se`
* (Scripture Earth) shipped unscreened because a field list written before those pools existed
* could not see them, and the SUN block and the access table each had to be added after the fact.
* This one recurses over the whole structure and treats ANY string that starts with http as a
* shipped link. A new pool is then covered on the day it lands, without anyone remembering to come
* here — which is the only kind of coverage check worth having.
*
* WHAT COUNTS AS A PASS:
* every shipped URL has an entry — an unrecorded link has never been looked at
* no shipped URL is recorded BROKEN — the builder drops dead links; if one survives, it
* is shipping against its own evidence
* `unknown` only on hosts that cannot be — live.bible.is and knowgod.com answer 200 with
* checked by status, and always counted identical bytes for any path, so a status check
* there is a false green, and the verifier says so
*
* Self-test: `--poison` adds a fabricated URL to the corpus, marks a shipped one broken, and moves
* an `unknown` onto a host that IS checkable. All three arms must fire.
*/
import { readFileSync, readdirSync } from 'node:fs'
import { resolve, dirname } from 'node:path'
import { fileURLToPath } from 'node:url'
import { TREE_NAME } from './_tree-name.mjs'
const HERE = dirname(fileURLToPath(import.meta.url))
const REPO = resolve(HERE, '..')
const POISON = process.argv.includes('--poison')
/* What cannot be proved by a status code, kept here as literals: a check that reads its own
exemptions out of the file it is checking exempts whatever that file says.
⚠ THE LAST ENTRY IS A URL SHAPE, NOT A HOST, and that distinction is the whole point of the
migration that preceded it: jesusfilm.org/watch/jesus/<slug> DOES discriminate (a bogus slug
404s) and 2,049 film links became checkable because of it, while the older
/watch/?languageId=<n> form renders byte-identical pages for any id, valid or bogus. Exempting
the host would have re-blinded every one of those 2,049. */
const STATUS_BLIND = [
{ re: /^(https?:\/\/)?([a-z0-9-]+\.)*(live\.bible\.is|bible\.is)(\/|$)/i, why: '200 with identical bytes for any fileset' },
{ re: /^(https?:\/\/)?([a-z0-9-]+\.)*knowgod\.com(\/|$)/i, why: 'page bytes identical; proved through Cru\'s API instead' },
{ re: /^(https?:\/\/)?([a-z0-9-]+\.)*sun\.bible(\/|$)/i, why: '200 for /sun/<anything>; proved by content' },
{ re: /jesusfilm\.org\/watch\/\?languageId=/i, why: 'the pre-shareUrl form renders the same bytes for any id' },
]
const statusBlind = (u) => STATUS_BLIND.find((x) => x.re.test(u)) || null
/* ⚠ A CONTACT IS VERIFIED SOMEWHERE ELSE, and the recursive walk found that out the honest way:
it reported 18 unrecorded links, all of them `cr.u` — the translator organisations' own
homepages. They were fetched on the run that wrote them, but by verify-translator-org-links.mjs
into its OWN record, so gospel-link-verification.json has never heard of them. Resolving them
against that second record is right; declaring them exempt would not be. */
let orgVerified = new Set()
try {
const orgs = JSON.parse(readFileSync(resolve(REPO, 'data/tables/translator-org-links.json'), 'utf8')).orgs || {}
orgVerified = new Set(Object.values(orgs).filter((o) => o.state === 'verified').map((o) => o.url))
} catch { /* no org record: those URLs then have no evidence anywhere, and must fail below */ }
/* SUN IS THE THIRD RECORD, and its evidence is STRONGER than a status code rather than weaker:
sun.bible answers 200 for /sun/<anything>, so fetch-sun-deaf-index.mjs proves each page by
CONTENT against a catch-all control (a bogus path returns 904 characters; the New Testament page
returns 22,043). The recursive walk found these three the same way it found the contacts — by not
knowing which fields were supposed to hold links. */
/* PRAYER PAGES ARE PROVED BY A LISTING, NOT A FETCH — the fourth record, and the reason is in
fetch-pray-index.mjs: pray.doxa.life answers 200 with identical bytes for ANY slug, so a status
check there is a false green. Existence comes from doxa.life's own /api/people-groups/list, and
every URL this build ships is built from a slug that listing returned. A pray.doxa.life URL that
is NOT in that index is therefore a CONSTRUCTED one, and must fail here. */
let prayListed = new Set()
try {
const pray = JSON.parse(readFileSync(resolve(REPO, 'data/tables/pray-index.json'), 'utf8')).byKey || {}
for (const e of Object.values(pray)) if (e && e.u) prayListed.add(e.u.split('?')[0])
} catch { /* no prayer index: those URLs then have no evidence, and must fail below */ }
/* ⚠ THE INDEX IS A SUBSET AND THE ROSTER IS THE LISTING. pray-index.json holds only the 1,723
groups whose (name, country) matched a pin; the access table ships a prayer link for all 2,107,
so 809 of them were reported unrecorded here. They are not unproved — doxa-people-groups.json IS
the API listing, one row per group with the slug doxa.life returned, and the index is that
listing narrowed by a join this check has no business requiring. */
try {
const roster = JSON.parse(readFileSync(resolve(REPO, 'data/tables/doxa-people-groups.json'), 'utf8')).rows || []
for (const r of roster) if (r && r.pray_url) prayListed.add(String(r.pray_url).split('?')[0])
} catch { /* no roster: the subset above still covers what it covers */ }
let sunVerified = new Set()
try {
const sun = JSON.parse(readFileSync(resolve(REPO, 'data/tables/sun-deaf-index.json'), 'utf8'))
if ((sun.meta || {}).catch_all_control) sunVerified = new Set((sun.resources || []).map((r) => r.url))
} catch { /* no SUN record: its links then have no evidence, and must fail below */ }
const fails = []
const idx = JSON.parse(readFileSync(resolve(REPO, 'data/tables/people-group-pins.json'), 'utf8'))
const verif = JSON.parse(readFileSync(resolve(REPO, 'data/tables/gospel-link-verification.json'), 'utf8')).links || {}
/** Every http string anywhere in the structure, with a breadcrumb of where it was found. */
const found = new Map()
const walk = (node, path) => {
if (node == null) return
if (typeof node === 'string') {
/* A citation is not an offer. `source`, `_note` and everything under `meta` record WHERE the
data came from — an API endpoint, a licence page — and no surface renders them as links. */
if (/(^|\.)(meta|_note|source|sources|site|fetchedFrom|licenceSource|boundarySource|why|note)(\.|$|\[)/.test(path)) return
if (/^https?:\/\//i.test(node) && !found.has(node)) found.set(node, path)
return
}
if (Array.isArray(node)) { node.forEach((v, i) => walk(v, `${path}[${i}]`)); return }
if (typeof node === 'object') for (const [k, v] of Object.entries(node)) walk(v, path ? `${path}.${k}` : k)
}
walk(idx.codes, 'codes')
walk(idx.sun, 'sun')
walk(idx.bridges, 'bridges')
walk(idx.bridgeCandidates, 'bridgeCandidates')
/* ⚠ THE PIN INDEX IS NOT THE ONLY THING SHIPPED, and scoping this check to it repeated the very
blind spot it was written to close: people-group-resources.json — the "Resources by People
Group" tab — was serving 2,294 URL cells of which 1,412 were the retired jesusfilm
?languageId= form, links the site ignores, while the map beside it had been migrated onto
Arclight shareUrls. Nothing saw it, because every check looked at one file.
*
* ⚠ AND THE SCOPE IS "WHAT A READER CAN CLICK", which is narrower than "what is staged". Reading
* every staged file reported 6,115 unrecorded links and 53 broken ones, and NONE of them is
* offered to anybody: they are API endpoints in `source` fields, and the raw upstream catalogues
* (arclight-share-urls and friends) which are build CACHES that happen to sit in the same folder —
* they still hold the 53 share URLs the verifier proved dead, which is exactly why the pin builder
* drops them. Judging those as shipped would make this check red on correct behaviour, and a gate
* that is red on correct behaviour gets switched off. So the scope is the tables the SURFACES
* read, taken from the surfaces, plus provenance fields excluded by path. */
const SURFACES = [
resolve(REPO, 'plugin-designs/gospel-resources/data-tables.vue'),
resolve(REPO, 'plugin-designs/gospel-resources/resources-map.vue'),
resolve(REPO, 'plugin-designs/gospel-resources/resources-list.vue'),
resolve(REPO, 'plugin-designs/gospel-resources/charts.vue'),
]
const wanted = new Set()
for (const f of SURFACES) {
let src = ''
try { src = readFileSync(f, 'utf8') } catch { continue }
for (const m of src.matchAll(/file:\s*'([a-z0-9-]+)'/gi)) wanted.add(`${m[1]}.json`)
for (const m of src.matchAll(/tables\/([a-z0-9-]+)\.json/gi)) wanted.add(`${m[1]}.json`)
}
if (wanted.size < 5) fails.push(`only ${wanted.size} reader-facing table(s) found in the surfaces — the scope is not being derived, so this check would under-measure`)
const POOL = resolve(REPO, 'public', TREE_NAME, 'assets/tables')
for (const f of wanted) {
/* The verification record is a LEDGER, deliberately keeping URLs proved dead; walking it would
ask the record to vouch for itself. */
if (f === 'gospel-link-verification.json') continue
try { walk(JSON.parse(readFileSync(resolve(POOL, f), 'utf8')), `tables/${f}`) } catch (e) { fails.push(`${f} is read by a surface but could not be read here: ${e.message}`) }
}
if (POISON) found.set('https://ebible.org/this-was-never-verified/', 'codes.__poison.s')
const unrecorded = [], broken = [], unknownOnCheckable = []
let unknownBlind = 0, verified = 0, contacts = 0, byContent = 0, listed = 0
let poisonedBroken = false, poisonedUnknown = false
for (const [url, where] of found) {
let e = verif[url]
if (POISON && !poisonedBroken && e && e.state === 'verified') { e = { ...e, state: 'broken' }; poisonedBroken = true }
else if (POISON && !poisonedUnknown && e && e.state === 'verified' && !statusBlind(url)) { e = { ...e, state: 'unknown' }; poisonedUnknown = true }
if (!e && orgVerified.has(url)) { contacts++; continue }
if (!e && sunVerified.has(url)) { byContent++; continue }
/* The shipped URL may carry a tracking suffix the index does not (or vice versa); the SLUG is
what the listing proved, so compare on origin+path. */
if (!e && /pray\.doxa\.life/i.test(url)) {
if (prayListed.has(url.split('?')[0])) { listed++; continue }
}
if (!e) { unrecorded.push(`${url} (${where})`); continue }
if (e.state === 'broken') { broken.push(`${url} (${where})`); continue }
if (e.state === 'unknown') {
if (statusBlind(url)) unknownBlind++
else unknownOnCheckable.push(`${url} — ${e.detail || 'no reason recorded'}`)
continue
}
verified++
}
if (!found.size) fails.push('no URL found in the pin index at all — this check would pass vacuously')
if (unrecorded.length) fails.push(`${unrecorded.length} shipped link(s) have NO verification entry (e.g. ${unrecorded[0]})`)
if (broken.length) fails.push(`${broken.length} shipped link(s) are recorded BROKEN (e.g. ${broken[0]})`)
if (unknownOnCheckable.length) fails.push(`${unknownOnCheckable.length} link(s) are 'unknown' on a host that CAN be checked by status (e.g. ${unknownOnCheckable[0]})`)
if (POISON) {
const want = ['have NO verification entry', 'recorded BROKEN', "'unknown' on a host that CAN be checked"]
const missed = want.filter((w) => !fails.some((f) => f.includes(w)))
if (missed.length) { console.error(`LINK-COVERAGE --poison: these arms did NOT fire: ${missed.join(' | ')}`); process.exit(1) }
console.log(`LINK-COVERAGE --poison: PASS — all 3 arms fired (${fails.length} failure(s)).`)
process.exit(0)
}
if (fails.length) {
console.error(`LINK-COVERAGE: FAIL — ${fails.length} problem(s):`)
for (const f of fails) console.error(` ${f}`)
console.error(' Fix: node plugin-designs/gospel-resources/scripts/verify-gospel-links.mjs')
process.exit(1)
}
console.log(`LINK-COVERAGE: PASS — ${found.size} distinct shipped link(s): ${verified} verified by fetch, ` +
`${contacts} translator contact(s) and ${byContent} SUN page(s) verified in their own records, ` +
`${listed} prayer page(s) proved by doxa.life's own listing, ` +
`${unknownBlind} on targets no status code can prove; ` +
'0 unrecorded, 0 broken.')

View file

@ -138,34 +138,33 @@ const SITES = [
['plugin-designs/cities-internet/connectivity-list.vue', 'useOptionalT', null],
['plugin-designs/cities-internet/connectivity-map.vue', 'useOptionalT', null],
['plugin-designs/cities-internet/data-tables.vue', 'useOptionalT', null],
// wave 3c — my-upg-100-list (owner: upg100). Both used the humanizeKey fallback and no namespace,
// since callers pass full keys. Each keeps humanizeKey, so a bare host reads the same title-cased
// last segment it always did.
// ★ THE REASON, STATED PRECISELY, because a wrong one was in circulation. humanizeKey does NOT guard a
// MISSING KEY: it guards a host that never installed vue-i18n at all, where useI18n() throws — seven
// bundles here install none. The catalogue is NOT the issue, and I verified that rather than repeat it:
// these two files use exactly SEVEN keys, all string literals, and all seven are present in ALL ELEVEN
// locales (0 gaps, counted 2026-09-27). upg100 had advised keeping humanizeKey on the grounds that their
// keys were incompletely catalogued; they retracted it themselves after enumerating, and the count agrees
// with the retraction, not the advice.
// SO THE ONLY TRUE REASON TO KEEP IT IS THE ONE IN useOptionalT's OWN HEADER: switching to tStatic would
// change what SEVEN BARE-HOST BUNDLES RENDER — strings that are English today would start following the
// reader's locale. That was a product decision about the embed contract, and it has since been MADE:
// dx-shepherd RULED the locale-honouring fallback everywhere (2026-09-27, the Driver's goal (d), now the
// embed contract in COMMON.md). upg100 is switching these two files themselves. So this row's
// 'humanizeKey(k)' is CORRECT-BUT-EXPIRING: it describes main today and becomes null when their switch
// lands. If it fails after that, the row is the stale thing, not the code.
['plugin-designs/my-upg-100-list/poster-wall.vue', 'useOptionalT', 'humanizeKey(k)'],
['plugin-designs/my-upg-100-list/upg-100-mfe.vue', 'useOptionalT', 'humanizeKey(k)'],
// wave 3d — all-people-groups. Treated as UNOWNED (studio agreed), so the conservative reading wins:
// its inline EN map stays the fallback rather than switching to the catalogue, because nobody is
// available to decide that this bundle should start honouring the locale on a bare host -- THEN
// dx-shepherd RULED the locale-honouring fallback everywhere (2026-09-27; the Driver's goal (d), a
// bare-host embed with ?lang=ar must render Arabic, now the embed contract in COMMON.md). So it takes
// the catalogue default, its duplicate EN map is deleted, and section 9 keeps the ruling's PRECONDITION
// true rather than measured once.
// available to decide that this bundle should start honouring the locale on a bare host. An unowned
// file is the wrong place to make a product decision by default.
// First site on the {t, te, locale} form: it needs locale for toLocaleString.
// ⚠ null, NOT a fallback. This row came back as 'fallback: (full) =>' in a merge, because the branch it
// merged from predates 0ded1a4e — the commit that carried out the embed-contract ruling here and deleted
// the inline EN map. Taking a SITES array wholesale from another branch re-asserts ITS view of every
// file in it, including the ones you changed; the gate caught it, which is the only reason it is right.
['plugin-designs/all-people-groups/all-people-groups.vue', 'useOptionalI18n', null],
// wave 3c — my-upg-100-list (owner: upg100-plugin). No namespace, since callers pass full keys.
// ⚠ THE THIRD COLUMN IS null, NOT 'humanizeKey(k)', and that is a DEPARTURE from 4c05e589, which
// kept the fallback. Correct as regions-plugin wrote it; wrong the moment dx-shepherd took the
// product call (UPGHK, 2026-09-27) to delete it. A row asserting a fallback the code no longer
// passes fails by design — the same hole regions flagged to me in the opposite direction.
// ★ WHY IT WAS DELETED. humanizeKey rendered a key's last segment title-cased, failing two ways: on
// the messages keys it printed "Upg100 Wall Intro" where a whole sentence exists (wrong even in
// English), and on the tabs keys it printed the RIGHT English by coincidence — those keys are named
// after the words — so it looked correct forever while every non-English reader silently got
// English. A fallback that agrees with en is indistinguishable from a working translation until
// someone changes locale.
// The premise for keeping it ("these keys are not fully catalogued") was MINE and was FALSE: seven
// keys across both files, all string literals, every one present in all eleven locales, 0 gaps.
// Same class of decision gospel-plugin took for its four sites above, and the OPPOSITE of the 3d
// reading directly above — which is right, because that file is unowned and these two are not.
['plugin-designs/my-upg-100-list/poster-wall.vue', 'useOptionalT', null],
['plugin-designs/my-upg-100-list/upg-100-mfe.vue', 'useOptionalT', null],
]
// ★ ONE FILE KEEPS TWO BARE useI18n CALLS ON PURPOSE, and the count is asserted rather than waived.
// resources-map.vue reads the LOCALE twice (`useI18n().locale.value`) to decide isEnglish and the
@ -205,12 +204,30 @@ for (const [file, helper, fallbackFrag] of SITES) {
// catalogue; gospel's four used to resolve through enLookup's ENGLISH and were MOVED here by their
// owner's decision. Saying "as it always did" for those four would be a false statement inside a
// gate message, which is the one place a reader has no reason to doubt.
// Which sites were MOVED to the catalogue by an owner's decision, vs which always resolved there.
// Saying "as it always did" about a moved file is a false statement inside a gate message, and a
// gate message is the one place a reader has no reason to doubt.
const moved = ['gospel-resources', 'cities-internet', 'all-people-groups'].some((d) => file.includes(d))
// ⚠ AND my-upg-100-list's TWO ARE ALSO "moved", added 2026-09-27. They used humanizeKey until
// dx-shepherd took the product call (UPGHK) to delete it, so "as it always did" would have been
// false for them the moment they were registered — the exact failure this branch already guards
// against for gospel's four. A register that is right about the assertion and wrong in the sentence
// it prints is still teaching the next reader something untrue.
// ★ UNIONED ON MERGE, 2026-09-27. upg100's version listed only their two and gospel's; it omitted
// cities-internet (exp-shepherd took the same call for that bundle) and all-people-groups (moved by
// the ruling itself). Taking it verbatim would have printed "as it always did" about two files that
// were moved — the very falsehood this branch exists to prevent, reintroduced by a merge.
const MOVED_TO_CATALOGUE = ['gospel-resources', 'my-upg-100-list', 'cities-internet', 'all-people-groups']
const moved = MOVED_TO_CATALOGUE.some((m) => file.includes(m))
// ★ FOUR OWNERS, NOT TWO. The version merged in had a 2-branch chain, so every non-gospel file was
// attributed to upg100-plugin — including cities-internet (exp-shepherd's call) and all-people-groups
// (moved by the ruling, not by an owner at all). Naming the wrong agent in a gate message is the same
// class as "as it always did": a reader has no reason to doubt it.
const who = file.includes('gospel-resources')
? "gospel-plugin moved it here from enLookup's English"
: file.includes('my-upg-100-list')
? 'upg100-plugin moved it here from humanizeKey, a title-cased key segment'
: file.includes('cities-internet')
? "exp-shepherd moved it here from enLookup's English, for the same reason gospel did"
: "dx-shepherd's embed-contract ruling moved it here from an inline EN map"
ok(!/fallback:/.test(code), moved
? `${short}: takes the locale-honouring CATALOGUE fallback (moved here from its own English by its owner's decision, or by dx-shepherd's embed-contract ruling)`
? `${short}: takes the locale-honouring CATALOGUE fallback (${who})`
: `${short}: takes the CATALOGUE fallback, as it always did`)
}
}

View file

@ -17,9 +17,11 @@
// SKIP (exit 0) only when the directory is genuinely absent or empty: packets are
// generated on demand and a clean checkout has none. A skip says so out loud, with the
// count, so "0 packets checked" can never read as "all packets passed".
import { existsSync, readdirSync } from 'node:fs'
import { existsSync, readdirSync, readFileSync } from 'node:fs'
import { resolve, dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
// pathToFileURL, not a bare path: a dynamic import() of an absolute POSIX path works by luck on
// linux and throws ERR_UNSUPPORTED_ESM_URL_SCHEME the moment anyone runs this on windows.
import { fileURLToPath, pathToFileURL } from 'node:url'
import { spawnSync } from 'node:child_process'
const HERE = dirname(fileURLToPath(import.meta.url))
@ -93,22 +95,68 @@ for (const f of files) {
// PASS line from overclaiming.
const zoneOf = (f) => f.replace(/\.html$/, '').replace(/\.[a-z]{2}$/, '')
const zones = [...new Set(files.map(zoneOf))].sort()
// ★ THE DENOMINATOR NOW COMES FROM THE SAME PLACE THE NUMERATOR DOES.
//
// ⚠ IT DID NOT, AND THAT HID A BUG FOR THREE DAYS. This counted out/posters/*.html — POSTER files —
// while the numerator counts PACKET zones. Two different populations, printed either side of the word
// "of". out/posters is also itself GENERATED, so on a clean checkout the denominator was null and the
// shortfall warning silently vanished: the one line whose job was to stop an overclaim could not fire
// precisely when nothing had been rendered at all.
// MEASURED 2026-09-27: posters = 28, packet-able zones = 27, and the missing one was `deaf` — a legend
// row and a poster with NO reachable packet, because packet.mjs's allZones() looped three cluster keys
// and clusters.json carries zones in a fourth (deaf_lists, collapsed by legend-tree.js into one leaf).
// So "2 of 28" was not 26-to-go: it was 25-to-go plus one that could not be generated by any command.
// Fixed in packet.mjs (it now enumerates via leafZones, deaf included); this line stops the two sides
// from ever drifting again by reading the SAME seam rather than a directory listing of a side effect.
let totalZones = null
let zoneIds = null
try {
const postersDir = resolve(REPO, 'library/scripts/posters/out/posters')
if (existsSync(postersDir)) totalZones = readdirSync(postersDir).filter((f) => f.endsWith('.html')).length
} catch { /* the denominator is a nicety; its absence must not fail the gate */ }
const DATA = resolve(REPO, 'plugin-designs/my-upg-100-list/data')
const clusters = JSON.parse(readFileSync(join(DATA, 'clusters.json'), 'utf8'))
const { buildLegendNodes, leafZones } = await import(pathToFileURL(join(DATA, 'legend-tree.js')).href)
zoneIds = leafZones(buildLegendNodes(clusters, { records: null })).map((z) => z.id).sort()
totalZones = zoneIds.length
} catch (e) {
// The denominator is a nicety and its absence must not fail the gate — but SAY so, because a
// silently-absent denominator is exactly how the shortfall warning disappeared before.
console.log(` (zone total unavailable: ${e.message})`)
}
console.log(`\nPACKETS-ALL: ${files.length} packet(s) checked, ${files.length - failed.length} passed`)
console.log(` zones covered : ${zones.length}${totalZones ? ` of ${totalZones}` : ''} — ${zones.join(', ')}`)
if (totalZones && zones.length < totalZones) {
console.log(` ⚠ ${totalZones - zones.length} zone(s) have NO packet here and were not rendered by`)
console.log(' anything. Generate another with: bun run packet:ci (one zone, committed fixture,')
console.log(' no network), or ONLY=<zone> node library/scripts/posters/packet.mjs.')
// ★ COUNT AND LIST FROM ONE SET, derived the same way. My first version printed
// `totalZones - zones.length` next to a list built by filtering zoneIds, and they disagreed on sight:
// "21 zone(s)" above a list of 26. zones[] holds every FILE stem, which includes things that are not
// zones at all — the four fixture-* packets and the `<zone>.frontier` ?list= variant — so it overcounts
// coverage against a real-zone denominator. Two numbers describing one quantity, computed two ways, is
// how a report contradicts itself in public; the derived list is now the single source and the count
// is just its length.
const covered = (zoneIds || []).filter((z) => zones.includes(z))
const missing = (zoneIds || []).filter((z) => !zones.includes(z))
const extras = zones.filter((z) => zoneIds && !zoneIds.includes(z))
console.log(` zones covered : ${totalZones ? `${covered.length} of ${totalZones}` : zones.length}`
+ ` — ${(totalZones ? covered : zones).join(', ') || '(none)'}`)
// The fixture and ?list= packets are real coverage of the RENDERER and no coverage at all of a zone's
// data shape. Reported separately rather than folded into the numerator, which is what made the old
// line read "7 of 28" when only 2 real zones had ever been rendered.
if (extras.length) {
console.log(` also checked : ${extras.length} non-zone packet(s) — ${extras.join(', ')}`)
console.log(' (fixtures and ?list= variants: they exercise the renderer, not a zone\'s data shape)')
}
if (totalZones && missing.length) {
// ★ NAME THE MISSING ZONES, do not just count them. "26 zone(s) have no packet" tells a reader the
// size of the hole; it does not tell them what to type, so nobody ever closed it. The list is the
// actionable form — and it is what surfaced `deaf` as unreachable rather than merely ungenerated.
console.log(` ⚠ ${missing.length} zone(s) have NO packet here and were not rendered by`)
console.log(` anything: ${missing.join(', ')}`)
console.log(' Generate all of them (committed fixture, NO network):')
console.log(' node internal/build-packet-fixtures.mjs # the fixture zones')
console.log(' ONLY=<zone> node library/scripts/posters/packet.mjs')
}
if (failed.length) {
console.error(`PACKETS-ALL: FAIL — ${failed.length} packet(s): ${failed.join(', ')}`)
process.exit(1)
}
console.log(`PACKETS-ALL: PASS — every generated packet renders, across ${zones.length} zone(s) `
+ 'and every locale present. This does NOT cover zones no one generated.')
console.log(`PACKETS-ALL: PASS — every generated packet renders, across `
+ `${totalZones ? `${covered.length} of ${totalZones}` : zones.length} zone(s) and every locale `
+ `present.${missing.length ? ` This does NOT cover the ${missing.length} zone(s) named above.` : ''}`)

View file

@ -57,8 +57,20 @@ const API = process.env.VITE_API_BASE_URL || process.env.MAP_APP_API_URL;
// BEFORE loadRecords() ever looks at PACKET_RECORDS, so `bun run packet:ci` exited 1 on a
// tree that had everything it needed. It only passed in hand-testing because I had been
// supplying a dummy host — the real invocation path is what exposed it.
if (!API && !process.env.PACKET_RECORDS) {
console.error('[packet] No API base — set VITE_API_BASE_URL, or PACKET_RECORDS for a fixture build.');
// ⚠ AND THE SAME BUG HAD A SECOND ESCAPE HATCH, fixed 2026-09-27. The note above records this guard
// being taught about PACKET_RECORDS; OFFLINE was never added to it. loadRecords() reads
// `if (process.env.OFFLINE && existsSync(cache)) return ...` — a path that needs no API either — but
// this check ran first and exited 1, so OFFLINE=1 was documented at the top of this file and could
// never once have worked. That is worse than an undocumented feature: it sends a reader down a route
// the program refuses. Any env var that makes the API unnecessary must be named HERE, next to the
// guard, or it is decoration.
const CACHE_OK = process.env.OFFLINE && existsSync(join(__dirname, 'out', '_records.json'));
if (!API && !process.env.PACKET_RECORDS && !CACHE_OK) {
console.error('[packet] No API base — set VITE_API_BASE_URL, or PACKET_RECORDS for a fixture build,');
console.error(' or OFFLINE=1 to reuse out/_records.json if a previous run cached it.');
if (process.env.OFFLINE) {
console.error(' OFFLINE=1 was set but out/_records.json does not exist — nothing to reuse.');
}
console.error(' Failing closed.');
process.exit(1);
}
@ -149,6 +161,9 @@ function buildFunnel(membership) {
}
const { evaluate } = await import(join(DATA, 'selector-evaluator.js'));
const { normalizeRecords, extractRecords } = await import(join(DATA, 'normalize.js'));
// The zone enumeration itself, so this generator, the poster generator and upg-100-list.vue cannot
// disagree about which zones exist (see allZones below).
const { buildLegendNodes, leafZones } = await import(join(DATA, 'legend-tree.js'));
async function loadRecords() {
// ★ A COMMITTED FIXTURE, so CI can build a packet at all. out/ is gitignored — both the
@ -168,7 +183,26 @@ async function loadRecords() {
}
const cache = join(__dirname, 'out', '_records.json');
if (process.env.OFFLINE && existsSync(cache)) return JSON.parse(readFileSync(cache, 'utf8'));
const fields = 'slug,name,country_code,population,religion,image_url,has_photo,picture_credit,latitude,longitude';
// ★ THE FIELD LIST IS PART OF THE SELECTOR CONTRACT — every field a selector READS must be asked
// for here, or its zone silently resolves to nothing and gets skipped as "no groups resolved".
//
// ⚠ SIX OF TWENTY-EIGHT ZONES WERE UNREACHABLE FOR THIS ONE REASON, measured 2026-09-27 while
// raising check:packets-all's coverage. This list named country_code and no other selector field,
// while selector-evaluator reads three more:
// rop1 affinity_bloc -> deaf (rop1Code, :51)
// wagf_block wagf_block -> west/east/central-africa (:102)
// wagf_region wagf_region -> europe, and the union (:113, :136)
// so those six zones evaluated to an EMPTY set for every record. The tell was unmistakable once
// looked at: the records cache held 123 groups with "Deaf" in the name and evaluate() still returned
// 0 slugs. It reads as missing DATA and is missing FIELDS.
// ★ DERIVED, NOT TYPED: the presentation fields are listed, the selector fields come from the
// evaluator's own requirements, so adding a selector type that reads a new field is a change in ONE
// place. A hand-maintained superset drifts the moment someone adds a selector — which is exactly
// what happened here.
const PRESENTATION_FIELDS = ['slug', 'name', 'country_code', 'population', 'religion',
'image_url', 'has_photo', 'picture_credit', 'latitude', 'longitude'];
const SELECTOR_FIELDS = ['country_code', 'wagf_block', 'wagf_region', 'rop1'];
const fields = [...new Set([...PRESENTATION_FIELDS, ...SELECTOR_FIELDS])].join(',');
const r = await fetch(`${API}/api/people-groups/list?fields=${fields}&lang=${LANG}`,
{ headers: { accept: 'application/json' } });
if (!r.ok) throw new Error(`PG API ${r.status}`);
@ -177,12 +211,37 @@ async function loadRecords() {
return recs;
}
// ★ ENUMERATE FROM THE LEGEND TREE, NOT FROM clusters.json'S KEYS — the same seam the POSTER
// generator and upg-100-list.vue use, so all three agree on what "a zone" is.
//
// THE DEFECT THIS FIXES, measured 2026-09-27 while making check:packets-all cover every zone:
// this function looped THREE cluster keys — lists (13) + asia_lists (12) + uncovered_lists (2) = 27
// — and clusters.json has a FOURTH that carries zones: deaf_lists (2). legend-tree.js:106 states the
// real arithmetic, "13 + 12 + 2 + 1 deaf = 28", because buildLegendNodes COLLAPSES the two deaf_lists
// into ONE unified leaf (affinity bloc A017, R1.1). So `deaf` is a poster zone and a legend row that
// no packet could ever be generated for: ONLY=deaf exited 1 with "no zone matches".
//
// ⚠ IT PRESENTED AS A COVERAGE GAP AND WAS A REACHABILITY BUG. check:packets-all printed
// "zones covered : 2 of 28", which reads as "26 nobody got round to" — but one of the 26 was
// unreachable by construction, and generating all of them would have silently produced 27, still
// printed against a denominator of 28. The numerator counted packet zones and the denominator counted
// POSTER files (out/posters/*.html), so the two sides were never the same population. A gap you can
// close by working harder and a gap you cannot close at all look identical in that line.
//
// leafZones() carries id/zoneKey/label/count but NOT selector (leaves do not), so the selector is
// re-attached by id exactly as generate.mjs:2801-2808 does it, deaf included.
function allZones() {
const out = [];
const tops = buildLegendNodes(clusters, { records: null });
const byId = {};
for (const k of ['lists', 'asia_lists', 'uncovered_lists']) {
for (const z of clusters[k] || []) out.push({ ...z, zoneKey: z.id });
for (const z of clusters[k] || []) byId[z.id] = z.selector;
}
return out;
// The unified deaf leaf's selector. DUPLICATED from legend-tree.js:99's DEAF_SELECTOR, which is
// function-scoped rather than exported, and from generate.mjs:2805 which hardcodes the same literal.
// Kept as a literal here rather than exporting a third consumer's dependency mid-hold; if a fourth
// caller ever needs it, export it once from legend-tree.js and delete all three copies.
byId.deaf = { type: 'affinity_bloc', rop1: 'A017' };
return leafZones(tops).map((z) => ({ ...z, zoneKey: z.zoneKey || z.id, selector: byId[z.id] }));
}
// ── PHOTOS: parallel fetch, inlined as data URIs ────────────────────────────

View file

@ -92,6 +92,8 @@
"check:gospel-tables-staged": "node internal/check-gospel-tables-staged.mjs",
"check:embed-contract": "node internal/check-gospel-embed-contract.mjs",
"check:engine-contract": "node internal/check-gospel-engine-contract.mjs",
"check:link-coverage": "node internal/check-link-verification-coverage.mjs",
"check:link-coverage:poison": "node internal/check-link-verification-coverage.mjs --poison",
"check:engine-contract:poison": "node internal/check-gospel-engine-contract.mjs --poison",
"check:embed-contract:poison": "node internal/check-gospel-embed-contract.mjs --poison",
"check:map-attribution": "node internal/check-map-attribution.mjs",
@ -399,6 +401,7 @@
"check:translator-contacts": "the credit line LINKS the translating organisation now, and a contact URL is a link this map stands behind. Reads ALLOWED_CONTACT out of internal/verify-gospel-map.mjs rather than restating it (two copies of an allowlist drift, and the drifting copy is the one nobody reads), then asserts every cr.u is on that list, was recorded `verified` by a real fetch, is never also serving a RESOURCE (wycliffe.org is somewhere to write to, not a publisher this map reads text from), and is actually RENDERED as an anchor \u2014 the data can carry 838 URLs while the template shows none. Static, sub-second. Self-test: `bun run check:translator-contacts:poison`.",
"check:embed-contract": "the map's URL is a PUBLIC INTERFACE: a partner embeds ?codes=<iso3 list>, ?country=<ISO3> or ?view=zero in an iframe and never touches it again. A refactor breaks those links SILENTLY \u2014 the leaf still mounts, renders and logs no errors, it just shows the whole world to a church that asked for its forty groups. Asserts through the reader's eyes (the partner-list notice's two numbers), with both numbers and the probe codes derived from the roster the leaf itself reads, never typed in. Browser gate; needs no server of its own. Self-test: `bun run check:embed-contract:poison`.",
"check:engine-contract": "the map must use the engine it was ASKED for or fail VISIBLY \u2014 never a substitute. resources-map read `engineGlobal(provider) || window.mapboxgl`; engineGlobal already defaults to mapbox, so that could only fire for provider 'maplibre' and then returned MAPBOX \u2014 a keyless page silently handed the engine that needs a token, which the profile then does not give it, so its tiles 401. Invisible to every other gate: the leaf mounts, renders and logs nothing. Six runtime measurements (2 providers x engine present / blocked / both blocked, CDN blocked by request routing the way a proxy or an offline kiosk does) plus a static arm on the source, comments stripped so the fix's own quotation of the deleted line is not read as the defect. Mapbox arms inject a dummy MAP_TOKEN, because without one the element never mounts on a static tree and that is the environment, not the product. Engine attribution is read off the canvas class, not a debug handle. Browser gate. Self-test: `bun run check:engine-contract:poison`.",
"check:link-coverage": "rule 4 of the brief — 'verify a URL resolves before shipping it' — as a gate instead of a habit. verify-gospel-links.mjs does the verifying; nothing asserted the record COVERS what is shipped, so the honest state of the corpus was a number someone had to go and measure. DISCOVERS the URLs by recursing the pin index for any http string rather than naming fields: every field list in this bundle has gone stale (ga and se shipped unscreened, the SUN block and the access table were each added after the fact), and the recursion found 16 links my own hand-rolled measurement had missed. Resolves each against THREE records — the verification table, translator-org-links.json (contacts, fetched by their own script) and sun-deaf-index.json (proved by content against a catch-all control, which is stronger than a status code) — and fails on any unrecorded link, any shipped link recorded BROKEN, and any 'unknown' on a target a status code CAN prove. The status-blind list holds one URL SHAPE, not a host: jesusfilm.org/watch/jesus/<slug> discriminates and 2,049 links depend on that, while /watch/?languageId= does not. Static, sub-second. Self-test: `bun run check:link-coverage:poison`.",
"check:snapshot-guard": "every gospel-resources catalogue fetcher must write through scripts/_snapshot-guard.mjs, which refuses a write that loses more than a tenth of the previous snapshot. Written after a rate-limited FCBH run silently replaced 1,666 video scopes with 1,103. Static, sub-second. Self-test: `bun run check:snapshot-guard:poison` injects a raw write and the check must fail on it.",
"check:spa-tab-order": "the cap-5 SPA must open on Big Picture and keep the Driver's order (Big Picture, UPG-100, Prayer Progress, Gospel Resources) — asserted as PRESENCE + tab-1 + RELATIVE order on the RENDERED rail, because the rail gained three sections and now renders 12 rows with others between them. ⚠ NOT WAVE-WIRED: it FAILS rather than skips when the SPA is unreachable, deliberately (\"a gate that cannot reach its subject has not passed, it has not run\") — correct standalone, but gates:post runs on checkouts with no server. Needs `bun run serve` on 4173.",
"check:stale-sweep-claim": "a caveat that has stopped being true is a wrong number. The gospel surfaces carry a paragraph saying which pools were searched; while the GRN sweep ran they truthfully said part of it was unread, the sweep finished, and the sentence stayed for four more commits. The DATA decides here: undecided gap codes mean the claim is allowed, all-decided means it must go, so it re-arms itself for the next slow pool. Does NOT flag a claim rendered under a v-if on the pending count — that form expires on its own. Self-test: `--poison`. Static, sub-second.",

View file

@ -30,6 +30,7 @@ import { writeSnapshot } from './_snapshot-guard.mjs'
const HERE = dirname(fileURLToPath(import.meta.url))
const REPO = resolve(HERE, '../../..')
const OUT = resolve(REPO, 'data/tables/gospel-by-province.json')
const OUT_GROUPS = resolve(REPO, 'data/tables/gospel-by-province-groups.json')
const T = (f) => JSON.parse(readFileSync(resolve(REPO, 'data/tables', f), 'utf8'))
const BOUND = (f) => JSON.parse(readFileSync(resolve(REPO, 'public/dx-maps/assets/boundaries', f), 'utf8'))
@ -110,6 +111,7 @@ const COUNTRIES = [
const rows = []
const unassigned = []
const assignments = []
let provincesAvailable = 0
for (const c of COUNTRIES) {
const feats = BOUND(c.file).features.map((f) => ({ f, b: bboxOf(f), name: f.properties.shapeName }))
@ -124,6 +126,20 @@ for (const c of COUNTRIES) {
if (featHas(cand.f, x, y)) { hit = cand.name; break }
}
if (!hit) { unassigned.push({ country: c.country, iso3: c.iso3, people_group: r.name, lat: r.lat, lng: r.lng }); continue }
/* THE PER-GROUP ROW, published beside the aggregate so no other build has to repeat this
point-in-polygon. gospel-internet was running the identical ray-casting over the identical
two geometry files and refusing to write unless its counts matched these; a second
implementation of the same geometry is a second place for it to drift. */
assignments.push({
people_group: r.name,
country: c.country,
iso3: c.iso3,
province: hit,
iso639_3: r.iso639_3 || '',
population: r.population || 0,
tier: tiers[r.iso639_3] ?? r.tier ?? 0,
approximate: true,
})
if (!by.has(hit)) by.set(hit, [])
by.get(hit).push(r)
}
@ -212,6 +228,49 @@ if (!assigned || assigned < 900) {
console.error(' geometry or the coordinates changed shape, not that the answer moved.')
process.exitCode = 1
} else {
/* ═══ THE TWO FILES MUST AGREE, ASSERTED HERE ════════════════════════════════════════════
* The aggregate says a province holds N groups; the per-group file lists them. Published
* separately (the map fetches only the aggregate, lazily, and has no use for 1,005 rows), which
* means they CAN disagree — so they are reconciled before either is written. gospel-internet
* built its own copy of this point-in-polygon and refused to write unless its counts matched
* ours; it should not have to, and it should not have to trust us either.
*/
const perProvince = new Map()
for (const a of assignments) perProvince.set(`${a.iso3}|${a.province}`, (perProvince.get(`${a.iso3}|${a.province}`) || 0) + 1)
const disagree = rows.filter((r) => (perProvince.get(`${r.iso3}|${r.province}`) || 0) !== r.groups)
if (disagree.length || assignments.length !== assigned) {
console.error(`GOSPEL-BY-PROVINCE: REFUSING TO WRITE — the per-group file and the aggregate disagree on ${disagree.length} province(s); ${assignments.length} assignments vs ${assigned} counted.`)
process.exit(1)
}
writeSnapshot(
OUT_GROUPS,
`${JSON.stringify({
meta: {
title: 'Which province each people group falls in — India and China',
why: 'Published so no other build has to repeat this point-in-polygon. gospel-internet ran ' +
'the identical ray-casting over the identical geometry and could only check itself against ' +
'our aggregates; a second implementation of the same geometry is a second place for it to drift.',
generated: new Date().toISOString().slice(0, 10),
grain: 'one row per PLACED people group; the four that fall outside every polygon are in the aggregate file\'s `unassigned`',
rows: assignments.length,
agrees_with: 'gospel-by-province.json — per-province counts and the assigned total are reconciled at build time, and neither file is written if they differ',
approximate: 'Every row is approximate. The coordinate marks where a PEOPLE is found, not a ' +
'settlement, and near a border it can fall on the wrong side. No surface may present a ' +
'province as a fact about a group.',
},
columns: [
{ title: 'People group', field: 'people_group', type: 'string' },
{ title: 'Country', field: 'country', type: 'string' },
{ title: 'Province', field: 'province', type: 'string' },
{ title: 'Language code', field: 'iso639_3', type: 'string' },
{ title: 'People', field: 'population', type: 'number' },
{ title: 'Tier', field: 'tier', type: 'number' },
],
rows: assignments,
}, null, 1)}\n`,
(j) => (j.rows || []).length,
'province assignment(s)',
)
writeSnapshot(
OUT,
`${JSON.stringify({
@ -225,6 +284,7 @@ if (!assigned || assigned < 900) {
provinces_available: provincesAvailable,
groups_assigned: assigned,
groups_unassigned: unassigned.length,
per_group_file: 'gospel-by-province-groups.json — one row per placed group, reconciled with these counts at build time',
india_note: 'India is read from the COMPLETE ADM1 (36 states, geoBoundaries via DataMeet ' +
'India, CC BY 2.5 IN). The first build of this table read gb-IND-ADM1.geojson, which is ' +
'the UPG-100 list\'s curated 34-unit partition, and eleven groups came out with no ' +

View file

@ -31,19 +31,32 @@ const UA = 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Geck
const JF = '1_jf-0-0'
/**
* `jos` IS SKIPPED, AND THE REASON IS UPSTREAM. ISO 639-3 jos is Jordanian Sign Language.
* Arclight's roster files iso3 "jos" under languageId 139080, whose own name field reads
* "Lebanese Sign Language" — a different language (lbs). Meanwhile the JESUS Film DOES exist
* at languageId 7138, named Jordanian Sign Language, which is the id the pin index carries.
* ═══ TWO ARCLIGHT LANGUAGES, ONE ISO CODE — RESOLVED, WITH THE EVIDENCE ═════════════════════
*
* So the ISO join — the thing this whole map is built on — disagrees with the publisher's own
* label for this one code, and the fallback below resolved 139080 to Magdalena: the wrong film
* AND the wrong language, shown to three people groups. Left exactly as it was rather than
* guessed at. Resolving it means deciding whether the code or the name is authoritative here,
* and that is a data question for someone who can check it against Ethnologue, not something
* to settle inside a fetcher.
* This code was SKIPPED, and the note here said resolving it meant deciding whether the ISO code
* or the publisher's name was authoritative — "a data question for someone who can check it".
* It has now been checked, against Arclight's own API rather than against an opinion:
*
* media-languages?iso3=jos returns TWO ids
* 7138 name "Jordanian Sign Language" JESUS film -> arc.gt/g8wmw
* 139080 name "Lebanese Sign Language" (the id the roster file lists under jos)
* arc.gt/g8wmw redirects to .../watch/jesus.html/jordanian-sign-language.html — the publisher's
* own slug names the language, and a bogus arc.gt code 404s, so it discriminates.
*
* ⚠ AND THE OLD NOTE WAS WRONG ON A FACT. It called Lebanese Sign Language "a different language
* (lbs)". ISO 639-3 lbs is LIBYAN Sign Language; Lebanese Sign Language has no code of its own.
* So this is not a mis-filing to be corrected upstream — it is two Levantine varieties sharing one
* code, which ISO does not separate. Nothing here can be "given back" to a Lebanese code, because
* there is none, and joining 139080 to our lbs would be a NAME match on the wrong country.
*
* THE RULE, therefore: where several Arclight ids share an ISO code, take the one whose own NAME
* agrees with the language this map names for that code, and record the other. The code stays
* authoritative for the JOIN; the name only breaks a tie the code cannot. Our three jos groups are
* Jordanian, so they get the Jordanian film and not the Lebanese variety.
*/
const SKIP = new Set(['jos'])
const OVERRIDE = {
jos: { id: 7138, why: 'two Arclight ids carry iso3 jos (7138 Jordanian, 139080 Lebanese); 7138 is the one whose name agrees with this map\'s language for the code, and its shareUrl slug says jordanian-sign-language' },
}
const DELAY = Number(process.env.DELAY_MS || 250)
const arc = JSON.parse(readFileSync(ARC, 'utf8')).codes
@ -54,10 +67,12 @@ let fetched = 0, cached = 0, missing = 0
for (let i = 0; i < entries.length; i++) {
const [code, v] = entries[i]
if (SKIP.has(code)) { cached++; continue }
if (out[code] !== undefined) { cached++; continue }
/* An override replaces the ROSTER's id, not the result: the shareUrl is still fetched from the
API for that id, so a wrong override cannot invent a link. */
const id = OVERRIDE[code] ? OVERRIDE[code].id : v.id
try {
const r = await fetch(`https://api.arclight.org/v2/media-components/${JF}/languages/${v.id}`, { headers: { 'User-Agent': UA } })
const r = await fetch(`https://api.arclight.org/v2/media-components/${JF}/languages/${id}`, { headers: { 'User-Agent': UA } })
if (r.ok) {
const d = await r.json()
// https, always: the API returns http:// and a plain-http link in a field context
@ -98,12 +113,12 @@ for (let i = 0; i < entries.length; i++) {
* than falling back to a URL nobody can check. */
let alt = null
try {
const c = await fetch(`https://api.arclight.org/v2/media-components?languageIds=${v.id}&limit=25`, { headers: { 'User-Agent': UA } })
const c = await fetch(`https://api.arclight.org/v2/media-components?languageIds=${id}&limit=25`, { headers: { 'User-Agent': UA } })
if (c.ok) {
const cj = await c.json()
const feature = (cj._embedded?.mediaComponents || []).find((m) => m.subType === 'featureFilm')
if (feature) {
const fr = await fetch(`https://api.arclight.org/v2/media-components/${feature.mediaComponentId}/languages/${v.id}`, { headers: { 'User-Agent': UA } })
const fr = await fetch(`https://api.arclight.org/v2/media-components/${feature.mediaComponentId}/languages/${id}`, { headers: { 'User-Agent': UA } })
if (fr.ok) {
const fd = await fr.json()
const fu = String(fd.shareUrl || '').replace(/^http:\/\//, 'https://')
@ -130,7 +145,18 @@ for (let i = 0; i < entries.length; i++) {
writeSnapshot(OUT, JSON.stringify({
_note: 'Per-language JESUS Film Project deep links (shareUrl) from Arclight media-components. A STRING is the JESUS Film (component 1_jf-0-0). An OBJECT {u,t} is a different JESUS Film Project feature in that language — the JESUS component has no entry for it — and t is the real title, which the surfaces must show instead of "JESUS Film". null means no feature exists and the film is dropped rather than linked to the retired ?languageId= URL, which nothing can verify.',
generated: new Date().toISOString().slice(0, 10),
stats: { languages: entries.length, withShareUrl: Object.keys(out).length, missing },
/* COUNTED, NOT NAMED. withShareUrl used to be Object.keys(out).length, which counts the nulls
and the alternative-feature objects too — so a file holding one null reported every language
as having a shareUrl, and the previous run reported 2,087 of them while jos was absent
entirely. Each state is now counted as what it is. */
stats: {
languages: entries.length,
recorded: Object.keys(out).length,
jesusFilm: Object.values(out).filter((v) => typeof v === 'string').length,
otherFeature: Object.values(out).filter((v) => v && typeof v === 'object').length,
noFeature: Object.values(out).filter((v) => v === null).length,
notAnswered: missing,
},
codes: out,
}), (j) => Object.keys(j.codes || {}).length, 'Arclight shareUrl codes')
console.log(`DONE languages ${entries.length} shareUrl ${Object.keys(out).length} missing ${missing}`)
console.log(`DONE languages ${entries.length} JESUS film ${Object.values(out).filter((v) => typeof v === 'string').length} other feature ${Object.values(out).filter((v) => v && typeof v === 'object').length} no feature ${Object.values(out).filter((v) => v === null).length} not answered ${missing}`)

View file

@ -118,6 +118,28 @@ const bump = (k) => { changed[k] = (changed[k] || 0) + 1 }
const any = hasAny(c)
if (r.any_resource_found !== any) { r.any_resource_found = any; bump('any_resource_found') }
if (r.no_resource_found !== !any) { r.no_resource_found = !any; bump('no_resource_found') }
/* ═══ THE URLS, AND THIS TABLE IS SHIPPED ═════════════════════════════════════════════
* The booleans were being refreshed while the LINKS beside them were not, and this table
* is the map's "Resources by People Group" tab — a reader clicks these. Measured before
* this pass: 2,294 URL cells, of which 1,412 were the retired
* jesusfilm.org/watch/?languageId=<id> form. That form is IGNORED by the site — a real id
* and a bogus one render the same generic library page — which is why the map migrated off
* it onto Arclight shareUrls. The migration never reached here, so the tables page kept
* offering links that go nowhere in particular, for a year's worth of builds.
*
* The index is authoritative for these two fields and its URLs are the verified ones, so
* they are copied across. Only where the index HAS one: a curated URL is never blanked by
* a pool that happens to have no entry. */
const sUrl = (codes[c] || {}).s, fUrl = (codes[c] || {}).f
if ('scripture_url' in r && sUrl && r.scripture_url !== sUrl) {
if (/[?&]languageId=/.test(r.scripture_url || '')) bump('retired_form_replaced')
r.scripture_url = sUrl; bump('scripture_url')
}
if ('film_url' in r && fUrl && r.film_url !== fUrl) {
if (/[?&]languageId=/.test(r.film_url || '')) bump('retired_form_replaced')
r.film_url = fUrl; bump('film_url')
}
}
// THE POOL LIST IS A CLAIM TOO. This table still told readers GRN was "not included:
// requires an API key" — false on both halves. GRN is included, 583 codes of it, and it

View file

@ -160,16 +160,23 @@ async function fetchListRecords(list) {
// copy a reader reads FIRST was the untranslated part. The <strong> travels inside the
// translated string so each language can emphasise its own words, which is not something a
// wrapper around an English sentence could do.
// Guarded like every other useI18n() here: it throws on a host without vue-i18n.
const humanizeKey = (k) => String(k).split('.').pop()
.replace(/([a-z0-9])([A-Z])/g, '$1 $2').replace(/^./, (c) => c.toUpperCase())
// C4 wave 3c: the guard is library/composables/useOptionalT.js now. humanizeKey STAYS as the fallback,
// passed in, so a bare host still reads a title-cased last segment rather than a dotted key. No
// namespace: callers pass FULL keys here, exactly as the raw useI18n().t they used to get.
// ★ ONE BEHAVIOUR GAIN, stated rather than smuggled: the old i18n path was the bare useI18n().t, so on
// a host WITH i18n a missing key rendered the raw dotted key. It now falls through to humanizeKey, the
// same fallback the bare host already got. Nothing that resolves today changes.
const t = useOptionalT('', { fallback: (k) => humanizeKey(k) })
// Guarded like every other useI18n() here: it throws on a host without vue-i18n. useOptionalT stores
// that guard once (host i18n -> catalogue -> the key) instead of a hand-rolled copy.
// No namespace: callers pass FULL keys here, exactly as the raw useI18n().t they used to get.
//
// ★ ONE BEHAVIOUR GAIN FROM WAVE 3c (regions-plugin, 4c05e589): the old path was the bare
// useI18n().t, so on a host WITH i18n a missing key rendered the raw dotted key. It now falls through.
//
// ★ AND THE humanizeKey FALLBACK IS GONE — the one deliberate departure from 4c05e589, which kept it.
// It rendered a key's last segment title-cased, so a bare host showed "Upg100 Wall Intro" where a
// whole SENTENCE exists: wrong even in English, and untranslatable because it comes from the
// identifier rather than a catalogue. It was kept on the theory that these keys were not fully
// catalogued — I told regions-plugin exactly that, and it was FALSE: this file's three keys are
// present in all ELEVEN locales, 0 gaps (enumerated 2026-09-27; the only three, all string literals).
// ⚠ IT GUARDS A BARE HOST, NOT A MISSING KEY. Those are different failures and I had them confused.
// Product call taken by dx-shepherd (UPGHK, 2026-09-27) under the Driver's standing goal that the
// production maps be localized.
const t = useOptionalT('')
const intro = computed(() => t('messages.upg100WallIntro'))
const heading = computed(() => t('messages.upg100WallHeading'))
const wallTag = computed(() => t('messages.upg100WallTag'))

View file

@ -46,17 +46,26 @@ import ShareButton from '@map/components/map-controls/ShareButton.vue'
// wrapper never did. The chrome a reader sees FIRST was the untranslated part.
//
// ⚠ GUARDED, like every other useI18n() call in this codebase: it THROWS on a host that has not
// installed vue-i18n, and this component is mounted by hosts we do not control. The fallback
// humanises the key so a bare host shows "Select View", never a blank or a raw dotted key.
const humanizeKey = (k) => String(k).split('.').pop()
.replace(/([a-z0-9])([A-Z])/g, '$1 $2').replace(/^./, (c) => c.toUpperCase())
// C4 wave 3c: the guard is library/composables/useOptionalT.js now. humanizeKey STAYS as the fallback,
// passed in, so a bare host still reads a title-cased last segment rather than a dotted key. No
// namespace: callers pass FULL keys here, exactly as the raw useI18n().t they used to get.
// ★ ONE BEHAVIOUR GAIN, stated rather than smuggled: the old i18n path was the bare useI18n().t, so on
// a host WITH i18n a missing key rendered the raw dotted key. It now falls through to humanizeKey, the
// same fallback the bare host already got. Nothing that resolves today changes.
const t = useOptionalT('', { fallback: (k) => humanizeKey(k) })
// installed vue-i18n, and this component is mounted by hosts we do not control. useOptionalT holds
// that guard once — host i18n first, then tStatic, then the key — instead of a hand-rolled copy here.
// No namespace: callers pass FULL keys here, exactly as the raw useI18n().t they used to get.
//
// ★ ONE BEHAVIOUR GAIN FROM WAVE 3c, stated rather than smuggled (regions-plugin, 4c05e589): the old
// i18n path was the bare useI18n().t, so on a host WITH i18n a missing key rendered the raw dotted key.
// It now falls through to the catalogue instead.
//
// ★ AND THE humanizeKey FALLBACK IS GONE — the one deliberate departure from 4c05e589, which kept it.
// It rendered a key's last segment title-cased, so a bare host showed "Select View": fine in English
// and simply WRONG for every other reader, because it is derived from the identifier and cannot be
// translated. Worse, it AGREED with en on these four keys (they are named after the words), so it
// looked correct forever while every non-English reader silently got English. tStatic honours the
// reader's locale and still ends at English.
// The four keys this file uses (tabs.map / prayerResources / selectView / views) are present in all
// ELEVEN locales, 0 gaps, enumerated 2026-09-27 — nothing here was relying on the fallback.
// Product call taken by dx-shepherd (UPGHK, 2026-09-27) under the Driver's standing goal that the
// production maps be localized; it changes what seven vue-i18n-less bundles render, which is not a
// decision a migration gets to make for itself.
const t = useOptionalT('')
const cfg = inject('profileConfig', ref(null))
const tk = inject('mapboxToken', ref(''))

File diff suppressed because one or more lines are too long

View file

@ -59,7 +59,7 @@
"meta": {
"title": "The Deaf Gap",
"grain": "one row per Deaf people group",
"generated": "2026-09-25",
"generated": "2026-09-27",
"measured": "150 sign languages · 234 people groups · 84,159,050 people. 136 languages / 183 groups / 55,199,840 people with NOTHING found.",
"bridge_rule": "A sign language is bridged ONLY by another sign language. Text and audio in a spoken language do not reach a Deaf community.",
"compounding_problem": "40 of these languages carry PRIVATE-USE (qaa-qtz) codes outside ISO 639-3. No resource pool indexes a private-use code, so those groups cannot be matched to a resource even if one exists.",
@ -2621,7 +2621,7 @@
"iso639_3": "jos",
"countries": "SYR",
"population": 221000,
"signed_film": "https://www.jesusfilm.org/watch/?languageId=7138",
"signed_film": "https://arc.gt/g8wmw",
"has_signed_film": true,
"has_any_resource": true,
"code_unmatchable": false,
@ -2660,7 +2660,7 @@
"iso639_3": "jos",
"countries": "JOR",
"population": 116000,
"signed_film": "https://www.jesusfilm.org/watch/?languageId=7138",
"signed_film": "https://arc.gt/g8wmw",
"has_signed_film": true,
"has_any_resource": true,
"code_unmatchable": false,
@ -2738,7 +2738,7 @@
"iso639_3": "jos",
"countries": "LBN",
"population": 51000,
"signed_film": "https://www.jesusfilm.org/watch/?languageId=7138",
"signed_film": "https://arc.gt/g8wmw",
"has_signed_film": true,
"has_any_resource": true,
"code_unmatchable": false,

View file

@ -3,7 +3,7 @@
"title": "Doxa people groups — what the gospel is available in, and what is missing",
"grain": "one row per people group on Doxa's roster",
"source": "pray.doxa.life roster joined to the resource index by ISO 639-3",
"generated": "2026-09-26",
"generated": "2026-09-27",
"groups": 2107,
"by_access": {
"nothing found": 369,
@ -19768,7 +19768,7 @@
"translated_by": "",
"translated_year": "",
"scripture_url": "",
"film_url": "https://www.jesusfilm.org/watch/?languageId=7138",
"film_url": "https://arc.gt/g8wmw",
"audio_url": "",
"bible_video_url": "",
"stories_url": "",
@ -20048,7 +20048,7 @@
"translated_by": "",
"translated_year": "",
"scripture_url": "",
"film_url": "https://www.jesusfilm.org/watch/?languageId=7138",
"film_url": "https://arc.gt/g8wmw",
"audio_url": "",
"bible_video_url": "",
"stories_url": "",
@ -21928,7 +21928,7 @@
"translated_by": "",
"translated_year": "",
"scripture_url": "",
"film_url": "https://www.jesusfilm.org/watch/?languageId=7138",
"film_url": "https://arc.gt/g8wmw",
"audio_url": "",
"bible_video_url": "",
"stories_url": "",

View file

@ -9,7 +9,7 @@
"bridge_strength_note": "MEASURED: 24% of aligned rows rest on a bridge shared by only 2-3 groups. bridge_strength grades this (strong >=8 groups, moderate >=4, weak <4). A WEAK bridge is a hypothesis, not a route — e.g. \"Gawri\" was offered as the bridge for 10.3M Pakistanis where the real lingua franca is Urdu, which no unengaged group here claims as primary and which this method therefore cannot see. Filter on bridge_strength before acting.",
"bridge_caveat": "A bridge candidate is measured from THIS corpus (a language primary to >1 DX group in the country). It is NOT proof the group speaks it, and it is NOT a national trade language — a lingua franca no unengaged group claims as primary is invisible here.",
"pools_caveat": "Resource status is from eBible + JESUS Film. GRN and FCBH audio are key-blocked and unchecked — absence is never proof.",
"resource_flags_refreshed": "2026-09-25",
"resource_flags_refreshed": "2026-09-27",
"resource_flags_source": "data/tables/people-group-pins.json — the same index the map draws. Curated bridge, internet and channel columns are untouched."
},
"columns": [

File diff suppressed because it is too large Load diff

View file

@ -2,11 +2,12 @@
"meta": {
"title": "Gospel access by province — India and China",
"why": "These two countries hold 47.9% of Doxa's roster (855 + 154 of 2,107). No other country is close; the third is Sudan at 105. A national fill cannot answer \"where would a team go\" for half the map.",
"generated": "2026-09-26",
"generated": "2026-09-27",
"provinces_with_groups": 46,
"provinces_available": 48,
"groups_assigned": 1005,
"groups_unassigned": 4,
"per_group_file": "gospel-by-province-groups.json — one row per placed group, reconciled with these counts at build time",
"india_note": "India is read from the COMPLETE ADM1 (36 states, geoBoundaries via DataMeet India, CC BY 2.5 IN). The first build of this table read gb-IND-ADM1.geojson, which is the UPG-100 list's curated 34-unit partition, and eleven groups came out with no province because they stood in states that partition does not draw. Switching the file moved 998 assigned to 1,005 and 44 province rows to 46, and turned the province names into India's own states.",
"unassigned_note": "Four remain, each one legible rather than lost. Great Andamanese and the Sentinelese sit on small islands whose simplified coastline the point falls just outside — Andaman and Nicobar IS in the file, so this is geometry precision, not a missing province. Jing and Pana are on China's Vietnam and Laos borders, outside the twelve-province subset. They are listed in `unassigned` with their coordinates.",
"china_note": "China's ADM1 file carries 12 provinces, not 31+. Measured rather than assumed: 152 of 154 Chinese groups fall inside them and all twelve are used, so the file is cut to the provinces where the unreached peoples are.",

View file

@ -1,7 +1,7 @@
{
"meta": {
"title": "Gospel resource link verification",
"generated": "2026-09-25T09:56:59.860Z",
"generated": "2026-09-27T01:21:18.374Z",
"method": "HEAD, redirects followed. verified = HTTP 200. broken = 404/410. unknown = unreachable, an ambiguous status, or a URL shape that cannot be checked by fetch.",
"scope": "Every URL in data/tables/people-group-pins.json, deduplicated.",
"states": {
@ -96,12 +96,12 @@
]
},
"summary": {
"records": 8524,
"checkedThisPass": 8,
"records": 8525,
"checkedThisPass": 1,
"carriedForward": 74,
"pruned": 0,
"broken": 74,
"verified": 8445,
"verified": 8446,
"unknown": 5
},
"byHost": {
@ -123,7 +123,7 @@
"broken": 1
},
"arc.gt": {
"verified": 33,
"verified": 34,
"broken": 1
},
"zume.training": {
@ -42766,6 +42766,11 @@
"state": "verified",
"detail": "5fish redirected to its language page https://fivefish.org/15537?language=Pashai%2C%20Southeast (the site answers 200 for any path)",
"at": "2026-09-25T09:56:59.503Z"
},
"https://arc.gt/g8wmw": {
"state": "verified",
"detail": "HTTP 200 (via https://www.jesusfilm.org/watch/jesus.html/jordanian-sign-language.html)",
"at": "2026-09-27T01:21:17.988Z"
}
}
}

File diff suppressed because one or more lines are too long

View file

@ -11,7 +11,7 @@
"honest_limit": "This is NOT the same as a national trade language. It finds languages widely used AMONG THE UNENGAGED GROUPS TRACKED HERE, and will MISS a lingua franca that no unengaged group claims as primary (e.g. Hindi in India). Named bridge_candidates, not trade_languages, for that reason.",
"why_not_a_list": "No open machine-readable list of national trade languages was available; bridge candidates are derived from this corpus."
},
"resource_flags_refreshed": "2026-09-25",
"resource_flags_refreshed": "2026-09-27",
"resource_flags_source": "data/tables/people-group-pins.json. prevalence_rank, bridge_candidate, groups_speaking and full_bible are curated and untouched."
},
"columns": [

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load diff

View file

@ -663,7 +663,7 @@
"bundle": "my-upg-100-list",
"scriptUrl": "./my-upg-100-list/my-upg-100-list.js",
"output": "dx-maps/my-upg-100-list/my-upg-100-list.js",
"integrity": "sha384-aN/2qoWx/R3L4jLqfHwqilkn/zEbdb+hSr5MMfCjkzckAjpyCPRWmclB5xdY2Ysd",
"integrity": "sha384-TkiPEj7EL0mDm8I28pBM7VnNIk9VCv+DuLq7WdEOHMfplFSIVc3Sf6k2CDGjcD05",
"elements": [
"my-upg-100-list-map"
],
@ -683,7 +683,7 @@
"scriptUrl": "./my-upg-100-list/my-upg-100-list.js",
"embed": {
"iframe": "<iframe src=\"./my-upg-100-list/poster-wall/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./my-upg-100-list/my-upg-100-list.js\" integrity=\"sha384-aN/2qoWx/R3L4jLqfHwqilkn/zEbdb+hSr5MMfCjkzckAjpyCPRWmclB5xdY2Ysd\" crossorigin=\"anonymous\"></script>\n<my-upg-100-list-map profile-config='{\"profile\":\"poster-wall\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></my-upg-100-list-map>"
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./my-upg-100-list/my-upg-100-list.js\" integrity=\"sha384-TkiPEj7EL0mDm8I28pBM7VnNIk9VCv+DuLq7WdEOHMfplFSIVc3Sf6k2CDGjcD05\" crossorigin=\"anonymous\"></script>\n<my-upg-100-list-map profile-config='{\"profile\":\"poster-wall\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></my-upg-100-list-map>"
}
},
{
@ -697,7 +697,7 @@
"scriptUrl": "./my-upg-100-list/my-upg-100-list.js",
"embed": {
"iframe": "<iframe src=\"./my-upg-100-list/upg-100-list/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./my-upg-100-list/my-upg-100-list.js\" integrity=\"sha384-aN/2qoWx/R3L4jLqfHwqilkn/zEbdb+hSr5MMfCjkzckAjpyCPRWmclB5xdY2Ysd\" crossorigin=\"anonymous\"></script>\n<my-upg-100-list-map profile-config='{\"profile\":\"upg-100-list\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></my-upg-100-list-map>"
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./my-upg-100-list/my-upg-100-list.js\" integrity=\"sha384-TkiPEj7EL0mDm8I28pBM7VnNIk9VCv+DuLq7WdEOHMfplFSIVc3Sf6k2CDGjcD05\" crossorigin=\"anonymous\"></script>\n<my-upg-100-list-map profile-config='{\"profile\":\"upg-100-list\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></my-upg-100-list-map>"
}
},
{
@ -711,7 +711,7 @@
"scriptUrl": "./my-upg-100-list/my-upg-100-list.js",
"embed": {
"iframe": "<iframe src=\"./my-upg-100-list/upg-100-mfe/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./my-upg-100-list/my-upg-100-list.js\" integrity=\"sha384-aN/2qoWx/R3L4jLqfHwqilkn/zEbdb+hSr5MMfCjkzckAjpyCPRWmclB5xdY2Ysd\" crossorigin=\"anonymous\"></script>\n<my-upg-100-list-map profile-config='{\"profile\":\"upg-100-mfe\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></my-upg-100-list-map>"
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./my-upg-100-list/my-upg-100-list.js\" integrity=\"sha384-TkiPEj7EL0mDm8I28pBM7VnNIk9VCv+DuLq7WdEOHMfplFSIVc3Sf6k2CDGjcD05\" crossorigin=\"anonymous\"></script>\n<my-upg-100-list-map profile-config='{\"profile\":\"upg-100-mfe\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></my-upg-100-list-map>"
}
}
]

View file

@ -663,7 +663,7 @@
"bundle": "my-upg-100-list",
"scriptUrl": "./my-upg-100-list/my-upg-100-list.js",
"output": "dx-maps/my-upg-100-list/my-upg-100-list.js",
"integrity": "sha384-aN/2qoWx/R3L4jLqfHwqilkn/zEbdb+hSr5MMfCjkzckAjpyCPRWmclB5xdY2Ysd",
"integrity": "sha384-TkiPEj7EL0mDm8I28pBM7VnNIk9VCv+DuLq7WdEOHMfplFSIVc3Sf6k2CDGjcD05",
"elements": [
"my-upg-100-list-map"
],
@ -683,7 +683,7 @@
"scriptUrl": "./my-upg-100-list/my-upg-100-list.js",
"embed": {
"iframe": "<iframe src=\"./my-upg-100-list/poster-wall/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./my-upg-100-list/my-upg-100-list.js\" integrity=\"sha384-aN/2qoWx/R3L4jLqfHwqilkn/zEbdb+hSr5MMfCjkzckAjpyCPRWmclB5xdY2Ysd\" crossorigin=\"anonymous\"></script>\n<my-upg-100-list-map profile-config='{\"profile\":\"poster-wall\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></my-upg-100-list-map>"
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./my-upg-100-list/my-upg-100-list.js\" integrity=\"sha384-TkiPEj7EL0mDm8I28pBM7VnNIk9VCv+DuLq7WdEOHMfplFSIVc3Sf6k2CDGjcD05\" crossorigin=\"anonymous\"></script>\n<my-upg-100-list-map profile-config='{\"profile\":\"poster-wall\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></my-upg-100-list-map>"
}
},
{
@ -697,7 +697,7 @@
"scriptUrl": "./my-upg-100-list/my-upg-100-list.js",
"embed": {
"iframe": "<iframe src=\"./my-upg-100-list/upg-100-list/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./my-upg-100-list/my-upg-100-list.js\" integrity=\"sha384-aN/2qoWx/R3L4jLqfHwqilkn/zEbdb+hSr5MMfCjkzckAjpyCPRWmclB5xdY2Ysd\" crossorigin=\"anonymous\"></script>\n<my-upg-100-list-map profile-config='{\"profile\":\"upg-100-list\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></my-upg-100-list-map>"
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./my-upg-100-list/my-upg-100-list.js\" integrity=\"sha384-TkiPEj7EL0mDm8I28pBM7VnNIk9VCv+DuLq7WdEOHMfplFSIVc3Sf6k2CDGjcD05\" crossorigin=\"anonymous\"></script>\n<my-upg-100-list-map profile-config='{\"profile\":\"upg-100-list\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></my-upg-100-list-map>"
}
},
{
@ -711,7 +711,7 @@
"scriptUrl": "./my-upg-100-list/my-upg-100-list.js",
"embed": {
"iframe": "<iframe src=\"./my-upg-100-list/upg-100-mfe/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./my-upg-100-list/my-upg-100-list.js\" integrity=\"sha384-aN/2qoWx/R3L4jLqfHwqilkn/zEbdb+hSr5MMfCjkzckAjpyCPRWmclB5xdY2Ysd\" crossorigin=\"anonymous\"></script>\n<my-upg-100-list-map profile-config='{\"profile\":\"upg-100-mfe\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></my-upg-100-list-map>"
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./my-upg-100-list/my-upg-100-list.js\" integrity=\"sha384-TkiPEj7EL0mDm8I28pBM7VnNIk9VCv+DuLq7WdEOHMfplFSIVc3Sf6k2CDGjcD05\" crossorigin=\"anonymous\"></script>\n<my-upg-100-list-map profile-config='{\"profile\":\"upg-100-mfe\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></my-upg-100-list-map>"
}
}
]

File diff suppressed because one or more lines are too long

View file

@ -32,7 +32,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <my-upg-100-list-map> custom element. -->
<script src="../my-upg-100-list.js" integrity="sha384-aN/2qoWx/R3L4jLqfHwqilkn/zEbdb+hSr5MMfCjkzckAjpyCPRWmclB5xdY2Ysd" crossorigin="anonymous"></script>
<script src="../my-upg-100-list.js" integrity="sha384-TkiPEj7EL0mDm8I28pBM7VnNIk9VCv+DuLq7WdEOHMfplFSIVc3Sf6k2CDGjcD05" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

View file

@ -36,7 +36,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <my-upg-100-list-map> custom element. -->
<script src="../my-upg-100-list.js" integrity="sha384-aN/2qoWx/R3L4jLqfHwqilkn/zEbdb+hSr5MMfCjkzckAjpyCPRWmclB5xdY2Ysd" crossorigin="anonymous"></script>
<script src="../my-upg-100-list.js" integrity="sha384-TkiPEj7EL0mDm8I28pBM7VnNIk9VCv+DuLq7WdEOHMfplFSIVc3Sf6k2CDGjcD05" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

View file

@ -47,7 +47,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <my-upg-100-list-map> custom element. -->
<script src="../my-upg-100-list.js" integrity="sha384-aN/2qoWx/R3L4jLqfHwqilkn/zEbdb+hSr5MMfCjkzckAjpyCPRWmclB5xdY2Ysd" crossorigin="anonymous"></script>
<script src="../my-upg-100-list.js" integrity="sha384-TkiPEj7EL0mDm8I28pBM7VnNIk9VCv+DuLq7WdEOHMfplFSIVc3Sf6k2CDGjcD05" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order: