1
0
forked from baron/baron-sso

common/locales 기반 i18n 스캐너와 문서 정리

This commit is contained in:
2026-05-11 11:57:10 +09:00
parent 27f48baadc
commit c0c5a23dc1
7 changed files with 261 additions and 121 deletions

View File

@@ -541,11 +541,12 @@ KETO_WRITE_URL = "http://keto:4467"
``` ```
## 🌐 i18n 구조 (간략) ## 🌐 i18n 구조 (간략)
- **Source of Truth**: `locales/template.toml`이 전체 키의 기준이며 `locales/ko.toml`, `locales/en.toml`과 항상 동기화합니다. - **Root locales**: `locales/template.toml`, `locales/ko.toml`, `locales/en.toml`은 현재 `userfront`와 전역 i18n 검증 기준 리소스입니다.
- **React(Admin/Dev)**: `adminfront/src/lib/i18n.ts`, `devfront/src/lib/i18n.ts`에서 `t(key, fallback, vars)`로 사용하고 TOML을 `?raw`로 로드합니다. - **Common locales**: `common/locales/template.toml`, `common/locales/ko.toml`, `common/locales/en.toml``ui.common.*`, `msg.common.*` 같은 React 공통 문구 레이어입니다.
- **React(Admin/Dev/Org)**: `adminfront/src/lib/i18n.ts`, `devfront/src/lib/i18n.ts`, `orgfront/src/lib/i18n.ts`에서 `t(key, fallback, vars)`를 사용하며 `common locale -> app locale override` 순서로 TOML을 `?raw` 로드합니다.
- **Flutter(User)**: `userfront/lib/i18n.dart`에서 `tr(key, fallback, params)` 사용. `locales/*.toml``tools/i18n-scanner/gen-flutter-i18n.js``userfront/lib/i18n_data.dart`에 사전 생성합니다. - **Flutter(User)**: `userfront/lib/i18n.dart`에서 `tr(key, fallback, params)` 사용. `locales/*.toml``tools/i18n-scanner/gen-flutter-i18n.js``userfront/lib/i18n_data.dart`에 사전 생성합니다.
- **UserFront 동기화 규칙**: `locales/*.toml`을 수정한 뒤에는 반드시 `./scripts/sync_userfront_locales.sh`를 실행해 `userfront/assets/translations/*.toml`과 런타임 번역 리소스를 동기화합니다. - **UserFront 동기화 규칙**: `locales/*.toml`을 수정한 뒤에는 반드시 `./scripts/sync_userfront_locales.sh`를 실행해 `userfront/assets/translations/*.toml`과 런타임 번역 리소스를 동기화합니다.
- **검증**: `node tools/i18n-scanner/index.js`로 코드-키-로케일 동기화 상태를 점검합니다. - **검증**: `node tools/i18n-scanner/index.js` `root locales``common/locales` 코드-키-로케일 동기화 상태를 함께 점검합니다.
## 🧪 Code Check CI ## 🧪 Code Check CI
워크플로우 파일: `.gitea/workflows/code_check.yml` 워크플로우 파일: `.gitea/workflows/code_check.yml`

View File

