zustand-querystring 0.6.1 → 0.6.2

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
@@ -14,16 +14,16 @@ import { querystring } from 'zustand-querystring';
14
14
 
15
15
  const useStore = create(
16
16
  querystring(
17
- (set) => ({
17
+ set => ({
18
18
  search: '',
19
19
  page: 1,
20
- setSearch: (search) => set({ search }),
21
- setPage: (page) => set({ page }),
20
+ setSearch: search => set({ search }),
21
+ setPage: page => set({ page }),
22
22
  }),
23
23
  {
24
24
  select: () => ({ search: true, page: true }),
25
- }
26
- )
25
+ },
26
+ ),
27
27
  );
28
28
  // URL: ?search=hello&page=2
29
29
  ```
@@ -34,14 +34,15 @@ const useStore = create(
34
34
 
35
35
  ```ts
36
36
  querystring(storeCreator, {
37
- select: undefined, // which fields to sync
38
- key: false, // false | 'state'
39
- prefix: '', // prefix for URL params
40
- format: marked, // serialization format
41
- syncNull: false, // sync null values
37
+ select: undefined, // which fields to sync
38
+ key: false, // false | 'state'
39
+ prefix: '', // prefix for URL params
40
+ format: marked, // serialization format
41
+ map: undefined, // bidirectional store ↔ URL mapping
42
+ syncNull: false, // sync null values
42
43
  syncUndefined: false, // sync undefined values
43
- url: undefined, // request URL for SSR
44
- })
44
+ url: undefined, // request URL for SSR
45
+ });
45
46
  ```
46
47
 
47
48
  ### `select`
@@ -50,14 +51,14 @@ Controls which state fields sync to URL. Receives pathname, returns object with
50
51
 
51
52
  ```ts
52
53
  // All fields
53
- select: () => ({ search: true, page: true, filters: true })
54
+ select: () => ({ search: true, page: true, filters: true });
54
55
 
55
56
  // Route-based
56
- select: (pathname) => ({
57
+ select: pathname => ({
57
58
  search: true,
58
59
  filters: pathname.startsWith('/products'),
59
60
  adminSettings: pathname.startsWith('/admin'),
60
- })
61
+ });
61
62
 
62
63
  // Nested fields
63
64
  select: () => ({
@@ -65,7 +66,7 @@ select: () => ({
65
66
  name: true,
66
67
  settings: { theme: true },
67
68
  },
68
- })
69
+ });
69
70
  ```
70
71
 
71
72
  ### `key`
@@ -84,8 +85,8 @@ select: () => ({
84
85
  Adds prefix to all params. Use when multiple stores share URL.
85
86
 
86
87
  ```ts
87
- querystring(storeA, { prefix: 'a_', select: () => ({ search: true }) })
88
- querystring(storeB, { prefix: 'b_', select: () => ({ filter: true }) })
88
+ querystring(storeA, { prefix: 'a_', select: () => ({ search: true }) });
89
+ querystring(storeB, { prefix: 'b_', select: () => ({ filter: true }) });
89
90
  // URL: ?a_search=hello&b_filter=active
90
91
  ```
91
92
 
@@ -93,12 +94,68 @@ querystring(storeB, { prefix: 'b_', select: () => ({ filter: true }) })
93
94
 
94
95
  By default, `null` and `undefined` reset to initial state (removed from URL). Set to `true` to write them.
95
96
 
97
+ ### `map`
98
+
99
+ Bidirectional mapping between store state and URL state. Use when the URL should represent a different shape than the store — e.g., a store keyed by dynamic IDs where the URL should only reflect the active entry.
100
+
101
+ `to` receives the selected state and pathname, returns the URL shape. `from` receives the parsed URL state and pathname, returns store state to merge. Types flow automatically: `from`'s parameter type is inferred from `to`'s return type.
102
+
103
+ ```ts
104
+ interface Store {
105
+ filtersByOperation: Record<string, { filters: string[] }>;
106
+ aggregationByOperation: Record<string, string>;
107
+ setFilters: (opId: string, filters: string[]) => void;
108
+ }
109
+
110
+ const useStore = create<Store>()(
111
+ querystring(
112
+ set => ({
113
+ filtersByOperation: {},
114
+ aggregationByOperation: {},
115
+ setFilters: (opId, filters) =>
116
+ set(s => ({
117
+ filtersByOperation: { ...s.filtersByOperation, [opId]: { filters } },
118
+ })),
119
+ }),
120
+ {
121
+ key: 'state',
122
+ select: pathname => ({
123
+ filtersByOperation: pathname.startsWith('/view/'),
124
+ aggregationByOperation: pathname.startsWith('/view/'),
125
+ }),
126
+ map: {
127
+ to: (state, pathname) => {
128
+ const id = pathname.split('/').pop()!;
129
+ return {
130
+ filters: state.filtersByOperation?.[id]?.filters,
131
+ aggregation: state.aggregationByOperation?.[id],
132
+ };
133
+ },
134
+ from: (urlState, pathname) => {
135
+ // urlState is typed as { filters: string[] | undefined, aggregation: string | undefined }
136
+ const id = pathname.split('/').pop()!;
137
+ return {
138
+ ...(urlState.filters
139
+ ? { filtersByOperation: { [id]: { filters: urlState.filters } } }
140
+ : {}),
141
+ ...(urlState.aggregation
142
+ ? { aggregationByOperation: { [id]: urlState.aggregation } }
143
+ : {}),
144
+ };
145
+ },
146
+ },
147
+ },
148
+ ),
149
+ );
150
+ // On /view/DAM_v1 → URL: ?state=filters@price_>10~,aggregation=daily
151
+ ```
152
+
96
153
  ### `url`
97
154
 
98
155
  For SSR, pass the request URL:
99
156
 
100
157
  ```ts
101
- querystring(store, { url: request.url, select: () => ({ search: true }) })
158
+ querystring(store, { url: request.url, select: () => ({ search: true }) });
102
159
  ```
103
160
 
104
161
  ---
@@ -118,6 +175,7 @@ Only values **different from initial state** are written to URL:
118
175
  ```
