toiljs 0.0.79 → 0.0.81

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.
@@ -5,21 +5,32 @@ 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;
16
26
  /** Intrinsic height in px. Set together with `width` to reserve space (avoids layout shift). */
17
27
  height?: number;
18
28
  /**
19
- * Fill a box toil wraps around the image (a relatively-positioned, block-level `<span>`), so the
20
- * image can never escape to fill the page. Size the box via `width`/`height` or `style` (e.g.
21
- * `style={{ aspectRatio: '16/9' }}`); `className`/`style` apply to the box, not the `<img>`. Pair
22
- * with `objectFit` to control cropping.
29
+ * Make the image fill the width of a box toil wraps around it (a block-level `<span>`), scaling to
30
+ * its natural height or, if you size the box (`width`/`height`, or `style` like
31
+ * `aspectRatio: '16/9'`), the image covers that box, cropped per `objectFit` (default `cover`).
32
+ * The image flows in-block (never absolutely positioned), so it can't escape to fill the page or
33
+ * collapse to nothing. `className`/`style` apply to the box, not the `<img>`.
23
34
  */
24
35
  fill?: boolean;
25
36
  /** `object-fit` for the rendered image (handy with `fill`). */
@@ -30,12 +41,17 @@ export interface ImageProps extends Omit<
30
41
  */
31
42
  priority?: boolean;
32
43
  /**
33
- * Placeholder shown until the image loads: `'empty'` (default) or `'blur'`. `'blur'` needs
34
- * `blurDataURL` AND a reserved size (`width`+`height`, or `fill`) without a box there's nothing
35
- * 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).
36
48
  */
37
49
  placeholder?: 'empty' | 'blur';
38
- /** 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
+ */
39
55
  blurDataURL?: string;
40
56
  }
41
57
 
@@ -47,36 +63,58 @@ export interface ImageProps extends Omit<
47
63
  */
