toiljs 0.0.8 → 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 (105) hide show
  1. package/build/backend/.tsbuildinfo +1 -1
  2. package/build/cli/.tsbuildinfo +1 -1
  3. package/build/cli/configure.js +5 -5
  4. package/build/cli/create.js +4 -4
  5. package/build/client/.tsbuildinfo +1 -1
  6. package/build/client/components/Slot.d.ts +6 -0
  7. package/build/client/components/Slot.js +6 -0
  8. package/build/client/dev/error-overlay.d.ts +20 -0
  9. package/build/client/dev/error-overlay.js +123 -0
  10. package/build/client/head/head.d.ts +2 -0
  11. package/build/client/head/head.js +17 -2
  12. package/build/client/head/metadata.d.ts +29 -0
  13. package/build/client/head/metadata.js +38 -0
  14. package/build/client/index.d.ts +5 -1
  15. package/build/client/index.js +3 -1
  16. package/build/client/navigation/navigation.d.ts +3 -0
  17. package/build/client/navigation/navigation.js +42 -1
  18. package/build/client/routing/Router.d.ts +1 -0
  19. package/build/client/routing/Router.js +55 -33
  20. package/build/client/routing/hooks.js +2 -6
  21. package/build/client/routing/loader.d.ts +2 -0
  22. package/build/client/routing/loader.js +9 -1
  23. package/build/client/routing/mount.d.ts +1 -1
  24. package/build/client/routing/mount.js +12 -4
  25. package/build/client/routing/slot-context.d.ts +2 -0
  26. package/build/client/routing/slot-context.js +2 -0
  27. package/build/client/types.d.ts +1 -0
  28. package/build/compiler/.tsbuildinfo +1 -1
  29. package/build/compiler/config.d.ts +8 -0
  30. package/build/compiler/config.js +4 -1
  31. package/build/compiler/docs.js +26 -26
  32. package/build/compiler/fonts.d.ts +4 -0
  33. package/build/compiler/fonts.js +64 -0
  34. package/build/compiler/generate.js +65 -32
  35. package/build/compiler/plugin.js +1 -1
  36. package/build/compiler/prerender.d.ts +7 -0
  37. package/build/compiler/prerender.js +111 -0
  38. package/build/compiler/routes.d.ts +3 -0
  39. package/build/compiler/routes.js +50 -5
  40. package/build/compiler/seo.d.ts +70 -0
  41. package/build/compiler/seo.js +221 -0
  42. package/build/compiler/vite.js +5 -1
  43. package/build/io/.tsbuildinfo +1 -1
  44. package/build/shared/.tsbuildinfo +1 -1
  45. package/examples/basic/client/404.tsx +1 -1
  46. package/examples/basic/client/global-error.tsx +1 -1
  47. package/examples/basic/client/routes/about.tsx +8 -0
  48. package/examples/basic/client/routes/get-started.tsx +1 -1
  49. package/examples/basic/client/routes/io.tsx +1 -1
  50. package/examples/basic/client/routes/loader-demo/index.tsx +7 -2
  51. package/package.json +1 -1
  52. package/presets/eslint.js +7 -4
  53. package/presets/tsconfig.json +1 -1
  54. package/src/backend/index.ts +1 -1
  55. package/src/cli/configure.ts +7 -7
  56. package/src/cli/create.ts +7 -7
  57. package/src/cli/features.ts +2 -2
  58. package/src/cli/index.ts +1 -1
  59. package/src/cli/ui.ts +1 -1
  60. package/src/cli/validate.ts +1 -1
  61. package/src/client/components/Form.tsx +2 -2
  62. package/src/client/components/Image.tsx +2 -2
  63. package/src/client/components/Script.tsx +3 -3
  64. package/src/client/components/Slot.tsx +21 -0
  65. package/src/client/dev/error-overlay.tsx +197 -0
  66. package/src/client/head/head.ts +28 -3
  67. package/src/client/head/metadata.ts +92 -0
  68. package/src/client/index.ts +5 -1
  69. package/src/client/navigation/Link.tsx +1 -1
  70. package/src/client/navigation/navigation.ts +74 -4
  71. package/src/client/navigation/prefetch.ts +2 -2
  72. package/src/client/routing/Router.tsx +121 -67
  73. package/src/client/routing/action.ts +4 -4
  74. package/src/client/routing/error-boundary.tsx +1 -1
  75. package/src/client/routing/hooks.ts +6 -25
  76. package/src/client/routing/loader.ts +20 -8
  77. package/src/client/routing/mount.tsx +25 -3
  78. package/src/client/routing/slot-context.ts +7 -0
  79. package/src/client/types.ts +6 -4
  80. package/src/compiler/config.ts +31 -3
  81. package/src/compiler/docs.ts +26 -26
  82. package/src/compiler/fonts.ts +87 -0
  83. package/src/compiler/generate.ts +66 -31
  84. package/src/compiler/image-report.ts +1 -1
  85. package/src/compiler/plugin.ts +2 -2
  86. package/src/compiler/prerender.ts +130 -0
  87. package/src/compiler/routes.ts +62 -7
  88. package/src/compiler/seo.ts +356 -0
  89. package/src/compiler/vite.ts +9 -4
  90. package/src/io/FastSet.ts +1 -1
  91. package/src/io/index.ts +1 -1
  92. package/src/io/types.ts +1 -1
  93. package/src/server/index.ts +1 -1
  94. package/src/server/main.ts +1 -1
  95. package/src/shared/index.ts +1 -1
  96. package/test/dom/error-overlay.test.tsx +44 -0
  97. package/test/dom/revalidate.test.tsx +38 -0
  98. package/test/dom/route-head.test.tsx +34 -0
  99. package/test/dom/slot.test.tsx +109 -0
  100. package/test/dom/view-transitions.test.tsx +51 -0
  101. package/test/fonts.test.ts +26 -0
  102. package/test/metadata.test.ts +41 -0
  103. package/test/prerender.test.ts +46 -0
  104. package/test/routes.test.ts +20 -1
  105. package/test/seo.test.ts +142 -0
