From 1fd0ad335292c4b453d6b7b6827799a0765f29f3 Mon Sep 17 00:00:00 2001 From: dangreen Date: Tue, 1 Sep 2026 15:15:01 +0400 Subject: [PATCH] feat(cli,bundler-utils): bake image modules with the `--module` flag Generate an ES module next to the optimized variants, so a project can commit the result and import the images without a bundler integration. `ts` and `js` put the module next to the variants under the source name, `ts-dir` and `js-dir` put both into a folder named after the image. A typescript module narrows the variant formats with `as const`, so its entries stay assignable to `SrcSetEntry`. --- packages/bundler-utils/src/generate.ts | 13 +- packages/bundler-utils/src/generate.types.ts | 13 ++ packages/bundler-utils/src/module.spec.ts | 84 ++++--- packages/bundler-utils/src/module.ts | 65 ++++-- packages/bundler-utils/src/types.ts | 5 + packages/cli/package.json | 1 + packages/cli/src/args.spec.ts | 6 + packages/cli/src/args.ts | 5 + packages/cli/src/cli.ts | 32 +-- packages/cli/src/module.spec.ts | 65 ++++++ packages/cli/src/module.ts | 50 ++++ packages/cli/src/options.spec.ts | 108 +++++++++ packages/cli/src/options.ts | 56 +++++ packages/cli/src/run.spec.ts | 228 +++++++++++++++++++ packages/cli/src/run.ts | 158 +++++++++++-- packages/cli/src/types.ts | 29 ++- pnpm-lock.yaml | 3 + 17 files changed, 806 insertions(+), 115 deletions(-) create mode 100644 packages/cli/src/module.spec.ts create mode 100644 packages/cli/src/module.ts create mode 100644 packages/cli/src/options.spec.ts create mode 100644 packages/cli/src/options.ts diff --git a/packages/bundler-utils/src/generate.ts b/packages/bundler-utils/src/generate.ts index 5a834fe..24955de 100644 --- a/packages/bundler-utils/src/generate.ts +++ b/packages/bundler-utils/src/generate.ts @@ -7,7 +7,7 @@ import { } from '@srcset/core' import type { QueryOptions } from './query.ts' import type { - SrcSetModuleOptions, + SrcSetModuleGenerateOptions, EmitImage } from './generate.types.ts' import { @@ -25,7 +25,7 @@ export type * from './generate.types.ts' * on the bundler side and make the module code. * @param source - Image file. * @param query - Parsed import query options. - * @param options - Bundler integration options. + * @param options - Options of the module generation. * @param emitImage - Emits an image on the bundler side. * @param limit - Concurrency limit of the integration. * @returns Module code. @@ -33,7 +33,7 @@ export type * from './generate.types.ts' export async function generateSrcSetModule( source: ImageSource, query: QueryOptions, - options: SrcSetModuleOptions, + options: SrcSetModuleGenerateOptions, emitImage: EmitImage, limit?: LimitFunction ) { @@ -86,5 +86,10 @@ export async function generateSrcSetModule( }) } - return createModuleString(select, srcSet, placeholder) + return createModuleString({ + select, + srcSet, + placeholder, + typescript: options.typescript + }) } diff --git a/packages/bundler-utils/src/generate.types.ts b/packages/bundler-utils/src/generate.types.ts index c4c568b..f448c6b 100644 --- a/packages/bundler-utils/src/generate.types.ts +++ b/packages/bundler-utils/src/generate.types.ts @@ -33,6 +33,19 @@ export interface SrcSetModuleOptions extends Omit { describe('module', () => { describe('createModuleString', () => { it('should select default variant by format and width', () => { - const module = createModuleString( - { + const module = createModuleString({ + select: { format: 'webp', width: 320 }, - [createEntry('jpg', 320), createEntry('webp', 320), createEntry('webp', 640)] - ) + srcSet: [createEntry('jpg', 320), createEntry('webp', 320), createEntry('webp', 640)] + }) expect(module).toContain('const url = (__webpack_public_path__) + "image@320w.webp";') }) it('should select default variant by multiplier', () => { - const module = createModuleString( - { + const module = createModuleString({ + select: { format: 'jpg', width: 0.5 }, - [createEntry('jpg', 640, 1), createEntry('jpg', 320, 0.5)] - ) + srcSet: [createEntry('jpg', 640, 1), createEntry('jpg', 320, 0.5)] + }) expect(module).toContain('const url = (__webpack_public_path__) + "image@320w.jpg";') }) it('should select default variant by id', () => { - const module = createModuleString( - { + const module = createModuleString({ + select: { id: 'webp640' }, - [createEntry('jpg', 320), createEntry('webp', 640)] - ) + srcSet: [createEntry('jpg', 320), createEntry('webp', 640)] + }) expect(module).toContain('const url = (__webpack_public_path__) + "image@640w.webp";') }) it('should fall back to first variant', () => { - const module = createModuleString( - { + const module = createModuleString({ + select: { format: 'avif', width: 5000 }, - [createEntry('jpg', 320), createEntry('webp', 640)] - ) + srcSet: [createEntry('jpg', 320), createEntry('webp', 640)] + }) expect(module).toContain('const url = (__webpack_public_path__) + "image@320w.jpg";') }) it('should create empty module without variants', () => { - const module = createModuleString( - { + const module = createModuleString({ + select: { format: 'jpg', width: 640 }, - [] - ) + srcSet: [] + }) expect(module).toContain("const url = '';") expect(module).toContain('const src = null;') @@ -93,13 +93,13 @@ describe('bundler-utils', () => { }) it('should reuse url and src references for default variant', () => { - const module = createModuleString( - { + const module = createModuleString({ + select: { format: 'jpg', width: 320 }, - [createEntry('jpg', 320), createEntry('webp', 320)] - ) + srcSet: [createEntry('jpg', 320), createEntry('webp', 320)] + }) expect(module).toContain('url: url') expect(module).toContain('export const srcSet = [src, {') @@ -107,38 +107,48 @@ describe('bundler-utils', () => { }) it('should emit placeholder export', () => { - const module = createModuleString( - { + const module = createModuleString({ + select: { format: 'jpg', width: 320 }, - [createEntry('jpg', 320)], - 'data:image/webp;base64,abc' - ) + srcSet: [createEntry('jpg', 320)], + placeholder: 'data:image/webp;base64,abc' + }) expect(module).toContain('export const placeholder = "data:image/webp;base64,abc";') }) + it('should narrow the variant format for a typescript module', () => { + const module = createModuleString({ + select: {}, + srcSet: [createEntry('jpg', 320)], + typescript: true + }) + + expect(module).toContain('format: "jpg" as const,') + }) + it('should emit undefined placeholder without data-url', () => { - const module = createModuleString( - { + const module = createModuleString({ + select: { format: 'jpg', width: 320 }, - [createEntry('jpg', 320)] - ) + srcSet: [createEntry('jpg', 320)] + }) expect(module).toContain('export const placeholder = undefined;') }) it('should map ids to urls', () => { - const module = createModuleString( - { + const module = createModuleString({ + select: { format: 'jpg', width: 320 }, - [createEntry('jpg', 320), createEntry('webp', 640)] - ) + srcSet: [createEntry('jpg', 320), createEntry('webp', 640)] + }) expect(module).toContain('"webp640": (__webpack_public_path__) + "image@640w.webp"') }) diff --git a/packages/bundler-utils/src/module.ts b/packages/bundler-utils/src/module.ts index 47f5cf1..90a9ebd 100644 --- a/packages/bundler-utils/src/module.ts +++ b/packages/bundler-utils/src/module.ts @@ -26,6 +26,10 @@ const emptyUrlExpression = "''" * @returns JS expression string. */ function toUrlExpression(url: SrcSetImagePaths) { + if (url.urlExpression) { + return url.urlExpression + } + if (url.publicPath !== null) { return JSON.stringify(url.publicPath) } @@ -68,16 +72,24 @@ function findDefaultIndex(select: SrcSetEntrySelect, srcSet: SrcSetModuleEntry[] return index } -function createEntryString({ - id, - format, - type, - width, - height -}: SrcSetModuleEntry, urlString: string) { +function createEntryString( + { + id, + format, + type, + width, + height + }: SrcSetModuleEntry, + urlString: string, + typescript: boolean +) { + // Without the assertion the format of a typescript module widens to `string`, + // and the entry stops being assignable to `SrcSetEntry`. + const formatString = typescript ? `${JSON.stringify(format)} as const` : JSON.stringify(format) + return `{ id: ${JSON.stringify(id)}, - format: ${JSON.stringify(format)}, + format: ${formatString}, type: ${JSON.stringify(type)}, width: ${String(width)}, height: ${String(height)}, @@ -85,14 +97,41 @@ function createEntryString({ }` } +/** + * Options of the module code generation. + */ +export interface ModuleStringOptions { + /** + * Selection of the image variant for the default export. + */ + select: SrcSetEntrySelect + /** + * Generated image variant entries. + */ + srcSet: SrcSetModuleEntry[] + /** + * Data-url of the placeholder variant, falsy to emit `undefined`. + */ + placeholder?: string | false + /** + * Generate typescript: the variant formats are narrowed with `as const`, + * so the entries stay assignable to `SrcSetEntry`. + */ + typescript?: boolean +} + /** * Create ES module code for the image import. - * @param select - Selection of the image variant for the default export. - * @param srcSet - Generated image variant entries. - * @param placeholder - Data-url of the placeholder variant, falsy to emit `undefined`. + * @param options - Options of the generation. * @returns Module code. */ -export function createModuleString(select: SrcSetEntrySelect, srcSet: SrcSetModuleEntry[], placeholder?: string | false) { +export function createModuleString(options: ModuleStringOptions) { + const { + select, + srcSet, + placeholder, + typescript = false + } = options const defaultIndex = findDefaultIndex(select, srcSet) const urlExpressions = srcSet.map(entry => toUrlExpression(entry.url)) const urlExpression = defaultIndex < 0 ? emptyUrlExpression : urlExpressions[defaultIndex] @@ -103,7 +142,7 @@ export function createModuleString(select: SrcSetEntrySelect, srcSet: SrcSetModu srcSet.forEach((entry, index) => { const isDefault = index === defaultIndex const urlString = isDefault ? 'url' : urlExpressions[index] - const entryString = createEntryString(entry, urlString) + const entryString = createEntryString(entry, urlString, typescript) if (isDefault) { srcString = entryString diff --git a/packages/bundler-utils/src/types.ts b/packages/bundler-utils/src/types.ts index d4b2867..60758dc 100644 --- a/packages/bundler-utils/src/types.ts +++ b/packages/bundler-utils/src/types.ts @@ -29,4 +29,9 @@ export interface SrcSetImagePaths { * e.g. `__webpack_public_path__` of webpack. */ publicPathExpression?: string + /** + * JS expression of the whole url, when it is not a path at all, + * e.g. an identifier the generated module imports the image with. + */ + urlExpression?: string } diff --git a/packages/cli/package.json b/packages/cli/package.json index 8808cc6..e270b8a 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -55,6 +55,7 @@ "test": "run -p lint test:unit test:types" }, "dependencies": { + "@srcset/bundler-utils": "workspace:^", "@srcset/core": "workspace:^", "argue-cli": "^3.1.0", "tinyglobby": "^0.2.10" diff --git a/packages/cli/src/args.spec.ts b/packages/cli/src/args.spec.ts index e226164..e732457 100644 --- a/packages/cli/src/args.spec.ts +++ b/packages/cli/src/args.spec.ts @@ -70,6 +70,12 @@ describe('cli', () => { expect(parseCliArgs().help).toBe(true) }) + + it('should read the module format without validating it', () => { + setArgs('--module', 'typescript') + + expect(parseCliArgs().module).toBe('typescript') + }) }) }) }) diff --git a/packages/cli/src/args.ts b/packages/cli/src/args.ts index 13c2d65..88427b5 100644 --- a/packages/cli/src/args.ts +++ b/packages/cli/src/args.ts @@ -22,6 +22,7 @@ export const usage = `srcset [...sources] [...options] --skip-optimization Do not optimize output images. --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. --config, -c Config file path. Defaults to the \`srcset.config.js\` lookup. --concurrency Concurrency limit. ` @@ -34,6 +35,7 @@ export interface CliArgs { skipOptimization: boolean | undefined scalingUp: boolean | undefined dest: string | undefined + module: string | undefined config: string | undefined concurrency: number | undefined } @@ -52,6 +54,7 @@ export function parseCliArgs(): CliArgs { skipOptimization, scalingUp, dest, + module: moduleFormat, config, concurrency } = readOptions( @@ -63,6 +66,7 @@ export function parseCliArgs(): CliArgs { flag(autocase('skipOptimization')), flag(autocase('scalingUp')), option(alias('dest', 'd'), String), + option('module', String), option(alias('config', 'c'), String), option('concurrency', Number) ) @@ -94,6 +98,7 @@ export function parseCliArgs(): CliArgs { skipOptimization, scalingUp, dest, + module: moduleFormat, config, concurrency } diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 04fe76e..66901e6 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -1,10 +1,10 @@ import process from 'node:process' -import type { SrcSetCliOptions } from './types.ts' import { usage, parseCliArgs } from './args.ts' import { loadConfig } from './config.ts' +import { toCliOptions } from './options.ts' import { run } from './run.ts' try { @@ -15,35 +15,7 @@ try { process.exit(0) } - const config = await loadConfig(args.config) - const options: SrcSetCliOptions = { - ...config, - src: args.sources.length ? args.sources : config.src ?? [], - dest: args.dest ?? config.dest ?? '', - rules: args.rule ? [args.rule] : config.rules, - ...args.verbose !== undefined && { - verbose: args.verbose - }, - ...args.skipOptimization !== undefined && { - skipOptimization: args.skipOptimization - }, - ...args.scalingUp !== undefined && { - scalingUp: args.scalingUp - }, - ...args.concurrency !== undefined && { - concurrency: args.concurrency - } - } - - if (!options.src.length) { - throw new Error('No source images: pass glob patterns or set `src` in the config.') - } - - if (!options.dest) { - throw new Error('No destination directory: pass `--dest` or set `dest` in the config.') - } - - await run(options) + await run(toCliOptions(args, await loadConfig(args.config))) } catch (error) { console.error(error instanceof Error ? error.message : error) process.exit(1) diff --git a/packages/cli/src/module.spec.ts b/packages/cli/src/module.spec.ts new file mode 100644 index 0000000..764c6f4 --- /dev/null +++ b/packages/cli/src/module.spec.ts @@ -0,0 +1,65 @@ +import { join } from 'node:path' +import { + describe, + it, + expect +} from 'vitest' +import { + toIdentifier, + withImports +} from './module.ts' + +describe('cli', () => { + describe('module', () => { + describe('toIdentifier', () => { + it('should keep the extension as a part of the identifier', () => { + const used = new Set() + + expect(toIdentifier('photo.jpg', used)).toBe('photo_jpg') + expect(toIdentifier('photo.webp', used)).toBe('photo_webp') + }) + + it('should take the file name of a path', () => { + expect(toIdentifier(join('images', 'photo.jpg'), new Set())).toBe('photo_jpg') + }) + + it('should replace the characters an identifier cannot hold', () => { + expect(toIdentifier('photo@320w.jpg', new Set())).toBe('photo_320w_jpg') + expect(toIdentifier("it's.jpg", new Set())).toBe('it_s_jpg') + }) + + it('should prefix an identifier starting with a digit', () => { + expect(toIdentifier('1.jpg', new Set())).toBe('_1_jpg') + }) + + it('should suffix the identifiers taken by other variants', () => { + const used = new Set() + + expect(toIdentifier('photo@320w.jpg', used)).toBe('photo_320w_jpg') + expect(toIdentifier('photo_320w.jpg', used)).toBe('photo_320w_jpg_1') + expect(toIdentifier('photo-320w.jpg', used)).toBe('photo_320w_jpg_2') + }) + }) + + describe('withImports', () => { + it('should prepend the imports of the variants', () => { + expect(withImports([ + ['photo_jpg', 'photo.jpg'], + ['photo_webp', 'photo.webp'] + ], 'export default photo_jpg;\n')).toBe( + 'import photo_jpg from "./photo.jpg"\nimport photo_webp from "./photo.webp"\n\nexport default photo_jpg;\n' + ) + }) + + it('should escape a file name the specifier cannot hold', () => { + expect(withImports([['it_s_jpg', "it's.jpg"]], '')).toContain( + 'import it_s_jpg from "./it\'s.jpg"' + ) + }) + + it('should keep the module as is without imports', () => { + expect(withImports([], 'export default null;\n')).toBe('export default null;\n') + }) + }) + }) +}) diff --git a/packages/cli/src/module.ts b/packages/cli/src/module.ts new file mode 100644 index 0000000..cfa28e4 --- /dev/null +++ b/packages/cli/src/module.ts @@ -0,0 +1,50 @@ +import { basename } from 'node:path' + +const identifierStart = /^[^a-z_$]/i +const identifierUnsafe = /[^\w$]/g + +/** + * Make a javascript identifier to import a variant file with. Derived from + * the file name rather than from the resource id: an id is neither unique + * across variants nor guaranteed to be a valid identifier. + * @param path - Variant file path. + * @param used - Identifiers already taken by this module. + * @returns Unique identifier. + */ +export function toIdentifier(path: string, used: Set) { + // The extension is a part of the name: `felix.jpg` and `felix.webp` + // are two imports of one module. + const name = basename(path).replace(identifierUnsafe, '_') + const base = identifierStart.test(name) ? `_${name}` : name + let identifier = base + let index = 1 + + while (used.has(identifier)) { + identifier = `${base}_${index++}` + } + + used.add(identifier) + + return identifier +} + +/** + * Make the module code: the variant urls come from imports of the files + * next to it, so the bundler of the project resolves them as plain assets. + * @param imports - Identifier and file name of every imported variant. + * @param body - Module code from the generator. + * @returns Module code with the imports. + */ +export function withImports(imports: [string, string][], body: string) { + if (!imports.length) { + return body + } + + // A file name is not a safe string literal: quotes and line breaks + // in it are legal on every platform we run on. + const lines = imports.map( + ([identifier, name]) => `import ${identifier} from ${JSON.stringify(`./${name}`)}` + ) + + return `${lines.join('\n')}\n\n${body}` +} diff --git a/packages/cli/src/options.spec.ts b/packages/cli/src/options.spec.ts new file mode 100644 index 0000000..c6a59fa --- /dev/null +++ b/packages/cli/src/options.spec.ts @@ -0,0 +1,108 @@ +import { + describe, + it, + expect +} from 'vitest' +import type { CliArgs } from './args.ts' +import { + toModuleFormat, + toCliOptions +} from './options.ts' + +const noArgs: CliArgs = { + help: false, + verbose: undefined, + sources: [], + rule: null, + skipOptimization: undefined, + scalingUp: undefined, + dest: undefined, + module: undefined, + config: undefined, + concurrency: undefined +} + +describe('cli', () => { + describe('options', () => { + describe('toModuleFormat', () => { + it('should pass a known format through', () => { + expect(toModuleFormat('js-dir')).toBe('js-dir') + }) + + it('should allow no format', () => { + expect(toModuleFormat(undefined)).toBeUndefined() + }) + + it('should reject an unknown format', () => { + expect(() => toModuleFormat('typescript')).toThrow('Unknown module format: "typescript"') + }) + }) + + describe('toCliOptions', () => { + it('should prefer the arguments over the config', () => { + const options = toCliOptions({ + ...noArgs, + sources: ['images/*.png'], + dest: 'build', + module: 'js', + verbose: true + }, { + src: 'images/*.jpg', + dest: 'dist', + module: 'ts-dir', + verbose: false + }) + + expect(options.src).toEqual(['images/*.png']) + expect(options.dest).toBe('build') + expect(options.module).toBe('js') + expect(options.verbose).toBe(true) + }) + + it('should fall back to the config', () => { + const options = toCliOptions(noArgs, { + src: 'images/*.jpg', + dest: 'dist', + module: 'ts-dir', + concurrency: 2 + }) + + expect(options.src).toBe('images/*.jpg') + expect(options.dest).toBe('dist') + expect(options.module).toBe('ts-dir') + expect(options.concurrency).toBe(2) + }) + + it('should keep a negated flag of the arguments', () => { + expect(toCliOptions({ + ...noArgs, + scalingUp: false + }, { + src: 'images/*.jpg', + dest: 'dist', + scalingUp: true + }).scalingUp).toBe(false) + }) + + it('should validate the module format of the config', () => { + expect(() => toCliOptions(noArgs, { + src: 'images/*.jpg', + dest: 'dist', + module: 'typescript' as never + })).toThrow('Unknown module format: "typescript"') + }) + + it('should throw without sources', () => { + expect(() => toCliOptions(noArgs, { + dest: 'dist' + })).toThrow('No source images') + }) + + it('should throw without a destination', () => { + expect(() => toCliOptions(noArgs, { + src: 'images/*.jpg' + })).toThrow('No destination directory') + }) + }) + }) +}) diff --git a/packages/cli/src/options.ts b/packages/cli/src/options.ts new file mode 100644 index 0000000..0eaf165 --- /dev/null +++ b/packages/cli/src/options.ts @@ -0,0 +1,56 @@ +import type { CliArgs } from './args.ts' +import type { + SrcSetCliOptions, + SrcSetModuleFormat +} from './types.ts' + +const moduleFormats = new Set(['ts', 'js', 'ts-dir', 'js-dir']) + +/** + * Read the module format option: the cli argument and the config file + * value go through the same check. + * @param value - Option value. + * @returns Module format, or `undefined` when the option is not set. + */ +export function toModuleFormat(value: string | undefined) { + if (value === undefined) { + return undefined + } + + if (!moduleFormats.has(value as SrcSetModuleFormat)) { + throw new Error(`Unknown module format: "${value}". Use ts, js, ts-dir or js-dir.`) + } + + return value as SrcSetModuleFormat +} + +/** + * Merge the command line arguments into the config file options: + * an argument wins, an option it does not carry falls back to the config. + * @param args - Parsed command line arguments. + * @param config - Config file options. + * @returns Options of the run. + */ +export function toCliOptions(args: CliArgs, config: Partial): SrcSetCliOptions { + 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), + verbose: args.verbose ?? config.verbose, + skipOptimization: args.skipOptimization ?? config.skipOptimization, + scalingUp: args.scalingUp ?? config.scalingUp, + concurrency: args.concurrency ?? config.concurrency + } + + if (!options.src.length) { + throw new Error('No source images: pass glob patterns or set `src` in the config.') + } + + if (!options.dest) { + throw new Error('No destination directory: pass `--dest` or set `dest` in the config.') + } + + return options +} diff --git a/packages/cli/src/run.spec.ts b/packages/cli/src/run.spec.ts index 6c76340..f6bf0b3 100644 --- a/packages/cli/src/run.spec.ts +++ b/packages/cli/src/run.spec.ts @@ -1,7 +1,9 @@ import { + copyFile, mkdir, mkdtemp, readdir, + readFile, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' @@ -13,6 +15,8 @@ import { expect } from 'vitest' import sharp from 'sharp' +import { SrcSetCacheStorage } from '@srcset/core' +import type { SrcSetCliOptions } from './types.ts' import { run } from './run.ts' async function createProject() { @@ -174,6 +178,230 @@ describe('cli', () => { })).rejects.toThrow('collision') }) + describe('module', () => { + it('should bake the module next to the variants', async () => { + const dir = await createProject() + + await runIn(dir, { + src: 'images/*.jpg', + dest: 'dist', + module: 'ts', + skipOptimization: true, + rules: [ + { + width: [1, 0.5], + format: ['jpg', 'webp'] + } + ] + }) + + expect((await readdir(join(dir, 'dist/images'))).sort()).toEqual([ + 'photo.jpg', + 'photo.ts', + 'photo.webp', + 'photo@320w.jpg', + 'photo@320w.webp' + ]) + }) + + it('should bake a folder named after the image', async () => { + const dir = await createProject() + + await runIn(dir, { + src: 'images/*.jpg', + dest: 'dist', + module: 'js-dir', + skipOptimization: true, + rules: [ + { + width: [1], + format: ['jpg', 'webp'] + } + ] + }) + + expect((await readdir(join(dir, 'dist/images/photo'))).sort()).toEqual([ + 'index.js', + 'photo.jpg', + 'photo.webp' + ]) + }) + + it('should import every variant of the module', async () => { + const dir = await createProject() + + await runIn(dir, { + src: 'images/*.jpg', + dest: 'dist', + module: 'js', + skipOptimization: true, + rules: [ + { + width: [1, 0.5], + format: ['jpg', 'webp'] + } + ] + }) + + const module = await readFile(join(dir, 'dist/images/photo.js'), 'utf8') + + // The extension is a part of the identifier: two formats of one name. + expect(module).toContain('import photo_jpg from "./photo.jpg"') + expect(module).toContain('import photo_320w_jpg from "./photo@320w.jpg"') + expect(module).toContain('import photo_webp from "./photo.webp"') + expect(module).toContain('const url = photo_jpg;') + expect(module).toContain('"webp320": photo_320w_webp') + expect(module).not.toContain('as const') + }) + + it('should narrow the variant formats of a typescript module', async () => { + const dir = await createProject() + + await runIn(dir, { + src: 'images/*.jpg', + dest: 'dist', + module: 'ts', + skipOptimization: true + }) + + const module = await readFile(join(dir, 'dist/images/photo.ts'), 'utf8') + + expect(module).toContain('format: "jpg" as const,') + }) + + it('should inline the placeholder of a baked module', async () => { + const dir = await createProject() + + await runIn(dir, { + src: 'images/*.jpg', + dest: 'dist', + module: 'js', + skipOptimization: true, + placeholder: true + }) + + const module = await readFile(join(dir, 'dist/images/photo.js'), 'utf8') + + expect(module).toMatch(/export const placeholder = "data:image\/webp;base64,[^"]+";/) + }) + + it('should bake a typescript folder module', async () => { + const dir = await createProject() + + await runIn(dir, { + src: 'images/*.jpg', + dest: 'dist', + module: 'ts-dir', + skipOptimization: true, + rules: [ + { + width: [1] + } + ] + }) + + expect((await readdir(join(dir, 'dist/images/photo'))).sort()).toEqual([ + 'index.ts', + 'photo.jpg' + ]) + expect(await readFile(join(dir, 'dist/images/photo/index.ts'), 'utf8')).toContain('format: "jpg" as const,') + }) + + it('should escape a file name the import specifier cannot hold', async () => { + const dir = await createProject() + + await copyFile(join(dir, 'images/photo.jpg'), join(dir, "images/it's.jpg")) + await runIn(dir, { + src: "images/it's.jpg", + dest: 'dist', + module: 'js', + skipOptimization: true, + rules: [ + { + width: [1] + } + ] + }) + + const module = await readFile(join(dir, "dist/images/it's.js"), 'utf8') + + expect(module).toContain('import it_s_jpg from "./it\'s.jpg"') + }) + + it('should skip the module of an image no rule matched', async () => { + const dir = await createProject() + const written = await runIn(dir, { + src: 'images/*.jpg', + dest: 'dist', + module: 'js', + skipOptimization: true, + rules: [ + { + match: '**/*.png', + width: [0.5] + } + ] + }) + + expect(written).toEqual([]) + }) + + it('should throw when two sources want one module path', async () => { + const dir = await createProject() + const contents = await sharp({ + create: { + width: 320, + height: 240, + channels: 3, + background: '#d53a7b' + } + }).png().toBuffer() + + await writeFile(join(dir, 'images/photo.png'), contents) + + await expect(runIn(dir, { + src: 'images/*', + dest: 'dist', + module: 'ts', + skipOptimization: true, + rules: [ + { + width: [1] + } + ] + })).rejects.toThrow('collision') + }) + }) + + it('should ignore a cache storage of the config', async () => { + const dir = await createProject() + const cacheDir = join(dir, 'cache') + + await runIn(dir, { + src: 'images/*.jpg', + dest: 'dist', + skipOptimization: true, + // A config file is javascript: an option cut from the types still arrives. + cache: new SrcSetCacheStorage({ + dir: cacheDir + }) + } as unknown as SrcSetCliOptions) + + await expect(readdir(cacheDir)).rejects.toThrow() + }) + + it('should throw on a source it cannot read', async () => { + const dir = await createProject() + + await writeFile(join(dir, 'images/broken.jpg'), 'not an image') + + await expect(runIn(dir, { + src: 'images/broken.jpg', + dest: 'dist', + skipOptimization: true + })).rejects.toThrow('Cannot read image "images/broken.jpg"') + }) + it('should throw without matched sources', async () => { const dir = await createProject() diff --git a/packages/cli/src/run.ts b/packages/cli/src/run.ts index 7191965..cfb5356 100644 --- a/packages/cli/src/run.ts +++ b/packages/cli/src/run.ts @@ -4,17 +4,30 @@ import { writeFile } from 'node:fs/promises' import { + basename, dirname, + extname, isAbsolute, join, relative, - basename, resolve, sep } from 'node:path' -import { SrcSetGenerator } from '@srcset/core' +import { + type SrcSetImage, + SrcSetGenerator, + getImageMetadata +} from '@srcset/core' +import { generateSrcSetModule } from '@srcset/bundler-utils' import { glob } from 'tinyglobby' -import type { SrcSetCliOptions } from './types.ts' +import type { + SrcSetCliOptions, + SrcSetModuleFormat +} from './types.ts' +import { + toIdentifier, + withImports +} from './module.ts' const outsidePattern = new RegExp(`^\\.\\.(?:\\${sep}|$)`) @@ -24,6 +37,37 @@ function toOutputPath(dest: string, path: string) { return join(dest, outsidePattern.test(relativePath) || isAbsolute(relativePath) ? basename(path) : relativePath) } +/** + * Make the directory of the baked files: the `-dir` formats put an image + * and its module into a folder named after the source. + * @param dest - Destination directory. + * @param sourcePath - Source image file path. + * @param format - Module format. + * @returns Directory to write the image files into. + */ +function toModuleDir(dest: string, sourcePath: string, format: SrcSetModuleFormat) { + const outputPath = toOutputPath(dest, sourcePath) + + return format.endsWith('-dir') + ? join(dirname(outputPath), basename(outputPath, extname(outputPath))) + : dirname(outputPath) +} + +/** + * Make the path of the generated module: next to the variants under the + * source name, or `index` in the folder of the `-dir` formats. + * @param dir - Directory of the baked files. + * @param sourcePath - Source image file path. + * @param format - Module format. + * @returns Module file path. + */ +function toModulePath(dir: string, sourcePath: string, format: SrcSetModuleFormat) { + const extension = format.startsWith('ts') ? '.ts' : '.js' + const name = format.endsWith('-dir') ? 'index' : basename(sourcePath, extname(sourcePath)) + + return join(dir, `${name}${extension}`) +} + /** * Generate image variants for the matched source images * and write them to the destination directory. @@ -34,51 +78,117 @@ export async function run(options: SrcSetCliOptions) { const { src, dest, - rules = [{}], + module: moduleFormat, verbose, - ...generatorOptions + // Both are cut from the cli options, but a config file is javascript: + // dropping them here keeps a stray one out of the generator. + cache, + limit, + ...generateOptions } = options + const { rules = [{}] } = generateOptions const files = await glob(src) if (!files.length) { throw new Error('No source images found.') } - const generator = new SrcSetGenerator(generatorOptions) const written: string[] = [] const outputPaths = new Set() - const processFile = async (file: string) => { + // Sources outside the cwd keep their file name only, so two of them + // can resolve to one output path - losing a file without a word + // is worse than stopping. + const write = async (outputPath: string, contents: Buffer | string, from: string) => { + if (outputPaths.has(outputPath)) { + throw new Error(`Output path collision: "${outputPath}". Run from a directory containing every source, or process them separately.`) + } + + outputPaths.add(outputPath) + await mkdir(dirname(outputPath), { + recursive: true + }) + await writeFile(outputPath, contents) + written.push(outputPath) + + if (verbose) { + console.info(`${from} -> ${outputPath}`) + } + } + const readSource = async (file: string) => { const source = { path: resolve(file), contents: await readFile(file) } + // Rule matching reads an image it cannot decode as a miss, so without + // this the cli would silently skip a source it was pointed at. + try { + await getImageMetadata(source) + } catch (error) { + throw new Error(`Cannot read image "${file}": ${error instanceof Error ? error.message : String(error)}`, { + cause: error + }) + } + + return source + } + const writeVariants = async (file: string, generator: SrcSetGenerator) => { + const source = await readSource(file) + for await (const image of generator.generateAll(source, rules)) { - const outputPath = toOutputPath(dest, image.path) + await write(toOutputPath(dest, image.path), image.contents, file) + } + } + // Baking writes the variants too: the module imports them as plain assets. + const bakeModule = async (file: string, format: SrcSetModuleFormat) => { + const source = await readSource(file) + const identifiers = new Set() + const imports: [string, string][] = [] + const images: SrcSetImage[] = [] + const emitImage = (image: SrcSetImage) => { + const name = basename(image.path) + const identifier = toIdentifier(name, identifiers) + + images.push(image) + imports.push([identifier, name]) - // Sources outside the cwd keep their file name only, so two of them - // can resolve to one output path - losing an image without a word - // is worse than stopping. - if (outputPaths.has(outputPath)) { - throw new Error(`Output path collision: "${outputPath}". Run from a directory containing every source, or process them separately.`) + return { + outputPath: name, + publicPath: null, + urlExpression: identifier } + } + const body = await generateSrcSetModule(source, {}, { + ...generateOptions, + typescript: format.startsWith('ts') + }, emitImage) - outputPaths.add(outputPath) - await mkdir(dirname(outputPath), { - recursive: true - }) - await writeFile(outputPath, image.contents) - written.push(outputPath) + // An image no rule matched has no variants to import: the plain mode + // writes nothing for it, and a module exporting nothing is no better. + if (!images.length) { + return + } - if (verbose) { - console.info(`${file} -> ${outputPath}`) - } + const dir = toModuleDir(dest, source.path, format) + + for (const image of images) { + await write(join(dir, basename(image.path)), image.contents, file) } + + await write(toModulePath(dir, source.path, format), withImports(imports, body), file) } // Sequential on purpose: the variant processing of a single file is parallel already. - for (const file of files) { - await processFile(file) + if (moduleFormat) { + for (const file of files) { + await bakeModule(file, moduleFormat) + } + } else { + const generator = new SrcSetGenerator(generateOptions) + + for (const file of files) { + await writeVariants(file, generator) + } } return written diff --git a/packages/cli/src/types.ts b/packages/cli/src/types.ts index 4f967bb..3f51b19 100644 --- a/packages/cli/src/types.ts +++ b/packages/cli/src/types.ts @@ -1,9 +1,12 @@ -import type { - SrcSetRule, - SrcSetGeneratorOptions -} from '@srcset/core' +import type { SrcSetModuleOptions } from '@srcset/bundler-utils' -export interface SrcSetCliOptions extends SrcSetGeneratorOptions { +/** + * Generated module flavour: the language, and whether the module goes + * next to the variants or into a folder of its own named after the image. + */ +export type SrcSetModuleFormat = 'ts' | 'js' | 'ts-dir' | 'js-dir' + +export interface SrcSetCliOptions extends Omit { /** * Source image(s) glob patterns. */ @@ -13,11 +16,23 @@ export interface SrcSetCliOptions extends SrcSetGeneratorOptions { */ dest: string /** - * Rules to generate image variants. + * Generate an image module next to the variants, so a project can import + * the baked images without a bundler integration. Off by default. + * `placeholder`, `select` and `resourceId` shape the module, so without + * it they do nothing. */ - rules?: SrcSetRule[] + module?: SrcSetModuleFormat /** * Print processed images. */ verbose?: boolean + /** + * Not supported: a run of the cli is one-shot, so nothing configures + * the storage and nothing prunes it afterwards. + */ + cache?: never + /** + * Not supported: use `concurrency`. + */ + limit?: never } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ff6f516..41b96c3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -180,6 +180,9 @@ importers: packages/cli: dependencies: + '@srcset/bundler-utils': + specifier: workspace:^ + version: link:../bundler-utils '@srcset/core': specifier: workspace:^ version: link:../core