@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.
@@ -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
+ }