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
@@ -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,11 +36,16 @@ 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` +
40
+ // Types for image-optimization query imports (`import img from './x.png?w=400&format=webp'`).
41
+ `/// <reference types="vite-imagetools/client" />\n` +
39
42
  `declare const Toil: typeof import('toiljs/client');\n` +
40
43
  `declare namespace Toil {\n` +
41
44
  ` type LoaderArgs = import('toiljs/client').LoaderArgs;\n` +
42
45
  ` type LoaderFunction<T = unknown> = import('toiljs/client').LoaderFunction<T>;\n` +
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` +
43
49
  ` type RouteErrorProps = import('toiljs/client').RouteErrorProps;\n` +
44
50
  `}\n` +
45
51
  `declare const BinaryWriter: typeof import('toiljs/io').BinaryWriter;\n` +
@@ -56,6 +62,7 @@ export const TOIL_ENV_DTS =
56
62
  ` export const layout: import('toiljs/client').LayoutLoader;\n` +
57
63
  ` export const notFound: import('toiljs/client').NotFoundLoader;\n` +
58
64
  ` export const globalError: import('toiljs/client').ErrorComponentLoader;\n` +
65
+ ` export const slots: Record<string, import('toiljs/client').RouteDef[]>;\n` +
59
66
  `}\n`;
60
67
 
61
68
  /**
@@ -119,7 +126,7 @@ function routePathUnion(routes: ScannedRoute[]): string {
119
126
  */
120
127
  function routesDts(routes: ScannedRoute[]): string {
121
128
  return (
122
- `// AUTO-GENERATED by toil do not edit.\n` +
129
+ `// AUTO-GENERATED by toil, do not edit.\n` +
123
130
  `export {};\n` +
124
131
  `declare module 'toiljs/client' {\n` +
125
132
  ` interface Register {\n` +
