@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
@@ -47,6 +47,78 @@ function getHydratedRoutes(manifest: RoutesManifest): RouteSpec[] {
47
47
  );
48
48
  }
49
49
 
50
+ const REACT_SHIM_EXPORTS = [
51
+ "Activity",
52
+ "Children",
53
+ "Component",
54
+ "Fragment",
55
+ "Profiler",
56
+ "PureComponent",
57
+ "StrictMode",
58
+ "Suspense",
59
+ "__COMPILER_RUNTIME",
60
+ "act",
61
+ "cache",
62
+ "cacheSignal",
63
+ "captureOwnerStack",
64
+ "cloneElement",
65
+ "createContext",
66
+ "createElement",
67
+ "createRef",
68
+ "forwardRef",
69
+ "isValidElement",
70
+ "lazy",
71
+ "memo",
72
+ "startTransition",
73
+ "unstable_useCacheRefresh",
74
+ "use",
75
+ "useActionState",
76
+ "useCallback",
77
+ "useContext",
78
+ "useDebugValue",
79
+ "useDeferredValue",
80
+ "useEffect",
81
+ "useEffectEvent",
82
+ "useId",
83
+ "useImperativeHandle",
84
+ "useInsertionEffect",
85
+ "useLayoutEffect",
86
+ "useMemo",
87
+ "useOptimistic",
88
+ "useReducer",
89
+ "useRef",
90
+ "useState",
91
+ "useSyncExternalStore",
92
+ "useTransition",
93
+ "version",
94
+ ] as const;
95
+
96
+ const REACT_DOM_SHIM_EXPORTS = [
97
+ "createPortal",
98
+ "flushSync",
99
+ "preconnect",
100
+ "prefetchDNS",
101
+ "preinit",
102
+ "preinitModule",
103
+ "preload",
104
+ "preloadModule",
105
+ "requestFormReset",
106
+ "unstable_batchedUpdates",
107
+ "useFormState",
108
+ "useFormStatus",
109
+ "version",
110
+ ] as const;
111
+
112
+ const REACT_DOM_CLIENT_SHIM_EXPORTS = [
113
+ "createRoot",
114
+ "hydrateRoot",
115
+ "version",
116
+ ] as const;
117
+
118
+ function formatShimBindings(names: readonly string[], indent = " "): string {
119
+ return names.map((name) => `${indent}${name},`).join("\n");
120
+ }
121
+
50
122
  /**
51
123
  * Runtime 번들 소스 생성 (v0.8.0 재설계)
52
124
  *
@@ -66,7 +138,7 @@ function generateRuntimeSource(): string {
66
138
 
67
139
  // React 정적 import (Island와 같은 인스턴스 공유)
68
140
  import React, { useState, useEffect, Component } from 'react';
69
- import { hydrateRoot } from 'react-dom/client';
141
+ import { hydrateRoot, createRoot } from 'react-dom/client';
70
142
 
71
143
  // Hydrated roots 추적 (unmount용) - 전역 초기화
72
144
  window.__MANDU_ROOTS__ = window.__MANDU_ROOTS__ || new Map();
@@ -147,6 +219,67 @@ function IslandLoadingWrapper({ children, loading, isReady }) {
147
219
  return children;
148
220
  }
149
221
 
222
+ function resolveHydrationTarget(element) {
223
+ if (!(element instanceof HTMLElement)) {
224
+ return element;
225
+ }
226
+
227
+ if (getComputedStyle(element).display !== 'contents') {
228
+ return element;
229
+ }
230
+
231
+ const queue = Array.from(element.children);
232
+ while (queue.length > 0) {
233
+ const candidate = queue.shift();
234
+ if (candidate instanceof HTMLElement) {
235
+ return candidate;
236
+ }
237
+ if (candidate) {
238
+ queue.push(...candidate.children);
239
+ }
240
+ }
241
+
242
+ return element.parentElement || element;
243
+ }
244
+
245
+ function hasHydratableMarkup(element) {
246
+ for (const node of element.childNodes) {
247
+ if (node.nodeType === Node.ELEMENT_NODE) {
248
+ return true;
249
+ }
250
+
251
+ if (node.nodeType === Node.TEXT_NODE && node.textContent && node.textContent.trim() !== '') {
252
+ return true;
253
+ }
254
+ }
255
+
256
+ return false;
257
+ }
258
+
259
+ function shouldHydrateCompiledIsland(element) {
260
+ return (
261
+ element.getAttribute('data-mandu-loading') !== 'true' &&
262
+ hasHydratableMarkup(element)
263
+ );
264
+ }
265
+
266
+ function createHydrationOptions(element, id, mode) {
267
+ return {
268
+ onRecoverableError(error) {
269
+ element.setAttribute('data-mandu-recoverable-error', 'true');
270
+ console.warn('[Mandu] Recoverable hydration error:', id, mode, error);
271
+ element.dispatchEvent(new CustomEvent('mandu:recoverable-hydration-error', {
272
+ bubbles: true,
273
+ detail: {
274
+ id,
275
+ mode,
276
+ error: error instanceof Error ? error.message : String(error),
277
+ },
278
+ }));
279
+ },
280
+ };
281
+ }
282
+
150
283
  /**
151
284
  * Hydration 스케줄러
152
285
  */
