paramour 0.2.0 → 0.3.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Jason Paff
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/dist/codec.d.ts CHANGED
@@ -9,25 +9,41 @@ export type Arity = "many" | "single";
9
9
  /**
10
10
  * A bidirectional wire codec.
11
11
  *
12
- * `Out` is the decoded in-memory type. `P`, `C`, and `A` are type-state:
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
14
  * (`.optional().default()`, double `.catch()`) fail to compile (design-02 D3).
15
+ * `E` carries `~defaultElides` as a literal after `.default()` (NQ6a).
15
16
  * Presence modifiers are also `never` for arity-"many" codecs: absent and `[]`
16
17
  * are the same wire state (S6/P6), so `.default()`/`.optional()` could never
17
18
  * round-trip there.
18
19
  *
19
20
  * `.default()` and `.catch()` accept either a value or a zero-arg factory;
20
21
  * factories are invoked per decode/encode, so reference-typed defaults can be
21
- * isolated per call (plain object values are returned by reference).
22
+ * isolated per call. Array values are shallow-copied per call for the same
23
+ * isolation; other plain object values are returned by reference.
22
24
  *
23
25
  * Properties prefixed `~` are internal machinery, not public API. For
24
26
  * arity-"many" codecs the element functions operate on single elements of
25
27
  * `Out` (which is an array type).
26
28
  */
