paramour 0.8.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/codec.d.ts CHANGED
@@ -1,9 +1,13 @@
1
1
  /**
2
- * `any` is deliberate: `~out` appears in inferred method parameter positions
3
- * (`.default(value: Out)`), which are contravariant under strictFunctionTypes;
4
- * the `unknown` form would reject every concrete codec.
2
+ * Any codec, optionally narrowed to one output type: `AnyCodec<number>` is
3
+ * the supported spelling of "a codec producing numbers" in any presence,
4
+ * catch, or arity state — only `Codec`'s first type parameter is public API;
5
+ * the type-state parameters after it may change in minor releases. The
6
+ * `any` default is deliberate: `~out` appears in inferred method parameter
7
+ * positions (`.default(value: Out)`), which are contravariant under
8
+ * strictFunctionTypes; the `unknown` form would reject every concrete codec.
5
9
  */
6
- export type AnyCodec = Codec<any, Presence, boolean, Arity>;
10
+ export type AnyCodec<Out = any> = Codec<Out, Presence, boolean, Arity>;
7
11
  /** "single" = one wire value per key; "many" = repeated keys (arrays). */
8
12
  export type Arity = "many" | "single";
9
13
  /**
@@ -71,18 +75,26 @@ export interface Codec<Out, P extends Presence = "required", C extends boolean =
71
75
  /** Members of a `p.enum` codec; undefined for every other kind. */
72
76
  readonly "~enumMembers": readonly string[] | undefined;
73
77
  /**
74
- * Which builder produced the codec (`"integer"`, `"enum"`, …; `p.custom`
75
- * uses its `label` or `"custom"`). Reflection metadata for describeCodec —
76
- * never consulted by parse/serialize.
78
+ * Which builder produced the codec. Reflection metadata for describeCodec
79
+ * — never consulted by parse/serialize. `p.custom` is always `"custom"`,
80
+ * so a custom codec can never pass itself off as a built-in; its
81
+ * user-chosen name lives in `~label`.
77
82
  */
78
- readonly "~kind": string;
83
+ readonly "~kind": CodecKind;
84
+ /** `p.custom`'s display label; undefined for every other codec. */
85
+ readonly "~label": string | undefined;
79
86
  /** phantom — carries `Out` for inference; never set at runtime */
80
87
  readonly "~out": Out;
81
88
  readonly "~parseElement": (raw: string) => unknown;
82
89
  readonly "~presence": P;
83
90
  readonly "~serializeElement": (value: unknown) => string;
84
91
  }
85
- export type OutputOf<C extends AnyCodec> = C["~out"];
92
+ /**
93
+ * Which builder produced a codec. New builders add members in minor
94
+ * releases, so exhaustive switches over it need a default branch.
95
+ */
96
+ export type CodecKind = "array" | "boolean" | "csv" | "custom" | "enum" | "index" | "integer" | "isoDate" | "json" | "number" | "string" | "timestamp";
97
+ export type InferCodecOutput<C extends AnyCodec> = C["~out"];
86
98
  /** Codecs legal in a `params:` config — no presence modifiers (D5). */
87
99
  export type ParamCodec = Codec<any, "required", boolean>;
88
100
  /**
@@ -108,7 +120,8 @@ export declare function createCodec<Out, A extends Arity = "single">(impl: {
108
120
  arity?: A;
109
121
  element?: AnyCodec;
110
122
  enumMembers?: readonly string[];
111
- kind?: string;
123
+ kind?: CodecKind;
124
+ label?: string;
112
125
  parseElement: (raw: string) => unknown;
113
126
  serializeElement: (value: unknown) => string;
114
127
  }): Codec<Out, "required", false, A>;
package/dist/codec.js CHANGED
@@ -9,6 +9,7 @@ export function createCodec(impl) {
9
9
  element: impl.element,
10
10
  enumMembers: impl.enumMembers,
11
11
  kind: impl.kind ?? "custom",
12
+ label: impl.label,
12
13
  parseElement: impl.parseElement,
13
14
  presence: "required",
14
15
  serializeElement: impl.serializeElement,
@@ -64,6 +65,7 @@ function build(state) {
64
65
  "~element": state.element,
65
66
  "~enumMembers": state.enumMembers,
66
67
  "~kind": state.kind,
68
+ "~label": state.label,
67
69
  "~parseElement": state.parseElement,
68
70
  "~presence": state.presence,
69
71
  "~serializeElement": state.serializeElement,
@@ -1,4 +1,4 @@
1
- import type { AnyCodec, Arity, Presence } from "./codec.js";
1
+ import type { AnyCodec, Arity, CodecKind, Presence } from "./codec.js";
2
2
  import type { AnyRoute, RouterKind } from "./route.js";
3
3
  /**
4
4
  * `.default()` in reflected form. Value-form defaults carry their wire
@@ -28,7 +28,9 @@ export interface CodecDescription {
28
28
  */
29
29
  readonly element?: CodecDescription;
30
30
  readonly enumMembers?: readonly string[];
31
- readonly kind: string;
31
+ readonly kind: CodecKind;
32
+ /** A `p.custom` codec's display label, when it was given one. */
33
+ readonly label?: string;
32
34
  readonly presence: Presence;
33
35
  }
34
36
  /** Rendering styles accepted by {@link formatCodecDescription}. */
package/dist/describe.js CHANGED
@@ -28,6 +28,7 @@ export function describeCodec(codec) {
28
28
  ...(element === undefined ? {} : { element: describeCodec(element) }),
29
29
  ...(enumMembers === undefined ? {} : { enumMembers }),
30
30
  kind: codec["~kind"],
31
+ ...(codec["~label"] === undefined ? {} : { label: codec["~label"] }),
31
32
  presence: codec["~presence"],
32
33
  };
33
34
  }
