첫 커밋: 로컬 프로젝트 업로드

This commit is contained in:
2026-06-10 15:51:34 +09:00
commit 6a8dbeb2e9
1211 changed files with 312864 additions and 0 deletions

View File

@@ -0,0 +1,96 @@
#!/usr/bin/env node
"use strict";
const fs = require("fs");
const path = require("path");
const { spawnSync } = require("child_process");
const ROOT = process.cwd();
const LOCALES_DIR = path.join(ROOT, "locales");
const KO_PATH = path.join(LOCALES_DIR, "ko.toml");
const EN_PATH = path.join(LOCALES_DIR, "en.toml");
const OUT_PATH = path.join(ROOT, "userfront", "lib", "i18n_data.dart");
function parseToml(filePath) {
if (!fs.existsSync(filePath)) return new Map();
const content = fs.readFileSync(filePath, "utf8");
const lines = content.split(/\r?\n/);
let section = [];
const map = new Map();
for (const rawLine of lines) {
const line = rawLine.trim();
if (!line || line.startsWith("#")) continue;
if (line.startsWith("[[") && line.endsWith("]]")) {
const name = line.slice(2, -2).trim();
section = name ? name.split(".").map((p) => p.trim()).filter(Boolean) : [];
continue;
}
if (line.startsWith("[") && line.endsWith("]")) {
const name = line.slice(1, -1).trim();
section = name ? name.split(".").map((p) => p.trim()).filter(Boolean) : [];
continue;
}
const eqIndex = line.indexOf("=");
if (eqIndex === -1) continue;
const key = line.slice(0, eqIndex).trim();
const valueRaw = line.slice(eqIndex + 1).trim();
if (!key) continue;
let value = "";
if (valueRaw.startsWith('"')) {
try {
value = JSON.parse(valueRaw);
} catch {
value = valueRaw.slice(1, -1);
}
} else if (valueRaw.startsWith("'") && valueRaw.endsWith("'")) {
value = valueRaw.slice(1, -1);
} else {
value = valueRaw;
}
const fullKey = [...section, key].join(".");
map.set(fullKey, value);
}
return map;
}
function dartStringLiteral(value) {
return JSON.stringify(value).replace(/\$/g, "\\$");
}
function renderDartMap(name, map) {
const keys = Array.from(map.keys()).sort();
const lines = [];
lines.push(`const Map<String, String> ${name} = {`);
for (const key of keys) {
const value = map.get(key) ?? "";
lines.push(` ${dartStringLiteral(key)}: ${dartStringLiteral(value)},`);
}
lines.push("};");
return lines.join("\n");
}
const koMap = parseToml(KO_PATH);
const enMap = parseToml(EN_PATH);
const output = [
"// locales/*.toml에서 생성됨",
renderDartMap("koStrings", koMap),
"",
renderDartMap("enStrings", enMap),
"",
].join("\n");
fs.writeFileSync(OUT_PATH, output, "utf8");
const formatResult = spawnSync("dart", ["format", OUT_PATH], {
cwd: ROOT,
stdio: "inherit",
});
if (formatResult.status !== 0) {
process.exit(formatResult.status ?? 1);
}

View File

@@ -0,0 +1,285 @@
#!/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();

View File

@@ -0,0 +1,46 @@
// This file is used by tools/i18n-scanner to mark keys as used.
// These keys are either used dynamically or in a way that the scanner cannot detect.
import { t } from "../../adminfront/src/lib/i18n";
// Navigation
t("ui.admin.nav.overview");
t("ui.admin.nav.tenant_dashboard");
t("ui.admin.nav.user_groups");
t("ui.admin.nav.tenants");
t("ui.admin.nav.users");
t("ui.admin.nav.api_keys");
t("ui.admin.nav.audit_logs");
t("ui.admin.nav.auth_guard");
t("ui.admin.nav.logout");
t("ui.admin.nav.relying_parties");
t("ui.dev.nav.clients");
// Common & Info
t("err.common.unknown");
t("msg.info.saved_success");
// Userfront Error - Ory
t("msg.userfront.error.ory.access_denied");
t("msg.userfront.error.ory.consent_required");
t("msg.userfront.error.ory.interaction_required");
t("msg.userfront.error.ory.invalid_client");
t("msg.userfront.error.ory.invalid_grant");
t("msg.userfront.error.ory.invalid_request");
t("msg.userfront.error.ory.invalid_scope");
t("msg.userfront.error.ory.login_required");
t("msg.userfront.error.ory.request_forbidden");
t("msg.userfront.error.ory.server_error");
t("msg.userfront.error.ory.temporarily_unavailable");
t("msg.userfront.error.ory.unauthorized_client");
t("msg.userfront.error.ory.unsupported_response_type");
// Userfront Error - Whitelist
t("msg.userfront.error.whitelist.bad_request");
t("msg.userfront.error.whitelist.invalid_session");
t("msg.userfront.error.whitelist.not_found");
t("msg.userfront.error.whitelist.password_or_email_mismatch");
t("msg.userfront.error.whitelist.rate_limited");
t("msg.userfront.error.whitelist.recovery_expired");
t("msg.userfront.error.whitelist.recovery_invalid");
t("msg.userfront.error.whitelist.verification_required");

