i18n reference for plugin authors: eight rules, each one paid for by a production defect

skills/plugin-designer/references/i18n.md, beside backend/glossary/targets/workflow. Every example is
real code with a file and line, and every claim was checked against main rather than written from
memory — a reference that is wrong costs more than no reference.

★ ONE CORRECTION TO THE BRIEF, and it matters for whoever reads this next. The ask named poster-wall.vue
as the fleet's `<i18n-t>` example. It is the opposite: that file's comment explains why `<i18n-t>` could
NOT be used there — the string is a prop rendered by a library component with no slots — and there is no
`<i18n-t>` usage anywhere in the tree. So the rule is written as "compose the parts, do not reach for
i18n-t", which is what the code actually does and what a plugin author can actually follow. Telling them
to use a component the codebase has never used would have sent them down a path with no example to copy.

The rules: one key per SENTENCE with named placeholders (the Hindi tail case, where "." in English is
" प्रार्थना करते हुए।" in Hindi and a wrapper would have dropped it); label form for counts while there is
no pluralRules layer; compose markup from escaped parts; dir="ltr" for deliberate English so the gate can
tell it from a leak; te()/proxy-t fallbacks are NOT leaks and must not be "fixed"; no resolved labels at
module scope (getters, or the first locale freezes); a namespaced translator hides the prefix from every
scanner; and the two gates that cover your code rather than the catalogue.

Rule 8 carries the precision dx-shepherd gave me today: check:hardcoded-english is ON DEMAND, and
gates:pre runs only its self-test — so an author who assumes the suite covers it will land leaks.

skills/ is shared, so this is a source sha for studio rather than something I land.
This commit is contained in:
Doxa Maps 2026-09-28 13:05:51 +00:00
parent dbbcd5bc45
commit f99681a9d9

View file

@ -0,0 +1,91 @@
# 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.
```json
"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:
```js
{{ 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.
```js
// 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.
```html
<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.
```js
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.
```js
{ 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.