UNPKG

astro

Version:

Astro is a modern site builder with web best practices, performance, and DX front-of-mind.

241 lines (240 loc) • 10.7 kB
import { isRemotePath } from "@astrojs/internal-helpers/path"; import { isRemoteAllowed } from "@astrojs/internal-helpers/remote"; import { AstroError, AstroErrorData } from "../core/errors/index.js"; import { isCollectingStaticImages, recordStaticImage } from "../core/render-scope/record.js"; import { DEFAULT_HASH_PROPS } from "./consts.js"; import { DEFAULT_RESOLUTIONS, getSizesAttribute, getWidths, LIMITED_RESOLUTIONS } from "./layout.js"; import { isLocalService } from "./services/service.js"; import { isImageMetadata } from "./types.js"; import { getUntrackedImage } from "./utils/image-asset.js"; import { isESMImportedImage, isRemoteImage, resolveSrc } from "./utils/imageKind.js"; import { resolveDefaultOutputFormat } from "./utils/inferSourceFormat.js"; import { inferRemoteSize } from "./utils/remoteProbe.js"; import { resolveStaticImage } from "./utils/static-image.js"; import { createPlaceholderURL, stringifyPlaceholderURL } from "./utils/url.js"; export { verifyOptions } from "./services/service.js"; export const cssFitValues = [ "fill", "contain", "cover", "scale-down" ]; let configuredImageService; export async function getConfiguredImageService() { if (!configuredImageService) { const { default: service } = await import( // @ts-expect-error "virtual:image-service" ).catch((e) => { const error = new AstroError(AstroErrorData.InvalidImageService); error.cause = e; throw error; }); configuredImageService = service; } return configuredImageService; } // The build only writes the images it collected, so static URLs anywhere else would 404. function getStaticImageConfig(imageConfig) { return isCollectingStaticImages() ? imageConfig.staticImageConfig : undefined; } /** Test-only: override the image service `getImage()` uses. */ export function setConfiguredImageService(service) { configuredImageService = service; } export async function getImage(options, imageConfig, logger) { if (!options || typeof options !== "object") { throw new AstroError({ ...AstroErrorData.ExpectedImageOptions, message: AstroErrorData.ExpectedImageOptions.message(JSON.stringify(options)) }); } if (typeof options.src === "undefined") { throw new AstroError({ ...AstroErrorData.ExpectedImage, message: AstroErrorData.ExpectedImage.message(options.src, "undefined", JSON.stringify(options)) }); } if (isImageMetadata(options)) { throw new AstroError(AstroErrorData.ExpectedNotESMImage); } const service = await getConfiguredImageService(); // If the user inlined an import, something fairly common especially in MDX, or passed a function that returns an Image, await it for them const resolvedOptions = { ...options, src: await resolveSrc(options.src) }; let originalWidth; let originalHeight; // Infer size for remote images if inferSize is true if (resolvedOptions.inferSize) { delete resolvedOptions.inferSize; if (isRemoteImage(resolvedOptions.src) && isRemotePath(resolvedOptions.src)) { if (!isRemoteAllowed(resolvedOptions.src, imageConfig)) { throw new AstroError({ ...AstroErrorData.RemoteImageNotAllowed, message: AstroErrorData.RemoteImageNotAllowed.message(resolvedOptions.src) }); } const getRemoteSize = (url) => service.getRemoteSize?.(url, imageConfig, logger) ?? inferRemoteSize(url, imageConfig); const result = await getRemoteSize(resolvedOptions.src); resolvedOptions.width ??= result.width; resolvedOptions.height ??= result.height; // We've already paid for the fetch; reuse it to pin down the output format so the URL // (and any baked filename) doesn't have to defer or refetch. if (result.format) { resolvedOptions.format ??= resolveDefaultOutputFormat(result.format); } originalWidth = result.width; originalHeight = result.height; } } const originalFilePath = isESMImportedImage(resolvedOptions.src) ? resolvedOptions.src.fsPath : undefined; // Services reading `src` must not keep the original in the build output. const clonedSrc = getUntrackedImage(resolvedOptions.src); if (isESMImportedImage(clonedSrc)) { originalWidth = clonedSrc.width; originalHeight = clonedSrc.height; } if (originalWidth && originalHeight) { // Calculate any missing dimensions from the aspect ratio, if available const aspectRatio = originalWidth / originalHeight; if (resolvedOptions.height && !resolvedOptions.width) { resolvedOptions.width = Math.round(resolvedOptions.height * aspectRatio); } else if (resolvedOptions.width && !resolvedOptions.height) { resolvedOptions.height = Math.round(resolvedOptions.width / aspectRatio); } else if (!resolvedOptions.width && !resolvedOptions.height) { resolvedOptions.width = originalWidth; resolvedOptions.height = originalHeight; } } resolvedOptions.src = clonedSrc; const layout = options.layout ?? imageConfig.layout ?? "none"; if (resolvedOptions.priority) { resolvedOptions.loading ??= "eager"; resolvedOptions.decoding ??= "sync"; resolvedOptions.fetchpriority ??= "high"; delete resolvedOptions.priority; } else { resolvedOptions.loading ??= "lazy"; resolvedOptions.decoding ??= "async"; // Omit fetchpriority to use the default `"auto"` value resolvedOptions.fetchpriority ??= undefined; } if (layout !== "none") { resolvedOptions.widths ||= getWidths({ width: resolvedOptions.width, layout, originalWidth, breakpoints: imageConfig.breakpoints?.length ? imageConfig.breakpoints : isLocalService(service) ? LIMITED_RESOLUTIONS : DEFAULT_RESOLUTIONS }); resolvedOptions.sizes ||= getSizesAttribute({ width: resolvedOptions.width, layout }); // The densities option is incompatible with the `layout` option delete resolvedOptions.densities; // Set data attribute for layout resolvedOptions["data-astro-image"] = layout; // Set data attributes for fit and position for CSP-compliant styling if (resolvedOptions.fit && cssFitValues.includes(resolvedOptions.fit)) { resolvedOptions["data-astro-image-fit"] = resolvedOptions.fit; } // Always output 'data-astro-image-pos', defaulting to 'center' if unspecified. // The corresponding CSS rules are generated by generateImageStylesCSS() and delivered // via a virtual CSS module, avoiding inline styles for CSP compliance. const currentPosition = resolvedOptions.position || "center"; resolvedOptions["data-astro-image-pos"] = currentPosition.replace(/\s+/g, "-"); } const validatedOptions = service.validateOptions ? await service.validateOptions(resolvedOptions, imageConfig, logger) : resolvedOptions; validatedOptions.format ??= await peekRemoteFormatForStaticEmit(validatedOptions, imageConfig, service, logger); // Get all the options for the different srcSets const srcSetTransforms = service.getSrcSet ? await service.getSrcSet(validatedOptions, imageConfig, logger) : []; // In the Picture component, the optimized original-sized image is typically not used when `widths` is set. // Since resolving a static image registers it for generation immediately, // we fetch it lazily to avoid creating unnecessary assets. const lazyImageURLFactory = (getValue) => { let cached = null; return () => cached ??= getValue(); }; const initialImageURL = await service.getURL(validatedOptions, imageConfig, logger); let lazyImageURL = lazyImageURLFactory(() => initialImageURL); const matchesValidatedTransform = (transform) => transform.width === validatedOptions.width && transform.height === validatedOptions.height && transform.format === validatedOptions.format; let srcSets = await Promise.all(srcSetTransforms.map(async (srcSet) => { return { transform: srcSet.transform, url: matchesValidatedTransform(srcSet.transform) ? initialImageURL : await service.getURL(srcSet.transform, imageConfig, logger), descriptor: srcSet.descriptor, attributes: srcSet.attributes }; })); const staticImageConfig = getStaticImageConfig(imageConfig); if (isLocalService(service) && staticImageConfig && !(isRemoteImage(validatedOptions.src) && initialImageURL === validatedOptions.src)) { const propsToHash = service.propertiesToHash ?? DEFAULT_HASH_PROPS; const addStaticImage = (transform) => { const { url, image } = resolveStaticImage(transform, propsToHash, originalFilePath, { ...staticImageConfig, serviceEntrypoint: imageConfig.service.entrypoint, assetQueryParams: imageConfig.assetQueryParams }); // Record dedup hits too: every page using the image needs it attributed. recordStaticImage(image); return url; }; lazyImageURL = lazyImageURLFactory(() => addStaticImage(validatedOptions)); srcSets = srcSetTransforms.map((srcSet) => { return { transform: srcSet.transform, url: matchesValidatedTransform(srcSet.transform) ? lazyImageURL() : addStaticImage(srcSet.transform), descriptor: srcSet.descriptor, attributes: srcSet.attributes }; }); } else if (imageConfig.assetQueryParams) { // For SSR-rendered images, append assetQueryParams manually const imageURLObj = createPlaceholderURL(initialImageURL); imageConfig.assetQueryParams.forEach((value, key) => { imageURLObj.searchParams.set(key, value); }); lazyImageURL = lazyImageURLFactory(() => stringifyPlaceholderURL(imageURLObj)); srcSets = srcSets.map((srcSet) => { const urlObj = createPlaceholderURL(srcSet.url); imageConfig.assetQueryParams.forEach((value, key) => { urlObj.searchParams.set(key, value); }); return { ...srcSet, url: stringifyPlaceholderURL(urlObj) }; }); } return { rawOptions: resolvedOptions, options: validatedOptions, get src() { return lazyImageURL(); }, srcSet: { values: srcSets, attribute: srcSets.map((srcSet) => `${srcSet.url} ${srcSet.descriptor}`).join(", ") }, attributes: service.getHTMLAttributes !== undefined ? await service.getHTMLAttributes(validatedOptions, imageConfig, logger) : {} }; } /** * Build-time only: probes the remote URL via `getRemoteSize` so we can commit a real `format` * before the filename is hashed. Returns undefined when no peek applies (SSR, non-remote, * disallowed remote, external service, missing `getRemoteSize`, or probe failure) — the * transform service handles those by detecting from the actual buffer. */ async function peekRemoteFormatForStaticEmit(options, imageConfig, service, logger) { if (!isRemoteImage(options.src) || !isRemoteAllowed(options.src, imageConfig) || !getStaticImageConfig(imageConfig) || !isLocalService(service) || !service.getRemoteSize) { return undefined; } try { const probed = await service.getRemoteSize(options.src, imageConfig, logger); return resolveDefaultOutputFormat(probed.format); } catch { return undefined; } }