@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,4 +1,4 @@
1
- export type DoctorRule = 'response-mixing' | 'n-plus-one' | 'dependency-direction' | 'connections' | 'migration-diff' | 'shared-purity' | 'no-auto-import' | 'schema-filename' | 'agents-doc-index' | 'agents-docs-stale' | 'column-casing' | 'model-filename' | 'page-filename' | 'auth-wiring' | 'ui-kit-wiring' | 'route-registration' | 'static-collision' | 'method-override' | 'csrf-wiring' | 'internal-anchor' | 'pageprops-destructure' | 'async-offload' | 'page-layout-breakpoint' | 'link-button-nesting' | 'seal-security' | 'schema-relations' | 'no-import-meta-env' | 'locale-parity' | 'render-return' | 'channel-collision' | 'channel-instance-authorize' | 'dotenv-node-env';
1
+ export type DoctorRule = 'response-mixing' | 'n-plus-one' | 'dependency-direction' | 'connections' | 'migration-diff' | 'shared-purity' | 'no-auto-import' | 'schema-filename' | 'agents-doc-index' | 'agents-docs-stale' | 'column-casing' | 'model-filename' | 'page-filename' | 'auth-wiring' | 'ui-kit-wiring' | 'route-registration' | 'static-collision' | 'method-override' | 'csrf-wiring' | 'internal-anchor' | 'pageprops-destructure' | 'async-offload' | 'page-layout-breakpoint' | 'link-button-nesting' | 'seal-security' | 'schema-relations' | 'no-import-meta-env' | 'locale-parity' | 'render-return' | 'channel-collision' | 'channel-instance-authorize' | 'dotenv-node-env' | 'page-fetch' | 'i18n-layout' | 'i18n-app-scope';
2
2
  export type DoctorLevel = 'passed' | 'warning' | 'error';
3
3
  export interface DoctorCheck {
4
4
  readonly rule: DoctorRule;
package/dist/doctor.d.ts CHANGED
@@ -25,6 +25,8 @@ export { isApiApp, importsMailSdk, importsImageLib, callsExternalHttp, pageActio
25
25
  export { usesLayoutBreakpoint, checkPageLayoutBreakpoint, } from './doctor/page-layout-breakpoint.js';
26
26
  export { usesLinkButtonNesting, checkLinkButtonNesting, } from './doctor/link-button-nesting.js';
27
27
  export { checkLocaleParity } from './doctor/locale-parity.js';
28
+ export { checkI18nLayout } from './doctor/i18n-layout.js';
29
+ export { checkI18nAppScope } from './doctor/i18n-app-scope.js';
28
30
  export { checkChannelCollision, reexportSpecifier } from './doctor/channel-collision.js';
29
31
  export { checkChannelInstanceAuthorize, declaresInstance, hasAuthorize, } from './doctor/channel-instance-authorize.js';
30
32
  export { renderHuman, renderJson } from './doctor/reporter.js';
@@ -33,7 +35,7 @@ export { checkTypeScriptApi, detectProject, fatalNoProject, fatalTsApiMissing, }
33
35
  * 실행할 검사 이름. 지정 없음(undefined) = 31개 모두.
34
36
  */
35
37
  /**
36
- * doctor 정적 검사 32종의 정본 목록(§2.2). `--check=` 필터의 인정 집합도
38
+ * doctor 정적 검사 35종의 정본 목록(§2.2). `--check=` 필터의 인정 집합도
37
39
  * 이 배열을 단일 출처로 삼는다(parseDoctorChecks) — 새 규칙 추가 시 여기만
38
40
  * 늘리면 실행·필터·타입이 함께 정합된다(손유지 중복 리스트 표류 방지).
39
41
  */
package/dist/doctor.js CHANGED
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * @gaonjs/cli · `gaon doctor` — 정적 검사 (M9-E · CLI DX 완성 · E-5 확장)
3
3
  *
4
- * 32 검사를 조립한다:
4
+ * 33 검사를 조립한다:
5
5
  * 1) response-mixing (errata E-3 §C · 라이브)
6
6
  * 2) n-plus-one (errata E-4 (e))
7
7
  * 3) dependency-direction (CLAUDE.md §5 · 4 규칙)
@@ -30,10 +30,13 @@
30
30
  * 25) schema-relations (§4.5 · 결정 134 · 커넥션 가로지르는 belongsTo·관계 · 대상 부재 error)
31
31
  * 26) no-import-meta-env (결정 198 · F-9 ② · `.vue` 의 import.meta.env = TS1470 → env 접근자 안내 error)
32
32
  * 27) locale-parity (결정 216 · 13차 W4 · 로케일 간 키 부분 누락 = fallback 조용 노출 경고)
33
+ * 34) i18n-layout (결정 454 · 레거시 단일 파일 = 클라 t() 불가 안내 · --fix 로 폴더 이관)
34
+ * 35) i18n-app-scope (결정 454 · 앱 카탈로그에 없는 클라 t() 키 = 런타임 키 노출)
33
35
  * 28) render-return (결정 340 · this.render/redirect/json 호출만 하고 return 누락 = 무신호 204 경고)
34
36
  * 29) channel-collision (§7 · 앱간 동명 채널 = 전역 subject·프레즌스 병합 error)
35
37
  * 30) channel-instance-authorize (결정 440 · authorize 없는 인스턴스 채널 = 임의 인스턴스 공개 입장 경고)
36
38
  * 31) dotenv-node-env (결정 430 · 공유 .env 의 NODE_ENV = 모드 누출 경고)
