mirror of
https://github.com/olehomelchenko/astrolabe.git
synced 2026-08-08 10:12:34 +00:00
f365258fcc
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.
111 lines
4.2 KiB
TypeScript
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));
|
|
}
|