vue-context-storage 0.1.45 → 0.1.46

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/README.md CHANGED
@@ -11,7 +11,7 @@ Vue 3 reactive state management — sync state with the URL query, localStorage,
11
11
  ![CI](https://github.com/lviobio/vue-context-storage/actions/workflows/ci.yml/badge.svg)
12
12
  ![Coverage](https://github.com/lviobio/vue-context-storage/actions/workflows/coverage.yml/badge.svg)
13
13
  [![codecov](https://codecov.io/gh/lviobio/vue-context-storage/branch/main/graph/badge.svg)](https://codecov.io/gh/lviobio/vue-context-storage)
14
- [![Live Demo](https://img.shields.io/badge/demo-live-brightgreen)](https://lviobio.github.io/vue-context-storage/)
14
+ [![Live Demo](https://img.shields.io/badge/demo-live-brightgreen)](https://lviobio.github.io/vue-context-storage)
15
15
 
16
16
  Key features:
17
17
 
@@ -66,7 +66,6 @@ In Vue applications, reactive state often needs to live beyond a single componen
66
66
  - **URL query parameters** stay in sync with your data automatically - users can bookmark or share a page and get the exact same state back.
67
67
  - **localStorage and sessionStorage** are kept up to date without manual `getItem`/`setItem` calls, including cross-tab synchronization.
68
68
  - **Type safety** is preserved end-to-end: URL strings are coerced back to numbers, booleans, and arrays via transform helpers or Zod schemas.
69
- - **Multiple independent contexts** (e.g. two data tables on the same page) are supported out of the box through the key pattern, so query parameters never collide.
70
69
 
71
70
  The goal is a single, declarative API - `useContextStorage('query', data, options)` - that replaces scattered watchers, router guards, and storage listeners with one composable call per piece of state.
72
71
 
@@ -462,6 +461,93 @@ const customHandlers = [
462
461
  // <ContextStorage :handlers="customHandlers">
463
462
  ```
464
463
 
464
+ ### Comma-separated Arrays in the Query String
465
+
466
+ By default, flat arrays are written as repeated query keys (`?ids=1&ids=2&ids=3`). Set `serialize: { arrayFormat: 'comma' }` to store them as a single comma-joined value instead:
467
+
468
+ ```typescript
469
+ createQueryHandler({ serialize: { arrayFormat: 'comma' } })
470
+
471
+ // { ids: [1, 2, 3] } → ?ids=1,2,3
472
+ ```
473
+
474
+ Values that themselves contain the separator are safely backslash-escaped, so they survive the round-trip without breaking into extra elements:
475
+
476
+ ```typescript
477
+ // { tags: ['Some value', 'with, comma', 'last'] }
478
+ // → ?tags=Some value,with\,comma,last
479
+ // → { tags: ['Some value', 'with,comma', 'last'] } (escaped comma restored)
480
+ ```
481
+
482
+ The option can be set on the factory (applies to every registered context) or per `useContextStorage('query', ...)` call (the per-call value wins). A custom separator is supported via `arraySeparator`:
483
+
484
+ ```typescript
485
+ createQueryHandler({ serialize: { arrayFormat: 'comma', arraySeparator: '|' } })
486
+
487
+ // { ids: [1, 2, 3] } → ?ids=1|2|3
488
+ ```
489
+
490
+ Restoring a comma-joined array back into an array requires a `schema` or `transform` (a comma-joined string is indistinguishable from a plain string on the URL — the same requirement as any non-string query value):
491
+
492
+ ```typescript
493
+ import { z } from 'zod'
494
+
495
+ const data = reactive({ ids: [] as number[] })
496
+
497
+ useContextStorage('query', data, {
498
+ schema: z.object({ ids: z.array(z.number()) }),
499
+ serialize: { arrayFormat: 'comma' },
500
+ })
501
+ // ?ids=1,2,3 → { ids: [1, 2, 3] } (split + coerced automatically)
502
+ ```
503
+
504
+ With the manual `transform` approach, pass the matching `separator` to the array helpers:
505
+
506
+ ```typescript
507
+ useContextStorage('query', data, {
508
+ transform: (deserialized) => ({
509
+ ids: transform.asNumberArray(deserialized.ids, { separator: ',' }),
510
+ }),
511
+ serialize: { arrayFormat: 'comma' },
512
+ })
513
+ ```
514
+
515
+ ### Fully Custom Serializer
516
+
517
+ When the built-in serializer options aren't enough, pass a `serializer` (a `serialize` / `deserialize` pair) to `createQueryHandler` to take **full control** of how state is encoded to and decoded from the URL — e.g. a `qs`-style format, base64, or JSON. This is a **factory-level** option.
518
+
519
+ ```typescript
520
+ import { createQueryHandler, type QuerySerializer } from 'vue-context-storage'
521
+
522
+ // Example: JSON-encode each registration's data under its key
523
+ const jsonSerializer: QuerySerializer = {
524
+ serialize: (data, options) => ({ [options?.key ?? '_']: JSON.stringify(data) }),
525
+ deserialize: (query) => {
526
+ const out: Record<string, unknown> = {}
527
+ for (const key of Object.keys(query)) {
528
+ try {
529
+ out[key] = JSON.parse(query[key] as string)
530
+ } catch {
531
+ out[key] = query[key]
532
+ }
533
+ }
534
+ return out
535
+ },
536
+ }
537
+
538
+ // <ContextStorage :additional-handlers="[createQueryHandler({ serializer: jsonSerializer })]">
539
+ // useContextStorage('query', data, { key: 'f', schema: ... })
540
+ // → ?f={"ids":[1,2,3],"q":"hello"}
541
+ ```
542
+
543
+ **Contract** (`QuerySerializer`):
544
+
545
+ - `serialize` and `deserialize` **must be mutual inverses**.
546
+ - They must preserve the same per-`key` nesting the built-in pair uses: `deserialize(serialize(data, { key }))` reproduces `{ [key]: data }` (and `{ ...data }` when no `key` is given). The handler relies on this to extract each registration's subtree, diff against baselines, and drive `onlyChanges`.
547
+ - `serialize` always receives the **fully resolved options** (`ResolvedSerializeOptions`): `key`, plus `arrayFormat` and `arraySeparator` (the factory-level and per-`useContextStorage` `serialize` options merged, with defaults applied). A custom serializer can honour them — e.g. respect `arrayFormat: 'comma'` — or own the encoding entirely and ignore them.
548
+ - `deserialize` runs on the **whole** route query before per-registration extraction, so it does NOT receive options — fix the decoding format in the function (mirroring whatever `serialize` produced).
549
+ - `deserialize` output still flows through `schema` / `transform` coercion, so return already-typed values (numbers, arrays — like the JSON example above) or URL-style strings the standard coercion understands.
550
+
465
551
  ## Use localStorage Handler in Components
466
552
 
467
553
  Persist reactive state to `localStorage`. Data is automatically synced across browser tabs.
package/dist/index.d.ts CHANGED
@@ -220,8 +220,119 @@ declare function createCollectionManager(handlerFactories: ContextStorageHandler
220
220
  };
221
221
  type CollectionManager = ReturnType<typeof createCollectionManager>;
222
222
  //#endregion
223
+ //#region src/handlers/query/helpers.d.ts
224
+ /**
225
+ * Controls how flat (scalar) arrays are written to the URL query.
226
+ *
227
+ * - `'repeat'` (default): one query entry per value — `?ids=1&ids=2&ids=3`.
228
+ * Round-trips natively because vue-router restores repeated keys as arrays.
229
+ * - `'comma'`: a single comma-joined value — `?ids=1,2,3`. Produces shorter,
230
+ * more readable URLs, but the array-ness is lost on the URL (a comma-joined
231
+ * string is indistinguishable from a plain string), so restoring an array
232
+ * requires a `schema` or `transform` to know the field is an array.
233
+ */
234
+ type QueryArrayFormat = 'repeat' | 'comma';
235
+ interface QuerySerializeOptions {
236
+ /**
237
+ * How flat (scalar) arrays are written to the URL. Default: `'repeat'`.
238
+ */
239
+ arrayFormat?: QueryArrayFormat;
240
+ /**
241
+ * Separator used when `arrayFormat` is `'comma'`. Default: `','`.
242
+ */
243
+ arraySeparator?: string;
244
+ }
245
+ interface SerializeOptions extends QuerySerializeOptions {
246
+ /**
247
+ * Custom key prefix for serialized keys.
248
+ * @example
249
+ * - key: 'filters' => 'filters[field]'
250
+ * - key: 'search' => 'search[field]'
251
+ * - key: '' => 'field' (no prefix)
252
+ */
253
+ key?: string;
254
+ }
255
+ /**
256
+ * The serialize options after the handler has resolved them — the factory-level
257
+ * and per-register `serialize` options merged, with defaults applied
258
+ * (`arrayFormat: 'repeat'`, `arraySeparator: ','`). This is exactly what the
259
+ * query handler passes to {@link serializeParams} (and to a custom
260
+ * `serializer.serialize`), so `arrayFormat` and `arraySeparator` are always
261
+ * present — `key` is present only when the registration sets one.
262
+ */
263
+ interface ResolvedSerializeOptions {
264
+ key?: string;
265
+ arrayFormat: QueryArrayFormat;
266
+ arraySeparator: string;
267
+ }
268
+ /**
269
+ * Serializes filter parameters into a URL-friendly format.
270
+ *
271
+ * @param params - Raw parameters object to serialize
272
+ * @param options - Serialization options
273
+ * @returns Serialized parameters with prefixed keys
274
+ *
275
+ * @example
276
+ * // With default prefix 'filters'
277
+ * serializeFiltersParams({ status: 'active', tags: ['a', 'b'] })
278
+ * // => { 'filters[status]': 'active', 'filters[tags]': 'a,b' }
279
+ *
280
+ * @example
281
+ * // With custom prefix
282
+ * serializeFiltersParams({ name: 'John', all: true }, { prefix: 'search' })
283
+ * // => { 'search[name]': 'John', 'search[all]': '1' }
284
+ *
285
+ * @example
286
+ * // Without prefix
287
+ * serializeFiltersParams({ page: 1, all: false }, { prefix: '' })
288
+ * // => { 'page': '1', 'all': '0' }
289
+ */
290
+ declare function serializeParams(params: Record<string, unknown>, options?: SerializeOptions): LocationQuery;
291
+ /**
292
+ * Deserializes query parameters from a URL-friendly format back to an object.
293
+ *
294
+ * @param params - Serialized parameters object
295
+ * @returns Deserialized parameters object
296
+ *
297
+ * @example
298
+ * deserializeParams({ 'filters[status]': 'active', search: 'test' })
299
+ * // => { filters: {status: 'active'}, search: 'test' }
300
+ */
301
+ declare function deserializeParams(params: Record<string, any>): Record<string, unknown>;
302
+ //#endregion
223
303
  //#region src/handlers/query/types.d.ts
224
304
  type QueryValue = LocationQueryValue | LocationQueryValue[];
305
+ /**
306
+ * A fully custom serializer pair that replaces the built-in
307
+ * `serializeParams` / `deserializeParams` for the query handler.
308
+ *
309
+ * Set it on the handler factory (`createQueryHandler({ serializer })`) to take
310
+ * full control of how reactive state is encoded to / decoded from the URL query
311
+ * (e.g. a `qs`-style format, base64, JSON, or custom escaping).
312
+ *
313
+ * Contract:
314
+ * - `serialize` and `deserialize` MUST be mutual inverses.
315
+ * - They must preserve the same per-`key` nesting the standard pair uses, i.e.
316
+ * `deserialize(serialize(data, { key }))` reproduces `{ [key]: data }` (and
317
+ * `{ ...data }` when no `key` is given). The handler relies on this to extract
318
+ * each registration's subtree, diff against baselines, and drive `onlyChanges`.
319
+ * - `serialize` receives the fully {@link ResolvedSerializeOptions resolved}
320
+ * options — the factory-level and per-register `serialize` options merged with
321
+ * defaults — so `key`, `arrayFormat` and `arraySeparator` are all available.
322
+ * A custom implementation may honour the array options (e.g. respect
323
+ * `arrayFormat: 'comma'`) or own the encoding entirely and ignore them.
324
+ * - `deserialize` runs on the whole route query before per-registration
325
+ * extraction, so it does NOT receive options — the author fixes the decoding
326
+ * format (and must mirror whatever `serialize` produced).
327
+ *
328
+ * `deserialize` output still flows through `schema` / `transform` coercion, so
329
+ * return already-typed values (numbers, arrays) or URL-style strings the
330
+ * standard coercion understands.
331
+ */
332
+ interface QuerySerializer {
333
+ serialize: (params: Record<string, unknown>, options: ResolvedSerializeOptions) => LocationQuery;
334
+ deserialize: (query: Record<string, any>) => Record<string, any>;
335
+ }
225
336
  type DeepTransformValuesToLocationQueryValue<T> = { [K in keyof T]?: T[K] extends object ? T[K] extends Array<any> ? QueryValue : DeepTransformValuesToLocationQueryValue<T[K]> : QueryValue };
226
337
  interface QueryHandlerSharedOptions {
227
338
  /**
@@ -290,6 +401,28 @@ interface QueryHandlerSharedOptions {
290
401
  * If transform option is not passed, ref will be merged with query only by keys that exists in ref.
291
402
  */
292
403
  mergeOnlyExistingKeysWithoutTransform?: boolean;
404
+ /**
405
+ * Options for the standard query serializer.
406
+ *
407
+ * Use this to store flat arrays as a single comma-joined value instead of
408
+ * repeated keys:
409
+ *
410
+ * @example
411
+ * ```ts
412
+ * createQueryHandler({ serialize: { arrayFormat: 'comma' } })
413
+ *
414
+ * // { ids: [1, 2, 3] } → ?ids=1,2,3 (instead of ?ids=1&ids=2&ids=3)
415
+ * ```
416
+ *
417
+ * Can be set on the handler factory (applies to every registered context) or
418
+ * per `useContextStorage('query', ...)` call (overrides the factory value).
419
+ *
420
+ * Restoring a comma-joined array back into an array requires a `schema` or a
421
+ * `transform` (the comma string is indistinguishable from a plain string on
422
+ * the URL). The built-in `schema` coercion and the `asArray` / `asNumberArray`
423
+ * transform helpers split on `arraySeparator` automatically.
424
+ */
425
+ serialize?: QuerySerializeOptions;
293
426
  }
294
427
  interface QueryHandlerBaseOptions extends QueryHandlerSharedOptions {
295
428
  /**
@@ -317,6 +450,27 @@ interface QueryHandlerBaseOptions extends QueryHandlerSharedOptions {
317
450
  * to have exclusive ownership of all query parameters.
318
451
  */
319
452
  preserveUnusedKeys?: boolean;
453
+ /**
454
+ * A fully custom serializer pair replacing the built-in `serializeParams` /
455
+ * `deserializeParams`. Factory-level only — owns the entire URL encoding.
456
+ *
457
+ * When set, the built-in `serialize` options (`arrayFormat` / `arraySeparator`)
458
+ * no longer apply. See {@link QuerySerializer} for the contract.
459
+ *
460
+ * @example
461
+ * ```ts
462
+ * import qs from 'qs'
463
+ *
464
+ * createQueryHandler({
465
+ * serializer: {
466
+ * serialize: (data, { key } = {}) =>
467
+ * qs.parse(qs.stringify(key ? { [key]: data } : data, { encode: false })),
468
+ * deserialize: (query) => query,
469
+ * },
470
+ * })
471
+ * ```
472
+ */
473
+ serializer?: QuerySerializer;
320
474
  }
321
475
  interface RegisterQueryHandlerBaseOptions<T> extends QueryHandlerSharedOptions {
322
476
  /**
@@ -557,30 +711,36 @@ declare function asString(value: QueryValue | undefined, options: {
557
711
  declare function asNumberArray(value: QueryValue | undefined): number[];
558
712
  declare function asNumberArray(value: QueryValue | undefined, options: {
559
713
  nullable: true;
714
+ separator?: string;
560
715
  }): number[] | null;
561
716
  declare function asNumberArray(value: QueryValue | undefined, options: {
562
717
  nullable?: false;
718
+ separator?: string;
563
719
  }): number[];
564
720
  declare function asArray<T>(value: QueryValue | undefined): T[];
565
721
  declare function asArray<T>(value: QueryValue | undefined, options: {
566
722
  nullable: true;
567
723
  missable: true;
568
724
  transform?: (value: QueryValue) => T;
725
+ separator?: string;
569
726
  }): T[] | null | undefined;
570
727
  declare function asArray<T>(value: QueryValue | undefined, options: {
571
728
  nullable: true;
572
729
  missable?: false;
573
730
  transform?: (value: QueryValue) => T;
731
+ separator?: string;
574
732
  }): T[] | null;
575
733
  declare function asArray<T>(value: QueryValue | undefined, options: {
576
734
  nullable?: false;
577
735
  missable: true;
578
736
  transform?: (value: QueryValue) => T;
737
+ separator?: string;
579
738
  }): T[] | undefined;
580
739
  declare function asArray<T>(value: QueryValue | undefined, options: {
581
740
  nullable?: false;
582
741
  missable?: false;
583
742
  transform?: (value: QueryValue) => T;
743
+ separator?: string;
584
744
  }): T[];
585
745
  declare function asBoolean(value: QueryValue | undefined): boolean;
586
746
  declare function asBoolean(value: QueryValue | undefined, options: {
@@ -634,52 +794,6 @@ declare const transform: {
634
794
  asObjectArray: typeof asObjectArray;
635
795
  };
636
796
  //#endregion
637
- //#region src/handlers/query/helpers.d.ts
638
- interface SerializeOptions {
639
- /**
640
- * Custom key prefix for serialized keys.
641
- * @example
642
- * - key: 'filters' => 'filters[field]'
643
- * - key: 'search' => 'search[field]'
644
- * - key: '' => 'field' (no prefix)
645
- */
646
- key?: string;
647
- }
648
- /**
649
- * Serializes filter parameters into a URL-friendly format.
650
- *
651
- * @param params - Raw parameters object to serialize
652
- * @param options - Serialization options
653
- * @returns Serialized parameters with prefixed keys
654
- *
655
- * @example
656
- * // With default prefix 'filters'
657
- * serializeFiltersParams({ status: 'active', tags: ['a', 'b'] })
658
- * // => { 'filters[status]': 'active', 'filters[tags]': 'a,b' }
659
- *
660
- * @example
661
- * // With custom prefix
662
- * serializeFiltersParams({ name: 'John', all: true }, { prefix: 'search' })
663
- * // => { 'search[name]': 'John', 'search[all]': '1' }
664
- *
665
- * @example
666
- * // Without prefix
667
- * serializeFiltersParams({ page: 1, all: false }, { prefix: '' })
668
- * // => { 'page': '1', 'all': '0' }
669
- */
670
- declare function serializeParams(params: Record<string, unknown>, options?: SerializeOptions): LocationQuery;
671
- /**
672
- * Deserializes query parameters from a URL-friendly format back to an object.
673
- *
674
- * @param params - Serialized parameters object
675
- * @returns Deserialized parameters object
676
- *
677
- * @example
678
- * deserializeParams({ 'filters[status]': 'active', search: 'test' })
679
- * // => { filters: {status: 'active'}, search: 'test' }
680
- */
681
- declare function deserializeParams(params: Record<string, any>): Record<string, unknown>;
682
- //#endregion
683
797
  //#region src/injectionSymbols.d.ts
684
798
  declare const contextStorageCollectionInjectKey: InjectionKey<CollectionManager>;
685
799
  declare const contextStorageCollectionItemInjectKey: InjectionKey<CollectionManagerItem>;
@@ -691,4 +805,4 @@ declare const contextStorageSessionStorageHandlerInjectKey: InjectionKey<Context
691
805
  //#region src/constants.d.ts
692
806
  declare const defaultHandlers: ContextStorageHandlerFactory[];
693
807
  //#endregion
694
- export { type CollectionManager, type CollectionManagerItem, _default as ContextStorage, _default$1 as ContextStorageActivator, _default$2 as ContextStorageCollection, type ContextStorageHandler, type ContextStorageHandlerFactory, type ContextStorageHandlerMap, _default$3 as ContextStoragePrefix, type ContextStoragePrefixSegment, _default$4 as ContextStorageProvider, type WebStorageHandlerBaseOptions as LocalStorageHandlerBaseOptions, type MaybeWithSchema, type QueryValue, type RegisterBaseOptions, type RegisterWebStorageHandlerBaseOptions as RegisterLocalStorageHandlerBaseOptions, SCHEMA_SYMBOL, VueContextStoragePlugin, asArray, asBoolean, asNumber, asNumberArray, asObjectArray, asString, contextStorageCollectionInjectKey, contextStorageCollectionItemInjectKey, contextStorageHandlersInjectKey, contextStorageLocalStorageHandlerInjectKey, contextStoragePrefixSegmentsInjectKey, contextStorageQueryHandlerInjectKey, contextStorageSessionStorageHandlerInjectKey, createCollectionManager, createEmptyZodObject, createLocalStorageHandler, createQueryHandler, createSessionStorageHandler, defaultHandlers, defineContextStorageHandler, deserializeParams as deserializeQueryParams, resolveHandlerInjectionKey, serializeParams as serializeQueryParams, transform, useContextStorage, useContextStorageActivator, useContextStorageCollection, useContextStorageProvider };
808
+ export { type CollectionManager, type CollectionManagerItem, _default as ContextStorage, _default$1 as ContextStorageActivator, _default$2 as ContextStorageCollection, type ContextStorageHandler, type ContextStorageHandlerFactory, type ContextStorageHandlerMap, _default$3 as ContextStoragePrefix, type ContextStoragePrefixSegment, _default$4 as ContextStorageProvider, type WebStorageHandlerBaseOptions as LocalStorageHandlerBaseOptions, type MaybeWithSchema, type QueryArrayFormat, type QueryHandlerBaseOptions, type QuerySerializeOptions, type QuerySerializer, type QueryValue, type RegisterBaseOptions, type RegisterWebStorageHandlerBaseOptions as RegisterLocalStorageHandlerBaseOptions, type ResolvedSerializeOptions, SCHEMA_SYMBOL, type SerializeOptions, VueContextStoragePlugin, asArray, asBoolean, asNumber, asNumberArray, asObjectArray, asString, contextStorageCollectionInjectKey, contextStorageCollectionItemInjectKey, contextStorageHandlersInjectKey, contextStorageLocalStorageHandlerInjectKey, contextStoragePrefixSegmentsInjectKey, contextStorageQueryHandlerInjectKey, contextStorageSessionStorageHandlerInjectKey, createCollectionManager, createEmptyZodObject, createLocalStorageHandler, createQueryHandler, createSessionStorageHandler, defaultHandlers, defineContextStorageHandler, deserializeParams as deserializeQueryParams, resolveHandlerInjectionKey, serializeParams as serializeQueryParams, transform, useContextStorage, useContextStorageActivator, useContextStorageCollection, useContextStorageProvider };
package/dist/index.js CHANGED
@@ -1,6 +1,5 @@
1
- import { computed, defineComponent, getCurrentInstance, h, inject, markRaw, onBeforeUnmount, onUnmounted, provide, toValue, watch } from "vue";
1
+ import { computed, defineComponent, getCurrentInstance, h, inject, markRaw, onBeforeUnmount, onUnmounted, provide, toRaw, toValue, watch } from "vue";
2
2
  import { useRoute, useRouter } from "vue-router";
3
- import { cloneDeep, isEqual, merge, omit, pick } from "lodash";
4
3
 
5
4
  //#region src/symbols.ts
6
5
  const collection = /* @__PURE__ */ Symbol("context-storage-collection");
@@ -60,8 +59,39 @@ var ContextStorageActivator_default = ContextStorageActivator_vue_vue_type_scrip
60
59
 
61
60
  //#endregion
62
61
  //#region src/handlers/query/helpers.ts
62
+ function escapeArrayValue(value, separator) {
63
+ if (!value.includes(separator) && !value.includes("\\")) return value;
64
+ return value.split("\\").join("\\\\").split(separator).join("\\" + separator);
65
+ }
66
+ function joinArrayValues(values, separator) {
67
+ return values.map((value) => escapeArrayValue(String(value), separator)).join(separator);
68
+ }
69
+ function splitArrayValue(value, separator) {
70
+ if (separator.length === 0) return [value];
71
+ if (!value.includes("\\")) return value.split(separator);
72
+ const result = [];
73
+ let current = "";
74
+ let i = 0;
75
+ while (i < value.length) {
76
+ if (value[i] === "\\" && i + 1 < value.length) {
77
+ current += value[i + 1];
78
+ i += 2;
79
+ continue;
80
+ }
81
+ if (value.startsWith(separator, i)) {
82
+ result.push(current);
83
+ current = "";
84
+ i += separator.length;
85
+ continue;
86
+ }
87
+ current += value[i];
88
+ i += 1;
89
+ }
90
+ result.push(current);
91
+ return result;
92
+ }
63
93
  function serializeParams(params, options = {}) {
64
- const { key: prefix = "" } = options;
94
+ const { key: prefix = "", arrayFormat = "repeat", arraySeparator = "," } = options;
65
95
  const result = {};
66
96
  Object.keys(params).forEach((key) => {
67
97
  const value = params[key];
@@ -81,7 +111,8 @@ function serializeParams(params, options = {}) {
81
111
  ...options,
82
112
  key: formattedKey
83
113
  }));
84
- } else result[formattedKey] = value.map(String);
114
+ } else if (arrayFormat === "comma") result[formattedKey] = joinArrayValues(value, arraySeparator);
115
+ else result[formattedKey] = value.map(String);
85
116
  else Object.assign(result, serializeParams(value, {
86
117
  ...options,
87
118
  key: formattedKey
@@ -112,6 +143,52 @@ function deserializeParams(params) {
112
143
  }, {});
113
144
  }
114
145
 
146
+ //#endregion
147
+ //#region src/handlers/deep-utils.ts
148
+ function isPlainObject(value) {
149
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
150
+ const proto = Object.getPrototypeOf(value);
151
+ return proto === Object.prototype || proto === null;
152
+ }
153
+ function isEqual(a, b) {
154
+ if (a === b) return true;
155
+ if (a !== a && b !== b) return true;
156
+ if (Array.isArray(a) || Array.isArray(b)) {
157
+ if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false;
158
+ return a.every((item, i) => isEqual(item, b[i]));
159
+ }
160
+ if (isPlainObject(a) && isPlainObject(b)) {
161
+ const aKeys = Object.keys(a);
162
+ const bKeys = Object.keys(b);
163
+ if (aKeys.length !== bKeys.length) return false;
164
+ return aKeys.every((key) => Object.prototype.hasOwnProperty.call(b, key) && isEqual(a[key], b[key]));
165
+ }
166
+ return false;
167
+ }
168
+ function mergeDeep(target, ...sources) {
169
+ for (const source of sources) {
170
+ if (!source) continue;
171
+ for (const key of Object.keys(source)) {
172
+ const sourceValue = source[key];
173
+ if (sourceValue === void 0) continue;
174
+ const targetValue = target[key];
175
+ target[key] = isPlainObject(targetValue) && isPlainObject(sourceValue) ? mergeDeep({ ...targetValue }, sourceValue) : sourceValue;
176
+ }
177
+ }
178
+ return target;
179
+ }
180
+ function pick(obj, keys) {
181
+ const result = {};
182
+ for (const key of keys) if (key in obj) result[key] = obj[key];
183
+ return result;
184
+ }
185
+ function omit(obj, keys) {
186
+ const keySet = new Set(keys);
187
+ const result = {};
188
+ for (const key of Object.keys(obj)) if (!keySet.has(key)) result[key] = obj[key];
189
+ return result;
190
+ }
191
+
115
192
  //#endregion
116
193
  //#region src/handlers/helpers.ts
117
194
  function syncReactive(target, source) {
@@ -185,7 +262,7 @@ function extractDefaultsFromSchema(schema) {
185
262
  const { hasDefault: hasDefault2, value: objectDefault } = readZodFieldDefault(shape[key]);
186
263
  const nested = extractDefaultsFromSchema(base);
187
264
  let combined;
188
- if (hasDefault2 && nested) combined = merge({}, objectDefault, nested);
265
+ if (hasDefault2 && nested) combined = mergeDeep({}, objectDefault, nested);
189
266
  else if (hasDefault2) combined = objectDefault;
190
267
  else combined = nested;
191
268
  if (combined !== void 0) {
@@ -217,7 +294,7 @@ function coerceScalar(value, baseType) {
217
294
  }
218
295
  return value;
219
296
  }
220
- function coerceDataForSchema(data, schema) {
297
+ function coerceDataForSchema(data, schema, arraySeparator) {
221
298
  const shape = schema?.shape;
222
299
  if (!shape || typeof shape !== "object") return data;
223
300
  const result = { ...data };
@@ -232,16 +309,17 @@ function coerceDataForSchema(data, schema) {
232
309
  let arr;
233
310
  if (Array.isArray(value)) arr = value;
234
311
  else if (typeof value === "object") arr = Object.entries(value).sort(([a], [b]) => Number(a) - Number(b)).map(([, v]) => v);
312
+ else if (arraySeparator !== void 0 && typeof value === "string") arr = splitArrayValue(value, arraySeparator);
235
313
  else arr = [value];
236
314
  const elementBase = unwrapZodField(base.element ?? base._zod?.def?.element);
237
315
  const elementType = zodDefType(elementBase);
238
- if (elementType === "object" && elementBase.shape) arr = arr.map((v) => v && typeof v === "object" && !Array.isArray(v) ? coerceDataForSchema(v, elementBase) : v);
316
+ if (elementType === "object" && elementBase.shape) arr = arr.map((v) => v && typeof v === "object" && !Array.isArray(v) ? coerceDataForSchema(v, elementBase, arraySeparator) : v);
239
317
  else if (elementType === "number" || elementType === "boolean") arr = arr.map((v) => coerceScalar(v, elementType));
240
318
  result[key] = arr;
241
319
  }
242
320
  } else if (baseType === "object" && base.shape) {
243
321
  const nested = result[key];
244
- if (nested && typeof nested === "object" && !Array.isArray(nested)) result[key] = coerceDataForSchema(nested, base);
322
+ if (nested && typeof nested === "object" && !Array.isArray(nested)) result[key] = coerceDataForSchema(nested, base, arraySeparator);
245
323
  }
246
324
  }
247
325
  return result;
@@ -250,7 +328,7 @@ function applyTransform(input) {
250
328
  const warnings = [];
251
329
  let data = input.state;
252
330
  if (input.schema) {
253
- const coerced = coerceDataForSchema(merge({}, input.initialData, data), input.schema);
331
+ const coerced = coerceDataForSchema(mergeDeep({}, input.initialData, data), input.schema, input.arraySeparator);
254
332
  const result = input.schema.safeParse(coerced);
255
333
  if (result.success) data = result.data;
256
334
  else {
@@ -339,11 +417,16 @@ function sortQueryByReference(query, ...references) {
339
417
  function buildQuery(input) {
340
418
  const warnings = [];
341
419
  const newQueryRaw = {};
420
+ const serialize = input.serialize ?? serializeParams;
342
421
  const ownedKeys = /* @__PURE__ */ new Set();
343
422
  input.items.forEach((item) => {
344
423
  const { key, onlyChanges = input.onlyChanges } = item;
345
424
  let preserveEmptyState = item.preserveEmptyState ?? input.preserveEmptyState;
346
- const patch = serializeParams(item.data, { key });
425
+ const patch = serialize(item.data, {
426
+ key,
427
+ arrayFormat: item.arrayFormat ?? "repeat",
428
+ arraySeparator: item.arraySeparator ?? ","
429
+ });
347
430
  Object.keys(patch).forEach((k) => ownedKeys.add(k));
348
431
  Object.keys(item.initialQueryData).forEach((k) => ownedKeys.add(k));
349
432
  if (onlyChanges) {
@@ -433,8 +516,21 @@ function createQueryHandler(baseOptions) {
433
516
  preserveUnusedKeys: true,
434
517
  preserveEmptyState: false,
435
518
  onlyChanges: true,
519
+ serialize: {},
436
520
  ...baseOptions
437
521
  };
522
+ const serialize = baseOptions?.serializer?.serialize ?? serializeParams;
523
+ const deserialize = baseOptions?.serializer?.deserialize ?? deserializeParams;
524
+ function resolveSerializeOptions(itemOptions) {
525
+ const merged = {
526
+ ...options.serialize,
527
+ ...itemOptions?.serialize
528
+ };
529
+ return {
530
+ arrayFormat: merged.arrayFormat ?? "repeat",
531
+ arraySeparator: merged.arraySeparator ?? ","
532
+ };
533
+ }
438
534
  const stopAfterEach = router.afterEach(() => {
439
535
  afterEachRoute();
440
536
  });
@@ -508,7 +604,7 @@ function createQueryHandler(baseOptions) {
508
604
  const { mergeOnlyExistingKeysWithoutTransform = options.mergeOnlyExistingKeysWithoutTransform } = item.options || {};
509
605
  const itemState = toValue(item.data);
510
606
  const result = computeSyncState({
511
- deserializedState: deserializeParams(route.query),
607
+ deserializedState: deserialize(route.query),
512
608
  initialData: item.initialData,
513
609
  key,
514
610
  emptyPlaceholder: options.emptyPlaceholder
@@ -516,16 +612,18 @@ function createQueryHandler(baseOptions) {
516
612
  if (result.type === "none") return;
517
613
  if (result.type === "reset") {
518
614
  result.data;
519
- syncReactive(itemState, cloneDeep(result.data));
615
+ syncReactive(itemState, structuredClone(result.data));
520
616
  return;
521
617
  }
522
618
  const urlKeys = new Set(Object.keys(result.data));
619
+ const serializeOptions = resolveSerializeOptions(item.options);
523
620
  const transformed = applyTransform({
524
621
  state: result.data,
525
622
  initialData: item.initialData,
526
623
  schema: item.options?.schema,
527
624
  transform: item.options?.transform,
528
- mergeOnlyExistingKeysWithoutTransform
625
+ mergeOnlyExistingKeysWithoutTransform,
626
+ arraySeparator: serializeOptions.arrayFormat === "comma" ? serializeOptions.arraySeparator : void 0
529
627
  });
530
628
  transformed.warnings.forEach((w) => console.warn(w.message, ...w.args));
531
629
  const finalData = { ...transformed.data };
@@ -550,11 +648,16 @@ function createQueryHandler(baseOptions) {
550
648
  registerOptions.key;
551
649
  scheduleSyncToQuery();
552
650
  }, { deep: true });
553
- const initialData = cloneDeep(resolvedData);
554
- const initialQueryData = serializeParams(initialData, { key: registerOptions.key });
555
- const additionalDefaultQueryData = registerOptions.additionalDefaultData ? serializeParams(registerOptions.additionalDefaultData, { key: registerOptions.key }) : void 0;
651
+ const serializeOptions = resolveSerializeOptions(registerOptions);
652
+ const serializeBaselineOptions = {
653
+ key: registerOptions.key,
654
+ ...serializeOptions
655
+ };
656
+ const initialData = structuredClone(toRaw(resolvedData));
657
+ const initialQueryData = serialize(initialData, serializeBaselineOptions);
658
+ const additionalDefaultQueryData = registerOptions.additionalDefaultData ? serialize(registerOptions.additionalDefaultData, serializeBaselineOptions) : void 0;
556
659
  const schemaMetaDefaults = registerOptions.schema ? extractAdditionalDefaultDataFromSchema(registerOptions.schema) : void 0;
557
- const schemaMetaDefaultQueryData = schemaMetaDefaults ? serializeParams(schemaMetaDefaults, { key: registerOptions.key }) : void 0;
660
+ const schemaMetaDefaultQueryData = schemaMetaDefaults ? serialize(schemaMetaDefaults, serializeBaselineOptions) : void 0;
558
661
  const schemaDefaults = registerOptions.schema ? extractDefaultsFromSchema(registerOptions.schema) : void 0;
559
662
  const item = {
560
663
  data,
@@ -562,7 +665,7 @@ function createQueryHandler(baseOptions) {
562
665
  initialQueryData,
563
666
  additionalDefaultQueryData,
564
667
  schemaMetaDefaultQueryData,
565
- schemaDefaultQueryData: schemaDefaults ? serializeParams(schemaDefaults, { key: registerOptions.key }) : void 0,
668
+ schemaDefaultQueryData: schemaDefaults ? serialize(schemaDefaults, serializeBaselineOptions) : void 0,
566
669
  options: registerOptions,
567
670
  watchHandle
568
671
  };
@@ -591,30 +694,36 @@ function createQueryHandler(baseOptions) {
591
694
  },
592
695
  reset: () => {
593
696
  registerOptions.key;
594
- syncReactive(toValue(data), cloneDeep(initialData));
697
+ syncReactive(toValue(data), structuredClone(initialData));
595
698
  },
596
699
  wasChanged: computed(() => !isEqual(toValue(data), initialData))
597
700
  };
598
701
  }
599
702
  function buildQueryFromRegistered() {
600
703
  const result = buildQuery({
601
- items: registered.map((item) => ({
602
- data: toValue(item.data),
603
- initialQueryData: item.initialQueryData,
604
- additionalDefaultQueryData: item.additionalDefaultQueryData,
605
- schemaMetaDefaultQueryData: item.schemaMetaDefaultQueryData,
606
- schemaDefaultQueryData: item.schemaDefaultQueryData,
607
- key: item.options?.key,
608
- onlyChanges: item.options?.onlyChanges,
609
- preserveEmptyState: item.options?.preserveEmptyState,
610
- causer: item.options?.causer
611
- })),
704
+ items: registered.map((item) => {
705
+ const serializeOptions = resolveSerializeOptions(item.options);
706
+ return {
707
+ data: toValue(item.data),
708
+ initialQueryData: item.initialQueryData,
709
+ additionalDefaultQueryData: item.additionalDefaultQueryData,
710
+ schemaMetaDefaultQueryData: item.schemaMetaDefaultQueryData,
711
+ schemaDefaultQueryData: item.schemaDefaultQueryData,
712
+ key: item.options?.key,
713
+ onlyChanges: item.options?.onlyChanges,
714
+ preserveEmptyState: item.options?.preserveEmptyState,
715
+ causer: item.options?.causer,
716
+ arrayFormat: serializeOptions.arrayFormat,
717
+ arraySeparator: serializeOptions.arraySeparator
718
+ };
719
+ }),
612
720
  currentQuery,
613
721
  routeQuery: route.query,
614
722
  preserveUnusedKeys: options.preserveUnusedKeys,
615
723
  preserveEmptyState: options.preserveEmptyState,
616
724
  onlyChanges: options.onlyChanges,
617
- emptyPlaceholder: options.emptyPlaceholder
725
+ emptyPlaceholder: options.emptyPlaceholder,
726
+ serialize
618
727
  });
619
728
  result.warnings.forEach((w) => console.warn(w));
620
729
  return {
@@ -722,7 +831,7 @@ function createWebStorageHandlerInstance(config) {
722
831
  const watchHandle = watch(data, () => syncRegisteredToStorage(), { deep: true });
723
832
  const item = {
724
833
  data,
725
- initialData: cloneDeep(resolvedData),
834
+ initialData: structuredClone(toRaw(resolvedData)),
726
835
  options,
727
836
  watchHandle
728
837
  };
@@ -737,7 +846,7 @@ function createWebStorageHandlerInstance(config) {
737
846
  registeredDataObjects.delete(resolvedData);
738
847
  },
739
848
  reset: () => {
740
- syncReactive(toValue(data), cloneDeep(item.initialData));
849
+ syncReactive(toValue(data), structuredClone(item.initialData));
741
850
  },
742
851
  wasChanged: computed(() => !isEqual(toValue(data), item.initialData))
743
852
  };
@@ -1104,12 +1213,12 @@ function asString(value, options) {
1104
1213
  return stringValue;
1105
1214
  }
1106
1215
  function asNumberArray(value, options) {
1107
- const { nullable = false } = options || {};
1216
+ const { nullable = false, separator } = options || {};
1108
1217
  if (value === null && nullable) return null;
1109
1218
  if (value === void 0) return nullable ? null : [];
1110
1219
  let arrayValue;
1111
1220
  if (Array.isArray(value)) arrayValue = value;
1112
- else if (typeof value === "string") arrayValue = [value];
1221
+ else if (typeof value === "string") arrayValue = separator !== void 0 ? splitArrayValue(value, separator) : [value];
1113
1222
  else arrayValue = [];
1114
1223
  return arrayValue.map((item) => {
1115
1224
  if (item === null) return 0;
@@ -1118,12 +1227,13 @@ function asNumberArray(value, options) {
1118
1227
  });
1119
1228
  }
1120
1229
  function asArray(value, options) {
1121
- const { nullable = false, missable = false, transform: transform2 } = options || {};
1230
+ const { nullable = false, missable = false, transform: transform2, separator } = options || {};
1122
1231
  if (value === null && nullable) return null;
1123
1232
  if (value === void 0 && missable) return;
1124
1233
  if (value === void 0) return nullable ? null : [];
1125
1234
  let arrayValue;
1126
1235
  if (Array.isArray(value)) arrayValue = value;
1236
+ else if (separator !== void 0 && typeof value === "string") arrayValue = splitArrayValue(value, separator);
1127
1237
  else arrayValue = [value];
1128
1238
  if (transform2) return arrayValue.map((item) => transform2(item));
1129
1239
  return arrayValue;
package/package.json CHANGED
@@ -1,98 +1,94 @@
1
- {
2
- "name": "vue-context-storage",
3
- "type": "module",
4
- "version": "0.1.45",
5
- "description": "Vue 3 reactive state management — sync state with the URL query, localStorage, and sessionStorage",
6
- "author": "",
7
- "license": "MIT",
8
- "homepage": "https://github.com/lviobio/vue-context-storage#readme",
9
- "repository": {
10
- "type": "git",
11
- "url": "git+https://github.com/lviobio/vue-context-storage.git"
12
- },
13
- "bugs": {
14
- "url": "https://github.com/lviobio/vue-context-storage/issues"
15
- },
16
- "exports": {
17
- ".": "./dist/index.js",
18
- "./package.json": "./package.json"
19
- },
20
- "main": "./dist/index.js",
21
- "module": "./dist/index.js",
22
- "types": "./dist/index.d.ts",
23
- "files": [
24
- "dist"
25
- ],
26
- "publishConfig": {
27
- "access": "public"
28
- },
29
- "scripts": {
30
- "build": "tsdown",
31
- "dev": "tsdown --watch",
32
- "check": "npm run ts:check && npm run lint:check && npm run format:check && npm run dependency-cruiser:check",
33
- "ts:check": "vue-tsc --noEmit",
34
- "format": "prettier --write src/ playground/src playground-e2e/src",
35
- "format:check": "prettier --check src/ playground/src playground-e2e/src",
36
- "lint": "eslint . --fix",
37
- "lint:check": "eslint .",
38
- "dependency-cruiser:check": "depcruise --config .dependency-cruiser.cjs src playground/src",
39
- "play": "vite --port 5173",
40
- "play:e2e": "vite --port 5174 --config vite.e2e.config.ts",
41
- "build:playground": "vite build",
42
- "preview:playground": "vite preview --outDir playground/dist",
43
- "test": "npm run test:unit && npm run test:e2e",
44
- "test:unit": "vitest run",
45
- "test:e2e": "playwright test",
46
- "test:e2e:ui": "playwright test --ui",
47
- "test:e2e:headed": "playwright test --headed",
48
- "test:e2e:debug": "playwright test --debug",
49
- "test:e2e:report": "playwright show-report",
50
- "release": "npm run check && npm run test && bumpp && npm publish",
51
- "prepublishOnly": "npm run check && npm run test && npm run build"
52
- },
53
- "peerDependencies": {
54
- "vue": "^3.0.0",
55
- "vue-router": "^4.0.0 || ^5.0.0",
56
- "zod": "^4.0.0"
57
- },
58
- "peerDependenciesMeta": {
59
- "zod": {
60
- "optional": true
61
- }
62
- },
63
- "devDependencies": {
64
- "@playwright/test": "^1.60.0",
65
- "@tailwindcss/vite": "^4.1.18",
66
- "@types/lodash": "^4.17.21",
67
- "@types/node": "^25.0.3",
68
- "@vitejs/plugin-vue": "^6.0.3",
69
- "@vitest/browser-playwright": "^4.0.16",
70
- "@vitest/coverage-v8": "^4.0.16",
71
- "@vue/eslint-config-typescript": "^14.6.0",
72
- "@vue/test-utils": "^2.4.6",
73
- "bumpp": "^10.3.2",
74
- "dependency-cruiser": "^17.3.6",
75
- "eslint": "^9.39.2",
76
- "eslint-config-prettier": "^10.1.8",
77
- "eslint-plugin-vue": "^10.6.2",
78
- "happy-dom": "^20.1.0",
79
- "highlight.js": "^11.11.1",
80
- "jsdom": "^27.4.0",
81
- "naive-ui": "^2.43.2",
82
- "playwright": "^1.57.0",
83
- "prettier": "^3.7.4",
84
- "tailwindcss": "^4.1.18",
85
- "tsdown": "^0.18.4",
86
- "typescript": "^5.9.3",
87
- "typescript-eslint": "^8.51.0",
88
- "vite": "^7.3.0",
89
- "vitest": "^4.0.16",
90
- "vitest-browser-vue": "^2.0.1",
91
- "vue": "^3.5.26",
92
- "vue-tsc": "^3.2.1",
93
- "zod": "^4.3.5"
94
- },
95
- "dependencies": {
96
- "lodash": "^4.17.23"
97
- }
98
- }
1
+ {
2
+ "name": "vue-context-storage",
3
+ "type": "module",
4
+ "version": "0.1.46",
5
+ "description": "Vue 3 reactive state management — sync state with the URL query, localStorage, and sessionStorage",
6
+ "author": "",
7
+ "license": "MIT",
8
+ "homepage": "https://github.com/lviobio/vue-context-storage#readme",
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "git+https://github.com/lviobio/vue-context-storage.git"
12
+ },
13
+ "bugs": {
14
+ "url": "https://github.com/lviobio/vue-context-storage/issues"
15
+ },
16
+ "exports": {
17
+ ".": "./dist/index.js",
18
+ "./package.json": "./package.json"
19
+ },
20
+ "main": "./dist/index.js",
21
+ "module": "./dist/index.js",
22
+ "types": "./dist/index.d.ts",
23
+ "files": [
24
+ "dist"
25
+ ],
26
+ "publishConfig": {
27
+ "access": "public"
28
+ },
29
+ "scripts": {
30
+ "build": "tsdown",
31
+ "dev": "tsdown --watch",
32
+ "check": "npm run ts:check && npm run lint:check && npm run format:check && npm run dependency-cruiser:check",
33
+ "ts:check": "vue-tsc --noEmit",
34
+ "format": "prettier --write src/ playground/src playground-e2e/src",
35
+ "format:check": "prettier --check src/ playground/src playground-e2e/src",
36
+ "lint": "eslint . --fix",
37
+ "lint:check": "eslint .",
38
+ "dependency-cruiser:check": "depcruise --config .dependency-cruiser.cjs src playground/src",
39
+ "play": "vite --port 5173",
40
+ "play:e2e": "vite --port 5174 --config vite.e2e.config.ts",
41
+ "build:playground": "vite build",
42
+ "preview:playground": "vite preview --outDir playground/dist",
43
+ "test": "npm run test:unit && npm run test:e2e",
44
+ "test:unit": "vitest run",
45
+ "test:e2e": "playwright test",
46
+ "test:e2e:ui": "playwright test --ui",
47
+ "test:e2e:headed": "playwright test --headed",
48
+ "test:e2e:debug": "playwright test --debug",
49
+ "test:e2e:report": "playwright show-report",
50
+ "release": "npm run check && npm run test && bumpp && npm publish",
51
+ "prepublishOnly": "npm run check && npm run test && npm run build"
52
+ },
53
+ "peerDependencies": {
54
+ "vue": "^3.0.0",
55
+ "vue-router": "^4.0.0 || ^5.0.0",
56
+ "zod": "^4.0.0"
57
+ },
58
+ "peerDependenciesMeta": {
59
+ "zod": {
60
+ "optional": true
61
+ }
62
+ },
63
+ "devDependencies": {
64
+ "@playwright/test": "^1.60.0",
65
+ "@tailwindcss/vite": "^4.1.18",
66
+ "@types/node": "^25.0.3",
67
+ "@vitejs/plugin-vue": "^6.0.3",
68
+ "@vitest/browser-playwright": "^4.0.16",
69
+ "@vitest/coverage-v8": "^4.0.16",
70
+ "@vue/eslint-config-typescript": "^14.6.0",
71
+ "@vue/test-utils": "^2.4.6",
72
+ "bumpp": "^10.3.2",
73
+ "dependency-cruiser": "^17.3.6",
74
+ "eslint": "^9.39.2",
75
+ "eslint-config-prettier": "^10.1.8",
76
+ "eslint-plugin-vue": "^10.6.2",
77
+ "happy-dom": "^20.1.0",
78
+ "highlight.js": "^11.11.1",
79
+ "jsdom": "^27.4.0",
80
+ "naive-ui": "^2.43.2",
81
+ "playwright": "^1.57.0",
82
+ "prettier": "^3.7.4",
83
+ "tailwindcss": "^4.1.18",
84
+ "tsdown": "^0.18.4",
85
+ "typescript": "^5.9.3",
86
+ "typescript-eslint": "^8.51.0",
87
+ "vite": "^7.3.0",
88
+ "vitest": "^4.0.16",
89
+ "vitest-browser-vue": "^2.0.1",
90
+ "vue": "^3.5.26",
91
+ "vue-tsc": "^3.2.1",
92
+ "zod": "^4.3.5"
93
+ }
94
+ }