From 79d1646c8e9230f6b753af11a7aa83ac6081e757 Mon Sep 17 00:00:00 2001 From: dangreen Date: Wed, 5 Aug 2026 18:23:58 +0400 Subject: [PATCH] feat(core): disk cache storage for generated variants --- packages/core/src/cache.spec.ts | 262 ++++++++++++++++++++++++++++ packages/core/src/cache.ts | 244 ++++++++++++++++++++++++++ packages/core/src/cache.utils.ts | 48 +++++ packages/core/src/cache.version.ts | 15 ++ packages/core/src/generator.spec.ts | 34 ++++ packages/core/src/generator.ts | 84 ++++----- packages/core/src/index.ts | 1 + packages/core/src/path.ts | 83 +++++++++ packages/core/src/types.ts | 60 ++++++- 9 files changed, 790 insertions(+), 41 deletions(-) create mode 100644 packages/core/src/cache.spec.ts create mode 100644 packages/core/src/cache.ts create mode 100644 packages/core/src/cache.utils.ts create mode 100644 packages/core/src/cache.version.ts diff --git a/packages/core/src/cache.spec.ts b/packages/core/src/cache.spec.ts new file mode 100644 index 0000000..66f2155 --- /dev/null +++ b/packages/core/src/cache.spec.ts @@ -0,0 +1,262 @@ +import { + describe, + it, + expect, + vi +} from 'vitest' +import { + mkdtemp, + readdir, + rm +} from 'node:fs/promises' +import { tmpdir } from 'node:os' +import path from 'node:path' +import type { + GenerateContext, + SrcSetImage +} from './types.ts' +import { SrcSetCacheStorage } from './cache.ts' + +function createContext(contents = 'source'): GenerateContext { + return { + source: { + path: '/images/image.jpg', + contents: Buffer.from(contents) + }, + metadata: { + format: 'jpg', + width: 640, + height: 480, + animated: false + }, + processing: {}, + optimization: {}, + postfix: '', + skipOptimization: true, + scalingUp: true + } +} + +function createImage(): SrcSetImage { + return { + path: '/images/image@320w.webp', + contents: Buffer.from('variant'), + format: 'webp', + width: 320, + height: 240, + postfix: '@320w', + originMultiplier: 0.5 + } +} + +async function createStorage() { + const dir = await mkdtemp(path.join(tmpdir(), 'srcset-storage-')) + + return { + dir, + storage: new SrcSetCacheStorage(dir) + } +} + +describe('core', () => { + describe('cache', () => { + describe('SrcSetCacheStorage', () => { + describe('memo', () => { + it('should generate on miss and read back on hit', async () => { + const { + dir, + storage + } = await createStorage() + const context = createContext() + const variant = { + format: 'webp' as const, + width: 0.5 + } + const image = createImage() + const fn = vi.fn(() => Promise.resolve(image)) + const generated = await storage.memo(context, variant, fn) + + expect(fn).toHaveBeenCalledTimes(1) + expect(generated).toEqual(image) + + const cached = await new SrcSetCacheStorage(dir).memo(context, variant, fn) + + expect(fn).toHaveBeenCalledTimes(1) + expect(cached).toEqual(image) + }) + + it('should miss on different variant or source', async () => { + const { storage } = await createStorage() + const variant = { + format: 'webp' as const, + width: 0.5 + } + const fn = vi.fn(() => Promise.resolve(createImage())) + + await storage.memo(createContext(), variant, fn) + await storage.memo(createContext(), { + ...variant, + width: 0.25 + }, fn) + await storage.memo(createContext('other'), variant, fn) + + expect(fn).toHaveBeenCalledTimes(3) + }) + + it('should miss on the same contents under a different source path', async () => { + const { storage } = await createStorage() + const variant = { + format: 'webp' as const, + width: 0.5 + } + const fn = vi.fn(() => Promise.resolve(createImage())) + const moved = createContext() + + moved.source.path = '/other/image.jpg' + + await storage.memo(createContext(), variant, fn) + await storage.memo(moved, variant, fn) + + expect(fn).toHaveBeenCalledTimes(2) + }) + + it('should miss when the stored file is overwritten by a colliding name', async () => { + const { storage } = await createStorage() + const context = createContext() + const variant = { + format: 'webp' as const, + width: 0.5 + } + const fn = vi.fn(() => Promise.resolve(createImage())) + + await storage.memo(context, variant, fn) + await storage.write(storage.getKey(context, variant).path, Buffer.from('other')) + await storage.memo(context, variant, fn) + + expect(fn).toHaveBeenCalledTimes(2) + }) + + it('should not store skipped variants', async () => { + const { + dir, + storage + } = await createStorage() + const context = createContext() + const variant = { + format: 'webp' as const, + width: 2000 + } + const fn = vi.fn(() => Promise.resolve(null)) + + expect(await storage.memo(context, variant, fn)).toBeNull() + expect(await storage.memo(context, variant, fn)).toBeNull() + expect(fn).toHaveBeenCalledTimes(2) + expect(await readdir(dir)).toHaveLength(0) + }) + + it('should regenerate when stored files are cleaned away', async () => { + const { + dir, + storage + } = await createStorage() + const context = createContext() + const variant = { + format: 'webp' as const, + width: 0.5 + } + const fn = vi.fn(() => Promise.resolve(createImage())) + + await storage.memo(context, variant, fn) + + const files = (await readdir(dir)).filter(name => !name.endsWith('.json')) + + await Promise.all(files.map(name => rm(path.join(dir, name)))) + + const regenerated = await storage.memo(context, variant, fn) + + expect(fn).toHaveBeenCalledTimes(2) + expect(regenerated).toEqual(createImage()) + }) + }) + + describe('getKey', () => { + it('should derive both address parts from the inputs', async () => { + const { storage } = await createStorage() + const context = createContext() + const variant = { + format: 'webp' as const, + width: 0.5 + } + const address = storage.getKey(context, variant) + + expect(address.key).toMatch(/^[0-9a-f]{64}$/) + expect(address.path).toBe('image.webp') + expect(storage.getKey(context, variant)).toEqual(address) + }) + + it('should use the source file name for the svg passthrough', async () => { + const { storage } = await createStorage() + + expect(storage.getKey(createContext(), null).path).toBe('image.jpg') + }) + }) + + describe('write', () => { + it('should overwrite an existing file', async () => { + const { storage } = await createStorage() + const path = await storage.write('image.webp', Buffer.from('variant')) + const updated = Buffer.from('other') + + await storage.write(path, updated) + + expect(await storage.read(path)).toEqual(updated) + }) + }) + + describe('paths', () => { + it('should reject paths outside the storage directory', async () => { + const { storage } = await createStorage() + + await expect(storage.write('../outside.webp', Buffer.from('x'))).rejects.toThrow('Invalid stored file path') + await expect(storage.write('..', Buffer.from('x'))).rejects.toThrow('Invalid stored file path') + await expect(storage.read('../outside.webp')).rejects.toThrow('Invalid stored file path') + expect(() => storage.readStream('sub/dir.webp')).toThrow('Invalid stored file path') + }) + + it('should accept flat names with inner dots', async () => { + const { storage } = await createStorage() + const contents = Buffer.from('dots') + + await storage.write('image..webp', contents) + + expect(await storage.read('image..webp')).toEqual(contents) + }) + }) + + describe('read', () => { + it('should read stored contents back', async () => { + const { storage } = await createStorage() + const image = createImage() + const path = await storage.write('image.webp', image.contents) + + expect(await storage.read(path)).toEqual(image.contents) + }) + }) + + describe('readStream', () => { + it('should stream stored contents', async () => { + const { storage } = await createStorage() + const image = createImage() + const path = await storage.write('image.webp', image.contents) + const chunks: Buffer[] = [] + + for await (const chunk of storage.readStream(path)) { + chunks.push(chunk as Buffer) + } + + expect(Buffer.concat(chunks)).toEqual(image.contents) + }) + }) + }) + }) +}) diff --git a/packages/core/src/cache.ts b/packages/core/src/cache.ts new file mode 100644 index 0000000..9def819 --- /dev/null +++ b/packages/core/src/cache.ts @@ -0,0 +1,244 @@ +import { createHash } from 'node:crypto' +import { createReadStream } from 'node:fs' +import { + mkdir, + readFile, + rename, + rm, + writeFile +} from 'node:fs/promises' +import { + join, + parse +} from 'node:path' +import type { ImageFormat } from './formats.ts' +import type { + GenerateContext, + ImageVariant, + SrcSetImage +} from './types.ts' +import { resolveVariant } from './path.ts' +import { + assertStoredPath, + getTemporaryName, + getContentsHash, + serialize +} from './cache.utils.ts' +import { environment } from './cache.version.ts' + +/** + * Address of a cached variant: the manifest key and the stored file path. + */ +export interface CacheAddress { + /** + * Manifest key of the variant. + */ + key: string + /** + * Stored file path of the variant: the variant file name. + */ + path: string +} + +interface CacheEntry { + path: string + hash: string + format: ImageFormat + width: number + height: number + postfix: string + originMultiplier: number | null +} + +/** + * Disk storage of the generated image variants. + * + * `memo` skips repeated generation: the variant is stored on disk + * together with its manifest, and the repeated generation with the same + * source, options and variant reads it back instead of processing. + * Function options, like custom optimizers, are keyed by their source text. + * The stored files are named by the variant file name from `SrcSetImage.path`, + * and can be read back with `read` and `readStream`. + */ +export class SrcSetCacheStorage { + private readonly dir: string + + constructor(dir: string) { + this.dir = dir + } + + /** + * Make a cache address for the variant: both parts are derived + * from the generation inputs, so they are known before generating. + * @param context - Generation inputs. + * @param variant - Variant to generate, `null` for the SVG passthrough. + * @returns Manifest key and stored file path of the variant. + */ + getKey(context: GenerateContext, variant: ImageVariant | null): CacheAddress { + const { + source, + processing, + optimization, + postfix, + skipOptimization, + scalingUp + } = context + const key = createHash('sha256') + .update(environment) + .update(source.contents) + .update(serialize({ + // Posix separators, so the keys are stable across platforms. + path: source.path.replaceAll('\\', '/'), + variant, + processing, + optimization, + postfix, + skipOptimization, + scalingUp + })) + .digest('hex') + + return { + key, + path: parse(resolveVariant(context, variant).path).base + } + } + + private async readEntry(address: CacheAddress): Promise { + try { + const [entry, contents] = await Promise.all([ + this.read(`${address.key}.json`, 'utf8') + .then(manifest => JSON.parse(manifest) as CacheEntry), + this.read(address.path) + ]) + + // Variant names are not unique across sources and options: + // a file overwritten by a colliding name is a miss. + if (entry.hash !== getContentsHash(contents)) { + return null + } + + return { + path: entry.path, + contents, + format: entry.format, + width: entry.width, + height: entry.height, + postfix: entry.postfix, + originMultiplier: entry.originMultiplier + } + } catch { + // No entry, or a stored file was cleaned away: generate. + return null + } + } + + private async writeEntry(address: CacheAddress, image: SrcSetImage) { + const entry: CacheEntry = { + path: image.path, + hash: getContentsHash(image.contents), + format: image.format, + width: image.width, + height: image.height, + postfix: image.postfix, + originMultiplier: image.originMultiplier + } + + // Partial states are safe to write in parallel: a manifest without + // its file is a read miss with regeneration. + await Promise.all([ + this.write(address.path, image.contents), + this.write(`${address.key}.json`, JSON.stringify(entry)) + ]) + } + + /** + * Memoize the variant generation: read the stored variant, + * or generate and store it. + * @param context - Generation inputs. + * @param variant - Variant to generate, `null` for the SVG passthrough. + * @param fn - Variant generator function. + * @returns Generated image variant, or `null` if the variant is skipped. + */ + async memo( + context: GenerateContext, + variant: ImageVariant | null, + fn: () => Promise + ): Promise { + const address = this.getKey(context, variant) + const cached = await this.readEntry(address) + + if (cached) { + return cached + } + + const image = await fn() + + if (image) { + await this.writeEntry(address, image) + } + + return image + } + + /** + * Write contents to the storage. An existing file is overwritten: + * variant names are not unique across option changes, + * stale contents must not survive. + * @param path - Stored file path. + * @param contents - File contents. + * @returns Stored file path. + */ + async write(path: string, contents: Buffer | string) { + assertStoredPath(path) + await mkdir(this.dir, { + recursive: true + }) + + // Write to a temporary file and rename: renames are atomic within + // the directory, so a concurrent reader never sees partial contents. + const temporaryPath = join(this.dir, getTemporaryName(path)) + + try { + await writeFile(temporaryPath, contents) + await rename(temporaryPath, join(this.dir, path)) + } catch (error) { + // Cleanup failures are secondary: keep the original write error. + try { + await rm(temporaryPath, { + force: true + }) + } catch {} + + throw error + } + + return path + } + + async read(path: string): Promise + async read(path: string, encoding: BufferEncoding): Promise + + /** + * Read the stored file contents. + * @param path - Stored file path from the cache address. + * @param encoding - Text encoding to decode the contents with. + * @returns File contents: a buffer, or a string when the encoding is set. + */ + async read(path: string, encoding?: BufferEncoding): Promise { + assertStoredPath(path) + + return readFile(join(this.dir, path), encoding) + } + + /** + * Create a read stream of the stored file. + * @param path - Stored file path from the cache address. + * @returns Readable stream of the file contents. + */ + readStream(path: string) { + assertStoredPath(path) + + return createReadStream(join(this.dir, path)) + } +} diff --git a/packages/core/src/cache.utils.ts b/packages/core/src/cache.utils.ts new file mode 100644 index 0000000..911a894 --- /dev/null +++ b/packages/core/src/cache.utils.ts @@ -0,0 +1,48 @@ +import { + createHash, + randomBytes +} from 'node:crypto' + +const temporarySuffixLength = 4 + +/** + * Hash the contents to verify a stored file against its manifest. + * @param contents - File contents. + * @returns Hex digest of the contents. + */ +export function getContentsHash(contents: Buffer) { + return createHash('sha256').update(contents).digest('hex') +} + +/** + * Make a unique temporary name for the atomic write. + * @param path - Target stored file path. + * @returns Temporary file name. + */ +export function getTemporaryName(path: string) { + return `${path}.${randomBytes(temporarySuffixLength).toString('hex')}.tmp` +} + +/** + * Assert the stored file path is flat: storage paths are plain file names, + * anything else would escape the storage directory. + * @param path - Stored file path. + */ +export function assertStoredPath(path: string) { + if (!path || path === '.' || path === '..' || path.includes('/') || path.includes('\\')) { + throw new Error(`Invalid stored file path: "${path}"`) + } +} + +/** + * Serialize a value for the cache key. + * Functions, like custom optimizers, are serialized by their source text. + * @param value - Value to serialize. + * @returns Serialized value. + */ +export function serialize(value: unknown) { + return JSON.stringify( + value, + (_, item: unknown) => (typeof item === 'function' ? String(item) : item) + ) +} diff --git a/packages/core/src/cache.version.ts b/packages/core/src/cache.version.ts new file mode 100644 index 0000000..ba64648 --- /dev/null +++ b/packages/core/src/cache.version.ts @@ -0,0 +1,15 @@ +import sharp from 'sharp' + +// Bump on any change that affects the generated output +// or the manifest format, beyond the sharp versions below. +const cacheVersion = 1 + +/** + * Environment part of the cache key: the storage version + * and the sharp versions, so encoder upgrades producing + * different output invalidate the cache. + */ +export const environment = JSON.stringify({ + cacheVersion, + versions: sharp.versions +}) diff --git a/packages/core/src/generator.spec.ts b/packages/core/src/generator.spec.ts index b9441d8..ea43650 100644 --- a/packages/core/src/generator.spec.ts +++ b/packages/core/src/generator.spec.ts @@ -3,7 +3,14 @@ import { it, expect } from 'vitest' +import { + mkdtemp, + readdir +} from 'node:fs/promises' +import { tmpdir } from 'node:os' +import path from 'node:path' import sharp from 'sharp' +import { SrcSetCacheStorage } from './cache.ts' import { SrcSetGenerator } from './generator.ts' import type { ImageSource, @@ -460,6 +467,33 @@ describe('core', () => { expect(images.length).toBe(3) }) }) + + describe('cache', () => { + it('should reuse stored variants between generators', async () => { + const dir = await mkdtemp(path.join(tmpdir(), 'srcset-generator-cache-')) + const cache = new SrcSetCacheStorage(dir) + const image = await createImage('jpg') + const options: GenerateOptions = { + width: [1, 0.5], + format: ['webp', 'jpg'] + } + const generated = await generateAll(new SrcSetGenerator({ + skipOptimization: true, + cache + }), image, options) + const cached = await generateAll(new SrcSetGenerator({ + skipOptimization: true, + cache + }), image, options) + + expect(generated.length).toBe(4) + expect(cached).toEqual(generated) + + const files = (await readdir(dir)).filter(name => !name.endsWith('.json')) + + expect(files.length).toBe(4) + }) + }) }) }) }) diff --git a/packages/core/src/generator.ts b/packages/core/src/generator.ts index ff004ad..1b2dbce 100644 --- a/packages/core/src/generator.ts +++ b/packages/core/src/generator.ts @@ -4,15 +4,17 @@ import type { Sharp } from 'sharp' import pLimit from 'p-limit' import sharp from 'sharp' import type { ImageFormat } from './formats.ts' +import type { SrcSetCacheStorage } from './cache.ts' import type { ImageSource, - ImageMetadata, + ImageVariant, SrcSetImage, ProcessingOptions, OptimizationOptions, Postfix, SrcSetGeneratorOptions, - GenerateOptions + GenerateOptions, + GenerateContext } from './types.ts' import { isSupportedFormat, @@ -24,30 +26,15 @@ import { mergeProcessingOptions } from './defaults.ts' import { getImageMetadata } from './metadata.ts' -import { renameImagePath } from './path.ts' +import { resolveVariant } from './path.ts' import { parallel } from './parallel.ts' -interface Variant { - format: ImageFormat - width: number -} - -interface GenerateContext { - source: ImageSource - metadata: ImageMetadata - processing: ProcessingOptions - optimization: OptimizationOptions - postfix: Postfix - skipOptimization: boolean - scalingUp: boolean -} - const animatableFormats = new Set(['gif', 'webp']) function createVariants(formats: ImageFormat[], widths: number[]) { const uniqueFormats = new Set(formats) const uniqueWidths = new Set(widths) - const variants: Variant[] = [] + const variants: ImageVariant[] = [] for (const format of uniqueFormats) { for (const width of uniqueWidths) { @@ -74,14 +61,6 @@ function validateWidths(widths: number[]) { }) } -function formatPostfix(postfix: Postfix, width: number, requestedWidth: number, format: ImageFormat) { - if (typeof postfix === 'string') { - return postfix - } - - return postfix(width, requestedWidth, format) -} - function applyFormat(pipeline: Sharp, format: Exclude, processing: ProcessingOptions) { switch (format) { case 'avif': @@ -116,6 +95,7 @@ export class SrcSetGenerator { private readonly scalingUp: boolean private readonly postfix: Postfix private readonly limit: LimitFunction + private readonly cache?: SrcSetCacheStorage constructor(options: SrcSetGeneratorOptions = {}) { this.processing = mergeProcessingOptions(defaultProcessing, options.processing) @@ -126,6 +106,7 @@ export class SrcSetGenerator { this.scalingUp = options.scalingUp ?? true this.postfix = options.postfix ?? defaultPostfix this.limit = options.limit ?? pLimit(options.concurrency ?? availableParallelism()) + this.cache = options.cache } /** @@ -134,7 +115,10 @@ export class SrcSetGenerator { * @param options - Image handle options. * @yields Generated image variants. */ - async* generate(source: ImageSource, options: GenerateOptions = {}): AsyncGenerator { + async* generate( + source: ImageSource, + options: GenerateOptions = {} + ): AsyncGenerator { if (typeof source.path !== 'string' || !Buffer.isBuffer(source.contents)) { throw new TypeError('Invalid source: path string and contents buffer are required.') } @@ -185,7 +169,7 @@ export class SrcSetGenerator { if (metadata.format === 'svg') { if (formats.includes('svg')) { - yield await this.processSvg(context) + yield await this.memo(context, null, () => this.processSvg(context)) } return @@ -195,11 +179,26 @@ export class SrcSetGenerator { yield* parallel( variants, - variant => this.processVariant(variant, context), + variant => this.memo(context, variant, () => this.processVariant(context, variant)), this.limit ) } + /** + * Memoize the variant generation in the cache storage, when configured. + * @param context - Generate context. + * @param variant - Variant to generate, `null` for the SVG passthrough. + * @param fn - Variant generator function. + * @returns Generated image variant, or `null` if the variant is skipped. + */ + private async memo( + context: GenerateContext, + variant: ImageVariant | null, + fn: () => Promise + ): Promise { + return this.cache ? this.cache.memo(context, variant, fn) : fn() + } + /** * Pass SVG image through, optionally applying a custom optimizer. * @param context - Generate context. @@ -225,11 +224,14 @@ export class SrcSetGenerator { /** * Resize, convert and optimize the image variant. - * @param variant - Format and width of the variant. * @param context - Generate context. + * @param variant - Format and width of the variant. * @returns Image variant, or `null` if the variant should be skipped. */ - private async processVariant(variant: Variant, context: GenerateContext): Promise { + private async processVariant( + context: GenerateContext, + variant: ImageVariant + ): Promise { const { format, width @@ -245,18 +247,18 @@ export class SrcSetGenerator { metadata } = context const isMultiplier = width <= 1 - const requestedWidth = isMultiplier ? Math.ceil(width * metadata.width) : width + const { + requestedWidth, + targetWidth, + postfix, + path + } = resolveVariant(context, variant) if (!context.scalingUp && requestedWidth > metadata.width) { return null } - // Pixels are never upscaled, so the variant width is capped by the original width - // and the postfix is built from the actual output width. - const targetWidth = Math.min(requestedWidth, metadata.width) const willResize = targetWidth < metadata.width - const postfix = formatPostfix(context.postfix, targetWidth, width, format) - const path = renameImagePath(source.path, postfix, format) const passthrough = !willResize && format === metadata.format && context.skipOptimization let contents: Buffer let outputWidth = metadata.width @@ -308,7 +310,11 @@ export class SrcSetGenerator { * @param context - Generate context. * @returns Optimized image contents. */ - private async optimizeImage(contents: Buffer, format: ImageFormat, context: GenerateContext) { + private async optimizeImage( + contents: Buffer, + format: ImageFormat, + context: GenerateContext + ) { const optimize = context.optimization[format] if (context.skipOptimization || !optimize) { diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 8b7b70a..368dad8 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -5,4 +5,5 @@ export * from './path.ts' export * from './metadata.ts' export * from './match.ts' export * from './parallel.ts' +export * from './cache.ts' export * from './generator.ts' diff --git a/packages/core/src/path.ts b/packages/core/src/path.ts index 7acb8d1..bfaad8c 100644 --- a/packages/core/src/path.ts +++ b/packages/core/src/path.ts @@ -1,5 +1,10 @@ import path from 'node:path/posix' import type { ImageFormat } from './formats.ts' +import type { + GenerateContext, + ImageVariant, + Postfix +} from './types.ts' /** * Add postfix and format extension to the image file path. @@ -21,3 +26,81 @@ export function renameImagePath(imagePath: string, postfix: string, format: Imag ext: `.${format}` }) } + +/** + * Format the postfix for the variant. + * @param postfix - Postfix string or formatter function. + * @param width - Actual width of the variant in pixels. + * @param requestedWidth - Width as it was requested. + * @param format - Image variant format. + * @returns Postfix string. + */ +export function formatPostfix(postfix: Postfix, width: number, requestedWidth: number, format: ImageFormat) { + if (typeof postfix === 'string') { + return postfix + } + + return postfix(width, requestedWidth, format) +} + +/** + * Resolved widths and path of a variant to generate. + */ +export interface ResolvedVariant { + /** + * Width as it was requested, in pixels. + */ + requestedWidth: number + /** + * Actual output width: pixels are never upscaled, so the requested + * width is capped by the original width. + */ + targetWidth: number + /** + * Postfix built from the actual output width. + */ + postfix: string + /** + * Image variant file path. + */ + path: string +} + +/** + * Resolve the variant widths and path from the generation inputs, + * before generating. The SVG passthrough keeps the source path. + * @param context - Generation inputs. + * @param variant - Variant to generate, `null` for the SVG passthrough. + * @returns Resolved variant. + */ +export function resolveVariant(context: GenerateContext, variant: ImageVariant | null): ResolvedVariant { + const { + source, + metadata + } = context + + if (!variant) { + return { + requestedWidth: metadata.width, + targetWidth: metadata.width, + postfix: '', + path: source.path.replaceAll('\\', '/') + } + } + + const { + format, + width + } = variant + const isMultiplier = width <= 1 + const requestedWidth = isMultiplier ? Math.ceil(width * metadata.width) : width + const targetWidth = Math.min(requestedWidth, metadata.width) + const postfix = formatPostfix(context.postfix, targetWidth, width, format) + + return { + requestedWidth, + targetWidth, + postfix, + path: renameImagePath(source.path, postfix, format) + } +} diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 3a1588d..858f578 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -7,6 +7,7 @@ import type { } from 'sharp' import type { LimitFunction } from 'p-limit' import type { ImageFormat } from './formats.ts' +import type { SrcSetCacheStorage } from './cache.ts' /** * Source image file. @@ -113,7 +114,9 @@ export type ImageOptimizer = (contents: Buffer, format: ImageFormat) => Buffer | export type OptimizationOptions = Partial> /** - * Postfix formatter function. + * Postfix formatter function. Must be pure: it is invoked repeatedly + * for the same variant - including for variants that end up skipped - + * and its output participates in the cache addressing. * @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. @@ -155,9 +158,14 @@ export interface SrcSetGeneratorOptions { * p-limit's limit function, e.g. to share one limit between several generators. */ limit?: LimitFunction + /** + * Disk cache storage: repeated generation reads the stored variants + * instead of processing. + */ + cache?: SrcSetCacheStorage } -export interface GenerateOptions extends Omit { +export interface GenerateOptions extends Omit { /** * Output image format(s) to convert. Defaults to the source image format. */ @@ -167,3 +175,51 @@ export interface GenerateOptions extends Omit