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
+8 -2
View File
@@ -100,7 +100,8 @@ test('retry URL points at the content-hashed runtime chunk after naming', () =>
type: 'chunk', fileName: 'houseplan-assets/houseplan-card.js',
code: 'new URL("__HOUSEPLAN_EDITOR_RETRY_ASSET__", import.meta.url);'
+ 'new URL("__HOUSEPLAN_ONBOARDING_RETRY_ASSET__", import.meta.url);'
+ 'new URL("__HOUSEPLAN_DE_RETRY_ASSET__", import.meta.url)', modules: {},
+ 'new URL("__HOUSEPLAN_DE_RETRY_ASSET__", import.meta.url);'
+ 'new URL("__HOUSEPLAN_FR_RETRY_ASSET__", import.meta.url)', modules: {},
},
'houseplan-assets/houseplan-editor-runtime-HASH.js': {
type: 'chunk', fileName: 'houseplan-assets/houseplan-editor-runtime-HASH.js', code: '',
@@ -114,13 +115,18 @@ test('retry URL points at the content-hashed runtime chunk after naming', () =>
type: 'chunk', fileName: 'houseplan-assets/de-HASH.js', code: '',
modules: { '/repo/src/i18n/de.ts': {} },
},
'houseplan-assets/fr-HASH.js': {
type: 'chunk', fileName: 'houseplan-assets/fr-HASH.js', code: '',
modules: { '/repo/src/i18n/fr.ts': {} },
},
};
plugin.generateBundle({}, bundle);
assert.equal(
bundle['houseplan-assets/houseplan-card.js'].code,
'new URL("./houseplan-editor-runtime-HASH.js", import.meta.url);'
+ 'new URL("./houseplan-onboarding-runtime-HASH.js", import.meta.url);'
+ 'new URL("./de-HASH.js", import.meta.url)',
+ 'new URL("./de-HASH.js", import.meta.url);'
+ 'new URL("./fr-HASH.js", import.meta.url)',
);
});
+51 -1
View File
@@ -21,6 +21,7 @@ const dictionaries = new Map(LANGUAGE_REGISTRY.map(({ code }) => [code, dictiona
const en = dictionaries.get('en');
const ru = dictionaries.get('ru');
const de = dictionaries.get('de');
const fr = dictionaries.get('fr');
const cardSource = readHouseplanProductionSource();
test('i18n: registry codes and English fallback are valid', () => {
@@ -133,7 +134,7 @@ test('i18n: placeholders match between languages', () => {
});
test('i18n: resolver supports exact, primary, explicit and fallback paths', () => {
const supported = ['en', 'ru', 'de', 'pt-BR'];
const supported = ['en', 'ru', 'de', 'fr', 'pt-BR'];
const cases = [
{ explicit: 'RU', ha: 'en-US', expected: 'ru' },
{ explicit: 'pt_BR', ha: 'ru-RU', expected: 'pt-BR' },
@@ -145,6 +146,10 @@ test('i18n: resolver supports exact, primary, explicit and fallback paths', () =
{ explicit: undefined, ha: 'de-DE', expected: 'de' },
{ explicit: undefined, ha: 'de-AT', expected: 'de' },
{ explicit: undefined, ha: 'de-CH', expected: 'de' },
{ explicit: undefined, ha: 'fr-FR', expected: 'fr' },
{ explicit: undefined, ha: 'fr-CA', expected: 'fr' },
{ explicit: undefined, ha: 'fr_BE', expected: 'fr' },
{ explicit: undefined, ha: 'fr-CH', expected: 'fr' },
];
for (const { explicit, ha, expected } of cases) {
assert.equal(resolveLanguageCode(explicit, ha, supported, 'en'), expected);
@@ -156,6 +161,7 @@ test('i18n: langOf wires card config and both HA locale shapes to the registry',
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'), 'de');
assert.equal(langOf({ locale: { language: 'fr-CA' } }, 'unknown'), 'fr');
});
test('i18n: editor options follow registry order and preserve unknown raw values', () => {
@@ -368,3 +374,47 @@ test('i18n: German values equal to English are explicitly reviewed', () => {
const equalKeys = Object.keys(en).filter((key) => en[key] === de[key]);
assert.deepEqual(new Set(equalKeys), allowed);
});
test('i18n: French catalog keeps the product glossary and has no translation sentinels (#371)', () => {
assert.equal(fr['btn.save'], 'Enregistrer');
assert.equal(fr['btn.cancel'], 'Annuler');
assert.equal(fr['btn.delete'], 'Supprimer');
assert.equal(fr['space.header'], 'Espace');
assert.equal(fr['opening.passage'], 'Passage ouvert');
for (const [key, value] of Object.entries(fr)) {
assert.doesNotMatch(value, /ZXQPH|QXZ|⟦HP/u, `fr:${key} contains a translator sentinel`);
assert.doesNotMatch(value, /[А-Яа-яЁё]/u, `fr:${key} contains Cyrillic text`);
}
});
test('i18n: French values equal to English are explicitly reviewed (#371)', () => {
// Every entry here is a legitimate French/English homograph or a unit,
// template or brand token — reviewed with the contributed dictionary.
const allowed = new Set([
'color_picker.saturation',
'confirm.delete_partition_openings_item',
'decor.rect',
'err.code',
'furn.cat_bidet',
'gs.about_version',
'gs.north_letter',
'gs.unit_m',
'marker.desc_label',
'marker.preview.multiple_sources',
'marker.value_badge_attr_current_position',
'marker.value_badge_attr_volume_level',
'marker.value_badge_position',
'opening.type_label',
'rules.icon_ph',
'run.script',
'space.plan_alt',
'vac.cap_position',
'vac.diag_position',
'vac.diag_source',
'vac.documentation',
'wallthick.unit_cm',
]);
const equalKeys = Object.keys(en).filter((key) => en[key] === fr[key]);
assert.deepEqual(new Set(equalKeys), allowed);
});