paramour 0.2.1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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 (`p.csv`, `p.array`) — the
70
+ * per-segment/per-key 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
+ * `p.csv` and `p.array`).
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,16 @@
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: composite nesting is bounded at construction —
14
+ // csv rejects nested csv (CV2) and array rejects arity-many inners
15
+ // (PP1), so the deepest legal chain is array<csv<scalar>>.
16
+ ...(element === undefined ? {} : { element: describeCodec(element) }),
12
17
  ...(enumMembers === undefined ? {} : { enumMembers }),
13
18
  kind: codec["~kind"],
14
19
  presence: codec["~presence"],
@@ -42,6 +47,64 @@ export function describeRoute(route) {
42
47
  search: describeSearch(route["~search"]),
43
48
  };
44
49
  }
50
+ /**
51
+ * One-line label for a {@link CodecDescription} — THE shared walk over the
52
+ * description's fields, so every consumer (the devtools panel's shape
53
+ * column, `paramour list`'s annotations) renders the same structure and a
54
+ * future field lands everywhere at once. Two skins over one walk:
55
+ *
56
+ * - `"compact"`: `enum(asc|desc)? =asc catch`, `csv<enum(a|b)>`, `string[]`
57
+ * — `?` for optional presence, the default's wire form (`=3`) or `=ƒ()`
58
+ * for a factory default, bare `catch`.
59
+ * - `"verbose"`: `enum(asc, desc) (optional) (default: asc) (catch)` —
60
+ * parenthesized annotations in fixed order: presence, default, catch.
61
+ */
62
+ export function formatCodecDescription(description, style) {
63
+ const memberSeparator = style === "compact" ? "|" : ", ";
64
+ const kindLabel = (part) => part.enumMembers === undefined
65
+ ? part.kind
66
+ : `enum(${part.enumMembers.join(memberSeparator)})`;
67
+ // Composite labels: a one-key list wraps its element (`csv<integer>`); a
68
+ // repeated-key list IS its element, pluralized (`integer[]`) — the
69
+ // "array" kind never appears in a label, the `[]` carries it. The elision
70
+ // keys on the kind, NOT arity: consumers force arity "many" onto
71
+ // non-array descriptions (render.ts's catch-all params), where a csv
72
+ // wrapper must survive as `csv<integer>[]`. Recursive so `array<csv<E>>`
73
+ // renders `csv<E>[]`.
74
+ const shapeLabel = (part) => {
75
+ const base = part.element === undefined
76
+ ? kindLabel(part)
77
+ : part.kind === "array"
78
+ ? shapeLabel(part.element)
79
+ : `${part.kind}<${shapeLabel(part.element)}>`;
80
+ return part.arity === "many" ? `${base}[]` : base;
81
+ };
82
+ let label = shapeLabel(description);
83
+ if (style === "compact") {
84
+ if (description.presence === "optional")
85
+ label += "?";
86
+ if (description.defaultValue !== undefined) {
87
+ label +=
88
+ description.defaultValue.kind === "value"
89
+ ? ` =${description.defaultValue.wire}`
90
+ : " =ƒ()";
91
+ }
92
+ if (description.caught)
93
+ label += " catch";
94
+ return label;
95
+ }
96
+ const notes = [];
97
+ if (description.presence === "optional")
98
+ notes.push("(optional)");
99
+ if (description.defaultValue !== undefined) {
100
+ notes.push(description.defaultValue.kind === "value"
101
+ ? `(default: ${description.defaultValue.wire})`
102
+ : "(default: factory)");
103
+ }
104
+ if (description.caught)
105
+ notes.push("(catch)");
106
+ return [label, ...notes].join(" ");
107
+ }
45
108
  /**
46
109
  * Value-form defaults re-serialize the live value (the D8 ethos — never a
47
110
  * 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
@@ -5,7 +5,21 @@ import { type Codec } from "./codec.js";
5
5
  * Each codec defines how one value crosses the URL boundary, both directions.
6
6
  */
