@gaonjs/cli 0.63.1 → 0.65.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 (46) hide show
  1. package/dist/commands/check.js +34 -18
  2. package/dist/commands/g.d.ts +1 -0
  3. package/dist/commands/g.js +11 -2
  4. package/dist/commands/gen.js +1 -1
  5. package/dist/dev.d.ts +13 -7
  6. package/dist/dev.js +30 -22
  7. package/dist/doctor/fixers/i18n-layout.d.ts +2 -2
  8. package/dist/doctor/fixers/i18n-layout.js +119 -25
  9. package/dist/doctor/fixers/index.js +7 -2
  10. package/dist/doctor/fixers/types.d.ts +17 -2
  11. package/dist/doctor/i18n-app-scope.d.ts +3 -0
  12. package/dist/doctor/i18n-app-scope.js +94 -65
  13. package/dist/doctor/i18n-layout.d.ts +1 -1
  14. package/dist/doctor/i18n-layout.js +157 -32
  15. package/dist/doctor/i18n-server-scope.d.ts +2 -0
  16. package/dist/doctor/i18n-server-scope.js +124 -0
  17. package/dist/doctor/locale-parity.js +29 -68
  18. package/dist/doctor/types.d.ts +1 -1
  19. package/dist/doctor.d.ts +2 -1
  20. package/dist/doctor.js +36 -10
  21. package/dist/i18n-config.d.ts +9 -9
  22. package/dist/i18n-config.js +8 -15
  23. package/dist/messages-gen.d.ts +10 -10
  24. package/dist/messages-gen.js +42 -54
  25. package/dist/scaffold/app.d.ts +10 -1
  26. package/dist/scaffold/app.js +12 -2
  27. package/dist/templates/project/AGENTS.md.tpl +7 -6
  28. package/dist/templates/project/agents/async.md.tpl +9 -9
  29. package/dist/templates/project/agents/data.md.tpl +37 -12
  30. package/dist/templates/project/agents/frontend.md.tpl +14 -9
  31. package/dist/templates/project/agents/i18n.md.tpl +185 -74
  32. package/dist/templates/project/agents/mail.md.tpl +3 -1
  33. package/dist/templates/project/agents/realtime.md.tpl +54 -31
  34. package/dist/templates/project/agents/seal.md.tpl +22 -4
  35. package/dist/templates/project/agents/security.md.tpl +1 -1
  36. package/dist/templates/project/agents/storage.md.tpl +23 -15
  37. package/dist/templates/project/agents/testing.md.tpl +5 -5
  38. package/dist/templates/project/agents/web.md.tpl +24 -11
  39. package/dist/templates/project/{locales → apps/web/locales}/en/frontend.json.tpl +1 -0
  40. package/dist/templates/project/{locales → apps/web/locales}/ko/frontend.json.tpl +1 -0
  41. package/dist/templates/project/gaon.config.ts.tpl +6 -5
  42. package/package.json +7 -7
  43. package/dist/templates/project/apps/web/locales/en.json.tpl +0 -3
  44. package/dist/templates/project/apps/web/locales/ko.json.tpl +0 -3
  45. /package/dist/templates/project/{locales → domain/locales}/en/backend.json.tpl +0 -0
  46. /package/dist/templates/project/{locales → domain/locales}/ko/backend.json.tpl +0 -0
@@ -6,43 +6,55 @@
6
6
 
7
7
  ## 한 장 요약
8
8
 
9
+ 문구의 **소유자**가 파일 위치를 정한다 — 화면이면 **그 앱**, 화면 밖이면 **domain**.
10
+
9
11
  | 문구가 어디에 보이나 | 카탈로그 파일 | 부르는 곳 |
10
12
  |---|---|---|
11
- | **화면**(.vue 페이지·컴포넌트·레이아웃) | `locales/<로케일>/frontend.json`(앱 공용) · `apps/<앱>/locales/<로케일>.json`( 앱 전용) | **`import { t } from 'gaonjs/vue'`** |
12
- | 메일·잡·크론·검증 문안·로그 **서버** | `locales/<로케일>/backend.json` | `import { t } from 'gaonjs/i18n'` |
13
+ | **화면**(.vue 페이지·컴포넌트·레이아웃) | `apps/<앱>/locales/<로케일>/frontend.json` **(앱마다 필수)** | **`import { t } from 'gaonjs/vue'`** |
14
+ | **컨트롤러**의 서버 문구(flash·리다이렉트 안내) | `apps/<앱>/locales/<로케일>/backend.json` (선택) | `import { t } from 'gaonjs/i18n'` |
15
+ | 메일·잡·크론·검증 문안·로그 등 **도메인 공통** | `domain/locales/<로케일>/backend.json` | `import { t } from 'gaonjs/i18n'` |
13
16
 
14
- 한 문장: **"화면에 보이는 문구는 frontend + 클라 `t()`, 화면 밖으로 나가는 문구는
17
+ 한 문장: **"화면에 보이는 문구는 그 앱의 frontend + 클라 `t()`, 화면 밖으로 나가는 문구는
15
18
  backend + 서버 `t()`."** 텍스트는 카탈로그에 **한 번만** 쓴다 — 컨트롤러가 키를 다시
16
19
  열거하거나 타입을 손으로 선언하지 않는다.
17
20
 
21
+ **루트 공용 `locales/` 는 없다**(결정 459). 여러 앱이 함께 쓰는 UI 문구는 **각 앱에
22
+ 같은 키를 둔다**(§10) — doctor 가 전 앱 존재를 강제하므로 손으로 맞출 일은 없다.
23
+
18
24
  ## 정본 규칙
19
25
 
20
- ### 1. 카탈로그 저작 레이아웃 (결정 454)
26
+ ### 1. 카탈로그 저작 레이아웃 (결정 459)
21
27
 
