@pyreon/url-state 0.11.0
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/LICENSE +21 -0
- package/README.md +42 -0
- package/lib/analysis/index.js.html +5406 -0
- package/lib/index.js +195 -0
- package/lib/index.js.map +1 -0
- package/lib/types/index.d.ts +88 -0
- package/lib/types/index.d.ts.map +1 -0
- package/package.json +51 -0
- package/src/index.ts +38 -0
- package/src/serializers.ts +49 -0
- package/src/tests/url-state.test.ts +566 -0
- package/src/types.ts +54 -0
- package/src/url.ts +99 -0
- package/src/use-url-state.ts +193 -0
package/lib/index.js
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import { effect, onCleanup, signal } from "@pyreon/reactivity";
|
|
2
|
+
|
|
3
|
+
//#region src/url.ts
|
|
4
|
+
const _isBrowser = typeof window !== "undefined";
|
|
5
|
+
/** Read a search param from the current URL. Returns `null` if not present. */
|
|
6
|
+
function getParam(key) {
|
|
7
|
+
if (!_isBrowser) return null;
|
|
8
|
+
return new URLSearchParams(window.location.search).get(key);
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Read all values for a repeated param (e.g. `?tags=a&tags=b`).
|
|
12
|
+
* Returns an empty array if the param is not present.
|
|
13
|
+
*/
|
|
14
|
+
function getParamAll(key) {
|
|
15
|
+
if (!_isBrowser) return [];
|
|
16
|
+
return new URLSearchParams(window.location.search).getAll(key);
|
|
17
|
+
}
|
|
18
|
+
/** Module-level router reference. Set via `setUrlRouter()`. */
|
|
19
|
+
let _router = null;
|
|
20
|
+
/** Register a router to use for URL updates instead of the raw history API. */
|
|
21
|
+
function setUrlRouter(router) {
|
|
22
|
+
_router = router;
|
|
23
|
+
}
|
|
24
|
+
/** Write one or more search params to the URL without a full navigation. */
|
|
25
|
+
function setParams(entries, replace) {
|
|
26
|
+
if (!_isBrowser) return;
|
|
27
|
+
const params = new URLSearchParams(window.location.search);
|
|
28
|
+
for (const [key, value] of Object.entries(entries)) if (value === null) params.delete(key);
|
|
29
|
+
else params.set(key, value);
|
|
30
|
+
const search = params.toString();
|
|
31
|
+
const url = search ? `${window.location.pathname}?${search}` : window.location.pathname;
|
|
32
|
+
if (_router) {
|
|
33
|
+
_router.replace(url);
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
if (replace) history.replaceState(null, "", url);
|
|
37
|
+
else history.pushState(null, "", url);
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Write an array param using repeated keys (e.g. `?tags=a&tags=b`).
|
|
41
|
+
* When `values` is null the param is deleted.
|
|
42
|
+
*/
|
|
43
|
+
function setParamRepeated(key, values, replace) {
|
|
44
|
+
if (!_isBrowser) return;
|
|
45
|
+
const params = new URLSearchParams(window.location.search);
|
|
46
|
+
params.delete(key);
|
|
47
|
+
if (values !== null) for (const v of values) params.append(key, v);
|
|
48
|
+
const search = params.toString();
|
|
49
|
+
const url = search ? `${window.location.pathname}?${search}` : window.location.pathname;
|
|
50
|
+
if (_router) {
|
|
51
|
+
_router.replace(url);
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
if (replace) history.replaceState(null, "", url);
|
|
55
|
+
else history.pushState(null, "", url);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
//#endregion
|
|
59
|
+
//#region src/serializers.ts
|
|
60
|
+
/** Infer a serializer pair from the type of the default value. */
|
|
61
|
+
function inferSerializer(defaultValue, arrayFormat = "comma") {
|
|
62
|
+
if (Array.isArray(defaultValue)) {
|
|
63
|
+
if (arrayFormat === "repeat") return {
|
|
64
|
+
serialize: (v) => v.join("\0REPEAT\0"),
|
|
65
|
+
deserialize: (raw) => raw === "" ? [] : raw.split("\0REPEAT\0")
|
|
66
|
+
};
|
|
67
|
+
return {
|
|
68
|
+
serialize: (v) => v.join(","),
|
|
69
|
+
deserialize: (raw) => raw === "" ? [] : raw.split(",")
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
switch (typeof defaultValue) {
|
|
73
|
+
case "number": return {
|
|
74
|
+
serialize: (v) => String(v),
|
|
75
|
+
deserialize: (raw) => Number(raw)
|
|
76
|
+
};
|
|
77
|
+
case "boolean": return {
|
|
78
|
+
serialize: (v) => String(v),
|
|
79
|
+
deserialize: (raw) => raw === "true"
|
|
80
|
+
};
|
|
81
|
+
case "string": return {
|
|
82
|
+
serialize: (v) => v,
|
|
83
|
+
deserialize: (raw) => raw
|
|
84
|
+
};
|
|
85
|
+
case "object": return {
|
|
86
|
+
serialize: (v) => JSON.stringify(v),
|
|
87
|
+
deserialize: (raw) => JSON.parse(raw)
|
|
88
|
+
};
|
|
89
|
+
default: return {
|
|
90
|
+
serialize: (v) => String(v),
|
|
91
|
+
deserialize: (raw) => raw
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
//#endregion
|
|
97
|
+
//#region src/use-url-state.ts
|
|
98
|
+
function useUrlState(keyOrSchema, defaultOrOptions, maybeOptions) {
|
|
99
|
+
if (typeof keyOrSchema === "object") {
|
|
100
|
+
const schema = keyOrSchema;
|
|
101
|
+
const opts = defaultOrOptions;
|
|
102
|
+
const result = {};
|
|
103
|
+
for (const key of Object.keys(schema)) result[key] = createUrlSignal(key, schema[key], opts);
|
|
104
|
+
return result;
|
|
105
|
+
}
|
|
106
|
+
return createUrlSignal(keyOrSchema, defaultOrOptions, maybeOptions);
|
|
107
|
+
}
|
|
108
|
+
function createUrlSignal(key, defaultValue, options) {
|
|
109
|
+
const replace = options?.replace !== false;
|
|
110
|
+
const debounceMs = options?.debounce ?? 0;
|
|
111
|
+
const arrayFormat = options?.arrayFormat ?? "comma";
|
|
112
|
+
const isRepeat = Array.isArray(defaultValue) && arrayFormat === "repeat";
|
|
113
|
+
const { serialize, deserialize } = options?.serialize && options?.deserialize ? {
|
|
114
|
+
serialize: options.serialize,
|
|
115
|
+
deserialize: options.deserialize
|
|
116
|
+
} : inferSerializer(defaultValue, arrayFormat);
|
|
117
|
+
let initial;
|
|
118
|
+
if (isRepeat) {
|
|
119
|
+
const values = getParamAll(key);
|
|
120
|
+
initial = values.length > 0 ? values : defaultValue;
|
|
121
|
+
} else {
|
|
122
|
+
const raw = getParam(key);
|
|
123
|
+
initial = raw !== null ? deserialize(raw) : defaultValue;
|
|
124
|
+
}
|
|
125
|
+
const state = signal(initial);
|
|
126
|
+
let timer;
|
|
127
|
+
const writeUrl = (value) => {
|
|
128
|
+
if (isRepeat) {
|
|
129
|
+
const arr = value;
|
|
130
|
+
const defaultArr = defaultValue;
|
|
131
|
+
if (arr.length === defaultArr.length && arr.every((v, i) => v === defaultArr[i])) setParamRepeated(key, null, replace);
|
|
132
|
+
else setParamRepeated(key, arr, replace);
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
const serialized = serialize(value);
|
|
136
|
+
if (serialized === serialize(defaultValue)) setParams({ [key]: null }, replace);
|
|
137
|
+
else setParams({ [key]: serialized }, replace);
|
|
138
|
+
};
|
|
139
|
+
/** Force-remove the param from URL regardless of value. */
|
|
140
|
+
const removeFromUrl = () => {
|
|
141
|
+
if (isRepeat) setParamRepeated(key, null, replace);
|
|
142
|
+
else setParams({ [key]: null }, replace);
|
|
143
|
+
};
|
|
144
|
+
const scheduleWrite = (value) => {
|
|
145
|
+
if (debounceMs <= 0) {
|
|
146
|
+
writeUrl(value);
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
if (timer !== void 0) clearTimeout(timer);
|
|
150
|
+
timer = setTimeout(() => {
|
|
151
|
+
timer = void 0;
|
|
152
|
+
writeUrl(value);
|
|
153
|
+
}, debounceMs);
|
|
154
|
+
};
|
|
155
|
+
if (_isBrowser) {
|
|
156
|
+
const onPopState = () => {
|
|
157
|
+
let value;
|
|
158
|
+
if (isRepeat) {
|
|
159
|
+
const values = getParamAll(key);
|
|
160
|
+
value = values.length > 0 ? values : defaultValue;
|
|
161
|
+
} else {
|
|
162
|
+
const current = getParam(key);
|
|
163
|
+
value = current !== null ? deserialize(current) : defaultValue;
|
|
164
|
+
}
|
|
165
|
+
state.set(value);
|
|
166
|
+
options?.onChange?.(value);
|
|
167
|
+
};
|
|
168
|
+
effect(() => {
|
|
169
|
+
window.addEventListener("popstate", onPopState);
|
|
170
|
+
onCleanup(() => {
|
|
171
|
+
window.removeEventListener("popstate", onPopState);
|
|
172
|
+
if (timer !== void 0) clearTimeout(timer);
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
const accessor = (() => state());
|
|
177
|
+
accessor.set = (value) => {
|
|
178
|
+
state.set(value);
|
|
179
|
+
scheduleWrite(value);
|
|
180
|
+
};
|
|
181
|
+
accessor.reset = () => {
|
|
182
|
+
state.set(defaultValue);
|
|
183
|
+
scheduleWrite(defaultValue);
|
|
184
|
+
};
|
|
185
|
+
accessor.remove = () => {
|
|
186
|
+
state.set(defaultValue);
|
|
187
|
+
if (timer !== void 0) clearTimeout(timer);
|
|
188
|
+
removeFromUrl();
|
|
189
|
+
};
|
|
190
|
+
return accessor;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
//#endregion
|
|
194
|
+
export { setUrlRouter, useUrlState };
|
|
195
|
+
//# sourceMappingURL=index.js.map
|
package/lib/index.js.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","names":[],"sources":["../src/url.ts","../src/serializers.ts","../src/use-url-state.ts"],"sourcesContent":["const _isBrowser = typeof window !== \"undefined\"\n\n/** Read a search param from the current URL. Returns `null` if not present. */\nexport function getParam(key: string): string | null {\n if (!_isBrowser) return null\n return new URLSearchParams(window.location.search).get(key)\n}\n\n/**\n * Read all values for a repeated param (e.g. `?tags=a&tags=b`).\n * Returns an empty array if the param is not present.\n */\nexport function getParamAll(key: string): string[] {\n if (!_isBrowser) return []\n return new URLSearchParams(window.location.search).getAll(key)\n}\n\n/**\n * Minimal router-like interface — only the `replace` method is needed.\n * This avoids a hard dependency on `@pyreon/router`.\n */\nexport interface UrlRouter {\n replace(path: string): void | Promise<void>\n}\n\n/** Module-level router reference. Set via `setUrlRouter()`. */\nlet _router: UrlRouter | null = null\n\n/** Register a router to use for URL updates instead of the raw history API. */\nexport function setUrlRouter(router: UrlRouter | null): void {\n _router = router\n}\n\n/** @internal */\nexport function getUrlRouter(): UrlRouter | null {\n return _router\n}\n\n/** Write one or more search params to the URL without a full navigation. */\nexport function setParams(entries: Record<string, string | null>, replace: boolean): void {\n if (!_isBrowser) return\n\n const params = new URLSearchParams(window.location.search)\n\n for (const [key, value] of Object.entries(entries)) {\n if (value === null) {\n params.delete(key)\n } else {\n params.set(key, value)\n }\n }\n\n const search = params.toString()\n const url = search ? `${window.location.pathname}?${search}` : window.location.pathname\n\n if (_router) {\n _router.replace(url)\n return\n }\n\n if (replace) {\n history.replaceState(null, \"\", url)\n } else {\n history.pushState(null, \"\", url)\n }\n}\n\n/**\n * Write an array param using repeated keys (e.g. `?tags=a&tags=b`).\n * When `values` is null the param is deleted.\n */\nexport function setParamRepeated(key: string, values: string[] | null, replace: boolean): void {\n if (!_isBrowser) return\n\n const params = new URLSearchParams(window.location.search)\n params.delete(key)\n\n if (values !== null) {\n for (const v of values) {\n params.append(key, v)\n }\n }\n\n const search = params.toString()\n const url = search ? `${window.location.pathname}?${search}` : window.location.pathname\n\n if (_router) {\n _router.replace(url)\n return\n }\n\n if (replace) {\n history.replaceState(null, \"\", url)\n } else {\n history.pushState(null, \"\", url)\n }\n}\n\nexport { _isBrowser }\n","import type { ArrayFormat, Serializer } from \"./types\"\n\n/** Infer a serializer pair from the type of the default value. */\nexport function inferSerializer<T>(\n defaultValue: T,\n arrayFormat: ArrayFormat = \"comma\",\n): Serializer<T> {\n if (Array.isArray(defaultValue)) {\n if (arrayFormat === \"repeat\") {\n return {\n serialize: (v: T) => (v as string[]).join(\"\\0REPEAT\\0\"),\n deserialize: (raw: string) => (raw === \"\" ? [] : raw.split(\"\\0REPEAT\\0\")) as T,\n }\n }\n // comma (default)\n return {\n serialize: (v: T) => (v as string[]).join(\",\"),\n deserialize: (raw: string) => (raw === \"\" ? [] : raw.split(\",\")) as T,\n }\n }\n\n switch (typeof defaultValue) {\n case \"number\":\n return {\n serialize: (v: T) => String(v),\n deserialize: (raw: string) => Number(raw) as T,\n }\n case \"boolean\":\n return {\n serialize: (v: T) => String(v),\n deserialize: (raw: string) => (raw === \"true\") as T,\n }\n case \"string\":\n return {\n serialize: (v: T) => v as string,\n deserialize: (raw: string) => raw as T,\n }\n case \"object\":\n return {\n serialize: (v: T) => JSON.stringify(v),\n deserialize: (raw: string) => JSON.parse(raw) as T,\n }\n default:\n return {\n serialize: (v: T) => String(v),\n deserialize: (raw: string) => raw as T,\n }\n }\n}\n","import { effect, onCleanup, signal } from \"@pyreon/reactivity\"\nimport { inferSerializer } from \"./serializers\"\nimport type { Serializer, UrlStateOptions, UrlStateSignal } from \"./types\"\nimport { _isBrowser, getParam, getParamAll, setParamRepeated, setParams } from \"./url\"\n\n// ─── Single-param overload ──────────────────────────────────────────────────\n\n/**\n * Bind a single URL search parameter to a reactive signal.\n *\n * @example\n * ```ts\n * const page = useUrlState(\"page\", 1)\n * page() // read reactively (number)\n * page.set(2) // updates signal + URL\n * page.reset() // back to 1\n * page.remove() // removes ?page entirely\n * ```\n */\nexport function useUrlState<T>(\n key: string,\n defaultValue: T,\n options?: UrlStateOptions<T>,\n): UrlStateSignal<T>\n\n// ─── Schema overload ────────────────────────────────────────────────────────\n\n/**\n * Bind multiple URL search parameters at once via a schema object.\n *\n * @example\n * ```ts\n * const { page, q } = useUrlState({ page: 1, q: \"\" })\n * page() // number\n * q.set(\"hi\") // updates ?q=hi\n * ```\n */\nexport function useUrlState<T extends Record<string, unknown>>(\n schema: T,\n options?: UrlStateOptions,\n): { [K in keyof T]: UrlStateSignal<T[K]> }\n\n// ─── Implementation ─────────────────────────────────────────────────────────\n\nexport function useUrlState<T>(\n keyOrSchema: string | Record<string, unknown>,\n defaultOrOptions?: T | UrlStateOptions,\n maybeOptions?: UrlStateOptions<T>,\n): UrlStateSignal<T> | Record<string, UrlStateSignal<unknown>> {\n // Schema mode\n if (typeof keyOrSchema === \"object\") {\n const schema = keyOrSchema as Record<string, unknown>\n const opts = defaultOrOptions as UrlStateOptions | undefined\n const result: Record<string, UrlStateSignal<unknown>> = {}\n\n for (const key of Object.keys(schema)) {\n result[key] = createUrlSignal(key, schema[key], opts)\n }\n\n return result\n }\n\n // Single-param mode\n const key = keyOrSchema\n const defaultValue = defaultOrOptions as T\n const options = maybeOptions\n return createUrlSignal(key, defaultValue, options)\n}\n\n// ─── Core factory ───────────────────────────────────────────────────────────\n\nfunction createUrlSignal<T>(\n key: string,\n defaultValue: T,\n options?: UrlStateOptions<T>,\n): UrlStateSignal<T> {\n const replace = options?.replace !== false\n const debounceMs = options?.debounce ?? 0\n const arrayFormat = options?.arrayFormat ?? \"comma\"\n const isArray = Array.isArray(defaultValue)\n const isRepeat = isArray && arrayFormat === \"repeat\"\n\n const { serialize, deserialize }: Serializer<T> =\n options?.serialize && options?.deserialize\n ? { serialize: options.serialize, deserialize: options.deserialize }\n : inferSerializer(defaultValue, arrayFormat)\n\n // Read initial value from URL (falls back to default when missing or in SSR)\n let initial: T\n if (isRepeat) {\n const values = getParamAll(key)\n initial = values.length > 0 ? (values as T) : defaultValue\n } else {\n const raw = getParam(key)\n initial = raw !== null ? deserialize(raw) : defaultValue\n }\n\n const state = signal<T>(initial)\n\n // Pending debounce timer\n let timer: ReturnType<typeof setTimeout> | undefined\n\n // Write URL when signal changes\n const writeUrl = (value: T) => {\n if (isRepeat) {\n const arr = value as string[]\n const defaultArr = defaultValue as string[]\n // Remove param when value equals default to keep URLs clean\n if (arr.length === defaultArr.length && arr.every((v, i) => v === defaultArr[i])) {\n setParamRepeated(key, null, replace)\n } else {\n setParamRepeated(key, arr, replace)\n }\n return\n }\n\n const serialized = serialize(value)\n const defaultSerialized = serialize(defaultValue)\n\n // Remove param when value equals default to keep URLs clean\n if (serialized === defaultSerialized) {\n setParams({ [key]: null }, replace)\n } else {\n setParams({ [key]: serialized }, replace)\n }\n }\n\n /** Force-remove the param from URL regardless of value. */\n const removeFromUrl = () => {\n if (isRepeat) {\n setParamRepeated(key, null, replace)\n } else {\n setParams({ [key]: null }, replace)\n }\n }\n\n const scheduleWrite = (value: T) => {\n if (debounceMs <= 0) {\n writeUrl(value)\n return\n }\n if (timer !== undefined) clearTimeout(timer)\n timer = setTimeout(() => {\n timer = undefined\n writeUrl(value)\n }, debounceMs)\n }\n\n // Listen for popstate (back/forward navigation)\n if (_isBrowser) {\n const onPopState = () => {\n let value: T\n if (isRepeat) {\n const values = getParamAll(key)\n value = values.length > 0 ? (values as T) : defaultValue\n } else {\n const current = getParam(key)\n value = current !== null ? deserialize(current) : defaultValue\n }\n state.set(value)\n options?.onChange?.(value)\n }\n\n effect(() => {\n window.addEventListener(\"popstate\", onPopState)\n onCleanup(() => {\n window.removeEventListener(\"popstate\", onPopState)\n if (timer !== undefined) clearTimeout(timer)\n })\n })\n }\n\n // Build the signal-like accessor\n const accessor = (() => state()) as UrlStateSignal<T>\n\n accessor.set = (value: T) => {\n state.set(value)\n scheduleWrite(value)\n }\n\n accessor.reset = () => {\n state.set(defaultValue)\n scheduleWrite(defaultValue)\n }\n\n accessor.remove = () => {\n state.set(defaultValue)\n if (timer !== undefined) clearTimeout(timer)\n removeFromUrl()\n }\n\n return accessor\n}\n"],"mappings":"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"}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
//#region src/url.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* Minimal router-like interface — only the `replace` method is needed.
|
|
4
|
+
* This avoids a hard dependency on `@pyreon/router`.
|
|
5
|
+
*/
|
|
6
|
+
interface UrlRouter {
|
|
7
|
+
replace(path: string): void | Promise<void>;
|
|
8
|
+
}
|
|
9
|
+
/** Register a router to use for URL updates instead of the raw history API. */
|
|
10
|
+
declare function setUrlRouter(router: UrlRouter | null): void;
|
|
11
|
+
//#endregion
|
|
12
|
+
//#region src/types.d.ts
|
|
13
|
+
/** A signal-like accessor for a single URL search parameter. */
|
|
14
|
+
interface UrlStateSignal<T> {
|
|
15
|
+
/** Read the current value reactively. */
|
|
16
|
+
(): T;
|
|
17
|
+
/** Write a new value and update the URL. */
|
|
18
|
+
set(value: T): void;
|
|
19
|
+
/** Reset to the default value and update the URL. */
|
|
20
|
+
reset(): void;
|
|
21
|
+
/** Remove the parameter from the URL entirely and reset signal to default. */
|
|
22
|
+
remove(): void;
|
|
23
|
+
}
|
|
24
|
+
/** Encoding strategy for array values in the URL. */
|
|
25
|
+
type ArrayFormat = /** Comma-separated: `?tags=a,b` */"comma" /** Repeated keys: `?tags=a&tags=b` */ | "repeat";
|
|
26
|
+
/** Options for `useUrlState`. */
|
|
27
|
+
interface UrlStateOptions<T = unknown> {
|
|
28
|
+
/** Custom serializer — converts value to a URL-safe string. */
|
|
29
|
+
serialize?: (value: T) => string;
|
|
30
|
+
/** Custom deserializer — converts URL string back to a value. */
|
|
31
|
+
deserialize?: (raw: string) => T;
|
|
32
|
+
/**
|
|
33
|
+
* Use `history.replaceState` (true) or `history.pushState` (false).
|
|
34
|
+
* @default true
|
|
35
|
+
*/
|
|
36
|
+
replace?: boolean;
|
|
37
|
+
/**
|
|
38
|
+
* Debounce URL writes by this many milliseconds.
|
|
39
|
+
* @default 0
|
|
40
|
+
*/
|
|
41
|
+
debounce?: number;
|
|
42
|
+
/**
|
|
43
|
+
* Encoding strategy for array values.
|
|
44
|
+
* - `"comma"` — comma-separated: `?tags=a,b` (default)
|
|
45
|
+
* - `"repeat"` — repeated keys: `?tags=a&tags=b`
|
|
46
|
+
* @default "comma"
|
|
47
|
+
*/
|
|
48
|
+
arrayFormat?: ArrayFormat;
|
|
49
|
+
/**
|
|
50
|
+
* Called when the URL param changes externally (popstate or another
|
|
51
|
+
* `useUrlState` call updating the same param).
|
|
52
|
+
*/
|
|
53
|
+
onChange?: (value: T) => void;
|
|
54
|
+
}
|
|
55
|
+
/** Serializer pair for a given type. */
|
|
56
|
+
interface Serializer<T> {
|
|
57
|
+
serialize: (value: T) => string;
|
|
58
|
+
deserialize: (raw: string) => T;
|
|
59
|
+
}
|
|
60
|
+
//#endregion
|
|
61
|
+
//#region src/use-url-state.d.ts
|
|
62
|
+
/**
|
|
63
|
+
* Bind a single URL search parameter to a reactive signal.
|
|
64
|
+
*
|
|
65
|
+
* @example
|
|
66
|
+
* ```ts
|
|
67
|
+
* const page = useUrlState("page", 1)
|
|
68
|
+
* page() // read reactively (number)
|
|
69
|
+
* page.set(2) // updates signal + URL
|
|
70
|
+
* page.reset() // back to 1
|
|
71
|
+
* page.remove() // removes ?page entirely
|
|
72
|
+
* ```
|
|
73
|
+
*/
|
|
74
|
+
declare function useUrlState<T>(key: string, defaultValue: T, options?: UrlStateOptions<T>): UrlStateSignal<T>;
|
|
75
|
+
/**
|
|
76
|
+
* Bind multiple URL search parameters at once via a schema object.
|
|
77
|
+
*
|
|
78
|
+
* @example
|
|
79
|
+
* ```ts
|
|
80
|
+
* const { page, q } = useUrlState({ page: 1, q: "" })
|
|
81
|
+
* page() // number
|
|
82
|
+
* q.set("hi") // updates ?q=hi
|
|
83
|
+
* ```
|
|
84
|
+
*/
|
|
85
|
+
declare function useUrlState<T extends Record<string, unknown>>(schema: T, options?: UrlStateOptions): { [K in keyof T]: UrlStateSignal<T[K]> };
|
|
86
|
+
//#endregion
|
|
87
|
+
export { type ArrayFormat, type Serializer, type UrlRouter, type UrlStateOptions, type UrlStateSignal, setUrlRouter, useUrlState };
|
|
88
|
+
//# sourceMappingURL=index2.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index2.d.ts","names":[],"sources":["../../../src/url.ts","../../../src/types.ts","../../../src/use-url-state.ts"],"mappings":";;;AA6BA;;UARiB,SAAA;EACf,OAAA,CAAQ,IAAA,kBAAsB,OAAA;AAAA;;iBAOhB,YAAA,CAAa,MAAA,EAAQ,SAAA;;;;UC5BpB,cAAA;EDoBS;EAAA,IClBpB,CAAA;EDmBiC;ECjBrC,GAAA,CAAI,KAAA,EAAO,CAAA;EDiBH;ECfR,KAAA;EDeqC;ECbrC,MAAA;AAAA;;KAIU,WAAA;;UAOK,eAAA;EAnBc;EAqB7B,SAAA,IAAa,KAAA,EAAO,CAAA;EAjBR;EAmBZ,WAAA,IAAe,GAAA,aAAgB,CAAA;EArB3B;;;;EA0BJ,OAAA;EApBA;;;AAIF;EAqBE,QAAA;;;;AAdF;;;EAqBE,WAAA,GAAc,WAAA;EAjBiB;;;;EAsB/B,QAAA,IAAY,KAAA,EAAO,CAAA;AAAA;;UAIJ,UAAA;EACf,SAAA,GAAY,KAAA,EAAO,CAAA;EACnB,WAAA,GAAc,GAAA,aAAgB,CAAA;AAAA;;;AD/BhC;;;;;;;;;AAQA;;;AARA,iBEFgB,WAAA,GAAA,CACd,GAAA,UACA,YAAA,EAAc,CAAA,EACd,OAAA,GAAU,eAAA,CAAgB,CAAA,IACzB,cAAA,CAAe,CAAA;;;;;ADtBlB;;;;;;iBCoCgB,WAAA,WAAsB,MAAA,kBAAA,CACpC,MAAA,EAAQ,CAAA,EACR,OAAA,GAAU,eAAA,iBACK,CAAA,GAAI,cAAA,CAAe,CAAA,CAAE,CAAA"}
|
package/package.json
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@pyreon/url-state",
|
|
3
|
+
"version": "0.11.0",
|
|
4
|
+
"description": "Reactive URL search-param state for Pyreon — signal-backed, type-coerced, SSR-safe",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "https://github.com/pyreon/pyreon.git",
|
|
9
|
+
"directory": "packages/fundamentals/url-state"
|
|
10
|
+
},
|
|
11
|
+
"homepage": "https://github.com/pyreon/fundamentals/tree/main/packages/url-state#readme",
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "https://github.com/pyreon/pyreon/issues"
|
|
14
|
+
},
|
|
15
|
+
"publishConfig": {
|
|
16
|
+
"access": "public"
|
|
17
|
+
},
|
|
18
|
+
"files": [
|
|
19
|
+
"lib",
|
|
20
|
+
"src",
|
|
21
|
+
"README.md",
|
|
22
|
+
"LICENSE"
|
|
23
|
+
],
|
|
24
|
+
"type": "module",
|
|
25
|
+
"main": "./lib/index.js",
|
|
26
|
+
"module": "./lib/index.js",
|
|
27
|
+
"types": "./lib/types/index.d.ts",
|
|
28
|
+
"exports": {
|
|
29
|
+
".": {
|
|
30
|
+
"bun": "./src/index.ts",
|
|
31
|
+
"import": "./lib/index.js",
|
|
32
|
+
"types": "./lib/types/index.d.ts"
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
"sideEffects": false,
|
|
36
|
+
"scripts": {
|
|
37
|
+
"build": "vl_rolldown_build",
|
|
38
|
+
"dev": "vl_rolldown_build-watch",
|
|
39
|
+
"test": "vitest run",
|
|
40
|
+
"typecheck": "tsc --noEmit",
|
|
41
|
+
"lint": "biome check ."
|
|
42
|
+
},
|
|
43
|
+
"peerDependencies": {
|
|
44
|
+
"@pyreon/reactivity": "^0.11.0"
|
|
45
|
+
},
|
|
46
|
+
"devDependencies": {
|
|
47
|
+
"@happy-dom/global-registrator": "^20.8.3",
|
|
48
|
+
"@pyreon/reactivity": "^0.11.0",
|
|
49
|
+
"@vitus-labs/tools-lint": "^1.11.0"
|
|
50
|
+
}
|
|
51
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @pyreon/url-state — Reactive URL search-param state for Pyreon.
|
|
3
|
+
*
|
|
4
|
+
* Signal-backed, type-coerced, SSR-safe URL state management. Each search
|
|
5
|
+
* parameter is a reactive signal that syncs with the browser URL.
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* ```ts
|
|
9
|
+
* import { useUrlState } from '@pyreon/url-state'
|
|
10
|
+
*
|
|
11
|
+
* // Single parameter — type inferred from default
|
|
12
|
+
* const page = useUrlState('page', 1)
|
|
13
|
+
* page() // read reactively (number)
|
|
14
|
+
* page.set(2) // updates signal + URL (?page=2)
|
|
15
|
+
* page.reset() // back to default (removes ?page)
|
|
16
|
+
* page.remove() // removes ?page entirely
|
|
17
|
+
*
|
|
18
|
+
* // Schema mode — multiple params at once
|
|
19
|
+
* const { q, sort } = useUrlState({ q: '', sort: 'name' })
|
|
20
|
+
* q.set('hello') // ?q=hello&sort=name
|
|
21
|
+
*
|
|
22
|
+
* // Array with repeated keys
|
|
23
|
+
* const tags = useUrlState('tags', [] as string[], { arrayFormat: 'repeat' })
|
|
24
|
+
* tags.set(['a', 'b']) // ?tags=a&tags=b
|
|
25
|
+
*
|
|
26
|
+
* // Router integration — uses router.replace() when available
|
|
27
|
+
* import { setUrlRouter } from '@pyreon/url-state'
|
|
28
|
+
* setUrlRouter(router) // pass your @pyreon/router instance
|
|
29
|
+
* ```
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
export { setUrlRouter } from "./url"
|
|
33
|
+
export { useUrlState } from "./use-url-state"
|
|
34
|
+
|
|
35
|
+
// ─── Types ───────────────────────────────────────────────────────────────────
|
|
36
|
+
|
|
37
|
+
export type { ArrayFormat, Serializer, UrlStateOptions, UrlStateSignal } from "./types"
|
|
38
|
+
export type { UrlRouter } from "./url"
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type { ArrayFormat, Serializer } from "./types"
|
|
2
|
+
|
|
3
|
+
/** Infer a serializer pair from the type of the default value. */
|
|
4
|
+
export function inferSerializer<T>(
|
|
5
|
+
defaultValue: T,
|
|
6
|
+
arrayFormat: ArrayFormat = "comma",
|
|
7
|
+
): Serializer<T> {
|
|
8
|
+
if (Array.isArray(defaultValue)) {
|
|
9
|
+
if (arrayFormat === "repeat") {
|
|
10
|
+
return {
|
|
11
|
+
serialize: (v: T) => (v as string[]).join("\0REPEAT\0"),
|
|
12
|
+
deserialize: (raw: string) => (raw === "" ? [] : raw.split("\0REPEAT\0")) as T,
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
// comma (default)
|
|
16
|
+
return {
|
|
17
|
+
serialize: (v: T) => (v as string[]).join(","),
|
|
18
|
+
deserialize: (raw: string) => (raw === "" ? [] : raw.split(",")) as T,
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
switch (typeof defaultValue) {
|
|
23
|
+
case "number":
|
|
24
|
+
return {
|
|
25
|
+
serialize: (v: T) => String(v),
|
|
26
|
+
deserialize: (raw: string) => Number(raw) as T,
|
|
27
|
+
}
|
|
28
|
+
case "boolean":
|
|
29
|
+
return {
|
|
30
|
+
serialize: (v: T) => String(v),
|
|
31
|
+
deserialize: (raw: string) => (raw === "true") as T,
|
|
32
|
+
}
|
|
33
|
+
case "string":
|
|
34
|
+
return {
|
|
35
|
+
serialize: (v: T) => v as string,
|
|
36
|
+
deserialize: (raw: string) => raw as T,
|
|
37
|
+
}
|
|
38
|
+
case "object":
|
|
39
|
+
return {
|
|
40
|
+
serialize: (v: T) => JSON.stringify(v),
|
|
41
|
+
deserialize: (raw: string) => JSON.parse(raw) as T,
|
|
42
|
+
}
|
|
43
|
+
default:
|
|
44
|
+
return {
|
|
45
|
+
serialize: (v: T) => String(v),
|
|
46
|
+
deserialize: (raw: string) => raw as T,
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|