7
7
  export declare const p: {
8
+ /**
9
+ * A repeated-key list (`?tags=a&tags=b`, design-13 PP1): arity "many", so
10
+ * presence modifiers are unavailable — absent and `[]` are the same wire
11
+ * state (S6/P6) — unlike `p.csv`'s one-key packing (CV7). Elements are
12
+ * strings unless an element codec is given.
13
+ */
14
+ array<E = string>(element?: Codec<E>): Codec<E[], "required", false, "many">;
8
15
  boolean(): Codec<boolean>;
16
+ /**
17
+ * A comma-separated scalar list in ONE wire value (design-11 CV1): arity
18
+ * "single", so the full modifier set applies — unlike `p.array`'s
19
+ * repeated-key format (CV7: both are first-class; csv is the one-key
20
+ * packing). Elements are strings unless an element codec is given.
21
+ */
22
+ csv<E = string>(element?: Codec<E>): Codec<E[]>;
9
23
  custom<Out>(codec: {
10
24
  /** Reflection name shown by describeCodec/`paramour list` (default "custom"). */
11
25
  label?: string;
@@ -13,11 +27,21 @@ export declare const p: {
13
27
  serialize: (value: Out) => string;
14
28
  }): Codec<Out>;
15
29
  enum<const M extends readonly [string, ...string[]]>(members: M): Codec<M[number]>;
30
+ /**
31
+ * A 1-based-on-wire / 0-based-in-memory integer for pagination-style
32
+ * params (`?page=1` ↔ index 0, design-13 PP5). Deliberately stricter than
33
+ * nuqs's `parseAsIndex`, whose below-floor behavior is unspecified: wire
34
+ * values below 1 are a ParseError (recoverable via `.catch()`, like any
35
+ * other malformed input), and a negative in-memory index — which cannot
36
+ * round-trip through the 1-based wire floor — is a SerializeError at
37
+ * link-build time (the RL1 ethos). The optional schema validates the
38
+ * in-memory (0-based) value on both sides.
39
+ */
40
+ index<S extends StandardSchemaV1<number, number>>(schema?: S): Codec<S extends undefined ? number : StandardSchemaV1.InferOutput<S>>;
16
41
  integer<S extends StandardSchemaV1<number, number>>(schema?: S): Codec<S extends undefined ? number : StandardSchemaV1.InferOutput<S>>;
17
42
  isoDate(): Codec<Date>;
18
43
  json<S extends StandardSchemaV1>(schema: S): Codec<StandardSchemaV1.InferOutput<S>>;
19
44
  number<S extends StandardSchemaV1<number, number>>(schema?: S): Codec<S extends undefined ? number : StandardSchemaV1.InferOutput<S>>;
20
45
  string<S extends StandardSchemaV1<string, string>>(schema?: S): Codec<S extends undefined ? string : StandardSchemaV1.InferOutput<S>>;
21
- stringArray(): Codec<string[], "required", false, "many">;
22
46
  timestamp(): Codec<Date>;
23
47
  };
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.
@@ -73,12 +73,46 @@ function refineForSerialize(schema, value) {
73
73
  }
74
74
  return result.value;
75
75
  }
76
+ /**
77
+ * Shared element admission for `p.array`/`p.csv` (PP1/CV2): resolves the
78
+ * no-arg default and mirrors the type-state exclusions — presence, catch,
79
+ * and arity-many inners — for JS consumers (the RL1 ethos). Only the
80
+ * element's parse/serialize functions are captured by the list builders, so
81
+ * an accepted modifier would be silently dropped, not applied; one guard
82
+ * keeps that runtime mirror of the type-state in a single place.
83
+ */
84
+ function resolveListElement(element, builder) {
85
+ const inner = element ?? (defaultListElement ??= p.string());
86
+ if (inner["~arity"] === "many" ||
87
+ inner["~caught"] ||
88
+ inner["~presence"] !== "required") {
89
+ throw new ParamourError(`${builder}() elements cannot carry modifiers (.optional()/.default()/.catch()) or be array codecs`);
90
+ }
91
+ return inner;
92
+ }
76
93
  function serializeFiniteNumber(value) {
77
94
  if (typeof value !== "number" || !Number.isFinite(value)) {
78
95
  throw new SerializeError(`Expected a finite number, got ${showValue(value)}`);
79
96
  }
80
97
  return String(value);
81
98
  }
99
+ /**
100
+ * Serialize-side twin of {@link parseIntegerElement}, shared by `p.integer`
101
+ * and `p.index`: schema refinement plus the finite and safe-integer guards.
102
+ * Returns the refined NUMBER — `p.integer` stringifies it as-is, `p.index`
103
+ * shifts it into the 1-based wire form first — so the happy path pays no
104
+ * throwaway stringification.
105
+ */
106
+ function serializeIntegerElement(schema, value) {
107
+ const refined = schema ? refineForSerialize(schema, value) : value;
108
+ if (typeof refined !== "number" || !Number.isFinite(refined)) {
109
+ throw new SerializeError(`Expected a finite number, got ${showValue(refined)}`);
110
+ }
111
+ if (!Number.isSafeInteger(refined)) {
112
+ throw new SerializeError(`${String(refined)} is not a safe integer`);
113
+ }
114
+ return refined;
115
+ }
82
116
  /**
83
117
  * JSON.stringify throws raw TypeErrors (circular refs, BigInt) and lets
84
118
  * toJSON() exceptions escape; wrap them so the ParamourError contract holds.
@@ -86,11 +120,44 @@ function serializeFiniteNumber(value) {
86
120
  function stringifyJson(value) {
87
121
  return rebrandForeign(() => JSON.stringify(value), (error) => new SerializeError("Value is not JSON-serializable", { cause: error }));
88
122
  }
123
+ /**
124
+ * Shared element for no-arg `p.csv()`/`p.array()` — codecs are immutable, so
125
+ * one schemaless string codec serves every list. Lazily built: `p` does not
126
+ * exist yet while the module initializes.
127
+ */
128
+ let defaultListElement;
89
129
  /**
90
130
  * The `p.*` wire-codec builders (DESIGN §5 layer 1, design-02 D9).
91
131
  * Each codec defines how one value crosses the URL boundary, both directions.
92
132
  */
