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
@@ -4,6 +4,10 @@ import { fileURLToPath, pathToFileURL } from 'node:url';
4
4
 
5
5
  import { type InlineConfig } from 'vite';
6
6
 
7
+ import { type SeoConfig } from './seo.js';
8
+
9
+ export type { SeoConfig } from './seo.js';
10
+
7
11
  /**
8
12
  * Client-side (TSX/React/Vite) configuration. All fields optional; sensible defaults applied.
9
13
  */
@@ -30,9 +34,25 @@ export interface ClientConfig {
30
34
  * Default `true`. Set `false` to disable the pipeline (images are then served as-is).
31
35
  */
32
36
  readonly images?: boolean;
37
+ /**
38
+ * Preload bundled fonts at build time: injects `<link rel="preload" as="font">` for each
39
+ * `@font-face` font so it loads in parallel with the CSS (faster text paint). Default `true`.
40
+ */
41
+ readonly fonts?: boolean;
42
+ /**
43
+ * Animate cross-page navigations with the browser View Transitions API (a crossfade by default;
44
+ * add `view-transition-name` in CSS for shared-element transitions). Respects
45
+ * `prefers-reduced-motion`. Default `false`.
46
+ */
47
+ readonly viewTransitions?: boolean;
48
+ /**
49
+ * Build-time SEO: bakes site-level metadata into the HTML `<head>` (so JS-less crawlers and AI
50
+ * bots see real tags) and generates `robots.txt`, `sitemap.xml`, and `llms.txt`. Omit to skip.
51
+ */
52
+ readonly seo?: SeoConfig;
33
53
  /**
34
54
  * Raw Vite escape hatch, deep-merged over the framework's opinionated config.
35
- * This is NOT the client config itself toil owns the Vite setup; use this only
55
+ * This is NOT the client config itself, toil owns the Vite setup; use this only
36
56
  * to override specific Vite options.
37
57
  */
38
58
  readonly vite?: InlineConfig;
@@ -76,6 +96,12 @@ export interface ResolvedToilConfig {
76
96
  readonly port: number;
77
97
  /** Whether build-time image optimization (`vite-imagetools`) is enabled. */
78
98
  readonly images: boolean;
99
+ /** Whether build-time font preloading is enabled. */
100
+ readonly fonts: boolean;
101
+ /** Whether animated View Transitions are enabled for navigation. */
102
+ readonly viewTransitions: boolean;
103
+ /** Build-time SEO config, or `null` when not configured. */
104
+ readonly seo: SeoConfig | null;
79
105
  /** Absolute path to the framework client runtime (`toiljs/client`). */
80
106
  readonly runtimePath: string;
81
107
  readonly vite: InlineConfig;
@@ -112,8 +138,7 @@ export async function loadConfig(
112
138
  for (const name of CONFIG_NAMES) {
113
139
  const candidate = path.join(root, name);
114
140
  if (fs.existsSync(candidate)) {
115
- // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- dynamic import() is typed `any`
116
- const loaded: { default?: ToilConfig } = await import(pathToFileURL(candidate).href);
141
+ const loaded = (await import(pathToFileURL(candidate).href)) as { default?: ToilConfig };
117
142
  if (loaded.default) user = loaded.default;
118
143
  break;
119
144
  }
@@ -137,6 +162,9 @@ export async function loadConfig(
137
162
  base: client.base ?? '/',
138
163
  port: opts.port ?? client.port ?? 3000,
139
164
  images: client.images ?? true,
165
+ fonts: client.fonts ?? true,
166
+ viewTransitions: client.viewTransitions ?? false,
167
+ seo: client.seo ?? null,
140
168
  runtimePath: resolveRuntimePath(),
141
169
  vite: client.vite ?? {},
142
170
  };
@@ -10,18 +10,18 @@ import path from 'node:path';
10
10
  /** Shared body for the per-tool pointer files. */
11
11
  const POINTER_BODY = `# toiljs · AI assistant guide
12
12
 
13
- This is a **toiljs** project a full-stack React framework (React + Vite client, file-based
13
+ This is a **toiljs** project, a full-stack React framework (React + Vite client, file-based
14
14
  routing, and a toilscript→WebAssembly server).
15
15
 
16
16
  **Before editing this project, read the generated documentation in \`.toil/docs/\`.** It describes
17
17
  the conventions you must follow:
18
18
 
19
- - \`.toil/docs/index.md\` overview and project layout
20
- - \`.toil/docs/routing.md\` file-based routing, nested layouts, loading / error files
21
- - \`.toil/docs/client.md\` the \`Toil\` global, Link / NavLink, router hooks
22
- - \`.toil/docs/styling.md\` CSS / Sass / Less / Stylus / Tailwind (via \`toiljs configure\`)
23
- - \`.toil/docs/server.md\` the toilscript server target
24
- - \`.toil/docs/cli.md\` toiljs CLI commands
19
+ - \`.toil/docs/index.md\`, overview and project layout
20
+ - \`.toil/docs/routing.md\`, file-based routing, nested layouts, loading / error files
21
+ - \`.toil/docs/client.md\`, the \`Toil\` global, Link / NavLink, router hooks
22
+ - \`.toil/docs/styling.md\`, CSS / Sass / Less / Stylus / Tailwind (via \`toiljs configure\`)
23
+ - \`.toil/docs/server.md\`, the toilscript server target
24
+ - \`.toil/docs/cli.md\`, toiljs CLI commands
25
25
 
26
26
  \`.toil/docs/\` is regenerated by toiljs; do not edit it by hand. This pointer file is yours to edit.
27
27
  `;
@@ -82,10 +82,10 @@ export const TOIL_DOCS: Record<string, string> = {
82
82
  '',
83
83
  '## Project layout',
84
84
  '',
85
- '- `client/` the app: `routes/` (file-based), `layout.tsx`, `components/`, `styles/`,',
85
+ '- `client/`, the app: `routes/` (file-based), `layout.tsx`, `components/`, `styles/`,',
86
86
  ' `public/`, and `toil.tsx` (the entry that calls `Toil.mount`).',
87
- '- `server/` the toilscript → WASM target (`@main` entry), compiled by `toilscript`.',
88
- '- `toil.config.ts` configuration via `defineConfig` (`toiljs.config.ts` also works).',
87
+ '- `server/`, the toilscript → WASM target (`@main` entry), compiled by `toilscript`.',
88
+ '- `toil.config.ts`, configuration via `defineConfig` (`toiljs.config.ts` also works).',
89
89
  '- Generated, gitignored, do not edit: `.toil/` (working dir), `toil-env.d.ts` (ambient',
90
90
  ' globals), `toil-routes.d.ts` (typed routes).',
91
91
  '',
@@ -106,17 +106,17 @@ export const TOIL_DOCS: Record<string, string> = {
106
106
  '## Route files',
107
107
  '',
108
108
  '- `index.tsx` → `/`, `about.tsx` → `/about`, `blog/index.tsx` → `/blog`',
109
- '- `[id].tsx` → dynamic `/:id` read with `Toil.useParams<{ id: string }>()`',
109
+ '- `[id].tsx` → dynamic `/:id`, read with `Toil.useParams<{ id: string }>()`',
110
110
  '- `[...slug].tsx` → catch-all (1+ segments); `[[...slug]].tsx` → optional catch-all (0+)',
111
111
  '- `(group)/` → route group: groups files / scopes a layout, adds no URL segment',
112
112
  '',
113
113
  '## Special files',
114
114
  '',
115
- '- `layout.tsx` wraps the routes beneath it. Root `client/layout.tsx` wraps everything;',
115
+ '- `layout.tsx`, wraps the routes beneath it. Root `client/layout.tsx` wraps everything;',
116
116
  ' nested `routes/**/layout.tsx` compose inside it.',
117
- '- `loading.tsx` Suspense fallback shown while a route (chunk + loader) loads',
118
- '- `error.tsx` error boundary; receives `{ error, reset }` (`Toil.RouteErrorProps`)',
119
- '- `client/404.tsx` shown when no route matches',
117
+ '- `loading.tsx`, Suspense fallback shown while a route (chunk + loader) loads',
118
+ '- `error.tsx`, error boundary; receives `{ error, reset }` (`Toil.RouteErrorProps`)',
119
+ '- `client/404.tsx`, shown when no route matches',
120
120
  '',
121
121
  '## Data loaders',
122
122
  '',
@@ -138,12 +138,12 @@ export const TOIL_DOCS: Record<string, string> = {
138
138
  '- `Toil.Link` / `Toil.NavLink` (adds an active class) / `Toil.useRouter()`',
139
139
  ' (push / replace / back / forward / refresh / prefetch)',
140
140
  '- `Toil.useParams`, `usePathname`, `useSearchParams`, `useNavigationPending`',
141
- '- Hrefs are type-checked against your routes a typo is a compile error.',
141
+ '- Hrefs are type-checked against your routes, a typo is a compile error.',
142
142
  ]),
143
143
  'client.md': doc([
144
144
  '# Client runtime',
145
145
  '',
146
- 'Everything is on the `Toil` global no imports needed in route files.',
146
+ 'Everything is on the `Toil` global, no imports needed in route files.',
147
147
  '',
148
148
  '## Entry',
149
149
  '',
@@ -159,7 +159,7 @@ export const TOIL_DOCS: Record<string, string> = {
159
159
  '- Navigation: `navigate`, `useRouter`, `useNavigate`',
160
160
  '- Location: `usePathname`, `useSearchParams`, `useParams`, `useNavigationPending`',
161
161
  '- Data: `useLoaderData` (see `routing.md`)',
162
- '- Head: `useHead`, `useTitle`, `<Head>` set the `<title>` / meta per route',
162
+ '- Head: `useHead`, `useTitle`, `<Head>`, set the `<title>` / meta per route',
163
163
  '- Realtime: `useChannel`, `connectChannel` (WebSocket to the backend at `/_toil`)',
164
164
  '- IO globals (no `Toil.` prefix): `BinaryWriter`, `BinaryReader`, `FastMap`, `FastSet`',
165
165
  '',
@@ -198,9 +198,9 @@ export const TOIL_DOCS: Record<string, string> = {
198
198
  '',
199
199
  '`server/` is the toilscript source, compiled to WebAssembly by `toilscript`.',
200
200
  '',
201
- '- `server/main.ts` the `@main` entry, exported as the WASM `main`.',
202
- '- `server/index.ts` your functions.',
203
- '- `server/tsconfig.json` extends `toilscript/std/assembly.json` (AssemblyScript/toilscript',
201
+ '- `server/main.ts`, the `@main` entry, exported as the WASM `main`.',
202
+ '- `server/index.ts`, your functions.',
203
+ '- `server/tsconfig.json`, extends `toilscript/std/assembly.json` (AssemblyScript/toilscript',
204
204
  ' globals like `i32`, not the DOM), so editors resolve server types correctly.',
205
205
  '- `npm run build:server` (or `npm run build`) emits `build/server/release.wasm`.',
206
206
  '',
@@ -209,12 +209,12 @@ export const TOIL_DOCS: Record<string, string> = {
209
209
  'cli.md': doc([
210
210
  '# CLI',
211
211
  '',
212
- '- `toiljs create [name]` scaffold a project. Flags: `--template app|minimal`,',
212
+ '- `toiljs create [name]`, scaffold a project. Flags: `--template app|minimal`,',
213
213
  ' `--style css|sass|less|stylus`, `--tailwind`, `--no-ai`, `-y`/`--yes`.',
214
- '- `toiljs dev` dev server with HMR (`--port`, `--root`).',
215
- '- `toiljs build` production build → `build/client` (chain `toilscript` for the server).',
216
- '- `toiljs start` self-host the built app (hyper-express) with a `/_toil` WebSocket channel.',
217
- '- `toiljs configure` toggle styling features on an existing project (see `styling.md`).',
214
+ '- `toiljs dev`, dev server with HMR (`--port`, `--root`).',
215
+ '- `toiljs build`, production build → `build/client` (chain `toilscript` for the server).',
216
+ '- `toiljs start`, self-host the built app (hyper-express) with a `/_toil` WebSocket channel.',
217
+ '- `toiljs configure`, toggle styling features on an existing project (see `styling.md`).',
218
218
  ]),
219
219
  };
220
220
 
@@ -0,0 +1,87 @@
1
+ import type { HtmlTagDescriptor, Logger, Plugin } from 'vite';
2
+
3
+ import { type ResolvedToilConfig } from './config.js';
4
+
5
+ /**
6
+ * Build-time font optimization. Bundled font files (`@font-face` `url(...)` imports) are emitted +
7
+ * hashed by Vite, but without a hint the browser only discovers them after parsing CSS, delaying
8
+ * text paint. This injects a `<link rel="preload" as="font" crossorigin>` for each bundled font so
9
+ * it loads in parallel with the CSS, and logs what it preloaded (mirrors the image-optimization log).
10
+ */
11
+ const FONT_RE = /\.(woff2|woff|ttf|otf)$/i;
12
+ const FONT_TYPE: Record<string, string> = {
13
+ woff2: 'font/woff2',
14
+ woff: 'font/woff',
15
+ ttf: 'font/ttf',
16
+ otf: 'font/otf',
17
+ };
18
+
19
+ function kb(bytes: number): string {
20
+ return `${(bytes / 1000).toFixed(2)} kB`;
21
+ }
22
+
23
+ /** Builds the `<link rel="preload">` head tags for a set of bundled font file names. */
24
+ export function fontPreloadTags(fileNames: readonly string[], base: string): HtmlTagDescriptor[] {
25
+ const prefix = base.endsWith('/') ? base : `${base}/`;
26
+ return fileNames
27
+ .filter((name) => FONT_RE.test(name))
28
+ .map((name) => {
29
+ const ext = name.split('.').pop()?.toLowerCase() ?? '';
30
+ return {
31
+ tag: 'link',
32
+ attrs: {
33
+ rel: 'preload',
34
+ as: 'font',
35
+ type: FONT_TYPE[ext] ?? `font/${ext}`,
36
+ href: `${prefix}${name}`,
37
+ crossorigin: '',
38
+ },
39
+ injectTo: 'head',
40
+ };
41
+ });
42
+ }
43
+
44
+ /** Build-only plugin that preloads bundled fonts and logs them. Disabled by `client.fonts: false`. */
45
+ export function fontPreloadPlugin(cfg: ResolvedToilConfig): Plugin {
46
+ let logger: Logger | undefined;
47
+ let logged = false;
48
+ return {
49
+ name: 'toil:font-preload',
50
+ apply: 'build',
51
+ configResolved(config) {
52
+ logger = config.logger;
53
+ },
54
+ transformIndexHtml: {
55
+ order: 'post',
56
+ handler(html, ctx) {
57
+ const bundle = ctx.bundle ?? {};
58
+ const fonts = Object.values(bundle).filter(
59
+ (file) => file.type === 'asset' && FONT_RE.test(file.fileName),
60
+ );
61
+ if (fonts.length === 0) return html;
62
+
63
+ // Log once (the same template's HTML is transformed per emitted page).
64
+ if (!logged && logger) {
65
+ logged = true;
66
+ logger.info('');
67
+ logger.info(` ✓ preloaded ${String(fonts.length)} font${fonts.length === 1 ? '' : 's'}`);
68
+ for (const file of fonts) {
69
+ const size =
70
+ file.type === 'asset' && typeof file.source !== 'string'
71
+ ? kb(file.source.byteLength)
72
+ : '';
73
+ logger.info(` → ${file.fileName} ${size}`);
74
+ }
75
+ }
76
+
77
+ return {
78
+ html,
79
+ tags: fontPreloadTags(
80
+ fonts.map((f) => f.fileName),
81
+ cfg.base,
82
+ ),
83
+ };
84
+ },
85
+ },
86
+ };
87
+ }
@@ -4,6 +4,7 @@ import path from 'node:path';
4
4
  import { type ResolvedToilConfig } from './config.js';
5
5
  import { writeDocs } from './docs.js';
6
6
  import { scanRoutes, type ScannedRoute } from './routes.js';
7
+ import { llmsTxt, robotsTxt, sitemapXml } from './seo.js';
7
8
 
8
9
  /**
9
10
  * Contents of the root `toil-env.d.ts`: ambient global types so `new BinaryWriter()` etc. resolve
@@ -35,7 +36,7 @@ const ASSET_MODULES = ASSET_EXTENSIONS.map(
35
36
  ).join('\n');
36
37
 
37
38
  export const TOIL_ENV_DTS =
38
- `// AUTO-GENERATED by toil do not edit.\n` +
39
+ `// AUTO-GENERATED by toil, do not edit.\n` +
39
40
  // Types for image-optimization query imports (`import img from './x.png?w=400&format=webp'`).
40
41
  `/// <reference types="vite-imagetools/client" />\n` +
41
42
  `declare const Toil: typeof import('toiljs/client');\n` +
@@ -43,6 +44,8 @@ export const TOIL_ENV_DTS =
43
44
  ` type LoaderArgs = import('toiljs/client').LoaderArgs;\n` +
44
45
  ` type LoaderFunction<T = unknown> = import('toiljs/client').LoaderFunction<T>;\n` +
45
46
  ` type Revalidate = import('toiljs/client').Revalidate;\n` +
47
+ ` type Metadata = import('toiljs/client').Metadata;\n` +
48
+ ` type GenerateMetadata<T = unknown> = import('toiljs/client').GenerateMetadata<T>;\n` +
46
49
  ` type RouteErrorProps = import('toiljs/client').RouteErrorProps;\n` +
47
50
  `}\n` +
48
51
  `declare const BinaryWriter: typeof import('toiljs/io').BinaryWriter;\n` +
@@ -59,6 +62,7 @@ export const TOIL_ENV_DTS =
59
62
  ` export const layout: import('toiljs/client').LayoutLoader;\n` +
60
63
  ` export const notFound: import('toiljs/client').NotFoundLoader;\n` +
61
64
  ` export const globalError: import('toiljs/client').ErrorComponentLoader;\n` +
65
+ ` export const slots: Record<string, import('toiljs/client').RouteDef[]>;\n` +
62
66
  `}\n`;
63
67
 
64
68
  /**
@@ -122,7 +126,7 @@ function routePathUnion(routes: ScannedRoute[]): string {
122
126
  */
123
127
  function routesDts(routes: ScannedRoute[]): string {
124
128
  return (
125
- `// AUTO-GENERATED by toil do not edit.\n` +
129
+ `// AUTO-GENERATED by toil, do not edit.\n` +
126
130
  `export {};\n` +
127
131
  `declare module 'toiljs/client' {\n` +
128
132
  ` interface Register {\n` +
@@ -201,30 +205,40 @@ export function generate(cfg: ResolvedToilConfig): ScannedRoute[] {
201
205
  const layoutFile = findLayout(cfg);
202
206
  const notFoundFile = findNotFound(cfg);
203
207
  const globalErrorFile = findGlobalError(cfg);
208
+ const imp = (f: string): string => `() => import(${JSON.stringify(relFromToil(cfg, f))})`;
209
+ const routeObj = (r: ScannedRoute): string => {
210
+ const layouts = findSpecialChain(cfg, r.file, 'layout', false).map(imp).join(', ');
211
+ const templates = findSpecialChain(cfg, r.file, 'template', true).map(imp).join(', ');
212
+ const parts = [
213
+ `pattern: ${JSON.stringify(r.pattern)}`,
214
+ `load: ${imp(r.file)}`,
215
+ `layouts: [${layouts}]`,
216
+ ];
217
+ if (templates) parts.push(`templates: [${templates}]`);
218
+ const loadingFile = findNearest(cfg, r.file, 'loading');
219
+ if (loadingFile) parts.push(`loading: ${imp(loadingFile)}`);
220
+ const errorFile = findNearest(cfg, r.file, 'error');
221
+ if (errorFile) parts.push(`errorComponent: ${imp(errorFile)}`);
222
+ if (r.intercept) parts.push(`intercept: true`);
223
+ return `{ ${parts.join(', ')} }`;
224
+ };
225
+ const mainRoutes = routes.filter((r) => r.slot === undefined);
226
+ const slotNames = [...new Set(routes.flatMap((r) => (r.slot ? [r.slot] : [])))];
227
+ const slotsBody = slotNames
228
+ .map((name) => {
229
+ const items = routes
230
+ .filter((r) => r.slot === name)
231
+ .map((r) => ` ${routeObj(r)},`)
232
+ .join('\n');
233
+ return ` ${JSON.stringify(name)}: [\n${items}\n ],`;
234
+ })
235
+ .join('\n');
204
236
  const routesSrc =
205
237
  `// @ts-nocheck\n` +
206
- `// AUTO-GENERATED by toil do not edit.\n` +
238
+ `// AUTO-GENERATED by toil, do not edit.\n` +
207
239
  `import type { RouteDef, LayoutLoader, NotFoundLoader } from 'toiljs/client';\n\n` +
208
- `export const routes: RouteDef[] = [\n` +
209
- routes
210
- .map((r) => {
211
- const imp = (f: string): string => `() => import(${JSON.stringify(relFromToil(cfg, f))})`;
212
- const layouts = findSpecialChain(cfg, r.file, 'layout', false).map(imp).join(', ');
213
- const templates = findSpecialChain(cfg, r.file, 'template', true).map(imp).join(', ');
214
- const parts = [
215
- `pattern: ${JSON.stringify(r.pattern)}`,
216
- `load: ${imp(r.file)}`,
217
- `layouts: [${layouts}]`,
218
- ];
219
- if (templates) parts.push(`templates: [${templates}]`);
220
- const loadingFile = findNearest(cfg, r.file, 'loading');
221
- if (loadingFile) parts.push(`loading: ${imp(loadingFile)}`);
222
- const errorFile = findNearest(cfg, r.file, 'error');
223
- if (errorFile) parts.push(`errorComponent: ${imp(errorFile)}`);
224
- return ` { ${parts.join(', ')} },`;
225
- })
226
- .join('\n') +
227
- `\n];\n\n` +
240
+ `export const routes: RouteDef[] = [\n${mainRoutes.map((r) => ` ${routeObj(r)},`).join('\n')}\n];\n\n` +
241
+ `export const slots: Record<string, RouteDef[]> = {\n${slotsBody}\n};\n\n` +
228
242
  `export const layout: LayoutLoader = ${layoutFile ? `() => import(${JSON.stringify(relFromToil(cfg, layoutFile))})` : 'null'};\n` +
229
243
  `export const notFound: NotFoundLoader = ${notFoundFile ? `() => import(${JSON.stringify(relFromToil(cfg, notFoundFile))})` : 'null'};\n` +
230
244
  `export const globalError = ${globalErrorFile ? `() => import(${JSON.stringify(relFromToil(cfg, globalErrorFile))})` : 'null'};\n`;
@@ -232,24 +246,25 @@ export function generate(cfg: ResolvedToilConfig): ScannedRoute[] {
232
246
 
233
247
  const globalsSrc =
234
248
  `// @ts-nocheck\n` +
235
- `// AUTO-GENERATED by toil do not edit.\n` +
249
+ `// AUTO-GENERATED by toil, do not edit.\n` +
236
250
  `import * as Toil from 'toiljs/client';\n` +
237
251
  `import { BinaryWriter, BinaryReader, FastMap, FastSet } from 'toiljs/io';\n\n` +
238
- `Object.assign(globalThis, { Toil, BinaryWriter, BinaryReader, FastMap, FastSet });\n`;
252
+ `Object.assign(globalThis, { Toil, BinaryWriter, BinaryReader, FastMap, FastSet });\n` +
253
+ `Toil.setViewTransitions(${String(cfg.viewTransitions)});\n`;
239
254
  fs.writeFileSync(path.join(cfg.toilDir, 'globals.ts'), globalsSrc);
240
255
 
241
256
  const entryFile = findEntry(cfg);
242
257
  const entrySrc = entryFile
243
258
  ? `// @ts-nocheck\n` +
244
- `// AUTO-GENERATED by toil do not edit.\n` +
259
+ `// AUTO-GENERATED by toil, do not edit.\n` +
245
260
  `import './globals';\n` +
246
261
  `import ${JSON.stringify(relFromToil(cfg, entryFile))};\n`
247
262
  : `// @ts-nocheck\n` +
248
- `// AUTO-GENERATED by toil do not edit.\n` +
263
+ `// AUTO-GENERATED by toil, do not edit.\n` +
249
264
  `import './globals';\n` +
250
265
  `import { mount } from 'toiljs/client';\n` +
251
- `import { routes, layout, notFound, globalError } from './routes';\n\n` +
252
- `mount(routes, layout, notFound, globalError);\n`;
266
+ `import { routes, layout, notFound, globalError, slots } from './routes';\n\n` +
267
+ `mount(routes, layout, notFound, globalError, slots);\n`;
253
268
  fs.writeFileSync(path.join(cfg.toilDir, 'entry.tsx'), entrySrc);
254
269
 
255
270
  fs.writeFileSync(path.join(cfg.root, 'toil-env.d.ts'), TOIL_ENV_DTS);
@@ -257,11 +272,31 @@ export function generate(cfg: ResolvedToilConfig): ScannedRoute[] {
257
272
 
258
273
  fs.writeFileSync(path.join(cfg.toilDir, 'index.html'), buildHtml(cfg));
259
274
  syncPublicAssets(cfg);
275
+ writeSeoFiles(cfg, routes);
260
276
  writeDocs(cfg.toilDir);
261
277
 
262
278
  return routes;
263
279
  }
264
280
 
281
+ /**
282
+ * Writes the build-time SEO crawler files into `.toil/public` (Vite's publicDir, served in dev and
283
+ * copied to the output root in build): `robots.txt` (incl. AI-crawler directives), `sitemap.xml`
284
+ * (static routes), and `llms.txt` (AI guidance). No-op when SEO isn't configured.
285
+ */
286
+ function writeSeoFiles(cfg: ResolvedToilConfig, routes: ScannedRoute[]): void {
287
+ if (!cfg.seo) return;
288
+ const dest = path.join(cfg.toilDir, 'public');
289
+ const files: [string, string][] = [
290
+ ['robots.txt', robotsTxt(cfg.seo)],
291
+ ['sitemap.xml', sitemapXml(cfg.seo, routes)],
292
+ ['llms.txt', llmsTxt(cfg.seo, routes)],
293
+ ];
294
+ const present = files.filter(([, content]) => content !== '');
295
+ if (present.length === 0) return;
296
+ fs.mkdirSync(dest, { recursive: true });
297
+ for (const [name, content] of present) fs.writeFileSync(path.join(dest, name), content);
298
+ }
299
+
265
300
  /** Fallback HTML when the project has no `public/index.html` template. The entry script is added
266
301
  * by {@link buildHtml}. */
267
302
  const DEFAULT_HTML =
@@ -277,7 +312,7 @@ const ENTRY_SCRIPT = `<script type="module" src="./entry.tsx"></script>`;
277
312
  /**
278
313
  * Produces the `.toil/index.html` Vite entry from the project's `public/index.html` template (or
279
314
  * the built-in default if absent), ensuring the generated module entry script is present. Users
280
- * own the template toil only guarantees the entry is wired, so it stays the SPA root.
315
+ * own the template, toil only guarantees the entry is wired, so it stays the SPA root.
281
316
  */
282
317
  function buildHtml(cfg: ResolvedToilConfig): string {
283
318
  const templatePath = path.join(cfg.publicDir, 'index.html');
@@ -296,7 +331,7 @@ function buildHtml(cfg: ResolvedToilConfig): string {
296
331
 
297
332
  /**
298
333
  * Mirrors the project's `public/` assets into `.toil/public/` (Vite's publicDir under the `.toil`
299
- * root), excluding the `index.html` template that is processed into the entry above, and copying
334
+ * root), excluding the `index.html` template, that is processed into the entry above, and copying
300
335
  * it here would clobber the built, asset-hashed page. Cleared each run so deletions propagate.
301
336
  */
302
337
  function syncPublicAssets(cfg: ResolvedToilConfig): void {
@@ -18,7 +18,7 @@ interface Variant {
18
18
  }
19
19
 
20
20
  /**
21
- * Build-only plugin that reports which imported images the pipeline optimized each source image,
21
+ * Build-only plugin that reports which imported images the pipeline optimized, each source image,
22
22
  * its emitted variant(s), and the size saved. `public/` assets (copied as-is) never enter the
23
23
  * bundle, so they don't appear here. Logs nothing when no images were processed.
24
24
  *
@@ -11,7 +11,7 @@ import { generate } from './generate.js';
11
11
  export function toilPlugin(cfg: ResolvedToilConfig): Plugin {
12
12
  return {
13
13
  name: 'toil',
14
- // Catch empty import specifiers in source and report the file rolldown otherwise fails
14
+ // Catch empty import specifiers in source and report the file, rolldown otherwise fails
15
15
  // resolution with a cryptic "The specifiers must be a non-empty string. Received ''".
16
16
  transform(code, id) {
17
17
  const file = id.split('?')[0];
@@ -25,7 +25,7 @@ export function toilPlugin(cfg: ResolvedToilConfig): Plugin {
25
25
  /\bimport\s*\(\s*(['"])\1\s*\)/.test(code);
26
26
  if (empty) {
27
27
  throw new Error(
28
- `toil: empty import specifier (e.g. \`import '';\`) in ${file} remove or complete the import.`,
28
+ `toil: empty import specifier (e.g. \`import '';\`) in ${file}, remove or complete the import.`,
29
29
  );
30
30
  }
31
31
  return null;
@@ -0,0 +1,130 @@
1
+ import { createRequire } from 'node:module';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { pathToFileURL } from 'node:url';
5
+
6
+ import type * as TS from 'typescript';
7
+ import type { Plugin } from 'vite';
8
+
9
+ import { type ResolvedToilConfig } from './config.js';
10
+ import { scanRoutes } from './routes.js';
11
+ import { injectSeoHtml, routeSeo } from './seo.js';
12
+
13
+ type Ts = typeof TS;
14
+
15
+ /** Loads the project's TypeScript (used to read each route's static `metadata`), or `null` if absent. */
16
+ async function loadTypeScript(root: string): Promise<Ts | null> {
17
+ try {
18
+ const resolved = createRequire(path.join(root, 'package.json')).resolve('typescript');
19
+ const mod = (await import(pathToFileURL(resolved).href)) as { default?: Ts } & Ts;
20
+ return mod.default ?? mod;
21
+ } catch {
22
+ return null;
23
+ }
24
+ }
25
+
26
+ /** Marks an AST node that isn't a static literal (so its value can't be baked at build). */
27
+ const UNRESOLVED = Symbol('unresolved');
28
+
29
+ /** Statically evaluates a literal expression node to a JS value, or `UNRESOLVED` if it isn't one. */
30
+ function evalNode(ts: Ts, node: TS.Expression): unknown {
31
+ if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) return node.text;
32
+ if (ts.isNumericLiteral(node)) return Number(node.text);
33
+ if (node.kind === ts.SyntaxKind.TrueKeyword) return true;
34
+ if (node.kind === ts.SyntaxKind.FalseKeyword) return false;
35
+ if (node.kind === ts.SyntaxKind.NullKeyword) return null;
36
+ if (ts.isArrayLiteralExpression(node)) {
37
+ const out: unknown[] = [];
38
+ for (const el of node.elements) {
39
+ const value = evalNode(ts, el);
40
+ if (value === UNRESOLVED) return UNRESOLVED;
41
+ out.push(value);
42
+ }
43
+ return out;
44
+ }
45
+ if (ts.isObjectLiteralExpression(node)) return evalObject(ts, node);
46
+ return UNRESOLVED;
47
+ }
48
+
49
+ /** Evaluates an object literal to a plain object, skipping any property that isn't a static literal. */
50
+ function evalObject(ts: Ts, node: TS.ObjectLiteralExpression): Record<string, unknown> {
51
+ const obj: Record<string, unknown> = {};
52
+ for (const prop of node.properties) {
53
+ if (!ts.isPropertyAssignment(prop)) continue;
54
+ const key = ts.isIdentifier(prop.name)
55
+ ? prop.name.text
56
+ : ts.isStringLiteral(prop.name)
57
+ ? prop.name.text
58
+ : null;
59
+ if (key === null) continue;
60
+ const value = evalNode(ts, prop.initializer);
61
+ if (value !== UNRESOLVED) obj[key] = value;
62
+ }
63
+ return obj;
64
+ }
65
+
66
+ /**
67
+ * Extracts a route's `export const metadata = { … }` if it's a static object literal, returning the
68
+ * statically-evaluable subset (dynamic `generateMetadata` and computed values are skipped). `null`
69
+ * when the file has no static metadata.
70
+ */
71
+ export function extractStaticMetadata(ts: Ts, filePath: string): Record<string, unknown> | null {
72
+ let source: string;
73
+ try {
74
+ source = fs.readFileSync(filePath, 'utf8');
75
+ } catch {
76
+ return null;
77
+ }
78
+ const sf = ts.createSourceFile(filePath, source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
79
+ for (const stmt of sf.statements) {
80
+ if (!ts.isVariableStatement(stmt)) continue;
81
+ if (!stmt.modifiers?.some((m) => m.kind === ts.SyntaxKind.ExportKeyword)) continue;
82
+ for (const decl of stmt.declarationList.declarations) {
83
+ if (
84
+ ts.isIdentifier(decl.name) &&
85
+ decl.name.text === 'metadata' &&
86
+ decl.initializer &&
87
+ ts.isObjectLiteralExpression(decl.initializer)
88
+ ) {
89
+ return evalObject(ts, decl.initializer);
90
+ }
91
+ }
92
+ }
93
+ return null;
94
+ }
95
+
96
+ /**
97
+ * Build-only plugin that statically pre-renders per-route HTML for SEO. After the bundle is written,
98
+ * it takes the built shell (`index.html`), and for each static route bakes that route's
99
+ * `metadata` (merged over the site-wide `seo` defaults) into a `<route>/index.html` so a JS-less
100
+ * crawler hitting the route gets correct per-page tags. Dynamic (`generateMetadata`) and `:param`
101
+ * routes are skipped (no data at build) and fall back to the client-rendered shell.
102
+ */
103
+ export function prerenderPlugin(cfg: ResolvedToilConfig): Plugin {
104
+ return {
105
+ name: 'toil:prerender-seo',
106
+ apply: 'build',
107
+ async closeBundle() {
108
+ if (!cfg.seo) return;
109
+ const outDir = path.resolve(cfg.root, cfg.outDir);
110
+ const shellPath = path.join(outDir, 'index.html');
111
+ if (!fs.existsSync(shellPath)) return;
112
+ const shell = fs.readFileSync(shellPath, 'utf8');
113
+ const ts = await loadTypeScript(cfg.root);
114
+
115
+ const routes = scanRoutes(cfg.routesAbsDir).filter(
116
+ (r) => r.slot === undefined && !r.intercept && !/[:*]/.test(r.pattern),
117
+ );
118
+ for (const route of routes) {
119
+ const metadata = ts ? extractStaticMetadata(ts, route.file) : null;
120
+ const html = injectSeoHtml(shell, routeSeo(cfg.seo, metadata, route.pattern));
121
+ const target =
122
+ route.pattern === '/'
123
+ ? shellPath
124
+ : path.join(outDir, route.pattern.replace(/^\//, ''), 'index.html');
125
+ fs.mkdirSync(path.dirname(target), { recursive: true });
126
+ fs.writeFileSync(target, html);
127
+ }
128
+ },
129
+ };
130
+ }