export const CATALOGUE_QUERY_PARAM_ALIASES = { query: ['q', 'query', 'search'], category: ['category', 'cat'], difficulty: ['difficulty', 'diff'], sort: ['sort', 'order'], favouritesOnly: [ 'fav', 'favorite', 'favourite', 'favorites', 'favourites', 'bookmarked', 'bookmarks', ], view: ['view', 'layout'], page: ['page', 'p'], } as const; const catalogueQueryParamKeys: string[] = []; Object.values(CATALOGUE_QUERY_PARAM_ALIASES).forEach((group) => { catalogueQueryParamKeys.push(...group); }); export const CATALOGUE_QUERY_PARAM_KEYS: readonly string[] = Object.freeze( catalogueQueryParamKeys, ); const CATALOGUE_QUERY_PARAM_KEY_SET = new Set(CATALOGUE_QUERY_PARAM_KEYS); export const CATALOGUE_SORT_KEYS = [ 'featured', 'relevance', 'name-asc', 'name-desc', 'category', 'difficulty-asc', 'difficulty-desc', 'newest', ] as const; export type CatalogueSortKey = | (typeof CATALOGUE_SORT_KEYS)[number] | (string & {}); export const CATALOGUE_VIEW_MODES = ['grid', 'list', 'compact'] as const; export type CatalogueViewMode = (typeof CATALOGUE_VIEW_MODES)[number]; export interface CatalogueQueryState { query: string; category: string; difficulty: string; sort: CatalogueSortKey; favouritesOnly: boolean; view: CatalogueViewMode; page: number; } export const DEFAULT_CATALOGUE_QUERY_STATE = { query: '', category: 'all', difficulty: 'all', sort: 'featured', favouritesOnly: false, view: 'grid', page: 1, } as const satisfies CatalogueQueryState; export type CatalogueUrlStateWarningCode = | 'query-truncated' | 'invalid-category' | 'invalid-difficulty' | 'invalid-sort' | 'invalid-favourites' | 'invalid-view' | 'invalid-page'; export interface CatalogueUrlStateWarning { code: CatalogueUrlStateWarningCode; param: string; value: string; message: string; } export interface ParsedCatalogueUrlState { state: CatalogueQueryState; canonicalSearch: string; wasCanonical: boolean; warnings: CatalogueUrlStateWarning[]; } export interface CatalogueUrlStateOptions { validCategories?: readonly string[]; validDifficulties?: readonly string[]; validSorts?: readonly CatalogueSortKey[]; validViewModes?: readonly CatalogueViewMode[]; defaultState?: Partial; maxQueryLength?: number; maxPage?: number; } export type SearchParamValue = string | number | boolean | null | undefined; export type SearchParamsInput = | string | URLSearchParams | Record; export type CatalogueQueryStatePatch = | Partial | ((current: CatalogueQueryState) => Partial); export interface CatalogueQueryMergeOptions { resetPage?: boolean; resetPageWhenKeysChange?: readonly (keyof CatalogueQueryState)[]; } export const DEFAULT_CATALOGUE_PAGE_RESET_KEYS = [ 'query', 'category', 'difficulty', 'sort', 'favouritesOnly', ] as const satisfies readonly (keyof CatalogueQueryState)[]; const DEFAULT_VALID_DIFFICULTIES = [ 'beginner', 'introductory', 'intermediate', 'advanced', 'expert', ] as const; const DEFAULT_MAX_QUERY_LENGTH = 80; const DEFAULT_MAX_PAGE = 250; const ALL_CHOICE_ALIASES = new Set([ '*', 'all', 'any', 'everything', 'all-machines', 'all-categories', 'all-difficulties', ]); const SAFE_SLUG_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; const SORT_ALIASES: Record = { default: 'featured', featured: 'featured', recommended: 'featured', registry: 'featured', relevance: 'relevance', relevant: 'relevance', name: 'name-asc', 'name-ascending': 'name-asc', az: 'name-asc', 'a-z': 'name-asc', 'name-descending': 'name-desc', za: 'name-desc', 'z-a': 'name-desc', cat: 'category', category: 'category', difficulty: 'difficulty-asc', 'difficulty-easy': 'difficulty-asc', easiest: 'difficulty-asc', 'difficulty-hard': 'difficulty-desc', hardest: 'difficulty-desc', latest: 'newest', recent: 'newest', 'recently-added': 'newest', }; const TRUE_TOKENS = new Set([ '1', 'true', 'yes', 'y', 'on', 'only', 'favourite', 'favorite', 'favourites', 'favorites', 'bookmarked', 'bookmarks', ]); const FALSE_TOKENS = new Set(['0', 'false', 'no', 'n', 'off', 'all', 'none']); const CATALOGUE_STATE_KEYS = [ 'query', 'category', 'difficulty', 'sort', 'favouritesOnly', 'view', 'page', ] as const satisfies readonly (keyof CatalogueQueryState)[]; interface NormaliseResult { value: T; accepted: boolean; truncated?: boolean; clamped?: boolean; } interface ResolvedCatalogueUrlStateOptions { validCategories?: readonly string[]; validDifficulties: readonly string[]; validSorts: readonly CatalogueSortKey[]; validViewModes: readonly CatalogueViewMode[]; defaultState: CatalogueQueryState; maxQueryLength: number; maxPage: number; } interface ReadParamResult { param: string; value: string; } export function toSearchParams(input: SearchParamsInput = ''): URLSearchParams { if (input instanceof URLSearchParams) { return new URLSearchParams(input); } if (typeof input === 'string') { return new URLSearchParams(input); } const params = new URLSearchParams(); Object.entries(input).forEach(([key, value]) => { appendSearchParamValue(params, key, value); }); return params; } export function stripCatalogueSearchParams( input: SearchParamsInput = '', ): URLSearchParams { const params = toSearchParams(input); deleteCatalogueSearchParams(params); return params; } export function normaliseCatalogueQueryState( input: Partial = {}, options: CatalogueUrlStateOptions = {}, ): CatalogueQueryState { const resolved = resolveCatalogueUrlStateOptions(options); return { query: normaliseTextQuery( input.query ?? resolved.defaultState.query, resolved.maxQueryLength, ).value, category: normaliseChoiceValue( input.category ?? resolved.defaultState.category, resolved.defaultState.category, resolved.validCategories, true, ).value, difficulty: normaliseChoiceValue( input.difficulty ?? resolved.defaultState.difficulty, resolved.defaultState.difficulty, resolved.validDifficulties, true, ).value, sort: normaliseSortValue( input.sort ?? resolved.defaultState.sort, resolved.defaultState.sort, resolved.validSorts, ).value, favouritesOnly: normaliseBooleanValue( input.favouritesOnly ?? resolved.defaultState.favouritesOnly, resolved.defaultState.favouritesOnly, ).value, view: normaliseViewValue( input.view ?? resolved.defaultState.view, resolved.defaultState.view, resolved.validViewModes, ).value, page: normalisePageValue( input.page ?? resolved.defaultState.page, resolved.defaultState.page, resolved.maxPage, ).value, }; } export function parseCatalogueSearchParams( input: SearchParamsInput = '', options: CatalogueUrlStateOptions = {}, ): ParsedCatalogueUrlState { const params = toSearchParams(input); const resolved = resolveCatalogueUrlStateOptions(options); const warnings: CatalogueUrlStateWarning[] = []; const rawQuery = readFirstPresentParam( params, CATALOGUE_QUERY_PARAM_ALIASES.query, ); const queryResult = rawQuery ? normaliseTextQuery(rawQuery.value, resolved.maxQueryLength) : normaliseTextQuery(resolved.defaultState.query, resolved.maxQueryLength); if (rawQuery && queryResult.truncated) { warnings.push( createWarning( 'query-truncated', rawQuery.param, rawQuery.value, `Search query was truncated to ${resolved.maxQueryLength} characters.`, ), ); } const rawCategory = readFirstPresentParam( params, CATALOGUE_QUERY_PARAM_ALIASES.category, ); const categoryResult = rawCategory ? normaliseChoiceValue( rawCategory.value, resolved.defaultState.category, resolved.validCategories, true, ) : normaliseChoiceValue( resolved.defaultState.category, resolved.defaultState.category, resolved.validCategories, true, ); if (rawCategory && !categoryResult.accepted) { warnings.push( createWarning( 'invalid-category', rawCategory.param, rawCategory.value, 'Unknown category filter was ignored.', ), ); } const rawDifficulty = readFirstPresentParam( params, CATALOGUE_QUERY_PARAM_ALIASES.difficulty, ); const difficultyResult = rawDifficulty ? normaliseChoiceValue( rawDifficulty.value, resolved.defaultState.difficulty, resolved.validDifficulties, true, ) : normaliseChoiceValue( resolved.defaultState.difficulty, resolved.defaultState.difficulty, resolved.validDifficulties, true, ); if (rawDifficulty && !difficultyResult.accepted) { warnings.push( createWarning( 'invalid-difficulty', rawDifficulty.param, rawDifficulty.value, 'Unknown difficulty filter was ignored.', ), ); } const rawSort = readFirstPresentParam( params, CATALOGUE_QUERY_PARAM_ALIASES.sort, ); const sortResult = rawSort ? normaliseSortValue( rawSort.value, resolved.defaultState.sort, resolved.validSorts, ) : normaliseSortValue( resolved.defaultState.sort, resolved.defaultState.sort, resolved.validSorts, ); if (rawSort && !sortResult.accepted) { warnings.push( createWarning( 'invalid-sort', rawSort.param, rawSort.value, 'Unknown sort order was ignored.', ), ); } const rawFavourites = readFirstPresentParam( params, CATALOGUE_QUERY_PARAM_ALIASES.favouritesOnly, ); const favouritesResult = rawFavourites ? normaliseBooleanValue( rawFavourites.value, resolved.defaultState.favouritesOnly, ) : normaliseBooleanValue( resolved.defaultState.favouritesOnly, resolved.defaultState.favouritesOnly, ); if (rawFavourites && !favouritesResult.accepted) { warnings.push( createWarning( 'invalid-favourites', rawFavourites.param, rawFavourites.value, 'Favourite-only filter must be true or false.', ), ); } const rawView = readFirstPresentParam( params, CATALOGUE_QUERY_PARAM_ALIASES.view, ); const viewResult = rawView ? normaliseViewValue( rawView.value, resolved.defaultState.view, resolved.validViewModes, ) : normaliseViewValue( resolved.defaultState.view, resolved.defaultState.view, resolved.validViewModes, ); if (rawView && !viewResult.accepted) { warnings.push( createWarning( 'invalid-view', rawView.param, rawView.value, 'Unknown catalogue view mode was ignored.', ), ); } const rawPage = readFirstPresentParam( params, CATALOGUE_QUERY_PARAM_ALIASES.page, ); const pageResult = rawPage ? normalisePageValue( rawPage.value, resolved.defaultState.page, resolved.maxPage, ) : normalisePageValue( resolved.defaultState.page, resolved.defaultState.page, resolved.maxPage, ); if (rawPage && !pageResult.accepted) { warnings.push( createWarning( 'invalid-page', rawPage.param, rawPage.value, pageResult.clamped ? `Page was capped at ${resolved.maxPage}.` : 'Page must be a positive integer.', ), ); } const state: CatalogueQueryState = { query: queryResult.value, category: categoryResult.value, difficulty: difficultyResult.value, sort: sortResult.value, favouritesOnly: favouritesResult.value, view: viewResult.value, page: pageResult.value, }; const canonicalSearch = getCanonicalCatalogueSearch(state, options); const knownOriginalParams = pickCatalogueSearchParams(params).toString(); return { state, canonicalSearch, wasCanonical: knownOriginalParams === canonicalSearch.replace(/^\?/, ''), warnings, }; } export function createCatalogueSearchParams( state: Partial = {}, options: CatalogueUrlStateOptions = {}, baseSearchParams?: SearchParamsInput, ): URLSearchParams { const params = baseSearchParams ? stripCatalogueSearchParams(baseSearchParams) : new URLSearchParams(); const resolved = resolveCatalogueUrlStateOptions(options); const normalisedState = normaliseCatalogueQueryState(state, options); const defaults = resolved.defaultState; if (normalisedState.query !== defaults.query) { params.set('q', normalisedState.query); } if (normalisedState.category !== defaults.category) { params.set('category', normalisedState.category); } if (normalisedState.difficulty !== defaults.difficulty) { params.set('difficulty', normalisedState.difficulty); } if (normalisedState.sort !== defaults.sort) { params.set('sort', normalisedState.sort); } if (normalisedState.favouritesOnly !== defaults.favouritesOnly) { params.set('fav', normalisedState.favouritesOnly ? '1' : '0'); } if (normalisedState.view !== defaults.view) { params.set('view', normalisedState.view); } if (normalisedState.page !== defaults.page) { params.set('page', String(normalisedState.page)); } return params; } export function getCanonicalCatalogueSearch( state: Partial = {}, options: CatalogueUrlStateOptions = {}, baseSearchParams?: SearchParamsInput, ): string { const queryString = createCatalogueSearchParams( state, options, baseSearchParams, ).toString(); return queryString ? `?${queryString}` : ''; } export function buildCatalogueUrl( pathname: string, state: Partial = {}, options: CatalogueUrlStateOptions = {}, baseSearchParams?: SearchParamsInput, ): string { const { path, hash } = splitUrlPath(pathname); return `${path}${getCanonicalCatalogueSearch( state, options, baseSearchParams, )}${hash}`; } export function mergeCatalogueQueryState( current: Partial, patch: CatalogueQueryStatePatch, options: CatalogueUrlStateOptions = {}, mergeOptions: CatalogueQueryMergeOptions = {}, ): CatalogueQueryState { const currentState = normaliseCatalogueQueryState(current, options); const resolvedPatch = typeof patch === 'function' ? patch(currentState) : patch; const safePatch = resolvedPatch ?? {}; const nextState = normaliseCatalogueQueryState( { ...currentState, ...safePatch, }, options, ); if (mergeOptions.resetPage) { const resetKeys = mergeOptions.resetPageWhenKeysChange ?? DEFAULT_CATALOGUE_PAGE_RESET_KEYS; const shouldResetPage = resetKeys.some( (key) => Object.prototype.hasOwnProperty.call(safePatch, key) && nextState[key] !== currentState[key], ); if (shouldResetPage) { nextState.page = resolveCatalogueUrlStateOptions( options, ).defaultState.page; } } return nextState; } export function areCatalogueQueryStatesEqual( a: Partial, b: Partial, options: CatalogueUrlStateOptions = {}, ): boolean { const normalisedA = normaliseCatalogueQueryState(a, options); const normalisedB = normaliseCatalogueQueryState(b, options); return CATALOGUE_STATE_KEYS.every( (key) => normalisedA[key] === normalisedB[key], ); } export function isCatalogueQueryStateDefault( state: Partial, options: CatalogueUrlStateOptions = {}, ): boolean { return areCatalogueQueryStatesEqual( state, resolveCatalogueUrlStateOptions(options).defaultState, options, ); } function appendSearchParamValue( params: URLSearchParams, key: string, value: SearchParamValue | readonly SearchParamValue[], ): void { if (Array.isArray(value)) { value.forEach((item) => appendSearchParamValue(params, key, item)); return; } if (value === undefined || value === null) { return; } params.append(key, String(value)); } function deleteCatalogueSearchParams(params: URLSearchParams): void { CATALOGUE_QUERY_PARAM_KEYS.forEach((key) => { params.delete(key); }); } function pickCatalogueSearchParams(params: URLSearchParams): URLSearchParams { const picked = new URLSearchParams(); params.forEach((value, key) => { if (CATALOGUE_QUERY_PARAM_KEY_SET.has(key)) { picked.append(key, value); } }); return picked; } function readFirstPresentParam( params: URLSearchParams, names: readonly string[], ): ReadParamResult | undefined { for (const name of names) { const values = params.getAll(name); for (const value of values) { if (value.trim() !== '') { return { param: name, value, }; } } } return undefined; } function resolveCatalogueUrlStateOptions( options: CatalogueUrlStateOptions = {}, ): ResolvedCatalogueUrlStateOptions { const maxQueryLength = normalisePositiveInteger( options.maxQueryLength, DEFAULT_MAX_QUERY_LENGTH, 1, 500, ); const maxPage = normalisePositiveInteger( options.maxPage, DEFAULT_MAX_PAGE, 1, 10_000, ); const validSorts = normaliseSortList(options.validSorts, CATALOGUE_SORT_KEYS); const validViewModes = normaliseViewModeList( options.validViewModes, CATALOGUE_VIEW_MODES, ); const validDifficulties = options.validDifficulties ?? DEFAULT_VALID_DIFFICULTIES; const fallbackSort = validSorts.includes(DEFAULT_CATALOGUE_QUERY_STATE.sort) ? DEFAULT_CATALOGUE_QUERY_STATE.sort : validSorts[0] ?? DEFAULT_CATALOGUE_QUERY_STATE.sort; const fallbackView = validViewModes.includes(DEFAULT_CATALOGUE_QUERY_STATE.view) ? DEFAULT_CATALOGUE_QUERY_STATE.view : validViewModes[0] ?? DEFAULT_CATALOGUE_QUERY_STATE.view; const rawDefaultState = { ...DEFAULT_CATALOGUE_QUERY_STATE, ...options.defaultState, }; const defaultState: CatalogueQueryState = { query: normaliseTextQuery(rawDefaultState.query, maxQueryLength).value, category: normaliseChoiceValue( rawDefaultState.category, DEFAULT_CATALOGUE_QUERY_STATE.category, options.validCategories, true, ).value, difficulty: normaliseChoiceValue( rawDefaultState.difficulty, DEFAULT_CATALOGUE_QUERY_STATE.difficulty, validDifficulties, true, ).value, sort: normaliseSortValue(rawDefaultState.sort, fallbackSort, validSorts) .value, favouritesOnly: normaliseBooleanValue( rawDefaultState.favouritesOnly, DEFAULT_CATALOGUE_QUERY_STATE.favouritesOnly, ).value, view: normaliseViewValue(rawDefaultState.view, fallbackView, validViewModes) .value, page: normalisePageValue( rawDefaultState.page, DEFAULT_CATALOGUE_QUERY_STATE.page, maxPage, ).value, }; return { validCategories: options.validCategories, validDifficulties, validSorts, validViewModes, defaultState, maxQueryLength, maxPage, }; } function normaliseTextQuery( raw: unknown, maxLength: number, ): NormaliseResult { const collapsed = String(raw ?? '') .replace(/\s+/g, ' ') .trim(); const characters = Array.from(collapsed); const truncated = characters.length > maxLength; return { value: truncated ? characters.slice(0, maxLength).join('') : collapsed, accepted: true, truncated, }; } function normaliseChoiceValue( raw: unknown, defaultValue: string, validValues: readonly string[] | undefined, allowOpenSlug: boolean, ): NormaliseResult { const token = normaliseFilterToken(raw); if (!token) { return { value: defaultValue, accepted: true, }; } if (ALL_CHOICE_ALIASES.has(token)) { return { value: 'all', accepted: true, }; } if (validValues !== undefined) { const lookup = createChoiceLookup(validValues); const value = lookup.get(token); return value ? { value, accepted: true, } : { value: defaultValue, accepted: false, }; } if (allowOpenSlug && SAFE_SLUG_PATTERN.test(token)) { return { value: token, accepted: true, }; } return { value: defaultValue, accepted: false, }; } function createChoiceLookup(validValues: readonly string[]): Map { const lookup = new Map(); validValues.forEach((value) => { const token = normaliseFilterToken(value); if (token && !ALL_CHOICE_ALIASES.has(token)) { lookup.set(token, token); } }); return lookup; } function normaliseSortValue( raw: unknown, defaultValue: CatalogueSortKey, validSorts: readonly CatalogueSortKey[], ): NormaliseResult { const token = normaliseSortToken(raw); const defaultToken = normaliseSortToken(defaultValue) || DEFAULT_CATALOGUE_QUERY_STATE.sort; if (!token) { return { value: defaultToken, accepted: true, }; } const validSet = new Set( validSorts .map((sort) => normaliseSortToken(sort)) .filter((sort): sort is CatalogueSortKey => sort !== ''), ); return validSet.has(token) ? { value: token, accepted: true, } : { value: defaultToken, accepted: false, }; } function normaliseSortList( values: readonly CatalogueSortKey[] | undefined, fallback: readonly CatalogueSortKey[], ): readonly CatalogueSortKey[] { const source = values && values.length > 0 ? values : fallback; const seen = new Set(); const result: CatalogueSortKey[] = []; source.forEach((value) => { const token = normaliseSortToken(value); if (token && !seen.has(token)) { seen.add(token); result.push(token); } }); return result.length > 0 ? result : [...fallback]; } function normaliseSortToken(value: unknown): CatalogueSortKey | '' { const token = normaliseFilterToken(value); if (!token) { return ''; } return SORT_ALIASES[token] ?? (token as CatalogueSortKey); } function normaliseViewValue( raw: unknown, defaultValue: CatalogueViewMode, validViewModes: readonly CatalogueViewMode[], ): NormaliseResult { const token = normaliseViewToken(raw); const defaultToken = normaliseViewToken(defaultValue) || DEFAULT_CATALOGUE_QUERY_STATE.view; if (!token) { return { value: defaultToken, accepted: true, }; } const validSet = new Set( validViewModes .map((mode) => normaliseViewToken(mode)) .filter((mode): mode is CatalogueViewMode => mode !== ''), ); return validSet.has(token) ? { value: token, accepted: true, } : { value: defaultToken, accepted: false, }; } function normaliseViewModeList( values: readonly CatalogueViewMode[] | undefined, fallback: readonly CatalogueViewMode[], ): readonly CatalogueViewMode[] { const source = values && values.length > 0 ? values : fallback; const seen = new Set(); const result: CatalogueViewMode[] = []; source.forEach((value) => { const token = normaliseViewToken(value); if (token && !seen.has(token)) { seen.add(token); result.push(token); } }); return result.length > 0 ? result : [...fallback]; } function normaliseViewToken(value: unknown): CatalogueViewMode | '' { const token = normaliseFilterToken(value); return isCatalogueViewMode(token) ? token : ''; } function isCatalogueViewMode(value: string): value is CatalogueViewMode { return (CATALOGUE_VIEW_MODES as readonly string[]).includes(value); } function normaliseBooleanValue( raw: unknown, defaultValue: boolean, ): NormaliseResult { if (raw === undefined || raw === null) { return { value: defaultValue, accepted: true, }; } if (typeof raw === 'boolean') { return { value: raw, accepted: true, }; } if (typeof raw === 'number') { if (raw === 1 || raw === 0) { return { value: raw === 1, accepted: true, }; } return { value: defaultValue, accepted: false, }; } const token = String(raw).trim().toLowerCase(); if (!token) { return { value: defaultValue, accepted: true, }; } if (TRUE_TOKENS.has(token)) { return { value: true, accepted: true, }; } if (FALSE_TOKENS.has(token)) { return { value: false, accepted: true, }; } return { value: defaultValue, accepted: false, }; } function normalisePageValue( raw: unknown, defaultValue: number, maxPage: number, ): NormaliseResult { if (raw === undefined || raw === null || raw === '') { return { value: defaultValue, accepted: true, }; } if (typeof raw === 'boolean') { return { value: defaultValue, accepted: false, }; } let numericValue: number; if (typeof raw === 'number') { numericValue = raw; } else { const token = String(raw).trim(); if (!/^\d+$/.test(token)) { return { value: defaultValue, accepted: false, }; } numericValue = Number(token); } if (!Number.isInteger(numericValue) || numericValue < 1) { return { value: defaultValue, accepted: false, }; } if (numericValue > maxPage) { return { value: maxPage, accepted: false, clamped: true, }; } return { value: numericValue, accepted: true, }; } function normalisePositiveInteger( value: unknown, fallback: number, min: number, max: number, ): number { if (value === undefined || value === null || value === '') { return fallback; } const numericValue = Number(value); if (!Number.isInteger(numericValue)) { return fallback; } return Math.min(Math.max(numericValue, min), max); } function normaliseFilterToken(value: unknown): string { return String(value ?? '') .trim() .toLowerCase() .replace(/[\s_]+/g, '-') .replace(/-+/g, '-') .replace(/^-+|-+$/g, ''); } function createWarning( code: CatalogueUrlStateWarningCode, param: string, value: string, message: string, ): CatalogueUrlStateWarning { return { code, param, value, message, }; } function splitUrlPath(pathname: string): { path: string; hash: string } { const hashIndex = pathname.indexOf('#'); const beforeHash = hashIndex === -1 ? pathname : pathname.slice(0, hashIndex); const hash = hashIndex === -1 ? '' : pathname.slice(hashIndex); const queryIndex = beforeHash.indexOf('?'); return { path: queryIndex === -1 ? beforeHash : beforeHash.slice(0, queryIndex), hash, }; }