@cfasim-ui/docs 0.4.11 → 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.
@@ -28,6 +28,50 @@ export interface UrlParamsRoute {
28
28
  */
29
29
  export type DefaultsInput<T> = T | Ref<T> | (() => T | undefined);
30
30
 
31
+ /**
32
+ * A path into the params object. Either a top-level key (autocompleted from
33
+ * `T`) or a dotted path to a nested leaf (`"nested.foo"`). Matches the leaf
34
+ * exactly or any descendant when used as `include`/`ignore`.
35
+ */
36
+ export type ParamPath<T> = keyof T | (string & {});
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
+
31
75
  export type UrlParamsOptions<T> = {
32
76
  debounceMs?: number;
33
77
  /**
@@ -39,14 +83,23 @@ export type UrlParamsOptions<T> = {
39
83
  router?: UrlParamsRouter;
40
84
  route?: UrlParamsRoute;
41
85
  /**
42
- * Only sync these keys. Useful when `defaults` contains labels, flags, or
43
- * other fields the user never edits.
86
+ * Only sync these paths. Accepts top-level keys or dotted paths to nested
87
+ * leaves; a parent path implicitly covers all of its descendants.
44
88
  */
45
- include?: (keyof T)[];
89
+ include?: ParamPath<T>[];
46
90
  /**
47
- * Sync all keys except these. Ignored if `include` is provided.
91
+ * Sync all paths except these. Same matching rules as `include`. Ignored
92
+ * if `include` is provided.
48
93
  */
49
- ignore?: (keyof T)[];
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;
50
103
  };
51
104
 
52
105
  export type ResetOptions = {
@@ -74,16 +127,169 @@ export function deserialize(raw: string, defaultValue: unknown): unknown {
74
127
  return raw;
75
128
  }
76
129
 
130
+ // A "container" is any non-null object — plain object or array. A "branch"
131
+ // is a container we recurse into when flattening defaults: plain objects
132
+ // always recurse; arrays only recurse when at least one element is itself a
133
+ // plain object (so `[1, 2, 3]` stays a comma-joined leaf, while
134
+ // `[{foo: 1}, {foo: 2}]` becomes `items.0.foo=…&items.1.foo=…`).
135
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
136
+ return value !== null && typeof value === "object" && !Array.isArray(value);
137
+ }
138
+
139
+ function isContainer(
140
+ value: unknown,
141
+ ): value is Record<string, unknown> | unknown[] {
142
+ return value !== null && typeof value === "object";
143
+ }
144
+
145
+ function isBranch(value: unknown): boolean {
146
+ if (isPlainObject(value)) return true;
147
+ // Recurse into arrays whose elements are themselves containers (plain
148
+ // objects or arrays), so array-of-arrays and array-of-objects map onto
149
+ // indexed dotted paths. Arrays of primitives stay as comma-joined leaves.
150
+ if (Array.isArray(value)) return value.some(isContainer);
151
+ return false;
152
+ }
153
+
154
+ function* flattenLeaves(
155
+ obj: Record<string, unknown> | unknown[],
156
+ prefix: string[] = [],
157
+ stopPaths?: Set<string>,
158
+ ): Generator<[string[], unknown]> {
159
+ const entries: Array<[string, unknown]> = Array.isArray(obj)
160
+ ? obj.map((v, i) => [String(i), v])
161
+ : Object.entries(obj);
162
+ for (const [k, v] of entries) {
163
+ const path = [...prefix, k];
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
+ );
173
+ } else {
174
+ yield [path, v];
175
+ }
176
+ }
177
+ }
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
+
193
+ function getAtSegments(obj: unknown, segments: string[]): unknown {
194
+ let cur: unknown = obj;
195
+ for (const p of segments) {
196
+ if (!isContainer(cur)) return undefined;
197
+ cur = (cur as Record<string, unknown>)[p];
198
+ }
199
+ return cur;
200
+ }
201
+
202
+ // Sets `value` at `segments` inside `obj`. When `defaults` is supplied,
203
+ // missing intermediate slots match the defaults' shape (array vs object) at
204
+ // the same path — so a URL like `items.0.foo=5` rebuilds an array, not an
205
+ // object with numeric keys.
206
+ function setAtSegments(
207
+ obj: Record<string, unknown> | unknown[],
208
+ segments: string[],
209
+ value: unknown,
210
+ defaults?: unknown,
211
+ ): void {
212
+ let cur: Record<string, unknown> | unknown[] = obj;
213
+ let defCur: unknown = defaults;
214
+ for (let i = 0; i < segments.length - 1; i++) {
215
+ const p = segments[i];
216
+ const defNext = isContainer(defCur)
217
+ ? (defCur as Record<string, unknown>)[p]
218
+ : undefined;
219
+ const slot = (cur as Record<string, unknown>)[p];
220
+ if (!isContainer(slot)) {
221
+ (cur as Record<string, unknown>)[p] = Array.isArray(defNext) ? [] : {};
222
+ }
223
+ cur = (cur as Record<string, unknown>)[p] as
224
+ | Record<string, unknown>
225
+ | unknown[];
226
+ defCur = defNext;
227
+ }
228
+ (cur as Record<string, unknown>)[segments[segments.length - 1]] = value;
229
+ }
230
+
231
+ // Encode/decode segment arrays to/from URL query keys. Each segment is
232
+ // percent-encoded so a literal `.` inside a key name won't collide with the
233
+ // nesting separator. `encodeURIComponent` leaves `.` alone, so we replace
234
+ // it manually to `%2E`. The encoded key still passes cleanly through
235
+ // `URLSearchParams` and vue-router.
236
+ function encodeKey(segments: string[]): string {
237
+ return segments
238
+ .map((s) => encodeURIComponent(s).replace(/\./g, "%2E"))
239
+ .join(".");
240
+ }
241
+
242
+ function decodeKey(key: string): string[] {
243
+ return key.split(".").map(decodeURIComponent);
244
+ }
245
+
246
+ // Recursive merge used to layer defaults onto the cloned current params.
247
+ // Descends into matching containers (objects and arrays) so sibling
248
+ // branches survive. URL overrides bypass this and use `setAtSegments`
249
+ // directly, since a comma-encoded leaf array must replace its slot
250
+ // wholesale rather than merge element-wise with the default.
251
+ function deepMerge(
252
+ target: Record<string, unknown> | unknown[],
253
+ source: Record<string, unknown> | unknown[],
254
+ stopPaths?: Set<string>,
255
+ prefix: string[] = [],
256
+ ): Record<string, unknown> | unknown[] {
257
+ for (const [k, v] of Object.entries(source)) {
258
+ const slot = (target as Record<string, unknown>)[k];
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);
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).
267
+ (target as Record<string, unknown>)[k] = v;
268
+ }
269
+ }
270
+ return target;
271
+ }
272
+
77
273
  export function paramsToQuery<T extends object>(
78
274
  params: T,
79
275
  defaults: T,
276
+ codecs?: ParamCodecs,
80
277
  ): Record<string, string> {
81
278
  const query: Record<string, string> = {};
82
- for (const key of Object.keys(defaults) as (keyof T & string)[]) {
83
- const serialized = serialize(params[key]);
84
- const defaultSerialized = serialize(defaults[key]);
279
+ const stopPaths = codecs ? new Set(Object.keys(codecs)) : undefined;
280
+ for (const [segments, defaultLeaf] of flattenLeaves(
281
+ defaults as Record<string, unknown>,
282
+ [],
283
+ stopPaths,
284
+ )) {
285
+ const paramLeaf = getAtSegments(params, segments);
286
+ if (paramLeaf === undefined) continue;
287
+ const codec = codecs?.[segments.join(".")];
288
+ const serializeFn = codec ? codec.serialize : serialize;
289
+ const serialized = serializeFn(paramLeaf);
290
+ const defaultSerialized = serializeFn(defaultLeaf);
85
291
  if (serialized !== defaultSerialized) {
86
- query[key] = serialized;
292
+ query[encodeKey(segments)] = serialized;
87
293
  }
88
294
  }
89
295
  return query;
@@ -92,13 +298,30 @@ export function paramsToQuery<T extends object>(
92
298
  export function queryToParams<T extends object>(
93
299
  query: Record<string, unknown>,
94
300
  defaults: T,
301
+ codecs?: ParamCodecs,
95
302
  ): Partial<T> {
96
303
  const result: Record<string, unknown> = {};
97
- const defaultsRecord = defaults as Record<string, unknown>;
304
+ const codecPaths = codecs ? new Set(Object.keys(codecs)) : undefined;
98
305
  for (const [key, raw] of Object.entries(query)) {
99
- if (!(key in defaultsRecord)) continue;
100
306
  if (typeof raw !== "string") continue;
101
- result[key] = deserialize(raw, defaultsRecord[key]);
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;
320
+ const defaultLeaf = getAtSegments(defaults, segments);
321
+ // Drop unknown dotted keys: matches the flat behavior and keeps the
322
+ // params shape stable.
323
+ if (defaultLeaf === undefined) continue;
324
+ setAtSegments(result, segments, deserialize(raw, defaultLeaf), defaults);
102
325
  }
103
326
  return result as Partial<T>;
104
327
  }
@@ -127,22 +350,43 @@ function toGetter<T>(input: DefaultsInput<T>): () => T | undefined {
127
350
  return () => input as T;
128
351
  }
129
352
 
130
- function filterKeys<T extends object>(
353
+ // `include`/`ignore` entries are dotted strings using `.` as a JS-side path
354
+ // separator (one segment per key). A path is in scope iff it (or any
355
+ // ancestor) matches an include entry, or it (and all its ancestors) avoid
356
+ // every ignore entry. `include` wins when both are supplied.
357
+ function segmentsStartWith(path: string[], prefix: string[]): boolean {
358
+ if (prefix.length > path.length) return false;
359
+ for (let i = 0; i < prefix.length; i++) {
360
+ if (path[i] !== prefix[i]) return false;
361
+ }
362
+ return true;
363
+ }
364
+
365
+ function pathInScope(
366
+ path: string[],
367
+ include?: string[][],
368
+ ignore?: string[][],
369
+ ): boolean {
370
+ if (include) return include.some((entry) => segmentsStartWith(path, entry));
371
+ if (ignore) return !ignore.some((entry) => segmentsStartWith(path, entry));
372
+ return true;
373
+ }
374
+
375
+ function filterDefaults<T extends object>(
131
376
  obj: T,
132
- include?: (keyof T)[],
133
- ignore?: (keyof T)[],
377
+ include?: string[][],
378
+ ignore?: string[][],
379
+ stopPaths?: Set<string>,
134
380
  ): T {
135
381
  if (!include && !ignore) return obj;
136
- const src = obj as Record<string, unknown>;
137
382
  const out: Record<string, unknown> = {};
138
- for (const k of Object.keys(src)) {
139
- const key = k as keyof T;
140
- if (include) {
141
- if (include.includes(key)) out[k] = src[k];
142
- } else if (ignore) {
143
- if (!ignore.includes(key)) out[k] = src[k];
144
- } else {
145
- out[k] = src[k];
383
+ for (const [path, leaf] of flattenLeaves(
384
+ obj as Record<string, unknown>,
385
+ [],
386
+ stopPaths,
387
+ )) {
388
+ if (pathInScope(path, include, ignore)) {
389
+ setAtSegments(out, path, leaf, obj);
146
390
  }
147
391
  }
148
392
  return out as T;
@@ -151,7 +395,8 @@ function filterKeys<T extends object>(
151
395
  /**
152
396
  * Syncs a reactive params object with the URL query string. Only values
153
397
  * that differ from defaults appear in the URL. Accepts either a `ref()`
154
- * or a `reactive()` object.
398
+ * or a `reactive()` object. Nested objects are encoded as dotted keys
399
+ * (`nested.foo=5`); arrays of primitives remain comma-joined leaves.
155
400
  *
156
401
  * For async defaults (e.g. loaded from WASM/network), pass a getter or ref
157
402
  * that returns `undefined` until ready, and call `hydrate()` once defaults
@@ -171,11 +416,16 @@ export function useUrlParams<T extends object>(
171
416
  options: UrlParamsOptions<T> = {},
172
417
  ) {
173
418
  const debounceMs = options.debounceMs ?? 300;
174
- const { router, route, include, ignore } = options;
419
+ const { router, route, include, ignore, codecs } = options;
420
+ const includeSegments = include?.map((e) => String(e).split("."));
421
+ const ignoreSegments = ignore?.map((e) => String(e).split("."));
422
+ const codecPaths = codecs ? new Set(Object.keys(codecs)) : undefined;
175
423
  const getDefaults = toGetter<T>(defaults);
176
424
  const scopedDefaults = (): T | undefined => {
177
425
  const d = getDefaults();
178
- return d === undefined ? undefined : filterKeys(d, include, ignore);
426
+ return d === undefined
427
+ ? undefined
428
+ : filterDefaults(d, includeSegments, ignoreSegments, codecPaths);
179
429
  };
180
430
 
181
431
  function readQuery(): Record<string, unknown> {
@@ -195,18 +445,24 @@ export function useUrlParams<T extends object>(
195
445
  return isRef(params) ? params.value : params;
196
446
  }
197
447
 
198
- function apply(overrides: Partial<T>, d: T) {
448
+ function apply(overrides: Array<[string[], unknown]>, d: T) {
199
449
  // Layer order (later wins): current params -> defaults -> URL overrides.
200
450
  // Starting from current preserves keys outside the sync scope (i.e.
201
451
  // those filtered out by `include`/`ignore`), which matters when `params`
202
- // is a ref and the whole object gets replaced below. `toRaw` unwraps
203
- // reactive proxies so `structuredClone` doesn't choke.
204
- const current = toRaw(read());
205
- const merged = {
206
- ...structuredClone(current),
207
- ...structuredClone(toRaw(d)),
208
- ...overrides,
209
- } as T;
452
+ // is a ref and the whole object gets replaced below. Defaults deep-merge
453
+ // so sibling branches survive; URL overrides are applied at leaf
454
+ // precision so a leaf array like `tags=1,2` replaces the whole leaf
455
+ // instead of merging element-wise with the default array.
456
+ const current = structuredClone(toRaw(read())) as Record<string, unknown>;
457
+ deepMerge(
458
+ current,
459
+ structuredClone(toRaw(d)) as Record<string, unknown>,
460
+ codecPaths,
461
+ );
462
+ for (const [segments, value] of overrides) {
463
+ setAtSegments(current, segments, value, d);
464
+ }
465
+ const merged = current as T;
210
466
  if (isRef(params)) {
211
467
  params.value = merged;
212
468
  } else {
@@ -214,13 +470,36 @@ export function useUrlParams<T extends object>(
214
470
  }
215
471
  }
216
472
 
473
+ function readOverrides(d: T): Array<[string[], unknown]> {
474
+ const out: Array<[string[], unknown]> = [];
475
+ for (const [key, raw] of Object.entries(readQuery())) {
476
+ if (typeof raw !== "string") continue;
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;
489
+ const defaultLeaf = getAtSegments(d, segments);
490
+ if (defaultLeaf === undefined) continue;
491
+ out.push([segments, deserialize(raw, defaultLeaf)]);
492
+ }
493
+ return out;
494
+ }
495
+
217
496
  let hydrated = false;
218
497
 
219
498
  function hydrate(): boolean {
220
499
  const d = scopedDefaults();
221
500
  if (d === undefined) return false;
222
- const overrides = queryToParams(readQuery(), d);
223
- if (Object.keys(overrides).length > 0) apply(overrides, d);
501
+ const overrides = readOverrides(d);
502
+ if (overrides.length > 0) apply(overrides, d);
224
503
  hydrated = true;
225
504
  return true;
226
505
  }
@@ -232,8 +511,7 @@ export function useUrlParams<T extends object>(
232
511
  }
233
512
  const d = scopedDefaults();
234
513
  if (d === undefined) return;
235
- const overrides = queryToParams(readQuery(), d);
236
- apply(overrides, d);
514
+ apply(readOverrides(d), d);
237
515
  }
238
516
 
239
517
  onMounted(() => {
@@ -250,7 +528,7 @@ export function useUrlParams<T extends object>(
250
528
  debounceTimer = setTimeout(() => {
251
529
  const d = scopedDefaults();
252
530
  if (d === undefined) return;
253
- writeQuery(paramsToQuery(read(), d));
531
+ writeQuery(paramsToQuery(read(), d, codecs));
254
532
  }, debounceMs);
255
533
  },
256
534
  { deep: true },
@@ -281,7 +559,7 @@ export function useUrlParams<T extends object>(
281
559
  const { clearUrl = true } = opts;
282
560
  const d = scopedDefaults();
283
561
  if (d === undefined) return;
284
- apply({}, d);
562
+ apply([], d);
285
563
  if (clearUrl) {
286
564
  if (debounceTimer) clearTimeout(debounceTimer);
287
565
  writeQuery({});