@@ -164,7 +297,8 @@ function scheduleHydration(element, src, priority) {
164
297
  loadAndHydrate(element, src);
165
298
  }
166
299
  }, { rootMargin: '50px' });
167
- observer.observe(element);
300
+ const target = resolveHydrationTarget(element);
301
+ observer.observe(target);
168
302
  } else {
169
303
  loadAndHydrate(element, src);
170
304
  }
@@ -179,15 +313,20 @@ function scheduleHydration(element, src, priority) {
179
313
  break;
180
314
 
181
315
  case 'interaction': {
316
+ const target = resolveHydrationTarget(element);
182
317
  const hydrate = () => {
183
- element.removeEventListener('mouseenter', hydrate);
184
- element.removeEventListener('focusin', hydrate);
185
- element.removeEventListener('touchstart', hydrate);
318
+ target.removeEventListener('mouseenter', hydrate);
319
+ target.removeEventListener('focusin', hydrate);
320
+ target.removeEventListener('touchstart', hydrate);
321
+ target.removeEventListener('pointerdown', hydrate);
322
+ target.removeEventListener('keydown', hydrate);
186
323
  loadAndHydrate(element, src);
187
324
  };
188
- element.addEventListener('mouseenter', hydrate, { once: true, passive: true });
189
- element.addEventListener('focusin', hydrate, { once: true });
190
- element.addEventListener('touchstart', hydrate, { once: true, passive: true });
325
+ target.addEventListener('mouseenter', hydrate, { once: true, passive: true });
326
+ target.addEventListener('focusin', hydrate, { once: true });
327
+ target.addEventListener('touchstart', hydrate, { once: true, passive: true });
328
+ target.addEventListener('pointerdown', hydrate, { once: true, passive: true });
329
+ target.addEventListener('keydown', hydrate, { once: true });
191
330
  break;
192
331
  }
193
332
  }
