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.
- package/build/backend/.tsbuildinfo +1 -1
- package/build/cli/.tsbuildinfo +1 -1
- package/build/cli/configure.d.ts +1 -0
- package/build/cli/configure.js +85 -20
- package/build/cli/create.d.ts +1 -0
- package/build/cli/create.js +18 -7
- package/build/cli/features.d.ts +2 -0
- package/build/cli/features.js +22 -0
- package/build/cli/index.js +8 -0
- package/build/client/.tsbuildinfo +1 -1
- package/build/client/components/Form.d.ts +12 -0
- package/build/client/components/Form.js +23 -0
- package/build/client/components/Image.d.ts +13 -0
- package/build/client/components/Image.js +22 -0
- package/build/client/components/Script.d.ts +13 -0
- package/build/client/components/Script.js +68 -0
- package/build/client/components/Slot.d.ts +6 -0
- package/build/client/components/Slot.js +6 -0
- package/build/client/dev/error-overlay.d.ts +20 -0
- package/build/client/dev/error-overlay.js +123 -0
- package/build/client/head/head.d.ts +2 -0
- package/build/client/head/head.js +17 -2
- package/build/client/head/metadata.d.ts +29 -0
- package/build/client/head/metadata.js +38 -0
- package/build/client/index.d.ts +15 -3
- package/build/client/index.js +8 -2
- package/build/client/navigation/navigation.d.ts +3 -0
- package/build/client/navigation/navigation.js +42 -1
- package/build/client/routing/Router.d.ts +1 -0
- package/build/client/routing/Router.js +56 -34
- package/build/client/routing/action.d.ts +17 -0
- package/build/client/routing/action.js +55 -0
- package/build/client/routing/hooks.d.ts +1 -0
- package/build/client/routing/hooks.js +6 -7
- package/build/client/routing/loader.d.ts +10 -2
- package/build/client/routing/loader.js +83 -24
- package/build/client/routing/mount.d.ts +1 -1
- package/build/client/routing/mount.js +12 -4
- package/build/client/routing/slot-context.d.ts +2 -0
- package/build/client/routing/slot-context.js +2 -0
- package/build/client/types.d.ts +1 -0
- package/build/compiler/.tsbuildinfo +1 -1
- package/build/compiler/config.d.ts +10 -0
- package/build/compiler/config.js +5 -1
- package/build/compiler/docs.js +26 -26
- package/build/compiler/fonts.d.ts +4 -0
- package/build/compiler/fonts.js +64 -0
- package/build/compiler/generate.js +67 -32
- package/build/compiler/image-report.d.ts +2 -0
- package/build/compiler/image-report.js +62 -0
- package/build/compiler/plugin.js +1 -1
- package/build/compiler/prerender.d.ts +7 -0
- package/build/compiler/prerender.js +111 -0
- package/build/compiler/routes.d.ts +3 -0
- package/build/compiler/routes.js +50 -5
- package/build/compiler/seo.d.ts +70 -0
- package/build/compiler/seo.js +221 -0
- package/build/compiler/vite.js +13 -1
- package/build/io/.tsbuildinfo +1 -1
- package/build/shared/.tsbuildinfo +1 -1
- package/examples/basic/client/404.tsx +1 -1
- package/examples/basic/client/components/Header.tsx +38 -0
- package/examples/basic/client/components/HoneycombBackground.tsx +86 -18
- package/examples/basic/client/global-error.tsx +3 -3
- package/examples/basic/client/layout.tsx +2 -33
- package/examples/basic/client/public/images/test_image.webp +0 -0
- package/examples/basic/client/routes/about.tsx +8 -0
- package/examples/basic/client/routes/get-started.tsx +1 -1
- package/examples/basic/client/routes/index.tsx +8 -1
- package/examples/basic/client/routes/io.tsx +1 -1
- package/examples/basic/client/routes/loader-demo/index.tsx +29 -1
- package/examples/basic/client/routes/test.tsx +8 -0
- package/examples/basic/client/styles/main.css +48 -1
- package/package.json +8 -6
- package/presets/eslint.js +7 -4
- package/presets/tsconfig.json +1 -1
- package/src/backend/index.ts +1 -1
- package/src/cli/configure.ts +102 -21
- package/src/cli/create.ts +25 -9
- package/src/cli/features.ts +33 -1
- package/src/cli/index.ts +10 -1
- package/src/cli/ui.ts +1 -1
- package/src/cli/validate.ts +1 -1
- package/src/client/components/Form.tsx +65 -0
- package/src/client/components/Image.tsx +89 -0
- package/src/client/components/Script.tsx +113 -0
- package/src/client/components/Slot.tsx +21 -0
- package/src/client/dev/error-overlay.tsx +197 -0
- package/src/client/head/head.ts +28 -3
- package/src/client/head/metadata.ts +92 -0
- package/src/client/index.ts +20 -3
- package/src/client/navigation/Link.tsx +1 -1
- package/src/client/navigation/navigation.ts +74 -4
- package/src/client/navigation/prefetch.ts +2 -2
- package/src/client/routing/Router.tsx +128 -62
- package/src/client/routing/action.ts +122 -0
- package/src/client/routing/error-boundary.tsx +1 -1
- package/src/client/routing/hooks.ts +17 -23
- package/src/client/routing/loader.ts +158 -35
- package/src/client/routing/mount.tsx +25 -3
- package/src/client/routing/slot-context.ts +7 -0
- package/src/client/types.ts +6 -4
- package/src/compiler/config.ts +40 -3
- package/src/compiler/docs.ts +26 -26
- package/src/compiler/fonts.ts +87 -0
- package/src/compiler/generate.ts +69 -31
- package/src/compiler/image-report.ts +85 -0
- package/src/compiler/plugin.ts +2 -2
- package/src/compiler/prerender.ts +130 -0
- package/src/compiler/routes.ts +62 -7
- package/src/compiler/seo.ts +356 -0
- package/src/compiler/vite.ts +21 -4
- package/src/io/FastSet.ts +1 -1
- package/src/io/index.ts +1 -1
- package/src/io/types.ts +1 -1
- package/src/server/index.ts +1 -1
- package/src/server/main.ts +1 -1
- package/src/shared/index.ts +1 -1
- package/test/dom/Image.test.tsx +46 -0
- package/test/dom/Script.test.tsx +45 -0
- package/test/dom/action.test.tsx +129 -0
- package/test/dom/error-overlay.test.tsx +44 -0
- package/test/dom/loader.test.tsx +121 -0
- package/test/dom/revalidate.test.tsx +38 -0
- package/test/dom/route-head.test.tsx +34 -0
- package/test/dom/router-loading.test.tsx +44 -0
- package/test/dom/slot.test.tsx +109 -0
- package/test/dom/view-transitions.test.tsx +51 -0
- package/test/features.test.ts +31 -0
- package/test/fonts.test.ts +26 -0
- package/test/metadata.test.ts +41 -0
- package/test/prerender.test.ts +46 -0
- package/test/routes.test.ts +20 -1
- package/test/seo.test.ts +142 -0
- package/examples/basic/client/template.tsx +0 -7
package/src/compiler/generate.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
+
}
|
package/src/compiler/plugin.ts
CHANGED
|
@@ -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
|
|
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}
|
|
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
|
+
}
|
package/src/compiler/routes.ts
CHANGED
|
@@ -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(
|
|
121
|
+
pattern: target ?? filePathToRoute(rel),
|
|
122
|
+
slot: slotOf(rel),
|
|
123
|
+
intercept: target !== null,
|
|
69
124
|
});
|
|
70
125
|
}
|
|
71
126
|
}
|