@mandujs/core 0.9.30 → 0.9.37
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 +3 -1
- package/src/brain/architecture/analyzer.ts +15 -7
- package/src/bundler/dev.ts +21 -7
- package/src/guard/analyzer.ts +350 -0
- package/src/guard/ast-analyzer.ts +806 -0
- package/src/guard/index.ts +174 -0
- package/src/guard/presets/atomic.ts +70 -0
- package/src/guard/presets/clean.ts +77 -0
- package/src/guard/presets/fsd.ts +79 -0
- package/src/guard/presets/hexagonal.ts +68 -0
- package/src/guard/presets/index.ts +155 -0
- package/src/guard/reporter.ts +445 -0
- package/src/guard/statistics.ts +572 -0
- package/src/guard/suggestions.ts +345 -0
- package/src/guard/types.ts +331 -0
- package/src/guard/validator.ts +683 -0
- package/src/guard/watcher.ts +376 -0
- package/src/index.ts +1 -0
- package/src/router/fs-patterns.ts +380 -0
- package/src/router/fs-routes.ts +389 -0
- package/src/router/fs-scanner.ts +513 -0
- package/src/router/fs-types.ts +278 -0
- package/src/router/index.ts +81 -0
- package/src/runtime/boundary.tsx +232 -0
- package/src/runtime/index.ts +1 -0
- package/src/runtime/router.test.ts +53 -0
- package/src/runtime/router.ts +143 -46
- package/src/runtime/server.ts +233 -2
- package/src/spec/schema.ts +11 -0
- package/src/watcher/rules.ts +4 -4
package/src/runtime/router.ts
CHANGED
|
@@ -78,13 +78,25 @@ export class RouterError extends Error {
|
|
|
78
78
|
// TrieNode Class
|
|
79
79
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
80
80
|
|
|
81
|
+
/**
|
|
82
|
+
* Wildcard 설정
|
|
83
|
+
*/
|
|
84
|
+
interface WildcardConfig {
|
|
85
|
+
/** 파라미터 이름 (예: "path" for :path*) */
|
|
86
|
+
name: string;
|
|
87
|
+
/** optional 여부 (예: :path*? 는 optional) */
|
|
88
|
+
optional: boolean;
|
|
89
|
+
/** 라우트 정보 */
|
|
90
|
+
route: RouteSpec;
|
|
91
|
+
}
|
|
92
|
+
|
|
81
93
|
/**
|
|
82
94
|
* Trie node for dynamic route matching
|
|
83
95
|
*
|
|
84
96
|
* Structure:
|
|
85
97
|
* - children: Map for static segments
|
|
86
98
|
* - paramChild: Single param child with name tracking (P0-4)
|
|
87
|
-
* -
|
|
99
|
+
* - wildcardConfig: Wildcard with param name and optional flag
|
|
88
100
|
* - route: Route that terminates at this node
|
|
89
101
|
*/
|
|
90
102
|
class TrieNode {
|
|
@@ -94,11 +106,16 @@ class TrieNode {
|
|
|
94
106
|
/** Parameter child with name for conflict detection */
|
|
95
107
|
paramChild: { name: string; node: TrieNode } | null = null;
|
|
96
108
|
|
|
97
|
-
/** Wildcard
|
|
98
|
-
|
|
109
|
+
/** Wildcard config with param name (only valid at leaf) */
|
|
110
|
+
wildcardConfig: WildcardConfig | null = null;
|
|
99
111
|
|
|
100
112
|
/** Route terminating at this node */
|
|
101
113
|
route: RouteSpec | null = null;
|
|
114
|
+
|
|
115
|
+
/** @deprecated Use wildcardConfig instead */
|
|
116
|
+
get wildcardRoute(): RouteSpec | null {
|
|
117
|
+
return this.wildcardConfig?.route ?? null;
|
|
118
|
+
}
|
|
102
119
|
}
|
|
103
120
|
|
|
104
121
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
@@ -116,7 +133,7 @@ class TrieNode {
|
|
|
116
133
|
*
|
|
117
134
|
* @returns Decoded string or null if security violation
|
|
118
135
|
*/
|
|
119
|
-
function safeDecodeURIComponent(str: string): string | null {
|
|
136
|
+
function safeDecodeURIComponent(str: string): string | null {
|
|
120
137
|
// 1. Pre-decode %2F check
|
|
121
138
|
if (ENCODED_SLASH_PATTERN.test(str)) {
|
|
122
139
|
return null;
|
|
@@ -141,8 +158,26 @@ function safeDecodeURIComponent(str: string): string | null {
|
|
|
141
158
|
return null;
|
|
142
159
|
}
|
|
143
160
|
|
|
144
|
-
return decoded;
|
|
145
|
-
}
|
|
161
|
+
return decoded;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Decode wildcard segments safely (per-segment)
|
|
166
|
+
*/
|
|
167
|
+
function decodeWildcardSegments(segments: string[]): string | null {
|
|
168
|
+
if (segments.length === 0) return "";
|
|
169
|
+
|
|
170
|
+
const decodedSegments: string[] = [];
|
|
171
|
+
for (const segment of segments) {
|
|
172
|
+
const decoded = safeDecodeURIComponent(segment);
|
|
173
|
+
if (decoded === null) {
|
|
174
|
+
return null;
|
|
175
|
+
}
|
|
176
|
+
decodedSegments.push(decoded);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return decodedSegments.join("/");
|
|
180
|
+
}
|
|
146
181
|
|
|
147
182
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
148
183
|
// Router Class
|
|
@@ -305,7 +340,8 @@ export class Router {
|
|
|
305
340
|
}
|
|
306
341
|
|
|
307
342
|
// P0-2: Segment-based wildcard validation
|
|
308
|
-
|
|
343
|
+
// Wildcard formats: * (legacy), :param*, :param*? (optional)
|
|
344
|
+
const wildcardIdx = segments.findIndex((s) => s === "*" || s.includes("*"));
|
|
309
345
|
if (wildcardIdx !== -1 && wildcardIdx !== segments.length - 1) {
|
|
310
346
|
throw new RouterError(
|
|
311
347
|
`Wildcard must be the last segment in pattern "${pattern}"`,
|
|
@@ -335,17 +371,58 @@ export class Router {
|
|
|
335
371
|
private insertTrie(pattern: string, segments: string[], route: RouteSpec): void {
|
|
336
372
|
let node = this.trie;
|
|
337
373
|
|
|
338
|
-
for (let i = 0; i < segments.length; i++) {
|
|
339
|
-
const seg = segments[i];
|
|
340
|
-
|
|
341
|
-
//
|
|
342
|
-
if (seg === "*") {
|
|
343
|
-
node.
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
374
|
+
for (let i = 0; i < segments.length; i++) {
|
|
375
|
+
const seg = segments[i];
|
|
376
|
+
|
|
377
|
+
// Legacy wildcard: *
|
|
378
|
+
if (seg === "*") {
|
|
379
|
+
if (node.wildcardConfig) {
|
|
380
|
+
throw new RouterError(
|
|
381
|
+
`Wildcard conflict in pattern "${pattern}"`,
|
|
382
|
+
"ROUTE_CONFLICT",
|
|
383
|
+
route.id,
|
|
384
|
+
node.wildcardConfig.route.id
|
|
385
|
+
);
|
|
386
|
+
}
|
|
387
|
+
node.wildcardConfig = {
|
|
388
|
+
name: WILDCARD_PARAM_KEY,
|
|
389
|
+
optional: false,
|
|
390
|
+
route,
|
|
391
|
+
};
|
|
392
|
+
return;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// Parameter handling (including wildcards)
|
|
348
396
|
if (seg.startsWith(":")) {
|
|
397
|
+
// Check for wildcard pattern: :param* or :param*?
|
|
398
|
+
const wildcardMatch = seg.match(/^:([^*?]+)\*(\?)?$/);
|
|
399
|
+
if (wildcardMatch) {
|
|
400
|
+
const paramName = wildcardMatch[1];
|
|
401
|
+
const isOptional = wildcardMatch[2] === "?";
|
|
402
|
+
|
|
403
|
+
if (node.wildcardConfig) {
|
|
404
|
+
throw new RouterError(
|
|
405
|
+
`Wildcard conflict in pattern "${pattern}"`,
|
|
406
|
+
"ROUTE_CONFLICT",
|
|
407
|
+
route.id,
|
|
408
|
+
node.wildcardConfig.route.id
|
|
409
|
+
);
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
node.wildcardConfig = {
|
|
413
|
+
name: paramName,
|
|
414
|
+
optional: isOptional,
|
|
415
|
+
route,
|
|
416
|
+
};
|
|
417
|
+
|
|
418
|
+
// Optional wildcard: 현재 노드도 매칭 가능하게 route 설정
|
|
419
|
+
if (isOptional && !node.route) {
|
|
420
|
+
node.route = route;
|
|
421
|
+
}
|
|
422
|
+
return;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// Regular parameter: :param
|
|
349
426
|
const paramName = seg.slice(1);
|
|
350
427
|
|
|
351
428
|
// P0-3: Param name conflict detection
|
|
@@ -385,14 +462,14 @@ export class Router {
|
|
|
385
462
|
let node = this.trie;
|
|
386
463
|
|
|
387
464
|
// Track wildcard candidate for backtracking
|
|
388
|
-
let wildcardMatch: {
|
|
465
|
+
let wildcardMatch: { config: WildcardConfig; consumed: number } | null = null;
|
|
389
466
|
|
|
390
467
|
for (let i = 0; i < segments.length; i++) {
|
|
391
468
|
const seg = segments[i];
|
|
392
469
|
|
|
393
470
|
// Save wildcard candidate before advancing
|
|
394
|
-
if (node.
|
|
395
|
-
wildcardMatch = {
|
|
471
|
+
if (node.wildcardConfig) {
|
|
472
|
+
wildcardMatch = { config: node.wildcardConfig, consumed: i };
|
|
396
473
|
}
|
|
397
474
|
|
|
398
475
|
// 1. Try static child first (higher priority)
|
|
@@ -417,15 +494,19 @@ export class Router {
|
|
|
417
494
|
continue;
|
|
418
495
|
}
|
|
419
496
|
|
|
420
|
-
// 3. No match - try wildcard fallback
|
|
421
|
-
if (wildcardMatch) {
|
|
422
|
-
const
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
497
|
+
// 3. No match - try wildcard fallback
|
|
498
|
+
if (wildcardMatch) {
|
|
499
|
+
const remainingSegments = segments.slice(wildcardMatch.consumed);
|
|
500
|
+
const remaining = decodeWildcardSegments(remainingSegments);
|
|
501
|
+
if (remaining === null) {
|
|
502
|
+
return null;
|
|
503
|
+
}
|
|
504
|
+
if (this.debug) {
|
|
505
|
+
console.log(`[Router] Wildcard match: ${wildcardMatch.config.route.id} with ${remaining}`);
|
|
506
|
+
}
|
|
507
|
+
return {
|
|
508
|
+
route: wildcardMatch.config.route,
|
|
509
|
+
params: { ...params, [wildcardMatch.config.name]: remaining },
|
|
429
510
|
};
|
|
430
511
|
}
|
|
431
512
|
|
|
@@ -441,23 +522,36 @@ export class Router {
|
|
|
441
522
|
return { route: node.route, params };
|
|
442
523
|
}
|
|
443
524
|
|
|
444
|
-
// Check for wildcard at current node (but with no remaining segments)
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
525
|
+
// Check for wildcard at current node (but with no remaining segments)
|
|
526
|
+
if (node.wildcardConfig) {
|
|
527
|
+
// Optional wildcard: /files/:path*? matches /files (with empty path param)
|
|
528
|
+
if (node.wildcardConfig.optional) {
|
|
529
|
+
if (this.debug) {
|
|
530
|
+
console.log(`[Router] Optional wildcard match: ${node.wildcardConfig.route.id} with empty path`);
|
|
531
|
+
}
|
|
532
|
+
return {
|
|
533
|
+
route: node.wildcardConfig.route,
|
|
534
|
+
params,
|
|
535
|
+
};
|
|
536
|
+
}
|
|
537
|
+
// Non-optional wildcard: /files/:path* does NOT match /files
|
|
538
|
+
if (this.debug) {
|
|
539
|
+
console.log(`[Router] Wildcard policy: ${pathname} does not match non-optional wildcard`);
|
|
540
|
+
}
|
|
451
541
|
}
|
|
452
542
|
|
|
453
|
-
// Try wildcard fallback from earlier in the path
|
|
454
|
-
if (wildcardMatch) {
|
|
455
|
-
const
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
}
|
|
460
|
-
|
|
543
|
+
// Try wildcard fallback from earlier in the path
|
|
544
|
+
if (wildcardMatch) {
|
|
545
|
+
const remainingSegments = segments.slice(wildcardMatch.consumed);
|
|
546
|
+
const remaining = decodeWildcardSegments(remainingSegments);
|
|
547
|
+
if (remaining === null) {
|
|
548
|
+
return null;
|
|
549
|
+
}
|
|
550
|
+
return {
|
|
551
|
+
route: wildcardMatch.config.route,
|
|
552
|
+
params: { ...params, [wildcardMatch.config.name]: remaining },
|
|
553
|
+
};
|
|
554
|
+
}
|
|
461
555
|
|
|
462
556
|
return null;
|
|
463
557
|
}
|
|
@@ -470,8 +564,11 @@ export class Router {
|
|
|
470
564
|
routes.push(node.route);
|
|
471
565
|
}
|
|
472
566
|
|
|
473
|
-
if (node.
|
|
474
|
-
|
|
567
|
+
if (node.wildcardConfig) {
|
|
568
|
+
// Optional wildcard의 경우 route가 이미 추가되었을 수 있음
|
|
569
|
+
if (!node.route || node.wildcardConfig.route !== node.route) {
|
|
570
|
+
routes.push(node.wildcardConfig.route);
|
|
571
|
+
}
|
|
475
572
|
}
|
|
476
573
|
|
|
477
574
|
for (const child of node.children.values()) {
|
package/src/runtime/server.ts
CHANGED
|
@@ -5,7 +5,8 @@ import type { ManduFilling } from "../filling/filling";
|
|
|
5
5
|
import { ManduContext } from "../filling/context";
|
|
6
6
|
import { Router } from "./router";
|
|
7
7
|
import { renderSSR, renderStreamingResponse } from "./ssr";
|
|
8
|
-
import
|
|
8
|
+
import { PageBoundary, DefaultLoading, DefaultError, type ErrorFallbackProps } from "./boundary";
|
|
9
|
+
import React, { type ReactNode } from "react";
|
|
9
10
|
import path from "path";
|
|
10
11
|
import {
|
|
11
12
|
formatErrorResponse,
|
|
@@ -119,6 +120,36 @@ export interface ManduServer {
|
|
|
119
120
|
export type ApiHandler = (req: Request, params: Record<string, string>) => Response | Promise<Response>;
|
|
120
121
|
export type PageLoader = () => Promise<{ default: React.ComponentType<{ params: Record<string, string> }> }>;
|
|
121
122
|
|
|
123
|
+
/**
|
|
124
|
+
* Layout 컴포넌트 타입
|
|
125
|
+
* children을 받아서 감싸는 구조
|
|
126
|
+
*/
|
|
127
|
+
export type LayoutComponent = React.ComponentType<{
|
|
128
|
+
children: React.ReactNode;
|
|
129
|
+
params?: Record<string, string>;
|
|
130
|
+
}>;
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Layout 로더 타입
|
|
134
|
+
*/
|
|
135
|
+
export type LayoutLoader = () => Promise<{ default: LayoutComponent }>;
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Loading 컴포넌트 타입
|
|
139
|
+
*/
|
|
140
|
+
export type LoadingComponent = React.ComponentType<Record<string, never>>;
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Error 컴포넌트 타입
|
|
144
|
+
*/
|
|
145
|
+
export type ErrorComponent = React.ComponentType<ErrorFallbackProps>;
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Loading/Error 로더 타입
|
|
149
|
+
*/
|
|
150
|
+
export type LoadingLoader = () => Promise<{ default: LoadingComponent }>;
|
|
151
|
+
export type ErrorLoader = () => Promise<{ default: ErrorComponent }>;
|
|
152
|
+
|
|
122
153
|
/**
|
|
123
154
|
* Page 등록 정보
|
|
124
155
|
* - component: React 컴포넌트
|
|
@@ -166,6 +197,18 @@ export class ServerRegistry {
|
|
|
166
197
|
readonly pageLoaders: Map<string, PageLoader> = new Map();
|
|
167
198
|
readonly pageHandlers: Map<string, PageHandler> = new Map();
|
|
168
199
|
readonly routeComponents: Map<string, RouteComponent> = new Map();
|
|
200
|
+
/** Layout 컴포넌트 캐시 (모듈 경로 → 컴포넌트) */
|
|
201
|
+
readonly layoutComponents: Map<string, LayoutComponent> = new Map();
|
|
202
|
+
/** Layout 로더 (모듈 경로 → 로더 함수) */
|
|
203
|
+
readonly layoutLoaders: Map<string, LayoutLoader> = new Map();
|
|
204
|
+
/** Loading 컴포넌트 캐시 (모듈 경로 → 컴포넌트) */
|
|
205
|
+
readonly loadingComponents: Map<string, LoadingComponent> = new Map();
|
|
206
|
+
/** Loading 로더 (모듈 경로 → 로더 함수) */
|
|
207
|
+
readonly loadingLoaders: Map<string, LoadingLoader> = new Map();
|
|
208
|
+
/** Error 컴포넌트 캐시 (모듈 경로 → 컴포넌트) */
|
|
209
|
+
readonly errorComponents: Map<string, ErrorComponent> = new Map();
|
|
210
|
+
/** Error 로더 (모듈 경로 → 로더 함수) */
|
|
211
|
+
readonly errorLoaders: Map<string, ErrorLoader> = new Map();
|
|
169
212
|
createAppFn: CreateAppFn | null = null;
|
|
170
213
|
settings: ServerRegistrySettings = {
|
|
171
214
|
isDev: false,
|
|
@@ -191,10 +234,130 @@ export class ServerRegistry {
|
|
|
191
234
|
this.routeComponents.set(routeId, component);
|
|
192
235
|
}
|
|
193
236
|
|
|
237
|
+
/**
|
|
238
|
+
* Layout 로더 등록
|
|
239
|
+
*/
|
|
240
|
+
registerLayoutLoader(modulePath: string, loader: LayoutLoader): void {
|
|
241
|
+
this.layoutLoaders.set(modulePath, loader);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Layout 컴포넌트 가져오기 (캐시 또는 로드)
|
|
246
|
+
*/
|
|
247
|
+
async getLayoutComponent(modulePath: string): Promise<LayoutComponent | null> {
|
|
248
|
+
// 캐시 확인
|
|
249
|
+
const cached = this.layoutComponents.get(modulePath);
|
|
250
|
+
if (cached) {
|
|
251
|
+
return cached;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// 로더로 로드
|
|
255
|
+
const loader = this.layoutLoaders.get(modulePath);
|
|
256
|
+
if (loader) {
|
|
257
|
+
try {
|
|
258
|
+
const module = await loader();
|
|
259
|
+
const component = module.default;
|
|
260
|
+
this.layoutComponents.set(modulePath, component);
|
|
261
|
+
return component;
|
|
262
|
+
} catch (error) {
|
|
263
|
+
console.error(`[Mandu] Failed to load layout: ${modulePath}`, error);
|
|
264
|
+
return null;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// 동적 import 시도
|
|
269
|
+
try {
|
|
270
|
+
const fullPath = path.join(this.settings.rootDir, modulePath);
|
|
271
|
+
const module = await import(fullPath);
|
|
272
|
+
const component = module.default;
|
|
273
|
+
this.layoutComponents.set(modulePath, component);
|
|
274
|
+
return component;
|
|
275
|
+
} catch (error) {
|
|
276
|
+
console.error(`[Mandu] Failed to load layout: ${modulePath}`, error);
|
|
277
|
+
return null;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
194
281
|
setCreateApp(fn: CreateAppFn): void {
|
|
195
282
|
this.createAppFn = fn;
|
|
196
283
|
}
|
|
197
284
|
|
|
285
|
+
/**
|
|
286
|
+
* Loading 로더 등록
|
|
287
|
+
*/
|
|
288
|
+
registerLoadingLoader(modulePath: string, loader: LoadingLoader): void {
|
|
289
|
+
this.loadingLoaders.set(modulePath, loader);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Error 로더 등록
|
|
294
|
+
*/
|
|
295
|
+
registerErrorLoader(modulePath: string, loader: ErrorLoader): void {
|
|
296
|
+
this.errorLoaders.set(modulePath, loader);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Loading 컴포넌트 가져오기 (캐시 또는 로드)
|
|
301
|
+
*/
|
|
302
|
+
async getLoadingComponent(modulePath: string): Promise<LoadingComponent | null> {
|
|
303
|
+
const cached = this.loadingComponents.get(modulePath);
|
|
304
|
+
if (cached) return cached;
|
|
305
|
+
|
|
306
|
+
const loader = this.loadingLoaders.get(modulePath);
|
|
307
|
+
if (loader) {
|
|
308
|
+
try {
|
|
309
|
+
const module = await loader();
|
|
310
|
+
const component = module.default;
|
|
311
|
+
this.loadingComponents.set(modulePath, component);
|
|
312
|
+
return component;
|
|
313
|
+
} catch (error) {
|
|
314
|
+
console.error(`[Mandu] Failed to load loading component: ${modulePath}`, error);
|
|
315
|
+
return null;
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
try {
|
|
320
|
+
const fullPath = path.join(this.settings.rootDir, modulePath);
|
|
321
|
+
const module = await import(fullPath);
|
|
322
|
+
const component = module.default;
|
|
323
|
+
this.loadingComponents.set(modulePath, component);
|
|
324
|
+
return component;
|
|
325
|
+
} catch {
|
|
326
|
+
return null;
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* Error 컴포넌트 가져오기 (캐시 또는 로드)
|
|
332
|
+
*/
|
|
333
|
+
async getErrorComponent(modulePath: string): Promise<ErrorComponent | null> {
|
|
334
|
+
const cached = this.errorComponents.get(modulePath);
|
|
335
|
+
if (cached) return cached;
|
|
336
|
+
|
|
337
|
+
const loader = this.errorLoaders.get(modulePath);
|
|
338
|
+
if (loader) {
|
|
339
|
+
try {
|
|
340
|
+
const module = await loader();
|
|
341
|
+
const component = module.default;
|
|
342
|
+
this.errorComponents.set(modulePath, component);
|
|
343
|
+
return component;
|
|
344
|
+
} catch (error) {
|
|
345
|
+
console.error(`[Mandu] Failed to load error component: ${modulePath}`, error);
|
|
346
|
+
return null;
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
try {
|
|
351
|
+
const fullPath = path.join(this.settings.rootDir, modulePath);
|
|
352
|
+
const module = await import(fullPath);
|
|
353
|
+
const component = module.default;
|
|
354
|
+
this.errorComponents.set(modulePath, component);
|
|
355
|
+
return component;
|
|
356
|
+
} catch {
|
|
357
|
+
return null;
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
198
361
|
/**
|
|
199
362
|
* 모든 핸들러/컴포넌트 초기화 (테스트용)
|
|
200
363
|
*/
|
|
@@ -203,6 +366,12 @@ export class ServerRegistry {
|
|
|
203
366
|
this.pageLoaders.clear();
|
|
204
367
|
this.pageHandlers.clear();
|
|
205
368
|
this.routeComponents.clear();
|
|
369
|
+
this.layoutComponents.clear();
|
|
370
|
+
this.layoutLoaders.clear();
|
|
371
|
+
this.loadingComponents.clear();
|
|
372
|
+
this.loadingLoaders.clear();
|
|
373
|
+
this.errorComponents.clear();
|
|
374
|
+
this.errorLoaders.clear();
|
|
206
375
|
this.createAppFn = null;
|
|
207
376
|
}
|
|
208
377
|
}
|
|
@@ -253,6 +422,63 @@ export function setCreateApp(fn: CreateAppFn): void {
|
|
|
253
422
|
defaultRegistry.setCreateApp(fn);
|
|
254
423
|
}
|
|
255
424
|
|
|
425
|
+
/**
|
|
426
|
+
* Layout 로더 등록 (전역)
|
|
427
|
+
*/
|
|
428
|
+
export function registerLayoutLoader(modulePath: string, loader: LayoutLoader): void {
|
|
429
|
+
defaultRegistry.registerLayoutLoader(modulePath, loader);
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/**
|
|
433
|
+
* Loading 로더 등록 (전역)
|
|
434
|
+
*/
|
|
435
|
+
export function registerLoadingLoader(modulePath: string, loader: LoadingLoader): void {
|
|
436
|
+
defaultRegistry.registerLoadingLoader(modulePath, loader);
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/**
|
|
440
|
+
* Error 로더 등록 (전역)
|
|
441
|
+
*/
|
|
442
|
+
export function registerErrorLoader(modulePath: string, loader: ErrorLoader): void {
|
|
443
|
+
defaultRegistry.registerErrorLoader(modulePath, loader);
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/**
|
|
447
|
+
* 레이아웃 체인으로 컨텐츠 래핑
|
|
448
|
+
*
|
|
449
|
+
* @param content 페이지 컴포넌트로 렌더된 React Element
|
|
450
|
+
* @param layoutChain 레이아웃 모듈 경로 배열 (외부 → 내부)
|
|
451
|
+
* @param registry ServerRegistry 인스턴스
|
|
452
|
+
* @param params URL 파라미터
|
|
453
|
+
* @returns 래핑된 React Element
|
|
454
|
+
*/
|
|
455
|
+
async function wrapWithLayouts(
|
|
456
|
+
content: React.ReactElement,
|
|
457
|
+
layoutChain: string[],
|
|
458
|
+
registry: ServerRegistry,
|
|
459
|
+
params: Record<string, string>
|
|
460
|
+
): Promise<React.ReactElement> {
|
|
461
|
+
if (!layoutChain || layoutChain.length === 0) {
|
|
462
|
+
return content;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
// 레이아웃 로드 (병렬)
|
|
466
|
+
const layouts = await Promise.all(
|
|
467
|
+
layoutChain.map((modulePath) => registry.getLayoutComponent(modulePath))
|
|
468
|
+
);
|
|
469
|
+
|
|
470
|
+
// 내부 → 외부 순서로 래핑 (역순)
|
|
471
|
+
let wrapped = content;
|
|
472
|
+
for (let i = layouts.length - 1; i >= 0; i--) {
|
|
473
|
+
const Layout = layouts[i];
|
|
474
|
+
if (Layout) {
|
|
475
|
+
wrapped = React.createElement(Layout, { params }, wrapped);
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
return wrapped;
|
|
480
|
+
}
|
|
481
|
+
|
|
256
482
|
// Default createApp implementation (registry 기반)
|
|
257
483
|
function createDefaultAppFactory(registry: ServerRegistry) {
|
|
258
484
|
return function defaultCreateApp(context: AppContext): React.ReactElement {
|
|
@@ -504,13 +730,18 @@ async function handleRequest(req: Request, router: Router, registry: ServerRegis
|
|
|
504
730
|
const defaultAppCreator = createDefaultAppFactory(registry);
|
|
505
731
|
const appCreator = registry.createAppFn || defaultAppCreator;
|
|
506
732
|
try {
|
|
507
|
-
|
|
733
|
+
let app = appCreator({
|
|
508
734
|
routeId: route.id,
|
|
509
735
|
url: req.url,
|
|
510
736
|
params,
|
|
511
737
|
loaderData,
|
|
512
738
|
});
|
|
513
739
|
|
|
740
|
+
// 레이아웃 체인 적용 (layoutChain이 있는 경우)
|
|
741
|
+
if (route.layoutChain && route.layoutChain.length > 0) {
|
|
742
|
+
app = await wrapWithLayouts(app, route.layoutChain, registry, params);
|
|
743
|
+
}
|
|
744
|
+
|
|
514
745
|
// serverData 구조: { [routeId]: { serverData: loaderData } }
|
|
515
746
|
const serverData = loaderData
|
|
516
747
|
? { [route.id]: { serverData: loaderData } }
|
package/src/spec/schema.ts
CHANGED
|
@@ -94,6 +94,17 @@ export const RouteSpec = z
|
|
|
94
94
|
// - false: 이 라우트에 전통적 SSR 적용
|
|
95
95
|
// - undefined: 서버 설정 따름
|
|
96
96
|
streaming: z.boolean().optional(),
|
|
97
|
+
|
|
98
|
+
// Layout 체인 [NEW v0.9.33]
|
|
99
|
+
// - 페이지에 적용할 레이아웃 모듈 경로 배열
|
|
100
|
+
// - 배열 순서: 외부 → 내부 (Root → Parent → Child)
|
|
101
|
+
layoutChain: z.array(z.string()).optional(),
|
|
102
|
+
|
|
103
|
+
// Loading UI 모듈 경로 [NEW v0.9.33]
|
|
104
|
+
loadingModule: z.string().optional(),
|
|
105
|
+
|
|
106
|
+
// Error UI 모듈 경로 [NEW v0.9.33]
|
|
107
|
+
errorModule: z.string().optional(),
|
|
97
108
|
})
|
|
98
109
|
.refine(
|
|
99
110
|
(route) => {
|
package/src/watcher/rules.ts
CHANGED
|
@@ -21,10 +21,10 @@ import fs from "fs/promises";
|
|
|
21
21
|
*/
|
|
22
22
|
export const MVP_RULES: ArchRule[] = [
|
|
23
23
|
{
|
|
24
|
-
id: "GENERATED_DIRECT_EDIT",
|
|
24
|
+
id: "GENERATED_DIRECT_EDIT",
|
|
25
25
|
name: "Generated Direct Edit",
|
|
26
26
|
description: "Generated 파일은 직접 수정하면 안 됩니다",
|
|
27
|
-
pattern: "generated/**",
|
|
27
|
+
pattern: "**/generated/**",
|
|
28
28
|
action: "warn",
|
|
29
29
|
message: "Generated 파일이 직접 수정되었습니다. 이 파일은 `mandu generate`로 재생성됩니다.",
|
|
30
30
|
agentAction: "regenerate",
|
|
@@ -64,10 +64,10 @@ export const MVP_RULES: ArchRule[] = [
|
|
|
64
64
|
agentCommand: "mandu_check_location",
|
|
65
65
|
},
|
|
66
66
|
{
|
|
67
|
-
id: "FORBIDDEN_IMPORT",
|
|
67
|
+
id: "FORBIDDEN_IMPORT",
|
|
68
68
|
name: "Forbidden Import in Generated",
|
|
69
69
|
description: "Generated 파일에서 금지된 모듈 import",
|
|
70
|
-
pattern: "generated/**",
|
|
70
|
+
pattern: "**/generated/**",
|
|
71
71
|
action: "warn",
|
|
72
72
|
message: "Generated 파일에서 금지된 모듈이 import되었습니다.",
|
|
73
73
|
forbiddenImports: ["fs", "child_process", "cluster", "worker_threads"],
|