diff --git a/packages/vite-plugin/README.md b/packages/vite-plugin/README.md
new file mode 100644
index 0000000..c5b7133
--- /dev/null
+++ b/packages/vite-plugin/README.md
@@ -0,0 +1,94 @@
+# @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: [
+ {
+ match: '**/*.jpg',
+ width: [1, 0.5],
+ format: ['avif', 'webp', 'jpg']
+ },
+ {
+ match: '**/*.gif',
+ width: [1, 0.5],
+ format: ['webp', 'gif']
+ }
+ ],
+ 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..97e4014
--- /dev/null
+++ b/packages/vite-plugin/client.d.ts
@@ -0,0 +1,64 @@
+/* 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:
+ * ///
+ */
+
+declare module '*.jpg' {
+ import type { SrcSetEntry } from '@srcset/runtime'
+
+ export const src: SrcSetEntry
+ export const srcSet: SrcSetEntry[]
+ export const srcMap: Record
+ export const placeholder: string | undefined
+}
+
+declare module '*.jpeg' {
+ import type { SrcSetEntry } from '@srcset/runtime'
+
+ export const src: SrcSetEntry
+ export const srcSet: SrcSetEntry[]
+ export const srcMap: Record
+ export const placeholder: string | undefined
+}
+
+declare module '*.png' {
+ import type { SrcSetEntry } from '@srcset/runtime'
+
+ export const src: SrcSetEntry
+ export const srcSet: SrcSetEntry[]
+ export const srcMap: Record
+ export const placeholder: string | undefined
+}
+
+declare module '*.webp' {
+ import type { SrcSetEntry } from '@srcset/runtime'
+
+ export const src: SrcSetEntry
+ export const srcSet: SrcSetEntry[]
+ export const srcMap: Record
+ export const placeholder: string | undefined
+}
+
+declare module '*.avif' {
+ import type { SrcSetEntry } from '@srcset/runtime'
+
+ export const src: SrcSetEntry
+ export const srcSet: SrcSetEntry[]
+ export const srcMap: Record
+ export const placeholder: string | undefined
+}
+
+declare module '*.gif' {
+ import type { SrcSetEntry } from '@srcset/runtime'
+
+ export const src: SrcSetEntry
+ export const srcSet: SrcSetEntry[]
+ export const srcMap: Record
+ export const placeholder: string | undefined
+}
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.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
new file mode 100644
index 0000000..47b6f88
--- /dev/null
+++ b/packages/vite-plugin/src/dev.ts
@@ -0,0 +1,67 @@
+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)
+ // 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,
+ 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..0ab6490
--- /dev/null
+++ b/packages/vite-plugin/src/plugin.spec.ts
@@ -0,0 +1,235 @@
+import {
+ describe,
+ it,
+ expect
+} from 'vitest'
+import {
+ mkdir,
+ copyFile
+} from 'node:fs/promises'
+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 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, {
+ 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..0fa9957
--- /dev/null
+++ b/packages/vite-plugin/src/plugin.ts
@@ -0,0 +1,140 @@
+import { availableParallelism } from 'node:os'
+import {
+ access,
+ readFile
+} from 'node:fs/promises'
+import { join } from 'node:path'
+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
+}
+
+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'`.
+ * 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 origin = ''
+ let publicDir = ''
+ 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: origin + base + devPath
+ }
+ }
+
+ return generateSrcSetModule(
+ source,
+ query,
+ options,
+ emitImage,
+ limit
+ )
+ }
+
+ return {
+ name: 'srcset',
+ enforce: 'pre',
+ configResolved(config) {
+ 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) {
+ server.middlewares.use(createDevMiddleware(devCache))
+ },
+ load: {
+ filter: loadFilter,
+ 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 && !await fileExists(path) && await fileExists(join(publicDir, path))) {
+ 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..be8d7e2
--- /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 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
new file mode 100644
index 0000000..f974934
--- /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)
+ }
+}
+
+/**
+ * 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 `?${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':