@ternkit/tern 1.0.0 → 1.2.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.
@@ -3,6 +3,8 @@ 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 { setBase } from './base.js';
7
+ import { configureI18n, useLocale } from './i18n.svelte.js';
6
8
  import { runAfter, runBefore, runOn } from './navigation.js';
7
9
  import { plan } from './select.js';
8
10
  import { applyPayloadToPage, page, setNavigating } from './state.svelte.js';
@@ -31,6 +33,8 @@ const RELOAD = Symbol('reload');
31
33
  /** Starts the client. Called by the generated entry module. */
32
34
  export async function start(m) {
33
35
  manifest = m;
36
+ setBase(m.base ?? '');
37
+ configureI18n(m.i18n ?? null);
34
38
  const el = document.getElementById('__tern_data');
35
39
  const target = document.getElementById('tern-root');
36
40
  if (!el || !target) {
@@ -66,6 +70,7 @@ export async function start(m) {
66
70
  started = true;
67
71
  await tick();
68
72
  runAfter({ from: null, to: current(), type: 'enter' });
73
+ registerServiceWorker();
69
74
  const saved = scrollPositions[historyIndex];
70
75
  if (saved) {
71
76
  scrollTo(saved.x, saved.y);
@@ -75,6 +80,15 @@ export async function start(m) {
75
80
  }
76
81
  listen();
77
82
  }
83
+ function registerServiceWorker() {
84
+ // Not in development: a caching worker would serve stale modules.
85
+ if (!manifest.serviceWorker || import.meta.env?.DEV || !('serviceWorker' in navigator))
86
+ return;
87
+ const base = manifest.base ?? '';
88
+ navigator.serviceWorker.register(`${base}/service-worker.js`, { scope: `${base}/` }).catch((err) => {
89
+ console.error('Tern: registering the service worker failed', err);
90
+ });
91
+ }
78
92
  /** Navigates to href, which may be relative to the current URL. */
79
93
  export async function goto(href, opts = {}) {
80
94
  if (!started) {
@@ -329,6 +343,7 @@ async function render(p, url, state, id) {
329
343
  }
330
344
  }
331
345
  const what = plan(manifest, p);
346
+ const ready = useLocale(p.locale); // messages load alongside the components
332
347
  let components;
333
348
  switch (what.kind) {
334
349
  case 'not-a-page':
@@ -351,6 +366,7 @@ async function render(p, url, state, id) {
351
366
  layoutCount = 0;
352
367
  }
353
368
  nodes = p.nodes ?? [];
369
+ await ready;
354
370
  if (id !== token)
355
371
  return true; // superseded; the newer navigation renders
356
372
  if (p.type === 'data') {
@@ -23,6 +23,10 @@ export interface RouteEntry {
23
23
  export interface ClientManifest {
24
24
  /** The base path from tern.json, such as '/app', or ''. */
25
25
  base?: string;
26
+ /** Whether to register /service-worker.js. */
27
+ serviceWorker?: boolean;
28
+ /** Translations from src/locales, or null. */
29
+ i18n?: import('./i18n.svelte.js').I18nConfig | null;
26
30
  layouts: LayoutEntry[];
27
31
  routes: Record<string, RouteEntry>;
28
32
  fallback: {
@@ -51,6 +55,8 @@ export interface Payload {
51
55
  reused?: number[];
52
56
  /** PUBLIC_ environment variables, on full page loads. */
53
57
  env?: Record<string, string>;
58
+ /** The request's locale, when the app has src/locales. */
59
+ locale?: string;
54
60
  }
55
61
  export interface NodeUses {
56
62
  url?: boolean;
@@ -0,0 +1,18 @@
1
+ import type { Component } from 'svelte';
2
+ /** An email component module from src/emails. */
3
+ export interface EmailModule {
4
+ default: Component<any>;
5
+ /** Props for the preview page under tern dev. */
6
+ preview?: Record<string, unknown>;
7
+ }
8
+ export interface EmailResult {
9
+ subject: string;
10
+ html: string;
11
+ error?: string;
12
+ }
13
+ /**
14
+ * Creates the function Go calls to render emails: a component's markup
15
+ * becomes the body, its <svelte:head> the head, and its <title> the
16
+ * subject.
17
+ */
18
+ export declare function createEmailRenderer(emails: Record<string, EmailModule>): (name: string, propsJSON: string) => string;
@@ -0,0 +1,34 @@
1
+ import { render } from 'svelte/server';
2
+ const entities = { '&amp;': '&', '&lt;': '<', '&gt;': '>', '&quot;': '"', '&#39;': "'", '&#x27;': "'" };
3
+ function decode(s) {
4
+ return s.replace(/&(amp|lt|gt|quot|#39|#x27);/g, (m) => entities[m] ?? m);
5
+ }
6
+ /**
7
+ * Creates the function Go calls to render emails: a component's markup
8
+ * becomes the body, its <svelte:head> the head, and its <title> the
9
+ * subject.
10
+ */
11
+ export function createEmailRenderer(emails) {
12
+ return (name, propsJSON) => {
13
+ if (name === '') {
14
+ // The list of emails and their preview props, for the dev preview.
15
+ return JSON.stringify(Object.fromEntries(Object.entries(emails).map(([n, m]) => [n, m.preview ?? {}])));
16
+ }
17
+ const mod = emails[name];
18
+ if (!mod)
19
+ return JSON.stringify({ subject: '', html: '', error: `no email named ${name} in src/emails` });
20
+ const props = JSON.parse(propsJSON || '{}');
21
+ const out = render(mod.default, { props });
22
+ const title = /<title>([\s\S]*?)<\/title>/.exec(out.head);
23
+ const subject = title ? decode(title[1].trim()) : '';
24
+ const head = out.head.replace(/<!--[\s\S]*?-->/g, '');
25
+ const body = out.body.replace(/<!--[\s\S]*?-->/g, '');
26
+ const html = '<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">' +
27
+ '<meta name="color-scheme" content="light dark">' +
28
+ head +
29
+ '</head><body>' +
30
+ body +
31
+ '</body></html>';
32
+ return JSON.stringify({ subject, html });
33
+ };
34
+ }
@@ -1,4 +1,5 @@
1
1
  import type { Component } from 'svelte';
2
+ import { type I18nConfig } from '../runtime/i18n.svelte.js';
2
3
  /** The server manifest generated by `tern gen` in .tern/generated/server.js. */
3
4
  export interface ServerManifest {
4
5
  layouts: {
@@ -19,6 +20,8 @@ export interface ServerManifest {
19
20
  component: Component<any>;
20
21
  }[];
21
22
  };
23
+ i18n?: I18nConfig | null;
24
+ base?: string;
22
25
  }
23
26
  /** The result of a server render, as JSON for the Go side. */
24
27
  export interface RenderResult {
@@ -33,3 +36,5 @@ export interface RenderResult {
33
36
  * exactly the same tree and hydration matches.
34
37
  */
35
38
  export declare function createRenderer(manifest: ServerManifest): (payload: string, href: string) => string;
39
+ export { createEmailRenderer } from './email.js';
40
+ export type { EmailModule, EmailResult } from './email.js';
@@ -2,7 +2,9 @@ import { render } from 'svelte/server';
2
2
  import DefaultError from '../runtime/DefaultError.svelte';
3
3
  import Passthrough from '../runtime/Passthrough.svelte';
4
4
  import Root from '../runtime/Root.svelte';
5
+ import { setBase } from '../runtime/base.js';
5
6
  import { revive } from '../runtime/deferred.js';
7
+ import { configureI18n, useLocaleSync } from '../runtime/i18n.svelte.js';
6
8
  import { plan } from '../runtime/select.js';
7
9
  import { applyPayloadToPage } from '../runtime/state.svelte.js';
8
10
  /**
@@ -12,6 +14,8 @@ import { applyPayloadToPage } from '../runtime/state.svelte.js';
12
14
  */
13
15
  export function createRenderer(manifest) {
14
16
  const layout = (i) => manifest.layouts[i]?.component ?? Passthrough;
17
+ configureI18n(manifest.i18n ?? null);
18
+ setBase(manifest.base ?? '');
15
19
  return (payloadJSON, href) => {
16
20
  const p = JSON.parse(payloadJSON);
17
21
  // Deferred values are still loading: render their pending state.
@@ -32,9 +36,11 @@ export function createRenderer(manifest) {
32
36
  case 'default-error':
33
37
  components = [DefaultError];
34
38
  }
39
+ useLocaleSync(p.locale);
35
40
  applyPayloadToPage(p, new URL(href), {}, what.data);
36
41
  const form = p.hasForm ? (p.form ?? null) : null;
37
42
  const out = render(Root, { props: { components, data: what.data, form } });
38
43
  return JSON.stringify({ head: out.head, body: out.body });
39
44
  };
40
45
  }
46
+ export { createEmailRenderer } from './email.js';
@@ -33,7 +33,7 @@ class NumberFormat {
33
33
  }
34
34
  }
35
35
  const isCurrency = o.style === 'currency';
36
- const max = o.maximumFractionDigits ?? (isCurrency ? 2 : o.notation === 'compact' ? 1 : 3);
36
+ const max = o.maximumFractionDigits ?? (isCurrency ? 2 : o.style === 'percent' ? 0 : o.notation === 'compact' ? 1 : 3);
37
37
  const min = o.minimumFractionDigits ?? (isCurrency ? 2 : 0);
38
38
  let s = Math.abs(n).toFixed(Math.max(min, Math.min(max, 20)));
39
39
  if (s.includes('.')) {
@@ -1,2 +1 @@
1
- import 'core-js/actual/url';
2
- import 'core-js/actual/url-search-params';
1
+ export {};
@@ -2,17 +2,44 @@
2
2
  // JavaScript engine (QuickJS) that only implements the language itself.
3
3
  // Each polyfill is skipped where the API already exists, such as in Node
4
4
  // during development.
5
- import 'core-js/actual/url';
6
- import 'core-js/actual/url-search-params';
7
5
  import { IntlShim } from './intl.js';
6
+ import { URL, URLSearchParams } from './url.js';
7
+ import { install } from './web.js';
8
8
  const g = globalThis;
9
+ install(g);
9
10
  // Some libraries read process.env.SOMETHING at runtime. Give them an empty
10
11
  // environment instead of a ReferenceError.
11
12
  if (typeof g.process === 'undefined') {
12
13
  g.process = { env: { NODE_ENV: 'production' }, browser: false, version: '', versions: {} };
13
14
  }
15
+ if (typeof g.URL === 'undefined') {
16
+ g.URL = URL;
17
+ g.URLSearchParams = URLSearchParams;
18
+ }
14
19
  if (typeof g.Intl === 'undefined') {
15
20
  g.Intl = IntlShim;
21
+ // QuickJS's own toLocaleString ignores locales: (5e6).toLocaleString()
22
+ // is "5000000" where browsers print "5,000,000". Route them through the
23
+ // shim so server-rendered text matches the browser's en-US output.
24
+ const NF = IntlShim.NumberFormat;
25
+ const DF = IntlShim.DateTimeFormat;
26
+ Object.defineProperty(Number.prototype, 'toLocaleString', {
27
+ value(locales, options) {
28
+ return new NF(locales, options).format(Number(this));
29
+ },
30
+ configurable: true,
31
+ writable: true
32
+ });
33
+ const dateMethod = (defaults) => function (locales, options) {
34
+ return new DF(locales, { ...defaults, ...options }).format(this);
35
+ };
36
+ for (const [name, defaults] of [
37
+ ['toLocaleString', { dateStyle: 'short', timeStyle: 'medium' }],
38
+ ['toLocaleDateString', { dateStyle: 'short' }],
39
+ ['toLocaleTimeString', { timeStyle: 'medium' }]
40
+ ]) {
41
+ Object.defineProperty(Date.prototype, name, { value: dateMethod(defaults), configurable: true, writable: true });
42
+ }
16
43
  }
17
44
  if (typeof g.queueMicrotask !== 'function') {
18
45
  g.queueMicrotask = (fn) => void Promise.resolve().then(fn);
@@ -0,0 +1,56 @@
1
+ export declare class URLSearchParams {
2
+ #private;
3
+ constructor(init?: string | Record<string, string> | Iterable<[string, string]> | URLSearchParams);
4
+ /** @internal Links the params to their URL, so changes update it. */
5
+ static _link(params: URLSearchParams, url: URL, query: string | null): void;
6
+ /** @internal */
7
+ static _reset(params: URLSearchParams, query: string | null): void;
8
+ get size(): number;
9
+ append(name: string, value: string): void;
10
+ delete(name: string, value?: string): void;
11
+ get(name: string): string | null;
12
+ getAll(name: string): string[];
13
+ has(name: string, value?: string): boolean;
14
+ set(name: string, value: string): void;
15
+ sort(): void;
16
+ forEach(fn: (value: string, name: string, params: URLSearchParams) => void, thisArg?: unknown): void;
17
+ entries(): IterableIterator<[string, string]>;
18
+ keys(): IterableIterator<string>;
19
+ values(): IterableIterator<string>;
20
+ [Symbol.iterator](): IterableIterator<[string, string]>;
21
+ toString(): string;
22
+ get [Symbol.toStringTag](): string;
23
+ }
24
+ export declare class URL {
25
+ #private;
26
+ constructor(url: string | URL, base?: string | URL);
27
+ static canParse(url: string, base?: string): boolean;
28
+ static parse(url: string, base?: string): URL | null;
29
+ /** @internal */
30
+ static _setQuery(url: URL, query: string): void;
31
+ get href(): string;
32
+ set href(v: string);
33
+ get origin(): string;
34
+ get protocol(): string;
35
+ set protocol(v: string);
36
+ get username(): string;
37
+ set username(v: string);
38
+ get password(): string;
39
+ set password(v: string);
40
+ get host(): string;
41
+ set host(v: string);
42
+ get hostname(): string;
43
+ set hostname(v: string);
44
+ get port(): string;
45
+ set port(v: string);
46
+ get pathname(): string;
47
+ set pathname(v: string);
48
+ get search(): string;
49
+ set search(v: string);
50
+ get searchParams(): URLSearchParams;
51
+ get hash(): string;
52
+ set hash(v: string);
53
+ toString(): string;
54
+ toJSON(): string;
55
+ get [Symbol.toStringTag](): string;
56
+ }