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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

## 6.1.0-dev

- Added a `{path}` output template for preserving directories matched by glob inputs ([#43](https://github.com/vseventer/sharp-cli/issues/43)).
- Added percentage-based dimensions to the `resize` command ([#49](https://github.com/vseventer/sharp-cli/issues/49)).

## 6.0.0 (August 21, 2026)
Expand Down
10 changes: 9 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -216,7 +216,7 @@ For more information on available options, please visit https://sharp.pixelplumb

- The CLI supports input streams.
- [Glob](https://www.npmjs.com/package/glob) patterns are allowed, for example `--input './images/**/*.jpg'`. Make sure you quote the pattern when using the CLI.
- Supported output macros: `{root}`, `{dir}`, `{base}`, `{name}`, and `{ext}` (from [Node.js path](https://nodejs.org/api/path.html#path_path_parse_path)), for example: `--output {dir}` will overwrite original files.
- Supported output macros: `{root}`, `{dir}`, `{base}`, `{name}`, and `{ext}` (from [Node.js path](https://nodejs.org/api/path.html#path_path_parse_path)), for example: `--output {dir}` will overwrite original files. Additionally, `{path}` is the directory relative to the non-glob portion of the input path.

```
┌─────────────────────┬────────────┐
Expand All @@ -227,6 +227,14 @@ For more information on available options, please visit https://sharp.pixelplumb
└──────┴──────────────┴──────┴─────┘
```

For glob input, `{path}` can be used in an `--output` template to preserve the matched directory relative to the non-glob portion of the input path:

```
Command: sharp --input './images/**/*.jpg' --output './output/{path}/{name}.webp'
Match: ./images/icons/example.jpg
Output: ./output/icons/example.webp
```

## Related

- [sharp](http://sharp.pixelplumbing.com/) - API for this module
Expand Down
36 changes: 31 additions & 5 deletions lib/convert.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@

// Standard lib.
import { createReadStream } from "node:fs";
import { mkdir } from "node:fs/promises";
import path from "node:path";
import { pipeline } from "node:stream/promises";

Expand Down Expand Up @@ -55,26 +56,49 @@ const FORMATS = {
".webp": "webp",
};

// Resolve the non-magic portion of a glob to use as its relative path root.
const getGlobRoot = (pattern) => {
const absolute = path.resolve(pattern);
const magicIndex = absolute.search(/[*?[{(]/);
if (magicIndex === -1) {
return path.dirname(absolute);
}

const prefix = absolute.slice(0, magicIndex);
return prefix.endsWith(path.sep)
? path.resolve(prefix)
: path.dirname(path.resolve(prefix));
};

// Exports.
export default {
// Convert a list of files.
files: async (input, output, context) => {
// Resolve files.
const files = input.flatMap((input) => globSync(input, { absolute: true }));
const files = input.flatMap((pattern) => {
const root = getGlobRoot(pattern);
return globSync(pattern, { absolute: true }).map((src) => ({
root,
src,
}));
});
if (files.length === 0) {
throw new Error("No input files");
}

// Process files.
const isBatch = files.length > 1;
const promises = files.map((src) => {
const promises = files.map(({ root, src }) => {
const image = sharp(context.options);
return pipeline(createReadStream(src), image)
.then(() => image.metadata())
.then((metadata) => {
// Process output as a template.
const parts = path.parse(src);
const regex = /\{(root|dir|base|ext|name)\}/g;
const parts = {
...path.parse(src),
path: path.relative(root, path.dirname(src)),
};
const regex = /\{(root|dir|path|base|ext|name)\}/g;
let dest = output;
let match;
while ((match = regex.exec(output)) !== null) {
Expand Down Expand Up @@ -110,7 +134,9 @@ export default {
? transformer
.toBuffer({ resolveWithObject: true })
.then(({ info }) => info)
: transformer.toFile(dest);
: mkdir(path.dirname(dest), { recursive: true }).then(() =>
transformer.toFile(dest),
);
return promise.then((info) => ({
input: inputMetadata,
output: { ...info, path: dest },
Expand Down
26 changes: 16 additions & 10 deletions test/convert.js
Original file line number Diff line number Diff line change
Expand Up @@ -124,19 +124,13 @@ describe("convert", () => {
.then(() => assert.equal(format, "avif"));
});
it("must report a file conversion error", () => {
// Negative test for directory that does not exist.
const rand = "" + Math.random();
// Negative test for a parent path that is a file.
const output = path.join(copy, "output");
return convert
.files([input, input], rand, createContext())
.files([input, input], output, createContext())
.then(([result]) => {
assert.equal(result.status, "rejected");
assert.ok(
Object.prototype.hasOwnProperty.call(result.reason, "message"),
);
assert.ok(result.reason.message.includes(`${rand}/input.jpg`));
assert.ok(
result.reason.message.includes("No such file or directory"),
);
assert.equal(result.reason.code, "ENOTDIR");
});
});
it("must convert multiple files", () => {
Expand All @@ -152,6 +146,18 @@ describe("convert", () => {
assert.ok(getValue(result).output.path.includes(`input-${rand}.jpg`)),
);
});
it("must support relative path output templates", async () => {
const testDir = path.dirname(path.dirname(input));
const results = await convert.files(
[path.join(testDir, "**", "*.jpg")],
path.join(dest, "{path}", "{base}"),
createContext(),
);
const [result] = results;
const outputPath = getValue(result).output.path;
assert.equal(outputPath, path.join(dest, "fixtures", "input.jpg"));
assert.equal(fs.existsSync(outputPath), true);
});
it("must allow the same file as input and output", () => {
return convert.files([copy], path.dirname(copy), createContext());
});
Expand Down
Loading