22
28
  ```
23
- locales/
24
- ko/
25
- backend.json # 서버 전용 — 메일·잡·검증(validation.*)·알림. 클라로 나가지 않는다.
26
- frontend.json # 앱 공용 화면 문구 — nav·버튼·공통 라벨.
27
- en/
28
- backend.json
29
- frontend.json
29
+ domain/
30
+ locales/
31
+ ko/backend.json # 도메인 공통 서버 전용 — 메일·잡·검증(validation.*)·알림.
32
+ en/backend.json # 화면 문구(frontend.json)는 여기 둘 수 없다 부팅 에러.
30
33
  apps/
31
- web/locales/ko.json # web 앱 전용 화면 문구(web 번들에만 실린다)
32
- admin/locales/ko.json # admin 앱 전용 화면 문구(admin 번들에만 실린다)
34
+ web/
35
+ locales/
36
+ ko/frontend.json # web 화면 문구 — web 번들 청크에만 실린다. (필수)
37
+ ko/backend.json # web 컨트롤러 전용 서버 문구. (선택)
38
+ en/frontend.json
39
+ admin/
40
+ locales/ko/frontend.json …
33
41
  ```
34
42
 
35
- - **키 이름은 스코프와 무관하다** — `t('nav.home')` 은 파일이 어디에 있든 그대로다.
43
+ - **키 이름은 위치와 무관하다** — `t('nav.home')` 은 파일이 어디에 있든 그대로다.
36
44
  파일 위치가 곧 "누가 이 문구를 볼 수 있나"를 정한다.
37
- - **backend 는 클라 번들에 물리적으로 없다** — 앱 카탈로그 청크의 입력이 frontend 뿐이라,
38
- 서버 문구가 `/assets/*` 로 새지 않는다. 타입 축도 같은 경계를 지켜 클라에서 backend 키를
39
- 참조하면 **컴파일 에러**다.
40
- - **한 키를 두 스코프에 두면 부팅 에러**(어느 번역이 나갈지가 병합 순서에 좌우되면 안 된다).
45
+ - **backend 는 클라 번들에 물리적으로 없다** — 앱 카탈로그 청크의 입력이
46
+ `frontend.json` **하나**뿐이라 서버 문구가 `/assets/*` 로 길이 없다. 타입 축도 같은
47
+ 경계를 지켜 클라에서 backend 키를 참조하면 **컴파일 에러**다.
48
+ - **한 키를 두 스코프에 두면 부팅 에러** 같은 앱의 frontend↔backend 도, 앱↔도메인도
49
+ 마찬가지다(어느 번역이 나갈지가 병합 순서·요청 컨텍스트에 좌우되면 안 된다).
41
50
  - 카탈로그는 **순수 JSON**(주석·트레일링 콤마 금지). 로케일 등록 = 폴더 존재.
51
+ - **위치는 관례로 고정**이라 `i18n.dir` 설정이 없다(결정 459 — 있으면 doctor 가 error).
42
52
  - `gaon.config.ts` 의 `i18n.fallbackLng` 가 **기준 로케일**이다(키 유니온의 출처 · 결정 352).
43
53
 
44
54
  ```ts
45
55
  // gaon.config.ts
56
+ import { defineConfig } from 'gaonjs/config'
57
+
46
58
  export default defineConfig({
47
59
  i18n: { fallbackLng: 'ko', supportedLngs: ['ko', 'en'] },
48
60
  })
@@ -78,21 +90,43 @@ const props = pageProps<'web:posts#index'>()
78
90
 
79
91
  ```ts
80
92
  // apps/web/main.ts
93
+ import { createGaonApp } from 'gaonjs/vue'
94
+ import { routes } from './.gaon/routes.manifest.js'
81
95
  import { catalogs, fallbackLng } from './.gaon/messages.catalog.js'
96
+
97
+ const pages = import.meta.glob('./pages/**/*.vue')
98
+ const layouts = import.meta.glob('./layouts/*.vue', { eager: true })
99
+
82
100
  void createGaonApp({ pages, layouts, routes, messages: { catalogs, fallbackLng } })
