#!/usr/bin/env node 'use strict'; const fs = require('fs'); const path = require('path'); const ROOT = process.cwd(); const LOCALE_SPECS = [ { name: 'root', label: 'root locales', dir: path.join(ROOT, '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, 'common', 'locales'), template: 'template.toml', langs: ['ko.toml', 'en.toml'], ownsKey: (key) => key.startsWith('ui.common.') || key.startsWith('msg.common.'), }, ]; 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.') ); } 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 = p.trim(); if ((p.startsWith('"') && p.endsWith('"')) || (p.startsWith("'") && p.endsWith("'"))) { p = p.slice(1, -1).trim(); } return p; }).filter(Boolean) : []; continue; } if (line.startsWith('[') && line.endsWith(']')) { const name = line.slice(1, -1).trim(); section = name ? name.split('.').map((p) => { p = p.trim(); if ((p.startsWith('"') && p.endsWith('"')) || (p.startsWith("'") && p.endsWith("'"))) { p = p.slice(1, -1).trim(); } return p; }).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.startsWith("'") && key.endsWith("'"))) { key = key.slice(1, -1).trim(); } 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] === undefined) { node[part] = {}; } else if (typeof node[part] === 'string') { node[part] = { "": node[part] }; } node = node[part]; } const leaf = parts[parts.length - 1]; const value = valuesMap ? (valuesMap.get(key) ?? '') : ''; if (node[leaf] !== undefined && typeof node[leaf] === 'object') { node[leaf][""] = value; } else { 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 = []; const childKeys = []; for (const key of keys) { if (typeof node[key] === 'string') { leafKeys.push(key); } else if (typeof node[key] === 'object') { if (node[key][""] !== undefined) { leafKeys.push(key); } else { childKeys.push(key); } } } for (const key of leafKeys) { const val = node[key]; if (typeof val === 'string') { lines.push(`${key} = ${JSON.stringify(val)}`); } else { lines.push(`${key} = ${JSON.stringify(val[""])}`); const subKeys = Object.keys(val).filter((k) => k !== "").sort(); for (const subKey of subKeys) { lines.push(`${key}.${subKey} = ${JSON.stringify(val[subKey])}`); } } } 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 fallbacks = extractFallbacks(); for (const spec of LOCALE_SPECS) { const templatePath = path.join(spec.dir, spec.template); const koPath = path.join(spec.dir, 'ko.toml'); const enPath = path.join(spec.dir, 'en.toml'); const templateMap = parseToml(templatePath); const koMap = parseToml(koPath); const enMap = parseToml(enPath); const ownedFallbackKeys = Array.from(fallbacks.keys()).filter( (key) => spec.ownsKey(key) && !shouldIgnoreCodeKey(key) ); const allKeys = new Set([ ...templateMap.keys(), ...koMap.keys(), ...enMap.keys(), ...ownedFallbackKeys, ]); 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(koPath, renderToml(buildTree(keys, koMap))); fs.writeFileSync(enPath, renderToml(buildTree(keys, enMap))); fs.writeFileSync(templatePath, renderToml(buildTree(keys, null))); } } main();