@cfasim-ui/docs 0.4.10 → 0.4.12

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,13 @@ 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
+
31
38
  export type UrlParamsOptions<T> = {
32
39
  debounceMs?: number;
33
40
  /**
@@ -39,14 +46,15 @@ export type UrlParamsOptions<T> = {
39
46
  router?: UrlParamsRouter;
40
47
  route?: UrlParamsRoute;
41
48
  /**
42
- * Only sync these keys. Useful when `defaults` contains labels, flags, or
43
- * other fields the user never edits.
49
+ * Only sync these paths. Accepts top-level keys or dotted paths to nested
50
+ * leaves; a parent path implicitly covers all of its descendants.
44
51
  */
45
- include?: (keyof T)[];
52
+ include?: ParamPath<T>[];
46
53
  /**
47
- * Sync all keys except these. Ignored if `include` is provided.
54
+ * Sync all paths except these. Same matching rules as `include`. Ignored
55
+ * if `include` is provided.
48
56
  */
49
- ignore?: (keyof T)[];
57
+ ignore?: ParamPath<T>[];
50
58
  };
51
59
 
52
60
  export type ResetOptions = {
@@ -74,16 +82,134 @@ export function deserialize(raw: string, defaultValue: unknown): unknown {
74
82
  return raw;
75
83
  }
76
84
 
85
+ // A "container" is any non-null object — plain object or array. A "branch"
86
+ // is a container we recurse into when flattening defaults: plain objects
87
+ // always recurse; arrays only recurse when at least one element is itself a
88
+ // plain object (so `[1, 2, 3]` stays a comma-joined leaf, while
89
+ // `[{foo: 1}, {foo: 2}]` becomes `items.0.foo=…&items.1.foo=…`).
90
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
91
+ return value !== null && typeof value === "object" && !Array.isArray(value);
92
+ }
93
+
94
+ function isContainer(
95
+ value: unknown,
96
+ ): value is Record<string, unknown> | unknown[] {
97
+ return value !== null && typeof value === "object";
98
+ }
99
+
100
+ function isBranch(value: unknown): boolean {
101
+ if (isPlainObject(value)) return true;
102
+ // Recurse into arrays whose elements are themselves containers (plain
103
+ // objects or arrays), so array-of-arrays and array-of-objects map onto
104
+ // indexed dotted paths. Arrays of primitives stay as comma-joined leaves.
105
+ if (Array.isArray(value)) return value.some(isContainer);
106
+ return false;
107
+ }
108
+
109
+ function* flattenLeaves(
110
+ obj: Record<string, unknown> | unknown[],
111
+ prefix: string[] = [],
112
+ ): Generator<[string[], unknown]> {
113
+ const entries: Array<[string, unknown]> = Array.isArray(obj)
114
+ ? obj.map((v, i) => [String(i), v])
115
+ : Object.entries(obj);
116
+ for (const [k, v] of entries) {
117
+ const path = [...prefix, k];
118
+ if (isBranch(v)) {
119
+ yield* flattenLeaves(v as Record<string, unknown> | unknown[], path);
120
+ } else {
121
+ yield [path, v];
122
+ }
123
+ }
124
+ }
125
+
126
+ function getAtSegments(obj: unknown, segments: string[]): unknown {
127
+ let cur: unknown = obj;
128
+ for (const p of segments) {
129
+ if (!isContainer(cur)) return undefined;
130
+ cur = (cur as Record<string, unknown>)[p];
131
+ }
132
+ return cur;
133
+ }
134
+
135
+ // Sets `value` at `segments` inside `obj`. When `defaults` is supplied,
136
+ // missing intermediate slots match the defaults' shape (array vs object) at
137
+ // the same path — so a URL like `items.0.foo=5` rebuilds an array, not an
138
+ // object with numeric keys.
139
+ function setAtSegments(
140
+ obj: Record<string, unknown> | unknown[],
141
+ segments: string[],
142
+ value: unknown,
143
+ defaults?: unknown,
144
+ ): void {
145
+ let cur: Record<string, unknown> | unknown[] = obj;
146
+ let defCur: unknown = defaults;
147
+ for (let i = 0; i < segments.length - 1; i++) {
148
+ const p = segments[i];
149
+ const defNext = isContainer(defCur)
150
+ ? (defCur as Record<string, unknown>)[p]
151
+ : undefined;
152
+ const slot = (cur as Record<string, unknown>)[p];
153
+ if (!isContainer(slot)) {
154
+ (cur as Record<string, unknown>)[p] = Array.isArray(defNext) ? [] : {};
155
+ }
156
+ cur = (cur as Record<string, unknown>)[p] as
157
+ | Record<string, unknown>
158
+ | unknown[];
159
+ defCur = defNext;
160
+ }
161
+ (cur as Record<string, unknown>)[segments[segments.length - 1]] = value;
162
+ }
163
+
164
+ // Encode/decode segment arrays to/from URL query keys. Each segment is
165
+ // percent-encoded so a literal `.` inside a key name won't collide with the
166
+ // nesting separator. `encodeURIComponent` leaves `.` alone, so we replace
167
+ // it manually to `%2E`. The encoded key still passes cleanly through
168
+ // `URLSearchParams` and vue-router.
169
+ function encodeKey(segments: string[]): string {
170
+ return segments
171
+ .map((s) => encodeURIComponent(s).replace(/\./g, "%2E"))
172
+ .join(".");
173
+ }
174
+
175
+ function decodeKey(key: string): string[] {
176
+ return key.split(".").map(decodeURIComponent);
177
+ }
178
+
179
+ // Recursive merge used to layer defaults onto the cloned current params.
180
+ // Descends into matching containers (objects and arrays) so sibling
181
+ // branches survive. URL overrides bypass this and use `setAtSegments`
182
+ // directly, since a comma-encoded leaf array must replace its slot
183
+ // wholesale rather than merge element-wise with the default.
184
+ function deepMerge(
185
+ target: Record<string, unknown> | unknown[],
186
+ source: Record<string, unknown> | unknown[],
187
+ ): Record<string, unknown> | unknown[] {
188
+ for (const [k, v] of Object.entries(source)) {
189
+ const slot = (target as Record<string, unknown>)[k];
190
+ if (isContainer(v) && isContainer(slot)) {
191
+ deepMerge(slot, v);
192
+ } else {
193
+ (target as Record<string, unknown>)[k] = v;
194
+ }
195
+ }
196
+ return target;
197
+ }
198
+
77
199
  export function paramsToQuery<T extends object>(
78
200
  params: T,
79
201
  defaults: T,
80
202
  ): Record<string, string> {
81
203
  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]);
