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.
- package/CHANGELOG.md +16 -0
- package/build/cli/.tsbuildinfo +1 -1
- package/build/client/.tsbuildinfo +1 -1
- package/build/client/components/Image.d.ts +7 -1
- package/build/client/components/Image.js +20 -6
- package/build/client/index.d.ts +1 -1
- package/build/compiler/.tsbuildinfo +1 -1
- package/build/compiler/generate.js +4 -0
- package/build/compiler/image-blur.d.ts +2 -0
- package/build/compiler/image-blur.js +24 -0
- package/build/compiler/prerender.d.ts +1 -0
- package/build/compiler/prerender.js +36 -6
- package/build/compiler/routes.d.ts +2 -0
- package/build/compiler/routes.js +19 -0
- package/build/compiler/vite.js +2 -0
- package/build/devserver/.tsbuildinfo +1 -1
- package/build/devserver/http/runtime.d.ts +1 -0
- package/build/devserver/http/runtime.js +88 -0
- package/build/devserver/production.js +3 -2
- package/examples/basic/client/assets/cube.webp +0 -0
- package/examples/basic/client/routes/test.tsx +5 -0
- package/package.json +1 -1
- package/src/client/components/Image.tsx +58 -20
- package/src/client/index.ts +1 -1
- package/src/compiler/generate.ts +6 -0
- package/src/compiler/image-blur.ts +42 -0
- package/src/compiler/prerender.ts +64 -11
- package/src/compiler/routes.ts +38 -0
- package/src/compiler/vite.ts +5 -0
- package/src/devserver/http/runtime.ts +100 -0
- package/src/devserver/production.ts +7 -1
- package/test/dom/Image.test.tsx +38 -2
- package/test/dynamic-routing.test.ts +132 -0
- package/test/image-blur.test.ts +50 -0
|
@@ -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
|
-
|
|
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');
|
|
Binary file
|
|
@@ -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
|
@@ -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
|
-
|
|
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'`
|
|
35
|
-
* `blurDataURL`
|
|
36
|
-
*
|
|
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
|
-
/**
|
|
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
|
-
|
|
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 +
|
|
70
|
-
//
|
|
71
|
-
//
|
|
72
|
-
//
|
|
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,
|
|
76
|
-
|
|
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
|
-
...(
|
|
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,
|
package/src/client/index.ts
CHANGED
|
@@ -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';
|
package/src/compiler/generate.ts
CHANGED
|
@@ -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
|
|
121
|
-
*
|
|
122
|
-
*
|
|
123
|
-
*
|
|
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
|
|
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
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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
|
}
|
package/src/compiler/routes.ts
CHANGED
|
@@ -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
|
|
package/src/compiler/vite.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
package/test/dom/Image.test.tsx
CHANGED
|
@@ -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
|
-
//
|
|
27
|
-
expect(img.
|
|
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
|
});
|