Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 6 additions & 6 deletions .github/workflows/tests.yml → .github/workflows/tests.yaml
Original file line number Diff line number Diff line change
@@ -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
Expand Down
21 changes: 19 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,20 +1,37 @@
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
------

_2021-01-12_

Maintenance release to update dependencies; no significant changes
maintenance release to update dependencies; no significant changes


v2.0.0
Expand Down
167 changes: 78 additions & 89 deletions index.js
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -51,99 +66,54 @@
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<string>}
*/
async function optimizeSVG(sourcePath) {
let input = await readFile(sourcePath);

try {
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<Buffer>}
*/
async function optimizeBitmap(sourcePath, format,
{ autorotate, width, height, scale, quality, crop }) {
let image = sharp(sourcePath);
Expand All @@ -153,7 +123,12 @@

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) {
Expand All @@ -176,12 +151,17 @@
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`);

Check warning on line 154 in index.js

View workflow job for this annotation

GitHub Actions / test (24.x)

This line has a length of 91. Maximum allowed is 90

Check warning on line 154 in index.js

View workflow job for this annotation

GitHub Actions / test (20.x)

This line has a length of 91. Maximum allowed is 90

Check warning on line 154 in index.js

View workflow job for this annotation

GitHub Actions / test (latest)

This line has a length of 91. Maximum allowed is 90
}

return image.toBuffer();
}

/**
* @param {string} filepath
* @param {Object} options
* @returns {string}
*/
function determineTargetPath(filepath, { format, suffix = "" }) {
format = format ? `.${format}` : "";
let directory = path.dirname(filepath);
Expand All @@ -190,11 +170,20 @@
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();
}
19 changes: 9 additions & 10 deletions package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand All @@ -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"
}
}
6 changes: 3 additions & 3 deletions test/run
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
18 changes: 8 additions & 10 deletions test/test_avif/faucet.config.js
Original file line number Diff line number Diff line change
@@ -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, "../..")];
16 changes: 7 additions & 9 deletions test/test_basic/faucet.config.js
Original file line number Diff line number Diff line change
@@ -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, "../..")];
Loading