@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
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Slot Content Validator
|
|
3
|
+
* 슬롯 파일 내용을 작성 전에 검증하고 문제를 식별합니다.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export interface SlotValidationIssue {
|
|
7
|
+
code: string;
|
|
8
|
+
severity: "error" | "warning";
|
|
9
|
+
message: string;
|
|
10
|
+
line?: number;
|
|
11
|
+
suggestion: string;
|
|
12
|
+
autoFixable: boolean;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface SlotValidationResult {
|
|
16
|
+
valid: boolean;
|
|
17
|
+
issues: SlotValidationIssue[];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// 금지된 import 모듈들
|
|
21
|
+
const FORBIDDEN_IMPORTS = [
|
|
22
|
+
"fs",
|
|
23
|
+
"child_process",
|
|
24
|
+
"cluster",
|
|
25
|
+
"worker_threads",
|
|
26
|
+
"node:fs",
|
|
27
|
+
"node:child_process",
|
|
28
|
+
"node:cluster",
|
|
29
|
+
"node:worker_threads",
|
|
30
|
+
];
|
|
31
|
+
|
|
32
|
+
// 필수 패턴들
|
|
33
|
+
const REQUIRED_PATTERNS = {
|
|
34
|
+
manduImport: /import\s+.*\bMandu\b.*from\s+['"]@mandujs\/core['"]/,
|
|
35
|
+
fillingPattern: /Mandu\s*\.\s*filling\s*\(\s*\)/,
|
|
36
|
+
defaultExport: /export\s+default\b/,
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* 슬롯 내용을 검증합니다.
|
|
41
|
+
*/
|
|
42
|
+
export function validateSlotContent(content: string): SlotValidationResult {
|
|
43
|
+
const issues: SlotValidationIssue[] = [];
|
|
44
|
+
const lines = content.split("\n");
|
|
45
|
+
|
|
46
|
+
// 1. 금지된 import 검사
|
|
47
|
+
for (let i = 0; i < lines.length; i++) {
|
|
48
|
+
const line = lines[i];
|
|
49
|
+
for (const forbidden of FORBIDDEN_IMPORTS) {
|
|
50
|
+
// import 문에서 금지된 모듈 체크
|
|
51
|
+
const importPattern = new RegExp(
|
|
52
|
+
`import\\s+.*from\\s+['"]${forbidden.replace("/", "\\/")}['"]`
|
|
53
|
+
);
|
|
54
|
+
const requirePattern = new RegExp(
|
|
55
|
+
`require\\s*\\(\\s*['"]${forbidden.replace("/", "\\/")}['"]\\s*\\)`
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
if (importPattern.test(line) || requirePattern.test(line)) {
|
|
59
|
+
issues.push({
|
|
60
|
+
code: "FORBIDDEN_IMPORT",
|
|
61
|
+
severity: "error",
|
|
62
|
+
message: `금지된 모듈 import: '${forbidden}'`,
|
|
63
|
+
line: i + 1,
|
|
64
|
+
suggestion: `'${forbidden}' 대신 Bun의 안전한 API 또는 adapter를 사용하세요`,
|
|
65
|
+
autoFixable: true,
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// 2. Mandu import 검사
|
|
72
|
+
if (!REQUIRED_PATTERNS.manduImport.test(content)) {
|
|
73
|
+
issues.push({
|
|
74
|
+
code: "MISSING_MANDU_IMPORT",
|
|
75
|
+
severity: "error",
|
|
76
|
+
message: "Mandu import가 없습니다",
|
|
77
|
+
suggestion: "import { Mandu } from '@mandujs/core' 추가 필요",
|
|
78
|
+
autoFixable: true,
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// 3. Mandu.filling() 패턴 검사
|
|
83
|
+
if (!REQUIRED_PATTERNS.fillingPattern.test(content)) {
|
|
84
|
+
issues.push({
|
|
85
|
+
code: "MISSING_FILLING_PATTERN",
|
|
86
|
+
severity: "error",
|
|
87
|
+
message: "Mandu.filling() 패턴이 없습니다",
|
|
88
|
+
suggestion: "슬롯은 Mandu.filling()으로 시작해야 합니다",
|
|
89
|
+
autoFixable: false,
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// 4. default export 검사
|
|
94
|
+
if (!REQUIRED_PATTERNS.defaultExport.test(content)) {
|
|
95
|
+
issues.push({
|
|
96
|
+
code: "MISSING_DEFAULT_EXPORT",
|
|
97
|
+
severity: "error",
|
|
98
|
+
message: "default export가 없습니다",
|
|
99
|
+
suggestion: "export default Mandu.filling()... 형태로 작성하세요",
|
|
100
|
+
autoFixable: true,
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// 5. 기본 문법 검사 (간단한 체크)
|
|
105
|
+
const syntaxIssues = checkBasicSyntax(content, lines);
|
|
106
|
+
issues.push(...syntaxIssues);
|
|
107
|
+
|
|
108
|
+
// 6. HTTP 메서드 핸들러 검사
|
|
109
|
+
const methodIssues = checkHttpMethods(content);
|
|
110
|
+
issues.push(...methodIssues);
|
|
111
|
+
|
|
112
|
+
return {
|
|
113
|
+
valid: issues.filter((i) => i.severity === "error").length === 0,
|
|
114
|
+
issues,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* 기본 문법 검사
|
|
120
|
+
*/
|
|
121
|
+
function checkBasicSyntax(
|
|
122
|
+
content: string,
|
|
123
|
+
lines: string[]
|
|
124
|
+
): SlotValidationIssue[] {
|
|
125
|
+
const issues: SlotValidationIssue[] = [];
|
|
126
|
+
|
|
127
|
+
// 괄호 균형 체크
|
|
128
|
+
const brackets = { "(": 0, "{": 0, "[": 0 };
|
|
129
|
+
const bracketPairs: Record<string, keyof typeof brackets> = {
|
|
130
|
+
")": "(",
|
|
131
|
+
"}": "{",
|
|
132
|
+
"]": "[",
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
for (let i = 0; i < lines.length; i++) {
|
|
136
|
+
const line = lines[i];
|
|
137
|
+
// 문자열 내부는 스킵 (간단한 처리)
|
|
138
|
+
const withoutStrings = line
|
|
139
|
+
.replace(/"[^"]*"/g, "")
|
|
140
|
+
.replace(/'[^']*'/g, "")
|
|
141
|
+
.replace(/`[^`]*`/g, "");
|
|
142
|
+
|
|
143
|
+
for (const char of withoutStrings) {
|
|
144
|
+
if (char in brackets) {
|
|
145
|
+
brackets[char as keyof typeof brackets]++;
|
|
146
|
+
} else if (char in bracketPairs) {
|
|
147
|
+
brackets[bracketPairs[char]]--;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (brackets["("] !== 0) {
|
|
153
|
+
issues.push({
|
|
154
|
+
code: "UNBALANCED_PARENTHESES",
|
|
155
|
+
severity: "error",
|
|
156
|
+
message: `괄호 불균형: ${brackets["("] > 0 ? "닫는" : "여는"} 괄호 부족`,
|
|
157
|
+
suggestion: "괄호 쌍을 확인하세요",
|
|
158
|
+
autoFixable: false,
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (brackets["{"] !== 0) {
|
|
163
|
+
issues.push({
|
|
164
|
+
code: "UNBALANCED_BRACES",
|
|
165
|
+
severity: "error",
|
|
166
|
+
message: `중괄호 불균형: ${brackets["{"] > 0 ? "닫는" : "여는"} 중괄호 부족`,
|
|
167
|
+
suggestion: "중괄호 쌍을 확인하세요",
|
|
168
|
+
autoFixable: false,
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
if (brackets["["] !== 0) {
|
|
173
|
+
issues.push({
|
|
174
|
+
code: "UNBALANCED_BRACKETS",
|
|
175
|
+
severity: "error",
|
|
176
|
+
message: `대괄호 불균형: ${brackets["["] > 0 ? "닫는" : "여는"} 대괄호 부족`,
|
|
177
|
+
suggestion: "대괄호 쌍을 확인하세요",
|
|
178
|
+
autoFixable: false,
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return issues;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* HTTP 메서드 핸들러 검사
|
|
187
|
+
*/
|
|
188
|
+
function checkHttpMethods(content: string): SlotValidationIssue[] {
|
|
189
|
+
const issues: SlotValidationIssue[] = [];
|
|
190
|
+
|
|
191
|
+
// .get(), .post() 등의 핸들러가 있는지 확인
|
|
192
|
+
const methodPattern = /\.(get|post|put|patch|delete|options|head)\s*\(/gi;
|
|
193
|
+
const hasMethod = methodPattern.test(content);
|
|
194
|
+
|
|
195
|
+
if (!hasMethod) {
|
|
196
|
+
issues.push({
|
|
197
|
+
code: "NO_HTTP_HANDLER",
|
|
198
|
+
severity: "warning",
|
|
199
|
+
message: "HTTP 메서드 핸들러가 없습니다",
|
|
200
|
+
suggestion:
|
|
201
|
+
".get(ctx => ...), .post(ctx => ...) 등의 핸들러를 추가하세요",
|
|
202
|
+
autoFixable: false,
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// ctx.ok(), ctx.json() 등 응답 패턴 확인
|
|
207
|
+
const responsePattern = /ctx\s*\.\s*(ok|json|created|noContent|error|html)\s*\(/;
|
|
208
|
+
if (hasMethod && !responsePattern.test(content)) {
|
|
209
|
+
issues.push({
|
|
210
|
+
code: "NO_RESPONSE_PATTERN",
|
|
211
|
+
severity: "warning",
|
|
212
|
+
message: "응답 패턴이 없습니다",
|
|
213
|
+
suggestion:
|
|
214
|
+
"핸들러에서 ctx.ok(), ctx.json() 등으로 응답을 반환하세요",
|
|
215
|
+
autoFixable: false,
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
return issues;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* 에러 요약 생성
|
|
224
|
+
*/
|
|
225
|
+
export function summarizeValidationIssues(
|
|
226
|
+
issues: SlotValidationIssue[]
|
|
227
|
+
): string {
|
|
228
|
+
const errors = issues.filter((i) => i.severity === "error");
|
|
229
|
+
const warnings = issues.filter((i) => i.severity === "warning");
|
|
230
|
+
|
|
231
|
+
const parts: string[] = [];
|
|
232
|
+
|
|
233
|
+
if (errors.length > 0) {
|
|
234
|
+
parts.push(`${errors.length}개 에러`);
|
|
235
|
+
}
|
|
236
|
+
if (warnings.length > 0) {
|
|
237
|
+
parts.push(`${warnings.length}개 경고`);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
return parts.join(", ") || "문제 없음";
|
|
241
|
+
}
|
package/src/spec/schema.ts
CHANGED
|
@@ -1,16 +1,90 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
|
|
3
|
+
// ========== Hydration 설정 ==========
|
|
4
|
+
|
|
5
|
+
export const HydrationStrategy = z.enum(["none", "island", "full", "progressive"]);
|
|
6
|
+
export type HydrationStrategy = z.infer<typeof HydrationStrategy>;
|
|
7
|
+
|
|
8
|
+
export const HydrationPriority = z.enum(["immediate", "visible", "idle", "interaction"]);
|
|
9
|
+
export type HydrationPriority = z.infer<typeof HydrationPriority>;
|
|
10
|
+
|
|
11
|
+
export const HydrationConfig = z.object({
|
|
12
|
+
/**
|
|
13
|
+
* Hydration 전략
|
|
14
|
+
* - none: 순수 Static HTML (JS 없음)
|
|
15
|
+
* - island: Slot 영역만 hydrate (기본값)
|
|
16
|
+
* - full: 전체 페이지 hydrate
|
|
17
|
+
* - progressive: 점진적 hydrate
|
|
18
|
+
*/
|
|
19
|
+
strategy: HydrationStrategy,
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Hydration 우선순위
|
|
23
|
+
* - immediate: 페이지 로드 즉시
|
|
24
|
+
* - visible: 뷰포트에 보일 때 (기본값)
|
|
25
|
+
* - idle: 브라우저 idle 시
|
|
26
|
+
* - interaction: 사용자 상호작용 시
|
|
27
|
+
*/
|
|
28
|
+
priority: HydrationPriority.default("visible"),
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* 번들 preload 여부
|
|
32
|
+
*/
|
|
33
|
+
preload: z.boolean().default(false),
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
export type HydrationConfig = z.infer<typeof HydrationConfig>;
|
|
37
|
+
|
|
38
|
+
// ========== Loader 설정 ==========
|
|
39
|
+
|
|
40
|
+
export const LoaderConfig = z.object({
|
|
41
|
+
/**
|
|
42
|
+
* SSR 시 데이터 로딩 타임아웃 (ms)
|
|
43
|
+
*/
|
|
44
|
+
timeout: z.number().positive().default(5000),
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* 로딩 실패 시 fallback 데이터
|
|
48
|
+
*/
|
|
49
|
+
fallback: z.record(z.unknown()).optional(),
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
export type LoaderConfig = z.infer<typeof LoaderConfig>;
|
|
53
|
+
|
|
54
|
+
// ========== Route 설정 ==========
|
|
55
|
+
|
|
3
56
|
export const RouteKind = z.enum(["page", "api"]);
|
|
4
57
|
export type RouteKind = z.infer<typeof RouteKind>;
|
|
5
58
|
|
|
59
|
+
export const HttpMethod = z.enum(["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "HEAD"]);
|
|
60
|
+
export type HttpMethod = z.infer<typeof HttpMethod>;
|
|
61
|
+
|
|
6
62
|
export const RouteSpec = z
|
|
7
63
|
.object({
|
|
8
64
|
id: z.string().min(1, "id는 필수입니다"),
|
|
9
65
|
pattern: z.string().startsWith("/", "pattern은 /로 시작해야 합니다"),
|
|
10
66
|
kind: RouteKind,
|
|
67
|
+
|
|
68
|
+
// HTTP 메서드 (API용)
|
|
69
|
+
methods: z.array(HttpMethod).optional(),
|
|
70
|
+
|
|
71
|
+
// 서버 모듈 (generated route handler)
|
|
11
72
|
module: z.string().min(1, "module 경로는 필수입니다"),
|
|
73
|
+
|
|
74
|
+
// 페이지 컴포넌트 모듈 (generated)
|
|
12
75
|
componentModule: z.string().optional(),
|
|
76
|
+
|
|
77
|
+
// 서버 슬롯 (비즈니스 로직)
|
|
13
78
|
slotModule: z.string().optional(),
|
|
79
|
+
|
|
80
|
+
// 클라이언트 슬롯 (interactive 로직) [NEW]
|
|
81
|
+
clientModule: z.string().optional(),
|
|
82
|
+
|
|
83
|
+
// Hydration 설정 [NEW]
|
|
84
|
+
hydration: HydrationConfig.optional(),
|
|
85
|
+
|
|
86
|
+
// Loader 설정 [NEW]
|
|
87
|
+
loader: LoaderConfig.optional(),
|
|
14
88
|
})
|
|
15
89
|
.refine(
|
|
16
90
|
(route) => {
|
|
@@ -23,10 +97,25 @@ export const RouteSpec = z
|
|
|
23
97
|
message: "kind가 'page'인 경우 componentModule은 필수입니다",
|
|
24
98
|
path: ["componentModule"],
|
|
25
99
|
}
|
|
100
|
+
)
|
|
101
|
+
.refine(
|
|
102
|
+
(route) => {
|
|
103
|
+
// clientModule이 있으면 hydration.strategy가 none이 아니어야 함
|
|
104
|
+
if (route.clientModule && route.hydration?.strategy === "none") {
|
|
105
|
+
return false;
|
|
106
|
+
}
|
|
107
|
+
return true;
|
|
108
|
+
},
|
|
109
|
+
{
|
|
110
|
+
message: "clientModule이 있으면 hydration.strategy는 'none'이 아니어야 합니다",
|
|
111
|
+
path: ["hydration"],
|
|
112
|
+
}
|
|
26
113
|
);
|
|
27
114
|
|
|
28
115
|
export type RouteSpec = z.infer<typeof RouteSpec>;
|
|
29
116
|
|
|
117
|
+
// ========== Manifest ==========
|
|
118
|
+
|
|
30
119
|
export const RoutesManifest = z
|
|
31
120
|
.object({
|
|
32
121
|
version: z.number().int().positive(),
|
|
@@ -56,3 +145,46 @@ export const RoutesManifest = z
|
|
|
56
145
|
);
|
|
57
146
|
|
|
58
147
|
export type RoutesManifest = z.infer<typeof RoutesManifest>;
|
|
148
|
+
|
|
149
|
+
// ========== 유틸리티 함수 ==========
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* 기본 hydration 설정 반환
|
|
153
|
+
*/
|
|
154
|
+
export function getDefaultHydration(route: RouteSpec): HydrationConfig {
|
|
155
|
+
// clientModule이 있으면 island, 없으면 none
|
|
156
|
+
if (route.clientModule) {
|
|
157
|
+
return {
|
|
158
|
+
strategy: "island",
|
|
159
|
+
priority: "visible",
|
|
160
|
+
preload: false,
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
return {
|
|
164
|
+
strategy: "none",
|
|
165
|
+
priority: "visible",
|
|
166
|
+
preload: false,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* 라우트의 실제 hydration 설정 반환 (기본값 적용)
|
|
172
|
+
*/
|
|
173
|
+
export function getRouteHydration(route: RouteSpec): HydrationConfig {
|
|
174
|
+
if (route.hydration) {
|
|
175
|
+
return {
|
|
176
|
+
strategy: route.hydration.strategy,
|
|
177
|
+
priority: route.hydration.priority ?? "visible",
|
|
178
|
+
preload: route.hydration.preload ?? false,
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
return getDefaultHydration(route);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Hydration이 필요한 라우트인지 확인
|
|
186
|
+
*/
|
|
187
|
+
export function needsHydration(route: RouteSpec): boolean {
|
|
188
|
+
const hydration = getRouteHydration(route);
|
|
189
|
+
return route.kind === "page" && hydration.strategy !== "none";
|
|
190
|
+
}
|