@@ -200,20 +339,47 @@ function scheduleHydration(element, src, priority) {
200
339
  */
201
340
  async function loadAndHydrate(element, src) {
202
341
  const id = element.getAttribute('data-mandu-island');
342
+ if (!id) {
343
+ return;
344
+ }
345
+
346
+ if (
347
+ hydratedRoots.has(id) ||
348
+ element.hasAttribute('data-mandu-hydrated') ||
349
+ element.getAttribute('data-mandu-hydrating') === 'true'
350
+ ) {
351
+ return;
352
+ }
353
+
354
+ element.setAttribute('data-mandu-hydrating', 'true');
203
355
 
204
356
  try {
205
357
  // Dynamic import - 이 시점에 Island 모듈 로드
206
358
  const module = await import(src);
207
359
  const island = module.default;
208
- const data = getServerData(id);
360
+ let data = getServerData(id);
361
+
362
+ // Fallback: read data-props from child element if __MANDU_DATA__ is empty
363
+ if (!data || Object.keys(data).length === 0) {
364
+ const propsEl = element.querySelector('[data-props]');
365
+ if (propsEl) {
366
+ try {
367
+ data = JSON.parse(propsEl.getAttribute('data-props'));
368
+ } catch (e) {
369
+ console.warn('[Mandu] Failed to parse data-props fallback:', e);
370
+ }
371
+ }
372
+ }
209
373
 
210
374
  // Mandu Island (preferred)
211
375
  if (island && island.__mandu_island === true) {
212
376
  const { definition } = island;
377
+ const shouldHydrate = shouldHydrateCompiledIsland(element);
378
+ const renderMode = shouldHydrate ? 'hydrate' : 'mount';
213
379
 
214
380
  // Island 컴포넌트 (Error Boundary + Loading 지원)
215
- function IslandComponent() {
216
- const [isReady, setIsReady] = useState(false);
381
+ function IslandComponent({ initialReady }) {
382
+ const [isReady, setIsReady] = useState(initialReady);
217
383
 
218
384
  useEffect(() => {
219
385
  setIsReady(true);
@@ -238,11 +404,22 @@ async function loadAndHydrate(element, src) {
238
404
  }, wrappedContent);
239
405
  }
240
406
 
241
- // Hydrate (SSR DOM 재사용 + 이벤트 연결)
242
- const root = hydrateRoot(element, React.createElement(IslandComponent));
407
+ const root = shouldHydrate
408
+ ? hydrateRoot(
409
+ element,
410
+ React.createElement(IslandComponent, { initialReady: true }),
411
+ createHydrationOptions(element, id, renderMode)
412
+ )
413
+ : createRoot(element);
414
+
415
+ if (!shouldHydrate) {
416
+ root.render(React.createElement(IslandComponent, { initialReady: false }));
417
+ }
418
+
243
419
  hydratedRoots.set(id, root);
244
420
 
245
421
  // 완료 표시
422
+ element.setAttribute('data-mandu-render-mode', renderMode);
246
423
  element.setAttribute('data-mandu-hydrated', 'true');
247
424
 
248
425
  // 성능 마커
@@ -253,22 +430,47 @@ async function loadAndHydrate(element, src) {
253
430
  // 이벤트 발송
254
431
  element.dispatchEvent(new CustomEvent('mandu:hydrated', {
255
432
  bubbles: true,
256
- detail: { id, data }
433
+ detail: { id, data, mode: renderMode }
257
434
  }));
258
435
 
259
- console.log('[Mandu] Hydrated:', id);
436
+ // Kitchen DevTools에 island 등록
437
+ if (window.__MANDU_DEVTOOLS_HOOK__) {
438
+ const hydrateTime = performance.now ? performance.now() : Date.now();
439
+ window.__MANDU_DEVTOOLS_HOOK__.emit({
440
+ type: 'island:register',
441
+ timestamp: Date.now(),
442
+ data: {
443
+ id,
444
+ name: id,
445
+ strategy: element.getAttribute('data-mandu-priority') || 'visible',
446
+ status: 'hydrated',
447
+ renderMode,
448
+ hydrateStartTime: hydrateTime - 10,
449
+ hydrateEndTime: hydrateTime,
450
+ propsSize: JSON.stringify(data).length,
451
+ },
452
+ });
453
+ }
454
+
455
+ console.log('[Mandu] Hydrated:', id, '(' + renderMode + ')');
260
456
  }
261
457
  // Plain React component fallback (e.g. "use client" pages)
262
458
  else if (typeof island === 'function' || React.isValidElement(island)) {
263
459
  console.warn('[Mandu] Plain component hydration:', id);
460
+ const renderMode = 'hydrate';
264
461
 
265
462
  const root = typeof island === 'function'
266
- ? hydrateRoot(element, React.createElement(island, data))
267
- : hydrateRoot(element, island);
463
+ ? hydrateRoot(
464
+ element,
465
+ React.createElement(island, data),
466
+ createHydrationOptions(element, id, renderMode)
467
+ )
468
+ : hydrateRoot(element, island, createHydrationOptions(element, id, renderMode));
268
469
 
269
470
  hydratedRoots.set(id, root);
270
471
 
271
472
  // 완료 표시
473
+ element.setAttribute('data-mandu-render-mode', renderMode);
272
474
  element.setAttribute('data-mandu-hydrated', 'true');
273
475
 
274
476
  // 성능 마커
@@ -279,10 +481,10 @@ async function loadAndHydrate(element, src) {
279
481
  // 이벤트 발송
280
482
  element.dispatchEvent(new CustomEvent('mandu:hydrated', {
281
483
  bubbles: true,
282
- detail: { id, data }
484
+ detail: { id, data, mode: renderMode }
283
485
  }));
284
486
 
285
- console.log('[Mandu] Plain component hydrated:', id);
487
+ console.log('[Mandu] Plain component hydrated:', id, '(' + renderMode + ')');
286
488
  }
287
489
  else {
288
490
  throw new Error('[Mandu] Invalid module: expected Mandu island or React component: ' + id);
@@ -296,6 +498,8 @@ async function loadAndHydrate(element, src) {
296
498
  bubbles: true,
297
499
  detail: { id, error: error.message }
298
500
  }));
501
+ } finally {
502
+ element.removeAttribute('data-mandu-hydrating');
299
503
  }
300
504
  }
301
505
 
@@ -363,42 +567,7 @@ function generateReactShimSource(): string {
363
567
  * import map을 통해 bare specifier 해결
364
568
  */
365
569
  import React, {
366
- // Core
367
- createElement,
368
- cloneElement,
369
- createContext,
370
- createRef,
371
- forwardRef,
372
- isValidElement,
373
- memo,
374
- lazy,
375
- // Hooks
376
- useState,
377
- useEffect,
378
- useContext,
379
- useReducer,
380
- useCallback,
381
- useMemo,
382
- useRef,
383
- useLayoutEffect,
384
- useImperativeHandle,
385
- useDebugValue,
386
- useDeferredValue,
387
- useTransition,
388
- useId,
389
- useSyncExternalStore,
390
- useInsertionEffect,
391
- // Components
392
- Fragment,
393
- Suspense,
394
- StrictMode,
395
- Profiler,
396
- // Misc
397
- version,
398
- // Types
399
- Component,
400
- PureComponent,
401
- Children,
570
+ ${formatShimBindings(REACT_SHIM_EXPORTS)}
402
571
  } from 'react';
403
572
 
404
573
  // JSX Runtime functions (JSX 변환에 필요)
@@ -426,37 +595,7 @@ if (typeof window !== 'undefined') {
426
595
 
427
596
  // Named exports
428
597
  export {
429
- createElement,
430
- cloneElement,
431
- createContext,
432
- createRef,
433
- forwardRef,
434
- isValidElement,
435
- memo,
436
- lazy,
437
- useState,
438
- useEffect,
439
- useContext,
440
- useReducer,
441
- useCallback,
442
- useMemo,
443
- useRef,
444
- useLayoutEffect,
445
- useImperativeHandle,
446
- useDebugValue,
447
- useDeferredValue,
448
- useTransition,
449
- useId,
450
- useSyncExternalStore,
451
- useInsertionEffect,
452
- Fragment,
453
- Suspense,
454
- StrictMode,
455
- Profiler,
456
- version,
457
- Component,
458
- PureComponent,
459
- Children,
598
+ ${formatShimBindings(REACT_SHIM_EXPORTS)}
460
599
  __CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE,
461
600
  __SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED,
462
601
  // JSX Runtime exports
@@ -480,16 +619,12 @@ function generateReactDOMShimSource(): string {
480
619
  * Mandu React DOM Shim (Generated)
481
620
  */
482
621
  import ReactDOM, {
483
- createPortal,
484
- flushSync,
485
- version,
622
+ ${formatShimBindings(REACT_DOM_SHIM_EXPORTS)}
486
623
  } from 'react-dom';
487
624
 
488
625
  // Named exports
489
626
  export {
490
- createPortal,
491
- flushSync,
492
- version,
627
+ ${formatShimBindings(REACT_DOM_SHIM_EXPORTS)}
493
628
  };
494
629
 
495
630
  // Default export
@@ -506,13 +641,19 @@ function generateReactDOMClientShimSource(): string {
506
641
  /**
507
642
  * Mandu React DOM Client Shim (Generated)
508
643
  */
509
- import { createRoot, hydrateRoot } from 'react-dom/client';
644
+ import {
645
+ ${formatShimBindings(REACT_DOM_CLIENT_SHIM_EXPORTS)}
646
+ } from 'react-dom/client';
510
647
 
511
648
  // Named exports (명시적으로 re-export)
512
- export { createRoot, hydrateRoot };
649
+ export {
650
+ ${formatShimBindings(REACT_DOM_CLIENT_SHIM_EXPORTS)}
651
+ };
513
652
 
514
653
  // Default export
515
- export default { createRoot, hydrateRoot };
654
+ export default {
655
+ ${formatShimBindings(REACT_DOM_CLIENT_SHIM_EXPORTS)}
656
+ };
516
657
  `;
517
658
  }
518
659
 
@@ -1087,7 +1228,7 @@ async function buildIsland(
1087
1228
  minify: options.minify ?? process.env.NODE_ENV === "production",
1088
1229
  sourcemap: options.sourcemap ? "external" : "none",
1089
1230
  target: "browser",
1090
- splitting: options.splitting ?? false,
1231
+ splitting: options.splitting ?? (process.env.NODE_ENV === "production"),
1091
1232
  external: ["react", "react-dom", "react-dom/client", ...(options.external || [])],
1092
1233
  define: {
1093
1234
  "process.env.NODE_ENV": JSON.stringify(process.env.NODE_ENV || "development"),
@@ -1305,24 +1446,28 @@ export async function buildClientBundles(
1305
1446
  return buildClientBundles(manifest, rootDir, { ...options, targetRouteIds: undefined });
1306
1447
  }
1307
1448
 
1308
- for (const output of outputs) {
1309
- if (existingManifest.bundles[output.routeId]) {
1310
- existingManifest.bundles[output.routeId].js = output.outputPath;
1311
- } else {
1312
- const route = targetRoutes.find((r) => r.id === output.routeId);
1313
- const hydration = route ? getRouteHydration(route) : null;
1314
- existingManifest.bundles[output.routeId] = {
1315
- js: output.outputPath,
1316
- dependencies: ["_runtime", "_react"],
1317
- priority: hydration?.priority || HYDRATION.DEFAULT_PRIORITY,
1318
- };
1449
+ // Only update manifest with successfully built outputs (#10: preserve previous good manifest on failure)
1450
+ if (outputs.length > 0) {
1451
+ for (const output of outputs) {
1452
+ if (existingManifest.bundles[output.routeId]) {
1453
+ existingManifest.bundles[output.routeId].js = output.outputPath;
1454
+ } else {
1455
+ const route = targetRoutes.find((r) => r.id === output.routeId);
1456
+ const hydration = route ? getRouteHydration(route) : null;
1457
+ existingManifest.bundles[output.routeId] = {
1458
+ js: output.outputPath,
1459
+ dependencies: ["_runtime", "_react"],
1460
+ priority: hydration?.priority || HYDRATION.DEFAULT_PRIORITY,
1461
+ };
1462
+ }
1319
1463
  }
1320
- }
1321
1464
 
1322
- await fs.writeFile(
1323
- path.join(rootDir, ".mandu/manifest.json"),
1324
- JSON.stringify(existingManifest, null, 2)
1325
- );
1465
+ await fs.writeFile(
1466
+ path.join(rootDir, ".mandu/manifest.json"),
1467
+ JSON.stringify(existingManifest, null, 2)
1468
+ );
1469
+ }
1470
+ // When all builds failed, do NOT overwrite manifest — keep previous good state
1326
1471
 
1327
1472
  const stats = calculateStats(outputs, startTime);
1328
1473
  return { success: errors.length === 0, outputs, errors, manifest: existingManifest, stats };
@@ -1356,13 +1501,46 @@ export async function buildClientBundles(
1356
1501
  console.warn("[Mandu] DevTools bundle build failed:", devtoolsResult.errors.join(", "));
1357
1502
  }
1358
1503
 
1504
+ // 4.5. Pre-build validation: detect wrong import paths in island files
1505
+ for (const route of hydratedRoutes) {
1506
+ if (route.clientModule) {
1507
+ const clientModulePath = path.join(rootDir, route.clientModule);
1508
+ try {
1509
+ const source = await fs.readFile(clientModulePath, "utf-8");
1510
+ // Match imports from "@mandujs/core" but NOT "@mandujs/core/client" or other subpaths
1511
+ const wrongImportPattern = /(?:import|from)\s+['"]@mandujs\/core['"]|require\s*\(\s*['"]@mandujs\/core['"]\s*\)/;
1512
+ if (wrongImportPattern.test(source)) {
1513
+ const errMsg =
1514
+ `[${route.id}] Island file "${route.clientModule}" imports from "@mandujs/core" which is a server-side module.\n` +
1515
+ ` Fix: Change the import to "@mandujs/core/client".\n` +
1516
+ ` Client islands cannot use server-side modules.`;
1517
+ console.error(`\n\x1b[31mERROR: ${errMsg}\x1b[0m\n`);
1518
+ errors.push(errMsg);
1519
+ }
1520
+ } catch {
1521
+ // File read failure will be caught later during build
1522
+ }
1523
+ }
1524
+ }
1525
+
1359
1526
  // 5. 각 Island 번들 빌드
1360
1527
  for (const route of hydratedRoutes) {
1361
1528
  try {
1362
1529
  const result = await buildIsland(route, rootDir, outDir, options);
1363
1530
  outputs.push(result);
1364
1531
  } catch (error) {
1365
- errors.push(`[${route.id}] ${String(error)}`);
1532
+ const errorStr = String(error);
1533
+ // Detect common mistake: importing @mandujs/core (server module) in client island
1534
+ if (errorStr.includes("AggregateError") || errorStr.includes("Could not resolve")) {
1535
+ const clientModule = route.clientModule || "";
1536
+ errors.push(
1537
+ `[${route.id}] ${errorStr}\n` +
1538
+ ` 💡 Hint: If your island imports from "@mandujs/core", change it to "@mandujs/core/client".\n` +
1539
+ ` Client islands cannot use server-side modules. File: ${clientModule}`
1540
+ );
1541
+ } else {
1542
+ errors.push(`[${route.id}] ${errorStr}`);
1543
+ }
1366
1544
  }
1367
1545
  }
1368
1546
 
@@ -8,11 +8,24 @@
8
8
  * - 출력: .mandu/client/globals.css
9
9
  */
10
10
 
11
- import { spawn, type Subprocess } from "bun";
11
+ import { spawn, which, type Subprocess } from "bun";
12
12
  import path from "path";
13
13
  import fs from "fs/promises";
14
14
  import { watch as fsWatch, type FSWatcher } from "fs";
15
15
 
16
+ /**
17
+ * Tailwind CLI 실행 명령어를 결정한다.
18
+ * bunx가 PATH에 없는 환경(일부 Windows/CI)에서도 동작하도록
19
+ * `bun x`로 fallback한다.
20
+ */
21
+ function getTailwindCommand(args: string[]): string[] {
22
+ if (which("bunx")) {
23
+ return ["bunx", ...args];
24
+ }
25
+ // bunx shim이 없어도 `bun x`는 동작함
26
+ return ["bun", "x", ...args];
27
+ }
28
+
16
29
  // ========== Types ==========
17
30
 
18
31
  export interface CSSBuildOptions {
@@ -124,7 +137,7 @@ export async function buildCSS(options: CSSBuildOptions): Promise<CSSBuildResult
124
137
  }
125
138
 
126
139
  try {
127
- const proc = spawn(["bunx", ...args], {
140
+ const proc = spawn(getTailwindCommand(args), {
128
141
  cwd: rootDir,
129
142
  stdout: "pipe",
130
143
  stderr: "pipe",
@@ -206,7 +219,7 @@ export async function startCSSWatch(options: CSSBuildOptions): Promise<CSSWatche
206
219
  // Bun subprocess로 Tailwind CLI 실행
207
220
  let proc;
208
221
  try {
209
- proc = spawn(["bunx", ...args], {
222
+ proc = spawn(getTailwindCommand(args), {
210
223
  cwd: rootDir,
211
224
  stdout: "pipe",
212
225
  stderr: "pipe",
@@ -275,7 +288,9 @@ export async function startCSSWatch(options: CSSBuildOptions): Promise<CSSWatche
275
288
  const { done, value } = await reader.read();
276
289
  if (done) break;
277
290
 
278
- const text = decoder.decode(value).trim();
291
+ const rawText = decoder.decode(value).trim();
292
+ // ANSI 이스케이프 코드 제거 후 비교 (Tailwind CLI가 컬러 출력)
293
+ const text = rawText.replace(/\u001b\[[0-9;]*m/g, "").trim();
279
294
  if (text) {
280
295
  // 환경 경고 무시
281
296
  if (text.includes(".bash_profile") || text.includes("$'\\377")) {
@@ -287,7 +302,7 @@ export async function startCSSWatch(options: CSSBuildOptions): Promise<CSSWatche
287
302
  text.includes("Resolving dependencies") ||
288
303
  text.includes("Resolved, downloaded") ||
289
304
  text.includes("Saved lockfile") ||
290
- text.includes("tailwindcss") ||
305
+ text.includes("tailwindcss") ||
291
306
  text.match(/^v?\d+\.\d+\.\d+/) // 버전 출력
292
307
  ) {
293
308
  if (text) console.log(` ℹ️ CSS: ${text}`);