@gaonjs/cli 0.61.0 → 0.62.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/commands/check.js +8 -3
- package/dist/dev.d.ts +17 -4
- package/dist/dev.js +10 -3
- package/dist/doctor/fixers/i18n-layout.d.ts +7 -0
- package/dist/doctor/fixers/i18n-layout.js +40 -0
- package/dist/doctor/fixers/index.d.ts +1 -0
- package/dist/doctor/fixers/index.js +13 -0
- package/dist/doctor/i18n-app-scope.d.ts +8 -0
- package/dist/doctor/i18n-app-scope.js +178 -0
- package/dist/doctor/i18n-layout.d.ts +4 -0
- package/dist/doctor/i18n-layout.js +47 -0
- package/dist/doctor/locale-parity.js +21 -6
- package/dist/doctor/types.d.ts +1 -1
- package/dist/doctor.d.ts +3 -1
- package/dist/doctor.js +13 -1
- package/dist/index.js +2 -2
- package/dist/messages-gen.d.ts +28 -4
- package/dist/messages-gen.js +137 -17
- package/dist/templates/project/AGENTS.md.tpl +4 -2
- package/dist/templates/project/agents/frontend.md.tpl +8 -4
- package/dist/templates/project/agents/i18n.md.tpl +187 -213
- package/dist/templates/project/apps/web/locales/en.json.tpl +3 -0
- package/dist/templates/project/apps/web/locales/ko.json.tpl +3 -0
- package/dist/templates/project/apps/web/main.ts.tpl +7 -0
- package/dist/templates/project/apps/web/pages/Home/Index.vue.tpl +3 -3
- package/dist/templates/project/gaon.config.ts.tpl +7 -0
- package/dist/templates/project/locales/en/backend.json.tpl +4 -0
- package/dist/templates/project/locales/en/frontend.json.tpl +8 -0
- package/dist/templates/project/locales/ko/backend.json.tpl +4 -0
- package/dist/templates/project/locales/ko/frontend.json.tpl +7 -0
- package/package.json +5 -5
|
@@ -1,284 +1,258 @@
|
|
|
1
|
-
# agents/i18n.md — 다국어 (`t()` ·
|
|
1
|
+
# agents/i18n.md — 다국어 (클라 `t()` · 서버 `t()` · 카탈로그 레이아웃)
|
|
2
2
|
|
|
3
3
|
> 골격: **정본 규칙 → 정본 예시 → 알려진 함정 → 관련 결정 번호** (결정 40 · 2층 구조).
|
|
4
4
|
> 루트 `AGENTS.md` 는 코어 요약만 담는다 — 시그니처·표·예시의 정본은 이 파일이다.
|
|
5
|
-
> 대상 패키지: `@gaonjs/i18n`
|
|
5
|
+
> 대상 패키지: `@gaonjs/i18n`(서버 · import 는 `gaonjs/i18n`) · `@gaonjs/vue`(클라 · `gaonjs/vue`).
|
|
6
6
|
|
|
7
|
-
##
|
|
7
|
+
## 한 장 요약
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
| 문구가 어디에 보이나 | 카탈로그 파일 | 부르는 곳 |
|
|
10
|
+
|---|---|---|
|
|
11
|
+
| **화면**(.vue 페이지·컴포넌트·레이아웃) | `locales/<로케일>/frontend.json`(앱 공용) · `apps/<앱>/locales/<로케일>.json`(그 앱 전용) | **`import { t } from 'gaonjs/vue'`** |
|
|
12
|
+
| 메일·잡·크론·검증 문안·로그 등 **서버** | `locales/<로케일>/backend.json` | `import { t } from 'gaonjs/i18n'` |
|
|
10
13
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
로케일이 없어 항상 fallback** 이므로, 로케일을 명시적으로 실어 `runWithLanguage`
|
|
15
|
-
로 감싼다(메일은 `deliver(data, { locale })` 로 대칭 · §정본 예시).
|
|
14
|
+
한 문장: **"화면에 보이는 문구는 frontend + 클라 `t()`, 화면 밖으로 나가는 문구는
|
|
15
|
+
backend + 서버 `t()`."** 텍스트는 카탈로그에 **한 번만** 쓴다 — 컨트롤러가 키를 다시
|
|
16
|
+
열거하거나 타입을 손으로 선언하지 않는다.
|
|
16
17
|
|
|
17
|
-
|
|
18
|
+
## 정본 규칙
|
|
19
|
+
|
|
20
|
+
### 1. 카탈로그 저작 레이아웃 (결정 454)
|
|
18
21
|
|
|
19
|
-
```
|
|
20
|
-
|
|
22
|
+
```
|
|
23
|
+
locales/
|
|
24
|
+
ko/
|
|
25
|
+
backend.json # 서버 전용 — 메일·잡·검증(validation.*)·알림. 클라로 나가지 않는다.
|
|
26
|
+
frontend.json # 앱 공용 화면 문구 — nav·버튼·공통 라벨.
|
|
27
|
+
en/
|
|
28
|
+
backend.json
|
|
29
|
+
frontend.json
|
|
30
|
+
apps/
|
|
31
|
+
web/locales/ko.json # web 앱 전용 화면 문구(web 번들에만 실린다)
|
|
32
|
+
admin/locales/ko.json # admin 앱 전용 화면 문구(admin 번들에만 실린다)
|
|
21
33
|
```
|
|
22
34
|
|
|
23
|
-
`
|
|
35
|
+
- **키 이름은 스코프와 무관하다** — `t('nav.home')` 은 파일이 어디에 있든 그대로다.
|
|
36
|
+
파일 위치가 곧 "누가 이 문구를 볼 수 있나"를 정한다.
|
|
37
|
+
- **backend 는 클라 번들에 물리적으로 없다** — 앱 카탈로그 청크의 입력이 frontend 뿐이라,
|
|
38
|
+
서버 문구가 `/assets/*` 로 새지 않는다. 타입 축도 같은 경계를 지켜 클라에서 backend 키를
|
|
39
|
+
참조하면 **컴파일 에러**다.
|
|
40
|
+
- **한 키를 두 스코프에 두면 부팅 에러**(어느 번역이 나갈지가 병합 순서에 좌우되면 안 된다).
|
|
41
|
+
- 카탈로그는 **순수 JSON**(주석·트레일링 콤마 금지). 로케일 등록 = 폴더 존재.
|
|
42
|
+
- `gaon.config.ts` 의 `i18n.fallbackLng` 가 **기준 로케일**이다(키 유니온의 출처 · 결정 352).
|
|
24
43
|
|
|
25
|
-
```
|
|
26
|
-
|
|
44
|
+
```ts
|
|
45
|
+
// gaon.config.ts
|
|
46
|
+
export default defineConfig({
|
|
47
|
+
i18n: { fallbackLng: 'ko', supportedLngs: ['ko', 'en'] },
|
|
48
|
+
})
|
|
27
49
|
```
|
|
28
50
|
|
|
29
|
-
|
|
30
|
-
로케일마다 별도 파일이며 한 파일에 두 언어를 담지 않는다.
|
|
51
|
+
### 2. 화면 문구 = 클라 `t()` (결정 454 · 정본)
|
|
31
52
|
|
|
32
|
-
```
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
t
|
|
53
|
+
```vue
|
|
54
|
+
<!-- apps/web/pages/Posts/Index.vue -->
|
|
55
|
+
<script setup lang="ts">
|
|
56
|
+
import { pageProps, t } from 'gaonjs/vue'
|
|
57
|
+
const props = pageProps<'web:posts#index'>()
|
|
58
|
+
</script>
|
|
59
|
+
|
|
60
|
+
<template>
|
|
61
|
+
<h1>{{ t('posts.heading') }}</h1>
|
|
62
|
+
<p v-if="!props.posts.length">{{ t('posts.empty') }}</p>
|
|
63
|
+
<!-- 클라에서 조립하는 동적 문구 — 서버 왕복 없이 숫자가 바뀐다 -->
|
|
64
|
+
<p>{{ t('cart.items', { count: props.posts.length }) }}</p>
|
|
65
|
+
</template>
|
|
36
66
|
```
|
|
37
67
|
|
|
38
68
|
| 표면 | 시그니처 | 비고 |
|
|
39
69
|
|---|---|---|
|
|
40
|
-
| 번역 | `t(key, params?)` |
|
|
41
|
-
| 현재
|
|
42
|
-
| 지원 언어 | `languages(): readonly string[]` | 설정된 supportedLngs(읽기 전용) |
|
|
43
|
-
| 고정 번역 | `runWithLanguage(lng, fn)` | fn 안의 t() 가 그 언어(메일·비요청 경로 · §mail) |
|
|
44
|
-
| 고정 번역기 | `translator(lng)` | 언어를 고정한 번역 함수 `(key, params?) => string` 를 돌려준다(테스트·비요청 경로) |
|
|
45
|
-
|
|
46
|
-
### 1.5 복수형 — `count` 로 자동 선택 (i18next 규약 · 결정 181)
|
|
70
|
+
| 번역 | `t(key, params?)` | 키·파라미터가 **컴파일 타임 검사**된다(§4) · 반응형 |
|
|
71
|
+
| 현재 로케일 | `currentLocale(): string` | 서버가 협상한 로케일(공유 prop) |
|
|
47
72
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
73
|
+
- **컨트롤러에서 화면 문구를 번역해 넘기지 않는다** — 텍스트가 카탈로그·컨트롤러 두 곳에
|
|
74
|
+
적히던 옛 방식(결정 213)은 탈출구로만 남는다(§6).
|
|
75
|
+
- 카탈로그는 **활성 로케일 청크 1개**로 나가고(`/assets/*` · immutable 캐시), Inertia
|
|
76
|
+
마운트 **전에** 로드된다 — 미번역 키가 스치는 프레임이 없다.
|
|
77
|
+
- `main.ts` 배선(스캐폴드가 이미 해 둔다):
|
|
51
78
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
79
|
+
```ts
|
|
80
|
+
// apps/web/main.ts
|
|
81
|
+
import { catalogs, fallbackLng } from './.gaon/messages.catalog.js'
|
|
82
|
+
void createGaonApp({ pages, layouts, routes, messages: { catalogs, fallbackLng } })
|
|
56
83
|
```
|
|
57
84
|
|
|
58
|
-
|
|
85
|
+
### 3. 서버 문구 = 서버 `t()` (역할 유지)
|
|
59
86
|
|
|
60
|
-
```
|
|
61
|
-
|
|
87
|
+
```ts
|
|
88
|
+
// domain/mails/welcome.ts — 수신자 로케일로 렌더된다(결정 160)
|
|
89
|
+
import { t } from 'gaonjs/i18n'
|
|
90
|
+
export const Welcome = mail({ subject: () => t('mail.welcome.subject', { name: '가온' }) })
|
|
62
91
|
```
|
|
63
92
|
|
|
64
93
|
```ts
|
|
65
|
-
|
|
66
|
-
|
|
94
|
+
// domain/jobs/sendDigest.ts — 잡은 요청 밖이라 로케일을 페이로드로 싣는다
|
|
95
|
+
import { runWithLanguage, t } from 'gaonjs/i18n'
|
|
96
|
+
export const SendDigest = job(async ({ locale }: { locale: string }) => {
|
|
97
|
+
const line = runWithLanguage(locale, () => t('mail.digest.body'))
|
|
98
|
+
})
|
|
67
99
|
```
|
|
68
100
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
101
|
+
| 표면 | 시그니처 | 쓰는 곳 |
|
|
102
|
+
|---|---|---|
|
|
103
|
+
| 번역 | `t(key, params?)` | 요청 로케일(ALS) 자동 추종 |
|
|
104
|
+
| 현재 언어 | `currentLanguage(): string` | 요청 로케일 |
|
|
105
|
+
| 지원 언어 | `languages(): readonly string[]` | 설정된 supportedLngs |
|
|
106
|
+
| 로케일 고정 | `runWithLanguage(lng, fn)` | 잡·크론·스크립트(요청 밖) |
|
|
107
|
+
| 고정 번역기 | `translator(lng)` | 테스트·비요청 경로 |
|
|
73
108
|
|
|
74
|
-
|
|
109
|
+
서버 `t()` 는 backend ∪ frontend 를 **모두** 볼 수 있다(SSR·로그·관리 스크립트에서 화면
|
|
110
|
+
문구가 필요할 수 있다). 반대 방향만 막힌다 — 클라는 frontend 만.
|
|
75
111
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
112
|
+
### 4. 키·파라미터 타입 — `.gaon/messages.d.ts` 두 갈래 (결정 454)
|
|
113
|
+
|
|
114
|
+
`gaon gen`/`dev`/`check` 가 카탈로그를 읽어 **두 유니온**을 생성한다:
|
|
115
|
+
서버(`gaonjs/i18n`) = backend ∪ frontend / 클라(`gaonjs/vue`) = frontend 만.
|
|
80
116
|
|
|
81
117
|
```ts
|
|
82
|
-
//
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
// dir: 'locales', // 생략 시 'locales' — 타입 축·doctor 도 이 값을 따른다(결정 352)
|
|
88
|
-
// detect: { cookieName: 'gaon_locale', priority: ['session', 'cookie', 'header'] }, // 기본값
|
|
89
|
-
},
|
|
90
|
-
})
|
|
118
|
+
t('posts.heading') // ✅
|
|
119
|
+
t('mail.welcome.subject', { name }) // ❌ 클라에서 backend 키 — 컴파일 에러
|
|
120
|
+
t('greeting') // ❌ {{name}} 보간 파라미터 누락 — 컴파일 에러
|
|
121
|
+
t('greeting', { nam: '가온' }) // ❌ 파라미터 이름 오타 — 컴파일 에러
|
|
122
|
+
t('cart.items', { count: 'many' }) // ❌ count 는 number — 컴파일 에러
|
|
91
123
|
```
|
|
92
124
|
|
|
93
|
-
|
|
125
|
+
카탈로그가 없거나 frontend 스코프가 없으면 키가 `string` 으로 열린다(무회귀). 이 파일은
|
|
126
|
+
자동 생성이니 직접 수정하지 않는다.
|
|
94
127
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
128
|
+
### 5. 복수형 — `count` 로 자동 선택 (i18next 규약 · 결정 181)
|
|
129
|
+
|
|
130
|
+
카탈로그에 **접미사 키**(`<키>_one`·`<키>_other`)를 두고 `t('<키>', { count })` 로 부른다.
|
|
131
|
+
타입은 **base 키**로 검사한다. 서버·클라 런타임이 같은 CLDR 규칙(`Intl.PluralRules`)을 써
|
|
132
|
+
**같은 문자열**을 낸다(85 케이스 동치 게이트로 고정).
|
|
133
|
+
|
|
134
|
+
```json
|
|
135
|
+
// locales/en/frontend.json — 영어는 단수/복수 구분
|
|
136
|
+
{ "cart": { "items_one": "{{count}} item", "items_other": "{{count}} items" } }
|
|
137
|
+
```
|
|
138
|
+
```json
|
|
139
|
+
// locales/ko/frontend.json — 한국어는 _other 만
|
|
140
|
+
{ "cart": { "items_other": "상품 {{count}}개" } }
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
### 6. 옛 방식(서버 주도)은 탈출구로만 (결정 213 부분 번복)
|
|
144
|
+
|
|
145
|
+
컨트롤러 render props·`app.config` `sharedProps` 로 번역 문구를 넘기던 경로는 **계속
|
|
146
|
+
동작한다**(무회귀). 다만 정본이 아니다 — 그 길은 문구 하나에 선언 지점이 3곳(카탈로그 +
|
|
147
|
+
키 재열거 + `GaonSharedProps` 수동 선언)이라 문구 수에 비례해 늘어난다. 새 코드는 §2 를 쓴다.
|
|
148
|
+
`sharedProps` 자체는 **번역과 무관한** 앱 공유 값(테마·플래그)에 그대로 쓴다(결정 150).
|
|
149
|
+
|
|
150
|
+
### 7. 요청별 로케일 협상·전환 (결정 159 · 불변)
|
|
151
|
+
|
|
152
|
+
`gaon.config.ts` 에 `i18n` 이 있으면 매 요청 **세션 > 쿠키 > Accept-Language** 로 협상된다.
|
|
153
|
+
전환은 서버가 권위를 갖는다 — 클라에 `setLocale` 은 **없다**.
|
|
98
154
|
|
|
99
155
|
```ts
|
|
100
|
-
// 컨트롤러 — 언어 전환 라우트
|
|
156
|
+
// 컨트롤러 — 언어 전환 라우트
|
|
101
157
|
async switchLocale() {
|
|
102
|
-
// 입력은 정본 this.params — raw this.request.params + cast 대신 통합 입력 접근자.
|
|
103
158
|
const { lng } = this.params({ _row: {} as { lng: string } })
|
|
104
|
-
this.setLocale(lng)
|
|
159
|
+
this.setLocale(lng) // gaon_locale 쿠키(+세션)에 저장
|
|
105
160
|
return this.redirect(this.request.headers.referer ?? '/')
|
|
106
161
|
}
|
|
107
162
|
```
|
|
108
163
|
|
|
109
|
-
|
|
164
|
+
전환 뒤에는 새 로케일 카탈로그 청크가 필요하므로 **전체 리로드가 정답**이다(그 한 번의
|
|
165
|
+
왕복이 깜빡임 0을 보장한다). `<html lang>` 은 최초 문서에서 협상 로케일을 자동 추종한다(결정 214).
|
|
110
166
|
|
|
111
|
-
|
|
112
|
-
`.gaon/messages.d.ts`(키 유니온)를 생성한다(routes·tables 와 같은 `.gaon`
|
|
113
|
-
파이프라인의 3번째 축). 그러면 `t('없는키')` 가 **컴파일 에러**로 잡힌다 —
|
|
114
|
-
카탈로그에 없는 키·오타가 `gaon check` 에서 걸린다(카탈로그가 없으면 키는
|
|
115
|
-
`string` 폴백). 이 파일은 자동 생성이니 직접 수정하지 않는다.
|
|
167
|
+
### 8. 로케일 커버리지 — `locale-parity` 경고 (결정 216)
|
|
116
168
|
|
|
117
|
-
|
|
169
|
+
키 유니온은 기준 로케일 하나에서 나오므로, 어떤 키를 `ko` 에만 넣고 `ja` 에 빠뜨리면
|
|
170
|
+
컴파일은 통과하고 일본어 사용자만 조용히 fallback 을 본다. `gaon doctor` 의 `locale-parity`
|
|
171
|
+
가 **스코프별로**(backend↔backend · frontend↔frontend) 키 diff 를 계산해 경고한다.
|
|
118
172
|
|
|
119
|
-
|
|
120
|
-
는 `t()`·`useT()` 를 **내보내지 않는다**(설계 의도). Vue 페이지·컴포넌트는 번역을
|
|
121
|
-
직접 하지 않고, **서버에서 번역한 문자열을 받아 쓴다.** 번역 소스가 서버 하나로
|
|
122
|
-
고정되므로(The One Way) 카탈로그가 클라 번들에 중복 실리지 않고, 키 타입 검사
|
|
123
|
-
(§4)도 서버 한 곳에서만 성립한다. 경로는 두 가지다:
|
|
173
|
+
## 정본 예시
|
|
124
174
|
|
|
125
|
-
|
|
126
|
-
`t()` 로 번역해 `this.render` props 로 넘긴다 — 페이지는 이미 현지화된 문자열을 받는다.
|
|
175
|
+
### (a) 새 화면 문구 추가 — 앱 전용
|
|
127
176
|
|
|
128
|
-
```
|
|
129
|
-
// apps/web/
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
posts: (await Post.latest().all()).map((p) => ({ id: String(p.id), title: p.title })),
|
|
136
|
-
})
|
|
137
|
-
}
|
|
177
|
+
```jsonc
|
|
178
|
+
// apps/web/locales/ko.json
|
|
179
|
+
{ "posts": { "heading": "게시글 목록", "empty": "아직 글이 없습니다." } }
|
|
180
|
+
```
|
|
181
|
+
```jsonc
|
|
182
|
+
// apps/web/locales/en.json ← 같은 키를 나란히(locale-parity)
|
|
183
|
+
{ "posts": { "heading": "Posts", "empty": "No posts yet." } }
|
|
138
184
|
```
|
|
139
|
-
|
|
140
185
|
```vue
|
|
141
|
-
<!-- apps/web/pages/Posts/Index.vue -->
|
|
142
186
|
<script setup lang="ts">
|
|
143
|
-
import {
|
|
144
|
-
const props = pageProps<'web:posts#index'>() // heading·empty 가 현지화된 채로 온다
|
|
187
|
+
import { t } from 'gaonjs/vue'
|
|
145
188
|
</script>
|
|
146
|
-
<template>
|
|
147
|
-
<h1>{{ props.heading }}</h1>
|
|
148
|
-
<p v-if="!props.posts.length">{{ props.empty }}</p>
|
|
149
|
-
</template>
|
|
189
|
+
<template><h1>{{ t('posts.heading') }}</h1></template>
|
|
150
190
|
```
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
nav 라벨·레이아웃 문구처럼 앱의 **모든** 페이지가 쓰는 chrome 문구는 컨트롤러마다
|
|
154
|
-
넘기지 않고 `sharedProps` 로 한 번 등록한다 — `useShared()` 로 라우트 키 없이 읽힌다
|
|
155
|
-
(레이아웃·공용 컴포넌트에서 특히 유용 · `agents/frontend.md` §1·web.md §4.2).
|
|
156
|
-
|
|
157
|
-
```ts
|
|
158
|
-
// apps/web/app.config.ts
|
|
159
|
-
import { defineAppConfig } from 'gaonjs/config'
|
|
160
|
-
import { t } from 'gaonjs/i18n'
|
|
161
|
-
export default defineAppConfig({
|
|
162
|
-
sharedProps: () => ({
|
|
163
|
-
nav: { home: t('nav.home'), posts: t('nav.posts') }, // 요청 로케일로 번역돼 전 렌더에 주입
|
|
164
|
-
}),
|
|
165
|
-
})
|
|
191
|
+
```bash
|
|
192
|
+
gaon gen # .gaon/messages.d.ts(두 갈래) + apps/web/.gaon/messages.catalog.ts 재생성
|
|
166
193
|
```
|
|
167
194
|
|
|
168
|
-
|
|
169
|
-
고정 · 앱 키만 추가):
|
|
195
|
+
### (b) 앱 공용 chrome 문구(모든 앱이 쓰는 nav)
|
|
170
196
|
|
|
171
|
-
```
|
|
172
|
-
//
|
|
173
|
-
|
|
174
|
-
declare module 'gaonjs/vue' {
|
|
175
|
-
interface GaonSharedProps { nav: { home: string; posts: string } }
|
|
176
|
-
}
|
|
197
|
+
```jsonc
|
|
198
|
+
// locales/ko/frontend.json
|
|
199
|
+
{ "nav": { "home": "홈", "posts": "게시판" } }
|
|
177
200
|
```
|
|
178
|
-
|
|
179
201
|
```vue
|
|
180
202
|
<!-- apps/web/layouts/Default.vue -->
|
|
181
203
|
<script setup lang="ts">
|
|
182
|
-
import {
|
|
183
|
-
const shared = useShared() // 타입 안전 · 반응형
|
|
204
|
+
import { t, Link } from 'gaonjs/vue'
|
|
184
205
|
</script>
|
|
185
206
|
<template>
|
|
186
207
|
<nav>
|
|
187
|
-
<Link href="/">{{
|
|
188
|
-
<Link href="/posts">{{
|
|
208
|
+
<Link href="/">{{ t('nav.home') }}</Link>
|
|
209
|
+
<Link href="/posts">{{ t('nav.posts') }}</Link>
|
|
189
210
|
</nav>
|
|
190
211
|
</template>
|
|
191
212
|
```
|
|
192
213
|
|
|
193
|
-
|
|
194
|
-
render props·sharedProps 가 새 로케일로 다시 번역돼 흘러든다. 클라가 카탈로그를
|
|
195
|
-
들고 다시 번역할 일이 없다(같은 정답이 둘로 갈라지지 않는다).
|
|
196
|
-
- **함수형 `sharedProps` 로 매 요청 번역** — `sharedProps: () => ({...})` 는 요청마다
|
|
197
|
-
실행되므로 `t()` 가 그 요청의 로케일을 읽는다. 상수 객체로 굳히면 첫 로케일에
|
|
198
|
-
박제된다(변이 축은 함수로 계산 · web.md §4.2).
|
|
199
|
-
|
|
200
|
-
### 6. `<html lang>` 은 요청 로케일을 자동으로 따른다 (결정 214)
|
|
201
|
-
|
|
202
|
-
`i18n` 이 설정된 프로젝트는 최초 문서 응답(Inertia 셸)의 `<html lang>` 이 그 요청의
|
|
203
|
-
협상 로케일로 자동 치환된다 — `apps/<앱>/index.html` 의 `<html lang="ko">` 는 정적
|
|
204
|
-
기본값일 뿐이고, `Accept-Language: ja`(또는 `gaon_locale=ja` 쿠키·세션)면 응답 셸은
|
|
205
|
-
`<html lang="ja">` 로 나간다. 손으로 배선할 것이 없다(자동 · SEO·스크린리더·`:lang`
|
|
206
|
-
CSS 가 올바른 언어를 안다). 비-i18n 프로젝트는 템플릿 정적값을 그대로 유지한다.
|
|
207
|
-
|
|
208
|
-
- **템플릿의 `lang` 을 요청마다 바꾸려 하지 말 것** — `index.html` 은 정적 기본값만
|
|
209
|
-
둔다. 실제 치환은 서버 셸 조립이 한다(SPA X-Inertia JSON 응답은 대상 아님 · 최초
|
|
210
|
-
문서에만 `<html>` 이 있다).
|
|
211
|
-
- **로케일 전환(`this.setLocale`·§3) 후에도 추종** — 다음 요청의 협상 로케일이
|
|
212
|
-
쿠키/세션으로 실려 셸 `lang` 도 함께 바뀐다.
|
|
213
|
-
|
|
214
|
-
### 7. 로케일 커버리지 — `locale-parity` 경고 (결정 216)
|
|
215
|
-
|
|
216
|
-
`messages.d.ts` 의 키 유니온은 **기준 로케일 하나**에서 나온다(§4 · 기준 = `fallbackLng` — 결정 352 ·
|
|
217
|
-
이전엔 알파벳순 첫 로케일이라 컴파일 보증이 fallback 체인과 다른 로케일에 정박했다). 그래서 어떤 키를
|
|
218
|
-
`ko.json`·`en.json` 에는 넣고 `ja.json` 에만 빠뜨리면 **컴파일은 통과**하고, 런타임에
|
|
219
|
-
일본어 사용자만 fallback(대개 다른 언어) 번역을 조용히 본다 — 타입도 화면도 못 잡는
|
|
220
|
-
사각이다. `gaon doctor`/`gaon check` 의 `locale-parity`(§2.2 27번)가 `locales/` 의
|
|
221
|
-
로케일 간 키 diff 를 계산해 빠진 파일·키를 **경고**로 짚는다(`--json` 은 `detail.missing`).
|
|
222
|
-
로케일이 0·1개면 비교 대상이 없어 무소음이다. 경고이므로 빌드를 세우진 않지만, 로케일을
|
|
223
|
-
추가할 때 키를 전 로케일에 나란히 채워 커버리지를 맞추는 것이 관례다.
|
|
224
|
-
|
|
225
|
-
## 정본 예시
|
|
214
|
+
### (c) 서버 전용 문구(메일)
|
|
226
215
|
|
|
216
|
+
```jsonc
|
|
217
|
+
// locales/ko/backend.json
|
|
218
|
+
{ "mail": { "welcome": { "subject": "{{name}}님, 환영합니다" } } }
|
|
219
|
+
```
|
|
227
220
|
```ts
|
|
228
|
-
// domain/services/greet.ts — 서비스는 요청 컨텍스트 안이라 t() 가 요청 로케일을 쓴다.
|
|
229
221
|
import { t } from 'gaonjs/i18n'
|
|
230
|
-
|
|
231
|
-
return t('greeting', { name })
|
|
232
|
-
}
|
|
222
|
+
const subject = t('mail.welcome.subject', { name: user.name })
|
|
233
223
|
```
|
|
234
224
|
|
|
235
|
-
|
|
236
|
-
// domain/jobs/sendDigest.ts — 잡은 `gaon work`(별도 프로세스)라 요청 컨텍스트가 없다.
|
|
237
|
-
// 로케일을 페이로드에 실어 runWithLanguage 로 감싼다(안 그러면 t() 는 fallback).
|
|
238
|
-
import { job } from 'gaonjs/async'
|
|
239
|
-
import { runWithLanguage, t } from 'gaonjs/i18n'
|
|
225
|
+
### (d) 레거시 단일 파일에서 이관
|
|
240
226
|
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
227
|
+
```bash
|
|
228
|
+
gaon doctor # i18n-layout 경고 — 클라 t() 를 쓸 수 없는 상태
|
|
229
|
+
gaon doctor --fix # locales/ko.json → locales/ko/backend.json (내용·키 무변경)
|
|
230
|
+
# 화면에 보이는 문구만 locales/ko/frontend.json 으로 옮긴다(사람의 판단 — 노출 방향)
|
|
231
|
+
gaon gen
|
|
245
232
|
```
|
|
246
233
|
|
|
247
|
-
`t()` 는 요청 컨텍스트(ALS)의 로케일을 자동으로 따라간다 — 로케일을 인자로
|
|
248
|
-
넘기고 다니지 않는다. **요청 밖(잡·크론·스크립트)** 이나 특정 로케일로 강제하려면
|
|
249
|
-
`runWithLanguage(lng, () => t('key'))` — 잡은 로케일을 페이로드에 담아 넘긴다(위 예).
|
|
250
|
-
|
|
251
234
|
## 알려진 함정
|
|
252
235
|
|
|
253
|
-
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
-
|
|
259
|
-
`
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
- **메일은 요청 로케일이 아니라 수신자 로케일** — `deliver(data, { locale })` 로
|
|
274
|
-
명시한다(`agents/mail.md` · 결정 160).
|
|
275
|
-
- **검증 실패 문안도 로케일화된다** — 예약 namespace `validation.<code>`(예 `validation.required`)
|
|
276
|
-
를 `locales/` 에 넣으면 필드별 사유가 요청 로케일로 번역된다(`agents/web.md` §4.1 · 결정 183).
|
|
277
|
-
프레임웍은 코드만 노출하고 번역은 앱 몫이다(미제공 시 내장 fallback).
|
|
278
|
-
- **Vue 에서 `t()` 를 부르지 말 것** — `gaonjs/vue` 에는 `t()`·`useT()` 가 없다(서버 ALS
|
|
279
|
-
전용 · 결정 213). 페이지 문구는 컨트롤러 render props, 앱 chrome 은 `sharedProps` 로
|
|
280
|
-
서버가 번역해 흘려보낸다(§5). 클라 번역 접근자를 자작하지 않는다(카탈로그 이중 존재·
|
|
281
|
-
번들 비용).
|
|
236
|
+
- **클라에서 backend 키를 쓰지 말 것** — 컴파일 에러로 막힌다. 그 문구가 화면에도 필요하면
|
|
237
|
+
`frontend.json` 으로 **옮긴다**(양쪽에 복사하면 부팅 에러).
|
|
238
|
+
- **다른 앱 전용 키를 쓰지 말 것** — 타입은 프로젝트 전체 frontend 합집합이라 통과하지만
|
|
239
|
+
(앱별 유니온은 TS2717 로 구조적으로 불가), 그 앱 번들에는 없어서 화면에 키 문자열이
|
|
240
|
+
그대로 뜬다. `gaon doctor` 의 `i18n-app-scope` 가 잡는다.
|
|
241
|
+
- **`.vue` 에서 서버 `t`(`gaonjs/i18n`)를 import 하지 말 것** — ALS 기반 서버 전용이다.
|
|
242
|
+
클라는 `gaonjs/vue` 의 `t`.
|
|
243
|
+
- **`v-html` + `t()` + 사용자 입력 조합 금지** — 템플릿 텍스트 보간(`{{ t(...) }}`)은 Vue 가
|
|
244
|
+
이스케이프하지만 `v-html` 은 하지 않는다(서버·클라 모두 `escapeValue:false`).
|
|
245
|
+
- **카탈로그에 비밀·미출시 문구를 두지 말 것** — `frontend.json` 은 클라로 나간다. 권한이
|
|
246
|
+
갈리는 화면은 **앱을 나눈다**(같은 앱 안에서는 문구가 갈리지 않는다).
|
|
247
|
+
- **잡·크론은 로케일을 페이로드에 싣는다** — 요청 밖이라 ALS 가 없다(`runWithLanguage`).
|
|
248
|
+
- **메일은 요청 로케일이 아니라 수신자 로케일** — `deliver(data, { locale })`(결정 160).
|
|
249
|
+
- **`i18n.dir`·`fallbackLng` 는 문자열 리터럴로**(결정 412) — 변수·env 표현식은 타입 축과
|
|
250
|
+
doctor 가 정적으로 못 읽는다.
|
|
251
|
+
- **`supportedLngs: []`(빈 배열)은 "미지정" 과 같다**(결정 414).
|
|
252
|
+
- **`gaon check` 가 부팅 조건을 먼저 본다**(결정 413) — 카탈로그가 비었거나 `fallbackLng`
|
|
253
|
+
가 지목한 카탈로그가 없으면 check 가 실패한다.
|
|
254
|
+
- **검증 실패 문안은 backend** — 예약 namespace `validation.<code>` 를 `backend.json` 에
|
|
255
|
+
두면 서버가 요청 로케일로 번역한다(결정 183).
|
|
282
256
|
|
|
283
257
|
## 관련 결정 번호
|
|
284
258
|
|
|
@@ -286,12 +260,12 @@ export const SendDigest = job(async ({ userId, locale }: { userId: bigint; local
|
|
|
286
260
|
|---|---|
|
|
287
261
|
| §7 (v0.15) | i18n 배터리 · locales/ 카탈로그 · t() |
|
|
288
262
|
| 결정 158 (13차 W2) | `.gaon/messages.d.ts` 키 타입 브리지 — 없는 키 컴파일 에러 |
|
|
289
|
-
| 결정 159 (13차 W1) | 요청별 로케일 자동 협상
|
|
290
|
-
| 결정
|
|
291
|
-
| 결정
|
|
292
|
-
| 결정
|
|
293
|
-
| 결정
|
|
294
|
-
| 결정
|
|
295
|
-
| 결정
|
|
296
|
-
| 결정
|
|
297
|
-
|
|
|
263
|
+
| 결정 159 (13차 W1) | 요청별 로케일 자동 협상 · `this.setLocale` · detect 설정 |
|
|
264
|
+
| 결정 181 | 복수형 base 키 노출(`t('cart.items', { count })`) |
|
|
265
|
+
| 결정 213 | (구) Vue 소비 = 서버 주도 render props/sharedProps 만 — **결정 454 로 부분 번복**(§6 탈출구로 존속) |
|
|
266
|
+
| 결정 214 (13차 W2) | 최초 문서 셸 `<html lang>` 이 협상 로케일 자동 추종 |
|
|
267
|
+
| 결정 216 (13차 W4) | `locale-parity` 경고 — 결정 454 로 **스코프별 비교** |
|
|
268
|
+
| 결정 352 | 기준 로케일 = `fallbackLng` · `i18n.dir` 존중 |
|
|
269
|
+
| 결정 353 · 413 | 빈 카탈로그·지목 로케일 부재 = 부팅 fail-loud + check 사전 검사 |
|
|
270
|
+
| 결정 412 · 414 | i18n config 정적 분석 범위·경고 · 생성 키 이스케이프 · `supportedLngs: []` |
|
|
271
|
+
| **결정 454** | **클라 `t()`(`gaonjs/vue`) · backend/frontend 폴더 레이아웃 · 두 갈래 타입 · 앱별 카탈로그 청크(immutable·seal 면제) · 공유 prop `locale`(연성 예약) · doctor `i18n-layout`·`i18n-app-scope`** |
|
|
@@ -13,6 +13,12 @@ import { createGaonApp } from 'gaonjs/vue'
|
|
|
13
13
|
// .gaon/routes.manifest.ts 를 재생성한다. 이 값을 넘겨야 api() 가 요청 경로를 안다.
|
|
14
14
|
import { routes } from './.gaon/routes.manifest.js'
|
|
15
15
|
|
|
16
|
+
// 클라이언트 번역 카탈로그(결정 454) — 로케일별 로더. gaon dev·check·gen 이
|
|
17
|
+
// .gaon/messages.catalog.ts 를 재생성한다. 넘기면 페이지에서 t('키') 를 쓸 수 있고,
|
|
18
|
+
// 활성 로케일 카탈로그는 **마운트 전에** 로드된다(미번역 문구가 스치는 프레임 0).
|
|
19
|
+
// frontend 스코프만 실린다 — locales/<로케일>/backend.json 은 서버 전용이라 안 나간다.
|
|
20
|
+
import { catalogs, fallbackLng } from './.gaon/messages.catalog.js'
|
|
21
|
+
|
|
16
22
|
// 전역 스타일 — Tailwind 레이어 + 디자인 토큰(결정 74). 부수효과 import 라
|
|
17
23
|
// 번들에 CSS 가 실린다. 앱마다 하나(관례 = 배치).
|
|
18
24
|
import './style.css'
|
|
@@ -29,5 +35,6 @@ void createGaonApp({
|
|
|
29
35
|
pages,
|
|
30
36
|
layouts,
|
|
31
37
|
routes,
|
|
38
|
+
messages: { catalogs, fallbackLng },
|
|
32
39
|
title: (t) => (t ? `${t} · {{PROJECT_NAME}}` : '{{PROJECT_NAME}}'),
|
|
33
40
|
})
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
import { computed } from 'vue'
|
|
3
|
-
import { pageProps } from 'gaonjs/vue'
|
|
3
|
+
import { pageProps, t } from 'gaonjs/vue'
|
|
4
4
|
import { useGaonHealth, type HealthDoctor } from '../../composables/useGaonHealth.js'
|
|
5
5
|
import Card from '@shared/components/ui/Card.vue'
|
|
6
6
|
import Badge from '@shared/components/ui/Badge.vue'
|
|
@@ -172,7 +172,7 @@ const routeLines = computed<CodeLine[]>(() => {
|
|
|
172
172
|
<div class="mx-auto max-w-[880px] px-5 pb-16 pt-12 leading-relaxed">
|
|
173
173
|
<section class="mb-10">
|
|
174
174
|
<p class="mb-1.5 text-xs font-semibold uppercase tracking-[0.08em] text-[#7c5cff]">{{ props.title }}</p>
|
|
175
|
-
<h1 class="mb-3.5 text-[clamp(2rem,5vw,2.9rem)] font-bold leading-[1.15] tracking-tight"
|
|
175
|
+
<h1 class="mb-3.5 text-[clamp(2rem,5vw,2.9rem)] font-bold leading-[1.15] tracking-tight">{{ t('home.tagline') }}</h1>
|
|
176
176
|
<p class="my-1.5 text-[1.02rem] text-foreground/80">
|
|
177
177
|
이 화면은 <code class="rounded bg-muted px-1.5 py-0.5 font-mono text-[0.86em]">apps/web/pages/Home/Index.vue</code> 입니다.
|
|
178
178
|
<code class="rounded bg-muted px-1.5 py-0.5 font-mono text-[0.86em]">routes.ts</code> 의 <code class="rounded bg-muted px-1.5 py-0.5 font-mono text-[0.86em]">r.get('/', 'home#index')</code> 가 여기로 연결했습니다.
|
|
@@ -197,7 +197,7 @@ const routeLines = computed<CodeLine[]>(() => {
|
|
|
197
197
|
</Card>
|
|
198
198
|
</section>
|
|
199
199
|
|
|
200
|
-
<p v-if="loading" class="my-1 mb-6 text-sm text-muted-foreground"
|
|
200
|
+
<p v-if="loading" class="my-1 mb-6 text-sm text-muted-foreground">{{ t('common.loading') }}</p>
|
|
201
201
|
<p v-else-if="!available" class="my-1 mb-6 text-sm text-muted-foreground">
|
|
202
202
|
라이브 상태는 <code class="rounded bg-muted px-1.5 py-0.5 font-mono text-[0.86em]">gaon dev</code> 개발 서버에서만 보입니다(운영 빌드엔 진단 엔드포인트가 없습니다).
|
|
203
203
|
</p>
|
|
@@ -54,6 +54,13 @@ export default defineConfig({
|
|
|
54
54
|
}
|
|
55
55
|
: undefined,
|
|
56
56
|
|
|
57
|
+
// 다국어(§7 · 결정 454). 카탈로그는 언어별 폴더 + 스코프 분리다:
|
|
58
|
+
// locales/<로케일>/frontend.json — 화면 문구. 클라 t()(gaonjs/vue)가 쓰고 앱 번들 청크로 나간다.
|
|
59
|
+
// locales/<로케일>/backend.json — 메일·잡·검증 등 **서버 전용**. 클라로 나가지 않는다.
|
|
60
|
+
// apps/<앱>/locales/<로케일>.json — 그 앱 전용 화면 문구(그 앱 청크에만 실린다).
|
|
61
|
+
// 요청 로케일은 세션>쿠키>Accept-Language 로 자동 협상된다(결정 159 · this.setLocale 로 전환).
|
|
62
|
+
i18n: { fallbackLng: 'ko', supportedLngs: ['ko', 'en'] },
|
|
63
|
+
|
|
57
64
|
// 웹 서버 리슨 옵션. --port · env PORT 로 덮을 수 있다.
|
|
58
65
|
web: {
|
|
59
66
|
port: process.env.PORT ? Number(process.env.PORT) : 3000,
|