doxa-maps/skills/plugin-designer/references/i18n.md
Doxa Maps 140d38e0b2
Some checks are pending
Deploy to GitHub Pages / build (push) Waiting to run
Deploy to GitHub Pages / deploy (push) Blocked by required conditions
Update from source b5178381d
2026-09-28 14:15:16 +00:00

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.