toiljs 0.0.8 → 0.0.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (105) hide show
  1. package/build/backend/.tsbuildinfo +1 -1
  2. package/build/cli/.tsbuildinfo +1 -1
  3. package/build/cli/configure.js +5 -5
  4. package/build/cli/create.js +4 -4
  5. package/build/client/.tsbuildinfo +1 -1
  6. package/build/client/components/Slot.d.ts +6 -0
  7. package/build/client/components/Slot.js +6 -0
  8. package/build/client/dev/error-overlay.d.ts +20 -0
  9. package/build/client/dev/error-overlay.js +123 -0
  10. package/build/client/head/head.d.ts +2 -0
  11. package/build/client/head/head.js +17 -2
  12. package/build/client/head/metadata.d.ts +29 -0
  13. package/build/client/head/metadata.js +38 -0
  14. package/build/client/index.d.ts +5 -1
  15. package/build/client/index.js +3 -1
  16. package/build/client/navigation/navigation.d.ts +3 -0
  17. package/build/client/navigation/navigation.js +42 -1
  18. package/build/client/routing/Router.d.ts +1 -0
  19. package/build/client/routing/Router.js +55 -33
  20. package/build/client/routing/hooks.js +2 -6
  21. package/build/client/routing/loader.d.ts +2 -0
  22. package/build/client/routing/loader.js +9 -1
  23. package/build/client/routing/mount.d.ts +1 -1
  24. package/build/client/routing/mount.js +12 -4
  25. package/build/client/routing/slot-context.d.ts +2 -0
  26. package/build/client/routing/slot-context.js +2 -0
  27. package/build/client/types.d.ts +1 -0
  28. package/build/compiler/.tsbuildinfo +1 -1
  29. package/build/compiler/config.d.ts +8 -0
  30. package/build/compiler/config.js +4 -1
  31. package/build/compiler/docs.js +26 -26
  32. package/build/compiler/fonts.d.ts +4 -0
  33. package/build/compiler/fonts.js +64 -0
  34. package/build/compiler/generate.js +65 -32
  35. package/build/compiler/plugin.js +1 -1
  36. package/build/compiler/prerender.d.ts +7 -0
  37. package/build/compiler/prerender.js +111 -0
  38. package/build/compiler/routes.d.ts +3 -0
  39. package/build/compiler/routes.js +50 -5
  40. package/build/compiler/seo.d.ts +70 -0
  41. package/build/compiler/seo.js +221 -0
  42. package/build/compiler/vite.js +5 -1
  43. package/build/io/.tsbuildinfo +1 -1
  44. package/build/shared/.tsbuildinfo +1 -1
  45. package/examples/basic/client/404.tsx +1 -1
  46. package/examples/basic/client/global-error.tsx +1 -1
  47. package/examples/basic/client/routes/about.tsx +8 -0
  48. package/examples/basic/client/routes/get-started.tsx +1 -1
  49. package/examples/basic/client/routes/io.tsx +1 -1
  50. package/examples/basic/client/routes/loader-demo/index.tsx +7 -2
  51. package/package.json +1 -1
  52. package/presets/eslint.js +7 -4
  53. package/presets/tsconfig.json +1 -1
  54. package/src/backend/index.ts +1 -1
  55. package/src/cli/configure.ts +7 -7
  56. package/src/cli/create.ts +7 -7
  57. package/src/cli/features.ts +2 -2
  58. package/src/cli/index.ts +1 -1
  59. package/src/cli/ui.ts +1 -1
  60. package/src/cli/validate.ts +1 -1
  61. package/src/client/components/Form.tsx +2 -2
  62. package/src/client/components/Image.tsx +2 -2
  63. package/src/client/components/Script.tsx +3 -3
  64. package/src/client/components/Slot.tsx +21 -0
  65. package/src/client/dev/error-overlay.tsx +197 -0
  66. package/src/client/head/head.ts +28 -3
  67. package/src/client/head/metadata.ts +92 -0
  68. package/src/client/index.ts +5 -1
  69. package/src/client/navigation/Link.tsx +1 -1
  70. package/src/client/navigation/navigation.ts +74 -4
  71. package/src/client/navigation/prefetch.ts +2 -2
  72. package/src/client/routing/Router.tsx +121 -67
  73. package/src/client/routing/action.ts +4 -4
  74. package/src/client/routing/error-boundary.tsx +1 -1
  75. package/src/client/routing/hooks.ts +6 -25
  76. package/src/client/routing/loader.ts +20 -8
  77. package/src/client/routing/mount.tsx +25 -3
  78. package/src/client/routing/slot-context.ts +7 -0
  79. package/src/client/types.ts +6 -4
  80. package/src/compiler/config.ts +31 -3
  81. package/src/compiler/docs.ts +26 -26
  82. package/src/compiler/fonts.ts +87 -0
  83. package/src/compiler/generate.ts +66 -31
  84. package/src/compiler/image-report.ts +1 -1
  85. package/src/compiler/plugin.ts +2 -2
  86. package/src/compiler/prerender.ts +130 -0
  87. package/src/compiler/routes.ts +62 -7
  88. package/src/compiler/seo.ts +356 -0
  89. package/src/compiler/vite.ts +9 -4
  90. package/src/io/FastSet.ts +1 -1
  91. package/src/io/index.ts +1 -1
  92. package/src/io/types.ts +1 -1
  93. package/src/server/index.ts +1 -1
  94. package/src/server/main.ts +1 -1
  95. package/src/shared/index.ts +1 -1
  96. package/test/dom/error-overlay.test.tsx +44 -0
  97. package/test/dom/revalidate.test.tsx +38 -0
  98. package/test/dom/route-head.test.tsx +34 -0
  99. package/test/dom/slot.test.tsx +109 -0
  100. package/test/dom/view-transitions.test.tsx +51 -0
  101. package/test/fonts.test.ts +26 -0
  102. package/test/metadata.test.ts +41 -0
  103. package/test/prerender.test.ts +46 -0
  104. package/test/routes.test.ts +20 -1
  105. package/test/seo.test.ts +142 -0
