4 KiB
i18n for a dx-maps plugin
Eight rules, each one paid for by a defect on a production map. Every example is real code.
1. One key per SENTENCE, with named placeholders
Never assemble a sentence from fragments. Word order is not universal, and the parts a language needs are not the parts English needs.
"results_live": "{count} of {total} {unit} shown" // library/i18n/locales/en/dashboard.json:64
The measured case: messages.upg100WallIntroB is "." in English — a bare full stop, because the verb
sits earlier. In German it is " beten.", in Hindi " प्रार्थना करते हुए।", in Chinese "為他們禱告。".
A head+emphasis wrapper with no tail would have silently dropped that text in three locales.
2. Counts take a LABEL form, not a plural form
There is no pluralRules layer yet. Write "Countries: {count}", not "{count} countries", so no locale
needs a plural rule the code cannot express. Where a singular/plural pair already exists, branch at the
call site and keep both keys — and keep every call site on the same rule:
{{ rowCount === 1 ? T('dashboard.group', 'group') : T('dashboard.groups', 'groups') }}
// DashboardDataTable.vue — the header count and the footer total must not disagree at one row
3. Inline markup: COMPOSE THE PARTS. Do not reach for <i18n-t>
The fleet has no <i18n-t> usage. The one place that considered it rejected it, and the reasoning
generalises: the string is often a prop rendered by a library component that has no slots.
// poster-wall.vue:214-228 — three keys (head, emphasis, tail), each escaped, one tag emitted here
const escHtml = (s) => String(s).replace(/[&<>"]/g, …)
Composing also shrinks the trusted surface: v-html on a whole catalogue string trusts every
translation of that key; composing trusts one line of your code.
4. Deliberate English gets dir="ltr", not silence
Prose that is intentionally English (a citation, a code, a fixed figure) is marked where it sits, so an RTL page does not flip it and so the gate can tell it apart from a leak.
<b dir="ltr">{{ fmt(linkMatched) }} / {{ fmt(pinRows.length) }}</b> <!-- resources-map.vue:2182 -->
check:hardcoded-english reads that marker, and inherits it down the subtree.
5. A te() guard or a proxy-t miss is a FALLBACK, not a leak
Hardcoded English behind a catalogue lookup is correct: it renders only when a key is missing, which is better than rendering blank. Do not "fix" these, and do not delete them.
function tabLabel(tab) { // research-map.vue
const key = 'tabs.' + (tab?.i18nKey || tab?.id)
return tab && te(key) ? t(key) : (tab?.label ?? '') // the literal is the last resort
}
The same shape as a Proxy whose miss path is the catalogue (people-groups-grid.vue:315,
t.search || 'Search people groups…' → tStatic('grid.search')).
6. No resolved labels at module scope, and no tStatic frozen in a computed
An array built before the locale is known freezes the first locale seen. Use getters.
{ id: 'prayer', get label() { return tStatic('dashboard.prayer') || 'Prayer' } }
// dashboard.config.js:81 — getter syntax, so the label is read per access, not per build
7. A namespaced translator hides the prefix from every scanner
useOptionalT('neighbor') means call sites pass BARE keys, so a search for 'neighbor.foo' finds
nothing and a gate that matches full dotted keys scores the file clean. If you wrap a translator, say so
where you define it — check:i18n-keys-exist reads the namespace out of the factory's one-line body.
8. Run these before you land
npm run check:hardcoded-english # full scan, ON DEMAND — gates:pre runs only its self-test
npm run check:i18n-keys-exist # every literal key named in code exists in English
check:i18n-parity and check:i18n-never-translated cover the catalogue; these two cover your code.
Neither sees a string your code never asks for, which is why rule 4 exists.