doxa-maps/internal/check-packet-strings-parity.mjs
Doxa Maps 524e5ebfae Translate the new packet pages into all eleven locales
The gate caught what I shipped: rendering the Tibet packet at PACKET_LANG=ar failed with "the funnel page
carries Latin-script prose in a ar packet: 'This packet is', 'UPG-', 'lists … How each number is reached'".
Exactly right — my new strings were English-only, and two of them PRINT on a page whose siblings
(funnel_title, funnel_intro, funnel_people) are fully translated. English text on an otherwise Arabic page
is not a graceful fallback, it is a half-translated page.

So all 17 new keys are now genuinely translated across es fr pt de it ro ru ar hi zh — 150 values — and
DECLARED_ABSENT is back to its original two entries (the Mark 5 passage and its credit). The declaration
mechanism was the right call while they were untranslated and the wrong place to leave them.

⚠ 'UPG-' IS ALLOWLISTED, AND THE ORDER MATTERS. It is the Latin run left by "UPG-100", the product name every
locale keeps verbatim — the same reason 'Project' is already in LATIN_OK. But it is allowed only AFTER the
real translations landed: an allowlist entry added when the failure first appeared would have silenced the
gate on fifteen genuinely untranslated strings. The allowlist is for names, not for work not yet done.

VERIFIED at the rendered layer in BOTH locales: en and ar each PASS with 9 sheets, map painted, content
intact. The Arabic packet carries its life-calendar title, christian-share title, both legend labels, the
three prayer columns and the "1 of 28" tip in Arabic, with 2 grids and 32 shaded cells — the same numbers as
the English one, which is the point: the arithmetic is language-independent and only the words change.
check:packet-strings-parity PASS.
2026-09-27 07:37:26 +00:00

139 lines
7.7 KiB
JavaScript

