feat: Update legal text for user consent and data processing across multiple locales, ensuring clarity and compliance with regulations
This commit is contained in:
368
scripts/check-i18n-keys.mjs
Normal file
368
scripts/check-i18n-keys.mjs
Normal file
@@ -0,0 +1,368 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Reports which keys from src/i18n/locales/de are referenced in application code.
|
||||
*
|
||||
* - Loads the same namespaces as src/i18n/index.js (translation bundle + legal-* bundles).
|
||||
* - Parses static t("...") / t('...') calls and maps useTranslation() aliases to namespaces.
|
||||
* - Legal text is duplicated: separate namespaces (e.g. legal-agb-delivery) AND embedded under
|
||||
* translation (legalAgbDelivery.*). When a separate-namespace key is used, the embedded
|
||||
* translation::* copy is treated as used too.
|
||||
* - Known dynamic patterns from AGB.js, Datenschutz.js, Impressum.js are expanded so those
|
||||
* keys are not falsely listed as unused.
|
||||
*
|
||||
* Usage: node scripts/check-i18n-keys.mjs
|
||||
* node scripts/check-i18n-keys.mjs --json
|
||||
*/
|
||||
|
||||
import { readFile, readdir } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = path.join(__dirname, '..');
|
||||
const SRC = path.join(ROOT, 'src');
|
||||
const LOCALE_DE = path.join(SRC, 'i18n', 'locales', 'de');
|
||||
|
||||
const SEPARATE_NAMESPACES = [
|
||||
'legal-agb-delivery',
|
||||
'legal-agb-payment',
|
||||
'legal-agb-consumer',
|
||||
'legal-datenschutz-basic',
|
||||
'legal-datenschutz-customer',
|
||||
'legal-datenschutz-google-orders',
|
||||
'legal-datenschutz-newsletter',
|
||||
'legal-datenschutz-chatbot',
|
||||
'legal-datenschutz-push',
|
||||
'legal-datenschutz-cookies-payment',
|
||||
'legal-datenschutz-rights',
|
||||
'legal-impressum',
|
||||
'legal-widerruf',
|
||||
'legal-batterie',
|
||||
];
|
||||
|
||||
/** Separate i18n namespace -> key prefix inside translation bundle (locales/de/index.js). */
|
||||
const NS_TO_EMBEDDED_PREFIX = {
|
||||
'legal-agb-delivery': 'legalAgbDelivery',
|
||||
'legal-agb-payment': 'legalAgbPayment',
|
||||
'legal-agb-consumer': 'legalAgbConsumer',
|
||||
'legal-datenschutz-basic': 'legalDatenschutzBasic',
|
||||
'legal-datenschutz-customer': 'legalDatenschutzCustomer',
|
||||
'legal-datenschutz-google-orders': 'legalDatenschutzGoogleOrders',
|
||||
'legal-datenschutz-newsletter': 'legalDatenschutzNewsletter',
|
||||
'legal-datenschutz-chatbot': 'legalDatenschutzChatbot',
|
||||
'legal-datenschutz-cookies-payment': 'legalDatenschutzCookiesPayment',
|
||||
'legal-datenschutz-rights': 'legalDatenschutzRights',
|
||||
};
|
||||
|
||||
/**
|
||||
* Keys reached only via t(`…${var}…`) in a few pages — expand so they count as used.
|
||||
*/
|
||||
function addKnownDynamicLegalKeys(used) {
|
||||
for (let n = 1; n <= 14; n++) {
|
||||
used.add(keySet('legal-agb-delivery', `deliveryTerms.${n}`));
|
||||
}
|
||||
for (const n of [1, 2, 3, 5, 6, 7, 8]) {
|
||||
used.add(keySet('legal-agb-consumer', `distanceSelling.sections.${n}.title`));
|
||||
used.add(keySet('legal-agb-consumer', `distanceSelling.sections.${n}.content`));
|
||||
}
|
||||
for (const section of ['informationDeletion', 'serverLogfiles']) {
|
||||
used.add(keySet('legal-datenschutz-basic', `sections.${section}.title`));
|
||||
used.add(keySet('legal-datenschutz-basic', `sections.${section}.content`));
|
||||
}
|
||||
for (const section of ['operator', 'contact', 'vatId', 'disclaimer', 'copyright']) {
|
||||
used.add(keySet('legal-impressum', `sections.${section}.title`));
|
||||
used.add(keySet('legal-impressum', `sections.${section}.content`));
|
||||
}
|
||||
}
|
||||
|
||||
/** If legal-agb-delivery::foo is used, translation::legalAgbDelivery.foo is the same strings. */
|
||||
function propagateEmbeddedCopies(used) {
|
||||
const additions = [];
|
||||
for (const entry of used) {
|
||||
const sep = entry.indexOf('::');
|
||||
if (sep === -1) continue;
|
||||
const ns = entry.slice(0, sep);
|
||||
const keyPath = entry.slice(sep + 2);
|
||||
const prefix = NS_TO_EMBEDDED_PREFIX[ns];
|
||||
if (!prefix) continue;
|
||||
additions.push(keySet('translation', `${prefix}.${keyPath}`));
|
||||
}
|
||||
for (const a of additions) used.add(a);
|
||||
}
|
||||
|
||||
function flattenLeaves(obj, prefix = '') {
|
||||
const keys = [];
|
||||
if (obj === null || obj === undefined) return keys;
|
||||
if (typeof obj !== 'object' || Array.isArray(obj)) {
|
||||
if (prefix) keys.push(prefix);
|
||||
return keys;
|
||||
}
|
||||
const entries = Object.entries(obj);
|
||||
if (entries.length === 0 && prefix) keys.push(prefix);
|
||||
for (const [k, v] of entries) {
|
||||
const next = prefix ? `${prefix}.${k}` : k;
|
||||
if (v !== null && typeof v === 'object' && !Array.isArray(v)) {
|
||||
keys.push(...flattenLeaves(v, next));
|
||||
} else {
|
||||
keys.push(next);
|
||||
}
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
|
||||
function keySet(ns, keyPath) {
|
||||
return `${ns}::${keyPath}`;
|
||||
}
|
||||
|
||||
async function loadDefinedKeys() {
|
||||
const translationMod = await import(
|
||||
pathToFileUrl(path.join(LOCALE_DE, 'index.js'))
|
||||
);
|
||||
const translation = translationMod.default;
|
||||
const defined = new Map();
|
||||
defined.set('translation', new Set(flattenLeaves(translation)));
|
||||
|
||||
for (const ns of SEPARATE_NAMESPACES) {
|
||||
const mod = await import(pathToFileUrl(path.join(LOCALE_DE, `${ns}.js`)));
|
||||
defined.set(ns, new Set(flattenLeaves(mod.default)));
|
||||
}
|
||||
|
||||
return defined;
|
||||
}
|
||||
|
||||
function pathToFileUrl(p) {
|
||||
const normalized = path.resolve(p);
|
||||
return new URL(`file://${normalized}`).href;
|
||||
}
|
||||
|
||||
async function collectSourceFiles(dir, out = []) {
|
||||
const entries = await readdir(dir, { withFileTypes: true });
|
||||
for (const e of entries) {
|
||||
const full = path.join(dir, e.name);
|
||||
if (e.isDirectory()) {
|
||||
if (full.includes(`${path.sep}i18n${path.sep}locales`)) continue;
|
||||
await collectSourceFiles(full, out);
|
||||
} else if (/\.(jsx?|tsx?)$/.test(e.name)) {
|
||||
out.push(full);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Per file: map translation function alias -> i18next namespace.
|
||||
*/
|
||||
function buildAliasMap(source) {
|
||||
const map = new Map();
|
||||
|
||||
const named = /const\s*\{\s*t:\s*(\w+)\s*\}\s*=\s*useTranslation\s*\(\s*['"]([^'"]+)['"]\s*\)/g;
|
||||
let m;
|
||||
while ((m = named.exec(source))) {
|
||||
map.set(m[1], m[2]);
|
||||
}
|
||||
|
||||
const tWithNs =
|
||||
/const\s*\{\s*t\s*\}\s*=\s*useTranslation\s*\(\s*['"]([^'"]+)['"]\s*\)/g;
|
||||
while ((m = tWithNs.exec(source))) {
|
||||
map.set('t', m[1]);
|
||||
}
|
||||
|
||||
const tDefault = /const\s*\{\s*t\s*\}\s*=\s*useTranslation\s*\(\s*\)/g;
|
||||
while (tDefault.exec(source)) {
|
||||
map.set('t', 'translation');
|
||||
}
|
||||
|
||||
// withTranslation HOC: t comes from props (any position in destructuring)
|
||||
const tFromThisProps =
|
||||
/const\s*\{[^}]*\bt(?:\s*:\s*(\w+))?\b[^}]*\}\s*=\s*this\.props/;
|
||||
const mProps = tFromThisProps.exec(source);
|
||||
if (mProps) {
|
||||
map.set(mProps[1] || 't', 'translation');
|
||||
}
|
||||
const tFromProps =
|
||||
/const\s*\{[^}]*\bt(?:\s*:\s*(\w+))?\b[^}]*\}\s*=\s*props\b/;
|
||||
const mP = tFromProps.exec(source);
|
||||
if (mP) {
|
||||
map.set(mP[1] || 't', 'translation');
|
||||
}
|
||||
|
||||
if (!map.has('t')) {
|
||||
map.set('t', 'translation');
|
||||
}
|
||||
|
||||
return map;
|
||||
}
|
||||
|
||||
function escapeRe(s) {
|
||||
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract static keys from t-like calls for known aliases.
|
||||
*/
|
||||
function extractUsedKeysFromSource(source, filePath) {
|
||||
const aliasMap = buildAliasMap(source);
|
||||
const used = new Set();
|
||||
const dynamicHints = [];
|
||||
let m;
|
||||
|
||||
const propsT =
|
||||
/(?:this\.props\.t|props\.t)\(\s*['"]([^'"]+)['"]/g;
|
||||
while ((m = propsT.exec(source))) {
|
||||
used.add(keySet('translation', m[1]));
|
||||
}
|
||||
|
||||
for (const [alias, ns] of aliasMap) {
|
||||
const re = new RegExp(
|
||||
`\\b${escapeRe(alias)}\\(\\s*['"]([^'"]+)['"]`,
|
||||
'g'
|
||||
);
|
||||
while ((m = re.exec(source))) {
|
||||
used.add(keySet(ns, m[1]));
|
||||
}
|
||||
}
|
||||
|
||||
const tplStatic = /\b(\w+)\(\s*`([^`${}]*)`\s*\)/g;
|
||||
while ((m = tplStatic.exec(source))) {
|
||||
const alias = m[1];
|
||||
const key = m[2].trim();
|
||||
if (!key || !aliasMap.has(alias)) continue;
|
||||
if (/^[a-zA-Z][a-zA-Z0-9_.]*$/.test(key)) {
|
||||
used.add(keySet(aliasMap.get(alias), key));
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
/(?:this\.props\.t|props\.t|\bt\w*)\(\s*`[^`]*\$\{/.test(source)
|
||||
) {
|
||||
dynamicHints.push(filePath);
|
||||
}
|
||||
|
||||
return { used, dynamicHints };
|
||||
}
|
||||
|
||||
function mergeSets(into, from) {
|
||||
for (const x of from) into.add(x);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const jsonOut = process.argv.includes('--json');
|
||||
|
||||
const defined = await loadDefinedKeys();
|
||||
const files = await collectSourceFiles(SRC);
|
||||
|
||||
const allUsed = new Set();
|
||||
const dynamicFiles = new Set();
|
||||
|
||||
for (const file of files) {
|
||||
const source = await readFile(file, 'utf8');
|
||||
const { used, dynamicHints } = extractUsedKeysFromSource(source, file);
|
||||
mergeSets(allUsed, used);
|
||||
if (dynamicHints.length) dynamicFiles.add(file);
|
||||
}
|
||||
|
||||
addKnownDynamicLegalKeys(allUsed);
|
||||
propagateEmbeddedCopies(allUsed);
|
||||
|
||||
const unusedByNs = new Map();
|
||||
let totalDefined = 0;
|
||||
let totalUnused = 0;
|
||||
|
||||
for (const [ns, keys] of defined) {
|
||||
const unused = [];
|
||||
for (const k of keys) {
|
||||
totalDefined++;
|
||||
const full = keySet(ns, k);
|
||||
if (!allUsed.has(full)) {
|
||||
unused.push(k);
|
||||
totalUnused++;
|
||||
}
|
||||
}
|
||||
if (unused.length) unusedByNs.set(ns, unused.sort());
|
||||
}
|
||||
|
||||
const orphans = new Set();
|
||||
for (const u of allUsed) {
|
||||
const [ns, keyPath] = u.split('::');
|
||||
const set = defined.get(ns);
|
||||
if (!set || !set.has(keyPath)) {
|
||||
orphans.add(u);
|
||||
}
|
||||
}
|
||||
|
||||
if (jsonOut) {
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
totalDefined,
|
||||
totalUsed: allUsed.size,
|
||||
totalUnused,
|
||||
unusedByNamespace: Object.fromEntries(unusedByNs),
|
||||
usedButNotDefined: [...orphans].sort(),
|
||||
filesWithLikelyDynamicT: [...dynamicFiles].map((f) =>
|
||||
path.relative(ROOT, f)
|
||||
),
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('i18n key usage (locale: de)\n');
|
||||
console.log(
|
||||
'Legal strings exist twice: separate namespaces (AGB, Datenschutz, …) and embedded copies'
|
||||
);
|
||||
console.log(
|
||||
'under translation (legalAgbDelivery.*, …). The latter are marked used when the former are.\n'
|
||||
);
|
||||
console.log(`Defined keys (all namespaces): ${totalDefined}`);
|
||||
console.log(`References after static scan + known dynamic legal patterns: ${allUsed.size}`);
|
||||
console.log(`Unused (best-effort): ${totalUnused}`);
|
||||
console.log(`Used but not in locale files: ${orphans.size}\n`);
|
||||
|
||||
const stillDynamic = [...dynamicFiles].filter(
|
||||
(f) =>
|
||||
![
|
||||
'pages/AGB.js',
|
||||
'pages/Datenschutz.js',
|
||||
'pages/Impressum.js',
|
||||
].includes(path.relative(SRC, f).replace(/\\/g, '/'))
|
||||
);
|
||||
if (stillDynamic.length) {
|
||||
console.log(
|
||||
'Other files with t(`…${…}…`) (may still hide unused keys — not expanded):'
|
||||
);
|
||||
for (const f of stillDynamic.sort()) {
|
||||
console.log(` ${path.relative(ROOT, f)}`);
|
||||
}
|
||||
console.log('');
|
||||
}
|
||||
|
||||
for (const [ns, keys] of [...unusedByNs.entries()].sort((a, b) =>
|
||||
a[0].localeCompare(b[0])
|
||||
)) {
|
||||
console.log(`--- ${ns} (${keys.length} unused) ---`);
|
||||
for (const k of keys) {
|
||||
console.log(` ${k}`);
|
||||
}
|
||||
console.log('');
|
||||
}
|
||||
|
||||
if (orphans.size) {
|
||||
console.log('--- Referenced in code but missing from de locale ---');
|
||||
for (const o of [...orphans].sort()) {
|
||||
console.log(` ${o.replace('::', ' / ')}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(
|
||||
'\nNote: Keys built from arbitrary variables or unknown dynamic templates may still look unused.'
|
||||
);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
Reference in New Issue
Block a user