@mandujs/core 0.3.3 → 0.4.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/package.json +2 -1
- package/src/bundler/build.ts +609 -0
- package/src/bundler/index.ts +7 -0
- package/src/bundler/types.ts +100 -0
- package/src/client/index.ts +68 -0
- package/src/client/island.ts +197 -0
- package/src/client/runtime.ts +335 -0
- package/src/filling/filling.ts +76 -5
- package/src/index.ts +2 -0
- package/src/runtime/ssr.ts +142 -2
- package/src/slot/corrector.ts +282 -0
- package/src/slot/index.ts +18 -0
- package/src/slot/validator.ts +241 -0
- package/src/spec/schema.ts +132 -0
package/src/filling/filling.ts
CHANGED
|
@@ -15,10 +15,14 @@ export type Guard = (ctx: ManduContext) => symbol | Response | Promise<symbol |
|
|
|
15
15
|
/** HTTP methods */
|
|
16
16
|
export type HttpMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE" | "HEAD" | "OPTIONS";
|
|
17
17
|
|
|
18
|
-
|
|
18
|
+
/** Loader function type - SSR 데이터 로딩 */
|
|
19
|
+
export type Loader<T = unknown> = (ctx: ManduContext) => T | Promise<T>;
|
|
20
|
+
|
|
21
|
+
interface FillingConfig<TLoaderData = unknown> {
|
|
19
22
|
handlers: Map<HttpMethod, Handler>;
|
|
20
23
|
guards: Guard[];
|
|
21
24
|
methodGuards: Map<HttpMethod, Guard[]>;
|
|
25
|
+
loader?: Loader<TLoaderData>;
|
|
22
26
|
}
|
|
23
27
|
|
|
24
28
|
/**
|
|
@@ -30,14 +34,64 @@ interface FillingConfig {
|
|
|
30
34
|
* .get(ctx => ctx.ok({ message: 'Hello!' }))
|
|
31
35
|
* .post(ctx => ctx.created({ id: 1 }))
|
|
32
36
|
* ```
|
|
37
|
+
*
|
|
38
|
+
* @example with loader
|
|
39
|
+
* ```typescript
|
|
40
|
+
* export default Mandu.filling<{ todos: Todo[] }>()
|
|
41
|
+
* .loader(async (ctx) => {
|
|
42
|
+
* const todos = await db.todos.findMany();
|
|
43
|
+
* return { todos };
|
|
44
|
+
* })
|
|
45
|
+
* .get(ctx => ctx.ok(ctx.get('loaderData')))
|
|
46
|
+
* ```
|
|
33
47
|
*/
|
|
34
|
-
export class ManduFilling {
|
|
35
|
-
private config: FillingConfig = {
|
|
48
|
+
export class ManduFilling<TLoaderData = unknown> {
|
|
49
|
+
private config: FillingConfig<TLoaderData> = {
|
|
36
50
|
handlers: new Map(),
|
|
37
51
|
guards: [],
|
|
38
52
|
methodGuards: new Map(),
|
|
39
53
|
};
|
|
40
54
|
|
|
55
|
+
// ============================================
|
|
56
|
+
// 🥟 SSR Loader
|
|
57
|
+
// ============================================
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Define SSR data loader
|
|
61
|
+
* 페이지 렌더링 전 서버에서 데이터를 로드합니다.
|
|
62
|
+
* 로드된 데이터는 클라이언트로 전달되어 hydration에 사용됩니다.
|
|
63
|
+
*
|
|
64
|
+
* @example
|
|
65
|
+
* ```typescript
|
|
66
|
+
* .loader(async (ctx) => {
|
|
67
|
+
* const todos = await db.todos.findMany();
|
|
68
|
+
* return { todos, user: ctx.get('user') };
|
|
69
|
+
* })
|
|
70
|
+
* ```
|
|
71
|
+
*/
|
|
72
|
+
loader(loaderFn: Loader<TLoaderData>): this {
|
|
73
|
+
this.config.loader = loaderFn;
|
|
74
|
+
return this;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Execute loader and return data
|
|
79
|
+
* @internal Used by SSR runtime
|
|
80
|
+
*/
|
|
81
|
+
async executeLoader(ctx: ManduContext): Promise<TLoaderData | undefined> {
|
|
82
|
+
if (!this.config.loader) {
|
|
83
|
+
return undefined;
|
|
84
|
+
}
|
|
85
|
+
return await this.config.loader(ctx);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Check if loader is defined
|
|
90
|
+
*/
|
|
91
|
+
hasLoader(): boolean {
|
|
92
|
+
return !!this.config.loader;
|
|
93
|
+
}
|
|
94
|
+
|
|
41
95
|
// ============================================
|
|
42
96
|
// 🥟 HTTP Method Handlers
|
|
43
97
|
// ============================================
|
|
@@ -242,9 +296,26 @@ export const Mandu = {
|
|
|
242
296
|
* export default Mandu.filling()
|
|
243
297
|
* .get(ctx => ctx.ok({ message: 'Hello!' }))
|
|
244
298
|
* ```
|
|
299
|
+
*
|
|
300
|
+
* @example with loader data type
|
|
301
|
+
* ```typescript
|
|
302
|
+
* import { Mandu } from '@mandujs/core'
|
|
303
|
+
*
|
|
304
|
+
* interface LoaderData {
|
|
305
|
+
* todos: Todo[];
|
|
306
|
+
* user: User | null;
|
|
307
|
+
* }
|
|
308
|
+
*
|
|
309
|
+
* export default Mandu.filling<LoaderData>()
|
|
310
|
+
* .loader(async (ctx) => {
|
|
311
|
+
* const todos = await db.todos.findMany();
|
|
312
|
+
* return { todos, user: null };
|
|
313
|
+
* })
|
|
314
|
+
* .get(ctx => ctx.ok(ctx.get('loaderData')))
|
|
315
|
+
* ```
|
|
245
316
|
*/
|
|
246
|
-
filling(): ManduFilling {
|
|
247
|
-
return new ManduFilling();
|
|
317
|
+
filling<TLoaderData = unknown>(): ManduFilling<TLoaderData> {
|
|
318
|
+
return new ManduFilling<TLoaderData>();
|
|
248
319
|
},
|
|
249
320
|
|
|
250
321
|
/**
|
package/src/index.ts
CHANGED
package/src/runtime/ssr.ts
CHANGED
|
@@ -1,14 +1,120 @@
|
|
|
1
1
|
import { renderToString } from "react-dom/server";
|
|
2
2
|
import type { ReactElement } from "react";
|
|
3
|
+
import type { BundleManifest } from "../bundler/types";
|
|
4
|
+
import type { HydrationConfig, HydrationPriority } from "../spec/schema";
|
|
3
5
|
|
|
4
6
|
export interface SSROptions {
|
|
5
7
|
title?: string;
|
|
6
8
|
lang?: string;
|
|
9
|
+
/** 서버에서 로드한 데이터 (클라이언트로 전달) */
|
|
10
|
+
serverData?: Record<string, unknown>;
|
|
11
|
+
/** Hydration 설정 */
|
|
12
|
+
hydration?: HydrationConfig;
|
|
13
|
+
/** 번들 매니페스트 */
|
|
14
|
+
bundleManifest?: BundleManifest;
|
|
15
|
+
/** 라우트 ID (island 식별용) */
|
|
16
|
+
routeId?: string;
|
|
17
|
+
/** 추가 head 태그 */
|
|
18
|
+
headTags?: string;
|
|
19
|
+
/** 추가 body 끝 태그 */
|
|
20
|
+
bodyEndTags?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* SSR 데이터를 안전하게 직렬화
|
|
25
|
+
*/
|
|
26
|
+
function serializeServerData(data: Record<string, unknown>): string {
|
|
27
|
+
// XSS 방지를 위한 이스케이프
|
|
28
|
+
const json = JSON.stringify(data)
|
|
29
|
+
.replace(/</g, "\\u003c")
|
|
30
|
+
.replace(/>/g, "\\u003e")
|
|
31
|
+
.replace(/&/g, "\\u0026")
|
|
32
|
+
.replace(/'/g, "\\u0027");
|
|
33
|
+
|
|
34
|
+
return `<script id="__MANDU_DATA__" type="application/json">${json}</script>
|
|
35
|
+
<script>window.__MANDU_DATA__ = JSON.parse(document.getElementById('__MANDU_DATA__').textContent);</script>`;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Hydration 스크립트 태그 생성
|
|
40
|
+
*/
|
|
41
|
+
function generateHydrationScripts(
|
|
42
|
+
routeId: string,
|
|
43
|
+
manifest: BundleManifest
|
|
44
|
+
): string {
|
|
45
|
+
const scripts: string[] = [];
|
|
46
|
+
|
|
47
|
+
// Runtime 로드
|
|
48
|
+
if (manifest.shared.runtime) {
|
|
49
|
+
scripts.push(`<script type="module" src="${manifest.shared.runtime}"></script>`);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Vendor 로드
|
|
53
|
+
if (manifest.shared.vendor) {
|
|
54
|
+
scripts.push(`<script type="module" src="${manifest.shared.vendor}"></script>`);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Island 번들 로드
|
|
58
|
+
const bundle = manifest.bundles[routeId];
|
|
59
|
+
if (bundle) {
|
|
60
|
+
// Preload (선택적)
|
|
61
|
+
scripts.push(`<link rel="modulepreload" href="${bundle.js}">`);
|
|
62
|
+
scripts.push(`<script type="module" src="${bundle.js}"></script>`);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return scripts.join("\n");
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Island 래퍼로 컨텐츠 감싸기
|
|
70
|
+
*/
|
|
71
|
+
export function wrapWithIsland(
|
|
72
|
+
content: string,
|
|
73
|
+
routeId: string,
|
|
74
|
+
priority: HydrationPriority = "visible"
|
|
75
|
+
): string {
|
|
76
|
+
return `<div data-mandu-island="${routeId}" data-mandu-priority="${priority}">${content}</div>`;
|
|
7
77
|
}
|
|
8
78
|
|
|
9
79
|
export function renderToHTML(element: ReactElement, options: SSROptions = {}): string {
|
|
10
|
-
const {
|
|
11
|
-
|
|
80
|
+
const {
|
|
81
|
+
title = "Mandu App",
|
|
82
|
+
lang = "ko",
|
|
83
|
+
serverData,
|
|
84
|
+
hydration,
|
|
85
|
+
bundleManifest,
|
|
86
|
+
routeId,
|
|
87
|
+
headTags = "",
|
|
88
|
+
bodyEndTags = "",
|
|
89
|
+
} = options;
|
|
90
|
+
|
|
91
|
+
let content = renderToString(element);
|
|
92
|
+
|
|
93
|
+
// Island 래퍼 적용 (hydration 필요 시)
|
|
94
|
+
const needsHydration =
|
|
95
|
+
hydration && hydration.strategy !== "none" && routeId && bundleManifest;
|
|
96
|
+
|
|
97
|
+
if (needsHydration) {
|
|
98
|
+
content = wrapWithIsland(content, routeId, hydration.priority);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// 서버 데이터 스크립트
|
|
102
|
+
let dataScript = "";
|
|
103
|
+
if (serverData && routeId) {
|
|
104
|
+
const wrappedData = {
|
|
105
|
+
[routeId]: {
|
|
106
|
+
serverData,
|
|
107
|
+
timestamp: Date.now(),
|
|
108
|
+
},
|
|
109
|
+
};
|
|
110
|
+
dataScript = serializeServerData(wrappedData);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Hydration 스크립트
|
|
114
|
+
let hydrationScripts = "";
|
|
115
|
+
if (needsHydration && bundleManifest) {
|
|
116
|
+
hydrationScripts = generateHydrationScripts(routeId, bundleManifest);
|
|
117
|
+
}
|
|
12
118
|
|
|
13
119
|
return `<!doctype html>
|
|
14
120
|
<html lang="${lang}">
|
|
@@ -16,9 +122,13 @@ export function renderToHTML(element: ReactElement, options: SSROptions = {}): s
|
|
|
16
122
|
<meta charset="UTF-8">
|
|
17
123
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
18
124
|
<title>${title}</title>
|
|
125
|
+
${headTags}
|
|
19
126
|
</head>
|
|
20
127
|
<body>
|
|
21
128
|
<div id="root">${content}</div>
|
|
129
|
+
${dataScript}
|
|
130
|
+
${hydrationScripts}
|
|
131
|
+
${bodyEndTags}
|
|
22
132
|
</body>
|
|
23
133
|
</html>`;
|
|
24
134
|
}
|
|
@@ -36,3 +146,33 @@ export function renderSSR(element: ReactElement, options: SSROptions = {}): Resp
|
|
|
36
146
|
const html = renderToHTML(element, options);
|
|
37
147
|
return createHTMLResponse(html);
|
|
38
148
|
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Hydration이 포함된 SSR 렌더링
|
|
152
|
+
*
|
|
153
|
+
* @example
|
|
154
|
+
* ```typescript
|
|
155
|
+
* const response = await renderWithHydration(
|
|
156
|
+
* <TodoList todos={todos} />,
|
|
157
|
+
* {
|
|
158
|
+
* title: "할일 목록",
|
|
159
|
+
* routeId: "todos",
|
|
160
|
+
* serverData: { todos },
|
|
161
|
+
* hydration: { strategy: "island", priority: "visible" },
|
|
162
|
+
* bundleManifest,
|
|
163
|
+
* }
|
|
164
|
+
* );
|
|
165
|
+
* ```
|
|
166
|
+
*/
|
|
167
|
+
export async function renderWithHydration(
|
|
168
|
+
element: ReactElement,
|
|
169
|
+
options: SSROptions & {
|
|
170
|
+
routeId: string;
|
|
171
|
+
serverData: Record<string, unknown>;
|
|
172
|
+
hydration: HydrationConfig;
|
|
173
|
+
bundleManifest: BundleManifest;
|
|
174
|
+
}
|
|
175
|
+
): Promise<Response> {
|
|
176
|
+
const html = renderToHTML(element, options);
|
|
177
|
+
return createHTMLResponse(html);
|
|
178
|
+
}
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Slot Content Corrector
|
|
3
|
+
* 슬롯 파일의 자동 수정 가능한 문제를 해결합니다.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { SlotValidationIssue } from "./validator";
|
|
7
|
+
|
|
8
|
+
export interface CorrectionResult {
|
|
9
|
+
corrected: boolean;
|
|
10
|
+
content: string;
|
|
11
|
+
appliedFixes: AppliedFix[];
|
|
12
|
+
remainingIssues: SlotValidationIssue[];
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface AppliedFix {
|
|
16
|
+
code: string;
|
|
17
|
+
description: string;
|
|
18
|
+
before?: string;
|
|
19
|
+
after?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// 금지된 import를 대체할 안전한 패턴
|
|
23
|
+
const SAFE_ALTERNATIVES: Record<string, string> = {
|
|
24
|
+
fs: "// Use Bun.file() or Bun.write() instead of fs",
|
|
25
|
+
"node:fs": "// Use Bun.file() or Bun.write() instead of fs",
|
|
26
|
+
child_process: "// Use Bun.spawn() or Bun.spawnSync() instead",
|
|
27
|
+
"node:child_process": "// Use Bun.spawn() or Bun.spawnSync() instead",
|
|
28
|
+
cluster: "// Clustering should be handled at the infrastructure level",
|
|
29
|
+
"node:cluster": "// Clustering should be handled at the infrastructure level",
|
|
30
|
+
worker_threads: "// Use Bun workers or external job queues",
|
|
31
|
+
"node:worker_threads": "// Use Bun workers or external job queues",
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* 슬롯 내용을 자동 수정합니다.
|
|
36
|
+
*/
|
|
37
|
+
export function correctSlotContent(
|
|
38
|
+
content: string,
|
|
39
|
+
issues: SlotValidationIssue[]
|
|
40
|
+
): CorrectionResult {
|
|
41
|
+
let correctedContent = content;
|
|
42
|
+
const appliedFixes: AppliedFix[] = [];
|
|
43
|
+
const remainingIssues: SlotValidationIssue[] = [];
|
|
44
|
+
|
|
45
|
+
for (const issue of issues) {
|
|
46
|
+
if (!issue.autoFixable) {
|
|
47
|
+
remainingIssues.push(issue);
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const result = applyFix(correctedContent, issue);
|
|
52
|
+
if (result.fixed) {
|
|
53
|
+
correctedContent = result.content;
|
|
54
|
+
appliedFixes.push({
|
|
55
|
+
code: issue.code,
|
|
56
|
+
description: issue.message,
|
|
57
|
+
before: result.before,
|
|
58
|
+
after: result.after,
|
|
59
|
+
});
|
|
60
|
+
} else {
|
|
61
|
+
remainingIssues.push(issue);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return {
|
|
66
|
+
corrected: appliedFixes.length > 0,
|
|
67
|
+
content: correctedContent,
|
|
68
|
+
appliedFixes,
|
|
69
|
+
remainingIssues,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
interface FixResult {
|
|
74
|
+
fixed: boolean;
|
|
75
|
+
content: string;
|
|
76
|
+
before?: string;
|
|
77
|
+
after?: string;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function applyFix(content: string, issue: SlotValidationIssue): FixResult {
|
|
81
|
+
switch (issue.code) {
|
|
82
|
+
case "FORBIDDEN_IMPORT":
|
|
83
|
+
return fixForbiddenImport(content, issue);
|
|
84
|
+
|
|
85
|
+
case "MISSING_MANDU_IMPORT":
|
|
86
|
+
return fixMissingManduImport(content);
|
|
87
|
+
|
|
88
|
+
case "MISSING_DEFAULT_EXPORT":
|
|
89
|
+
return fixMissingDefaultExport(content);
|
|
90
|
+
|
|
91
|
+
default:
|
|
92
|
+
return { fixed: false, content };
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* 금지된 import를 제거하고 주석으로 대체
|
|
98
|
+
*/
|
|
99
|
+
function fixForbiddenImport(
|
|
100
|
+
content: string,
|
|
101
|
+
issue: SlotValidationIssue
|
|
102
|
+
): FixResult {
|
|
103
|
+
const lines = content.split("\n");
|
|
104
|
+
|
|
105
|
+
if (!issue.line) {
|
|
106
|
+
return { fixed: false, content };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const lineIndex = issue.line - 1;
|
|
110
|
+
const originalLine = lines[lineIndex];
|
|
111
|
+
|
|
112
|
+
// 어떤 금지된 모듈인지 찾기
|
|
113
|
+
let forbiddenModule = "";
|
|
114
|
+
for (const [module, alternative] of Object.entries(SAFE_ALTERNATIVES)) {
|
|
115
|
+
if (originalLine.includes(`'${module}'`) || originalLine.includes(`"${module}"`)) {
|
|
116
|
+
forbiddenModule = module;
|
|
117
|
+
break;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (!forbiddenModule) {
|
|
122
|
+
return { fixed: false, content };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// import 문을 주석으로 대체
|
|
126
|
+
const alternative = SAFE_ALTERNATIVES[forbiddenModule];
|
|
127
|
+
lines[lineIndex] = `// REMOVED: ${originalLine.trim()}\n${alternative}`;
|
|
128
|
+
|
|
129
|
+
return {
|
|
130
|
+
fixed: true,
|
|
131
|
+
content: lines.join("\n"),
|
|
132
|
+
before: originalLine,
|
|
133
|
+
after: lines[lineIndex],
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Mandu import 추가
|
|
139
|
+
*/
|
|
140
|
+
function fixMissingManduImport(content: string): FixResult {
|
|
141
|
+
const manduImport = `import { Mandu } from "@mandujs/core";\n`;
|
|
142
|
+
|
|
143
|
+
// 이미 다른 import가 있는지 확인
|
|
144
|
+
const hasImports = /^import\s+/m.test(content);
|
|
145
|
+
|
|
146
|
+
let newContent: string;
|
|
147
|
+
if (hasImports) {
|
|
148
|
+
// 첫 번째 import 앞에 추가
|
|
149
|
+
newContent = content.replace(/^(import\s+)/m, `${manduImport}$1`);
|
|
150
|
+
} else {
|
|
151
|
+
// 파일 맨 앞에 추가
|
|
152
|
+
newContent = manduImport + content;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return {
|
|
156
|
+
fixed: true,
|
|
157
|
+
content: newContent,
|
|
158
|
+
before: "(없음)",
|
|
159
|
+
after: manduImport.trim(),
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* default export 추가
|
|
165
|
+
*/
|
|
166
|
+
function fixMissingDefaultExport(content: string): FixResult {
|
|
167
|
+
// Mandu.filling()이 있는지 확인
|
|
168
|
+
const fillingMatch = content.match(/Mandu\s*\.\s*filling\s*\(\s*\)/);
|
|
169
|
+
|
|
170
|
+
if (!fillingMatch) {
|
|
171
|
+
// filling 패턴이 없으면 수정 불가
|
|
172
|
+
return { fixed: false, content };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// export default가 없는 Mandu.filling() 찾기
|
|
176
|
+
// 예: const handler = Mandu.filling()... -> export default Mandu.filling()...
|
|
177
|
+
const patterns = [
|
|
178
|
+
// const/let/var handler = Mandu.filling()
|
|
179
|
+
/^(\s*)(const|let|var)\s+\w+\s*=\s*(Mandu\s*\.\s*filling\s*\(\s*\))/m,
|
|
180
|
+
// 단독 Mandu.filling()
|
|
181
|
+
/^(\s*)(Mandu\s*\.\s*filling\s*\(\s*\))/m,
|
|
182
|
+
];
|
|
183
|
+
|
|
184
|
+
for (const pattern of patterns) {
|
|
185
|
+
if (pattern.test(content)) {
|
|
186
|
+
const newContent = content.replace(pattern, "$1export default $3");
|
|
187
|
+
return {
|
|
188
|
+
fixed: true,
|
|
189
|
+
content: newContent,
|
|
190
|
+
before: content.match(pattern)?.[0],
|
|
191
|
+
after: newContent.match(/export default Mandu\.filling\(\)/)?.[0],
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// 파일 끝에 export default 추가 시도
|
|
197
|
+
if (content.includes("Mandu.filling()") && !content.includes("export default")) {
|
|
198
|
+
// 마지막 세미콜론 또는 중괄호 뒤에 추가
|
|
199
|
+
const lastLine = content.trimEnd();
|
|
200
|
+
if (!lastLine.endsWith(";") && !lastLine.endsWith("}")) {
|
|
201
|
+
return { fixed: false, content };
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
return { fixed: false, content };
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* 여러 번의 수정 시도 (Self-correction loop)
|
|
210
|
+
*/
|
|
211
|
+
export async function runSlotCorrection(
|
|
212
|
+
content: string,
|
|
213
|
+
validateFn: (content: string) => { valid: boolean; issues: SlotValidationIssue[] },
|
|
214
|
+
maxRetries: number = 3
|
|
215
|
+
): Promise<{
|
|
216
|
+
success: boolean;
|
|
217
|
+
finalContent: string;
|
|
218
|
+
attempts: number;
|
|
219
|
+
allFixes: AppliedFix[];
|
|
220
|
+
remainingIssues: SlotValidationIssue[];
|
|
221
|
+
}> {
|
|
222
|
+
let currentContent = content;
|
|
223
|
+
let attempts = 0;
|
|
224
|
+
const allFixes: AppliedFix[] = [];
|
|
225
|
+
|
|
226
|
+
while (attempts < maxRetries) {
|
|
227
|
+
attempts++;
|
|
228
|
+
|
|
229
|
+
// 1. 검증
|
|
230
|
+
const validation = validateFn(currentContent);
|
|
231
|
+
|
|
232
|
+
if (validation.valid) {
|
|
233
|
+
return {
|
|
234
|
+
success: true,
|
|
235
|
+
finalContent: currentContent,
|
|
236
|
+
attempts,
|
|
237
|
+
allFixes,
|
|
238
|
+
remainingIssues: [],
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// 2. 자동 수정 가능한 문제가 있는지 확인
|
|
243
|
+
const autoFixable = validation.issues.filter((i) => i.autoFixable);
|
|
244
|
+
if (autoFixable.length === 0) {
|
|
245
|
+
// 자동 수정 불가능한 문제만 남음
|
|
246
|
+
return {
|
|
247
|
+
success: false,
|
|
248
|
+
finalContent: currentContent,
|
|
249
|
+
attempts,
|
|
250
|
+
allFixes,
|
|
251
|
+
remainingIssues: validation.issues,
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// 3. 수정 적용
|
|
256
|
+
const correction = correctSlotContent(currentContent, validation.issues);
|
|
257
|
+
allFixes.push(...correction.appliedFixes);
|
|
258
|
+
|
|
259
|
+
if (!correction.corrected) {
|
|
260
|
+
// 수정이 적용되지 않음
|
|
261
|
+
return {
|
|
262
|
+
success: false,
|
|
263
|
+
finalContent: currentContent,
|
|
264
|
+
attempts,
|
|
265
|
+
allFixes,
|
|
266
|
+
remainingIssues: validation.issues,
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
currentContent = correction.content;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// maxRetries 도달
|
|
274
|
+
const finalValidation = validateFn(currentContent);
|
|
275
|
+
return {
|
|
276
|
+
success: finalValidation.valid,
|
|
277
|
+
finalContent: currentContent,
|
|
278
|
+
attempts,
|
|
279
|
+
allFixes,
|
|
280
|
+
remainingIssues: finalValidation.issues,
|
|
281
|
+
};
|
|
282
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Slot Module
|
|
3
|
+
* 슬롯 파일 검증 및 자동 수정 기능
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export {
|
|
7
|
+
validateSlotContent,
|
|
8
|
+
summarizeValidationIssues,
|
|
9
|
+
type SlotValidationIssue,
|
|
10
|
+
type SlotValidationResult,
|
|
11
|
+
} from "./validator";
|
|
12
|
+
|
|
13
|
+
export {
|
|
14
|
+
correctSlotContent,
|
|
15
|
+
runSlotCorrection,
|
|
16
|
+
type CorrectionResult,
|
|
17
|
+
type AppliedFix,
|
|
18
|
+
} from "./corrector";
|