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':