@flight-framework/router 0.0.1

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 ADDED
@@ -0,0 +1,144 @@
1
+ # @flight-framework/router
2
+
3
+ Agnostic client-side routing primitives for Flight Framework.
4
+
5
+ ## Philosophy
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
11
+
12
+ ## Installation
13
+
14
+ ```bash
15
+ npm install @flight-framework/router
16
+ ```
17
+
18
+ ## Usage
19
+
20
+ ### With React
21
+
22
+ ```tsx
23
+ import { RouterProvider, Link, useRouter } from '@flight-framework/router';
24
+
25
+ // Wrap your app
26
+ function App() {
27
+ return (
28
+ <RouterProvider initialPath={url}>
29
+ <Navigation />
30
+ <Content />
31
+ </RouterProvider>
32
+ );
33
+ }
34
+
35
+ // Use Link for navigation
36
+ function Navigation() {
37
+ return (
38
+ <nav>
39
+ <Link href="/">Home</Link>
40
+ <Link href="/docs" prefetch>Docs</Link>
41
+ <Link href="/about">About</Link>
42
+ </nav>
43
+ );
44
+ }
45
+
46
+ // Use hooks to access router state
47
+ function Content() {
48
+ const { path, navigate } = useRouter();
49
+
50
+ return (
51
+ <main>
52
+ <p>Current path: {path}</p>
53
+ <button onClick={() => navigate('/dashboard')}>
54
+ Go to Dashboard
55
+ </button>
56
+ </main>
57
+ );
58
+ }
59
+ ```
60
+
61
+ ### Hooks
62
+
63
+ ```tsx
64
+ // Get current path and navigation functions
65
+ const { path, searchParams, navigate, back, forward } = useRouter();
66
+
67
+ // Get route parameters (from dynamic routes like [slug])
68
+ const { slug } = useParams<{ slug: string }>();
69
+
70
+ // Get and set search params
71
+ const [searchParams, setSearchParams] = useSearchParams();
72
+
73
+ // Get current pathname
74
+ const pathname = usePathname();
75
+ ```
76
+
77
+ ### Programmatic Navigation
78
+
79
+ ```ts
80
+ import { navigate, prefetch } from '@flight-framework/router';
81
+
82
+ // Navigate to a path
83
+ navigate('/docs');
84
+
85
+ // Replace history instead of push
86
+ navigate('/login', { replace: true });
87
+
88
+ // Navigate without scrolling to top
89
+ navigate('/next-page', { scroll: false });
90
+
91
+ // Pass state data
92
+ navigate('/dashboard', { state: { from: '/login' } });
93
+
94
+ // Prefetch a route
95
+ prefetch('/heavy-page');
96
+ ```
97
+
98
+ ### Route Matching
99
+
100
+ ```ts
101
+ import { matchRoute, parseParams, generatePath } from '@flight-framework/router';
102
+
103
+ // Check if a path matches a pattern
104
+ const { matched, params } = matchRoute('/docs/routing', '/docs/:slug');
105
+ // matched: true, params: { slug: 'routing' }
106
+
107
+ // Parse params from a path
108
+ const params = parseParams('/blog/2024/my-post', '/blog/:year/:slug');
109
+ // { year: '2024', slug: 'my-post' }
110
+
111
+ // Generate a path from pattern and params
112
+ const path = generatePath('/docs/:slug', { slug: 'api-routes' });
113
+ // '/docs/api-routes'
114
+ ```
115
+
116
+ ### Link Props
117
+
118
+ | Prop | Type | Default | Description |
119
+ |------|------|---------|-------------|
120
+ | `href` | `string` | required | Target URL path |
121
+ | `prefetch` | `boolean` | `false` | Prefetch target on hover |
122
+ | `replace` | `boolean` | `false` | Replace history entry |
123
+ | `scroll` | `boolean` | `true` | Scroll to top on navigate |
124
+ | `target` | `string` | - | Link target (_blank, etc) |
125
+ | `className` | `string` | - | CSS class |
126
+
127
+ ## SSR Support
128
+
129
+ The router is SSR-safe. Pass the initial path from your server:
130
+
131
+ ```tsx
132
+ // entry-server.tsx
133
+ export function render(url: string) {
134
+ return renderToString(
135
+ <RouterProvider initialPath={url}>
136
+ <App />
137
+ </RouterProvider>
138
+ );
139
+ }
140
+ ```
141
+
142
+ ## License
143
+
144
+ MIT
@@ -0,0 +1,161 @@
1
+ /**
2
+ * Type definitions for @flight-framework/router
3
+ */
4
+ /**
5
+ * Router context value provided to all components
6
+ */
7
+ interface RouterContextValue {
8
+ /** Current pathname (e.g., '/docs/routing') */
9
+ path: string;
10
+ /** URL search params as object */
11
+ searchParams: URLSearchParams;
12
+ /** Navigate to a new path */
13
+ navigate: (to: string, options?: NavigateOptions) => void;
14
+ /** Go back in history */
15
+ back: () => void;
16
+ /** Go forward in history */
17
+ forward: () => void;
18
+ }
19
+ /**
20
+ * Props for RouterProvider component
21
+ */
22
+ interface RouterProviderProps {
23
+ /** Child components */
24
+ children: unknown;
25
+ /** Initial path for SSR (server passes request URL) */
26
+ initialPath?: string;
27
+ /** Base path for the router (e.g., '/app') */
28
+ basePath?: string;
29
+ }
30
+ /**
31
+ * Props for Link component
32
+ */
33
+ interface LinkProps {
34
+ /** Target URL path */
35
+ href: string;
36
+ /** Child content */
37
+ children: unknown;
38
+ /** CSS class name */
39
+ className?: string;
40
+ /** Open in new tab */
41
+ target?: string;
42
+ /** Link relationship */
43
+ rel?: string;
44
+ /** Prefetch the target page */
45
+ prefetch?: boolean;
46
+ /** Replace current history entry instead of pushing */
47
+ replace?: boolean;
48
+ /** Scroll to top after navigation */
49
+ scroll?: boolean;
50
+ /** Aria label for accessibility */
51
+ 'aria-label'?: string;
52
+ /** Click handler */
53
+ onClick?: (event: MouseEvent) => void;
54
+ }
55
+ /**
56
+ * Options for programmatic navigation
57
+ */
58
+ interface NavigateOptions {
59
+ /** Replace current history entry instead of pushing */
60
+ replace?: boolean;
61
+ /** Scroll to top after navigation */
62
+ scroll?: boolean;
63
+ /** State data to pass to the new route */
64
+ state?: unknown;
65
+ }
66
+ /**
67
+ * Dynamic route parameters extracted from URL
68
+ */
69
+ type RouteParams<T extends string = string> = Record<T, string>;
70
+ /**
71
+ * Search params as key-value pairs
72
+ */
73
+ type SearchParams = Record<string, string | string[]>;
74
+
75
+ /**
76
+ * Router Context and Provider
77
+ *
78
+ * Provides routing state to the component tree.
79
+ * SSR-safe: works on both server and client.
80
+ */
81
+
82
+ /**
83
+ * React Context for router
84
+ * Only used if React is available
85
+ */
86
+ declare let RouterContext: unknown;
87
+ declare let RouterProvider: unknown;
88
+ declare let useRouter: () => RouterContextValue;
89
+
90
+ /**
91
+ * Link Component
92
+ *
93
+ * Client-side navigation link with prefetching support.
94
+ * Works with React or as a vanilla function.
95
+ */
96
+
97
+ declare let Link: unknown;
98
+
99
+ /**
100
+ * Router Hooks
101
+ *
102
+ * React hooks for accessing router state.
103
+ * These require React to be available.
104
+ */
105
+
106
+ declare let useParams: <T extends RouteParams = RouteParams>() => T;
107
+ declare let useSearchParams: () => [URLSearchParams, (params: SearchParams | URLSearchParams) => void];
108
+ declare let usePathname: () => string;
109
+
110
+ /**
111
+ * Navigation Utilities
112
+ *
113
+ * Programmatic navigation and route matching utilities.
114
+ */
115
+
116
+ /**
117
+ * Navigate programmatically to a new path
118
+ *
119
+ * @example
120
+ * ```ts
121
+ * navigate('/docs');
122
+ * navigate('/login', { replace: true });
123
+ * navigate('/dashboard', { scroll: false, state: { from: '/home' } });
124
+ * ```
125
+ */
126
+ 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
+ /**
138
+ * Match a pathname against a route pattern
139
+ *
140
+ * @example
141
+ * ```ts
142
+ * matchRoute('/docs/routing', '/docs/:slug');
143
+ * // Returns: { params: { slug: 'routing' }, matched: true }
144
+ * ```
145
+ */
146
+ declare function matchRoute(pathname: string, pattern: string): {
147
+ matched: boolean;
148
+ params: RouteParams;
149
+ };
150
+ /**
151
+ * Parse parameters from a matched route
152
+ *
153
+ * @example
154
+ * ```ts
155
+ * parseParams('/blog/2024/my-post', '/blog/:year/:slug');
156
+ * // Returns: { year: '2024', slug: 'my-post' }
157
+ * ```
158
+ */
159
+ declare function parseParams(pathname: string, pattern: string): RouteParams;
160
+
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 };
package/dist/index.js ADDED
@@ -0,0 +1,323 @@
1
+ // src/context.ts
2
+ var isBrowser = typeof window !== "undefined";
3
+ var currentContext = {
4
+ path: "/",
5
+ searchParams: new URLSearchParams(),
6
+ navigate: () => {
7
+ },
8
+ back: () => {
9
+ },
10
+ forward: () => {
11
+ }
12
+ };
13
+ var subscribers = /* @__PURE__ */ new Set();
14
+ function subscribe(callback) {
15
+ subscribers.add(callback);
16
+ return () => subscribers.delete(callback);
17
+ }
18
+ function getRouterContext() {
19
+ return currentContext;
20
+ }
21
+ function updateContext(updates) {
22
+ currentContext = { ...currentContext, ...updates };
23
+ subscribers.forEach((cb) => cb(currentContext));
24
+ }
25
+ function navigateTo(to, options = {}) {
26
+ if (!isBrowser) return;
27
+ const { replace = false, scroll = true, state } = options;
28
+ if (replace) {
29
+ window.history.replaceState(state ?? null, "", to);
30
+ } else {
31
+ window.history.pushState(state ?? null, "", to);
32
+ }
33
+ const url = new URL(to, window.location.origin);
34
+ updateContext({
35
+ path: url.pathname,
36
+ searchParams: url.searchParams
37
+ });
38
+ if (scroll) {
39
+ window.scrollTo({ top: 0, left: 0, behavior: "instant" });
40
+ }
41
+ }
42
+ var RouterContext = null;
43
+ var RouterProvider = null;
44
+ var useRouter = getRouterContext;
45
+ if (typeof globalThis !== "undefined") {
46
+ try {
47
+ const React = globalThis.React;
48
+ if (React?.createContext) {
49
+ const { createContext, useState, useEffect, useContext } = React;
50
+ const ReactRouterContext = createContext(currentContext);
51
+ RouterContext = ReactRouterContext;
52
+ RouterProvider = function FlightRouterProvider({
53
+ children,
54
+ initialPath,
55
+ basePath = ""
56
+ }) {
57
+ const [routerState, setRouterState] = useState(() => {
58
+ const path = isBrowser ? window.location.pathname : initialPath || "/";
59
+ const searchParams = isBrowser ? new URLSearchParams(window.location.search) : new URLSearchParams();
60
+ return {
61
+ path: basePath && path.startsWith(basePath) ? path.slice(basePath.length) || "/" : path,
62
+ searchParams,
63
+ navigate: navigateTo,
64
+ back: () => isBrowser && window.history.back(),
65
+ forward: () => isBrowser && window.history.forward()
66
+ };
67
+ });
68
+ useEffect(() => {
69
+ if (!isBrowser) return;
70
+ const handlePopState = () => {
71
+ let path = window.location.pathname;
72
+ if (basePath && path.startsWith(basePath)) {
73
+ path = path.slice(basePath.length) || "/";
74
+ }
75
+ setRouterState((prev) => ({
76
+ ...prev,
77
+ path,
78
+ searchParams: new URLSearchParams(window.location.search)
79
+ }));
80
+ };
81
+ window.addEventListener("popstate", handlePopState);
82
+ return () => window.removeEventListener("popstate", handlePopState);
83
+ }, [basePath]);
84
+ useEffect(() => {
85
+ return subscribe((ctx) => {
86
+ setRouterState((prev) => ({
87
+ ...prev,
88
+ path: ctx.path,
89
+ searchParams: ctx.searchParams
90
+ }));
91
+ });
92
+ }, []);
93
+ return React.createElement(
94
+ ReactRouterContext.Provider,
95
+ { value: routerState },
96
+ children
97
+ );
98
+ };
99
+ useRouter = function useFlightRouter() {
100
+ return useContext(ReactRouterContext);
101
+ };
102
+ }
103
+ } catch {
104
+ }
105
+ }
106
+
107
+ // src/link.ts
108
+ var isBrowser2 = typeof window !== "undefined";
109
+ function handleLinkClick(href, options, event) {
110
+ if (event && (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey)) {
111
+ return;
112
+ }
113
+ event?.preventDefault();
114
+ const { navigate: navigate2 } = getRouterContext();
115
+ navigate2(href, options);
116
+ }
117
+ function isExternalUrl(href) {
118
+ if (!href) return false;
119
+ return href.startsWith("http://") || href.startsWith("https://") || href.startsWith("//") || href.startsWith("mailto:") || href.startsWith("tel:");
120
+ }
121
+ 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);
127
+ }
128
+ var Link = null;
129
+ if (typeof globalThis !== "undefined") {
130
+ try {
131
+ const React = globalThis.React;
132
+ if (React?.createElement) {
133
+ const { useCallback, useEffect, useRef } = React;
134
+ Link = function FlightLink({
135
+ href,
136
+ children,
137
+ className,
138
+ target,
139
+ rel,
140
+ prefetch: prefetch2 = false,
141
+ replace = false,
142
+ scroll = true,
143
+ onClick,
144
+ ...props
145
+ }) {
146
+ const linkRef = useRef(null);
147
+ 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]);
156
+ useEffect(() => {
157
+ if (!prefetch2 || isExternal || !isBrowser2) return;
158
+ const link = linkRef.current;
159
+ 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]);
174
+ 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);
184
+ };
185
+ }
186
+ } catch {
187
+ }
188
+ }
189
+
190
+ // src/hooks.ts
191
+ var isBrowser3 = typeof window !== "undefined";
192
+ var useParams = () => ({});
193
+ var useSearchParams = () => [new URLSearchParams(), () => {
194
+ }];
195
+ var usePathname = () => "/";
196
+ if (typeof globalThis !== "undefined") {
197
+ try {
198
+ const React = globalThis.React;
199
+ if (React?.useState) {
200
+ const { useState, useEffect, useCallback, useMemo } = React;
201
+ useParams = function useFlightParams() {
202
+ const [params, setParams] = useState({});
203
+ useEffect(() => {
204
+ const storedParams = window.__FLIGHT_PARAMS__;
205
+ if (storedParams) {
206
+ setParams(storedParams);
207
+ }
208
+ }, []);
209
+ return params;
210
+ };
211
+ useSearchParams = function useFlightSearchParams() {
212
+ const [searchParams, setSearchParamsState] = useState(
213
+ () => isBrowser3 ? new URLSearchParams(window.location.search) : new URLSearchParams()
214
+ );
215
+ useEffect(() => {
216
+ if (!isBrowser3) return;
217
+ const handleChange = () => {
218
+ setSearchParamsState(new URLSearchParams(window.location.search));
219
+ };
220
+ window.addEventListener("popstate", handleChange);
221
+ return () => window.removeEventListener("popstate", handleChange);
222
+ }, []);
223
+ const setSearchParams = useCallback((newParams) => {
224
+ if (!isBrowser3) return;
225
+ let params;
226
+ if (newParams instanceof URLSearchParams) {
227
+ params = newParams;
228
+ } else {
229
+ params = new URLSearchParams();
230
+ Object.entries(newParams).forEach(([key, value]) => {
231
+ if (Array.isArray(value)) {
232
+ value.forEach((v) => params.append(key, v));
233
+ } else {
234
+ params.set(key, value);
235
+ }
236
+ });
237
+ }
238
+ const newUrl = `${window.location.pathname}?${params.toString()}`;
239
+ window.history.pushState(null, "", newUrl);
240
+ setSearchParamsState(params);
241
+ }, []);
242
+ return [searchParams, setSearchParams];
243
+ };
244
+ usePathname = function useFlightPathname() {
245
+ const { path } = getRouterContext();
246
+ const [pathname, setPathname] = useState(path);
247
+ useEffect(() => {
248
+ if (!isBrowser3) return;
249
+ const handleChange = () => {
250
+ setPathname(window.location.pathname);
251
+ };
252
+ window.addEventListener("popstate", handleChange);
253
+ return () => window.removeEventListener("popstate", handleChange);
254
+ }, []);
255
+ return pathname;
256
+ };
257
+ }
258
+ } catch {
259
+ }
260
+ }
261
+
262
+ // src/navigate.ts
263
+ var isBrowser4 = typeof window !== "undefined";
264
+ function navigate(to, options = {}) {
265
+ const { navigate: routerNavigate } = getRouterContext();
266
+ routerNavigate(to, options);
267
+ }
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
+ function patternToRegex(pattern) {
278
+ const paramNames = [];
279
+ let regexStr = pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/\\\[\.\.\.(\w+)\\\]/g, (_, name) => {
280
+ paramNames.push(name);
281
+ return "(.+)";
282
+ }).replace(/\\\[(\w+)\\\]/g, (_, name) => {
283
+ paramNames.push(name);
284
+ return "([^/]+)";
285
+ }).replace(/:(\w+)/g, (_, name) => {
286
+ paramNames.push(name);
287
+ return "([^/]+)";
288
+ });
289
+ regexStr = `^${regexStr}$`;
290
+ return {
291
+ regex: new RegExp(regexStr),
292
+ paramNames
293
+ };
294
+ }
295
+ function matchRoute(pathname, pattern) {
296
+ const { regex, paramNames } = patternToRegex(pattern);
297
+ const match = pathname.match(regex);
298
+ if (!match) {
299
+ return { matched: false, params: {} };
300
+ }
301
+ const params = {};
302
+ paramNames.forEach((name, index) => {
303
+ params[name] = match[index + 1] || "";
304
+ });
305
+ return { matched: true, params };
306
+ }
307
+ function parseParams(pathname, pattern) {
308
+ const { params } = matchRoute(pathname, pattern);
309
+ return params;
310
+ }
311
+ export {
312
+ Link,
313
+ RouterContext,
314
+ RouterProvider,
315
+ matchRoute,
316
+ navigate,
317
+ parseParams,
318
+ prefetch,
319
+ useParams,
320
+ usePathname,
321
+ useRouter,
322
+ useSearchParams
323
+ };
package/package.json ADDED
@@ -0,0 +1,48 @@
1
+ {
2
+ "name": "@flight-framework/router",
3
+ "version": "0.0.1",
4
+ "description": "Agnostic client-side routing primitives for Flight Framework",
5
+ "type": "module",
6
+ "main": "./dist/index.js",
7
+ "module": "./dist/index.js",
8
+ "types": "./dist/index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "types": "./dist/index.d.ts",
12
+ "import": "./dist/index.js"
13
+ }
14
+ },
15
+ "files": [
16
+ "dist"
17
+ ],
18
+ "scripts": {
19
+ "build": "tsup src/index.ts --format esm --dts --clean",
20
+ "dev": "tsup src/index.ts --format esm --dts --watch"
21
+ },
22
+ "keywords": [
23
+ "flight",
24
+ "router",
25
+ "spa",
26
+ "navigation",
27
+ "ssr"
28
+ ],
29
+ "author": "Flight Framework",
30
+ "license": "MIT",
31
+ "repository": {
32
+ "type": "git",
33
+ "url": "https://github.com/EliosLT/Flight-framework",
34
+ "directory": "packages/router"
35
+ },
36
+ "devDependencies": {
37
+ "tsup": "^8.0.0",
38
+ "typescript": "^5.3.0"
39
+ },
40
+ "peerDependencies": {
41
+ "react": ">=18.0.0"
42
+ },
43
+ "peerDependenciesMeta": {
44
+ "react": {
45
+ "optional": true
46
+ }
47
+ }
48
+ }