83
101
  ```
84
102
 
85
- ### 3. 서버 문구 = 서버 `t()` (역할 유지)
103
+ ### 3. 서버 문구 = 서버 `t()` (결정 459 · 스코프 2개)
104
+
105
+ 서버 `t()` 의 해석 범위는 **호출이 일어난 컨텍스트**가 정한다:
106
+
107
+ | 어디서 불렀나 | 보이는 카탈로그 | 우선순위 |
108
+ |---|---|---|
109
+ | `domain/**`(mails·jobs·services·listeners·schedule) | domain-backend **만** | — (단일) |
110
+ | `apps/<앱>/**`(컨트롤러 등 서버 코드) | 그 앱 backend ∪ 그 앱 frontend ∪ domain-backend | **앱 먼저**, 없으면 domain |
111
+ | 워커(`gaon work`)·크론·부팅 스크립트 | domain-backend **만**(앱 컨텍스트가 없다) | — (단일) |
86
112
 
87
113
  ```ts
88
114
  // domain/mails/welcome.ts — 수신자 로케일로 렌더된다(결정 160)
115
+ import { mail } from 'gaonjs/mail'
89
116
  import { t } from 'gaonjs/i18n'
90
- export const Welcome = mail({ subject: () => t('mail.welcome.subject', { name: '가온' }) })
117
+
118
+ export const Welcome = mail<{ name: string; email: string }>((u) => ({
119
+ to: u.email,
120
+ subject: t('mail.welcome.subject', { name: u.name }),
121
+ html: `<h1>${t('mail.welcome.body')}</h1>`,
122
+ }))
91
123
  ```
92
124
 
93
125
  ```ts
94
126
  // domain/jobs/sendDigest.ts — 잡은 요청 밖이라 로케일을 페이로드로 싣는다
127
+ import { job } from 'gaonjs/async'
95
128
  import { runWithLanguage, t } from 'gaonjs/i18n'
129
+
96
130
  export const SendDigest = job(async ({ locale }: { locale: string }) => {
97
131
  const line = runWithLanguage(locale, () => t('mail.digest.body'))
98
132
  })
@@ -100,28 +134,47 @@ export const SendDigest = job(async ({ locale }: { locale: string }) => {
100
134
 
101
135
  | 표면 | 시그니처 | 쓰는 곳 |
102
136
  |---|---|---|
103
- | 번역 | `t(key, params?)` | 요청 로케일(ALS) 자동 추종 |
137
+ | 번역 | `t(key, params?)` | 요청 로케일(ALS) + 앱 스코프 자동 추종 |
104
138
  | 현재 언어 | `currentLanguage(): string` | 요청 로케일 |
139
+ | 현재 앱 스코프 | `currentApp(): string \| undefined` | 진단용(요청 밖이면 undefined) |
105
140
  | 지원 언어 | `languages(): readonly string[]` | 설정된 supportedLngs |
106
141
  | 로케일 고정 | `runWithLanguage(lng, fn)` | 잡·크론·스크립트(요청 밖) |
107
142
  | 고정 번역기 | `translator(lng)` | 테스트·비요청 경로 |
108
143
 
109
- 서버 `t()` backend ∪ frontend 를 **모두** 볼 수 있다(SSR·로그·관리 스크립트에서 화면
110
- 문구가 필요할 수 있다). 반대 방향만 막힌다 — 클라는 frontend 만.
144
+ 앱 스코프에서는 서버 `t()` 그 앱의 backend ∪ frontend 를 **모두** 볼 수 있다(SSR·로그·
145
+ 관리 스크립트에서 화면 문구가 필요할 수 있다 · 결정 459 O5). 반대 방향만 막힌다 —
146
+ 클라는 frontend 만.
147
+
148
+ **⚠️ 도메인 코드는 앱 키에 기대면 안 된다.** 앱 요청이 부른 도메인 서비스는 같은 요청
149
+ 컨텍스트라 앱 네임스페이스가 **우연히 보인다**. 그런데 같은 코드가 워커·크론에서 불리면
150
+ 앱 스코프가 없어 그 키가 빈다 — "개발 중엔 되는데 잡에서만 키 문자열이 뜬다" 는 가장 나쁜
151
+ 형태다. `gaon doctor` 의 `i18n-server-scope` 가 정적으로 막는다(§9).
111
152
 
112
- ### 4. 키·파라미터 타입 — `.gaon/messages.d.ts` 두 갈래 (결정 454)
153
+ ### 4. 키·파라미터 타입 — `.gaon/messages.d.ts` 두 갈래 (결정 454 · 459)
113
154
 
114
155
  `gaon gen`/`dev`/`check` 가 카탈로그를 읽어 **두 유니온**을 생성한다:
115
- 서버(`gaonjs/i18n`) = backend ∪ frontend / 클라(`gaonjs/vue`) = frontend 만.
156
+ 서버(`gaonjs/i18n`) = domain-backend ∪ backend 전 앱 frontend /
157
+ 클라(`gaonjs/vue`) = 전 앱 frontend 만.
116
158
 
117
- ```ts
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 — 컴파일 에러
159
+ ```vue expect-error
160
+ <!-- apps/web/pages/Posts/Bad.vue — 아래 세 줄은 **전부 컴파일 에러**다 -->
161
+ <script setup lang="ts">
162
+ import { t } from 'gaonjs/vue'
163
+ const a = t('mail.welcome.subject', { name: '가온' }) // ❌ 클라에서 backend
164
+ const b = t('greeting') // ❌ {{name}} 보간 파라미터 누락
165
+ const c = t('greeting', { nam: '가온' }) // ❌ 파라미터 이름 오타
166
+ </script>
123
167
  ```
124
168
 
169
+ **유니온은 앱별로 못 나눈다** — 두 앱이 `GaonMessages.keys` 를 서로 다른 유니온으로
170
+ augment 하면 **TS2717**(Subsequent property declarations must have the same type)이고,
171
+ `gaon check` 는 단일 tsc 프로그램이라 앱마다 다른 프로그램을 돌릴 수 없다(결정 454 실측 ·
172
+ 결정 459 §5.1 로 유효). **파일은 앱별인데 타입은 프로젝트 전역**인 이 비대칭이 함정이다 —
173
+ 그래서 앱 경계는 ① 번들 물리 경계 ② doctor 가 강제한다(§9).
174
+
175
+ 산출은 **루트 `.gaon/messages.d.ts` 하나**다(앱별로 쪼개면 "앱별 타입" 이라는 오해를
176
+ 만든다 · 결정 459 O7). 앱별 `.gaon/` 에는 **값 모듈**(`messages.catalog.ts`)만 나간다.
177
+
125
178
  카탈로그가 없거나 frontend 스코프가 없으면 키가 `string` 으로 열린다(무회귀). 이 파일은
126
179
  자동 생성이니 직접 수정하지 않는다.
127
180
 
@@ -132,11 +185,11 @@ t('cart.items', { count: 'many' }) // ❌ count 는 number — 컴파일 에
132
185
  **같은 문자열**을 낸다(85 케이스 동치 게이트로 고정).
133
186
 
134
187
  ```json
135
- // locales/en/frontend.json — 영어는 단수/복수 구분
188
+ // apps/web/locales/en/frontend.json — 영어는 단수/복수 구분
136
189
  { "cart": { "items_one": "{{count}} item", "items_other": "{{count}} items" } }
137
190
  ```
138
191
  ```json
139
- // locales/ko/frontend.json — 한국어는 _other 만
192
+ // apps/web/locales/ko/frontend.json — 한국어는 _other 만
140
193
  { "cart": { "items_other": "상품 {{count}}개" } }
141
194
  ```
142
195
 
@@ -152,7 +205,7 @@ t('cart.items', { count: 'many' }) // ❌ count 는 number — 컴파일 에
152
205
  `gaon.config.ts` 에 `i18n` 이 있으면 매 요청 **세션 > 쿠키 > Accept-Language** 로 협상된다.
153
206
  전환은 서버가 권위를 갖는다 — 클라에 `setLocale` 은 **없다**.
154
207
 
155
- ```ts
208
+ ```ts controller-action
156
209
  // 컨트롤러 — 언어 전환 라우트
157
210
  async switchLocale() {
158
211
  const { lng } = this.params({ _row: {} as { lng: string } })
@@ -177,7 +230,7 @@ async switchLocale() {
177
230
  런타임까지 오는 것은 **동적 키**(`t(변수)`)와 진짜 카탈로그 누락뿐이라, 그 둘만 우아하게
178
231
  degrade 한다. 즉 **빌드 = 차단 / 런타임 = 키 렌더 + 경고**의 2단이다.
179
232
 
180
- ```ts
233
+ ```ts fragment
181
234
  const key = `posts.${kind}` // 동적 키 — 컴파일 게이트가 못 본다
182
235
  t(key as never) // 없으면 'posts.abc' 가 화면에 뜨고 경고 1줄
183
236
  ```
@@ -186,30 +239,52 @@ t(key as never) // 없으면 'posts.abc' 가 화면에
186
239
  녹는다(이 계약이 막는 것이 정확히 그 침묵이다).
187
240
  - 런타임까지 **실패로 만들고 싶다면**(엄격 모드) 지금은 지원하지 않는다 — 필요하면 요청하라.
188
241
 
189
- ### 9. 로케일 커버리지 `locale-parity` 경고 (결정 216)
242
+ ### 9. 정적 검사 3종 (`gaon doctor`)
243
+
244
+ | 검사 | 무엇을 잡나 | 등급 |
245
+ |---|---|---|
246
+ | `locale-parity` | 같은 스코프 안에서 **로케일 간** 키 부분 누락(§9.1) | 경고 |
247
+ | `i18n-app-scope` | 클라 `t()` 가 **그 앱에 없는** 키 참조 · shared 키가 일부 앱에만 존재 | 오류 |
248
+ | `i18n-server-scope` | 서버 `t()` 가 **소유자 경계**를 넘는 키 참조(도메인 → 앱 키 등) | 오류 |
249
+
250
+ #### 9.1 로케일 커버리지 — `locale-parity` (결정 216 · 456 · 459)
190
251
 
191
252
  키 유니온은 기준 로케일 하나에서 나오므로, 어떤 키를 `ko` 에만 넣고 `ja` 에 빠뜨리면
192
- 컴파일은 통과하고 일본어 사용자만 조용히 fallback 을 본다. `gaon doctor` 의 `locale-parity`
193
- **스코프별로**(backend↔backend · frontend↔frontend) diff 를 계산해 경고한다.
253
+ 컴파일은 통과하고 일본어 사용자만 조용히 fallback(서버) 또는 키 문자열(클라)을 본다.
254
+ 비교 단위는 **3종**이다: ① domain-backend 앱별 frontend 앱별 backend.
255
+ **스코프·소유자를 가로지르는 비교는 하지 않는다** — 앱 전용 키가 전량 "도메인에 없음"
256
+ 으로 뜨는 오탐이 되기 때문이다. 복수형 접미사는 base 로 정규화해 비교한다(en `_one`/`_other`
257
+ 와 ko `_other` 의 정당한 차이를 누락으로 세지 않는다).
194
258
 
195
- **앱 스코프 카탈로그도 대상이다**(결정 456) `apps/<앱>/locales/<로케일>.json`
196
- 안에서만 비교한다(스코프 이름 `app:<앱>`). 앱 전용 키는 루트에 없는 것이 정상이라
197
- 루트와 교차 비교하지 않는다. 카탈로그의 누락은 fallback 아니라 **키 문자열이 그대로
198
- 화면에 뜬다**(결정 455) 그래서 정적으로 먼저 잡는다.
259
+ ### 10. 여러 앱이 함께 쓰는 문구 · `shared/` 컴포넌트 (결정 459 O1)
260
+
261
+ 공용 카탈로그가 없으므로, 여러 앱이 쓰는 UI 원자 문구(`ui.close`·`ui.prev`·`ui.save` …)는
262
+ **각 앱의 `frontend.json` 같은 키를 둔다**. 복제가 비용처럼 보이지만 대안이 더 나쁘다 —
263
+ 문구를 props 로 끌어올리면(`closeLabel`·`prevLabel`) 원자 컴포넌트마다 prop 이 생기고
264
+ 사용처마다 문구를 다시 적어야 해서, 결정 454 가 없앤 "선언 지점 3곳" 통증이 되살아난다.
265
+
266
+ **`shared/` 컴포넌트가 쓰는 키는 모든 앱에 있어야 한다** — shared 는 어느 앱 번들에도
267
+ 실릴 수 있어서, 키가 한 앱에만 있으면 **다른 앱 화면에서만** 키 문자열이 뜨는 조용한
268
+ 실패가 된다. `i18n-app-scope` 가 누락된 앱 이름을 짚어 준다:
269
+
270
+ ```
271
+ shared 컴포넌트가 쓰는 'ui.close' 가 일부 앱 카탈로그에 없습니다(누락: admin)
272
+ → apps/admin/locales/<로케일>/frontend.json 에 같은 키를 추가하고 gaon gen
273
+ ```
199
274
 
200
275
  ## 정본 예시
201
276
 
202
- ### (a) 새 화면 문구 추가 — 앱 전용
277
+ ### (a) 새 화면 문구 추가 — 앱 전용
203
278
 
204
- ```jsonc
205
- // apps/web/locales/ko.json
279
+ ```json
280
+ // apps/web/locales/ko/frontend.json
206
281
  { "posts": { "heading": "게시글 목록", "empty": "아직 글이 없습니다." } }
207
282
  ```
208
- ```jsonc
209
- // apps/web/locales/en.json ← 같은 키를 나란히(locale-parity)
283
+ ```json
284
+ // apps/web/locales/en/frontend.json ← 같은 키를 나란히(locale-parity)
210
285
  { "posts": { "heading": "Posts", "empty": "No posts yet." } }
211
286
  ```
212
- ```vue
287
+ ```vue fragment
213
288
  <script setup lang="ts">
214
289
  import { t } from 'gaonjs/vue'
215
290
  </script>
@@ -219,10 +294,14 @@ import { t } from 'gaonjs/vue'
219
294
  gaon gen # .gaon/messages.d.ts(두 갈래) + apps/web/.gaon/messages.catalog.ts 재생성
220
295
  ```
221
296
 
222
- ### (b) 공용 chrome 문구(모든 앱이 쓰는 nav)
297
+ ### (b) 여러 앱이 쓰는 chrome 문구 앱에 복제
223
298
 
224
- ```jsonc
225
- // locales/ko/frontend.json
299
+ ```json
300
+ // apps/web/locales/ko/frontend.json
301
+ { "nav": { "home": "홈", "posts": "게시판" } }
302
+ ```
303
+ ```json
304
+ // apps/admin/locales/ko/frontend.json ← 같은 키를 admin 에도(전 앱 존재를 doctor 가 강제)
226
305
  { "nav": { "home": "홈", "posts": "게시판" } }
227
306
  ```
228
307
  ```vue
@@ -238,33 +317,64 @@ import { t, Link } from 'gaonjs/vue'
238
317
  </template>
239
318
  ```
240
319
 
241
- ### (c) 서버 전용 문구(메일)
320
+ ### (c) 도메인 공통 서버 문구(메일·검증)
242
321
 
243
- ```jsonc
244
- // locales/ko/backend.json
245
- { "mail": { "welcome": { "subject": "{{name}}님, 환영합니다" } } }
322
+ ```json
323
+ // domain/locales/ko/backend.json
324
+ {
325
+ "mail": { "welcome": { "subject": "{{name}}님, 환영합니다" } },
326
+ "validation": { "required": "필수 입력 항목입니다." }
327
+ }
246
328
  ```
247
- ```ts
329
+ ```ts fragment
248
330
  import { t } from 'gaonjs/i18n'
249
331
  const subject = t('mail.welcome.subject', { name: user.name })
250
332
  ```
251
333
 
252
- ### (d) 레거시 단일 파일에서 이관
334
+ ### (d) 컨트롤러 전용 서버 문구
335
+
336
+ ```json
337
+ // apps/web/locales/ko/backend.json
338
+ { "flash": { "saved": "저장했습니다." } }
339
+ ```
340
+ ```ts controller-action
341
+ async update() {
342
+ this.flash('notice', t('flash.saved')) // web 스코프에서만 보이는 키
343
+ return this.redirect('/posts')
344
+ }
345
+ ```
346
+
347
+ ### (e) 옛 배치에서 이관 (결정 459 · breaking)
253
348
 
254
349
  ```bash
255
- gaon doctor # i18n-layout 경고 클라 t() 없는 상태
256
- gaon doctor --fix # locales/ko.json → locales/ko/backend.json (내용·키 무변경)
257
- # 화면에 보이는 문구만 locales/ko/frontend.json 으로 옮긴다(사람의 판단 — 노출 방향)
258
- gaon gen
350
+ gaon doctor # i18n-layout — 루트 locales/ · 평면 파일 · i18n.dir 을 전부 지목
351
+ gaon doctor --fix --yes
352
+ # locales/<로케일>/backend.json → domain/locales/<로케일>/backend.json (이동)
353
+ # locales/<로케일>.json → domain/locales/<로케일>/backend.json (이동 · 미분류는 서버 전용)
354
+ # apps/<앱>/locales/<로케일>.json → apps/<앱>/locales/<로케일>/frontend.json (이동)
355
+ # locales/<로케일>/frontend.json → 전 앱 frontend.json 에 복제 후 원본 제거
356
+ # 그 뒤 각 앱이 안 쓰는 키는 사람이 지운다(전 앱 복제는 되돌리기 쉬운 방향의 자동화)
357
+ gaon gen && gaon check
259
358
  ```
260
359
 
261
360
  ## 알려진 함정
262
361
 
362
+ - **루트 `locales/` 는 더 이상 읽히지 않는다**(결정 459) — 그대로 두면 전 화면·전 메일이
363
+ 키 문자열로 degrade 한다. `gaon doctor --fix` 로 이관한다.
364
+ - **도메인 코드에서 앱 키를 쓰지 말 것** — 요청 안에서는 우연히 해석되고 워커·크론에서만
365
+ 빈다. `i18n-server-scope` 가 잡는다(§3 ⚠️).
366
+ - **`shared/` 가 쓰는 키는 전 앱에 둘 것** — 한 앱에만 있으면 다른 앱에서만 키가 뜬다(§10).
263
367
  - **클라에서 backend 키를 쓰지 말 것** — 컴파일 에러로 막힌다. 그 문구가 화면에도 필요하면
264
368
  `frontend.json` 으로 **옮긴다**(양쪽에 복사하면 부팅 에러).
265
369
  - **다른 앱 전용 키를 쓰지 말 것** — 타입은 프로젝트 전체 frontend 합집합이라 통과하지만
266
370
  (앱별 유니온은 TS2717 로 구조적으로 불가), 그 앱 번들에는 없어서 화면에 키 문자열이
267
- 그대로 뜬다. `gaon doctor` 의 `i18n-app-scope` 가 잡는다.
371
+ 그대로 뜬다. `i18n-app-scope` 가 잡는다.
372
+ - **파일은 앱별인데 타입은 전역이다** — 카탈로그를 앱 폴더로 나눠도 키 유니온은 프로젝트
373
+ 전체다(§4). "앱을 나눴으니 타입도 갈렸겠지" 가 이 배치의 가장 큰 오해다.
374
+ - **`domain/locales/<로케일>/frontend.json` 은 만들 수 없다** — 도메인은 화면을 소유하지
375
+ 않는다. 부팅에서 실패하고 doctor 가 error 로 짚는다.
376
+ - **`i18n.dir` 은 제거됐다**(결정 459) — 위치가 관례로 고정이라 설정이 없다. 남아 있으면
377
+ doctor error(효과 없는 설정을 조용히 두면 "설정한 폴더를 읽겠지" 라는 오해가 된다).
268
378
  - **`.vue` 에서 서버 `t`(`gaonjs/i18n`)를 import 하지 말 것** — ALS 기반 서버 전용이다.
269
379
  클라는 `gaonjs/vue` 의 `t`.
270
380
  - **`v-html` + `t()` + 사용자 입력 조합 금지** — 템플릿 텍스트 보간(`{{ t(...) }}`)은 Vue 가
@@ -276,28 +386,29 @@ gaon gen
276
386
  로케일인지 짚어 준다.
277
387
  - **잡·크론은 로케일을 페이로드에 싣는다** — 요청 밖이라 ALS 가 없다(`runWithLanguage`).
278
388
  - **메일은 요청 로케일이 아니라 수신자 로케일** — `deliver(data, { locale })`(결정 160).
279
- - **`i18n.dir`·`fallbackLng` 는 문자열 리터럴로**(결정 412) — 변수·env 표현식은 타입 축과
280
- doctor 가 정적으로 못 읽는다.
389
+ - **`fallbackLng` 는 문자열 리터럴로**(결정 412) — 변수·env 표현식은 타입 축과 doctor 가
390
+ 정적으로 못 읽는다.
281
391
  - **`supportedLngs: []`(빈 배열)은 "미지정" 과 같다**(결정 414).
282
- - **`gaon check` 가 부팅 조건을 먼저 본다**(결정 413) — 카탈로그가 비었거나 `fallbackLng`
283
- 가 지목한 카탈로그가 없으면 check 가 실패한다.
284
- - **검증 실패 문안은 backend** — 예약 namespace `validation.<code>` 를 `backend.json` 에
285
- 두면 서버가 요청 로케일로 번역한다(결정 183).
392
+ - **`gaon check` 가 부팅 조건을 먼저 본다**(결정 413) — 카탈로그가 하나도 없거나
393
+ `fallbackLng` 가 지목한 카탈로그가 없으면 check 가 실패한다.
394
+ - **검증 실패 문안은 도메인 backend** — 예약 namespace `validation.<code>` 를
395
+ `domain/locales/<로케일>/backend.json` 에 두면 서버가 요청 로케일로 번역한다(결정 183).
286
396
 
287
397
  ## 관련 결정 번호
288
398
 
289
399
  | 결정 | 요지 |
290
400
  |---|---|
291
- | §7 (v0.15) | i18n 배터리 · locales/ 카탈로그 · t() |
401
+ | §7 (v0.15) | i18n 배터리 · 카탈로그 · t() |
292
402
  | 결정 158 (13차 W2) | `.gaon/messages.d.ts` 키 타입 브리지 — 없는 키 컴파일 에러 |
293
403
  | 결정 159 (13차 W1) | 요청별 로케일 자동 협상 · `this.setLocale` · detect 설정 |
294
404
  | 결정 181 | 복수형 base 키 노출(`t('cart.items', { count })`) |
295
405
  | 결정 213 | (구) Vue 소비 = 서버 주도 render props/sharedProps 만 — **결정 454 로 부분 번복**(§6 탈출구로 존속) |
296
406
  | 결정 214 (13차 W2) | 최초 문서 셸 `<html lang>` 이 협상 로케일 자동 추종 |
297
- | 결정 216 (13차 W4) | `locale-parity` 경고 — 결정 454 로 **스코프별 비교** |
298
- | 결정 352 | 기준 로케일 = `fallbackLng` · `i18n.dir` 존중 |
407
+ | 결정 216 (13차 W4) | `locale-parity` 경고 — 결정 454·459 로 **스코프별 비교** |
408
+ | 결정 352 | 기준 로케일 = `fallbackLng` |
299
409
  | 결정 353 · 413 | 빈 카탈로그·지목 로케일 부재 = 부팅 fail-loud + check 사전 검사 |
300
410
  | 결정 412 · 414 | i18n config 정적 분석 범위·경고 · 생성 키 이스케이프 · `supportedLngs: []` |
301
- | **결정 455** | 런타임 없는키 = **키 렌더 + 경고**(서버·클라 동일) · 요청/화면을 죽이지 않는다 · 폴백 해석은 유지 · 정적 키는 컴파일에서 차단(2단) |
302
- | **결정 454** | **클라 `t()`(`gaonjs/vue`) · backend/frontend 폴더 레이아웃 · 두 갈래 타입 · 앱별 카탈로그 청크(immutable·seal 면제) · 공유 prop `locale`(연성 예약) · doctor `i18n-layout`·`i18n-app-scope`** |
303
- | **결정 456** | **`locale-parity` 앱 스코프 확장**(§9)`apps/<앱>/locales/` 를 앱 내부에서 비교(스코프 `app:<앱>`). 결정 454 가 정본 배치로 지정한 경로가 커버리지 밖이라 앱 카탈로그의 로케일 누락이 정적으로 안 잡혔다(auction 실측 · 이 문서 정본 예시는 이미 `locale-parity` 를 약속하고 있었다). 루트 교차 비교는 기각(앱 전용 키가 전량 오탐) · `i18n-app-scope` 재사용도 기각(그 검사의 키 집합은 전 로케일 합집합이라 축이 다름) |
411
+ | 결정 455 | 런타임 없는키 = **키 렌더 + 경고**(서버·클라 동일) · 요청/화면을 죽이지 않는다 · 정적 키는 컴파일에서 차단(2단) |
412
+ | 결정 454 | 클라 `t()`(`gaonjs/vue`) · backend/frontend 스코프 분리 · 두 갈래 타입 · 앱별 카탈로그 청크(immutable·seal 면제) · 공유 prop `locale` |
413
+ | 결정 456 | `locale-parity` 앱 스코프 확장 — 앱 내부에서 비교(루트 교차 비교는 오탐이라 기각) |
414
+ | **결정 459** | **소유자 2개로 재편**(결정 454 부분 반전) — 루트 공용 `locales/` **폐지** · 화면 문구 = `apps/<앱>/locales/<로케일>/frontend.json`(필수) · 앱 서버 문구 = 같은 폴더 `backend.json`(선택) · 도메인 공통 = `domain/locales/<로케일>/backend.json` · 서버 `t()` 는 `app:<앱>` → `domain` 네임스페이스 폴백(앱 ALS) · 소유자 간 키 충돌 = 부팅 fail-loud · `i18n.dir` 제거 · `domain/locales/*/frontend.json` = error · shared 키는 전 앱 복제 + doctor 강제 · 신규 검사 `i18n-server-scope` · 타입 유니온은 **여전히 프로젝트 전역**(TS2717) |
@@ -39,7 +39,7 @@ export const WelcomeMail = mail<{ name: string; email: string }>((u) => ({
39
39
  로케일은 앱이 `recipient.locale` 로 넘긴다(프레임웍이 모델 구조를 알지 않는다).
40
40
  `locale` 생략 시 현재 요청 로케일(없으면 fallback).
41
41
 
42
- ```ts
42
+ ```ts fragment
43
43
  await WelcomeMail.deliver({ name: user.name, email: user.email }, { locale: user.locale })
44
44
  // to 옵션으로 수신자를 데이터 밖에서 덮어쓸 수도 있다:
45
45
  await WelcomeMail.deliver(data, { locale: 'ja', to: 'ops@example.com' })
@@ -71,6 +71,8 @@ await WelcomeMail.deliver(data, { locale: 'ja', to: 'ops@example.com' })
71
71
 
72
72
  ```ts