27
- export interface Codec<Out, P extends Presence = "required", C extends boolean = false, A extends Arity = "single"> {
28
- readonly catch: C extends false ? (fallback: (() => Out) | Out) => Codec<Out, P, true, A> : never;
29
- readonly default: A extends "single" ? P extends "required" ? (value: (() => Out) | Out) => Codec<Out, "defaulted", C, A> : never : never;
30
- readonly optional: A extends "single" ? P extends "required" ? () => Codec<Out, "optional", C, A> : never : never;
29
+ export interface Codec<Out, P extends Presence = "required", C extends boolean = false, A extends Arity = "single", E extends boolean = boolean> {
30
+ readonly catch: C extends false ? (fallback: (() => Out) | Out) => Codec<Out, P, true, A, E> : never;
31
+ /**
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
34
+ * {@link isFactory} check treats ANY function as a factory, so a function
35
+ * argument must either match the factory overload or fail to compile
36
+ * ({@link NonFactoryValue}) — E=true is only ever inferred for arguments
37
+ * the runtime will also treat as values. The one statically-invisible
38
+ * residue: an argument whose static type is not a function (e.g.
39
+ * `unknown`) but holds one at runtime lands on the runtime's factory
40
+ * branch anyway; no type-level check can see that.
41
+ */
42
+ readonly default: A extends "single" ? P extends "required" ? {
43
+ (value: () => Out): Codec<Out, "defaulted", C, A, false>;
44
+ <V extends Out>(value: NonFactoryValue<V> & V): Codec<Out, "defaulted", C, A, true>;
45
+ } : never : never;
46
+ readonly optional: A extends "single" ? P extends "required" ? () => Codec<Out, "optional", C, A, E> : never : never;
31
47
  readonly "~arity": A;
32
48
  /** Stored as a thunk regardless of the form passed to `.catch()`. */
33
49
  readonly "~catchValue": (() => Out) | undefined;
@@ -38,10 +54,22 @@ export interface Codec<Out, P extends Presence = "required", C extends boolean =
38
54
  * re-serialized per encode. Factory defaults never elide: a time-varying
39
55
  * factory would elide an explicitly-passed value that later decodes as a
40
56
  * different one.
57
+ *
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.
41
64
  */
42
- readonly "~defaultElides": boolean;
65
+ readonly "~defaultElides": E;
43
66
  /** Stored as a thunk regardless of the form passed to `.default()`. */
44
67
  readonly "~defaultValue": (() => Out) | undefined;
68
+ /**
69
+ * Element codec of a composite list codec (currently `p.csv`) — the
70
+ * per-segment scalar; undefined for every non-composite kind (CV6).
71
+ */
72
+ readonly "~element": AnyCodec | undefined;
45
73
  /** Members of a `p.enum` codec; undefined for every other kind. */
46
74
  readonly "~enumMembers": readonly string[] | undefined;
47
75
  /**
@@ -66,11 +94,24 @@ export type ParamCodec = Codec<any, "required", boolean>;
66
94
  */
67
95
  export type Presence = "defaulted" | "optional" | "required";
68
96
  export type PresenceOf<C extends AnyCodec> = C["~presence"];
97
+ /**
98
+ * Rejects value-form `.default()` arguments whose static type includes any
99
+ * function member: runtime {@link isFactory} would treat them as factories,
100
+ * 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
103
+ * runtime branch is unknowable at compile time.
104
+ */
105
+ type NonFactoryValue<V> = [Extract<V, (...args: never[]) => unknown>] extends [
106
+ never
107
+ ] ? unknown : never;
69
108
  /** Internal factory used by the `p.*` builders. */
70
109
  export declare function createCodec<Out, A extends Arity = "single">(impl: {
71
110
  arity?: A;
111
+ element?: AnyCodec;
72
112
  enumMembers?: readonly string[];
73
113
  kind?: string;
74
114
  parseElement: (raw: string) => unknown;
75
115
  serializeElement: (value: unknown) => string;
76
116
  }): Codec<Out, "required", false, A>;
117
+ export {};
package/dist/codec.js CHANGED
@@ -6,6 +6,7 @@ export function createCodec(impl) {
6
6
  catchValue: undefined,
7
7
  defaultElides: false,
8
8
  defaultValue: undefined,
9
+ element: impl.element,
9
10
  enumMembers: impl.enumMembers,
10
11
  kind: impl.kind ?? "custom",
11
12
  parseElement: impl.parseElement,
@@ -60,6 +61,7 @@ function build(state) {
60
61
  "~caught": state.catchValue !== undefined,
61
62
  "~defaultElides": state.defaultElides,
62
63
  "~defaultValue": state.defaultValue,
64
+ "~element": state.element,
63
65
  "~enumMembers": state.enumMembers,
64
66
  "~kind": state.kind,
65
67
  "~parseElement": state.parseElement,
@@ -90,8 +92,15 @@ function serializeDefault(serializeElement, value) {
90
92
  * the one chokepoint where a throwing user factory is branded ParamourError.
91
93
  */
92
94
  function toThunk(stored, what) {
93
- if (!isFactory(stored))
95
+ if (!isFactory(stored)) {
96
+ // Array values are handed out as fresh shallow copies: a consumer
97
+ // mutating a decoded fallback must not pollute later decodes or shift
98
+ // D8 elision (p.csv makes array defaults idiomatic — CV5). Non-array
99
+ // reference values stay by-reference; use a factory to isolate those.
100
+ if (Array.isArray(stored))
101
+ return () => stored.slice();
94
102
  return () => stored;
103
+ }
95
104
  return () => {
96
105
  try {
97
106
  return stored();
@@ -22,10 +22,17 @@ export interface CodecDescription {
22
22
  readonly arity: Arity;
23
23
  readonly caught: boolean;
24
24
  readonly defaultValue?: CodecDefaultDescription;
25
+ /**
26
+ * Nested description of a composite list codec's element scalar (CV6;
27
+ * currently `p.csv`).
28
+ */
29
+ readonly element?: CodecDescription;
25
30
  readonly enumMembers?: readonly string[];
26
31
  readonly kind: string;
27
32
  readonly presence: Presence;
28
33
  }
34
+ /** Rendering styles accepted by {@link formatCodecDescription}. */
35
+ export type CodecFormatStyle = "compact" | "verbose";
29
36
  /** A param codec plus the dynamic-segment kind that hosts it. */
30
37
  export interface ParamDescription extends CodecDescription {
31
38
  readonly segmentKind: "catchall" | "optional-catchall" | "single";
@@ -61,3 +68,16 @@ export declare function describeCodec(codec: AnyCodec): CodecDescription;
61
68
  * both router brands — reflection only needs the data core.
62
69
  */
63
70
  export declare function describeRoute(route: AnyRoute): RouteDescription;
71
+ /**
72
+ * One-line label for a {@link CodecDescription} — THE shared walk over the
73
+ * description's fields, so every consumer (the devtools panel's shape
74
+ * column, `paramour list`'s annotations) renders the same structure and a
75
+ * future field lands everywhere at once. Two skins over one walk:
76
+ *
77
+ * - `"compact"`: `enum(asc|desc)? =asc catch`, `csv<enum(a|b)>`, `string[]`
78
+ * — `?` for optional presence, the default's wire form (`=3`) or `=ƒ()`
79
+ * for a factory default, bare `catch`.
80
+ * - `"verbose"`: `enum(asc, desc) (optional) (default: asc) (catch)` —
81
+ * parenthesized annotations in fixed order: presence, default, catch.
82
+ */
83
+ export declare function formatCodecDescription(description: CodecDescription, style: CodecFormatStyle): string;
package/dist/describe.js CHANGED
@@ -4,11 +4,15 @@
4
4
  */
5
5
  export function describeCodec(codec) {
6
6
  const defaultValue = describeDefault(codec);
7
+ const element = codec["~element"];
7
8
  const enumMembers = codec["~enumMembers"];
8
9
  return {
9
10
  arity: codec["~arity"],
10
11
  caught: codec["~caught"],
11
12
  ...(defaultValue === undefined ? {} : { defaultValue }),
13
+ // Recursion terminates: nested csv is rejected at construction (CV2),
14
+ // and element codecs are unmodified scalars with no element of their own.
15
+ ...(element === undefined ? {} : { element: describeCodec(element) }),
12
16
  ...(enumMembers === undefined ? {} : { enumMembers }),
13
17
  kind: codec["~kind"],
14
18
  presence: codec["~presence"],
@@ -42,6 +46,53 @@ export function describeRoute(route) {
42
46
  search: describeSearch(route["~search"]),
43
47
  };
44
48
  }
49
+ /**
50
+ * One-line label for a {@link CodecDescription} — THE shared walk over the
51
+ * description's fields, so every consumer (the devtools panel's shape
52
+ * column, `paramour list`'s annotations) renders the same structure and a
53
+ * future field lands everywhere at once. Two skins over one walk:
54
+ *
55
+ * - `"compact"`: `enum(asc|desc)? =asc catch`, `csv<enum(a|b)>`, `string[]`
56
+ * — `?` for optional presence, the default's wire form (`=3`) or `=ƒ()`
57
+ * for a factory default, bare `catch`.
58
+ * - `"verbose"`: `enum(asc, desc) (optional) (default: asc) (catch)` —
59
+ * parenthesized annotations in fixed order: presence, default, catch.
60
+ */
61
+ export function formatCodecDescription(description, style) {
62
+ const memberSeparator = style === "compact" ? "|" : ", ";
63
+ const kindLabel = (part) => part.enumMembers === undefined
64
+ ? part.kind
65
+ : `enum(${part.enumMembers.join(memberSeparator)})`;
66
+ let label = description.element === undefined
67
+ ? kindLabel(description)
68
+ : `${description.kind}<${kindLabel(description.element)}>`;
69
+ if (description.arity === "many")
70
+ label += "[]";
71
+ if (style === "compact") {
72
+ if (description.presence === "optional")
73
+ label += "?";
74
+ if (description.defaultValue !== undefined) {
75
+ label +=
76
+ description.defaultValue.kind === "value"
77
+ ? ` =${description.defaultValue.wire}`
78
+ : " =ƒ()";
79
+ }
80
+ if (description.caught)
81
+ label += " catch";
82
+ return label;
83
+ }
84
+ const notes = [];
85
+ if (description.presence === "optional")
86
+ notes.push("(optional)");
87
+ if (description.defaultValue !== undefined) {
88
+ notes.push(description.defaultValue.kind === "value"
89
+ ? `(default: ${description.defaultValue.wire})`
90
+ : "(default: factory)");
91
+ }
92
+ if (description.caught)
93
+ notes.push("(catch)");
94
+ return [label, ...notes].join(" ");
95
+ }
45
96
  /**
46
97
  * Value-form defaults re-serialize the live value (the D8 ethos — never a
47
98
  * stale snapshot); a throwing serialize here means the default was mutated
package/dist/errors.d.ts CHANGED
@@ -54,8 +54,12 @@ export declare class SerializeError extends ParamourError {
54
54
  */
55
55
  export declare function describeType(value: unknown): string;
56
56
  /**
57
- * Best-effort human-readable message for a foreign (non-paramour) throw.
58
- * Not exported from the package — internal to error branding.
57
+ * Best-effort human-readable message for a foreign (non-paramour) throw:
58
+ * an `Error`'s message, else a {@link showValue}-hardened `String()` — safe
59
+ * even for values whose primitive conversion itself throws (null-prototype
60
+ * objects, `Symbol.toPrimitive` throwers). Public via the barrel so derived
61
+ * tooling that catches user-code throws (the devtools panel's edit preview)
62
+ * shares the hardening instead of re-implementing it minus the guard.
59
63
  */
60
64
  export declare function foreignMessage(error: unknown): string;
61
65
  /**
package/dist/errors.js CHANGED
@@ -107,8 +107,12 @@ export function describeType(value) {
107
107
  return value === null ? "null" : typeof value;
108
108
  }
109
109
  /**
110
- * Best-effort human-readable message for a foreign (non-paramour) throw.
111
- * Not exported from the package — internal to error branding.
110
+ * Best-effort human-readable message for a foreign (non-paramour) throw:
111
+ * an `Error`'s message, else a {@link showValue}-hardened `String()` — safe
112
+ * even for values whose primitive conversion itself throws (null-prototype
113
+ * objects, `Symbol.toPrimitive` throwers). Public via the barrel so derived
114
+ * tooling that catches user-code throws (the devtools panel's edit preview)
115
+ * shares the hardening instead of re-implementing it minus the guard.
112
116
  */
113
117
  export function foreignMessage(error) {
114
118
  return error instanceof Error ? error.message : showValue(error);
package/dist/index.d.ts CHANGED
@@ -1,10 +1,10 @@
1
1
  export { type AnyCodec, type Arity, type Codec, type OutputOf, type ParamCodec, type Presence, type PresenceOf, } from "./codec.js";
2
- export { type CodecDefaultDescription, type CodecDescription, describeCodec, describeRoute, type ParamDescription, type RouteDescription, type SearchDescription, } from "./describe.js";
3
- export { type Issue, ParamourError, ParamsDecodeError, ParseError, type RouteDecodeError, SearchDecodeError, SearchSourceError, SerializeError, } from "./errors.js";
2
+ export { type CodecDefaultDescription, type CodecDescription, type CodecFormatStyle, describeCodec, describeRoute, formatCodecDescription, type ParamDescription, type RouteDescription, type SearchDescription, } from "./describe.js";
3
+ export { foreignMessage, type Issue, 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
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 RegisteredAppRoutePaths, type RegisteredPagesRoutePaths, type RegisteredStaticAppRoutePaths, type RegisteredStaticPagesRoutePaths, type RegisteredStaticRoutePaths, type Route, type RouteProps, type RouterKind, type SafeResult, type SearchProps, } from "./route.js";
8
8
  export { safeDecodeParams, safeDecodeSearch } from "./safe-decode.js";
9
- export { buildSearchString, decodeSearch, encodeSearch, type InferSearchInput, type InferSearchOutput, rawSearch, type RawSearch, type SearchConfig, type SearchOutputOf, type SearchSource, searchToString, } from "./search.js";
9
+ export { buildSearchString, decodeSearch, encodeSearch, type InferSearchInput, type InferSearchOutput, isRawSearch, parseValue, rawSearch, type RawSearch, type SearchConfig, type SearchOutputOf, type SearchSource, searchToString, serializeValue, } from "./search.js";
10
10
  export { standardSearchSchema, type StandardSearchSchema, } from "./standard-schema.js";
package/dist/index.js CHANGED
@@ -1,10 +1,10 @@
1
1
  export {} from "./codec.js";
2
- export { describeCodec, describeRoute, } from "./describe.js";
3
- export { ParamourError, ParamsDecodeError, ParseError, SearchDecodeError, SearchSourceError, SerializeError, } from "./errors.js";
2
+ export { describeCodec, describeRoute, formatCodecDescription, } from "./describe.js";
3
+ export { foreignMessage, ParamourError, ParamsDecodeError, ParseError, SearchDecodeError, SearchSourceError, SerializeError, } from "./errors.js";
4
4
  export { href, } from "./href.js";
5
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, rawSearch, searchToString, } from "./search.js";
9
+ export { buildSearchString, decodeSearch, encodeSearch, isRawSearch, parseValue, rawSearch, searchToString, serializeValue, } from "./search.js";
10
10
  export { standardSearchSchema, } from "./standard-schema.js";
package/dist/p.d.ts CHANGED
@@ -6,6 +6,13 @@ import { type Codec } from "./codec.js";
6
6
  */
7
7
  export declare const p: {
8
8
  boolean(): Codec<boolean>;
9
+ /**
10
+ * A comma-separated scalar list in ONE wire value (design-11 CV1): arity
11
+ * "single", so the full modifier set applies — unlike `p.stringArray`'s
12
+ * repeated-key format (CV7: both are first-class; csv is the one-key
13
+ * packing). Elements are strings unless an element codec is given.
14
+ */
15
+ csv<E = string>(element?: Codec<E>): Codec<E[]>;
9
16
  custom<Out>(codec: {
10
17
  /** Reflection name shown by describeCodec/`paramour list` (default "custom"). */
11
18
  label?: string;
package/dist/p.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { createCodec } from "./codec.js";
2
- import { foreignMessage, ParseError, rebrandForeign, SerializeError, showValue, } from "./errors.js";
2
+ import { foreignMessage, ParamourError, ParseError, rebrandForeign, SerializeError, showValue, } from "./errors.js";
3
3
  import { runStandardSchemaSync } from "./schema.js";
4
4
  // Wire grammars per wire-format spec §4. `Number()` alone is too loose
5
5
  // (accepts hex, trims whitespace), hence explicit anchored patterns.
@@ -86,6 +86,12 @@ function serializeFiniteNumber(value) {
86
86
  function stringifyJson(value) {
87
87
  return rebrandForeign(() => JSON.stringify(value), (error) => new SerializeError("Value is not JSON-serializable", { cause: error }));
88
88
  }
89
+ /**
90
+ * Shared element for no-arg `p.csv()` — codecs are immutable, so one
91
+ * schemaless string codec serves every list. Lazily built: `p` does not
92
+ * exist yet while the module initializes.
93
+ */
94
+ let defaultCsvElement;
89
95
  /**
90
96
  * The `p.*` wire-codec builders (DESIGN §5 layer 1, design-02 D9).
91
97
  * Each codec defines how one value crosses the URL boundary, both directions.
@@ -109,6 +115,85 @@ export const p = {
109
115
  },
110
116
  });
111
117
  },
118
+ /**
119
+ * A comma-separated scalar list in ONE wire value (design-11 CV1): arity
120
+ * "single", so the full modifier set applies — unlike `p.stringArray`'s
121
+ * repeated-key format (CV7: both are first-class; csv is the one-key
122
+ * packing). Elements are strings unless an element codec is given.
123
+ */
124
+ csv(element) {
125
+ // CV2: presence, catch, and arity-many inners are excluded by the
126
+ // parameter type (the D3 philosophy); these guards mirror that
127
+ // type-state for JS consumers (the RL1 ethos) — only the element
128
+ // functions are captured below, so an accepted modifier would be
129
+ // silently dropped, not applied. Nesting is detected structurally via
130
+ // ~element (never via ~kind, which is reflection-only and free-form for
131
+ // p.custom labels). Comma-emitting p.custom inners are undetectable
132
+ // here and are caught by the CV4 serialize guard instead.
133
+ const inner = element ?? (defaultCsvElement ??= p.string());
134
+ if (inner["~element"] !== undefined) {
135
+ throw new ParamourError("p.csv() elements cannot themselves be csv lists");
136
+ }
137
+ if (inner["~arity"] === "many" ||
138
+ inner["~caught"] ||
139
+ inner["~presence"] !== "required") {
140
+ throw new ParamourError("p.csv() elements cannot carry modifiers (.optional()/.default()/.catch()) or be array codecs");
141
+ }
142
+ const parseInner = inner["~parseElement"];
143
+ const serializeInner = inner["~serializeElement"];
144
+ return createCodec({
145
+ element: inner,
146
+ kind: "csv",
147
+ parseElement: (raw) => {
148
+ // CV3: the empty wire string is [] — checked before split, because
149
+ // "".split(",") is [""], which the strict grammar would reject.
150
+ if (raw === "")
151
+ return [];
152
+ const result = [];
153
+ for (const segment of raw.split(",")) {
154
+ // CV3: strict grammar — "a,,b", trailing "a,", and a lone "," are
155
+ // ParseErrors, recoverable via the LIST's .catch() (D2).
156
+ if (segment === "") {
157
+ throw new ParseError(`"${raw}" contains an empty list element`);
158
+ }
159
+ // CV3: the first element failure aborts the list parse — the
160
+ // element codec's own ParseError propagates unwrapped.
161
+ result.push(parseInner(segment));
162
+ }
163
+ return result;
164
+ },
165
+ serializeElement: (value) => {
166
+ if (!Array.isArray(value)) {
167
+ throw new SerializeError(`Expected an array, got ${showValue(value)}`);
168
+ }
169
+ const parts = [];
170
+ for (const item of value) {
171
+ const serialized = serializeInner(item);
172
+ // A plain-JS custom element serializer can return a non-string;
173
+ // enforce the string contract here — as search.ts/path.ts do at
174
+ // their ~serializeElement call sites — so the CV4 guards below
175
+ // cannot throw raw TypeErrors.
176
+ if (typeof serialized !== "string") {
177
+ throw new SerializeError(`List element serializer must return a string, got ${typeof serialized}`);
178
+ }
179
+ // CV4: an empty segment on re-parse; [""] is deliberately
180
+ // unrepresentable — the empty wire string already means [].
181
+ if (serialized === "") {
182
+ throw new SerializeError(`List element ${showValue(item)} serializes to the empty string`);
183
+ }
184
+ // CV4: would mis-split on re-parse.
185
+ if (serialized.includes(",")) {
186
+ throw new SerializeError(`List element serialization "${serialized}" contains a comma`);
187
+ }
188
+ parts.push(serialized);
189
+ }
190
+ // [] joins to "" (CV5) — which is also why .default([])'s
191
+ // define-time pre-serialization succeeds and D8 elision compares
192
+ // [] against the empty wire form.
193
+ return parts.join(",");
194
+ },
195
+ });
196
+ },
112
197
  custom(codec) {
113
198
  // Paramour's own errors are never downgraded: ANY ParamourError thrown
114
199
  // by user parse/serialize code — config-level failures (async schema,
package/dist/path.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { describeType, ParamourError, ParamsDecodeError, ParseError, SerializeError, } from "./errors.js";
2
- import { encodeComponent, readInputValue } from "./search.js";
2
+ import { encodeComponent, readInputValue, serializeValue } from "./search.js";
3
3
  // Anchored per the wire-format spec's regex ethos; name charset excludes
4
4
  // brackets so nesting can't smuggle through. Match order mirrors the type
5
5
  // grammar: `[[...` before `[...` before `[`.
@@ -394,18 +394,14 @@ function serializeDynamicSegment(codec, segment, value) {
394
394
  };
395
395
  }
396
396
  /**
397
- * Serializes one segment value into its wire-string form. Enforces the
398
- * serializer's string contract exactly as search.ts's serializeValue does —
399
- * a plain-JS custom codec returning a non-string must never reach the byte
400
- * layer as the literal text "undefined". Percent-encoding is deliberately
401
- * NOT applied here: that is encodeParams' byte-layer step (S7), and the
402
- * static surfaces must skip it.
397
+ * Serializes one segment value into its wire-string form. The string
398
+ * contract is search.ts's shared {@link serializeValue}; the path surface
399
+ * layers R4 on top (a segment additionally can't be empty).
400
+ * Percent-encoding is deliberately NOT applied here: that is encodeParams'
401
+ * byte-layer step (S7), and the static surfaces must skip it.
403
402
  */
404
403
  function serializeSegmentValue(codec, name, value) {
405
- const serialized = codec["~serializeElement"](value);
406
- if (typeof serialized !== "string") {
407
- throw new SerializeError(`serializer for route param "${name}" must return a string, got ${typeof serialized}`);
408
- }
404
+ const serialized = serializeValue(codec, `route param "${name}"`, value);
409
405
  if (serialized === "") {
410
406
  // R4: "" would produce "//" or a vanishing segment — same rationale as R3.
411
407
  throw new SerializeError(`route param "${name}" serialized to an empty string, which cannot form a path segment`);
package/dist/search.d.ts CHANGED
@@ -112,12 +112,28 @@ export declare function encodeComponent(text: string): string;
112
112
  */
113
113
  export declare function encodeSearch<S extends SearchSlot>(config: S, input: SearchInputOf<S>): [string, string][];
114
114
  /**
115
- * Runtime discriminant for the `search:` slot (design-04 SS2): the reserved
116
- * `~kind` marker is unambiguous against a codec map, which never carries a
117
- * top-level `~`-prefixed key. Module-exported for standard-schema.ts, not
118
- * barrel-exported.
115
+ * Runtime discriminant for the `search:` slot (design-04 SS2): probes the
116
+ * `~kind` marker's VALUE, which is unambiguous against a codec map — a map
117
+ * key literally named "~kind" would hold a codec object, never the marker
118
+ * string. Exported from the package barrel so derived surfaces
119
+ * (`@paramour-js/nuqs`) share the discriminant instead of duplicating the
120
+ * literal.
119
121
  */
120
122
  export declare function isRawSearch(config: SearchSlot): config is RawSearch<StandardSchemaV1>;
123
+ /**
124
+ * Invokes a codec's element parser on one wire string — the parse twin of
125
+ * {@link serializeValue}, and the ONE sanctioned way to run a parse WITHOUT
126
+ * the codec's `.catch()` recovery applied (decodeSearch always recovers a
127
+ * caught failure, so a probe through it cannot tell "parsed cleanly" from
128
+ * "failed and was caught"). Exists for reflection-driven tooling — the
129
+ * devtools panel's catch-attribution probe (design-12 DT7) — and any other
130
+ * derived surface that must observe the raw parse outcome. A parse failure
131
+ * throws the codec's own {@link ParseError}; foreign throws from a custom
132
+ * codec propagate unwrapped, matching decodeSearch's taxonomy. For
133
+ * arity-"many" codecs this parses ONE element of the repeated-key array,
134
+ * not the whole array (the same contract as `~parseElement` itself).
135
+ */
136
+ export declare function parseValue(codec: AnyCodec, raw: string): unknown;
121
137
  /**
122
138
  * The whole-object search escape hatch (design-04 SS1, maintainer ruling):
123
139
  * an explicit, greppable wrapper around a bare Standard Schema so a route's
@@ -152,4 +168,16 @@ export declare function readInputValue(values: Record<string, unknown>, key: str
152
168
  export declare function requireSearchConfig(config: SearchSlot): void;
153
169
  /** Convenience: encode + build in one step. */
154
170
  export declare function searchToString<S extends SearchSlot>(config: S, input: SearchInputOf<S>): string;
171
+ /**
172
+ * Invokes a codec's serializer and enforces its string contract: a custom
173
+ * codec written in plain JS can return undefined, which would otherwise
174
+ * reach the byte layer as the literal text "undefined" — or, worse, match
175
+ * an absent default and silently drop the param. `label` names the value's
176
+ * site in the error (`search param "q"`, `route param "id"`, …). Exported
177
+ * from the package barrel as the ONE implementation of this contract:
178
+ * path.ts segments and derived surfaces (`@paramour-js/nuqs` eq/
179
+ * clearOnDefault) share it so their judgment stays identical to D8
180
+ * elision's by construction, not by parallel copies.
181
+ */
182
+ export declare function serializeValue(codec: AnyCodec, label: string, value: unknown): string;
155
183
  export {};
package/dist/search.js CHANGED
@@ -164,7 +164,10 @@ export function encodeSearch(config, input) {
164
164
  }
165
165
  // Array codecs cannot carry defaults, so no elision applies.
166
166
  for (const element of value) {
167
- pairs.push([key, serializeValue(codec, key, element)]);
167
+ pairs.push([
168
+ key,
169
+ serializeValue(codec, `search param "${key}"`, element),
170
+ ]);
168
171
  }
169
172
  continue;
170
173
  }
@@ -174,13 +177,14 @@ export function encodeSearch(config, input) {
174
177
  }
175
178
  continue; // absent optional/defaulted param → key omitted (S3)
176
179
  }
177
- const serialized = serializeValue(codec, key, value);
180
+ const serialized = serializeValue(codec, `search param "${key}"`, value);
178
181
  // D8 elision, gated on an elidable default existing — an ungated
179
182
  // comparison would let a (contract-violating) serialize that returns
180
183
  // undefined match undefined and silently drop the param.
181
184
  if (codec["~defaultElides"] &&
182
185
  codec["~defaultValue"] !== undefined &&
183
- serialized === serializeValue(codec, key, codec["~defaultValue"]())) {
186
+ serialized ===
187
+ serializeValue(codec, `search param "${key}"`, codec["~defaultValue"]())) {
184
188
  continue;
185
189
  }
186
190
  pairs.push([key, serialized]);
@@ -188,14 +192,32 @@ export function encodeSearch(config, input) {
188
192
  return pairs;
189
193
  }
190
194
  /**
191
- * Runtime discriminant for the `search:` slot (design-04 SS2): the reserved
192
- * `~kind` marker is unambiguous against a codec map, which never carries a
193
- * top-level `~`-prefixed key. Module-exported for standard-schema.ts, not
194
- * barrel-exported.
195
+ * Runtime discriminant for the `search:` slot (design-04 SS2): probes the
196
+ * `~kind` marker's VALUE, which is unambiguous against a codec map — a map
197
+ * key literally named "~kind" would hold a codec object, never the marker
198
+ * string. Exported from the package barrel so derived surfaces
199
+ * (`@paramour-js/nuqs`) share the discriminant instead of duplicating the
200
+ * literal.
195
201
  */
196
202
  export function isRawSearch(config) {
197
203
  return "~kind" in config && config["~kind"] === "raw-search";
198
204
  }
205
+ /**
206
+ * Invokes a codec's element parser on one wire string — the parse twin of
207
+ * {@link serializeValue}, and the ONE sanctioned way to run a parse WITHOUT
208
+ * the codec's `.catch()` recovery applied (decodeSearch always recovers a
209
+ * caught failure, so a probe through it cannot tell "parsed cleanly" from
210
+ * "failed and was caught"). Exists for reflection-driven tooling — the
211
+ * devtools panel's catch-attribution probe (design-12 DT7) — and any other
212
+ * derived surface that must observe the raw parse outcome. A parse failure
213
+ * throws the codec's own {@link ParseError}; foreign throws from a custom
214
+ * codec propagate unwrapped, matching decodeSearch's taxonomy. For
215
+ * arity-"many" codecs this parses ONE element of the repeated-key array,
216
+ * not the whole array (the same contract as `~parseElement` itself).
217
+ */
218
+ export function parseValue(codec, raw) {
219
+ return codec["~parseElement"](raw);
220
+ }
199
221
  /**
200
222
  * The whole-object search escape hatch (design-04 SS1, maintainer ruling):
201
223
  * an explicit, greppable wrapper around a bare Standard Schema so a route's
@@ -255,6 +277,24 @@ export function requireSearchConfig(config) {
255
277
  export function searchToString(config, input) {
256
278
  return buildSearchString(encodeSearch(config, input));
257
279
  }
280
+ /**
281
+ * Invokes a codec's serializer and enforces its string contract: a custom
282
+ * codec written in plain JS can return undefined, which would otherwise
283
+ * reach the byte layer as the literal text "undefined" — or, worse, match
284
+ * an absent default and silently drop the param. `label` names the value's
285
+ * site in the error (`search param "q"`, `route param "id"`, …). Exported
286
+ * from the package barrel as the ONE implementation of this contract:
287
+ * path.ts segments and derived surfaces (`@paramour-js/nuqs` eq/
288
+ * clearOnDefault) share it so their judgment stays identical to D8
289
+ * elision's by construction, not by parallel copies.
290
+ */
291
+ export function serializeValue(codec, label, value) {
292
+ const serialized = codec["~serializeElement"](value);
293
+ if (typeof serialized !== "string") {
294
+ throw new SerializeError(`serializer for ${label} must return a string, got ${typeof serialized}`);
295
+ }
296
+ return serialized;
297
+ }
258
298
  /**
259
299
  * The `RawSearch` decode path (design-04 SS3/SS4). The schema receives EVERY
260
300
  * source key, normalized to Next's own `searchParams` shape — P8's
@@ -459,16 +499,3 @@ function requireRawSearchString(key, value) {
459
499
  }
460
500
  return value;
461
501
  }
462
- /**
463
- * Invokes a codec's serializer and enforces its string contract: a custom
464
- * codec written in plain JS can return undefined, which would otherwise
465
- * reach the byte layer as the literal text "undefined" — or, worse, match
466
- * an absent default and silently drop the param.
467
- */
468
- function serializeValue(codec, key, value) {
469
- const serialized = codec["~serializeElement"](value);
470
- if (typeof serialized !== "string") {
471
- throw new SerializeError(`serializer for search param "${key}" must return a string, got ${typeof serialized}`);
472
- }
473
- return serialized;
474
- }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "paramour",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": {
@@ -8,10 +8,6 @@
8
8
  "default": "./dist/index.js"
9
9
  }
10
10
  },
11
- "scripts": {
12
- "build": "tsc -p tsconfig.build.json",
13
- "typecheck": "tsc --noEmit"
14
- },
15
11
  "description": "Type-safe routing companion for the Next.js App Router: validated route params and search params, typed path building, and explicit URL serialization.",
16
12
  "keywords": [
17
13
  "nextjs",
@@ -37,6 +33,10 @@
37
33
  "files": [
38
34
  "dist"
39
35
  ],
36
+ "publishConfig": {
37
+ "access": "public",
38
+ "provenance": true
39
+ },
40
40
  "dependencies": {
41
41
  "@standard-schema/spec": "^1.1.0"
42
42
  },
@@ -46,5 +46,9 @@
46
46
  "fast-check": "^4.8.0",
47
47
  "valibot": "^1.4.2",
48
48
  "zod": "^4.4.3"
49
+ },
50
+ "scripts": {
51
+ "build": "tsc -p tsconfig.build.json",
52
+ "typecheck": "tsc --noEmit"
49
53
  }
50
- }
54
+ }