@mandujs/core 0.18.22 → 0.19.2

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.
Files changed (91) hide show
  1. package/README.ko.md +0 -14
  2. package/package.json +4 -1
  3. package/src/brain/architecture/analyzer.ts +4 -4
  4. package/src/brain/doctor/analyzer.ts +18 -14
  5. package/src/bundler/build.test.ts +127 -0
  6. package/src/bundler/build.ts +291 -113
  7. package/src/bundler/css.ts +20 -5
  8. package/src/bundler/dev.ts +55 -2
  9. package/src/bundler/prerender.ts +195 -0
  10. package/src/change/snapshot.ts +4 -23
  11. package/src/change/types.ts +2 -3
  12. package/src/client/Form.tsx +105 -0
  13. package/src/client/__tests__/use-sse.test.ts +153 -0
  14. package/src/client/hooks.ts +105 -6
  15. package/src/client/index.ts +35 -6
  16. package/src/client/router.ts +670 -433
  17. package/src/client/rpc.ts +140 -0
  18. package/src/client/runtime.ts +24 -21
  19. package/src/client/use-fetch.ts +239 -0
  20. package/src/client/use-head.ts +197 -0
  21. package/src/client/use-sse.ts +378 -0
  22. package/src/components/Image.tsx +162 -0
  23. package/src/config/mandu.ts +5 -0
  24. package/src/config/validate.ts +34 -0
  25. package/src/content/index.ts +5 -1
  26. package/src/devtools/client/catchers/error-catcher.ts +17 -0
  27. package/src/devtools/client/catchers/network-proxy.ts +390 -367
  28. package/src/devtools/client/components/kitchen-root.tsx +479 -467
  29. package/src/devtools/client/components/panel/diff-viewer.tsx +219 -0
  30. package/src/devtools/client/components/panel/guard-panel.tsx +374 -244
  31. package/src/devtools/client/components/panel/index.ts +45 -32
  32. package/src/devtools/client/components/panel/panel-container.tsx +332 -312
  33. package/src/devtools/client/components/panel/preview-panel.tsx +188 -0
  34. package/src/devtools/client/state-manager.ts +535 -478
  35. package/src/devtools/design-tokens.ts +265 -264
  36. package/src/devtools/types.ts +345 -319
  37. package/src/filling/context.ts +65 -0
  38. package/src/filling/filling.ts +336 -14
  39. package/src/filling/index.ts +5 -1
  40. package/src/filling/session.ts +216 -0
  41. package/src/filling/ws.ts +78 -0
  42. package/src/generator/generate.ts +2 -2
  43. package/src/guard/auto-correct.ts +0 -29
  44. package/src/guard/check.ts +14 -31
  45. package/src/guard/presets/index.ts +296 -294
  46. package/src/guard/rules.ts +15 -19
  47. package/src/guard/validator.ts +834 -834
  48. package/src/index.ts +5 -1
  49. package/src/island/index.ts +373 -304
  50. package/src/kitchen/api/contract-api.ts +225 -0
  51. package/src/kitchen/api/diff-parser.ts +108 -0
  52. package/src/kitchen/api/file-api.ts +273 -0
  53. package/src/kitchen/api/guard-api.ts +83 -0
  54. package/src/kitchen/api/guard-decisions.ts +100 -0
  55. package/src/kitchen/api/routes-api.ts +50 -0
  56. package/src/kitchen/index.ts +21 -0
  57. package/src/kitchen/kitchen-handler.ts +256 -0
  58. package/src/kitchen/kitchen-ui.ts +1732 -0
  59. package/src/kitchen/stream/activity-sse.ts +145 -0
  60. package/src/kitchen/stream/file-tailer.ts +99 -0
  61. package/src/middleware/compress.ts +62 -0
  62. package/src/middleware/cors.ts +47 -0
  63. package/src/middleware/index.ts +10 -0
  64. package/src/middleware/jwt.ts +134 -0
  65. package/src/middleware/logger.ts +58 -0
  66. package/src/middleware/timeout.ts +55 -0
  67. package/src/paths.ts +0 -4
  68. package/src/plugins/hooks.ts +64 -0
  69. package/src/plugins/index.ts +3 -0
  70. package/src/plugins/types.ts +5 -0
  71. package/src/report/build.ts +0 -6
  72. package/src/resource/__tests__/backward-compat.test.ts +0 -1
  73. package/src/router/fs-patterns.ts +11 -1
  74. package/src/router/fs-routes.ts +78 -14
  75. package/src/router/fs-scanner.ts +2 -2
  76. package/src/router/fs-types.ts +2 -1
  77. package/src/runtime/adapter-bun.ts +62 -0
  78. package/src/runtime/adapter.ts +47 -0
  79. package/src/runtime/cache.ts +310 -0
  80. package/src/runtime/handler.ts +65 -0
  81. package/src/runtime/image-handler.ts +195 -0
  82. package/src/runtime/index.ts +12 -0
  83. package/src/runtime/middleware.ts +263 -0
  84. package/src/runtime/server.ts +686 -92
  85. package/src/runtime/ssr.ts +55 -29
  86. package/src/runtime/streaming-ssr.ts +106 -82
  87. package/src/spec/index.ts +0 -1
  88. package/src/spec/schema.ts +1 -0
  89. package/src/testing/index.ts +144 -0
  90. package/src/watcher/watcher.ts +27 -1
  91. package/src/spec/lock.ts +0 -56
