238 lines
18 KiB
JavaScript
238 lines
18 KiB
JavaScript
#!/usr/bin/env node
|
|
/* check-optional-t-equivalence.mjs — `bun run check:optional-t-equivalence`. Source-only, headless.
|
|
EVERY LATER C4 WAVE EXTENDS THIS FILE (dx-shepherd, REGC4G): a wave that migrates a site without
|
|
adding its row here is a wave whose behaviour nobody re-checks. Sections 1-6 are the helper's own
|
|
contract; section 7 is the per-site register. */
|
|
// C4: does useOptionalT() behave like the twenty hand-written guarded translators it replaces?
|
|
//
|
|
// Same condition the shepherd set for C2 and C3: old pattern vs new, every branch, negative-tested.
|
|
// The old shape, verbatim from usePplrT.js before this migration:
|
|
// const enLookup = (key) => String(key).replace(/^pplr\./,'').split('.').reduce(...enPplr)
|
|
// const fill = (str, params) => params ? str.replace(/\{(\w+)\}/g, ...) : str
|
|
// try { const i18n = useI18n(); return (k,p) => { const v = i18n.t(k,p||{}); return v===k ? fill(enLookup(k) ?? k, p) : v } }
|
|
// catch (_) { return (k,p) => fill(enLookup(k) ?? String(k), p) }
|
|
//
|
|
// ★ WHY THIS CANNOT JUST IMPORT THE MODULE. useOptionalT.js imports vue-i18n and @map/i18n/index.js,
|
|
// and that index calls `import.meta.glob` — a Vite transform plain Node cannot run. So the source is
|
|
// EXTRACTED AND EXECUTED with tStatic and useI18n stubbed, the same approach check-provider-for.mjs
|
|
// uses for engineGlobal, and for the same reason: re-typing the function into the gate would test the
|
|
// copy rather than the thing that ships.
|
|
import { readFileSync } from 'node:fs'
|
|
import { codeOnly } from '../plugin-designs/regions-dashboard/scripts/strip-code.mjs'
|
|
|
|
let fails = 0
|
|
const ok = (c, m) => { console.log(` ${c ? 'ok ' : 'FAIL'} - ${m}`); if (!c) fails++ }
|
|
|
|
const src = readFileSync(new URL('../library/composables/useOptionalT.js', import.meta.url), 'utf8')
|
|
const body = src
|
|
.replace(/^import .*$/gm, '') // drop the two un-runnable imports
|
|
.replace(/^export default .*$/gm, '') // and the default re-export (it is not a declaration)
|
|
.replace(/^export /gm, '')
|
|
const make = (tStatic, useI18n) => new Function('tStatic', 'useI18n',
|
|
body + '\nreturn { useOptionalT, useOptionalI18n, fillParams, qualify };')(tStatic, useI18n)
|
|
|
|
// The English catalogue the old code imported, as a stub tree: messages.en.pplr equivalent.
|
|
const EN = { greeting: 'Hello {name}', nested: { deep: 'Deep value' } }
|
|
const tStatic = (path) => String(path).replace(/^pplr\./, '').split('.').reduce((o, k) => (o == null ? o : o[k]), EN)
|
|
const throwingI18n = () => { throw new Error('Need to install with `app.use` function') }
|
|
const hostI18n = (table) => () => ({ t: (k) => (k in table ? table[k] : k) })
|
|
|
|
// ── 1. no i18n on the host — the case seven bundles are actually in ─────────────────────────────
|
|
{
|
|
const { useOptionalT } = make(tStatic, throwingI18n)
|
|
const t = useOptionalT('pplr')
|
|
ok(t('greeting', { name: 'Ada' }) === 'Hello Ada', 'bare host: falls back to the catalogue AND interpolates')
|
|
ok(t('nested.deep') === 'Deep value', 'bare host: resolves a nested key')
|
|
ok(t('missing.key') === 'missing.key', 'bare host: a missing key renders as the KEY, never blank')
|
|
ok(t('pplr.greeting', { name: 'Ada' }) === 'Hello Ada', 'bare host: an already-qualified key is not double-prefixed')
|
|
}
|
|
// ── 2. host HAS i18n and has the string — the host must win ─────────────────────────────────────
|
|
{
|
|
const { useOptionalT } = make(tStatic, hostI18n({ 'pplr.greeting': 'Bonjour' }))
|
|
ok(useOptionalT('pplr')('greeting') === 'Bonjour', 'host i18n wins when it has the key -- a host managing locale keeps control')
|
|
}
|
|
// ── 3. host HAS i18n but LACKS the string: i18n.t returns the key unchanged ─────────────────────
|
|
{
|
|
const { useOptionalT } = make(tStatic, hostI18n({}))
|
|
const t = useOptionalT('pplr')
|
|
ok(t('greeting', { name: 'Ada' }) === 'Hello Ada',
|
|
'host i18n present but missing the key falls through to the catalogue -- the case every old copy handled with `v === key`')
|
|
}
|
|
// ── 4. the pure helpers ────────────────────────────────────────────────────────────────────────
|
|
{
|
|
const { fillParams, qualify } = make(tStatic, throwingI18n)
|
|
ok(fillParams('a {x} b', { x: 1 }) === 'a 1 b', 'fillParams interpolates')
|
|
ok(fillParams('a {x} b', {}) === 'a {x} b', 'fillParams leaves an unknown placeholder visible rather than blanking it')
|
|
ok(fillParams('a {x} b', { x: null }) === 'a {x} b',
|
|
'fillParams leaves the placeholder for a NULL value -- a key-presence test would render the string "null" into the sentence')
|
|
ok(fillParams('a {x} b', { x: 0 }) === 'a 0 b',
|
|
'but 0 is a real value and must still substitute (a truthiness test would drop it)')
|
|
ok(fillParams('plain') === 'plain', 'fillParams with no params is a no-op')
|
|
ok(qualify('pplr', 'x') === 'pplr.x' && qualify('pplr', 'pplr.x') === 'pplr.x', 'qualify prefixes once, never twice')
|
|
}
|
|
// ── 5. EQUIVALENCE with the old hand-written implementation, branch for branch ──────────────────
|
|
{
|
|
const fill = (s, p) => (p ? String(s).replace(/\{(\w+)\}/g, (m, k) => (k in p ? p[k] : m)) : s)
|
|
const enLookup = (key) => String(key).replace(/^pplr\./, '').split('.').reduce((o, k) => (o == null ? o : o[k]), EN)
|
|
const oldBare = (k, p) => fill(enLookup(k) ?? String(k), p)
|
|
const { useOptionalT } = make(tStatic, throwingI18n)
|
|
const neu = useOptionalT('pplr')
|
|
for (const [k, p] of [['greeting', { name: 'Ada' }], ['nested.deep', null], ['missing.key', null], ['greeting', null]]) {
|
|
ok(oldBare(k, p) === neu(k, p), `EQUIVALENT on the bare-host path for ${JSON.stringify(k)}: both give ${JSON.stringify(oldBare(k, p))}`)
|
|
}
|
|
}
|
|
// ── 6. NEG CONTROLS ────────────────────────────────────────────────────────────────────────────
|
|
{
|
|
const { useOptionalT } = make(() => undefined, throwingI18n)
|
|
ok(useOptionalT('pplr')('greeting') === 'greeting',
|
|
'NEG CONTROL: with a catalogue that knows nothing, the key comes back -- so the passes above were really reading the catalogue')
|
|
const { useOptionalT: u2 } = make(tStatic, hostI18n({ 'pplr.greeting': 'Bonjour' }))
|
|
ok(u2('pplr')('greeting') !== 'Hello Ada', 'NEG CONTROL: the host path and the catalogue path are distinguishable')
|
|
}
|
|
ok(/import \{ tStatic \}/.test(src), 'CONTROL: the shipped module really does route through tStatic (not a catalogue import of its own)')
|
|
// ★ codeOnly, because this assertion failed on its OWN DOCUMENTATION: useOptionalT.js's header QUOTES
|
|
// '@map/i18n/locales/en/pplr.json' as the import it removed, and a whole-file grep counted the prose as
|
|
// the thing it forbids. Fifth time this session that a detector has read a comment as code.
|
|
ok(!/locales\/en\//.test(codeOnly(src)), 'CONTROL: and it imports NO per-area English JSON in CODE -- the redundant second copy is gone')
|
|
|
|
|
|
// ── 7. THE PER-SITE REGISTER. One row per migrated site; later waves append here. ───────────────
|
|
// Each row asserts the two things a migration can get wrong: that the site actually routes through
|
|
// the shared guard, and that it still carries ITS OWN fallback rather than silently inheriting the
|
|
// catalogue. The second matters more than it looks: routing these through tStatic would change what
|
|
// seven bare-host bundles render, and would start translating strings that are English today.
|
|
import { readFileSync as rf } from 'node:fs'
|
|
const SITES = [
|
|
// file, helper it must use, a fragment of the fallback it must still pass
|
|
['library/components/legends/ApproxPinNote.vue', 'useOptionalT', 'APPROX_FALLBACK[key]'],
|
|
['library/components/search/FilterDropdown.vue', 'useOptionalT', 'FALLBACK[key]'],
|
|
['library/components/legends/SemanticTreeLegend.vue', 'useOptionalI18n', 'humanizeKey(k)'],
|
|
['library/components/poster/PosterWall.vue', 'useOptionalI18n', 'humanizeKey(k)'],
|
|
['library/components/hints/PinHint.vue', 'useOptionalI18n', 'PINHINT_FALLBACK[key]'],
|
|
['plugin-designs/pplr-data-maps/src/composables/usePplrT.js', 'useOptionalT', null],
|
|
['plugin-designs/upg-neighbor/src/composables/useNeighborT.js', 'useOptionalT', null],
|
|
// wave 3a — gospel-resources (owner: gospel-plugin). All four fell back to enLookup, i.e. the
|
|
// SHIPPED ENGLISH, not the locale-aware catalogue; each passes that enLookup in, so a bare host
|
|
// still renders English. Switching them to the catalogue default would make a bare host honour
|
|
// ?lang=ar — a real improvement, and gospel-plugin's to make, not this migration's.
|
|
// ★ gospel-plugin CHOSE the locale-honouring catalogue fallback (2026-09-26) rather than keeping
|
|
// enLookup's English: "most partners will drop a custom element or an iframe and never install
|
|
// vue-i18n ... an Arabic ministry site embedding this map serves its readers English while the
|
|
// bundle already carries all 11 catalogues. That is a bug, not a decision." So these four take NO
|
|
// fallback, and row 7b proves the Arabic actually arrives.
|
|
['plugin-designs/gospel-resources/charts.vue', 'useOptionalT', null],
|
|
['plugin-designs/gospel-resources/data-tables.vue', 'useOptionalT', null],
|
|
['plugin-designs/gospel-resources/resources-list.vue', 'useOptionalT', null],
|
|
// ★ resources-map passes NO NAMESPACE: it uses keys in TWO areas (gospel.* and pins.*) and its
|
|
// enLookup walks the catalogue ROOT to see both. useOptionalT('gospel', ...) would have qualified
|
|
// pins.approximate into gospel.pins.approximate and broken the pins sentence — studio caught that
|
|
// as a semantic conflict on main before it landed. The row asserts the '' explicitly so a later
|
|
// edit cannot quietly re-add a namespace.
|
|
['plugin-designs/gospel-resources/resources-map.vue', "useOptionalT(''", null],
|
|
// wave 3b — cities-internet. All three were byte-identical in the guard block; each keeps its own
|
|
// enLookup fallback, so a bare host renders the same English. Their local fill() used `k in params`,
|
|
// so the shared fillParams also removes a way for them to print the literal "null".
|
|
// ★ exp-shepherd, as cities-internet's owner, took the locale-honouring default (2026-09-26): "the
|
|
// bundle already carries all 11 catalogues, so a bare host getting English would be a bug". So these
|
|
// three pass NO fallback, and their English-only enLookup + per-area catalogue import are deleted.
|
|
['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],
|
|
]
|
|
// ★ 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
|
|
// engine's language tag. That is a different fact from translating text: `locale` arrives as a ref OR
|
|
// a plain string depending on the host (PosterWall.vue records the same variance), so normalising it
|
|
// belongs in a deliberate helper change, not in a translator migration. Asserting EXACTLY 2 means a
|
|
// third bare call — a new unguarded translator — still fails this gate.
|
|
// ★ DELIBERATELY NOT IN THIS REGISTER, so nobody "completes" C4 by converting it:
|
|
// plugin-designs/pplr-data-maps/src/components/SemanticTreeLegend.vue
|
|
// It still hand-rolls the guard, and exp-shepherd asked for it to stay that way: the file is carded for
|
|
// retirement (RETIRE-PPLR-SEMANTICTREELEGEND) in favour of the library component, so converting it is
|
|
// work that gets thrown away. A migration whose value expires before it lands is not worth the diff.
|
|
// Two more sites are their owners' to cut, not absent by oversight: country-posters-mfe.vue
|
|
// (exp-shepherd, into its single posters landing) and key-cities/cities-map.vue (exp-shepherd, gated).
|
|
|
|
const ALLOWED_BARE = { 'plugin-designs/gospel-resources/resources-map.vue': 2 }
|
|
for (const [file, helper, fallbackFrag] of SITES) {
|
|
// ★ STRIP HTML COMMENTS TOO, for .vue files. codeOnly is a JS scanner: it knows /* */ and //, not
|
|
// <!-- -->. resources-map.vue carries a long template comment that MENTIONS useI18n(), and counting
|
|
// it made this register read 3 bare calls where the code has 2. Same lesson as the CSS-in-a-template
|
|
// -literal case: the stripper has to match the LANGUAGE of the text it is reading.
|
|
const raw = rf(new URL('../' + file, import.meta.url), 'utf8')
|
|
const code = codeOnly(raw.replace(/<!--[\s\S]*?-->/g, ' '))
|
|
const short = file.split('/').pop()
|
|
ok(helper.includes('(') ? code.includes(helper) : new RegExp(`\\b${helper}\\(`).test(code),
|
|
`${short}: routes through ${helper}${helper.includes('(') ? '' : '()'}`)
|
|
const bare = (code.match(/useI18n\(/g) || []).length
|
|
const allowed = ALLOWED_BARE[file] || 0
|
|
ok(bare === allowed, allowed
|
|
? `${short}: exactly ${allowed} bare useI18n() left, the documented locale read(s) -- found ${bare}`
|
|
: `${short}: no bare useI18n() left in CODE (comments explaining it are fine)`)
|
|
if (fallbackFrag) {
|
|
ok(code.includes(fallbackFrag),
|
|
`${short}: still passes its OWN fallback (${fallbackFrag}) -- migrating it to the catalogue would change what a bare host renders`)
|
|
} else {
|
|
// ★ NOT "as it always did" for all of them. The two composables always resolved through the
|
|
// 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.
|
|
const moved = file.includes('gospel-resources')
|
|
ok(!/fallback:/.test(code), moved
|
|
? `${short}: takes the locale-honouring CATALOGUE fallback (gospel-plugin moved it here from enLookup's English)`
|
|
: `${short}: takes the CATALOGUE fallback, as it always did`)
|
|
}
|
|
}
|
|
|
|
// ── 7b. gospel-plugin's ASKED-FOR ROW, against the REAL catalogue ───────────────────────────────
|
|
// Their words: "a BARE host (no vue-i18n) with ?lang=ar must render pins.approximate in Arabic — not
|
|
// the key, not gospel.pins.approximate, not English." It covers their conditions 1 and 2 together: a
|
|
// namespace would corrupt the key, and an EN-only fallback would serve English to an Arabic reader.
|
|
// This reads the SHIPPED ar/ and en/ pins.json rather than a stub, and rebuilds tStatic's own
|
|
// resolution order (messages[want] ?? messages.en) over them, so it measures the real catalogue.
|
|
{
|
|
const arPins = JSON.parse(rf(new URL('../library/i18n/locales/ar/pins.json', import.meta.url), 'utf8'))
|
|
const enPins = JSON.parse(rf(new URL('../library/i18n/locales/en/pins.json', import.meta.url), 'utf8'))
|
|
const messages = { ar: { pins: arPins }, en: { pins: enPins } }
|
|
const realish = (want) => (path) => {
|
|
const walk = (loc) => String(path).split('.').reduce((o, k) => (o == null ? o : o[k]), messages[loc])
|
|
const v = walk(want); return typeof v === 'string' ? v : walk('en')
|
|
}
|
|
const { useOptionalT } = make(realish('ar'), throwingI18n)
|
|
const t = useOptionalT('')
|
|
const got = t('pins.approximate')
|
|
ok(got === arPins.approximate,
|
|
'GOSPEL ROW: a BARE host under ar renders pins.approximate in ARABIC from the real catalogue')
|
|
ok(got !== 'pins.approximate' && got !== 'gospel.pins.approximate',
|
|
'and it is neither the raw key nor a namespace-mangled key')
|
|
ok(got !== enPins.approximate,
|
|
'and it is NOT the English -- which is exactly what enLookup used to serve every partner host')
|
|
// the same call must still reach English when the locale has no entry
|
|
const tEnOnly = useOptionalT('')
|
|
ok(typeof arPins.approximate === 'string' && typeof enPins.approximate === 'string',
|
|
'CONTROL: both catalogues really do carry the key, so the three assertions above are measuring a difference that exists')
|
|
const { useOptionalT: uMissing } = make(realish('zz'), throwingI18n)
|
|
ok(uMissing('')('pins.approximate') === enPins.approximate,
|
|
'ENGLISH REMAINS THE LAST RESORT: an unknown locale falls back to en, as gospel required')
|
|
}
|
|
|
|
// ── 8. the fallback option and te/locale, behaviourally ────────────────────────────────────────
|
|
{
|
|
const { useOptionalT, useOptionalI18n } = make(tStatic, throwingI18n)
|
|
const mine = useOptionalT('pplr', { fallback: (k) => (k === 'pplr.greeting' ? 'MINE {name}' : undefined) })
|
|
ok(mine('greeting', { name: 'Ada' }) === 'MINE Ada',
|
|
'opts.fallback WINS over the catalogue -- that is what keeps each migrated site rendering its own English')
|
|
ok(mine('nested.deep') === 'nested.deep',
|
|
'and when the site fallback has nothing, the KEY shows (it does NOT silently fall through to the catalogue)')
|
|
const { te, locale } = useOptionalI18n('pplr')
|
|
ok(te('greeting') === false, 'te() is FALSE on a bare host -- PinHint and SemanticTreeLegend depend on that to use their own defaults')
|
|
ok(locale === 'en', 'locale defaults to en on a bare host, as PosterWall had it')
|
|
}
|
|
{
|
|
const { useOptionalI18n } = make(tStatic, hostI18n({ 'pplr.greeting': 'Bonjour' }))
|
|
const { te } = useOptionalI18n('pplr')
|
|
ok(typeof te === 'function', 'te() is a function when the host HAS i18n')
|
|
}
|
|
|
|
console.log(fails ? `\nC4-OPTIONAL-T: ${fails} FAIL(s)` : '\nC4-OPTIONAL-T: the shared translator matches the hand-written guards on every branch')
|
|
process.exit(fails ? 1 : 0)
|