Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand Down Expand Up @@ -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
"
`;
37 changes: 36 additions & 1 deletion packages/sqip-cli/__tests__/e2e/sqip-cli-e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
})
Expand Down Expand Up @@ -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('<svg')
expect(stdout).toContain('</svg>')
})

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)
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
{
Expand Down
102 changes: 102 additions & 0 deletions packages/sqip-cli/__tests__/unit/sqip-cli.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof import('fs')>()
return { ...actual, fstatSync: vi.fn(actual.fstatSync) }
})

const mockedFstatSync = fstatSync as MockedFunction<typeof fstatSync>

vi.mock('sqip', () => ({
sqip: vi.fn(async () => []),
resolvePlugins: vi.fn(() => [
Expand Down Expand Up @@ -43,6 +51,7 @@ describe('sqip-plugin-cli', () => {
proccessExitSpy.mockClear()
mockedSqip.mockClear()
mockedResolvePlugins.mockClear()
mockedFstatSync.mockRestore()
process.argv = originalArgv
})

Expand Down Expand Up @@ -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<typeof fstatSync>)
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)
})
})
})
56 changes: 39 additions & 17 deletions packages/sqip-cli/src/sqip-cli.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { fstatSync } from 'fs'
import path from 'path'
import { fileURLToPath } from 'url'

Expand Down Expand Up @@ -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',
Expand All @@ -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',
Expand All @@ -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.'
}
]

Expand All @@ -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',
Expand All @@ -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<undefined> {
const pluginDetectionArgs = commandLineArgs(defaultOptionList, {
partial: true
Expand Down Expand Up @@ -162,21 +169,35 @@ export default async function sqipCLI(): Promise<undefined> {
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) {
Copy link
Owner Author

@axe312ger axe312ger Mar 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SQIP actually only used required for the input parameter, all the others have sane defaults.

So dropping the missing others check is totally fine here!

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
Expand All @@ -192,12 +213,13 @@ export default async function sqipCLI(): Promise<undefined> {

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)
Expand Down
4 changes: 2 additions & 2 deletions packages/sqip/__tests__/unit/__snapshots__/sqip.test.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
21 changes: 19 additions & 2 deletions packages/sqip/src/sqip.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down Expand Up @@ -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,
Expand Down