UNPKG

next-image-export-optimizer

Version:

Optimizes all static images for Next.js static HTML export functionality

215 lines (214 loc) 10.5 kB
"use client"; import Image from "next/image"; import React, { forwardRef, useCallback, useMemo, useState } from "react"; const splitFilePath = ({ filePath }) => { const filenameWithExtension = filePath.split("\\").pop()?.split("/").pop() || ""; const filePathWithoutFilename = filePath.split(filenameWithExtension).shift(); const fileExtension = filePath.split(".").pop(); const filenameWithoutExtension = filenameWithExtension.substring(0, filenameWithExtension.lastIndexOf(".")) || filenameWithExtension; return { path: filePathWithoutFilename, filename: filenameWithoutExtension, extension: fileExtension || "", }; }; const generateImageURL = (src, width, basePath, isRemoteImage = false) => { const { filename, path, extension } = splitFilePath({ filePath: src }); const useWebp = process.env.nextImageExportOptimizer_storePicturesInWEBP != undefined ? process.env.nextImageExportOptimizer_storePicturesInWEBP == "true" : true; if (!["JPG", "JPEG", "WEBP", "PNG", "AVIF", "GIF"].includes(extension.toUpperCase())) { // The images has an unsupported extension // We will return the src return src; } // If the images are stored as WEBP by the package, then we should change // the extension to WEBP to load them correctly let processedExtension = extension; if (useWebp && ["JPG", "JPEG", "PNG", "GIF"].includes(extension.toUpperCase())) { processedExtension = "WEBP"; } let correctedPath = path; const lastChar = correctedPath?.substr(-1); // Selects the last character if (lastChar != "/") { // If the last character is not a slash correctedPath = correctedPath + "/"; // Append a slash to it. } const isStaticImage = src.includes("_next/static/media"); if (basePath) { if (basePath.endsWith("/") && correctedPath && correctedPath.startsWith("/")) { correctedPath = basePath + correctedPath.slice(1); } else if (!basePath.endsWith("/") && correctedPath && !correctedPath.startsWith("/")) { correctedPath = basePath + "/" + correctedPath; } else { correctedPath = basePath + correctedPath; } } const exportFolderName = process.env.nextImageExportOptimizer_exportFolderName || "nextImageExportOptimizer"; const basePathPrefixForStaticImages = basePath ? basePath + "/" : ""; let generatedImageURL = `${isStaticImage ? basePathPrefixForStaticImages : correctedPath}${exportFolderName}/${filename}-opt-${width}.${processedExtension.toUpperCase()}`; // if the generatedImageURL is not starting with a slash, then we add one as long as it is not a remote image if (!isRemoteImage && generatedImageURL.charAt(0) !== "/") { generatedImageURL = "/" + generatedImageURL; } return generatedImageURL; }; // Credits to https://github.com/bryc/code/blob/master/jshash/experimental/cyrb53.js // This is a hash function that is used to generate a hash from the image URL const hashAlgorithm = (str, seed = 0) => { let h1 = 0xdeadbeef ^ seed, h2 = 0x41c6ce57 ^ seed; for (let i = 0, ch; i < str.length; i++) { ch = str.charCodeAt(i); h1 = Math.imul(h1 ^ ch, 2654435761); h2 = Math.imul(h2 ^ ch, 1597334677); } h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507); h1 ^= Math.imul(h2 ^ (h2 >>> 13), 3266489909); h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507); h2 ^= Math.imul(h1 ^ (h1 >>> 13), 3266489909); return 4294967296 * (2097151 & h2) + (h1 >>> 0); }; function urlToFilename(url) { try { const parsedUrl = new URL(url); const extension = parsedUrl.pathname.split(".").pop(); if (extension) { return hashAlgorithm(url).toString().concat(".", extension); } } catch (error) { console.error("Error parsing URL", url, error); } return hashAlgorithm(url).toString(); } const imageURLForRemoteImage = ({ src, width, basePath, }) => { const encodedSrc = urlToFilename(src); return generateImageURL(encodedSrc, width, basePath, true); }; const optimizedLoader = ({ src, width, basePath, }) => { const isStaticImage = typeof src === "object"; const _src = isStaticImage ? src.src : src; const originalImageWidth = (isStaticImage && src.width) || undefined; // if it is a static image, we can use the width of the original image to generate a reduced srcset that returns // the same image url for widths that are larger than the original image if (isStaticImage && originalImageWidth && width > originalImageWidth) { const deviceSizes = (process.env.__NEXT_IMAGE_OPTS?.deviceSizes || [ 640, 750, 828, 1080, 1200, 1920, 2048, 3840, ]).map(Number); const imageSizes = (process.env.__NEXT_IMAGE_OPTS?.imageSizes || [ 16, 32, 48, 64, 96, 128, 256, 384, ]).map(Number); let allSizes = [...deviceSizes, ...imageSizes]; allSizes = allSizes.filter((v, i, a) => a.indexOf(v) === i); allSizes.sort((a, b) => a - b); // only use the width if it is smaller or equal to the next size in the allSizes array let nextLargestSize = null; for (let i = 0; i < allSizes.length; i++) { if (Number(allSizes[i]) >= originalImageWidth && (nextLargestSize === null || Number(allSizes[i]) < nextLargestSize)) { nextLargestSize = Number(allSizes[i]); } } if (nextLargestSize !== null) { return generateImageURL(_src, nextLargestSize, basePath); } } // Check if the image is a remote image (starts with http or https) if (_src.startsWith("http")) { return imageURLForRemoteImage({ src: _src, width, basePath }); } return generateImageURL(_src, width, basePath); }; const fallbackLoader = ({ src }) => { let _src = typeof src === "object" ? src.src : src; const isRemoteImage = _src.startsWith("http"); // if the _src does not start with a slash, then we add one as long as it is not a remote image if (!isRemoteImage && _src.charAt(0) !== "/") { _src = "/" + _src; } return _src; }; const ExportedImage = forwardRef(({ src, priority = false, loading, className, width, height, onLoad, unoptimized, placeholder = "blur", basePath = "", alt = "", blurDataURL, style, onError, overrideSrc, ...rest }, ref) => { const [imageError, setImageError] = useState(false); const automaticallyCalculatedBlurDataURL = useMemo(() => { if (blurDataURL) { // use the user provided blurDataURL if present return blurDataURL; } // check if the src is specified as a local file -> then it is an object const isStaticImage = typeof src === "object"; let _src = isStaticImage ? src.src : src; if (unoptimized === true) { // return the src image when unoptimized return _src; } // Check if the image is a remote image (starts with http or https) if (_src.startsWith("http")) { return imageURLForRemoteImage({ src: _src, width: 10, basePath }); } // otherwise use the generated image of 10px width as a blurDataURL return generateImageURL(_src, 10, basePath); }, [blurDataURL, src, unoptimized, basePath]); // check if the src is a SVG image -> then we should not use the blurDataURL and use unoptimized const isSVG = typeof src === "object" ? src.src.endsWith(".svg") : src.endsWith(".svg"); const [blurComplete, setBlurComplete] = useState(false); // Currently, we have to handle the blurDataURL ourselves as the new Image component // is expecting a base64 encoded string, but the generated blurDataURL is a normal URL const blurStyle = placeholder === "blur" && !isSVG && automaticallyCalculatedBlurDataURL && automaticallyCalculatedBlurDataURL.startsWith("/") && !blurComplete ? { backgroundSize: style?.objectFit || "cover", backgroundPosition: style?.objectPosition || "50% 50%", backgroundRepeat: "no-repeat", backgroundImage: `url("${automaticallyCalculatedBlurDataURL}")`, } : undefined; const isStaticImage = typeof src === "object"; let _src = isStaticImage ? src.src : src; if (basePath && !isStaticImage && _src.startsWith("/")) { _src = basePath + _src; } if (basePath && !isStaticImage && !_src.startsWith("/")) { _src = basePath + "/" + _src; } // Memoize the loader function const imageLoader = useMemo(() => { return imageError || unoptimized === true ? () => fallbackLoader({ src: overrideSrc || src }) : (e) => optimizedLoader({ src, width: e.width, basePath }); }, [imageError, unoptimized, overrideSrc, src, basePath]); const handleError = useCallback((error) => { setImageError(true); setBlurComplete(true); // execute the onError function if provided onError && onError(error); }, [onError]); const handleLoad = useCallback((e) => { // for some configurations, the onError handler is not called on an error occurrence // so we need to check if the image is loaded correctly const target = e.target; if (target.naturalWidth === 0) { // Broken image, fall back to unoptimized (meaning the original image src) setImageError(true); } setBlurComplete(true); // execute the onLoad callback if present onLoad && onLoad(e); }, [onLoad]); return (React.createElement(Image, { ref: ref, alt: alt, ...rest, ...(width && { width }), ...(height && { height }), ...(loading && { loading }), ...(className && { className }), ...(onLoad && { onLoad }), ...(overrideSrc && { overrideSrc }), ...(placeholder && { placeholder: blurStyle || blurComplete ? "empty" : placeholder, }), ...(unoptimized && { unoptimized }), ...(priority && { priority }), ...(isSVG && { unoptimized: true }), style: { ...style, ...blurStyle }, loader: imageLoader, blurDataURL: automaticallyCalculatedBlurDataURL, onError: handleError, onLoad: handleLoad, src: isStaticImage ? src : _src })); }); ExportedImage.displayName = "ExportedImage"; export default ExportedImage;