From 65f608bbe218ed753cfc022f72cbbc59172c6f01 Mon Sep 17 00:00:00 2001 From: mricoul Date: Thu, 5 Jun 2025 15:28:28 +0200 Subject: [PATCH] fix (webpack-image-sizes-plugin): optimizes image generation and processing Improves the image generation process by adding change detection based on timestamps to avoid unnecessary rebuilds. Refactors TPL parsing to extract comprehensive size and crop information, ensuring accurate image configuration. Enhances default image generation by skipping existing images and updating only outdated ones. Cleans up image locations data for output by removing internal properties. --- config/webpack-image-sizes-plugin.js | 442 +++++++++++++++++++++------ 1 file changed, 356 insertions(+), 86 deletions(-) diff --git a/config/webpack-image-sizes-plugin.js b/config/webpack-image-sizes-plugin.js index 3b90d0b3..2cf3b773 100644 --- a/config/webpack-image-sizes-plugin.js +++ b/config/webpack-image-sizes-plugin.js @@ -47,6 +47,8 @@ class WebpackImageSizesPlugin { silence: options.silence || false, ...options, } + // Track last generation timestamp to avoid unnecessary rebuilds + this.lastGenerationTime = 0 } /** @@ -77,6 +79,15 @@ class WebpackImageSizesPlugin { const sizesPath = path.join(confImgPath, this.options.sizesSubdir) const tplPath = path.join(confImgPath, this.options.tplSubdir) + // Check if we need to regenerate based on file changes + if (this.shouldSkipGeneration(confImgPath, sizesPath, tplPath)) { + this.log('log', '⏭️ No changes detected, skipping generation') + if (callback) { + callback() + } + return + } + this.log('log', '🔧 Starting WebpackImageSizesPlugin generation...') // Check for deleted/renamed files if output files already exist @@ -88,15 +99,21 @@ class WebpackImageSizesPlugin { // Generate image-sizes.json from JSON files and TPL files const imageSizes = this.generateImageSizes(sizesPath, tplPath, imageLocations) + // Clean image-locations.json from internal properties before writing + const cleanedImageLocations = this.cleanImageLocationsForOutput(imageLocations) + // Write output files const imageLocationsPath = path.join(confImgPath, this.options.outputImageLocations) const imageSizesPath = path.join(confImgPath, this.options.outputImageSizes) - fs.writeFileSync(imageLocationsPath, JSON.stringify(imageLocations, null, 2)) + fs.writeFileSync(imageLocationsPath, JSON.stringify(cleanedImageLocations, null, 2)) fs.writeFileSync(imageSizesPath, JSON.stringify(imageSizes, null, 2)) this.log('log', `✅ Generated ${this.options.outputImageLocations} and ${this.options.outputImageSizes}`) + // Update last generation timestamp + this.lastGenerationTime = Date.now() + // Generate default images if option is enabled if (this.options.generateDefaultImages) { await this.generateDefaultImages(compiler.context, imageSizes) @@ -113,15 +130,9 @@ class WebpackImageSizesPlugin { } } - // Hook for initial build + // Hook for initial build only compiler.hooks.emit.tapAsync('WebpackImageSizesPlugin', runGeneration) - // Hook for rebuilds in watch mode - compiler.hooks.watchRun.tapAsync('WebpackImageSizesPlugin', (compiler, callback) => { - this.log('log', '👀 Watch mode: checking for conf-img changes...') - runGeneration(null, callback) - }) - // Add directories to watch compiler.hooks.afterEnvironment.tap('WebpackImageSizesPlugin', () => { const confImgPath = path.resolve(compiler.context, this.options.confImgPath) @@ -158,37 +169,47 @@ class WebpackImageSizesPlugin { const imageSizes = [{}] const allSizes = new Set() // To avoid duplicates - if (!fs.existsSync(sizesPath)) { - this.log('warn', `⚠️ Sizes directory not found: ${sizesPath}`) - return imageSizes + // Check if both directories exist + const sizesExists = fs.existsSync(sizesPath) + const tplExists = fs.existsSync(tplPath) + + if (!sizesExists && !tplExists) { + throw new Error(`Both sizes directory (${sizesPath}) and tpl directory (${tplPath}) not found`) } - const sizeFiles = fs.readdirSync(sizesPath).filter((file) => file.endsWith('.json')) - this.log('log', `📋 Processing ${sizeFiles.length} size files: ${sizeFiles.join(', ')}`) + if (!sizesExists) { + this.log('warn', `⚠️ Sizes directory not found: ${sizesPath}, continuing with tpl directory only`) + } - sizeFiles.forEach((file) => { - try { - const filePath = path.join(sizesPath, file) - const sizesData = JSON.parse(fs.readFileSync(filePath, 'utf8')) - - // Convert sizes to image-sizes.json format - sizesData.forEach((size) => { - const sizeKey = `img-${size.width}-${size.height}` - - // Avoid duplicates between files - if (!allSizes.has(sizeKey)) { - allSizes.add(sizeKey) - imageSizes[0][sizeKey] = { - width: size.width.toString(), - height: size.height.toString(), - crop: size.crop, + // Process JSON files only if sizes directory exists + if (sizesExists) { + const sizeFiles = fs.readdirSync(sizesPath).filter((file) => file.endsWith('.json')) + this.log('log', `📋 Processing ${sizeFiles.length} size files: ${sizeFiles.join(', ')}`) + + sizeFiles.forEach((file) => { + try { + const filePath = path.join(sizesPath, file) + const sizesData = JSON.parse(fs.readFileSync(filePath, 'utf8')) + + // Convert sizes to image-sizes.json format + sizesData.forEach((size) => { + const sizeKey = `img-${size.width}-${size.height}` + + // Avoid duplicates between files + if (!allSizes.has(sizeKey)) { + allSizes.add(sizeKey) + imageSizes[0][sizeKey] = { + width: size.width.toString(), + height: size.height.toString(), + crop: size.crop, + } } - } - }) - } catch (error) { - this.log('error', `❌ Error parsing ${file}:`, error) - } - }) + }) + } catch (error) { + this.log('error', `❌ Error parsing ${file}:`, error) + } + }) + } // Extract additional sizes from TPL files that are not in JSON files this.extractSizesFromTPLFiles(tplPath, imageLocations, imageSizes[0], allSizes) @@ -213,8 +234,9 @@ class WebpackImageSizesPlugin { return } - // Extract all unique sizes from image locations + // Extract all unique sizes from image locations and collect crop values const tplSizes = new Set() + const sizeCropMapping = new Map() // Store crop values for each size Object.values(imageLocations[0]).forEach((locationArray) => { locationArray.forEach((location) => { @@ -222,12 +244,41 @@ class WebpackImageSizesPlugin { location.srcsets.forEach((srcset) => { if (srcset.size && srcset.size.startsWith('img-')) { tplSizes.add(srcset.size) + // If srcset has crop info, store it + if (srcset.crop !== undefined) { + sizeCropMapping.set(srcset.size, srcset.crop) + } } }) } }) }) + // Also parse TPL files directly to ensure we catch all sizes and their crop values + const tplFiles = fs.readdirSync(tplPath).filter((file) => file.endsWith('.tpl')) + + tplFiles.forEach((file) => { + try { + const filePath = path.join(tplPath, file) + const tplContent = fs.readFileSync(filePath, 'utf8') + const locationData = this.parseTPLContent(tplContent) + + if (locationData && locationData.srcsets && locationData.sizeCropMap) { + locationData.srcsets.forEach((srcset) => { + if (srcset.size && srcset.size.startsWith('img-')) { + tplSizes.add(srcset.size) + // Store crop value from TPL parsing + if (locationData.sizeCropMap[srcset.size] !== undefined) { + sizeCropMapping.set(srcset.size, locationData.sizeCropMap[srcset.size]) + } + } + }) + } + } catch (error) { + this.log('error', `❌ Error extracting sizes from TPL ${file}:`, error) + } + }) + // Add sizes that are not already defined tplSizes.forEach((sizeKey) => { if (!allSizes.has(sizeKey)) { @@ -237,19 +288,123 @@ class WebpackImageSizesPlugin { const width = matches[1] const height = matches[2] + // Use the crop value from TPL parsing, default to true if not found + const cropValue = sizeCropMapping.get(sizeKey) !== undefined ? sizeCropMapping.get(sizeKey) : true + allSizes.add(sizeKey) imageSizesObj[sizeKey] = { width: width, height: height, - crop: true, // Default crop value for TPL-extracted sizes + crop: cropValue, // Use the actual crop value from TPL } - this.log('log', `🎨 Added size from TPL: ${sizeKey}`) + this.log('log', `🎨 Added size from TPL: ${sizeKey} (crop: ${cropValue})`) } } }) } + /** + * Parses TPL content to extract image information including srcsets, default_img, and img_base. + * + * @param {string} tplContent - Content of the TPL file + * @returns {Object|null} Parsed location data or null if no valid data found + * @memberof WebpackImageSizesPlugin + */ + parseTPLContent(tplContent) { + // Look for source tags with srcset or data-srcset containing image patterns + let sourceMatches = tplContent.match(/]*(?:data-srcset|srcset)="([^"]*)"[^>]*>/g) + + if (!sourceMatches || sourceMatches.length === 0) { + // Fallback to simple image pattern extraction + const sizeMatches = tplContent.match(/%%img-(\d+)-(\d+)%%/g) + if (sizeMatches) { + const srcsets = [...new Set(sizeMatches)].map((match) => { + const size = match.replace(/%%/g, '') + return { size, srcset: '' } + }) + return { srcsets } + } + return null + } + + // Parse all source tags and extract all sizes from all sources + const srcsets = [] + const allSizes = new Set() // To avoid duplicates + + sourceMatches.forEach((sourceMatch) => { + const srcsetMatch = sourceMatch.match(/(?:data-srcset|srcset)="([^"]*)"/) + if (srcsetMatch) { + const srcsetContent = srcsetMatch[1] + + // Extract data-crop attribute value + const cropMatch = sourceMatch.match(/data-crop="([^"]*)"/) + const cropValue = cropMatch ? cropMatch[1] === 'true' : true // Default to true if not specified + + // Parse patterns like: %%img-144-144%%, %%img-288-288%% 2x + const imageMatches = srcsetContent.match(/%%img-(\d+)-(\d+)%%(\s+2x)?/g) + + if (imageMatches) { + imageMatches.forEach((match) => { + const sizeMatch = match.match(/%%img-(\d+)-(\d+)%%/) + const is2x = match.includes('2x') + + if (sizeMatch) { + const width = parseInt(sizeMatch[1]) + const height = parseInt(sizeMatch[2]) + const size = `img-${width}-${height}` + + // Avoid duplicates across different sources + if (!allSizes.has(size)) { + allSizes.add(size) + srcsets.push({ + srcset: is2x ? '2x' : '', + size: size, + crop: cropValue, // Store crop value for later use + }) + } + } + }) + } + } + }) + + // Check if we found any sizes from all sources + if (srcsets.length === 0) { + return null + } + + // All sizes have been processed above, no additional processing needed + + // Determine default_img and img_base (use the largest size, typically the 2x version) + const largestSize = srcsets.reduce((largest, current) => { + const currentMatch = current.size.match(/img-(\d+)-(\d+)/) + const largestMatch = largest.size.match(/img-(\d+)-(\d+)/) + + if (currentMatch && largestMatch) { + const currentArea = parseInt(currentMatch[1]) * parseInt(currentMatch[2]) + const largestArea = parseInt(largestMatch[1]) * parseInt(largestMatch[2]) + return currentArea > largestArea ? current : largest + } + return largest + }) + + // Create a map of size to crop value for later use + const sizeCropMap = {} + srcsets.forEach((srcset) => { + sizeCropMap[srcset.size] = srcset.crop + }) + + const result = { + srcsets: srcsets, + default_img: `default-${largestSize.size.replace('img-', '')}.${this.options.defaultImageFormat}`, + img_base: largestSize.size, + sizeCropMap: sizeCropMap, // Include crop mapping + } + + return result + } + /** * Generates image locations configuration by parsing JSON and TPL files. * @@ -263,40 +418,49 @@ class WebpackImageSizesPlugin { const imageLocations = [{}] const processedFiles = new Set() // For tracking processed files - if (!fs.existsSync(sizesPath)) { - this.log('warn', `⚠️ Sizes directory not found: ${sizesPath}`) - return imageLocations + // Check if both directories exist + const sizesExists = fs.existsSync(sizesPath) + const tplExists = fs.existsSync(tplPath) + + if (!sizesExists && !tplExists) { + throw new Error(`Both sizes directory (${sizesPath}) and tpl directory (${tplPath}) not found`) } - // Process JSON files in sizes/ first - const sizeFiles = fs.readdirSync(sizesPath).filter((file) => file.endsWith('.json')) - this.log('log', `📋 Processing ${sizeFiles.length} JSON files from sizes/: ${sizeFiles.join(', ')}`) + if (!sizesExists) { + this.log('warn', `⚠️ Sizes directory not found: ${sizesPath}, continuing with tpl directory only`) + } - sizeFiles.forEach((file) => { - try { - const filename = path.basename(file, '.json') - const filePath = path.join(sizesPath, file) - const sizesData = JSON.parse(fs.readFileSync(filePath, 'utf8')) - - // Generate srcsets from sizes - const srcsets = sizesData.map((size) => ({ - size: `img-${size.width}-${size.height}`, - })) - - imageLocations[0][filename] = [ - { - srcsets: srcsets, - }, - ] - - processedFiles.add(filename) - } catch (error) { - this.log('error', `❌ Error parsing JSON ${file}:`, error) - } - }) + // Process JSON files in sizes/ first (only if directory exists) + if (sizesExists) { + const sizeFiles = fs.readdirSync(sizesPath).filter((file) => file.endsWith('.json')) + this.log('log', `📋 Processing ${sizeFiles.length} JSON files from sizes/: ${sizeFiles.join(', ')}`) + + sizeFiles.forEach((file) => { + try { + const filename = path.basename(file, '.json') + const filePath = path.join(sizesPath, file) + const sizesData = JSON.parse(fs.readFileSync(filePath, 'utf8')) + + // Generate srcsets from sizes + const srcsets = sizesData.map((size) => ({ + size: `img-${size.width}-${size.height}`, + })) + + imageLocations[0][filename] = [ + { + srcsets: srcsets, + }, + ] + + processedFiles.add(filename) + } catch (error) { + this.log('error', `❌ Error parsing JSON ${file}:`, error) + } + }) + } // Then process TPL files for unprocessed or missing files - if (fs.existsSync(tplPath)) { + if (tplExists) { const tplFiles = fs.readdirSync(tplPath).filter((file) => file.endsWith('.tpl')) this.log('log', `📋 Processing ${tplFiles.length} TPL files: ${tplFiles.join(', ')}`) @@ -306,24 +470,15 @@ class WebpackImageSizesPlugin { const filePath = path.join(tplPath, file) const tplContent = fs.readFileSync(filePath, 'utf8') - // Extract sizes from templates (pattern %%img-width-height%%) - const sizeMatches = tplContent.match(/%%img-(\d+)-(\d+)%%/g) - // Process only if not already processed by a JSON file or no JSON match - if (sizeMatches && !processedFiles.has(filename)) { - const srcsets = [...new Set(sizeMatches)].map((match) => { - const size = match.replace(/%%/g, '') - return { size } - }) - - imageLocations[0][filename] = [ - { - srcsets: srcsets, - }, - ] - - processedFiles.add(filename) - this.log('log', `📝 Added location from TPL: ${filename}`) + if (!processedFiles.has(filename)) { + const locationData = this.parseTPLContent(tplContent) + + if (locationData && locationData.srcsets && locationData.srcsets.length > 0) { + imageLocations[0][filename] = [locationData] + processedFiles.add(filename) + this.log('log', `📝 Added location from TPL: ${filename}`) + } } } catch (error) { this.log('error', `❌ Error parsing TPL ${file}:`, error) @@ -380,11 +535,14 @@ class WebpackImageSizesPlugin { this.log( 'log', - `🖼️ Generating ${sizeKeys.length} default images (${format.toUpperCase()}) from ${ + `🖼️ Processing ${sizeKeys.length} default images (${format.toUpperCase()}) from ${ this.options.defaultImageSource }` ) + let generatedCount = 0 + let skippedCount = 0 + const promises = sizeKeys.map(async (sizeKey) => { const size = sizesObj[sizeKey] const width = parseInt(size.width) @@ -392,6 +550,26 @@ class WebpackImageSizesPlugin { const outputFilename = `default-${width}-${height}.${this.options.defaultImageFormat}` const outputPath = path.join(outputDir, outputFilename) + // Check if image needs to be generated or updated + if (fs.existsSync(outputPath)) { + try { + // Compare modification times: regenerate if source is newer + const sourceStats = fs.statSync(sourceImagePath) + const outputStats = fs.statSync(outputPath) + + if (sourceStats.mtime <= outputStats.mtime) { + this.log('log', `⏭️ Skipped existing: ${outputFilename}`) + skippedCount++ + return + } else { + this.log('log', `🔄 Updating outdated: ${outputFilename}`) + } + } catch (error) { + // If we can't compare timestamps, regenerate to be safe + this.log('log', `🔄 Regenerating (timestamp check failed): ${outputFilename}`) + } + } + try { let sharpInstance = sharp(sourceImagePath) @@ -414,13 +592,14 @@ class WebpackImageSizesPlugin { await sharpInstance[formatOptions.method](formatOptions.options).toFile(outputPath) this.log('log', `✨ Generated: ${outputFilename} (${width}x${height}${size.crop ? ', cropped' : ''})`) + generatedCount++ } catch (error) { this.log('error', `❌ Error generating ${outputFilename}:`, error.message) } }) await Promise.all(promises) - this.log('log', `🎉 Default image generation completed!`) + this.log('log', `🎉 Default image generation completed! (${generatedCount} generated, ${skippedCount} skipped)`) } /** @@ -459,6 +638,97 @@ class WebpackImageSizesPlugin { return formatConfigs[formatLower] || formatConfigs.jpg } + /** + * Cleans image locations data by removing internal properties before output. + * + * @param {Array} imageLocations - Image locations data with internal properties + * @returns {Array} Cleaned image locations data + * @memberof WebpackImageSizesPlugin + */ + cleanImageLocationsForOutput(imageLocations) { + return imageLocations.map((locationGroup) => { + const cleanedGroup = {} + + Object.keys(locationGroup).forEach((key) => { + cleanedGroup[key] = locationGroup[key].map((location) => { + const cleanedLocation = { + srcsets: location.srcsets.map((srcset) => ({ + srcset: srcset.srcset, + size: srcset.size, + // Remove crop property from srcsets + })), + default_img: location.default_img, + img_base: location.img_base, + // Remove sizeCropMap property + } + + return cleanedLocation + }) + }) + + return cleanedGroup + }) + } + + /** + * Determines if generation should be skipped based on file modification times. + * + * @param {string} confImgPath - Path to the conf-img directory + * @param {string} sizesPath - Path to the sizes directory + * @param {string} tplPath - Path to the tpl directory + * @returns {boolean} True if generation should be skipped + * @memberof WebpackImageSizesPlugin + */ + shouldSkipGeneration(confImgPath, sizesPath, tplPath) { + // Skip if never generated before + if (this.lastGenerationTime === 0) { + return false + } + + // Check modification times of directories and files + const checkPaths = [ + { path: sizesPath, isDir: true }, + { path: tplPath, isDir: true }, + ] + + for (const { path: checkPath, isDir } of checkPaths) { + if (!fs.existsSync(checkPath)) { + continue + } + + try { + if (isDir) { + // Check directory modification time and all files within + const dirStats = fs.statSync(checkPath) + if (dirStats.mtime.getTime() > this.lastGenerationTime) { + return false + } + + // Check all files in directory + const files = fs.readdirSync(checkPath) + for (const file of files) { + const filePath = path.join(checkPath, file) + const fileStats = fs.statSync(filePath) + if (fileStats.mtime.getTime() > this.lastGenerationTime) { + return false + } + } + } else { + // Check single file modification time + const stats = fs.statSync(checkPath) + if (stats.mtime.getTime() > this.lastGenerationTime) { + return false + } + } + } catch (error) { + // If we can't check the file, don't skip generation + return false + } + } + + return true + } + /** * Checks for deleted or renamed files by comparing current files with existing configuration. *