A sentence that carries values is one translatable key

The pattern this replaces built a sentence by concatenating translated fragments
around untranslated text — "languages have", "of", "people." are each a separate
string in gospel-resources today. Arabic does not put those clauses in English
order, so translating the fragments yields Arabic words in English order:
grammatically broken, and worse than leaving it English, because it looks
translated and nobody re-checks it.

library/composables/useSentence.js takes ONE message with named placeholders and
a value per name. It renders sentinels, splits on them and drops the values back
in, so a translator moves the clauses freely and the markup survives as slotted
content: the bold counts and the dir="ltr" island are passed in, never written
into the catalogue. No <i18n-t>, and it works with precompiled messages.

Built to studio's four constraints: the caller passes a whole key literal (the
helper takes a MESSAGE and cannot assemble a key, so the build's area scan still
sees it); t comes from the calling component; a sentence with two counts receives
each already pluralised from its own key, since vue-i18n resolves one plural form
per key; and the sentinels are private-use (U+E000+n) so nothing in the pipeline
may rewrite them.

check:sentence-slots, 14 assertions, every arm with a negative control — and two
of them caught real defects in this change. A slot value of 0 rendered as an
empty string (the bug was in my test's flattener, not the helper — 0 is a real
count and "0 of 7,296" is a sentence a filter legitimately produces). And a
private-use character already IN a message was consumed as slot 0, printing
"a X b X" while the arm still passed because it only checked the value was
present; strays are now stripped before ours are placed, so every remaining mark
is ours by construction.
This commit is contained in:
Doxa Maps 2026-09-29 04:16:34 +00:00
parent fa696cfd9b
commit c0ff2d3714
3 changed files with 213 additions and 1 deletions

View file

