diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yaml similarity index 69% rename from .github/workflows/tests.yml rename to .github/workflows/tests.yaml index 1bff6e6..31c2bbb 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yaml @@ -1,18 +1,18 @@ -name: tests on: - push + jobs: - build: + test: runs-on: ubuntu-latest strategy: matrix: node-version: - - 18.x - 20.x - - 21.x + - 24.x + - latest steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 + - uses: actions/checkout@v5 + - uses: actions/setup-node@v6 with: node-version: ${{ matrix.node-version }} - run: npm run test:prepare && npm install-test diff --git a/CHANGELOG.md b/CHANGELOG.md index e2829d1..fdac9b0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,12 +1,29 @@ faucet-pipeline-images version history ====================================== +v2.3.0 +------ + +_2025-11-03_ + +notable changes for end users: + +* bumped Node requirement to v20 or later, dropping support for obsolete versions +* reduced the number of third-party dependencies + +notable changes for developers: + +* switched from faucet-pipeline-core's FileFinder to faucet-pipeline-asset's + utils +* removed requirement for ImageMagick to be installed + + v2.2.0 ------ _2024-03-20_ -Maintenance release to update dependencies; bump minimum supported Node version +maintenance release to update dependencies; bump minimum supported Node version to 18 due to end of life. v2.1.0 @@ -14,7 +31,7 @@ v2.1.0 _2021-01-12_ -Maintenance release to update dependencies; no significant changes +maintenance release to update dependencies; no significant changes v2.0.0 diff --git a/index.js b/index.js index 7a7a006..0e4e06b 100644 --- a/index.js +++ b/index.js @@ -1,9 +1,24 @@ -let path = require("path"); -let FileFinder = require("faucet-pipeline-core/lib/util/files/finder"); -let sharp = require("sharp"); -let svgo = require("svgo"); -let { stat, readFile } = require("fs").promises; -let { abort } = require("faucet-pipeline-core/lib/util"); +import path from "node:path"; +import sharp from "sharp"; +import svgo from "svgo"; +import { readFile } from "node:fs/promises"; +import { buildProcessPipeline } from "faucet-pipeline-assets/lib/util.js"; +import { abort, repr } from "faucet-pipeline-core/lib/util/index.js"; + +export const key = "images"; +export const bucket = "static"; + +export function plugin(config, assetManager) { + let pipeline = config.map(optimizerConfig => { + let processFile = buildProcessFile(optimizerConfig); + let { source, target } = optimizerConfig; + let filter = optimizerConfig.filter || + withFileExtension("avif", "jpg", "jpeg", "png", "webp", "svg"); + return buildProcessPipeline(source, target, processFile, assetManager, filter); + }); + + return filepaths => Promise.all(pipeline.map(optimize => optimize(filepaths))); +} // we can optimize the settings here, but some would require libvips // to be compiled with additional stuff @@ -51,88 +66,35 @@ let settings = { avif: {} }; -module.exports = { - key: "images", - bucket: "static", - plugin: faucetImages -}; - -function faucetImages(config, assetManager) { - let optimizers = config.map(optimizerConfig => - makeOptimizer(optimizerConfig, assetManager)); - - return filepaths => Promise.all(optimizers.map(optimize => optimize(filepaths))); -} - -function makeOptimizer(optimizerConfig, assetManager) { - let source = assetManager.resolvePath(optimizerConfig.source); - let target = assetManager.resolvePath(optimizerConfig.target, { - enforceRelative: true - }); - let fileFinder = new FileFinder(source, { - skipDotfiles: true, - filter: optimizerConfig.filter || - withFileExtension("avif", "jpg", "jpeg", "png", "webp", "svg") - }); - let { - autorotate, - fingerprint, - format, - width, - height, - crop, - quality, - scale, - suffix - } = optimizerConfig; - - return async filepaths => { - let [fileNames, targetDir] = await Promise.all([ - (filepaths ? fileFinder.match(filepaths) : fileFinder.all()), - determineTargetDir(source, target) - ]); - return processFiles(fileNames, { - assetManager, - source, - target, - targetDir, - fingerprint, - variant: { - autorotate, format, width, height, crop, quality, scale, suffix - } - }); +/** + * Returns a function that processes a single file + */ +function buildProcessFile(config) { + return async function(filename, + { source, target, targetDir, assetManager }) { + let sourcePath = path.join(source, filename); + let targetPath = determineTargetPath(path.join(target, filename), config); + + let format = config.format ? config.format : extname(filename); + + let output = format === "svg" ? + await optimizeSVG(sourcePath) : + await optimizeBitmap(sourcePath, format, config); + + let writeOptions = { targetDir }; + if(config.fingerprint !== undefined) { + writeOptions.fingerprint = config.fingerprint; + } + return assetManager.writeFile(targetPath, output, writeOptions); }; } -// If `source` is a directory, `target` is used as target directory - -// otherwise, `target`'s parent directory is used -async function determineTargetDir(source, target) { - let results = await stat(source); - return results.isDirectory() ? target : path.dirname(target); -} - -async function processFiles(fileNames, config) { - return Promise.all(fileNames.map(fileName => processFile(fileName, config))); -} - -async function processFile(fileName, - { source, target, targetDir, fingerprint, assetManager, variant }) { - let sourcePath = path.join(source, fileName); - let targetPath = determineTargetPath(path.join(target, fileName), variant); - - let format = variant.format ? variant.format : extname(fileName); - - let output = format === "svg" ? - await optimizeSVG(sourcePath) : - await optimizeBitmap(sourcePath, format, variant); - - let writeOptions = { targetDir }; - if(fingerprint !== undefined) { - writeOptions.fingerprint = fingerprint; - } - return assetManager.writeFile(targetPath, output, writeOptions); -} - +/** + * Optimize a single SVG + * + * @param {string} sourcePath + * @returns {Promise} + */ async function optimizeSVG(sourcePath) { let input = await readFile(sourcePath); @@ -140,10 +102,18 @@ async function optimizeSVG(sourcePath) { let output = await svgo.optimize(input, settings.svg); return output.data; } catch(error) { - abort(`Only SVG can be converted to SVG: ${sourcePath}`); + abort(`Only SVG can be converted to SVG: ${repr(sourcePath)}`); } } +/** + * Optimize a single bitmap image + * + * @param {string} sourcePath + * @param {string} format + * @param {Object} options + * @returns {Promise} + */ async function optimizeBitmap(sourcePath, format, { autorotate, width, height, scale, quality, crop }) { let image = sharp(sourcePath); @@ -153,7 +123,12 @@ async function optimizeBitmap(sourcePath, format, if(scale) { let metadata = await image.metadata(); - image.resize({ width: metadata.width * scale, height: metadata.height * scale }); + if(metadata.width && metadata.height) { + image.resize({ + width: metadata.width * scale, + height: metadata.height * scale + }); + } } if(width || height) { @@ -176,12 +151,17 @@ async function optimizeBitmap(sourcePath, format, image.avif({ ...settings.avif, quality }); break; default: - abort(`unsupported format ${format}. We support: AVIF, JPG, PNG, WebP, SVG`); + abort(`unsupported format ${repr(format)}. We support: AVIF, JPG, PNG, WebP, SVG`); } return image.toBuffer(); } +/** + * @param {string} filepath + * @param {Object} options + * @returns {string} + */ function determineTargetPath(filepath, { format, suffix = "" }) { format = format ? `.${format}` : ""; let directory = path.dirname(filepath); @@ -190,11 +170,20 @@ function determineTargetPath(filepath, { format, suffix = "" }) { return path.join(directory, `${basename}${suffix}${extension}${format}`); } +/** + * @param {...string} extensions + * @returns {Filter} + */ function withFileExtension(...extensions) { return filename => extensions.includes(extname(filename)); } -// extname follows this annoying idea that the dot belongs to the extension +/** + * File extension of a filename without the dot + * + * @param {string} filename + * @returns {string} + */ function extname(filename) { return path.extname(filename).slice(1).toLowerCase(); } diff --git a/package.json b/package.json index 8c709a2..58e02df 100644 --- a/package.json +++ b/package.json @@ -1,13 +1,14 @@ { "name": "faucet-pipeline-images", - "version": "2.2.0", + "version": "2.3.0", "description": "image optimization for faucet-pipeline", - "main": "index.js", + "main": "./index.js", + "type": "module", "scripts": { - "test": "npm-run-all --parallel lint test:cli", + "test": "npm run lint && npm run test:cli", "test:cli": "./test/run", "test:prepare": "./test/prepare", - "lint": "eslint --cache index.js test && echo ✓" + "lint": "eslint --cache ./index.js ./test && echo ✓" }, "repository": { "type": "git", @@ -20,18 +21,16 @@ }, "homepage": "https://www.faucet-pipeline.org", "engines": { - "node": ">= 18" + "node": ">= 20.19.0" }, "dependencies": { "faucet-pipeline-core": "^2.0.0", - "sharp": "^0.33.2", - "svgo": "^3.0.2" + "faucet-pipeline-assets": "^0.2.0", + "sharp": "^0.34.4", + "svgo": "^3.3.2" }, "devDependencies": { "eslint-config-fnd": "^1.13.0", - "file-type-cli": "^6.0.0", - "json-diff": "^1.0.0", - "npm-run-all": "^4.1.5", "release-util-fnd": "^3.0.0" } } diff --git a/test/run b/test/run index b5dd19c..0f760c7 100755 --- a/test/run +++ b/test/run @@ -32,7 +32,7 @@ function assert_smaller_size { # can't recognize SVG function assert_mime_type { expected_mime="${1:?}" - actual_mime=$(file-type "${2:?}" | tail -n1) + actual_mime=$(file --brief --mime-type "${2:?}") if [ "$expected_mime" == "$actual_mime" ]; then true @@ -46,10 +46,10 @@ function assert_svg { grep -q svg "${1:?}" || fail "expected $1 to be an SVG" } -# checks the dimensions of an image, requires ImageMagick +# checks the dimensions of an image function assert_dimensions { expected_dimensions="${1:?}" - actual_dimensions=$(identify -format "%wx%h" "${2:?}") + actual_dimensions=$(file "${2:?}" | grep -oE "[0-9]+x[0-9]+") if [ "$expected_dimensions" == "$actual_dimensions" ]; then true diff --git a/test/test_avif/faucet.config.js b/test/test_avif/faucet.config.js index 69a17e0..c3dd509 100644 --- a/test/test_avif/faucet.config.js +++ b/test/test_avif/faucet.config.js @@ -1,11 +1,9 @@ -"use strict"; -let path = require("path"); +import { resolve } from "node:path"; -module.exports = { - images: [{ - source: "./src", - target: "./dist", - format: "avif" - }], - plugins: [path.resolve(__dirname, "../..")] -}; +export const images = [{ + source: "./src", + target: "./dist", + format: "avif" +}]; + +export const plugins = [resolve(import.meta.dirname, "../..")]; diff --git a/test/test_basic/faucet.config.js b/test/test_basic/faucet.config.js index 1b79828..e1d9b35 100644 --- a/test/test_basic/faucet.config.js +++ b/test/test_basic/faucet.config.js @@ -1,10 +1,8 @@ -"use strict"; -let path = require("path"); +import { resolve } from "node:path"; -module.exports = { - images: [{ - source: "./src", - target: "./dist" - }], - plugins: [path.resolve(__dirname, "../..")] -}; +export const images = [{ + source: "./src", + target: "./dist" +}]; + +export const plugins = [resolve(import.meta.dirname, "../..")]; diff --git a/test/test_filter/faucet.config.js b/test/test_filter/faucet.config.js index 46a7a84..4294a3e 100644 --- a/test/test_filter/faucet.config.js +++ b/test/test_filter/faucet.config.js @@ -1,11 +1,9 @@ -"use strict"; -let path = require("path"); +import { resolve } from "node:path"; -module.exports = { - images: [{ - source: "./src", - target: "./dist", - filter: file => file.endsWith(".jpg") - }], - plugins: [path.resolve(__dirname, "../..")] -}; +export const images = [{ + source: "./src", + target: "./dist", + filter: file => file.endsWith(".jpg") +}]; + +export const plugins = [resolve(import.meta.dirname, "../..")]; diff --git a/test/test_generated_names/faucet.config.js b/test/test_generated_names/faucet.config.js index 6eb8d65..ccc7441 100644 --- a/test/test_generated_names/faucet.config.js +++ b/test/test_generated_names/faucet.config.js @@ -1,23 +1,21 @@ -"use strict"; -let path = require("path"); +import { resolve } from "node:path"; -module.exports = { - images: [{ - source: "./src", - target: "./dist" - }, { - source: "./src", - target: "./dist", - format: "webp" - }, { - source: "./src", - target: "./dist", - suffix: "-suffix" - }, { - source: "./src", - target: "./dist", - format: "webp", - suffix: "-suffix" - }], - plugins: [path.resolve(__dirname, "../..")] -}; +export const images = [{ + source: "./src", + target: "./dist" +}, { + source: "./src", + target: "./dist", + format: "webp" +}, { + source: "./src", + target: "./dist", + suffix: "-suffix" +}, { + source: "./src", + target: "./dist", + format: "webp", + suffix: "-suffix" +}]; + +export const plugins = [resolve(import.meta.dirname, "../..")]; diff --git a/test/test_quality/faucet.config.js b/test/test_quality/faucet.config.js index ddbc25e..0284e1a 100644 --- a/test/test_quality/faucet.config.js +++ b/test/test_quality/faucet.config.js @@ -1,11 +1,9 @@ -"use strict"; -let path = require("path"); +import { resolve } from "node:path"; -module.exports = { - images: [{ - source: "./src", - target: "./dist", - quality: 20 - }], - plugins: [path.resolve(__dirname, "../..")] -}; +export const images = [{ + source: "./src", + target: "./dist", + quality: 20 +}]; + +export const plugins = [resolve(import.meta.dirname, "../..")]; diff --git a/test/test_resizing/faucet.config.js b/test/test_resizing/faucet.config.js index 7529059..cbf8547 100644 --- a/test/test_resizing/faucet.config.js +++ b/test/test_resizing/faucet.config.js @@ -1,25 +1,23 @@ -"use strict"; -let path = require("path"); +import { resolve } from "node:path"; -module.exports = { - images: [{ - source: "./src", - target: "./dist", - scale: 0.5, - suffix: "-small" - }, { - source: "./src", - target: "./dist", - width: 300, - height: 300, - suffix: "-thumbnail" - }, { - source: "./src", - target: "./dist", - width: 300, - height: 300, - crop: true, - suffix: "-square" - }], - plugins: [path.resolve(__dirname, "../..")] -}; +export const images = [{ + source: "./src", + target: "./dist", + scale: 0.5, + suffix: "-small" +}, { + source: "./src", + target: "./dist", + width: 300, + height: 300, + suffix: "-thumbnail" +}, { + source: "./src", + target: "./dist", + width: 300, + height: 300, + crop: true, + suffix: "-square" +}]; + +export const plugins = [resolve(import.meta.dirname, "../..")]; diff --git a/test/test_webp/faucet.config.js b/test/test_webp/faucet.config.js index 4b2fa72..7b9762a 100644 --- a/test/test_webp/faucet.config.js +++ b/test/test_webp/faucet.config.js @@ -1,11 +1,9 @@ -"use strict"; -let path = require("path"); +import { resolve } from "node:path"; -module.exports = { - images: [{ - source: "./src", - target: "./dist", - format: "webp" - }], - plugins: [path.resolve(__dirname, "../..")] -}; +export const images = [{ + source: "./src", + target: "./dist", + format: "webp" +}]; + +export const plugins = [resolve(import.meta.dirname, "../..")];