@mandujs/core 0.5.2 → 0.5.3

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mandujs/core",
3
- "version": "0.5.2",
3
+ "version": "0.5.3",
4
4
  "description": "Mandu Framework Core - Spec, Generator, Guard, Runtime",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -28,6 +28,9 @@ function createEmptyManifest(env: "development" | "production"): BundleManifest
28
28
  runtime: "",
29
29
  vendor: "",
30
30
  },
31
+ importMap: {
32
+ imports: {},
33
+ },
31
34
  };
32
35
  }
33
36
 
@@ -201,18 +204,45 @@ export { islandRegistry, hydratedRoots };
201
204
  }
202
205
 
203
206
  /**
204
- * Vendor 번들 소스 생성 (React 등 공유 의존성)
207
+ * React shim 소스 생성 (import map용)
205
208
  */
206
- function generateVendorSource(): string {
209
+ function generateReactShimSource(): string {
207
210
  return `
208
211
  /**
209
- * Mandu Vendor Bundle (Generated)
210
- * 공유 의존성
212
+ * Mandu React Shim (Generated)
213
+ * import map을 통해 bare specifier 해결
211
214
  */
215
+ import * as React from 'react';
216
+ export * from 'react';
217
+ export default React;
218
+ `;
219
+ }
212
220
 
213
- export * as React from 'react';
214
- export * as ReactDOM from 'react-dom';
215
- export * as ReactDOMClient from 'react-dom/client';
221
+ /**
222
+ * React DOM shim 소스 생성
223
+ */
224
+ function generateReactDOMShimSource(): string {
225
+ return `
226
+ /**
227
+ * Mandu React DOM Shim (Generated)
228
+ */
229
+ import * as ReactDOM from 'react-dom';
230
+ export * from 'react-dom';
231
+ export default ReactDOM;
232
+ `;
233
+ }
234
+
235
+ /**
236
+ * React DOM Client shim 소스 생성
237
+ */
238
+ function generateReactDOMClientShimSource(): string {
239
+ return `
240
+ /**
241
+ * Mandu React DOM Client Shim (Generated)
242
+ */
243
+ import * as ReactDOMClient from 'react-dom/client';
244
+ export * from 'react-dom/client';
245
+ export default ReactDOMClient;
216
246
  `;
217
247
  }
218
248
 
@@ -292,57 +322,77 @@ async function buildRuntime(
292
322
  }
293
323
 
294
324
  /**
295
- * Vendor 번들 빌드
325
+ * Vendor shim 번들 빌드 결과
326
+ */
327
+ interface VendorBuildResult {
328
+ success: boolean;
329
+ react: string;
330
+ reactDom: string;
331
+ reactDomClient: string;
332
+ errors: string[];
333
+ }
334
+
335
+ /**
336
+ * Vendor shim 번들 빌드
337
+ * React, ReactDOM, ReactDOMClient를 각각의 shim으로 빌드
296
338
  */
297
- async function buildVendor(
339
+ async function buildVendorShims(
298
340
  outDir: string,
299
341
  options: BundlerOptions
300
- ): Promise<{ success: boolean; outputPath: string; errors: string[] }> {
301
- const vendorPath = path.join(outDir, "_vendor.src.js");
302
- const outputName = "_vendor.js";
342
+ ): Promise<VendorBuildResult> {
343
+ const errors: string[] = [];
344
+ const results: Record<string, string> = {
345
+ react: "",
346
+ reactDom: "",
347
+ reactDomClient: "",
348
+ };
303
349
 
304
- try {
305
- // 벤더 소스 작성
306
- await Bun.write(vendorPath, generateVendorSource());
350
+ const shims = [
351
+ { name: "_react", source: generateReactShimSource(), key: "react" },
352
+ { name: "_react-dom", source: generateReactDOMShimSource(), key: "reactDom" },
353
+ { name: "_react-dom-client", source: generateReactDOMClientShimSource(), key: "reactDomClient" },
354
+ ];
307
355
 
308
- // 빌드
309
- const result = await Bun.build({
310
- entrypoints: [vendorPath],
311
- outdir: outDir,
312
- naming: outputName,
313
- minify: options.minify ?? process.env.NODE_ENV === "production",
314
- sourcemap: options.sourcemap ? "external" : "none",
315
- target: "browser",
316
- define: {
317
- "process.env.NODE_ENV": JSON.stringify(process.env.NODE_ENV || "development"),
318
- ...options.define,
319
- },
320
- });
356
+ for (const shim of shims) {
357
+ const srcPath = path.join(outDir, `${shim.name}.src.js`);
358
+ const outputName = `${shim.name}.js`;
321
359
 
322
- // 소스 파일 정리
323
- await fs.unlink(vendorPath).catch(() => {});
324
-
325
- if (!result.success) {
326
- return {
327
- success: false,
328
- outputPath: "",
329
- errors: result.logs.map((l) => l.message),
330
- };
360
+ try {
361
+ await Bun.write(srcPath, shim.source);
362
+
363
+ const result = await Bun.build({
364
+ entrypoints: [srcPath],
365
+ outdir: outDir,
366
+ naming: outputName,
367
+ minify: options.minify ?? process.env.NODE_ENV === "production",
368
+ sourcemap: options.sourcemap ? "external" : "none",
369
+ target: "browser",
370
+ define: {
371
+ "process.env.NODE_ENV": JSON.stringify(process.env.NODE_ENV || "development"),
372
+ ...options.define,
373
+ },
374
+ });
375
+
376
+ await fs.unlink(srcPath).catch(() => {});
377
+
378
+ if (!result.success) {
379
+ errors.push(`[${shim.name}] ${result.logs.map((l) => l.message).join(", ")}`);
380
+ } else {
381
+ results[shim.key] = `/.mandu/client/${outputName}`;
382
+ }
383
+ } catch (error) {
384
+ await fs.unlink(srcPath).catch(() => {});
385
+ errors.push(`[${shim.name}] ${String(error)}`);
331
386
  }
332
-
333
- return {
334
- success: true,
335
- outputPath: `/.mandu/client/${outputName}`,
336
- errors: [],
337
- };
338
- } catch (error) {
339
- await fs.unlink(vendorPath).catch(() => {});
340
- return {
341
- success: false,
342
- outputPath: "",
343
- errors: [String(error)],
344
- };
345
387
  }
388
+
389
+ return {
390
+ success: errors.length === 0,
391
+ react: results.react,
392
+ reactDom: results.reactDom,
393
+ reactDomClient: results.reactDomClient,
394
+ errors,
395
+ };
346
396
  }
347
397
 
348
398
  /**
@@ -411,7 +461,7 @@ function createBundleManifest(
411
461
  outputs: BundleOutput[],
412
462
  routes: RouteSpec[],
413
463
  runtimePath: string,
414
- vendorPath: string,
464
+ vendorResult: VendorBuildResult,
415
465
  env: "development" | "production"
416
466
  ): BundleManifest {
417
467
  const bundles: BundleManifest["bundles"] = {};
@@ -422,7 +472,7 @@ function createBundleManifest(
422
472
 
423
473
  bundles[output.routeId] = {
424
474
  js: output.outputPath,
425
- dependencies: ["_runtime", "_vendor"],
475
+ dependencies: ["_runtime", "_react"],
426
476
  priority: hydration?.priority || "visible",
427
477
  };
428
478
  }
@@ -434,7 +484,14 @@ function createBundleManifest(
434
484
  bundles,
435
485
  shared: {
436
486
  runtime: runtimePath,
437
- vendor: vendorPath,
487
+ vendor: vendorResult.react, // primary vendor for backwards compatibility
488
+ },
489
+ importMap: {
490
+ imports: {
491
+ "react": vendorResult.react,
492
+ "react-dom": vendorResult.reactDom,
493
+ "react-dom/client": vendorResult.reactDomClient,
494
+ },
438
495
  },
439
496
  };
440
497
  }
@@ -523,10 +580,10 @@ export async function buildClientBundles(
523
580
  errors.push(...runtimeResult.errors.map((e) => `[Runtime] ${e}`));
524
581
  }
525
582
 
526
- // 4. Vendor 번들 빌드
527
- const vendorResult = await buildVendor(outDir, options);
583
+ // 4. Vendor shim 번들 빌드 (React, ReactDOM, ReactDOMClient)
584
+ const vendorResult = await buildVendorShims(outDir, options);
528
585
  if (!vendorResult.success) {
529
- errors.push(...vendorResult.errors.map((e) => `[Vendor] ${e}`));
586
+ errors.push(...vendorResult.errors);
530
587
  }
531
588
 
532
589
  // 5. 각 Island 번들 빌드
@@ -544,7 +601,7 @@ export async function buildClientBundles(
544
601
  outputs,
545
602
  hydratedRoutes,
546
603
  runtimeResult.outputPath,
547
- vendorResult.outputPath,
604
+ vendorResult,
548
605
  env
549
606
  );
550
607
 
@@ -57,9 +57,13 @@ export interface BundleManifest {
57
57
  shared: {
58
58
  /** Hydration 런타임 */
59
59
  runtime: string;
60
- /** 벤더 번들 (React 등) */
60
+ /** React 번들 경로 */
61
61
  vendor: string;
62
62
  };
63
+ /** Import map for bare specifiers (react, react-dom, etc.) */
64
+ importMap?: {
65
+ imports: Record<string, string>;
66
+ };
63
67
  }
64
68
 
65
69
  /**
@@ -17,6 +17,7 @@
17
17
  // Island API
18
18
  export {
19
19
  island,
20
+ wrapComponent,
20
21
  useServerData,
21
22
  useHydrated,
22
23
  useIslandEvent,
@@ -25,6 +26,7 @@ export {
25
26
  type IslandMetadata,
26
27
  type CompiledIsland,
27
28
  type FetchOptions,
29
+ type WrapComponentOptions,
28
30
  } from "./island";
29
31
 
30
32
  // Runtime API
@@ -41,7 +43,7 @@ export {
41
43
  } from "./runtime";
42
44
 
43
45
  // Re-export as Mandu namespace for consistent API
44
- import { island } from "./island";
46
+ import { island, wrapComponent } from "./island";
45
47
  import { hydrateIslands, initializeRuntime } from "./runtime";
46
48
 
47
49
  /**
@@ -54,6 +56,12 @@ export const Mandu = {
54
56
  */
55
57
  island,
56
58
 
59
+ /**
60
+ * Wrap existing React component as island
61
+ * @see wrapComponent
62
+ */
63
+ wrapComponent,
64
+
57
65
  /**
58
66
  * Hydrate all islands on the page
59
67
  * @see hydrateIslands
@@ -166,6 +166,65 @@ export function useIslandEvent<T = unknown>(
166
166
  };
167
167
  }
168
168
 
169
+ /**
170
+ * 기존 React 컴포넌트를 Island로 래핑
171
+ *
172
+ * @example
173
+ * ```typescript
174
+ * // 기존 React 컴포넌트
175
+ * import DatePicker from 'react-datepicker';
176
+ *
177
+ * // Island로 래핑 (serverData가 그대로 props로 전달됨)
178
+ * export default Mandu.wrapComponent(DatePicker);
179
+ *
180
+ * // 또는 props 변환이 필요한 경우
181
+ * export default Mandu.wrapComponent(DatePicker, {
182
+ * transformProps: (serverData) => ({
183
+ * selected: new Date(serverData.selectedDate),
184
+ * onChange: (date) => console.log(date),
185
+ * })
186
+ * });
187
+ * ```
188
+ */
189
+ export interface WrapComponentOptions<TServerData, TProps> {
190
+ /** 서버 데이터를 컴포넌트 props로 변환 */
191
+ transformProps?: (serverData: TServerData) => TProps;
192
+ /** 에러 시 표시할 UI */
193
+ errorBoundary?: (error: Error, reset: () => void) => ReactNode;
194
+ /** 로딩 중 표시할 UI */
195
+ loading?: () => ReactNode;
196
+ }
197
+
198
+ export function wrapComponent<TProps extends Record<string, any>>(
199
+ Component: React.ComponentType<TProps>,
200
+ options?: WrapComponentOptions<TProps, TProps>
201
+ ): CompiledIsland<TProps, TProps>;
202
+
203
+ export function wrapComponent<TServerData, TProps>(
204
+ Component: React.ComponentType<TProps>,
205
+ options: WrapComponentOptions<TServerData, TProps> & { transformProps: (serverData: TServerData) => TProps }
206
+ ): CompiledIsland<TServerData, TProps>;
207
+
208
+ export function wrapComponent<TServerData, TProps>(
209
+ Component: React.ComponentType<TProps>,
210
+ options?: WrapComponentOptions<TServerData, TProps>
211
+ ): CompiledIsland<TServerData, TProps> {
212
+ const { transformProps, errorBoundary, loading } = options || {};
213
+
214
+ return island({
215
+ setup: (serverData: TServerData) => {
216
+ return transformProps ? transformProps(serverData) : (serverData as unknown as TProps);
217
+ },
218
+ render: (props: TProps) => {
219
+ // React.createElement를 사용하여 Component 렌더링
220
+ const React = require("react");
221
+ return React.createElement(Component, props);
222
+ },
223
+ errorBoundary,
224
+ loading,
225
+ });
226
+ }
227
+
169
228
  /**
170
229
  * API 호출 헬퍼
171
230
  */
@@ -39,6 +39,18 @@ function serializeServerData(data: Record<string, unknown>): string {
39
39
  <script>window.__MANDU_DATA__ = JSON.parse(document.getElementById('__MANDU_DATA__').textContent);</script>`;
40
40
  }
41
41
 
42
+ /**
43
+ * Import map 생성 (bare specifier 해결용)
44
+ */
45
+ function generateImportMap(manifest: BundleManifest): string {
46
+ if (!manifest.importMap || Object.keys(manifest.importMap.imports).length === 0) {
47
+ return "";
48
+ }
49
+
50
+ const importMapJson = JSON.stringify(manifest.importMap, null, 2);
51
+ return `<script type="importmap">${importMapJson}</script>`;
52
+ }
53
+
42
54
  /**
43
55
  * Hydration 스크립트 태그 생성
44
56
  */
@@ -48,16 +60,17 @@ function generateHydrationScripts(
48
60
  ): string {
49
61
  const scripts: string[] = [];
50
62
 
63
+ // Import map 먼저 (반드시 module scripts 전에 위치해야 함)
64
+ const importMap = generateImportMap(manifest);
65
+ if (importMap) {
66
+ scripts.push(importMap);
67
+ }
68
+
51
69
  // Runtime 로드
52
70
  if (manifest.shared.runtime) {
53
71
  scripts.push(`<script type="module" src="${manifest.shared.runtime}"></script>`);
54
72
  }
55
73
 
56
- // Vendor 로드
57
- if (manifest.shared.vendor) {
58
- scripts.push(`<script type="module" src="${manifest.shared.vendor}"></script>`);
59
- }
60
-
61
74
  // Island 번들 로드
62
75
  const bundle = manifest.bundles[routeId];
63
76
  if (bundle) {