48
64
  export function Image(props: ImageProps): ReactNode {
49
65
  const {
50
- src,
66
+ src: srcProp,
51
67
  alt,
52
- width,
53
- height,
68
+ width: widthProp,
69
+ height: heightProp,
54
70
  fill = false,
55
71
  objectFit,
56
72
  priority = false,
57
73
  placeholder = 'empty',
58
- blurDataURL,
74
+ blurDataURL: blurDataURLProp,
59
75
  className: userClassName,
60
76
  style,
61
77
  onLoad,
62
78
  ...rest
63
79
  } = props;
64
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
+
65
89
  const [loaded, setLoaded] = useState(false);
66
- 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;
67
104
 
68
- // Layout + the blur placeholder come from toil's shipped CSS classes (see `buildHtml`'s
69
- // `<style id="toil-base">`) rather than inline styles, so they're SSR-safe AND overridable by the
70
- // app's CSS. Only genuinely per-instance bits stay inline: `objectFit` and the blur image URL. For
71
- // a non-`fill` image the caller's `className`/`style` ride on the <img>; for `fill` they go on the
72
- // 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.
73
109
  const imgClass =
74
- [fill ? 'toil-img-fill' : userClassName, showBlur ? 'toil-img-blur' : undefined]
75
- .filter(Boolean)
76
- .join(' ') || undefined;
110
+ [fill ? 'toil-img-fill' : userClassName, placeholderClass].filter(Boolean).join(' ') ||
111
+ undefined;
77
112
  const imgStyle: CSSProperties = {
78
113
  ...(objectFit !== undefined ? { objectFit } : {}),
79
- ...(showBlur ? { backgroundImage: `url(${blurDataURL})` } : {}),
114
+ ...(showPlaceholder && blurDataURL !== undefined
115
+ ? { backgroundImage: `url(${blurDataURL})` }
116
+ : {}),
117
+ ...(fill || aspectRatio === undefined ? {} : { aspectRatio }),
80
118
  ...(fill ? {} : style),
81
119
  };
82
120
 
@@ -101,11 +139,12 @@ export function Image(props: ImageProps): ReactNode {
101
139
 
102
140
  if (!fill) return img;
103
141
 
104
- // `fill`: the image is absolutely positioned, so without a positioned ancestor it escapes to fill
105
- // the nearest positioned one (often the whole page). Wrap it in our OWN relatively-positioned,
106
- // block-level box so it can only ever fill THIS box, which the caller sizes via `width`/`height`
107
- // or `style` (e.g. `<Image fill width={400} height={300} />` or `style={{ aspectRatio: '16/9' }}`).
142
+ // `fill`: the image flows in-block at 100% of its box's width (scaling to natural height), or
143
+ // covers the box if the caller sizes it (`width`/`height`, or `style` like `aspectRatio`). It is
144
+ // NEVER absolutely positioned, so it can't escape to fill the page nor collapse to a zero-height
145
+ // box. We still wrap it so the caller's `width`/`height`/`style` size the box, not the raw `<img>`.
108
146
  const boxStyle: CSSProperties = {
147
+ ...(aspectRatio !== undefined ? { aspectRatio } : {}),
109
148
  ...(width !== undefined ? { width } : {}),
110
149
  ...(height !== undefined ? { height } : {}),
111
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 =
@@ -409,9 +412,12 @@ const ENTRY_SCRIPT = `<script type="module" src="./entry.tsx"></script>`;
409
412
  * SSR-safe (present before JS). The app can restyle `.toil-img-*` freely. */
410
413
  const TOIL_BASE_STYLE =
411
414
  `<style id="toil-base">` +
412
- `.toil-img-fill-box{position:relative;display:block;overflow:hidden}` +
413
- `.toil-img-fill{position:absolute;inset:0;width:100%;height:100%}` +
415
+ `.toil-img-fill-box{display:block;overflow:hidden}` +
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
+ }
@@ -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
@@ -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
 
@@ -41,7 +41,7 @@ describe('Image', () => {
41
41
  expect(img.getAttribute('fetchpriority')).toBe('high');
42
42
  });
43
43
 
44
- it('fill wraps the image in a positioned box; the image lays out via a class, not inline styles', () => {
44
+ it('fill wraps the image in a box; the image lays out via a class, not inline styles', () => {
45
45
  const { getByAltText } = render(
46
46
  <Image
47
47
  src="/bg.png"
@@ -58,7 +58,7 @@ describe('Image', () => {
58
58
  expect(img.style.position).toBe('');
59
59
  // objectFit is genuinely per-instance, so it stays inline.
60
60
  expect(img.style.objectFit).toBe('cover');
61
- // The image is wrapped in a relatively-positioned <span> box, so it can only fill THAT box.
61
+ // The image is wrapped in a <span> box the caller sizes, so it fills the box, not the page.
62
62
  const box = img.parentElement as HTMLElement;
63
63
  expect(box.tagName).toBe('SPAN');
64
64
  expect(box.classList.contains('toil-img-fill-box')).toBe(true);
@@ -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
  });
@@ -0,0 +1,50 @@
1
+ import fs from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+
5
+ import sharp from 'sharp';
6
+ import { describe, expect, it } from 'vitest';
7
+
8
+ import { imageBlurPlugin } from '../src/compiler/image-blur';
9
+
10
+ /** Call the plugin's `load` hook directly (it uses no Rollup `this` context). */
11
+ function loadHook(): (id: string) => Promise<string | undefined> {
12
+ const plugin = imageBlurPlugin();
13
+ return plugin.load as unknown as (id: string) => Promise<string | undefined>;
14
+ }
15
+
16
+ describe('imageBlurPlugin', () => {
17
+ it('emits a { src, width, height, blurDataURL } module for a ?toil image import', async () => {
18
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'toil-blur-'));
19
+ const file = path.join(dir, 'pic.png');
20
+ await sharp({
21
+ create: { width: 40, height: 30, channels: 3, background: { r: 200, g: 100, b: 50 } },
22
+ })
23
+ .png()
24
+ .toFile(file);
25
+ try {
26
+ const load = loadHook();
27
+ const out = await load(`${file}?toil`);
28
+ expect(typeof out).toBe('string');
29
+ // re-imports the bare asset for src (so Vite/imagetools still optimize + hash it)
30
+ expect(out).toContain(`import src from ${JSON.stringify(file)}`);
31
+ // carries the intrinsic size for the aspect-ratio
32
+ expect(out).toContain('width: 40');
33
+ expect(out).toContain('height: 30');
34
+ // a real inlined LQIP, not a placeholder string
35
+ expect(out).toContain('blurDataURL: "data:image/webp;base64,');
36
+ const b64 = /base64,([^"]+)"/.exec(out as string)?.[1] ?? '';
37
+ expect(b64.length).toBeGreaterThan(20);
38
+ } finally {
39
+ fs.rmSync(dir, { recursive: true, force: true });
40
+ }
41
+ });
42
+
43
+ it('ignores imports without the ?toil flag', async () => {
44
+ const load = loadHook();
45
+ expect(await load('/some/pic.png')).toBeUndefined();
46
+ expect(await load('/some/pic.png?w=400')).toBeUndefined();
47
+ // non-raster ?toil (e.g. svg) is skipped too
48
+ expect(await load('/some/icon.svg?toil')).toBeUndefined();
49
+ });
50
+ });