@gaonjs/cli 0.60.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.
Files changed (35) hide show
  1. package/dist/commands/check.js +8 -3
  2. package/dist/dev.d.ts +17 -4
  3. package/dist/dev.js +10 -3
  4. package/dist/doctor/fixers/i18n-layout.d.ts +7 -0
  5. package/dist/doctor/fixers/i18n-layout.js +40 -0
  6. package/dist/doctor/fixers/index.d.ts +1 -0
  7. package/dist/doctor/fixers/index.js +18 -0
  8. package/dist/doctor/i18n-app-scope.d.ts +8 -0
  9. package/dist/doctor/i18n-app-scope.js +178 -0
  10. package/dist/doctor/i18n-layout.d.ts +4 -0
  11. package/dist/doctor/i18n-layout.js +47 -0
  12. package/dist/doctor/locale-parity.js +21 -6
  13. package/dist/doctor/page-fetch.d.ts +8 -0
  14. package/dist/doctor/page-fetch.js +107 -0
  15. package/dist/doctor/types.d.ts +1 -1
  16. package/dist/doctor.d.ts +3 -1
  17. package/dist/doctor.js +19 -2
  18. package/dist/index.js +3 -3
  19. package/dist/messages-gen.d.ts +28 -4
  20. package/dist/messages-gen.js +137 -17
  21. package/dist/templates/project/AGENTS.md.tpl +6 -3
  22. package/dist/templates/project/CLAUDE.md.tpl +1 -1
  23. package/dist/templates/project/agents/frontend.md.tpl +38 -6
  24. package/dist/templates/project/agents/i18n.md.tpl +187 -213
  25. package/dist/templates/project/agents/realtime.md.tpl +36 -2
  26. package/dist/templates/project/apps/web/locales/en.json.tpl +3 -0
  27. package/dist/templates/project/apps/web/locales/ko.json.tpl +3 -0
  28. package/dist/templates/project/apps/web/main.ts.tpl +7 -0
  29. package/dist/templates/project/apps/web/pages/Home/Index.vue.tpl +3 -3
  30. package/dist/templates/project/gaon.config.ts.tpl +7 -0
  31. package/dist/templates/project/locales/en/backend.json.tpl +4 -0
  32. package/dist/templates/project/locales/en/frontend.json.tpl +8 -0
  33. package/dist/templates/project/locales/ko/backend.json.tpl +4 -0
  34. package/dist/templates/project/locales/ko/frontend.json.tpl +7 -0
  35. package/package.json +6 -6
