forked from baron/baron-sso
i18n 값 품질 검사 추가 및 devfront locale placeholder 정리
This commit is contained in:
365
tools/i18n-scanner/value-check.js
Normal file
365
tools/i18n-scanner/value-check.js
Normal file
@@ -0,0 +1,365 @@
|
||||
#!/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();
|
||||
Reference in New Issue
Block a user