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
8 changes: 7 additions & 1 deletion packages/bundler-utils/src/generate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,13 @@ 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, limit)
const placeholder = await createPlaceholder(
source,
metadata,
query.placeholder ?? options.placeholder,
limit,
options.cache
)
const select = {
format: metadata.format,
width: metadata.width,
Expand Down
88 changes: 70 additions & 18 deletions packages/bundler-utils/src/placeholder.spec.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,17 @@
import {
describe,
it,
expect
expect,
vi
} from 'vitest'
import { mkdtemp } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import path from 'node:path'
import sharp from 'sharp'
import {
SrcSetCacheStorage,
getImageMetadata
} from '@srcset/core'
import { createPlaceholder } from './placeholder.ts'

async function createImage(width = 640, height = 480) {
Expand All @@ -22,52 +30,96 @@ async function createImage(width = 640, height = 480) {
}
}

async function createFixture(width = 640, height = 480) {
const image = await createImage(width, height)

return {
image,
metadata: await getImageMetadata(image)
}
}

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)
const {
image,
metadata
} = await createFixture()
const placeholder = await createPlaceholder(image, metadata, true)

expect(placeholder).toMatch(/^data:image\/webp;base64,/)

const decoded = Buffer.from((placeholder as string).split(',')[1], 'base64')
const metadata = await sharp(decoded).metadata()
const decodedMetadata = await sharp(decoded).metadata()

expect(metadata.format).toBe('webp')
expect(metadata.width).toBe(16)
expect(decodedMetadata.format).toBe('webp')
expect(decodedMetadata.width).toBe(16)
})

