alabjs 0.1.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.
- package/dist/adapters/cloudflare.d.ts +31 -0
- package/dist/adapters/cloudflare.d.ts.map +1 -0
- package/dist/adapters/cloudflare.js +30 -0
- package/dist/adapters/cloudflare.js.map +1 -0
- package/dist/adapters/deno.d.ts +22 -0
- package/dist/adapters/deno.d.ts.map +1 -0
- package/dist/adapters/deno.js +21 -0
- package/dist/adapters/deno.js.map +1 -0
- package/dist/adapters/web.d.ts +47 -0
- package/dist/adapters/web.d.ts.map +1 -0
- package/dist/adapters/web.js +212 -0
- package/dist/adapters/web.js.map +1 -0
- package/dist/cli.d.ts +11 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +61 -0
- package/dist/cli.js.map +1 -0
- package/dist/client/hooks.d.ts +119 -0
- package/dist/client/hooks.d.ts.map +1 -0
- package/dist/client/hooks.js +220 -0
- package/dist/client/hooks.js.map +1 -0
- package/dist/client/hooks.test.d.ts +2 -0
- package/dist/client/hooks.test.d.ts.map +1 -0
- package/dist/client/hooks.test.js +45 -0
- package/dist/client/hooks.test.js.map +1 -0
- package/dist/client/index.d.ts +6 -0
- package/dist/client/index.d.ts.map +1 -0
- package/dist/client/index.js +4 -0
- package/dist/client/index.js.map +1 -0
- package/dist/client/offline.d.ts +52 -0
- package/dist/client/offline.d.ts.map +1 -0
- package/dist/client/offline.js +90 -0
- package/dist/client/offline.js.map +1 -0
- package/dist/client/provider.d.ts +12 -0
- package/dist/client/provider.d.ts.map +1 -0
- package/dist/client/provider.js +10 -0
- package/dist/client/provider.js.map +1 -0
- package/dist/commands/build.d.ts +18 -0
- package/dist/commands/build.d.ts.map +1 -0
- package/dist/commands/build.js +173 -0
- package/dist/commands/build.js.map +1 -0
- package/dist/commands/dev.d.ts +8 -0
- package/dist/commands/dev.d.ts.map +1 -0
- package/dist/commands/dev.js +447 -0
- package/dist/commands/dev.js.map +1 -0
- package/dist/commands/info.d.ts +6 -0
- package/dist/commands/info.d.ts.map +1 -0
- package/dist/commands/info.js +92 -0
- package/dist/commands/info.js.map +1 -0
- package/dist/commands/ssg.d.ts +8 -0
- package/dist/commands/ssg.d.ts.map +1 -0
- package/dist/commands/ssg.js +124 -0
- package/dist/commands/ssg.js.map +1 -0
- package/dist/commands/start.d.ts +7 -0
- package/dist/commands/start.d.ts.map +1 -0
- package/dist/commands/start.js +26 -0
- package/dist/commands/start.js.map +1 -0
- package/dist/commands/test.d.ts +24 -0
- package/dist/commands/test.d.ts.map +1 -0
- package/dist/commands/test.js +87 -0
- package/dist/commands/test.js.map +1 -0
- package/dist/components/ErrorBoundary.d.ts +38 -0
- package/dist/components/ErrorBoundary.d.ts.map +1 -0
- package/dist/components/ErrorBoundary.js +46 -0
- package/dist/components/ErrorBoundary.js.map +1 -0
- package/dist/components/Font.d.ts +57 -0
- package/dist/components/Font.d.ts.map +1 -0
- package/dist/components/Font.js +33 -0
- package/dist/components/Font.js.map +1 -0
- package/dist/components/Image.d.ts +74 -0
- package/dist/components/Image.d.ts.map +1 -0
- package/dist/components/Image.js +85 -0
- package/dist/components/Image.js.map +1 -0
- package/dist/components/Link.d.ts +23 -0
- package/dist/components/Link.d.ts.map +1 -0
- package/dist/components/Link.js +48 -0
- package/dist/components/Link.js.map +1 -0
- package/dist/components/Script.d.ts +37 -0
- package/dist/components/Script.d.ts.map +1 -0
- package/dist/components/Script.js +70 -0
- package/dist/components/Script.js.map +1 -0
- package/dist/components/index.d.ts +10 -0
- package/dist/components/index.d.ts.map +1 -0
- package/dist/components/index.js +6 -0
- package/dist/components/index.js.map +1 -0
- package/dist/i18n/i18n.test.d.ts +2 -0
- package/dist/i18n/i18n.test.d.ts.map +1 -0
- package/dist/i18n/i18n.test.js +132 -0
- package/dist/i18n/i18n.test.js.map +1 -0
- package/dist/i18n/index.d.ts +135 -0
- package/dist/i18n/index.d.ts.map +1 -0
- package/dist/i18n/index.js +189 -0
- package/dist/i18n/index.js.map +1 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -0
- package/dist/router/code-router.d.ts +204 -0
- package/dist/router/code-router.d.ts.map +1 -0
- package/dist/router/code-router.js +258 -0
- package/dist/router/code-router.js.map +1 -0
- package/dist/router/code-router.test.d.ts +2 -0
- package/dist/router/code-router.test.d.ts.map +1 -0
- package/dist/router/code-router.test.js +128 -0
- package/dist/router/code-router.test.js.map +1 -0
- package/dist/router/index.d.ts +4 -0
- package/dist/router/index.d.ts.map +1 -0
- package/dist/router/index.js +2 -0
- package/dist/router/index.js.map +1 -0
- package/dist/router/manifest.d.ts +12 -0
- package/dist/router/manifest.d.ts.map +1 -0
- package/dist/router/manifest.js +2 -0
- package/dist/router/manifest.js.map +1 -0
- package/dist/server/app.d.ts +13 -0
- package/dist/server/app.d.ts.map +1 -0
- package/dist/server/app.js +407 -0
- package/dist/server/app.js.map +1 -0
- package/dist/server/cache.d.ts +99 -0
- package/dist/server/cache.d.ts.map +1 -0
- package/dist/server/cache.js +161 -0
- package/dist/server/cache.js.map +1 -0
- package/dist/server/cache.test.d.ts +2 -0
- package/dist/server/cache.test.d.ts.map +1 -0
- package/dist/server/cache.test.js +150 -0
- package/dist/server/cache.test.js.map +1 -0
- package/dist/server/csrf.d.ts +28 -0
- package/dist/server/csrf.d.ts.map +1 -0
- package/dist/server/csrf.js +66 -0
- package/dist/server/csrf.js.map +1 -0
- package/dist/server/csrf.test.d.ts +2 -0
- package/dist/server/csrf.test.d.ts.map +1 -0
- package/dist/server/csrf.test.js +154 -0
- package/dist/server/csrf.test.js.map +1 -0
- package/dist/server/image.d.ts +18 -0
- package/dist/server/image.d.ts.map +1 -0
- package/dist/server/image.js +97 -0
- package/dist/server/image.js.map +1 -0
- package/dist/server/index.d.ts +57 -0
- package/dist/server/index.d.ts.map +1 -0
- package/dist/server/index.js +58 -0
- package/dist/server/index.js.map +1 -0
- package/dist/server/middleware.d.ts +53 -0
- package/dist/server/middleware.d.ts.map +1 -0
- package/dist/server/middleware.js +80 -0
- package/dist/server/middleware.js.map +1 -0
- package/dist/server/middleware.test.d.ts +2 -0
- package/dist/server/middleware.test.d.ts.map +1 -0
- package/dist/server/middleware.test.js +125 -0
- package/dist/server/middleware.test.js.map +1 -0
- package/dist/server/revalidate.d.ts +49 -0
- package/dist/server/revalidate.d.ts.map +1 -0
- package/dist/server/revalidate.js +62 -0
- package/dist/server/revalidate.js.map +1 -0
- package/dist/server/revalidate.test.d.ts +2 -0
- package/dist/server/revalidate.test.d.ts.map +1 -0
- package/dist/server/revalidate.test.js +93 -0
- package/dist/server/revalidate.test.js.map +1 -0
- package/dist/server/server-fn.test.d.ts +2 -0
- package/dist/server/server-fn.test.d.ts.map +1 -0
- package/dist/server/server-fn.test.js +105 -0
- package/dist/server/server-fn.test.js.map +1 -0
- package/dist/server/sitemap.d.ts +9 -0
- package/dist/server/sitemap.d.ts.map +1 -0
- package/dist/server/sitemap.js +26 -0
- package/dist/server/sitemap.js.map +1 -0
- package/dist/server/sitemap.test.d.ts +2 -0
- package/dist/server/sitemap.test.d.ts.map +1 -0
- package/dist/server/sitemap.test.js +61 -0
- package/dist/server/sitemap.test.js.map +1 -0
- package/dist/server/sse.d.ts +59 -0
- package/dist/server/sse.d.ts.map +1 -0
- package/dist/server/sse.js +91 -0
- package/dist/server/sse.js.map +1 -0
- package/dist/server/sse.test.d.ts +2 -0
- package/dist/server/sse.test.d.ts.map +1 -0
- package/dist/server/sse.test.js +68 -0
- package/dist/server/sse.test.js.map +1 -0
- package/dist/signals/index.d.ts +101 -0
- package/dist/signals/index.d.ts.map +1 -0
- package/dist/signals/index.js +149 -0
- package/dist/signals/index.js.map +1 -0
- package/dist/signals/signals.test.d.ts +2 -0
- package/dist/signals/signals.test.d.ts.map +1 -0
- package/dist/signals/signals.test.js +146 -0
- package/dist/signals/signals.test.js.map +1 -0
- package/dist/ssr/html.d.ts +27 -0
- package/dist/ssr/html.d.ts.map +1 -0
- package/dist/ssr/html.js +107 -0
- package/dist/ssr/html.js.map +1 -0
- package/dist/ssr/html.test.d.ts +2 -0
- package/dist/ssr/html.test.d.ts.map +1 -0
- package/dist/ssr/html.test.js +178 -0
- package/dist/ssr/html.test.js.map +1 -0
- package/dist/ssr/render.d.ts +46 -0
- package/dist/ssr/render.d.ts.map +1 -0
- package/dist/ssr/render.js +87 -0
- package/dist/ssr/render.js.map +1 -0
- package/dist/ssr/router-dev.d.ts +60 -0
- package/dist/ssr/router-dev.d.ts.map +1 -0
- package/dist/ssr/router-dev.js +205 -0
- package/dist/ssr/router-dev.js.map +1 -0
- package/dist/ssr/router-dev.test.d.ts +2 -0
- package/dist/ssr/router-dev.test.d.ts.map +1 -0
- package/dist/ssr/router-dev.test.js +189 -0
- package/dist/ssr/router-dev.test.js.map +1 -0
- package/dist/test/index.d.ts +93 -0
- package/dist/test/index.d.ts.map +1 -0
- package/dist/test/index.js +146 -0
- package/dist/test/index.js.map +1 -0
- package/dist/types/index.d.ts +117 -0
- package/dist/types/index.d.ts.map +1 -0
- package/dist/types/index.js +2 -0
- package/dist/types/index.js.map +1 -0
- package/dist/types/napi.d.ts +15 -0
- package/dist/types/napi.d.ts.map +1 -0
- package/dist/types/napi.js +2 -0
- package/dist/types/napi.js.map +1 -0
- package/package.json +107 -0
- package/src/adapters/cloudflare.ts +30 -0
- package/src/adapters/deno.ts +21 -0
- package/src/adapters/web.ts +259 -0
- package/src/cli.ts +68 -0
- package/src/client/hooks.test.ts +54 -0
- package/src/client/hooks.ts +329 -0
- package/src/client/index.ts +5 -0
- package/src/client/offline-sw.ts +191 -0
- package/src/client/offline.ts +114 -0
- package/src/client/provider.tsx +14 -0
- package/src/commands/build.ts +201 -0
- package/src/commands/dev.ts +509 -0
- package/src/commands/info.ts +111 -0
- package/src/commands/ssg.ts +177 -0
- package/src/commands/start.ts +32 -0
- package/src/commands/test.ts +102 -0
- package/src/components/ErrorBoundary.tsx +73 -0
- package/src/components/Font.tsx +100 -0
- package/src/components/Image.tsx +141 -0
- package/src/components/Link.tsx +64 -0
- package/src/components/Script.tsx +97 -0
- package/src/components/index.ts +9 -0
- package/src/i18n/i18n.test.tsx +169 -0
- package/src/i18n/index.tsx +256 -0
- package/src/index.ts +10 -0
- package/src/router/code-router.test.ts +146 -0
- package/src/router/code-router.tsx +459 -0
- package/src/router/index.ts +18 -0
- package/src/router/manifest.ts +13 -0
- package/src/server/app.ts +466 -0
- package/src/server/cache.test.ts +192 -0
- package/src/server/cache.ts +195 -0
- package/src/server/csrf.test.ts +199 -0
- package/src/server/csrf.ts +80 -0
- package/src/server/image.ts +112 -0
- package/src/server/index.ts +144 -0
- package/src/server/middleware.test.ts +151 -0
- package/src/server/middleware.ts +95 -0
- package/src/server/revalidate.test.ts +106 -0
- package/src/server/revalidate.ts +75 -0
- package/src/server/server-fn.test.ts +127 -0
- package/src/server/sitemap.test.ts +68 -0
- package/src/server/sitemap.ts +30 -0
- package/src/server/sse.test.ts +81 -0
- package/src/server/sse.ts +110 -0
- package/src/signals/index.ts +177 -0
- package/src/signals/signals.test.ts +164 -0
- package/src/ssr/html.test.ts +200 -0
- package/src/ssr/html.ts +140 -0
- package/src/ssr/render.ts +144 -0
- package/src/ssr/router-dev.test.ts +230 -0
- package/src/ssr/router-dev.ts +229 -0
- package/src/test/index.ts +206 -0
- package/src/types/compiler.d.ts +25 -0
- package/src/types/index.ts +147 -0
- package/src/types/napi.ts +20 -0
- package/src/types/plugins.d.ts +3 -0
- package/tsconfig.json +11 -0
- package/tsconfig.tsbuildinfo +1 -0
- package/vitest.config.ts +32 -0
|
@@ -0,0 +1,459 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Alab Code-Based Router — type-safe client-side navigation.
|
|
3
|
+
*
|
|
4
|
+
* The file-system router stays the default (zero config). This module is
|
|
5
|
+
* opt-in for large apps that need IDE searchability, typed `href` props,
|
|
6
|
+
* search param schemas, and co-located loaders.
|
|
7
|
+
*
|
|
8
|
+
* Inspired by TanStack Router.
|
|
9
|
+
*
|
|
10
|
+
* @example
|
|
11
|
+
* ```ts
|
|
12
|
+
* // routes.ts
|
|
13
|
+
* import { createRoute, createRouter } from "alabjs/router";
|
|
14
|
+
* import { z } from "zod";
|
|
15
|
+
* import UserPage from "./app/users/[id]/page.js";
|
|
16
|
+
* import UserError from "./app/users/[id]/error.js";
|
|
17
|
+
*
|
|
18
|
+
* export const userRoute = createRoute({
|
|
19
|
+
* path: "/users/$id",
|
|
20
|
+
* search: z.object({ tab: z.enum(["posts", "about"]).optional() }),
|
|
21
|
+
* loader: ({ params }) => getUser(params.id),
|
|
22
|
+
* component: UserPage,
|
|
23
|
+
* errorComponent: UserError,
|
|
24
|
+
* });
|
|
25
|
+
*
|
|
26
|
+
* export const router = createRouter([userRoute]);
|
|
27
|
+
* ```
|
|
28
|
+
*
|
|
29
|
+
* ```tsx
|
|
30
|
+
* // app/layout.tsx
|
|
31
|
+
* import { RouterProvider } from "alabjs/router";
|
|
32
|
+
* import { router } from "../routes.js";
|
|
33
|
+
*
|
|
34
|
+
* export default function RootLayout({ children }) {
|
|
35
|
+
* return <RouterProvider router={router}>{children}</RouterProvider>;
|
|
36
|
+
* }
|
|
37
|
+
* ```
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
import {
|
|
41
|
+
createContext,
|
|
42
|
+
useContext,
|
|
43
|
+
useState,
|
|
44
|
+
useEffect,
|
|
45
|
+
useCallback,
|
|
46
|
+
type ComponentType,
|
|
47
|
+
type ReactNode,
|
|
48
|
+
} from "react";
|
|
49
|
+
|
|
50
|
+
// ─── Path param extraction (compile-time) ─────────────────────────────────────
|
|
51
|
+
|
|
52
|
+
/** Extract `$param` names from a path string. */
|
|
53
|
+
type ExtractRouteParams<Path extends string> =
|
|
54
|
+
Path extends `${string}$${infer Param}/${infer Rest}`
|
|
55
|
+
? Param | ExtractRouteParams<`/${Rest}`>
|
|
56
|
+
: Path extends `${string}$${infer Param}`
|
|
57
|
+
? Param
|
|
58
|
+
: never;
|
|
59
|
+
|
|
60
|
+
/** Typed record of path params for a route. */
|
|
61
|
+
export type RouteParams<Path extends string> =
|
|
62
|
+
[ExtractRouteParams<Path>] extends [never]
|
|
63
|
+
? Record<string, never>
|
|
64
|
+
: { readonly [K in ExtractRouteParams<Path>]: string };
|
|
65
|
+
|
|
66
|
+
// ─── Zod-like schema duck type (no hard Zod dep) ──────────────────────────────
|
|
67
|
+
|
|
68
|
+
interface SchemaLike<T> {
|
|
69
|
+
parse(input: unknown): T;
|
|
70
|
+
safeParse(input: unknown): { success: true; data: T } | { success: false; error: unknown };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
type InferSchema<S> = S extends SchemaLike<infer T> ? T : Record<string, string>;
|
|
74
|
+
|
|
75
|
+
// ─── Route descriptor ─────────────────────────────────────────────────────────
|
|
76
|
+
|
|
77
|
+
export interface RouteConfig<
|
|
78
|
+
Path extends string,
|
|
79
|
+
Search extends SchemaLike<unknown> | undefined,
|
|
80
|
+
LoaderData,
|
|
81
|
+
> {
|
|
82
|
+
/** URL path using `$param` syntax: `"/users/$id"`, `"/posts/$slug/edit"`. */
|
|
83
|
+
path: Path;
|
|
84
|
+
/**
|
|
85
|
+
* Zod (or any schema) that validates + parses search params.
|
|
86
|
+
* Parsed value is available via `useSearch(route)`.
|
|
87
|
+
*/
|
|
88
|
+
search?: Search;
|
|
89
|
+
/**
|
|
90
|
+
* Runs before the component mounts — blocks render until resolved.
|
|
91
|
+
* Data is available via `useLoaderData(route)`.
|
|
92
|
+
*/
|
|
93
|
+
loader?: (ctx: {
|
|
94
|
+
params: RouteParams<Path>;
|
|
95
|
+
search: Search extends SchemaLike<unknown> ? InferSchema<Search> : Record<string, string>;
|
|
96
|
+
}) => Promise<LoaderData>;
|
|
97
|
+
/** The page component for this route. */
|
|
98
|
+
component: ComponentType<{
|
|
99
|
+
params: RouteParams<Path>;
|
|
100
|
+
search: Search extends SchemaLike<unknown> ? InferSchema<Search> : Record<string, string>;
|
|
101
|
+
loaderData: LoaderData;
|
|
102
|
+
}>;
|
|
103
|
+
/**
|
|
104
|
+
* Rendered when the loader throws or component throws during render.
|
|
105
|
+
* Equivalent to `error.tsx` in the file-system router.
|
|
106
|
+
*/
|
|
107
|
+
errorComponent?: ComponentType<{ error: Error; reset: () => void }>;
|
|
108
|
+
/**
|
|
109
|
+
* Rendered while the loader is running.
|
|
110
|
+
* Equivalent to `loading.tsx` in the file-system router.
|
|
111
|
+
*/
|
|
112
|
+
pendingComponent?: ComponentType;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export interface RouteDescriptor<
|
|
116
|
+
Path extends string = string,
|
|
117
|
+
Search extends SchemaLike<unknown> | undefined = undefined,
|
|
118
|
+
LoaderData = undefined,
|
|
119
|
+
> extends RouteConfig<Path, Search, LoaderData> {
|
|
120
|
+
/** @internal Compiled regex for matching this route's path. */
|
|
121
|
+
_regex: RegExp;
|
|
122
|
+
/** @internal Ordered param names extracted from the path. */
|
|
123
|
+
_paramNames: string[];
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Define a type-safe route.
|
|
128
|
+
*
|
|
129
|
+
* @example
|
|
130
|
+
* ```ts
|
|
131
|
+
* export const postRoute = createRoute({
|
|
132
|
+
* path: "/posts/$id",
|
|
133
|
+
* loader: ({ params }) => fetchPost(params.id),
|
|
134
|
+
* component: PostPage,
|
|
135
|
+
* });
|
|
136
|
+
* ```
|
|
137
|
+
*/
|
|
138
|
+
export function createRoute<
|
|
139
|
+
Path extends string,
|
|
140
|
+
Search extends SchemaLike<unknown> | undefined = undefined,
|
|
141
|
+
LoaderData = undefined,
|
|
142
|
+
>(
|
|
143
|
+
config: RouteConfig<Path, Search, LoaderData>,
|
|
144
|
+
): RouteDescriptor<Path, Search, LoaderData> {
|
|
145
|
+
const paramNames: string[] = [];
|
|
146
|
+
const regexStr = config.path
|
|
147
|
+
.split("/")
|
|
148
|
+
.map((seg) => {
|
|
149
|
+
if (seg.startsWith("$")) {
|
|
150
|
+
paramNames.push(seg.slice(1));
|
|
151
|
+
return "([^/]+)";
|
|
152
|
+
}
|
|
153
|
+
return seg.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
154
|
+
})
|
|
155
|
+
.join("/");
|
|
156
|
+
|
|
157
|
+
const regex = config.path === "/" ? /^\/$/ : new RegExp(`^${regexStr}\\/?$`);
|
|
158
|
+
|
|
159
|
+
return { ...config, _regex: regex, _paramNames: paramNames };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// ─── Router ───────────────────────────────────────────────────────────────────
|
|
163
|
+
|
|
164
|
+
export interface Router {
|
|
165
|
+
routes: RouteDescriptor[];
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Assemble multiple routes into a router instance. */
|
|
169
|
+
export function createRouter(routes: RouteDescriptor[]): Router {
|
|
170
|
+
// Sort: static routes before dynamic ones (fewer params = higher priority).
|
|
171
|
+
const sorted = [...routes].sort((a, b) => a._paramNames.length - b._paramNames.length);
|
|
172
|
+
return { routes: sorted };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// ─── Router context ───────────────────────────────────────────────────────────
|
|
176
|
+
|
|
177
|
+
interface RouterState {
|
|
178
|
+
pathname: string;
|
|
179
|
+
search: string;
|
|
180
|
+
params: Record<string, string>;
|
|
181
|
+
searchParsed: Record<string, unknown>;
|
|
182
|
+
loaderData: unknown;
|
|
183
|
+
matchedRoute: RouteDescriptor | null;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
interface RouterContextValue extends RouterState {
|
|
187
|
+
navigate: (href: string) => void;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const RouterCtx = createContext<RouterContextValue | null>(null);
|
|
191
|
+
|
|
192
|
+
function useRouterCtx(): RouterContextValue {
|
|
193
|
+
const ctx = useContext(RouterCtx);
|
|
194
|
+
if (!ctx) throw new Error("[alabjs] useParams / useSearch / navigate must be used inside <RouterProvider>");
|
|
195
|
+
return ctx;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// ─── Route matching ───────────────────────────────────────────────────────────
|
|
199
|
+
|
|
200
|
+
function matchRoute(
|
|
201
|
+
routes: RouteDescriptor[],
|
|
202
|
+
pathname: string,
|
|
203
|
+
): { route: RouteDescriptor; params: Record<string, string> } | null {
|
|
204
|
+
for (const route of routes) {
|
|
205
|
+
const match = route._regex.exec(pathname);
|
|
206
|
+
if (match) {
|
|
207
|
+
const params: Record<string, string> = {};
|
|
208
|
+
route._paramNames.forEach((name, i) => {
|
|
209
|
+
params[name] = decodeURIComponent(match[i + 1] ?? "");
|
|
210
|
+
});
|
|
211
|
+
return { route, params };
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
return null;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function parseSearch(
|
|
218
|
+
searchStr: string,
|
|
219
|
+
schema?: SchemaLike<unknown>,
|
|
220
|
+
): Record<string, unknown> {
|
|
221
|
+
const raw = Object.fromEntries(new URLSearchParams(searchStr).entries());
|
|
222
|
+
if (!schema) return raw;
|
|
223
|
+
const result = schema.safeParse(raw);
|
|
224
|
+
return result.success ? (result.data as Record<string, unknown>) : raw;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// ─── RouterProvider ───────────────────────────────────────────────────────────
|
|
228
|
+
|
|
229
|
+
export interface RouterProviderProps {
|
|
230
|
+
router: Router;
|
|
231
|
+
children?: ReactNode;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Wrap your root layout with `RouterProvider` to enable typed navigation.
|
|
236
|
+
*
|
|
237
|
+
* @example
|
|
238
|
+
* ```tsx
|
|
239
|
+
* import { RouterProvider } from "alabjs/router";
|
|
240
|
+
* import { router } from "../routes.js";
|
|
241
|
+
*
|
|
242
|
+
* export default function RootLayout({ children }) {
|
|
243
|
+
* return <RouterProvider router={router}>{children}</RouterProvider>;
|
|
244
|
+
* }
|
|
245
|
+
* ```
|
|
246
|
+
*/
|
|
247
|
+
export function RouterProvider({ router, children }: RouterProviderProps) {
|
|
248
|
+
const buildState = useCallback(async (pathname: string, search: string): Promise<RouterState> => {
|
|
249
|
+
const matched = matchRoute(router.routes, pathname);
|
|
250
|
+
if (!matched) {
|
|
251
|
+
return {
|
|
252
|
+
pathname, search,
|
|
253
|
+
params: {},
|
|
254
|
+
searchParsed: parseSearch(search),
|
|
255
|
+
loaderData: undefined,
|
|
256
|
+
matchedRoute: null,
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const { route, params } = matched;
|
|
261
|
+
const searchParsed = parseSearch(search, route.search);
|
|
262
|
+
|
|
263
|
+
let loaderData: unknown = undefined;
|
|
264
|
+
if (route.loader) {
|
|
265
|
+
loaderData = await route.loader({
|
|
266
|
+
params: params as never,
|
|
267
|
+
search: searchParsed as never,
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
return { pathname, search, params, searchParsed, loaderData, matchedRoute: route };
|
|
272
|
+
}, [router]);
|
|
273
|
+
|
|
274
|
+
const [state, setState] = useState<RouterState>({
|
|
275
|
+
pathname: typeof window !== "undefined" ? window.location.pathname : "/",
|
|
276
|
+
search: typeof window !== "undefined" ? window.location.search : "",
|
|
277
|
+
params: {},
|
|
278
|
+
searchParsed: {},
|
|
279
|
+
loaderData: undefined,
|
|
280
|
+
matchedRoute: null,
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
// Run loader for initial route.
|
|
284
|
+
useEffect(() => {
|
|
285
|
+
void buildState(window.location.pathname, window.location.search).then(setState);
|
|
286
|
+
}, [buildState]);
|
|
287
|
+
|
|
288
|
+
// Intercept Alab's SPA navigate events.
|
|
289
|
+
useEffect(() => {
|
|
290
|
+
const handler = () => {
|
|
291
|
+
void buildState(window.location.pathname, window.location.search).then(setState);
|
|
292
|
+
};
|
|
293
|
+
window.addEventListener("popstate", handler);
|
|
294
|
+
return () => window.removeEventListener("popstate", handler);
|
|
295
|
+
}, [buildState]);
|
|
296
|
+
|
|
297
|
+
const navigate = useCallback((href: string) => {
|
|
298
|
+
if (typeof window !== "undefined" && "__alabjs_navigate" in window) {
|
|
299
|
+
(window as { __alabjs_navigate: (h: string) => void }).__alabjs_navigate(href);
|
|
300
|
+
} else {
|
|
301
|
+
window.location.href = href;
|
|
302
|
+
}
|
|
303
|
+
void buildState(href.split("?")[0] ?? href, href.includes("?") ? href.split("?")[1] ?? "" : "").then(setState);
|
|
304
|
+
}, [buildState]);
|
|
305
|
+
|
|
306
|
+
return (
|
|
307
|
+
<RouterCtx.Provider value={{ ...state, navigate }}>
|
|
308
|
+
{children}
|
|
309
|
+
</RouterCtx.Provider>
|
|
310
|
+
);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// ─── Hooks ────────────────────────────────────────────────────────────────────
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Access typed path params for a specific route.
|
|
317
|
+
*
|
|
318
|
+
* @example
|
|
319
|
+
* ```tsx
|
|
320
|
+
* const params = useParams(userRoute);
|
|
321
|
+
* params.id; // string ✅
|
|
322
|
+
* params.foo; // TS error ✅
|
|
323
|
+
* ```
|
|
324
|
+
*/
|
|
325
|
+
export function useParams<
|
|
326
|
+
Path extends string,
|
|
327
|
+
S extends SchemaLike<unknown> | undefined,
|
|
328
|
+
L,
|
|
329
|
+
>(
|
|
330
|
+
_route: RouteDescriptor<Path, S, L>,
|
|
331
|
+
): RouteParams<Path> {
|
|
332
|
+
return useRouterCtx().params as RouteParams<Path>;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Access typed, schema-validated search params for a specific route.
|
|
337
|
+
*
|
|
338
|
+
* @example
|
|
339
|
+
* ```tsx
|
|
340
|
+
* const search = useSearch(postRoute);
|
|
341
|
+
* search.tab; // "posts" | "about" | undefined ✅
|
|
342
|
+
* ```
|
|
343
|
+
*/
|
|
344
|
+
export function useSearch<
|
|
345
|
+
Path extends string,
|
|
346
|
+
S extends SchemaLike<unknown> | undefined,
|
|
347
|
+
L,
|
|
348
|
+
>(
|
|
349
|
+
_route: RouteDescriptor<Path, S, L>,
|
|
350
|
+
): S extends SchemaLike<unknown> ? InferSchema<S> : Record<string, string> {
|
|
351
|
+
return useRouterCtx().searchParsed as never;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* Access the data returned by the route's `loader` function.
|
|
356
|
+
*
|
|
357
|
+
* @example
|
|
358
|
+
* ```tsx
|
|
359
|
+
* const user = useLoaderData(userRoute);
|
|
360
|
+
* user.name; // typed from loader return ✅
|
|
361
|
+
* ```
|
|
362
|
+
*/
|
|
363
|
+
export function useLoaderData<
|
|
364
|
+
Path extends string,
|
|
365
|
+
S extends SchemaLike<unknown> | undefined,
|
|
366
|
+
L,
|
|
367
|
+
>(
|
|
368
|
+
_route: RouteDescriptor<Path, S, L>,
|
|
369
|
+
): L {
|
|
370
|
+
return useRouterCtx().loaderData as L;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* Returns a typed `navigate` function.
|
|
375
|
+
*
|
|
376
|
+
* @example
|
|
377
|
+
* ```tsx
|
|
378
|
+
* const nav = useNavigate();
|
|
379
|
+
* nav("/users/42");
|
|
380
|
+
* nav("/users/42?tab=posts");
|
|
381
|
+
* ```
|
|
382
|
+
*/
|
|
383
|
+
export function useNavigate(): (href: string) => void {
|
|
384
|
+
return useRouterCtx().navigate;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// ─── Typed Link ───────────────────────────────────────────────────────────────
|
|
388
|
+
|
|
389
|
+
type LinkToRoute<
|
|
390
|
+
Path extends string,
|
|
391
|
+
S extends SchemaLike<unknown> | undefined,
|
|
392
|
+
L,
|
|
393
|
+
> = {
|
|
394
|
+
/** Destination route descriptor (replaces `href`). */
|
|
395
|
+
to: RouteDescriptor<Path, S, L>;
|
|
396
|
+
/** Path params — required when the route has dynamic segments. */
|
|
397
|
+
params?: RouteParams<Path>;
|
|
398
|
+
/** Search params to append. */
|
|
399
|
+
search?: S extends SchemaLike<infer T> ? Partial<T & Record<string, string>> : Record<string, string>;
|
|
400
|
+
children?: ReactNode;
|
|
401
|
+
className?: string;
|
|
402
|
+
style?: React.CSSProperties;
|
|
403
|
+
onClick?: React.MouseEventHandler<HTMLAnchorElement>;
|
|
404
|
+
};
|
|
405
|
+
|
|
406
|
+
/**
|
|
407
|
+
* A type-safe `<Link>` bound to a route descriptor.
|
|
408
|
+
* Compile-time error if required params are missing or wrong type.
|
|
409
|
+
*
|
|
410
|
+
* @example
|
|
411
|
+
* ```tsx
|
|
412
|
+
* <RouteLink to={userRoute} params={{ id: "42" }}>
|
|
413
|
+
* View user
|
|
414
|
+
* </RouteLink>
|
|
415
|
+
* ```
|
|
416
|
+
*/
|
|
417
|
+
export function RouteLink<
|
|
418
|
+
Path extends string,
|
|
419
|
+
S extends SchemaLike<unknown> | undefined,
|
|
420
|
+
L,
|
|
421
|
+
>({ to, params, search, children, onClick, ...rest }: LinkToRoute<Path, S, L>) {
|
|
422
|
+
const { navigate } = useRouterCtx();
|
|
423
|
+
|
|
424
|
+
// Build href from route path + params + search.
|
|
425
|
+
const href = buildHref(to.path, params as Record<string, string>, search as Record<string, string>);
|
|
426
|
+
|
|
427
|
+
const handleClick: React.MouseEventHandler<HTMLAnchorElement> = (e) => {
|
|
428
|
+
onClick?.(e);
|
|
429
|
+
if (e.defaultPrevented) return;
|
|
430
|
+
if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
|
|
431
|
+
e.preventDefault();
|
|
432
|
+
navigate(href);
|
|
433
|
+
};
|
|
434
|
+
|
|
435
|
+
return (
|
|
436
|
+
<a href={href} onClick={handleClick} {...rest}>
|
|
437
|
+
{children}
|
|
438
|
+
</a>
|
|
439
|
+
);
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
function buildHref(
|
|
443
|
+
path: string,
|
|
444
|
+
params?: Record<string, string>,
|
|
445
|
+
search?: Record<string, string>,
|
|
446
|
+
): string {
|
|
447
|
+
let resolved = path;
|
|
448
|
+
if (params) {
|
|
449
|
+
for (const [k, v] of Object.entries(params)) {
|
|
450
|
+
resolved = resolved.replace(`$${k}`, encodeURIComponent(v));
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
if (search && Object.keys(search).length > 0) {
|
|
454
|
+
resolved += "?" + new URLSearchParams(
|
|
455
|
+
Object.fromEntries(Object.entries(search).map(([k, v]) => [k, String(v)])),
|
|
456
|
+
).toString();
|
|
457
|
+
}
|
|
458
|
+
return resolved;
|
|
459
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export type { Route, RouteKind, RouteManifest } from "./manifest.js";
|
|
2
|
+
export {
|
|
3
|
+
createRoute,
|
|
4
|
+
createRouter,
|
|
5
|
+
RouterProvider,
|
|
6
|
+
RouteLink,
|
|
7
|
+
useParams,
|
|
8
|
+
useSearch,
|
|
9
|
+
useLoaderData,
|
|
10
|
+
useNavigate,
|
|
11
|
+
} from "./code-router.js";
|
|
12
|
+
export type {
|
|
13
|
+
RouteDescriptor,
|
|
14
|
+
RouteConfig,
|
|
15
|
+
RouteParams,
|
|
16
|
+
Router,
|
|
17
|
+
RouterProviderProps,
|
|
18
|
+
} from "./code-router.js";
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export type RouteKind = "page" | "server" | "layout" | "error" | "loading" | "api";
|
|
2
|
+
|
|
3
|
+
export interface Route {
|
|
4
|
+
path: string;
|
|
5
|
+
file: string;
|
|
6
|
+
kind: RouteKind;
|
|
7
|
+
ssr: boolean;
|
|
8
|
+
params: string[];
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface RouteManifest {
|
|
12
|
+
routes: Route[];
|
|
13
|
+
}
|