Skip to content

Commit cbe6797

Browse files
committed
Make this an actual image pipeline via sharp [WIP]
New features: * Own pipeline, no longer part of static * New image optimization * Resizing * Output images in different formats (PNG, JPG, WebP)
1 parent 78ea785 commit cbe6797

File tree

19 files changed

+369
-10
lines changed

19 files changed

+369
-10
lines changed

.eslintrc

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
root: true
2+
extends: fnd

.github/workflows/nodejs.yml

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
name: tests
2+
on:
3+
- push
4+
jobs:
5+
build:
6+
runs-on: ubuntu-latest
7+
strategy:
8+
matrix:
9+
node-version:
10+
- 10.x
11+
- 12.x
12+
steps:
13+
- uses: actions/checkout@v1
14+
- uses: actions/setup-node@v1
15+
with:
16+
node-version: ${{ matrix.node-version }}
17+
- run: npm i && npm run test:prepare && npm test
18+
env:
19+
CI: true

.gitignore

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,11 @@
11
/node_modules
2+
/.eslintcache
3+
24
/package-lock.json
5+
6+
# Ignore the images
7+
*.jpg
8+
*.png
9+
*.webp
10+
*.gif
11+
*.svg

.travis.yml

Lines changed: 0 additions & 3 deletions
This file was deleted.