@@ -1,6 +1,9 @@
[msg.common] [msg.common]
copied = "Copied."
error = "Error" error = "Error"
forbidden = "Access denied."
loading = "Loading..." loading = "Loading..."
no_results = "No results found."
no_description = "No Description." no_description = "No Description."
parsing = "Parsing data..." parsing = "Parsing data..."
requesting = "Requesting..." requesting = "Requesting..."
@@ -8,9 +11,11 @@ saving = "Saving..."
unknown_error = "unknown error" unknown_error = "unknown error"
[ui.common] [ui.common]
actions = "Actions"
add = "Add" add = "Add"
all = "All" all = "All"
admin_only = "Admin Only" admin_only = "Admin Only"
approve = "Approve"
assign = "Assign" assign = "Assign"
back = "Back" back = "Back"
back_to_login = "Back to login" back_to_login = "Back to login"
@@ -20,19 +25,27 @@ clear_search = "Clear Search"
close = "Close" close = "Close"
collapse = "Collapse" collapse = "Collapse"
confirm = "Confirm" confirm = "Confirm"
continue = "Continue"
copy = "Copy" copy = "Copy"
create = "Create" create = "Create"
delete = "Delete" delete = "Delete"
detail = "Detail"
details = "Details" details = "Details"
disabled = "Disabled" disabled = "Disabled"
edit = "Edit" edit = "Edit"
enabled = "Enabled" enabled = "Enabled"
export = "Export" export = "Export"
export_with_ids = "Include UUID"
export_without_ids = "Export without UUID"
fail = "Fail" fail = "Fail"
go_home = "Go Home" go_home = "Go Home"
info = "Info"
view = "View" view = "View"
hyphen = "-" hyphen = "-"
loading = "Loading..."
manage = "Manage" manage = "Manage"
move = "Move"
move_org = "Move to another organization"
na = "N/A" na = "N/A"
never = "Never" never = "Never"
next = "Next" next = "Next"
@@ -41,14 +54,19 @@ page_of = "Page {{page}} of {{total}}"
prev = "Prev" prev = "Prev"
previous = "Previous" previous = "Previous"
qr = "QR" qr = "QR"
reject = "Reject"
rejected = "Rejected"
reset = "Reset" reset = "Reset"
read_only = "Read Only" read_only = "Read Only"
refresh = "Refresh" refresh = "Refresh"
remove = "Remove" remove = "Remove"
remove_org = "Remove from organization"
resend = "Resend" resend = "Resend"
retry = "Retry" retry = "Retry"
row = "Row"
save = "Save" save = "Save"
search = "Search" search = "Search"
search_group = "Search groups..."
select = "Select" select = "Select"
select_file = "Select File" select_file = "Select File"
select_placeholder = "Select Placeholder" select_placeholder = "Select Placeholder"
@@ -56,10 +74,13 @@ show_more = "Show More"
language = "Language" language = "Language"
language_ko = "Korean" language_ko = "Korean"
language_en = "English" language_en = "English"
submit = "Submit"
submitting = "Submitting..."
success = "Success" success = "Success"
theme_dark = "Dark" theme_dark = "Dark"
theme_light = "Light" theme_light = "Light"
theme_toggle = "Theme Toggle" theme_toggle = "Theme Toggle"
unassigned = "Unassigned"
unknown = "Unknown" unknown = "Unknown"
[ui.common.badge] [ui.common.badge]

View File

@@ -1,6 +1,9 @@
[msg.common] [msg.common]
copied = "복사되었습니다."
error = "오류가 발생했습니다." error = "오류가 발생했습니다."
forbidden = "접근 권한이 없습니다."
loading = "로딩 중..." loading = "로딩 중..."
no_results = "검색 결과가 없습니다."
no_description = "설명이 없습니다." no_description = "설명이 없습니다."
parsing = "데이터 파싱 중..." parsing = "데이터 파싱 중..."
requesting = "요청 중..." requesting = "요청 중..."
@@ -8,9 +11,11 @@ saving = "저장 중..."
unknown_error = "알 수 없는 오류" unknown_error = "알 수 없는 오류"
[ui.common] [ui.common]
actions = "액션"
add = "추가" add = "추가"
all = "전체" all = "전체"
admin_only = "관리자 전용" admin_only = "관리자 전용"
approve = "승인"
assign = "할당" assign = "할당"
back = "돌아가기" back = "돌아가기"
back_to_login = "로그인으로 돌아가기" back_to_login = "로그인으로 돌아가기"
@@ -20,19 +25,27 @@ clear_search = "검색 초기화"
close = "닫기" close = "닫기"
collapse = "접기" collapse = "접기"
confirm = "확인" confirm = "확인"
continue = "계속 진행"
copy = "복사" copy = "복사"
create = "생성" create = "생성"
delete = "삭제" delete = "삭제"
detail = "상세보기"
details = "상세정보" details = "상세정보"
disabled = "사용 안 함" disabled = "사용 안 함"
edit = "편집" edit = "편집"
enabled = "사용" enabled = "사용"
export = "내보내기" export = "내보내기"
export_with_ids = "UUID 포함"
export_without_ids = "UUID 제외 내보내기"
fail = "실패" fail = "실패"
go_home = "홈으로" go_home = "홈으로"
info = "상세 안내"
view = "보기" view = "보기"
hyphen = "-" hyphen = "-"
loading = "로딩 중..."
manage = "관리" manage = "관리"
move = "이동"
move_org = "타 조직으로 이동"
na = "N/A" na = "N/A"
never = "Never" never = "Never"
next = "다음" next = "다음"
@@ -41,14 +54,19 @@ page_of = "Page {{page}} of {{total}}"
prev = "이전" prev = "이전"
previous = "이전" previous = "이전"
qr = "QR" qr = "QR"
reject = "반려"
rejected = "반려됨"
reset = "초기화" reset = "초기화"
read_only = "읽기 전용" read_only = "읽기 전용"
refresh = "새로고침" refresh = "새로고침"
remove = "제외" remove = "제외"
remove_org = "조직에서 제외"
resend = "재발송" resend = "재발송"
retry = "다시 시도" retry = "다시 시도"
row = "행"
save = "저장" save = "저장"
search = "검색" search = "검색"
search_group = "그룹 검색..."
select = "선택" select = "선택"
select_file = "파일 선택" select_file = "파일 선택"
select_placeholder = "선택하세요" select_placeholder = "선택하세요"
@@ -56,10 +74,13 @@ show_more = "+ 더보기"
language = "언어" language = "언어"
language_ko = "한국어" language_ko = "한국어"
language_en = "English" language_en = "English"
submit = "신청하기"
submitting = "제출 중..."
success = "성공" success = "성공"
theme_dark = "Dark" theme_dark = "Dark"
theme_light = "Light" theme_light = "Light"
theme_toggle = "테마 전환" theme_toggle = "테마 전환"
unassigned = "미배정"
unknown = "Unknown" unknown = "Unknown"
[ui.common.badge] [ui.common.badge]