@@ -5,6 +5,10 @@ import path from 'node:path';
5
5
  export interface ScannedRoute {
6
6
  readonly file: string;
7
7
  readonly pattern: string;
8
+ /** Named parallel slot this route belongs to (from an `@slot` dir), or `undefined` for the main tree. */
9
+ readonly slot?: string;
10
+ /** True for an intercepting route (`(.)`/`(..)`/`(...)`), matched only on soft navigation. */
11
+ readonly intercept?: boolean;
8
12
  }
9
13
 
10
14
  const ROUTE_EXT = /\.(tsx|jsx)$/;
@@ -20,7 +24,19 @@ const SPECIAL_FILE = /^(layout|template|loading|error|global-error|404|not-found
20
24
  * docs/[...slug].tsx -> /docs/*slug (catch-all)
21
25
  * docs/[[...slug]].tsx -> /docs/**slug (optional catch-all)
22
26
  * (marketing)/about.tsx -> /about (route group: parens add no URL segment)
27
+ * @modal/photo/[id].tsx -> /photo/:id (parallel slot: `@slot` adds no URL segment)
23
28
  */
29
+ /** Converts a path segment's dynamic brackets to URL params (`[id]`→`:id`, `[...x]`→`*x`, `[[...x]]`→`**x`). */
30
+ function toUrlSegment(segment: string): string {
31
+ return segment
32
+ .replace(/^\[\[\.\.\.(.+)\]\]$/, '**$1')
33
+ .replace(/^\[\.\.\.(.+)\]$/, '*$1')
34
+ .replace(/^\[(.+)\]$/, ':$1');
35
+ }
36
+
37
+ /** Interception markers: `(.)` same level, `(..)` up one, `(...)` from the routes root. */
38
+ const INTERCEPT_RE = /^\((\.{1,3})\)(.+)$/;
39
+
24
40
  export function filePathToRoute(relPath: string): string {
25
41
  const withoutExt = relPath.replace(/\\/g, '/').replace(ROUTE_EXT, '');
26
42
  const segments = withoutExt.split('/').filter(Boolean);
@@ -28,17 +44,43 @@ export function filePathToRoute(relPath: string): string {
28
44
  for (let i = 0; i < segments.length; i++) {
29
45
  const segment = segments[i];
30
46
  if (/^\(.+\)$/.test(segment)) continue;
47
+ if (/^@/.test(segment)) continue; // parallel-slot marker, contributes no URL segment
31
48
  if (segment === 'index' && i === segments.length - 1) continue;
32
- out.push(
33
- segment
34
- .replace(/^\[\[\.\.\.(.+)\]\]$/, '**$1')
35
- .replace(/^\[\.\.\.(.+)\]$/, '*$1')
36
- .replace(/^\[(.+)\]$/, ':$1'),
37
- );
49
+ out.push(toUrlSegment(segment));
38
50
  }
39
51
  return '/' + out.join('/');
40
52
  }
41
53
 
54
+ /**
55
+ * The URL an intercepting route targets, or `null` if the path has no `(.)`/`(..)`/`(...)` marker.
56
+ * The marker resolves the target relative to the route's position (ignoring `@slot`/`(group)`
57
+ * segments): `(.)` keeps the current level, `(..)` drops one, `(...)` resets to the root.
58
+ * @modal/(.)photo/[id].tsx -> /photo/:id
59
+ * feed/@modal/(..)photo/[id].tsx -> /photo/:id
60
+ */
61
+ export function interceptTarget(relPath: string): string | null {
62
+ const segments = relPath.replace(/\\/g, '/').replace(ROUTE_EXT, '').split('/').filter(Boolean);
63
+ const out: string[] = [];
64
+ let marked = false;
65
+ for (let i = 0; i < segments.length; i++) {
66
+ const segment = segments[i];
67
+ if (/^@/.test(segment)) continue;
68
+ const marker = INTERCEPT_RE.exec(segment);
69
+ if (marker) {
70
+ marked = true;
71
+ const dots = marker[1].length;
72
+ if (dots === 2) out.pop(); // (..) up one level
73
+ else if (dots === 3) out.length = 0; // (...) from the routes root
74
+ out.push(toUrlSegment(marker[2]));
75
+ continue;
76
+ }
77
+ if (/^\(.+\)$/.test(segment)) continue;
78
+ if (segment === 'index' && i === segments.length - 1) continue;
79
+ out.push(toUrlSegment(segment));
80
+ }
81
+ return marked ? '/' + out.join('/') : null;
82
+ }
83
+
42
84
  /**
43
85
  * Ranks a pattern so more specific routes match first: static segments beat dynamic (`:x`),
44
86
  * which beat catch-all (`*x`); deeper routes beat shallower ones.
@@ -53,6 +95,15 @@ function specificity(pattern: string): number {
53
95
  return score;
54
96
  }
55
97
 
98
+ /** The parallel-slot name for a route path (the first `@slot` segment), or `undefined`. */
99
+ function slotOf(relPath: string): string | undefined {
100
+ for (const segment of relPath.replace(/\\/g, '/').split('/')) {
101
+ const match = /^@(.+)$/.exec(segment);
102
+ if (match) return match[1];
103
+ }
104
+ return undefined;
105
+ }
106
+
56
107
  /** Recursively scans `routesDir` for `.tsx`/`.jsx` files, returning routes sorted by specificity. */
57
108
  export function scanRoutes(routesDir: string): ScannedRoute[] {
58
109
  if (!fs.existsSync(routesDir)) return [];
@@ -63,9 +114,13 @@ export function scanRoutes(routesDir: string): ScannedRoute[] {
63
114
  if (entry.isDirectory()) {
64
115
  walk(full);
65
116
  } else if (ROUTE_EXT.test(entry.name) && !SPECIAL_FILE.test(entry.name)) {
117
+ const rel = path.relative(routesDir, full);
118
+ const target = interceptTarget(rel);
66
119
  found.push({
67
120
  file: full,
68
- pattern: filePathToRoute(path.relative(routesDir, full)),
121
+ pattern: target ?? filePathToRoute(rel),
122
+ slot: slotOf(rel),
123
+ intercept: target !== null,
69
124
  });
70
125
  }
71
126
  }
@@ -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
+ }
@@ -8,8 +8,10 @@ import { nodePolyfills } from 'vite-plugin-node-polyfills';
8
8
  import { mergeConfig, type InlineConfig, type PluginOption } from 'vite';
9
9
 
10
10
  import { type ResolvedToilConfig } from './config.js';
11
+ import { fontPreloadPlugin } from './fonts.js';
11
12
  import { imageReportPlugin } from './image-report.js';
12
13
  import { toilPlugin } from './plugin.js';
14
+ import { prerenderPlugin } from './prerender.js';
13
15
 
14
16
  /** Image extensions routed to `images/` in the build output. */
15
17
  const IMAGE_EXT = /^(png|jpe?g|svg|gif|tiff|bmp|ico|webp|avif)$/i;
@@ -37,8 +39,7 @@ async function tailwindPlugin(root: string): Promise<PluginOption | undefined> {
37
39
  } catch {
38
40
  return undefined;
39
41
  }
40
- // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- dynamic import() is typed any
41
- const mod: { default?: () => PluginOption } = await import(pathToFileURL(resolved).href);
42
+ const mod = (await import(pathToFileURL(resolved).href)) as { default?: () => PluginOption };
42
43
  return mod.default?.();
43
44
  }
44
45
 
@@ -60,9 +61,9 @@ function manualChunks(id: string): string | undefined {
60
61
  * `index.html` (built from the project's `public/index.html` template) emits at the output root
61
62
  * with assets resolving correctly; static `public/` assets are mirrored to `.toil/public` and
62
63
  * picked up via Vite's default publicDir. `fs.allow` opens the project (for `client/`) and the
63
- * framework runtime. The opinionated default Node polyfills
64
+ * framework runtime. The opinionated default, Node polyfills
64
65
  * (Buffer/global/process), React plugin, toil route plugin, typed asset folders, React chunk
65
- * 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
66
67
  * runtime, and the user's `client.vite` overrides deep-merge on top.
67
68
  */
68
69
  export async function createViteConfig(cfg: ResolvedToilConfig): Promise<InlineConfig> {
@@ -85,6 +86,10 @@ export async function createViteConfig(cfg: ResolvedToilConfig): Promise<InlineC
85
86
  })
86
87
  : undefined,
87
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,
88
93
  nodePolyfills({ globals: { Buffer: true, global: true, process: true } }),
89
94
  react(),
90
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,44 @@
1
+ // @vitest-environment jsdom
2
+ import { act, cleanup, fireEvent, render } from '@testing-library/react';
3
+ import { afterEach, describe, expect, it, vi } from 'vitest';
4
+
5
+ import {
6
+ DevErrorBoundary,
7
+ DevErrorOverlay,
8
+ initDevErrorOverlay,
9
+ } from '../../src/client/dev/error-overlay';
10
+
11
+ afterEach(cleanup);
12
+
13
+ function Boom(): never {
14
+ throw new Error('render boom');
15
+ }
16
+
17
+ describe('dev error overlay', () => {
18
+ it('surfaces an uncaught render error', () => {
19
+ // React logs caught boundary errors to console.error — silence it for a clean test run.
20
+ const spy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
21
+ const { getByRole } = render(
22
+ <>
23
+ <DevErrorBoundary>
24
+ <Boom />
25
+ </DevErrorBoundary>
26
+ <DevErrorOverlay />
27
+ </>,
28
+ );
29
+ expect(getByRole('alert').textContent).toContain('render boom');
30
+ spy.mockRestore();
31
+ });
32
+
33
+ it('surfaces an unhandled window error and dismisses it', async () => {
34
+ initDevErrorOverlay();
35
+ const { findByRole, queryByRole, getByText } = render(<DevErrorOverlay />);
36
+ act(() => {
37
+ window.dispatchEvent(new ErrorEvent('error', { error: new Error('async boom') }));
38
+ });
39
+ const alert = await findByRole('alert');
40
+ expect(alert.textContent).toContain('async boom');
41
+ fireEvent.click(getByText('Dismiss'));
42
+ expect(queryByRole('alert')).toBeNull();
43
+ });
44
+ });
@@ -0,0 +1,38 @@
1
+ // @vitest-environment jsdom
2
+ import { act, cleanup, render } from '@testing-library/react';
3
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
4
+
5
+ import { Router } from '../../src/client/routing/Router';
6
+ import { clearLoaderData, revalidate, useLoaderData } from '../../src/client/routing/loader';
7
+ import type { RouteDef } from '../../src/client/types';
8
+
9
+ afterEach(cleanup);
10
+ beforeEach(() => {
11
+ clearLoaderData();
12
+ window.history.replaceState({}, '', '/');
13
+ });
14
+
15
+ describe('revalidate refetches', () => {
16
+ it('re-runs the loader and updates the rendered data', async () => {
17
+ let n = 0;
18
+ function Page(): React.ReactNode {
19
+ const value = useLoaderData<number>();
20
+ return <p>val:{String(value)}</p>;
21
+ }
22
+ const routes: RouteDef[] = [
23
+ {
24
+ pattern: '/',
25
+ load: () => Promise.resolve({ default: Page, loader: () => (n += 1) }),
26
+ // matches the example: this route has a loading.tsx (keyed boundary + transition).
27
+ loading: () => Promise.resolve({ default: () => <p>loading</p> }),
28
+ },
29
+ ];
30
+ const { findByText } = render(<Router routes={routes} />);
31
+ await findByText('val:1');
32
+
33
+ act(() => {
34
+ revalidate();
35
+ });
36
+ await findByText('val:2');
37
+ });
38
+ });