From 8b57eacb5a2a62b41fa12af259d8e3eb897f1bb9 Mon Sep 17 00:00:00 2001 From: "Alexis H. Munsayac" Date: Wed, 9 Sep 2026 23:40:02 +0800 Subject: [PATCH 1/3] fix: wrong data attribute, default quality, img loading --- .changeset/soft-donkeys-repeat.md | 9 + README.md | 315 +++++++++++++++--------- src/__tests__/components.test.tsx | 100 +++++++- src/__tests__/formats.test.ts | 56 +++++ src/__tests__/fs.test.ts | 46 ++++ src/__tests__/utils.test.ts | 87 +++++++ src/__tests__/vite-plugin.test.ts | 312 +++++++++++++++++++++++ src/__tests__/vite-transformers.test.ts | 63 +++++ src/__tests__/xxhash32.test.ts | 16 ++ src/core/aspect-ratio.ts | 13 + src/core/create-lazy-render.ts | 5 + src/core/index.tsx | 44 +++- src/core/transformer.ts | 14 ++ src/core/utils.ts | 12 + src/vite/index.ts | 18 +- src/vite/transformers.ts | 5 + 16 files changed, 984 insertions(+), 131 deletions(-) create mode 100644 .changeset/soft-donkeys-repeat.md create mode 100644 src/__tests__/formats.test.ts create mode 100644 src/__tests__/fs.test.ts create mode 100644 src/__tests__/utils.test.ts create mode 100644 src/__tests__/vite-plugin.test.ts create mode 100644 src/__tests__/vite-transformers.test.ts create mode 100644 src/__tests__/xxhash32.test.ts diff --git a/.changeset/soft-donkeys-repeat.md b/.changeset/soft-donkeys-repeat.md new file mode 100644 index 0000000..d07b537 --- /dev/null +++ b/.changeset/soft-donkeys-repeat.md @@ -0,0 +1,9 @@ +--- +"@solidjs/image": patch +--- + +Images now load. The `img` element receives the source, and the broken `source` element that was rendered without a transformer is gone. + +The shipped stylesheet now matches the rendered markup. Elements are tagged with `data-solid-image` instead of `data-start-image`. + +The default image quality is now 80. It was 0.8, which sharp rejects. The `quality` option is now optional. diff --git a/README.md b/README.md index 036a9f5..298e708 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,13 @@ # `@solidjs/image` +Optimized image components and Vite tooling for [Solid](https://solidjs.com). + +- `SolidImage` renders a responsive `` that reserves the image's aspect ratio, so the page does not shift while the image loads. +- The image only loads once it scrolls into view, using `IntersectionObserver`. +- Your own placeholder is rendered while the image loads, and fades out when the image is ready. +- The Vite plugin turns a local image import into a set of resized and reformatted files at build time. +- Remote images go through your own URL mapping, so a CDN can serve the variants instead. + ## Install ```bash @@ -10,119 +18,70 @@ npm i @solidjs/image pnpm add @solidjs/image ``` +```bash +yarn add @solidjs/image +``` + +The package needs `solid-js` 1.9.9 or newer and Vite 8 or newer as peer dependencies. Node 24 or newer is required for the Vite plugin, which uses [`sharp`](https://sharp.pixelplumbing.com) to process images. + ## Setup -### Vite +### 1. Add the Vite plugin ```ts +// vite.config.ts +import { defineConfig } from "vite"; +import solid from "vite-plugin-solid"; import { imagePlugin } from "@solidjs/image/vite"; export default defineConfig({ plugins: [ + solid(), imagePlugin({ - /** - * Used to process local image imports - * - * example: - * import myImage from './path/to/my-image.jpg?image'; - */ local: { - /** - * Image formats that can be processed - */ - input: ['jpeg', 'png'], - /** - * Image format for the output images. - * - * Take note that each input image will - * produce a new image for each format - */ - output: ['jpeg', 'png'], - /** - * Sizes of the output images, based on width - * while retaining the aspect ratio. - * - * This option also produces an image for - * each width and for each output format. - */ - sizes: [480, 600], - /** - * Quality of the processed images - */ + input: ["jpeg", "png"], + output: ["webp", "jpeg"], + sizes: [480, 800, 1200], quality: 80, - /** - * Where the processed images as emitted - */ publicPath: "public", }, - /** - * Used for remote images - * - * example: - * import myImage from 'image:my-value'; - */ - remote: { - /** - * Transforms the right-hand part of the `image:*` string - */ - transformURL(url) { - return { - /** - * The default image for the given url - */ - src: { - source: `https://picsum.photos/seed/${url}/1200/900.webp`, - width: 1080, - height: 760, - }, - /** - * Variants of the image (format, size) for responsiveness - */ - variants: [ - { - path: `https://picsum.photos/seed/${url}/800/600.jpg`, - width: 800, - type: "image/jpeg", - }, - { - path: `https://picsum.photos/seed/${url}/400/300.jpg`, - width: 400, - type: "image/jpeg", - }, - { - path: `https://picsum.photos/seed/${url}/800/600.png`, - width: 800, - type: "image/png", - }, - { - path: `https://picsum.photos/seed/${url}/400/300.png`, - width: 400, - type: "image/png", - }, - ], - }; - }, - }, }), ], }); ``` +`imagePlugin` returns an array of Vite plugins, so spread it or nest it in the `plugins` array. Both are accepted by Vite. + +### 2. Add the ambient types + +The plugin resolves imports that TypeScript does not know about, such as `./photo.png?image` and `image:hero`. Reference the shipped declarations once in your project: + +```ts +// env.d.ts +/// +``` + +### 3. Import the styles + +```ts +import "@solidjs/image/style.css"; +``` + +The stylesheet positions the picture, the image and the placeholder inside the aspect ratio box. Import it once, in your app entry. + ## Usage ### Local image +Import the image with the `?image` query. The import gives you the `src` and the `transformer` props. + ```tsx -import { SolidImage as Image } from "@solidjs/image"; +import { SolidImage } from "@solidjs/image"; import { type JSX, onMount, Show } from "solid-js"; -import exampleImage from "../images/example.jpg?image"; +import example from "../images/example.jpg?image"; -interface PlaceholderProps { - show: () => void; -} - -function Placeholder(props: PlaceholderProps): JSX.Element { +function Placeholder(props: { show: () => void }): JSX.Element { onMount(() => { props.show(); }); @@ -133,8 +92,8 @@ function Placeholder(props: PlaceholderProps): JSX.Element { export default function App(): JSX.Element { return (
- example ( @@ -149,37 +108,167 @@ export default function App(): JSX.Element { ### Remote image +Import `image:` followed by any string. The plugin passes that string to your `transformURL` function. + ```tsx -import { SolidImage as Image } from "@solidjs/image"; -import { type JSX, onMount, Show } from "solid-js"; +import { SolidImage } from "@solidjs/image"; -import exampleImage from "image:foobar"; +import example from "image:foobar"; -interface PlaceholderProps { - show: () => void; +export default function App() { + return
Loading...
} />; } +``` -function Placeholder(props: PlaceholderProps): JSX.Element { - onMount(() => { - props.show(); - }); +```ts +imagePlugin({ + remote: { + transformURL(url) { + return { + src: { + source: `https://cdn.example.com/${url}/1200.webp`, + width: 1200, + height: 900, + }, + variants: [ + { path: `https://cdn.example.com/${url}/800.webp`, width: 800, type: "image/webp" }, + { path: `https://cdn.example.com/${url}/400.webp`, width: 400, type: "image/webp" }, + ], + }; + }, + }, +}); +``` - return
Loading...
; +`transformURL` may be async, so you can call a CDN API while the module is loaded. + +### Without the plugin + +The component does not depend on the plugin. Pass `src` and an optional `transformer` yourself: + +```tsx + [ + { path: `/cdn/${source.source}?w=400`, width: 400, type: "image/webp" }, + { path: `/cdn/${source.source}?w=800`, width: 800, type: "image/webp" }, + ], + }} + fallback={() =>
Loading...
} +/> +``` + +## API + +### `` + +| Prop | Type | Required | Description | +| --- | --- | --- | --- | +| `src` | `SolidImageSource` | yes | The image source, its intrinsic size and any options your transformer needs. | +| `alt` | `string` | yes | Alternative text for the image. | +| `fallback` | `(visible: () => boolean, onLoad: () => void) => JSX.Element` | yes | Placeholder shown while the image loads. See below. | +| `transformer` | `SolidImageTransformer` | no | Produces the responsive variants for `src`. | +| `onLoad` | `() => void` | no | Called once the image has loaded and the placeholder is hidden. | +| `crossOrigin` | `JSX.HTMLCrossorigin` | no | Forwarded to the ``. | +| `fetchPriority` | `"high" \| "low" \| "auto"` | no | Forwarded to the ``. | +| `decoding` | `"sync" \| "async" \| "auto"` | no | Forwarded to the ``. | + +The `fallback` callback receives two arguments. + +- `visible` is a signal that is `true` while the placeholder should be shown. It turns `false` once the image has loaded. +- `onLoad` tells the component that your placeholder is on screen. Call it once your placeholder has mounted. The image is only revealed after this call, so the placeholder is never skipped by an image that loads instantly. + +The `fallback` only renders on the client, and only after the container has scrolled into view. + +### Types + +```ts +interface SolidImageSource { + source: string; + width: number; + height: number; + options: T; } -export default function App(): JSX.Element { - return ( -
- example ( - - - - )} - /> -
- ); +interface SolidImageVariant { + path: string; + width: number; + type: SolidImageMIME; +} + +interface SolidImageTransformer { + transform: (source: SolidImageSource) => SolidImageVariant | SolidImageVariant[]; } ``` + +- `SolidImageMIME` is `"image/avif" | "image/jpeg" | "image/png" | "image/webp" | "image/tiff"`. +- `SolidImageFormat` is `"avif" | "jpeg" | "png" | "webp" | "tiff"`. +- `SolidImageFile` is every file extension that maps to a format, such as `"jpg"`, `"jpeg"`, `"jfif"`, `"pjpeg"`, `"pjp"`, `"tif"` and `"tiff"`. + +`width` and `height` on the source are the intrinsic pixel size. They are only used to reserve the aspect ratio box, so any pair with the right ratio works. + +Variants are grouped by `type` and merged into one `srcset` per group. The browser picks the first `` whose type it supports, then picks a width from the `srcset`. Order your output formats from most to least preferred. + +The transformer is optional. Without one, no `` is rendered and the browser loads `src.source` directly. + +### `imagePlugin(options)` + +```ts +import { imagePlugin } from "@solidjs/image/vite"; +``` + +#### `options.local` + +Handles imports ending in `?image`. + +| Option | Type | Default | Description | +| --- | --- | --- | --- | +| `sizes` | `number[]` | required | Output widths in pixels. Height follows the aspect ratio. | +| `quality` | `number` | `80` | Quality passed to sharp, from 1 to 100. | +| `input` | `SolidImageFormat[]` | `["png", "jpeg", "webp"]` | Source formats the plugin will process. Other files are left alone. | +| `output` | `SolidImageFormat[]` | `["png", "jpeg", "webp"]` | Formats to emit. | +| `publicPath` | `string` | `"dist"` | Directory the processed files are written to. | + +One file is emitted per output format and per size, so `output: ["webp", "jpeg"]` with `sizes: [480, 800]` gives four files per image. + +Files are written to `/.image/i--.`, where the hash is an xxHash32 of the source path. The module exports the public URL `/.image/i--.`, so `publicPath` should be a directory that is served at the root of your site. Add `.image` to `.gitignore` if it lives inside a checked in directory such as `public`. + +#### `options.remote` + +Handles imports starting with `image:`. + +| Option | Type | Description | +| --- | --- | --- | +| `transformURL` | `(url: string) => MaybePromise<{ src, variants }>` | Maps the text after `image:` to a source and its variants. | + +`src` is `{ source, width, height }`. `variants` is one `SolidImageVariant` or an array of them. + +Both option groups are optional. Passing neither returns no plugin. + +## How it works + +1. `SolidImage` renders a container with a padding based aspect ratio box, so the layout is stable before the image arrives. +2. An `IntersectionObserver` watches the container. Nothing loads until it enters the viewport. +3. Once visible, the `` and your placeholder are rendered. The image starts fully transparent. +4. Your placeholder calls `onLoad` to say it is on screen. When the image finishes loading after that, the placeholder is hidden, the image fades in and the `onLoad` prop is called. +5. On the server, the `` renders with a blank SVG of the same size, so the browser does not fetch the image before it is in view. The placeholder and the loading logic are client only. + +The rendered elements carry a `data-solid-image` attribute you can style. The values are `container`, `aspect-ratio`, `picture`, `image` and `blocker`. The shipped stylesheet uses the same attribute. + +## Development + +```bash +pnpm install +pnpm build # bundle with tsdown +pnpm test # run the test suite once +pnpm test:watch +pnpm changeset # add a changeset before opening a pull request +``` + +Tests run under Vitest in a Node environment. Components are checked through `renderToString`, so they cover the server rendering path. The Vite plugin tests call the plugin hooks directly and process real images with sharp. + +## License + +MIT diff --git a/src/__tests__/components.test.tsx b/src/__tests__/components.test.tsx index 3571913..8e9a85b 100644 --- a/src/__tests__/components.test.tsx +++ b/src/__tests__/components.test.tsx @@ -97,11 +97,10 @@ describe("SolidImage SSR", () => { /> )); - expect(html).toContain("data-start-image"); - expect(html).toContain("test.jpg"); + expect(html).toContain('data-solid-image="container"'); }); - it("renders without a transformer (default fallback path)", () => { + it("renders no when there is no transformer", () => { const html = renderToString(() => ( { /> )); - expect(html).toContain("hero.png"); + expect(html).not.toContain(" { + const html = renderToString(() => ( + placeholder} + /> + )); + + // The server placeholder is a blank SVG of the same size, so the browser + // does not fetch the image before it scrolls into view. + expect(html).not.toContain("hero.png"); + expect(html).toContain("data:image/svg+xml,"); + expect(html).toContain(encodeURIComponent('width="800"')); + }); + + it("renders one per MIME type with a srcset", () => { + const html = renderToString(() => ( + [ + { path: "/hero-400.webp", width: 400, type: "image/webp" }, + { path: "/hero-800.webp", width: 800, type: "image/webp" }, + { path: "/hero-400.jpg", width: 400, type: "image/jpeg" }, + ], + }} + fallback={() =>
loading
} + /> + )); + + expect(html).toContain( + '', + ); + expect(html).toContain('type="image/jpeg" srcset="/hero-400.jpg 400w"'); + }); + + it("reserves the aspect ratio box from the source size", () => { + const html = renderToString(() => ( +
loading
} + /> + )); + + expect(html).toContain("padding-top:56.25%"); + }); + + it("forwards crossOrigin, fetchPriority and decoding to the img", () => { + const html = renderToString(() => ( +
loading
} + /> + )); + + expect(html).toContain('crossorigin="anonymous"'); + expect(html).toContain('fetchpriority="high"'); + expect(html).toContain('decoding="async"'); + }); + + it("does not render the fallback on the server", () => { + const html = renderToString(() => ( +
loading
} + /> + )); + + expect(html).not.toContain("loading"); + }); + + it("marks the container, aspect ratio box, picture and blocker elements", () => { + const html = renderToString(() => ( +
loading
} + /> + )); + + for (const part of ["container", "aspect-ratio", "picture", "image", "blocker"]) { + expect(html).toContain(`data-solid-image="${part}"`); + } + }); }); diff --git a/src/__tests__/formats.test.ts b/src/__tests__/formats.test.ts new file mode 100644 index 0000000..46e8357 --- /dev/null +++ b/src/__tests__/formats.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from "vitest"; +import { + getFilesFromFormat, + getFormatFromFile, + getFormatFromMIME, + getMIMEFromFormat, + getOutputFileFromFormat, +} from "../core/transformer"; +import type { SolidImageFormat, SolidImageMIME } from "../core/types"; + +const FORMATS: SolidImageFormat[] = ["avif", "jpeg", "png", "webp", "tiff"]; +const MIMES: SolidImageMIME[] = [ + "image/avif", + "image/jpeg", + "image/png", + "image/webp", + "image/tiff", +]; + +describe("getMIMEFromFormat", () => { + it("maps every format to a MIME type", () => { + expect(FORMATS.map(getMIMEFromFormat)).toEqual(MIMES); + }); + + it("round trips with getFormatFromMIME", () => { + for (const format of FORMATS) { + expect(getFormatFromMIME(getMIMEFromFormat(format))).toBe(format); + } + }); +}); + +describe("getFilesFromFormat", () => { + it("lists every extension accepted for jpeg", () => { + expect(getFilesFromFormat("jpeg")).toEqual(["jfif", "jpeg", "jpg", "pjp", "pjpeg"]); + }); + + it("returns extensions that map back to the same format", () => { + for (const format of FORMATS) { + for (const file of getFilesFromFormat(format)) { + expect(getFormatFromFile(file)).toBe(format); + } + } + }); +}); + +describe("getOutputFileFromFormat", () => { + it("picks a single output extension per format", () => { + expect(FORMATS.map(getOutputFileFromFormat)).toEqual(["avif", "jpg", "png", "webp", "tiff"]); + }); + + it("picks an extension that is valid for the format", () => { + for (const format of FORMATS) { + expect(getFilesFromFormat(format)).toContain(getOutputFileFromFormat(format)); + } + }); +}); diff --git a/src/__tests__/fs.test.ts b/src/__tests__/fs.test.ts new file mode 100644 index 0000000..86293aa --- /dev/null +++ b/src/__tests__/fs.test.ts @@ -0,0 +1,46 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { outputFile } from "../vite/fs"; + +let dir: string; + +beforeEach(async () => { + dir = await fs.mkdtemp(path.join(os.tmpdir(), "solid-image-fs-")); +}); + +afterEach(async () => { + await fs.rm(dir, { recursive: true, force: true }); +}); + +describe("outputFile", () => { + it("writes a file into an existing directory", async () => { + const file = path.join(dir, "a.txt"); + await outputFile(file, "hello"); + + expect(await fs.readFile(file, "utf8")).toBe("hello"); + }); + + it("creates the parent directories when they are missing", async () => { + const file = path.join(dir, "deep", "nested", "a.txt"); + await outputFile(file, "hello"); + + expect(await fs.readFile(file, "utf8")).toBe("hello"); + }); + + it("writes binary data", async () => { + const file = path.join(dir, "a.bin"); + await outputFile(file, Buffer.from([1, 2, 3])); + + expect([...(await fs.readFile(file))]).toEqual([1, 2, 3]); + }); + + it("overwrites an existing file", async () => { + const file = path.join(dir, "a.txt"); + await outputFile(file, "first"); + await outputFile(file, "second"); + + expect(await fs.readFile(file, "utf8")).toBe("second"); + }); +}); diff --git a/src/__tests__/utils.test.ts b/src/__tests__/utils.test.ts new file mode 100644 index 0000000..4689c49 --- /dev/null +++ b/src/__tests__/utils.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it } from "vitest"; +import { + getAspectRatioBoxStyle, + getEmptyImageURL, + getEmptySVGPlaceholder, + getEncodedOptionalSVG, + getEncodedSVG, +} from "../core/utils"; + +describe("getAspectRatioBoxStyle", () => { + it("uses padding-top to reserve the 16:9 box", () => { + expect(getAspectRatioBoxStyle({ width: 16, height: 9 })).toEqual({ + position: "relative", + "padding-top": "56.25%", + width: "100%", + height: "0", + overflow: "hidden", + }); + }); + + it("reserves a square box with 100% padding", () => { + expect(getAspectRatioBoxStyle({ width: 100, height: 100 })["padding-top"]).toBe("100%"); + }); + + it("reserves more than 100% padding for a portrait box", () => { + expect(getAspectRatioBoxStyle({ width: 9, height: 16 })["padding-top"]).toBe( + `${(16 * 100) / 9}%`, + ); + }); + + it("works with the raw pixel size of the image", () => { + expect(getAspectRatioBoxStyle({ width: 1920, height: 1080 })["padding-top"]).toBe("56.25%"); + }); +}); + +describe("getEmptySVGPlaceholder", () => { + it("returns an SVG with the given size", () => { + const svg = getEmptySVGPlaceholder({ width: 16, height: 9 }); + + expect(svg).toContain('width="16"'); + expect(svg).toContain('height="9"'); + expect(svg).toContain("http://www.w3.org/2000/svg"); + }); + + it("returns a self closing SVG with no content", () => { + expect(getEmptySVGPlaceholder({ width: 1, height: 1 })).toBe( + '', + ); + }); +}); + +describe("getEncodedSVG", () => { + it("prefixes the encoded SVG with the data URL scheme", () => { + expect(getEncodedSVG("")).toBe("data:image/svg+xml,%3Csvg%2F%3E"); + }); + + it("encodes characters that are unsafe in a URL", () => { + const result = getEncodedSVG(''); + + expect(result).not.toContain("<"); + expect(result).not.toContain('"'); + }); +}); + +describe("getEncodedOptionalSVG", () => { + it("encodes the given SVG when one is passed", () => { + expect(getEncodedOptionalSVG({ width: 4, height: 3 }, "")).toBe( + getEncodedSVG(""), + ); + }); + + it("falls back to an empty placeholder when no SVG is passed", () => { + expect(getEncodedOptionalSVG({ width: 4, height: 3 })).toBe( + getEncodedSVG(getEmptySVGPlaceholder({ width: 4, height: 3 })), + ); + }); +}); + +describe("getEmptyImageURL", () => { + it("returns a data URL usable as an img src", () => { + const url = getEmptyImageURL({ width: 800, height: 600 }); + + expect(url.startsWith("data:image/svg+xml,")).toBe(true); + expect(decodeURIComponent(url)).toContain('width="800"'); + expect(decodeURIComponent(url)).toContain('height="600"'); + }); +}); diff --git a/src/__tests__/vite-plugin.test.ts b/src/__tests__/vite-plugin.test.ts new file mode 100644 index 0000000..ee21f20 --- /dev/null +++ b/src/__tests__/vite-plugin.test.ts @@ -0,0 +1,312 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import sharp from "sharp"; +import type { Plugin } from "vite"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { imagePlugin } from "../vite/index"; +import type { SolidImageOptions } from "../vite/index"; + +// Vite hooks can be a function or an object with a handler. +// These helpers call either shape with a stub plugin context. +function callResolveId(plugin: Plugin, id: string, importer?: string) { + const hook = plugin.resolveId as any; + const fn = typeof hook === "function" ? hook : hook.handler; + return fn.call({} as any, id, importer, {}); +} + +function callLoad(plugin: Plugin, id: string) { + const hook = plugin.load as any; + const fn = typeof hook === "function" ? hook : hook.handler; + return fn.call({} as any, id, {}); +} + +function getPlugin(plugins: Plugin[], name: string): Plugin { + const found = plugins.find(plugin => plugin.name === name); + if (!found) { + throw new Error(`Missing plugin: ${name}`); + } + return found; +} + +describe("imagePlugin", () => { + it("returns no plugin when no option is given", () => { + expect(imagePlugin({})).toHaveLength(0); + }); + + it("returns only the remote plugin when only remote is given", () => { + const plugins = imagePlugin({ + remote: { + transformURL: () => ({ + src: { source: "/a.jpg", width: 1, height: 1 }, + variants: [], + }), + }, + }); + + expect(plugins.map(plugin => plugin.name)).toEqual(["solid-start:image/remote"]); + }); + + it("returns only the local plugin when only local is given", () => { + const plugins = imagePlugin({ local: { sizes: [400], quality: 80 } }); + + expect(plugins.map(plugin => plugin.name)).toEqual(["solid-start:image/local"]); + }); + + it("returns both plugins and runs them before other plugins", () => { + const plugins = imagePlugin({ + local: { sizes: [400], quality: 80 }, + remote: { + transformURL: () => ({ + src: { source: "/a.jpg", width: 1, height: 1 }, + variants: [], + }), + }, + }); + + expect(plugins).toHaveLength(2); + for (const plugin of plugins) { + expect(plugin.enforce).toBe("pre"); + } + }); +}); + +describe("remote images", () => { + const options: SolidImageOptions = { + remote: { + transformURL(url) { + return { + src: { source: `https://cdn.test/${url}/1200.webp`, width: 1200, height: 900 }, + variants: [ + { path: `https://cdn.test/${url}/800.jpg`, width: 800, type: "image/jpeg" }, + { path: `https://cdn.test/${url}/400.jpg`, width: 400, type: "image/jpeg" }, + ], + }; + }, + }, + }; + + const plugin = getPlugin(imagePlugin(options), "solid-start:image/remote"); + + it("resolves an image: id to itself", () => { + expect(callResolveId(plugin, "image:hero")).toBe("image:hero"); + }); + + it("ignores ids that are not image: ids", () => { + expect(callResolveId(plugin, "./photo.png")).toBe(null); + }); + + it("loads a module that exports the source and a transformer", async () => { + const code: string = await callLoad(plugin, "image:hero"); + + expect(code).toContain('"source":"https://cdn.test/hero/1200.webp"'); + expect(code).toContain('"width":1200'); + expect(code).toContain('"height":900'); + expect(code).toContain("transform()"); + }); + + it("passes the part after image: to transformURL", async () => { + const code: string = await callLoad(plugin, "image:some/nested/name"); + + expect(code).toContain("https://cdn.test/some/nested/name/1200.webp"); + }); + + it("awaits an async transformURL", async () => { + const asyncPlugin = getPlugin( + imagePlugin({ + remote: { + async transformURL(url) { + return { + src: { source: `/${url}.jpg`, width: 10, height: 10 }, + variants: { path: `/${url}-10.jpg`, width: 10, type: "image/jpeg" }, + }; + }, + }, + }), + "solid-start:image/remote", + ); + + const code: string = await callLoad(asyncPlugin, "image:async"); + + expect(code).toContain('"path":"/async-10.jpg"'); + }); + + it("ignores ids that are not image: ids on load", async () => { + expect(await callLoad(plugin, "./photo.png")).toBe(null); + }); +}); + +describe("local images", () => { + let dir: string; + let publicPath: string; + let imagePath: string; + + beforeAll(async () => { + dir = await fs.mkdtemp(path.join(os.tmpdir(), "solid-image-vite-")); + publicPath = path.join(dir, "public"); + imagePath = path.join(dir, "photo.png"); + + await sharp({ + create: { width: 64, height: 32, channels: 3, background: "#336699" }, + }) + .png() + .toFile(imagePath); + }); + + afterAll(async () => { + await fs.rm(dir, { recursive: true, force: true }); + }); + + function createLocalPlugin(local?: Partial): Plugin { + return getPlugin( + imagePlugin({ + local: { + sizes: [400, 800], + input: ["png"], + output: ["webp", "jpeg"], + quality: 80, + publicPath, + ...local, + } as SolidImageOptions["local"], + }), + "solid-start:image/local", + ); + } + + it("resolves a ?image id next to the importer", () => { + const plugin = createLocalPlugin(); + const resolved = callResolveId(plugin, "./photo.png?image", path.join(dir, "app.tsx")); + + expect(resolved).toBe(path.join(dir, "photo.png?image")); + }); + + it("ignores an id without an image query", () => { + const plugin = createLocalPlugin(); + + expect(callResolveId(plugin, "./photo.png", path.join(dir, "app.tsx"))).toBe(null); + }); + + it("ignores an id with no importer", () => { + const plugin = createLocalPlugin(); + + expect(callResolveId(plugin, "./photo.png?image", undefined)).toBe(null); + }); + + it("loads an entry point that exports src and transformer", async () => { + const plugin = createLocalPlugin(); + const code: string = await callLoad(plugin, path.join(dir, "photo.png?image")); + + expect(code).toContain('"./photo.png?image-source"'); + expect(code).toContain('"./photo.png?image-transformer"'); + expect(code).toContain("export default { src, transformer };"); + }); + + it("loads the source module with the real image size", async () => { + const plugin = createLocalPlugin(); + const code: string = await callLoad(plugin, path.join(dir, "photo.png?image-source")); + + expect(code).toContain("width: 64"); + expect(code).toContain("height: 32"); + expect(code).toContain('import source from "./photo.png"'); + }); + + it("loads a transformer that imports one variant per format and size", async () => { + const plugin = createLocalPlugin(); + const code: string = await callLoad(plugin, path.join(dir, "photo.png?image-transformer")); + + expect(code).toContain('import variant_webp_400 from "./photo.png?image-webp-400"'); + expect(code).toContain('import variant_webp_800 from "./photo.png?image-webp-800"'); + expect(code).toContain('import variant_jpeg_400 from "./photo.png?image-jpeg-400"'); + expect(code).toContain('import variant_jpeg_800 from "./photo.png?image-jpeg-800"'); + expect(code).toContain("export default { transform() { return variants; }};"); + }); + + it("loads a variant module with its width and MIME type", async () => { + const plugin = createLocalPlugin(); + const code: string = await callLoad(plugin, path.join(dir, "photo.png?image-webp-400")); + + expect(code).toContain("width: 400"); + expect(code).toContain("type: 'image/webp'"); + expect(code).toContain('import source from "./photo.png?image-raw-webp-400"'); + }); + + it("emits the resized file and exports its public path", async () => { + const plugin = createLocalPlugin(); + const code: string = await callLoad(plugin, path.join(dir, "photo.png?image-raw-webp-400")); + + const match = /export default "(.+)"/.exec(code); + expect(match).not.toBe(null); + + const publicUrl = match![1]!; + expect(publicUrl.startsWith("/.image/")).toBe(true); + expect(publicUrl.endsWith(".webp")).toBe(true); + + const emitted = path.join(publicPath, publicUrl); + expect(await fs.stat(emitted).then(stat => stat.isFile())).toBe(true); + + const meta = await sharp(emitted).metadata(); + expect(meta.format).toBe("webp"); + expect(meta.width).toBe(400); + }); + + it("uses the jpg extension for jpeg output", async () => { + const plugin = createLocalPlugin(); + const code: string = await callLoad(plugin, path.join(dir, "photo.png?image-raw-jpeg-800")); + + expect(code).toContain(".jpg"); + }); + + it("gives the same file name for the same image and size", async () => { + const plugin = createLocalPlugin(); + const first: string = await callLoad(plugin, path.join(dir, "photo.png?image-raw-webp-400")); + const second: string = await callLoad(plugin, path.join(dir, "photo.png?image-raw-webp-400")); + + expect(first).toBe(second); + }); + + it("ignores a file extension that is not in the input list", async () => { + const plugin = createLocalPlugin({ input: ["jpeg"] }); + + expect(await callLoad(plugin, path.join(dir, "photo.png?image"))).toBe(null); + }); + + it("ignores a file with no image query", async () => { + const plugin = createLocalPlugin(); + + expect(await callLoad(plugin, path.join(dir, "photo.png"))).toBe(null); + }); + + it("ignores virtual module ids", async () => { + const plugin = createLocalPlugin(); + + expect(await callLoad(plugin, "\0virtual:photo.png?image")).toBe(null); + }); + + it("accepts every extension of an input format", () => { + const plugin = createLocalPlugin({ input: ["jpeg"] }); + + expect(callResolveId(plugin, "./photo.jpg?image", path.join(dir, "app.tsx"))).toBe( + path.join(dir, "photo.jpg?image"), + ); + }); + + it("emits a file with the default quality when none is given", async () => { + const plugin = createLocalPlugin({ quality: undefined }); + const code: string = await callLoad(plugin, path.join(dir, "photo.png?image-raw-jpeg-400")); + + const publicUrl = /export default "(.+)"/.exec(code)![1]!; + const meta = await sharp(path.join(publicPath, publicUrl)).metadata(); + + expect(meta.format).toBe("jpeg"); + expect(meta.width).toBe(400); + }); + + it("defaults to png, jpeg and webp output when no format is given", async () => { + const plugin = createLocalPlugin({ input: undefined, output: undefined }); + const code: string = await callLoad(plugin, path.join(dir, "photo.png?image-transformer")); + + expect(code).toContain("variant_png_400"); + expect(code).toContain("variant_jpeg_400"); + expect(code).toContain("variant_webp_400"); + }); +}); diff --git a/src/__tests__/vite-transformers.test.ts b/src/__tests__/vite-transformers.test.ts new file mode 100644 index 0000000..adb7ef4 --- /dev/null +++ b/src/__tests__/vite-transformers.test.ts @@ -0,0 +1,63 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import sharp from "sharp"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { getImageData, transformImage } from "../vite/transformers"; + +let dir: string; +let imagePath: string; + +beforeAll(async () => { + dir = await fs.mkdtemp(path.join(os.tmpdir(), "solid-image-sharp-")); + imagePath = path.join(dir, "photo.png"); + + await sharp({ + create: { width: 800, height: 400, channels: 3, background: "#112233" }, + }) + .png() + .toFile(imagePath); +}); + +afterAll(async () => { + await fs.rm(dir, { recursive: true, force: true }); +}); + +describe("getImageData", () => { + it("reads the size of an image", async () => { + expect(await getImageData(imagePath)).toEqual({ width: 800, height: 400 }); + }); + + it("rejects for a missing file", async () => { + await expect(getImageData(path.join(dir, "missing.png"))).rejects.toThrow(); + }); +}); + +describe("transformImage", () => { + it("resizes to the requested width and keeps the aspect ratio", async () => { + const buffer = await transformImage(imagePath, "webp", 400, 80).toBuffer(); + const meta = await sharp(buffer).metadata(); + + expect(meta.width).toBe(400); + expect(meta.height).toBe(200); + }); + + it("converts to every supported output format", async () => { + // sharp reports an AVIF file as heif, its container format. + const formats = [ + ["avif", "heif"], + ["jpeg", "jpeg"], + ["png", "png"], + ["webp", "webp"], + ["tiff", "tiff"], + ] as const; + + for (const [format, reported] of formats) { + const buffer = await transformImage(imagePath, format, 100, 80).toBuffer(); + const meta = await sharp(buffer).metadata(); + + expect(meta.format).toBe(reported); + expect(meta.width).toBe(100); + } + }); +}); diff --git a/src/__tests__/xxhash32.test.ts b/src/__tests__/xxhash32.test.ts new file mode 100644 index 0000000..8e0cb59 --- /dev/null +++ b/src/__tests__/xxhash32.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from "vitest"; +import xxHash32 from "../vite/xxhash32"; + +describe("xxHash32", () => { + it("matches the reference digest for an empty string", () => { + expect(xxHash32("")).toBe(0x02cc5d05); + }); + + it("matches the reference digest for a short string", () => { + expect(xxHash32("abc")).toBe(0x32d153ff); + }); + + it("matches the reference digest for an input longer than one stripe", () => { + expect(xxHash32("0123456789abcdefghijklmnopqrstuvwxyz")).toBe(0x9aa38e7e); + }); +}); diff --git a/src/core/aspect-ratio.ts b/src/core/aspect-ratio.ts index 9624641..fdb8294 100644 --- a/src/core/aspect-ratio.ts +++ b/src/core/aspect-ratio.ts @@ -1,3 +1,4 @@ +// Greatest common divisor, used to reduce a size to its smallest ratio. function gcd(a: number, b: number): number { if (b === 0) { return a; @@ -42,6 +43,10 @@ const VERTICAL_ASPECT_RATIO = HORIZONTAL_ASPECT_RATIO.map(item => ({ const ASPECT_RATIO = [...HORIZONTAL_ASPECT_RATIO, ...VERTICAL_ASPECT_RATIO]; +/** + * Reduces a pixel size to its smallest integer ratio. + * A 1920x1080 image becomes 16x9. + */ export function getAspectRatio({ width, height }: AspectRatio): AspectRatio { const denom = gcd(width, height); @@ -51,6 +56,10 @@ export function getAspectRatio({ width, height }: AspectRatio): AspectRatio { }; } +/** + * Returns the known aspect ratio closest to the given one. + * Use it to snap an odd image size to a common ratio. + */ export function getNearestAspectRatio(ratio: AspectRatio): AspectRatio { let nearest = Number.MAX_VALUE; let id = 0; @@ -75,6 +84,10 @@ export function getNearestAspectRatio(ratio: AspectRatio): AspectRatio { return ASPECT_RATIO[id]!; } +/** + * Scales a ratio so its larger side is 9. + * The ratio itself is unchanged. + */ export function getScaledComponentRatio(ratio: AspectRatio): AspectRatio { const xScale = 9 / ratio.width; const yScale = 9 / ratio.height; diff --git a/src/core/create-lazy-render.ts b/src/core/create-lazy-render.ts index d5b32d3..374e9eb 100644 --- a/src/core/create-lazy-render.ts +++ b/src/core/create-lazy-render.ts @@ -9,6 +9,11 @@ export interface LazyRenderOptions { refresh?: boolean; } +/** + * Tracks whether the host element is in the viewport. + * Set `refresh` to keep watching after the first intersection, + * so `visible` also turns false when the element leaves the viewport. + */ export function createLazyRender( options?: LazyRenderOptions, ): LazyRender { diff --git a/src/core/index.tsx b/src/core/index.tsx index ee3ce11..6163aac 100644 --- a/src/core/index.tsx +++ b/src/core/index.tsx @@ -8,16 +8,28 @@ import { mergeImageVariantsToSrcSet, } from "./transformer.ts"; import type { SolidImageSource, SolidImageTransformer, SolidImageVariant } from "./types.ts"; -import { getAspectRatioBoxStyle } from "./utils.ts"; +import { getAspectRatioBoxStyle, getEmptyImageURL } from "./utils.ts"; import "./styles.css"; export interface SolidImageProps { + /** The image, its intrinsic size and any options the transformer needs. */ src: SolidImageSource; + /** Alternative text for the image. */ alt: string; + /** Produces the responsive variants of the source. */ transformer?: SolidImageTransformer; + /** Called once the image has loaded and the placeholder is hidden. */ onLoad?: () => void; + /** + * Placeholder shown while the image loads. It only renders on the client, + * and only after the container enters the viewport. + * + * `visible` is true while the placeholder should be shown. + * Call `onLoad` once the placeholder has mounted. The image is only + * revealed after that call, so a fast image never skips the placeholder. + */ fallback: (visible: () => boolean, onLoad: () => void) => JSX.Element; crossOrigin?: JSX.HTMLCrossorigin | undefined; @@ -47,6 +59,11 @@ function SolidImageSources(props: SolidImageSourcesProps): JSX.Element { ); } +/** + * Renders a responsive image inside a box that keeps its aspect ratio. + * The image loads once the box enters the viewport, and the placeholder + * is shown until then. + */ export function SolidImage(props: SolidImageProps): JSX.Element { const [showPlaceholder, setShowPlaceholder] = createSignal(true); const laze = createLazyRender(); @@ -60,22 +77,28 @@ export function SolidImage(props: SolidImageProps): JSX.Element { const height = createMemo(() => props.src.height); return ( -
+
- - }> + + {cb => } (props: SolidImageProps): JSX.Element { > {props.alt} { if (!defer()) { @@ -108,7 +128,7 @@ export function SolidImage(props: SolidImageProps): JSX.Element {
-
+
{props.fallback(showPlaceholder, onPlaceholderLoad)} diff --git a/src/core/transformer.ts b/src/core/transformer.ts index c377997..5c8bebe 100644 --- a/src/core/transformer.ts +++ b/src/core/transformer.ts @@ -15,6 +15,7 @@ const MIME_TO_FORMAT: Record = { "image/tiff": "tiff", }; +/** Returns the image format for a MIME type. */ export function getFormatFromMIME(mime: SolidImageMIME): SolidImageFormat { return MIME_TO_FORMAT[mime]; } @@ -27,6 +28,7 @@ const FORMAT_TO_MIME: Record = { tiff: "image/tiff", }; +/** Returns the MIME type for an image format. */ export function getMIMEFromFormat(format: SolidImageFormat): SolidImageMIME { return FORMAT_TO_MIME[format]; } @@ -44,6 +46,7 @@ const FILE_TO_FORMAT: Record = { tiff: "tiff", }; +/** Returns the image format for a file extension, such as jpg for jpeg. */ export function getFormatFromFile(file: SolidImageFile): SolidImageFormat { return FILE_TO_FORMAT[file]; } @@ -56,6 +59,7 @@ const FORMAT_TO_FILES: Record = { tiff: ["tif", "tiff"], }; +/** Returns every file extension that maps to the given format. */ export function getFilesFromFormat(format: SolidImageFormat): SolidImageFile[] { return FORMAT_TO_FILES[format]; } @@ -68,6 +72,7 @@ const FORMAT_TO_OUTPUT: Record = { tiff: "tiff", }; +/** Returns the file extension to use when writing a file of the given format. */ export function getOutputFileFromFormat(format: SolidImageFormat): SolidImageFile { return FORMAT_TO_OUTPUT[format]; } @@ -79,6 +84,10 @@ function ensureArray(value: T | T[]): T[] { return [value]; } +/** + * Runs the transformer over a source and always returns an array. + * A transformer may return a single variant for convenience. + */ export function createImageVariants( source: SolidImageSource, transformer: SolidImageTransformer, @@ -90,6 +99,7 @@ function variantToSrcSetPart(variant: SolidImageVariant): string { return variant.path + " " + variant.width + "w"; } +/** Joins variants into one `srcset` value. Each entry is a path and its width. */ export function mergeImageVariantsToSrcSet(variants: SolidImageVariant[]): string { let result = variantToSrcSetPart(variants[0]!); @@ -100,6 +110,10 @@ export function mergeImageVariantsToSrcSet(variants: SolidImageVariant[]): strin return result; } +/** + * Groups variants by MIME type. + * Each group becomes one `source` element inside the picture. + */ export function mergeImageVariantsByType( variants: SolidImageVariant[], ): Map { diff --git a/src/core/utils.ts b/src/core/utils.ts index 4b2291e..97b6739 100644 --- a/src/core/utils.ts +++ b/src/core/utils.ts @@ -9,6 +9,10 @@ function kebabify(str: string): string { .toLowerCase(); } +/** + * Converts camelCase style keys to kebab-case. + * Solid only accepts kebab-case keys when the style object is rendered as a string. + */ export function shimStyle(style: JSX.CSSProperties): JSX.CSSProperties { const keys = Object.keys(style) as (keyof JSX.CSSProperties)[]; const newStyle: JSX.CSSProperties = {}; @@ -20,6 +24,10 @@ export function shimStyle(style: JSX.CSSProperties): JSX.CSSProperties { return newStyle; } +/** + * Style for a box that keeps the given aspect ratio at any width. + * The height comes from a percentage padding, which is relative to the width. + */ export function getAspectRatioBoxStyle(ratio: AspectRatio): JSX.CSSProperties { return { position: "relative", @@ -30,19 +38,23 @@ export function getAspectRatioBoxStyle(ratio: AspectRatio): JSX.CSSProperties { }; } +/** Returns an empty SVG of the given size. */ export function getEmptySVGPlaceholder({ width, height }: AspectRatio): string { return ``; } +/** Wraps an SVG string in a data URL. */ export function getEncodedSVG(svg: string): string { const encodedSVG = encodeURIComponent(svg); return `data:image/svg+xml,${encodedSVG}`; } +/** Encodes the given SVG, or an empty one of that size when none is given. */ export function getEncodedOptionalSVG(ratio: AspectRatio, svg?: string): string { return getEncodedSVG(svg || getEmptySVGPlaceholder(ratio)); } +/** Returns a data URL usable as a blank `img` source of the given size. */ export function getEmptyImageURL(ratio: AspectRatio): string { return getEncodedOptionalSVG(ratio); } diff --git a/src/vite/index.ts b/src/vite/index.ts index 02f66e6..6cafb79 100644 --- a/src/vite/index.ts +++ b/src/vite/index.ts @@ -8,19 +8,28 @@ import xxHash32 from "./xxhash32.ts"; const DEFAULT_INPUT: SolidImageFormat[] = ["png", "jpeg", "webp"]; const DEFAULT_OUTPUT: SolidImageFormat[] = ["png", "jpeg", "webp"]; -const DEFAULT_QUALITY = 0.8; +// sharp takes a quality from 1 to 100. +const DEFAULT_QUALITY = 80; type MaybePromise = T | Promise; export interface SolidImageOptions { + /** Handles imports that end with `?image`. */ local?: { + /** Output widths in pixels. The height follows the aspect ratio. */ sizes: number[]; + /** Source formats to process. Other files are left alone. Defaults to png, jpeg and webp. */ input?: SolidImageFormat[]; + /** Formats to emit. One file is written per format and per size. Defaults to png, jpeg and webp. */ output?: SolidImageFormat[]; - quality: number; + /** Quality passed to sharp, from 1 to 100. Defaults to 80. */ + quality?: number; + /** Directory the processed files are written to. Defaults to `dist`. */ publicPath?: string; }; + /** Handles imports that start with `image:`. */ remote?: { + /** Maps the text after `image:` to a source and its variants. May be async. */ transformURL(url: string): MaybePromise<{ src: { source: string; @@ -101,6 +110,11 @@ export default { src, transformer }; const LOCAL_PATH = /\?image(-[a-z]+(-[0-9]+)?)?/; const REMOTE_PATH = "image:"; +/** + * Vite plugins that turn image imports into responsive image props. + * Returns one plugin per enabled option group, so it can be spread + * or nested in the Vite `plugins` array. + */ export const imagePlugin = (options: SolidImageOptions) => { const plugins: Plugin[] = []; if (options.remote) { diff --git a/src/vite/transformers.ts b/src/vite/transformers.ts index 762d59c..1a1d5c3 100644 --- a/src/vite/transformers.ts +++ b/src/vite/transformers.ts @@ -1,6 +1,10 @@ import sharp from "sharp"; import type { SolidImageFormat } from "../core/types.ts"; +/** + * Resizes an image to the given width and converts it to the target format. + * The height follows the aspect ratio. Quality goes from 1 to 100. + */ export function transformImage( originalPath: string, targetFormat: SolidImageFormat, @@ -37,6 +41,7 @@ interface ImageData { height: number; } +/** Reads the intrinsic size of an image. Missing values become 0. */ export async function getImageData(originalPath: string): Promise { const result = await sharp(originalPath).metadata(); return { From 1654ee5f44b0000b88167f47e7e895a26ec59fb8 Mon Sep 17 00:00:00 2001 From: "Alexis H. Munsayac" Date: Wed, 9 Sep 2026 23:49:34 +0800 Subject: [PATCH 2/3] add: vitest browser mode --- .github/workflows/tests.yml | 3 + README.md | 14 +- package.json | 5 + pnpm-lock.yaml | 123 ++++++++++++- src/__tests__/browser/solid-image.test.tsx | 201 +++++++++++++++++++++ vitest.config.ts | 67 +++++-- 6 files changed, 387 insertions(+), 26 deletions(-) create mode 100644 src/__tests__/browser/solid-image.test.tsx diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index b249002..e46dcc3 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -34,6 +34,9 @@ jobs: - name: Build run: pnpm build + - name: Install Playwright browsers + run: pnpm exec playwright install --with-deps chromium + - name: Test run: pnpm test diff --git a/README.md b/README.md index 298e708..ed52ebb 100644 --- a/README.md +++ b/README.md @@ -261,13 +261,19 @@ The rendered elements carry a `data-solid-image` attribute you can style. The va ```bash pnpm install -pnpm build # bundle with tsdown -pnpm test # run the test suite once +pnpm exec playwright install chromium # once, for the browser tests +pnpm build # bundle with tsdown +pnpm test # run every test once +pnpm test:node # server rendering and Vite plugin only +pnpm test:browser # browser tests only pnpm test:watch -pnpm changeset # add a changeset before opening a pull request +pnpm changeset # add a changeset before opening a pull request ``` -Tests run under Vitest in a Node environment. Components are checked through `renderToString`, so they cover the server rendering path. The Vite plugin tests call the plugin hooks directly and process real images with sharp. +The suite is split into two Vitest projects. + +- `node` covers server rendering through `renderToString`, and calls the Vite plugin hooks directly with real images processed by sharp. +- `browser` runs in headless Chromium through Vitest browser mode. It covers the client path, where a real `IntersectionObserver` decides when the image loads and the browser really loads it. ## License diff --git a/package.json b/package.json index e0f7bd0..fc53930 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,8 @@ "build": "tsdown", "watch": "tsdown --watch", "test": "vitest run", + "test:node": "vitest run --project node", + "test:browser": "vitest run --project browser", "test:watch": "vitest", "changeset": "changeset", "version": "changeset version", @@ -39,6 +41,9 @@ "@changesets/cli": "^2.30.0", "@tsdown/css": "^0.22.12", "@types/node": "^25.5.0", + "@vitest/browser": "4.1.10", + "@vitest/browser-playwright": "4.1.10", + "playwright": "^1.63.0", "solid-js": "^1.9.9", "tsdown": "^0.22.12", "typescript": "^7.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 853142c..fcfa8b7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -21,6 +21,15 @@ importers: '@types/node': specifier: ^25.5.0 version: 25.9.5 + '@vitest/browser': + specifier: 4.1.10 + version: 4.1.10(vite@8.1.5(@types/node@25.9.5))(vitest@4.1.10) + '@vitest/browser-playwright': + specifier: 4.1.10 + version: 4.1.10(playwright@1.63.0)(vite@8.1.5(@types/node@25.9.5))(vitest@4.1.10) + playwright: + specifier: ^1.63.0 + version: 1.63.0 solid-js: specifier: ^1.9.9 version: 1.9.14 @@ -38,7 +47,7 @@ importers: version: 2.11.13(solid-js@1.9.14)(vite@8.1.5(@types/node@25.9.5)) vitest: specifier: ^4.0.10 - version: 4.1.10(@types/node@25.9.5)(vite@8.1.5(@types/node@25.9.5)) + version: 4.1.10(@types/node@25.9.5)(@vitest/browser-playwright@4.1.10)(vite@8.1.5(@types/node@25.9.5)) packages: @@ -127,6 +136,9 @@ packages: resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==} engines: {node: '>=6.9.0'} + '@blazediff/core@1.9.1': + resolution: {integrity: sha512-ehg3jIkYKulZh+8om/O25vkvSsXXwC+skXmyA87FFx6A/45eqOkZsBltMw/TVteb0mloiGT8oGRTcjRAz66zaA==} + '@changesets/apply-release-plan@7.1.1': resolution: {integrity: sha512-9qPCm/rLx/xoOFXIHGB229+4GOL76S4MC+7tyOuTsR6+1jYlfFDQORdvwR5hDA6y4FL2BPt3qpbcQIS+dW85LA==} @@ -414,6 +426,9 @@ packages: '@oxc-project/types@0.140.0': resolution: {integrity: sha512-h5LUOzGArYemnW1NMz/DuuQhBi96J6JL2Bk8zE4kvqxB5Sg3jxmCiH4uyOWHDkiKSt5vWlG4FIwCR/DbstcNRQ==} + '@polka/url@1.0.0-next.29': + resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} + '@quansync/fs@1.0.0': resolution: {integrity: sha512-4TJ3DFtlf1L5LDMaM6CanJ/0lckGNtJcMjQ1NAV6zDmA0tEHKZtxNKin8EgPaVX1YzljbxckyT2tJrpQKAtngQ==} @@ -785,6 +800,17 @@ packages: cpu: [x64] os: [win32] + '@vitest/browser-playwright@4.1.10': + resolution: {integrity: sha512-nMoXGEiRpT7m3W7NsbvrM2aKNwiNHZf+zEpUCvMteGjZFvfT96Q9fh7QyB98dvDWXiKvrLxA7bJ1mCOOv+JQPw==} + peerDependencies: + playwright: '*' + vitest: 4.1.10 + + '@vitest/browser@4.1.10': + resolution: {integrity: sha512-UDwuWGwXj646CBx/bQHOaJSX7np0I8JL/UKQYa1e4QrVHH8VdWtx8eaOuf8sy0ShwDgR6NjJAsp5eF6vjF6qng==} + peerDependencies: + vitest: 4.1.10 + '@vitest/expect@4.1.10': resolution: {integrity: sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==} @@ -1327,6 +1353,10 @@ packages: resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==} engines: {node: '>=4'} + mrmime@2.0.1: + resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==} + engines: {node: '>=10'} + ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} @@ -1402,6 +1432,20 @@ packages: resolution: {integrity: sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==} engines: {node: '>=6'} + playwright-core@1.63.0: + resolution: {integrity: sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==} + engines: {node: '>=20'} + hasBin: true + + playwright@1.63.0: + resolution: {integrity: sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==} + engines: {node: '>=20'} + hasBin: true + + pngjs@7.0.0: + resolution: {integrity: sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==} + engines: {node: '>=14.19.0'} + postcss-load-config@6.0.1: resolution: {integrity: sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==} engines: {node: '>= 18'} @@ -1531,6 +1575,10 @@ packages: resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} engines: {node: '>=14'} + sirv@3.0.2: + resolution: {integrity: sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==} + engines: {node: '>=18'} + slash@3.0.0: resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} engines: {node: '>=8'} @@ -1590,6 +1638,10 @@ packages: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} + totalist@3.0.1: + resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==} + engines: {node: '>=6'} + tree-kill@1.2.2: resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} hasBin: true @@ -1768,6 +1820,18 @@ packages: engines: {node: '>=8'} hasBin: true + ws@8.21.3: + resolution: {integrity: sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} @@ -1895,6 +1959,8 @@ snapshots: '@babel/helper-string-parser': 7.29.7 '@babel/helper-validator-identifier': 7.29.7 + '@blazediff/core@1.9.1': {} + '@changesets/apply-release-plan@7.1.1': dependencies: '@changesets/config': 3.1.4 @@ -2243,6 +2309,8 @@ snapshots: '@oxc-project/types@0.140.0': {} + '@polka/url@1.0.0-next.29': {} + '@quansync/fs@1.0.0': dependencies: quansync: 1.0.0 @@ -2463,6 +2531,36 @@ snapshots: '@typescript/typescript-win32-x64@7.0.2': optional: true + '@vitest/browser-playwright@4.1.10(playwright@1.63.0)(vite@8.1.5(@types/node@25.9.5))(vitest@4.1.10)': + dependencies: + '@vitest/browser': 4.1.10(vite@8.1.5(@types/node@25.9.5))(vitest@4.1.10) + '@vitest/mocker': 4.1.10(vite@8.1.5(@types/node@25.9.5)) + playwright: 1.63.0 + tinyrainbow: 3.1.0 + vitest: 4.1.10(@types/node@25.9.5)(@vitest/browser-playwright@4.1.10)(vite@8.1.5(@types/node@25.9.5)) + transitivePeerDependencies: + - bufferutil + - msw + - utf-8-validate + - vite + + '@vitest/browser@4.1.10(vite@8.1.5(@types/node@25.9.5))(vitest@4.1.10)': + dependencies: + '@blazediff/core': 1.9.1 + '@vitest/mocker': 4.1.10(vite@8.1.5(@types/node@25.9.5)) + '@vitest/utils': 4.1.10 + magic-string: 0.30.21 + pngjs: 7.0.0 + sirv: 3.0.2 + tinyrainbow: 3.1.0 + vitest: 4.1.10(@types/node@25.9.5)(@vitest/browser-playwright@4.1.10)(vite@8.1.5(@types/node@25.9.5)) + ws: 8.21.3 + transitivePeerDependencies: + - bufferutil + - msw + - utf-8-validate + - vite + '@vitest/expect@4.1.10': dependencies: '@standard-schema/spec': 1.1.0 @@ -2871,6 +2969,8 @@ snapshots: mri@1.2.0: {} + mrmime@2.0.1: {} + ms@2.1.3: {} nanoid@3.3.16: {} @@ -2921,6 +3021,14 @@ snapshots: pify@4.0.1: {} + playwright-core@1.63.0: {} + + playwright@1.63.0: + dependencies: + playwright-core: 1.63.0 + + pngjs@7.0.0: {} + postcss-load-config@6.0.1(postcss@8.5.22): dependencies: lilconfig: 3.1.3 @@ -3069,6 +3177,12 @@ snapshots: signal-exit@4.1.0: {} + sirv@3.0.2: + dependencies: + '@polka/url': 1.0.0-next.29 + mrmime: 2.0.1 + totalist: 3.0.1 + slash@3.0.0: {} solid-js@1.9.14: @@ -3122,6 +3236,8 @@ snapshots: dependencies: is-number: 7.0.0 + totalist@3.0.1: {} + tree-kill@1.2.2: {} tsdown@0.22.13(@tsdown/css@0.22.13)(typescript@7.0.2): @@ -3221,7 +3337,7 @@ snapshots: optionalDependencies: vite: 8.1.5(@types/node@25.9.5) - vitest@4.1.10(@types/node@25.9.5)(vite@8.1.5(@types/node@25.9.5)): + vitest@4.1.10(@types/node@25.9.5)(@vitest/browser-playwright@4.1.10)(vite@8.1.5(@types/node@25.9.5)): dependencies: '@vitest/expect': 4.1.10 '@vitest/mocker': 4.1.10(vite@8.1.5(@types/node@25.9.5)) @@ -3245,6 +3361,7 @@ snapshots: why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 25.9.5 + '@vitest/browser-playwright': 4.1.10(playwright@1.63.0)(vite@8.1.5(@types/node@25.9.5))(vitest@4.1.10) transitivePeerDependencies: - msw @@ -3257,6 +3374,8 @@ snapshots: siginfo: 2.0.0 stackback: 0.0.2 + ws@8.21.3: {} + yallist@3.1.1: {} yuku-ast@0.7.4: diff --git a/src/__tests__/browser/solid-image.test.tsx b/src/__tests__/browser/solid-image.test.tsx new file mode 100644 index 0000000..97c09de --- /dev/null +++ b/src/__tests__/browser/solid-image.test.tsx @@ -0,0 +1,201 @@ +import { onMount, Show } from "solid-js"; +import { render } from "solid-js/web"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { SolidImage } from "../../core/index"; +import "../../core/styles.css"; + +// A 1x1 transparent PNG, so the browser can really load an image offline. +const PIXEL = + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="; + +const disposers: (() => void)[] = []; + +afterEach(() => { + for (const dispose of disposers.splice(0)) { + dispose(); + } + document.body.innerHTML = ""; + window.scrollTo(0, 0); +}); + +/** + * Mounts the component below the fold, inside a page that scrolls. + * `scrollIntoView` then moves it into the viewport. + */ +function mount(ui: () => ReturnType) { + const page = document.createElement("div"); + const spacer = document.createElement("div"); + spacer.style.height = "200vh"; + const host = document.createElement("div"); + host.style.width = "320px"; + + page.append(spacer, host); + document.body.append(page); + + disposers.push(render(ui, host)); + + return { host, scrollIntoView: () => host.scrollIntoView() }; +} + +function findImage(host: HTMLElement) { + return host.querySelector('img[data-solid-image="image"]'); +} + +function Placeholder(props: { show: () => void }) { + onMount(() => { + props.show(); + }); + + return
Loading...
; +} + +describe("SolidImage in the browser", () => { + it("does not render the image before it scrolls into view", async () => { + const { host } = mount(() => ( + ( + + + + )} + /> + )); + + // Give the observer a chance to report, then confirm nothing rendered. + await new Promise(resolve => setTimeout(resolve, 100)); + + expect(findImage(host)).toBe(null); + expect(host.querySelector('[data-test="placeholder"]')).toBe(null); + }); + + it("renders the image with its source once it scrolls into view", async () => { + const { host, scrollIntoView } = mount(() => ( + ( + + + + )} + /> + )); + + scrollIntoView(); + + await expect.poll(() => findImage(host)?.getAttribute("src")).toBe(PIXEL); + expect(findImage(host)!.alt).toBe("pixel"); + }); + + it("shows the placeholder, then reveals the loaded image", async () => { + const onLoad = vi.fn(); + let visibleOnMount: boolean | undefined; + + const { host, scrollIntoView } = mount(() => ( + ( + + { + // The image loads in a few milliseconds, so record the state + // here instead of polling for a placeholder that is already gone. + visibleOnMount = visible(); + show(); + }} + /> + + )} + /> + )); + + scrollIntoView(); + + await expect.poll(() => findImage(host)?.style.opacity).toBe("1"); + + expect(visibleOnMount).toBe(true); + expect(onLoad).toHaveBeenCalledTimes(1); + expect(host.querySelector('[data-test="placeholder"]')).toBe(null); + }); + + it("keeps the image hidden while the placeholder has not mounted", async () => { + const { host, scrollIntoView } = mount(() => ( +
Loading...
} + /> + )); + + scrollIntoView(); + + await expect.poll(() => findImage(host)).not.toBe(null); + await new Promise(resolve => setTimeout(resolve, 100)); + + expect(findImage(host)!.style.opacity).toBe("0"); + }); + + it("renders one source per MIME type from the transformer", async () => { + const { host, scrollIntoView } = mount(() => ( + [ + { path: PIXEL, width: 400, type: "image/webp" }, + { path: PIXEL, width: 800, type: "image/webp" }, + { path: PIXEL, width: 400, type: "image/png" }, + ], + }} + fallback={(visible, show) => ( + + + + )} + /> + )); + + scrollIntoView(); + + await expect.poll(() => findImage(host)).not.toBe(null); + + const sources = [...host.querySelectorAll("source")]; + expect(sources).toHaveLength(2); + expect(sources.map(source => source.type)).toEqual(["image/webp", "image/png"]); + expect(sources[0]!.srcset).toBe(`${PIXEL} 400w,${PIXEL} 800w`); + }); + + it("reserves the aspect ratio before the image loads", () => { + const { host } = mount(() => ( +
Loading...
} + /> + )); + + const box = host.querySelector('[data-solid-image="aspect-ratio"]')!; + + // 320px wide at 16:9. + expect(box.getBoundingClientRect().height).toBeCloseTo(180, 0); + }); + + it("applies the shipped stylesheet to the rendered elements", () => { + const { host } = mount(() => ( +
Loading...
} + /> + )); + + const picture = host.querySelector('[data-solid-image="picture"]')!; + + expect(getComputedStyle(picture).position).toBe("absolute"); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts index 021686d..3ee138a 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,27 +1,54 @@ +import { playwright } from "@vitest/browser-playwright"; import solid from "vite-plugin-solid"; import { defineConfig } from "vitest/config"; +import type { Plugin } from "vite"; + +// vite-plugin-solid automatically injects @testing-library/jest-dom into +// setupFiles when it detects the module in pnpm's store. Because the image +// package does not depend on jest-dom, the import fails at runtime. +// Strip the injected entry so vitest never tries to load it. +const stripJestDomSetup: Plugin = { + name: "strip-jest-dom-setup", + config(config) { + const files = config.test?.setupFiles; + if (Array.isArray(files)) { + config.test!.setupFiles = files.filter( + f => typeof f !== "string" || !f.includes("jest-dom"), + ); + } + }, +}; export default defineConfig({ - plugins: [ - solid({ ssr: true }), - // vite-plugin-solid automatically injects @testing-library/jest-dom into - // setupFiles when it detects the module in pnpm's store. Because the image - // package does not depend on jest-dom, the import fails at runtime. - // Strip the injected entry so vitest never tries to load it. - { - name: "strip-jest-dom-setup", - config(config) { - const files = config.test?.setupFiles; - if (Array.isArray(files)) { - config.test!.setupFiles = files.filter( - f => typeof f !== "string" || !f.includes("jest-dom"), - ); - } - }, - }, - ], test: { - globals: true, - environment: "node", + projects: [ + { + // Server rendering and the Vite plugin, which both run in Node. + plugins: [solid({ ssr: true }), stripJestDomSetup], + test: { + name: "node", + globals: true, + environment: "node", + include: ["src/__tests__/*.test.{ts,tsx}"], + }, + }, + { + // Client rendering, which needs a real IntersectionObserver and a + // browser that loads images. + plugins: [solid(), stripJestDomSetup], + test: { + name: "browser", + globals: true, + include: ["src/__tests__/browser/*.test.{ts,tsx}"], + browser: { + enabled: true, + provider: playwright(), + headless: true, + screenshotFailures: false, + instances: [{ browser: "chromium" }], + }, + }, + }, + ], }, }); From 0105842e811848f7730ac0552f8185fef234bc6c Mon Sep 17 00:00:00 2001 From: "Alexis H. Munsayac" Date: Thu, 10 Sep 2026 00:58:22 +0800 Subject: [PATCH 3/3] docs: simplify README Cut words that added no information and turned trailing prose into lists. Dropped the duplicate install blocks for pnpm and yarn. Trimmed the usage example to the parts a reader needs. Co-Authored-By: Claude Opus 5 --- README.md | 123 +++++++++++++++++++++++++----------------------------- 1 file changed, 56 insertions(+), 67 deletions(-) diff --git a/README.md b/README.md index ed52ebb..1132c5c 100644 --- a/README.md +++ b/README.md @@ -2,11 +2,11 @@ Optimized image components and Vite tooling for [Solid](https://solidjs.com). -- `SolidImage` renders a responsive `` that reserves the image's aspect ratio, so the page does not shift while the image loads. -- The image only loads once it scrolls into view, using `IntersectionObserver`. -- Your own placeholder is rendered while the image loads, and fades out when the image is ready. -- The Vite plugin turns a local image import into a set of resized and reformatted files at build time. -- Remote images go through your own URL mapping, so a CDN can serve the variants instead. +- `SolidImage` renders a responsive `` that reserves the aspect ratio, so the page does not shift while the image loads. +- The image loads once it scrolls into view. +- Your placeholder shows until the image is ready. +- The Vite plugin resizes and reformats local images at build time. +- Remote images go through your own URL mapping, so a CDN can serve the variants. ## Install @@ -14,15 +14,10 @@ Optimized image components and Vite tooling for [Solid](https://solidjs.com). npm i @solidjs/image ``` -```bash -pnpm add @solidjs/image -``` - -```bash -yarn add @solidjs/image -``` +Requirements: -The package needs `solid-js` 1.9.9 or newer and Vite 8 or newer as peer dependencies. Node 24 or newer is required for the Vite plugin, which uses [`sharp`](https://sharp.pixelplumbing.com) to process images. +- `solid-js` 1.9.9 or newer, and Vite 8 or newer. Both are peer dependencies. +- Node 24 or newer for the Vite plugin. It uses [`sharp`](https://sharp.pixelplumbing.com) to process images. ## Setup @@ -50,11 +45,11 @@ export default defineConfig({ }); ``` -`imagePlugin` returns an array of Vite plugins, so spread it or nest it in the `plugins` array. Both are accepted by Vite. +`imagePlugin` returns an array of plugins. Spread it or nest it, Vite accepts both. ### 2. Add the ambient types -The plugin resolves imports that TypeScript does not know about, such as `./photo.png?image` and `image:hero`. Reference the shipped declarations once in your project: +TypeScript does not know about imports such as `./photo.png?image` and `image:hero`. Reference the shipped declarations once: ```ts // env.d.ts @@ -67,57 +62,49 @@ The plugin resolves imports that TypeScript does not know about, such as `./phot import "@solidjs/image/style.css"; ``` -The stylesheet positions the picture, the image and the placeholder inside the aspect ratio box. Import it once, in your app entry. +This positions the picture, the image and the placeholder inside the aspect ratio box. Import it once, in your app entry. ## Usage ### Local image -Import the image with the `?image` query. The import gives you the `src` and the `transformer` props. +Import the image with the `?image` query. You get the `src` and `transformer` props. ```tsx import { SolidImage } from "@solidjs/image"; -import { type JSX, onMount, Show } from "solid-js"; +import { onMount, Show } from "solid-js"; import example from "../images/example.jpg?image"; -function Placeholder(props: { show: () => void }): JSX.Element { - onMount(() => { - props.show(); - }); +function Placeholder(props: { show: () => void }) { + onMount(() => props.show()); return
Loading...
; } -export default function App(): JSX.Element { +export default function App() { return ( -
- ( - - - - )} - /> -
+ ( + + + + )} + /> ); } ``` ### Remote image -Import `image:` followed by any string. The plugin passes that string to your `transformURL` function. +Import `image:` followed by any string. The plugin passes that string to `transformURL`. ```tsx -import { SolidImage } from "@solidjs/image"; - import example from "image:foobar"; -export default function App() { - return
Loading...
} />; -} +
Loading...
} />; ``` ```ts @@ -140,11 +127,11 @@ imagePlugin({ }); ``` -`transformURL` may be async, so you can call a CDN API while the module is loaded. +`transformURL` may be async, so it can call a CDN API. ### Without the plugin -The component does not depend on the plugin. Pass `src` and an optional `transformer` yourself: +The component works on its own. Pass `src` and an optional `transformer`: ```tsx ` | yes | The image source, its intrinsic size and any options your transformer needs. | -| `alt` | `string` | yes | Alternative text for the image. | -| `fallback` | `(visible: () => boolean, onLoad: () => void) => JSX.Element` | yes | Placeholder shown while the image loads. See below. | +| `src` | `SolidImageSource` | yes | The image, its intrinsic size and any options your transformer needs. | +| `alt` | `string` | yes | Alternative text. | +| `fallback` | `(visible: () => boolean, onLoad: () => void) => JSX.Element` | yes | Placeholder shown while the image loads. | | `transformer` | `SolidImageTransformer` | no | Produces the responsive variants for `src`. | | `onLoad` | `() => void` | no | Called once the image has loaded and the placeholder is hidden. | | `crossOrigin` | `JSX.HTMLCrossorigin` | no | Forwarded to the ``. | | `fetchPriority` | `"high" \| "low" \| "auto"` | no | Forwarded to the ``. | | `decoding` | `"sync" \| "async" \| "auto"` | no | Forwarded to the ``. | -The `fallback` callback receives two arguments. +The `fallback` callback takes two arguments. -- `visible` is a signal that is `true` while the placeholder should be shown. It turns `false` once the image has loaded. -- `onLoad` tells the component that your placeholder is on screen. Call it once your placeholder has mounted. The image is only revealed after this call, so the placeholder is never skipped by an image that loads instantly. +- `visible` is a signal. It is `true` while the placeholder should be shown, and `false` once the image has loaded. +- `onLoad` tells the component your placeholder is on screen. Call it once the placeholder has mounted. The image is only revealed after that call, so an image that loads instantly never skips the placeholder. -The `fallback` only renders on the client, and only after the container has scrolled into view. +The `fallback` renders on the client only, and only after the container scrolls into view. ### Types @@ -205,13 +192,14 @@ interface SolidImageTransformer { - `SolidImageMIME` is `"image/avif" | "image/jpeg" | "image/png" | "image/webp" | "image/tiff"`. - `SolidImageFormat` is `"avif" | "jpeg" | "png" | "webp" | "tiff"`. -- `SolidImageFile` is every file extension that maps to a format, such as `"jpg"`, `"jpeg"`, `"jfif"`, `"pjpeg"`, `"pjp"`, `"tif"` and `"tiff"`. +- `SolidImageFile` is every file extension that maps to a format, such as `"jpg"`, `"jfif"` and `"tif"`. -`width` and `height` on the source are the intrinsic pixel size. They are only used to reserve the aspect ratio box, so any pair with the right ratio works. +Notes on the shape: -Variants are grouped by `type` and merged into one `srcset` per group. The browser picks the first `` whose type it supports, then picks a width from the `srcset`. Order your output formats from most to least preferred. - -The transformer is optional. Without one, no `` is rendered and the browser loads `src.source` directly. +- `width` and `height` are the intrinsic pixel size. They only reserve the aspect ratio box, so any pair with the right ratio works. +- Variants are grouped by `type`, and each group becomes one `` with a merged `srcset`. +- The browser takes the first `` it supports, so order your output formats from most to least preferred. +- Without a transformer no `` is rendered, and the browser loads `src.source`. ### `imagePlugin(options)` @@ -219,6 +207,8 @@ The transformer is optional. Without one, no `` is rendered and the brow import { imagePlugin } from "@solidjs/image/vite"; ``` +Both option groups are optional. Passing neither returns no plugin. + #### `options.local` Handles imports ending in `?image`. @@ -227,13 +217,13 @@ Handles imports ending in `?image`. | --- | --- | --- | --- | | `sizes` | `number[]` | required | Output widths in pixels. Height follows the aspect ratio. | | `quality` | `number` | `80` | Quality passed to sharp, from 1 to 100. | -| `input` | `SolidImageFormat[]` | `["png", "jpeg", "webp"]` | Source formats the plugin will process. Other files are left alone. | +| `input` | `SolidImageFormat[]` | `["png", "jpeg", "webp"]` | Source formats to process. Other files are left alone. | | `output` | `SolidImageFormat[]` | `["png", "jpeg", "webp"]` | Formats to emit. | | `publicPath` | `string` | `"dist"` | Directory the processed files are written to. | -One file is emitted per output format and per size, so `output: ["webp", "jpeg"]` with `sizes: [480, 800]` gives four files per image. - -Files are written to `/.image/i--.`, where the hash is an xxHash32 of the source path. The module exports the public URL `/.image/i--.`, so `publicPath` should be a directory that is served at the root of your site. Add `.image` to `.gitignore` if it lives inside a checked in directory such as `public`. +- One file is emitted per output format and per size. `output: ["webp", "jpeg"]` with `sizes: [480, 800]` gives four files per image. +- Files are written to `/.image/i--.`, and the module exports the URL `/.image/i--.`. The hash is an xxHash32 of the source path. +- `publicPath` should be served at the root of your site. Add `.image` to `.gitignore` when it sits inside a checked in directory such as `public`. #### `options.remote` @@ -245,17 +235,16 @@ Handles imports starting with `image:`. `src` is `{ source, width, height }`. `variants` is one `SolidImageVariant` or an array of them. -Both option groups are optional. Passing neither returns no plugin. - ## How it works -1. `SolidImage` renders a container with a padding based aspect ratio box, so the layout is stable before the image arrives. +1. `SolidImage` renders a padding based aspect ratio box, so the layout is stable before the image arrives. 2. An `IntersectionObserver` watches the container. Nothing loads until it enters the viewport. -3. Once visible, the `` and your placeholder are rendered. The image starts fully transparent. -4. Your placeholder calls `onLoad` to say it is on screen. When the image finishes loading after that, the placeholder is hidden, the image fades in and the `onLoad` prop is called. -5. On the server, the `` renders with a blank SVG of the same size, so the browser does not fetch the image before it is in view. The placeholder and the loading logic are client only. +3. Once visible, the `` and your placeholder render. The image starts transparent. +4. Your placeholder calls `onLoad` to say it is on screen. +5. When the image finishes loading after that call, the placeholder is hidden, the image fades in, and the `onLoad` prop fires. +6. On the server the `` carries a blank SVG of the same size, so nothing is fetched before the image is in view. The placeholder and the loading logic are client only. -The rendered elements carry a `data-solid-image` attribute you can style. The values are `container`, `aspect-ratio`, `picture`, `image` and `blocker`. The shipped stylesheet uses the same attribute. +Every rendered element carries a `data-solid-image` attribute you can style. The values are `container`, `aspect-ratio`, `picture`, `image` and `blocker`. The shipped stylesheet uses the same attribute. ## Development @@ -272,8 +261,8 @@ pnpm changeset # add a changeset before opening a pull request The suite is split into two Vitest projects. -- `node` covers server rendering through `renderToString`, and calls the Vite plugin hooks directly with real images processed by sharp. -- `browser` runs in headless Chromium through Vitest browser mode. It covers the client path, where a real `IntersectionObserver` decides when the image loads and the browser really loads it. +- `node` covers server rendering through `renderToString`. It also calls the Vite plugin hooks directly, with real images processed by sharp. +- `browser` runs in headless Chromium through Vitest browser mode. It covers the client path, where a real `IntersectionObserver` decides when the image loads. ## License