diff --git a/skills/setup-srcset/SKILL.md b/skills/setup-srcset/SKILL.md new file mode 100644 index 0000000..950586a --- /dev/null +++ b/skills/setup-srcset/SKILL.md @@ -0,0 +1,227 @@ +--- +name: setup-srcset +description: Add srcset responsive image generation to a project — pick the integration (Vite plugin, Webpack/Rspack loader, CLI baking, or a runtime proxy adapter), write the generation rules, wire the TypeScript types, and render the variants with the runtime helpers or the React/Preact/Svelte components. Apply when adding responsive images to a project or configuring a bundler for them. +license: MIT +compatibility: + - Claude Code + - Codex + - Cursor + - Gemini CLI + - GitHub Copilot + - Windsurf + - Cline + - Roo Code + - Goose + - Continue + - OpenCode + - Amp + - universal +metadata: + author: dangreen + tags: + - srcset + - responsive-images + - vite + - webpack + - rspack + - sharp + - image-optimization +--- + +# Setup srcset + +Set up [srcset](https://github.com/TrigenSoftware/srcset) in a project: an image import turns into a **tree-shakable module** carrying every generated variant, so the app renders a real `srcset` instead of one fixed file. Variants are encoded with [sharp](https://sharp.pixelplumbing.com/) at build time. + +```ts +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` | +| `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` | +| `placeholder` | Blur-up data-url, when the `placeholder` option is on | + +Documentation: + +## Pick the Integration + +Ask what the project builds with, or detect it — `vite.config.*`, `webpack.config.*`, `rspack.config.*`, `package.json` scripts. Then: + +| Situation | Package | +|---|---| +| 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 | +| 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. + +A project can use several — a bundler integration for the images it ships and a proxy adapter for content images. + +## Install + +Always add `@srcset/runtime` alongside a build-time integration: it carries the `SrcSetEntry` type and the helpers that turn variants into DOM attributes. + +```bash +pnpm add -D @srcset/vite-plugin @srcset/runtime # vite +pnpm add -D @srcset/loader @srcset/runtime # webpack / rspack +``` + +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`. + +## Configure Vite + +```js +// vite.config.js +import { defineConfig } from 'vite' +import { srcset } from '@srcset/vite-plugin' + +export default defineConfig({ + plugins: [ + srcset({ + rules: [ + { + match: '**/*.png', + width: [1, 0.5], + format: ['png', 'webp'] + }, + { + width: [1, 0.5], + format: ['jpg', 'webp', 'avif'] + } + ], + placeholder: true + }) + ] +}) +``` + +Every raster image import — jpg, jpeg, png, webp, avif, gif — is processed by default; native Vite queries such as `?url` and `?raw` stay in the asset pipeline untouched. Narrow the scope with `include` / `exclude` (picomatch patterns or regexps) — `exclude` defaults to `node_modules`. + +`cache` is on by default and stores variants in Vite's cache directory, so repeated builds skip the encoding. It takes `{ dir, maxAge }` to move or age the storage; the dev server always uses it. + +## Configure Webpack and Rspack + +```js +// webpack.config.js / rspack.config.js +export default { + module: { + rules: [ + { + test: /\.(jpe?g|png|gif)$/i, + use: { + loader: '@srcset/loader', + options: { + rules: [ + { + match: '**/*.png', + width: [1, 0.5], + format: ['png', 'webp'] + }, + { + width: [1, 0.5], + format: ['jpg', 'webp', 'avif'] + } + ], + placeholder: true + } + } + } + ] + } +} +``` + +The loader replaces whatever asset rule the project had for those extensions — remove a competing `type: 'asset/resource'` rule for the same test, or the two will fight over the same files. + +Loader-specific options: + +- `name` — output file name template, defaults to `[name][postfix].[contenthash:8].[ext]` (`[path][name][postfix][sourceext].[ext]` in development mode). Tokens: `[name]`, `[postfix]`, `[ext]`, `[path]`, `[sourceext]`, `[hash]`/`[contenthash]` (with optional `:length`). `[sourceext]` is the source extension, empty when the output format matches it — it keeps `photo.jpg` and `photo.png` apart when both convert to webp. +- `outputPath`, `publicPath` — a string or a resolver function; `publicPath` defaults to the compiler's. +- `context` — base directory for `[path]`, defaults to the compiler root context. +- `emitFile: false` — for an SSR build, where the client build already wrote the files. +- `cache: true` — disk cache in `node_modules/.cache/srcset`. Off by default, because the bundler's own persistent cache (`cache: { type: 'filesystem' }` in webpack, `cache: { type: 'persistent' }` in Rspack) already covers it. Turn it on when the bundler cache is off, or point it elsewhere with `{ dir, maxAge }`. + +## Write the Rules + +A rule is a match plus what to generate. This is where most of the setup goes, and where the surprises live: + +- **The first matched rule wins.** Rules are tried in order and matching stops at the first hit. Set `fallthrough: true` to keep matching after it. +- **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']`. +- **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. + +Other generation options, usable per rule or globally: `processing` (sharp encoder options per format), `optimization` (custom optimizer functions, the only way to touch svg), `skipOptimization`, `postfix`, `concurrency`. + +## Wire the TypeScript Types + +Both integrations ship ambient declarations for image imports. Reference them once, in a `.d.ts` of the project or through tsconfig `types`: + +```ts +/// +``` + +```ts +/// +``` + +The Vite one declares only the named exports and pulls the default url export from `vite/client`, so keep the `vite/client` reference the project already has. + +## Render the Variants + +Do not build `srcset` strings by hand — `@srcset/runtime` orders formats by efficiency and groups them by mime type: + +```ts +import url, { src, srcSet } from './photo.jpg' +import { getImageProps, getSourceProps } from '@srcset/runtime' + +const { src: imgSrc, srcSet: imgSrcSet } = getImageProps(src, srcSet) +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' + + + Hero photo + +``` + +`@srcset/preact` and `@srcset/svelte` expose the same two components. + +## Override Per Import + +The import query overrides the configured options for one import. 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 which variant the default export points at: `./photo.jpg?format=webp&width=600` +- `placeholder` / `placeholder=false` switches the placeholder export on or off without losing the configured placeholder options + +## Placeholders + +`placeholder: true` adds a tiny variant inlined as a data-url — 16px wide webp by default. `{ width, format }` changes it; `format` is `'webp'` or `'jpg'`. The export is dropped from the bundle when unused, so enabling it costs nothing until it is imported. + +## Verify + +1. Build the project and check that the emitted assets include the extra formats and widths, not just the originals. +2. Import an image in the app and log `srcSet` — the array length should match the rule (widths × formats). +3. Check the rendered markup: an `` with a `srcset` attribute, or a `` with one `` per format. +4. In TypeScript, confirm the named imports type-check — a missing `/// ` shows up as "has no exported member 'srcSet'". + +## Pitfalls + +- 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. +- 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-cli/SKILL.md new file mode 100644 index 0000000..7875729 --- /dev/null +++ b/skills/srcset-cli/SKILL.md @@ -0,0 +1,224 @@ +--- +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. +license: MIT +compatibility: + - Claude Code + - Codex + - Cursor + - Gemini CLI + - GitHub Copilot + - Windsurf + - Cline + - Roo Code + - Goose + - Continue + - OpenCode + - Amp + - universal +metadata: + author: dangreen + tags: + - srcset + - responsive-images + - cli + - sharp + - image-optimization + - codegen +--- + +# srcset 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 an ES module importing them, so a project can commit the result and never install a bundler integration. + +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. + +```bash +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 + +``` +srcset [...sources] [...options] +``` + +| Option | Meaning | +|---|---| +| `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.** | +| `--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. | +| `--placeholder-width`, `--placeholder-format` | 16 and `webp` by default; `webp` or `jpg`. Either one implies `--placeholder`. | +| `--select-id`, `--select-format`, `--select-width` | Which variant the module's default export points at. | +| `--skip-optimization` | Do not re-encode the original variant and skip custom optimizers. | +| `--no-scaling-up` | Do not emit variants wider than the source. | +| `--concurrency` | Concurrency limit. | +| `--config`, `-c` | Config file path. Defaults to looking up `srcset.config.js`. | +| `--verbose`, `-v` | Print every written file as `source -> output`. | +| `--help`, `-h` | Print the usage. | + +`-w` and `-f` take several values either comma-separated (`-w 1920,1280`) or as repeated flags (`-f jpg -f webp`). `-m` takes its argument as it is — commas inside a brace glob or a media query list are the value's own — so several patterns are passed as repeated `-m`. + +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 + +`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`. + +```js +export default { + src: 'src/images/**/*.jpg', + dest: 'static/images', + module: 'ts', + placeholder: true, + rules: [ + { + match: '**/*.png', + width: [1, 0.5], + format: ['png', 'webp'] + }, + { + width: [1, 0.5], + format: ['jpg', 'webp', 'avif'] + } + ] +} +``` + +A command line argument wins over the config value. **Note that `-m`, `-w` and `-f` build one single rule that replaces the whole `rules` list of the config** — they do not merge into it, and the same goes for the placeholder and select options. + +Only two things need the config file, because neither is expressible as an argument: **more than one rule**, and the options that are functions or nested objects — `resourceId`, `optimization`, `processing`, and `postfix` as a formatter. Everything else has a flag. + +`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 + +`--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 +``` + +```ts +// src/baked/images/photo.ts +import photo_jpg from "./photo.jpg" +import photo_780w_jpg from "./photo@780w.jpg" +import photo_webp from "./photo.webp" +import photo_780w_webp from "./photo@780w.webp" + +const url = photo_jpg; +const src = { id: "jpg1560", format: "jpg" as const, /* ... */ url: url }; + +export default url; +export { src }; +export const srcSet = [src, /* ... */]; +export const srcMap = { "jpg1560": url, /* ... */ }; +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 + +| Format | Layout | +|---|---| +| `ts` / `js` | Module flat, next to the variants, named after the source: `dist/images/photo.ts` | +| `ts-dir` / `js-dir` | A folder named after the source holding the variants and an `index.ts` / `index.js`: `dist/images/photo/index.ts` | + +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 + +```ts +import photo, { src, srcSet } from './baked/images/photo' +import { getImageProps } from '@srcset/runtime' + +const { src: imgSrc, srcSet: imgSrcSet } = getImageProps(src, srcSet) +``` + +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`. +- **`sideEffects: false`** in the project's `package.json`, if unused variants should be dropped. Without it a bundler keeps every import. Note that Rollup, and so Vite, emits asset files regardless of tree-shaking; webpack can drop them. +- **A `.d.ts`, if a `js` module is used in a TypeScript project.** The cli generates no declarations. In a TypeScript project use `ts` or `ts-dir` — a typescript module narrows the formats with `as const`, so its entries are assignable to `SrcSetEntry` without importing a type. +- **Ambient types for the image imports inside a `ts` module.** The module itself imports `./photo.jpg`, so a `declare module '*.jpg'` with a default `string` export must exist per baked extension — `vite/client` already provides them; with webpack, write them in a `.d.ts`. + +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 + +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): + +```bash +pnpm srcset "assets/**/*.jpg" -d dist -f jpg,webp,avif +``` + +Make one image responsive at several widths: + +```bash +pnpm srcset "src/hero.jpg" -d public -w 1920,1280,640 -f jpg,webp +``` + +Half-size copies of everything, originals untouched: + +```bash +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 +``` + +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 \ + --placeholder --placeholder-width 24 --select-format webp +``` + +Repeatable setup — put it in the config and add a script: + +```json +{ + "scripts": { + "images": "srcset" + } +} +``` + +## Verify + +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 + +- 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. +- 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`. +- An image that no rule matched is skipped silently — nothing is written for it, and in `--module` mode no module is written either. +- A source that sharp cannot decode fails the whole run with `Cannot read image ""`. That is deliberate: a corrupt file the user pointed at should not be passed over. +- Re-running does not clean the destination. Removing a rule leaves the files it used to write in place.