View File

@@ -1,6 +1,9 @@
[msg.common] [msg.common]
copied = ""
error = "" error = ""
forbidden = ""
loading = "" loading = ""
no_results = ""
no_description = "" no_description = ""
parsing = "" parsing = ""
requesting = "" requesting = ""
@@ -8,9 +11,11 @@ saving = ""
unknown_error = "" unknown_error = ""
[ui.common] [ui.common]
actions = ""
add = "" add = ""
all = "" all = ""
admin_only = "" admin_only = ""
approve = ""
assign = "" assign = ""
back = "" back = ""
back_to_login = "" back_to_login = ""
@@ -20,19 +25,27 @@ clear_search = ""
close = "" close = ""
collapse = "" collapse = ""
confirm = "" confirm = ""
continue = ""
copy = "" copy = ""
create = "" create = ""
delete = "" delete = ""
detail = ""
details = "" details = ""
disabled = "" disabled = ""
edit = "" edit = ""
enabled = "" enabled = ""
export = "" export = ""
export_with_ids = ""
export_without_ids = ""
fail = "" fail = ""
go_home = "" go_home = ""
info = ""
view = "" view = ""
hyphen = "" hyphen = ""
loading = ""
manage = "" manage = ""
move = ""
move_org = ""
na = "" na = ""
never = "" never = ""
next = "" next = ""
@@ -41,14 +54,19 @@ page_of = ""
prev = "" prev = ""
previous = "" previous = ""
qr = "" qr = ""
reject = ""
rejected = ""
reset = "" reset = ""
read_only = "" read_only = ""
refresh = "" refresh = ""
remove = "" remove = ""
remove_org = ""
resend = "" resend = ""
retry = "" retry = ""
row = ""
save = "" save = ""
search = "" search = ""
search_group = ""
select = "" select = ""
select_file = "" select_file = ""
select_placeholder = "" select_placeholder = ""
@@ -56,10 +74,13 @@ show_more = ""
language = "" language = ""
language_ko = "" language_ko = ""
language_en = "" language_en = ""
submit = ""
submitting = ""
success = "" success = ""
theme_dark = "" theme_dark = ""
theme_light = "" theme_light = ""
theme_toggle = "" theme_toggle = ""
unassigned = ""
unknown = "" unknown = ""
[ui.common.badge] [ui.common.badge]

View File