@@ -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` (파사드 import 는 `gaonjs/i18n`).
5
+ > 대상 패키지: `@gaonjs/i18n`(서버 · import 는 `gaonjs/i18n`) · `@gaonjs/vue`(클라 · `gaonjs/vue`).
6
6
 
7
- ## 정본 규칙
7
+ ## 장 요약
8
8
 
9
- ### 1. 카탈로그와 `t()`
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
- 번역 문자열은 프로젝트 루트 `locales/<로케일>.json` 둔다(중첩 JSON = 표기
12
- 키). `t('key')` **현재 요청 로케일**의 문자열을 얻는다 요청 컨텍스트
13
- (컨트롤러·서비스)에서 자동으로 따라간다. **요청 밖(잡·크론·스크립트)에는 요청
14
- 로케일이 없어 항상 fallback** 이므로, 로케일을 명시적으로 실어 `runWithLanguage`
15
- 로 감싼다(메일은 `deliver(data, { locale })` 로 대칭 · §정본 예시).
14
+ 문장: **"화면에 보이는 문구는 frontend + 클라 `t()`, 화면 밖으로 나가는 문구는
15
+ backend + 서버 `t()`."** 텍스트는 카탈로그에 **한 번만** 쓴다컨트롤러가 키를 다시
16
+ 열거하거나 타입을 손으로 선언하지 않는다.
16
17
 
17
- `locales/ko.json`:
18
+ ## 정본 규칙
19
+
20
+ ### 1. 카탈로그 저작 레이아웃 (결정 454)
18
21
 
19
- ```json
20
- { "greeting": "안녕하세요, {{name}}님", "nav": { "home": "홈" } }
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
- `locales/en.json`:
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
- ```json
26
- { "greeting": "Hello, {{name}}", "nav": { "home": "Home" } }
44
+ ```ts
45
+ // gaon.config.ts
46
+ export default defineConfig({
47
+ i18n: { fallbackLng: 'ko', supportedLngs: ['ko', 'en'] },
48
+ })
27
49
  ```
28
50
 
29
- 카탈로그는 **순수 JSON** 이다 주석·트레일링 콤마가 들어가면 로드가 깨진다.
30
- 로케일마다 별도 파일이며 한 파일에 두 언어를 담지 않는다.
51
+ ### 2. 화면 문구 = 클라 `t()` (결정 454 · 정본)
31
52
 
32
- ```ts
33
- import { t } from 'gaonjs/i18n'
34
- t('greeting', { name: '가온' }) // 요청 로케일이 ko 면 "안녕하세요, 가온님"
35
- t('nav.home') // 중첩은 표기
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?)` | 키는 카탈로그에서 타입 검사(아래 §4) · params 는 `{{name}}` 보간 |
41
- | 현재 언어 | `currentLanguage(): string` | 요청 로케일 |
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
- 복수형은 카탈로그에 **접미사 키**(`<키>_one`·`<키>_other`)를 두고 `t('<키>', { count })`
49
- 부른다 i18next `count` 와 로케일의 CLDR 규칙으로 알맞은 접미사를 고른다. 타입은
50
- **base 키**(`<키>`)로 검사한다(생성기가 접미사 키에서 base 키를 함께 노출 · 결정 181).
73
+ - **컨트롤러에서 화면 문구를 번역해 넘기지 않는다** 텍스트가 카탈로그·컨트롤러 두 곳에
74
+ 적히던 방식(결정 213)은 탈출구로만 남는다(§6).
75
+ - 카탈로그는 **활성 로케일 청크 1개**로 나가고(`/assets/*` · immutable 캐시), Inertia
76
+ 마운트 **전에** 로드된다 — 미번역 키가 스치는 프레임이 없다.
77
+ - `main.ts` 배선(스캐폴드가 이미 해 둔다):
51
78
 
52
- `locales/en.json` — 영어는 단수/복수 구분(`_one`·`_other`):
53
-
54
- ```json
55
- { "cart": { "items_one": "{{count}} item", "items_other": "{{count}} items" } }
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
- `locales/ko.json` 한국어는 복수 구분 없음(`_other` ):
85
+ ### 3. 서버 문구 = 서버 `t()` (역할 유지)
59
86
 
60
- ```json
61
- { "cart": { "items_other": "상품 {{count}}개" } }
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
- t('cart.items', { count: 1 }) // en "1 item" · ko "상품 1개"
66
- t('cart.items', { count: 5 }) // en → "5 items" · ko → "상품 5개"
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
- - **base 키로 부른다** `t('cart.items', { count })`. `t('cart.items_one')` 처럼 접미사를
70
- 직접 부르면 복수 선택이 안 된다(안티패턴).
71
- - 로케일마다 필요한 접미사만 둔다(영어 `_one`·`_other` / 한국어·일본어 `_other`). 기준
72
- 로케일(§4)에 있는 키가 타입 유니온이 되므로 **기준 로케일 카탈로그에 복수형 키를 둔다**.
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
- ### 2. 요청별 로케일 자동 협상 (결정 159)
109
+ 서버 `t()` backend frontend 를 **모두** 볼 수 있다(SSR·로그·관리 스크립트에서 화면
110
+ 문구가 필요할 수 있다). 반대 방향만 막힌다 — 클라는 frontend 만.
75
111
 
76
- `gaon.config.ts` `i18n` 설정이 있으면 `gaon serve`/`gaon dev` **매 요청**
77
- 로케일을 협상해 `t()` 가 그 언어로 번역한다 — 앱이 손으로 배선할 필요가 없다
78
- (자동 · opt-in 아님). 우선순위 기본값 **세션 > 쿠키 > 헤더**(명시 선택이 브라우저
79
- 기본을 이긴다). 지원 하는 로케일은 `fallbackLng`.
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
- // gaon.config.ts
83
- export default defineConfig({
84
- i18n: {
85
- fallbackLng: 'ko',
86
- supportedLngs: ['ko', 'en', 'ja'], // 생략 locales/ 폴더 하위 언어들
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
- ### 3. 로케일 전환 `this.setLocale()` (결정 159)
125
+ 카탈로그가 없거나 frontend 스코프가 없으면 키가 `string` 으로 열린다(무회귀). 이 파일은
126
+ 자동 생성이니 직접 수정하지 않는다.
94
127
 
95
- 사용자가 언어를 바꾸면 `this.setLocale(lng)` 로 저장한다 `gaon_locale` 쿠키
96
- (세션이 있으면 세션에도)에 심어 **다음 요청부터 유지**된다. 쿠키 기반이라 세션
97
- 없는 (랜딩·API)에서도·앱 간에도 유지된다.
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
- // 컨트롤러 — 언어 전환 라우트 (액션명은 컨텍스트 메서드 this.setLocale 과 겹치지 않게)
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
- ### 4. 메시지 타입 `.gaon/messages.d.ts` (결정 158)
164
+ 전환 뒤에는 로케일 카탈로그 청크가 필요하므로 **전체 리로드가 정답**이다( 한 번의
165
+ 왕복이 깜빡임 0을 보장한다). `<html lang>` 은 최초 문서에서 협상 로케일을 자동 추종한다(결정 214).
110
166
 
111
- `gaon gen`/`gaon dev`/`gaon check` `locales/` 카탈로그를 읽어
112
- `.gaon/messages.d.ts`(키 유니온)를 생성한다(routes·tables 와 같은 `.gaon`
113
- 파이프라인의 3번째 축). 그러면 `t('없는키')` 가 **컴파일 에러**로 잡힌다 —
114
- 카탈로그에 없는 키·오타가 `gaon check` 에서 걸린다(카탈로그가 없으면 키는
115
- `string` 폴백). 이 파일은 자동 생성이니 직접 수정하지 않는다.
167
+ ### 8. 로케일 커버리지 `locale-parity` 경고 (결정 216)
116
168
 
117
- ### 5. Vue 페이지 소비 서버가 번역해 흘려보낸다 (결정 213)
169
+ 유니온은 기준 로케일 하나에서 나오므로, 어떤 키를 `ko` 에만 넣고 `ja` 에 빠뜨리면
170
+ 컴파일은 통과하고 일본어 사용자만 조용히 fallback 을 본다. `gaon doctor` 의 `locale-parity`
171
+ 가 **스코프별로**(backend↔backend · frontend↔frontend) 키 diff 를 계산해 경고한다.
118
172
 
119
- **`t()` 서버 전용이다** — 요청 컨텍스트(ALS)의 로케일을 읽으므로 `gaonjs/vue`
120
- 는 `t()`·`useT()` 를 **내보내지 않는다**(설계 의도). Vue 페이지·컴포넌트는 번역을
121
- 직접 하지 않고, **서버에서 번역한 문자열을 받아 쓴다.** 번역 소스가 서버 하나로
122
- 고정되므로(The One Way) 카탈로그가 클라 번들에 중복 실리지 않고, 키 타입 검사
123
- (§4)도 서버 한 곳에서만 성립한다. 경로는 두 가지다:
173
+ ## 정본 예시
124
174
 
125
- **(a) 페이지 문구 = 컨트롤러 render props.** 그 페이지에서만 쓰는 문구는 컨트롤러가
126
- `t()` 로 번역해 `this.render` props 로 넘긴다 — 페이지는 이미 현지화된 문자열을 받는다.
175
+ ### (a) 화면 문구 추가 전용
127
176
 
128
- ```ts
129
- // apps/web/controllers/posts.ts
130
- import { t } from 'gaonjs/i18n'
131
- async index() {
132
- return this.render('Posts/Index', {
133
- heading: t('posts.heading'), // 서버가 요청 로케일로 번역
134
- empty: t('posts.empty'),
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 { pageProps } from 'gaonjs/vue'
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
- **(b) chrome( 페이지 공용 문구) = `app.config.ts` 의 `sharedProps` (결정 150 동형).**
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
- 읽는 쪽은 타입 브리지를 **선언 병합**으로 확장한다(코어 3종 currentUser·csrf·flash
169
- 고정 · 앱 키만 추가):
195
+ ### (b) 공용 chrome 문구(모든 앱이 쓰는 nav)
170
196
 
171
- ```ts
172
- // shared/gaon-shared.d.ts (또는 아무 .d.ts)
173
- import 'gaonjs/vue'
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 { useShared, Link } from 'gaonjs/vue'
183
- const shared = useShared() // 타입 안전 · 반응형
204
+ import { t, Link } from 'gaonjs/vue'
184
205
  </script>
185
206
  <template>
186
207
  <nav>
187
- <Link href="/">{{ shared.nav.home }}</Link>
188
- <Link href="/posts">{{ shared.nav.posts }}</Link>
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
- - **서버 주도가 The One Way** — 로케일 전환(`this.setLocale`·§3) 후 다음 요청의
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
- export function greetLine(name: string): string {
231
- return t('greeting', { name })
232
- }
222
+ const subject = t('mail.welcome.subject', { name: user.name })
233
223
  ```
234
224
 
235
- ```ts
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
- export const SendDigest = job(async ({ userId, locale }: { userId: bigint; locale: string }) => {
242
- const line = runWithLanguage(locale, () => t('greeting', { name: '가온' }))
243
- // …line 으로 메일/알림 조립
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
- - **`i18n.dir`·`fallbackLng` 문자열 리터럴로 쓴다**(결정 412)변수 참조·env 표현식은
254
- 타입 축(`.gaon/messages.d.ts`)과 doctor 가 정적으로 못 읽어 기본값(`locales` · 정렬
255
- 로케일)으로 떨어진다. 읽으면 `gaon gen`·`dev`·`check` 경고를 낸다(무소음 아님).
256
- 치환 없는 템플릿 리터럴(`` `locales` ``)·`satisfies`/`as` 래핑은 읽는다.
257
- - **절대 경로 `dir` 지원된다**(결정 412) — 런타임과 CLI 축이 같은 규약으로 해석한다.
258
- - **`gaon check` 부팅 조건을 먼저 본다**(결정 413)카탈로그가 비었거나
259
- `fallbackLng`/`supportedLngs` 지목한 파일이 없으면 check 가 실패한다(종전엔 check 는
260
- 통과하고 `gaon serve` 죽었다 · 결정 353).
261
- - **`supportedLngs: []`(빈 배열)"미지정" 과 같다**(결정 414) — 종전엔 협상이 조용히
262
- 꺼져 항상 fallback 나갔다. 실제로 제한하려면 언어를 채우고, 제한이 없으면 키를 뺀다.
263
-
264
-
265
- - **`i18n` 설정이 없으면 `t()` 항상 fallback** 요청별 로케일 협상은
266
- `gaon.config.ts` 에 `i18n` 있어야 배선된다(결정 159). 설정만 하면 자동.
267
- - **키를 손으로 `string` 으로 넓히지 말 것** — `.gaon/messages.d.ts`(결정 158)가
268
- 키를 타입으로 좁혀 준다. `gaon check` 없는 키를 잡는다.
269
- - **요청 코드에선 로케일을 함수 인자로 실어 나르지 것** — `t()` 는 ALS 로 요청
270
- 로케일을 안다. 전환은 `this.setLocale`, 특정 로케일 강제는 `runWithLanguage`. **예외:
271
- 잡·크론(요청 밖)은 요청 로케일이 없으므로** 로케일을 페이로드에 담아 `runWithLanguage`
272
- 감싼다(§정본 예시 · 메일 `deliver(data,{locale})` 와 동형).
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) | 요청별 로케일 자동 협상(wireGaon onRequest) · `this.setLocale` · detect 설정 |
290
- | 결정 213 | Vue 클라 소비 = 서버 주도 render props/sharedProps 만(§5) · `t()`·`useT()` 클라 미노출(서버 ALS 전용) · sharedProps 는 결정 150 동형 |
291
- | 결정 214 (13차 W2) | 최초 문서 `<html lang>` 요청 협상 로케일 자동 추종(§6) · 템플릿 정적값은 기본값 · 비-i18n 무회귀 |
292
- | 결정 216 (13차 W4) | `locale-parity` doctor 경고(§7) 로케일 부분 누락 = fallback 조용 노출 · 기준 로케일 유니온의 사각 |
293
- | 결정 352 | messages.d.ts 기준 로케일 = `fallbackLng`(정적 분석 전달) · `i18n.dir` 타입 축(gen/dev/check)·locale-parity 존중(하드코딩 'locales' 제거) |
294
- | 결정 353 | 부팅 fail-loud i18n 설정 + 빈 카탈로그(전 화면 raw 키 방지) · `fallbackLng`/`supportedLngs` 지목한 로케일 파일 통째 부재(조용한 fallback 언어 대체 방지) 는 부팅 에러 + 수리 안내 |
295
- | 결정 412 | i18n config 정적 분석 범위 확대(템플릿 리터럴·satisfies/as·shorthand) + **미해석 경고**(무소음 제거) · 절대 경로 `dir` 런타임과 같은 규약으로 해석 |
296
- | 결정 413 | 결정 353(부팅 fail-loud) 조건을 `gaon check` 정적으로 미리 검사(check green serve 크래시 사각 제거) |
297
- | 결정 414 | messages 축을 gen/check 보고에 포함 · 생성 이스케이프 · stale `messages.d.ts` 정리 · `supportedLngs: []` 미지정과 일원화 · 협상 언어 순서 결정론화 |
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`** |
@@ -340,16 +340,21 @@ import { Room } from '../models/Room.js'
340
340
 
341
341
  export default on(InstanceClosed, async ({ channel, instance }) => {
342
342
  if (channel !== 'match') return
343
- await Room.where('key', '=', instance).delete()
343
+ await Room.where('key', '=', instance).deleteAll()
344
344
  broadcast('lobby', { type: 'room-closed', key: instance })
345
345
  })
346
346
  ```
347
347
 
348
+ - **체인에 `.delete()` 는 없다** — 단건은 `rec.delete()` · 벌크는 체인
349
+ `deleteAll()` (`agents/data.md` §벌크 계약). `where(...).delete()` 는 컴파일이
350
+ 통과하는 것처럼 보여도 런타임 TypeError 로 리스너가 죽고, 재시도 소진 후
351
+ 이벤트가 폐기돼 **방이 영영 안 지워진다**(rooms 샘플 실측 · 결정 451).
348
352
  - **핸들러는 워커 딱 하나에서만 돈다** — 이벤트 스트림(JetStream)의 리스너
349
353
  durable 컨슈머를 전 `gaon work` 가 공유하므로, 여러 워커를 띄워도 이벤트당
350
354
  한 워커만 처리한다(개발자가 서버를 고르지 않는다). at-least-once 라
351
355
  **핸들러는 멱등하게**(이벤트 배터리 공통 관례 — 위 예시의 create 는 key
352
- unique + upsert 또는 존재 검사로 감싸는 것이 안전하다).
356
+ unique + upsert 또는 존재 검사로 감싸는 것이 안전하고, closed 쪽 `deleteAll`
357
+ 은 0행 삭제 = no-op 이라 그 자체로 멱등이다).
353
358
  - **일시적 방 vs 영속 방** — 일시적 방(익명 대화방 등)은 이 두 이벤트가 곧
354
359
  생성/삭제다. 영속 방(게임 매치·게시물 스레드)은 방 row 를 서비스로 먼저
355
360
  만들고(DB = 진실 원천 · 참가 authorize 도 그 row 로) 이 이벤트는 **점유
@@ -426,6 +431,35 @@ present 대상 수(0 = 부재 = no-op 멱등). **누가 kick 할 수 있는가
426
431
  await kick('room', `user:${targetId}`, { instance: roomId, reason: '규정 위반' })
427
432
  ```
428
433
 
434
+ **버튼 → 컨트롤러 → `api()` 완결 경로(결정 452).** kick 은 서버 전용
435
+ 표면이라 페이지의 강퇴 버튼은 **JSON 액션을 `api()` 로** 부른다 — 폼이
436
+ 아니므로 `useForm` 이 아니고, raw `fetch()` 는 CSRF 미부착으로 403 이다
437
+ (`agents/frontend.md` §2). 위임(delegate)·방 설정 변경류 **방장 커맨드도
438
+ 전부 같은 경로**다:
439
+
440
+ ```ts
441
+ // apps/web/routes.ts
442
+ r.post('/rooms/:key/kick', 'rooms#kick')
443
+
444
+ // apps/web/controllers/rooms.ts — 인가(방장 검사) → kick → this.json
445
+ async kick() {
446
+ this.requireAuth()
447
+ const { key, targetUserId } = this.params({ _row: {} as { key: string; targetUserId: string } })
448
+ const room = await Room.where('key', '=', key).first()
449
+ if (!room) return this.json({ message: '존재하지 않는 방입니다.' }, 404)
450
+ if (String(room.ownerId) !== String((this.auth.user as { id: bigint }).id))
451
+ return this.json({ message: '방장만 강퇴할 수 있습니다.' }, 403)
452
+ await kick('room', `user:${targetUserId}`, { instance: key, reason: '방장에 의해 강퇴되었습니다.' })
453
+ return this.json({ success: true })
454
+ }
455
+ ```
456
+
457
+ ```ts
458
+ // apps/web/pages/Rooms/Show.vue — CSRF 는 api() 가 자동 부착(결정 166·341)
459
+ import { api, isApiError } from 'gaonjs/vue'
460
+ await api('web:rooms#kick', { key: room.key, targetUserId }) // :key 는 자리표시자, 나머지는 JSON 바디
461
+ ```
462
+
429
463
  - 마지막 멤버를 kick 하면 `InstanceClosed` 가 **자동 발화**한다(§2.8 앵커
430
464
  그대로 · 특례 없음).
431
465
  - 전달은 at-most-once(즉시성 도구) — **영구 차단 보증은 ban 패턴(③)이
@@ -0,0 +1,3 @@
1
+ {
2
+ "home": { "tagline": "You are on Gaon." }
3
+ }
@@ -0,0 +1,3 @@
1
+ {
2
+ "home": { "tagline": "가온에 올라탔습니다." }
3
+ }
@@ -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">가온에 올라탔습니다.</h1>
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">상태 확인 중…</p>
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>