@@ -76,8 +77,9 @@ export function describeRoute(route) {
76
77
  */
77
78
  export function formatCodecDescription(description, style) {
78
79
  const memberSeparator = style === "verbose" ? ", " : "|";
80
+ // A custom codec renders its label when it has one; otherwise its kind.
79
81
  const kindLabel = (part) => part.enumMembers === undefined
80
- ? part.kind
82
+ ? (part.label ?? part.kind)
81
83
  : `enum(${part.enumMembers.join(memberSeparator)})`;
82
84
  // Composite labels: a one-key list wraps its element (`csv<integer>`); a
83
85
  // repeated-key list IS its element, pluralized (`integer[]`) — the
package/dist/errors.d.ts CHANGED
@@ -57,6 +57,7 @@ export declare class ParamourError extends Error {
57
57
  /** Aggregate failure for a whole route-params decode. */
58
58
  export declare class ParamsDecodeError extends ParamourError {
59
59
  readonly issues: readonly Issue[];
60
+ readonly name: "ParamsDecodeError";
60
61
  /** The failed route's path pattern; null when decoded outside a route. */
61
62
  readonly route: null | string;
62
63
  constructor(issues: readonly Issue[], route?: null | string);
@@ -68,7 +69,14 @@ export declare class ParamsDecodeError extends ParamourError {
68
69
  */
69
70
  export declare class ParseError extends ParamourError {
70
71
  /**
71
- * True when the message follows core's grammar-authoring convention —
72
+ * Literal per class, so the decode errors stay structurally distinct
73
+ * (the `SafeResult` error parameter relies on it) and `error.name`
74
+ * survives minification.
75
+ */
76
+ readonly name: "ParseError";
77
+ /**
78
+ * Internal, outside semver: true when the message follows core's
79
+ * grammar-authoring convention —
72
80
  * it quotes the offending wire value and names the grammar it failed
73
81
  * (`'"x" is not an integer'`). Only core's own grammar throw sites set
74
82
  * it (via {@link grammarParseError}); schema-validation failures and
@@ -77,16 +85,13 @@ export declare class ParseError extends ParamourError {
77
85
  * expected-shape context themselves. This flag — never message sniffing
78
86
  * — is what issue producers key `reason: "parse" | "validate"` on.
79
87
  */
80
- readonly selfDescribing: boolean;
81
- constructor(message: string, options?: {
82
- cause?: unknown;
83
- selfDescribing?: boolean;
84
- });
88
+ readonly "~selfDescribing": boolean;
85
89
  static [Symbol.hasInstance](value: unknown): value is ParseError;
86
90
  }
87
91
  /** Aggregate failure for a whole search-params decode. */
88
92
  export declare class SearchDecodeError extends ParamourError {
89
93
  readonly issues: readonly Issue[];
94
+ readonly name: "SearchDecodeError";
90
95
  /** The failed route's path pattern; null when decoded outside a route. */
91
96
  readonly route: null | string;
92
97
  constructor(issues: readonly Issue[], route?: null | string);
@@ -102,11 +107,13 @@ export declare class SearchDecodeError extends ParamourError {
102
107
  export declare class SearchSourceError extends ParamourError {
103
108
  /** The offending source key, or null when the source itself is malformed. */
104
109
  readonly key: null | string;
110
+ readonly name: "SearchSourceError";
105
111
  constructor(message: string, key: null | string);
106
112
  static [Symbol.hasInstance](value: unknown): value is SearchSourceError;
107
113
  }
108
114
  /** A value could not be serialized to the wire (bad type, non-finite, etc.). */
109
115
  export declare class SerializeError extends ParamourError {
116
+ readonly name: "SerializeError";
110
117
  static [Symbol.hasInstance](value: unknown): value is SerializeError;
111
118
  }
112
119
  /**
@@ -137,7 +144,7 @@ export declare function grammarParseError(message: string): ParseError;
137
144
  * Maps a caught {@link ParseError} to its {@link Issue} reason: core's
138
145
  * grammar-authored messages are `"parse"`, everything else — schema
139
146
  * validators, rebranded custom-codec throws — is `"validate"`. Keyed on the
140
- * structural `selfDescribing` flag, never on message sniffing; shared by
147
+ * structural `~selfDescribing` flag, never on message sniffing; shared by
141
148
  * search.ts and path.ts so both surfaces classify identically. Not exported
142
149
  * from the package.
143
150
  */
package/dist/errors.js CHANGED
@@ -20,7 +20,12 @@ export class ParamourError extends Error {
20
20
  }
21
21
  constructor(message, options) {
22
22
  super(message, options);
23
- this.name = new.target.name;
23
+ // Paramour's own classes pin a literal `name` field (which runs after
24
+ // this and wins) — a minifier that drops class names would otherwise
25
+ // rename them in production. User subclasses fall back to their class
26
+ // name.
27
+ this.name =
28
+ new.target === ParamourError ? "ParamourError" : new.target.name;
24
29
  }
25
30
  // Each class checks its OWN brand: an inherited base check would make
26
31
  // every ParamourError pass `instanceof ParseError`. The type-predicate
@@ -35,6 +40,7 @@ export class ParamsDecodeError extends ParamourError {
35
40
  brandPrototype(this, paramsDecodeErrorBrand);
36
41
  }
37
42
  issues;
43
+ name = "ParamsDecodeError";
38
44
  /** The failed route's path pattern; null when decoded outside a route. */
39
45
  route;
40
46
  constructor(issues, route = null) {
@@ -55,7 +61,14 @@ export class ParseError extends ParamourError {
55
61
  brandPrototype(this, parseErrorBrand);
56
62
  }
57
63
  /**
58
- * True when the message follows core's grammar-authoring convention —
64
+ * Literal per class, so the decode errors stay structurally distinct
65
+ * (the `SafeResult` error parameter relies on it) and `error.name`
66
+ * survives minification.
67
+ */
68
+ name = "ParseError";
69
+ /**
70
+ * Internal, outside semver: true when the message follows core's
71
+ * grammar-authoring convention —
59
72
  * it quotes the offending wire value and names the grammar it failed
60
73
  * (`'"x" is not an integer'`). Only core's own grammar throw sites set
61
74
  * it (via {@link grammarParseError}); schema-validation failures and
@@ -64,11 +77,7 @@ export class ParseError extends ParamourError {
64
77
  * expected-shape context themselves. This flag — never message sniffing
65
78
  * — is what issue producers key `reason: "parse" | "validate"` on.
66
79
  */
67
- selfDescribing;
68
- constructor(message, options) {
69
- super(message, options);
70
- this.selfDescribing = options?.selfDescribing ?? false;
71
- }
80
+ "~selfDescribing" = false;
72
81
  static [Symbol.hasInstance](value) {
73
82
  return hasBrand(value, parseErrorBrand);
74
83
  }
@@ -79,6 +88,7 @@ export class SearchDecodeError extends ParamourError {
79
88
  brandPrototype(this, searchDecodeErrorBrand);
80
89
  }
81
90
  issues;
91
+ name = "SearchDecodeError";
82
92
  /** The failed route's path pattern; null when decoded outside a route. */
83
93
  route;
84
94
  constructor(issues, route = null) {
@@ -103,6 +113,7 @@ export class SearchSourceError extends ParamourError {
103
113
  }
104
114
  /** The offending source key, or null when the source itself is malformed. */
105
115
  key;
116
+ name = "SearchSourceError";
106
117
  constructor(message, key) {
107
118
  super(message);
108
119
  this.key = key;
@@ -116,6 +127,7 @@ export class SerializeError extends ParamourError {
116
127
  static {
117
128
  brandPrototype(this, serializeErrorBrand);
118
129
  }
130
+ name = "SerializeError";
119
131
  static [Symbol.hasInstance](value) {
120
132
  return hasBrand(value, serializeErrorBrand);
121
133
  }
@@ -148,18 +160,21 @@ export function foreignMessage(error) {
148
160
  * Not exported from the package.
149
161
  */
150
162
  export function grammarParseError(message) {
151
- return new ParseError(message, { selfDescribing: true });
163
+ const error = new ParseError(message);
164
+ // The one setter: the flag is readonly to everyone else.
165
+ error["~selfDescribing"] = true;
166
+ return error;
152
167
  }
153
168
  /**
154
169
  * Maps a caught {@link ParseError} to its {@link Issue} reason: core's
155
170
  * grammar-authored messages are `"parse"`, everything else — schema
156
171
  * validators, rebranded custom-codec throws — is `"validate"`. Keyed on the
157
- * structural `selfDescribing` flag, never on message sniffing; shared by
172
+ * structural `~selfDescribing` flag, never on message sniffing; shared by
158
173
  * search.ts and path.ts so both surfaces classify identically. Not exported
159
174
  * from the package.
160
175
  */
161
176
  export function parseIssueReason(error) {
162
- return error.selfDescribing ? "parse" : "validate";
177
+ return error["~selfDescribing"] ? "parse" : "validate";
163
178
  }
164
179
  /**
165
180
  * Runs user (or platform) code, letting paramour's own errors pass through
package/dist/href.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import type { AnyRoute, RegisteredStaticRoutePaths } from "./route.js";
2
2
  import { type InferParamsInput } from "./path.js";
3
- import { type SearchInputOf } from "./search.js";
3
+ import { type InferSearchInput } from "./search.js";
4
4
  /**
5
5
  * Type-only brand carrier: no runtime value ever exists — the brand is
6
6
  * applied by a compile-time cast, so Href costs nothing at runtime.
@@ -30,7 +30,7 @@ export type HrefArgs<R extends AnyRoute> = Record<never, never> extends InferHre
30
30
  * keys AT ALL may not be passed even empty (see PartFor). `hash` implements
31
31
  * S10 — fragments come only from an explicit caller option.
32
32
  */
33
- export type InferHrefInput<R extends AnyRoute> = PartFor<"params", InferParamsInput<R>> & PartFor<"search", SearchInputOf<R["~search"]>> & {
33
+ export type InferHrefInput<R extends AnyRoute> = PartFor<"params", InferParamsInput<R>> & PartFor<"search", InferSearchInput<R["~search"]>> & {
34
34
  hash?: string;
35
35
  };
36
36
  /**
package/dist/index.d.ts CHANGED
@@ -1,10 +1,10 @@
1
- export { type AnyCodec, type Arity, type Codec, type OutputOf, type ParamCodec, type Presence, type PresenceOf, } from "./codec.js";
1
+ export { type AnyCodec, type Arity, type Codec, type CodecKind, type InferCodecOutput, type ParamCodec, type Presence, type PresenceOf, } from "./codec.js";
2
2
  export { type CodecDefaultDescription, type CodecDescription, type CodecFormatStyle, describeCodec, describeRoute, formatCodecDescription, type ParamDescription, type RouteDescription, type SearchDescription, } from "./describe.js";
3
3
  export { type Issue, type IssueReason, ParamourError, ParamsDecodeError, ParseError, type RouteDecodeError, SearchDecodeError, SearchSourceError, SerializeError, } from "./errors.js";
4
4
  export { href, type Href, type HrefArgs, type InferHrefInput, type StaticHrefOptions, } from "./href.js";
5
5
  export { p } from "./p.js";
6
6
  export { buildPath, decodeParams, type DecodeParamsOptions, encodeParams, encodeStaticParams, type InferStaticParams, type ParamsSource, } from "./path.js";
7
- export { type AnyAppRoute, type AnyPagesRoute, type AnyRoute, type AppRoute, defineAppRoute, definePagesRoute, type InferRouteParams, type PagesContext, type PagesRoute, type ParamourRegister, type ParamsConfig, type ParamsProps, type ParamsPropsInput, type RegisteredAppRoutePaths, type RegisteredPagesRoutePaths, type RegisteredStaticAppRoutePaths, type RegisteredStaticPagesRoutePaths, type RegisteredStaticRoutePaths, type Route, type RouteProps, type RoutePropsInput, type RouterKind, type SafeResult, type SearchProps, type SearchPropsInput, } from "./route.js";
7
+ export { type AnyAppRoute, type AnyPagesRoute, type AnyRoute, type AppRoute, defineAppRoute, definePagesRoute, type InferRouteParams, type InferRouteSearch, type PagesContext, type PagesRoute, type ParamourRegister, type ParamsConfig, type ParamsProps, type ParamsPropsLike, type RegisteredAppRoutePaths, type RegisteredPagesRoutePaths, type RegisteredStaticAppRoutePaths, type RegisteredStaticPagesRoutePaths, type RegisteredStaticRoutePaths, type Route, type RouteConfig, type RouteProps, type RoutePropsLike, type RouterKind, type SafeResult, type SearchProps, type SearchPropsLike, } from "./route.js";
8
8
  export { safeDecodeParams, safeDecodeSearch } from "./safe-decode.js";
9
- export { buildSearchString, decodeSearch, encodeSearch, type InferSearchInput, type InferSearchOutput, isRawSearch, rawSearch, type RawSearch, type SearchConfig, type SearchOutputOf, type SearchSource, searchToString, serializeValue, } from "./search.js";
9
+ export { buildSearchString, decodeSearch, encodeSearch, type InferSearchInput, type InferSearchOutput, isRawSearch, parseValue, rawSearch, type RawSearch, type SearchConfig, type SearchSlot, type SearchSource, searchToString, serializeValue, } from "./search.js";
10
10
  export { standardSearchSchema, type StandardSearchSchema, } from "./standard-schema.js";
package/dist/index.js CHANGED
@@ -6,5 +6,5 @@ export { p } from "./p.js";
6
6
  export { buildPath, decodeParams, encodeParams, encodeStaticParams, } from "./path.js";
7
7
  export { defineAppRoute, definePagesRoute, } from "./route.js";
8
8
  export { safeDecodeParams, safeDecodeSearch } from "./safe-decode.js";
9
- export { buildSearchString, decodeSearch, encodeSearch, isRawSearch, rawSearch, searchToString, serializeValue, } from "./search.js";
9
+ export { buildSearchString, decodeSearch, encodeSearch, isRawSearch, parseValue, rawSearch, searchToString, serializeValue, } from "./search.js";
10
10
  export { standardSearchSchema, } from "./standard-schema.js";
@@ -1,12 +1,13 @@
1
1
  /**
2
- * The `paramour/internal` entry: unstable helpers for derived tooling
3
- * (devtools, adapters), NOT for app authors and NOT covered by the public
4
- * API's stability expectations. These live off the main barrel on purpose —
5
- * the docs' Reference section documents the app-author surface, and these
6
- * exist solely so reflection-driven consumers (the devtools panel's
7
- * catch-attribution probe, edit preview, and synthesized-issue labels)
8
- * share core's implementation instead of re-deriving it.
2
+ * The `paramour/internal` entry: helpers for derived tooling (devtools,
3
+ * adapters), NOT for app authors — they live off the main barrel so the
4
+ * docs' Reference section stays the app-author surface. Covered by semver
5
+ * within a major all the same (additions only until the next major): the
6
+ * devtools panel peers on `paramour` with a caret range, so a newer core
7
+ * must never break an installed panel that imports from here. These exist
8
+ * so reflection-driven consumers (the panel's synthesized-issue labels and
9
+ * foreign-error rendering) share core's implementation instead of
10
+ * re-deriving it.
9
11
  */
10
12
  export { codecShapeLabel } from "./describe.js";
11
13
  export { foreignMessage } from "./errors.js";
12
- export { parseValue } from "./search.js";
package/dist/internal.js CHANGED
@@ -1,12 +1,13 @@
1
1
  /**
2
- * The `paramour/internal` entry: unstable helpers for derived tooling
3
- * (devtools, adapters), NOT for app authors and NOT covered by the public
4
- * API's stability expectations. These live off the main barrel on purpose —
5
- * the docs' Reference section documents the app-author surface, and these
6
- * exist solely so reflection-driven consumers (the devtools panel's
7
- * catch-attribution probe, edit preview, and synthesized-issue labels)
8
- * share core's implementation instead of re-deriving it.
2
+ * The `paramour/internal` entry: helpers for derived tooling (devtools,
3
+ * adapters), NOT for app authors — they live off the main barrel so the
4
+ * docs' Reference section stays the app-author surface. Covered by semver
5
+ * within a major all the same (additions only until the next major): the
6
+ * devtools panel peers on `paramour` with a caret range, so a newer core
7
+ * must never break an installed panel that imports from here. These exist
8
+ * so reflection-driven consumers (the panel's synthesized-issue labels and
9
+ * foreign-error rendering) share core's implementation instead of
10
+ * re-deriving it.
9
11
  */
10
12
  export { codecShapeLabel } from "./describe.js";
11
13
  export { foreignMessage } from "./errors.js";
12
- export { parseValue } from "./search.js";
package/dist/p.js CHANGED
@@ -6,9 +6,11 @@ import { runStandardSchemaSync } from "./schema.js";
6
6
  const INTEGER_RE = /^-?\d+$/;
7
7
  const NUMBER_RE = /^-?\d+(\.\d+)?([eE][+-]?\d+)?$/;
8
8
  const ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
9
- // Canonical emit is Date#toISOString (milliseconds always); parse tolerates
10
- // missing milliseconds. UTC (`Z`) only — offsets are rejected in v0.1.
11
- const TIMESTAMP_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{1,3})?Z$/;
9
+ // Canonical emit is Date#toISOString (UTC, milliseconds always); parse
10
+ // tolerates missing milliseconds and accepts a `Z` or `±HH:MM` offset, so
11
+ // links from systems that emit local-offset timestamps decode to the same
12
+ // instant. The emit side never produces an offset: one instant, one URL.
13
+ const TIMESTAMP_RE = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.(\d{1,3}))?(?:Z|([+-])(\d{2}):(\d{2}))$/;
12
14
  /**
13
15
  * Serialize-side Date guard. Years outside 0000–9999 are rejected:
14
16
  * toISOString switches to the expanded ±6-digit-year form there, which the
@@ -190,8 +192,8 @@ export const p = {
190
192
  // CV2: presence, catch, and arity-many inners are excluded by the
191
193
  // parameter type (the same type-state philosophy); resolveListElement
192
194
  // mirrors that type-state for JS consumers. Nesting is detected
193
- // structurally via ~element (never via ~kind, which is reflection-only
194
- // and free-form for p.custom labels). Comma-emitting p.custom inners are
195
+ // structurally via ~element (never via ~kind, which is reflection-only).
196
+ // Comma-emitting p.custom inners are
195
197
  // undetectable here and are caught by the CV4 serialize guard instead.
196
198
  const inner = resolveListElement(element, "p.csv");
197
199
  // The shared guard ran first: p.array also carries ~element (PP1), and
@@ -264,7 +266,7 @@ export const p = {
264
266
  // aggregation. .catch() recovers foreign parse failures only, which
265
267
  // rebrandForeign normalizes to ParseError so recovery sees them.
266
268
  return createCodec({
267
- ...(codec.label === undefined ? {} : { kind: codec.label }),
269
+ ...(codec.label === undefined ? {} : { label: codec.label }),
268
270
  parseElement: (raw) => rebrandForeign(() => codec.parse(raw),
269
271
  // Not grammarParseError: foreign prose may name neither the value
270
272
  // nor the grammar, so decode issues classify it "validate" and
@@ -409,20 +411,43 @@ export const p = {
409
411
  return createCodec({
410
412
  kind: "timestamp",
411
413
  parseElement: (raw) => {
412
- if (!TIMESTAMP_RE.test(raw)) {
413
- throw grammarParseError(`"${raw}" is not an ISO 8601 UTC timestamp`);
414
+ const match = TIMESTAMP_RE.exec(raw);
415
+ if (!match) {
416
+ throw grammarParseError(`"${raw}" is not an ISO 8601 timestamp`);
414
417
  }
415
- const date = new Date(raw);
416
- if (Number.isNaN(date.getTime())) {
417
- throw grammarParseError(`"${raw}" is not a real instant`);
418
+ const [year, month, day, hour, minute, second] = match
419
+ .slice(1, 7)
420
+ .map(Number);
421
+ const millis = Number((match[7] ?? "").padEnd(3, "0"));
422
+ const sign = match[8] === "-" ? -1 : 1;
423
+ const offsetHours = Number(match[9] ?? "0");
424
+ const offsetMinutes = Number(match[10] ?? "0");
425
+ if (offsetHours > 23 || offsetMinutes > 59) {
426
+ throw grammarParseError(`"${raw}" has an impossible UTC offset`);
418
427
  }
419
- // The engine silently normalizes impossible fields (Feb 30 → Mar 1,
420
- // 24:00 → next day). Pad the input to canonical millisecond form and
421
- // require an exact round-trip instead.
422
- const canonical = raw.replace(/(?:\.(\d{1,3}))?Z$/, (_match, ms) => `.${(ms ?? "").padEnd(3, "0")}Z`);
423
- if (date.toISOString() !== canonical) {
428
+ // Built field by field — Date.UTC maps years 0–99 to 1900–1999 —
429
+ // then checked field by field: the engine silently normalizes
430
+ // impossible fields (Feb 30 → Mar 1, 24:00 → next day), which must
431
+ // be rejected, not reinterpreted.
432
+ const local = new Date(0);
433
+ local.setUTCFullYear(year, month - 1, day);
434
+ local.setUTCHours(hour, minute, second, millis);
435
+ if (local.getUTCFullYear() !== year ||
436
+ local.getUTCMonth() !== month - 1 ||
437
+ local.getUTCDate() !== day ||
438
+ local.getUTCHours() !== hour ||
439
+ local.getUTCMinutes() !== minute ||
440
+ local.getUTCSeconds() !== second) {
424
441
  throw grammarParseError(`"${raw}" is not a real instant`);
425
442
  }
443
+ const date = new Date(local.getTime() - sign * (offsetHours * 60 + offsetMinutes) * 60_000);
444
+ // An offset can push the instant outside what the canonical UTC form
445
+ // can represent (0000-01-01T00:30+01:00 is year -1); every decoded
446
+ // value must re-serialize, so reject it here rather than at encode.
447
+ const utcYear = date.getUTCFullYear();
448
+ if (utcYear < 0 || utcYear > 9999) {
449
+ throw grammarParseError(`"${raw}" is outside the representable 0000-9999 range in UTC`);
450
+ }
426
451
  return date;
427
452
  },
428
453
  serializeElement: (value) => expectSerializableDate(value).toISOString(),
package/dist/path.js CHANGED
@@ -98,7 +98,7 @@ export function decodeParams(route, source, options) {
98
98
  key: segment.name,
99
99
  message: error.message,
100
100
  // "parse" vs "validate" comes from the ParseError's own
101
- // selfDescribing flag — structural, never message sniffing.
101
+ // ~selfDescribing flag — structural, never message sniffing.
102
102
  reason: parseIssueReason(error),
103
103
  // Issue.wire is the codec-grammar-layer value — the DECODED
104
104
  // segment, not the raw URL text — matching decodeSearch,
package/dist/route.d.ts CHANGED
@@ -1,7 +1,7 @@
1
- import type { AnyCodec, OutputOf, ParamCodec } from "./codec.js";
2
- import { type RouteDecodeError } from "./errors.js";
1
+ import type { AnyCodec, InferCodecOutput, ParamCodec } from "./codec.js";
2
+ import { ParamsDecodeError, type RouteDecodeError, SearchDecodeError } from "./errors.js";
3
3
  import { type ParamsSource, type PathSegment } from "./path.js";
4
- import { type SearchOutputOf, type SearchSlot } from "./search.js";
4
+ import { type InferSearchOutput, type SearchSlot } from "./search.js";
5
5
  /**
6
6
  * `any` is deliberate (same variance gotcha as AnyCodec): codec configs
7
7
  * reach contravariant positions through the parse methods and `HrefArgs`;
@@ -27,20 +27,20 @@ export interface AppRoute<Path extends string, PC extends ParamsConfig<Path>, SC
27
27
  * FIRST — a params grammar failure means the URL doesn't denote this
28
28
  * route at all (morally a 404), so it throws before search is decoded.
29
29
  */
30
- parse(props: RoutePropsInput): Promise<{
30
+ parse(props: RoutePropsLike): Promise<{
31
31
  params: ParamsOutput<Path, PC>;
32
- search: SearchOutputOf<SC>;
32
+ search: InferSearchOutput<SC>;
33
33
  }>;
34
34
  /** Bare params object — layout props are structurally assignable. */
35
- parseParams(props: ParamsPropsInput): Promise<ParamsOutput<Path, PC>>;
35
+ parseParams(props: ParamsPropsLike): Promise<ParamsOutput<Path, PC>>;
36
36
  /** Bare search object — the search half alone. */
37
- parseSearch(props: SearchPropsInput): Promise<SearchOutputOf<SC>>;
38
- safeParse(props: RoutePropsInput): Promise<SafeResult<{
37
+ parseSearch(props: SearchPropsLike): Promise<InferSearchOutput<SC>>;
38
+ safeParse(props: RoutePropsLike): Promise<SafeResult<{
39
39
  params: ParamsOutput<Path, PC>;
40
- search: SearchOutputOf<SC>;
40
+ search: InferSearchOutput<SC>;
41
41
  }>>;
42
- safeParseParams(props: ParamsPropsInput): Promise<SafeResult<ParamsOutput<Path, PC>>>;
43
- safeParseSearch(props: SearchPropsInput): Promise<SafeResult<SearchOutputOf<SC>>>;
42
+ safeParseParams(props: ParamsPropsLike): Promise<SafeResult<ParamsOutput<Path, PC>, ParamsDecodeError>>;
43
+ safeParseSearch(props: SearchPropsLike): Promise<SafeResult<InferSearchOutput<SC>, SearchDecodeError>>;
44
44
  }
45
45
  /** Names of `[...name]` catch-all segments in the path literal. */
46
46
  export type CatchAllNames<Path extends string> = Segments<Path> extends infer S extends string ? S extends `[...${infer Name}]` ? NonEmptyName<Name> : never : never;
@@ -52,9 +52,14 @@ export type CatchAllNames<Path extends string> = Segments<Path> extends infer S
52
52
  export type ConformParams<Path extends string, PC> = PC & Record<Exclude<keyof PC, PathParamNames<Path>>, never>;
53
53
  /** Decoded params object type for a route; see {@link ParamsOutput}. */
54
54
  export type InferRouteParams<R extends AnyRoute> = ParamsOutput<R["path"], R["~params"]>;
55
+ /**
56
+ * Decoded search object type for a route — the {@link InferRouteParams}
57
+ * twin, and the public spelling that keeps `~search` out of user code.
58
+ */
59
+ export type InferRouteSearch<R extends AnyRoute> = InferSearchOutput<R["~search"]>;
55
60
  /**
56
61
  * Accepts promised props and plain objects alike. This width lives on
57
- * the parse INPUT surface ({@link RoutePropsInput} and friends), not on the
62
+ * the parse INPUT surface ({@link RoutePropsLike} and friends), not on the
58
63
  * annotation types: every supported Next (peer `>=15`) delivers page props
59
64
  * as promises, and Next 15.5's generated `.next/types` page check requires
60
65
  * a page's `params` prop to be `Promise<any> | undefined` — a sync arm in
@@ -105,12 +110,12 @@ export interface PagesRoute<Path extends string, PC extends ParamsConfig<Path>,
105
110
  */
106
111
  parseContext(context: PagesContext): {
107
112
  params: ParamsOutput<Path, PC>;
108
- search: SearchOutputOf<SC>;
113
+ search: InferSearchOutput<SC>;
109
114
  };
110
115
  /** {@link parseContext} in the safe shape — `safely`'s taxonomy. */
111
116
  safeParseContext(context: PagesContext): SafeResult<{
112
117
  params: ParamsOutput<Path, PC>;
113
- search: SearchOutputOf<SC>;
118
+ search: InferSearchOutput<SC>;
114
119
  }>;
115
120
  }
116
121
  /**
@@ -124,7 +129,7 @@ export interface PagesRoute<Path extends string, PC extends ParamsConfig<Path>,
124
129
  export interface ParamourRegister {
125
130
  }
126
131
  /** The decoded output type of the codec at key `K`, if one is declared. */
127
- export type ParamOutput<PC, K extends PropertyKey> = K extends keyof PC ? PC[K] extends AnyCodec ? OutputOf<PC[K]> : never : never;
132
+ export type ParamOutput<PC, K extends PropertyKey> = K extends keyof PC ? PC[K] extends AnyCodec ? InferCodecOutput<PC[K]> : never : never;
128
133
  /**
129
134
  * Params schema shape for a path: one codec per dynamic segment name. The
130
135
  * codec describes ONE segment element (D5/D6) — arrays come from the segment
@@ -160,7 +165,7 @@ export interface ParamsProps {
160
165
  * objects — see {@link MaybePromise} for why the annotation type is
161
166
  * promise-only while the parse input stays wide.
162
167
  */
163
- export interface ParamsPropsInput {
168
+ export interface ParamsPropsLike {
164
169
  readonly params?: MaybePromise<ParamsSource>;
165
170
  }
166
171
  /** Every dynamic segment name in the path literal. */
@@ -206,7 +211,8 @@ export type RegisteredStaticRoutePaths = [PresentRegisteredPaths] extends [
206
211
  * {@link AppRoute} / {@link PagesRoute} — gating it via the interface split
207
212
  * makes the wrong surface ABSENT, not just ill-typed. `~`-prefixed members
208
213
  * are runtime-internal, not public API — same convention as codecs;
209
- * `@paramour/next` is a blessed consumer, user code is not.
214
+ * the lockstep `@paramour-js/*` packages are blessed consumers, user code is
215
+ * not — and these members are outside semver.
210
216
  */
211
217
  export interface Route<Path extends string, PC extends ParamsConfig<Path>, SC extends SearchSlot, R extends RouterKind = RouterKind> {
212
218
  readonly path: Path;
@@ -244,7 +250,7 @@ export type RouteConfig<Path extends string, PC extends ParamsConfig<Path>, SC e
244
250
  export interface RouteProps extends ParamsProps, SearchProps {
245
251
  }
246
252
  /** What `parse`/`safeParse` ACCEPT: {@link RouteProps} plus sync props. */
247
- export interface RoutePropsInput extends ParamsPropsInput, SearchPropsInput {
253
+ export interface RoutePropsLike extends ParamsPropsLike, SearchPropsLike {
248
254
  }
249
255
  /** Which router a route belongs to — the value of the `~router` brand. */
250
256
  export type RouterKind = "app" | "pages";
@@ -252,13 +258,15 @@ export type RouterKind = "app" | "pages";
252
258
  * Status-discriminated result shape, unified with the pages hooks'
253
259
  * `RouterResult` (which extends this union by one `pending` member):
254
260
  * `if (result.status === "error")` narrows both arms, and both routers'
255
- * results destructure identically.
261
+ * results destructure identically. `E` narrows the error arm on surfaces
262
+ * that can only fail one way — params-only surfaces carry
263
+ * `ParamsDecodeError`, search-only ones `SearchDecodeError`.
256
264
  */
257
- export type SafeResult<T> = {
265
+ export type SafeResult<T, E extends RouteDecodeError = RouteDecodeError> = {
258
266
  data: T;
259
267
  status: "success";
260
268
  } | {
261
- error: RouteDecodeError;
269
+ error: E;
262
270
  status: "error";
263
271
  };
264
272
  /**
@@ -268,8 +276,8 @@ export type SafeResult<T> = {
268
276
  export interface SearchProps {
269
277
  readonly searchParams?: Promise<ParamsSource>;
270
278
  }
271
- /** Sync-accepting twin of {@link SearchProps} — see {@link ParamsPropsInput}. */
272
- export interface SearchPropsInput {
279
+ /** Sync-accepting twin of {@link SearchProps} — see {@link ParamsPropsLike}. */
280
+ export interface SearchPropsLike {
273
281
  readonly searchParams?: MaybePromise<ParamsSource>;
274
282
  }
275
283
  /**
package/dist/route.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { describeType, foreignMessage, ParamourError, ParamsDecodeError, SearchDecodeError, } from "./errors.js";
2
2
  import { decodeParams, tokenizePath, } from "./path.js";
3
- import { decodeSearch, } from "./search.js";
3
+ import { decodeSearchSlot, } from "./search.js";
4
4
  /**
5
5
  * Defines an App Router route: the URL-shaped path literal plus its
6
6
  * param/search codec configs. Validates the literal eagerly — fail-fast at
@@ -19,7 +19,7 @@ export function defineAppRoute(path, config) {
19
19
  const decodedParams = decodeParams(route, paramsSource ?? {});
20
20
  return {
21
21
  params: decodedParams,
22
- search: decodeSearch(route["~search"], searchSource ?? {}, route.path),
22
+ search: decodeSearchSlot(route["~search"], searchSource ?? {}, route.path),
23
23
  };
24
24
  },
25
25
  async parseParams(props) {
@@ -28,7 +28,7 @@ export function defineAppRoute(path, config) {
28
28
  },
29
29
  async parseSearch(props) {
30
30
  const source = await awaitProp(props.searchParams);
31
- return decodeSearch(route["~search"], source ?? {}, route.path);
31
+ return decodeSearchSlot(route["~search"], source ?? {}, route.path);
32
32
  },
33
33
  safeParse(props) {
34
34
  return safely(() => route.parse(props));
@@ -71,7 +71,7 @@ export function definePagesRoute(path, config) {
71
71
  });
72
72
  return {
73
73
  params: decodedParams,
74
- search: decodeSearch(route["~search"], searchSource, route.path),
74
+ search: decodeSearchSlot(route["~search"], searchSource, route.path),
75
75
  };
76
76
  },
77
77
  safeParseContext(context) {
@@ -173,7 +173,9 @@ function routeData(router, path, config) {
173
173
  /**
174
174
  * Wraps a throwing parse into the status-discriminated shape.
175
175
  * Only decode failures become the `error` arm; source-contract violations
176
- * and rebranded foreign errors stay loud.
176
+ * and rebranded foreign errors stay loud. `E` is the caller's claim about
177
+ * which decode error `run` can throw — params-only runs never throw
178
+ * `SearchDecodeError` and vice versa, which is what makes the cast sound.
177
179
  */
178
180
  async function safely(run) {
179
181
  try {
@@ -182,7 +184,7 @@ async function safely(run) {
182
184
  catch (error) {
183
185
  if (error instanceof ParamsDecodeError ||
184
186
  error instanceof SearchDecodeError) {
185
- return { error, status: "error" };
187
+ return { error: error, status: "error" };
186
188
  }
187
189
  throw error;
188
190
  }
@@ -1,6 +1,7 @@
1
1
  import type { AnyRoute, InferRouteParams, SafeResult } from "./route.js";
2
+ import { ParamsDecodeError, SearchDecodeError } from "./errors.js";
2
3
  import { type DecodeParamsOptions, type ParamsSource } from "./path.js";
3
- import { type SearchOutputOf, type SearchSource } from "./search.js";
4
+ import { type InferSearchOutput, type SearchSlot, type SearchSource, type SlotOf } from "./search.js";
4
5
  /**
5
6
  * Sync `SafeResult` twins of {@link decodeParams} / {@link decodeSearch},
6
7
  * carrying the route methods' safe-parse stance down to the
@@ -12,6 +13,9 @@ import { type SearchOutputOf, type SearchSource } from "./search.js";
12
13
  * unchanged.
13
14
  */
14
15
  /** Decoded route params as a `SafeResult` (discriminated on `status`). */
15
- export declare function safeDecodeParams<R extends AnyRoute>(route: R, source: ParamsSource, options?: DecodeParamsOptions): SafeResult<InferRouteParams<R>>;
16
- /** Decoded search params as a `SafeResult` (discriminated on `status`). */
17
- export declare function safeDecodeSearch<R extends AnyRoute>(route: R, source: SearchSource): SafeResult<SearchOutputOf<R["~search"]>>;
16
+ export declare function safeDecodeParams<R extends AnyRoute>(route: R, source: ParamsSource, options?: DecodeParamsOptions): SafeResult<InferRouteParams<R>, ParamsDecodeError>;
17
+ /**
18
+ * Decoded search params as a `SafeResult` (discriminated on `status`).
19
+ * `target` is a route or a bare `search:` slot, as for {@link decodeSearch}.
20
+ */
21
+ export declare function safeDecodeSearch<T extends AnyRoute | SearchSlot>(target: T, source: SearchSource): SafeResult<InferSearchOutput<SlotOf<T>>, SearchDecodeError>;
@@ -22,18 +22,13 @@ export function safeDecodeParams(route, source, options) {
22
22
  throw error;
23
23
  }
24
24
  }
25
- /** Decoded search params as a `SafeResult` (discriminated on `status`). */
26
- export function safeDecodeSearch(route, source) {
25
+ /**
26
+ * Decoded search params as a `SafeResult` (discriminated on `status`).
27
+ * `target` is a route or a bare `search:` slot, as for {@link decodeSearch}.
28
+ */
29
+ export function safeDecodeSearch(target, source) {
27
30
  try {
28
- // decodeSearch is keyed on SearchOutputOf (SS6) — the correct
29
- // public type — but AnyRoute erases its SC to `any`, so for a still-
30
- // generic R the call's value side reduces to `unknown` while the
31
- // annotation side stays deferred. The cast bridges that inference gap to
32
- // the SAME (correct) type.
33
- return {
34
- data: decodeSearch(route["~search"], source, route.path),
35
- status: "success",
36
- };
31
+ return { data: decodeSearch(target, source), status: "success" };
37
32
  }
38
33
  catch (error) {
39
34
  if (error instanceof SearchDecodeError)
package/dist/search.d.ts CHANGED
@@ -1,28 +1,27 @@
1
1
  import type { StandardSchemaV1 } from "@standard-schema/spec";
2
- import type { AnyCodec, OutputOf, PresenceOf } from "./codec.js";
3
- /**
4
- * href-input side (D4): required presence stays required;
5
- * optional and defaulted keys may be omitted. Array (arity-"many") keys may
6
- * also be omitted: absent and [] are the same wire state (S6/P6), so
7
- * requiring `tags: []` ceremony would be pure noise. Omittable keys also
8
- * admit an EXPLICIT `undefined` — encodeSearch already treats that value as
9
- * absent (S3), and without the `| undefined` widening a decoded
10
- * {@link InferSearchOutput} (every key present, optional presence as
11
- * `| undefined`) could not flow back into href under
12
- * `exactOptionalPropertyTypes` without key-by-key reassembly.
13
- */
14
- export type InferSearchInput<S extends SearchConfig> = {
15
- [K in Exclude<keyof S, OptionalInputKeys<S>>]: OutputOf<S[K]>;
16
- } & {
17
- [K in OptionalInputKeys<S>]?: OutputOf<S[K]> | undefined;
18
- };
19
- /**
20
- * Parse-output side (D4): every declared key is PRESENT on the
21
- * object; optional presence contributes `| undefined` to the value type.
22
- */
23
- export type InferSearchOutput<S extends SearchConfig> = {
24
- [K in keyof S]: PresenceOf<S[K]> extends "optional" ? OutputOf<S[K]> | undefined : OutputOf<S[K]>;
25
- };
2
+ import type { AnyCodec, InferCodecOutput, PresenceOf } from "./codec.js";
3
+ import type { AnyRoute } from "./route.js";
4
+ /**
5
+ * href / encode input of a `search:` slot (SS6). A codec map: required
6
+ * presence stays required; optional and defaulted keys may be omitted
7
+ * (D4). Array (arity-"many") keys may also be omitted: absent and [] are
8
+ * the same wire state (S6/P6), so requiring `tags: []` ceremony would be
9
+ * pure noise. Omittable keys also admit an EXPLICIT `undefined` —
10
+ * encodeSearch already treats that value as absent (S3), and without the
11
+ * `| undefined` widening a decoded {@link InferSearchOutput} could not flow
12
+ * back into href under `exactOptionalPropertyTypes` without key-by-key
13
+ * reassembly. A `RawSearch` slot accepts the raw wire record instead (SS5 —
14
+ * the schema never runs on encode, so there's no encode-side type to infer
15
+ * from it).
16
+ */
17
+ export type InferSearchInput<SC extends SearchSlot> = SC extends RawSearch<StandardSchemaV1> ? Record<string, string | string[]> : SC extends SearchConfig ? CodecMapInput<SC> : never;
18
+ /**
19
+ * Decoded output of a `search:` slot (SS6). A codec map: every declared key
20
+ * is PRESENT on the object; optional presence contributes `| undefined` to
21
+ * the value type (D4). A `RawSearch` slot's output is the schema's own
22
+ * inferred output.
23
+ */
24
+ export type InferSearchOutput<SC extends SearchSlot> = SC extends RawSearch<infer S> ? StandardSchemaV1.InferOutput<S> : SC extends SearchConfig ? CodecMapOutput<SC> : never;
26
25
  /**
27
26
  * The whole-object search escape hatch (SS1/SS2): wraps a bare
28
27
  * Standard Schema in a branded marker so `search:` config discrimination is
@@ -37,25 +36,9 @@ export interface RawSearch<S extends StandardSchemaV1> {
37
36
  /** A search-params schema: key → codec. */
38
37
  export type SearchConfig = Record<string, AnyCodec>;
39
38
  /**
40
- * href / encode side of a `search:` config (SS6): a `RawSearch`
41
- * route accepts the raw wire record (SS5 — the schema never runs on encode,
42
- * so there's no encode-side type to infer from it); a codec map keeps its
43
- * existing `InferSearchInput` behavior. Module-exported for route.ts/href.ts,
44
- * not barrel-exported — same precedent as `encodeComponent`/`readInputValue`.
45
- */
46
- export type SearchInputOf<SC> = SC extends RawSearch<StandardSchemaV1> ? Record<string, string | string[]> : SC extends SearchConfig ? InferSearchInput<SC> : never;
47
- /**
48
- * Parse-output side of a `search:` config (SS6): a `RawSearch`
49
- * route's output is the schema's own inferred output; a codec map keeps its
50
- * existing `InferSearchOutput` behavior. Module-exported for
51
- * route.ts/href.ts, not barrel-exported.
52
- */
53
- export type SearchOutputOf<SC> = SC extends RawSearch<infer S> ? StandardSchemaV1.InferOutput<S> : SC extends SearchConfig ? InferSearchOutput<SC> : never;
54
- /**
55
- * The `search:` config slot's full type (SS2): a codec map (the
56
- * main road) or a `RawSearch` marker (the escape hatch). Internal — not
57
- * barrel-exported; `Route`/`RouteConfig`/`HrefArgs` consume it as their `SC`
58
- * bound.
39
+ * The `search:` config slot's full type (SS2): a codec map (the main road)
40
+ * or a `RawSearch` marker (the escape hatch). Public so route wrappers can
41
+ * bound their own `SC` parameter the way `define*Route` does.
59
42
  */
60
43
  export type SearchSlot = RawSearch<StandardSchemaV1> | SearchConfig;
61
44
  /**
@@ -64,6 +47,21 @@ export type SearchSlot = RawSearch<StandardSchemaV1> | SearchConfig;
64
47
  * platform.
65
48
  */
66
49
  export type SearchSource = Record<string, string | string[] | undefined> | URLSearchParams;
50
+ /**
51
+ * The `search:` slot a search-function target resolves to: a route's own
52
+ * slot, or the slot itself. Every standalone search function accepts either
53
+ * form (the `nuqsParsers` precedent), so route users never reach for
54
+ * `~search`. Module-exported for safe-decode.ts, not barrel-exported.
55
+ */
56
+ export type SlotOf<T extends AnyRoute | SearchSlot> = T extends AnyRoute ? T["~search"] : T;
57
+ type CodecMapInput<S extends SearchConfig> = {
58
+ [K in Exclude<keyof S, OptionalInputKeys<S>>]: InferCodecOutput<S[K]>;
59
+ } & {
60
+ [K in OptionalInputKeys<S>]?: InferCodecOutput<S[K]> | undefined;
61
+ };
62
+ type CodecMapOutput<S extends SearchConfig> = {
63
+ [K in keyof S]: PresenceOf<S[K]> extends "optional" ? InferCodecOutput<S[K]> | undefined : InferCodecOutput<S[K]>;
64
+ };
67
65
  type OptionalInputKeys<S extends SearchConfig> = {
68
66
  [K in keyof S]: S[K]["~arity"] extends "many" ? K : PresenceOf<S[K]> extends "required" ? never : K;
69
67
  }[keyof S];
@@ -84,11 +82,18 @@ export declare function buildSearchString(pairs: readonly (readonly [string, str
84
82
  * path instead: every source key reaches the schema (P8 does not apply
85
83
  * there — the schema owns stripping or passing through extras).
86
84
  *
87
- * `routePath` anchors a thrown {@link SearchDecodeError} to the owning
88
- * route's path pattern — route-level surfaces pass `route.path`; standalone
89
- * callers (nuqs, devtools) omit it and the error stays route-less.
85
+ * `target` is a route or a bare `search:` slot. A route anchors a thrown
86
+ * {@link SearchDecodeError} to its path pattern; a bare slot (nuqs,
87
+ * devtools, middleware snippets) leaves the error route-less.
88
+ */
89
+ export declare function decodeSearch<T extends AnyRoute | SearchSlot>(target: T, source: SearchSource): InferSearchOutput<SlotOf<T>>;
90
+ /**
91
+ * {@link decodeSearch} on an already-resolved slot. Module-exported for the
92
+ * route methods, whose generic `SC` is the slot itself — going through the
93
+ * route-or-slot entry would re-derive it as `SlotOf<AppRoute<…>>`, which
94
+ * doesn't reduce back to `SC` inside a generic body.
90
95
  */
91
- export declare function decodeSearch<S extends SearchSlot>(config: S, source: SearchSource, routePath?: string): SearchOutputOf<S>;
96
+ export declare function decodeSearchSlot<S extends SearchSlot>(config: S, source: SearchSource, routePath: null | string): InferSearchOutput<S>;
92
97
  /**
93
98
  * encodeURIComponent throws a raw URIError on lone surrogates; wrap it so
94
99
  * the documented "every error is a ParamourError" contract holds (S7).
@@ -114,7 +119,7 @@ export declare function encodeComponent(text: string): string;
114
119
  * serializer exists for a whole-object schema, so the caller's record goes
115
120
  * straight to the byte layer and the schema never runs on encode.
116
121
  */
117
- export declare function encodeSearch<S extends SearchSlot>(config: S, input: SearchInputOf<S>): [string, string][];
122
+ export declare function encodeSearch<T extends AnyRoute | SearchSlot>(target: T, input: InferSearchInput<SlotOf<T>>): [string, string][];
118
123
  /**
119
124
  * Runtime discriminant for the `search:` slot (SS2): probes the
120
125
  * `~kind` marker's VALUE, which is unambiguous against a codec map — a map
@@ -171,7 +176,7 @@ export declare function readInputValue(values: Record<string, unknown>, key: str
171
176
  */
172
177
  export declare function requireSearchConfig(config: SearchSlot): void;
173
178
  /** Convenience: encode + build in one step. */
174
- export declare function searchToString<S extends SearchSlot>(config: S, input: SearchInputOf<S>): string;
179
+ export declare function searchToString<T extends AnyRoute | SearchSlot>(target: T, input: InferSearchInput<SlotOf<T>>): string;
175
180
  /**
176
181
  * Invokes a codec's serializer and enforces its string contract: a custom
177
182
  * codec written in plain JS can return undefined, which would otherwise
package/dist/search.js CHANGED
@@ -24,14 +24,24 @@ export function buildSearchString(pairs) {
24
24
  * path instead: every source key reaches the schema (P8 does not apply
25
25
  * there — the schema owns stripping or passing through extras).
26
26
  *
27
- * `routePath` anchors a thrown {@link SearchDecodeError} to the owning
28
- * route's path pattern — route-level surfaces pass `route.path`; standalone
29
- * callers (nuqs, devtools) omit it and the error stays route-less.
27
+ * `target` is a route or a bare `search:` slot. A route anchors a thrown
28
+ * {@link SearchDecodeError} to its path pattern; a bare slot (nuqs,
29
+ * devtools, middleware snippets) leaves the error route-less.
30
30
  */
31
- export function decodeSearch(config, source, routePath) {
31
+ export function decodeSearch(target, source) {
32
+ const [config, routePath] = resolveSearchTarget(target);
33
+ return decodeSearchSlot(config, source, routePath);
34
+ }
35
+ /**
36
+ * {@link decodeSearch} on an already-resolved slot. Module-exported for the
37
+ * route methods, whose generic `SC` is the slot itself — going through the
38
+ * route-or-slot entry would re-derive it as `SlotOf<AppRoute<…>>`, which
39
+ * doesn't reduce back to `SC` inside a generic body.
40
+ */
41
+ export function decodeSearchSlot(config, source, routePath) {
32
42
  requireSearchConfig(config);
33
43
  if (isRawSearch(config)) {
34
- return decodeRawSearch(config, source, routePath ?? null);
44
+ return decodeRawSearch(config, source, routePath);
35
45
  }
36
46
  // The conditional SearchSlot doesn't narrow inside the generic body once
37
47
  // the RawSearch branch returns (S stays a generic type parameter); this
@@ -132,7 +142,7 @@ export function decodeSearch(config, source, routePath) {
132
142
  }
133
143
  }
134
144
  if (issues.length > 0) {
135
- throw new SearchDecodeError(issues, routePath ?? null);
145
+ throw new SearchDecodeError(issues, routePath);
136
146
  }
137
147
  return Object.fromEntries(entries);
138
148
  }
@@ -163,7 +173,8 @@ export function encodeComponent(text) {
163
173
  * serializer exists for a whole-object schema, so the caller's record goes
164
174
  * straight to the byte layer and the schema never runs on encode.
165
175
  */
166
- export function encodeSearch(config, input) {
176
+ export function encodeSearch(target, input) {
177
+ const [config] = resolveSearchTarget(target);
167
178
  requireSearchConfig(config);
168
179
  if (isRawSearch(config)) {
169
180
  return encodeRawSearch(input);
@@ -302,8 +313,8 @@ export function requireSearchConfig(config) {
302
313
  }
303
314
  }
304
315
  /** Convenience: encode + build in one step. */
305
- export function searchToString(config, input) {
306
- return buildSearchString(encodeSearch(config, input));
316
+ export function searchToString(target, input) {
317
+ return buildSearchString(encodeSearch(target, input));
307
318
  }
308
319
  /**
309
320
  * Invokes a codec's serializer and enforces its string contract: a custom
@@ -533,3 +544,21 @@ function requireRawSearchString(key, value) {
533
544
  }
534
545
  return value;
535
546
  }
547
+ /**
548
+ * Splits a search-function target into its slot and the path that anchors
549
+ * errors. Routes are recognized by their `~search`/`~segments` members —
550
+ * `~`-prefixed keys are reserved, so no codec map or `RawSearch` marker can
551
+ * carry both. Anything else passes through for `requireSearchConfig` to
552
+ * validate.
553
+ */
554
+ function resolveSearchTarget(target) {
555
+ const untrusted = target;
556
+ if (typeof untrusted === "object" &&
557
+ untrusted !== null &&
558
+ "~search" in untrusted &&
559
+ "~segments" in untrusted) {
560
+ const route = target;
561
+ return [route["~search"], route.path];
562
+ }
563
+ return [target, null];
564
+ }
@@ -1,6 +1,5 @@
1
1
  import type { StandardSchemaV1 } from "@standard-schema/spec";
2
- import type { AnyRoute } from "./route.js";
3
- import { type SearchOutputOf } from "./search.js";
2
+ import type { AnyRoute, InferRouteSearch } from "./route.js";
4
3
  /**
5
4
  * Standard Schema generate-OUT: exports a route's `search:` config as a
6
5
  * spec-compliant Standard Schema. The mirror of schema.ts, which runs
@@ -14,7 +13,7 @@ import { type SearchOutputOf } from "./search.js";
14
13
  * shape. `types` is carried by this annotation alone; the spec reads it at
15
14
  * the type level only, so no runtime key exists.
16
15
  */
17
- export type StandardSearchSchema<SC> = StandardSchemaV1<Record<string, string | string[] | undefined>, SearchOutputOf<SC>>;
16
+ export type StandardSearchSchema<R extends AnyRoute> = StandardSchemaV1<Record<string, string | string[] | undefined>, InferRouteSearch<R>>;
18
17
  /**
19
18
  * Exports a route's `search:` config as the URL wire contract in Standard
20
19
  * Schema form, for consumers like tRPC inputs or TanStack `validateSearch`.
@@ -24,4 +23,4 @@ export type StandardSearchSchema<SC> = StandardSchemaV1<Record<string, string |
24
23
  * reject (P5). No coercion, ever: the schema accepts wire strings (`"42"`),
25
24
  * not decoded values (`42`).
26
25
  */
27
- export declare function standardSearchSchema<R extends AnyRoute>(route: R): StandardSearchSchema<R["~search"]>;
26
+ export declare function standardSearchSchema<R extends AnyRoute>(route: R): StandardSearchSchema<R>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "paramour",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": {