@ternkit/tern 1.5.0 → 1.6.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.
Files changed (67) hide show
  1. package/dist/index.d.ts +2 -1
  2. package/dist/index.js +2 -1
  3. package/dist/runtime/builtin.d.ts +1 -0
  4. package/dist/runtime/builtin.js +140 -0
  5. package/dist/runtime/deferred.d.ts +1 -0
  6. package/dist/runtime/deferred.js +4 -2
  7. package/dist/runtime/i18n.svelte.d.ts +6 -0
  8. package/dist/runtime/i18n.svelte.js +10 -0
  9. package/dist/runtime/passkeys.d.ts +21 -0
  10. package/dist/runtime/passkeys.js +107 -0
  11. package/dist/runtime/router.svelte.js +2 -1
  12. package/dist/runtime/types.d.ts +2 -0
  13. package/dist/runtime/validate.d.ts +2 -1
  14. package/dist/runtime/validate.js +20 -18
  15. package/dist/server/index.d.ts +3 -1
  16. package/dist/server/index.js +8 -4
  17. package/dist/ui/AccountSection.svelte +75 -0
  18. package/dist/ui/AccountSection.svelte.d.ts +12 -0
  19. package/dist/ui/AlertDialog.svelte +5 -2
  20. package/dist/ui/AuthCard.svelte +64 -0
  21. package/dist/ui/AuthCard.svelte.d.ts +12 -0
  22. package/dist/ui/BarChart.svelte +2 -1
  23. package/dist/ui/Breadcrumb.svelte +2 -1
  24. package/dist/ui/Calendar.svelte +3 -2
  25. package/dist/ui/Carousel.svelte +4 -3
  26. package/dist/ui/ChartFrame.svelte +2 -1
  27. package/dist/ui/Combobox.svelte +6 -3
  28. package/dist/ui/Command.svelte +5 -1
  29. package/dist/ui/ConsentScreen.svelte +7 -6
  30. package/dist/ui/DataTable.svelte +7 -5
  31. package/dist/ui/DatePicker.svelte +8 -5
  32. package/dist/ui/Devices.svelte +50 -0
  33. package/dist/ui/Devices.svelte.d.ts +23 -0
  34. package/dist/ui/Dialog.svelte +2 -1
  35. package/dist/ui/DonutChart.svelte +3 -1
  36. package/dist/ui/Field.svelte +1 -1
  37. package/dist/ui/ForgotPassword.svelte +43 -0
  38. package/dist/ui/ForgotPassword.svelte.d.ts +16 -0
  39. package/dist/ui/Form.svelte +8 -3
  40. package/dist/ui/NavigationMenu.svelte +3 -1
  41. package/dist/ui/Pagination.svelte +5 -3
  42. package/dist/ui/Passkeys.svelte +73 -0
  43. package/dist/ui/Passkeys.svelte.d.ts +21 -0
  44. package/dist/ui/PasswordInput.svelte +2 -1
  45. package/dist/ui/ResetPassword.svelte +54 -0
  46. package/dist/ui/ResetPassword.svelte.d.ts +23 -0
  47. package/dist/ui/Resizable.svelte +3 -1
  48. package/dist/ui/SelectMenu.svelte +5 -2
  49. package/dist/ui/Sidebar.svelte +5 -3
  50. package/dist/ui/SignIn.svelte +48 -12
  51. package/dist/ui/SignIn.svelte.d.ts +4 -0
  52. package/dist/ui/SignUp.svelte +10 -8
  53. package/dist/ui/Slider.svelte +2 -1
  54. package/dist/ui/Spinner.svelte +3 -1
  55. package/dist/ui/Switch.svelte +2 -1
  56. package/dist/ui/Table.svelte +3 -1
  57. package/dist/ui/Toaster.svelte +3 -2
  58. package/dist/ui/Toggle.svelte +2 -0
  59. package/dist/ui/ToggleGroup.svelte +2 -0
  60. package/dist/ui/accountDates.d.ts +1 -0
  61. package/dist/ui/accountDates.js +15 -0
  62. package/dist/ui/announce.d.ts +4 -0
  63. package/dist/ui/announce.js +14 -0
  64. package/dist/ui/index.d.ts +4 -0
  65. package/dist/ui/index.js +4 -0
  66. package/dist/vite/native.js +21 -4
  67. package/package.json +1 -1
package/dist/index.d.ts CHANGED
@@ -21,6 +21,7 @@ export type { ConnectionStatus, StreamOptions, SocketOptions } from './runtime/r
21
21
  export { default as Image } from './image/Image.svelte';
22
22
  export type { Picture } from './image/types.js';
23
23
  export { t, i18n, setLocale } from './runtime/i18n.svelte.js';
24
+ export { passkeysSupported, registerPasskey, signInWithPasskey, PasskeyError, type PasskeyOptions } from './runtime/passkeys.js';
24
25
  export type { MessageKey } from './runtime/i18n.svelte.js';
