diff --git a/skills/setup-srcset/SKILL.md b/skills/setup-srcset/SKILL.md index 950586a..bc0aae5 100644 --- a/skills/setup-srcset/SKILL.md +++ b/skills/setup-srcset/SKILL.md @@ -38,7 +38,7 @@ import url, { src, srcSet, srcMap, placeholder } from './photo.jpg' | Export | What it is | |---|---| -| `default` | Url of the selected variant, e.g. `/assets/photo.f37e2d3a.jpg` | +| `default` | Url of the selected variant, e.g. `/assets/photo-BsK7yzuP.jpg` | | `src` | The selected variant: `{ id, format, type, width, height, url }` | | `srcSet` | Every generated variant, as an array | | `srcMap` | Id-to-url map, e.g. `srcMap.webp600` | @@ -54,7 +54,7 @@ Ask what the project builds with, or detect it — `vite.config.*`, `webpack.con |---|---| | Vite (also Astro, SvelteKit, Nuxt, Remix — anything on Vite) | `@srcset/vite-plugin` | | Webpack or Rspack (also Rsbuild) | `@srcset/loader` | -| No bundler integration wanted, or images processed once and committed | `@srcset/cli` with `--module` — see the `srcset-cli` skill | +| No bundler integration wanted, or images processed once and committed | `@srcset/cli` with `--module` — see the `srcset` skill | | Images are not in the repository — they come from an API or a CMS | `@srcset/imgproxy` or `@srcset/cloudflare` | The first three are build-time: they need the image files in the project. The proxy adapters are runtime and isomorphic: they build variant urls for images served by [imgproxy](https://imgproxy.net/) or [Cloudflare](https://developers.cloudflare.com/images/), so they need no sharp and no build step, and they install as regular dependencies rather than dev ones. @@ -63,11 +63,11 @@ A project can use several — a bundler integration for the images it ships and ## Install -Always add `@srcset/runtime` alongside a build-time integration: it carries the `SrcSetEntry` type and the helpers that turn variants into DOM attributes. +Always add `@srcset/runtime` alongside a build-time integration: it carries the `SrcSetEntry` type and the helpers that turn variants into DOM attributes. The integration only runs at build time, the runtime ships to the browser, so they go into different dependency groups: ```bash -pnpm add -D @srcset/vite-plugin @srcset/runtime # vite -pnpm add -D @srcset/loader @srcset/runtime # webpack / rspack +pnpm add -D @srcset/vite-plugin # or @srcset/loader +pnpm add @srcset/runtime ``` Use the project's package manager — `yarn add -D`, `npm i -D`. For a framework, add the components package too: `@srcset/react`, `@srcset/preact` or `@srcset/svelte`. @@ -153,7 +153,7 @@ A rule is a match plus what to generate. This is where most of the setup goes, a - **A rule without `match` matches everything** — it belongs last, as the catch-all. - **`match`** takes a glob (`'**/*.png'`), a CSS media query against the source size (`'(min-width: 1920px)'`), a function, or an array of them. An array means **all** must match, not any. - **`width`** — a number greater than 1 is absolute pixels, a number **less than or equal to 1 is a multiplier** of the source width: `[1, 0.5]` is "original and half". Pixels are never upscaled; `scalingUp: false` drops variants requested wider than the source instead of capping them. -- **`format`** — the **first format is the fallback**: it becomes the default export and `src`. Put the widely supported one first and the modern ones after it: `['jpg', 'webp', 'avif']`. +- **`format`** — the default export and `src` point at the variant **in the source format at the source width**; the first format of the list takes over only when the source format is not in it. Put the widely supported one first and the modern ones after it: `['jpg', 'webp', 'avif']`. - **Keep png as png and gif as gif** in their own rules. Converting a png to jpg loses transparency, and a gif that is not kept as gif or webp loses its animation. - **Svg is never resized or converted.** A rule passes an svg through only when its `format` is unset or includes `svg` — a raster-only `format` drops the svg silently. The Vite plugin skips `.svg` imports entirely; keep them out of the loader's `test` too. @@ -188,15 +188,17 @@ const sources = getSourceProps(srcSet) With a framework, use the components — they handle the `` structure, the blur-up placeholder and priority loading: ```tsx -import { src, srcSet, placeholder } from './photo.jpg' import { Picture, Image } from '@srcset/react' +import { src, srcSet, placeholder } from './photo.jpg' + +const sizes = '(min-width: 900px) 800px, 100vw' - - Hero photo + + Hero photo ``` -`@srcset/preact` and `@srcset/svelte` expose the same two components. +`srcSet` and `sizes` go to both — given only to `Picture`, the fallback `` is left with one fixed url. `@srcset/preact` and `@srcset/svelte` expose the same two components; the `srcset` skill covers writing code against them. ## Override Per Import @@ -221,7 +223,7 @@ The import query overrides the configured options for one import. Parts combine - A rule set with no catch-all produces an **empty module** for an unmatched image — default export `''`, `src` is `null`, `srcSet` is `[]` — and the page silently renders no image. - `match` with an array is an **and**, not an or. Use separate rules for "either". -- The first `format` is the fallback that non-supporting browsers get. `['avif', 'jpg']` hands avif to everyone as the default export. +- The first `format` is the fallback only when the source format is missing from the list. `['avif', 'jpg']` on a jpg source still hands out the jpg; the same list on a png source hands avif to everyone. - In webpack and Rspack an image extension with no rule fails to import at all — there is no built-in handling for `.jpg`. Image extensions left out of the loader's `test` still need an `asset/resource` rule of their own. - For an SSR or SSG setup, run the loader with `emitFile: false` on the server build so the same files are not written twice. - Animated gif: keep `gif` or `webp` in the formats. Converting to jpg or avif flattens it to a single frame. diff --git a/skills/srcset-cli/SKILL.md b/skills/srcset/SKILL.md similarity index 54% rename from skills/srcset-cli/SKILL.md rename to skills/srcset/SKILL.md index 7875729..c3ece27 100644 --- a/skills/srcset-cli/SKILL.md +++ b/skills/srcset/SKILL.md @@ -1,6 +1,6 @@ --- -name: srcset-cli -description: Generate responsive image variants from the command line with @srcset/cli — resize, convert to modern formats and optimize by glob and rules, and bake ES modules that import the variants so a project can commit the result and drop its bundler integration. Apply when asked to resize, convert, optimize or bake images without a bundler. +name: srcset +description: Write application code that uses srcset — import the generated image module, render it with the Picture and Image components or the runtime helpers, size it with `sizes`, and generate or bake variants with the cli. Apply when writing or changing code that displays images in a project already set up with srcset. license: MIT compatibility: - Claude Code @@ -21,28 +21,138 @@ metadata: tags: - srcset - responsive-images + - react + - preact + - svelte - cli - - sharp - image-optimization - - codegen --- -# srcset CLI +# srcset -[`@srcset/cli`](https://github.com/TrigenSoftware/srcset/tree/main/packages/cli) resizes, converts and optimizes images with [sharp](https://sharp.pixelplumbing.com/) from the command line. With `--module` it also **bakes**: alongside the variants it writes an ES module importing them, so a project can commit the result and never install a bundler integration. +[srcset](https://github.com/TrigenSoftware/srcset) turns an image import into a **module carrying every generated variant**, so a page renders a real `srcset` instead of one fixed file. -Use it when the user asks to prepare responsive images, convert a folder to webp/avif, shrink images for the web, or bake image modules. For wiring a bundler instead, use the `setup-srcset` skill. +Use this skill to write code against it: importing the module, rendering it, and generating variants from the command line. To wire an integration into a project for the first time, use the `setup-srcset` skill instead. -```bash +Documentation: + +## The image module + +```ts +import url, { src, srcSet, srcMap, placeholder } from './photo.jpg' +``` + +| Export | What it is | +| --- | --- | +| `default` | Url of the selected variant | +| `src` | The selected variant: `{ id, format, type, width, height, url }` | +| `srcSet` | Every generated variant, as an array | +| `srcMap` | Id-to-url map, e.g. `srcMap.webp640` — the id is the format plus the actual width | +| `placeholder` | Blur-up data-url, when the `placeholder` option is on | + +The module is tree-shakable, so importing `url` alone leaves the rest out of the bundle. + +**Two things produce it, and their exports are identical**: a bundler integration, where the import is the image file itself, and the cli in `--module` mode, where the module is a real file on disk that the project commits. Code written against one works against the other, so everything below applies to both. + +The **default export and `src`** point at the variant in the source format at the source width; when the rules produced no such variant, the first generated one wins. + +## Rendering + +Do not build `srcset` strings by hand. Grouping by mime type and ordering by format efficiency is what the runtime and the components are for. + +### With the components + +`@srcset/react`, `@srcset/preact` and `@srcset/svelte` ship the same two components. + +```tsx +import { Picture, Image } from '@srcset/react' +import { src, srcSet, placeholder } from './photo.jpg' + +// The layout width of the image, which the browser needs before it has laid +// the page out: 800px on wide screens, the full viewport width otherwise. +const sizes = '(min-width: 900px) 800px, 100vw' + + + A photo + +``` + +Rules that matter: + +- **`srcSet` goes to both.** `Picture` splits it into a `` per format; `Image` narrows it to the format of `src` for the fallback ``. Given only to `Picture`, the `` is left with one fixed url, and a browser that falls through to it downloads the full sized file on a phone. +- **`src` goes to `Image` only.** It selects the fallback format, supplies the `src` attribute, and gives the intrinsic `width`/`height` that hold the layout still. `Picture` has no `src` prop. +- **`sizes` goes to both.** The `sizes` of an `` does not apply to a selected ``. +- **`placeholder`** is shown as a background until the image loads, once per mount. For a new image source, remount: a `key` in React and Preact, a `{#key}` block in Svelte. +- **`priority`** switches `loading="lazy"` to `eager` with `fetchpriority="high"`, for the one image that is the largest contentful paint. None of the three packages preloads, so it is safe inside a `Picture`; a real preload is a `` you add yourself, pointed at the format the browser will take. +- **`Image` alone**, without a `Picture`, is the right call when the rule generates one format: it still carries the widths. + +Per-framework differences: + +| | React | Preact | Svelte | +| --- | --- | --- | --- | +| Element ref | `ref` | `imgRef` | `bind:ref` | +| Prop casing | `className`, `fetchPriority` | `class`, `fetchPriority` in JSX, `fetchpriority` in the DOM | `class`, `fetchpriority` | +| `style` | object | object | string | + +### Without a framework + +```ts +import { getImageProps, getSourceProps } from '@srcset/runtime' +import { src, srcSet } from './photo.jpg' + +const sources = getSourceProps(srcSet) // [{ type, srcSet }] per format +const imageProps = getImageProps(src, srcSet) // { src, srcSet } for the +``` + +`getSourceProps` groups by mime type and orders avif, then webp, then the rest. `getImageProps` filters the set to the format of `src`, and omits `srcSet` when it would duplicate the url. Which comes out as: + +```html + + + + A photo + +``` + +### sizes and retina + +A `w` descriptor states how wide the file is, not which screen it is for. The browser takes the layout width from `sizes`, multiplies it by its own pixel ratio, and picks a variant that covers the result. 800px of layout takes the 1280px file on an ordinary screen, and the 1920px one on a retina display. + +So **one list of widths covers both**, and there is no `@2x` rule to write — the list just has to reach twice the largest layout width. Set `sizes` whenever the image is not full width: without it the browser assumes `100vw` and over-fetches. + +## Per-import overrides + +Build-time integrations only — a baked module is already generated, so its options were fixed by the cli run. Parts combine with `&`: + +- a **JSON rule** replaces the whole rule set for that import: `./photo.jpg?{"width":[1,0.5],"format":["webp","jpg"]}` +- `id=`, `format=`, `width=` pick the variant the default export points at: `./photo.jpg?format=webp&width=600` +- `placeholder` / `placeholder=false` switches the placeholder export on or off + +## Rules + +A rule is a matcher plus what to generate, and the same rule objects go into the plugin, the loader, the cli config and an import query: + +- the **first matched rule wins**; `fallthrough: true` keeps matching after it; +- a rule without `match` matches everything — it goes last, as the catch-all; +- `match` takes a glob, a CSS media query against the source size (`'(min-width: 1920px)'`), a function, or an array of them, in which case **all** must match; +- `width` ≤ 1 is a multiplier, above 1 is absolute pixels; pixels are never upscaled; +- the default export and `src` of a baked module point at the variant in the **source format at the source width**, and fall back to the first `format` of the list only when the source format is not in it; +- keep png as png and gif as gif in their own rules, or transparency and animation are lost; +- svg is never resized or converted: it passes through only when the rule's `format` is unset or includes `svg` — a raster-only `format` drops it silently. + +## The cli + +[`@srcset/cli`](https://github.com/TrigenSoftware/srcset/tree/main/packages/cli) resizes, converts and optimizes images with [sharp](https://sharp.pixelplumbing.com/) from the command line. With `--module` it also **bakes**: alongside the variants it writes the module above as a file, so a project can commit the result and never install a bundler integration. + +```sh pnpm add -D @srcset/cli pnpm srcset "src/images/*.jpg" --width 1920,1280,860,320 --format jpg,webp,avif -d static/images ``` Use the project's package manager throughout — `yarn add -D` and `yarn srcset`, `npm i -D` and `npm exec srcset`. -Documentation: - -## Command +### Command ``` srcset [...sources] [...options] @@ -53,7 +163,7 @@ srcset [...sources] [...options] | `sources` | Glob pattern(s) for the source images. Quote them so the shell does not expand them. | | `--dest`, `-d` | Destination directory. Required (or `dest` in the config). | | `--width`, `-w` | Widths to resize to. A value **≤ 1 is a multiplier** of the source width. | -| `--format`, `-f` | Formats to convert to. **The first one is the fallback.** | +| `--format`, `-f` | Formats to convert to. The first one is the fallback when the source format is not among them. | | `--match`, `-m` | Glob or media query to match images by name or size. Repeat to add more — **all** of them must match. | | `--module` | Bake a module: `ts`, `js`, `ts-dir` or `js-dir`. | | `--placeholder` | Add the `placeholder` export. `--no-placeholder` switches off one enabled in the config. | @@ -70,7 +180,7 @@ srcset [...sources] [...options] Output paths keep the source directory structure relative to the current directory: `images/photo.jpg` with `--dest dist` lands at `dist/images/photo.jpg`. Resized variants get a `@w` postfix — `dist/images/photo@1280w.webp`. Sources outside the current directory keep only their file name, and two of them colliding on one output path stops the run. -## Config File +### Config file `srcset.config.js` is an ES module with the options object as the default export. The project must be `"type": "module"`, or pass an `.mjs` file with `--config`. @@ -100,24 +210,12 @@ Only two things need the config file, because neither is expressible as an argum `placeholder`, `select` and `resourceId` shape the baked module and do nothing without `module`. -## Rules - -Same rules as the bundler integrations: - -- the **first matched rule wins**; `fallthrough: true` keeps matching after it; -- a rule without `match` matches everything — it goes last, as the catch-all; -- `match` takes a glob, a CSS media query against the source size (`'(min-width: 1920px)'`), a function, or an array of them, in which case **all** must match; -- `width` ≤ 1 is a multiplier, above 1 is absolute pixels; pixels are never upscaled; -- the **first `format` is the fallback** — the default export and `src` of a baked module; -- keep png as png and gif as gif in their own rules, or transparency and animation are lost; -- svg is never resized or converted: it passes through only when the rule's `format` is unset or includes `svg` — a raster-only `format` drops it silently. - -## Baking Modules +### Baking modules `--module` writes an ES module that imports the variants it just generated: ```bash -pnpm srcset "src/images/*.jpg" -d src/baked --module ts -w 1,0.5 -f jpg,webp +pnpm srcset "images/*.jpg" -d src/baked --module ts -w 1,0.5 -f jpg,webp ``` ```ts @@ -139,7 +237,7 @@ export const placeholder = undefined; The exports are identical to what the Vite plugin and the loader produce, so app code written against one works against the other. -### The four module formats +#### The four module formats | Format | Layout | |---|---| @@ -148,15 +246,17 @@ The exports are identical to what the Vite plugin and the loader produce, so app Flat mirrors the source tree one-to-one; `-dir` keeps one image's files together and lets the app import the folder: `import photo from './baked/images/photo'`. -### Using a baked module +#### Using a baked module ```ts -import photo, { src, srcSet } from './baked/images/photo' import { getImageProps } from '@srcset/runtime' +import photo, { src, srcSet } from './baked/images/photo' const { src: imgSrc, srcSet: imgSrcSet } = getImageProps(src, srcSet) ``` +The components take these exports exactly as in [Rendering](#rendering) — only the import path differs, because the module is a file in the project rather than the image itself. + What the project must provide, because the cli deliberately does not touch it: - **A way to import the image files.** Vite handles asset imports natively. Webpack and Rspack need an `asset/resource` rule for those extensions — there is no built-in one for `.jpg`. @@ -166,7 +266,7 @@ What the project must provide, because the cli deliberately does not touch it: File names stay exactly as configured — no hashes are added. A project that wants hashed names should let its bundler add them, or set a `postfix`. -## Recipes +### Recipes Convert a folder of photos to modern formats, keeping the original as the fallback (png and gif belong in config rules of their own — a flat `-f` list would convert them to jpg): @@ -189,13 +289,13 @@ pnpm srcset "images/*" -d thumbs -w 0.5 Bake a folder of photos into a TypeScript project (a mixed folder with png or gif needs config rules, like the config example above): ```bash -pnpm srcset "src/images/**/*.jpg" -d src/baked --module ts-dir -w 1,0.5 -f jpg,webp -v +pnpm srcset "images/**/*.jpg" -d src/baked --module ts-dir -w 1,0.5 -f jpg,webp -v ``` Bake with a blur-up placeholder, and point the default export at the webp variant — no config file involved: ```bash -pnpm srcset "src/images/**/*.jpg" -d src/baked --module ts -w 1,0.5 -f jpg,webp \ +pnpm srcset "images/**/*.jpg" -d src/baked --module ts -w 1,0.5 -f jpg,webp \ --placeholder --placeholder-width 24 --select-format webp ``` @@ -209,13 +309,18 @@ Repeatable setup — put it in the config and add a script: } ``` -## Verify +### Verify a run Run with `-v` and read the `source -> output` lines: one per variant plus, when baking, one per module. Then check the destination tree and, for a baked module, that the app's bundler resolves the imports — build the project, do not just eyeball the file. ## Pitfalls +- **`srcSet` given only to `Picture`.** The fallback `` is then a single fixed url, and the browser that falls through to it downloads the full sized file. Pass it to both, and pass `sizes` to both too. +- **A missing `sizes`.** The browser assumes `100vw` and picks the widest variant for a thumbnail. +- **A `placeholder` that does not reset.** It is shown once per mount, so a new image source needs a remount — `key`, or a `{#key}` block in Svelte. +- **`priority` on more than one image.** Marking everything as priority is the same as marking nothing. - Repeated `-m` values are an **and**, not an or: `-m '**/*.jpg' -m '**/hero*'` matches only the jpg files whose name starts with `hero`. For "either" use one brace glob, `-m '**/*.{jpg,png}'`, or one media query list. +- **A `match` glob is tested against the absolute path.** `'src/images/*.jpg'` matches nothing; anchor it with `**/`, as `'**/src/images/*.jpg'`. - Quote the source globs. Unquoted, the shell expands them itself: `**` silently loses its recursive meaning in shells without `globstar`, and zsh errors out when nothing matches. - `--width 0.5` is a **multiplier**, `--width 500` is pixels. `-w 1` means "the original width", which is how the untouched-size variant is requested. - Without `-w`, only the source width is generated; without `-f`, only the source format. With neither the run just re-encodes the originals — a valid optimize-only pass, but no `srcset`.