From 668bf05b48b279b9462e48716e18e8038860eecf Mon Sep 17 00:00:00 2001 From: donghyeon-ka Date: Sun, 26 Jul 2026 16:17:39 +0900 Subject: [PATCH] feat: add internationalization message platform --- config/ci/gates.json | 8 +- .../VD-06-intl-typed-message-catalog.md | 96 ++++++ .../frontend-platform-capability-review.md | 33 +- ...rontend-platform-implementation-roadmap.md | 20 ++ .../frontend-ports-adapters-and-boundaries.md | 9 + .../routing-pages-and-patterns.md | 7 + .../typescript-state-and-data-flow.md | 27 ++ docs/styling/design-system-platform.md | 18 +- .../frontend-platform-testing-strategy.md | 17 + eslint.config.js | 1 + package.json | 4 + scripts/check-i18n.mjs | 108 +++++++ scripts/test-sample-removal.mjs | 10 + src/features/installed-feature-messages.js | 10 + .../contracts/reference-mapper.ts | 8 +- .../contracts/reference-message-catalog.js | 22 ++ .../reference-resource-detail-page.tsx | 8 +- .../presentation/use-reference-feature.ts | 2 +- .../boundaries/boot-error-shell.jsx | 20 +- .../boundaries/chunk-recovery-boundary.tsx | 50 ++- .../boundaries/render-error-boundary.jsx | 5 +- src/presentation/components/async-surface.jsx | 46 ++- src/presentation/components/error-copy.js | 28 +- .../components/state-surfaces.jsx | 28 +- .../design-system/primitives/core.tsx | 16 +- .../design-system/primitives/forms.tsx | 4 +- .../design-system/primitives/navigation.tsx | 14 +- .../design-system/primitives/overlays.tsx | 7 +- src/presentation/forms/form-components.tsx | 4 +- src/presentation/forms/form-contracts.ts | 36 ++- src/presentation/forms/use-app-form.ts | 17 +- .../forms/use-dirty-navigation-guard.tsx | 10 +- src/presentation/i18n/catalog.ts | 304 ++++++++++++++++++ src/presentation/i18n/formatters.ts | 96 ++++++ src/presentation/i18n/index.ts | 29 ++ src/presentation/i18n/locale-provider.tsx | 118 +++++++ src/presentation/i18n/message-contract.ts | 135 ++++++++ src/presentation/layouts/app-shell.jsx | 73 +++-- src/presentation/pages/not-found-page.jsx | 8 +- src/presentation/routes/app-router.tsx | 72 +++-- src/presentation/styles/theme.css | 17 +- src/presentation/templates/page-templates.tsx | 36 ++- tests/component/async-surface.test.jsx | 4 +- .../chunk-recovery-boundary.test.tsx | 4 +- .../component/design-system-platform.test.tsx | 2 +- tests/component/locale-platform.test.tsx | 120 +++++++ tests/component/page-templates.test.tsx | 2 +- tests/component/render-boundary.test.jsx | 6 +- tests/e2e/i18n.spec.js | 41 +++ .../reference-contract.test.ts | 4 +- .../reference-feature/reference-page.test.tsx | 14 +- .../i18n/forbidden/backend-message.tsx | 5 + .../i18n/forbidden/hardcoded-common.tsx | 3 + tests/fixtures/i18n/forbidden/unsafe-html.tsx | 3 + .../fixtures/typecheck/invalid-message-key.ts | 3 + .../typecheck/invalid-message-params.ts | 5 + tests/unit/i18n-contract.test.ts | 115 +++++++ 57 files changed, 1711 insertions(+), 201 deletions(-) create mode 100644 docs/architecture/decisions/VD-06-intl-typed-message-catalog.md create mode 100644 scripts/check-i18n.mjs create mode 100644 src/features/installed-feature-messages.js create mode 100644 src/features/reference-feature/contracts/reference-message-catalog.js create mode 100644 src/presentation/i18n/catalog.ts create mode 100644 src/presentation/i18n/formatters.ts create mode 100644 src/presentation/i18n/index.ts create mode 100644 src/presentation/i18n/locale-provider.tsx create mode 100644 src/presentation/i18n/message-contract.ts create mode 100644 tests/component/locale-platform.test.tsx create mode 100644 tests/e2e/i18n.spec.js create mode 100644 tests/fixtures/i18n/forbidden/backend-message.tsx create mode 100644 tests/fixtures/i18n/forbidden/hardcoded-common.tsx create mode 100644 tests/fixtures/i18n/forbidden/unsafe-html.tsx create mode 100644 tests/fixtures/typecheck/invalid-message-key.ts create mode 100644 tests/fixtures/typecheck/invalid-message-params.ts create mode 100644 tests/unit/i18n-contract.test.ts diff --git a/config/ci/gates.json b/config/ci/gates.json index cf78832..1e16676 100644 --- a/config/ci/gates.json +++ b/config/ci/gates.json @@ -82,7 +82,9 @@ { "script": "check:types:fixture:async-overlay", "expect": "fail" }, { "script": "check:types:fixture:route-runtime", "expect": "fail" }, { "script": "check:types:fixture:page-action", "expect": "fail" }, - { "script": "check:types:fixture:icon-button", "expect": "fail" } + { "script": "check:types:fixture:icon-button", "expect": "fail" }, + { "script": "check:types:fixture:i18n-key", "expect": "fail" }, + { "script": "check:types:fixture:i18n-params", "expect": "fail" } ], "logPath": "artifacts/quality/check-types.txt", "evidence": ["artifacts/quality/check-types.txt"], @@ -154,6 +156,8 @@ { "script": "check:architecture", "expect": "pass" }, { "script": "check:design-system", "expect": "pass" }, { "script": "check:design-system:fixture", "expect": "fail" }, + { "script": "check:i18n", "expect": "pass" }, + { "script": "check:i18n:fixture", "expect": "fail" }, { "script": "check:registries", "expect": "pass" }, { "script": "check:registries:fixture", "expect": "fail" }, { "script": "check:routes:fixture", "expect": "fail" } @@ -163,6 +167,8 @@ "artifacts/quality/dependency-report.json", "artifacts/quality/design-system.json", "artifacts/quality/design-system-fixture.json", + "artifacts/quality/i18n.json", + "artifacts/quality/i18n-fixture.json", "artifacts/quality/registries.json", "artifacts/quality/registry-fixture.json", "artifacts/quality/route-registry-fixture.json" diff --git a/docs/architecture/decisions/VD-06-intl-typed-message-catalog.md b/docs/architecture/decisions/VD-06-intl-typed-message-catalog.md new file mode 100644 index 0000000..8938441 --- /dev/null +++ b/docs/architecture/decisions/VD-06-intl-typed-message-catalog.md @@ -0,0 +1,96 @@ +# VD-06: Intl과 typed local message catalog + +- 상태: Accepted +- 결정일: 2026-07-26 +- 적용 브랜치: `feature-frontend-i18n-message-formatting-contract` +- 재검토: 승인 locale·복수형 문법·번역 추출 workflow가 local catalog 범위를 넘을 때 + +## 배경 + +공통 셸, route surface, async/form 상태와 디자인 시스템 기본 문구가 JSX와 +JavaScript에 분산돼 있었다. 날짜는 일부 application mapper에서 고정 locale로 +가공되어 presentation이 locale을 바꿀 수 없었고, direction·누락 key·보간 실패 +정책도 없었다. 반면 현재 skeleton에는 번역 관리 서비스, 실제 번역 승인 절차, +복잡한 ICU 문법이라는 제품 요구가 아직 없다. 이 단계에서 i18n vendor를 기본 +번들에 넣으면 소비 프로젝트가 제거하거나 다시 감싸야 할 의존성만 늘어난다. + +## 결정 + +1. 표준 `Intl.DateTimeFormat`, `NumberFormat`, `RelativeTimeFormat`, + `ListFormat`, `PluralRules`와 typed local catalog를 기본 엔진으로 사용한다. +2. `MessageKey`는 한국어 canonical catalog에서 도출하며 영어와 RTL smoke + catalog는 `satisfies Record`으로 compile-time parity를 + 강제한다. +3. 보간이 필요한 key는 `MessageParameters`에 key별 parameter object를 + 선언한다. 잘못된 key, 누락·초과 parameter는 TypeScript negative fixture가 + 거절한다. +4. 기본 locale은 `ko-KR`, fallback locale도 `ko-KR`이다. 알려지지 않은 locale은 + language fallback 후 `ko-KR`로 정규화한다. 알려지지 않은 key와 누락 보간은 + raw key나 외부 값을 출력하지 않고 안전한 공통 fallback을 반환한다. +5. `en-XA`는 영어 문구를 확장·accent 처리하는 pseudo locale이고 `ar-EG`는 + RTL 동작 smoke locale이다. 이 두 locale은 실제 제품 번역 완료를 의미하지 + 않는다. +6. 날짜 formatter의 기본 timezone은 테스트와 SSR/브라우저 결과가 흔들리지 + 않도록 `UTC`다. 제품 timezone이 필요하면 호출자가 명시한다. invalid + date/number/timezone은 `—`를 반환하고 throw하지 않는다. +7. locale state는 React inbound concern이다. `LocaleProvider`가 copy, + formatter와 ``을 제공하며 application/domain은 미리 번역된 + 문자열 대신 의미 값과 timestamp를 반환한다. +8. backend `message`, raw HTML, stack과 내부 key를 catalog 입력으로 신뢰하지 + 않는다. transport/application failure kind를 등록된 사용자 message key로 + 매핑한 뒤 presentation이 해석한다. +9. key rename은 즉시 제거하지 않고 `MESSAGE_KEY_ALIASES`에 compatibility alias를 + 둔다. alias는 새 호출의 타입에 포함하지 않아 신규 코드는 canonical key만 + 사용한다. +10. extraction, ICU rich message, 번역 SaaS 또는 framework adapter가 필요해지면 + `presentation/i18n` public API 뒤에서 교체한다. vendor type은 feature와 + design-system public prop으로 노출하지 않는다. + +## 실행 경계 + +```text +route/form/failure 의미 값 + -> presentation message key + -> LocaleProvider + -> typed catalog / Intl formatter + -> text node와 accessible name +``` + +- canonical catalog: `src/presentation/i18n/catalog.ts` +- feature contribution: `src/features/*/contracts/*-message-catalog.js`를 + `src/features/installed-feature-messages.js`에서 조립 +- key/보간/fallback/alias: `message-contract.ts` +- locale-safe value formatting: `formatters.ts` +- React composition과 document metadata: `locale-provider.tsx` +- public entry: `src/presentation/i18n/index.ts` + +## 검증 + +- `check:i18n`은 catalog key와 placeholder parity, common UI의 한국어 literal, + backend message JSX 렌더링과 raw HTML 사용을 검사한다. +- `check:i18n:fixture`는 세 금지 사례를 실제로 거절해야 성공으로 인정된다. +- type negative fixture는 unknown key와 잘못된 parameter shape를 거절한다. +- unit test는 fallback, alias, pseudo 확장, direction과 timezone/number/relative/ + list/plural/select의 결정성을 검증한다. +- component test는 document `lang/dir`, RTL Tabs와 direction-aware pagination, + Drawer semantics를 검증한다. +- Playwright는 320px pseudo reflow와 RTL compact shell/Drawer/focus restore를 + Chromium, Firefox, WebKit project에서 실행한다. + +## 한계와 재검토 조건 + +현재 catalog는 실제 번역 승인, ICU rich text, locale별 plural 문장 전체 조합, +메시지 추출/번역 메모리와 서버 locale negotiation을 제공하지 않는다. 다음 중 +하나가 확인되면 별도 ADR로 엔진을 재평가한다. + +- 세 개 이상의 실제 승인 locale과 번역 담당 workflow +- 복수형·성별·select가 한 문장 안에서 중첩되는 제품 copy +- server/client extraction, namespace lazy-loading 또는 번역 SaaS 연동 +- SSR locale negotiation과 hydration 일치가 필요한 rendering mode + +## Rollback + +`ko-KR` catalog가 기존 기본 문구를 보존하므로 provider를 고정 locale adapter로 +되돌려도 기본 UX를 유지한다. formatter/vendor 교체 시 public `message`, +`date`, `number`, `relativeTime`, `list`, `plural`, `select` 계약과 negative +fixture는 유지한다. alias는 migration window 종료 근거 없이 제거하지 않는다. diff --git a/docs/architecture/frontend-platform-capability-review.md b/docs/architecture/frontend-platform-capability-review.md index 4b86056..0b4b7b1 100644 --- a/docs/architecture/frontend-platform-capability-review.md +++ b/docs/architecture/frontend-platform-capability-review.md @@ -12,7 +12,7 @@ - 기본 번들에 포함할 역량과 필요할 때 설치할 확장 역량을 구분한다. - 특정 벤더를 채택하더라도 제품 코드가 벤더 API에 직접 결합되지 않는지 확인한다. -최초 검토 기준은 `develop`의 `cb195f8`이며, RP-01~RP-04 구현 결과를 이 문서에 +최초 검토 기준은 `develop`의 `cb195f8`이며, RP-01~RP-08 구현 결과를 이 문서에 누적 반영했다. 이후 구현으로 경로나 세부 내용이 달라질 수 있으므로, 각 항목은 문서의 경로뿐 아니라 해당 테스트와 아키텍처 게이트로 계속 검증해야 한다. @@ -33,13 +33,14 @@ 특히 다음은 선행 해결이 필요하다. -RP-01~RP-05에서 TypeScript 도구 안전망, application runtime 주입, +RP-01~RP-08에서 TypeScript 도구 안전망, application runtime 주입, query/mutation inbound adapter, HTTP 실행 계약과 executable route/release -recovery 계약, 제거 가능한 reference 수직 슬라이스는 구현됐다. 현재 선행 해결 +recovery 계약, 제거 가능한 reference 수직 슬라이스, form/page, design system과 +i18n 실행 경계는 구현됐다. 현재 선행 해결 대상은 다음과 같다. -1. 폼, 페이지 템플릿, 확장된 디자인 시스템과 컴포넌트 워크벤치 -2. 국제화, diagnostics, optional adapter recipe와 심화 품질 게이트 +1. diagnostics/telemetry 실제 producer 연결 +2. registry evidence, 공급망과 optional adapter recipe 심화 게이트 따라서 현재 상태를 “프론트 공통부가 모두 구현됐다”고 표현하면 범위가 과장된다. 더 정확한 표현은 다음과 같다. @@ -73,13 +74,13 @@ recovery 계약, 제거 가능한 reference 수직 슬라이스는 구현됐다. | 클라이언트 상태 | 부분 준비 | local state, theme context, session external store | 상태 소유권 표와 typed external-store 예제 | | 범용 global store | 프로젝트 선택 | 별도 라이브러리 없음 | 필요 조건에 따라 Zustand/Redux Toolkit/state machine 선택 | | 라우팅 | 준비됨 | Data Router, typed runtime map, codec, metadata consumer, bounded chunk recovery | reference feature route와 release E2E로 사용 범위 확장 | -| 앱 셸·반응형 | 준비됨 | native modal Drawer, compact/desktop layout, Escape/link dismiss와 focus restore | RP-08에서 RTL/direction 검증 | +| 앱 셸·반응형 | 준비됨 | native modal Drawer, compact/desktop layout, Escape/link dismiss/focus restore, pseudo reflow와 RTL direction | compact browser matrix 유지 | | 페이지 템플릿 | 준비됨 | Standard/Collection/Detail/Form/Status와 public design-system entry | feature별 slot 조합 유지 | | 디자인 토큰 | 준비됨 | primitive/semantic/component CSS, 48-token 자동 계약, dark/forced-colors/reduced-motion | 제품 brand token은 외부 프로젝트에서 확장 | | 공통 UI | 준비됨 | action/form/feedback/overlay/navigation primitive와 pattern, compatibility export | Storybook/visual은 RP-10 | | 아이콘 | 준비됨 | Lucide static vendor facade와 semantic icon/IconButton 접근성 계약 | 의미 icon 추가 시 bundle/접근성 기준 적용 | | 폼 | 준비됨 | Zod 기반 local facade, error summary/focus, 422 allowlist, dirty/pending/conflict 정책 | 복합 form 요구가 생기면 VD-04 조건으로 vendor adapter 평가 | -| 국제화 | 미제공 | 한국어 문자열·locale이 하드코딩 | typed message/formatter/locale/RTL 경계 | +| 국제화 | 준비됨 | 137-key typed catalog, locale provider, Intl formatter, safe fallback/alias, pseudo·RTL gate | 실제 locale·번역 승인은 프로젝트에서 연결 | | logging/diagnostics | 미제공 | telemetry port는 있으나 logger 없음 | redaction이 적용된 diagnostics/logging 경계 | | telemetry | 부분 준비 | registry, queue, redaction 존재 | HTTP·boot·cache·storage·route 사건에 실제 연결 | | 비동기 상태 불변식 | 준비됨 | 배타적 typed overlay, stale latch, 실제 retry/conflict action | reference 화면에서 전체 상태 전시 | @@ -250,6 +251,24 @@ known vulnerability, license policy, SBOM/provenance를 pinned tool로 검사해 - registry/compatibility의 실제 diff와 orphan reference 검사 - transitive vulnerability, license, SBOM/provenance 공급망 gate +#### RP-08에서 국제화 실행 경계 구현 + +`src/presentation/i18n`은 shell, route, async/form error, page template와 +design-system 기본 copy의 canonical 경계다. `MessageKey`와 key별 +`MessageParameters`가 잘못된 key/보간을 compile time에 막고, runtime +`resolveMessage`는 unknown locale/key와 누락 보간에서 raw 값 대신 안전한 +fallback을 반환한다. application mapper는 locale-formatted date를 반환하지 +않고 timestamp를 유지하며 presentation formatter가 `UTC` 또는 명시 timezone을 +적용한다. + +`LocaleProvider`는 `ko-KR`, `en-US`, `en-XA`, `ar-EG` smoke set과 document +`lang/dir`을 동기화한다. `en-XA`는 긴 문구 reflow, `ar-EG`는 logical CSS, +Drawer, Tabs arrow와 pagination 방향 icon을 검증하기 위한 개발 locale이다. +실제 아랍어 번역 완료를 뜻하지 않는다. `check:i18n`과 negative fixture는 common +UI literal, backend raw message render와 raw HTML interpolation을 거절한다. +새 key rename은 canonical type에는 넣지 않고 runtime alias/migration window로 +호환한다. + ### 5.3 P2: 경계와 recipe를 제공할 선택 항목 다음 기능을 모든 앱의 초기 번들에 설치할 필요는 없다. 대신 port 또는 local diff --git a/docs/architecture/frontend-platform-implementation-roadmap.md b/docs/architecture/frontend-platform-implementation-roadmap.md index fdc7754..82f6367 100644 --- a/docs/architecture/frontend-platform-implementation-roadmap.md +++ b/docs/architecture/frontend-platform-implementation-roadmap.md @@ -651,6 +651,26 @@ token rename은 alias/migration 기간을 두고, vendor adapter와 local API co RP-08은 기존 기본 언어 catalog를 fallback으로 유지한다. 번역 catalog를 파괴적으로 덮어쓰지 않는다. +**구현 증거 (2026-07-26)** + +- VD-06에서 dependency를 추가하지 않는 browser `Intl` + typed local catalog를 + 채택하고 vendor 재평가 조건, fallback과 key migration 정책을 문서화했다. +- `src/presentation/i18n`이 137개 common key, key별 interpolation, + `ko-KR` fallback, compatibility alias, date/number/relative/list/plural/select + formatter와 `LocaleProvider`를 제공한다. +- shell, route lifecycle/access/recovery, async/form error, page template와 + design-system default copy가 catalog consumer로 연결됐다. +- feature route copy는 feature-owned catalog contribution으로 분리되어 reference + feature 제거 시 source와 built artifact에 전용 message key가 남지 않는다. +- reference application mapper는 locale-formatted date 대신 timestamp를 + 반환하고 presentation formatter가 UTC 또는 명시 timezone을 적용한다. +- `check:i18n`과 type negative fixture가 catalog/placeholder 불일치, + hardcoded common literal, backend raw message render, unsafe HTML, + unknown key와 interpolation mismatch를 거절한다. +- unit/component/browser test가 safe fallback, pseudo 320px reflow, + document `lang/dir`, RTL Drawer/Tabs/directional icon과 deterministic formatter를 + 검증한다. + ### 09. `feature-frontend-diagnostics-telemetry-runtime` **목표** diff --git a/docs/architecture/frontend-ports-adapters-and-boundaries.md b/docs/architecture/frontend-ports-adapters-and-boundaries.md index db32916..9ff1fc4 100644 --- a/docs/architecture/frontend-ports-adapters-and-boundaries.md +++ b/docs/architecture/frontend-ports-adapters-and-boundaries.md @@ -178,6 +178,15 @@ template public API이며 feature와 shell은 이 entry만 소비한다. Lucide port가 아니다. native Dialog/Drawer/Menu/Tabs의 focus·keyboard 상태도 presentation이 소유하고 use case나 outbound adapter로 올리지 않는다. +RP-08에서 i18n은 application output port가 아니라 React inbound adapter의 +local facade로 확정됐다. domain/application은 locale이나 번역 문장을 알지 않고 +timestamp, number, failure kind 같은 의미 값만 반환한다. +`src/presentation/i18n`이 typed message catalog, formatter, fallback, +``과 pseudo/RTL smoke를 소유한다. backend raw `message`는 +application failure registry를 우회해 렌더링할 수 없으며 `check:i18n` negative +fixture가 이 경계를 집행한다. 번역 vendor를 나중에 선택해도 이 facade 뒤의 +adapter만 교체한다. + 이 문서의 목표 구조는 기존 기반을 폐기하는 것이 아니라 이러한 불일치를 제거하는 것이다. diff --git a/docs/architecture/routing-pages-and-patterns.md b/docs/architecture/routing-pages-and-patterns.md index 114eb0b..4aebd6c 100644 --- a/docs/architecture/routing-pages-and-patterns.md +++ b/docs/architecture/routing-pages-and-patterns.md @@ -447,6 +447,13 @@ controller가 소유하지 않는 것: | Page Template | 반복 layout/state | 접근성과 반응형 구조 재사용 | data fetching을 template에 포함 | | Registry + Runtime Map | route/operation/event | 선언과 실행 완전성 | 모든 설정을 하나의 거대 전역 파일에 집중 | +RP-08 이후 route contract의 `title`/`navigation` 필드는 fallback metadata이며 +실제 document title, navigation, loading/error/access surface는 +`route..title|navigation` typed catalog key를 해석한다. route params, +search, backend message를 translation key로 조립하지 않는다. locale 변경은 +현재 route를 재요청하거나 query key를 바꾸지 않고 document title과 화면 copy만 +다시 렌더링한다. + 패턴은 추상화 파일만 만든 것으로 완료되지 않는다. reference usage, negative architecture test, 실패 상태 test가 있어야 제공된 패턴으로 본다. diff --git a/docs/architecture/typescript-state-and-data-flow.md b/docs/architecture/typescript-state-and-data-flow.md index 894662a..541d837 100644 --- a/docs/architecture/typescript-state-and-data-flow.md +++ b/docs/architecture/typescript-state-and-data-flow.md @@ -485,6 +485,32 @@ raw response body, stack, token, URL query, PII를 사용자 copy나 일반 log - logging failure가 제품 flow를 실패시키지 않음 - consent가 필요한 analytics와 essential diagnostics를 분리 +### 7.4 locale, message와 표시 값 + +RP-08부터 locale은 presentation-owned React context다. server/application +state에 번역된 문자열을 저장하거나 query key에 locale을 넣는 것은 응답 자체가 +locale별 데이터인 경우에만 허용한다. 공통 UI copy 변경 때문에 query cache를 +복제하지 않는다. + +```text +API timestamp/number/failure kind + -> schema + mapper (의미 값 유지) + -> application result + -> presentation controller + -> useLocale().date/number/message +``` + +- message key는 `MessageKey` union이며 interpolation은 key별 tuple type이다. +- unknown external key는 `resolveMessage`에 전달해도 raw key가 표시되지 않는다. +- backend `message`는 diagnostic input일 수 있지만 사용자 copy가 아니다. +- form validation code는 `ParameterlessMessageKey` allowlist로 mapping한다. +- date의 기본 timezone은 UTC이고 제품 timezone은 presentation 호출자가 + 명시한다. +- pseudo/RTL locale state는 local interaction state이며 persistence와 server + synchronization을 기본 제공하지 않는다. +- key rename은 typed canonical key를 먼저 이동하고 runtime alias에 migration + 기간을 둔다. + ## 8. 폼 표준 VD-04에 따라 현재 기본 엔진은 React native form event와 controlled value이며 @@ -584,4 +610,5 @@ backend message와 알 수 없는 path는 field copy로 사용하지 않는다. - 상태 종류별 소유권이 테스트와 문서에서 확인된다. - token은 UI와 일반 storage/store에 노출되지 않는다. - failure와 validation의 각 계층이 typed mapper로 분리된다. +- common UI copy, locale formatter와 direction이 typed i18n facade를 통과한다. - query/mutation/form recipe만으로 새 기능을 만들 수 있다. diff --git a/docs/styling/design-system-platform.md b/docs/styling/design-system-platform.md index 642bf3d..c5538e6 100644 --- a/docs/styling/design-system-platform.md +++ b/docs/styling/design-system-platform.md @@ -534,8 +534,9 @@ Radix는 overlay와 primitive composition을 중심으로 평가할 수 있다. ## 9. 국제화 계약 -디자인 시스템 primitive는 한국어 문구를 내부 기본값으로 숨기지 않는다. -접근성 label이나 오류 문구가 필요하면 명시적인 prop 또는 message key를 받는다. +RP-08에서 디자인 시스템 primitive의 닫기, alert, toast, 글자 수 같은 기본 +문구는 `useLocale()` typed catalog로 이동했다. 제품 의미를 가진 label은 여전히 +명시적인 prop으로 받으며 primitive가 feature message key를 소유하지 않는다. 필수 국제화 기반: @@ -551,6 +552,17 @@ Radix는 overlay와 primitive composition을 중심으로 평가할 수 있다. - pseudo-locale - RTL story와 E2E smoke +현재 구현 위치와 실패 정책: + +- `presentation/i18n/catalog.ts`: 137개 canonical common key와 locale parity +- `message-contract.ts`: key별 interpolation, fallback과 compatibility alias +- `formatters.ts`: UTC-default date, number, relative time, list, plural/select +- `locale-provider.tsx`: document `lang/dir`과 React consumer API +- missing key/parameter와 formatter 예외: 빈 문자열이나 raw key 대신 안전한 + fallback +- `en-XA`: 긴 문자열/320px reflow, `ar-EG`: logical layout/RTL keyboard smoke +- backend raw message와 translated HTML: 정적 negative gate에서 거절 + 금지 패턴: - 번역 문장 중간에 JSX 문자열을 연결한다. @@ -559,7 +571,7 @@ Radix는 overlay와 primitive composition을 중심으로 평가할 수 있다. - route title, navigation label, toast message를 JSX literal로 분산한다. - 번역 누락 시 빈 문자열을 렌더링한다. -Storybook toolbar에서 최소한 다음 조합을 전환할 수 있어야 한다. +RP-10의 Storybook toolbar에서 최소한 다음 조합을 전환할 수 있어야 한다. - `ko-KR`, light - `en-US`, light diff --git a/docs/testing/frontend-platform-testing-strategy.md b/docs/testing/frontend-platform-testing-strategy.md index 50721e4..9ca2ba2 100644 --- a/docs/testing/frontend-platform-testing-strategy.md +++ b/docs/testing/frontend-platform-testing-strategy.md @@ -569,6 +569,23 @@ RP-07의 실행 경로는 `check:design-system`, baseline은 VD-08/RP-10에서 추가하며 현재 runtime gallery를 isolated workshop 완료 증거로 사용하지 않는다. +RP-08의 국제화 실행 경로는 `check:i18n`, `check:i18n:fixture`, +`check:types:fixture:i18n-key`, `check:types:fixture:i18n-params`, +`tests/unit/i18n-contract.test.ts`, `tests/component/locale-platform.test.tsx`와 +`tests/e2e/i18n.spec.js`다. + +- unit: locale catalog/placeholder parity, safe fallback/alias, pseudo expansion, + direction과 UTC date/number/relative/list/plural/select +- component: document `lang/dir`, RTL tab arrow semantics, direction-aware + pagination과 modal Drawer +- E2E: 320px pseudo reflow와 compact RTL shell/Drawer/focus restore +- negative: common UI locale literal, backend message JSX render, raw translated + HTML, unknown key와 잘못된 interpolation parameter + +`ar-EG` catalog가 영어 smoke copy를 재사용하는 것은 RTL behavior 검증용이며 +번역 품질 PASS가 아니다. 실제 locale catalog에는 언어 담당 승인 evidence가 +추가되어야 한다. + ### 10.1 Component behavior - native role/name/value diff --git a/eslint.config.js b/eslint.config.js index 6697937..1c2d577 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -76,6 +76,7 @@ export default [ "artifacts/**", "tests/fixtures/typecheck/**", "tests/fixtures/architecture/forbidden/**", + "tests/fixtures/i18n/forbidden/**", "tests/fixtures/security/forbidden/**", ], }, diff --git a/package.json b/package.json index f12f677..d88af41 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,8 @@ "check:architecture": "node scripts/check-architecture.mjs", "check:design-system": "node scripts/check-design-system.mjs", "check:design-system:fixture": "node scripts/check-design-system.mjs --fixture", + "check:i18n": "node scripts/check-i18n.mjs", + "check:i18n:fixture": "node scripts/check-i18n.mjs --fixture", "check:types": "corepack pnpm check:types:app && corepack pnpm check:types:node && corepack pnpm check:types:test", "check:types:app": "tsc --project tsconfig.app.json", "check:types:node": "tsc --project tsconfig.node.json", @@ -30,6 +32,8 @@ "check:types:fixture:route-runtime": "tsc --ignoreConfig --allowJs --checkJs --strict --noEmit --skipLibCheck --target ES2022 --module ESNext --moduleResolution Bundler tests/fixtures/typecheck/invalid-route-runtime.ts", "check:types:fixture:page-action": "tsc --ignoreConfig --allowJs --checkJs --strict --noEmit --skipLibCheck --target ES2022 --module ESNext --moduleResolution Bundler --jsx react-jsx tests/fixtures/typecheck/invalid-page-action.tsx", "check:types:fixture:icon-button": "tsc --ignoreConfig --allowJs --checkJs --strict --noEmit --skipLibCheck --target ES2022 --module ESNext --moduleResolution Bundler --jsx react-jsx tests/fixtures/typecheck/invalid-icon-button.tsx", + "check:types:fixture:i18n-key": "tsc --ignoreConfig --strict --noEmit --skipLibCheck --target ES2022 --module ESNext --moduleResolution Bundler tests/fixtures/typecheck/invalid-message-key.ts", + "check:types:fixture:i18n-params": "tsc --ignoreConfig --strict --noEmit --skipLibCheck --target ES2022 --module ESNext --moduleResolution Bundler tests/fixtures/typecheck/invalid-message-params.ts", "test:runtime-schema": "vitest run tests/runtime-schema --reporter=default --reporter=junit --outputFile.junit=artifacts/tests/runtime-schema.xml --passWithNoTests", "test:unit": "vitest run tests/unit --reporter=default --reporter=junit --outputFile.junit=artifacts/tests/unit.xml", "test:component": "vitest run tests/component --reporter=default --reporter=junit --outputFile.junit=artifacts/tests/component.xml", diff --git a/scripts/check-i18n.mjs b/scripts/check-i18n.mjs new file mode 100644 index 0000000..c626391 --- /dev/null +++ b/scripts/check-i18n.mjs @@ -0,0 +1,108 @@ +import { mkdir, readFile, readdir, writeFile } from "node:fs/promises"; +import path from "node:path"; + +// @ts-expect-error Node 24 executes erasable TypeScript for this build-time gate. +import { EN_MESSAGES, KO_MESSAGES, MESSAGE_CATALOGS } from "../src/presentation/i18n/catalog.ts"; + +const fixtureMode = process.argv.includes("--fixture"); +const failures = []; +const sourceExtensions = /\.(?:js|jsx|mjs|ts|tsx|mts)$/; +const koreanLiteral = /[가-힣]/; +const rawFailureRender = + /(?} */ +async function listSourceFiles(directory) { + const result = []; + for (const entry of await readdir(directory, { withFileTypes: true })) { + const target = path.join(directory, entry.name); + if (entry.isDirectory()) result.push(...(await listSourceFiles(target))); + else if (sourceExtensions.test(entry.name)) result.push(target); + } + return result; +} + +/** @param {string} template */ +function placeholders(template) { + return [...template.matchAll(/\{([a-zA-Z][a-zA-Z0-9]*)\}/g)] + .map((match) => match[1]) + .sort(); +} + +if (!fixtureMode) { + const canonicalKeys = Object.keys(KO_MESSAGES).sort(); + for (const [locale, catalog] of Object.entries(MESSAGE_CATALOGS)) { + const readableCatalog = + /** @type {Readonly>} */ (catalog); + const canonicalCatalog = + /** @type {Readonly>} */ (KO_MESSAGES); + const keys = Object.keys(catalog).sort(); + if (JSON.stringify(keys) !== JSON.stringify(canonicalKeys)) { + failures.push(`${locale} catalog keys do not match ko-KR`); + } + for (const key of canonicalKeys) { + if ( + JSON.stringify(placeholders(readableCatalog[key] ?? "")) !== + JSON.stringify(placeholders(canonicalCatalog[key] ?? "")) + ) { + failures.push(`${locale}:${key} interpolation parameters do not match`); + } + } + } + if (Object.keys(EN_MESSAGES).length !== canonicalKeys.length) { + failures.push("en-US catalog key count does not match ko-KR"); + } +} + +const commonDirectories = [ + "src/presentation/boundaries", + "src/presentation/components", + "src/presentation/design-system", + "src/presentation/forms", + "src/presentation/layouts", + "src/presentation/routes", + "src/presentation/templates", +]; +const sources = fixtureMode + ? await listSourceFiles("tests/fixtures/i18n/forbidden") + : ( + await Promise.all(commonDirectories.map((directory) => listSourceFiles(directory))) + ).flat(); + +for (const file of sources) { + const source = await readFile(file, "utf8"); + if (koreanLiteral.test(source)) { + failures.push(`hardcoded common user-facing locale literal in ${file}`); + } + if (rawFailureRender.test(source)) { + failures.push(`raw backend/error message rendered in ${file}`); + } + if (source.includes("dangerouslySetInnerHTML")) { + failures.push(`untrusted HTML interpolation boundary in ${file}`); + } +} + +const report = { + schemaVersion: 1, + mode: fixtureMode ? "negative-fixture" : "source", + localeCount: Object.keys(MESSAGE_CATALOGS).length + 1, + messageKeyCount: Object.keys(KO_MESSAGES).length, + checkedFiles: sources.length, + failures, + passed: failures.length === 0, +}; +await mkdir("artifacts/quality", { recursive: true }); +await writeFile( + fixtureMode + ? "artifacts/quality/i18n-fixture.json" + : "artifacts/quality/i18n.json", + `${JSON.stringify(report, null, 2)}\n`, +); + +if (failures.length > 0) { + process.stderr.write(`I18n contract failed:\n${failures.join("\n")}\n`); + process.exit(1); +} +process.stdout.write( + `I18n contract: ${report.messageKeyCount} keys across ${report.localeCount} locales PASS\n`, +); diff --git a/scripts/test-sample-removal.mjs b/scripts/test-sample-removal.mjs index ee3fe8b..647df44 100644 --- a/scripts/test-sample-removal.mjs +++ b/scripts/test-sample-removal.mjs @@ -89,6 +89,12 @@ export function createInstalledFeatureInputs(_context: FeatureContext) { } `; +const emptyMessages = `export const INSTALLED_MESSAGE_CATALOGS = Object.freeze({ + "ko-KR": Object.freeze({}), + "en-US": Object.freeze({}), +}); +`; + /** @param {string} directory @returns {Promise} */ async function filesBelow(directory) { const entries = await readdir(directory, { withFileTypes: true }); @@ -135,6 +141,10 @@ await writeFile( path.join(fixtureRoot, "src/features/installed-feature-adapters.ts"), emptyAdapters, ); +await writeFile( + path.join(fixtureRoot, "src/features/installed-feature-messages.js"), + emptyMessages, +); /** @type {string[]} */ const residue = []; diff --git a/src/features/installed-feature-messages.js b/src/features/installed-feature-messages.js new file mode 100644 index 0000000..8584a9f --- /dev/null +++ b/src/features/installed-feature-messages.js @@ -0,0 +1,10 @@ +import { REFERENCE_MESSAGE_CATALOGS } from "./reference-feature/contracts/reference-message-catalog.js"; + +export const INSTALLED_MESSAGE_CATALOGS = Object.freeze({ + "ko-KR": Object.freeze({ + ...REFERENCE_MESSAGE_CATALOGS["ko-KR"], + }), + "en-US": Object.freeze({ + ...REFERENCE_MESSAGE_CATALOGS["en-US"], + }), +}); diff --git a/src/features/reference-feature/contracts/reference-mapper.ts b/src/features/reference-feature/contracts/reference-mapper.ts index 06b39a6..dd6098e 100644 --- a/src/features/reference-feature/contracts/reference-mapper.ts +++ b/src/features/reference-feature/contracts/reference-mapper.ts @@ -6,7 +6,7 @@ import { export type ReferenceResourceView = Readonly<{ resourceId: string; title: string; - createdAtLabel: string | null; + createdAt: string | null; optimistic?: boolean; }>; @@ -44,14 +44,10 @@ export function mapReferenceOperation( export function toReferenceView( resource: ReferenceResource, - formatDate: (value: Date) => string = (value) => - new Intl.DateTimeFormat("ko-KR").format(value), ): ReferenceResourceView { return Object.freeze({ resourceId: resource.id, title: resource.displayName, - createdAtLabel: resource.createdAt - ? formatDate(new Date(resource.createdAt)) - : null, + createdAt: resource.createdAt, }); } diff --git a/src/features/reference-feature/contracts/reference-message-catalog.js b/src/features/reference-feature/contracts/reference-message-catalog.js new file mode 100644 index 0000000..c8bfa8c --- /dev/null +++ b/src/features/reference-feature/contracts/reference-message-catalog.js @@ -0,0 +1,22 @@ +export const REFERENCE_MESSAGE_CATALOGS = Object.freeze({ + "ko-KR": Object.freeze({ + "route.REFERENCE_RESOURCE_LIST.navigation": "Reference feature", + "route.REFERENCE_RESOURCE_LIST.title": "Reference resources", + "route.REFERENCE_RESOURCE_DETAIL.navigation": "Reference detail", + "route.REFERENCE_RESOURCE_DETAIL.title": "Reference resource detail", + "route.REFERENCE_RESOURCE_FORM.navigation": "Reference form", + "route.REFERENCE_RESOURCE_FORM.title": "Create reference resource", + "route.REFERENCE_RESOURCE_STATUS.navigation": "Reference status", + "route.REFERENCE_RESOURCE_STATUS.title": "Reference resource status", + }), + "en-US": Object.freeze({ + "route.REFERENCE_RESOURCE_LIST.navigation": "Reference feature", + "route.REFERENCE_RESOURCE_LIST.title": "Reference resources", + "route.REFERENCE_RESOURCE_DETAIL.navigation": "Reference detail", + "route.REFERENCE_RESOURCE_DETAIL.title": "Reference resource detail", + "route.REFERENCE_RESOURCE_FORM.navigation": "Reference form", + "route.REFERENCE_RESOURCE_FORM.title": "Create reference resource", + "route.REFERENCE_RESOURCE_STATUS.navigation": "Reference status", + "route.REFERENCE_RESOURCE_STATUS.title": "Reference resource status", + }), +}); diff --git a/src/features/reference-feature/presentation/reference-resource-detail-page.tsx b/src/features/reference-feature/presentation/reference-resource-detail-page.tsx index 9b62f3b..15104b7 100644 --- a/src/features/reference-feature/presentation/reference-resource-detail-page.tsx +++ b/src/features/reference-feature/presentation/reference-resource-detail-page.tsx @@ -5,9 +5,11 @@ import { DetailPage, } from "../../../presentation/design-system/index.js"; import { useRouteInput } from "../../../presentation/routes/app-router.js"; +import { useLocale } from "../../../presentation/i18n/index.js"; import { useReferenceDetail } from "./use-reference-feature.js"; export default function ReferenceResourceDetailPage() { + const { date, message } = useLocale(); const route = useRouteInput(); const resourceId = String(route.params.resourceId); const { query } = useReferenceDetail(resourceId); @@ -30,7 +32,11 @@ export default function ReferenceResourceDetailPage() {
Resource ID
{resource.resourceId}
Created
-
{resource.createdAtLabel ?? "표시 정보 없음"}
+
+ {resource.createdAt + ? date(new Date(resource.createdAt)) + : message("common.noDisplayValue")} +
) : (

요약 정보를 준비하고 있습니다.

diff --git a/src/features/reference-feature/presentation/use-reference-feature.ts b/src/features/reference-feature/presentation/use-reference-feature.ts index f051ae6..e9ce422 100644 --- a/src/features/reference-feature/presentation/use-reference-feature.ts +++ b/src/features/reference-feature/presentation/use-reference-feature.ts @@ -70,7 +70,7 @@ export function useReferenceFeature() { { resourceId: `optimistic:${command.name}`, title: command.name, - createdAtLabel: null, + createdAt: null, optimistic: true, }, ]; diff --git a/src/presentation/boundaries/boot-error-shell.jsx b/src/presentation/boundaries/boot-error-shell.jsx index f2aaf78..f97b45a 100644 --- a/src/presentation/boundaries/boot-error-shell.jsx +++ b/src/presentation/boundaries/boot-error-shell.jsx @@ -1,3 +1,5 @@ +import { formatMessage } from "../i18n/index.js"; + /** * @param {{ * kind?: string, @@ -18,32 +20,36 @@ export function BootErrorShell({ }) { return (
-

애플리케이션을 시작할 수 없습니다.

+

{formatMessage("ko-KR", "boot.failure.title")}

-
오류
+
{formatMessage("ko-KR", "boot.field.error")}
{kind}
-
코드
+
{formatMessage("ko-KR", "boot.field.code")}
{code}
{buildId && ( <> -
빌드
+
{formatMessage("ko-KR", "boot.field.build")}
{buildId}
)} {configSchemaVersion && ( <> -
설정 스키마
+
{formatMessage("ko-KR", "boot.field.configSchema")}
{configSchemaVersion}
)} {releaseId && ( <> -
릴리스
+
{formatMessage("ko-KR", "boot.field.release")}
{releaseId}
)}
-

지원 참조: {supportReference}

+

+ {formatMessage("ko-KR", "boot.supportReference", { + reference: supportReference, + })} +

); } diff --git a/src/presentation/boundaries/chunk-recovery-boundary.tsx b/src/presentation/boundaries/chunk-recovery-boundary.tsx index 7b47d2d..d9f25c9 100644 --- a/src/presentation/boundaries/chunk-recovery-boundary.tsx +++ b/src/presentation/boundaries/chunk-recovery-boundary.tsx @@ -3,6 +3,7 @@ import { type ErrorInfo, type ReactNode, } from "react"; +import { useLocale } from "../i18n/index.js"; type RecoveryResult = | Readonly<{ action: "reload-once"; releasePair: string }> @@ -71,27 +72,44 @@ export class ChunkRecoveryBoundary extends Component { const { error, recovery, reason } = this.state; if (error && !isChunkLoadFailure(error)) throw error; if (error && recovery === "checking") { - return ( -
- 새 릴리스 정보를 확인하고 있습니다. -
- ); + return ; } if (error && recovery === "reload-requested") { - return ( -
- 새 버전으로 한 번만 전환합니다. -
- ); + return ; } if (error && recovery === "support") { - return ( -
-

화면 자산을 복구하지 못했습니다.

-

문제가 계속되면 배포 상태와 지원 참조 정보를 확인해 주세요.

-
- ); + return ; } return this.props.children; } } + +function ChunkRecoverySurface({ + recovery, + reason, +}: Readonly<{ + recovery: Exclude; + reason?: string; +}>) { + const { message } = useLocale(); + if (recovery === "checking") { + return ( +
+ {message("chunk.checking")} +
+ ); + } + if (recovery === "reload-requested") { + return ( +
+ {message("chunk.reloadOnce")} +
+ ); + } + return ( +
+

{message("chunk.failure.title")}

+

{message("chunk.failure.description")}

+
+ ); +} diff --git a/src/presentation/boundaries/render-error-boundary.jsx b/src/presentation/boundaries/render-error-boundary.jsx index b7ccd8a..d795398 100644 --- a/src/presentation/boundaries/render-error-boundary.jsx +++ b/src/presentation/boundaries/render-error-boundary.jsx @@ -1,4 +1,5 @@ import { Component } from "react"; +import { formatMessage } from "../i18n/index.js"; /** * @typedef {{ @@ -57,9 +58,9 @@ export class RenderErrorBoundary extends Component { return ( this.props.fallback ?? (
-

error.render_failure

+

{formatMessage("ko-KR", "error.render_failure")}

) diff --git a/src/presentation/components/async-surface.jsx b/src/presentation/components/async-surface.jsx index c1cf68d..5a739ed 100644 --- a/src/presentation/components/async-surface.jsx +++ b/src/presentation/components/async-surface.jsx @@ -2,19 +2,22 @@ import { useId } from "react"; import { errorMessage } from "./error-copy.js"; import { Button } from "./ui/button.jsx"; +import { useLocale } from "../i18n/index.js"; /** @param {{ label?: string }} props */ -export function LoadingSurface({ label = "불러오는 중" }) { +export function LoadingSurface({ label }) { + const { message } = useLocale(); + const accessibleLabel = label ?? message("async.loading"); return (
); } @@ -27,13 +30,14 @@ export function LoadingSurface({ label = "불러오는 중" }) { * }} props */ export function EmptySurface({ - title = "표시할 항목이 없습니다.", + title, description, action, }) { + const { message } = useLocale(); return (
-

{title}

+

{title ?? message("async.empty")}

{description ?

{description}

: null} {action}
@@ -49,13 +53,14 @@ export function EmptySurface({ * }} props */ export function TerminalErrorSurface({ userMessageKey, action, onAction }) { + const { locale, message } = useLocale(); const messageId = useId(); const actionLabels = Object.freeze({ - retry: "다시 시도", - reauth: "로그인", - navigate: "안전한 화면으로 이동", - "reload-once": "한 번 새로고침", - "contact-support": "지원 정보 확인", + retry: message("action.retry"), + reauth: message("action.reauth"), + navigate: message("action.navigateSafe"), + "reload-once": message("action.reloadOnce"), + "contact-support": message("action.contactSupport"), }); return (
-

{errorMessage(userMessageKey)}

+

{errorMessage(userMessageKey, locale)}

{action !== "none" && onAction && ( )} @@ -88,6 +93,7 @@ export function AsyncSurface({ onRetry, onResolveConflict, }) { + const { message } = useLocale(); if (state.base === "initial-loading") return ; if (state.base === "empty") return ; if (state.base === "terminal-error" && state.failure) { @@ -104,12 +110,24 @@ export function AsyncSurface({
{state.indicator ? (
- {state.indicator} + + {message( + state.indicator === "stale-degraded" + ? "async.staleDegraded" + : state.indicator === "mutation-conflict" + ? "async.mutationConflict" + : state.indicator === "mutation-pending" + ? "async.mutationPending" + : "async.refreshing", + )} + {state.indicator === "stale-degraded" && onRetry ? ( - + ) : null} {state.indicator === "mutation-conflict" && onResolveConflict ? ( - + ) : null}
) : null} diff --git a/src/presentation/components/error-copy.js b/src/presentation/components/error-copy.js index 67b8580..78cb3f8 100644 --- a/src/presentation/components/error-copy.js +++ b/src/presentation/components/error-copy.js @@ -1,26 +1,6 @@ -const ERROR_MESSAGES = Object.freeze({ - "error.network_unreachable": "네트워크에 연결할 수 없습니다.", - "error.request_timeout": "요청 시간이 초과되었습니다.", - "error.auth_required": "계속하려면 로그인이 필요합니다.", - "error.auth_integration_failure": "로그인 연동을 사용할 수 없습니다.", - "error.forbidden": "이 작업을 수행할 권한이 없습니다.", - "error.not_found": "요청한 항목을 찾을 수 없습니다.", - "error.rate_limited": "요청이 많습니다. 잠시 후 다시 시도해 주세요.", - "error.server_failure": "요청을 완료하지 못했습니다.", - "error.chunk_load_failure": "새 화면 파일을 불러오지 못했습니다.", - "error.build_mismatch": "현재 화면과 활성 빌드가 일치하지 않습니다.", - "error.config_mismatch": "런타임 설정 버전이 현재 화면과 일치하지 않습니다.", - "error.api_contract_mismatch": "API 계약 버전이 현재 화면과 일치하지 않습니다.", - "error.release_mismatch": "현재 화면과 활성 릴리스가 일치하지 않습니다.", - "error.asset_mismatch": "화면 자산 구성이 현재 릴리스와 일치하지 않습니다.", - "error.render_failure": "화면을 표시하지 못했습니다.", - "error.unknown_failure": "예상하지 못한 문제가 발생했습니다.", -}); +import { resolveMessage } from "../i18n/index.js"; -/** @param {string} messageKey */ -export function errorMessage(messageKey) { - const messages = /** @type {Readonly>} */ ( - ERROR_MESSAGES - ); - return messages[messageKey] ?? "요청을 완료하지 못했습니다."; +/** @param {string} messageKey @param {string} [locale] */ +export function errorMessage(messageKey, locale = "ko-KR") { + return resolveMessage(locale, messageKey); } diff --git a/src/presentation/components/state-surfaces.jsx b/src/presentation/components/state-surfaces.jsx index 1526779..5395920 100644 --- a/src/presentation/components/state-surfaces.jsx +++ b/src/presentation/components/state-surfaces.jsx @@ -1,4 +1,5 @@ import { Button } from "./ui/button.jsx"; +import { useLocale } from "../i18n/index.js"; /** * @param {{ @@ -30,12 +31,13 @@ function StateSurface({ /** @param {{ onSignIn?: () => void }} props */ export function AuthRequiredSurface({ onSignIn }) { + const { message } = useLocale(); return ( ); @@ -43,12 +45,13 @@ export function AuthRequiredSurface({ onSignIn }) { /** @param {{ onNavigate?: () => void }} props */ export function ForbiddenSurface({ onNavigate }) { + const { message } = useLocale(); return ( @@ -57,12 +60,13 @@ export function ForbiddenSurface({ onNavigate }) { /** @param {{ onNavigate?: () => void }} props */ export function NotFoundSurface({ onNavigate }) { + const { message } = useLocale(); return ( ); diff --git a/src/presentation/design-system/primitives/core.tsx b/src/presentation/design-system/primitives/core.tsx index 3c0557c..633f796 100644 --- a/src/presentation/design-system/primitives/core.tsx +++ b/src/presentation/design-system/primitives/core.tsx @@ -8,6 +8,7 @@ import { import { createPortal } from "react-dom"; import { CloseIcon } from "../icons/semantic-icons.js"; +import { useLocale } from "../../i18n/index.js"; export type ButtonVariant = "primary" | "secondary" | "danger" | "ghost"; export type ButtonSize = "default" | "compact"; @@ -277,6 +278,7 @@ export function Alert({ dismissLabel, onDismiss, }: AlertProps) { + const { message } = useLocale(); return (
{onDismiss ? ( ( }, forwardedRef, ) { + const { message } = useLocale(); const dialogRef = useRef(null); const previousFocusRef = useRef(null); const titleId = useId(); @@ -364,8 +369,11 @@ export const Dialog = forwardRef( if (typeof dialog.close === "function") dialog.close(); else dialog.removeAttribute("open"); } - previousFocusRef.current?.focus(); + const previousFocus = previousFocusRef.current; previousFocusRef.current = null; + queueMicrotask(() => { + if (previousFocus?.isConnected) previousFocus.focus(); + }); }, [open]); return ( @@ -395,7 +403,9 @@ export const Dialog = forwardRef( {description ?

{description}

: null} ( }, ref, ) { + const { message } = useLocale(); const [uncontrolledValue, setUncontrolledValue] = useState( String(defaultValue ?? ""), ); @@ -80,7 +82,7 @@ export const TextArea = forwardRef( {maxLengthMessage ? maxLengthMessage(remaining) - : `${remaining}자 남음`} + : message("form.remaining", { count: remaining })} ) : null} diff --git a/src/presentation/design-system/primitives/navigation.tsx b/src/presentation/design-system/primitives/navigation.tsx index cce4965..61008df 100644 --- a/src/presentation/design-system/primitives/navigation.tsx +++ b/src/presentation/design-system/primitives/navigation.tsx @@ -1,5 +1,6 @@ import { useId, useRef, useState } from "react"; +import { useLocale } from "../../i18n/index.js"; import { NextIcon, PreviousIcon } from "../icons/semantic-icons.js"; import { IconButton, LinkButton } from "./core.js"; @@ -62,6 +63,7 @@ export function Tabs({ const selected = value ?? internalValue; const tabRefs = useRef>([]); const baseId = useId(); + const { direction } = useLocale(); function select(id: string) { if (value === undefined) setInternalValue(id); @@ -98,10 +100,10 @@ export function Tabs({ onKeyDown={(event) => { if (event.key === "ArrowRight") { event.preventDefault(); - move(index, 1); + move(index, direction === "rtl" ? -1 : 1); } else if (event.key === "ArrowLeft") { event.preventDefault(); - move(index, -1); + move(index, direction === "rtl" ? 1 : -1); } else if ( activation === "manual" && (event.key === "Enter" || event.key === " ") @@ -156,6 +158,10 @@ export function Pagination({ onChange(page: number): void; }>) { const pages = Array.from({ length: pageCount }, (_, index) => index + 1); + const { direction } = useLocale(); + const PreviousDirectionalIcon = + direction === "rtl" ? NextIcon : PreviousIcon; + const NextDirectionalIcon = direction === "rtl" ? PreviousIcon : NextIcon; return ( ); diff --git a/src/presentation/design-system/primitives/overlays.tsx b/src/presentation/design-system/primitives/overlays.tsx index b3db88b..e708daf 100644 --- a/src/presentation/design-system/primitives/overlays.tsx +++ b/src/presentation/design-system/primitives/overlays.tsx @@ -11,6 +11,7 @@ import { import { CloseIcon } from "../icons/semantic-icons.js"; import { Button, Dialog, IconButton } from "./core.js"; +import { useLocale } from "../../i18n/index.js"; export type DrawerProps = Readonly<{ open: boolean; @@ -343,9 +344,10 @@ function ToastRegion({ toasts: readonly ToastEntry[]; dismiss(id: string): void; }>) { + const { message } = useLocale(); return (
@@ -363,6 +365,7 @@ function ToastItem({ toast: ToastEntry; dismiss(id: string): void; }>) { + const { message } = useLocale(); const [paused, setPaused] = useState(false); useEffect(() => { if (paused) return; @@ -391,7 +394,7 @@ function ToastItem({ {toast.description ?

{toast.description}

: null} dismiss(toast.id)} variant="ghost" > diff --git a/src/presentation/forms/form-components.tsx b/src/presentation/forms/form-components.tsx index 0bbfa7c..3976408 100644 --- a/src/presentation/forms/form-components.tsx +++ b/src/presentation/forms/form-components.tsx @@ -1,6 +1,7 @@ import { useId, type FormHTMLAttributes, type ReactNode } from "react"; import { TextField } from "../components/ui/text-field.jsx"; +import { useLocale } from "../i18n/index.js"; import type { FieldErrors, FieldName, @@ -31,6 +32,7 @@ export function ErrorSummary(props: Readonly<{ fieldId(name: FieldName): string; onFocusField?(name: FieldName): void; }>) { + const { message } = useLocale(); const { fieldErrors, formErrors = [], @@ -50,7 +52,7 @@ export function ErrorSummary(props: Readonly<{ role="alert" aria-labelledby={headingId} > -

입력 내용을 확인해 주세요.

+

{message("form.errorSummary")}

{entries.length > 0 ? (
    {entries.map(([name, message]) => ( diff --git a/src/presentation/forms/form-contracts.ts b/src/presentation/forms/form-contracts.ts index c1f409e..42cde97 100644 --- a/src/presentation/forms/form-contracts.ts +++ b/src/presentation/forms/form-contracts.ts @@ -1,4 +1,8 @@ import type { ApiFailure } from "../../contracts/errors.js"; +import { + formatMessage, + type ParameterlessMessageKey, +} from "../i18n/index.js"; export type FormValues = Readonly>; export type FieldName = Extract; @@ -23,24 +27,32 @@ export type MappedValidationFailure = Readonly<{ }>; const VALIDATION_COPY = Object.freeze({ - REQUIRED: "필수 입력값입니다.", - too_small: "입력값이 너무 짧습니다.", - too_big: "입력값이 너무 깁니다.", - invalid_type: "입력 형식을 확인해 주세요.", - invalid_format: "입력 형식을 확인해 주세요.", - invalid_value: "허용된 값을 선택해 주세요.", + REQUIRED: "form.validation.required", + too_small: "form.validation.tooSmall", + too_big: "form.validation.tooBig", + invalid_type: "form.validation.invalidType", + invalid_format: "form.validation.invalidFormat", + invalid_value: "form.validation.invalidValue", }); -export function validationMessage(code: string): string { - return ( +type MessageResolver = (key: ParameterlessMessageKey) => string; + +const defaultMessage: MessageResolver = (key) => formatMessage("ko-KR", key); + +export function validationMessage( + code: string, + message: MessageResolver = defaultMessage, +): string { + return message( VALIDATION_COPY[code as keyof typeof VALIDATION_COPY] ?? - "입력값을 확인해 주세요." + "form.validation.unknown", ); } export function mapValidationFailureToFields( failure: ApiFailure, allowedFields: readonly FieldName[], + message: MessageResolver = defaultMessage, ): MappedValidationFailure { if (failure.kind !== "VALIDATION_REJECTED") { return Object.freeze({ fieldErrors: Object.freeze({}), formErrors: [] }); @@ -51,15 +63,15 @@ export function mapValidationFailureToFields( const issues = failure.validationIssues ?? []; if (issues.length === 0) { - formErrors.push("입력값을 다시 확인해 주세요."); + formErrors.push(message("form.validation.retry")); } for (const issue of issues) { const field = issue.path.split(".").at(0) ?? ""; if (allowed.has(field)) { const name = field as FieldName; - fieldErrors[name] ??= validationMessage(issue.code); + fieldErrors[name] ??= validationMessage(issue.code, message); } else { - formErrors.push("서버가 확인하지 못한 입력 항목이 있습니다."); + formErrors.push(message("form.validation.unknownField")); } } return Object.freeze({ diff --git a/src/presentation/forms/use-app-form.ts b/src/presentation/forms/use-app-form.ts index f17b4e9..9cd55cd 100644 --- a/src/presentation/forms/use-app-form.ts +++ b/src/presentation/forms/use-app-form.ts @@ -8,6 +8,10 @@ import { type FormEvent, } from "react"; import type { ZodType, ZodIssue } from "zod"; +import { + useLocale, + type ParameterlessMessageKey, +} from "../i18n/index.js"; import { mapValidationFailureToFields, @@ -37,6 +41,7 @@ export function useAppForm< Command, Output, >(options: AppFormOptions) { + const { message } = useLocale(); const { schema, defaultValues, @@ -142,13 +147,14 @@ export function useAppForm< const errors = issuesToFieldErrors( parsed.error.issues, allowedServerFields, + message, ); setFieldErrors(errors); setFormErrors( parsed.error.issues.some( (issue) => !allowedServerFields.includes(issue.path[0] as FieldName), ) - ? ["입력 구성을 다시 확인해 주세요."] + ? [message("form.validation.configuration")] : [], ); setTouched(new Set(allowedServerFields)); @@ -178,6 +184,7 @@ export function useAppForm< const mapped = mapValidationFailureToFields( outcome.error, allowedServerFields, + message, ); setFieldErrors(mapped.fieldErrors); setFormErrors(mapped.formErrors); @@ -185,11 +192,11 @@ export function useAppForm< focusFirstError(mapped.fieldErrors); } else if (outcome.error.kind === "CONFLICT") { setFormErrors([ - "다른 변경과 충돌했습니다. 입력은 유지되었으니 최신 상태를 확인해 주세요.", + message("form.conflict"), ]); setResult("conflict"); } else { - setFormErrors(["저장하지 못했습니다. 잠시 후 다시 시도해 주세요."]); + setFormErrors([message("form.unavailable")]); setResult("unavailable"); } return outcome; @@ -203,6 +210,7 @@ export function useAppForm< defaultValues, focusFirstError, mapToCommand, + message, resetOnSuccess, schema, submit, @@ -231,6 +239,7 @@ export function useAppForm< function issuesToFieldErrors( issues: readonly ZodIssue[], allowedFields: readonly FieldName[], + message: (key: ParameterlessMessageKey) => string, ): FieldErrors { const allowed = new Set(allowedFields); const errors: Partial, string>> = {}; @@ -238,7 +247,7 @@ function issuesToFieldErrors( const field = issue.path[0]; if (!allowed.has(field)) continue; const name = field as FieldName; - errors[name] ??= validationMessage(issue.code); + errors[name] ??= validationMessage(issue.code, message); } return Object.freeze(errors); } diff --git a/src/presentation/forms/use-dirty-navigation-guard.tsx b/src/presentation/forms/use-dirty-navigation-guard.tsx index f0fb63f..d47938a 100644 --- a/src/presentation/forms/use-dirty-navigation-guard.tsx +++ b/src/presentation/forms/use-dirty-navigation-guard.tsx @@ -3,6 +3,7 @@ import { useBeforeUnload, useBlocker } from "react-router-dom"; import { Button } from "../components/ui/button.jsx"; import { Dialog } from "../components/ui/dialog.jsx"; +import { useLocale } from "../i18n/index.js"; export function useDirtyNavigationGuard(when: boolean) { const blocker = useBlocker(when); @@ -33,19 +34,20 @@ export function useDirtyNavigationGuard(when: boolean) { export function DirtyNavigationDialog(props: Readonly<{ guard: ReturnType; }>) { + const { message } = useLocale(); return ( } diff --git a/src/presentation/i18n/catalog.ts b/src/presentation/i18n/catalog.ts new file mode 100644 index 0000000..1487269 --- /dev/null +++ b/src/presentation/i18n/catalog.ts @@ -0,0 +1,304 @@ +import { INSTALLED_MESSAGE_CATALOGS } from "../../features/installed-feature-messages.js"; + +const PLATFORM_KO_MESSAGES = { + "common.unavailable": "요청한 문구를 표시할 수 없습니다.", + "common.processing": "처리 중…", + "common.appName": "Frontend Skeleton", + "common.noDisplayValue": "표시 정보 없음", + "action.retry": "다시 시도", + "action.reauth": "로그인", + "action.navigateSafe": "안전한 화면으로 이동", + "action.reloadOnce": "한 번 새로고침", + "action.contactSupport": "지원 정보 확인", + "action.resolveConflict": "충돌 해결", + "action.continueEditing": "계속 작성", + "action.discardAndLeave": "변경 버리고 이동", + "action.signIn": "로그인 시작", + "action.recoverSession": "세션 복구", + "action.signOut": "로그아웃", + "action.goHome": "홈으로 이동", + "action.closeNamed": "{title} 닫기", + "action.alertCloseNamed": "{title} 알림 닫기", + "shell.skipToContent": "본문으로 건너뛰기", + "shell.menu": "메뉴", + "shell.closeMenu": "메뉴 닫기", + "shell.sidebar": "사이드바", + "shell.primaryNavigation": "주요 탐색", + "shell.theme": "색상 테마", + "shell.theme.system": "시스템 테마", + "shell.theme.light": "라이트 테마", + "shell.theme.dark": "다크 테마", + "shell.locale": "언어", + "shell.locale.ko": "한국어", + "shell.locale.en": "English", + "shell.locale.pseudo": "Pseudo", + "shell.locale.rtl": "RTL smoke", + "shell.session.authenticated": "인증됨", + "shell.session.unauthenticated": "로그인 전", + "shell.session.recoveryPending": "복구 대기", + "shell.session.integrationFailed": "연동 필요", + "shell.session.actionFailed": "세션 작업을 완료하지 못했습니다.", + "route.APP_HOME.navigation": "홈", + "route.APP_HOME.title": "Clean Architecture Frontend", + "route.EXAMPLES_UI.navigation": "UI 구성요소", + "route.EXAMPLES_UI.title": "UI 구성요소", + "route.EXAMPLES_STATES.navigation": "화면 상태", + "route.EXAMPLES_STATES.title": "화면 상태", + "route.EXAMPLES_AUTH.navigation": "인증 연동", + "route.EXAMPLES_AUTH.title": "인증 연동", + "route.NOT_FOUND.navigation": "찾을 수 없음", + "route.NOT_FOUND.title": "페이지를 찾을 수 없습니다.", + "async.loading": "불러오는 중", + "async.empty": "표시할 항목이 없습니다.", + "async.refreshing": "최신 정보를 확인하고 있습니다.", + "async.staleDegraded": "기존 정보를 표시하고 있습니다.", + "async.mutationPending": "변경 사항을 저장하고 있습니다.", + "async.mutationConflict": "다른 변경과 충돌했습니다.", + "access.auth.eyebrow": "401 · 인증 필요", + "access.auth.title": "로그인이 필요합니다.", + "access.auth.description": + "세션을 시작한 뒤 이전 작업을 안전하게 계속할 수 있습니다.", + "access.forbidden.eyebrow": "403 · 권한 없음", + "access.forbidden.title": "접근할 수 없습니다.", + "access.forbidden.description": + "권한을 확인하거나 접근 가능한 화면으로 이동하세요.", + "access.notFound.eyebrow": "404 · 찾을 수 없음", + "access.notFound.title": "요청한 화면이 없습니다.", + "access.notFound.description": + "주소를 확인하거나 시작 화면으로 돌아가세요.", + "route.loading": "화면을 준비하고 있습니다.", + "route.loadingNamed": "{title} 로딩 중", + "route.failure.title": "화면을 표시하지 못했습니다.", + "route.failure.description": + "잠시 후 다시 시도해 주세요. 문제가 계속되면 운영 지원 참조 정보를 확인하세요.", + "route.invalid.title": "올바르지 않은 주소입니다.", + "route.invalid.description": "주소의 경로 또는 검색 조건을 확인해 주세요.", + "route.invalid.action": "안전한 탐색 링크를 사용해 주세요.", + "route.auth.integration.title": "로그인 연동이 필요합니다.", + "route.auth.integration.description": + "외부 인증 소유자가 연결되면 이 보호 라우트를 사용할 수 있습니다.", + "route.auth.recovering.title": "세션을 복구하고 있습니다.", + "route.auth.required.title": "세션이 필요합니다.", + "route.auth.recovering.description": + "기존 세션 확인을 계속하려면 복구를 실행하세요.", + "route.auth.required.description": + "이 화면은 인증 연동 지점을 확인하기 위한 보호 라우트입니다.", + "route.documentTitle": "{title} · {appName}", + "chunk.checking": "새 릴리스 정보를 확인하고 있습니다.", + "chunk.reloadOnce": "새 버전으로 한 번만 전환합니다.", + "chunk.failure.title": "화면 자산을 복구하지 못했습니다.", + "chunk.failure.description": + "문제가 계속되면 배포 상태와 지원 참조 정보를 확인해 주세요.", + "page.notFound.title": "페이지를 찾을 수 없습니다.", + "page.notFound.description": + "주소를 확인하거나 준비된 시작 화면으로 돌아가세요.", + "template.breadcrumb": "현재 위치", + "template.relatedInformation": "관련 정보", + "template.searchAndFilter": "검색과 필터", + "template.pagination": "페이지 탐색", + "template.summary": "요약 정보", + "template.dangerActions": "위험 작업", + "template.supportReference": "지원 참조: {reference}", + "form.errorSummary": "입력 내용을 확인해 주세요.", + "form.unsaved.title": "저장하지 않은 변경이 있습니다.", + "form.unsaved.description": "이 화면을 떠나면 입력한 내용이 사라집니다.", + "form.validation.required": "필수 입력값입니다.", + "form.validation.tooSmall": "입력값이 너무 짧습니다.", + "form.validation.tooBig": "입력값이 너무 깁니다.", + "form.validation.invalidType": "입력 형식을 확인해 주세요.", + "form.validation.invalidFormat": "입력 형식을 확인해 주세요.", + "form.validation.invalidValue": "허용된 값을 선택해 주세요.", + "form.validation.unknown": "입력값을 확인해 주세요.", + "form.validation.retry": "입력값을 다시 확인해 주세요.", + "form.validation.unknownField": + "서버가 확인하지 못한 입력 항목이 있습니다.", + "form.validation.configuration": "입력 구성을 다시 확인해 주세요.", + "form.conflict": + "다른 변경과 충돌했습니다. 입력은 유지되었으니 최신 상태를 확인해 주세요.", + "form.unavailable": "저장하지 못했습니다. 잠시 후 다시 시도해 주세요.", + "form.remaining": "{count}자 남음", + "toast.region": "알림", + "error.network_unreachable": "네트워크에 연결할 수 없습니다.", + "error.request_timeout": "요청 시간이 초과되었습니다.", + "error.auth_required": "계속하려면 로그인이 필요합니다.", + "error.auth_integration_failure": "로그인 연동을 사용할 수 없습니다.", + "error.forbidden": "이 작업을 수행할 권한이 없습니다.", + "error.not_found": "요청한 항목을 찾을 수 없습니다.", + "error.rate_limited": "요청이 많습니다. 잠시 후 다시 시도해 주세요.", + "error.server_failure": "요청을 완료하지 못했습니다.", + "error.chunk_load_failure": "새 화면 파일을 불러오지 못했습니다.", + "error.build_mismatch": "현재 화면과 활성 빌드가 일치하지 않습니다.", + "error.config_mismatch": "런타임 설정 버전이 현재 화면과 일치하지 않습니다.", + "error.api_contract_mismatch": "API 계약 버전이 현재 화면과 일치하지 않습니다.", + "error.release_mismatch": "현재 화면과 활성 릴리스가 일치하지 않습니다.", + "error.asset_mismatch": "화면 자산 구성이 현재 릴리스와 일치하지 않습니다.", + "error.render_failure": "화면을 표시하지 못했습니다.", + "error.unknown_failure": "예상하지 못한 문제가 발생했습니다.", + "boot.failure.title": "애플리케이션을 시작할 수 없습니다.", + "boot.field.error": "오류", + "boot.field.code": "코드", + "boot.field.build": "빌드", + "boot.field.configSchema": "설정 스키마", + "boot.field.release": "릴리스", + "boot.supportReference": "지원 참조: {reference}", +} as const; + +export const KO_MESSAGES = { + ...PLATFORM_KO_MESSAGES, + ...INSTALLED_MESSAGE_CATALOGS["ko-KR"], +} as const; + +export type MessageKey = keyof typeof KO_MESSAGES; + +const PLATFORM_EN_MESSAGES = { + "common.unavailable": "This message is unavailable.", + "common.processing": "Processing…", + "common.appName": "Frontend Skeleton", + "common.noDisplayValue": "No display value", + "action.retry": "Try again", + "action.reauth": "Sign in", + "action.navigateSafe": "Go to an available page", + "action.reloadOnce": "Reload once", + "action.contactSupport": "View support information", + "action.resolveConflict": "Resolve conflict", + "action.continueEditing": "Continue editing", + "action.discardAndLeave": "Discard and leave", + "action.signIn": "Start sign-in", + "action.recoverSession": "Recover session", + "action.signOut": "Sign out", + "action.goHome": "Go home", + "action.closeNamed": "Close {title}", + "action.alertCloseNamed": "Dismiss {title} alert", + "shell.skipToContent": "Skip to content", + "shell.menu": "Menu", + "shell.closeMenu": "Close menu", + "shell.sidebar": "Sidebar", + "shell.primaryNavigation": "Primary navigation", + "shell.theme": "Color theme", + "shell.theme.system": "System theme", + "shell.theme.light": "Light theme", + "shell.theme.dark": "Dark theme", + "shell.locale": "Language", + "shell.locale.ko": "한국어", + "shell.locale.en": "English", + "shell.locale.pseudo": "Pseudo", + "shell.locale.rtl": "RTL smoke", + "shell.session.authenticated": "Authenticated", + "shell.session.unauthenticated": "Signed out", + "shell.session.recoveryPending": "Recovery pending", + "shell.session.integrationFailed": "Integration required", + "shell.session.actionFailed": "The session action could not be completed.", + "route.APP_HOME.navigation": "Home", + "route.APP_HOME.title": "Clean Architecture Frontend", + "route.EXAMPLES_UI.navigation": "UI components", + "route.EXAMPLES_UI.title": "UI components", + "route.EXAMPLES_STATES.navigation": "Screen states", + "route.EXAMPLES_STATES.title": "Screen states", + "route.EXAMPLES_AUTH.navigation": "Authentication", + "route.EXAMPLES_AUTH.title": "Authentication", + "route.NOT_FOUND.navigation": "Not found", + "route.NOT_FOUND.title": "Page not found", + "async.loading": "Loading", + "async.empty": "There are no items to display.", + "async.refreshing": "Checking for the latest information.", + "async.staleDegraded": "Showing previously loaded information.", + "async.mutationPending": "Saving changes.", + "async.mutationConflict": "The change conflicts with another update.", + "access.auth.eyebrow": "401 · Authentication required", + "access.auth.title": "Sign-in is required.", + "access.auth.description": + "Start a session to safely continue the previous task.", + "access.forbidden.eyebrow": "403 · Access denied", + "access.forbidden.title": "You cannot access this page.", + "access.forbidden.description": + "Check your permissions or go to an available page.", + "access.notFound.eyebrow": "404 · Not found", + "access.notFound.title": "The requested page does not exist.", + "access.notFound.description": "Check the address or return to the start page.", + "route.loading": "Preparing the page.", + "route.loadingNamed": "Loading {title}", + "route.failure.title": "The page could not be displayed.", + "route.failure.description": + "Try again shortly. If the problem continues, check the support reference.", + "route.invalid.title": "This address is invalid.", + "route.invalid.description": "Check the path and search parameters.", + "route.invalid.action": "Use a safe navigation link.", + "route.auth.integration.title": "Sign-in integration is required.", + "route.auth.integration.description": + "This protected route is available after an external authentication owner is connected.", + "route.auth.recovering.title": "Recovering the session.", + "route.auth.required.title": "A session is required.", + "route.auth.recovering.description": + "Run recovery to continue checking the existing session.", + "route.auth.required.description": + "This protected route demonstrates the authentication integration seam.", + "route.documentTitle": "{title} · {appName}", + "chunk.checking": "Checking the new release information.", + "chunk.reloadOnce": "Switching to the new version once.", + "chunk.failure.title": "The page assets could not be recovered.", + "chunk.failure.description": + "If the problem continues, check deployment status and support information.", + "page.notFound.title": "Page not found.", + "page.notFound.description": "Check the address or return to the start page.", + "template.breadcrumb": "Current location", + "template.relatedInformation": "Related information", + "template.searchAndFilter": "Search and filters", + "template.pagination": "Pagination", + "template.summary": "Summary information", + "template.dangerActions": "Dangerous actions", + "template.supportReference": "Support reference: {reference}", + "form.errorSummary": "Review the entered information.", + "form.unsaved.title": "You have unsaved changes.", + "form.unsaved.description": + "The entered information will be lost if you leave this page.", + "form.validation.required": "This field is required.", + "form.validation.tooSmall": "This value is too short.", + "form.validation.tooBig": "This value is too long.", + "form.validation.invalidType": "Check the input type.", + "form.validation.invalidFormat": "Check the input format.", + "form.validation.invalidValue": "Choose an allowed value.", + "form.validation.unknown": "Check the entered value.", + "form.validation.retry": "Review the entered values.", + "form.validation.unknownField": + "The server reported an unrecognized input field.", + "form.validation.configuration": "Review the input configuration.", + "form.conflict": + "Another update conflicts with this change. Your input has been preserved.", + "form.unavailable": "The change could not be saved. Try again shortly.", + "form.remaining": "{count} characters remaining", + "toast.region": "Notifications", + "error.network_unreachable": "The network is unavailable.", + "error.request_timeout": "The request timed out.", + "error.auth_required": "Sign in to continue.", + "error.auth_integration_failure": "Sign-in integration is unavailable.", + "error.forbidden": "You are not allowed to perform this action.", + "error.not_found": "The requested item could not be found.", + "error.rate_limited": "Too many requests were made. Try again shortly.", + "error.server_failure": "The request could not be completed.", + "error.chunk_load_failure": "The new page assets could not be loaded.", + "error.build_mismatch": "The active build does not match this page.", + "error.config_mismatch": "The runtime configuration does not match this page.", + "error.api_contract_mismatch": "The API contract does not match this page.", + "error.release_mismatch": "The active release does not match this page.", + "error.asset_mismatch": "The page assets do not match this release.", + "error.render_failure": "The page could not be displayed.", + "error.unknown_failure": "An unexpected problem occurred.", + "boot.failure.title": "The application could not start.", + "boot.field.error": "Error", + "boot.field.code": "Code", + "boot.field.build": "Build", + "boot.field.configSchema": "Configuration schema", + "boot.field.release": "Release", + "boot.supportReference": "Support reference: {reference}", +} as const satisfies Record; + +export const EN_MESSAGES = { + ...PLATFORM_EN_MESSAGES, + ...INSTALLED_MESSAGE_CATALOGS["en-US"], +} as const satisfies Record; + +export const MESSAGE_CATALOGS = Object.freeze({ + "ko-KR": KO_MESSAGES, + "en-US": EN_MESSAGES, + "ar-EG": EN_MESSAGES, +}); diff --git a/src/presentation/i18n/formatters.ts b/src/presentation/i18n/formatters.ts new file mode 100644 index 0000000..8ae0b78 --- /dev/null +++ b/src/presentation/i18n/formatters.ts @@ -0,0 +1,96 @@ +import { normalizeLocale, type SupportedLocale } from "./message-contract.js"; + +const FORMAT_FALLBACK = "—"; + +export type DateFormatStyle = "short" | "medium" | "long"; + +export function formatDate( + locale: SupportedLocale, + value: Date | number, + options: Readonly<{ + dateStyle?: DateFormatStyle; + timeZone?: string; + }> = {}, +): string { + try { + const date = value instanceof Date ? value : new Date(value); + if (Number.isNaN(date.getTime())) return FORMAT_FALLBACK; + return new Intl.DateTimeFormat(normalizeLocale(locale), { + dateStyle: options.dateStyle ?? "medium", + timeZone: options.timeZone ?? "UTC", + }).format(date); + } catch { + return FORMAT_FALLBACK; + } +} + +export function formatNumber( + locale: SupportedLocale, + value: number, + options: Readonly<{ + style?: "decimal" | "percent"; + maximumFractionDigits?: number; + }> = {}, +): string { + try { + if (!Number.isFinite(value)) return FORMAT_FALLBACK; + return new Intl.NumberFormat(normalizeLocale(locale), options).format(value); + } catch { + return FORMAT_FALLBACK; + } +} + +export function formatRelativeTime( + locale: SupportedLocale, + value: number, + unit: Intl.RelativeTimeFormatUnit, +): string { + try { + if (!Number.isFinite(value)) return FORMAT_FALLBACK; + return new Intl.RelativeTimeFormat(normalizeLocale(locale), { + numeric: "auto", + }).format(value, unit); + } catch { + return FORMAT_FALLBACK; + } +} + +export function formatList( + locale: SupportedLocale, + values: readonly string[], +): string { + try { + return new Intl.ListFormat(normalizeLocale(locale), { + style: "long", + type: "conjunction", + }).format(values); + } catch { + return values.join(", "); + } +} + +export function selectPlural( + locale: SupportedLocale, + value: number, + choices: Readonly< + Partial> & { other: string } + >, +): string { + try { + if (!Number.isFinite(value)) return choices.other; + return choices[ + new Intl.PluralRules(normalizeLocale(locale)).select(value) + ] ?? choices.other; + } catch { + return choices.other; + } +} + +export function selectMessage( + value: string, + choices: Readonly & { other: string }>, +): string { + return choices[value] ?? choices.other; +} + +export { FORMAT_FALLBACK }; diff --git a/src/presentation/i18n/index.ts b/src/presentation/i18n/index.ts new file mode 100644 index 0000000..f94602c --- /dev/null +++ b/src/presentation/i18n/index.ts @@ -0,0 +1,29 @@ +export { LocaleProvider, useLocale } from "./locale-provider.js"; +export { + catalogKeys, + fallbackMessage, + formatMessage, + localeDirection, + MESSAGE_KEY_ALIASES, + messagePlaceholders, + normalizeLocale, + resolveMessage, + SUPPORTED_LOCALES, +} from "./message-contract.js"; +export type { + MessageArguments, + MessageParameters, + ParameterlessMessageKey, + SupportedLocale, + TextDirection, +} from "./message-contract.js"; +export type { MessageKey } from "./catalog.js"; +export { + FORMAT_FALLBACK, + formatDate, + formatList, + formatNumber, + formatRelativeTime, + selectMessage, + selectPlural, +} from "./formatters.js"; diff --git a/src/presentation/i18n/locale-provider.tsx b/src/presentation/i18n/locale-provider.tsx new file mode 100644 index 0000000..2162a14 --- /dev/null +++ b/src/presentation/i18n/locale-provider.tsx @@ -0,0 +1,118 @@ +import { + createContext, + useContext, + useEffect, + useMemo, + useState, +} from "react"; + +import { + formatDate, + formatList, + formatNumber, + formatRelativeTime, + selectMessage, + selectPlural, +} from "./formatters.js"; +import { + formatMessage, + localeDirection, + normalizeLocale, + resolveMessage, + type MessageArguments, + type SupportedLocale, + type TextDirection, +} from "./message-contract.js"; +import type { MessageKey } from "./catalog.js"; + +type LocaleContextValue = Readonly<{ + locale: SupportedLocale; + direction: TextDirection; + setLocale(locale: SupportedLocale): void; + message( + key: Key, + ...args: MessageArguments + ): string; + resolve(key: string): string; + date( + value: Date | number, + options?: Parameters[2], + ): string; + number(value: number, options?: Parameters[2]): string; + relativeTime( + value: number, + unit: Intl.RelativeTimeFormatUnit, + ): string; + list(values: readonly string[]): string; + plural( + value: number, + choices: Parameters[2], + ): string; + select( + value: string, + choices: Readonly & { other: string }>, + ): string; +}>; + +const DEFAULT_LOCALE = "ko-KR" as const; +const DEFAULT_CONTEXT: LocaleContextValue = Object.freeze({ + locale: DEFAULT_LOCALE, + direction: "ltr", + setLocale() {}, + message: (key, ...args) => formatMessage(DEFAULT_LOCALE, key, ...args), + resolve: (key) => resolveMessage(DEFAULT_LOCALE, key), + date: (value, options) => formatDate(DEFAULT_LOCALE, value, options), + number: (value, options) => formatNumber(DEFAULT_LOCALE, value, options), + relativeTime: (value, unit) => + formatRelativeTime(DEFAULT_LOCALE, value, unit), + list: (values) => formatList(DEFAULT_LOCALE, values), + plural: (value, choices) => selectPlural(DEFAULT_LOCALE, value, choices), + select: (value, choices) => selectMessage(value, choices), +}); +const LocaleContext = createContext(DEFAULT_CONTEXT); + +export function LocaleProvider({ + children, + initialLocale = "ko-KR", +}: Readonly<{ + children: React.ReactNode; + initialLocale?: SupportedLocale; +}>) { + const [locale, setLocaleState] = useState(() => + normalizeLocale(initialLocale), + ); + const direction = localeDirection(locale); + + useEffect(() => { + document.documentElement.lang = locale; + document.documentElement.dir = direction; + }, [direction, locale]); + + const value = useMemo( + () => ({ + locale, + direction, + setLocale: setLocaleState, + message: (key, ...args) => formatMessage(locale, key, ...args), + resolve: (key) => resolveMessage(locale, key), + date: (dateValue, options) => formatDate(locale, dateValue, options), + number: (numberValue, options) => + formatNumber(locale, numberValue, options), + relativeTime: (relativeValue, unit) => + formatRelativeTime(locale, relativeValue, unit), + list: (values) => formatList(locale, values), + plural: (pluralValue, choices) => + selectPlural(locale, pluralValue, choices), + select: (selectValue, choices) => selectMessage(selectValue, choices), + }), + [direction, locale], + ); + + return ( + {children} + ); +} + +export function useLocale(): LocaleContextValue { + return useContext(LocaleContext); +} diff --git a/src/presentation/i18n/message-contract.ts b/src/presentation/i18n/message-contract.ts new file mode 100644 index 0000000..9341247 --- /dev/null +++ b/src/presentation/i18n/message-contract.ts @@ -0,0 +1,135 @@ +import { + EN_MESSAGES, + KO_MESSAGES, + MESSAGE_CATALOGS, + type MessageKey, +} from "./catalog.js"; + +export const SUPPORTED_LOCALES = Object.freeze([ + "ko-KR", + "en-US", + "en-XA", + "ar-EG", +] as const); +export type SupportedLocale = (typeof SUPPORTED_LOCALES)[number]; +export type TextDirection = "ltr" | "rtl"; + +export type MessageParameters = Readonly<{ + "action.closeNamed": { title: string }; + "action.alertCloseNamed": { title: string }; + "route.loadingNamed": { title: string }; + "route.documentTitle": { title: string; appName: string }; + "template.supportReference": { reference: string }; + "form.remaining": { count: number }; + "boot.supportReference": { reference: string }; +}>; +export type ParameterlessMessageKey = Exclude< + MessageKey, + keyof MessageParameters +>; + +export type MessageArguments = + Key extends keyof MessageParameters + ? [parameters: MessageParameters[Key]] + : [parameters?: never]; + +const PLACEHOLDER_PATTERN = /\{([a-zA-Z][a-zA-Z0-9]*)\}/g; +const FALLBACK_LOCALE = "ko-KR" as const; +export const MESSAGE_KEY_ALIASES = Object.freeze({ + "action.login": "action.reauth", + "async.pending": "common.processing", +} as const satisfies Readonly>); + +export function normalizeLocale(locale: string): SupportedLocale { + if ((SUPPORTED_LOCALES as readonly string[]).includes(locale)) { + return locale as SupportedLocale; + } + const language = locale.split("-")[0]?.toLowerCase(); + if (language === "ko") return "ko-KR"; + if (language === "ar") return "ar-EG"; + if (language === "en") return "en-US"; + return FALLBACK_LOCALE; +} + +export function localeDirection(locale: SupportedLocale): TextDirection { + return locale === "ar-EG" ? "rtl" : "ltr"; +} + +export function messagePlaceholders(template: string): readonly string[] { + return Object.freeze( + [...template.matchAll(PLACEHOLDER_PATTERN)].map((match) => match[1] ?? ""), + ); +} + +export function resolveMessage( + locale: string, + key: string, + parameters?: Readonly>, +): string { + const normalized = normalizeLocale(locale); + const sourceLocale: keyof typeof MESSAGE_CATALOGS = + normalized === "en-XA" ? "en-US" : normalized; + const catalog = + MESSAGE_CATALOGS[sourceLocale] ?? + MESSAGE_CATALOGS[FALLBACK_LOCALE]; + const fallback = KO_MESSAGES["common.unavailable"]; + const canonicalKey = + MESSAGE_KEY_ALIASES[key as keyof typeof MESSAGE_KEY_ALIASES] ?? key; + const template = catalog[canonicalKey as MessageKey] ?? fallback; + const placeholders = messagePlaceholders(template); + if ( + placeholders.some( + (placeholder) => + parameters?.[placeholder] === undefined || + parameters?.[placeholder] === null, + ) + ) { + return fallback; + } + const formatted = template.replace( + PLACEHOLDER_PATTERN, + (_, placeholder: string) => String(parameters?.[placeholder] ?? ""), + ); + return normalized === "en-XA" ? pseudoLocalize(formatted) : formatted; +} + +export function formatMessage( + locale: string, + key: Key, + ...args: MessageArguments +): string { + return resolveMessage( + locale, + key, + args[0] as Readonly> | undefined, + ); +} + +function pseudoLocalize(value: string): string { + const expanded = value.replace(/[A-Za-z]/g, (character) => { + const replacements: Readonly> = { + a: "á", + e: "é", + i: "í", + o: "ó", + u: "ú", + A: "Á", + E: "É", + I: "Í", + O: "Ó", + U: "Ú", + }; + return replacements[character] ?? character; + }); + return `[${expanded} ···]`; +} + +export function catalogKeys( + locale: "ko-KR" | "en-US" | "ar-EG", +): MessageKey[] { + return Object.keys(MESSAGE_CATALOGS[locale]).sort() as MessageKey[]; +} + +export function fallbackMessage() { + return EN_MESSAGES["common.unavailable"]; +} diff --git a/src/presentation/layouts/app-shell.jsx b/src/presentation/layouts/app-shell.jsx index ec17908..56c479c 100644 --- a/src/presentation/layouts/app-shell.jsx +++ b/src/presentation/layouts/app-shell.jsx @@ -12,20 +12,22 @@ import { MenuIcon, Select, } from "../design-system/index.js"; +import { useLocale } from "../i18n/index.js"; import { useSession } from "../providers/session-provider.jsx"; import { useTheme } from "../providers/theme-provider.jsx"; -const SESSION_LABELS = Object.freeze({ - authenticated: "인증됨", - unauthenticated: "로그인 전", - "recovery-pending": "복구 대기", - "integration-failed": "연동 필요", +const SESSION_MESSAGE_KEYS = Object.freeze({ + authenticated: "shell.session.authenticated", + unauthenticated: "shell.session.unauthenticated", + "recovery-pending": "shell.session.recoveryPending", + "integration-failed": "shell.session.integrationFailed", }); export function AppShell() { const location = useLocation(); const { sessionState, beginSignIn, signOut, recover } = useSession(); const { preference, setPreference } = useTheme(); + const { locale, setLocale, message } = useLocale(); const [navigationOpen, setNavigationOpen] = useState(false); const [sessionActionPending, setSessionActionPending] = useState(false); const [sessionActionFailed, setSessionActionFailed] = useState(false); @@ -56,20 +58,20 @@ export function AppShell() { const sessionActionLabel = sessionState === "authenticated" - ? "로그아웃" + ? message("action.signOut") : sessionState === "recovery-pending" - ? "세션 복구" - : "로그인"; + ? message("action.recoverSession") + : message("action.reauth"); const integrationAvailable = sessionState !== "integration-failed"; return (
    - 본문으로 건너뛰기 + {message("shell.skipToContent")}
    - Frontend Skeleton + {message("common.appName")}
    + setLocale( + /** @type {import("../i18n/index.js").SupportedLocale} */ ( + event.currentTarget.value + ), + ) + } + /> - {SESSION_LABELS[sessionState]} + {message(SESSION_MESSAGE_KEYS[sessionState])} {integrationAvailable ? ( ) : null}
    {sessionActionFailed ? (

    - 세션 작업을 완료하지 못했습니다. + {message("shell.session.actionFailed")}

    ) : null}
    -
@@ -117,7 +126,10 @@ export function CollectionPage( >
{props.children}
{props.pagination ? ( -