@mandujs/core 0.5.6 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/bundler/build.ts +266 -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/generator/templates.ts +47 -0
- package/src/runtime/server.ts +96 -7
- package/src/runtime/ssr.ts +65 -0
package/package.json
CHANGED
package/src/bundler/build.ts
CHANGED
|
@@ -249,6 +249,253 @@ export default ReactDOMClient;
|
|
|
249
249
|
`;
|
|
250
250
|
}
|
|
251
251
|
|
|
252
|
+
/**
|
|
253
|
+
* JSX Runtime shim 소스 생성
|
|
254
|
+
*/
|
|
255
|
+
function generateJsxRuntimeShimSource(): string {
|
|
256
|
+
return `
|
|
257
|
+
/**
|
|
258
|
+
* Mandu JSX Runtime Shim (Generated)
|
|
259
|
+
* Production JSX 변환용
|
|
260
|
+
*/
|
|
261
|
+
import * as jsxRuntime from 'react/jsx-runtime';
|
|
262
|
+
export * from 'react/jsx-runtime';
|
|
263
|
+
export default jsxRuntime;
|
|
264
|
+
`;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* JSX Dev Runtime shim 소스 생성
|
|
269
|
+
*/
|
|
270
|
+
function generateJsxDevRuntimeShimSource(): string {
|
|
271
|
+
return `
|
|
272
|
+
/**
|
|
273
|
+
* Mandu JSX Dev Runtime Shim (Generated)
|
|
274
|
+
* Development JSX 변환용
|
|
275
|
+
*/
|
|
276
|
+
import * as jsxDevRuntime from 'react/jsx-dev-runtime';
|
|
277
|
+
export * from 'react/jsx-dev-runtime';
|
|
278
|
+
export default jsxDevRuntime;
|
|
279
|
+
`;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Client-side Router 런타임 소스 생성
|
|
284
|
+
*/
|
|
285
|
+
function generateRouterRuntimeSource(): string {
|
|
286
|
+
return `
|
|
287
|
+
/**
|
|
288
|
+
* Mandu Client Router Runtime (Generated)
|
|
289
|
+
* Client-side Routing을 위한 런타임
|
|
290
|
+
*/
|
|
291
|
+
|
|
292
|
+
// 라우트 정보
|
|
293
|
+
let currentRoute = window.__MANDU_ROUTE__ || null;
|
|
294
|
+
let currentLoaderData = window.__MANDU_DATA__?.[currentRoute?.id]?.serverData;
|
|
295
|
+
let navigationState = { state: 'idle' };
|
|
296
|
+
const listeners = new Set();
|
|
297
|
+
|
|
298
|
+
// 패턴 매칭 캐시
|
|
299
|
+
const patternCache = new Map();
|
|
300
|
+
|
|
301
|
+
function compilePattern(pattern) {
|
|
302
|
+
if (patternCache.has(pattern)) return patternCache.get(pattern);
|
|
303
|
+
|
|
304
|
+
const paramNames = [];
|
|
305
|
+
let paramIndex = 0;
|
|
306
|
+
const paramMatches = [];
|
|
307
|
+
|
|
308
|
+
const withPlaceholders = pattern.replace(/:([a-zA-Z_][a-zA-Z0-9_]*)/g, (_, name) => {
|
|
309
|
+
paramMatches.push(name);
|
|
310
|
+
return '%%PARAM%%';
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
const escaped = withPlaceholders.replace(/[.*+?^\${}()|[\\]\\\\]/g, '\\\\$&');
|
|
314
|
+
const regexStr = escaped.replace(/%%PARAM%%/g, () => {
|
|
315
|
+
paramNames.push(paramMatches[paramIndex++]);
|
|
316
|
+
return '([^/]+)';
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
const compiled = { regex: new RegExp('^' + regexStr + '$'), paramNames };
|
|
320
|
+
patternCache.set(pattern, compiled);
|
|
321
|
+
return compiled;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function extractParams(pattern, pathname) {
|
|
325
|
+
const compiled = compilePattern(pattern);
|
|
326
|
+
const match = pathname.match(compiled.regex);
|
|
327
|
+
if (!match) return {};
|
|
328
|
+
|
|
329
|
+
const params = {};
|
|
330
|
+
compiled.paramNames.forEach((name, i) => { params[name] = match[i + 1]; });
|
|
331
|
+
return params;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function notifyListeners() {
|
|
335
|
+
const state = {
|
|
336
|
+
currentRoute,
|
|
337
|
+
loaderData: currentLoaderData,
|
|
338
|
+
navigation: navigationState
|
|
339
|
+
};
|
|
340
|
+
listeners.forEach(fn => { try { fn(state); } catch(e) {} });
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
export function subscribe(listener) {
|
|
344
|
+
listeners.add(listener);
|
|
345
|
+
return () => listeners.delete(listener);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
export function getRouterState() {
|
|
349
|
+
return {
|
|
350
|
+
currentRoute,
|
|
351
|
+
loaderData: currentLoaderData,
|
|
352
|
+
navigation: navigationState
|
|
353
|
+
};
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
export async function navigate(to, options = {}) {
|
|
357
|
+
const { replace = false, scroll = true } = options;
|
|
358
|
+
|
|
359
|
+
try {
|
|
360
|
+
const url = new URL(to, location.origin);
|
|
361
|
+
if (url.origin !== location.origin) {
|
|
362
|
+
location.href = to;
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
navigationState = { state: 'loading', location: to };
|
|
367
|
+
notifyListeners();
|
|
368
|
+
|
|
369
|
+
const dataUrl = url.pathname + (url.search ? url.search + '&' : '?') + '_data=1';
|
|
370
|
+
const res = await fetch(dataUrl);
|
|
371
|
+
|
|
372
|
+
if (!res.ok) {
|
|
373
|
+
location.href = to;
|
|
374
|
+
return;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
const data = await res.json();
|
|
378
|
+
|
|
379
|
+
if (replace) {
|
|
380
|
+
history.replaceState({ routeId: data.routeId }, '', to);
|
|
381
|
+
} else {
|
|
382
|
+
history.pushState({ routeId: data.routeId }, '', to);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
currentRoute = { id: data.routeId, pattern: data.pattern, params: data.params };
|
|
386
|
+
currentLoaderData = data.loaderData;
|
|
387
|
+
navigationState = { state: 'idle' };
|
|
388
|
+
|
|
389
|
+
window.__MANDU_DATA__ = window.__MANDU_DATA__ || {};
|
|
390
|
+
window.__MANDU_DATA__[data.routeId] = { serverData: data.loaderData };
|
|
391
|
+
|
|
392
|
+
notifyListeners();
|
|
393
|
+
|
|
394
|
+
if (scroll) window.scrollTo(0, 0);
|
|
395
|
+
} catch (err) {
|
|
396
|
+
console.error('[Mandu Router] Error:', err);
|
|
397
|
+
location.href = to;
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// Link 클릭 핸들러
|
|
402
|
+
function handleClick(e) {
|
|
403
|
+
if (e.defaultPrevented || e.button !== 0 || e.metaKey || e.altKey || e.ctrlKey || e.shiftKey) return;
|
|
404
|
+
|
|
405
|
+
const anchor = e.target.closest('a[data-mandu-link]');
|
|
406
|
+
if (!anchor) return;
|
|
407
|
+
|
|
408
|
+
const href = anchor.getAttribute('href');
|
|
409
|
+
if (!href) return;
|
|
410
|
+
|
|
411
|
+
try {
|
|
412
|
+
const url = new URL(href, location.origin);
|
|
413
|
+
if (url.origin !== location.origin) return;
|
|
414
|
+
} catch { return; }
|
|
415
|
+
|
|
416
|
+
e.preventDefault();
|
|
417
|
+
navigate(href);
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// Popstate 핸들러
|
|
421
|
+
function handlePopState(e) {
|
|
422
|
+
if (e.state?.routeId) {
|
|
423
|
+
navigate(location.pathname + location.search, { replace: true, scroll: false });
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// 초기화
|
|
428
|
+
function init() {
|
|
429
|
+
if (currentRoute) {
|
|
430
|
+
currentRoute.params = extractParams(currentRoute.pattern, location.pathname);
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
window.addEventListener('popstate', handlePopState);
|
|
434
|
+
document.addEventListener('click', handleClick);
|
|
435
|
+
console.log('[Mandu Router] Initialized');
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
if (document.readyState === 'loading') {
|
|
439
|
+
document.addEventListener('DOMContentLoaded', init);
|
|
440
|
+
} else {
|
|
441
|
+
init();
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
export { currentRoute, currentLoaderData, navigationState };
|
|
445
|
+
`;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/**
|
|
449
|
+
* Router 런타임 번들 빌드
|
|
450
|
+
*/
|
|
451
|
+
async function buildRouterRuntime(
|
|
452
|
+
outDir: string,
|
|
453
|
+
options: BundlerOptions
|
|
454
|
+
): Promise<{ success: boolean; outputPath: string; errors: string[] }> {
|
|
455
|
+
const routerPath = path.join(outDir, "_router.src.js");
|
|
456
|
+
const outputName = "_router.js";
|
|
457
|
+
|
|
458
|
+
try {
|
|
459
|
+
await Bun.write(routerPath, generateRouterRuntimeSource());
|
|
460
|
+
|
|
461
|
+
const result = await Bun.build({
|
|
462
|
+
entrypoints: [routerPath],
|
|
463
|
+
outdir: outDir,
|
|
464
|
+
naming: outputName,
|
|
465
|
+
minify: options.minify ?? process.env.NODE_ENV === "production",
|
|
466
|
+
sourcemap: options.sourcemap ? "external" : "none",
|
|
467
|
+
target: "browser",
|
|
468
|
+
define: {
|
|
469
|
+
"process.env.NODE_ENV": JSON.stringify(process.env.NODE_ENV || "development"),
|
|
470
|
+
...options.define,
|
|
471
|
+
},
|
|
472
|
+
});
|
|
473
|
+
|
|
474
|
+
await fs.unlink(routerPath).catch(() => {});
|
|
475
|
+
|
|
476
|
+
if (!result.success) {
|
|
477
|
+
return {
|
|
478
|
+
success: false,
|
|
479
|
+
outputPath: "",
|
|
480
|
+
errors: result.logs.map((l) => l.message),
|
|
481
|
+
};
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
return {
|
|
485
|
+
success: true,
|
|
486
|
+
outputPath: `/.mandu/client/${outputName}`,
|
|
487
|
+
errors: [],
|
|
488
|
+
};
|
|
489
|
+
} catch (error) {
|
|
490
|
+
await fs.unlink(routerPath).catch(() => {});
|
|
491
|
+
return {
|
|
492
|
+
success: false,
|
|
493
|
+
outputPath: "",
|
|
494
|
+
errors: [String(error)],
|
|
495
|
+
};
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
|
|
252
499
|
/**
|
|
253
500
|
* Island 엔트리 래퍼 생성
|
|
254
501
|
*/
|
|
@@ -332,6 +579,8 @@ interface VendorBuildResult {
|
|
|
332
579
|
react: string;
|
|
333
580
|
reactDom: string;
|
|
334
581
|
reactDomClient: string;
|
|
582
|
+
jsxRuntime: string;
|
|
583
|
+
jsxDevRuntime: string;
|
|
335
584
|
errors: string[];
|
|
336
585
|
}
|
|
337
586
|
|
|
@@ -348,12 +597,16 @@ async function buildVendorShims(
|
|
|
348
597
|
react: "",
|
|
349
598
|
reactDom: "",
|
|
350
599
|
reactDomClient: "",
|
|
600
|
+
jsxRuntime: "",
|
|
601
|
+
jsxDevRuntime: "",
|
|
351
602
|
};
|
|
352
603
|
|
|
353
604
|
const shims = [
|
|
354
605
|
{ name: "_react", source: generateReactShimSource(), key: "react" },
|
|
355
606
|
{ name: "_react-dom", source: generateReactDOMShimSource(), key: "reactDom" },
|
|
356
607
|
{ name: "_react-dom-client", source: generateReactDOMClientShimSource(), key: "reactDomClient" },
|
|
608
|
+
{ name: "_jsx-runtime", source: generateJsxRuntimeShimSource(), key: "jsxRuntime" },
|
|
609
|
+
{ name: "_jsx-dev-runtime", source: generateJsxDevRuntimeShimSource(), key: "jsxDevRuntime" },
|
|
357
610
|
];
|
|
358
611
|
|
|
359
612
|
for (const shim of shims) {
|
|
@@ -394,6 +647,8 @@ async function buildVendorShims(
|
|
|
394
647
|
react: results.react,
|
|
395
648
|
reactDom: results.reactDom,
|
|
396
649
|
reactDomClient: results.reactDomClient,
|
|
650
|
+
jsxRuntime: results.jsxRuntime,
|
|
651
|
+
jsxDevRuntime: results.jsxDevRuntime,
|
|
397
652
|
errors,
|
|
398
653
|
};
|
|
399
654
|
}
|
|
@@ -465,6 +720,7 @@ function createBundleManifest(
|
|
|
465
720
|
routes: RouteSpec[],
|
|
466
721
|
runtimePath: string,
|
|
467
722
|
vendorResult: VendorBuildResult,
|
|
723
|
+
routerPath: string,
|
|
468
724
|
env: "development" | "production"
|
|
469
725
|
): BundleManifest {
|
|
470
726
|
const bundles: BundleManifest["bundles"] = {};
|
|
@@ -488,12 +744,15 @@ function createBundleManifest(
|
|
|
488
744
|
shared: {
|
|
489
745
|
runtime: runtimePath,
|
|
490
746
|
vendor: vendorResult.react, // primary vendor for backwards compatibility
|
|
747
|
+
router: routerPath, // Client-side Router
|
|
491
748
|
},
|
|
492
749
|
importMap: {
|
|
493
750
|
imports: {
|
|
494
751
|
"react": vendorResult.react,
|
|
495
752
|
"react-dom": vendorResult.reactDom,
|
|
496
753
|
"react-dom/client": vendorResult.reactDomClient,
|
|
754
|
+
"react/jsx-runtime": vendorResult.jsxRuntime,
|
|
755
|
+
"react/jsx-dev-runtime": vendorResult.jsxDevRuntime,
|
|
497
756
|
},
|
|
498
757
|
},
|
|
499
758
|
};
|
|
@@ -583,6 +842,12 @@ export async function buildClientBundles(
|
|
|
583
842
|
errors.push(...runtimeResult.errors.map((e) => `[Runtime] ${e}`));
|
|
584
843
|
}
|
|
585
844
|
|
|
845
|
+
// 3.5. Client-side Router 런타임 빌드
|
|
846
|
+
const routerResult = await buildRouterRuntime(outDir, options);
|
|
847
|
+
if (!routerResult.success) {
|
|
848
|
+
errors.push(...routerResult.errors.map((e) => `[Router] ${e}`));
|
|
849
|
+
}
|
|
850
|
+
|
|
586
851
|
// 4. Vendor shim 번들 빌드 (React, ReactDOM, ReactDOMClient)
|
|
587
852
|
const vendorResult = await buildVendorShims(outDir, options);
|
|
588
853
|
if (!vendorResult.success) {
|
|
@@ -605,6 +870,7 @@ export async function buildClientBundles(
|
|
|
605
870
|
hydratedRoutes,
|
|
606
871
|
runtimeResult.outputPath,
|
|
607
872
|
vendorResult,
|
|
873
|
+
routerResult.outputPath,
|
|
608
874
|
env
|
|
609
875
|
);
|
|
610
876
|
|
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;
|