#!/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); });