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.
- package/build/backend/.tsbuildinfo +1 -1
- package/build/cli/.tsbuildinfo +1 -1
- package/build/cli/configure.js +5 -5
- package/build/cli/create.js +4 -4
- package/build/client/.tsbuildinfo +1 -1
- 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 +5 -1
- package/build/client/index.js +3 -1
- 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 +55 -33
- package/build/client/routing/hooks.js +2 -6
- package/build/client/routing/loader.d.ts +2 -0
- package/build/client/routing/loader.js +9 -1
- 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 +8 -0
- package/build/compiler/config.js +4 -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 +65 -32
- 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 +5 -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/global-error.tsx +1 -1
- package/examples/basic/client/routes/about.tsx +8 -0
- package/examples/basic/client/routes/get-started.tsx +1 -1
- package/examples/basic/client/routes/io.tsx +1 -1
- package/examples/basic/client/routes/loader-demo/index.tsx +7 -2
- package/package.json +1 -1
- package/presets/eslint.js +7 -4
- package/presets/tsconfig.json +1 -1
- package/src/backend/index.ts +1 -1
- package/src/cli/configure.ts +7 -7
- package/src/cli/create.ts +7 -7
- package/src/cli/features.ts +2 -2
- package/src/cli/index.ts +1 -1
- package/src/cli/ui.ts +1 -1
- package/src/cli/validate.ts +1 -1
- package/src/client/components/Form.tsx +2 -2
- package/src/client/components/Image.tsx +2 -2
- package/src/client/components/Script.tsx +3 -3
- 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 +5 -1
- 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 +121 -67
- package/src/client/routing/action.ts +4 -4
- package/src/client/routing/error-boundary.tsx +1 -1
- package/src/client/routing/hooks.ts +6 -25
- package/src/client/routing/loader.ts +20 -8
- 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 +31 -3
- package/src/compiler/docs.ts +26 -26
- package/src/compiler/fonts.ts +87 -0
- package/src/compiler/generate.ts +66 -31
- package/src/compiler/image-report.ts +1 -1
- 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 +9 -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/error-overlay.test.tsx +44 -0
- package/test/dom/revalidate.test.tsx +38 -0
- package/test/dom/route-head.test.tsx +34 -0
- package/test/dom/slot.test.tsx +109 -0
- package/test/dom/view-transitions.test.tsx +51 -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/src/compiler/config.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
};
|
package/src/compiler/docs.ts
CHANGED
|
@@ -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
|
|
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
|
|
20
|
-
- \`.toil/docs/routing.md
|
|
21
|
-
- \`.toil/docs/client.md
|
|
22
|
-
- \`.toil/docs/styling.md
|
|
23
|
-
- \`.toil/docs/server.md
|
|
24
|
-
- \`.toil/docs/cli.md
|
|
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
|
|
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
|
|
88
|
-
'- `toil.config.ts
|
|
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
|
|
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
|
|
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
|
|
118
|
-
'- `error.tsx
|
|
119
|
-
'- `client/404.tsx
|
|
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
|
|
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
|
|
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
|
|
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
|
|
202
|
-
'- `server/index.ts
|
|
203
|
-
'- `server/tsconfig.json
|
|
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]
|
|
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
|
|
215
|
-
'- `toiljs build
|
|
216
|
-
'- `toiljs start
|
|
217
|
-
'- `toiljs configure
|
|
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
|
+
}
|
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,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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
*
|
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
|
+
}
|