paramour 0.0.0 → 0.2.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.
@@ -0,0 +1,10 @@
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";
4
+ export { href, type Href, type HrefArgs, type InferHrefInput, type StaticHrefOptions, } from "./href.js";
5
+ export { p } from "./p.js";
6
+ export { buildPath, decodeParams, type DecodeParamsOptions, encodeParams, encodeStaticParams, type InferStaticParams, type ParamsSource, } from "./path.js";
7
+ export { type AnyAppRoute, type AnyPagesRoute, type AnyRoute, type AppRoute, defineAppRoute, definePagesRoute, type InferRouteParams, type PagesContext, type PagesRoute, type ParamourRegister, type ParamsConfig, type ParamsProps, type RegisteredAppRoutePaths, type RegisteredPagesRoutePaths, type RegisteredStaticAppRoutePaths, type RegisteredStaticPagesRoutePaths, type RegisteredStaticRoutePaths, type Route, type RouteProps, type RouterKind, type SafeResult, type SearchProps, } from "./route.js";
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";
10
+ export { standardSearchSchema, type StandardSearchSchema, } from "./standard-schema.js";
package/dist/index.js ADDED
@@ -0,0 +1,10 @@
1
+ export {} from "./codec.js";
2
+ export { describeCodec, describeRoute, } from "./describe.js";
3
+ export { ParamourError, ParamsDecodeError, ParseError, SearchDecodeError, SearchSourceError, SerializeError, } from "./errors.js";
4
+ export { href, } from "./href.js";
5
+ export { p } from "./p.js";
6
+ export { buildPath, decodeParams, encodeParams, encodeStaticParams, } from "./path.js";
7
+ export { defineAppRoute, definePagesRoute, } from "./route.js";
8
+ export { safeDecodeParams, safeDecodeSearch } from "./safe-decode.js";
9
+ export { buildSearchString, decodeSearch, encodeSearch, rawSearch, searchToString, } from "./search.js";
10
+ export { standardSearchSchema, } from "./standard-schema.js";
package/dist/p.d.ts ADDED
@@ -0,0 +1,23 @@
1
+ import type { StandardSchemaV1 } from "@standard-schema/spec";
2
+ import { type Codec } from "./codec.js";
3
+ /**
4
+ * The `p.*` wire-codec builders (DESIGN §5 layer 1, design-02 D9).
5
+ * Each codec defines how one value crosses the URL boundary, both directions.
6
+ */
7
+ export declare const p: {
8
+ boolean(): Codec<boolean>;
9
+ custom<Out>(codec: {
10
+ /** Reflection name shown by describeCodec/`paramour list` (default "custom"). */
11
+ label?: string;
12
+ parse: (raw: string) => Out;
13
+ serialize: (value: Out) => string;
14
+ }): Codec<Out>;
15
+ enum<const M extends readonly [string, ...string[]]>(members: M): Codec<M[number]>;
16
+ integer<S extends StandardSchemaV1<number, number>>(schema?: S): Codec<S extends undefined ? number : StandardSchemaV1.InferOutput<S>>;
17
+ isoDate(): Codec<Date>;
18
+ json<S extends StandardSchemaV1>(schema: S): Codec<StandardSchemaV1.InferOutput<S>>;
19
+ number<S extends StandardSchemaV1<number, number>>(schema?: S): Codec<S extends undefined ? number : StandardSchemaV1.InferOutput<S>>;
20
+ string<S extends StandardSchemaV1<string, string>>(schema?: S): Codec<S extends undefined ? string : StandardSchemaV1.InferOutput<S>>;
21
+ stringArray(): Codec<string[], "required", false, "many">;
22
+ timestamp(): Codec<Date>;
23
+ };
package/dist/p.js ADDED
@@ -0,0 +1,265 @@
1
+ import { createCodec } from "./codec.js";
2
+ import { foreignMessage, ParseError, rebrandForeign, SerializeError, showValue, } from "./errors.js";
3
+ import { runStandardSchemaSync } from "./schema.js";
4
+ // Wire grammars per wire-format spec §4. `Number()` alone is too loose
5
+ // (accepts hex, trims whitespace), hence explicit anchored patterns.
6
+ const INTEGER_RE = /^-?\d+$/;
7
+ const NUMBER_RE = /^-?\d+(\.\d+)?([eE][+-]?\d+)?$/;
8
+ const ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
9
+ // Canonical emit is Date#toISOString (milliseconds always); parse tolerates
10
+ // missing milliseconds. UTC (`Z`) only — offsets are rejected in v0.1.
11
+ const TIMESTAMP_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{1,3})?Z$/;
12
+ /**
13
+ * Serialize-side Date guard. Years outside 0000–9999 are rejected:
14
+ * toISOString switches to the expanded ±6-digit-year form there, which the
15
+ * wire grammars (§4) cannot represent — better a loud SerializeError than a
16
+ * URL that can never round-trip.
17
+ */
18
+ function expectSerializableDate(value) {
19
+ if (!(value instanceof Date) || Number.isNaN(value.getTime())) {
20
+ throw new SerializeError("Expected a valid Date");
21
+ }
22
+ const year = value.getUTCFullYear();
23
+ if (year < 0 || year > 9999) {
24
+ throw new SerializeError(`Date year ${String(year)} is outside the representable 0000-9999 range`);
25
+ }
26
+ return value;
27
+ }
28
+ // Array.from, not .map: the issues array belongs to the validator and may be
29
+ // an Array subclass whose Symbol.species constructor mangles a mapped result
30
+ // (see the note in search.ts's decodeRawSearch).
31
+ function joinIssues(issues) {
32
+ return Array.from(issues, (issue) => issue.message).join("; ");
33
+ }
34
+ function parseIntegerElement(raw) {
35
+ if (!INTEGER_RE.test(raw)) {
36
+ throw new ParseError(`"${raw}" is not an integer`);
37
+ }
38
+ const value = Number(raw);
39
+ if (!Number.isSafeInteger(value)) {
40
+ throw new ParseError(`"${raw}" is outside the safe integer range`);
41
+ }
42
+ return value;
43
+ }
44
+ function parseNumberElement(raw) {
45
+ if (!NUMBER_RE.test(raw)) {
46
+ throw new ParseError(`"${raw}" is not a number`);
47
+ }
48
+ const value = Number(raw);
49
+ if (!Number.isFinite(value)) {
50
+ throw new ParseError(`"${raw}" is not a finite number`);
51
+ }
52
+ return value;
53
+ }
54
+ function refine(schema, value) {
55
+ const result = runStandardSchemaSync(schema, value);
56
+ if (result.issues) {
57
+ throw new ParseError(`Schema validation failed: ${joinIssues(result.issues)}`);
58
+ }
59
+ return result.value;
60
+ }
61
+ /**
62
+ * Serialize-side twin of {@link refine}: schema-invalid in-memory values must
63
+ * fail loudly at link-build time, not on the next navigation. The schema's
64
+ * returned value is what goes on the wire, so normalizing schemas emit
65
+ * canonical form. Transforming (In≠Out) schemas are parse-only by design —
66
+ * their output fails input validation here; use `p.custom` for bidirectional
67
+ * transforms.
68
+ */
69
+ function refineForSerialize(schema, value) {
70
+ const result = runStandardSchemaSync(schema, value);
71
+ if (result.issues) {
72
+ throw new SerializeError(`Schema validation failed: ${joinIssues(result.issues)}`);
73
+ }
74
+ return result.value;
75
+ }
76
+ function serializeFiniteNumber(value) {
77
+ if (typeof value !== "number" || !Number.isFinite(value)) {
78
+ throw new SerializeError(`Expected a finite number, got ${showValue(value)}`);
79
+ }
80
+ return String(value);
81
+ }
82
+ /**
83
+ * JSON.stringify throws raw TypeErrors (circular refs, BigInt) and lets
84
+ * toJSON() exceptions escape; wrap them so the ParamourError contract holds.
85
+ */
86
+ function stringifyJson(value) {
87
+ return rebrandForeign(() => JSON.stringify(value), (error) => new SerializeError("Value is not JSON-serializable", { cause: error }));
88
+ }
89
+ /**
90
+ * The `p.*` wire-codec builders (DESIGN §5 layer 1, design-02 D9).
91
+ * Each codec defines how one value crosses the URL boundary, both directions.
92
+ */
93
+ export const p = {
94
+ boolean() {
95
+ return createCodec({
96
+ kind: "boolean",
97
+ parseElement: (raw) => {
98
+ if (raw === "true")
99
+ return true;
100
+ if (raw === "false")
101
+ return false;
102
+ throw new ParseError(`"${raw}" is not "true" or "false"`);
103
+ },
104
+ serializeElement: (value) => {
105
+ if (typeof value !== "boolean") {
106
+ throw new SerializeError(`Expected a boolean, got ${showValue(value)}`);
107
+ }
108
+ return value ? "true" : "false";
109
+ },
110
+ });
111
+ },
112
+ custom(codec) {
113
+ // Paramour's own errors are never downgraded: ANY ParamourError thrown
114
+ // by user parse/serialize code — config-level failures (async schema,
115
+ // builder misuse) but also value-level errors from reused paramour
116
+ // helpers — passes through loud, bypassing .catch() recovery and per-key
117
+ // aggregation. .catch() recovers foreign parse failures only, which
118
+ // rebrandForeign normalizes to ParseError so recovery sees them.
119
+ return createCodec({
120
+ ...(codec.label === undefined ? {} : { kind: codec.label }),
121
+ parseElement: (raw) => rebrandForeign(() => codec.parse(raw), (error) => new ParseError(foreignMessage(error), { cause: error })),
122
+ serializeElement: (value) => rebrandForeign(() => codec.serialize(value), (error) => new SerializeError(foreignMessage(error), { cause: error })),
123
+ });
124
+ },
125
+ enum(members) {
126
+ const set = new Set(members);
127
+ return createCodec({
128
+ enumMembers: members,
129
+ kind: "enum",
130
+ parseElement: (raw) => {
131
+ if (!set.has(raw)) {
132
+ throw new ParseError(`"${raw}" is not one of: ${members.join(", ")}`);
133
+ }
134
+ return raw;
135
+ },
136
+ serializeElement: (value) => {
137
+ if (typeof value !== "string" || !set.has(value)) {
138
+ throw new SerializeError(`${showValue(value)} is not one of: ${members.join(", ")}`);
139
+ }
140
+ return value;
141
+ },
142
+ });
143
+ },
144
+ integer(schema) {
145
+ return createCodec({
146
+ kind: "integer",
147
+ parseElement: (raw) => {
148
+ const value = parseIntegerElement(raw);
149
+ return schema ? refine(schema, value) : value;
150
+ },
151
+ 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`);
156
+ }
157
+ return serialized;
158
+ },
159
+ });
160
+ },
161
+ isoDate() {
162
+ return createCodec({
163
+ kind: "isoDate",
164
+ parseElement: (raw) => {
165
+ if (!ISO_DATE_RE.test(raw)) {
166
+ throw new ParseError(`"${raw}" is not a YYYY-MM-DD date`);
167
+ }
168
+ // ISO-string construction, not Date.UTC: the latter maps years 0-99
169
+ // to 1900+year. The round-trip comparison rejects days the engine
170
+ // would silently normalize (2026-02-30 → Mar 2).
171
+ const date = new Date(`${raw}T00:00:00.000Z`);
172
+ if (Number.isNaN(date.getTime()) ||
173
+ date.toISOString().slice(0, 10) !== raw) {
174
+ throw new ParseError(`"${raw}" is not a real calendar date`);
175
+ }
176
+ return date;
177
+ },
178
+ serializeElement: (value) => expectSerializableDate(value).toISOString().slice(0, 10),
179
+ });
180
+ },
181
+ json(schema) {
182
+ return createCodec({
183
+ kind: "json",
184
+ parseElement: (raw) => {
185
+ let parsed;
186
+ try {
187
+ parsed = JSON.parse(raw);
188
+ }
189
+ catch {
190
+ throw new ParseError(`"${raw}" is not valid JSON`);
191
+ }
192
+ return refine(schema, parsed);
193
+ },
194
+ serializeElement: (value) => {
195
+ const refined = refineForSerialize(schema, value);
196
+ // lib.d.ts types JSON.stringify as always-string, but it returns
197
+ // undefined for undefined/function/symbol inputs.
198
+ const serialized = stringifyJson(refined);
199
+ if (serialized === undefined) {
200
+ throw new SerializeError("Value is not JSON-serializable");
201
+ }
202
+ return serialized;
203
+ },
204
+ });
205
+ },
206
+ number(schema) {
207
+ return createCodec({
208
+ kind: "number",
209
+ parseElement: (raw) => {
210
+ const value = parseNumberElement(raw);
211
+ return schema ? refine(schema, value) : value;
212
+ },
213
+ serializeElement: (value) => serializeFiniteNumber(schema ? refineForSerialize(schema, value) : value),
214
+ });
215
+ },
216
+ string(schema) {
217
+ return createCodec({
218
+ kind: "string",
219
+ parseElement: (raw) => (schema ? refine(schema, raw) : raw),
220
+ serializeElement: (value) => {
221
+ const refined = schema ? refineForSerialize(schema, value) : value;
222
+ if (typeof refined !== "string") {
223
+ throw new SerializeError("Expected a string");
224
+ }
225
+ return refined;
226
+ },
227
+ });
228
+ },
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
+ timestamp() {
243
+ return createCodec({
244
+ kind: "timestamp",
245
+ parseElement: (raw) => {
246
+ if (!TIMESTAMP_RE.test(raw)) {
247
+ throw new ParseError(`"${raw}" is not an ISO 8601 UTC timestamp`);
248
+ }
249
+ const date = new Date(raw);
250
+ if (Number.isNaN(date.getTime())) {
251
+ throw new ParseError(`"${raw}" is not a real instant`);
252
+ }
253
+ // The engine silently normalizes impossible fields (Feb 30 → Mar 1,
254
+ // 24:00 → next day). Pad the input to canonical millisecond form and
255
+ // require an exact round-trip instead.
256
+ const canonical = raw.replace(/(?:\.(\d{1,3}))?Z$/, (_match, ms) => `.${(ms ?? "").padEnd(3, "0")}Z`);
257
+ if (date.toISOString() !== canonical) {
258
+ throw new ParseError(`"${raw}" is not a real instant`);
259
+ }
260
+ return date;
261
+ },
262
+ serializeElement: (value) => expectSerializableDate(value).toISOString(),
263
+ });
264
+ },
265
+ };
package/dist/path.d.ts ADDED
@@ -0,0 +1,108 @@
1
+ import type { AnyRoute, CatchAllNames, InferRouteParams, OptionalCatchAllNames, ParamOutput, SingleParamNames } from "./route.js";
2
+ /**
3
+ * Decode-policy knob for {@link decodeParams} / {@link safeDecodeParams} (R5).
4
+ * `percentDecode` defaults to `true` — the App-Router reality, where Next
5
+ * hands the `params` prop / `useParams()` surface percent-ENCODED values
6
+ * (issues #48058/#64952), so core owns the decode. The pages surfaces are the
7
+ * exception: `useRouter().query` and `getServerSideProps` `ctx.params`/`query`
8
+ * have ALREADY been percent-decoded by Node's querystring layer, so those
9
+ * entry points pass `{ percentDecode: false }` to avoid a double-decode
10
+ * (`/product/a%2520b` → `"a%20b"` from Node → must survive, not decode again).
11
+ * Optional-with-default under `exactOptionalPropertyTypes`: absent or a plain
12
+ * `boolean`, never `undefined`.
13
+ */
14
+ export interface DecodeParamsOptions {
15
+ readonly percentDecode?: boolean;
16
+ }
17
+ /**
18
+ * Encode-input side of a route's params (RL3's href-input column): `[id]` →
19
+ * `Out` (required), `[...slug]` → `Out[]` (required — `[]` is an R3
20
+ * serialization error), `[[...slug]]` → `Out[]` with an OPTIONAL key, per
21
+ * the spike-01 follow-up under exactOptionalPropertyTypes. Module-level
22
+ * export only; Block 3's `InferHrefInput` builds on it.
23
+ */
24
+ export type InferParamsInput<R extends AnyRoute> = {
25
+ [K in CatchAllNames<R["path"]>]: ParamOutput<R["~params"], K>[];
26
+ } & {
27
+ [K in OptionalCatchAllNames<R["path"]>]?: ParamOutput<R["~params"], K>[];
28
+ } & {
29
+ [K in SingleParamNames<R["path"]>]: ParamOutput<R["~params"], K>;
30
+ };
31
+ /**
32
+ * Return shape of {@link encodeStaticParams}: the per-param wire-value record
33
+ * Next's static-generation surfaces expect — `[id]` → `string`, `[...slug]` →
34
+ * `string[]`, `[[...slug]]` → `string[]` with an OPTIONAL key (absent is the
35
+ * R3 base-path variant). Mapped types carry implicit index signatures, so the
36
+ * result is assignable to `generateStaticParams`' and `getStaticPaths`'
37
+ * params shapes without a cast.
38
+ */
39
+ export type InferStaticParams<R extends AnyRoute> = Partial<Record<OptionalCatchAllNames<R["path"]>, string[]>> & Record<CatchAllNames<R["path"]>, string[]> & Record<SingleParamNames<R["path"]>, string>;
40
+ /**
41
+ * Value-layer params source (wire spec §1, R5): the shape of Next's `params`
42
+ * prop and `useParams()` return. Contrary to the byte-layer's usual "platform
43
+ * already decoded it" stance, Next hands the App-Router surfaces percent-ENCODED
44
+ * values (Next issues #48058/#64952 — only route.ts handlers decode), so core
45
+ * owns the decode in {@link decodeParams} before applying codec grammars. The
46
+ * pages surfaces are the exception (already Node-decoded); they opt out via
47
+ * {@link DecodeParamsOptions}'s `percentDecode: false`.
48
+ */
49
+ export type ParamsSource = Record<string, string | string[] | undefined>;
50
+ /** One parsed path segment, as produced by {@link tokenizePath}. */
51
+ export type PathSegment = {
52
+ readonly kind: "catchall" | "optional-catchall" | "single";
53
+ readonly name: string;
54
+ readonly raw: string;
55
+ } | {
56
+ readonly kind: "static";
57
+ readonly raw: string;
58
+ };
59
+ /**
60
+ * Builds the path portion of an href (RL5): `/` plus the encoded segments
61
+ * joined with `/`. R2's element joining falls out of the same join as
62
+ * everything else; a fully-elided path (an optional catch-all at the root)
63
+ * yields "/".
64
+ */
65
+ export declare function buildPath<R extends AnyRoute>(route: R, params: InferParamsInput<R>): string;
66
+ /**
67
+ * Decodes a params source against a route's codecs (RL7), the sync twin of
68
+ * `route.parseParams` — mirrors decodeSearch: per-key {@link Issue}
69
+ * aggregation into {@link ParamsDecodeError}. Shape validation is strict:
70
+ * `[id]` given an array, a catch-all given a string, a missing required key,
71
+ * or a non-string element are recorded issues, and NOT `.catch()`-recoverable
72
+ * — a shape mismatch means the props came from a route this definition
73
+ * doesn't describe. Unknown source keys are never read (P8's spirit; Next
74
+ * includes parent-layout params).
75
+ */
76
+ export declare function decodeParams<R extends AnyRoute>(route: R, source: ParamsSource, options?: DecodeParamsOptions): InferRouteParams<R>;
77
+ /**
78
+ * Encodes a params input into ordered, already-percent-encoded URL segment
79
+ * strings (RL5) — ONE entry per emitted URL segment: a static segment is
80
+ * emitted verbatim, a single param contributes one entry (R1), a catch-all
81
+ * one per element (R2), an elided optional catch-all none (R3). Codec
82
+ * serialize errors and schema-refinement failures (N9) propagate unchanged,
83
+ * already branded at their own chokepoints.
84
+ */
85
+ export declare function encodeParams<R extends AnyRoute>(route: R, params: InferParamsInput<R>): string[];
86
+ /**
87
+ * Encodes a params input into the per-param wire-value record the static
88
+ * generation surfaces expect: App Router `generateStaticParams` entries and
89
+ * Pages Router `getStaticPaths` `{ params }` objects (PR10's static story).
90
+ * Same codec serialization and R1–R4 validation as {@link encodeParams},
91
+ * with two deliberate differences: static segments are skipped (Next wants
92
+ * only the dynamic params, keyed by name), and values are NOT percent-encoded
93
+ * — Next percent-encodes static-params values itself when it materializes
94
+ * the concrete URLs, so pre-encoding here would double-encode (the
95
+ * encode-side mirror of R5's decode asymmetry). That also means the S7
96
+ * lone-surrogate brand stays an encodeParams concern: strings are handed to
97
+ * Next verbatim. An elided optional catch-all (R3) OMITS its key — an absent
98
+ * key is the base-path variant on both routers (Pages also accepts
99
+ * undefined/[]; omission is the one spelling valid on both).
100
+ */
101
+ export declare function encodeStaticParams<R extends AnyRoute>(route: R, params: InferParamsInput<R>): InferStaticParams<R>;
102
+ /**
103
+ * Tokenizes a path literal into segments, throwing ParamourError on every
104
+ * RL1 rejection. Shared by the route constructors (define-time validation)
105
+ * and the R-rule runtime here, so encode/decode never re-derive segment
106
+ * kinds.
107
+ */
108
+ export declare function tokenizePath(path: string): PathSegment[];