204
+ for (const [segments, defaultLeaf] of flattenLeaves(
205
+ defaults as Record<string, unknown>,
206
+ )) {
207
+ const paramLeaf = getAtSegments(params, segments);
208
+ if (paramLeaf === undefined) continue;
209
+ const serialized = serialize(paramLeaf);
210
+ const defaultSerialized = serialize(defaultLeaf);
85
211
  if (serialized !== defaultSerialized) {
86
- query[key] = serialized;
212
+ query[encodeKey(segments)] = serialized;
87
213
  }
88
214
  }
89
215
  return query;
@@ -94,11 +220,14 @@ export function queryToParams<T extends object>(
94
220
  defaults: T,
95
221
  ): Partial<T> {
96
222
  const result: Record<string, unknown> = {};
97
- const defaultsRecord = defaults as Record<string, unknown>;
98
223
  for (const [key, raw] of Object.entries(query)) {
99
- if (!(key in defaultsRecord)) continue;
100
224
  if (typeof raw !== "string") continue;
101
- result[key] = deserialize(raw, defaultsRecord[key]);
225
+ const segments = decodeKey(key);
226
+ const defaultLeaf = getAtSegments(defaults, segments);
227
+ // Drop unknown dotted keys: matches the flat behavior and keeps the
228
+ // params shape stable.
229
+ if (defaultLeaf === undefined) continue;
230
+ setAtSegments(result, segments, deserialize(raw, defaultLeaf), defaults);
102
231
  }
103
232
  return result as Partial<T>;
104
233
  }
@@ -127,22 +256,38 @@ function toGetter<T>(input: DefaultsInput<T>): () => T | undefined {
127
256
  return () => input as T;
128
257
  }
129
258
 
