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
|
@@ -2,6 +2,7 @@ import fs from 'node:fs';
|
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { writeDocs } from './docs.js';
|
|
4
4
|
import { scanRoutes } from './routes.js';
|
|
5
|
+
import { llmsTxt, robotsTxt, sitemapXml } from './seo.js';
|
|
5
6
|
const STYLE_EXTENSIONS = ['css', 'scss', 'sass', 'less', 'styl', 'stylus', 'pcss', 'sss'];
|
|
6
7
|
const ASSET_EXTENSIONS = [
|
|
7
8
|
'svg',
|
|
@@ -17,11 +18,15 @@ const ASSET_EXTENSIONS = [
|
|
|
17
18
|
];
|
|
18
19
|
const STYLE_MODULES = STYLE_EXTENSIONS.map((ext) => `declare module '*.${ext}' {}`).join('\n');
|
|
19
20
|
const ASSET_MODULES = ASSET_EXTENSIONS.map((ext) => `declare module '*.${ext}' {\n const src: string;\n export default src;\n}`).join('\n');
|
|
20
|
-
export const TOIL_ENV_DTS = `// AUTO-GENERATED by toil
|
|
21
|
+
export const TOIL_ENV_DTS = `// AUTO-GENERATED by toil, do not edit.\n` +
|
|
22
|
+
`/// <reference types="vite-imagetools/client" />\n` +
|
|
21
23
|
`declare const Toil: typeof import('toiljs/client');\n` +
|
|
22
24
|
`declare namespace Toil {\n` +
|
|
23
25
|
` type LoaderArgs = import('toiljs/client').LoaderArgs;\n` +
|
|
24
26
|
` type LoaderFunction<T = unknown> = import('toiljs/client').LoaderFunction<T>;\n` +
|
|
27
|
+
` type Revalidate = import('toiljs/client').Revalidate;\n` +
|
|
28
|
+
` type Metadata = import('toiljs/client').Metadata;\n` +
|
|
29
|
+
` type GenerateMetadata<T = unknown> = import('toiljs/client').GenerateMetadata<T>;\n` +
|
|
25
30
|
` type RouteErrorProps = import('toiljs/client').RouteErrorProps;\n` +
|
|
26
31
|
`}\n` +
|
|
27
32
|
`declare const BinaryWriter: typeof import('toiljs/io').BinaryWriter;\n` +
|
|
@@ -38,6 +43,7 @@ export const TOIL_ENV_DTS = `// AUTO-GENERATED by toil — do not edit.\n` +
|
|
|
38
43
|
` export const layout: import('toiljs/client').LayoutLoader;\n` +
|
|
39
44
|
` export const notFound: import('toiljs/client').NotFoundLoader;\n` +
|
|
40
45
|
` export const globalError: import('toiljs/client').ErrorComponentLoader;\n` +
|
|
46
|
+
` export const slots: Record<string, import('toiljs/client').RouteDef[]>;\n` +
|
|
41
47
|
`}\n`;
|
|
42
48
|
function relFromToil(cfg, abs) {
|
|
43
49
|
let rel = path.relative(cfg.toilDir, abs).replace(/\\/g, '/').replace(/\.(tsx|jsx)$/, '');
|
|
@@ -80,7 +86,7 @@ function routePathUnion(routes) {
|
|
|
80
86
|
return members.size ? [...members].join(' | ') : 'string';
|
|
81
87
|
}
|
|
82
88
|
function routesDts(routes) {
|
|
83
|
-
return (`// AUTO-GENERATED by toil
|
|
89
|
+
return (`// AUTO-GENERATED by toil, do not edit.\n` +
|
|
84
90
|
`export {};\n` +
|
|
85
91
|
`declare module 'toiljs/client' {\n` +
|
|
86
92
|
` interface Register {\n` +
|
|
@@ -140,62 +146,91 @@ export function generate(cfg) {
|
|
|
140
146
|
const layoutFile = findLayout(cfg);
|
|
141
147
|
const notFoundFile = findNotFound(cfg);
|
|
142
148
|
const globalErrorFile = findGlobalError(cfg);
|
|
149
|
+
const imp = (f) => `() => import(${JSON.stringify(relFromToil(cfg, f))})`;
|
|
150
|
+
const routeObj = (r) => {
|
|
151
|
+
const layouts = findSpecialChain(cfg, r.file, 'layout', false).map(imp).join(', ');
|
|
152
|
+
const templates = findSpecialChain(cfg, r.file, 'template', true).map(imp).join(', ');
|
|
153
|
+
const parts = [
|
|
154
|
+
`pattern: ${JSON.stringify(r.pattern)}`,
|
|
155
|
+
`load: ${imp(r.file)}`,
|
|
156
|
+
`layouts: [${layouts}]`,
|
|
157
|
+
];
|
|
158
|
+
if (templates)
|
|
159
|
+
parts.push(`templates: [${templates}]`);
|
|
160
|
+
const loadingFile = findNearest(cfg, r.file, 'loading');
|
|
161
|
+
if (loadingFile)
|
|
162
|
+
parts.push(`loading: ${imp(loadingFile)}`);
|
|
163
|
+
const errorFile = findNearest(cfg, r.file, 'error');
|
|
164
|
+
if (errorFile)
|
|
165
|
+
parts.push(`errorComponent: ${imp(errorFile)}`);
|
|
166
|
+
if (r.intercept)
|
|
167
|
+
parts.push(`intercept: true`);
|
|
168
|
+
return `{ ${parts.join(', ')} }`;
|
|
169
|
+
};
|
|
170
|
+
const mainRoutes = routes.filter((r) => r.slot === undefined);
|
|
171
|
+
const slotNames = [...new Set(routes.flatMap((r) => (r.slot ? [r.slot] : [])))];
|
|
172
|
+
const slotsBody = slotNames
|
|
173
|
+
.map((name) => {
|
|
174
|
+
const items = routes
|
|
175
|
+
.filter((r) => r.slot === name)
|
|
176
|
+
.map((r) => ` ${routeObj(r)},`)
|
|
177
|
+
.join('\n');
|
|
178
|
+
return ` ${JSON.stringify(name)}: [\n${items}\n ],`;
|
|
179
|
+
})
|
|
180
|
+
.join('\n');
|
|
143
181
|
const routesSrc = `// @ts-nocheck\n` +
|
|
144
|
-
`// AUTO-GENERATED by toil
|
|
182
|
+
`// AUTO-GENERATED by toil, do not edit.\n` +
|
|
145
183
|
`import type { RouteDef, LayoutLoader, NotFoundLoader } from 'toiljs/client';\n\n` +
|
|
146
|
-
`export const routes: RouteDef[] = [\n` +
|
|
147
|
-
|
|
148
|
-
.map((r) => {
|
|
149
|
-
const imp = (f) => `() => import(${JSON.stringify(relFromToil(cfg, f))})`;
|
|
150
|
-
const layouts = findSpecialChain(cfg, r.file, 'layout', false).map(imp).join(', ');
|
|
151
|
-
const templates = findSpecialChain(cfg, r.file, 'template', true).map(imp).join(', ');
|
|
152
|
-
const parts = [
|
|
153
|
-
`pattern: ${JSON.stringify(r.pattern)}`,
|
|
154
|
-
`load: ${imp(r.file)}`,
|
|
155
|
-
`layouts: [${layouts}]`,
|
|
156
|
-
];
|
|
157
|
-
if (templates)
|
|
158
|
-
parts.push(`templates: [${templates}]`);
|
|
159
|
-
const loadingFile = findNearest(cfg, r.file, 'loading');
|
|
160
|
-
if (loadingFile)
|
|
161
|
-
parts.push(`loading: ${imp(loadingFile)}`);
|
|
162
|
-
const errorFile = findNearest(cfg, r.file, 'error');
|
|
163
|
-
if (errorFile)
|
|
164
|
-
parts.push(`errorComponent: ${imp(errorFile)}`);
|
|
165
|
-
return ` { ${parts.join(', ')} },`;
|
|
166
|
-
})
|
|
167
|
-
.join('\n') +
|
|
168
|
-
`\n];\n\n` +
|
|
184
|
+
`export const routes: RouteDef[] = [\n${mainRoutes.map((r) => ` ${routeObj(r)},`).join('\n')}\n];\n\n` +
|
|
185
|
+
`export const slots: Record<string, RouteDef[]> = {\n${slotsBody}\n};\n\n` +
|
|
169
186
|
`export const layout: LayoutLoader = ${layoutFile ? `() => import(${JSON.stringify(relFromToil(cfg, layoutFile))})` : 'null'};\n` +
|
|
170
187
|
`export const notFound: NotFoundLoader = ${notFoundFile ? `() => import(${JSON.stringify(relFromToil(cfg, notFoundFile))})` : 'null'};\n` +
|
|
171
188
|
`export const globalError = ${globalErrorFile ? `() => import(${JSON.stringify(relFromToil(cfg, globalErrorFile))})` : 'null'};\n`;
|
|
172
189
|
fs.writeFileSync(path.join(cfg.toilDir, 'routes.ts'), routesSrc);
|
|
173
190
|
const globalsSrc = `// @ts-nocheck\n` +
|
|
174
|
-
`// AUTO-GENERATED by toil
|
|
191
|
+
`// AUTO-GENERATED by toil, do not edit.\n` +
|
|
175
192
|
`import * as Toil from 'toiljs/client';\n` +
|
|
176
193
|
`import { BinaryWriter, BinaryReader, FastMap, FastSet } from 'toiljs/io';\n\n` +
|
|
177
|
-
`Object.assign(globalThis, { Toil, BinaryWriter, BinaryReader, FastMap, FastSet });\n
|
|
194
|
+
`Object.assign(globalThis, { Toil, BinaryWriter, BinaryReader, FastMap, FastSet });\n` +
|
|
195
|
+
`Toil.setViewTransitions(${String(cfg.viewTransitions)});\n`;
|
|
178
196
|
fs.writeFileSync(path.join(cfg.toilDir, 'globals.ts'), globalsSrc);
|
|
179
197
|
const entryFile = findEntry(cfg);
|
|
180
198
|
const entrySrc = entryFile
|
|
181
199
|
? `// @ts-nocheck\n` +
|
|
182
|
-
`// AUTO-GENERATED by toil
|
|
200
|
+
`// AUTO-GENERATED by toil, do not edit.\n` +
|
|
183
201
|
`import './globals';\n` +
|
|
184
202
|
`import ${JSON.stringify(relFromToil(cfg, entryFile))};\n`
|
|
185
203
|
: `// @ts-nocheck\n` +
|
|
186
|
-
`// AUTO-GENERATED by toil
|
|
204
|
+
`// AUTO-GENERATED by toil, do not edit.\n` +
|
|
187
205
|
`import './globals';\n` +
|
|
188
206
|
`import { mount } from 'toiljs/client';\n` +
|
|
189
|
-
`import { routes, layout, notFound, globalError } from './routes';\n\n` +
|
|
190
|
-
`mount(routes, layout, notFound, globalError);\n`;
|
|
207
|
+
`import { routes, layout, notFound, globalError, slots } from './routes';\n\n` +
|
|
208
|
+
`mount(routes, layout, notFound, globalError, slots);\n`;
|
|
191
209
|
fs.writeFileSync(path.join(cfg.toilDir, 'entry.tsx'), entrySrc);
|
|
192
210
|
fs.writeFileSync(path.join(cfg.root, 'toil-env.d.ts'), TOIL_ENV_DTS);
|
|
193
211
|
fs.writeFileSync(path.join(cfg.root, 'toil-routes.d.ts'), routesDts(routes));
|
|
194
212
|
fs.writeFileSync(path.join(cfg.toilDir, 'index.html'), buildHtml(cfg));
|
|
195
213
|
syncPublicAssets(cfg);
|
|
214
|
+
writeSeoFiles(cfg, routes);
|
|
196
215
|
writeDocs(cfg.toilDir);
|
|
197
216
|
return routes;
|
|
198
217
|
}
|
|
218
|
+
function writeSeoFiles(cfg, routes) {
|
|
219
|
+
if (!cfg.seo)
|
|
220
|
+
return;
|
|
221
|
+
const dest = path.join(cfg.toilDir, 'public');
|
|
222
|
+
const files = [
|
|
223
|
+
['robots.txt', robotsTxt(cfg.seo)],
|
|
224
|
+
['sitemap.xml', sitemapXml(cfg.seo, routes)],
|
|
225
|
+
['llms.txt', llmsTxt(cfg.seo, routes)],
|
|
226
|
+
];
|
|
227
|
+
const present = files.filter(([, content]) => content !== '');
|
|
228
|
+
if (present.length === 0)
|
|
229
|
+
return;
|
|
230
|
+
fs.mkdirSync(dest, { recursive: true });
|
|
231
|
+
for (const [name, content] of present)
|
|
232
|
+
fs.writeFileSync(path.join(dest, name), content);
|
|
233
|
+
}
|
|
199
234
|
const DEFAULT_HTML = `<!doctype html>\n<html lang="en">\n <head>\n <meta charset="utf-8" />\n` +
|
|
200
235
|
` <meta name="viewport" content="width=device-width, initial-scale=1" />\n` +
|
|
201
236
|
` <meta name="description" content="" />\n` +
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import pc from 'picocolors';
|
|
4
|
+
const IMAGE_RE = /\.(png|jpe?g|webp|avif|gif|tiff|svg)$/i;
|
|
5
|
+
function kb(bytes) {
|
|
6
|
+
return `${(bytes / 1000).toFixed(2)} kB`;
|
|
7
|
+
}
|
|
8
|
+
export function imageReportPlugin(projectRoot, viteRoot) {
|
|
9
|
+
let logger;
|
|
10
|
+
return {
|
|
11
|
+
name: 'toil:image-report',
|
|
12
|
+
apply: 'build',
|
|
13
|
+
configResolved(config) {
|
|
14
|
+
logger = config.logger;
|
|
15
|
+
},
|
|
16
|
+
writeBundle(_options, bundle) {
|
|
17
|
+
const bySource = new Map();
|
|
18
|
+
for (const file of Object.values(bundle)) {
|
|
19
|
+
if (file.type !== 'asset' || !IMAGE_RE.test(file.fileName))
|
|
20
|
+
continue;
|
|
21
|
+
const source = file.originalFileNames[0];
|
|
22
|
+
const key = source ?? file.fileName;
|
|
23
|
+
const outSize = typeof file.source === 'string'
|
|
24
|
+
? Buffer.byteLength(file.source)
|
|
25
|
+
: file.source.byteLength;
|
|
26
|
+
let entry = bySource.get(key);
|
|
27
|
+
if (!entry) {
|
|
28
|
+
let inSize = null;
|
|
29
|
+
let label = '(generated)';
|
|
30
|
+
if (source !== undefined) {
|
|
31
|
+
const abs = path.resolve(viteRoot, source);
|
|
32
|
+
label = path.relative(projectRoot, abs);
|
|
33
|
+
try {
|
|
34
|
+
inSize = fs.statSync(abs).size;
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
inSize = null;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
entry = { label, inSize, variants: [] };
|
|
41
|
+
bySource.set(key, entry);
|
|
42
|
+
}
|
|
43
|
+
entry.variants.push({ out: file.fileName, outSize });
|
|
44
|
+
}
|
|
45
|
+
if (bySource.size === 0 || !logger)
|
|
46
|
+
return;
|
|
47
|
+
const count = bySource.size;
|
|
48
|
+
logger.info('');
|
|
49
|
+
logger.info(pc.green(` ✓ optimized ${String(count)} image${count === 1 ? '' : 's'}`));
|
|
50
|
+
for (const { label, inSize, variants } of bySource.values()) {
|
|
51
|
+
logger.info(` ${pc.dim(label)}`);
|
|
52
|
+
for (const v of variants) {
|
|
53
|
+
const saved = inSize && inSize > 0
|
|
54
|
+
? pc.green(` -${String(Math.round((1 - v.outSize / inSize) * 100))}%`)
|
|
55
|
+
: '';
|
|
56
|
+
const from = inSize ? `${kb(inSize)} → ` : '';
|
|
57
|
+
logger.info(` ${pc.dim('→')} ${v.out} ${from}${kb(v.outSize)}${saved}`);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
}
|
package/build/compiler/plugin.js
CHANGED
|
@@ -12,7 +12,7 @@ export function toilPlugin(cfg) {
|
|
|
12
12
|
/^[ \t]*export\b[^'"\n]*\bfrom\s+(['"])\1/m.test(code) ||
|
|
13
13
|
/\bimport\s*\(\s*(['"])\1\s*\)/.test(code);
|
|
14
14
|
if (empty) {
|
|
15
|
-
throw new Error(`toil: empty import specifier (e.g. \`import '';\`) in ${file}
|
|
15
|
+
throw new Error(`toil: empty import specifier (e.g. \`import '';\`) in ${file}, remove or complete the import.`);
|
|
16
16
|
}
|
|
17
17
|
return null;
|
|
18
18
|
},
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type * as TS from 'typescript';
|
|
2
|
+
import type { Plugin } from 'vite';
|
|
3
|
+
import { type ResolvedToilConfig } from './config.js';
|
|
4
|
+
type Ts = typeof TS;
|
|
5
|
+
export declare function extractStaticMetadata(ts: Ts, filePath: string): Record<string, unknown> | null;
|
|
6
|
+
export declare function prerenderPlugin(cfg: ResolvedToilConfig): Plugin;
|
|
7
|
+
export {};
|
|
@@ -0,0 +1,111 @@
|
|
|
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
|
+
import { scanRoutes } from './routes.js';
|
|
6
|
+
import { injectSeoHtml, routeSeo } from './seo.js';
|
|
7
|
+
async function loadTypeScript(root) {
|
|
8
|
+
try {
|
|
9
|
+
const resolved = createRequire(path.join(root, 'package.json')).resolve('typescript');
|
|
10
|
+
const mod = (await import(pathToFileURL(resolved).href));
|
|
11
|
+
return mod.default ?? mod;
|
|
12
|
+
}
|
|
13
|
+
catch {
|
|
14
|
+
return null;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
const UNRESOLVED = Symbol('unresolved');
|
|
18
|
+
function evalNode(ts, node) {
|
|
19
|
+
if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node))
|
|
20
|
+
return node.text;
|
|
21
|
+
if (ts.isNumericLiteral(node))
|
|
22
|
+
return Number(node.text);
|
|
23
|
+
if (node.kind === ts.SyntaxKind.TrueKeyword)
|
|
24
|
+
return true;
|
|
25
|
+
if (node.kind === ts.SyntaxKind.FalseKeyword)
|
|
26
|
+
return false;
|
|
27
|
+
if (node.kind === ts.SyntaxKind.NullKeyword)
|
|
28
|
+
return null;
|
|
29
|
+
if (ts.isArrayLiteralExpression(node)) {
|
|
30
|
+
const out = [];
|
|
31
|
+
for (const el of node.elements) {
|
|
32
|
+
const value = evalNode(ts, el);
|
|
33
|
+
if (value === UNRESOLVED)
|
|
34
|
+
return UNRESOLVED;
|
|
35
|
+
out.push(value);
|
|
36
|
+
}
|
|
37
|
+
return out;
|
|
38
|
+
}
|
|
39
|
+
if (ts.isObjectLiteralExpression(node))
|
|
40
|
+
return evalObject(ts, node);
|
|
41
|
+
return UNRESOLVED;
|
|
42
|
+
}
|
|
43
|
+
function evalObject(ts, node) {
|
|
44
|
+
const obj = {};
|
|
45
|
+
for (const prop of node.properties) {
|
|
46
|
+
if (!ts.isPropertyAssignment(prop))
|
|
47
|
+
continue;
|
|
48
|
+
const key = ts.isIdentifier(prop.name)
|
|
49
|
+
? prop.name.text
|
|
50
|
+
: ts.isStringLiteral(prop.name)
|
|
51
|
+
? prop.name.text
|
|
52
|
+
: null;
|
|
53
|
+
if (key === null)
|
|
54
|
+
continue;
|
|
55
|
+
const value = evalNode(ts, prop.initializer);
|
|
56
|
+
if (value !== UNRESOLVED)
|
|
57
|
+
obj[key] = value;
|
|
58
|
+
}
|
|
59
|
+
return obj;
|
|
60
|
+
}
|
|
61
|
+
export function extractStaticMetadata(ts, filePath) {
|
|
62
|
+
let source;
|
|
63
|
+
try {
|
|
64
|
+
source = fs.readFileSync(filePath, 'utf8');
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
const sf = ts.createSourceFile(filePath, source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
|
|
70
|
+
for (const stmt of sf.statements) {
|
|
71
|
+
if (!ts.isVariableStatement(stmt))
|
|
72
|
+
continue;
|
|
73
|
+
if (!stmt.modifiers?.some((m) => m.kind === ts.SyntaxKind.ExportKeyword))
|
|
74
|
+
continue;
|
|
75
|
+
for (const decl of stmt.declarationList.declarations) {
|
|
76
|
+
if (ts.isIdentifier(decl.name) &&
|
|
77
|
+
decl.name.text === 'metadata' &&
|
|
78
|
+
decl.initializer &&
|
|
79
|
+
ts.isObjectLiteralExpression(decl.initializer)) {
|
|
80
|
+
return evalObject(ts, decl.initializer);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
export function prerenderPlugin(cfg) {
|
|
87
|
+
return {
|
|
88
|
+
name: 'toil:prerender-seo',
|
|
89
|
+
apply: 'build',
|
|
90
|
+
async closeBundle() {
|
|
91
|
+
if (!cfg.seo)
|
|
92
|
+
return;
|
|
93
|
+
const outDir = path.resolve(cfg.root, cfg.outDir);
|
|
94
|
+
const shellPath = path.join(outDir, 'index.html');
|
|
95
|
+
if (!fs.existsSync(shellPath))
|
|
96
|
+
return;
|
|
97
|
+
const shell = fs.readFileSync(shellPath, 'utf8');
|
|
98
|
+
const ts = await loadTypeScript(cfg.root);
|
|
99
|
+
const routes = scanRoutes(cfg.routesAbsDir).filter((r) => r.slot === undefined && !r.intercept && !/[:*]/.test(r.pattern));
|
|
100
|
+
for (const route of routes) {
|
|
101
|
+
const metadata = ts ? extractStaticMetadata(ts, route.file) : null;
|
|
102
|
+
const html = injectSeoHtml(shell, routeSeo(cfg.seo, metadata, route.pattern));
|
|
103
|
+
const target = route.pattern === '/'
|
|
104
|
+
? shellPath
|
|
105
|
+
: path.join(outDir, route.pattern.replace(/^\//, ''), 'index.html');
|
|
106
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
107
|
+
fs.writeFileSync(target, html);
|
|
108
|
+
}
|
|
109
|
+
},
|
|
110
|
+
};
|
|
111
|
+
}
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
export interface ScannedRoute {
|
|
2
2
|
readonly file: string;
|
|
3
3
|
readonly pattern: string;
|
|
4
|
+
readonly slot?: string;
|
|
5
|
+
readonly intercept?: boolean;
|
|
4
6
|
}
|
|
5
7
|
export declare function filePathToRoute(relPath: string): string;
|
|
8
|
+
export declare function interceptTarget(relPath: string): string | null;
|
|
6
9
|
export declare function scanRoutes(routesDir: string): ScannedRoute[];
|
package/build/compiler/routes.js
CHANGED
|
@@ -2,6 +2,13 @@ import fs from 'node:fs';
|
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
const ROUTE_EXT = /\.(tsx|jsx)$/;
|
|
4
4
|
const SPECIAL_FILE = /^(layout|template|loading|error|global-error|404|not-found)\.(tsx|jsx)$/;
|
|
5
|
+
function toUrlSegment(segment) {
|
|
6
|
+
return segment
|
|
7
|
+
.replace(/^\[\[\.\.\.(.+)\]\]$/, '**$1')
|
|
8
|
+
.replace(/^\[\.\.\.(.+)\]$/, '*$1')
|
|
9
|
+
.replace(/^\[(.+)\]$/, ':$1');
|
|
10
|
+
}
|
|
11
|
+
const INTERCEPT_RE = /^\((\.{1,3})\)(.+)$/;
|
|
5
12
|
export function filePathToRoute(relPath) {
|
|
6
13
|
const withoutExt = relPath.replace(/\\/g, '/').replace(ROUTE_EXT, '');
|
|
7
14
|
const segments = withoutExt.split('/').filter(Boolean);
|
|
@@ -10,15 +17,41 @@ export function filePathToRoute(relPath) {
|
|
|
10
17
|
const segment = segments[i];
|
|
11
18
|
if (/^\(.+\)$/.test(segment))
|
|
12
19
|
continue;
|
|
20
|
+
if (/^@/.test(segment))
|
|
21
|
+
continue;
|
|
13
22
|
if (segment === 'index' && i === segments.length - 1)
|
|
14
23
|
continue;
|
|
15
|
-
out.push(segment
|
|
16
|
-
.replace(/^\[\[\.\.\.(.+)\]\]$/, '**$1')
|
|
17
|
-
.replace(/^\[\.\.\.(.+)\]$/, '*$1')
|
|
18
|
-
.replace(/^\[(.+)\]$/, ':$1'));
|
|
24
|
+
out.push(toUrlSegment(segment));
|
|
19
25
|
}
|
|
20
26
|
return '/' + out.join('/');
|
|
21
27
|
}
|
|
28
|
+
export function interceptTarget(relPath) {
|
|
29
|
+
const segments = relPath.replace(/\\/g, '/').replace(ROUTE_EXT, '').split('/').filter(Boolean);
|
|
30
|
+
const out = [];
|
|
31
|
+
let marked = false;
|
|
32
|
+
for (let i = 0; i < segments.length; i++) {
|
|
33
|
+
const segment = segments[i];
|
|
34
|
+
if (/^@/.test(segment))
|
|
35
|
+
continue;
|
|
36
|
+
const marker = INTERCEPT_RE.exec(segment);
|
|
37
|
+
if (marker) {
|
|
38
|
+
marked = true;
|
|
39
|
+
const dots = marker[1].length;
|
|
40
|
+
if (dots === 2)
|
|
41
|
+
out.pop();
|
|
42
|
+
else if (dots === 3)
|
|
43
|
+
out.length = 0;
|
|
44
|
+
out.push(toUrlSegment(marker[2]));
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
if (/^\(.+\)$/.test(segment))
|
|
48
|
+
continue;
|
|
49
|
+
if (segment === 'index' && i === segments.length - 1)
|
|
50
|
+
continue;
|
|
51
|
+
out.push(toUrlSegment(segment));
|
|
52
|
+
}
|
|
53
|
+
return marked ? '/' + out.join('/') : null;
|
|
54
|
+
}
|
|
22
55
|
function specificity(pattern) {
|
|
23
56
|
const segments = pattern.split('/').filter(Boolean);
|
|
24
57
|
let score = segments.length * 10;
|
|
@@ -30,6 +63,14 @@ function specificity(pattern) {
|
|
|
30
63
|
}
|
|
31
64
|
return score;
|
|
32
65
|
}
|
|
66
|
+
function slotOf(relPath) {
|
|
67
|
+
for (const segment of relPath.replace(/\\/g, '/').split('/')) {
|
|
68
|
+
const match = /^@(.+)$/.exec(segment);
|
|
69
|
+
if (match)
|
|
70
|
+
return match[1];
|
|
71
|
+
}
|
|
72
|
+
return undefined;
|
|
73
|
+
}
|
|
33
74
|
export function scanRoutes(routesDir) {
|
|
34
75
|
if (!fs.existsSync(routesDir))
|
|
35
76
|
return [];
|
|
@@ -41,9 +82,13 @@ export function scanRoutes(routesDir) {
|
|
|
41
82
|
walk(full);
|
|
42
83
|
}
|
|
43
84
|
else if (ROUTE_EXT.test(entry.name) && !SPECIAL_FILE.test(entry.name)) {
|
|
85
|
+
const rel = path.relative(routesDir, full);
|
|
86
|
+
const target = interceptTarget(rel);
|
|
44
87
|
found.push({
|
|
45
88
|
file: full,
|
|
46
|
-
pattern: filePathToRoute(
|
|
89
|
+
pattern: target ?? filePathToRoute(rel),
|
|
90
|
+
slot: slotOf(rel),
|
|
91
|
+
intercept: target !== null,
|
|
47
92
|
});
|
|
48
93
|
}
|
|
49
94
|
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import type { ScannedRoute } from './routes.js';
|
|
2
|
+
export interface SeoOpenGraph {
|
|
3
|
+
readonly title?: string;
|
|
4
|
+
readonly description?: string;
|
|
5
|
+
readonly type?: string;
|
|
6
|
+
readonly siteName?: string;
|
|
7
|
+
readonly locale?: string;
|
|
8
|
+
readonly image?: string;
|
|
9
|
+
readonly imageAlt?: string;
|
|
10
|
+
readonly imageWidth?: number;
|
|
11
|
+
readonly imageHeight?: number;
|
|
12
|
+
readonly imageType?: string;
|
|
13
|
+
}
|
|
14
|
+
export interface SeoTwitter {
|
|
15
|
+
readonly card?: string;
|
|
16
|
+
readonly site?: string;
|
|
17
|
+
readonly creator?: string;
|
|
18
|
+
readonly title?: string;
|
|
19
|
+
readonly description?: string;
|
|
20
|
+
readonly image?: string;
|
|
21
|
+
readonly imageAlt?: string;
|
|
22
|
+
}
|
|
23
|
+
export interface RobotsRule {
|
|
24
|
+
readonly userAgent?: string | readonly string[];
|
|
25
|
+
readonly allow?: readonly string[];
|
|
26
|
+
readonly disallow?: readonly string[];
|
|
27
|
+
}
|
|
28
|
+
export interface RobotsConfig {
|
|
29
|
+
readonly rules?: readonly RobotsRule[];
|
|
30
|
+
readonly ai?: 'allow' | 'disallow';
|
|
31
|
+
readonly sitemap?: string;
|
|
32
|
+
}
|
|
33
|
+
export interface LlmsPage {
|
|
34
|
+
readonly title: string;
|
|
35
|
+
readonly url: string;
|
|
36
|
+
readonly description?: string;
|
|
37
|
+
}
|
|
38
|
+
export interface LlmsConfig {
|
|
39
|
+
readonly title?: string;
|
|
40
|
+
readonly summary?: string;
|
|
41
|
+
readonly instructions?: string;
|
|
42
|
+
readonly pages?: readonly LlmsPage[];
|
|
43
|
+
}
|
|
44
|
+
export interface SeoConfig {
|
|
45
|
+
readonly url?: string;
|
|
46
|
+
readonly title?: string;
|
|
47
|
+
readonly description?: string;
|
|
48
|
+
readonly robotsMeta?: string;
|
|
49
|
+
readonly themeColor?: string;
|
|
50
|
+
readonly openGraph?: SeoOpenGraph;
|
|
51
|
+
readonly twitter?: SeoTwitter;
|
|
52
|
+
readonly facebook?: {
|
|
53
|
+
readonly appId?: string;
|
|
54
|
+
};
|
|
55
|
+
readonly jsonLd?: Record<string, unknown> | readonly Record<string, unknown>[];
|
|
56
|
+
readonly preconnect?: readonly string[];
|
|
57
|
+
readonly dnsPrefetch?: readonly string[];
|
|
58
|
+
readonly robots?: RobotsConfig | false;
|
|
59
|
+
readonly sitemap?: boolean;
|
|
60
|
+
readonly llms?: LlmsConfig | boolean;
|
|
61
|
+
}
|
|
62
|
+
export declare function escapeHtml(value: string): string;
|
|
63
|
+
export declare function joinUrl(base: string, path: string): string;
|
|
64
|
+
export declare function seoHeadTags(seo: SeoConfig): string;
|
|
65
|
+
export declare function seoTitle(seo: SeoConfig): string | undefined;
|
|
66
|
+
export declare function injectSeoHtml(html: string, seo: SeoConfig): string;
|
|
67
|
+
export declare function routeSeo(seo: SeoConfig, metadata: Record<string, unknown> | null, pattern: string): SeoConfig;
|
|
68
|
+
export declare function robotsTxt(seo: SeoConfig): string;
|
|
69
|
+
export declare function sitemapXml(seo: SeoConfig, routes: readonly ScannedRoute[]): string;
|
|
70
|
+
export declare function llmsTxt(seo: SeoConfig, routes: readonly ScannedRoute[]): string;
|