nuqs-svelte
Version:
Svelte adaptation of the `nuqs` library for managing URL query strings as state.
321 lines (320 loc) • 9.7 kB
JavaScript
import { safeParse } from "./utils";
/**
* Wrap a set of parse/serialize functions into a builder pattern parser
* you can pass to one of the hooks, making its default value type safe.
*/
export function createParser(parser) {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
function parseServerSideNullable(value) {
if (typeof value === "undefined") {
return null;
}
let str = "";
if (Array.isArray(value)) {
// Follow the spec:
// https://url.spec.whatwg.org/#dom-urlsearchparams-get
if (value[0] === undefined) {
return null;
}
str = value[0];
}
if (typeof value === "string") {
str = value;
}
return safeParse(parser.parse, str);
}
return {
eq: (a, b) => a === b,
...parser,
parseServerSide: /** parseServerSideNullable */ () => {
throw new Error("parseServerSide is not implemented. This parser should not be used");
},
withDefault(defaultValue) {
return {
...this,
defaultValue,
parseServerSide: /** (value) {
return parseServerSideNullable(value) ?? defaultValue;
}, */ () => {
throw new Error("parseServerSide is not implemented. This parser should not be used");
},
};
},
withOptions(options) {
return {
...this,
...options,
};
},
};
}
// Parsers implementations -----------------------------------------------------
export const parseAsString = createParser({
parse: (v) => v,
serialize: (v) => `${v}`,
});
export const parseAsInteger = createParser({
parse: (v) => {
const int = parseInt(v);
if (Number.isNaN(int)) {
return null;
}
return int;
},
serialize: (v) => Math.round(v).toFixed(),
});
export const parseAsIndex = createParser({
parse: (v) => {
const int = parseAsInteger.parse(v);
if (int === null) {
return null;
}
return int - 1;
},
serialize: (v) => parseAsInteger.serialize(v + 1),
});
export const parseAsHex = createParser({
parse: (v) => {
const int = parseInt(v, 16);
if (Number.isNaN(int)) {
return null;
}
return int;
},
serialize: (v) => {
const hex = Math.round(v).toString(16);
return hex.padStart(hex.length + (hex.length % 2), "0");
},
});
export const parseAsFloat = createParser({
parse: (v) => {
const float = parseFloat(v);
if (Number.isNaN(float)) {
return null;
}
return float;
},
serialize: (v) => v.toString(),
});
export const parseAsBoolean = createParser({
parse: (v) => v === "true",
serialize: (v) => (v ? "true" : "false"),
});
function compareDates(a, b) {
return a.valueOf() === b.valueOf();
}
/**
* Querystring encoded as the number of milliseconds since epoch,
* and returned as a Date object.
*/
export const parseAsTimestamp = createParser({
parse: (v) => {
const ms = parseInt(v);
if (Number.isNaN(ms)) {
return null;
}
return new Date(ms);
},
serialize: (v) => v.valueOf().toString(),
eq: compareDates,
});
/**
* Querystring encoded as an ISO-8601 string (UTC),
* and returned as a Date object.
*/
export const parseAsIsoDateTime = createParser({
parse: (v) => {
const date = new Date(v);
if (Number.isNaN(date.valueOf())) {
return null;
}
return date;
},
serialize: (v) => v.toISOString(),
eq: compareDates,
});
/**
* Querystring encoded as an ISO-8601 string (UTC)
* without the time zone offset, and returned as
* a Date object.
*
* The Date is parsed without the time zone offset,
* making it at 00:00:00 UTC.
*/
export const parseAsIsoDate = createParser({
parse: (v) => {
const date = new Date(v.slice(0, 10));
if (Number.isNaN(date.valueOf())) {
return null;
}
return date;
},
serialize: (v) => v.toISOString().slice(0, 10),
eq: compareDates,
});
/**
* String-based enums provide better type-safety for known sets of values.
* You will need to pass the parseAsStringEnum function a list of your enum values
* in order to validate the query string. Anything else will return `null`,
* or your default value if specified.
*
* Example:
* ```ts
* enum Direction {
* up = 'UP',
* down = 'DOWN',
* left = 'LEFT',
* right = 'RIGHT'
* }
*
* const [direction, setDirection] = useQueryState(
* 'direction',
* parseAsStringEnum<Direction>(Object.values(Direction)) // pass a list of allowed values
* .withDefault(Direction.up)
* )
* ```
*
* Note: the query string value will be the value of the enum, not its name
* (example above: `direction=UP`).
*
* @param validValues The values you want to accept
*/
export function parseAsStringEnum(validValues) {
return createParser({
parse: (query) => {
const asEnum = query;
if (validValues.includes(asEnum)) {
return asEnum;
}
return null;
},
serialize: (value) => value.toString(),
});
}
/**
* String-based literals provide better type-safety for known sets of values.
* You will need to pass the parseAsStringLiteral function a list of your string values
* in order to validate the query string. Anything else will return `null`,
* or your default value if specified.
*
* Example:
* ```ts
* const colors = ["red", "green", "blue"] as const
*
* const [color, setColor] = useQueryState(
* 'color',
* parseAsStringLiteral(colors) // pass a readonly list of allowed values
* .withDefault("red")
* )
* ```
*
* @param validValues The values you want to accept
*/
export function parseAsStringLiteral(validValues) {
return createParser({
parse: (query) => {
const asConst = query;
if (validValues.includes(asConst)) {
return asConst;
}
return null;
},
serialize: (value) => value.toString(),
});
}
/**
* Number-based literals provide better type-safety for known sets of values.
* You will need to pass the parseAsNumberLiteral function a list of your number values
* in order to validate the query string. Anything else will return `null`,
* or your default value if specified.
*
* Example:
* ```ts
* const diceSides = [1, 2, 3, 4, 5, 6] as const
*
* const [side, setSide] = useQueryState(
* 'side',
* parseAsNumberLiteral(diceSides) // pass a readonly list of allowed values
* .withDefault(4)
* )
* ```
*
* @param validValues The values you want to accept
*/
export function parseAsNumberLiteral(validValues) {
return createParser({
parse: (query) => {
const asConst = parseFloat(query);
if (validValues.includes(asConst)) {
return asConst;
}
return null;
},
serialize: (value) => value.toString(),
});
}
/**
* Encode any object shape into the querystring value as JSON.
* Note: you may want to use `useQueryStates` for finer control over
* multiple related query keys.
*
* @param runtimeParser Runtime parser (eg: Zod schema) to validate after JSON.parse
*/
export function parseAsJson(runtimeParser) {
return createParser({
parse: (query) => {
try {
const obj = JSON.parse(query);
return runtimeParser(obj);
}
catch {
return null;
}
},
serialize: (value) => JSON.stringify(value),
eq(a, b) {
// Check for referential equality first
return a === b || JSON.stringify(a) === JSON.stringify(b);
},
});
}
/**
* A comma-separated list of items.
* Items are URI-encoded for safety, so they may not look nice in the URL.
*
* @param itemParser Parser for each individual item in the array
* @param separator The character to use to separate items (default ',')
*/
export function parseAsArrayOf(itemParser, separator = ",") {
const itemEq = itemParser.eq ?? ((a, b) => a === b);
const encodedSeparator = encodeURIComponent(separator);
// todo: Handle default item values and make return type non-nullable
return createParser({
parse: (query) => {
if (query === "") {
// Empty query should not go through the split/map/filter logic,
// see https://github.com/47ng/nuqs/issues/329
return [];
}
return query
.split(separator)
.map((item, index) => safeParse(itemParser.parse, item.replaceAll(encodedSeparator, separator), `[${index}]`))
.filter((value) => value !== null && value !== undefined);
},
serialize: (values) => values
.map((value) => {
const str = itemParser.serialize ? itemParser.serialize(value) : String(value);
return str.replaceAll(separator, encodedSeparator);
})
.join(separator),
eq(a, b) {
if (a === b) {
return true; // Referentially stable
}
if (a.length !== b.length) {
return false;
}
return a.every((value, index) => itemEq(value, b[index]));
},
});
}