#!/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( `^`, 'i' ); function findUnsafeHtml(value) { const problems = []; if (/