next-image-export-optimizer
Version:
Optimizes all static images for Next.js static HTML export functionality
518 lines (517 loc) • 25.9 kB
JavaScript
Object.defineProperty(exports, "__esModule", { value: true });
const defineProgressBar = require("./utils/defineProgressBar");
const ensureDirectoryExists = require("./utils/ensureDirectoryExists");
const getAllFilesAsObject = require("./utils/getAllFilesAsObject");
const getHash = require("./utils/getHash");
const getRemoteImageURLs_1 = require("./utils/getRemoteImageURLs");
const downloadImagesInBatches_1 = require("./utils/downloadImagesInBatches");
const urlToFilename = require("./utils/urlToFilename");
const fs = require("fs");
const sharp = require("sharp");
const path = require("path");
const loadConfig = require("next/dist/server/config").default;
// Check if the --name and --age arguments are present
const nextConfigPathIndex = process.argv.indexOf("--nextConfigPath");
const exportFolderPathIndex = process.argv.indexOf("--exportFolderPath");
// Check if there is only one argument without a name present -> this is the case if the user does not provide the path to the next.config.[js/ts] file
if (process.argv.length === 3) {
// Colorize the output to red
// Colorize the output to red
console.error("\x1b[31m");
console.error("next-image-export-optimizer: Breaking change: Please provide the path to the next.config.[js/ts] file as an argument with the name --nextConfigPath.");
// Reset the color
console.error("\x1b[0m");
process.exit(1);
}
// Set the nextConfigPath and exportFolderPath variables to the corresponding arguments, or to undefined if the arguments are not present
let nextConfigPath = nextConfigPathIndex !== -1
? process.argv[nextConfigPathIndex + 1]
: undefined;
let exportFolderPathCommandLine = exportFolderPathIndex !== -1
? process.argv[exportFolderPathIndex + 1]
: undefined;
if (nextConfigPath) {
nextConfigPath = path.isAbsolute(nextConfigPath)
? nextConfigPath
: path.join(process.cwd(), nextConfigPath);
}
else {
// Check for next.config.js, next.config.ts, and next.config.mjs
const jsConfigPath = path.join(process.cwd(), "next.config.js");
const tsConfigPath = path.join(process.cwd(), "next.config.ts");
const mjsConfigPath = path.join(process.cwd(), "next.config.mjs");
if (fs.existsSync(jsConfigPath)) {
nextConfigPath = jsConfigPath;
}
else if (fs.existsSync(tsConfigPath)) {
nextConfigPath = tsConfigPath;
}
else if (fs.existsSync(mjsConfigPath)) {
nextConfigPath = mjsConfigPath;
}
else {
console.error("\x1b[31m");
console.error("next-image-export-optimizer: Could not find next.config.js, next.config.ts, or next.config.mjs. Please provide the path to the configuration file.");
console.error("\x1b[0m");
process.exit(1);
}
}
const nextConfigFolder = path.dirname(nextConfigPath);
const folderNameForRemoteImages = `remoteImagesForOptimization`;
const folderPathForRemoteImages = path.join(nextConfigFolder, folderNameForRemoteImages);
if (exportFolderPathCommandLine) {
exportFolderPathCommandLine = path.isAbsolute(exportFolderPathCommandLine)
? exportFolderPathCommandLine
: path.join(process.cwd(), exportFolderPathCommandLine);
}
const nextImageExportOptimizer = async function () {
console.log("---- next-image-export-optimizer: Begin with optimization... ---- ");
// Default values
let imageFolderPath = "public/images";
let staticImageFolderPath = ".next/static/media";
let exportFolderPath = "out";
let deviceSizes = [640, 750, 828, 1080, 1200, 1920, 2048, 3840];
let imageSizes = [16, 32, 48, 64, 96, 128, 256, 384];
let quality = 75;
let storePicturesInWEBP = true;
let blurSize = [];
let remoteImageCacheTTL = 0;
let exportFolderName = "nextImageExportOptimizer";
let remoteImageFileName = "remoteOptimizedImages.js";
let remoteImageFilenames = [];
let remoteImageURLs = [];
try {
// Read in the configuration parameters
const nextjsConfig = await loadConfig("phase-export", nextConfigFolder);
// Check if nextjsConfig is an object or is undefined
if (typeof nextjsConfig !== "object" || nextjsConfig === null) {
throw new Error("next.config.[js/ts] is not an object");
}
const legacyPath = nextjsConfig.images?.nextImageExportOptimizer;
const newPath = nextjsConfig.env;
if (legacyPath?.remoteImagesFilename !== undefined) {
remoteImageFileName = legacyPath.remoteImagesFilename;
}
else if (newPath?.nextImageExportOptimizer_remoteImagesFilename !== undefined) {
remoteImageFileName =
newPath.nextImageExportOptimizer_remoteImagesFilename;
}
if (legacyPath?.imageFolderPath !== undefined) {
imageFolderPath = legacyPath.imageFolderPath;
}
else if (newPath?.nextImageExportOptimizer_imageFolderPath !== undefined) {
imageFolderPath = newPath.nextImageExportOptimizer_imageFolderPath;
// if the imageFolderPath starts with a slash, remove it
if (imageFolderPath.startsWith("/")) {
imageFolderPath = imageFolderPath.slice(1);
}
}
if (legacyPath?.exportFolderPath !== undefined) {
exportFolderPath = legacyPath.exportFolderPath;
}
else if (newPath?.nextImageExportOptimizer_exportFolderPath !== undefined) {
exportFolderPath = newPath.nextImageExportOptimizer_exportFolderPath;
}
if (nextjsConfig.images?.deviceSizes !== undefined) {
deviceSizes = nextjsConfig.images.deviceSizes;
}
if (nextjsConfig.images?.imageSizes !== undefined) {
imageSizes = nextjsConfig.images.imageSizes;
}
if (legacyPath?.quality !== undefined) {
quality = Number(legacyPath.quality);
}
else if (newPath?.nextImageExportOptimizer_quality !== undefined) {
quality = Number(newPath.nextImageExportOptimizer_quality);
}
if (nextjsConfig.env?.storePicturesInWEBP !== undefined) {
storePicturesInWEBP =
nextjsConfig.env.storePicturesInWEBP.toLowerCase() == "true";
}
else if (newPath?.nextImageExportOptimizer_storePicturesInWEBP !== undefined) {
storePicturesInWEBP =
newPath.nextImageExportOptimizer_storePicturesInWEBP.toLowerCase() ==
"true";
}
if (nextjsConfig.env?.generateAndUseBlurImages?.toLowerCase() == "true") {
blurSize = [10];
}
else if (newPath?.nextImageExportOptimizer_generateAndUseBlurImages == "true") {
blurSize = [10];
}
if (newPath.nextImageExportOptimizer_exportFolderName !== undefined) {
exportFolderName = newPath.nextImageExportOptimizer_exportFolderName;
}
if (newPath.nextImageExportOptimizer_remoteImageCacheTTL !== undefined) {
remoteImageCacheTTL = Number(newPath.nextImageExportOptimizer_remoteImageCacheTTL);
}
// Give the user a warning if the transpilePackages: ["next-image-export-optimizer"], is not set in the next.config.[js/ts]
if (nextjsConfig.transpilePackages === undefined || // transpilePackages is not set
(nextjsConfig.transpilePackages !== undefined &&
!nextjsConfig.transpilePackages.includes("next-image-export-optimizer")) // transpilePackages is set but does not include next-image-export-optimizer
) {
console.warn("\x1b[41m", `Changed in 1.2.0: You have not set transpilePackages: ["next-image-export-optimizer"] in your next.config.[js/ts]. This may cause problems with next-image-export-optimizer. Please add this line to your next.config.[js/ts].`, "\x1b[0m");
}
}
catch (e) {
// Configuration file not found
console.log("Could not find a next.config.js or next.config.ts file. Use of default values");
}
finally {
const result = await (0, getRemoteImageURLs_1.getRemoteImageURLs)(remoteImageFileName, nextConfigFolder, folderPathForRemoteImages);
remoteImageFilenames = result.remoteImageFilenames;
remoteImageURLs = result.remoteImageURLs;
}
// if the user has specified a path for the export folder via the command line, use this path
exportFolderPath = exportFolderPathCommandLine || exportFolderPath;
// Give the user a warning, if the public directory of Next.js is not found as the user
// may have run the command in a wrong directory
if (!fs.existsSync(path.join(nextConfigFolder, "public"))) {
console.warn("\x1b[41m", `Could not find a public folder in this directory. Make sure you run the command in the main directory of your project.`, "\x1b[0m");
}
// Create the folder for the remote images if it does not exists
if (remoteImageURLs.length > 0) {
try {
if (!fs.existsSync(folderNameForRemoteImages)) {
fs.mkdirSync(folderNameForRemoteImages);
console.log(`Create remote image output folder: ${folderNameForRemoteImages}`);
}
}
catch (err) {
console.error(err);
}
}
// Download the remote images specified in the remoteOptimizedImages.js file
if (remoteImageURLs.length > 0)
console.log(`Found ${remoteImageURLs.length} remote image${remoteImageURLs.length > 1 ? "s" : ""}...`);
// we clear all images in the remote image folder that are not in the remoteImageURLs array
const allFilesInRemoteImageFolder = fs.existsSync(folderNameForRemoteImages)
? fs.readdirSync(folderNameForRemoteImages)
: [];
const encodedRemoteImageURLs = remoteImageURLs.map((url) => urlToFilename(url));
function removeLastUpdated(str) {
const suffix = ".lastUpdated";
if (str.endsWith(suffix)) {
return str.slice(0, -suffix.length);
}
return str;
}
for (const filename of allFilesInRemoteImageFolder) {
if (encodedRemoteImageURLs.includes(filename) ||
encodedRemoteImageURLs.includes(removeLastUpdated(filename))) {
// the filename is in the remoteImageURLs array or the filename without the .lastUpdated suffix
// so we do not delete it
continue;
}
fs.unlinkSync(path.join(folderNameForRemoteImages, filename));
console.log(`Deleted ${filename} from remote image folder as it is not retrieved from ${remoteImageFileName}.`);
}
await (0, downloadImagesInBatches_1.downloadImagesInBatches)(remoteImageURLs, remoteImageFilenames, folderPathForRemoteImages, Math.min(remoteImageURLs.length, 20), remoteImageCacheTTL);
// Create or read the JSON containing the hashes of the images in the image directory
let imageHashes = {};
const hashFilePath = `${imageFolderPath}/next-image-export-optimizer-hashes.json`;
try {
let rawData = fs.readFileSync(hashFilePath);
imageHashes = JSON.parse(rawData);
}
catch (e) {
// No image hashes yet
}
// check if the image folder is a subdirectory of the public folder
// if not, the images in the image folder can only be static images and are taken from the static image folder (staticImageFolderPath)
// so we do not add them to the images that need to be optimized
const isImageFolderSubdirectoryOfPublicFolder = imageFolderPath.includes("public");
// Generate a warning if the image folder is not a subdirectory of the public folder
if (!isImageFolderSubdirectoryOfPublicFolder) {
console.warn("\x1b[41mWarning: The image folder is not a subdirectory of the public folder. The images in the image folder are not optimized.\x1b[0m");
}
const allFilesInImageFolderAndSubdirectories = isImageFolderSubdirectoryOfPublicFolder
? getAllFilesAsObject(imageFolderPath, imageFolderPath, exportFolderName)
: [];
const allFilesInStaticImageFolder = getAllFilesAsObject(staticImageFolderPath, staticImageFolderPath, exportFolderName);
// append the static image folder to the image array
allFilesInImageFolderAndSubdirectories.push(...allFilesInStaticImageFolder);
// append the remote images to the image array
if (remoteImageURLs.length > 0) {
// get all files in the remote image folder again, as we added extensions to the filenames
// if they were not present in the URLs in remoteOptimizedImages.js
const allFilesInRemoteImageFolder = fs.readdirSync(folderNameForRemoteImages);
const remoteImageFiles = allFilesInRemoteImageFolder.map((filename) => {
const filenameFull = path.join(folderPathForRemoteImages, filename);
return {
basePath: folderPathForRemoteImages,
file: filename,
dirPathWithoutBasePath: "",
fullPath: filenameFull,
};
});
// append the remote images to the image array
allFilesInImageFolderAndSubdirectories.push(...remoteImageFiles);
}
const allImagesInImageFolder = allFilesInImageFolderAndSubdirectories.filter((fileObject) => {
if (fileObject === undefined)
return false;
if (fileObject.file === undefined)
return false;
// check if the file has a supported extension
const filenameSplit = fileObject.file.split(".");
if (filenameSplit.length === 1)
return false;
const extension = filenameSplit.pop().toUpperCase();
// Only include file with image extensions
return ["JPG", "JPEG", "WEBP", "PNG", "AVIF", "GIF"].includes(extension);
});
console.log(`Found ${allImagesInImageFolder.length - remoteImageURLs.length} supported images in ${imageFolderPath}, static folder and subdirectories and ${remoteImageURLs.length} remote image${remoteImageURLs.length > 1 ? "s" : ""}.`);
let widths = [...blurSize, ...imageSizes, ...deviceSizes];
// sort the widths in ascending order to make sure the logic works for limiting the number of images
widths.sort((a, b) => a - b);
// remove duplicate widths from the array
widths = widths.filter((item, index) => widths.indexOf(item) === index);
const progressBar = defineProgressBar();
if (allImagesInImageFolder.length > 0) {
console.log(`Using sizes: ${widths.toString()}`);
console.log(`Start optimization of ${allImagesInImageFolder.length} images with ${widths.length} sizes resulting in ${allImagesInImageFolder.length * widths.length} optimized images...`);
progressBar.start(allImagesInImageFolder.length * widths.length, 0, {
sizeOfGeneratedImages: 0,
});
}
let sizeOfGeneratedImages = 0;
const allGeneratedImages = [];
const updatedImageHashes = {};
// Loop through all images
for (let index = 0; index < allImagesInImageFolder.length; index++) {
// try catch to catch errors in the loop and let the user know which image caused the error
try {
const file = allImagesInImageFolder[index].file;
let fileDirectory = allImagesInImageFolder[index].dirPathWithoutBasePath;
let basePath = allImagesInImageFolder[index].basePath;
let extension = file.split(".").pop().toUpperCase();
const imageBuffer = fs.readFileSync(path.join(basePath, fileDirectory, file));
const imageHash = getHash([
imageBuffer,
...widths,
quality,
fileDirectory,
file,
]);
const keyForImageHashes = `${fileDirectory}/${file}`;
let hashContentChanged = false;
if (imageHashes[keyForImageHashes] !== imageHash) {
hashContentChanged = true;
}
// Store image hash in temporary object
updatedImageHashes[keyForImageHashes] = imageHash;
let optimizedOriginalWidthImagePath;
let optimizedOriginalWidthImageSizeInMegabytes;
// Loop through all widths
for (let indexWidth = 0; indexWidth < widths.length; indexWidth++) {
const width = widths[indexWidth];
const filename = path.parse(file).name;
if (storePicturesInWEBP) {
extension = "WEBP";
}
const isStaticImage = basePath === staticImageFolderPath;
// for a static image, we copy the image to public/nextImageExportOptimizer or public/${exportFolderName}
// and not the staticImageFolderPath
// as the static image folder is deleted before each build
const basePathToStoreOptimizedImages = isStaticImage ||
basePath === path.join(nextConfigFolder, folderNameForRemoteImages)
? "public"
: basePath;
const optimizedFileNameAndPath = path.join(basePathToStoreOptimizedImages, fileDirectory, exportFolderName, `${filename}-opt-${width}.${extension.toUpperCase()}`);
// Check if file is already in hash and specific size and quality is present in the
// opt file directory
if (!hashContentChanged &&
keyForImageHashes in imageHashes &&
fs.existsSync(optimizedFileNameAndPath)) {
const stats = fs.statSync(optimizedFileNameAndPath);
const fileSizeInBytes = stats.size;
const fileSizeInMegabytes = fileSizeInBytes / (1024 * 1024);
sizeOfGeneratedImages += fileSizeInMegabytes;
progressBar.increment({
sizeOfGeneratedImages: sizeOfGeneratedImages.toFixed(1),
});
allGeneratedImages.push(optimizedFileNameAndPath);
continue;
}
const transformer = sharp(imageBuffer, {
animated: true,
limitInputPixels: false, // disable pixel limit
});
transformer.rotate();
const { width: metaWidth } = await transformer.metadata();
// For a static image, we can skip the image optimization and the copying
// of the image for images with a width greater than the original image width
// we will stop the loop at the first image with a width greater than the original image width
let nextLargestSize = -1;
for (let i = 0; i < widths.length; i++) {
if (Number(widths[i]) >= metaWidth &&
(nextLargestSize === -1 || Number(widths[i]) < nextLargestSize)) {
nextLargestSize = Number(widths[i]);
}
}
if (isStaticImage &&
nextLargestSize !== -1 &&
width > nextLargestSize) {
progressBar.increment({
sizeOfGeneratedImages: sizeOfGeneratedImages.toFixed(1),
});
continue;
}
// If the original image's width is X, the optimized images are
// identical for all widths >= X. Once we have generated the first of
// these identical images, we can simply copy that file instead of redoing
// the optimization.
if (optimizedOriginalWidthImagePath &&
optimizedOriginalWidthImageSizeInMegabytes) {
fs.copyFileSync(optimizedOriginalWidthImagePath, optimizedFileNameAndPath);
sizeOfGeneratedImages += optimizedOriginalWidthImageSizeInMegabytes;
progressBar.increment({
sizeOfGeneratedImages: sizeOfGeneratedImages.toFixed(1),
});
allGeneratedImages.push(optimizedFileNameAndPath);
continue;
}
const resize = metaWidth && metaWidth > width;
if (resize) {
transformer.resize(width);
}
if (extension === "AVIF") {
if (transformer.avif) {
const avifQuality = quality - 15;
transformer.avif({
quality: Math.max(avifQuality, 0),
chromaSubsampling: "4:2:0", // same as webp
});
}
else {
transformer.webp({ quality });
}
}
else if (extension === "WEBP" || storePicturesInWEBP) {
transformer.webp({ quality });
}
else if (extension === "PNG") {
transformer.png({ quality });
}
else if (extension === "JPEG" || extension === "JPG") {
transformer.jpeg({ quality });
}
else if (extension === "GIF") {
transformer.gif({ quality });
}
// Write the optimized image to the file system
ensureDirectoryExists(optimizedFileNameAndPath);
const info = await transformer.toFile(optimizedFileNameAndPath);
const fileSizeInBytes = info.size;
const fileSizeInMegabytes = fileSizeInBytes / (1024 * 1024);
sizeOfGeneratedImages += fileSizeInMegabytes;
progressBar.increment({
sizeOfGeneratedImages: sizeOfGeneratedImages.toFixed(1),
});
allGeneratedImages.push(optimizedFileNameAndPath);
if (!resize) {
optimizedOriginalWidthImagePath = optimizedFileNameAndPath;
optimizedOriginalWidthImageSizeInMegabytes = fileSizeInMegabytes;
}
}
}
catch (error) {
console.log(`
Error while optimizing image ${allImagesInImageFolder[index].file}
${error}
`);
// throw the error so that the process stops
throw error;
}
}
let data = JSON.stringify(updatedImageHashes, null, 4);
ensureDirectoryExists(hashFilePath);
fs.writeFileSync(hashFilePath, data);
// Copy the optimized images to the build folder
console.log("\nCopy optimized images to build folder...");
for (let index = 0; index < allGeneratedImages.length; index++) {
const filePath = allGeneratedImages[index];
const fileInBuildFolder = path.join(exportFolderPath, (() => {
const parts = filePath.split("public");
if (parts.length > 1) {
return parts.slice(1).join("public");
}
else {
// Handle case where 'public' is not found
return filePath;
}
})());
// Create the folder for the optimized images in the build directory if it does not exists
ensureDirectoryExists(fileInBuildFolder);
fs.copyFileSync(filePath, fileInBuildFolder);
}
function findSubfolders(rootPath, folderName, results = []) {
const items = fs.readdirSync(rootPath);
for (const item of items) {
const itemPath = path.join(rootPath, item);
const stat = fs.statSync(itemPath);
if (stat.isDirectory()) {
if (item === folderName) {
results.push(itemPath);
}
findSubfolders(itemPath, folderName, results);
}
}
return results;
}
const optimizedImagesFolders = findSubfolders(imageFolderPath, exportFolderName);
optimizedImagesFolders.push(`public/${exportFolderName}`);
function findImageFiles(folderPath, extensions, results = []) {
// check if the folder exists
if (!fs.existsSync(folderPath)) {
return results;
}
const items = fs.readdirSync(folderPath);
for (const item of items) {
const itemPath = path.join(folderPath, item);
const stat = fs.statSync(itemPath);
if (stat.isDirectory()) {
findImageFiles(itemPath, extensions, results);
}
else {
const ext = path.extname(item).toUpperCase();
if (extensions.includes(ext)) {
results.push(itemPath);
}
}
}
return results;
}
const imageExtensions = [".PNG", ".GIF", ".JPG", ".JPEG", ".AVIF", ".WEBP"];
const imagePaths = [];
for (const subfolderPath of optimizedImagesFolders) {
const paths = findImageFiles(subfolderPath, imageExtensions);
imagePaths.push(...paths);
}
// find the optimized images that are no longer used in the project
const unusedImages = [];
for (const imagePath of imagePaths) {
const isUsed = allGeneratedImages.includes(imagePath);
if (!isUsed) {
unusedImages.push(imagePath);
}
}
// delete the unused images
for (const imagePath of unusedImages) {
if (fs.existsSync(imagePath)) {
fs.unlinkSync(imagePath);
}
}
if (unusedImages.length > 0)
console.log(`Deleted ${unusedImages.length} unused image${unusedImages.length > 1 ? "s" : ""} from the optimized images folders.`);
progressBar.stop();
console.log("---- next-image-export-optimizer: Done ---- ");
process.exit(0);
};
if (require.main === module) {
nextImageExportOptimizer();
}
module.exports = nextImageExportOptimizer;
;