feat(i18n): add French language support and enhance localization
All checks were successful
Deploy to production / deploy (push) Successful in 2m48s

- Introduced French as a supported language across the application, updating locale files and adding translations for various components.
- Enhanced language handling logic to accommodate French, ensuring proper detection and fallback mechanisms.
- Updated UI elements to include French language options, improving accessibility for French-speaking users.
- Refactored SEO handling to include French in hreflang links, enhancing search engine indexing for multilingual content.
- Added new scripts for managing French translations and ensuring consistency across language files.
This commit is contained in:
Torsten Schulz (local)
2026-04-07 18:04:03 +02:00
parent f715c6125d
commit f7030bbabe
56 changed files with 5220 additions and 175 deletions

View File

@@ -0,0 +1,78 @@
/**
* Vergleicht flache Schlüsselpfade zwischen zwei Locale-Ordnern (gleiche JSON-Dateinamen).
*
* Aufruf: node scripts/check-i18n-locale-parity.mjs de fr
*/
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const BASE = path.join(__dirname, '../src/i18n/locales');
function flattenLeaves(obj, prefix = '', out = {}) {
if (obj === null || typeof obj !== 'object' || Array.isArray(obj)) {
out[prefix] = obj;
return out;
}
const keys = Object.keys(obj);
if (keys.length === 0) {
out[prefix] = obj;
return out;
}
for (const k of keys) {
const p = prefix ? `${prefix}.${k}` : k;
const v = obj[k];
if (v !== null && typeof v === 'object' && !Array.isArray(v)) {
flattenLeaves(v, p, out);
} else {
out[p] = v;
}
}
return out;
}
const [a, b] = process.argv.slice(2);
if (!a || !b) {
console.error('Usage: node scripts/check-i18n-locale-parity.mjs <localeA> <localeB>');
process.exit(2);
}
const dirA = path.join(BASE, a);
const dirB = path.join(BASE, b);
const files = fs.readdirSync(dirA).filter((f) => f.endsWith('.json'));
let errors = 0;
for (const f of files) {
const pa = path.join(dirA, f);
const pb = path.join(dirB, f);
if (!fs.existsSync(pb)) {
console.error(`Fehlt: ${b}/${f}`);
errors++;
continue;
}
const ja = JSON.parse(fs.readFileSync(pa, 'utf8'));
const jb = JSON.parse(fs.readFileSync(pb, 'utf8'));
const fa = flattenLeaves(ja);
const fb = flattenLeaves(jb);
const ka = new Set(Object.keys(fa));
const kb = new Set(Object.keys(fb));
for (const k of ka) {
if (!kb.has(k)) {
console.error(`[${f}] fehlt in ${b}: ${k}`);
errors++;
}
}
for (const k of kb) {
if (!ka.has(k)) {
console.error(`[${f}] extra in ${b}: ${k}`);
errors++;
}
}
}
if (errors) {
console.error(`\n${errors} Abweichung(en).`);
process.exit(1);
}
console.log(`OK: Key-Parität ${a}${b} für ${files.length} Dateien.`);

View File

