@flight-framework/router 0.0.1 → 0.0.3

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.md CHANGED
@@ -1,13 +1,15 @@
1
1
  # @flight-framework/router
2
2
 
3
- Agnostic client-side routing primitives for Flight Framework.
3
+ Universal client-side routing primitives for Flight Framework. Zero external dependencies. Works with any UI framework.
4
4
 
5
- ## Philosophy
5
+ ## Features
6
6
 
7
- - Zero external runtime dependencies
8
- - Works with React, Vue, Svelte, or vanilla JS
9
- - SSR-safe (works on server and client)
10
- - No vendor lock-in
7
+ - Multiple prefetch strategies (none, intent, render, viewport)
8
+ - SSR-safe implementation
9
+ - Framework-agnostic with React adapters
10
+ - TypeScript-first design
11
+ - IntersectionObserver for viewport prefetching
12
+ - Backwards compatible API
11
13
 
12
14
  ## Installation
13
15
 
@@ -15,14 +17,13 @@ Agnostic client-side routing primitives for Flight Framework.
15
17
  npm install @flight-framework/router
16
18
  ```
17
19
 
18
- ## Usage
20
+ ## Quick Start
19
21
 
20
22
  ### With React
21
23
 
22
24
  ```tsx
23
25
  import { RouterProvider, Link, useRouter } from '@flight-framework/router';
24
26
 
