@ternkit/tern 0.4.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.
- package/dist/image/Image.svelte +39 -0
- package/dist/image/Image.svelte.d.ts +18 -0
- package/dist/image/types.d.ts +15 -0
- package/dist/image/types.js +1 -0
- package/dist/index.d.ts +12 -2
- package/dist/index.js +4 -2
- package/dist/runtime/format.d.ts +19 -0
- package/dist/runtime/format.js +279 -0
- package/dist/runtime/i18n.svelte.d.ts +50 -0
- package/dist/runtime/i18n.svelte.js +105 -0
- package/dist/runtime/router.svelte.js +26 -4
- package/dist/runtime/state.svelte.d.ts +11 -0
- package/dist/runtime/state.svelte.js +16 -0
- package/dist/runtime/types.d.ts +10 -0
- package/dist/server/index.d.ts +2 -0
- package/dist/server/index.js +3 -0
- package/dist/vite/image.d.ts +4 -0
- package/dist/vite/image.js +180 -0
- package/dist/vite/index.js +92 -1
- package/package.json +9 -2
|
@@ -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
|
@@ -7,12 +7,22 @@
|
|
|
7
7
|
* </script>
|
|
8
8
|
* ```
|
|
9
9
|
*/
|
|
10
|
-
export { page, navigating } from './runtime/state.svelte.js';
|
|
10
|
+
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 type { BeforeNavigateEvent, OnNavigateEvent } from './runtime/navigation.js';
|
|
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 = "
|
|
28
|
+
export declare const VERSION = "1.1.0";
|
package/dist/index.js
CHANGED
|
@@ -7,9 +7,11 @@
|
|
|
7
7
|
* </script>
|
|
8
8
|
* ```
|
|
9
9
|
*/
|
|
10
|
-
export { page, navigating } from './runtime/state.svelte.js';
|
|
10
|
+
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 = '
|
|
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,13 +78,22 @@ 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) {
|
|
81
93
|
throw new Error('Tern: goto() was called before the app started');
|
|
82
94
|
}
|
|
83
95
|
const url = new URL(href, location.href);
|
|
84
|
-
if (url
|
|
96
|
+
if (!inApp(url)) {
|
|
85
97
|
location.href = url.href;
|
|
86
98
|
return;
|
|
87
99
|
}
|
|
@@ -162,6 +174,7 @@ function setForm(form) {
|
|
|
162
174
|
}
|
|
163
175
|
function dataURL(url, reuse) {
|
|
164
176
|
const path = url.pathname.replace(/\/$/, '');
|
|
177
|
+
// The base path is part of the pathname; the server strips it.
|
|
165
178
|
let target = `${path}/__data.json${url.search}`;
|
|
166
179
|
if (reuse) {
|
|
167
180
|
const here = url.pathname + url.search;
|
|
@@ -240,7 +253,7 @@ async function navigate(url, opts, type, redirects, popIndex, fromIndex) {
|
|
|
240
253
|
}
|
|
241
254
|
if (payload.type === 'redirect' && payload.location) {
|
|
242
255
|
const next = new URL(payload.location, url);
|
|
243
|
-
if (redirects >= MAX_REDIRECTS || next
|
|
256
|
+
if (redirects >= MAX_REDIRECTS || !inApp(next)) {
|
|
244
257
|
location.href = next.href;
|
|
245
258
|
return;
|
|
246
259
|
}
|
|
@@ -328,6 +341,7 @@ async function render(p, url, state, id) {
|
|
|
328
341
|
}
|
|
329
342
|
}
|
|
330
343
|
const what = plan(manifest, p);
|
|
344
|
+
const ready = useLocale(p.locale); // messages load alongside the components
|
|
331
345
|
let components;
|
|
332
346
|
switch (what.kind) {
|
|
333
347
|
case 'not-a-page':
|
|
@@ -350,6 +364,7 @@ async function render(p, url, state, id) {
|
|
|
350
364
|
layoutCount = 0;
|
|
351
365
|
}
|
|
352
366
|
nodes = p.nodes ?? [];
|
|
367
|
+
await ready;
|
|
353
368
|
if (id !== token)
|
|
354
369
|
return true; // superseded; the newer navigation renders
|
|
355
370
|
if (p.type === 'data') {
|
|
@@ -410,12 +425,19 @@ function findAnchor(event) {
|
|
|
410
425
|
}
|
|
411
426
|
return null;
|
|
412
427
|
}
|
|
428
|
+
/** Reports whether url belongs to this app (its origin and base path). */
|
|
429
|
+
function inApp(url) {
|
|
430
|
+
if (url.origin !== location.origin)
|
|
431
|
+
return false;
|
|
432
|
+
const base = manifest.base ?? '';
|
|
433
|
+
return !base || url.pathname === base || url.pathname.startsWith(base + '/');
|
|
434
|
+
}
|
|
413
435
|
function anchorURL(a) {
|
|
414
436
|
const href = a instanceof SVGAElement ? a.href.baseVal : a.href;
|
|
415
437
|
if (!href)
|
|
416
438
|
return null;
|
|
417
439
|
const url = new URL(href, document.baseURI);
|
|
418
|
-
if (url
|
|
440
|
+
if (!inApp(url) || !/^https?:$/.test(url.protocol))
|
|
419
441
|
return null;
|
|
420
442
|
const target = a instanceof SVGAElement ? a.target.baseVal : a.target;
|
|
421
443
|
if (target && target !== '_self')
|
|
@@ -476,7 +498,7 @@ function listen() {
|
|
|
476
498
|
return;
|
|
477
499
|
}
|
|
478
500
|
const url = new URL(submitter?.hasAttribute('formaction') ? submitter.formAction : own.action);
|
|
479
|
-
if (url
|
|
501
|
+
if (!inApp(url))
|
|
480
502
|
return;
|
|
481
503
|
event.preventDefault();
|
|
482
504
|
url.search = new URLSearchParams(new FormData(form, submitter)).toString();
|
|
@@ -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 };
|
package/dist/runtime/types.d.ts
CHANGED
|
@@ -21,6 +21,12 @@ 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;
|
|
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;
|
|
24
30
|
layouts: LayoutEntry[];
|
|
25
31
|
routes: Record<string, RouteEntry>;
|
|
26
32
|
fallback: {
|
|
@@ -47,6 +53,10 @@ export interface Payload {
|
|
|
47
53
|
uses?: (NodeUses | null)[];
|
|
48
54
|
/** Nodes the server skipped because the client already has their data. */
|
|
49
55
|
reused?: number[];
|
|
56
|
+
/** PUBLIC_ environment variables, on full page loads. */
|
|
57
|
+
env?: Record<string, string>;
|
|
58
|
+
/** The request's locale, when the app has src/locales. */
|
|
59
|
+
locale?: string;
|
|
50
60
|
}
|
|
51
61
|
export interface NodeUses {
|
|
52
62
|
url?: boolean;
|
package/dist/server/index.d.ts
CHANGED
|
@@ -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 {
|
package/dist/server/index.js
CHANGED
|
@@ -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
|
+
}
|
package/dist/vite/index.js
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
import { svelte } from '@sveltejs/vite-plugin-svelte';
|
|
2
|
+
import fs from 'node:fs';
|
|
2
3
|
import path from 'node:path';
|
|
4
|
+
import { imagePlugin } from './image.js';
|
|
3
5
|
/** Paths shared with the Go code generator; keep in sync with internal/codegen. */
|
|
4
6
|
const CLIENT_ENTRY = '.tern/generated/client.js';
|
|
5
7
|
const SERVER_ENTRY = '.tern/generated/server.js';
|
|
6
8
|
const CLIENT_OUT = '.tern/output/client';
|
|
9
|
+
const SW_MODULE = '$service-worker';
|
|
7
10
|
const SERVER_OUT = '.tern/output/server';
|
|
8
11
|
/** The dev endpoint the Go app calls to server-render a page. */
|
|
9
12
|
const RENDER_PATH = '/__tern/render';
|
|
@@ -28,8 +31,10 @@ export function tern(options = {}) {
|
|
|
28
31
|
name: 'tern',
|
|
29
32
|
config(config, env) {
|
|
30
33
|
root = path.resolve(config.root ?? process.cwd());
|
|
34
|
+
const basePath = readBasePath(root);
|
|
31
35
|
const shared = {
|
|
32
36
|
appType: 'custom',
|
|
37
|
+
base: basePath + '/',
|
|
33
38
|
resolve: {
|
|
34
39
|
alias: {
|
|
35
40
|
$lib: path.join(root, 'src/lib'),
|
|
@@ -37,6 +42,26 @@ export function tern(options = {}) {
|
|
|
37
42
|
}
|
|
38
43
|
}
|
|
39
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
|
+
}
|
|
40
65
|
if (env.isSsrBuild) {
|
|
41
66
|
return {
|
|
42
67
|
...shared,
|
|
@@ -88,6 +113,14 @@ export function tern(options = {}) {
|
|
|
88
113
|
}
|
|
89
114
|
};
|
|
90
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
|
+
},
|
|
91
124
|
configureServer(server) {
|
|
92
125
|
server.middlewares.use(RENDER_PATH, (req, res) => {
|
|
93
126
|
void renderInDev(server, req).then((body) => {
|
|
@@ -113,6 +146,64 @@ export function tern(options = {}) {
|
|
|
113
146
|
const mod = (await server.ssrLoadModule(path.join(root, SERVER_ENTRY)));
|
|
114
147
|
return mod.render(payload, href);
|
|
115
148
|
}
|
|
116
|
-
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
|
+
});
|
|
198
|
+
}
|
|
199
|
+
/** Reads basePath from tern.json; the Go side validates it. */
|
|
200
|
+
function readBasePath(root) {
|
|
201
|
+
try {
|
|
202
|
+
const config = JSON.parse(fs.readFileSync(path.join(root, 'tern.json'), 'utf8'));
|
|
203
|
+
return config.basePath ?? '';
|
|
204
|
+
}
|
|
205
|
+
catch {
|
|
206
|
+
return '';
|
|
207
|
+
}
|
|
117
208
|
}
|
|
118
209
|
export default tern;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ternkit/tern",
|
|
3
|
-
"version": "
|
|
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
|
}
|