@ternkit/tern 1.8.0 → 1.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.ts CHANGED
@@ -30,4 +30,4 @@ export type { MessageKey } from './runtime/i18n.svelte.js';
30
30
  export interface Messages {
31
31
  }
32
32
  /** The version of the Tern client runtime. */
33
- export declare const VERSION = "1.8.0";
33
+ export declare const VERSION = "1.9.0";
package/dist/index.js CHANGED
@@ -18,4 +18,4 @@ export { default as Image } from './image/Image.svelte';
18
18
  export { t, i18n, setLocale } from './runtime/i18n.svelte.js';
19
19
  export { passkeysSupported, registerPasskey, signInWithPasskey, PasskeyError } from './runtime/passkeys.js';
20
20
  /** The version of the Tern client runtime. */
21
- export const VERSION = '1.8.0';
21
+ export const VERSION = '1.9.0';
@@ -16,6 +16,8 @@ export const builtin = {
16
16
  "tern.accounts.error.passkey": "That passkey didn’t work.",
17
17
  "tern.accounts.error.tooMany": "Too many attempts. Try again in a few minutes.",
18
18
  "tern.accounts.error.wrongPassword": "That isn’t your current password.",
19
+ "tern.accounts.new_device.subject": "A new sign-in to your account",
20
+ "tern.accounts.new_device.text": "Hello {name},\n\nYour account was just signed in to from a device it hadn’t been used on before: {device}, from {ip}, on {time}.\n\nIf this was you, there’s nothing to do. If it wasn’t, change your password now and sign that device out in your account’s security settings.",
19
21
  "tern.accounts.reset.button": "Choose a new password",
20
22
  "tern.accounts.reset.subject": "Reset your password",
21
23
  "tern.accounts.reset.text": "Hello {name},\n\nSomeone asked to reset your password. If it was you, choose a new one here within an hour:\n{link}\n\nIf it wasn’t you, ignore this email: your password stays as it is.",
@@ -46,8 +46,15 @@ export async function start(m) {
46
46
  catch {
47
47
  scrollPositions = {};
48
48
  }
49
- historyIndex = history.state?.ternIndex ?? 0;
50
- if (history.state?.ternIndex == null) {
49
+ // A page loaded into a new history entry (a link from elsewhere, the
50
+ // address bar) has no index yet. It takes its place in the tab's
51
+ // history, and starts at the top: a position saved for that place came
52
+ // from a page the browser has since left behind. A reload, or going
53
+ // back to the page, restores where the reader was.
54
+ const fresh = history.state?.ternIndex == null;
55
+ historyIndex = fresh ? history.length - 1 : history.state.ternIndex;
56
+ if (fresh) {
57
+ delete scrollPositions[historyIndex];
51
58
  history.replaceState({ ...history.state, ternIndex: historyIndex, ternState: {} }, '');
52
59
  }
53
60
  history.scrollRestoration = 'manual';
@@ -70,14 +77,19 @@ export async function start(m) {
70
77
  }
71
78
  started = true;
72
79
  await tick();
80
+ // tern dev puts the page's CSS in the HTML for the first paint; Vite's
81
+ // own style tags, which hot-reload, have replaced it by now.
82
+ if (import.meta.env?.DEV)
83
+ for (const el of document.querySelectorAll('style[data-tern-dev]'))
84
+ el.remove();
73
85
  runAfter({ from: null, to: current(), type: 'enter' });
74
86
  registerServiceWorker();
75
- const saved = scrollPositions[historyIndex];
87
+ const saved = fresh ? undefined : scrollPositions[historyIndex];
76
88
  if (saved) {
77
- scrollTo(saved.x, saved.y);
89
+ jump(saved.x, saved.y);
78
90
  }
79
91
  else if (url.hash) {
80
- scrollToHash(url.hash);
92
+ scrollToHash(url.hash, true);
81
93
  }
82
94
  listen();
83
95
  }
@@ -293,13 +305,13 @@ async function navigate(url, opts, type, redirects, popIndex, fromIndex) {
293
305
  await tick();
294
306
  if (type === 'popstate') {
295
307
  const pos = popIndex != null ? scrollPositions[popIndex] : undefined;
296
- scrollTo(pos?.x ?? 0, pos?.y ?? 0);
308
+ jump(pos?.x ?? 0, pos?.y ?? 0);
297
309
  }
298
310
  else if (!opts.noScroll) {
299
311
  if (url.hash)
300
- scrollToHash(url.hash);
312
+ scrollToHash(url.hash, true);
301
313
  else
302
- scrollTo(0, 0);
314
+ jump(0, 0);
303
315
  }
304
316
  if (!opts.keepFocus) {
305
317
  resetFocus();
@@ -393,13 +405,22 @@ function saveScroll() {
393
405
  // Storage can be full or disabled; scroll restoration is best effort.
394
406
  }
395
407
  }
396
- function scrollToHash(hash) {
408
+ /**
409
+ * Scrolls to a position at once. A page change is a jump, even on pages
410
+ * whose CSS asks for smooth scrolling: an animation from the old page's
411
+ * position would be cut short by the new page's layout, and stop halfway.
412
+ */
413
+ function jump(x, y) {
414
+ scrollTo({ left: x, top: y, behavior: 'instant' });
415
+ }
416
+ /** Scrolls to an anchor: smoothly within the page if the CSS says so, at once when arriving on a new page. */
417
+ function scrollToHash(hash, instant = false) {
397
418
  const id = decodeURIComponent(hash.slice(1));
398
419
  const el = document.getElementById(id) ?? document.getElementsByName(id)[0];
399
420
  if (el)
400
- el.scrollIntoView();
421
+ el.scrollIntoView(instant ? { behavior: 'instant' } : undefined);
401
422
  else
402
- scrollTo(0, 0);
423
+ jump(0, 0);
403
424
  }
404
425
  /** Moves focus to the document and announces the new page to screen readers. */
405
426
  function resetFocus() {
@@ -1,5 +1,5 @@
1
1
  import { type Options as SvelteOptions } from '@sveltejs/vite-plugin-svelte';
2
- import type { Plugin } from 'vite';
2
+ import { type Plugin } from 'vite';
3
3
  export interface TernOptions {
4
4
  /** Options passed to @sveltejs/vite-plugin-svelte. */
5
5
  svelte?: Partial<SvelteOptions>;
@@ -3,6 +3,7 @@ import fs from 'node:fs';
3
3
  import { createRequire } from 'node:module';
4
4
  import path from 'node:path';
5
5
  import { nativePlugin } from './native.js';
6
+ import { searchForWorkspaceRoot } from 'vite';
6
7
  import { goScriptPlugin, goScriptPreprocessor } from './goscript.js';
7
8
  import { imagePlugin } from './image.js';
8
9
  /** Packages whose browser build works in the server renderer and whose default build needs Node. */
@@ -17,6 +18,9 @@ const SERVER_OUT = '.tern/output/server';
17
18
  const RENDER_PATH = '/__tern/render';
18
19
  /** The dev endpoint the Go app calls to render an email. */
19
20
  const EMAIL_PATH = '/__tern/email';
21
+ // The CSS a page's components import, for tern dev to inline in the
22
+ // server-rendered page, so its first paint is styled.
23
+ const STYLES_PATH = '/__tern/dev/styles';
20
24
  /**
21
25
  * The Vite plugin for Tern apps. It includes the Svelte plugin, so it is
22
26
  * the only plugin a Tern app needs:
@@ -121,7 +125,10 @@ export function tern(options = {}) {
121
125
  strictPort: vitePort !== undefined,
122
126
  // The browser talks to the Go app, which proxies HMR to Vite.
123
127
  ws: appPort ? { clientPort: appPort } : undefined,
124
- fs: { allow: [root] }
128
+ // The app, and the workspace it is in: in a pnpm or npm
129
+ // workspace, dependencies (and their fonts and images) live in
130
+ // the workspace's node_modules, outside the app.
131
+ fs: { allow: [root, searchForWorkspaceRoot(root)] }
125
132
  },
126
133
  optimizeDeps: {
127
134
  entries: [CLIENT_ENTRY, 'src/**/*.svelte'],
@@ -157,6 +164,18 @@ export function tern(options = {}) {
157
164
  res.end(e.stack ?? e.message);
158
165
  });
159
166
  });
167
+ server.middlewares.use(STYLES_PATH, (req, res) => {
168
+ void stylesInDev(server, req.url ?? '').then((css) => {
169
+ res.setHeader('Content-Type', 'text/css; charset=utf-8');
170
+ res.setHeader('Cache-Control', 'no-store');
171
+ res.end(css);
172
+ }, (err) => {
173
+ const e = err instanceof Error ? err : new Error(String(err));
174
+ res.statusCode = 500;
175
+ res.setHeader('Content-Type', 'text/plain; charset=utf-8');
176
+ res.end(e.stack ?? e.message);
177
+ });
178
+ });
160
179
  server.middlewares.use(RENDER_PATH, (req, res) => {
161
180
  void renderInDev(server, req).then((body) => {
162
181
  res.setHeader('Content-Type', 'application/json');
@@ -181,6 +200,66 @@ export function tern(options = {}) {
181
200
  const mod = (await server.ssrLoadModule(path.join(root, SERVER_ENTRY)));
182
201
  return mod.render(payload, href);
183
202
  }
203
+ /**
204
+ * The CSS the given components import, directly or through other
205
+ * modules, as the browser will load it: stylesheets and Svelte
206
+ * components' styles, in import order. It walks the client's module
207
+ * graph, transforming modules the browser hasn't asked for yet, so it
208
+ * works on the first load too.
209
+ */
210
+ async function stylesInDev(server, url) {
211
+ const components = new URL(url, 'http://localhost').searchParams.getAll('c');
212
+ const client = server.environments.client;
213
+ const seen = new Set();
214
+ const out = [];
215
+ const failed = [];
216
+ const isCSS = (id) => /\.(css|scss|sass|less|styl|stylus|pcss|postcss|sss)(\?|$)/.test(id) || /[?&]type=style\b/.test(id);
217
+ // Vite knows a stylesheet by its URL ending in .css, so the flag
218
+ // goes first in the query: /x.svelte?direct&svelte&type=style&lang.css.
219
+ const withQuery = (u, q) => {
220
+ const i = u.indexOf('?');
221
+ return i < 0 ? u + '?' + q : u.slice(0, i + 1) + q + '&' + u.slice(i + 1);
222
+ };
223
+ const visit = async (mod) => {
224
+ // The ?direct forms this function requests join the graph too:
225
+ // skip them, or each would be read again.
226
+ if (seen.has(mod.url) || seen.size > 5000 || /[?&](direct|inline)\b/.test(mod.url))
227
+ return;
228
+ seen.add(mod.url);
229
+ try {
230
+ if (isCSS(mod.url)) {
231
+ const res = await client.transformRequest(withQuery(mod.url, 'direct'));
232
+ if (res?.code)
233
+ out.push(res.code);
234
+ return;
235
+ }
236
+ // Dependencies' JavaScript has no stylesheets of the app's.
237
+ if (mod.url.includes('/node_modules/'))
238
+ return;
239
+ if (!mod.transformResult)
240
+ await client.transformRequest(mod.url);
241
+ }
242
+ catch (err) {
243
+ // The browser will report it; the page is styled when it loads.
244
+ failed.push(mod.url + ': ' + (err instanceof Error ? err.message.split('\n')[0] : String(err)));
245
+ return;
246
+ }
247
+ // A copy: transforming adds to the graph while it is walked.
248
+ for (const dep of [...mod.importedModules])
249
+ await visit(dep);
250
+ };
251
+ for (const c of components) {
252
+ const file = path.join(root, c);
253
+ if (!file.startsWith(root + path.sep) || !fs.existsSync(file))
254
+ continue;
255
+ await client.transformRequest('/' + c.split(path.sep).join('/'));
256
+ for (const mod of client.moduleGraph.getModulesByFile(file) ?? [])
257
+ await visit(mod);
258
+ }
259
+ if (failed.length && process.env.TERN_DEBUG_STYLES)
260
+ console.warn('tern: styles not collected:\n' + failed.join('\n'));
261
+ return out.join('\n');
262
+ }
184
263
  async function emailInDev(server, req) {
185
264
  if (req.method !== 'POST')
186
265
  throw new Error('POST required');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ternkit/tern",
3
- "version": "1.8.0",
3
+ "version": "1.9.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",