@@ -0,0 +1,64 @@
1
+ const FONT_RE = /\.(woff2|woff|ttf|otf)$/i;
2
+ const FONT_TYPE = {
3
+ woff2: 'font/woff2',
4
+ woff: 'font/woff',
5
+ ttf: 'font/ttf',
6
+ otf: 'font/otf',
7
+ };
8
+ function kb(bytes) {
9
+ return `${(bytes / 1000).toFixed(2)} kB`;
10
+ }
11
+ export function fontPreloadTags(fileNames, base) {
12
+ const prefix = base.endsWith('/') ? base : `${base}/`;
13
+ return fileNames
14
+ .filter((name) => FONT_RE.test(name))
15
+ .map((name) => {
16
+ const ext = name.split('.').pop()?.toLowerCase() ?? '';
17
+ return {
18
+ tag: 'link',
19
+ attrs: {
20
+ rel: 'preload',
21
+ as: 'font',
22
+ type: FONT_TYPE[ext] ?? `font/${ext}`,
23
+ href: `${prefix}${name}`,
24
+ crossorigin: '',
25
+ },
26
+ injectTo: 'head',
27
+ };
28
+ });
29
+ }
30
+ export function fontPreloadPlugin(cfg) {
31
+ let logger;
32
+ let logged = false;
33
+ return {
34
+ name: 'toil:font-preload',
35
+ apply: 'build',
36
+ configResolved(config) {
37
+ logger = config.logger;
38
+ },
39
+ transformIndexHtml: {
40
+ order: 'post',
41
+ handler(html, ctx) {
42
+ const bundle = ctx.bundle ?? {};
43
+ const fonts = Object.values(bundle).filter((file) => file.type === 'asset' && FONT_RE.test(file.fileName));
44
+ if (fonts.length === 0)
45
+ return html;
46
+ if (!logged && logger) {
47
+ logged = true;
48
+ logger.info('');
49
+ logger.info(` ✓ preloaded ${String(fonts.length)} font${fonts.length === 1 ? '' : 's'}`);
50
+ for (const file of fonts) {
51
+ const size = file.type === 'asset' && typeof file.source !== 'string'
52
+ ? kb(file.source.byteLength)
53
+ : '';
54
+ logger.info(` → ${file.fileName} ${size}`);
55
+ }
56
+ }
57
+ return {
58
+ html,
59
+ tags: fontPreloadTags(fonts.map((f) => f.fileName), cfg.base),
60
+ };
61
+ },
62
+ },
63
+ };
64
+ }
@@ -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,13 +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 do not edit.\n` +
21
+ export const TOIL_ENV_DTS = `// AUTO-GENERATED by toil, do not edit.\n` +
21
22
  `/// <reference types="vite-imagetools/client" />\n` +
