From 04f70bb7233bda7902a1aaaadbac218a585cda36 Mon Sep 17 00:00:00 2001 From: dangreen Date: Tue, 21 Jul 2026 15:34:10 +0400 Subject: [PATCH 1/7] feat(bundler-utils): add shared bundler machinery --- packages/bundler-utils/README.md | 62 ++++++ packages/bundler-utils/oxlint.config.ts | 12 ++ packages/bundler-utils/package.json | 73 +++++++ packages/bundler-utils/src/generate.spec.ts | 201 ++++++++++++++++++ packages/bundler-utils/src/generate.ts | 105 +++++++++ packages/bundler-utils/src/generate.types.ts | 84 ++++++++ packages/bundler-utils/src/index.ts | 5 + packages/bundler-utils/src/module.spec.ts | 147 +++++++++++++ packages/bundler-utils/src/module.ts | 124 +++++++++++ packages/bundler-utils/src/module.types.ts | 44 ++++ .../bundler-utils/src/placeholder.spec.ts | 74 +++++++ packages/bundler-utils/src/placeholder.ts | 49 +++++ packages/bundler-utils/src/query.spec.ts | 64 ++++++ packages/bundler-utils/src/query.ts | 65 ++++++ packages/bundler-utils/src/types.ts | 66 ++++++ packages/bundler-utils/tsconfig.build.json | 17 ++ packages/bundler-utils/tsconfig.json | 14 ++ pnpm-lock.yaml | 17 ++ 18 files changed, 1223 insertions(+) create mode 100644 packages/bundler-utils/README.md create mode 100644 packages/bundler-utils/oxlint.config.ts create mode 100644 packages/bundler-utils/package.json create mode 100644 packages/bundler-utils/src/generate.spec.ts create mode 100644 packages/bundler-utils/src/generate.ts create mode 100644 packages/bundler-utils/src/generate.types.ts create mode 100644 packages/bundler-utils/src/index.ts create mode 100644 packages/bundler-utils/src/module.spec.ts create mode 100644 packages/bundler-utils/src/module.ts create mode 100644 packages/bundler-utils/src/module.types.ts create mode 100644 packages/bundler-utils/src/placeholder.spec.ts create mode 100644 packages/bundler-utils/src/placeholder.ts create mode 100644 packages/bundler-utils/src/query.spec.ts create mode 100644 packages/bundler-utils/src/query.ts create mode 100644 packages/bundler-utils/src/types.ts create mode 100644 packages/bundler-utils/tsconfig.build.json create mode 100644 packages/bundler-utils/tsconfig.json diff --git a/packages/bundler-utils/README.md b/packages/bundler-utils/README.md new file mode 100644 index 0000000..1cddbb1 --- /dev/null +++ b/packages/bundler-utils/README.md @@ -0,0 +1,62 @@ +# @srcset/bundler-utils + +[![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%2Fbundler-utils.svg +[npm-url]: https://npmjs.com/package/@srcset/bundler-utils + +[node]: https://img.shields.io/node/v/%40srcset%2Fbundler-utils.svg +[node-url]: https://nodejs.org + +[deps]: https://img.shields.io/librariesio/release/npm/%40srcset%2Fbundler-utils +[deps-url]: https://libraries.io/npm/%40srcset%2Fbundler-utils + +[size]: https://packagephobia.com/badge?p=%40srcset%2Fbundler-utils +[size-url]: https://packagephobia.com/result?p=%40srcset%2Fbundler-utils + +[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 + +Shared machinery of the [srcset](https://github.com/TrigenSoftware/srcset#readme) bundler integrations: import query parsing, image module codegen and the backend contract. + +- 🧩 Build custom bundler integrations on top of `generateSrcSetModule` +- 🌐 Implement custom image backends - encode variants or build image proxy urls + +## Install + +```bash +# pnpm +pnpm add -D @srcset/bundler-utils +# yarn +yarn add -D @srcset/bundler-utils +# npm +npm i -D @srcset/bundler-utils +``` + +## Usage + +```ts +import { parseResourceQuery, generateSrcSetModule } from '@srcset/bundler-utils' + +const query = parseResourceQuery(resourceQuery) +const module = await generateSrcSetModule(source, query, options, emitImage, limit) +// ES module code: default url, src, srcSet, srcMap and placeholder exports +``` + +You probably need [@srcset/loader](https://npmjs.com/package/@srcset/loader) or [@srcset/vite-plugin](https://npmjs.com/package/@srcset/vite-plugin) instead, unless you are building an integration or a backend. + +## Documentation + +For more details, guides and API references, check out the [documentation website](https://srcset.js.org/api/backends/). diff --git a/packages/bundler-utils/oxlint.config.ts b/packages/bundler-utils/oxlint.config.ts new file mode 100644 index 0000000..4eba28c --- /dev/null +++ b/packages/bundler-utils/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/bundler-utils/package.json b/packages/bundler-utils/package.json new file mode 100644 index 0000000..951193b --- /dev/null +++ b/packages/bundler-utils/package.json @@ -0,0 +1,73 @@ +{ + "name": "@srcset/bundler-utils", + "type": "module", + "version": "1.0.0", + "description": "Shared utilities for srcset bundler integrations: import query parsing and module code generation.", + "author": { + "name": "Dan Onoshko", + "email": "danon0404@gmail.com", + "url": "https://github.com/dangreen" + }, + "license": "MIT", + "homepage": "https://srcset.js.org/api/backends/", + "funding": "https://ko-fi.com/dangreen", + "repository": { + "type": "git", + "url": "https://github.com/TrigenSoftware/srcset.git", + "directory": "packages/bundler-utils" + }, + "bugs": { + "url": "https://github.com/TrigenSoftware/srcset/issues" + }, + "keywords": [ + "srcset", + "image", + "responsive", + "bundler", + "utils" + ], + "engines": { + "node": ">=22" + }, + "sideEffects": false, + "exports": { + "./package.json": "./package.json", + ".": "./src/index.ts" + }, + "publishConfig": { + "exports": { + "./package.json": "./package.json", + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + }, + "directory": "package", + "linkDirectory": false + }, + "files": [ + "dist" + ], + "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" + }, + "dependencies": { + "@srcset/core": "workspace:^", + "p-limit": "^7.3.0", + "sharp": "^0.35.3" + }, + "devDependencies": { + "@types/node": "^22.0.0" + } +} diff --git a/packages/bundler-utils/src/generate.spec.ts b/packages/bundler-utils/src/generate.spec.ts new file mode 100644 index 0000000..96c36da --- /dev/null +++ b/packages/bundler-utils/src/generate.spec.ts @@ -0,0 +1,201 @@ +import { + describe, + it, + expect, + vi +} from 'vitest' +import sharp from 'sharp' +import pLimit from 'p-limit' +import type { SrcSetBackend } from './generate.types.ts' +import { + generateSrcSetModule, + srcsetBackend +} from './generate.ts' + +async function createImage(width = 640, height = 480) { + const contents = await sharp({ + create: { + width, + height, + channels: 3, + background: '#3a7bd5' + } + }).jpeg().toBuffer() + + return { + path: '/images/image.jpg', + contents + } +} + +function emitToPath(image: { path: string }) { + return { + outputPath: image.path, + publicPath: image.path + } +} + +describe('bundler-utils', () => { + describe('generate', () => { + describe('generateSrcSetModule', () => { + it('should generate module for matched rules', async () => { + const image = await createImage() + const module = await generateSrcSetModule(image, {}, { + rules: [ + { + width: [1, 0.5] + } + ] + }, emitToPath) + + expect(module).toContain('const url = "/images/image.jpg";') + expect(module).toContain('"jpg640": url') + expect(module).toContain('"jpg320": "/images/image@320w.jpg"') + expect(module).toContain('export const placeholder = undefined;') + }) + + it('should prefer rules from query', async () => { + const image = await createImage() + const module = await generateSrcSetModule(image, { + rules: [ + { + width: [0.25] + } + ] + }, { + rules: [ + { + width: [1, 0.5] + } + ] + }, emitToPath) + + expect(module).toContain('"jpg160"') + expect(module).not.toContain('"jpg640"') + }) + + it('should skip rules not matching the image', async () => { + const image = await createImage() + const module = await generateSrcSetModule(image, {}, { + rules: [ + { + match: '**/*.png', + width: [1] + } + ] + }, emitToPath) + + expect(module).toContain("const url = '';") + expect(module).toContain('export const srcSet = [];') + }) + + it('should stop after only rule', async () => { + const image = await createImage() + const module = await generateSrcSetModule(image, {}, { + rules: [ + { + only: true, + width: [0.5] + }, + { + width: [0.25] + } + ] + }, emitToPath) + + expect(module).toContain('"jpg320"') + expect(module).not.toContain('"jpg160"') + }) + + it('should inline placeholder data-url', async () => { + const image = await createImage() + const module = await generateSrcSetModule( + image, + {}, + { + skipOptimization: true, + placeholder: true + }, + emitToPath + ) + + expect(module).toMatch(/export const placeholder = "data:image\/webp;base64,[^"]+";/) + }) + + it('should pass options, emitImage and limit to the backend', async () => { + const limit = pLimit(1) + let received: unknown[] = [] + const backend: typeof srcsetBackend = (backendOptions, backendEmitImage, backendLimit) => { + received = [backendOptions, backendEmitImage, backendLimit] + + return srcsetBackend(backendOptions, backendEmitImage, backendLimit) + } + const image = await createImage() + const options = { + skipOptimization: true, + backend + } + + await generateSrcSetModule(image, {}, options, emitToPath, limit) + + expect(received[0]).toBe(options) + expect(received[1]).toBe(emitToPath) + expect(received[2]).toBe(limit) + }) + + it('should build public path expression without a plain public path', async () => { + const image = await createImage() + const module = await generateSrcSetModule(image, {}, { + skipOptimization: true + }, (emitted: { path: string }) => ({ + outputPath: emitted.path, + publicPath: null, + publicPathExpression: '__webpack_public_path__' + })) + + expect(module).toContain('const url = __webpack_public_path__ + "/images/image.jpg";') + }) + + it('should fall back to the output path url without a public path expression', async () => { + const image = await createImage() + const module = await generateSrcSetModule(image, {}, { + skipOptimization: true + }, (emitted: { path: string }) => ({ + outputPath: emitted.path, + publicPath: null + })) + + expect(module).toContain('const url = "/images/image.jpg";') + }) + + it('should use url variants without emitting', async () => { + const image = await createImage() + const emitImage = vi.fn(emitToPath) + const urlBackend: SrcSetBackend = { + * generate(_source, metadata, rule) { + const [width = 1] = Array.isArray(rule.width) ? rule.width : [rule.width] + + yield { + format: metadata.format, + width: width <= 1 ? metadata.width : width, + height: metadata.height, + originMultiplier: width <= 1 ? width : null, + url: `https://proxy.test/w:${String(width)}` + } + } + } + const module = await generateSrcSetModule(image, {}, { + backend: () => urlBackend, + rules: [ + { + width: [320] + } + ] + }, emitImage) + + expect(emitImage).not.toHaveBeenCalled() + expect(module).toContain('const url = "https://proxy.test/w:320";') + }) + }) + }) +}) diff --git a/packages/bundler-utils/src/generate.ts b/packages/bundler-utils/src/generate.ts new file mode 100644 index 0000000..1500412 --- /dev/null +++ b/packages/bundler-utils/src/generate.ts @@ -0,0 +1,105 @@ +import type { LimitFunction } from 'p-limit' +import { + type ImageSource, + SrcSetGenerator, + getImageMetadata, + matchImage, + mimeTypes +} from '@srcset/core' +import type { QueryOptions } from './query.ts' +import type { + SrcSetModuleOptions, + SrcSetBackendFactory, + EmitImage +} from './generate.types.ts' +import { + type SrcSetModuleEntry, + createModuleString, + defaultResourceId +} from './module.ts' +import { createPlaceholder } from './placeholder.ts' + +export type * from './generate.types.ts' + +/** + * The default backend factory: generates images with the sharp + * generator and emits them on the bundler side. + * @param options - Generator options. + * @param emitImage - Emits an image on the bundler side. + * @param limit - Concurrency limit of the integration. + * @returns Backend. + */ +export const srcsetBackend: SrcSetBackendFactory = (options, emitImage, limit) => { + const generator = new SrcSetGenerator({ + ...options, + limit + }) + + return { + async* generate(source, _metadata, rule) { + for await (const image of generator.generate(source, rule)) { + yield { + format: image.format, + width: image.width, + height: image.height, + originMultiplier: image.originMultiplier, + url: emitImage(image) + } + } + } + } +} + +/** + * Generate ES module code for the image import: match the rules, + * collect the images from the backend and make the module code. + * @param source - Image file. + * @param query - Parsed import query options. + * @param options - Bundler integration options. + * @param emitImage - Emits an image on the bundler side, passed to the backend. + * @param limit - Concurrency limit of the integration, passed to the backend. + * @returns Module code. + */ +export async function generateSrcSetModule( + source: ImageSource, + query: QueryOptions, + options: SrcSetModuleOptions, + emitImage: EmitImage, + limit?: LimitFunction +) { + const { + backend: backendFactory = srcsetBackend, + resourceId = defaultResourceId + } = options + const rules = query.rules ?? options.rules ?? [{}] + const backend = backendFactory(options, emitImage, limit) + const metadata = await getImageMetadata(source) + const placeholder = await createPlaceholder(source, query.placeholder ?? options.placeholder) + const select = { + format: metadata.format, + width: metadata.width, + ...options.select, + ...query.select + } + const srcSet: SrcSetModuleEntry[] = [] + + for (const rule of rules) { + if (!await matchImage(source, rule.match)) { + continue + } + + for await (const image of backend.generate(source, metadata, rule)) { + srcSet.push({ + ...image, + id: resourceId(image.width, image.originMultiplier ?? image.width, image.format), + type: mimeTypes[image.format] + }) + } + + if (rule.only) { + break + } + } + + return createModuleString(select, srcSet, placeholder) +} diff --git a/packages/bundler-utils/src/generate.types.ts b/packages/bundler-utils/src/generate.types.ts new file mode 100644 index 0000000..0a2bd23 --- /dev/null +++ b/packages/bundler-utils/src/generate.types.ts @@ -0,0 +1,84 @@ +import type { LimitFunction } from 'p-limit' +import type { + ImageMetadata, + ImageSource, + SrcSetImage, + SrcSetGeneratorOptions +} from '@srcset/core' +import type { + SrcSetRule, + SrcSetImagePaths, + SrcSetBackendImage +} from './types.ts' +import type { + SrcSetEntrySelect, + ResourceIdFormatter +} from './module.types.ts' +import type { PlaceholderOptions } from './placeholder.ts' + +/** + * Common options of a srcset bundler integration. + */ +export interface SrcSetModuleOptions extends Omit { + /** + * Backend factory to produce image variants: the backend emits images + * with `emitImage` and yields their urls. Defaults to the sharp generator backend. + */ + backend?: SrcSetBackendFactory + /** + * Rules to generate image variants. An import query rule takes precedence. + */ + rules?: SrcSetRule[] + /** + * Add `placeholder` module export - a tiny variant inlined as a data-url, + * for blur-up placeholders. Unused export is tree-shaken away. + */ + placeholder?: boolean | PlaceholderOptions + /** + * Resource id formatter function. + */ + resourceId?: ResourceIdFormatter + /** + * Selection of the image variant for the default export. Import query takes precedence. + */ + select?: SrcSetEntrySelect +} + +/** + * Backend of a srcset bundler integration: produces image variants. + */ +export interface SrcSetBackend { + /** + * Produce images for the rule. + * @param source - Image file. + * @param metadata - Image metadata. + * @param rule - Rule to generate images. + * @returns Images. + */ + generate( + source: ImageSource, + metadata: ImageMetadata, + rule: SrcSetRule + ): AsyncIterable | Iterable +} + +/** + * Factory of a backend: creates the backend instance from the integration + * options, the image emitter and the shared concurrency limit. + * @param options - Integration options. + * @param emitImage - Emits an image on the bundler side. + * @param limit - Concurrency limit of the integration. + * @returns Backend. + */ +export type SrcSetBackendFactory = ( + options: Omit, + emitImage: EmitImage, + limit?: LimitFunction +) => SrcSetBackend + +/** + * Emits an image on the bundler side. + * @param image - Image variant. + * @returns Paths of the emitted image. + */ +export type EmitImage = (image: SrcSetImage) => SrcSetImagePaths diff --git a/packages/bundler-utils/src/index.ts b/packages/bundler-utils/src/index.ts new file mode 100644 index 0000000..e649947 --- /dev/null +++ b/packages/bundler-utils/src/index.ts @@ -0,0 +1,5 @@ +export type * from './types.ts' +export * from './module.ts' +export * from './query.ts' +export * from './placeholder.ts' +export * from './generate.ts' diff --git a/packages/bundler-utils/src/module.spec.ts b/packages/bundler-utils/src/module.spec.ts new file mode 100644 index 0000000..2a1f384 --- /dev/null +++ b/packages/bundler-utils/src/module.spec.ts @@ -0,0 +1,147 @@ +import { + describe, + it, + expect +} from 'vitest' +import { + type SrcSetModuleEntry, + createModuleString +} from './module.ts' + +function createEntry( + format: 'jpg' | 'webp', + width: number, + originMultiplier: number | null = null +): SrcSetModuleEntry { + return { + id: `${format}${width}`, + format, + type: format === 'jpg' ? 'image/jpeg' : 'image/webp', + width, + height: Math.round(width * 0.75), + originMultiplier, + url: { + outputPath: `image@${width}w.${format}`, + publicPath: null, + publicPathExpression: '__webpack_public_path__' + } + } +} + +describe('bundler-utils', () => { + describe('module', () => { + describe('createModuleString', () => { + it('should select default variant by format and width', () => { + const module = createModuleString( + { + format: 'webp', + width: 320 + }, + [createEntry('jpg', 320), createEntry('webp', 320), createEntry('webp', 640)] + ) + + expect(module).toContain('const url = __webpack_public_path__ + "image@320w.webp";') + }) + + it('should select default variant by multiplier', () => { + const module = createModuleString( + { + format: 'jpg', + width: 0.5 + }, + [createEntry('jpg', 640, 1), createEntry('jpg', 320, 0.5)] + ) + + expect(module).toContain('const url = __webpack_public_path__ + "image@320w.jpg";') + }) + + it('should select default variant by id', () => { + const module = createModuleString( + { + id: 'webp640' + }, + [createEntry('jpg', 320), createEntry('webp', 640)] + ) + + expect(module).toContain('const url = __webpack_public_path__ + "image@640w.webp";') + }) + + it('should fall back to first variant', () => { + const module = createModuleString( + { + format: 'avif', + width: 5000 + }, + [createEntry('jpg', 320), createEntry('webp', 640)] + ) + + expect(module).toContain('const url = __webpack_public_path__ + "image@320w.jpg";') + }) + + it('should create empty module without variants', () => { + const module = createModuleString( + { + format: 'jpg', + width: 640 + }, + [] + ) + + expect(module).toContain("const url = '';") + expect(module).toContain('const src = null;') + expect(module).toContain('export const srcSet = [];') + }) + + it('should reuse url and src references for default variant', () => { + const module = createModuleString( + { + format: 'jpg', + width: 320 + }, + [createEntry('jpg', 320), createEntry('webp', 320)] + ) + + expect(module).toContain('url: url') + expect(module).toContain('export const srcSet = [src, {') + expect(module).toContain('"jpg320": url') + }) + + it('should emit placeholder export', () => { + const module = createModuleString( + { + format: 'jpg', + width: 320 + }, + [createEntry('jpg', 320)], + 'data:image/webp;base64,abc' + ) + + expect(module).toContain('export const placeholder = "data:image/webp;base64,abc";') + }) + + it('should emit undefined placeholder without data-url', () => { + const module = createModuleString( + { + format: 'jpg', + width: 320 + }, + [createEntry('jpg', 320)] + ) + + expect(module).toContain('export const placeholder = undefined;') + }) + + it('should map ids to urls', () => { + const module = createModuleString( + { + format: 'jpg', + width: 320 + }, + [createEntry('jpg', 320), createEntry('webp', 640)] + ) + + expect(module).toContain('"webp640": __webpack_public_path__ + "image@640w.webp"') + }) + }) + }) +}) diff --git a/packages/bundler-utils/src/module.ts b/packages/bundler-utils/src/module.ts new file mode 100644 index 0000000..4e33a0f --- /dev/null +++ b/packages/bundler-utils/src/module.ts @@ -0,0 +1,124 @@ +import type { SrcSetImagePaths } from './types.ts' +import type { + SrcSetModuleEntry, + SrcSetEntrySelect, + ResourceIdFormatter +} from './module.types.ts' + +export type * from './module.types.ts' + +/** + * Default resource id formatter: format plus actual width, e.g. `jpg640`. + * @param width - Actual width of the image variant in pixels. + * @param requestedWidth - Width as it was requested. + * @param format - Image variant format. + * @returns Resource id. + */ +export const defaultResourceId: ResourceIdFormatter = (width, _requestedWidth, format) => `${format}${String(width)}` + +const emptyUrlExpression = "''" + +/** + * Make a JS expression for the image variant url: plain urls are encoded, + * emitted images without a plain public url are prefixed with the + * public path expression of the integration, like `__webpack_public_path__`. + * @param url - Plain url string, or the emitted image paths. + * @returns JS expression string. + */ +function toUrlExpression(url: string | SrcSetImagePaths) { + if (typeof url === 'string') { + return JSON.stringify(url) + } + + if (url.publicPath !== null) { + return JSON.stringify(url.publicPath) + } + + const outputPath = JSON.stringify(url.outputPath) + + return url.publicPathExpression ? `${url.publicPathExpression} + ${outputPath}` : outputPath +} + +function findDefaultIndex(select: SrcSetEntrySelect, srcSet: SrcSetModuleEntry[]) { + const index = srcSet.findIndex((entry) => { + if (entry.id === select.id) { + return true + } + + if (entry.format !== select.format) { + return false + } + + const isMultiplier = typeof select.width === 'number' && select.width <= 1 + + if (isMultiplier) { + return entry.originMultiplier === select.width + } + + return entry.width === select.width + }) + + if (index < 0 && srcSet.length) { + return 0 + } + + return index +} + +function createEntryString({ + id, + format, + type, + width, + height +}: SrcSetModuleEntry, urlString: string) { + return `{ + id: ${JSON.stringify(id)}, + format: ${JSON.stringify(format)}, + type: ${JSON.stringify(type)}, + width: ${String(width)}, + height: ${String(height)}, + url: ${urlString} +}` +} + +/** + * Create ES module code for the image import. + * @param select - Selection of the image variant for the default export. + * @param srcSet - Generated image variant entries. + * @param placeholder - Data-url of the placeholder variant, falsy to emit `undefined`. + * @returns Module code. + */ +export function createModuleString(select: SrcSetEntrySelect, srcSet: SrcSetModuleEntry[], placeholder?: string | false) { + const defaultIndex = findDefaultIndex(select, srcSet) + const urlExpressions = srcSet.map(entry => toUrlExpression(entry.url)) + const urlExpression = defaultIndex < 0 ? emptyUrlExpression : urlExpressions[defaultIndex] + const srcSetStrings: string[] = [] + const srcMapStrings: string[] = [] + let srcString = 'null' + + srcSet.forEach((entry, index) => { + const isDefault = index === defaultIndex + const urlString = isDefault ? 'url' : urlExpressions[index] + const entryString = createEntryString(entry, urlString) + + if (isDefault) { + srcString = entryString + } + + srcMapStrings.push(`${JSON.stringify(entry.id)}: ${urlString}`) + srcSetStrings.push(isDefault ? 'src' : entryString) + }) + + return `const url = ${urlExpression}; +const src = ${srcString}; + +export default url; +export { src }; +export const srcSet = [${srcSetStrings.join(', ')}]; +export const srcMap = { + ${srcMapStrings.join(',\n ')} +}; +export const placeholder = ${placeholder ? JSON.stringify(placeholder) : 'undefined'}; +` +} diff --git a/packages/bundler-utils/src/module.types.ts b/packages/bundler-utils/src/module.types.ts new file mode 100644 index 0000000..e1ca20e --- /dev/null +++ b/packages/bundler-utils/src/module.types.ts @@ -0,0 +1,44 @@ +import type { ImageFormat } from '@srcset/core' +import type { SrcSetBackendImage } from './types.ts' + +/** + * Resource id formatter function. + * @param width - Actual width of the image variant in pixels. + * @param requestedWidth - Width as it was requested: absolute value or multiplier less than or equal to 1. + * @param format - Image variant format. + * @returns Resource id. + */ +export type ResourceIdFormatter = (width: number, requestedWidth: number, format: ImageFormat) => string + +/** + * Generated image variant entry of the module: the backend image + * plus the resource id and the mime type, the runtime `SrcSetEntry` shape. + */ +export interface SrcSetModuleEntry extends SrcSetBackendImage { + /** + * Resource id of the variant. + */ + id: string + /** + * Image mime type. + */ + type: string +} + +/** + * Selection of the image variant for the default export. + */ +export interface SrcSetEntrySelect { + /** + * Resource id of the variant. + */ + id?: string + /** + * Format of the variant. + */ + format?: string + /** + * Width of the variant: absolute value or multiplier less than or equal to 1. + */ + width?: number +} diff --git a/packages/bundler-utils/src/placeholder.spec.ts b/packages/bundler-utils/src/placeholder.spec.ts new file mode 100644 index 0000000..42d9425 --- /dev/null +++ b/packages/bundler-utils/src/placeholder.spec.ts @@ -0,0 +1,74 @@ +import { + describe, + it, + expect +} from 'vitest' +import sharp from 'sharp' +import { createPlaceholder } from './placeholder.ts' + +async function createImage(width = 640, height = 480) { + const contents = await sharp({ + create: { + width, + height, + channels: 3, + background: '#3a7bd5' + } + }).jpeg().toBuffer() + + return { + path: '/images/image.jpg', + contents + } +} + +describe('bundler-utils', () => { + describe('placeholder', () => { + describe('createPlaceholder', () => { + it('should create webp data-url of default width', async () => { + const image = await createImage() + const placeholder = await createPlaceholder(image, true) + + expect(placeholder).toMatch(/^data:image\/webp;base64,/) + + const decoded = Buffer.from((placeholder as string).split(',')[1], 'base64') + const metadata = await sharp(decoded).metadata() + + expect(metadata.format).toBe('webp') + expect(metadata.width).toBe(16) + }) + + it('should respect width and format options', async () => { + const image = await createImage() + const placeholder = await createPlaceholder(image, { + width: 8, + format: 'jpg' + }) + + expect(placeholder).toMatch(/^data:image\/jpeg;base64,/) + + const decoded = Buffer.from((placeholder as string).split(',')[1], 'base64') + const metadata = await sharp(decoded).metadata() + + expect(metadata.format).toBe('jpeg') + expect(metadata.width).toBe(8) + }) + + it('should not enlarge images smaller than the placeholder', async () => { + const image = await createImage(8, 6) + const placeholder = await createPlaceholder(image, true) + const decoded = Buffer.from((placeholder as string).split(',')[1], 'base64') + const metadata = await sharp(decoded).metadata() + + expect(metadata.width).toBe(8) + }) + + it('should return undefined when disabled', async () => { + const image = await createImage() + + expect(await createPlaceholder(image, undefined)).toBeUndefined() + expect(await createPlaceholder(image, false)).toBeUndefined() + }) + }) + }) +}) diff --git a/packages/bundler-utils/src/placeholder.ts b/packages/bundler-utils/src/placeholder.ts new file mode 100644 index 0000000..009fc5b --- /dev/null +++ b/packages/bundler-utils/src/placeholder.ts @@ -0,0 +1,49 @@ +import sharp from 'sharp' +import { + type ImageSource, + mimeTypes +} from '@srcset/core' + +const defaultWidth = 16 +const defaultFormat = 'webp' + +/** + * Options of the `placeholder` module export. + */ +export interface PlaceholderOptions { + /** + * Placeholder width in pixels. + */ + width?: number + /** + * Placeholder format. + */ + format?: 'webp' | 'jpg' +} + +/** + * Create a tiny data-url variant of the image for blur-up placeholders. + * @param source - Image file. + * @param options - Placeholder options, `true` for the defaults. + * @returns Data-url string, or `undefined` when disabled. + */ +export async function createPlaceholder( + source: ImageSource, + options: PlaceholderOptions | boolean | undefined +): Promise { + if (!options) { + return undefined + } + + const { + width = defaultWidth, + format = defaultFormat + } = options === true ? {} : options + const pipeline = sharp(source.contents).resize({ + width, + withoutEnlargement: true + }) + const contents = await (format === 'webp' ? pipeline.webp() : pipeline.jpeg()).toBuffer() + + return `data:${mimeTypes[format]};base64,${contents.toString('base64')}` +} diff --git a/packages/bundler-utils/src/query.spec.ts b/packages/bundler-utils/src/query.spec.ts new file mode 100644 index 0000000..86661e2 --- /dev/null +++ b/packages/bundler-utils/src/query.spec.ts @@ -0,0 +1,64 @@ +import { + describe, + it, + expect +} from 'vitest' +import { parseResourceQuery } from './query.ts' + +describe('bundler-utils', () => { + describe('query', () => { + describe('parseResourceQuery', () => { + it('should parse JSON rule', () => { + expect(parseResourceQuery('?{ "width": [1, 0.5], "format": ["webp"] }')).toEqual({ + rules: [ + { + width: [1, 0.5], + format: ['webp'] + } + ] + }) + }) + + it('should parse select params', () => { + expect(parseResourceQuery('?id=jpg320&format=webp&width=320')).toEqual({ + select: { + id: 'jpg320', + format: 'webp', + width: 320 + } + }) + }) + + it('should combine rule and select params', () => { + expect(parseResourceQuery('?{ "width": [0.5, 1] }&width=320')).toEqual({ + rules: [ + { + width: [0.5, 1] + } + ], + select: { + width: 320 + } + }) + }) + + it('should parse placeholder param', () => { + expect(parseResourceQuery('?placeholder')).toEqual({ + placeholder: true + }) + expect(parseResourceQuery('?placeholder=false')).toEqual({ + placeholder: false + }) + }) + + it('should ignore srcset marker and unknown params', () => { + expect(parseResourceQuery('?srcset&unknown=1')).toEqual({}) + }) + + it('should return empty options for query without question mark', () => { + expect(parseResourceQuery('')).toEqual({}) + expect(parseResourceQuery('srcset')).toEqual({}) + }) + }) + }) +}) diff --git a/packages/bundler-utils/src/query.ts b/packages/bundler-utils/src/query.ts new file mode 100644 index 0000000..b01736c --- /dev/null +++ b/packages/bundler-utils/src/query.ts @@ -0,0 +1,65 @@ +import type { SrcSetRule } from './types.ts' +import type { SrcSetEntrySelect } from './module.ts' + +export interface QueryOptions { + rules?: SrcSetRule[] + select?: SrcSetEntrySelect + placeholder?: boolean +} + +/** + * Parse srcset options from the import query. + * Supported parts, combined with `&`: + * - `{ "width": [1, 0.5], "format": ["webp", "jpg"] }` - JSON rule to generate variants; + * - `id=`, `format=`, `width=` - selection of the variant for the default export; + * - `placeholder` - add the `placeholder` module export. + * @param resourceQuery - Resource query string starting with `?`. + * @returns Parsed options. + */ +export function parseResourceQuery(resourceQuery: string): QueryOptions { + const query: QueryOptions = {} + + if (!resourceQuery.startsWith('?')) { + return query + } + + for (const pair of resourceQuery.slice(1).split('&')) { + if (pair.startsWith('{')) { + query.rules = [JSON.parse(pair) as SrcSetRule] + continue + } + + const [key, value] = pair.split('=') + + switch (key) { + case 'id': + query.select = { + ...query.select, + id: value + } + break + + case 'format': + query.select = { + ...query.select, + format: value + } + break + + case 'width': + query.select = { + ...query.select, + width: Number(value) + } + break + + case 'placeholder': + query.placeholder = value !== 'false' + break + + default: + } + } + + return query +} diff --git a/packages/bundler-utils/src/types.ts b/packages/bundler-utils/src/types.ts new file mode 100644 index 0000000..f7d1f10 --- /dev/null +++ b/packages/bundler-utils/src/types.ts @@ -0,0 +1,66 @@ +import type { + ImageFormat, + Matcher, + GenerateOptions +} from '@srcset/core' + +/** + * Paths of an image emitted on the bundler side. + */ +export interface SrcSetImagePaths { + /** + * Path of the emitted image in the build output. + */ + outputPath: string + /** + * Public url of the emitted image, when known as a plain string at the build time. + */ + publicPath: string | null + /** + * JS expression of the public path prefix for the url expression, + * when the public path is not known as a plain string, + * e.g. `__webpack_public_path__` of webpack. + */ + publicPathExpression?: string +} + +/** + * Image produced by a backend: the variant url and its properties. + */ +export interface SrcSetBackendImage { + /** + * Image format. + */ + format: ImageFormat + /** + * Image width in pixels. + */ + width: number + /** + * Image height in pixels. + */ + height: number + /** + * Width multiplier relative to the original image, if the variant was requested with one. + */ + originMultiplier: number | null + /** + * Image variant url: a plain url string, or the paths + * of the image emitted with `EmitImage`. + */ + url: string | SrcSetImagePaths +} + +/** + * Rule to generate image variants: match options plus generate options. + */ +export interface SrcSetRule extends GenerateOptions { + /** + * Rule(s) to match the image: glob, media query or matcher function. + */ + match?: Matcher | Matcher[] + /** + * Do not apply the rest of the rules if this rule matched. + */ + only?: boolean +} diff --git a/packages/bundler-utils/tsconfig.build.json b/packages/bundler-utils/tsconfig.build.json new file mode 100644 index 0000000..977c689 --- /dev/null +++ b/packages/bundler-utils/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/bundler-utils/tsconfig.json b/packages/bundler-utils/tsconfig.json new file mode 100644 index 0000000..2783c96 --- /dev/null +++ b/packages/bundler-utils/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "./tsconfig.build.json", + "compilerOptions": { + "rootDir": "../../", + "allowJs": true + }, + "include": [ + "src", + "test", + "*.js", + "*.ts" + ], + "exclude": [] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2f3ab56..587898a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -68,6 +68,23 @@ importers: specifier: ^4.1.9 version: 4.1.10(@types/node@22.20.1)(@vitest/coverage-v8@4.1.10)(vite@8.1.5(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.6.1)) + packages/bundler-utils: + dependencies: + '@srcset/core': + specifier: workspace:^ + version: link:../core + p-limit: + specifier: ^7.3.0 + version: 7.3.0 + sharp: + specifier: ^0.35.3 + version: 0.35.3(@types/node@22.20.1) + devDependencies: + '@types/node': + specifier: ^22.0.0 + version: 22.20.1 + publishDirectory: package + packages/cli: dependencies: '@srcset/core': From 77d9b3b215f2f673a2e411d8bd36506715343f7e Mon Sep 17 00:00:00 2001 From: dangreen Date: Tue, 21 Jul 2026 16:22:39 +0400 Subject: [PATCH 2/7] fix(bundler-utils): robust import query parsing --- packages/bundler-utils/src/query.ts | 21 ++++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/packages/bundler-utils/src/query.ts b/packages/bundler-utils/src/query.ts index b01736c..6559fdc 100644 --- a/packages/bundler-utils/src/query.ts +++ b/packages/bundler-utils/src/query.ts @@ -25,7 +25,12 @@ export function parseResourceQuery(resourceQuery: string): QueryOptions { for (const pair of resourceQuery.slice(1).split('&')) { if (pair.startsWith('{')) { - query.rules = [JSON.parse(pair) as SrcSetRule] + try { + query.rules = [JSON.parse(pair) as SrcSetRule] + } catch { + throw new Error(`Invalid srcset rule in the import query: "${pair}"`) + } + continue } @@ -46,12 +51,18 @@ export function parseResourceQuery(resourceQuery: string): QueryOptions { } break - case 'width': - query.select = { - ...query.select, - width: Number(value) + case 'width': { + const width = Number(value) + + if (Number.isFinite(width)) { + query.select = { + ...query.select, + width + } } + break + } case 'placeholder': query.placeholder = value !== 'false' From 86404dee7c90b34858faaa715bed730d9fa45907 Mon Sep 17 00:00:00 2001 From: dangreen Date: Tue, 21 Jul 2026 16:45:21 +0400 Subject: [PATCH 3/7] fix(bundler-utils): parenthesize public path expression --- packages/bundler-utils/src/generate.spec.ts | 2 +- packages/bundler-utils/src/module.spec.ts | 10 +++++----- packages/bundler-utils/src/module.ts | 2 +- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/bundler-utils/src/generate.spec.ts b/packages/bundler-utils/src/generate.spec.ts index 96c36da..a2d01f5 100644 --- a/packages/bundler-utils/src/generate.spec.ts +++ b/packages/bundler-utils/src/generate.spec.ts @@ -153,7 +153,7 @@ describe('bundler-utils', () => { publicPathExpression: '__webpack_public_path__' })) - expect(module).toContain('const url = __webpack_public_path__ + "/images/image.jpg";') + expect(module).toContain('const url = (__webpack_public_path__) + "/images/image.jpg";') }) it('should fall back to the output path url without a public path expression', async () => { diff --git a/packages/bundler-utils/src/module.spec.ts b/packages/bundler-utils/src/module.spec.ts index 2a1f384..5427e93 100644 --- a/packages/bundler-utils/src/module.spec.ts +++ b/packages/bundler-utils/src/module.spec.ts @@ -40,7 +40,7 @@ describe('bundler-utils', () => { [createEntry('jpg', 320), createEntry('webp', 320), createEntry('webp', 640)] ) - expect(module).toContain('const url = __webpack_public_path__ + "image@320w.webp";') + expect(module).toContain('const url = (__webpack_public_path__) + "image@320w.webp";') }) it('should select default variant by multiplier', () => { @@ -52,7 +52,7 @@ describe('bundler-utils', () => { [createEntry('jpg', 640, 1), createEntry('jpg', 320, 0.5)] ) - expect(module).toContain('const url = __webpack_public_path__ + "image@320w.jpg";') + expect(module).toContain('const url = (__webpack_public_path__) + "image@320w.jpg";') }) it('should select default variant by id', () => { @@ -63,7 +63,7 @@ describe('bundler-utils', () => { [createEntry('jpg', 320), createEntry('webp', 640)] ) - expect(module).toContain('const url = __webpack_public_path__ + "image@640w.webp";') + expect(module).toContain('const url = (__webpack_public_path__) + "image@640w.webp";') }) it('should fall back to first variant', () => { @@ -75,7 +75,7 @@ describe('bundler-utils', () => { [createEntry('jpg', 320), createEntry('webp', 640)] ) - expect(module).toContain('const url = __webpack_public_path__ + "image@320w.jpg";') + expect(module).toContain('const url = (__webpack_public_path__) + "image@320w.jpg";') }) it('should create empty module without variants', () => { @@ -140,7 +140,7 @@ describe('bundler-utils', () => { [createEntry('jpg', 320), createEntry('webp', 640)] ) - expect(module).toContain('"webp640": __webpack_public_path__ + "image@640w.webp"') + expect(module).toContain('"webp640": (__webpack_public_path__) + "image@640w.webp"') }) }) }) diff --git a/packages/bundler-utils/src/module.ts b/packages/bundler-utils/src/module.ts index 4e33a0f..32b4add 100644 --- a/packages/bundler-utils/src/module.ts +++ b/packages/bundler-utils/src/module.ts @@ -36,7 +36,7 @@ function toUrlExpression(url: string | SrcSetImagePaths) { const outputPath = JSON.stringify(url.outputPath) - return url.publicPathExpression ? `${url.publicPathExpression} + ${outputPath}` : outputPath + return url.publicPathExpression ? `(${url.publicPathExpression}) + ${outputPath}` : outputPath } function findDefaultIndex(select: SrcSetEntrySelect, srcSet: SrcSetModuleEntry[]) { From 74b5da9b3996c4212631b2454c1920f4fba89698 Mon Sep 17 00:00:00 2001 From: dangreen Date: Tue, 21 Jul 2026 17:17:01 +0400 Subject: [PATCH 4/7] fix(bundler-utils): positive select width and limited placeholder --- packages/bundler-utils/src/generate.ts | 2 +- packages/bundler-utils/src/placeholder.ts | 15 ++++++++++----- packages/bundler-utils/src/query.ts | 2 +- 3 files changed, 12 insertions(+), 7 deletions(-) diff --git a/packages/bundler-utils/src/generate.ts b/packages/bundler-utils/src/generate.ts index 1500412..c7a3d1d 100644 --- a/packages/bundler-utils/src/generate.ts +++ b/packages/bundler-utils/src/generate.ts @@ -74,7 +74,7 @@ export async function generateSrcSetModule( const rules = query.rules ?? options.rules ?? [{}] const backend = backendFactory(options, emitImage, limit) const metadata = await getImageMetadata(source) - const placeholder = await createPlaceholder(source, query.placeholder ?? options.placeholder) + const placeholder = await createPlaceholder(source, query.placeholder ?? options.placeholder, limit) const select = { format: metadata.format, width: metadata.width, diff --git a/packages/bundler-utils/src/placeholder.ts b/packages/bundler-utils/src/placeholder.ts index 009fc5b..ca5ecab 100644 --- a/packages/bundler-utils/src/placeholder.ts +++ b/packages/bundler-utils/src/placeholder.ts @@ -25,11 +25,13 @@ export interface PlaceholderOptions { * Create a tiny data-url variant of the image for blur-up placeholders. * @param source - Image file. * @param options - Placeholder options, `true` for the defaults. + * @param limit - Concurrency limit for the sharp work. * @returns Data-url string, or `undefined` when disabled. */ export async function createPlaceholder( source: ImageSource, - options: PlaceholderOptions | boolean | undefined + options: PlaceholderOptions | boolean | undefined, + limit: (task: () => Promise) => Promise = task => task() ): Promise { if (!options) { return undefined @@ -39,11 +41,14 @@ export async function createPlaceholder( width = defaultWidth, format = defaultFormat } = options === true ? {} : options - const pipeline = sharp(source.contents).resize({ - width, - withoutEnlargement: true + const contents = await limit(() => { + const pipeline = sharp(source.contents).resize({ + width, + withoutEnlargement: true + }) + + return (format === 'webp' ? pipeline.webp() : pipeline.jpeg()).toBuffer() }) - const contents = await (format === 'webp' ? pipeline.webp() : pipeline.jpeg()).toBuffer() return `data:${mimeTypes[format]};base64,${contents.toString('base64')}` } diff --git a/packages/bundler-utils/src/query.ts b/packages/bundler-utils/src/query.ts index 6559fdc..3404281 100644 --- a/packages/bundler-utils/src/query.ts +++ b/packages/bundler-utils/src/query.ts @@ -54,7 +54,7 @@ export function parseResourceQuery(resourceQuery: string): QueryOptions { case 'width': { const width = Number(value) - if (Number.isFinite(width)) { + if (width > 0) { query.select = { ...query.select, width From 073896201aafbc9e2c706309b85ae1ed9ed7bdd6 Mon Sep 17 00:00:00 2001 From: dangreen Date: Tue, 21 Jul 2026 17:27:31 +0400 Subject: [PATCH 5/7] docs(bundler-utils): clarify resource id and entry docs --- packages/bundler-utils/src/module.types.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/bundler-utils/src/module.types.ts b/packages/bundler-utils/src/module.types.ts index e1ca20e..f6c0327 100644 --- a/packages/bundler-utils/src/module.types.ts +++ b/packages/bundler-utils/src/module.types.ts @@ -4,15 +4,16 @@ import type { SrcSetBackendImage } from './types.ts' /** * Resource id formatter function. * @param width - Actual width of the image variant in pixels. - * @param requestedWidth - Width as it was requested: absolute value or multiplier less than or equal to 1. + * @param requestedWidth - Multiplier when the variant was requested with one, otherwise the actual width. * @param format - Image variant format. * @returns Resource id. */ export type ResourceIdFormatter = (width: number, requestedWidth: number, format: ImageFormat) => string /** - * Generated image variant entry of the module: the backend image - * plus the resource id and the mime type, the runtime `SrcSetEntry` shape. + * Generated image variant entry of the module: the backend image plus + * the resource id and the mime type. Serialized to the runtime + * `SrcSetEntry` shape in the module code. */ export interface SrcSetModuleEntry extends SrcSetBackendImage { /** From 1fd8b6e8486c1947309be1f29a49b63efa8b3222 Mon Sep 17 00:00:00 2001 From: dangreen Date: Tue, 21 Jul 2026 17:39:43 +0400 Subject: [PATCH 6/7] fix(bundler-utils): skip empty select query values --- packages/bundler-utils/src/query.ts | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/packages/bundler-utils/src/query.ts b/packages/bundler-utils/src/query.ts index 3404281..35ea138 100644 --- a/packages/bundler-utils/src/query.ts +++ b/packages/bundler-utils/src/query.ts @@ -38,17 +38,23 @@ export function parseResourceQuery(resourceQuery: string): QueryOptions { switch (key) { case 'id': - query.select = { - ...query.select, - id: value + if (value) { + query.select = { + ...query.select, + id: value + } } + break case 'format': - query.select = { - ...query.select, - format: value + if (value) { + query.select = { + ...query.select, + format: value + } } + break case 'width': { From 6279d1240a20e232d28e83b869198d79f7e6ac72 Mon Sep 17 00:00:00 2001 From: dangreen Date: Tue, 21 Jul 2026 18:05:59 +0400 Subject: [PATCH 7/7] fix(bundler-utils): partial select matching and placeholder width validation --- packages/bundler-utils/src/module.ts | 13 ++++++++++--- packages/bundler-utils/src/placeholder.ts | 5 +++++ 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/packages/bundler-utils/src/module.ts b/packages/bundler-utils/src/module.ts index 32b4add..62cbd2d 100644 --- a/packages/bundler-utils/src/module.ts +++ b/packages/bundler-utils/src/module.ts @@ -45,13 +45,20 @@ function findDefaultIndex(select: SrcSetEntrySelect, srcSet: SrcSetModuleEntry[] return true } - if (entry.format !== select.format) { + // An undefined select field is a wildcard, an empty select falls back to the first variant. + if (select.format === undefined && select.width === undefined) { return false } - const isMultiplier = typeof select.width === 'number' && select.width <= 1 + if (select.format !== undefined && entry.format !== select.format) { + return false + } + + if (select.width === undefined) { + return true + } - if (isMultiplier) { + if (select.width <= 1) { return entry.originMultiplier === select.width } diff --git a/packages/bundler-utils/src/placeholder.ts b/packages/bundler-utils/src/placeholder.ts index ca5ecab..05011a2 100644 --- a/packages/bundler-utils/src/placeholder.ts +++ b/packages/bundler-utils/src/placeholder.ts @@ -41,6 +41,11 @@ export async function createPlaceholder( width = defaultWidth, format = defaultFormat } = options === true ? {} : options + + if (width <= 0 || !Number.isInteger(width)) { + throw new Error(`Invalid placeholder width: ${String(width)}`) + } + const contents = await limit(() => { const pipeline = sharp(source.contents).resize({ width,