@@ -0,0 +1,64 @@
1
+ /**
2
+ * CLI Lifecycle Hook System
3
+ *
4
+ * Lightweight hook runner for build/dev/start lifecycle events.
5
+ * Hooks defined in mandu.config.ts run first, then plugin hooks
6
+ * in registration order. Each hook is isolated -- one failure
7
+ * does not block subsequent hooks.
8
+ */
9
+
10
+ export interface ManduHooks {
11
+ onBeforeBuild?: () => void | Promise<void>;
12
+ onAfterBuild?: (result: { success: boolean; duration: number }) => void | Promise<void>;
13
+ onDevStart?: (info: { port: number; hostname: string }) => void | Promise<void>;
14
+ onDevStop?: () => void | Promise<void>;
15
+ onRouteChange?: (info: { routeId: string; pattern: string; kind: string }) => void | Promise<void>;
16
+ onBeforeStart?: () => void | Promise<void>;
17
+ }
18
+
19
+ export interface ManduPlugin {
20
+ name: string;
21
+ hooks?: Partial<ManduHooks>;
22
+ setup?: (config: Record<string, unknown>) => void | Promise<void>;
23
+ }
24
+
25
+ /**
26
+ * Run a named lifecycle hook across config-level hooks and plugins.
27
+ *
28
+ * Execution order:
29
+ * 1. Config hook (from mandu.config.ts `hooks` field)
30
+ * 2. Plugin hooks (from `plugins[].hooks`, in array order)
31
+ *
32
+ * Each invocation is wrapped in try/catch so a single failing hook
33
+ * does not prevent the remaining hooks from executing.
34
+ */
35
+ export async function runHook<K extends keyof ManduHooks>(
36
+ hookName: K,
37
+ plugins: ManduPlugin[],
38
+ configHooks: Partial<ManduHooks> | undefined,
39
+ ...args: Parameters<NonNullable<ManduHooks[K]>>
40
+ ): Promise<void> {
41
+ const invoke = async (
42
+ label: string,
43
+ fn: ((...a: unknown[]) => void | Promise<void>) | undefined,
44
+ ) => {
45
+ if (!fn) return;
46
+ try {
47
+ await fn(...args);
48
+ } catch (error) {
49
+ const msg = error instanceof Error ? error.message : String(error);
50
+ console.error(`[plugin] ${hookName} failed in ${label}: ${msg}`);
51
+ }
52
+ };
53
+
54
+ // Config-level hook runs first
55
+ await invoke("config", configHooks?.[hookName] as ((...a: unknown[]) => void | Promise<void>) | undefined);
56
+
57
+ // Plugin hooks run in registration order
58
+ for (const plugin of plugins) {
59
+ await invoke(
60
+ plugin.name,
61
+ plugin.hooks?.[hookName] as ((...a: unknown[]) => void | Promise<void>) | undefined,
62
+ );
63
+ }
64
+ }
@@ -36,3 +36,6 @@ export type {
36
36
  McpToolPlugin,
37
37
  MiddlewarePlugin,
38
38
  } from "./types";
