From efcf99522780bf110e1ac0e9ed68bee19292f328 Mon Sep 17 00:00:00 2001 From: dangreen Date: Tue, 1 Sep 2026 17:44:22 +0400 Subject: [PATCH] feat(cli): add `--placeholder` and `--select-*` flags Everything a value can express is an argument now, so a run needs no config file: `--placeholder`, `--placeholder-width` and `--placeholder-format` shape the placeholder export, and `--select-id`, `--select-format` and `--select-width` pick the variant the default export points at. A width or a format switches the placeholder on by itself, and `--no-placeholder` turns off one enabled in the config. The config file stays necessary for what an argument cannot carry: more than one rule, and the options that are functions or nested objects. --- packages/cli/src/args.spec.ts | 26 ++++++++ packages/cli/src/args.ts | 30 +++++++++ packages/cli/src/options.spec.ts | 105 +++++++++++++++++++++++++++++++ packages/cli/src/options.ts | 76 ++++++++++++++++++++++ 4 files changed, 237 insertions(+) diff --git a/packages/cli/src/args.spec.ts b/packages/cli/src/args.spec.ts index e05ff25..bc91997 100644 --- a/packages/cli/src/args.spec.ts +++ b/packages/cli/src/args.spec.ts @@ -87,6 +87,32 @@ describe('cli', () => { expect(parseCliArgs().help).toBe(true) }) + it('should read the placeholder and select options', () => { + setArgs( + '--placeholder-width', + '24', + '--placeholder-format', + 'jpg', + '--select-format', + 'webp', + '--select-width', + '640' + ) + + const args = parseCliArgs() + + expect(args.placeholderWidth).toBe(24) + expect(args.placeholderFormat).toBe('jpg') + expect(args.selectFormat).toBe('webp') + expect(args.selectWidth).toBe(640) + }) + + it('should read the negated placeholder flag', () => { + setArgs('--no-placeholder') + + expect(parseCliArgs().placeholder).toBe(false) + }) + it('should read the module format without validating it', () => { setArgs('--module', 'typescript') diff --git a/packages/cli/src/args.ts b/packages/cli/src/args.ts index 62304c9..dfb3174 100644 --- a/packages/cli/src/args.ts +++ b/packages/cli/src/args.ts @@ -23,6 +23,12 @@ export const usage = `srcset [...sources] [...options] --no-scaling-up Do not generate images larger than the source. --dest, -d Destination directory. --module Generate an image module: ts, js, ts-dir or js-dir. + --placeholder Add the \`placeholder\` module export - a tiny variant inlined as a data-url. + --placeholder-width Placeholder width in pixels. Implies --placeholder. + --placeholder-format Placeholder format: webp or jpg. Implies --placeholder. + --select-id Module default export: the variant with this resource id. + --select-format Module default export: the variant of this format. + --select-width Module default export: the variant of this width, value less than or equal to 1 is treated as a multiplier. --config, -c Config file path. Defaults to the \`srcset.config.js\` lookup. --concurrency Concurrency limit. ` @@ -36,6 +42,12 @@ export interface CliArgs { scalingUp: boolean | undefined dest: string | undefined module: string | undefined + placeholder: boolean | undefined + placeholderWidth: number | undefined + placeholderFormat: string | undefined + selectId: string | undefined + selectFormat: string | undefined + selectWidth: number | undefined config: string | undefined concurrency: number | undefined } @@ -55,6 +67,12 @@ export function parseCliArgs(): CliArgs { scalingUp, dest, module: moduleFormat, + placeholder, + placeholderWidth, + placeholderFormat, + selectId, + selectFormat, + selectWidth, config, concurrency } = readOptions( @@ -67,6 +85,12 @@ export function parseCliArgs(): CliArgs { flag(autocase('scalingUp')), option(alias('dest', 'd'), String), option('module', String), + flag('placeholder'), + option(autocase('placeholderWidth'), Number), + option(autocase('placeholderFormat'), String), + option(autocase('selectId'), String), + option(autocase('selectFormat'), String), + option(autocase('selectWidth'), Number), option(alias('config', 'c'), String), option('concurrency', Number) ) @@ -99,6 +123,12 @@ export function parseCliArgs(): CliArgs { scalingUp, dest, module: moduleFormat, + placeholder, + placeholderWidth, + placeholderFormat, + selectId, + selectFormat, + selectWidth, config, concurrency } diff --git a/packages/cli/src/options.spec.ts b/packages/cli/src/options.spec.ts index c6a59fa..1d566db 100644 --- a/packages/cli/src/options.spec.ts +++ b/packages/cli/src/options.spec.ts @@ -18,6 +18,12 @@ const noArgs: CliArgs = { scalingUp: undefined, dest: undefined, module: undefined, + placeholder: undefined, + placeholderWidth: undefined, + placeholderFormat: undefined, + selectId: undefined, + selectFormat: undefined, + selectWidth: undefined, config: undefined, concurrency: undefined } @@ -92,6 +98,105 @@ describe('cli', () => { })).toThrow('Unknown module format: "typescript"') }) + it('should switch the placeholder on', () => { + expect(toCliOptions({ + ...noArgs, + placeholder: true + }, { + src: 'images/*.jpg', + dest: 'dist' + }).placeholder).toBe(true) + }) + + it('should read the placeholder options', () => { + expect(toCliOptions({ + ...noArgs, + placeholderWidth: 24, + placeholderFormat: 'jpg' + }, { + src: 'images/*.jpg', + dest: 'dist' + }).placeholder).toEqual({ + width: 24, + format: 'jpg' + }) + }) + + it('should let the placeholder options switch the placeholder on', () => { + expect(toCliOptions({ + ...noArgs, + placeholderWidth: 24 + }, { + src: 'images/*.jpg', + dest: 'dist' + }).placeholder).toEqual({ + width: 24 + }) + }) + + it('should keep the negated placeholder off', () => { + expect(toCliOptions({ + ...noArgs, + placeholder: false, + placeholderWidth: 24 + }, { + src: 'images/*.jpg', + dest: 'dist', + placeholder: true + }).placeholder).toBe(false) + }) + + it('should reject an unknown placeholder format', () => { + expect(() => toCliOptions({ + ...noArgs, + placeholderFormat: 'png' + }, { + src: 'images/*.jpg', + dest: 'dist' + })).toThrow('Unknown placeholder format: "png"') + }) + + it('should fall back to the placeholder of the config', () => { + expect(toCliOptions(noArgs, { + src: 'images/*.jpg', + dest: 'dist', + placeholder: { + width: 8 + } + }).placeholder).toEqual({ + width: 8 + }) + }) + + it('should read the selection of the default export', () => { + expect(toCliOptions({ + ...noArgs, + selectFormat: 'webp', + selectWidth: 640 + }, { + src: 'images/*.jpg', + dest: 'dist' + }).select).toEqual({ + format: 'webp', + width: 640 + }) + }) + + it('should replace the selection of the config', () => { + expect(toCliOptions({ + ...noArgs, + selectId: 'jpg640' + }, { + src: 'images/*.jpg', + dest: 'dist', + select: { + format: 'avif' + } + }).select).toEqual({ + id: 'jpg640' + }) + }) + it('should throw without sources', () => { expect(() => toCliOptions(noArgs, { dest: 'dist' diff --git a/packages/cli/src/options.ts b/packages/cli/src/options.ts index 0eaf165..4d73f2a 100644 --- a/packages/cli/src/options.ts +++ b/packages/cli/src/options.ts @@ -1,3 +1,7 @@ +import type { + PlaceholderOptions, + SrcSetEntrySelect +} from '@srcset/bundler-utils' import type { CliArgs } from './args.ts' import type { SrcSetCliOptions, @@ -5,6 +9,7 @@ import type { } from './types.ts' const moduleFormats = new Set(['ts', 'js', 'ts-dir', 'js-dir']) +const placeholderFormats = new Set(['webp', 'jpg']) /** * Read the module format option: the cli argument and the config file @@ -24,6 +29,73 @@ export function toModuleFormat(value: string | undefined) { return value as SrcSetModuleFormat } +/** + * Read the placeholder options: a width or a format switches the placeholder + * on by itself, there is nothing else they could mean. + * @param args - Parsed command line arguments. + * @returns Placeholder options, or `undefined` when no option is set. + */ +function toPlaceholder(args: CliArgs) { + const { + placeholder, + placeholderWidth: width, + placeholderFormat + } = args + + if (placeholderFormat !== undefined && !placeholderFormats.has(placeholderFormat as PlaceholderOptions['format'])) { + throw new Error(`Unknown placeholder format: "${placeholderFormat}". Use webp or jpg.`) + } + + const format = placeholderFormat as PlaceholderOptions['format'] + + if (width === undefined && format === undefined) { + return placeholder + } + + // An explicit `--no-placeholder` wins over the options of the same run. + return placeholder === false + ? false + : { + ...width !== undefined && { + width + }, + ...format !== undefined && { + format + } + } +} + +/** + * Read the selection of the variant for the default export of the module. + * @param args - Parsed command line arguments. + * @returns Selection, or `undefined` when no option is set. + */ +function toSelect(args: CliArgs) { + const { + selectId: id, + selectFormat: format, + selectWidth: width + } = args + + if (id === undefined && format === undefined && width === undefined) { + return undefined + } + + const select: SrcSetEntrySelect = { + ...id !== undefined && { + id + }, + ...format !== undefined && { + format + }, + ...width !== undefined && { + width + } + } + + return select +} + /** * Merge the command line arguments into the config file options: * an argument wins, an option it does not carry falls back to the config. @@ -32,12 +104,16 @@ export function toModuleFormat(value: string | undefined) { * @returns Options of the run. */ export function toCliOptions(args: CliArgs, config: Partial): SrcSetCliOptions { + const placeholder = toPlaceholder(args) + const select = toSelect(args) const options: SrcSetCliOptions = { ...config, src: args.sources.length ? args.sources : config.src ?? [], dest: args.dest ?? config.dest ?? '', rules: args.rule ? [args.rule] : config.rules, module: toModuleFormat(args.module ?? config.module), + placeholder: placeholder ?? config.placeholder, + select: select ?? config.select, verbose: args.verbose ?? config.verbose, skipOptimization: args.skipOptimization ?? config.skipOptimization, scalingUp: args.scalingUp ?? config.scalingUp,