25
- // Wrap your app
26
27
  function App() {
27
28
  return (
28
29
  <RouterProvider initialPath={url}>
@@ -32,21 +33,19 @@ function App() {
32
33
  );
33
34
  }
34
35
 
35
- // Use Link for navigation
36
36
  function Navigation() {
37
37
  return (
38
38
  <nav>
39
39
  <Link href="/">Home</Link>
40
- <Link href="/docs" prefetch>Docs</Link>
40
+ <Link href="/docs" prefetch="intent">Docs</Link>
41
41
  <Link href="/about">About</Link>
42
42
  </nav>
43
43
  );
44
44
  }
45
45
 
46
- // Use hooks to access router state
47
46
  function Content() {
48
47
  const { path, navigate } = useRouter();
49
-
48
+
50
49
  return (
51
50
  <main>
52
51
  <p>Current path: {path}</p>
@@ -58,7 +57,89 @@ function Content() {
58
57
  }
59
58
  ```
60
59
 
61
- ### Hooks
60
+ ## Prefetch Strategies
61
+
62
+ Flight Router supports multiple prefetch strategies to optimize navigation performance:
63
+
64
+ | Strategy | Behavior | Best For |
65
+ |----------|----------|----------|
66
+ | `'none'` | No prefetching (default) | Low-priority links |
67
+ | `'intent'` | Prefetch on hover/focus | Most navigation links |
68
+ | `'render'` | Prefetch immediately | Critical paths (checkout) |
69
+ | `'viewport'` | Prefetch when visible | Mobile, infinite scroll |
70
+
71
+ ### Usage Examples
72
+
73
+ ```tsx
74
+ // No prefetching (default)
75
+ <Link href="/docs">Docs</Link>
76
+ <Link href="/docs" prefetch="none">Docs</Link>
77
+
78
+ // Prefetch on hover/focus (recommended for most cases)
79
+ <Link href="/docs" prefetch="intent">Docs</Link>
80
+ <Link href="/docs" prefetch>Docs</Link> // boolean true = "intent"
81
+
82
+ // Prefetch immediately when link renders
83
+ <Link href="/checkout" prefetch="render">Checkout</Link>
84
+
85
+ // Prefetch when link enters viewport (good for mobile)
86
+ <Link href="/products" prefetch="viewport">Products</Link>
87
+ ```
88
+
89
+ ## Programmatic Prefetching
90
+
91
+ ```typescript
92
+ import {
93
+ prefetch,
94
+ prefetchAll,
95
+ prefetchWhenIdle,
96
+ isPrefetched,
97
+ } from '@flight-framework/router';
98
+
99
+ // Basic prefetch
100
+ prefetch('/docs');
101
+
102
+ // High priority prefetch
103
+ prefetch('/checkout', { priority: 'high' });
104
+
105
+ // Prefetch with data loaders
106
+ prefetch('/products', { includeData: true });
107
+
108
+ // Prefetch multiple pages
109
+ prefetchAll(['/page1', '/page2', '/page3']);
110
+
111
+ // Prefetch when browser is idle
112
+ prefetchWhenIdle('/dashboard');
113
+
114
+ // Check if already prefetched
115
+ if (!isPrefetched('/docs')) {
116
+ prefetch('/docs');
117
+ }
118
+ ```
119
+
120
+ ## PrefetchPageLinks Component
121
+
122
+ For proactive prefetching based on user behavior (search results, autocomplete):
123
+
124
+ ```tsx
125
+ import { PrefetchPageLinks } from '@flight-framework/router';
126
+
127
+ function SearchResults({ results }) {
128
+ return (
129
+ <>
130
+ {results.map(result => (
131
+ <div key={result.id}>
132
+ {/* Prefetch as soon as result renders */}
133
+ <PrefetchPageLinks page={result.url} />
134
+ <Link href={result.url}>{result.title}</Link>
135
+ </div>
136
+ ))}
137
+ </>
138
+ );
139
+ }
140
+ ```
141
+
142
+ ## Hooks
62
143
 
63
144
  ```tsx
64
145
  // Get current path and navigation functions
@@ -74,9 +155,9 @@ const [searchParams, setSearchParams] = useSearchParams();
74
155
  const pathname = usePathname();
75
156
  ```
76
157
 
77
- ### Programmatic Navigation
158
+ ## Programmatic Navigation
78
159
 
79
- ```ts
160
+ ```typescript
80
161
  import { navigate, prefetch } from '@flight-framework/router';
81
162
 
82
163
  // Navigate to a path
@@ -90,14 +171,11 @@ navigate('/next-page', { scroll: false });
90
171
 
91
172
  // Pass state data
92
173
  navigate('/dashboard', { state: { from: '/login' } });
93
-
94
- // Prefetch a route
95
- prefetch('/heavy-page');
96
174
  ```
97
175
 
98
- ### Route Matching
176
+ ## Route Matching
99
177
 
100
- ```ts
178
+ ```typescript
101
179
  import { matchRoute, parseParams, generatePath } from '@flight-framework/router';
102
180
 
103
181
  // Check if a path matches a pattern
@@ -113,16 +191,45 @@ const path = generatePath('/docs/:slug', { slug: 'api-routes' });
113
191
  // '/docs/api-routes'
114
192
  ```
115
193
 
116
- ### Link Props
194
+ ## Link Props
117
195
 
118
196
  | Prop | Type | Default | Description |
119
197
  |------|------|---------|-------------|
120
198
  | `href` | `string` | required | Target URL path |
121
- | `prefetch` | `boolean` | `false` | Prefetch target on hover |
199
+ | `prefetch` | `boolean \| PrefetchStrategy` | `'none'` | Prefetch strategy |
122
200
  | `replace` | `boolean` | `false` | Replace history entry |
123
201
  | `scroll` | `boolean` | `true` | Scroll to top on navigate |
124
202
  | `target` | `string` | - | Link target (_blank, etc) |
125
203
  | `className` | `string` | - | CSS class |
204
+ | `aria-label` | `string` | - | Accessibility label |
205
+
206
+ ## Vue Integration
207
+
208
+ ```vue
209
+ <script setup>
210
+ import { useLinkProps } from '@flight-framework/router';
211
+
212
+ const docsLink = useLinkProps('/docs', { prefetch: 'intent' });
213
+ </script>
214
+
215
+ <template>
216
+ <a v-bind="docsLink">Documentation</a>
217
+ </template>
218
+ ```
219
+
220
+ ## Vanilla JavaScript
221
+
222
+ ```typescript
223
+ import { createLink, prefetch } from '@flight-framework/router';
224
+
225
+ const link = createLink({
226
+ href: '/docs',
227
+ children: 'Documentation',
228
+ prefetch: 'intent',
229
+ });
230
+
231
+ document.body.appendChild(link);
232
+ ```
126
233
 
127
234
  ## SSR Support
128
235
 
@@ -139,6 +246,28 @@ export function render(url: string) {
139
246
  }
140
247
  ```
141
248
 
249
+ ## Build Integration
250
+
251
+ For optimal prefetching, Flight generates a route manifest during build that maps routes to their JS modules. This enables `prefetch` to preload the correct chunks.
252
+
253
+ ```typescript
254
+ // flight.config.ts
255
+ export default defineConfig({
256
+ router: {
257
+ // Generates __FLIGHT_MANIFEST__ at build time
258
+ generateManifest: true,
259
+ },
260
+ });
261
+ ```
262
+
263
+ ## Browser Support
264
+
265
+ | Feature | Chrome | Firefox | Safari | Edge |
266
+ |---------|--------|---------|--------|------|
267
+ | Prefetch | All | All | All | All |
268
+ | Viewport (IntersectionObserver) | 51+ | 55+ | 12.1+ | 15+ |
269
+ | fetchPriority | 101+ | No | No | 101+ |
270
+
142
271
  ## License
143
272
 
144
273
  MIT
package/dist/index.d.ts CHANGED
@@ -27,6 +27,30 @@ interface RouterProviderProps {
27
27
  /** Base path for the router (e.g., '/app') */
28
28
  basePath?: string;
29
29
  }
30
+ /**
31
+ * Prefetch strategy for Link component
32
+ *
33
+ * - 'none': No prefetching (default)
34
+ * - 'intent': Prefetch on hover or focus (recommended for most cases)
35
+ * - 'render': Prefetch immediately when link renders
36
+ * - 'viewport': Prefetch when link enters the viewport (good for mobile)
37
+ */
38
+ type PrefetchStrategy = 'none' | 'intent' | 'render' | 'viewport';
39
+ /**
40
+ * Priority level for prefetch requests
41
+ */
42
+ type PrefetchPriority = 'high' | 'low' | 'auto';
43
+ /**
44
+ * Options for programmatic prefetching
45
+ */
46
+ interface PrefetchOptions {
47
+ /** Priority of the prefetch request */
48
+ priority?: PrefetchPriority;
49
+ /** Include data prefetch (loaders) */
50
+ includeData?: boolean;
51
+ /** Include module prefetch (JS chunks) */
52
+ includeModules?: boolean;
53
+ }
30
54
  /**
31
55
  * Props for Link component
32
56
  */
@@ -41,8 +65,17 @@ interface LinkProps {
41
65
  target?: string;
42
66
  /** Link relationship */
43
67
  rel?: string;
44
- /** Prefetch the target page */
45
- prefetch?: boolean;
68
+ /**
69
+ * Prefetch strategy for the target page.
70
+ *
71
+ * - `true` or `'intent'`: Prefetch on hover/focus
72
+ * - `'render'`: Prefetch when link renders
73
+ * - `'viewport'`: Prefetch when link enters viewport
74
+ * - `false` or `'none'`: No prefetching (default)
75
+ *
76
+ * @default 'none'
77
+ */
78
+ prefetch?: boolean | PrefetchStrategy;
46
79
  /** Replace current history entry instead of pushing */
47
80
  replace?: boolean;
48
81
  /** Scroll to top after navigation */
@@ -71,6 +104,51 @@ type RouteParams<T extends string = string> = Record<T, string>;
71
104
  * Search params as key-value pairs
72
105
  */
73
106
  type SearchParams = Record<string, string | string[]>;
107
+ /**
108
+ * Route definition for automatic route generation
109
+ */
110
+ interface RouteDefinition {
111
+ /** Route path pattern (e.g., '/docs/:slug') */
112
+ path: string;
113
+ /** Dynamic import for component */
114
+ component: () => Promise<{
115
+ default: unknown;
116
+ }>;
117
+ /** Dynamic imports for layouts (in order, root first) */
118
+ layouts?: Array<() => Promise<{
119
+ default: unknown;
120
+ }>>;
121
+ /** Dynamic import for loader function */
122
+ loader?: () => Promise<{
123
+ loader: LoaderFunction;
124
+ }>;
125
+ }
126
+ /**
127
+ * Loader function signature
128
+ */
129
+ type LoaderFunction = (context: LoaderContext) => Promise<unknown> | unknown;
130
+ /**
131
+ * Context passed to loader functions
132
+ */
133
+ interface LoaderContext {
134
+ /** Route parameters */
135
+ params: RouteParams;
136
+ /** Request object (available on server) */
137
+ request?: Request;
138
+ /** URL search params */
139
+ searchParams: URLSearchParams;
140
+ }
141
+ /**
142
+ * Route match result
143
+ */
144
+ interface RouteMatch {
145
+ /** Matched route definition */
146
+ route: RouteDefinition;
147
+ /** Extracted parameters */
148
+ params: RouteParams;
149
+ /** Matched path */
150
+ pathname: string;
151
+ }
74
152
 
75
153
  /**
76
154
  * Router Context and Provider
@@ -88,13 +166,101 @@ declare let RouterProvider: unknown;
88
166
  declare let useRouter: () => RouterContextValue;
89
167
 
90
168
  /**
91
- * Link Component
169
+ * @flight-framework/router - Link Component
170
+ *
171
+ * Universal client-side navigation link with advanced prefetching support.
172
+ * Works with React, Vue, Svelte, Solid, and vanilla JavaScript.
92
173
  *
93
- * Client-side navigation link with prefetching support.
94
- * Works with React or as a vanilla function.
174
+ * Features:
175
+ * - Multiple prefetch strategies (none, intent, render, viewport)
176
+ * - SSR-safe implementation
177
+ * - Accessibility compliant
178
+ * - Framework-agnostic core with React adapter
95
179
  */
96
180
 
181
+ /**
182
+ * @deprecated Use `prefetch` from '@flight-framework/router' instead.
183
+ */
184
+ declare function prefetchRoute(href: string): void;
97
185
  declare let Link: unknown;
186
+ /**
187
+ * Create a link element with SPA navigation support (vanilla JS).
188
+ *
189
+ * For Vue, Svelte, Solid, and other frameworks, use this function
190
+ * or implement framework-specific wrappers.
191
+ *
192
+ * @example
193
+ * ```typescript
194
+ * const link = createLink({
195
+ * href: '/docs',
196
+ * children: 'Documentation',
197
+ * prefetch: 'intent',
198
+ * });
199
+ * document.body.appendChild(link);
200
+ * ```
201
+ */
202
+ declare function createLink(props: LinkProps): HTMLAnchorElement;
203
+ /**
204
+ * Get link props for Vue template usage.
205
+ *
206
+ * @example
207
+ * ```vue
208
+ * <script setup>
209
+ * import { useLinkProps } from '@flight-framework/router';
210
+ * const linkProps = useLinkProps('/docs', { prefetch: 'intent' });
211
+ * </script>
212
+ *
213
+ * <template>
214
+ * <a v-bind="linkProps">Documentation</a>
215
+ * </template>
216
+ * ```
217
+ */
218
+ declare function useLinkProps(href: string, options?: Partial<Omit<LinkProps, 'href' | 'children'>>): {
219
+ href: string;
220
+ onClick: (e: MouseEvent) => void;
221
+ onMouseenter?: () => void;
222
+ onFocus?: () => void;
223
+ };
224
+
225
+ /**
226
+ * @flight-framework/router - PrefetchPageLinks Component
227
+ *
228
+ * Renders prefetch link tags for a page. Useful for proactively
229
+ * prefetching pages based on user behavior (e.g., search results).
230
+ *
231
+ * This is the Flight equivalent of React Router's PrefetchPageLinks.
232
+ */
233
+
234
+ declare let PrefetchPageLinks: unknown;
235
+ /**
236
+ * Prefetch multiple pages at once.
237
+ *
238
+ * Useful for prefetching a list of search results or related pages.
239
+ *
240
+ * @example
241
+ * ```typescript
242
+ * // Prefetch top 5 search results
243
+ * prefetchPages([
244
+ * '/products/1',
245
+ * '/products/2',
246
+ * '/products/3',
247
+ * ]);
248
+ * ```
249
+ */
250
+ declare function prefetchPages(pages: string[], options?: PrefetchOptions): void;
251
+ /**
252
+ * Prefetch a page when idle.
253
+ *
254
+ * Uses requestIdleCallback if available, otherwise falls back
255
+ * to setTimeout.
256
+ *
257
+ * @example
258
+ * ```typescript
259
+ * // Prefetch after initial render settles
260
+ * prefetchWhenIdle('/dashboard');
261
+ * ```
262
+ */
263
+ declare function prefetchWhenIdle(page: string, options?: PrefetchOptions): void;
98
264
 
99
265
  /**
100
266
  * Router Hooks
@@ -124,16 +290,6 @@ declare let usePathname: () => string;
124
290
  * ```
125
291
  */
126
292
  declare function navigate(to: string, options?: NavigateOptions): void;
127
- /**
128
- * Prefetch a route's assets
129
- * Creates a prefetch link for the target URL
130
- *
131
- * @example
132
- * ```ts
133
- * prefetch('/docs'); // Prefetch when user hovers
134
- * ```
135
- */
136
- declare function prefetch(href: string): void;
137
293
  /**
138
294
  * Match a pathname against a route pattern
139
295
  *
@@ -157,5 +313,100 @@ declare function matchRoute(pathname: string, pattern: string): {
157
313
  * ```
158
314
  */
159
315
  declare function parseParams(pathname: string, pattern: string): RouteParams;
316
+ /**
317
+ * Find the best matching route from a list of route definitions
318
+ */
319
+ declare function findRoute(pathname: string, routes: RouteDefinition[]): RouteMatch | null;
320
+ /**
321
+ * Generate a URL from a route pattern and params
322
+ *
323
+ * @example
324
+ * ```ts
325
+ * generatePath('/docs/:slug', { slug: 'routing' });
326
+ * // Returns: '/docs/routing'
327
+ * ```
328
+ */
329
+ declare function generatePath(pattern: string, params?: RouteParams): string;
330
+ /**
331
+ * Check if current path matches a pattern
332
+ */
333
+ declare function isActive(pattern: string): boolean;
334
+ /**
335
+ * Redirect to a new URL (full page navigation)
336
+ * Use this for external redirects or when you need to break out of SPA
337
+ */
338
+ declare function redirect(url: string): never;
339
+
340
+ /**
341
+ * @flight-framework/router - Prefetch Utilities
342
+ *
343
+ * Universal prefetch system for Flight Framework.
344
+ * Works across all UI frameworks and runtimes.
345
+ *
346
+ * Features:
347
+ * - Multiple prefetch strategies (intent, render, viewport)
348
+ * - Priority-based prefetching
349
+ * - Module and data prefetching
350
+ * - IntersectionObserver for viewport detection
351
+ * - SSR-safe implementation
352
+ */
353
+
354
+ /**
355
+ * Prefetch a URL with the specified options.
356
+ *
357
+ * This is the main programmatic prefetch API for Flight Framework.
358
+ * It creates appropriate link elements for prefetching resources.
359
+ *
360
+ * @param href - The URL to prefetch
361
+ * @param options - Prefetch configuration options
362
+ *
363
+ * @example
364
+ * ```typescript
365
+ * // Basic prefetch
366
+ * prefetch('/docs');
367
+ *
368
+ * // High priority prefetch (for critical navigation paths)
369
+ * prefetch('/checkout', { priority: 'high' });
370
+ *
371
+ * // Prefetch with data loaders
372
+ * prefetch('/products', { includeData: true });
373
+ * ```
374
+ */
375
+ declare function prefetch(href: string, options?: PrefetchOptions): void;
376
+ /**
377
+ * Prefetch multiple URLs at once.
378
+ *
379
+ * @param hrefs - Array of URLs to prefetch
380
+ * @param options - Prefetch configuration options
381
+ */
382
+ declare function prefetchAll(hrefs: string[], options?: PrefetchOptions): void;
383
+ /**
384
+ * Check if a URL has been prefetched.
385
+ *
386
+ * @param href - The URL to check
387
+ * @returns True if the URL has been prefetched
388
+ */
389
+ declare function isPrefetched(href: string): boolean;
390
+ /**
391
+ * Clear all prefetch state (useful for testing or memory management).
392
+ */
393
+ declare function clearPrefetchCache(): void;
394
+ /**
395
+ * Observe an element for viewport entry and trigger prefetch.
396
+ *
397
+ * @param element - The link element to observe
398
+ * @param href - The URL to prefetch when visible
399
+ * @returns Cleanup function to stop observing
400
+ */
401
+ declare function observeForPrefetch(element: Element, href: string): () => void;
402
+ /**
403
+ * Setup intent-based prefetching for an element.
404
+ * Prefetches on mouseenter or focus.
405
+ *
406
+ * @param element - The link element
407
+ * @param href - The URL to prefetch
408
+ * @returns Cleanup function to remove listeners
409
+ */
410
+ declare function setupIntentPrefetch(element: HTMLElement, href: string): () => void;
160
411
 
161
- export { Link, type LinkProps, type NavigateOptions, type RouteParams, RouterContext, type RouterContextValue, RouterProvider, type RouterProviderProps, type SearchParams, matchRoute, navigate, parseParams, prefetch, useParams, usePathname, useRouter, useSearchParams };
412
+ export { Link, type LinkProps, type LoaderContext, type LoaderFunction, type NavigateOptions, type PrefetchOptions, PrefetchPageLinks, type PrefetchPriority, type PrefetchStrategy, type RouteDefinition, type RouteMatch, type RouteParams, RouterContext, type RouterContextValue, RouterProvider, type RouterProviderProps, type SearchParams, clearPrefetchCache, createLink, findRoute, generatePath, isActive, isPrefetched, matchRoute, navigate, observeForPrefetch, parseParams, prefetch, prefetchAll, prefetchPages, prefetchRoute, prefetchWhenIdle, redirect, setupIntentPrefetch, useLinkProps, useParams, usePathname, useRouter, useSearchParams };
package/dist/index.js CHANGED
@@ -104,26 +104,192 @@ if (typeof globalThis !== "undefined") {
104
104
  }
105
105
  }
106
106
 
107
- // src/link.ts
107
+ // src/prefetch.ts
108
108
  var isBrowser2 = typeof window !== "undefined";
109
+ var supportsIntersectionObserver = isBrowser2 && "IntersectionObserver" in window;
110
+ var prefetchedUrls = /* @__PURE__ */ new Set();
111
+ var prefetchingUrls = /* @__PURE__ */ new Set();
112
+ var viewportObservers = /* @__PURE__ */ new Map();
113
+ function prefetch(href, options = {}) {
114
+ if (!isBrowser2) return;
115
+ const {
116
+ priority = "auto",
117
+ includeModules = true,
118
+ includeData = false
119
+ } = options;
120
+ const url = normalizeUrl(href);
121
+ if (prefetchedUrls.has(url) || prefetchingUrls.has(url)) {
122
+ return;
123
+ }
124
+ prefetchingUrls.add(url);
125
+ createPrefetchLink(url, "document", priority);
126
+ if (includeModules) {
127
+ prefetchModules(url, priority);
128
+ }
129
+ if (includeData) {
130
+ prefetchData(url, priority);
131
+ }
132
+ prefetchedUrls.add(url);
133
+ prefetchingUrls.delete(url);
134
+ }
135
+ function prefetchAll(hrefs, options = {}) {
136
+ for (const href of hrefs) {
137
+ prefetch(href, options);
138
+ }
139
+ }
140
+ function isPrefetched(href) {
141
+ return prefetchedUrls.has(normalizeUrl(href));
142
+ }
143
+ function clearPrefetchCache() {
144
+ prefetchedUrls.clear();
145
+ prefetchingUrls.clear();
146
+ }
147
+ function createPrefetchLink(href, as, priority) {
148
+ if (!isBrowser2) return null;
149
+ const existing = document.querySelector(
150
+ `link[rel="prefetch"][href="${href}"], link[rel="modulepreload"][href="${href}"]`
151
+ );
152
+ if (existing) return existing;
153
+ const link = document.createElement("link");
154
+ if (as === "script") {
155
+ link.rel = "modulepreload";
156
+ } else {
157
+ link.rel = "prefetch";
158
+ link.as = as;
159
+ }
160
+ link.href = href;
161
+ if (priority !== "auto" && "fetchPriority" in link) {
162
+ link.fetchPriority = priority;
163
+ }
164
+ if (priority === "low" && "requestIdleCallback" in window) {
165
+ window.requestIdleCallback(() => {
166
+ document.head.appendChild(link);
167
+ });
168
+ } else {
169
+ document.head.appendChild(link);
170
+ }
171
+ return link;
172
+ }
173
+ function prefetchModules(href, priority) {
174
+ const manifest = window.__FLIGHT_MANIFEST__;
175
+ if (!manifest?.routes) return;
176
+ const routeModules = manifest.routes[href];
177
+ if (!routeModules) return;
178
+ for (const module of routeModules) {
179
+ createPrefetchLink(module, "script", priority);
180
+ }
181
+ }
182
+ function prefetchData(href, priority) {
183
+ const dataUrl = `/_flight/data${href === "/" ? "/index" : href}.json`;
184
+ createPrefetchLink(dataUrl, "fetch", priority);
185
+ }
186
+ var sharedObserver = null;
187
+ var observerCallbacks = /* @__PURE__ */ new Map();
188
+ function getViewportObserver() {
189
+ if (!supportsIntersectionObserver) return null;
190
+ if (!sharedObserver) {
191
+ sharedObserver = new IntersectionObserver(
192
+ (entries) => {
193
+ for (const entry of entries) {
194
+ if (entry.isIntersecting) {
195
+ const callback = observerCallbacks.get(entry.target);
196
+ if (callback) {
197
+ callback();
198
+ sharedObserver?.unobserve(entry.target);
199
+ observerCallbacks.delete(entry.target);
200
+ }
201
+ }
202
+ }
203
+ },
204
+ {
205
+ // Start prefetching when link is 25% visible or within 100px of viewport
206
+ rootMargin: "100px",
207
+ threshold: 0.25
208
+ }
209
+ );
210
+ }
211
+ return sharedObserver;
212
+ }
213
+ function observeForPrefetch(element, href) {
214
+ if (!supportsIntersectionObserver) {
215
+ return () => {
216
+ };
217
+ }
218
+ const observer = getViewportObserver();
219
+ if (!observer) return () => {
220
+ };
221
+ const callback = () => {
222
+ prefetch(href, { priority: "low" });
223
+ };
224
+ observerCallbacks.set(element, callback);
225
+ observer.observe(element);
226
+ const cleanup = () => {
227
+ observer.unobserve(element);
228
+ observerCallbacks.delete(element);
229
+ viewportObservers.delete(element);
230
+ };
231
+ viewportObservers.set(element, cleanup);
232
+ return cleanup;
233
+ }
234
+ function setupIntentPrefetch(element, href) {
235
+ if (!isBrowser2) return () => {
236
+ };
237
+ let prefetchTriggered = false;
238
+ const handleIntent = () => {
239
+ if (!prefetchTriggered) {
240
+ prefetchTriggered = true;
241
+ prefetch(href, { priority: "auto" });
242
+ }
243
+ };
244
+ element.addEventListener("mouseenter", handleIntent, { passive: true });
245
+ element.addEventListener("focus", handleIntent, { passive: true });
246
+ element.addEventListener("touchstart", handleIntent, { passive: true });
247
+ return () => {
248
+ element.removeEventListener("mouseenter", handleIntent);
249
+ element.removeEventListener("focus", handleIntent);
250
+ element.removeEventListener("touchstart", handleIntent);
251
+ };
252
+ }
253
+ function normalizeUrl(href) {
254
+ if (isBrowser2 && !href.startsWith("http")) {
255
+ try {
256
+ const url = new URL(href, window.location.origin);
257
+ return url.pathname + url.search;
258
+ } catch {
259
+ return href;
260
+ }
261
+ }
262
+ return href;
263
+ }
264
+
265
+ // src/link.ts
266
+ var isBrowser3 = typeof window !== "undefined";
109
267
  function handleLinkClick(href, options, event) {
110
268
  if (event && (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey)) {
111
269
  return;
112
270
  }
271
+ if (event && event.button !== 0) {
272
+ return;
273
+ }
113
274
  event?.preventDefault();
114
275
  const { navigate: navigate2 } = getRouterContext();
115
276
  navigate2(href, options);
116
277
  }
117
278
  function isExternalUrl(href) {
118
279
  if (!href) return false;
119
- return href.startsWith("http://") || href.startsWith("https://") || href.startsWith("//") || href.startsWith("mailto:") || href.startsWith("tel:");
280
+ return href.startsWith("http://") || href.startsWith("https://") || href.startsWith("//") || href.startsWith("mailto:") || href.startsWith("tel:") || href.startsWith("javascript:") || href.startsWith("#");
281
+ }
282
+ function normalizePrefetchStrategy(prefetchProp) {
283
+ if (prefetchProp === true) {
284
+ return "intent";
285
+ }
286
+ if (prefetchProp === false || prefetchProp === void 0) {
287
+ return "none";
288
+ }
289
+ return prefetchProp;
120
290
  }
121
291
  function prefetchRoute(href) {
122
- if (!isBrowser2) return;
123
- const link = document.createElement("link");
124
- link.rel = "prefetch";
125
- link.href = href;
126
- document.head.appendChild(link);
292
+ prefetch(href);
127
293
  }
128
294
  var Link = null;
129
295
  if (typeof globalThis !== "undefined") {
@@ -137,58 +303,196 @@ if (typeof globalThis !== "undefined") {
137
303
  className,
138
304
  target,
139
305
  rel,
140
- prefetch: prefetch2 = false,
306
+ prefetch: prefetchProp = "none",
141
307
  replace = false,
142
308
  scroll = true,
143
309
  onClick,
310
+ "aria-label": ariaLabel,
144
311
  ...props
145
312
  }) {
146
313
  const linkRef = useRef(null);
147
314
  const isExternal = isExternalUrl(href);
148
- const handleClick = useCallback((e) => {
149
- if (onClick) {
150
- onClick(e);
151
- if (e.defaultPrevented) return;
152
- }
153
- if (isExternal || target === "_blank") return;
154
- handleLinkClick(href, { replace, scroll }, e);
155
- }, [href, isExternal, target, replace, scroll, onClick]);
315
+ const prefetchStrategy = normalizePrefetchStrategy(prefetchProp);
316
+ const handleClick = useCallback(
317
+ (e) => {
318
+ if (onClick) {
319
+ onClick(e);
320
+ if (e.defaultPrevented) return;
321
+ }
322
+ if (isExternal || target === "_blank") return;
323
+ handleLinkClick(href, { replace, scroll }, e);
324
+ },
325
+ [href, isExternal, target, replace, scroll, onClick]
326
+ );
156
327
  useEffect(() => {
157
- if (!prefetch2 || isExternal || !isBrowser2) return;
328
+ if (isExternal || !isBrowser3 || prefetchStrategy === "none") {
329
+ return;
330
+ }
158
331
  const link = linkRef.current;
159
332
  if (!link) return;
160
- let prefetched = false;
161
- const doPrefetch = () => {
162
- if (!prefetched) {
163
- prefetched = true;
164
- prefetchRoute(href);
165
- }
166
- };
167
- link.addEventListener("mouseenter", doPrefetch);
168
- link.addEventListener("focus", doPrefetch);
169
- return () => {
170
- link.removeEventListener("mouseenter", doPrefetch);
171
- link.removeEventListener("focus", doPrefetch);
172
- };
173
- }, [href, prefetch2, isExternal]);
333
+ switch (prefetchStrategy) {
334
+ case "render":
335
+ prefetch(href, { priority: "low" });
336
+ break;
337
+ case "viewport":
338
+ return observeForPrefetch(link, href);
339
+ case "intent":
340
+ default:
341
+ return setupIntentPrefetch(link, href);
342
+ }
343
+ }, [href, prefetchStrategy, isExternal]);
174
344
  const computedRel = isExternal && target === "_blank" ? rel || "noopener noreferrer" : rel;
175
- return React.createElement("a", {
176
- ref: linkRef,
177
- href,
178
- className,
179
- target,
180
- rel: computedRel,
181
- onClick: handleClick,
182
- ...props
183
- }, children);
345
+ return React.createElement(
346
+ "a",
347
+ {
348
+ ref: linkRef,
349
+ href,
350
+ className,
351
+ target,
352
+ rel: computedRel,
353
+ "aria-label": ariaLabel,
354
+ onClick: handleClick,
355
+ ...props
356
+ },
357
+ children
358
+ );
184
359
  };
185
360
  }
