mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
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:
@@ -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);
|
||||
|
||||
@@ -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`);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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'],
|
||||
|
||||
Reference in New Issue
Block a user