22
23
  `declare const Toil: typeof import('toiljs/client');\n` +
23
24
  `declare namespace Toil {\n` +
24
25
  ` type LoaderArgs = import('toiljs/client').LoaderArgs;\n` +
25
26
  ` type LoaderFunction<T = unknown> = import('toiljs/client').LoaderFunction<T>;\n` +
26
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` +
27
30
  ` type RouteErrorProps = import('toiljs/client').RouteErrorProps;\n` +
28
31
  `}\n` +
29
32
  `declare const BinaryWriter: typeof import('toiljs/io').BinaryWriter;\n` +
@@ -40,6 +43,7 @@ export const TOIL_ENV_DTS = `// AUTO-GENERATED by toil — do not edit.\n` +
40
43
  ` export const layout: import('toiljs/client').LayoutLoader;\n` +
41
44
  ` export const notFound: import('toiljs/client').NotFoundLoader;\n` +
42
45
  ` export const globalError: import('toiljs/client').ErrorComponentLoader;\n` +
46
+ ` export const slots: Record<string, import('toiljs/client').RouteDef[]>;\n` +
43
47
  `}\n`;
44
48
  function relFromToil(cfg, abs) {
45
49
  let rel = path.relative(cfg.toilDir, abs).replace(/\\/g, '/').replace(/\.(tsx|jsx)$/, '');
@@ -82,7 +86,7 @@ function routePathUnion(routes) {
82
86
  return members.size ? [...members].join(' | ') : 'string';
83
87
  }
84
88
  function routesDts(routes) {
85
- return (`// AUTO-GENERATED by toil do not edit.\n` +
89
+ return (`// AUTO-GENERATED by toil, do not edit.\n` +
86
90
  `export {};\n` +
87
91
  `declare module 'toiljs/client' {\n` +
88
92
  ` interface Register {\n` +
@@ -142,62 +146,91 @@ export function generate(cfg) {
142
146
  const layoutFile = findLayout(cfg);
143
147
  const notFoundFile = findNotFound(cfg);
144
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');
145
181
  const routesSrc = `// @ts-nocheck\n` +
146
- `// AUTO-GENERATED by toil do not edit.\n` +
182
+ `// AUTO-GENERATED by toil, do not edit.\n` +
147
183
  `import type { RouteDef, LayoutLoader, NotFoundLoader } from 'toiljs/client';\n\n` +
148
- `export const routes: RouteDef[] = [\n` +
149
- routes
150
- .map((r) => {
151
- const imp = (f) => `() => import(${JSON.stringify(relFromToil(cfg, f))})`;
152
- const layouts = findSpecialChain(cfg, r.file, 'layout', false).map(imp).join(', ');
153
- const templates = findSpecialChain(cfg, r.file, 'template', true).map(imp).join(', ');
154
- const parts = [
155
- `pattern: ${JSON.stringify(r.pattern)}`,
156
- `load: ${imp(r.file)}`,
157
- `layouts: [${layouts}]`,
158
- ];
159
- if (templates)
160
- parts.push(`templates: [${templates}]`);
161
- const loadingFile = findNearest(cfg, r.file, 'loading');
162
- if (loadingFile)
163
- parts.push(`loading: ${imp(loadingFile)}`);
164
- const errorFile = findNearest(cfg, r.file, 'error');
165
- if (errorFile)
166
- parts.push(`errorComponent: ${imp(errorFile)}`);
167
- return ` { ${parts.join(', ')} },`;
168
- })
169
- .join('\n') +
170
- `\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` +
171
186
  `export const layout: LayoutLoader = ${layoutFile ? `() => import(${JSON.stringify(relFromToil(cfg, layoutFile))})` : 'null'};\n` +
172
187
  `export const notFound: NotFoundLoader = ${notFoundFile ? `() => import(${JSON.stringify(relFromToil(cfg, notFoundFile))})` : 'null'};\n` +
173
188
  `export const globalError = ${globalErrorFile ? `() => import(${JSON.stringify(relFromToil(cfg, globalErrorFile))})` : 'null'};\n`;
174
189
  fs.writeFileSync(path.join(cfg.toilDir, 'routes.ts'), routesSrc);
175
190
  const globalsSrc = `// @ts-nocheck\n` +
176
- `// AUTO-GENERATED by toil do not edit.\n` +
191
+ `// AUTO-GENERATED by toil, do not edit.\n` +
177
192
  `import * as Toil from 'toiljs/client';\n` +
178
193
  `import { BinaryWriter, BinaryReader, FastMap, FastSet } from 'toiljs/io';\n\n` +
179
- `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`;
180
196
  fs.writeFileSync(path.join(cfg.toilDir, 'globals.ts'), globalsSrc);
181
197
  const entryFile = findEntry(cfg);
182
198
  const entrySrc = entryFile
183
199
  ? `// @ts-nocheck\n` +
184
- `// AUTO-GENERATED by toil do not edit.\n` +
200
+ `// AUTO-GENERATED by toil, do not edit.\n` +
185
201
  `import './globals';\n` +
186
202
  `import ${JSON.stringify(relFromToil(cfg, entryFile))};\n`
187
203
  : `// @ts-nocheck\n` +
188
- `// AUTO-GENERATED by toil do not edit.\n` +
204
+ `// AUTO-GENERATED by toil, do not edit.\n` +
189
205
  `import './globals';\n` +
190
206
  `import { mount } from 'toiljs/client';\n` +
191
- `import { routes, layout, notFound, globalError } from './routes';\n\n` +
192
- `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`;
193
209
  fs.writeFileSync(path.join(cfg.toilDir, 'entry.tsx'), entrySrc);
194
210
  fs.writeFileSync(path.join(cfg.root, 'toil-env.d.ts'), TOIL_ENV_DTS);
195
211
  fs.writeFileSync(path.join(cfg.root, 'toil-routes.d.ts'), routesDts(routes));
196
212
  fs.writeFileSync(path.join(cfg.toilDir, 'index.html'), buildHtml(cfg));
197
213
  syncPublicAssets(cfg);
214
+ writeSeoFiles(cfg, routes);
198
215
  writeDocs(cfg.toilDir);
199
216
  return routes;
200
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
+ }
201
234
  const DEFAULT_HTML = `<!doctype html>\n<html lang="en">\n <head>\n <meta charset="utf-8" />\n` +
202
235
  ` <meta name="viewport" content="width=device-width, initial-scale=1" />\n` +
203
236
  ` <meta name="description" content="" />\n` +
@@ -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} remove or complete the import.`);
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[];
@@ -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(path.relative(routesDir, full)),
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;