toiljs 0.0.81 → 0.0.82

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.
@@ -7,7 +7,7 @@ import type * as TS from 'typescript';
7
7
  import type { Plugin } from 'vite';
8
8
 
9
9
  import { type ResolvedToilConfig } from './config.js';
10
- import { scanRoutes } from './routes.js';
10
+ import { patternToBracketFile, scanRoutes, staticSectionPattern } from './routes.js';
11
11
  import { injectSeoHtml, routeSeo } from './seo.js';
12
12
 
13
13
  type Ts = typeof TS;
@@ -115,12 +115,51 @@ export function extractStaticMetadata(ts: Ts, filePath: string): Record<string,
115
115
  return extractStaticExports(ts, filePath, ['metadata']).metadata ?? null;
116
116
  }
117
117
 
118
+ /**
119
+ * True when the route file has a literal `export const <name> = true`. Used to detect the edge-SSR
120
+ * opt-in (`export const ssr = true`) without spinning up a Vite SSR server, so the static prerender
121
+ * can leave SSR routes to the SSR path.
122
+ */
123
+ export function exportsTrue(ts: Ts, filePath: string, name: string): boolean {
124
+ let source: string;
125
+ try {
126
+ source = fs.readFileSync(filePath, 'utf8');
127
+ } catch {
128
+ return false;
129
+ }
130
+ const sf = ts.createSourceFile(
131
+ filePath,
132
+ source,
133
+ ts.ScriptTarget.Latest,
134
+ true,
135
+ ts.ScriptKind.TSX,
136
+ );
137
+ for (const stmt of sf.statements) {
138
+ if (!ts.isVariableStatement(stmt)) continue;
139
+ if (!stmt.modifiers?.some((m) => m.kind === ts.SyntaxKind.ExportKeyword)) continue;
140
+ for (const decl of stmt.declarationList.declarations) {
141
+ if (
142
+ ts.isIdentifier(decl.name) &&
143
+ decl.name.text === name &&
144
+ decl.initializer?.kind === ts.SyntaxKind.TrueKeyword
145
+ ) {
146
+ return true;
147
+ }
148
+ }
149
+ }
150
+ return false;
151
+ }
152
+
118
153
  /**
119
154
  * Build-only plugin that statically pre-renders per-route HTML for SEO. After the bundle is written,
120
- * it takes the built shell (`index.html`), and for each static route bakes that route's
121
- * `metadata` (merged over the site-wide `seo` defaults) into a `<route>/index.html` so a JS-less
122
- * crawler hitting the route gets correct per-page tags. Dynamic (`generateMetadata`) and `:param`
123
- * routes are skipped (no data at build) and fall back to the client-rendered shell.
155
+ * it takes the built shell (`index.html`), and for each route bakes that route's `metadata` (merged
156
+ * over the site-wide `seo` defaults) into a browsable HTML file so a JS-less crawler gets correct
157
+ * per-page tags:
158
+ * - a STATIC route (`/about`) -> `<route>/index.html`;
159
+ * - a DYNAMIC route (`/blog/:id`) -> a literal bracket template `blog/[id].html` that the static
160
+ * server serves for any concrete `/blog/<x>` and the client router hydrates. Its canonical points
161
+ * at the route's static section (`/blog`); the client sets the exact per-value URL on hydration.
162
+ * A dynamic template is a file, never a `<seg>/index.html` folder, so it never shadows a sibling.
124
163
  */
