@ternkit/tern 0.3.1 → 1.0.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.
package/dist/index.d.ts CHANGED
@@ -7,10 +7,12 @@
7
7
  * </script>
8
8
  * ```
9
9
  */
10
- export { page, navigating } from './runtime/state.svelte.js';
11
- export { goto, invalidateAll, preloadData, applyAction } from './runtime/router.svelte.js';
10
+ export { page, navigating, env } from './runtime/state.svelte.js';
11
+ export { goto, invalidate, invalidateAll, preloadData, applyAction } from './runtime/router.svelte.js';
12
+ export { beforeNavigate, onNavigate, afterNavigate } from './runtime/navigation.js';
13
+ export type { BeforeNavigateEvent, OnNavigateEvent } from './runtime/navigation.js';
12
14
  export { enhance } from './runtime/forms.js';
13
15
  export type { SubmitFunction, SubmitInput, SubmitResult } from './runtime/forms.js';
14
16
  export type { ActionResult, GotoOptions, Navigation, NavigationTarget, NavigationType } from './runtime/types.js';
15
17
  /** The version of the Tern client runtime. */
16
- export declare const VERSION = "0.3.1";
18
+ export declare const VERSION = "1.0.0";
package/dist/index.js CHANGED
@@ -7,8 +7,9 @@
7
7
  * </script>
8
8
  * ```
9
9
  */
10
- export { page, navigating } from './runtime/state.svelte.js';
11
- export { goto, invalidateAll, preloadData, applyAction } from './runtime/router.svelte.js';
10
+ export { page, navigating, env } from './runtime/state.svelte.js';
11
+ export { goto, invalidate, invalidateAll, preloadData, applyAction } from './runtime/router.svelte.js';
12
+ export { beforeNavigate, onNavigate, afterNavigate } from './runtime/navigation.js';
12
13
  export { enhance } from './runtime/forms.js';
13
14
  /** The version of the Tern client runtime. */
14
- export const VERSION = '0.3.1';
15
+ export const VERSION = '1.0.0';
@@ -0,0 +1,55 @@
1
+ import type { Navigation } from './types.js';
2
+ /** Passed to beforeNavigate callbacks. */
3
+ export interface BeforeNavigateEvent extends Navigation {
4
+ /** True when the browser is about to leave the app (a reload, closing the tab, or another site). */
5
+ willUnload: boolean;
6
+ /** Stops the navigation. When leaving the page, the browser asks the user to confirm. */
7
+ cancel(): void;
8
+ }
9
+ /** Passed to onNavigate callbacks. */
10
+ export interface OnNavigateEvent extends Navigation {
11
+ /** Resolves when the new page has rendered. */
12
+ complete: Promise<void>;
13
+ }
14
+ type BeforeFn = (nav: BeforeNavigateEvent) => void;
15
+ type OnFn = (nav: OnNavigateEvent) => void | Promise<void> | (() => void) | Promise<() => void>;
16
+ type AfterFn = (nav: Navigation) => void;
17
+ /**
18
+ * Runs before every navigation, including leaving the app. Call
19
+ * `nav.cancel()` to stop it, for example to keep unsaved changes:
20
+ *
21
+ * ```js
22
+ * beforeNavigate((nav) => {
23
+ * if (dirty && !confirm('Discard your changes?')) nav.cancel();
24
+ * });
25
+ * ```
26
+ *
27
+ * Call it while a component initializes; it is removed when the component is destroyed.
28
+ */
29
+ export declare function beforeNavigate(fn: BeforeFn): void;
30
+ /**
31
+ * Runs when a client-side navigation has loaded its data, right before the
32
+ * new page renders. Return a promise to delay rendering, for example to
33
+ * start a view transition; return a function to run after the navigation.
34
+ *
35
+ * ```js
36
+ * onNavigate((nav) => {
37
+ * if (!document.startViewTransition) return;
38
+ * return new Promise((resolve) => {
39
+ * document.startViewTransition(async () => {
40
+ * resolve();
41
+ * await nav.complete;
42
+ * });
43
+ * });
44
+ * });
45
+ * ```
46
+ */
47
+ export declare function onNavigate(fn: OnFn): void;
48
+ /** Runs after every navigation, including the first page load. */
49
+ export declare function afterNavigate(fn: AfterFn): void;
50
+ /** Runs beforeNavigate callbacks; reports whether the navigation may go ahead. */
51
+ export declare function runBefore(nav: Navigation, willUnload: boolean): boolean;
52
+ /** Runs onNavigate callbacks and returns the cleanups to call afterwards. */
53
+ export declare function runOn(nav: Navigation, complete: Promise<void>): Promise<(() => void)[]>;
54
+ export declare function runAfter(nav: Navigation): void;
55
+ export {};
@@ -0,0 +1,69 @@
1
+ import { onMount } from 'svelte';
2
+ const before = new Set();
3
+ const on = new Set();
4
+ const after = new Set();
5
+ /**
6
+ * Runs before every navigation, including leaving the app. Call
7
+ * `nav.cancel()` to stop it, for example to keep unsaved changes:
8
+ *
9
+ * ```js
10
+ * beforeNavigate((nav) => {
11
+ * if (dirty && !confirm('Discard your changes?')) nav.cancel();
12
+ * });
13
+ * ```
14
+ *
15
+ * Call it while a component initializes; it is removed when the component is destroyed.
16
+ */
17
+ export function beforeNavigate(fn) {
18
+ onMount(() => {
19
+ before.add(fn);
20
+ return () => before.delete(fn);
21
+ });
22
+ }
23
+ /**
24
+ * Runs when a client-side navigation has loaded its data, right before the
25
+ * new page renders. Return a promise to delay rendering, for example to
26
+ * start a view transition; return a function to run after the navigation.
27
+ *
28
+ * ```js
29
+ * onNavigate((nav) => {
30
+ * if (!document.startViewTransition) return;
31
+ * return new Promise((resolve) => {
32
+ * document.startViewTransition(async () => {
33
+ * resolve();
34
+ * await nav.complete;
35
+ * });
36
+ * });
37
+ * });
38
+ * ```
39
+ */
40
+ export function onNavigate(fn) {
41
+ onMount(() => {
42
+ on.add(fn);
43
+ return () => on.delete(fn);
44
+ });
45
+ }
46
+ /** Runs after every navigation, including the first page load. */
47
+ export function afterNavigate(fn) {
48
+ onMount(() => {
49
+ after.add(fn);
50
+ return () => after.delete(fn);
51
+ });
52
+ }
53
+ /** Runs beforeNavigate callbacks; reports whether the navigation may go ahead. */
54
+ export function runBefore(nav, willUnload) {
55
+ let cancelled = false;
56
+ const event = { ...nav, willUnload, cancel: () => (cancelled = true) };
57
+ for (const fn of before)
58
+ fn(event);
59
+ return !cancelled;
60
+ }
61
+ /** Runs onNavigate callbacks and returns the cleanups to call afterwards. */
62
+ export async function runOn(nav, complete) {
63
+ const results = await Promise.all([...on].map((fn) => fn({ ...nav, complete })));
64
+ return results.filter((r) => typeof r === 'function');
65
+ }
66
+ export function runAfter(nav) {
67
+ for (const fn of after)
68
+ fn(nav);
69
+ }
@@ -3,6 +3,11 @@ import type { ActionResult, ClientManifest, GotoOptions, Payload } from './types
3
3
  export declare function start(m: ClientManifest): Promise<void>;
