UNPKG

@metapages/hash-query

Version:

Get/set URL parameters (state) in the hash string instead of the query string.

691 lines (610 loc) 17.7 kB
/** * Core logic for getting/setting hash params * Important note: the internal hash string does NOT have the leading # */ import stringify from "fast-json-stable-stringify"; export type SetHashParamOpts = { modifyHistory?: boolean; }; export const blobToBase64String = (blob: Record<string, any>) => { return stringToBase64String(stringify(blob)); }; export const blobFromBase64String = (value: string | undefined) => { if (value && value.length > 0) { try { return JSON.parse(stringFromBase64String(value)); } catch (e: any) { return JSON.parse(decodeURIComponent(atob(decodeURIComponent(value)))); } } return undefined; }; export const stringToBase64String = (value: string): string => { return btoa(encodeURIComponent(value)); }; export const stringFromBase64String = (value: string): string => { try { return decodeURIComponent(atob(value)); } catch (e: any) { // swallow error because it might be old format return decodeURIComponent(atob(decodeURIComponent(value))); } }; // Get everything after # then after ? export const getUrlHashParams = ( url: string | URL ): [string, Record<string, string>] => { const urlBlob = url instanceof URL ? url : new URL(url); return getUrlHashParamsFromHashString(urlBlob.hash); }; export const getUrlHashParamsFromHashString = ( hash: string ): [string, Record<string, string>] => { let hashString = hash; while (hashString.startsWith("#")) { hashString = hashString.substring(1); } const queryIndex = hashString.indexOf("?"); if (queryIndex === -1) { return [hashString, {}]; } const preHashString = hashString.substring(0, queryIndex); hashString = hashString.substring(queryIndex + 1); const hashObject: Record<string, string> = {}; hashString .split("&") .filter((s) => s.length > 0) .map((s) => { const dividerIndex = s.indexOf("="); if (dividerIndex === -1) { return [s, ""]; } const key = s.substring(0, dividerIndex); const value = s.substring(dividerIndex + 1); return [key, value]; }) .forEach(([key, value]) => { hashObject[key] = value; }); Object.keys(hashObject).forEach((key) => { hashObject[key] = hashObject[key]; }); return [preHashString, hashObject]; }; export const getHashParamValue = ( url: string | URL, key: string ): string | undefined => { const [_, hashParams] = getUrlHashParams(url); return hashParams[key]; }; const isBrowser = (): boolean => { return typeof window !== "undefined" && typeof globalThis.location !== "undefined"; }; export const getHashParamFromWindow = (key: string): string | undefined => { if (!isBrowser()) { return undefined; } return getHashParamsFromWindow()[1][key]; }; export const getHashParamsFromWindow = (): [string, Record<string, string>] => { if (!isBrowser()) { return ["", {}]; } return getUrlHashParams(globalThis.location.href); }; export const setHashParamInWindow = ( key: string, value: string | undefined, opts?: SetHashParamOpts ) => { if (!isBrowser()) { return; } const hash = globalThis.location.hash.startsWith("#") ? globalThis.location.hash.substring(1) : globalThis.location.hash; const newHash = setHashParamValueInHashString(hash, key, value); if (newHash === hash) { return; } if (opts?.modifyHistory) { // adds to browser history, so affects back button // fires "hashchange" event globalThis.location.hash = newHash; } else { // The following will NOT work to trigger a 'hashchange' event: // Replace the state so the back button works correctly globalThis.history.replaceState( null, typeof document !== "undefined" ? document.title : "", `${globalThis.location.pathname}${globalThis.location.search}${ newHash.startsWith("#") ? "" : "#" }${newHash}` ); // Manually trigger a hashchange event: // I don't know how to add the previous and new url parameters globalThis.dispatchEvent(new HashChangeEvent("hashchange")); } }; // returns hash string export const setHashParamValueInHashString = ( hash: string, key: string, value: string | undefined ): string => { const [preHashParamString, hashObject] = getUrlHashParamsFromHashString(hash); let changed = false; if ( (hashObject.hasOwnProperty(key) && value === null) || value === undefined ) { delete hashObject[key]; changed = true; } else { if (hashObject[key] !== value) { hashObject[key] = value; changed = true; } } // don't do work if unneeded if (!changed) { return hash; } const keys = Object.keys(hashObject); keys.sort(); const hashStringNew = keys .map((key, i) => { const value = hashObject[key]; // Check if value is already base64-encoded (contains only base64 chars and has proper length) // This is a simple check to avoid URL-encoding base64 strings const isBase64 = /^[A-Za-z0-9+/]+={0,2}$/.test(value) && value.length % 4 === 0; // Only URL-encode if it's not already base64-encoded return `${key}=${value}`; }) .join("&"); // replace after the ? but keep before that if (!preHashParamString && !hashStringNew) { return ""; } return `${preHashParamString || ""}${ hashStringNew ? "?" + hashStringNew : "" }`; }; /** * Efficiently creates a hash string with multiple parameters at once. * This is more efficient than calling setHashParamValueInHashString repeatedly. */ export const createHashParamValuesInHashString = ( hash: string, params: Record<string, string | undefined> ): string => { // Efficiently extract prehash string and existing parameters let hashString = hash; while (hashString.startsWith("#")) { hashString = hashString.substring(1); } const queryIndex = hashString.indexOf("?"); const preHashString = queryIndex === -1 ? hashString : hashString.substring(0, queryIndex); // Parse existing parameters efficiently const hashObject: Record<string, string> = {}; if (queryIndex !== -1) { const paramsString = hashString.substring(queryIndex + 1); if (paramsString.length > 0) { paramsString.split("&").forEach((s) => { if (s.length > 0) { const dividerIndex = s.indexOf("="); if (dividerIndex === -1) { hashObject[s] = ""; } else { const key = s.substring(0, dividerIndex); const value = s.substring(dividerIndex + 1); hashObject[key] = value; } } }); } } // Apply new parameters let changed = false; for (const [key, value] of Object.entries(params)) { if (value === undefined || value === null) { if (hashObject.hasOwnProperty(key)) { delete hashObject[key]; changed = true; } } else if (hashObject[key] !== value) { hashObject[key] = value; changed = true; } } // don't do work if unneeded if (!changed) { return hash; } // Build the new hash string efficiently const keys = Object.keys(hashObject); keys.sort(); const hashStringNew = keys .map((key) => { const value = hashObject[key]; // Check if value is already base64-encoded (contains only base64 chars and has proper length) const isBase64 = /^[A-Za-z0-9+/]+={0,2}$/.test(value) && value.length % 4 === 0; // Only URL-encode if it's not already base64-encoded return `${key}=${value}`; }) .join("&"); // Construct final hash string if (!preHashString && !hashStringNew) { return ""; } return `${preHashString || ""}${hashStringNew ? "?" + hashStringNew : ""}`; }; // returns URL string export const setHashParamValueInUrl = ( url: string | URL, key: string, value: string | undefined ): URL => { const urlBlob = url instanceof URL ? url : new URL(url); const newHash = setHashParamValueInHashString(urlBlob.hash, key, value); urlBlob.hash = newHash; return urlBlob; }; /** * Convenience function to set multiple hash parameters in a URL at once. * Takes a URL (string or URL object) and a record of hash parameters, * then returns a URL with those parameters set. */ export const setHashParamsInUrl = ( url: string | URL, params: Record<string, string | undefined> ): URL => { const urlBlob = url instanceof URL ? url : new URL(url); let newHash = createHashParamValuesInHashString(urlBlob.hash, params); urlBlob.hash = newHash; return urlBlob; }; /* json */ export const setHashParamValueJsonInUrl = <T>( url: string | URL, key: string, value: T | undefined ): URL => { const urlBlob = url instanceof URL ? url : new URL(url); urlBlob.hash = setHashParamValueJsonInHashString(urlBlob.hash, key, value); return urlBlob; }; export const getHashParamValueJsonFromUrl = <T>( url: string | URL, key: string ): T | undefined => { const valueString = getHashParamValue(url, key); if (valueString && valueString !== "") { const value = blobFromBase64String(valueString); return value; } return; }; export const getHashParamValueJsonFromHashString = <T>( hash: string, key: string ): T | undefined => { const [_, hashParams] = getUrlHashParamsFromHashString(hash); const valueString = hashParams[key]; if (valueString && valueString !== "") { const value = blobFromBase64String(valueString); return value; } return; }; export const setHashParamValueJsonInWindow = <T>( key: string, value: T | undefined, opts?: SetHashParamOpts ): void => { const valueString = value ? blobToBase64String(value) : undefined; setHashParamInWindow(key, valueString, opts); }; export const getHashParamValueJsonFromWindow = <T>( key: string ): T | undefined => { if (!isBrowser()) { return undefined; } return getHashParamValueJsonFromUrl(globalThis.location.href, key); }; export const setHashParamValueJsonInHashString = <T>( hash: string, key: string, value: T | undefined ) => { const valueString = value ? blobToBase64String(value) : undefined; return setHashParamValueInHashString(hash, key, valueString); }; /* float */ export const setHashParamValueFloatInUrl = ( url: string | URL, key: string, value: number | undefined ): URL => { return setHashParamValueInUrl(url, key, value ? value.toString() : undefined); }; export const getHashParamValueFloatFromUrl = ( url: string | URL, key: string ): number | undefined => { const hashParamString = getHashParamValue(url, key); return hashParamString ? parseFloat(hashParamString) : undefined; }; export const setHashParamValueFloatInWindow = ( key: string, value: number | undefined, opts?: SetHashParamOpts ): void => { setHashParamInWindow( key, value !== undefined && value !== null ? value.toString() : undefined, opts ); }; export const getHashParamValueFloatFromWindow = ( key: string ): number | undefined => { if (!isBrowser()) { return undefined; } return getHashParamValueFloatFromUrl(globalThis.location.href, key); }; /* integer */ export const setHashParamValueIntInUrl = ( url: string | URL, key: string, value: number | undefined ): URL => { return setHashParamValueInUrl( url, key, value !== undefined && value !== null ? value.toString() : undefined ); }; export const getHashParamValueIntFromUrl = ( url: string | URL, key: string ): number | undefined => { const hashParamString = getHashParamValue(url, key); return hashParamString ? parseInt(hashParamString) : undefined; }; export const setHashParamValueIntInWindow = ( key: string, value: number | undefined, opts?: SetHashParamOpts ): void => { setHashParamValueFloatInWindow(key, value, opts); }; export const getHashParamValueIntFromWindow = ( key: string ): number | undefined => { if (!isBrowser()) { return undefined; } return getHashParamValueIntFromUrl(globalThis.location.href, key); }; /* boolean */ export const setHashParamValueBooleanInUrl = ( url: string | URL, key: string, value: boolean | undefined ): URL => { return setHashParamValueInUrl(url, key, value ? "true" : undefined); }; export const getHashParamValueBooleanFromUrl = ( url: string | URL, key: string ): boolean | undefined => { const hashParamString = getHashParamValue(url, key); return hashParamString === "true" ? true : false; }; export const setHashParamValueBooleanInWindow = ( key: string, value: boolean | undefined, opts?: SetHashParamOpts ): void => { setHashParamInWindow(key, value ? "true" : undefined, opts); }; export const getHashParamValueBooleanFromWindow = ( key: string ): boolean | undefined => { if (!isBrowser()) { return undefined; } return getHashParamValueBooleanFromUrl(globalThis.location.href, key); }; /* HashValueBase64 */ export const setHashParamValueBase64EncodedInUrl = ( url: string | URL, key: string, value: string | undefined ): URL => { return setHashParamValueInUrl( url, key, value === null || value === undefined ? undefined : stringToBase64String(value) ); }; export const getHashParamValueBase64DecodedFromUrl = ( url: string | URL, key: string ): string | undefined => { const valueString = getHashParamValue(url, key); return valueString && valueString !== "" ? stringFromBase64String(valueString) : undefined; }; export const setHashParamValueBase64EncodedInWindow = ( key: string, value: string | undefined, opts?: SetHashParamOpts ): void => { const encodedValue = value === null || value === undefined ? undefined : stringToBase64String(value); setHashParamInWindow(key, encodedValue, opts); }; export const getHashParamValueBase64DecodedFromWindow = ( key: string ): string | undefined => { if (!isBrowser()) { return undefined; } return getHashParamValueBase64DecodedFromUrl(globalThis.location.href, key); }; /* HashValueUriEncoded */ export const setHashParamValueUriEncodedInUrl = ( url: string | URL, key: string, value: string | undefined ): URL => { return setHashParamValueInUrl( url, key, value === null || value === undefined ? undefined : encodeURIComponent(value) ); }; export const getHashParamValueUriDecodedFromUrl = ( url: string | URL, key: string ): string | undefined => { const valueString = getHashParamValue(url, key); return valueString && valueString !== "" ? decodeURIComponent(valueString) : undefined; }; export const setHashParamValueUriEncodedInWindow = ( key: string, value: string | undefined, opts?: SetHashParamOpts ): void => { const encodedValue = value === null || value === undefined ? undefined : encodeURIComponent(value); setHashParamInWindow(key, encodedValue, opts); }; export const getHashParamValueUriDecodedFromWindow = ( key: string ): string | undefined => { if (!isBrowser()) { return undefined; } return getHashParamValueUriDecodedFromUrl(globalThis.location.href, key); }; type HashParamValueListener<T> = (value: T | undefined) => void; const addEventListenerHashParam = <T>( key: string, getValue: (key: string) => T | undefined, onHashParamChange: HashParamValueListener<T> ): (() => void) => { if (!isBrowser()) { return () => {}; } let disposed = false; const onHashChange = (_: HashChangeEvent) => { if (disposed) { return; } onHashParamChange(getValue(key)); }; globalThis.addEventListener("hashchange", onHashChange); // Defer initial call so consumers can subscribe and then process consistently. setTimeout(() => { if (disposed) { return; } onHashParamChange(getValue(key)); }, 0); return () => { if (disposed) { return; } disposed = true; globalThis.removeEventListener("hashchange", onHashChange); }; }; export const addEventListenerHashParamBase64 = ( key: string, onHashParamChange: HashParamValueListener<string> ): (() => void) => { return addEventListenerHashParam( key, getHashParamValueBase64DecodedFromWindow, onHashParamChange ); }; export const addEventListenerHashParamBoolean = ( key: string, onHashParamChange: HashParamValueListener<boolean> ): (() => void) => { return addEventListenerHashParam( key, getHashParamValueBooleanFromWindow, onHashParamChange ); }; export const addEventListenerHashParamFloat = ( key: string, onHashParamChange: HashParamValueListener<number> ): (() => void) => { return addEventListenerHashParam( key, getHashParamValueFloatFromWindow, onHashParamChange ); }; export const addEventListenerHashParamInt = ( key: string, onHashParamChange: HashParamValueListener<number> ): (() => void) => { return addEventListenerHashParam( key, getHashParamValueIntFromWindow, onHashParamChange ); }; export const addEventListenerHashParamJson = <T>( key: string, onHashParamChange: HashParamValueListener<T> ): (() => void) => { return addEventListenerHashParam( key, getHashParamValueJsonFromWindow, onHashParamChange ); }; export const addEventListenerHashParamUriEncoded = ( key: string, onHashParamChange: HashParamValueListener<string> ): (() => void) => { return addEventListenerHashParam( key, getHashParamValueUriDecodedFromWindow, onHashParamChange ); }; export const deleteHashParamFromWindow = ( key: string, opts?: SetHashParamOpts ): void => { setHashParamInWindow(key, undefined, opts); }; export const deleteHashParamFromUrl = (url: string | URL, key: string): URL => { return setHashParamValueInUrl(url, key, undefined); };