@native-router/core 1.3.0 → 1.4.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/README.md CHANGED
@@ -59,9 +59,10 @@ commit(router, entry.task, entry.location); // commit like a click
59
59
  - Route guards: static `redirect` and async `beforeLoad` on every route level, run shallow → deep; more than 10 chained redirects reject with `RedirectLoopError`
60
60
  - Cancelable async navigation: a new resolve supersedes the in-flight one (`currentGuard`); `cancel()` aborts it; a history POP cancels it too. A superseded or cancelled `navigate()` promise **never settles** — don't `await` a navigation that might be superseded
61
61
  - Navigation API: `navigate`, `refresh`, `go`/`forward`/`back`, `commit`/`commitReplace`, `createHref`, `getParams`, `match`, `toLocation`, `resolve`, `resolveTo`
62
+ - Search validation via [Standard Schema](https://standardschema.dev): a `search` schema on any route level (zod/valibot/arktype, no hard dependency), parsed with `parseSearch`/`parseSearchSync`; failures throw `SearchError`
62
63
  - `preload(router, to, {ttl})`: resolve a target through the guards ahead of time, sharing one task across concurrent callers (in-flight dedup) with a TTL, default 30s; consumed entries are dropped on commit
63
64
  - `errorHandler` hook turns resolve failures into fallback views
64
- - Errors: `NativeRouterError`, `NotFoundError`, `RedirectLoopError`
65
+ - Errors: `NativeRouterError`, `NotFoundError`, `RedirectLoopError`, `SearchError`
65
66
  - Tree-shakable: `sideEffects: false`
66
67
 
67
68
  ## Matching semantics
@@ -73,6 +74,30 @@ commit(router, entry.task, entry.location); // commit like a click
73
74
  - Matching is **case-sensitive**.
74
75
  - Params of nested levels are merged **deep over shallow** (`mergeMatchedParams`): for `/:id` + `/posts/:id`, the deeper `id` wins.
75
76
 
77
+ ## Search validation
78
+
79
+ Declare a `search` validator on a route level and parse `location.search` with it in your `resolveView`. Any [Standard Schema](https://standardschema.dev) validator works — zod, valibot and arktype all implement the interface — so the core keeps zero extra runtime dependencies.
80
+
81
+ ```ts
82
+ import {create, parseSearch} from '@native-router/core';
83
+ import {z} from 'zod';
84
+
85
+ const listSearch = z.object({page: z.coerce.number().default(1)});
86
+
87
+ const router = create(
88
+ {path: '', children: [{path: '/list', search: listSearch}]},
89
+ createBrowserHistory(),
90
+ // Your resolveView consumes route.search itself: parse the location
91
+ // search, then resolve the view from the parsed output
92
+ async (matched, {location}) =>
93
+ renderList(await parseSearch(matched.at(-1)!.route.search!, location.search))
94
+ );
95
+ ```
96
+
97
+ - `parseSearchInput(search)` degrades a query string into a plain object — single-valued keys are strings, keys repeated in the query are arrays — which is also the input every schema validates
98
+ - `parseSearch(schema, search)` resolves the schema output (async validators are awaited); `parseSearchSync` is the render/guard-time flavor and rejects async validators with a clear error
99
+ - A rejected validation throws `SearchError` (a `NativeRouterError`) carrying the raw `search` and the reported `issues` — route it through your `errorHandler` like any other resolve failure
100
+
76
101
  ## Install
77
102
 
78
103
  ```bash
package/dist/index.cjs CHANGED
@@ -18,6 +18,32 @@ class RedirectLoopError extends NativeRouterError {
18
18
  }
19
19
  }
20
20
 
21
+ /**
22
+ * Thrown when a route {@link BaseRoute.search search schema} rejects the
23
+ * location search. Issues are formatted as `path: message` pairs joined
24
+ * with `; `, e.g.
25
+ * `Invalid search params "?page=abc": page: Expected a positive integer`.
26
+ */
27
+ class SearchError extends NativeRouterError {
28
+ /** The raw search string that failed validation. */
29
+
30
+ /** The issues reported by the schema. */
31
+
32
+ constructor(search, issues) {
33
+ super(`Invalid search params "${search}": ${issues.map(({
34
+ message,
35
+ path
36
+ }) => `${formatIssuePath(path)}${message}`).join('; ')}`);
37
+ this.search = search;
38
+ this.issues = issues;
39
+ }
40
+ }
41
+ function formatIssuePath(path) {
42
+ if (!path?.length) return '';
43
+ const keys = path.map(segment => typeof segment === 'object' ? String(segment.key) : String(segment));
44
+ return `${keys.join('.')}: `;
45
+ }
46
+
21
47
  const DEFAULT_MAX_STACK_DEPTH = 100;
22
48
 
23
49
  /** Max redirects followed by {@link resolveEntry} before giving up. */
@@ -753,9 +779,88 @@ function getParams(router) {
753
779
  return mergeMatchedParams(match(router, location.pathname) ?? []);
754
780
  }
755
781
 
782
+ /**
783
+ * Parse a raw search string(e.g. `?page=2&tag=a&tag=b`) into the plain
784
+ * input object consumed by {@link StandardSchemaV1 search schemas}:
785
+ * single-valued keys are strings, keys repeated in the query string are
786
+ * arrays of their values. An empty search is `{}`.
787
+ *
788
+ * This is also the degraded shape every search API falls back to when no
789
+ * schema is given.
790
+ * @group Methods
791
+ * @category Route
792
+ * @param search the raw `location.search` string, with or without `?`
793
+ * @returns the input object for schema validation
794
+ */
795
+ function parseSearchInput(search) {
796
+ const input = {};
797
+ // eslint-disable-next-line compat/compat -- URLSearchParams support is the app's polyfill concern, not bundled
798
+ new URLSearchParams(search).forEach((value, key) => {
799
+ const prev = input[key];
800
+ if (prev === undefined) {
801
+ input[key] = value;
802
+ } else if (Array.isArray(prev)) {
803
+ prev.push(value);
804
+ } else {
805
+ input[key] = [prev, value];
806
+ }
807
+ });
808
+ return input;
809
+ }
810
+
811
+ /**
812
+ * Validate a search string with a {@link StandardSchemaV1} schema — any
813
+ * zod/valibot/arktype schema works, no hard dependency. The string is
814
+ * first degraded via {@link parseSearchInput}, then parsed by the schema,
815
+ * so schemas can coerce(`'2'` → `2`) and normalize along the way.
816
+ *
817
+ * Async schemas(`validate` returning a promise) are awaited.
818
+ *
819
+ * @group Methods
820
+ * @category Route
821
+ * @param schema the search schema
822
+ * @param search the raw `location.search` string
823
+ * @returns the parsed(and possibly coerced) output of the schema
824
+ * @throws {SearchError} when the schema reports issues
825
+ */
826
+ async function parseSearch(schema, search) {
827
+ const result = await schema['~standard'].validate(parseSearchInput(search));
828
+ if (result.issues) throw new SearchError(search, result.issues);
829
+ // The schema's declared output; the loose `StandardSchemaV1` default
830
+ // degrades to `unknown`.
831
+ return result.value;
832
+ }
833
+
834
+ /**
835
+ * Synchronous flavor of {@link parseSearch}, for render-time reads(see
836
+ * `useSearch` of `@native-router/react`) and route guards.
837
+ *
838
+ * @group Methods
839
+ * @category Route
840
+ * @param schema the search schema — must validate synchronously
841
+ * @param search the raw `location.search` string
842
+ * @returns the parsed(and possibly coerced) output of the schema
843
+ * @throws {SearchError} when the schema reports issues
844
+ * @throws when the schema validates asynchronously; use {@link parseSearch}
845
+ * for async schemas instead
846
+ */
847
+ function parseSearchSync(schema, search) {
848
+ const result = schema['~standard'].validate(parseSearchInput(search));
849
+ if (isThenable(result)) {
850
+ throw new Error('The search schema validates asynchronously; parse it during resolve ' + '(parseSearch) instead of synchronously');
851
+ }
852
+ if (result.issues) throw new SearchError(search, result.issues);
853
+ // See parseSearch for the cast rationale.
854
+ return result.value;
855
+ }
856
+ function isThenable(value) {
857
+ return typeof value?.then === 'function';
858
+ }
859
+
756
860
  exports.NativeRouterError = NativeRouterError;
757
861
  exports.NotFoundError = NotFoundError;
758
862
  exports.RedirectLoopError = RedirectLoopError;
863
+ exports.SearchError = SearchError;
759
864
  exports.back = back;
760
865
  exports.cancel = cancel;
761
866
  exports.commit = commit;
@@ -772,6 +877,9 @@ exports.listen = listen;
772
877
  exports.match = match;
773
878
  exports.mergeMatchedParams = mergeMatchedParams;
774
879
  exports.navigate = navigate;
880
+ exports.parseSearch = parseSearch;
881
+ exports.parseSearchInput = parseSearchInput;
882
+ exports.parseSearchSync = parseSearchSync;
775
883
  exports.preload = preload;
776
884
  exports.refresh = refresh;
777
885
  exports.resolve = resolve;
package/dist/index.mjs CHANGED
@@ -16,6 +16,32 @@ class RedirectLoopError extends NativeRouterError {
16
16
  }
17
17
  }
18
18
 
19
+ /**
20
+ * Thrown when a route {@link BaseRoute.search search schema} rejects the
21
+ * location search. Issues are formatted as `path: message` pairs joined
22
+ * with `; `, e.g.
23
+ * `Invalid search params "?page=abc": page: Expected a positive integer`.
24
+ */
25
+ class SearchError extends NativeRouterError {
26
+ /** The raw search string that failed validation. */
27
+
28
+ /** The issues reported by the schema. */
29
+
30
+ constructor(search, issues) {
31
+ super(`Invalid search params "${search}": ${issues.map(({
32
+ message,
33
+ path
34
+ }) => `${formatIssuePath(path)}${message}`).join('; ')}`);
35
+ this.search = search;
36
+ this.issues = issues;
37
+ }
38
+ }
39
+ function formatIssuePath(path) {
40
+ if (!path?.length) return '';
41
+ const keys = path.map(segment => typeof segment === 'object' ? String(segment.key) : String(segment));
42
+ return `${keys.join('.')}: `;
43
+ }
44
+
19
45
  const DEFAULT_MAX_STACK_DEPTH = 100;
20
46
 
21
47
  /** Max redirects followed by {@link resolveEntry} before giving up. */
@@ -751,4 +777,82 @@ function getParams(router) {
751
777
  return mergeMatchedParams(match(router, location.pathname) ?? []);
752
778
  }
753
779
 
754
- export { NativeRouterError, NotFoundError, RedirectLoopError, back, cancel, commit, commitReplace, create, createHref, forward, getCurrentView, getLocation, getParams, go, initHistoryStack, listen, match, mergeMatchedParams, navigate, preload, refresh, resolve, resolveEntry, resolveTo, setOptions, toLocation };
780
+ /**
781
+ * Parse a raw search string(e.g. `?page=2&tag=a&tag=b`) into the plain
782
+ * input object consumed by {@link StandardSchemaV1 search schemas}:
783
+ * single-valued keys are strings, keys repeated in the query string are
784
+ * arrays of their values. An empty search is `{}`.
785
+ *
786
+ * This is also the degraded shape every search API falls back to when no
787
+ * schema is given.
788
+ * @group Methods
789
+ * @category Route
790
+ * @param search the raw `location.search` string, with or without `?`
791
+ * @returns the input object for schema validation
792
+ */
793
+ function parseSearchInput(search) {
794
+ const input = {};
795
+ // eslint-disable-next-line compat/compat -- URLSearchParams support is the app's polyfill concern, not bundled
796
+ new URLSearchParams(search).forEach((value, key) => {
797
+ const prev = input[key];
798
+ if (prev === undefined) {
799
+ input[key] = value;
800
+ } else if (Array.isArray(prev)) {
801
+ prev.push(value);
802
+ } else {
803
+ input[key] = [prev, value];
804
+ }
805
+ });
806
+ return input;
807
+ }
808
+
809
+ /**
810
+ * Validate a search string with a {@link StandardSchemaV1} schema — any
811
+ * zod/valibot/arktype schema works, no hard dependency. The string is
812
+ * first degraded via {@link parseSearchInput}, then parsed by the schema,
813
+ * so schemas can coerce(`'2'` → `2`) and normalize along the way.
814
+ *
815
+ * Async schemas(`validate` returning a promise) are awaited.
816
+ *
817
+ * @group Methods
818
+ * @category Route
819
+ * @param schema the search schema
820
+ * @param search the raw `location.search` string
821
+ * @returns the parsed(and possibly coerced) output of the schema
822
+ * @throws {SearchError} when the schema reports issues
823
+ */
824
+ async function parseSearch(schema, search) {
825
+ const result = await schema['~standard'].validate(parseSearchInput(search));
826
+ if (result.issues) throw new SearchError(search, result.issues);
827
+ // The schema's declared output; the loose `StandardSchemaV1` default
828
+ // degrades to `unknown`.
829
+ return result.value;
830
+ }
831
+
832
+ /**
833
+ * Synchronous flavor of {@link parseSearch}, for render-time reads(see
834
+ * `useSearch` of `@native-router/react`) and route guards.
835
+ *
836
+ * @group Methods
837
+ * @category Route
838
+ * @param schema the search schema — must validate synchronously
839
+ * @param search the raw `location.search` string
840
+ * @returns the parsed(and possibly coerced) output of the schema
841
+ * @throws {SearchError} when the schema reports issues
842
+ * @throws when the schema validates asynchronously; use {@link parseSearch}
843
+ * for async schemas instead
844
+ */
845
+ function parseSearchSync(schema, search) {
846
+ const result = schema['~standard'].validate(parseSearchInput(search));
847
+ if (isThenable(result)) {
848
+ throw new Error('The search schema validates asynchronously; parse it during resolve ' + '(parseSearch) instead of synchronously');
849
+ }
850
+ if (result.issues) throw new SearchError(search, result.issues);
851
+ // See parseSearch for the cast rationale.
852
+ return result.value;
853
+ }
854
+ function isThenable(value) {
855
+ return typeof value?.then === 'function';
856
+ }
857
+
858
+ export { NativeRouterError, NotFoundError, RedirectLoopError, SearchError, back, cancel, commit, commitReplace, create, createHref, forward, getCurrentView, getLocation, getParams, go, initHistoryStack, listen, match, mergeMatchedParams, navigate, parseSearch, parseSearchInput, parseSearchSync, preload, refresh, resolve, resolveEntry, resolveTo, setOptions, toLocation };
@@ -1,3 +1,4 @@
1
+ import type { StandardSchemaV1 } from './types';
1
2
  export declare class NativeRouterError extends Error {
2
3
  }
3
4
  export declare class NotFoundError extends NativeRouterError {
@@ -6,3 +7,16 @@ export declare class NotFoundError extends NativeRouterError {
6
7
  export declare class RedirectLoopError extends NativeRouterError {
7
8
  constructor(target?: string);
8
9
  }
10
+ /**
11
+ * Thrown when a route {@link BaseRoute.search search schema} rejects the
12
+ * location search. Issues are formatted as `path: message` pairs joined
13
+ * with `; `, e.g.
14
+ * `Invalid search params "?page=abc": page: Expected a positive integer`.
15
+ */
16
+ export declare class SearchError extends NativeRouterError {
17
+ /** The raw search string that failed validation. */
18
+ readonly search: string;
19
+ /** The issues reported by the schema. */
20
+ readonly issues: ReadonlyArray<StandardSchemaV1.Issue>;
21
+ constructor(search: string, issues: ReadonlyArray<StandardSchemaV1.Issue>);
22
+ }
@@ -1,3 +1,4 @@
1
1
  export * from './router';
2
2
  export * from './errors';
3
+ export * from './search';
3
4
  export type * from './types';
@@ -0,0 +1,45 @@
1
+ import type { SearchInput, SearchOutputOf, StandardSchemaV1 } from './types';
2
+ /**
3
+ * Parse a raw search string(e.g. `?page=2&tag=a&tag=b`) into the plain
4
+ * input object consumed by {@link StandardSchemaV1 search schemas}:
5
+ * single-valued keys are strings, keys repeated in the query string are
6
+ * arrays of their values. An empty search is `{}`.
7
+ *
8
+ * This is also the degraded shape every search API falls back to when no
9
+ * schema is given.
10
+ * @group Methods
11
+ * @category Route
12
+ * @param search the raw `location.search` string, with or without `?`
13
+ * @returns the input object for schema validation
14
+ */
15
+ export declare function parseSearchInput(search: string): SearchInput;
16
+ /**
17
+ * Validate a search string with a {@link StandardSchemaV1} schema — any
18
+ * zod/valibot/arktype schema works, no hard dependency. The string is
19
+ * first degraded via {@link parseSearchInput}, then parsed by the schema,
20
+ * so schemas can coerce(`'2'` → `2`) and normalize along the way.
21
+ *
22
+ * Async schemas(`validate` returning a promise) are awaited.
23
+ *
24
+ * @group Methods
25
+ * @category Route
26
+ * @param schema the search schema
27
+ * @param search the raw `location.search` string
28
+ * @returns the parsed(and possibly coerced) output of the schema
29
+ * @throws {SearchError} when the schema reports issues
30
+ */
31
+ export declare function parseSearch<S extends StandardSchemaV1>(schema: S, search: string): Promise<SearchOutputOf<S>>;
32
+ /**
33
+ * Synchronous flavor of {@link parseSearch}, for render-time reads(see
34
+ * `useSearch` of `@native-router/react`) and route guards.
35
+ *
36
+ * @group Methods
37
+ * @category Route
38
+ * @param schema the search schema — must validate synchronously
39
+ * @param search the raw `location.search` string
40
+ * @returns the parsed(and possibly coerced) output of the schema
41
+ * @throws {SearchError} when the schema reports issues
42
+ * @throws when the schema validates asynchronously; use {@link parseSearch}
43
+ * for async schemas instead
44
+ */
45
+ export declare function parseSearchSync<S extends StandardSchemaV1>(schema: S, search: string): SearchOutputOf<S>;
@@ -22,6 +22,63 @@ export type HistoryState = {
22
22
  };
23
23
  export type WrappedLocation = Location<HistoryState>;
24
24
  export type Awaitable<T> = T | Promise<T>;
25
+ /**
26
+ * The [Standard Schema](https://standardschema.dev) interface, version 1 —
27
+ * the common validation interface implemented by zod, valibot and arktype.
28
+ *
29
+ * Inlined type-only from `@standard-schema/spec` so the core keeps zero
30
+ * extra runtime dependencies: any schema exposing `~standard` works.
31
+ * @group Types
32
+ * @category Route
33
+ */
34
+ export interface StandardSchemaV1<Input = unknown, Output = Input> {
35
+ readonly '~standard': StandardSchemaV1.Props<Input, Output>;
36
+ }
37
+ export declare namespace StandardSchemaV1 {
38
+ interface Props<Input = unknown, Output = Input> {
39
+ /** The version number of the standard. */
40
+ readonly version: 1;
41
+ /** The vendor name of the schema library. */
42
+ readonly vendor: string;
43
+ /** Validates unknown input values. */
44
+ readonly validate: (value: unknown) => Result<Output> | Promise<Result<Output>>;
45
+ }
46
+ type Result<Output> = SuccessResult<Output> | FailureResult;
47
+ interface SuccessResult<Output> {
48
+ /** The typed output value. */
49
+ readonly value: Output;
50
+ /** The issues of the input value. */
51
+ readonly issues?: undefined;
52
+ }
53
+ interface FailureResult {
54
+ /** The issues of the input value. */
55
+ readonly issues: ReadonlyArray<Issue>;
56
+ }
57
+ interface Issue {
58
+ /** The issue message. */
59
+ readonly message: string;
60
+ /** The issue path. */
61
+ readonly path?: ReadonlyArray<PropertyKey | PathSegment>;
62
+ }
63
+ interface PathSegment {
64
+ /** The key of the path segment. */
65
+ readonly key: PropertyKey;
66
+ }
67
+ }
68
+ /**
69
+ * The plain input object a search string degrades into before schema
70
+ * validation: single-valued keys are strings, keys repeated in the query
71
+ * string are arrays of their values.
72
+ * @group Types
73
+ * @category Route
74
+ */
75
+ export type SearchInput = Record<string, string | string[]>;
76
+ /**
77
+ * Parsed output type of a {@link StandardSchemaV1 search schema}.
78
+ * @group Types
79
+ * @category Route
80
+ */
81
+ export type SearchOutputOf<S> = S extends StandardSchemaV1<any, infer Output> ? Output : never;
25
82
  /**
26
83
  * Params contributed by a single path segment: `:name` is required,
27
84
  * `:name?` is optional, anything else(static or wildcard) contributes
@@ -70,6 +127,14 @@ export type BaseRoute<T = any> = {
70
127
  * redirected to the target path before the view resolves.
71
128
  */
72
129
  redirect?: string;
130
+ /**
131
+ * Optional Standard Schema(zod/valibot/arktype, ...) validator of the
132
+ * route search. Frameworks parse `location.search` with it at resolve
133
+ * time(see `parseSearch`) and inject the parsed output into their data
134
+ * contexts; a validation failure fails the resolve like any other
135
+ * navigation error.
136
+ */
137
+ search?: StandardSchemaV1;
73
138
  /**
74
139
  * Route guard invoked before the view resolves. Return a path string
75
140
  * to redirect, or nothing(`undefined`) to continue.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@native-router/core",
3
- "version": "1.3.0",
3
+ "version": "1.4.0",
4
4
  "exports": {
5
5
  ".": {
6
6
  "types": "./dist/types/index.d.ts",