From f02996470b4e91da6ae52037cae16fc1eeee3f72 Mon Sep 17 00:00:00 2001 From: dangreen Date: Wed, 2 Sep 2026 16:43:53 +0400 Subject: [PATCH] docs: turn the `srcset-cli` skill into a skill for writing code The two skills split by moment rather than by package: `setup-srcset` wires an integration into a project once, and `srcset` is what an agent reads afterwards, when something has to display an image. So the cli skill is renamed and gains the image module contract, rendering with the components and the runtime helpers, the `sizes` and retina reasoning, and the import query, with the command line kept as the section that generates and bakes the variants. Both skills also lose a few claims that did not survive a check against the sources. --- skills/setup-srcset/SKILL.md | 24 ++-- skills/{srcset-cli => srcset}/SKILL.md | 173 ++++++++++++++++++++----- 2 files changed, 152 insertions(+), 45 deletions(-) rename skills/{srcset-cli => srcset}/SKILL.md (54%) 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`.