@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.
- package/LICENSE +21 -0
- package/README.md +37 -0
- package/dist/components/codec-input.d.ts +39 -0
- package/dist/components/codec-input.js +119 -0
- package/dist/components/copy-toolbar.d.ts +11 -0
- package/dist/components/copy-toolbar.js +10 -0
- package/dist/components/issues-section.d.ts +15 -0
- package/dist/components/issues-section.js +16 -0
- package/dist/components/panel.d.ts +16 -0
- package/dist/components/panel.js +21 -0
- package/dist/components/params-table.d.ts +13 -0
- package/dist/components/params-table.js +33 -0
- package/dist/components/primitives.d.ts +38 -0
- package/dist/components/primitives.js +59 -0
- package/dist/components/route-view.d.ts +18 -0
- package/dist/components/route-view.js +94 -0
- package/dist/components/search-table.d.ts +21 -0
- package/dist/components/search-table.js +177 -0
- package/dist/components/sidebar.d.ts +14 -0
- package/dist/components/sidebar.js +19 -0
- package/dist/edit.d.ts +50 -0
- package/dist/edit.js +81 -0
- package/dist/format.d.ts +36 -0
- package/dist/format.js +109 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +8 -0
- package/dist/inference.d.ts +41 -0
- package/dist/inference.js +78 -0
- package/dist/match.d.ts +16 -0
- package/dist/match.js +49 -0
- package/dist/plugin.d.ts +20 -0
- package/dist/plugin.js +12 -0
- package/dist/seam.d.ts +16 -0
- package/dist/seam.js +16 -0
- package/dist/store.d.ts +70 -0
- package/dist/store.js +305 -0
- package/dist/styles.d.ts +16 -0
- package/dist/styles.js +278 -0
- package/package.json +72 -0
|
@@ -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
|
+
}
|
package/dist/format.d.ts
ADDED
|
@@ -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
|
+
}
|
package/dist/index.d.ts
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, 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
|
+
}
|
package/dist/match.d.ts
ADDED
|
@@ -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;
|