@cfasim-ui/docs 0.4.12 → 0.4.13
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/index.json +1 -1
- package/package.json +1 -1
- package/shared/index.ts +6 -1
- package/shared/useUrlParams.ts +126 -11
package/index.json
CHANGED
package/package.json
CHANGED
package/shared/index.ts
CHANGED
|
@@ -22,5 +22,10 @@ export {
|
|
|
22
22
|
deserialize,
|
|
23
23
|
paramsToQuery,
|
|
24
24
|
queryToParams,
|
|
25
|
+
jsonCodec,
|
|
26
|
+
} from "./useUrlParams.js";
|
|
27
|
+
export type {
|
|
28
|
+
UrlParamsOptions,
|
|
29
|
+
ParamCodec,
|
|
30
|
+
ParamCodecs,
|
|
25
31
|
} from "./useUrlParams.js";
|
|
26
|
-
export type { UrlParamsOptions } from "./useUrlParams.js";
|
package/shared/useUrlParams.ts
CHANGED
|
@@ -35,6 +35,43 @@ export type DefaultsInput<T> = T | Ref<T> | (() => T | undefined);
|
|
|
35
35
|
*/
|
|
36
36
|
export type ParamPath<T> = keyof T | (string & {});
|
|
37
37
|
|
|
38
|
+
/**
|
|
39
|
+
* Custom serializer/deserializer pair for a single path. Lets the consumer
|
|
40
|
+
* round-trip a value through one URL key as an opaque string instead of
|
|
41
|
+
* letting `useUrlParams` flatten through it. Use for tagged unions,
|
|
42
|
+
* polymorphic objects, or anything else whose shape can change at runtime —
|
|
43
|
+
* the default leaf-flattening can only enumerate fields present in
|
|
44
|
+
* `defaults`, so variants that aren't the default variant lose their
|
|
45
|
+
* fields.
|
|
46
|
+
*/
|
|
47
|
+
export type ParamCodec = {
|
|
48
|
+
serialize: (value: unknown) => string;
|
|
49
|
+
deserialize: (raw: string) => unknown;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Map of dotted path → codec. A path with a codec is treated as a single
|
|
54
|
+
* atomic leaf: its whole subtree round-trips through one URL key, and any
|
|
55
|
+
* descendant URL keys are ignored. Top-level keys (`"infectionRate"`) and
|
|
56
|
+
* nested paths (`"model.infectionRate"`) both work; segments in the path
|
|
57
|
+
* use `.` as the separator, matching the same convention as
|
|
58
|
+
* `include`/`ignore`.
|
|
59
|
+
*/
|
|
60
|
+
export type ParamCodecs = Record<string, ParamCodec>;
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Convenience codec that uses `JSON.stringify` / `JSON.parse`. Suitable
|
|
64
|
+
* for tagged unions and small object/array values. Note that comparison
|
|
65
|
+
* during URL writes is string-equality on the stringified form, so two
|
|
66
|
+
* objects with the same fields in different key orders will look distinct;
|
|
67
|
+
* provide a custom codec with a normalized stringifier if you need
|
|
68
|
+
* order-insensitive comparison.
|
|
69
|
+
*/
|
|
70
|
+
export const jsonCodec: ParamCodec = {
|
|
71
|
+
serialize: (value) => JSON.stringify(value),
|
|
72
|
+
deserialize: (raw) => JSON.parse(raw),
|
|
73
|
+
};
|
|
74
|
+
|
|
38
75
|
export type UrlParamsOptions<T> = {
|
|
39
76
|
debounceMs?: number;
|
|
40
77
|
/**
|
|
@@ -55,6 +92,14 @@ export type UrlParamsOptions<T> = {
|
|
|
55
92
|
* if `include` is provided.
|
|
56
93
|
*/
|
|
57
94
|
ignore?: ParamPath<T>[];
|
|
95
|
+
/**
|
|
96
|
+
* Per-path codecs. A path listed here is round-tripped as one opaque
|
|
97
|
+
* string under that key (using the codec's `serialize`/`deserialize`)
|
|
98
|
+
* instead of being flattened. URL keys nested inside a codec path are
|
|
99
|
+
* ignored, so a leftover `foo.bar=1` from an earlier shape doesn't
|
|
100
|
+
* punch through the atomic value.
|
|
101
|
+
*/
|
|
102
|
+
codecs?: ParamCodecs;
|
|
58
103
|
};
|
|
59
104
|
|
|
60
105
|
export type ResetOptions = {
|
|
@@ -109,20 +154,42 @@ function isBranch(value: unknown): boolean {
|
|
|
109
154
|
function* flattenLeaves(
|
|
110
155
|
obj: Record<string, unknown> | unknown[],
|
|
111
156
|
prefix: string[] = [],
|
|
157
|
+
stopPaths?: Set<string>,
|
|
112
158
|
): Generator<[string[], unknown]> {
|
|
113
159
|
const entries: Array<[string, unknown]> = Array.isArray(obj)
|
|
114
160
|
? obj.map((v, i) => [String(i), v])
|
|
115
161
|
: Object.entries(obj);
|
|
116
162
|
for (const [k, v] of entries) {
|
|
117
163
|
const path = [...prefix, k];
|
|
118
|
-
|
|
119
|
-
|
|
164
|
+
// `stopPaths` (codec paths) halt recursion: the whole subtree at that
|
|
165
|
+
// path is yielded as a single leaf value so a codec can serialize it
|
|
166
|
+
// atomically.
|
|
167
|
+
if (isBranch(v) && !stopPaths?.has(path.join("."))) {
|
|
168
|
+
yield* flattenLeaves(
|
|
169
|
+
v as Record<string, unknown> | unknown[],
|
|
170
|
+
path,
|
|
171
|
+
stopPaths,
|
|
172
|
+
);
|
|
120
173
|
} else {
|
|
121
174
|
yield [path, v];
|
|
122
175
|
}
|
|
123
176
|
}
|
|
124
177
|
}
|
|
125
178
|
|
|
179
|
+
// True iff some proper-prefix ancestor of `segments` is a codec path. Used
|
|
180
|
+
// to drop URL keys that would punch through a codec boundary (e.g. a stale
|
|
181
|
+
// `infectionRate.value=0.5` left over from before infectionRate gained a
|
|
182
|
+
// codec).
|
|
183
|
+
function pathIsInsideAnyCodec(
|
|
184
|
+
segments: string[],
|
|
185
|
+
codecPaths: Set<string>,
|
|
186
|
+
): boolean {
|
|
187
|
+
for (let i = 1; i < segments.length; i++) {
|
|
188
|
+
if (codecPaths.has(segments.slice(0, i).join("."))) return true;
|
|
189
|
+
}
|
|
190
|
+
return false;
|
|
191
|
+
}
|
|
192
|
+
|
|
126
193
|
function getAtSegments(obj: unknown, segments: string[]): unknown {
|
|
127
194
|
let cur: unknown = obj;
|
|
128
195
|
for (const p of segments) {
|
|
@@ -184,12 +251,19 @@ function decodeKey(key: string): string[] {
|
|
|
184
251
|
function deepMerge(
|
|
185
252
|
target: Record<string, unknown> | unknown[],
|
|
186
253
|
source: Record<string, unknown> | unknown[],
|
|
254
|
+
stopPaths?: Set<string>,
|
|
255
|
+
prefix: string[] = [],
|
|
187
256
|
): Record<string, unknown> | unknown[] {
|
|
188
257
|
for (const [k, v] of Object.entries(source)) {
|
|
189
258
|
const slot = (target as Record<string, unknown>)[k];
|
|
190
|
-
|
|
191
|
-
|
|
259
|
+
const path = stopPaths ? [...prefix, k] : prefix;
|
|
260
|
+
const atStop = stopPaths?.has(path.join("."));
|
|
261
|
+
if (!atStop && isContainer(v) && isContainer(slot)) {
|
|
262
|
+
deepMerge(slot, v, stopPaths, path);
|
|
192
263
|
} else {
|
|
264
|
+
// Codec paths are atomic: overwrite the target subtree with source's
|
|
265
|
+
// value instead of merging the two variants field-by-field (which
|
|
266
|
+
// would produce a malformed hybrid for tagged unions).
|
|
193
267
|
(target as Record<string, unknown>)[k] = v;
|
|
194
268
|
}
|
|
195
269
|
}
|
|
@@ -199,15 +273,21 @@ function deepMerge(
|
|
|
199
273
|
export function paramsToQuery<T extends object>(
|
|
200
274
|
params: T,
|
|
201
275
|
defaults: T,
|
|
276
|
+
codecs?: ParamCodecs,
|
|
202
277
|
): Record<string, string> {
|
|
203
278
|
const query: Record<string, string> = {};
|
|
279
|
+
const stopPaths = codecs ? new Set(Object.keys(codecs)) : undefined;
|
|
204
280
|
for (const [segments, defaultLeaf] of flattenLeaves(
|
|
205
281
|
defaults as Record<string, unknown>,
|
|
282
|
+
[],
|
|
283
|
+
stopPaths,
|
|
206
284
|
)) {
|
|
207
285
|
const paramLeaf = getAtSegments(params, segments);
|
|
208
286
|
if (paramLeaf === undefined) continue;
|
|
209
|
-
const
|
|
210
|
-
const
|
|
287
|
+
const codec = codecs?.[segments.join(".")];
|
|
288
|
+
const serializeFn = codec ? codec.serialize : serialize;
|
|
289
|
+
const serialized = serializeFn(paramLeaf);
|
|
290
|
+
const defaultSerialized = serializeFn(defaultLeaf);
|
|
211
291
|
if (serialized !== defaultSerialized) {
|
|
212
292
|
query[encodeKey(segments)] = serialized;
|
|
213
293
|
}
|
|
@@ -218,11 +298,25 @@ export function paramsToQuery<T extends object>(
|
|
|
218
298
|
export function queryToParams<T extends object>(
|
|
219
299
|
query: Record<string, unknown>,
|
|
220
300
|
defaults: T,
|
|
301
|
+
codecs?: ParamCodecs,
|
|
221
302
|
): Partial<T> {
|
|
222
303
|
const result: Record<string, unknown> = {};
|
|
304
|
+
const codecPaths = codecs ? new Set(Object.keys(codecs)) : undefined;
|
|
223
305
|
for (const [key, raw] of Object.entries(query)) {
|
|
224
306
|
if (typeof raw !== "string") continue;
|
|
225
307
|
const segments = decodeKey(key);
|
|
308
|
+
const pathStr = segments.join(".");
|
|
309
|
+
const codec = codecs?.[pathStr];
|
|
310
|
+
if (codec) {
|
|
311
|
+
try {
|
|
312
|
+
setAtSegments(result, segments, codec.deserialize(raw), defaults);
|
|
313
|
+
} catch {
|
|
314
|
+
// Malformed codec value — skip rather than corrupt the params.
|
|
315
|
+
}
|
|
316
|
+
continue;
|
|
317
|
+
}
|
|
318
|
+
// Drop descendants of a codec path; the codec owns the whole subtree.
|
|
319
|
+
if (codecPaths && pathIsInsideAnyCodec(segments, codecPaths)) continue;
|
|
226
320
|
const defaultLeaf = getAtSegments(defaults, segments);
|
|
227
321
|
// Drop unknown dotted keys: matches the flat behavior and keeps the
|
|
228
322
|
// params shape stable.
|
|
@@ -282,10 +376,15 @@ function filterDefaults<T extends object>(
|
|
|
282
376
|
obj: T,
|
|
283
377
|
include?: string[][],
|
|
284
378
|
ignore?: string[][],
|
|
379
|
+
stopPaths?: Set<string>,
|
|
285
380
|
): T {
|
|
286
381
|
if (!include && !ignore) return obj;
|
|
287
382
|
const out: Record<string, unknown> = {};
|
|
288
|
-
for (const [path, leaf] of flattenLeaves(
|
|
383
|
+
for (const [path, leaf] of flattenLeaves(
|
|
384
|
+
obj as Record<string, unknown>,
|
|
385
|
+
[],
|
|
386
|
+
stopPaths,
|
|
387
|
+
)) {
|
|
289
388
|
if (pathInScope(path, include, ignore)) {
|
|
290
389
|
setAtSegments(out, path, leaf, obj);
|
|
291
390
|
}
|
|
@@ -317,15 +416,16 @@ export function useUrlParams<T extends object>(
|
|
|
317
416
|
options: UrlParamsOptions<T> = {},
|
|
318
417
|
) {
|
|
319
418
|
const debounceMs = options.debounceMs ?? 300;
|
|
320
|
-
const { router, route, include, ignore } = options;
|
|
419
|
+
const { router, route, include, ignore, codecs } = options;
|
|
321
420
|
const includeSegments = include?.map((e) => String(e).split("."));
|
|
322
421
|
const ignoreSegments = ignore?.map((e) => String(e).split("."));
|
|
422
|
+
const codecPaths = codecs ? new Set(Object.keys(codecs)) : undefined;
|
|
323
423
|
const getDefaults = toGetter<T>(defaults);
|
|
324
424
|
const scopedDefaults = (): T | undefined => {
|
|
325
425
|
const d = getDefaults();
|
|
326
426
|
return d === undefined
|
|
327
427
|
? undefined
|
|
328
|
-
: filterDefaults(d, includeSegments, ignoreSegments);
|
|
428
|
+
: filterDefaults(d, includeSegments, ignoreSegments, codecPaths);
|
|
329
429
|
};
|
|
330
430
|
|
|
331
431
|
function readQuery(): Record<string, unknown> {
|
|
@@ -354,7 +454,11 @@ export function useUrlParams<T extends object>(
|
|
|
354
454
|
// precision so a leaf array like `tags=1,2` replaces the whole leaf
|
|
355
455
|
// instead of merging element-wise with the default array.
|
|
356
456
|
const current = structuredClone(toRaw(read())) as Record<string, unknown>;
|
|
357
|
-
deepMerge(
|
|
457
|
+
deepMerge(
|
|
458
|
+
current,
|
|
459
|
+
structuredClone(toRaw(d)) as Record<string, unknown>,
|
|
460
|
+
codecPaths,
|
|
461
|
+
);
|
|
358
462
|
for (const [segments, value] of overrides) {
|
|
359
463
|
setAtSegments(current, segments, value, d);
|
|
360
464
|
}
|
|
@@ -371,6 +475,17 @@ export function useUrlParams<T extends object>(
|
|
|
371
475
|
for (const [key, raw] of Object.entries(readQuery())) {
|
|
372
476
|
if (typeof raw !== "string") continue;
|
|
373
477
|
const segments = decodeKey(key);
|
|
478
|
+
const pathStr = segments.join(".");
|
|
479
|
+
const codec = codecs?.[pathStr];
|
|
480
|
+
if (codec) {
|
|
481
|
+
try {
|
|
482
|
+
out.push([segments, codec.deserialize(raw)]);
|
|
483
|
+
} catch {
|
|
484
|
+
// skip malformed codec values
|
|
485
|
+
}
|
|
486
|
+
continue;
|
|
487
|
+
}
|
|
488
|
+
if (codecPaths && pathIsInsideAnyCodec(segments, codecPaths)) continue;
|
|
374
489
|
const defaultLeaf = getAtSegments(d, segments);
|
|
375
490
|
if (defaultLeaf === undefined) continue;
|
|
376
491
|
out.push([segments, deserialize(raw, defaultLeaf)]);
|
|
@@ -413,7 +528,7 @@ export function useUrlParams<T extends object>(
|
|
|
413
528
|
debounceTimer = setTimeout(() => {
|
|
414
529
|
const d = scopedDefaults();
|
|
415
530
|
if (d === undefined) return;
|
|
416
|
-
writeQuery(paramsToQuery(read(), d));
|
|
531
|
+
writeQuery(paramsToQuery(read(), d, codecs));
|
|
417
532
|
}, debounceMs);
|
|
418
533
|
},
|
|
419
534
|
{ deep: true },
|