1
0
forked from baron/baron-sso

i18n refresh and frontend fixes

This commit is contained in:
Lectom C Han
2026-02-10 19:15:51 +09:00
parent 2441c64598
commit b6d3b69cda
44 changed files with 8603 additions and 1760 deletions

View File

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

224
tools/i18n-scanner/index.js Normal file
View File

@@ -0,0 +1,224 @@
#!/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 TEMPLATE_PATH = path.join(LOCALES_DIR, 'template.toml');
const LANG_FILES = ['ko.toml', 'en.toml'];
const FAIL_UNUSED = process.argv.includes('--fail-unused');
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;
}
const key = line.slice(0, eqIndex).trim();
if (!key) {
continue;
}
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;
}
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 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 main() {
const errors = [];
const warnings = [];
const templateResult = parseTomlKeys(TEMPLATE_PATH);
if (!templateResult.ok) {
errors.push(templateResult.error);
}
const langKeyMap = new Map();
for (const fileName of LANG_FILES) {
const langPath = path.join(LOCALES_DIR, fileName);
const langResult = parseTomlKeys(langPath);
if (!langResult.ok) {
errors.push(langResult.error);
continue;
}
langKeyMap.set(fileName, langResult.keys);
}
if (errors.length > 0) {
console.error('i18n 검증 실패: 필수 리소스 파일을 찾지 못했습니다.');
for (const error of errors) {
console.error(`- ${error}`);
}
process.exit(1);
}
const templateKeys = templateResult.keys;
const codeKeys = collectCodeKeys();
for (const [fileName, langKeys] of langKeyMap.entries()) {
const missingInLang = difference(templateKeys, langKeys);
if (missingInLang.length > 0) {
errors.push(`[Sync Error] ${fileName} 누락 키 ${missingInLang.length}`);
printList(`${fileName}에 없는 키`, missingInLang);
}
}
const missingInTemplate = difference(codeKeys, templateKeys);
if (missingInTemplate.length > 0) {
errors.push(`[Missing Key] template.toml 누락 키 ${missingInTemplate.length}`);
printList('template.toml에 없는 코드 사용 키', missingInTemplate);
}
const unusedInTemplate = difference(templateKeys, codeKeys);
if (unusedInTemplate.length > 0) {
warnings.push(`[Unused Key] template.toml 미사용 키 ${unusedInTemplate.length}`);
printList('코드에서 사용되지 않는 키', 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,443 @@
#!/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)));
}
main();