@gaonjs/cli 0.64.0 → 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.
- package/dist/commands/check.js +34 -18
- package/dist/commands/g.d.ts +1 -0
- package/dist/commands/g.js +11 -2
- package/dist/commands/gen.js +1 -1
- package/dist/dev.d.ts +13 -7
- package/dist/dev.js +30 -22
- package/dist/doctor/fixers/i18n-layout.d.ts +2 -2
- package/dist/doctor/fixers/i18n-layout.js +119 -25
- package/dist/doctor/fixers/index.js +7 -2
- package/dist/doctor/fixers/types.d.ts +17 -2
- package/dist/doctor/i18n-app-scope.d.ts +3 -0
- package/dist/doctor/i18n-app-scope.js +94 -65
- package/dist/doctor/i18n-layout.d.ts +1 -1
- package/dist/doctor/i18n-layout.js +157 -32
- package/dist/doctor/i18n-server-scope.d.ts +2 -0
- package/dist/doctor/i18n-server-scope.js +124 -0
- package/dist/doctor/locale-parity.js +29 -68
- package/dist/doctor/types.d.ts +1 -1
- package/dist/doctor.d.ts +2 -1
- package/dist/doctor.js +36 -10
- package/dist/i18n-config.d.ts +9 -9
- package/dist/i18n-config.js +8 -15
- package/dist/messages-gen.d.ts +10 -10
- package/dist/messages-gen.js +42 -54
- package/dist/scaffold/app.d.ts +10 -1
- package/dist/scaffold/app.js +12 -2
- package/dist/templates/project/AGENTS.md.tpl +7 -6
- package/dist/templates/project/agents/i18n.md.tpl +165 -70
- package/dist/templates/project/agents/web.md.tpl +4 -3
- package/dist/templates/project/{locales → apps/web/locales}/en/frontend.json.tpl +1 -0
- package/dist/templates/project/{locales → apps/web/locales}/ko/frontend.json.tpl +1 -0
- package/dist/templates/project/gaon.config.ts.tpl +6 -5
- package/package.json +7 -7
- package/dist/templates/project/apps/web/locales/en.json.tpl +0 -3
- package/dist/templates/project/apps/web/locales/ko.json.tpl +0 -3
- /package/dist/templates/project/{locales → domain/locales}/en/backend.json.tpl +0 -0
- /package/dist/templates/project/{locales → domain/locales}/ko/backend.json.tpl +0 -0
|
@@ -9,18 +9,17 @@
|
|
|
9
9
|
// 경고(error 아님): 부분 누락은 fallback 으로 화면이 깨지진 않는 소프트 결함이라
|
|
10
10
|
// 빌드를 세우지 않는다. --json 은 detail.locale·detail.missing 으로 구조화한다.
|
|
11
11
|
//
|
|
12
|
-
// 결정 456: **앱 스코프
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
// 비교는
|
|
19
|
-
//
|
|
20
|
-
import { existsSync, readdirSync } from 'node:fs';
|
|
12
|
+
// 결정 456: **앱 스코프 카탈로그까지 훑는다.** `i18n-app-scope` 는 대신 못 잡는다:
|
|
13
|
+
// 그 검사의 키 집합은 **전 로케일 합집합**이라 en 에만 있는 키도 "있는 키" 로 세기
|
|
14
|
+
// 때문이다(축이 다르다 — 앱 경계 침범 검출).
|
|
15
|
+
//
|
|
16
|
+
// 결정 459: 소유자가 domain / 앱 N개로 재편되면서 비교 단위가 **3종**이 된다:
|
|
17
|
+
// ① domain-backend 로케일 간 ② 앱별 frontend 로케일 간 ③ 앱별 backend 로케일 간
|
|
18
|
+
// **스코프·소유자를 가로지르는 비교는 하지 않는다** — 앱 전용 키가 전량 "도메인에 없음"
|
|
19
|
+
// 으로 뜨는 오탐이 되고(결정 456 이 이미 기각한 축), frontend↔backend 는 애초에 다른 축이다.
|
|
21
20
|
import { join, relative } from 'node:path';
|
|
22
|
-
import {
|
|
23
|
-
import { analyzeProjectI18n
|
|
21
|
+
import { loadProjectLocales, flattenKeys } from '@gaonjs/i18n';
|
|
22
|
+
import { analyzeProjectI18n } from '../i18n-config.js';
|
|
24
23
|
// 경고 한 줄이 폭주하지 않게 나열 상한 — 넘으면 "…외 N개" 로 접는다(detail 에는 전량).
|
|
25
24
|
const MAX_KEYS_SHOWN = 20;
|
|
26
25
|
// i18next 복수형 접미사(결정 181 · generator.ts 와 동일 규약). 로케일마다 필요한 접미사만
|
|
@@ -36,66 +35,27 @@ function pluralBase(key) {
|
|
|
36
35
|
* 비교 대상이 없어 건너뛴다(i18n 미사용·단일 로케일 프로젝트는 무소음).
|
|
37
36
|
*/
|
|
38
37
|
export async function checkLocaleParity(cwd) {
|
|
39
|
-
|
|
40
|
-
// 'locales' 는 dir 커스텀 프로젝트에서 이 검사를 무소음으로 껐다).
|
|
41
|
-
const localesDir = resolveLocalesDir(cwd, analyzeProjectI18n(cwd).dir);
|
|
42
|
-
if (!existsSync(localesDir))
|
|
38
|
+
if (!analyzeProjectI18n(cwd).declared)
|
|
43
39
|
return { rule: 'locale-parity', issues: [] };
|
|
44
|
-
|
|
45
|
-
// 스코프에 두면(ko 는 frontend, ja 는 backend) 클라 카탈로그가 갈리므로, 병합본으로
|
|
46
|
-
// 비교하면 그 배치 불일치를 못 본다. 파일 경로도 스코프별로 정확히 짚는다.
|
|
47
|
-
const scoped = loadScopedLocales(localesDir);
|
|
48
|
-
const issues = [];
|
|
49
|
-
for (const scope of ['backend', 'frontend']) {
|
|
50
|
-
const resources = scoped[scope];
|
|
51
|
-
issues.push(...parityIssues(cwd, localesDir, resources, scoped.layout, scope));
|
|
52
|
-
}
|
|
53
|
-
issues.push(...appParityIssues(cwd));
|
|
54
|
-
return { rule: 'locale-parity', issues };
|
|
55
|
-
}
|
|
56
|
-
/** `apps/` 아래 앱 폴더 이름들(없으면 빈 배열). */
|
|
57
|
-
function appNames(cwd) {
|
|
40
|
+
let project;
|
|
58
41
|
try {
|
|
59
|
-
|
|
60
|
-
.filter((e) => e.isDirectory())
|
|
61
|
-
.map((e) => e.name)
|
|
62
|
-
.sort();
|
|
42
|
+
project = loadProjectLocales(cwd);
|
|
63
43
|
}
|
|
64
44
|
catch {
|
|
65
|
-
|
|
45
|
+
// 로더 fail-loud 는 i18n-layout·check 가 소유한다 — 여기선 침묵(중복 진단 금지).
|
|
46
|
+
return { rule: 'locale-parity', issues: [] };
|
|
66
47
|
}
|
|
67
|
-
}
|
|
68
|
-
/**
|
|
69
|
-
* 결정 456: 앱 스코프 카탈로그의 로케일 간 키 diff. 앱 **내부에서만** 비교한다
|
|
70
|
-
* (앱 카탈로그는 루트 frontend 의 초과분이 정상이라 교차 비교는 전량 오탐).
|
|
71
|
-
* 앱 카탈로그는 frontend 전용이므로(결정 454 R7) 스코프 분리는 없다.
|
|
72
|
-
*/
|
|
73
|
-
function appParityIssues(cwd) {
|
|
74
48
|
const issues = [];
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
const
|
|
80
|
-
for (const [
|
|
81
|
-
|
|
82
|
-
const shown = missing.slice(0, MAX_KEYS_SHOWN);
|
|
83
|
-
const more = missing.length - shown.length;
|
|
84
|
-
const list = shown.map((k) => `'${k}'`).join(', ') + (more > 0 ? ` …외 ${more}개` : '');
|
|
85
|
-
issues.push({
|
|
86
|
-
rule: 'locale-parity',
|
|
87
|
-
level: 'warning',
|
|
88
|
-
file: rel,
|
|
89
|
-
message: `로케일 '${lng}' 에 같은 앱의 다른 로케일엔 있는 키 ${missing.length}개가 빠졌습니다: ${rel}\n` +
|
|
90
|
-
` 누락 키: ${list}\n` +
|
|
91
|
-
`→ ${rel} 에 이 키들을 채우고 gaon gen 을 실행하세요 — 없으면 '${lng}' 사용자 화면에\n` +
|
|
92
|
-
` 키 문자열이 그대로 노출됩니다(결정 455 런타임 degrade). 앱 공용 문구면 대신\n` +
|
|
93
|
-
` locales/${lng}/frontend.json 으로 올리세요.`,
|
|
94
|
-
detail: { locale: lng, scope: `app:${app}`, app, missing },
|
|
95
|
-
});
|
|
49
|
+
// ① 도메인 — backend 만 정당하다(frontend 가 있으면 로더가 이미 throw).
|
|
50
|
+
issues.push(...parityIssues(cwd, join(cwd, 'domain', 'locales'), project.domain.backend, project.domain.layout, 'backend', 'domain'));
|
|
51
|
+
// ②③ 앱별 — frontend·backend 를 **각각** 그 앱 안에서만 비교한다.
|
|
52
|
+
for (const [app, scoped] of Object.entries(project.apps).sort(([a], [b]) => a.localeCompare(b))) {
|
|
53
|
+
const dir = join(cwd, 'apps', app, 'locales');
|
|
54
|
+
for (const scope of ['backend', 'frontend']) {
|
|
55
|
+
issues.push(...parityIssues(cwd, dir, scoped[scope], scoped.layout, scope, `app:${app}`));
|
|
96
56
|
}
|
|
97
57
|
}
|
|
98
|
-
return issues;
|
|
58
|
+
return { rule: 'locale-parity', issues };
|
|
99
59
|
}
|
|
100
60
|
/**
|
|
101
61
|
* 로케일 간 키 diff — 로케일별 "다른 로케일엔 있는데 나에겐 없는 키" 를 낸다.
|
|
@@ -123,8 +83,8 @@ function missingByLang(resources) {
|
|
|
123
83
|
}
|
|
124
84
|
return out;
|
|
125
85
|
}
|
|
126
|
-
/** 한 스코프 안에서 로케일 간 키 diff 를 계산한다(레거시 단일
|
|
127
|
-
function parityIssues(cwd, localesDir, resources, layout, scope) {
|
|
86
|
+
/** 한 소유자의 한 스코프 안에서 로케일 간 키 diff 를 계산한다(레거시 단일 파일 경로 포함). */
|
|
87
|
+
function parityIssues(cwd, localesDir, resources, layout, scope, owner) {
|
|
128
88
|
const issues = [];
|
|
129
89
|
for (const [lng, missing] of missingByLang(resources)) {
|
|
130
90
|
// 파일 경로는 레이아웃에 맞춘다 — 폴더 분리면 <lng>/<scope>.json, 레거시면 <lng>.json.
|
|
@@ -138,11 +98,12 @@ function parityIssues(cwd, localesDir, resources, layout, scope) {
|
|
|
138
98
|
rule: 'locale-parity',
|
|
139
99
|
level: 'warning',
|
|
140
100
|
file: rel,
|
|
141
|
-
message: `로케일 '${lng}' 에 다른 로케일엔 있는 키 ${missing.length}개가 빠졌습니다: ${rel}\n` +
|
|
101
|
+
message: `로케일 '${lng}' 에 같은 스코프의 다른 로케일엔 있는 키 ${missing.length}개가 빠졌습니다: ${rel} (${owner})\n` +
|
|
142
102
|
` 누락 키: ${list}\n` +
|
|
143
103
|
`→ ${rel} 에 이 키들을 채우세요 — 없으면 '${lng}' 사용자에게 fallback(대개 다른 언어)\n` +
|
|
144
|
-
` 번역이 조용히
|
|
145
|
-
|
|
104
|
+
` 번역이 조용히 노출되거나(서버), 키 문자열이 그대로 뜹니다(클라 · 결정 455).\n` +
|
|
105
|
+
` messages.d.ts 는 기준 로케일 기준이라 컴파일로 못 잡습니다(결정 216).`,
|
|
106
|
+
detail: { locale: lng, scope, owner, missing },
|
|
146
107
|
});
|
|
147
108
|
}
|
|
148
109
|
return issues;
|
package/dist/doctor/types.d.ts
CHANGED
|
@@ -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' | 'page-fetch' | 'i18n-layout' | 'i18n-app-scope';
|
|
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' | 'i18n-server-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
|
@@ -27,6 +27,7 @@ export { usesLinkButtonNesting, checkLinkButtonNesting, } from './doctor/link-bu
|
|
|
27
27
|
export { checkLocaleParity } from './doctor/locale-parity.js';
|
|
28
28
|
export { checkI18nLayout } from './doctor/i18n-layout.js';
|
|
29
29
|
export { checkI18nAppScope } from './doctor/i18n-app-scope.js';
|
|
30
|
+
export { checkI18nServerScope } from './doctor/i18n-server-scope.js';
|
|
30
31
|
export { checkChannelCollision, reexportSpecifier } from './doctor/channel-collision.js';
|
|
31
32
|
export { checkChannelInstanceAuthorize, declaresInstance, hasAuthorize, } from './doctor/channel-instance-authorize.js';
|
|
32
33
|
export { renderHuman, renderJson } from './doctor/reporter.js';
|
|
@@ -35,7 +36,7 @@ export { checkTypeScriptApi, detectProject, fatalNoProject, fatalTsApiMissing, }
|
|
|
35
36
|
* 실행할 검사 이름. 지정 없음(undefined) = 31개 모두.
|
|
36
37
|
*/
|
|
37
38
|
/**
|
|
38
|
-
* doctor 정적 검사
|
|
39
|
+
* doctor 정적 검사 36종의 정본 목록(§2.2). `--check=` 필터의 인정 집합도
|
|
39
40
|
* 이 배열을 단일 출처로 삼는다(parseDoctorChecks) — 새 규칙 추가 시 여기만
|
|
40
41
|
* 늘리면 실행·필터·타입이 함께 정합된다(손유지 중복 리스트 표류 방지).
|
|
41
42
|
*/
|
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
|
-
*
|
|
4
|
+
* 검사를 조립한다(수는 ALL_RULES 가 단일 출처 — CLI help·AGENTS §2.2 가 그걸 읽는다):
|
|
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,13 +30,15 @@
|
|
|
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() 키 = 런타임 키 노출)
|
|
35
33
|
* 28) render-return (결정 340 · this.render/redirect/json 호출만 하고 return 누락 = 무신호 204 경고)
|
|
36
34
|
* 29) channel-collision (§7 · 앱간 동명 채널 = 전역 subject·프레즌스 병합 error)
|
|
37
35
|
* 30) channel-instance-authorize (결정 440 · authorize 없는 인스턴스 채널 = 임의 인스턴스 공개 입장 경고)
|
|
38
36
|
* 31) dotenv-node-env (결정 430 · 공유 .env 의 NODE_ENV = 모드 누출 경고)
|
|
39
37
|
* 32) page-fetch (결정 453 · 세션 앱 .vue 의 내부 경로 raw fetch = CSRF 미부착 403 경고)
|
|
38
|
+
* 33) i18n-layout (결정 454·459 · 옛 카탈로그 배치 = 안 읽히는 폴더 안내 · --fix 로 이관)
|
|
39
|
+
* 34) i18n-app-scope (결정 454·459 · 그 앱 카탈로그에 없는 클라 t() 키 = 런타임 키 노출 ·
|
|
40
|
+
* shared/ 가 쓰는 키는 전 앱에 있어야 한다)
|
|
41
|
+
* 35) i18n-server-scope (결정 459 · 소유자 경계를 넘는 서버 t() 키 = 워커·크론에서 조용히 빔)
|
|
40
42
|
*
|
|
41
43
|
* 각 검사는 순수 함수(cwd → RuleReport). 상위 runDoctorCommand 가 조립해
|
|
42
44
|
* DoctorResult 로 낸다. --json 은 자동화(CI)를 위해 반드시 파싱 가능한
|
|
@@ -45,9 +47,9 @@
|
|
|
45
47
|
* 하위 호환: 기존 응답 혼용 API(`inspectControllerSource`·`runDoctor`)는
|
|
46
48
|
* 그대로 export — 기존 테스트가 계속 동작한다.
|
|
47
49
|
*/
|
|
48
|
-
import { rename, writeFile } from 'node:fs/promises';
|
|
50
|
+
import { mkdir, rename, rm, writeFile } from 'node:fs/promises';
|
|
49
51
|
import { existsSync } from 'node:fs';
|
|
50
|
-
import { join, resolve } from 'node:path';
|
|
52
|
+
import { dirname, join, resolve } from 'node:path';
|
|
51
53
|
import ts from 'typescript';
|
|
52
54
|
import { checkResponseMixing } from './doctor/response-mixing.js';
|
|
53
55
|
import { checkRenderReturn } from './doctor/render-return.js';
|
|
@@ -80,6 +82,7 @@ import { checkNoImportMetaEnv } from './doctor/no-import-meta-env.js';
|
|
|
80
82
|
import { checkLocaleParity } from './doctor/locale-parity.js';
|
|
81
83
|
import { checkI18nLayout } from './doctor/i18n-layout.js';
|
|
82
84
|
import { checkI18nAppScope } from './doctor/i18n-app-scope.js';
|
|
85
|
+
import { checkI18nServerScope } from './doctor/i18n-server-scope.js';
|
|
83
86
|
import { checkChannelCollision } from './doctor/channel-collision.js';
|
|
84
87
|
import { checkChannelInstanceAuthorize } from './doctor/channel-instance-authorize.js';
|
|
85
88
|
import { checkDotenvNodeEnv } from './doctor/dotenv-node-env.js';
|
|
@@ -114,6 +117,7 @@ export { usesLinkButtonNesting, checkLinkButtonNesting, } from './doctor/link-bu
|
|
|
114
117
|
export { checkLocaleParity } from './doctor/locale-parity.js';
|
|
115
118
|
export { checkI18nLayout } from './doctor/i18n-layout.js';
|
|
116
119
|
export { checkI18nAppScope } from './doctor/i18n-app-scope.js';
|
|
120
|
+
export { checkI18nServerScope } from './doctor/i18n-server-scope.js';
|
|
117
121
|
export { checkChannelCollision, reexportSpecifier } from './doctor/channel-collision.js';
|
|
118
122
|
export { checkChannelInstanceAuthorize, declaresInstance, hasAuthorize, } from './doctor/channel-instance-authorize.js';
|
|
119
123
|
export { renderHuman, renderJson } from './doctor/reporter.js';
|
|
@@ -122,7 +126,7 @@ export { checkTypeScriptApi, detectProject, fatalNoProject, fatalTsApiMissing, }
|
|
|
122
126
|
* 실행할 검사 이름. 지정 없음(undefined) = 31개 모두.
|
|
123
127
|
*/
|
|
124
128
|
/**
|
|
125
|
-
* doctor 정적 검사
|
|
129
|
+
* doctor 정적 검사 36종의 정본 목록(§2.2). `--check=` 필터의 인정 집합도
|
|
126
130
|
* 이 배열을 단일 출처로 삼는다(parseDoctorChecks) — 새 규칙 추가 시 여기만
|
|
127
131
|
* 늘리면 실행·필터·타입이 함께 정합된다(손유지 중복 리스트 표류 방지).
|
|
128
132
|
*/
|
|
@@ -162,6 +166,7 @@ export const ALL_RULES = [
|
|
|
162
166
|
'page-fetch',
|
|
163
167
|
'i18n-layout',
|
|
164
168
|
'i18n-app-scope',
|
|
169
|
+
'i18n-server-scope',
|
|
165
170
|
];
|
|
166
171
|
/**
|
|
167
172
|
* `gaon help` 이 doctor 한 줄에 요약할 규칙별 문구(§2.2 상세는 AGENTS). 타입이
|
|
@@ -199,7 +204,8 @@ export const RULE_SUMMARIES = {
|
|
|
199
204
|
'no-import-meta-env': 'import.meta.env',
|
|
200
205
|
'locale-parity': '로케일 커버리지',
|
|
201
206
|
'i18n-layout': 'i18n 카탈로그 레이아웃(단일 파일 → backend/frontend 분리 안내 · 결정 454)',
|
|
202
|
-
'i18n-app-scope': '앱 경계를 넘는 클라 번역 키(그 앱 카탈로그에 없는 t() 키 · 결정 454)',
|
|
207
|
+
'i18n-app-scope': '앱 경계를 넘는 클라 번역 키(그 앱 카탈로그에 없는 t() 키 · 결정 454·459)',
|
|
208
|
+
'i18n-server-scope': '소유자 경계를 넘는 서버 번역 키(도메인이 앱 키를 쓰면 워커에서 빔 · 결정 459)',
|
|
203
209
|
'render-return': 'render return 누락',
|
|
204
210
|
'channel-collision': '앱간 동명 채널',
|
|
205
211
|
'channel-instance-authorize': '인스턴스 채널 authorize',
|
|
@@ -237,6 +243,7 @@ const CHECKERS = {
|
|
|
237
243
|
'locale-parity': checkLocaleParity,
|
|
238
244
|
'i18n-layout': checkI18nLayout,
|
|
239
245
|
'i18n-app-scope': checkI18nAppScope,
|
|
246
|
+
'i18n-server-scope': checkI18nServerScope,
|
|
240
247
|
'render-return': checkRenderReturn,
|
|
241
248
|
'channel-collision': checkChannelCollision,
|
|
242
249
|
'channel-instance-authorize': checkChannelInstanceAuthorize,
|
|
@@ -435,6 +442,9 @@ export async function runDoctorFix(reports, cwd, apply) {
|
|
|
435
442
|
await writeFile(join(cwd, ref.file), ref.after, 'utf8');
|
|
436
443
|
backups.push(bak);
|
|
437
444
|
}
|
|
445
|
+
// 이관 대상은 새 폴더일 수 있다(예 locales/ko.json → domain/locales/ko/backend.json)
|
|
446
|
+
// — 부모가 없으면 rename 이 ENOENT 로 죽는다(결정 459).
|
|
447
|
+
await mkdir(dirname(join(cwd, plan.to)), { recursive: true });
|
|
438
448
|
await rename(join(cwd, plan.file), join(cwd, plan.to));
|
|
439
449
|
outcomes.push({
|
|
440
450
|
rule: report.rule,
|
|
@@ -445,17 +455,33 @@ export async function runDoctorFix(reports, cwd, apply) {
|
|
|
445
455
|
});
|
|
446
456
|
continue;
|
|
447
457
|
}
|
|
448
|
-
|
|
458
|
+
if (plan.kind === 'remove') {
|
|
459
|
+
// 결정 459: 원본을 백업한 뒤 지운다(복제가 이미 끝난 이관의 마지막 단계).
|
|
460
|
+
const backupRel = `${plan.file}.bak-${ts}`;
|
|
461
|
+
await writeFile(join(cwd, backupRel), plan.before, 'utf8');
|
|
462
|
+
await rm(join(cwd, plan.file), { force: true });
|
|
463
|
+
outcomes.push({
|
|
464
|
+
rule: report.rule,
|
|
465
|
+
file: plan.file,
|
|
466
|
+
summary: plan.summary,
|
|
467
|
+
applied: true,
|
|
468
|
+
backup: backupRel,
|
|
469
|
+
});
|
|
470
|
+
continue;
|
|
471
|
+
}
|
|
472
|
+
// 내용 재작성 — 원본 백업 먼저. 새 파일(before='')이면 부모 폴더를 만든다.
|
|
449
473
|
const abs = join(cwd, plan.file);
|
|
450
474
|
const backupRel = `${plan.file}.bak-${ts}`;
|
|
451
|
-
await
|
|
475
|
+
await mkdir(dirname(abs), { recursive: true });
|
|
476
|
+
if (plan.before !== '')
|
|
477
|
+
await writeFile(join(cwd, backupRel), plan.before, 'utf8');
|
|
452
478
|
await writeFile(abs, plan.after, 'utf8');
|
|
453
479
|
outcomes.push({
|
|
454
480
|
rule: report.rule,
|
|
455
481
|
file: plan.file,
|
|
456
482
|
summary: plan.summary,
|
|
457
483
|
applied: true,
|
|
458
|
-
backup: backupRel,
|
|
484
|
+
backup: plan.before === '' ? undefined : backupRel,
|
|
459
485
|
});
|
|
460
486
|
}
|
|
461
487
|
catch (err) {
|
package/dist/i18n-config.d.ts
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
export interface ConfigI18nAnalysis {
|
|
2
2
|
/** defineConfig 인자에 i18n 프로퍼티가 존재하는가. */
|
|
3
3
|
readonly declared: boolean;
|
|
4
|
-
/**
|
|
4
|
+
/**
|
|
5
|
+
* 결정 459: `i18n.dir` 은 **제거된 설정**이다(카탈로그 위치는 관례 고정). 여기서는
|
|
6
|
+
* 더 이상 카탈로그를 찾는 데 쓰지 않고, **남아 있으면 doctor 가 error 로 잡기 위해서만**
|
|
7
|
+
* 읽는다(조용히 무시하면 "설정했는데 왜 안 먹지" 로 시간이 녹는다).
|
|
8
|
+
*/
|
|
5
9
|
readonly dir?: string;
|
|
6
10
|
/** 정적 리터럴로 읽힌 fallbackLng. 못 읽으면 undefined. */
|
|
7
11
|
readonly fallbackLng?: string;
|
|
@@ -18,15 +22,11 @@ export interface ConfigI18nAnalysis {
|
|
|
18
22
|
export declare function analyzeConfigI18n(source: string): ConfigI18nAnalysis;
|
|
19
23
|
/** cwd 의 gaon.config.ts 를 읽어 i18n 블록을 분석한다. 파일이 없으면 미선언. */
|
|
20
24
|
export declare function analyzeProjectI18n(cwd: string): ConfigI18nAnalysis;
|
|
21
|
-
/**
|
|
22
|
-
* 결정 412: 카탈로그 디렉터리의 절대 경로. `dir` 이 절대 경로면 그대로 쓴다 —
|
|
23
|
-
* wire(런타임)는 이미 그렇게 해석하는데 CLI 축만 무조건 join 해서
|
|
24
|
-
* `join('/proj','/var/locales')` = `/proj/var/locales` 로 조용히 빗나갔다.
|
|
25
|
-
*/
|
|
26
|
-
export declare function resolveLocalesDir(cwd: string, dir: string | undefined): string;
|
|
27
25
|
/**
|
|
28
26
|
* 결정 412: 정적으로 못 읽은 i18n 키를 한 줄 경고로 알린다(무신호 금지). 런타임은
|
|
29
|
-
* 실값을, 타입 축·doctor 는 폴백을 쓰므로 messages.d.ts 가 엉뚱한
|
|
30
|
-
*
|
|
27
|
+
* 실값을, 타입 축·doctor 는 폴백을 쓰므로 messages.d.ts 가 엉뚱한 기준 로케일을 볼 수
|
|
28
|
+
* 있다 — 어느 형태로 바꾸면 되는지까지 쓴다(§7.5.3).
|
|
29
|
+
* 결정 459: 카탈로그 **위치**는 관례 고정이라 `dir` 미해석은 더 이상 축을 갈라놓지 않는다
|
|
30
|
+
* (남아 있는 `dir` 자체는 doctor `i18n-layout` 이 error 로 잡는다) — fallbackLng 만 경고한다.
|
|
31
31
|
*/
|
|
32
32
|
export declare function warnUnresolvedI18n(analysis: ConfigI18nAnalysis, write: (s: string) => void): void;
|
package/dist/i18n-config.js
CHANGED
|
@@ -133,26 +133,19 @@ export function analyzeProjectI18n(cwd) {
|
|
|
133
133
|
return { declared: false, unresolved: [] };
|
|
134
134
|
}
|
|
135
135
|
}
|
|
136
|
-
/**
|
|
137
|
-
* 결정 412: 카탈로그 디렉터리의 절대 경로. `dir` 이 절대 경로면 그대로 쓴다 —
|
|
138
|
-
* wire(런타임)는 이미 그렇게 해석하는데 CLI 축만 무조건 join 해서
|
|
139
|
-
* `join('/proj','/var/locales')` = `/proj/var/locales` 로 조용히 빗나갔다.
|
|
140
|
-
*/
|
|
141
|
-
export function resolveLocalesDir(cwd, dir) {
|
|
142
|
-
const d = dir ?? 'locales';
|
|
143
|
-
return d.startsWith('/') ? d : join(cwd, d);
|
|
144
|
-
}
|
|
145
136
|
/**
|
|
146
137
|
* 결정 412: 정적으로 못 읽은 i18n 키를 한 줄 경고로 알린다(무신호 금지). 런타임은
|
|
147
|
-
* 실값을, 타입 축·doctor 는 폴백을 쓰므로 messages.d.ts 가 엉뚱한
|
|
148
|
-
*
|
|
138
|
+
* 실값을, 타입 축·doctor 는 폴백을 쓰므로 messages.d.ts 가 엉뚱한 기준 로케일을 볼 수
|
|
139
|
+
* 있다 — 어느 형태로 바꾸면 되는지까지 쓴다(§7.5.3).
|
|
140
|
+
* 결정 459: 카탈로그 **위치**는 관례 고정이라 `dir` 미해석은 더 이상 축을 갈라놓지 않는다
|
|
141
|
+
* (남아 있는 `dir` 자체는 doctor `i18n-layout` 이 error 로 잡는다) — fallbackLng 만 경고한다.
|
|
149
142
|
*/
|
|
150
143
|
export function warnUnresolvedI18n(analysis, write) {
|
|
151
|
-
if (analysis.unresolved.
|
|
144
|
+
if (!analysis.unresolved.includes('fallbackLng'))
|
|
152
145
|
return;
|
|
153
|
-
write(` ! gaon.config.ts 의 i18n
|
|
154
|
-
`타입 축(.gaon/messages.d.ts)과 doctor 는 기본값(
|
|
155
|
-
` → 문자열 리터럴로 직접 쓰면 정확히 반영됩니다: i18n: {
|
|
146
|
+
write(` ! gaon.config.ts 의 i18n fallbackLng 를 정적으로 읽지 못했습니다 — ` +
|
|
147
|
+
`타입 축(.gaon/messages.d.ts)과 doctor 는 기본값(기준=정렬 첫 로케일)을 씁니다.\n` +
|
|
148
|
+
` → 문자열 리터럴로 직접 쓰면 정확히 반영됩니다: i18n: { fallbackLng: 'ko' }\n`);
|
|
156
149
|
}
|
|
157
150
|
function isDefineConfig(e) {
|
|
158
151
|
if (ts.isIdentifier(e) && e.text === 'defineConfig')
|
package/dist/messages-gen.d.ts
CHANGED
|
@@ -1,27 +1,27 @@
|
|
|
1
|
-
import { type
|
|
1
|
+
import { type ScopedLocaleResources } from '@gaonjs/i18n';
|
|
2
2
|
/** 메시지 축이 카탈로그 모듈을 낼 앱(결정 454). dev 레이아웃이 만들어 넘긴다. */
|
|
3
3
|
export interface MessagesApp {
|
|
4
4
|
readonly name: string;
|
|
5
|
-
/** apps/<앱>/locales (
|
|
5
|
+
/** apps/<앱>/locales (그 앱의 스코프 카탈로그 · 없어도 된다). */
|
|
6
6
|
readonly localesDir: string;
|
|
7
7
|
/** apps/<앱>/.gaon/messages.catalog.ts */
|
|
8
8
|
readonly catalogOut: string;
|
|
9
9
|
}
|
|
10
10
|
/**
|
|
11
|
-
* locales
|
|
12
|
-
* 카탈로그가 없거나 비면 생성하지 않는다(GaonMessages 를
|
|
13
|
-
* i18n 을 안 쓰는 프로젝트가 never 로 깨지지 않게).
|
|
11
|
+
* 프로젝트 카탈로그(domain/locales · apps/*/locales)에서 `.gaon/messages.d.ts`(두 갈래)와
|
|
12
|
+
* 앱별 카탈로그 값 모듈을 생성한다. 카탈로그가 없거나 비면 생성하지 않는다(GaonMessages 를
|
|
13
|
+
* 비운 채로 둬 키가 string 폴백 — i18n 을 안 쓰는 프로젝트가 never 로 깨지지 않게).
|
|
14
|
+
* 생성 여부를 돌려준다.
|
|
14
15
|
*/
|
|
15
|
-
export declare function generateMessagesDts(
|
|
16
|
+
export declare function generateMessagesDts(root: string, out: string, baseLng?: string, apps?: readonly MessagesApp[]): boolean;
|
|
16
17
|
interface CatalogInput {
|
|
17
|
-
readonly localesDir: string;
|
|
18
18
|
readonly app: MessagesApp;
|
|
19
|
-
|
|
20
|
-
readonly
|
|
19
|
+
/** 그 앱의 스코프 카탈로그(없으면 undefined = locales 폴더 부재). */
|
|
20
|
+
readonly appScoped: ScopedLocaleResources | undefined;
|
|
21
21
|
readonly fallbackLng: string;
|
|
22
22
|
}
|
|
23
23
|
/**
|
|
24
|
-
* 결정 454: 앱별 카탈로그 값 모듈을 쓴다. 로케일별 로더가
|
|
24
|
+
* 결정 454·459: 앱별 카탈로그 값 모듈을 쓴다. 로케일별 로더가 **그 앱의 frontend.json 하나**를
|
|
25
25
|
* 동적 import 해 vite 가 로케일별 해시 청크(.js)로 쪼갠다 — `/assets/*` immutable 캐시 대상이며
|
|
26
26
|
* `text/javascript` 라 seal 봉인 대상(application/json)에서 자연 면제된다.
|
|
27
27
|
* 생성 여부를 돌려준다(프론트 카탈로그가 없으면 false + 스테일 제거).
|
package/dist/messages-gen.js
CHANGED
|
@@ -1,19 +1,23 @@
|
|
|
1
|
-
// @gaonjs/cli · .gaon 메시지 축 생성기 (결정 158 · 13차 W2 · 결정 454)
|
|
1
|
+
// @gaonjs/cli · .gaon 메시지 축 생성기 (결정 158 · 13차 W2 · 결정 454 · 459)
|
|
2
2
|
//
|
|
3
|
-
// tables.d.ts·routes.d.ts 와 같은 .gaon 파이프라인의 메시지 축.
|
|
3
|
+
// tables.d.ts·routes.d.ts 와 같은 .gaon 파이프라인의 메시지 축. 두 산출을 낸다:
|
|
4
4
|
//
|
|
5
|
-
// 1) `.gaon/messages.d.ts` — **두 갈래 타입 브리지**
|
|
6
|
-
// · `gaonjs/i18n`(서버) = backend ∪
|
|
7
|
-
// · `gaonjs/vue`(클라) = frontend 만(+ 키별 보간 파라미터) → 서버 전용 문구를
|
|
5
|
+
// 1) `.gaon/messages.d.ts` — **두 갈래 타입 브리지**(루트 단일 · 결정 459 O7)
|
|
6
|
+
// · `gaonjs/i18n`(서버) = domain-backend ∪ 전 앱 backend ∪ 전 앱 frontend
|
|
7
|
+
// · `gaonjs/vue`(클라) = 전 앱 frontend 만(+ 키별 보간 파라미터) → 서버 전용 문구를
|
|
8
8
|
// 클라에서 참조하면 컴파일 에러.
|
|
9
|
+
// 유니온이 **프로젝트 전역**인 것은 타협이 아니라 제약이다 — 앱마다 다른 유니온으로
|
|
10
|
+
// `GaonMessages.keys` 를 augment 하면 TS2717(결정 454 실측 · 결정 459 §5.1 로 유효).
|
|
11
|
+
// 앱 경계는 ① 번들 물리 경계 ② doctor(i18n-app-scope·i18n-server-scope)가 강제한다.
|
|
9
12
|
// 2) `apps/<앱>/.gaon/messages.catalog.ts` — **앱별 값 모듈**(routes.manifest.ts 선례).
|
|
10
13
|
// 그 앱의 frontend 카탈로그만 동적 import 로 묶어 vite 가 로케일별 청크로 쪼갠다.
|
|
11
14
|
// backend.json 은 입력에 아예 없다(노출 차단은 번들 경계가 물리적으로 강제).
|
|
15
|
+
// 결정 459: 공용 스코프가 사라져 **로케일당 소스가 정확히 하나**다.
|
|
12
16
|
//
|
|
13
|
-
// @gaonjs/i18n 의 공개 API(
|
|
17
|
+
// @gaonjs/i18n 의 공개 API(loadProjectLocales·renderMessagesDtsSplit 등)만 쓴다.
|
|
14
18
|
import { existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
|
|
15
19
|
import { dirname, join, relative, sep } from 'node:path';
|
|
16
|
-
import { extractParams, flattenKeys,
|
|
20
|
+
import { extractParams, flattenKeys, loadProjectLocales, projectLanguages, renderMessagesDtsSplit, withPluralBaseKeys, } from '@gaonjs/i18n';
|
|
17
21
|
/** 번역 트리 두 개를 깊은 병합한다(리프 충돌은 로더가 이미 fail-loud). */
|
|
18
22
|
function mergeTrees(a, b) {
|
|
19
23
|
const out = { ...a };
|
|
@@ -36,42 +40,35 @@ function pickBaseLng(langs, baseLng) {
|
|
|
36
40
|
return [...langs].sort()[0];
|
|
37
41
|
}
|
|
38
42
|
/**
|
|
39
|
-
* locales
|
|
40
|
-
* 카탈로그가 없거나 비면 생성하지 않는다(GaonMessages 를
|
|
41
|
-
* i18n 을 안 쓰는 프로젝트가 never 로 깨지지 않게).
|
|
43
|
+
* 프로젝트 카탈로그(domain/locales · apps/*/locales)에서 `.gaon/messages.d.ts`(두 갈래)와
|
|
44
|
+
* 앱별 카탈로그 값 모듈을 생성한다. 카탈로그가 없거나 비면 생성하지 않는다(GaonMessages 를
|
|
45
|
+
* 비운 채로 둬 키가 string 폴백 — i18n 을 안 쓰는 프로젝트가 never 로 깨지지 않게).
|
|
46
|
+
* 생성 여부를 돌려준다.
|
|
42
47
|
*/
|
|
43
|
-
export function generateMessagesDts(
|
|
48
|
+
export function generateMessagesDts(root, out, baseLng, apps = []) {
|
|
49
|
+
const project = loadProjectLocales(root);
|
|
50
|
+
const langs = projectLanguages(project);
|
|
44
51
|
// 결정 414: 카탈로그가 사라졌으면 **옛 생성물을 지운다** — 남겨두면 없어진 키의
|
|
45
52
|
// 유니온이 그대로 살아 t('없어진키') 가 계속 컴파일된다(제거를 못 잡는 사각).
|
|
46
|
-
if (
|
|
53
|
+
if (langs.length === 0) {
|
|
47
54
|
if (existsSync(out))
|
|
48
55
|
rmSync(out, { force: true });
|
|
49
56
|
for (const app of apps)
|
|
50
57
|
removeStale(app.catalogOut);
|
|
51
58
|
return false;
|
|
52
59
|
}
|
|
53
|
-
const
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
for (const
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
if (langs.size === 0) {
|
|
62
|
-
if (existsSync(out))
|
|
63
|
-
rmSync(out, { force: true });
|
|
64
|
-
for (const app of apps)
|
|
65
|
-
removeStale(app.catalogOut);
|
|
66
|
-
return false;
|
|
60
|
+
const base = pickBaseLng(langs, baseLng);
|
|
61
|
+
// 결정 459: 클라 키 = **전 앱 frontend 합집합**(공용 스코프는 폐지됐다). 앱×앱 축을
|
|
62
|
+
// 타입으로 못 나누는 것은 TS2717 제약이라 그대로다 — 경계는 청크 분리와 doctor 가 강제한다.
|
|
63
|
+
let clientTree = {};
|
|
64
|
+
let appBackendTree = {};
|
|
65
|
+
for (const scoped of Object.values(project.apps)) {
|
|
66
|
+
clientTree = mergeTrees(clientTree, translationOf(scoped.frontend, base));
|
|
67
|
+
appBackendTree = mergeTrees(appBackendTree, translationOf(scoped.backend, base));
|
|
67
68
|
}
|
|
68
|
-
|
|
69
|
-
//
|
|
70
|
-
|
|
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);
|
|
69
|
+
// 서버 키 = domain-backend ∪ 전 앱 backend ∪ 전 앱 frontend(결정 459 O5 — 서버는
|
|
70
|
+
// 화면 문구도 볼 수 있다: SSR·로그·관리 스크립트 경로를 좁히지 않는다).
|
|
71
|
+
const serverTree = mergeTrees(mergeTrees(translationOf(project.domain.backend, base), appBackendTree), clientTree);
|
|
75
72
|
const clientKeys = withPluralBaseKeys(flattenKeys(clientTree));
|
|
76
73
|
const serverKeys = withPluralBaseKeys(flattenKeys(serverTree));
|
|
77
74
|
mkdirSync(dirname(out), { recursive: true });
|
|
@@ -81,14 +78,12 @@ export function generateMessagesDts(localesDir, out, baseLng, apps = []) {
|
|
|
81
78
|
clientKeys,
|
|
82
79
|
clientParams: extractParams(clientTree),
|
|
83
80
|
}), 'utf8');
|
|
84
|
-
// 앱별 카탈로그 값 모듈 — frontend 소스가
|
|
81
|
+
// 앱별 카탈로그 값 모듈 — 그 앱에 frontend 소스가 없으면 만들지 않는다.
|
|
85
82
|
const fallback = base ?? 'en';
|
|
86
83
|
for (const app of apps) {
|
|
87
84
|
generateMessagesCatalog({
|
|
88
|
-
localesDir,
|
|
89
85
|
app,
|
|
90
|
-
|
|
91
|
-
frontendLangs: Object.keys(scoped.frontend),
|
|
86
|
+
appScoped: project.apps[app.name],
|
|
92
87
|
fallbackLng: fallback,
|
|
93
88
|
});
|
|
94
89
|
}
|
|
@@ -104,40 +99,33 @@ function importPath(fromDir, target) {
|
|
|
104
99
|
return rel.startsWith('.') ? rel : `./${rel}`;
|
|
105
100
|
}
|
|
106
101
|
/**
|
|
107
|
-
* 결정 454: 앱별 카탈로그 값 모듈을 쓴다. 로케일별 로더가
|
|
102
|
+
* 결정 454·459: 앱별 카탈로그 값 모듈을 쓴다. 로케일별 로더가 **그 앱의 frontend.json 하나**를
|
|
108
103
|
* 동적 import 해 vite 가 로케일별 해시 청크(.js)로 쪼갠다 — `/assets/*` immutable 캐시 대상이며
|
|
109
104
|
* `text/javascript` 라 seal 봉인 대상(application/json)에서 자연 면제된다.
|
|
110
105
|
* 생성 여부를 돌려준다(프론트 카탈로그가 없으면 false + 스테일 제거).
|
|
111
106
|
*/
|
|
112
107
|
export function generateMessagesCatalog(input) {
|
|
113
108
|
const outDir = dirname(input.app.catalogOut);
|
|
114
|
-
const
|
|
109
|
+
const frontend = input.appScoped?.frontend ?? {};
|
|
110
|
+
const langs = Object.keys(frontend).sort();
|
|
115
111
|
if (langs.length === 0) {
|
|
116
112
|
removeStale(input.app.catalogOut);
|
|
117
113
|
return false;
|
|
118
114
|
}
|
|
119
115
|
const lines = [];
|
|
120
116
|
lines.push('// AUTO-GENERATED by @gaonjs/cli — 편집 금지.');
|
|
121
|
-
lines.push('// `gaon check` / `gaon dev` / `gaon gen` 가 이 파일을 재생성한다(결정 454).');
|
|
122
|
-
lines.push('// 클라이언트 번역 카탈로그: 로케일 → 로더(
|
|
123
|
-
lines.push('// 서버 전용 문구(
|
|
117
|
+
lines.push('// `gaon check` / `gaon dev` / `gaon gen` 가 이 파일을 재생성한다(결정 454 · 459).');
|
|
118
|
+
lines.push('// 클라이언트 번역 카탈로그: 로케일 → 로더(이 앱의 frontend.json).');
|
|
119
|
+
lines.push('// 서버 전용 문구(backend.json)는 여기에 실리지 않는다 — 클라로 나가지 않는다.');
|
|
124
120
|
lines.push("import type { CatalogLoaders } from 'gaonjs/vue'");
|
|
125
121
|
lines.push('');
|
|
126
122
|
lines.push('export const catalogs: CatalogLoaders = {');
|
|
127
123
|
for (const lng of langs) {
|
|
128
|
-
|
|
129
|
-
const
|
|
130
|
-
if (existsSync(
|
|
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)
|
|
124
|
+
// 결정 459: 로케일당 소스가 정확히 하나다(공용 스코프 폐지) — 다중 import 분기가 없다.
|
|
125
|
+
const file = join(input.app.localesDir, lng, 'frontend.json');
|
|
126
|
+
if (!existsSync(file))
|
|
136
127
|
continue;
|
|
137
|
-
|
|
138
|
-
? `import('${sources[0]}')`
|
|
139
|
-
: `Promise.all([${sources.map((s) => `import('${s}')`).join(', ')}])`;
|
|
140
|
-
lines.push(` '${lng}': () => ${body},`);
|
|
128
|
+
lines.push(` '${lng}': () => import('${importPath(outDir, file)}'),`);
|
|
141
129
|
}
|
|
142
130
|
lines.push('}');
|
|
143
131
|
lines.push('');
|
package/dist/scaffold/app.d.ts
CHANGED
|
@@ -1,5 +1,14 @@
|
|
|
1
1
|
import type { ScaffoldFile } from './controller.js';
|
|
2
2
|
/** 이름 유효성 — apps/<name>/ 이 안전하고 URL 프리픽스로도 무리 없는 부분집합. */
|
|
3
3
|
export declare function validateAppName(name: string): void;
|
|
4
|
+
/** 앱 스캐폴드 옵션. */
|
|
5
|
+
export interface AppScaffoldOptions {
|
|
6
|
+
/**
|
|
7
|
+
* 결정 459: i18n 을 쓰는 프로젝트의 기준 로케일. 주면 그 앱의 **필수** frontend
|
|
8
|
+
* 카탈로그(`apps/<앱>/locales/<로케일>/frontend.json`)를 함께 만든다. 생략 =
|
|
9
|
+
* i18n 미사용 프로젝트라 카탈로그를 만들지 않는다.
|
|
10
|
+
*/
|
|
11
|
+
readonly fallbackLng?: string;
|
|
12
|
+
}
|
|
4
13
|
/** 앱 스캐폴드 파일 계획. 순수 함수 — 실제 쓰기는 writeScaffold 가 담당. */
|
|
5
|
-
export declare function appScaffoldFiles(name: string): ScaffoldFile[];
|
|
14
|
+
export declare function appScaffoldFiles(name: string, options?: AppScaffoldOptions): ScaffoldFile[];
|
package/dist/scaffold/app.js
CHANGED
|
@@ -47,7 +47,7 @@ export function validateAppName(name) {
|
|
|
47
47
|
}
|
|
48
48
|
}
|
|
49
49
|
/** 앱 스캐폴드 파일 계획. 순수 함수 — 실제 쓰기는 writeScaffold 가 담당. */
|
|
50
|
-
export function appScaffoldFiles(name) {
|
|
50
|
+
export function appScaffoldFiles(name, options = {}) {
|
|
51
51
|
validateAppName(name);
|
|
52
52
|
const files = [];
|
|
53
53
|
// 1) routes.ts — 관례상 GET / + /health 두 라우트로 시작한다.
|
|
@@ -160,7 +160,17 @@ export function appScaffoldFiles(name) {
|
|
|
160
160
|
``,
|
|
161
161
|
].join('\n'),
|
|
162
162
|
});
|
|
163
|
-
// 6
|
|
163
|
+
// 6) locales/<로케일>/frontend.json — 결정 459: **화면 문구의 소유자는 그 앱**이다.
|
|
164
|
+
// frontend 카탈로그는 앱마다 필수라 스캐폴드가 자리를 만들어 둔다(빈 파일이 아니라
|
|
165
|
+
// 실제로 쓰이는 키 하나를 둬야 "여기에 넣으면 되는구나" 가 보인다). 서버 문구가
|
|
166
|
+
// 필요해지면 같은 폴더에 backend.json 을 추가한다(선택).
|
|
167
|
+
if (options.fallbackLng) {
|
|
168
|
+
files.push({
|
|
169
|
+
path: `apps/${name}/locales/${options.fallbackLng}/frontend.json`,
|
|
170
|
+
contents: `${JSON.stringify({ nav: { home: name } }, null, 2)}\n`,
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
// 7-9) 관례 폴더 유지 — git 이 빈 폴더를 추적하지 않아 .gitkeep 을 둔다.
|
|
164
174
|
// components · composables · channels 는 앱이 클수록 자주 채워지는
|
|
165
175
|
// 세 자리다(E-5 §2.2 · M6 실시간).
|
|
166
176
|
for (const sub of ['components', 'composables', 'channels']) {
|