@mindees/router 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.
@@ -0,0 +1,217 @@
1
+ import { RouterHistory, RouterLocation, createHref } from "./history.js";
2
+ import { LoaderData, LoaderDepsFn, LoaderFn } from "./data.js";
3
+ import { HasPathParams, PathParams } from "./pattern.js";
4
+ import { StandardSchemaV1 } from "./standard-schema.js";
5
+ import { QueryValue } from "./search.js";
6
+ import { Component, MindeesNode } from "@mindees/core";
7
+
8
+ //#region src/router.d.ts
9
+ /**
10
+ * Props a route's component receives from {@link createRouterView}. `params` and
11
+ * `search` are **reactive accessors** (read them in a reactive scope so a
12
+ * same-route param change updates in place, no re-mount); `children` is the
13
+ * matched **child route** — render it wherever the nested route should appear
14
+ * (the "outlet"). See ADR-0004.
15
+ */
16
+ interface RouteComponentProps {
17
+ /** The router instance (for `navigate`/`select`). */
18
+ router: Router;
19
+ /** Reactive accessor for the current (merged) path params. */
20
+ params: () => Record<string, string>;
21
+ /** Reactive accessor for the current search params. */
22
+ search: () => Record<string, unknown>;
23
+ /** Reactive accessor for this route's loader state (`idle` when it has no loader). */
24
+ data: () => LoaderData;
25
+ /** The matched child route (the outlet); render it to show nested routes. */
26
+ children: MindeesNode;
27
+ }
28
+ /** A route: a path pattern, an optional component, optional search schema, and optional children. */
29
+ interface RouteRecord {
30
+ /**
31
+ * The path pattern. At the top level this is absolute (`/posts/:postId`); a
32
+ * nested route's path is **relative to its parent** (`settings`), and a child
33
+ * with `''` or `'/'` is the parent's **index** route.
34
+ */
35
+ path: string;
36
+ /** The component to render for this route. A component-less route passes its child through. */
37
+ component?: Component<RouteComponentProps>;
38
+ /**
39
+ * A Standard Schema validating this route's search params. Its **output must
40
+ * be object-shaped** (search params are a record), so a non-object schema like
41
+ * `z.string()` is rejected at compile time and validated results need no cast.
42
+ *
43
+ * Note: only the **matched leaf** route's `searchSchema` is applied — a schema
44
+ * on a parent/layout route is currently not used (search is global to the URL;
45
+ * the leaf governs it). Per-route end-to-end `InferOutput` typing through
46
+ * `Router.search()` arrives with the typed route registry (a later phase).
47
+ */
48
+ searchSchema?: StandardSchemaV1<unknown, Record<string, unknown>>;
49
+ /** Data loader for this route (sync or async); result is exposed via `data()`. */
50
+ loader?: LoaderFn;
51
+ /** Declares which inputs key this route's loader cache (e.g. specific search params). */
52
+ loaderDeps?: LoaderDepsFn;
53
+ /** Stale-while-revalidate window in ms; within it a successful load is reused. */
54
+ staleTime?: number;
55
+ /** Nested child routes (their `path` is relative to this route). */
56
+ children?: readonly RouteRecord[];
57
+ /** Arbitrary route metadata. */
58
+ meta?: Readonly<Record<string, unknown>>;
59
+ }
60
+ /** The result of matching a location against the route table. */
61
+ interface RouteMatch {
62
+ /** The matched route record. */
63
+ route: RouteRecord;
64
+ /** The matched pathname. */
65
+ pathname: string;
66
+ /** Path params extracted from the pattern. */
67
+ params: Record<string, string>;
68
+ /** Search params — validated output when a schema is present, else the raw parse. */
69
+ search: Record<string, unknown>;
70
+ /** The raw parsed query, before schema validation. */
71
+ searchRaw: Record<string, string | string[]>;
72
+ /** Search-validation issues, present only when validation failed. */
73
+ issues?: ReadonlyArray<StandardSchemaV1.Issue>;
74
+ }
75
+ /** The router's reactive state — a snapshot read through fine-grained signals. */
76
+ interface RouterState {
77
+ /** The current location. */
78
+ location: RouterLocation;
79
+ /**
80
+ * The matched route **chain**, root → leaf (one entry per nesting level), or
81
+ * empty when nothing matched. Drives nested rendering ({@link createRouterView}).
82
+ */
83
+ matches: readonly RouteMatch[];
84
+ /** The matched **leaf** route, or `null` if nothing matched. */
85
+ match: RouteMatch | null;
86
+ /** Convenience: the current pathname. */
87
+ pathname: string;
88
+ /** Convenience: the current path params (`{}` when unmatched). */
89
+ params: Record<string, string>;
90
+ /** Convenience: the current search params (`{}` when unmatched). */
91
+ search: Record<string, unknown>;
92
+ }
93
+ /** Options that apply to any navigation. */
94
+ interface NavigateOptions {
95
+ /** Replace the current history entry instead of pushing a new one. */
96
+ replace?: boolean;
97
+ /** Navigate even if the target equals the current location (skips the idempotent no-op). */
98
+ force?: boolean;
99
+ /**
100
+ * Wrap the update in `document.startViewTransition` when available (web only).
101
+ * Overrides the router-level `viewTransitions` default. No-op outside a DOM.
102
+ */
103
+ viewTransition?: boolean;
104
+ }
105
+ /**
106
+ * A navigation guard. Run before each navigation with the target and current
107
+ * hrefs. Return `false` to cancel, a string to redirect, or nothing to proceed.
108
+ */
109
+ type BeforeNavigate = (to: string, from: string) => boolean | string | undefined;
110
+ /** The params/search/hash carried by a structured target, with params required iff the pattern has them. */
111
+ type NavExtras<P extends string> = {
112
+ /** Search params to serialize into the query string. */search?: Record<string, QueryValue>; /** A hash fragment (with or without a leading `#`). */
113
+ hash?: string;
114
+ } & (HasPathParams<P> extends true ? {
115
+ params: PathParams<P>;
116
+ } : {
117
+ params?: Record<string, never>;
118
+ });
119
+ /**
120
+ * A fully-typed structured navigation target. `to` is a path pattern; `params`
121
+ * is **required** when the pattern has dynamic segments and forbidden otherwise
122
+ * — inferred from `to` with zero codegen.
123
+ *
124
+ * The param requirement is enforced when `to` is a **string literal**. If `to`
125
+ * is a widened `string` (e.g. read from a variable typed `string`), its segments
126
+ * can't be inferred, so `params` is not type-checked — a missing required param
127
+ * then throws {@link RouterError} (`MISSING_PARAM`) at runtime.
128
+ *
129
+ * @example
130
+ * router.navigate({ to: '/posts/:postId', params: { postId: '42' } })
131
+ * router.navigate({ to: '/about' }) // no params allowed
132
+ */
133
+ type NavTarget<P extends string> = {
134
+ to: P;
135
+ } & NavExtras<P>;
136
+ /** Options for {@link createRouter}. */
137
+ interface CreateRouterOptions {
138
+ /** The route table. Order is irrelevant — routes are matched most-specific first. */
139
+ routes: readonly RouteRecord[];
140
+ /** The history adapter. Defaults to an in-memory history at `/`. */
141
+ history?: RouterHistory;
142
+ /** A guard run before each navigation (cancel with `false`, redirect with a string). */
143
+ beforeNavigate?: BeforeNavigate;
144
+ /** Wrap navigations in `document.startViewTransition` by default (web only). */
145
+ viewTransitions?: boolean;
146
+ }
147
+ /** A live router instance. */
148
+ interface Router {
149
+ /** The full reactive state snapshot. */
150
+ state(): RouterState;
151
+ /** The current location. */
152
+ location(): RouterLocation;
153
+ /** The matched route chain (root → leaf), or empty when unmatched. */
154
+ matches(): readonly RouteMatch[];
155
+ /** The current leaf match, or `null`. */
156
+ match(): RouteMatch | null;
157
+ /** The current path params (`{}` when unmatched). */
158
+ params(): Record<string, string>;
159
+ /** The current search params (`{}` when unmatched). */
160
+ search(): Record<string, unknown>;
161
+ /**
162
+ * Subscribe to a derived slice of router state with re-render isolation. The
163
+ * returned accessor (a memo) only changes when `selector(state)` changes under
164
+ * `equals` (default `Object.is`) — the same selector-isolation technique as
165
+ * core's Phase 2 `createProvider` (a computed memo over an `equals:false`
166
+ * source), applied to route state.
167
+ */
168
+ select<S>(selector: (state: RouterState) => S, equals?: (a: S, b: S) => boolean): () => S;
169
+ /** Navigate to a typed target or a (possibly relative) href string. */
170
+ navigate<P extends string>(target: string | NavTarget<P>, options?: NavigateOptions): void;
171
+ /** Reactively read a match's loader state (`idle` when the route has no loader). */
172
+ loaderData(match: RouteMatch): LoaderData;
173
+ /** Re-run the current chain's loaders (marks their cached data stale first). */
174
+ invalidate(): void;
175
+ /**
176
+ * Run a target href's loaders WITHOUT navigating (intent prefetch). A
177
+ * still-in-flight preload is aborted if you then navigate to a *different*
178
+ * route; once it settles it warms the cache for that route's next visit
179
+ * (subject to `staleTime`).
180
+ */
181
+ preload(to: string): void;
182
+ /**
183
+ * Replace the route table and re-match the current location **in place** — the
184
+ * location is preserved (dynamic reconfiguration without state reset).
185
+ */
186
+ setRoutes(routes: readonly RouteRecord[]): void;
187
+ /**
188
+ * The active route table, as provided to {@link createRouter} / {@link Router.setRoutes}
189
+ * (the nested tree, in insertion order). Matching internally flattens the tree
190
+ * and orders leaves by specificity (static > dynamic > catch-all); that
191
+ * precedence is an implementation detail, not the shape returned here.
192
+ */
193
+ routes(): readonly RouteRecord[];
194
+ /** The underlying history adapter. */
195
+ readonly history: RouterHistory;
196
+ /** Tear down the router's reactive scope and history subscription. */
197
+ dispose(): void;
198
+ }
199
+ /**
200
+ * Resolve a (possibly relative) path against a base pathname. Absolute paths
201
+ * (leading `/`) ignore the base; `.`/`..` segments are applied against it,
202
+ * treating the base pathname as a directory.
203
+ *
204
+ * @example
205
+ * resolvePath('/a/b', '/x') // '/a/b'
206
+ * resolvePath('edit', '/posts/1') // '/posts/1/edit'
207
+ * resolvePath('../', '/posts/1') // '/posts'
208
+ */
209
+ declare function resolvePath(to: string, from: string): string;
210
+ /**
211
+ * Create a router over a route table. State is reactive (signals); call
212
+ * {@link Router.dispose} to tear it down.
213
+ */
214
+ declare function createRouter(options: CreateRouterOptions): Router;
215
+ //#endregion
216
+ export { BeforeNavigate, CreateRouterOptions, NavTarget, NavigateOptions, RouteComponentProps, RouteMatch, RouteRecord, Router, RouterState, createRouter, resolvePath };
217
+ //# sourceMappingURL=router.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"router.d.ts","names":[],"sources":["../src/router.ts"],"mappings":";;;;;;;;;;;;;;;UA0DiB,mBAAA;EAUf;EARA,MAAA,EAAQ,MAAA;EAQa;EANrB,MAAA,QAAc,MAAA;EAUC;EARf,MAAA,QAAc,MAAA;;EAEd,IAAA,QAAY,UAAA;EAcA;EAZZ,QAAA,EAAU,WAAA;AAAA;;UAIK,WAAA;EA2BK;;;;;EArBpB,IAAA;EAEA;EAAA,SAAA,GAAY,SAAA,CAAU,mBAAA;EAAA;;;;;;;;;;EAWtB,YAAA,GAAe,gBAAA,UAA0B,MAAA;EAUzC;EARA,MAAA,GAAS,QAAA;EAQO;EANhB,UAAA,GAAa,YAAA;EAMS;EAJtB,SAAA;EAQyB;EANzB,QAAA,YAAoB,WAAA;EAQb;EANP,IAAA,GAAO,QAAA,CAAS,MAAA;AAAA;;UAID,UAAA;EAYN;EAVT,KAAA,EAAO,WAAA;EAUe;EARtB,QAAA;EAFO;EAIP,MAAA,EAAQ,MAAA;EAAR;EAEA,MAAA,EAAQ,MAAA;EAAR;EAEA,SAAA,EAAW,MAAA;EAAX;EAEA,MAAA,GAAS,aAAA,CAAc,gBAAA,CAAiB,KAAA;AAAA;;UAIzB,WAAA;EAJyB;EAMxC,QAAA,EAAU,cAAA;EANmC;AAI/C;;;EAOE,OAAA,WAAkB,UAAA;EAAA;EAElB,KAAA,EAAO,UAAA;EAIC;EAFR,QAAA;EAIc;EAFd,MAAA,EAAQ,MAAA;EAXR;EAaA,MAAA,EAAQ,MAAA;AAAA;;UAQO,eAAA;EAdR;EAgBP,OAAA;EAZA;EAcA,KAAA;EAZA;;;AAAc;EAiBd,cAAA;AAAA;;;;;KAOU,cAAA,IAAkB,EAAA,UAAY,IAAY;;KAGjD,SAAA;EAHO,wDAKV,MAAA,GAAS,MAAA,SAAe,UAAA;EAExB,IAAA;AAAA,KACG,aAAA,CAAc,CAAA;EAAoB,MAAA,EAAQ,UAAA,CAAW,CAAA;AAAA;EAAS,MAAA,GAAS,MAAA;AAAA;;;;;;;;;;;;;;;KAgBhE,SAAA;EAAgC,EAAA,EAAI,CAAA;AAAA,IAAM,SAAA,CAAU,CAAA;;UAe/C,mBAAA;EA/B2D;EAiC1E,MAAA,WAAiB,WAAA;EAjC+D;EAmChF,OAAA,GAAU,aAAA;EAnBS;EAqBnB,cAAA,GAAiB,cAAA;EArB6B;EAuB9C,eAAA;AAAA;;UAIe,MAAA;EA3BK;EA6BpB,KAAA,IAAS,WAAA;EA7BqC;EA+B9C,QAAA,IAAY,cAAA;EA/BkD;EAiC9D,OAAA,aAAoB,UAAA;EAjC2C;EAmC/D,KAAA,IAAS,UAAA;EApByB;EAsBlC,MAAA,IAAU,MAAA;EApBO;EAsBjB,MAAA,IAAU,MAAA;EAlBO;;;;;;;EA0BjB,MAAA,IAAU,QAAA,GAAW,KAAA,EAAO,WAAA,KAAgB,CAAA,EAAG,MAAA,IAAU,CAAA,EAAG,CAAA,EAAG,CAAA,EAAG,CAAA,qBAAsB,CAAA;EA1BvE;EA4BjB,QAAA,mBAA2B,MAAA,WAAiB,SAAA,CAAU,CAAA,GAAI,OAAA,GAAU,eAAA;EA1BrD;EA4Bf,UAAA,CAAW,KAAA,EAAO,UAAA,GAAa,UAAA;EAxBhB;EA0Bf,UAAA;EA1BqB;;;;;;EAiCrB,OAAA,CAAQ,EAAA;EAboB;;;;EAkB5B,SAAA,CAAU,MAAA,WAAiB,WAAA;EAhB2B;;;;;;EAuBtD,MAAA,aAAmB,WAAA;EAED;EAAA,SAAT,OAAA,EAAS,aAAA;EAAa;EAE/B,OAAA;AAAA;;;;;;;;;;;iBAwGc,WAAA,CAAY,EAAA,UAAY,IAAY;;;;;iBA+CpC,YAAA,CAAa,OAAA,EAAS,mBAAA,GAAsB,MAAM"}
package/dist/router.js ADDED
@@ -0,0 +1,263 @@
1
+ import { buildPath, compareSpecificity, matchPattern } from "./pattern.js";
2
+ import { parseQuery, safeValidateSearch, stringifyQuery } from "./search.js";
3
+ import { createHref, createMemoryHistory, parseHref } from "./history.js";
4
+ import { createLoaderManager } from "./data.js";
5
+ import { computed, createRoot, effect, signal } from "@mindees/core";
6
+ //#region src/router.ts
7
+ /**
8
+ * The Quantum router — signals-native routing state with typed, validated
9
+ * navigation and re-render isolation.
10
+ *
11
+ * Router state (location, params, search, matched route) is modeled as the
12
+ * fine-grained signal graph from `@mindees/core` (Phase 1 `signal`/`computed`,
13
+ * Phase 2 selector isolation). Consumers read a slice via {@link Router.select}
14
+ * and re-run **only** when that slice changes — no whole-tree re-render on
15
+ * navigation, no global-vs-local hook trap (cf. Expo Router). See ADR-0003.
16
+ *
17
+ * @module
18
+ */
19
+ const EMPTY_PARAMS = Object.freeze({});
20
+ const EMPTY_SEARCH = Object.freeze({});
21
+ const EMPTY_MATCHES = Object.freeze([]);
22
+ /** Join a parent path and a (relative) child path into a normalized full path. */
23
+ function joinPaths(parent, child) {
24
+ const base = parent.endsWith("/") ? parent.slice(0, -1) : parent;
25
+ const rel = child.startsWith("/") ? child.slice(1) : child;
26
+ if (rel.length === 0) return base.length === 0 ? "/" : base;
27
+ return `${base}/${rel}`;
28
+ }
29
+ /**
30
+ * Flatten a (possibly nested) route tree into leaf entries, each carrying its
31
+ * full path and the root→leaf chain of records. A route with children
32
+ * contributes only via its children (add an index child — `path: ''` — to match
33
+ * the parent's own path).
34
+ */
35
+ function flattenRouteTree(routes, parentPath, parentChain) {
36
+ const out = [];
37
+ for (const route of routes) {
38
+ const fullPath = joinPaths(parentPath, route.path);
39
+ const chain = [...parentChain, route];
40
+ if (route.children && route.children.length > 0) out.push(...flattenRouteTree(route.children, fullPath, chain));
41
+ else out.push({
42
+ fullPath,
43
+ chain
44
+ });
45
+ }
46
+ return out;
47
+ }
48
+ /** Flatten + sort a route tree most-specific first (static > dynamic > catch-all). */
49
+ function compileRoutes(routes) {
50
+ return flattenRouteTree(routes, "", []).sort((a, b) => compareSpecificity(a.fullPath, b.fullPath));
51
+ }
52
+ /**
53
+ * Match a location against the compiled route table, returning the matched chain
54
+ * (root → leaf), or an empty array if nothing matched. Search is validated
55
+ * against the **leaf** route's schema and shared across the chain.
56
+ */
57
+ function matchLocation(flat, location) {
58
+ for (const fr of flat) {
59
+ const params = matchPattern(fr.fullPath, location.pathname);
60
+ if (params === null) continue;
61
+ const searchRaw = parseQuery(location.search);
62
+ let search = searchRaw;
63
+ let issues;
64
+ const leaf = fr.chain[fr.chain.length - 1];
65
+ if (leaf?.searchSchema) try {
66
+ const result = safeValidateSearch(leaf.searchSchema, searchRaw);
67
+ if (result.ok) search = result.value;
68
+ else issues = result.issues;
69
+ } catch (err) {
70
+ issues = [{ message: err instanceof Error ? err.message : "search validation failed" }];
71
+ }
72
+ return fr.chain.map((route) => {
73
+ const base = {
74
+ route,
75
+ pathname: location.pathname,
76
+ params,
77
+ search,
78
+ searchRaw
79
+ };
80
+ return issues ? {
81
+ ...base,
82
+ issues
83
+ } : base;
84
+ });
85
+ }
86
+ return EMPTY_MATCHES;
87
+ }
88
+ /**
89
+ * Resolve a (possibly relative) path against a base pathname. Absolute paths
90
+ * (leading `/`) ignore the base; `.`/`..` segments are applied against it,
91
+ * treating the base pathname as a directory.
92
+ *
93
+ * @example
94
+ * resolvePath('/a/b', '/x') // '/a/b'
95
+ * resolvePath('edit', '/posts/1') // '/posts/1/edit'
96
+ * resolvePath('../', '/posts/1') // '/posts'
97
+ */
98
+ function resolvePath(to, from) {
99
+ const stack = to.startsWith("/") ? [] : from.split("/").filter((s) => s.length > 0);
100
+ for (const seg of to.split("/")) {
101
+ if (seg === "" || seg === ".") continue;
102
+ if (seg === "..") stack.pop();
103
+ else stack.push(seg);
104
+ }
105
+ return `/${stack.join("/")}`;
106
+ }
107
+ /** Resolve an href string (which may be relative and carry query/hash) against a location. */
108
+ function resolveHref(to, from) {
109
+ const hasPath = to.length > 0 && to[0] !== "?" && to[0] !== "#";
110
+ let rest = to;
111
+ let hash = "";
112
+ const hashIndex = rest.indexOf("#");
113
+ if (hashIndex !== -1) {
114
+ hash = rest.slice(hashIndex);
115
+ rest = rest.slice(0, hashIndex);
116
+ }
117
+ let search = "";
118
+ const queryIndex = rest.indexOf("?");
119
+ if (queryIndex !== -1) {
120
+ search = rest.slice(queryIndex);
121
+ rest = rest.slice(0, queryIndex);
122
+ }
123
+ return `${hasPath ? resolvePath(rest, from.pathname) : from.pathname}${!hasPath && queryIndex === -1 ? from.search : search}${hash}`;
124
+ }
125
+ /** Build an href from a structured navigation target. */
126
+ function buildHref(target) {
127
+ const pathname = buildPath(target.to, target.params ?? {});
128
+ const query = target.search ? stringifyQuery(target.search) : "";
129
+ let hash = target.hash ?? "";
130
+ if (hash.length > 0 && !hash.startsWith("#")) hash = `#${hash}`;
131
+ return `${pathname}${query ? `?${query}` : ""}${hash}`;
132
+ }
133
+ /**
134
+ * Create a router over a route table. State is reactive (signals); call
135
+ * {@link Router.dispose} to tear it down.
136
+ */
137
+ function createRouter(options) {
138
+ const history = options.history ?? createMemoryHistory();
139
+ let routesSig;
140
+ let flatMemo;
141
+ let locationSig;
142
+ let stateMemo;
143
+ let loaders;
144
+ const dispose = createRoot((disposeRoot) => {
145
+ routesSig = signal(options.routes, { equals: false });
146
+ flatMemo = computed(() => compileRoutes(routesSig()));
147
+ locationSig = signal(history.location(), { equals: false });
148
+ const matchMemo = computed(() => matchLocation(flatMemo(), locationSig()));
149
+ stateMemo = computed(() => {
150
+ const location = locationSig();
151
+ const matches = matchMemo();
152
+ const leaf = matches.length > 0 ? matches[matches.length - 1] ?? null : null;
153
+ return {
154
+ location,
155
+ matches,
156
+ match: leaf,
157
+ pathname: location.pathname,
158
+ params: leaf ? leaf.params : EMPTY_PARAMS,
159
+ search: leaf ? leaf.search : EMPTY_SEARCH
160
+ };
161
+ });
162
+ const dataVersion = signal(0, { equals: false });
163
+ loaders = createLoaderManager({
164
+ location: () => locationSig(),
165
+ onChange: () => dataVersion.set(0),
166
+ track: () => {
167
+ dataVersion();
168
+ }
169
+ });
170
+ effect(() => loaders.sync(matchMemo()));
171
+ const unsubscribe = history.subscribe((loc) => locationSig.set(loc));
172
+ return () => {
173
+ unsubscribe();
174
+ loaders.dispose();
175
+ disposeRoot();
176
+ };
177
+ });
178
+ /** Apply the navigation guard, following redirects (capped). Returns the final href, or null to cancel. */
179
+ const applyGuard = (href) => {
180
+ const guard = options.beforeNavigate;
181
+ if (!guard) return href;
182
+ let current = href;
183
+ for (let i = 0; i < 10; i++) {
184
+ const result = guard(current, createHref(locationSig()));
185
+ if (result === false) return null;
186
+ if (typeof result === "string") {
187
+ const next = resolveHref(result, locationSig());
188
+ if (next === current) return current;
189
+ current = next;
190
+ continue;
191
+ }
192
+ return current;
193
+ }
194
+ return null;
195
+ };
196
+ const navigate = (target, opts) => {
197
+ const href = applyGuard(typeof target === "string" ? resolveHref(target, locationSig()) : buildHref(target));
198
+ if (href === null) return;
199
+ if (opts?.force !== true && href === createHref(locationSig())) return;
200
+ const commit = () => {
201
+ if (opts?.replace) history.replace(href);
202
+ else history.push(href);
203
+ };
204
+ if (opts?.viewTransition ?? options.viewTransitions ?? false) startViewTransition(commit);
205
+ else commit();
206
+ };
207
+ const preload = (to) => {
208
+ const href = resolveHref(to, locationSig());
209
+ loaders.preload(matchLocation(flatMemo(), parseHref(href)));
210
+ };
211
+ return {
212
+ state: () => stateMemo(),
213
+ location: () => locationSig(),
214
+ matches: () => stateMemo().matches,
215
+ match: () => stateMemo().match,
216
+ params: () => stateMemo().params,
217
+ search: () => stateMemo().search,
218
+ select: (selector, equals = Object.is) => computed(() => selector(stateMemo()), { equals }),
219
+ navigate,
220
+ loaderData: (match) => loaders.read(match),
221
+ invalidate: () => loaders.invalidate(stateMemo().matches),
222
+ preload,
223
+ setRoutes: (routes) => routesSig.set(routes),
224
+ routes: () => routesSig(),
225
+ history,
226
+ dispose
227
+ };
228
+ }
229
+ /**
230
+ * Run `commit` inside `document.startViewTransition` when available (web only),
231
+ * else run it directly. The signals re-render is synchronous, so it happens
232
+ * inside the transition. No-op-wrapping outside a DOM (SSR, native, tests).
233
+ *
234
+ * View transitions are a progressive enhancement, so this must NEVER throw out of
235
+ * navigate() or leak an unhandled rejection: (1) a rapid second navigation aborts
236
+ * the first transition and rejects its eagerly-created `ready`/`updateCallbackDone`
237
+ * promises — we mark those handled; (2) some browsers throw synchronously (e.g. a
238
+ * hidden/background document) — we fall back to a plain commit so the navigation
239
+ * still lands (without committing twice).
240
+ */
241
+ function startViewTransition(commit) {
242
+ const doc = typeof document === "undefined" ? void 0 : document;
243
+ if (!doc?.startViewTransition) {
244
+ commit();
245
+ return;
246
+ }
247
+ let committed = false;
248
+ const runCommit = () => {
249
+ committed = true;
250
+ commit();
251
+ };
252
+ try {
253
+ const transition = doc.startViewTransition(runCommit);
254
+ transition?.ready?.catch(() => {});
255
+ transition?.updateCallbackDone?.catch(() => {});
256
+ } catch {
257
+ if (!committed) commit();
258
+ }
259
+ }
260
+ //#endregion
261
+ export { createRouter, resolvePath };
262
+
263
+ //# sourceMappingURL=router.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"router.js","names":[],"sources":["../src/router.ts"],"sourcesContent":["/**\n * The Quantum router — signals-native routing state with typed, validated\n * navigation and re-render isolation.\n *\n * Router state (location, params, search, matched route) is modeled as the\n * fine-grained signal graph from `@mindees/core` (Phase 1 `signal`/`computed`,\n * Phase 2 selector isolation). Consumers read a slice via {@link Router.select}\n * and re-run **only** when that slice changes — no whole-tree re-render on\n * navigation, no global-vs-local hook trap (cf. Expo Router). See ADR-0003.\n *\n * @module\n */\n\nimport {\n type Component,\n computed,\n createRoot,\n effect,\n type Memo,\n type MindeesNode,\n type Signal,\n signal,\n} from '@mindees/core'\nimport {\n createLoaderManager,\n type LoaderData,\n type LoaderDepsFn,\n type LoaderFn,\n type LoaderManager,\n} from './data'\nimport {\n createHref,\n createMemoryHistory,\n parseHref,\n type RouterHistory,\n type RouterLocation,\n} from './history'\nimport {\n buildPath,\n compareSpecificity,\n type HasPathParams,\n matchPattern,\n type PathParams,\n} from './pattern'\nimport { parseQuery, type QueryValue, safeValidateSearch, stringifyQuery } from './search'\nimport type { StandardSchemaV1 } from './standard-schema'\n\n// ---------------------------------------------------------------------------\n// Route table types\n// ---------------------------------------------------------------------------\n\n/**\n * Props a route's component receives from {@link createRouterView}. `params` and\n * `search` are **reactive accessors** (read them in a reactive scope so a\n * same-route param change updates in place, no re-mount); `children` is the\n * matched **child route** — render it wherever the nested route should appear\n * (the \"outlet\"). See ADR-0004.\n */\nexport interface RouteComponentProps {\n /** The router instance (for `navigate`/`select`). */\n router: Router\n /** Reactive accessor for the current (merged) path params. */\n params: () => Record<string, string>\n /** Reactive accessor for the current search params. */\n search: () => Record<string, unknown>\n /** Reactive accessor for this route's loader state (`idle` when it has no loader). */\n data: () => LoaderData\n /** The matched child route (the outlet); render it to show nested routes. */\n children: MindeesNode\n}\n\n/** A route: a path pattern, an optional component, optional search schema, and optional children. */\nexport interface RouteRecord {\n /**\n * The path pattern. At the top level this is absolute (`/posts/:postId`); a\n * nested route's path is **relative to its parent** (`settings`), and a child\n * with `''` or `'/'` is the parent's **index** route.\n */\n path: string\n /** The component to render for this route. A component-less route passes its child through. */\n component?: Component<RouteComponentProps>\n /**\n * A Standard Schema validating this route's search params. Its **output must\n * be object-shaped** (search params are a record), so a non-object schema like\n * `z.string()` is rejected at compile time and validated results need no cast.\n *\n * Note: only the **matched leaf** route's `searchSchema` is applied — a schema\n * on a parent/layout route is currently not used (search is global to the URL;\n * the leaf governs it). Per-route end-to-end `InferOutput` typing through\n * `Router.search()` arrives with the typed route registry (a later phase).\n */\n searchSchema?: StandardSchemaV1<unknown, Record<string, unknown>>\n /** Data loader for this route (sync or async); result is exposed via `data()`. */\n loader?: LoaderFn\n /** Declares which inputs key this route's loader cache (e.g. specific search params). */\n loaderDeps?: LoaderDepsFn\n /** Stale-while-revalidate window in ms; within it a successful load is reused. */\n staleTime?: number\n /** Nested child routes (their `path` is relative to this route). */\n children?: readonly RouteRecord[]\n /** Arbitrary route metadata. */\n meta?: Readonly<Record<string, unknown>>\n}\n\n/** The result of matching a location against the route table. */\nexport interface RouteMatch {\n /** The matched route record. */\n route: RouteRecord\n /** The matched pathname. */\n pathname: string\n /** Path params extracted from the pattern. */\n params: Record<string, string>\n /** Search params — validated output when a schema is present, else the raw parse. */\n search: Record<string, unknown>\n /** The raw parsed query, before schema validation. */\n searchRaw: Record<string, string | string[]>\n /** Search-validation issues, present only when validation failed. */\n issues?: ReadonlyArray<StandardSchemaV1.Issue>\n}\n\n/** The router's reactive state — a snapshot read through fine-grained signals. */\nexport interface RouterState {\n /** The current location. */\n location: RouterLocation\n /**\n * The matched route **chain**, root → leaf (one entry per nesting level), or\n * empty when nothing matched. Drives nested rendering ({@link createRouterView}).\n */\n matches: readonly RouteMatch[]\n /** The matched **leaf** route, or `null` if nothing matched. */\n match: RouteMatch | null\n /** Convenience: the current pathname. */\n pathname: string\n /** Convenience: the current path params (`{}` when unmatched). */\n params: Record<string, string>\n /** Convenience: the current search params (`{}` when unmatched). */\n search: Record<string, unknown>\n}\n\n// ---------------------------------------------------------------------------\n// Navigation types — typed targets\n// ---------------------------------------------------------------------------\n\n/** Options that apply to any navigation. */\nexport interface NavigateOptions {\n /** Replace the current history entry instead of pushing a new one. */\n replace?: boolean\n /** Navigate even if the target equals the current location (skips the idempotent no-op). */\n force?: boolean\n /**\n * Wrap the update in `document.startViewTransition` when available (web only).\n * Overrides the router-level `viewTransitions` default. No-op outside a DOM.\n */\n viewTransition?: boolean\n}\n\n/**\n * A navigation guard. Run before each navigation with the target and current\n * hrefs. Return `false` to cancel, a string to redirect, or nothing to proceed.\n */\nexport type BeforeNavigate = (to: string, from: string) => boolean | string | undefined\n\n/** The params/search/hash carried by a structured target, with params required iff the pattern has them. */\ntype NavExtras<P extends string> = {\n /** Search params to serialize into the query string. */\n search?: Record<string, QueryValue>\n /** A hash fragment (with or without a leading `#`). */\n hash?: string\n} & (HasPathParams<P> extends true ? { params: PathParams<P> } : { params?: Record<string, never> })\n\n/**\n * A fully-typed structured navigation target. `to` is a path pattern; `params`\n * is **required** when the pattern has dynamic segments and forbidden otherwise\n * — inferred from `to` with zero codegen.\n *\n * The param requirement is enforced when `to` is a **string literal**. If `to`\n * is a widened `string` (e.g. read from a variable typed `string`), its segments\n * can't be inferred, so `params` is not type-checked — a missing required param\n * then throws {@link RouterError} (`MISSING_PARAM`) at runtime.\n *\n * @example\n * router.navigate({ to: '/posts/:postId', params: { postId: '42' } })\n * router.navigate({ to: '/about' }) // no params allowed\n */\nexport type NavTarget<P extends string> = { to: P } & NavExtras<P>\n\n/** The broad runtime shape `navigate` accepts (the typed surface is {@link NavTarget}). */\ninterface NavTargetInput {\n to: string\n params?: Record<string, string | number>\n search?: Record<string, QueryValue>\n hash?: string\n}\n\n// ---------------------------------------------------------------------------\n// Router\n// ---------------------------------------------------------------------------\n\n/** Options for {@link createRouter}. */\nexport interface CreateRouterOptions {\n /** The route table. Order is irrelevant — routes are matched most-specific first. */\n routes: readonly RouteRecord[]\n /** The history adapter. Defaults to an in-memory history at `/`. */\n history?: RouterHistory\n /** A guard run before each navigation (cancel with `false`, redirect with a string). */\n beforeNavigate?: BeforeNavigate\n /** Wrap navigations in `document.startViewTransition` by default (web only). */\n viewTransitions?: boolean\n}\n\n/** A live router instance. */\nexport interface Router {\n /** The full reactive state snapshot. */\n state(): RouterState\n /** The current location. */\n location(): RouterLocation\n /** The matched route chain (root → leaf), or empty when unmatched. */\n matches(): readonly RouteMatch[]\n /** The current leaf match, or `null`. */\n match(): RouteMatch | null\n /** The current path params (`{}` when unmatched). */\n params(): Record<string, string>\n /** The current search params (`{}` when unmatched). */\n search(): Record<string, unknown>\n /**\n * Subscribe to a derived slice of router state with re-render isolation. The\n * returned accessor (a memo) only changes when `selector(state)` changes under\n * `equals` (default `Object.is`) — the same selector-isolation technique as\n * core's Phase 2 `createProvider` (a computed memo over an `equals:false`\n * source), applied to route state.\n */\n select<S>(selector: (state: RouterState) => S, equals?: (a: S, b: S) => boolean): () => S\n /** Navigate to a typed target or a (possibly relative) href string. */\n navigate<P extends string>(target: string | NavTarget<P>, options?: NavigateOptions): void\n /** Reactively read a match's loader state (`idle` when the route has no loader). */\n loaderData(match: RouteMatch): LoaderData\n /** Re-run the current chain's loaders (marks their cached data stale first). */\n invalidate(): void\n /**\n * Run a target href's loaders WITHOUT navigating (intent prefetch). A\n * still-in-flight preload is aborted if you then navigate to a *different*\n * route; once it settles it warms the cache for that route's next visit\n * (subject to `staleTime`).\n */\n preload(to: string): void\n /**\n * Replace the route table and re-match the current location **in place** — the\n * location is preserved (dynamic reconfiguration without state reset).\n */\n setRoutes(routes: readonly RouteRecord[]): void\n /**\n * The active route table, as provided to {@link createRouter} / {@link Router.setRoutes}\n * (the nested tree, in insertion order). Matching internally flattens the tree\n * and orders leaves by specificity (static > dynamic > catch-all); that\n * precedence is an implementation detail, not the shape returned here.\n */\n routes(): readonly RouteRecord[]\n /** The underlying history adapter. */\n readonly history: RouterHistory\n /** Tear down the router's reactive scope and history subscription. */\n dispose(): void\n}\n\nconst EMPTY_PARAMS: Record<string, string> = Object.freeze({})\nconst EMPTY_SEARCH: Record<string, unknown> = Object.freeze({})\nconst EMPTY_MATCHES: readonly RouteMatch[] = Object.freeze([])\n\n/** A leaf route flattened from the tree: its full path and its root→leaf chain. */\ninterface FlatRoute {\n fullPath: string\n chain: readonly RouteRecord[]\n}\n\n/** Join a parent path and a (relative) child path into a normalized full path. */\nfunction joinPaths(parent: string, child: string): string {\n const base = parent.endsWith('/') ? parent.slice(0, -1) : parent\n const rel = child.startsWith('/') ? child.slice(1) : child\n if (rel.length === 0) return base.length === 0 ? '/' : base\n return `${base}/${rel}`\n}\n\n/**\n * Flatten a (possibly nested) route tree into leaf entries, each carrying its\n * full path and the root→leaf chain of records. A route with children\n * contributes only via its children (add an index child — `path: ''` — to match\n * the parent's own path).\n */\nfunction flattenRouteTree(\n routes: readonly RouteRecord[],\n parentPath: string,\n parentChain: readonly RouteRecord[],\n): FlatRoute[] {\n const out: FlatRoute[] = []\n for (const route of routes) {\n const fullPath = joinPaths(parentPath, route.path)\n const chain = [...parentChain, route]\n if (route.children && route.children.length > 0) {\n out.push(...flattenRouteTree(route.children, fullPath, chain))\n } else {\n out.push({ fullPath, chain })\n }\n }\n return out\n}\n\n/** Flatten + sort a route tree most-specific first (static > dynamic > catch-all). */\nfunction compileRoutes(routes: readonly RouteRecord[]): FlatRoute[] {\n return flattenRouteTree(routes, '', []).sort((a, b) => compareSpecificity(a.fullPath, b.fullPath))\n}\n\n/**\n * Match a location against the compiled route table, returning the matched chain\n * (root → leaf), or an empty array if nothing matched. Search is validated\n * against the **leaf** route's schema and shared across the chain.\n */\nfunction matchLocation(\n flat: readonly FlatRoute[],\n location: RouterLocation,\n): readonly RouteMatch[] {\n for (const fr of flat) {\n const params = matchPattern(fr.fullPath, location.pathname)\n if (params === null) continue\n\n const searchRaw = parseQuery(location.search)\n let search: Record<string, unknown> = searchRaw\n let issues: ReadonlyArray<StandardSchemaV1.Issue> | undefined\n\n const leaf = fr.chain[fr.chain.length - 1]\n if (leaf?.searchSchema) {\n // searchSchema's output is constrained to Record<string, unknown>, so the\n // validated value is already correctly typed — no cast needed.\n try {\n const result = safeValidateSearch(leaf.searchSchema, searchRaw)\n if (result.ok) search = result.value\n else issues = result.issues\n } catch (err) {\n // safeValidateSearch THROWS on an async schema (ASYNC_SCHEMA), and a\n // schema could throw synchronously too. This runs inside matchMemo\n // (a computed read by stateMemo AND the loader effect), so an escaping\n // throw would poison every router-state accessor and wedge navigation.\n // Contain it: degrade to raw search and surface it via match.issues,\n // exactly like the invalid-input path.\n issues = [{ message: err instanceof Error ? err.message : 'search validation failed' }]\n }\n }\n\n return fr.chain.map((route) => {\n const base: RouteMatch = { route, pathname: location.pathname, params, search, searchRaw }\n return issues ? { ...base, issues } : base\n })\n }\n return EMPTY_MATCHES\n}\n\n/**\n * Resolve a (possibly relative) path against a base pathname. Absolute paths\n * (leading `/`) ignore the base; `.`/`..` segments are applied against it,\n * treating the base pathname as a directory.\n *\n * @example\n * resolvePath('/a/b', '/x') // '/a/b'\n * resolvePath('edit', '/posts/1') // '/posts/1/edit'\n * resolvePath('../', '/posts/1') // '/posts'\n */\nexport function resolvePath(to: string, from: string): string {\n const stack = to.startsWith('/') ? [] : from.split('/').filter((s) => s.length > 0)\n for (const seg of to.split('/')) {\n if (seg === '' || seg === '.') continue\n if (seg === '..') stack.pop()\n else stack.push(seg)\n }\n return `/${stack.join('/')}`\n}\n\n/** Resolve an href string (which may be relative and carry query/hash) against a location. */\nfunction resolveHref(to: string, from: RouterLocation): string {\n const hasPath = to.length > 0 && to[0] !== '?' && to[0] !== '#'\n let rest = to\n let hash = ''\n const hashIndex = rest.indexOf('#')\n if (hashIndex !== -1) {\n hash = rest.slice(hashIndex)\n rest = rest.slice(0, hashIndex)\n }\n let search = ''\n const queryIndex = rest.indexOf('?')\n if (queryIndex !== -1) {\n search = rest.slice(queryIndex)\n rest = rest.slice(0, queryIndex)\n }\n const pathname = hasPath ? resolvePath(rest, from.pathname) : from.pathname\n // RFC 3986: a fragment-only reference (no path, no query) keeps the current\n // path AND query, replacing only the fragment — so a `#anchor` navigation must\n // not drop the active search params.\n const finalSearch = !hasPath && queryIndex === -1 ? from.search : search\n return `${pathname}${finalSearch}${hash}`\n}\n\n/** Build an href from a structured navigation target. */\nfunction buildHref(target: NavTargetInput): string {\n const pathname = buildPath(target.to, target.params ?? {})\n const query = target.search ? stringifyQuery(target.search) : ''\n let hash = target.hash ?? ''\n if (hash.length > 0 && !hash.startsWith('#')) hash = `#${hash}`\n return `${pathname}${query ? `?${query}` : ''}${hash}`\n}\n\n/**\n * Create a router over a route table. State is reactive (signals); call\n * {@link Router.dispose} to tear it down.\n */\nexport function createRouter(options: CreateRouterOptions): Router {\n const history = options.history ?? createMemoryHistory()\n\n let routesSig!: Signal<readonly RouteRecord[]>\n let flatMemo!: Memo<FlatRoute[]>\n let locationSig!: Signal<RouterLocation>\n let stateMemo!: Memo<RouterState>\n let loaders!: LoaderManager\n\n const dispose = createRoot((disposeRoot) => {\n routesSig = signal<readonly RouteRecord[]>(options.routes, { equals: false })\n flatMemo = computed(() => compileRoutes(routesSig()))\n locationSig = signal<RouterLocation>(history.location(), { equals: false })\n const matchMemo = computed(() => matchLocation(flatMemo(), locationSig()))\n stateMemo = computed<RouterState>(() => {\n const location = locationSig()\n const matches = matchMemo()\n const leaf = matches.length > 0 ? (matches[matches.length - 1] ?? null) : null\n return {\n location,\n matches,\n match: leaf,\n pathname: location.pathname,\n params: leaf ? leaf.params : EMPTY_PARAMS,\n search: leaf ? leaf.search : EMPTY_SEARCH,\n }\n })\n\n // Reactive data version: bumped on any loader-cache change; loader reads\n // subscribe to it so a component's data binding updates when its load\n // resolves (without re-mounting the component).\n const dataVersion = signal(0, { equals: false })\n loaders = createLoaderManager({\n location: () => locationSig(),\n onChange: () => dataVersion.set(0),\n track: () => {\n dataVersion()\n },\n })\n // Orchestrate loaders on every navigation. Owned by the router root (not a\n // re-running region), and it never reads `dataVersion` — so no loops.\n effect(() => loaders.sync(matchMemo()))\n\n const unsubscribe = history.subscribe((loc) => locationSig.set(loc))\n return () => {\n unsubscribe()\n loaders.dispose()\n disposeRoot()\n }\n })\n\n /** Apply the navigation guard, following redirects (capped). Returns the final href, or null to cancel. */\n const applyGuard = (href: string): string | null => {\n const guard = options.beforeNavigate\n if (!guard) return href\n let current = href\n for (let i = 0; i < 10; i++) {\n const result = guard(current, createHref(locationSig()))\n if (result === false) return null\n if (typeof result === 'string') {\n const next = resolveHref(result, locationSig())\n if (next === current) return current\n current = next\n continue\n }\n return current\n }\n // Redirect cap exceeded (a guard that keeps redirecting): cancel rather than\n // commit a location the guard never approved.\n return null\n }\n\n const navigate = (target: string | NavTargetInput, opts?: NavigateOptions): void => {\n const initial =\n typeof target === 'string' ? resolveHref(target, locationSig()) : buildHref(target)\n const href = applyGuard(initial)\n if (href === null) return\n // Idempotent: navigating to the current location is a no-op unless forced.\n if (opts?.force !== true && href === createHref(locationSig())) return\n\n const commit = (): void => {\n if (opts?.replace) history.replace(href)\n else history.push(href)\n }\n const wantsTransition = opts?.viewTransition ?? options.viewTransitions ?? false\n if (wantsTransition) startViewTransition(commit)\n else commit()\n }\n\n const preload = (to: string): void => {\n const href = resolveHref(to, locationSig())\n loaders.preload(matchLocation(flatMemo(), parseHref(href)))\n }\n\n return {\n state: () => stateMemo(),\n location: () => locationSig(),\n matches: () => stateMemo().matches,\n match: () => stateMemo().match,\n params: () => stateMemo().params,\n search: () => stateMemo().search,\n select: <S>(selector: (state: RouterState) => S, equals: (a: S, b: S) => boolean = Object.is) =>\n computed(() => selector(stateMemo()), { equals }),\n navigate: navigate as Router['navigate'],\n loaderData: (match) => loaders.read(match),\n invalidate: () => loaders.invalidate(stateMemo().matches),\n preload,\n setRoutes: (routes) => routesSig.set(routes),\n routes: () => routesSig(),\n history,\n dispose,\n }\n}\n\n/** A document that may support the View Transitions API. */\ninterface ViewTransitionDocument {\n startViewTransition?: (callback: () => void) => unknown\n}\n\n/** The subset of a ViewTransition we touch (its eagerly-created promises). */\ninterface ViewTransitionLike {\n ready?: Promise<unknown>\n updateCallbackDone?: Promise<unknown>\n}\n\n/**\n * Run `commit` inside `document.startViewTransition` when available (web only),\n * else run it directly. The signals re-render is synchronous, so it happens\n * inside the transition. No-op-wrapping outside a DOM (SSR, native, tests).\n *\n * View transitions are a progressive enhancement, so this must NEVER throw out of\n * navigate() or leak an unhandled rejection: (1) a rapid second navigation aborts\n * the first transition and rejects its eagerly-created `ready`/`updateCallbackDone`\n * promises — we mark those handled; (2) some browsers throw synchronously (e.g. a\n * hidden/background document) — we fall back to a plain commit so the navigation\n * still lands (without committing twice).\n */\nfunction startViewTransition(commit: () => void): void {\n const doc =\n typeof document === 'undefined' ? undefined : (document as unknown as ViewTransitionDocument)\n if (!doc?.startViewTransition) {\n commit()\n return\n }\n let committed = false\n const runCommit = (): void => {\n committed = true\n commit()\n }\n try {\n const transition = doc.startViewTransition(runCommit) as ViewTransitionLike | undefined\n void transition?.ready?.catch(() => {})\n void transition?.updateCallbackDone?.catch(() => {})\n } catch {\n if (!committed) commit()\n }\n}\n\nexport { createHref }\n"],"mappings":"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"}
@@ -0,0 +1,50 @@
1
+ import { StandardSchemaV1 } from "./standard-schema.js";
2
+
3
+ //#region src/search.d.ts
4
+ /** A value accepted when serializing a query string. */
5
+ type QueryValue = string | number | boolean | null | undefined | ReadonlyArray<string | number | boolean>;
6
+ /**
7
+ * Parse a query string into a record. Accepts an optional leading `?`. Repeated
8
+ * keys collapse into an array, preserving order.
9
+ *
10
+ * @example
11
+ * parseQuery('?page=2&tag=a&tag=b') // { page: '2', tag: ['a', 'b'] }
12
+ */
13
+ declare function parseQuery(search: string): Record<string, string | string[]>;
14
+ /**
15
+ * Serialize a record into a query string (no leading `?`). `null`/`undefined`
16
+ * values are skipped; arrays emit one `key=value` pair each. Keys are sorted for
17
+ * deterministic, cache-friendly output.
18
+ *
19
+ * @example
20
+ * stringifyQuery({ page: 2, tag: ['a', 'b'] }) // 'page=2&tag=a&tag=b'
21
+ */
22
+ declare function stringifyQuery(query: Record<string, QueryValue>): string;
23
+ /** The result of a non-throwing validation. */
24
+ type ValidationResult<T> = {
25
+ readonly ok: true;
26
+ readonly value: T;
27
+ } | {
28
+ readonly ok: false;
29
+ readonly issues: ReadonlyArray<StandardSchemaV1.Issue>;
30
+ };
31
+ /**
32
+ * Validate `input` against a Standard Schema, **without throwing** on invalid
33
+ * input — returns a discriminated result. Throws {@link RouterError}
34
+ * (`ASYNC_SCHEMA`) only for the programming error of passing an async schema,
35
+ * since navigation-time parsing must be synchronous.
36
+ */
37
+ declare function safeValidateSearch<S extends StandardSchemaV1>(schema: S, input: unknown): ValidationResult<StandardSchemaV1.InferOutput<S>>;
38
+ /**
39
+ * Validate `input` against a Standard Schema, returning the typed output or
40
+ * throwing {@link RouterError} (`VALIDATE_SEARCH` with the issues, or
41
+ * `ASYNC_SCHEMA`).
42
+ *
43
+ * @example
44
+ * const schema = z.object({ page: z.coerce.number() }) // Zod, Valibot, ArkType…
45
+ * validateSearch(schema, { page: '2' }) // { page: 2 }
46
+ */
47
+ declare function validateSearch<S extends StandardSchemaV1>(schema: S, input: unknown): StandardSchemaV1.InferOutput<S>;
48
+ //#endregion
49
+ export { QueryValue, ValidationResult, parseQuery, safeValidateSearch, stringifyQuery, validateSearch };
50
+ //# sourceMappingURL=search.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"search.d.ts","names":[],"sources":["../src/search.ts"],"mappings":";;;;KAoBY,UAAA,kDAMR,aAAa;;;;AA+C+C;AAgBhE;;;iBAtDgB,UAAA,CAAW,MAAA,WAAiB,MAAM;;;;;;;;;iBAsClC,cAAA,CAAe,KAAA,EAAO,MAAM,SAAS,UAAA;;KAgBzC,gBAAA;EAAA,SACG,EAAA;EAAA,SAAmB,KAAA,EAAO,CAAA;AAAA;EAAA,SAC1B,EAAA;EAAA,SAAoB,MAAA,EAAQ,aAAA,CAAc,gBAAA,CAAiB,KAAA;AAAA;;;;;;;iBAQ1D,kBAAA,WAA6B,gBAAA,EAC3C,MAAA,EAAQ,CAAA,EACR,KAAA,YACC,gBAAA,CAAiB,gBAAA,CAAiB,WAAA,CAAY,CAAA;;;;;;;;;;iBAyBjC,cAAA,WAAyB,gBAAA,EACvC,MAAA,EAAQ,CAAA,EACR,KAAA,YACC,gBAAA,CAAiB,WAAA,CAAY,CAAA"}