@mandujs/core 0.5.6 → 0.6.0

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.6",
3
+ "version": "0.6.0",
4
4
  "description": "Mandu Framework Core - Spec, Generator, Guard, Runtime",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -249,6 +249,253 @@ export default ReactDOMClient;
249
249
  `;
250
250
  }
251
251
 
252
+ /**
253
+ * JSX Runtime shim 소스 생성
254
+ */
255
+ function generateJsxRuntimeShimSource(): string {
256
+ return `
257
+ /**
258
+ * Mandu JSX Runtime Shim (Generated)
259
+ * Production JSX 변환용
260
+ */
261
+ import * as jsxRuntime from 'react/jsx-runtime';
262
+ export * from 'react/jsx-runtime';
263
+ export default jsxRuntime;
264
+ `;
265
+ }
266
+
267
+ /**
268
+ * JSX Dev Runtime shim 소스 생성
269
+ */
270
+ function generateJsxDevRuntimeShimSource(): string {
271
+ return `
272
+ /**
273
+ * Mandu JSX Dev Runtime Shim (Generated)
274
+ * Development JSX 변환용
275
+ */
276
+ import * as jsxDevRuntime from 'react/jsx-dev-runtime';
277
+ export * from 'react/jsx-dev-runtime';
278
+ export default jsxDevRuntime;
279
+ `;
280
+ }
281
+
282
+ /**
283
+ * Client-side Router 런타임 소스 생성
284
+ */
285
+ function generateRouterRuntimeSource(): string {
286
+ return `
287
+ /**
288
+ * Mandu Client Router Runtime (Generated)
289
+ * Client-side Routing을 위한 런타임
290
+ */
291
+
292
+ // 라우트 정보
293
+ let currentRoute = window.__MANDU_ROUTE__ || null;
294
+ let currentLoaderData = window.__MANDU_DATA__?.[currentRoute?.id]?.serverData;
295
+ let navigationState = { state: 'idle' };
296
+ const listeners = new Set();
297
+
298
+ // 패턴 매칭 캐시
299
+ const patternCache = new Map();
300
+
301
+ function compilePattern(pattern) {
302
+ if (patternCache.has(pattern)) return patternCache.get(pattern);
303
+
304
+ const paramNames = [];
305
+ let paramIndex = 0;
306
+ const paramMatches = [];
307
+
308
+ const withPlaceholders = pattern.replace(/:([a-zA-Z_][a-zA-Z0-9_]*)/g, (_, name) => {
309
+ paramMatches.push(name);
310
+ return '%%PARAM%%';
311
+ });
312
+
313
+ const escaped = withPlaceholders.replace(/[.*+?^\${}()|[\\]\\\\]/g, '\\\\$&');
314
+ const regexStr = escaped.replace(/%%PARAM%%/g, () => {
315
+ paramNames.push(paramMatches[paramIndex++]);
316
+ return '([^/]+)';
317
+ });
318
+
319
+ const compiled = { regex: new RegExp('^' + regexStr + '$'), paramNames };
320
+ patternCache.set(pattern, compiled);
321
+ return compiled;
322
+ }
323
+
324
+ function extractParams(pattern, pathname) {
325
+ const compiled = compilePattern(pattern);
326
+ const match = pathname.match(compiled.regex);
327
+ if (!match) return {};
328
+
329
+ const params = {};
330
+ compiled.paramNames.forEach((name, i) => { params[name] = match[i + 1]; });
331
+ return params;
332
+ }
333
+
334
+ function notifyListeners() {
335
+ const state = {
336
+ currentRoute,
337
+ loaderData: currentLoaderData,
338
+ navigation: navigationState
339
+ };
340
+ listeners.forEach(fn => { try { fn(state); } catch(e) {} });
341
+ }
342
+
343
+ export function subscribe(listener) {
344
+ listeners.add(listener);
345
+ return () => listeners.delete(listener);
346
+ }
347
+
348
+ export function getRouterState() {
349
+ return {
350
+ currentRoute,
351
+ loaderData: currentLoaderData,
352
+ navigation: navigationState
353
+ };
354
+ }
355
+
356
+ export async function navigate(to, options = {}) {
357
+ const { replace = false, scroll = true } = options;
358
+
359
+ try {
360
+ const url = new URL(to, location.origin);
361
+ if (url.origin !== location.origin) {
362
+ location.href = to;
363
+ return;
364
+ }
365
+
366
+ navigationState = { state: 'loading', location: to };
367
+ notifyListeners();
368
+
369
+ const dataUrl = url.pathname + (url.search ? url.search + '&' : '?') + '_data=1';
370
+ const res = await fetch(dataUrl);
371
+
372
+ if (!res.ok) {
373
+ location.href = to;
374
+ return;
375
+ }
376
+
377
+ const data = await res.json();
378
+
379
+ if (replace) {
380
+ history.replaceState({ routeId: data.routeId }, '', to);
381
+ } else {
382
+ history.pushState({ routeId: data.routeId }, '', to);
383
+ }
384
+
385
+ currentRoute = { id: data.routeId, pattern: data.pattern, params: data.params };
386
+ currentLoaderData = data.loaderData;
387
+ navigationState = { state: 'idle' };
388
+
389
+ window.__MANDU_DATA__ = window.__MANDU_DATA__ || {};
390
+ window.__MANDU_DATA__[data.routeId] = { serverData: data.loaderData };
391
+
392
+ notifyListeners();
393
+
394
+ if (scroll) window.scrollTo(0, 0);
395
+ } catch (err) {
396
+ console.error('[Mandu Router] Error:', err);
397
+ location.href = to;
398
+ }
399
+ }
400
+
401
+ // Link 클릭 핸들러
402
+ function handleClick(e) {
403
+ if (e.defaultPrevented || e.button !== 0 || e.metaKey || e.altKey || e.ctrlKey || e.shiftKey) return;
404
+
405
+ const anchor = e.target.closest('a[data-mandu-link]');
406
+ if (!anchor) return;
407
+
408
+ const href = anchor.getAttribute('href');
409
+ if (!href) return;
410
+
411
+ try {
412
+ const url = new URL(href, location.origin);
413
+ if (url.origin !== location.origin) return;
414
+ } catch { return; }
415
+
416
+ e.preventDefault();
417
+ navigate(href);
418
+ }
419
+
420
+ // Popstate 핸들러
421
+ function handlePopState(e) {
422
+ if (e.state?.routeId) {
423
+ navigate(location.pathname + location.search, { replace: true, scroll: false });
424
+ }
425
+ }
426
+
427
+ // 초기화
428
+ function init() {
429
+ if (currentRoute) {
430
+ currentRoute.params = extractParams(currentRoute.pattern, location.pathname);
431
+ }
432
+
433
+ window.addEventListener('popstate', handlePopState);
434
+ document.addEventListener('click', handleClick);
435
+ console.log('[Mandu Router] Initialized');
436
+ }
437
+
438
+ if (document.readyState === 'loading') {
439
+ document.addEventListener('DOMContentLoaded', init);
440
+ } else {
441
+ init();
442
+ }
443
+
444
+ export { currentRoute, currentLoaderData, navigationState };
445
+ `;
446
+ }
447
+
448
+ /**
449
+ * Router 런타임 번들 빌드
450
+ */
451
+ async function buildRouterRuntime(
452
+ outDir: string,
453
+ options: BundlerOptions
454
+ ): Promise<{ success: boolean; outputPath: string; errors: string[] }> {
455
+ const routerPath = path.join(outDir, "_router.src.js");
456
+ const outputName = "_router.js";
457
+
458
+ try {
459
+ await Bun.write(routerPath, generateRouterRuntimeSource());
460
+
461
+ const result = await Bun.build({
462
+ entrypoints: [routerPath],
463
+ outdir: outDir,
464
+ naming: outputName,
465
+ minify: options.minify ?? process.env.NODE_ENV === "production",
466
+ sourcemap: options.sourcemap ? "external" : "none",
467
+ target: "browser",
468
+ define: {
469
+ "process.env.NODE_ENV": JSON.stringify(process.env.NODE_ENV || "development"),
470
+ ...options.define,
471
+ },
472
+ });
473
+
474
+ await fs.unlink(routerPath).catch(() => {});
475
+
476
+ if (!result.success) {
477
+ return {
478
+ success: false,
479
+ outputPath: "",
480
+ errors: result.logs.map((l) => l.message),
481
+ };
482
+ }
483
+
484
+ return {
485
+ success: true,
486
+ outputPath: `/.mandu/client/${outputName}`,
487
+ errors: [],
488
+ };
489
+ } catch (error) {
490
+ await fs.unlink(routerPath).catch(() => {});
491
+ return {
492
+ success: false,
493
+ outputPath: "",
494
+ errors: [String(error)],
495
+ };
496
+ }
497
+ }
498
+
252
499
  /**
253
500
  * Island 엔트리 래퍼 생성
254
501
  */
@@ -332,6 +579,8 @@ interface VendorBuildResult {
332
579
  react: string;
333
580
  reactDom: string;
334
581
  reactDomClient: string;
582
+ jsxRuntime: string;
583
+ jsxDevRuntime: string;
335
584
  errors: string[];
336
585
  }
337
586
 
@@ -348,12 +597,16 @@ async function buildVendorShims(
348
597
  react: "",
349
598
  reactDom: "",
350
599
  reactDomClient: "",
600
+ jsxRuntime: "",
601
+ jsxDevRuntime: "",
351
602
  };
352
603
 
353
604
  const shims = [
354
605
  { name: "_react", source: generateReactShimSource(), key: "react" },
355
606
  { name: "_react-dom", source: generateReactDOMShimSource(), key: "reactDom" },
356
607
  { name: "_react-dom-client", source: generateReactDOMClientShimSource(), key: "reactDomClient" },
608
+ { name: "_jsx-runtime", source: generateJsxRuntimeShimSource(), key: "jsxRuntime" },
609
+ { name: "_jsx-dev-runtime", source: generateJsxDevRuntimeShimSource(), key: "jsxDevRuntime" },
357
610
  ];
358
611
 
359
612
  for (const shim of shims) {
@@ -394,6 +647,8 @@ async function buildVendorShims(
394
647
  react: results.react,
395
648
  reactDom: results.reactDom,
396
649
  reactDomClient: results.reactDomClient,
650
+ jsxRuntime: results.jsxRuntime,
651
+ jsxDevRuntime: results.jsxDevRuntime,
397
652
  errors,
398
653
  };
399
654
  }
@@ -465,6 +720,7 @@ function createBundleManifest(
465
720
  routes: RouteSpec[],
466
721
  runtimePath: string,
467
722
  vendorResult: VendorBuildResult,
723
+ routerPath: string,
468
724
  env: "development" | "production"
469
725
  ): BundleManifest {
470
726
  const bundles: BundleManifest["bundles"] = {};
@@ -488,12 +744,15 @@ function createBundleManifest(
488
744
  shared: {
489
745
  runtime: runtimePath,
490
746
  vendor: vendorResult.react, // primary vendor for backwards compatibility
747
+ router: routerPath, // Client-side Router
491
748
  },
492
749
  importMap: {
493
750
  imports: {
494
751
  "react": vendorResult.react,
495
752
  "react-dom": vendorResult.reactDom,
496
753
  "react-dom/client": vendorResult.reactDomClient,
754
+ "react/jsx-runtime": vendorResult.jsxRuntime,
755
+ "react/jsx-dev-runtime": vendorResult.jsxDevRuntime,
497
756
  },
498
757
  },
499
758
  };
@@ -583,6 +842,12 @@ export async function buildClientBundles(
583
842
  errors.push(...runtimeResult.errors.map((e) => `[Runtime] ${e}`));
584
843
  }
585
844
 
845
+ // 3.5. Client-side Router 런타임 빌드
846
+ const routerResult = await buildRouterRuntime(outDir, options);
847
+ if (!routerResult.success) {
848
+ errors.push(...routerResult.errors.map((e) => `[Router] ${e}`));
849
+ }
850
+
586
851
  // 4. Vendor shim 번들 빌드 (React, ReactDOM, ReactDOMClient)
587
852
  const vendorResult = await buildVendorShims(outDir, options);
588
853
  if (!vendorResult.success) {
@@ -605,6 +870,7 @@ export async function buildClientBundles(
605
870
  hydratedRoutes,
606
871
  runtimeResult.outputPath,
607
872
  vendorResult,
873
+ routerResult.outputPath,
608
874
  env
609
875
  );
610
876
 
@@ -59,6 +59,8 @@ export interface BundleManifest {
59
59
  runtime: string;
60
60
  /** React 번들 경로 */
61
61
  vendor: string;
62
+ /** Client-side Router 런타임 */
63
+ router?: string;
62
64
  };
63
65
  /** Import map for bare specifiers (react, react-dom, etc.) */
64
66
  importMap?: {
@@ -0,0 +1,209 @@
1
+ /**
2
+ * Mandu Link Component 🔗
3
+ * Client-side 네비게이션을 위한 Link 컴포넌트
4
+ */
5
+
6
+ import React, {
7
+ type AnchorHTMLAttributes,
8
+ type MouseEvent,
9
+ type ReactNode,
10
+ useCallback,
11
+ useEffect,
12
+ useRef,
13
+ } from "react";
14
+ import { navigate, prefetch } from "./router";
15
+
16
+ export interface LinkProps
17
+ extends Omit<AnchorHTMLAttributes<HTMLAnchorElement>, "href"> {
18
+ /** 이동할 URL */
19
+ href: string;
20
+ /** history.replaceState 사용 여부 */
21
+ replace?: boolean;
22
+ /** 마우스 hover 시 prefetch 여부 */
23
+ prefetch?: boolean;
24
+ /** 스크롤 위치 복원 여부 (기본: true) */
25
+ scroll?: boolean;
26
+ /** 자식 요소 */
27
+ children?: ReactNode;
28
+ }
29
+
30
+ /**
31
+ * Client-side 네비게이션 Link 컴포넌트
32
+ *
33
+ * @example
34
+ * ```tsx
35
+ * import { Link } from "@mandujs/core/client";
36
+ *
37
+ * // 기본 사용
38
+ * <Link href="/about">About</Link>
39
+ *
40
+ * // Prefetch 활성화
41
+ * <Link href="/users" prefetch>Users</Link>
42
+ *
43
+ * // Replace 모드 (뒤로가기 히스토리 없음)
44
+ * <Link href="/login" replace>Login</Link>
45
+ * ```
46
+ */
47
+ export function Link({
48
+ href,
49
+ replace = false,
50
+ prefetch: shouldPrefetch = false,
51
+ scroll = true,
52
+ children,
53
+ onClick,
54
+ onMouseEnter,
55
+ onFocus,
56
+ ...rest
57
+ }: LinkProps): React.ReactElement {
58
+ const prefetchedRef = useRef(false);
59
+
60
+ // 클릭 핸들러
61
+ const handleClick = useCallback(
62
+ (event: MouseEvent<HTMLAnchorElement>) => {
63
+ // 사용자 정의 onClick 먼저 실행
64
+ onClick?.(event);
65
+
66
+ // 기본 동작 방지 조건
67
+ if (
68
+ event.defaultPrevented ||
69
+ event.button !== 0 ||
70
+ event.metaKey ||
71
+ event.altKey ||
72
+ event.ctrlKey ||
73
+ event.shiftKey
74
+ ) {
75
+ return;
76
+ }
77
+
78
+ // 외부 링크 체크
79
+ try {
80
+ const url = new URL(href, window.location.origin);
81
+ if (url.origin !== window.location.origin) {
82
+ return; // 외부 링크는 기본 동작
83
+ }
84
+ } catch {
85
+ return;
86
+ }
87
+
88
+ // Client-side 네비게이션
89
+ event.preventDefault();
90
+ navigate(href, { replace, scroll });
91
+ },
92
+ [href, replace, scroll, onClick]
93
+ );
94
+
95
+ // Prefetch 실행
96
+ const doPrefetch = useCallback(() => {
97
+ if (!shouldPrefetch || prefetchedRef.current) return;
98
+
99
+ try {
100
+ const url = new URL(href, window.location.origin);
101
+ if (url.origin === window.location.origin) {
102
+ prefetch(href);
103
+ prefetchedRef.current = true;
104
+ }
105
+ } catch {
106
+ // 무시
107
+ }
108
+ }, [href, shouldPrefetch]);
109
+
110
+ // 마우스 hover 핸들러
111
+ const handleMouseEnter = useCallback(
112
+ (event: MouseEvent<HTMLAnchorElement>) => {
113
+ onMouseEnter?.(event);
114
+ doPrefetch();
115
+ },
116
+ [onMouseEnter, doPrefetch]
117
+ );
118
+
119
+ // 포커스 핸들러 (키보드 네비게이션)
120
+ const handleFocus = useCallback(
121
+ (event: React.FocusEvent<HTMLAnchorElement>) => {
122
+ onFocus?.(event);
123
+ doPrefetch();
124
+ },
125
+ [onFocus, doPrefetch]
126
+ );
127
+
128
+ // Viewport 진입 시 prefetch (IntersectionObserver)
129
+ useEffect(() => {
130
+ if (!shouldPrefetch || typeof IntersectionObserver === "undefined") {
131
+ return;
132
+ }
133
+
134
+ // ref가 없으면 무시 (SSR)
135
+ return;
136
+ }, [shouldPrefetch]);
137
+
138
+ return (
139
+ <a
140
+ href={href}
141
+ onClick={handleClick}
142
+ onMouseEnter={handleMouseEnter}
143
+ onFocus={handleFocus}
144
+ data-mandu-link=""
145
+ {...rest}
146
+ >
147
+ {children}
148
+ </a>
149
+ );
150
+ }
151
+
152
+ /**
153
+ * NavLink - 현재 경로와 일치할 때 활성 스타일 적용
154
+ *
155
+ * @example
156
+ * ```tsx
157
+ * import { NavLink } from "@mandujs/core/client";
158
+ *
159
+ * <NavLink
160
+ * href="/about"
161
+ * className={({ isActive }) => isActive ? "active" : ""}
162
+ * >
163
+ * About
164
+ * </NavLink>
165
+ * ```
166
+ */
167
+ export interface NavLinkProps extends Omit<LinkProps, "className" | "style"> {
168
+ /** 활성 상태에 따른 className */
169
+ className?: string | ((props: { isActive: boolean }) => string);
170
+ /** 활성 상태에 따른 style */
171
+ style?:
172
+ | React.CSSProperties
173
+ | ((props: { isActive: boolean }) => React.CSSProperties);
174
+ /** 정확히 일치해야 활성화 (기본: false) */
175
+ exact?: boolean;
176
+ }
177
+
178
+ export function NavLink({
179
+ href,
180
+ className,
181
+ style,
182
+ exact = false,
183
+ ...rest
184
+ }: NavLinkProps): React.ReactElement {
185
+ // 현재 경로와 비교
186
+ const isActive =
187
+ typeof window !== "undefined"
188
+ ? exact
189
+ ? window.location.pathname === href
190
+ : window.location.pathname.startsWith(href)
191
+ : false;
192
+
193
+ const resolvedClassName =
194
+ typeof className === "function" ? className({ isActive }) : className;
195
+
196
+ const resolvedStyle =
197
+ typeof style === "function" ? style({ isActive }) : style;
198
+
199
+ return (
200
+ <Link
201
+ href={href}
202
+ className={resolvedClassName}
203
+ style={resolvedStyle}
204
+ {...rest}
205
+ />
206
+ );
207
+ }
208
+
209
+ export default Link;