mirror of
https://github.com/alam00000/bentopdf.git
synced 2026-11-05 20:02:47 +01:00
347 lines
8.9 KiB
JavaScript
Executable File
347 lines
8.9 KiB
JavaScript
Executable File
#!/usr/bin/env node
|
|
|
|
/**
|
|
* Translation Checker for BentoPDF
|
|
*
|
|
* This script compares translation files across languages and reports:
|
|
* - Missing keys (keys present in English but absent in other languages)
|
|
* - Extra keys (keys present in other languages but not in English)
|
|
* - Untranslated keys (keys with the same value as English)
|
|
*
|
|
* Usage:
|
|
* node scripts/check-translations.js
|
|
* node scripts/check-translations.js --verbose
|
|
* node scripts/check-translations.js --lang=de
|
|
*/
|
|
|
|
import fs from 'fs';
|
|
import path from 'path';
|
|
import { fileURLToPath } from 'url';
|
|
|
|
const __filename = fileURLToPath(import.meta.url);
|
|
const __dirname = path.dirname(__filename);
|
|
|
|
const LOCALES_DIR = path.join(__dirname, '../public/locales');
|
|
const REFERENCE_LANG = 'en';
|
|
|
|
const colors = {
|
|
reset: '\x1b[0m',
|
|
red: '\x1b[31m',
|
|
green: '\x1b[32m',
|
|
yellow: '\x1b[33m',
|
|
blue: '\x1b[34m',
|
|
cyan: '\x1b[36m',
|
|
dim: '\x1b[2m',
|
|
};
|
|
|
|
const args = process.argv.slice(2);
|
|
const verbose = args.includes('--verbose');
|
|
const specificLang = args
|
|
.find((arg) => arg.startsWith('--lang='))
|
|
?.split('=')[1];
|
|
|
|
/**
|
|
* Flatten nested JSON object into dot notation
|
|
* { a: { b: 'value' } } => { 'a.b': 'value' }
|
|
*/
|
|
function flattenObject(obj, prefix = '') {
|
|
const flattened = {};
|
|
|
|
for (const [key, value] of Object.entries(obj)) {
|
|
const newKey = prefix ? `${prefix}.${key}` : key;
|
|
|
|
if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
|
|
Object.assign(flattened, flattenObject(value, newKey));
|
|
} else {
|
|
flattened[newKey] = value;
|
|
}
|
|
}
|
|
|
|
return flattened;
|
|
}
|
|
|
|
/**
|
|
* Load and parse a translation file
|
|
*/
|
|
function loadTranslation(lang) {
|
|
const filePath = path.join(LOCALES_DIR, lang, 'common.json');
|
|
|
|
if (!fs.existsSync(filePath)) {
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
const content = fs.readFileSync(filePath, 'utf8');
|
|
return JSON.parse(content);
|
|
} catch (error) {
|
|
console.error(
|
|
`${colors.red}✗ Error parsing ${lang}/common.json:${colors.reset}`,
|
|
error.message
|
|
);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get all available languages
|
|
*/
|
|
function getAvailableLanguages() {
|
|
if (!fs.existsSync(LOCALES_DIR)) {
|
|
console.error(
|
|
`${colors.red}✗ Locales directory not found: ${LOCALES_DIR}${colors.reset}`
|
|
);
|
|
process.exit(1);
|
|
}
|
|
|
|
return fs
|
|
.readdirSync(LOCALES_DIR)
|
|
.filter((item) => {
|
|
const itemPath = path.join(LOCALES_DIR, item);
|
|
return fs.statSync(itemPath).isDirectory();
|
|
})
|
|
.filter((lang) => {
|
|
// Only include if common.json exists
|
|
return fs.existsSync(path.join(LOCALES_DIR, lang, 'common.json'));
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Compare two sets of keys and report differences
|
|
*/
|
|
function compareKeys(
|
|
refKeys,
|
|
targetKeys,
|
|
refLang,
|
|
targetLang,
|
|
refFlat,
|
|
targetFlat
|
|
) {
|
|
const missing = refKeys.filter((key) => !targetKeys.includes(key));
|
|
const extra = targetKeys.filter((key) => !refKeys.includes(key));
|
|
const untranslated = refKeys.filter(
|
|
(key) =>
|
|
targetKeys.includes(key) &&
|
|
refFlat[key] === targetFlat[key] &&
|
|
typeof refFlat[key] === 'string'
|
|
);
|
|
|
|
return { missing, extra, untranslated };
|
|
}
|
|
|
|
/**
|
|
* Print section header
|
|
*/
|
|
function printHeader(text) {
|
|
console.log(`\n${colors.cyan}${'='.repeat(60)}${colors.reset}`);
|
|
console.log(`${colors.cyan}${text}${colors.reset}`);
|
|
console.log(`${colors.cyan}${'='.repeat(60)}${colors.reset}\n`);
|
|
}
|
|
|
|
/**
|
|
* Print section
|
|
*/
|
|
function printSection(title, items, color = colors.yellow) {
|
|
if (items.length === 0) return;
|
|
|
|
console.log(`${color}${title} (${items.length}):${colors.reset}`);
|
|
items.forEach((item) => {
|
|
console.log(` ${colors.dim}•${colors.reset} ${item}`);
|
|
});
|
|
console.log();
|
|
}
|
|
|
|
const ALLOWED_HTML_TAGS = ['br', 'strong', 'em'];
|
|
const ALLOWED_TAG = new RegExp(
|
|
`^</?(?:${ALLOWED_HTML_TAGS.join('|')})\\s*/?>`,
|
|
'i'
|
|
);
|
|
|
|
function findUnsafeHtml(value) {
|
|
const problems = [];
|
|
if (/<script/i.test(value)) problems.push('<script');
|
|
if (/javascript:/i.test(value)) problems.push('javascript:');
|
|
if (/\bon[a-z]+\s*=/i.test(value)) problems.push('on* attribute');
|
|
for (const match of value.matchAll(/<[a-z/!?]/gi)) {
|
|
const markup = value.slice(match.index);
|
|
if (!ALLOWED_TAG.test(markup)) {
|
|
problems.push(`disallowed markup "${markup.slice(0, 40)}"`);
|
|
}
|
|
}
|
|
return problems;
|
|
}
|
|
|
|
function collectStrings(value, keyPath, out) {
|
|
if (typeof value === 'string') {
|
|
out.push([keyPath, value]);
|
|
} else if (value && typeof value === 'object') {
|
|
for (const [key, child] of Object.entries(value)) {
|
|
collectStrings(child, keyPath ? `${keyPath}.${key}` : key, out);
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function checkHtmlSafety(langs) {
|
|
const findings = [];
|
|
for (const lang of langs) {
|
|
const dir = path.join(LOCALES_DIR, lang);
|
|
for (const file of fs.readdirSync(dir).filter((f) => f.endsWith('.json'))) {
|
|
let data;
|
|
try {
|
|
data = JSON.parse(fs.readFileSync(path.join(dir, file), 'utf8'));
|
|
} catch (error) {
|
|
findings.push(`${lang}/${file}: invalid JSON (${error.message})`);
|
|
continue;
|
|
}
|
|
for (const [keyPath, value] of collectStrings(data, '', [])) {
|
|
for (const problem of findUnsafeHtml(value)) {
|
|
findings.push(`${lang}/${file} ${keyPath}: ${problem}`);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return findings;
|
|
}
|
|
|
|
/**
|
|
* Main function
|
|
*/
|
|
function main() {
|
|
console.log(`${colors.blue}🌍 BentoPDF Translation Checker${colors.reset}\n`);
|
|
|
|
const languages = getAvailableLanguages();
|
|
|
|
if (languages.length === 0) {
|
|
console.error(
|
|
`${colors.red}✗ No translation files found in ${LOCALES_DIR}${colors.reset}`
|
|
);
|
|
process.exit(1);
|
|
}
|
|
|
|
// Load reference language (English)
|
|
const refTranslation = loadTranslation(REFERENCE_LANG);
|
|
if (!refTranslation) {
|
|
console.error(
|
|
`${colors.red}✗ Reference language (${REFERENCE_LANG}) not found${colors.reset}`
|
|
);
|
|
process.exit(1);
|
|
}
|
|
|
|
const refFlat = flattenObject(refTranslation);
|
|
const refKeys = Object.keys(refFlat);
|
|
|
|
console.log(
|
|
`${colors.green}✓ Reference language (${REFERENCE_LANG}): ${refKeys.length} keys${colors.reset}`
|
|
);
|
|
console.log(
|
|
`${colors.dim} Available languages: ${languages.join(', ')}${colors.reset}\n`
|
|
);
|
|
|
|
// Filter languages to check
|
|
const langsToCheck = specificLang
|
|
? languages.filter((lang) => lang === specificLang)
|
|
: languages.filter((lang) => lang !== REFERENCE_LANG);
|
|
|
|
if (langsToCheck.length === 0) {
|
|
console.log(`${colors.yellow}⚠ No languages to check${colors.reset}`);
|
|
process.exit(0);
|
|
}
|
|
|
|
let hasIssues = false;
|
|
|
|
// Check each language
|
|
for (const lang of langsToCheck) {
|
|
printHeader(`Checking: ${lang.toUpperCase()}`);
|
|
|
|
const translation = loadTranslation(lang);
|
|
if (!translation) {
|
|
hasIssues = true;
|
|
continue;
|
|
}
|
|
|
|
const targetFlat = flattenObject(translation);
|
|
const targetKeys = Object.keys(targetFlat);
|
|
|
|
const { missing, extra, untranslated } = compareKeys(
|
|
refKeys,
|
|
targetKeys,
|
|
REFERENCE_LANG,
|
|
lang,
|
|
refFlat,
|
|
targetFlat
|
|
);
|
|
|
|
// Summary
|
|
console.log(
|
|
`${colors.dim}Total keys: ${targetKeys.length} / ${refKeys.length}${colors.reset}\n`
|
|
);
|
|
|
|
// Missing keys
|
|
if (missing.length > 0) {
|
|
hasIssues = true;
|
|
printSection(`Missing Keys`, missing, colors.red);
|
|
}
|
|
|
|
// Extra keys
|
|
if (extra.length > 0) {
|
|
hasIssues = true;
|
|
printSection(`Extra Keys (not in English)`, extra, colors.yellow);
|
|
}
|
|
|
|
// Untranslated keys (same as English)
|
|
if (verbose && untranslated.length > 0) {
|
|
printSection(
|
|
`Possibly Untranslated (same as English)`,
|
|
untranslated,
|
|
colors.cyan
|
|
);
|
|
} else if (untranslated.length > 0) {
|
|
console.log(
|
|
`${colors.cyan}Possibly Untranslated: ${untranslated.length}${colors.reset}`
|
|
);
|
|
console.log(
|
|
`${colors.dim} (use --verbose to see details)${colors.reset}\n`
|
|
);
|
|
}
|
|
|
|
// All good
|
|
if (missing.length === 0 && extra.length === 0) {
|
|
console.log(`${colors.green} No missing or extra keys!${colors.reset}\n`);
|
|
}
|
|
}
|
|
|
|
const htmlFindings = checkHtmlSafety(
|
|
specificLang ? languages.filter((lang) => lang === specificLang) : languages
|
|
);
|
|
if (htmlFindings.length > 0) {
|
|
hasIssues = true;
|
|
printHeader('Unsafe HTML in translations');
|
|
printSection(
|
|
`Allowed tags: ${ALLOWED_HTML_TAGS.join(', ')} (no attributes)`,
|
|
htmlFindings,
|
|
colors.red
|
|
);
|
|
} else {
|
|
console.log(
|
|
`${colors.green} No unsafe HTML in translations (allowed tags: ${ALLOWED_HTML_TAGS.join(', ')})${colors.reset}\n`
|
|
);
|
|
}
|
|
|
|
// Final summary
|
|
console.log(`${colors.cyan}${'='.repeat(60)}${colors.reset}\n`);
|
|
|
|
if (!hasIssues) {
|
|
console.log(
|
|
`${colors.green} All translations are in sync!${colors.reset}\n`
|
|
);
|
|
process.exit(0);
|
|
} else {
|
|
console.log(
|
|
`${colors.yellow} Issues found. Please review the output above.${colors.reset}\n`
|
|
);
|
|
process.exit(1);
|
|
}
|
|
}
|
|
|
|
main();
|