@ternkit/tern 0.0.0-stage → 0.4.0

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,506 @@
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 { runAfter, runBefore, runOn } from './navigation.js';
7
+ import { plan } from './select.js';
8
+ import { applyPayloadToPage, page, setNavigating } from './state.svelte.js';
9
+ const MAX_REDIRECTS = 20;
10
+ const PRELOAD_TTL = 10_000;
11
+ const SCROLL_KEY = 'tern:scroll';
12
+ let manifest;
13
+ let started = false;
14
+ let token = 0;
15
+ let historyIndex = 0;
16
+ let nodes = [];
17
+ let layoutCount = 0;
18
+ let scrollPositions = {};
19
+ let announcer = null;
20
+ const props = $state({
21
+ components: [],
22
+ data: [],
23
+ form: null
24
+ });
25
+ const preloads = new Map();
26
+ const held = new Map();
27
+ const invalidated = new Set();
28
+ let ignoreNextPop = false;
29
+ /** Marks invalidate() reloads, which are not navigations: no hooks, no navigating state. */
30
+ const RELOAD = Symbol('reload');
31
+ /** Starts the client. Called by the generated entry module. */
32
+ export async function start(m) {
33
+ manifest = m;
34
+ const el = document.getElementById('__tern_data');
35
+ const target = document.getElementById('tern-root');
36
+ if (!el || !target) {
37
+ throw new Error('Tern: the page is missing its data or mount point; check that src/app.html contains %tern.head% and %tern.body%');
38
+ }
39
+ try {
40
+ scrollPositions = JSON.parse(sessionStorage.getItem(SCROLL_KEY) ?? '{}');
41
+ }
42
+ catch {
43
+ scrollPositions = {};
44
+ }
45
+ historyIndex = history.state?.ternIndex ?? 0;
46
+ if (history.state?.ternIndex == null) {
47
+ history.replaceState({ ...history.state, ternIndex: historyIndex, ternState: {} }, '');
48
+ }
49
+ history.scrollRestoration = 'manual';
50
+ const url = new URL(location.href);
51
+ const payload = JSON.parse(el.textContent ?? '{}');
52
+ // Deferred values stream in as inline scripts calling __tern_resolve.
53
+ const w = window;
54
+ const scope = new DeferredScope();
55
+ payload.nodes = revive(payload.nodes, (id) => scope.promise(id));
56
+ for (const chunk of Object.values(w.__tern_d ?? {}))
57
+ scope.settle(chunk);
58
+ w.__tern_on = (chunk) => scope.settle(chunk);
59
+ await render(payload, url, history.state?.ternState ?? {}, token);
60
+ if (target.hasAttribute('data-tern-ssr')) {
61
+ hydrate(Root, { target, props });
62
+ }
63
+ else {
64
+ mount(Root, { target, props });
65
+ }
66
+ started = true;
67
+ await tick();
68
+ runAfter({ from: null, to: current(), type: 'enter' });
69
+ const saved = scrollPositions[historyIndex];
70
+ if (saved) {
71
+ scrollTo(saved.x, saved.y);
72
+ }
73
+ else if (url.hash) {
74
+ scrollToHash(url.hash);
75
+ }
76
+ listen();
77
+ }
78
+ /** Navigates to href, which may be relative to the current URL. */
79
+ export async function goto(href, opts = {}) {
80
+ if (!started) {
81
+ throw new Error('Tern: goto() was called before the app started');
82
+ }
83
+ const url = new URL(href, location.href);
84
+ if (url.origin !== location.origin) {
85
+ location.href = url.href;
86
+ return;
87
+ }
88
+ await navigate(url, opts, 'goto', 0);
89
+ }
90
+ /**
91
+ * Reruns the loads that depend on key, as declared with ctx.Depends(key)
92
+ * in Go, plus the current page's load.
93
+ */
94
+ export function invalidate(key) {
95
+ invalidated.add(key);
96
+ preloads.clear();
97
+ return navigate(new URL(location.href), {
98
+ replaceState: true,
99
+ noScroll: true,
100
+ keepFocus: true,
101
+ state: page.state,
102
+ [RELOAD]: true
103
+ }, 'goto', 0);
104
+ }
105
+ /** Reruns every load for the current page. */
106
+ export function invalidateAll() {
107
+ return navigate(new URL(location.href), {
108
+ replaceState: true,
109
+ noScroll: true,
110
+ keepFocus: true,
111
+ invalidateAll: true,
112
+ state: page.state,
113
+ [RELOAD]: true
114
+ }, 'goto', 0);
115
+ }
116
+ /**
117
+ * Loads the data and code for a URL so a later navigation to it is instant.
118
+ * Tern does this automatically for links; see data-tern-preload.
119
+ */
120
+ export function preloadData(href) {
121
+ const url = new URL(href, location.href);
122
+ const key = url.pathname + url.search;
123
+ const hit = preloads.get(key);
124
+ if (hit && Date.now() - hit.at < PRELOAD_TTL) {
125
+ return hit.promise;
126
+ }
127
+ const promise = fetchPayload(url).then(async (p) => {
128
+ if (p?.type === 'data' && p.route) {
129
+ await loadRoute(p.route).catch(() => { });
130
+ }
131
+ return p;
132
+ });
133
+ preloads.set(key, { at: Date.now(), promise });
134
+ return promise;
135
+ }
136
+ /** Applies a form action result to the page, as `use:enhance` does. */
137
+ export async function applyAction(result) {
138
+ switch (result.type) {
139
+ case 'success':
140
+ case 'failure':
141
+ page.status = result.status;
142
+ setForm(result.data ?? null);
143
+ return;
144
+ case 'redirect':
145
+ await goto(result.location, { invalidateAll: true });
146
+ return;
147
+ case 'error':
148
+ await render({
149
+ type: 'error',
150
+ route: page.route.id ?? undefined,
151
+ params: page.params,
152
+ status: result.status,
153
+ error: result.error,
154
+ nodes: nodes.slice(0, layoutCount),
155
+ failed: layoutCount
156
+ }, page.url, page.state, token);
157
+ }
158
+ }
159
+ function setForm(form) {
160
+ page.form = form;
161
+ props.form = form;
162
+ }
163
+ function dataURL(url, reuse) {
164
+ const path = url.pathname.replace(/\/$/, '');
165
+ let target = `${path}/__data.json${url.search}`;
166
+ if (reuse) {
167
+ const here = url.pathname + url.search;
168
+ const entries = [];
169
+ for (const [i, h] of held) {
170
+ if (!h.uses)
171
+ continue; // no load: nothing to reuse
172
+ if (h.uses.url && h.url !== here)
173
+ continue;
174
+ if (h.uses.deps?.some((d) => invalidated.has(d)))
175
+ continue;
176
+ const p = {};
177
+ for (const name of h.uses.params ?? [])
178
+ p[name] = h.params[name] ?? '';
179
+ entries.push({ i, p });
180
+ }
181
+ if (entries.length) {
182
+ target += (url.search ? '&' : '?') + 'x-tern-reuse=' + encodeURIComponent(JSON.stringify(entries));
183
+ }
184
+ }
185
+ return target;
186
+ }
187
+ async function fetchPayload(url, reuse = true) {
188
+ try {
189
+ const res = await fetch(dataURL(url, reuse), {
190
+ headers: { accept: 'application/json' },
191
+ credentials: 'same-origin'
192
+ });
193
+ if (!res.ok || !res.headers.get('content-type')?.includes('application/json')) {
194
+ return null;
195
+ }
196
+ return await readStreamed(res);
197
+ }
198
+ catch {
199
+ return null;
200
+ }
201
+ }
202
+ function takePreload(url) {
203
+ const key = url.pathname + url.search;
204
+ const hit = preloads.get(key);
205
+ preloads.delete(key);
206
+ if (hit && Date.now() - hit.at < PRELOAD_TTL) {
207
+ return hit.promise;
208
+ }
209
+ return undefined;
210
+ }
211
+ async function navigate(url, opts, type, redirects, popIndex, fromIndex) {
212
+ const id = ++token;
213
+ // A change of hash alone is a scroll, not a navigation.
214
+ if (type !== 'popstate' && !opts.invalidateAll && url.hash && samePage(url)) {
215
+ saveScroll();
216
+ history.pushState({ ternIndex: (historyIndex = historyIndex + 1), ternState: $state.snapshot(opts.state ?? {}) }, '', url);
217
+ scrollToHash(url.hash);
218
+ return;
219
+ }
220
+ const from = current();
221
+ const nav = { from, to: { url, params: null, route: { id: null } }, type };
222
+ const reload = opts[RELOAD] === true;
223
+ if (!reload && !runBefore(nav, false)) {
224
+ if (type === 'popstate' && popIndex != null && fromIndex != null) {
225
+ // The browser already moved; move back to where we were.
226
+ ignoreNextPop = true;
227
+ historyIndex = fromIndex;
228
+ history.go(fromIndex - popIndex);
229
+ }
230
+ return;
231
+ }
232
+ if (!reload)
233
+ setNavigating(nav);
234
+ const payload = await (opts.invalidateAll ? undefined : takePreload(url)) ?? (await fetchPayload(url, !opts.invalidateAll));
235
+ if (id !== token)
236
+ return;
237
+ if (!payload) {
238
+ location.href = url.href; // not a page, or the server is unreachable
239
+ return;
240
+ }
241
+ if (payload.type === 'redirect' && payload.location) {
242
+ const next = new URL(payload.location, url);
243
+ if (redirects >= MAX_REDIRECTS || next.origin !== location.origin) {
244
+ location.href = next.href;
245
+ return;
246
+ }
247
+ return navigate(next, { ...opts, replaceState: true, [RELOAD]: false }, type, redirects + 1);
248
+ }
249
+ if (type !== 'popstate')
250
+ saveScroll(); // popstate saved before historyIndex changed
251
+ const state = opts.state ?? (type === 'popstate' ? history.state?.ternState ?? {} : {});
252
+ nav.to = { url, params: payload.params ?? {}, route: { id: payload.route ?? null } };
253
+ let finish;
254
+ const complete = new Promise((resolve) => (finish = resolve));
255
+ const cleanups = reload ? [] : await runOn(nav, complete);
256
+ if (id !== token)
257
+ return;
258
+ const ok = await render(payload, url, state, id);
259
+ if (id !== token)
260
+ return;
261
+ if (!ok) {
262
+ location.href = url.href;
263
+ return;
264
+ }
265
+ if (type !== 'popstate') {
266
+ // History entries must be structured-cloneable: no state proxies.
267
+ const entry = { ternIndex: historyIndex, ternState: $state.snapshot(state) };
268
+ if (opts.replaceState) {
269
+ history.replaceState(entry, '', url);
270
+ }
271
+ else {
272
+ entry.ternIndex = historyIndex = historyIndex + 1;
273
+ history.pushState(entry, '', url);
274
+ }
275
+ }
276
+ setNavigating(null);
277
+ await tick();
278
+ if (type === 'popstate') {
279
+ const pos = popIndex != null ? scrollPositions[popIndex] : undefined;
280
+ scrollTo(pos?.x ?? 0, pos?.y ?? 0);
281
+ }
282
+ else if (!opts.noScroll) {
283
+ if (url.hash)
284
+ scrollToHash(url.hash);
285
+ else
286
+ scrollTo(0, 0);
287
+ }
288
+ if (!opts.keepFocus) {
289
+ resetFocus();
290
+ }
291
+ invalidated.clear();
292
+ finish();
293
+ if (!reload)
294
+ runAfter(nav);
295
+ for (const fn of cleanups)
296
+ fn();
297
+ }
298
+ function samePage(url) {
299
+ return url.pathname === location.pathname && url.search === location.search;
300
+ }
301
+ function current() {
302
+ if (!started)
303
+ return null;
304
+ return { url: page.url, params: page.params, route: { id: page.route.id } };
305
+ }
306
+ async function loadLayout(i) {
307
+ const entry = manifest.layouts[i];
308
+ if (!entry?.component)
309
+ return Passthrough;
310
+ return (await entry.component()).default;
311
+ }
312
+ function loadRoute(id) {
313
+ const route = manifest.routes[id];
314
+ if (!route)
315
+ return Promise.reject(new Error(`unknown route ${id}`));
316
+ return Promise.all([...route.layouts.map(loadLayout), route.page().then((m) => m.default)]);
317
+ }
318
+ /** Renders a payload. It reports false if the payload is not a page. */
319
+ async function render(p, url, state, id) {
320
+ const route = p.route ? manifest.routes[p.route] : undefined;
321
+ const layouts = route ? route.layouts : manifest.fallback.layouts;
322
+ // Fill in the layout data the server skipped because we already have it.
323
+ if (p.reused?.length) {
324
+ p.nodes = [...(p.nodes ?? [])];
325
+ for (const k of p.reused) {
326
+ const li = layouts[k];
327
+ p.nodes[k] = li === undefined ? null : (held.get(li)?.data ?? null);
328
+ }
329
+ }
330
+ const what = plan(manifest, p);
331
+ let components;
332
+ switch (what.kind) {
333
+ case 'not-a-page':
334
+ return false;
335
+ case 'page':
336
+ components = await loadRoute(what.route);
337
+ layoutCount = what.layouts.length;
338
+ break;
339
+ case 'error': {
340
+ const errors = what.route ? manifest.routes[what.route].errors : manifest.fallback.errors;
341
+ components = [
342
+ ...(await Promise.all(what.layouts.map(loadLayout))),
343
+ (await errors[what.errorIndex].component()).default
344
+ ];
345
+ layoutCount = what.layouts.length;
346
+ break;
347
+ }
348
+ case 'default-error':
349
+ components = [DefaultError];
350
+ layoutCount = 0;
351
+ }
352
+ nodes = p.nodes ?? [];
353
+ if (id !== token)
354
+ return true; // superseded; the newer navigation renders
355
+ if (p.type === 'data') {
356
+ const here = url.pathname + url.search;
357
+ layouts.forEach((li, k) => {
358
+ if (p.reused?.includes(k))
359
+ return;
360
+ held.set(li, { data: p.nodes?.[k] ?? null, uses: p.uses?.[k] ?? null, params: p.params ?? {}, url: here });
361
+ });
362
+ }
363
+ applyPayloadToPage(p, url, state, what.data);
364
+ props.components = components;
365
+ props.data = what.data;
366
+ props.form = page.form;
367
+ return true;
368
+ }
369
+ function saveScroll() {
370
+ scrollPositions[historyIndex] = { x: scrollX, y: scrollY };
371
+ try {
372
+ sessionStorage.setItem(SCROLL_KEY, JSON.stringify(scrollPositions));
373
+ }
374
+ catch {
375
+ // Storage can be full or disabled; scroll restoration is best effort.
376
+ }
377
+ }
378
+ function scrollToHash(hash) {
379
+ const id = decodeURIComponent(hash.slice(1));
380
+ const el = document.getElementById(id) ?? document.getElementsByName(id)[0];
381
+ if (el)
382
+ el.scrollIntoView();
383
+ else
384
+ scrollTo(0, 0);
385
+ }
386
+ /** Moves focus to the document and announces the new page to screen readers. */
387
+ function resetFocus() {
388
+ const body = document.body;
389
+ const tabindex = body.getAttribute('tabindex');
390
+ body.tabIndex = -1;
391
+ body.focus({ preventScroll: true });
392
+ if (tabindex === null)
393
+ body.removeAttribute('tabindex');
394
+ else
395
+ body.setAttribute('tabindex', tabindex);
396
+ if (!announcer) {
397
+ announcer = document.createElement('div');
398
+ announcer.setAttribute('aria-live', 'assertive');
399
+ announcer.setAttribute('aria-atomic', 'true');
400
+ announcer.style.cssText =
401
+ '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';
402
+ document.body.append(announcer);
403
+ }
404
+ announcer.textContent = document.title || page.url.pathname;
405
+ }
406
+ function findAnchor(event) {
407
+ for (const el of event.composedPath()) {
408
+ if (el instanceof HTMLAnchorElement || el instanceof SVGAElement)
409
+ return el;
410
+ }
411
+ return null;
412
+ }
413
+ function anchorURL(a) {
414
+ const href = a instanceof SVGAElement ? a.href.baseVal : a.href;
415
+ if (!href)
416
+ return null;
417
+ const url = new URL(href, document.baseURI);
418
+ if (url.origin !== location.origin || !/^https?:$/.test(url.protocol))
419
+ return null;
420
+ const target = a instanceof SVGAElement ? a.target.baseVal : a.target;
421
+ if (target && target !== '_self')
422
+ return null;
423
+ if (a.hasAttribute('download') || (a.getAttribute('rel') ?? '').split(/\s+/).includes('external'))
424
+ return null;
425
+ if (a.closest('[data-tern-reload]:not([data-tern-reload="off"])'))
426
+ return null;
427
+ return url;
428
+ }
429
+ function preloadMode(a) {
430
+ return a.closest('[data-tern-preload]')?.getAttribute('data-tern-preload') ?? 'tap';
431
+ }
432
+ function listen() {
433
+ document.addEventListener('click', (event) => {
434
+ if (event.defaultPrevented || event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) {
435
+ return;
436
+ }
437
+ const a = findAnchor(event);
438
+ const url = a && anchorURL(a);
439
+ if (!a || !url)
440
+ return;
441
+ event.preventDefault();
442
+ void navigate(url, {
443
+ noScroll: a.closest('[data-tern-noscroll]') !== null,
444
+ replaceState: a.closest('[data-tern-replacestate]') !== null || url.href === location.href
445
+ }, 'link', 0);
446
+ });
447
+ let hoverTimer;
448
+ document.addEventListener('mouseover', (event) => {
449
+ clearTimeout(hoverTimer);
450
+ const a = findAnchor(event);
451
+ const url = a && anchorURL(a);
452
+ if (!a || !url || preloadMode(a) !== 'hover' || samePage(url))
453
+ return;
454
+ hoverTimer = setTimeout(() => void preloadData(url), 20);
455
+ });
456
+ const onTap = (event) => {
457
+ const a = findAnchor(event);
458
+ const url = a && anchorURL(a);
459
+ if (!a || !url || preloadMode(a) === 'off' || samePage(url))
460
+ return;
461
+ void preloadData(url);
462
+ };
463
+ document.addEventListener('mousedown', onTap);
464
+ document.addEventListener('touchstart', onTap, { passive: true });
465
+ // GET forms navigate on the client, like links. POST forms are handled
466
+ // by use:enhance or submit normally.
467
+ document.addEventListener('submit', (event) => {
468
+ if (event.defaultPrevented)
469
+ return;
470
+ const form = event.target;
471
+ const submitter = event.submitter;
472
+ const own = HTMLFormElement.prototype.cloneNode.call(form);
473
+ const method = submitter?.hasAttribute('formmethod') ? submitter.formMethod : own.method;
474
+ const target = submitter?.getAttribute('formtarget') ?? own.target;
475
+ if (method !== 'get' || (target && target !== '_self') || form.closest('[data-tern-reload]:not([data-tern-reload="off"])')) {
476
+ return;
477
+ }
478
+ const url = new URL(submitter?.hasAttribute('formaction') ? submitter.formAction : own.action);
479
+ if (url.origin !== location.origin)
480
+ return;
481
+ event.preventDefault();
482
+ url.search = new URLSearchParams(new FormData(form, submitter)).toString();
483
+ const filtering = url.pathname === location.pathname;
484
+ void navigate(url, { keepFocus: filtering, noScroll: filtering }, 'form', 0);
485
+ });
486
+ addEventListener('beforeunload', (event) => {
487
+ if (!runBefore({ from: current(), to: null, type: 'leave' }, true)) {
488
+ event.preventDefault();
489
+ event.returnValue = '';
490
+ }
491
+ });
492
+ addEventListener('popstate', (event) => {
493
+ if (ignoreNextPop) {
494
+ ignoreNextPop = false;
495
+ return;
496
+ }
497
+ const index = event.state?.ternIndex;
498
+ if (index == null)
499
+ return;
500
+ saveScroll();
501
+ const fromIndex = historyIndex;
502
+ historyIndex = index;
503
+ void navigate(new URL(location.href), { state: event.state?.ternState ?? {} }, 'popstate', 0, index, fromIndex);
504
+ });
505
+ addEventListener('pagehide', saveScroll);
506
+ }
@@ -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
+ }