toiljs 0.0.81 → 0.0.83

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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "toiljs",
3
3
  "type": "module",
4
- "version": "0.0.81",
4
+ "version": "0.0.83",
5
5
  "author": "Dacely",
6
6
  "description": "The modern React framework: a file-based React frontend and a ToilScript-compiled WebAssembly backend.",
7
7
  "repository": {
@@ -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
 
@@ -0,0 +1,90 @@
1
+ import { createHash } from 'node:crypto';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+
5
+ import type { Plugin } from 'vite';
6
+
7
+ import { type ResolvedToilConfig } from './config.js';
8
+
9
+ /** SHA-384 Subresource-Integrity token (`sha384-<base64>`) for a local file, or `null` if it can't
10
+ * be read. Memoized per absolute path (the same asset is referenced by many baked pages). */
11
+ function computeSri(absPath: string, cache: Map<string, string | null>): string | null {
12
+ const cached = cache.get(absPath);
13
+ if (cached !== undefined) return cached;
14
+ let sri: string | null;
15
+ try {
16
+ sri = `sha384-${createHash('sha384').update(fs.readFileSync(absPath)).digest('base64')}`;
17
+ } catch {
18
+ sri = null;
19
+ }
20
+ cache.set(absPath, sri);
21
+ return sri;
22
+ }
23
+
24
+ /** Resolve a tag URL to a path relative to `outDir`, or `null` if it is not a local build asset
25
+ * (an absolute scheme, protocol-relative, `data:`, or a traversal are all skipped). */
26
+ function toLocalAsset(url: string, base: string): string | null {
27
+ const clean = url.split('?')[0].split('#')[0];
28
+ // scheme (`https:`, `data:`), protocol-relative (`//cdn`), or empty -> not ours to hash.
29
+ if (clean === '' || /^[a-z][a-z0-9+.-]*:/i.test(clean) || clean.startsWith('//')) return null;
30
+ let rel = clean;
31
+ if (base && base !== '/' && rel.startsWith(base)) rel = rel.slice(base.length);
32
+ else if (rel.startsWith('/')) rel = rel.slice(1);
33
+ else if (rel.startsWith('./')) rel = rel.slice(2);
34
+ if (rel === '' || rel.includes('..')) return null;
35
+ return rel;
36
+ }
37
+
38
+ /**
39
+ * Add Subresource Integrity to every LOCAL `<script src>`, `<link rel="modulepreload" href>`, and
40
+ * `<link rel="stylesheet" href>` in `html`: an `integrity="sha384-…"` computed from the asset's
41
+ * bytes plus `crossorigin="anonymous"` (required for the browser to verify + a no-op same-origin).
42
+ * External URLs, inline scripts, non-script/style links, and tags that already carry `integrity`
43
+ * are left untouched. Vite content-hashes these assets, so the integrity is stable and matches the
44
+ * immutable-cached bytes the edge serves.
45
+ */
46
+ export function injectSri(html: string, outDir: string, base: string): string {
47
+ const cache = new Map<string, string | null>();
48
+ return html.replace(/<(script|link)\b([^>]*)>/gi, (full, tag: string, rawAttrs: string) => {
49
+ if (/\bintegrity\s*=/i.test(rawAttrs)) return full; // already integrity-tagged
50
+ const selfClose = /\/\s*$/.test(rawAttrs);
51
+ const attrs = rawAttrs.replace(/\s*\/\s*$/, '');
52
+ const isScript = tag.toLowerCase() === 'script';
53
+ if (!isScript) {
54
+ const rel = /\brel\s*=\s*["']?([^"'\s>]+)/i.exec(attrs)?.[1]?.toLowerCase();
55
+ if (rel !== 'stylesheet' && rel !== 'modulepreload') return full;
56
+ }
57
+ const attr = isScript ? 'src' : 'href';
58
+ const url = new RegExp(`\\b${attr}\\s*=\\s*["']([^"']+)["']`, 'i').exec(attrs)?.[1];
59
+ if (url === undefined) return full; // inline script or no url
60
+ const rel = toLocalAsset(url, base);
61
+ if (rel === null) return full;
62
+ const sri = computeSri(path.join(outDir, rel), cache);
63
+ if (sri === null) return full;
64
+ const cross = /\bcrossorigin\b/i.test(attrs) ? '' : ' crossorigin="anonymous"';
65
+ return `<${tag}${attrs} integrity="${sri}"${cross}${selfClose ? ' /' : ''}>`;
66
+ });
67
+ }
68
+
69
+ /**
70
+ * Build-only plugin: after the bundle is written, rewrites the built shell (`outDir/index.html`) so
71
+ * every local script/stylesheet/modulepreload carries an `integrity`. It runs in `closeBundle`
72
+ * (assets are on disk, so real bytes are hashed) and is registered BEFORE the prerender / SSR-
73
+ * template passes, which bake per-route HTML, bracket templates, and the SSR `.tmpl` FROM this
74
+ * shell -- so every emitted page inherits SRI and the `.tmpl` coherence hash covers it. Build-only:
75
+ * the dev server serves un-hashed modules straight from Vite, where SRI would be meaningless.
76
+ */
77
+ export function sriPlugin(cfg: ResolvedToilConfig): Plugin {
78
+ return {
79
+ name: 'toil:sri',
80
+ apply: 'build',
81
+ closeBundle() {
82
+ const outDir = path.resolve(cfg.root, cfg.outDir);
83
+ const shell = path.join(outDir, 'index.html');
84
+ if (!fs.existsSync(shell)) return;
85
+ const html = fs.readFileSync(shell, 'utf8');
86
+ const out = injectSri(html, outDir, cfg.base);
87
+ if (out !== html) fs.writeFileSync(shell, out);
88
+ },
89
+ };
90
+ }
@@ -15,6 +15,7 @@ import { imageReportPlugin } from './image-report.js';
15
15
  import { imageBlurPlugin } from './image-blur.js';
16
16
  import { toilPlugin } from './plugin.js';
17
17
  import { prerenderPlugin } from './prerender.js';
18
+ import { sriPlugin } from './sri.js';
18
19
 
19
20
  // `vite-plugin-node-polyfills` rewrites react/react-dom to import its `vite-plugin-node-polyfills/
20
21
  // shims/*` modules. When a consumer links toiljs by symlink (`file:`/workspace), that package lives
@@ -182,6 +183,11 @@ export async function createViteConfig(cfg: ResolvedToilConfig): Promise<InlineC
182
183
  })
