toiljs 0.0.55 → 0.0.57

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 (99) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/README.md +72 -14
  3. package/build/backend/.tsbuildinfo +1 -1
  4. package/build/cli/.tsbuildinfo +1 -1
  5. package/build/cli/index.js +293 -142
  6. package/build/client/.tsbuildinfo +1 -1
  7. package/build/client/auth.js +1 -1
  8. package/build/client/components/Image.d.ts +1 -1
  9. package/build/client/dev/devtools.js +4 -2
  10. package/build/client/index.d.ts +2 -2
  11. package/build/client/index.js +2 -2
  12. package/build/client/routing/Router.js +1 -1
  13. package/build/client/routing/hooks.js +2 -2
  14. package/build/client/routing/mount.js +1 -1
  15. package/build/compiler/.tsbuildinfo +1 -1
  16. package/build/compiler/docs.js +1 -1
  17. package/build/compiler/seo.js +1 -3
  18. package/build/compiler/template-build.d.ts +5 -2
  19. package/build/compiler/template-build.js +19 -7
  20. package/build/devserver/.tsbuildinfo +1 -1
  21. package/build/devserver/cache.js +0 -0
  22. package/build/devserver/crypto.js +45 -17
  23. package/build/devserver/database.d.ts +1 -1
  24. package/build/devserver/database.js +84 -0
  25. package/build/devserver/email/caps.js +0 -0
  26. package/build/devserver/email/config.js +7 -2
  27. package/build/devserver/email/validate.js +1 -4
  28. package/build/devserver/host.js +18 -1
  29. package/build/devserver/index.d.ts +1 -1
  30. package/build/devserver/index.js +3 -2
  31. package/build/devserver/module.js +51 -12
  32. package/build/devserver/proxy.js +2 -1
  33. package/build/io/.tsbuildinfo +1 -1
  34. package/build/io/codec.d.ts +5 -5
  35. package/build/io/codec.js +193 -77
  36. package/examples/basic/client/components/HoneycombBackground.tsx +1 -1
  37. package/examples/basic/client/public/images/logo.svg +37 -34
  38. package/examples/basic/client/public/index.html +14 -14
  39. package/examples/basic/client/routes/auth.tsx +18 -10
  40. package/examples/basic/client/routes/cookies.tsx +15 -24
  41. package/examples/basic/client/routes/crypto.tsx +4 -5
  42. package/examples/basic/client/routes/features/template/template.tsx +1 -1
  43. package/examples/basic/client/routes/hello.tsx +1 -1
  44. package/examples/basic/client/routes/pq.tsx +14 -14
  45. package/examples/basic/client/routes/rest.tsx +1 -3
  46. package/examples/basic/client/styles/main.css +25 -22
  47. package/examples/basic/client/toil.tsx +1 -1
  48. package/examples/basic/server/README.md +8 -8
  49. package/examples/basic/server/core/AppHandler.ts +4 -7
  50. package/examples/basic/server/routes/Auth.ts +13 -10
  51. package/examples/basic/server/routes/EnvDemo.ts +9 -3
  52. package/examples/basic/server/routes/Guestbook.ts +2 -4
  53. package/package.json +26 -26
  54. package/src/backend/index.ts +4 -2
  55. package/src/cli/create.ts +19 -4
  56. package/src/cli/diagnostics.ts +48 -0
  57. package/src/cli/doctor.ts +155 -9
  58. package/src/cli/notify.ts +1 -6
  59. package/src/cli/ui.ts +3 -3
  60. package/src/cli/version-check.ts +5 -1
  61. package/src/client/auth.ts +33 -10
  62. package/src/client/components/Form.tsx +2 -2
  63. package/src/client/components/Image.tsx +1 -1
  64. package/src/client/components/Script.tsx +1 -1
  65. package/src/client/components/Slot.tsx +1 -1
  66. package/src/client/dev/devtools.tsx +126 -55
  67. package/src/client/dev/error-overlay.tsx +7 -1
  68. package/src/client/head/metadata.ts +1 -1
  69. package/src/client/index.ts +13 -2
  70. package/src/client/routing/Router.tsx +2 -2
  71. package/src/client/routing/error-boundary.tsx +1 -1
  72. package/src/client/routing/hooks.ts +5 -3
  73. package/src/client/routing/loader.ts +2 -2
  74. package/src/client/routing/mount.tsx +5 -6
  75. package/src/compiler/docs.ts +1 -1
  76. package/src/compiler/email-preview.ts +1 -1
  77. package/src/compiler/generate.ts +1 -1
  78. package/src/compiler/seo.ts +1 -3
  79. package/src/compiler/ssg.ts +10 -4
  80. package/src/compiler/template-build.ts +43 -11
  81. package/src/compiler/template.ts +1 -4
  82. package/src/compiler/vite.ts +1 -1
  83. package/src/devserver/cache.ts +0 -0
  84. package/src/devserver/crypto.ts +140 -51
  85. package/src/devserver/database.ts +168 -9
  86. package/src/devserver/dotenv.ts +10 -2
  87. package/src/devserver/email/caps.ts +0 -0
  88. package/src/devserver/email/config.ts +8 -2
  89. package/src/devserver/email/index.ts +3 -3
  90. package/src/devserver/email/validate.ts +1 -4
  91. package/src/devserver/envelope.ts +3 -3
  92. package/src/devserver/host.ts +46 -6
  93. package/src/devserver/index.ts +15 -6
  94. package/src/devserver/module.ts +56 -14
  95. package/src/devserver/proxy.ts +5 -7
  96. package/src/io/codec.ts +226 -83
  97. package/test/devserver-database.test.ts +60 -0
  98. package/test/devserver-secrets.test.ts +59 -0
  99. package/test/doctor.test.ts +30 -0
