vite-imagetools
Version:
Load and transform images using a toolbox of import directives!
237 lines (233 loc) • 12.8 kB
JavaScript
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