#!/usr/bin/env node 'use strict'; const fs = require('fs'); const path = require('path'); const ROOT_DIR = process.cwd(); const LOCALE_SPECS = [ { name: 'root', label: 'root locales', dir: path.join(ROOT_DIR, 'locales'), template: 'template.toml', langs: ['ko.toml', 'en.toml'], ownsKey: (key) => !key.startsWith('ui.common.') && !key.startsWith('msg.common.'), }, { name: 'common', label: 'common locales', dir: path.join(ROOT_DIR, 'common', 'locales'), template: 'template.toml', langs: ['ko.toml', 'en.toml'], ownsKey: (key) => key.startsWith('ui.common.') || key.startsWith('msg.common.'), }, ]; const SKIP_DIRS = new Set([ '.git', 'node_modules', 'dist', 'build', '.dart_tool', '.idea', '.vscode', 'coverage', '.next', '.cache', 'tmp', 'logs', ]); const CODE_EXTENSIONS = new Set(['.ts', '.tsx', '.dart']); const CODE_PATTERNS = [ /\b(?:i18n\.)?t\s*\(\s*['"]([^'"]+)['"]/g, /\btr\s*\(\s*['"]([^'"]+)['"]/g, /['"]([^'"]+)['"]\s*\.tr\s*\(/g, ]; function readFileRequired(filePath) { if (!fs.existsSync(filePath)) { return { ok: false, error: `파일이 없습니다: ${filePath}` }; } return { ok: true, value: fs.readFileSync(filePath, 'utf8') }; } function parseTomlKeys(filePath) { const result = readFileRequired(filePath); if (!result.ok) { return { ok: false, error: result.error, keys: new Set() }; } const keys = new Set(); const lines = result.value.split(/\r?\n/); let currentSection = []; for (const rawLine of lines) { const line = rawLine.trim(); if (!line || line.startsWith('#')) { continue; } if (line.startsWith('[[') && line.endsWith(']]')) { const sectionName = line.slice(2, -2).trim(); currentSection = sectionName ? sectionName.split('.').map((p) => p.trim()).filter(Boolean) : []; continue; } if (line.startsWith('[') && line.endsWith(']')) { const sectionName = line.slice(1, -1).trim(); currentSection = sectionName ? sectionName.split('.').map((p) => p.trim()).filter(Boolean) : []; continue; } const eqIndex = line.indexOf('='); if (eqIndex === -1) { continue; } let key = line.slice(0, eqIndex).trim(); if (!key) { continue; } if (key.startsWith('"') && key.endsWith('"')) { key = key.slice(1, -1); } const fullKey = [...currentSection, key].join('.'); keys.add(fullKey); } return { ok: true, keys }; } function walkDir(dirPath, files) { const entries = fs.readdirSync(dirPath, { withFileTypes: true }); for (const entry of entries) { if (entry.isDirectory()) { if (SKIP_DIRS.has(entry.name)) { continue; } walkDir(path.join(dirPath, entry.name), files); continue; } if (!entry.isFile()) { continue; } const ext = path.extname(entry.name).toLowerCase(); if (!CODE_EXTENSIONS.has(ext)) { continue; } if (entry.name.includes('.test.') || entry.name.includes('.spec.')) { continue; } files.push(path.join(dirPath, entry.name)); } } function collectCodeKeys() { const files = []; walkDir(ROOT_DIR, files); const keys = new Set(); for (const filePath of files) { const content = fs.readFileSync(filePath, 'utf8'); for (const pattern of CODE_PATTERNS) { let match; while ((match = pattern.exec(content)) !== null) { if (match[1]) { keys.add(match[1]); } } } } return keys; } function shouldIgnoreCodeKey(key) { return ( key.includes('.msg.') || key.includes('.ui.') || key.includes('.err.') || key.includes('.test.') || key.includes('.non.') || key.startsWith('ui.admin.users.list.table.') || key.startsWith('msg.admin.users.detail.') || key.startsWith('msg.dev.clients.') || key.startsWith('ui.admin.users.create.') || key.startsWith('ui.admin.users.detail.') || key.startsWith('ui.dev.clients.') || key.startsWith('ui.dev.session.') ); } function difference(aSet, bSet) { const result = []; for (const item of aSet) { if (!bSet.has(item)) { result.push(item); } } return result.sort(); } function collectSpecResources(spec) { const templatePath = path.join(spec.dir, spec.template); const templateResult = parseTomlKeys(templatePath); if (!templateResult.ok) { return { ok: false, error: templateResult.error }; } const langKeyMap = new Map(); for (const fileName of spec.langs) { const langPath = path.join(spec.dir, fileName); const langResult = parseTomlKeys(langPath); if (!langResult.ok) { return { ok: false, error: langResult.error }; } langKeyMap.set(fileName, langResult.keys); } return { ok: true, templateKeys: templateResult.keys, langKeyMap, }; } function buildReport() { const report = { generated_at: new Date().toISOString(), errors: [], warnings: [], details: {}, }; const rawCodeKeys = Array.from(collectCodeKeys()).filter( (key) => !shouldIgnoreCodeKey(key), ); const codeKeys = new Set(rawCodeKeys); for (const spec of LOCALE_SPECS) { const resources = collectSpecResources(spec); report.details[spec.name] = { missing_in_template: [], missing_in_lang: {}, unused_in_template: [], }; if (!resources.ok) { report.errors.push(resources.error); continue; } for (const [fileName, langKeys] of resources.langKeyMap.entries()) { const missingInLang = difference(resources.templateKeys, langKeys); if (missingInLang.length > 0) { report.errors.push( `[Sync Error] ${spec.label} ${fileName} 누락 키 ${missingInLang.length}개`, ); report.details[spec.name].missing_in_lang[fileName] = missingInLang; } } const ownedCodeKeys = new Set(rawCodeKeys.filter((key) => spec.ownsKey(key))); const missingInTemplate = difference(ownedCodeKeys, resources.templateKeys); if (missingInTemplate.length > 0) { report.errors.push( `[Missing Key] ${spec.label} template.toml 누락 키 ${missingInTemplate.length}개`, ); report.details[spec.name].missing_in_template = missingInTemplate; } const unusedInTemplate = difference(resources.templateKeys, codeKeys); if (unusedInTemplate.length > 0) { report.warnings.push( `[Unused Key] ${spec.label} template.toml 미사용 키 ${unusedInTemplate.length}개`, ); report.details[spec.name].unused_in_template = unusedInTemplate; } } return report; } function main() { const report = buildReport(); const outDir = path.join(ROOT_DIR, 'reports'); if (!fs.existsSync(outDir)) { fs.mkdirSync(outDir, { recursive: true }); } const outPath = path.join(outDir, 'i18n-report.json'); fs.writeFileSync(outPath, JSON.stringify(report, null, 2)); const summaryPath = path.join(outDir, 'i18n-report.txt'); const lines = []; lines.push(`generated_at: ${report.generated_at}`); if (report.errors.length > 0) { lines.push('errors:'); report.errors.forEach((err) => lines.push(`- ${err}`)); } else { lines.push('errors: none'); } if (report.warnings.length > 0) { lines.push('warnings:'); report.warnings.forEach((warn) => lines.push(`- ${warn}`)); } else { lines.push('warnings: none'); } fs.writeFileSync(summaryPath, lines.join('\n')); if (report.errors.length > 0) { console.error('❌ i18n report generated with errors'); process.exit(1); } console.log(`✅ i18n report written to ${outPath}`); console.log(`✅ i18n summary written to ${summaryPath}`); } main();