Files
astrolabe/src/app/infrastructure/url-hash.ts
T
oleh f365258fcc Add URL hash view-state routing with Back/Forward and restore (M6, §01E)
The hash becomes the single serialized view (snippet / Datasets list / a dataset
/ new-dataset form / Chart Builder). url-hash is the sole reader/writer of
location+history (total, never-throwing parse; hash-only canonical URLs).
UrlStateSync grows from the modal↔URL seam into the full router: state→hash is
derived from the stores (so in-modal dataset sub-views reach the hash), hash→state
restores on load and on Back/Forward, validating ids and cleaning stale links.
startRouting() runs after store hydrate.
2026-06-07 17:18:10 +03:00

111 lines
4.2 KiB
TypeScript

/**
* 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> (id is an opaque string)
* datasets → #datasets
* dataset → #datasets/dataset-<id> (id is a decimal number)
* dataset-new → #datasets/new
* dataset-build → #datasets/dataset-<id>/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-<id>
| { kind: 'datasets' } // #datasets
| { kind: 'dataset'; datasetId: number } // #datasets/dataset-<id>
| { kind: 'dataset-new' } // #datasets/new
| { kind: 'dataset-build'; datasetId: number }; // #datasets/dataset-<id>/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));
}