130
- function filterKeys<T extends object>(
259
+ // `include`/`ignore` entries are dotted strings using `.` as a JS-side path
260
+ // separator (one segment per key). A path is in scope iff it (or any
261
+ // ancestor) matches an include entry, or it (and all its ancestors) avoid
262
+ // every ignore entry. `include` wins when both are supplied.
263
+ function segmentsStartWith(path: string[], prefix: string[]): boolean {
264
+ if (prefix.length > path.length) return false;
265
+ for (let i = 0; i < prefix.length; i++) {
266
+ if (path[i] !== prefix[i]) return false;
267
+ }
268
+ return true;
269
+ }
270
+
271
+ function pathInScope(
272
+ path: string[],
273
+ include?: string[][],
274
+ ignore?: string[][],
275
+ ): boolean {
276
+ if (include) return include.some((entry) => segmentsStartWith(path, entry));
277
+ if (ignore) return !ignore.some((entry) => segmentsStartWith(path, entry));
278
+ return true;
279
+ }
280
+
281
+ function filterDefaults<T extends object>(
131
282
  obj: T,
132
- include?: (keyof T)[],
133
- ignore?: (keyof T)[],
283
+ include?: string[][],
284
+ ignore?: string[][],
134
285
  ): T {
135
286
  if (!include && !ignore) return obj;
136
- const src = obj as Record<string, unknown>;
137
287
  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];
288
+ for (const [path, leaf] of flattenLeaves(obj as Record<string, unknown>)) {
289
+ if (pathInScope(path, include, ignore)) {
290
+ setAtSegments(out, path, leaf, obj);
146
291
  }
147
292
  }
148
293
  return out as T;
@@ -151,7 +296,8 @@ function filterKeys<T extends object>(
151
296
  /**
152
297
  * Syncs a reactive params object with the URL query string. Only values
153
298
  * that differ from defaults appear in the URL. Accepts either a `ref()`
154
- * or a `reactive()` object.
299
+ * or a `reactive()` object. Nested objects are encoded as dotted keys
300
+ * (`nested.foo=5`); arrays of primitives remain comma-joined leaves.
155
301
  *
156
302
  * For async defaults (e.g. loaded from WASM/network), pass a getter or ref
157
303
  * that returns `undefined` until ready, and call `hydrate()` once defaults
@@ -172,10 +318,14 @@ export function useUrlParams<T extends object>(
172
318
  ) {
173
319
  const debounceMs = options.debounceMs ?? 300;
174
320
  const { router, route, include, ignore } = options;
321
+ const includeSegments = include?.map((e) => String(e).split("."));
322
+ const ignoreSegments = ignore?.map((e) => String(e).split("."));
175
323
  const getDefaults = toGetter<T>(defaults);
176
324
  const scopedDefaults = (): T | undefined => {
177
325
  const d = getDefaults();
178
- return d === undefined ? undefined : filterKeys(d, include, ignore);
326
+ return d === undefined
327
+ ? undefined
328
+ : filterDefaults(d, includeSegments, ignoreSegments);
179
329
  };
180
330
 
181
331
  function readQuery(): Record<string, unknown> {
@@ -195,18 +345,20 @@ export function useUrlParams<T extends object>(
195
345
  return isRef(params) ? params.value : params;
196
346
  }
197
347
 
198
- function apply(overrides: Partial<T>, d: T) {
348
+ function apply(overrides: Array<[string[], unknown]>, d: T) {
199
349
  // Layer order (later wins): current params -> defaults -> URL overrides.
200
350
  // Starting from current preserves keys outside the sync scope (i.e.
201
351
  // 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;
352
+ // is a ref and the whole object gets replaced below. Defaults deep-merge
353
+ // so sibling branches survive; URL overrides are applied at leaf
354
+ // precision so a leaf array like `tags=1,2` replaces the whole leaf
355
+ // instead of merging element-wise with the default array.
356
+ const current = structuredClone(toRaw(read())) as Record<string, unknown>;
357
+ deepMerge(current, structuredClone(toRaw(d)) as Record<string, unknown>);
358
+ for (const [segments, value] of overrides) {
359
+ setAtSegments(current, segments, value, d);
360
+ }
361
+ const merged = current as T;
210
362
  if (isRef(params)) {
211
363
  params.value = merged;
212
364
  } else {
@@ -214,13 +366,25 @@ export function useUrlParams<T extends object>(
214
366
  }
215
367
  }
216
368
 
369
+ function readOverrides(d: T): Array<[string[], unknown]> {
370
+ const out: Array<[string[], unknown]> = [];
371
+ for (const [key, raw] of Object.entries(readQuery())) {
372
+ if (typeof raw !== "string") continue;
373
+ const segments = decodeKey(key);
374
+ const defaultLeaf = getAtSegments(d, segments);
375
+ if (defaultLeaf === undefined) continue;
376
+ out.push([segments, deserialize(raw, defaultLeaf)]);
377
+ }
378
+ return out;
379
+ }
380
+
217
381
  let hydrated = false;
218
382
 
219
383
  function hydrate(): boolean {
220
384
  const d = scopedDefaults();
221
385
  if (d === undefined) return false;
222
- const overrides = queryToParams(readQuery(), d);
223
- if (Object.keys(overrides).length > 0) apply(overrides, d);
386
+ const overrides = readOverrides(d);
387
+ if (overrides.length > 0) apply(overrides, d);
224
388
  hydrated = true;
225
389
  return true;
226
390
  }
@@ -232,8 +396,7 @@ export function useUrlParams<T extends object>(
232
396
  }
233
397
  const d = scopedDefaults();
234
398
  if (d === undefined) return;
235
- const overrides = queryToParams(readQuery(), d);
236
- apply(overrides, d);
399
+ apply(readOverrides(d), d);
237
400
  }
238
401
 
239
402
  onMounted(() => {
@@ -281,7 +444,7 @@ export function useUrlParams<T extends object>(
281
444
  const { clearUrl = true } = opts;
282
445
  const d = scopedDefaults();
283
446
  if (d === undefined) return;
284
- apply({}, d);
447
+ apply([], d);
285
448
  if (clearUrl) {
286
449
  if (debounceTimer) clearTimeout(debounceTimer);
287
450
  writeQuery({});