25
26
  /**
26
27
  * The app's message keys. `tern gen` fills this in from src/locales, so
@@ -29,4 +30,4 @@ export type { MessageKey } from './runtime/i18n.svelte.js';
29
30
  export interface Messages {
30
31
  }
31
32
  /** The version of the Tern client runtime. */
32
- export declare const VERSION = "1.5.0";
33
+ export declare const VERSION = "1.6.0";
package/dist/index.js CHANGED
@@ -16,5 +16,6 @@ export { ApiError, createApi } from './runtime/api.js';
16
16
  export { stream, socket, EventStreamState, SocketState } from './runtime/realtime.svelte.js';
17
17
  export { default as Image } from './image/Image.svelte';
18
18
  export { t, i18n, setLocale } from './runtime/i18n.svelte.js';
19
+ export { passkeysSupported, registerPasskey, signInWithPasskey, PasskeyError } from './runtime/passkeys.js';
19
20
  /** The version of the Tern client runtime. */
20
- export const VERSION = '1.5.0';
21
+ export const VERSION = '1.6.0';
@@ -0,0 +1 @@
1
+ export declare const builtin: Record<string, string>;
@@ -0,0 +1,140 @@
1
+ // Tern's own texts, in English: Tern UI's and the validation messages.
2
+ // An app translates them in src/locales like its own messages. Generated
3
+ // from internal/i18n/builtin.json; a Go test keeps the two the same.
4
+ export const builtin = {
5
+ "tern.accounts.error.credentials": "Wrong email or password.",
6
+ "tern.accounts.error.emailTaken": "An account with this email address already exists.",
7
+ "tern.accounts.error.invalidToken": "This link is invalid or has expired.",
8
+ "tern.accounts.error.passkey": "That passkey didn’t work.",
9
+ "tern.accounts.error.wrongPassword": "That isn’t your current password.",
10
+ "tern.accounts.reset.button": "Choose a new password",
11
+ "tern.accounts.reset.subject": "Reset your password",
12
+ "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.",
13
+ "tern.accounts.verify.button": "Confirm my email address",
14
+ "tern.accounts.verify.subject": "Confirm your email address",
15
+ "tern.accounts.verify.text": "Hello {name},\n\nConfirm your email address by opening this link:\n{link}\n\nIf you didn’t create an account, you can ignore this email.",
16
+ "tern.ui.alertDialog.cancel": "Cancel",
17
+ "tern.ui.alertDialog.confirm": "Continue",
18
+ "tern.ui.barChart.total": "Total",
19
+ "tern.ui.breadcrumb.label": "Breadcrumb",
20
+ "tern.ui.calendar.nextMonth": "Next month",
21
+ "tern.ui.calendar.previousMonth": "Previous month",
22
+ "tern.ui.carousel.label": "Slides",
23
+ "tern.ui.carousel.next": "Next slide",
24
+ "tern.ui.carousel.previous": "Previous slide",
25
+ "tern.ui.chart.legend": "Legend",
26
+ "tern.ui.combobox.empty": "No results",
27
+ "tern.ui.combobox.searching": "Searching…",
28
+ "tern.ui.command.empty": "No results",
29
+ "tern.ui.command.label": "Commands",
30
+ "tern.ui.command.placeholder": "Type a command or search…",
31
+ "tern.ui.consent.allow": "Allow",
32
+ "tern.ui.consent.deny": "Deny",
33
+ "tern.ui.consent.notYou": "Not you?",
34
+ "tern.ui.consent.note": "You can take this back at any time in your account settings.",
35
+ "tern.ui.consent.scopes": "It will be able to:",
36
+ "tern.ui.consent.title": "{client} wants to access your account",
37
+ "tern.ui.dataTable.actions": "Actions",
38
+ "tern.ui.dataTable.empty": "No rows match",
39
+ "tern.ui.dataTable.search": "Search…",
40
+ "tern.ui.dataTable.searchLabel": "Search {caption}",
41
+ "tern.ui.dataTable.selectAll": "Select all rows on this page",
42
+ "tern.ui.datePicker.label": "Choose a date",
43
+ "tern.ui.datePicker.placeholder": "Pick a date",
44
+ "tern.ui.datePicker.placeholderRange": "Pick dates",
45
+ "tern.ui.devices.current": "This device",
46
+ "tern.ui.devices.description": "Where your account is signed in. Sign out any device you don’t recognize.",
47
+ "tern.ui.devices.lastSeen": "Last active {when}",
48
+ "tern.ui.devices.signOut": "Sign out",
49
+ "tern.ui.devices.signOutOthers": "Sign out all other devices",
50
+ "tern.ui.devices.title": "Signed-in devices",
51
+ "tern.ui.dialog.close": "Close",
52
+ "tern.ui.donutChart.total": "total",
53
+ "tern.ui.forgotPassword.back": "Back to sign in",
54
+ "tern.ui.forgotPassword.description": "Enter your account’s email address and we’ll send you a link to choose a new password.",
55
+ "tern.ui.forgotPassword.email": "Email",
56
+ "tern.ui.forgotPassword.sent": "If an account uses that address, a link to reset its password is on its way. Check your inbox.",
57
+ "tern.ui.forgotPassword.submit": "Send link",
58
+ "tern.ui.forgotPassword.title": "Reset your password",
59
+ "tern.ui.form.summary": "{n, plural, one {# field needs attention.} other {# fields need attention.}}",
60
+ "tern.ui.navigationMenu.label": "Main",
61
+ "tern.ui.pagination.label": "Pagination",
62
+ "tern.ui.pagination.next": "Next ›",
63
+ "tern.ui.pagination.nextLabel": "Next page",
64
+ "tern.ui.pagination.previous": "‹ Previous",
65
+ "tern.ui.pagination.previousLabel": "Previous page",
66
+ "tern.ui.passkeys.add": "Add a passkey",
67
+ "tern.ui.passkeys.added": "Added {when}",
68
+ "tern.ui.passkeys.description": "Sign in with your fingerprint, face or screen lock instead of a password.",
69
+ "tern.ui.passkeys.empty": "No passkeys yet.",
70
+ "tern.ui.passkeys.failed": "The passkey wasn’t added. Try again.",
71
+ "tern.ui.passkeys.lastUsed": "last used {when}",
72
+ "tern.ui.passkeys.never": "never used",
73
+ "tern.ui.passkeys.remove": "Remove",
74
+ "tern.ui.passkeys.title": "Passkeys",
75
+ "tern.ui.passkeys.unsupported": "This browser can’t use passkeys.",
76
+ "tern.ui.password.show": "Show password",
77
+ "tern.ui.resetPassword.again": "Ask for a new link",
78
+ "tern.ui.resetPassword.confirm": "Confirm new password",
79
+ "tern.ui.resetPassword.invalid": "This link is invalid or has expired.",
80
+ "tern.ui.resetPassword.password": "New password",
81
+ "tern.ui.resetPassword.submit": "Save password",
82
+ "tern.ui.resetPassword.title": "Choose a new password",
83
+ "tern.ui.resizable.label": "Resize",
84
+ "tern.ui.selectMenu.placeholder": "Choose…",
85
+ "tern.ui.sidebar.label": "Main",
86
+ "tern.ui.sidebar.open": "Open navigation",
87
+ "tern.ui.sidebar.title": "Navigation",
88
+ "tern.ui.signIn.cancelled": "You cancelled the sign-in. Try again when you’re ready.",
89
+ "tern.ui.signIn.continueWith": "Continue with {provider}",
90
+ "tern.ui.signIn.email": "Email",
91
+ "tern.ui.signIn.failed": "Signing in didn’t work. Try again, or choose another way.",
92
+ "tern.ui.signIn.forgot": "Forgot your password?",
93
+ "tern.ui.signIn.new": "New here?",
94
+ "tern.ui.signIn.or": "or",
95
+ "tern.ui.signIn.passkey": "Sign in with a passkey",
96
+ "tern.ui.signIn.passkeyFailed": "That passkey didn’t work. Try again, or sign in another way.",
97
+ "tern.ui.signIn.password": "Password",
98
+ "tern.ui.signIn.remember": "Keep me signed in",
99
+ "tern.ui.signIn.signUp": "Create an account",
100
+ "tern.ui.signIn.submit": "Sign in",
101
+ "tern.ui.signIn.title": "Sign in",
102
+ "tern.ui.signUp.agree": "I agree to the",
103
+ "tern.ui.signUp.confirm": "Confirm password",
104
+ "tern.ui.signUp.email": "Email",
105
+ "tern.ui.signUp.haveAccount": "Already have an account?",
106
+ "tern.ui.signUp.minPassword": "At least {n} characters.",
107
+ "tern.ui.signUp.name": "Name",
108
+ "tern.ui.signUp.password": "Password",
109
+ "tern.ui.signUp.signIn": "Sign in",
110
+ "tern.ui.signUp.submit": "Create account",
111
+ "tern.ui.signUp.terms": "terms",
112
+ "tern.ui.signUp.title": "Create an account",
113
+ "tern.ui.spinner.label": "Loading",
114
+ "tern.ui.table.empty": "Nothing to show",
115
+ "tern.ui.toaster.dismiss": "Dismiss",
116
+ "tern.ui.toaster.label": "Notifications",
117
+ "tern.validate.after": "{field} must be on or after {date}.",
118
+ "tern.validate.before": "{field} must be on or before {date}.",
119
+ "tern.validate.checked": "{field} must be checked.",
120
+ "tern.validate.date": "{field} must be a date.",
121
+ "tern.validate.datetime": "{field} must be a date and time.",
122
+ "tern.validate.email": "{field} must be an email address.",
123
+ "tern.validate.exactly": "{field} must be exactly {count}.",
124
+ "tern.validate.int": "{field} must be a whole number.",
125
+ "tern.validate.items": "Choose exactly {count} for {fieldLower}.",
126
+ "tern.validate.length": "{field} must be exactly {count} {n, plural, one {character} other {characters}}.",
127
+ "tern.validate.max": "{field} must be at most {count}.",
128
+ "tern.validate.maxItems": "Choose at most {count} for {fieldLower}.",
129
+ "tern.validate.maxLength": "{field} must be at most {count} {n, plural, one {character} other {characters}}.",
130
+ "tern.validate.min": "{field} must be at least {count}.",
131
+ "tern.validate.minItems": "Choose at least {count} for {fieldLower}.",
132
+ "tern.validate.minLength": "{field} must be at least {count} {n, plural, one {character} other {characters}}.",
133
+ "tern.validate.number": "{field} must be a number.",
134
+ "tern.validate.oneof": "{field} must be one of {options}.",
135
+ "tern.validate.or": "or",
136
+ "tern.validate.pattern": "{field} isn't in the expected format.",
137
+ "tern.validate.required": "{field} is required.",
138
+ "tern.validate.same": "{field} must match {otherLower}.",
139
+ "tern.validate.url": "{field} must be a web address starting with http:// or https://."
140
+ };
@@ -30,4 +30,5 @@ export declare function revive<T>(value: T, promise: (id: number) => Promise<unk
30
30
  */
31
31
  export declare function readStreamed<P extends {
32
32
  nodes?: unknown;
33
+ deferred?: boolean;
33
34
  }>(res: Response): Promise<P>;
@@ -76,7 +76,8 @@ export async function readStreamed(res) {
76
76
  const [first, ...rest] = (await res.text()).split('\n');
77
77
  const scope = new DeferredScope();
78
78
  const payload = JSON.parse(first);
79
- payload.nodes = revive(payload.nodes, (id) => scope.promise(id));
79
+ if (payload.deferred)
80
+ payload.nodes = revive(payload.nodes, (id) => scope.promise(id));
80
81
  for (const line of rest)
81
82
  if (line.trim())
82
83
  scope.settle(JSON.parse(line));
@@ -98,7 +99,8 @@ export async function readStreamed(res) {
98
99
  buffer = newline < 0 ? '' : buffer.slice(newline + 1);
99
100
  const scope = new DeferredScope();
100
101
  const payload = JSON.parse(first);
101
- payload.nodes = revive(payload.nodes, (id) => scope.promise(id));
102
+ if (payload.deferred)
103
+ payload.nodes = revive(payload.nodes, (id) => scope.promise(id));
102
104
  void (async () => {
103
105
  try {
104
106
  for (;;) {
@@ -32,6 +32,12 @@ export declare function useLocaleSync(locale: string | undefined): void;
32
32
  * then to the key.
33
33
  */
34
34
  export declare function t(key: MessageKey, params?: Record<string, unknown>): string;
35
+ /**
36
+ * Translates one of Tern's own texts, such as Tern UI's labels and the
37
+ * validation messages: the app's message for key in src/locales if it has
38
+ * one, else Tern's English. Their keys start with "tern.".
39
+ */
40
+ export declare function tb(key: string, params?: Record<string, unknown>): string;
35
41
  /** The current locale and the ones the app supports. */
36
42
  export declare const i18n: {
37
43
  /** The current locale, such as "de", or "" if the app has no src/locales. */
@@ -1,3 +1,4 @@
1
+ import { builtin } from './builtin.js';
1
2
  import { format } from './format.js';
2
3
  class I18nState {
3
4
  locale = $state('');
@@ -78,6 +79,15 @@ export function t(key, params) {
78
79
  }
79
80
  return format(state.locale || config?.defaultLocale || 'en', message, params);
80
81
  }
82
+ /**
83
+ * Translates one of Tern's own texts, such as Tern UI's labels and the
84
+ * validation messages: the app's message for key in src/locales if it has
85
+ * one, else Tern's English. Their keys start with "tern.".
86
+ */
87
+ export function tb(key, params) {
88
+ const message = state.messages[key] ?? state.fallback[key] ?? builtin[key] ?? key;
89
+ return format(state.locale || config?.defaultLocale || 'en', message, params);
90
+ }
81
91
  /** The current locale and the ones the app supports. */
82
92
  export const i18n = {
83
93
  /** The current locale, such as "de", or "" if the app has no src/locales. */
@@ -0,0 +1,21 @@
1
+ export type PasskeyOptions = {
2
+ /** Where the accounts package mounts its passkey endpoints (Options.PasskeyPath). */
3
+ path?: string;
4
+ };
5
+ /** A passkey ceremony that didn't finish. cancelled is true when the user closed the browser's prompt. */
6
+ export declare class PasskeyError extends Error {
7
+ cancelled: boolean;
8
+ constructor(message: string, cancelled?: boolean);
9
+ }
10
+ /** Whether this browser can use passkeys. */
11
+ export declare function passkeysSupported(): boolean;
12
+ export declare function toBase64url(buf: ArrayBuffer | Uint8Array): string;
13
+ export declare function fromBase64url(s: string): Uint8Array<ArrayBuffer>;
14
+ /** Adds a passkey to the signed-in user's account. name labels it in their list; the default names the browser and system. */
15
+ export declare function registerPasskey(options?: PasskeyOptions & {
16
+ name?: string;
17
+ }): Promise<void>;
18
+ /** Signs in with a passkey. The browser offers the user's passkeys for this site; no email is needed. */
19
+ export declare function signInWithPasskey(options?: PasskeyOptions & {
20
+ remember?: boolean;
21
+ }): Promise<void>;
@@ -0,0 +1,107 @@
1
+ // Passkeys in the browser, for the accounts package's endpoints. Each
2
+ // helper asks the server for options, hands them to the browser's
3
+ // WebAuthn API, and posts the authenticator's answer back.
4
+ import { getBase } from './base.js';
5
+ /** A passkey ceremony that didn't finish. cancelled is true when the user closed the browser's prompt. */
6
+ export class PasskeyError extends Error {
7
+ cancelled;
8
+ constructor(message, cancelled = false) {
9
+ super(message);
10
+ this.name = 'PasskeyError';
11
+ this.cancelled = cancelled;
12
+ }
13
+ }
14
+ /** Whether this browser can use passkeys. */
15
+ export function passkeysSupported() {
16
+ return typeof window !== 'undefined' && typeof window.PublicKeyCredential === 'function' && !!navigator.credentials;
17
+ }
18
+ export function toBase64url(buf) {
19
+ const bytes = buf instanceof Uint8Array ? buf : new Uint8Array(buf);
20
+ let s = '';
21
+ for (const b of bytes)
22
+ s += String.fromCharCode(b);
23
+ return btoa(s).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
24
+ }
25
+ export function fromBase64url(s) {
26
+ const b = atob(s.replace(/-/g, '+').replace(/_/g, '/') + '='.repeat((4 - (s.length % 4)) % 4));
27
+ const out = new Uint8Array(new ArrayBuffer(b.length));
28
+ for (let i = 0; i < b.length; i++)
29
+ out[i] = b.charCodeAt(i);
30
+ return out;
31
+ }
32
+ const endpoint = (o, p) => getBase() + (o?.path ?? '/_tern/passkeys').replace(/\/$/, '') + p;
33
+ async function post(url, body) {
34
+ const res = await fetch(url, {
35
+ method: 'POST',
36
+ headers: { 'content-type': 'application/json', accept: 'application/json' },
37
+ credentials: 'same-origin',
38
+ body: JSON.stringify(body ?? {})
39
+ });
40
+ const data = (await res.json().catch(() => ({})));
41
+ if (!res.ok)
42
+ throw new PasskeyError(typeof data.message === 'string' ? data.message : `passkey request failed (${res.status})`);
43
+ return data;
44
+ }
45
+ function failed(e) {
46
+ if (e instanceof PasskeyError)
47
+ throw e;
48
+ const name = e?.name;
49
+ // NotAllowedError is the browser's answer for both "cancelled" and "timed out".
50
+ if (name === 'NotAllowedError' || name === 'AbortError')
51
+ throw new PasskeyError('cancelled', true);
52
+ throw new PasskeyError(e?.message ?? String(e));
53
+ }
54
+ /** Adds a passkey to the signed-in user's account. name labels it in their list; the default names the browser and system. */
55
+ export async function registerPasskey(options) {
56
+ try {
57
+ const o = (await post(endpoint(options, '/register/options')));
58
+ const publicKey = {
59
+ ...o,
60
+ challenge: fromBase64url(o.challenge),
61
+ user: { ...o.user, id: fromBase64url(o.user.id) },
62
+ excludeCredentials: (o.excludeCredentials ?? []).map((c) => ({ type: c.type, id: fromBase64url(c.id) }))
63
+ };
64
+ const cred = (await navigator.credentials.create({ publicKey }));
65
+ if (!cred)
66
+ throw new PasskeyError('cancelled', true);
67
+ const r = cred.response;
68
+ await post(endpoint(options, '/register'), {
69
+ id: cred.id,
70
+ type: cred.type,
71
+ name: options?.name ?? '',
72
+ response: { clientDataJSON: toBase64url(r.clientDataJSON), attestationObject: toBase64url(r.attestationObject) }
73
+ });
74
+ }
75
+ catch (e) {
76
+ failed(e);
77
+ }
78
+ }
79
+ /** Signs in with a passkey. The browser offers the user's passkeys for this site; no email is needed. */
80
+ export async function signInWithPasskey(options) {
81
+ try {
82
+ const o = (await post(endpoint(options, '/signin/options')));
83
+ const publicKey = {
84
+ ...o,
85
+ challenge: fromBase64url(o.challenge),
86
+ allowCredentials: []
87
+ };
88
+ const cred = (await navigator.credentials.get({ publicKey }));
89
+ if (!cred)
90
+ throw new PasskeyError('cancelled', true);
91
+ const r = cred.response;
92
+ await post(endpoint(options, '/signin'), {
93
+ id: cred.id,
94
+ type: cred.type,
95
+ remember: !!options?.remember,
96
+ response: {
97
+ clientDataJSON: toBase64url(r.clientDataJSON),
98
+ authenticatorData: toBase64url(r.authenticatorData),
99
+ signature: toBase64url(r.signature),
100
+ userHandle: r.userHandle ? toBase64url(r.userHandle) : ''
101
+ }
102
+ });
103
+ }
104
+ catch (e) {
105
+ failed(e);
106
+ }
107
+ }
@@ -56,7 +56,8 @@ export async function start(m) {
56
56
  // Deferred values stream in as inline scripts calling __tern_resolve.
57
57
  const w = window;
58
58
  const scope = new DeferredScope();
59
- payload.nodes = revive(payload.nodes, (id) => scope.promise(id));
59
+ if (payload.deferred)
60
+ payload.nodes = revive(payload.nodes, (id) => scope.promise(id));
60
61
  for (const chunk of Object.values(w.__tern_d ?? {}))
61
62
  scope.settle(chunk);
62
63
  w.__tern_on = (chunk) => scope.settle(chunk);
@@ -49,6 +49,8 @@ export interface Payload {
49
49
  data?: unknown;
50
50
  form?: unknown;
51
51
  hasForm?: boolean;
52
+ /** Whether the nodes have deferred values' placeholders to revive. */
53
+ deferred?: boolean;
52
54
  /** What each node's load read, so the client knows when to rerun it. */
53
55
  uses?: (NodeUses | null)[];
54
56
  /** Nodes the server skipped because the client already has their data. */
@@ -17,7 +17,8 @@ export type FormErrors<Name extends string = string> = Partial<Record<Name, stri
17
17
  export declare function parseFormTime(kind: 'date' | 'datetime', s: string): number | null;
18
18
  /**
19
19
  * Returns why a field's values are invalid, or "". values are its posted
20
- * values; other reads another field's first value, for same=.
20
+ * values; other reads another field's first value, for same=. Messages
21
+ * are Tern's texts (tern.validate.*), translated through src/locales.
21
22
  */
22
23
  export declare function checkField(f: FormField, values: string[], other?: (name: string) => string, labelOf?: (name: string) => string): string;
23
24
  /**
@@ -2,6 +2,7 @@
2
2
  // kit.FormAction's validate tags. The checks and messages match
3
3
  // internal/formrules in Go, which checks the same rules on the server;
4
4
  // internal/formrules/testdata/cases.json keeps the two in step.
5
+ import { tb } from './i18n.svelte.js';
5
6
  const emailRe = /^[^\s@<>()",;:]+@[^\s@<>()",;:]+\.[^\s@<>()",;:]+$/;
6
7
  const intRe = /^[+-]?\d+$/;
7
8
  const floatRe = /^[+-]?(\d+\.?\d*|\.\d+)$/;
@@ -33,45 +34,46 @@ export function parseFormTime(kind, s) {
33
34
  }
34
35
  return null;
35
36
  }
36
- function joinOr(list) {
37
- return list.length === 1 ? list[0] : `${list.slice(0, -1).join(', ')} or ${list[list.length - 1]}`;
37
+ function joinOr(list, or) {
38
+ return list.length === 1 ? list[0] : `${list.slice(0, -1).join(', ')} ${or} ${list[list.length - 1]}`;
38
39
  }
39
- const bound = { min: 'at least', max: 'at most', len: 'exactly' };
40
40
  /**
41
41
  * Returns why a field's values are invalid, or "". values are its posted
42
- * values; other reads another field's first value, for same=.
42
+ * values; other reads another field's first value, for same=. Messages
43
+ * are Tern's texts (tern.validate.*), translated through src/locales.
43
44
  */
44
45
  export function checkField(f, values, other = () => '', labelOf = (n) => n) {
45
- const msg = (m) => f.message || m;
46
+ const msg = (key, params = {}) => f.message || tb(`tern.validate.${key}`, { ...params, field: f.label, fieldLower: f.label.toLowerCase() });
46
47
  const list = values.filter((v) => v.trim() !== '');
47
48
  const v = values[0] ?? '';
48
49
  const empty = f.kind === 'strings' ? list.length === 0 : f.kind === 'bool' ? !truthy(v) : v.trim() === '';
49
50
  if (empty) {
50
51
  if (!f.required)
51
52
  return '';
52
- return msg(f.kind === 'bool' ? `${f.label} must be checked.` : `${f.label} is required.`);
53
+ return msg(f.kind === 'bool' ? 'checked' : 'required');
53
54
  }
54
55
  let num = 0;
55
56
  let when = 0;
56
57
  if (f.kind === 'int') {
57
58
  if (!intRe.test(v.trim()))
58
- return msg(`${f.label} must be a whole number.`);
59
+ return msg('int');
59
60
  num = Number(v.trim());
60
61
  }
61
62
  else if (f.kind === 'float') {
62
63
  if (!floatRe.test(v.trim()))
63
- return msg(`${f.label} must be a number.`);
64
+ return msg('number');
64
65
  num = Number(v.trim());
65
66
  }
66
67
  else if (f.kind === 'date' || f.kind === 'datetime') {
67
68
  const t = parseFormTime(f.kind, v.trim());
68
69
  if (t === null)
69
- return msg(f.kind === 'date' ? `${f.label} must be a date.` : `${f.label} must be a date and time.`);
70
+ return msg(f.kind === 'date' ? 'date' : 'datetime');
70
71
  when = t;
71
72
  }
72
73
  for (const r of f.rules ?? []) {
73
74
  const arg = r.arg ?? '';
74
75
  const n = Number(arg);
76
+ const bound = { count: arg, n };
75
77
  switch (r.name) {
76
78
  case 'min':
77
79
  case 'max':
@@ -80,26 +82,26 @@ export function checkField(f, values, other = () => '', labelOf = (n) => n) {
80
82
  const fails = (c) => (r.name === 'len' && c !== n) || (low && c < n) || (r.name === 'max' && c > n);
81
83
  if (f.kind === 'string') {
82
84
  if (fails([...v].length))
83
- return msg(`${f.label} must be ${bound[r.name]} ${arg} ${n === 1 ? 'character' : 'characters'}.`);
85
+ return msg({ min: 'minLength', max: 'maxLength', len: 'length' }[r.name], bound);
84
86
  }
85
87
  else if (f.kind === 'int' || f.kind === 'float') {
86
88
  if (fails(num))
87
- return msg(`${f.label} must be ${bound[r.name]} ${arg}.`);
89
+ return msg({ min: 'min', max: 'max', len: 'exactly' }[r.name], bound);
88
90
  }
89
91
  else if (f.kind === 'date' || f.kind === 'datetime') {
90
92
  const b = parseFormTime(f.kind, arg) ?? 0;
91
93
  if ((low && when < b) || (r.name === 'max' && when > b))
92
- return msg(`${f.label} must be on or ${low ? 'after' : 'before'} ${arg}.`);
94
+ return msg(low ? 'after' : 'before', { date: arg });
93
95
  }
94
96
  else if (f.kind === 'strings') {
95
97
  if (fails(list.length))
96
- return msg(`Choose ${bound[r.name]} ${arg} for ${f.label.toLowerCase()}.`);
98
+ return msg({ min: 'minItems', max: 'maxItems', len: 'items' }[r.name], bound);
97
99
  }
98
100
  break;
99
101
  }
100
102
  case 'email':
101
103
  if (!emailRe.test(v))
102
- return msg(`${f.label} must be an email address.`);
104
+ return msg('email');
103
105
  break;
104
106
  case 'url': {
105
107
  let ok = false;
@@ -111,7 +113,7 @@ export function checkField(f, values, other = () => '', labelOf = (n) => n) {
111
113
  ok = false;
112
114
  }
113
115
  if (!ok)
114
- return msg(`${f.label} must be a web address starting with http:// or https://.`);
116
+ return msg('url');
115
117
  break;
116
118
  }
117
119
  case 'pattern': {
@@ -120,19 +122,19 @@ export function checkField(f, values, other = () => '', labelOf = (n) => n) {
120
122
  patterns.set(arg, (re = new RegExp(`^(?:${arg})$`, 'u')));
121
123
  for (const s of f.kind === 'strings' ? list : [v])
122
124
  if (!re.test(s))
123
- return msg(`${f.label} isn't in the expected format.`);
125
+ return msg('pattern');
124
126
  break;
125
127
  }
126
128
  case 'oneof': {
127
129
  const opts = arg.split('|');
128
130
  for (const s of f.kind === 'strings' ? list : [v])
129
131
  if (!opts.includes(s))
130
- return msg(`${f.label} must be one of ${joinOr(opts)}.`);
132
+ return msg('oneof', { options: joinOr(opts, tb('tern.validate.or')) });
131
133
  break;
132
134
  }
133
135
  case 'same':
134
136
  if (other(arg) !== v)
135
- return msg(`${f.label} must match ${labelOf(arg).toLowerCase()}.`);
137
+ return msg('same', { otherLower: labelOf(arg).toLowerCase() });
136
138
  break;
137
139
  }
138
140
  }
@@ -35,6 +35,8 @@ export interface RenderResult {
35
35
  * same payload the client receives, so the server and the client render
36
36
  * exactly the same tree and hydration matches.
37
37
  */
38
- export declare function createRenderer(manifest: ServerManifest): (payload: string, href: string) => string;
38
+ export declare function createRenderer(manifest: ServerManifest): ((payload: string, href: string) => string) & {
39
+ raw: (payload: string, href: string) => RenderResult;
40
+ };
39
41
  export { createEmailRenderer } from './email.js';
40
42
  export type { EmailModule, EmailResult } from './email.js';
@@ -16,15 +16,18 @@ export function createRenderer(manifest) {
16
16
  const layout = (i) => manifest.layouts[i]?.component ?? Passthrough;
17
17
  configureI18n(manifest.i18n ?? null);
18
18
  setBase(manifest.base ?? '');
19
- return (payloadJSON, href) => {
19
+ // raw returns the result as an object, for a native renderer to read
20
+ // without encoding it as JSON first.
21
+ const raw = (payloadJSON, href) => {
20
22
  const p = JSON.parse(payloadJSON);
21
23
  // Deferred values are still loading: render their pending state.
22
- p.nodes = revive(p.nodes, () => new Promise(() => { }));
24
+ if (p.deferred)
25
+ p.nodes = revive(p.nodes, () => new Promise(() => { }));
23
26
  const what = plan(manifest, p);
24
27
  let components;
25
28
  switch (what.kind) {
26
29
  case 'not-a-page':
27
- return JSON.stringify({ skip: true });
30
+ return { skip: true };
28
31
  case 'page':
29
32
  components = [...what.layouts.map(layout), manifest.routes[what.route].page];
30
33
  break;
@@ -40,7 +43,8 @@ export function createRenderer(manifest) {
40
43
  applyPayloadToPage(p, new URL(href), {}, what.data);
41
44
  const form = p.hasForm ? (p.form ?? null) : null;
42
45
  const out = render(Root, { props: { components, data: what.data, form } });
43
- return JSON.stringify({ head: out.head, body: out.body });
46
+ return { head: out.head, body: out.body };
44
47
  };
48
+ return Object.assign((payloadJSON, href) => JSON.stringify(raw(payloadJSON, href)), { raw });
45
49
  }
46
50
  export { createEmailRenderer } from './email.js';
@@ -0,0 +1,75 @@
1
+ <script lang="ts">
2
+ // The section the account settings lists share. Internal: not exported.
3
+ import type { Snippet } from 'svelte';
4
+
5
+ type Props = { id: string; title: string; description?: string; level?: 2 | 3 | 4; class?: string; children: Snippet };
6
+ let { id, title, description, level = 2, class: klass = '', children }: Props = $props();
7
+ </script>
8
+
9
+ <section class="tern-account {klass}" aria-labelledby={id}>
10
+ <header>
11
+ <svelte:element this={`h${level}`} {id}>{title}</svelte:element>
12
+ {#if description}<p>{description}</p>{/if}
13
+ </header>
14
+ {@render children()}
15
+ </section>
16
+
17
+ <style>
18
+ .tern-account {
19
+ display: grid;
20
+ gap: 1rem;
21
+ color: var(--tern-fg);
22
+ }
23
+ header {
24
+ display: grid;
25
+ gap: 0.25rem;
26
+ }
27
+ :is(h2, h3, h4) {
28
+ margin: 0;
29
+ font-size: 1.0625rem;
30
+ font-weight: 650;
31
+ }
32
+ header p,
33
+ .tern-account :global(.tern-account-empty) {
34
+ margin: 0;
35
+ color: var(--tern-muted);
36
+ font-size: 0.875rem;
37
+ }
38
+ .tern-account :global(ul) {
39
+ margin: 0;
40
+ padding: 0;
41
+ list-style: none;
42
+ border: 1px solid var(--tern-border);
43
+ border-radius: var(--tern-radius);
44
+ background: var(--tern-surface);
45
+ }
46
+ .tern-account :global(li) {
47
+ display: flex;
48
+ align-items: center;
49
+ justify-content: space-between;
50
+ gap: 1rem;
51
+ padding: 0.75rem 1rem;
52
+ }
53
+ .tern-account :global(li + li) {
54
+ border-top: 1px solid var(--tern-border);
55
+ }
56
+ .tern-account :global(.tern-account-text) {
57
+ display: grid;
58
+ gap: 0.15rem;
59
+ min-width: 0;
60
+ }
61
+ .tern-account :global(.tern-account-name) {
62
+ display: flex;
63
+ align-items: center;
64
+ gap: 0.5rem;
65
+ font-weight: 550;
66
+ overflow-wrap: anywhere;
67
+ }
68
+ .tern-account :global(.tern-account-meta) {
69
+ color: var(--tern-muted);
70
+ font-size: 0.8125rem;
71
+ }
72
+ .tern-account :global(.tern-form) {
73
+ display: contents;
74
+ }
75
+ </style>