toiljs 0.0.115 → 0.0.117

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 (326) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/README.md +16 -7
  3. package/TYPESCRIPT_LAW.md +1946 -2073
  4. package/build/backend/.tsbuildinfo +1 -1
  5. package/build/cli/.tsbuildinfo +1 -1
  6. package/build/cli/index.js +2375 -258
  7. package/build/client/.tsbuildinfo +1 -1
  8. package/build/client/dev/devtools.js +1 -1
  9. package/build/client/dev/error-overlay.js +1 -1
  10. package/build/client/routing/mount.js +1 -1
  11. package/build/compiler/.tsbuildinfo +1 -1
  12. package/build/compiler/auth-emails.js +7 -3
  13. package/build/compiler/emails.js +1 -1
  14. package/build/compiler/index.d.ts +1 -0
  15. package/build/compiler/index.js +25 -4
  16. package/build/compiler/pages.js +2 -3
  17. package/build/compiler/prerender.d.ts +3 -8
  18. package/build/compiler/prerender.js +88 -127
  19. package/build/compiler/ssg.js +3 -4
  20. package/build/compiler/template-build.js +3 -4
  21. package/build/compiler/toil-docs.generated.js +6 -5
  22. package/build/compiler/vite.js +3 -6
  23. package/build/devserver/.tsbuildinfo +1 -1
  24. package/build/devserver/db/database.js +7 -3
  25. package/build/devserver/db/types.js +1 -1
  26. package/build/devserver/production.js +4 -3
  27. package/build/devserver/runtime/crypto.d.ts +1 -1
  28. package/build/io/.tsbuildinfo +1 -1
  29. package/build/logger/.tsbuildinfo +1 -1
  30. package/build/shared/.tsbuildinfo +1 -1
  31. package/examples/basic/client/routes/analytics.tsx +7 -5
  32. package/examples/basic/server/tsconfig.json +1 -3
  33. package/package.json +45 -44
  34. package/presets/no-uint8array-tostring.js +120 -153
  35. package/presets/oxlint.d.ts +5 -0
  36. package/presets/oxlint.js +224 -0
  37. package/server/runtime/tsconfig.json +1 -3
  38. package/tsconfig.json +2 -11
  39. package/.github/ISSUE_TEMPLATE/bug_report.md +0 -38
  40. package/.github/ISSUE_TEMPLATE/bug_report.yml +0 -90
  41. package/.github/ISSUE_TEMPLATE/config.yml +0 -8
  42. package/.github/ISSUE_TEMPLATE/feature_request.md +0 -20
  43. package/.github/PULL_REQUEST_TEMPLATE.md +0 -43
  44. package/.github/changelog-config.json +0 -45
  45. package/.github/dependabot.yml +0 -27
  46. package/.github/workflows/ci.yml +0 -222
  47. package/.vscode/settings.json +0 -10
  48. package/docs/README.md +0 -107
  49. package/docs/auth/README.md +0 -104
  50. package/docs/auth/configuration.md +0 -105
  51. package/docs/auth/emails.md +0 -92
  52. package/docs/auth/extending.md +0 -202
  53. package/docs/auth/how-it-works.md +0 -138
  54. package/docs/auth/usage.md +0 -188
  55. package/docs/backend/README.md +0 -143
  56. package/docs/backend/data.md +0 -351
  57. package/docs/backend/rest.md +0 -402
  58. package/docs/backend/rpc.md +0 -226
  59. package/docs/background/README.md +0 -114
  60. package/docs/background/daemons.md +0 -276
  61. package/docs/background/derive.md +0 -179
  62. package/docs/cli/README.md +0 -379
  63. package/docs/concepts/ai-guide.md +0 -132
  64. package/docs/concepts/config.md +0 -416
  65. package/docs/concepts/decorators.md +0 -127
  66. package/docs/concepts/security.md +0 -108
  67. package/docs/concepts/tiers.md +0 -166
  68. package/docs/concepts/types.md +0 -216
  69. package/docs/database/README.md +0 -143
  70. package/docs/database/capacity.md +0 -350
  71. package/docs/database/counters.md +0 -174
  72. package/docs/database/documents.md +0 -277
  73. package/docs/database/events.md +0 -307
  74. package/docs/database/membership.md +0 -246
  75. package/docs/database/setup.md +0 -155
  76. package/docs/database/unique.md +0 -216
  77. package/docs/database/views.md +0 -246
  78. package/docs/frontend/README.md +0 -104
  79. package/docs/frontend/components.md +0 -286
  80. package/docs/frontend/data-fetching.md +0 -260
  81. package/docs/frontend/images.md +0 -148
  82. package/docs/frontend/metadata.md +0 -344
  83. package/docs/frontend/navigation.md +0 -296
  84. package/docs/frontend/rendering.md +0 -236
  85. package/docs/frontend/routing.md +0 -346
  86. package/docs/frontend/scripts.md +0 -118
  87. package/docs/frontend/search.md +0 -191
  88. package/docs/frontend/styling.md +0 -147
  89. package/docs/frontend/toil-global.md +0 -216
  90. package/docs/getting-started/README.md +0 -81
  91. package/docs/getting-started/create-project.md +0 -131
  92. package/docs/getting-started/deploy.md +0 -101
  93. package/docs/getting-started/first-app.md +0 -215
  94. package/docs/getting-started/installation.md +0 -124
  95. package/docs/getting-started/migrating.md +0 -125
  96. package/docs/getting-started/project-structure.md +0 -163
  97. package/docs/introduction/README.md +0 -66
  98. package/docs/introduction/design-principles.md +0 -81
  99. package/docs/introduction/distributed.md +0 -91
  100. package/docs/introduction/how-it-works.md +0 -121
  101. package/docs/introduction/hyperscale.md +0 -70
  102. package/docs/introduction/modern-stack.md +0 -85
  103. package/docs/introduction/vs-other-frameworks.md +0 -64
  104. package/docs/introduction/why-toil.md +0 -99
  105. package/docs/llms.txt +0 -95
  106. package/docs/realtime/README.md +0 -102
  107. package/docs/realtime/channels.md +0 -211
  108. package/docs/realtime/streams.md +0 -369
  109. package/docs/services/README.md +0 -60
  110. package/docs/services/analytics.md +0 -279
  111. package/docs/services/caching.md +0 -175
  112. package/docs/services/cookies.md +0 -235
  113. package/docs/services/crypto.md +0 -209
  114. package/docs/services/email.md +0 -289
  115. package/docs/services/environment.md +0 -141
  116. package/docs/services/ratelimit.md +0 -174
  117. package/docs/services/time.md +0 -85
  118. package/eslint.config.js +0 -57
  119. package/examples/basic/client/routes/docs/[...slug].tsx +0 -12
  120. package/presets/eslint.js +0 -88
  121. package/src/backend/index.ts +0 -204
  122. package/src/cli/configure.ts +0 -363
  123. package/src/cli/create.ts +0 -924
  124. package/src/cli/db.ts +0 -158
  125. package/src/cli/diagnostics.ts +0 -665
  126. package/src/cli/doctor.ts +0 -982
  127. package/src/cli/features.ts +0 -170
  128. package/src/cli/index.ts +0 -326
  129. package/src/cli/notify.ts +0 -120
  130. package/src/cli/proc.ts +0 -56
  131. package/src/cli/ui.ts +0 -169
  132. package/src/cli/update.ts +0 -248
  133. package/src/cli/updates.ts +0 -92
  134. package/src/cli/validate.ts +0 -31
  135. package/src/cli/version-check.ts +0 -125
  136. package/src/client/auth.ts +0 -869
  137. package/src/client/channel/channel.ts +0 -146
  138. package/src/client/components/Form.tsx +0 -65
  139. package/src/client/components/Image.tsx +0 -160
  140. package/src/client/components/Script.tsx +0 -113
  141. package/src/client/components/Slot.tsx +0 -21
  142. package/src/client/dev/devtools.tsx +0 -1089
  143. package/src/client/dev/error-overlay.tsx +0 -225
  144. package/src/client/errors.ts +0 -11
  145. package/src/client/head/head.ts +0 -182
  146. package/src/client/head/metadata.ts +0 -110
  147. package/src/client/index.ts +0 -130
  148. package/src/client/navigation/Link.tsx +0 -108
  149. package/src/client/navigation/NavLink.tsx +0 -86
  150. package/src/client/navigation/navigation.ts +0 -235
  151. package/src/client/navigation/prefetch.ts +0 -169
  152. package/src/client/navigation/scroll.ts +0 -53
  153. package/src/client/routing/Router.tsx +0 -216
  154. package/src/client/routing/action.ts +0 -122
  155. package/src/client/routing/error-boundary.tsx +0 -43
  156. package/src/client/routing/hooks.ts +0 -140
  157. package/src/client/routing/lazy.ts +0 -77
  158. package/src/client/routing/loader.ts +0 -381
  159. package/src/client/routing/match.ts +0 -47
  160. package/src/client/routing/mount.tsx +0 -90
  161. package/src/client/routing/params-context.ts +0 -10
  162. package/src/client/routing/slot-context.ts +0 -7
  163. package/src/client/rpc.ts +0 -97
  164. package/src/client/search/search.ts +0 -189
  165. package/src/client/search/use-page-search.ts +0 -73
  166. package/src/client/ssr/markers.tsx +0 -172
  167. package/src/client/stream/client.ts +0 -237
  168. package/src/client/types.ts +0 -73
  169. package/src/compiler/auth-emails.ts +0 -312
  170. package/src/compiler/config.ts +0 -369
  171. package/src/compiler/docs.ts +0 -127
  172. package/src/compiler/email-preview.ts +0 -314
  173. package/src/compiler/emails.ts +0 -404
  174. package/src/compiler/fonts.ts +0 -89
  175. package/src/compiler/generate.ts +0 -471
  176. package/src/compiler/image-blur.ts +0 -42
  177. package/src/compiler/image-report.ts +0 -88
  178. package/src/compiler/index.ts +0 -1362
  179. package/src/compiler/pages.ts +0 -49
  180. package/src/compiler/plugin.ts +0 -391
  181. package/src/compiler/prerender.ts +0 -263
  182. package/src/compiler/routes.ts +0 -170
  183. package/src/compiler/seo.ts +0 -415
  184. package/src/compiler/sri.ts +0 -144
  185. package/src/compiler/ssg.ts +0 -177
  186. package/src/compiler/ssr-codegen.ts +0 -85
  187. package/src/compiler/template-build.ts +0 -676
  188. package/src/compiler/template.ts +0 -262
  189. package/src/compiler/toil-docs.generated.ts +0 -77
  190. package/src/compiler/vite.ts +0 -245
  191. package/src/devserver/analytics/index.ts +0 -317
  192. package/src/devserver/config/dotenv.ts +0 -102
  193. package/src/devserver/config/env.ts +0 -30
  194. package/src/devserver/config/ratelimit.ts +0 -123
  195. package/src/devserver/daemon/catalog.ts +0 -120
  196. package/src/devserver/daemon/cron.ts +0 -87
  197. package/src/devserver/daemon/host.ts +0 -134
  198. package/src/devserver/daemon/index.ts +0 -369
  199. package/src/devserver/daemon/runtime.ts +0 -48
  200. package/src/devserver/db/catalog.ts +0 -109
  201. package/src/devserver/db/database.ts +0 -1834
  202. package/src/devserver/db/derives.ts +0 -121
  203. package/src/devserver/db/index.ts +0 -20
  204. package/src/devserver/db/routeKinds.ts +0 -191
  205. package/src/devserver/db/types.ts +0 -168
  206. package/src/devserver/email/caps.ts +0 -0
  207. package/src/devserver/email/config.ts +0 -141
  208. package/src/devserver/email/console.ts +0 -348
  209. package/src/devserver/email/index.ts +0 -155
  210. package/src/devserver/email/providers.ts +0 -131
  211. package/src/devserver/email/status.ts +0 -23
  212. package/src/devserver/email/validate.ts +0 -37
  213. package/src/devserver/email/wire.ts +0 -55
  214. package/src/devserver/http/cache.ts +0 -0
  215. package/src/devserver/http/envelope.ts +0 -157
  216. package/src/devserver/http/proxy.ts +0 -172
  217. package/src/devserver/http/runtime.ts +0 -387
  218. package/src/devserver/index.ts +0 -43
  219. package/src/devserver/production-ipc.ts +0 -63
  220. package/src/devserver/production-worker.ts +0 -83
  221. package/src/devserver/production.ts +0 -718
  222. package/src/devserver/runtime/crypto.ts +0 -587
  223. package/src/devserver/runtime/host.ts +0 -427
  224. package/src/devserver/runtime/module.ts +0 -442
  225. package/src/devserver/server.ts +0 -251
  226. package/src/devserver/ssr.ts +0 -208
  227. package/src/devserver/stream/catalog.ts +0 -106
  228. package/src/devserver/stream/host.ts +0 -42
  229. package/src/devserver/stream/index.ts +0 -301
  230. package/src/devserver/stream/manager.ts +0 -167
  231. package/src/devserver/stream/router.ts +0 -101
  232. package/src/devserver/stream/wire.ts +0 -58
  233. package/src/devserver/stream/ws.ts +0 -78
  234. package/src/devserver/wasm/sections.ts +0 -59
  235. package/src/devserver/wasm/surface.ts +0 -115
  236. package/src/io/FastMap.ts +0 -151
  237. package/src/io/FastSet.ts +0 -113
  238. package/src/io/codec.ts +0 -362
  239. package/src/io/index.ts +0 -10
  240. package/src/io/lengths.ts +0 -14
  241. package/src/io/types.ts +0 -19
  242. package/src/logger/index.ts +0 -22
  243. package/src/shared/index.ts +0 -46
  244. package/test/analytics-dev.test.ts +0 -113
  245. package/test/assembly/aspect-shim.ts +0 -33
  246. package/test/assembly/cookie.spec.ts +0 -303
  247. package/test/assembly/example.spec.ts +0 -22
  248. package/test/assembly/ssr.spec.ts +0 -95
  249. package/test/assembly.test.ts +0 -59
  250. package/test/auth-emails.test.ts +0 -118
  251. package/test/built-ssr.test.ts +0 -148
  252. package/test/channel.test.ts +0 -21
  253. package/test/config-host-port.test.ts +0 -59
  254. package/test/configure.test.ts +0 -94
  255. package/test/daemon-build.test.ts +0 -278
  256. package/test/daemon-catalog.test.ts +0 -349
  257. package/test/daemon-emulation.test.ts +0 -256
  258. package/test/db.test.ts +0 -0
  259. package/test/devserver-database.test.ts +0 -1102
  260. package/test/devserver-email.test.ts +0 -306
  261. package/test/devserver-pqauth.test.ts +0 -93
  262. package/test/devserver-secrets.test.ts +0 -63
  263. package/test/devserver.test.ts +0 -259
  264. package/test/doctor.test.ts +0 -298
  265. package/test/dom/Image.test.tsx +0 -144
  266. package/test/dom/Link.test.tsx +0 -67
  267. package/test/dom/NavLink.test.tsx +0 -37
  268. package/test/dom/Script.test.tsx +0 -48
  269. package/test/dom/action.test.tsx +0 -146
  270. package/test/dom/error-overlay.test.tsx +0 -44
  271. package/test/dom/loader.test.tsx +0 -121
  272. package/test/dom/navigation.test.ts +0 -59
  273. package/test/dom/revalidate.test.tsx +0 -38
  274. package/test/dom/route-head.test.tsx +0 -56
  275. package/test/dom/router-loading.test.tsx +0 -44
  276. package/test/dom/scroll.test.ts +0 -56
  277. package/test/dom/slot.test.tsx +0 -131
  278. package/test/dom/use-metadata.test.tsx +0 -58
  279. package/test/dom/view-transitions.test.tsx +0 -53
  280. package/test/dynamic-routing.test.ts +0 -132
  281. package/test/email-console.test.ts +0 -86
  282. package/test/email-preview.test.ts +0 -73
  283. package/test/emails.test.ts +0 -58
  284. package/test/errors.test.ts +0 -21
  285. package/test/example-guestbook.test.ts +0 -123
  286. package/test/features.test.ts +0 -149
  287. package/test/fixtures/bignum-wire/spec.ts +0 -25
  288. package/test/fixtures/daemon-app.ts +0 -65
  289. package/test/fixtures/stream-echo.ts +0 -26
  290. package/test/fixtures/stream-gate.ts +0 -28
  291. package/test/fixtures/stream-trap.ts +0 -18
  292. package/test/fixtures/stream-typed.ts +0 -41
  293. package/test/fonts.test.ts +0 -28
  294. package/test/global-setup.ts +0 -17
  295. package/test/head.test.ts +0 -42
  296. package/test/image-blur.test.ts +0 -50
  297. package/test/io.test.ts +0 -117
  298. package/test/metadata.test.ts +0 -40
  299. package/test/navlink.test.ts +0 -28
  300. package/test/pages.test.ts +0 -105
  301. package/test/placeholder.test.ts +0 -9
  302. package/test/pqauth-e2e.test.ts +0 -216
  303. package/test/pqauth-email.test.ts +0 -801
  304. package/test/prerender.test.ts +0 -116
  305. package/test/prettier-plugin.test.ts +0 -46
  306. package/test/routes.test.ts +0 -76
  307. package/test/rpc-bignum-wire.test.ts +0 -164
  308. package/test/rpc-dispatch.test.ts +0 -132
  309. package/test/rpc-kinds.test.ts +0 -18
  310. package/test/rpc.test.ts +0 -66
  311. package/test/search.test.ts +0 -114
  312. package/test/seo.test.ts +0 -175
  313. package/test/slot-layouts.test.ts +0 -69
  314. package/test/sri.test.ts +0 -129
  315. package/test/ssg.test.ts +0 -36
  316. package/test/ssr-hydration.test.tsx +0 -122
  317. package/test/ssr-render.test.ts +0 -197
  318. package/test/ssr-template.test.tsx +0 -482
  319. package/test/stream-emulation.test.ts +0 -479
  320. package/test/ui.test.ts +0 -40
  321. package/test/update.test.ts +0 -58
  322. package/test/validate.test.ts +0 -42
  323. package/test/version-check.test.ts +0 -130
  324. package/tests/data-parity/generated-parity.ts +0 -99
  325. package/tests/data-parity/parity.ts +0 -80
  326. package/tests/data-parity/spec.ts +0 -46
