Skip to content

Commit 83d38b1

Browse files
committed
feat: implement getResponsiveImageAttributes function and corresponding tests
1 parent 3f5ce73 commit 83d38b1

File tree

4 files changed

+244
-106
lines changed

4 files changed

+244
-106
lines changed

src/getImageProps.ts

Lines changed: 0 additions & 102 deletions
This file was deleted.

src/getResponsiveImageAttributes.ts

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
import type { SrcOptions } from './interfaces'
2+
import { buildSrc } from './url'
3+
4+
/* Default break‑point pools */
5+
const DEFAULT_DEVICE_BREAKPOINTS = [640, 750, 828, 1080, 1200, 1920, 2048, 3840] as const
6+
const DEFAULT_IMAGE_BREAKPOINTS = [16, 32, 48, 64, 96, 128, 256, 320, 420] as const
7+
8+
export interface GetImageAttributesOptions extends SrcOptions {
9+
width?: number // explicit rendered width
10+
sizes?: string // the HTML sizes value
11+
deviceBreakpoints?: number[] // override default device break‑points
12+
imageBreakpoints?: number[] // override tiny image break‑points
13+
}
14+
15+
export interface ResponsiveImageAttributes {
16+
src: string
17+
srcSet?: string
18+
sizes?: string
19+
width?: number
20+
}
21+
22+
export function getResponsiveImageAttributes(
23+
opts: GetImageAttributesOptions
24+
): ResponsiveImageAttributes {
25+
const {
26+
src,
27+
urlEndpoint,
28+
transformation = [],
29+
queryParameters,
30+
transformationPosition,
31+
sizes,
32+
width,
33+
deviceBreakpoints = DEFAULT_DEVICE_BREAKPOINTS as unknown as number[],
34+
imageBreakpoints = DEFAULT_IMAGE_BREAKPOINTS as unknown as number[],
35+
} = opts
36+
37+
const allBreakpoints = [...imageBreakpoints, ...deviceBreakpoints].sort((a, b) => a - b)
38+
39+
const { candidates, descriptorKind } = computeCandidateWidths({
40+
allBreakpoints,
41+
deviceBreakpoints,
42+
explicitWidth: width,
43+
sizesAttr: sizes,
44+
})
45+
46+
/* helper to build a single ImageKit URL */
47+
const buildURL = (w: number) =>
48+
buildSrc({
49+
src,
50+
urlEndpoint,
51+
queryParameters,
52+
transformationPosition,
53+
transformation: [
54+
...transformation,
55+
{ width: w, crop: 'at_max' }, // never upscale beyond original
56+
],
57+
})
58+
59+
/* build srcSet */
60+
const srcSet =
61+
candidates
62+
.map((w, i) => `${buildURL(w)} ${descriptorKind === 'w' ? w : i + 1}${descriptorKind}`)
63+
.join(', ') || undefined
64+
65+
return {
66+
src: buildURL(candidates[candidates.length - 1]), // largest candidate
67+
srcSet,
68+
sizes: sizes ?? (descriptorKind === 'w' ? '100vw' : undefined),
69+
width,
70+
}
71+
}
72+
73+
function computeCandidateWidths(params: {
74+
allBreakpoints: number[]
75+
deviceBreakpoints: number[]
76+
explicitWidth?: number
77+
sizesAttr?: string
78+
}): { candidates: number[]; descriptorKind: 'w' | 'x' } {
79+
const { allBreakpoints, deviceBreakpoints, explicitWidth, sizesAttr } = params
80+
81+
/* --- sizes attribute present ----------------------------------- */
82+
if (sizesAttr) {
83+
const vwTokens = sizesAttr.match(/(^|\s)(1?\d{1,2})vw/g) || []
84+
const vwPercents = vwTokens.map((t) => parseInt(t, 10))
85+
86+
if (vwPercents.length) {
87+
const smallestRatio = Math.min(...vwPercents) / 100
88+
const minRequiredPx = deviceBreakpoints[0] * smallestRatio
89+
return {
90+
candidates: allBreakpoints.filter((w) => w >= minRequiredPx),
91+
descriptorKind: 'w',
92+
}
93+
}
94+
/* no vw → give the full break‑point list */
95+
return { candidates: allBreakpoints, descriptorKind: 'w' }
96+
}
97+
98+
/* --- no sizes attr ------------------------------------------------ */
99+
if (typeof explicitWidth !== 'number') {
100+
return { candidates: deviceBreakpoints, descriptorKind: 'w' }
101+
}
102+
103+
/* DPR strategy: 1× & 2× nearest break‑points */
104+
const nearest = (t: number) =>
105+
allBreakpoints.find((n) => n >= t) || allBreakpoints[allBreakpoints.length - 1]
106+
107+
const unique = Array.from(
108+
new Set([nearest(explicitWidth), nearest(explicitWidth * 2)]),
109+
)
110+
111+
return { candidates: unique, descriptorKind: 'x' }
112+
}