View File

@@ -0,0 +1,302 @@
#!/usr/bin/env node
'use strict';
const fs = require('fs');
const path = require('path');
const ROOT_DIR = process.cwd();
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 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 buildReport() {
const report = {
generated_at: new Date().toISOString(),
errors: [],
warnings: [],
details: {},
};
const rawCodeKeys = Array.from(collectCodeKeys()).filter(
(key) => !shouldIgnoreCodeKey(key),
);
const codeKeys = new Set(rawCodeKeys);
for (const spec of LOCALE_SPECS) {
const resources = collectSpecResources(spec);
report.details[spec.name] = {
missing_in_template: [],
missing_in_lang: {},
unused_in_template: [],
};
if (!resources.ok) {
report.errors.push(resources.error);
continue;
}
for (const [fileName, langKeys] of resources.langKeyMap.entries()) {
const missingInLang = difference(resources.templateKeys, langKeys);
if (missingInLang.length > 0) {
report.errors.push(
`[Sync Error] ${spec.label} ${fileName} 누락 키 ${missingInLang.length}`,
);
report.details[spec.name].missing_in_lang[fileName] = missingInLang;
}
}
const ownedCodeKeys = new Set(rawCodeKeys.filter((key) => spec.ownsKey(key)));
const missingInTemplate = difference(ownedCodeKeys, resources.templateKeys);
if (missingInTemplate.length > 0) {
report.errors.push(
`[Missing Key] ${spec.label} template.toml 누락 키 ${missingInTemplate.length}`,
);
report.details[spec.name].missing_in_template = missingInTemplate;
}
const unusedInTemplate = difference(resources.templateKeys, codeKeys);
if (unusedInTemplate.length > 0) {
report.warnings.push(
`[Unused Key] ${spec.label} template.toml 미사용 키 ${unusedInTemplate.length}`,
);
report.details[spec.name].unused_in_template = unusedInTemplate;
}
}
return report;
}
function main() {
const report = buildReport();
const outDir = path.join(ROOT_DIR, 'reports');
if (!fs.existsSync(outDir)) {
fs.mkdirSync(outDir, { recursive: true });
}
const outPath = path.join(outDir, 'i18n-report.json');
fs.writeFileSync(outPath, JSON.stringify(report, null, 2));
const summaryPath = path.join(outDir, 'i18n-report.txt');
const lines = [];
lines.push(`generated_at: ${report.generated_at}`);
if (report.errors.length > 0) {
lines.push('errors:');
report.errors.forEach((err) => lines.push(`- ${err}`));
} else {
lines.push('errors: none');
}
if (report.warnings.length > 0) {
lines.push('warnings:');
report.warnings.forEach((warn) => lines.push(`- ${warn}`));
} else {
lines.push('warnings: none');
}
fs.writeFileSync(summaryPath, lines.join('\n'));
if (report.errors.length > 0) {
console.error('❌ i18n report generated with errors');
process.exit(1);
}
console.log(`✅ i18n report written to ${outPath}`);
console.log(`✅ i18n summary written to ${summaryPath}`);
}
main();

View File