@@ -119,14 +119,18 @@ TOML에서는 `[Section]`을 사용하여 계층을 표현합니다.
./scripts/sync_userfront_locales.sh ./scripts/sync_userfront_locales.sh
``` ```
* 이 단계가 누락되면 루트 SoT와 UserFront 실제 표시 문구가 어긋날 수 있습니다. * 이 단계가 누락되면 루트 SoT와 UserFront 실제 표시 문구가 어긋날 수 있습니다.
4. **React 공통 locale 레이어**:
* React 계열 프런트(`adminfront`, `devfront`, `orgfront`)는 `common/locales/*.toml`을 공통 문구 레이어로 사용합니다.
* 공통 key는 `ui.common.*`, `msg.common.*` 범위에만 둡니다.
* 각 앱의 `src/locales/*.toml`은 앱 전용 문구를 유지하고, 로딩 시 `common locale -> app locale override` 순서로 merge 합니다.
3. **CI 검증 (Verification)**: 3. **CI 검증 (Verification)**:
* **Level 1: 리소스 동기화 검사 (`template` vs `lang`)** * **Level 1: 리소스 동기화 검사 (`template` vs `lang`)**
* `template.toml`에 있는 모든 키가 `ko.toml`, `en.toml`에 존재하는지 재귀적으로 검사합니다. * `locales/*.toml`과 `common/locales/*.toml` 각각에 대해 `template.toml`에 있는 모든 키가 `ko.toml`, `en.toml`에 존재하는지 재귀적으로 검사합니다.
* 누락 시 빌드 실패. * 누락 시 빌드 실패.
* **Level 2: 코드 사용성 검사 (`code` vs `template`)** * **Level 2: 코드 사용성 검사 (`code` vs `template`)**
* 전체 프론트엔드 소스코드(`src/**/*.{ts,tsx}`, `lib/**/*.dart`)를 스캔하여 번역 함수(`t('key')`, `'key'.tr()`)에 사용된 키를 추출합니다. * 전체 프론트엔드 소스코드(`src/**/*.{ts,tsx}`, `lib/**/*.dart`)를 스캔하여 번역 함수(`t('key')`, `'key'.tr()`)에 사용된 키를 추출합니다.
* **Missing Key**: 코드에는 있는데 `template.toml`에 없는 키를 검출하여 경고 또는 에러를 발생시킵니다. * **Missing Key**: 코드에는 있는데 해당 레이어의 `template.toml`에 없는 키를 검출하여 경고 또는 에러를 발생시킵니다.
* **Unused Key**: `template.toml`에는 있는데 코드 어디에서도 쓰이지 않는 키를 리포트하여 정리할 수 있게 합니다. * **Unused Key**: `template.toml`에는 있는데 코드 어디에서도 쓰이지 않는 키를 리포트하여 정리할 수 있게 합니다.
#### 5.2.3 React (Admin/Dev) 구현 가이드 #### 5.2.3 React (Admin/Dev) 구현 가이드
* **패키지 설치**: * **패키지 설치**:

View File

