mirror of
https://github.com/Kareadita/Kavita.git
synced 2026-01-24 20:57:11 -05:00
Co-authored-by: Amelia <77553571+Fesaa@users.noreply.github.com> Co-authored-by: DieselTech <30128380+DieselTech@users.noreply.github.com> Co-authored-by: Alex George <xzeroknightx@gmail.com> Co-authored-by: Lucas Winther <lucasw89@live.dk> Co-authored-by: Toni Kielo <toni.kielo@gmail.com> Co-authored-by: Patrick Orave <oravep@gmail.com>
340 lines
12 KiB
JavaScript
340 lines
12 KiB
JavaScript
const fs = require('fs');
|
|
const path = require('path');
|
|
|
|
// Configuration
|
|
const CONFIG = {
|
|
i18nFile: './src/assets/langs/en.json',
|
|
searchDirs: ['./src'],
|
|
extensions: ['.ts', '.html'],
|
|
// Namespaces to skip entirely (dynamic usage too complex to trace)
|
|
blacklistedNamespaces: [
|
|
'month-label-pipe',
|
|
'ordinal-date-pipe',
|
|
],
|
|
// Patterns that reference i18n keys
|
|
usagePatterns: [
|
|
// Transloco patterns
|
|
/(?:^|[^a-zA-Z0-9_])t\(['"`]([a-zA-Z0-9._-]+)['"`]/g, // t('key') or t('key', {params})
|
|
/translocoService\.translate\(['"`]([a-zA-Z0-9._-]+)['"`]/g, // translocoService.translate('key')
|
|
/translocoService\.selectTranslate\(['"`]([a-zA-Z0-9._-]+)['"`]/g,
|
|
/(?:^|[^a-zA-Z0-9_.])translate\(['"`]([a-zA-Z0-9._-]+)['"`]/g, // translate('key') standalone function
|
|
/transloco\.translate\(['"`]([a-zA-Z0-9._-]+)['"`]/g, // this.transloco.translate('key')
|
|
/\[transloco\]=['"`]([a-zA-Z0-9._-]+)['"`]/g, // [transloco]="key"
|
|
|
|
// Template literal translate: translate(`namespace.${var}`) or translate(`key`)
|
|
/translate\(`([a-zA-Z0-9._-]+)`\)/g,
|
|
/transloco\.translate\(`([a-zA-Z0-9._-]+)\./g, // transloco.translate(`namespace. - captures namespace
|
|
|
|
// ngx-translate fallback patterns
|
|
/['"`]([a-zA-Z0-9._-]+)['"]\s*\|\s*translate/g,
|
|
/translate\.instant\(['"`]([a-zA-Z0-9._-]+)['"`]/g,
|
|
/translate\.get\(['"`]([a-zA-Z0-9._-]+)['"`]/g,
|
|
],
|
|
// Pattern for interpolated keys within i18n values: {{key.path}}
|
|
interpolationPattern: /\{\{([a-zA-Z0-9._-]+)\}\}/g,
|
|
// Patterns indicating dynamic key construction - mark whole namespace as used
|
|
dynamicKeyPatterns: [
|
|
/translate\(['"`]([a-zA-Z0-9._-]+)\.\s*['"`]\s*\+/g, // translate('namespace.' +
|
|
/t\(['"`]([a-zA-Z0-9._-]+)\.\s*['"`]\s*\+/g, // t('namespace.' +
|
|
/translate\([a-zA-Z0-9_]+\s*\+\s*['"`]\.([a-zA-Z0-9._-]+)['"`]/g, // translate(variable + '.suffix') - captures suffix
|
|
/\+\s*['"`]\.([a-zA-Z0-9_-]+)['"]\s*[,)]/g, // + '.suffix', or + '.suffix') - generic dynamic suffix
|
|
/translate\(`([a-zA-Z0-9._-]+)\.\$\{/g, // translate(`namespace.${...}`) - template literal dynamic
|
|
/transloco\.translate\(`([a-zA-Z0-9._-]+)\.\$\{/g, // this.transloco.translate(`namespace.${...}`)
|
|
],
|
|
};
|
|
|
|
// Check if a key belongs to a blacklisted namespace
|
|
function isBlacklisted(key) {
|
|
for (const ns of CONFIG.blacklistedNamespaces) {
|
|
if (key === ns || key.startsWith(ns + '.')) {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
// Flatten nested JSON to dot-notation keys
|
|
function flattenKeys(obj, prefix = '') {
|
|
const keys = [];
|
|
for (const [k, v] of Object.entries(obj)) {
|
|
const fullKey = prefix ? `${prefix}.${k}` : k;
|
|
if (typeof v === 'object' && v !== null && !Array.isArray(v)) {
|
|
keys.push(...flattenKeys(v, fullKey));
|
|
} else {
|
|
keys.push({ key: fullKey, value: v });
|
|
}
|
|
}
|
|
return keys;
|
|
}
|
|
|
|
// Recursively get all files with given extensions
|
|
function getFiles(dir, exts, files = []) {
|
|
if (!fs.existsSync(dir)) return files;
|
|
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
const fullPath = path.join(dir, entry.name);
|
|
if (entry.isDirectory() && !entry.name.startsWith('.') && entry.name !== 'node_modules') {
|
|
getFiles(fullPath, exts, files);
|
|
} else if (entry.isFile() && exts.includes(path.extname(entry.name))) {
|
|
files.push(fullPath);
|
|
}
|
|
}
|
|
return files;
|
|
}
|
|
|
|
// Extract Transloco prefixes from a file (and its sibling template/component)
|
|
function extractPrefixes(filePath, content, fileContentsCache) {
|
|
const prefixes = [];
|
|
|
|
// *transloco="let t; prefix: 'namespace'"
|
|
const structuralPattern = /\*transloco\s*=\s*["'][^"']*prefix\s*:\s*['"]([a-zA-Z0-9._-]+)['"][^"']*["']/g;
|
|
let match;
|
|
while ((match = structuralPattern.exec(content)) !== null) {
|
|
prefixes.push(match[1]);
|
|
}
|
|
|
|
// Also check for @Component transloco scope in .ts files
|
|
// providers: [{ provide: TRANSLOCO_SCOPE, useValue: 'namespace' }]
|
|
const scopePattern = /TRANSLOCO_SCOPE\s*,\s*useValue\s*:\s*['"]([a-zA-Z0-9._-]+)['"]/g;
|
|
while ((match = scopePattern.exec(content)) !== null) {
|
|
prefixes.push(match[1]);
|
|
}
|
|
|
|
// If this is a .ts file, check sibling .html for prefix
|
|
if (filePath.endsWith('.ts')) {
|
|
const htmlPath = filePath.replace(/\.ts$/, '.html');
|
|
const htmlContent = fileContentsCache.get(htmlPath);
|
|
if (htmlContent) {
|
|
structuralPattern.lastIndex = 0;
|
|
while ((match = structuralPattern.exec(htmlContent)) !== null) {
|
|
prefixes.push(match[1]);
|
|
}
|
|
}
|
|
}
|
|
|
|
// If this is a .html file, check sibling .ts for TRANSLOCO_SCOPE
|
|
if (filePath.endsWith('.html')) {
|
|
const tsPath = filePath.replace(/\.html$/, '.ts');
|
|
const tsContent = fileContentsCache.get(tsPath);
|
|
if (tsContent) {
|
|
scopePattern.lastIndex = 0;
|
|
while ((match = scopePattern.exec(tsContent)) !== null) {
|
|
prefixes.push(match[1]);
|
|
}
|
|
}
|
|
}
|
|
|
|
return [...new Set(prefixes)]; // dedupe
|
|
}
|
|
|
|
// Extract namespaces that use dynamic key construction
|
|
function extractDynamicNamespaces(content, allI18nKeys) {
|
|
const namespaces = new Set();
|
|
|
|
// Pattern: translate('namespace.' + or t('namespace.' +
|
|
for (const pattern of CONFIG.dynamicKeyPatterns.slice(0, 2)) {
|
|
pattern.lastIndex = 0;
|
|
let match;
|
|
while ((match = pattern.exec(content)) !== null) {
|
|
namespaces.add(match[1]);
|
|
}
|
|
}
|
|
|
|
// Pattern: translate(variable + '.suffix') - find all namespaces that have this suffix
|
|
for (const pattern of CONFIG.dynamicKeyPatterns.slice(2)) {
|
|
pattern.lastIndex = 0;
|
|
let match;
|
|
while ((match = pattern.exec(content)) !== null) {
|
|
const suffix = match[1];
|
|
// Find all namespaces that contain a key ending with this suffix
|
|
for (const key of allI18nKeys) {
|
|
if (key.endsWith('.' + suffix)) {
|
|
const ns = key.substring(0, key.lastIndexOf('.'));
|
|
// Only add top-level namespace
|
|
const topNs = ns.includes('.') ? ns.split('.')[0] : ns;
|
|
namespaces.add(topNs);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return namespaces;
|
|
}
|
|
|
|
// Extract all i18n key usages from source files
|
|
function findUsedKeys(files, allI18nKeys, debug = false) {
|
|
const used = new Set();
|
|
const dynamicNamespaces = new Set();
|
|
|
|
// Pre-load all file contents for sibling lookup
|
|
const fileContentsCache = new Map();
|
|
for (const file of files) {
|
|
fileContentsCache.set(file, fs.readFileSync(file, 'utf-8'));
|
|
}
|
|
|
|
for (const file of files) {
|
|
const content = fileContentsCache.get(file);
|
|
const prefixes = extractPrefixes(file, content, fileContentsCache);
|
|
|
|
// Debug: check specific file
|
|
if (debug && file.includes('metadata-filter-row')) {
|
|
console.log(`\n[DEBUG] File: ${file}`);
|
|
console.log(`[DEBUG] Prefixes found: ${JSON.stringify(prefixes)}`);
|
|
console.log(`[DEBUG] Contains 'disclaimer-file-size': ${content.includes('disclaimer-file-size')}`);
|
|
}
|
|
|
|
// Collect dynamic namespaces
|
|
for (const ns of extractDynamicNamespaces(content, allI18nKeys)) {
|
|
dynamicNamespaces.add(ns);
|
|
}
|
|
|
|
// Apply regex patterns
|
|
for (const pattern of CONFIG.usagePatterns) {
|
|
pattern.lastIndex = 0;
|
|
let match;
|
|
while ((match = pattern.exec(content)) !== null) {
|
|
if (match[1]) {
|
|
const key = match[1];
|
|
used.add(key);
|
|
for (const prefix of prefixes) {
|
|
used.add(`${prefix}.${key}`);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Direct string matching: check if any i18n key appears as a quoted string
|
|
for (const key of allI18nKeys) {
|
|
const shortKey = key.includes('.') ? key.split('.').slice(1).join('.') : key;
|
|
|
|
if (content.includes(`'${key}'`) ||
|
|
content.includes(`"${key}"`) ||
|
|
content.includes(`\`${key}\``)) {
|
|
used.add(key);
|
|
}
|
|
// Also check for the unprefixed version with valid prefixes
|
|
if (shortKey !== key && prefixes.length > 0) {
|
|
if (content.includes(`'${shortKey}'`) ||
|
|
content.includes(`"${shortKey}"`) ||
|
|
content.includes(`\`${shortKey}\``)) {
|
|
for (const prefix of prefixes) {
|
|
if (key.startsWith(prefix + '.')) {
|
|
used.add(key);
|
|
if (debug && key.includes('disclaimer-file-size')) {
|
|
console.log(`[DEBUG] Matched ${key} via prefix ${prefix} in ${file}`);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Mark all keys under dynamic namespaces as used
|
|
for (const key of allI18nKeys) {
|
|
for (const ns of dynamicNamespaces) {
|
|
if (key.startsWith(ns + '.')) {
|
|
used.add(key);
|
|
}
|
|
}
|
|
}
|
|
|
|
return used;
|
|
}
|
|
|
|
// Extract keys referenced via interpolation in i18n values
|
|
function findInterpolatedKeys(entries) {
|
|
const referenced = new Set();
|
|
for (const { value } of entries) {
|
|
if (typeof value !== 'string') continue;
|
|
let match;
|
|
CONFIG.interpolationPattern.lastIndex = 0;
|
|
while ((match = CONFIG.interpolationPattern.exec(value)) !== null) {
|
|
referenced.add(match[1]);
|
|
}
|
|
}
|
|
return referenced;
|
|
}
|
|
|
|
// Check if a key or any of its ancestors is used (prefix matching)
|
|
function isKeyUsed(key, usedKeys) {
|
|
// Direct match
|
|
if (usedKeys.has(key)) return true;
|
|
// Check if this key is a prefix of any used key (parent namespace)
|
|
for (const used of usedKeys) {
|
|
if (used.startsWith(key + '.')) return true;
|
|
}
|
|
// Check if any used key is a prefix (dynamic access pattern)
|
|
for (const used of usedKeys) {
|
|
if (key.startsWith(used + '.')) return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
// Group dead keys by their parent namespace
|
|
function groupByParent(deadKeys) {
|
|
const grouped = {};
|
|
for (const key of deadKeys) {
|
|
const parts = key.split('.');
|
|
if (parts.length === 1) {
|
|
grouped['_root'] = grouped['_root'] || [];
|
|
grouped['_root'].push(key);
|
|
} else {
|
|
const leaf = parts.pop();
|
|
const parent = parts.join('.');
|
|
grouped[parent] = grouped[parent] || [];
|
|
grouped[parent].push(leaf);
|
|
}
|
|
}
|
|
return grouped;
|
|
}
|
|
|
|
// Main
|
|
function main() {
|
|
const DEBUG = process.argv.includes('--debug');
|
|
|
|
console.log('Loading i18n file...');
|
|
const i18n = JSON.parse(fs.readFileSync(CONFIG.i18nFile, 'utf-8'));
|
|
const entries = flattenKeys(i18n);
|
|
const allKeys = new Set(entries.map(e => e.key));
|
|
console.log(`Found ${allKeys.size} i18n keys`);
|
|
|
|
console.log('Scanning source files...');
|
|
const files = CONFIG.searchDirs.flatMap(dir => getFiles(dir, CONFIG.extensions));
|
|
console.log(`Scanning ${files.length} files...`);
|
|
|
|
const usedInCode = findUsedKeys(files, [...allKeys], DEBUG);
|
|
console.log(`Found ${usedInCode.size} key references in code`);
|
|
|
|
const interpolated = findInterpolatedKeys(entries);
|
|
console.log(`Found ${interpolated.size} interpolated key references`);
|
|
|
|
const allUsed = new Set([...usedInCode, ...interpolated]);
|
|
|
|
console.log('Finding dead keys...');
|
|
const deadKeys = [];
|
|
for (const key of allKeys) {
|
|
if (isBlacklisted(key)) continue;
|
|
if (!isKeyUsed(key, allUsed)) {
|
|
deadKeys.push(key);
|
|
}
|
|
}
|
|
|
|
console.log(`\nFound ${deadKeys.length} potentially dead keys`);
|
|
|
|
const result = groupByParent(deadKeys.sort());
|
|
|
|
const outputFile = 'dead-i18n-keys.json';
|
|
fs.writeFileSync(outputFile, JSON.stringify(result, null, 2));
|
|
console.log(`Results written to ${outputFile}`);
|
|
|
|
// Also print summary
|
|
if (deadKeys.length > 0) {
|
|
console.log('\nDead keys by namespace:');
|
|
for (const [ns, keys] of Object.entries(result)) {
|
|
console.log(` ${ns}: ${keys.length} keys`);
|
|
}
|
|
}
|
|
}
|
|
|
|
main();
|