feat: add complete German localization

Add Deutsch across all card surfaces and backend flows, backed by the language registry introduced in #62. German loads as a fingerprint-checked page-shared locale chunk so EN/RU remain synchronous and the initial View budget stays intact. Root render gates prevent mixed-language flashes, retry once, and fail open to English. Extend parity, runtime, bundle, browser and visual coverage, plus contributor and user documentation.

Issue: #348
User-Visible: yes
This commit is contained in:
Matysh
2026-08-28 11:58:46 +03:00
parent 7a0702adea
commit 12cd8a1af3
50 changed files with 2265 additions and 524 deletions
+12
View File
@@ -12,9 +12,19 @@ export function assertBundleBudget(manifest, budget = INITIAL_VIEW_GZIP_BUDGET)
if (!manifest.lazyEditorFiles?.length) {
throw new Error('bundle has no lazy editor graph');
}
if (!manifest.lazyLocaleFiles?.length) {
throw new Error('bundle has no lazy locale graph');
}
if (manifest.initialViewFiles.some((path) => manifest.lazyEditorFiles.includes(path))) {
throw new Error('initial View graph overlaps lazy editor graph');
}
if (manifest.initialViewFiles.some((path) => manifest.lazyLocaleFiles.includes(path))) {
throw new Error('initial View graph overlaps lazy locale graph');
}
if (manifest.lazyLocaleFiles.some((path) => manifest.lazyEditorFiles.includes(path)
|| manifest.lazyOnboardingFiles?.includes(path))) {
throw new Error('lazy locale graph overlaps an editor graph');
}
if (manifest.initialViewGzipBytes > budget) {
throw new Error(
`initial View graph ${manifest.initialViewGzipBytes} B gzip exceeds ${budget} B budget`,
@@ -23,6 +33,7 @@ export function assertBundleBudget(manifest, budget = INITIAL_VIEW_GZIP_BUDGET)
return {
initialViewGzipBytes: manifest.initialViewGzipBytes,
lazyEditorGzipBytes: manifest.lazyEditorGzipBytes,
lazyLocaleGzipBytes: manifest.lazyLocaleGzipBytes,
};
}
@@ -32,6 +43,7 @@ if (import.meta.url === pathToFileURL(process.argv[1] || '').href) {
const result = assertBundleBudget(manifest);
console.log(`initial View: ${result.initialViewGzipBytes} B gzip`);
console.log(`lazy editor: ${result.lazyEditorGzipBytes} B gzip`);
console.log(`lazy locale: ${result.lazyLocaleGzipBytes} B gzip`);
} catch (error) {
console.error(error instanceof Error ? error.message : String(error));
process.exit(1);
+33 -5
View File
@@ -6,6 +6,7 @@ import { gzipSync } from 'node:zlib';
const BUILD_FINGERPRINT_TOKEN = '__HOUSEPLAN_SOURCE_FINGERPRINT__';
const EDITOR_RETRY_ASSET_TOKEN = '__HOUSEPLAN_EDITOR_RETRY_ASSET__';
const ONBOARDING_RETRY_ASSET_TOKEN = '__HOUSEPLAN_ONBOARDING_RETRY_ASSET__';
const DE_RETRY_ASSET_TOKEN = '__HOUSEPLAN_DE_RETRY_ASSET__';
const sha256 = (value) => createHash('sha256').update(value).digest('hex');
@@ -27,6 +28,14 @@ export function buildBundleManifest(bundle, fingerprint) {
.filter((item) => item.type === 'chunk')
.map((chunk) => {
const contents = Buffer.from(chunk.code, 'utf8');
const modules = Object.keys(chunk.modules || {}).map((id) => id.replaceAll('\\', '/'));
const role = modules.some((id) => id.endsWith('/src/i18n/de.ts'))
? 'locale'
: modules.some((id) => id.endsWith('/src/houseplan-onboarding-runtime.ts'))
? 'onboarding'
: modules.some((id) => id.endsWith('/src/houseplan-editor-runtime.ts'))
? 'editor'
: undefined;
return {
path: chunk.fileName.replaceAll('\\', '/'),
sha256: sha256(contents),
@@ -35,6 +44,7 @@ export function buildBundleManifest(bundle, fingerprint) {
isEntry: chunk.isEntry,
imports: [...chunk.imports].sort(),
dynamicImports: [...chunk.dynamicImports].sort(),
_role: role,
};
})
.sort((left, right) => left.path.localeCompare(right.path));
@@ -48,8 +58,13 @@ export function buildBundleManifest(bundle, fingerprint) {
for (const root of dynamicRoots) {
for (const path of reachable(root, byPath, 'imports')) if (!initial.has(path)) lazy.add(path);
}
const onboardingRoots = dynamicRoots.filter((path) => path.includes('houseplan-onboarding-runtime-'));
const editorRoots = dynamicRoots.filter((path) => !onboardingRoots.includes(path));
const localeRoots = dynamicRoots.filter((path) => byPath.get(path)?._role === 'locale'
|| /(?:^|\/)de-[^/]+\.js$/.test(path));
const onboardingRoots = dynamicRoots.filter((path) => byPath.get(path)?._role === 'onboarding'
|| path.includes('houseplan-onboarding-runtime-'));
const editorRoots = dynamicRoots.filter((path) => (byPath.get(path)?._role === 'editor'
|| /(?:^|\/)editor(?:-[^/]+)?\.js$/.test(path))
&& !localeRoots.includes(path) && !onboardingRoots.includes(path));
const graphFrom = (roots) => {
const graph = new Set();
for (const root of roots) {
@@ -59,6 +74,7 @@ export function buildBundleManifest(bundle, fingerprint) {
};
const lazyEditor = graphFrom(editorRoots);
const lazyOnboarding = graphFrom(onboardingRoots);
const lazyLocale = graphFrom(localeRoots);
const sum = (paths) => [...paths]
.reduce((total, path) => total + (byPath.get(path)?.gzipBytes || 0), 0);
return {
@@ -73,7 +89,9 @@ export function buildBundleManifest(bundle, fingerprint) {
lazyEditorGzipBytes: sum(lazyEditor),
lazyOnboardingFiles: [...lazyOnboarding].sort(),
lazyOnboardingGzipBytes: sum(lazyOnboarding),
files,
lazyLocaleFiles: [...lazyLocale].sort(),
lazyLocaleGzipBytes: sum(lazyLocale),
files: files.map(({ _role, ...file }) => file),
};
}
@@ -120,10 +138,14 @@ export function editorRuntimeRetryUrlPlugin() {
.some((id) => id.replaceAll('\\', '/').endsWith('/src/houseplan-editor-runtime.ts')));
const onboarding = chunks.find((chunk) => Object.keys(chunk.modules)
.some((id) => id.replaceAll('\\', '/').endsWith('/src/houseplan-onboarding-runtime.ts')));
const german = chunks.find((chunk) => Object.keys(chunk.modules)
.some((id) => id.replaceAll('\\', '/').endsWith('/src/i18n/de.ts')));
if (!editor) throw new Error('editor runtime chunk was not emitted');
if (!onboarding) throw new Error('onboarding runtime chunk was not emitted');
if (!german) throw new Error('German locale chunk was not emitted');
let editorReplacements = 0;
let onboardingReplacements = 0;
let germanReplacements = 0;
for (const chunk of chunks) {
if (chunk.code.includes(EDITOR_RETRY_ASSET_TOKEN)) {
let asset = posix.relative(posix.dirname(chunk.fileName), editor.fileName);
@@ -137,10 +159,16 @@ export function editorRuntimeRetryUrlPlugin() {
onboardingReplacements += chunk.code.split(ONBOARDING_RETRY_ASSET_TOKEN).length - 1;
chunk.code = chunk.code.replaceAll(ONBOARDING_RETRY_ASSET_TOKEN, asset);
}
if (chunk.code.includes(DE_RETRY_ASSET_TOKEN)) {
let asset = posix.relative(posix.dirname(chunk.fileName), german.fileName);
if (!asset.startsWith('.')) asset = `./${asset}`;
germanReplacements += chunk.code.split(DE_RETRY_ASSET_TOKEN).length - 1;
chunk.code = chunk.code.replaceAll(DE_RETRY_ASSET_TOKEN, asset);
}
}
if (editorReplacements !== 1 || onboardingReplacements !== 1) {
if (editorReplacements !== 1 || onboardingReplacements !== 1 || germanReplacements !== 1) {
throw new Error('lazy retry URL placeholder counts are '
+ `${editorReplacements}/${onboardingReplacements}, expected 1/1`);
+ `${editorReplacements}/${onboardingReplacements}/${germanReplacements}, expected 1/1/1`);
}
},
};
+10
View File
@@ -27,6 +27,16 @@
/** @type {SmokeLink[]} */
export const SMOKE_LINKS = [
{
symbols: [
'LanguageRuntime', 'LanguageRuntimeContract', 'languageRenderGate',
'dictionaryFor', 'ensureLanguage',
],
smokes: ['smoke_german_locale.mjs'],
because: 'the production-bundle scenario observes the lazy German chunk through network '
+ 'requests and four root render gates: neutral cold frame, atomic German commit, page-cache '
+ 'reuse and the bounded retry-to-English path cannot be proven from pure registry tests (#348)',
},
{
symbols: ['quantizeKeyCoord', 'INCIDENT_EPS', 'KEY_FACTOR'],
smokes: ['smoke_junction_limits.mjs'],