@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.
- package/dist/commands/check.js +8 -3
- package/dist/dev.d.ts +17 -4
- package/dist/dev.js +10 -3
- package/dist/doctor/fixers/i18n-layout.d.ts +7 -0
- package/dist/doctor/fixers/i18n-layout.js +40 -0
- package/dist/doctor/fixers/index.d.ts +1 -0
- package/dist/doctor/fixers/index.js +18 -0
- package/dist/doctor/i18n-app-scope.d.ts +8 -0
- package/dist/doctor/i18n-app-scope.js +178 -0
- package/dist/doctor/i18n-layout.d.ts +4 -0
- package/dist/doctor/i18n-layout.js +47 -0
- package/dist/doctor/locale-parity.js +21 -6
- package/dist/doctor/page-fetch.d.ts +8 -0
- package/dist/doctor/page-fetch.js +107 -0
- package/dist/doctor/types.d.ts +1 -1
- package/dist/doctor.d.ts +3 -1
- package/dist/doctor.js +19 -2
- package/dist/index.js +3 -3
- package/dist/messages-gen.d.ts +28 -4
- package/dist/messages-gen.js +137 -17
- package/dist/templates/project/AGENTS.md.tpl +6 -3
- package/dist/templates/project/CLAUDE.md.tpl +1 -1
- package/dist/templates/project/agents/frontend.md.tpl +38 -6
- package/dist/templates/project/agents/i18n.md.tpl +187 -213
- package/dist/templates/project/agents/realtime.md.tpl +36 -2
- package/dist/templates/project/apps/web/locales/en.json.tpl +3 -0
- package/dist/templates/project/apps/web/locales/ko.json.tpl +3 -0
- package/dist/templates/project/apps/web/main.ts.tpl +7 -0
- package/dist/templates/project/apps/web/pages/Home/Index.vue.tpl +3 -3
- package/dist/templates/project/gaon.config.ts.tpl +7 -0
- package/dist/templates/project/locales/en/backend.json.tpl +4 -0
- package/dist/templates/project/locales/en/frontend.json.tpl +8 -0
- package/dist/templates/project/locales/ko/backend.json.tpl +4 -0
- package/dist/templates/project/locales/ko/frontend.json.tpl +7 -0
- package/package.json +6 -6
package/dist/commands/check.js
CHANGED
|
@@ -255,15 +255,20 @@ function verifyI18nCatalogs(cwd) {
|
|
|
255
255
|
const resources = existsSync(abs) ? loadLocales(abs) : {};
|
|
256
256
|
if (Object.keys(resources).length === 0) {
|
|
257
257
|
return (`i18n 이 설정됐지만 로케일 카탈로그가 비어 있습니다: ${abs}\n` +
|
|
258
|
-
` → ${dir}/${cfg.fallbackLng ?? '<fallbackLng>'}.json
|
|
258
|
+
` → ${dir}/${cfg.fallbackLng ?? '<fallbackLng>'}/frontend.json (화면 문구) 또는 ` +
|
|
259
|
+
`${dir}/${cfg.fallbackLng ?? '<fallbackLng>'}/backend.json (서버 전용 문구)을 만드세요 — 결정 454.\n` +
|
|
260
|
+
` → 단일 파일 레이아웃(${dir}/${cfg.fallbackLng ?? '<fallbackLng>'}.json)도 계속 지원합니다(서버 전용으로 취급).\n` +
|
|
259
261
|
` → gaon.config.ts 의 i18n.dir 이 실제 카탈로그 폴더를 가리키는지 확인하세요.\n` +
|
|
260
262
|
` (이 상태로는 gaon serve 가 부팅에서 실패합니다 — 결정 353)`);
|
|
261
263
|
}
|
|
262
264
|
const wanted = [...(cfg.fallbackLng ? [cfg.fallbackLng] : []), ...(cfg.supportedLngs ?? [])];
|
|
263
265
|
const missing = wanted.filter((lng, i, arr) => arr.indexOf(lng) === i && !resources[lng]);
|
|
264
266
|
if (missing.length > 0) {
|
|
265
|
-
return (`i18n 설정이 지목한 로케일의
|
|
266
|
-
` → ${dir}/ 에 ${missing.map((l) => `${l}.json`).join(' · ')}
|
|
267
|
+
return (`i18n 설정이 지목한 로케일의 카탈로그가 없습니다: ${missing.join(', ')}\n` +
|
|
268
|
+
` → ${dir}/ 에 ${missing.map((l) => `${l}/frontend.json`).join(' · ')} (또는 ${missing
|
|
269
|
+
.map((l) => `${l}/backend.json`)
|
|
270
|
+
.join(' · ')})을 추가하거나,\n` +
|
|
271
|
+
` → 단일 파일 레이아웃이면 ${missing.map((l) => `${l}.json`).join(' · ')} 을 추가하거나,\n` +
|
|
267
272
|
` → gaon.config.ts 의 fallbackLng/supportedLngs 에서 해당 로케일을 제거하세요.\n` +
|
|
268
273
|
` (이 상태로는 gaon serve 가 부팅에서 실패합니다 — 결정 353)`);
|
|
269
274
|
}
|
package/dist/dev.d.ts
CHANGED
|
@@ -4,6 +4,16 @@ export interface DevApp {
|
|
|
4
4
|
readonly appDir: string;
|
|
5
5
|
/** apps/<app>/.gaon/routes.d.ts */
|
|
6
6
|
readonly routesOut: string;
|
|
7
|
+
/** apps/<app>/locales (앱 전용 frontend 카탈로그 · 결정 454). */
|
|
8
|
+
readonly localesDir: string;
|
|
9
|
+
/** apps/<app>/.gaon/messages.catalog.ts (클라 카탈로그 값 모듈 · 결정 454). */
|
|
10
|
+
readonly catalogOut: string;
|
|
11
|
+
}
|
|
12
|
+
/** 메시지 축이 카탈로그 모듈을 낼 앱 참조(결정 454 · messages-gen.MessagesApp 과 구조 호환). */
|
|
13
|
+
export interface MessagesAppRef {
|
|
14
|
+
readonly name: string;
|
|
15
|
+
readonly localesDir: string;
|
|
16
|
+
readonly catalogOut: string;
|
|
7
17
|
}
|
|
8
18
|
export interface DevLayout {
|
|
9
19
|
/** domain/schema (없으면 undefined — tables 워치 생략). */
|
|
@@ -28,8 +38,11 @@ export interface DevDeps {
|
|
|
28
38
|
readonly layout: DevLayout;
|
|
29
39
|
regenerateTables(schemaDir: string, out: string): Promise<unknown>;
|
|
30
40
|
regenerateRoutes(appDir: string, out: string): Promise<unknown>;
|
|
31
|
-
/**
|
|
32
|
-
|
|
41
|
+
/**
|
|
42
|
+
* locales/ → .gaon/messages.d.ts (결정 158 · W2 · 기준 로케일 = 결정 352) + 앱별
|
|
43
|
+
* messages.catalog.ts (결정 454 · apps 를 넘긴 호출자만). i18n 축을 쓰는 호출자만 준다.
|
|
44
|
+
*/
|
|
45
|
+
regenerateMessages?(localesDir: string, out: string, baseLng?: string, apps?: readonly MessagesAppRef[]): unknown;
|
|
33
46
|
/** `.env` → .gaon/env.d.ts (결정 198 · F-9 ②). env 축을 쓰는 호출자만 준다. */
|
|
34
47
|
regenerateEnv?(envFile: string, out: string): unknown;
|
|
35
48
|
watch(dir: string, opts: WatchOptions): WatchHandle;
|
|
@@ -60,8 +73,8 @@ export declare function startDev(deps: DevDeps): Promise<DevHandle>;
|
|
|
60
73
|
export interface RegenDeps {
|
|
61
74
|
regenerateTables(schemaDir: string, out: string): Promise<unknown>;
|
|
62
75
|
regenerateRoutes(appDir: string, out: string): Promise<unknown>;
|
|
63
|
-
/** locales/ → .gaon/messages.d.ts (결정 158 ·
|
|
64
|
-
regenerateMessages?(localesDir: string, out: string, baseLng?: string): unknown;
|
|
76
|
+
/** locales/ → .gaon/messages.d.ts + 앱별 messages.catalog.ts (결정 158 · 352 · 454). */
|
|
77
|
+
regenerateMessages?(localesDir: string, out: string, baseLng?: string, apps?: readonly MessagesAppRef[]): unknown;
|
|
65
78
|
/** `.env` → .gaon/env.d.ts (결정 198 · F-9 ②). env 축을 쓰는 호출자만 준다. */
|
|
66
79
|
regenerateEnv?(envFile: string, out: string): unknown;
|
|
67
80
|
}
|
package/dist/dev.js
CHANGED
|
@@ -40,7 +40,7 @@ export async function startDev(deps) {
|
|
|
40
40
|
}
|
|
41
41
|
// 결정 158(W2): locales/ → messages.d.ts.
|
|
42
42
|
if (layout.localesDir && deps.regenerateMessages) {
|
|
43
|
-
await deps.regenerateMessages(layout.localesDir, layout.messagesOut, layout.messagesBaseLng);
|
|
43
|
+
await deps.regenerateMessages(layout.localesDir, layout.messagesOut, layout.messagesBaseLng, messagesAppsOf(layout));
|
|
44
44
|
deps.log({ kind: 'regen', target: 'messages' });
|
|
45
45
|
}
|
|
46
46
|
// 결정 198(F-9 ②): 프론트 앱이 있으면 .env → env.d.ts(VITE_* 타입 브리지). `.env` 부재는
|
|
@@ -80,7 +80,7 @@ export async function startDev(deps) {
|
|
|
80
80
|
handles.push(deps.watch(localesDir, {
|
|
81
81
|
filter: isMessagesChange,
|
|
82
82
|
onChange: async () => {
|
|
83
|
-
await regenerateMessages(localesDir, layout.messagesOut, layout.messagesBaseLng);
|
|
83
|
+
await regenerateMessages(localesDir, layout.messagesOut, layout.messagesBaseLng, messagesAppsOf(layout));
|
|
84
84
|
deps.log({ kind: 'regen', target: 'messages' });
|
|
85
85
|
},
|
|
86
86
|
onError: deps.onError,
|
|
@@ -111,7 +111,8 @@ export async function regenerateGaonOnce(layout, deps) {
|
|
|
111
111
|
// 결정 158(W2): locales/ 가 있으면 messages.d.ts 도 재생성한다(i18n 키 타입 브리지).
|
|
112
112
|
let messages = false;
|
|
113
113
|
if (layout.localesDir && deps.regenerateMessages) {
|
|
114
|
-
messages =
|
|
114
|
+
messages =
|
|
115
|
+
(await deps.regenerateMessages(layout.localesDir, layout.messagesOut, layout.messagesBaseLng, messagesAppsOf(layout))) === true;
|
|
115
116
|
}
|
|
116
117
|
// 결정 198(F-9 ②): 프론트 앱이 있으면 .env → env.d.ts. `.env` 부재는 throw(수리 안내).
|
|
117
118
|
let env = false;
|
|
@@ -120,6 +121,10 @@ export async function regenerateGaonOnce(layout, deps) {
|
|
|
120
121
|
}
|
|
121
122
|
return { tables: !!layout.schemaDir, apps: layout.apps.map((a) => a.name), messages, env };
|
|
122
123
|
}
|
|
124
|
+
/** 결정 454: 레이아웃의 앱 목록을 메시지 축 입력(카탈로그 모듈 경로)으로 좁힌다. */
|
|
125
|
+
function messagesAppsOf(layout) {
|
|
126
|
+
return layout.apps.map((a) => ({ name: a.name, localesDir: a.localesDir, catalogOut: a.catalogOut }));
|
|
127
|
+
}
|
|
123
128
|
/** cwd 관례로 프로젝트 레이아웃을 해석한다(존재하는 것만 포함). */
|
|
124
129
|
export function resolveDevLayout(cwd) {
|
|
125
130
|
const root = resolve(cwd);
|
|
@@ -142,6 +147,8 @@ export function resolveDevLayout(cwd) {
|
|
|
142
147
|
name: entry.name,
|
|
143
148
|
appDir,
|
|
144
149
|
routesOut: join(appDir, '.gaon', 'routes.d.ts'),
|
|
150
|
+
localesDir: join(appDir, 'locales'),
|
|
151
|
+
catalogOut: join(appDir, '.gaon', 'messages.catalog.ts'),
|
|
145
152
|
});
|
|
146
153
|
}
|
|
147
154
|
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { DoctorCheck } from '../types.js';
|
|
2
|
+
import type { FixerPlan } from './types.js';
|
|
3
|
+
/**
|
|
4
|
+
* i18n-layout 위반(레거시 단일 파일)을 폴더 레이아웃으로 옮기는 계획을 만든다.
|
|
5
|
+
* 내용은 그대로다 — 키 이름도 바뀌지 않는다(결정 454 R4).
|
|
6
|
+
*/
|
|
7
|
+
export declare function fixI18nLayout(issues: readonly DoctorCheck[], cwd: string): Promise<readonly FixerPlan[]>;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// @gaonjs/cli · doctor fixer · 레거시 단일 파일 카탈로그 → 폴더 레이아웃 이관 (결정 454)
|
|
2
|
+
//
|
|
3
|
+
// `locales/<로케일>.json` → `locales/<로케일>/backend.json` **이동만** 한다(rename 계획).
|
|
4
|
+
//
|
|
5
|
+
// **왜 전량 backend 인가(안전 방향 자동화)**: 자동 분류가 오판하면 서버 전용 문구를
|
|
6
|
+
// frontend 로 보내 클라 번들·CDN 캐시에 노출된다 — 되돌리기 어려운 방향의 사고다.
|
|
7
|
+
// 되돌리기 쉬운 방향(전량 backend = 클라에서 안 보임)으로만 자동화하고, 화면 문구를
|
|
8
|
+
// frontend.json 으로 옮기는 판단은 사람이 한다(doctor 경고가 그 다음 단계를 안내).
|
|
9
|
+
import { readFile } from 'node:fs/promises';
|
|
10
|
+
import { join } from 'node:path';
|
|
11
|
+
/**
|
|
12
|
+
* i18n-layout 위반(레거시 단일 파일)을 폴더 레이아웃으로 옮기는 계획을 만든다.
|
|
13
|
+
* 내용은 그대로다 — 키 이름도 바뀌지 않는다(결정 454 R4).
|
|
14
|
+
*/
|
|
15
|
+
export async function fixI18nLayout(issues, cwd) {
|
|
16
|
+
const plans = [];
|
|
17
|
+
for (const issue of issues) {
|
|
18
|
+
const to = issue.detail?.to;
|
|
19
|
+
if (typeof to !== 'string' || issue.file === undefined)
|
|
20
|
+
continue;
|
|
21
|
+
let before;
|
|
22
|
+
try {
|
|
23
|
+
before = await readFile(join(cwd, issue.file), 'utf8');
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
continue; // 그 사이 파일이 사라졌으면 계획에서 제외(상위가 부분 적용을 감당하지 않게).
|
|
27
|
+
}
|
|
28
|
+
plans.push({
|
|
29
|
+
kind: 'rename',
|
|
30
|
+
file: issue.file,
|
|
31
|
+
to,
|
|
32
|
+
before,
|
|
33
|
+
summary: `카탈로그를 폴더 레이아웃으로 이동: ${issue.file} → ${to} (결정 454)\n` +
|
|
34
|
+
` 내용·키는 그대로다. 이 상태에서는 **서버 전용**(클라로 나가지 않음)이다.\n` +
|
|
35
|
+
` → 화면에 보이는 문구만 같은 폴더의 frontend.json 으로 옮긴 뒤 gaon gen 을 실행하세요.`,
|
|
36
|
+
refEdits: [], // JSON 카탈로그는 소스가 import 하지 않는다(경로 참조 갱신 없음).
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
return plans;
|
|
40
|
+
}
|
|
@@ -5,6 +5,7 @@ export { fixDependencyDirection, fixDomainToSharedTypeOnly, } from './dependency
|
|
|
5
5
|
export { fixSchemaFilename, rewriteSchemaImport } from './schema-filename.js';
|
|
6
6
|
export { fixModelFilename, rewriteModelImport } from './model-filename.js';
|
|
7
7
|
export { fixSealClientWiring, wireSealClient } from './seal-client-wiring.js';
|
|
8
|
+
export { fixI18nLayout } from './i18n-layout.js';
|
|
8
9
|
/**
|
|
9
10
|
* 규칙별 fixer 표. 없는 규칙(값 undefined)은 "수동 수정 필요" 로 리포트된다.
|
|
10
11
|
* 새 fixer 를 만들면 이 표에만 추가하면 된다(runDoctorFix 는 표를 순회).
|
|
@@ -10,10 +10,12 @@ import { fixDependencyDirection } from './dependency-direction.js';
|
|
|
10
10
|
import { fixSchemaFilename } from './schema-filename.js';
|
|
11
11
|
import { fixModelFilename } from './model-filename.js';
|
|
12
12
|
import { fixSealClientWiring } from './seal-client-wiring.js';
|
|
13
|
+
import { fixI18nLayout } from './i18n-layout.js';
|
|
13
14
|
export { fixDependencyDirection, fixDomainToSharedTypeOnly, } from './dependency-direction.js';
|
|
14
15
|
export { fixSchemaFilename, rewriteSchemaImport } from './schema-filename.js';
|
|
15
16
|
export { fixModelFilename, rewriteModelImport } from './model-filename.js';
|
|
16
17
|
export { fixSealClientWiring, wireSealClient } from './seal-client-wiring.js';
|
|
18
|
+
export { fixI18nLayout } from './i18n-layout.js';
|
|
17
19
|
/**
|
|
18
20
|
* 규칙별 fixer 표. 없는 규칙(값 undefined)은 "수동 수정 필요" 로 리포트된다.
|
|
19
21
|
* 새 fixer 를 만들면 이 표에만 추가하면 된다(runDoctorFix 는 표를 순회).
|
|
@@ -23,6 +25,7 @@ export const FIXERS = {
|
|
|
23
25
|
'schema-filename': fixSchemaFilename,
|
|
24
26
|
'model-filename': fixModelFilename,
|
|
25
27
|
'seal-security': fixSealClientWiring,
|
|
28
|
+
'i18n-layout': fixI18nLayout,
|
|
26
29
|
};
|
|
27
30
|
/**
|
|
28
31
|
* 규칙별 fix 지원 여부 카탈로그. 리포트가 사용자에게 무엇이 자동 · 무엇이
|
|
@@ -35,6 +38,16 @@ export const FIXERS = {
|
|
|
35
38
|
* (`fixers/capabilities.test.ts` · ALL_RULES ↔ 카탈로그 대칭).
|
|
36
39
|
*/
|
|
37
40
|
export const FIXER_CAPABILITIES = [
|
|
41
|
+
{
|
|
42
|
+
rule: 'i18n-layout',
|
|
43
|
+
hasFixer: true,
|
|
44
|
+
note: '자동 · locales/<로케일>.json → locales/<로케일>/backend.json 이동(결정 454). 화면 문구를 frontend.json 으로 나누는 것은 노출 방향이라 사람이 판단한다.',
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
rule: 'i18n-app-scope',
|
|
48
|
+
hasFixer: false,
|
|
49
|
+
note: '수동 · 그 키를 이 앱 카탈로그(apps/<앱>/locales/<로케일>.json) 또는 공용 locales/<로케일>/frontend.json 에 추가(어느 앱 문구인지는 설계 판단).',
|
|
50
|
+
},
|
|
38
51
|
{
|
|
39
52
|
rule: 'response-mixing',
|
|
40
53
|
hasFixer: false,
|
|
@@ -195,4 +208,9 @@ export const FIXER_CAPABILITIES = [
|
|
|
195
208
|
hasFixer: false,
|
|
196
209
|
note: '수동 · 공유 .env 에서 NODE_ENV 줄을 지우세요 — 모드는 명령이 정합니다(gaon dev=development · gaon serve=production · 결정 430). 지웠을 때 어떤 모드로 돌리려던 것인지는 사람이 알아야 해서 자동 정정하지 않습니다.',
|
|
197
210
|
},
|
|
211
|
+
{
|
|
212
|
+
rule: 'page-fetch',
|
|
213
|
+
hasFixer: false,
|
|
214
|
+
note: '수동 · raw fetch 를 api()/useForm 으로 바꾸려면 대응 라우트 키(<앱>:<컨트롤러>#<액션>) 추론이 필요해 기계 변환이 불가합니다 — JSON 액션은 api(), 폼은 useForm, 부득이한 커스텀 전송은 readCsrfToken() 탈출구를 쓰세요(결정 453).',
|
|
215
|
+
},
|
|
198
216
|
];
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { RuleReport } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* 주석을 공백으로 지운다(문자열 리터럴 안의 `//`·`/*` 는 보존). 스캐폴드·문서 주석이
|
|
4
|
+
* 예시로 적은 `t('키')` 를 실 호출로 오인해 **오탐 에러**를 내던 것을 막는다(브라우저 e2e
|
|
5
|
+
* 게이트가 실제로 잡았다). 길이를 보존해 라인 번호 계산이 어긋나지 않는다.
|
|
6
|
+
*/
|
|
7
|
+
export declare function stripComments(src: string): string;
|
|
8
|
+
export declare function checkI18nAppScope(cwd: string): Promise<RuleReport>;
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
// @gaonjs/cli · doctor · 앱 경계를 넘는 클라 번역 키 (결정 454 · 에러)
|
|
2
|
+
//
|
|
3
|
+
// **왜 doctor 가 필요한가(타입으로 못 잡는 축)**: 두 앱이 `GaonMessages.keys` 를 서로 다른
|
|
4
|
+
// 유니온으로 augment 하면 TS2717(Subsequent property declarations must have the same type)이
|
|
5
|
+
// 나고, `gaon check` 는 단일 tsc 프로그램이라 앱별 클라 키 유니온을 만들 수 없다(실측 · 결정 454).
|
|
6
|
+
// 그래서 클라 키 유니온은 **프로젝트 전체 frontend 합집합**이고, 앱 경계는 두 층이 강제한다:
|
|
7
|
+
// · 런타임 = 앱 청크에 그 앱 카탈로그만 실린다(물리적).
|
|
8
|
+
// · 정적 = 이 검사 — 앱 코드가 **다른 앱 전용 키**를 쓰면 에러로 잡는다.
|
|
9
|
+
//
|
|
10
|
+
// 그냥 두면 컴파일은 통과하고 화면에는 키 문자열이 그대로 뜬다(조용한 실패).
|
|
11
|
+
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
|
|
12
|
+
import { join, relative } from 'node:path';
|
|
13
|
+
import { flattenKeys, loadAppLocales, loadScopedLocales, withPluralBaseKeys } from '@gaonjs/i18n';
|
|
14
|
+
import { analyzeProjectI18n, resolveLocalesDir } from '../i18n-config.js';
|
|
15
|
+
// `import { t } from 'gaonjs/vue'` 를 쓴 파일에서만 본다 — 서버 t(gaonjs/i18n)는 대상이 아니다.
|
|
16
|
+
const CLIENT_T_IMPORT = /from\s+['"]gaonjs\/vue['"]/;
|
|
17
|
+
// t('key') · t("key") · t(`key`) 의 리터럴 키만 본다(동적 키는 정적 검출 대상 아님).
|
|
18
|
+
const T_CALL = /\bt\(\s*['"`]([^'"`]+)['"`]/g;
|
|
19
|
+
/**
|
|
20
|
+
* 주석을 공백으로 지운다(문자열 리터럴 안의 `//`·`/*` 는 보존). 스캐폴드·문서 주석이
|
|
21
|
+
* 예시로 적은 `t('키')` 를 실 호출로 오인해 **오탐 에러**를 내던 것을 막는다(브라우저 e2e
|
|
22
|
+
* 게이트가 실제로 잡았다). 길이를 보존해 라인 번호 계산이 어긋나지 않는다.
|
|
23
|
+
*/
|
|
24
|
+
export function stripComments(src) {
|
|
25
|
+
const out = src.split('');
|
|
26
|
+
let i = 0;
|
|
27
|
+
let state = 'code';
|
|
28
|
+
const blank = (from, to) => {
|
|
29
|
+
for (let k = from; k < to; k++)
|
|
30
|
+
if (out[k] !== '\n')
|
|
31
|
+
out[k] = ' ';
|
|
32
|
+
};
|
|
33
|
+
while (i < src.length) {
|
|
34
|
+
const two = src.slice(i, i + 2);
|
|
35
|
+
if (state === 'code') {
|
|
36
|
+
if (two === '//') {
|
|
37
|
+
const end = src.indexOf('\n', i);
|
|
38
|
+
const stop = end === -1 ? src.length : end;
|
|
39
|
+
blank(i, stop);
|
|
40
|
+
i = stop;
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
if (two === '/*') {
|
|
44
|
+
const end = src.indexOf('*/', i + 2);
|
|
45
|
+
const stop = end === -1 ? src.length : end + 2;
|
|
46
|
+
blank(i, stop);
|
|
47
|
+
i = stop;
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
if (src.startsWith('<!--', i)) {
|
|
51
|
+
const end = src.indexOf('-->', i + 4);
|
|
52
|
+
const stop = end === -1 ? src.length : end + 3;
|
|
53
|
+
blank(i, stop);
|
|
54
|
+
i = stop;
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
const c = src[i];
|
|
58
|
+
if (c === "'")
|
|
59
|
+
state = 'single';
|
|
60
|
+
else if (c === '"')
|
|
61
|
+
state = 'double';
|
|
62
|
+
else if (c === '`')
|
|
63
|
+
state = 'tick';
|
|
64
|
+
i++;
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
// 문자열 안 — 이스케이프를 건너뛰고 닫는 따옴표에서 code 로 돌아간다.
|
|
68
|
+
const c = src[i];
|
|
69
|
+
if (c === '\\') {
|
|
70
|
+
i += 2;
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
if ((state === 'single' && c === "'") || (state === 'double' && c === '"') || (state === 'tick' && c === '`')) {
|
|
74
|
+
state = 'code';
|
|
75
|
+
}
|
|
76
|
+
i++;
|
|
77
|
+
}
|
|
78
|
+
return out.join('');
|
|
79
|
+
}
|
|
80
|
+
const SOURCE_EXT = ['.vue', '.ts'];
|
|
81
|
+
const SKIP_DIRS = new Set(['node_modules', 'dist', '.gaon']);
|
|
82
|
+
function walk(dir, out = []) {
|
|
83
|
+
if (!existsSync(dir))
|
|
84
|
+
return out;
|
|
85
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
86
|
+
if (entry.name.startsWith('.') && entry.name !== '.gaon')
|
|
87
|
+
continue;
|
|
88
|
+
if (SKIP_DIRS.has(entry.name))
|
|
89
|
+
continue;
|
|
90
|
+
const p = join(dir, entry.name);
|
|
91
|
+
if (entry.isDirectory())
|
|
92
|
+
walk(p, out);
|
|
93
|
+
else if (SOURCE_EXT.some((e) => entry.name.endsWith(e)))
|
|
94
|
+
out.push(p);
|
|
95
|
+
}
|
|
96
|
+
return out;
|
|
97
|
+
}
|
|
98
|
+
/** 그 앱의 클라가 실제로 받는 키 집합 = 공용 frontend ∪ 자기 앱 카탈로그(전 로케일 합집합). */
|
|
99
|
+
function keysForApp(localesDir, appLocalesDir) {
|
|
100
|
+
const scoped = loadScopedLocales(localesDir);
|
|
101
|
+
const keys = new Set();
|
|
102
|
+
for (const res of Object.values(scoped.frontend)) {
|
|
103
|
+
for (const k of withPluralBaseKeys(flattenKeys(res.translation)))
|
|
104
|
+
keys.add(k);
|
|
105
|
+
}
|
|
106
|
+
for (const res of Object.values(loadAppLocales(appLocalesDir))) {
|
|
107
|
+
for (const k of withPluralBaseKeys(flattenKeys(res.translation)))
|
|
108
|
+
keys.add(k);
|
|
109
|
+
}
|
|
110
|
+
return keys;
|
|
111
|
+
}
|
|
112
|
+
/** 프로젝트 전체(모든 앱)의 클라 키 — "다른 앱에는 있는 키" 인지 구분해 안내를 정확히 한다. */
|
|
113
|
+
function allClientKeys(localesDir, appDirs) {
|
|
114
|
+
const keys = keysForApp(localesDir, '');
|
|
115
|
+
for (const appDir of appDirs) {
|
|
116
|
+
for (const res of Object.values(loadAppLocales(join(appDir, 'locales')))) {
|
|
117
|
+
for (const k of withPluralBaseKeys(flattenKeys(res.translation)))
|
|
118
|
+
keys.add(k);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return keys;
|
|
122
|
+
}
|
|
123
|
+
export async function checkI18nAppScope(cwd) {
|
|
124
|
+
const appsDir = join(cwd, 'apps');
|
|
125
|
+
if (!existsSync(appsDir))
|
|
126
|
+
return { rule: 'i18n-app-scope', issues: [] };
|
|
127
|
+
const cfg = analyzeProjectI18n(cwd);
|
|
128
|
+
const localesDir = resolveLocalesDir(cwd, cfg.dir);
|
|
129
|
+
if (!existsSync(localesDir))
|
|
130
|
+
return { rule: 'i18n-app-scope', issues: [] };
|
|
131
|
+
const appDirs = readdirSync(appsDir, { withFileTypes: true })
|
|
132
|
+
.filter((e) => e.isDirectory())
|
|
133
|
+
.map((e) => join(appsDir, e.name));
|
|
134
|
+
// frontend 카탈로그가 하나도 없으면(레거시 레이아웃) 클라 t() 자체가 없다 — 무소음.
|
|
135
|
+
const scoped = loadScopedLocales(localesDir);
|
|
136
|
+
if (Object.keys(scoped.frontend).length === 0)
|
|
137
|
+
return { rule: 'i18n-app-scope', issues: [] };
|
|
138
|
+
const everyKey = allClientKeys(localesDir, appDirs);
|
|
139
|
+
const issues = [];
|
|
140
|
+
for (const appDir of appDirs) {
|
|
141
|
+
const appLocales = join(appDir, 'locales');
|
|
142
|
+
const own = keysForApp(localesDir, appLocales);
|
|
143
|
+
for (const file of walk(appDir)) {
|
|
144
|
+
if (!statSync(file).isFile())
|
|
145
|
+
continue;
|
|
146
|
+
const raw = readFileSync(file, 'utf8');
|
|
147
|
+
if (!CLIENT_T_IMPORT.test(raw))
|
|
148
|
+
continue;
|
|
149
|
+
// 주석 안 예시(`t('키')` 같은 설명)를 실 호출로 세지 않는다 — 오탐은 빌드를 세운다.
|
|
150
|
+
const src = stripComments(raw);
|
|
151
|
+
for (const m of src.matchAll(T_CALL)) {
|
|
152
|
+
const key = m[1];
|
|
153
|
+
if (own.has(key))
|
|
154
|
+
continue;
|
|
155
|
+
const line = src.slice(0, m.index).split('\n').length;
|
|
156
|
+
const rel = relative(cwd, file);
|
|
157
|
+
const elsewhere = everyKey.has(key);
|
|
158
|
+
issues.push({
|
|
159
|
+
rule: 'i18n-app-scope',
|
|
160
|
+
level: 'error',
|
|
161
|
+
file: rel,
|
|
162
|
+
line,
|
|
163
|
+
message: elsewhere
|
|
164
|
+
? `'${key}' 는 **다른 앱 전용** 번역 키입니다 — 이 앱의 카탈로그에 없습니다: ${rel}:${line} (결정 454)\n` +
|
|
165
|
+
` 앱 번들에는 그 앱 카탈로그만 실리므로 런타임에 번역 대신 키 문자열이 그대로 보입니다.\n` +
|
|
166
|
+
`→ 이 앱에서도 쓸 문구면 공용 ${relative(cwd, join(localesDir, '<로케일>', 'frontend.json'))} 로 옮기고,\n` +
|
|
167
|
+
`→ 이 앱 전용 문구면 ${relative(cwd, join(appLocales, '<로케일>.json'))} 에 추가한 뒤 gaon gen 을 실행하세요.`
|
|
168
|
+
: `'${key}' 를 어느 카탈로그에서도 찾을 수 없습니다: ${rel}:${line} (결정 454)\n` +
|
|
169
|
+
`→ ${relative(cwd, join(appLocales, '<로케일>.json'))}(앱 전용) 또는 ` +
|
|
170
|
+
`${relative(cwd, join(localesDir, '<로케일>', 'frontend.json'))}(앱 공용)에 키를 추가하고 gaon gen 을 실행하세요.\n` +
|
|
171
|
+
` (서버 전용 문구는 backend.json 에 두고 서버 t()(gaonjs/i18n)로 쓰세요 — 클라로 나가지 않습니다.)`,
|
|
172
|
+
detail: { key, app: relative(cwd, appDir), definedInAnotherApp: elsewhere },
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
return { rule: 'i18n-app-scope', issues };
|
|
178
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// @gaonjs/cli · doctor · i18n 카탈로그 레이아웃 안내 (결정 454 · 경고)
|
|
2
|
+
//
|
|
3
|
+
// 결정 454 의 정본 저작 레이아웃은 언어별 폴더 + 스코프 분리다:
|
|
4
|
+
// locales/<로케일>/backend.json — 메일·잡·검증 등 **서버 전용**(클라로 안 나감)
|
|
5
|
+
// locales/<로케일>/frontend.json — 화면 문구(클라 t() 가 쓰는 카탈로그)
|
|
6
|
+
//
|
|
7
|
+
// 레거시 단일 파일(`locales/<로케일>.json`)도 계속 **동작**하지만, 미분류 카탈로그는
|
|
8
|
+
// 안전 기본으로 전량 backend 취급이라 **클라 `t()` 를 쓸 수 없다**(클라 키 축 미생성).
|
|
9
|
+
// 그 사실을 조용히 두면 "왜 gaonjs/vue 의 t 가 키를 그대로 뱉지?" 로 시간을 태운다 —
|
|
10
|
+
// 경고로 짚고 `--fix` 로 폴더 이관까지 해 준다(노출 방향 자동 분류는 하지 않는다).
|
|
11
|
+
import { existsSync, readdirSync } from 'node:fs';
|
|
12
|
+
import { basename, join, relative } from 'node:path';
|
|
13
|
+
import { analyzeProjectI18n, resolveLocalesDir } from '../i18n-config.js';
|
|
14
|
+
/** 레거시 단일 파일 로케일 목록(폴더 레이아웃과 공존하면 로더가 별도로 fail-loud). */
|
|
15
|
+
export function legacyLocaleFiles(localesDir) {
|
|
16
|
+
if (!existsSync(localesDir))
|
|
17
|
+
return [];
|
|
18
|
+
return readdirSync(localesDir, { withFileTypes: true })
|
|
19
|
+
.filter((e) => e.isFile() && e.name.endsWith('.json'))
|
|
20
|
+
.map((e) => basename(e.name, '.json'))
|
|
21
|
+
.sort();
|
|
22
|
+
}
|
|
23
|
+
export async function checkI18nLayout(cwd) {
|
|
24
|
+
const cfg = analyzeProjectI18n(cwd);
|
|
25
|
+
const localesDir = resolveLocalesDir(cwd, cfg.dir);
|
|
26
|
+
if (!existsSync(localesDir))
|
|
27
|
+
return { rule: 'i18n-layout', issues: [] };
|
|
28
|
+
const legacy = legacyLocaleFiles(localesDir);
|
|
29
|
+
if (legacy.length === 0)
|
|
30
|
+
return { rule: 'i18n-layout', issues: [] };
|
|
31
|
+
const issues = legacy.map((lng) => {
|
|
32
|
+
const rel = relative(cwd, join(localesDir, `${lng}.json`));
|
|
33
|
+
const dirRel = relative(cwd, join(localesDir, lng));
|
|
34
|
+
return {
|
|
35
|
+
rule: 'i18n-layout',
|
|
36
|
+
level: 'warning',
|
|
37
|
+
file: rel,
|
|
38
|
+
message: `로케일 '${lng}' 이 단일 파일 레이아웃입니다: ${rel} (결정 454)\n` +
|
|
39
|
+
` 이 카탈로그는 **서버 전용**으로 취급돼 클라이언트 t()(gaonjs/vue)가 쓸 수 없습니다.\n` +
|
|
40
|
+
`→ 자동 이관: gaon doctor --fix (${rel} → ${dirRel}/backend.json)\n` +
|
|
41
|
+
`→ 그 뒤 화면에 보이는 문구만 ${dirRel}/frontend.json 으로 옮기고 gaon gen 을 실행하세요.\n` +
|
|
42
|
+
` (frontend 만 클라 번들 청크로 나갑니다 — backend 문구는 노출되지 않습니다.)`,
|
|
43
|
+
detail: { locale: lng, from: rel, to: `${dirRel}/backend.json` },
|
|
44
|
+
};
|
|
45
|
+
});
|
|
46
|
+
return { rule: 'i18n-layout', issues };
|
|
47
|
+
}
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
// 빌드를 세우지 않는다. --json 은 detail.locale·detail.missing 으로 구조화한다.
|
|
11
11
|
import { existsSync } from 'node:fs';
|
|
12
12
|
import { join, relative } from 'node:path';
|
|
13
|
-
import {
|
|
13
|
+
import { loadScopedLocales, flattenKeys } from '@gaonjs/i18n';
|
|
14
14
|
import { analyzeProjectI18n, resolveLocalesDir } from '../i18n-config.js';
|
|
15
15
|
// 경고 한 줄이 폭주하지 않게 나열 상한 — 넘으면 "…외 N개" 로 접는다(detail 에는 전량).
|
|
16
16
|
const MAX_KEYS_SHOWN = 20;
|
|
@@ -32,10 +32,22 @@ export async function checkLocaleParity(cwd) {
|
|
|
32
32
|
const localesDir = resolveLocalesDir(cwd, analyzeProjectI18n(cwd).dir);
|
|
33
33
|
if (!existsSync(localesDir))
|
|
34
34
|
return { rule: 'locale-parity', issues: [] };
|
|
35
|
-
|
|
35
|
+
// 결정 454: 스코프(backend·frontend)별로 따로 비교한다 — 같은 키를 로케일마다 다른
|
|
36
|
+
// 스코프에 두면(ko 는 frontend, ja 는 backend) 클라 카탈로그가 갈리므로, 병합본으로
|
|
37
|
+
// 비교하면 그 배치 불일치를 못 본다. 파일 경로도 스코프별로 정확히 짚는다.
|
|
38
|
+
const scoped = loadScopedLocales(localesDir);
|
|
39
|
+
const issues = [];
|
|
40
|
+
for (const scope of ['backend', 'frontend']) {
|
|
41
|
+
const resources = scoped[scope];
|
|
42
|
+
issues.push(...parityIssues(cwd, localesDir, resources, scoped.layout, scope));
|
|
43
|
+
}
|
|
44
|
+
return { rule: 'locale-parity', issues };
|
|
45
|
+
}
|
|
46
|
+
/** 한 스코프 안에서 로케일 간 키 diff 를 계산한다(레거시 단일 파일은 backend 스코프에 담긴다). */
|
|
47
|
+
function parityIssues(cwd, localesDir, resources, layout, scope) {
|
|
36
48
|
const langs = Object.keys(resources).sort();
|
|
37
49
|
if (langs.length < 2)
|
|
38
|
-
return
|
|
50
|
+
return [];
|
|
39
51
|
// 로케일별 키 집합과 전체 union(어느 로케일에든 등장한 키의 합집합)을 만든다.
|
|
40
52
|
// 복수형 접미사는 base 로 정규화해 로케일별 정당한 복수형 차이를 오탐으로 잡지 않는다.
|
|
41
53
|
const keysByLang = new Map();
|
|
@@ -52,7 +64,10 @@ export async function checkLocaleParity(cwd) {
|
|
|
52
64
|
const missing = [...union].filter((k) => !have.has(k)).sort();
|
|
53
65
|
if (missing.length === 0)
|
|
54
66
|
continue;
|
|
55
|
-
|
|
67
|
+
// 파일 경로는 레이아웃에 맞춘다 — 폴더 분리면 <lng>/<scope>.json, 레거시면 <lng>.json.
|
|
68
|
+
const rel = layout[lng] === 'legacy'
|
|
69
|
+
? relative(cwd, join(localesDir, `${lng}.json`))
|
|
70
|
+
: relative(cwd, join(localesDir, lng, `${scope}.json`));
|
|
56
71
|
const shown = missing.slice(0, MAX_KEYS_SHOWN);
|
|
57
72
|
const more = missing.length - shown.length;
|
|
58
73
|
const list = shown.map((k) => `'${k}'`).join(', ') + (more > 0 ? ` …외 ${more}개` : '');
|
|
@@ -64,8 +79,8 @@ export async function checkLocaleParity(cwd) {
|
|
|
64
79
|
` 누락 키: ${list}\n` +
|
|
65
80
|
`→ ${rel} 에 이 키들을 채우세요 — 없으면 '${lng}' 사용자에게 fallback(대개 다른 언어)\n` +
|
|
66
81
|
` 번역이 조용히 노출됩니다. messages.d.ts 는 기준 로케일 기준이라 컴파일로 못 잡습니다(결정 216).`,
|
|
67
|
-
detail: { locale: lng, missing },
|
|
82
|
+
detail: { locale: lng, scope, missing },
|
|
68
83
|
});
|
|
69
84
|
}
|
|
70
|
-
return
|
|
85
|
+
return issues;
|
|
71
86
|
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { RuleReport } from './types.js';
|
|
2
|
+
/** 소스에서 내부 경로 raw fetch 호출을 찾는다(단위 테스트 진입점 · 주석 제외). */
|
|
3
|
+
export declare function internalFetchCalls(source: string): {
|
|
4
|
+
line: number;
|
|
5
|
+
path: string;
|
|
6
|
+
}[];
|
|
7
|
+
/** 세션 앱(비 JWT)의 .vue 를 훑어 내부 경로 raw fetch 를 경고로 낸다. */
|
|
8
|
+
export declare function checkPageFetch(cwd: string): Promise<RuleReport>;
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// @gaonjs/cli · doctor · 세션 앱 프론트의 내부 경로 raw fetch 검출 (결정 453 · 경고)
|
|
2
|
+
//
|
|
3
|
+
// 세션 앱의 `.vue` 에서 앱 내부 경로를 raw `fetch('/...')` 로 부르면 CSRF
|
|
4
|
+
// 토큰이 어디에도 실리지 않아 상태 변경 요청(POST/PUT/PATCH/DELETE)이 403
|
|
5
|
+
// 으로 죽는다 — 자동 부착(결정 166·341·342)은 `api()`·`useForm`·`router` 를
|
|
6
|
+
// 탈 때만 작동한다(rooms 샘플 실사용에서 강퇴/위임이 이 경로로 죽었다).
|
|
7
|
+
// csrf-wiring(결정 93)은 서버 세션 배선만 보므로 클라이언트 우회는 이 검사가
|
|
8
|
+
// 잡는다. 폼은 `useForm`, JSON 액션은 `api()` 가 정본(agents/frontend.md §2).
|
|
9
|
+
//
|
|
10
|
+
// 오탐 방지: 첫 인자가 `/` 로 시작하는 **문자열/템플릿 리터럴**일 때만 —
|
|
11
|
+
// 외부 URL(http…)·변수/식 인자(정적 판별 불가)·`//`(프로토콜-상대)는 건드리지
|
|
12
|
+
// 않는다. JWT/API 앱은 토큰 인증이라 REST + fetch 가 정본 경로 — 제외한다.
|
|
13
|
+
import { readdir, readFile } from 'node:fs/promises';
|
|
14
|
+
import { existsSync } from 'node:fs';
|
|
15
|
+
import { join, relative } from 'node:path';
|
|
16
|
+
import { usesJwtStrategy } from './auth-wiring.js';
|
|
17
|
+
// 주석을 공백 치환하되 줄바꿈은 보존(라인 번호 유지) — internal-anchor 와 동형.
|
|
18
|
+
function stripCommentsKeepLines(source) {
|
|
19
|
+
const blank = (m) => m.replace(/[^\n]/g, ' ');
|
|
20
|
+
return source
|
|
21
|
+
.replace(/\/\*[\s\S]*?\*\//g, blank)
|
|
22
|
+
.replace(/<!--[\s\S]*?-->/g, blank)
|
|
23
|
+
.replace(/(^|[^:])\/\/[^\n]*/g, (_m, p1) => p1 + ' '.repeat(_m.length - p1.length));
|
|
24
|
+
}
|
|
25
|
+
// `fetch(` 의 첫 인자가 내부 경로 리터럴인 호출만 잡는다. 앞이 식별자/점이면
|
|
26
|
+
// (`myFetch(`·`api.fetch(`) 다른 함수다 — 전역 fetch 호출만 본다.
|
|
27
|
+
const INTERNAL_FETCH = /(?<![.\w$])fetch\s*\(\s*(['"`])(\/(?!\/)[^'"`\n]*)\1/g;
|
|
28
|
+
/** 소스에서 내부 경로 raw fetch 호출을 찾는다(단위 테스트 진입점 · 주석 제외). */
|
|
29
|
+
export function internalFetchCalls(source) {
|
|
30
|
+
const stripped = stripCommentsKeepLines(source);
|
|
31
|
+
const out = [];
|
|
32
|
+
for (const m of stripped.matchAll(INTERNAL_FETCH)) {
|
|
33
|
+
const line = stripped.slice(0, m.index ?? 0).split('\n').length;
|
|
34
|
+
out.push({ line, path: m[2] });
|
|
35
|
+
}
|
|
36
|
+
return out;
|
|
37
|
+
}
|
|
38
|
+
/** 세션 앱(비 JWT)의 .vue 를 훑어 내부 경로 raw fetch 를 경고로 낸다. */
|
|
39
|
+
export async function checkPageFetch(cwd) {
|
|
40
|
+
const appsDir = join(cwd, 'apps');
|
|
41
|
+
const issues = [];
|
|
42
|
+
for (const app of await safeListDirs(appsDir)) {
|
|
43
|
+
// JWT/API 앱은 REST + fetch 가 정본 — 제외(csrf-wiring 과 동일 판정).
|
|
44
|
+
const acPath = join(appsDir, app, 'app.config.ts');
|
|
45
|
+
if (existsSync(acPath)) {
|
|
46
|
+
const acSource = await readFile(acPath, 'utf8').catch(() => '');
|
|
47
|
+
if (usesJwtStrategy(acSource))
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
for (const abs of await walkVue(join(appsDir, app))) {
|
|
51
|
+
const source = await readFile(abs, 'utf8').catch(() => '');
|
|
52
|
+
const rel = relative(cwd, abs);
|
|
53
|
+
for (const hit of internalFetchCalls(source)) {
|
|
54
|
+
issues.push({
|
|
55
|
+
rule: 'page-fetch',
|
|
56
|
+
level: 'warning',
|
|
57
|
+
file: rel,
|
|
58
|
+
line: hit.line,
|
|
59
|
+
message: `내부 경로 raw fetch 발견: ${rel}:${hit.line} 이 \`fetch('${hit.path}')\` 로 앱 내부를 ` +
|
|
60
|
+
`직접 호출합니다. 세션 앱의 CSRF 토큰 자동 부착(결정 166·341·342)은 \`api()\`·\`useForm\`·` +
|
|
61
|
+
`\`router\` 를 탈 때만 작동해, raw fetch 의 상태 변경 요청은 403 으로 죽습니다(결정 453).\n` +
|
|
62
|
+
`→ JSON 액션(강퇴·위임·좋아요 등 커맨드 포함)은 \`gaonjs/vue\` 의 \`api()\` 로 부르세요: ` +
|
|
63
|
+
`\`await api('<앱>:<컨트롤러>#<액션>', { ...params })\` (agents/frontend.md §2).\n` +
|
|
64
|
+
`→ 폼 제출은 \`useForm(...).post()\`, DELETE 등은 \`router.delete(...)\` (agents/web.md §4).\n` +
|
|
65
|
+
`→ 부득이한 커스텀 전송은 \`readCsrfToken()\` 으로 토큰을 직접 실으세요(탈출구 · 결정 342).`,
|
|
66
|
+
detail: { file: rel, line: hit.line, path: hit.path },
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return { rule: 'page-fetch', issues };
|
|
72
|
+
}
|
|
73
|
+
async function safeListDirs(dir) {
|
|
74
|
+
try {
|
|
75
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
76
|
+
return entries.filter((e) => e.isDirectory()).map((e) => e.name);
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
return [];
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
/** 앱 하위 .vue 절대경로(내림차순 아님 · node_modules/.gaon 제외). */
|
|
83
|
+
async function walkVue(dir) {
|
|
84
|
+
const out = [];
|
|
85
|
+
const walk = async (d) => {
|
|
86
|
+
let entries;
|
|
87
|
+
try {
|
|
88
|
+
entries = await readdir(d, { withFileTypes: true });
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
for (const e of entries) {
|
|
94
|
+
const abs = join(d, e.name);
|
|
95
|
+
if (e.isDirectory()) {
|
|
96
|
+
if (e.name === 'node_modules' || e.name === '.gaon')
|
|
97
|
+
continue;
|
|
98
|
+
await walk(abs);
|
|
99
|
+
}
|
|
100
|
+
else if (e.isFile() && e.name.endsWith('.vue')) {
|
|
101
|
+
out.push(abs);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
};
|
|
105
|
+
await walk(dir);
|
|
106
|
+
return out.sort();
|
|
107
|
+
}
|