@@ -198,30 +205,40 @@ export function generate(cfg: ResolvedToilConfig): ScannedRoute[] {
198
205
  const layoutFile = findLayout(cfg);
199
206
  const notFoundFile = findNotFound(cfg);
200
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');
201
236
  const routesSrc =
202
237
  `// @ts-nocheck\n` +
203
- `// AUTO-GENERATED by toil do not edit.\n` +
238
+ `// AUTO-GENERATED by toil, do not edit.\n` +
204
239
  `import type { RouteDef, LayoutLoader, NotFoundLoader } from 'toiljs/client';\n\n` +
205
- `export const routes: RouteDef[] = [\n` +
206
- routes
207
- .map((r) => {
208
- const imp = (f: string): string => `() => import(${JSON.stringify(relFromToil(cfg, f))})`;
209
- const layouts = findSpecialChain(cfg, r.file, 'layout', false).map(imp).join(', ');
210
- const templates = findSpecialChain(cfg, r.file, 'template', true).map(imp).join(', ');
211
- const parts = [
212
- `pattern: ${JSON.stringify(r.pattern)}`,
213
- `load: ${imp(r.file)}`,
214
- `layouts: [${layouts}]`,
215
- ];
216
- if (templates) parts.push(`templates: [${templates}]`);
217
- const loadingFile = findNearest(cfg, r.file, 'loading');
218
- if (loadingFile) parts.push(`loading: ${imp(loadingFile)}`);
219
- const errorFile = findNearest(cfg, r.file, 'error');
220
- if (errorFile) parts.push(`errorComponent: ${imp(errorFile)}`);
221
- return ` { ${parts.join(', ')} },`;
222
- })
223
- .join('\n') +
224
- `\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` +
225
242
  `export const layout: LayoutLoader = ${layoutFile ? `() => import(${JSON.stringify(relFromToil(cfg, layoutFile))})` : 'null'};\n` +
226
243
  `export const notFound: NotFoundLoader = ${notFoundFile ? `() => import(${JSON.stringify(relFromToil(cfg, notFoundFile))})` : 'null'};\n` +
227
244
  `export const globalError = ${globalErrorFile ? `() => import(${JSON.stringify(relFromToil(cfg, globalErrorFile))})` : 'null'};\n`;
@@ -229,24 +246,25 @@ export function generate(cfg: ResolvedToilConfig): ScannedRoute[] {
229
246
 
230
247
  const globalsSrc =
231
248
  `// @ts-nocheck\n` +
232
- `// AUTO-GENERATED by toil do not edit.\n` +
249
+ `// AUTO-GENERATED by toil, do not edit.\n` +
233
250
  `import * as Toil from 'toiljs/client';\n` +
234
251
  `import { BinaryWriter, BinaryReader, FastMap, FastSet } from 'toiljs/io';\n\n` +
235
- `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`;
236
254
  fs.writeFileSync(path.join(cfg.toilDir, 'globals.ts'), globalsSrc);
237
255
 
238
256
  const entryFile = findEntry(cfg);
239
257
  const entrySrc = entryFile
240
258
  ? `// @ts-nocheck\n` +
241
- `// AUTO-GENERATED by toil do not edit.\n` +
259
+ `// AUTO-GENERATED by toil, do not edit.\n` +
242
260
  `import './globals';\n` +
243
261
  `import ${JSON.stringify(relFromToil(cfg, entryFile))};\n`
244
262
  : `// @ts-nocheck\n` +
245
- `// AUTO-GENERATED by toil do not edit.\n` +
263
+ `// AUTO-GENERATED by toil, do not edit.\n` +
246
264
  `import './globals';\n` +
247
265
  `import { mount } from 'toiljs/client';\n` +
248
- `import { routes, layout, notFound, globalError } from './routes';\n\n` +
249
- `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`;
250
268
  fs.writeFileSync(path.join(cfg.toilDir, 'entry.tsx'), entrySrc);
251
269
 
252
270
  fs.writeFileSync(path.join(cfg.root, 'toil-env.d.ts'), TOIL_ENV_DTS);
@@ -254,11 +272,31 @@ export function generate(cfg: ResolvedToilConfig): ScannedRoute[] {
254
272
 
255
273
  fs.writeFileSync(path.join(cfg.toilDir, 'index.html'), buildHtml(cfg));
256
274
  syncPublicAssets(cfg);
275
+ writeSeoFiles(cfg, routes);
257
276
  writeDocs(cfg.toilDir);
258
277
 
259
278
  return routes;
260
279
  }
261
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
+
262
300
  /** Fallback HTML when the project has no `public/index.html` template. The entry script is added
263
301
  * by {@link buildHtml}. */
264
302
  const DEFAULT_HTML =
@@ -274,7 +312,7 @@ const ENTRY_SCRIPT = `<script type="module" src="./entry.tsx"></script>`;
274
312
  /**
275
313
  * Produces the `.toil/index.html` Vite entry from the project's `public/index.html` template (or
276
314
  * the built-in default if absent), ensuring the generated module entry script is present. Users
277
- * 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.
278
316
  */
279
317
  function buildHtml(cfg: ResolvedToilConfig): string {
280
318
  const templatePath = path.join(cfg.publicDir, 'index.html');
@@ -293,7 +331,7 @@ function buildHtml(cfg: ResolvedToilConfig): string {
293
331
 
294
332
  /**
295
333
  * Mirrors the project's `public/` assets into `.toil/public/` (Vite's publicDir under the `.toil`
296
- * 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
297
335
  * it here would clobber the built, asset-hashed page. Cleared each run so deletions propagate.
298
336
  */
299
337
  function syncPublicAssets(cfg: ResolvedToilConfig): void {
@@ -0,0 +1,85 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+
4
+ import pc from 'picocolors';
5
+ import type { Logger, Plugin } from 'vite';
6
+
7
+ /** Raster/vector outputs the image pipeline may emit. */
8
+ const IMAGE_RE = /\.(png|jpe?g|webp|avif|gif|tiff|svg)$/i;
9
+
10
+ /** Formats a byte count like Vite's asset table (kB, base 1000). */
11
+ function kb(bytes: number): string {
12
+ return `${(bytes / 1000).toFixed(2)} kB`;
13
+ }
14
+
15
+ interface Variant {
16
+ readonly out: string;
17
+ readonly outSize: number;
18
+ }
19
+
20
+ /**
21
+ * Build-only plugin that reports which imported images the pipeline optimized, each source image,
22
+ * its emitted variant(s), and the size saved. `public/` assets (copied as-is) never enter the
23
+ * bundle, so they don't appear here. Logs nothing when no images were processed.
24
+ *
25
+ * `viteRoot` is Vite's root (the `.toil` dir) that emitted assets' `originalFileNames` are relative
26
+ * to; `projectRoot` is used only to print friendly source paths.
27
+ */
28
+ export function imageReportPlugin(projectRoot: string, viteRoot: string): Plugin {
29
+ let logger: Logger | undefined;
30
+ return {
31
+ name: 'toil:image-report',
32
+ apply: 'build',
33
+ configResolved(config) {
34
+ logger = config.logger;
35
+ },
36
+ writeBundle(_options, bundle) {
37
+ // Group emitted image assets by their source file.
38
+ const bySource = new Map<string, { label: string; inSize: number | null; variants: Variant[] }>();
39
+ for (const file of Object.values(bundle)) {
40
+ if (file.type !== 'asset' || !IMAGE_RE.test(file.fileName)) continue;
41
+ const source = file.originalFileNames[0];
42
+ const key = source ?? file.fileName;
43
+ const outSize =
44
+ typeof file.source === 'string'
45
+ ? Buffer.byteLength(file.source)
46
+ : file.source.byteLength;
47
+
48
+ let entry = bySource.get(key);
49
+ if (!entry) {
50
+ let inSize: number | null = null;
51
+ let label = '(generated)';
52
+ if (source !== undefined) {
53
+ const abs = path.resolve(viteRoot, source);
54
+ label = path.relative(projectRoot, abs);
55
+ try {
56
+ inSize = fs.statSync(abs).size;
57
+ } catch {
58
+ inSize = null;
59
+ }
60
+ }
61
+ entry = { label, inSize, variants: [] };
62
+ bySource.set(key, entry);
63
+ }
64
+ entry.variants.push({ out: file.fileName, outSize });
65
+ }
66
+
67
+ if (bySource.size === 0 || !logger) return;
68
+
69
+ const count = bySource.size;
70
+ logger.info('');
71
+ logger.info(pc.green(` ✓ optimized ${String(count)} image${count === 1 ? '' : 's'}`));
72
+ for (const { label, inSize, variants } of bySource.values()) {
73
+ logger.info(` ${pc.dim(label)}`);
74
+ for (const v of variants) {
75
+ const saved =
76
+ inSize && inSize > 0
77
+ ? pc.green(` -${String(Math.round((1 - v.outSize / inSize) * 100))}%`)
78
+ : '';
79
+ const from = inSize ? `${kb(inSize)} → ` : '';
80
+ logger.info(` ${pc.dim('→')} ${v.out} ${from}${kb(v.outSize)}${saved}`);
81
+ }
82
+ }
83
+ },
84
+ };
85
+ }
@@ -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
+ }
@@ -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
  }