@@ -11,7 +11,7 @@
11
11
  * JSON, byte-identical to the server's `AuthService.buildLoginMessage`.
12
12
  */
13
13
 
14
- import { argon2id, createSHA256, createHMAC } from 'hash-wasm';
14
+ import { argon2id, createHMAC, createSHA256 } from 'hash-wasm';
15
15
  import { ml_dsa44 } from '@dacely/noble-post-quantum/ml-dsa.js';
16
16
  import { ml_kem768 } from '@dacely/noble-post-quantum/ml-kem.js';
17
17
  import { ristretto255_oprf } from '@noble/curves/ed25519.js';
@@ -45,7 +45,9 @@ function fromHex(hex: string): Uint8Array {
45
45
  * decapsulate, so a valid confirmation tag authenticates the server. This is
46
46
  * the demo dev key; a real deployment pins its own (and rotates it).
47
47
  */
48
- export const SERVER_KEM_PUBLIC_KEY = fromHex('29d765e8083182891302569b3712a856e564fdd484b0706b0c68568d5ab7edc742cf74459d64595455a60f267973aa55e43c5be61925a3822eafcca445e36dc4655636e31e6fc9bec338b253f94290008ef7f40dbddb49c15c690f6755a23a1b3c85cfd5207e71a607086a6fc6d74a05080f43276901a19cafdb8de7771d58ea07f0f1056b905127b22223d08e75173199f13ab13c5dcd3b51ac784f84e520484a262b845a897c41cf27324ab6ba545c78c9ccab361051e0bba53498af26240fa0d566d1572684f4b42e253e6d052c848650915063c35641e1121ef8d9cfd17b667b351103c56d195007c9376d0c08aa268396814490eab4c364175a94533267a1933862cc4c33bcf0a13d1fa2b9d6c5082eeca1480672f2526cbe013beff14dc908a386e0b633c8761023cbed760deac6709bc328d865ac82e12307b673d96711dbb27a4d939230d25b53d594169a318be0200fa33550e9418e2a3b30e9719edc09d5fc4306f1abfd021eab14637a8a72c5931d25dc9b56db0e6ab677522b10f25307dbb804a6774ce05b87b0976a4b227bfe6caf20a79e64004fbd27b1eea018b3ab8ffa629f2dc87f19278f95168e94e44660a3370c537795678eb2f056260609769740583b51b291862927a1938737c6a37f40b78f00671cccbcb88ac3427b37915ed58782998f84051647707d48995472baad3f64a7cca54e1c0734db08751c614a34f28b84f2c1b5a6817355ab61957c486b7acffbc092bc8a7b46387f33b53ed372f7168d31a71cd008539928b0cdf91e835aa97f6a2be6d327b87a6ae478701d75a59a25179cb14997bb2552853014724170a1c49b82c2bcebc3279024e1fa44c53c7afdc43f0bd22116490f3b74c90e7296be58b9a91168f2fa0c3d378a3bcac959f357825c9976a8c9ee944f29b45e96d7345d9b478431a20cf1c5d3a3227c717fd204619777636c0cb140db5c50d2a3302334461030bee34e4eb1a6f02b733f9ccda4290fa168bc039568373241542728d00030d1f251e83737cb215adbdc1de75978675a0cd0d75b12748abdda7a9852629c63697d145af2c69854b06e03f37c4b064e4c9a4c03f2ad4d081e70180e9547247921918118086b62b4f7727f46b24e3e79ba3f28209f32b5102035bf935856232f83642268c0292ec6bf8e9462382163d30a20b4bcb7b4439310ec9d0a148193907fc07697342967cf1a16c6b3c71558951fa915400736cf699262b54b723abb2ecc27b74b68ee494287595ef818388adb49e883c67bfa5c226c0eef037a0851a29d34675912c1ea1068310b6dfcd017c809c8fbfc2c3ae78dfef07299960eeefba182662a90fa422c1790f356a2ea909012b15623a9b9e450a282cb530589a68368b3583159d9010ac3e52cc974753c342e58279516339dfb691df94b13a223ad97eb6a09c21dafe6304a3642d6d2067b5238497661fe88ad1227ca3557be2a576b6e17c5a7f997ea07929e76407e376aba74c44cd8504804776f39bbb8327624188a63501e83b404d9438cade0b11dc3ac61856447fb072b91761c228878f01b2eb6b4b21ba664c2c75882431603b25a449ffeb8410b910558581777562aa9b2181fd9c04713ad9326462d3e842121c4997f9aa932417c67851625816de66e0d65637434629f39');
48
+ export const SERVER_KEM_PUBLIC_KEY = fromHex(
49
+ '29d765e8083182891302569b3712a856e564fdd484b0706b0c68568d5ab7edc742cf74459d64595455a60f267973aa55e43c5be61925a3822eafcca445e36dc4655636e31e6fc9bec338b253f94290008ef7f40dbddb49c15c690f6755a23a1b3c85cfd5207e71a607086a6fc6d74a05080f43276901a19cafdb8de7771d58ea07f0f1056b905127b22223d08e75173199f13ab13c5dcd3b51ac784f84e520484a262b845a897c41cf27324ab6ba545c78c9ccab361051e0bba53498af26240fa0d566d1572684f4b42e253e6d052c848650915063c35641e1121ef8d9cfd17b667b351103c56d195007c9376d0c08aa268396814490eab4c364175a94533267a1933862cc4c33bcf0a13d1fa2b9d6c5082eeca1480672f2526cbe013beff14dc908a386e0b633c8761023cbed760deac6709bc328d865ac82e12307b673d96711dbb27a4d939230d25b53d594169a318be0200fa33550e9418e2a3b30e9719edc09d5fc4306f1abfd021eab14637a8a72c5931d25dc9b56db0e6ab677522b10f25307dbb804a6774ce05b87b0976a4b227bfe6caf20a79e64004fbd27b1eea018b3ab8ffa629f2dc87f19278f95168e94e44660a3370c537795678eb2f056260609769740583b51b291862927a1938737c6a37f40b78f00671cccbcb88ac3427b37915ed58782998f84051647707d48995472baad3f64a7cca54e1c0734db08751c614a34f28b84f2c1b5a6817355ab61957c486b7acffbc092bc8a7b46387f33b53ed372f7168d31a71cd008539928b0cdf91e835aa97f6a2be6d327b87a6ae478701d75a59a25179cb14997bb2552853014724170a1c49b82c2bcebc3279024e1fa44c53c7afdc43f0bd22116490f3b74c90e7296be58b9a91168f2fa0c3d378a3bcac959f357825c9976a8c9ee944f29b45e96d7345d9b478431a20cf1c5d3a3227c717fd204619777636c0cb140db5c50d2a3302334461030bee34e4eb1a6f02b733f9ccda4290fa168bc039568373241542728d00030d1f251e83737cb215adbdc1de75978675a0cd0d75b12748abdda7a9852629c63697d145af2c69854b06e03f37c4b064e4c9a4c03f2ad4d081e70180e9547247921918118086b62b4f7727f46b24e3e79ba3f28209f32b5102035bf935856232f83642268c0292ec6bf8e9462382163d30a20b4bcb7b4439310ec9d0a148193907fc07697342967cf1a16c6b3c71558951fa915400736cf699262b54b723abb2ecc27b74b68ee494287595ef818388adb49e883c67bfa5c226c0eef037a0851a29d34675912c1ea1068310b6dfcd017c809c8fbfc2c3ae78dfef07299960eeefba182662a90fa422c1790f356a2ea909012b15623a9b9e450a282cb530589a68368b3583159d9010ac3e52cc974753c342e58279516339dfb691df94b13a223ad97eb6a09c21dafe6304a3642d6d2067b5238497661fe88ad1227ca3557be2a576b6e17c5a7f997ea07929e76407e376aba74c44cd8504804776f39bbb8327624188a63501e83b404d9438cade0b11dc3ac61856447fb072b91761c228878f01b2eb6b4b21ba664c2c75882431603b25a449ffeb8410b910558581777562aa9b2181fd9c04713ad9326462d3e842121c4997f9aa932417c67851625816de66e0d65637434629f39',
50
+ );
49
51
 
50
52
  export const PUBLIC_KEY_LEN = 1312;
51
53
  export const SECRET_KEY_LEN = 2560;
@@ -171,7 +173,6 @@ export function buildRegisterMessage(username: string, publicKey: Uint8Array): U
171
173
  return new DataWriter().writeU8(1).writeString(username).writeBytes(publicKey).toBytes();
172
174
  }