119
176
 
120
177
  Type handling:
178
+
121
179
  - Objects: recursively diffed
122
180
  - Arrays, Dates: compared as whole values
123
181
  - Functions: never synced
@@ -128,18 +186,18 @@ Type handling:
128
186
 
129
187
  Three built-in formats:
130
188
 
131
- | Format | Example Output |
132
- |--------|----------------|
133
- | `marked` | `count:5,tags@a,b~` |
134
- | `plain` | `count=5&tags=a&tags=b` |
135
- | `json` | `count=5&tags=%5B%22a%22%5D` |
189
+ | Format | Example Output |
190
+ | -------- | ---------------------------- |
191
+ | `marked` | `count:5,tags@a,b~` |
192
+ | `plain` | `count=5&tags=a&tags=b` |
193
+ | `json` | `count=5&tags=%5B%22a%22%5D` |
136
194
 
137
195
  ```ts
138
196
  import { marked } from 'zustand-querystring/format/marked';
139
197
  import { plain } from 'zustand-querystring/format/plain';
140
198
  import { json } from 'zustand-querystring/format/json';
141
199
 
142
- querystring(store, { format: plain })
200
+ querystring(store, { format: plain });
143
201
  ```
144
202
 
145
203
  ### Marked Format (default)
@@ -171,13 +229,13 @@ Dot notation for nesting, repeated keys for arrays.
171
229
  import { createFormat } from 'zustand-querystring/format/plain';
172
230
 
173
231
  const format = createFormat({
174
- entrySeparator: ',', // between entries in namespaced mode
175
- nestingSeparator: '.', // for nested keys
176
- arraySeparator: 'repeat', // 'repeat' for ?tags=a&tags=b, or ',' for ?tags=a,b
232
+ entrySeparator: ',', // between entries in namespaced mode
233
+ nestingSeparator: '.', // for nested keys
234
+ arraySeparator: 'repeat', // 'repeat' for ?tags=a&tags=b, or ',' for ?tags=a,b
177
235
  escapeChar: '_',
178
236
  nullString: 'null',
179
237
  undefinedString: 'undefined',
180
- infinityString: 'Infinity', // string representation of Infinity
238
+ infinityString: 'Infinity', // string representation of Infinity
181
239
  negativeInfinityString: '-Infinity',
182
240
  nanString: 'NaN',
183
241
  });
@@ -196,7 +254,11 @@ URL-encoded JSON. No configuration.
196
254
  Implement `QueryStringFormat`:
197
255
 
198
256
  ```ts
199
- import type { QueryStringFormat, QueryStringParams, ParseContext } from 'zustand-querystring';
257
+ import type {
258
+ QueryStringFormat,
259
+ QueryStringParams,
260
+ ParseContext,
261
+ } from 'zustand-querystring';
200
262
 
201
263
  const myFormat: QueryStringFormat = {
202
264
  // For key: 'state' (namespaced mode)
@@ -224,10 +286,11 @@ const myFormat: QueryStringFormat = {
224
286
  },
225
287
  };
226
288
 
227
- querystring(store, { format: myFormat })
289
+ querystring(store, { format: myFormat });
228
290
  ```
229
291
 
230
292
  Types:
293
+
231
294
  - `QueryStringParams` = `Record<string, string[]>` (values always arrays)
232
295
  - `ctx.initialState` available for type coercion
233
296
 
@@ -240,14 +303,14 @@ Types:
240
303
  ```ts
241
304
  const useStore = create(
242
305
  querystring(
243
- (set) => ({
306
+ set => ({
244
307
  query: '',
245
308
  page: 1,
246
- setQuery: (query) => set({ query, page: 1 }), // reset page on new query
247
- setPage: (page) => set({ page }),
309
+ setQuery: query => set({ query, page: 1 }), // reset page on new query
310
+ setPage: page => set({ page }),
248
311
  }),
249
- { select: () => ({ query: true, page: true }) }
250
- )
312
+ { select: () => ({ query: true, page: true }) },
313
+ ),
251
314
  );
252
315
  ```
253
316
 
@@ -258,14 +321,14 @@ const useFilters = create(
258
321
  querystring(filtersStore, {
259
322
  prefix: 'f_',
260
323
  select: () => ({ category: true, price: true }),
261
- })
324
+ }),
262
325
  );
263
326
 
264
327
  const usePagination = create(
265
328
  querystring(paginationStore, {
266
329
  prefix: 'p_',
267
330
  select: () => ({ page: true, limit: true }),
268
- })
331
+ }),
269
332
  );
270
333
  // URL: ?f_category=shoes&f_price=100&p_page=2&p_limit=20