125
164
  export function prerenderPlugin(cfg: ResolvedToilConfig): Plugin {
126
165
  return {
@@ -139,15 +178,29 @@ export function prerenderPlugin(cfg: ResolvedToilConfig): Plugin {
139
178
  const ts = await loadTypeScript(cfg.root);
140
179
 
141
180
  const routes = scanRoutes(cfg.routesAbsDir).filter(
142
- (r) => r.slot === undefined && !r.intercept && !/[:*]/.test(r.pattern),
181
+ (r) => r.slot === undefined && !r.intercept,
143
182
  );
144
183
  for (const route of routes) {
184
+ const isDynamic = /[:*]/.test(route.pattern);
185
+ // A dynamic route that opts into edge SSR (`export const ssr = true`) is rendered by
186
+ // the SSR template path. Baking a static bracket template would let the edge's
187
+ // static-first serving shadow it, so leave SSR routes to SSR. (When `ts` is null the
188
+ // check is skipped and we bake anyway: fail-OPEN deliberately favors the common
189
+ // non-SSR dynamic route, which would 404 without a template; typescript is always
190
+ // present in a real toiljs project, so this branch is effectively unreachable.)
191
+ if (isDynamic && ts && exportsTrue(ts, route.file, 'ssr')) continue;
145
192
  const metadata = ts ? extractStaticMetadata(ts, route.file) : null;
146
- const html = injectSeoHtml(shell, routeSeo(cfg.seo, metadata, route.pattern));
147
- const target =
148
- route.pattern === '/'
149
- ? shellPath
150
- : path.join(outDir, route.pattern.replace(/^\//, ''), 'index.html');
193
+ // A dynamic route's concrete URL is unknown at build, so its canonical/OG URL points
194
+ // at the static section (`/blog/:id` -> `/blog`); the client refines it on hydration.
195
+ const seoPattern = isDynamic ? staticSectionPattern(route.pattern) : route.pattern;
196
+ const html = injectSeoHtml(shell, routeSeo(cfg.seo, metadata, seoPattern));
197
+ // Dynamic -> a literal bracket template (`blog/[id].html`); static -> `<route>/index.html`
198
+ // (the `/` route is the shell itself). The bracket file is never a folder+index.html.
199
+ const target = isDynamic
200
+ ? path.join(outDir, patternToBracketFile(route.pattern))
201
+ : route.pattern === '/'
202
+ ? shellPath
203
+ : path.join(outDir, route.pattern.replace(/^\//, ''), 'index.html');
151
204
  fs.mkdirSync(path.dirname(target), { recursive: true });
152
205
  fs.writeFileSync(target, html);
153
206
  }
@@ -34,6 +34,44 @@ function toUrlSegment(segment: string): string {
34
34
  .replace(/^\[(.+)\]$/, ':$1');
35
35
  }
36
36
 
37
+ /** Inverse of {@link toUrlSegment}: turns a URL pattern segment back into its on-disk bracket
38
+ * form (`:id`→`[id]`, `*x`→`[...x]`, `**x`→`[[...x]]`). Static segments pass through unchanged. */
39
+ function bracketSegment(segment: string): string {
40
+ if (segment.startsWith('**')) return `[[...${segment.slice(2)}]]`;
41
+ if (segment.startsWith('*')) return `[...${segment.slice(1)}]`;
42
+ if (segment.startsWith(':')) return `[${segment.slice(1)}]`;
43
+ return segment;
44
+ }
45
+
46
+ /**
47
+ * The on-disk HTML filename (relative to the build output dir) a DYNAMIC route pattern bakes to:
48
+ * the inverse of the scan mapping, with the last segment as the file and the rest as directories.
49
+ * This is the literal bracket template the static server serves for any matching URL and the client
50
+ * router hydrates, so it must not become a `<seg>/index.html` folder (which would shadow siblings).
51
+ * /blog/:id -> blog/[id].html
52
+ * /docs/*slug -> docs/[...slug].html
53
+ * /docs/**slug -> docs/[[...slug]].html
54
+ * /:category/:id -> [category]/[id].html
55
+ */
56
+ export function patternToBracketFile(pattern: string): string {
57
+ return pattern.split('/').filter(Boolean).map(bracketSegment).join('/') + '.html';
58
+ }
59
+
60
+ /**
61
+ * The static "section" of a pattern: the same pattern with every dynamic (`:`/`*`) segment dropped.
62
+ * Used as the canonical/OG base URL when baking a dynamic route's build-time SEO (the concrete
63
+ * per-value URL is unknown at build; the client sets it on hydration).
64
+ * /blog/:id -> /blog
65
+ * /:category/:id -> /
66
+ */
67
+ export function staticSectionPattern(pattern: string): string {
68
+ const kept = pattern
69
+ .split('/')
70
+ .filter(Boolean)
71
+ .filter((s) => !s.startsWith(':') && !s.startsWith('*'));
72
+ return '/' + kept.join('/');
73
+ }
74
+
37
75
  /** Interception markers: `(.)` same level, `(..)` up one, `(...)` from the routes root. */
38
76
  const INTERCEPT_RE = /^\((\.{1,3})\)(.+)$/;
39
77
 
@@ -84,6 +84,106 @@ export function resolveStaticFile(root: string, requestPath: string): string | n
84
84
  return resolveFileInside(root, decoded.replace(/^\/+/, ''));
85
85
  }
86
86
 
87
+ // --- Dynamic bracket-template routing (mirrors the toil-backend edge) ---------
88
+ //
89
+ // The build emits a dynamic route `blog/[id].tsx` as a literal `blog/[id].html`
90
+ // template that boots the SPA for any concrete `/blog/<x>`. When no exact file
91
+ // matches, walk the URL segments and, at each directory, serve a bracket sibling
92
+ // using the client router's precedence: a real subdir wins (static > dynamic),
93
+ // then `[x].html` (one segment), then `[x]/` (descend), then `[...x].html` (1+),
94
+ // then `[[...x]].html` (0+). Directory scans are memoized.
95
+
96
+ interface DirDynamic {
97
+ singleFile: string | null;
98
+ singleDir: string | null;
99
+ catchall: string | null;
100
+ optCatchall: string | null;
101
+ }
102
+
103
+ const dynDirCache = new Map<string, DirDynamic>();
104
+
105
+ function classifyBracket(name: string, isDir: boolean): keyof DirDynamic | null {
106
+ if (isDir) {
107
+ if (name.startsWith('[') && name.endsWith(']') && !name.includes('...') && name.length > 2)
108
+ return 'singleDir';
109
+ return null;
110
+ }
111
+ if (!name.endsWith('.html')) return null;
112
+ const stem = name.slice(0, -'.html'.length);
113
+ if (stem.startsWith('[[...') && stem.endsWith(']]') && stem.length > 7) return 'optCatchall';
114
+ if (stem.startsWith('[...') && stem.endsWith(']') && stem.length > 5) return 'catchall';
115
+ if (stem.startsWith('[') && stem.endsWith(']') && !stem.startsWith('[..') && stem.length > 2)
116
+ return 'singleFile';
117
+ return null;
118
+ }
119
+
120
+ function scanDirDynamic(absDir: string): DirDynamic {
121
+ const cached = dynDirCache.get(absDir);
122
+ if (cached) return cached;
123
+ const d: DirDynamic = { singleFile: null, singleDir: null, catchall: null, optCatchall: null };
124
+ try {
125
+ for (const e of fs.readdirSync(absDir, { withFileTypes: true })) {
126
+ const kind = classifyBracket(e.name, e.isDirectory());
127
+ if (kind === null) continue;
128
+ const cur = d[kind];
129
+ if (cur === null || e.name < cur) d[kind] = e.name;
130
+ }
131
+ } catch {
132
+ // missing/unreadable directory: no templates
133
+ }
134
+ dynDirCache.set(absDir, d);
135
+ return d;
136
+ }
137
+
138
+ const joinRel = (dir: string, name: string): string => (dir === '' ? name : `${dir}/${name}`);
139
+
140
+ function isDirInside(root: string, rel: string): boolean {
141
+ const resolved = path.resolve(root, rel);
142
+ if (resolved !== root && !resolved.startsWith(root + path.sep)) return false;
143
+ try {
144
+ return fs.statSync(resolved).isDirectory();
145
+ } catch {
146
+ return false;
147
+ }
148
+ }
149
+
150
+ function resolveDynamicWalk(root: string, dir: string, segs: string[], i: number): string | null {
151
+ if (i >= segs.length) {
152
+ // Nothing left: only an optional catch-all (0 segments) serves the bare dir.
153
+ const oc = scanDirDynamic(path.join(root, dir)).optCatchall;
154
+ return oc === null ? null : joinRel(dir, oc);
155
+ }
156
+ const isLast = i + 1 === segs.length;
157
+ // A real static subdirectory wins over any dynamic template at this level.
158
+ const child = joinRel(dir, segs[i]);
159
+ if (isDirInside(root, child)) {
160
+ const r = resolveDynamicWalk(root, child, segs, i + 1);
161
+ if (r !== null) return r;
162
+ }
163
+ const d = scanDirDynamic(path.join(root, dir));
164
+ if (isLast && d.singleFile !== null) return joinRel(dir, d.singleFile);
165
+ if (d.singleDir !== null) {
166
+ const r = resolveDynamicWalk(root, joinRel(dir, d.singleDir), segs, i + 1);
167
+ if (r !== null) return r;
168
+ }
169
+ if (d.catchall !== null) return joinRel(dir, d.catchall);
170
+ if (d.optCatchall !== null) return joinRel(dir, d.optCatchall);
171
+ return null;
172
+ }
173
+
174
+ /** Resolve a request path to a bracket-template file (`blog/[id].html`) if one matches, else null. */
175
+ export function resolveDynamicFile(root: string, requestPath: string): string | null {
176
+ let decoded: string;
177
+ try {
178
+ decoded = decodeURIComponent(requestPath);
179
+ } catch {
180
+ return null;
181
+ }
182
+ const segs = decoded.split('/').filter(Boolean);
183
+ const rel = resolveDynamicWalk(root, '', segs, 0);
184
+ return rel === null ? null : resolveFileInside(root, rel);
185
+ }
186
+
87
187
  export interface PreparedHttpResponse {
88
188
  readonly status: number;
89
189
  readonly headers: readonly (readonly [string, string])[];
@@ -31,6 +31,7 @@ import {
31
31
  isDispatchableMethod,
32
32
  prepareSsrResponse,
33
33
  prepareWasmResponse,
34
+ resolveDynamicFile,
34
35
  resolveStaticFile,
35
36
  runtimeServerOptions,
36
37
  toEnvelopeRequest,
@@ -456,7 +457,12 @@ async function startBuiltHttpServer(
456
457
  // lookup EVERY route served the root index.html, so view-source on e.g. /login showed
457
458
  // the home page's <head> (title/description/canonical/og) instead of the page's own.
458
459
  const routeHtml = resolveStaticFile(paths.staticRoot, `${request.path}/index.html`);
459
- response.sendFile(routeHtml ?? paths.indexHtml);
460
+ // A dynamic route with no baked concrete page is served by its bracket template
461
+ // (`blog/[id].html`) matched the way the client router matches, mirroring the edge; the
462
+ // client then hydrates it for the concrete URL. Falls back to the root shell if none.
463
+ const dynHtml =
464
+ routeHtml === null ? resolveDynamicFile(paths.staticRoot, request.path) : null;
465
+ response.sendFile(routeHtml ?? dynHtml ?? paths.indexHtml);
460
466
  return;
461
467
  }
462
468
 
@@ -0,0 +1,132 @@
1
+ import fs from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+
5
+ import ts from 'typescript';
6
+ import { afterEach, describe, expect, it } from 'vitest';
7
+
8
+ import { exportsTrue } from '../src/compiler/prerender';
9
+ import { patternToBracketFile, staticSectionPattern } from '../src/compiler/routes';
10
+ import { resolveDynamicFile } from '../src/devserver/http/runtime';
11
+
12
+ describe('patternToBracketFile', () => {
13
+ it('inverts URL patterns back to on-disk bracket filenames', () => {
14
+ expect(patternToBracketFile('/blog/:id')).toBe('blog/[id].html');
15
+ expect(patternToBracketFile('/docs/*slug')).toBe('docs/[...slug].html');
16
+ expect(patternToBracketFile('/docs/**slug')).toBe('docs/[[...slug]].html');
17
+ expect(patternToBracketFile('/:category/:id')).toBe('[category]/[id].html');
18
+ expect(patternToBracketFile('/gallery/photo/:id')).toBe('gallery/photo/[id].html');
19
+ });
20
+ });
21
+
22
+ describe('staticSectionPattern', () => {
23
+ it('strips dynamic segments to the static section (for the template canonical)', () => {
24
+ expect(staticSectionPattern('/blog/:id')).toBe('/blog');
25
+ expect(staticSectionPattern('/docs/*slug')).toBe('/docs');
26
+ expect(staticSectionPattern('/:category/:id')).toBe('/');
27
+ expect(staticSectionPattern('/gallery/photo/:id')).toBe('/gallery/photo');
28
+ });
29
+ });
30
+
31
+ describe('exportsTrue (edge-SSR opt-in detection)', () => {
32
+ const files: string[] = [];
33
+ function tmp(source: string): string {
34
+ const file = path.join(os.tmpdir(), `toil-ssrflag-${String(files.length)}-${process.pid}.tsx`);
35
+ fs.writeFileSync(file, source);
36
+ files.push(file);
37
+ return file;
38
+ }
39
+ afterEach(() => {
40
+ for (const f of files.splice(0)) fs.rmSync(f, { force: true });
41
+ });
42
+
43
+ it('detects `export const ssr = true`', () => {
44
+ expect(exportsTrue(ts, tmp(`export const ssr = true;\nexport default () => null;\n`), 'ssr')).toBe(
45
+ true,
46
+ );
47
+ });
48
+ it('is false for ssr=false, a non-literal, or an absent export', () => {
49
+ expect(exportsTrue(ts, tmp(`export const ssr = false;\n`), 'ssr')).toBe(false);
50
+ expect(exportsTrue(ts, tmp(`const ssr = true;\n`), 'ssr')).toBe(false); // not exported
51
+ expect(exportsTrue(ts, tmp(`export const other = true;\n`), 'ssr')).toBe(false);
52
+ });
53
+ });
54
+
55
+ describe('resolveDynamicFile (self-host bracket routing, mirrors the edge)', () => {
56
+ const roots: string[] = [];
57
+ function siteRoot(): string {
58
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'toil-dyn-'));
59
+ roots.push(root);
60
+ return root;
61
+ }
62
+ function write(root: string, rel: string, body = '<html></html>'): void {
63
+ const full = path.join(root, rel);
64
+ fs.mkdirSync(path.dirname(full), { recursive: true });
65
+ fs.writeFileSync(full, body);
66
+ }
67
+ afterEach(() => {
68
+ for (const d of roots.splice(0)) fs.rmSync(d, { recursive: true, force: true });
69
+ });
70
+
71
+ it('serves a single-segment [id].html for a matching URL, one segment only', () => {
72
+ const root = siteRoot();
73
+ write(root, 'blog/[id].html', 'blog-shell');
74
+ expect(resolveDynamicFile(root, '/blog/hello')).toBe(path.join(root, 'blog/[id].html'));
75
+ expect(resolveDynamicFile(root, '/blog/a/b')).toBeNull();
76
+ });
77
+
78
+ it('serves a required catch-all for 1+ segments but not zero', () => {
79
+ const root = siteRoot();
80
+ write(root, 'docs/[...slug].html', 'docs');
81
+ expect(resolveDynamicFile(root, '/docs/a')).toBe(path.join(root, 'docs/[...slug].html'));
82
+ expect(resolveDynamicFile(root, '/docs/a/b/c')).toBe(path.join(root, 'docs/[...slug].html'));
83
+ expect(resolveDynamicFile(root, '/docs')).toBeNull();
84
+ // Parity with the client router + edge: a literal `index.html` segment is a slug (matches
85
+ // the catch-all), while a bare `/docs/` is zero trailing segments (no required-catch-all match).
86
+ expect(resolveDynamicFile(root, '/docs/index.html')).toBe(
87
+ path.join(root, 'docs/[...slug].html'),
88
+ );
89
+ expect(resolveDynamicFile(root, '/docs/')).toBeNull();
90
+ });
91
+
92
+ it('serves an optional catch-all for 0+ segments', () => {
93
+ const root = siteRoot();
94
+ write(root, 'files/[[...slug]].html', 'files');
95
+ expect(resolveDynamicFile(root, '/files')).toBe(path.join(root, 'files/[[...slug]].html'));
96
+ expect(resolveDynamicFile(root, '/files/a/b')).toBe(path.join(root, 'files/[[...slug]].html'));
97
+ });
98
+
99
+ it('prefers single over catch-all for one segment, catch-all for deeper', () => {
100
+ const root = siteRoot();
101
+ write(root, 'shop/[id].html', 'one');
102
+ write(root, 'shop/[...rest].html', 'many');
103
+ expect(fs.readFileSync(resolveDynamicFile(root, '/shop/x') as string, 'utf8')).toBe('one');
104
+ expect(fs.readFileSync(resolveDynamicFile(root, '/shop/x/y') as string, 'utf8')).toBe('many');
105
+ });
106
+
107
+ it('prefers a real static subdirectory over a dynamic sibling', () => {
108
+ const root = siteRoot();
109
+ write(root, 'blog/[id].html', 'template');
110
+ write(root, 'blog/about/deep/[id].html', 'deep');
111
+ // /blog/about/deep/7 descends the real dirs to the deep template, not blog/[id].html.
112
+ expect(resolveDynamicFile(root, '/blog/about/deep/7')).toBe(
113
+ path.join(root, 'blog/about/deep/[id].html'),
114
+ );
115
+ });
116
+
117
+ it('resolves a nested bracket directory', () => {
118
+ const root = siteRoot();
119
+ write(root, '[category]/[id].html', 'cat');
120
+ expect(resolveDynamicFile(root, '/electronics/9')).toBe(
121
+ path.join(root, '[category]/[id].html'),
122
+ );
123
+ expect(resolveDynamicFile(root, '/electronics')).toBeNull();
124
+ });
125
+
126
+ it('returns null when no bracket template exists', () => {
127
+ const root = siteRoot();
128
+ write(root, 'about/index.html', 'about');
129
+ expect(resolveDynamicFile(root, '/blog/foo')).toBeNull();
130
+ expect(resolveDynamicFile(root, '/x/y/z')).toBeNull();
131
+ });
132
+ });