4
4
  /** Navigates to href, which may be relative to the current URL. */
5
5
  export declare function goto(href: string | URL, opts?: GotoOptions): Promise<void>;
6
+ /**
7
+ * Reruns the loads that depend on key, as declared with ctx.Depends(key)
8
+ * in Go, plus the current page's load.
9
+ */
10
+ export declare function invalidate(key: string): Promise<void>;
6
11
  /** Reruns every load for the current page. */
7
12
  export declare function invalidateAll(): Promise<void>;
8
13
  /**
@@ -3,6 +3,7 @@ import DefaultError from './DefaultError.svelte';
3
3
  import Passthrough from './Passthrough.svelte';
4
4
  import Root from './Root.svelte';
5
5
  import { DeferredScope, readStreamed, revive } from './deferred.js';
6
+ import { runAfter, runBefore, runOn } from './navigation.js';
6
7
  import { plan } from './select.js';
7
8
  import { applyPayloadToPage, page, setNavigating } from './state.svelte.js';
8
9
  const MAX_REDIRECTS = 20;
@@ -22,6 +23,11 @@ const props = $state({
22
23
  form: null
23
24
  });
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');
25
31
  /** Starts the client. Called by the generated entry module. */
26
32
  export async function start(m) {
27
33
  manifest = m;
@@ -36,7 +42,7 @@ export async function start(m) {
36
42
  catch {
37
43
  scrollPositions = {};
38
44
  }
39
- historyIndex = history.state?.ternIndex ?? Date.now();
45
+ historyIndex = history.state?.ternIndex ?? 0;
40
46
  if (history.state?.ternIndex == null) {
41
47
  history.replaceState({ ...history.state, ternIndex: historyIndex, ternState: {} }, '');
42
48
  }
@@ -58,6 +64,8 @@ export async function start(m) {
58
64
  mount(Root, { target, props });
59
65
  }
60
66
  started = true;
67
+ await tick();
68
+ runAfter({ from: null, to: current(), type: 'enter' });
61
69
  const saved = scrollPositions[historyIndex];
62
70
  if (saved) {
63
71
  scrollTo(saved.x, saved.y);
@@ -73,12 +81,27 @@ export async function goto(href, opts = {}) {
73
81
  throw new Error('Tern: goto() was called before the app started');
74
82
  }
75
83
  const url = new URL(href, location.href);
76
- if (url.origin !== location.origin) {
84
+ if (!inApp(url)) {
77
85
  location.href = url.href;
78
86
  return;
79
87
  }
80
88
  await navigate(url, opts, 'goto', 0);
81
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
+ }
82
105
  /** Reruns every load for the current page. */
83
106
  export function invalidateAll() {
84
107
  return navigate(new URL(location.href), {
@@ -86,7 +109,8 @@ export function invalidateAll() {
86
109
  noScroll: true,
87
110
  keepFocus: true,
88
111
  invalidateAll: true,
89
- state: page.state
112
+ state: page.state,
113
+ [RELOAD]: true
90
114
  }, 'goto', 0);
91
115
  }
92
116
  /**
@@ -136,13 +160,34 @@ function setForm(form) {
136
160
  page.form = form;
137
161
  props.form = form;
138
162
  }
139
- function dataURL(url) {
163
+ function dataURL(url, reuse) {
140
164
  const path = url.pathname.replace(/\/$/, '');
141
- return `${path}/__data.json${url.search}`;
165
+ // The base path is part of the pathname; the server strips it.
166
+ let target = `${path}/__data.json${url.search}`;
167
+ if (reuse) {
168
+ const here = url.pathname + url.search;
169
+ const entries = [];
170
+ for (const [i, h] of held) {
171
+ if (!h.uses)
172
+ continue; // no load: nothing to reuse
173
+ if (h.uses.url && h.url !== here)
174
+ continue;
175
+ if (h.uses.deps?.some((d) => invalidated.has(d)))
176
+ continue;
177
+ const p = {};
178
+ for (const name of h.uses.params ?? [])
179
+ p[name] = h.params[name] ?? '';
180
+ entries.push({ i, p });
181
+ }
182
+ if (entries.length) {
183
+ target += (url.search ? '&' : '?') + 'x-tern-reuse=' + encodeURIComponent(JSON.stringify(entries));
184
+ }
185
+ }
186
+ return target;
142
187
  }
143
- async function fetchPayload(url) {
188
+ async function fetchPayload(url, reuse = true) {
144
189
  try {
145
- const res = await fetch(dataURL(url), {
190
+ const res = await fetch(dataURL(url, reuse), {
146
191
  headers: { accept: 'application/json' },
147
192
  credentials: 'same-origin'
148
193
  });
@@ -164,18 +209,30 @@ function takePreload(url) {
164
209
  }
165
210
  return undefined;
166
211
  }
167
- async function navigate(url, opts, type, redirects, popIndex) {
212
+ async function navigate(url, opts, type, redirects, popIndex, fromIndex) {
168
213
  const id = ++token;
169
214
  // A change of hash alone is a scroll, not a navigation.
170
215
  if (type !== 'popstate' && !opts.invalidateAll && url.hash && samePage(url)) {
171
216
  saveScroll();
172
- history.pushState({ ternIndex: (historyIndex = Date.now()), ternState: $state.snapshot(opts.state ?? {}) }, '', url);
217
+ history.pushState({ ternIndex: (historyIndex = historyIndex + 1), ternState: $state.snapshot(opts.state ?? {}) }, '', url);
173
218
  scrollToHash(url.hash);
174
219
  return;
175
220
  }
176
221
  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));
222
+ const nav = { from, to: { url, params: null, route: { id: null } }, type };
223
+ const reload = opts[RELOAD] === true;
224
+ if (!reload && !runBefore(nav, false)) {
225
+ if (type === 'popstate' && popIndex != null && fromIndex != null) {
226
+ // The browser already moved; move back to where we were.
227
+ ignoreNextPop = true;
228
+ historyIndex = fromIndex;
229
+ history.go(fromIndex - popIndex);
230
+ }
231
+ return;
232
+ }
233
+ if (!reload)
234
+ setNavigating(nav);
235
+ const payload = await (opts.invalidateAll ? undefined : takePreload(url)) ?? (await fetchPayload(url, !opts.invalidateAll));
179
236
  if (id !== token)
180
237
  return;
181
238
  if (!payload) {
@@ -184,15 +241,21 @@ async function navigate(url, opts, type, redirects, popIndex) {
184
241
  }
185
242
  if (payload.type === 'redirect' && payload.location) {
186
243
  const next = new URL(payload.location, url);
187
- if (redirects >= MAX_REDIRECTS || next.origin !== location.origin) {
244
+ if (redirects >= MAX_REDIRECTS || !inApp(next)) {
188
245
  location.href = next.href;
189
246
  return;
190
247
  }
191
- return navigate(next, { ...opts, replaceState: true }, type, redirects + 1);
248
+ return navigate(next, { ...opts, replaceState: true, [RELOAD]: false }, type, redirects + 1);
192
249
  }
193
250
  if (type !== 'popstate')
194
251
  saveScroll(); // popstate saved before historyIndex changed
195
252
  const state = opts.state ?? (type === 'popstate' ? history.state?.ternState ?? {} : {});
253
+ nav.to = { url, params: payload.params ?? {}, route: { id: payload.route ?? null } };
254
+ let finish;
255
+ const complete = new Promise((resolve) => (finish = resolve));
256
+ const cleanups = reload ? [] : await runOn(nav, complete);
257
+ if (id !== token)
258
+ return;
196
259
  const ok = await render(payload, url, state, id);
197
260
  if (id !== token)
198
261
  return;
@@ -207,7 +270,7 @@ async function navigate(url, opts, type, redirects, popIndex) {
207
270
  history.replaceState(entry, '', url);
208
271
  }
209
272
  else {
210
- entry.ternIndex = historyIndex = Date.now();
273
+ entry.ternIndex = historyIndex = historyIndex + 1;
211
274
  history.pushState(entry, '', url);
212
275
  }
213
276
  }
@@ -226,6 +289,12 @@ async function navigate(url, opts, type, redirects, popIndex) {
226
289
  if (!opts.keepFocus) {
227
290
  resetFocus();
228
291
  }
292
+ invalidated.clear();
293
+ finish();
294
+ if (!reload)
295
+ runAfter(nav);
296
+ for (const fn of cleanups)
297
+ fn();
229
298
  }
230
299
  function samePage(url) {
231
300
  return url.pathname === location.pathname && url.search === location.search;
@@ -249,6 +318,16 @@ function loadRoute(id) {
249
318
  }
250
319
  /** Renders a payload. It reports false if the payload is not a page. */
251
320
  async function render(p, url, state, id) {
321
+ const route = p.route ? manifest.routes[p.route] : undefined;
322
+ const layouts = route ? route.layouts : manifest.fallback.layouts;
323
+ // Fill in the layout data the server skipped because we already have it.
324
+ if (p.reused?.length) {
325
+ p.nodes = [...(p.nodes ?? [])];
326
+ for (const k of p.reused) {
327
+ const li = layouts[k];
328
+ p.nodes[k] = li === undefined ? null : (held.get(li)?.data ?? null);
329
+ }
330
+ }
252
331
  const what = plan(manifest, p);
253
332
  let components;
254
333
  switch (what.kind) {
@@ -274,6 +353,14 @@ async function render(p, url, state, id) {
274
353
  nodes = p.nodes ?? [];
275
354
  if (id !== token)
276
355
  return true; // superseded; the newer navigation renders
356
+ if (p.type === 'data') {
357
+ const here = url.pathname + url.search;
358
+ layouts.forEach((li, k) => {
359
+ if (p.reused?.includes(k))
360
+ return;
361
+ held.set(li, { data: p.nodes?.[k] ?? null, uses: p.uses?.[k] ?? null, params: p.params ?? {}, url: here });
362
+ });
363
+ }
277
364
  applyPayloadToPage(p, url, state, what.data);
278
365
  props.components = components;
279
366
  props.data = what.data;
@@ -324,12 +411,19 @@ function findAnchor(event) {
324
411
  }
325
412
  return null;
326
413
  }
414
+ /** Reports whether url belongs to this app (its origin and base path). */
415
+ function inApp(url) {
416
+ if (url.origin !== location.origin)
417
+ return false;
418
+ const base = manifest.base ?? '';
419
+ return !base || url.pathname === base || url.pathname.startsWith(base + '/');
420
+ }
327
421
  function anchorURL(a) {
328
422
  const href = a instanceof SVGAElement ? a.href.baseVal : a.href;
329
423
  if (!href)
330
424
  return null;
331
425
  const url = new URL(href, document.baseURI);
332
- if (url.origin !== location.origin || !/^https?:$/.test(url.protocol))
426
+ if (!inApp(url) || !/^https?:$/.test(url.protocol))
333
427
  return null;
334
428
  const target = a instanceof SVGAElement ? a.target.baseVal : a.target;
335
429
  if (target && target !== '_self')
@@ -376,13 +470,45 @@ function listen() {
376
470
  };
377
471
  document.addEventListener('mousedown', onTap);
378
472
  document.addEventListener('touchstart', onTap, { passive: true });
473
+ // GET forms navigate on the client, like links. POST forms are handled
474
+ // by use:enhance or submit normally.
475
+ document.addEventListener('submit', (event) => {
476
+ if (event.defaultPrevented)
477
+ return;
478
+ const form = event.target;
479
+ const submitter = event.submitter;
480
+ const own = HTMLFormElement.prototype.cloneNode.call(form);
481
+ const method = submitter?.hasAttribute('formmethod') ? submitter.formMethod : own.method;
482
+ const target = submitter?.getAttribute('formtarget') ?? own.target;
483
+ if (method !== 'get' || (target && target !== '_self') || form.closest('[data-tern-reload]:not([data-tern-reload="off"])')) {
484
+ return;
485
+ }
486
+ const url = new URL(submitter?.hasAttribute('formaction') ? submitter.formAction : own.action);
487
+ if (!inApp(url))
488
+ return;
489
+ event.preventDefault();
490
+ url.search = new URLSearchParams(new FormData(form, submitter)).toString();
491
+ const filtering = url.pathname === location.pathname;
492
+ void navigate(url, { keepFocus: filtering, noScroll: filtering }, 'form', 0);
493
+ });
494
+ addEventListener('beforeunload', (event) => {
495
+ if (!runBefore({ from: current(), to: null, type: 'leave' }, true)) {
496
+ event.preventDefault();
497
+ event.returnValue = '';
498
+ }
499
+ });
379
500
  addEventListener('popstate', (event) => {
501
+ if (ignoreNextPop) {
502
+ ignoreNextPop = false;
503
+ return;
504
+ }
380
505
  const index = event.state?.ternIndex;
381
506
  if (index == null)
382
507
  return;
383
508
  saveScroll();
509
+ const fromIndex = historyIndex;
384
510
  historyIndex = index;
385
- void navigate(new URL(location.href), { state: event.state?.ternState ?? {} }, 'popstate', 0, index);
511
+ void navigate(new URL(location.href), { state: event.state?.ternState ?? {} }, 'popstate', 0, index, fromIndex);
386
512
  });
387
513
  addEventListener('pagehide', saveScroll);
388
514
  }
@@ -46,6 +46,17 @@ export declare const navigating: {
46
46
  readonly current: Navigation | null;
47
47
  };
48
48
  export declare function setNavigating(n: Navigation | null): void;
49
+ /**
50
+ * The server's PUBLIC_ environment variables, read when it started:
51
+ *
52
+ * ```js
53
+ * import { env } from '@ternkit/tern';
54
+ * fetch(`${env.PUBLIC_API_URL}/status`);
55
+ * ```
56
+ *
57
+ * Only variables whose names start with PUBLIC_ are sent to the browser.
58
+ */
59
+ export declare const env: Record<string, string | undefined>;
49
60
  /** Updates page state for a rendered payload. */
50
61
  export declare function applyPayloadToPage(p: Payload, url: URL, state: Record<string, unknown>, data: Record<string, unknown>[]): void;
51
62
  export {};
@@ -49,8 +49,24 @@ export const navigating = {
49
49
  export function setNavigating(n) {
50
50
  navigatingState.current = n;
51
51
  }
52
+ /**
53
+ * The server's PUBLIC_ environment variables, read when it started:
54
+ *
55
+ * ```js
56
+ * import { env } from '@ternkit/tern';
57
+ * fetch(`${env.PUBLIC_API_URL}/status`);
58
+ * ```
59
+ *
60
+ * Only variables whose names start with PUBLIC_ are sent to the browser.
61
+ */
62
+ export const env = {};
52
63
  /** Updates page state for a rendered payload. */
53
64
  export function applyPayloadToPage(p, url, state, data) {
65
+ if (p.env) {
66
+ for (const k of Object.keys(env))
67
+ delete env[k];
68
+ Object.assign(env, p.env);
69
+ }
54
70
  page.url = url;
55
71
  page.params = p.params ?? {};
56
72
  page.route = { id: p.route ?? null };
@@ -21,6 +21,8 @@ export interface RouteEntry {
21
21
  }
22
22
  /** The client manifest generated by `tern gen` in .tern/generated/manifest.js. */
23
23
  export interface ClientManifest {
24
+ /** The base path from tern.json, such as '/app', or ''. */
25
+ base?: string;
24
26
  layouts: LayoutEntry[];
25
27
  routes: Record<string, RouteEntry>;
26
28
  fallback: {
@@ -43,6 +45,18 @@ export interface Payload {
43
45
  data?: unknown;
44
46
  form?: unknown;
45
47
  hasForm?: boolean;
48
+ /** What each node's load read, so the client knows when to rerun it. */
49
+ uses?: (NodeUses | null)[];
50
+ /** Nodes the server skipped because the client already has their data. */
51
+ reused?: number[];
52
+ /** PUBLIC_ environment variables, on full page loads. */
53
+ env?: Record<string, string>;
54
+ }
55
+ export interface NodeUses {
56
+ url?: boolean;
57
+ params?: string[];
58
+ deps?: string[];
59
+ parent?: boolean;
46
60
  }
47
61
  /** The result of submitting a form action with `use:enhance`. */
48
62
  export type ActionResult<Success = Record<string, unknown>, Failure = Record<string, unknown>> = {
@@ -71,7 +85,7 @@ export interface NavigationTarget {
71
85
  id: string | null;
72
86
  };
73
87
  }
74
- export type NavigationType = 'enter' | 'link' | 'goto' | 'popstate' | 'form';
88
+ export type NavigationType = 'enter' | 'link' | 'goto' | 'popstate' | 'form' | 'leave';
75
89
  export interface Navigation {
76
90
  from: NavigationTarget | null;
77
91
  to: NavigationTarget | null;
@@ -0,0 +1,100 @@
1
+ type Opts = Record<string, unknown>;
2
+ declare class NumberFormat {
3
+ #private;
4
+ constructor(_locale?: unknown, options?: Opts);
5
+ format(value: number | bigint): string;
6
+ formatToParts(value: number): {
7
+ type: string;
8
+ value: string;
9
+ }[];
10
+ resolvedOptions(): {
11
+ locale: string;
12
+ numberingSystem: string;
13
+ };
14
+ static supportedLocalesOf(): string[];
15
+ }
16
+ declare class DateTimeFormat {
17
+ #private;
18
+ constructor(_locale?: unknown, options?: Opts);
19
+ format(value?: Date | number): string;
20
+ formatToParts(value?: Date | number): {
21
+ type: string;
22
+ value: string;
23
+ }[];
24
+ formatRange(a: Date | number, b: Date | number): string;
25
+ resolvedOptions(): {
26
+ locale: string;
27
+ calendar: string;
28
+ numberingSystem: string;
29
+ timeZone: string;
30
+ };
31
+ static supportedLocalesOf(): string[];
32
+ }
33
+ declare class PluralRules {
34
+ select(n: number): "one" | "other";
35
+ resolvedOptions(): {
36
+ locale: string;
37
+ pluralCategories: string[];
38
+ };
39
+ static supportedLocalesOf(): string[];
40
+ }
41
+ declare class Collator {
42
+ #private;
43
+ constructor(_locale?: unknown, options?: Opts);
44
+ compare: (a: string, b: string) => number;
45
+ resolvedOptions(): {
46
+ locale: string;
47
+ usage: string;
48
+ sensitivity: string;
49
+ };
50
+ static supportedLocalesOf(): string[];
51
+ }
52
+ declare class RelativeTimeFormat {
53
+ format(value: number, unit: string): string;
54
+ static supportedLocalesOf(): string[];
55
+ }
56
+ declare class ListFormat {
57
+ #private;
58
+ constructor(_locale?: unknown, options?: Opts);
59
+ format(list: Iterable<string>): string;
60
+ static supportedLocalesOf(): string[];
61
+ }
62
+ declare class Locale {
63
+ baseName: string;
64
+ language: string;
65
+ region?: string;
66
+ constructor(tag?: string);
67
+ get weekInfo(): {
68
+ firstDay: number;
69
+ weekend: number[];
70
+ minimalDays: number;
71
+ };
72
+ getWeekInfo(): {
73
+ firstDay: number;
74
+ weekend: number[];
75
+ minimalDays: number;
76
+ };
77
+ maximize(): this;
78
+ minimize(): this;
79
+ toString(): string;
80
+ }
81
+ declare class Segmenter {
82
+ segment(input: string): {
83
+ segment: string;
84
+ index: number;
85
+ input: string;
86
+ }[];
87
+ }
88
+ export declare const IntlShim: {
89
+ NumberFormat: typeof NumberFormat;
90
+ DateTimeFormat: typeof DateTimeFormat;
91
+ PluralRules: typeof PluralRules;
92
+ Collator: typeof Collator;
93
+ RelativeTimeFormat: typeof RelativeTimeFormat;
94
+ ListFormat: typeof ListFormat;
95
+ Locale: typeof Locale;
96
+ Segmenter: typeof Segmenter;
97
+ getCanonicalLocales: (l?: string | string[]) => string[];
98
+ supportedValuesOf: () => never[];
99
+ };
100
+ export {};
@@ -0,0 +1,222 @@
1
+ // A small Intl for the server renderer. The embedded engine (QuickJS) has
2
+ // no Intl, and many libraries create formatters when they load. This shim
3
+ // formats like en-US, which is good enough for server-rendered HTML: the
4
+ // browser hydrates with the user's real Intl and locale.
5
+ const MONTHS = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];
6
+ const DAYS = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
7
+ function group(int) {
8
+ return int.replace(/\B(?=(\d{3})+(?!\d))/g, ',');
9
+ }
10
+ class NumberFormat {
11
+ #o;
12
+ constructor(_locale, options = {}) {
13
+ this.#o = options;
14
+ }
15
+ format(value) {
16
+ let n = Number(value);
17
+ const o = this.#o;
18
+ if (!Number.isFinite(n))
19
+ return Number.isNaN(n) ? 'NaN' : n > 0 ? '∞' : '-∞';
20
+ let suffix = '';
21
+ if (o.style === 'percent') {
22
+ n *= 100;
23
+ suffix = '%';
24
+ }
25
+ if (o.notation === 'compact') {
26
+ const abs = Math.abs(n);
27
+ for (const [limit, s] of [[1e12, 'T'], [1e9, 'B'], [1e6, 'M'], [1e3, 'K']]) {
28
+ if (abs >= limit) {
29
+ n /= limit;
30
+ suffix = s + suffix;
31
+ break;
32
+ }
33
+ }
34
+ }
35
+ const isCurrency = o.style === 'currency';
36
+ const max = o.maximumFractionDigits ?? (isCurrency ? 2 : o.notation === 'compact' ? 1 : 3);
37
+ const min = o.minimumFractionDigits ?? (isCurrency ? 2 : 0);
38
+ let s = Math.abs(n).toFixed(Math.max(min, Math.min(max, 20)));
39
+ if (s.includes('.')) {
40
+ let [int, frac = ''] = s.split('.');
41
+ while (frac.length > min && frac.endsWith('0'))
42
+ frac = frac.slice(0, -1);
43
+ s = frac ? `${int}.${frac}` : int;
44
+ }
45
+ const [int, frac] = s.split('.');
46
+ s = (o.useGrouping === false ? int : group(int)) + (frac ? `.${frac}` : '');
47
+ if (isCurrency) {
48
+ const code = String(o.currency ?? 'USD').toUpperCase();
49
+ s = (code === 'USD' ? '$' : code === 'EUR' ? '€' : code === 'GBP' ? '£' : `${code} `) + s;
50
+ }
51
+ return (n < 0 ? '-' : '') + s + suffix;
52
+ }
53
+ formatToParts(value) {
54
+ return [{ type: 'literal', value: this.format(value) }];
55
+ }
56
+ resolvedOptions() {
57
+ return { locale: 'en-US', numberingSystem: 'latn', ...this.#o };
58
+ }
59
+ static supportedLocalesOf() {
60
+ return ['en-US'];
61
+ }
62
+ }
63
+ class DateTimeFormat {
64
+ #o;
65
+ constructor(_locale, options = {}) {
66
+ this.#o = options;
67
+ }
68
+ format(value) {
69
+ const d = value === undefined ? new Date() : new Date(value);
70
+ if (Number.isNaN(d.getTime()))
71
+ return 'Invalid Date';
72
+ const o = this.#o;
73
+ const utc = o.timeZone === 'UTC';
74
+ const get = {
75
+ y: utc ? d.getUTCFullYear() : d.getFullYear(),
76
+ m: utc ? d.getUTCMonth() : d.getMonth(),
77
+ d: utc ? d.getUTCDate() : d.getDate(),
78
+ w: utc ? d.getUTCDay() : d.getDay(),
79
+ h: utc ? d.getUTCHours() : d.getHours(),
80
+ min: utc ? d.getUTCMinutes() : d.getMinutes()
81
+ };
82
+ const hasDate = o.year || o.month || o.day || o.weekday || o.dateStyle;
83
+ const hasTime = o.hour || o.minute || o.timeStyle;
84
+ const parts = [];
85
+ if (hasDate || !hasTime) {
86
+ const month = o.month ?? (o.dateStyle ? 'short' : 'numeric');
87
+ if (o.weekday)
88
+ parts.push((o.weekday === 'long' ? DAYS[get.w] : DAYS[get.w].slice(0, 3)) + ',');
89
+ if (month === 'numeric' || month === '2-digit') {
90
+ parts.push(`${get.m + 1}/${get.d}/${get.y}`);
91
+ }
92
+ else {
93
+ const name = month === 'long' ? MONTHS[get.m] : MONTHS[get.m].slice(0, 3);
94
+ const day = o.day !== undefined || o.dateStyle ? ` ${get.d}` : '';
95
+ const year = o.year !== undefined || o.dateStyle ? `, ${get.y}` : '';
96
+ parts.push(name + day + (day ? year : year.replace(', ', ' ')));
97
+ }
98
+ }
99
+ if (hasTime) {
100
+ const h12 = o.hour12 !== false;
101
+ const hour = h12 ? get.h % 12 || 12 : get.h;
102
+ const mm = String(get.min).padStart(2, '0');
103
+ parts.push(`${hour}:${mm}${h12 ? (get.h < 12 ? ' AM' : ' PM') : ''}`);
104
+ }
105
+ return parts.join(' ');
106
+ }
107
+ formatToParts(value) {
108
+ return [{ type: 'literal', value: this.format(value) }];
109
+ }
110
+ formatRange(a, b) {
111
+ return `${this.format(a)} – ${this.format(b)}`;
112
+ }
113
+ resolvedOptions() {
114
+ return { locale: 'en-US', calendar: 'gregory', numberingSystem: 'latn', timeZone: 'UTC', ...this.#o };
115
+ }
116
+ static supportedLocalesOf() {
117
+ return ['en-US'];
118
+ }
119
+ }
120
+ class PluralRules {
121
+ select(n) {
122
+ return n === 1 ? 'one' : 'other';
123
+ }
124
+ resolvedOptions() {
125
+ return { locale: 'en-US', pluralCategories: ['one', 'other'] };
126
+ }
127
+ static supportedLocalesOf() {
128
+ return ['en-US'];
129
+ }
130
+ }
131
+ class Collator {
132
+ #numeric;
133
+ constructor(_locale, options = {}) {
134
+ this.#numeric = options.numeric === true;
135
+ }
136
+ compare = (a, b) => {
137
+ if (this.#numeric) {
138
+ const na = Number(a);
139
+ const nb = Number(b);
140
+ if (!Number.isNaN(na) && !Number.isNaN(nb))
141
+ return na - nb;
142
+ }
143
+ const x = String(a).toLowerCase();
144
+ const y = String(b).toLowerCase();
145
+ return x < y ? -1 : x > y ? 1 : 0;
146
+ };
147
+ resolvedOptions() {
148
+ return { locale: 'en-US', usage: 'sort', sensitivity: 'variant' };
149
+ }
150
+ static supportedLocalesOf() {
151
+ return ['en-US'];
152
+ }
153
+ }
154
+ class RelativeTimeFormat {
155
+ format(value, unit) {
156
+ const u = unit.replace(/s$/, '');
157
+ const n = Math.abs(value);
158
+ const label = `${n} ${u}${n === 1 ? '' : 's'}`;
159
+ return value < 0 ? `${label} ago` : `in ${label}`;
160
+ }
161
+ static supportedLocalesOf() {
162
+ return ['en-US'];
163
+ }
164
+ }
165
+ class ListFormat {
166
+ #type;
167
+ constructor(_locale, options = {}) {
168
+ this.#type = options.type === 'disjunction' ? 'or' : 'and';
169
+ }
170
+ format(list) {
171
+ const items = [...list];
172
+ if (items.length < 3)
173
+ return items.join(` ${this.#type} `);
174
+ return `${items.slice(0, -1).join(', ')}, ${this.#type} ${items[items.length - 1]}`;
175
+ }
176
+ static supportedLocalesOf() {
177
+ return ['en-US'];
178
+ }
179
+ }
180
+ class Locale {
181
+ baseName;
182
+ language;
183
+ region;
184
+ constructor(tag = 'en-US') {
185
+ this.baseName = String(tag);
186
+ const [language, region] = this.baseName.split(/[-_]/);
187
+ this.language = language || 'en';
188
+ this.region = region;
189
+ }
190
+ get weekInfo() {
191
+ return this.getWeekInfo();
192
+ }
193
+ getWeekInfo() {
194
+ return { firstDay: 7, weekend: [6, 7], minimalDays: 1 };
195
+ }
196
+ maximize() {
197
+ return this;
198
+ }
199
+ minimize() {
200
+ return this;
201
+ }
202
+ toString() {
203
+ return this.baseName;
204
+ }
205
+ }
206
+ class Segmenter {
207
+ segment(input) {
208
+ return [...input].map((segment, index) => ({ segment, index, input }));
209
+ }
210
+ }
211
+ export const IntlShim = {
212
+ NumberFormat,
213
+ DateTimeFormat,
214
+ PluralRules,
215
+ Collator,
216
+ RelativeTimeFormat,
217
+ ListFormat,
218
+ Locale,
219
+ Segmenter,
220
+ getCanonicalLocales: (l) => (l === undefined ? [] : Array.isArray(l) ? l : [l]),
221
+ supportedValuesOf: () => []
222
+ };
@@ -4,7 +4,16 @@
4
4
  // during development.
5
5
  import 'core-js/actual/url';
6
6
  import 'core-js/actual/url-search-params';
7
+ import { IntlShim } from './intl.js';
7
8
  const g = globalThis;
9
+ // Some libraries read process.env.SOMETHING at runtime. Give them an empty
10
+ // environment instead of a ReferenceError.
11
+ if (typeof g.process === 'undefined') {
12
+ g.process = { env: { NODE_ENV: 'production' }, browser: false, version: '', versions: {} };
13
+ }
14
+ if (typeof g.Intl === 'undefined') {
15
+ g.Intl = IntlShim;
16
+ }
8
17
  if (typeof g.queueMicrotask !== 'function') {
9
18
  g.queueMicrotask = (fn) => void Promise.resolve().then(fn);
10
19
  }
@@ -1,4 +1,5 @@
1
1
  import { svelte } from '@sveltejs/vite-plugin-svelte';
2
+ import fs from 'node:fs';
2
3
  import path from 'node:path';
3
4
  /** Paths shared with the Go code generator; keep in sync with internal/codegen. */
4
5
  const CLIENT_ENTRY = '.tern/generated/client.js';
@@ -28,9 +29,16 @@ export function tern(options = {}) {
28
29
  name: 'tern',
29
30
  config(config, env) {
30
31
  root = path.resolve(config.root ?? process.cwd());
32
+ const basePath = readBasePath(root);
31
33
  const shared = {
32
34
  appType: 'custom',
33
- resolve: { alias: { $lib: path.join(root, 'src/lib') } }
35
+ base: basePath + '/',
36
+ resolve: {
37
+ alias: {
38
+ $lib: path.join(root, 'src/lib'),
39
+ $routes: path.join(root, '.tern/generated/routes.js')
40
+ }
41
+ }
34
42
  };
35
43
  if (env.isSsrBuild) {
36
44
  return {
@@ -49,7 +57,10 @@ export function tern(options = {}) {
49
57
  },
50
58
  // One self-contained file: the renderer runs in an embedded engine
51
59
  // without a module loader or Node built-ins.
52
- ssr: { noExternal: true }
60
+ ssr: { noExternal: true },
61
+ // Libraries often read process.env.NODE_ENV; the engine has no
62
+ // process object, so replace it at build time.
63
+ define: { 'process.env.NODE_ENV': JSON.stringify('production') }
53
64
  };
54
65
  }
55
66
  const vitePort = Number(process.env.TERN_VITE_PORT) || undefined;
@@ -71,7 +82,7 @@ export function tern(options = {}) {
71
82
  port: vitePort,
72
83
  strictPort: vitePort !== undefined,
73
84
  // The browser talks to the Go app, which proxies HMR to Vite.
74
- hmr: appPort ? { clientPort: appPort } : undefined,
85
+ ws: appPort ? { clientPort: appPort } : undefined,
75
86
  fs: { allow: [root] }
76
87
  },
77
88
  optimizeDeps: {
@@ -107,4 +118,14 @@ export function tern(options = {}) {
107
118
  }
108
119
  return [...svelte(options.svelte), plugin];
109
120
  }
121
+ /** Reads basePath from tern.json; the Go side validates it. */
122
+ function readBasePath(root) {
123
+ try {
124
+ const config = JSON.parse(fs.readFileSync(path.join(root, 'tern.json'), 'utf8'));
125
+ return config.basePath ?? '';
126
+ }
127
+ catch {
128
+ return '';
129
+ }
130
+ }
110
131
  export default tern;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ternkit/tern",
3
- "version": "0.3.1",
3
+ "version": "1.0.0",
4
4
  "description": "Client runtime and Vite plugin for Tern: Svelte on the client, Go on the server",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -51,7 +51,8 @@
51
51
  "scripts": {
52
52
  "build": "svelte-package -i src -o dist",
53
53
  "prepare": "svelte-package -i src -o dist",
54
- "typecheck": "svelte-check --tsconfig ./tsconfig.json --fail-on-warnings"
54
+ "typecheck": "svelte-check --tsconfig ./tsconfig.json --fail-on-warnings",
55
+ "test": "vitest run"
55
56
  },
56
57
  "peerDependencies": {
57
58
  "svelte": "^5.46.4",
@@ -67,6 +68,7 @@
67
68
  "svelte": "^5.57.3",
68
69
  "svelte-check": "^4.7.8",
69
70
  "typescript": "^6.0.0",
70
- "vite": "^8.3.7"
71
+ "vite": "^8.3.7",
72
+ "vitest": "^5.1.0"
71
73
  }
72
74
  }