@@ -0,0 +1,444 @@
#!/usr/bin/env node
'use strict';
const fs = require('fs');
const path = require('path');
const ROOT = process.cwd();
const LOCALES_DIR = path.join(ROOT, 'locales');
const TEMPLATE_PATH = path.join(LOCALES_DIR, 'template.toml');
const KO_PATH = path.join(LOCALES_DIR, 'ko.toml');
const EN_PATH = path.join(LOCALES_DIR, 'en.toml');
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']);
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 parseToml(filePath) {
if (!fs.existsSync(filePath)) return new Map();
const content = fs.readFileSync(filePath, 'utf8');
const lines = content.split(/\r?\n/);
let section = [];
const map = new Map();
for (const rawLine of lines) {
const line = rawLine.trim();
if (!line || line.startsWith('#')) continue;
if (line.startsWith('[[') && line.endsWith(']]')) {
const name = line.slice(2, -2).trim();
section = name ? name.split('.').map((p) => p.trim()).filter(Boolean) : [];
continue;
}
if (line.startsWith('[') && line.endsWith(']')) {
const name = line.slice(1, -1).trim();
section = name ? name.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;
let valueRaw = line.slice(eqIndex + 1).trim();
let value = '';
if (
(valueRaw.startsWith('"') && valueRaw.endsWith('"')) ||
(valueRaw.startsWith("'") && valueRaw.endsWith("'"))
) {
value = valueRaw.slice(1, -1);
} else {
value = valueRaw;
}
const fullKey = [...section, key].join('.');
map.set(fullKey, value);
}
return map;
}
function buildTree(keys, valuesMap) {
const root = {};
for (const key of keys) {
const parts = key.split('.');
let node = root;
for (let i = 0; i < parts.length - 1; i++) {
const part = parts[i];
if (!node[part]) node[part] = {};
node = node[part];
}
const leaf = parts[parts.length - 1];
const value = valuesMap ? (valuesMap.get(key) ?? '') : '';
node[leaf] = value;
}
return root;
}
function renderToml(tree) {
const lines = [];
function walk(node, path) {
if (path.length) {
lines.push(`[${path.join('.')}]`);
}
const keys = Object.keys(node).sort();
const leafKeys = keys.filter((k) => typeof node[k] === 'string');
const childKeys = keys.filter((k) => typeof node[k] === 'object');
for (const key of leafKeys) {
const value = node[key];
lines.push(`${key} = ${JSON.stringify(value)}`);
}
for (const key of childKeys) {
lines.push('');
walk(node[key], [...path, key]);
}
}
walk(tree, []);
return lines.join('\n').trimEnd() + '\n';
}
function extractFallbacks() {
const files = [];
walkDir(ROOT, files);
const map = new Map();
const tsRegex = /\b(?:i18n\.)?t\s*\(\s*['"]([^'"]+)['"]\s*,\s*(['"])([\s\S]*?)\2/g;
const dartRegex = /\btr\s*\(\s*['"]([^'"]+)['"][\s\S]*?fallback\s*:\s*(?:(\"\"\"[\s\S]*?\"\"\")|('(?:\\.|[^'])*')|(\"(?:\\.|[^\"])*\"))/g;
for (const filePath of files) {
const content = fs.readFileSync(filePath, 'utf8');
let match;
while ((match = tsRegex.exec(content)) !== null) {
const key = match[1];
const raw = match[3] ?? '';
if (!map.has(key)) map.set(key, raw);
}
while ((match = dartRegex.exec(content)) !== null) {
const key = match[1];
let raw = match[2] || match[3] || match[4] || '';
if (raw.startsWith('"""') && raw.endsWith('"""')) {
raw = raw.slice(3, -3);
} else if (
(raw.startsWith('"') && raw.endsWith('"')) ||
(raw.startsWith("'") && raw.endsWith("'"))
) {
raw = raw.slice(1, -1);
}
if (!map.has(key)) map.set(key, raw);
}
}
return map;
}
function isLongText(value) {
if (!value) return false;
if (value.includes('\n')) return true;
if (value.length > 220) return true;
if (/제\\d+조/.test(value)) return true;
return false;
}
function isMostlyAscii(value) {
if (!value) return false;
const ascii = value.replace(/[^\x00-\x7F]/g, '');
return ascii.length / value.length > 0.85;
}
const phraseMap = [
['로딩 중...', 'Loading...'],
['저장 중...', 'Saving...'],
['가족사 임직원', 'Affiliate employees'],
['일반 User', 'General user'],
['로그인 링크 전송', 'Send sign-in link'],
['링크 로그인 완료', 'Link sign-in complete'],
['링크 로그인', 'Link sign-in'],
['로그인 완료', 'Sign-in complete'],
['로그인 화면으로 이동', 'Go to sign-in'],
['회원가입 하기', 'Sign up'],
['회원가입', 'Sign up'],
['가입 완료', 'Sign-up complete'],
['미등록 회원', 'Unregistered member'],
['본인인증', 'Identity verification'],
['로그인', 'Sign in'],
['로그아웃', 'Sign out'],
['대시보드', 'Dashboard'],
['프로필', 'Profile'],
['내 정보', 'My profile'],
['QR 코드', 'QR code'],
['QR 인증', 'QR verification'],
['QR 스캔', 'Scan QR'],
['카메라 켜기', 'Turn on camera'],
['카메라', 'Camera'],
['스캔', 'Scan'],
['링크', 'Link'],
['승인', 'Approve'],
['거부', 'Reject'],
['완료', 'Complete'],
['실패', 'Failed'],
['성공', 'Success'],
['인증번호', 'Verification code'],
['인증', 'Verification'],
['비밀번호', 'Password'],
['재설정', 'Reset'],
['재설정', 'Reset'],
['재발송', 'Resend'],
['발송', 'Send'],
['요청', 'Request'],
['확인', 'Confirm'],
['취소', 'Cancel'],
['저장', 'Save'],
['삭제', 'Delete'],
['생성', 'Create'],
['수정', 'Edit'],
['등록', 'Register'],
['조회', 'Fetch'],
['목록', 'List'],
['상세', 'Details'],
['설정', 'Settings'],
['권한', 'Permission'],
['범위', 'Scope'],
['보안', 'Security'],
['사용자', 'User'],
['테넌트', 'Tenant'],
['그룹', 'Group'],
['이름', 'Name'],
['설명', 'Description'],
['상태', 'Status'],
['활성', 'Active'],
['비활성', 'Inactive'],
['복사', 'Copy'],
['정보', 'Information'],
['소속', 'Affiliation'],
['부서', 'Department'],
['부서명', 'Department name'],
['회사코드', 'Company code'],
['회사', 'Company'],
['조직', 'Organization'],
['약관동의', 'Agreements'],
['약관', 'Terms'],
['개인정보', 'Privacy'],
['동의', 'Consent'],
['접속이력', 'Access history'],
['접속일자', 'Access date'],
['접속환경', 'Access environment'],
['인증수단', 'Authentication method'],
['현황', 'Overview'],
['세션', 'Session'],
['없는', 'None'],
['없음', 'None'],
['없습니다', 'Not available'],
['없습니다.', 'Not available.'],
['알 수 없음', 'Unknown'],
['준비중', 'Pending'],
['남은 시간', 'Time remaining'],
['유효시간', 'Valid for'],
['숫자', 'Digits'],
['영문', 'Letters'],
['필수', 'Required'],
['선택', 'Optional'],
['이메일', 'Email'],
['휴대폰', 'Mobile'],
['전화번호', 'Phone number'],
['내용', 'Details'],
['추가', 'Add'],
['편집', 'Edit'],
['관리', 'Manage'],
['바론', 'Baron'],
['한라', 'Halla'],
['한맥', 'Hanmac'],
['장헌', 'Jangheon'],
['삼안', 'Saman'],
['준비 중', 'Pending'],
['새로고침', 'Refresh'],
['검색', 'Search'],
['추가', 'Add'],
['삭제', 'Delete'],
['수정', 'Edit'],
['편집', 'Edit'],
['생성', 'Create'],
['등록', 'Register'],
['관리', 'Manage'],
['목록', 'List'],
['상세', 'Details'],
['설정', 'Settings'],
['권한', 'Permission'],
['범위', 'Scope'],
['보안', 'Security'],
['비밀번호', 'Password'],
['이메일', 'Email'],
['전화번호', 'Phone number'],
['휴대폰', 'Mobile'],
['사용자', 'User'],
['테넌트', 'Tenant'],
['그룹', 'Group'],
['이름', 'Name'],
['설명', 'Description'],
['상태', 'Status'],
['활성', 'Active'],
['비활성', 'Inactive'],
['승인', 'Approve'],
['해지', 'Revoke'],
['복사', 'Copy'],
['요청', 'Request'],
['확인', 'Confirm'],
['취소', 'Cancel'],
['저장', 'Save'],
];
const keyTokenMap = [
['qr', 'QR'],
['idp', 'IDP'],
['oidc', 'OIDC'],
['api', 'API'],
['app', 'App'],
['m2m', 'M2M'],
['ui', 'UI'],
['otp', 'OTP'],
];
function translateNoun(text) {
let result = text;
for (const [from, to] of phraseMap) {
result = result.split(from).join(to);
}
result = result.replace(/\s+/g, ' ').trim();
return result;
}
function translateKorean(text) {
if (!text) return text;
const trimmed = text.trim();
const patterns = [
[/^(.+?)에 실패했습니다\.$/, (m, p1) => `Failed to ${translateNoun(p1)}.`],
[/^(.+?)에 실패했습니다$/, (m, p1) => `Failed to ${translateNoun(p1)}.`],
[/^(.+?)가 필요합니다\.$/, (m, p1) => `${translateNoun(p1)} is required.`],
[/^(.+?)가 필요합니다$/, (m, p1) => `${translateNoun(p1)} is required.`],
[/^(.+?)을 입력해 주세요\.$/, (m, p1) => `Please enter ${translateNoun(p1)}.`],
[/^(.+?)을 입력해 주세요$/, (m, p1) => `Please enter ${translateNoun(p1)}.`],
[/^(.+?)를 입력해 주세요\.$/, (m, p1) => `Please enter ${translateNoun(p1)}.`],
[/^(.+?)를 입력해 주세요$/, (m, p1) => `Please enter ${translateNoun(p1)}.`],
[/^(.+?)를 입력해주세요\.$/, (m, p1) => `Please enter ${translateNoun(p1)}.`],
[/^(.+?)를 입력해주세요$/, (m, p1) => `Please enter ${translateNoun(p1)}.`],
[/^(.+?)을 확인해 주세요\.$/, (m, p1) => `Please check ${translateNoun(p1)}.`],
[/^(.+?)을 확인해 주세요$/, (m, p1) => `Please check ${translateNoun(p1)}.`],
[/^(.+?)를 확인해 주세요\.$/, (m, p1) => `Please check ${translateNoun(p1)}.`],
[/^(.+?)를 확인해 주세요$/, (m, p1) => `Please check ${translateNoun(p1)}.`],
[/^(.+?)가 없습니다\.$/, (m, p1) => `No ${translateNoun(p1)}.`],
[/^(.+?)가 없습니다$/, (m, p1) => `No ${translateNoun(p1)}.`],
[/^(.+?)이 없습니다\.$/, (m, p1) => `No ${translateNoun(p1)}.`],
[/^(.+?)이 없습니다$/, (m, p1) => `No ${translateNoun(p1)}.`],
[/^(.+?)가 복사되었습니다\.$/, (m, p1) => `${translateNoun(p1)} copied.`],
[/^(.+?)가 저장되었습니다\.$/, (m, p1) => `${translateNoun(p1)} saved.`],
[/^(.+?)가 생성되었습니다\.$/, (m, p1) => `${translateNoun(p1)} created.`],
[/^(.+?)가 수정되었습니다\.$/, (m, p1) => `${translateNoun(p1)} updated.`],
[/^(.+?)가 삭제되었습니다\.$/, (m, p1) => `${translateNoun(p1)} deleted.`],
[/^(.+?)를 삭제할까요\?$/, (m, p1) => `Delete ${translateNoun(p1)}?`],
];
for (const [regex, fn] of patterns) {
const match = trimmed.match(regex);
if (match) return fn(...match);
}
let result = trimmed;
const sortedPhraseMap = [...phraseMap].sort((a, b) => b[0].length - a[0].length);
for (const [from, to] of sortedPhraseMap) {
result = result.split(from).join(to);
}
return result;
}
function containsHangul(text) {
return /[가-힣]/.test(text);
}
function keyToEnglish(key) {
if (!key) return 'Unknown';
const segment = key.split('.').pop() || key;
let result = segment.replace(/_/g, ' ').replace(/\s+/g, ' ').trim();
result = result.replace(/\b([a-z])/g, (m) => m.toUpperCase());
for (const [from, to] of keyTokenMap) {
const regex = new RegExp(`\\b${from}\\b`, 'gi');
result = result.replace(regex, to);
}
return result || 'Unknown';
}
function main() {
const templateMap = parseToml(TEMPLATE_PATH);
const koMap = parseToml(KO_PATH);
const enMap = parseToml(EN_PATH);
const fallbacks = extractFallbacks();
const allKeys = new Set([
...templateMap.keys(),
...koMap.keys(),
...enMap.keys(),
]);
for (const key of allKeys) {
const fallback = fallbacks.get(key);
const currentKo = koMap.get(key) ?? '';
const currentEn = enMap.get(key) ?? '';
let nextKo = currentKo;
if (!nextKo && fallback) {
nextKo = fallback;
}
if (!nextKo) {
nextKo = key;
}
let nextEn = currentEn;
if (!nextEn) {
const source = fallback || nextKo || key;
if (isLongText(source)) {
nextEn = source;
} else if (isMostlyAscii(source)) {
nextEn = source;
} else {
nextEn = translateKorean(source);
}
}
if (!nextEn) {
nextEn = key;
}
if (!isLongText(nextEn) && containsHangul(nextEn)) {
nextEn = keyToEnglish(key);
}
koMap.set(key, nextKo);
enMap.set(key, nextEn);
}
const keys = Array.from(allKeys).sort();
fs.writeFileSync(KO_PATH, renderToml(buildTree(keys, koMap)));
fs.writeFileSync(EN_PATH, renderToml(buildTree(keys, enMap)));
fs.writeFileSync(TEMPLATE_PATH, renderToml(buildTree(keys, null)));
}
main();

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