it('should respect width and format options', async () => {
const image = await createImage()
const placeholder = await createPlaceholder(image, {
const {
image,
metadata
} = await createFixture()
const placeholder = await createPlaceholder(image, metadata, {
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()
const decodedMetadata = await sharp(decoded).metadata()

expect(metadata.format).toBe('jpeg')
expect(metadata.width).toBe(8)
expect(decodedMetadata.format).toBe('jpeg')
expect(decodedMetadata.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 {
image,
metadata
} = await createFixture(8, 6)
const placeholder = await createPlaceholder(image, metadata, true)
const decoded = Buffer.from((placeholder as string).split(',')[1], 'base64')
const metadata = await sharp(decoded).metadata()
const decodedMetadata = await sharp(decoded).metadata()

expect(metadata.width).toBe(8)
expect(decodedMetadata.width).toBe(8)
})

it('should return undefined when disabled', async () => {
const image = await createImage()
const {
image,
metadata
} = await createFixture()

expect(await createPlaceholder(image, metadata, undefined)).toBeUndefined()
expect(await createPlaceholder(image, metadata, false)).toBeUndefined()
})

it('should reuse the stored placeholder from the cache', async () => {
const cache = new SrcSetCacheStorage(await mkdtemp(path.join(tmpdir(), 'srcset-placeholder-')))
const {
image,
metadata
} = await createFixture()
const limit = vi.fn((task: () => Promise<Buffer>) => task())
const placeholder = await createPlaceholder(image, metadata, true, limit, cache)

expect(limit).toHaveBeenCalledTimes(1)

const cached = await createPlaceholder(image, metadata, true, limit, cache)

expect(limit).toHaveBeenCalledTimes(1)
expect(cached).toBe(placeholder)

await createPlaceholder(image, metadata, {
width: 8
}, limit, cache)

expect(await createPlaceholder(image, undefined)).toBeUndefined()
expect(await createPlaceholder(image, false)).toBeUndefined()
expect(limit).toHaveBeenCalledTimes(2)
})
})
})
Expand Down
68 changes: 59 additions & 9 deletions packages/bundler-utils/src/placeholder.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,36 @@
import sharp from 'sharp'
import {
type GenerateContext,
type ImageMetadata,
type ImageSource,
mimeTypes
type SrcSetCacheStorage,
type SrcSetImage,
mimeTypes,
renameImagePath
} from '@srcset/core'

const defaultWidth = 16
const defaultFormat = 'webp'
const placeholderPostfix = '.placeholder'

/**
* Make a generate context for the placeholder variant: constant inputs,
* distinct from the regular variants by the postfix.
* @param source - Image file.
* @param metadata - Image metadata.
* @returns Generate context.
*/
function createPlaceholderContext(source: ImageSource, metadata: ImageMetadata): GenerateContext {
return {
source,
metadata,
processing: {},
optimization: {},
postfix: placeholderPostfix,
skipOptimization: true,
scalingUp: false
}
}

/**
* Options of the `placeholder` module export.
Expand All @@ -24,14 +49,18 @@ export interface PlaceholderOptions {
/**
* Create a tiny data-url variant of the image for blur-up placeholders.
* @param source - Image file.
* @param metadata - Image metadata.
* @param options - Placeholder options, `true` for the defaults.
* @param limit - Concurrency limit for the sharp work.
* @param cache - Cache storage: repeated creation reads the stored placeholder.
* @returns Data-url string, or `undefined` when disabled.
*/
export async function createPlaceholder(
source: ImageSource,
metadata: ImageMetadata,
options: PlaceholderOptions | boolean | undefined,
limit: (task: () => Promise<Buffer>) => Promise<Buffer> = task => task()
limit: (task: () => Promise<Buffer>) => Promise<Buffer> = task => task(),
cache?: SrcSetCacheStorage
): Promise<string | undefined> {
if (!options) {
return undefined
Expand All @@ -46,14 +75,35 @@ export async function createPlaceholder(
throw new Error(`Invalid placeholder width: ${String(width)}`)
}

const contents = await limit(() => {
const pipeline = sharp(source.contents).resize({
width,
withoutEnlargement: true
const createImage = async (): Promise<SrcSetImage> => {
const contents = await limit(() => {
const pipeline = sharp(source.contents).resize({
width,
withoutEnlargement: true
})

return (format === 'webp' ? pipeline.webp() : pipeline.jpeg()).toBuffer()
})
// Informational dimensions: the data-url uses the contents only,
// so sharp's own rounding of the height is not worth a second pass.
const outputWidth = Math.min(width, metadata.width)

return (format === 'webp' ? pipeline.webp() : pipeline.jpeg()).toBuffer()
})
return {
path: renameImagePath(source.path, placeholderPostfix, format),
contents,
format,
width: outputWidth,
height: Math.round(metadata.height * outputWidth / metadata.width),
postfix: placeholderPostfix,
originMultiplier: null
}
}
const image = cache
? await cache.memo(createPlaceholderContext(source, metadata), {
format,
width
}, createImage)
: await createImage()

return `data:${mimeTypes[format]};base64,${contents.toString('base64')}`
return `data:${mimeTypes[format]};base64,${image.contents.toString('base64')}`
}
8 changes: 6 additions & 2 deletions packages/bundler-utils/src/query.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,9 +55,13 @@ describe('bundler-utils', () => {
expect(parseResourceQuery('?srcset&unknown=1')).toEqual({})
})

it('should return empty options for query without question mark', () => {
it('should parse query without the leading question mark', () => {
expect(parseResourceQuery('')).toEqual({})
expect(parseResourceQuery('srcset')).toEqual({})
expect(parseResourceQuery('width=320')).toEqual({
select: {
width: 320
}
})
})
})
})
Expand Down
7 changes: 4 additions & 3 deletions packages/bundler-utils/src/query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,17 +13,18 @@ export interface QueryOptions {
* - `{ "width": [1, 0.5], "format": ["webp", "jpg"] }` - JSON rule to generate variants;
* - `id=<id>`, `format=<format>`, `width=<width>` - selection of the variant for the default export;
* - `placeholder` - add the `placeholder` module export.
* @param resourceQuery - Resource query string starting with `?`.
* @param resourceQuery - Resource query string, with or without the leading `?`.
* @returns Parsed options.
*/
export function parseResourceQuery(resourceQuery: string): QueryOptions {
const query: QueryOptions = {}
const pairs = resourceQuery.startsWith('?') ? resourceQuery.slice(1) : resourceQuery

if (!resourceQuery.startsWith('?')) {
if (!pairs) {
return query
}

for (const pair of resourceQuery.slice(1).split('&')) {
for (const pair of pairs.split('&')) {
if (pair.startsWith('{')) {
try {
query.rules = [JSON.parse(pair) as SrcSetRule]
Expand Down
Loading