@mandujs/core 0.5.7 → 0.7.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.7",
3
+ "version": "0.7.0",
4
4
  "description": "Mandu Framework Core - Spec, Generator, Guard, Runtime",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -279,6 +279,223 @@ export default jsxDevRuntime;
279
279
  `;
280
280
  }
281
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
+
282
499
  /**
283
500
  * Island 엔트리 래퍼 생성
284
501
  */
@@ -503,6 +720,7 @@ function createBundleManifest(
503
720
  routes: RouteSpec[],
504
721
  runtimePath: string,
505
722
  vendorResult: VendorBuildResult,
723
+ routerPath: string,
506
724
  env: "development" | "production"
507
725
  ): BundleManifest {
508
726
  const bundles: BundleManifest["bundles"] = {};
@@ -526,6 +744,7 @@ function createBundleManifest(
526
744
  shared: {
527
745
  runtime: runtimePath,
528
746
  vendor: vendorResult.react, // primary vendor for backwards compatibility
747
+ router: routerPath, // Client-side Router
529
748
  },
530
749
  importMap: {
531
750
  imports: {
@@ -623,6 +842,12 @@ export async function buildClientBundles(
623
842
  errors.push(...runtimeResult.errors.map((e) => `[Runtime] ${e}`));
624
843
  }
625
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
+
626
851
  // 4. Vendor shim 번들 빌드 (React, ReactDOM, ReactDOMClient)
627
852
  const vendorResult = await buildVendorShims(outDir, options);
628
853
  if (!vendorResult.success) {
@@ -645,6 +870,7 @@ export async function buildClientBundles(
645
870
  hydratedRoutes,
646
871
  runtimeResult.outputPath,
647
872
  vendorResult,
873
+ routerResult.outputPath,
648
874
  env
649
875
  );
650
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;