@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.
@@ -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
- let watcher: fs.FSWatcher | null = null;
60
- let debounceTimer: ReturnType<typeof setTimeout> | null = null;
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
- watcher = fs.watch(slotsDir, { recursive: true }, async (event, filename) => {
66
- if (!filename) return;
82
+ // 파일 감시 설정
83
+ const watchers: fs.FSWatcher[] = [];
84
+ let debounceTimer: ReturnType<typeof setTimeout> | null = null;
67
85
 
68
- // .client.ts 파일만 감시
69
- if (!filename.endsWith(".client.ts")) return;
86
+ const handleFileChange = async (changedFile: string) => {
87
+ const normalizedPath = changedFile.replace(/\\/g, "/");
70
88
 
71
- // Debounce - 연속 변경 무시
72
- if (debounceTimer) {
73
- clearTimeout(debounceTimer);
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
- debounceTimer = setTimeout(async () => {
77
- const routeId = filename.replace(".client.ts", "").replace(/\\/g, "/").split("/").pop();
78
- if (!routeId) return;
101
+ if (!routeId) return;
79
102
 
80
- const route = manifest.routes.find((r) => r.id === routeId);
81
- if (!route || !route.clientModule) return;
103
+ const route = manifest.routes.find((r) => r.id === routeId);
104
+ if (!route || !route.clientModule) return;
82
105
 
83
- console.log(`\n🔄 Rebuilding: ${routeId}`);
84
- const startTime = performance.now();
106
+ console.log(`\n🔄 Rebuilding: ${routeId}`);
107
+ const startTime = performance.now();
85
108
 
86
- try {
87
- const result = await buildClientBundles(manifest, rootDir, {
88
- minify: false,
89
- sourcemap: true,
90
- });
91
-
92
- const buildTime = performance.now() - startTime;
93
-
94
- if (result.success) {
95
- console.log(`✅ Rebuilt in ${buildTime.toFixed(0)}ms`);
96
- onRebuild?.({
97
- routeId,
98
- success: true,
99
- buildTime,
100
- });
101
- } else {
102
- console.error(`❌ Build failed:`, result.errors);
103
- onRebuild?.({
104
- routeId,
105
- success: false,
106
- buildTime,
107
- error: result.errors.join(", "),
108
- });
109
- }
110
- } catch (error) {
111
- const err = error instanceof Error ? error : new Error(String(error));
112
- console.error(`❌ Build error:`, err.message);
113
- onError?.(err, routeId);
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
- console.log("👀 Watching for client slot changes...");
119
- } catch {
120
- console.warn(`⚠️ Slots directory not found: ${slotsDir}`);
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
- if (watcher) {
175
+ for (const watcher of watchers) {
130
176
  watcher.close();
131
177
  }
132
178
  },
@@ -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;