@mandujs/core 0.5.7 → 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.7",
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",
@@ -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;
@@ -0,0 +1,267 @@
1
+ /**
2
+ * Mandu Router Hooks 🪝
3
+ * React hooks for client-side routing
4
+ */
5
+
6
+ import { useState, useEffect, useCallback, useSyncExternalStore } from "react";
7
+ import {
8
+ subscribe,
9
+ getRouterState,
10
+ getCurrentRoute,
11
+ getLoaderData,
12
+ getNavigationState,
13
+ navigate,
14
+ type RouteInfo,
15
+ type NavigationState,
16
+ type NavigateOptions,
17
+ } from "./router";
18
+
19
+ /**
20
+ * 라우터 상태 전체 접근
21
+ *
22
+ * @example
23
+ * ```tsx
24
+ * const { currentRoute, loaderData, navigation } = useRouterState();
25
+ * ```
26
+ */
27
+ export function useRouterState() {
28
+ return useSyncExternalStore(
29
+ subscribe,
30
+ getRouterState,
31
+ getRouterState // SSR에서도 동일
32
+ );
33
+ }
34
+
35
+ /**
36
+ * 현재 라우트 정보
37
+ *
38
+ * @example
39
+ * ```tsx
40
+ * const route = useRoute();
41
+ * console.log(route?.id, route?.params);
42
+ * ```
43
+ */
44
+ export function useRoute(): RouteInfo | null {
45
+ const state = useRouterState();
46
+ return state.currentRoute;
47
+ }
48
+
49
+ /**
50
+ * URL 파라미터 접근
51
+ *
52
+ * @example
53
+ * ```tsx
54
+ * // URL: /users/123
55
+ * const { id } = useParams<{ id: string }>();
56
+ * console.log(id); // "123"
57
+ * ```
58
+ */
59
+ export function useParams<T extends Record<string, string> = Record<string, string>>(): T {
60
+ const route = useRoute();
61
+ return (route?.params ?? {}) as T;
62
+ }
63
+
64
+ /**
65
+ * 현재 경로명
66
+ *
67
+ * @example
68
+ * ```tsx
69
+ * const pathname = usePathname();
70
+ * console.log(pathname); // "/users/123"
71
+ * ```
72
+ */
73
+ export function usePathname(): string {
74
+ const [pathname, setPathname] = useState(() =>
75
+ typeof window !== "undefined" ? window.location.pathname : "/"
76
+ );
77
+
78
+ useEffect(() => {
79
+ const handleChange = () => {
80
+ setPathname(window.location.pathname);
81
+ };
82
+
83
+ window.addEventListener("popstate", handleChange);
84
+
85
+ // 라우터 상태 변경 구독
86
+ const unsubscribe = subscribe(() => {
87
+ setPathname(window.location.pathname);
88
+ });
89
+
90
+ return () => {
91
+ window.removeEventListener("popstate", handleChange);
92
+ unsubscribe();
93
+ };
94
+ }, []);
95
+
96
+ return pathname;
97
+ }
98
+
99
+ /**
100
+ * 현재 검색 파라미터 (쿼리 스트링)
101
+ *
102
+ * @example
103
+ * ```tsx
104
+ * // URL: /search?q=hello&page=2
105
+ * const searchParams = useSearchParams();
106
+ * console.log(searchParams.get("q")); // "hello"
107
+ * ```
108
+ */
109
+ export function useSearchParams(): URLSearchParams {
110
+ const [searchParams, setSearchParams] = useState(() =>
111
+ typeof window !== "undefined"
112
+ ? new URLSearchParams(window.location.search)
113
+ : new URLSearchParams()
114
+ );
115
+
116
+ useEffect(() => {
117
+ const handleChange = () => {
118
+ setSearchParams(new URLSearchParams(window.location.search));
119
+ };
120
+
121
+ window.addEventListener("popstate", handleChange);
122
+
123
+ const unsubscribe = subscribe(() => {
124
+ setSearchParams(new URLSearchParams(window.location.search));
125
+ });
126
+
127
+ return () => {
128
+ window.removeEventListener("popstate", handleChange);
129
+ unsubscribe();
130
+ };
131
+ }, []);
132
+
133
+ return searchParams;
134
+ }
135
+
136
+ /**
137
+ * Loader 데이터 접근
138
+ *
139
+ * @example
140
+ * ```tsx
141
+ * interface UserData { name: string; email: string; }
142
+ * const data = useLoaderData<UserData>();
143
+ * ```
144
+ */
145
+ export function useLoaderData<T = unknown>(): T | undefined {
146
+ const state = useRouterState();
147
+ return state.loaderData as T | undefined;
148
+ }
149
+
150
+ /**
151
+ * 네비게이션 상태 (로딩 여부)
152
+ *
153
+ * @example
154
+ * ```tsx
155
+ * const { state, location } = useNavigation();
156
+ *
157
+ * if (state === "loading") {
158
+ * return <Spinner />;
159
+ * }
160
+ * ```
161
+ */
162
+ export function useNavigation(): NavigationState {
163
+ const state = useRouterState();
164
+ return state.navigation;
165
+ }
166
+
167
+ /**
168
+ * 프로그래매틱 네비게이션
169
+ *
170
+ * @example
171
+ * ```tsx
172
+ * const navigate = useNavigate();
173
+ *
174
+ * const handleClick = () => {
175
+ * navigate("/dashboard");
176
+ * };
177
+ *
178
+ * const handleSubmit = () => {
179
+ * navigate("/success", { replace: true });
180
+ * };
181
+ * ```
182
+ */
183
+ export function useNavigate(): (to: string, options?: NavigateOptions) => Promise<void> {
184
+ return useCallback((to: string, options?: NavigateOptions) => {
185
+ return navigate(to, options);
186
+ }, []);
187
+ }
188
+
189
+ /**
190
+ * 라우터 통합 훅 (편의용)
191
+ *
192
+ * @example
193
+ * ```tsx
194
+ * const {
195
+ * pathname,
196
+ * params,
197
+ * searchParams,
198
+ * navigate,
199
+ * isNavigating
200
+ * } = useRouter();
201
+ * ```
202
+ */
203
+ export function useRouter() {
204
+ const pathname = usePathname();
205
+ const params = useParams();
206
+ const searchParams = useSearchParams();
207
+ const navigation = useNavigation();
208
+ const navigateFn = useNavigate();
209
+
210
+ return {
211
+ /** 현재 경로명 */
212
+ pathname,
213
+ /** URL 파라미터 */
214
+ params,
215
+ /** 검색 파라미터 (쿼리 스트링) */
216
+ searchParams,
217
+ /** 네비게이션 함수 */
218
+ navigate: navigateFn,
219
+ /** 네비게이션 중 여부 */
220
+ isNavigating: navigation.state === "loading",
221
+ /** 네비게이션 상태 상세 */
222
+ navigation,
223
+ };
224
+ }
225
+
226
+ /**
227
+ * 특정 경로와 현재 경로 일치 여부
228
+ *
229
+ * @example
230
+ * ```tsx
231
+ * const isActive = useMatch("/about");
232
+ * const isUsersPage = useMatch("/users/:id");
233
+ * ```
234
+ */
235
+ export function useMatch(pattern: string): boolean {
236
+ const pathname = usePathname();
237
+
238
+ // 간단한 패턴 매칭 (파라미터 고려)
239
+ const regexStr = pattern
240
+ .replace(/:[a-zA-Z_][a-zA-Z0-9_]*/g, "[^/]+")
241
+ .replace(/\//g, "\\/");
242
+
243
+ const regex = new RegExp(`^${regexStr}$`);
244
+ return regex.test(pathname);
245
+ }
246
+
247
+ /**
248
+ * 뒤로 가기
249
+ */
250
+ export function useGoBack(): () => void {
251
+ return useCallback(() => {
252
+ if (typeof window !== "undefined") {
253
+ window.history.back();
254
+ }
255
+ }, []);
256
+ }
257
+
258
+ /**
259
+ * 앞으로 가기
260
+ */
261
+ export function useGoForward(): () => void {
262
+ return useCallback(() => {
263
+ if (typeof window !== "undefined") {
264
+ window.history.forward();
265
+ }
266
+ }, []);
267
+ }
@@ -1,10 +1,10 @@
1
1
  /**
2
2
  * Mandu Client Module 🏝️
3
- * 클라이언트 사이드 hydration 위한 API
3
+ * 클라이언트 사이드 hydration 및 라우팅을 위한 API
4
4
  *
5
5
  * @example
6
6
  * ```typescript
7
- * // spec/slots/todos.client.ts
7
+ * // Island 컴포넌트
8
8
  * import { Mandu } from "@mandujs/core/client";
9
9
  *
10
10
  * export default Mandu.island<TodosData>({
@@ -12,6 +12,17 @@
12
12
  * render: (props) => <TodoList {...props} />
13
13
  * });
14
14
  * ```
15
+ *
16
+ * @example
17
+ * ```typescript
18
+ * // Client-side 라우팅
19
+ * import { Link, useRouter } from "@mandujs/core/client";
20
+ *
21
+ * function Nav() {
22
+ * const { pathname, navigate } = useRouter();
23
+ * return <Link href="/about">About</Link>;
24
+ * }
25
+ * ```
15
26
  */
16
27
 
17
28
  // Island API
@@ -42,9 +53,47 @@ export {
42
53
  type IslandLoader,
43
54
  } from "./runtime";
44
55
 
56
+ // Client-side Router API
57
+ export {
58
+ navigate,
59
+ prefetch,
60
+ subscribe,
61
+ getRouterState,
62
+ getCurrentRoute,
63
+ getLoaderData,
64
+ getNavigationState,
65
+ initializeRouter,
66
+ cleanupRouter,
67
+ type RouteInfo,
68
+ type NavigationState,
69
+ type RouterState,
70
+ type NavigateOptions,
71
+ } from "./router";
72
+
73
+ // Link Components
74
+ export { Link, NavLink, type LinkProps, type NavLinkProps } from "./Link";
75
+
76
+ // Router Hooks
77
+ export {
78
+ useRouter,
79
+ useRoute,
80
+ useParams,
81
+ usePathname,
82
+ useSearchParams,
83
+ useLoaderData,
84
+ useNavigation,
85
+ useNavigate,
86
+ useMatch,
87
+ useGoBack,
88
+ useGoForward,
89
+ useRouterState,
90
+ } from "./hooks";
91
+
45
92
  // Re-export as Mandu namespace for consistent API
46
93
  import { island, wrapComponent } from "./island";
47
94
  import { hydrateIslands, initializeRuntime } from "./runtime";
95
+ import { navigate, prefetch, initializeRouter } from "./router";
96
+ import { Link, NavLink } from "./Link";
48
97
 
49
98
  /**
50
99
  * Mandu Client namespace
@@ -73,4 +122,34 @@ export const Mandu = {
73
122
  * @see initializeRuntime
74
123
  */
75
124
  init: initializeRuntime,
125
+
126
+ /**
127
+ * Navigate to a URL (client-side)
128
+ * @see navigate
129
+ */
130
+ navigate,
131
+
132
+ /**
133
+ * Prefetch a URL for faster navigation
134
+ * @see prefetch
135
+ */
136
+ prefetch,
137
+
138
+ /**
139
+ * Initialize the client-side router
140
+ * @see initializeRouter
141
+ */
142
+ initRouter: initializeRouter,
143
+
144
+ /**
145
+ * Link component for client-side navigation
146
+ * @see Link
147
+ */
148
+ Link,
149
+
150
+ /**
151
+ * NavLink component with active state
152
+ * @see NavLink
153
+ */
154
+ NavLink,
76
155
  };
