From 75e4f3ebcc7e3b9cec287d33f15756a2ffae19c3 Mon Sep 17 00:00:00 2001 From: dangreen Date: Tue, 21 Jul 2026 23:45:39 +0400 Subject: [PATCH 1/7] feat(vite-plugin): add Vite plugin --- packages/vite-plugin/README.md | 88 +++++++++ packages/vite-plugin/client.d.ts | 79 +++++++++ packages/vite-plugin/oxlint.config.ts | 12 ++ packages/vite-plugin/package.json | 88 +++++++++ packages/vite-plugin/src/dev.ts | 65 +++++++ packages/vite-plugin/src/index.ts | 9 + packages/vite-plugin/src/plugin.spec.ts | 217 +++++++++++++++++++++++ packages/vite-plugin/src/plugin.ts | 114 ++++++++++++ packages/vite-plugin/src/query.spec.ts | 91 ++++++++++ packages/vite-plugin/src/query.ts | 66 +++++++ packages/vite-plugin/src/types.ts | 12 ++ packages/vite-plugin/test/vite.mock.ts | 51 ++++++ packages/vite-plugin/tsconfig.build.json | 17 ++ packages/vite-plugin/tsconfig.json | 14 ++ packages/vite-plugin/vite.config.js | 11 ++ pnpm-lock.yaml | 26 +++ 16 files changed, 960 insertions(+) create mode 100644 packages/vite-plugin/README.md create mode 100644 packages/vite-plugin/client.d.ts create mode 100644 packages/vite-plugin/oxlint.config.ts create mode 100644 packages/vite-plugin/package.json create mode 100644 packages/vite-plugin/src/dev.ts create mode 100644 packages/vite-plugin/src/index.ts create mode 100644 packages/vite-plugin/src/plugin.spec.ts create mode 100644 packages/vite-plugin/src/plugin.ts create mode 100644 packages/vite-plugin/src/query.spec.ts create mode 100644 packages/vite-plugin/src/query.ts create mode 100644 packages/vite-plugin/src/types.ts create mode 100644 packages/vite-plugin/test/vite.mock.ts create mode 100644 packages/vite-plugin/tsconfig.build.json create mode 100644 packages/vite-plugin/tsconfig.json create mode 100644 packages/vite-plugin/vite.config.js diff --git a/packages/vite-plugin/README.md b/packages/vite-plugin/README.md new file mode 100644 index 0000000..75dbb8a --- /dev/null +++ b/packages/vite-plugin/README.md @@ -0,0 +1,88 @@ +# @srcset/vite-plugin + +[![ESM-only package][package]][package-url] +[![NPM version][npm]][npm-url] +[![Node version][node]][node-url] +[![Dependencies status][deps]][deps-url] +[![Install size][size]][size-url] +[![Build status][build]][build-url] +[![Coverage status][coverage]][coverage-url] + +[package]: https://img.shields.io/badge/package-ESM--only-ffe536.svg +[package-url]: https://nodejs.org/api/esm.html + +[npm]: https://img.shields.io/npm/v/%40srcset%2Fvite-plugin.svg +[npm-url]: https://npmjs.com/package/@srcset/vite-plugin + +[node]: https://img.shields.io/node/v/%40srcset%2Fvite-plugin.svg +[node-url]: https://nodejs.org + +[deps]: https://img.shields.io/librariesio/release/npm/%40srcset%2Fvite-plugin +[deps-url]: https://libraries.io/npm/%40srcset%2Fvite-plugin + +[size]: https://packagephobia.com/badge?p=%40srcset%2Fvite-plugin +[size-url]: https://packagephobia.com/result?p=%40srcset%2Fvite-plugin + +[build]: https://img.shields.io/github/actions/workflow/status/TrigenSoftware/srcset/tests.yml?branch=main +[build-url]: https://github.com/TrigenSoftware/srcset/actions + +[coverage]: https://img.shields.io/codecov/c/github/TrigenSoftware/srcset.svg +[coverage-url]: https://app.codecov.io/gh/TrigenSoftware/srcset + +Vite plugin for generating responsive images. + +- 🧩 Image imports are processed by default; native Vite queries like `?url` stay in the asset pipeline +- 🌳 Tree-shakable image modules: unused exports are dropped from the bundle +- 🌫 Blur-up placeholders inlined as data-urls +- 🌐 Pluggable backends: encode with [sharp](https://sharp.pixelplumbing.com/) or build [imgproxy](https://npmjs.com/package/@srcset/imgproxy) / [Cloudflare](https://npmjs.com/package/@srcset/cloudflare) urls + +## Install + +```bash +# pnpm +pnpm add -D @srcset/vite-plugin @srcset/runtime +# yarn +yarn add -D @srcset/vite-plugin @srcset/runtime +# npm +npm i -D @srcset/vite-plugin @srcset/runtime +``` + +## Usage + +```js +// vite.config.js +import { defineConfig } from 'vite' +import { srcset } from '@srcset/vite-plugin' + +export default defineConfig({ + plugins: [ + srcset({ + rules: [ + { + width: [1, 0.5], + format: ['avif', 'webp', 'jpg'] + } + ], + placeholder: true + }) + ] +}) +``` + +```ts +import url, { src, srcSet, srcMap, placeholder } from './photo.jpg' + +// url - url of the selected variant, e.g. '/assets/photo.f37e2d3a.jpg' +// src - selected variant: { id: 'jpg1200', format: 'jpg', type: 'image/jpeg', width: 1200, height: 800, url } +// srcSet - array of all generated variants +// srcMap - id-to-url map, e.g. srcMap.webp600 +// placeholder - blur-up data-url, when the `placeholder` option is enabled + +const img = `` +``` + +The module is tree-shakable: import only what you use - the rest is dropped from the bundle. + +## Documentation + +For more details, guides and API references, check out the [documentation website](https://srcset.js.org/integrations/vite-plugin/). diff --git a/packages/vite-plugin/client.d.ts b/packages/vite-plugin/client.d.ts new file mode 100644 index 0000000..4733106 --- /dev/null +++ b/packages/vite-plugin/client.d.ts @@ -0,0 +1,79 @@ +/* oxlint-disable trigen/import-order */ +/** + * Ambient module declarations for image imports handled by @srcset/vite-plugin. + * + * Usage: add to tsconfig `types` or reference directly: + * /// + */ + +declare module '*.jpg' { + import type { SrcSetEntry } from '@srcset/runtime' + + const url: string + + export const src: SrcSetEntry + export const srcSet: SrcSetEntry[] + export const srcMap: Record + export const placeholder: string | undefined + export default url +} + +declare module '*.jpeg' { + import type { SrcSetEntry } from '@srcset/runtime' + + const url: string + + export const src: SrcSetEntry + export const srcSet: SrcSetEntry[] + export const srcMap: Record + export const placeholder: string | undefined + export default url +} + +declare module '*.png' { + import type { SrcSetEntry } from '@srcset/runtime' + + const url: string + + export const src: SrcSetEntry + export const srcSet: SrcSetEntry[] + export const srcMap: Record + export const placeholder: string | undefined + export default url +} + +declare module '*.webp' { + import type { SrcSetEntry } from '@srcset/runtime' + + const url: string + + export const src: SrcSetEntry + export const srcSet: SrcSetEntry[] + export const srcMap: Record + export const placeholder: string | undefined + export default url +} + +declare module '*.avif' { + import type { SrcSetEntry } from '@srcset/runtime' + + const url: string + + export const src: SrcSetEntry + export const srcSet: SrcSetEntry[] + export const srcMap: Record + export const placeholder: string | undefined + export default url +} + +declare module '*.gif' { + import type { SrcSetEntry } from '@srcset/runtime' + + const url: string + + export const src: SrcSetEntry + export const srcSet: SrcSetEntry[] + export const srcMap: Record + export const placeholder: string | undefined + export default url +} diff --git a/packages/vite-plugin/oxlint.config.ts b/packages/vite-plugin/oxlint.config.ts new file mode 100644 index 0000000..4eba28c --- /dev/null +++ b/packages/vite-plugin/oxlint.config.ts @@ -0,0 +1,12 @@ +import { defineConfig } from '@trigen/oxlint' +import testConfig from '@trigen/oxlint-config/test' +import tsTypeCheckedConfig from '@trigen/oxlint-config/typescript-type-checked' +import rootConfig from '../../oxlint.config.ts' + +export default defineConfig({ + extends: [ + rootConfig, + tsTypeCheckedConfig, + testConfig + ] +}) diff --git a/packages/vite-plugin/package.json b/packages/vite-plugin/package.json new file mode 100644 index 0000000..b567fb8 --- /dev/null +++ b/packages/vite-plugin/package.json @@ -0,0 +1,88 @@ +{ + "name": "@srcset/vite-plugin", + "type": "module", + "version": "1.0.0", + "description": "Vite plugin for generating responsive images.", + "author": { + "name": "Dan Onoshko", + "email": "danon0404@gmail.com", + "url": "https://github.com/dangreen" + }, + "license": "MIT", + "homepage": "https://srcset.js.org/integrations/vite-plugin/", + "funding": "https://ko-fi.com/dangreen", + "repository": { + "type": "git", + "url": "https://github.com/TrigenSoftware/srcset.git", + "directory": "packages/vite-plugin" + }, + "bugs": { + "url": "https://github.com/TrigenSoftware/srcset/issues" + }, + "keywords": [ + "srcset", + "image", + "picture", + "responsive", + "vite", + "vite-plugin" + ], + "engines": { + "node": ">=22" + }, + "sideEffects": false, + "exports": { + "./package.json": "./package.json", + ".": "./src/index.ts", + "./client": { + "types": "./client.d.ts" + } + }, + "publishConfig": { + "exports": { + "./package.json": "./package.json", + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "./client": { + "types": "./client.d.ts" + } + }, + "directory": "package", + "linkDirectory": false + }, + "files": [ + "dist", + "client.d.ts" + ], + "scripts": { + "clear:package": "del ./package", + "clear:dist": "del ./dist", + "clear": "del ./package ./dist ./coverage", + "prepublishOnly": "run build clear:package clean-publish", + "postpublish": "pnpm clear:package", + "build": "tsc -p ./tsconfig.build.json", + "lint": "oxlint", + "format": "oxlint --fix", + "test:unit": "vitest run --coverage", + "test:unit:watch": "vitest watch", + "test:types": "tsc --noEmit", + "test": "run -p lint test:unit test:types" + }, + "peerDependencies": { + "@srcset/runtime": "workspace:^", + "vite": ">=5" + }, + "dependencies": { + "@srcset/bundler-utils": "workspace:^", + "@srcset/core": "workspace:^", + "p-limit": "^7.3.0" + }, + "devDependencies": { + "@srcset/runtime": "workspace:^", + "@types/node": "^22.0.0", + "sharp": "^0.35.3", + "vite": "^8.1.0" + } +} diff --git a/packages/vite-plugin/src/dev.ts b/packages/vite-plugin/src/dev.ts new file mode 100644 index 0000000..e29b0b1 --- /dev/null +++ b/packages/vite-plugin/src/dev.ts @@ -0,0 +1,65 @@ +import type { + IncomingMessage, + ServerResponse +} from 'node:http' +import { createHash } from 'node:crypto' +import { + type SrcSetImage, + mimeTypes +} from '@srcset/core' + +export const devPathPrefix = '/@srcset/' + +const hashLength = 8 + +interface DevImage { + contents: Buffer + type: string +} + +/** + * In-memory cache of the generated variants for the dev server middleware. + */ +export type DevCache = Map + +/** + * Add an image variant to the dev cache. + * @param cache - Dev cache. + * @param image - Image variant. + * @returns Dev server pathname of the variant. + */ +export function addDevImage(cache: DevCache, image: SrcSetImage) { + const hash = createHash('sha256').update(image.contents).digest('hex').slice(0, hashLength) + const extensionIndex = image.path.lastIndexOf('.') + const stem = image.path.slice(image.path.lastIndexOf('/') + 1, extensionIndex) + const pathname = `${devPathPrefix}${stem}.${hash}.${image.format}` + + cache.set(pathname, { + contents: image.contents, + type: mimeTypes[image.format] + }) + + return pathname +} + +/** + * Create a dev server middleware serving the generated variants from the cache. + * @param cache - Dev cache. + * @returns Connect-style middleware. + */ +export function createDevMiddleware(cache: DevCache) { + return (request: IncomingMessage, response: ServerResponse, next: () => void) => { + const url = request.url ?? '' + const index = url.indexOf(devPathPrefix) + const image = index < 0 ? undefined : cache.get(url.slice(index)) + + if (!image) { + next() + return + } + + response.setHeader('Content-Type', image.type) + response.setHeader('Cache-Control', 'no-cache') + response.end(image.contents) + } +} diff --git a/packages/vite-plugin/src/index.ts b/packages/vite-plugin/src/index.ts new file mode 100644 index 0000000..9ac5a7f --- /dev/null +++ b/packages/vite-plugin/src/index.ts @@ -0,0 +1,9 @@ +export { srcset } from './plugin.ts' +export type * from './types.ts' +export type { + SrcSetRule, + SrcSetEntrySelect, + SrcSetModuleEntry, + ResourceIdFormatter, + PlaceholderOptions +} from '@srcset/bundler-utils' diff --git a/packages/vite-plugin/src/plugin.spec.ts b/packages/vite-plugin/src/plugin.spec.ts new file mode 100644 index 0000000..a0bd833 --- /dev/null +++ b/packages/vite-plugin/src/plugin.spec.ts @@ -0,0 +1,217 @@ +import { + describe, + it, + expect +} from 'vitest' +import path from 'node:path' +import sharp from 'sharp' +import { + type Rolldown, + build, + createServer +} from 'vite' +import { srcset } from './plugin.ts' +import { + type ModuleExports, + createFixtureProject, + imageWidth, + imageHeight +} from '../test/vite.mock.ts' + +const defaultEntry = `import url, { src, srcSet, srcMap, placeholder } from './image.jpg' +export { url as default, src, srcSet, srcMap, placeholder } +` +const ruleEntry = `import url, { src, srcSet, srcMap } from './image.jpg?{ "width": [0.5, 1], "format": ["webp", "jpg"] }' +export { url as default, src, srcSet, srcMap } +` + +async function buildFixture(dir: string, pluginOptions = {}) { + const result = await build({ + configFile: false, + logLevel: 'error', + root: dir, + base: '/assets/', + plugins: [srcset({ + skipOptimization: true, + ...pluginOptions + })], + build: { + write: false, + minify: false, + rolldownOptions: { + input: path.join(dir, 'entry.js'), + preserveEntrySignatures: 'strict', + output: { + entryFileNames: 'entry.js' + } + } + } + }) as Rolldown.RolldownOutput + const chunk = result.output.find( + (item): item is Rolldown.OutputChunk => item.type === 'chunk' && item.isEntry + ) + const assets = result.output.filter( + (item): item is Rolldown.OutputAsset => item.type === 'asset' + ) + const exports = await import( + /* @vite-ignore */ + `data:text/javascript;base64,${Buffer.from(chunk?.code ?? '').toString('base64')}` + ) as ModuleExports + + return { + assets, + exports + } +} + +describe('vite-plugin', () => { + describe('srcset', () => { + describe('build', () => { + it('should build module with default options', async () => { + const dir = await createFixtureProject(defaultEntry) + const { + assets, + exports + } = await buildFixture(dir) + + expect(exports.default).toMatch(/^\/assets\/.*image.*\.jpg$/) + expect(exports.src).toEqual({ + id: `jpg${imageWidth}`, + format: 'jpg', + type: 'image/jpeg', + width: imageWidth, + height: imageHeight, + url: exports.default + }) + expect(exports.srcSet).toEqual([exports.src]) + expect(exports.srcMap).toEqual({ + [`jpg${imageWidth}`]: exports.default + }) + expect(exports.placeholder).toBeUndefined() + expect(assets.some(asset => exports.default.endsWith(asset.fileName))).toBe(true) + }) + + it('should apply rule from import query', async () => { + const dir = await createFixtureProject(ruleEntry) + const { + assets, + exports + } = await buildFixture(dir) + const halfWidth = imageWidth / 2 + + expect(exports.srcSet.length).toBe(4) + expect(Object.keys(exports.srcMap).sort()).toEqual([ + `jpg${halfWidth}`, + `jpg${imageWidth}`, + `webp${halfWidth}`, + `webp${imageWidth}` + ]) + expect(exports.default).toBe(exports.srcMap[`jpg${imageWidth}`]) + expect(assets.length).toBe(4) + }) + + it('should emit the original for a backend enforcing public path', async () => { + const dir = await createFixtureProject(defaultEntry) + const urlBackend = ( + _options: unknown, + emitImage: (image: unknown) => { + outputPath: string + publicPath: string | null + } + ) => ({ + * generate(source: { + path: string + contents: Buffer + }, metadata: { + format: string + width: number + height: number + }) { + const { publicPath: publicUrl } = emitImage({ + path: source.path, + contents: source.contents, + format: metadata.format, + width: metadata.width, + height: metadata.height, + postfix: '', + originMultiplier: 1 + }) + + yield { + format: metadata.format, + width: metadata.width, + height: metadata.height, + originMultiplier: 1, + url: `https://proxy.test/plain${publicUrl ?? ''}` + } + } + }) + const { + assets, + exports + } = await buildFixture(dir, { + backend: urlBackend + }) + const [original] = assets + + expect(assets.length).toBe(1) + // The `__VITE_ASSET__` placeholder is replaced inside the built url string. + expect(exports.default).toBe(`https://proxy.test/plain/assets/${original.fileName}`) + }) + + it('should export placeholder data-url when enabled', async () => { + const dir = await createFixtureProject(defaultEntry) + const { exports } = await buildFixture(dir, { + placeholder: true + }) + + expect(exports.placeholder).toMatch(/^data:image\/webp;base64,/) + + const decoded = Buffer.from((exports.placeholder as string).split(',')[1], 'base64') + const metadata = await sharp(decoded).metadata() + + expect(metadata.width).toBe(16) + }) + }) + + describe('dev', () => { + it('should serve module and variants from dev server', async () => { + const dir = await createFixtureProject(ruleEntry) + const server = await createServer({ + configFile: false, + logLevel: 'error', + root: dir, + plugins: [srcset({ + skipOptimization: true + })] + }) + + try { + await server.listen() + + const address = server.httpServer?.address() + const port = typeof address === 'object' && address ? address.port : 0 + const exports = await server.ssrLoadModule('/entry.js') as ModuleExports + const halfWidth = imageWidth / 2 + + expect(exports.srcSet.length).toBe(4) + expect(exports.default).toMatch(/^\/@srcset\/image\.[0-9a-f]{8}\.jpg$/) + + const webpUrl = exports.srcMap[`webp${halfWidth}`] + const response = await fetch(`http://localhost:${port}${webpUrl}`) + + expect(response.status).toBe(200) + expect(response.headers.get('content-type')).toBe('image/webp') + + const contents = Buffer.from(await response.arrayBuffer()) + const metadata = await sharp(contents).metadata() + + expect(metadata.format).toBe('webp') + expect(metadata.width).toBe(halfWidth) + } finally { + await server.close() + } + }) + }) + }) +}) diff --git a/packages/vite-plugin/src/plugin.ts b/packages/vite-plugin/src/plugin.ts new file mode 100644 index 0000000..5b8df43 --- /dev/null +++ b/packages/vite-plugin/src/plugin.ts @@ -0,0 +1,114 @@ +import { availableParallelism } from 'node:os' +import { readFile } from 'node:fs/promises' +import type { SrcSetImage } from '@srcset/core' +import pLimit from 'p-limit' +import { + type Plugin, + createFilter +} from 'vite' +import { + parseResourceQuery, + generateSrcSetModule +} from '@srcset/bundler-utils' +import type { SrcSetVitePluginOptions } from './types.ts' +import { + splitId, + getResourceQuery, + createLoadFilter +} from './query.ts' +import { + type DevCache, + addDevImage, + createDevMiddleware +} from './dev.ts' + +interface EmitContext { + emitFile(file: { + type: 'asset' + name: string + source: Buffer + }): string +} + +/** + * Vite plugin for generating responsive images. + * Handles image imports by default: `import url, { src, srcSet, srcMap } from './image.jpg'`. + * Imports with a foreign query, like `?url` or `?raw`, stay in the Vite asset pipeline. + * @param options - Plugin options. + * @returns Vite plugin. + */ +export function srcset(options: SrcSetVitePluginOptions = {}): Plugin { + const { + concurrency = availableParallelism(), + include, + exclude + } = options + const limit = pLimit(concurrency) + const loadFilter = createLoadFilter(include, exclude) + // Fallback for environments without hook filters, built from the same filter. + const matchesLoadFilter = createFilter(loadFilter.id.include, loadFilter.id.exclude) + const devCache: DevCache = new Map() + let base = '/' + let isBuild = false + const generateModule = async (context: EmitContext, id: string) => { + const { path } = splitId(id) + const source = { + path, + contents: await readFile(path) + } + const query = parseResourceQuery(getResourceQuery(id)) + const emitImage = (image: SrcSetImage) => { + if (isBuild) { + const name = image.path.slice(image.path.lastIndexOf('/') + 1) + const referenceId = context.emitFile({ + type: 'asset', + name, + source: image.contents + }) + + return { + outputPath: name, + // Vite replaces the placeholder with the final asset url, respecting `base`. + publicPath: `__VITE_ASSET__${referenceId}__` + } + } + + const devPath = addDevImage(devCache, image).slice(1) + + return { + outputPath: devPath, + publicPath: base + devPath + } + } + + return generateSrcSetModule( + source, + query, + options, + emitImage, + limit + ) + } + + return { + name: 'srcset', + enforce: 'pre', + configResolved(config) { + base = config.base + isBuild = config.command === 'build' + }, + configureServer(server) { + server.middlewares.use(createDevMiddleware(devCache)) + }, + load: { + filter: loadFilter, + handler(id) { + if (!matchesLoadFilter(id)) { + return null + } + + return generateModule(this, id) + } + } + } +} diff --git a/packages/vite-plugin/src/query.spec.ts b/packages/vite-plugin/src/query.spec.ts new file mode 100644 index 0000000..abf7df3 --- /dev/null +++ b/packages/vite-plugin/src/query.spec.ts @@ -0,0 +1,91 @@ +import { + describe, + it, + expect +} from 'vitest' +import { createFilter } from 'vite' +import { + createLoadFilter, + splitId, + getResourceQuery +} from './query.ts' + +function createMatcher(include?: Parameters[0], exclude?: Parameters[1]) { + const filter = createLoadFilter(include, exclude) + + return createFilter(filter.id.include, filter.id.exclude) +} + +describe('vite-plugin', () => { + describe('query', () => { + describe('createLoadFilter', () => { + it('should match image ids with and without query by default', () => { + const matches = createMatcher() + + expect(matches('/images/photo.jpg')).toBe(true) + expect(matches('/images/photo.avif')).toBe(true) + expect(matches('/images/photo.jpg?width=320&format=webp')).toBe(true) + expect(matches('/images/photo.jpg?{ "width": [1, 0.5] }')).toBe(true) + }) + + it('should not match non-image ids', () => { + const matches = createMatcher() + + expect(matches('/scripts/main.ts')).toBe(false) + expect(matches('/images/icon.svg')).toBe(false) + }) + + it('should not match native Vite queries', () => { + const matches = createMatcher() + + expect(matches('/images/photo.jpg?url')).toBe(false) + expect(matches('/images/photo.jpg?raw')).toBe(false) + expect(matches('/images/photo.jpg?inline')).toBe(false) + expect(matches('/images/photo.jpg?width=320&url')).toBe(false) + }) + + it('should not match virtual module ids', () => { + const matches = createMatcher() + + expect(matches('\u0000virtual:module')).toBe(false) + }) + + it('should not match node_modules by default', () => { + const matches = createMatcher() + + expect(matches('/project/node_modules/pkg/photo.jpg')).toBe(false) + }) + + it('should respect user include and exclude patterns', () => { + const filter = createLoadFilter('src/hero/**', ['**/skip/**']) + + expect(filter.id.include).toEqual(['src/hero/**']) + expect(filter.id.exclude[2]).toBe('**/skip/**') + }) + }) + + describe('splitId', () => { + it('should split id into path and query', () => { + expect(splitId('/images/photo.jpg?srcset')).toEqual({ + path: '/images/photo.jpg', + query: 'srcset' + }) + }) + + it('should return empty query without question mark', () => { + expect(splitId('/images/photo.jpg')).toEqual({ + path: '/images/photo.jpg', + query: '' + }) + }) + }) + + describe('getResourceQuery', () => { + it('should decode percent-encoded query', () => { + expect(getResourceQuery(`/images/photo.jpg?${encodeURIComponent('{ "width": [320] }')}`)).toBe( + '?{ "width": [320] }' + ) + }) + }) + }) +}) diff --git a/packages/vite-plugin/src/query.ts b/packages/vite-plugin/src/query.ts new file mode 100644 index 0000000..0ed46fe --- /dev/null +++ b/packages/vite-plugin/src/query.ts @@ -0,0 +1,66 @@ +import type { FilterPattern } from 'vite' + +// Image extensions with the import query allowed: filters match the full module id. +const imageIdPattern = /\.(?:jpe?g|png|webp|avif|gif)(?:\?|$)/i +// oxlint-disable-next-line eslint/no-control-regex +const virtualIdPattern = /^\u0000/ +// Native Vite queries leave the import to the Vite asset pipeline. +const viteQueryPattern = /[?&](?:url|raw|inline|no-inline|worker|sharedworker|init)(?:=|&|$)/ +const nodeModulesPattern = /\/node_modules\// + +function toArray(value: T | readonly T[]): T[] { + return Array.isArray(value) ? [...value] : [value as T] +} + +/** + * Create the `load` hook filter from the plugin options: Vite evaluates it + * natively (in rolldown) and skips calling the JS handler for non-matching + * module ids entirely. The handler applies the same filter with `createFilter` + * as a fallback for environments without hook filters. + * @param include - Ids to process, defaults to all image imports. + * @param exclude - Ids to skip, defaults to `node_modules`. String patterns are globs matching absolute ids. + * @returns Declarative hook filter. + */ +export function createLoadFilter(include?: FilterPattern, exclude?: FilterPattern) { + return { + id: { + include: include ? toArray(include) : [imageIdPattern], + exclude: [ + virtualIdPattern, + viteQueryPattern, + ...exclude ? toArray(exclude) : [nodeModulesPattern] + ] + } + } +} + +/** + * Split a Vite module id into the file path and the query string. + * @param id - Module id. + * @returns File path and query string without `?`. + */ +export function splitId(id: string) { + const index = id.indexOf('?') + + if (index < 0) { + return { + path: id, + query: '' + } + } + + return { + path: id.slice(0, index), + query: id.slice(index + 1) + } +} + +/** + * Decode the module id query for parsing: browser urls in dev mode + * arrive percent-encoded. + * @param id - Module id. + * @returns Query string starting with `?`. + */ +export function getResourceQuery(id: string) { + return `?${decodeURIComponent(splitId(id).query)}` +} diff --git a/packages/vite-plugin/src/types.ts b/packages/vite-plugin/src/types.ts new file mode 100644 index 0000000..38ba082 --- /dev/null +++ b/packages/vite-plugin/src/types.ts @@ -0,0 +1,12 @@ +import type { SrcSetModuleOptions } from '@srcset/bundler-utils' + +export interface SrcSetVitePluginOptions extends SrcSetModuleOptions { + /** + * Paths to process, picomatch pattern(s). Defaults to all image imports. + */ + include?: string | RegExp | (string | RegExp)[] + /** + * Paths to skip, picomatch pattern(s). Defaults to `node_modules`. + */ + exclude?: string | RegExp | (string | RegExp)[] +} diff --git a/packages/vite-plugin/test/vite.mock.ts b/packages/vite-plugin/test/vite.mock.ts new file mode 100644 index 0000000..d692776 --- /dev/null +++ b/packages/vite-plugin/test/vite.mock.ts @@ -0,0 +1,51 @@ +import { + mkdtemp, + writeFile +} from 'node:fs/promises' +import { tmpdir } from 'node:os' +import path from 'node:path' +import sharp from 'sharp' +import type { SrcSetEntry } from '@srcset/runtime' + +export const imageWidth = 640 +export const imageHeight = 480 + +const channels = 3 +const colorDepth = 256 + +/** + * Create a temporary project directory with a noise image and an entry file. + * @param entrySource - Entry file source code. + * @returns Project directory path. + */ +export async function createFixtureProject(entrySource: string) { + const dir = await mkdtemp(path.join(tmpdir(), 'srcset-vite-')) + const raw = Buffer.alloc(imageWidth * imageHeight * channels) + + for (let i = 0; i < raw.length; i++) { + raw[i] = Math.floor(Math.random() * colorDepth) + } + + const image = await sharp(raw, { + raw: { + width: imageWidth, + height: imageHeight, + channels + } + }).jpeg({ + quality: 90 + }).toBuffer() + + await writeFile(path.join(dir, 'image.jpg'), image) + await writeFile(path.join(dir, 'entry.js'), entrySource) + + return dir +} + +export interface ModuleExports { + default: string + src: SrcSetEntry | null + srcSet: SrcSetEntry[] + srcMap: Record + placeholder: string | undefined +} diff --git a/packages/vite-plugin/tsconfig.build.json b/packages/vite-plugin/tsconfig.build.json new file mode 100644 index 0000000..977c689 --- /dev/null +++ b/packages/vite-plugin/tsconfig.build.json @@ -0,0 +1,17 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src", + "types": [ + "node" + ] + }, + "include": [ + "src" + ], + "exclude": [ + "**/*.config.ts", + "**/*.spec.ts" + ] +} diff --git a/packages/vite-plugin/tsconfig.json b/packages/vite-plugin/tsconfig.json new file mode 100644 index 0000000..2783c96 --- /dev/null +++ b/packages/vite-plugin/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "./tsconfig.build.json", + "compilerOptions": { + "rootDir": "../../", + "allowJs": true + }, + "include": [ + "src", + "test", + "*.js", + "*.ts" + ], + "exclude": [] +} diff --git a/packages/vite-plugin/vite.config.js b/packages/vite-plugin/vite.config.js new file mode 100644 index 0000000..418f84b --- /dev/null +++ b/packages/vite-plugin/vite.config.js @@ -0,0 +1,11 @@ +import { defineConfig } from 'vite' + +export default defineConfig({ + test: { + testTimeout: 60000, + coverage: { + reporter: ['lcovonly', 'text'], + include: ['src/**/*'] + } + } +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 68d369b..4f88029 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -173,6 +173,32 @@ importers: version: 12.1.0(jiti@2.6.1) publishDirectory: package + packages/vite-plugin: + dependencies: + '@srcset/bundler-utils': + specifier: workspace:^ + version: link:../bundler-utils + '@srcset/core': + specifier: workspace:^ + version: link:../core + p-limit: + specifier: ^7.3.0 + version: 7.3.0 + devDependencies: + '@srcset/runtime': + specifier: workspace:^ + version: link:../runtime + '@types/node': + specifier: ^22.0.0 + version: 22.20.1 + sharp: + specifier: ^0.35.3 + version: 0.35.3(@types/node@22.20.1) + vite: + specifier: ^8.1.0 + version: 8.1.5(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.6.1)(terser@5.49.0) + publishDirectory: package + packages: '@babel/code-frame@7.29.7': From aebf2f7a38c0223ba88c7bcd09d1ae067d300536 Mon Sep 17 00:00:00 2001 From: dangreen Date: Wed, 22 Jul 2026 01:34:28 +0400 Subject: [PATCH 2/7] fix(vite-plugin): drop double query decoding --- packages/vite-plugin/src/query.spec.ts | 4 ++-- packages/vite-plugin/src/query.ts | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/vite-plugin/src/query.spec.ts b/packages/vite-plugin/src/query.spec.ts index abf7df3..be8d7e2 100644 --- a/packages/vite-plugin/src/query.spec.ts +++ b/packages/vite-plugin/src/query.spec.ts @@ -81,8 +81,8 @@ describe('vite-plugin', () => { }) describe('getResourceQuery', () => { - it('should decode percent-encoded query', () => { - expect(getResourceQuery(`/images/photo.jpg?${encodeURIComponent('{ "width": [320] }')}`)).toBe( + it('should keep the query as is', () => { + expect(getResourceQuery('/images/photo.jpg?{ "width": [320] }')).toBe( '?{ "width": [320] }' ) }) diff --git a/packages/vite-plugin/src/query.ts b/packages/vite-plugin/src/query.ts index 0ed46fe..f974934 100644 --- a/packages/vite-plugin/src/query.ts +++ b/packages/vite-plugin/src/query.ts @@ -56,11 +56,11 @@ export function splitId(id: string) { } /** - * Decode the module id query for parsing: browser urls in dev mode - * arrive percent-encoded. + * Get the module id query for parsing. The id arrives already decoded: + * Vite decodes request urls before the plugins. * @param id - Module id. * @returns Query string starting with `?`. */ export function getResourceQuery(id: string) { - return `?${decodeURIComponent(splitId(id).query)}` + return `?${splitId(id).query}` } From a79920eeb3eb73116c375067d449ad4fbdec8399 Mon Sep 17 00:00:00 2001 From: dangreen Date: Wed, 22 Jul 2026 02:28:22 +0400 Subject: [PATCH 3/7] fix(vite-plugin): honor server origin and merge client types with Vite's --- packages/vite-plugin/client.d.ts | 20 ++------------------ packages/vite-plugin/src/plugin.ts | 5 ++++- 2 files changed, 6 insertions(+), 19 deletions(-) diff --git a/packages/vite-plugin/client.d.ts b/packages/vite-plugin/client.d.ts index 4733106..e5cee99 100644 --- a/packages/vite-plugin/client.d.ts +++ b/packages/vite-plugin/client.d.ts @@ -1,6 +1,8 @@ /* oxlint-disable trigen/import-order */ /** * Ambient module declarations for image imports handled by @srcset/vite-plugin. + * Only the named exports are declared: the default url export + * comes from the `vite/client` asset types. * * Usage: add to tsconfig `types` or reference directly: * /// @@ -9,71 +11,53 @@ declare module '*.jpg' { import type { SrcSetEntry } from '@srcset/runtime' - const url: string - export const src: SrcSetEntry export const srcSet: SrcSetEntry[] export const srcMap: Record export const placeholder: string | undefined - export default url } declare module '*.jpeg' { import type { SrcSetEntry } from '@srcset/runtime' - const url: string - export const src: SrcSetEntry export const srcSet: SrcSetEntry[] export const srcMap: Record export const placeholder: string | undefined - export default url } declare module '*.png' { import type { SrcSetEntry } from '@srcset/runtime' - const url: string - export const src: SrcSetEntry export const srcSet: SrcSetEntry[] export const srcMap: Record export const placeholder: string | undefined - export default url } declare module '*.webp' { import type { SrcSetEntry } from '@srcset/runtime' - const url: string - export const src: SrcSetEntry export const srcSet: SrcSetEntry[] export const srcMap: Record export const placeholder: string | undefined - export default url } declare module '*.avif' { import type { SrcSetEntry } from '@srcset/runtime' - const url: string - export const src: SrcSetEntry export const srcSet: SrcSetEntry[] export const srcMap: Record export const placeholder: string | undefined - export default url } declare module '*.gif' { import type { SrcSetEntry } from '@srcset/runtime' - const url: string - export const src: SrcSetEntry export const srcSet: SrcSetEntry[] export const srcMap: Record export const placeholder: string | undefined - export default url } diff --git a/packages/vite-plugin/src/plugin.ts b/packages/vite-plugin/src/plugin.ts index 5b8df43..ade15ad 100644 --- a/packages/vite-plugin/src/plugin.ts +++ b/packages/vite-plugin/src/plugin.ts @@ -49,6 +49,7 @@ export function srcset(options: SrcSetVitePluginOptions = {}): Plugin { const matchesLoadFilter = createFilter(loadFilter.id.include, loadFilter.id.exclude) const devCache: DevCache = new Map() let base = '/' + let origin = '' let isBuild = false const generateModule = async (context: EmitContext, id: string) => { const { path } = splitId(id) @@ -77,7 +78,7 @@ export function srcset(options: SrcSetVitePluginOptions = {}): Plugin { return { outputPath: devPath, - publicPath: base + devPath + publicPath: origin + base + devPath } } @@ -95,6 +96,8 @@ export function srcset(options: SrcSetVitePluginOptions = {}): Plugin { enforce: 'pre', configResolved(config) { base = config.base + // Vite includes the origin in the dev asset urls for backend integrations. + origin = config.server.origin ?? '' isBuild = config.command === 'build' }, configureServer(server) { From 3ce0ba00d5eeabc616e75ddc0fa0f8d9e90bc49b Mon Sep 17 00:00:00 2001 From: dangreen Date: Wed, 22 Jul 2026 12:44:56 +0400 Subject: [PATCH 4/7] fix(vite-plugin): encode dev urls, guard non-image and public imports, type query imports --- packages/vite-plugin/client.d.ts | 15 +++++++++++- packages/vite-plugin/src/dev.spec.ts | 31 +++++++++++++++++++++++++ packages/vite-plugin/src/dev.ts | 4 +++- packages/vite-plugin/src/plugin.spec.ts | 18 ++++++++++++++ packages/vite-plugin/src/plugin.ts | 16 +++++++++++-- packages/vite-plugin/src/query.spec.ts | 12 +++++++++- packages/vite-plugin/src/query.ts | 11 +++++++++ 7 files changed, 102 insertions(+), 5 deletions(-) create mode 100644 packages/vite-plugin/src/dev.spec.ts diff --git a/packages/vite-plugin/client.d.ts b/packages/vite-plugin/client.d.ts index e5cee99..ee8ffb7 100644 --- a/packages/vite-plugin/client.d.ts +++ b/packages/vite-plugin/client.d.ts @@ -2,7 +2,8 @@ /** * Ambient module declarations for image imports handled by @srcset/vite-plugin. * Only the named exports are declared: the default url export - * comes from the `vite/client` asset types. + * comes from the `vite/client` asset types. The `*}` pattern covers + * imports with a JSON rule query, where `vite/client` does not apply. * * Usage: add to tsconfig `types` or reference directly: * /// @@ -61,3 +62,15 @@ declare module '*.gif' { export const srcMap: Record export const placeholder: string | undefined } + +declare module '*}' { + import type { SrcSetEntry } from '@srcset/runtime' + + const url: string + + export const src: SrcSetEntry + export const srcSet: SrcSetEntry[] + export const srcMap: Record + export const placeholder: string | undefined + export default url +} diff --git a/packages/vite-plugin/src/dev.spec.ts b/packages/vite-plugin/src/dev.spec.ts new file mode 100644 index 0000000..1b47c15 --- /dev/null +++ b/packages/vite-plugin/src/dev.spec.ts @@ -0,0 +1,31 @@ +import { + describe, + it, + expect +} from 'vitest' +import { + type DevCache, + addDevImage +} from './dev.ts' + +describe('vite-plugin', () => { + describe('dev', () => { + describe('addDevImage', () => { + it('should encode special characters in the pathname', () => { + const cache: DevCache = new Map() + const pathname = addDevImage(cache, { + path: '/images/my photo#1.jpg', + contents: Buffer.from('contents'), + format: 'jpg', + width: 640, + height: 480, + postfix: '', + originMultiplier: 1 + }) + + expect(pathname).toMatch(/^\/@srcset\/my%20photo%231\.[0-9a-f]{8}\.jpg$/) + expect(cache.has(pathname)).toBe(true) + }) + }) + }) +}) diff --git a/packages/vite-plugin/src/dev.ts b/packages/vite-plugin/src/dev.ts index e29b0b1..47b6f88 100644 --- a/packages/vite-plugin/src/dev.ts +++ b/packages/vite-plugin/src/dev.ts @@ -32,7 +32,9 @@ export function addDevImage(cache: DevCache, image: SrcSetImage) { const hash = createHash('sha256').update(image.contents).digest('hex').slice(0, hashLength) const extensionIndex = image.path.lastIndexOf('.') const stem = image.path.slice(image.path.lastIndexOf('/') + 1, extensionIndex) - const pathname = `${devPathPrefix}${stem}.${hash}.${image.format}` + // Encoded pathname is both the cache key and the public url: browsers + // percent-encode special characters in requests, the keys must match. + const pathname = `${devPathPrefix}${encodeURIComponent(`${stem}.${hash}.${image.format}`)}` cache.set(pathname, { contents: image.contents, diff --git a/packages/vite-plugin/src/plugin.spec.ts b/packages/vite-plugin/src/plugin.spec.ts index a0bd833..0ab6490 100644 --- a/packages/vite-plugin/src/plugin.spec.ts +++ b/packages/vite-plugin/src/plugin.spec.ts @@ -3,6 +3,10 @@ import { it, expect } from 'vitest' +import { + mkdir, + copyFile +} from 'node:fs/promises' import path from 'node:path' import sharp from 'sharp' import { @@ -159,6 +163,20 @@ describe('vite-plugin', () => { expect(exports.default).toBe(`https://proxy.test/plain/assets/${original.fileName}`) }) + it('should leave public directory imports to Vite', async () => { + const dir = await createFixtureProject(`import logo from '/logo.png' +export default logo +`) + + await mkdir(path.join(dir, 'public')) + await copyFile(path.join(dir, 'image.jpg'), path.join(dir, 'public', 'logo.png')) + + const { exports } = await buildFixture(dir) + + // Vite serves public assets from the base url root. + expect(exports.default).toBe('/assets/logo.png') + }) + it('should export placeholder data-url when enabled', async () => { const dir = await createFixtureProject(defaultEntry) const { exports } = await buildFixture(dir, { diff --git a/packages/vite-plugin/src/plugin.ts b/packages/vite-plugin/src/plugin.ts index ade15ad..93e3f71 100644 --- a/packages/vite-plugin/src/plugin.ts +++ b/packages/vite-plugin/src/plugin.ts @@ -1,5 +1,7 @@ import { availableParallelism } from 'node:os' +import { existsSync } from 'node:fs' import { readFile } from 'node:fs/promises' +import { join } from 'node:path' import type { SrcSetImage } from '@srcset/core' import pLimit from 'p-limit' import { @@ -14,7 +16,8 @@ import type { SrcSetVitePluginOptions } from './types.ts' import { splitId, getResourceQuery, - createLoadFilter + createLoadFilter, + isImageId } from './query.ts' import { type DevCache, @@ -50,6 +53,7 @@ export function srcset(options: SrcSetVitePluginOptions = {}): Plugin { const devCache: DevCache = new Map() let base = '/' let origin = '' + let publicDir = '' let isBuild = false const generateModule = async (context: EmitContext, id: string) => { const { path } = splitId(id) @@ -98,6 +102,7 @@ export function srcset(options: SrcSetVitePluginOptions = {}): Plugin { base = config.base // Vite includes the origin in the dev asset urls for backend integrations. origin = config.server.origin ?? '' + publicDir = config.publicDir isBuild = config.command === 'build' }, configureServer(server) { @@ -106,7 +111,14 @@ export function srcset(options: SrcSetVitePluginOptions = {}): Plugin { load: { filter: loadFilter, handler(id) { - if (!matchesLoadFilter(id)) { + if (!matchesLoadFilter(id) || !isImageId(id)) { + return null + } + + // Root-absolute imports of `publicDir` assets stay in the Vite asset pipeline. + const { path } = splitId(id) + + if (publicDir && !existsSync(path) && existsSync(join(publicDir, path))) { return null } diff --git a/packages/vite-plugin/src/query.spec.ts b/packages/vite-plugin/src/query.spec.ts index be8d7e2..af83d03 100644 --- a/packages/vite-plugin/src/query.spec.ts +++ b/packages/vite-plugin/src/query.spec.ts @@ -7,7 +7,8 @@ import { createFilter } from 'vite' import { createLoadFilter, splitId, - getResourceQuery + getResourceQuery, + isImageId } from './query.ts' function createMatcher(include?: Parameters[0], exclude?: Parameters[1]) { @@ -80,6 +81,15 @@ describe('vite-plugin', () => { }) }) + describe('isImageId', () => { + it('should match image ids only', () => { + expect(isImageId('/images/photo.jpg')).toBe(true) + expect(isImageId('/images/photo.png?{ "width": [1] }')).toBe(true) + expect(isImageId('/hero/main.ts')).toBe(false) + expect(isImageId('/hero/styles.css')).toBe(false) + }) + }) + describe('getResourceQuery', () => { it('should keep the query as is', () => { expect(getResourceQuery('/images/photo.jpg?{ "width": [320] }')).toBe( diff --git a/packages/vite-plugin/src/query.ts b/packages/vite-plugin/src/query.ts index f974934..0f89462 100644 --- a/packages/vite-plugin/src/query.ts +++ b/packages/vite-plugin/src/query.ts @@ -12,6 +12,17 @@ function toArray(value: T | readonly T[]): T[] { return Array.isArray(value) ? [...value] : [value as T] } +/** + * Check the module id is a supported image import. + * Guards the `load` handler with a custom `include`: user patterns + * narrow the scope, but only images can be processed. + * @param id - Module id. + * @returns Whether the id is an image import. + */ +export function isImageId(id: string) { + return imageIdPattern.test(id) +} + /** * Create the `load` hook filter from the plugin options: Vite evaluates it * natively (in rolldown) and skips calling the JS handler for non-matching From 37301229815baeb16ea09c1e9a40f118b7b3c8f9 Mon Sep 17 00:00:00 2001 From: dangreen Date: Wed, 22 Jul 2026 12:55:17 +0400 Subject: [PATCH 5/7] fix(vite-plugin): leave include scope and query typings to the user, drop sync fs --- packages/vite-plugin/client.d.ts | 15 +-------------- packages/vite-plugin/src/plugin.ts | 25 ++++++++++++++++++------- packages/vite-plugin/src/query.spec.ts | 12 +----------- packages/vite-plugin/src/query.ts | 11 ----------- 4 files changed, 20 insertions(+), 43 deletions(-) diff --git a/packages/vite-plugin/client.d.ts b/packages/vite-plugin/client.d.ts index ee8ffb7..e5cee99 100644 --- a/packages/vite-plugin/client.d.ts +++ b/packages/vite-plugin/client.d.ts @@ -2,8 +2,7 @@ /** * Ambient module declarations for image imports handled by @srcset/vite-plugin. * Only the named exports are declared: the default url export - * comes from the `vite/client` asset types. The `*}` pattern covers - * imports with a JSON rule query, where `vite/client` does not apply. + * comes from the `vite/client` asset types. * * Usage: add to tsconfig `types` or reference directly: * /// @@ -62,15 +61,3 @@ declare module '*.gif' { export const srcMap: Record export const placeholder: string | undefined } - -declare module '*}' { - import type { SrcSetEntry } from '@srcset/runtime' - - const url: string - - export const src: SrcSetEntry - export const srcSet: SrcSetEntry[] - export const srcMap: Record - export const placeholder: string | undefined - export default url -} diff --git a/packages/vite-plugin/src/plugin.ts b/packages/vite-plugin/src/plugin.ts index 93e3f71..0fa9957 100644 --- a/packages/vite-plugin/src/plugin.ts +++ b/packages/vite-plugin/src/plugin.ts @@ -1,6 +1,8 @@ import { availableParallelism } from 'node:os' -import { existsSync } from 'node:fs' -import { readFile } from 'node:fs/promises' +import { + access, + readFile +} from 'node:fs/promises' import { join } from 'node:path' import type { SrcSetImage } from '@srcset/core' import pLimit from 'p-limit' @@ -16,8 +18,7 @@ import type { SrcSetVitePluginOptions } from './types.ts' import { splitId, getResourceQuery, - createLoadFilter, - isImageId + createLoadFilter } from './query.ts' import { type DevCache, @@ -33,6 +34,16 @@ interface EmitContext { }): string } +async function fileExists(path: string) { + try { + await access(path) + + return true + } catch { + return false + } +} + /** * Vite plugin for generating responsive images. * Handles image imports by default: `import url, { src, srcSet, srcMap } from './image.jpg'`. @@ -110,15 +121,15 @@ export function srcset(options: SrcSetVitePluginOptions = {}): Plugin { }, load: { filter: loadFilter, - handler(id) { - if (!matchesLoadFilter(id) || !isImageId(id)) { + async handler(id) { + if (!matchesLoadFilter(id)) { return null } // Root-absolute imports of `publicDir` assets stay in the Vite asset pipeline. const { path } = splitId(id) - if (publicDir && !existsSync(path) && existsSync(join(publicDir, path))) { + if (publicDir && !await fileExists(path) && await fileExists(join(publicDir, path))) { return null } diff --git a/packages/vite-plugin/src/query.spec.ts b/packages/vite-plugin/src/query.spec.ts index af83d03..be8d7e2 100644 --- a/packages/vite-plugin/src/query.spec.ts +++ b/packages/vite-plugin/src/query.spec.ts @@ -7,8 +7,7 @@ import { createFilter } from 'vite' import { createLoadFilter, splitId, - getResourceQuery, - isImageId + getResourceQuery } from './query.ts' function createMatcher(include?: Parameters[0], exclude?: Parameters[1]) { @@ -81,15 +80,6 @@ describe('vite-plugin', () => { }) }) - describe('isImageId', () => { - it('should match image ids only', () => { - expect(isImageId('/images/photo.jpg')).toBe(true) - expect(isImageId('/images/photo.png?{ "width": [1] }')).toBe(true) - expect(isImageId('/hero/main.ts')).toBe(false) - expect(isImageId('/hero/styles.css')).toBe(false) - }) - }) - describe('getResourceQuery', () => { it('should keep the query as is', () => { expect(getResourceQuery('/images/photo.jpg?{ "width": [320] }')).toBe( diff --git a/packages/vite-plugin/src/query.ts b/packages/vite-plugin/src/query.ts index 0f89462..f974934 100644 --- a/packages/vite-plugin/src/query.ts +++ b/packages/vite-plugin/src/query.ts @@ -12,17 +12,6 @@ function toArray(value: T | readonly T[]): T[] { return Array.isArray(value) ? [...value] : [value as T] } -/** - * Check the module id is a supported image import. - * Guards the `load` handler with a custom `include`: user patterns - * narrow the scope, but only images can be processed. - * @param id - Module id. - * @returns Whether the id is an image import. - */ -export function isImageId(id: string) { - return imageIdPattern.test(id) -} - /** * Create the `load` hook filter from the plugin options: Vite evaluates it * natively (in rolldown) and skips calling the JS handler for non-matching From fe94887b2b58b6c241d79a708002f0e843fc66b0 Mon Sep 17 00:00:00 2001 From: dangreen Date: Wed, 22 Jul 2026 14:10:46 +0400 Subject: [PATCH 6/7] fix(vite-plugin): reference vite client types from the client declarations --- packages/vite-plugin/client.d.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/vite-plugin/client.d.ts b/packages/vite-plugin/client.d.ts index e5cee99..97e4014 100644 --- a/packages/vite-plugin/client.d.ts +++ b/packages/vite-plugin/client.d.ts @@ -1,4 +1,5 @@ /* oxlint-disable trigen/import-order */ +/// /** * Ambient module declarations for image imports handled by @srcset/vite-plugin. * Only the named exports are declared: the default url export From d17738a177bd3f70446410faf05953f801121a42 Mon Sep 17 00:00:00 2001 From: dangreen Date: Wed, 22 Jul 2026 15:17:56 +0400 Subject: [PATCH 7/7] docs(vite-plugin): per-format rules in the usage example --- packages/vite-plugin/README.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/vite-plugin/README.md b/packages/vite-plugin/README.md index 75dbb8a..c5b7133 100644 --- a/packages/vite-plugin/README.md +++ b/packages/vite-plugin/README.md @@ -59,8 +59,14 @@ export default defineConfig({ srcset({ rules: [ { + match: '**/*.jpg', width: [1, 0.5], format: ['avif', 'webp', 'jpg'] + }, + { + match: '**/*.gif', + width: [1, 0.5], + format: ['webp', 'gif'] } ], placeholder: true