forked from baron/baron-sso
286 lines
6.9 KiB
JavaScript
286 lines
6.9 KiB
JavaScript
#!/usr/bin/env node
|
|
'use strict';
|
|
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
|
|
const ROOT_DIR = process.cwd();
|
|
const FAIL_UNUSED = process.argv.includes('--fail-unused');
|
|
|
|
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 printList(title, items) {
|
|
if (items.length === 0) {
|
|
return;
|
|
}
|
|
console.log(`\n${title}`);
|
|
for (const item of items) {
|
|
console.log(`- ${item}`);
|
|
}
|
|
}
|
|
|
|
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 main() {
|
|
const errors = [];
|
|
const warnings = [];
|
|
|
|
const rawCodeKeys = Array.from(collectCodeKeys()).filter(
|
|
(key) => !shouldIgnoreCodeKey(key),
|
|
);
|
|
const codeKeys = new Set(rawCodeKeys);
|
|
|
|
for (const spec of LOCALE_SPECS) {
|
|
const resources = collectSpecResources(spec);
|
|
if (!resources.ok) {
|
|
errors.push(resources.error);
|
|
continue;
|
|
}
|
|
|
|
for (const [fileName, langKeys] of resources.langKeyMap.entries()) {
|
|
const missingInLang = difference(resources.templateKeys, langKeys);
|
|
if (missingInLang.length > 0) {
|
|
errors.push(
|
|
`[Sync Error] ${spec.label} ${fileName} 누락 키 ${missingInLang.length}개`,
|
|
);
|
|
printList(`${spec.label} ${fileName}에 없는 키`, missingInLang);
|
|
}
|
|
}
|
|
|
|
const ownedCodeKeys = new Set(
|
|
rawCodeKeys.filter((key) => spec.ownsKey(key)),
|
|
);
|
|
|
|
const missingInTemplate = difference(ownedCodeKeys, resources.templateKeys);
|
|
if (missingInTemplate.length > 0) {
|
|
errors.push(
|
|
`[Missing Key] ${spec.label} template.toml 누락 키 ${missingInTemplate.length}개`,
|
|
);
|
|
printList(`${spec.label} template.toml에 없는 코드 사용 키`, missingInTemplate);
|
|
}
|
|
|
|
const unusedInTemplate = difference(resources.templateKeys, codeKeys);
|
|
if (unusedInTemplate.length > 0) {
|
|
warnings.push(
|
|
`[Unused Key] ${spec.label} template.toml 미사용 키 ${unusedInTemplate.length}개`,
|
|
);
|
|
printList(`${spec.label} 코드에서 사용되지 않는 키`, 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();
|