93
133
  export const p = {
134
+ /**
135
+ * A repeated-key list (`?tags=a&tags=b`, design-13 PP1): arity "many", so
136
+ * presence modifiers are unavailable — absent and `[]` are the same wire
137
+ * state (S6/P6) — unlike `p.csv`'s one-key packing (CV7). Elements are
138
+ * strings unless an element codec is given.
139
+ */
140
+ array(element) {
141
+ // PP1: same element-by-composition shape as p.csv (CV2) — presence,
142
+ // catch, and arity-many inners are excluded by the parameter type;
143
+ // resolveListElement mirrors that type-state for JS consumers. Unlike
144
+ // csv there is no nested-composite special case: a csv element is a
145
+ // legal whole-value scalar per repeated key (?m=a,b&m=c,d), and
146
+ // repeated-key values have no separator for element serializations to
147
+ // collide with, so no CV4 twin is needed either.
148
+ const inner = resolveListElement(element, "p.array");
149
+ // The element functions already have the arity-"many" per-element
150
+ // contract (one wire value per array item), so they pass through as-is;
151
+ // the string-return contract on custom serializers is enforced at the
152
+ // search.ts/path.ts call sites, same as using the element directly.
153
+ return createCodec({
154
+ arity: "many",
155
+ element: inner,
156
+ kind: "array",
157
+ parseElement: inner["~parseElement"],
158
+ serializeElement: inner["~serializeElement"],
159
+ });
160
+ },
94
161
  boolean() {
95
162
  return createCodec({
96
163
  kind: "boolean",
@@ -109,6 +176,83 @@ export const p = {
109
176
  },
110
177
  });
111
178
  },
179
+ /**
180
+ * A comma-separated scalar list in ONE wire value (design-11 CV1): arity
181
+ * "single", so the full modifier set applies — unlike `p.array`'s
182
+ * repeated-key format (CV7: both are first-class; csv is the one-key
183
+ * packing). Elements are strings unless an element codec is given.
184
+ */
185
+ csv(element) {
186
+ // CV2: presence, catch, and arity-many inners are excluded by the
187
+ // parameter type (the D3 philosophy); resolveListElement mirrors that
188
+ // type-state for JS consumers (the RL1 ethos). Nesting is detected
189
+ // structurally via ~element (never via ~kind, which is reflection-only
190
+ // and free-form for p.custom labels). Comma-emitting p.custom inners
191
+ // are undetectable here and are caught by the CV4 serialize guard
192
+ // instead.
193
+ const inner = resolveListElement(element, "p.csv");
194
+ // The shared guard ran first: p.array also carries ~element (PP1), and
195
+ // the arity guard owns the "array codecs" wording — the ~element guard
196
+ // here is then specifically the nested-csv (arity-"single" composite)
197
+ // case.
198
+ if (inner["~element"] !== undefined) {
199
+ throw new ParamourError("p.csv() elements cannot themselves be csv lists");
200
+ }
201
+ const parseInner = inner["~parseElement"];
202
+ const serializeInner = inner["~serializeElement"];
203
+ return createCodec({
204
+ element: inner,
205
+ kind: "csv",
206
+ parseElement: (raw) => {
207
+ // CV3: the empty wire string is [] — checked before split, because
208
+ // "".split(",") is [""], which the strict grammar would reject.
209
+ if (raw === "")
210
+ return [];
211
+ const result = [];
212
+ for (const segment of raw.split(",")) {
213
+ // CV3: strict grammar — "a,,b", trailing "a,", and a lone "," are
214
+ // ParseErrors, recoverable via the LIST's .catch() (D2).
215
+ if (segment === "") {
216
+ throw new ParseError(`"${raw}" contains an empty list element`);
217
+ }
218
+ // CV3: the first element failure aborts the list parse — the
219
+ // element codec's own ParseError propagates unwrapped.
220
+ result.push(parseInner(segment));
221
+ }
222
+ return result;
223
+ },
224
+ serializeElement: (value) => {
225
+ if (!Array.isArray(value)) {
226
+ throw new SerializeError(`Expected an array, got ${showValue(value)}`);
227
+ }
228
+ const parts = [];
229
+ for (const item of value) {
230
+ const serialized = serializeInner(item);
231
+ // A plain-JS custom element serializer can return a non-string;
232
+ // enforce the string contract here — as search.ts/path.ts do at
233
+ // their ~serializeElement call sites — so the CV4 guards below
234
+ // cannot throw raw TypeErrors.
235
+ if (typeof serialized !== "string") {
236
+ throw new SerializeError(`List element serializer must return a string, got ${typeof serialized}`);
237
+ }
238
+ // CV4: an empty segment on re-parse; [""] is deliberately
239
+ // unrepresentable — the empty wire string already means [].
240
+ if (serialized === "") {
241
+ throw new SerializeError(`List element ${showValue(item)} serializes to the empty string`);
242
+ }
243
+ // CV4: would mis-split on re-parse.
244
+ if (serialized.includes(",")) {
245
+ throw new SerializeError(`List element serialization "${serialized}" contains a comma`);
246
+ }
247
+ parts.push(serialized);
248
+ }
249
+ // [] joins to "" (CV5) — which is also why .default([])'s
250
+ // define-time pre-serialization succeeds and D8 elision compares
251
+ // [] against the empty wire form.
252
+ return parts.join(",");
253
+ },
254
+ });
255
+ },
112
256
  custom(codec) {
113
257
  // Paramour's own errors are never downgraded: ANY ParamourError thrown
114
258
  // by user parse/serialize code — config-level failures (async schema,
@@ -141,23 +285,50 @@ export const p = {
141
285
  },
142
286
  });
