UNPKG

svimg

Version:

Svelte image component with image preprocessing and lazy loading

486 lines (462 loc) 15.2 kB
import PQueue from 'p-queue'; import replaceAsync from 'string-replace-async'; import { join, basename, extname, dirname } from 'node:path'; import sharp from 'sharp'; import { access, mkdir } from 'node:fs/promises'; import md5file from 'md5-file'; import { constants } from 'node:fs'; import { createHash } from 'node:crypto'; import { parse } from 'node-html-parser'; class Queue { constructor(options) { this.cache = new Map(); this.queue = new PQueue({ concurrency: options?.concurrency || Infinity }); } enqueue(func, ...args) { const cacheKey = `${func.name}|${JSON.stringify(args)}`; if (this.cache.has(cacheKey)) { return this.cache.get(cacheKey); } const p = this.queue.add(() => func.apply(null, args)); this.cache.set(cacheKey, p); return p; } } function getSrcset(images, options) { return images .map((i) => (options?.pathOnly ? i.path : `${i.path} ${i.width}w`)) .join(', '); } function getComponentAttributes(input) { return { srcset: getSrcset(input.images), srcsetwebp: input.webpImages.length ? getSrcset(input.webpImages) : undefined, srcsetavif: input.avifImages.length ? getSrcset(input.avifImages) : undefined, placeholder: input.placeholder, aspectratio: input.aspectRatio, placeholdersrc: input.placeholderImage ? getSrcset([input.placeholderImage], { pathOnly: true }) : undefined, placeholderwebp: input.placeholderWebp ? getSrcset([input.placeholderWebp], { pathOnly: true }) : undefined, placeholderavif: input.placeholderAvif ? getSrcset([input.placeholderAvif], { pathOnly: true }) : undefined, }; } const pathSepPattern = /\\/g; function stripPrefix(path, prefix) { prefix = prefix.replace(pathSepPattern, '/'); if (!path.startsWith(prefix)) { return path; } return path.substring(prefix.length + (prefix.endsWith('/') ? 0 : 1)); } function defaultSrcGenerator(path, { inputDir, src }) { if (inputDir) { path = stripPrefix(path, inputDir); } if (src && !path.startsWith('/') && /^\/[^\/]/.test(src)) { path = '/' + path; } return path; } function pathToUrl(path, options) { path = path.replace(pathSepPattern, '/'); if (!options) { return path; } let { srcGenerator, ...info } = options; if (srcGenerator) { if (info.outputDir) { path = stripPrefix(path, info.outputDir); } const url = srcGenerator(path, info); if (!url) { throw new Error(`srcGenerator function returned an empty src for path ${path}`); } return url; } return defaultSrcGenerator(path, info); } // sharp only supports a very specific list of image formats, // no point depending on a complete mime type database function getMimeType(format) { switch (format) { case 'jpeg': case 'png': case 'webp': case 'avif': case 'tiff': case 'gif': return `image/${format}`; case 'svg': return 'image/svg+xml'; } return ''; } function resizeImageToFile(inputFile, options, outputFile) { return resizeImage(inputFile, options, outputFile); } async function resizeImage(inputFile, options, outputFile) { if (!inputFile) { throw new Error('Input file is required'); } let sharpInstance = sharp(inputFile); if (options.quality) { sharpInstance = sharpInstance.jpeg({ quality: options.quality, force: false, }).png({ quality: options.quality, force: false, }).webp({ quality: options.quality, force: false, }).avif({ quality: options.quality, force: false, }); } sharpInstance = sharpInstance.resize(options.width, options.height); return outputFile !== undefined ? sharpInstance.toFile(outputFile) : sharpInstance.toBuffer(); } async function getImageMetadata(inputFile) { if (!inputFile) { throw new Error('Input file is required'); } return sharp(inputFile).metadata(); } const DEFAULT_WIDTHS = [480, 1024, 1920, 2560]; const DEFAULT_WEBP = true; const DEFAULT_AVIF = true; const PLACEHOLDER_WIDTH = 64; async function createPlaceholder(inputFile, queue) { if (!inputFile) { throw new Error('Input file is required'); } const [{ format }, blurData] = await Promise.all([ queue.enqueue(getImageMetadata, inputFile), queue.enqueue(resizeImage, inputFile, { width: PLACEHOLDER_WIDTH }), ]); if (!format) { throw new Error('Image format could not be determined'); } const blur64 = blurData.toString('base64'); const mime = getMimeType(format); const href = `data:${mime};base64,${blur64}`; return href; } function isError(error) { return 'code' in error; } async function exists(file) { if (!file) { return false; } try { await access(file, constants.F_OK); return true; } catch (err) { if (isError(err) && err.code === 'ENOENT') { return false; } throw err; } } async function ensureResizeImage(inputFile, outputFile, queue, options) { if (!inputFile) { throw new Error('Input file is required'); } if (!outputFile) { throw new Error('Output file is required'); } let width; let height; if (await queue.enqueue(exists, outputFile)) { ({ width, height } = await queue.enqueue(getImageMetadata, outputFile)); } else { ({ width, height } = await queue.enqueue(resizeImageToFile, inputFile, { width: options.width, quality: options.quality, }, outputFile)); } if (!width || !height) { throw new Error('Image dimensions could not be determined'); } return { path: outputFile, width, height, }; } async function resizeImageMultiple(inputFile, outputDir, queue, options) { if (!inputFile) { throw new Error('Input file is required'); } if (!outputDir) { throw new Error('Output file is required'); } const widthPaths = options.widths.map((width) => { const outFile = options.filenameGenerator({ width, quality: options.quality, inputFile, }); if (!outFile) { throw new Error('Output filename not provided'); } return { path: join(outputDir, outFile), width, }; }); return options?.skipGeneration ? widthPaths.map(({ path, width }) => ({ path, width, height: Math.round((width / options.aspectRatio + Number.EPSILON) * 100) / 100, })) : await Promise.all(widthPaths.map(({ width, path }) => ensureResizeImage(inputFile, path, queue, { width, quality: options.quality, }))); } function getHash(content) { return createHash('md5').update(content).digest('hex'); } function getOptionsHash(options, length) { const hash = getHash(Object.entries(options) .map(([k, v]) => `${k}=${v}`) .join(',')); return length ? hash.substring(0, length) : hash; } function getProcessImageOptions(imageWidth, options) { let widths = options?.widths || DEFAULT_WIDTHS; widths = widths.filter((w) => w <= imageWidth); if (!widths.length || (imageWidth > Math.max(...widths) && !options?.widths?.length)) { // use original width if smaller or larger than all widths widths.push(imageWidth); } const webp = options?.webp ?? DEFAULT_WEBP; const avif = options?.avif ?? DEFAULT_AVIF; return { widths, quality: options?.quality, webp, avif, }; } async function processImage(inputFile, outputDir, queue, options) { if (!inputFile) { throw new Error('Input file is required'); } if (!outputDir) { throw new Error('Output dir is required'); } const [, metadata, fileHash] = await Promise.all([ (async () => { if (!options?.skipGeneration) { if (!(await queue.enqueue(exists, outputDir))) { await queue.enqueue(mkdir, outputDir, { recursive: true, }); } } })(), queue.enqueue(getImageMetadata, inputFile), queue.enqueue(md5file, inputFile), ]); if (!metadata.width || !metadata.height) { throw new Error('Image dimensions could not be determined'); } const { skipGeneration, ...restOpts } = options || {}; const { widths, quality, webp, avif } = getProcessImageOptions(metadata.width, restOpts); const filename = basename(inputFile); const extension = extname(filename); const baseFilename = filename.substring(0, filename.length - extension.length); const aspectRatio = metadata.width / metadata.height; const [images, webpImages, avifImages] = await Promise.all([ resizeImageMultiple(inputFile, outputDir, queue, { widths, quality, filenameGenerator: ({ width, quality }) => `${baseFilename}.${getOptionsHash({ width, quality }, 7)}.${fileHash}${extension}`, aspectRatio, skipGeneration, }), webp ? resizeImageMultiple(inputFile, outputDir, queue, { widths, quality, filenameGenerator: ({ width, quality }) => `${baseFilename}.${getOptionsHash({ width, quality }, 7)}.${fileHash}.webp`, aspectRatio, skipGeneration, }) : [], avif ? resizeImageMultiple(inputFile, outputDir, queue, { widths, quality, filenameGenerator: ({ width, quality }) => `${baseFilename}.${getOptionsHash({ width, quality }, 7)}.${fileHash}.avif`, aspectRatio, skipGeneration, }) : [], ]); return { images, webpImages, avifImages, aspectRatio, }; } function transformImagePath(image, { inputDir, outputDir, src, srcGenerator, }) { return { ...image, path: pathToUrl(image.path, { inputDir, outputDir, src, srcGenerator, }), }; } async function generateComponentAttributes(options) { let { src, queue, inputDir, outputDir, webp, avif, widths, quality, skipGeneration, skipPlaceholder, embedPlaceholder, } = options; if (!src) { throw new Error('Src is required'); } if (!inputDir) { throw new Error('Input dir is required'); } if (!outputDir) { throw new Error('Output dir is required'); } if (typeof embedPlaceholder === 'undefined') { // TODO: change to false with major version embedPlaceholder = true; } queue = queue || new Queue(); const inputFile = join(inputDir, src); const outputDirReal = join(outputDir, dirname(src)); const [{ images, webpImages, avifImages, aspectRatio }, placeholder, placeholderImages,] = await Promise.all([ processImage(inputFile, outputDirReal, queue, { webp: webp ?? true, avif: avif ?? true, widths, skipGeneration, quality, }), !skipPlaceholder && embedPlaceholder ? createPlaceholder(inputFile, queue) : undefined, !skipPlaceholder && !embedPlaceholder ? processImage(inputFile, outputDirReal, queue, { webp: webp ?? true, avif: avif ?? true, widths: [PLACEHOLDER_WIDTH], skipGeneration, quality, }) : undefined, ]); return getComponentAttributes({ images: images.map((i) => transformImagePath(i, options)), webpImages: webpImages.map((i) => transformImagePath(i, options)), avifImages: avifImages.map((i) => transformImagePath(i, options)), placeholder, aspectRatio, placeholderImage: placeholderImages?.images?.length ? transformImagePath(placeholderImages.images[0], options) : undefined, placeholderWebp: placeholderImages?.webpImages?.length ? transformImagePath(placeholderImages.webpImages[0], options) : undefined, placeholderAvif: placeholderImages?.avifImages?.length ? transformImagePath(placeholderImages.avifImages[0], options) : undefined, }); } function formatAttribute(attribute, value) { if (!attribute || !value) { return ''; } return value === true ? attribute : `${attribute}="${value}"`; } function tryParseInt(val) { return val && /^[0-9]+$/.test(val) ? parseInt(val, 10) : undefined; } function parseAttributes(element) { if (!element) { return {}; } const root = parse(element.replace(/[\r\n]+/g, ' ')); if (!root?.firstChild) { return {}; } const node = root.firstChild; if (!node?.attributes) { return {}; } return Object.entries(node.attributes).reduce((rv, [attr, val]) => { rv[attr] = val === '' ? attr : val; // so empty value attributes can be truthy return rv; }, {}); } async function processImageElement(element, queue, options) { if (!element) { return element; } const attrs = parseAttributes(element); const src = attrs['src']; if (!src) { return element; } const width = tryParseInt(attrs['width']); const quality = tryParseInt(attrs['quality']); const immediate = !!attrs['immediate']; const newAttrs = await generateComponentAttributes({ src, queue, inputDir: options.inputDir, outputDir: options.outputDir, webp: options.webp, avif: options.avif, widths: width ? [width] : undefined, quality, skipPlaceholder: immediate || undefined, srcGenerator: options.srcGenerator, embedPlaceholder: options.embedPlaceholder, }); const attrString = Object.entries(newAttrs) .map(([attr, val]) => formatAttribute(attr, val)) .join(' '); return element.substring(0, 6) + ' ' + attrString + element.substring(6); } /** * Image processing Svelte preprocessor * for the svimg package * * @param options Image preprocessor options * @returns Svelte preprocessor */ function imagePreprocessor(options) { const queue = new Queue(); return { async markup({ content }) { return { code: await replaceAsync(content, /<Image[^>]+>/g, (element) => processImageElement(element, queue, options)), }; }, }; } export { Queue, generateComponentAttributes, imagePreprocessor, processImage };