@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/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
@@ -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
+ }