143
287
  },
144
- integer(schema) {
288
+ /**
289
+ * A 1-based-on-wire / 0-based-in-memory integer for pagination-style
290
+ * params (`?page=1` ↔ index 0, design-13 PP5). Deliberately stricter than
291
+ * nuqs's `parseAsIndex`, whose below-floor behavior is unspecified: wire
292
+ * values below 1 are a ParseError (recoverable via `.catch()`, like any
293
+ * other malformed input), and a negative in-memory index — which cannot
294
+ * round-trip through the 1-based wire floor — is a SerializeError at
295
+ * link-build time (the RL1 ethos). The optional schema validates the
296
+ * in-memory (0-based) value on both sides.
297
+ */
298
+ index(schema) {
145
299
  return createCodec({
146
- kind: "integer",
300
+ kind: "index",
147
301
  parseElement: (raw) => {
148
- const value = parseIntegerElement(raw);
302
+ const wire = parseIntegerElement(raw);
303
+ if (wire < 1) {
304
+ throw new ParseError(`"${raw}" is below the 1-based wire floor of 1`);
305
+ }
306
+ const value = wire - 1;
149
307
  return schema ? refine(schema, value) : value;
150
308
  },
151
309
  serializeElement: (value) => {
152
- const refined = schema ? refineForSerialize(schema, value) : value;
153
- const serialized = serializeFiniteNumber(refined);
154
- if (!Number.isSafeInteger(refined)) {
155
- throw new SerializeError(`${serialized} is not a safe integer`);
310
+ const index = serializeIntegerElement(schema, value);
311
+ if (index < 0) {
312
+ throw new SerializeError(`${String(index)} is negative and cannot round-trip through the 1-based wire form`);
156
313
  }
157
- return serialized;
314
+ const wire = index + 1;
315
+ if (!Number.isSafeInteger(wire)) {
316
+ throw new SerializeError(`${String(index)} is outside the 1-based wire form's safe integer range`);
317
+ }
318
+ return String(wire);
158
319
  },
159
320
  });
160
321
  },
322
+ integer(schema) {
323
+ return createCodec({
324
+ kind: "integer",
325
+ parseElement: (raw) => {
326
+ const value = parseIntegerElement(raw);
327
+ return schema ? refine(schema, value) : value;
328
+ },
329
+ serializeElement: (value) => String(serializeIntegerElement(schema, value)),
330
+ });
331
+ },
161
332
  isoDate() {
162
333
  return createCodec({
163
334
  kind: "isoDate",
@@ -226,19 +397,6 @@ export const p = {
226
397
  },
227
398
  });
228
399
  },
229
- stringArray() {
230
- return createCodec({
231
- arity: "many",
232
- kind: "string",
233
- parseElement: (raw) => raw,
234
- serializeElement: (value) => {
235
- if (typeof value !== "string") {
236
- throw new SerializeError("Expected an array of strings");
237
- }
238
- return value;
239
- },
240
- });
241
- },
242
400
  timestamp() {
243
401
  return createCodec({
244
402
  kind: "timestamp",
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.1",
3
+ "version": "0.4.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": {