From 474b0b4e8ad50ce1234e84e3bfc2df109acfb99c Mon Sep 17 00:00:00 2001 From: Doxa Maps Date: Tue, 29 Sep 2026 04:24:21 +0000 Subject: [PATCH] Prove the sentence helper against a real t(), instead of claiming it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- internal/check-sentence-slots.mjs | 27 ++++++++++++++++++++++++++- library/composables/useSentence.js | 20 ++++++++++++++++++++ 2 files changed, 46 insertions(+), 1 deletion(-) diff --git a/internal/check-sentence-slots.mjs b/internal/check-sentence-slots.mjs index d5923881f..0d597d4b6 100644 --- a/internal/check-sentence-slots.mjs +++ b/internal/check-sentence-slots.mjs @@ -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) { diff --git a/library/composables/useSentence.js b/library/composables/useSentence.js index 24887e0dd..16f32d3a7 100644 --- a/library/composables/useSentence.js +++ b/library/composables/useSentence.js @@ -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 +}