src/index.ts

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,12 @@
11
import type { SrcOptions, Transformation, UploadOptions, UploadResponse } from "./interfaces";
22
import { ImageKitAbortError, ImageKitInvalidRequestError, ImageKitServerError, ImageKitUploadNetworkError, upload } from "./upload";
33
import { buildSrc, buildTransformationString } from "./url";
4-
import { getImageProps } from "./getImageProps";
4+
import { getResponsiveImageAttributes } from "./getResponsiveImageAttributes";
55

6-
export { buildSrc, buildTransformationString, upload, getImageProps, ImageKitInvalidRequestError, ImageKitAbortError, ImageKitServerError, ImageKitUploadNetworkError };
6+
export { buildSrc, buildTransformationString, upload, getResponsiveImageAttributes, ImageKitInvalidRequestError, ImageKitAbortError, ImageKitServerError, ImageKitUploadNetworkError };
77
export type {
88
Transformation,
99
SrcOptions,
1010
UploadOptions,
1111
UploadResponse
1212
};
13-
14-
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
const { expect } = require('chai');
2+
const { getResponsiveImageAttributes } = require('../src/getResponsiveImageAttributes');
3+
4+
describe.only('getResponsiveImageAttributes – smoke run‑through', () => {
5+
it('bare minimum input', () => {
6+
const out = getResponsiveImageAttributes({
7+
src: 'sample.jpg',
8+
urlEndpoint: 'https://ik.imagekit.io/demo',
9+
});
10+
// Expected object based on default deviceSizes and imageSizes:
11+
expect(out).to.deep.equal({
12+
src: "https://ik.imagekit.io/demo/sample.jpg?tr=w-3840,c-at_max",
13+
srcSet: "https://ik.imagekit.io/demo/sample.jpg?tr=w-640,c-at_max 640w, https://ik.imagekit.io/demo/sample.jpg?tr=w-750,c-at_max 750w, https://ik.imagekit.io/demo/sample.jpg?tr=w-828,c-at_max 828w, https://ik.imagekit.io/demo/sample.jpg?tr=w-1080,c-at_max 1080w, https://ik.imagekit.io/demo/sample.jpg?tr=w-1200,c-at_max 1200w, https://ik.imagekit.io/demo/sample.jpg?tr=w-1920,c-at_max 1920w, https://ik.imagekit.io/demo/sample.jpg?tr=w-2048,c-at_max 2048w, https://ik.imagekit.io/demo/sample.jpg?tr=w-3840,c-at_max 3840w",
14+
sizes: "100vw",
15+
width: undefined
16+
});
17+
});
18+
19+
it('sizes provided (100vw)', () => {
20+
const out = getResponsiveImageAttributes({
21+
src: 'sample.jpg',
22+
urlEndpoint: 'https://ik.imagekit.io/demo',
23+
sizes: '100vw',
24+
});
25+
// With a sizes value of "100vw", the function should use the same breakpoints as in the bare minimum case.
26+
expect(out).to.deep.equal({
27+
src: "https://ik.imagekit.io/demo/sample.jpg?tr=w-3840,c-at_max",
28+
srcSet: "https://ik.imagekit.io/demo/sample.jpg?tr=w-640,c-at_max 640w, https://ik.imagekit.io/demo/sample.jpg?tr=w-750,c-at_max 750w, https://ik.imagekit.io/demo/sample.jpg?tr=w-828,c-at_max 828w, https://ik.imagekit.io/demo/sample.jpg?tr=w-1080,c-at_max 1080w, https://ik.imagekit.io/demo/sample.jpg?tr=w-1200,c-at_max 1200w, https://ik.imagekit.io/demo/sample.jpg?tr=w-1920,c-at_max 1920w, https://ik.imagekit.io/demo/sample.jpg?tr=w-2048,c-at_max 2048w, https://ik.imagekit.io/demo/sample.jpg?tr=w-3840,c-at_max 3840w",
29+
sizes: "100vw",
30+
width: undefined
31+
});
32+
});
33+
34+
it('width only – DPR strategy', () => {
35+
const out = getResponsiveImageAttributes({
36+
src: 'sample.jpg',
37+
urlEndpoint: 'https://ik.imagekit.io/demo',
38+
width: 400,
39+
});
40+
// When width is provided without sizes attribute, the DPR strategy should be used.
41+
expect(out).to.deep.equal({
42+
src: "https://ik.imagekit.io/demo/sample.jpg?tr=w-750,c-at_max",
43+
srcSet: "https://ik.imagekit.io/demo/sample.jpg?tr=w-640,c-at_max 1x, https://ik.imagekit.io/demo/sample.jpg?tr=w-750,c-at_max 2x",
44+
sizes: undefined,
45+
width: 400
46+
});
47+
});
48+
49+
it('custom breakpoints', () => {
50+
const out = getResponsiveImageAttributes({
51+
src: 'sample.jpg',
52+
urlEndpoint: 'https://ik.imagekit.io/demo',
53+
deviceSizes: [200, 400, 800],
54+
imageSizes: [100],
55+
});
56+
// For custom breakpoints, the breakpoints will be derived from the provided arrays.
57+
expect(out).to.deep.equal({
58+
src: "https://ik.imagekit.io/demo/sample.jpg?tr=w-800,c-at_max",
59+
srcSet: "https://ik.imagekit.io/demo/sample.jpg?tr=w-200,c-at_max 200w, https://ik.imagekit.io/demo/sample.jpg?tr=w-400,c-at_max 400w, https://ik.imagekit.io/demo/sample.jpg?tr=w-800,c-at_max 800w",
60+
sizes: "100vw",
61+
width: undefined
62+
});
63+
});
64+
65+
it('preserves caller transformations', () => {
66+
const out = getResponsiveImageAttributes({
67+
src: 'sample.jpg',
68+
urlEndpoint: 'https://ik.imagekit.io/demo',
69+
width: 500,
70+
transformation: [{ height: 300 }],
71+
});
72+
// The provided transformation should be preserved in the output.
73+
expect(out).to.deep.equal({
74+
src: "https://ik.imagekit.io/demo/sample.jpg?tr=height-300,w-1200,c-at_max",
75+
srcSet: "https://ik.imagekit.io/demo/sample.jpg?tr=height-300,w-640,c-at_max 1x, https://ik.imagekit.io/demo/sample.jpg?tr=height-300,w-1200,c-at_max 2x",
76+
sizes: undefined,
77+
width: 500
78+
});
79+
});
80+
81+
it('both sizes and width passed', () => {
82+
const out = getResponsiveImageAttributes({
83+
src: 'sample.jpg',
84+
urlEndpoint: 'https://ik.imagekit.io/demo',
85+
sizes: '50vw',
86+
width: 600,
87+
});
88+
// Both sizes and width are provided, so the function should apply the sizes attribute while using width for DPR strategy.
89+
expect(out).to.deep.equal({
90+
src: "https://ik.imagekit.io/demo/sample.jpg?tr=w-1200,c-at_max",
91+
srcSet: "https://ik.imagekit.io/demo/sample.jpg?tr=w-640,c-at_max 640w, https://ik.imagekit.io/demo/sample.jpg?tr=w-750,c-at_max 750w, https://ik.imagekit.io/demo/sample.jpg?tr=w-828,c-at_max 828w, https://ik.imagekit.io/demo/sample.jpg?tr=w-1080,c-at_max 1080w, https://ik.imagekit.io/demo/sample.jpg?tr=w-1200,c-at_max 1200w, https://ik.imagekit.io/demo/sample.jpg?tr=w-1920,c-at_max 1920w, https://ik.imagekit.io/demo/sample.jpg?tr=w-2048,c-at_max 2048w, https://ik.imagekit.io/demo/sample.jpg?tr=w-3840,c-at_max 3840w",
92+
sizes: "50vw",
93+
width: 600
94+
});
95+
});
96+
97+
it('multiple transformations', () => {
98+
const out = getResponsiveImageAttributes({
99+
src: 'sample.jpg',
100+
urlEndpoint: 'https://ik.imagekit.io/demo',
101+
width: 450,
102+
transformation: [
103+
{ height: 300 },
104+
{ aiRemoveBackground: true }
105+
]
106+
});
107+
// Multiple caller transformations should be combined appropriately.
108+
expect(out).to.deep.equal({
109+
src: "https://ik.imagekit.io/demo/sample.jpg?tr=height-300,aiRemoveBackground-true,w-828,c-at_max",
110+
srcSet: "https://ik.imagekit.io/demo/sample.jpg?tr=height-300,aiRemoveBackground-true,w-640,c-at_max 1x, https://ik.imagekit.io/demo/sample.jpg?tr=height-300,aiRemoveBackground-true,w-828,c-at_max 2x",
111+
sizes: undefined,
112+
width: 450
113+
});
114+
});
115+
116+
it('sizes causes breakpoint pruning (33vw path)', () => {
117+
const out = getResponsiveImageAttributes({
118+
src: 'sample.jpg',
119+
urlEndpoint: 'https://ik.imagekit.io/demo',
120+
sizes: '(min-width: 800px) 33vw, 100vw',
121+
});
122+
// When specified with a sizes attribute that prunes breakpoints, the output should reflect the pruned values.
123+
expect(out).to.deep.equal({
124+
src: "https://ik.imagekit.io/demo/sample.jpg?tr=w-3840,c-at_max",
125+
srcSet: "https://ik.imagekit.io/demo/sample.jpg?tr=w-640,c-at_max 640w, https://ik.imagekit.io/demo/sample.jpg?tr=w-750,c-at_max 750w, https://ik.imagekit.io/demo/sample.jpg?tr=w-828,c-at_max 828w, https://ik.imagekit.io/demo/sample.jpg?tr=w-1080,c-at_max 1080w, https://ik.imagekit.io/demo/sample.jpg?tr=w-1200,c-at_max 1200w, https://ik.imagekit.io/demo/sample.jpg?tr=w-1920,c-at_max 1920w, https://ik.imagekit.io/demo/sample.jpg?tr=w-2048,c-at_max 2048w, https://ik.imagekit.io/demo/sample.jpg?tr=w-3840,c-at_max 3840w",
126+
sizes: "(min-width: 800px) 33vw, 100vw",
127+
width: undefined
128+
});
129+
});
130+
});

0 commit comments

Comments
 (0)