mirror of
https://github.com/olehomelchenko/astrolabe.git
synced 2026-08-08 02:02:33 +00:00
Shell: one-shot #example-<id> deep link consumed at startup
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import {
|
||||
parseHash,
|
||||
parseExampleHash,
|
||||
serializeHash,
|
||||
readView,
|
||||
replaceView,
|
||||
@@ -75,6 +76,24 @@ describe('parseHash', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseExampleHash', () => {
|
||||
it('extracts the example id from the one-shot action link', () => {
|
||||
expect(parseExampleHash('#example-brush')).toBe('brush');
|
||||
expect(parseExampleHash('example-bar')).toBe('bar'); // leading "#" optional
|
||||
});
|
||||
|
||||
it('returns null for anything that is not an example link', () => {
|
||||
expect(parseExampleHash('')).toBeNull();
|
||||
expect(parseExampleHash('#example-')).toBeNull(); // empty id
|
||||
expect(parseExampleHash('#snippet-abc')).toBeNull();
|
||||
expect(parseExampleHash('#build')).toBeNull();
|
||||
});
|
||||
|
||||
it('is not a ViewState: parseHash degrades an example link to the default view', () => {
|
||||
expect(parseHash('#example-brush')).toEqual({ kind: 'snippets' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('round-trip identity', () => {
|
||||
it('parseHash(serializeHash(v)) deep-equals v for every variant', () => {
|
||||
for (const v of ALL_VIEWS) {
|
||||
|
||||
@@ -88,6 +88,25 @@ export function serializeHash(view: ViewState): string {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One-shot action link, parsed separately from `ViewState`: `#example-<id>`
|
||||
* asks the app to add that gallery example (`@core/examples`) as a snippet at
|
||||
* startup and open it. It is consumed once — routing's settle step then
|
||||
* replaces the hash with the created snippet's view — so it never serializes
|
||||
* back and never participates in Back/Forward. `parseHash` degrades it (like
|
||||
* any unknown hash) to the default view, which is exactly the fallback for an
|
||||
* id that no longer exists.
|
||||
*/
|
||||
export function parseExampleHash(rawHash: string): string | null {
|
||||
const m = /^example-(.+)$/.exec(rawHash.replace(/^#/, ''));
|
||||
return m ? m[1] : null;
|
||||
}
|
||||
|
||||
/** Read the one-shot example id from the current URL, if any. */
|
||||
export function readExampleId(): string | null {
|
||||
return parseExampleHash(window.location.hash);
|
||||
}
|
||||
|
||||
/** Read the current view from `window.location.hash`. */
|
||||
export function readView(): ViewState {
|
||||
return parseHash(window.location.hash);
|
||||
|
||||
@@ -12,6 +12,8 @@ import type { Snippet } from '@core/snippet';
|
||||
import type { Dataset } from '@core/dataset';
|
||||
import type { CustomTheme } from '@core/custom-theme';
|
||||
import type { FontAsset } from '@core/font-asset';
|
||||
import { CHART_EXAMPLES, exampleSpecText } from '@core/examples';
|
||||
import { readExampleId } from '../infrastructure/url-hash';
|
||||
import { loadSnippets } from '../infrastructure/snippet-store';
|
||||
import { loadDatasets } from '../infrastructure/dataset-store';
|
||||
import { loadCustomThemes } from '../infrastructure/theme-store';
|
||||
@@ -23,6 +25,7 @@ import {
|
||||
} from '../services/storage-errors';
|
||||
import { notify } from '../stores/NotificationStore';
|
||||
import { useSnippetStore } from '../stores/SnippetStore';
|
||||
import { usePanesStore } from '../stores/PanesStore';
|
||||
import { useDatasetStore } from '../stores/DatasetStore';
|
||||
import { useCustomThemeStore } from '../stores/CustomThemeStore';
|
||||
import { useFontStore } from '../stores/FontStore';
|
||||
@@ -95,6 +98,32 @@ export async function initApp(): Promise<void> {
|
||||
wireThemePersistence();
|
||||
wireFontPersistence();
|
||||
|
||||
// One-shot deep link (spec §01E): `#example-<id>` — the landing's hand-off
|
||||
// links — adds that gallery example as an ordinary snippet and opens it. It
|
||||
// runs after persistence wiring (so the new snippet write-throughs; anything
|
||||
// created before wiring would be treated as loaded baseline and never saved)
|
||||
// and before routing (whose settle step below replaces the hash with the
|
||||
// created snippet's view, so a reload doesn't re-add it). An unknown id is
|
||||
// ignored and the hash degrades to the default view.
|
||||
// TODO: the ordering constraints above (after wiring, before routing) have no
|
||||
// automated coverage — an initApp integration test (fake-indexeddb + stubbed
|
||||
// location.hash) would catch a reorder silently breaking the marketing links.
|
||||
const exampleId = readExampleId();
|
||||
if (exampleId) {
|
||||
const example = CHART_EXAMPLES.find((e) => e.id === exampleId);
|
||||
if (example) {
|
||||
const firstSnippet = useSnippetStore.getState().snippets.length === 0;
|
||||
useSnippetStore.getState().createSnippet({
|
||||
name: example.name,
|
||||
spec: exampleSpecText(example),
|
||||
});
|
||||
// Entering the workspace directly, the onboarding canvas never shows — so
|
||||
// give a first chart the same generous default split leaving it would
|
||||
// (spec §02 → First-Run & Empty Workspace).
|
||||
if (firstSnippet) usePanesStore.getState().applyOnboardingSplit(window.innerWidth);
|
||||
}
|
||||
}
|
||||
|
||||
// Routing starts AFTER hydrate so the on-load hash restore can resolve snippet
|
||||
// / dataset ids against the loaded stores (spec §01E, docs/architecture/04).
|
||||
startRouting();
|
||||
|
||||
Reference in New Issue
Block a user