173
175
 
174
-
175
176
  function decodeKdf(r: DataReader): KdfParams {
176
177
  return {
177
178
  memKiB: r.readU32(),
@@ -181,7 +182,6 @@ function decodeKdf(r: DataReader): KdfParams {
181
182
  };
182
183
  }
183
184
 
184
-
185
185
  async function postBinary(baseUrl: string, path: string, body: Uint8Array): Promise<DataReader> {
186
186
  const res = await fetch(baseUrl + path, {
187
187
  method: 'POST',
@@ -204,7 +204,11 @@ export interface AuthOptions {
204
204
  * stretched with Argon2id into an ML-DSA-44 keypair, and ONLY the public key
205
205
  * (plus a proof-of-possession signature) is submitted. Throws on failure.
206
206
  */
207
- export async function register(username: string, password: string, opts: AuthOptions = {}): Promise<void> {
207
+ export async function register(
208
+ username: string,
209
+ password: string,
210
+ opts: AuthOptions = {},
211
+ ): Promise<void> {
208
212
  const baseUrl = opts.baseUrl ?? '/auth';
209
213
  const oprf = ristretto255_oprf.oprf;
210
214
  const pw = utf8(password.normalize('NFKC'));
@@ -268,7 +272,11 @@ export async function register(username: string, password: string, opts: AuthOpt
268
272
  * The secret key, seed, and shared secret are wiped as soon as they are used.
269
273
  * Returns the opaque session token. Throws (one generic message) on any failure.
270
274
  */
271
- export async function login(username: string, password: string, opts: AuthOptions = {}): Promise<Uint8Array> {
275
+ export async function login(
276
+ username: string,
277
+ password: string,
278
+ opts: AuthOptions = {},
279
+ ): Promise<Uint8Array> {
272
280
  const baseUrl = opts.baseUrl ?? '/auth';
273
281
  const oprf = ristretto255_oprf.oprf;
274
282
  const pw = utf8(password.normalize('NFKC'));
@@ -299,8 +307,17 @@ export async function login(username: string, password: string, opts: AuthOption
299
307
  const { cipherText, sharedSecret } = ml_kem768.encapsulate(SERVER_KEM_PUBLIC_KEY);
300
308
  const serverKemKeyId = await sha256Bytes(SERVER_KEM_PUBLIC_KEY);
301
309
  const message = buildLoginMessage(
302
- username, aud, cid, nonce, iat, exp,
303
- cipherText, kdf.memKiB, kdf.iterations, kdf.parallelism, serverKemKeyId,
310
+ username,
311
+ aud,
312
+ cid,
313
+ nonce,
314
+ iat,
315
+ exp,
316
+ cipherText,
317
+ kdf.memKiB,
318
+ kdf.iterations,
319
+ kdf.parallelism,
320
+ serverKemKeyId,
304
321
  );
305
322
  let signature: Uint8Array;
306
323
  try {
@@ -333,9 +350,15 @@ export async function login(username: string, password: string, opts: AuthOption
333
350
  // decapsulated correctly derives the same K, so a valid tag proves its
334
351
  // identity. Verify before returning the session.
335
352
  const transcriptHash = await sha256Bytes(message);
336
- const sessionKey = await hmacSha256(sharedSecret, concatBytes(utf8(SESSION_KEY_LABEL), transcriptHash));
353
+ const sessionKey = await hmacSha256(
354
+ sharedSecret,
355
+ concatBytes(utf8(SESSION_KEY_LABEL), transcriptHash),
356
+ );
337
357
  wipe(sharedSecret);
338
- const expected = await hmacSha256(sessionKey, concatBytes(utf8(SERVER_CONFIRM_LABEL), transcriptHash));
358
+ const expected = await hmacSha256(
359
+ sessionKey,
360
+ concatBytes(utf8(SERVER_CONFIRM_LABEL), transcriptHash),
361
+ );
339
362
  if (!bytesEqual(expected, serverConfirm)) throw new Error('auth: server authentication failed');
340
363
 
341
364
  return session; // session token
@@ -1,6 +1,6 @@
1
- import { useRef, type ReactNode, type SyntheticEvent } from 'react';
1
+ import { type ReactNode, type SyntheticEvent, useRef } from 'react';
2
2
 
3
- import { useAction, type ActionState, type RevalidateTarget } from '../routing/action.js';
3
+ import { type ActionState, type RevalidateTarget, useAction } from '../routing/action.js';
4
4
 
5
5
  /** Props for {@link Form}. */
6
6
  export interface FormProps {
@@ -1,4 +1,4 @@
1
- import { useState, type CSSProperties, type ComponentPropsWithRef, type ReactNode } from 'react';
1
+ import { type ComponentPropsWithRef, type CSSProperties, type ReactNode, useState } from 'react';
2
2
 
3
3
  /**
4
4
  * Props for {@link Image}: every standard `<img>` attribute, plus toil's layout/loading controls.
@@ -1,4 +1,4 @@
1
- import { useEffect, type ReactNode } from 'react';
1
+ import { type ReactNode, useEffect } from 'react';
2
2
 
3
3
  /**
4
4
  * When a {@link Script} is injected, relative to the app becoming interactive:
@@ -1,4 +1,4 @@
1
- import { useContext, type ReactNode } from 'react';
1
+ import { type ReactNode, useContext } from 'react';
2
2
 
3
3
  import { SlotContext } from '../routing/slot-context.js';
4
4
 
@@ -7,7 +7,7 @@
7
7
  * It stays decoupled from the Router (it computes the current match itself via `matchRoute`) so it
8
8
  * renders even when the app tree has crashed.
9
9
  */
10
- import { useEffect, useState, useSyncExternalStore, type ReactNode } from 'react';
10
+ import { type ReactNode, useEffect, useState, useSyncExternalStore } from 'react';
11
11
 
12
12
  import { type DevError, getErrorLog, subscribeErrors } from './error-overlay.js';
13
13
  import {
@@ -21,10 +21,10 @@ import {
21
21
  import {
22
22
  clearLoaderData,
23
23
  inspectLoaderCache,
24
+ type LoaderCacheSnapshot,
24
25
  loaderKey,
25
26
  revalidate,
26
27
  subscribeLoaderCache,
27
- type LoaderCacheSnapshot,
28
28
  } from '../routing/loader.js';
29
29
  import { matchRoute } from '../routing/match.js';
30
30
  import { getPages } from '../search/search.js';
@@ -52,10 +52,22 @@ function ToilLogo({ size = 16 }: { size?: number }): ReactNode {
52
52
  x2="467.12"
53
53
  y2="467.12"
54
54
  gradientUnits="userSpaceOnUse">
55
- <stop offset="0" stopColor="#6990ff" />
56
- <stop offset=".28" stopColor="#521be0" />
57
- <stop offset=".66" stopColor="#6900f4" />
58
- <stop offset="1" stopColor="#7f00f6" />
55
+ <stop
56
+ offset="0"
57
+ stopColor="#6990ff"
58
+ />
59
+ <stop
60
+ offset=".28"
61
+ stopColor="#521be0"
62
+ />
63
+ <stop
64
+ offset=".66"
65
+ stopColor="#6900f4"
66
+ />
67
+ <stop
68
+ offset="1"
69
+ stopColor="#7f00f6"
70
+ />
59
71
  </linearGradient>
60
72
  <linearGradient
61
73
  id="toilDtB"
@@ -64,12 +76,28 @@ function ToilLogo({ size = 16 }: { size?: number }): ReactNode {
64
76
  x2="149.99"
65
77
  y2="0"
66
78
  gradientUnits="userSpaceOnUse">
67
- <stop offset=".15" stopColor="#6990ff" stopOpacity=".6" />
68
- <stop offset=".55" stopColor="#531ae1" />
79
+ <stop
80
+ offset=".15"
81
+ stopColor="#6990ff"
82
+ stopOpacity=".6"
83
+ />
84
+ <stop
85
+ offset=".55"
86
+ stopColor="#531ae1"
87
+ />
69
88
  </linearGradient>
70
89
  </defs>
71
- <rect width="500" height="500" rx="130" ry="130" fill="url(#toilDtA)" />
72
- <path d="M299.98,0L0,355.49v-225.49C0,58.2,58.2,0,130,0h169.98Z" fill="url(#toilDtB)" />
90
+ <rect
91
+ width="500"
92
+ height="500"
93
+ rx="130"
94
+ ry="130"
95
+ fill="url(#toilDtA)"
96
+ />
97
+ <path
98
+ d="M299.98,0L0,355.49v-225.49C0,58.2,58.2,0,130,0h169.98Z"
99
+ fill="url(#toilDtB)"
100
+ />
73
101
  <path
74
102
  d="M106.17,111.11h285.24c9.9,0,16.7,9.96,13.09,19.18l-17.98,45.96c-2.11,5.39-7.31,8.94-13.09,8.94h-74.65c-7.76,0-14.06,6.29-14.06,14.06v214.94c0,7.76-6.29,14.06-14.06,14.06h-45.96c-7.76,0-14.06-6.29-14.06-14.06v-217.25c0-7.76-6.29-14.06-14.06-14.06h-73.66c-5.82,0-11.04-3.59-13.12-9.02l-16.76-43.64c-3.54-9.21,3.26-19.1,13.12-19.1Z"
75
103
  fill="#fff"
@@ -143,7 +171,11 @@ function loadPrefs(): Prefs {
143
171
  }
144
172
  }
145
173
 
146
- let prefs: Prefs = typeof localStorage !== 'undefined' ? loadPrefs() : defaultPrefs;
174
+ // Gate on `window`, not `localStorage`: the devtools are browser-only, and merely
175
+ // touching the bare `localStorage` global under SSR/Node trips its experimental-API
176
+ // warning ("localStorage is not available because --localstorage-file ..."). In the
177
+ // browser `loadPrefs()` reads it; under SSR we keep the defaults and never touch it.
178
+ let prefs: Prefs = typeof window !== 'undefined' ? loadPrefs() : defaultPrefs;
147
179
  const prefListeners = new Set<() => void>();
148
180
  function setPrefs(next: Partial<Prefs>): void {
149
181
  prefs = { ...prefs, ...next };
@@ -194,7 +226,10 @@ function safeJson(value: unknown): string {
194
226
  }
195
227
 
196
228
  /** Reads the current document head's meta + link tags (live). */
197
- function readHead(): { metas: { name: string; content: string }[]; links: { rel: string; href: string }[] } {
229
+ function readHead(): {
230
+ metas: { name: string; content: string }[];
231
+ links: { rel: string; href: string }[];
232
+ } {
198
233
  const metas: { name: string; content: string }[] = [];
199
234
  const links: { rel: string; href: string }[] = [];
200
235
  if (typeof document === 'undefined') return { metas, links };
@@ -346,7 +381,9 @@ function RouteTab({
346
381
  <Row k="slots">{activeSlots.length ? activeSlots.join(', ') : 'none'}</Row>
347
382
  <Row k="navigating">{pending ? 'yes' : 'no'}</Row>
348
383
 
349
- <p className="toil-dt-sec" style={{ marginTop: 12 }}>
384
+ <p
385
+ className="toil-dt-sec"
386
+ style={{ marginTop: 12 }}>
350
387
  Routes ({routes.length})
351
388
  </p>
352
389
  {routes.map((r) => {
@@ -474,7 +511,9 @@ function HeadTab(): ReactNode {
474
511
  <div className="toil-dt-body">
475
512
  <Row k="title">{title || '(none)'}</Row>
476
513
 
477
- <p className="toil-dt-sec" style={{ marginTop: 10 }}>
514
+ <p
515
+ className="toil-dt-sec"
516
+ style={{ marginTop: 10 }}>
478
517
  OpenGraph preview
479
518
  </p>
480
519
  <div className="toil-dt-og">
@@ -491,23 +530,41 @@ function HeadTab(): ReactNode {
491
530
  </div>
492
531
  </div>
493
532
 
494
- <p className="toil-dt-sec" style={{ marginTop: 10 }}>
533
+ <p
534
+ className="toil-dt-sec"
535
+ style={{ marginTop: 10 }}>
495
536
  SEO checklist
496
537
  </p>
497
- <Check ok={Boolean(title)} label="Has a title" />
498
- <Check ok={meta('description') !== undefined} label="Has a meta description" />
499
- <Check ok={og.image !== undefined} label="Has an og:image" />
500
- <Check ok={links.some((l) => l.rel === 'canonical')} label="Has a canonical link" />
538
+ <Check
539
+ ok={Boolean(title)}
540
+ label="Has a title"
541
+ />
542
+ <Check
543
+ ok={meta('description') !== undefined}
544
+ label="Has a meta description"
545
+ />
546
+ <Check
547
+ ok={og.image !== undefined}
548
+ label="Has an og:image"
549
+ />
550
+ <Check
551
+ ok={links.some((l) => l.rel === 'canonical')}
552
+ label="Has a canonical link"
553
+ />
501
554
  <Check
502
555
  ok={pages.length === 0 || described === pages.length}
503
556
  label={`Pages with a description: ${String(described)}/${String(pages.length)}`}
504
557
  />
505
558
 
506
- <p className="toil-dt-sec" style={{ marginTop: 10 }}>
559
+ <p
560
+ className="toil-dt-sec"
561
+ style={{ marginTop: 10 }}>
507
562
  Meta ({metas.length})
508
563
  </p>
509
564
  {metas.map((m, i) => (
510
- <Row k={m.name} key={`${m.name}:${String(i)}`}>
565
+ <Row
566
+ k={m.name}
567
+ key={`${m.name}:${String(i)}`}>
511
568
  {m.content}
512
569
  </Row>
513
570
  ))}
@@ -546,7 +603,8 @@ function BuildTab({ info }: { info: DevInfo | null }): ReactNode {
546
603
 
547
604
  function ErrorsTab(): ReactNode {
548
605
  const errors = useErrors();
549
- if (errors.length === 0) return <p className="toil-dt-empty toil-dt-body">No errors captured.</p>;
606
+ if (errors.length === 0)
607
+ return <p className="toil-dt-empty toil-dt-body">No errors captured.</p>;
550
608
  return (
551
609
  <div className="toil-dt-body">
552
610
  {[...errors].reverse().map((e, i) => (
@@ -974,45 +1032,58 @@ export function DevToolbar({
974
1032
 
975
1033
  return (
976
1034
  <>
977
- <div className={`toil-dt ${p.side}`}>
978
- <div className="toil-dt-panel">
979
- <div className="toil-dt-head">
980
- <span style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
981
- <ToilLogo size={14} />
982
- <span className="toil-dt-logo">toiljs</span> devtools
983
- <span className={`toil-dt-dot ${dotClass}`} />
984
- </span>
985
- <button
986
- className="toil-dt-x"
987
- onClick={() => {
988
- setPrefs({ open: false });
989
- }}>
990
-
991
- </button>
992
- </div>
993
- <div className="toil-dt-tabs">
994
- {TABS.map((t) => (
1035
+ <div className={`toil-dt ${p.side}`}>
1036
+ <div className="toil-dt-panel">
1037
+ <div className="toil-dt-head">
1038
+ <span style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
1039
+ <ToilLogo size={14} />
1040
+ <span className="toil-dt-logo">toiljs</span> devtools
1041
+ <span className={`toil-dt-dot ${dotClass}`} />
1042
+ </span>
995
1043
  <button
996
- key={t.id}
997
- className={`toil-dt-tab ${p.tab === t.id ? 'active' : ''}`}
1044
+ className="toil-dt-x"
998
1045
  onClick={() => {
999
- setPrefs({ tab: t.id });
1046
+ setPrefs({ open: false });
1000
1047
  }}>
1001
- {t.label}
1002
- {t.id === 'errors' && errors.length > 0 ? ` (${String(errors.length)})` : ''}
1048
+
1003
1049
  </button>
1004
- ))}
1050
+ </div>
1051
+ <div className="toil-dt-tabs">
1052
+ {TABS.map((t) => (
1053
+ <button
1054
+ key={t.id}
1055
+ className={`toil-dt-tab ${p.tab === t.id ? 'active' : ''}`}
1056
+ onClick={() => {
1057
+ setPrefs({ tab: t.id });
1058
+ }}>
1059
+ {t.label}
1060
+ {t.id === 'errors' && errors.length > 0
1061
+ ? ` (${String(errors.length)})`
1062
+ : ''}
1063
+ </button>
1064
+ ))}
1065
+ </div>
1066
+ {p.tab === 'route' && (
1067
+ <RouteTab
1068
+ routes={routes}
1069
+ slots={slots}
1070
+ info={info}
1071
+ />
1072
+ )}
1073
+ {p.tab === 'data' && <DataTab />}
1074
+ {p.tab === 'head' && <HeadTab />}
1075
+ {p.tab === 'build' && <BuildTab info={info} />}
1076
+ {p.tab === 'errors' && <ErrorsTab />}
1077
+ {p.tab === 'ai' && (
1078
+ <AiTab
1079
+ info={info}
1080
+ routes={routes}
1081
+ />
1082
+ )}
1083
+ {p.tab === 'prefs' && <PrefsTab />}
1005
1084
  </div>
1006
- {p.tab === 'route' && <RouteTab routes={routes} slots={slots} info={info} />}
1007
- {p.tab === 'data' && <DataTab />}
1008
- {p.tab === 'head' && <HeadTab />}
1009
- {p.tab === 'build' && <BuildTab info={info} />}
1010
- {p.tab === 'errors' && <ErrorsTab />}
1011
- {p.tab === 'ai' && <AiTab info={info} routes={routes} />}
1012
- {p.tab === 'prefs' && <PrefsTab />}
1013
1085
  </div>
1014
- </div>
1015
- {pal}
1086
+ {pal}
1016
1087
  </>
1017
1088
  );
1018
1089
  }
@@ -4,7 +4,13 @@
4
4
  * plus `window` `error` / `unhandledrejection` events. Shows the message, stack, and (for render
5
5
  * errors) the React component stack, with Dismiss / Reload. Inert in production builds.
6
6
  */
7
- import { Component, type CSSProperties, type ErrorInfo, type ReactNode, useSyncExternalStore, } from 'react';
7
+ import {
8
+ Component,
9
+ type CSSProperties,
10
+ type ErrorInfo,
11
+ type ReactNode,
12
+ useSyncExternalStore,
13
+ } from 'react';
8
14
 
9
15
  /** A captured dev error. */
10
16
  export interface DevError {
@@ -4,7 +4,7 @@
4
4
  * data); the compiler-driven loader resolves it to a {@link HeadSpec} that the router applies as the
5
5
  * route's baseline head (component-level `useHead`/`<Head>` still compose on top and can override).
6
6
  */
7
- import { useHead, type HeadSpec, type LinkTag, type MetaTag } from './head.js';
7
+ import { type HeadSpec, type LinkTag, type MetaTag, useHead } from './head.js';
8
8
  import type { RouteParams } from '../routing/match.js';
9
9
 
10
10
  /** OpenGraph fields, expanded to `og:*` meta tags. */
@@ -10,7 +10,13 @@
10
10
 
11
11
  export { mount } from './routing/mount.js';
12
12
  export { Router } from './routing/Router.js';
13
- export { Auth, register as authRegister, login as authLogin, buildLoginMessage, LOGIN_CONTEXT } from './auth.js';
13
+ export {
14
+ Auth,
15
+ register as authRegister,
16
+ login as authLogin,
17
+ buildLoginMessage,
18
+ LOGIN_CONTEXT,
19
+ } from './auth.js';
14
20
  export type { KdfParams, AuthOptions } from './auth.js';
15
21
  export { Link } from './navigation/Link.js';
16
22
  export type { LinkProps } from './navigation/Link.js';
@@ -36,7 +42,12 @@ export {
36
42
  useNavigationPending,
37
43
  } from './routing/hooks.js';
38
44
  export type { RouterInstance } from './routing/hooks.js';
39
- export { useLoaderData, revalidate, invalidateLoaderData, LoaderDataContext } from './routing/loader.js';
45
+ export {
46
+ useLoaderData,
47
+ revalidate,
48
+ invalidateLoaderData,
49
+ LoaderDataContext,
50
+ } from './routing/loader.js';
40
51
  export type {
41
52
  LoaderArgs,
42
53
  LoaderFunction,
@@ -1,4 +1,4 @@
1
- import { createElement, Suspense, useLayoutEffect, type ReactNode } from 'react';
1
+ import { createElement, type ReactNode, Suspense, useLayoutEffect } from 'react';
2
2
 
3
3
  import { ErrorBoundary } from './error-boundary.js';
4
4
  import { useLocation } from './hooks.js';
@@ -9,7 +9,7 @@ import {
9
9
  resolveLayout,
10
10
  resolveNotFound,
11
11
  } from './lazy.js';
12
- import { loaderKey, LoaderDataContext, readRouteData } from './loader.js';
12
+ import { LoaderDataContext, loaderKey, readRouteData } from './loader.js';
13
13
  import { matchRoute, type RouteParams } from './match.js';
14
14
  import { useRouteHead } from '../head/head.js';
15
15
  import { ParamsContext } from './params-context.js';
@@ -1,4 +1,4 @@
1
- import { Component, Suspense, type ComponentType, type ReactNode } from 'react';
1
+ import { Component, type ComponentType, type ReactNode, Suspense } from 'react';
2
2
 
3
3
  import type { RouteErrorProps } from '../types.js';
4
4
 
@@ -101,10 +101,12 @@ function useLocationSubscription(): void {
101
101
  );
102
102
  }
103
103
 
104
- /** Subscribes to and returns the current `location.pathname`. */
104
+ /** Subscribes to and returns the current `location.pathname`. SSR-safe: during a
105
+ * server render (build-time template extraction / edge SSR) there is no `window`,
106
+ * so it reports `/`; the client recomputes on hydration. */
105
107
  export function useLocation(): string {
106
108
  useLocationSubscription();
107
- return window.location.pathname;
109
+ return typeof window === 'undefined' ? '/' : window.location.pathname;
108
110
  }
109
111
 
110
112
  /** Alias of {@link useLocation}: the current `location.pathname`. */
@@ -115,7 +117,7 @@ export function usePathname(): string {
115
117
  /** The current query string as a `URLSearchParams`, re-read on every navigation. */
116
118
  export function useSearchParams(): URLSearchParams {
117
119
  useLocationSubscription();
118
- const search = window.location.search;
120
+ const search = typeof window === 'undefined' ? '' : window.location.search;
119
121
  return useMemo(() => new URLSearchParams(search), [search]);
120
122
  }
121
123
 
@@ -10,10 +10,10 @@
10
10
  * a number keeps data fresh for that many seconds; `false` caches until manual invalidation.
11
11
  * `revalidate()` / `router.refresh()` bust the cache to force a refetch.
12
12
  */
13
- import { createContext, useContext, type ComponentType } from 'react';
13
+ import { type ComponentType, createContext, useContext } from 'react';
14
14
 
15
15
  import type { HeadSpec } from '../head/head.js';
16
- import { resolveMetadata, type GenerateMetadata, type Metadata } from '../head/metadata.js';
16
+ import { type GenerateMetadata, type Metadata, resolveMetadata } from '../head/metadata.js';
17
17
  import { navigationEpoch, refresh as rerender } from '../navigation/navigation.js';
18
18
  import type { RouteDef } from '../types.js';
19
19
  import type { RouteParams } from './match.js';
@@ -1,11 +1,7 @@
1
1
  import { createRoot, hydrateRoot } from 'react-dom/client';
2
2
 
3
3
  import { DevToolbar } from '../dev/devtools.js';
4
- import {
5
- DevErrorBoundary,
6
- DevErrorOverlay,
7
- initDevErrorOverlay,
8
- } from '../dev/error-overlay.js';
4
+ import { DevErrorBoundary, DevErrorOverlay, initDevErrorOverlay } from '../dev/error-overlay.js';
9
5
  import { initNavigation } from '../navigation/navigation.js';
10
6
  import { startPrefetcher } from '../navigation/prefetch.js';
11
7
  import { hydrateLoaderData } from './loader.js';
@@ -73,7 +69,10 @@ export function mount(
73
69
  <>
74
70
  <DevErrorBoundary>{app}</DevErrorBoundary>
75
71
  <DevErrorOverlay />
76
- <DevToolbar routes={routes} slots={slots} />
72
+ <DevToolbar
73
+ routes={routes}
74
+ slots={slots}
75
+ />
77
76
  </>,
78
77
  );
79
78
  } else if (isSsrDocument()) {
@@ -346,7 +346,7 @@ export const TOIL_DOCS: Record<string, string> = {
346
346
  ' an `<Island>`. A route that cannot render this way is skipped at build (with a warning) and',
347
347
  ' simply falls back to normal client rendering.',
348
348
  '- Hole values are HTML-escaped exactly as React escapes them, so hydration is byte-for-byte',
349
- ' clean. Keep a repeat row\'s structure the same across items (only the leaf hole values vary).',
349
+ " clean. Keep a repeat row's structure the same across items (only the leaf hole values vary).",
350
350
  '- Build output for an SSR route lands in `build/client/_ssr/` (the template + its manifest)',
351
351
  ' alongside the generated `Slot` module; routes without `ssr = true` are unaffected.',
352
352
  ]),
@@ -13,7 +13,7 @@ import path from 'node:path';
13
13
  import type { ViteDevServer } from 'vite';
14
14
 
15
15
  import type { ResolvedToilConfig } from './config.js';
16
- import { renderEmailFile, toPascal, type RenderedEmail } from './emails.js';
16
+ import { type RenderedEmail, renderEmailFile, toPascal } from './emails.js';
17
17
 
18
18
  /** One discoverable email: its generated `Emails.<name>` and its absolute file. */
19
19
  export interface EmailListItem {
@@ -4,7 +4,7 @@ import path from 'node:path';
4
4
  import { type ResolvedToilConfig } from './config.js';
5
5
  import { writeDocs } from './docs.js';
6
6
  import { buildPageIndex, pagesModuleSource } from './pages.js';
7
- import { scanRoutes, type ScannedRoute } from './routes.js';
7
+ import { type ScannedRoute, scanRoutes } from './routes.js';
8
8
  import { llmsTxt, robotsTxt, sitemapXml } from './seo.js';
9
9
 
10
10
  /**
@@ -407,9 +407,7 @@ export function llmsTxt(
407
407
  const title = escapeMarkdownInline(page.title);
408
408
  const url = escapeMarkdownUrl(page.url);
409
409
  const desc =
410
- page.description !== undefined
411
- ? `: ${escapeMarkdownInline(page.description)}`
412
- : '';
410
+ page.description !== undefined ? `: ${escapeMarkdownInline(page.description)}` : '';
413
411
  out.push(`- [${title}](${url})${desc}`);
414
412
  }
415
413
  }
@@ -16,7 +16,7 @@ import { createServer } from 'vite';
16
16
  import { type ResolvedToilConfig } from './config.js';
17
17
  import { extractStaticMetadata, loadTypeScript } from './prerender.js';
18
18
  import { scanRoutes } from './routes.js';
19
- import { injectSeoHtml, joinUrl, llmsTxt, routeSeo, sitemapXml, type LlmsPage } from './seo.js';
19
+ import { injectSeoHtml, joinUrl, type LlmsPage, llmsTxt, routeSeo, sitemapXml } from './seo.js';
20
20
  import { createViteConfig } from './vite.js';
21
21
 
22
22
  /** Reads a string field off a metadata record, or undefined. */
@@ -91,7 +91,9 @@ export async function prerenderStaticParams(cfg: ResolvedToilConfig): Promise<st
91
91
  try {
92
92
  mod = (await server.ssrLoadModule(route.file)) as RouteModule;
93
93
  } catch (err) {
94
- warn(`skipped ${route.pattern} (${err instanceof Error ? err.message : String(err)})`);
94
+ warn(
95
+ `skipped ${route.pattern} (${err instanceof Error ? err.message : String(err)})`,
96
+ );
95
97
  continue;
96
98
  }
97
99
  if (typeof mod.generateStaticParams !== 'function') continue;
@@ -116,12 +118,16 @@ export async function prerenderStaticParams(cfg: ResolvedToilConfig): Promise<st
116
118
  typeof mod.loader === 'function'
117
119
  ? await mod.loader({ params, searchParams })
118
120
  : undefined;
119
- metadata = asMetadata(await mod.generateMetadata({ params, searchParams, data }));
121
+ metadata = asMetadata(
122
+ await mod.generateMetadata({ params, searchParams, data }),
123
+ );
120
124
  } else if (mod.metadata) {
121
125
  metadata = asMetadata(mod.metadata);
122
126
  }
123
127
  } catch (err) {
124
- warn(`metadata failed for ${url} (${err instanceof Error ? err.message : String(err)})`);
128
+ warn(
129
+ `metadata failed for ${url} (${err instanceof Error ? err.message : String(err)})`,
130
+ );
125
131
  }
126
132
  const html = injectSeoHtml(shell, routeSeo(cfg.seo, metadata, url));
127
133
  fs.mkdirSync(path.dirname(target), { recursive: true });