toiljs 0.0.80 → 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.
@@ -8,7 +8,7 @@ import pc from 'picocolors';
8
8
  import { startDaemonRuntime } from './daemon/runtime.js';
9
9
  import { configureDbPersistence } from './db/index.js';
10
10
  import { initEmailService } from './email/index.js';
11
- import { assembleRouteSsr, dispatchEnvelopeRequest, installRuntimeErrorHandler, isDispatchableMethod, prepareSsrResponse, prepareWasmResponse, resolveStaticFile, runtimeServerOptions, toEnvelopeRequest, } from './http/runtime.js';
11
+ import { assembleRouteSsr, dispatchEnvelopeRequest, installRuntimeErrorHandler, isDispatchableMethod, prepareSsrResponse, prepareWasmResponse, resolveDynamicFile, resolveStaticFile, runtimeServerOptions, toEnvelopeRequest, } from './http/runtime.js';
12
12
  import { decodeBody, encodeBody, isWorkerToPrimaryMessage, } from './production-ipc.js';
13
13
  import { WasmServerModule } from './runtime/module.js';
14
14
  import { buildSsrRoutes, pathnameOf } from './ssr.js';
@@ -297,7 +297,8 @@ async function startBuiltHttpServer(options, paths, dynamicHandler, runtimeOptio
297
297
  return;
298
298
  if (request.method === 'GET' || request.method === 'HEAD') {
299
299
  const routeHtml = resolveStaticFile(paths.staticRoot, `${request.path}/index.html`);
300
- response.sendFile(routeHtml ?? paths.indexHtml);
300
+ const dynHtml = routeHtml === null ? resolveDynamicFile(paths.staticRoot, request.path) : null;
301
+ response.sendFile(routeHtml ?? dynHtml ?? paths.indexHtml);
301
302
  return;
302
303
  }
303
304
  response.status(404).send('not found\n');
@@ -1,7 +1,12 @@
1
+ import cube from '../assets/cube.webp?toil';
2
+
1
3
  export default function TestPage() {
2
4
  return (
3
5
  <div className="test-page">
4
6
  <img src="/images/test_image.webp" alt="Test" className="test-page-image" />
7
+ {/* A `?toil` import auto-generates a blurred LQIP + dimensions; placeholder="blur" fades
8
+ the real image in over it, and the aspect-ratio reserves space (no layout shift). */}
9
+ <Toil.Image src={cube} alt="Cube" placeholder="blur" />
5
10
  </div>
6
11
  );
7
12
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "toiljs",
3
3
  "type": "module",
4
- "version": "0.0.80",
4
+ "version": "0.0.82",
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": {
@@ -5,11 +5,21 @@ import { type ComponentPropsWithRef, type CSSProperties, type ReactNode, useStat
5
5
  * `src` and `alt` are required (`alt` is enforced for accessibility, pass `alt=""` for decorative
6
6
  * images). `width`/`height` (or `fill`) reserve space to prevent layout shift.
7
7
  */
8
+ /** A toil image source carrying an auto-generated blur LQIP + intrinsic size, produced by importing an
9
+ * image with the `?toil` flag (`import hero from './hero.webp?toil'`). Pass it straight to `Image`. */
10
+ export interface ToilImageSource {
11
+ src: string;
12
+ width?: number;
13
+ height?: number;
14
+ blurDataURL?: string;
15
+ }
16
+
8
17
  export interface ImageProps extends Omit<
9
18
  ComponentPropsWithRef<'img'>,
10
- 'loading' | 'placeholder' | 'width' | 'height'
19
+ 'loading' | 'placeholder' | 'width' | 'height' | 'src'
11
20
  > {
12
- src: string;
21
+ /** The image URL, or a `?toil` import object that auto-fills the blur placeholder + aspect-ratio. */
22
+ src: string | ToilImageSource;
13
23
  alt: string;
14
24
  /** Intrinsic width in px. Set together with `height` to reserve space (avoids layout shift). */
15
25
  width?: number;
@@ -31,12 +41,17 @@ export interface ImageProps extends Omit<
31
41
  */
32
42
  priority?: boolean;
33
43
  /**
34
- * Placeholder shown until the image loads: `'empty'` (default) or `'blur'`. `'blur'` needs
35
- * `blurDataURL` AND a reserved size (`width`+`height`, or `fill`) without a box there's nothing
36
- * to paint it in. Layout shift is prevented by the reserved size, not by the placeholder.
44
+ * Placeholder shown until the image loads: `'empty'` (default) or `'blur'`. `'blur'` paints the
45
+ * `blurDataURL` (auto-filled from a `?toil` import, or passed) blurred, or a neutral skeleton
46
+ * shimmer if there's none. It needs a reserved size (`width`+`height`, or `fill`) to paint into
47
+ * which also prevents layout shift (the placeholder is cosmetic; the reserved size is what holds).
37
48
  */
38
49
  placeholder?: 'empty' | 'blur';
39
- /** A tiny (base64) image shown blurred behind the image while it loads, when `placeholder="blur"`. */
50
+ /**
51
+ * A tiny base64 image shown blurred while the real image loads (with `placeholder="blur"`).
52
+ * Auto-generated when you import via `?toil` (`import hero from './hero.webp?toil'`); pass it
53
+ * explicitly only for a string `src`. Omit it to fall back to a neutral skeleton shimmer.
54
+ */
40
55
  blurDataURL?: string;
41
56
  }
42
57
 
@@ -48,36 +63,58 @@ export interface ImageProps extends Omit<
48
63
  */
49
64
  export function Image(props: ImageProps): ReactNode {
50
65
  const {
51
- src,
66
+ src: srcProp,
52
67
  alt,
53
- width,
54
- height,
68
+ width: widthProp,
69
+ height: heightProp,
55
70
  fill = false,
56
71
  objectFit,
57
72
  priority = false,
58
73
  placeholder = 'empty',
59
- blurDataURL,
74
+ blurDataURL: blurDataURLProp,
60
75
  className: userClassName,
61
76
  style,
62
77
  onLoad,
63
78
  ...rest
64
79
  } = props;
65
80
 
81
+ // `src` may be a plain URL or a `?toil` import object; unpack it and let explicit props win, so a
82
+ // `?toil` import auto-fills the blur placeholder + aspect-ratio with no extra props.
83
+ const source = typeof srcProp === 'string' ? null : srcProp;
84
+ const src = typeof srcProp === 'string' ? srcProp : srcProp.src;
85
+ const width = widthProp ?? source?.width;
86
+ const height = heightProp ?? source?.height;
87
+ const blurDataURL = blurDataURLProp ?? source?.blurDataURL;
88
+
66
89
  const [loaded, setLoaded] = useState(false);
67
- const showBlur = placeholder === 'blur' && blurDataURL !== undefined && !loaded;
90
+ // The placeholder paints while the real image loads, then drops on load. With a `blurDataURL`
91
+ // (auto-filled by a `?toil` import, or passed) it's that tiny image blurred; otherwise it's a
92
+ // neutral skeleton shimmer — so `placeholder="blur"` is never a silent no-op. It only shows if
93
+ // there's a reserved box to paint into (give `width`+`height`, or use `fill` in a sized parent).
94
+ const showPlaceholder = placeholder === 'blur' && !loaded;
95
+ const placeholderClass = !showPlaceholder
96
+ ? undefined
97
+ : blurDataURL !== undefined
98
+ ? 'toil-img-blur'
99
+ : 'toil-img-skeleton';
100
+ // Reserve space so the image can't shift layout when it loads: an explicit aspect-ratio derived
101
+ // from width+height survives responsive `width:100%` CSS (bare width/height attributes do not).
102
+ const aspectRatio =
103
+ width !== undefined && height !== undefined ? `${width} / ${height}` : undefined;
68
104
 
69
- // Layout + the blur placeholder come from toil's shipped CSS classes (see `buildHtml`'s
70
- // `<style id="toil-base">`) rather than inline styles, so they're SSR-safe AND overridable by the
71
- // app's CSS. Only genuinely per-instance bits stay inline: `objectFit` and the blur image URL. For
72
- // a non-`fill` image the caller's `className`/`style` ride on the <img>; for `fill` they go on the
73
- // wrapper box (below) instead, since that box is what the caller sizes.
105
+ // Layout + placeholder come from toil's shipped CSS classes (see `buildHtml`'s `<style id="toil-
106
+ // base">`) not inline styles, so they're SSR-safe AND overridable. Only per-instance bits stay
107
+ // inline: `objectFit`, the aspect-ratio, and the blur image URL. For a non-`fill` image the
108
+ // caller's `className`/`style` ride on the <img>; for `fill` they go on the wrapper box below.
74
109
  const imgClass =
75
- [fill ? 'toil-img-fill' : userClassName, showBlur ? 'toil-img-blur' : undefined]
76
- .filter(Boolean)
77
- .join(' ') || undefined;
110
+ [fill ? 'toil-img-fill' : userClassName, placeholderClass].filter(Boolean).join(' ') ||
111
+ undefined;
78
112
  const imgStyle: CSSProperties = {
79
113
  ...(objectFit !== undefined ? { objectFit } : {}),
80
- ...(showBlur ? { backgroundImage: `url(${blurDataURL})` } : {}),
114
+ ...(showPlaceholder && blurDataURL !== undefined
115
+ ? { backgroundImage: `url(${blurDataURL})` }
116
+ : {}),
117
+ ...(fill || aspectRatio === undefined ? {} : { aspectRatio }),
81
118
  ...(fill ? {} : style),
82
119
  };
83
120
 
@@ -107,6 +144,7 @@ export function Image(props: ImageProps): ReactNode {
107
144
  // NEVER absolutely positioned, so it can't escape to fill the page nor collapse to a zero-height
108
145
  // box. We still wrap it so the caller's `width`/`height`/`style` size the box, not the raw `<img>`.
109
146
  const boxStyle: CSSProperties = {
147
+ ...(aspectRatio !== undefined ? { aspectRatio } : {}),
110
148
  ...(width !== undefined ? { width } : {}),
111
149
  ...(height !== undefined ? { height } : {}),
112
150
  ...style,
@@ -96,7 +96,7 @@ export type {
96
96
  export { usePageSearch } from './search/use-page-search.js';
97
97
  export type { PageSearch } from './search/use-page-search.js';
98
98
  export { Image } from './components/Image.js';
99
- export type { ImageProps } from './components/Image.js';
99
+ export type { ImageProps, ToilImageSource } from './components/Image.js';
100
100
  export { Script } from './components/Script.js';
101
101
  export type { ScriptProps, ScriptStrategy } from './components/Script.js';
102
102
  export { Form } from './components/Form.js';
@@ -36,6 +36,9 @@ const IMAGETOOLS_MODULES = [
36
36
  `declare module '*as=srcset' {\n const src: string;\n export default src;\n}`,
37
37
  `declare module '*as=url' {\n const src: string;\n export default src;\n}`,
38
38
  `declare module '*as=metadata' {\n const metadata: { src: string; width?: number; height?: number; format?: string }[];\n export default metadata;\n}`,
39
+ // `import hero from './hero.webp?toil'` -> a Toil.Image source carrying an auto-generated blur LQIP
40
+ // and intrinsic size (see compiler/image-blur.ts). Pass it straight to `<Toil.Image src={hero} />`.
41
+ `declare module '*?toil' {\n const image: { src: string; width: number; height: number; blurDataURL: string };\n export default image;\n}`,
39
42
  ].join('\n');
40
43
 
41
44
  export const TOIL_ENV_DTS =
@@ -412,6 +415,9 @@ const TOIL_BASE_STYLE =
412
415
  `.toil-img-fill-box{display:block;overflow:hidden}` +
413
416
  `.toil-img-fill{display:block;width:100%;height:100%;object-fit:cover}` +
414
417
  `.toil-img-blur{background-size:cover;background-position:center;filter:blur(20px)}` +
418
+ `.toil-img-skeleton{background:linear-gradient(90deg,#ececed 25%,#f4f4f5 37%,#ececed 63%);background-size:400% 100%;animation:toil-img-shimmer 1.4s ease infinite}` +
419
+ `@keyframes toil-img-shimmer{0%{background-position:100% 50%}100%{background-position:0 50%}}` +
420
+ `@media (prefers-reduced-motion:reduce){.toil-img-skeleton{animation:none}}` +
415
421
  `</style>`;
416
422
 
417
423
  /**
@@ -0,0 +1,42 @@
1
+ import type { Plugin } from 'vite';
2
+
3
+ /** An image import carrying the `?toil` flag: `import hero from './hero.webp?toil'`. */
4
+ const TOIL_QUERY = /[?&]toil(?:&|$)/;
5
+ /** Raster formats sharp can downscale into an LQIP. (SVG/animated stay as-is — no blur.) */
6
+ const RASTER = /\.(?:png|jpe?g|webp|avif|gif|tiff?)(?:\?|$)/i;
7
+
8
+ /**
9
+ * Auto-generates a tiny blurred base64 LQIP (low-quality image placeholder) for raster images imported
10
+ * with the `?toil` flag, and returns a `{ src, width, height, blurDataURL }` object that `Toil.Image`
11
+ * consumes to auto-fill its `blurDataURL` (`placeholder="blur"`) and its aspect-ratio — the way Next.js
12
+ * bakes a `blurDataURL` for static image imports. Runs in dev and build (Vite `load`); `sharp` is loaded
13
+ * lazily so it only costs anything when a `?toil` import is actually resolved.
14
+ *
15
+ * Usage: `import hero from './hero.webp?toil'` then `<Toil.Image src={hero} placeholder="blur" />`.
16
+ * The bare `src` is re-imported with no query, so Vite/imagetools still optimize + hash the real asset
17
+ * (this plugin only adds the placeholder + intrinsic size); it skips itself on that bare import.
18
+ */
19
+ export function imageBlurPlugin(): Plugin {
20
+ return {
21
+ name: 'toil:image-blur',
22
+ enforce: 'pre',
23
+ async load(id) {
24
+ if (!TOIL_QUERY.test(id) || !RASTER.test(id)) return undefined;
25
+ const file = id.replace(/\?.*$/, '');
26
+ const { default: sharp } = await import('sharp');
27
+ const meta = await sharp(file).metadata();
28
+ // ~24px longest edge, blurred, webp — a few hundred base64 bytes, inlined as a data URI.
29
+ const lqip = await sharp(file)
30
+ .resize(24, 24, { fit: 'inside' })
31
+ .blur()
32
+ .webp({ quality: 40 })
33
+ .toBuffer();
34
+ const blurDataURL = `data:image/webp;base64,${lqip.toString('base64')}`;
35
+ return (
36
+ `import src from ${JSON.stringify(file)};\n` +
37
+ `export default { src, width: ${meta.width ?? 0}, height: ${meta.height ?? 0}, ` +
38
+ `blurDataURL: ${JSON.stringify(blurDataURL)} };\n`
39
+ );
40
+ },
41
+ };
42
+ }
@@ -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
 
@@ -12,6 +12,7 @@ import { createLogger, type InlineConfig, type Logger, mergeConfig, type PluginO
12
12
  import { type ResolvedToilConfig } from './config.js';
13
13
  import { fontPreloadPlugin } from './fonts.js';
14
14
  import { imageReportPlugin } from './image-report.js';
15
+ import { imageBlurPlugin } from './image-blur.js';
15
16
  import { toilPlugin } from './plugin.js';
16
17
  import { prerenderPlugin } from './prerender.js';
17
18
 
@@ -166,6 +167,10 @@ export async function createViteConfig(cfg: ResolvedToilConfig): Promise<InlineC
166
167
  customLogger: brandedLogger(),
167
168
  plugins: [
168
169
  tailwind,
170
+ // Auto-generate a tiny blurred base64 LQIP for `?toil` image imports (consumed by
171
+ // `Toil.Image` placeholder="blur"). Runs before imagetools (enforce:'pre') so it owns the
172
+ // `?toil` query; the bare re-imported src still flows through imagetools below.
173
+ cfg.images ? imageBlurPlugin() : undefined,
169
174
  // Build-time image resize/optimization. Every *imported* raster image is compressed to
170
175
  // webp by default (so a plain `<img src={imported}>` is optimized too, not just
171
176
  // `Toil.Image`); add `?w=400;800&format=…` to resize or pick a format. `public/` assets
@@ -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
 
@@ -23,8 +23,8 @@ describe('Image', () => {
23
23
  expect(img.getAttribute('width')).toBe('200');
24
24
  expect(img.getAttribute('height')).toBe('100');
25
25
  expect(img.getAttribute('fetchpriority')).toBe('auto');
26
- // A plain image adds no inline style and no toil class (nothing to lay out).
27
- expect(img.getAttribute('style')).toBe(null);
26
+ // width+height reserve space via an explicit aspect-ratio (survives responsive `width:100%`).
27
+ expect(img.style.aspectRatio).toBe('200 / 100');
28
28
  expect(img.className).toBe('');
29
29
  });
30
30
 
@@ -105,4 +105,40 @@ describe('Image', () => {
105
105
  expect(img.style.backgroundImage).toBe('');
106
106
  expect(img.classList.contains('toil-img-blur')).toBe(false);
107
107
  });
108
+
109
+ it('falls back to a skeleton shimmer when placeholder="blur" has no blurDataURL', () => {
110
+ const { getByAltText } = render(
111
+ <Image src="/s.png" alt="s" width={10} height={10} placeholder="blur" />,
112
+ );
113
+ const img = getByAltText('s') as HTMLImageElement;
114
+ // never a silent no-op: a neutral skeleton paints (not the blur class, no background image)
115
+ expect(img.classList.contains('toil-img-skeleton')).toBe(true);
116
+ expect(img.classList.contains('toil-img-blur')).toBe(false);
117
+ expect(img.style.backgroundImage).toBe('');
118
+ fireEvent.load(img);
119
+ expect(img.classList.contains('toil-img-skeleton')).toBe(false);
120
+ });
121
+
122
+ it('reserves space with an aspect-ratio derived from width+height', () => {
123
+ const { getByAltText } = render(<Image src="/a.png" alt="a" width={400} height={300} />);
124
+ const img = getByAltText('a') as HTMLImageElement;
125
+ expect(img.style.aspectRatio).toBe('400 / 300');
126
+ expect(img.getAttribute('width')).toBe('400');
127
+ expect(img.getAttribute('height')).toBe('300');
128
+ });
129
+
130
+ it('reads src/dimensions/blur from a ?toil import object', () => {
131
+ const { getByAltText } = render(
132
+ <Image
133
+ src={{ src: '/hero.webp', width: 40, height: 30, blurDataURL: 'data:image/blur' }}
134
+ alt="h"
135
+ placeholder="blur"
136
+ />,
137
+ );
138
+ const img = getByAltText('h') as HTMLImageElement;
139
+ expect(img.getAttribute('src')).toBe('/hero.webp');
140
+ expect(img.style.aspectRatio).toBe('40 / 30');
141
+ expect(img.classList.contains('toil-img-blur')).toBe(true);
142
+ expect(img.style.backgroundImage).toContain('data:image/blur');
143
+ });
108
144
  });