toiljs 0.0.7 → 0.0.9

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 (135) hide show
  1. package/build/backend/.tsbuildinfo +1 -1
  2. package/build/cli/.tsbuildinfo +1 -1
  3. package/build/cli/configure.d.ts +1 -0
  4. package/build/cli/configure.js +85 -20
  5. package/build/cli/create.d.ts +1 -0
  6. package/build/cli/create.js +18 -7
  7. package/build/cli/features.d.ts +2 -0
  8. package/build/cli/features.js +22 -0
  9. package/build/cli/index.js +8 -0
  10. package/build/client/.tsbuildinfo +1 -1
  11. package/build/client/components/Form.d.ts +12 -0
  12. package/build/client/components/Form.js +23 -0
  13. package/build/client/components/Image.d.ts +13 -0
  14. package/build/client/components/Image.js +22 -0
  15. package/build/client/components/Script.d.ts +13 -0
  16. package/build/client/components/Script.js +68 -0
  17. package/build/client/components/Slot.d.ts +6 -0
  18. package/build/client/components/Slot.js +6 -0
  19. package/build/client/dev/error-overlay.d.ts +20 -0
  20. package/build/client/dev/error-overlay.js +123 -0
  21. package/build/client/head/head.d.ts +2 -0
  22. package/build/client/head/head.js +17 -2
  23. package/build/client/head/metadata.d.ts +29 -0
  24. package/build/client/head/metadata.js +38 -0
  25. package/build/client/index.d.ts +15 -3
  26. package/build/client/index.js +8 -2
  27. package/build/client/navigation/navigation.d.ts +3 -0
  28. package/build/client/navigation/navigation.js +42 -1
  29. package/build/client/routing/Router.d.ts +1 -0
  30. package/build/client/routing/Router.js +56 -34
  31. package/build/client/routing/action.d.ts +17 -0
  32. package/build/client/routing/action.js +55 -0
  33. package/build/client/routing/hooks.d.ts +1 -0
  34. package/build/client/routing/hooks.js +6 -7
  35. package/build/client/routing/loader.d.ts +10 -2
  36. package/build/client/routing/loader.js +83 -24
  37. package/build/client/routing/mount.d.ts +1 -1
  38. package/build/client/routing/mount.js +12 -4
  39. package/build/client/routing/slot-context.d.ts +2 -0
  40. package/build/client/routing/slot-context.js +2 -0
  41. package/build/client/types.d.ts +1 -0
  42. package/build/compiler/.tsbuildinfo +1 -1
  43. package/build/compiler/config.d.ts +10 -0
  44. package/build/compiler/config.js +5 -1
  45. package/build/compiler/docs.js +26 -26
  46. package/build/compiler/fonts.d.ts +4 -0
  47. package/build/compiler/fonts.js +64 -0
  48. package/build/compiler/generate.js +67 -32
  49. package/build/compiler/image-report.d.ts +2 -0
  50. package/build/compiler/image-report.js +62 -0
  51. package/build/compiler/plugin.js +1 -1
  52. package/build/compiler/prerender.d.ts +7 -0
  53. package/build/compiler/prerender.js +111 -0
  54. package/build/compiler/routes.d.ts +3 -0
  55. package/build/compiler/routes.js +50 -5
  56. package/build/compiler/seo.d.ts +70 -0
  57. package/build/compiler/seo.js +221 -0
  58. package/build/compiler/vite.js +13 -1
  59. package/build/io/.tsbuildinfo +1 -1
  60. package/build/shared/.tsbuildinfo +1 -1
  61. package/examples/basic/client/404.tsx +1 -1
  62. package/examples/basic/client/components/Header.tsx +38 -0
  63. package/examples/basic/client/components/HoneycombBackground.tsx +86 -18
  64. package/examples/basic/client/global-error.tsx +3 -3
  65. package/examples/basic/client/layout.tsx +2 -33
  66. package/examples/basic/client/public/images/test_image.webp +0 -0
  67. package/examples/basic/client/routes/about.tsx +8 -0
  68. package/examples/basic/client/routes/get-started.tsx +1 -1
  69. package/examples/basic/client/routes/index.tsx +8 -1
  70. package/examples/basic/client/routes/io.tsx +1 -1
  71. package/examples/basic/client/routes/loader-demo/index.tsx +29 -1
  72. package/examples/basic/client/routes/test.tsx +8 -0
  73. package/examples/basic/client/styles/main.css +48 -1
  74. package/package.json +8 -6
  75. package/presets/eslint.js +7 -4
  76. package/presets/tsconfig.json +1 -1
  77. package/src/backend/index.ts +1 -1
  78. package/src/cli/configure.ts +102 -21
  79. package/src/cli/create.ts +25 -9
  80. package/src/cli/features.ts +33 -1
  81. package/src/cli/index.ts +10 -1
  82. package/src/cli/ui.ts +1 -1
  83. package/src/cli/validate.ts +1 -1
  84. package/src/client/components/Form.tsx +65 -0
  85. package/src/client/components/Image.tsx +89 -0
  86. package/src/client/components/Script.tsx +113 -0
  87. package/src/client/components/Slot.tsx +21 -0
  88. package/src/client/dev/error-overlay.tsx +197 -0
  89. package/src/client/head/head.ts +28 -3
  90. package/src/client/head/metadata.ts +92 -0
  91. package/src/client/index.ts +20 -3
  92. package/src/client/navigation/Link.tsx +1 -1
  93. package/src/client/navigation/navigation.ts +74 -4
  94. package/src/client/navigation/prefetch.ts +2 -2
  95. package/src/client/routing/Router.tsx +128 -62
  96. package/src/client/routing/action.ts +122 -0
  97. package/src/client/routing/error-boundary.tsx +1 -1
  98. package/src/client/routing/hooks.ts +17 -23
  99. package/src/client/routing/loader.ts +158 -35
  100. package/src/client/routing/mount.tsx +25 -3
  101. package/src/client/routing/slot-context.ts +7 -0
  102. package/src/client/types.ts +6 -4
  103. package/src/compiler/config.ts +40 -3
  104. package/src/compiler/docs.ts +26 -26
  105. package/src/compiler/fonts.ts +87 -0
  106. package/src/compiler/generate.ts +69 -31
  107. package/src/compiler/image-report.ts +85 -0
  108. package/src/compiler/plugin.ts +2 -2
  109. package/src/compiler/prerender.ts +130 -0
  110. package/src/compiler/routes.ts +62 -7
  111. package/src/compiler/seo.ts +356 -0
  112. package/src/compiler/vite.ts +21 -4
  113. package/src/io/FastSet.ts +1 -1
  114. package/src/io/index.ts +1 -1
  115. package/src/io/types.ts +1 -1
  116. package/src/server/index.ts +1 -1
  117. package/src/server/main.ts +1 -1
  118. package/src/shared/index.ts +1 -1
  119. package/test/dom/Image.test.tsx +46 -0
  120. package/test/dom/Script.test.tsx +45 -0
  121. package/test/dom/action.test.tsx +129 -0
  122. package/test/dom/error-overlay.test.tsx +44 -0
  123. package/test/dom/loader.test.tsx +121 -0
  124. package/test/dom/revalidate.test.tsx +38 -0
  125. package/test/dom/route-head.test.tsx +34 -0
  126. package/test/dom/router-loading.test.tsx +44 -0
  127. package/test/dom/slot.test.tsx +109 -0
  128. package/test/dom/view-transitions.test.tsx +51 -0
  129. package/test/features.test.ts +31 -0
  130. package/test/fonts.test.ts +26 -0
  131. package/test/metadata.test.ts +41 -0
  132. package/test/prerender.test.ts +46 -0
  133. package/test/routes.test.ts +20 -1
  134. package/test/seo.test.ts +142 -0
  135. package/examples/basic/client/template.tsx +0 -7
