@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 CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.4.12",
2
+ "version": "0.4.13",
3
3
  "package": "@cfasim-ui/docs",
4
4
  "content": {
5
5
  "components": [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cfasim-ui/docs",
3
- "version": "0.4.12",
3
+ "version": "0.4.13",
4
4
  "description": "LLM-friendly component and chart documentation for cfasim-ui",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
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";
@@ -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
- if (isBranch(v)) {
119
- yield* flattenLeaves(v as Record<string, unknown> | unknown[], path);
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
- if (isContainer(v) && isContainer(slot)) {
191
- deepMerge(slot, v);
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 serialized = serialize(paramLeaf);
210
- const defaultSerialized = serialize(defaultLeaf);
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(obj as Record<string, unknown>)) {
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(current, structuredClone(toRaw(d)) as Record<string, unknown>);
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 },