@ternkit/tern 1.0.0 → 1.1.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,39 @@
1
+ <script lang="ts">
2
+ import type { HTMLImgAttributes } from 'svelte/elements';
3
+ import type { Picture } from './types.js';
4
+
5
+ interface Props extends Omit<HTMLImgAttributes, 'src' | 'srcset' | 'width' | 'height'> {
6
+ /** The image, from `import picture from './photo.jpg?image'`. */
7
+ src: Picture;
8
+ /** Describes the image; use "" for decorative images. */
9
+ alt: string;
10
+ /**
11
+ * How wide the image is displayed, so the browser picks the right file,
12
+ * such as "(min-width: 768px) 50vw, 100vw". Defaults to its full width.
13
+ */
14
+ sizes?: string;
15
+ /** Load at once and with high priority, for the main image of a page. */
16
+ priority?: boolean;
17
+ }
18
+
19
+ let { src, alt, sizes, priority = false, ...rest }: Props = $props();
20
+ const fallbackSizes = $derived(`(min-width: ${src.width}px) ${src.width}px, 100vw`);
21
+ </script>
22
+
23
+ <picture>
24
+ {#each src.sources as source (source.type)}
25
+ <source type={source.type} srcset={source.srcset} sizes={sizes ?? fallbackSizes} />
26
+ {/each}
27
+ <img
28
+ src={src.src}
29
+ srcset={src.srcset}
30
+ sizes={sizes ?? fallbackSizes}
31
+ width={src.width}
32
+ height={src.height}
33
+ {alt}
34
+ loading={priority ? 'eager' : 'lazy'}
35
+ fetchpriority={priority ? 'high' : undefined}
36
+ decoding="async"
37
+ {...rest}
38
+ />
39
+ </picture>
@@ -0,0 +1,18 @@
1
+ import type { HTMLImgAttributes } from 'svelte/elements';
2
+ import type { Picture } from './types.js';
3
+ interface Props extends Omit<HTMLImgAttributes, 'src' | 'srcset' | 'width' | 'height'> {
4
+ /** The image, from `import picture from './photo.jpg?image'`. */
5
+ src: Picture;
6
+ /** Describes the image; use "" for decorative images. */
7
+ alt: string;
8
+ /**
9
+ * How wide the image is displayed, so the browser picks the right file,
10
+ * such as "(min-width: 768px) 50vw, 100vw". Defaults to its full width.
11
+ */
12
+ sizes?: string;
13
+ /** Load at once and with high priority, for the main image of a page. */
14
+ priority?: boolean;
15
+ }
16
+ declare const Image: import("svelte").Component<Props, {}, "">;
17
+ type Image = ReturnType<typeof Image>;
18
+ export default Image;
@@ -0,0 +1,15 @@
1
+ /** A responsive image from an `import x from './x.jpg?image'`. */
2
+ export interface Picture {
3
+ /** The largest version in the image's own format, for browsers without srcset. */
4
+ src: string;
5
+ /** The largest version's size, so the browser reserves space before it loads. */
6
+ width: number;
7
+ height: number;
8
+ /** Every width in the image's own format. */
9
+ srcset: string;
10
+ /** AVIF and WebP versions, best first. */
11
+ sources: {
12
+ type: string;
13
+ srcset: string;
14
+ }[];
15
+ }
@@ -0,0 +1 @@
1
+ export {};
package/dist/index.d.ts CHANGED
@@ -14,5 +14,15 @@ export type { BeforeNavigateEvent, OnNavigateEvent } from './runtime/navigation.
14
14
  export { enhance } from './runtime/forms.js';
15
15
  export type { SubmitFunction, SubmitInput, SubmitResult } from './runtime/forms.js';
16
16
  export type { ActionResult, GotoOptions, Navigation, NavigationTarget, NavigationType } from './runtime/types.js';
17
+ export { default as Image } from './image/Image.svelte';
18
+ export type { Picture } from './image/types.js';
19
+ export { t, i18n, setLocale } from './runtime/i18n.svelte.js';
20
+ export type { MessageKey } from './runtime/i18n.svelte.js';
21
+ /**
22
+ * The app's message keys. `tern gen` fills this in from src/locales, so
23
+ * t() only accepts keys that exist.
24
+ */
25
+ export interface Messages {
26
+ }
17
27
  /** The version of the Tern client runtime. */
18
- export declare const VERSION = "1.0.0";
28
+ export declare const VERSION = "1.1.0";
package/dist/index.js CHANGED
@@ -11,5 +11,7 @@ export { page, navigating, env } from './runtime/state.svelte.js';
11
11
  export { goto, invalidate, invalidateAll, preloadData, applyAction } from './runtime/router.svelte.js';
12
12
  export { beforeNavigate, onNavigate, afterNavigate } from './runtime/navigation.js';
13
13
  export { enhance } from './runtime/forms.js';
14
+ export { default as Image } from './image/Image.svelte';
15
+ export { t, i18n, setLocale } from './runtime/i18n.svelte.js';
14
16
  /** The version of the Tern client runtime. */
15
- export const VERSION = '1.0.0';
17
+ export const VERSION = '1.1.0';
@@ -0,0 +1,19 @@
1
+ type Node = {
2
+ type: 'text';
3
+ text: string;
4
+ } | {
5
+ type: 'hash';
6
+ } | {
7
+ type: 'arg';
8
+ name: string;
9
+ } | {
10
+ type: 'plural' | 'select';
11
+ name: string;
12
+ options: Record<string, Node[]>;
13
+ };
14
+ /** Formats one message in the given locale. */
15
+ export declare function format(locale: string, message: string, params?: Record<string, unknown>): string;
16
+ export declare function parse(message: string): Node[];
17
+ /** The CLDR cardinal plural category of n in locale (see the Go version). */
18
+ export declare function pluralCategory(locale: string, n: number): string;
19
+ export {};
@@ -0,0 +1,279 @@
1
+ // Message formatting for i18n. This mirrors internal/i18n in Go exactly:
2
+ // both are tested against internal/i18n/testdata/cases.json, so the server
3
+ // and the browser produce the same text and hydration matches.
4
+ const cache = new Map();
5
+ /** Formats one message in the given locale. */
6
+ export function format(locale, message, params = {}) {
7
+ let nodes = cache.get(message);
8
+ if (nodes === undefined) {
9
+ try {
10
+ nodes = parse(message);
11
+ }
12
+ catch {
13
+ nodes = null;
14
+ }
15
+ cache.set(message, nodes);
16
+ }
17
+ if (!nodes)
18
+ return message;
19
+ return render(nodes, locale, params, undefined);
20
+ }
21
+ function render(nodes, locale, params, hash) {
22
+ let out = '';
23
+ for (const n of nodes) {
24
+ switch (n.type) {
25
+ case 'text':
26
+ out += n.text;
27
+ break;
28
+ case 'hash':
29
+ out += hash === undefined ? '#' : formatNumber(hash);
30
+ break;
31
+ case 'arg':
32
+ out += n.name in params ? String(params[n.name]) : `{${n.name}}`;
33
+ break;
34
+ case 'plural': {
35
+ const num = Number(params[n.name] ?? 0) || 0;
36
+ const body = n.options[`=${formatNumber(num)}`] ?? n.options[pluralCategory(locale, num)] ?? n.options.other;
37
+ out += render(body, locale, params, num);
38
+ break;
39
+ }
40
+ case 'select': {
41
+ const body = n.options[String(params[n.name])] ?? n.options.other;
42
+ out += render(body, locale, params, hash);
43
+ }
44
+ }
45
+ }
46
+ return out;
47
+ }
48
+ function formatNumber(n) {
49
+ return String(n);
50
+ }
51
+ export function parse(message) {
52
+ const p = { s: [...message], i: 0 };
53
+ const nodes = parseNodes(p, false, false);
54
+ if (p.i < p.s.length)
55
+ throw new Error(`unexpected '}' at ${p.i}`);
56
+ return nodes;
57
+ }
58
+ function parseNodes(p, inBranch, plural) {
59
+ const out = [];
60
+ let text = '';
61
+ const flush = () => {
62
+ if (text)
63
+ out.push({ type: 'text', text });
64
+ text = '';
65
+ };
66
+ while (p.i < p.s.length) {
67
+ const c = p.s[p.i];
68
+ if (c === "'") {
69
+ const next = p.s[p.i + 1];
70
+ if (next === "'") {
71
+ text += "'";
72
+ p.i += 2;
73
+ continue;
74
+ }
75
+ if (next !== undefined && '{}#'.includes(next)) {
76
+ let end = p.i + 1;
77
+ while (end < p.s.length && p.s[end] !== "'")
78
+ end++;
79
+ text += p.s.slice(p.i + 1, end).join('');
80
+ p.i = end + 1;
81
+ continue;
82
+ }
83
+ text += c;
84
+ p.i++;
85
+ }
86
+ else if (c === '{') {
87
+ flush();
88
+ out.push(parseArgument(p));
89
+ }
90
+ else if (c === '}') {
91
+ if (!inBranch)
92
+ throw new Error(`unexpected '}' at ${p.i}`);
93
+ flush();
94
+ return out;
95
+ }
96
+ else if (c === '#' && plural) {
97
+ flush();
98
+ out.push({ type: 'hash' });
99
+ p.i++;
100
+ }
101
+ else {
102
+ text += c;
103
+ p.i++;
104
+ }
105
+ }
106
+ if (inBranch)
107
+ throw new Error("unclosed '{'");
108
+ flush();
109
+ return out;
110
+ }
111
+ function until(p, stops) {
112
+ const start = p.i;
113
+ while (p.i < p.s.length && !stops.includes(p.s[p.i]))
114
+ p.i++;
115
+ return p.s.slice(start, p.i).join('');
116
+ }
117
+ function parseArgument(p) {
118
+ p.i++; // {
119
+ const name = until(p, ',}').trim();
120
+ if (!name)
121
+ throw new Error(`empty argument at ${p.i}`);
122
+ if (p.i >= p.s.length)
123
+ throw new Error("unclosed '{'");
124
+ if (p.s[p.i] === '}') {
125
+ p.i++;
126
+ return { type: 'arg', name };
127
+ }
128
+ p.i++; // ,
129
+ const kind = until(p, ',}').trim();
130
+ if (kind !== 'plural' && kind !== 'select')
131
+ throw new Error(`unknown argument type "${kind}"`);
132
+ if (p.s[p.i] !== ',')
133
+ throw new Error(`${kind} argument "${name}" needs options`);
134
+ p.i++;
135
+ const options = {};
136
+ for (;;) {
137
+ while (p.i < p.s.length && ' \n\t'.includes(p.s[p.i]))
138
+ p.i++;
139
+ if (p.i >= p.s.length)
140
+ throw new Error("unclosed '{'");
141
+ if (p.s[p.i] === '}') {
142
+ p.i++;
143
+ break;
144
+ }
145
+ const key = until(p, '{').trim();
146
+ if (!key || p.i >= p.s.length)
147
+ throw new Error(`expected an option in "${name}"`);
148
+ p.i++; // {
149
+ options[key] = parseNodes(p, true, kind === 'plural');
150
+ p.i++; // }
151
+ }
152
+ if (!options.other)
153
+ throw new Error(`${kind} argument "${name}" needs an 'other' option`);
154
+ return { type: kind, name, options };
155
+ }
156
+ /** The CLDR cardinal plural category of n in locale (see the Go version). */
157
+ export function pluralCategory(locale, n) {
158
+ const lang = locale.toLowerCase().split(/[-_]/)[0];
159
+ const i = Math.abs(Math.trunc(n));
160
+ const isInt = n === Math.trunc(n);
161
+ const mod10 = i % 10;
162
+ const mod100 = i % 100;
163
+ switch (lang) {
164
+ case 'ja':
165
+ case 'zh':
166
+ case 'ko':
167
+ case 'vi':
168
+ case 'th':
169
+ case 'id':
170
+ case 'ms':
171
+ case 'lo':
172
+ case 'my':
173
+ case 'km':
174
+ return 'other';
175
+ case 'fr':
176
+ case 'pt':
177
+ case 'hy':
178
+ case 'ff':
179
+ case 'kab':
180
+ if (i === 0 || i === 1)
181
+ return 'one';
182
+ break;
183
+ case 'ru':
184
+ case 'uk':
185
+ case 'be':
186
+ case 'sr':
187
+ case 'hr':
188
+ case 'bs':
189
+ if (!isInt)
190
+ return 'other';
191
+ if (mod10 === 1 && mod100 !== 11)
192
+ return 'one';
193
+ if (mod10 >= 2 && mod10 <= 4 && (mod100 < 12 || mod100 > 14))
194
+ return 'few';
195
+ if (lang === 'ru' || lang === 'uk' || lang === 'be')
196
+ return 'many';
197
+ break;
198
+ case 'pl':
199
+ if (!isInt)
200
+ return 'other';
201
+ if (i === 1)
202
+ return 'one';
203
+ if (mod10 >= 2 && mod10 <= 4 && (mod100 < 12 || mod100 > 14))
204
+ return 'few';
205
+ return 'many';
206
+ case 'cs':
207
+ case 'sk':
208
+ if (!isInt)
209
+ return 'many';
210
+ if (i === 1)
211
+ return 'one';
212
+ if (i >= 2 && i <= 4)
213
+ return 'few';
214
+ break;
215
+ case 'ro':
216
+ if (i === 1 && isInt)
217
+ return 'one';
218
+ if (!isInt || i === 0 || (mod100 >= 2 && mod100 <= 19))
219
+ return 'few';
220
+ break;
221
+ case 'lt':
222
+ if (mod10 === 1 && (mod100 < 11 || mod100 > 19))
223
+ return 'one';
224
+ if (mod10 >= 2 && (mod100 < 11 || mod100 > 19))
225
+ return 'few';
226
+ break;
227
+ case 'lv':
228
+ if (mod10 === 0 || (mod100 >= 11 && mod100 <= 19))
229
+ return 'zero';
230
+ if (mod10 === 1 && mod100 !== 11)
231
+ return 'one';
232
+ break;
233
+ case 'ar':
234
+ if (n === 0)
235
+ return 'zero';
236
+ if (n === 1)
237
+ return 'one';
238
+ if (n === 2)
239
+ return 'two';
240
+ if (mod100 >= 3 && mod100 <= 10)
241
+ return 'few';
242
+ if (mod100 >= 11 && mod100 <= 99)
243
+ return 'many';
244
+ break;
245
+ case 'he':
246
+ case 'iw':
247
+ if (i === 1 && isInt)
248
+ return 'one';
249
+ if (i === 2 && isInt)
250
+ return 'two';
251
+ break;
252
+ case 'ga':
253
+ if (n === 1)
254
+ return 'one';
255
+ if (n === 2)
256
+ return 'two';
257
+ if (n >= 3 && n <= 6)
258
+ return 'few';
259
+ if (n >= 7 && n <= 10)
260
+ return 'many';
261
+ break;
262
+ case 'cy':
263
+ if (n === 0)
264
+ return 'zero';
265
+ if (n === 1)
266
+ return 'one';
267
+ if (n === 2)
268
+ return 'two';
269
+ if (n === 3)
270
+ return 'few';
271
+ if (n === 6)
272
+ return 'many';
273
+ break;
274
+ default:
275
+ if (n === 1 && isInt)
276
+ return 'one';
277
+ }
278
+ return 'other';
279
+ }
@@ -0,0 +1,50 @@
1
+ import type { Messages } from '../index.js';
2
+ /** A message key from src/locales, or any string when the app has none. */
3
+ export type MessageKey = [keyof Messages] extends [never] ? string : keyof Messages & string;
4
+ type Nested = {
5
+ [key: string]: string | Nested;
6
+ };
7
+ export interface I18nConfig {
8
+ defaultLocale: string;
9
+ locales?: string[];
10
+ /** Client: lazy loaders. */
11
+ load?: Record<string, () => Promise<{
12
+ default: Nested;
13
+ }>>;
14
+ /** Server: every locale's messages, eagerly imported. */
15
+ messages?: Record<string, Nested>;
16
+ }
17
+ export declare function configureI18n(c: I18nConfig | null): void;
18
+ /** Loads a locale's messages (and the default's, for fallbacks) and makes it current. */
19
+ export declare function useLocale(locale: string | undefined): Promise<void>;
20
+ /** Synchronous version for the server renderer, where every locale is loaded. */
21
+ export declare function useLocaleSync(locale: string | undefined): void;
22
+ /**
23
+ * Translates a message from src/locales into the current locale:
24
+ *
25
+ * ```svelte
26
+ * <h1>{t('welcome', { name: data.user })}</h1>
27
+ * <p>{t('todos.left', { count: data.left })}</p>
28
+ * ```
29
+ *
30
+ * Messages use ICU syntax for placeholders, plurals and selects. A message
31
+ * missing from the current locale falls back to the default locale, and
32
+ * then to the key.
33
+ */
34
+ export declare function t(key: MessageKey, params?: Record<string, unknown>): string;
35
+ /** The current locale and the ones the app supports. */
36
+ export declare const i18n: {
37
+ /** The current locale, such as "de", or "" if the app has no src/locales. */
38
+ readonly locale: string;
39
+ /** Every locale in src/locales. */
40
+ readonly locales: string[];
41
+ /** The locale used for missing messages. */
42
+ readonly defaultLocale: string;
43
+ };
44
+ /**
45
+ * Switches the user to another locale: stores it in the locale cookie, which
46
+ * the server prefers over Accept-Language, and reloads the page's data. For
47
+ * locales in the URL (a lang route parameter), link to the other URL instead.
48
+ */
49
+ export declare function setLocale(locale: string, base?: string): Promise<void>;
50
+ export {};
@@ -0,0 +1,105 @@
1
+ import { format } from './format.js';
2
+ class I18nState {
3
+ locale = $state('');
4
+ messages = $state({});
5
+ fallback = {};
6
+ }
7
+ const state = new I18nState();
8
+ let config = null;
9
+ const loaded = new Map();
10
+ const warned = new Set();
11
+ function flatten(tree, prefix = '', out = {}) {
12
+ for (const [k, v] of Object.entries(tree)) {
13
+ const key = prefix ? `${prefix}.${k}` : k;
14
+ if (typeof v === 'string')
15
+ out[key] = v;
16
+ else
17
+ flatten(v, key, out);
18
+ }
19
+ return out;
20
+ }
21
+ export function configureI18n(c) {
22
+ config = c;
23
+ if (c?.messages) {
24
+ for (const [l, m] of Object.entries(c.messages))
25
+ loaded.set(l, flatten(m));
26
+ }
27
+ }
28
+ async function messagesFor(locale) {
29
+ const hit = loaded.get(locale);
30
+ if (hit)
31
+ return hit;
32
+ const loader = config?.load?.[locale];
33
+ if (!loader)
34
+ return {};
35
+ const flat = flatten((await loader()).default);
36
+ loaded.set(locale, flat);
37
+ return flat;
38
+ }
39
+ /** Loads a locale's messages (and the default's, for fallbacks) and makes it current. */
40
+ export async function useLocale(locale) {
41
+ if (!config || !locale)
42
+ return;
43
+ const [messages, fallback] = await Promise.all([messagesFor(locale), messagesFor(config.defaultLocale)]);
44
+ state.fallback = fallback;
45
+ state.messages = messages;
46
+ state.locale = locale;
47
+ if (typeof document !== 'undefined')
48
+ document.documentElement.lang = locale;
49
+ }
50
+ /** Synchronous version for the server renderer, where every locale is loaded. */
51
+ export function useLocaleSync(locale) {
52
+ if (!config || !locale)
53
+ return;
54
+ state.fallback = loaded.get(config.defaultLocale) ?? {};
55
+ state.messages = loaded.get(locale) ?? {};
56
+ state.locale = locale;
57
+ }
58
+ /**
59
+ * Translates a message from src/locales into the current locale:
60
+ *
61
+ * ```svelte
62
+ * <h1>{t('welcome', { name: data.user })}</h1>
63
+ * <p>{t('todos.left', { count: data.left })}</p>
64
+ * ```
65
+ *
66
+ * Messages use ICU syntax for placeholders, plurals and selects. A message
67
+ * missing from the current locale falls back to the default locale, and
68
+ * then to the key.
69
+ */
70
+ export function t(key, params) {
71
+ const message = state.messages[key] ?? state.fallback[key];
72
+ if (message === undefined) {
73
+ if (import.meta.env?.DEV && !warned.has(key)) {
74
+ warned.add(key);
75
+ console.warn(`[tern] missing message "${key}"`);
76
+ }
77
+ return key;
78
+ }
79
+ return format(state.locale || config?.defaultLocale || 'en', message, params);
80
+ }
81
+ /** The current locale and the ones the app supports. */
82
+ export const i18n = {
83
+ /** The current locale, such as "de", or "" if the app has no src/locales. */
84
+ get locale() {
85
+ return state.locale;
86
+ },
87
+ /** Every locale in src/locales. */
88
+ get locales() {
89
+ return config?.locales ?? Object.keys(config?.messages ?? {});
90
+ },
91
+ /** The locale used for missing messages. */
92
+ get defaultLocale() {
93
+ return config?.defaultLocale ?? '';
94
+ }
95
+ };
96
+ /**
97
+ * Switches the user to another locale: stores it in the locale cookie, which
98
+ * the server prefers over Accept-Language, and reloads the page's data. For
99
+ * locales in the URL (a lang route parameter), link to the other URL instead.
100
+ */
101
+ export async function setLocale(locale, base = '') {
102
+ document.cookie = `locale=${encodeURIComponent(locale)}; path=${base || '/'}; max-age=31536000; samesite=lax`;
103
+ const { invalidateAll } = await import('./router.svelte.js');
104
+ await invalidateAll();
105
+ }
@@ -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 { configureI18n, useLocale } from './i18n.svelte.js';
6
7
  import { runAfter, runBefore, runOn } from './navigation.js';
7
8
  import { plan } from './select.js';
8
9
  import { applyPayloadToPage, page, setNavigating } from './state.svelte.js';
@@ -31,6 +32,7 @@ const RELOAD = Symbol('reload');
31
32
  /** Starts the client. Called by the generated entry module. */
32
33
  export async function start(m) {
33
34
  manifest = m;
35
+ configureI18n(m.i18n ?? null);
34
36
  const el = document.getElementById('__tern_data');
35
37
  const target = document.getElementById('tern-root');
36
38
  if (!el || !target) {
@@ -66,6 +68,7 @@ export async function start(m) {
66
68
  started = true;
67
69
  await tick();
68
70
  runAfter({ from: null, to: current(), type: 'enter' });
71
+ registerServiceWorker();
69
72
  const saved = scrollPositions[historyIndex];
70
73
  if (saved) {
71
74
  scrollTo(saved.x, saved.y);
@@ -75,6 +78,15 @@ export async function start(m) {
75
78
  }
76
79
  listen();
77
80
  }
81
+ function registerServiceWorker() {
82
+ // Not in development: a caching worker would serve stale modules.
83
+ if (!manifest.serviceWorker || import.meta.env?.DEV || !('serviceWorker' in navigator))
84
+ return;
85
+ const base = manifest.base ?? '';
86
+ navigator.serviceWorker.register(`${base}/service-worker.js`, { scope: `${base}/` }).catch((err) => {
87
+ console.error('Tern: registering the service worker failed', err);
88
+ });
89
+ }
78
90
  /** Navigates to href, which may be relative to the current URL. */
79
91
  export async function goto(href, opts = {}) {
80
92
  if (!started) {
@@ -329,6 +341,7 @@ async function render(p, url, state, id) {
329
341
  }
330
342
  }
331
343
  const what = plan(manifest, p);
344
+ const ready = useLocale(p.locale); // messages load alongside the components
332
345
  let components;
333
346
  switch (what.kind) {
334
347
  case 'not-a-page':
@@ -351,6 +364,7 @@ async function render(p, url, state, id) {
351
364
  layoutCount = 0;
352
365
  }
353
366
  nodes = p.nodes ?? [];
367
+ await ready;
354
368
  if (id !== token)
355
369
  return true; // superseded; the newer navigation renders
356
370
  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;
@@ -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,7 @@ export interface ServerManifest {
19
20
  component: Component<any>;
20
21
  }[];
21
22
  };
23
+ i18n?: I18nConfig | null;
22
24
  }
23
25
  /** The result of a server render, as JSON for the Go side. */
24
26
  export interface RenderResult {
@@ -3,6 +3,7 @@ import DefaultError from '../runtime/DefaultError.svelte';
3
3
  import Passthrough from '../runtime/Passthrough.svelte';
4
4
  import Root from '../runtime/Root.svelte';
5
5
  import { revive } from '../runtime/deferred.js';
6
+ import { configureI18n, useLocaleSync } from '../runtime/i18n.svelte.js';
6
7
  import { plan } from '../runtime/select.js';
7
8
  import { applyPayloadToPage } from '../runtime/state.svelte.js';
8
9
  /**
@@ -12,6 +13,7 @@ import { applyPayloadToPage } from '../runtime/state.svelte.js';
12
13
  */
13
14
  export function createRenderer(manifest) {
14
15
  const layout = (i) => manifest.layouts[i]?.component ?? Passthrough;
16
+ configureI18n(manifest.i18n ?? null);
15
17
  return (payloadJSON, href) => {
16
18
  const p = JSON.parse(payloadJSON);
17
19
  // Deferred values are still loading: render their pending state.
@@ -32,6 +34,7 @@ export function createRenderer(manifest) {
32
34
  case 'default-error':
33
35
  components = [DefaultError];
34
36
  }
37
+ useLocaleSync(p.locale);
35
38
  applyPayloadToPage(p, new URL(href), {}, what.data);
36
39
  const form = p.hasForm ? (p.form ?? null) : null;
37
40
  const out = render(Root, { props: { components, data: what.data, form } });
@@ -0,0 +1,4 @@
1
+ import type { Plugin } from 'vite';
2
+ export declare function imagePlugin(): Plugin;
3
+ /** The widths to generate: the requested ones, or common widths below the image's, plus its own. */
4
+ export declare function pickWidths(original: number, requested: string | null): number[];
@@ -0,0 +1,180 @@
1
+ import { createHash } from 'node:crypto';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ /**
5
+ * Responsive images: `import hero from './hero.jpg?image'` resizes the image
6
+ * to several widths in AVIF, WebP and its own format, and gives a Picture
7
+ * for the <Image> component. `?w=400,800&image` picks the widths.
8
+ *
9
+ * Images are encoded with sharp, an optional dependency. Output names are
10
+ * derived from the source's contents and the encoding settings, so the client
11
+ * and server builds agree on URLs without sharing state, and encoded files
12
+ * are cached in node_modules/.cache/tern-images between builds.
13
+ */
14
+ /** Bumped when the encoding changes, so cached files are not reused. */
15
+ const VERSION = 1;
16
+ const DEFAULT_WIDTHS = [640, 1280, 1920];
17
+ const QUALITY = { avif: 50, webp: 75, jpeg: 80, png: 100 };
18
+ const MIME = { avif: 'image/avif', webp: 'image/webp', jpeg: 'image/jpeg', png: 'image/png' };
19
+ const DEV_PREFIX = '@tern-image/';
20
+ const OUT_DIR = '_app/immutable/img/';
21
+ export function imagePlugin() {
22
+ let config;
23
+ let cacheDir;
24
+ let sharpPromise;
25
+ /** Dev: generated file name → how to make it. */
26
+ const pending = new Map();
27
+ const sharp = () => (sharpPromise ??= import('sharp').then((m) => m.default, () => {
28
+ throw new Error('tern: ?image imports need sharp. Install it with: npm install -D sharp');
29
+ }));
30
+ async function encode(source, v) {
31
+ const cached = path.join(cacheDir, `${v.key}.${v.format}`);
32
+ try {
33
+ return await fs.promises.readFile(cached);
34
+ }
35
+ catch {
36
+ // not cached yet
37
+ }
38
+ const s = await sharp();
39
+ const img = s(source).rotate().resize({ width: v.width, withoutEnlargement: true });
40
+ const out = await img.toFormat(v.format, { quality: QUALITY[v.format] }).toBuffer();
41
+ await fs.promises.mkdir(cacheDir, { recursive: true });
42
+ await fs.promises.writeFile(cached, out);
43
+ return out;
44
+ }
45
+ return {
46
+ name: 'tern:image',
47
+ enforce: 'pre',
48
+ configResolved(c) {
49
+ config = c;
50
+ cacheDir = path.join(c.root, 'node_modules/.cache/tern-images');
51
+ },
52
+ async load(id) {
53
+ const q = id.indexOf('?');
54
+ if (q < 0)
55
+ return undefined;
56
+ const params = new URLSearchParams(id.slice(q + 1));
57
+ if (!params.has('image'))
58
+ return undefined;
59
+ const source = id.slice(0, q);
60
+ this.addWatchFile(source);
61
+ const bytes = await fs.promises.readFile(source);
62
+ const meta = await metadata(bytes, cacheDir, sharp);
63
+ const fallback = fallbackFormat(meta.format, meta.hasAlpha);
64
+ const widths = pickWidths(meta.width, params.get('w'));
65
+ const formats = fallback === 'webp' ? ['avif', 'webp'] : ['avif', 'webp', fallback];
66
+ const contentHash = hash(bytes);
67
+ const name = path.basename(source, path.extname(source)).replace(/[^\w-]/g, '_');
68
+ const variants = new Map();
69
+ for (const format of formats) {
70
+ variants.set(format, widths.map((width) => {
71
+ const key = hash(`${VERSION}:${contentHash}:${width}:${format}:${QUALITY[format]}`).slice(0, 10);
72
+ return { file: `${name}-${width}-${key}.${format}`, width, format, key };
73
+ }));
74
+ }
75
+ const isBuild = config.command === 'build';
76
+ const isClient = !(this.environment?.config.consumer === 'server' || config.build.ssr);
77
+ const url = (v) => (isBuild ? config.base + OUT_DIR + v.file : config.base + DEV_PREFIX + v.file);
78
+ const all = [...variants.values()].flat();
79
+ if (isBuild && isClient) {
80
+ await Promise.all(all.map(async (v) => {
81
+ this.emitFile({ type: 'asset', fileName: OUT_DIR + v.file, source: await encode(source, v) });
82
+ }));
83
+ }
84
+ else if (!isBuild) {
85
+ for (const v of all)
86
+ pending.set(v.file, { ...v, source });
87
+ }
88
+ const srcset = (f) => variants
89
+ .get(f)
90
+ .map((v) => `${url(v)} ${v.width}w`)
91
+ .join(', ');
92
+ const largest = variants.get(fallback).at(-1);
93
+ const height = Math.round((meta.height * largest.width) / meta.width);
94
+ const picture = {
95
+ src: url(largest),
96
+ width: largest.width,
97
+ height,
98
+ srcset: srcset(fallback),
99
+ sources: formats.filter((f) => f !== fallback).map((f) => ({ type: MIME[f], srcset: srcset(f) }))
100
+ };
101
+ return `export default ${JSON.stringify(picture)};`;
102
+ },
103
+ configureServer(server) {
104
+ server.middlewares.use((req, res, next) => {
105
+ const at = req.url?.indexOf('/' + DEV_PREFIX) ?? -1;
106
+ if (at < 0)
107
+ return next();
108
+ const file = decodeURIComponent(req.url.slice(at + DEV_PREFIX.length + 1).split('?')[0]);
109
+ const v = pending.get(file);
110
+ if (!v)
111
+ return next();
112
+ encode(v.source, v).then((buf) => {
113
+ res.setHeader('Content-Type', MIME[v.format]);
114
+ res.setHeader('Cache-Control', 'no-cache');
115
+ res.end(buf);
116
+ }, (err) => next(err));
117
+ });
118
+ }
119
+ };
120
+ }
121
+ function hash(data) {
122
+ return createHash('sha256').update(data).digest('hex');
123
+ }
124
+ /** The widths to generate: the requested ones, or common widths below the image's, plus its own. */
125
+ export function pickWidths(original, requested) {
126
+ let widths;
127
+ if (requested) {
128
+ widths = requested
129
+ .split(/[,;]/)
130
+ .map((w) => Number.parseInt(w, 10))
131
+ .filter((w) => w > 0)
132
+ .map((w) => Math.min(w, original));
133
+ if (widths.length === 0)
134
+ throw new Error(`tern: invalid image widths "${requested}"`);
135
+ }
136
+ else {
137
+ widths = [...DEFAULT_WIDTHS.filter((w) => w < original), original];
138
+ }
139
+ return [...new Set(widths)].sort((a, b) => a - b);
140
+ }
141
+ function fallbackFormat(format, hasAlpha) {
142
+ switch (format) {
143
+ case 'jpeg':
144
+ case 'jpg':
145
+ return 'jpeg';
146
+ case 'webp':
147
+ return 'webp';
148
+ case 'png':
149
+ case 'gif':
150
+ return 'png';
151
+ default:
152
+ return hasAlpha ? 'png' : 'jpeg';
153
+ }
154
+ }
155
+ async function metadata(bytes, cacheDir, sharp) {
156
+ const cached = path.join(cacheDir, `${hash(bytes).slice(0, 16)}.json`);
157
+ try {
158
+ return JSON.parse(await fs.promises.readFile(cached, 'utf8'));
159
+ }
160
+ catch {
161
+ // not cached yet
162
+ }
163
+ const s = await sharp();
164
+ const m = await s(bytes).metadata();
165
+ if (m.format === 'svg')
166
+ throw new Error('tern: ?image is for raster images; import SVGs without ?image');
167
+ if (!m.width || !m.height)
168
+ throw new Error('tern: could not read the image size');
169
+ // EXIF orientations 5–8 rotate by 90°, swapping width and height.
170
+ const rotated = (m.orientation ?? 1) >= 5;
171
+ const meta = {
172
+ width: rotated ? m.height : m.width,
173
+ height: rotated ? m.width : m.height,
174
+ format: m.format,
175
+ hasAlpha: m.hasAlpha ?? false
176
+ };
177
+ await fs.promises.mkdir(cacheDir, { recursive: true });
178
+ await fs.promises.writeFile(cached, JSON.stringify(meta));
179
+ return meta;
180
+ }
@@ -1,10 +1,12 @@
1
1
  import { svelte } from '@sveltejs/vite-plugin-svelte';
2
2
  import fs from 'node:fs';
3
3
  import path from 'node:path';
4
+ import { imagePlugin } from './image.js';
4
5
  /** Paths shared with the Go code generator; keep in sync with internal/codegen. */
5
6
  const CLIENT_ENTRY = '.tern/generated/client.js';
6
7
  const SERVER_ENTRY = '.tern/generated/server.js';
7
8
  const CLIENT_OUT = '.tern/output/client';
9
+ const SW_MODULE = '$service-worker';
8
10
  const SERVER_OUT = '.tern/output/server';
9
11
  /** The dev endpoint the Go app calls to server-render a page. */
10
12
  const RENDER_PATH = '/__tern/render';
@@ -40,6 +42,26 @@ export function tern(options = {}) {
40
42
  }
41
43
  }
42
44
  };
45
+ if (process.env.TERN_SW_BUILD === '1') {
46
+ const source = ['src/service-worker.ts', 'src/service-worker.js'].map((f) => path.join(root, f)).find((f) => fs.existsSync(f));
47
+ if (!source)
48
+ throw new Error('tern: no src/service-worker.ts to build');
49
+ return {
50
+ ...shared,
51
+ publicDir: false,
52
+ build: {
53
+ outDir: CLIENT_OUT,
54
+ emptyOutDir: false,
55
+ manifest: false,
56
+ minify: true,
57
+ target: 'es2022',
58
+ rolldownOptions: {
59
+ input: source,
60
+ output: { format: 'iife', entryFileNames: 'service-worker.js' }
61
+ }
62
+ }
63
+ };
64
+ }
43
65
  if (env.isSsrBuild) {
44
66
  return {
45
67
  ...shared,
@@ -91,6 +113,14 @@ export function tern(options = {}) {
91
113
  }
92
114
  };
93
115
  },
116
+ resolveId(id) {
117
+ return id === SW_MODULE ? '\0' + SW_MODULE : undefined;
118
+ },
119
+ load(id) {
120
+ if (id !== '\0' + SW_MODULE)
121
+ return undefined;
122
+ return serviceWorkerModule(root, readBasePath(root));
123
+ },
94
124
  configureServer(server) {
95
125
  server.middlewares.use(RENDER_PATH, (req, res) => {
96
126
  void renderInDev(server, req).then((body) => {
@@ -116,7 +146,55 @@ export function tern(options = {}) {
116
146
  const mod = (await server.ssrLoadModule(path.join(root, SERVER_ENTRY)));
117
147
  return mod.render(payload, href);
118
148
  }
119
- return [...svelte(options.svelte), plugin];
149
+ return [imagePlugin(), ...svelte(options.svelte), plugin];
150
+ }
151
+ /** The $service-worker module: what this build produced, for caching. */
152
+ function serviceWorkerModule(root, base) {
153
+ const out = path.join(root, CLIENT_OUT);
154
+ const build = [];
155
+ try {
156
+ const manifest = JSON.parse(fs.readFileSync(path.join(out, '.vite/manifest.json'), 'utf8'));
157
+ const seen = new Set();
158
+ for (const chunk of Object.values(manifest)) {
159
+ for (const f of [chunk.file, ...(chunk.css ?? []), ...(chunk.assets ?? [])]) {
160
+ if (!seen.has(f)) {
161
+ seen.add(f);
162
+ build.push(`${base}/${f}`);
163
+ }
164
+ }
165
+ }
166
+ }
167
+ catch {
168
+ // Development: nothing is built yet.
169
+ }
170
+ const files = walk(path.join(root, 'static')).map((f) => `${base}/${f}`);
171
+ const prerendered = walk(out)
172
+ .filter((f) => f === 'index.html' || f.endsWith('/index.html'))
173
+ .map((f) => `${base}/${f.replace(/\/?index\.html$/, '')}` || '/');
174
+ const version = Date.now().toString(36);
175
+ return [
176
+ `export const base = ${JSON.stringify(base)};`,
177
+ `export const build = ${JSON.stringify(build.sort())};`,
178
+ `export const files = ${JSON.stringify(files.sort())};`,
179
+ `export const prerendered = ${JSON.stringify(prerendered.sort())};`,
180
+ `export const version = ${JSON.stringify(version)};`
181
+ ].join('\n');
182
+ }
183
+ /** Lists files below dir, relative to it, with forward slashes. */
184
+ function walk(dir, prefix = '') {
185
+ let entries;
186
+ try {
187
+ entries = fs.readdirSync(dir, { withFileTypes: true });
188
+ }
189
+ catch {
190
+ return [];
191
+ }
192
+ return entries.flatMap((e) => {
193
+ if (e.name.startsWith('.') || e.name.endsWith('.gz'))
194
+ return [];
195
+ const rel = prefix ? `${prefix}/${e.name}` : e.name;
196
+ return e.isDirectory() ? walk(path.join(dir, e.name), rel) : [rel];
197
+ });
120
198
  }
121
199
  /** Reads basePath from tern.json; the Go side validates it. */
122
200
  function readBasePath(root) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ternkit/tern",
3
- "version": "1.0.0",
3
+ "version": "1.1.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",
@@ -56,7 +56,8 @@
56
56
  },
57
57
  "peerDependencies": {
58
58
  "svelte": "^5.46.4",
59
- "vite": "^8.0.0"
59
+ "vite": "^8.0.0",
60
+ "sharp": ">=0.33.0"
60
61
  },
61
62
  "dependencies": {
62
63
  "@sveltejs/vite-plugin-svelte": "^7.3.1",
@@ -65,10 +66,16 @@
65
66
  "devDependencies": {
66
67
  "@sveltejs/package": "^3.0.2",
67
68
  "@types/node": "^24.0.0",
69
+ "sharp": "^0.35.5",
68
70
  "svelte": "^5.57.3",
69
71
  "svelte-check": "^4.7.8",
70
72
  "typescript": "^6.0.0",
71
73
  "vite": "^8.3.7",
72
74
  "vitest": "^5.1.0"
75
+ },
76
+ "peerDependenciesMeta": {
77
+ "sharp": {
78
+ "optional": true
79
+ }
73
80
  }
74
81
  }