/** * URL hash as view-state (spec §01 → E "Navigation & Shareable URL State"; * architecture §04 → "1. URL Hash as View-State"). The current view — selected * snippet, open Datasets modal, a specific dataset, the new-dataset form, or the * Chart Builder — is serialized into `location.hash` so every meaningful view is * shareable, bookmarkable, and reload-safe, and Back/Forward steps between states. * * This is the ONLY module that reads or writes `window.location` / `history`. * Parsing is a total pure function (no side effects, never throws — an unknown or * malformed hash degrades to the default snippets view). Writing is the only place * `history.replaceState` / `pushState` is called. * * Hash grammar (matches the spec table exactly): * * snippets (default) → '' (empty / absent) * snippet → #snippet- (id is an opaque string) * datasets → #datasets * dataset → #datasets/dataset- (id is a decimal number) * dataset-new → #datasets/new * dataset-build → #datasets/dataset-/build */ /** The serialized view. Snippet id is opaque; dataset id is the numeric id. */ export type ViewState = | { kind: 'snippets' } // empty hash | { kind: 'snippet'; snippetId: string } // #snippet- | { kind: 'datasets' } // #datasets | { kind: 'dataset'; datasetId: number } // #datasets/dataset- | { kind: 'dataset-new' } // #datasets/new | { kind: 'dataset-build'; datasetId: number }; // #datasets/dataset-/build /** * Parse a raw hash (with or without the leading `#`) into a typed `ViewState`. * Total and pure: anything unrecognized or malformed degrades to the default * snippets view rather than throwing. */ export function parseHash(rawHash: string): ViewState { const hash = rawHash.replace(/^#/, ''); if (hash === '') return { kind: 'snippets' }; const snippet = /^snippet-(.+)$/.exec(hash); if (snippet) return { kind: 'snippet', snippetId: snippet[1] }; const parts = hash.split('/').filter(Boolean); if (parts[0] === 'datasets') { if (parts.length === 1) return { kind: 'datasets' }; if (parts[1] === 'new') return { kind: 'dataset-new' }; const m = /^dataset-(\d+)$/.exec(parts[1]); if (m) { const id = Number(m[1]); if (parts[2] === 'build') return { kind: 'dataset-build', datasetId: id }; if (parts.length === 2) return { kind: 'dataset', datasetId: id }; } } // Unknown / malformed hash → fall back to default rather than throwing. return { kind: 'snippets' }; } /** * Serialize a `ViewState` into a hash string (round-trips with `parseHash`). * The exhaustive switch uses a `never`-typed default so a newly added `ViewState` * variant without a case becomes a compile-time error. */ export function serializeHash(view: ViewState): string { switch (view.kind) { case 'snippets': return ''; case 'snippet': return `#snippet-${view.snippetId}`; case 'datasets': return '#datasets'; case 'dataset': return `#datasets/dataset-${view.datasetId}`; case 'dataset-new': return '#datasets/new'; case 'dataset-build': return `#datasets/dataset-${view.datasetId}/build`; default: { const _exhaustive: never = view; return _exhaustive; } } } /** Read the current view from `window.location.hash`. */ export function readView(): ViewState { return parseHash(window.location.hash); } /** * Build the next URL for `view`. The hash is the app's only view-state channel, * so we normalize to a hash-only canonical URL: any stray query string (a shared * link's `?utm=…`, a dev-server param) is dropped rather than carried forward. */ function nextUrl(view: ViewState): string { const url = new URL(window.location.href); url.hash = serializeHash(view); url.search = ''; return url.toString(); } /** Write without adding a history entry (in-place correction, load-restore). */ export function replaceView(view: ViewState): void { window.history.replaceState({}, '', nextUrl(view)); } /** Write and add a history entry (deliberate user navigation → Back works). */ export function pushView(view: ViewState): void { window.history.pushState({}, '', nextUrl(view)); }