diff --git a/packages/sqip-cli/__tests__/e2e/__snapshots__/sqip-cli-e2e.test.ts.snap b/packages/sqip-cli/__tests__/e2e/__snapshots__/sqip-cli-e2e.test.ts.snap index 18cdc084..1e95e578 100644 --- a/packages/sqip-cli/__tests__/e2e/__snapshots__/sqip-cli-e2e.test.ts.snap +++ b/packages/sqip-cli/__tests__/e2e/__snapshots__/sqip-cli-e2e.test.ts.snap @@ -5,6 +5,7 @@ exports[`cli api > --help shows help screen to user 1`] = ` sqip CLI Usage: sqip --input [path] + command | sqip "SQIP" (pronounced skwɪb like the non-magical folk of magical descent) is a SVG-based LQIP technique - https://github.com/technopagan/sqip @@ -15,18 +16,23 @@ Options --version string Show version number -p, --plugins string[] One or more plugins. E.g. "-p primitive blur" - -i, --input string + -i, --input string Input file path. Can also be + provided via stdin: command | + sqip -o, --output string Define the path of the resulting file. By default SQIP will guess the output file name. -w, --width number Width of the resulting file. Negative values and 0 will fall back to original image width. - --silent Supress all output + --silent Supress all output. Defaults to + true when reading from stdin. --parseable-output Ensure the output is parseable. Will suppress the preview images and the table borders. --print Print resulting svg to stdout. + Defaults to true when reading + from stdin. -n, --primitive-numberOfPrimitives number The number of primitive shapes to use to build the SQIP SVG -m, --primitive-mode number The style of primitives to use: @@ -81,5 +87,8 @@ Examples Save input.jpg as result.svg with 25 shapes and no blur $ sqip -i input.jpg -n 25 -b 0 -o result.svg + + Process an image from stdin + $ curl -s https://example.com/image.jpg | sqip -p pixels blur svgo " `; diff --git a/packages/sqip-cli/__tests__/e2e/sqip-cli-e2e.test.ts b/packages/sqip-cli/__tests__/e2e/sqip-cli-e2e.test.ts index 8bc5a156..de98951d 100644 --- a/packages/sqip-cli/__tests__/e2e/sqip-cli-e2e.test.ts +++ b/packages/sqip-cli/__tests__/e2e/sqip-cli-e2e.test.ts @@ -35,7 +35,8 @@ describe('cli api', () => { test('no config exists programm and shows help', async () => { await expect(() => execa(cliCmd, [cliPath], { - stripFinalNewline: true + stripFinalNewline: true, + stdin: 'ignore' }) ).rejects.toThrow('Please provide the following arguments: input') }) @@ -104,4 +105,38 @@ describe('cli api', () => { await remove(tmpOutputFile) }) + + test('reads image from stdin and prints SVG', async () => { + const { stdout } = await execa( + 'cat', + [inputFile, '|', cliCmd, cliPath, '-p', 'pixels'], + { shell: true, stripFinalNewline: true } + ) + + // stdin mode defaults to --print, so stdout should contain an SVG + expect(stdout).toContain('') + }) + + test('stdin with -o writes file to given path', async () => { + const tmpOutputFile = resolve( + tmpdir(), + `sqip-stdin-e2e-test-${new Date().getTime()}.svg` + ) + + await execa( + 'cat', + [inputFile, '|', cliCmd, cliPath, '-p', 'pixels', '-o', tmpOutputFile], + { shell: true, stripFinalNewline: true } + ) + + // Does the new file exist + expect(await stat(tmpOutputFile)).toBeTruthy() + + const content = await readFile(tmpOutputFile) + const $ = cheerioLoad(content, { xml: true }) + expect($('svg')).toHaveLength(1) + + await remove(tmpOutputFile) + }) }) diff --git a/packages/sqip-cli/__tests__/unit/__snapshots__/sqip-cli.test.ts.snap b/packages/sqip-cli/__tests__/unit/__snapshots__/sqip-cli.test.ts.snap index f5dd0a06..4839536d 100644 --- a/packages/sqip-cli/__tests__/unit/__snapshots__/sqip-cli.test.ts.snap +++ b/packages/sqip-cli/__tests__/unit/__snapshots__/sqip-cli.test.ts.snap @@ -36,6 +36,7 @@ exports[`sqip-plugin-cli > passes all given args to library 1`] = ` { "input": "mocked-image.jpg", "output": "/tmp/mocked-image.svg", + "outputFileName": undefined, "parseableOutput": false, "plugins": [ { diff --git a/packages/sqip-cli/__tests__/unit/sqip-cli.test.ts b/packages/sqip-cli/__tests__/unit/sqip-cli.test.ts index ccb6bd4f..1ad035e6 100644 --- a/packages/sqip-cli/__tests__/unit/sqip-cli.test.ts +++ b/packages/sqip-cli/__tests__/unit/sqip-cli.test.ts @@ -1,9 +1,17 @@ import { vi, type MockedFunction } from 'vitest' +import { fstatSync } from 'fs' import { sqip, resolvePlugins } from 'sqip' import sqipCLI from '../../src/sqip-cli' import semver from 'semver' +vi.mock('fs', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, fstatSync: vi.fn(actual.fstatSync) } +}) + +const mockedFstatSync = fstatSync as MockedFunction + vi.mock('sqip', () => ({ sqip: vi.fn(async () => []), resolvePlugins: vi.fn(() => [ @@ -43,6 +51,7 @@ describe('sqip-plugin-cli', () => { proccessExitSpy.mockClear() mockedSqip.mockClear() mockedResolvePlugins.mockClear() + mockedFstatSync.mockRestore() process.argv = originalArgv }) @@ -169,4 +178,97 @@ describe('sqip-plugin-cli', () => { expect(errorSpy).toHaveBeenCalled() expect(proccessExitSpy).toHaveBeenCalledWith(1) }) + + describe('stdin support', () => { + const fakeImageBuffer = Buffer.from('fake-image-data') + + const originalToArray = process.stdin.toArray + + afterEach(() => { + process.stdin.toArray = originalToArray + }) + + function mockStdin(data: Buffer) { + mockedFstatSync.mockReturnValue({ isFIFO: () => true } as ReturnType) + process.stdin.toArray = (() => Promise.resolve([data])) as typeof process.stdin.toArray + } + + it('reads input from stdin when no --input flag', async () => { + mockStdin(fakeImageBuffer) + process.argv = ['', '', '-p', 'mocked'] + + await sqipCLI() + + expect(mockedSqip).toHaveBeenCalled() + const callArgs = mockedSqip.mock.calls[0][0] + expect(Buffer.isBuffer(callArgs.input)).toBe(true) + expect(callArgs.input).toEqual(fakeImageBuffer) + }) + + it('--input flag takes precedence over stdin', async () => { + mockStdin(fakeImageBuffer) + process.argv = ['', '', '-i', 'mocked-image.jpg', '-p', 'mocked'] + + await sqipCLI() + + expect(mockedSqip).toHaveBeenCalled() + const callArgs = mockedSqip.mock.calls[0][0] + expect(callArgs.input).toBe('mocked-image.jpg') + }) + + it('defaults print to true for stdin input', async () => { + mockStdin(fakeImageBuffer) + process.argv = ['', '', '-p', 'mocked'] + + await sqipCLI() + + expect(mockedSqip).toHaveBeenCalled() + const callArgs = mockedSqip.mock.calls[0][0] + expect(callArgs.print).toBe(true) + }) + + it('does not set output when input is from stdin', async () => { + mockStdin(fakeImageBuffer) + process.argv = ['', '', '-p', 'mocked'] + + await sqipCLI() + + expect(mockedSqip).toHaveBeenCalled() + const callArgs = mockedSqip.mock.calls[0][0] + expect(callArgs.output).toBeUndefined() + }) + + it('sets outputFileName to stdin for stdin input', async () => { + mockStdin(fakeImageBuffer) + process.argv = ['', '', '-p', 'mocked'] + + await sqipCLI() + + expect(mockedSqip).toHaveBeenCalled() + const callArgs = mockedSqip.mock.calls[0][0] + expect(callArgs.outputFileName).toBe('stdin') + }) + + it('respects -o flag with stdin input', async () => { + mockStdin(fakeImageBuffer) + process.argv = ['', '', '-p', 'mocked', '-o', '/tmp/out.svg'] + + await sqipCLI() + + expect(mockedSqip).toHaveBeenCalled() + const callArgs = mockedSqip.mock.calls[0][0] + expect(callArgs.output).toBe('/tmp/out.svg') + }) + + it('defaults silent to true for stdin input', async () => { + mockStdin(fakeImageBuffer) + process.argv = ['', '', '-p', 'mocked'] + + await sqipCLI() + + expect(mockedSqip).toHaveBeenCalled() + const callArgs = mockedSqip.mock.calls[0][0] + expect(callArgs.silent).toBe(true) + }) + }) }) diff --git a/packages/sqip-cli/src/sqip-cli.ts b/packages/sqip-cli/src/sqip-cli.ts index 14c7c9d2..10494926 100644 --- a/packages/sqip-cli/src/sqip-cli.ts +++ b/packages/sqip-cli/src/sqip-cli.ts @@ -1,3 +1,4 @@ +import { fstatSync } from 'fs' import path from 'path' import { fileURLToPath } from 'url' @@ -36,7 +37,8 @@ const defaultOptionList: SqipCliOptionDefinition[] = [ name: 'input', alias: 'i', type: String, - required: true + description: + 'Input file path. Can also be provided via stdin: command | sqip' }, { name: 'output', @@ -56,8 +58,8 @@ const defaultOptionList: SqipCliOptionDefinition[] = [ { name: 'silent', type: Boolean, - defaultValue: false, - description: 'Supress all output' + description: + 'Supress all output. Defaults to true when reading from stdin.' }, { name: 'parseable-output', @@ -69,8 +71,8 @@ const defaultOptionList: SqipCliOptionDefinition[] = [ { name: 'print', type: Boolean, - defaultValue: false, - description: 'Print resulting svg to stdout.' + description: + 'Print resulting svg to stdout. Defaults to true when reading from stdin.' } ] @@ -79,7 +81,7 @@ function showHelp({ optionList }: { optionList: SqipCliOptionDefinition[] }) { { header: 'sqip CLI', content: - 'Usage: sqip --input [path]\n\n"SQIP" (pronounced \\skwɪb\\ like the non-magical folk of magical descent) is a SVG-based LQIP technique - https://github.com/technopagan/sqip' + 'Usage: sqip --input [path]\n command | sqip\n\n"SQIP" (pronounced \\skwɪb\\ like the non-magical folk of magical descent) is a SVG-based LQIP technique - https://github.com/technopagan/sqip' }, { header: 'Options', @@ -91,13 +93,18 @@ function showHelp({ optionList }: { optionList: SqipCliOptionDefinition[] }) { $ sqip --input /path/to/input.jpg Save input.jpg as result.svg with 25 shapes and no blur -$ sqip -i input.jpg -n 25 -b 0 -o result.svg` +$ sqip -i input.jpg -n 25 -b 0 -o result.svg + +Process an image from stdin +$ curl -s https://example.com/image.jpg | sqip -p pixels blur svgo` } ] const usage = commandLineUsage(sections) console.log(usage) } + + export default async function sqipCLI(): Promise { const pluginDetectionArgs = commandLineArgs(defaultOptionList, { partial: true @@ -162,21 +169,35 @@ export default async function sqipCLI(): Promise { return process.exit(0) } - const missing = optionList - .filter(({ required }) => required) - .filter(({ name }) => !args[name]) - .map(({ name }) => name) + // Detect piped stdin when no --input flag is provided. + // Use fstatSync to check if fd 0 is a pipe (FIFO) — this reliably detects + // shell pipes (e.g., cat img | sqip) without hanging in non-TTY environments. + if (!args.input) { + const stdinIsPiped = (() => { + try { return fstatSync(0).isFIFO() } catch { return false } + })() + + if (stdinIsPiped) { + const chunks = await process.stdin.toArray() + const stdinBuffer = Buffer.concat(chunks) + + if (stdinBuffer.length > 0) { + args.input = stdinBuffer + } + } + } - if (missing.length) { + if (!args.input) { showHelp({ optionList }) console.error( - `\nPlease provide the following arguments: ${missing.join(', ')}` + `\nPlease provide the following arguments: input` ) return process.exit(1) } const { input, output, width } = args - const { name } = path.parse(input) + const fromStdin = Buffer.isBuffer(input) + const name = fromStdin ? 'stdin' : path.parse(input).name const guessedOutput = path.resolve(process.cwd(), `${name}.svg`) // Build list of plugins with options based on passed arguments @@ -192,12 +213,13 @@ export default async function sqipCLI(): Promise { const options: SqipOptions = { input, - output: output || guessedOutput, + outputFileName: fromStdin ? 'stdin' : undefined, + output: fromStdin ? output : output || guessedOutput, width, plugins: pluginsOptions, - silent: args.silent, + silent: fromStdin ? args.silent ?? true : args.silent || false, parseableOutput: args['parseable-output'], - print: args.print || false + print: fromStdin ? args.print ?? true : args.print || false } debug(`Final sqip options:`, options) diff --git a/packages/sqip/__tests__/unit/__snapshots__/sqip.test.ts.snap b/packages/sqip/__tests__/unit/__snapshots__/sqip.test.ts.snap index 7ee8fe85..4b7a1134 100644 --- a/packages/sqip/__tests__/unit/__snapshots__/sqip.test.ts.snap +++ b/packages/sqip/__tests__/unit/__snapshots__/sqip.test.ts.snap @@ -7,9 +7,9 @@ exports[`node api > accepts buffers as input 1`] = ` "backgroundColor": "#454449", "dataURI": "data:image/svg+xml,dataURI", "dataURIBase64": "data:image/svg+xml;base64,dataURIBase64==", - "filename": "-", + "filename": "buffer-test.svg", "height": 188, - "mimeType": "unknown", + "mimeType": "image/jpeg", "originalHeight": 640, "originalWidth": 1024, "palette": { diff --git a/packages/sqip/src/sqip.ts b/packages/sqip/src/sqip.ts index 0d5447b2..4df4f202 100644 --- a/packages/sqip/src/sqip.ts +++ b/packages/sqip/src/sqip.ts @@ -300,6 +300,11 @@ async function processFile({ } } + // Print to stdout even when silent (for piping) + if (silent && print && metadata.type === 'svg') { + process.stdout.write(result.content.toString()) + } + return result } @@ -333,8 +338,20 @@ async function processImage({ const backgroundColor = await findBackgroundColor(buffer) - const { name: filename } = path.parse(filePath) - const mimeType = mime.getType(filePath) || 'unknown' + let filename: string + let mimeType: string + + if (filePath === '-') { + // Input from stdin — detect format from buffer contents + const sharpMeta = await sharp(buffer).metadata() + filename = config.outputFileName || 'stdin' + mimeType = sharpMeta.format + ? mime.getType(sharpMeta.format) || `image/${sharpMeta.format}` + : 'unknown' + } else { + filename = path.parse(filePath).name + mimeType = mime.getType(filePath) || 'unknown' + } const metadata: SqipImageMetadata = { filename,