Skip to content

i18n system

Home Assistant localizes an integration's config flow and entity names, but it gives a custom Lovelace card nothing for its own markup β€” every literal in the card's renderers is English regardless of the user's HA language. This subsystem is the seam that closes that gap: ~2,500 UI strings, plural-correct in any language, switchable per-user, with community translations loadable at runtime behind a security gate.

Source lives at src/i18n/ (the card is a build artifact β€” edit src/, run npm run build:deploy; never hand-edit the bundle).

Two audiences:

  • Card developers adding or touching UI strings β€” read Authoring strings.
  • Translators / contributors adding a language β€” start with the how-to, Contributing a translation; this page is the mechanism underneath it.

Files

src/i18n/
β”œβ”€β”€ en.js                 English base β€” the source of truth + the key manifest
β”œβ”€β”€ index.js              translate() / resolveLang() / registerLocale / loadLocale
β”œβ”€β”€ flatten.js            nested authoring JSON β†’ flat catalog (+ commons/plurals)
β”œβ”€β”€ sanitize-locale.js    the intake GATE (sanitize-or-quarantine untrusted drop-ins)
β”œβ”€β”€ lang-store.js         per-user language choice, persisted via HA frontend user-data
β”œβ”€β”€ font-store.js         per-user typeface choice (`ui_font`), same user-data object
└── guide-keys.js          generated upkeep-guide KEY pack (see "Guide on the card")

The non-English locales are not bundled β€” they ship as nested JSON served assets (custom_components/eufy_vacuum/frontend/locales/<code>.json) and load + flatten at runtime. English (en.js) is the only bundled catalog: it is both the universal fallback and the complete key manifest everything else is validated against.

translate() and resolution

A renderer calls this.t("rooms.empty") (defined in renderers/shared.js); it resolves the user's language and delegates to translate(lang, key, vars, options) in index.js.

Resolution order, most-explicit first (resolveLang):

  1. override β€” the in-card language control's per-user choice (the globe).
  2. config.i18n.locale β€” a per-dashboard author pin.
  3. hass.locale.language / hass.language β€” the HA system language.
  4. "en".

A missing key renders the key itself (rooms.empty), never a blank β€” a visible miss in dev.

Review status and auto-activation