@@ -1,415 +0,0 @@
1
- import type { ScannedRoute } from './routes.js';
2
-
3
- /**
4
- * Build-time SEO for the (otherwise JS-only) SPA: bakes site-level metadata into the HTML `<head>`
5
- * so JS-less crawlers and AI bots see real tags, and generates `robots.txt`, `sitemap.xml`, and
6
- * `llms.txt`. All pure string builders here; `generate.ts` wires them into the build output.
7
- */
8
-
9
- /**
10
- * OpenGraph defaults baked into the HTML, read by Facebook, Discord, Slack, LinkedIn, GitHub, and
11
- * most link-preview crawlers. `image` should be an absolute URL (≥1200×630 for a large card).
12
- */
13
- export interface SeoOpenGraph {
14
- readonly title?: string;
15
- readonly description?: string;
16
- /** `og:type`, e.g. `'website'` or `'article'`. */
17
- readonly type?: string;
18
- readonly siteName?: string;
19
- /** `og:locale`, e.g. `'en_US'`. */
20
- readonly locale?: string;
21
- /** `og:image`, the preview image (absolute URL). */
22
- readonly image?: string;
23
- /** `og:image:alt`. */
24
- readonly imageAlt?: string;
25
- /** `og:image:width` in px (helps Facebook/LinkedIn render without a re-fetch). */
26
- readonly imageWidth?: number;
27
- /** `og:image:height` in px. */
28
- readonly imageHeight?: number;
29
- /** `og:image:type`, e.g. `'image/png'`. */
30
- readonly imageType?: string;
31
- }
32
-
33
- /** Twitter / X card. Unset fields fall back to the OpenGraph / top-level values. */
34
- export interface SeoTwitter {
35
- /** `'summary'` | `'summary_large_image'` | … Defaults by whether an image is present. */
36
- readonly card?: string;
37
- readonly site?: string;
38
- readonly creator?: string;
39
- readonly title?: string;
40
- readonly description?: string;
41
- readonly image?: string;
42
- readonly imageAlt?: string;
43
- }
44
-
45
- /** A `robots.txt` group. */
46
- export interface RobotsRule {
47
- readonly userAgent?: string | readonly string[];
48
- readonly allow?: readonly string[];
49
- readonly disallow?: readonly string[];
50
- }
51
-
52
- /** `robots.txt` configuration. */
53
- export interface RobotsConfig {
54
- readonly rules?: readonly RobotsRule[];
55
- /** How to treat known AI crawlers (GPTBot, ClaudeBot, Google-Extended, …). Default `'allow'`. */
56
- readonly ai?: 'allow' | 'disallow';
57
- /** Explicit `Sitemap:` URL (defaults to `<url>/sitemap.xml` when `seo.url` is set). */
58
- readonly sitemap?: string;
59
- }
60
-
61
- /** A page listed in `llms.txt`. */
62
- export interface LlmsPage {
63
- readonly title: string;
64
- readonly url: string;
65
- readonly description?: string;
66
- }
67
-
68
- /** `llms.txt` configuration (the AI-crawler guidance file). */
69
- export interface LlmsConfig {
70
- readonly title?: string;
71
- readonly summary?: string;
72
- /** Free-form instructions for AI/LLM crawlers. */
73
- readonly instructions?: string;
74
- /** Key pages; defaults to the site's static routes. */
75
- readonly pages?: readonly LlmsPage[];
76
- }
77
-
78
- /** Build-time SEO configuration (under `client.seo`). */
79
- export interface SeoConfig {
80
- /** Absolute site base URL, e.g. `https://toil.dev`, required for `sitemap.xml` and canonical/OG URLs. */
81
- readonly url?: string;
82
- /** Default document title baked into the HTML. */
83
- readonly title?: string;
84
- /** Default meta description. */
85
- readonly description?: string;
86
- /** Default robots directive, e.g. `'index, follow'`. */
87
- readonly robotsMeta?: string;
88
- /** `<meta name="theme-color">`, also the accent color of Discord/Slack link embeds. */
89
- readonly themeColor?: string;
90
- readonly openGraph?: SeoOpenGraph;
91
- readonly twitter?: SeoTwitter;
92
- /** Facebook-specific tags (`fb:app_id`). OpenGraph above covers the rest of the FB card. */
93
- readonly facebook?: { readonly appId?: string };
94
- /** JSON-LD structured data injected as `<script type="application/ld+json">`. */
95
- readonly jsonLd?: Record<string, unknown> | readonly Record<string, unknown>[];
96
- /** Origins to `<link rel="preconnect">` (early connection hints). */
97
- readonly preconnect?: readonly string[];
98
- /** Origins to `<link rel="dns-prefetch">`. */
99
- readonly dnsPrefetch?: readonly string[];
100
- /** `robots.txt` generation; `false` to skip. */
101
- readonly robots?: RobotsConfig | false;
102
- /** `sitemap.xml` generation; defaults to on when `url` is set. */
103
- readonly sitemap?: boolean;
104
- /** `llms.txt` (AI guidance) generation; `false` to skip, `true`/object to configure. */
105
- readonly llms?: LlmsConfig | boolean;
106
- }
107
-
108
- /** Known AI / LLM crawler user-agents, for explicit allow/disallow in `robots.txt`. */
109
- const AI_CRAWLERS: readonly string[] = [
110
- 'GPTBot',
111
- 'OAI-SearchBot',
112
- 'ChatGPT-User',
113
- 'ClaudeBot',
114
- 'Claude-Web',
115
- 'anthropic-ai',
116
- 'Google-Extended',
117
- 'PerplexityBot',
118
- 'CCBot',
119
- 'Applebot-Extended',
120
- 'Bytespider',
121
- 'Amazonbot',
122
- 'Meta-ExternalAgent',
123
- ];
124
-
125
- /** Escapes a value for use inside a double-quoted HTML attribute (prevents attribute-breakout XSS). */
126
- export function escapeAttr(value: string): string {
127
- return value
128
- .replace(/&/g, '&amp;')
129
- .replace(/"/g, '&quot;')
130
- .replace(/'/g, '&#39;')
131
- .replace(/</g, '&lt;')
132
- .replace(/>/g, '&gt;');
133
- }
134
- /** Escapes a value for HTML text content (e.g. `<title>`, XML text). */
135
- export function escapeHtml(value: string): string {
136
- return value.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
137
- }
138
- /**
139
- * Neutralizes a string for safe single-line Markdown interpolation (e.g. `llms.txt`): collapses
140
- * newlines/control chars to a space and backslash-escapes the characters that would break out of a
141
- * `[text](url)` link or start a new block (`[ ] ( ) < > \``). Without this, a page title/description
142
- * from `generateMetadata` could inject extra list items, links, or headings.
143
- */
144
- function escapeMarkdownInline(value: string): string {
145
- return value
146
- .replace(/[\r\n\t\f\v]+/g, ' ')
147
- .replace(/[\\[\]()<>`]/g, (c) => `\\${c}`)
148
- .trim();
149
- }
150
- /** Escapes a URL for a Markdown link target: strips whitespace/control chars and parens. */
151
- function escapeMarkdownUrl(value: string): string {
152
- return value.replace(/\s+/g, '').replace(/[()<>]/g, encodeURIComponent);
153
- }
154
- /**
155
- * Serializes a value for embedding in an inline `<script>` (JSON-LD). Escapes `<`, `>`, and `&`,
156
- * which neutralizes `</script>` and `<!--` (the only HTML-significant sequences inside a script),
157
- * so attacker-controlled data can't break out of the script element.
158
- */
159
- function escapeJsonForScript(value: unknown): string {
160
- return JSON.stringify(value)
161
- .replace(/</g, '\\u003c')
162
- .replace(/>/g, '\\u003e')
163
- .replace(/&/g, '\\u0026');
164
- }
165
- function meta(attrs: Record<string, string | number | undefined>): string {
166
- const pairs = Object.entries(attrs)
167
- .filter((entry): entry is [string, string | number] => entry[1] !== undefined)
168
- .map(([k, v]) => `${k}="${escapeAttr(String(v))}"`);
169
- return ` <meta ${pairs.join(' ')} />`;
170
- }
171
-
172
- /** Joins a base URL and a route path into a clean absolute URL. */
173
- export function joinUrl(base: string, path: string): string {
174
- return `${base.replace(/\/+$/, '')}/${path.replace(/^\/+/, '')}`.replace(/\/$/, '') || base;
175
- }
176
-
177
- /** Static (parameter-free) route patterns, the ones that can be listed in a sitemap. */
178
- function staticPaths(routes: readonly ScannedRoute[]): string[] {
179
- return routes
180
- .filter((r) => r.slot === undefined && !r.intercept && !/[:*]/.test(r.pattern))
181
- .map((r) => r.pattern)
182
- .sort();
183
- }
184
-
185
- /** The site-level `<head>` fragment baked into the built HTML (title is handled separately). */
186
- export function seoHeadTags(seo: SeoConfig): string {
187
- const lines: string[] = [];
188
- if (seo.description !== undefined)
189
- lines.push(meta({ name: 'description', content: seo.description }));
190
- if (seo.robotsMeta !== undefined) lines.push(meta({ name: 'robots', content: seo.robotsMeta }));
191
- if (seo.themeColor !== undefined)
192
- lines.push(meta({ name: 'theme-color', content: seo.themeColor }));
193
- if (seo.url !== undefined)
194
- lines.push(` <link rel="canonical" href="${escapeAttr(seo.url)}" />`);
195
-
196
- // OpenGraph (also drives Facebook, Discord, Slack, LinkedIn, GitHub previews).
197
- const og = seo.openGraph;
198
- const ogTitle = og?.title ?? seo.title;
199
- const ogDesc = og?.description ?? seo.description;
200
- if (ogTitle !== undefined) lines.push(meta({ property: 'og:title', content: ogTitle }));
201
- if (ogDesc !== undefined) lines.push(meta({ property: 'og:description', content: ogDesc }));
202
- lines.push(meta({ property: 'og:type', content: og?.type ?? 'website' }));
203
- if (seo.url !== undefined) lines.push(meta({ property: 'og:url', content: seo.url }));
204
- if (og?.siteName !== undefined)
205
- lines.push(meta({ property: 'og:site_name', content: og.siteName }));
206
- if (og?.locale !== undefined) lines.push(meta({ property: 'og:locale', content: og.locale }));
207
- if (og?.image !== undefined) {
208
- lines.push(meta({ property: 'og:image', content: og.image }));
209
- if (og.imageAlt !== undefined)
210
- lines.push(meta({ property: 'og:image:alt', content: og.imageAlt }));
211
- if (og.imageType !== undefined)
212
- lines.push(meta({ property: 'og:image:type', content: og.imageType }));
213
- if (og.imageWidth !== undefined)
214
- lines.push(meta({ property: 'og:image:width', content: og.imageWidth }));
215
- if (og.imageHeight !== undefined) {
216
- lines.push(meta({ property: 'og:image:height', content: og.imageHeight }));
217
- }
218
- }
219
- if (seo.facebook?.appId !== undefined) {
220
- lines.push(meta({ property: 'fb:app_id', content: seo.facebook.appId }));
221
- }
222
-
223
- // Twitter / X card. Unset fields fall back to OpenGraph / top-level values.
224
- const tw = seo.twitter;
225
- if (tw) {
226
- const twImage = tw.image ?? og?.image;
227
- const card = tw.card ?? (twImage !== undefined ? 'summary_large_image' : 'summary');
228
- lines.push(meta({ name: 'twitter:card', content: card }));
229
- if (tw.site !== undefined) lines.push(meta({ name: 'twitter:site', content: tw.site }));
230
- if (tw.creator !== undefined)
231
- lines.push(meta({ name: 'twitter:creator', content: tw.creator }));
232
- const twTitle = tw.title ?? ogTitle;
233
- const twDesc = tw.description ?? ogDesc;
234
- if (twTitle !== undefined) lines.push(meta({ name: 'twitter:title', content: twTitle }));
235
- if (twDesc !== undefined)
236
- lines.push(meta({ name: 'twitter:description', content: twDesc }));
237
- if (twImage !== undefined) lines.push(meta({ name: 'twitter:image', content: twImage }));
238
- const twImageAlt = tw.imageAlt ?? og?.imageAlt;
239
- if (twImageAlt !== undefined)
240
- lines.push(meta({ name: 'twitter:image:alt', content: twImageAlt }));
241
- }
242
-
243
- for (const origin of seo.preconnect ?? []) {
244
- lines.push(` <link rel="preconnect" href="${escapeAttr(origin)}" />`);
245
- }
246
- for (const origin of seo.dnsPrefetch ?? []) {
247
- lines.push(` <link rel="dns-prefetch" href="${escapeAttr(origin)}" />`);
248
- }
249
- if (seo.jsonLd !== undefined) {
250
- lines.push(
251
- ` <script type="application/ld+json">${escapeJsonForScript(seo.jsonLd)}</script>`,
252
- );
253
- }
254
- return lines.join('\n');
255
- }
256
-
257
- /** The default document title to bake into the HTML, if any. */
258
- export function seoTitle(seo: SeoConfig): string | undefined {
259
- return seo.title;
260
- }
261
-
262
- /**
263
- * Bakes the SEO `<head>` into an HTML document: replaces the existing `<title>` and `description`
264
- * meta (so they aren't duplicated) and inserts the rest before `</head>`. Used for the shell and,
265
- * per route, by the prerenderer.
266
- */
267
- export function injectSeoHtml(html: string, seo: SeoConfig): string {
268
- let out = html;
269
- const title = seoTitle(seo);
270
- if (title !== undefined) {
271
- const tag = `<title>${escapeHtml(title)}</title>`;
272
- out = /<title>[\s\S]*?<\/title>/i.test(out)
273
- ? out.replace(/<title>[\s\S]*?<\/title>/i, tag)
274
- : out.replace(/<\/head>/i, ` ${tag}\n </head>`);
275
- }
276
- if (seo.description !== undefined) {
277
- out = out.replace(/[ \t]*<meta\s+name=["']description["'][^>]*>\s*\n?/i, '');
278
- }
279
- const tags = seoHeadTags(seo);
280
- if (tags) {
281
- out = out.includes('</head>')
282
- ? out.replace(/<\/head>/i, `${tags}\n </head>`)
283
- : `${tags}\n${out}`;
284
- }
285
- return out;
286
- }
287
-
288
- function asString(value: unknown): string | undefined {
289
- return typeof value === 'string' ? value : undefined;
290
- }
291
- function asRecord(value: unknown): Record<string, unknown> {
292
- return typeof value === 'object' && value !== null ? (value as Record<string, unknown>) : {};
293
- }
294
-
295
- /**
296
- * Overlays a route's extracted `metadata` (title/description/openGraph/…) onto the site-wide
297
- * {@link SeoConfig}, and points the canonical/`og:url` at the route's own URL. The result is what
298
- * the prerenderer bakes into that route's HTML, per-file metadata winning over the site defaults.
299
- */
300
- export function routeSeo(
301
- seo: SeoConfig,
302
- metadata: Record<string, unknown> | null,
303
- pattern: string,
304
- ): SeoConfig {
305
- const routeUrl = seo.url !== undefined ? joinUrl(seo.url, pattern) : undefined;
306
- if (!metadata) return { ...seo, url: routeUrl };
307
- const og = asRecord(metadata.openGraph);
308
- return {
309
- ...seo,
310
- url: asString(metadata.canonical) ?? routeUrl,
311
- title: asString(metadata.title) ?? seo.title,
312
- description: asString(metadata.description) ?? seo.description,
313
- robotsMeta: asString(metadata.robots) ?? seo.robotsMeta,
314
- themeColor: asString(metadata.themeColor) ?? seo.themeColor,
315
- openGraph: {
316
- ...seo.openGraph,
317
- title: asString(og.title) ?? asString(metadata.title) ?? seo.openGraph?.title,
318
- description:
319
- asString(og.description) ??
320
- asString(metadata.description) ??
321
- seo.openGraph?.description,
322
- type: asString(og.type) ?? seo.openGraph?.type,
323
- image: asString(og.image) ?? seo.openGraph?.image,
324
- imageAlt: asString(og.imageAlt) ?? seo.openGraph?.imageAlt,
325
- siteName: asString(og.siteName) ?? seo.openGraph?.siteName,
326
- },
327
- };
328
- }
329
-
330
- /** `robots.txt` contents. */
331
- export function robotsTxt(seo: SeoConfig): string {
332
- if (seo.robots === false) return '';
333
- const cfg: RobotsConfig = seo.robots ?? {};
334
- const blocks: string[] = [];
335
-
336
- const rules = cfg.rules ?? [{ userAgent: '*', allow: ['/'] }];
337
- for (const rule of rules) {
338
- const agents = rule.userAgent === undefined ? ['*'] : [rule.userAgent].flat();
339
- const lines = agents.map((a) => `User-agent: ${a}`);
340
- for (const p of rule.allow ?? []) lines.push(`Allow: ${p}`);
341
- for (const p of rule.disallow ?? []) lines.push(`Disallow: ${p}`);
342
- blocks.push(lines.join('\n'));
343
- }
344
-
345
- const aiDirective = cfg.ai === 'disallow' ? 'Disallow: /' : 'Allow: /';
346
- blocks.push(
347
- ['# AI / LLM crawlers', ...AI_CRAWLERS.map((a) => `User-agent: ${a}\n${aiDirective}`)].join(
348
- '\n\n',
349
- ),
350
- );
351
-
352
- const sitemap =
353
- cfg.sitemap ?? (seo.url !== undefined ? joinUrl(seo.url, 'sitemap.xml') : undefined);
354
- if (sitemap !== undefined) blocks.push(`Sitemap: ${sitemap}`);
355
-
356
- return blocks.join('\n\n') + '\n';
357
- }
358
-
359
- /**
360
- * `sitemap.xml` from the site's static routes plus any `extra` concrete paths (e.g. SSG URLs from
361
- * `generateStaticParams`); requires `seo.url`, empty when no base URL. `extra` is deduped against the
362
- * static paths.
363
- */
364
- export function sitemapXml(
365
- seo: SeoConfig,
366
- routes: readonly ScannedRoute[],
367
- extra: readonly string[] = [],
368
- ): string {
369
- if (seo.url === undefined || seo.sitemap === false) return '';
370
- const paths = [...new Set([...staticPaths(routes), ...extra])];
371
- const urls = paths
372
- .map((p) => ` <url><loc>${escapeHtml(joinUrl(seo.url ?? '', p))}</loc></url>`)
373
- .join('\n');
374
- return `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${urls}\n</urlset>\n`;
375
- }
376
-
377
- /** `llms.txt` (AI-crawler guidance) contents; empty when disabled. */
378
- export function llmsTxt(
379
- seo: SeoConfig,
380
- routes: readonly ScannedRoute[],
381
- pages?: readonly LlmsPage[],
382
- ): string {
383
- if (seo.llms === false) return '';
384
- const cfg: LlmsConfig = seo.llms === true || seo.llms === undefined ? {} : seo.llms;
385
- const title = cfg.title ?? seo.title ?? seo.url ?? 'Site';
386
- const out: string[] = [`# ${title}`];
387
- const summary = cfg.summary ?? seo.description;
388
- if (summary !== undefined) out.push(`\n> ${summary}`);
389
- if (cfg.instructions !== undefined) out.push(`\n${cfg.instructions}`);
390
-
391
- // Page list precedence: an explicit `seo.llms.pages`, else the build-supplied list (every route's
392
- // resolved title/description, including SSG-enumerated dynamic pages), else just the static paths.
393
- const resolvedPages: readonly LlmsPage[] =
394
- cfg.pages ??
395
- pages ??
396
- (seo.url !== undefined
397
- ? staticPaths(routes).map(
398
- (p): LlmsPage => ({
399
- title: p === '/' ? 'Home' : p,
400
- url: joinUrl(seo.url ?? '', p),
401
- }),
402
- )
403
- : []);
404
- if (resolvedPages.length) {
405
- out.push('\n## Pages\n');
406
- for (const page of resolvedPages) {
407
- const title = escapeMarkdownInline(page.title);
408
- const url = escapeMarkdownUrl(page.url);
409
- const desc =
410
- page.description !== undefined ? `: ${escapeMarkdownInline(page.description)}` : '';
411
- out.push(`- [${title}](${url})${desc}`);
412
- }
413
- }
414
- return out.join('\n') + '\n';
415
- }
@@ -1,144 +0,0 @@
1
- import { createHash } from 'node:crypto';
2
- import fs from 'node:fs';
3
- import path from 'node:path';
4
-
5
- import type { Plugin } from 'vite';
6
-
7
- import { type ResolvedToilConfig } from './config.js';
8
-
9
- /** SHA-384 Subresource-Integrity token (`sha384-<base64>`) for a local file, or `null` if it can't
10
- * be read. Memoized per absolute path (the same asset is referenced by many baked pages). */
11
- function computeSri(absPath: string, cache: Map<string, string | null>): string | null {
12
- const cached = cache.get(absPath);
13
- if (cached !== undefined) return cached;
14
- let sri: string | null;
15
- try {
16
- sri = `sha384-${createHash('sha384').update(fs.readFileSync(absPath)).digest('base64')}`;
17
- } catch {
18
- sri = null;
19
- }
20
- cache.set(absPath, sri);
21
- return sri;
22
- }
23
-
24
- /** Resolve a tag URL to a path relative to `outDir`, or `null` if it is not a local build asset
25
- * (an absolute scheme, protocol-relative, `data:`, or a traversal are all skipped). */
26
- function toLocalAsset(url: string, base: string): string | null {
27
- const clean = url.split('?')[0].split('#')[0];
28
- // scheme (`https:`, `data:`), protocol-relative (`//cdn`), or empty -> not ours to hash.
29
- if (clean === '' || /^[a-z][a-z0-9+.-]*:/i.test(clean) || clean.startsWith('//')) return null;
30
- let rel = clean;
31
- if (base && base !== '/' && rel.startsWith(base)) rel = rel.slice(base.length);
32
- else if (rel.startsWith('/')) rel = rel.slice(1);
33
- else if (rel.startsWith('./')) rel = rel.slice(2);
34
- if (rel === '' || rel.includes('..')) return null;
35
- return rel;
36
- }
37
-
38
- /**
39
- * Add Subresource Integrity to every LOCAL `<script src>`, `<link rel="modulepreload" href>`, and
40
- * `<link rel="stylesheet" href>` in `html`: an `integrity="sha384-…"` computed from the asset's
41
- * bytes plus `crossorigin="anonymous"` (required for the browser to verify + a no-op same-origin).
42
- * External URLs, inline scripts, non-script/style links, and tags that already carry `integrity`
43
- * are left untouched. Vite content-hashes these assets, so the integrity is stable and matches the
44
- * immutable-cached bytes the edge serves.
45
- */
46
- export function injectSri(html: string, outDir: string, base: string): string {
47
- const cache = new Map<string, string | null>();
48
- return html.replace(/<(script|link)\b([^>]*)>/gi, (full, tag: string, rawAttrs: string) => {
49
- // `(?<![\w-])` (not `\b`) so a hyphenated decoy attr never counts as the real one: `\bintegrity`
50
- // matches inside `data-integrity` (would wrongly skip us), `\bsrc` inside `data-src` (would hash
51
- // the wrong URL). The guard requires the attr name to start at a real attribute boundary.
52
- if (/(?<![\w-])integrity\s*=/i.test(rawAttrs)) return full; // already integrity-tagged
53
- const selfClose = /\/\s*$/.test(rawAttrs);
54
- const attrs = rawAttrs.replace(/\s*\/\s*$/, '');
55
- const isScript = tag.toLowerCase() === 'script';
56
- if (!isScript) {
57
- const rel = /(?<![\w-])rel\s*=\s*["']?([^"'\s>]+)/i.exec(attrs)?.[1]?.toLowerCase();
58
- if (rel !== 'stylesheet' && rel !== 'modulepreload') return full;
59
- }
60
- const attr = isScript ? 'src' : 'href';
61
- const url = new RegExp(`(?<![\\w-])${attr}\\s*=\\s*["']([^"']+)["']`, 'i').exec(attrs)?.[1];
62
- if (url === undefined) return full; // inline script or no url
63
- const rel = toLocalAsset(url, base);
64
- if (rel === null) return full;
65
- const sri = computeSri(path.join(outDir, rel), cache);
66
- if (sri === null) return full;
67
- const cross = /(?<![\w-])crossorigin(?![\w-])/i.test(attrs) ? '' : ' crossorigin="anonymous"';
68
- return `<${tag}${attrs} integrity="${sri}"${cross}${selfClose ? ' /' : ''}>`;
69
- });
70
- }
71
-
72
- /**
73
- * Build an `<script type="importmap">` tag whose `integrity` section maps every emitted JS chunk
74
- * (`assets/*.js`) to its sha384. Tag-level SRI only protects the ENTRY script: the rest of the
75
- * module graph -- static imports (the react vendor chunk) and lazily `import()`ed route chunks --
76
- * is fetched by the module loader, which does NOT inherit the tag's integrity. The import map's
77
- * `integrity` field is the platform mechanism that extends verification to those fetches; browsers
78
- * without support ignore the key (progressive enhancement, nothing breaks). Returns `null` when
79
- * there are no chunks.
80
- */
81
- export function buildImportMapIntegrity(
82
- outDir: string,
83
- base: string,
84
- cache: Map<string, string | null> = new Map(),
85
- ): string | null {
86
- const assetsDir = path.join(outDir, 'assets');
87
- let names: string[];
88
- try {
89
- names = fs.readdirSync(assetsDir).filter((n) => n.endsWith('.js'));
90
- } catch {
91
- return null;
92
- }
93
- const prefix = base.endsWith('/') ? base : `${base}/`;
94
- const integrity: Record<string, string> = {};
95
- for (const name of names.sort()) {
96
- const sri = computeSri(path.join(assetsDir, name), cache);
97
- if (sri !== null) integrity[`${prefix}assets/${name}`] = sri;
98
- }
99
- if (Object.keys(integrity).length === 0) return null;
100
- return `<script type="importmap">${JSON.stringify({ integrity })}</script>`;
101
- }
102
-
103
- /** Insert the import-map tag where it takes effect: right after `<head>` (an import map must be
104
- * parsed before any module load). Falls back to prepending before the first `<script`. No-op if
105
- * an integrity import map is already present (idempotent on an already-processed shell). */
106
- export function injectImportMap(html: string, importMapTag: string): string {
107
- if (html.includes('<script type="importmap">{"integrity"')) return html;
108
- const head = /<head[^>]*>/i.exec(html);
109
- if (head !== null) {
110
- const at = head.index + head[0].length;
111
- return `${html.slice(0, at)}\n ${importMapTag}${html.slice(at)}`;
112
- }
113
- const script = html.indexOf('<script');
114
- if (script !== -1) return `${html.slice(0, script)}${importMapTag}\n${html.slice(script)}`;
115
- return html;
116
- }
117
-
118
- /**
119
- * Build-only plugin: after the bundle is written, rewrites the built shell (`outDir/index.html`) so
120
- * (a) every local script/stylesheet/modulepreload TAG carries an `integrity`, and (b) an import map
121
- * `integrity` section covers every emitted JS chunk, so the FULL module graph -- the entry's static
122
- * imports and every dynamically `import()`ed route chunk -- is verified, not just the entry tag. It
123
- * runs in `closeBundle` (assets are on disk, so real bytes are hashed) and is registered BEFORE the
124
- * prerender / SSR-template passes, which bake per-route HTML, bracket templates, and the SSR `.tmpl`
125
- * FROM this shell -- so every emitted page inherits both and the `.tmpl` coherence hash covers them.
126
- * Build-only: the dev server serves un-hashed modules straight from Vite, where SRI is meaningless.
127
- */
128
- export function sriPlugin(cfg: ResolvedToilConfig): Plugin {
129
- return {
130
- name: 'toil:sri',
131
- apply: 'build',
132
- closeBundle() {
133
- const outDir = path.resolve(cfg.root, cfg.outDir);
134
- const shell = path.join(outDir, 'index.html');
135
- if (!fs.existsSync(shell)) return;
136
- const html = fs.readFileSync(shell, 'utf8');
137
- const cache = new Map<string, string | null>();
138
- let out = injectSri(html, outDir, cfg.base);
139
- const importMap = buildImportMapIntegrity(outDir, cfg.base, cache);
140
- if (importMap !== null) out = injectImportMap(out, importMap);
141
- if (out !== html) fs.writeFileSync(shell, out);
142
- },
143
- };
144
- }