From 55f0850e5920637678096116b6575a21eeb794f7 Mon Sep 17 00:00:00 2001 From: Mark van Seventer Date: Mon, 31 Aug 2026 16:59:26 -0700 Subject: [PATCH] Add path output template - fixes #43. --- CHANGELOG.md | 1 + README.md | 10 +++++++++- lib/convert.js | 36 +++++++++++++++++++++++++++++++----- test/convert.js | 26 ++++++++++++++++---------- 4 files changed, 57 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3f70990..2b716c7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/README.md b/README.md index 9936d7d..dc8dcf5 100644 --- a/README.md +++ b/README.md @@ -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. ``` ┌─────────────────────┬────────────┐ @@ -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 diff --git a/lib/convert.js b/lib/convert.js index 9a926f9..fb2aa23 100644 --- a/lib/convert.js +++ b/lib/convert.js @@ -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"; @@ -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) { @@ -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 }, diff --git a/test/convert.js b/test/convert.js index 0dee1ee..d918a72 100644 --- a/test/convert.js +++ b/test/convert.js @@ -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", () => { @@ -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()); });