Separate from the intake-gate outcomes (which judge a file's safety, not its review state), every locale carries a review status (LOCALE_STATUS in index.js) that controls whether it may auto-activate from the HA system language:

status meaning auto-activates at step 2?
stable native-reviewed (English is always stable) yes
draft AI-generated, not yet native-reviewed no
custom a runtime drop-in (config/eufy_vacuum/locales/) no
unknown no status on record no

The rule the gate enforces (isDraftLocale in resolveLang): step 2 (the HA system language) activates a locale only if its status is stable. A draft or custom locale falls through to English at step 2 and is reachable only by a deliberate choice β€” the globe override or the dashboard pin (steps 0–1), which bypass the gate. Promotion draft β†’ stable after native review is a one-line LOCALE_STATUS change, and it is the single switch that lets a language follow the system language.

Consequence today: all shipped non-English locales are draft, so Auto (follow-the-system-language) resolves to English for everyone until a locale is promoted β€” by design (an unreviewed translation never activates silently), but the user-facing language page makes that visible to the user rather than leaving Auto looking broken.

Trust Model B

Locales may be community-contributed, so translate() HTML-escapes the catalog string by default β€” a contributed value carrying <script> can never reach an innerHTML sink raw. The short, audited set of first-party strings that carry authored markup (<strong>, <code>, …) opt out via options.raw (exposed as this.tRaw). Interpolated {name} values are inserted raw β€” the caller escapes user data at the sink, exactly as the original literal did. In an RTL locale each substituted run is additionally wrapped in Unicode bidi isolates (FSI U+2068 … PDI U+2069) so an embedded LTR token β€” a number, %, mΒ², an entity_id, a duration like "14 min" β€” keeps its own direction; LTR locales are left byte-identical. Invisible controls are used rather than a literal <bdi> tag because .t() output also lands in plain-text sinks β€” the textContent writes in bindings/theme.js / bindings/map.js, and the ~200 HTML attribute values (title= / aria-label= / placeholder=) β€” where a tag would render as visible text rather than as markup.

This escape is one of two independent layers. The other is the intake gate, which scrubs a contributed locale before it is registered. Two layers is the correct posture for an untrusted path: even if the gate is bypassed, the per-render escape still neutralizes a plain string β€” and the gate exists because tRaw values are emitted raw, where the escape does not run.

Authoring strings

  • Plain text: add the key to en.js, call this.t("ns.key").
  • Interpolation: this.t("rooms.n_selected", { count: n }) against "{count} selected". Escape user data in the var: { name: this.escapeHtml(x) }.
  • Plurals: the value is an object of CLDR forms ({ one, other }; English ships those two). translate() reads vars.count and selects the form via the language's native Intl.PluralRules β€” no per-language logic in the card; a locale supplies whatever its language needs (Russian one/few/many/other, …).
  • Authored markup: key the text run, keep the markup in the template where you can; only reach for this.tRaw for the audited markup allowlist.

tVocab β€” backend vocabulary values

The integration hands the card stable vocabulary values (a fan-speed "max", a clean-mode "vacuum_mop", a run "status", a theme token key, a maintenance component) as English labels. this.tVocab(field, value, fallback) localizes them:

  • Keys on vocab.<field>.<slug> where the slug is value lowercased with non-alphanumerics collapsed to _.
  • Falls back to the backend label for any value not keyed β€” so a new brand / model / value renders its English label unchanged (never a raw key).
  • Returns an HTML-escaped string. this.tVocabRaw is the raw twin, for the few sinks that escape downstream (e.g. a value dropped into a data object the renderer escapeHtmls later β€” using tVocab there would double-escape).

For room-setting values (clean_mode / clean_intensity / fan_speed / water_level) the slug-derives-the-key contract only lands if the value the card receives is already the canonical code. Stored settings are un-normalized display strings ("Vacuum and mop", "Standard", "BoostIQ"), whose slug (vacuum_and_mop / standard / boostiq) would miss the canonical key (vacuum_mop / boost) and silently fall back to English. So the backend normalizes them first: each brand's adapter declares display→canonical alias maps (adapter_config.vocabulary.{clean_mode,clean_intensity,fan_speed,water_level}_aliases), and learning/manager.py::_normalize_profile_setting canonicalizes every observed room-profile setting before emitting — so on that surface the card gets a code its vocab is keyed on. Two mechanisms hold this property, not one: the normalizer covers the Learning Review's observed settings, while the surfaces that render a room's stored value untouched (the rooms-view per-room chips, the standalone room card, the external-run summary) are covered by display-slug alias keys — every locale ships vocab.clean_mode.vacuum_and_mop alongside vocab.clean_mode.vacuum_mop, so the label the card actually stored localizes rather than falling back to English. Add a display spelling to an adapter's alias map and you must add the matching alias key, or that surface silently reverts to English for every language. (Reason-code surfaces follow the same contract: the Learning Review badges + per-job notes tVocabRaw the stable backend code — vocab.reason_code.*, vocab.exclude_suggested_reason.* — with the English text as the per-code fallback.)

The template literal must be inline in the t() call (this.t(\vocab.${field}.${slug}`)) so the [reachability check](#checki18n) sees everyvocab.*key β€” athis.t(varKey)would read as a dead key. Standalone components that don't use the renderers prototype (the room-card classes) carry their owntVocab` method using the same pattern.

faultLabel β€” brand fault codes

Hardware faults follow the same backend-hands-a-key contract, with their own seam. The backend never sends fault text: the adapter maps a vendor's numeric code to an i18n key (adapters/eufy/vocabulary.py EUFY_ERROR_LABEL_KEYS, Roborock likewise), and the card resolves it at render time via this.faultLabel(key, code) (src/state/faults.js, mixed onto the state by applyFaultState):

  • Keys on fault.<brand>.<slug> β€” the English base carries the per-brand tables (en.js, the fault.eufy.* / fault.roborock.* blocks), so locales translate fault labels like any other key. 237 keys total (189 Eufy + 48 Roborock).
  • The resolution is one inline template β€” this.t(\fault.${brand}.${slug}`)(faults.js::faultLabel) β€” which is what proves all 237fault.*` keys reachable to check:i18n instead of reporting them dead; that single-template property is a stated design reason for the seam existing at all.
  • Fallback is the raw code, deliberately. A key with no entry (a brand that declares nothing, or a code the vendor shipped after the table was written) falls to faults.unknown_code ("Error 6013" β€” honest and searchable), or faults.unknown when there is no code either. A translator echoing the key back (label === key) is treated as no-entry, so a dotted key is never printed at the user.
  • The Job Summary modal's fault list (renderers/job-summary.js _renderJobSummaryFaults) calls faultLabel directly per fault, alongside source/recovered fields the label alone doesn't carry. A sibling helper, faultRows(errors) (state/faults.js:56, alongside faultLabel itself β€” not in the renderer), maps a run's captured error list to a reduced {index, code, capturedAt, label} shape β€” but it has no production caller today; only faultLabel is wired into a renderer.

The language control

The header globe lets a user pick a language for their view, independent of the HA system language and of other users. The choice is persisted with HA's frontend user-data API (frontend/get_user_data / set_user_data, lang-store.js) β€” per-user, cross-device, server-stored. It is the most explicit source in resolveLang and bypasses the draft-gate (a deliberate opt-in).

Because this lives in frontend user-data, the backend cannot read it β€” a constraint that drives the guide-on-card design.

The same eufy_vacuum_card user-data object also carries the per-user typeface choice (ui_font, font-store.js) β€” both writers read-then-merge the stored object rather than clobber it, so the two preferences coexist. The typeface chain itself is styles-system Β§4; the font offering is language-gated (fontSupportsLang, font-store.js) β€” a font is offered for a locale only after its glyph coverage has been verified against that locale's shipped catalogue, not assumed from "looks Latin" β€” a cmap inspection of the shipped OpenDyslexic-Regular/Bold found 1586 codepoints including Latin Extended and Cyrillic, which is why cs/pl/tr/ru are in the verified set; Hebrew and CJK are genuinely absent from the cmap and Arabic additionally needs shaping. It ships one entry β€” opendyslexic, verified for 12 of the 18 shipped locales (cs, de, en, es, fr, id, it, nl, pl, pt, ru, tr).

Locales: bundled, shipped, and drop-in

loadDroppedLocales(baseUrl) discovers <code>.json files from a served index.json and loads each via loadLocale (fetch β†’ flattenLocale β†’ validateLocale β†’ registerLocale). It runs twice (ensureLocalesLoaded):

  1. The shipped non-English locales (first-party, from the served frontend dir) β€” no status, keep their bundled review status (e.g. draft).
  2. The user drop-ins (config/eufy_vacuum/locales/, tagged status:"custom") β€” gated as untrusted (below) and draft-gated like any unreviewed locale.

en.json is refused (the base is not overridable). validateLocale drops bad shapes / unsafe keys (__proto__) / placeholder-parity violations, so the English fallback is always intact. flattenLocale lets locales be authored nested (commons + scoped sections) and flattens them against the English manifest into the flat key β†’ string | plural-object catalog translate() expects.

Guide on the card

Maintenance guide content (cleaning steps, notes, frequencies) used to follow hass.config.language β€” the HA instance language β€” because it is built in a shared backend snapshot with no per-user context. That diverged from the ~95% of the card driven by the per-user globe (a confusing "two switches"). Since the per-user language is frontend-only the backend can't reach it, so the guide moved onto the card: guide-keys.js (generated by scripts/sync-dreame-guide-keys.py from the authoring fixture) carries the 42 English guide sentences; the other 17 languages are served as frontend/guides/keys/<lang>.json.

⚠ THE PER-FAMILY PROSE CHANNEL IS GONE (2026-09-12). guide-translations.js and its 17 served frontend/guides/<lang>.json packs held ~2.4 MB of vendor guide text keyed by device FAMILY. All three adapters ship i18n KEYS now, so the last producer was deleted with the Roborock port and nothing could fill the catalogs. The card's family-routing branch went with them. the English base + the official-manual translations, and maintenance.js _localizedGuide() overlays steps/notes/frequency by the resolved per-user language (per-field β†’ English β†’ the backend value). One switch.

The intake gate

Security boundary. A user-dropped custom locale is untrusted and its values feed ~60 tRaw innerHTML sinks. The gate scrubs or rejects each file before registerLocale, so by translate-time provenance no longer matters.

sanitizeOrQuarantineLocale(catalog) in sanitize-locale.js returns one of three bright-line outcomes:

outcome trigger action
REJECT_MALFORMED not a plain object of string / plural-of-string values soft skip, not hash-locked (retried next reload)
QUARANTINE_HOSTILE any value carries active content reject the whole file (a tamper signature taints the shared-source siblings); hash-locked
LOAD clean, or only inert-disallowed markup that was scrubbed register the cleaned catalog

It walks every value, including each plural-object form, and:

  • Detects by PARSING, not regex β€” a real-browser <template> walk, the same parser the innerHTML sink uses. That is what defeats encoded/padded evasion (java&#9;script: collapses to its scheme via new URL(); on\nerror resolves to its true attribute name in the DOM). Active content = a dangerous tag (script/iframe/object/embed/link/meta/base/form), an on* handler, or a non-http(s) URL scheme (including scheme-relative //host).
  • Scrubs inert junk to escaped-visible text β€” a disallowed <span> renders as literal &lt;span&gt; so a translator sees their mistake (silent stripping hides it). The allowlist strong/em/code/a survives; an <a href> off the host allowlist (github.com / kingchddg901.github.io) keeps its text, drops the href.
  • Hardens the scrubbed output through DOMPurify as a final, independent pass β€” the string that ultimately reaches the sink is one DOMPurify certifies.

The gate runs only on status:"custom" drop-ins (shipped locales are vetted at build time) and only in a browser (typeof document β€” the sink only exists there; Node/SSR has nothing to defend). It fails closed (a throw β†’ no register). loadLocale hashes the raw bytes (FNV-1a β€” a dedup key, not a crypto primitive): a hostile file is hash-locked and skipped silently on re-load, while a fixed file gets a new hash and is re-evaluated fresh β€” no rebuild, no manual step. getLocaleQuarantineReport() exposes the record for diagnostics.

Why DOMPurify is the final pass, not the detection engine: DOMPurify.removed logs the BODY wrapper (not the forbidden tag) when the whole value is stripped, so it's unreliable for detection. The <template> walk + escape-visible scrub are self-sufficient and adversarially tested; DOMPurify is kept as defense-in-depth over them.

check:i18n

npm run check:i18n (scripts/check-i18n.mjs, framework-free Node) is the contract gate, run after every wave. Four sections:

  • A. translate() contract β€” exercises translate(): the fallback chain, interpolation, plural selection, resolveLang, validateLocale, loadLocale/loadDroppedLocales, and Trust-Model-B adversarially (a <script> catalog value must come back escaped) β€” the real security assertion the visual harness can't make.
  • B. Key cross-check β€” every literal t("…")/tRaw("…") in src/ must exist in en.js (an orphan renders a raw key β€” FATAL); every defined key must be reachable from source, one of three provable forms: (1) a literal t("…")/tRaw("…") call, (2) the full key appearing as a quoted string anywhere in src/ (a data value handed to t() through a variable), or (3) a t(\…${…}…`)**template**, each${…}segment matched generically β€” this is whytVocabandfaultLabelinline their templates. Source-derived, no allowlist; a template whose leading literal segment has no.is rejected, so a pathologically dynamic${a}.${b}` can never silently exempt the whole catalog. A key defined but never reachable is reported as a dead key (warning, not a failure).
  • C. Shipped locale validation β€” each served locale JSON is flattened against the English manifest and validated (placeholder parity, plural forms, no unsafe keys), reporting its β†’ en fallback count. A broken committed translation fails the build, not the user's render.
  • D. English-identical ratchet β€” a locale value byte-identical to its English value is either legitimately universal (cognate, unit, symbol, product term) or untranslated leakage, and telling them apart needs a human exactly once. scripts/i18n-accepted-english.json is the reviewed snapshot (accepted: key β†’ locale list or "*"; pending: provisionally tolerated but listed so entries can't rot invisibly β€” 178 accepted keys and 2 pending today: vocab.obstacle_type.cable (es) and vocab.obstacle_type.pedestal (es, pt). ⚠ The file's own _meta still asserts "pending is now empty" under a dated 2026-08-04 note β€” the data file misdescribes itself, so read the pending object, never the prose beside it). Only NEW English-identical values not in the snapshot are flagged; comparison is language-blind by design (no dictionaries; plural objects compared by key-sorted serialization).

--strict-coverage is release-gate mode: NEW English-identical values and any untranslated key become failures instead of a printed list (the "@100%" claim is an enforced invariant at release time). A plain check:i18n run stays permissive β€” an en-first mid-wave tree is a legitimate state.

The intake gate has its own real-Chromium adversarial suite (scripts/sanitize-locale.test.mjs) β€” jsdom would test a different parser than the runtime sink, exactly where mutation-XSS hides, so it bundles the real index.js and runs loadLocale end-to-end in the browser. See Testing.