1// Pure URL <-> view-state serialisation for the archive SPA. 2// 3// "View state" is the user's actual mental model of "where am I": the route, 4// route-specific context such as a selected entity, the active filters, and 5// the selected record. Today that state is split across router.js (hash route 6// plus query context) and App.js (filter/selection state), so this helper keeps 7// reloads and shared URLs from losing meaningful context. 8// 9// This module is the single serialisation helper called for in issue #133. 10// It is deliberately pure: no React, no reads or writes of window.location. 11// Every function takes plain values or URL strings and returns the same, so 12// the whole round-trip is unit-testable under `node --test` with no DOM. 13// 14// Phase 1 of #133: this helper only. The React `useViewState` provider that 15// consumes it is intentionally deferred until the entity-index hook 16// conventions land (#130 / PR #180), so both hooks share one shape. 17 18import { parseWikiHash, wikiPageHref } from './wikiService.js?v=3.8.36'; 19import { 20 ABOUT_PRIVACY_HASH, 21 ABOUT_PRIVACY_SECTION, 22} from './privacyRoute.js?v=3.8.36'; 23 24export { 25 ABOUT_PRIVACY_HASH, 26 getPrivacyDetailsHref, 27} from './privacyRoute.js?v=3.8.36'; 28 29/** Hash route names. The default route renders with no hash at all. */ 30export const ROUTES = { 31 archive: 'archive', 32 start: 'start', 33 folders: 'folders', 34 entities: 'entities', 35 dissertation: 'dissertation', 36 about: 'about', 37 analytics: 'analytics', 38 wiki: 'wiki', 39 desktop: 'desktop', 40 // Hidden route (#754). Nothing in the site navigation links to it, but it is 41 // a real route: it parses, serialises, and renders like every other one. 42 nowhere: 'nowhere', 43}; 44 45export const DEFAULT_ROUTE = ROUTES.archive; 46 47// Query-param keys. Kept short so shared URLs stay readable. Array filters use 48// repeated keys (?cat=a&cat=b) rather than a comma-joined value: a category or 49// publication name containing a comma would silently corrupt a joined string, 50// and URLSearchParams handles repeated keys and percent-encoding for free. 51const PARAM = { 52 search: 'q', 53 era: 'era', 54 year: 'year', 55 type: 'type', 56 includeReplies: 'replies', 57 categories: 'cat', 58 publication: 'pub', 59 record: 'record', 60 entity: 'entity', 61}; 62 63/** 64 * Return a fresh default filters object. A factory, not a shared constant, so 65 * callers can never mutate one default into another's state (the nested 66 * arrays make a shared frozen constant awkward to spread safely). 67 */ 68export function defaultFilters() { 69 return { 70 search: '', 71 categories: [], 72 era: null, 73 year: null, 74 publication: [], 75 type: null, 76 includeReplies: false, 77 }; 78} 79 80/** True for values that should be treated as "filter not set" when emitting. */ 81function isUnset(value) { 82 return value === null || value === undefined || value === ''; 83} 84 85/** 86 * Parse a full URL string into { route, filters, selectedRecord }. 87 * Unknown or empty hashes fall back to the default route. Missing filter 88 * params fall back to defaults, so any URL parses to a complete view state. 89 */ 90export function parseViewState(href) { 91 const url = new URL(href); 92 93 // Hash carries the route. Strip a stray "?suffix" defensively even though 94 // navigateTo() never produces one (search params sit before the hash). 95 const hash = url.hash.replace(/^#/, '').split('?')[0]; 96 const wikiHash = parseWikiHash(hash); 97 const desktopHash = hash.match(/^desktop(?:\/([a-z0-9]+(?:-[a-z0-9]+)*))?$/); 98 const isAboutPrivacy = hash === ABOUT_PRIVACY_HASH; 99 const route = desktopHash 100 ? ROUTES.desktop 101 : wikiHash.route === ROUTES.wiki 102 ? ROUTES.wiki 103 : isAboutPrivacy 104 ? ROUTES.about 105 : Object.values(ROUTES).includes(hash) ? hash : DEFAULT_ROUTE; 106 const routeParams = desktopHash?.[1] 107 ? { desktopAppId: desktopHash[1] } 108 : wikiHash.route === ROUTES.wiki && wikiHash.slug 109 ? { wikiSlug: wikiHash.slug } 110 : isAboutPrivacy 111 ? { aboutSection: ABOUT_PRIVACY_SECTION } 112 : {}; 113 114 const params = url.searchParams; 115 const entityId = params.get(PARAM.entity); 116 const isEntityRoute = route === ROUTES.entities
117 || (route === ROUTES.desktop && routeParams.desktopAppId === 'entities'); 118 if (isEntityRoute && /^[A-Za-z0-9_.:-]+$/.test(entityId || '')) { 119 routeParams.entityId = entityId; 120 } 121 const filters = defaultFilters(); 122 123 const search = params.get(PARAM.search); 124 if (search !== null) filters.search = search; 125 126 const era = params.get(PARAM.era); 127 if (era !== null && era !== '') filters.era = era; 128 129 const type = params.get(PARAM.type); 130 if (type !== null && type !== '') filters.type = type; 131 132 // year stays a string: archive records store year as a string ("2025"), and 133 // App.js compares record.year to filters.year with strict inequality, so a 134 // coerced Number would silently match no records after a reload. The 135 // Number.isFinite guard only rejects a non-numeric param value. 136 const yearRaw = params.get(PARAM.year); 137 if (yearRaw !== null && yearRaw !== '' && Number.isFinite(Number(yearRaw))) { 138 filters.year = yearRaw; 139 } 140 141 filters.includeReplies = params.get(PARAM.includeReplies) === '1'; 142 filters.categories = params.getAll(PARAM.categories); 143 filters.publication = params.getAll(PARAM.publication); 144 145 const selectedRecord = params.get(PARAM.record) || null; 146 147 return { route, routeParams, filters, selectedRecord }; 148} 149 150/** 151 * Serialise a view state back into a URL string, preserving the origin and 152 * pathname of baseHref. Only non-default fields are emitted, so a pristine 153 * view produces a clean URL with no query string and no hash. 154 * 155 * @param {{route?: string, routeParams?: object, filters?: object, selectedRecord?: string|null}} viewState 156 * @param {string} baseHref - a URL whose origin and pathname are reused. 157 */ 158export function viewStateToUrl(viewState, baseHref) { 159 if (typeof baseHref !== 'string' || baseHref === '') { 160 throw new TypeError('viewStateToUrl requires a baseHref URL string'); 161 } 162 163 const url = new URL(baseHref); 164 url.search = ''; 165 url.hash = ''; 166 167 const route = viewState.route || DEFAULT_ROUTE; 168 const filters = { ...defaultFilters(), ...(viewState.filters || {}) }; 169 const params = new URLSearchParams(); 170 171 if (filters.search !== '') params.set(PARAM.search, filters.search); 172 if (!isUnset(filters.era)) params.set(PARAM.era, filters.era); 173 if (!isUnset(filters.type)) params.set(PARAM.type, filters.type); 174 if (!isUnset(filters.year) && Number.isFinite(Number(filters.year))) { 175 params.set(PARAM.year, String(filters.year)); 176 } 177 if (filters.includeReplies === true) params.set(PARAM.includeReplies, '1'); 178 179 for (const category of filters.categories || []) { 180 params.append(PARAM.categories, category); 181 } 182 for (const publication of filters.publication || []) { 183 params.append(PARAM.publication, publication); 184 } 185 if (!isUnset(viewState.selectedRecord)) { 186 params.set(PARAM.record, viewState.selectedRecord); 187 } 188 189 const wikiSlug = viewState.routeParams?.wikiSlug; 190 const desktopAppId = viewState.routeParams?.desktopAppId; 191 const entityId = viewState.routeParams?.entityId; 192 const aboutSection = viewState.routeParams?.aboutSection; 193 const isEntityRoute = route === ROUTES.entities 194 || (route === ROUTES.desktop && desktopAppId === 'entities'); 195 if (isEntityRoute && /^[A-Za-z0-9_.:-]+$/.test(entityId || '')) { 196 params.set(PARAM.entity, entityId); 197 } 198 url.search = params.toString(); 199 url.hash = route === DEFAULT_ROUTE ? '' 200 : route === ROUTES.about && aboutSection === ABOUT_PRIVACY_SECTION ? ABOUT_PRIVACY_HASH 201 : route === ROUTES.wiki && wikiSlug ? wikiPageHref(wikiSlug).slice(1) 202 : route === ROUTES.desktop && /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(desktopAppId || '') 203 ? `${ROUTES.desktop}/${desktopAppId}` 204 : route; 205 return url.toString(); 206} 207 208/** 209 * Migrate a legacy ?view=dissertation|about URL to the hash-route form. 210 * Pure: returns the migrated URL string, or the input unchanged when there is 211 * nothing to migrate. A wiring-phase wrapper applies the result to history. 212 */ 213export function migrateLegacyHref(href) { 214 const url = new URL(href); 215 const view = url.searchParams.get('view'); 216 if (view !== 'dissertation' && view !== 'about') return href; 217 218 url.searchParams.delete('view'); 219 url.hash = ROUTES[view]; 220 return url.toString(); 221}
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.