1
0
forked from baron/baron-sso
Files
baron-sso/tools/i18n-scanner/value-check.js

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();