271
334
  ```
@@ -295,6 +358,7 @@ import { json } from 'zustand-querystring/format/json';
295
358
  // Types
296
359
  import type {
297
360
  QueryStringOptions,
361
+ QueryStringMap,
298
362
  QueryStringFormat,
299
363
  QueryStringParams,
300
364
  ParseContext,
@@ -38,7 +38,9 @@ function validateOptions(opts) {
38
38
  seen.add(token);
39
39
  }
40
40
  if (seen.has(opts.datePrefix)) {
41
- throw new Error(`datePrefix '${opts.datePrefix}' conflicts with another token`);
41
+ throw new Error(
42
+ `datePrefix '${opts.datePrefix}' conflicts with another token`
43
+ );
42
44
  }
43
45
  }
44
46
  function escapeRegex(str) {
@@ -50,7 +52,9 @@ function buildKeyStopPattern(opts) {
50
52
  );
51
53
  }
52
54
  function buildValueStopPattern(opts) {
53
- return new RegExp(`[${escapeRegex(opts.separator)}${escapeRegex(opts.terminator)}]`);
55
+ return new RegExp(
56
+ `[${escapeRegex(opts.separator)}${escapeRegex(opts.terminator)}]`
57
+ );
54
58
  }
55
59
  function buildKeyEscapePattern(opts) {
56
60
  return new RegExp(
@@ -59,7 +63,10 @@ function buildKeyEscapePattern(opts) {
59
63
  );
60
64
  }
61
65
  function buildValueEscapePattern(opts) {
62
- return new RegExp(`([${escapeRegex(opts.separator)}${escapeRegex(opts.terminator)}])`, "g");
66
+ return new RegExp(
67
+ `([${escapeRegex(opts.separator)}${escapeRegex(opts.terminator)}])`,
68
+ "g"
69
+ );
63
70
  }
64
71
  function buildDatePattern(opts) {
65
72
  return new RegExp(`^${escapeRegex(opts.datePrefix)}-?\\d+$`);
@@ -94,7 +101,9 @@ function cleanResult(str, standalone, opts) {
94
101
  while (str.endsWith(opts.terminator)) {
95
102
  str = str.slice(0, -opts.terminator.length);
96
103
  }
97
- const datePattern = new RegExp(`^${escapeRegex(opts.typeString)}${escapeRegex(opts.datePrefix)}-?\\d+$`);
104
+ const datePattern = new RegExp(
105
+ `^${escapeRegex(opts.typeString)}${escapeRegex(opts.datePrefix)}-?\\d+$`
106
+ );
98
107
  if (standalone && datePattern.test(str)) {
99
108
  return str.slice(opts.typeString.length);
100
109
  }
@@ -312,7 +321,12 @@ function parse(input, standalone = false, options = {}) {
312
321
  }
313
322
  return result;
314
323
  } else {
315
- const escapedMarkers = [opts.typeString, opts.typePrimitive, opts.typeArray, opts.typeObject].map(escapeRegex).join("");
324
+ const escapedMarkers = [
325
+ opts.typeString,
326
+ opts.typePrimitive,
327
+ opts.typeArray,
328
+ opts.typeObject
329
+ ].map(escapeRegex).join("");
316
330
  const escapedEscape = escapeRegex(opts.escape);
317
331
  const escapedSeparator = escapeRegex(opts.separator);
318
332
  const escapedTerminator = escapeRegex(opts.terminator);
@@ -65,7 +65,9 @@ function validateOptions(opts) {
65
65
  seen.add(token);
66
66
  }
67
67
  if (seen.has(opts.datePrefix)) {
68
- throw new Error(`datePrefix '${opts.datePrefix}' conflicts with another token`);
68
+ throw new Error(
69
+ `datePrefix '${opts.datePrefix}' conflicts with another token`
70
+ );
69
71
  }
70
72
  }
71
73
  function escapeRegex(str) {
@@ -77,7 +79,9 @@ function buildKeyStopPattern(opts) {
77
79
  );
78
80
  }
79
81
  function buildValueStopPattern(opts) {
80
- return new RegExp(`[${escapeRegex(opts.separator)}${escapeRegex(opts.terminator)}]`);
82
+ return new RegExp(
83
+ `[${escapeRegex(opts.separator)}${escapeRegex(opts.terminator)}]`
84
+ );
81
85
  }
82
86
  function buildKeyEscapePattern(opts) {
83
87
  return new RegExp(
@@ -86,7 +90,10 @@ function buildKeyEscapePattern(opts) {
86
90
  );
87
91
  }
88
92
  function buildValueEscapePattern(opts) {
89
- return new RegExp(`([${escapeRegex(opts.separator)}${escapeRegex(opts.terminator)}])`, "g");
93
+ return new RegExp(
94
+ `([${escapeRegex(opts.separator)}${escapeRegex(opts.terminator)}])`,
95
+ "g"
96
+ );
90
97
  }
91
98
  function buildDatePattern(opts) {
92
99
  return new RegExp(`^${escapeRegex(opts.datePrefix)}-?\\d+$`);
@@ -121,7 +128,9 @@ function cleanResult(str, standalone, opts) {
121
128
  while (str.endsWith(opts.terminator)) {
122
129
  str = str.slice(0, -opts.terminator.length);
123
130
  }
124
- const datePattern = new RegExp(`^${escapeRegex(opts.typeString)}${escapeRegex(opts.datePrefix)}-?\\d+$`);
131
+ const datePattern = new RegExp(
132
+ `^${escapeRegex(opts.typeString)}${escapeRegex(opts.datePrefix)}-?\\d+$`
133
+ );
125
134
  if (standalone && datePattern.test(str)) {
126
135
  return str.slice(opts.typeString.length);
127
136
  }
@@ -339,7 +348,12 @@ function parse(input, standalone = false, options = {}) {
339
348
  }
340
349
  return result;
341
350
  } else {
342
- const escapedMarkers = [opts.typeString, opts.typePrimitive, opts.typeArray, opts.typeObject].map(escapeRegex).join("");
351
+ const escapedMarkers = [
352
+ opts.typeString,
353
+ opts.typePrimitive,
354
+ opts.typeArray,
355
+ opts.typeObject
356
+ ].map(escapeRegex).join("");
343
357
  const escapedEscape = escapeRegex(opts.escape);
344
358
  const escapedSeparator = escapeRegex(opts.separator);
345
359
  const escapedTerminator = escapeRegex(opts.terminator);
@@ -4,7 +4,7 @@ import {
4
4
  marked_default,
5
5
  parse,
6
6
  stringify
7
- } from "../chunk-TQQUWVLF.mjs";
7
+ } from "../chunk-M3ILBO2T.mjs";
8
8
  export {
9
9
  createFormat,
10
10
  marked_default as default,
@@ -40,16 +40,24 @@ function resolveOptions(opts = {}) {
40
40
  function validateOptions(opts) {
41
41
  const { entrySep, nestingSep, arraySep, escape: escape2 } = opts;
42
42
  if (entrySep === nestingSep) {
43
- throw new Error(`entrySeparator and nestingSeparator cannot be the same: '${entrySep}'`);
43
+ throw new Error(
44
+ `entrySeparator and nestingSeparator cannot be the same: '${entrySep}'`
45
+ );
44
46
  }
45
47
  if (escape2 === entrySep || escape2 === nestingSep) {
46
- throw new Error(`escapeChar cannot be the same as a separator: '${escape2}'`);
48
+ throw new Error(
49
+ `escapeChar cannot be the same as a separator: '${escape2}'`
50
+ );
47
51
  }
48
52
  if (arraySep !== "repeat" && arraySep === nestingSep) {
49
- throw new Error(`arraySeparator cannot be the same as nestingSeparator: '${arraySep}'`);
53
+ throw new Error(
54
+ `arraySeparator cannot be the same as nestingSeparator: '${arraySep}'`
55
+ );
50
56
  }
51
57
  if (arraySep !== "repeat" && arraySep === escape2) {
52
- throw new Error(`arraySeparator cannot be the same as escapeChar: '${arraySep}'`);
58
+ throw new Error(
59
+ `arraySeparator cannot be the same as escapeChar: '${arraySep}'`
60
+ );
53
61
  }
54
62
  if (entrySep.length === 0 || nestingSep.length === 0 || escape2.length === 0) {
55
63
  throw new Error("Separators and escape character cannot be empty");
@@ -59,12 +67,7 @@ function validateOptions(opts) {
59
67
  }
60
68
  }
61
69
  function encodePreservingMarkers(str, opts) {
62
- const markers = /* @__PURE__ */ new Set([
63
- opts.entrySep,
64
- opts.nestingSep,
65
- opts.escape,
66
- "="
67
- ]);
70
+ const markers = /* @__PURE__ */ new Set([opts.entrySep, opts.nestingSep, opts.escape, "="]);
68
71
  if (opts.arraySep !== "repeat") {
69
72
  markers.add(opts.arraySep);
70
73
  }
@@ -180,7 +183,9 @@ function unescape(str, esc, chars) {
180
183
  while (i < str.length) {
181
184
  if (str.substring(i, i + esc.length) === esc) {
182
185
  const nextPos = i + esc.length;
183
- const isEscapeSequence = chars.some((c) => str.substring(nextPos, nextPos + c.length) === c);
186
+ const isEscapeSequence = chars.some(
187
+ (c) => str.substring(nextPos, nextPos + c.length) === c
188
+ );
184
189
  if (isEscapeSequence && nextPos < str.length) {
185
190
  i = nextPos;
186
191
  for (const special of chars) {
@@ -311,7 +316,11 @@ function flatten(obj, prefix, opts) {
311
316
  Object.assign(result, flatten(item, indexedKey, opts));
312
317
  } else {
313
318
  const serialized = serializeValue(item, opts);
314
- const escapedValue = escape(serialized, valueEscapeChars, opts.escape);
319
+ const escapedValue = escape(
320
+ serialized,
321
+ valueEscapeChars,
322
+ opts.escape
323
+ );
315
324
  result[indexedKey] = [escapedValue];
316
325
  }
317
326
  }
@@ -363,7 +372,11 @@ function unflatten(entries, initialState, opts) {
363
372
  continue;
364
373
  }
365
374
  const elementHint = isArrayHint ? hint[0] : void 0;
366
- setAtPath(result, path, values.map((v) => parseValue(v, elementHint, opts)));
375
+ setAtPath(
376
+ result,
377
+ path,
378
+ values.map((v) => parseValue(v, elementHint, opts))
379
+ );
367
380
  }
368
381
  return result;
369
382
  }
@@ -399,10 +412,14 @@ function stringifyNamespaced(state, opts) {
399
412
  for (const [key, values] of Object.entries(entries)) {
400
413
  if (opts.arraySep === "repeat") {
401
414
  for (const value of values) {
402
- parts.push(encodePreservingMarkers(key, opts) + "=" + encodePreservingMarkers(value, opts));
415
+ parts.push(
416
+ encodePreservingMarkers(key, opts) + "=" + encodePreservingMarkers(value, opts)
417
+ );
403
418
  }
404
419
  } else {
405
- parts.push(encodePreservingMarkers(key, opts) + "=" + encodePreservingMarkers(values.join(opts.arraySep), opts));
420
+ parts.push(
421
+ encodePreservingMarkers(key, opts) + "=" + encodePreservingMarkers(values.join(opts.arraySep), opts)
422
+ );
406
423
  }
407
424
  }
408
425
  return parts.join(opts.entrySep);
@@ -16,16 +16,24 @@ function resolveOptions(opts = {}) {
16
16
  function validateOptions(opts) {
17
17
  const { entrySep, nestingSep, arraySep, escape: escape2 } = opts;
18
18
  if (entrySep === nestingSep) {
19
- throw new Error(`entrySeparator and nestingSeparator cannot be the same: '${entrySep}'`);
19
+ throw new Error(
20
+ `entrySeparator and nestingSeparator cannot be the same: '${entrySep}'`
21
+ );
20
22
  }
21
23
  if (escape2 === entrySep || escape2 === nestingSep) {
22
- throw new Error(`escapeChar cannot be the same as a separator: '${escape2}'`);
24
+ throw new Error(
25
+ `escapeChar cannot be the same as a separator: '${escape2}'`
26
+ );
23
27
  }
24
28
  if (arraySep !== "repeat" && arraySep === nestingSep) {
25
- throw new Error(`arraySeparator cannot be the same as nestingSeparator: '${arraySep}'`);
29
+ throw new Error(
30
+ `arraySeparator cannot be the same as nestingSeparator: '${arraySep}'`
31
+ );
26
32
  }
27
33
  if (arraySep !== "repeat" && arraySep === escape2) {
28
- throw new Error(`arraySeparator cannot be the same as escapeChar: '${arraySep}'`);
34
+ throw new Error(
35
+ `arraySeparator cannot be the same as escapeChar: '${arraySep}'`
36
+ );
29
37
  }
30
38
  if (entrySep.length === 0 || nestingSep.length === 0 || escape2.length === 0) {
31
39
  throw new Error("Separators and escape character cannot be empty");
@@ -35,12 +43,7 @@ function validateOptions(opts) {
35
43
  }
36
44
  }
37
45
  function encodePreservingMarkers(str, opts) {
38
- const markers = /* @__PURE__ */ new Set([
39
- opts.entrySep,
40
- opts.nestingSep,
41
- opts.escape,
42
- "="
43
- ]);
46
+ const markers = /* @__PURE__ */ new Set([opts.entrySep, opts.nestingSep, opts.escape, "="]);
44
47
  if (opts.arraySep !== "repeat") {
45
48
  markers.add(opts.arraySep);
46
49
  }
@@ -156,7 +159,9 @@ function unescape(str, esc, chars) {
156
159
  while (i < str.length) {
157
160
  if (str.substring(i, i + esc.length) === esc) {
158
161
  const nextPos = i + esc.length;
159
- const isEscapeSequence = chars.some((c) => str.substring(nextPos, nextPos + c.length) === c);
162
+ const isEscapeSequence = chars.some(
163
+ (c) => str.substring(nextPos, nextPos + c.length) === c
164
+ );
160
165
  if (isEscapeSequence && nextPos < str.length) {
161
166
  i = nextPos;
162
167
  for (const special of chars) {
@@ -287,7 +292,11 @@ function flatten(obj, prefix, opts) {
287
292
  Object.assign(result, flatten(item, indexedKey, opts));
288
293
  } else {
289
294
  const serialized = serializeValue(item, opts);
290
- const escapedValue = escape(serialized, valueEscapeChars, opts.escape);
295
+ const escapedValue = escape(
296
+ serialized,
297
+ valueEscapeChars,
298
+ opts.escape
299
+ );
291
300
  result[indexedKey] = [escapedValue];
292
301
  }
293
302
  }
@@ -339,7 +348,11 @@ function unflatten(entries, initialState, opts) {
339
348
  continue;
340
349
  }
341
350
  const elementHint = isArrayHint ? hint[0] : void 0;
342
- setAtPath(result, path, values.map((v) => parseValue(v, elementHint, opts)));
351
+ setAtPath(
352
+ result,
353
+ path,
354
+ values.map((v) => parseValue(v, elementHint, opts))
355
+ );
343
356
  }
344
357
  return result;
345
358
  }
@@ -375,10 +388,14 @@ function stringifyNamespaced(state, opts) {
375
388
  for (const [key, values] of Object.entries(entries)) {
376
389
  if (opts.arraySep === "repeat") {
377
390
  for (const value of values) {
378
- parts.push(encodePreservingMarkers(key, opts) + "=" + encodePreservingMarkers(value, opts));
391
+ parts.push(
392
+ encodePreservingMarkers(key, opts) + "=" + encodePreservingMarkers(value, opts)
393
+ );
379
394
  }
380
395
  } else {
381
- parts.push(encodePreservingMarkers(key, opts) + "=" + encodePreservingMarkers(values.join(opts.arraySep), opts));
396
+ parts.push(
397
+ encodePreservingMarkers(key, opts) + "=" + encodePreservingMarkers(values.join(opts.arraySep), opts)
398
+ );
382
399
  }
383
400
  }
384
401
  return parts.join(opts.entrySep);
package/dist/index.d.mts CHANGED
@@ -3,6 +3,10 @@ import { StoreMutatorIdentifier, StateCreator } from 'zustand/vanilla';
3
3
  type DeepSelect<T> = T extends object ? {
4
4
  [P in keyof T]?: DeepSelect<T[P]> | boolean;
5
5
  } : boolean;
6
+ /** Maps T to only the fields marked as selected in S. `false` excludes, everything else includes. */
7
+ type ApplySelect<T, S> = [S] extends [false] ? never : S extends object ? T extends object ? {
8
+ [P in keyof S & keyof T as [ApplySelect<T[P], S[P]>] extends [never] ? never : P]: ApplySelect<T[P], S[P]>;
9
+ } : never : T;
6
10
  interface QueryStringParam {
7
11
  key: string;
8
12
  value: string | string[];
@@ -32,16 +36,28 @@ interface QueryStringFormatStandalone {
32
36
  type QueryStringFormat = QueryStringFormatNamespaced & QueryStringFormatStandalone;
33
37
  /** Conditional format type based on key option */
34
38
  type QueryStringFormatFor<K extends string | false> = K extends false ? QueryStringFormatStandalone : QueryStringFormatNamespaced;
35
- interface QueryStringOptions<T, K extends string | false = string | false> {
39
+ /**
40
+ * Bidirectional mapping between store state and URL state.
41
+ * `to` runs before serialization (store → URL), `from` after deserialization (URL → store).
42
+ * S is inferred from `select` via `ApplySelect` — only selected fields are visible.
43
+ * U is inferred from `to`'s return type and flows into `from`'s parameter.
44
+ */
45
+ interface QueryStringMap<S, U extends object = Record<string, unknown>> {
46
+ to: (state: S, pathname: string) => U;
47
+ from: (urlState: U, pathname: string) => Partial<S>;
48
+ }
49
+ interface QueryStringOptions<T, K extends string | false = string | false, S extends DeepSelect<T> = DeepSelect<T>, U extends object = Record<string, unknown>> {
36
50
  url?: string;
37
- select?: (pathname: string) => DeepSelect<T>;
51
+ select?: (pathname: string) => S;
38
52
  key?: K;
39
53
  prefix?: string;
40
54
  format?: QueryStringFormatFor<K>;
41
55
  syncNull?: boolean;
42
56
  syncUndefined?: boolean;
57
+ /** Bidirectional mapping between store state shape and URL state shape. */
58
+ map?: QueryStringMap<ApplySelect<T, S>, U>;
43
59
  }
44
- type QueryString = <T, Mps extends [StoreMutatorIdentifier, unknown][] = [], Mcs extends [StoreMutatorIdentifier, unknown][] = []>(initializer: StateCreator<T, Mps, Mcs>, options?: QueryStringOptions<T>) => StateCreator<T, Mps, Mcs>;
60
+ type QueryString = <T, Mps extends [StoreMutatorIdentifier, unknown][] = [], Mcs extends [StoreMutatorIdentifier, unknown][] = [], const S extends DeepSelect<T> = DeepSelect<T>, U extends object = Record<string, unknown>>(initializer: StateCreator<T, Mps, Mcs>, options?: QueryStringOptions<T, string | false, S, U>) => StateCreator<T, Mps, Mcs>;
45
61
  declare const querystring: QueryString;
46
62
 
47
- export { type ParseContext, type QueryStringFormat, type QueryStringFormatFor, type QueryStringFormatNamespaced, type QueryStringFormatStandalone, type QueryStringOptions, type QueryStringParam, type QueryStringParams, querystring };
63
+ export { type ParseContext, type QueryStringFormat, type QueryStringFormatFor, type QueryStringFormatNamespaced, type QueryStringFormatStandalone, type QueryStringMap, type QueryStringOptions, type QueryStringParam, type QueryStringParams, querystring };
package/dist/index.d.ts CHANGED
@@ -3,6 +3,10 @@ import { StoreMutatorIdentifier, StateCreator } from 'zustand/vanilla';
3
3
  type DeepSelect<T> = T extends object ? {
4
4
  [P in keyof T]?: DeepSelect<T[P]> | boolean;
5
5
  } : boolean;
6
+ /** Maps T to only the fields marked as selected in S. `false` excludes, everything else includes. */
7
+ type ApplySelect<T, S> = [S] extends [false] ? never : S extends object ? T extends object ? {
8
+ [P in keyof S & keyof T as [ApplySelect<T[P], S[P]>] extends [never] ? never : P]: ApplySelect<T[P], S[P]>;
9
+ } : never : T;
6
10
  interface QueryStringParam {
7
11
  key: string;
8
12
  value: string | string[];
@@ -32,16 +36,28 @@ interface QueryStringFormatStandalone {
32
36
  type QueryStringFormat = QueryStringFormatNamespaced & QueryStringFormatStandalone;
33
37
  /** Conditional format type based on key option */
34
38
  type QueryStringFormatFor<K extends string | false> = K extends false ? QueryStringFormatStandalone : QueryStringFormatNamespaced;
35
- interface QueryStringOptions<T, K extends string | false = string | false> {
39
+ /**
40
+ * Bidirectional mapping between store state and URL state.
41
+ * `to` runs before serialization (store → URL), `from` after deserialization (URL → store).
42
+ * S is inferred from `select` via `ApplySelect` — only selected fields are visible.
43
+ * U is inferred from `to`'s return type and flows into `from`'s parameter.
44
+ */
45
+ interface QueryStringMap<S, U extends object = Record<string, unknown>> {
46
+ to: (state: S, pathname: string) => U;
47
+ from: (urlState: U, pathname: string) => Partial<S>;
48
+ }
49
+ interface QueryStringOptions<T, K extends string | false = string | false, S extends DeepSelect<T> = DeepSelect<T>, U extends object = Record<string, unknown>> {
36
50
  url?: string;
37
- select?: (pathname: string) => DeepSelect<T>;
51
+ select?: (pathname: string) => S;
38
52
  key?: K;
39
53
  prefix?: string;
40
54
  format?: QueryStringFormatFor<K>;
41
55
  syncNull?: boolean;
42
56
  syncUndefined?: boolean;
57
+ /** Bidirectional mapping between store state shape and URL state shape. */
58
+ map?: QueryStringMap<ApplySelect<T, S>, U>;
43
59
  }
44
- type QueryString = <T, Mps extends [StoreMutatorIdentifier, unknown][] = [], Mcs extends [StoreMutatorIdentifier, unknown][] = []>(initializer: StateCreator<T, Mps, Mcs>, options?: QueryStringOptions<T>) => StateCreator<T, Mps, Mcs>;
60
+ type QueryString = <T, Mps extends [StoreMutatorIdentifier, unknown][] = [], Mcs extends [StoreMutatorIdentifier, unknown][] = [], const S extends DeepSelect<T> = DeepSelect<T>, U extends object = Record<string, unknown>>(initializer: StateCreator<T, Mps, Mcs>, options?: QueryStringOptions<T, string | false, S, U>) => StateCreator<T, Mps, Mcs>;
45
61
  declare const querystring: QueryString;
46
62
 
47
- export { type ParseContext, type QueryStringFormat, type QueryStringFormatFor, type QueryStringFormatNamespaced, type QueryStringFormatStandalone, type QueryStringOptions, type QueryStringParam, type QueryStringParams, querystring };
63
+ export { type ParseContext, type QueryStringFormat, type QueryStringFormatFor, type QueryStringFormatNamespaced, type QueryStringFormatStandalone, type QueryStringMap, type QueryStringOptions, type QueryStringParam, type QueryStringParams, querystring };
package/dist/index.js CHANGED
@@ -66,7 +66,9 @@ function validateOptions(opts) {
66
66
  seen.add(token);
67
67
  }
68
68
  if (seen.has(opts.datePrefix)) {
69
- throw new Error(`datePrefix '${opts.datePrefix}' conflicts with another token`);
69
+ throw new Error(
70
+ `datePrefix '${opts.datePrefix}' conflicts with another token`
71
+ );
70
72
  }
71
73
  }
72
74
  function escapeRegex(str) {
@@ -78,7 +80,9 @@ function buildKeyStopPattern(opts) {
78
80
  );
79
81
  }
80
82
  function buildValueStopPattern(opts) {
81
- return new RegExp(`[${escapeRegex(opts.separator)}${escapeRegex(opts.terminator)}]`);
83
+ return new RegExp(
84
+ `[${escapeRegex(opts.separator)}${escapeRegex(opts.terminator)}]`
85
+ );
82
86
  }
83
87
  function buildKeyEscapePattern(opts) {
84
88
  return new RegExp(
@@ -87,7 +91,10 @@ function buildKeyEscapePattern(opts) {
87
91
  );
88
92
  }
89
93
  function buildValueEscapePattern(opts) {
90
- return new RegExp(`([${escapeRegex(opts.separator)}${escapeRegex(opts.terminator)}])`, "g");
94
+ return new RegExp(
95
+ `([${escapeRegex(opts.separator)}${escapeRegex(opts.terminator)}])`,
96
+ "g"
97
+ );
91
98
  }
92
99
  function buildDatePattern(opts) {
93
100
  return new RegExp(`^${escapeRegex(opts.datePrefix)}-?\\d+$`);
@@ -122,7 +129,9 @@ function cleanResult(str, standalone, opts) {
122
129
  while (str.endsWith(opts.terminator)) {
123
130
  str = str.slice(0, -opts.terminator.length);
124
131
  }
125
- const datePattern = new RegExp(`^${escapeRegex(opts.typeString)}${escapeRegex(opts.datePrefix)}-?\\d+$`);
132
+ const datePattern = new RegExp(
133
+ `^${escapeRegex(opts.typeString)}${escapeRegex(opts.datePrefix)}-?\\d+$`
134
+ );
126
135
  if (standalone && datePattern.test(str)) {
127
136
  return str.slice(opts.typeString.length);
128
137
  }
@@ -340,7 +349,12 @@ function parse(input, standalone = false, options = {}) {
340
349
  }
341
350
  return result;
342
351
  } else {
343
- const escapedMarkers = [opts.typeString, opts.typePrimitive, opts.typeArray, opts.typeObject].map(escapeRegex).join("");
352
+ const escapedMarkers = [
353
+ opts.typeString,
354
+ opts.typePrimitive,
355
+ opts.typeArray,
356
+ opts.typeObject
357
+ ].map(escapeRegex).join("");
344
358
  const escapedEscape = escapeRegex(opts.escape);
345
359
  const escapedSeparator = escapeRegex(opts.separator);
346
360
  const escapedTerminator = escapeRegex(opts.terminator);
@@ -487,13 +501,25 @@ var queryStringImpl = (fn, options) => (set, get, api) => {
487
501
  }
488
502
  return state != null ? state : {};
489
503
  };
504
+ const map = defaultedOptions.map;
505
+ const applyMapTo = (state, pathname) => {
506
+ return map ? map.to(state, pathname) : state;
507
+ };
508
+ const applyMapFrom = (urlState, pathname) => {
509
+ return map ? map.from(urlState, pathname) : urlState;
510
+ };
490
511
  const initialize = (url, initialState) => {
491
512
  try {
492
- const stateFromUrl = getStateFromUrl(url, initialState);
513
+ const mappedInitial = applyMapTo(
514
+ getSelectedState(initialState, url.pathname),
515
+ url.pathname
516
+ );
517
+ const stateFromUrl = getStateFromUrl(url, mappedInitial);
493
518
  if (!stateFromUrl) {
494
519
  return initialState;
495
520
  }
496
- const selected = getSelectedState(stateFromUrl, url.pathname);
521
+ const storeState = applyMapFrom(stateFromUrl, url.pathname);
522
+ const selected = getSelectedState(storeState, url.pathname);
497
523
  const merged = (0, import_lodash_es.mergeWith)(
498
524
  {},
499
525
  initialState,
@@ -521,12 +547,20 @@ var queryStringImpl = (fn, options) => (set, get, api) => {
521
547
  api
522
548
  );
523
549
  let previouslyManagedKeys = /* @__PURE__ */ new Set();
550
+ const getMappedInitial = (pathname) => {
551
+ if (defaultedOptions.map) {
552
+ return applyMapTo(getSelectedState(initialState, pathname), pathname);
553
+ }
554
+ return initialState;
555
+ };
524
556
  const setQuery = () => {
525
557
  const url = new URL(window.location.href);
526
558
  const selectedState = getSelectedState(get(), url.pathname);
559
+ const stateForUrl = applyMapTo(selectedState, url.pathname);
560
+ const mappedInitial = getMappedInitial(url.pathname);
527
561
  const { output: newCompacted } = compact(
528
- selectedState,
529
- initialState,
562
+ stateForUrl,
563
+ mappedInitial,
530
564
  defaultedOptions.syncNull,
531
565
  defaultedOptions.syncUndefined
532
566
  );
@@ -534,9 +568,14 @@ var queryStringImpl = (fn, options) => (set, get, api) => {
534
568
  let stateParams;
535
569
  let managedKeys;
536
570
  if (standalone) {
537
- const allParams = format.stringifyStandalone(selectedState);
538
- const currentKeys = new Set(Object.keys(allParams).map((k) => defaultedOptions.prefix + k));
539
- managedKeys = /* @__PURE__ */ new Set([...Array.from(currentKeys), ...Array.from(previouslyManagedKeys)]);
571
+ const allParams = format.stringifyStandalone(stateForUrl);
572
+ const currentKeys = new Set(
573
+ Object.keys(allParams).map((k) => defaultedOptions.prefix + k)
574
+ );
575
+ managedKeys = /* @__PURE__ */ new Set([
576
+ ...Array.from(currentKeys),
577
+ ...Array.from(previouslyManagedKeys)
578
+ ]);
540
579
  previouslyManagedKeys = currentKeys;
541
580
  const compactedParams = format.stringifyStandalone(newCompacted);
542
581
  stateParams = {};
@@ -604,9 +643,14 @@ var queryStringImpl = (fn, options) => (set, get, api) => {
604
643
  };
605
644
  const initialized = initialize(new URL(window.location.href), initialState);
606
645
  if (standalone) {
607
- const initSelected = getSelectedState(initialized, new URL(window.location.href).pathname);
646
+ const initSelected = getSelectedState(
647
+ initialized,
648
+ new URL(window.location.href).pathname
649
+ );
608
650
  const initParams = format.stringifyStandalone(initSelected);
609
- previouslyManagedKeys = new Set(Object.keys(initParams).map((k) => defaultedOptions.prefix + k));
651
+ previouslyManagedKeys = new Set(
652
+ Object.keys(initParams).map((k) => defaultedOptions.prefix + k)
653
+ );
610
654
  }
611
655
  api.getInitialState = () => initialized;
612
656
  return initialized;
package/dist/index.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  marked
3
- } from "./chunk-TQQUWVLF.mjs";
3
+ } from "./chunk-M3ILBO2T.mjs";
4
4
 
5
5
  // src/middleware.ts
6
6
  import { isEqual, mergeWith } from "lodash-es";
@@ -108,13 +108,25 @@ var queryStringImpl = (fn, options) => (set, get, api) => {
108
108
  }
109
109
  return state != null ? state : {};
110
110
  };
111
+ const map = defaultedOptions.map;
112
+ const applyMapTo = (state, pathname) => {
113
+ return map ? map.to(state, pathname) : state;
114
+ };
115
+ const applyMapFrom = (urlState, pathname) => {
116
+ return map ? map.from(urlState, pathname) : urlState;
117
+ };
111
118
  const initialize = (url, initialState) => {
112
119
  try {
113
- const stateFromUrl = getStateFromUrl(url, initialState);
120
+ const mappedInitial = applyMapTo(
121
+ getSelectedState(initialState, url.pathname),
122
+ url.pathname
123
+ );
124
+ const stateFromUrl = getStateFromUrl(url, mappedInitial);
114
125
  if (!stateFromUrl) {
115
126
  return initialState;
116
127
  }
117
- const selected = getSelectedState(stateFromUrl, url.pathname);
128
+ const storeState = applyMapFrom(stateFromUrl, url.pathname);
129
+ const selected = getSelectedState(storeState, url.pathname);
118
130
  const merged = mergeWith(
119
131
  {},
120
132
  initialState,
@@ -142,12 +154,20 @@ var queryStringImpl = (fn, options) => (set, get, api) => {
142
154
  api
143
155
  );
144
156
  let previouslyManagedKeys = /* @__PURE__ */ new Set();
157
+ const getMappedInitial = (pathname) => {
158
+ if (defaultedOptions.map) {
159
+ return applyMapTo(getSelectedState(initialState, pathname), pathname);
160
+ }
161
+ return initialState;
162
+ };
145
163
  const setQuery = () => {
146
164
  const url = new URL(window.location.href);
147
165
  const selectedState = getSelectedState(get(), url.pathname);
166
+ const stateForUrl = applyMapTo(selectedState, url.pathname);
167
+ const mappedInitial = getMappedInitial(url.pathname);
148
168
  const { output: newCompacted } = compact(
149
- selectedState,
150
- initialState,
169
+ stateForUrl,
170
+ mappedInitial,
151
171
  defaultedOptions.syncNull,
152
172
  defaultedOptions.syncUndefined
153
173
  );
@@ -155,9 +175,14 @@ var queryStringImpl = (fn, options) => (set, get, api) => {
155
175
  let stateParams;
156
176
  let managedKeys;
157
177
  if (standalone) {
158
- const allParams = format.stringifyStandalone(selectedState);
159
- const currentKeys = new Set(Object.keys(allParams).map((k) => defaultedOptions.prefix + k));
160
- managedKeys = /* @__PURE__ */ new Set([...Array.from(currentKeys), ...Array.from(previouslyManagedKeys)]);
178
+ const allParams = format.stringifyStandalone(stateForUrl);
179
+ const currentKeys = new Set(
180
+ Object.keys(allParams).map((k) => defaultedOptions.prefix + k)
181
+ );
182
+ managedKeys = /* @__PURE__ */ new Set([
183
+ ...Array.from(currentKeys),
184
+ ...Array.from(previouslyManagedKeys)
185
+ ]);
161
186
  previouslyManagedKeys = currentKeys;
162
187
  const compactedParams = format.stringifyStandalone(newCompacted);
163
188
  stateParams = {};
@@ -225,9 +250,14 @@ var queryStringImpl = (fn, options) => (set, get, api) => {
225
250
  };
226
251
  const initialized = initialize(new URL(window.location.href), initialState);
227
252
  if (standalone) {
228
- const initSelected = getSelectedState(initialized, new URL(window.location.href).pathname);
253
+ const initSelected = getSelectedState(
254
+ initialized,
255
+ new URL(window.location.href).pathname
256
+ );
229
257
  const initParams = format.stringifyStandalone(initSelected);
230
- previouslyManagedKeys = new Set(Object.keys(initParams).map((k) => defaultedOptions.prefix + k));
258
+ previouslyManagedKeys = new Set(
259
+ Object.keys(initParams).map((k) => defaultedOptions.prefix + k)
260
+ );
231
261
  }
232
262
  api.getInitialState = () => initialized;
233
263
  return initialized;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zustand-querystring",
3
- "version": "0.6.1",
3
+ "version": "0.6.2",
4
4
  "license": "MIT",
5
5
  "main": "dist/index.js",
6
6
  "module": "dist/index.mjs",
@@ -13,12 +13,6 @@
13
13
  "type": "github",
14
14
  "url": "https://github.com/nitedani/zustand-querystring"
15
15
  },
16
- "scripts": {
17
- "build": "tsup",
18
- "dev": "tsup --watch",
19
- "test": "vitest run",
20
- "test:watch": "vitest"
21
- },
22
16
  "exports": {
23
17
  ".": {
24
18
  "types": "./dist/index.d.ts",
@@ -98,5 +92,11 @@
98
92
  "vitest": "^4.0.12",
99
93
  "vitest-browser-react": "^2.0.2",
100
94
  "zustand": "^5.0.3"
95
+ },
96
+ "scripts": {
97
+ "build": "tsup",
98
+ "dev": "tsup --watch",
99
+ "test": "vitest run",
100
+ "test:watch": "vitest"
101
101
  }
102
- }
102
+ }