@ternkit/tern 0.0.0-stage → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,388 @@
1
+ import { hydrate, mount, tick } from 'svelte';
2
+ import DefaultError from './DefaultError.svelte';
3
+ import Passthrough from './Passthrough.svelte';
4
+ import Root from './Root.svelte';
5
+ import { DeferredScope, readStreamed, revive } from './deferred.js';
6
+ import { plan } from './select.js';
7
+ import { applyPayloadToPage, page, setNavigating } from './state.svelte.js';
8
+ const MAX_REDIRECTS = 20;
9
+ const PRELOAD_TTL = 10_000;
10
+ const SCROLL_KEY = 'tern:scroll';
11
+ let manifest;
12
+ let started = false;
13
+ let token = 0;
14
+ let historyIndex = 0;
15
+ let nodes = [];
16
+ let layoutCount = 0;
17
+ let scrollPositions = {};
18
+ let announcer = null;
19
+ const props = $state({
20
+ components: [],
21
+ data: [],
22
+ form: null
23
+ });
24
+ const preloads = new Map();
25
+ /** Starts the client. Called by the generated entry module. */
26
+ export async function start(m) {
27
+ manifest = m;
28
+ const el = document.getElementById('__tern_data');
29
+ const target = document.getElementById('tern-root');
30
+ if (!el || !target) {
31
+ throw new Error('Tern: the page is missing its data or mount point; check that src/app.html contains %tern.head% and %tern.body%');
32
+ }
33
+ try {
34
+ scrollPositions = JSON.parse(sessionStorage.getItem(SCROLL_KEY) ?? '{}');
35
+ }
36
+ catch {
37
+ scrollPositions = {};
38
+ }
39
+ historyIndex = history.state?.ternIndex ?? Date.now();
40
+ if (history.state?.ternIndex == null) {
41
+ history.replaceState({ ...history.state, ternIndex: historyIndex, ternState: {} }, '');
42
+ }
43
+ history.scrollRestoration = 'manual';
44
+ const url = new URL(location.href);
45
+ const payload = JSON.parse(el.textContent ?? '{}');
46
+ // Deferred values stream in as inline scripts calling __tern_resolve.
47
+ const w = window;
48
+ const scope = new DeferredScope();
49
+ payload.nodes = revive(payload.nodes, (id) => scope.promise(id));
50
+ for (const chunk of Object.values(w.__tern_d ?? {}))
51
+ scope.settle(chunk);
52
+ w.__tern_on = (chunk) => scope.settle(chunk);
53
+ await render(payload, url, history.state?.ternState ?? {}, token);
54
+ if (target.hasAttribute('data-tern-ssr')) {
55
+ hydrate(Root, { target, props });
56
+ }
57
+ else {
58
+ mount(Root, { target, props });
59
+ }
60
+ started = true;
61
+ const saved = scrollPositions[historyIndex];
62
+ if (saved) {
63
+ scrollTo(saved.x, saved.y);
64
+ }
65
+ else if (url.hash) {
66
+ scrollToHash(url.hash);
67
+ }
68
+ listen();
69
+ }
70
+ /** Navigates to href, which may be relative to the current URL. */
71
+ export async function goto(href, opts = {}) {
72
+ if (!started) {
73
+ throw new Error('Tern: goto() was called before the app started');
74
+ }
75
+ const url = new URL(href, location.href);
76
+ if (url.origin !== location.origin) {
77
+ location.href = url.href;
78
+ return;
79
+ }
80
+ await navigate(url, opts, 'goto', 0);
81
+ }
82
+ /** Reruns every load for the current page. */
83
+ export function invalidateAll() {
84
+ return navigate(new URL(location.href), {
85
+ replaceState: true,
86
+ noScroll: true,
87
+ keepFocus: true,
88
+ invalidateAll: true,
89
+ state: page.state
90
+ }, 'goto', 0);
91
+ }
92
+ /**
93
+ * Loads the data and code for a URL so a later navigation to it is instant.
94
+ * Tern does this automatically for links; see data-tern-preload.
95
+ */
96
+ export function preloadData(href) {
97
+ const url = new URL(href, location.href);
98
+ const key = url.pathname + url.search;
99
+ const hit = preloads.get(key);
100
+ if (hit && Date.now() - hit.at < PRELOAD_TTL) {
101
+ return hit.promise;
102
+ }
103
+ const promise = fetchPayload(url).then(async (p) => {
104
+ if (p?.type === 'data' && p.route) {
105
+ await loadRoute(p.route).catch(() => { });
106
+ }
107
+ return p;
108
+ });
109
+ preloads.set(key, { at: Date.now(), promise });
110
+ return promise;
111
+ }
112
+ /** Applies a form action result to the page, as `use:enhance` does. */
113
+ export async function applyAction(result) {
114
+ switch (result.type) {
115
+ case 'success':
116
+ case 'failure':
117
+ page.status = result.status;
118
+ setForm(result.data ?? null);
119
+ return;
120
+ case 'redirect':
121
+ await goto(result.location, { invalidateAll: true });
122
+ return;
123
+ case 'error':
124
+ await render({
125
+ type: 'error',
126
+ route: page.route.id ?? undefined,
127
+ params: page.params,
128
+ status: result.status,
129
+ error: result.error,
130
+ nodes: nodes.slice(0, layoutCount),
131
+ failed: layoutCount
132
+ }, page.url, page.state, token);
133
+ }
134
+ }
135
+ function setForm(form) {
136
+ page.form = form;
137
+ props.form = form;
138
+ }
139
+ function dataURL(url) {
140
+ const path = url.pathname.replace(/\/$/, '');
141
+ return `${path}/__data.json${url.search}`;
142
+ }
143
+ async function fetchPayload(url) {
144
+ try {
145
+ const res = await fetch(dataURL(url), {
146
+ headers: { accept: 'application/json' },
147
+ credentials: 'same-origin'
148
+ });
149
+ if (!res.ok || !res.headers.get('content-type')?.includes('application/json')) {
150
+ return null;
151
+ }
152
+ return await readStreamed(res);
153
+ }
154
+ catch {
155
+ return null;
156
+ }
157
+ }
158
+ function takePreload(url) {
159
+ const key = url.pathname + url.search;
160
+ const hit = preloads.get(key);
161
+ preloads.delete(key);
162
+ if (hit && Date.now() - hit.at < PRELOAD_TTL) {
163
+ return hit.promise;
164
+ }
165
+ return undefined;
166
+ }
167
+ async function navigate(url, opts, type, redirects, popIndex) {
168
+ const id = ++token;
169
+ // A change of hash alone is a scroll, not a navigation.
170
+ if (type !== 'popstate' && !opts.invalidateAll && url.hash && samePage(url)) {
171
+ saveScroll();
172
+ history.pushState({ ternIndex: (historyIndex = Date.now()), ternState: $state.snapshot(opts.state ?? {}) }, '', url);
173
+ scrollToHash(url.hash);
174
+ return;
175
+ }
176
+ const from = current();
177
+ setNavigating({ from, to: { url, params: null, route: { id: null } }, type });
178
+ const payload = await (opts.invalidateAll ? undefined : takePreload(url)) ?? (await fetchPayload(url));
179
+ if (id !== token)
180
+ return;
181
+ if (!payload) {
182
+ location.href = url.href; // not a page, or the server is unreachable
183
+ return;
184
+ }
185
+ if (payload.type === 'redirect' && payload.location) {
186
+ const next = new URL(payload.location, url);
187
+ if (redirects >= MAX_REDIRECTS || next.origin !== location.origin) {
188
+ location.href = next.href;
189
+ return;
190
+ }
191
+ return navigate(next, { ...opts, replaceState: true }, type, redirects + 1);
192
+ }
193
+ if (type !== 'popstate')
194
+ saveScroll(); // popstate saved before historyIndex changed
195
+ const state = opts.state ?? (type === 'popstate' ? history.state?.ternState ?? {} : {});
196
+ const ok = await render(payload, url, state, id);
197
+ if (id !== token)
198
+ return;
199
+ if (!ok) {
200
+ location.href = url.href;
201
+ return;
202
+ }
203
+ if (type !== 'popstate') {
204
+ // History entries must be structured-cloneable: no state proxies.
205
+ const entry = { ternIndex: historyIndex, ternState: $state.snapshot(state) };
206
+ if (opts.replaceState) {
207
+ history.replaceState(entry, '', url);
208
+ }
209
+ else {
210
+ entry.ternIndex = historyIndex = Date.now();
211
+ history.pushState(entry, '', url);
212
+ }
213
+ }
214
+ setNavigating(null);
215
+ await tick();
216
+ if (type === 'popstate') {
217
+ const pos = popIndex != null ? scrollPositions[popIndex] : undefined;
218
+ scrollTo(pos?.x ?? 0, pos?.y ?? 0);
219
+ }
220
+ else if (!opts.noScroll) {
221
+ if (url.hash)
222
+ scrollToHash(url.hash);
223
+ else
224
+ scrollTo(0, 0);
225
+ }
226
+ if (!opts.keepFocus) {
227
+ resetFocus();
228
+ }
229
+ }
230
+ function samePage(url) {
231
+ return url.pathname === location.pathname && url.search === location.search;
232
+ }
233
+ function current() {
234
+ if (!started)
235
+ return null;
236
+ return { url: page.url, params: page.params, route: { id: page.route.id } };
237
+ }
238
+ async function loadLayout(i) {
239
+ const entry = manifest.layouts[i];
240
+ if (!entry?.component)
241
+ return Passthrough;
242
+ return (await entry.component()).default;
243
+ }
244
+ function loadRoute(id) {
245
+ const route = manifest.routes[id];
246
+ if (!route)
247
+ return Promise.reject(new Error(`unknown route ${id}`));
248
+ return Promise.all([...route.layouts.map(loadLayout), route.page().then((m) => m.default)]);
249
+ }
250
+ /** Renders a payload. It reports false if the payload is not a page. */
251
+ async function render(p, url, state, id) {
252
+ const what = plan(manifest, p);
253
+ let components;
254
+ switch (what.kind) {
255
+ case 'not-a-page':
256
+ return false;
257
+ case 'page':
258
+ components = await loadRoute(what.route);
259
+ layoutCount = what.layouts.length;
260
+ break;
261
+ case 'error': {
262
+ const errors = what.route ? manifest.routes[what.route].errors : manifest.fallback.errors;
263
+ components = [
264
+ ...(await Promise.all(what.layouts.map(loadLayout))),
265
+ (await errors[what.errorIndex].component()).default
266
+ ];
267
+ layoutCount = what.layouts.length;
268
+ break;
269
+ }
270
+ case 'default-error':
271
+ components = [DefaultError];
272
+ layoutCount = 0;
273
+ }
274
+ nodes = p.nodes ?? [];
275
+ if (id !== token)
276
+ return true; // superseded; the newer navigation renders
277
+ applyPayloadToPage(p, url, state, what.data);
278
+ props.components = components;
279
+ props.data = what.data;
280
+ props.form = page.form;
281
+ return true;
282
+ }
283
+ function saveScroll() {
284
+ scrollPositions[historyIndex] = { x: scrollX, y: scrollY };
285
+ try {
286
+ sessionStorage.setItem(SCROLL_KEY, JSON.stringify(scrollPositions));
287
+ }
288
+ catch {
289
+ // Storage can be full or disabled; scroll restoration is best effort.
290
+ }
291
+ }
292
+ function scrollToHash(hash) {
293
+ const id = decodeURIComponent(hash.slice(1));
294
+ const el = document.getElementById(id) ?? document.getElementsByName(id)[0];
295
+ if (el)
296
+ el.scrollIntoView();
297
+ else
298
+ scrollTo(0, 0);
299
+ }
300
+ /** Moves focus to the document and announces the new page to screen readers. */
301
+ function resetFocus() {
302
+ const body = document.body;
303
+ const tabindex = body.getAttribute('tabindex');
304
+ body.tabIndex = -1;
305
+ body.focus({ preventScroll: true });
306
+ if (tabindex === null)
307
+ body.removeAttribute('tabindex');
308
+ else
309
+ body.setAttribute('tabindex', tabindex);
310
+ if (!announcer) {
311
+ announcer = document.createElement('div');
312
+ announcer.setAttribute('aria-live', 'assertive');
313
+ announcer.setAttribute('aria-atomic', 'true');
314
+ announcer.style.cssText =
315
+ 'position:absolute;left:0;top:0;clip:rect(0 0 0 0);clip-path:inset(50%);overflow:hidden;white-space:nowrap;width:1px;height:1px';
316
+ document.body.append(announcer);
317
+ }
318
+ announcer.textContent = document.title || page.url.pathname;
319
+ }
320
+ function findAnchor(event) {
321
+ for (const el of event.composedPath()) {
322
+ if (el instanceof HTMLAnchorElement || el instanceof SVGAElement)
323
+ return el;
324
+ }
325
+ return null;
326
+ }
327
+ function anchorURL(a) {
328
+ const href = a instanceof SVGAElement ? a.href.baseVal : a.href;
329
+ if (!href)
330
+ return null;
331
+ const url = new URL(href, document.baseURI);
332
+ if (url.origin !== location.origin || !/^https?:$/.test(url.protocol))
333
+ return null;
334
+ const target = a instanceof SVGAElement ? a.target.baseVal : a.target;
335
+ if (target && target !== '_self')
336
+ return null;
337
+ if (a.hasAttribute('download') || (a.getAttribute('rel') ?? '').split(/\s+/).includes('external'))
338
+ return null;
339
+ if (a.closest('[data-tern-reload]:not([data-tern-reload="off"])'))
340
+ return null;
341
+ return url;
342
+ }
343
+ function preloadMode(a) {
344
+ return a.closest('[data-tern-preload]')?.getAttribute('data-tern-preload') ?? 'tap';
345
+ }
346
+ function listen() {
347
+ document.addEventListener('click', (event) => {
348
+ if (event.defaultPrevented || event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) {
349
+ return;
350
+ }
351
+ const a = findAnchor(event);
352
+ const url = a && anchorURL(a);
353
+ if (!a || !url)
354
+ return;
355
+ event.preventDefault();
356
+ void navigate(url, {
357
+ noScroll: a.closest('[data-tern-noscroll]') !== null,
358
+ replaceState: a.closest('[data-tern-replacestate]') !== null || url.href === location.href
359
+ }, 'link', 0);
360
+ });
361
+ let hoverTimer;
362
+ document.addEventListener('mouseover', (event) => {
363
+ clearTimeout(hoverTimer);
364
+ const a = findAnchor(event);
365
+ const url = a && anchorURL(a);
366
+ if (!a || !url || preloadMode(a) !== 'hover' || samePage(url))
367
+ return;
368
+ hoverTimer = setTimeout(() => void preloadData(url), 20);
369
+ });
370
+ const onTap = (event) => {
371
+ const a = findAnchor(event);
372
+ const url = a && anchorURL(a);
373
+ if (!a || !url || preloadMode(a) === 'off' || samePage(url))
374
+ return;
375
+ void preloadData(url);
376
+ };
377
+ document.addEventListener('mousedown', onTap);
378
+ document.addEventListener('touchstart', onTap, { passive: true });
379
+ addEventListener('popstate', (event) => {
380
+ const index = event.state?.ternIndex;
381
+ if (index == null)
382
+ return;
383
+ saveScroll();
384
+ historyIndex = index;
385
+ void navigate(new URL(location.href), { state: event.state?.ternState ?? {} }, 'popstate', 0, index);
386
+ });
387
+ addEventListener('pagehide', saveScroll);
388
+ }
@@ -0,0 +1,38 @@
1
+ import type { ErrorEntry, Payload } from './types.js';
2
+ /** The parts of a manifest that decide what renders. */
3
+ export interface Tree<C> {
4
+ routes: Record<string, {
5
+ layouts: number[];
6
+ errors: {
7
+ layouts: number;
8
+ }[];
9
+ } & C>;
10
+ fallback: {
11
+ layouts: number[];
12
+ errors: {
13
+ layouts: number;
14
+ }[];
15
+ };
16
+ }
17
+ /** What to render for a payload. Shared by the server and the client so hydration matches. */
18
+ export type Plan = {
19
+ kind: 'page';
20
+ route: string;
21
+ layouts: number[];
22
+ data: Record<string, unknown>[];
23
+ } | {
24
+ kind: 'error';
25
+ route: string | null;
26
+ layouts: number[];
27
+ errorIndex: number;
28
+ data: Record<string, unknown>[];
29
+ } | {
30
+ kind: 'default-error';
31
+ data: Record<string, unknown>[];
32
+ } | {
33
+ kind: 'not-a-page';
34
+ };
35
+ export declare function mergeData(list: (Record<string, unknown> | null | undefined)[]): Record<string, unknown>[];
36
+ /** The innermost error page whose layouts all loaded successfully. */
37
+ export declare function pickErrorPage(errors: Pick<ErrorEntry, 'layouts'>[], failed: number): number;
38
+ export declare function plan<C>(tree: Tree<C>, p: Payload): Plan;
@@ -0,0 +1,45 @@
1
+ export function mergeData(list) {
2
+ const out = [];
3
+ let acc = {};
4
+ for (const node of list) {
5
+ acc = { ...acc, ...(node ?? {}) };
6
+ out.push(acc);
7
+ }
8
+ return out;
9
+ }
10
+ /** The innermost error page whose layouts all loaded successfully. */
11
+ export function pickErrorPage(errors, failed) {
12
+ let pick = -1;
13
+ errors.forEach((e, i) => {
14
+ if (e.layouts <= failed)
15
+ pick = i;
16
+ });
17
+ return pick;
18
+ }
19
+ export function plan(tree, p) {
20
+ const received = p.nodes ?? [];
21
+ const route = p.route ? tree.routes[p.route] : undefined;
22
+ if (p.type === 'data') {
23
+ if (!route || !p.route)
24
+ return { kind: 'not-a-page' };
25
+ return { kind: 'page', route: p.route, layouts: route.layouts, data: mergeData(received) };
26
+ }
27
+ if (p.type !== 'error' || (p.route && !route)) {
28
+ return { kind: 'not-a-page' }; // an endpoint, or a non-render result
29
+ }
30
+ const layouts = route ? route.layouts : tree.fallback.layouts;
31
+ const errors = route ? route.errors : tree.fallback.errors;
32
+ const index = pickErrorPage(errors, p.failed ?? 0);
33
+ if (index < 0) {
34
+ return { kind: 'default-error', data: [{}] };
35
+ }
36
+ const above = errors[index].layouts;
37
+ const merged = mergeData(received.slice(0, above));
38
+ return {
39
+ kind: 'error',
40
+ route: p.route ?? null,
41
+ layouts: layouts.slice(0, above),
42
+ errorIndex: index,
43
+ data: [...merged, merged[merged.length - 1] ?? {}]
44
+ };
45
+ }
@@ -0,0 +1,51 @@
1
+ import type { Navigation, Payload } from './types.js';
2
+ declare class PageState {
3
+ /** The current URL. */
4
+ url: URL;
5
+ /** The current route's parameters. */
6
+ params: Record<string, string>;
7
+ /** The current route. `id` is null when no route matched. */
8
+ route: {
9
+ id: string | null;
10
+ };
11
+ /** The HTTP status of the current page. */
12
+ status: number;
13
+ /** The error being shown, or null. */
14
+ error: {
15
+ message: string;
16
+ } | null;
17
+ /** The merged data of every layout and the page. */
18
+ data: Record<string, unknown>;
19
+ /** The result of the last form action, or null. */
20
+ form: unknown;
21
+ /** State stored with the current history entry (see goto's `state`). */
22
+ state: Record<string, unknown>;
23
+ }
24
+ /**
25
+ * Reactive information about the current page. Read it in components or
26
+ * in `$derived`/`$effect`; it updates on every navigation.
27
+ *
28
+ * ```svelte
29
+ * <script>
30
+ * import { page } from '@ternkit/tern';
31
+ * </script>
32
+ *
33
+ * <a href="/" aria-current={page.url.pathname === '/' ? 'page' : undefined}>Home</a>
34
+ * ```
35
+ */
36
+ export declare const page: PageState;
37
+ /**
38
+ * The navigation in progress, or `null`. Use it to show a loading
39
+ * indicator:
40
+ *
41
+ * ```svelte
42
+ * {#if navigating.current}<Spinner />{/if}
43
+ * ```
44
+ */
45
+ export declare const navigating: {
46
+ readonly current: Navigation | null;
47
+ };
48
+ export declare function setNavigating(n: Navigation | null): void;
49
+ /** Updates page state for a rendered payload. */
50
+ export declare function applyPayloadToPage(p: Payload, url: URL, state: Record<string, unknown>, data: Record<string, unknown>[]): void;
51
+ export {};
@@ -0,0 +1,62 @@
1
+ class PageState {
2
+ /** The current URL. */
3
+ url = $state(new URL('http://localhost/'));
4
+ /** The current route's parameters. */
5
+ params = $state({});
6
+ /** The current route. `id` is null when no route matched. */
7
+ route = $state({ id: null });
8
+ /** The HTTP status of the current page. */
9
+ status = $state(200);
10
+ /** The error being shown, or null. */
11
+ error = $state(null);
12
+ /** The merged data of every layout and the page. */
13
+ data = $state({});
14
+ /** The result of the last form action, or null. */
15
+ form = $state(null);
16
+ /** State stored with the current history entry (see goto's `state`). */
17
+ state = $state({});
18
+ }
19
+ /**
20
+ * Reactive information about the current page. Read it in components or
21
+ * in `$derived`/`$effect`; it updates on every navigation.
22
+ *
23
+ * ```svelte
24
+ * <script>
25
+ * import { page } from '@ternkit/tern';
26
+ * </script>
27
+ *
28
+ * <a href="/" aria-current={page.url.pathname === '/' ? 'page' : undefined}>Home</a>
29
+ * ```
30
+ */
31
+ export const page = new PageState();
32
+ class NavigatingState {
33
+ current = $state(null);
34
+ }
35
+ const navigatingState = new NavigatingState();
36
+ /**
37
+ * The navigation in progress, or `null`. Use it to show a loading
38
+ * indicator:
39
+ *
40
+ * ```svelte
41
+ * {#if navigating.current}<Spinner />{/if}
42
+ * ```
43
+ */
44
+ export const navigating = {
45
+ get current() {
46
+ return navigatingState.current;
47
+ }
48
+ };
49
+ export function setNavigating(n) {
50
+ navigatingState.current = n;
51
+ }
52
+ /** Updates page state for a rendered payload. */
53
+ export function applyPayloadToPage(p, url, state, data) {
54
+ page.url = url;
55
+ page.params = p.params ?? {};
56
+ page.route = { id: p.route ?? null };
57
+ page.status = p.status;
58
+ page.error = p.type === 'error' ? (p.error ?? { message: 'Error' }) : null;
59
+ page.data = data[data.length - 1] ?? {};
60
+ page.state = state;
61
+ page.form = p.hasForm ? (p.form ?? null) : null;
62
+ }
@@ -0,0 +1,91 @@
1
+ import type { Component } from 'svelte';
2
+ /** A lazily imported Svelte component module. */
3
+ export type ComponentLoader = () => Promise<{
4
+ default: Component<any>;
5
+ }>;
6
+ export interface LayoutEntry {
7
+ dir: string;
8
+ component: ComponentLoader | null;
9
+ hasLoad: boolean;
10
+ }
11
+ export interface ErrorEntry {
12
+ /** How many of the route's layouts wrap this error page. */
13
+ layouts: number;
14
+ component: ComponentLoader;
15
+ }
16
+ export interface RouteEntry {
17
+ layouts: number[];
18
+ page: ComponentLoader;
19
+ hasLoad: boolean;
20
+ errors: ErrorEntry[];
21
+ }
22
+ /** The client manifest generated by `tern gen` in .tern/generated/manifest.js. */
23
+ export interface ClientManifest {
24
+ layouts: LayoutEntry[];
25
+ routes: Record<string, RouteEntry>;
26
+ fallback: {
27
+ layouts: number[];
28
+ errors: ErrorEntry[];
29
+ };
30
+ }
31
+ /** What the server sends for a page, inline or from __data.json. */
32
+ export interface Payload {
33
+ type: 'data' | 'error' | 'redirect' | 'success' | 'failure';
34
+ route?: string;
35
+ params?: Record<string, string>;
36
+ status: number;
37
+ nodes?: (Record<string, unknown> | null)[];
38
+ failed?: number;
39
+ error?: {
40
+ message: string;
41
+ };
42
+ location?: string;
43
+ data?: unknown;
44
+ form?: unknown;
45
+ hasForm?: boolean;
46
+ }
47
+ /** The result of submitting a form action with `use:enhance`. */
48
+ export type ActionResult<Success = Record<string, unknown>, Failure = Record<string, unknown>> = {
49
+ type: 'success';
50
+ status: number;
51
+ data?: Success;
52
+ } | {
53
+ type: 'failure';
54
+ status: number;
55
+ data?: Failure;
56
+ } | {
57
+ type: 'redirect';
58
+ status: number;
59
+ location: string;
60
+ } | {
61
+ type: 'error';
62
+ status: number;
63
+ error: {
64
+ message: string;
65
+ };
66
+ };
67
+ export interface NavigationTarget {
68
+ url: URL;
69
+ params: Record<string, string> | null;
70
+ route: {
71
+ id: string | null;
72
+ };
73
+ }
74
+ export type NavigationType = 'enter' | 'link' | 'goto' | 'popstate' | 'form';
75
+ export interface Navigation {
76
+ from: NavigationTarget | null;
77
+ to: NavigationTarget | null;
78
+ type: NavigationType;
79
+ }
80
+ export interface GotoOptions {
81
+ /** Replace the current history entry instead of adding one. */
82
+ replaceState?: boolean;
83
+ /** Keep the scroll position instead of scrolling to the top. */
84
+ noScroll?: boolean;
85
+ /** Keep focus on the currently focused element. */
86
+ keepFocus?: boolean;
87
+ /** Rerun every load, even for the current URL. */
88
+ invalidateAll?: boolean;
89
+ /** State to store in the history entry, available as page.state. */
90
+ state?: Record<string, unknown>;
91
+ }
@@ -0,0 +1 @@
1
+ export {};