@vizejs/router 0.0.0-stage → 0.714.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 ubugeeei
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,70 @@
1
- # Temporary Holding Version
1
+ # `@vizejs/router`
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ > Experimental: the route table, SSR handoff shape, and generated metadata contract may change
4
+ > during the Vize alpha line.
5
+
6
+ The package is released on npm as `@vizejs/router`; the route and navigation contracts remain
7
+ experimental throughout the Vize alpha line.
8
+
9
+ `@vizejs/router` provides the first typed route-table kernel for Vize applications. It is
10
+ intentionally SSR-safe: matching and serialization do not touch browser globals, timers, or reactive
11
+ scope state.
12
+
13
+ ```ts
14
+ import { createRouterMatcher, defineRoutes } from "@vizejs/router";
15
+
16
+ const routes = defineRoutes([
17
+ { name: "home", path: "/", component: "./routes/home.vue" },
18
+ { name: "user", path: "/users/:id/:tab?", component: "./routes/user.vue" },
19
+ ] as const);
20
+
21
+ const router = createRouterMatcher(routes);
22
+ const match = router.match("/users/42/settings?from=ssr");
23
+
24
+ router.resolve("user", { id: "42" });
25
+ ```
26
+
27
+ Route components are declared as `.vue` specifiers. JavaScript render-function component sources are
28
+ not part of this package surface.
29
+
30
+ SSR integrations can pass `serializeRouteMatch(match, data)` through their own safe serialization
31
+ channel, then call `hydrateRouteState(routes, state, currentUrl)` during client activation. Hydration
32
+ rematches the pathname against the current table and checks route identity, decoded params, query,
33
+ and hash. Malformed or stale handoffs return `undefined`; match and load the current URL afresh in
34
+ that case. The optional third argument checks the activation URL without reading browser globals.
35
+ Omitting it reconciles the serialized pathname and params only; it cannot detect navigation since
36
+ the server render. Query key order is ignored, while repeated-value order is preserved.
37
+
38
+ ## Navigation lifecycle
39
+
40
+ `createRouter(routes, adapters)` adds cancellable guards, loaders, redirects, stale-result custody,
41
+ history commits, and explicit focus/scroll/announcement effects without reading browser globals.
42
+ Integrations provide the browser or native adapters they support; missing adapters are deliberate
43
+ no-ops rather than implicit global access.
44
+
45
+ ```ts
46
+ import { createRouter } from "@vizejs/router";
47
+
48
+ const router = createRouter(routes, {
49
+ history: {
50
+ push: (url, leaving) => history.pushState(leaving, "", url),
51
+ replace: (url, leaving) => history.replaceState(leaving, "", url),
52
+ },
53
+ announce: (title) => announceRoute(title),
54
+ });
55
+
56
+ router.beforeEach(({ to }) => (to.meta.requiresAuth && !session.value ? "/login" : undefined));
57
+ await router.navigate({ name: "user", params: { id: "42" } });
58
+ ```
59
+
60
+ Starting a second navigation aborts the first one. A late loader result is reported as `stale` and
61
+ cannot replace current state. Explicit cancellation reports `cancelled`; loader/guard failures
62
+ report `error` and retain the route's optional `.vue` error boundary. History traversal passes saved
63
+ scroll/focus values through `navigate(..., { history: "pop", scroll, focus })`.
64
+
65
+ ## File routes and evidence
66
+
67
+ `createFileRoutes` maps `index.vue`, `[id].vue`, `[[tab]].vue`, and `[...slug].vue` into explicit
68
+ route records. Each source can override its generated name/path while retaining the same manifest,
69
+ SSR, and navigation contracts. `vp run --filter './npm/compose/router' bench` records cold table
70
+ construction, warm matching, warm navigation, and a 1,000-route table in one machine-readable row.
@@ -0,0 +1,265 @@
1
+ //#region src/types.d.ts
2
+ /** A source-owned Vue route component. */
3
+ type RouteComponentSpecifier = `${string}.vue`;
4
+ /** Serializable metadata attached to a route record. */
5
+ type RouteMeta = Readonly<Record<string, unknown>>;
6
+ /** Query values preserve repeated keys without depending on URLSearchParams at hydration time. */
7
+ type RouteQuery = Readonly<Record<string, string | readonly string[]>>;
8
+ /** Context passed to a route loader. */
9
+ interface RouteLoaderContext<Params = RouteParams> {
10
+ /** URL pathname that matched the route. */
11
+ readonly pathname: string;
12
+ /** Params decoded from dynamic path segments. */
13
+ readonly params: Params;
14
+ /** Parsed query string. Repeated keys become arrays in insertion order. */
15
+ readonly query: RouteQuery;
16
+ /** Hash without the leading `#`. */
17
+ readonly hash: string;
18
+ /** Abort signal owned by the current navigation or server render. */
19
+ readonly signal?: AbortSignal;
20
+ }
21
+ /** Loader invoked by integrations after a route has matched. */
22
+ type RouteLoader<Params, Data> = (context: RouteLoaderContext<Params>) => Data | Promise<Data>;
23
+ /** Typed route record accepted by defineRoutes. */
24
+ interface RouteDefinition<Name extends string = string, Path extends string = string, Meta extends RouteMeta = RouteMeta, Data = unknown> {
25
+ /** Literal route name used for typed navigation. */
26
+ readonly name: Name;
27
+ /** Absolute path pattern. Dynamic segments use `:id`; optional segments use `:tab?`. */
28
+ readonly path: Path;
29
+ /** `.vue` component source for the route. */
30
+ readonly component: RouteComponentSpecifier;
31
+ /** Serializable route metadata available on server and client. */
32
+ readonly meta?: Meta;
33
+ /** Optional data loader. Its param type is inferred from `path`. */
34
+ readonly loader?: RouteLoader<Simplify<RoutePathParams<Path>>, Data>;
35
+ /** Optional redirect evaluated before the loader. */
36
+ readonly redirect?: RouteRedirect<Simplify<RoutePathParams<Path>>>;
37
+ /** Source-owned error boundary selected when the loader or a guard fails. */
38
+ readonly errorComponent?: RouteComponentSpecifier;
39
+ }
40
+ /** Redirect returned by a route record or navigation guard. */
41
+ type RouteRedirect<Params = RouteParams> = string | URL | ((context: RouteLoaderContext<Params>) => string | URL | Promise<string | URL>);
42
+ /** Matched route result shared by server and client runtimes. */
43
+ interface RouteMatch<Route extends RouteDefinition = RouteDefinition> {
44
+ readonly route: Route;
45
+ readonly name: Route["name"];
46
+ readonly pathname: string;
47
+ readonly params: Simplify<RoutePathParams<Route["path"]>>;
48
+ readonly query: RouteQuery;
49
+ readonly hash: string;
50
+ readonly meta: Route["meta"] extends RouteMeta ? Route["meta"] : RouteMeta;
51
+ }
52
+ /** Serialized route state embedded into SSR HTML and restored during hydration. */
53
+ interface SerializedRouteState<Name extends string = string> {
54
+ readonly schemaVersion: 1;
55
+ readonly name: Name;
56
+ readonly pathname: string;
57
+ readonly params: RouteParams;
58
+ readonly query: RouteQuery;
59
+ readonly hash: string;
60
+ readonly data?: unknown;
61
+ }
62
+ /** Options for typed URL generation. */
63
+ interface RouteResolveOptions {
64
+ /**
65
+ * Query values appended to the generated URL.
66
+ *
67
+ * @default {}
68
+ */
69
+ readonly query?: Readonly<Record<string, string | number | boolean | readonly (string | number | boolean)[] | null | undefined>>;
70
+ /**
71
+ * Hash appended without requiring a leading `#`.
72
+ *
73
+ * @default ""
74
+ */
75
+ readonly hash?: string;
76
+ }
77
+ /** Machine-readable route manifest for generators and docs. */
78
+ interface RouteManifest {
79
+ readonly schemaVersion: 1;
80
+ readonly routes: readonly RouteManifestEntry[];
81
+ }
82
+ /** Serializable manifest row for one route. */
83
+ interface RouteManifestEntry {
84
+ readonly name: string;
85
+ readonly path: string;
86
+ readonly component: RouteComponentSpecifier;
87
+ readonly meta: RouteMeta;
88
+ readonly errorComponent?: RouteComponentSpecifier;
89
+ }
90
+ /** A named, type-safe navigation target. */
91
+ type NamedRouteLocation<Routes extends readonly RouteDefinition[], Name extends RouteNames<Routes> = RouteNames<Routes>> = Name extends RouteNames<Routes> ? {
92
+ readonly name: Name;
93
+ readonly params: RouteParamsForName<Routes, Name>;
94
+ readonly query?: RouteResolveOptions["query"];
95
+ readonly hash?: string;
96
+ } : never;
97
+ /** Input accepted by the navigation lifecycle. */
98
+ type RouteLocation<Routes extends readonly RouteDefinition[]> = string | URL | NamedRouteLocation<Routes>;
99
+ /** History operation performed after a successful navigation. */
100
+ type NavigationHistoryMode = "push" | "replace" | "pop" | "none";
101
+ /** Observable phase of the router navigation lifecycle. */
102
+ type NavigationStatus = "idle" | "pending" | "success" | "cancelled" | "stale" | "error";
103
+ /** Per-navigation history, scroll, and focus behavior. */
104
+ interface NavigationOptions {
105
+ /** History behavior after a successful navigation. @default "push" */
106
+ readonly history?: NavigationHistoryMode;
107
+ /** Scroll snapshot restored for a history traversal. @default undefined */
108
+ readonly scroll?: unknown;
109
+ /** Focus snapshot restored for a history traversal. @default undefined */
110
+ readonly focus?: unknown;
111
+ }
112
+ /** Stable inputs supplied to each registered navigation guard. */
113
+ interface NavigationContext<Routes extends readonly RouteDefinition[]> {
114
+ readonly from: RouteMatch<Routes[number]> | undefined;
115
+ readonly to: RouteMatch<Routes[number]>;
116
+ readonly signal: AbortSignal;
117
+ }
118
+ /** Result accepted from a navigation guard. */
119
+ type NavigationGuardResult<Routes extends readonly RouteDefinition[]> = void | true | false | RouteLocation<Routes>;
120
+ /** Guard that can continue, cancel, or redirect a navigation. */
121
+ type NavigationGuard<Routes extends readonly RouteDefinition[]> = (context: NavigationContext<Routes>) => NavigationGuardResult<Routes> | Promise<NavigationGuardResult<Routes>>;
122
+ /** Terminal result returned to the caller that started a navigation. */
123
+ type NavigationResult<Routes extends readonly RouteDefinition[]> = {
124
+ readonly status: "success";
125
+ readonly match: RouteMatch<Routes[number]>;
126
+ readonly data: unknown;
127
+ readonly redirected: boolean;
128
+ } | {
129
+ readonly status: "cancelled" | "stale";
130
+ readonly match?: RouteMatch<Routes[number]>;
131
+ } | {
132
+ readonly status: "error";
133
+ readonly match?: RouteMatch<Routes[number]>;
134
+ readonly error: unknown;
135
+ readonly errorComponent?: RouteComponentSpecifier;
136
+ };
137
+ /** Current observable state, including the route that owns pending work. */
138
+ interface RouterNavigationState<Routes extends readonly RouteDefinition[]> {
139
+ readonly status: NavigationStatus;
140
+ readonly current: RouteMatch<Routes[number]> | undefined;
141
+ readonly pending: RouteMatch<Routes[number]> | undefined;
142
+ readonly data: unknown;
143
+ readonly error: unknown;
144
+ }
145
+ /** Explicit history boundary used by browser and non-browser hosts. */
146
+ interface RouterHistoryAdapter {
147
+ readonly push: (url: string, leaving: RouterHistorySnapshot) => void | Promise<void>;
148
+ readonly replace: (url: string, leaving: RouterHistorySnapshot) => void | Promise<void>;
149
+ }
150
+ /** Host-owned focus and scroll state retained in a history entry. */
151
+ interface RouterHistorySnapshot {
152
+ readonly scroll?: unknown;
153
+ readonly focus?: unknown;
154
+ }
155
+ /** Focus custody supplied by a navigation host. */
156
+ interface RouterFocusAdapter {
157
+ readonly capture?: () => unknown;
158
+ readonly restore?: (snapshot: unknown) => void | Promise<void>;
159
+ readonly focusMain?: (match: RouteMatch) => void | Promise<void>;
160
+ }
161
+ /** Scroll custody supplied by a navigation host. */
162
+ interface RouterScrollAdapter {
163
+ readonly capture?: () => unknown;
164
+ readonly restore?: (snapshot: unknown) => void | Promise<void>;
165
+ readonly top?: (match: RouteMatch) => void | Promise<void>;
166
+ }
167
+ /** Host adapters and lifecycle defaults used by createRouter. */
168
+ interface RouterNavigationOptions<Routes extends readonly RouteDefinition[]> {
169
+ /** Initial URL to match without running loaders or guards. @default undefined */
170
+ readonly initial?: string | URL;
171
+ /** History writer. Omission keeps navigation history side-effect free. @default undefined */
172
+ readonly history?: RouterHistoryAdapter;
173
+ /** Focus capture and restoration adapter. @default undefined */
174
+ readonly focus?: RouterFocusAdapter;
175
+ /** Scroll capture and restoration adapter. @default undefined */
176
+ readonly scroll?: RouterScrollAdapter;
177
+ /** Accessible route announcement hook. @default undefined */
178
+ readonly announce?: (message: string, match: RouteMatch<Routes[number]>) => void | Promise<void>;
179
+ /** Maximum redirect chain before failing closed. @default 16 */
180
+ readonly maxRedirects?: number;
181
+ }
182
+ /** Stateful navigation facade produced by createRouter. */
183
+ interface Router<Routes extends readonly RouteDefinition[]> {
184
+ readonly matcher: RouterMatcher<Routes>;
185
+ readonly state: () => RouterNavigationState<Routes>;
186
+ readonly navigate: (location: RouteLocation<Routes>, options?: NavigationOptions) => Promise<NavigationResult<Routes>>;
187
+ readonly beforeEach: (guard: NavigationGuard<Routes>) => () => void;
188
+ readonly subscribe: (listener: (state: RouterNavigationState<Routes>) => void) => () => void;
189
+ readonly cancel: (reason?: unknown) => void;
190
+ }
191
+ /** Source row used by the file-route generator. */
192
+ interface FileRouteSource {
193
+ readonly file: RouteComponentSpecifier;
194
+ readonly name?: string;
195
+ readonly path?: string;
196
+ readonly meta?: RouteMeta;
197
+ readonly loader?: RouteLoader<RouteParams, unknown>;
198
+ readonly redirect?: RouteRedirect<RouteParams>;
199
+ readonly errorComponent?: RouteComponentSpecifier;
200
+ }
201
+ /** Matcher produced by createRouterMatcher. */
202
+ interface RouterMatcher<Routes extends readonly RouteDefinition[]> {
203
+ readonly routes: Routes;
204
+ readonly match: (input: string | URL) => RouteMatch<Routes[number]> | undefined;
205
+ readonly manifest: () => RouteManifest;
206
+ readonly resolve: <Name extends RouteNames<Routes>>(name: Name, params: RouteParamsForName<Routes, Name>, options?: RouteResolveOptions) => string;
207
+ }
208
+ /** Literal names available in a route table. */
209
+ type RouteNames<Routes extends readonly RouteDefinition[]> = Routes[number]["name"];
210
+ /** Route record selected by literal name. */
211
+ type RouteByName<Routes extends readonly RouteDefinition[], Name extends RouteNames<Routes>> = Extract<Routes[number], {
212
+ readonly name: Name;
213
+ }>;
214
+ /** Params required by the route with `Name`. */
215
+ type RouteParamsForName<Routes extends readonly RouteDefinition[], Name extends RouteNames<Routes>> = RouteByName<Routes, Name> extends RouteDefinition<string, infer Path> ? Simplify<RoutePathParams<Path>> : never;
216
+ /** Param object inferred from a path pattern. */
217
+ type RoutePathParams<Path extends string> = Path extends `${infer Head}/${infer Tail}` ? Simplify<SegmentParams<Head> & RoutePathParams<Tail>> : SegmentParams<Path>;
218
+ /** Generic decoded route-param record used when no literal path is available. */
219
+ type RouteParams = Readonly<Record<string, string | undefined>>;
220
+ type SegmentParams<Segment extends string> = Segment extends `:${infer Name}?` ? { readonly [Key in CleanParamName<Name>]?: string } : Segment extends `:${infer Name}` ? { readonly [Key in CleanParamName<Name>]: string } : Segment extends `*${infer Name}` ? { readonly [Key in CleanParamName<Name>]: string } : {};
221
+ type CleanParamName<Name extends string> = Name extends "" ? never : Name;
222
+ /** Materialize an inferred intersection as one readonly object type. */
223
+ type Simplify<T> = { readonly [Key in keyof T]: T[Key] } & {};
224
+ /** Internal matcher row exposed for generator and adapter integrations. */
225
+ interface CompiledRoute<Route extends RouteDefinition = RouteDefinition> {
226
+ readonly route: Route;
227
+ readonly pattern: RegExp;
228
+ readonly params: readonly string[];
229
+ }
230
+ //#endregion
231
+ //#region src/file-routes.d.ts
232
+ /** Generate route records from source-owned `.vue` route modules. */
233
+ declare function createFileRoutes(sources: readonly FileRouteSource[]): readonly RouteDefinition[];
234
+ /** Convert `routes/users/[id].vue` into `/users/:id`. */
235
+ declare function routePathFromFile(file: `${string}.vue`): string;
236
+ /** Produce a stable manifest name from a file route. */
237
+ declare function routeNameFromFile(file: `${string}.vue`): string;
238
+ //#endregion
239
+ //#region src/navigation.d.ts
240
+ /**
241
+ * Create an SSR-safe navigation lifecycle around a literal route table.
242
+ *
243
+ * Browser history, focus, scrolling, and announcements are explicit adapters;
244
+ * importing or constructing a router never reads browser globals.
245
+ */
246
+ declare function createRouter<const Routes extends readonly RouteDefinition[]>(routes: Routes, options?: RouterNavigationOptions<Routes>): Router<Routes>;
247
+ //#endregion
248
+ //#region src/runtime.d.ts
249
+ /** Define a literal route table and fail closed on duplicate or unsupported rows. */
250
+ declare function defineRoutes<const Routes extends readonly RouteDefinition[]>(routes: Routes): Routes;
251
+ /** Define one route record while preserving loader param inference from its path. */
252
+ declare function defineRoute<const Name extends string, const Path extends string, const Meta extends Readonly<Record<string, unknown>> = Readonly<Record<string, unknown>>, Data = unknown>(route: RouteDefinition<Name, Path, Meta, Data>): RouteDefinition<Name, Path, Meta, Data>;
253
+ /** Create an SSR-safe matcher and typed URL resolver for a route table. */
254
+ declare function createRouterMatcher<const Routes extends readonly RouteDefinition[]>(routes: Routes): RouterMatcher<Routes>;
255
+ /** Emit serializable metadata for generators, docs, and devtools. */
256
+ declare function createRouteManifest<const Routes extends readonly RouteDefinition[]>(routes: Routes): RouteManifest;
257
+ /** Serialize a route match for SSR handoff. */
258
+ declare function serializeRouteMatch<Route extends RouteDefinition>(match: RouteMatch<Route>, data?: unknown): SerializedRouteState<Route["name"]>;
259
+ /**
260
+ * Reconcile SSR state with the client table, optionally checking the activation URL.
261
+ * Returns undefined for stale or malformed state; integrations must match/load afresh.
262
+ */
263
+ declare function hydrateRouteState<const Routes extends readonly RouteDefinition[]>(routes: Routes, state: SerializedRouteState<RouteNames<Routes>>, input?: string | URL): RouteMatch<Routes[number]> | undefined;
264
+ //#endregion
265
+ export { CompiledRoute, FileRouteSource, NamedRouteLocation, NavigationContext, NavigationGuard, NavigationGuardResult, NavigationHistoryMode, NavigationOptions, NavigationResult, NavigationStatus, RouteByName, RouteComponentSpecifier, RouteDefinition, RouteLoader, RouteLoaderContext, RouteLocation, RouteManifest, RouteManifestEntry, RouteMatch, RouteMeta, RouteNames, RouteParams, RouteParamsForName, RoutePathParams, RouteQuery, RouteRedirect, RouteResolveOptions, Router, RouterFocusAdapter, RouterHistoryAdapter, RouterHistorySnapshot, RouterMatcher, RouterNavigationOptions, RouterNavigationState, RouterScrollAdapter, SerializedRouteState, Simplify, createFileRoutes, createRouteManifest, createRouter, createRouterMatcher, defineRoute, defineRoutes, hydrateRouteState, routeNameFromFile, routePathFromFile, serializeRouteMatch };
package/dist/index.mjs ADDED
@@ -0,0 +1,468 @@
1
+ //#region src/runtime.ts
2
+ const ROUTER_ERROR = {
3
+ duplicateName: "VIZE_ROUTER_DUPLICATE_NAME",
4
+ duplicatePath: "VIZE_ROUTER_DUPLICATE_PATH",
5
+ invalidComponent: "VIZE_ROUTER_COMPONENT_NOT_VUE",
6
+ invalidPath: "VIZE_ROUTER_INVALID_PATH",
7
+ missingParam: "VIZE_ROUTER_MISSING_PARAM",
8
+ unknownRoute: "VIZE_ROUTER_UNKNOWN_ROUTE"
9
+ };
10
+ /** Define a literal route table and fail closed on duplicate or unsupported rows. */
11
+ function defineRoutes(routes) {
12
+ validateRoutes(routes);
13
+ return routes;
14
+ }
15
+ /** Define one route record while preserving loader param inference from its path. */
16
+ function defineRoute(route) {
17
+ validateRoutes([route]);
18
+ return route;
19
+ }
20
+ /** Create an SSR-safe matcher and typed URL resolver for a route table. */
21
+ function createRouterMatcher(routes) {
22
+ validateRoutes(routes);
23
+ const compiled = routes.map((route) => ({
24
+ route,
25
+ ...compilePath(route.path)
26
+ }));
27
+ return {
28
+ routes,
29
+ match(input) {
30
+ const url = toUrl(input);
31
+ for (const candidate of compiled) {
32
+ const matched = candidate.pattern.exec(url.pathname);
33
+ if (!matched) continue;
34
+ const params = Object.fromEntries(candidate.params.flatMap((name, index) => {
35
+ const value = matched[index + 1];
36
+ return value == null ? [] : [[name, decodeURIComponent(value)]];
37
+ }));
38
+ return {
39
+ route: candidate.route,
40
+ name: candidate.route.name,
41
+ pathname: url.pathname,
42
+ params,
43
+ query: parseQuery(url.searchParams),
44
+ hash: url.hash.startsWith("#") ? url.hash.slice(1) : url.hash,
45
+ meta: candidate.route.meta ?? {}
46
+ };
47
+ }
48
+ },
49
+ manifest: () => createRouteManifest(routes),
50
+ resolve(name, params, options) {
51
+ const route = routes.find((candidate) => candidate.name === name);
52
+ if (!route) throw new Error(`[${ROUTER_ERROR.unknownRoute}] Unknown route ${String(name)}`);
53
+ return `${buildPath(route.path, params)}${stringifyQuery(options?.query)}${stringifyHash(options?.hash)}`;
54
+ }
55
+ };
56
+ }
57
+ /** Emit serializable metadata for generators, docs, and devtools. */
58
+ function createRouteManifest(routes) {
59
+ validateRoutes(routes);
60
+ return {
61
+ schemaVersion: 1,
62
+ routes: routes.map((route) => ({
63
+ name: route.name,
64
+ path: route.path,
65
+ component: route.component,
66
+ meta: route.meta ?? {},
67
+ ...route.errorComponent === void 0 ? {} : { errorComponent: route.errorComponent }
68
+ }))
69
+ };
70
+ }
71
+ /** Serialize a route match for SSR handoff. */
72
+ function serializeRouteMatch(match, data) {
73
+ return {
74
+ schemaVersion: 1,
75
+ name: match.name,
76
+ pathname: match.pathname,
77
+ params: match.params,
78
+ query: match.query,
79
+ hash: match.hash,
80
+ ...data === void 0 ? {} : { data }
81
+ };
82
+ }
83
+ /**
84
+ * Reconcile SSR state with the client table, optionally checking the activation URL.
85
+ * Returns undefined for stale or malformed state; integrations must match/load afresh.
86
+ */
87
+ function hydrateRouteState(routes, state, input) {
88
+ if (!state || state.schemaVersion !== 1 || typeof state.name !== "string" || typeof state.pathname !== "string" || !state.pathname.startsWith("/") || state.pathname.startsWith("//") || typeof state.hash !== "string" || !isRecord(state.params) || !isRecord(state.query) || !Object.values(state.params).every((value) => value === void 0 || typeof value === "string") || !Object.values(state.query).every((value) => typeof value === "string" || Array.isArray(value) && value.every((item) => typeof item === "string"))) return void 0;
89
+ const matcher = createRouterMatcher(routes);
90
+ try {
91
+ const match = matcher.match(state.pathname);
92
+ const params = Object.fromEntries(Object.entries(state.params).filter(([, value]) => value !== void 0));
93
+ if (!match || match.name !== state.name || match.pathname !== state.pathname || !sameRecord(match.params, params)) return void 0;
94
+ if (input !== void 0) {
95
+ const current = matcher.match(input);
96
+ if (!current || current.name !== match.name || current.pathname !== match.pathname || current.hash !== state.hash || !sameRecord(current.query, state.query)) return void 0;
97
+ }
98
+ return {
99
+ ...match,
100
+ query: state.query,
101
+ hash: state.hash
102
+ };
103
+ } catch (error) {
104
+ if (error instanceof URIError || error instanceof TypeError) return void 0;
105
+ throw error;
106
+ }
107
+ }
108
+ function isRecord(value) {
109
+ return value !== null && typeof value === "object" && !Array.isArray(value);
110
+ }
111
+ function sameRecord(left, right) {
112
+ const entries = Object.entries(left);
113
+ return entries.length === Object.keys(right).length && entries.every(([key, value]) => {
114
+ if (!Object.hasOwn(right, key)) return false;
115
+ const other = right[key];
116
+ return Array.isArray(value) ? Array.isArray(other) && value.length === other.length && value.every((item, index) => item === other[index]) : value === other;
117
+ });
118
+ }
119
+ function validateRoutes(routes) {
120
+ const names = /* @__PURE__ */ new Set();
121
+ const paths = /* @__PURE__ */ new Set();
122
+ for (const route of routes) {
123
+ if (!route.path.startsWith("/")) throw new Error(`[${ROUTER_ERROR.invalidPath}] Route ${route.name} path must start with /`);
124
+ if (!route.component.endsWith(".vue")) throw new Error(`[${ROUTER_ERROR.invalidComponent}] Route ${route.name} must use a .vue component`);
125
+ if (route.errorComponent !== void 0 && !route.errorComponent.endsWith(".vue")) throw new Error(`[${ROUTER_ERROR.invalidComponent}] Route ${route.name} error boundary must use a .vue component`);
126
+ if (names.has(route.name)) throw new Error(`[${ROUTER_ERROR.duplicateName}] Duplicate route name ${route.name}`);
127
+ if (paths.has(route.path)) throw new Error(`[${ROUTER_ERROR.duplicatePath}] Duplicate route path ${route.path}`);
128
+ names.add(route.name);
129
+ paths.add(route.path);
130
+ }
131
+ }
132
+ function compilePath(pathPattern) {
133
+ if (pathPattern === "/") return {
134
+ pattern: /^\/$/,
135
+ params: []
136
+ };
137
+ const params = [];
138
+ const source = pathPattern.split("/").filter(Boolean).map((segment) => {
139
+ if (segment.startsWith(":")) {
140
+ const optional = segment.endsWith("?");
141
+ const name = segment.slice(1, optional ? -1 : void 0);
142
+ params.push(name);
143
+ return optional ? "(?:/([^/]+))?" : "/([^/]+)";
144
+ }
145
+ if (segment.startsWith("*")) {
146
+ const name = segment.slice(1);
147
+ params.push(name);
148
+ return "/(.+)";
149
+ }
150
+ return `/${escapeRegExp(segment)}`;
151
+ }).join("");
152
+ return {
153
+ pattern: new RegExp(`^${source}$`),
154
+ params
155
+ };
156
+ }
157
+ function buildPath(pathPattern, params) {
158
+ if (pathPattern === "/") return "/";
159
+ return `/${pathPattern.split("/").filter(Boolean).flatMap((segment) => {
160
+ if (!segment.startsWith(":") && !segment.startsWith("*")) return [segment];
161
+ const optional = segment.endsWith("?");
162
+ const name = segment.slice(segment.startsWith(":") ? 1 : 1, optional ? -1 : void 0);
163
+ const value = params[name];
164
+ if (value == null) {
165
+ if (optional) return [];
166
+ throw new Error(`[${ROUTER_ERROR.missingParam}] Missing route param ${name}`);
167
+ }
168
+ return [segment.startsWith("*") ? value.split("/").map(encodeURIComponent).join("/") : encodeURIComponent(value)];
169
+ }).join("/")}`;
170
+ }
171
+ function toUrl(input) {
172
+ return input instanceof URL ? input : new URL(input, "https://vize.local");
173
+ }
174
+ function parseQuery(searchParams) {
175
+ const query = /* @__PURE__ */ new Map();
176
+ for (const [key, value] of searchParams) {
177
+ const current = query.get(key);
178
+ if (current == null) query.set(key, value);
179
+ else if (Array.isArray(current)) current.push(value);
180
+ else query.set(key, [current, value]);
181
+ }
182
+ return Object.fromEntries(query);
183
+ }
184
+ function stringifyQuery(query) {
185
+ if (!query) return "";
186
+ const search = new URLSearchParams();
187
+ for (const [key, value] of Object.entries(query)) {
188
+ if (value == null) continue;
189
+ const values = Array.isArray(value) ? value : [value];
190
+ for (const item of values) search.append(key, String(item));
191
+ }
192
+ const text = search.toString();
193
+ return text === "" ? "" : `?${text}`;
194
+ }
195
+ function stringifyHash(hash) {
196
+ if (!hash) return "";
197
+ return hash.startsWith("#") ? hash : `#${hash}`;
198
+ }
199
+ function escapeRegExp(value) {
200
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
201
+ }
202
+ //#endregion
203
+ //#region src/file-routes.ts
204
+ /** Generate route records from source-owned `.vue` route modules. */
205
+ function createFileRoutes(sources) {
206
+ return defineRoutes(sources.map((source) => ({
207
+ name: source.name ?? routeNameFromFile(source.file),
208
+ path: source.path ?? routePathFromFile(source.file),
209
+ component: source.file,
210
+ ...source.meta === void 0 ? {} : { meta: source.meta },
211
+ ...source.loader === void 0 ? {} : { loader: source.loader },
212
+ ...source.redirect === void 0 ? {} : { redirect: source.redirect },
213
+ ...source.errorComponent === void 0 ? {} : { errorComponent: source.errorComponent }
214
+ })));
215
+ }
216
+ /** Convert `routes/users/[id].vue` into `/users/:id`. */
217
+ function routePathFromFile(file) {
218
+ const segments = routeSegments(file).flatMap((segment) => {
219
+ if (segment === "index") return [];
220
+ const rest = segment.match(/^\[\.\.\.([^\]]+)\]$/u);
221
+ if (rest?.[1]) return [`*${rest[1]}`];
222
+ const optional = segment.match(/^\[\[([^\]]+)\]\]$/u);
223
+ if (optional?.[1]) return [`:${optional[1]}?`];
224
+ const dynamic = segment.match(/^\[([^\]]+)\]$/u);
225
+ if (dynamic?.[1]) return [`:${dynamic[1]}`];
226
+ return [segment];
227
+ });
228
+ return segments.length === 0 ? "/" : `/${segments.join("/")}`;
229
+ }
230
+ /** Produce a stable manifest name from a file route. */
231
+ function routeNameFromFile(file) {
232
+ const segments = routeSegments(file).filter((segment) => segment !== "index").map((segment) => segment.replace(/^\[+\.\.\./u, "").replace(/^\[+/u, "").replace(/\]+$/u, ""));
233
+ return segments.length === 0 ? "index" : segments.join("-");
234
+ }
235
+ function routeSegments(file) {
236
+ const normalized = file.replaceAll("\\", "/").replace(/^\.\//u, "");
237
+ const routeRoot = normalized.lastIndexOf("/routes/");
238
+ return (routeRoot === -1 ? normalized.replace(/^routes\//u, "") : normalized.slice(routeRoot + 8)).slice(0, -4).split("/").filter(Boolean);
239
+ }
240
+ //#endregion
241
+ //#region src/navigation.ts
242
+ const MAX_REDIRECTS = 16;
243
+ /**
244
+ * Create an SSR-safe navigation lifecycle around a literal route table.
245
+ *
246
+ * Browser history, focus, scrolling, and announcements are explicit adapters;
247
+ * importing or constructing a router never reads browser globals.
248
+ */
249
+ function createRouter(routes, options = {}) {
250
+ const matcher = createRouterMatcher(routes);
251
+ const guards = /* @__PURE__ */ new Set();
252
+ const listeners = /* @__PURE__ */ new Set();
253
+ let revision = 0;
254
+ let active;
255
+ let state = {
256
+ status: "idle",
257
+ current: options.initial === void 0 ? void 0 : matcher.match(options.initial),
258
+ pending: void 0,
259
+ data: void 0,
260
+ error: void 0
261
+ };
262
+ const publish = (next) => {
263
+ state = next;
264
+ for (const listener of listeners) listener(state);
265
+ };
266
+ return {
267
+ matcher,
268
+ state: () => state,
269
+ beforeEach(guard) {
270
+ guards.add(guard);
271
+ return () => guards.delete(guard);
272
+ },
273
+ subscribe(listener) {
274
+ listeners.add(listener);
275
+ return () => listeners.delete(listener);
276
+ },
277
+ cancel(reason) {
278
+ active?.abort(reason ?? /* @__PURE__ */ new Error("Navigation cancelled"));
279
+ },
280
+ async navigate(location, navigationOptions = {}) {
281
+ const navigationRevision = ++revision;
282
+ active?.abort(/* @__PURE__ */ new Error("Navigation superseded"));
283
+ const controller = new AbortController();
284
+ active = controller;
285
+ const initial = resolveLocation(matcher, location);
286
+ if (!initial) {
287
+ const result = {
288
+ status: "error",
289
+ error: /* @__PURE__ */ new Error(`No route matches ${locationLabel(location)}`)
290
+ };
291
+ publish({
292
+ ...state,
293
+ status: "error",
294
+ pending: void 0,
295
+ error: result.error
296
+ });
297
+ return result;
298
+ }
299
+ let resolved = initial;
300
+ publish({
301
+ ...state,
302
+ status: "pending",
303
+ pending: resolved.match,
304
+ error: void 0
305
+ });
306
+ let redirected = false;
307
+ try {
308
+ for (let redirectCount = 0; redirectCount <= (options.maxRedirects ?? MAX_REDIRECTS); redirectCount += 1) {
309
+ if (isStale(navigationRevision, revision, controller.signal)) return finishInterrupted(navigationRevision, revision, state, publish);
310
+ const guarded = await runGuards(guards, navigationContext(state.current, resolved.match, controller.signal));
311
+ if (guarded === false) {
312
+ publish({
313
+ ...state,
314
+ status: "cancelled",
315
+ pending: void 0,
316
+ error: void 0
317
+ });
318
+ return {
319
+ status: "cancelled",
320
+ match: resolved.match
321
+ };
322
+ }
323
+ if (isRouteLocation(guarded)) {
324
+ const next = resolveLocation(matcher, guarded);
325
+ if (!next) throw new Error(`Redirect does not match a route: ${locationLabel(guarded)}`);
326
+ resolved = next;
327
+ redirected = true;
328
+ publish({
329
+ ...state,
330
+ status: "pending",
331
+ pending: resolved.match,
332
+ error: void 0
333
+ });
334
+ continue;
335
+ }
336
+ const redirect = resolved.match.route.redirect;
337
+ if (redirect !== void 0) {
338
+ const nextLocation = typeof redirect === "function" ? await redirect(loaderContext(resolved.match, controller.signal)) : redirect;
339
+ const next = resolveLocation(matcher, nextLocation);
340
+ if (!next) throw new Error(`Redirect does not match a route: ${String(nextLocation)}`);
341
+ resolved = next;
342
+ redirected = true;
343
+ publish({
344
+ ...state,
345
+ status: "pending",
346
+ pending: resolved.match,
347
+ error: void 0
348
+ });
349
+ continue;
350
+ }
351
+ const data = resolved.match.route.loader === void 0 ? void 0 : await resolved.match.route.loader(loaderContext(resolved.match, controller.signal));
352
+ if (isStale(navigationRevision, revision, controller.signal)) return finishInterrupted(navigationRevision, revision, state, publish);
353
+ await commitNavigation(options, resolved.url, resolved.match, navigationOptions);
354
+ publish({
355
+ status: "success",
356
+ current: resolved.match,
357
+ pending: void 0,
358
+ data,
359
+ error: void 0
360
+ });
361
+ return {
362
+ status: "success",
363
+ match: resolved.match,
364
+ data,
365
+ redirected
366
+ };
367
+ }
368
+ throw new Error(`Redirect limit exceeded (${options.maxRedirects ?? MAX_REDIRECTS})`);
369
+ } catch (error) {
370
+ if (isStale(navigationRevision, revision, controller.signal)) return finishInterrupted(navigationRevision, revision, state, publish);
371
+ publish({
372
+ ...state,
373
+ status: "error",
374
+ pending: void 0,
375
+ error
376
+ });
377
+ return {
378
+ status: "error",
379
+ match: resolved.match,
380
+ error,
381
+ ...resolved.match.route.errorComponent === void 0 ? {} : { errorComponent: resolved.match.route.errorComponent }
382
+ };
383
+ } finally {
384
+ if (active === controller) active = void 0;
385
+ }
386
+ }
387
+ };
388
+ }
389
+ function resolveLocation(matcher, location) {
390
+ const url = isNamedLocation(location) ? matcher.resolve(location.name, location.params, {
391
+ ...location.query === void 0 ? {} : { query: location.query },
392
+ ...location.hash === void 0 ? {} : { hash: location.hash }
393
+ }) : location instanceof URL ? `${location.pathname}${location.search}${location.hash}` : location;
394
+ const match = matcher.match(url);
395
+ return match === void 0 ? void 0 : {
396
+ match,
397
+ url
398
+ };
399
+ }
400
+ function isNamedLocation(location) {
401
+ return typeof location === "object" && !(location instanceof URL) && "name" in location;
402
+ }
403
+ function locationLabel(location) {
404
+ if (typeof location === "string") return location;
405
+ if (location instanceof URL) return location.href;
406
+ return location.name;
407
+ }
408
+ function isRouteLocation(value) {
409
+ return typeof value === "string" || value instanceof URL || value !== null && typeof value === "object" && "name" in value;
410
+ }
411
+ function navigationContext(from, to, signal) {
412
+ return {
413
+ from,
414
+ to,
415
+ signal
416
+ };
417
+ }
418
+ function loaderContext(match, signal) {
419
+ return {
420
+ pathname: match.pathname,
421
+ params: match.params,
422
+ query: match.query,
423
+ hash: match.hash,
424
+ signal
425
+ };
426
+ }
427
+ async function runGuards(guards, context) {
428
+ for (const guard of guards) {
429
+ const result = await guard(context);
430
+ if (result === false || isRouteLocation(result)) return result;
431
+ }
432
+ }
433
+ function isStale(current, revision, signal) {
434
+ return current !== revision || signal.aborted;
435
+ }
436
+ function finishInterrupted(current, revision, state, publish) {
437
+ const status = current === revision ? "cancelled" : "stale";
438
+ if (current === revision) publish({
439
+ ...state,
440
+ status,
441
+ pending: void 0,
442
+ error: void 0
443
+ });
444
+ return {
445
+ status,
446
+ ...state.pending === void 0 ? {} : { match: state.pending }
447
+ };
448
+ }
449
+ async function commitNavigation(options, url, match, navigation) {
450
+ const history = navigation.history ?? "push";
451
+ const leaving = {
452
+ ...options.scroll?.capture === void 0 ? {} : { scroll: options.scroll.capture() },
453
+ ...options.focus?.capture === void 0 ? {} : { focus: options.focus.capture() }
454
+ };
455
+ if (history === "push") await options.history?.push(url, leaving);
456
+ if (history === "replace") await options.history?.replace(url, leaving);
457
+ if (history === "pop") {
458
+ if (navigation.scroll !== void 0) await options.scroll?.restore?.(navigation.scroll);
459
+ if (navigation.focus !== void 0) await options.focus?.restore?.(navigation.focus);
460
+ } else {
461
+ await options.scroll?.top?.(match);
462
+ await options.focus?.focusMain?.(match);
463
+ }
464
+ const title = typeof match.meta?.title === "string" ? match.meta.title : match.name;
465
+ await options.announce?.(title, match);
466
+ }
467
+ //#endregion
468
+ export { createFileRoutes, createRouteManifest, createRouter, createRouterMatcher, defineRoute, defineRoutes, hydrateRouteState, routeNameFromFile, routePathFromFile, serializeRouteMatch };
package/package.json CHANGED
@@ -1,6 +1,65 @@
1
1
  {
2
2
  "name": "@vizejs/router",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.714.0",
4
+ "description": "Strongly typed SSR router foundations for Vize applications",
5
+ "keywords": [
6
+ "router",
7
+ "ssr",
8
+ "type-safe",
9
+ "vize",
10
+ "vue"
11
+ ],
12
+ "homepage": "https://github.com/ubugeeei-prod/vize",
13
+ "bugs": {
14
+ "url": "https://github.com/ubugeeei-prod/vize/issues"
15
+ },
16
+ "license": "MIT",
17
+ "repository": {
18
+ "type": "git",
19
+ "url": "https://github.com/ubugeeei-prod/vize.git",
20
+ "directory": "npm/compose/router"
21
+ },
22
+ "files": [
23
+ "dist",
24
+ "README.md"
25
+ ],
26
+ "type": "module",
27
+ "sideEffects": false,
28
+ "main": "./dist/index.mjs",
29
+ "types": "./dist/index.d.mts",
30
+ "exports": {
31
+ ".": {
32
+ "types": "./dist/index.d.mts",
33
+ "import": "./dist/index.mjs",
34
+ "default": "./dist/index.mjs"
35
+ }
36
+ },
37
+ "publishConfig": {
38
+ "access": "public"
39
+ },
40
+ "devDependencies": {
41
+ "@types/node": "25.9.2",
42
+ "tsdown": "0.22.14",
43
+ "typescript": "6.0.3",
44
+ "vite-plus": "0.1.24"
45
+ },
46
+ "engines": {
47
+ "node": ">=22"
48
+ },
49
+ "metadata": {
50
+ "vize": {
51
+ "stability": "experimental",
52
+ "stabilityDocs": "https://github.com/ubugeeei-prod/vize/blob/main/docs/content/stability.md#package-support-tiers"
53
+ }
54
+ },
55
+ "scripts": {
56
+ "build": "vp pack",
57
+ "bench": "vp exec node --test src/benchmark.test.ts",
58
+ "dev": "vp pack --watch",
59
+ "test": "vp exec node --test 'src/**/*.test.ts'",
60
+ "check": "vp check src vite.config.ts && vp exec tsc --noEmit -p tsconfig.json",
61
+ "check:fix": "vp check --fix src vite.config.ts",
62
+ "check:types": "vp exec tsc --noEmit -p tsconfig.json",
63
+ "fmt": "vp fmt --write src vite.config.ts"
64
+ }
6
65
  }