mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-05 22:29:05 +00:00
The production LANGUAGE_RUNTIME was a handwritten twin of the tested LanguageRuntime class (germanDictionary/Pending/Failed): equivalent on the day it was written, invisible to every i18n-runtime test afterwards. The registry now exports one page-scoped `new LanguageRuntime(LANGUAGE_REGISTRY, …)` instance — the whole existing suite starts proving the object production actually runs, and a contract unit (instanceof + source free of the old field names) keeps the duplicate from returning. The class gains an optional `loadFailed(code)` hook — fired once when a dictionary load settles into English fallback — and the registry fans it out through `subscribeLanguageLoadFailures`. Only the View card subscribes (it alone owns toast infrastructure): a failed language pack now shows the new `toast.locale_load_failed` message (en/ru/de) instead of a console-only warning; space card and both GUI editors keep the console warning as before. Proofs: contract unit, hook unit, subscription unit; smoke_german_locale extended — the both-attempts-failed scenario now asserts the visible toast; two new registry mutants (handwritten-twin returns, toast dropped). Issue: #354 User-Visible: yes
140 lines
4.6 KiB
TypeScript
140 lines
4.6 KiB
TypeScript
import { html, type TemplateResult } from 'lit';
|
|
|
|
export type LocaleDictionary = Record<string, string>;
|
|
|
|
export interface LazyLanguageModule {
|
|
dictionary: LocaleDictionary;
|
|
fingerprint: string;
|
|
}
|
|
|
|
export interface RuntimeLanguageEntry {
|
|
code: string;
|
|
dictionary?: LocaleDictionary;
|
|
loadDictionary?: (attempt: 0 | 1) => Promise<LazyLanguageModule>;
|
|
}
|
|
|
|
export type LanguageLoadState = 'ready' | 'pending' | 'fallback';
|
|
|
|
export interface LanguageRuntimeContract {
|
|
state(code: string): LanguageLoadState;
|
|
dictionary(code: string): LocaleDictionary | undefined;
|
|
ensure(code: string): Promise<void>;
|
|
}
|
|
|
|
/**
|
|
* Page-scoped dictionary cache shared by all House Plan cards and editors.
|
|
*
|
|
* `fallback` is a settled state: a failed optional locale must never leave a
|
|
* card behind an endless loading surface. The synchronous translation helper
|
|
* then uses English in the ordinary way.
|
|
*/
|
|
export class LanguageRuntime {
|
|
private readonly dictionaries = new Map<string, LocaleDictionary>();
|
|
private readonly pending = new Map<string, Promise<void>>();
|
|
private readonly failed = new Set<string>();
|
|
|
|
public constructor(
|
|
private readonly entries: readonly RuntimeLanguageEntry[],
|
|
private readonly expectedFingerprint: string,
|
|
private readonly warn: (message: string, error: unknown) => void = console.warn,
|
|
/** Optional #354 hook: a dictionary load settled into English fallback. */
|
|
private readonly loadFailed?: (code: string) => void,
|
|
) {
|
|
for (const entry of entries) {
|
|
if (entry.dictionary) this.dictionaries.set(entry.code, entry.dictionary);
|
|
}
|
|
}
|
|
|
|
public state(code: string): LanguageLoadState {
|
|
if (this.dictionaries.has(code)) return 'ready';
|
|
if (this.failed.has(code)) return 'fallback';
|
|
return 'pending';
|
|
}
|
|
|
|
public dictionary(code: string): LocaleDictionary | undefined {
|
|
return this.dictionaries.get(code);
|
|
}
|
|
|
|
public ensure(code: string): Promise<void> {
|
|
if (this.state(code) !== 'pending') return Promise.resolve();
|
|
const existing = this.pending.get(code);
|
|
if (existing) return existing;
|
|
const entry = this.entries.find((candidate) => candidate.code === code);
|
|
const task = this.load(entry).finally(() => this.pending.delete(code));
|
|
this.pending.set(code, task);
|
|
return task;
|
|
}
|
|
|
|
private async load(entry: RuntimeLanguageEntry | undefined): Promise<void> {
|
|
if (!entry?.loadDictionary) {
|
|
if (entry) this.failed.add(entry.code);
|
|
return;
|
|
}
|
|
let lastError: unknown;
|
|
for (const attempt of [0, 1] as const) {
|
|
try {
|
|
const loaded = await entry.loadDictionary(attempt);
|
|
if (loaded.fingerprint !== this.expectedFingerprint) {
|
|
throw new Error(`locale fingerprint mismatch for ${entry.code}`);
|
|
}
|
|
this.dictionaries.set(entry.code, loaded.dictionary);
|
|
this.failed.delete(entry.code);
|
|
return;
|
|
} catch (error) {
|
|
lastError = error;
|
|
}
|
|
}
|
|
this.failed.add(entry.code);
|
|
this.warn(`[houseplan] unable to load ${entry.code} locale; using English`, lastError);
|
|
this.loadFailed?.(entry.code);
|
|
}
|
|
}
|
|
|
|
interface LanguageHostElement {
|
|
inert: boolean;
|
|
isConnected: boolean;
|
|
requestUpdate(): void;
|
|
setAttribute(name: string, value: string): void;
|
|
removeAttribute(name: string): void;
|
|
}
|
|
|
|
const committedHosts = new WeakSet<LanguageHostElement>();
|
|
const pendingHosts = new WeakSet<LanguageHostElement>();
|
|
|
|
/** Lightweight production render gate; generic controller below remains testable in isolation. */
|
|
export function languageRenderGate(
|
|
host: LanguageHostElement,
|
|
runtime: LanguageRuntimeContract,
|
|
code: string | null,
|
|
): LanguageRenderGate {
|
|
const state = code ? runtime.state(code) : 'ready';
|
|
if (!code || state !== 'pending') {
|
|
if (pendingHosts.delete(host)) {
|
|
host.inert = false;
|
|
host.removeAttribute('aria-busy');
|
|
}
|
|
if (code) {
|
|
host.setAttribute('lang', state === 'fallback' ? 'en' : code);
|
|
committedHosts.add(host);
|
|
}
|
|
return 'ready';
|
|
}
|
|
if (!pendingHosts.has(host)) {
|
|
host.inert = true;
|
|
host.setAttribute('aria-busy', 'true');
|
|
pendingHosts.add(host);
|
|
}
|
|
void runtime.ensure(code).then(() => {
|
|
if (host.isConnected) host.requestUpdate();
|
|
});
|
|
return committedHosts.has(host) ? 'warm' : 'cold';
|
|
}
|
|
|
|
export type LanguageRenderGate = 'ready' | 'cold' | 'warm';
|
|
|
|
/** Keep a root Lit surface inert and visually stable while its lazy locale loads. */
|
|
/** A language-neutral first frame: no fallback-language copy is allowed to flash. */
|
|
export function languageLoadingTemplate(): TemplateResult {
|
|
return html`<ha-circular-progress active role="status" aria-busy="true"></ha-circular-progress>`;
|
|
}
|