@@ -0,0 +1,356 @@
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
+ 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
+ * Serializes a value for embedding in an inline `<script>` (JSON-LD). Escapes `<`, `>`, and `&`,
140
+ * which neutralizes `</script>` and `<!--` (the only HTML-significant sequences inside a script),
141
+ * so attacker-controlled data can't break out of the script element.
142
+ */
143
+ function escapeJsonForScript(value: unknown): string {
144
+ return JSON.stringify(value)
145
+ .replace(/</g, '\\u003c')
146
+ .replace(/>/g, '\\u003e')
147
+ .replace(/&/g, '\\u0026');
148
+ }
149
+ function meta(attrs: Record<string, string | number | undefined>): string {
150
+ const pairs = Object.entries(attrs)
151
+ .filter((entry): entry is [string, string | number] => entry[1] !== undefined)
152
+ .map(([k, v]) => `${k}="${escapeAttr(String(v))}"`);
153
+ return ` <meta ${pairs.join(' ')} />`;
154
+ }
155
+
156
+ /** Joins a base URL and a route path into a clean absolute URL. */
157
+ export function joinUrl(base: string, path: string): string {
158
+ return `${base.replace(/\/+$/, '')}/${path.replace(/^\/+/, '')}`.replace(/\/$/, '') || base;
159
+ }
160
+
161
+ /** Static (parameter-free) route patterns, the ones that can be listed in a sitemap. */
162
+ function staticPaths(routes: readonly ScannedRoute[]): string[] {
163
+ return routes
164
+ .filter((r) => r.slot === undefined && !r.intercept && !/[:*]/.test(r.pattern))
165
+ .map((r) => r.pattern)
166
+ .sort();
167
+ }
168
+
169
+ /** The site-level `<head>` fragment baked into the built HTML (title is handled separately). */
170
+ export function seoHeadTags(seo: SeoConfig): string {
171
+ const lines: string[] = [];
172
+ if (seo.description !== undefined) lines.push(meta({ name: 'description', content: seo.description }));
173
+ if (seo.robotsMeta !== undefined) lines.push(meta({ name: 'robots', content: seo.robotsMeta }));
174
+ if (seo.themeColor !== undefined) lines.push(meta({ name: 'theme-color', content: seo.themeColor }));
175
+ if (seo.url !== undefined) lines.push(` <link rel="canonical" href="${escapeAttr(seo.url)}" />`);
176
+
177
+ // OpenGraph (also drives Facebook, Discord, Slack, LinkedIn, GitHub previews).
178
+ const og = seo.openGraph;
179
+ const ogTitle = og?.title ?? seo.title;
180
+ const ogDesc = og?.description ?? seo.description;
181
+ if (ogTitle !== undefined) lines.push(meta({ property: 'og:title', content: ogTitle }));
182
+ if (ogDesc !== undefined) lines.push(meta({ property: 'og:description', content: ogDesc }));
183
+ lines.push(meta({ property: 'og:type', content: og?.type ?? 'website' }));
184
+ if (seo.url !== undefined) lines.push(meta({ property: 'og:url', content: seo.url }));
185
+ if (og?.siteName !== undefined) lines.push(meta({ property: 'og:site_name', content: og.siteName }));
186
+ if (og?.locale !== undefined) lines.push(meta({ property: 'og:locale', content: og.locale }));
187
+ if (og?.image !== undefined) {
188
+ lines.push(meta({ property: 'og:image', content: og.image }));
189
+ if (og.imageAlt !== undefined) lines.push(meta({ property: 'og:image:alt', content: og.imageAlt }));
190
+ if (og.imageType !== undefined) lines.push(meta({ property: 'og:image:type', content: og.imageType }));
191
+ if (og.imageWidth !== undefined) lines.push(meta({ property: 'og:image:width', content: og.imageWidth }));
192
+ if (og.imageHeight !== undefined) {
193
+ lines.push(meta({ property: 'og:image:height', content: og.imageHeight }));
194
+ }
195
+ }
196
+ if (seo.facebook?.appId !== undefined) {
197
+ lines.push(meta({ property: 'fb:app_id', content: seo.facebook.appId }));
198
+ }
199
+
200
+ // Twitter / X card. Unset fields fall back to OpenGraph / top-level values.
201
+ const tw = seo.twitter;
202
+ if (tw) {
203
+ const twImage = tw.image ?? og?.image;
204
+ const card = tw.card ?? (twImage !== undefined ? 'summary_large_image' : 'summary');
205
+ lines.push(meta({ name: 'twitter:card', content: card }));
206
+ if (tw.site !== undefined) lines.push(meta({ name: 'twitter:site', content: tw.site }));
207
+ if (tw.creator !== undefined) lines.push(meta({ name: 'twitter:creator', content: tw.creator }));
208
+ const twTitle = tw.title ?? ogTitle;
209
+ const twDesc = tw.description ?? ogDesc;
210
+ if (twTitle !== undefined) lines.push(meta({ name: 'twitter:title', content: twTitle }));
211
+ if (twDesc !== undefined) lines.push(meta({ name: 'twitter:description', content: twDesc }));
212
+ if (twImage !== undefined) lines.push(meta({ name: 'twitter:image', content: twImage }));
213
+ const twImageAlt = tw.imageAlt ?? og?.imageAlt;
214
+ if (twImageAlt !== undefined) lines.push(meta({ name: 'twitter:image:alt', content: twImageAlt }));
215
+ }
216
+
217
+ for (const origin of seo.preconnect ?? []) {
218
+ lines.push(` <link rel="preconnect" href="${escapeAttr(origin)}" />`);
219
+ }
220
+ for (const origin of seo.dnsPrefetch ?? []) {
221
+ lines.push(` <link rel="dns-prefetch" href="${escapeAttr(origin)}" />`);
222
+ }
223
+ if (seo.jsonLd !== undefined) {
224
+ lines.push(` <script type="application/ld+json">${escapeJsonForScript(seo.jsonLd)}</script>`);
225
+ }
226
+ return lines.join('\n');
227
+ }
228
+
229
+ /** The default document title to bake into the HTML, if any. */
230
+ export function seoTitle(seo: SeoConfig): string | undefined {
231
+ return seo.title;
232
+ }
233
+
234
+ /**
235
+ * Bakes the SEO `<head>` into an HTML document: replaces the existing `<title>` and `description`
236
+ * meta (so they aren't duplicated) and inserts the rest before `</head>`. Used for the shell and,
237
+ * per route, by the prerenderer.
238
+ */
239
+ export function injectSeoHtml(html: string, seo: SeoConfig): string {
240
+ let out = html;
241
+ const title = seoTitle(seo);
242
+ if (title !== undefined) {
243
+ const tag = `<title>${escapeHtml(title)}</title>`;
244
+ out = /<title>[\s\S]*?<\/title>/i.test(out)
245
+ ? out.replace(/<title>[\s\S]*?<\/title>/i, tag)
246
+ : out.replace(/<\/head>/i, ` ${tag}\n </head>`);
247
+ }
248
+ if (seo.description !== undefined) {
249
+ out = out.replace(/[ \t]*<meta\s+name=["']description["'][^>]*>\s*\n?/i, '');
250
+ }
251
+ const tags = seoHeadTags(seo);
252
+ if (tags) {
253
+ out = out.includes('</head>') ? out.replace(/<\/head>/i, `${tags}\n </head>`) : `${tags}\n${out}`;
254
+ }
255
+ return out;
256
+ }
257
+
258
+ function asString(value: unknown): string | undefined {
259
+ return typeof value === 'string' ? value : undefined;
260
+ }
261
+ function asRecord(value: unknown): Record<string, unknown> {
262
+ return typeof value === 'object' && value !== null ? (value as Record<string, unknown>) : {};
263
+ }
264
+
265
+ /**
266
+ * Overlays a route's extracted `metadata` (title/description/openGraph/…) onto the site-wide
267
+ * {@link SeoConfig}, and points the canonical/`og:url` at the route's own URL. The result is what
268
+ * the prerenderer bakes into that route's HTML, per-file metadata winning over the site defaults.
269
+ */
270
+ export function routeSeo(
271
+ seo: SeoConfig,
272
+ metadata: Record<string, unknown> | null,
273
+ pattern: string,
274
+ ): SeoConfig {
275
+ const routeUrl = seo.url !== undefined ? joinUrl(seo.url, pattern) : undefined;
276
+ if (!metadata) return { ...seo, url: routeUrl };
277
+ const og = asRecord(metadata.openGraph);
278
+ return {
279
+ ...seo,
280
+ url: asString(metadata.canonical) ?? routeUrl,
281
+ title: asString(metadata.title) ?? seo.title,
282
+ description: asString(metadata.description) ?? seo.description,
283
+ robotsMeta: asString(metadata.robots) ?? seo.robotsMeta,
284
+ themeColor: asString(metadata.themeColor) ?? seo.themeColor,
285
+ openGraph: {
286
+ ...seo.openGraph,
287
+ title: asString(og.title) ?? asString(metadata.title) ?? seo.openGraph?.title,
288
+ description: asString(og.description) ?? asString(metadata.description) ?? seo.openGraph?.description,
289
+ type: asString(og.type) ?? seo.openGraph?.type,
290
+ image: asString(og.image) ?? seo.openGraph?.image,
291
+ imageAlt: asString(og.imageAlt) ?? seo.openGraph?.imageAlt,
292
+ siteName: asString(og.siteName) ?? seo.openGraph?.siteName,
293
+ },
294
+ };
295
+ }
296
+
297
+ /** `robots.txt` contents. */
298
+ export function robotsTxt(seo: SeoConfig): string {
299
+ if (seo.robots === false) return '';
300
+ const cfg: RobotsConfig = seo.robots ?? {};
301
+ const blocks: string[] = [];
302
+
303
+ const rules = cfg.rules ?? [{ userAgent: '*', allow: ['/'] }];
304
+ for (const rule of rules) {
305
+ const agents = rule.userAgent === undefined ? ['*'] : [rule.userAgent].flat();
306
+ const lines = agents.map((a) => `User-agent: ${a}`);
307
+ for (const p of rule.allow ?? []) lines.push(`Allow: ${p}`);
308
+ for (const p of rule.disallow ?? []) lines.push(`Disallow: ${p}`);
309
+ blocks.push(lines.join('\n'));
310
+ }
311
+
312
+ const aiDirective = cfg.ai === 'disallow' ? 'Disallow: /' : 'Allow: /';
313
+ blocks.push(
314
+ ['# AI / LLM crawlers', ...AI_CRAWLERS.map((a) => `User-agent: ${a}\n${aiDirective}`)].join('\n\n'),
315
+ );
316
+
317
+ const sitemap = cfg.sitemap ?? (seo.url !== undefined ? joinUrl(seo.url, 'sitemap.xml') : undefined);
318
+ if (sitemap !== undefined) blocks.push(`Sitemap: ${sitemap}`);
319
+
320
+ return blocks.join('\n\n') + '\n';
321
+ }
322
+
323
+ /** `sitemap.xml` from the site's static routes (requires `seo.url`); empty when no base URL. */
324
+ export function sitemapXml(seo: SeoConfig, routes: readonly ScannedRoute[]): string {
325
+ if (seo.url === undefined || seo.sitemap === false) return '';
326
+ const urls = staticPaths(routes)
327
+ .map((p) => ` <url><loc>${escapeHtml(joinUrl(seo.url ?? '', p))}</loc></url>`)
328
+ .join('\n');
329
+ return `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${urls}\n</urlset>\n`;
330
+ }
331
+
332
+ /** `llms.txt` (AI-crawler guidance) contents; empty when disabled. */
333
+ export function llmsTxt(seo: SeoConfig, routes: readonly ScannedRoute[]): string {
334
+ if (seo.llms === false) return '';
335
+ const cfg: LlmsConfig = seo.llms === true || seo.llms === undefined ? {} : seo.llms;
336
+ const title = cfg.title ?? seo.title ?? seo.url ?? 'Site';
337
+ const out: string[] = [`# ${title}`];
338
+ const summary = cfg.summary ?? seo.description;
339
+ if (summary !== undefined) out.push(`\n> ${summary}`);
340
+ if (cfg.instructions !== undefined) out.push(`\n${cfg.instructions}`);
341
+
342
+ const pages: readonly LlmsPage[] =
343
+ cfg.pages ??
344
+ (seo.url !== undefined
345
+ ? staticPaths(routes).map(
346
+ (p): LlmsPage => ({ title: p === '/' ? 'Home' : p, url: joinUrl(seo.url ?? '', p) }),
347
+ )
348
+ : []);
349
+ if (pages.length) {
350
+ out.push('\n## Pages\n');
351
+ for (const page of pages) {
352
+ out.push(`- [${page.title}](${page.url})${page.description !== undefined ? `: ${page.description}` : ''}`);
353
+ }
354
+ }
355
+ return out.join('\n') + '\n';
356
+ }
@@ -3,11 +3,15 @@ import path from 'node:path';
3
3
  import { pathToFileURL } from 'node:url';
4
4
 
5
5
  import react from '@vitejs/plugin-react';
6
+ import { imagetools } from 'vite-imagetools';
6
7
  import { nodePolyfills } from 'vite-plugin-node-polyfills';
7
8
  import { mergeConfig, type InlineConfig, type PluginOption } from 'vite';
8
9
 
9
10
  import { type ResolvedToilConfig } from './config.js';
11
+ import { fontPreloadPlugin } from './fonts.js';
12
+ import { imageReportPlugin } from './image-report.js';
10
13
  import { toilPlugin } from './plugin.js';
14
+ import { prerenderPlugin } from './prerender.js';
11
15
 
12
16
  /** Image extensions routed to `images/` in the build output. */
13
17
  const IMAGE_EXT = /^(png|jpe?g|svg|gif|tiff|bmp|ico|webp|avif)$/i;
@@ -35,8 +39,7 @@ async function tailwindPlugin(root: string): Promise<PluginOption | undefined> {
35
39
  } catch {
36
40
  return undefined;
37
41
  }
38
- // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- dynamic import() is typed any
39
- const mod: { default?: () => PluginOption } = await import(pathToFileURL(resolved).href);
42
+ const mod = (await import(pathToFileURL(resolved).href)) as { default?: () => PluginOption };
40
43
  return mod.default?.();
41
44
  }
42
45
 
@@ -58,9 +61,9 @@ function manualChunks(id: string): string | undefined {
58
61
  * `index.html` (built from the project's `public/index.html` template) emits at the output root
59
62
  * with assets resolving correctly; static `public/` assets are mirrored to `.toil/public` and
60
63
  * picked up via Vite's default publicDir. `fs.allow` opens the project (for `client/`) and the
61
- * framework runtime. The opinionated default Node polyfills
64
+ * framework runtime. The opinionated default, Node polyfills
62
65
  * (Buffer/global/process), React plugin, toil route plugin, typed asset folders, React chunk
63
- * splitting and tuned build options is applied here; `toiljs/client` is aliased to the
66
+ * splitting and tuned build options, is applied here; `toiljs/client` is aliased to the
64
67
  * runtime, and the user's `client.vite` overrides deep-merge on top.
65
68
  */
66
69
  export async function createViteConfig(cfg: ResolvedToilConfig): Promise<InlineConfig> {
@@ -73,6 +76,20 @@ export async function createViteConfig(cfg: ResolvedToilConfig): Promise<InlineC
73
76
  configFile: false,
74
77
  plugins: [
75
78
  tailwind,
79
+ // Build-time image resize/optimization. Every *imported* raster image is compressed to
80
+ // webp by default (so a plain `<img src={imported}>` is optimized too, not just
81
+ // `Toil.Image`); add `?w=400;800&format=…` to resize or pick a format. `public/` assets
82
+ // referenced by string path are served as-is. Disabled by `client.images: false`.
83
+ cfg.images
84
+ ? imagetools({
85
+ defaultDirectives: () => new URLSearchParams({ format: 'webp', quality: '80' }),
86
+ })
87
+ : undefined,
88
+ cfg.images ? imageReportPlugin(cfg.root, cfg.toilDir) : undefined,
89
+ // Static per-route SEO prerender (build only): bakes each route's metadata into its HTML.
90
+ cfg.seo ? prerenderPlugin(cfg) : undefined,
91
+ // Preload bundled fonts (build only). Disabled by `client.fonts: false`.
92
+ cfg.fonts ? fontPreloadPlugin(cfg) : undefined,
76
93
  nodePolyfills({ globals: { Buffer: true, global: true, process: true } }),
77
94
  react(),
78
95
  toilPlugin(cfg),
package/src/io/FastSet.ts CHANGED
@@ -4,7 +4,7 @@ import { type FastRecord, type IndexKey, type PropertyExtendedKey } from './Fast
4
4
  * The Set counterpart to {@link FastMap}: an insertion-ordered set backed by an array (for
5
5
  * iteration/ordering) plus a record index (for O(1) membership), with bigint-key support.
6
6
  *
7
- * Authored to match FastMap's design the upstream package ships no `FastSet`.
7
+ * Authored to match FastMap's design, the upstream package ships no `FastSet`.
8
8
  */
9
9
  export class FastSet<T extends PropertyExtendedKey> implements Disposable {
10
10
  protected _values: T[] = [];
package/src/io/index.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * toiljs IO native binary serialization + fast collections, exposed to the client both as
2
+ * toiljs IO, native binary serialization + fast collections, exposed to the client both as
3
3
  * `toiljs/io` imports and as ambient globals (see the generated `.toil/toil-env.d.ts`).
4
4
  */
5
5
  export { BinaryWriter } from './BinaryWriter.js';
package/src/io/types.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Branded numeric width aliases used by the binary IO classes. They are plain `number`/`bigint`
3
- * at runtime the names document intent.
3
+ * at runtime, the names document intent.
4
4
  */
5
5
  export type i8 = number;
6
6
  export type i16 = number;
@@ -2,7 +2,7 @@
2
2
  * toilscript server (WASM) entry, compiled to WebAssembly by `toilscript`.
3
3
  *
4
4
  * Placeholder module: a trivial exported function that compiles with the toilscript std.
5
- * Native decorators (e.g. `@main`) ship from toilscript directly no transformer required.
5
+ * Native decorators (e.g. `@main`) ship from toilscript directly, no transformer required.
6
6
  */
7
7
 
8
8
  export function add(a: i32, b: i32): i32 {
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Server (WASM) entry point, compiled by the toilscript fork (`toilscript --target release`).
3
3
  *
4
- * `@main` is a toilscript-native decorator no import needed. It marks this
4
+ * `@main` is a toilscript-native decorator, no import needed. It marks this
5
5
  * function as the module entry; the compiler exports it as the WebAssembly
6
6
  * export `main`.
7
7
  */
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Shared primitives used across every toiljs target (client, compiler, cli, server tooling).
3
- * Placeholder real shared types/utilities land here.
3
+ * Placeholder, real shared types/utilities land here.
4
4
  */
5
5
 
6
6
  export const FRAMEWORK_NAME = 'toiljs';
@@ -0,0 +1,46 @@
1
+ // @vitest-environment jsdom
2
+ import { cleanup, fireEvent, render } from '@testing-library/react';
3
+ import { afterEach, describe, expect, it } from 'vitest';
4
+
5
+ import { Image } from '../../src/client/components/Image';
6
+
7
+ afterEach(cleanup);
8
+
9
+ describe('Image', () => {
10
+ it('lazy-loads and decodes async by default, with the given dimensions', () => {
11
+ const { getByAltText } = render(<Image src="/a.png" alt="a" width={200} height={100} />);
12
+ const img = getByAltText('a') as HTMLImageElement;
13
+ expect(img.getAttribute('src')).toBe('/a.png');
14
+ expect(img.getAttribute('loading')).toBe('lazy');
15
+ expect(img.getAttribute('decoding')).toBe('async');
16
+ expect(img.getAttribute('width')).toBe('200');
17
+ expect(img.getAttribute('height')).toBe('100');
18
+ expect(img.getAttribute('fetchpriority')).toBe('auto');
19
+ });
20
+
21
+ it('priority images load eagerly with high fetch priority', () => {
22
+ const { getByAltText } = render(<Image src="/hero.png" alt="hero" priority />);
23
+ const img = getByAltText('hero') as HTMLImageElement;
24
+ expect(img.getAttribute('loading')).toBe('eager');
25
+ expect(img.getAttribute('fetchpriority')).toBe('high');
26
+ });
27
+
28
+ it('fill drops width/height and absolutely positions the image', () => {
29
+ const { getByAltText } = render(<Image src="/bg.png" alt="bg" fill objectFit="cover" />);
30
+ const img = getByAltText('bg') as HTMLImageElement;
31
+ expect(img.hasAttribute('width')).toBe(false);
32
+ expect(img.hasAttribute('height')).toBe(false);
33
+ expect(img.style.position).toBe('absolute');
34
+ expect(img.style.objectFit).toBe('cover');
35
+ });
36
+
37
+ it('shows a blur placeholder until the image loads', () => {
38
+ const { getByAltText } = render(
39
+ <Image src="/p.png" alt="p" width={10} height={10} placeholder="blur" blurDataURL="data:image/x" />,
40
+ );
41
+ const img = getByAltText('p') as HTMLImageElement;
42
+ expect(img.style.backgroundImage).toContain('data:image/x');
43
+ fireEvent.load(img);
44
+ expect(img.style.backgroundImage).toBe('');
45
+ });
46
+ });
@@ -0,0 +1,45 @@
1
+ // @vitest-environment jsdom
2
+ import { cleanup, render } from '@testing-library/react';
3
+ import { afterEach, describe, expect, it, vi } from 'vitest';
4
+
5
+ import { Script } from '../../src/client/components/Script';
6
+
7
+ afterEach(cleanup);
8
+
9
+ const scriptsFor = (key: string): HTMLScriptElement[] =>
10
+ Array.from(document.querySelectorAll<HTMLScriptElement>(`script[data-toil-script="${key}"]`));
11
+
12
+ describe('Script', () => {
13
+ it('injects an async external script on mount (afterInteractive)', () => {
14
+ render(<Script src="https://cdn.example.com/a.js" />);
15
+ const els = scriptsFor('https://cdn.example.com/a.js');
16
+ expect(els).toHaveLength(1);
17
+ expect(els[0].async).toBe(true);
18
+ });
19
+
20
+ it('dedups: the same src is only injected once across instances', () => {
21
+ const src = 'https://cdn.example.com/dedup.js';
22
+ render(
23
+ <>
24
+ <Script src={src} />
25
+ <Script src={src} />
26
+ </>,
27
+ );
28
+ expect(scriptsFor(src)).toHaveLength(1);
29
+ });
30
+
31
+ it('injects an inline script body and fires onLoad + onReady', () => {
32
+ const onLoad = vi.fn();
33
+ const onReady = vi.fn();
34
+ render(
35
+ <Script id="inline-1" onLoad={onLoad} onReady={onReady}>
36
+ {'window.__toilTest = 1;'}
37
+ </Script>,
38
+ );
39
+ const els = scriptsFor('inline-1');
40
+ expect(els).toHaveLength(1);
41
+ expect(els[0].textContent).toBe('window.__toilTest = 1;');
42
+ expect(onLoad).toHaveBeenCalledOnce();
43
+ expect(onReady).toHaveBeenCalledOnce();
44
+ });
45
+ });