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.
- package/dist/codec.d.ts +76 -0
- package/dist/codec.js +110 -0
- package/dist/describe.d.ts +63 -0
- package/dist/describe.js +75 -0
- package/dist/errors.d.ts +73 -0
- package/dist/errors.js +157 -0
- package/dist/href.d.ts +79 -0
- package/dist/href.js +32 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.js +10 -0
- package/dist/p.d.ts +23 -0
- package/dist/p.js +265 -0
- package/dist/path.d.ts +108 -0
- package/dist/path.js +439 -0
- package/dist/route.d.ts +293 -0
- package/dist/route.js +210 -0
- package/dist/safe-decode.d.ts +16 -0
- package/dist/safe-decode.js +42 -0
- package/dist/schema.d.ts +10 -0
- package/dist/schema.js +16 -0
- package/dist/search.d.ts +155 -0
- package/dist/search.js +474 -0
- package/dist/standard-schema.d.ts +27 -0
- package/dist/standard-schema.js +77 -0
- package/package.json +28 -4
- package/LICENSE +0 -21
- package/README.md +0 -28
package/dist/codec.d.ts
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `any` is deliberate: `~out` appears in inferred method parameter positions
|
|
3
|
+
* (`.default(value: Out)`), which are contravariant under strictFunctionTypes;
|
|
4
|
+
* the `unknown` form would reject every concrete codec.
|
|
5
|
+
*/
|
|
6
|
+
export type AnyCodec = Codec<any, Presence, boolean, Arity>;
|
|
7
|
+
/** "single" = one wire value per key; "many" = repeated keys (arrays). */
|
|
8
|
+
export type Arity = "many" | "single";
|
|
9
|
+
/**
|
|
10
|
+
* A bidirectional wire codec.
|
|
11
|
+
*
|
|
12
|
+
* `Out` is the decoded in-memory type. `P`, `C`, and `A` are type-state:
|
|
13
|
+
* modifier methods are conditionally `never`, so illegal chains
|
|
14
|
+
* (`.optional().default()`, double `.catch()`) fail to compile (design-02 D3).
|
|
15
|
+
* Presence modifiers are also `never` for arity-"many" codecs: absent and `[]`
|
|
16
|
+
* are the same wire state (S6/P6), so `.default()`/`.optional()` could never
|
|
17
|
+
* round-trip there.
|
|
18
|
+
*
|
|
19
|
+
* `.default()` and `.catch()` accept either a value or a zero-arg factory;
|
|
20
|
+
* factories are invoked per decode/encode, so reference-typed defaults can be
|
|
21
|
+
* isolated per call (plain object values are returned by reference).
|
|
22
|
+
*
|
|
23
|
+
* Properties prefixed `~` are internal machinery, not public API. For
|
|
24
|
+
* arity-"many" codecs the element functions operate on single elements of
|
|
25
|
+
* `Out` (which is an array type).
|
|
26
|
+
*/
|
|
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;
|
|
31
|
+
readonly "~arity": A;
|
|
32
|
+
/** Stored as a thunk regardless of the form passed to `.catch()`. */
|
|
33
|
+
readonly "~catchValue": (() => Out) | undefined;
|
|
34
|
+
readonly "~caught": C;
|
|
35
|
+
/**
|
|
36
|
+
* True when `.default()` received a value (not a factory). Value defaults
|
|
37
|
+
* participate in D8 elision, compared against the live default
|
|
38
|
+
* re-serialized per encode. Factory defaults never elide: a time-varying
|
|
39
|
+
* factory would elide an explicitly-passed value that later decodes as a
|
|
40
|
+
* different one.
|
|
41
|
+
*/
|
|
42
|
+
readonly "~defaultElides": boolean;
|
|
43
|
+
/** Stored as a thunk regardless of the form passed to `.default()`. */
|
|
44
|
+
readonly "~defaultValue": (() => Out) | undefined;
|
|
45
|
+
/** Members of a `p.enum` codec; undefined for every other kind. */
|
|
46
|
+
readonly "~enumMembers": readonly string[] | undefined;
|
|
47
|
+
/**
|
|
48
|
+
* Which builder produced the codec (`"integer"`, `"enum"`, …; `p.custom`
|
|
49
|
+
* uses its `label` or `"custom"`). Reflection metadata for describeCodec —
|
|
50
|
+
* never consulted by parse/serialize.
|
|
51
|
+
*/
|
|
52
|
+
readonly "~kind": string;
|
|
53
|
+
/** phantom — carries `Out` for inference; never set at runtime */
|
|
54
|
+
readonly "~out": Out;
|
|
55
|
+
readonly "~parseElement": (raw: string) => unknown;
|
|
56
|
+
readonly "~presence": P;
|
|
57
|
+
readonly "~serializeElement": (value: unknown) => string;
|
|
58
|
+
}
|
|
59
|
+
export type OutputOf<C extends AnyCodec> = C["~out"];
|
|
60
|
+
/** Codecs legal in a `params:` config — no presence modifiers (design-02 D5). */
|
|
61
|
+
export type ParamCodec = Codec<any, "required", boolean>;
|
|
62
|
+
/**
|
|
63
|
+
* Presence governs absence semantics and property optionality on both the
|
|
64
|
+
* parse-output and href-input sides (design-02 D4). Catch is orthogonal:
|
|
65
|
+
* it recovers parse *failures*, never absence (D2).
|
|
66
|
+
*/
|
|
67
|
+
export type Presence = "defaulted" | "optional" | "required";
|
|
68
|
+
export type PresenceOf<C extends AnyCodec> = C["~presence"];
|
|
69
|
+
/** Internal factory used by the `p.*` builders. */
|
|
70
|
+
export declare function createCodec<Out, A extends Arity = "single">(impl: {
|
|
71
|
+
arity?: A;
|
|
72
|
+
enumMembers?: readonly string[];
|
|
73
|
+
kind?: string;
|
|
74
|
+
parseElement: (raw: string) => unknown;
|
|
75
|
+
serializeElement: (value: unknown) => string;
|
|
76
|
+
}): Codec<Out, "required", false, A>;
|
package/dist/codec.js
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { foreignMessage, ParamourError, ParseError, rebrandForeign, } from "./errors.js";
|
|
2
|
+
/** Internal factory used by the `p.*` builders. */
|
|
3
|
+
export function createCodec(impl) {
|
|
4
|
+
return build({
|
|
5
|
+
arity: impl.arity ?? "single",
|
|
6
|
+
catchValue: undefined,
|
|
7
|
+
defaultElides: false,
|
|
8
|
+
defaultValue: undefined,
|
|
9
|
+
enumMembers: impl.enumMembers,
|
|
10
|
+
kind: impl.kind ?? "custom",
|
|
11
|
+
parseElement: impl.parseElement,
|
|
12
|
+
presence: "required",
|
|
13
|
+
serializeElement: impl.serializeElement,
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
function build(state) {
|
|
17
|
+
const codec = {
|
|
18
|
+
catch(fallback) {
|
|
19
|
+
// Runtime guards mirror the type-state for JS consumers.
|
|
20
|
+
if (state.catchValue !== undefined) {
|
|
21
|
+
throw new ParamourError(".catch() may only be applied once");
|
|
22
|
+
}
|
|
23
|
+
return build({ ...state, catchValue: toThunk(fallback, "catch") });
|
|
24
|
+
},
|
|
25
|
+
default(value) {
|
|
26
|
+
if (state.arity === "many") {
|
|
27
|
+
throw new ParamourError(".default() is not available on array codecs: absent and [] are the same wire state");
|
|
28
|
+
}
|
|
29
|
+
if (state.presence !== "required") {
|
|
30
|
+
throw new ParamourError(`.default() is not available after .${state.presence === "optional" ? "optional" : "default"}()`);
|
|
31
|
+
}
|
|
32
|
+
// Value-form defaults are serialized once, here, so a schema-invalid
|
|
33
|
+
// or unserializable default fails at config-definition time — not on
|
|
34
|
+
// every subsequent encode. The wire form is deliberately NOT cached:
|
|
35
|
+
// D8 elision re-serializes the live default per encode, so mutating a
|
|
36
|
+
// reference-typed default can never desync encode from decode the way
|
|
37
|
+
// a stale build-time snapshot would. Factory defaults can't be
|
|
38
|
+
// pre-validated.
|
|
39
|
+
if (!isFactory(value)) {
|
|
40
|
+
serializeDefault(state.serializeElement, value);
|
|
41
|
+
}
|
|
42
|
+
return build({
|
|
43
|
+
...state,
|
|
44
|
+
defaultElides: !isFactory(value),
|
|
45
|
+
defaultValue: toThunk(value, "default"),
|
|
46
|
+
presence: "defaulted",
|
|
47
|
+
});
|
|
48
|
+
},
|
|
49
|
+
optional() {
|
|
50
|
+
if (state.arity === "many") {
|
|
51
|
+
throw new ParamourError(".optional() is not available on array codecs: absent already decodes to []");
|
|
52
|
+
}
|
|
53
|
+
if (state.presence !== "required") {
|
|
54
|
+
throw new ParamourError(`.optional() is not available after .${state.presence === "optional" ? "optional" : "default"}()`);
|
|
55
|
+
}
|
|
56
|
+
return build({ ...state, presence: "optional" });
|
|
57
|
+
},
|
|
58
|
+
"~arity": state.arity,
|
|
59
|
+
"~catchValue": state.catchValue,
|
|
60
|
+
"~caught": state.catchValue !== undefined,
|
|
61
|
+
"~defaultElides": state.defaultElides,
|
|
62
|
+
"~defaultValue": state.defaultValue,
|
|
63
|
+
"~enumMembers": state.enumMembers,
|
|
64
|
+
"~kind": state.kind,
|
|
65
|
+
"~parseElement": state.parseElement,
|
|
66
|
+
"~presence": state.presence,
|
|
67
|
+
"~serializeElement": state.serializeElement,
|
|
68
|
+
};
|
|
69
|
+
// The cast erases the runtime shape into the type-stated interface; the
|
|
70
|
+
// "~out" phantom is intentionally absent at runtime.
|
|
71
|
+
return codec;
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Factory-vs-value discrimination for `.default()`/`.catch()` arguments.
|
|
75
|
+
* Single source of truth: `.default()`'s elision flag and {@link toThunk}
|
|
76
|
+
* must agree on it for D8 correctness. (An `Out` that is itself a function
|
|
77
|
+
* is indistinguishable from a factory — such values can't serialize anyway.)
|
|
78
|
+
*/
|
|
79
|
+
function isFactory(stored) {
|
|
80
|
+
return typeof stored === "function";
|
|
81
|
+
}
|
|
82
|
+
function serializeDefault(serializeElement, value) {
|
|
83
|
+
return rebrandForeign(() => serializeElement(value), (error) => new ParamourError(".default() value is not serializable by this codec", {
|
|
84
|
+
cause: error,
|
|
85
|
+
}));
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Normalizes a `.default()`/`.catch()` argument to a thunk. Factories are
|
|
89
|
+
* invoked per decode/encode so each call gets a fresh value; the wrapper is
|
|
90
|
+
* the one chokepoint where a throwing user factory is branded ParamourError.
|
|
91
|
+
*/
|
|
92
|
+
function toThunk(stored, what) {
|
|
93
|
+
if (!isFactory(stored))
|
|
94
|
+
return () => stored;
|
|
95
|
+
return () => {
|
|
96
|
+
try {
|
|
97
|
+
return stored();
|
|
98
|
+
}
|
|
99
|
+
catch (error) {
|
|
100
|
+
// ParseError is data-level by contract (recoverable via .catch()); a
|
|
101
|
+
// factory throwing one is a config-side failure that must not
|
|
102
|
+
// masquerade as a recoverable parse failure — brand it like a foreign
|
|
103
|
+
// throw. Every other ParamourError stays loud as-is.
|
|
104
|
+
if (error instanceof ParamourError && !(error instanceof ParseError)) {
|
|
105
|
+
throw error;
|
|
106
|
+
}
|
|
107
|
+
throw new ParamourError(`.${what}() factory threw: ${foreignMessage(error)}`, { cause: error });
|
|
108
|
+
}
|
|
109
|
+
};
|
|
110
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import type { AnyCodec, Arity, Presence } from "./codec.js";
|
|
2
|
+
import type { AnyRoute, RouterKind } from "./route.js";
|
|
3
|
+
/**
|
|
4
|
+
* `.default()` in reflected form. Value-form defaults carry their wire
|
|
5
|
+
* serialization (the same text D8 elision compares against); factory
|
|
6
|
+
* defaults have no stable wire form — invoking one per description would
|
|
7
|
+
* leak time-varying values into what should be static metadata.
|
|
8
|
+
*/
|
|
9
|
+
export type CodecDefaultDescription = {
|
|
10
|
+
readonly kind: "factory";
|
|
11
|
+
} | {
|
|
12
|
+
readonly kind: "value";
|
|
13
|
+
readonly wire: string;
|
|
14
|
+
};
|
|
15
|
+
/**
|
|
16
|
+
* A codec's reflection surface: everything `paramour list`-style tooling
|
|
17
|
+
* needs to render a config without executing parse/serialize. Optional
|
|
18
|
+
* members are ABSENT (not `undefined`) when they don't apply —
|
|
19
|
+
* exactOptionalPropertyTypes consumers can spread these safely.
|
|
20
|
+
*/
|
|
21
|
+
export interface CodecDescription {
|
|
22
|
+
readonly arity: Arity;
|
|
23
|
+
readonly caught: boolean;
|
|
24
|
+
readonly defaultValue?: CodecDefaultDescription;
|
|
25
|
+
readonly enumMembers?: readonly string[];
|
|
26
|
+
readonly kind: string;
|
|
27
|
+
readonly presence: Presence;
|
|
28
|
+
}
|
|
29
|
+
/** A param codec plus the dynamic-segment kind that hosts it. */
|
|
30
|
+
export interface ParamDescription extends CodecDescription {
|
|
31
|
+
readonly segmentKind: "catchall" | "optional-catchall" | "single";
|
|
32
|
+
}
|
|
33
|
+
/** Reflected shape of one defined route. */
|
|
34
|
+
export interface RouteDescription {
|
|
35
|
+
readonly params: Readonly<Record<string, ParamDescription>>;
|
|
36
|
+
readonly path: string;
|
|
37
|
+
readonly router: RouterKind;
|
|
38
|
+
readonly search: SearchDescription;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* The `search:` slot's three shapes: absent config, a codec map, or the
|
|
42
|
+
* rawSearch escape hatch (whose schema is a black box — Standard Schema
|
|
43
|
+
* carries no introspectable structure, so `raw` is all there is to say).
|
|
44
|
+
*/
|
|
45
|
+
export type SearchDescription = {
|
|
46
|
+
readonly keys: Readonly<Record<string, CodecDescription>>;
|
|
47
|
+
readonly kind: "codecs";
|
|
48
|
+
} | {
|
|
49
|
+
readonly kind: "none";
|
|
50
|
+
} | {
|
|
51
|
+
readonly kind: "raw";
|
|
52
|
+
};
|
|
53
|
+
/**
|
|
54
|
+
* Reflects a codec into plain data. This is the public face of the
|
|
55
|
+
* `~`-prefixed metadata: user code reads descriptions, never the props.
|
|
56
|
+
*/
|
|
57
|
+
export declare function describeCodec(codec: AnyCodec): CodecDescription;
|
|
58
|
+
/**
|
|
59
|
+
* Reflects a defined route: params in path order (from `~segments`, the
|
|
60
|
+
* define-time token cache), search per {@link SearchDescription}. Accepts
|
|
61
|
+
* both router brands — reflection only needs the data core.
|
|
62
|
+
*/
|
|
63
|
+
export declare function describeRoute(route: AnyRoute): RouteDescription;
|
package/dist/describe.js
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reflects a codec into plain data. This is the public face of the
|
|
3
|
+
* `~`-prefixed metadata: user code reads descriptions, never the props.
|
|
4
|
+
*/
|
|
5
|
+
export function describeCodec(codec) {
|
|
6
|
+
const defaultValue = describeDefault(codec);
|
|
7
|
+
const enumMembers = codec["~enumMembers"];
|
|
8
|
+
return {
|
|
9
|
+
arity: codec["~arity"],
|
|
10
|
+
caught: codec["~caught"],
|
|
11
|
+
...(defaultValue === undefined ? {} : { defaultValue }),
|
|
12
|
+
...(enumMembers === undefined ? {} : { enumMembers }),
|
|
13
|
+
kind: codec["~kind"],
|
|
14
|
+
presence: codec["~presence"],
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Reflects a defined route: params in path order (from `~segments`, the
|
|
19
|
+
* define-time token cache), search per {@link SearchDescription}. Accepts
|
|
20
|
+
* both router brands — reflection only needs the data core.
|
|
21
|
+
*/
|
|
22
|
+
export function describeRoute(route) {
|
|
23
|
+
const paramsConfig = route["~params"];
|
|
24
|
+
const params = {};
|
|
25
|
+
for (const segment of route["~segments"]) {
|
|
26
|
+
if (segment.kind === "static")
|
|
27
|
+
continue;
|
|
28
|
+
const codec = paramsConfig[segment.name];
|
|
29
|
+
// Unreachable for routes built by the define constructors (RL1 requires
|
|
30
|
+
// exactly the extracted names); guards hand-assembled objects.
|
|
31
|
+
if (codec === undefined)
|
|
32
|
+
continue;
|
|
33
|
+
params[segment.name] = {
|
|
34
|
+
...describeCodec(codec),
|
|
35
|
+
segmentKind: segment.kind,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
return {
|
|
39
|
+
params,
|
|
40
|
+
path: route.path,
|
|
41
|
+
router: route["~router"],
|
|
42
|
+
search: describeSearch(route["~search"]),
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Value-form defaults re-serialize the live value (the D8 ethos — never a
|
|
47
|
+
* stale snapshot); a throwing serialize here means the default was mutated
|
|
48
|
+
* into invalidity since define time, in which case the description degrades
|
|
49
|
+
* to the factory arm rather than throwing from a read-only reflection call.
|
|
50
|
+
*/
|
|
51
|
+
function describeDefault(codec) {
|
|
52
|
+
const thunk = codec["~defaultValue"];
|
|
53
|
+
if (thunk === undefined)
|
|
54
|
+
return undefined;
|
|
55
|
+
if (!codec["~defaultElides"])
|
|
56
|
+
return { kind: "factory" };
|
|
57
|
+
try {
|
|
58
|
+
return { kind: "value", wire: codec["~serializeElement"](thunk()) };
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
return { kind: "factory" };
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
function describeSearch(slot) {
|
|
65
|
+
// RawSearch's brand; codec maps never carry top-level `~` keys (SS2).
|
|
66
|
+
if ("~kind" in slot && slot["~kind"] === "raw-search")
|
|
67
|
+
return { kind: "raw" };
|
|
68
|
+
const entries = Object.entries(slot);
|
|
69
|
+
if (entries.length === 0)
|
|
70
|
+
return { kind: "none" };
|
|
71
|
+
const keys = {};
|
|
72
|
+
for (const [key, codec] of entries)
|
|
73
|
+
keys[key] = describeCodec(codec);
|
|
74
|
+
return { keys, kind: "codecs" };
|
|
75
|
+
}
|
package/dist/errors.d.ts
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/** One failed key in an aggregate decode error (shared by both surfaces, RL6). */
|
|
2
|
+
export interface Issue {
|
|
3
|
+
readonly key: string;
|
|
4
|
+
readonly message: string;
|
|
5
|
+
}
|
|
6
|
+
/** The single error type surfaced by a full route parse failure (RL6). */
|
|
7
|
+
export type RouteDecodeError = ParamsDecodeError | SearchDecodeError;
|
|
8
|
+
/** Base class for every error paramour throws. */
|
|
9
|
+
export declare class ParamourError extends Error {
|
|
10
|
+
constructor(message: string, options?: {
|
|
11
|
+
cause?: unknown;
|
|
12
|
+
});
|
|
13
|
+
static [Symbol.hasInstance](value: unknown): value is ParamourError;
|
|
14
|
+
}
|
|
15
|
+
/** Aggregate failure for a whole route-params decode (RL6). */
|
|
16
|
+
export declare class ParamsDecodeError extends ParamourError {
|
|
17
|
+
readonly issues: readonly Issue[];
|
|
18
|
+
constructor(issues: readonly Issue[]);
|
|
19
|
+
static [Symbol.hasInstance](value: unknown): value is ParamsDecodeError;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* A single wire value failed its codec grammar or schema validation.
|
|
23
|
+
* Thrown by element-level parsing; recoverable via `.catch()`.
|
|
24
|
+
*/
|
|
25
|
+
export declare class ParseError extends ParamourError {
|
|
26
|
+
static [Symbol.hasInstance](value: unknown): value is ParseError;
|
|
27
|
+
}
|
|
28
|
+
/** Aggregate failure for a whole search-params decode. */
|
|
29
|
+
export declare class SearchDecodeError extends ParamourError {
|
|
30
|
+
readonly issues: readonly Issue[];
|
|
31
|
+
constructor(issues: readonly Issue[]);
|
|
32
|
+
static [Symbol.hasInstance](value: unknown): value is SearchDecodeError;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* A search source violated its wire-shape contract (design-08 STD7): a
|
|
36
|
+
* non-object source, or a non-string / non-string[] value under a read key.
|
|
37
|
+
* Thrown by search.ts's source readers; distinct from {@link ParamourError}
|
|
38
|
+
* so the Standard Schema adapter can soften exactly these throws to issues
|
|
39
|
+
* while config-contract violations and rebranded validator throws stay loud.
|
|
40
|
+
*/
|
|
41
|
+
export declare class SearchSourceError extends ParamourError {
|
|
42
|
+
/** The offending source key, or null when the source itself is malformed. */
|
|
43
|
+
readonly key: null | string;
|
|
44
|
+
constructor(message: string, key: null | string);
|
|
45
|
+
static [Symbol.hasInstance](value: unknown): value is SearchSourceError;
|
|
46
|
+
}
|
|
47
|
+
/** A value could not be serialized to the wire (bad type, non-finite, etc.). */
|
|
48
|
+
export declare class SerializeError extends ParamourError {
|
|
49
|
+
static [Symbol.hasInstance](value: unknown): value is SerializeError;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Renders a value's type for "…, got X" error messages, distinguishing null
|
|
53
|
+
* from typeof's "object". Not exported from the package.
|
|
54
|
+
*/
|
|
55
|
+
export declare function describeType(value: unknown): string;
|
|
56
|
+
/**
|
|
57
|
+
* Best-effort human-readable message for a foreign (non-paramour) throw.
|
|
58
|
+
* Not exported from the package — internal to error branding.
|
|
59
|
+
*/
|
|
60
|
+
export declare function foreignMessage(error: unknown): string;
|
|
61
|
+
/**
|
|
62
|
+
* Runs user (or platform) code, letting paramour's own errors pass through
|
|
63
|
+
* and branding any foreign throw via `wrap` — the shared chokepoint for the
|
|
64
|
+
* "every throw is a ParamourError" contract. Not exported from the package.
|
|
65
|
+
*/
|
|
66
|
+
export declare function rebrandForeign<T>(run: () => T, wrap: (error: unknown) => ParamourError): T;
|
|
67
|
+
/**
|
|
68
|
+
* String() for error messages: objects without a usable primitive conversion
|
|
69
|
+
* (null-prototype objects, Symbol.toPrimitive throwers) make String() itself
|
|
70
|
+
* throw a raw TypeError, which would escape before the guard's branded error
|
|
71
|
+
* is even constructed. Not exported from the package.
|
|
72
|
+
*/
|
|
73
|
+
export declare function showValue(value: unknown): string;
|
package/dist/errors.js
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cross-copy identity brands (RL6). `Symbol.for()` keys resolve in the
|
|
3
|
+
* realm-global symbol registry, so a second physical copy of this module
|
|
4
|
+
* (dual-package hazard, bundler duplication) mints the SAME symbols:
|
|
5
|
+
* `instanceof` recognizes instances across copies, while a structurally
|
|
6
|
+
* identical foreign class lacks the brands entirely. Brands sit on the
|
|
7
|
+
* prototype (non-enumerable), so an instance carries every brand in its
|
|
8
|
+
* chain and subclass/base checks stay hierarchy-correct across copies.
|
|
9
|
+
*/
|
|
10
|
+
const paramourErrorBrand = Symbol.for("paramour.errors.ParamourError");
|
|
11
|
+
const paramsDecodeErrorBrand = Symbol.for("paramour.errors.ParamsDecodeError");
|
|
12
|
+
const parseErrorBrand = Symbol.for("paramour.errors.ParseError");
|
|
13
|
+
const searchDecodeErrorBrand = Symbol.for("paramour.errors.SearchDecodeError");
|
|
14
|
+
const searchSourceErrorBrand = Symbol.for("paramour.errors.SearchSourceError");
|
|
15
|
+
const serializeErrorBrand = Symbol.for("paramour.errors.SerializeError");
|
|
16
|
+
/** Base class for every error paramour throws. */
|
|
17
|
+
export class ParamourError extends Error {
|
|
18
|
+
static {
|
|
19
|
+
brandPrototype(this, paramourErrorBrand);
|
|
20
|
+
}
|
|
21
|
+
constructor(message, options) {
|
|
22
|
+
super(message, options);
|
|
23
|
+
this.name = new.target.name;
|
|
24
|
+
}
|
|
25
|
+
// Each class checks its OWN brand: an inherited base check would make
|
|
26
|
+
// every ParamourError pass `instanceof ParseError`. The type-predicate
|
|
27
|
+
// signature is load-bearing — TS narrows `instanceof` from it.
|
|
28
|
+
static [Symbol.hasInstance](value) {
|
|
29
|
+
return hasBrand(value, paramourErrorBrand);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
/** Aggregate failure for a whole route-params decode (RL6). */
|
|
33
|
+
export class ParamsDecodeError extends ParamourError {
|
|
34
|
+
static {
|
|
35
|
+
brandPrototype(this, paramsDecodeErrorBrand);
|
|
36
|
+
}
|
|
37
|
+
issues;
|
|
38
|
+
constructor(issues) {
|
|
39
|
+
super(`Failed to decode route params: ${formatIssues(issues)}`);
|
|
40
|
+
this.issues = issues;
|
|
41
|
+
}
|
|
42
|
+
static [Symbol.hasInstance](value) {
|
|
43
|
+
return hasBrand(value, paramsDecodeErrorBrand);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* A single wire value failed its codec grammar or schema validation.
|
|
48
|
+
* Thrown by element-level parsing; recoverable via `.catch()`.
|
|
49
|
+
*/
|
|
50
|
+
export class ParseError extends ParamourError {
|
|
51
|
+
static {
|
|
52
|
+
brandPrototype(this, parseErrorBrand);
|
|
53
|
+
}
|
|
54
|
+
static [Symbol.hasInstance](value) {
|
|
55
|
+
return hasBrand(value, parseErrorBrand);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
/** Aggregate failure for a whole search-params decode. */
|
|
59
|
+
export class SearchDecodeError extends ParamourError {
|
|
60
|
+
static {
|
|
61
|
+
brandPrototype(this, searchDecodeErrorBrand);
|
|
62
|
+
}
|
|
63
|
+
issues;
|
|
64
|
+
constructor(issues) {
|
|
65
|
+
super(`Failed to decode search params: ${formatIssues(issues)}`);
|
|
66
|
+
this.issues = issues;
|
|
67
|
+
}
|
|
68
|
+
static [Symbol.hasInstance](value) {
|
|
69
|
+
return hasBrand(value, searchDecodeErrorBrand);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* A search source violated its wire-shape contract (design-08 STD7): a
|
|
74
|
+
* non-object source, or a non-string / non-string[] value under a read key.
|
|
75
|
+
* Thrown by search.ts's source readers; distinct from {@link ParamourError}
|
|
76
|
+
* so the Standard Schema adapter can soften exactly these throws to issues
|
|
77
|
+
* while config-contract violations and rebranded validator throws stay loud.
|
|
78
|
+
*/
|
|
79
|
+
export class SearchSourceError extends ParamourError {
|
|
80
|
+
static {
|
|
81
|
+
brandPrototype(this, searchSourceErrorBrand);
|
|
82
|
+
}
|
|
83
|
+
/** The offending source key, or null when the source itself is malformed. */
|
|
84
|
+
key;
|
|
85
|
+
constructor(message, key) {
|
|
86
|
+
super(message);
|
|
87
|
+
this.key = key;
|
|
88
|
+
}
|
|
89
|
+
static [Symbol.hasInstance](value) {
|
|
90
|
+
return hasBrand(value, searchSourceErrorBrand);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
/** A value could not be serialized to the wire (bad type, non-finite, etc.). */
|
|
94
|
+
export class SerializeError extends ParamourError {
|
|
95
|
+
static {
|
|
96
|
+
brandPrototype(this, serializeErrorBrand);
|
|
97
|
+
}
|
|
98
|
+
static [Symbol.hasInstance](value) {
|
|
99
|
+
return hasBrand(value, serializeErrorBrand);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Renders a value's type for "…, got X" error messages, distinguishing null
|
|
104
|
+
* from typeof's "object". Not exported from the package.
|
|
105
|
+
*/
|
|
106
|
+
export function describeType(value) {
|
|
107
|
+
return value === null ? "null" : typeof value;
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Best-effort human-readable message for a foreign (non-paramour) throw.
|
|
111
|
+
* Not exported from the package — internal to error branding.
|
|
112
|
+
*/
|
|
113
|
+
export function foreignMessage(error) {
|
|
114
|
+
return error instanceof Error ? error.message : showValue(error);
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Runs user (or platform) code, letting paramour's own errors pass through
|
|
118
|
+
* and branding any foreign throw via `wrap` — the shared chokepoint for the
|
|
119
|
+
* "every throw is a ParamourError" contract. Not exported from the package.
|
|
120
|
+
*/
|
|
121
|
+
export function rebrandForeign(run, wrap) {
|
|
122
|
+
try {
|
|
123
|
+
return run();
|
|
124
|
+
}
|
|
125
|
+
catch (error) {
|
|
126
|
+
if (error instanceof ParamourError)
|
|
127
|
+
throw error;
|
|
128
|
+
throw wrap(error);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* String() for error messages: objects without a usable primitive conversion
|
|
133
|
+
* (null-prototype objects, Symbol.toPrimitive throwers) make String() itself
|
|
134
|
+
* throw a raw TypeError, which would escape before the guard's branded error
|
|
135
|
+
* is even constructed. Not exported from the package.
|
|
136
|
+
*/
|
|
137
|
+
export function showValue(value) {
|
|
138
|
+
try {
|
|
139
|
+
return String(value);
|
|
140
|
+
}
|
|
141
|
+
catch {
|
|
142
|
+
return `[unstringifiable ${typeof value}]`;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
function brandPrototype(ctor, brand) {
|
|
146
|
+
// defineProperty defaults: non-enumerable, non-writable, non-configurable —
|
|
147
|
+
// the brand never leaks into JSON/spread and can't be reassigned.
|
|
148
|
+
Object.defineProperty(ctor.prototype, brand, { value: true });
|
|
149
|
+
}
|
|
150
|
+
function formatIssues(issues) {
|
|
151
|
+
return issues.map((issue) => `[${issue.key}] ${issue.message}`).join("; ");
|
|
152
|
+
}
|
|
153
|
+
function hasBrand(value, brand) {
|
|
154
|
+
if (typeof value !== "object" || value === null)
|
|
155
|
+
return false;
|
|
156
|
+
return value[brand] === true;
|
|
157
|
+
}
|
package/dist/href.d.ts
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import type { AnyRoute, RegisteredStaticRoutePaths } from "./route.js";
|
|
2
|
+
import { type InferParamsInput } from "./path.js";
|
|
3
|
+
import { type SearchInputOf } from "./search.js";
|
|
4
|
+
/**
|
|
5
|
+
* Type-only brand carrier (RL4): no runtime value ever exists — the brand
|
|
6
|
+
* is applied by a compile-time cast, so Href costs nothing at runtime.
|
|
7
|
+
*/
|
|
8
|
+
declare const HREF: unique symbol;
|
|
9
|
+
/**
|
|
10
|
+
* A paramour-built link (RL4). Assignable TO `string`, so `next/link`,
|
|
11
|
+
* `router.push`, `redirect`, `generateMetadata` consume it unchanged
|
|
12
|
+
* (DESIGN principle 5); not assignable FROM `string`, which is the enabling
|
|
13
|
+
* substrate for the v1.x "accept only paramour-built links" narrowing APIs
|
|
14
|
+
* (RL10.6). Removing the brand later would be breaking; RL4 commits to it.
|
|
15
|
+
*/
|
|
16
|
+
export type Href<P extends string = string> = string & {
|
|
17
|
+
[HREF]: P;
|
|
18
|
+
};
|
|
19
|
+
/**
|
|
20
|
+
* href's variadic options tuple (RL4): the entire argument is omittable
|
|
21
|
+
* when neither half has a required member — `href(aboutRoute)`.
|
|
22
|
+
*/
|
|
23
|
+
export type HrefArgs<R extends AnyRoute> = Record<never, never> extends InferHrefInput<R> ? [options?: InferHrefInput<R>] : [options: InferHrefInput<R>];
|
|
24
|
+
/**
|
|
25
|
+
* href's options object (RL4): `{ params, search?, hash? }`. Property
|
|
26
|
+
* optionality is presence-driven on BOTH halves (maintainer ruling,
|
|
27
|
+
* 2026-07-04, amending RL4's letter): a half may be omitted when its input
|
|
28
|
+
* type has no required key — for `params` that means static routes and
|
|
29
|
+
* routes whose only dynamic segment is an optional catch-all; for `search`
|
|
30
|
+
* it is design-02 D4's rule surfacing at the property level. A half whose
|
|
31
|
+
* input has no keys AT ALL may not be passed even empty (see PartFor).
|
|
32
|
+
* `hash` implements S10 — fragments come only from an explicit caller
|
|
33
|
+
* option.
|
|
34
|
+
*/
|
|
35
|
+
export type InferHrefInput<R extends AnyRoute> = PartFor<"params", InferParamsInput<R>> & PartFor<"search", SearchInputOf<R["~search"]>> & {
|
|
36
|
+
hash?: string;
|
|
37
|
+
};
|
|
38
|
+
/**
|
|
39
|
+
* The string form's options (SH4): hash only. `params` is meaningless on a
|
|
40
|
+
* static path, and a query string comes only from a defined route's search
|
|
41
|
+
* codecs — a raw-search escape hatch here would be an untyped side door
|
|
42
|
+
* around library-owned serialization. Both are banned outright (`?: never`,
|
|
43
|
+
* the 2026-07-04 ruling's move) rather than merely omitted, so a non-fresh
|
|
44
|
+
* options object can't smuggle them past excess-property checking.
|
|
45
|
+
*/
|
|
46
|
+
export interface StaticHrefOptions {
|
|
47
|
+
hash?: string;
|
|
48
|
+
params?: never;
|
|
49
|
+
search?: never;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* One options property whose presence follows its input type: required iff
|
|
53
|
+
* the input has at least one required key (the design-02 D4
|
|
54
|
+
* `object extends` probe). An input with NO keys at all bans the property
|
|
55
|
+
* outright (`?: never`, maintainer ruling 2026-07-04 amending RL4) — the
|
|
56
|
+
* bare `Partial<Record<Key, Input>>` form would accept arbitrary junk
|
|
57
|
+
* there, because the empty object type is exempt from excess-property
|
|
58
|
+
* checking; `?: never` mirrors RouteConfig's static-path `params?: never`.
|
|
59
|
+
*/
|
|
60
|
+
type PartFor<Key extends string, Input> = keyof Input extends never ? Partial<Record<Key, never>> : Record<never, never> extends Input ? Partial<Record<Key, Input>> : Record<Key, Input>;
|
|
61
|
+
/**
|
|
62
|
+
* Builds a link for a route: fixed path–`?query`–`#hash` assembly (RL4). A
|
|
63
|
+
* standalone function, not a route method (DESIGN §4/§8): parse sites sit
|
|
64
|
+
* next to one route, href sites import `{ href }` once and use it against
|
|
65
|
+
* many routes. Serialization failures are `SerializeError` at link-build
|
|
66
|
+
* time (RL5's R-rules); config-contract violations from hand-built routes
|
|
67
|
+
* (a missing param codec or `~search` config) are base `ParamourError` — a
|
|
68
|
+
* JS caller omitting a required `search` half falls through to
|
|
69
|
+
* encodeSearch's own required-missing error.
|
|
70
|
+
*
|
|
71
|
+
* The string form (SH1): a registered STATIC path stands in for the route
|
|
72
|
+
* object — same brand, same hash assembly, no route definition needed. The
|
|
73
|
+
* string overload sits first so the route-object overload is last (SH8):
|
|
74
|
+
* TS's "the last overload gave the following error" heuristic keeps
|
|
75
|
+
* route-object misuse diagnostics prominent.
|
|
76
|
+
*/
|
|
77
|
+
export declare function href<P extends RegisteredStaticRoutePaths>(path: P, options?: StaticHrefOptions): Href<P>;
|
|
78
|
+
export declare function href<R extends AnyRoute>(route: R, ...args: HrefArgs<R>): Href<R["path"]>;
|
|
79
|
+
export {};
|
package/dist/href.js
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { ParamourError } from "./errors.js";
|
|
2
|
+
import { buildPath } from "./path.js";
|
|
3
|
+
import { searchToString, } from "./search.js";
|
|
4
|
+
// The conditional HrefArgs tuple is unresolvable inside a generic body, so
|
|
5
|
+
// the unsoundness lives at this one overload boundary (same move as
|
|
6
|
+
// routeData's config cast) instead of per-expression casts: the
|
|
7
|
+
// implementation sees each option half at its loosest honest type.
|
|
8
|
+
export function href(route, options) {
|
|
9
|
+
// S10: the fragment is appended VERBATIM — no encoding, the caller owns
|
|
10
|
+
// escaping (a value already starting with "#" yields "##…"). The empty
|
|
11
|
+
// string emits no "#".
|
|
12
|
+
const hash = options?.hash;
|
|
13
|
+
const fragment = hash === undefined || hash === "" ? "" : `#${hash}`;
|
|
14
|
+
if (typeof route === "string") {
|
|
15
|
+
// SH6: fail-fast backstop for JS callers and world-A typos of the
|
|
16
|
+
// dynamic-path variety — a bracket means "you need a route object", and
|
|
17
|
+
// query/hash never ride in the path string (query comes only from
|
|
18
|
+
// search codecs, hash only from the option).
|
|
19
|
+
if (!route.startsWith("/") || /[#?[\]]/.test(route)) {
|
|
20
|
+
throw new ParamourError(`href(path) requires a static route path, got ${JSON.stringify(route)}: dynamic segments need a route object, and query/hash never ride in the path string`);
|
|
21
|
+
}
|
|
22
|
+
// SH6: silently dropping a half a JS caller passed would build a wrong
|
|
23
|
+
// link — contract violations stay loud (never the safe-parse error arm).
|
|
24
|
+
if (options?.params !== undefined || options?.search !== undefined) {
|
|
25
|
+
throw new ParamourError(`href(path) takes no params/search — a static path has no params, and a query string needs a route with search codecs`);
|
|
26
|
+
}
|
|
27
|
+
return `${route}${fragment}`;
|
|
28
|
+
}
|
|
29
|
+
const path = buildPath(route, options?.params ?? {});
|
|
30
|
+
const query = searchToString(route["~search"], options?.search ?? {});
|
|
31
|
+
return `${path}${query}${fragment}`;
|
|
32
|
+
}
|