@@ -5,11 +5,27 @@ const fs = require('fs');
const path = require('path'); const path = require('path');
const ROOT_DIR = process.cwd(); 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 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([ const SKIP_DIRS = new Set([
'.git', '.git',
'node_modules', 'node_modules',
@@ -78,7 +94,6 @@ function parseTomlKeys(filePath) {
continue; continue;
} }
// Strip quotes if present
if (key.startsWith('"') && key.endsWith('"')) { if (key.startsWith('"') && key.endsWith('"')) {
key = key.slice(1, -1); key = key.slice(1, -1);
} }
@@ -138,6 +153,23 @@ function collectCodeKeys() {
return keys; 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) { function difference(aSet, bSet) {
const result = []; const result = [];
for (const item of aSet) { for (const item of aSet) {
@@ -158,72 +190,75 @@ function printList(title, items) {
} }
} }
function main() { function collectSpecResources(spec) {
const errors = []; const templatePath = path.join(spec.dir, spec.template);
const warnings = []; const templateResult = parseTomlKeys(templatePath);
const templateResult = parseTomlKeys(TEMPLATE_PATH);
if (!templateResult.ok) { if (!templateResult.ok) {
errors.push(templateResult.error); return { ok: false, error: templateResult.error };
} }
const langKeyMap = new Map(); const langKeyMap = new Map();
for (const fileName of LANG_FILES) { for (const fileName of spec.langs) {
const langPath = path.join(LOCALES_DIR, fileName); const langPath = path.join(spec.dir, fileName);
const langResult = parseTomlKeys(langPath); const langResult = parseTomlKeys(langPath);
if (!langResult.ok) { if (!langResult.ok) {
errors.push(langResult.error); return { ok: false, error: langResult.error };
continue;
} }
langKeyMap.set(fileName, langResult.keys); langKeyMap.set(fileName, langResult.keys);
} }
if (errors.length > 0) { return {
console.error('i18n 검증 실패: 필수 리소스 파일을 찾지 못했습니다.'); ok: true,
for (const error of errors) { templateKeys: templateResult.keys,
console.error(`- ${error}`); langKeyMap,
} };
process.exit(1); }
}
const templateKeys = templateResult.keys; function main() {
const rawCodeKeys = Array.from(collectCodeKeys()); const errors = [];
const codeKeysArray = rawCodeKeys.filter(k => const warnings = [];
!k.includes('.msg.') &&
!k.includes('.ui.') && const rawCodeKeys = Array.from(collectCodeKeys()).filter(
!k.includes('.err.') && (key) => !shouldIgnoreCodeKey(key),
!k.includes('.test.') &&
!k.includes('.non.') &&
!k.startsWith("ui.admin.users.list.table.") &&
!k.startsWith("msg.admin.users.detail.") &&
!k.startsWith("msg.common.") &&
!k.startsWith("msg.dev.clients.") &&
!k.startsWith("ui.admin.users.create.") &&
!k.startsWith("ui.admin.users.detail.") &&
!k.startsWith("ui.common.") &&
!k.startsWith("ui.dev.clients.") &&
!k.startsWith("ui.dev.session.")
); );
const codeKeys = new Set(codeKeysArray); const codeKeys = new Set(rawCodeKeys);
for (const [fileName, langKeys] of langKeyMap.entries()) { for (const spec of LOCALE_SPECS) {
const missingInLang = difference(templateKeys, langKeys); const resources = collectSpecResources(spec);
if (missingInLang.length > 0) { if (!resources.ok) {
errors.push(`[Sync Error] ${fileName} 누락 키 ${missingInLang.length}`); errors.push(resources.error);
printList(`${fileName}에 없는 키`, missingInLang); continue;
} }
}
const missingInTemplate = difference(codeKeys, templateKeys); for (const [fileName, langKeys] of resources.langKeyMap.entries()) {
if (missingInTemplate.length > 0) { const missingInLang = difference(resources.templateKeys, langKeys);
errors.push(`[Missing Key] template.toml 누락 키 ${missingInTemplate.length}`); if (missingInLang.length > 0) {
printList('template.toml에 없는 코드 사용 키', missingInTemplate); errors.push(
} `[Sync Error] ${spec.label} ${fileName} 누락 키 ${missingInLang.length}`,
);
printList(`${spec.label} ${fileName}에 없는 키`, missingInLang);
}
}
const unusedInTemplate = difference(templateKeys, codeKeys); const ownedCodeKeys = new Set(
if (unusedInTemplate.length > 0) { rawCodeKeys.filter((key) => spec.ownsKey(key)),
warnings.push(`[Unused Key] template.toml 미사용 키 ${unusedInTemplate.length}`); );
printList('코드에서 사용되지 않는 키', unusedInTemplate);
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) { if (errors.length > 0) {

View File

@@ -5,9 +5,25 @@ const fs = require('fs');
const path = require('path'); const path = require('path');
const ROOT_DIR = process.cwd(); const ROOT_DIR = process.cwd();
const LOCALES_DIR = path.join(ROOT_DIR, 'locales');
const TEMPLATE_PATH = path.join(LOCALES_DIR, 'template.toml'); const LOCALE_SPECS = [
const LANG_FILES = ['ko.toml', 'en.toml']; {
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([ const SKIP_DIRS = new Set([
'.git', '.git',
@@ -81,7 +97,6 @@ function parseTomlKeys(filePath) {
continue; continue;
} }
// Strip quotes if present
if (key.startsWith('"') && key.endsWith('"')) { if (key.startsWith('"') && key.endsWith('"')) {
key = key.slice(1, -1); key = key.slice(1, -1);
} }
@@ -141,22 +156,20 @@ function collectCodeKeys() {
return keys; return keys;
} }
function filterCodeKeys(rawKeys) { function shouldIgnoreCodeKey(key) {
return Array.from(rawKeys).filter((k) => return (
!k.includes('.msg.') && key.includes('.msg.') ||
!k.includes('.ui.') && key.includes('.ui.') ||
!k.includes('.err.') && key.includes('.err.') ||
!k.includes('.test.') && key.includes('.test.') ||
!k.includes('.non.') && key.includes('.non.') ||
!k.startsWith('ui.admin.users.list.table.') && key.startsWith('ui.admin.users.list.table.') ||
!k.startsWith('msg.admin.users.detail.') && key.startsWith('msg.admin.users.detail.') ||
!k.startsWith('msg.common.') && key.startsWith('msg.dev.clients.') ||
!k.startsWith('msg.dev.clients.') && key.startsWith('ui.admin.users.create.') ||
!k.startsWith('ui.admin.users.create.') && key.startsWith('ui.admin.users.detail.') ||
!k.startsWith('ui.admin.users.detail.') && key.startsWith('ui.dev.clients.') ||
!k.startsWith('ui.common.') && key.startsWith('ui.dev.session.')
!k.startsWith('ui.dev.clients.') &&
!k.startsWith('ui.dev.session.')
); );
} }
@@ -170,62 +183,82 @@ function difference(aSet, bSet) {
return result.sort(); 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() { function buildReport() {
const report = { const report = {
generated_at: new Date().toISOString(), generated_at: new Date().toISOString(),
errors: [], errors: [],
warnings: [], warnings: [],
details: { 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_template: [],
missing_in_lang: {}, missing_in_lang: {},
unused_in_template: [], unused_in_template: [],
}, };
};
const templateResult = parseTomlKeys(TEMPLATE_PATH); if (!resources.ok) {
if (!templateResult.ok) { report.errors.push(resources.error);
report.errors.push(templateResult.error);
return report;
}
const templateKeys = templateResult.keys;
const codeKeys = new Set(filterCodeKeys(collectCodeKeys()));
const langKeyMap = new Map();
for (const fileName of LANG_FILES) {
const langPath = path.join(LOCALES_DIR, fileName);
const langResult = parseTomlKeys(langPath);
if (!langResult.ok) {
report.errors.push(langResult.error);
continue; continue;
} }
langKeyMap.set(fileName, langResult.keys);
}
for (const [fileName, langKeys] of langKeyMap.entries()) { for (const [fileName, langKeys] of resources.langKeyMap.entries()) {
const missingInLang = difference(templateKeys, langKeys); const missingInLang = difference(resources.templateKeys, langKeys);
if (missingInLang.length > 0) { if (missingInLang.length > 0) {
report.errors.push( report.errors.push(
`[Sync Error] ${fileName} 누락 키 ${missingInLang.length}`, `[Sync Error] ${spec.label} ${fileName} 누락 키 ${missingInLang.length}`,
); );
report.details.missing_in_lang[fileName] = missingInLang; report.details[spec.name].missing_in_lang[fileName] = missingInLang;
}
} }
}
const missingInTemplate = difference(codeKeys, templateKeys); const ownedCodeKeys = new Set(rawCodeKeys.filter((key) => spec.ownsKey(key)));
if (missingInTemplate.length > 0) { const missingInTemplate = difference(ownedCodeKeys, resources.templateKeys);
report.errors.push( if (missingInTemplate.length > 0) {
`[Missing Key] template.toml 누락 키 ${missingInTemplate.length}`, report.errors.push(
); `[Missing Key] ${spec.label} template.toml 누락 키 ${missingInTemplate.length}`,
report.details.missing_in_template = missingInTemplate; );
} report.details[spec.name].missing_in_template = missingInTemplate;
}
const unusedInTemplate = difference(templateKeys, codeKeys); const unusedInTemplate = difference(resources.templateKeys, codeKeys);
if (unusedInTemplate.length > 0) { if (unusedInTemplate.length > 0) {
report.warnings.push( report.warnings.push(
`[Unused Key] template.toml 미사용 키 ${unusedInTemplate.length}`, `[Unused Key] ${spec.label} template.toml 미사용 키 ${unusedInTemplate.length}`,
); );
report.details.unused_in_template = unusedInTemplate; report.details[spec.name].unused_in_template = unusedInTemplate;
}
} }
return report; return report;
@@ -258,8 +291,12 @@ function main() {
fs.writeFileSync(summaryPath, lines.join('\n')); fs.writeFileSync(summaryPath, lines.join('\n'));
if (report.errors.length > 0) { if (report.errors.length > 0) {
console.error('❌ i18n report generated with errors');
process.exit(1); process.exit(1);
} }
console.log(`✅ i18n report written to ${outPath}`);
console.log(`✅ i18n summary written to ${summaryPath}`);
} }
main(); main();