@mandujs/core 0.8.1 → 0.8.2
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/README.ko.md +200 -200
- package/README.md +200 -200
- package/package.json +41 -41
- package/src/bundler/build.ts +30 -2
- package/src/bundler/dev.ts +98 -52
- package/src/client/Link.tsx +209 -209
- package/src/client/hooks.ts +267 -267
- package/src/client/router.ts +387 -387
- package/src/client/serialize.ts +404 -404
- package/src/filling/auth.ts +308 -308
- package/src/filling/context.ts +438 -438
- package/src/filling/filling.ts +306 -306
- package/src/filling/index.ts +21 -21
- package/src/generator/index.ts +3 -3
- package/src/report/index.ts +1 -1
- package/src/runtime/compose.ts +222 -222
- package/src/runtime/index.ts +3 -3
- package/src/runtime/lifecycle.ts +381 -381
- package/src/runtime/ssr.ts +321 -321
- package/src/runtime/trace.ts +144 -144
- package/src/spec/index.ts +3 -3
- package/src/spec/load.ts +76 -76
- package/src/spec/lock.ts +56 -56
package/src/bundler/dev.ts
CHANGED
|
@@ -40,7 +40,6 @@ export interface DevBundler {
|
|
|
40
40
|
*/
|
|
41
41
|
export async function startDevBundler(options: DevBundlerOptions): Promise<DevBundler> {
|
|
42
42
|
const { rootDir, manifest, onRebuild, onError } = options;
|
|
43
|
-
const slotsDir = path.join(rootDir, "spec", "slots");
|
|
44
43
|
|
|
45
44
|
// 초기 빌드
|
|
46
45
|
console.log("🔨 Initial client bundle build...");
|
|
@@ -55,69 +54,116 @@ export async function startDevBundler(options: DevBundlerOptions): Promise<DevBu
|
|
|
55
54
|
console.error("⚠️ Initial build had errors:", initialBuild.errors);
|
|
56
55
|
}
|
|
57
56
|
|
|
58
|
-
//
|
|
59
|
-
|
|
60
|
-
|
|
57
|
+
// clientModule 경로에서 routeId 매핑 생성
|
|
58
|
+
const clientModuleToRoute = new Map<string, string>();
|
|
59
|
+
const watchDirs = new Set<string>();
|
|
61
60
|
|
|
61
|
+
for (const route of manifest.routes) {
|
|
62
|
+
if (route.clientModule) {
|
|
63
|
+
const absPath = path.resolve(rootDir, route.clientModule);
|
|
64
|
+
const normalizedPath = absPath.replace(/\\/g, "/");
|
|
65
|
+
clientModuleToRoute.set(normalizedPath, route.id);
|
|
66
|
+
|
|
67
|
+
// 감시할 디렉토리 추가
|
|
68
|
+
const dir = path.dirname(absPath);
|
|
69
|
+
watchDirs.add(dir);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// spec/slots 디렉토리도 추가
|
|
74
|
+
const slotsDir = path.join(rootDir, "spec", "slots");
|
|
62
75
|
try {
|
|
63
76
|
await fs.promises.access(slotsDir);
|
|
77
|
+
watchDirs.add(slotsDir);
|
|
78
|
+
} catch {
|
|
79
|
+
// slots 디렉토리 없으면 무시
|
|
80
|
+
}
|
|
64
81
|
|
|
65
|
-
|
|
66
|
-
|
|
82
|
+
// 파일 감시 설정
|
|
83
|
+
const watchers: fs.FSWatcher[] = [];
|
|
84
|
+
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
|
67
85
|
|
|
68
|
-
|
|
69
|
-
|
|
86
|
+
const handleFileChange = async (changedFile: string) => {
|
|
87
|
+
const normalizedPath = changedFile.replace(/\\/g, "/");
|
|
70
88
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
89
|
+
// clientModule 매핑에서 routeId 찾기
|
|
90
|
+
let routeId = clientModuleToRoute.get(normalizedPath);
|
|
91
|
+
|
|
92
|
+
// .client.ts 파일인 경우 파일명에서 routeId 추출
|
|
93
|
+
if (!routeId && changedFile.endsWith(".client.ts")) {
|
|
94
|
+
const basename = path.basename(changedFile, ".client.ts");
|
|
95
|
+
const route = manifest.routes.find((r) => r.id === basename);
|
|
96
|
+
if (route) {
|
|
97
|
+
routeId = route.id;
|
|
74
98
|
}
|
|
99
|
+
}
|
|
75
100
|
|
|
76
|
-
|
|
77
|
-
const routeId = filename.replace(".client.ts", "").replace(/\\/g, "/").split("/").pop();
|
|
78
|
-
if (!routeId) return;
|
|
101
|
+
if (!routeId) return;
|
|
79
102
|
|
|
80
|
-
|
|
81
|
-
|
|
103
|
+
const route = manifest.routes.find((r) => r.id === routeId);
|
|
104
|
+
if (!route || !route.clientModule) return;
|
|
82
105
|
|
|
83
|
-
|
|
84
|
-
|
|
106
|
+
console.log(`\n🔄 Rebuilding: ${routeId}`);
|
|
107
|
+
const startTime = performance.now();
|
|
85
108
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
109
|
+
try {
|
|
110
|
+
const result = await buildClientBundles(manifest, rootDir, {
|
|
111
|
+
minify: false,
|
|
112
|
+
sourcemap: true,
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
const buildTime = performance.now() - startTime;
|
|
116
|
+
|
|
117
|
+
if (result.success) {
|
|
118
|
+
console.log(`✅ Rebuilt in ${buildTime.toFixed(0)}ms`);
|
|
119
|
+
onRebuild?.({
|
|
120
|
+
routeId,
|
|
121
|
+
success: true,
|
|
122
|
+
buildTime,
|
|
123
|
+
});
|
|
124
|
+
} else {
|
|
125
|
+
console.error(`❌ Build failed:`, result.errors);
|
|
126
|
+
onRebuild?.({
|
|
127
|
+
routeId,
|
|
128
|
+
success: false,
|
|
129
|
+
buildTime,
|
|
130
|
+
error: result.errors.join(", "),
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
} catch (error) {
|
|
134
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
135
|
+
console.error(`❌ Build error:`, err.message);
|
|
136
|
+
onError?.(err, routeId);
|
|
137
|
+
}
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
// 각 디렉토리에 watcher 설정
|
|
141
|
+
for (const dir of watchDirs) {
|
|
142
|
+
try {
|
|
143
|
+
const watcher = fs.watch(dir, { recursive: true }, async (event, filename) => {
|
|
144
|
+
if (!filename) return;
|
|
145
|
+
|
|
146
|
+
// TypeScript/TSX 파일만 감시
|
|
147
|
+
if (!filename.endsWith(".ts") && !filename.endsWith(".tsx")) return;
|
|
148
|
+
|
|
149
|
+
const fullPath = path.join(dir, filename);
|
|
150
|
+
|
|
151
|
+
// Debounce - 연속 변경 무시
|
|
152
|
+
if (debounceTimer) {
|
|
153
|
+
clearTimeout(debounceTimer);
|
|
114
154
|
}
|
|
115
|
-
}, 100); // 100ms debounce
|
|
116
|
-
});
|
|
117
155
|
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
156
|
+
debounceTimer = setTimeout(() => handleFileChange(fullPath), 100);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
watchers.push(watcher);
|
|
160
|
+
} catch {
|
|
161
|
+
console.warn(`⚠️ Cannot watch directory: ${dir}`);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (watchers.length > 0) {
|
|
166
|
+
console.log(`👀 Watching ${watchers.length} directories for changes...`);
|
|
121
167
|
}
|
|
122
168
|
|
|
123
169
|
return {
|
|
@@ -126,7 +172,7 @@ export async function startDevBundler(options: DevBundlerOptions): Promise<DevBu
|
|
|
126
172
|
if (debounceTimer) {
|
|
127
173
|
clearTimeout(debounceTimer);
|
|
128
174
|
}
|
|
129
|
-
|
|
175
|
+
for (const watcher of watchers) {
|
|
130
176
|
watcher.close();
|
|
131
177
|
}
|
|
132
178
|
},
|
package/src/client/Link.tsx
CHANGED
|
@@ -1,209 +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;
|
|
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;
|