@ternkit/tern 0.3.1 → 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.
package/dist/index.d.ts CHANGED
@@ -8,9 +8,11 @@
8
8
  * ```
9
9
  */
10
10
  export { page, navigating } from './runtime/state.svelte.js';
11
- export { goto, invalidateAll, preloadData, applyAction } from './runtime/router.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 = "0.4.0";
package/dist/index.js CHANGED
@@ -8,7 +8,8 @@
8
8
  * ```
9
9
  */
10
10
  export { page, navigating } from './runtime/state.svelte.js';
11
- export { goto, invalidateAll, preloadData, applyAction } from './runtime/router.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 = '0.4.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);
@@ -79,6 +87,21 @@ export async function goto(href, opts = {}) {
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,33 @@ 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
+ 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;
142
186
  }
143
- async function fetchPayload(url) {
187
+ async function fetchPayload(url, reuse = true) {
144
188
  try {
145
- const res = await fetch(dataURL(url), {
189
+ const res = await fetch(dataURL(url, reuse), {
146
190
  headers: { accept: 'application/json' },
147
191
  credentials: 'same-origin'
148
192
  });
@@ -164,18 +208,30 @@ function takePreload(url) {
164
208
  }
165
209
  return undefined;
166
210
  }
167
- async function navigate(url, opts, type, redirects, popIndex) {
211
+ async function navigate(url, opts, type, redirects, popIndex, fromIndex) {
168
212
  const id = ++token;
169
213
  // A change of hash alone is a scroll, not a navigation.
170
214
  if (type !== 'popstate' && !opts.invalidateAll && url.hash && samePage(url)) {
171
215
  saveScroll();
172
- history.pushState({ ternIndex: (historyIndex = Date.now()), ternState: $state.snapshot(opts.state ?? {}) }, '', url);
216
+ history.pushState({ ternIndex: (historyIndex = historyIndex + 1), ternState: $state.snapshot(opts.state ?? {}) }, '', url);
173
217
  scrollToHash(url.hash);
174
218
  return;
175
219
  }
176
220
  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));
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));
179
235
  if (id !== token)
180
236
  return;
181
237
  if (!payload) {
@@ -188,11 +244,17 @@ async function navigate(url, opts, type, redirects, popIndex) {
188
244
  location.href = next.href;
189
245
  return;
190
246
  }
191
- return navigate(next, { ...opts, replaceState: true }, type, redirects + 1);
247
+ return navigate(next, { ...opts, replaceState: true, [RELOAD]: false }, type, redirects + 1);
192
248
  }
193
249
  if (type !== 'popstate')
194
250
  saveScroll(); // popstate saved before historyIndex changed
195
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;
196
258
  const ok = await render(payload, url, state, id);
197
259
  if (id !== token)
198
260
  return;
@@ -207,7 +269,7 @@ async function navigate(url, opts, type, redirects, popIndex) {
207
269
  history.replaceState(entry, '', url);
208
270
  }
209
271
  else {
210
- entry.ternIndex = historyIndex = Date.now();
272
+ entry.ternIndex = historyIndex = historyIndex + 1;
211
273
  history.pushState(entry, '', url);
212
274
  }
213
275
  }
@@ -226,6 +288,12 @@ async function navigate(url, opts, type, redirects, popIndex) {
226
288
  if (!opts.keepFocus) {
227
289
  resetFocus();
228
290
  }
291
+ invalidated.clear();
292
+ finish();
293
+ if (!reload)
294
+ runAfter(nav);
295
+ for (const fn of cleanups)
296
+ fn();
229
297
  }
230
298
  function samePage(url) {
231
299
  return url.pathname === location.pathname && url.search === location.search;
@@ -249,6 +317,16 @@ function loadRoute(id) {
249
317
  }
250
318
  /** Renders a payload. It reports false if the payload is not a page. */
251
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
+ }
252
330
  const what = plan(manifest, p);
253
331
  let components;
254
332
  switch (what.kind) {
@@ -274,6 +352,14 @@ async function render(p, url, state, id) {
274
352
  nodes = p.nodes ?? [];
275
353
  if (id !== token)
276
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
+ }
277
363
  applyPayloadToPage(p, url, state, what.data);
278
364
  props.components = components;
279
365
  props.data = what.data;
@@ -376,13 +462,45 @@ function listen() {
376
462
  };
377
463
  document.addEventListener('mousedown', onTap);
378
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
+ });
379
492
  addEventListener('popstate', (event) => {
493
+ if (ignoreNextPop) {
494
+ ignoreNextPop = false;
495
+ return;
496
+ }
380
497
  const index = event.state?.ternIndex;
381
498
  if (index == null)
382
499
  return;
383
500
  saveScroll();
501
+ const fromIndex = historyIndex;
384
502
  historyIndex = index;
385
- void navigate(new URL(location.href), { state: event.state?.ternState ?? {} }, 'popstate', 0, index);
503
+ void navigate(new URL(location.href), { state: event.state?.ternState ?? {} }, 'popstate', 0, index, fromIndex);
386
504
  });
