@mandujs/core 0.54.12 → 0.54.14
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 +9 -1
- package/scripts/postinstall-lock.ts +153 -153
- package/src/a11y/run-audit.ts +15 -15
- package/src/agent/__tests__/context.test.ts +49 -1
- package/src/agent/context.ts +535 -535
- package/src/agent/index.ts +6 -6
- package/src/agent/plan.ts +282 -282
- package/src/agent/repair.ts +171 -171
- package/src/agent/sync.ts +200 -200
- package/src/agent/types.ts +8 -0
- package/src/agent/verify.ts +100 -2
- package/src/brain/doctor/analyzer.ts +7 -7
- package/src/bundler/__tests__/build-runner.ts +36 -16
- package/src/bundler/__tests__/cold-start.test.ts +60 -60
- package/src/bundler/__tests__/css.test.ts +20 -20
- package/src/bundler/__tests__/fast-refresh.test.ts +24 -17
- package/src/bundler/analyzer.ts +15 -15
- package/src/bundler/build.test.ts +136 -48
- package/src/bundler/build.ts +193 -122
- package/src/bundler/css.ts +42 -42
- package/src/bundler/manifest-schema.ts +21 -21
- package/src/bundler/plugins/__tests__/block-generated-imports.test.ts +13 -13
- package/src/bundler/plugins/block-generated-imports.ts +13 -13
- package/src/bundler/types.ts +31 -31
- package/src/client/island.ts +79 -79
- package/src/config/validate.ts +1 -1
- package/src/contract/schema.ts +7 -0
- package/src/deploy/inference/context.ts +82 -82
- package/src/devtools/client/components/panel/islands-panel.tsx +16 -16
- package/src/devtools/client/components/panel/panel-container.tsx +1 -1
- package/src/error/formatter.ts +10 -1
- package/src/experimental/index.ts +10 -0
- package/src/filling/context.ts +17 -17
- package/src/filling/filling.ts +22 -1
- package/src/filling/index.ts +15 -1
- package/src/generator/generate.ts +30 -30
- package/src/generator/index.ts +3 -3
- package/src/generator/templates.ts +210 -210
- package/src/guard/check.ts +9 -9
- package/src/guard/config-guard.ts +13 -13
- package/src/guard/fs-routes-policy.ts +51 -51
- package/src/guard/index.ts +11 -11
- package/src/index.ts +0 -10
- package/src/internal/index.ts +25 -0
- package/src/kitchen/api/file-api.ts +11 -11
- package/src/report/index.ts +1 -1
- package/src/resource/__tests__/generator.test.ts +6 -6
- package/src/resource/__tests__/schema.test.ts +14 -14
- package/src/resource/ddl/__tests__/emit.test.ts +165 -165
- package/src/resource/ddl/emit.ts +146 -146
- package/src/resource/generator-schema.ts +11 -11
- package/src/resource/generators/slot.ts +72 -72
- package/src/resource/schema.ts +21 -21
- package/src/router/client-entry.test.ts +84 -41
- package/src/router/client-entry.ts +156 -89
- package/src/router/fs-routes.test.ts +90 -0
- package/src/router/fs-routes.ts +37 -29
- package/src/router/fs-scanner.ts +81 -33
- package/src/router/fs-types.ts +8 -5
- package/src/runtime/__tests__/devtools-adapter.test.ts +68 -68
- package/src/runtime/__tests__/observability-lifecycle.test.ts +103 -103
- package/src/runtime/__tests__/page-render-response.test.ts +164 -103
- package/src/runtime/__tests__/request-middleware.test.ts +70 -70
- package/src/runtime/devtools-adapter.ts +68 -68
- package/src/runtime/escape.ts +34 -34
- package/src/runtime/image-feature.ts +15 -0
- package/src/runtime/observability-lifecycle.ts +290 -290
- package/src/runtime/page-render-response.ts +208 -106
- package/src/runtime/rate-limit.ts +231 -0
- package/src/runtime/request-middleware.ts +31 -31
- package/src/runtime/router.test.ts +4 -4
- package/src/runtime/router.ts +10 -12
- package/src/runtime/scheduler-lifecycle.ts +64 -0
- package/src/runtime/server.ts +148 -353
- package/src/runtime/ssr.ts +59 -59
- package/src/runtime/static-files.ts +289 -289
- package/src/runtime/streaming-ssr.ts +22 -22
- package/src/spec/schema.ts +4 -3
- package/src/watcher/__tests__/watcher.test.ts +59 -59
- package/src/watcher/watcher.ts +61 -61
package/src/filling/filling.ts
CHANGED
|
@@ -62,9 +62,17 @@ export interface MiddlewarePlugin {
|
|
|
62
62
|
* `redirect(url)` helper for the common case; throwing a `Response` is
|
|
63
63
|
* also accepted (Remix idiom).
|
|
64
64
|
*/
|
|
65
|
+
export type LoaderResult<T = unknown> = T | Response;
|
|
66
|
+
|
|
65
67
|
export type Loader<T = unknown> = (
|
|
66
68
|
ctx: ManduContext
|
|
67
|
-
) => T |
|
|
69
|
+
) => LoaderResult<T> | Promise<LoaderResult<T>>;
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Page-level SSR data reader. Prefer this name in public docs when the
|
|
73
|
+
* distinction from mutation actions or schema contracts matters.
|
|
74
|
+
*/
|
|
75
|
+
export type RouteDataLoader<T = unknown> = Loader<T>;
|
|
68
76
|
|
|
69
77
|
/** Loader 실행 옵션 */
|
|
70
78
|
export interface LoaderOptions<T = unknown> {
|
|
@@ -105,6 +113,19 @@ export class LoaderTimeoutError extends Error {
|
|
|
105
113
|
/** Action handler type — named mutation handler */
|
|
106
114
|
export type ActionHandler = (ctx: ManduContext) => Response | Promise<Response>;
|
|
107
115
|
|
|
116
|
+
/**
|
|
117
|
+
* Named mutation/interaction handler. Alias of `ActionHandler`, exported so
|
|
118
|
+
* docs and generated examples can name the action responsibility directly.
|
|
119
|
+
*/
|
|
120
|
+
export type MutationAction = ActionHandler;
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Executable route pipeline: handlers, loader, actions, middleware, cache,
|
|
124
|
+
* render mode, and deploy intent. This is intentionally separate from the
|
|
125
|
+
* API schema contract.
|
|
126
|
+
*/
|
|
127
|
+
export type RouteFilling<TLoaderData = unknown> = ManduFilling<TLoaderData>;
|
|
128
|
+
|
|
108
129
|
interface FillingConfig<TLoaderData = unknown> {
|
|
109
130
|
handlers: Map<HttpMethod, Handler>;
|
|
110
131
|
actions: Map<string, ActionHandler>;
|
package/src/filling/index.ts
CHANGED
|
@@ -7,7 +7,21 @@
|
|
|
7
7
|
export { ManduContext, ValidationError, CookieManager } from "./context";
|
|
8
8
|
export type { CookieOptions } from "./context";
|
|
9
9
|
export { ManduFilling, ManduFillingFactory, LoaderTimeoutError } from "./filling";
|
|
10
|
-
export type {
|
|
10
|
+
export type {
|
|
11
|
+
Handler,
|
|
12
|
+
Guard,
|
|
13
|
+
ActionHandler,
|
|
14
|
+
MutationAction,
|
|
15
|
+
HttpMethod,
|
|
16
|
+
Loader,
|
|
17
|
+
RouteDataLoader,
|
|
18
|
+
LoaderResult,
|
|
19
|
+
LoaderOptions,
|
|
20
|
+
LoaderCacheOptions,
|
|
21
|
+
RenderMode,
|
|
22
|
+
MiddlewarePlugin,
|
|
23
|
+
RouteFilling,
|
|
24
|
+
} from "./filling";
|
|
11
25
|
export { createCookieSessionStorage, Session } from "./session";
|
|
12
26
|
export type { SessionStorage, SessionData, CookieSessionOptions } from "./session";
|
|
13
27
|
export { wrapBunWebSocket } from "./ws";
|
|
@@ -99,25 +99,25 @@ export interface GeneratedMap {
|
|
|
99
99
|
frameworkPaths: string[];
|
|
100
100
|
}
|
|
101
101
|
|
|
102
|
-
async function ensureDir(dirPath: string): Promise<void> {
|
|
103
|
-
try {
|
|
104
|
-
await fs.mkdir(dirPath, { recursive: true });
|
|
105
|
-
} catch {
|
|
106
|
-
// ignore if exists
|
|
107
|
-
}
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
function touchGenerateStamp(rootDir: string): void {
|
|
111
|
-
const stampDir = path.join(rootDir, ".mandu");
|
|
112
|
-
if (!fsSync.existsSync(stampDir)) {
|
|
113
|
-
fsSync.mkdirSync(stampDir, { recursive: true });
|
|
114
|
-
}
|
|
115
|
-
fsSync.writeFileSync(path.join(stampDir, "generate.stamp"), Date.now().toString());
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
async function getExistingFiles(dir: string): Promise<string[]> {
|
|
119
|
-
try {
|
|
120
|
-
const files = await fs.readdir(dir);
|
|
102
|
+
async function ensureDir(dirPath: string): Promise<void> {
|
|
103
|
+
try {
|
|
104
|
+
await fs.mkdir(dirPath, { recursive: true });
|
|
105
|
+
} catch {
|
|
106
|
+
// ignore if exists
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function touchGenerateStamp(rootDir: string): void {
|
|
111
|
+
const stampDir = path.join(rootDir, ".mandu");
|
|
112
|
+
if (!fsSync.existsSync(stampDir)) {
|
|
113
|
+
fsSync.mkdirSync(stampDir, { recursive: true });
|
|
114
|
+
}
|
|
115
|
+
fsSync.writeFileSync(path.join(stampDir, "generate.stamp"), Date.now().toString());
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async function getExistingFiles(dir: string): Promise<string[]> {
|
|
119
|
+
try {
|
|
120
|
+
const files = await fs.readdir(dir);
|
|
121
121
|
return files.filter((f) => f.endsWith(".route.ts") || f.endsWith(".route.tsx"));
|
|
122
122
|
} catch {
|
|
123
123
|
return [];
|
|
@@ -146,10 +146,10 @@ export async function generateRoutes(
|
|
|
146
146
|
warnings: [],
|
|
147
147
|
};
|
|
148
148
|
|
|
149
|
-
// Suppress watcher during generation to avoid false positives
|
|
150
|
-
const watcher = getWatcher();
|
|
151
|
-
watcher?.suppress();
|
|
152
|
-
touchGenerateStamp(rootDir);
|
|
149
|
+
// Suppress watcher during generation to avoid false positives
|
|
150
|
+
const watcher = getWatcher();
|
|
151
|
+
watcher?.suppress();
|
|
152
|
+
touchGenerateStamp(rootDir);
|
|
153
153
|
|
|
154
154
|
const generatedPaths = resolveGeneratedPaths(rootDir);
|
|
155
155
|
const serverRoutesDir = generatedPaths.serverRoutesDir;
|
|
@@ -363,10 +363,10 @@ export async function generateRoutes(
|
|
|
363
363
|
const mapPath = path.join(mapDir, "generated.map.json");
|
|
364
364
|
await Bun.write(mapPath, JSON.stringify(generatedMap, null, 2));
|
|
365
365
|
|
|
366
|
-
// Cross-process timestamp: watcher skips warnings if generate finished recently
|
|
367
|
-
touchGenerateStamp(rootDir);
|
|
368
|
-
// Resume watcher after the stamp is visible.
|
|
369
|
-
watcher?.resume();
|
|
370
|
-
|
|
371
|
-
return result;
|
|
372
|
-
}
|
|
366
|
+
// Cross-process timestamp: watcher skips warnings if generate finished recently
|
|
367
|
+
touchGenerateStamp(rootDir);
|
|
368
|
+
// Resume watcher after the stamp is visible.
|
|
369
|
+
watcher?.resume();
|
|
370
|
+
|
|
371
|
+
return result;
|
|
372
|
+
}
|
package/src/generator/index.ts
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
export * from "./generate";
|
|
2
|
-
export * from "./templates";
|
|
3
|
-
export * from "./contract-glue";
|
|
1
|
+
export * from "./generate";
|
|
2
|
+
export * from "./templates";
|
|
3
|
+
export * from "./contract-glue";
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { needsHydration, type RouteSpec } from "../spec/schema";
|
|
1
|
+
import { needsHydration, type RouteSpec } from "../spec/schema";
|
|
2
2
|
import { GENERATED_RELATIVE_PATHS } from "../paths";
|
|
3
3
|
|
|
4
4
|
export function generateApiHandler(route: RouteSpec): string {
|
|
@@ -238,34 +238,34 @@ function computeSlotImportPath(slotModule: string, fromDir: string): string {
|
|
|
238
238
|
return result;
|
|
239
239
|
}
|
|
240
240
|
|
|
241
|
-
export function generatePageComponent(route: RouteSpec): string {
|
|
242
|
-
// Island-First: clientModule이 있으면 Island render를 SSR에서 직접 사용
|
|
243
|
-
if (route.clientModule) {
|
|
244
|
-
return generatePageComponentWithIsland(route);
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
if (needsHydration(route)) {
|
|
248
|
-
throw new Error(
|
|
249
|
-
`[${route.id}] Route has hydration strategy "${route.hydration?.strategy}" but no clientModule. ` +
|
|
250
|
-
"Refusing to generate a placeholder page because it would disagree with runtime hydration state.",
|
|
251
|
-
);
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
// slotModule이 있으면 PageHandler 형식으로 생성 (filling 포함)
|
|
255
|
-
if (route.slotModule) {
|
|
256
|
-
return generatePageHandlerWithSlot(route);
|
|
257
|
-
}
|
|
258
|
-
|
|
259
|
-
if (route.kind === "page" && route.componentModule) {
|
|
260
|
-
return generatePageComponentFromModule(route);
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
const pageName = toPascalCase(route.id);
|
|
264
|
-
|
|
265
|
-
// Legacy fallback for malformed historical manifests that lack componentModule.
|
|
266
|
-
return `// Generated by Mandu - DO NOT EDIT DIRECTLY
|
|
267
|
-
// Route ID: ${route.id}
|
|
268
|
-
// Pattern: ${route.pattern}
|
|
241
|
+
export function generatePageComponent(route: RouteSpec): string {
|
|
242
|
+
// Island-First: clientModule이 있으면 Island render를 SSR에서 직접 사용
|
|
243
|
+
if (route.clientModule) {
|
|
244
|
+
return generatePageComponentWithIsland(route);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
if (needsHydration(route)) {
|
|
248
|
+
throw new Error(
|
|
249
|
+
`[${route.id}] Route has hydration strategy "${route.hydration?.strategy}" but no clientModule. ` +
|
|
250
|
+
"Refusing to generate a placeholder page because it would disagree with runtime hydration state.",
|
|
251
|
+
);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// slotModule이 있으면 PageHandler 형식으로 생성 (filling 포함)
|
|
255
|
+
if (route.slotModule) {
|
|
256
|
+
return generatePageHandlerWithSlot(route);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
if (route.kind === "page" && route.componentModule) {
|
|
260
|
+
return generatePageComponentFromModule(route);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const pageName = toPascalCase(route.id);
|
|
264
|
+
|
|
265
|
+
// Legacy fallback for malformed historical manifests that lack componentModule.
|
|
266
|
+
return `// Generated by Mandu - DO NOT EDIT DIRECTLY
|
|
267
|
+
// Route ID: ${route.id}
|
|
268
|
+
// Pattern: ${route.pattern}
|
|
269
269
|
|
|
270
270
|
import React from "react";
|
|
271
271
|
|
|
@@ -281,31 +281,31 @@ export default function ${pageName}Page({ params }: Props): React.ReactElement {
|
|
|
281
281
|
React.createElement("p", null, "Pattern: ${route.pattern}")
|
|
282
282
|
);
|
|
283
283
|
}
|
|
284
|
-
`;
|
|
285
|
-
}
|
|
286
|
-
|
|
287
|
-
export function generatePageComponentFromModule(route: RouteSpec): string {
|
|
288
|
-
const pageName = toPascalCase(route.id);
|
|
289
|
-
const pageImportPath = computeSlotImportPath(route.componentModule!, GENERATED_RELATIVE_PATHS.webRoutes);
|
|
290
|
-
|
|
291
|
-
return `// Generated by Mandu - DO NOT EDIT DIRECTLY
|
|
292
|
-
// Route ID: ${route.id}
|
|
293
|
-
// Pattern: ${route.pattern}
|
|
294
|
-
// Page Module: ${route.componentModule}
|
|
295
|
-
|
|
296
|
-
import React from "react";
|
|
297
|
-
import pageModule from "${pageImportPath}";
|
|
298
|
-
|
|
299
|
-
interface Props {
|
|
300
|
-
params: Record<string, string>;
|
|
301
|
-
loaderData?: unknown;
|
|
302
|
-
}
|
|
303
|
-
|
|
304
|
-
export default function ${pageName}Page(props: Props): React.ReactElement {
|
|
305
|
-
return React.createElement(pageModule as React.ComponentType<Props>, props);
|
|
306
|
-
}
|
|
307
|
-
`;
|
|
308
|
-
}
|
|
284
|
+
`;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
export function generatePageComponentFromModule(route: RouteSpec): string {
|
|
288
|
+
const pageName = toPascalCase(route.id);
|
|
289
|
+
const pageImportPath = computeSlotImportPath(route.componentModule!, GENERATED_RELATIVE_PATHS.webRoutes);
|
|
290
|
+
|
|
291
|
+
return `// Generated by Mandu - DO NOT EDIT DIRECTLY
|
|
292
|
+
// Route ID: ${route.id}
|
|
293
|
+
// Pattern: ${route.pattern}
|
|
294
|
+
// Page Module: ${route.componentModule}
|
|
295
|
+
|
|
296
|
+
import React from "react";
|
|
297
|
+
import pageModule from "${pageImportPath}";
|
|
298
|
+
|
|
299
|
+
interface Props {
|
|
300
|
+
params: Record<string, string>;
|
|
301
|
+
loaderData?: unknown;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
export default function ${pageName}Page(props: Props): React.ReactElement {
|
|
305
|
+
return React.createElement(pageModule as React.ComponentType<Props>, props);
|
|
306
|
+
}
|
|
307
|
+
`;
|
|
308
|
+
}
|
|
309
309
|
|
|
310
310
|
/**
|
|
311
311
|
* Island-First Rendering: SSR이 island의 render 함수를 직접 사용
|
|
@@ -313,51 +313,51 @@ export default function ${pageName}Page(props: Props): React.ReactElement {
|
|
|
313
313
|
* - SSR과 클라이언트가 동일한 렌더링 로직 사용 → 불일치 구조적 방지
|
|
314
314
|
* - slotModule 유무에 따라 두 가지 변형 생성
|
|
315
315
|
*/
|
|
316
|
-
export function generatePageComponentWithIsland(route: RouteSpec): string {
|
|
317
|
-
const pageName = toPascalCase(route.id);
|
|
318
|
-
const clientImportPath = computeSlotImportPath(route.clientModule!, GENERATED_RELATIVE_PATHS.webRoutes);
|
|
319
|
-
const pageImportPath = route.kind === "page" && route.componentModule
|
|
320
|
-
? computeSlotImportPath(route.componentModule, GENERATED_RELATIVE_PATHS.webRoutes)
|
|
321
|
-
: null;
|
|
322
|
-
const pageModuleComment = route.kind === "page" && route.componentModule
|
|
323
|
-
? `// Page Module: ${route.componentModule}\n`
|
|
324
|
-
: "";
|
|
325
|
-
const shouldImportPageModule = !!pageImportPath &&
|
|
326
|
-
normalizeRouteModulePath(route.componentModule) !== normalizeRouteModulePath(route.clientModule);
|
|
327
|
-
const pageImport = shouldImportPageModule ? `import pageModule from "${pageImportPath}";\n` : "";
|
|
328
|
-
const pageRenderTarget = shouldImportPageModule ? "pageModule" : "islandModule";
|
|
329
|
-
const renderHelper = generateClientBackedPageRenderHelper(pageRenderTarget, route.id);
|
|
330
|
-
|
|
331
|
-
// clientModule + slotModule → PageRegistration 형식
|
|
332
|
-
if (route.slotModule) {
|
|
333
|
-
const slotImportPath = computeSlotImportPath(route.slotModule!, GENERATED_RELATIVE_PATHS.webRoutes);
|
|
316
|
+
export function generatePageComponentWithIsland(route: RouteSpec): string {
|
|
317
|
+
const pageName = toPascalCase(route.id);
|
|
318
|
+
const clientImportPath = computeSlotImportPath(route.clientModule!, GENERATED_RELATIVE_PATHS.webRoutes);
|
|
319
|
+
const pageImportPath = route.kind === "page" && route.componentModule
|
|
320
|
+
? computeSlotImportPath(route.componentModule, GENERATED_RELATIVE_PATHS.webRoutes)
|
|
321
|
+
: null;
|
|
322
|
+
const pageModuleComment = route.kind === "page" && route.componentModule
|
|
323
|
+
? `// Page Module: ${route.componentModule}\n`
|
|
324
|
+
: "";
|
|
325
|
+
const shouldImportPageModule = !!pageImportPath &&
|
|
326
|
+
normalizeRouteModulePath(route.componentModule) !== normalizeRouteModulePath(route.clientModule);
|
|
327
|
+
const pageImport = shouldImportPageModule ? `import pageModule from "${pageImportPath}";\n` : "";
|
|
328
|
+
const pageRenderTarget = shouldImportPageModule ? "pageModule" : "islandModule";
|
|
329
|
+
const renderHelper = generateClientBackedPageRenderHelper(pageRenderTarget, route.id);
|
|
330
|
+
|
|
331
|
+
// clientModule + slotModule → PageRegistration 형식
|
|
332
|
+
if (route.slotModule) {
|
|
333
|
+
const slotImportPath = computeSlotImportPath(route.slotModule!, GENERATED_RELATIVE_PATHS.webRoutes);
|
|
334
334
|
|
|
335
335
|
return `// Generated by Mandu - DO NOT EDIT DIRECTLY
|
|
336
336
|
// Island-First Rendering + Slot Module
|
|
337
337
|
// Route ID: ${route.id}
|
|
338
|
-
// Pattern: ${route.pattern}
|
|
339
|
-
// Client Module: ${route.clientModule}
|
|
340
|
-
// Slot Module: ${route.slotModule}
|
|
341
|
-
${pageModuleComment}
|
|
342
|
-
|
|
343
|
-
import React from "react";
|
|
344
|
-
import filling from "${slotImportPath}";
|
|
345
|
-
import islandModule from "${clientImportPath}";
|
|
346
|
-
${pageImport}
|
|
347
|
-
|
|
348
|
-
interface Props {
|
|
349
|
-
params: Record<string, string>;
|
|
350
|
-
loaderData?: unknown;
|
|
351
|
-
}
|
|
352
|
-
|
|
353
|
-
${renderHelper}
|
|
354
|
-
|
|
355
|
-
function ${pageName}Page({ params, loaderData }: Props): React.ReactElement {
|
|
356
|
-
const serverData = (loaderData || {}) as Record<string, unknown>;
|
|
357
|
-
return renderClientBackedPage({ params, loaderData }, serverData);
|
|
358
|
-
}
|
|
359
|
-
|
|
360
|
-
// PageRegistration 형식으로 export (server.ts의 registerPageHandler용)
|
|
338
|
+
// Pattern: ${route.pattern}
|
|
339
|
+
// Client Module: ${route.clientModule}
|
|
340
|
+
// Slot Module: ${route.slotModule}
|
|
341
|
+
${pageModuleComment}
|
|
342
|
+
|
|
343
|
+
import React from "react";
|
|
344
|
+
import filling from "${slotImportPath}";
|
|
345
|
+
import islandModule from "${clientImportPath}";
|
|
346
|
+
${pageImport}
|
|
347
|
+
|
|
348
|
+
interface Props {
|
|
349
|
+
params: Record<string, string>;
|
|
350
|
+
loaderData?: unknown;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
${renderHelper}
|
|
354
|
+
|
|
355
|
+
function ${pageName}Page({ params, loaderData }: Props): React.ReactElement {
|
|
356
|
+
const serverData = (loaderData || {}) as Record<string, unknown>;
|
|
357
|
+
return renderClientBackedPage({ params, loaderData }, serverData);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// PageRegistration 형식으로 export (server.ts의 registerPageHandler용)
|
|
361
361
|
export default {
|
|
362
362
|
component: ${pageName}Page,
|
|
363
363
|
filling: filling,
|
|
@@ -368,124 +368,124 @@ export default {
|
|
|
368
368
|
// clientModule만 (slotModule 없음) → default export 컴포넌트
|
|
369
369
|
return `// Generated by Mandu - DO NOT EDIT DIRECTLY
|
|
370
370
|
// Island-First Rendering: SSR이 island render 직접 사용
|
|
371
|
-
// Route ID: ${route.id}
|
|
372
|
-
// Pattern: ${route.pattern}
|
|
373
|
-
// Client Module: ${route.clientModule}
|
|
374
|
-
${pageModuleComment}
|
|
375
|
-
|
|
376
|
-
import React from "react";
|
|
377
|
-
import islandModule from "${clientImportPath}";
|
|
378
|
-
${pageImport}
|
|
379
|
-
|
|
380
|
-
interface Props {
|
|
381
|
-
params: Record<string, string>;
|
|
382
|
-
loaderData?: unknown;
|
|
383
|
-
}
|
|
384
|
-
|
|
385
|
-
${renderHelper}
|
|
386
|
-
|
|
387
|
-
export default function ${pageName}Page({ params, loaderData }: Props): React.ReactElement {
|
|
388
|
-
const serverData = (loaderData || {}) as Record<string, unknown>;
|
|
389
|
-
return renderClientBackedPage({ params, loaderData }, serverData);
|
|
390
|
-
}
|
|
391
|
-
`;
|
|
392
|
-
}
|
|
371
|
+
// Route ID: ${route.id}
|
|
372
|
+
// Pattern: ${route.pattern}
|
|
373
|
+
// Client Module: ${route.clientModule}
|
|
374
|
+
${pageModuleComment}
|
|
375
|
+
|
|
376
|
+
import React from "react";
|
|
377
|
+
import islandModule from "${clientImportPath}";
|
|
378
|
+
${pageImport}
|
|
379
|
+
|
|
380
|
+
interface Props {
|
|
381
|
+
params: Record<string, string>;
|
|
382
|
+
loaderData?: unknown;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
${renderHelper}
|
|
386
|
+
|
|
387
|
+
export default function ${pageName}Page({ params, loaderData }: Props): React.ReactElement {
|
|
388
|
+
const serverData = (loaderData || {}) as Record<string, unknown>;
|
|
389
|
+
return renderClientBackedPage({ params, loaderData }, serverData);
|
|
390
|
+
}
|
|
391
|
+
`;
|
|
392
|
+
}
|
|
393
393
|
|
|
394
394
|
/**
|
|
395
395
|
* slotModule이 있는 Page Route용 Handler 생성
|
|
396
396
|
* - component와 filling을 함께 export
|
|
397
397
|
* - server.ts에서 filling.executeLoader() 호출 가능
|
|
398
398
|
*/
|
|
399
|
-
export function generatePageHandlerWithSlot(route: RouteSpec): string {
|
|
400
|
-
const pageName = toPascalCase(route.id);
|
|
401
|
-
const slotImportPath = computeSlotImportPath(route.slotModule!, GENERATED_RELATIVE_PATHS.serverRoutes);
|
|
402
|
-
const pageImportPath = route.kind === "page" && route.componentModule
|
|
403
|
-
? computeSlotImportPath(route.componentModule, GENERATED_RELATIVE_PATHS.serverRoutes)
|
|
404
|
-
: null;
|
|
405
|
-
const pageImport = pageImportPath ? `import pageModule from "${pageImportPath}";\n` : "";
|
|
406
|
-
const pageRender = pageImportPath
|
|
407
|
-
? ` return React.createElement(pageModule as React.ComponentType<Props>, { params, loaderData });`
|
|
408
|
-
: ` return React.createElement("div", null,
|
|
409
|
-
React.createElement("h1", null, "${pageName} Page"),
|
|
410
|
-
React.createElement("p", null, "Route ID: ${route.id}"),
|
|
411
|
-
React.createElement("p", null, "Pattern: ${route.pattern}"),
|
|
412
|
-
loaderData ? React.createElement("pre", null, JSON.stringify(loaderData, null, 2)) : null
|
|
413
|
-
);`;
|
|
414
|
-
|
|
415
|
-
return `// Generated by Mandu - DO NOT EDIT DIRECTLY
|
|
416
|
-
// Route ID: ${route.id}
|
|
417
|
-
// Pattern: ${route.pattern}
|
|
418
|
-
// Slot Module: ${route.slotModule}
|
|
419
|
-
|
|
420
|
-
import React from "react";
|
|
421
|
-
import filling from "${slotImportPath}";
|
|
422
|
-
${pageImport}
|
|
423
|
-
|
|
424
|
-
interface Props {
|
|
425
|
-
params: Record<string, string>;
|
|
426
|
-
loaderData?: unknown;
|
|
427
|
-
}
|
|
428
|
-
|
|
429
|
-
function ${pageName}Page({ params, loaderData }: Props): React.ReactElement {
|
|
430
|
-
${pageRender}
|
|
431
|
-
}
|
|
432
|
-
|
|
433
|
-
// PageRegistration 형식으로 export (server.ts의 registerPageHandler용)
|
|
399
|
+
export function generatePageHandlerWithSlot(route: RouteSpec): string {
|
|
400
|
+
const pageName = toPascalCase(route.id);
|
|
401
|
+
const slotImportPath = computeSlotImportPath(route.slotModule!, GENERATED_RELATIVE_PATHS.serverRoutes);
|
|
402
|
+
const pageImportPath = route.kind === "page" && route.componentModule
|
|
403
|
+
? computeSlotImportPath(route.componentModule, GENERATED_RELATIVE_PATHS.serverRoutes)
|
|
404
|
+
: null;
|
|
405
|
+
const pageImport = pageImportPath ? `import pageModule from "${pageImportPath}";\n` : "";
|
|
406
|
+
const pageRender = pageImportPath
|
|
407
|
+
? ` return React.createElement(pageModule as React.ComponentType<Props>, { params, loaderData });`
|
|
408
|
+
: ` return React.createElement("div", null,
|
|
409
|
+
React.createElement("h1", null, "${pageName} Page"),
|
|
410
|
+
React.createElement("p", null, "Route ID: ${route.id}"),
|
|
411
|
+
React.createElement("p", null, "Pattern: ${route.pattern}"),
|
|
412
|
+
loaderData ? React.createElement("pre", null, JSON.stringify(loaderData, null, 2)) : null
|
|
413
|
+
);`;
|
|
414
|
+
|
|
415
|
+
return `// Generated by Mandu - DO NOT EDIT DIRECTLY
|
|
416
|
+
// Route ID: ${route.id}
|
|
417
|
+
// Pattern: ${route.pattern}
|
|
418
|
+
// Slot Module: ${route.slotModule}
|
|
419
|
+
|
|
420
|
+
import React from "react";
|
|
421
|
+
import filling from "${slotImportPath}";
|
|
422
|
+
${pageImport}
|
|
423
|
+
|
|
424
|
+
interface Props {
|
|
425
|
+
params: Record<string, string>;
|
|
426
|
+
loaderData?: unknown;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
function ${pageName}Page({ params, loaderData }: Props): React.ReactElement {
|
|
430
|
+
${pageRender}
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
// PageRegistration 형식으로 export (server.ts의 registerPageHandler용)
|
|
434
434
|
export default {
|
|
435
435
|
component: ${pageName}Page,
|
|
436
436
|
filling: filling,
|
|
437
437
|
};
|
|
438
|
-
`;
|
|
439
|
-
}
|
|
440
|
-
|
|
441
|
-
function generateClientBackedPageRenderHelper(pageRenderTarget: string, routeId: string): string {
|
|
442
|
-
return `type ManduIslandModule = {
|
|
443
|
-
__mandu_island: true;
|
|
444
|
-
definition: {
|
|
445
|
-
setup: (serverData: Record<string, unknown>) => unknown;
|
|
446
|
-
render: (props: unknown) => React.ReactNode;
|
|
447
|
-
};
|
|
448
|
-
};
|
|
449
|
-
|
|
450
|
-
function isManduIslandModule(value: unknown): value is ManduIslandModule {
|
|
451
|
-
const candidate = value as Partial<ManduIslandModule> | null;
|
|
452
|
-
return !!(
|
|
453
|
-
candidate &&
|
|
454
|
-
typeof candidate === "object" &&
|
|
455
|
-
candidate.__mandu_island === true &&
|
|
456
|
-
typeof candidate.definition?.setup === "function" &&
|
|
457
|
-
typeof candidate.definition?.render === "function"
|
|
458
|
-
);
|
|
459
|
-
}
|
|
460
|
-
|
|
461
|
-
function renderClientBackedPage(props: Props, serverData: Record<string, unknown>): React.ReactElement {
|
|
462
|
-
if (isManduIslandModule(islandModule)) {
|
|
463
|
-
const setupResult = islandModule.definition.setup(serverData);
|
|
464
|
-
return islandModule.definition.render(setupResult) as React.ReactElement;
|
|
465
|
-
}
|
|
466
|
-
|
|
467
|
-
if (typeof ${pageRenderTarget} === "function") {
|
|
468
|
-
return React.createElement(${pageRenderTarget} as React.ComponentType<Props>, props);
|
|
469
|
-
}
|
|
470
|
-
|
|
471
|
-
if (typeof islandModule === "function") {
|
|
472
|
-
return React.createElement(islandModule as React.ComponentType<Record<string, unknown>>, serverData);
|
|
473
|
-
}
|
|
474
|
-
|
|
475
|
-
if (React.isValidElement(${pageRenderTarget})) {
|
|
476
|
-
return ${pageRenderTarget} as React.ReactElement;
|
|
477
|
-
}
|
|
478
|
-
|
|
479
|
-
if (React.isValidElement(islandModule)) {
|
|
480
|
-
return islandModule as React.ReactElement;
|
|
481
|
-
}
|
|
482
|
-
|
|
483
|
-
throw new Error("[Mandu] Route ${routeId} clientModule must export a Mandu island or React component.");
|
|
484
|
-
}`;
|
|
485
|
-
}
|
|
486
|
-
|
|
487
|
-
/**
|
|
488
|
-
* Convert string to PascalCase (handles kebab-case, snake_case)
|
|
438
|
+
`;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
function generateClientBackedPageRenderHelper(pageRenderTarget: string, routeId: string): string {
|
|
442
|
+
return `type ManduIslandModule = {
|
|
443
|
+
__mandu_island: true;
|
|
444
|
+
definition: {
|
|
445
|
+
setup: (serverData: Record<string, unknown>) => unknown;
|
|
446
|
+
render: (props: unknown) => React.ReactNode;
|
|
447
|
+
};
|
|
448
|
+
};
|
|
449
|
+
|
|
450
|
+
function isManduIslandModule(value: unknown): value is ManduIslandModule {
|
|
451
|
+
const candidate = value as Partial<ManduIslandModule> | null;
|
|
452
|
+
return !!(
|
|
453
|
+
candidate &&
|
|
454
|
+
typeof candidate === "object" &&
|
|
455
|
+
candidate.__mandu_island === true &&
|
|
456
|
+
typeof candidate.definition?.setup === "function" &&
|
|
457
|
+
typeof candidate.definition?.render === "function"
|
|
458
|
+
);
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
function renderClientBackedPage(props: Props, serverData: Record<string, unknown>): React.ReactElement {
|
|
462
|
+
if (isManduIslandModule(islandModule)) {
|
|
463
|
+
const setupResult = islandModule.definition.setup(serverData);
|
|
464
|
+
return islandModule.definition.render(setupResult) as React.ReactElement;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
if (typeof ${pageRenderTarget} === "function") {
|
|
468
|
+
return React.createElement(${pageRenderTarget} as React.ComponentType<Props>, props);
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
if (typeof islandModule === "function") {
|
|
472
|
+
return React.createElement(islandModule as React.ComponentType<Record<string, unknown>>, serverData);
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
if (React.isValidElement(${pageRenderTarget})) {
|
|
476
|
+
return ${pageRenderTarget} as React.ReactElement;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
if (React.isValidElement(islandModule)) {
|
|
480
|
+
return islandModule as React.ReactElement;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
throw new Error("[Mandu] Route ${routeId} clientModule must export a Mandu island or React component.");
|
|
484
|
+
}`;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
/**
|
|
488
|
+
* Convert string to PascalCase (handles kebab-case, snake_case)
|
|
489
489
|
* "todo-page" → "TodoPage"
|
|
490
490
|
* "user_profile" → "UserProfile"
|
|
491
491
|
*/
|
|
@@ -496,10 +496,10 @@ function toPascalCase(str: string): string {
|
|
|
496
496
|
.join("");
|
|
497
497
|
}
|
|
498
498
|
|
|
499
|
-
function pathDirname(filePath: string): string {
|
|
500
|
-
return filePath.replace(/\\/g, "/").split("/").slice(0, -1).join("/");
|
|
501
|
-
}
|
|
502
|
-
|
|
503
|
-
function normalizeRouteModulePath(filePath: string | undefined): string {
|
|
504
|
-
return (filePath ?? "").replace(/\\/g, "/").replace(/^\.\//, "");
|
|
505
|
-
}
|
|
499
|
+
function pathDirname(filePath: string): string {
|
|
500
|
+
return filePath.replace(/\\/g, "/").split("/").slice(0, -1).join("/");
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
function normalizeRouteModulePath(filePath: string | undefined): string {
|
|
504
|
+
return (filePath ?? "").replace(/\\/g, "/").replace(/^\.\//, "");
|
|
505
|
+
}
|
package/src/guard/check.ts
CHANGED
|
@@ -30,21 +30,21 @@ export const GENERATED_IMPORT_DOCS_URL =
|
|
|
30
30
|
"https://mandujs.com/docs/architect/generated-access";
|
|
31
31
|
|
|
32
32
|
/**
|
|
33
|
-
* Build the user-facing message for a detected direct generated-artifact
|
|
34
|
-
* import. `specifier` is the literal import string that tripped the guard
|
|
35
|
-
* (not the resolved path).
|
|
33
|
+
* Build the user-facing message for a detected direct generated-artifact
|
|
34
|
+
* import. `specifier` is the literal import string that tripped the guard
|
|
35
|
+
* (not the resolved path).
|
|
36
36
|
*
|
|
37
37
|
* This helper is the single source of truth for the message text — both
|
|
38
38
|
* the static Guard pass (`checkInvalidGeneratedImport`) and the bundler
|
|
39
39
|
* plugin (`blockGeneratedImports`) call through it so the two paths
|
|
40
40
|
* cannot drift.
|
|
41
41
|
*/
|
|
42
|
-
export function buildForbiddenGeneratedImportMessage(specifier: string): string {
|
|
43
|
-
return (
|
|
44
|
-
`Direct generated artifact imports are forbidden: ${specifier}. ` +
|
|
45
|
-
`Use the runtime registry: see ${GENERATED_IMPORT_DOCS_URL}`
|
|
46
|
-
);
|
|
47
|
-
}
|
|
42
|
+
export function buildForbiddenGeneratedImportMessage(specifier: string): string {
|
|
43
|
+
return (
|
|
44
|
+
`Direct generated artifact imports are forbidden: ${specifier}. ` +
|
|
45
|
+
`Use the runtime registry: see ${GENERATED_IMPORT_DOCS_URL}`
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
48
|
|
|
49
49
|
/**
|
|
50
50
|
* Shared remediation hint. Points at `getGenerated()` from
|