paramour 0.5.0 → 0.6.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 ADDED
@@ -0,0 +1,44 @@
1
+ # paramour
2
+
3
+ Type-safe routing companion for the Next.js App Router: validated, typed
4
+ route and search params, type-checked path building, and a predictable,
5
+ human-readable URL wire format. Validation is bring-your-own via
6
+ [Standard Schema](https://github.com/standard-schema/standard-schema) —
7
+ paramour owns serialization (the part validators can't do), your validator
8
+ owns the rules.
9
+
10
+ ```sh
11
+ pnpm add paramour @paramour-js/next
12
+ ```
13
+
14
+ ```ts
15
+ import { defineAppRoute, href, p } from "paramour";
16
+
17
+ export const productRoute = defineAppRoute("/product/[id]", {
18
+ params: { id: p.integer() },
19
+ search: { q: p.string().optional() },
20
+ });
21
+
22
+ // typed, validated, explicit: "/product/42?q=paramour"
23
+ href(productRoute, { params: { id: 42 }, search: { q: "paramour" } });
24
+
25
+ // a string into p.integer() fails to compile
26
+ href(productRoute, { params: { id: "42" } });
27
+ ```
28
+
29
+ Routes are plain imported objects — no central registry, nothing to
30
+ tree-shake around. Codecs are bidirectional wire converters with a
31
+ type-state modifier API (`.optional()`, `.default()`, `.catch()`) where
32
+ illegal chains fail to compile, and every codec serializes by a
33
+ [published, numbered spec](https://paramour.dev/docs/reference/wire-format).
34
+
35
+ ## Docs
36
+
37
+ - [Getting started](https://paramour.dev/docs/getting-started)
38
+ - [Core API reference](https://paramour.dev/docs/reference/core)
39
+ - [Wire-format spec & explorer](https://paramour.dev/docs/reference/wire-format)
40
+ - [Migrating from next-typesafe-url](https://paramour.dev/docs/migrate)
41
+
42
+ ## License
43
+
44
+ MIT © Jason Paff
package/dist/codec.d.ts CHANGED
@@ -11,11 +11,10 @@ export type Arity = "many" | "single";
11
11
  *
12
12
  * `Out` is the decoded in-memory type. `P`, `C`, `A`, and `E` are type-state:
13
13
  * modifier methods are conditionally `never`, so illegal chains
14
- * (`.optional().default()`, double `.catch()`) fail to compile (design-02 D3).
15
- * `E` carries `~defaultElides` as a literal after `.default()` (NQ6a).
16
- * Presence modifiers are also `never` for arity-"many" codecs: absent and `[]`
17
- * are the same wire state (S6/P6), so `.default()`/`.optional()` could never
18
- * round-trip there.
14
+ * (`.optional().default()`, double `.catch()`) fail to compile. `E` carries
15
+ * `~defaultElides` as a literal after `.default()`. Presence modifiers are
16
+ * also `never` for arity-"many" codecs: absent and `[]` are the same wire
17
+ * state (S6/P6), so `.default()`/`.optional()` could never round-trip there.
19
18
  *
20
19
  * `.default()` and `.catch()` accept either a value or a zero-arg factory;
21
20
  * factories are invoked per decode/encode, so reference-typed defaults can be
@@ -29,8 +28,8 @@ export type Arity = "many" | "single";
29
28
  export interface Codec<Out, P extends Presence = "required", C extends boolean = false, A extends Arity = "single", E extends boolean = boolean> {
30
29
  readonly catch: C extends false ? (fallback: (() => Out) | Out) => Codec<Out, P, true, A, E> : never;
31
30
  /**
32
- * Overloaded so the value/factory split is visible in type-state (NQ6a):
33
- * the factory overload comes FIRST and must stay first. The runtime
31
+ * Overloaded so the value/factory split is visible in type-state: the
32
+ * factory overload comes FIRST and must stay first. The runtime
34
33
  * {@link isFactory} check treats ANY function as a factory, so a function
35
34
  * argument must either match the factory overload or fail to compile
36
35
  * ({@link NonFactoryValue}) — E=true is only ever inferred for arguments
@@ -55,19 +54,18 @@ export interface Codec<Out, P extends Presence = "required", C extends boolean =
55
54
  * factory would elide an explicitly-passed value that later decodes as a
56
55
  * different one.
57
56
  *
58
- * Literal-typed via `E` (NQ6a) so derived surfaces (`@paramour-js/nuqs`)
59
- * can give value-defaulted keys non-nullable reads while keeping
60
- * factory-defaulted keys honestly nullable. A hand-written
61
- * `Codec<…, "defaulted">` leaves `E` at its `boolean` default, which
62
- * consumers must treat as the factory (nullable) branch — the safe
63
- * reading.
57
+ * Literal-typed via `E` so derived surfaces (`@paramour-js/nuqs`) can give
58
+ * value-defaulted keys non-nullable reads while keeping factory-defaulted
59
+ * keys honestly nullable. A hand-written `Codec<…, "defaulted">` leaves
60
+ * `E` at its `boolean` default, which consumers must treat as the factory
61
+ * (nullable) branch — the safe reading.
64
62
  */
65
63
  readonly "~defaultElides": E;
66
64
  /** Stored as a thunk regardless of the form passed to `.default()`. */
67
65
  readonly "~defaultValue": (() => Out) | undefined;
68
66
  /**
69
67
  * Element codec of a composite list codec (`p.csv`, `p.array`) — the
70
- * per-segment/per-key scalar; undefined for every non-composite kind (CV6).
68
+ * per-segment/per-key scalar; undefined for every non-composite kind.
71
69
  */
72
70
  readonly "~element": AnyCodec | undefined;
73
71
  /** Members of a `p.enum` codec; undefined for every other kind. */
@@ -85,11 +83,11 @@ export interface Codec<Out, P extends Presence = "required", C extends boolean =
85
83
  readonly "~serializeElement": (value: unknown) => string;
86
84
  }
87
85
  export type OutputOf<C extends AnyCodec> = C["~out"];
88
- /** Codecs legal in a `params:` config — no presence modifiers (design-02 D5). */
86
+ /** Codecs legal in a `params:` config — no presence modifiers (D5). */
89
87
  export type ParamCodec = Codec<any, "required", boolean>;
90
88
  /**
91
89
  * Presence governs absence semantics and property optionality on both the
92
- * parse-output and href-input sides (design-02 D4). Catch is orthogonal:
90
+ * parse-output and href-input sides (D4). Catch is orthogonal:
93
91
  * it recovers parse *failures*, never absence (D2).
94
92
  */
95
93
  export type Presence = "defaulted" | "optional" | "required";
@@ -98,8 +96,8 @@ export type PresenceOf<C extends AnyCodec> = C["~presence"];
98
96
  * Rejects value-form `.default()` arguments whose static type includes any
99
97
  * function member: runtime {@link isFactory} would treat them as factories,
100
98
  * so letting them infer the value branch would let type-state assert an
101
- * elision (`E = true`) the runtime never performs (NQ6a). Non-distributive
102
- * on purpose — a union with a function member is rejected whole, since its
99
+ * elision (`E = true`) the runtime never performs. Non-distributive on
100
+ * purpose — a union with a function member is rejected whole, since its
103
101
  * runtime branch is unknowable at compile time.
104
102
  */
105
103
  type NonFactoryValue<V> = [Extract<V, (...args: never[]) => unknown>] extends [
@@ -23,8 +23,8 @@ export interface CodecDescription {
23
23
  readonly caught: boolean;
24
24
  readonly defaultValue?: CodecDefaultDescription;
25
25
  /**
26
- * Nested description of a composite list codec's element scalar (CV6;
27
- * `p.csv` and `p.array`).
26
+ * Nested description of a composite list codec's element scalar (`p.csv`
27
+ * and `p.array`).
28
28
  */
29
29
  readonly element?: CodecDescription;
30
30
  readonly enumMembers?: readonly string[];
@@ -32,7 +32,7 @@ export interface CodecDescription {
32
32
  readonly presence: Presence;
33
33
  }
34
34
  /** Rendering styles accepted by {@link formatCodecDescription}. */
35
- export type CodecFormatStyle = "compact" | "verbose";
35
+ export type CodecFormatStyle = "compact" | "shape" | "verbose";
36
36
  /** A param codec plus the dynamic-segment kind that hosts it. */
37
37
  export interface ParamDescription extends CodecDescription {
38
38
  readonly segmentKind: "catchall" | "optional-catchall" | "single";
@@ -57,6 +57,15 @@ export type SearchDescription = {
57
57
  } | {
58
58
  readonly kind: "raw";
59
59
  };
60
+ /**
61
+ * A codec's `"shape"`-style label for decode-issue enrichment. `forceMany`
62
+ * renders the repeated form (`integer[]`) for segment-level catch-all
63
+ * issues, whose codec describes ONE element (the same forced-arity move as
64
+ * render.ts's catch-all params). Exported for path.ts/search.ts and — via
65
+ * the `paramour/internal` tooling entry — for devtools' synthesized issues,
66
+ * never from the package barrel.
67
+ */
68
+ export declare function codecShapeLabel(codec: AnyCodec, forceMany?: boolean): string;
60
69
  /**
61
70
  * Reflects a codec into plain data. This is the public face of the
62
71
  * `~`-prefixed metadata: user code reads descriptions, never the props.
@@ -77,6 +86,8 @@ export declare function describeRoute(route: AnyRoute): RouteDescription;
77
86
  * - `"compact"`: `enum(asc|desc)? =asc catch`, `csv<enum(a|b)>`, `string[]`
78
87
  * — `?` for optional presence, the default's wire form (`=3`) or `=ƒ()`
79
88
  * for a factory default, bare `catch`.
89
+ * - `"shape"`: the bare shape label with no presence/default/catch
90
+ * annotations — the form decode errors cite as an issue's `expected`.
80
91
  * - `"verbose"`: `enum(asc, desc) (optional) (default: asc) (catch)` —
81
92
  * parenthesized annotations in fixed order: presence, default, catch.
82
93
  */
package/dist/describe.js CHANGED
@@ -1,3 +1,15 @@
1
+ /**
2
+ * A codec's `"shape"`-style label for decode-issue enrichment. `forceMany`
3
+ * renders the repeated form (`integer[]`) for segment-level catch-all
4
+ * issues, whose codec describes ONE element (the same forced-arity move as
5
+ * render.ts's catch-all params). Exported for path.ts/search.ts and — via
6
+ * the `paramour/internal` tooling entry — for devtools' synthesized issues,
7
+ * never from the package barrel.
8
+ */
9
+ export function codecShapeLabel(codec, forceMany = false) {
10
+ const description = describeCodec(codec);
11
+ return formatCodecDescription(forceMany ? { ...description, arity: "many" } : description, "shape");
12
+ }
1
13
  /**
2
14
  * Reflects a codec into plain data. This is the public face of the
3
15
  * `~`-prefixed metadata: user code reads descriptions, never the props.
@@ -31,8 +43,9 @@ export function describeRoute(route) {
31
43
  if (segment.kind === "static")
32
44
  continue;
33
45
  const codec = paramsConfig[segment.name];
34
- // Unreachable for routes built by the define constructors (RL1 requires
35
- // exactly the extracted names); guards hand-assembled objects.
46
+ // Unreachable for routes built by the define constructors (they require
47
+ // a codec for exactly the path's extracted param names); guards
48
+ // hand-assembled objects.
36
49
  if (codec === undefined)
37
50
  continue;
38
51
  params[segment.name] = {
@@ -56,11 +69,13 @@ export function describeRoute(route) {
56
69
  * - `"compact"`: `enum(asc|desc)? =asc catch`, `csv<enum(a|b)>`, `string[]`
57
70
  * — `?` for optional presence, the default's wire form (`=3`) or `=ƒ()`
58
71
  * for a factory default, bare `catch`.
72
+ * - `"shape"`: the bare shape label with no presence/default/catch
73
+ * annotations — the form decode errors cite as an issue's `expected`.
59
74
  * - `"verbose"`: `enum(asc, desc) (optional) (default: asc) (catch)` —
60
75
  * parenthesized annotations in fixed order: presence, default, catch.
61
76
  */
62
77
  export function formatCodecDescription(description, style) {
63
- const memberSeparator = style === "compact" ? "|" : ", ";
78
+ const memberSeparator = style === "verbose" ? ", " : "|";
64
79
  const kindLabel = (part) => part.enumMembers === undefined
65
80
  ? part.kind
66
81
  : `enum(${part.enumMembers.join(memberSeparator)})`;
@@ -80,6 +95,8 @@ export function formatCodecDescription(description, style) {
80
95
  return part.arity === "many" ? `${base}[]` : base;
81
96
  };
82
97
  let label = shapeLabel(description);
98
+ if (style === "shape")
99
+ return label;
83
100
  if (style === "compact") {
84
101
  if (description.presence === "optional")
85
102
  label += "?";
package/dist/errors.d.ts CHANGED
@@ -1,9 +1,51 @@
1
- /** One failed key in an aggregate decode error (shared by both surfaces, RL6). */
1
+ /** One failed key in an aggregate decode error (shared by both surfaces). */
2
2
  export interface Issue {
3
+ /**
4
+ * Bare shape label of the codec the key expected (`integer`,
5
+ * `enum(asc|desc)`, `csv<integer>[]`). Absent when no codec owns the key:
6
+ * rawSearch schema issues and foreign throws carry only prose.
7
+ */
8
+ readonly expected?: string;
3
9
  readonly key: string;
4
10
  readonly message: string;
11
+ /**
12
+ * What KIND of failure this issue records — the structured discriminant
13
+ * renderers key on instead of sniffing `message` prose (see
14
+ * {@link IssueReason} for the members). Core's decoders always set it;
15
+ * it is optional only so prose-only issues built outside core (derived
16
+ * tooling, hand-built test fixtures) remain representable — an absent
17
+ * reason means "unclassified", and renderers must not infer one.
18
+ */
19
+ readonly reason?: IssueReason;
20
+ /**
21
+ * The offending value as the codec grammar saw it — the value-layer
22
+ * string AFTER byte-layer percent-decoding, not the raw URL text. Search
23
+ * sources (`URLSearchParams` / Next's `searchParams`) arrive
24
+ * platform-decoded; route params are decoded by core (R5) before the
25
+ * grammar runs — so a segment `1%20x` records `wire: "1 x"` on both
26
+ * surfaces. Present only when a single offending value exists: absent
27
+ * for missing keys, non-string source values, and the duplicate-scalar
28
+ * rejection — absence there is the point, not a data gap.
29
+ */
30
+ readonly wire?: string;
5
31
  }
6
- /** The single error type surfaced by a full route parse failure (RL6). */
32
+ /**
33
+ * The failure kinds an {@link Issue} can record:
34
+ *
35
+ * - `"duplicate"` — a single-value param received multiple wire values (P5).
36
+ * - `"missing"` — a required key had no wire value at all (including a
37
+ * required catch-all whose array arrived empty: the values are missing
38
+ * even though the key exists).
39
+ * - `"parse"` — the codec's OWN wire grammar rejected the value; core's
40
+ * grammar messages quote the value and name the grammar themselves.
41
+ * - `"shape"` — the source value's shape doesn't match the param kind (an
42
+ * array where a single segment belongs, a non-string element, …).
43
+ * - `"validate"` — user-supplied code rejected the value (a Standard Schema
44
+ * validator, a custom codec's parse): its prose is not authored by core
45
+ * and may name neither the value nor the expected shape.
46
+ */
47
+ export type IssueReason = "duplicate" | "missing" | "parse" | "shape" | "validate";
48
+ /** The single error type surfaced by a full route parse failure. */
7
49
  export type RouteDecodeError = ParamsDecodeError | SearchDecodeError;
8
50
  /** Base class for every error paramour throws. */
9
51
  export declare class ParamourError extends Error {
@@ -12,10 +54,12 @@ export declare class ParamourError extends Error {
12
54
  });
13
55
  static [Symbol.hasInstance](value: unknown): value is ParamourError;
14
56
  }
15
- /** Aggregate failure for a whole route-params decode (RL6). */
57
+ /** Aggregate failure for a whole route-params decode. */
16
58
  export declare class ParamsDecodeError extends ParamourError {
17
59
  readonly issues: readonly Issue[];
18
- constructor(issues: readonly Issue[]);
60
+ /** The failed route's path pattern; null when decoded outside a route. */
61
+ readonly route: null | string;
62
+ constructor(issues: readonly Issue[], route?: null | string);
19
63
  static [Symbol.hasInstance](value: unknown): value is ParamsDecodeError;
20
64
  }
21
65
  /**
@@ -23,20 +67,37 @@ export declare class ParamsDecodeError extends ParamourError {
23
67
  * Thrown by element-level parsing; recoverable via `.catch()`.
24
68
  */
25
69
  export declare class ParseError extends ParamourError {
70
+ /**
71
+ * True when the message follows core's grammar-authoring convention —
72
+ * it quotes the offending wire value and names the grammar it failed
73
+ * (`'"x" is not an integer'`). Only core's own grammar throw sites set
74
+ * it (via {@link grammarParseError}); schema-validation failures and
75
+ * rebranded foreign/custom throws stay false, the safe default: an
76
+ * unknown message is assumed to name neither, so renderers supply the
77
+ * expected-shape context themselves. This flag — never message sniffing
78
+ * — is what issue producers key `reason: "parse" | "validate"` on.
79
+ */
80
+ readonly selfDescribing: boolean;
81
+ constructor(message: string, options?: {
82
+ cause?: unknown;
83
+ selfDescribing?: boolean;
84
+ });
26
85
  static [Symbol.hasInstance](value: unknown): value is ParseError;
27
86
  }
28
87
  /** Aggregate failure for a whole search-params decode. */
29
88
  export declare class SearchDecodeError extends ParamourError {
30
89
  readonly issues: readonly Issue[];
31
- constructor(issues: readonly Issue[]);
90
+ /** The failed route's path pattern; null when decoded outside a route. */
91
+ readonly route: null | string;
92
+ constructor(issues: readonly Issue[], route?: null | string);
32
93
  static [Symbol.hasInstance](value: unknown): value is SearchDecodeError;
33
94
  }
34
95
  /**
35
- * A search source violated its wire-shape contract (design-08 STD7): a
36
- * non-object source, or a non-string / non-string[] value under a read key.
37
- * Thrown by search.ts's source readers; distinct from {@link ParamourError}
38
- * so the Standard Schema adapter can soften exactly these throws to issues
39
- * while config-contract violations and rebranded validator throws stay loud.
96
+ * A search source violated its wire-shape contract: a non-object source, or
97
+ * a non-string / non-string[] value under a read key. Thrown by search.ts's
98
+ * source readers; distinct from {@link ParamourError} so the Standard Schema
99
+ * adapter can soften exactly these throws to issues while config-contract
100
+ * violations and rebranded validator throws stay loud.
40
101
  */
41
102
  export declare class SearchSourceError extends ParamourError {
42
103
  /** The offending source key, or null when the source itself is malformed. */
@@ -63,6 +124,24 @@ export declare function describeType(value: unknown): string;
63
124
  * guard.
64
125
  */
65
126
  export declare function foreignMessage(error: unknown): string;
127
+ /**
128
+ * A {@link ParseError} whose message follows core's grammar-authoring
129
+ * convention: it quotes the offending wire value and names the expected
130
+ * grammar (`'"x" is not an integer'`). The one sanctioned way to mint a
131
+ * self-describing ParseError — every `p.*` grammar throw site goes through
132
+ * it, so the convention is enforced by structure, not by prose review.
133
+ * Not exported from the package.
134
+ */
135
+ export declare function grammarParseError(message: string): ParseError;
136
+ /**
137
+ * Maps a caught {@link ParseError} to its {@link Issue} reason: core's
138
+ * grammar-authored messages are `"parse"`, everything else — schema
139
+ * validators, rebranded custom-codec throws — is `"validate"`. Keyed on the
140
+ * structural `selfDescribing` flag, never on message sniffing; shared by
141
+ * search.ts and path.ts so both surfaces classify identically. Not exported
142
+ * from the package.
143
+ */
144
+ export declare function parseIssueReason(error: ParseError): IssueReason;
66
145
  /**
67
146
  * Runs user (or platform) code, letting paramour's own errors pass through
68
147
  * and branding any foreign throw via `wrap` — the shared chokepoint for the
package/dist/errors.js CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Cross-copy identity brands (RL6). `Symbol.for()` keys resolve in the
2
+ * Cross-copy identity brands. `Symbol.for()` keys resolve in the
3
3
  * realm-global symbol registry, so a second physical copy of this module
4
4
  * (dual-package hazard, bundler duplication) mints the SAME symbols:
5
5
  * `instanceof` recognizes instances across copies, while a structurally
@@ -29,15 +29,18 @@ export class ParamourError extends Error {
29
29
  return hasBrand(value, paramourErrorBrand);
30
30
  }
31
31
  }
32
- /** Aggregate failure for a whole route-params decode (RL6). */
32
+ /** Aggregate failure for a whole route-params decode. */
33
33
  export class ParamsDecodeError extends ParamourError {
34
34
  static {
35
35
  brandPrototype(this, paramsDecodeErrorBrand);
36
36
  }
37
37
  issues;
38
- constructor(issues) {
39
- super(`Failed to decode route params: ${formatIssues(issues)}`);
38
+ /** The failed route's path pattern; null when decoded outside a route. */
39
+ route;
40
+ constructor(issues, route = null) {
41
+ super(formatDecodeMessage("route params", issues, route));
40
42
  this.issues = issues;
43
+ this.route = route;
41
44
  }
42
45
  static [Symbol.hasInstance](value) {
43
46
  return hasBrand(value, paramsDecodeErrorBrand);
@@ -51,6 +54,21 @@ export class ParseError extends ParamourError {
51
54
  static {
52
55
  brandPrototype(this, parseErrorBrand);
53
56
  }
57
+ /**
58
+ * True when the message follows core's grammar-authoring convention —
59
+ * it quotes the offending wire value and names the grammar it failed
60
+ * (`'"x" is not an integer'`). Only core's own grammar throw sites set
61
+ * it (via {@link grammarParseError}); schema-validation failures and
62
+ * rebranded foreign/custom throws stay false, the safe default: an
63
+ * unknown message is assumed to name neither, so renderers supply the
64
+ * expected-shape context themselves. This flag — never message sniffing
65
+ * — is what issue producers key `reason: "parse" | "validate"` on.
66
+ */
67
+ selfDescribing;
68
+ constructor(message, options) {
69
+ super(message, options);
70
+ this.selfDescribing = options?.selfDescribing ?? false;
71
+ }
54
72
  static [Symbol.hasInstance](value) {
55
73
  return hasBrand(value, parseErrorBrand);
56
74
  }
@@ -61,20 +79,23 @@ export class SearchDecodeError extends ParamourError {
61
79
  brandPrototype(this, searchDecodeErrorBrand);
62
80
  }
63
81
  issues;
64
- constructor(issues) {
65
- super(`Failed to decode search params: ${formatIssues(issues)}`);
82
+ /** The failed route's path pattern; null when decoded outside a route. */
83
+ route;
84
+ constructor(issues, route = null) {
85
+ super(formatDecodeMessage("search params", issues, route));
66
86
  this.issues = issues;
87
+ this.route = route;
67
88
  }
68
89
  static [Symbol.hasInstance](value) {
69
90
  return hasBrand(value, searchDecodeErrorBrand);
70
91
  }
71
92
  }
72
93
  /**
73
- * A search source violated its wire-shape contract (design-08 STD7): a
74
- * non-object source, or a non-string / non-string[] value under a read key.
75
- * Thrown by search.ts's source readers; distinct from {@link ParamourError}
76
- * so the Standard Schema adapter can soften exactly these throws to issues
77
- * while config-contract violations and rebranded validator throws stay loud.
94
+ * A search source violated its wire-shape contract: a non-object source, or
95
+ * a non-string / non-string[] value under a read key. Thrown by search.ts's
96
+ * source readers; distinct from {@link ParamourError} so the Standard Schema
97
+ * adapter can soften exactly these throws to issues while config-contract
98
+ * violations and rebranded validator throws stay loud.
78
99
  */
79
100
  export class SearchSourceError extends ParamourError {
80
101
  static {
@@ -118,6 +139,28 @@ export function describeType(value) {
118
139
  export function foreignMessage(error) {
119
140
  return error instanceof Error ? error.message : showValue(error);
120
141
  }
142
+ /**
143
+ * A {@link ParseError} whose message follows core's grammar-authoring
144
+ * convention: it quotes the offending wire value and names the expected
145
+ * grammar (`'"x" is not an integer'`). The one sanctioned way to mint a
146
+ * self-describing ParseError — every `p.*` grammar throw site goes through
147
+ * it, so the convention is enforced by structure, not by prose review.
148
+ * Not exported from the package.
149
+ */
150
+ export function grammarParseError(message) {
151
+ return new ParseError(message, { selfDescribing: true });
152
+ }
153
+ /**
154
+ * Maps a caught {@link ParseError} to its {@link Issue} reason: core's
155
+ * grammar-authored messages are `"parse"`, everything else — schema
156
+ * validators, rebranded custom-codec throws — is `"validate"`. Keyed on the
157
+ * structural `selfDescribing` flag, never on message sniffing; shared by
158
+ * search.ts and path.ts so both surfaces classify identically. Not exported
159
+ * from the package.
160
+ */
161
+ export function parseIssueReason(error) {
162
+ return error.selfDescribing ? "parse" : "validate";
163
+ }
121
164
  /**
122
165
  * Runs user (or platform) code, letting paramour's own errors pass through
123
166
  * and branding any foreign throw via `wrap` — the shared chokepoint for the
@@ -152,8 +195,31 @@ function brandPrototype(ctor, brand) {
152
195
  // the brand never leaks into JSON/spread and can't be reassigned.
153
196
  Object.defineProperty(ctor.prototype, brand, { value: true });
154
197
  }
155
- function formatIssues(issues) {
156
- return issues.map((issue) => `[${issue.key}] ${issue.message}`).join("; ");
198
+ /**
199
+ * The aggregate decode message: a route-anchored header plus one `✖` line
200
+ * per issue. Multi-line and pretty BY DEFAULT because the unhandled-throw
201
+ * surfaces that matter (Next's dev overlay, terminal stacks) render
202
+ * `error.message` verbatim — an opt-in `.pretty()` helper would never be
203
+ * reached there. `(expected …)` is keyed on the issue's structured `reason`,
204
+ * never on message/wire sniffing: it renders exactly where the message
205
+ * cannot name the expected shape itself — a `"missing"` key has no value to
206
+ * describe, and a `"validate"` failure carries foreign prose (schema
207
+ * validators, custom parsers) with no authoring convention. Core's own
208
+ * `"parse"` grammar messages already quote the value and name the grammar,
209
+ * a `"duplicate"` or `"shape"` message states a problem that isn't about
210
+ * the grammar at all, and a reason-less issue is unclassified prose — none
211
+ * of those take the suffix.
212
+ */
213
+ function formatDecodeMessage(subject, issues, route) {
214
+ const target = route === null ? subject : `${subject} for ${route}`;
215
+ const lines = issues.map((issue) => {
216
+ const expected = issue.expected !== undefined &&
217
+ (issue.reason === "missing" || issue.reason === "validate")
218
+ ? ` (expected ${issue.expected})`
219
+ : "";
220
+ return ` ✖ ${issue.key}: ${issue.message}${expected}`;
221
+ });
222
+ return [`Failed to decode ${target}:`, ...lines].join("\n");
157
223
  }
158
224
  function hasBrand(value, brand) {
159
225
  if (typeof value !== "object" || value === null)
package/dist/href.d.ts CHANGED
@@ -2,46 +2,44 @@ import type { AnyRoute, RegisteredStaticRoutePaths } from "./route.js";
2
2
  import { type InferParamsInput } from "./path.js";
3
3
  import { type SearchInputOf } from "./search.js";
4
4
  /**
5
- * Type-only brand carrier (RL4): no runtime value ever exists — the brand
6
- * is applied by a compile-time cast, so Href costs nothing at runtime.
5
+ * Type-only brand carrier: no runtime value ever exists — the brand is
6
+ * applied by a compile-time cast, so Href costs nothing at runtime.
7
7
  */
8
8
  declare const HREF: unique symbol;
9
9
  /**
10
- * A paramour-built link (RL4). Assignable TO `string`, so `next/link`,
11
- * `router.push`, `redirect`, `generateMetadata` consume it unchanged
12
- * (DESIGN principle 5); not assignable FROM `string`, which is the enabling
13
- * substrate for the v1.x "accept only paramour-built links" narrowing APIs
14
- * (RL10.6). Removing the brand later would be breaking; RL4 commits to it.
10
+ * A paramour-built link. Assignable TO `string`, so `next/link`,
11
+ * `router.push`, `redirect`, `generateMetadata` consume it unchanged; not
12
+ * assignable FROM `string`, which is the enabling substrate for the future
13
+ * "accept only paramour-built links" narrowing APIs. The brand is a
14
+ * permanent commitment — removing it later would be a breaking change.
15
15
  */
16
16
  export type Href<P extends string = string> = string & {
17
17
  [HREF]: P;
18
18
  };
19
19
  /**
20
- * href's variadic options tuple (RL4): the entire argument is omittable
21
- * when neither half has a required member — `href(aboutRoute)`.
20
+ * href's variadic options tuple: the entire argument is omittable when
21
+ * neither half has a required member — `href(aboutRoute)`.
22
22
  */
23
23
  export type HrefArgs<R extends AnyRoute> = Record<never, never> extends InferHrefInput<R> ? [options?: InferHrefInput<R>] : [options: InferHrefInput<R>];
24
24
  /**
25
- * href's options object (RL4): `{ params, search?, hash? }`. Property
26
- * optionality is presence-driven on BOTH halves (maintainer ruling,
27
- * 2026-07-04, amending RL4's letter): a half may be omitted when its input
25
+ * href's options object: `{ params, search?, hash? }`. Property optionality
26
+ * is presence-driven on BOTH halves: a half may be omitted when its input
28
27
  * type has no required key — for `params` that means static routes and
29
28
  * routes whose only dynamic segment is an optional catch-all; for `search`
30
- * it is design-02 D4's rule surfacing at the property level. A half whose
31
- * input has no keys AT ALL may not be passed even empty (see PartFor).
32
- * `hash` implements S10 — fragments come only from an explicit caller
33
- * option.
29
+ * it is D4's rule surfacing at the property level. A half whose input has no
30
+ * keys AT ALL may not be passed even empty (see PartFor). `hash` implements
31
+ * S10 — fragments come only from an explicit caller option.
34
32
  */
35
33
  export type InferHrefInput<R extends AnyRoute> = PartFor<"params", InferParamsInput<R>> & PartFor<"search", SearchInputOf<R["~search"]>> & {
36
34
  hash?: string;
37
35
  };
38
36
  /**
39
- * The string form's options (SH4): hash only. `params` is meaningless on a
40
- * static path, and a query string comes only from a defined route's search
41
- * codecs — a raw-search escape hatch here would be an untyped side door
42
- * around library-owned serialization. Both are banned outright (`?: never`,
43
- * the 2026-07-04 ruling's move) rather than merely omitted, so a non-fresh
44
- * options object can't smuggle them past excess-property checking.
37
+ * The string form's options: hash only. `params` is meaningless on a static
38
+ * path, and a query string comes only from a defined route's search codecs —
39
+ * a raw-search escape hatch here would be an untyped side door around
40
+ * library-owned serialization. Both are banned outright (`?: never`) rather
41
+ * than merely omitted, so a non-fresh options object can't smuggle them past
42
+ * excess-property checking.
45
43
  */
46
44
  export interface StaticHrefOptions {
47
45
  hash?: string;
@@ -50,29 +48,28 @@ export interface StaticHrefOptions {
50
48
  }
51
49
  /**
52
50
  * One options property whose presence follows its input type: required iff
53
- * the input has at least one required key (the design-02 D4
54
- * `object extends` probe). An input with NO keys at all bans the property
55
- * outright (`?: never`, maintainer ruling 2026-07-04 amending RL4) — the
56
- * bare `Partial<Record<Key, Input>>` form would accept arbitrary junk
57
- * there, because the empty object type is exempt from excess-property
58
- * checking; `?: never` mirrors RouteConfig's static-path `params?: never`.
51
+ * the input has at least one required key (the D4 `object extends` probe).
52
+ * An input with NO keys at all bans the property outright (`?: never`) — the
53
+ * bare `Partial<Record<Key, Input>>` form would accept arbitrary junk there,
54
+ * because the empty object type is exempt from excess-property checking;
55
+ * `?: never` mirrors RouteConfig's static-path `params?: never`.
59
56
  */
60
57
  type PartFor<Key extends string, Input> = keyof Input extends never ? Partial<Record<Key, never>> : Record<never, never> extends Input ? Partial<Record<Key, Input>> : Record<Key, Input>;
61
58
  /**
62
- * Builds a link for a route: fixed path–`?query`–`#hash` assembly (RL4). A
63
- * standalone function, not a route method (DESIGN §4/§8): parse sites sit
64
- * next to one route, href sites import `{ href }` once and use it against
65
- * many routes. Serialization failures are `SerializeError` at link-build
66
- * time (RL5's R-rules); config-contract violations from hand-built routes
67
- * (a missing param codec or `~search` config) are base `ParamourError` — a
68
- * JS caller omitting a required `search` half falls through to
69
- * encodeSearch's own required-missing error.
59
+ * Builds a link for a route: fixed path–`?query`–`#hash` assembly. A
60
+ * standalone function, not a route method: parse sites sit next to one
61
+ * route, while href sites import `{ href }` once and use it against many
62
+ * routes. Serialization failures are `SerializeError` at link-build time
63
+ * (the R-rules); config-contract violations from hand-built routes (a
64
+ * missing param codec or `~search` config) are base `ParamourError` — a JS
65
+ * caller omitting a required `search` half falls through to encodeSearch's
66
+ * own required-missing error.
70
67
  *
71
- * The string form (SH1): a registered STATIC path stands in for the route
72
- * object — same brand, same hash assembly, no route definition needed. The
73
- * string overload sits first so the route-object overload is last (SH8):
74
- * TS's "the last overload gave the following error" heuristic keeps
75
- * route-object misuse diagnostics prominent.
68
+ * The string form: a registered STATIC path stands in for the route object —
69
+ * same brand, same hash assembly, no route definition needed. The string
70
+ * overload sits first so the route-object overload is last: TS's "the last
71
+ * overload gave the following error" heuristic keeps route-object misuse
72
+ * diagnostics prominent.
76
73
  */
77
74
  export declare function href<P extends RegisteredStaticRoutePaths>(path: P, options?: StaticHrefOptions): Href<P>;
78
75
  export declare function href<R extends AnyRoute>(route: R, ...args: HrefArgs<R>): Href<R["path"]>;
package/dist/href.js CHANGED
@@ -12,15 +12,15 @@ export function href(route, options) {
12
12
  const hash = options?.hash;
13
13
  const fragment = hash === undefined || hash === "" ? "" : `#${hash}`;
14
14
  if (typeof route === "string") {
15
- // SH6: fail-fast backstop for JS callers and world-A typos of the
15
+ // Fail-fast backstop for JS callers and world-A typos of the
16
16
  // dynamic-path variety — a bracket means "you need a route object", and
17
17
  // query/hash never ride in the path string (query comes only from
18
18
  // search codecs, hash only from the option).
19
19
  if (!route.startsWith("/") || /[#?[\]]/.test(route)) {
20
20
  throw new ParamourError(`href(path) requires a static route path, got ${JSON.stringify(route)}: dynamic segments need a route object, and query/hash never ride in the path string`);
21
21
  }
22
- // SH6: silently dropping a half a JS caller passed would build a wrong
23
- // link — contract violations stay loud (never the safe-parse error arm).
22
+ // Silently dropping a half a JS caller passed would build a wrong link —
23
+ // contract violations stay loud (never the safe-parse error arm).
24
24
  if (options?.params !== undefined || options?.search !== undefined) {
25
25
  throw new ParamourError(`href(path) takes no params/search — a static path has no params, and a query string needs a route with search codecs`);
26
26
  }
package/dist/index.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  export { type AnyCodec, type Arity, type Codec, type OutputOf, 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
- export { type Issue, ParamourError, ParamsDecodeError, ParseError, type RouteDecodeError, SearchDecodeError, SearchSourceError, SerializeError, } from "./errors.js";
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";