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
JavaScript
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;
}
}