1
0
forked from baron/baron-sso
Files
baron-sso/tools/i18n-scanner/index.js
2026-02-13 10:23:50 +09:00

225 lines
5.3 KiB
JavaScript

#!/usr/bin/env node
'use strict';
const fs = require('fs');
const path = require('path');
const ROOT_DIR = process.cwd();
const LOCALES_DIR = path.join(ROOT_DIR, 'locales');
const TEMPLATE_PATH = path.join(LOCALES_DIR, 'template.toml');
const LANG_FILES = ['ko.toml', 'en.toml'];
const FAIL_UNUSED = process.argv.includes('--fail-unused');
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;
}
const key = line.slice(0, eqIndex).trim();
if (!key) {
continue;
}
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;
}
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 difference(aSet, bSet) {
const result = [];
for (const item of aSet) {
if (!bSet.has(item)) {
result.push(item);
}
}
return result.sort();
}
function printList(title, items) {
if (items.length === 0) {
return;
}
console.log(`\n${title}`);
for (const item of items) {
console.log(`- ${item}`);
}
}
function main() {
const errors = [];
const warnings = [];
const templateResult = parseTomlKeys(TEMPLATE_PATH);
if (!templateResult.ok) {
errors.push(templateResult.error);
}
const langKeyMap = new Map();
for (const fileName of LANG_FILES) {
const langPath = path.join(LOCALES_DIR, fileName);
const langResult = parseTomlKeys(langPath);
if (!langResult.ok) {
errors.push(langResult.error);
continue;
}
langKeyMap.set(fileName, langResult.keys);
}
if (errors.length > 0) {
console.error('i18n 검증 실패: 필수 리소스 파일을 찾지 못했습니다.');
for (const error of errors) {
console.error(`- ${error}`);
}
process.exit(1);
}
const templateKeys = templateResult.keys;
const codeKeys = collectCodeKeys();
for (const [fileName, langKeys] of langKeyMap.entries()) {
const missingInLang = difference(templateKeys, langKeys);
if (missingInLang.length > 0) {
errors.push(`[Sync Error] ${fileName} 누락 키 ${missingInLang.length}`);
printList(`${fileName}에 없는 키`, missingInLang);
}
}
const missingInTemplate = difference(codeKeys, templateKeys);
if (missingInTemplate.length > 0) {
errors.push(`[Missing Key] template.toml 누락 키 ${missingInTemplate.length}`);
printList('template.toml에 없는 코드 사용 키', missingInTemplate);
}
const unusedInTemplate = difference(templateKeys, codeKeys);
if (unusedInTemplate.length > 0) {
warnings.push(`[Unused Key] template.toml 미사용 키 ${unusedInTemplate.length}`);
printList('코드에서 사용되지 않는 키', unusedInTemplate);
}
if (errors.length > 0) {
console.error('\n요약');
for (const error of errors) {
console.error(`- ${error}`);
}
process.exit(1);
}
if (warnings.length > 0) {
console.warn('\n요약');
for (const warning of warnings) {
console.warn(`- ${warning}`);
}
if (FAIL_UNUSED) {
process.exit(1);
}
}
console.log('\n✅ i18n 검증 완료');
}
main();