39
+ * 32) page-fetch (결정 453 · 세션 앱 .vue 의 내부 경로 raw fetch = CSRF 미부착 403 경고)
37
40
  *
38
41
  * 각 검사는 순수 함수(cwd → RuleReport). 상위 runDoctorCommand 가 조립해
39
42
  * DoctorResult 로 낸다. --json 은 자동화(CI)를 위해 반드시 파싱 가능한
@@ -75,9 +78,12 @@ import { checkPageLayoutBreakpoint } from './doctor/page-layout-breakpoint.js';
75
78
  import { checkLinkButtonNesting } from './doctor/link-button-nesting.js';
76
79
  import { checkNoImportMetaEnv } from './doctor/no-import-meta-env.js';
77
80
  import { checkLocaleParity } from './doctor/locale-parity.js';
81
+ import { checkI18nLayout } from './doctor/i18n-layout.js';
82
+ import { checkI18nAppScope } from './doctor/i18n-app-scope.js';
78
83
  import { checkChannelCollision } from './doctor/channel-collision.js';
79
84
  import { checkChannelInstanceAuthorize } from './doctor/channel-instance-authorize.js';
80
85
  import { checkDotenvNodeEnv } from './doctor/dotenv-node-env.js';
86
+ import { checkPageFetch } from './doctor/page-fetch.js';
81
87
  import { renderHuman, renderJson } from './doctor/reporter.js';
82
88
  import { checkTypeScriptApi, detectProject, fatalNoProject, fatalTsApiMissing, } from './doctor/setup.js';
83
89
  import { makeResult, } from './doctor/types.js';
