Skip to content

Commit 1fd0ad3

Browse files
committed
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`.
1 parent 5f81e2d commit 1fd0ad3

17 files changed

Lines changed: 806 additions & 115 deletions

‎packages/bundler-utils/src/generate.ts‎

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import {
77
} from '@srcset/core'
88
import type { QueryOptions } from './query.ts'
99
import type {
10-
SrcSetModuleOptions,
10+
SrcSetModuleGenerateOptions,
1111
EmitImage
1212
} from './generate.types.ts'
1313
import {
@@ -25,15 +25,15 @@ export type * from './generate.types.ts'
2525
* on the bundler side and make the module code.
2626
* @param source - Image file.
2727
* @param query - Parsed import query options.
28-
* @param options - Bundler integration options.
28+
* @param options - Options of the module generation.
2929
* @param emitImage - Emits an image on the bundler side.
3030
* @param limit - Concurrency limit of the integration.
3131
* @returns Module code.
3232
*/
3333
export async function generateSrcSetModule(
3434
source: ImageSource,
3535
query: QueryOptions,
36-
options: SrcSetModuleOptions,
36+
options: SrcSetModuleGenerateOptions,
3737
emitImage: EmitImage,
3838
limit?: LimitFunction
3939
) {
@@ -86,5 +86,10 @@ export async function generateSrcSetModule(
8686
})
8787
}
8888

89-
return createModuleString(select, srcSet, placeholder)
89+
return createModuleString({
90+
select,
91+
srcSet,
92+
placeholder,
93+
typescript: options.typescript
94+
})
9095
}

‎packages/bundler-utils/src/generate.types.ts‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,19 @@ export interface SrcSetModuleOptions extends Omit<SrcSetGeneratorOptions, 'limit
3333
select?: SrcSetEntrySelect
3434
}
3535

36+
/**
37+
* Options of the module generation: the integration options plus what
38+
* only the generation itself needs.
39+
*/
40+
export interface SrcSetModuleGenerateOptions extends SrcSetModuleOptions {
41+
/**
42+
* Generate typescript instead of javascript: the variant formats are
43+
* narrowed with `as const`, so the entries stay assignable to `SrcSetEntry`.
44+
* Bundler integrations never need it - their modules are not written to disk.
45+
*/
46+
typescript?: boolean
47+
}
48+
3649
/**
3750
* Emits an image on the bundler side.
3851
* @param image - Image variant.

‎packages/bundler-utils/src/module.spec.ts‎

Lines changed: 47 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -32,113 +32,123 @@ describe('bundler-utils', () => {
3232
describe('module', () => {
3333
describe('createModuleString', () => {
3434
it('should select default variant by format and width', () => {
35-
const module = createModuleString(
36-
{
35+
const module = createModuleString({
36+
select: {
3737
format: 'webp',
3838
width: 320
3939
},
40-
[createEntry('jpg', 320), createEntry('webp', 320), createEntry('webp', 640)]
41-
)
40+
srcSet: [createEntry('jpg', 320), createEntry('webp', 320), createEntry('webp', 640)]
41+
})
4242

4343
expect(module).toContain('const url = (__webpack_public_path__) + "image@320w.webp";')
4444
})
4545

4646
it('should select default variant by multiplier', () => {
47-
const module = createModuleString(
48-
{
47+
const module = createModuleString({
48+
select: {
4949
format: 'jpg',
5050
width: 0.5
5151
},
52-
[createEntry('jpg', 640, 1), createEntry('jpg', 320, 0.5)]
53-
)
52+
srcSet: [createEntry('jpg', 640, 1), createEntry('jpg', 320, 0.5)]
53+
})
5454

5555
expect(module).toContain('const url = (__webpack_public_path__) + "image@320w.jpg";')
5656
})
5757

5858
it('should select default variant by id', () => {
59-
const module = createModuleString(
60-
{
59+
const module = createModuleString({
60+
select: {
6161
id: 'webp640'
6262
},
63-
[createEntry('jpg', 320), createEntry('webp', 640)]
64-
)
63+
srcSet: [createEntry('jpg', 320), createEntry('webp', 640)]
64+
})
6565

6666
expect(module).toContain('const url = (__webpack_public_path__) + "image@640w.webp";')
6767
})
6868

6969
it('should fall back to first variant', () => {
70-
const module = createModuleString(
71-
{
70+
const module = createModuleString({
71+
select: {
7272
format: 'avif',
7373
width: 5000
7474
},
75-
[createEntry('jpg', 320), createEntry('webp', 640)]
76-
)
75+
srcSet: [createEntry('jpg', 320), createEntry('webp', 640)]
76+
})
7777

7878
expect(module).toContain('const url = (__webpack_public_path__) + "image@320w.jpg";')
7979
})
8080

8181
it('should create empty module without variants', () => {
82-
const module = createModuleString(
83-
{
82+
const module = createModuleString({
83+
select: {
8484
format: 'jpg',
8585
width: 640
8686
},
87-
[]
88-
)
87+
srcSet: []
88+
})
8989

9090
expect(module).toContain("const url = '';")
9191
expect(module).toContain('const src = null;')
9292
expect(module).toContain('export const srcSet = [];')
9393
})
9494

9595
it('should reuse url and src references for default variant', () => {
96-
const module = createModuleString(
97-
{
96+
const module = createModuleString({
97+
select: {
9898
format: 'jpg',
9999
width: 320
100100
},
101-
[createEntry('jpg', 320), createEntry('webp', 320)]
102-
)
101+
srcSet: [createEntry('jpg', 320), createEntry('webp', 320)]
102+
})
103103

104104
expect(module).toContain('url: url')
105105
expect(module).toContain('export const srcSet = [src, {')
106106
expect(module).toContain('"jpg320": url')
107107
})
108108

109109
it('should emit placeholder export', () => {
110-
const module = createModuleString(
111-
{
110+
const module = createModuleString({
111+
select: {
112112
format: 'jpg',
113113
width: 320
114114
},
115-
[createEntry('jpg', 320)],
116-
'data:image/webp;base64,abc'
117-
)
115+
srcSet: [createEntry('jpg', 320)],
116+
placeholder: 'data:image/webp;base64,abc'
117+
})
118118

119119
expect(module).toContain('export const placeholder = "data:image/webp;base64,abc";')
120120
})
121121

122+
it('should narrow the variant format for a typescript module', () => {
123+
const module = createModuleString({
124+
select: {},
125+
srcSet: [createEntry('jpg', 320)],
126+
typescript: true
127+
})
128+
129+
expect(module).toContain('format: "jpg" as const,')
130+
})
131+
122132
it('should emit undefined placeholder without data-url', () => {
123-
const module = createModuleString(
124-
{
133+
const module = createModuleString({
134+
select: {
125135
format: 'jpg',
126136
width: 320
127137
},
128-
[createEntry('jpg', 320)]
129-
)
138+
srcSet: [createEntry('jpg', 320)]
139+
})
130140

131141
expect(module).toContain('export const placeholder = undefined;')
132142
})
133143

134144
it('should map ids to urls', () => {
135-
const module = createModuleString(
136-
{
145+
const module = createModuleString({
146+
select: {
137147
format: 'jpg',
138148
width: 320
139149
},
140-
[createEntry('jpg', 320), createEntry('webp', 640)]
141-
)
150+
srcSet: [createEntry('jpg', 320), createEntry('webp', 640)]
151+
})
142152

143153
expect(module).toContain('"webp640": (__webpack_public_path__) + "image@640w.webp"')
144154
})

‎packages/bundler-utils/src/module.ts‎

Lines changed: 52 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,10 @@ const emptyUrlExpression = "''"
2626
* @returns JS expression string.
2727
*/
2828
function toUrlExpression(url: SrcSetImagePaths) {
29+
if (url.urlExpression) {
30+
return url.urlExpression
31+
}
32+
2933
if (url.publicPath !== null) {
3034
return JSON.stringify(url.publicPath)
3135
}
@@ -68,31 +72,66 @@ function findDefaultIndex(select: SrcSetEntrySelect, srcSet: SrcSetModuleEntry[]
6872
return index
6973
}
7074

71-
function createEntryString({
72-
id,
73-
format,
74-
type,
75-
width,
76-
height
77-
}: SrcSetModuleEntry, urlString: string) {
75+
function createEntryString(
76+
{
77+
id,
78+
format,
79+
type,
80+
width,
81+
height
82+
}: SrcSetModuleEntry,
83+
urlString: string,
84+
typescript: boolean
85+
) {
86+
// Without the assertion the format of a typescript module widens to `string`,
87+
// and the entry stops being assignable to `SrcSetEntry`.
88+
const formatString = typescript ? `${JSON.stringify(format)} as const` : JSON.stringify(format)
89+
7890
return `{
7991
id: ${JSON.stringify(id)},
80-
format: ${JSON.stringify(format)},
92+
format: ${formatString},
8193
type: ${JSON.stringify(type)},
8294
width: ${String(width)},
8395
height: ${String(height)},
8496
url: ${urlString}
8597
}`
8698
}
8799

100+
/**
101+
* Options of the module code generation.
102+
*/
103+
export interface ModuleStringOptions {
104+
/**
105+
* Selection of the image variant for the default export.
106+
*/
107+
select: SrcSetEntrySelect
108+
/**
109+
* Generated image variant entries.
110+
*/
111+
srcSet: SrcSetModuleEntry[]
112+
/**
113+
* Data-url of the placeholder variant, falsy to emit `undefined`.
114+
*/
115+
placeholder?: string | false
116+
/**
117+
* Generate typescript: the variant formats are narrowed with `as const`,
118+
* so the entries stay assignable to `SrcSetEntry`.
119+
*/
120+
typescript?: boolean
121+
}
122+
88123
/**
89124
* Create ES module code for the image import.
90-
* @param select - Selection of the image variant for the default export.
91-
* @param srcSet - Generated image variant entries.
92-
* @param placeholder - Data-url of the placeholder variant, falsy to emit `undefined`.
125+
* @param options - Options of the generation.
93126
* @returns Module code.
94127
*/
95-
export function createModuleString(select: SrcSetEntrySelect, srcSet: SrcSetModuleEntry[], placeholder?: string | false) {
128+
export function createModuleString(options: ModuleStringOptions) {
129+
const {
130+
select,
131+
srcSet,
132+
placeholder,
133+
typescript = false
134+
} = options
96135
const defaultIndex = findDefaultIndex(select, srcSet)
97136
const urlExpressions = srcSet.map(entry => toUrlExpression(entry.url))
98137
const urlExpression = defaultIndex < 0 ? emptyUrlExpression : urlExpressions[defaultIndex]
@@ -103,7 +142,7 @@ export function createModuleString(select: SrcSetEntrySelect, srcSet: SrcSetModu
103142
srcSet.forEach((entry, index) => {
104143
const isDefault = index === defaultIndex
105144
const urlString = isDefault ? 'url' : urlExpressions[index]
106-
const entryString = createEntryString(entry, urlString)
145+
const entryString = createEntryString(entry, urlString, typescript)
107146

108147
if (isDefault) {
109148
srcString = entryString

‎packages/bundler-utils/src/types.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,4 +29,9 @@ export interface SrcSetImagePaths {
2929
* e.g. `__webpack_public_path__` of webpack.
3030
*/
3131
publicPathExpression?: string
32+
/**
33+
* JS expression of the whole url, when it is not a path at all,
34+
* e.g. an identifier the generated module imports the image with.
35+
*/
36+
urlExpression?: string
3237
}

‎packages/cli/package.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@
5555
"test": "run -p lint test:unit test:types"
5656
},
5757
"dependencies": {
58+
"@srcset/bundler-utils": "workspace:^",
5859
"@srcset/core": "workspace:^",
5960
"argue-cli": "^3.1.0",
6061
"tinyglobby": "^0.2.10"

‎packages/cli/src/args.spec.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,12 @@ describe('cli', () => {
7070

7171
expect(parseCliArgs().help).toBe(true)
7272
})
73+
74+
it('should read the module format without validating it', () => {
75+
setArgs('--module', 'typescript')
76+
77+
expect(parseCliArgs().module).toBe('typescript')
78+
})
7379
})
7480
})
7581
})

‎packages/cli/src/args.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ export const usage = `srcset [...sources] [...options]
2222
--skip-optimization Do not optimize output images.
2323
--no-scaling-up Do not generate images larger than the source.
2424
--dest, -d Destination directory.
25+
--module Generate an image module: ts, js, ts-dir or js-dir.
2526
--config, -c Config file path. Defaults to the \`srcset.config.js\` lookup.
2627
--concurrency Concurrency limit.
2728
`
@@ -34,6 +35,7 @@ export interface CliArgs {
3435
skipOptimization: boolean | undefined
3536
scalingUp: boolean | undefined
3637
dest: string | undefined
38+
module: string | undefined
3739
config: string | undefined
3840
concurrency: number | undefined
3941
}
@@ -52,6 +54,7 @@ export function parseCliArgs(): CliArgs {
5254
skipOptimization,
5355
scalingUp,
5456
dest,
57+
module: moduleFormat,
5558
config,
5659
concurrency
5760
} = readOptions(
@@ -63,6 +66,7 @@ export function parseCliArgs(): CliArgs {
6366
flag(autocase('skipOptimization')),
6467
flag(autocase('scalingUp')),
6568
option(alias('dest', 'd'), String),
69+
option('module', String),
6670
option(alias('config', 'c'), String),
6771
option('concurrency', Number)
6872
)
@@ -94,6 +98,7 @@ export function parseCliArgs(): CliArgs {
9498
skipOptimization,
9599
scalingUp,
96100
dest,
101+
module: moduleFormat,
97102
config,
98103
concurrency
99104
}

0 commit comments

Comments
 (0)