387
505
  addEventListener('pagehide', saveScroll);
388
506
  }
@@ -43,6 +43,16 @@ export interface Payload {
43
43
  data?: unknown;
44
44
  form?: unknown;
45
45
  hasForm?: boolean;
46
+ /** What each node's load read, so the client knows when to rerun it. */
47
+ uses?: (NodeUses | null)[];
48
+ /** Nodes the server skipped because the client already has their data. */
49
+ reused?: number[];
50
+ }
51
+ export interface NodeUses {
52
+ url?: boolean;
53
+ params?: string[];
54
+ deps?: string[];
55
+ parent?: boolean;
46
56
  }
47
57
  /** The result of submitting a form action with `use:enhance`. */
48
58
  export type ActionResult<Success = Record<string, unknown>, Failure = Record<string, unknown>> = {
@@ -71,7 +81,7 @@ export interface NavigationTarget {
71
81
  id: string | null;
72
82
  };
73
83
  }
74
- export type NavigationType = 'enter' | 'link' | 'goto' | 'popstate' | 'form';
84
+ export type NavigationType = 'enter' | 'link' | 'goto' | 'popstate' | 'form' | 'leave';
75
85
  export interface Navigation {
76
86
  from: NavigationTarget | null;
77
87
  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
  }
@@ -30,7 +30,12 @@ export function tern(options = {}) {
30
30
  root = path.resolve(config.root ?? process.cwd());
31
31
  const shared = {
32
32
  appType: 'custom',
33
- resolve: { alias: { $lib: path.join(root, 'src/lib') } }
33
+ resolve: {
34
+ alias: {
35
+ $lib: path.join(root, 'src/lib'),
36
+ $routes: path.join(root, '.tern/generated/routes.js')
37
+ }
38
+ }
34
39
  };
35
40
  if (env.isSsrBuild) {
36
41
  return {
@@ -49,7 +54,10 @@ export function tern(options = {}) {
49
54
  },
50
55
  // One self-contained file: the renderer runs in an embedded engine
51
56
  // without a module loader or Node built-ins.
52
- ssr: { noExternal: true }
57
+ ssr: { noExternal: true },
58
+ // Libraries often read process.env.NODE_ENV; the engine has no
59
+ // process object, so replace it at build time.
60
+ define: { 'process.env.NODE_ENV': JSON.stringify('production') }
53
61
  };
54
62
  }
55
63
  const vitePort = Number(process.env.TERN_VITE_PORT) || undefined;
@@ -71,7 +79,7 @@ export function tern(options = {}) {
71
79
  port: vitePort,
72
80
  strictPort: vitePort !== undefined,
73
81
  // The browser talks to the Go app, which proxies HMR to Vite.
74
- hmr: appPort ? { clientPort: appPort } : undefined,
82
+ ws: appPort ? { clientPort: appPort } : undefined,
75
83
  fs: { allow: [root] }
76
84
  },
77
85
  optimizeDeps: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ternkit/tern",
3
- "version": "0.3.1",
3
+ "version": "0.4.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
  }