summaryrefslogtreecommitdiff
path: root/packages/integrations/vercel/src/image/shared.ts
blob: da9342f2942f661a4e6899bc0bf663cbdb8c2ccb (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
import type { AstroConfig, ImageMetadata, ImageQualityPreset, ImageTransform } from 'astro';

export function getDefaultImageConfig(astroImageConfig: AstroConfig['image']): VercelImageConfig {
	return {
		sizes: [640, 750, 828, 1080, 1200, 1920, 2048, 3840],
		domains: astroImageConfig.domains ?? [],
		// Cast is necessary here because Vercel's types are slightly different from ours regarding allowed protocols. Behavior should be the same, however.
		remotePatterns: (astroImageConfig.remotePatterns as VercelImageConfig['remotePatterns']) ?? [],
	};
}

export function isESMImportedImage(src: ImageMetadata | string): src is ImageMetadata {
	return typeof src === 'object';
}

export type DevImageService = 'sharp' | 'squoosh' | (string & {});

// https://vercel.com/docs/build-output-api/v3/configuration#images
type ImageFormat = 'image/avif' | 'image/webp';

type RemotePattern = {
	protocol?: 'http' | 'https';
	hostname: string;
	port?: string;
	pathname?: string;
};

export type VercelImageConfig = {
	/**
	 * Supported image widths.
	 */
	sizes: number[];
	/**
	 * Allowed external domains that can use Image Optimization. Leave empty for only allowing the deployment domain to use Image Optimization.
	 */
	domains: string[];
	/**
	 * Allowed external patterns that can use Image Optimization. Similar to `domains` but provides more control with RegExp.
	 */
	remotePatterns?: RemotePattern[];
	/**
	 * Cache duration (in seconds) for the optimized images.
	 */
	minimumCacheTTL?: number;
	/**
	 * Supported output image formats
	 */
	formats?: ImageFormat[];
	/**
	 * Allow SVG input image URLs. This is disabled by default for security purposes.
	 */
	dangerouslyAllowSVG?: boolean;
	/**
	 * Change the [Content Security Policy](https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP) of the optimized images.
	 */
	contentSecurityPolicy?: string;
};

export const qualityTable: Record<ImageQualityPreset, number> = {
	low: 25,
	mid: 50,
	high: 80,
	max: 100,
};

export function getAstroImageConfig(
	images: boolean | undefined,
	imagesConfig: VercelImageConfig | undefined,
	command: string,
	devImageService: DevImageService,
	astroImageConfig: AstroConfig['image'],
) {
	let devService = '@astrojs/vercel/dev-image-service';

	switch (devImageService) {
		case 'sharp':
			devService = '@astrojs/vercel/dev-image-service';
			break;
		case 'squoosh':
			devService = '@astrojs/vercel/squoosh-dev-image-service';
			break;
		default:
			if (typeof devImageService === 'string') {
				devService = devImageService;
			} else {
				devService = '@astrojs/vercel/dev-image-service';
			}
			break;
	}

	if (images) {
		return {
			image: {
				service: {
					entrypoint: command === 'dev' ? devService : '@astrojs/vercel/build-image-service',
					config: imagesConfig ? imagesConfig : getDefaultImageConfig(astroImageConfig),
				},
			},
		};
	}

	return {};
}

export function sharedValidateOptions(
	options: ImageTransform,
	serviceConfig: Record<string, any>,
	mode: 'development' | 'production',
) {
	const vercelImageOptions = serviceConfig as VercelImageConfig;

	if (
		mode === 'development' &&
		(!vercelImageOptions.sizes || vercelImageOptions.sizes.length === 0)
	) {
		throw new Error('Vercel Image Optimization requires at least one size to be configured.');
	}

	const configuredWidths = vercelImageOptions.sizes.sort((a, b) => a - b);

	// The logic for finding the perfect width is a bit confusing, here it goes:
	// For images where no width has been specified:
	// - For local, imported images, fallback to nearest width we can find in our configured
	// - For remote images, that's an error, width is always required.
	// For images where a width has been specified:
	// - If the width that the user asked for isn't in `sizes`, then fallback to the nearest one, but save the width
	// 	the user asked for so we can put it on the `img` tag later.
	// - Otherwise, just use as-is.
	// The end goal is:
	// - The size on the page is always the one the user asked for or the base image's size
	// - The actual size of the image file is always one of `sizes`, either the one the user asked for or the nearest to it
	if (!options.width) {
		const src = options.src;
		if (isESMImportedImage(src)) {
			const nearestWidth = configuredWidths.reduce((prev, curr) => {
				return Math.abs(curr - src.width) < Math.abs(prev - src.width) ? curr : prev;
			});

			// Use the image's base width to inform the `width` and `height` on the `img` tag
			options.inputtedWidth = src.width;
			options.width = nearestWidth;
		} else {
			throw new Error(`Missing \`width\` parameter for remote image ${options.src}`);
		}
	} else {
		if (!configuredWidths.includes(options.width)) {
			const nearestWidth = configuredWidths.reduce((prev, curr) => {
				return Math.abs(curr - options.width!) < Math.abs(prev - options.width!) ? curr : prev;
			});

			// Save the width the user asked for to inform the `width` and `height` on the `img` tag
			options.inputtedWidth = options.width;
			options.width = nearestWidth;
		}
	}

	if (options.quality && typeof options.quality === 'string') {
		options.quality = options.quality in qualityTable ? qualityTable[options.quality] : undefined;
	}

	if (!options.quality) {
		options.quality = 100;
	}

	return options;
}