366 lines
8.3 KiB
JavaScript
366 lines
8.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 TARGET_FILES = ['ko.toml', 'en.toml'];
|
|
|
|
const PLACEHOLDER_VALUES = new Set([
|
|
'title',
|
|
'subtitle',
|
|
'description',
|
|
'body',
|
|
'note',
|
|
'footer',
|
|
'empty',
|
|
'error',
|
|
'count',
|
|
'loading',
|
|
'name required',
|
|
'scope required',
|
|
'scopes count',
|
|
'scopes hint',
|
|
'delete confirm',
|
|
'fetch error',
|
|
'create success',
|
|
'create failed',
|
|
'create error',
|
|
'delete success',
|
|
'delete error',
|
|
'remove confirm',
|
|
'remove success',
|
|
'add success',
|
|
'copy hint',
|
|
'notice',
|
|
'notice emphasis',
|
|
'notice suffix',
|
|
'idp policy',
|
|
'idp body',
|
|
'tenant body',
|
|
'approve confirm',
|
|
'approve success',
|
|
'user id',
|
|
'missing',
|
|
'consent accept',
|
|
'consent fetch',
|
|
'consent reject',
|
|
'linked app revoke',
|
|
'login failed',
|
|
'password reset complete',
|
|
'password reset init',
|
|
'load failed',
|
|
'send code failed',
|
|
'update failed',
|
|
'verify code failed',
|
|
'text',
|
|
]);
|
|
|
|
const KO_UNTRANSLATED_VALUES = new Set([
|
|
'End of audit feed',
|
|
'Error loading logs: {{error}}',
|
|
'Loading audit logs...',
|
|
'Error loading clients: {{error}}',
|
|
'Loading apps...',
|
|
'Showing {{shown}} of {{total}} apps',
|
|
'No consents found.',
|
|
'Error loading consents: {{error}}',
|
|
'Loading consents...',
|
|
'Showing {{from}} to {{to}} of {{total}} users',
|
|
'Error loading client: {{error}}',
|
|
'Loading client...',
|
|
'Advanced Filters',
|
|
'Active Grants',
|
|
'Avg. Scopes per User',
|
|
'Total Scopes Issued',
|
|
'Action',
|
|
'First Granted',
|
|
'Last Authenticated',
|
|
'Granted Scopes',
|
|
'Status',
|
|
'Tenant',
|
|
'User',
|
|
'Client ID',
|
|
'Client Secret',
|
|
'Redirect URIs',
|
|
'Application Identity',
|
|
'App Logo URL',
|
|
'Logo Preview',
|
|
'My Awesome Application',
|
|
'Scopes',
|
|
'Mandatory',
|
|
'Scope Name',
|
|
'Delete',
|
|
'Description',
|
|
]);
|
|
|
|
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 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 stripInlineComment(value) {
|
|
let inSingle = false;
|
|
let inDouble = false;
|
|
|
|
for (let i = 0; i < value.length; i += 1) {
|
|
const ch = value[i];
|
|
const prev = i > 0 ? value[i - 1] : '';
|
|
|
|
if (ch === "'" && !inDouble && prev !== '\\') {
|
|
inSingle = !inSingle;
|
|
continue;
|
|
}
|
|
|
|
if (ch === '"' && !inSingle && prev !== '\\') {
|
|
inDouble = !inDouble;
|
|
continue;
|
|
}
|
|
|
|
if (ch === '#' && !inSingle && !inDouble) {
|
|
return value.slice(0, i).trimEnd();
|
|
}
|
|
}
|
|
|
|
return value.trimEnd();
|
|
}
|
|
|
|
function parseTomlStringEntries(filePath) {
|
|
const result = readFileRequired(filePath);
|
|
if (!result.ok) {
|
|
return { ok: false, error: result.error, entries: [] };
|
|
}
|
|
|
|
const entries = new Map();
|
|
const lines = result.value.split(/\r?\n/);
|
|
let currentSection = [];
|
|
|
|
for (let index = 0; index < lines.length; index += 1) {
|
|
const rawLine = lines[index];
|
|
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((part) => part.trim()).filter(Boolean)
|
|
: [];
|
|
continue;
|
|
}
|
|
|
|
if (line.startsWith('[') && line.endsWith(']')) {
|
|
const sectionName = line.slice(1, -1).trim();
|
|
currentSection = sectionName
|
|
? sectionName.split('.').map((part) => part.trim()).filter(Boolean)
|
|
: [];
|
|
continue;
|
|
}
|
|
|
|
const eqIndex = rawLine.indexOf('=');
|
|
if (eqIndex === -1) {
|
|
continue;
|
|
}
|
|
|
|
let key = rawLine.slice(0, eqIndex).trim();
|
|
if (!key) {
|
|
continue;
|
|
}
|
|
|
|
if (key.startsWith('"') && key.endsWith('"')) {
|
|
key = key.slice(1, -1);
|
|
}
|
|
|
|
const rawValue = stripInlineComment(rawLine.slice(eqIndex + 1).trim());
|
|
if (
|
|
(rawValue.startsWith('"') && rawValue.endsWith('"')) ||
|
|
(rawValue.startsWith("'") && rawValue.endsWith("'"))
|
|
) {
|
|
entries.set([...currentSection, key].join('.'), {
|
|
line: index + 1,
|
|
value: rawValue.slice(1, -1),
|
|
});
|
|
}
|
|
}
|
|
|
|
return { ok: true, entries };
|
|
}
|
|
|
|
function normalizeValue(value) {
|
|
return value.replace(/\{\{[^}]+\}\}/g, ' ').replace(/\s+/g, ' ').trim().toLowerCase();
|
|
}
|
|
|
|
function formatFinding(fileName, line, key, value, reason) {
|
|
return `${fileName}:${line} ${key} = "${value}" (${reason})`;
|
|
}
|
|
|
|
function main() {
|
|
const errors = [];
|
|
const findings = [];
|
|
const codeKeys = collectCodeKeys();
|
|
const localeMaps = new Map();
|
|
|
|
for (const fileName of TARGET_FILES) {
|
|
const parsed = parseTomlStringEntries(path.join(LOCALES_DIR, fileName));
|
|
if (!parsed.ok) {
|
|
errors.push(parsed.error);
|
|
continue;
|
|
}
|
|
localeMaps.set(fileName, parsed.entries);
|
|
}
|
|
|
|
if (errors.length > 0) {
|
|
console.error('i18n 값 검증 실패');
|
|
errors.forEach((error) => console.error(`- ${error}`));
|
|
process.exit(1);
|
|
}
|
|
|
|
const koEntries = localeMaps.get('ko.toml');
|
|
const enEntries = localeMaps.get('en.toml');
|
|
|
|
for (const key of codeKeys) {
|
|
for (const fileName of TARGET_FILES) {
|
|
const entry = localeMaps.get(fileName).get(key);
|
|
if (!entry) {
|
|
continue;
|
|
}
|
|
|
|
if (PLACEHOLDER_VALUES.has(normalizeValue(entry.value))) {
|
|
findings.push(
|
|
formatFinding(fileName, entry.line, key, entry.value, 'placeholder value'),
|
|
);
|
|
}
|
|
}
|
|
|
|
const koEntry = koEntries.get(key);
|
|
const enEntry = enEntries.get(key);
|
|
if (!koEntry || !enEntry) {
|
|
continue;
|
|
}
|
|
|
|
if (koEntry.value === enEntry.value && KO_UNTRANSLATED_VALUES.has(koEntry.value)) {
|
|
findings.push(
|
|
formatFinding(
|
|
'ko.toml',
|
|
koEntry.line,
|
|
key,
|
|
koEntry.value,
|
|
'untranslated english value in Korean locale',
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
const reportsDir = path.join(ROOT_DIR, 'reports');
|
|
if (!fs.existsSync(reportsDir)) {
|
|
fs.mkdirSync(reportsDir, { recursive: true });
|
|
}
|
|
|
|
const generatedAt = new Date().toISOString();
|
|
const summaryPath = path.join(reportsDir, 'i18n-value-report.txt');
|
|
const jsonPath = path.join(reportsDir, 'i18n-value-report.json');
|
|
|
|
const summaryLines = [
|
|
`generated_at: ${generatedAt}`,
|
|
`errors: ${errors.length}`,
|
|
`findings: ${findings.length}`,
|
|
];
|
|
if (findings.length > 0) {
|
|
summaryLines.push('details:');
|
|
findings.forEach((finding) => summaryLines.push(`- ${finding}`));
|
|
}
|
|
|
|
fs.writeFileSync(summaryPath, `${summaryLines.join('\n')}\n`);
|
|
fs.writeFileSync(
|
|
jsonPath,
|
|
JSON.stringify({ generated_at: generatedAt, errors, findings }, null, 2),
|
|
);
|
|
|
|
if (findings.length > 0) {
|
|
console.error('i18n 값 품질 검증 실패');
|
|
findings.forEach((finding) => console.error(`- ${finding}`));
|
|
process.exit(1);
|
|
}
|
|
|
|
console.log('✅ i18n 값 품질 검증 완료');
|
|
}
|
|
|
|
main();
|