diff --git a/README.md b/README.md index a9559c83..5b62f029 100644 --- a/README.md +++ b/README.md @@ -541,11 +541,12 @@ KETO_WRITE_URL = "http://keto:4467" ``` ## 🌐 i18n ꡬ쑰 (κ°„λž΅) -- **Source of Truth**: `locales/template.toml`이 전체 ν‚€μ˜ 기쀀이며 `locales/ko.toml`, `locales/en.toml`κ³Ό 항상 λ™κΈ°ν™”ν•©λ‹ˆλ‹€. -- **React(Admin/Dev)**: `adminfront/src/lib/i18n.ts`, `devfront/src/lib/i18n.ts`μ—μ„œ `t(key, fallback, vars)`둜 μ‚¬μš©ν•˜κ³  TOML을 `?raw`둜 λ‘œλ“œν•©λ‹ˆλ‹€. +- **Root locales**: `locales/template.toml`, `locales/ko.toml`, `locales/en.toml`은 ν˜„μž¬ `userfront`와 μ „μ—­ i18n 검증 κΈ°μ€€ λ¦¬μ†ŒμŠ€μž…λ‹ˆλ‹€. +- **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`에 사전 μƒμ„±ν•©λ‹ˆλ‹€. - **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 μ›Œν¬ν”Œλ‘œμš° 파일: `.gitea/workflows/code_check.yml` diff --git a/common/locales/en.toml b/common/locales/en.toml index 73d530b6..e27eff98 100644 --- a/common/locales/en.toml +++ b/common/locales/en.toml @@ -1,6 +1,9 @@ [msg.common] +copied = "Copied." error = "Error" +forbidden = "Access denied." loading = "Loading..." +no_results = "No results found." no_description = "No Description." parsing = "Parsing data..." requesting = "Requesting..." @@ -8,9 +11,11 @@ saving = "Saving..." unknown_error = "unknown error" [ui.common] +actions = "Actions" add = "Add" all = "All" admin_only = "Admin Only" +approve = "Approve" assign = "Assign" back = "Back" back_to_login = "Back to login" @@ -20,19 +25,27 @@ clear_search = "Clear Search" close = "Close" collapse = "Collapse" confirm = "Confirm" +continue = "Continue" copy = "Copy" create = "Create" delete = "Delete" +detail = "Detail" details = "Details" disabled = "Disabled" edit = "Edit" enabled = "Enabled" export = "Export" +export_with_ids = "Include UUID" +export_without_ids = "Export without UUID" fail = "Fail" go_home = "Go Home" +info = "Info" view = "View" hyphen = "-" +loading = "Loading..." manage = "Manage" +move = "Move" +move_org = "Move to another organization" na = "N/A" never = "Never" next = "Next" @@ -41,14 +54,19 @@ page_of = "Page {{page}} of {{total}}" prev = "Prev" previous = "Previous" qr = "QR" +reject = "Reject" +rejected = "Rejected" reset = "Reset" read_only = "Read Only" refresh = "Refresh" remove = "Remove" +remove_org = "Remove from organization" resend = "Resend" retry = "Retry" +row = "Row" save = "Save" search = "Search" +search_group = "Search groups..." select = "Select" select_file = "Select File" select_placeholder = "Select Placeholder" @@ -56,10 +74,13 @@ show_more = "Show More" language = "Language" language_ko = "Korean" language_en = "English" +submit = "Submit" +submitting = "Submitting..." success = "Success" theme_dark = "Dark" theme_light = "Light" theme_toggle = "Theme Toggle" +unassigned = "Unassigned" unknown = "Unknown" [ui.common.badge] diff --git a/common/locales/ko.toml b/common/locales/ko.toml index f5cf7c74..7e86dd7b 100644 --- a/common/locales/ko.toml +++ b/common/locales/ko.toml @@ -1,6 +1,9 @@ [msg.common] +copied = "λ³΅μ‚¬λ˜μ—ˆμŠ΅λ‹ˆλ‹€." error = "였λ₯˜κ°€ λ°œμƒν–ˆμŠ΅λ‹ˆλ‹€." +forbidden = "μ ‘κ·Ό κΆŒν•œμ΄ μ—†μŠ΅λ‹ˆλ‹€." loading = "λ‘œλ”© 쀑..." +no_results = "검색 κ²°κ³Όκ°€ μ—†μŠ΅λ‹ˆλ‹€." no_description = "μ„€λͺ…이 μ—†μŠ΅λ‹ˆλ‹€." parsing = "데이터 νŒŒμ‹± 쀑..." requesting = "μš”μ²­ 쀑..." @@ -8,9 +11,11 @@ saving = "μ €μž₯ 쀑..." unknown_error = "μ•Œ 수 μ—†λŠ” 였λ₯˜" [ui.common] +actions = "μ•‘μ…˜" add = "μΆ”κ°€" all = "전체" admin_only = "κ΄€λ¦¬μž μ „μš©" +approve = "승인" assign = "ν• λ‹Ή" back = "λŒμ•„κ°€κΈ°" back_to_login = "둜그인으둜 λŒμ•„κ°€κΈ°" @@ -20,19 +25,27 @@ clear_search = "검색 μ΄ˆκΈ°ν™”" close = "λ‹«κΈ°" collapse = "μ ‘κΈ°" confirm = "확인" +continue = "계속 μ§„ν–‰" copy = "볡사" create = "생성" delete = "μ‚­μ œ" +detail = "상세보기" details = "상세정보" disabled = "μ‚¬μš© μ•ˆ 함" edit = "νŽΈμ§‘" enabled = "μ‚¬μš©" export = "내보내기" +export_with_ids = "UUID 포함" +export_without_ids = "UUID μ œμ™Έ 내보내기" fail = "μ‹€νŒ¨" go_home = "ν™ˆμœΌλ‘œ" +info = "상세 μ•ˆλ‚΄" view = "보기" hyphen = "-" +loading = "λ‘œλ”© 쀑..." manage = "관리" +move = "이동" +move_org = "타 쑰직으둜 이동" na = "N/A" never = "Never" next = "λ‹€μŒ" @@ -41,14 +54,19 @@ page_of = "Page {{page}} of {{total}}" prev = "이전" previous = "이전" qr = "QR" +reject = "반렀" +rejected = "반렀됨" reset = "μ΄ˆκΈ°ν™”" read_only = "읽기 μ „μš©" refresh = "μƒˆλ‘œκ³ μΉ¨" remove = "μ œμ™Έ" +remove_org = "μ‘°μ§μ—μ„œ μ œμ™Έ" resend = "μž¬λ°œμ†‘" retry = "λ‹€μ‹œ μ‹œλ„" +row = "ν–‰" save = "μ €μž₯" search = "검색" +search_group = "κ·Έλ£Ή 검색..." select = "선택" select_file = "파일 선택" select_placeholder = "μ„ νƒν•˜μ„Έμš”" @@ -56,10 +74,13 @@ show_more = "+ 더보기" language = "μ–Έμ–΄" language_ko = "ν•œκ΅­μ–΄" language_en = "English" +submit = "μ‹ μ²­ν•˜κΈ°" +submitting = "제좜 쀑..." success = "성곡" theme_dark = "Dark" theme_light = "Light" theme_toggle = "ν…Œλ§ˆ μ „ν™˜" +unassigned = "λ―Έλ°°μ •" unknown = "Unknown" [ui.common.badge] diff --git a/common/locales/template.toml b/common/locales/template.toml index 1cb9e149..3c16a2b3 100644 --- a/common/locales/template.toml +++ b/common/locales/template.toml @@ -1,6 +1,9 @@ [msg.common] +copied = "" error = "" +forbidden = "" loading = "" +no_results = "" no_description = "" parsing = "" requesting = "" @@ -8,9 +11,11 @@ saving = "" unknown_error = "" [ui.common] +actions = "" add = "" all = "" admin_only = "" +approve = "" assign = "" back = "" back_to_login = "" @@ -20,19 +25,27 @@ clear_search = "" close = "" collapse = "" confirm = "" +continue = "" copy = "" create = "" delete = "" +detail = "" details = "" disabled = "" edit = "" enabled = "" export = "" +export_with_ids = "" +export_without_ids = "" fail = "" go_home = "" +info = "" view = "" hyphen = "" +loading = "" manage = "" +move = "" +move_org = "" na = "" never = "" next = "" @@ -41,14 +54,19 @@ page_of = "" prev = "" previous = "" qr = "" +reject = "" +rejected = "" reset = "" read_only = "" refresh = "" remove = "" +remove_org = "" resend = "" retry = "" +row = "" save = "" search = "" +search_group = "" select = "" select_file = "" select_placeholder = "" @@ -56,10 +74,13 @@ show_more = "" language = "" language_ko = "" language_en = "" +submit = "" +submitting = "" success = "" theme_dark = "" theme_light = "" theme_toggle = "" +unassigned = "" unknown = "" [ui.common.badge] diff --git a/docs/i18n.md b/docs/i18n.md index 26f0eaba..1361c9cf 100644 --- a/docs/i18n.md +++ b/docs/i18n.md @@ -119,14 +119,18 @@ TOMLμ—μ„œλŠ” `[Section]`을 μ‚¬μš©ν•˜μ—¬ 계측을 ν‘œν˜„ν•©λ‹ˆλ‹€. ./scripts/sync_userfront_locales.sh ``` * 이 단계가 λˆ„λ½λ˜λ©΄ 루트 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)**: * **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`)** * 전체 ν”„λ‘ νŠΈμ—”λ“œ μ†ŒμŠ€μ½”λ“œ(`src/**/*.{ts,tsx}`, `lib/**/*.dart`)λ₯Ό μŠ€μΊ”ν•˜μ—¬ λ²ˆμ—­ ν•¨μˆ˜(`t('key')`, `'key'.tr()`)에 μ‚¬μš©λœ ν‚€λ₯Ό μΆ”μΆœν•©λ‹ˆλ‹€. - * **Missing Key**: μ½”λ“œμ—λŠ” μžˆλŠ”λ° `template.toml`에 μ—†λŠ” ν‚€λ₯Ό κ²€μΆœν•˜μ—¬ κ²½κ³  λ˜λŠ” μ—λŸ¬λ₯Ό λ°œμƒμ‹œν‚΅λ‹ˆλ‹€. - * **Unused Key**: `template.toml`μ—λŠ” μžˆλŠ”λ° μ½”λ“œ μ–΄λ””μ—μ„œλ„ 쓰이지 μ•ŠλŠ” ν‚€λ₯Ό λ¦¬ν¬νŠΈν•˜μ—¬ 정리할 수 있게 ν•©λ‹ˆλ‹€. + * **Missing Key**: μ½”λ“œμ—λŠ” μžˆλŠ”λ° ν•΄λ‹Ή λ ˆμ΄μ–΄μ˜ `template.toml`에 μ—†λŠ” ν‚€λ₯Ό κ²€μΆœν•˜μ—¬ κ²½κ³  λ˜λŠ” μ—λŸ¬λ₯Ό λ°œμƒμ‹œν‚΅λ‹ˆλ‹€. + * **Unused Key**: 각 `template.toml`μ—λŠ” μžˆλŠ”λ° μ½”λ“œ μ–΄λ””μ—μ„œλ„ 쓰이지 μ•ŠλŠ” ν‚€λ₯Ό λ¦¬ν¬νŠΈν•˜μ—¬ 정리할 수 있게 ν•©λ‹ˆλ‹€. #### 5.2.3 React (Admin/Dev) κ΅¬ν˜„ κ°€μ΄λ“œ * **νŒ¨ν‚€μ§€ μ„€μΉ˜**: diff --git a/tools/i18n-scanner/index.js b/tools/i18n-scanner/index.js index b7d6bdc9..27e8993d 100644 --- a/tools/i18n-scanner/index.js +++ b/tools/i18n-scanner/index.js @@ -5,11 +5,27 @@ 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 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', @@ -78,7 +94,6 @@ function parseTomlKeys(filePath) { continue; } - // Strip quotes if present if (key.startsWith('"') && key.endsWith('"')) { key = key.slice(1, -1); } @@ -138,6 +153,23 @@ function collectCodeKeys() { 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) { @@ -158,72 +190,75 @@ function printList(title, items) { } } -function main() { - const errors = []; - const warnings = []; - - const templateResult = parseTomlKeys(TEMPLATE_PATH); +function collectSpecResources(spec) { + const templatePath = path.join(spec.dir, spec.template); + const templateResult = parseTomlKeys(templatePath); if (!templateResult.ok) { - errors.push(templateResult.error); + return { ok: false, error: templateResult.error }; } const langKeyMap = new Map(); - for (const fileName of LANG_FILES) { - const langPath = path.join(LOCALES_DIR, fileName); + for (const fileName of spec.langs) { + const langPath = path.join(spec.dir, fileName); const langResult = parseTomlKeys(langPath); if (!langResult.ok) { - errors.push(langResult.error); - continue; + return { ok: false, error: langResult.error }; } langKeyMap.set(fileName, langResult.keys); } - if (errors.length > 0) { - console.error('i18n 검증 μ‹€νŒ¨: ν•„μˆ˜ λ¦¬μ†ŒμŠ€ νŒŒμΌμ„ μ°Ύμ§€ λͺ»ν–ˆμŠ΅λ‹ˆλ‹€.'); - for (const error of errors) { - console.error(`- ${error}`); - } - process.exit(1); - } + return { + ok: true, + templateKeys: templateResult.keys, + langKeyMap, + }; +} - const templateKeys = templateResult.keys; - const rawCodeKeys = Array.from(collectCodeKeys()); - const codeKeysArray = rawCodeKeys.filter(k => - !k.includes('.msg.') && - !k.includes('.ui.') && - !k.includes('.err.') && - !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.") +function main() { + const errors = []; + const warnings = []; + + const rawCodeKeys = Array.from(collectCodeKeys()).filter( + (key) => !shouldIgnoreCodeKey(key), ); - const codeKeys = new Set(codeKeysArray); + const codeKeys = new Set(rawCodeKeys); - 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); + for (const spec of LOCALE_SPECS) { + const resources = collectSpecResources(spec); + if (!resources.ok) { + errors.push(resources.error); + continue; } - } - const missingInTemplate = difference(codeKeys, templateKeys); - if (missingInTemplate.length > 0) { - errors.push(`[Missing Key] template.toml λˆ„λ½ ν‚€ ${missingInTemplate.length}개`); - printList('template.toml에 μ—†λŠ” μ½”λ“œ μ‚¬μš© ν‚€', missingInTemplate); - } + 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 unusedInTemplate = difference(templateKeys, codeKeys); - if (unusedInTemplate.length > 0) { - warnings.push(`[Unused Key] template.toml λ―Έμ‚¬μš© ν‚€ ${unusedInTemplate.length}개`); - printList('μ½”λ“œμ—μ„œ μ‚¬μš©λ˜μ§€ μ•ŠλŠ” ν‚€', unusedInTemplate); + 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) { diff --git a/tools/i18n-scanner/report.js b/tools/i18n-scanner/report.js index ace4b1a2..190b451e 100644 --- a/tools/i18n-scanner/report.js +++ b/tools/i18n-scanner/report.js @@ -5,9 +5,25 @@ 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 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', @@ -81,7 +97,6 @@ function parseTomlKeys(filePath) { continue; } - // Strip quotes if present if (key.startsWith('"') && key.endsWith('"')) { key = key.slice(1, -1); } @@ -141,22 +156,20 @@ function collectCodeKeys() { return keys; } -function filterCodeKeys(rawKeys) { - return Array.from(rawKeys).filter((k) => - !k.includes('.msg.') && - !k.includes('.ui.') && - !k.includes('.err.') && - !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.') +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.') ); } @@ -170,62 +183,82 @@ function difference(aSet, bSet) { 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: { + 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: [], - }, - }; + }; - const templateResult = parseTomlKeys(TEMPLATE_PATH); - if (!templateResult.ok) { - 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); + if (!resources.ok) { + report.errors.push(resources.error); continue; } - langKeyMap.set(fileName, langResult.keys); - } - for (const [fileName, langKeys] of langKeyMap.entries()) { - const missingInLang = difference(templateKeys, langKeys); - if (missingInLang.length > 0) { - report.errors.push( - `[Sync Error] ${fileName} λˆ„λ½ ν‚€ ${missingInLang.length}개`, - ); - report.details.missing_in_lang[fileName] = missingInLang; + 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 missingInTemplate = difference(codeKeys, templateKeys); - if (missingInTemplate.length > 0) { - report.errors.push( - `[Missing Key] template.toml λˆ„λ½ ν‚€ ${missingInTemplate.length}개`, - ); - report.details.missing_in_template = missingInTemplate; - } + 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(templateKeys, codeKeys); - if (unusedInTemplate.length > 0) { - report.warnings.push( - `[Unused Key] template.toml λ―Έμ‚¬μš© ν‚€ ${unusedInTemplate.length}개`, - ); - report.details.unused_in_template = unusedInTemplate; + 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; @@ -258,8 +291,12 @@ function main() { 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();