183
184
  : undefined,
184
185
  cfg.images ? imageReportPlugin(cfg.root, cfg.toilDir) : undefined,
186
+ // Subresource Integrity (build only): adds integrity="sha384-…" + crossorigin to every
187
+ // local script/stylesheet/modulepreload in the shell. MUST precede prerenderPlugin so the
188
+ // per-route/bracket/SSR pages baked from the shell inherit it (and the SSR .tmpl coherence
189
+ // hash covers it).
190
+ sriPlugin(cfg),
185
191
  // Static per-route SEO prerender (build only): bakes each route's metadata into its HTML.
186
192
  cfg.seo ? prerenderPlugin(cfg) : undefined,
187
193
  // Preload bundled fonts (build only). Disabled by `client.fonts: false`.
@@ -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
+ });
@@ -0,0 +1,73 @@
1
+ import fs from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+
5
+ import { afterEach, describe, expect, it } from 'vitest';
6
+
7
+ import { injectSri } from '../src/compiler/sri';
8
+
9
+ describe('injectSri', () => {
10
+ const dirs: string[] = [];
11
+ function outDir(files: Record<string, string>): string {
12
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'toil-sri-'));
13
+ dirs.push(root);
14
+ for (const [rel, body] of Object.entries(files)) {
15
+ const full = path.join(root, rel);
16
+ fs.mkdirSync(path.dirname(full), { recursive: true });
17
+ fs.writeFileSync(full, body);
18
+ }
19
+ return root;
20
+ }
21
+ afterEach(() => {
22
+ for (const d of dirs.splice(0)) fs.rmSync(d, { recursive: true, force: true });
23
+ });
24
+
25
+ it('adds sha384 integrity + crossorigin to a local module script', () => {
26
+ const root = outDir({ 'assets/index-abc.js': 'console.log(1)' });
27
+ const html = injectSri(
28
+ '<script type="module" crossorigin src="/assets/index-abc.js"></script>',
29
+ root,
30
+ '/',
31
+ );
32
+ expect(html).toMatch(/integrity="sha384-[A-Za-z0-9+/]+=*"/);
33
+ // Vite already put crossorigin on the tag, so it isn't duplicated.
34
+ expect(html.match(/crossorigin/g)?.length).toBe(1);
35
+ });
36
+
37
+ it('adds integrity to stylesheets and modulepreload, plus a missing crossorigin', () => {
38
+ const root = outDir({
39
+ 'assets/a.css': 'body{}',
40
+ 'assets/chunk.js': 'export default 1',
41
+ });
42
+ const css = injectSri('<link rel="stylesheet" href="/assets/a.css">', root, '/');
43
+ expect(css).toMatch(/integrity="sha384-/);
44
+ expect(css).toContain('crossorigin="anonymous"');
45
+ const pre = injectSri('<link rel="modulepreload" href="/assets/chunk.js" />', root, '/');
46
+ expect(pre).toMatch(/integrity="sha384-/);
47
+ expect(pre.trimEnd().endsWith('/>')).toBe(true); // self-closing preserved
48
+ });
49
+
50
+ it('leaves external URLs, inline scripts, icons, and missing files alone', () => {
51
+ const root = outDir({ 'assets/x.js': 'x' });
52
+ const external = '<script src="https://cdn.example.com/a.js"></script>';
53
+ expect(injectSri(external, root, '/')).toBe(external);
54
+ const inline = '<script type="application/json">{"a":1}</script>';
55
+ expect(injectSri(inline, root, '/')).toBe(inline);
56
+ const icon = '<link rel="icon" href="/favicon.ico">';
57
+ expect(injectSri(icon, root, '/')).toBe(icon);
58
+ const missing = '<script src="/assets/gone.js"></script>';
59
+ expect(injectSri(missing, root, '/')).toBe(missing); // unreadable -> skipped, not crashed
60
+ });
61
+
62
+ it('does not double-tag a script that already has integrity', () => {
63
+ const root = outDir({ 'assets/x.js': 'x' });
64
+ const already = '<script src="/assets/x.js" integrity="sha384-existing"></script>';
65
+ expect(injectSri(already, root, '/')).toBe(already);
66
+ });
67
+
68
+ it('honors a non-root base', () => {
69
+ const root = outDir({ 'assets/x.js': 'x' });
70
+ const html = injectSri('<script src="/sub/assets/x.js"></script>', root, '/sub/');
71
+ expect(html).toMatch(/integrity="sha384-/);
72
+ });
73
+ });