@@ -0,0 +1,387 @@
1
+ /**
2
+ * Mandu Client-side Router 🧭
3
+ * SPA 스타일 네비게이션을 위한 클라이언트 라우터
4
+ */
5
+
6
+ import type { ReactNode } from "react";
7
+
8
+ // ========== Types ==========
9
+
10
+ export interface RouteInfo {
11
+ id: string;
12
+ pattern: string;
13
+ params: Record<string, string>;
14
+ }
15
+
16
+ export interface NavigationState {
17
+ state: "idle" | "loading";
18
+ location?: string;
19
+ }
20
+
21
+ export interface RouterState {
22
+ currentRoute: RouteInfo | null;
23
+ loaderData: unknown;
24
+ navigation: NavigationState;
25
+ }
26
+
27
+ export interface NavigateOptions {
28
+ /** history.replaceState 사용 여부 */
29
+ replace?: boolean;
30
+ /** 스크롤 위치 복원 여부 */
31
+ scroll?: boolean;
32
+ }
33
+
34
+ type RouterListener = (state: RouterState) => void;
35
+
36
+ // ========== Router State ==========
37
+
38
+ let routerState: RouterState = {
39
+ currentRoute: null,
40
+ loaderData: undefined,
41
+ navigation: { state: "idle" },
42
+ };
43
+
44
+ const listeners = new Set<RouterListener>();
45
+
46
+ /**
47
+ * 초기화: 서버에서 전달된 라우트 정보로 상태 설정
48
+ */
49
+ function initializeFromServer(): void {
50
+ if (typeof window === "undefined") return;
51
+
52
+ const route = (window as any).__MANDU_ROUTE__;
53
+ const data = (window as any).__MANDU_DATA__;
54
+
55
+ if (route) {
56
+ // URL에서 실제 params 추출
57
+ const params = extractParamsFromPath(route.pattern, window.location.pathname);
58
+
59
+ routerState = {
60
+ currentRoute: {
61
+ id: route.id,
62
+ pattern: route.pattern,
63
+ params,
64
+ },
65
+ loaderData: data?.[route.id]?.serverData,
66
+ navigation: { state: "idle" },
67
+ };
68
+ }
69
+ }
70
+
71
+ // ========== Pattern Matching ==========
72
+
73
+ interface CompiledPattern {
74
+ regex: RegExp;
75
+ paramNames: string[];
76
+ }
77
+
78
+ const patternCache = new Map<string, CompiledPattern>();
79
+
80
+ /**
81
+ * 패턴을 정규식으로 컴파일
82
+ */
83
+ function compilePattern(pattern: string): CompiledPattern {
84
+ const cached = patternCache.get(pattern);
85
+ if (cached) return cached;
86
+
87
+ const paramNames: string[] = [];
88
+ const PARAM_PLACEHOLDER = "\x00PARAM\x00";
89
+ const paramMatches: string[] = [];
90
+
91
+ const withPlaceholders = pattern.replace(
92
+ /:([a-zA-Z_][a-zA-Z0-9_]*)/g,
93
+ (_, paramName) => {
94
+ paramMatches.push(paramName);
95
+ return PARAM_PLACEHOLDER;
96
+ }
97
+ );
98
+
99
+ const escaped = withPlaceholders.replace(/[.*+?^${}()|[\]\\\/]/g, "\\$&");
100
+
101
+ let paramIndex = 0;
102
+ const regexStr = escaped.replace(
103
+ new RegExp(PARAM_PLACEHOLDER.replace(/\x00/g, "\\x00"), "g"),
104
+ () => {
105
+ paramNames.push(paramMatches[paramIndex++]);
106
+ return "([^/]+)";
107
+ }
108
+ );
109
+
110
+ const compiled = {
111
+ regex: new RegExp(`^${regexStr}$`),
112
+ paramNames,
113
+ };
114
+
115
+ patternCache.set(pattern, compiled);
116
+ return compiled;
117
+ }
118
+
119
+ /**
120
+ * 패턴에서 파라미터 추출
121
+ */
122
+ function extractParamsFromPath(
123
+ pattern: string,
124
+ pathname: string
125
+ ): Record<string, string> {
126
+ const compiled = compilePattern(pattern);
127
+ const match = pathname.match(compiled.regex);
128
+
129
+ if (!match) return {};
130
+
131
+ const params: Record<string, string> = {};
132
+ compiled.paramNames.forEach((name, index) => {
133
+ params[name] = match[index + 1];
134
+ });
135
+
136
+ return params;
137
+ }
138
+
139
+ // ========== Navigation ==========
140
+
141
+ /**
142
+ * 페이지 네비게이션
143
+ */
144
+ export async function navigate(
145
+ to: string,
146
+ options: NavigateOptions = {}
147
+ ): Promise<void> {
148
+ const { replace = false, scroll = true } = options;
149
+
150
+ try {
151
+ const url = new URL(to, window.location.origin);
152
+
153
+ // 외부 URL은 일반 네비게이션
154
+ if (url.origin !== window.location.origin) {
155
+ window.location.href = to;
156
+ return;
157
+ }
158
+
159
+ // 로딩 상태 시작
160
+ routerState = {
161
+ ...routerState,
162
+ navigation: { state: "loading", location: to },
163
+ };
164
+ notifyListeners();
165
+
166
+ // 데이터 fetch
167
+ const dataUrl = `${url.pathname}${url.search ? url.search + "&" : "?"}_data=1`;
168
+ const response = await fetch(dataUrl);
169
+
170
+ if (!response.ok) {
171
+ // 에러 시 full navigation fallback
172
+ window.location.href = to;
173
+ return;
174
+ }
175
+
176
+ const data = await response.json();
177
+
178
+ // History 업데이트
179
+ const historyState = { routeId: data.routeId, params: data.params };
180
+ if (replace) {
181
+ history.replaceState(historyState, "", to);
182
+ } else {
183
+ history.pushState(historyState, "", to);
184
+ }
185
+
186
+ // 상태 업데이트
187
+ routerState = {
188
+ currentRoute: {
189
+ id: data.routeId,
190
+ pattern: data.pattern,
191
+ params: data.params,
192
+ },
193
+ loaderData: data.loaderData,
194
+ navigation: { state: "idle" },
195
+ };
196
+
197
+ // __MANDU_DATA__ 업데이트
198
+ if (typeof window !== "undefined") {
199
+ (window as any).__MANDU_DATA__ = {
200
+ ...(window as any).__MANDU_DATA__,
201
+ [data.routeId]: { serverData: data.loaderData },
202
+ };
203
+ }
204
+
205
+ notifyListeners();
206
+
207
+ // 스크롤 복원
208
+ if (scroll) {
209
+ window.scrollTo(0, 0);
210
+ }
211
+ } catch (error) {
212
+ console.error("[Mandu Router] Navigation failed:", error);
213
+ // 에러 시 full navigation fallback
214
+ window.location.href = to;
215
+ }
216
+ }
217
+
218
+ /**
219
+ * 뒤로가기/앞으로가기 처리
220
+ */
221
+ function handlePopState(event: PopStateEvent): void {
222
+ const state = event.state;
223
+
224
+ if (state?.routeId) {
225
+ // 이미 방문한 페이지 - 데이터 다시 fetch
226
+ navigate(window.location.pathname + window.location.search, {
227
+ replace: true,
228
+ scroll: false,
229
+ });
230
+ }
231
+ }
232
+
233
+ // ========== State Management ==========
234
+
235
+ /**
236
+ * 리스너에게 상태 변경 알림
237
+ */
238
+ function notifyListeners(): void {
239
+ for (const listener of listeners) {
240
+ try {
241
+ listener(routerState);
242
+ } catch (error) {
243
+ console.error("[Mandu Router] Listener error:", error);
244
+ }
245
+ }
246
+ }
247
+
248
+ /**
249
+ * 상태 변경 구독
250
+ */
251
+ export function subscribe(listener: RouterListener): () => void {
252
+ listeners.add(listener);
253
+ return () => listeners.delete(listener);
254
+ }
255
+
256
+ /**
257
+ * 현재 라우터 상태 가져오기
258
+ */
259
+ export function getRouterState(): RouterState {
260
+ return routerState;
261
+ }
262
+
263
+ /**
264
+ * 현재 라우트 정보 가져오기
265
+ */
266
+ export function getCurrentRoute(): RouteInfo | null {
267
+ return routerState.currentRoute;
268
+ }
269
+
270
+ /**
271
+ * 현재 loader 데이터 가져오기
272
+ */
273
+ export function getLoaderData<T = unknown>(): T | undefined {
274
+ return routerState.loaderData as T | undefined;
275
+ }
276
+
277
+ /**
278
+ * 네비게이션 상태 가져오기
279
+ */
280
+ export function getNavigationState(): NavigationState {
281
+ return routerState.navigation;
282
+ }
283
+
284
+ // ========== Link Click Handler ==========
285
+
286
+ /**
287
+ * 링크 클릭 이벤트 핸들러 (이벤트 위임용)
288
+ */
289
+ function handleLinkClick(event: MouseEvent): void {
290
+ // 기본 동작 조건 체크
291
+ if (
292
+ event.defaultPrevented ||
293
+ event.button !== 0 ||
294
+ event.metaKey ||
295
+ event.altKey ||
296
+ event.ctrlKey ||
297
+ event.shiftKey
298
+ ) {
299
+ return;
300
+ }
301
+
302
+ // 가장 가까운 앵커 태그 찾기
303
+ const anchor = (event.target as HTMLElement).closest("a");
304
+ if (!anchor) return;
305
+
306
+ // data-mandu-link 속성이 있는 링크만 처리
307
+ if (!anchor.hasAttribute("data-mandu-link")) return;
308
+
309
+ const href = anchor.getAttribute("href");
310
+ if (!href) return;
311
+
312
+ // 외부 링크 체크
313
+ try {
314
+ const url = new URL(href, window.location.origin);
315
+ if (url.origin !== window.location.origin) return;
316
+ } catch {
317
+ return;
318
+ }
319
+
320
+ // 기본 동작 방지 및 Client-side 네비게이션
321
+ event.preventDefault();
322
+ navigate(href);
323
+ }
324
+
325
+ // ========== Prefetch ==========
326
+
327
+ const prefetchedUrls = new Set<string>();
328
+
329
+ /**
330
+ * 페이지 데이터 미리 로드
331
+ */
332
+ export async function prefetch(url: string): Promise<void> {
333
+ if (prefetchedUrls.has(url)) return;
334
+
335
+ try {
336
+ const dataUrl = `${url}${url.includes("?") ? "&" : "?"}_data=1`;
337
+ await fetch(dataUrl, { priority: "low" } as RequestInit);
338
+ prefetchedUrls.add(url);
339
+ } catch {
340
+ // Prefetch 실패는 무시
341
+ }
342
+ }
343
+
344
+ // ========== Initialization ==========
345
+
346
+ let initialized = false;
347
+
348
+ /**
349
+ * 라우터 초기화
350
+ */
351
+ export function initializeRouter(): void {
352
+ if (typeof window === "undefined" || initialized) return;
353
+
354
+ initialized = true;
355
+
356
+ // 서버 데이터로 초기화
357
+ initializeFromServer();
358
+
359
+ // popstate 이벤트 리스너
360
+ window.addEventListener("popstate", handlePopState);
361
+
362
+ // 링크 클릭 이벤트 위임
363
+ document.addEventListener("click", handleLinkClick);
364
+
365
+ console.log("[Mandu Router] Initialized");
366
+ }
367
+
368
+ /**
369
+ * 라우터 정리
370
+ */
371
+ export function cleanupRouter(): void {
372
+ if (typeof window === "undefined" || !initialized) return;
373
+
374
+ window.removeEventListener("popstate", handlePopState);
375
+ document.removeEventListener("click", handleLinkClick);
376
+ listeners.clear();
377
+ initialized = false;
378
+ }
379
+
380
+ // 자동 초기화 (DOM 준비 시)
381
+ if (typeof window !== "undefined") {
382
+ if (document.readyState === "loading") {
383
+ document.addEventListener("DOMContentLoaded", initializeRouter);
384
+ } else {
385
+ initializeRouter();
386
+ }
387
+ }
@@ -315,6 +315,9 @@ async function handleRequest(req: Request, router: Router): Promise<Response> {
315
315
  let loaderData: unknown;
316
316
  let component: RouteComponent | undefined;
317
317
 
318
+ // Client-side Routing: 데이터 요청 감지
319
+ const isDataRequest = url.searchParams.has("_data");
320
+
318
321
  // 1. PageHandler 방식 (신규 - filling 포함)
319
322
  const pageHandler = pageHandlers.get(route.id);
320
323
  if (pageHandler) {
@@ -363,6 +366,18 @@ async function handleRequest(req: Request, router: Router): Promise<Response> {
363
366
  }
364
367
  }
365
368
 
369
+ // Client-side Routing: 데이터만 반환 (JSON)
370
+ if (isDataRequest) {
371
+ return Response.json({
372
+ routeId: route.id,
373
+ pattern: route.pattern,
374
+ params,
375
+ loaderData: loaderData ?? null,
376
+ timestamp: Date.now(),
377
+ });
378
+ }
379
+
380
+ // SSR 렌더링 (기존 로직)
366
381
  const appCreator = createAppFn || defaultCreateApp;
367
382
  try {
368
383
  const app = appCreator({
@@ -385,6 +400,9 @@ async function handleRequest(req: Request, router: Router): Promise<Response> {
385
400
  hydration: route.hydration,
386
401
  bundleManifest: serverSettings.bundleManifest,
387
402
  serverData,
403
+ // Client-side Routing 활성화 정보 전달
404
+ enableClientRouter: true,
405
+ routePattern: route.pattern,
388
406
  });
389
407
  } catch (err) {
390
408
  const ssrError = createSSRErrorResponse(
@@ -22,6 +22,10 @@ export interface SSROptions {
22
22
  isDev?: boolean;
23
23
  /** HMR 포트 (개발 모드에서 사용) */
24
24
  hmrPort?: number;
25
+ /** Client-side Routing 활성화 여부 */
26
+ enableClientRouter?: boolean;
27
+ /** 라우트 패턴 (Client-side Routing용) */
28
+ routePattern?: string;
25
29
  }
26
30
 
27
31
  /**
@@ -105,6 +109,8 @@ export function renderToHTML(element: ReactElement, options: SSROptions = {}): s
105
109
  bodyEndTags = "",
106
110
  isDev = false,
107
111
  hmrPort,
112
+ enableClientRouter = false,
113
+ routePattern,
108
114
  } = options;
109
115
 
110
116
  let content = renderToString(element);
@@ -129,12 +135,24 @@ export function renderToHTML(element: ReactElement, options: SSROptions = {}): s
129
135
  dataScript = serializeServerData(wrappedData);
130
136
  }
131
137
 
138
+ // Client-side Routing: 라우트 정보 주입
139
+ let routeScript = "";
140
+ if (enableClientRouter && routeId) {
141
+ routeScript = generateRouteScript(routeId, routePattern || "", serverData);
142
+ }
143
+
132
144
  // Hydration 스크립트
133
145
  let hydrationScripts = "";
134
146
  if (needsHydration && bundleManifest) {
135
147
  hydrationScripts = generateHydrationScripts(routeId, bundleManifest);
136
148
  }
137
149
 
150
+ // Client-side Router 스크립트
151
+ let routerScript = "";
152
+ if (enableClientRouter && bundleManifest) {
153
+ routerScript = generateClientRouterScript(bundleManifest);
154
+ }
155
+
138
156
  // HMR 스크립트 (개발 모드)
139
157
  let hmrScript = "";
140
158
  if (isDev && hmrPort) {
@@ -152,13 +170,60 @@ export function renderToHTML(element: ReactElement, options: SSROptions = {}): s
152
170
  <body>
153
171
  <div id="root">${content}</div>
154
172
  ${dataScript}
173
+ ${routeScript}
155
174
  ${hydrationScripts}
175
+ ${routerScript}
156
176
  ${hmrScript}
157
177
  ${bodyEndTags}
158
178
  </body>
159
179
  </html>`;
160
180
  }
161
181
 
182
+ /**
183
+ * Client-side Routing: 현재 라우트 정보 스크립트 생성
184
+ */
185
+ function generateRouteScript(
186
+ routeId: string,
187
+ pattern: string,
188
+ serverData?: Record<string, unknown>
189
+ ): string {
190
+ const routeInfo = {
191
+ id: routeId,
192
+ pattern,
193
+ params: extractParamsFromUrl(pattern),
194
+ };
195
+
196
+ const json = JSON.stringify(routeInfo)
197
+ .replace(/</g, "\\u003c")
198
+ .replace(/>/g, "\\u003e");
199
+
200
+ return `<script>window.__MANDU_ROUTE__ = ${json};</script>`;
201
+ }
202
+
203
+ /**
204
+ * URL 패턴에서 파라미터 추출 (클라이언트에서 사용)
205
+ */
206
+ function extractParamsFromUrl(pattern: string): Record<string, string> {
207
+ // 서버에서는 실제 params를 전달받으므로 빈 객체 반환
208
+ // 실제 params는 serverData나 별도 전달
209
+ return {};
210
+ }
211
+
212
+ /**
213
+ * Client-side Router 스크립트 로드
214
+ */
215
+ function generateClientRouterScript(manifest: BundleManifest): string {
216
+ // Import map 먼저 (이미 hydration에서 추가되었을 수 있음)
217
+ const scripts: string[] = [];
218
+
219
+ // 라우터 번들이 있으면 로드
220
+ if (manifest.shared?.router) {
221
+ scripts.push(`<script type="module" src="${manifest.shared.router}"></script>`);
222
+ }
223
+
224
+ return scripts.join("\n");
225
+ }
226
+
162
227
  /**
163
228
  * HMR 스크립트 생성
164
229
  */