diff options
Diffstat (limited to 'packages/integrations/vercel/src/image')
4 files changed, 309 insertions, 0 deletions
diff --git a/packages/integrations/vercel/src/image/build-service.ts b/packages/integrations/vercel/src/image/build-service.ts new file mode 100644 index 000000000..9bda259fe --- /dev/null +++ b/packages/integrations/vercel/src/image/build-service.ts @@ -0,0 +1,71 @@ +import type { ExternalImageService } from 'astro'; +import { baseService } from 'astro/assets'; +import { isESMImportedImage } from 'astro/assets/utils'; +import { sharedValidateOptions } from './shared.js'; + +const service: ExternalImageService = { + ...baseService, + validateOptions: (options, serviceOptions) => + sharedValidateOptions(options, serviceOptions.service.config, 'production'), + getHTMLAttributes(options) { + const { inputtedWidth, ...props } = options; + + // If `validateOptions` returned a different width than the one of the image, use it for attributes + if (inputtedWidth) { + props.width = inputtedWidth; + } + + let targetWidth = props.width; + let targetHeight = props.height; + if (isESMImportedImage(props.src)) { + const aspectRatio = props.src.width / props.src.height; + if (targetHeight && !targetWidth) { + // If we have a height but no width, use height to calculate the width + targetWidth = Math.round(targetHeight * aspectRatio); + } else if (targetWidth && !targetHeight) { + // If we have a width but no height, use width to calculate the height + targetHeight = Math.round(targetWidth / aspectRatio); + } else if (!targetWidth && !targetHeight) { + // If we have neither width or height, use the original image's dimensions + targetWidth = props.src.width; + targetHeight = props.src.height; + } + } + + const { src, width, height, format, quality, densities, widths, formats, ...attributes } = + options; + + return { + ...attributes, + width: targetWidth, + height: targetHeight, + loading: attributes.loading ?? 'lazy', + decoding: attributes.decoding ?? 'async', + }; + }, + getURL(options) { + // For SVG files, return the original source path + if (isESMImportedImage(options.src) && options.src.format === 'svg') { + return options.src.src; + } + + // For non-SVG files, continue with the Vercel image processing + const fileSrc = isESMImportedImage(options.src) + ? removeLeadingForwardSlash(options.src.src) + : options.src; + + const searchParams = new URLSearchParams(); + searchParams.append('url', fileSrc); + + options.width && searchParams.append('w', options.width.toString()); + options.quality && searchParams.append('q', options.quality.toString()); + + return '/_vercel/image?' + searchParams; + }, +}; + +function removeLeadingForwardSlash(path: string) { + return path.startsWith('/') ? path.substring(1) : path; +} + +export default service; diff --git a/packages/integrations/vercel/src/image/dev-service.ts b/packages/integrations/vercel/src/image/dev-service.ts new file mode 100644 index 000000000..c9702cff9 --- /dev/null +++ b/packages/integrations/vercel/src/image/dev-service.ts @@ -0,0 +1,31 @@ +import type { LocalImageService } from 'astro'; +import sharpService from 'astro/assets/services/sharp'; +import { baseDevService } from './shared-dev-service.js'; + +const service: LocalImageService = { + ...baseDevService, + getHTMLAttributes(options, serviceOptions) { + const { inputtedWidth, ...props } = options; + + // If `validateOptions` returned a different width than the one of the image, use it for attributes + if (inputtedWidth) { + props.width = inputtedWidth; + } + + return sharpService.getHTMLAttributes + ? sharpService.getHTMLAttributes(props, serviceOptions) + : {}; + }, + transform(inputBuffer, transform, serviceOptions) { + // NOTE: Hardcoding webp here isn't accurate to how the Vercel Image Optimization API works, normally what we should + // do is setup a custom endpoint that sniff the user's accept-content header and serve the proper format based on the + // user's Vercel config. However, that's: a lot of work for: not much. The dev service is inaccurate to the prod service + // in many more ways, this is one of the less offending cases and is, imo, okay, erika - 2023-04-27 + transform.format = transform.src.endsWith('svg') ? 'svg' : 'webp'; + + // The base sharp service works the same way as the Vercel Image Optimization API, so it's a safe fallback in local + return sharpService.transform(inputBuffer, transform, serviceOptions); + }, +}; + +export default service; diff --git a/packages/integrations/vercel/src/image/shared-dev-service.ts b/packages/integrations/vercel/src/image/shared-dev-service.ts new file mode 100644 index 000000000..ee6d8149d --- /dev/null +++ b/packages/integrations/vercel/src/image/shared-dev-service.ts @@ -0,0 +1,35 @@ +import type { LocalImageService } from 'astro'; +import { baseService } from 'astro/assets'; +import { sharedValidateOptions } from './shared.js'; + +export const baseDevService: Omit<LocalImageService, 'transform'> = { + ...baseService, + validateOptions: (options, serviceOptions) => + sharedValidateOptions(options, serviceOptions.service.config, 'development'), + getURL(options) { + const fileSrc = typeof options.src === 'string' ? options.src : options.src.src; + + const searchParams = new URLSearchParams(); + searchParams.append('href', fileSrc); + + options.width && searchParams.append('w', options.width.toString()); + options.quality && searchParams.append('q', options.quality.toString()); + + return '/_image?' + searchParams; + }, + parseURL(url) { + const params = url.searchParams; + + if (!params.has('href')) { + return undefined; + } + + const transform = { + src: params.get('href')!, + width: params.has('w') ? Number.parseInt(params.get('w')!) : undefined, + quality: params.get('q'), + }; + + return transform; + }, +}; diff --git a/packages/integrations/vercel/src/image/shared.ts b/packages/integrations/vercel/src/image/shared.ts new file mode 100644 index 000000000..e214cfa42 --- /dev/null +++ b/packages/integrations/vercel/src/image/shared.ts @@ -0,0 +1,172 @@ +import type { AstroConfig, ImageQualityPreset, ImageTransform } from 'astro'; +import { isESMImportedImage } from 'astro/assets/utils'; + +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 type DevImageService = 'sharp' | (string & {}); + +// https://vercel.com/docs/build-output-api/v3/configuration#images +type ImageFormat = 'image/avif' | 'image/webp'; + +export 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; +}; + +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'], + responsiveImages?: boolean, +) { + let devService = '@astrojs/vercel/dev-image-service'; + + switch (devImageService) { + case 'sharp': + devService = '@astrojs/vercel/dev-image-service'; + break; + default: + if (typeof devImageService === 'string') { + devService = devImageService; + } else { + devService = '@astrojs/vercel/dev-image-service'; + } + break; + } + + if (images) { + const config = imagesConfig ? imagesConfig : getDefaultImageConfig(astroImageConfig); + return { + image: { + service: { + entrypoint: command === 'dev' ? devService : '@astrojs/vercel/build-image-service', + config, + }, + experimentalBreakpoints: responsiveImages ? config.sizes : undefined, + }, + }; + } + + 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.widths) { + // Vercel only supports a fixed set of widths, so remove any that aren't in the list + options.widths = options.widths.filter((w) => configuredWidths.includes(w)); + // Oh no, we've removed all the widths! Let's add the nearest one back in + if (options.widths.length === 0) { + options.widths = [options.width]; + } + } + + 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; +} |