186
361
  } catch {
187
362
  }
188
363
  }
364
+ function createLink(props) {
365
+ const {
366
+ href,
367
+ children,
368
+ className,
369
+ target,
370
+ rel,
371
+ prefetch: prefetchProp = "none",
372
+ replace = false,
373
+ scroll = true
374
+ } = props;
375
+ const anchor = document.createElement("a");
376
+ anchor.href = href;
377
+ anchor.className = className || "";
378
+ if (target) anchor.target = target;
379
+ if (rel) anchor.rel = rel;
380
+ if (typeof children === "string") {
381
+ anchor.textContent = children;
382
+ }
383
+ const isExternal = isExternalUrl(href);
384
+ const prefetchStrategy = normalizePrefetchStrategy(prefetchProp);
385
+ if (!isExternal && target !== "_blank") {
386
+ anchor.addEventListener("click", (e) => {
387
+ handleLinkClick(href, { replace, scroll }, e);
388
+ });
389
+ }
390
+ if (!isExternal && prefetchStrategy !== "none") {
391
+ switch (prefetchStrategy) {
392
+ case "render":
393
+ prefetch(href, { priority: "low" });
394
+ break;
395
+ case "viewport":
396
+ observeForPrefetch(anchor, href);
397
+ break;
398
+ case "intent":
399
+ default:
400
+ setupIntentPrefetch(anchor, href);
401
+ break;
402
+ }
403
+ }
404
+ return anchor;
405
+ }
406
+ function useLinkProps(href, options = {}) {
407
+ const { replace = false, scroll = true, prefetch: prefetchProp = "none" } = options;
408
+ const isExternal = isExternalUrl(href);
409
+ const prefetchStrategy = normalizePrefetchStrategy(prefetchProp);
410
+ const result = {
411
+ href,
412
+ onClick: (e) => {
413
+ if (!isExternal) {
414
+ handleLinkClick(href, { replace, scroll }, e);
415
+ }
416
+ }
417
+ };
418
+ if (prefetchStrategy === "intent" && !isExternal) {
419
+ let prefetched = false;
420
+ const doPrefetch = () => {
421
+ if (!prefetched) {
422
+ prefetched = true;
423
+ prefetch(href);
424
+ }
425
+ };
426
+ result.onMouseenter = doPrefetch;
427
+ result.onFocus = doPrefetch;
428
+ }
429
+ if (prefetchStrategy === "render" && !isExternal && isBrowser3) {
430
+ prefetch(href, { priority: "low" });
431
+ }
432
+ return result;
433
+ }
434
+
435
+ // src/prefetch-links.ts
436
+ var isBrowser4 = typeof window !== "undefined";
437
+ var PrefetchPageLinks = null;
438
+ if (typeof globalThis !== "undefined") {
439
+ try {
440
+ const React = globalThis.React;
441
+ if (React?.createElement && "useEffect" in React) {
442
+ const { useEffect, useState } = React;
443
+ PrefetchPageLinks = function FlightPrefetchPageLinks({
444
+ page,
445
+ options = {}
446
+ }) {
447
+ const [shouldRender, setShouldRender] = useState(false);
448
+ useEffect(() => {
449
+ if (!isBrowser4) return;
450
+ if (isPrefetched(page)) {
451
+ return;
452
+ }
453
+ prefetch(page, {
454
+ priority: "low",
455
+ includeModules: true,
456
+ ...options
457
+ });
458
+ setShouldRender(false);
459
+ }, [page, options]);
460
+ return null;
461
+ };
462
+ }
463
+ } catch {
464
+ }
465
+ }
466
+ function prefetchPages(pages, options = {}) {
467
+ if (!isBrowser4) return;
468
+ for (const page of pages) {
469
+ if (!isPrefetched(page)) {
470
+ prefetch(page, {
471
+ priority: "low",
472
+ ...options
473
+ });
474
+ }
475
+ }
476
+ }
477
+ function prefetchWhenIdle(page, options = {}) {
478
+ if (!isBrowser4) return;
479
+ const doPrefetch = () => {
480
+ if (!isPrefetched(page)) {
481
+ prefetch(page, {
482
+ priority: "low",
483
+ ...options
484
+ });
485
+ }
486
+ };
487
+ if ("requestIdleCallback" in window) {
488
+ window.requestIdleCallback(doPrefetch, { timeout: 3e3 });
489
+ } else {
490
+ setTimeout(doPrefetch, 100);
491
+ }
492
+ }
189
493
 
