@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 +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 +90 -2
- package/src/client/router.ts +387 -0
- package/src/client/serialize.ts +404 -0
- package/src/filling/filling.ts +96 -0
- package/src/runtime/compose.ts +222 -0
- package/src/runtime/index.ts +2 -0
- package/src/runtime/lifecycle.ts +360 -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;
|