39
+
40
+ export { runHook } from "./hooks";
41
+ export type { ManduPlugin, ManduHooks } from "./hooks";
@@ -334,6 +334,11 @@ export interface McpToolPlugin {
334
334
  */
335
335
  inputSchema: Record<string, unknown>;
336
336
 
337
+ /**
338
+ * MCP tool annotations (hints for clients)
339
+ */
340
+ annotations?: Record<string, unknown>;
341
+
337
342
  /**
338
343
  * 도구 실행
339
344
  */
@@ -18,9 +18,6 @@ export function buildGuardReport(checkResult: GuardCheckResult): ManuduReport {
18
18
  const nextActions: string[] = [];
19
19
 
20
20
  if (!checkResult.passed) {
21
- const hasHashMismatch = checkResult.violations.some(
22
- (v) => v.ruleId === "SPEC_HASH_MISMATCH"
23
- );
24
21
  const hasManualEdit = checkResult.violations.some(
25
22
  (v) => v.ruleId === "GENERATED_MANUAL_EDIT"
26
23
  );
@@ -31,9 +28,6 @@ export function buildGuardReport(checkResult: GuardCheckResult): ManuduReport {
31
28
  (v) => v.ruleId === "FORBIDDEN_IMPORT_IN_GENERATED"
32
29
  );
33
30
 
34
- if (hasHashMismatch) {
35
- nextActions.push("bunx mandu routes generate");
36
- }
37
31
  if (hasManualEdit) {
38
32
  nextActions.push("bunx mandu generate");
39
33
  }
@@ -43,7 +43,6 @@ describe("Backward Compatibility - Path Structure", () => {
43
43
  expect(paths.typesDir).toBeDefined();
44
44
  expect(paths.mapDir).toBeDefined();
45
45
  expect(paths.manifestPath).toBeDefined();
46
- expect(paths.lockPath).toBeDefined();
47
46
  });
48
47
 
49
48
  test("should not conflict with existing generated directories", async () => {
@@ -62,6 +62,15 @@ export function parseSegment(segment: string): RouteSegment {
62
62
  };
63
63
  }
64
64
 
65
+ // Slot / Parallel route: @name
66
+ if (segment.startsWith("@")) {
67
+ return {
68
+ raw: segment,
69
+ type: "slot",
70
+ paramName: segment.slice(1), // @modal → "modal"
71
+ };
72
+ }
73
+
65
74
  // Static segment
66
75
  return {
67
76
  raw: segment,
@@ -123,7 +132,7 @@ export function segmentsToPattern(segments: RouteSegment[]): string {
123
132
  }
124
133
 
125
134
  const parts = segments
126
- .filter((seg) => seg.type !== "group") // 그룹은 URL에 포함 안 됨
135
+ .filter((seg) => seg.type !== "group" && seg.type !== "slot") // 그룹과 slot(@name)은 URL에 포함 안 됨
127
136
  .map((seg) => segmentToPatternPart(seg));
128
137
 
129
138
  return "/" + parts.join("/");
@@ -272,6 +281,7 @@ export function generateRouteId(relativePath: string): string {
272
281
  const SEGMENT_PRIORITY: Record<SegmentType, number> = {
273
282
  static: 0,
274
283
  group: 1, // 그룹은 URL에 영향 없으므로 static과 동일
284
+ slot: 1, // slot(@name)도 URL에 영향 없음 — layout named prop으로 전달
275
285
  dynamic: 2,
276
286
  catchAll: 3,
277
287
  optionalCatchAll: 4,
@@ -6,7 +6,7 @@
6
6
  * @module router/fs-routes
7
7
  */
8
8
 
9
- import { writeFile, mkdir } from "fs/promises";
9
+ import { writeFile, readFile, mkdir } from "fs/promises";
10
10
  import { join, dirname } from "path";
11
11
  import type { RoutesManifest, RouteSpec } from "../spec/schema";
12
12
  import type { FSRouteConfig, FSScannerConfig, ScanResult } from "./fs-types";
@@ -50,33 +50,38 @@ export interface GenerateOptions {
50
50
  /**
51
51
  * FSRouteConfig를 RouteSpec으로 변환
52
52
  */
53
+ /** Normalize path separators to forward slashes for cross-platform consistency */
54
+ function normalizePath(p: string): string {
55
+ return p.replace(/\\/g, "/");
56
+ }
57
+
53
58
  export function fsRouteToRouteSpec(fsRoute: FSRouteConfig): RouteSpec {
54
59
  const base = {
55
60
  id: fsRoute.id,
56
61
  pattern: fsRoute.pattern,
57
- module: fsRoute.module,
62
+ module: normalizePath(fsRoute.module),
58
63
  };
59
64
 
60
65
  if (fsRoute.kind === "page") {
61
66
  const pageRoute: RouteSpec = {
62
67
  ...base,
63
68
  kind: "page" as const,
64
- componentModule: fsRoute.componentModule ?? "",
69
+ componentModule: normalizePath(fsRoute.componentModule ?? ""),
65
70
  ...(fsRoute.clientModule
66
71
  ? {
67
- clientModule: fsRoute.clientModule,
72
+ clientModule: normalizePath(fsRoute.clientModule),
68
73
  hydration: fsRoute.hydration ?? {
69
74
  strategy: "island" as const,
70
- priority: "visible" as const,
75
+ priority: "immediate" as const,
71
76
  preload: false,
72
77
  },
73
78
  }
74
79
  : {}),
75
80
  ...(fsRoute.layoutChain && fsRoute.layoutChain.length > 0
76
- ? { layoutChain: fsRoute.layoutChain }
81
+ ? { layoutChain: fsRoute.layoutChain.map(normalizePath) }
77
82
  : {}),
78
- ...(fsRoute.loadingModule ? { loadingModule: fsRoute.loadingModule } : {}),
79
- ...(fsRoute.errorModule ? { errorModule: fsRoute.errorModule } : {}),
83
+ ...(fsRoute.loadingModule ? { loadingModule: normalizePath(fsRoute.loadingModule) } : {}),
84
+ ...(fsRoute.errorModule ? { errorModule: normalizePath(fsRoute.errorModule) } : {}),
80
85
  };
81
86
  return pageRoute;
82
87
  }
@@ -115,19 +120,54 @@ export async function resolveAutoLinks(
115
120
  manifest: RoutesManifest,
116
121
  rootDir: string
117
122
  ): Promise<void> {
123
+ // Slot = server-side data loader (.slot.ts/.tsx)
124
+ // Client = client-side island module (.client.ts/.tsx) — sets clientModule, NOT slotModule
125
+ const slotExtensions = [".slot.ts", ".slot.tsx"];
126
+ const clientExtensions = [".client.ts", ".client.tsx"];
127
+
118
128
  await Promise.all(
119
129
  manifest.routes.map(async (route) => {
120
- const slotPath = join(rootDir, "spec", "slots", `${route.id}.slot.ts`);
121
130
  const contractPath = join(rootDir, "spec", "contracts", `${route.id}.contract.ts`);
122
131
 
123
- const [slotExists, contractExists] = await Promise.all([
124
- Bun.file(slotPath).exists(),
132
+ // Check all extensions in parallel
133
+ const slotChecks = slotExtensions.map(async (ext) => {
134
+ const path = join(rootDir, "spec", "slots", `${route.id}${ext}`);
135
+ return (await Bun.file(path).exists()) ? ext : null;
136
+ });
137
+ const clientChecks = clientExtensions.map(async (ext) => {
138
+ const path = join(rootDir, "spec", "slots", `${route.id}${ext}`);
139
+ return (await Bun.file(path).exists()) ? ext : null;
140
+ });
141
+
142
+ const [contractExists, ...allResults] = await Promise.all([
125
143
  Bun.file(contractPath).exists(),
144
+ ...slotChecks,
145
+ ...clientChecks,
126
146
  ]);
127
147
 
128
- if (slotExists) {
129
- route.slotModule = `spec/slots/${route.id}.slot.ts`;
148
+ const slotResults = allResults.slice(0, slotExtensions.length);
149
+ const clientResults = allResults.slice(slotExtensions.length);
150
+
151
+ // Set slotModule for .slot.ts(x) files (server data loaders)
152
+ const matchedSlotExt = slotResults.find((ext) => ext !== null);
153
+ if (matchedSlotExt) {
154
+ route.slotModule = `spec/slots/${route.id}${matchedSlotExt}`;
155
+ }
156
+
157
+ // Set clientModule for .client.ts(x) files (island hydration) — only if not already set
158
+ const matchedClientExt = clientResults.find((ext) => ext !== null);
159
+ if (matchedClientExt && !route.clientModule) {
160
+ route.clientModule = `spec/slots/${route.id}${matchedClientExt}`;
161
+ // Also set default hydration config if not present
162
+ if (!route.hydration) {
163
+ route.hydration = {
164
+ strategy: "island" as const,
165
+ priority: "immediate" as const,
166
+ preload: false,
167
+ };
168
+ }
130
169
  }
170
+
131
171
  if (contractExists) {
132
172
  route.contractModule = `spec/contracts/${route.id}.contract.ts`;
133
173
  }
@@ -189,10 +229,34 @@ export async function generateManifest(
189
229
  // Auto-linking: spec/slots/, spec/contracts/ 자동 연결
190
230
  await resolveAutoLinks(manifest, rootDir);
191
231
 
192
- // .mandu/ 디렉토리에 매니페스트 저장
232
+ // 기존 매니페스트에서 사용자 설정 필드 보존 (clientModule, hydration 등)
193
233
  const outputPath = options.outputPath ?? ".mandu/routes.manifest.json";
194
234
  const outputFullPath = join(rootDir, outputPath);
195
235
  await mkdir(dirname(outputFullPath), { recursive: true });
236
+
237
+ try {
238
+ const existingRaw = await readFile(outputFullPath, "utf-8");
239
+ const existingManifest = JSON.parse(existingRaw) as RoutesManifest;
240
+ if (existingManifest.routes) {
241
+ const existingMap = new Map(
242
+ existingManifest.routes.map((r) => [r.id, r])
243
+ );
244
+ for (const route of manifest.routes) {
245
+ const prev = existingMap.get(route.id);
246
+ if (!prev) continue;
247
+ // 사용자가 설정한 clientModule/hydration 보존
248
+ if (prev.clientModule && !route.clientModule) {
249
+ route.clientModule = prev.clientModule;
250
+ }
251
+ if (prev.hydration && !route.hydration) {
252
+ route.hydration = prev.hydration;
253
+ }
254
+ }
255
+ }
256
+ } catch {
257
+ // 기존 매니페스트가 없으면 무시
258
+ }
259
+
196
260
  await writeFile(outputFullPath, JSON.stringify(manifest, null, 2), "utf-8");
197
261
 
198
262
  return {
@@ -241,7 +241,7 @@ export class FSScanner {
241
241
  const pattern = segmentsToPattern(file.segments);
242
242
  const patternShape = getPatternShape(pattern);
243
243
  const routeId = generateRouteId(file.relativePath);
244
- const modulePath = join(this.config.routesDir, file.relativePath);
244
+ const modulePath = join(this.config.routesDir, file.relativePath).replace(/\\/g, "/");
245
245
 
246
246
  // 중복 패턴 체크
247
247
  const existingRoute = patternMap.get(pattern);
@@ -288,7 +288,7 @@ export class FSScanner {
288
288
 
289
289
  if (islands?.[0]) {
290
290
  // 우선순위: 명시적 island 파일
291
- clientModule = join(this.config.routesDir, islands[0].relativePath);
291
+ clientModule = join(this.config.routesDir, islands[0].relativePath).replace(/\\/g, "/");
292
292
 
293
293
  // SSR shell + island placeholder 패턴은 hydration mismatch 위험이 매우 높으므로 에러로 처리
294
294
  if (pageFileContent && this.hasHydrationShellMismatchRisk(pageFileContent, islands[0].relativePath)) {
@@ -20,7 +20,8 @@ export type SegmentType =
20
20
  | "dynamic" // 동적 세그먼트 (예: "[slug]")
21
21
  | "catchAll" // Catch-all 세그먼트 (예: "[...path]")
22
22
  | "optionalCatchAll" // Optional catch-all (예: "[[...path]]")
23
- | "group"; // 라우트 그룹 (예: "(marketing)")
23
+ | "group" // 라우트 그룹 (예: "(marketing)")
24
+ | "slot"; // Named slot / Parallel route (예: "@modal")
24
25
 
25
26
  /**
26
27
  * 파싱된 라우트 세그먼트
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Mandu Bun Adapter (기본 어댑터)
3
+ * Bun.serve() 기반 서버 생성
4
+ */
5
+
6
+ import type { ManduAdapter, AdapterOptions, AdapterServer } from "./adapter";
7
+ import { startServer, type ManduServer } from "./server";
8
+
9
+ /**
10
+ * Bun 어댑터 (기본)
11
+ *
12
+ * @example
13
+ * ```typescript
14
+ * // mandu.config.ts
15
+ * import { adapterBun } from "@mandujs/core";
16
+ *
17
+ * export default {
18
+ * adapter: adapterBun(),
19
+ * };
20
+ * ```
21
+ */
22
+ export function adapterBun(): ManduAdapter {
23
+ return {
24
+ name: "adapter-bun",
25
+
26
+ createServer(options: AdapterOptions): AdapterServer {
27
+ let manduServer: ManduServer | null = null;
28
+
29
+ return {
30
+ async fetch(req: Request): Promise<Response> {
31
+ if (!manduServer) {
32
+ return new Response("Server not started", { status: 503 });
33
+ }
34
+ // 내부 서버로 프록시
35
+ const url = new URL(req.url);
36
+ const targetUrl = `http://localhost:${manduServer.server.port}${url.pathname}${url.search}`;
37
+ return globalThis.fetch(new Request(targetUrl, req));
38
+ },
39
+
40
+ async listen(port: number, hostname?: string) {
41
+ manduServer = startServer(options.manifest, {
42
+ ...options.serverOptions,
43
+ port,
44
+ hostname,
45
+ rootDir: options.rootDir,
46
+ bundleManifest: options.bundleManifest,
47
+ });
48
+
49
+ return {
50
+ port: manduServer.server.port ?? port,
51
+ hostname: hostname ?? "localhost",
52
+ };
53
+ },
54
+
55
+ async close() {
56
+ manduServer?.stop();
57
+ manduServer = null;
58
+ },
59
+ };
60
+ },
61
+ };
62
+ }
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Mandu Adapter Interface
3
+ * 런타임 중립적 서버 어댑터 추상화
4
+ */
5
+
6
+ import type { RoutesManifest } from "../spec/schema";
7
+ import type { BundleManifest } from "../bundler/types";
8
+ import type { ServerOptions } from "./server";
9
+
10
+ // ========== Types ==========
11
+
12
+ export interface AdapterOptions {
13
+ manifest: RoutesManifest;
14
+ bundleManifest?: BundleManifest;
15
+ rootDir: string;
16
+ serverOptions: ServerOptions;
17
+ }
18
+
19
+ export interface AdapterServer {
20
+ /** fetch handler (Web Fetch API — 런타임 중립) */
21
+ fetch: (request: Request) => Promise<Response>;
22
+ /** 서버 시작 */
23
+ listen(port: number, hostname?: string): Promise<{ port: number; hostname: string }>;
24
+ /** 서버 중지 */
25
+ close(): Promise<void>;
26
+ }
27
+
28
+ /**
29
+ * Mandu Adapter 인터페이스
30
+ *
31
+ * @example
32
+ * ```typescript
33
+ * // mandu.config.ts
34
+ * import adapterBun from "@mandujs/adapter-bun";
35
+ *
36
+ * export default {
37
+ * adapter: adapterBun(),
38
+ * };
39
+ * ```
40
+ */
41
+ export interface ManduAdapter {
42
+ name: string;
43
+ /** 빌드 타임: 배포 산출물 생성 (SSG, 서버리스 번들 등) */
44
+ build?(options: AdapterOptions): Promise<void>;
45
+ /** 런타임: 서버 인스턴스 생성 */
46
+ createServer(options: AdapterOptions): AdapterServer;
47
+ }