@@ -0,0 +1,134 @@
/**
* Übersetzt String-Blätter in locales/fr/*.json von Deutsch nach Französisch
* (google-translate-api-x, inoffiziell), mit Cache unter scripts/.i18n-de-fr-cache.json.
*
* Ausführen aus frontend/: node scripts/generate-fr-locale-from-de.mjs
*/
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import { translate } from 'google-translate-api-x';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const LOCALES_FR = path.join(__dirname, '../src/i18n/locales/fr');
const CACHE_PATH = path.join(__dirname, '.i18n-de-fr-cache.json');
const CONCURRENCY = Math.max(1, parseInt(process.env.I18N_MT_CONCURRENCY || '4', 10));
const DELAY_MS = Math.max(0, parseInt(process.env.I18N_MT_DELAY_MS || '350', 10));
function loadCache() {
try {
return JSON.parse(fs.readFileSync(CACHE_PATH, 'utf8'));
} catch {
return {};
}
}
function saveCache(cache) {
fs.writeFileSync(CACHE_PATH, JSON.stringify(cache, null, 0), 'utf8');
}
function shouldSkipTranslation(str) {
if (typeof str !== 'string' || str.length === 0) return true;
if (/^[\d\s./:\\\-–—:]+$/u.test(str) && str.length < 96) return true;
return false;
}
function collectStrings(node, set) {
if (node === null || node === undefined) return;
if (typeof node === 'string') {
set.add(node);
return;
}
if (Array.isArray(node)) {
for (const x of node) collectStrings(x, set);
return;
}
if (typeof node === 'object') {
for (const k of Object.keys(node)) collectStrings(node[k], set);
}
}
function applyTranslations(node, cache) {
if (node === null || node === undefined) return node;
if (typeof node === 'string') {
if (shouldSkipTranslation(node)) return node;
return cache[node] !== undefined ? cache[node] : node;
}
if (Array.isArray(node)) return node.map((x) => applyTranslations(x, cache));
if (typeof node === 'object') {
const out = {};
for (const k of Object.keys(node)) {
out[k] = applyTranslations(node[k], cache);
}
return out;
}
return node;
}
async function translateOne(text, cache) {
if (shouldSkipTranslation(text)) return text;
if (cache[text]) return cache[text];
for (let attempt = 0; attempt < 4; attempt++) {
try {
const res = await translate(text, { from: 'de', to: 'fr' });
const out = res.text;
cache[text] = out;
return out;
} catch (e) {
await new Promise((r) => setTimeout(r, 900 * (attempt + 1)));
}
}
console.warn('Übersetzung fehlgeschlagen, Original behalten:', text.slice(0, 80));
cache[text] = text;
return text;
}
async function main() {
const files = fs.readdirSync(LOCALES_FR).filter((f) => f.endsWith('.json'));
const all = new Set();
for (const f of files) {
const raw = JSON.parse(fs.readFileSync(path.join(LOCALES_FR, f), 'utf8'));
collectStrings(raw, all);
}
const unique = [...all].filter((s) => !shouldSkipTranslation(s));
const cache = loadCache();
const missing = unique.filter((s) => cache[s] === undefined);
console.log(`Unique (MT): ${unique.length}, ohne Cache-Eintrag: ${missing.length}`);
let next = 0;
let saved = 0;
async function worker() {
for (;;) {
const i = next++;
if (i >= missing.length) return;
const s = missing[i];
await translateOne(s, cache);
saved++;
if (saved % 30 === 0) {
saveCache(cache);
console.log(`${saved}/${missing.length}`);
}
if (DELAY_MS > 0) {
await new Promise((r) => setTimeout(r, DELAY_MS));
}
}
}
const workers = Array.from({ length: Math.min(CONCURRENCY, Math.max(1, missing.length)) }, () => worker());
await Promise.all(workers);
saveCache(cache);
for (const f of files) {
const p = path.join(LOCALES_FR, f);
const raw = JSON.parse(fs.readFileSync(p, 'utf8'));
const out = applyTranslations(raw, cache);
fs.writeFileSync(p, `${JSON.stringify(out, null, 4)}\n`, 'utf8');
}
console.log('Fertig: fr/*.json aktualisiert.');
}
main().catch((e) => {
console.error(e);
process.exit(1);
});

View File

