UNPKG

vite-imagetools

Version:

Load and transform images using a toolbox of import directives!

237 lines (233 loc) 12.8 kB
import { basename, extname } from 'node:path'; import { relative } from 'node:path/posix'; import { mkdirSync, createReadStream, statSync } from 'node:fs'; import { opendir, stat, rm, readFile, writeFile } from 'node:fs/promises'; import { normalizePath } from 'vite'; import { builtins, builtinOutputFormats, getMetadata, parseURL, extractEntries, resolveConfigs, urlFormat, generateTransforms, applyTransforms } from 'imagetools-core'; export * from 'imagetools-core'; import { createFilter, dataToEsm } from '@rollup/pluginutils'; import sharp from 'sharp'; import { createHash } from 'node:crypto'; const createBasePath = (base) => { return ((base === null || base === void 0 ? void 0 : base.replace(/\/$/, '')) || '') + '/@imagetools/'; }; function generateImageID(config, imageHash) { return hash([JSON.stringify(config), imageHash]); } function hash(keyParts) { let hash = createHash('sha1'); for (const keyPart of keyParts) { hash = hash.update(keyPart); } return hash.digest('hex'); } const defaultOptions = { include: /^[^?]+\.(avif|gif|heif|jpeg|jpg|png|tiff|webp)(\?.*)?$/, exclude: 'public/**/*', removeMetadata: true }; const transformPromises = new Map(); function imagetools(userOptions = {}) { var _a, _b, _c, _d, _e; const pluginOptions = { ...defaultOptions, ...userOptions }; const cacheOptions = { enabled: (_b = (_a = pluginOptions.cache) === null || _a === void 0 ? void 0 : _a.enabled) !== null && _b !== void 0 ? _b : true, dir: (_d = (_c = pluginOptions.cache) === null || _c === void 0 ? void 0 : _c.dir) !== null && _d !== void 0 ? _d : './node_modules/.cache/imagetools', retention: (_e = pluginOptions.cache) === null || _e === void 0 ? void 0 : _e.retention }; mkdirSync(`${cacheOptions.dir}`, { recursive: true }); const filter = createFilter(pluginOptions.include, pluginOptions.exclude); const transformFactories = pluginOptions.extendTransforms ? pluginOptions.extendTransforms(builtins) : builtins; const outputFormats = pluginOptions.extendOutputFormats ? pluginOptions.extendOutputFormats(builtinOutputFormats) : builtinOutputFormats; let viteConfig; let basePath; const generatedImages = new Map(); return { name: 'imagetools', enforce: 'pre', configResolved(cfg) { viteConfig = cfg; basePath = createBasePath(viteConfig.base); }, load: { filter: { id: { include: pluginOptions.include, exclude: pluginOptions.exclude } }, async handler(id) { var _a, _b, _c, _d, _e, _f; if (!filter(id)) return null; const srcURL = parseURL(id); const pathname = decodeURIComponent(srcURL.pathname); // lazy loaders so that we can load the metadata in defaultDirectives if needed // but if there are no directives then we can just skip loading let lazyImg; const lazyLoadImage = () => { if (lazyImg) return lazyImg; return (lazyImg = sharp(pathname)); }; let lazyMetadata; const lazyLoadMetadata = async () => { if (lazyMetadata) return lazyMetadata; return (lazyMetadata = await lazyLoadImage().metadata()); }; const defaultDirectives = typeof pluginOptions.defaultDirectives === 'function' ? await pluginOptions.defaultDirectives(srcURL, lazyLoadMetadata) : pluginOptions.defaultDirectives || new URLSearchParams(); const directives = new URLSearchParams({ ...Object.fromEntries(defaultDirectives), ...Object.fromEntries(srcURL.searchParams) }); if (!directives.toString()) return null; const img = lazyLoadImage(); const widthParam = directives.get('w'); const heightParam = directives.get('h'); if (directives.get('allowUpscale') !== 'true' && (widthParam || heightParam)) { const metadata = await lazyLoadMetadata(); const clamp = (s, intrinsic) => [...new Set(s.split(';').map((d) => (parseInt(d) <= intrinsic ? d : intrinsic.toString())))].join(';'); if (widthParam) { const intrinsicWidth = metadata.width || 0; directives.set('w', clamp(widthParam, intrinsicWidth)); } if (heightParam) { const intrinsicHeight = metadata.height || 0; directives.set('h', clamp(heightParam, intrinsicHeight)); } } const parameters = extractEntries(directives); const imageConfigs = (_b = (_a = pluginOptions.resolveConfigs) === null || _a === void 0 ? void 0 : _a.call(pluginOptions, parameters, outputFormats)) !== null && _b !== void 0 ? _b : resolveConfigs(parameters, outputFormats); const logger = { info: (msg) => viteConfig.logger.info(msg), warn: (msg) => this.warn(msg), error: (msg) => this.error(msg) }; const imageHash = hash([await img.toBuffer()]); const executeTransform = async (id, imageConfig) => { var _a, _b, _c, _d; let image; let metadata; let cachedBuffer; if (cacheOptions.enabled && ((_b = (_a = statSync(`${cacheOptions.dir}/${id}`, { throwIfNoEntry: false })) === null || _a === void 0 ? void 0 : _a.size) !== null && _b !== void 0 ? _b : 0) > 0) { cachedBuffer = await readFile(`${cacheOptions.dir}/${id}`); image = sharp(cachedBuffer); metadata = (await image.metadata()); // we set the format on the metadata during transformation using the format directive // when restoring from the cache, we use sharp to read it from the image and that results in a different value for avif images // see https://github.com/lovell/sharp/issues/2504 and https://github.com/lovell/sharp/issues/3746 if (imageConfig.format === 'avif' && metadata.format === 'heif' && metadata.compression === 'av1') metadata.format = 'avif'; } else { const { transforms } = generateTransforms(imageConfig, transformFactories, srcURL.searchParams, logger); const res = await applyTransforms(transforms, img.clone(), pluginOptions.removeMetadata); image = res.image; metadata = res.metadata; if (cacheOptions.enabled) { cachedBuffer = await image.toBuffer(); await writeFile(`${cacheOptions.dir}/${id}`, cachedBuffer); } } generatedImages.set(id, { image, metadata }); if (directives.has('inline')) { const inlineBuffer = cachedBuffer || (await image.toBuffer()); metadata.src = `data:image/${metadata.format};base64,${inlineBuffer.toString('base64')}`; } else if (viteConfig.command === 'serve') { metadata.src = ((_d = (_c = viteConfig === null || viteConfig === void 0 ? void 0 : viteConfig.server) === null || _c === void 0 ? void 0 : _c.origin) !== null && _d !== void 0 ? _d : '') + basePath + id; } else { const fileHandle = this.emitFile({ name: basename(pathname, extname(pathname)) + `.${metadata.format}`, source: cachedBuffer || (await image.toBuffer()), type: 'asset', originalFileName: normalizePath(relative(viteConfig.root, srcURL.pathname)) }); metadata.src = `__VITE_ASSET__${fileHandle}__`; } return metadata; }; /** allows only one transform to be run for a given id */ async function synchronizedTransform(id, imageConfig) { let transformPromise = transformPromises.get(id); if (transformPromise) return transformPromise; let resolve; let reject; transformPromise = new Promise((res, rej) => { resolve = res; reject = rej; }); transformPromises.set(id, transformPromise); executeTransform(id, imageConfig) .then(resolve, reject) .finally(() => { transformPromises.delete(id); }); return transformPromise; } const outputs = await Promise.all(imageConfigs.map((config) => { const id = generateImageID(config, imageHash); return synchronizedTransform(id, config); })); let outputFormat = urlFormat(); const asParam = (_c = directives.get('as')) === null || _c === void 0 ? void 0 : _c.split(':'); const as = asParam ? asParam[0] : undefined; for (const [key, format] of Object.entries(outputFormats)) { if (as === key) { outputFormat = format(asParam && asParam[1] ? asParam[1].split(';') : undefined); break; } } return dataToEsm(await outputFormat(outputs), { namedExports: (_f = (_d = pluginOptions.namedExports) !== null && _d !== void 0 ? _d : (_e = viteConfig.json) === null || _e === void 0 ? void 0 : _e.namedExports) !== null && _f !== void 0 ? _f : true, compact: !!viteConfig.build.minify, preferConst: true }); } }, configureServer(server) { server.middlewares.use((req, res, next) => { var _a, _b; if ((_a = req.url) === null || _a === void 0 ? void 0 : _a.startsWith(basePath)) { const [, id] = req.url.split(basePath); const { image, metadata } = (_b = generatedImages.get(id)) !== null && _b !== void 0 ? _b : {}; if (!metadata) throw new Error(`vite-imagetools cannot find image with id "${id}" this is likely an internal error`); if (!image) { res.setHeader('Content-Type', `image/${metadata.format}`); return createReadStream(`${cacheOptions.dir}/${id}`).pipe(res); } if (pluginOptions.removeMetadata === false) { image.withMetadata(); } res.setHeader('Content-Type', `image/${getMetadata(image, 'format')}`); return image.clone().pipe(res); } next(); }); }, async buildEnd(error) { if (!error && cacheOptions.enabled && cacheOptions.retention !== undefined && viteConfig.command !== 'serve') { const dir = await opendir(cacheOptions.dir); for await (const dirent of dir) { if (dirent.isFile()) { if (generatedImages.has(dirent.name)) continue; const imagePath = `${cacheOptions.dir}/${dirent.name}`; const stats = await stat(imagePath); if (Date.now() - stats.mtimeMs > cacheOptions.retention * 1000) { console.debug(`deleting stale cached image ${dirent.name}`); await rm(imagePath); } } } } } }; } export { imagetools }; //# sourceMappingURL=index.js.map