From 09aba6dc926b9fff381b0438222bbb74b8a87dbc Mon Sep 17 00:00:00 2001 From: Doxa Maps Date: Sat, 26 Sep 2026 18:07:44 +0000 Subject: [PATCH] C4 wave 2: five library components share the guard, and keep their own English MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The five are NOT one shape, and treating them as one would have changed what readers see. Measured before touching them: ApproxPinNote, FilterDropdown fall back to a literal map SemanticTreeLegend, PosterWall fall back to humanizeKey() — 'legend.columns.name' renders 'Name' PinHint falls back to a one-key map SemanticTreeLegend, PinHint also capture i18n.te (used 3x and 1x) PosterWall also captures i18n.locale So useOptionalT grew an optional `fallback` hook, and useOptionalI18n was added for the callers that need te and locale. Every site passes the fallback it already had, so not one rendered string changes. Routing them through the catalogue instead would have altered what seven bare-host bundles print, and — because tStatic honours the runtime locale — would have started translating strings that are English today. That may be an improvement; it is not this commit's to make. Changing a default is not a migration. te keeps its existing contract too: FALSE on a bare host, never "true because the catalogue has it". PinHint and SemanticTreeLegend both record why — te is how their callers know to use their own defaults. THE CHECK NOW LIVES ON MAIN, which is the point of this commit as much as the migration is. dx-shepherd: the 18-assertion version was in _qa-cycle, so it would never have run again. internal/check-optional-t-equivalence.mjs is registered as check:optional-t-equivalence — script line, //scripts note and gates:post token, all inserted by hand rather than by re-serialising package.json. It carries 46 assertions: the helper's contract across all three host states, a branch-for- branch equivalence against the old hand-written code, and a PER-SITE REGISTER that every later C4 wave appends a row to. Negative-tested from outside: strip one site's own fallback and it fails naming that site. check:library-index went red on the new composable and is green again after `bun run library-index`. That is the staleness gate working, not a flake. --- internal/check-optional-t-equivalence.mjs | 143 ++++++++++++++++++ library/components/hints/PinHint.vue | 8 +- library/components/legends/ApproxPinNote.vue | 7 +- .../components/legends/SemanticTreeLegend.vue | 9 +- library/components/poster/PosterWall.vue | 8 +- library/components/search/FilterDropdown.vue | 7 +- library/composables/useOptionalT.js | 47 +++++- package.json | 4 +- 8 files changed, 207 insertions(+), 26 deletions(-) create mode 100644 internal/check-optional-t-equivalence.mjs diff --git a/internal/check-optional-t-equivalence.mjs b/internal/check-optional-t-equivalence.mjs new file mode 100644 index 000000000..29953db81 --- /dev/null +++ b/internal/check-optional-t-equivalence.mjs @@ -0,0 +1,143 @@ +#!/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('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], +] +for (const [file, helper, fallbackFrag] of SITES) { + const code = codeOnly(rf(new URL('../' + file, import.meta.url), 'utf8')) + const short = file.split('/').pop() + ok(new RegExp(`\\b${helper}\\(`).test(code), `${short}: routes through ${helper}()`) + ok(!/useI18n\(/.test(code), `${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 { + ok(!/fallback:/.test(code), `${short}: takes the CATALOGUE fallback, as it always did`) + } +} + +// ── 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) diff --git a/library/components/hints/PinHint.vue b/library/components/hints/PinHint.vue index f70fe337e..5b4064b62 100644 --- a/library/components/hints/PinHint.vue +++ b/library/components/hints/PinHint.vue @@ -48,7 +48,7 @@ import { tStatic } from '@map/i18n/index.js' // custom elements that install no i18n plugin, and an unguarded useI18n() throws on the way in and // takes the subtree with it. The English stays as the fallback. const T = (key, en) => tStatic(key) || en -import { useI18n } from 'vue-i18n' +import { useOptionalI18n } from '@map/composables/useOptionalT.js' import { useShadowStyles } from '../../composables/useShadowStyles.js' // ★ i18n IS OPTIONAL HERE, NOT REQUIRED. useI18n() THROWS ("Need to install with `app.use` @@ -61,9 +61,9 @@ import { useShadowStyles } from '../../composables/useShadowStyles.js' // has them, the English literal otherwise. `te` becomes a "no, I don't have it" so every caller // falls through to its own default. const PINHINT_FALLBACK = { 'messages.pinHint.tapDot': 'Tap a dot to see who still needs prayer' } -let t = (key) => PINHINT_FALLBACK[key] || key -let te = () => false -try { const i18n = useI18n(); t = i18n.t; te = i18n.te } catch (_) { /* no i18n on this host */ } +// C4: the guard is library/composables/useOptionalI18n now. PINHINT_FALLBACK is passed in unchanged +// and `te` still answers false on a bare host, so every caller keeps its own default. +const { t, te } = useOptionalI18n('', { fallback: (key) => PINHINT_FALLBACK[key] }) const uiStore = inject('uiStore', null) const profileConfig = inject('profileConfig', null) const isDark = computed(() => uiStore?.theme === 'dark') diff --git a/library/components/legends/ApproxPinNote.vue b/library/components/legends/ApproxPinNote.vue index 5610bb9dc..7b77dc84e 100644 --- a/library/components/legends/ApproxPinNote.vue +++ b/library/components/legends/ApproxPinNote.vue @@ -9,7 +9,7 @@ -->