Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 62 additions & 0 deletions packages/bundler-utils/README.md
Original file line number Diff line number Diff line change
@@ -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/).
12 changes: 12 additions & 0 deletions packages/bundler-utils/oxlint.config.ts
Original file line number Diff line number Diff line change
@@ -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
]
})
73 changes: 73 additions & 0 deletions packages/bundler-utils/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
201 changes: 201 additions & 0 deletions packages/bundler-utils/src/generate.spec.ts
Original file line number Diff line number Diff line change
@@ -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";')
})
})
})
})
Loading