190
494
  // src/hooks.ts
191
- var isBrowser3 = typeof window !== "undefined";
495
+ var isBrowser5 = typeof window !== "undefined";
192
496
  var useParams = () => ({});
193
497
  var useSearchParams = () => [new URLSearchParams(), () => {
194
498
  }];
@@ -210,10 +514,10 @@ if (typeof globalThis !== "undefined") {
210
514
  };
211
515
  useSearchParams = function useFlightSearchParams() {
212
516
  const [searchParams, setSearchParamsState] = useState(
213
- () => isBrowser3 ? new URLSearchParams(window.location.search) : new URLSearchParams()
517
+ () => isBrowser5 ? new URLSearchParams(window.location.search) : new URLSearchParams()
214
518
  );
215
519
  useEffect(() => {
216
- if (!isBrowser3) return;
520
+ if (!isBrowser5) return;
217
521
  const handleChange = () => {
218
522
  setSearchParamsState(new URLSearchParams(window.location.search));
219
523
  };
@@ -221,7 +525,7 @@ if (typeof globalThis !== "undefined") {
221
525
  return () => window.removeEventListener("popstate", handleChange);
222
526
  }, []);
223
527
  const setSearchParams = useCallback((newParams) => {
224
- if (!isBrowser3) return;
528
+ if (!isBrowser5) return;
225
529
  let params;
226
530
  if (newParams instanceof URLSearchParams) {
227
531
  params = newParams;
@@ -245,7 +549,7 @@ if (typeof globalThis !== "undefined") {
245
549
  const { path } = getRouterContext();
246
550
  const [pathname, setPathname] = useState(path);
247
551
  useEffect(() => {
248
- if (!isBrowser3) return;
552
+ if (!isBrowser5) return;
249
553
  const handleChange = () => {
250
554
  setPathname(window.location.pathname);
251
555
  };
@@ -260,20 +564,11 @@ if (typeof globalThis !== "undefined") {
260
564
  }
261
565
 
262
566
  // src/navigate.ts
263
- var isBrowser4 = typeof window !== "undefined";
567
+ var isBrowser6 = typeof window !== "undefined";
264
568
  function navigate(to, options = {}) {
265
569
  const { navigate: routerNavigate } = getRouterContext();
266
570
  routerNavigate(to, options);
267
571
  }
268
- function prefetch(href) {
269
- if (!isBrowser4) return;
270
- const existing = document.querySelector(`link[rel="prefetch"][href="${href}"]`);
271
- if (existing) return;
272
- const link = document.createElement("link");
273
- link.rel = "prefetch";
274
- link.href = href;
275
- document.head.appendChild(link);
276
- }
277
572
  function patternToRegex(pattern) {
278
573
  const paramNames = [];
279
574
  let regexStr = pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/\\\[\.\.\.(\w+)\\\]/g, (_, name) => {
@@ -308,14 +603,63 @@ function parseParams(pathname, pattern) {
308
603
  const { params } = matchRoute(pathname, pattern);
309
604
  return params;
310
605
  }
606
+ function findRoute(pathname, routes) {
607
+ for (const route of routes) {
608
+ const { matched, params } = matchRoute(pathname, route.path);
609
+ if (matched) {
610
+ return {
611
+ route,
612
+ params,
613
+ pathname
614
+ };
615
+ }
616
+ }
617
+ return null;
618
+ }
619
+ function generatePath(pattern, params = {}) {
620
+ let path = pattern;
621
+ path = path.replace(/\[(\w+)\]/g, (_, name) => {
622
+ return params[name] || "";
623
+ });
624
+ path = path.replace(/:(\w+)/g, (_, name) => {
625
+ return params[name] || "";
626
+ });
627
+ return path;
628
+ }
629
+ function isActive(pattern) {
630
+ const { path } = getRouterContext();
631
+ const { matched } = matchRoute(path, pattern);
632
+ return matched;
633
+ }
634
+ function redirect(url) {
635
+ if (isBrowser6) {
636
+ window.location.href = url;
637
+ }
638
+ throw new Error(`Redirect to: ${url}`);
639
+ }
311
640
  export {
312
641
  Link,
642
+ PrefetchPageLinks,
313
643
  RouterContext,
314
644
  RouterProvider,
645
+ clearPrefetchCache,
646
+ createLink,
647
+ findRoute,
648
+ generatePath,
649
+ isActive,
650
+ isPrefetched,
315
651
  matchRoute,
316
652
  navigate,
653
+ observeForPrefetch,
317
654
  parseParams,
318
655
  prefetch,
656
+ prefetchAll,
657
+ prefetchPages,
658
+ prefetchRoute,
659
+ prefetchWhenIdle,
660
+ redirect,
661
+ setupIntentPrefetch,
662
+ useLinkProps,
319
663
  useParams,
320
664
  usePathname,
321
665
  useRouter,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flight-framework/router",
3
- "version": "0.0.1",
3
+ "version": "0.0.3",
4
4
  "description": "Agnostic client-side routing primitives for Flight Framework",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -45,4 +45,4 @@
45
45
  "optional": true
46
46
  }
47
47
  }
48
- }
48
+ }