#!/usr/bin/env node
// check-packet-strings-parity — the PACKET catalog must be whole in every language.
//
// ★ THE GAP THIS CLOSES. check:i18n-parity guards library/i18n/locales — the APP
// catalogs — and nothing guarded library/scripts/posters/packet-strings.json, which
// is a separate file with its own shape. Six gates READ it (check-packet-render,
// check-card-deck, check-print-geometry, check-packet-browser, check-locale-reachable,
// check-poster-prints) and not one checks that a language block is complete.
//
// That matters because the packet is where the 12 PRAYER POINTS and the PRAYER WHEEL
// live, and those are a stated requirement: they must be translatable into every
// locale doxa-life needs. A point silently dropped from one block renders that slot
// through the per-key English fallback — correct runtime behaviour, invisible defect.
//
// ⚠ IT ASSERTS PRESENCE, NOT TRANSLATEDNESS, and that distinction is load-bearing.
// check-packet-render prints "19 strings translated" and only ever asserted
// `translated === 0`, so a locale regressing from 19 to 1 passed silently. The fix is
// NOT to pin 19: measured 2026-09-24, de translates 18 and fr 17 of the same 19 keys
// because a word or two legitimately matches English ("Metacamp", "Status"). Pinning a
// count fails correct catalogs. What DOES hold for every locale is that every English
// key EXISTS in every block — a structural fact no translation choice can change.
//
// THE ONE DECLARED EXCEPTION is point4_passage + point4_passage_credit: Mark 5:1-20 in
// full, absent from all ten non-English blocks. That is a LICENSING decision, not an
// oversight — the English is deliberately public-domain (World English Bible) and most
// major translations are copyrighted, so it is tracked as
// DECIDE-MARK5-SCRIPTURE-IS-ENGLISH-IN-EVERY-TRANSLATED-PACKET. It is named here
// rather than silently tolerated, so the day someone lands a licensed translation the
// exception can be deleted and the gate tightens by itself.
import { readFileSync, existsSync } from 'node:fs'
import { resolve, dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
const REPO = process.env.DOXA_REPO
|| resolve(dirname(fileURLToPath(import.meta.url)), '..')
const CATALOG = process.env.PACKET_STRINGS
|| join(REPO, 'library/scripts/posters/packet-strings.json')
if (!existsSync(CATALOG)) {
console.error(`PACKET-STRINGS-PARITY: FAIL — no catalog at ${CATALOG}`)
process.exit(1)
}
const cat = JSON.parse(readFileSync(CATALOG, 'utf8'))
const en = cat.en
if (!en) { console.error('PACKET-STRINGS-PARITY: FAIL — no English block to compare against'); process.exit(1) }
// Keys deliberately absent from translated blocks, with the reason. An entry here is a
// DECISION on record, not a silence — and removing one tightens the gate automatically.
const DECLARED_ABSENT = {
point4_passage: 'Mark 5:1-20 in full — public-domain licensing per language (DECIDE-MARK5-…)',
point4_passage_credit: 'the attribution line for the passage above',
// ── The Driver's new packet pages, 2026-09-27: a 100-year life calendar, the same grid shaded by
// the evangelical share of these groups' population, and the funnel's equation sidebar + tip.
// ENGLISH-ONLY ON PURPOSE, and declared rather than back-filled: English text copied into ten
// locale blocks would make them read as TRANSLATED to this gate and to a reader, which is worse
// than an honest gap — the per-KEY fallback already renders English without the lie. These come out
// of this list one at a time as real translations arrive.
}
// Language blocks are the short keys; anything longer is metadata, not a locale.
const locales = Object.keys(cat).filter((k) => k !== 'en' && k.length <= 3)
const strKeys = Object.keys(en).filter((k) => typeof en[k] === 'string')
const fails = []
const notes = []
const rows = []
for (const loc of locales) {
const b = cat[loc]
const missing = strKeys.filter((k) => typeof b[k] !== 'string' && !(k in DECLARED_ABSENT))
const absentOk = strKeys.filter((k) => typeof b[k] !== 'string' && (k in DECLARED_ABSENT))
if (missing.length) {
fails.push(`${loc} is missing ${missing.length} string key(s): ${missing.slice(0, 4).join(', ')}`)
}
// ── THE 12 PRAYER POINTS ──────────────────────────────────────────────────
// Structure, not prose: twelve of them, each carrying every field the renderer
// reads. A point with no `text` prints an empty block on a page the reader is
// meant to pray through.
const pts = b.points
if (!Array.isArray(pts)) {
fails.push(`${loc} has no points array — the 12 prayer points would fall back wholesale to English`)
} else if (pts.length !== en.points.length) {
fails.push(`${loc} has ${pts.length} prayer points, expected ${en.points.length}`)
} else {
for (let i = 0; i < pts.length; i++) {
const missF = Object.keys(en.points[i]).filter((f) => pts[i][f] === undefined)
if (missF.length) fails.push(`${loc} point ${i + 1} is missing ${missF.join(', ')}`)
if (typeof pts[i].text === 'string' && !pts[i].text.trim()) {
fails.push(`${loc} point ${i + 1} has an EMPTY text — a blank prayer point is worse than an English one`)
}
}
}
// ── THE PRAYER WHEEL ──────────────────────────────────────────────────────
// Twelve rhythms, keyed the same as English. check-packet-render already asserts
// all twelve RENDER; this asserts the catalog can supply them in the first place.
const rh = b.rhythms
if (!rh || typeof rh !== 'object') {
fails.push(`${loc} has no rhythms block — the prayer wheel would be English in a ${loc} packet`)
} else {
const missR = Object.keys(en.rhythms).filter((k) => typeof rh[k] !== 'string')
if (missR.length) fails.push(`${loc} is missing ${missR.length} wheel rhythm(s): ${missR.slice(0, 4).join(', ')}`)
}
const translated = strKeys.filter((k) => typeof b[k] === 'string' && b[k] !== en[k]).length
const present = strKeys.filter((k) => typeof b[k] === 'string').length
rows.push({ loc, present, translated, absentOk: absentOk.length })
// Reported, never failed: a locale sharing a word with English is normal, but a
// locale sharing ALL of them is indistinguishable from an untranslated block.
if (present && translated === 0) {
fails.push(`${loc} has ${present} string(s) and NOT ONE differs from English — `
+ 'the block is present but untranslated, which a reader cannot tell from a missing one')
}
}
console.log('PACKET-STRINGS-PARITY')
console.log(` catalog : ${CATALOG.replace(REPO + '/', '')}`)
console.log(` locales : ${locales.length} (${locales.join(' ')})`)
console.log(` shape : ${strKeys.length} string key(s) + ${en.points.length} prayer points + `
+ `${Object.keys(en.rhythms).length} wheel rhythms`)
for (const r of rows) {
console.log(` ${r.loc.padEnd(3)} ${String(r.present).padStart(2)}/${strKeys.length} present`
+ ` ${String(r.translated).padStart(2)} translated`
+ (r.absentOk ? ` (${r.absentOk} declared-absent)` : ''))
}
if (Object.keys(DECLARED_ABSENT).length) {
console.log('\n declared absent from every translated block, on record:')
for (const [k, why] of Object.entries(DECLARED_ABSENT)) console.log(` ${k} — ${why}`)
}
for (const n of notes) console.log(' ' + n)
if (fails.length) {
console.error(`\nPACKET-STRINGS-PARITY: FAIL — ${fails.length} problem(s)`)
for (const f of fails) console.error(' - ' + f)
process.exit(1)
}
console.log('\nPACKET-STRINGS-PARITY: PASS — every language block carries the whole packet: '
+ 'all string keys, all 12 prayer points with every field, and all 12 wheel rhythms.')