feat(i18n, CourtDrawingTool, MembersOverviewSection): enhance internationalization and UI components
- Added new i18n keys for improved translations in the CourtDrawingTool and MembersOverviewSection components. - Updated CourtDrawingTool to utilize translation keys for various UI elements, enhancing user experience and accessibility. - Enhanced MembersOverviewSection with translation support for search and filter functionalities, improving usability for non-German speakers. - Introduced new scripts in package.json for i18n auditing and status checking, streamlining localization management.
This commit is contained in:
187
frontend/scripts/audit-i18n.js
Normal file
187
frontend/scripts/audit-i18n.js
Normal file
@@ -0,0 +1,187 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const ROOT = path.resolve(__dirname, '..');
|
||||
const LOCALES_DIR = path.join(ROOT, 'src', 'i18n', 'locales');
|
||||
const BASE_LOCALE = 'de.json';
|
||||
const CORE_NAMESPACES = [
|
||||
'common',
|
||||
'navigation',
|
||||
'club',
|
||||
'members',
|
||||
'diary',
|
||||
'trainingStats',
|
||||
'courtDrawingTool'
|
||||
];
|
||||
|
||||
const GERMAN_MARKERS = [
|
||||
'Mitglied',
|
||||
'Mitglieder',
|
||||
'Training',
|
||||
'Verein',
|
||||
'Bearbeiten',
|
||||
'Loeschen',
|
||||
'Löschen',
|
||||
'Zugriff',
|
||||
'Aufschlag',
|
||||
'Ziel',
|
||||
'Schlag',
|
||||
'Gruppe',
|
||||
'Gruppen',
|
||||
'Heute',
|
||||
'Zur Startseite'
|
||||
];
|
||||
|
||||
const PLACEHOLDER_PATTERN = /\{[^}]+\}/g;
|
||||
|
||||
const NATIVE_CHARACTER_HINTS = {
|
||||
fr: /[àâæçéèêëîïôœùûüÿ]/i,
|
||||
es: /[áéíóúñü¡¿]/i,
|
||||
it: /[àèéìíîòóù]/i,
|
||||
pl: /[ąćęłńóśźż]/i,
|
||||
de: /[äöüß]/i,
|
||||
'de-CH': /[äöü]/i,
|
||||
ja: /[\u3040-\u30ff\u4e00-\u9faf]/,
|
||||
zh: /[\u4e00-\u9faf]/,
|
||||
th: /[\u0e00-\u0e7f]/,
|
||||
tl: null,
|
||||
fil: null,
|
||||
'en-GB': null,
|
||||
'en-US': null,
|
||||
'en-AU': null
|
||||
};
|
||||
|
||||
function flatten(obj, prefix = '') {
|
||||
const out = {};
|
||||
for (const [key, value] of Object.entries(obj || {})) {
|
||||
const nextKey = prefix ? `${prefix}.${key}` : key;
|
||||
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
||||
Object.assign(out, flatten(value, nextKey));
|
||||
} else {
|
||||
out[nextKey] = value;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function pickNamespaces(source) {
|
||||
return Object.fromEntries(CORE_NAMESPACES.map(namespace => [namespace, source[namespace] || {}]));
|
||||
}
|
||||
|
||||
function loadJson(filePath) {
|
||||
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
||||
}
|
||||
|
||||
function getLocaleCode(localeFile) {
|
||||
return localeFile.replace(/\.json$/, '');
|
||||
}
|
||||
|
||||
function extractPlaceholders(value) {
|
||||
if (typeof value !== 'string') {
|
||||
return [];
|
||||
}
|
||||
return Array.from(value.matchAll(PLACEHOLDER_PATTERN)).map(match => match[0]).sort();
|
||||
}
|
||||
|
||||
function auditLocale(localeFile, baseFlat) {
|
||||
const data = loadJson(path.join(LOCALES_DIR, localeFile));
|
||||
const flat = flatten(pickNamespaces(data));
|
||||
const localeCode = getLocaleCode(localeFile);
|
||||
const missing = Object.keys(baseFlat).filter(key => !(key in flat));
|
||||
const sameAsDe = Object.keys(baseFlat).filter(key => flat[key] === baseFlat[key]);
|
||||
const germanHits = Object.entries(flat)
|
||||
.filter(([, value]) => typeof value === 'string' && GERMAN_MARKERS.some(marker => value.includes(marker)))
|
||||
.map(([key, value]) => `${key} = ${value}`);
|
||||
|
||||
const placeholderMismatches = Object.entries(baseFlat)
|
||||
.filter(([key, value]) => {
|
||||
if (!(key in flat) || typeof value !== 'string' || typeof flat[key] !== 'string') {
|
||||
return false;
|
||||
}
|
||||
const basePlaceholders = extractPlaceholders(value);
|
||||
const localePlaceholders = extractPlaceholders(flat[key]);
|
||||
return JSON.stringify(basePlaceholders) !== JSON.stringify(localePlaceholders);
|
||||
})
|
||||
.map(([key, value]) => `${key} = base:${value} | locale:${flat[key]}`);
|
||||
|
||||
const nativeCharHint = NATIVE_CHARACTER_HINTS[localeCode];
|
||||
const nativeCharCoverage = nativeCharHint
|
||||
? Object.entries(flat)
|
||||
.filter(([, value]) => typeof value === 'string' && value.trim())
|
||||
.filter(([, value]) => nativeCharHint.test(value))
|
||||
.length
|
||||
: null;
|
||||
|
||||
return {
|
||||
localeFile,
|
||||
localeCode,
|
||||
missing,
|
||||
sameAsDe,
|
||||
germanHits,
|
||||
placeholderMismatches,
|
||||
nativeCharCoverage
|
||||
};
|
||||
}
|
||||
|
||||
function printSection(title, entries, limit = 20) {
|
||||
console.log(title);
|
||||
if (!entries.length) {
|
||||
console.log(' none');
|
||||
return;
|
||||
}
|
||||
for (const entry of entries.slice(0, limit)) {
|
||||
console.log(` ${entry}`);
|
||||
}
|
||||
if (entries.length > limit) {
|
||||
console.log(` ... ${entries.length - limit} more`);
|
||||
}
|
||||
}
|
||||
|
||||
function main() {
|
||||
const localeArgs = process.argv.slice(2);
|
||||
const availableLocales = fs.readdirSync(LOCALES_DIR)
|
||||
.filter(file => file.endsWith('.json') && file !== BASE_LOCALE)
|
||||
.sort();
|
||||
|
||||
const locales = localeArgs.length
|
||||
? localeArgs.map(arg => (arg.endsWith('.json') ? arg : `${arg}.json`))
|
||||
: availableLocales;
|
||||
|
||||
const base = loadJson(path.join(LOCALES_DIR, BASE_LOCALE));
|
||||
const baseFlat = flatten(pickNamespaces(base));
|
||||
|
||||
let hasFailures = false;
|
||||
|
||||
for (const localeFile of locales) {
|
||||
if (!availableLocales.includes(localeFile)) {
|
||||
console.error(`Unknown locale: ${localeFile}`);
|
||||
hasFailures = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
const result = auditLocale(localeFile, baseFlat);
|
||||
console.log(`\n## ${localeFile}`);
|
||||
console.log(`missing keys: ${result.missing.length}`);
|
||||
console.log(`same as de: ${result.sameAsDe.length}`);
|
||||
console.log(`german marker hits: ${result.germanHits.length}`);
|
||||
console.log(`placeholder mismatches: ${result.placeholderMismatches.length}`);
|
||||
if (result.nativeCharCoverage !== null) {
|
||||
console.log(`entries with native characters: ${result.nativeCharCoverage}`);
|
||||
}
|
||||
|
||||
if (result.missing.length || result.germanHits.length || result.placeholderMismatches.length) {
|
||||
hasFailures = true;
|
||||
}
|
||||
|
||||
printSection('Missing keys', result.missing);
|
||||
printSection('Same as de', result.sameAsDe);
|
||||
printSection('German marker hits', result.germanHits);
|
||||
printSection('Placeholder mismatches', result.placeholderMismatches);
|
||||
}
|
||||
|
||||
if (hasFailures) {
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
79
frontend/scripts/translation-status.js
Normal file
79
frontend/scripts/translation-status.js
Normal file
@@ -0,0 +1,79 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const ROOT = path.resolve(__dirname, '..');
|
||||
const LOCALES_DIR = path.join(ROOT, 'src', 'i18n', 'locales');
|
||||
const BASE_LOCALE = 'de.json';
|
||||
const CORE_NAMESPACES = [
|
||||
'common',
|
||||
'navigation',
|
||||
'club',
|
||||
'members',
|
||||
'diary',
|
||||
'trainingStats',
|
||||
'courtDrawingTool'
|
||||
];
|
||||
|
||||
const BATCHES = {
|
||||
A: ['fr', 'es'],
|
||||
B: ['it', 'pl'],
|
||||
C: ['ja', 'zh'],
|
||||
D: ['th', 'tl', 'fil']
|
||||
};
|
||||
|
||||
function loadJson(filePath) {
|
||||
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
||||
}
|
||||
|
||||
function flatten(obj, prefix = '', result = {}) {
|
||||
for (const [key, value] of Object.entries(obj || {})) {
|
||||
const nextKey = prefix ? `${prefix}.${key}` : key;
|
||||
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
||||
flatten(value, nextKey, result);
|
||||
} else {
|
||||
result[nextKey] = value;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function pickNamespaces(source) {
|
||||
return Object.fromEntries(CORE_NAMESPACES.map(namespace => [namespace, source[namespace] || {}]));
|
||||
}
|
||||
|
||||
function statusFor(localeFile, baseFlat) {
|
||||
const locale = localeFile.replace(/\.json$/, '');
|
||||
const data = loadJson(path.join(LOCALES_DIR, localeFile));
|
||||
const flat = flatten(pickNamespaces(data));
|
||||
const keys = Object.keys(baseFlat);
|
||||
const missing = keys.filter(key => !(key in flat)).length;
|
||||
const sameAsDe = keys.filter(key => flat[key] === baseFlat[key]).length;
|
||||
const translated = keys.length - missing - sameAsDe;
|
||||
const coverage = ((keys.length - missing) / keys.length) * 100;
|
||||
const actualTranslationCoverage = (translated / keys.length) * 100;
|
||||
return { locale, missing, sameAsDe, translated, coverage, actualTranslationCoverage };
|
||||
}
|
||||
|
||||
function printGroup(title, locales, baseFlat) {
|
||||
console.log(`\n# ${title}`);
|
||||
console.log('locale | translated | same as de | missing | core coverage | actual translation');
|
||||
console.log('--- | ---: | ---: | ---: | ---: | ---:');
|
||||
for (const locale of locales) {
|
||||
const result = statusFor(`${locale}.json`, baseFlat);
|
||||
console.log(
|
||||
`${result.locale} | ${result.translated} | ${result.sameAsDe} | ${result.missing} | ${result.coverage.toFixed(1)}% | ${result.actualTranslationCoverage.toFixed(1)}%`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function main() {
|
||||
const base = loadJson(path.join(LOCALES_DIR, BASE_LOCALE));
|
||||
const baseFlat = flatten(pickNamespaces(base));
|
||||
|
||||
printGroup('Batch A', BATCHES.A, baseFlat);
|
||||
printGroup('Batch B', BATCHES.B, baseFlat);
|
||||
printGroup('Batch C', BATCHES.C, baseFlat);
|
||||
printGroup('Batch D', BATCHES.D, baseFlat);
|
||||
}
|
||||
|
||||
main();
|
||||
Reference in New Issue
Block a user