forked from baron/baron-sso
common/locales 기반 i18n 스캐너와 문서 정리
This commit is contained in:
@@ -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`
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
10
docs/i18n.md
10
docs/i18n.md
@@ -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) 구현 가이드
|
||||||
* **패키지 설치**:
|
* **패키지 설치**:
|
||||||
|
|||||||
@@ -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 (!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) {
|
if (missingInLang.length > 0) {
|
||||||
errors.push(`[Sync Error] ${fileName} 누락 키 ${missingInLang.length}개`);
|
errors.push(
|
||||||
printList(`${fileName}에 없는 키`, missingInLang);
|
`[Sync Error] ${spec.label} ${fileName} 누락 키 ${missingInLang.length}개`,
|
||||||
|
);
|
||||||
|
printList(`${spec.label} ${fileName}에 없는 키`, missingInLang);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const missingInTemplate = difference(codeKeys, templateKeys);
|
const ownedCodeKeys = new Set(
|
||||||
|
rawCodeKeys.filter((key) => spec.ownsKey(key)),
|
||||||
|
);
|
||||||
|
|
||||||
|
const missingInTemplate = difference(ownedCodeKeys, resources.templateKeys);
|
||||||
if (missingInTemplate.length > 0) {
|
if (missingInTemplate.length > 0) {
|
||||||
errors.push(`[Missing Key] template.toml 누락 키 ${missingInTemplate.length}개`);
|
errors.push(
|
||||||
printList('template.toml에 없는 코드 사용 키', missingInTemplate);
|
`[Missing Key] ${spec.label} template.toml 누락 키 ${missingInTemplate.length}개`,
|
||||||
|
);
|
||||||
|
printList(`${spec.label} template.toml에 없는 코드 사용 키`, missingInTemplate);
|
||||||
}
|
}
|
||||||
|
|
||||||
const unusedInTemplate = difference(templateKeys, codeKeys);
|
const unusedInTemplate = difference(resources.templateKeys, codeKeys);
|
||||||
if (unusedInTemplate.length > 0) {
|
if (unusedInTemplate.length > 0) {
|
||||||
warnings.push(`[Unused Key] template.toml 미사용 키 ${unusedInTemplate.length}개`);
|
warnings.push(
|
||||||
printList('코드에서 사용되지 않는 키', unusedInTemplate);
|
`[Unused Key] ${spec.label} template.toml 미사용 키 ${unusedInTemplate.length}개`,
|
||||||
|
);
|
||||||
|
printList(`${spec.label} 코드에서 사용되지 않는 키`, unusedInTemplate);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (errors.length > 0) {
|
if (errors.length > 0) {
|
||||||
|
|||||||
@@ -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)));
|
||||||
|
const missingInTemplate = difference(ownedCodeKeys, resources.templateKeys);
|
||||||
if (missingInTemplate.length > 0) {
|
if (missingInTemplate.length > 0) {
|
||||||
report.errors.push(
|
report.errors.push(
|
||||||
`[Missing Key] template.toml 누락 키 ${missingInTemplate.length}개`,
|
`[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();
|
||||||
|
|||||||
Reference in New Issue
Block a user