@paramour-js/devtools-panel 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,177 @@
1
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { buildSearchString } from "paramour";
3
+ import { useState } from "react";
4
+ import { buildCommittedPairs, draftLines } from "../edit.js";
5
+ import { formatShape, formatWire, jsLiteral } from "../format.js";
6
+ import { attributionFor, previewDecode } from "../inference.js";
7
+ import { CodecInput, isMultilineWidget } from "./codec-input.js";
8
+ import { AttributionTag, ValueCell } from "./primitives.js";
9
+ /**
10
+ * The search half of the inspector and the panel's editing surface: per-key
11
+ * widgets validating live through the codec, a per-key RAW WIRE toggle for
12
+ * reproducing invalid values, clear-to-absent, and a commit-to-push flow —
13
+ * Enter/blur assembles the FULL pair list (untouched and unknown keys
14
+ * carried verbatim), serializes through `buildSearchString` (spaces as %20
15
+ * — S-rule fidelity), and navigates via the EMITTING hook's `navigate`
16
+ * capability. The parent remounts this component when the observed wire
17
+ * changes (its React key), which is the drafts-invalidation rule: an
18
+ * external navigation resets the edit session.
19
+ */
20
+ export function SearchTable({ changeStamps, description, navigate, observation, searchConfig, }) {
21
+ const [drafts, setDrafts] = useState({});
22
+ const [invalidKeys, setInvalidKeys] = useState([]);
23
+ if (description.kind === "none") {
24
+ return (_jsxs(_Fragment, { children: [_jsx("div", { className: "pmr-section-title", children: "Search" }), _jsx("div", { className: "pmr-muted", children: "no search params declared" })] }));
25
+ }
26
+ const wire = observation?.kind === "search" ? observation.wire : [];
27
+ const parsed = observation?.result.status === "success"
28
+ ? observation.result.data
29
+ : undefined;
30
+ if (description.kind === "raw") {
31
+ // A rawSearch route renders its parsed value with the schema shown as
32
+ // opaque; per-key editing has no per-key codecs to validate through.
33
+ return (_jsxs(_Fragment, { children: [_jsx("div", { className: "pmr-section-title", children: "Search" }), _jsxs("table", { className: "pmr-table", children: [_jsx("thead", { children: _jsxs("tr", { children: [_jsx("th", { children: "wire" }), _jsx("th", { children: "parsed" }), _jsx("th", { children: "shape" })] }) }), _jsx("tbody", { children: _jsxs("tr", { children: [_jsx("td", { children: _jsx(ValueCell, { stamp: 0, children: wire.length === 0
34
+ ? "—"
35
+ : wire.map(([key, value]) => `${key}=${value}`).join(" & ") }) }), _jsx("td", { children: _jsx(ValueCell, { stamp: 0, children: parsed === undefined ? "—" : jsLiteral(parsed) }) }), _jsx("td", { className: "pmr-mono pmr-muted", children: "raw (opaque schema)" })] }) })] })] }));
36
+ }
37
+ const config = searchConfig ?? {};
38
+ const wireValuesFor = (key) => wire.filter((pair) => pair[0] === key).map((pair) => pair[1]);
39
+ // `overrides` carries a same-event draft (the checkbox's toggle-and-
40
+ // commit) that the `drafts` state cannot deliver yet — `setDrafts` lands
41
+ // next render, after this commit already read it.
42
+ const commit = (overrides) => {
43
+ const effective = { ...drafts, ...overrides };
44
+ if (navigate === undefined || Object.keys(effective).length === 0)
45
+ return;
46
+ let search;
47
+ try {
48
+ // Untouched pairs carry from the LIVE URL, not the observation's
49
+ // decode-time snapshot: undeclared-key churn (utm_* stripped or added
50
+ // by the app) never re-emits — the emit fingerprint covers declared
51
+ // keys only — so the snapshot can be stale in both directions, and
52
+ // committing it would resurrect removed pairs and drop added ones.
53
+ // `navigate` is only handed to CURRENT sessions, so the live URL is
54
+ // this session's page.
55
+ const result = buildCommittedPairs(config, liveWirePairs(), effective);
56
+ if (result.status === "invalid") {
57
+ setInvalidKeys(result.invalidKeys);
58
+ return;
59
+ }
60
+ search = buildSearchString(result.pairs);
61
+ }
62
+ catch {
63
+ // A serializer/byte-layer throw (SerializeError — custom codec, lone
64
+ // surrogate) surfaces after the preview validated the parse; it must
65
+ // not escape the event handler. Drafts stay editable.
66
+ setInvalidKeys(Object.keys(effective));
67
+ return;
68
+ }
69
+ setInvalidKeys([]);
70
+ // Search string ONLY: the hook-side navigate resolves it against
71
+ // its own basePath-/locale-relative pathname — `window.location.pathname`
72
+ // here would double a configured basePath through router.replace.
73
+ navigate(search);
74
+ setDrafts({});
75
+ };
76
+ const setDraft = (key, draft) => {
77
+ setDrafts((previous) => ({ ...previous, [key]: draft }));
78
+ setInvalidKeys((previous) => previous.filter((entry) => entry !== key));
79
+ };
80
+ return (_jsxs(_Fragment, { children: [_jsx("div", { className: "pmr-section-title", children: "Search" }), _jsxs("table", { className: "pmr-table", children: [_jsx("thead", { children: _jsxs("tr", { children: [_jsx("th", { children: "key" }), _jsx("th", { children: "wire" }), _jsx("th", { children: "parsed" }), _jsx("th", { children: "shape" }), _jsx("th", { "aria-label": "attribution" }), _jsx("th", { children: "edit" })] }) }), _jsx("tbody", { children: Object.entries(description.keys).map(([key, keyDescription]) => {
81
+ const codec = config[key];
82
+ const wireValues = wireValuesFor(key);
83
+ const draft = drafts[key];
84
+ const attribution = codec === undefined
85
+ ? undefined
86
+ : attributionFor(keyDescription, codec, wireValues);
87
+ return (_jsx(SearchRow, { attribution: attribution, changeStamp: changeStamps[key] ?? 0, codec: codec, dataKey: key, description: keyDescription, draft: draft, invalid: invalidKeys.includes(key), onCommit: (override) => {
88
+ commit(override === undefined ? undefined : { [key]: override });
89
+ }, onDraftChange: (next) => {
90
+ setDraft(key, next);
91
+ }, parsedValue: parsed?.[key], showParsed: parsed !== undefined, wireValues: wireValues }, key));
92
+ }) })] })] }));
93
+ }
94
+ /**
95
+ * The search pairs as they are on the wire RIGHT NOW. `URLSearchParams`
96
+ * decoding matches what the emitting hooks' sources see (`useSearchParams`
97
+ * IS a URLSearchParams view), so carried pairs round-trip through
98
+ * `buildSearchString` with the same fidelity as the observed snapshot's.
99
+ */
100
+ function liveWirePairs() {
101
+ const pairs = [];
102
+ for (const [key, value] of new URLSearchParams(window.location.search)) {
103
+ pairs.push([key, value]);
104
+ }
105
+ return pairs;
106
+ }
107
+ function previewLine(codec, description, key, draft, invalid) {
108
+ // Same arity rule and line-splitting as the commit path (draftLines), so
109
+ // what the preview shows is what a commit would do.
110
+ const input = draft.value.kind === "absent"
111
+ ? undefined
112
+ : description.arity === "many"
113
+ ? draftLines(draft.value.text)
114
+ : draft.value.text;
115
+ const preview = previewDecode(codec, key, input);
116
+ if (preview.status === "error") {
117
+ return (_jsxs("div", { className: "pmr-preview pmr-preview--error", children: [invalid ? "✕ " : "", preview.issues[0]?.message ?? "invalid"] }));
118
+ }
119
+ return (_jsxs("div", { className: "pmr-preview pmr-preview--ok", children: ["\u2192 ", jsLiteral(preview.value)] }));
120
+ }
121
+ function SearchRow({ attribution, changeStamp, codec, dataKey, description, draft, invalid, onCommit, onDraftChange, parsedValue, showParsed, wireValues, }) {
122
+ const [raw, setRaw] = useState(false);
123
+ // Only the multi-line textarea can represent repeated wire values; a
124
+ // single-line widget seeded with a newline join would value-sanitize the
125
+ // `\n` away ('1\n2' → '12'), and the first keystroke turns that
126
+ // fabricated value into a committed draft replacing every original pair.
127
+ // Broken wire (repeated key on an arity-one codec) seeds from the FIRST
128
+ // pair; the wire column still shows all of them.
129
+ const seedText = isMultilineWidget(description, raw)
130
+ ? wireValues.join("\n")
131
+ : (wireValues[0] ?? "");
132
+ const draftText = draft === undefined || draft.value.kind === "absent"
133
+ ? seedText
134
+ : draft.value.text;
135
+ const absent = draft?.value.kind === "absent";
136
+ return (_jsxs("tr", { children: [_jsx("td", { className: "pmr-mono", children: dataKey }), _jsx("td", { children: _jsx(ValueCell, { stamp: 0, children: formatWire(wireValues.length === 0 ? undefined : wireValues) }) }), _jsx("td", { children: _jsx(ValueCell, { stamp: changeStamp, children: showParsed ? jsLiteral(parsedValue) : "—" }) }), _jsx("td", { className: "pmr-mono pmr-muted", children: formatShape(description) }), _jsx("td", { children: attribution === undefined ? null : (_jsx(AttributionTag, { kind: attribution })) }), _jsx("td", { children: codec === undefined ? null : (_jsxs(_Fragment, { children: [_jsx(CodecInput, { codec: codec, dataKey: dataKey, description: description, draftText: absent ? "" : draftText, hasDraft: draft?.value.kind === "text", onCommit: (immediateText) => {
137
+ onCommit(immediateText === undefined
138
+ ? undefined
139
+ : {
140
+ mode: raw ? "raw" : "codec",
141
+ value: { kind: "text", text: immediateText },
142
+ });
143
+ }, onDraftChange: (text) => {
144
+ onDraftChange({
145
+ mode: raw ? "raw" : "codec",
146
+ value: { kind: "text", text },
147
+ });
148
+ }, parsedValue: parsedValue, raw: raw }), _jsx("button", { "aria-label": `toggle raw wire editing for ${dataKey}`, className: "pmr-icon-button", "data-active": raw, onClick: () => {
149
+ setRaw((previous) => {
150
+ const next = !previous;
151
+ if (draft?.value.kind === "text") {
152
+ onDraftChange({
153
+ mode: next ? "raw" : "codec",
154
+ value: draft.value,
155
+ });
156
+ }
157
+ return next;
158
+ });
159
+ },
160
+ // Mousedown on the button would BLUR a focused input first,
161
+ // and blur commits — navigating with the pending draft before
162
+ // this button's click ever runs. preventDefault keeps focus.
163
+ onMouseDown: (event) => {
164
+ event.preventDefault();
165
+ }, type: "button", children: "raw" }), _jsx("button", { "aria-label": `clear ${dataKey} to absent`, className: "pmr-icon-button", onClick: () => {
166
+ onDraftChange({
167
+ mode: raw ? "raw" : "codec",
168
+ value: { kind: "absent" },
169
+ });
170
+ },
171
+ // Same blur-commit race as the raw toggle above.
172
+ onMouseDown: (event) => {
173
+ event.preventDefault();
174
+ }, type: "button", children: "\u2300" }), draft === undefined
175
+ ? null
176
+ : previewLine(codec, description, dataKey, draft, invalid)] })) })] }));
177
+ }
@@ -0,0 +1,14 @@
1
+ import type { ReactNode } from "react";
2
+ import type { RouteSession } from "../store.js";
3
+ /**
4
+ * The session rail: every route observed this session in first-observed
5
+ * order — route pattern, router micro-badge, last-status dot
6
+ * (gray/stale when not matching the current URL). Selecting an entry pins
7
+ * it; selecting the pinned entry again returns to auto-follow-current.
8
+ */
9
+ export declare function Sidebar({ currentKeys, onSelect, selectedKey, sessions, }: {
10
+ readonly currentKeys: readonly string[];
11
+ readonly onSelect: (key: null | string) => void;
12
+ readonly selectedKey: null | string;
13
+ readonly sessions: readonly RouteSession[];
14
+ }): ReactNode;
@@ -0,0 +1,19 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { RouterBadge, StatusDot } from "./primitives.js";
3
+ /**
4
+ * The session rail: every route observed this session in first-observed
5
+ * order — route pattern, router micro-badge, last-status dot
6
+ * (gray/stale when not matching the current URL). Selecting an entry pins
7
+ * it; selecting the pinned entry again returns to auto-follow-current.
8
+ */
9
+ export function Sidebar({ currentKeys, onSelect, selectedKey, sessions, }) {
10
+ return (_jsx("nav", { "aria-label": "observed routes", className: "pmr-sidebar", children: sessions.map((session) => {
11
+ const current = currentKeys.includes(session.key);
12
+ const selected = selectedKey === null ? current : selectedKey === session.key;
13
+ return (_jsxs("button", { className: "pmr-sidebar-entry", "data-selected": selected, "data-stale": !current, onClick: () => {
14
+ onSelect(selectedKey === session.key ? null : session.key);
15
+ }, type: "button", children: [_jsx(StatusDot, { status: current ? session.status : "stale" }), _jsx("span", { className: "pmr-sidebar-path", children: session.route.path }), _jsx(RouterBadge, { router: session.params?.routerKind ??
16
+ session.search?.routerKind ??
17
+ "app" })] }, session.key));
18
+ }) }));
19
+ }
package/dist/edit.d.ts ADDED
@@ -0,0 +1,50 @@
1
+ import type { AnyCodec } from "paramour";
2
+ import type { ParamourSearchWire } from "./seam.js";
3
+ /**
4
+ * Pure commit-flow assembly: merge the user's per-key drafts into the
5
+ * observed wire pairs, preserving everything untouched.
6
+ */
7
+ export type CommitResult = {
8
+ readonly invalidKeys: readonly string[];
9
+ readonly status: "invalid";
10
+ } | {
11
+ readonly pairs: ParamourSearchWire;
12
+ readonly status: "ok";
13
+ };
14
+ /** One key's edit state. `text` is the WIRE string — the single draft currency. */
15
+ export interface Draft {
16
+ readonly mode: "codec" | "raw";
17
+ readonly value: DraftValue;
18
+ }
19
+ export type DraftValue = {
20
+ readonly kind: "absent";
21
+ } | {
22
+ readonly kind: "text";
23
+ readonly text: string;
24
+ };
25
+ /**
26
+ * The commit semantics:
27
+ * - Untouched keys (declared, unknown, even invalid/caught wire) carry
28
+ * VERBATIM in original wire order — never re-serialized.
29
+ * - A codec-mode draft validates through the single-key decode; any failure
30
+ * blocks the whole commit. On success its pairs come from the single-key
31
+ * `encodeSearch`, so D8 default-elision and optional absence fall out
32
+ * (`[]` → key omitted).
33
+ * - A raw-mode draft contributes its text verbatim (one pair per line for a
34
+ * multi-line draft) and is NEVER blocked — reproducing invalid wire is
35
+ * the point. Byte-layer percent-encoding still happens downstream in
36
+ * `buildSearchString`; raw mode bypasses codec serialization only.
37
+ * - An `absent` draft omits the key.
38
+ * - A drafted key not on the wire appends after the carried pairs, in
39
+ * drafts order.
40
+ */
41
+ export declare function buildCommittedPairs(config: Readonly<Record<string, AnyCodec>>, wire: ParamourSearchWire, drafts: Readonly<Record<string, Draft>>): CommitResult;
42
+ /**
43
+ * Draft text → wire values, shared by the commit path and the live preview
44
+ * so they can never disagree: one value per non-empty line, and a FULLY
45
+ * CLEARED textarea is zero values (`[]` → key omitted through
46
+ * `encodeSearch`), not one empty-string element. Raw mode shares the
47
+ * line-splitting but maps the empty draft to one `key=` pair at its call
48
+ * site — a raw empty draft is a legal wire value.
49
+ */
50
+ export declare function draftLines(text: string): string[];
package/dist/edit.js ADDED
@@ -0,0 +1,81 @@
1
+ import { describeCodec, encodeSearch } from "paramour";
2
+ import { previewDecode } from "./inference.js";
3
+ /**
4
+ * The commit semantics:
5
+ * - Untouched keys (declared, unknown, even invalid/caught wire) carry
6
+ * VERBATIM in original wire order — never re-serialized.
7
+ * - A codec-mode draft validates through the single-key decode; any failure
8
+ * blocks the whole commit. On success its pairs come from the single-key
9
+ * `encodeSearch`, so D8 default-elision and optional absence fall out
10
+ * (`[]` → key omitted).
11
+ * - A raw-mode draft contributes its text verbatim (one pair per line for a
12
+ * multi-line draft) and is NEVER blocked — reproducing invalid wire is
13
+ * the point. Byte-layer percent-encoding still happens downstream in
14
+ * `buildSearchString`; raw mode bypasses codec serialization only.
15
+ * - An `absent` draft omits the key.
16
+ * - A drafted key not on the wire appends after the carried pairs, in
17
+ * drafts order.
18
+ */
19
+ export function buildCommittedPairs(config, wire, drafts) {
20
+ const invalidKeys = [];
21
+ const replacements = new Map();
22
+ for (const [key, draft] of Object.entries(drafts)) {
23
+ replacements.set(key, pairsForDraft(config, key, draft, invalidKeys));
24
+ }
25
+ if (invalidKeys.length > 0)
26
+ return { invalidKeys, status: "invalid" };
27
+ const pairs = [];
28
+ const replaced = new Set();
29
+ for (const [key, value] of wire) {
30
+ const replacement = replacements.get(key);
31
+ if (replacement === undefined) {
32
+ pairs.push([key, value]);
33
+ continue;
34
+ }
35
+ if (replaced.has(key))
36
+ continue;
37
+ replaced.add(key);
38
+ for (const pair of replacement)
39
+ pairs.push([pair[0], pair[1]]);
40
+ }
41
+ for (const [key, replacement] of replacements) {
42
+ if (replaced.has(key))
43
+ continue;
44
+ for (const pair of replacement)
45
+ pairs.push([pair[0], pair[1]]);
46
+ }
47
+ return { pairs, status: "ok" };
48
+ }
49
+ /**
50
+ * Draft text → wire values, shared by the commit path and the live preview
51
+ * so they can never disagree: one value per non-empty line, and a FULLY
52
+ * CLEARED textarea is zero values (`[]` → key omitted through
53
+ * `encodeSearch`), not one empty-string element. Raw mode shares the
54
+ * line-splitting but maps the empty draft to one `key=` pair at its call
55
+ * site — a raw empty draft is a legal wire value.
56
+ */
57
+ export function draftLines(text) {
58
+ if (text === "")
59
+ return [];
60
+ return text.split("\n").filter((line) => line !== "");
61
+ }
62
+ function pairsForDraft(config, key, draft, invalidKeys) {
63
+ if (draft.value.kind === "absent")
64
+ return [];
65
+ const { text } = draft.value;
66
+ const codec = config[key];
67
+ if (draft.mode === "raw" || codec === undefined) {
68
+ // Multi-line raw drafts (arity-"many" textareas) contribute one pair
69
+ // per line; the empty draft — a legal wire value in raw mode —
70
+ // contributes exactly one `key=` pair rather than draftLines's absence.
71
+ const lines = text === "" ? [""] : draftLines(text);
72
+ return lines.map((line) => [key, line]);
73
+ }
74
+ const preview = previewDecode(codec, key, describeCodec(codec).arity === "many" ? draftLines(text) : text);
75
+ if (preview.status === "error") {
76
+ invalidKeys.push(key);
77
+ return [];
78
+ }
79
+ const singleKeyConfig = { [key]: codec };
80
+ return encodeSearch(singleKeyConfig, { [key]: preview.value });
81
+ }
@@ -0,0 +1,36 @@
1
+ import type { CodecDescription, RouterKind } from "paramour";
2
+ /**
3
+ * Pure presentation helpers: codec shapes, wire and parsed value rendering,
4
+ * and the `href()` reproduction snippet.
5
+ */
6
+ /**
7
+ * One-line shape label from a `CodecDescription`, e.g.
8
+ * `enum(asc|desc)? =asc catch` — core's shared walk in its compact skin,
9
+ * so the panel and `paramour list` can never drift on the field set.
10
+ */
11
+ export declare function formatShape(description: CodecDescription): string;
12
+ /**
13
+ * Wire column rendering: absence is `—`; present values are
14
+ * JSON-quoted so whitespace and emptiness are visible, repeated keys
15
+ * comma-joined in wire order.
16
+ */
17
+ export declare function formatWire(values: readonly string[] | string | undefined): string;
18
+ /**
19
+ * A JS source literal for a decoded value: `Date` prints as
20
+ * `new Date("<iso>")` so the snippet round-trips through `href`, arrays and
21
+ * plain objects recurse, identifier-safe keys go unquoted.
22
+ */
23
+ export declare function jsLiteral(value: unknown): string;
24
+ /**
25
+ * The `href(route, { params, search })` reproduction snippet: the user's
26
+ * route identifier is unknowable, so a path-derived placeholder name
27
+ * carries the true pattern in a trailing comment. Empty halves are omitted.
28
+ * The snippet doubles as documentation of the API the user should be
29
+ * writing.
30
+ */
31
+ export declare function reproSnippet(path: string, router: RouterKind, params: Readonly<Record<string, unknown>> | undefined, search: Readonly<Record<string, unknown>> | undefined): string;
32
+ /**
33
+ * `/shop/[slug]` → `shopSlugRoute`; `/` → `route`. Brackets and catch-all
34
+ * dots strip to the bare name; segments camelCase-join.
35
+ */
36
+ export declare function routeVariableName(path: string): string;
package/dist/format.js ADDED
@@ -0,0 +1,109 @@
1
+ import { formatCodecDescription } from "paramour";
2
+ /**
3
+ * Pure presentation helpers: codec shapes, wire and parsed value rendering,
4
+ * and the `href()` reproduction snippet.
5
+ */
6
+ /**
7
+ * One-line shape label from a `CodecDescription`, e.g.
8
+ * `enum(asc|desc)? =asc catch` — core's shared walk in its compact skin,
9
+ * so the panel and `paramour list` can never drift on the field set.
10
+ */
11
+ export function formatShape(description) {
12
+ return formatCodecDescription(description, "compact");
13
+ }
14
+ /**
15
+ * Wire column rendering: absence is `—`; present values are
16
+ * JSON-quoted so whitespace and emptiness are visible, repeated keys
17
+ * comma-joined in wire order.
18
+ */
19
+ export function formatWire(values) {
20
+ if (values === undefined)
21
+ return "—";
22
+ if (typeof values === "string")
23
+ return JSON.stringify(values);
24
+ if (values.length === 0)
25
+ return "—";
26
+ return values.map((value) => JSON.stringify(value)).join(", ");
27
+ }
28
+ /**
29
+ * A JS source literal for a decoded value: `Date` prints as
30
+ * `new Date("<iso>")` so the snippet round-trips through `href`, arrays and
31
+ * plain objects recurse, identifier-safe keys go unquoted.
32
+ */
33
+ export function jsLiteral(value) {
34
+ if (value === undefined)
35
+ return "undefined";
36
+ if (value === null)
37
+ return "null";
38
+ if (value instanceof Date) {
39
+ return `new Date(${JSON.stringify(value.toISOString())})`;
40
+ }
41
+ if (Array.isArray(value)) {
42
+ return `[${value.map((element) => jsLiteral(element)).join(", ")}]`;
43
+ }
44
+ switch (typeof value) {
45
+ case "bigint":
46
+ return `${String(value)}n`;
47
+ case "boolean":
48
+ case "number":
49
+ return String(value);
50
+ case "object": {
51
+ const entries = Object.entries(value);
52
+ if (entries.length === 0)
53
+ return "{}";
54
+ const body = entries
55
+ .map(([key, entry]) => `${literalKey(key)}: ${jsLiteral(entry)}`)
56
+ .join(", ");
57
+ return `{ ${body} }`;
58
+ }
59
+ case "string":
60
+ return JSON.stringify(value);
61
+ default:
62
+ // function / symbol: not representable as a value literal.
63
+ return `[${typeof value}]`;
64
+ }
65
+ }
66
+ /**
67
+ * The `href(route, { params, search })` reproduction snippet: the user's
68
+ * route identifier is unknowable, so a path-derived placeholder name
69
+ * carries the true pattern in a trailing comment. Empty halves are omitted.
70
+ * The snippet doubles as documentation of the API the user should be
71
+ * writing.
72
+ */
73
+ export function reproSnippet(path, router, params, search) {
74
+ const name = `${routeVariableName(path)} /* ${path} (${router} router) */`;
75
+ const lines = [];
76
+ if (params !== undefined && Object.keys(params).length > 0) {
77
+ lines.push(` params: ${jsLiteral(params)},`);
78
+ }
79
+ if (search !== undefined && Object.keys(search).length > 0) {
80
+ lines.push(` search: ${jsLiteral(search)},`);
81
+ }
82
+ if (lines.length === 0)
83
+ return `href(${name})`;
84
+ return `href(${name}, {\n${lines.join("\n")}\n})`;
85
+ }
86
+ /**
87
+ * `/shop/[slug]` → `shopSlugRoute`; `/` → `route`. Brackets and catch-all
88
+ * dots strip to the bare name; segments camelCase-join.
89
+ */
90
+ export function routeVariableName(path) {
91
+ const words = path
92
+ .split("/")
93
+ .map((segment) => segment.replaceAll(/[[\].]/g, ""))
94
+ .filter((segment) => segment !== "")
95
+ .flatMap((segment) => segment.split(/[^a-zA-Z0-9]+/))
96
+ .filter((word) => word !== "");
97
+ if (words.length === 0)
98
+ return "route";
99
+ const camel = words
100
+ .map((word, index) => index === 0
101
+ ? word.charAt(0).toLowerCase() + word.slice(1)
102
+ : word.charAt(0).toUpperCase() + word.slice(1))
103
+ .join("");
104
+ return `${camel}Route`;
105
+ }
106
+ const IDENTIFIER = /^[$A-Z_a-z][\w$]*$/;
107
+ function literalKey(key) {
108
+ return IDENTIFIER.test(key) ? key : JSON.stringify(key);
109
+ }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * `@paramour-js/devtools-panel` — a TanStack Devtools panel for paramour routes.
3
+ * The public surface is deliberately tiny: the panel component and the
4
+ * plugin-entry helper. The observation seam's types stay internal — their
5
+ * contract of record is `@paramour-js/next/devtools-seam`.
6
+ */
7
+ export { ParamourDevtoolsPanel, type ParamourDevtoolsPanelProps, } from "./components/panel.js";
8
+ export { paramourDevtoolsPlugin, type ParamourDevtoolsPluginEntry, type ParamourDevtoolsPluginOptions, } from "./plugin.js";
package/dist/index.js ADDED
@@ -0,0 +1,8 @@
1
+ /**
2
+ * `@paramour-js/devtools-panel` — a TanStack Devtools panel for paramour routes.
3
+ * The public surface is deliberately tiny: the panel component and the
4
+ * plugin-entry helper. The observation seam's types stay internal — their
5
+ * contract of record is `@paramour-js/next/devtools-seam`.
6
+ */
7
+ export { ParamourDevtoolsPanel, } from "./components/panel.js";
8
+ export { paramourDevtoolsPlugin, } from "./plugin.js";
@@ -0,0 +1,41 @@
1
+ import type { AnyCodec, CodecDescription, Issue } from "paramour";
2
+ /**
3
+ * Pure decode/attribution logic. Everything here goes through core's
4
+ * published surface — `parseValue`, `foreignMessage`, and `codecShapeLabel`
5
+ * via the `paramour/internal` tooling entry (the catch-attribution probe
6
+ * and issue-label helpers core exports for exactly this) and the single-key
7
+ * `decodeSearch` trick — a synthesized one-key config gives full
8
+ * presence/default/catch/duplicate semantics for one value without touching
9
+ * `~`-internals.
10
+ */
11
+ /** Why a rendered value differs from the wire. */
12
+ export type Attribution = "catch" | "default" | undefined;
13
+ export type PreviewResult = {
14
+ readonly issues: readonly Issue[];
15
+ readonly status: "error";
16
+ } | {
17
+ readonly status: "ok";
18
+ readonly value: unknown;
19
+ };
20
+ /**
21
+ * The attribution rule: default = wire absent + presence declared
22
+ * "defaulted"; catch = wire present + `.catch()` declared + the parse would
23
+ * have failed without it. The duplicate-scalar case (two values for a
24
+ * single-value param) is a parse failure decodeSearch raises itself, so it
25
+ * can't be probed through `parseValue` — special-cased here.
26
+ */
27
+ export declare function attributionFor(description: CodecDescription, codec: AnyCodec, wireValues: readonly string[]): Attribution;
28
+ /**
29
+ * The raw parse outcome WITHOUT `.catch()` recovery — core's `parseValue`
30
+ * exists for this probe. Foreign throws from a custom codec count as
31
+ * failures too: whatever the class, the wire value did not parse cleanly.
32
+ */
33
+ export declare function parseWouldFail(codec: AnyCodec, raw: string): boolean;
34
+ /**
35
+ * What WOULD this wire draft decode to — the live edit validation.
36
+ * `draft === undefined` previews absence — surfacing the default value or
37
+ * `undefined`, teaching presence semantics. Full fidelity via the
38
+ * single-key `decodeSearch`: defaults, catches, required-missing, and the
39
+ * duplicate-scalar rejection all behave exactly as the real decode would.
40
+ */
41
+ export declare function previewDecode(codec: AnyCodec, key: string, draft: readonly string[] | string | undefined): PreviewResult;
@@ -0,0 +1,78 @@
1
+ import { decodeSearch, SearchDecodeError } from "paramour";
2
+ import { codecShapeLabel, foreignMessage, parseValue } from "paramour/internal";
3
+ /**
4
+ * The attribution rule: default = wire absent + presence declared
5
+ * "defaulted"; catch = wire present + `.catch()` declared + the parse would
6
+ * have failed without it. The duplicate-scalar case (two values for a
7
+ * single-value param) is a parse failure decodeSearch raises itself, so it
8
+ * can't be probed through `parseValue` — special-cased here.
9
+ */
10
+ export function attributionFor(description, codec, wireValues) {
11
+ if (wireValues.length === 0) {
12
+ return description.presence === "defaulted" ? "default" : undefined;
13
+ }
14
+ if (!description.caught)
15
+ return undefined;
16
+ if (description.arity === "single" && wireValues.length > 1)
17
+ return "catch";
18
+ return wireValues.some((raw) => parseWouldFail(codec, raw))
19
+ ? "catch"
20
+ : undefined;
21
+ }
22
+ /**
23
+ * The raw parse outcome WITHOUT `.catch()` recovery — core's `parseValue`
24
+ * exists for this probe. Foreign throws from a custom codec count as
25
+ * failures too: whatever the class, the wire value did not parse cleanly.
26
+ */
27
+ export function parseWouldFail(codec, raw) {
28
+ try {
29
+ parseValue(codec, raw);
30
+ return false;
31
+ }
32
+ catch {
33
+ return true;
34
+ }
35
+ }
36
+ /**
37
+ * What WOULD this wire draft decode to — the live edit validation.
38
+ * `draft === undefined` previews absence — surfacing the default value or
39
+ * `undefined`, teaching presence semantics. Full fidelity via the
40
+ * single-key `decodeSearch`: defaults, catches, required-missing, and the
41
+ * duplicate-scalar rejection all behave exactly as the real decode would.
42
+ */
43
+ export function previewDecode(codec, key, draft) {
44
+ const config = { [key]: codec };
45
+ const source = {};
46
+ if (draft !== undefined) {
47
+ source[key] = typeof draft === "string" ? draft : [...draft];
48
+ }
49
+ try {
50
+ const decoded = decodeSearch(config, source);
51
+ return { status: "ok", value: decoded[key] };
52
+ }
53
+ catch (error) {
54
+ if (error instanceof SearchDecodeError) {
55
+ return { issues: error.issues, status: "error" };
56
+ }
57
+ // Foreign throws arrive UNWRAPPED (decodeSearch's taxonomy) from user
58
+ // schema/custom-codec code, including values String() itself cannot
59
+ // stringify — core's foreignMessage carries the hardening. The
60
+ // synthesized issue is enriched like a real one: the codec is in hand
61
+ // for `expected` — labeled by core's own codecShapeLabel, no forceMany,
62
+ // because this is a whole-codec search-position decode (the codec's own
63
+ // arity carries any `[]`), matching decodeSearch's issue labels — and a
64
+ // scalar draft IS the offending wire value (a string[] draft has no
65
+ // single one — same absence rule as core).
66
+ return {
67
+ issues: [
68
+ {
69
+ expected: codecShapeLabel(codec),
70
+ key,
71
+ message: foreignMessage(error),
72
+ ...(typeof draft === "string" ? { wire: draft } : {}),
73
+ },
74
+ ],
75
+ status: "error",
76
+ };
77
+ }
78
+ }
@@ -0,0 +1,16 @@
1
+ import type { AnyRoute } from "paramour";
2
+ /**
3
+ * Structural view of a route's define-time segment tokens (`~segments`) —
4
+ * derived from the route type rather than importing core's non-barrel
5
+ * `PathSegment`, so this stays pinned to what a live route actually carries.
6
+ */
7
+ export type RouteSegments = AnyRoute["~segments"];
8
+ /**
9
+ * Does `pathname` (from `window.location`) match this route's pattern?
10
+ * Powers the "current URL" grouping: static segments compare against the
11
+ * percent-DECODED path part; `single` consumes exactly one part; `catchall`
12
+ * one-plus; `optional-catchall` zero-plus (both are terminal by Next's
13
+ * grammar, mirrored by core's tokenizer). Trailing slashes are
14
+ * normalization noise (`/shop/` matches `/shop`).
15
+ */
16
+ export declare function matchesPathname(segments: RouteSegments, pathname: string): boolean;