refactor: centralize i18n language registry

Issue: #62
User-Visible: no
This commit is contained in:
Matysh
2026-08-28 02:44:26 +03:00
parent 2d4ace1b58
commit 6540474ff5
12 changed files with 549 additions and 332 deletions
+31 -2
View File
@@ -9,6 +9,35 @@ User-visible changes go into **both** changelogs in the same commit:
`docs/CHANGELOG.md` (English) and `docs/CHANGELOG.ru.md` (Russian). Entries
older than v1.42.0 exist only in the English file — no need to backfill them.
## Translations
A shipped UI language has three matching parts:
1. `src/i18n/<code>.json` for the card;
2. `custom_components/houseplan/translations/<code>.json` for the Home Assistant
integration;
3. one static entry (dictionary import, code and native label) in
`src/i18n/registry.ts`.
Use the canonical Home Assistant/BCP 47 language tag as `<code>` (for example,
`fr` or `pt-BR`) and use that exact spelling for both JSON filenames. Lookup is
case-insensitive and also accepts `_` from legacy locale sources.
The registry drives language resolution, the visual-editor selector and parity
tests. The tests reject missing or extra locale files; frontend dictionaries
also fail on mismatched keys, empty values and changed placeholders.
Placeholders such as `{name}` and `{n}` are a contract: do not translate, add
or remove them.
The current `subst()` helper does not implement plural rules. Phrase strings so
their grammar does not depend on the numeric value (for example, use a neutral
label followed by `{n}` rather than an English singular/plural pair).
Adding a UI locale does not automatically create another full documentation
set; maintain the existing English and Russian documentation according to the
project's normal rules. Right-to-left layout is a separate product project,
because the plan canvas and editors cannot be mirrored by translations alone.
## Where to ask
Not sure whether something is a bug, or just want to discuss an idea before
@@ -45,8 +74,8 @@ every push — locally they are skipped when `homeassistant` is not importable.
- **Docs in the same commit**: CHANGELOG entry for user-visible changes;
`docs/STATUS.md` for state changes; `docs/DEVELOPMENT.md` for new gotchas.
- Every UI string goes through `src/i18n/<lang>.json` (tests enforce en/ru key parity).
Adding a language = adding one JSON file + registering it in `src/i18n.ts`.
- Every UI string goes through `src/i18n/<lang>.json`; follow the
[Translations](#translations) flow for registry and backend parity.
- The built card must be committed in sync: `cp dist/houseplan-card.js
custom_components/houseplan/frontend/` (CI compares them byte-for-byte).
- Tap actions have a security model (locks/alarms never toggle from the plan) —
File diff suppressed because one or more lines are too long
+140 -140
View File
File diff suppressed because one or more lines are too long
Binary file not shown.

Before

Width:  |  Height:  |  Size: 291 KiB

After

Width:  |  Height:  |  Size: 291 KiB

+12 -12
View File
@@ -2,7 +2,7 @@
"version": 1,
"fixture": "synthetic-only",
"chromium": "151.0.7922.34",
"sourceFingerprint": "6d094396ebd90fad934bace295f8eee0e6dafda27d8f38dd31beac954ff93880",
"sourceFingerprint": "63fd6b17b9c24cb2f35753e6632c7be08208a5c279978306f7c6d101a0c779d1",
"captureScriptSha256": "ce2e9542fed9dade3085be87d16f69adb2ac8262893ad78ad966b1b9673f2983",
"command": "npm run build && node demo/docs/capture.mjs",
"scenarios": {
@@ -14,7 +14,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "6d094396ebd90fad934bace295f8eee0e6dafda27d8f38dd31beac954ff93880",
"sourceSha256": "63fd6b17b9c24cb2f35753e6632c7be08208a5c279978306f7c6d101a0c779d1",
"imageSha256": "36223106c073f07d8cc3ecf8eaab37192ebb2687daba65c5c21047d0b7890de0"
},
"view-touch": {
@@ -25,7 +25,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "6d094396ebd90fad934bace295f8eee0e6dafda27d8f38dd31beac954ff93880",
"sourceSha256": "63fd6b17b9c24cb2f35753e6632c7be08208a5c279978306f7c6d101a0c779d1",
"imageSha256": "41e3ba67f8db0e98f26f484293af83ef937c369ca5ca6a59a3350d8954c906f4"
},
"space-create": {
@@ -36,7 +36,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "6d094396ebd90fad934bace295f8eee0e6dafda27d8f38dd31beac954ff93880",
"sourceSha256": "63fd6b17b9c24cb2f35753e6632c7be08208a5c279978306f7c6d101a0c779d1",
"imageSha256": "0dc024726327f60f4a9ceaf3044381691f81f1090af81d1812e870f22d9343ba"
},
"room-contour-close": {
@@ -47,7 +47,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "6d094396ebd90fad934bace295f8eee0e6dafda27d8f38dd31beac954ff93880",
"sourceSha256": "63fd6b17b9c24cb2f35753e6632c7be08208a5c279978306f7c6d101a0c779d1",
"imageSha256": "44cfaa95bd51e2cd628400e28db0ad8b2f0cd904385845bd402494f3f5c0d93c"
},
"plan-context-tray": {
@@ -58,7 +58,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "6d094396ebd90fad934bace295f8eee0e6dafda27d8f38dd31beac954ff93880",
"sourceSha256": "63fd6b17b9c24cb2f35753e6632c7be08208a5c279978306f7c6d101a0c779d1",
"imageSha256": "e0662429b423fb74151b583dcc2c8635b001b637d03bbf7a6b16aec46399c3f8"
},
"device-editor": {
@@ -69,8 +69,8 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "6d094396ebd90fad934bace295f8eee0e6dafda27d8f38dd31beac954ff93880",
"imageSha256": "827a5f0cd6f1c122a63a51d83569a2dbd87b70b3753266a3eb0a9f2986b1b911"
"sourceSha256": "63fd6b17b9c24cb2f35753e6632c7be08208a5c279978306f7c6d101a0c779d1",
"imageSha256": "d0ffd31ce80bfde21ab75da356a5fc1af38246f2b301030880320620c228d89d"
},
"device-display-preview": {
"file": "06-device-display-preview.png",
@@ -80,7 +80,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "6d094396ebd90fad934bace295f8eee0e6dafda27d8f38dd31beac954ff93880",
"sourceSha256": "63fd6b17b9c24cb2f35753e6632c7be08208a5c279978306f7c6d101a0c779d1",
"imageSha256": "2cdabae1f89c3286e4fac0ce30f757ee1690b707ab8a5488748b7cd420626160"
},
"background-editor": {
@@ -91,7 +91,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "6d094396ebd90fad934bace295f8eee0e6dafda27d8f38dd31beac954ff93880",
"sourceSha256": "63fd6b17b9c24cb2f35753e6632c7be08208a5c279978306f7c6d101a0c779d1",
"imageSha256": "30147bb00a90eea7136b4cee30995f6e6a9217b5132f3e8d3ad7471413b1af8a"
},
"room-card": {
@@ -102,7 +102,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "6d094396ebd90fad934bace295f8eee0e6dafda27d8f38dd31beac954ff93880",
"sourceSha256": "63fd6b17b9c24cb2f35753e6632c7be08208a5c279978306f7c6d101a0c779d1",
"imageSha256": "029a3e69ec647a8a370d99e6bb7f9225833c526739076022f6b52ba54bff30ea"
},
"device-info": {
@@ -113,7 +113,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "6d094396ebd90fad934bace295f8eee0e6dafda27d8f38dd31beac954ff93880",
"sourceSha256": "63fd6b17b9c24cb2f35753e6632c7be08208a5c279978306f7c6d101a0c779d1",
"imageSha256": "dd492f53150b7149085daada5cce9eeae9bde9e7ea1d86679a54b3041f72f517"
}
}
+6 -2
View File
@@ -140,7 +140,10 @@ option с этим кодом и не меняет config при редакти
Gate проверяет:
- коды уникальны, lowercase и нормализованы;
- коды являются BCP 47 tags и уникальны после case-insensitive нормализации
(`_` считается эквивалентом `-`); canonical spelling registry сохраняется в
selector и точных именах frontend/backend файлов (`pt-BR`, а не принудительно
`pt-br`);
- `en` существует и является fallback;
- у каждой записи есть непустые `nativeLabel` и dictionary;
- каждый `src/i18n/<code>.json` зарегистрирован и у каждой записи есть ровно
@@ -230,7 +233,8 @@ Schema и persisted config не меняются. Миграции нет.
### Unit
- registry uniqueness, code format, English presence/fallback;
- registry uniqueness после нормализации, BCP 47 code format, English
presence/fallback;
- exact/primary/fallback locale matrix, case and `_` normalization;
- invalid explicit config;
- registry-driven dictionary/file/backend parity;
+2 -5
View File
@@ -1,6 +1,7 @@
/** Card configuration editor (Lovelace GUI). */
import { LitElement, html, nothing } from 'lit';
import { langOf, t, type Lang } from './i18n';
import { languageOptions } from './i18n/registry';
import { invalidDefaultFloor } from './card-editor-validation';
class HouseplanCardEditor extends LitElement {
@@ -97,11 +98,7 @@ class HouseplanCardEditor extends LitElement {
selector: {
select: {
mode: 'dropdown',
options: [
{ value: '', label: t(L, 'editor.lang_auto') },
{ value: 'en', label: t(L, 'editor.lang_en') },
{ value: 'ru', label: t(L, 'editor.lang_ru') },
],
options: languageOptions(t(L, 'editor.lang_auto'), this._config?.language),
},
},
},
+23 -15
View File
@@ -1,34 +1,42 @@
/**
* Card UI localization. Dictionaries live in src/i18n/<lang>.json so that new
* languages can be contributed without touching TypeScript. The language is
* resolved from the card config (`language: en|ru`) or, by default, from the
* HA user profile (hass.locale.language); anything that is not a known
* language falls back to English.
* Card UI localization. Shipped languages live in the typed registry; see the
* translation contribution flow in CONTRIBUTING.md. The language is resolved
* from the card config or, by default, from the HA user profile. Unknown
* languages fall back to English synchronously.
*/
import { subst } from './logic';
import en from './i18n/en.json';
import ru from './i18n/ru.json';
import {
FALLBACK_DICTIONARY,
FALLBACK_LANGUAGE_CODE,
LANGUAGE_REGISTRY,
languageEntry,
resolveLanguageCode,
type Lang,
} from './i18n/registry';
export type Lang = 'en' | 'ru';
type Key = keyof typeof en;
type Key = keyof typeof FALLBACK_DICTIONARY;
const DICTS: Record<Lang, Record<string, string>> = { en, ru };
export type { Lang } from './i18n/registry';
/** Resolve the UI language: explicit config option wins, then the HA profile. */
export function langOf(hass: any, configLang?: string | null): Lang {
if (configLang && configLang in DICTS) return configLang as Lang;
const l = (hass?.locale?.language || hass?.language || 'en').toLowerCase();
return l.startsWith('ru') ? 'ru' : 'en';
return resolveLanguageCode(
configLang,
hass?.locale?.language || hass?.language,
LANGUAGE_REGISTRY.map(({ code }) => code),
FALLBACK_LANGUAGE_CODE,
);
}
/** Translate a key with optional {placeholder} substitution. */
export function t(lang: Lang, key: Key, vars?: Record<string, string | number>): string {
return subst(DICTS[lang][key] ?? en[key] ?? key, vars);
const dictionary = languageEntry(lang)?.dictionary;
return subst(dictionary?.[key] ?? FALLBACK_DICTIONARY[key] ?? key, vars);
}
/** Whether a localized value exists and contains useful text after fallback. */
export function hasTranslation(lang: Lang, key: string): boolean {
const value = DICTS[lang][key] ?? DICTS.en[key];
const value = languageEntry(lang)?.dictionary[key] ?? FALLBACK_DICTIONARY[key];
return typeof value === 'string' && value.trim().length > 0;
}
+88
View File
@@ -0,0 +1,88 @@
import en from './en.json' with { type: 'json' };
import ru from './ru.json' with { type: 'json' };
export interface LanguageEntry {
code: string;
nativeLabel: string;
dictionary: Record<string, string>;
}
/**
* The single runtime registry of shipped UI languages.
*
* Keep English first: it is the synchronous fallback. Adding a locale means
* adding its frontend/backend JSON files and one static entry in this module.
*/
export const LANGUAGE_REGISTRY = [
{ code: 'en', nativeLabel: 'English', dictionary: en },
{ code: 'ru', nativeLabel: 'Русский', dictionary: ru },
] as const satisfies readonly LanguageEntry[];
export type Lang = (typeof LANGUAGE_REGISTRY)[number]['code'];
export const FALLBACK_LANGUAGE_CODE: Lang = 'en';
export const FALLBACK_DICTIONARY = en;
const LANGUAGE_BY_CODE = new Map<string, LanguageEntry>(
LANGUAGE_REGISTRY.map((entry) => [entry.code, entry]),
);
/** Normalize HA locale tags for registry lookup. */
export function normalizeLanguageTag(value: unknown): string {
return typeof value === 'string'
? value.trim().replaceAll('_', '-').toLowerCase()
: '';
}
/** Return the canonical registry entry for a locale code, if it is shipped. */
export function languageEntry(value: unknown): LanguageEntry | undefined {
return LANGUAGE_BY_CODE.get(normalizeLanguageTag(value));
}
/**
* Resolve an explicit setting and HA locale against any registry-shaped code
* list. The generic form keeps the fallback rules testable with future-locale
* fixtures without registering a fake production dictionary.
*/
export function resolveLanguageCode<T extends string>(
explicitLanguage: unknown,
haLanguage: unknown,
supportedCodes: readonly T[],
fallback: T,
): T {
const supported = new Map<string, T>(
supportedCodes.map((code) => [normalizeLanguageTag(code), code]),
);
const explicit = supported.get(normalizeLanguageTag(explicitLanguage));
if (explicit) return explicit;
const locale = normalizeLanguageTag(haLanguage);
const exact = supported.get(locale);
if (exact) return exact;
const primary = supported.get(locale.split('-')[0] || '');
return primary ?? fallback;
}
export interface LanguageOption {
value: string;
label: string;
}
/** Build visual-editor options and preserve an unknown persisted raw value. */
export function languageOptions(
autoLabel: string,
currentLanguage?: unknown,
): LanguageOption[] {
const options: LanguageOption[] = [
{ value: '', label: autoLabel },
...LANGUAGE_REGISTRY.map(({ code, nativeLabel }) => ({
value: code,
label: nativeLabel,
})),
];
const raw = typeof currentLanguage === 'string' ? currentLanguage : '';
if (raw && !options.some((option) => option.value === raw)) {
options.push({ value: raw, label: languageEntry(raw)?.nativeLabel ?? raw });
}
return options;
}
+2 -1
View File
@@ -273,7 +273,8 @@ export interface CardConfig {
show_temperature?: boolean;
live_states?: boolean;
show_signal?: boolean;
language?: string; // 'en' | 'ru' | '' (auto — HA profile)
/** Registry language code, or empty/absent for the Home Assistant profile. */
language?: string;
/** @deprecated Ignored since v1.38.1 — per-marker `tap_action` only.
* Kept so old YAML does not break; no runtime action reads this field. */
tap_action?: string;
+104 -14
View File
@@ -1,21 +1,72 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { readdirSync, readFileSync } from 'node:fs';
const en = JSON.parse(readFileSync(new URL('../src/i18n/en.json', import.meta.url)));
const ru = JSON.parse(readFileSync(new URL('../src/i18n/ru.json', import.meta.url)));
import {
FALLBACK_LANGUAGE_CODE,
LANGUAGE_REGISTRY,
languageOptions,
normalizeLanguageTag,
resolveLanguageCode,
} from '../test-build/i18n/registry.js';
import { langOf } from '../test-build/i18n.js';
const dictionaries = new Map(
LANGUAGE_REGISTRY.map(({ code, dictionary }) => [code, dictionary]),
);
const en = dictionaries.get('en');
const ru = dictionaries.get('ru');
const cardSource = readFileSync(new URL('../src/houseplan-card.ts', import.meta.url), 'utf8');
test('i18n: en and ru dictionaries carry the same key set', () => {
test('i18n: registry codes and English fallback are valid', () => {
const codes = LANGUAGE_REGISTRY.map(({ code }) => code);
const normalizedCodes = codes.map(normalizeLanguageTag);
assert.equal(FALLBACK_LANGUAGE_CODE, 'en');
assert.ok(dictionaries.has(FALLBACK_LANGUAGE_CODE));
assert.equal(new Set(codes).size, codes.length, 'registry codes must be unique');
assert.equal(
new Set(normalizedCodes).size,
normalizedCodes.length,
'registry codes must be unique after locale normalization',
);
for (const { code, nativeLabel, dictionary } of LANGUAGE_REGISTRY) {
assert.equal(code, code.trim(), `${code} has surrounding whitespace`);
assert.doesNotMatch(code, /_/u, `${code} must use BCP 47 hyphens`);
assert.match(code, /^[A-Za-z]{2,3}(?:-[A-Za-z0-9]{2,8})*$/u);
assert.ok(nativeLabel.trim(), `${code} has no native label`);
assert.ok(dictionary && typeof dictionary === 'object', `${code} has no dictionary`);
}
});
test('i18n: registry matches frontend and backend locale files', () => {
const registryCodes = LANGUAGE_REGISTRY.map(({ code }) => code).sort();
const localeCodes = (url) => readdirSync(url)
.filter((name) => name.endsWith('.json'))
.map((name) => name.slice(0, -'.json'.length))
.sort();
assert.deepEqual(
localeCodes(new URL('../src/i18n/', import.meta.url)),
registryCodes,
'frontend locale files and registry differ',
);
assert.deepEqual(
localeCodes(new URL('../custom_components/houseplan/translations/', import.meta.url)),
registryCodes,
'backend locale files and registry differ',
);
});
test('i18n: every registered dictionary carries the English key set', () => {
const enKeys = Object.keys(en).sort();
const ruKeys = Object.keys(ru).sort();
assert.deepEqual(enKeys, ruKeys);
for (const { code, dictionary } of LANGUAGE_REGISTRY) {
assert.deepEqual(Object.keys(dictionary).sort(), enKeys, `${code} key set differs`);
}
});
test('i18n: no empty values', () => {
for (const [lang, d] of [['en', en], ['ru', ru]]) {
for (const [k, v] of Object.entries(d)) {
assert.ok(typeof v === 'string' && v.length > 0, `${lang}:${k} is empty`);
for (const { code, dictionary } of LANGUAGE_REGISTRY) {
for (const [k, v] of Object.entries(dictionary)) {
assert.ok(typeof v === 'string' && v.length > 0, `${code}:${k} is empty`);
}
}
});
@@ -23,10 +74,49 @@ test('i18n: no empty values', () => {
test('i18n: placeholders match between languages', () => {
const ph = (s) => (s.match(/\{\w+\}/g) || []).sort().join(',');
for (const k of Object.keys(en)) {
assert.equal(ph(en[k]), ph(ru[k]), `placeholder mismatch in ${k}`);
for (const { code, dictionary } of LANGUAGE_REGISTRY) {
assert.equal(ph(en[k]), ph(dictionary[k]), `placeholder mismatch in ${code}:${k}`);
}
}
});
test('i18n: resolver supports exact, primary, explicit and fallback paths', () => {
const supported = ['en', 'ru', 'pt-BR'];
const cases = [
{ explicit: 'RU', ha: 'en-US', expected: 'ru' },
{ explicit: 'pt_BR', ha: 'ru-RU', expected: 'pt-BR' },
{ explicit: 'unknown', ha: 'ru_RU', expected: 'ru' },
{ explicit: '', ha: 'pt-br', expected: 'pt-BR' },
{ explicit: null, ha: 'ru-RU', expected: 'ru' },
{ explicit: 'ru-RU', ha: 'en-GB', expected: 'en' },
{ explicit: undefined, ha: 'pt-PT', expected: 'en' },
{ explicit: undefined, ha: 'de-DE', expected: 'en' },
];
for (const { explicit, ha, expected } of cases) {
assert.equal(resolveLanguageCode(explicit, ha, supported, 'en'), expected);
}
});
test('i18n: langOf wires card config and both HA locale shapes to the registry', () => {
assert.equal(langOf({ locale: { language: 'ru_RU' }, language: 'en' }), 'ru');
assert.equal(langOf({ locale: { language: 'en-GB' }, language: 'ru' }, 'RU'), 'ru');
assert.equal(langOf({ language: 'ru-RU' }, 'unknown'), 'ru');
assert.equal(langOf({ locale: { language: 'de-DE' } }, 'unknown'), 'en');
});
test('i18n: editor options follow registry order and preserve unknown raw values', () => {
assert.deepEqual(languageOptions('Auto'), [
{ value: '', label: 'Auto' },
...LANGUAGE_REGISTRY.map(({ code, nativeLabel }) => ({ value: code, label: nativeLabel })),
]);
assert.deepEqual(languageOptions('Auto', 'ru'), languageOptions('Auto'));
assert.deepEqual(languageOptions('Auto', 'de').at(-1), { value: 'de', label: 'de' });
assert.deepEqual(
languageOptions('Auto', ' RU ').at(-1),
{ value: ' RU ', label: 'Русский' },
);
});
test('issue 251 unavailable controls toast has exact singular and plural copy', () => {
assert.equal(
en['toast.toggle_target_unavailable'],
@@ -139,9 +229,9 @@ test('i18n: every literal help call has body and full aria keys in both language
assert.equal(helpKeys.length, allCalls.length, 'every _help call must use one string literal ending in .help');
assert.ok(helpKeys.length > 0, 'the help affordance pilot disappeared');
for (const key of helpKeys) {
for (const [lang, dictionary] of [['en', en], ['ru', ru]]) {
assert.ok(dictionary[key]?.trim(), `${lang}:${key} is missing or empty`);
assert.ok(dictionary[`${key}.aria`]?.trim(), `${lang}:${key}.aria is missing or empty`);
for (const { code, dictionary } of LANGUAGE_REGISTRY) {
assert.ok(dictionary[key]?.trim(), `${code}:${key} is missing or empty`);
assert.ok(dictionary[`${key}.aria`]?.trim(), `${code}:${key}.aria is missing or empty`);
}
}
});
@@ -171,7 +261,7 @@ test('help hosts do not duplicate their explanation in a title attribute', () =>
});
test('vac toasts never mention the removed point calibration (HP-1540-06)', () => {
for (const [lang, d] of [['en', en], ['ru', ru]]) {
for (const { code: lang, dictionary: d } of LANGUAGE_REGISTRY) {
for (const k of ['vac.autocal_no_rooms', 'vac.autocal_no_match', 'vac.residual_message']) {
assert.ok(!/point|точк/i.test(d[k]), `${lang}:${k} still points at point calibration`);
}
+1 -1
View File
@@ -10,7 +10,7 @@
"tsBuildInfoFile": "test-build/.tsbuildinfo"
},
"include": [
"src/color.ts", "src/styles.ts", "src/styles/*.styles.ts", "src/logic.ts", "src/glow-blend.ts", "src/grid-scale.ts", "src/device-visual.ts", "src/device-pulse.ts", "src/device-presentation.ts", "src/device-marker-geometry.ts",
"src/color.ts", "src/styles.ts", "src/styles/*.styles.ts", "src/logic.ts", "src/i18n.ts", "src/i18n/registry.ts", "src/glow-blend.ts", "src/grid-scale.ts", "src/device-visual.ts", "src/device-pulse.ts", "src/device-presentation.ts", "src/device-marker-geometry.ts",
"src/device-face.ts", "src/device-toggle.ts", "src/marker-toggle-entity.ts", "src/activity-runtime.ts",
"src/ha-binding-status.ts",
"src/integration-provider.ts", "src/vacuum.ts",