@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 +1 -1
- package/src/bundler/build.ts +226 -0
- package/src/bundler/types.ts +2 -0
- package/src/client/Link.tsx +209 -0
- package/src/client/hooks.ts +267 -0
- package/src/client/index.ts +81 -2
- package/src/client/router.ts +387 -0
- package/src/runtime/server.ts +18 -0
- package/src/runtime/ssr.ts +65 -0
package/package.json
CHANGED
package/src/bundler/build.ts
CHANGED
|
@@ -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
|
|
package/src/bundler/types.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/client/index.ts
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Mandu Client Module 🏝️
|
|
3
|
-
* 클라이언트 사이드 hydration
|
|
3
|
+
* 클라이언트 사이드 hydration 및 라우팅을 위한 API
|
|
4
4
|
*
|
|
5
5
|
* @example
|
|
6
6
|
* ```typescript
|
|
7
|
-
* //
|
|
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
|
+
}
|
package/src/runtime/server.ts
CHANGED
|
@@ -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(
|
package/src/runtime/ssr.ts
CHANGED
|
@@ -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
|
*/
|