Prove the sentence helper against a real t(), instead of claiming it

studio measured the trap: t(key) with no params BLANKS every placeholder. On
vue-i18n 9.14.5 "{nNone} of {nLang} have {noScripture}" comes back as " of  have "
— so sentenceParts would find nothing to split on and the sentence would lose its
values, silently, in every locale at once. I reproduced it before building on it.

keep(...names) returns { name: '{name}' } to hand t(), making the lookup a no-op
over the placeholders. Preferred over tm(), which returns the raw string only
while messages are compiled at runtime; this works with precompiled messages and
with plurals, which is the point of the pattern.

The previous commit SAID it works with precompiled messages. It was not measured,
and it was wrong in the way that matters: the helper was right and the call
pattern around it was not, so every arm passed while the real thing would have
rendered a blank sentence. Three new arms run an actual createI18n — English
renders, Arabic reorders the clauses, and the negative control asserts that
WITHOUT keep() the placeholders really are blanked, so if vue-i18n ever stops
doing that we find out rather than carrying keep() by habit.
This commit is contained in:
Doxa Maps 2026-09-29 04:24:21 +00:00
parent 396c8f2ee7
commit 474b0b4e8a
2 changed files with 46 additions and 1 deletions

View file

@ -12,7 +12,8 @@
*
* Every arm carries a negative control, because an assertion that cannot fail is not evidence.
*/
import { sentenceParts, strong, ltr } from '../library/composables/useSentence.js'
import { sentenceParts, strong, ltr, keep } from '../library/composables/useSentence.js'
import { createI18n } from 'vue-i18n'
const pass = []
const fail = []
@ -91,6 +92,30 @@ ok('a sentence carries two already-pluralised phrases without touching them',
ok('the helper takes a message, not a key (it cannot assemble one)',
!/\bt\s*\(/.test(String(sentenceParts)), 'sentenceParts never calls a translator')
// ── 6. THROUGH A REAL vue-i18n, because "works with precompiled messages" was a CLAIM ────────────
// studio measured the trap: t(key) with NO params BLANKS every placeholder — on 9.14.5 the message
// comes back as " of have " — so sentenceParts would split on nothing and the sentence would lose
// its values silently, in every locale at once. keep() hands each name back as its own literal so
// t() is a no-op over them. This arm runs the real thing rather than trusting the helper in isolation.
const i18n = createI18n({ legacy: false, locale: 'en', messages: {
en: { s: '{nNone} of {nLang} have {noScripture}' },
ar: { s: '{noScripture} في {nNone} من أصل {nLang}' },
} })
const g = i18n.global
const render = (loc) => {
g.locale.value = loc
return text(sentenceParts(g.t('s', keep('nNone', 'nLang', 'noScripture')),
{ nNone: '1,234', nLang: '7,296', noScripture: 'nothing found' }))
}
ok('a real t() keeps the placeholders when given keep()', render('en') === '1,234 of 7,296 have nothing found', render('en'))
const arLive = render('ar')
ok('and a real arabic message reorders the slots', arLive.indexOf('nothing found') < arLive.indexOf('1,234'), arLive)
// NEG CONTROL: WITHOUT keep(), t() blanks the placeholders — the exact trap. If this stops failing,
// vue-i18n changed and keep() may no longer be needed; find out rather than keep it by habit.
g.locale.value = 'en'
const blanked = g.t('s')
ok('NEG CONTROL: without keep(), t() blanks the placeholders', !blanked.includes('{nNone}'), JSON.stringify(blanked))
for (const p of pass) console.log(` ✓ ${p}`)
for (const f of fail) console.log(` ✗ ${f}`)
if (fail.length) {

View file

@ -108,3 +108,23 @@ export function ltr(value, bold = false) {
const inner = h('bdi', { dir: 'ltr' }, [String(value)])
return bold ? h('b', [inner]) : inner
}
/**
* The params to hand `t()` so it returns its message with the placeholders INTACT.
*
* ★ WITHOUT THIS THE SENTENCE COMES BACK EMPTY. vue-i18n resolves a message at lookup, so `t('s')`
* with no params BLANKS every placeholder: measured on 9.14.5, "{nNone} of {nLang} have
* {noScripture}" returns " of have " — and sentenceParts then has nothing to split, so the sentence
* silently loses its values. Passing each name back as its own literal makes t() a no-op over them.
*
* Preferred over tm(), which returns the raw string only while messages are compiled at runtime; this
* works with precompiled messages and with plurals, which is the whole point of the pattern.
*
* const msg = t('charts.noneOfLanguages', keep('nNone', 'nLang', 'noScripture'))
* sentenceParts(msg, { nNone: strong(a), nLang: strong(b), noScripture: ltr(c, true) })
*/
export function keep(...names) {
const out = {}
for (const n of names) out[n] = '{' + n + '}'
return out
}