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,100 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
βββ guide-translations.js generated upkeep-guide content (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.
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 β the card always gets a code its vocab is
keyed on. (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.
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.
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-translations.js (generated by
scripts/sync-guide-translations.py from the same Python source of truth) carries
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 ~13tRawinnerHTMLsinks. 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:
- Contract β exercises
translate(): the fallback chain, interpolation, plural selection, and Trust-Model-B adversarially (a<script>catalog value must come back escaped) β the real security assertion the visual harness can't make. - Keys β every literal
t("β¦")/tRaw("β¦")insrc/must exist inen.js(an orphan renders a raw key β FATAL); every defined key must be reachable from source (a literal call, a quoted key-as-data-value, or at(\β¦${β¦}β¦`)template β this is whytVocab` inlines its template). Source-derived, no allowlist. - Locale validation β each shipped locale validated against the manifest
(placeholder parity, plural forms, no unsafe keys), reporting its
β enfallback count.
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.