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):
overrideβ the in-card language control's per-user choice (the globe).config.i18n.localeβ a per-dashboard author pin.hass.locale.language/hass.languageβ the HA system language."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, callthis.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()readsvars.countand selects the form via the language's nativeIntl.PluralRulesβ no per-language logic in the card; a locale supplies whatever its language needs (Russianone/few/many/other, β¦). - Authored markup: key the text run, keep the markup in the template where
you can; only reach for
this.tRawfor 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 isvaluelowercased 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.tVocabRawis the raw twin, for the few sinks that escape downstream (e.g. a value dropped into a data object the rendererescapeHtmls later β usingtVocabthere 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, thefault.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), orfaults.unknownwhen 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) callsfaultLabeldirectly per fault, alongsidesource/recoveredfields the label alone doesn't carry. A sibling helper,faultRows(errors)(state/faults.js:56, alongsidefaultLabelitself β 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; onlyfaultLabelis 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):
- The shipped non-English locales (first-party, from the served frontend
dir) β no status, keep their bundled review status (e.g.
draft). - The user drop-ins (
config/eufy_vacuum/locales/, taggedstatus:"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
customlocale is untrusted and its values feed ~60tRawinnerHTMLsinks. The gate scrubs or rejects each file beforeregisterLocale, 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 theinnerHTMLsink uses. That is what defeats encoded/padded evasion (java	script:collapses to its scheme vianew URL();on\nerrorresolves to its true attribute name in the DOM). Active content = a dangerous tag (script/iframe/object/embed/link/meta/base/form), anon*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<span>so a translator sees their mistake (silent stripping hides it). The allowliststrong/em/code/asurvives; 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.removedlogs theBODYwrapper (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 β exercisestranslate(): 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("β¦")insrc/must exist inen.js(an orphan renders a raw key β FATAL); every defined key must be reachable from source, one of three provable forms: (1) a literalt("β¦")/tRaw("β¦")call, (2) the full key appearing as a quoted string anywhere insrc/(a data value handed tot()through a variable), or (3) at(\β¦${β¦}β¦`)**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
β enfallback 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.jsonis the reviewed snapshot (accepted: key β locale list or"*";pending: provisionally tolerated but listed so entries can't rot invisibly β 178acceptedkeys and 2pendingtoday:vocab.obstacle_type.cable(es) andvocab.obstacle_type.pedestal(es,pt). β The file's own_metastill asserts "pending is now empty" under a dated 2026-08-04 note β the data file misdescribes itself, so read thependingobject, 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.