73
73
  // gaon.config.ts
74
+ import { defineConfig } from 'gaonjs/config'
75
+
74
76
  export default defineConfig({
75
77
  mail: process.env.SMTP_HOST
76
78
  ? {
@@ -132,6 +132,7 @@ export default channel({
132
132
  // apps/web/controllers/posts.ts — service·job·listener 어디서든 동일하게 호출
133
133
  import { controller } from 'gaonjs/web'
134
134
  import { broadcast } from 'gaonjs/async'
135
+ import { Post } from '../../../domain/models/Post.js'
135
136
 
136
137
  export default controller({
137
138
  async create() {
@@ -244,16 +245,16 @@ export default channel({
244
245
  })
245
246
  ```
246
247
 
247
- ```ts
248
+ ```ts fragment
248
249
  // 클라이언트 — 인스턴스 키를 지정해 구독한다(경로: /gaon/ws/match/42)
249
250
  const game = useChannel('match', { instance: matchId }) // 숫자는 자동 문자열화
250
- const comments = useChannel('thread', { instance: postId })
251
+ const comments = useChannel('thread', { instance: String(postId) }) // bigint PK 는 String() (결정 37)
251
252
  ```
252
253
 
253
- ```ts
254
+ ```ts fragment
254
255
  // 서버 발화·조회 — { instance } 옵션으로 특정 인스턴스에만 스코프한다
255
256
  broadcast('match', { round: 2 }, { instance: '42' }) // match:42 전원
256
- await sendToUsers('match', userId, { note: '…' }, { instance: '42' }) // match:42 의 그 유저만
257
+ await sendToUsers('match', String(userId), { note: '…' }, { instance: '42' }) // match:42 의 그 유저만
257
258
  const players = await presenceList('match', { instance: '42' }) // match:42 로스터
258
259
  const rooms = await presenceOf(`user:${userId}`) // 역방향: 이 유저가 지금 있는 곳
259
260
  // rooms = [{ channel: 'match', instance: '42' }, { channel: 'lobby' }, …]
@@ -406,10 +407,16 @@ export default channel({})
406
407
  동적 정원이 된다:
407
408
 
408
409
  ```ts
409
- // 채팅방 — 고정 인원 제한
410
+ // domain/channels/chat.ts채팅방: 고정 인원 제한
411
+ import { channel } from 'gaonjs/async'
412
+
410
413
  export default channel({ instance: true, maxMembers: 100 })
414
+ ```
415
+
416
+ ```ts
417
+ // domain/channels/match.ts — 게임 매치: 방별 정원(메타·DB 조회)
418
+ import { channel, instanceMeta } from 'gaonjs/async'
411
419
 
412
- // 게임 매치 — 방별 정원(메타·DB 조회)
413
420
  export default channel({
414
421
  instance: true,
415
422
  async maxMembers(ctx) {
@@ -426,9 +433,9 @@ export default channel({
426
433
  present 대상 수(0 = 부재 = no-op 멱등). **누가 kick 할 수 있는가는 호출
427
434
  지점(컨트롤러·서비스)의 앱 인가가 정한다.**
428
435
 
429
- ```ts
436
+ ```ts fragment
430
437
  // 채팅 모더레이터 강퇴 / 게임 안티치트 축출 — 같은 한 줄
431
- await kick('room', `user:${targetId}`, { instance: roomId, reason: '규정 위반' })
438
+ await kick('room', `user:${targetId}`, { instance: String(roomId), reason: '규정 위반' })
432
439
  ```
433
440
 
434
441
  **버튼 → 컨트롤러 → `api()` 완결 경로(결정 452).** kick 은 서버 전용
@@ -437,10 +444,10 @@ await kick('room', `user:${targetId}`, { instance: roomId, reason: '규정 위
437
444
  (`agents/frontend.md` §2). 위임(delegate)·방 설정 변경류 **방장 커맨드도
438
445
  전부 같은 경로**다:
439
446
 
440
- ```ts
441
- // apps/web/routes.ts
442
- r.post('/rooms/:key/kick', 'rooms#kick')
447
+ 라우트는 한 줄이다 — `apps/web/routes.ts` 의 `routes((r) => { … })` 안에
448
+ `r.post('/rooms/:key/kick', 'rooms#kick')` 을 더한다.
443
449
 
450
+ ```ts controller-action
444
451
  // apps/web/controllers/rooms.ts — 인가(방장 검사) → kick → this.json
445
452
  async kick() {
446
453
  this.requireAuth()
@@ -454,8 +461,8 @@ async kick() {
454
461
  }
455
462
  ```
456
463
 
457
- ```ts
458
- // apps/web/pages/Rooms/Show.vue — CSRF 는 api() 가 자동 부착(결정 166·341)
464
+ ```ts fragment
465
+ // apps/web/pages/Rooms/Show.vue 의 <script setup> — CSRF 는 api() 가 자동 부착(결정 166·341)
459
466
  import { api, isApiError } from 'gaonjs/vue'
460
467
  await api('web:rooms#kick', { key: room.key, targetUserId }) // :key 는 자리표시자, 나머지는 JSON 바디
461
468
  ```
@@ -470,23 +477,34 @@ await api('web:rooms#kick', { key: room.key, targetUserId }) // :key 는 자
470
477
  도메인마다 달라 프레임웍이 정하지 않는다 — DB(정본) + authorize(보증) +
471
478
  kick(즉시성):
472
479
 
473
- ```ts
474
- // ① 도메인 기록(정본 = DB) ② 즉시 축출 ③ 재입장 차단(4401)
480
+ ```ts fragment
481
+ // ① 도메인 기록(정본 = DB) ② 즉시 축출
475
482
  await RoomBan.create({ roomId, userId, reason })
476
- await kick('room', `user:${userId}`, { instance: roomId, reason })
477
- // channels/room.ts — authorize 가 보증 층이다(kick 유실·재접속을 막는 백스톱)
478
- async authorize(ctx) {
479
- const u = ctx.user as { id: bigint } | null
480
- if (!u) return false
481
- let roomId: bigint
482
- try {
483
- roomId = BigInt(ctx.instance)
484
- } catch {
485
- return false
486
- }
487
- if (String(roomId) !== ctx.instance) return false // 정규형만(§2.7 유령 인스턴스 · 결정 447)
488
- return !(await RoomBan.where('roomId', '=', roomId).where('userId', '=', u.id).exists())
489
- }
483
+ await kick('room', `user:${userId}`, { instance: String(roomId), reason })
484
+ ```
485
+
486
+ 재입장 차단(4401)은 `authorize` 보증 층이다 kick 유실·재접속을 막는 백스톱:
487
+
488
+ ```ts
489
+ // domain/channels/room.ts — 공유 채널 정의는 domain/channels/ 가 정본(결정 456)
490
+ import { channel } from 'gaonjs/async'
491
+ import { RoomBan } from '../models/Room.js'
492
+
493
+ export default channel({
494
+ instance: true,
495
+ async authorize(ctx) {
496
+ const u = ctx.user as { id: bigint } | null
497
+ if (!u) return false
498
+ let roomId: bigint
499
+ try {
500
+ roomId = BigInt(ctx.instance)
501
+ } catch {
502
+ return false
503
+ }
504
+ if (String(roomId) !== ctx.instance) return false // 정규형만(§2.7 유령 인스턴스 · 결정 447)
505
+ return !(await RoomBan.where('roomId', '=', roomId).where('userId', '=', u.id).exists())
506
+ },
507
+ })
490
508
  ```
491
509
 
492
510
  익명 멤버(`conn:<uuid>`)는 신원 영속이 없어 ban 이 불가능하다(kick 만
@@ -499,13 +517,18 @@ DB + authorize** 고, 메타의 존재 이유는 닫힘 시 자동 소멸이다(
499
517
  소멸 앵커가 없어 메타 미지원 · 4400):
500
518
 
501
519
  ```ts
520
+ // domain/channels/match.ts — 열림 시 1회 기록되는 인스턴스 메타
521
+ import { channel } from 'gaonjs/async'
522
+
502
523
  export default channel({
503
524
  instance: true,
504
525
  instanceMeta(ctx) { // 열림(첫 점유) 시 1회 기록 — 허브가 open 앵커에서만
505
526
  return { mode: ctx.query.mode ?? 'ranked', capacity: 4 }
506
527
  },
507
528
  })
529
+ ```
508
530
 
531
+ ```ts fragment
509
532
  await setInstanceMeta('match', { mode: 'casual' }, { instance: '42' }) // 인가된 액션 갱신(LWW)
510
533
  const meta = await instanceMeta('match', { instance: '42' }) // 읽기(미설정·닫힘 = null)
511
534
  const rooms = await instancesOf('match', { meta: true }) // 로비: [{ instance, meta }]
@@ -523,7 +546,7 @@ const rooms = await instancesOf('match', { meta: true }) // 로
523
546
  로스터로 한 곳에서 결정" 하는 로직의 정본 앵커다. `InstanceOpened/Closed` 와
524
547
  같은 전달(워커 하나만 처리 · at-least-once — 핸들러 멱등):
525
548
 
526
- ```ts
549
+ ```ts fragment
527
550
  export default on(MemberLeft, async ({ channel, instance, member, roster }) => {
528
551
  // roster = 제거 반영 후 잔존 멤버 [{ id, joinSeq? }] · joinSeq 오름차순.
529
552
  // 빈 배열 = 마지막 이탈(InstanceClosed 도 발행되지만 컨슈머 축이 달라
@@ -575,7 +598,7 @@ export default on(MemberLeft, async ({ channel, instance, member, roster }) => {
575
598
  `ctx.presence()` 는 **전 서버의** 현재 접속자를 돌려준다. 목록의 권위는
576
599
  허브(KV) 이므로, 웹서버가 여러 대여도 같은 목록을 본다.
577
600
 
578
- ```ts
601
+ ```ts fragment
579
602
  const members = await ctx.presence()
580
603
  // [{ id: 'user:1', info: { name: '가온' } }, …]
581
604
  ```