diff --git a/.dockerignore b/.dockerignore index 96b110d..6d54d6c 100644 --- a/.dockerignore +++ b/.dockerignore @@ -2,6 +2,8 @@ !lib/ !.optimiztrc.cjs !cli.js +!convert.js !index.js !LICENSE +!optimize.js !package*.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 75e5fb0..790a86c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ # Changelog +## 9.0.0 (2024-10-03) + +Changes: + +- Improved image processing workflow. Check the [migration guide](./MIGRATION.md) for details. +- Added an interactive log for image processing. +- Changed the “File already exists” message from “error” to “info,” now only shown in verbose mode. +- Added a notice that animated AVIF is not supported (unfortunately). +- Fixed the progress indicator in conversion mode. It now correctly shows the total number of items. +- Fixed output to the user directory. The original folder structure is now preserved (i hope so!). + ## 8.0.0 (2024-08-05) ❤️ Thank you for using Optimizt. If you have any suggestions or feedback, please don't hesitate to [open an issue](https://github.com/343dev/optimizt/issues). diff --git a/MIGRATION.md b/MIGRATION.md index ac6c20f..879cd23 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -1,5 +1,16 @@ # Migration +## 8.0.1 → 9.0.0 + +The main change in the new version is how Optimizt handles file processing. Before, the result was stored in memory until all files were processed. This could cause the app to crash if it ran out of memory, leading to a loss of optimization results. Now, each file is processed one by one, and the result is saved to disk immediately. Logging events also happen in real-time, instead of waiting until all files are done. + +Previously, if you tried to create an AVIF or WebP file that already existed, an error message would appear in the log. Now, it shows an informational message, but only if you use the --verbose option. + +To handle AVIF files, Optimizt uses the sharp module, which uses the libheif library. However, libheif doesn't support converting to animated AVIF (AVIS). In earlier versions, Optimizt would quietly convert animated GIFs into static AVIF. But from this version, an error will show up if you try to do this. + +The logic for creating file structures when using the --output option has also changed. Now, the original file structure will be recreated inside the output folder, starting from the folder passed to the app. Who knows how this worked before, but hopefully, it works better now! + + ## 7.0.2 → 8.0.0 Optimizt now checks for the EXIF Orientation tag before processing images and rotates the resulting image accordingly. diff --git a/cli.js b/cli.js index 156420b..cfa07d7 100755 --- a/cli.js +++ b/cli.js @@ -7,6 +7,7 @@ import { fileURLToPath } from 'node:url'; import { program } from 'commander'; import optimizt from './index.js'; +import { setProgramOptions } from './lib/program-options.js'; const dirname = path.dirname(fileURLToPath(import.meta.url)); const packageJson = JSON.parse(await fs.readFile(path.join(dirname, 'package.json'))); @@ -29,9 +30,20 @@ program if (program.args.length === 0) { program.help(); } else { + const { avif, webp, force, lossless, verbose, config, output } = program.opts(); + + setProgramOptions({ + shouldConvertToAvif: Boolean(avif), + shouldConvertToWebp: Boolean(webp), + isForced: Boolean(force), + isLossless: Boolean(lossless), + isVerbose: Boolean(verbose), + }); + optimizt({ - paths: program.args, - ...program.opts(), + inputPaths: program.args, + outputDirectoryPath: output, + configFilePath: config, }); } diff --git a/convert.js b/convert.js new file mode 100644 index 0000000..c78d856 --- /dev/null +++ b/convert.js @@ -0,0 +1,222 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import execBuffer from 'exec-buffer'; +import gif2webp from 'gif2webp-bin'; +import pLimit from 'p-limit'; +import sharp from 'sharp'; + +import { calculateRatio } from './lib/calculate-ratio.js'; +import { checkPathAccessibility } from './lib/check-path-accessibility.js'; +import { createProgressBarContainer } from './lib/create-progress-bar-container.js'; +import { SUPPORTED_FILE_TYPES } from './lib/constants.js'; +import { formatBytes } from './lib/format-bytes.js'; +import { getPlural } from './lib/get-plural.js'; +import { getRelativePath } from './lib/get-relative-path.js'; +import { + LOG_TYPES, + log, + logProgress, + logProgressVerbose, +} from './lib/log.js'; +import { optionsToArguments } from './lib/options-to-arguments.js'; +import { parseImageMetadata } from './lib/parse-image-metadata.js'; +import { programOptions } from './lib/program-options.js'; +import { showTotal } from './lib/show-total.js'; + +export async function convert({ filePaths, config }) { + const { + isForced, + isLossless, + shouldConvertToAvif, + shouldConvertToWebp, + } = programOptions; + + const filePathsCount = filePaths.length; + + if (!filePathsCount) { + return; + } + + log(`Converting ${filePathsCount} ${getPlural(filePathsCount, 'image', 'images')} (${isLossless ? 'lossless' : 'lossy'})...`); + + const progressBarTotal = shouldConvertToAvif && shouldConvertToWebp + ? filePathsCount * 2 + : filePathsCount; + const progressBarContainer = createProgressBarContainer(progressBarTotal); + const progressBar = progressBarContainer.create(progressBarTotal, 0); + + const totalSize = { before: 0, after: 0 }; + + const avifConfig = isLossless + ? config?.avif?.lossless + : config?.avif?.lossy; + const webpConfig = isLossless + ? config?.webp?.lossless + : config?.webp?.lossy; + const webpGifConfig = isLossless + ? config?.webpGif?.lossless + : config?.webpGif?.lossy; + + const tasksSimultaneousLimit = pLimit(os.cpus().length); + const tasksPromises = filePaths.reduce((accumulator, filePath) => { + if (shouldConvertToAvif) { + accumulator.push( + tasksSimultaneousLimit( + () => processFile({ + filePath, + config: avifConfig || {}, + progressBarContainer, + progressBar, + totalSize, + isForced, + format: 'AVIF', + processFunction: processAvif, + }), + ), + ); + } + + if (shouldConvertToWebp) { + accumulator.push( + tasksSimultaneousLimit( + () => processFile({ + filePath, + config: (path.extname(filePath.input).toLowerCase() === '.gif' + ? webpGifConfig + : webpConfig) + || {}, + progressBarContainer, + progressBar, + totalSize, + isForced, + format: 'WebP', + processFunction: processWebp, + }), + ), + ); + } + + return accumulator; + }, []); + + await Promise.all(tasksPromises); + progressBarContainer.update(); // Prevent logs lost. See: https://github.com/npkgz/cli-progress/issues/145#issuecomment-1859594159 + progressBarContainer.stop(); + + showTotal(totalSize.before, totalSize.after); +} + +async function processFile({ + filePath, + config, + progressBarContainer, + progressBar, + totalSize, + isForced, + format, + processFunction, +}) { + try { + const { dir, name } = path.parse(filePath.output); + const outputFilePath = path.join(dir, `${name}.${format.toLowerCase()}`); + + const isAccessible = await checkPathAccessibility(outputFilePath); + + if (!isForced && isAccessible) { + logProgressVerbose(getRelativePath(outputFilePath), { + description: `File already exists, '${outputFilePath}'`, + progressBarContainer, + }); + + return; + } + + const fileBuffer = await fs.promises.readFile(filePath.input); + const processedFileBuffer = await processFunction({ fileBuffer, config }); + + await fs.promises.mkdir(path.dirname(outputFilePath), { recursive: true }); + await fs.promises.writeFile(outputFilePath, processedFileBuffer); + + const fileSize = fileBuffer.length; + const processedFileSize = processedFileBuffer.length; + + totalSize.before += fileSize; + totalSize.after += Math.min(fileSize, processedFileSize); + + const ratio = calculateRatio(fileSize, processedFileSize); + const before = formatBytes(fileSize); + const after = formatBytes(processedFileSize); + + logProgress(getRelativePath(filePath.input), { + type: LOG_TYPES.SUCCESS, + description: `${before} → ${format} ${after}. Ratio: ${ratio}%`, + progressBarContainer, + }); + } catch (error) { + if (error.message) { + logProgress(getRelativePath(filePath.input), { + type: LOG_TYPES.ERROR, + description: (error.message || '').trim(), + progressBarContainer, + }); + } else { + progressBarContainer.log(error); + } + } finally { + progressBar.increment(); + } +} + +async function processAvif({ fileBuffer, config }) { + const imageMetadata = await parseImageMetadata(fileBuffer); + checkImageFormat(imageMetadata.format); + + const isAnimated = imageMetadata.pages > 1; + + if (isAnimated) { + throw new Error('Animated AVIF is not supported'); // See: https://github.com/strukturag/libheif/issues/377 + } + + return sharp(fileBuffer) + .rotate() // Rotate image using information from EXIF Orientation tag + .avif(config) + .toBuffer(); +} + +async function processWebp({ fileBuffer, config }) { + const imageMetadata = await parseImageMetadata(fileBuffer); + checkImageFormat(imageMetadata.format); + + if (imageMetadata.format === 'gif') { + return execBuffer({ + bin: gif2webp, + args: [ + ...optionsToArguments({ + options: config, + prefix: '-', + }), + execBuffer.input, + '-o', + execBuffer.output, + ], + input: fileBuffer, + }); + } + + return sharp(fileBuffer) + .rotate() // Rotate image using information from EXIF Orientation tag + .webp(config) + .toBuffer(); +} + +function checkImageFormat(imageFormat) { + if (!imageFormat) { + throw new Error('Unknown file format'); + } + + if (!SUPPORTED_FILE_TYPES.CONVERT.includes(imageFormat)) { + throw new Error(`Unsupported image format: "${imageFormat}"`); + } +} diff --git a/index.js b/index.js index 8012c14..5631248 100755 --- a/index.js +++ b/index.js @@ -1,33 +1,57 @@ import { pathToFileURL } from 'node:url'; -import checkConfigPath from './lib/check-config-path.js'; -import convert from './lib/convert.js'; -import findConfig from './lib/find-config.js'; -import { enableVerbose } from './lib/log.js'; -import optimize from './lib/optimize.js'; -import prepareFilePaths from './lib/prepare-file-paths.js'; -import prepareOutputPath from './lib/prepare-output-path.js'; - -export default async function optimizt({ paths, avif, webp, force, lossless, verbose, output, config }) { - const configFilepath = pathToFileURL(config ? checkConfigPath(config) : findConfig()); - const configData = await import(configFilepath); - - if (verbose) { - enableVerbose(); +import { convert } from './convert.js'; +import { optimize } from './optimize.js'; + +import { SUPPORTED_FILE_TYPES } from './lib/constants.js'; +import { findConfigFilePath } from './lib/find-config-file-path.js'; +import { log } from './lib/log.js'; +import { prepareFilePaths } from './lib/prepare-file-paths.js'; +import { prepareOutputDirectoryPath } from './lib/prepare-output-directory-path.js'; +import { programOptions } from './lib/program-options.js'; + +const MODE_NAME = { + CONVERT: 'convert', + OPTIMIZE: 'optimize', +}; + +export default async function optimizt({ + inputPaths, + outputDirectoryPath, + configFilePath, +}) { + const { + isLossless, + shouldConvertToAvif, + shouldConvertToWebp, + } = programOptions; + + const shouldConvert = shouldConvertToAvif || shouldConvertToWebp; + + const currentMode = shouldConvert + ? MODE_NAME.CONVERT + : MODE_NAME.OPTIMIZE; + + const foundConfigFilePath = pathToFileURL(await findConfigFilePath(configFilePath)); + const configData = await import(foundConfigFilePath); + const config = configData.default[currentMode.toLowerCase()]; + + const filePaths = await prepareFilePaths({ + inputPaths, + outputDirectoryPath: await prepareOutputDirectoryPath(outputDirectoryPath), + extensions: SUPPORTED_FILE_TYPES[currentMode.toUpperCase()], + }); + + if (isLossless) { + log('Lossless optimization may take a long time'); } - await (avif || webp ? convert({ - paths: prepareFilePaths(paths, ['gif', 'jpeg', 'jpg', 'png']), - lossless, - avif, - webp, - force, - output: prepareOutputPath(output), - config: configData.default.convert, - }) : optimize({ - paths: prepareFilePaths(paths, ['gif', 'jpeg', 'jpg', 'png', 'svg']), - lossless, - output: prepareOutputPath(output), - config: configData.default.optimize, - })); + const processFunction = shouldConvert + ? convert + : optimize; + + await processFunction({ + filePaths, + config, + }); } diff --git a/lib/calc-ratio.js b/lib/calculate-ratio.js similarity index 51% rename from lib/calc-ratio.js rename to lib/calculate-ratio.js index e9c8d9c..26f1e46 100644 --- a/lib/calc-ratio.js +++ b/lib/calculate-ratio.js @@ -1,3 +1,3 @@ -export default function calcRatio(from, to) { +export function calculateRatio(from, to) { return Math.round((from - to) / from * 100); } diff --git a/lib/check-config-path.js b/lib/check-config-path.js deleted file mode 100644 index 6f0f48a..0000000 --- a/lib/check-config-path.js +++ /dev/null @@ -1,18 +0,0 @@ -import fs from 'node:fs'; -import path from 'node:path'; - -import { logErrorAndExit } from './log.js'; - -export default function checkConfigPath(filepath = '') { - const resolvedPath = path.resolve(filepath); - - if (!fs.existsSync(resolvedPath)) { - logErrorAndExit('Provided config path does not exist'); - } - - if (!fs.statSync(resolvedPath).isFile()) { - logErrorAndExit('Provided config path must point to a file'); - } - - return resolvedPath; -} diff --git a/lib/check-path-accessibility.js b/lib/check-path-accessibility.js new file mode 100644 index 0000000..f4788f7 --- /dev/null +++ b/lib/check-path-accessibility.js @@ -0,0 +1,10 @@ +import { access } from 'node:fs/promises'; + +export async function checkPathAccessibility(filePath) { + try { + await access(filePath); + return true; + } catch { + return false; + } +} diff --git a/lib/colorize.js b/lib/colorize.js index 5ae5998..ff97f91 100644 --- a/lib/colorize.js +++ b/lib/colorize.js @@ -1,4 +1,4 @@ -export default function colorize(...arguments_) { +export function colorize(...arguments_) { const string_ = arguments_.join(' '); const isTTY = Boolean(process.stdout.isTTY); const buildColor = (start, end) => `${isTTY ? start : ''}${string_}${isTTY ? end : ''}`; diff --git a/lib/constants.js b/lib/constants.js new file mode 100644 index 0000000..7353533 --- /dev/null +++ b/lib/constants.js @@ -0,0 +1,6 @@ +export const DEFAULT_CONFIG_FILENAME = '.optimiztrc.cjs'; + +export const SUPPORTED_FILE_TYPES = { + CONVERT: ['gif', 'jpeg', 'jpg', 'png'], + OPTIMIZE: ['gif', 'jpeg', 'jpg', 'png', 'svg'], +}; diff --git a/lib/convert.js b/lib/convert.js deleted file mode 100644 index 23f6e9c..0000000 --- a/lib/convert.js +++ /dev/null @@ -1,242 +0,0 @@ -import fs from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; - -import CliProgress from 'cli-progress'; -import execBuffer from 'exec-buffer'; -import gif2webp from 'gif2webp-bin'; -import pLimit from 'p-limit'; -import sharp from 'sharp'; - -import calcRatio from './calc-ratio.js'; -import formatBytes from './format-bytes.js'; -import getImageFormat from './get-image-format.js'; -import getPlural from './get-plural.js'; -import log from './log.js'; -import optionsToArguments from './options-to-arguments.js'; -import prepareWriteFilePath from './prepare-write-file-path.js'; -import showTotal from './show-total.js'; - -export default async function convert({ - paths, - lossless, - avif, - webp, - force, - output, - config, -}) { - const totalPaths = paths.length; - - if (!totalPaths) { - return; - } - - log(`Converting ${totalPaths} ${getPlural(totalPaths, 'image', 'images')} (${lossless ? 'lossless' : 'lossy'})...`); - if (lossless) { - log('Lossless conversion may take a long time'); - } - - const progressBar = new CliProgress.SingleBar({ - format: `{bar} {percentage}% | Processed {value} of {total} ${getPlural(totalPaths, 'image', 'images')}`, - clearOnComplete: true, - stopOnComplete: true, - }, CliProgress.Presets.shades_classic); - - progressBar.start(totalPaths, 0); - - const limit = pLimit(os.cpus().length); - - const tasksErrors = []; - - const tasks = paths.reduce((accumulator, filePath) => { - if (avif) { - accumulator.push(limit(() => processImage({ - convertFunction: createAvif, - format: 'AVIF', - progressBar, - filePath, - lossless, - force, - tasksErrors, - output, - config: config?.avif || {}, - }))); - } - - if (webp) { - accumulator.push(limit(() => processImage({ - convertFunction: createWebp, - format: 'WebP', - progressBar, - filePath, - lossless, - force, - tasksErrors, - output, - config: { - webp: config?.webp || {}, - webpGif: config?.webpGif || {}, - }, - }))); - } - - return accumulator; - }, []); - - const totalSize = { before: 0, after: 0 }; - const tasksResult = await Promise.all(tasks); - - for (const { fileBuffer, filePath, format } of tasksResult.filter(Boolean)) { - const fileSize = { - before: fs.statSync(filePath).size, - after: fileBuffer.length, - }; - - const isOptimized = fileSize.before > fileSize.after; - - totalSize.before += fileSize.before; - totalSize.after += isOptimized ? fileSize.after : fileSize.before; - - writeResultFile({ - fileBuffer, - filePath, - fileSize, - format, - output, - }); - } - - for (const error of tasksErrors) { - log(...error); - } - - console.log(); - showTotal(totalSize.before, totalSize.after); -} - -function getOutputFilePath(filePath, format) { - const { dir, name } = path.parse(filePath); - return path.join(dir, `${name}.${format.toLowerCase()}`); -} - -function writeResultFile({ fileBuffer, filePath, fileSize, format, output }) { - if (!Buffer.isBuffer(fileBuffer) || typeof filePath !== 'string') { - return; - } - - try { - const writeFilePath = prepareWriteFilePath(getOutputFilePath(filePath, format), output); - - fs.writeFileSync(writeFilePath, fileBuffer); - - const before = formatBytes(fileSize.before); - const after = formatBytes(fileSize.after); - const ratio = calcRatio(fileSize.before, fileSize.after); - - const successMessage = `${before} → ${format} ${after}. Ratio: ${ratio}%`; - - log(filePath, { - type: 'success', - description: successMessage, - }); - } catch (error) { - if (error.message) { - log(filePath, { - type: 'error', - description: error.message, - }); - } else { - console.error(error); - } - } -} - -function processImage({ - filePath, - convertFunction, - lossless, - format, - force, - progressBar, - tasksErrors, - output, - config, -}) { - const writeFilePath = prepareWriteFilePath(getOutputFilePath(filePath, format), output); - - return fs.promises.readFile(filePath) - .then(fileBuffer => { - if (!force && fs.existsSync(writeFilePath)) { - throw new Error(`File already exists, '${writeFilePath}'`); - } - - return convertFunction({ - fileBuffer, - fileExt: path.extname(filePath).toLowerCase(), - lossless, - config, - }); - }) - .then(fileBuffer => { - progressBar.increment(); - return { fileBuffer, filePath, format }; - }) - .catch(error => { - progressBar.increment(); - tasksErrors.push([filePath, { - type: 'error', - description: (error.message || '').trim(), - }]); - }); -} - -async function createAvif({ fileBuffer, lossless, config }) { - const imageFormat = await getImageFormat(fileBuffer); - - if (!['jpeg', 'png', 'gif'].includes(imageFormat)) { - return fileBuffer; - } - - return sharp(fileBuffer) - .rotate() // Rotate image using information from EXIF Orientation tag - .avif((lossless ? config?.lossless : config?.lossy) || {}) - .toBuffer(); -} - -async function createWebp({ fileBuffer, fileExt, lossless, config }) { - const imageFormat = await getImageFormat(fileBuffer); - - switch (fileExt) { - case '.gif': { - if (imageFormat !== 'gif') { - return fileBuffer; - } - - return execBuffer({ - bin: gif2webp, - args: [ - ...optionsToArguments({ - options: (lossless ? config?.webpGif?.lossless : config?.webpGif?.lossy) || {}, - prefix: '-', - }), - execBuffer.input, - '-o', - execBuffer.output, - ], - input: fileBuffer, - }); - } - - default: { - if (!['jpeg', 'png'].includes(imageFormat)) { - return fileBuffer; - } - - return sharp(fileBuffer) - .rotate() // Rotate image using information from EXIF Orientation tag - .webp((lossless ? config?.webp?.lossless : config?.webp?.lossy) || {}) - .toBuffer(); - } - } -} diff --git a/lib/create-progress-bar-container.js b/lib/create-progress-bar-container.js new file mode 100644 index 0000000..ac36a28 --- /dev/null +++ b/lib/create-progress-bar-container.js @@ -0,0 +1,10 @@ +import CliProgress from 'cli-progress'; + +import { getPlural } from './get-plural.js'; + +export function createProgressBarContainer(totalCount) { + return new CliProgress.MultiBar({ + format: `{bar} {percentage}% | Processed {value} of {total} ${getPlural(totalCount, 'image', 'images')}`, + clearOnComplete: true, + }, CliProgress.Presets.shades_classic); +} diff --git a/lib/find-config-file-path.js b/lib/find-config-file-path.js new file mode 100644 index 0000000..1dcca7e --- /dev/null +++ b/lib/find-config-file-path.js @@ -0,0 +1,54 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { DEFAULT_CONFIG_FILENAME } from './constants.js'; +import { logErrorAndExit } from './log.js'; + +const defaultDirectoryPath = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const defaultConfigPath = path.join(defaultDirectoryPath, DEFAULT_CONFIG_FILENAME); + +export async function findConfigFilePath(providedConfigPath) { + if (providedConfigPath) { + const resolvedPath = path.resolve(providedConfigPath); + + try { + const stat = await fs.promises.stat(resolvedPath); + + if (!stat.isFile()) { + logErrorAndExit('Config path must point to a file'); + } + + return resolvedPath; + } catch { + logErrorAndExit(`Config file not exists: ${resolvedPath}`); + } + } + + let currentDirectoryPath = path.resolve(process.cwd()); + + while (true) { // eslint-disable-line no-constant-condition + const currentConfigPath = path.join(currentDirectoryPath, DEFAULT_CONFIG_FILENAME); + + try { + const stat = await fs.promises.stat(currentConfigPath); // eslint-disable-line no-await-in-loop + + if (stat.isFile()) { + return currentConfigPath; + } + } catch { + // File not found, continue searching + } + + const parentDirectoryPath = path.dirname(currentDirectoryPath); + + if (parentDirectoryPath === currentDirectoryPath) { + // Reached the root of the file system + break; + } + + currentDirectoryPath = parentDirectoryPath; + } + + return defaultConfigPath; +} diff --git a/lib/find-config.js b/lib/find-config.js deleted file mode 100644 index 714a341..0000000 --- a/lib/find-config.js +++ /dev/null @@ -1,22 +0,0 @@ -import fs from 'node:fs'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; - -const defaultFilename = '.optimiztrc.cjs'; -const defaultDirname = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); -const defaultPath = path.join(defaultDirname, defaultFilename); - -export default function findConfig(filepath = process.cwd()) { - const resolvedPath = path.resolve(filepath, defaultFilename); - - if (fs.existsSync(resolvedPath) && fs.statSync(resolvedPath).isFile()) { - return resolvedPath; - } - - const { root, dir } = path.parse(resolvedPath); - const isRootDirectory = dir === root; - - return isRootDirectory - ? defaultPath - : findConfig(path.dirname(dir)); -} diff --git a/lib/format-bytes.js b/lib/format-bytes.js index 0dd2a49..992e22b 100644 --- a/lib/format-bytes.js +++ b/lib/format-bytes.js @@ -1,14 +1,15 @@ -export default function formatBytes(bytes) { +const SIZES = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB']; +const DECIMALS = 3; +const K = 1024; +const LOG_K = Math.log(K); + +export function formatBytes(bytes) { if (bytes === 0) { return '0 Bytes'; } - const decimals = 3; - const k = 1024; - const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']; - - const index = Math.floor(Math.log(bytes) / Math.log(k)); + const index = Math.floor(Math.log(bytes) / LOG_K); + const value = Number.parseFloat((bytes / (K ** index)).toFixed(DECIMALS)); // Use parseFloat to show "1 KB" instead of "1.000 KB" - // Use parseFloat to show "1 KB" instead of "1.000 KB" - return `${Number.parseFloat((bytes / (k ** index)).toFixed(decimals))} ${sizes[index]}`; + return `${value} ${SIZES[index]}`; } diff --git a/lib/get-image-format.js b/lib/get-image-format.js deleted file mode 100644 index bd75f13..0000000 --- a/lib/get-image-format.js +++ /dev/null @@ -1,8 +0,0 @@ -import sharp from 'sharp'; - -export default async function getImageFormat(buffer) { - try { - const metadata = await sharp(buffer).metadata(); - return metadata.format; - } catch {} -} diff --git a/lib/get-plural.js b/lib/get-plural.js index 62b91d9..9fad0b7 100644 --- a/lib/get-plural.js +++ b/lib/get-plural.js @@ -1,3 +1,3 @@ -export default function getPlural(number_, one, many) { +export function getPlural(number_, one, many) { return Math.abs(number_) === 1 ? one : many; } diff --git a/lib/get-relative-path.js b/lib/get-relative-path.js new file mode 100644 index 0000000..348d301 --- /dev/null +++ b/lib/get-relative-path.js @@ -0,0 +1,9 @@ +import path from 'node:path'; + +export function getRelativePath(filePath) { + const replacePath = `${process.cwd()}${path.sep}`; + + return filePath.startsWith(replacePath) + ? filePath.slice(replacePath.length) + : filePath; +} diff --git a/lib/log.js b/lib/log.js index 209a5f3..248dd4b 100644 --- a/lib/log.js +++ b/lib/log.js @@ -1,44 +1,74 @@ import { EOL } from 'node:os'; -import colorize from './colorize.js'; +import { colorize } from './colorize.js'; +import { programOptions } from './program-options.js'; + +export const LOG_TYPES = { + INFO: 'info', + SUCCESS: 'success', + WARNING: 'warning', + ERROR: 'error', +}; const colors = { - info: 'blue', - success: 'green', - warning: 'yellow', - error: 'red', + [LOG_TYPES.INFO]: 'blue', + [LOG_TYPES.SUCCESS]: 'green', + [LOG_TYPES.WARNING]: 'yellow', + [LOG_TYPES.ERROR]: 'red', }; + const symbols = { - info: ['i', 'ℹ'], - success: ['√', '✔'], - warning: ['‼', '⚠'], - error: ['×', '✖'], + [LOG_TYPES.INFO]: ['i', 'ℹ'], + [LOG_TYPES.SUCCESS]: ['√', '✔'], + [LOG_TYPES.WARNING]: ['‼', '⚠'], + [LOG_TYPES.ERROR]: ['×', '✖'], }; -const symbolIndex = Number(process.platform !== 'win32' || process.env.TERM === 'xterm-256color'); -let isVerbose = false; - -function enableVerbose() { - isVerbose = true; -} +const isUnicodeSupported = process.platform !== 'win32' || process.env.TERM === 'xterm-256color'; +const symbolIndex = isUnicodeSupported ? 1 : 0; -function log(title = '', { type = 'info', description, verboseOnly } = {}) { - if (!isVerbose && verboseOnly && type === 'info') { - return; +function formatLogMessage(title, { type = LOG_TYPES.INFO, description } = {}) { + if (!title) { + throw new Error('Title is required'); } - console.log( + /* + We use an array to create the message so that we can conveniently test + the content of `console.log` in tests + */ + return [ colorize(symbols[type][symbolIndex])[colors[type]], title, ...description ? [EOL, ' ', colorize(description).dim] : [], - ); + ]; +} + +export function log(title, { type, description } = {}) { + console.log(...formatLogMessage(title, { type, description })); +} + +export function logErrorAndExit(title) { + log(title, { type: LOG_TYPES.ERROR }); + process.exit(1); // eslint-disable-line unicorn/no-process-exit } -function logErrorAndExit(title) { - log(title, { type: 'error' }); - // eslint-disable-next-line unicorn/no-process-exit - process.exit(1); +export function logEmptyLine() { + console.log(); } -export { enableVerbose, logErrorAndExit }; -export default log; +export function logProgress(title, { type, description, progressBarContainer } = {}) { + if (process.stdout.isTTY && progressBarContainer) { + const message = `${formatLogMessage(title, { type, description }).join(' ')}${EOL}`; + progressBarContainer.log(message); + + return; + } + + log(title, { type, description }); +} + +export function logProgressVerbose(title, { type, description, progressBarContainer } = {}) { + if (programOptions.isVerbose) { + logProgress(title, { type, description, progressBarContainer }); + } +} diff --git a/lib/optimize.js b/lib/optimize.js deleted file mode 100644 index 26cf4ec..0000000 --- a/lib/optimize.js +++ /dev/null @@ -1,222 +0,0 @@ -import fs from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; - -import CliProgress from 'cli-progress'; -import execBuffer from 'exec-buffer'; -import gifsicle from 'gifsicle'; -import guetzli from 'guetzli'; -import pLimit from 'p-limit'; -import sharp from 'sharp'; -import { optimize as svgoOptimize } from 'svgo'; - -import calcRatio from './calc-ratio.js'; -import formatBytes from './format-bytes.js'; -import getImageFormat from './get-image-format.js'; -import getPlural from './get-plural.js'; -import log from './log.js'; -import optionsToArguments from './options-to-arguments.js'; -import prepareWriteFilePath from './prepare-write-file-path.js'; -import showTotal from './show-total.js'; - -export default async function optimize({ paths, lossless: isLossless, output, config }) { - const totalPaths = paths.length; - - if (!totalPaths) { - return; - } - - log(`Optimizing ${totalPaths} ${getPlural(totalPaths, 'image', 'images')} (${isLossless ? 'lossless' : 'lossy'})...`); - if (isLossless) { - log('Lossless optimization may take a long time'); - } - - const progressBar = new CliProgress.SingleBar({ - format: `{bar} {percentage}% | Processed {value} of {total} ${getPlural(totalPaths, 'image', 'images')}`, - clearOnComplete: true, - stopOnComplete: true, - }, CliProgress.Presets.shades_classic); - - progressBar.start(totalPaths, 0); - - const limit = pLimit( - /* - Guetzli uses a large amount of memory and a significant amount of CPU time. - To reduce the processor load in lossless mode, we reduce the number - of simultaneous tasks by half. - */ - isLossless ? Math.round(os.cpus().length / 2) : os.cpus().length, - ); - - const tasksErrors = []; - - const tasks = paths.map(filePath => limit(() => fs.promises.readFile(filePath) - .then(fileBuffer => optimizeByType({ - fileBuffer, - filePath, - isLossless, - config, - }))) - .then(fileBuffer => { - progressBar.increment(); - return { fileBuffer, filePath }; - }) - .catch(error => { - progressBar.increment(); - tasksErrors.push([filePath, { - type: 'error', - description: (error.message || '').trim(), - }]); - })); - - const totalSize = { before: 0, after: 0 }; - const tasksResult = await Promise.all(tasks); - - for (const { fileBuffer, filePath } of tasksResult.filter(Boolean)) { - const fileSize = { - before: fs.statSync(filePath).size, - after: fileBuffer.length, - }; - - const isOptimized = fileSize.before > fileSize.after; - - totalSize.before += fileSize.before; - totalSize.after += isOptimized ? fileSize.after : fileSize.before; - - checkResult(fileBuffer, filePath, fileSize, output); - } - - for (const error of tasksErrors) { - log(...error); - } - - console.log(); - showTotal(totalSize.before, totalSize.after); -} - -function checkResult(fileBuffer, filePath, fileSize, output) { - if (!Buffer.isBuffer(fileBuffer) || typeof filePath !== 'string') { - return; - } - - const fileExtension = path.extname(filePath).toLowerCase(); - const before = formatBytes(fileSize.before); - const after = formatBytes(fileSize.after); - const ratio = calcRatio(fileSize.before, fileSize.after); - const successMessage = `${before} → ${after}. Ratio: ${ratio}%`; - - const writeFilePath = prepareWriteFilePath(filePath, output); - - const isChanged = !fs.readFileSync(filePath).equals(fileBuffer); - const isOptimized = ratio > 0; - const isSvg = fileExtension === '.svg'; - - if (isOptimized || (isChanged && isSvg)) { - try { - fs.writeFileSync(writeFilePath, fileBuffer); - - log(filePath, { - type: isOptimized ? 'success' : 'warning', - description: successMessage, - }); - } catch (error) { - if (error.message) { - log(filePath, { - type: 'error', - description: error.message, - }); - } else { - console.error(error); - } - } - } else { - log(filePath, { - description: `${(isChanged ? 'File size increased' : 'Nothing changed')}. Skipped`, - verboseOnly: true, - }); - } -} - -async function optimizeByType({ fileBuffer, filePath, isLossless, config }) { - const fileExtension = path.extname(filePath).toLowerCase(); - const imageFormat = await getImageFormat(fileBuffer); - - switch (fileExtension) { - case '.jpg': - case '.jpeg': { - if (imageFormat !== 'jpeg') { - return fileBuffer; - } - - return isLossless - ? execBuffer({ - bin: guetzli, - args: [ - ...optionsToArguments({ - options: config?.jpeg?.lossless || {}, - }), - execBuffer.input, - execBuffer.output, - ], - input: await sharp(fileBuffer) - .toColorspace('srgb') // Replace colorspace (guetzli works only with sRGB) - .rotate() // Rotate image using information from EXIF Orientation tag - .jpeg({ quality: 100, optimizeCoding: false }) // Applying maximum quality to minimize losses during image processing with sharp - .toBuffer(), - }) - : sharp(fileBuffer) - .rotate() // Rotate image using information from EXIF Orientation tag - .jpeg(config?.jpeg?.lossy || {}) - .toBuffer(); - } - - case '.png': { - if (imageFormat !== 'png') { - return fileBuffer; - } - - return sharp(fileBuffer) - .png((isLossless ? config?.png?.lossless : config?.png?.lossy) || {}) - .toBuffer(); - } - - case '.gif': { - if (imageFormat !== 'gif') { - return fileBuffer; - } - - return execBuffer({ - bin: gifsicle, - args: [ - ...optionsToArguments({ - options: (isLossless ? config?.gif?.lossless : config?.gif?.lossy) || {}, - concat: true, - }), - `--threads=${os.cpus().length}`, - '--no-warnings', - '--output', - execBuffer.output, - execBuffer.input, - ], - input: fileBuffer, - }); - } - - case '.svg': { - if (imageFormat !== 'svg') { - return fileBuffer; - } - - return Buffer.from( - svgoOptimize( - fileBuffer, - config.svg, - ).data, - ); - } - - default: { - throw new Error(`Unsupported file type: "${fileExtension}"`); - } - } -} diff --git a/lib/options-to-arguments.js b/lib/options-to-arguments.js index 9571492..85d21a3 100644 --- a/lib/options-to-arguments.js +++ b/lib/options-to-arguments.js @@ -1,17 +1,21 @@ -export default function optionsToArguments({ options, prefix = '--', concat = false }) { - return Object.keys(options).reduce((accumulator, key) => { - const value = options[key]; - const shouldAddKey = value !== false; - const shouldAddValue = value !== true; +export function optionsToArguments({ options, prefix = '--', concat = false }) { + const arguments_ = []; - if (!shouldAddKey) { - return accumulator; + for (const [key, value] of Object.entries(options)) { + if (value === false) { + continue; } - return [ - ...accumulator, - `${prefix}${key}${(shouldAddValue && concat ? `=${value}` : '')}`, - ...shouldAddValue && !concat ? [value] : [], - ]; - }, []); + const option = `${prefix}${key}`; + + if (value === true) { + arguments_.push(option); + } else if (concat) { + arguments_.push(`${option}=${value}`); + } else { + arguments_.push(option, String(value)); + } + } + + return arguments_; } diff --git a/lib/parse-image-metadata.js b/lib/parse-image-metadata.js new file mode 100644 index 0000000..a7a4b35 --- /dev/null +++ b/lib/parse-image-metadata.js @@ -0,0 +1,12 @@ +import sharp from 'sharp'; + +export async function parseImageMetadata(buffer) { + try { + // Fast access to (uncached) image metadata without decoding any compressed pixel data. + // This is read from the header of the input image. It does not take into consideration any operations to be applied to the output image, such as resize or rotate. + const metadata = await sharp(buffer).metadata(); + return metadata; + } catch { + return {}; + } +} diff --git a/lib/prepare-file-paths.js b/lib/prepare-file-paths.js index 68b625c..e4e07e6 100644 --- a/lib/prepare-file-paths.js +++ b/lib/prepare-file-paths.js @@ -1,33 +1,73 @@ import fs from 'node:fs'; import path from 'node:path'; -import fg from 'fast-glob'; - -export default function prepareFilePaths(paths, extensions) { - const fgExtensions = extensions.join('|'); - const replacePath = `${process.cwd()}${path.sep}`; - const filePaths = [...new Set(paths.reduce((accumulator, filePath) => { - if (fs.existsSync(filePath)) { - // Search for files recursively inside the dir - if (fs.lstatSync(filePath).isDirectory()) { - accumulator = [ - ...accumulator, - ...fg.sync( - `${path.resolve(filePath).replaceAll('\\', '/')}/**/*.+(${fgExtensions})`, - { caseSensitiveMatch: false }, - ), - ]; - } +import { fdir } from 'fdir'; + +export async function prepareFilePaths({ + inputPaths, + outputDirectoryPath, + extensions, +}) { + const files = new Set(); + const directories = new Set(); + const inputPathsSet = new Set(inputPaths); + + await Promise.all( + [...inputPathsSet].map(async currentPath => { + try { + const stat = await fs.promises.stat(currentPath); + const resolvedPath = path.resolve(currentPath); + + if (stat.isDirectory()) { + directories.add(resolvedPath); + } else if (stat.isFile() && checkFileType(resolvedPath, extensions)) { + files.add(resolvedPath); + } + } catch {} + }), + ); + + const crawler = new fdir() // eslint-disable-line new-cap + .withFullPaths() + .withDirs() + .filter(currentPath => checkFileType(currentPath, extensions)); + + const crawledPaths = await Promise.all( + [...directories].map(currentPath => crawler.crawl(currentPath).withPromise()), + ); - // Filter files by extension - if (extensions.includes(path.extname(filePath).toLowerCase().slice(1))) { - accumulator.push(filePath); + for (const crawledPath of crawledPaths.flat()) { + files.add(crawledPath); + } + + const hasDirectories = directories.size > 0; + + const result = [...files].map(filePath => { + let outputPath = filePath; + + if (outputDirectoryPath) { + if (hasDirectories) { + for (const directory of directories) { + if (filePath.startsWith(directory)) { + outputPath = path.join(outputDirectoryPath, filePath.slice(directory.length)); + break; + } + } + } else { + outputPath = path.join(outputDirectoryPath, filePath.slice(path.dirname(filePath).length)); } } - return accumulator; - }, []))]; + return { + input: filePath, + output: outputPath, + }; + }); + + return result; +} - // Use relative paths when it's possible - return filePaths.map(p => p.replace(replacePath, '')); +function checkFileType(filePath, extensions) { + const extension = path.extname(filePath).toLowerCase().slice(1); + return extensions.includes(extension); } diff --git a/lib/prepare-output-directory-path.js b/lib/prepare-output-directory-path.js new file mode 100644 index 0000000..bb9ab0a --- /dev/null +++ b/lib/prepare-output-directory-path.js @@ -0,0 +1,24 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +import { logErrorAndExit } from './log.js'; + +export async function prepareOutputDirectoryPath(outputDirectoryPath) { + if (!outputDirectoryPath) { + return ''; + } + + const resolvedPath = path.resolve(outputDirectoryPath); + + try { + const stat = await fs.promises.stat(resolvedPath); + + if (!stat.isDirectory()) { + logErrorAndExit('Output path must be a directory'); + } + } catch { + logErrorAndExit('Output path does not exist'); + } + + return resolvedPath; +} diff --git a/lib/prepare-output-path.js b/lib/prepare-output-path.js deleted file mode 100644 index d880e11..0000000 --- a/lib/prepare-output-path.js +++ /dev/null @@ -1,22 +0,0 @@ -import fs from 'node:fs'; -import path from 'node:path'; - -import { logErrorAndExit } from './log.js'; - -export default function prepareOutputPath(outputPath) { - if (!outputPath) { - return ''; - } - - const resolvedPath = path.resolve(outputPath); - - if (!fs.existsSync(resolvedPath)) { - logErrorAndExit('Output path does not exist'); - } - - if (!fs.lstatSync(resolvedPath).isDirectory()) { - logErrorAndExit('Output path must be a directory'); - } - - return resolvedPath; -} diff --git a/lib/prepare-write-file-path.js b/lib/prepare-write-file-path.js deleted file mode 100644 index cfc0061..0000000 --- a/lib/prepare-write-file-path.js +++ /dev/null @@ -1,16 +0,0 @@ -import fs from 'node:fs'; -import path from 'node:path'; - -export default function prepareWriteFilePath(filePath, output) { - if (!output) { - return filePath; - } - - const replacePath = `${process.cwd()}${path.sep}`; - const { base, dir } = path.parse(filePath); - const [, ...subDirectories] = dir.split(path.sep); - - fs.mkdirSync(path.join(output, ...subDirectories), { recursive: true }); - - return path.join(output, ...subDirectories, base).replace(replacePath, ''); -} diff --git a/lib/program-options.js b/lib/program-options.js new file mode 100644 index 0000000..b50874f --- /dev/null +++ b/lib/program-options.js @@ -0,0 +1,12 @@ +export const programOptions = { + shouldConvertToAvif: false, + shouldConvertToWebp: false, + isForced: false, + isLossless: false, + isVerbose: false, +}; + +export function setProgramOptions(options) { + Object.assign(programOptions, options); + Object.freeze(programOptions); +} diff --git a/lib/show-total.js b/lib/show-total.js index 0d4fa3c..1aef226 100644 --- a/lib/show-total.js +++ b/lib/show-total.js @@ -1,14 +1,14 @@ -import calcRatio from './calc-ratio.js'; -import formatBytes from './format-bytes.js'; -import log from './log.js'; +import { calculateRatio } from './calculate-ratio.js'; +import { formatBytes } from './format-bytes.js'; +import { log, logEmptyLine } from './log.js'; -export default function showTotal(before, after) { - const ratio = calcRatio(before, after); +export function showTotal(before, after) { + const ratio = calculateRatio(before, after); const saved = formatBytes(before - after); - if (ratio > 0) { - log(`Yay! You saved ${saved} (${ratio}%)`); - } else { - log('Done!'); - } + logEmptyLine(); + log(ratio > 0 + ? `Yay! You saved ${saved} (${ratio}%)` + : 'Done!', + ); } diff --git a/optimize.js b/optimize.js new file mode 100644 index 0000000..6c43a57 --- /dev/null +++ b/optimize.js @@ -0,0 +1,221 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import execBuffer from 'exec-buffer'; +import gifsicle from 'gifsicle'; +import guetzli from 'guetzli'; +import pLimit from 'p-limit'; +import sharp from 'sharp'; +import { optimize as svgoOptimize } from 'svgo'; + +import { calculateRatio } from './lib/calculate-ratio.js'; +import { createProgressBarContainer } from './lib/create-progress-bar-container.js'; +import { formatBytes } from './lib/format-bytes.js'; +import { getPlural } from './lib/get-plural.js'; +import { getRelativePath } from './lib/get-relative-path.js'; +import { + LOG_TYPES, + log, + logProgress, + logProgressVerbose, +} from './lib/log.js'; +import { optionsToArguments } from './lib/options-to-arguments.js'; +import { parseImageMetadata } from './lib/parse-image-metadata.js'; +import { programOptions } from './lib/program-options.js'; +import { showTotal } from './lib/show-total.js'; + +export async function optimize({ filePaths, config }) { + const { isLossless } = programOptions; + + const filePathsCount = filePaths.length; + + if (filePathsCount <= 0) { + return; + } + + log(`Optimizing ${filePathsCount} ${getPlural(filePathsCount, 'image', 'images')} (${isLossless ? 'lossless' : 'lossy'})...`); + + const progressBarContainer = createProgressBarContainer(filePathsCount); + const progressBar = progressBarContainer.create(filePathsCount, 0); + + const totalSize = { before: 0, after: 0 }; + + const cpuCount = os.cpus().length; + const tasksSimultaneousLimit = pLimit( + /* + Guetzli uses a large amount of memory and a significant amount of CPU time. + To reduce the processor load in lossless mode, we reduce the number + of simultaneous tasks by half. + */ + isLossless ? Math.round(cpuCount / 2) : cpuCount, + ); + const tasksPromises = filePaths.map( + filePath => tasksSimultaneousLimit( + () => processFile({ + filePath, + config, + progressBarContainer, + progressBar, + totalSize, + isLossless, + }), + ), + ); + + await Promise.all(tasksPromises); + progressBarContainer.update(); // Prevent logs lost. See: https://github.com/npkgz/cli-progress/issues/145#issuecomment-1859594159 + progressBarContainer.stop(); + + showTotal(totalSize.before, totalSize.after); +} + +async function processFile({ + filePath, + config, + progressBarContainer, + progressBar, + totalSize, + isLossless, +}) { + try { + const fileBuffer = await fs.promises.readFile(filePath.input); + const processedFileBuffer = await processFileByFormat({ fileBuffer, config, isLossless }); + + const fileSize = fileBuffer.length; + const processedFileSize = processedFileBuffer.length; + + totalSize.before += fileSize; + totalSize.after += Math.min(fileSize, processedFileSize); + + const ratio = calculateRatio(fileSize, processedFileSize); + + const isOptimized = ratio > 0; + const isChanged = !fileBuffer.equals(processedFileBuffer); + const isSvg = path.extname(filePath.input).toLowerCase() === '.svg'; + + if (!isOptimized && !(isChanged && isSvg)) { + logProgressVerbose(getRelativePath(filePath.input), { + description: `${(isChanged ? 'File size increased' : 'Nothing changed')}. Skipped`, + progressBarContainer, + }); + + return; + } + + await fs.promises.mkdir(path.dirname(filePath.output), { recursive: true }); + await fs.promises.writeFile(filePath.output, processedFileBuffer); + + const before = formatBytes(fileSize); + const after = formatBytes(processedFileSize); + + logProgress(getRelativePath(filePath.input), { + type: isOptimized ? LOG_TYPES.SUCCESS : LOG_TYPES.WARNING, + description: `${before} → ${after}. Ratio: ${ratio}%`, + progressBarContainer, + }); + } catch (error) { + if (error.message) { + logProgress(getRelativePath(filePath.input), { + type: LOG_TYPES.ERROR, + description: (error.message || '').trim(), + progressBarContainer, + }); + } else { + progressBarContainer.log(error); + } + } finally { + progressBar.increment(); + } +} + +async function processFileByFormat({ fileBuffer, config, isLossless }) { + const imageMetadata = await parseImageMetadata(fileBuffer); + + if (!imageMetadata.format) { + throw new Error('Unknown file format'); + } + + switch (imageMetadata.format) { + case 'jpeg': { + return processJpeg({ fileBuffer, config, isLossless }); + } + + case 'png': { + return processPng({ fileBuffer, config, isLossless }); + } + + case 'gif': { + return processGif({ fileBuffer, config, isLossless }); + } + + case 'svg': { + return processSvg({ fileBuffer, config }); + } + + default: { + throw new Error(`Unsupported image format: "${imageMetadata.format}"`); + } + } +} + +async function processJpeg({ fileBuffer, config, isLossless }) { + const sharpImage = sharp(fileBuffer) + .rotate(); // Rotate image using information from EXIF Orientation tag + + if (isLossless) { + const inputBuffer = await sharpImage + .toColorspace('srgb') // Replace colorspace (guetzli works only with sRGB) + .jpeg({ quality: 100, optimizeCoding: false }) // Applying maximum quality to minimize losses during image processing with sharp + .toBuffer(); + + return execBuffer({ + bin: guetzli, + args: [ + ...optionsToArguments({ + options: config?.jpeg?.lossless || {}, + }), + execBuffer.input, + execBuffer.output, + ], + input: inputBuffer, + }); + } + + return sharpImage + .jpeg(config?.jpeg?.lossy || {}) + .toBuffer(); +} + +function processPng({ fileBuffer, config, isLossless }) { + return sharp(fileBuffer) + .png(isLossless ? config?.png?.lossless : config?.png?.lossy || {}) + .toBuffer(); +} + +function processGif({ fileBuffer, config, isLossless }) { + return execBuffer({ + bin: gifsicle, + args: [ + ...optionsToArguments({ + options: (isLossless ? config?.gif?.lossless : config?.gif?.lossy) || {}, + concat: true, + }), + `--threads=${os.cpus().length}`, + '--no-warnings', + '--output', + execBuffer.output, + execBuffer.input, + ], + input: fileBuffer, + }); +} + +function processSvg({ fileBuffer, config }) { + return Buffer.from( + svgoOptimize( + fileBuffer, + config.svg, + ).data, + ); +} diff --git a/package-lock.json b/package-lock.json index 30fe329..46302d3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,18 +1,18 @@ { "name": "@343dev/optimizt", - "version": "8.0.1", + "version": "9.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@343dev/optimizt", - "version": "8.0.1", + "version": "9.0.0", "license": "MIT", "dependencies": { "cli-progress": "^3.11.0", "commander": "^12.1.0", "exec-buffer": "^3.2.0", - "fast-glob": "^3.3.2", + "fdir": "^6.4.0", "gif2webp-bin": "^5.0.0", "gifsicle": "^7.0.0", "guetzli": "^5.0.0", @@ -27,7 +27,7 @@ "@343dev/eslint-config": "^1.0.0", "eslint": "^8.57.0", "jest": "^29.7.0", - "lint-staged": "^15.2.8", + "lint-staged": "^15.2.10", "simple-git-hooks": "^2.11.1" }, "engines": { @@ -1575,6 +1575,7 @@ "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" @@ -1587,6 +1588,7 @@ "version": "2.0.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, "engines": { "node": ">= 8" } @@ -1595,6 +1597,7 @@ "version": "1.2.8", "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" @@ -1865,6 +1868,18 @@ "node": ">= 8" } }, + "node_modules/anymatch/node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/arch": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/arch/-/arch-2.2.0.tgz", @@ -2491,6 +2506,7 @@ "version": "3.0.3", "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, "dependencies": { "fill-range": "^7.1.1" }, @@ -4279,32 +4295,6 @@ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", "dev": true }, - "node_modules/fast-glob": { - "version": "3.3.2", - "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.2.tgz", - "integrity": "sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow==", - "dependencies": { - "@nodelib/fs.stat": "^2.0.2", - "@nodelib/fs.walk": "^1.2.3", - "glob-parent": "^5.1.2", - "merge2": "^1.3.0", - "micromatch": "^4.0.4" - }, - "engines": { - "node": ">=8.6.0" - } - }, - "node_modules/fast-glob/node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dependencies": { - "is-glob": "^4.0.1" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/fast-json-stable-stringify": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", @@ -4321,6 +4311,7 @@ "version": "1.17.1", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.17.1.tgz", "integrity": "sha512-sRVD3lWVIXWg6By68ZN7vho9a1pQcN/WBFaAAsDDFzlJjvoGx0P8z7V1t72grFJfJhu3YPZBuu25f7Kaw2jN1w==", + "dev": true, "dependencies": { "reusify": "^1.0.4" } @@ -4342,6 +4333,19 @@ "pend": "~1.2.0" } }, + "node_modules/fdir": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.0.tgz", + "integrity": "sha512-3oB133prH1o4j/L5lLW7uOCF1PlD+/It2L0eL/iAqWMB91RBbqTewABqxhj0ibBd90EEmWZq7ntIWzVaWcXTGQ==", + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, "node_modules/file-entry-cache": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", @@ -4387,6 +4391,7 @@ "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, "dependencies": { "to-regex-range": "^5.0.1" }, @@ -5260,6 +5265,7 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, "engines": { "node": ">=0.10.0" } @@ -5289,6 +5295,7 @@ "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, "dependencies": { "is-extglob": "^2.1.1" }, @@ -5317,6 +5324,7 @@ "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, "engines": { "node": ">=0.12.0" } @@ -6294,6 +6302,18 @@ "node": ">=8" } }, + "node_modules/jest-util/node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/jest-validate": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-29.7.0.tgz", @@ -6497,9 +6517,9 @@ "dev": true }, "node_modules/lint-staged": { - "version": "15.2.8", - "resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-15.2.8.tgz", - "integrity": "sha512-PUWFf2zQzsd9EFU+kM1d7UP+AZDbKFKuj+9JNVTBkhUFhbg4MAt6WfyMMwBfM4lYqd4D2Jwac5iuTu9rVj4zCQ==", + "version": "15.2.10", + "resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-15.2.10.tgz", + "integrity": "sha512-5dY5t743e1byO19P9I4b3x8HJwalIznL5E1FWYnU6OWw33KxNBSLAc6Cy7F2PsFEO8FKnLwjwm5hx7aMF0jzZg==", "dev": true, "dependencies": { "chalk": "~5.3.0", @@ -6508,7 +6528,7 @@ "execa": "~8.0.1", "lilconfig": "~3.1.2", "listr2": "~8.2.4", - "micromatch": "~4.0.7", + "micromatch": "~4.0.8", "pidtree": "~0.6.0", "string-argv": "~0.3.2", "yaml": "~2.5.0" @@ -6834,18 +6854,11 @@ "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==" }, - "node_modules/merge2": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", - "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", - "engines": { - "node": ">= 8" - } - }, "node_modules/micromatch": { - "version": "4.0.7", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.7.tgz", - "integrity": "sha512-LPP/3KorzCwBxfeUuZmaR6bG2kdeHSbe0P2tY3FLRU4vYrjYz5hI4QZwV0njUx3jeuKe67YukQ1LSPZBKDqO/Q==", + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" @@ -6854,6 +6867,18 @@ "node": ">=8.6" } }, + "node_modules/micromatch/node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/mime-db": { "version": "1.53.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.53.0.tgz", @@ -7404,11 +7429,13 @@ "integrity": "sha512-anP1Z8qwhkbmu7MFP5iTt+wQKXgwzf7zTyGlcdzabySa9vd0Xt392U0rVmz9poOaBj0uHJKyyo9/upk0HrEQew==" }, "node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz", + "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", + "optional": true, + "peer": true, "engines": { - "node": ">=8.6" + "node": ">=12" }, "funding": { "url": "https://github.com/sponsors/jonschlinkert" @@ -7666,6 +7693,7 @@ "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, "funding": [ { "type": "github", @@ -7966,6 +7994,7 @@ "version": "1.0.4", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz", "integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==", + "dev": true, "engines": { "iojs": ">=1.0.0", "node": ">=0.10.0" @@ -7993,6 +8022,7 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, "funding": [ { "type": "github", @@ -8769,6 +8799,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, "dependencies": { "is-number": "^7.0.0" }, diff --git a/package.json b/package.json index 96669eb..05715b3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@343dev/optimizt", - "version": "8.0.1", + "version": "9.0.0", "description": "CLI image optimization tool", "keywords": [ "svg", @@ -29,9 +29,10 @@ "files": [ "MIGRATION.md", "cli.js", + "convert.js", "index.js", "lib/", - "svgo/", + "optimize.js", ".optimiztrc.cjs" ], "scripts": { @@ -47,7 +48,7 @@ "cli-progress": "^3.11.0", "commander": "^12.1.0", "exec-buffer": "^3.2.0", - "fast-glob": "^3.3.2", + "fdir": "^6.4.0", "gif2webp-bin": "^5.0.0", "gifsicle": "^7.0.0", "guetzli": "^5.0.0", @@ -59,7 +60,7 @@ "@343dev/eslint-config": "^1.0.0", "eslint": "^8.57.0", "jest": "^29.7.0", - "lint-staged": "^15.2.8", + "lint-staged": "^15.2.10", "simple-git-hooks": "^2.11.1" }, "simple-git-hooks": { diff --git a/tests/calc-ratio.test.js b/tests/calculate-ratio.test.js similarity index 50% rename from tests/calc-ratio.test.js rename to tests/calculate-ratio.test.js index 05160f6..34ff365 100644 --- a/tests/calc-ratio.test.js +++ b/tests/calculate-ratio.test.js @@ -1,9 +1,9 @@ -import calcRatio from '../lib/calc-ratio.js'; +import { calculateRatio } from '../lib/calculate-ratio.js'; test('Ratio should be “50” if the file size has decreased by 50%', () => { - expect(calcRatio(1_000_000, 500_000)).toBe(50); + expect(calculateRatio(1_000_000, 500_000)).toBe(50); }); test('Ratio should be “-100” if the file size has increased by 100%', () => { - expect(calcRatio(500_000, 1_000_000)).toBe(-100); + expect(calculateRatio(500_000, 1_000_000)).toBe(-100); }); diff --git a/tests/cli.test.js b/tests/cli.test.js index b8d3921..3835ea3 100644 --- a/tests/cli.test.js +++ b/tests/cli.test.js @@ -4,6 +4,8 @@ import os from 'node:os'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; +import { calculateRatio } from '../lib/calculate-ratio.js'; + const dirname = path.dirname(fileURLToPath(import.meta.url)); const cliPath = path.resolve('cli.js'); @@ -147,14 +149,13 @@ describe('CLI', () => { expectFileNotModified(file); }); - test('GIF should be converted', () => { + test('GIF should not be converted', () => { const file = 'gif-not-optimized.gif'; const stdout = runCliWithParameters(`--avif ${workDirectory}${file}`); - expectFileRatio({ - stdout, file, maxRatio: 95, minRatio: 90, outputExt: 'avif', - }); + expectStringContains(stdout, 'Animated AVIF is not supported'); expectFileNotModified(file); + expectFileNotExists('gif-not-optimized.avif'); }); test('Files in provided directory should be converted', () => { @@ -183,25 +184,27 @@ describe('CLI', () => { expectFileNotModified(file); }); - test('GIF should be converted', () => { + test('GIF should not be converted', () => { const file = 'gif-not-optimized.gif'; const stdout = runCliWithParameters(`--avif --lossless ${workDirectory}${file}`); - expectFileRatio({ - stdout, file, maxRatio: 75, minRatio: 70, outputExt: 'avif', - }); + expectStringContains(stdout, 'Animated AVIF is not supported'); expectFileNotModified(file); + expectFileNotExists('gif-not-optimized.avif'); }); - test('Files in provided directory should be converted', () => { + test('Files in provided directory should be converted (except GIF)', () => { const fileBasename = 'png-not-optimized'; const stdout = runCliWithParameters(`--avif --lossless ${workDirectory}`); const stdoutRatio = grepTotalRatio(stdout); expectStringContains(stdout, 'Converting 6 images (lossless)...'); - expectRatio(stdoutRatio, 45, 50); + expectRatio(stdoutRatio, 27, 31); expectFileNotModified(`${fileBasename}.png`); expectFileExists(`${fileBasename}.avif`); + + expectStringContains(stdout, 'Animated AVIF is not supported'); + expectFileNotExists('gif-not-optimized.avif'); }); }); }); @@ -329,7 +332,7 @@ describe('CLI', () => { describe('Force rewrite AVIF or WebP (--force)', () => { test('Should not be overwritten', () => { const fileBasename = 'png-not-optimized'; - const parameters = `--avif --webp ${workDirectory}${fileBasename}.png`; + const parameters = `--verbose --avif --webp ${workDirectory}${fileBasename}.png`; runCliWithParameters(parameters); const stdout = runCliWithParameters(parameters); @@ -368,13 +371,13 @@ describe('CLI', () => { const fileName = 'png-not-optimized.png'; runCliWithParameters(`--output ${outputDirectory} ${workDirectory}${fileName}`); - expect(fs.existsSync(path.join(outputDirectory, workDirectory, fileName))).toBeTruthy(); + expect(fs.existsSync(path.join(outputDirectory, fileName))).toBeTruthy(); }); test('Should output list of files', () => { runCliWithParameters(`--output ${outputDirectory} ${workDirectory}*.jpg ${workDirectory}*.jpeg`); - expect(fs.existsSync(path.join(outputDirectory, workDirectory, 'jpeg-low-quality.jpg'))).toBeTruthy(); - expect(fs.existsSync(path.join(outputDirectory, workDirectory, 'jpeg-not-optimized.jpeg'))).toBeTruthy(); + expect(fs.existsSync(path.join(outputDirectory, 'jpeg-low-quality.jpg'))).toBeTruthy(); + expect(fs.existsSync(path.join(outputDirectory, 'jpeg-not-optimized.jpeg'))).toBeTruthy(); }); }); @@ -383,13 +386,13 @@ describe('CLI', () => { const fileBasename = 'png-not-optimized'; runCliWithParameters(`--avif --output ${outputDirectory} ${workDirectory}${fileBasename}.png`); - expect(fs.existsSync(path.join(outputDirectory, workDirectory, `${fileBasename}.avif`))).toBeTruthy(); + expect(fs.existsSync(path.join(outputDirectory, `${fileBasename}.avif`))).toBeTruthy(); }); test('Should output list of files', () => { runCliWithParameters(`--avif --output ${outputDirectory} ${workDirectory}*.jpg ${workDirectory}*.jpeg`); - expect(fs.existsSync(path.join(outputDirectory, workDirectory, 'jpeg-low-quality.avif'))).toBeTruthy(); - expect(fs.existsSync(path.join(outputDirectory, workDirectory, 'jpeg-not-optimized.avif'))).toBeTruthy(); + expect(fs.existsSync(path.join(outputDirectory, 'jpeg-low-quality.avif'))).toBeTruthy(); + expect(fs.existsSync(path.join(outputDirectory, 'jpeg-not-optimized.avif'))).toBeTruthy(); }); }); }); @@ -471,10 +474,6 @@ function calculateDirectorySize(directoryPath) { return totalSize; } -function calcRatio(from, to) { - return Math.round((from - to) / from * 100); -} - function runCliWithParameters(parameters) { return execSync(`node ${cliPath} ${parameters}`).toString(); } @@ -506,7 +505,7 @@ function expectFileRatio({ file, maxRatio, minRatio, stdout, outputExt }) { const sizeBefore = fs.statSync(path.join(images, file)).size; const sizeAfter = fs.statSync(path.join(temporary, outputFile)).size; - const calculatedRatio = calcRatio(sizeBefore, sizeAfter); + const calculatedRatio = calculateRatio(sizeBefore, sizeAfter); const stdoutRatio = grepTotalRatio(stdout); expect(stdoutRatio).toBe(calculatedRatio); @@ -516,7 +515,7 @@ function expectFileRatio({ file, maxRatio, minRatio, stdout, outputExt }) { function expectTotalRatio({ maxRatio, minRatio, stdout }) { const sizeBefore = calculateDirectorySize(images); const sizeAfter = calculateDirectorySize(temporary); - const calculatedRatio = calcRatio(sizeBefore, sizeAfter); + const calculatedRatio = calculateRatio(sizeBefore, sizeAfter); const stdoutRatio = grepTotalRatio(stdout); expect(stdoutRatio).toBe(calculatedRatio); @@ -534,3 +533,8 @@ function expectFileExists(fileName) { const isFileExists = fs.existsSync(path.join(temporary, fileName)); expect(isFileExists).toBe(true); } + +function expectFileNotExists(fileName) { + const isFileExists = fs.existsSync(path.join(temporary, fileName)); + expect(isFileExists).toBe(false); +} diff --git a/tests/colorize.test.js b/tests/colorize.test.js index 7db5445..2379ed2 100644 --- a/tests/colorize.test.js +++ b/tests/colorize.test.js @@ -1,4 +1,4 @@ -import colorize from '../lib/colorize.js'; +import { colorize } from '../lib/colorize.js'; const isTTY = Boolean(process.stdout.isTTY); diff --git a/tests/format-bytes.test.js b/tests/format-bytes.test.js index 4db0be6..4db892c 100644 --- a/tests/format-bytes.test.js +++ b/tests/format-bytes.test.js @@ -1,4 +1,4 @@ -import formatBytes from '../lib/format-bytes.js'; +import { formatBytes } from '../lib/format-bytes.js'; test('1023 should be formatted as “1023 Bytes”', () => { expect(formatBytes(1023)).toBe('1023 Bytes'); diff --git a/tests/get-image-format.test.js b/tests/get-image-format.test.js deleted file mode 100644 index f19870e..0000000 --- a/tests/get-image-format.test.js +++ /dev/null @@ -1,32 +0,0 @@ -import fs from 'node:fs'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; - -import getImageFormat from '../lib/get-image-format.js'; - -const dirname = path.dirname(fileURLToPath(import.meta.url)); - -function readFile(filePath) { - return fs.readFileSync(path.resolve(dirname, filePath)); -} - -const gifBuffer = readFile('images/gif-not-optimized.gif'); -const jpegBuffer = readFile('images/jpeg-one-pixel.jpg'); -const pngBuffer = readFile('images/png-not-optimized.png'); -const svgBuffer = readFile('images/svg-optimized.svg'); - -test('GIF should be detected as “gif”', async () => { - await expect(getImageFormat(gifBuffer)).resolves.toBe('gif'); -}); - -test('JPEG should be detected as “jpeg”', async () => { - await expect(getImageFormat(jpegBuffer)).resolves.toBe('jpeg'); -}); - -test('PNG should be detected as “png”', async () => { - await expect(getImageFormat(pngBuffer)).resolves.toBe('png'); -}); - -test('SVG should be detected as “svg”', async () => { - await expect(getImageFormat(svgBuffer)).resolves.toBe('svg'); -}); diff --git a/tests/get-plural.test.js b/tests/get-plural.test.js index 7b88459..29ec363 100644 --- a/tests/get-plural.test.js +++ b/tests/get-plural.test.js @@ -1,4 +1,4 @@ -import getPlural from '../lib/get-plural.js'; +import { getPlural } from '../lib/get-plural.js'; test('Should return “image” if num equals 1', () => { expect(getPlural(1, 'image', 'images')).toBe('image'); diff --git a/tests/log.test.js b/tests/log.test.js index 6cd6878..db4492c 100644 --- a/tests/log.test.js +++ b/tests/log.test.js @@ -1,7 +1,7 @@ import { jest } from '@jest/globals'; -import colorize from '../lib/colorize.js'; -import log, { enableVerbose } from '../lib/log.js'; +import { colorize } from '../lib/colorize.js'; +import { LOG_TYPES, log } from '../lib/log.js'; const colors = { info: 'blue', @@ -32,42 +32,12 @@ test('Description logged', () => { }); }); -describe('Verbose mode', () => { - test('Not logged if type = info & verboseOnly = true & isVerbose = false', () => { - expectLog({ - symbol: symbols.info[symbolIndex], - title: 'info', - type: 'info', - verboseOnly: true, - }); - }); - - test('Logged if type = info & verboseOnly = true & isVerbose = true', () => { - expectLog({ - symbol: symbols.info[symbolIndex], - title: 'info', - type: 'info', - verboseModeEnabled: true, - verboseOnly: true, - }); - }); - - test('Logged if type = error & verboseOnly = true & isVerbose = false', () => { - expectLog({ - symbol: symbols.error[symbolIndex], - title: 'error', - type: 'error', - verboseOnly: true, - }); - }); -}); - describe('Titles and symbols', () => { test('Logged “info” with symbol', () => { expectLog({ symbol: symbols.info[symbolIndex], title: 'info', - type: 'info', + type: LOG_TYPES.INFO, }); }); @@ -75,7 +45,7 @@ describe('Titles and symbols', () => { expectLog({ symbol: symbols.success[symbolIndex], title: 'success', - type: 'success', + type: LOG_TYPES.SUCCESS, }); }); @@ -83,7 +53,7 @@ describe('Titles and symbols', () => { expectLog({ symbol: symbols.warning[symbolIndex], title: 'warning', - type: 'warning', + type: LOG_TYPES.WARNING, }); }); @@ -91,7 +61,7 @@ describe('Titles and symbols', () => { expectLog({ symbol: symbols.error[symbolIndex], title: 'error', - type: 'error', + type: LOG_TYPES.ERROR, }); }); }); @@ -101,28 +71,18 @@ function expectLog({ symbol, title, type, - verboseModeEnabled, - verboseOnly, }) { - const symbolColored = colorize(symbol)[colors[(type || 'info')]]; + const symbolColored = colorize(symbol)[colors[(type || LOG_TYPES.INFO)]]; const descriptionColored = description ? colorize(description).dim : undefined; - if (verboseModeEnabled) { - enableVerbose(); - } - console.log = jest.fn(); - log(title, { type, description, verboseOnly }); + log(title, { type, description }); - if (type === 'info' && !verboseModeEnabled && verboseOnly) { - expect(console.log.mock.calls[0]).toBeUndefined(); - } else { - expect(console.log.mock.calls[0][0]).toBe(symbolColored); - expect(console.log.mock.calls[0][1]).toBe(title); - expect(console.log.mock.calls[0][4]).toBe(descriptionColored); - } + expect(console.log.mock.calls[0][0]).toBe(symbolColored); + expect(console.log.mock.calls[0][1]).toBe(title); + expect(console.log.mock.calls[0][4]).toBe(descriptionColored); console.log.mockRestore(); } diff --git a/tests/parse-image-metadata.test.js b/tests/parse-image-metadata.test.js new file mode 100644 index 0000000..6ceb5f4 --- /dev/null +++ b/tests/parse-image-metadata.test.js @@ -0,0 +1,41 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { parseImageMetadata } from '../lib/parse-image-metadata.js'; + +const dirname = path.dirname(fileURLToPath(import.meta.url)); + +function readFile(filePath) { + return fs.readFileSync(path.resolve(dirname, filePath)); +} + +const gifBuffer = readFile('images/gif-not-optimized.gif'); +const jpegBuffer = readFile('images/jpeg-one-pixel.jpg'); +const pngBuffer = readFile('images/png-not-optimized.png'); +const svgBuffer = readFile('images/svg-optimized.svg'); + +const gifMetadata = await parseImageMetadata(gifBuffer); +const jpegMetadata = await parseImageMetadata(jpegBuffer); +const pngMetadata = await parseImageMetadata(pngBuffer); +const svgMetadata = await parseImageMetadata(svgBuffer); + +test('Format: GIF should be detected as “gif”', async () => { + expect(gifMetadata.format).toBe('gif'); +}); + +test('Format: JPEG should be detected as “jpeg”', async () => { + expect(jpegMetadata.format).toBe('jpeg'); +}); + +test('Format: PNG should be detected as “png”', async () => { + expect(pngMetadata.format).toBe('png'); +}); + +test('Format: SVG should be detected as “svg”', async () => { + expect(svgMetadata.format).toBe('svg'); +}); + +test('Pages: Frames count should be detected in animated GIF', async () => { + expect(gifMetadata.pages).toBe(10); +}); diff --git a/tests/prepare-file-paths.test.js b/tests/prepare-file-paths.test.js index bc8ceca..bc3025e 100644 --- a/tests/prepare-file-paths.test.js +++ b/tests/prepare-file-paths.test.js @@ -1,33 +1,39 @@ import path from 'node:path'; import { fileURLToPath } from 'node:url'; -import prepareFilePaths from '../lib/prepare-file-paths.js'; +import { getRelativePath } from '../lib/get-relative-path.js'; +import { prepareFilePaths } from '../lib/prepare-file-paths.js'; const dirname = path.dirname(fileURLToPath(import.meta.url)); const DEFAULT_IMAGE_PATH = resolvePath(['images']); const DEFAULT_EXTENSIONS = ['gif', 'jpeg', 'jpg', 'png', 'svg']; -test('Non-existent file paths are ignored', () => { - const paths = [ - resolvePath(['not+exists']), - resolvePath(['not+exists.svg']), - ]; - expect(prepareFilePaths(paths, DEFAULT_EXTENSIONS)).toStrictEqual([]); +test('Non-existent file paths are ignored', async () => { + const inputPaths = await generateInputPaths({ + inputPaths: [ + resolvePath(['not+exists']), + resolvePath(['not+exists.svg']), + ], + }); + + expect(inputPaths).toStrictEqual([]); }); -test('Files from subdirectories are processed', () => { - expect(prepareFilePaths([DEFAULT_IMAGE_PATH], DEFAULT_EXTENSIONS)).toEqual( +test('Files from subdirectories are processed', async () => { + const inputPaths = await generateInputPaths(); + + expect(inputPaths).toEqual( expect.arrayContaining([ expect.stringMatching(/file-in-subdirectory.jpg$/), ]), ); }); -test('Files are filtered by extension', () => { - const extensions = ['gif', 'jpeg', 'png', 'svg']; +test('Files are filtered by extension', async () => { + const inputPaths = await generateInputPaths({ extensions: ['gif', 'jpeg', 'png', 'svg'] }); - expect(prepareFilePaths([DEFAULT_IMAGE_PATH], extensions)).toEqual( + expect(inputPaths).toEqual( expect.arrayContaining([ expect.stringMatching(/\.gif$/), expect.stringMatching(/\.png$/), @@ -35,15 +41,17 @@ test('Files are filtered by extension', () => { ]), ); - expect(prepareFilePaths([DEFAULT_IMAGE_PATH], extensions)).not.toEqual( + expect(inputPaths).not.toEqual( expect.arrayContaining([ expect.stringMatching(/\.jpg$/), ]), ); }); -test('Only relative file paths are generated', () => { - expect(prepareFilePaths([DEFAULT_IMAGE_PATH], DEFAULT_EXTENSIONS)).not.toEqual( +test('Only relative file paths are generated', async () => { + const inputPaths = await generateInputPaths(); + + expect(inputPaths).not.toEqual( expect.arrayContaining([ expect.stringMatching(new RegExp(`^${dirname}`)), ]), @@ -53,3 +61,8 @@ test('Only relative file paths are generated', () => { function resolvePath(segments) { return path.resolve(dirname, ...segments); } + +async function generateInputPaths({ inputPaths = [DEFAULT_IMAGE_PATH], extensions = DEFAULT_EXTENSIONS } = {}) { + const result = await prepareFilePaths({ inputPaths, extensions }); + return result.map(item => getRelativePath(item.input)); +} diff --git a/tests/prepare-output-path.test.js b/tests/prepare-output-path.test.js index 494c011..331fba3 100644 --- a/tests/prepare-output-path.test.js +++ b/tests/prepare-output-path.test.js @@ -3,18 +3,18 @@ import { fileURLToPath } from 'node:url'; import { jest } from '@jest/globals'; -import prepareOutputPath from '../lib/prepare-output-path.js'; +import { prepareOutputDirectoryPath } from '../lib/prepare-output-directory-path.js'; const dirname = path.dirname(fileURLToPath(import.meta.url)); -test('Exit if the path does not exist', () => { +test('Exit if the path does not exist', async () => { const processExitMock = jest.spyOn(process, 'exit').mockImplementation(exitCode => { throw new Error(`Process exit with status code: ${exitCode}`); }); console.log = jest.fn(); - expect(() => prepareOutputPath('not+exists')).toThrow(); + await expect(() => prepareOutputDirectoryPath('not+exists')).rejects.toThrow(); expect(processExitMock).toHaveBeenCalledWith(1); expect(console.log.mock.calls[0][1]).toBe('Output path does not exist'); @@ -22,14 +22,14 @@ test('Exit if the path does not exist', () => { processExitMock.mockRestore(); }); -test('Exit if specified path to file instead of directory', () => { +test('Exit if specified path to file instead of directory', async () => { const processExitMock = jest.spyOn(process, 'exit').mockImplementation(exitCode => { throw new Error(`Process exit with status code: ${exitCode}`); }); console.log = jest.fn(); - expect(() => prepareOutputPath(path.resolve(dirname, 'images', 'svg-not-optimized.svg'))).toThrow(); + await expect(() => prepareOutputDirectoryPath(path.resolve(dirname, 'images', 'svg-not-optimized.svg'))).rejects.toThrow(); expect(processExitMock).toHaveBeenCalledWith(1); expect(console.log.mock.calls[0][1]).toBe('Output path must be a directory'); @@ -37,6 +37,6 @@ test('Exit if specified path to file instead of directory', () => { processExitMock.mockRestore(); }); -test('Full path is generated', () => { - expect(prepareOutputPath('tests/images')).toBe(path.resolve(dirname, 'images')); +test('Full path is generated', async () => { + expect(await prepareOutputDirectoryPath('tests/images')).toBe(path.resolve(dirname, 'images')); }); diff --git a/tests/prepare-write-file-path.test.js b/tests/prepare-write-file-path.test.js deleted file mode 100644 index e86d4e3..0000000 --- a/tests/prepare-write-file-path.test.js +++ /dev/null @@ -1,36 +0,0 @@ -import fs from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; - -import prepareWriteFilePath from '../lib/prepare-write-file-path.js'; - -const dirname = path.dirname(fileURLToPath(import.meta.url)); - -const imageName = 'jpeg-one-pixel.jpg'; -const output = fs.mkdtempSync(path.join(os.tmpdir(), 'optimizt-test-')); - -afterAll(() => { - fs.rmSync(output, { recursive: true }); -}); - -test('Write path does not change', () => { - const filePath = path.resolve(dirname, 'images', imageName); - - expect(prepareWriteFilePath(filePath)).toBe(filePath); -}); - -test('Path changes when outputDir is specified', () => { - const filePath = path.join('path', imageName); - const outputFilePath = path.join(output, imageName); - - expect(prepareWriteFilePath(filePath, output)).toBe(outputFilePath); -}); - -test('Hierarchy is preserved', () => { - const filePath = path.join('path', 'with', 'subdirs'); - const outputFilePath = path.join(output, 'with', 'subdirs'); - - expect(prepareWriteFilePath(filePath, output)).toBe(outputFilePath); - expect(prepareWriteFilePath(path.join(filePath, imageName), output)).toBe(path.join(outputFilePath, imageName)); -}); diff --git a/tests/show-total.test.js b/tests/show-total.test.js index ba7602a..0c4cd5e 100644 --- a/tests/show-total.test.js +++ b/tests/show-total.test.js @@ -1,6 +1,6 @@ import { jest } from '@jest/globals'; -import showTotal from '../lib/show-total.js'; +import { showTotal } from '../lib/show-total.js'; test('Savings size and compression ratio are displayed', () => { const fileSize = 1_048_576; @@ -8,7 +8,7 @@ test('Savings size and compression ratio are displayed', () => { console.log = jest.fn(); showTotal(fileSize, fileSize / 2); - expect(console.log.mock.calls[0][1]).toBe('Yay! You saved 512 KB (50%)'); + expect(console.log.mock.calls[1][1]).toBe('Yay! You saved 512 KB (50%)'); console.log.mockRestore(); }); @@ -19,7 +19,7 @@ test('Savings size and compression ratio are not displayed', () => { console.log = jest.fn(); showTotal(fileSize, fileSize * 2); - expect(console.log.mock.calls[0][1]).toBe('Done!'); + expect(console.log.mock.calls[1][1]).toBe('Done!'); console.log.mockRestore(); });