@@ -106,6 +112,8 @@ export { isApiApp, importsMailSdk, importsImageLib, callsExternalHttp, pageActio
106
112
  export { usesLayoutBreakpoint, checkPageLayoutBreakpoint, } from './doctor/page-layout-breakpoint.js';
107
113
  export { usesLinkButtonNesting, checkLinkButtonNesting, } from './doctor/link-button-nesting.js';
108
114
  export { checkLocaleParity } from './doctor/locale-parity.js';
115
+ export { checkI18nLayout } from './doctor/i18n-layout.js';
116
+ export { checkI18nAppScope } from './doctor/i18n-app-scope.js';
109
117
  export { checkChannelCollision, reexportSpecifier } from './doctor/channel-collision.js';
110
118
  export { checkChannelInstanceAuthorize, declaresInstance, hasAuthorize, } from './doctor/channel-instance-authorize.js';
111
119
  export { renderHuman, renderJson } from './doctor/reporter.js';
@@ -114,7 +122,7 @@ export { checkTypeScriptApi, detectProject, fatalNoProject, fatalTsApiMissing, }
114
122
  * 실행할 검사 이름. 지정 없음(undefined) = 31개 모두.
115
123
  */
116
124
  /**
117
- * doctor 정적 검사 32종의 정본 목록(§2.2). `--check=` 필터의 인정 집합도
125
+ * doctor 정적 검사 35종의 정본 목록(§2.2). `--check=` 필터의 인정 집합도
118
126
  * 이 배열을 단일 출처로 삼는다(parseDoctorChecks) — 새 규칙 추가 시 여기만
119
127
  * 늘리면 실행·필터·타입이 함께 정합된다(손유지 중복 리스트 표류 방지).
120
128
  */
@@ -151,6 +159,9 @@ export const ALL_RULES = [
151
159
  'channel-collision',
152
160
  'channel-instance-authorize',
153
161
  'dotenv-node-env',
162
+ 'page-fetch',
163
+ 'i18n-layout',
164
+ 'i18n-app-scope',
154
165
  ];
155
166
  /**
156
167
  * `gaon help` 이 doctor 한 줄에 요약할 규칙별 문구(§2.2 상세는 AGENTS). 타입이
@@ -187,10 +198,13 @@ export const RULE_SUMMARIES = {
187
198
  'schema-relations': '§4.5 관계',
188
199
  'no-import-meta-env': 'import.meta.env',
189
200
  'locale-parity': '로케일 커버리지',
201
+ 'i18n-layout': 'i18n 카탈로그 레이아웃(단일 파일 → backend/frontend 분리 안내 · 결정 454)',
202
+ 'i18n-app-scope': '앱 경계를 넘는 클라 번역 키(그 앱 카탈로그에 없는 t() 키 · 결정 454)',
190
203
  'render-return': 'render return 누락',
191
204
  'channel-collision': '앱간 동명 채널',
192
205
  'channel-instance-authorize': '인스턴스 채널 authorize',
193
206
  'dotenv-node-env': '.env NODE_ENV',
207
+ 'page-fetch': '세션 앱 raw fetch',
194
208
  };
195
209
  const CHECKERS = {
196
210
  'response-mixing': checkResponseMixing,
@@ -221,10 +235,13 @@ const CHECKERS = {
221
235
  'schema-relations': checkSchemaRelations,
222
236
  'no-import-meta-env': checkNoImportMetaEnv,
223
237
  'locale-parity': checkLocaleParity,
238
+ 'i18n-layout': checkI18nLayout,
239
+ 'i18n-app-scope': checkI18nAppScope,
224
240
  'render-return': checkRenderReturn,
225
241
  'channel-collision': checkChannelCollision,
226
242
  'channel-instance-authorize': checkChannelInstanceAuthorize,
227
243
  'dotenv-node-env': checkDotenvNodeEnv,
244
+ 'page-fetch': checkPageFetch,
228
245
  };
229
246
  /**
230
247
  * 규칙을 순서대로 실행해 RuleReport[] 를 낸다. 규칙 하나가 크래시해도 나머지는
package/dist/index.js CHANGED
@@ -156,7 +156,7 @@ function renderHelp(version = VERSION) {
156
156
  * 지정 없음(undefined) = 5 검사 모두 실행. 알 수 없는 이름은 무시(안전).
157
157
  */
158
158
  export function parseDoctorChecks(argv) {
159
- // 인정 집합은 doctor.ts 의 ALL_RULES(정본 32종)를 단일 출처로 쓴다 — 과거
159
+ // 인정 집합은 doctor.ts 의 ALL_RULES(정본 35종)를 단일 출처로 쓴다 — 과거
160
160
  // 손유지 9종 리스트가 뒤처져 --check=seal-security 같은 16종이 조용히 무시되고
161
161
  // 전체 검사로 되돌아가던 표류를 근본 차단한다(결정 168).
162
162
  const isKnown = (s) => ALL_RULES.includes(s);
@@ -177,7 +177,7 @@ export function parseDoctorChecks(argv) {
177
177
  }
178
178
  // 결정 411: 모르는 이름은 여전히 무시하되(안전 방향 — 전체 검사로 넓어짐) **조용히**
179
179
  // 넘기지 않는다. 오타 하나가 "그 검사만 돌렸다" 는 착각으로 이어지고, 전부 오타면
180
- // 32종 전체가 돌아가 선택 실행 의도가 통째로 사라진다.
180
+ // 35종 전체가 돌아가 선택 실행 의도가 통째로 사라진다.
181
181
  if (unknown.length > 0) {
182
182
  process.stderr.write(` ! 알 수 없는 검사 이름 무시: ${unknown.join(", ")}\n` +
183
183
  ` → 지원 이름은 gaon doctor --json 의 rule 값 또는 gaon help 참고` +
@@ -497,7 +497,7 @@ export function runCli(argv, opts = {}) {
497
497
  });
498
498
  return;
499
499
  }
500
- // `gaon doctor` — 정적 검사(M9-E · 32 검사 · ALL_RULES 단일 출처). --check=<이름>[,<이름>...] 로
500
+ // `gaon doctor` — 정적 검사(M9-E · 33 검사 · ALL_RULES 단일 출처). --check=<이름>[,<이름>...] 로
501
501
  // 선택 실행, --json 은 자동화 파싱용.
502
502
  // exit code (M9-E-Fix): fatal → 2(사용자 오류) / errors > 0 → 1 / 그 외 → 0.
503
503
  if (argv[0] === "doctor") {
@@ -1,6 +1,30 @@
1
+ import { type LocaleResources } from '@gaonjs/i18n';
2
+ /** 메시지 축이 카탈로그 모듈을 낼 앱(결정 454). dev 레이아웃이 만들어 넘긴다. */
3
+ export interface MessagesApp {
4
+ readonly name: string;
5
+ /** apps/<앱>/locales (앱 전용 frontend 카탈로그 · 없어도 된다). */
6
+ readonly localesDir: string;
7
+ /** apps/<앱>/.gaon/messages.catalog.ts */
8
+ readonly catalogOut: string;
9
+ }
1
10
  /**
2
- * locales/ 카탈로그에서 .gaon/messages.d.ts 생성한다. 카탈로그가 없거나 비어
3
- * 있으면 생성하지 않는다(GaonMessages 를 비운 채로 둬 t() 키가 string 폴백 — i18n 을
4
- * 안 쓰는 프로젝트가 never 로 깨지지 않게). 생성 여부를 돌려준다.
11
+ * locales/ 카탈로그에서 `.gaon/messages.d.ts`(두 갈래)와 앱별 카탈로그 모듈을 생성한다.
12
+ * 카탈로그가 없거나 비면 생성하지 않는다(GaonMessages 를 비운 채로 둬 키가 string 폴백 —
13
+ * i18n 을 안 쓰는 프로젝트가 never 로 깨지지 않게). 생성 여부를 돌려준다.
5
14
  */
6
- export declare function generateMessagesDts(localesDir: string, out: string, baseLng?: string): boolean;
15
+ export declare function generateMessagesDts(localesDir: string, out: string, baseLng?: string, apps?: readonly MessagesApp[]): boolean;
16
+ interface CatalogInput {
17
+ readonly localesDir: string;
18
+ readonly app: MessagesApp;
19
+ readonly appLocales: LocaleResources;
20
+ readonly frontendLangs: readonly string[];
21
+ readonly fallbackLng: string;
22
+ }
23
+ /**
24
+ * 결정 454: 앱별 카탈로그 값 모듈을 쓴다. 로케일별 로더가 **공용 frontend + 앱 카탈로그**를
25
+ * 동적 import 해 vite 가 로케일별 해시 청크(.js)로 쪼갠다 — `/assets/*` immutable 캐시 대상이며
26
+ * `text/javascript` 라 seal 봉인 대상(application/json)에서 자연 면제된다.
27
+ * 생성 여부를 돌려준다(프론트 카탈로그가 없으면 false + 스테일 제거).
28
+ */
29
+ export declare function generateMessagesCatalog(input: CatalogInput): boolean;
30
+ export {};
@@ -1,35 +1,155 @@
1
- // @gaonjs/cli · .gaon/messages.d.ts 생성기 (결정 158 · 13차 W2)
1
+ // @gaonjs/cli · .gaon 메시지 축 생성기 (결정 158 · 13차 W2 · 결정 454)
2
2
  //
3
- // tables.d.ts·routes.d.ts 와 같은 .gaon 파이프라인의 번째 축(메시지). locales/
4
- // 카탈로그의 키를 유니온 타입으로 물성화해, t('key') 의 존재하지 않는 키를 컴파일
5
- // 타임에 잡는다(현재는 GaonMessages 비어 있어 키가 string 으로 열림). 생성 파일은
6
- // 타입만 담는다(규칙 3). @gaonjs/i18n 공개 API(loadLocales·renderMessagesDts)만 쓴다.
3
+ // tables.d.ts·routes.d.ts 와 같은 .gaon 파이프라인의 메시지 축. 결정 454 부터 두 산출을 낸다:
4
+ //
5
+ // 1) `.gaon/messages.d.ts` **두 갈래 타입 브리지**
6
+ // · `gaonjs/i18n`(서버) = backend frontend ∪ 앱 frontend
7
+ // · `gaonjs/vue`(클라) = frontend 만(+ 키별 보간 파라미터) → 서버 전용 문구를
8
+ // 클라에서 참조하면 컴파일 에러.
9
+ // 2) `apps/<앱>/.gaon/messages.catalog.ts` — **앱별 값 모듈**(routes.manifest.ts 선례).
10
+ // 그 앱의 frontend 카탈로그만 동적 import 로 묶어 vite 가 로케일별 청크로 쪼갠다.
11
+ // backend.json 은 입력에 아예 없다(노출 차단은 번들 경계가 물리적으로 강제).
12
+ //
13
+ // @gaonjs/i18n 의 공개 API(loadScopedLocales·loadAppLocales·renderMessagesDtsSplit 등)만 쓴다.
7
14
  import { existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
8
- import { dirname } from 'node:path';
9
- import { loadLocales, renderMessagesDts } from '@gaonjs/i18n';
15
+ import { dirname, join, relative, sep } from 'node:path';
16
+ import { extractParams, flattenKeys, loadAppLocales, loadScopedLocales, renderMessagesDtsSplit, withPluralBaseKeys, } from '@gaonjs/i18n';
17
+ /** 번역 트리 두 개를 깊은 병합한다(리프 충돌은 로더가 이미 fail-loud). */
18
+ function mergeTrees(a, b) {
19
+ const out = { ...a };
20
+ for (const [k, v] of Object.entries(b)) {
21
+ const cur = out[k];
22
+ const isTree = (x) => x !== null && typeof x === 'object' && !Array.isArray(x);
23
+ out[k] = isTree(cur) && isTree(v) ? mergeTrees(cur, v) : v;
24
+ }
25
+ return out;
26
+ }
27
+ function translationOf(res, lng) {
28
+ if (lng === undefined)
29
+ return {};
30
+ return res[lng]?.translation ?? {};
31
+ }
32
+ /** 기준 로케일 = config i18n.fallbackLng(결정 352). 없으면 정렬 첫 로케일. */
33
+ function pickBaseLng(langs, baseLng) {
34
+ if (baseLng && langs.includes(baseLng))
35
+ return baseLng;
36
+ return [...langs].sort()[0];
37
+ }
10
38
  /**
11
- * locales/ 카탈로그에서 .gaon/messages.d.ts 생성한다. 카탈로그가 없거나 비어
12
- * 있으면 생성하지 않는다(GaonMessages 를 비운 채로 둬 t() 키가 string 폴백 — i18n 을
13
- * 안 쓰는 프로젝트가 never 로 깨지지 않게). 생성 여부를 돌려준다.
39
+ * locales/ 카탈로그에서 `.gaon/messages.d.ts`(두 갈래)와 앱별 카탈로그 모듈을 생성한다.
40
+ * 카탈로그가 없거나 비면 생성하지 않는다(GaonMessages 를 비운 채로 둬 키가 string 폴백 —
41
+ * i18n 을 안 쓰는 프로젝트가 never 로 깨지지 않게). 생성 여부를 돌려준다.
14
42
  */
15
- export function generateMessagesDts(localesDir, out, baseLng) {
43
+ export function generateMessagesDts(localesDir, out, baseLng, apps = []) {
16
44
  // 결정 414: 카탈로그가 사라졌으면 **옛 생성물을 지운다** — 남겨두면 없어진 키의
17
45
  // 유니온이 그대로 살아 t('없어진키') 가 계속 컴파일된다(제거를 못 잡는 사각).
18
46
  if (!existsSync(localesDir)) {
19
47
  if (existsSync(out))
20
48
  rmSync(out, { force: true });
49
+ for (const app of apps)
50
+ removeStale(app.catalogOut);
21
51
  return false;
22
52
  }
23
- const resources = loadLocales(localesDir);
24
- if (Object.keys(resources).length === 0) {
53
+ const scoped = loadScopedLocales(localesDir);
54
+ const appResources = new Map();
55
+ for (const app of apps)
56
+ appResources.set(app.name, loadAppLocales(app.localesDir));
57
+ const langs = new Set([...Object.keys(scoped.backend), ...Object.keys(scoped.frontend)]);
58
+ for (const res of appResources.values())
59
+ for (const lng of Object.keys(res))
60
+ langs.add(lng);
61
+ if (langs.size === 0) {
25
62
  if (existsSync(out))
26
63
  rmSync(out, { force: true });
64
+ for (const app of apps)
65
+ removeStale(app.catalogOut);
27
66
  return false;
28
67
  }
68
+ const base = pickBaseLng([...langs], baseLng);
69
+ // 클라 키 = 공용 frontend ∪ 모든 앱 frontend(결정 454 · 앱×앱 축은 타입으로 못 나눈다 —
70
+ // TS2717. 앱 경계는 청크 분리와 doctor `i18n-app-scope` 가 강제한다).
71
+ let clientTree = translationOf(scoped.frontend, base);
72
+ for (const res of appResources.values())
73
+ clientTree = mergeTrees(clientTree, translationOf(res, base));
74
+ const serverTree = mergeTrees(translationOf(scoped.backend, base), clientTree);
75
+ const clientKeys = withPluralBaseKeys(flattenKeys(clientTree));
76
+ const serverKeys = withPluralBaseKeys(flattenKeys(serverTree));
29
77
  mkdirSync(dirname(out), { recursive: true });
30
- // 결정 352: 기준 로케일 = config i18n.fallbackLng(호출자가 정적 분석으로 전달).
31
- // 이전엔 항상 알파벳순 첫 로케일이라 컴파일 보증이 fallback 체인과 다른 로케일에
32
- // 정박했다(en/ko + fallbackLng:'ko' 프로젝트에서 기준이 en).
33
- writeFileSync(out, renderMessagesDts(resources, baseLng), 'utf8');
78
+ writeFileSync(out, renderMessagesDtsSplit({
79
+ baseLng: base,
80
+ serverKeys,
81
+ clientKeys,
82
+ clientParams: extractParams(clientTree),
83
+ }), 'utf8');
84
+ // 앱별 카탈로그 값 모듈 — frontend 소스가 하나도 없으면 만들지 않는다(레거시 레이아웃).
85
+ const fallback = base ?? 'en';
86
+ for (const app of apps) {
87
+ generateMessagesCatalog({
88
+ localesDir,
89
+ app,
90
+ appLocales: appResources.get(app.name) ?? {},
91
+ frontendLangs: Object.keys(scoped.frontend),
92
+ fallbackLng: fallback,
93
+ });
94
+ }
95
+ return true;
96
+ }
97
+ function removeStale(path) {
98
+ if (existsSync(path))
99
+ rmSync(path, { force: true });
100
+ }
101
+ /** import 지정자로 쓸 상대 경로(항상 ./ 또는 ../ 로 시작 · POSIX 구분자). */
102
+ function importPath(fromDir, target) {
103
+ const rel = relative(fromDir, target).split(sep).join('/');
104
+ return rel.startsWith('.') ? rel : `./${rel}`;
105
+ }
106
+ /**
107
+ * 결정 454: 앱별 카탈로그 값 모듈을 쓴다. 로케일별 로더가 **공용 frontend + 앱 카탈로그**를
108
+ * 동적 import 해 vite 가 로케일별 해시 청크(.js)로 쪼갠다 — `/assets/*` immutable 캐시 대상이며
109
+ * `text/javascript` 라 seal 봉인 대상(application/json)에서 자연 면제된다.
110
+ * 생성 여부를 돌려준다(프론트 카탈로그가 없으면 false + 스테일 제거).
111
+ */
112
+ export function generateMessagesCatalog(input) {
113
+ const outDir = dirname(input.app.catalogOut);
114
+ const langs = [...new Set([...input.frontendLangs, ...Object.keys(input.appLocales)])].sort();
115
+ if (langs.length === 0) {
116
+ removeStale(input.app.catalogOut);
117
+ return false;
118
+ }
119
+ const lines = [];
120
+ lines.push('// AUTO-GENERATED by @gaonjs/cli — 편집 금지.');
121
+ lines.push('// `gaon check` / `gaon dev` / `gaon gen` 가 이 파일을 재생성한다(결정 454).');
122
+ lines.push('// 클라이언트 번역 카탈로그: 로케일 → 로더(공용 frontend + 이 앱의 카탈로그).');
123
+ lines.push('// 서버 전용 문구(locales/<로케일>/backend.json)는 여기에 실리지 않는다 — 클라로 나가지 않는다.');
124
+ lines.push("import type { CatalogLoaders } from 'gaonjs/vue'");
125
+ lines.push('');
126
+ lines.push('export const catalogs: CatalogLoaders = {');
127
+ for (const lng of langs) {
128
+ const sources = [];
129
+ const shared = join(input.localesDir, lng, 'frontend.json');
130
+ if (existsSync(shared))
131
+ sources.push(importPath(outDir, shared));
132
+ const appFile = join(input.app.localesDir, `${lng}.json`);
133
+ if (input.appLocales[lng] !== undefined && existsSync(appFile))
134
+ sources.push(importPath(outDir, appFile));
135
+ if (sources.length === 0)
136
+ continue;
137
+ const body = sources.length === 1
138
+ ? `import('${sources[0]}')`
139
+ : `Promise.all([${sources.map((s) => `import('${s}')`).join(', ')}])`;
140
+ lines.push(` '${lng}': () => ${body},`);
141
+ }
142
+ lines.push('}');
143
+ lines.push('');
144
+ lines.push(`export const fallbackLng = '${input.fallbackLng}'`);
145
+ lines.push('');
146
+ const text = lines.join('\n');
147
+ // 로더가 하나도 없으면(전부 backend 뿐) 모듈을 만들지 않는다 — 빈 카탈로그 배선은 오해를 만든다.
148
+ if (!text.includes('() =>')) {
149
+ removeStale(input.app.catalogOut);
150
+ return false;
151
+ }
152
+ mkdirSync(outDir, { recursive: true });
153
+ writeFileSync(input.app.catalogOut, text, 'utf8');
34
154
  return true;
35
155
  }
@@ -113,7 +113,7 @@ Gaon 의 제1 설계 목표는 **"AI 가 개발을 가장 잘하는 프레임웍
113
113
  컬럼명 · 스키마 파일 ↔ 테이블 ↔ `tables.d.ts` 키 변환 규칙)은
114
114
  `agents/data.md` "DB 네이밍" 표가 정본이다 — 먼저 읽는다.
115
115
 
116
- ### 2.2 `gaon doctor` 검사 32
116
+ ### 2.2 `gaon doctor` 검사 35
117
117
 
118
118
  1. `response-mixing` — 한 액션 안 render/JSON/redirect 혼용 (E-3)
119
119
  2. `n-plus-one` — include 미사용 · loop 안 관계 호출 (E-4)
@@ -147,6 +147,9 @@ Gaon 의 제1 설계 목표는 **"AI 가 개발을 가장 잘하는 프레임웍
147
147
  30. `channel-instance-authorize` — `instance: true` 채널(파라미터화 채널 · 결정 440)에 `authorize` 가 없음 = **경고**. 인스턴스 채널은 임의 문자열 키로 무한 실행 인스턴스(`/gaon/ws/<이름>/<인스턴스>`)가 열리므로, authorize 가 없으면 누구나 아무 인스턴스에나 입장한다. 매치·스레드처럼 참가자가 정해진 채널이면 `authorize(ctx)` 에서 `ctx.instance` 로 입장을 판정하라 — 공개 관전형(누구나 입장)이 의도면 무시해도 된다(재수출 정의는 대상 모듈을 따라가 판정 · `agents/realtime.md` §2.7)
148
148
  31. `agents-docs-stale` — 이 문서(`AGENTS.md`)·`agents/*.md` 사본이 **설치된 gaonjs 템플릿(정본)과 다름** = **경고**(byte 비교). gaonjs 업그레이드 후 관례 문서가 옛 채로 남으면 AI 가 낡은 관례·없는 표면으로 코드를 짠다 — `gaon g agents-docs` 로 재동기하라(미리보기 `--check` · 멱등 · 사본에 직접 적은 내용은 프로젝트 소유 문서(CLAUDE.md)로 옮긴 뒤 — 이 문서들은 프레임웍 정본 사본이다) (결정 449)
149
149
  32. `dotenv-node-env` — 공유 `.env`(·`.env.local`·`.env.example`)에 `NODE_ENV` 가 설정됨 = **경고**. **모드는 명령이 정한다** — `gaon dev` = development · `gaon serve` = production(결정 430). 이 파일들은 개발·운영이 함께 읽으므로 값을 박으면 모드가 양쪽으로 샌다: `production` 이면 `gaon dev` 가 쿠키 Secure·dev 플레이스홀더 secret 거부로 죽고, `development` 면 운영 `gaon serve` 에서 프로덕션 안전장치(플레이스홀더 secret 거부·쿠키 Secure·락 in-memory 폴백 차단)가 통째로 꺼진다(**부팅은 green, 보안만 꺼짐**). `.env` 에서 그 줄을 지우고, 모드별 값이 필요하면 `.env.development`/`.env.production` 오버레이에, 일회성이면 명령 앞에 붙인다(`NODE_ENV=production gaon serve`) — 모드별 오버레이 파일은 검사 대상이 아니다 (결정 430)
150
+ 33. `page-fetch` — 세션 앱 `.vue` 가 앱 내부 경로를 raw `fetch('/...')` 로 호출 = **경고**. CSRF 토큰 자동 부착(결정 166·341·342)은 `api()`·`useForm`·`router` 를 탈 때만 작동해, raw fetch 의 상태 변경 요청(POST/PUT/PATCH/DELETE)은 403 으로 죽는다(rooms 샘플 실사용에서 강퇴/위임이 이 경로로 죽었다). JSON 액션(강퇴·위임·좋아요 등 커맨드 포함)은 `api()`, 폼은 `useForm`, 부득이한 커스텀 전송은 `readCsrfToken()` 탈출구 — 첫 인자가 `/` 로 시작하는 문자열/템플릿 리터럴만 검출(외부 URL·변수 인자 오탐 제외), JWT/API 앱은 REST + fetch 가 정본이라 제외 (결정 453 · `agents/frontend.md` §2)
151
+ 34. `i18n-layout` — 카탈로그가 **레거시 단일 파일**(`locales/<로케일>.json`) = **경고**. 결정 454 의 정본 저작 레이아웃은 언어별 폴더 + 스코프 분리(`locales/<로케일>/{backend,frontend}.json`)다. 단일 파일은 계속 동작하지만 미분류라 **전량 서버 전용**으로 취급돼 클라 `t()`(`gaonjs/vue`)가 그 문구를 쓸 수 없다(클라 키 축 미생성). `gaon doctor --fix` 가 `locales/<로케일>/backend.json` 으로 옮겨 주고(내용·키 무변경), 그 뒤 화면 문구만 `frontend.json` 으로 나누면 된다 — 자동 분류는 하지 않는다(frontend 로 잘못 보내면 클라 번들·CDN 캐시에 노출돼 되돌리기 어렵다) (결정 454 · `agents/i18n.md`)
152
+ 35. `i18n-app-scope` — 앱 코드가 **그 앱 카탈로그에 없는** 클라 `t()` 키를 참조 = **오류**. 클라 키 유니온은 프로젝트 전체 frontend 합집합이라(두 앱이 같은 `GaonMessages.keys` 를 다른 유니온으로 augment 하면 TS2717 이고 `gaon check` 는 단일 tsc 프로그램이다 — 앱별 유니온이 구조적으로 불가) 타입만으로는 앱 경계를 못 지킨다. 앱 번들에는 그 앱 카탈로그만 실리므로 다른 앱 전용 키는 런타임에 번역 대신 **키 문자열이 그대로** 보인다. 공용으로 쓸 문구는 `locales/<로케일>/frontend.json`, 그 앱 전용이면 `apps/<앱>/locales/<로케일>.json` 에 두고 `gaon gen`. 서버 `t()`(`gaonjs/i18n`) 호출은 대상이 아니다 (결정 454)
150
153
 
151
154
  ## 3. 로직 배치 One Way 판단표
152
155
 
@@ -171,7 +174,7 @@ Gaon 의 제1 설계 목표는 **"AI 가 개발을 가장 잘하는 프레임웍
171
174
  |---|---|---|
172
175
  | 지금 페이지의 데이터를 다시 받기 (필터 변경·새로고침·무한 스크롤) | **Inertia partial reload** — 같은 액션 재호출, 필요한 props만 | §6.1 |
173
176
  | 서버가 먼저 밀어주는 데이터 (알림·채팅·접속자) | **채널/프레즌스** (`agents/realtime.md`) — 서버 개시는 `broadcast(name,data)`, 클라 메시지 응답은 `ctx.broadcast` | §7 |
174
- | 페이지와 무관한 데이터 요청 (자동완성·옵션 조회 앱 내부용) | **JSON 액션 + `api()` 클라이언트** (`agents/web.md`·`agents/frontend.md`) | E-3 |
177
+ | 페이지 리로드가 필요 없는 앱 내부 요청 — 조회(자동완성·옵션)든 **상태 변경 커맨드(강퇴·위임·좋아요·토글)**든 | **JSON 액션 + `api()` 클라이언트** (`agents/web.md`·`agents/frontend.md` · CSRF 자동 부착) | E-3 · 결정 452 |
175
178
  | 외부에 공개하는 API (모바일 앱·서드파티) | **별도 API 앱 + JWT 옵션** | §3, §7 |
176
179
 
177
180
  ### 3.4 잡 발행 위치 (결정 32)
@@ -207,7 +210,7 @@ Gaon 의 제1 설계 목표는 **"AI 가 개발을 가장 잘하는 프레임웍
207
210
  ```bash
208
211
  gaon check # .gaon 재생성 → typecheck + vue-tsc + build + doctor (기본 포함 · --no-doctor 로 뺌 · 결정 157)
209
212
  gaon test # vitest — DB·NATS 는 실 인프라 (agents/testing.md)
210
- gaon doctor # 정적 검사 32종 (§2.2)
213
+ gaon doctor # 정적 검사 35종 (§2.2)
211
214
  ```
212
215
 
213
216
  ### 4.1 CLI 명령 (전 명령 `--json` 지원)
@@ -86,7 +86,7 @@ Gaon 프레임웍 문서: https://gaonjs.dev
86
86
 
87
87
  ```bash
88
88
  gaon check # .gaon 재생성 → 타입검사+build+doctor (CI 한 번에 · --no-doctor 로 doctor 뺌)
89
- gaon doctor # 정적 검사 32종 (상세 AGENTS §2.2)
89
+ gaon doctor # 정적 검사 33종 (상세 AGENTS §2.2)
90
90
  npm test # Vitest · DB 테스트는 실 Docker 필수 (§9)
91
91
  ```
92
92
 
@@ -50,9 +50,12 @@ const props = pageProps<'web:posts#index'>()
50
50
  // <template> 에서 shared.currentUser?.name · shared.csrf · shared.flash.success
51
51
  // 앱이 app.config sharedProps 로 등록한 키(locale·theme 등)도 같은 자리에서 읽힌다(결정 150).
52
52
  ```
53
- **i18n 문구도 경로로 온다** — Vue 는 `t()` 직접 부르지 않는다(서버 ALS 전용).
54
- 페이지 문구는 컨트롤러 render props, nav·레이아웃 같은 chrome 문구는 `sharedProps`
55
- 서버가 번역해 흘려보낸다(`agents/i18n.md` §5 · 결정 213).
53
+ **i18n 문구는 여기가 아니라 클라 `t()` 온다**(결정 454) `import { t } from 'gaonjs/vue'`
54
+ `.vue` 에서 직접 부른다. 카탈로그는 `locales/<로케일>/frontend.json`(공용)과
55
+ `apps/<앱>/locales/<로케일>.json`(앱 전용)이고, 서버 전용 문구(`backend.json`)를 클라에서
56
+ 참조하면 컴파일 에러다(`agents/i18n.md`). 컨트롤러 render props·`sharedProps` 로 번역을
57
+ 흘려보내던 옛 경로(결정 213)는 탈출구로만 남는다 — `sharedProps` 는 번역과 무관한 앱 공유
58
+ 값(테마·플래그)에 쓴다.
56
59
  `pageProps<K>()` 반환에도 교차되어 `props.csrf` 로도 읽히지만, 라우트 키가 필요 없는
57
60
  `useShared()` 가 정본 표면이다(임의 라우트 키를 빌려 currentUser 를 읽던 우회 트릭을 없앤다).
58
61
  - **구조분해 금지 — `pageProps` 와 같은 함정** (`packages/vue/src/shared.ts`). `useShared()`
@@ -114,6 +117,30 @@ async function search(q: string) {
114
117
  </script>
115
118
  ```
116
119
 
120
+ **상태 변경 커맨드도 같은 경로다(결정 452)** — 강퇴·위임·좋아요·토글처럼
121
+ 페이지의 버튼이 서버 상태를 바꾸는 JSON 액션 호출도 `api()` 가 정본이다
122
+ (폼이 아니므로 `useForm` 이 아니고, `fetch()` 는 CSRF 미부착으로 403 — 아래
123
+ 함정). POST 라우트 `r.post('/posts/:id/like', 'posts#like')` 기준:
124
+
125
+ ```vue
126
+ <script setup lang="ts">
127
+ import { api, isApiError } from 'gaonjs/vue'
128
+
129
+ async function like(postId: string) {
130
+ try {
131
+ // :id 자리표시자는 params 에서 채워지고, 남는 값은 JSON 본문으로 실린다.
132
+ // CSRF 토큰은 프레임웍이 X-CSRF-Token 헤더로 자동 부착한다(결정 166·341).
133
+ const { likes } = await api('web:posts#like', { id: postId })
134
+ return likes
135
+ } catch (e) {
136
+ // 4xx/5xx 는 ApiError throw — res.ok 검사가 아니라 catch 로 받는다(결정 304).
137
+ if (isApiError(e) && e.status === 403) return alert('권한이 없습니다.')
138
+ throw e
139
+ }
140
+ }
141
+ </script>
142
+ ```
143
+
117
144
  - **시그니처** — `api(key, params?, opts?)`. 제네릭 타입 인자를 직접
118
145
  붙이지 않는다 — `key` 값 자체가 `keyof GaonRouteMap` 으로 좁혀져
119
146
  반환 타입을 결정한다 (`packages/vue/src/api.ts`).
@@ -459,8 +486,12 @@ async function runSearch(q: string) {
459
486
  결정한다.
460
487
  - **shared 컴포넌트/컴포저블에서 `pageProps`/`api` 호출·domain 값 import 금지** —
461
488
  doctor **shared-purity** 위반(`.ts`·`.vue` 공통 · 결정 217). 데이터는 props/인자로.
462
- - **Vue 페이지에서 `fetch()` 구현 금지** 세션 앱 폼은
463
- `gaonjs/vue` `useForm(...).post()` (`agents/web.md` §4 · 결정 64).
489
+ - **세션 페이지에서 `fetch()` 금지 폼이든 버튼 액션이든**(결정 452)
490
+ 폼은 `useForm(...).post()` (`agents/web.md` §4 · 결정 64), 강퇴·위임·좋아요
491
+ 같은 상태 변경 커맨드는 `api()` (§2). raw `fetch()` 는 CSRF 토큰이 안 실려
492
+ 상태 변경 요청(POST/PUT/PATCH/DELETE)이 **403 으로 죽는다** — 자동 부착
493
+ (결정 166·341·342)은 `api()`·`useForm`·`router` 를 탈 때만 작동한다.
494
+ REST + `fetch()` 는 API 앱(JWT) 전용이다.
464
495
  - **내부 경로 일반 `<a href="/...">` 금지** — 클릭마다 전체 문서를 다시
465
496
  로드해 SPA 상태가 초기화된다. 앱 내부 이동은 `gaonjs/vue` 의 `Link`
466
497
  (`<Link href="/...">`) 또는 `router.visit(...)`, 외부 URL·`target="_blank"`
@@ -532,7 +563,8 @@ async function runSearch(q: string) {
532
563
  | 결정 128 | `useChannel` 자동 재연결(지수 백오프 1s·2s·5s·10s·지터 · `onReconnect` 로 놓친 데이터 따라잡기 · 미인가 4401 은 재연결 안 함 · `agents/realtime.md` §4) |
533
564
  | 결정 198 | 클라 환경변수 접근자 `env`(gaonjs/vue · `.vue` 의 import.meta.env TS1470 회피) · VITE_* 접두만 노출·접두 제거 · `.gaon/env.d.ts`(.env 스캔) 타입 브리지 · doctor no-import-meta-env(§9) |
534
565
  | 결정 206 | UI 킷 §8 슬롯·props 요약표(카탈로그가 이름만이라 소스 열람 유발 · O-2 해소) · named slot 비대칭 명시(PageHeader `#actions` 복수 vs EmptyState `#action` 단수) |
535
- | 결정 213 | i18n Vue 소비 = 서버 주도 render props/sharedProps 만 · `t()`·`useT()` 클라 미노출(`agents/i18n.md` §5) |
566
+ | 결정 213 | (구) i18n Vue 소비 = 서버 주도 render props/sharedProps 만 **결정 454 로 부분 번복**(탈출구로 존속) |
567
+ | 결정 454 | 클라 `t()` 신설(`gaonjs/vue`) · 카탈로그 backend/frontend 분리 · 두 갈래 키 타입 · 앱별 카탈로그 청크(`agents/i18n.md`) |
536
568
  | 결정 217 | doctor `shared-purity`(구 shared-composable-purity 개명) — `shared/` 의 .ts 컴포저블 + .vue 컴포넌트 순수성(pageProps/api 호출·domain 값 import 금지 · §4) |
537
569
  | 결정 271 | W4 표면 정합 — `Head` 재수출(`gaonjs/vue` · `<Head title>` 제목 조합자 발화) 외 표면/최적화 4건(§12 결정 271) |
538
570
  | 결정 299 | 타입 브리지 PropsOf 정정 — 유니온 분배(조건부 redirect 혼합 액션의 never 붕괴 봉합) + `this.json(data)` 언랩(`{json,status}` 래퍼 타입 거짓 봉합 · `JsonResult<T>` 제네릭) (§2) |