@r0hitsharma/router-kit 0.12.0-rohit-fork-ci.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,422 @@
1
+ # @r0hitsharma/router-kit
2
+
3
+ The generic pieces every [TanStack Router](https://tanstack.com/router) app
4
+ rebuilds: search-param schemas that never reject, a root-route cleanup that keeps
5
+ the address bar honest, an adapter for the design system's URL-synced table, and
6
+ a test harness that follows entry URLs to a fixed point.
7
+
8
+ Consumers adopt `@tanstack/react-router` directly — it stays a peer dependency
9
+ and this package never wraps it, never owns your route tree, and never asks you
10
+ to describe a route twice. See [DESIGN.md](./DESIGN.md) for the dependency-shape
11
+ decisions and what is deliberately not here.
12
+
13
+ ## Installation
14
+
15
+ ```bash
16
+ npm install @r0hitsharma/router-kit @tanstack/react-router zod
17
+ ```
18
+
19
+ `@tanstack/react-router` and `zod` are both peer dependencies. See
20
+ [peer-version policy](#peer-version-policy) for what that means for upgrades.
21
+
22
+ ## The thing to know first: the query decoder coerces
23
+
24
+ Your `validateSearch` never sees the raw query text — `parseSearch` has already
25
+ decoded it, and decoding rewrites values. So a parser written against `string`
26
+ meets four other shapes in production:
27
+
28
+ | URL | value your parser actually receives |
29
+ | --------------- | ----------------------------------- |
30
+ | `?rows=25` | the number `25` |
31
+ | `?dense=true` | the boolean `true` |
32
+ | `?q=` or `?q` | the empty string `''` |
33
+ | `?q=a&q=b` | the array `['a', 'b']` |
34
+
35
+ This is the single most common source of "the param works in dev and vanishes in
36
+ production" — `?page=2` typed by hand becomes a number, a `z.string()` schema
37
+ rejects it, and the route throws.
38
+
39
+ How much coercion depends on which grammar your router was built with, and both
40
+ common choices coerce:
41
+
42
+ - **`parseSearchWith(JSON.parse)`** — the router's default, so this is what you
43
+ get by not configuring one. It applies the decode above and then `JSON.parse`s
44
+ every value still a string, so `?v=1e5` arrives as `100000`, `?v=null` as
45
+ `null`, and `?v=-0` as `0`.
46
+ - **`parseSearchWith((value) => value)`** — the identity parser, for apps whose
47
+ params are all plain text. It stops after the decode, so those three stay
48
+ strings.
49
+
50
+ A hand-written `parseSearch` *is* the decoder rather than a stage after it, so it
51
+ can opt out — but then it owns the whole grammar.
52
+
53
+ `toSearchText` is idempotent under either: feed its own output back through a
54
+ render-and-redecode round trip and you get that output unchanged. What it does
55
+ **not** promise is byte-preserved URL text — under the default grammar `?v=1e5`
56
+ canonicalizes to `100000`, because the value really was decoded to a number.
57
+
58
+ That rewrite is the *grammar's*, and it costs no redirect: the router
59
+ restringifies the decoded search whenever it builds a location, so its own
60
+ mount-time commit puts `?v=100000` in the address bar. Step 2's rewrites are the
61
+ ones that redirect. Either way it is one rewrite, not a loop; the second pass is
62
+ stable, and step 4 pins the settled form of both under both grammars.
63
+
64
+ `textParam()` and `oneOfParam()` absorb all of it. Reach for `toSearchText` or
65
+ `toSearchOption` directly when a control needs to apply the same rule while
66
+ writing a value back.
67
+
68
+ ## Usage
69
+
70
+ ### 1. Describe the search params
71
+
72
+ ```ts
73
+ import { oneOfParam, textParam } from '@r0hitsharma/router-kit';
74
+ import { z } from 'zod';
75
+
76
+ export const TABS = ['overview', 'detail'] as const;
77
+
78
+ export const sharedSearchSchema = z.object({
79
+ q: textParam(),
80
+ tab: oneOfParam(TABS),
81
+ });
82
+ ```
83
+
84
+ `textParam()` trims and treats empty as absent. `oneOfParam(allowed)` keeps a
85
+ value only if it is in the set — pass the set `as const` so the inferred type is
86
+ the literal union rather than `string`.
87
+
88
+ Every builder here is **total** and **idempotent**:
89
+
90
+ - **Total** — no input fails, so `validateSearch` never rejects a URL. A
91
+ hand-edited, stale, or bot-mangled param degrades to absent and the route still
92
+ renders.
93
+ - **Idempotent** — normalizing an already-normalized value, through a full
94
+ render-and-redecode round trip, returns it unchanged.
95
+
96
+ Idempotence is not a nicety; step 2 does not terminate without it.
97
+
98
+ ### 2. Make the URL tell the truth
99
+
100
+ The schemas above drop values they cannot honour, but the address bar keeps them.
101
+ So `?range=90D` reads as ninety days of data next to a chart showing the default
102
+ — and that is the URL that gets shared. `createValidatedSearchRedirect` closes
103
+ the gap on the root route: the URL is either the state on screen, or it is
104
+ replaced with the one that is.
105
+
106
+ ```ts
107
+ import { createValidatedSearchRedirect } from '@r0hitsharma/router-kit';
108
+ import {
109
+ createRootRoute,
110
+ createRouter,
111
+ parseSearchWith,
112
+ stringifySearchWith,
113
+ } from '@tanstack/react-router';
114
+
115
+ // Params here are plain text; the default JSON round trip would write
116
+ // `?rows=1` as `?rows=%221%22`.
117
+ const parseSearch = parseSearchWith((value: string) => value);
118
+ const stringifySearch = stringifySearchWith(JSON.stringify);
119
+
120
+ const rootRoute = createRootRoute({
121
+ validateSearch: sharedSearchSchema,
122
+ component: App,
123
+ beforeLoad: createValidatedSearchRedirect({ stringifySearch }),
124
+ });
125
+
126
+ export const router = createRouter({
127
+ routeTree,
128
+ parseSearch,
129
+ stringifySearch,
130
+ });
131
+ ```
132
+
133
+ Pass the **same** `stringifySearch` to both. It is the one function the helper
134
+ cannot derive, and a mismatch means the cleanup rewrites to a URL the router
135
+ reads differently — which shows up as an infinite redirect. Step 4 is what
136
+ catches that.
137
+
138
+ Two properties, both load-bearing:
139
+
140
+ - **It redirects to an explicit `href`, not `to: '.'`.** A relative target
141
+ resolves against the *pending* location, which during a `beforeLoad` is the
142
+ location being navigated to, not necessarily the one whose params were just
143
+ validated.
144
+ - **It requires total, idempotent schemas.** The rewritten URL is validated
145
+ again on arrival, so a param whose output fails to re-validate to itself
146
+ redirects forever.
147
+
148
+ #### Declare it or lose it
149
+
150
+ Read the mechanism literally: **any key no schema on the route declares is
151
+ deleted from the URL.** That is the feature — a stale `?range=90D` has to go —
152
+ and it does not know what it is deleting. A param some *other* system owns is
153
+ undeclared from this route's point of view, and it goes just as fast.
154
+
155
+ The way this is found in production is an OAuth callback. The provider returns
156
+ the user to `/callback?code=…&state=…`, the root cleanup runs first, and the
157
+ callback route reads an empty query string. The login fails, and nothing in the
158
+ logs mentions the URL. Same shape for a `?utm_source=…` a tag manager reads on
159
+ load, or a `?session_id=…` a payment provider appends.
160
+
161
+ Two ways out, and the first is usually right:
162
+
163
+ 1. **Declare the key in a route schema.** Then it is typed, it is part of the
164
+ route's contract, and `useSearch` can read it.
165
+ 2. **Allowlist it**, for keys something outside the route tree owns:
166
+
167
+ ```ts
168
+ beforeLoad: createValidatedSearchRedirect({
169
+ stringifySearch,
170
+ preserveKeys: ['code', 'state'],
171
+ });
172
+ ```
173
+
174
+ An allowlisted key is exempt from the deletion *and* from the comparison, so its
175
+ presence never triggers a rewrite on its own, and it cannot be the param that
176
+ fails to converge. List only keys no schema declares: a listed key never
177
+ overrides a value validation produced, but for a value validation *rejected*
178
+ there is no applied value to lose to, so the rejected one survives in the address
179
+ bar — which is the lie this whole step exists to remove.
180
+
181
+ For the sibling case — a value that moved out of the query string on one branch
182
+ while shared links still carry the old key — `createSearchParamStripper` drops
183
+ one key and carries the rest across:
184
+
185
+ ```ts
186
+ const itemDetailRoute = createRoute({
187
+ getParentRoute: () => rootRoute,
188
+ path: '/items/$itemId',
189
+ // The id rides in the path here, so a leftover `?item=` names a second one
190
+ // that nothing reads.
191
+ beforeLoad: createSearchParamStripper('item', { stringifySearch }),
192
+ });
193
+ ```
194
+
195
+ ### 3. Sync a table to the URL
196
+
197
+ `createUrlSyncedTableAdapter` builds the adapter the design system's
198
+ `useUrlSyncedTableStateAdapter` takes, reading two search keys and writing them
199
+ back with replace semantics.
200
+
201
+ ```ts
202
+ import { useUrlSyncedTableStateAdapter } from '@r0hitsharma/design-system';
203
+ import type { UseUrlSyncedTableReturn } from '@r0hitsharma/design-system';
204
+ import { createUrlSyncedTableAdapter } from '@r0hitsharma/router-kit';
205
+ import { useNavigate, useSearch } from '@tanstack/react-router';
206
+ import { useMemo } from 'react';
207
+
208
+ export function useItemsTableUrlState(): UseUrlSyncedTableReturn {
209
+ // Not strict: a table in a drawer can stay mounted on a route where this
210
+ // search does not exist, and both params then read as absent.
211
+ const search = useSearch({ from: '/items', shouldThrow: false });
212
+ const navigate = useNavigate();
213
+
214
+ const adapter = useMemo(
215
+ () =>
216
+ createUrlSyncedTableAdapter({
217
+ search,
218
+ sortKey: 'sort',
219
+ searchKey: 'q',
220
+ navigate,
221
+ }),
222
+ [search, navigate],
223
+ );
224
+
225
+ return useUrlSyncedTableStateAdapter(adapter);
226
+ }
227
+ ```
228
+
229
+ Four things worth knowing:
230
+
231
+ - **`useMemo` is required, not tidy.** `useUrlSyncedTableStateAdapter` memoizes
232
+ the setters it returns *on the adapter object*, so a fresh object per render
233
+ makes `setGlobalFilter` a new reference every time. A debounced search commit
234
+ holding that reference as a dependency is then torn down and re-armed on every
235
+ unrelated re-render, and never fires under a burst of keystrokes.
236
+ - **`useNavigate()`'s return value goes straight in.** No wrapper: the adapter
237
+ fixes all three navigation fields itself, and types each as the single value it
238
+ takes. It navigates with `to: '.'`, which keeps the current route's path params
239
+ without this package knowing your route tree. A relative target resolves
240
+ against the pending location if there is one, which is why it is wrong in a
241
+ `beforeLoad` (that runs *while* one is pending) and right from an event handler
242
+ (normally none is). The residual case is a write landing inside another
243
+ navigation's microtask window — a debounced search commit racing a link click.
244
+ - **`sortKey` and `searchKey` have no defaults.** Two tables that silently share
245
+ `sort`/`q` leak whichever state was set last across every switch between them,
246
+ and it reads as a bug in the table rather than in the URL.
247
+ - **The read is verbatim; the schema owns trimming.** A string param reaches the
248
+ table exactly as the URL holds it, whitespace included, so a write-read-write
249
+ round trip is lossless. It has to be: the hook renders `searchParam` back into
250
+ the search box, so an adapter that trimmed would make `'usd '` read back as
251
+ `'usd'`, land the next keystroke on `'usdc'`, and leave a two-word term
252
+ untypeable. Trim at the route boundary instead, where it is visible and
253
+ opt-out-able — `textParam()` does exactly that. Values the URL cannot mean (a
254
+ repeated key's array, an object) read as absent, and a number or boolean the
255
+ decoder produced reads as its text.
256
+
257
+ ### 4. Prove the entry URLs settle
258
+
259
+ `settleEntryUrl` builds a memory-history router from **your exported
260
+ `router.options`**, follows every `beforeLoad` redirect to a fixed point, and
261
+ throws if the chain never gets there.
262
+
263
+ ```ts
264
+ import { settleEntryUrl } from '@r0hitsharma/router-kit/testing';
265
+ import { describe, expect, it } from 'vitest';
266
+
267
+ import { router } from '../src/router';
268
+
269
+ describe('entry URLs', () => {
270
+ it.each([
271
+ '/',
272
+ '/items?tab=bogus',
273
+ '/items/42?item=7',
274
+ '/unknown/deep/path',
275
+ ])('settles %s', async (url) => {
276
+ await expect(settleEntryUrl(router.options, url)).resolves.toBeDefined();
277
+ });
278
+
279
+ it('rewrites a rejected param out of the address bar', async () => {
280
+ const settled = await settleEntryUrl(router.options, '/items?tab=bogus');
281
+
282
+ expect(settled.url).toBe('/items');
283
+ });
284
+ });
285
+ ```
286
+
287
+ Both entry points are async, so `await` them. That is not incidental: a
288
+ `beforeLoad` is often `async` — an auth guard or a context fetch — and its
289
+ redirect then arrives as a rejected promise rather than a synchronous throw. A
290
+ harness that did not await would report the *rejected* route as the settled one,
291
+ so your spec would pass while production redirected somewhere else.
292
+
293
+ It takes `router.options` rather than a route tree on purpose. `parseSearch`,
294
+ `stringifySearch`, `trailingSlash`, `caseSensitive`, and `basepath` all decide
295
+ what a URL *means*; a harness that rebuilt them would assert against a grammar
296
+ your app does not use — passing while production breaks, or failing on a
297
+ difference that exists only in the test. There is one description of the
298
+ grammar, and it is the one the app runs with.
299
+
300
+ Each throw covers a failure that is invisible from inside the app:
301
+
302
+ - **Non-termination** is a hung tab with a spinning address bar. Here it is a
303
+ thrown error naming the cycle it walked. This is also what catches a
304
+ `stringifySearch` that disagrees with the router's.
305
+ - **A schema that rejected the URL** is a thrown error naming the route and the
306
+ validation message. The router does not throw one: it records the rejection on
307
+ the match and carries on with a search no schema produced. So a harness that
308
+ did not look would report the one URL your schemas *could not* validate as
309
+ settled and clean. Step 1's builders are total, so this fires for a schema
310
+ that is not — a bare `z.number()` on a param, most often.
311
+
312
+ It does **not** assert that a hop replaces rather than pushes, because a
313
+ `beforeLoad` redirect cannot push: every path that follows one overrides
314
+ `replace: true` over the redirect's own options. Such an assertion would fail a
315
+ perfectly good `throw redirect({ to: '/login' })` — `replace` undefined, replaced
316
+ anyway. `resolveEntryUrl` still reports `replace` if you want to pin what your
317
+ own helper *declares*.
318
+
319
+ Two options of yours are overridden rather than honoured, both because a
320
+ headless run cannot mean them: `isServer` is forced false (client mode is what
321
+ makes `beforeLoad` run the way a cold load runs it) and `scrollRestoration` is
322
+ forced off. The second is not cosmetic — the router arms scroll restoration from
323
+ its own constructor via bare `history` and `document` references, so leaving it
324
+ on would throw `ReferenceError: history is not defined` before a route was
325
+ matched, and every app that sets it would find the harness unusable. Neither
326
+ option has any bearing on what a URL means.
327
+
328
+ Under a `basepath` (or a `rewrite`), `settled.url` and `settled.hops` come back
329
+ in the **router's** spelling — `/items`, not `/app/items`. Both spellings of an
330
+ entry URL resolve to the same route, so pass whichever you have; but a redirect
331
+ target built inside a `beforeLoad` is written in the router's space, so reporting
332
+ your string verbatim would put two spellings of one URL in a single `hops` array.
333
+ Assert against your route paths, the way you wrote them.
334
+
335
+ Known limit: the harness supplies a `beforeLoad` with `location`, `matches`,
336
+ `params`, and `search`. A `beforeLoad` that reads route `context` or calls
337
+ `navigate` fails loudly here rather than being silently skipped — entry-time
338
+ redirects do not normally need either, and a loud failure is the point.
339
+
340
+ ## Peer-version policy
341
+
342
+ | Peer | Range |
343
+ | ------------------------ | ------------- |
344
+ | `@tanstack/react-router` | `^1.170.0` |
345
+ | `zod` | `^4.0.0` |
346
+
347
+ Both are peers because a second copy of either would break something real:
348
+
349
+ - **The router** carries its types through module augmentation. Your
350
+ `declare module '@tanstack/react-router' { interface Register { ... } }` binds
351
+ to one copy, so a second one leaves your route paths and search types
352
+ unavailable to it. React context is per-copy too, so hooks from one copy return
353
+ nothing inside the other's provider.
354
+ - **zod** schemas built here are composed into `z.object({ ... })` in your app.
355
+ Schemas from two copies are not reliably interchangeable, and when they are
356
+ not, the failure reads as an opaque variance error at the route definition
357
+ rather than as a duplicate dependency.
358
+
359
+ The floor is the line this package is developed and tested against, and the caret
360
+ means router and zod patch/minor upgrades do not need a release here. The one
361
+ thing to know when upgrading the router: the cleanup and the harness both read
362
+ `_strictSearch`, a router-internal field. It is stable in practice, and this
363
+ package's own suite fails loudly if it moves — but that is why the floor is
364
+ recorded rather than left open.
365
+
366
+ `@r0hitsharma/design-system` is **not** a dependency of this package, not
367
+ even an optional peer. The table adapter's type is restated structurally; see
368
+ [DESIGN.md](./DESIGN.md), which ships in the tarball alongside this file.
369
+
370
+ ## Exported surface
371
+
372
+ From the root entry:
373
+
374
+ | Export | Kind | Module |
375
+ | --------------------------------- | -------- | -------------------- |
376
+ | `toSearchText` | function | `search-params` |
377
+ | `toSearchOption` | function | `search-params` |
378
+ | `textParam` | function | `search-params` |
379
+ | `oneOfParam` | function | `search-params` |
380
+ | `SearchTextParam` | type | `search-params` |
381
+ | `SearchOptionParam` | type | `search-params` |
382
+ | `createValidatedSearchRedirect` | function | `validated-search` |
383
+ | `createSearchParamStripper` | function | `validated-search` |
384
+ | `rendersSameSearch` | function | `validated-search` |
385
+ | `SearchRecord` | type | `validated-search` |
386
+ | `StringifySearch` | type | `validated-search` |
387
+ | `CanonicalSearchOptions` | type | `validated-search` |
388
+ | `ValidatedSearchRedirectOptions` | type | `validated-search` |
389
+ | `ValidatedSearchContext` | type | `validated-search` |
390
+ | `SearchParamStripperContext` | type | `validated-search` |
391
+ | `createUrlSyncedTableAdapter` | function | `table-adapter` |
392
+ | `UrlSyncedTableStateAdapter` | type | `table-adapter` |
393
+ | `UrlSyncedTableAdapterOptions` | type | `table-adapter` |
394
+ | `UrlSyncedTableNavigate` | type | `table-adapter` |
395
+ | `UrlSyncedTableNavigateOptions` | type | `table-adapter` |
396
+
397
+ From `@r0hitsharma/router-kit/testing`:
398
+
399
+ | Export | Kind |
400
+ | ----------------------- | -------- |
401
+ | `settleEntryUrl` | function |
402
+ | `resolveEntryUrl` | function |
403
+ | `EntryRouterOptions` | type |
404
+ | `EntryUrlResolution` | type |
405
+ | `EntryRedirect` | type |
406
+ | `EntryLeaf` | type |
407
+ | `SettleEntryUrlOptions` | type |
408
+ | `SettledEntryUrl` | type |
409
+
410
+ Both functions return promises — see [step 4](#4-prove-the-entry-urls-settle).
411
+
412
+ ## See also
413
+
414
+ Sibling packages are linked by full URL, not by `../`: this file ships in the
415
+ published tarball, where a relative path to another package resolves to nothing.
416
+
417
+ - [design-system](https://github.com/r0hitsharma/uikit/tree/main/packages/design-system)
418
+ for `useUrlSyncedTableStateAdapter` and the `DataTable` this package's adapter
419
+ feeds
420
+ - [http-client-react](https://github.com/r0hitsharma/uikit/tree/main/packages/http-client-react)
421
+ for the TanStack Query layer that a future loader/query glue would have to
422
+ straddle (see [DESIGN.md](./DESIGN.md#loader-and-query-glue))
@@ -0,0 +1,3 @@
1
+ export { oneOfParam, type SearchOptionParam, type SearchTextParam, textParam, toSearchOption, toSearchText, } from './search-params.js';
2
+ export { createUrlSyncedTableAdapter, type UrlSyncedTableAdapterOptions, type UrlSyncedTableNavigate, type UrlSyncedTableNavigateOptions, type UrlSyncedTableStateAdapter, } from './table-adapter.js';
3
+ export { type CanonicalSearchOptions, createSearchParamStripper, createValidatedSearchRedirect, rendersSameSearch, type SearchParamStripperContext, type SearchRecord, type StringifySearch, type ValidatedSearchContext, type ValidatedSearchRedirectOptions, } from './validated-search.js';
package/dist/index.js ADDED
@@ -0,0 +1,3 @@
1
+ export { oneOfParam, textParam, toSearchOption, toSearchText, } from './search-params.js';
2
+ export { createUrlSyncedTableAdapter, } from './table-adapter.js';
3
+ export { createSearchParamStripper, createValidatedSearchRedirect, rendersSameSearch, } from './validated-search.js';
@@ -0,0 +1,86 @@
1
+ import { z } from 'zod';
2
+ /**
3
+ * Collapses a raw URL search value to the text a param means, or `undefined`
4
+ * when it means nothing.
5
+ *
6
+ * ## Why a normalizer is needed at all
7
+ *
8
+ * `validateSearch` never sees the raw query text. `parseSearch` has already
9
+ * decoded it, and decoding coerces spellings, so a parser written against
10
+ * `string` meets four other shapes in production:
11
+ *
12
+ * | URL | value reaching the parser |
13
+ * | ---------------- | ------------------------- |
14
+ * | `?rows=25` | the number `25` |
15
+ * | `?dense=true` | the boolean `true` |
16
+ * | `?q=` or `?q` | the empty string `''` |
17
+ * | `?q=a&q=b` | the array `['a', 'b']` |
18
+ *
19
+ * How much coercion depends on the grammar the router was built with, and both
20
+ * common choices coerce:
21
+ *
22
+ * - `parseSearchWith(JSON.parse)` — the router's **default**. It applies the qss
23
+ * decode above and then `JSON.parse`s every value still a string, so `?v=1e5`
24
+ * arrives as `100000`, `?v=null` as `null`, and `?v=-0` as `0`.
25
+ * - `parseSearchWith((value) => value)` — the identity parser, for apps whose
26
+ * params are plain text. Stops after the qss decode, so those three stay
27
+ * strings.
28
+ *
29
+ * A hand-written `parseSearch` is the decoder rather than a stage after it, so
30
+ * it can opt out entirely — but then it owns the whole grammar.
31
+ *
32
+ * ## What is and is not promised
33
+ *
34
+ * **Promised: idempotence.** Feeding this function's own output back through a
35
+ * render-and-redecode round trip returns that output unchanged, under either
36
+ * grammar above. That is the property the entry-time cleanup needs to terminate.
37
+ *
38
+ * **Not promised: byte-preserved URL text.** Under the default grammar `?v=1e5`
39
+ * canonicalizes to `100000` and `?v=-0` to `0`, because the value really was
40
+ * decoded to a number.
41
+ *
42
+ * That rewrite is the *grammar's*, not the cleanup's, and it costs no redirect:
43
+ * the router restringifies the decoded search whenever it builds a location, and
44
+ * its own mount-time commit is what puts the canonical form in the address bar.
45
+ * The cleanup's rewrites are the ones that show up as a redirect. Either way it
46
+ * is one rewrite and not a loop — the second pass is stable, and
47
+ * `settleEntryUrl` pins the settled form of both under both grammars.
48
+ */
49
+ export declare function toSearchText(value: unknown): string | undefined;
50
+ /**
51
+ * Narrows a raw URL value (or a raw control value) to a closed option set.
52
+ * Exported alongside the schema builder because the change handlers that write
53
+ * a param back need the same rule the schema applied when reading it — two
54
+ * spellings of "is this allowed" would be one drift away from a control that
55
+ * can set a value the URL then drops.
56
+ */
57
+ export declare function toSearchOption<T extends string>(value: unknown, allowed: readonly T[]): T | undefined;
58
+ /**
59
+ * Schema shape returned by {@link textParam}. Named so the built `.d.ts` states
60
+ * the param's output type instead of inlining zod's internal generics.
61
+ */
62
+ export type SearchTextParam = z.ZodOptional<z.ZodPipe<z.ZodUnknown, z.ZodTransform<string | undefined, unknown>>>;
63
+ /**
64
+ * A free-text search param: trimmed, with empty and unusable values degrading
65
+ * to absent. Use for identifiers, query strings, and timestamps — anything
66
+ * whose value set is open.
67
+ *
68
+ * Total and idempotent by construction; see the contract note on this module's
69
+ * builders below.
70
+ */
71
+ export declare function textParam(): SearchTextParam;
72
+ /**
73
+ * Schema shape returned by {@link oneOfParam}, carrying the closed option set
74
+ * through to the inferred search type.
75
+ */
76
+ export type SearchOptionParam<T extends string> = z.ZodOptional<z.ZodPipe<z.ZodUnknown, z.ZodTransform<T | undefined, unknown>>>;
77
+ /**
78
+ * A closed-set param: one of `allowed`, or absent. Use for tabs, modes, sort
79
+ * directions, and flags whose spellings are fixed — a hand-edited or stale
80
+ * value degrades to absent rather than reaching a component that has no case
81
+ * for it.
82
+ *
83
+ * Pass `allowed` as `[...] as const` (or a `readonly` tuple) so the inferred
84
+ * search type is the literal union rather than `string`.
85
+ */
86
+ export declare function oneOfParam<T extends string>(allowed: readonly T[]): SearchOptionParam<T>;
@@ -0,0 +1,118 @@
1
+ import { z } from 'zod';
2
+ /**
3
+ * Collapses a raw URL search value to the text a param means, or `undefined`
4
+ * when it means nothing.
5
+ *
6
+ * ## Why a normalizer is needed at all
7
+ *
8
+ * `validateSearch` never sees the raw query text. `parseSearch` has already
9
+ * decoded it, and decoding coerces spellings, so a parser written against
10
+ * `string` meets four other shapes in production:
11
+ *
12
+ * | URL | value reaching the parser |
13
+ * | ---------------- | ------------------------- |
14
+ * | `?rows=25` | the number `25` |
15
+ * | `?dense=true` | the boolean `true` |
16
+ * | `?q=` or `?q` | the empty string `''` |
17
+ * | `?q=a&q=b` | the array `['a', 'b']` |
18
+ *
19
+ * How much coercion depends on the grammar the router was built with, and both
20
+ * common choices coerce:
21
+ *
22
+ * - `parseSearchWith(JSON.parse)` — the router's **default**. It applies the qss
23
+ * decode above and then `JSON.parse`s every value still a string, so `?v=1e5`
24
+ * arrives as `100000`, `?v=null` as `null`, and `?v=-0` as `0`.
25
+ * - `parseSearchWith((value) => value)` — the identity parser, for apps whose
26
+ * params are plain text. Stops after the qss decode, so those three stay
27
+ * strings.
28
+ *
29
+ * A hand-written `parseSearch` is the decoder rather than a stage after it, so
30
+ * it can opt out entirely — but then it owns the whole grammar.
31
+ *
32
+ * ## What is and is not promised
33
+ *
34
+ * **Promised: idempotence.** Feeding this function's own output back through a
35
+ * render-and-redecode round trip returns that output unchanged, under either
36
+ * grammar above. That is the property the entry-time cleanup needs to terminate.
37
+ *
38
+ * **Not promised: byte-preserved URL text.** Under the default grammar `?v=1e5`
39
+ * canonicalizes to `100000` and `?v=-0` to `0`, because the value really was
40
+ * decoded to a number.
41
+ *
42
+ * That rewrite is the *grammar's*, not the cleanup's, and it costs no redirect:
43
+ * the router restringifies the decoded search whenever it builds a location, and
44
+ * its own mount-time commit is what puts the canonical form in the address bar.
45
+ * The cleanup's rewrites are the ones that show up as a redirect. Either way it
46
+ * is one rewrite and not a loop — the second pass is stable, and
47
+ * `settleEntryUrl` pins the settled form of both under both grammars.
48
+ */
49
+ export function toSearchText(value) {
50
+ if (typeof value === 'number' || typeof value === 'boolean') {
51
+ return String(value);
52
+ }
53
+ // Arrays (a repeated key), objects, `null`, and absence all mean "no usable
54
+ // value", which the schemas below spell as `undefined`.
55
+ if (typeof value !== 'string') {
56
+ return undefined;
57
+ }
58
+ const trimmed = value.trim();
59
+ return trimmed === '' ? undefined : trimmed;
60
+ }
61
+ /**
62
+ * Narrows a raw URL value (or a raw control value) to a closed option set.
63
+ * Exported alongside the schema builder because the change handlers that write
64
+ * a param back need the same rule the schema applied when reading it — two
65
+ * spellings of "is this allowed" would be one drift away from a control that
66
+ * can set a value the URL then drops.
67
+ */
68
+ export function toSearchOption(value, allowed) {
69
+ const text = toSearchText(value);
70
+ if (text === undefined) {
71
+ return undefined;
72
+ }
73
+ // Widened rather than narrowed: `includes` on a `readonly T[]` would demand a
74
+ // `T` here, which is the very thing being decided. One cast on the way out.
75
+ return allowed.includes(text)
76
+ ? text
77
+ : undefined;
78
+ }
79
+ /**
80
+ * A free-text search param: trimmed, with empty and unusable values degrading
81
+ * to absent. Use for identifiers, query strings, and timestamps — anything
82
+ * whose value set is open.
83
+ *
84
+ * Total and idempotent by construction; see the contract note on this module's
85
+ * builders below.
86
+ */
87
+ export function textParam() {
88
+ return z.optional(z.unknown().transform(toSearchText));
89
+ }
90
+ /**
91
+ * A closed-set param: one of `allowed`, or absent. Use for tabs, modes, sort
92
+ * directions, and flags whose spellings are fixed — a hand-edited or stale
93
+ * value degrades to absent rather than reaching a component that has no case
94
+ * for it.
95
+ *
96
+ * Pass `allowed` as `[...] as const` (or a `readonly` tuple) so the inferred
97
+ * search type is the literal union rather than `string`.
98
+ */
99
+ export function oneOfParam(allowed) {
100
+ return z.optional(z.unknown().transform((value) => toSearchOption(value, allowed)));
101
+ }
102
+ /*
103
+ * ## Writing a param of your own
104
+ *
105
+ * The total-and-idempotent contract (see `toSearchText`) is what
106
+ * `createValidatedSearchRedirect` needs to terminate, so a custom param has to
107
+ * keep it. Three rules cover it:
108
+ *
109
+ * 1. Build on `toSearchText`, so the decoder's coercions are already absorbed.
110
+ * 2. Keep the transform a pure function of that text — no state, no clock, no
111
+ * counter. A param that grows or varies per call cannot converge.
112
+ * 3. Return absent, or a value that renders to text this module maps back to
113
+ * that same value. Notably `null` is *not* such a value: it is neither
114
+ * dropped as absent nor stable across both grammars.
115
+ *
116
+ * `settleEntryUrl` (from the `/testing` subpath) is the executable check — point
117
+ * it at the real route tree and it fails on a param that does not converge.
118
+ */