Skip to content

Commit 1a1bad3

Browse files
authored
fix: address the code review findings (#16)
- Variant selection: an explicit `select` or `?format=` no longer inherits the implicit width, and the fallback variant of a rule is documented - Dev server addresses variants by the manifest key, so same-named images of different sources no longer serve each other's contents - Variants resolving to one file are produced once, across rules as well - EXIF orientation is applied instead of being dropped, so the variants and the reported dimensions match what a browser renders - Glob matchers normalize the path, so the same rule matches on Windows - The cli rejects unknown options instead of taking them for globs, and stops on an output path collision instead of overwriting silently - `?placeholder` keeps the configured placeholder options - The loader ships declarations without a hard `webpack` dependency, and its development names keep converted siblings apart - An aborted dev request no longer leaks a file descriptor - The preact peer range matches what the source actually needs - Rule matching and deduplication moved into `SrcSetGenerator.generateAll`
1 parent 5289e7b commit 1a1bad3

38 files changed

Lines changed: 950 additions & 191 deletions

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

Lines changed: 135 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import {
22
describe,
33
it,
4-
expect
4+
expect,
5+
vi
56
} from 'vitest'
67
import sharp from 'sharp'
78
import { generateSrcSetModule } from './generate.ts'
@@ -118,6 +119,26 @@ describe('bundler-utils', () => {
118119
expect(module).toContain('"jpg160"')
119120
})
120121

122+
it('should deduplicate variants of overlapping fallthrough rules', async () => {
123+
const image = await createImage()
124+
const emitImage = vi.fn(emitToPath)
125+
const module = await generateSrcSetModule(image, {}, {
126+
rules: [
127+
{
128+
fallthrough: true,
129+
width: [0.5, 1]
130+
},
131+
{
132+
width: [320, 0.25]
133+
}
134+
]
135+
}, emitImage)
136+
137+
expect(emitImage).toHaveBeenCalledTimes(3)
138+
expect(module.match(/"jpg320":/g)?.length).toBe(1)
139+
expect(module).toContain('"jpg160"')
140+
})
141+
121142
it('should inline placeholder data-url', async () => {
122143
const image = await createImage()
123144
const module = await generateSrcSetModule(
@@ -133,6 +154,119 @@ describe('bundler-utils', () => {
133154
expect(module).toMatch(/export const placeholder = "data:image\/webp;base64,[^"]+";/)
134155
})
135156

157+
it('should keep the configured placeholder options for the query flag', async () => {
158+
const image = await createImage()
159+
const module = await generateSrcSetModule(image, {
160+
placeholder: true
161+
}, {
162+
skipOptimization: true,
163+
placeholder: {
164+
format: 'jpg'
165+
}
166+
}, emitToPath)
167+
168+
expect(module).toMatch(/export const placeholder = "data:image\/jpeg;base64,/)
169+
})
170+
171+
it('should disable the placeholder from the query', async () => {
172+
const image = await createImage()
173+
const module = await generateSrcSetModule(image, {
174+
placeholder: false
175+
}, {
176+
skipOptimization: true,
177+
placeholder: {
178+
format: 'jpg'
179+
}
180+
}, emitToPath)
181+
182+
expect(module).toContain('export const placeholder = undefined;')
183+
})
184+
185+
it('should select the requested format at the first width of the rule', async () => {
186+
const image = await createImage()
187+
const module = await generateSrcSetModule(image, {
188+
select: {
189+
format: 'webp'
190+
}
191+
}, {
192+
rules: [
193+
{
194+
format: ['jpg', 'webp'],
195+
width: [1, 0.5]
196+
}
197+
]
198+
}, emitToPath)
199+
200+
expect(module).toContain('const url = "/images/image.webp";')
201+
})
202+
203+
it('should select the requested width in the first format of the rule', async () => {
204+
const image = await createImage()
205+
const module = await generateSrcSetModule(image, {
206+
select: {
207+
width: 320
208+
}
209+
}, {
210+
rules: [
211+
{
212+
format: ['webp', 'jpg'],
213+
width: [1, 0.5]
214+
}
215+
]
216+
}, emitToPath)
217+
218+
expect(module).toContain('const url = "/images/image@320w.webp";')
219+
})
220+
221+
it('should prefer the select from the query over the options', async () => {
222+
const image = await createImage()
223+
const module = await generateSrcSetModule(image, {
224+
select: {
225+
id: 'webp320'
226+
}
227+
}, {
228+
select: {
229+
id: 'jpg320'
230+
},
231+
rules: [
232+
{
233+
format: ['jpg', 'webp'],
234+
width: [0.5]
235+
}
236+
]
237+
}, emitToPath)
238+
239+
expect(module).toContain('const url = "/images/image@320w.webp";')
240+
})
241+
242+
it('should select the original variant without an explicit select', async () => {
243+
const image = await createImage()
244+
const module = await generateSrcSetModule(image, {}, {
245+
rules: [
246+
{
247+
format: ['webp', 'jpg'],
248+
width: [1, 0.5]
249+
}
250+
]
251+
}, emitToPath)
252+
253+
expect(module).toContain('const url = "/images/image.jpg";')
254+
})
255+
256+
it('should fall back to the first variant when nothing matches the select', async () => {
257+
const image = await createImage()
258+
const module = await generateSrcSetModule(image, {}, {
259+
rules: [
260+
{
261+
format: ['webp', 'jpg'],
262+
width: [0.5]
263+
}
264+
]
265+
}, emitToPath)
266+
267+
expect(module).toContain('const url = "/images/image@320w.webp";')
268+
})
269+
136270
it('should build public path expression without a plain public path', async () => {
137271
const image = await createImage()
138272
const module = await generateSrcSetModule(image, {}, {

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

Lines changed: 29 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@ import {
33
type ImageSource,
44
SrcSetGenerator,
55
getImageMetadata,
6-
matchImage,
76
mimeTypes
87
} from '@srcset/core'
98
import type { QueryOptions } from './query.ts'
@@ -45,41 +44,46 @@ export async function generateSrcSetModule(
4544
limit
4645
})
4746
const metadata = await getImageMetadata(source)
47+
// The query flag only switches the placeholder on and off: the configured
48+
// options stay, so `?placeholder` does not fall back to the defaults.
49+
const placeholderOptions = query.placeholder === undefined
50+
? options.placeholder
51+
: query.placeholder && (options.placeholder ?? true)
4852
const placeholder = await createPlaceholder(
4953
source,
5054
metadata,
51-
query.placeholder ?? options.placeholder,
55+
placeholderOptions,
5256
limit,
5357
options.cache
5458
)
55-
const select = {
56-
format: metadata.format,
57-
width: metadata.width,
59+
const userSelect = {
5860
...options.select,
5961
...query.select
6062
}
61-
const srcSet: SrcSetModuleEntry[] = []
62-
63-
for (const rule of rules) {
64-
if (!await matchImage(source, rule.match)) {
65-
continue
66-
}
67-
68-
for await (const image of generator.generate(source, rule)) {
69-
srcSet.push({
70-
id: resourceId(image.width, image.originMultiplier ?? image.width, image.format),
71-
format: image.format,
72-
type: mimeTypes[image.format],
73-
width: image.width,
74-
height: image.height,
75-
originMultiplier: image.originMultiplier,
76-
url: emitImage(image)
77-
})
63+
const hasUserSelect = userSelect.id !== undefined
64+
|| userSelect.format !== undefined
65+
|| userSelect.width !== undefined
66+
// The implicit selection describes the original image and applies only when
67+
// nothing is selected explicitly: mixed into a partial selection it would
68+
// defeat the half the user did not specify.
69+
const select = hasUserSelect
70+
? userSelect
71+
: {
72+
format: metadata.format,
73+
width: metadata.width
7874
}
75+
const srcSet: SrcSetModuleEntry[] = []
7976

80-
if (!rule.fallthrough) {
81-
break
82-
}
77+
for await (const image of generator.generateAll(source, rules)) {
78+
srcSet.push({
79+
id: resourceId(image.width, image.originMultiplier ?? image.width, image.format),
80+
format: image.format,
81+
type: mimeTypes[image.format],
82+
width: image.width,
83+
height: image.height,
84+
originMultiplier: image.originMultiplier,
85+
url: emitImage(image)
86+
})
8387
}
8488

8589
return createModuleString(select, srcSet, placeholder)

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

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,9 @@
11
import type {
22
SrcSetImage,
3-
SrcSetGeneratorOptions
3+
SrcSetGeneratorOptions,
4+
SrcSetRule
45
} from '@srcset/core'
5-
import type {
6-
SrcSetRule,
7-
SrcSetImagePaths
8-
} from './types.ts'
6+
import type { SrcSetImagePaths } from './types.ts'
97
import type {
108
SrcSetEntrySelect,
119
ResourceIdFormatter

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

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,9 @@ export async function createPlaceholder(
7777

7878
const createImage = async (): Promise<SrcSetImage> => {
7979
const contents = await limit(() => {
80-
const pipeline = sharp(source.contents).resize({
80+
const pipeline = sharp(source.contents, {
81+
autoOrient: true
82+
}).resize({
8183
width,
8284
withoutEnlargement: true
8385
})

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { SrcSetRule } from './types.ts'
1+
import type { SrcSetRule } from '@srcset/core'
22
import type { SrcSetEntrySelect } from './module.ts'
33

44
export interface QueryOptions {
Lines changed: 0 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,3 @@
1-
import type {
2-
Matcher,
3-
GenerateOptions
4-
} from '@srcset/core'
5-
61
/**
72
* Paths of an image emitted on the bundler side.
83
*/
@@ -22,18 +17,3 @@ export interface SrcSetImagePaths {
2217
*/
2318
publicPathExpression?: string
2419
}
25-
26-
/**
27-
* Rule to generate image variants: match options plus generate options.
28-
*/
29-
export interface SrcSetRule extends GenerateOptions {
30-
/**
31-
* Rule(s) to match the image: glob, media query or matcher function.
32-
*/
33-
match?: Matcher | Matcher[]
34-
/**
35-
* Keep matching the rest of the rules after this rule matched.
36-
* By default the first matched rule is the only one applied.
37-
*/
38-
fallthrough?: boolean
39-
}

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,12 @@ import { parseCliArgs } from './args.ts'
99
describe('cli', () => {
1010
describe('args', () => {
1111
describe('parseCliArgs', () => {
12+
it('should reject an unrecognised option', () => {
13+
setArgs('images/**/*.jpg', '--formats', 'webp', '--dest', 'dist')
14+
15+
expect(() => parseCliArgs()).toThrow('Unknown option: "--formats"')
16+
})
17+
1218
it('should parse sources and options', () => {
1319
setArgs(
1420
'images/**/*.jpg',

‎packages/cli/src/args.ts‎

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
1-
import type { ImageFormat } from '@srcset/core'
1+
import type {
2+
ImageFormat,
3+
SrcSetRule
4+
} from '@srcset/core'
25
import {
36
rest,
47
alias,
@@ -7,7 +10,6 @@ import {
710
option,
811
readOptions
912
} from 'argue-cli'
10-
import type { SrcSetCliRule } from './types.ts'
1113

1214
export const usage = `srcset [...sources] [...options]
1315
@@ -28,7 +30,7 @@ export interface CliArgs {
2830
help: boolean
2931
verbose: boolean | undefined
3032
sources: string[]
31-
rule: SrcSetCliRule | null
33+
rule: SrcSetRule | null
3234
skipOptimization: boolean | undefined
3335
scalingUp: boolean | undefined
3436
dest: string | undefined
@@ -64,7 +66,7 @@ export function parseCliArgs(): CliArgs {
6466
option(alias('config', 'c'), String),
6567
option('concurrency', Number)
6668
)
67-
const rule: SrcSetCliRule = {
69+
const rule: SrcSetRule = {
6870
...match && {
6971
match
7072
},
@@ -75,11 +77,19 @@ export function parseCliArgs(): CliArgs {
7577
format: format as ImageFormat[]
7678
}
7779
}
80+
const sources = rest()
81+
// Whatever the readers did not take stays in `argv`: a leftover flag is a
82+
// typo, and treating it as a glob would quietly match nothing.
83+
const unknownOption = sources.find(source => source.startsWith('-'))
84+
85+
if (unknownOption) {
86+
throw new Error(`Unknown option: "${unknownOption}".`)
87+
}
7888

7989
return {
8090
help: Boolean(help),
8191
verbose,
82-
sources: rest(),
92+
sources,
8393
rule: Object.keys(rule).length ? rule : null,
8494
skipOptimization,
8595
scalingUp,

‎packages/cli/src/cli.ts‎

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,14 @@ import {
77
import { loadConfig } from './config.ts'
88
import { run } from './run.ts'
99

10-
const args = parseCliArgs()
10+
try {
11+
const args = parseCliArgs()
1112

12-
if (args.help) {
13-
console.info(usage)
14-
process.exit(0)
15-
}
13+
if (args.help) {
14+
console.info(usage)
15+
process.exit(0)
16+
}
1617

17-
try {
1818
const config = await loadConfig(args.config)
1919
const options: SrcSetCliOptions = {
2020
...config,

0 commit comments

Comments
 (0)