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/.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 036a9f5..1132c5c 100644 --- a/README.md +++ b/README.md @@ -1,185 +1,269 @@ # `@solidjs/image` +Optimized image components and Vite tooling for [Solid](https://solidjs.com). + +- `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 ```bash npm i @solidjs/image ``` -```bash -pnpm add @solidjs/image -``` +Requirements: + +- `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 -### 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 plugins. Spread it or nest it, Vite accepts both. + +### 2. Add the ambient types + +TypeScript does not know about imports such as `./photo.png?image` and `image:hero`. Reference the shipped declarations once: + +```ts +// env.d.ts +/// +``` + +### 3. Import the styles + +```ts +import "@solidjs/image/style.css"; +``` + +This positions the picture, the image and the placeholder inside the aspect ratio box. Import it once, in your app entry. + ## Usage ### Local image -```tsx -import { SolidImage as Image } from "@solidjs/image"; -import { type JSX, onMount, Show } from "solid-js"; +Import the image with the `?image` query. You get the `src` and `transformer` props. -import exampleImage from "../images/example.jpg?image"; +```tsx +import { SolidImage } from "@solidjs/image"; +import { onMount, Show } from "solid-js"; -interface PlaceholderProps { - show: () => void; -} +import example from "../images/example.jpg?image"; -function Placeholder(props: PlaceholderProps): 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 ( -
- example ( - - - - )} - /> -
+ ( + + + + )} + /> ); } ``` ### Remote image +Import `image:` followed by any string. The plugin passes that string to `transformURL`. + ```tsx -import { SolidImage as Image } from "@solidjs/image"; -import { type JSX, onMount, Show } from "solid-js"; +import example from "image:foobar"; -import exampleImage from "image:foobar"; +
Loading...
} />; +``` -interface PlaceholderProps { - show: () => void; -} +```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" }, + ], + }; + }, + }, +}); +``` -function Placeholder(props: PlaceholderProps): JSX.Element { - onMount(() => { - props.show(); - }); +`transformURL` may be async, so it can call a CDN API. - return
Loading...
; +### Without the plugin + +The component works on its own. Pass `src` and an optional `transformer`: + +```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, 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 takes two arguments. + +- `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` renders on the client only, and only after the container scrolls 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"`, `"jfif"` and `"tif"`. + +Notes on the shape: + +- `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)` + +```ts +import { imagePlugin } from "@solidjs/image/vite"; +``` + +Both option groups are optional. Passing neither returns no plugin. + +#### `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 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. `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` + +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. + +## How it works + +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 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. + +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 + +```bash +pnpm install +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 +``` + +The suite is split into two Vitest projects. + +- `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 + +MIT 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/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 { 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" }], + }, + }, + }, + ], }, });