README.md

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
# faucet-pipeline-images
22
[![npm](https://img.shields.io/npm/v/faucet-pipeline-images.svg)](https://www.npmjs.com/package/faucet-pipeline-images)
3-
[![Build Status](https://travis-ci.org/faucet-pipeline/faucet-pipeline-images.svg?branch=master)](https://travis-ci.org/faucet-pipeline/faucet-pipeline-images)
43
[![Greenkeeper badge](https://badges.greenkeeper.io/faucet-pipeline/faucet-pipeline-images.svg)](https://greenkeeper.io)
54

65
You can find the documentation [here](http://www.faucet-pipeline.org).

index.js

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
let path = require("path");
2+
let FileFinder = require("faucet-pipeline-core/lib/util/files/finder");
3+
let sharp = require("sharp");
4+
let SVGO = require("svgo");
5+
let { stat, readFile } = require("fs").promises;
6+
let { abort } = require("faucet-pipeline-core/lib/util");
7+
8+
// we can optimize the settings here, but some would require libvips
9+
// to be compiled with additional stuff
10+
let settings = {
11+
svg: {},
12+
png: { adaptiveFiltering: true },
13+
jpeg: { progressive: true },
14+
webp: {}
15+
};
16+
17+
module.exports = {
18+
key: "images",
19+
bucket: "static",
20+
plugin: faucetImages
21+
};
22+
23+
function faucetImages(config, assetManager) {
24+
let optimizers = config.map(optimizerConfig =>
25+
makeOptimizer(optimizerConfig, assetManager));
26+
27+
return filepaths => Promise.all(optimizers.map(optimize => optimize(filepaths)));
28+
}
29+
30+
function makeOptimizer(optimizerConfig, assetManager) {
31+
let source = assetManager.resolvePath(optimizerConfig.source);
32+
let target = assetManager.resolvePath(optimizerConfig.target, {
33+
enforceRelative: true
34+
});
35+
let fileFinder = new FileFinder(source, {
36+
skipDotfiles: true,
37+
// TODO: make configurable
38+
filter: withFileExtension("jpg", "jpeg", "png", "webp", "svg")
39+
});
40+
let {
41+
fingerprint,
42+
format,
43+
width,
44+
height,
45+
keepRatio,
46+
scale,
47+
suffix
48+
} = optimizerConfig;
49+
50+
return async filepaths => {
51+
let [fileNames, targetDir] = await Promise.all([
52+
(filepaths ? fileFinder.match(filepaths) : fileFinder.all()),
53+
determineTargetDir(source, target)
54+
]);
55+
return processFiles(fileNames, {
56+
assetManager,
57+
source,
58+
target,
59+
targetDir,
60+
fingerprint,
61+
variant: {
62+
format, width, height, keepRatio, scale, suffix
63+
}
64+
});
65+
};
66+
}
67+
68+
// If `source` is a directory, `target` is used as target directory -
69+
// otherwise, `target`'s parent directory is used
70+
async function determineTargetDir(source, target) {
71+
let results = await stat(source);
72+
return results.isDirectory() ? target : path.dirname(target);
73+
}
74+
75+
async function processFiles(fileNames, config) {
76+
return Promise.all(fileNames.map(fileName => processFile(fileName, config)));
77+
}
78+
79+
async function processFile(fileName,
80+
{ source, target, targetDir, fingerprint, assetManager, variant, suffix }) {
81+
let sourcePath = path.join(source, fileName);
82+
let targetPath = path.join(target, fileName);
83+
84+
let format = variant.format ? variant.format : fileExtension(fileName);
85+
86+
let output = format === "svg" ?
87+
await optimizeSVG(sourcePath) :
88+
await optimizeBitmap(sourcePath, format, variant);
89+
90+
let writeOptions = { targetDir };
91+
if(fingerprint !== undefined) {
92+
writeOptions.fingerprint = fingerprint;
93+
}
94+
return assetManager.writeFile(targetPath, output, writeOptions);
95+
}
96+
97+
async function optimizeSVG(sourcePath) {
98+
let input = await readFile(sourcePath);
99+
100+
try {
101+
let svgo = new SVGO(settings.svg);
102+
let output = await svgo.optimize(input);
103+
return output.data;
104+
} catch(error) {
105+
abort(`Only SVG can be converted to SVG: ${sourcePath}`);
106+
}
107+
}
108+
109+
async function optimizeBitmap(sourcePath, format,
110+
{ width, height, scale, keepRatio = true }) {
111+
let image = sharp(sourcePath);
112+
113+
if(scale) {
114+
let metadata = await image.metadata();
115+
image.resize({ width: metadata.width * scale, height: metadata.height * scale });
116+
}
117+
118+
if(width || height) {
119+
let fit = keepRatio ? "inside" : "cover";
120+
image.resize({ width: width, height: height, fit: sharp.fit[fit] });
121+
}
122+
123+
switch(format) {
124+
case "jpg":
125+
case "jpeg":
126+
image.jpeg(settings.jpeg);
127+
break;
128+
case "png":
129+
image.png(settings.png);
130+
break;
131+
case "webp":
132+
image.webp(settings.webp);
133+
break;
134+
default:
135+
abort(`unsupported format ${format}. We support: JPG, PNG, WebP, SVG`);
136+
}
137+
138+
return image.toBuffer();
139+
}
140+
141+
function withFileExtension(...extensions) {
142+
return filename => extensions.includes(fileExtension(filename));
143+
}
144+
145+
// extname follows this annoying idea that the dot belongs to the extension
146+
function fileExtension(filename) {
147+
return path.extname(filename).slice(1);
148+
}

package.json

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,14 @@
11
{
22
"name": "faucet-pipeline-images",
3-
"version": "1.2.0",
3+
"version": "2.0.0",
44
"description": "image optimization for faucet-pipeline",
5+
"main": "index.js",
6+
"scripts": {
7+
"test": "npm-run-all --parallel lint test:cli",
8+
"test:cli": "./test/run",
9+
"test:prepare": "./test/prepare",
10+
"lint": "eslint --cache index.js test && echo ✓"
11+
},
512
"repository": {
613
"type": "git",
714
"url": "git+https://github.com/faucet-pipeline/faucet-pipeline-images.git"
@@ -13,12 +20,15 @@
1320
},
1421
"homepage": "https://www.faucet-pipeline.org",
1522
"dependencies": {
16-
"faucet-pipeline-static": "^1.0.0",
17-
"imagemin-mozjpeg": "~8.0.0",
18-
"imagemin-pngquant": "~8.0.0",
19-
"imagemin-svgo": "~7.0.0"
23+
"faucet-pipeline-core": "^1.4.0",
24+
"sharp": "^0.25.2",
25+
"svgo": "^1.3.2"
2026
},
2127
"devDependencies": {
22-
"release-util-fnd": "^1.1.1"
28+
"eslint-config-fnd": "^1.8.0",
29+
"file-type-cli": "^4.0.0",
30+
"json-diff": "^0.5.4",
31+
"npm-run-all": "^4.1.5",
32+
"release-util-fnd": "^2.0.0"
2333
}
2434
}

test/prepare

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
#!/usr/bin/env bash
2+
set -xeuo pipefail
3+
4+
curl -o "test/test_basic/src/example.jpg" "https://images.unsplash.com/photo-1498496294664-d9372eb521f3?fm=jpg&q=85"
5+
curl -o "test/test_basic/src/example.png" "https://upload.wikimedia.org/wikipedia/commons/thumb/4/47/PNG_transparency_demonstration_1.png/560px-PNG_transparency_demonstration_1.png"
6+
curl -o "test/test_basic/src/example.gif" "https://upload.wikimedia.org/wikipedia/commons/2/2c/Rotating_earth_%28large%29.gif"
7+
curl -o "test/test_basic/src/example.svg" "https://upload.wikimedia.org/wikipedia/commons/0/02/SVG_logo.svg"
8+
curl -o "test/test_basic/src/example.webp" "https://www.gstatic.com/webp/gallery3/2_webp_a.webp"
9+
10+
cp test/test_basic/src/example.jpg test/test_webp/src/example.jpg
11+
cp test/test_basic/src/example.png test/test_webp/src/example.png
12+
cp test/test_basic/src/example.webp test/test_webp/src/example.webp
13+
14+
cp test/test_basic/src/example.jpg test/test_thumbnail/src/example.jpg
15+
cp test/test_basic/src/example.png test/test_thumbnail/src/example.png
16+
17+
cp test/test_basic/src/example.jpg test/test_thumbnail_square/src/example.jpg
18+
cp test/test_basic/src/example.png test/test_thumbnail_square/src/example.png

test/run

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
#!/usr/bin/env bash
2+
set -euo pipefail
3+
4+
root=`dirname "$0"`
5+
root=`node -r fs -p "fs.realpathSync(process.argv[1]);" "$root"`
6+
7+
. "$root/../node_modules/faucet-pipeline-core/test/cli_harness.sh"
8+
9+
# ensures the second file is smaller than the first file
10+
function assert_smaller_size {
11+
original=$(wc -c < "${1:?}")
12+
result=$(wc -c < "${2:?}")
13+
14+
if [ $(bc <<< "$result < $original") != 1 ]; then
15+
fail "file \`$2\` is not smaller than \`$1\`"
16+
else
17+
true
18+
fi
19+
}
20+
21+
# compare the mime type of a given file to the expected mime type
22+
# can't recognize SVG
23+
function assert_mime_type {
24+
expected_mime="${1:?}"
25+
actual_mime=$(file-type "${2:?}" | tail -n1)
26+
27+
if [ "$expected_mime" == "$actual_mime" ]; then
28+
true
29+
else
30+
fail "expected $2 to be a $expected_mime, but was $actual_mime"
31+
fi
32+
}
33+
34+
# very naive SVG check
35+
function assert_svg {
36+
grep -q svg "${1:?}" || fail "expected $1 to be an SVG"
37+
}
38+
39+
# checks the dimensions of an image, requires ImageMagick
40+
function assert_dimensions {
41+
expected_dimensions="${1:?}"
42+
actual_dimensions=$(identify -format "%wx%h" "${2:?}")
43+
44+
if [ "$expected_dimensions" == "$actual_dimensions" ]; then
45+
true
46+
else
47+
fail "expected $2 to be a $expected_dimensions, but was $actual_dimensions"
48+
fi
49+
}
50+
51+
begin "$root/test_basic"
52+
faucet
53+
54+
assert_smaller_size src/example.jpg dist/example.jpg
55+
assert_mime_type "image/jpeg" dist/example.jpg
56+
57+
assert_smaller_size src/example.png dist/example.png
58+
assert_mime_type "image/png" dist/example.png
59+
60+
assert_smaller_size src/example.webp dist/example.webp
61+
assert_mime_type "image/webp" dist/example.webp
62+
63+
assert_smaller_size src/example.svg dist/example.svg
64+
assert_svg dist/example.svg
65+
66+
assert_missing dist/example.gif
67+
end
68+
69+
# TODO: Note that by default, faucet does not change the file extension
70+
# we need to do that as an option `extension`
71+
begin "$root/test_webp"
72+
faucet
73+
assert_mime_type "image/webp" dist/example.jpg
74+
assert_mime_type "image/webp" dist/example.png
75+
assert_mime_type "image/webp" dist/example.webp
76+
end
77+
78+
begin "$root/test_thumbnail"
79+
faucet
80+
assert_dimensions "300x200" dist/example.jpg
81+
assert_dimensions "300x225" dist/example.png
82+
end
83+
84+
begin "$root/test_thumbnail_square"
85+
faucet
86+
assert_dimensions "300x300" dist/example.jpg
87+
assert_dimensions "300x300" dist/example.png
88+
end
89+
90+
# TODO: This does not work on Github Actions, and I don't know why
91+
# begin "$root/test_scaling"
92+
# faucet
93+
# assert_dimensions "3000x2000" dist/example.jpg
94+
# assert_dimensions "280x210" dist/example.png
95+
# end
96+
97+
# TODO: Add Suffix
98+
# Merge the last three tests into one with three different suffixes
99+
100+
echo; echo "SUCCESS: all tests passed"

test/test_basic/faucet.config.js

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
"use strict";
2+
let path = require("path");
3+
4+
module.exports = {
5+
images: [{
6+
source: "./src",
7+
target: "./dist"
8+
}],
9+
plugins: [path.resolve(__dirname, "../..")]
10+
};

0 commit comments

Comments
 (0)