@@ -0,0 +1,137 @@
/**
* Erzeugt fr/falukant.json neu aus de/falukant.json (google-translate-api-x).
* Stellt sicher, dass benannte Platzhalter {name} exakt wie im Deutschen bleiben.
*
* Aus frontend/: node scripts/smooth-fr-falukant.mjs
*/
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import { translate } from 'google-translate-api-x';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const DE_PATH = path.join(__dirname, '../src/i18n/locales/de/falukant.json');
const FR_PATH = path.join(__dirname, '../src/i18n/locales/fr/falukant.json');
const CACHE_PATH = path.join(__dirname, '.falukant-fr-smooth-cache.json');
const CONCURRENCY = Math.max(1, parseInt(process.env.I18N_MT_CONCURRENCY || '3', 10));
const DELAY_MS = Math.max(0, parseInt(process.env.I18N_MT_DELAY_MS || '400', 10));
function loadCache() {
try {
return JSON.parse(fs.readFileSync(CACHE_PATH, 'utf8'));
} catch {
return {};
}
}
function saveCache(cache) {
fs.writeFileSync(CACHE_PATH, JSON.stringify(cache, null, 0), 'utf8');
}
function shouldSkipTranslation(str) {
if (typeof str !== 'string' || str.length === 0) return true;
if (/^[\d\s./:\\\-–—:]+$/u.test(str) && str.length < 96) return true;
if (/^([dDMYHhms\.\/:,\[\]'\s-]|yyyy|yy)+$/i.test(str.trim()) && str.length < 96) return true;
return false;
}
/** Ersetzt {…}-Tokens in der Übersetzung durch die Namen aus dem Deutschen (gleiche Reihenfolge). */
function alignPlaceholders(deStr, frStr) {
const deNames = [...deStr.matchAll(/\{([a-zA-Z_][a-zA-Z0-9_]*)\}/g)].map((m) => m[1]);
if (deNames.length === 0) return frStr;
let i = 0;
return frStr.replace(/\{[^}]+\}/g, () => {
const name = deNames[i];
i += 1;
return name !== undefined ? `{${name}}` : '{}';
});
}
async function translateOne(text, cache) {
if (shouldSkipTranslation(text)) return text;
if (cache[text]) return cache[text];
for (let attempt = 0; attempt < 4; attempt++) {
try {
const res = await translate(text, { from: 'de', to: 'fr' });
const aligned = alignPlaceholders(text, res.text);
cache[text] = aligned;
return aligned;
} catch {
await new Promise((r) => setTimeout(r, 800 * (attempt + 1)));
}
}
console.warn('Übersetzung fehlgeschlagen:', text.slice(0, 72));
cache[text] = text;
return text;
}
function collectStrings(node, set) {
if (typeof node === 'string') {
set.add(node);
return;
}
if (Array.isArray(node)) {
for (const x of node) collectStrings(x, set);
return;
}
if (node && typeof node === 'object') {
for (const k of Object.keys(node)) collectStrings(node[k], set);
}
}
function applyMap(node, cache) {
if (typeof node === 'string') {
if (shouldSkipTranslation(node)) return node;
return cache[node] !== undefined ? cache[node] : node;
}
if (Array.isArray(node)) return node.map((x) => applyMap(x, cache));
if (node && typeof node === 'object') {
const out = {};
for (const k of Object.keys(node)) {
out[k] = applyMap(node[k], cache);
}
return out;
}
return node;
}
async function main() {
const deRoot = JSON.parse(fs.readFileSync(DE_PATH, 'utf8'));
const cache = loadCache();
const all = new Set();
collectStrings(deRoot, all);
const unique = [...all].filter((s) => !shouldSkipTranslation(s));
const missing = unique.filter((s) => cache[s] === undefined);
console.log(`Falukant: ${unique.length} übersetzbare Strings, ${missing.length} ohne Cache.`);
let next = 0;
let done = 0;
async function worker() {
for (;;) {
const i = next++;
if (i >= missing.length) return;
const s = missing[i];
await translateOne(s, cache);
done++;
if (done % 40 === 0) {
saveCache(cache);
console.log(`${done}/${missing.length}`);
}
if (DELAY_MS > 0) await new Promise((r) => setTimeout(r, DELAY_MS));
}
}
const workers = Array.from({ length: Math.min(CONCURRENCY, Math.max(1, missing.length)) }, () => worker());
await Promise.all(workers);
saveCache(cache);
const out = applyMap(deRoot, cache);
fs.writeFileSync(FR_PATH, `${JSON.stringify(out, null, 4)}\n`, 'utf8');
console.log('Geschrieben:', FR_PATH);
}
main().catch((e) => {
console.error(e);
process.exit(1);
});