feat: French localization — community contribution by @OUARZA (#371)

The complete French dictionary contributed in #371 (1026 keys, zero empty
values, zero placeholder mismatches) lands as the second lazy locale on the
fr.ts + one static entry. The contributor's snapshot predated this week's
keys, so the 121 additions (device inbox #29, backdrop guard #39, junction
limits #331, lazy-editor toasts #353/#354, furniture #159) are translated
in this commit and flagged in the issue for the author's review; 21 stale
pre-#62 keys are dropped; key order follows en.json. The HA integration
translations file ships as contributed (full parity).

Wiring: loadFrench + __HOUSEPLAN_FR_RETRY_ASSET__ with the same 1/1
replacement guarantee as German; locale roles and localeRoots generalise to
(de|fr); the stale-entry fallback panel (#353) gains its French branch —
the r1 reviewer caught that this second hardcoded language list would have
silently degraded French to English on a cached entry. French profiles
(fr, fr-FR, fr-CA, fr-BE, fr-CH) select automatically.

Proofs: the registry-driven parity suite covers fr by construction
(1128/1128 keys); French analogues of both German-personal tests (product
glossary + non-translation scan with a reviewed equal-to-English
allow-list of 22 legitimate homographs); smoke_french_locale — fr-CA
profile commits French from the real bundle, one lazy chunk per page,
initial graph free of fr; smoke_entry_stale gains the French run; a
registry mutant drops the fr entry and is killed by the smoke. Initial
view: +0.5 KB (registry entry + fallback branch); the 23 KB dictionary is
lazy.

Issue: #371
User-Visible: yes
This commit is contained in:
Codex
2026-08-29 13:39:21 +03:00
parent fbf2c1bd41
commit 89fa8ca588
44 changed files with 1540 additions and 201 deletions
+18 -4
View File
@@ -7,6 +7,7 @@ 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 FR_RETRY_ASSET_TOKEN = '__HOUSEPLAN_FR_RETRY_ASSET__';
const sha256 = (value) => createHash('sha256').update(value).digest('hex');
@@ -29,7 +30,7 @@ export function buildBundleManifest(bundle, fingerprint) {
.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'))
const role = modules.some((id) => id.endsWith('/src/i18n/de.ts') || id.endsWith('/src/i18n/fr.ts'))
? 'locale'
: modules.some((id) => id.endsWith('/src/houseplan-onboarding-runtime.ts'))
? 'onboarding'
@@ -59,7 +60,7 @@ export function buildBundleManifest(bundle, fingerprint) {
for (const path of reachable(root, byPath, 'imports')) if (!initial.has(path)) lazy.add(path);
}
const localeRoots = dynamicRoots.filter((path) => byPath.get(path)?._role === 'locale'
|| /(?:^|\/)de-[^/]+\.js$/.test(path));
|| /(?:^|\/)(?:de|fr)-[^/]+\.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'
@@ -140,12 +141,16 @@ export function editorRuntimeRetryUrlPlugin() {
.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')));
const french = chunks.find((chunk) => Object.keys(chunk.modules)
.some((id) => id.replaceAll('\\', '/').endsWith('/src/i18n/fr.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');
if (!french) throw new Error('French locale chunk was not emitted');
let editorReplacements = 0;
let onboardingReplacements = 0;
let germanReplacements = 0;
let frenchReplacements = 0;
for (const chunk of chunks) {
if (chunk.code.includes(EDITOR_RETRY_ASSET_TOKEN)) {
let asset = posix.relative(posix.dirname(chunk.fileName), editor.fileName);
@@ -165,10 +170,17 @@ export function editorRuntimeRetryUrlPlugin() {
germanReplacements += chunk.code.split(DE_RETRY_ASSET_TOKEN).length - 1;
chunk.code = chunk.code.replaceAll(DE_RETRY_ASSET_TOKEN, asset);
}
if (chunk.code.includes(FR_RETRY_ASSET_TOKEN)) {
let asset = posix.relative(posix.dirname(chunk.fileName), french.fileName);
if (!asset.startsWith('.')) asset = `./${asset}`;
frenchReplacements += chunk.code.split(FR_RETRY_ASSET_TOKEN).length - 1;
chunk.code = chunk.code.replaceAll(FR_RETRY_ASSET_TOKEN, asset);
}
}
if (editorReplacements !== 1 || onboardingReplacements !== 1 || germanReplacements !== 1) {
if (editorReplacements !== 1 || onboardingReplacements !== 1
|| germanReplacements !== 1 || frenchReplacements !== 1) {
throw new Error('lazy retry URL placeholder counts are '
+ `${editorReplacements}/${onboardingReplacements}/${germanReplacements}, expected 1/1/1`);
+ `${editorReplacements}/${onboardingReplacements}/${germanReplacements}/${frenchReplacements}, expected 1/1/1/1`);
}
},
};
@@ -209,6 +221,8 @@ export function entryFallbackPlugin() {
+ '?"House Plan обновился — перезагрузите страницу (Ctrl+F5)."'
+ ':l.startsWith("de")'
+ '?"House Plan wurde aktualisiert — bitte laden Sie die Seite neu (Strg+F5)."'
+ ':l.startsWith("fr")'
+ '?"House Plan a été mis à jour — veuillez recharger la page (Ctrl+F5)."'
+ ':"House Plan was updated — please reload the page (Ctrl+F5).";'
+ 'customElements.define("houseplan-card",class extends HTMLElement{'
+ 'setConfig(){}getCardSize(){return 1}connectedCallback(){'
+11
View File
@@ -680,6 +680,17 @@ const MUTANT_DEFINITIONS = [
replace: ' this._languageFailureUnsub = undefined;',
}],
},
{
id: 'french-locale-entry-removed',
guard: 'node demo/smoke_french_locale.mjs',
because: 'dropping the fr registry entry silently reverts French profiles to English — the '
+ 'community contribution must stay wired (#371)',
patches: [{
file: 'src/i18n/registry.ts',
find: " { code: 'fr', nativeLabel: 'Français', loadDictionary: loadFrench },\n",
replace: '',
}],
},
{
id: 'opening-light-quantum-identity',
guard: 'node --test --test-name-pattern="#366" test/logic.test.mjs',
+1 -1
View File
@@ -39,7 +39,7 @@ export const SMOKE_LINKS = [
'LanguageRuntime', 'LanguageRuntimeContract', 'languageRenderGate',
'dictionaryFor', 'ensureLanguage',
],
smokes: ['smoke_german_locale.mjs'],
smokes: ['smoke_german_locale.mjs', 'smoke_french_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)',