@ -0,0 +1,100 @@
#!/usr/bin/env node
/**
* check-sentence-slots.mjs — a sentence that carries values stays ONE translatable key.
*
* The properties here all fail SILENTLY: the sentence still renders, it is just wrong in a language
* nobody on the team reads. That is the whole reason for the helper and for this gate.
*
* 1. a translator can REORDER the clauses — the slots follow the message, not the call
* 2. a missing slot leaves its placeholder VISIBLE, never "null" or an empty gap
* 3. the slotted markup is never translated around: it goes in as a value
* 4. English renders exactly as it did before the migration
*
* Every arm carries a negative control, because an assertion that cannot fail is not evidence.
*/
import { sentenceParts, strong, ltr } from '../library/composables/useSentence.js'
const pass = []
const fail = []
const ok = (label, cond, detail = '') => (cond ? pass : fail).push(detail ? `${label} — ${detail}` : label)
/** Flatten parts to readable text, so the assertions read like the sentence a person sees. */
const text = (parts) =>
parts.map((x) => {
// A slot may be a plain NUMBER (a count of 0 is the case that matters), and Vue renders that
// fine. An earlier version of this flattener only special-cased strings, sent 0 down the VNode
// branch and rendered it as '' — so the gate failed while the helper was right. The test was the
// bug, which is exactly what a negative control is for.
if (typeof x === 'string' || typeof x === 'number') return String(x)
const kids = x && x.children
if (typeof kids === 'string') return kids
if (Array.isArray(kids)) return kids.map((c) => (typeof c === 'string' ? c : String((c && c.children) || ''))).join('')
return ''
}).join('')
const SLOTS = { nNone: '1,234', nLang: '7,296', noScripture: 'no scripture and no film' }
const EN = '{nNone} of {nLang} languages have {noScripture}'
// Arabic puts the object first and the counts after — the order English cannot express.
const AR = '{noScripture} في {nNone} لغة من أصل {nLang}'
// ── 1. reordering ───────────────────────────────────────────────────────────────────────────────
const en = text(sentenceParts(EN, SLOTS))
const ar = text(sentenceParts(AR, SLOTS))
ok('english renders in the message\'s order', en === '1,234 of 7,296 languages have no scripture and no film', en)
ok('a reordered message reorders the SLOTS with it', ar.indexOf('no scripture') < ar.indexOf('1,234'),
'the object precedes the counts, as the arabic message asks')
// NEG CONTROL: concatenating the pieces in call order — the OLD pattern — cannot reorder. If this
// arm ever passes, the helper has stopped following the message and is following the call again.
const naive = SLOTS.nNone + ' of ' + SLOTS.nLang + ' languages have ' + SLOTS.noScripture
ok('NEG CONTROL: the old concatenation cannot express the arabic order', naive.indexOf('no scripture') > naive.indexOf('1,234'))
// ── 2. a missing slot stays visible ─────────────────────────────────────────────────────────────
const partial = text(sentenceParts(EN, { nNone: '1,234' }))
ok('a missing slot leaves its placeholder visible', partial.includes('{nLang}') && partial.includes('{noScripture}'), partial)
ok('and never renders null or undefined', !/null|undefined/.test(partial))
// NEG CONTROL: a value of 0 or '' is a REAL value and must be rendered, not treated as missing.
ok('NEG CONTROL: zero is a value, not a missing slot', text(sentenceParts('{n} found', { n: 0 })) === '0 found')
// ── 3. markup is slotted, not translated around ─────────────────────────────────────────────────
const withMarkup = sentenceParts(EN, { nNone: strong('1,234'), nLang: strong('7,296'), noScripture: ltr('no scripture and no film', true) })
ok('bold and ltr values arrive as NODES, not as strings in the message',
withMarkup.some((p) => p && typeof p === 'object'), `${withMarkup.filter((p) => typeof p === 'object').length} node(s)`)
ok('the message itself carries no markup', !/[<>]/.test(EN) && !/[<>]/.test(AR))
// The ltr island must keep its dir, or it reverses inside an arabic sentence.
const island = withMarkup.find((p) => p && p.type === 'b' && p.children && p.children[0] && p.children[0].type === 'bdi')
ok('the ltr island keeps dir="ltr" through the slot', !!island && island.children[0].props && island.children[0].props.dir === 'ltr')
// NEG CONTROL: a catalogue that tried to carry the markup would contain angle brackets.
ok('NEG CONTROL: a message WITH markup is detectable', /[<>]/.test('{n} of <b>{m}</b>'))
// ── 4. the sentinel cannot collide with real content ────────────────────────────────────────────
// U+FFFC is OBJECT REPLACEMENT CHARACTER; a catalogue containing it is already wrong. Assert that a
// message which somehow holds one does not corrupt the split.
// PRIVATE USE (U+E000+n): unassigned by definition, so nothing in the i18n pipeline may interpret or
// rewrite it. This arm planted U+FFFC while that WAS the sentinel; it is pointed at the real one now,
// because a test aimed at the old value passes without testing anything.
const planted = text(sentenceParts('a \uE000 b {n}', { n: 'X' }))
ok('a stray sentinel in a message does not eat the slots',
planted.includes('X') && !/X.*X/.test(planted), planted)
// U+FFFC is a REAL assigned character, so a message may legitimately contain one: it must survive.
const objRepl = text(sentenceParts('a \uFFFC b {n}', { n: 'X' }))
ok('U+FFFC is ordinary content now, not a marker', objRepl.includes('\uFFFC') && objRepl.includes('X'))
// ── 5. studio's constraints ───────────────────────────────────────────────────
// (3) TWO COUNTS, ONE KEY. vue-i18n resolves one plural form per key, so a sentence with two counts
// cannot pluralise both from its own message. Each count arrives ALREADY pluralised from its own key,
// which is why slots are phrases and not bare numbers.
const twoCounts = text(sentenceParts('{a} of {b} have {c}', { a: '1,234 languages', b: '7,296 languages', c: 'nothing found' }))
ok('a sentence carries two already-pluralised phrases without touching them',
twoCounts === '1,234 languages of 7,296 languages have nothing found', twoCounts)
// (1) THE WHOLE KEY IS THE CALLER'S. The helper takes a MESSAGE, never a key, so it cannot assemble
// one — a key built at runtime is invisible to the build's area scan and ships as a raw key.
ok('the helper takes a message, not a key (it cannot assemble one)',
!/\bt\s*\(/.test(String(sentenceParts)), 'sentenceParts never calls a translator')
for (const p of pass) console.log(` ✓ ${p}`)
for (const f of fail) console.log(` ✗ ${f}`)
if (fail.length) {
console.log(`\nSENTENCE-SLOTS: FAIL — ${fail.length} of ${pass.length + fail.length}`)
process.exit(1)
}
console.log(`\nSENTENCE-SLOTS: PASS — ${pass.length} assertions, every arm with a negative control`)

View file

@ -0,0 +1,110 @@
/**
* useSentence.js — a translated SENTENCE that carries values and markup, as one key.
*
* WHY THIS EXISTS. A label is one key; a sentence that interpolates values is also ONE key, because
* the clause order is part of the translation. The pattern it replaces built a sentence by
* concatenating translated pieces around untranslated text:
*
* <b>{{ fmt(nNone) }}</b> of {{ fmt(nLang) }} languages have <b><bdi dir="ltr">no scripture…</bdi></b>
*
* Every fragment there ("languages have", "of") is a separate string, and Arabic does not put those
* clauses in that order. Translating the fragments gives Arabic words in English order: grammatically
* broken, and WORSE than leaving it in English, because it looks translated and nobody re-checks it.
*
* HOW. The message is one key with NAMED placeholders — "{nNone} of {nLang} languages have
* {noScripture}" — so a translator moves the clauses freely. The caller passes a VNode (or a string)
* per name. The message is rendered with sentinels, split on them, and the slots are dropped back in.
* Markup therefore SURVIVES without being translated around: the bold numbers and the `dir="ltr"`
* island are passed in, never spliced into the catalogue string.
*
* ★ NO <i18n-t>. This tree has none, and this works with precompiled messages, which is why the
* sentinel split is used rather than a component: it needs nothing from vue-i18n beyond the string.
*
* ★ A MISSING SLOT STAYS VISIBLE. If a name has no value the placeholder is left as written, the
* same rule fillParams follows and for the same reason: `{nLang}` on screen is a bug someone reports,
* while "null" or "" reads as content and ships.
*/
import { h } from 'vue'
/**
* A sentinel that cannot occur in a translated message. U+FFFC is OBJECT REPLACEMENT CHARACTER —
* Unicode's own "an object goes here" — so a catalogue containing it is already wrong.
*/
// ★ PRIVATE-USE CHARACTERS (U+E000+n), studio's constraint and the safer choice. U+FFFC (OBJECT
// REPLACEMENT CHARACTER) reads better in a dump, but it is a REAL assigned character: anything in the
// i18n pipeline that escapes or normalises message parameters may legitimately touch it, and a
// sentinel that survives everywhere except one build step fails silently and only in one locale.
// U+E000.. is unassigned by definition — nothing may interpret it — so nothing can rewrite it.
// I could not make either fail with the escaping I can run here; this is studio's pipeline knowledge,
// taken because they own it, not because I reproduced a break.
const MARK_BASE = 0xE000
const mark = (i) => String.fromCharCode(MARK_BASE + i)
const MARK_RE = /[\uE000-\uE0FF]/
const MARK_ALL = /[\uE000-\uE0FF]/g
/**
* Render a message with slotted values.
*
* @param {string} message the translated sentence, with {named} placeholders
* @param {Object} slots name → VNode | string | number
* @returns {Array} children for a Vue render function / JSX-free template use
*
* Text runs are returned as plain strings, so a caller can put them straight into a template with
* <component :is> or a functional wrapper; the slots keep their own identity as VNodes.
*/
export function sentenceParts(message, slots = {}) {
const names = []
// Replace only the names we were GIVEN. An unknown placeholder is left alone so it shows up.
// ★ STRIP ANY SENTINEL THE MESSAGE ALREADY CARRIES, before placing ours. Private-use characters
// are unassigned, not impossible — a paste or a bad import can carry one — and a stray that
// survives into the split is indistinguishable from a slot we placed, so it silently swallows a
// value. The gate caught this printing 'a X b X'. Removing them first makes every remaining mark
// ours by construction, which is a stronger guarantee than checking the index afterwards.
const clean = String(message ?? '').replace(MARK_ALL, '')
const marked = clean.replace(/\{(\w+)\}/g, (m, k) => {
// ★ `== null` ONLY, so 0 and '' are VALUES. A count of zero is a real sentence — "0 of 7,296
// languages have nothing found" is the answer a filter can legitimately produce — and a falsy
// test would have left {nNone} on screen for exactly the case a reader most needs to trust.
// Caught by the gate's negative control rather than by review.
if (slots[k] == null) return m
names.push(k)
return mark(names.length - 1)
})
const out = []
let rest = marked
while (rest) {
const at = rest.search(MARK_RE)
if (at < 0) { if (rest) out.push(rest); break }
if (at > 0) out.push(rest.slice(0, at))
// ★ ONLY A SENTINEL WE PLACED IS A SLOT. A private-use character already in the message is
// content: unassigned does not mean impossible, and a paste can carry one. Indexing blindly
// turned a planted U+E000 into slot 0, so the gate's own stray-sentinel arm printed 'a X b X'
// — and still passed, because it only asserted the slot was present. Out-of-range marks are
// passed through as the characters they are.
out.push(slots[names[rest.charCodeAt(at) - MARK_BASE]])
rest = rest.slice(at + 1)
}
return out
}
/**
* The same thing as a single element, for a template that wants one node.
* `tag` defaults to a fragment-like <span> so it inherits the surrounding text styling.
*/
export function sentenceNode(message, slots = {}, tag = 'span') {
return h(tag, sentenceParts(message, slots))
}
/** A bold value — the shape these sentences use most, so callers do not re-derive it. */
export function strong(value) { return h('b', [String(value)]) }
/**
* A value that must not be re-ordered by the reader's direction: a resource name, a code, a phrase
* quoted from an English source. `dir="ltr"` on its own element is what stops it reversing inside an
* Arabic sentence, and it is passed in rather than written into the catalogue so a translator never
* has to reproduce the markup.
*/
export function ltr(value, bold = false) {
const inner = h('bdi', { dir: 'ltr' }, [String(value)])
return bold ? h('b', [inner]) : inner
}

View file

@ -135,6 +135,7 @@
"check:i18n-keys-exist": "node plugin-designs/research-map-plus-grid/scripts/check-i18n-keys-exist.mjs",
"check:i18n-never-translated": "node internal/check-i18n-never-translated.mjs",
"check:i18n-parity": "node internal/check-i18n-parity.mjs",
"check:sentence-slots": "node internal/check-sentence-slots.mjs",
"check:i18n-stopped-scope": "node internal/check-i18n-stopped-scope.mjs",
"check:i18n-treeshake": "node internal/check-i18n-treeshake.mjs",
"check:instance-config-keys": "node internal/check-instance-config-keys.mjs",
@ -590,7 +591,8 @@
"verify:interop": "the CAP-4 INTEROPERABILITY GATE (real browser, real shadow DOM, real live data) — closes the gap verify:gate1 leaves open. Drives the ACTUAL compiled composites: (1) DESKTOP dashboard-plus-grid map->grid (a facet-rail click must narrow the grid's live `Showing N` count line — card count is a proven FALSE-NEGATIVE trap, always 24, never asserted on), (2) DESKTOP grid->map (selecting table rows must MOVE THE CAMERA, read back off window.__rdashMap.getCenter()/getZoom() — useSelectionCamera.js's contract), (3) MOBILE research-map-plus-grid, where source-reading FIRST established there is NO cross-wire between the map and grid children by design (StackLayout composes two independent elements, no provide/inject/prop crosses the boundary) — so this pair asserts each half narrows on its OWN control, not a link that does not exist. Prints ONE line per combination, pass/fail only, plus a combination table. SKIPs cleanly (exit 0) when the tree is mid-build (internal/settled-tree-gate.mjs) or pray.doxa.life is unreachable — never reports a false defect from a torn tree. Spawns its own internal/serve-strict.mjs on PORT (default 4180) so it does not depend on another process's server. run `verify:interop:self-test` for the NEGATIVE test.",
"verify:interop:self-test": "TWO regression modes, each required to fire ITS OWN combination-named failure: grid-prop-drop (default — freezes the grid child's filter props/attrs on the live dashboard-plus-grid page, reproducing the exact historical bug \"selecting anything in the dashboard above did not filter the grid below\") and `verify:interop:self-test:camera-freeze` (pins fitBounds/flyTo to no-ops on window.__rdashMap, reproducing \"the grid->map link stops moving the camera\"). A harness that has only ever reported PASS is unproven.",
"verify:interop:self-test:camera-freeze": "★ A DEDICATED ENTRY, not a mode appended at the call site. The parent script value ends in an inline # comment, so a mode appended by the caller lands INSIDE that comment and the shell drops it — the run then exits 0 in DEFAULT mode, indistinguishable from the mode being selected. A self-test is the proof a gate can fail, so a self-test whose mode cannot be chosen is a gate certifying itself. This entry hardcodes its mode, so nothing has to be appended. (Flag names are deliberately not spelled here: check:script-flags scans script VALUES, and naming one in prose reads as advertising it.)",
"check:gospel-set-fit": "the set charts FIT the canvas they are given. renderWorldCopies:false puts a hard floor under the zoom at log2(canvasWidth/512) — one world must fill the canvas WIDTH — and on a wide, short figure that floor is too close for the drawing's own height, so the outermost groups fall outside the frame (measured at 1440: 54 outside in ar, 97 in en, which is what proved it was the width and not RTL). Pure arithmetic against the components' own projection, so no browser and no drift. Negative control: the old 0.17 extentFraction still fails 796x249 at 271px of 169, so a pass cannot be vacuous. Also asserts both charts share one footprint, or the same population reads as a different size when a reader switches tabs."
"check:gospel-set-fit": "the set charts FIT the canvas they are given. renderWorldCopies:false puts a hard floor under the zoom at log2(canvasWidth/512) — one world must fill the canvas WIDTH — and on a wide, short figure that floor is too close for the drawing's own height, so the outermost groups fall outside the frame (measured at 1440: 54 outside in ar, 97 in en, which is what proved it was the width and not RTL). Pure arithmetic against the components' own projection, so no browser and no drift. Negative control: the old 0.17 extentFraction still fails 796x249 at 271px of 169, so a pass cannot be vacuous. Also asserts both charts share one footprint, or the same population reads as a different size when a reader switches tabs.",
"check:sentence-slots": "a translated SENTENCE that carries values stays ONE key, through library/composables/useSentence.js. Four properties that all fail silently: a translator can REORDER the clauses (the slots follow the message, not the call); a missing slot leaves its placeholder VISIBLE rather than rendering null or an empty gap, while 0 and '' are real values; slotted markup (a bold count, a dir=ltr island) goes in as a VALUE and is never translated around; and English renders as it did before the migration. Exists because the pattern it replaces built sentences by concatenating translated fragments around untranslated text — 'languages have', 'people.' — which in Arabic gives Arabic words in English clause order: grammatically broken, and worse than leaving it English because it looks translated. Sentinels are private-use (U+E000+n) so nothing in the i18n pipeline may rewrite them, and any sentinel already in a message is stripped before ours are placed — without that a stray swallowed a slot and the arm still passed, which is how this gate found its own weakness."
},
"devDependencies": {
"@capacitor/android": "^7.6.9",