summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.changeset/proud-terms-swim.md5
-rw-r--r--packages/astro/client.d.ts4
-rw-r--r--packages/astro/components/Image.astro29
-rw-r--r--packages/astro/components/Picture.astro39
-rw-r--r--packages/astro/components/image.css17
-rw-r--r--packages/astro/src/assets/consts.ts10
-rw-r--r--packages/astro/src/assets/internal.ts102
-rw-r--r--packages/astro/src/assets/layout.ts118
-rw-r--r--packages/astro/src/assets/services/service.ts118
-rw-r--r--packages/astro/src/assets/services/sharp.ts42
-rw-r--r--packages/astro/src/assets/types.ts59
-rw-r--r--packages/astro/src/assets/utils/imageAttributes.ts49
-rw-r--r--packages/astro/src/assets/vite-plugin-assets.ts4
-rw-r--r--packages/astro/src/core/config/schema.ts24
-rw-r--r--packages/astro/src/runtime/server/render/util.ts3
-rw-r--r--packages/astro/src/types/public/config.ts165
-rw-r--r--packages/astro/test/content-collections-render.test.js10
-rw-r--r--packages/astro/test/core-image-layout.test.js579
-rw-r--r--packages/astro/test/core-image-service.test.js206
-rw-r--r--packages/astro/test/fixtures/core-image-layout/astro.config.mjs12
-rw-r--r--packages/astro/test/fixtures/core-image-layout/package.json8
-rw-r--r--packages/astro/test/fixtures/core-image-layout/src/assets/penguin.jpgbin0 -> 258734 bytes
-rw-r--r--packages/astro/test/fixtures/core-image-layout/src/assets/walrus.jpgbin0 -> 46343 bytes
-rw-r--r--packages/astro/test/fixtures/core-image-layout/src/pages/both.astro19
-rw-r--r--packages/astro/test/fixtures/core-image-layout/src/pages/build.astro66
-rw-r--r--packages/astro/test/fixtures/core-image-layout/src/pages/fit.astro35
-rw-r--r--packages/astro/test/fixtures/core-image-layout/src/pages/index.astro56
-rw-r--r--packages/astro/test/fixtures/core-image-layout/src/pages/picture.astro63
-rw-r--r--packages/astro/test/fixtures/core-image-layout/src/pages/remote.astro25
-rw-r--r--packages/astro/test/fixtures/core-image-layout/tsconfig.json11
-rw-r--r--packages/astro/test/ssr-assets.test.js2
-rw-r--r--packages/astro/test/test-remote-image-service.js26
-rw-r--r--packages/astro/test/units/dev/collections-renderentry.test.js8
-rw-r--r--packages/integrations/markdoc/test/image-assets.test.js4
-rw-r--r--packages/integrations/markdoc/test/propagated-assets.test.js12
-rw-r--r--packages/integrations/mdx/test/css-head-mdx.test.js12
-rw-r--r--packages/integrations/mdx/test/mdx-math.test.js4
-rw-r--r--pnpm-lock.yaml6
38 files changed, 1845 insertions, 107 deletions
diff --git a/.changeset/proud-terms-swim.md b/.changeset/proud-terms-swim.md
new file mode 100644
index 000000000..e33b3d1af
--- /dev/null
+++ b/.changeset/proud-terms-swim.md
@@ -0,0 +1,5 @@
+---
+'astro': patch
+---
+
+Adds experimental reponsive image support
diff --git a/packages/astro/client.d.ts b/packages/astro/client.d.ts
index 19e10694f..a2e4cf0eb 100644
--- a/packages/astro/client.d.ts
+++ b/packages/astro/client.d.ts
@@ -47,7 +47,9 @@ declare module 'astro:assets' {
getImage: (
options: import('./dist/assets/types.js').UnresolvedImageTransform,
) => Promise<import('./dist/assets/types.js').GetImageResult>;
- imageConfig: import('./dist/types/public/config.js').AstroConfig['image'];
+ imageConfig: import('./dist/types/public/config.js').AstroConfig['image'] & {
+ experimentalResponsiveImages: boolean;
+ };
getConfiguredImageService: typeof import('./dist/assets/index.js').getConfiguredImageService;
inferRemoteSize: typeof import('./dist/assets/utils/index.js').inferRemoteSize;
Image: typeof import('./components/Image.astro').default;
diff --git a/packages/astro/components/Image.astro b/packages/astro/components/Image.astro
index 4e55f5608..6e7a83751 100644
--- a/packages/astro/components/Image.astro
+++ b/packages/astro/components/Image.astro
@@ -1,7 +1,10 @@
---
-import { type LocalImageProps, type RemoteImageProps, getImage } from 'astro:assets';
+import { type LocalImageProps, type RemoteImageProps, getImage, imageConfig } from 'astro:assets';
+import type { UnresolvedImageTransform } from '../dist/assets/types';
+import { applyResponsiveAttributes } from '../dist/assets/utils/imageAttributes.js';
import { AstroError, AstroErrorData } from '../dist/core/errors/index.js';
import type { HTMLAttributes } from '../types';
+import './image.css';
// The TypeScript diagnostic for JSX props uses the last member of the union to suggest props, so it would be better for
// LocalImageProps to be last. Unfortunately, when we do this the error messages that remote images get are complete nonsense
@@ -23,7 +26,17 @@ if (typeof props.height === 'string') {
props.height = parseInt(props.height);
}
-const image = await getImage(props);
+const layout = props.layout ?? imageConfig.experimentalLayout ?? 'none';
+const useResponsive = imageConfig.experimentalResponsiveImages && layout !== 'none';
+
+if (useResponsive) {
+ // Apply defaults from imageConfig if not provided
+ props.layout ??= imageConfig.experimentalLayout;
+ props.fit ??= imageConfig.experimentalObjectFit ?? 'cover';
+ props.position ??= imageConfig.experimentalObjectPosition ?? 'center';
+}
+
+const image = await getImage(props as UnresolvedImageTransform);
const additionalAttributes: HTMLAttributes<'img'> = {};
if (image.srcSet.values.length > 0) {
@@ -33,6 +46,16 @@ if (image.srcSet.values.length > 0) {
if (import.meta.env.DEV) {
additionalAttributes['data-image-component'] = 'true';
}
+
+const attributes = useResponsive
+ ? applyResponsiveAttributes({
+ layout,
+ image,
+ props,
+ additionalAttributes,
+ })
+ : { ...additionalAttributes, ...image.attributes };
---
-<img src={image.src} {...additionalAttributes} {...image.attributes} />
+{/* Applying class outside of the spread prevents it from applying unnecessary astro-* classes */}
+<img src={image.src} {...attributes} class={attributes.class} />
diff --git a/packages/astro/components/Picture.astro b/packages/astro/components/Picture.astro
index 73459db04..5d0382379 100644
--- a/packages/astro/components/Picture.astro
+++ b/packages/astro/components/Picture.astro
@@ -1,10 +1,16 @@
---
-import { type LocalImageProps, type RemoteImageProps, getImage } from 'astro:assets';
+import { type LocalImageProps, type RemoteImageProps, getImage, imageConfig } from 'astro:assets';
import * as mime from 'mrmime';
+import { applyResponsiveAttributes } from '../dist/assets/utils/imageAttributes';
import { isESMImportedImage, resolveSrc } from '../dist/assets/utils/imageKind';
import { AstroError, AstroErrorData } from '../dist/core/errors/index.js';
-import type { GetImageResult, ImageOutputFormat } from '../dist/types/public/index.js';
+import type {
+ GetImageResult,
+ ImageOutputFormat,
+ UnresolvedImageTransform,
+} from '../dist/types/public/index.js';
import type { HTMLAttributes } from '../types';
+import './image.css';
type Props = (LocalImageProps | RemoteImageProps) & {
formats?: ImageOutputFormat[];
@@ -37,6 +43,17 @@ if (scopedStyleClass) {
pictureAttributes.class = scopedStyleClass;
}
}
+
+const layout = props.layout ?? imageConfig.experimentalLayout ?? 'none';
+const useResponsive = imageConfig.experimentalResponsiveImages && layout !== 'none';
+
+if (useResponsive) {
+ // Apply defaults from imageConfig if not provided
+ props.layout ??= imageConfig.experimentalLayout;
+ props.fit ??= imageConfig.experimentalObjectFit ?? 'cover';
+ props.position ??= imageConfig.experimentalObjectPosition ?? 'center';
+}
+
for (const key in props) {
if (key.startsWith('data-astro-cid')) {
pictureAttributes[key] = props[key];
@@ -53,7 +70,7 @@ const optimizedImages: GetImageResult[] = await Promise.all(
format: format,
widths: props.widths,
densities: props.densities,
- }),
+ } as UnresolvedImageTransform),
),
);
@@ -71,7 +88,7 @@ const fallbackImage = await getImage({
format: resultFallbackFormat,
widths: props.widths,
densities: props.densities,
-});
+} as UnresolvedImageTransform);
const imgAdditionalAttributes: HTMLAttributes<'img'> = {};
const sourceAdditionalAttributes: HTMLAttributes<'source'> = {};
@@ -85,6 +102,15 @@ if (fallbackImage.srcSet.values.length > 0) {
imgAdditionalAttributes.srcset = fallbackImage.srcSet.attribute;
}
+const attributes = useResponsive
+ ? applyResponsiveAttributes({
+ layout,
+ image: fallbackImage,
+ props,
+ additionalAttributes: imgAdditionalAttributes,
+ })
+ : { ...imgAdditionalAttributes, ...fallbackImage.attributes };
+
if (import.meta.env.DEV) {
imgAdditionalAttributes['data-image-component'] = 'true';
}
@@ -94,7 +120,7 @@ if (import.meta.env.DEV) {
{
Object.entries(optimizedImages).map(([_, image]) => {
const srcsetAttribute =
- props.densities || (!props.densities && !props.widths)
+ props.densities || (!props.densities && !props.widths && !useResponsive)
? `${image.src}${image.srcSet.values.length > 0 ? ', ' + image.srcSet.attribute : ''}`
: image.srcSet.attribute;
return (
@@ -106,5 +132,6 @@ if (import.meta.env.DEV) {
);
})
}
- <img src={fallbackImage.src} {...imgAdditionalAttributes} {...fallbackImage.attributes} />
+ {/* Applying class outside of the spread prevents it from applying unnecessary astro-* classes */}
+ <img src={fallbackImage.src} {...attributes} class={attributes.class} />
</picture>
diff --git a/packages/astro/components/image.css b/packages/astro/components/image.css
new file mode 100644
index 000000000..d748ba7d5
--- /dev/null
+++ b/packages/astro/components/image.css
@@ -0,0 +1,17 @@
+[data-astro-image] {
+ width: 100%;
+ height: auto;
+ object-fit: var(--fit);
+ object-position: var(--pos);
+ aspect-ratio: var(--w) / var(--h);
+}
+/* Styles for responsive layout */
+[data-astro-image='responsive'] {
+ max-width: calc(var(--w) * 1px);
+ max-height: calc(var(--h) * 1px);
+}
+/* Styles for fixed layout */
+[data-astro-image='fixed'] {
+ width: calc(var(--w) * 1px);
+ height: calc(var(--h) * 1px);
+}
diff --git a/packages/astro/src/assets/consts.ts b/packages/astro/src/assets/consts.ts
index 15f9fe46f..5fae641ae 100644
--- a/packages/astro/src/assets/consts.ts
+++ b/packages/astro/src/assets/consts.ts
@@ -26,4 +26,12 @@ export const VALID_SUPPORTED_FORMATS = [
] as const;
export const DEFAULT_OUTPUT_FORMAT = 'webp' as const;
export const VALID_OUTPUT_FORMATS = ['avif', 'png', 'webp', 'jpeg', 'jpg', 'svg'] as const;
-export const DEFAULT_HASH_PROPS = ['src', 'width', 'height', 'format', 'quality'];
+export const DEFAULT_HASH_PROPS = [
+ 'src',
+ 'width',
+ 'height',
+ 'format',
+ 'quality',
+ 'fit',
+ 'position',
+];
diff --git a/packages/astro/src/assets/internal.ts b/packages/astro/src/assets/internal.ts
index 07584c4e5..3363a5648 100644
--- a/packages/astro/src/assets/internal.ts
+++ b/packages/astro/src/assets/internal.ts
@@ -2,6 +2,12 @@ import { isRemotePath } from '@astrojs/internal-helpers/path';
import { AstroError, AstroErrorData } from '../core/errors/index.js';
import type { AstroConfig } from '../types/public/config.js';
import { DEFAULT_HASH_PROPS } from './consts.js';
+import {
+ DEFAULT_RESOLUTIONS,
+ LIMITED_RESOLUTIONS,
+ getSizesAttribute,
+ getWidths,
+} from './layout.js';
import { type ImageService, isLocalService } from './services/service.js';
import {
type GetImageResult,
@@ -32,9 +38,13 @@ export async function getConfiguredImageService(): Promise<ImageService> {
return globalThis.astroAsset.imageService;
}
+type ImageConfig = AstroConfig['image'] & {
+ experimentalResponsiveImages: boolean;
+};
+
export async function getImage(
options: UnresolvedImageTransform,
- imageConfig: AstroConfig['image'],
+ imageConfig: ImageConfig,
): Promise<GetImageResult> {
if (!options || typeof options !== 'object') {
throw new AstroError({
@@ -65,6 +75,10 @@ export async function getImage(
src: await resolveSrc(options.src),
};
+ let originalWidth: number | undefined;
+ let originalHeight: number | undefined;
+ let originalFormat: string | undefined;
+
// Infer size for remote images if inferSize is true
if (
options.inferSize &&
@@ -74,6 +88,9 @@ export async function getImage(
const result = await inferRemoteSize(resolvedOptions.src); // Directly probe the image URL
resolvedOptions.width ??= result.width;
resolvedOptions.height ??= result.height;
+ originalWidth = result.width;
+ originalHeight = result.height;
+ originalFormat = result.format;
delete resolvedOptions.inferSize; // Delete so it doesn't end up in the attributes
}
@@ -88,8 +105,53 @@ export async function getImage(
(resolvedOptions.src.clone ?? resolvedOptions.src)
: resolvedOptions.src;
+ if (isESMImportedImage(clonedSrc)) {
+ originalWidth = clonedSrc.width;
+ originalHeight = clonedSrc.height;
+ originalFormat = clonedSrc.format;
+ }
+
+ 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.experimentalLayout;
+
+ if (imageConfig.experimentalResponsiveImages && layout) {
+ resolvedOptions.widths ||= getWidths({
+ width: resolvedOptions.width,
+ layout,
+ originalWidth,
+ breakpoints: imageConfig.experimentalBreakpoints?.length
+ ? imageConfig.experimentalBreakpoints
+ : isLocalService(service)
+ ? LIMITED_RESOLUTIONS
+ : DEFAULT_RESOLUTIONS,
+ });
+ resolvedOptions.sizes ||= getSizesAttribute({ width: resolvedOptions.width, layout });
+
+ if (resolvedOptions.priority) {
+ resolvedOptions.loading ??= 'eager';
+ resolvedOptions.decoding ??= 'sync';
+ resolvedOptions.fetchpriority ??= 'high';
+ } else {
+ resolvedOptions.loading ??= 'lazy';
+ resolvedOptions.decoding ??= 'async';
+ resolvedOptions.fetchpriority ??= 'auto';
+ }
+ delete resolvedOptions.priority;
+ }
+
const validatedOptions = service.validateOptions
? await service.validateOptions(resolvedOptions, imageConfig)
: resolvedOptions;
@@ -100,13 +162,23 @@ export async function getImage(
: [];
let imageURL = await service.getURL(validatedOptions, imageConfig);
+
+ const matchesOriginal = (transform: ImageTransform) =>
+ transform.width === originalWidth &&
+ transform.height === originalHeight &&
+ transform.format === originalFormat;
+
let srcSets: SrcSetValue[] = await Promise.all(
- srcSetTransforms.map(async (srcSet) => ({
- transform: srcSet.transform,
- url: await service.getURL(srcSet.transform, imageConfig),
- descriptor: srcSet.descriptor,
- attributes: srcSet.attributes,
- })),
+ srcSetTransforms.map(async (srcSet) => {
+ return {
+ transform: srcSet.transform,
+ url: matchesOriginal(srcSet.transform)
+ ? imageURL
+ : await service.getURL(srcSet.transform, imageConfig),
+ descriptor: srcSet.descriptor,
+ attributes: srcSet.attributes,
+ };
+ }),
);
if (
@@ -120,12 +192,16 @@ export async function getImage(
propsToHash,
originalFilePath,
);
- srcSets = srcSetTransforms.map((srcSet) => ({
- transform: srcSet.transform,
- url: globalThis.astroAsset.addStaticImage!(srcSet.transform, propsToHash, originalFilePath),
- descriptor: srcSet.descriptor,
- attributes: srcSet.attributes,
- }));
+ srcSets = srcSetTransforms.map((srcSet) => {
+ return {
+ transform: srcSet.transform,
+ url: matchesOriginal(srcSet.transform)
+ ? imageURL
+ : globalThis.astroAsset.addStaticImage!(srcSet.transform, propsToHash, originalFilePath),
+ descriptor: srcSet.descriptor,
+ attributes: srcSet.attributes,
+ };
+ });
}
return {
diff --git a/packages/astro/src/assets/layout.ts b/packages/astro/src/assets/layout.ts
new file mode 100644
index 000000000..adc117f39
--- /dev/null
+++ b/packages/astro/src/assets/layout.ts
@@ -0,0 +1,118 @@
+import type { ImageLayout } from './types.js';
+
+// Common screen widths. These will be filtered according to the image size and layout
+export const DEFAULT_RESOLUTIONS = [
+ 640, // older and lower-end phones
+ 750, // iPhone 6-8
+ 828, // iPhone XR/11
+ 960, // older horizontal phones
+ 1080, // iPhone 6-8 Plus
+ 1280, // 720p
+ 1668, // Various iPads
+ 1920, // 1080p
+ 2048, // QXGA
+ 2560, // WQXGA
+ 3200, // QHD+
+ 3840, // 4K
+ 4480, // 4.5K
+ 5120, // 5K
+ 6016, // 6K
+];
+
+// A more limited set of screen widths, for statically generated images
+export const LIMITED_RESOLUTIONS = [
+ 640, // older and lower-end phones
+ 750, // iPhone 6-8
+ 828, // iPhone XR/11
+ 1080, // iPhone 6-8 Plus
+ 1280, // 720p
+ 1668, // Various iPads
+ 2048, // QXGA
+ 2560, // WQXGA
+];
+
+/**
+ * Gets the breakpoints for an image, based on the layout and width
+ *
+ * The rules are as follows:
+ *
+ * - For full-width layout we return all breakpoints smaller than the original image width
+ * - For fixed layout we return 1x and 2x the requested width, unless the original image is smaller than that.
+ * - For responsive layout we return all breakpoints smaller than 2x the requested width, unless the original image is smaller than that.
+ */
+export const getWidths = ({
+ width,
+ layout,
+ breakpoints = DEFAULT_RESOLUTIONS,
+ originalWidth,
+}: {
+ width?: number;
+ layout: ImageLayout;
+ breakpoints?: Array<number>;
+ originalWidth?: number;
+}): Array<number> => {
+ const smallerThanOriginal = (w: number) => !originalWidth || w <= originalWidth;
+
+ // For full-width layout we return all breakpoints smaller than the original image width
+ if (layout === 'full-width') {
+ return breakpoints.filter(smallerThanOriginal);
+ }
+ // For other layouts we need a width to generate breakpoints. If no width is provided, we return an empty array
+ if (!width) {
+ return [];
+ }
+ const doubleWidth = width * 2;
+ // For fixed layout we want to return the 1x and 2x widths. We only do this if the original image is large enough to do this though.
+ const maxSize = originalWidth ? Math.min(doubleWidth, originalWidth) : doubleWidth;
+ if (layout === 'fixed') {
+ return originalWidth && width > originalWidth ? [originalWidth] : [width, maxSize];
+ }
+
+ // For responsive layout we want to return all breakpoints smaller than 2x requested width.
+ if (layout === 'responsive') {
+ return (
+ [
+ // Always include the image at 1x and 2x the specified width
+ width,
+ doubleWidth,
+ ...breakpoints,
+ ]
+ // Filter out any resolutions that are larger than the double-resolution image or source image
+ .filter((w) => w <= maxSize)
+ // Sort the resolutions in ascending order
+ .sort((a, b) => a - b)
+ );
+ }
+
+ return [];
+};
+
+/**
+ * Gets the `sizes` attribute for an image, based on the layout and width
+ */
+export const getSizesAttribute = ({
+ width,
+ layout,
+}: { width?: number; layout?: ImageLayout }): string | undefined => {
+ if (!width || !layout) {
+ return undefined;
+ }
+ switch (layout) {
+ // If screen is wider than the max size then image width is the max size,
+ // otherwise it's the width of the screen
+ case `responsive`:
+ return `(min-width: ${width}px) ${width}px, 100vw`;
+
+ // Image is always the same width, whatever the size of the screen
+ case `fixed`:
+ return `${width}px`;
+
+ // Image is always the width of the screen
+ case `full-width`:
+ return `100vw`;
+
+ case 'none':
+ default:
+ return undefined;
+ }
+};
diff --git a/packages/astro/src/assets/services/service.ts b/packages/astro/src/assets/services/service.ts
index e22bada89..d84ec1728 100644
--- a/packages/astro/src/assets/services/service.ts
+++ b/packages/astro/src/assets/services/service.ts
@@ -2,7 +2,12 @@ import { AstroError, AstroErrorData } from '../../core/errors/index.js';
import { isRemotePath, joinPaths } from '../../core/path.js';
import type { AstroConfig } from '../../types/public/config.js';
import { DEFAULT_HASH_PROPS, DEFAULT_OUTPUT_FORMAT, VALID_SUPPORTED_FORMATS } from '../consts.js';
-import type { ImageOutputFormat, ImageTransform, UnresolvedSrcSetValue } from '../types.js';
+import type {
+ ImageFit,
+ ImageOutputFormat,
+ ImageTransform,
+ UnresolvedSrcSetValue,
+} from '../types.js';
import { isESMImportedImage } from '../utils/imageKind.js';
import { isRemoteAllowed } from '../utils/remotePattern.js';
@@ -116,8 +121,12 @@ export type BaseServiceTransform = {
height?: number;
format: string;
quality?: string | null;
+ fit?: ImageFit;
+ position?: string;
};
+const sortNumeric = (a: number, b: number) => a - b;
+
/**
* Basic local service using the included `_image` endpoint.
* This service intentionally does not implement `transform`.
@@ -219,14 +228,32 @@ export const baseService: Omit<LocalImageService, 'transform'> = {
// Sometimes users will pass number generated from division, which can result in floating point numbers
if (options.width) options.width = Math.round(options.width);
if (options.height) options.height = Math.round(options.height);
-
+ if (options.layout && options.width && options.height) {
+ options.fit ??= 'cover';
+ delete options.layout;
+ }
+ if (options.fit === 'none') {
+ delete options.fit;
+ }
return options;
},
getHTMLAttributes(options) {
const { targetWidth, targetHeight } = getTargetDimensions(options);
- const { src, width, height, format, quality, densities, widths, formats, ...attributes } =
- options;
-
+ const {
+ src,
+ width,
+ height,
+ format,
+ quality,
+ densities,
+ widths,
+ formats,
+ layout,
+ priority,
+ fit,
+ position,
+ ...attributes
+ } = options;
return {
...attributes,
width: targetWidth,
@@ -235,12 +262,14 @@ export const baseService: Omit<LocalImageService, 'transform'> = {
decoding: attributes.decoding ?? 'async',
};
},
- getSrcSet(options) {
- const srcSet: UnresolvedSrcSetValue[] = [];
- const { targetWidth } = getTargetDimensions(options);
+ getSrcSet(options): Array<UnresolvedSrcSetValue> {
+ const { targetWidth, targetHeight } = getTargetDimensions(options);
+ const aspectRatio = targetWidth / targetHeight;
const { widths, densities } = options;
const targetFormat = options.format ?? DEFAULT_OUTPUT_FORMAT;
+ let transformedWidths = (widths ?? []).sort(sortNumeric);
+
// For remote images, we don't know the original image's dimensions, so we cannot know the maximum width
// It is ultimately the user's responsibility to make sure they don't request images larger than the original
let imageWidth = options.width;
@@ -250,8 +279,18 @@ export const baseService: Omit<LocalImageService, 'transform'> = {
if (isESMImportedImage(options.src)) {
imageWidth = options.src.width;
maxWidth = imageWidth;
+
+ // We've already sorted the widths, so we'll remove any that are larger than the original image's width
+ if (transformedWidths.length > 0 && transformedWidths.at(-1)! > maxWidth) {
+ transformedWidths = transformedWidths.filter((width) => width <= maxWidth);
+ // If we've had to remove some widths, we'll add the maximum width back in
+ transformedWidths.push(maxWidth);
+ }
}
+ // Dedupe the widths
+ transformedWidths = Array.from(new Set(transformedWidths));
+
// Since `widths` and `densities` ultimately control the width and height of the image,
// we don't want the dimensions the user specified, we'll create those ourselves.
const {
@@ -261,7 +300,10 @@ export const baseService: Omit<LocalImageService, 'transform'> = {
} = options;
// Collect widths to generate from specified densities or widths
- const allWidths: { maxTargetWidth: number; descriptor: `${number}x` | `${number}w` }[] = [];
+ let allWidths: Array<{
+ width: number;
+ descriptor: `${number}x` | `${number}w`;
+ }> = [];
if (densities) {
// Densities can either be specified as numbers, or descriptors (ex: '1x'), we'll convert them all to numbers
const densityValues = densities.map((density) => {
@@ -274,51 +316,31 @@ export const baseService: Omit<LocalImageService, 'transform'> = {
// Calculate the widths for each density, rounding to avoid floats.
const densityWidths = densityValues
- .sort()
+ .sort(sortNumeric)
.map((density) => Math.round(targetWidth * density));
- allWidths.push(
- ...densityWidths.map((width, index) => ({
- maxTargetWidth: Math.min(width, maxWidth),
- descriptor: `${densityValues[index]}x` as const,
- })),
- );
- } else if (widths) {
- allWidths.push(
- ...widths.map((width) => ({
- maxTargetWidth: Math.min(width, maxWidth),
- descriptor: `${width}w` as const,
- })),
- );
+ allWidths = densityWidths.map((width, index) => ({
+ width,
+ descriptor: `${densityValues[index]}x`,
+ }));
+ } else if (transformedWidths.length > 0) {
+ allWidths = transformedWidths.map((width) => ({
+ width,
+ descriptor: `${width}w`,
+ }));
}
- // Caution: The logic below is a bit tricky, as we need to make sure we don't generate the same image multiple times
- // When making changes, make sure to test with different combinations of local/remote images widths, densities, and dimensions etc.
- for (const { maxTargetWidth, descriptor } of allWidths) {
- const srcSetTransform: ImageTransform = { ...transformWithoutDimensions };
-
- // Only set the width if it's different from the original image's width, to avoid generating the same image multiple times
- if (maxTargetWidth !== imageWidth) {
- srcSetTransform.width = maxTargetWidth;
- } else {
- // If the width is the same as the original image's width, and we have both dimensions, it probably means
- // it's a remote image, so we'll use the user's specified dimensions to avoid recreating the original image unnecessarily
- if (options.width && options.height) {
- srcSetTransform.width = options.width;
- srcSetTransform.height = options.height;
- }
- }
-
- srcSet.push({
- transform: srcSetTransform,
+ return allWidths.map(({ width, descriptor }) => {
+ const height = Math.round(width / aspectRatio);
+ const transform = { ...transformWithoutDimensions, width, height };
+ return {
+ transform,
descriptor,
attributes: {
type: `image/${targetFormat}`,
},
- });
- }
-
- return srcSet;
+ };
+ });
},
getURL(options, imageConfig) {
const searchParams = new URLSearchParams();
@@ -337,6 +359,8 @@ export const baseService: Omit<LocalImageService, 'transform'> = {
h: 'height',
q: 'quality',
f: 'format',
+ fit: 'fit',
+ position: 'position',
};
Object.entries(params).forEach(([param, key]) => {
@@ -359,6 +383,8 @@ export const baseService: Omit<LocalImageService, 'transform'> = {
height: params.has('h') ? parseInt(params.get('h')!) : undefined,
format: params.get('f') as ImageOutputFormat,
quality: params.get('q'),
+ fit: params.get('fit') as ImageFit,
+ position: params.get('position') ?? undefined,
};
return transform;
diff --git a/packages/astro/src/assets/services/sharp.ts b/packages/astro/src/assets/services/sharp.ts
index c9df4c269..bbae39eb0 100644
--- a/packages/astro/src/assets/services/sharp.ts
+++ b/packages/astro/src/assets/services/sharp.ts
@@ -1,6 +1,6 @@
-import type { FormatEnum, SharpOptions } from 'sharp';
+import type { FitEnum, FormatEnum, SharpOptions } from 'sharp';
import { AstroError, AstroErrorData } from '../../core/errors/index.js';
-import type { ImageOutputFormat, ImageQualityPreset } from '../types.js';
+import type { ImageFit, ImageOutputFormat, ImageQualityPreset } from '../types.js';
import {
type BaseServiceTransform,
type LocalImageService,
@@ -38,6 +38,16 @@ async function loadSharp() {
return sharpImport;
}
+const fitMap: Record<ImageFit, keyof FitEnum> = {
+ fill: 'fill',
+ contain: 'inside',
+ cover: 'cover',
+ none: 'outside',
+ 'scale-down': 'inside',
+ outside: 'outside',
+ inside: 'inside',
+};
+
const sharpService: LocalImageService<SharpImageServiceConfig> = {
validateOptions: baseService.validateOptions,
getURL: baseService.getURL,
@@ -46,7 +56,6 @@ const sharpService: LocalImageService<SharpImageServiceConfig> = {
getSrcSet: baseService.getSrcSet,
async transform(inputBuffer, transformOptions, config) {
if (!sharp) sharp = await loadSharp();
-
const transform: BaseServiceTransform = transformOptions as BaseServiceTransform;
// Return SVGs as-is
@@ -62,11 +71,30 @@ const sharpService: LocalImageService<SharpImageServiceConfig> = {
// always call rotate to adjust for EXIF data orientation
result.rotate();
- // Never resize using both width and height at the same time, prioritizing width.
- if (transform.height && !transform.width) {
- result.resize({ height: Math.round(transform.height) });
+ // If `fit` isn't set then use old behavior:
+ // - Do not use both width and height for resizing, and prioritize width over height
+ // - Allow enlarging images
+
+ const withoutEnlargement = Boolean(transform.fit);
+ if (transform.width && transform.height && transform.fit) {
+ const fit: keyof FitEnum = fitMap[transform.fit] ?? 'inside';
+ result.resize({
+ width: Math.round(transform.width),
+ height: Math.round(transform.height),
+ fit,
+ position: transform.position,
+ withoutEnlargement,
+ });
+ } else if (transform.height && !transform.width) {
+ result.resize({
+ height: Math.round(transform.height),
+ withoutEnlargement,
+ });
} else if (transform.width) {
- result.resize({ width: Math.round(transform.width) });
+ result.resize({
+ width: Math.round(transform.width),
+ withoutEnlargement,
+ });
}
if (transform.format) {
diff --git a/packages/astro/src/assets/types.ts b/packages/astro/src/assets/types.ts
index 8bf7a5959..ac6df6799 100644
--- a/packages/astro/src/assets/types.ts
+++ b/packages/astro/src/assets/types.ts
@@ -6,6 +6,8 @@ export type ImageQualityPreset = 'low' | 'mid' | 'high' | 'max' | (string & {});
export type ImageQuality = ImageQualityPreset | number;
export type ImageInputFormat = (typeof VALID_INPUT_FORMATS)[number];
export type ImageOutputFormat = (typeof VALID_OUTPUT_FORMATS)[number] | (string & {});
+export type ImageLayout = 'responsive' | 'fixed' | 'full-width' | 'none';
+export type ImageFit = 'fill' | 'contain' | 'cover' | 'none' | 'scale-down' | (string & {});
export type AssetsGlobalStaticImagesList = Map<
string,
@@ -86,6 +88,8 @@ export type ImageTransform = {
height?: number | undefined;
quality?: ImageQuality | undefined;
format?: ImageOutputFormat | undefined;
+ fit?: ImageFit | undefined;
+ position?: string | undefined;
[key: string]: any;
};
@@ -156,6 +160,58 @@ type ImageSharedProps<T> = T & {
} & (
| {
/**
+ * The layout type for responsive images. Requires the `experimental.responsiveImages` flag to be enabled in the Astro config.
+ *
+ * Allowed values are `responsive`, `fixed`, `full-width` or `none`. Defaults to value of `image.experimentalLayout`.
+ *
+ * - `responsive` - The image will scale to fit the container, maintaining its aspect ratio, but will not exceed the specified dimensions.
+ * - `fixed` - The image will maintain its original dimensions.
+ * - `full-width` - The image will scale to fit the container, maintaining its aspect ratio, even if that means the image will exceed its original dimensions.
+ *
+ * **Example**:
+ * ```astro
+ * <Image src={...} layout="responsive" alt="..." />
+ * ```
+ */
+
+ layout?: ImageLayout;
+
+ /**
+ * Defines how the image should be cropped if the aspect ratio is changed. Requires the `experimental.responsiveImages` flag to be enabled in the Astro config.
+ *
+ * Default is `cover`. Allowed values are `fill`, `contain`, `cover`, `none` or `scale-down`. These behave like the equivalent CSS `object-fit` values. Other values may be passed if supported by the image service.
+ *
+ * **Example**:
+ * ```astro
+ * <Image src={...} fit="contain" alt="..." />
+ * ```
+ */
+
+ fit?: ImageFit;
+
+ /**
+ * Defines the position of the image when cropping. Requires the `experimental.responsiveImages` flag to be enabled in the Astro config.
+ *
+ * The value is a string that specifies the position of the image, which matches the CSS `object-position` property. Other values may be passed if supported by the image service.
+ *
+ * **Example**:
+ * ```astro
+ * <Image src={...} position="center top" alt="..." />
+ * ```
+ */
+
+ position?: string;
+ /**
+ * If true, the image will be loaded with a higher priority. This can be useful for images that are visible above the fold. There should usually be only one image with `priority` set to `true` per page.
+ * All other images will be lazy-loaded according to when they are in the viewport.
+ * **Example**:
+ * ```astro
+ * <Image src={...} priority alt="..." />
+ * ```
+ */
+ priority?: boolean;
+
+ /**
* A list of widths to generate images for. The value of this property will be used to assign the `srcset` property on the final `img` element.
*
* This attribute is incompatible with `densities`.
@@ -171,6 +227,9 @@ type ImageSharedProps<T> = T & {
*/
densities?: (number | `${number}x`)[];
widths?: never;
+ layout?: never;
+ fit?: never;
+ position?: never;
}
);
diff --git a/packages/astro/src/assets/utils/imageAttributes.ts b/packages/astro/src/assets/utils/imageAttributes.ts
new file mode 100644
index 000000000..1b17e11b6
--- /dev/null
+++ b/packages/astro/src/assets/utils/imageAttributes.ts
@@ -0,0 +1,49 @@
+import { toStyleString } from '../../runtime/server/render/util.js';
+import type { AstroConfig } from '../../types/public/config.js';
+import type { GetImageResult, ImageLayout, LocalImageProps, RemoteImageProps } from '../types.js';
+
+export function addCSSVarsToStyle(
+ vars: Record<string, string | false | undefined>,
+ styles?: string | Record<string, any>,
+) {
+ const cssVars = Object.entries(vars)
+ .filter(([_, value]) => value !== undefined && value !== false)
+ .map(([key, value]) => `--${key}: ${value};`)
+ .join(' ');
+
+ if (!styles) {
+ return cssVars;
+ }
+ const style = typeof styles === 'string' ? styles : toStyleString(styles);
+
+ return `${cssVars} ${style}`;
+}
+
+const cssFitValues = ['fill', 'contain', 'cover', 'scale-down'];
+
+export function applyResponsiveAttributes<
+ T extends LocalImageProps<unknown> | RemoteImageProps<unknown>,
+>({
+ layout,
+ image,
+ props,
+ additionalAttributes,
+}: {
+ layout: Exclude<ImageLayout, 'none'>;
+ image: GetImageResult;
+ additionalAttributes: Record<string, any>;
+ props: T;
+}) {
+ const attributes = { ...additionalAttributes, ...image.attributes };
+ attributes.style = addCSSVarsToStyle(
+ {
+ w: image.attributes.width ?? props.width ?? image.options.width,
+ h: image.attributes.height ?? props.height ?? image.options.height,
+ fit: cssFitValues.includes(props.fit ?? '') && props.fit,
+ pos: props.position,
+ },
+ attributes.style,
+ );
+ attributes['data-astro-image'] = layout;
+ return attributes;
+}
diff --git a/packages/astro/src/assets/vite-plugin-assets.ts b/packages/astro/src/assets/vite-plugin-assets.ts
index 037fae725..8214ce665 100644
--- a/packages/astro/src/assets/vite-plugin-assets.ts
+++ b/packages/astro/src/assets/vite-plugin-assets.ts
@@ -115,14 +115,14 @@ export default function assets({ settings }: { settings: AstroSettings }): vite.
},
load(id) {
if (id === resolvedVirtualModuleId) {
- return `
+ return /* ts */ `
export { getConfiguredImageService, isLocalService } from "astro/assets";
import { getImage as getImageInternal } from "astro/assets";
export { default as Image } from "astro/components/Image.astro";
export { default as Picture } from "astro/components/Picture.astro";
export { inferRemoteSize } from "astro/assets/utils/inferRemoteSize.js";
- export const imageConfig = ${JSON.stringify(settings.config.image)};
+ export const imageConfig = ${JSON.stringify({ ...settings.config.image, experimentalResponsiveImages: settings.config.experimental.responsiveImages })};
// This is used by the @astrojs/node integration to locate images.
// It's unused on other platforms, but on some platforms like Netlify (and presumably also Vercel)
// new URL("dist/...") is interpreted by the bundler as a signal to include that directory
diff --git a/packages/astro/src/core/config/schema.ts b/packages/astro/src/core/config/schema.ts
index 48af43339..67228cb09 100644
--- a/packages/astro/src/core/config/schema.ts
+++ b/packages/astro/src/core/config/schema.ts
@@ -95,6 +95,7 @@ export const ASTRO_CONFIG_DEFAULTS = {
experimental: {
clientPrerender: false,
contentIntellisense: false,
+ responsiveImages: false,
},
} satisfies AstroUserConfig & { server: { open: boolean } };
@@ -284,6 +285,10 @@ export const AstroConfigSchema = z.object({
}),
)
.default([]),
+ experimentalLayout: z.enum(['responsive', 'fixed', 'full-width', 'none']).optional(),
+ experimentalObjectFit: z.string().optional(),
+ experimentalObjectPosition: z.string().optional(),
+ experimentalBreakpoints: z.array(z.number()).optional(),
})
.default(ASTRO_CONFIG_DEFAULTS.image),
devToolbar: z
@@ -525,6 +530,10 @@ export const AstroConfigSchema = z.object({
.boolean()
.optional()
.default(ASTRO_CONFIG_DEFAULTS.experimental.contentIntellisense),
+ responsiveImages: z
+ .boolean()
+ .optional()
+ .default(ASTRO_CONFIG_DEFAULTS.experimental.responsiveImages),
})
.strict(
`Invalid or outdated experimental feature.\nCheck for incorrect spelling or outdated Astro version.\nSee https://docs.astro.build/en/reference/configuration-reference/#experimental-flags for a list of all current experiments.`,
@@ -688,7 +697,7 @@ export function createRelativeSchema(cmd: string, fileProtocolRoot: string) {
'The value of `outDir` must not point to a path within the folder set as `publicDir`, this will cause an infinite loop',
})
.superRefine((configuration, ctx) => {
- const { site, i18n, output } = configuration;
+ const { site, i18n, output, image, experimental } = configuration;
const hasDomains = i18n?.domains ? Object.keys(i18n.domains).length > 0 : false;
if (hasDomains) {
if (!site) {
@@ -705,6 +714,19 @@ export function createRelativeSchema(cmd: string, fileProtocolRoot: string) {
});
}
}
+ if (
+ !experimental.responsiveImages &&
+ (image.experimentalLayout ||
+ image.experimentalObjectFit ||
+ image.experimentalObjectPosition ||
+ image.experimentalBreakpoints)
+ ) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ message:
+ 'The `experimentalLayout`, `experimentalObjectFit`, `experimentalObjectPosition` and `experimentalBreakpoints` options are only available when `experimental.responsiveImages` is enabled.',
+ });
+ }
});
return AstroConfigRelativeSchema;
diff --git a/packages/astro/src/runtime/server/render/util.ts b/packages/astro/src/runtime/server/render/util.ts
index 45c0345e5..9c771a0de 100644
--- a/packages/astro/src/runtime/server/render/util.ts
+++ b/packages/astro/src/runtime/server/render/util.ts
@@ -28,7 +28,8 @@ export const toAttributeString = (value: any, shouldEscape = true) =>
const kebab = (k: string) =>
k.toLowerCase() === k ? k : k.replace(/[A-Z]/g, (match) => `-${match.toLowerCase()}`);
-const toStyleString = (obj: Record<string, any>) =>
+
+export const toStyleString = (obj: Record<string, any>) =>
Object.entries(obj)
.filter(([_, v]) => (typeof v === 'string' && v.trim()) || typeof v === 'number')
.map(([k, v]) => {
diff --git a/packages/astro/src/types/public/config.ts b/packages/astro/src/types/public/config.ts
index 1f8318726..47d6d2860 100644
--- a/packages/astro/src/types/public/config.ts
+++ b/packages/astro/src/types/public/config.ts
@@ -6,6 +6,7 @@ import type {
ShikiConfig,
} from '@astrojs/markdown-remark';
import type { UserConfig as OriginalViteUserConfig, SSROptions as ViteSSROptions } from 'vite';
+import type { ImageFit, ImageLayout } from '../../assets/types.js';
import type { RemotePattern } from '../../assets/utils/remotePattern.js';
import type { AssetsPrefix } from '../../core/app/types.js';
import type { AstroConfigType } from '../../core/config/schema.js';
@@ -1070,6 +1071,51 @@ export interface ViteUserConfig extends OriginalViteUserConfig {
*/
remotePatterns?: Partial<RemotePattern>[];
+
+ /**
+ * @docs
+ * @name image.experimentalLayout
+ * @type {ImageLayout}
+ * @default `undefined`
+ * @description
+ * The default layout type for responsive images. Can be overridden by the `layout` prop on the image component.
+ * Requires the `experimental.responsiveImages` flag to be enabled.
+ * - `responsive` - The image will scale to fit the container, maintaining its aspect ratio, but will not exceed the specified dimensions.
+ * - `fixed` - The image will maintain its original dimensions.
+ * - `full-width` - The image will scale to fit the container, maintaining its aspect ratio.
+ */
+ experimentalLayout?: ImageLayout | undefined;
+ /**
+ * @docs
+ * @name image.experimentalObjectFit
+ * @type {ImageFit}
+ * @default `"cover"`
+ * @description
+ * The default object-fit value for responsive images. Can be overridden by the `fit` prop on the image component.
+ * Requires the `experimental.responsiveImages` flag to be enabled.
+ */
+ experimentalObjectFit?: ImageFit;
+ /**
+ * @docs
+ * @name image.experimentalObjectPosition
+ * @type {string}
+ * @default `"center"`
+ * @description
+ * The default object-position value for responsive images. Can be overridden by the `position` prop on the image component.
+ * Requires the `experimental.responsiveImages` flag to be enabled.
+ */
+ experimentalObjectPosition?: string;
+ /**
+ * @docs
+ * @name image.experimentalBreakpoints
+ * @type {number[]}
+ * @default `[640, 750, 828, 1080, 1280, 1668, 2048, 2560] | [640, 750, 828, 960, 1080, 1280, 1668, 1920, 2048, 2560, 3200, 3840, 4480, 5120, 6016]`
+ * @description
+ * The breakpoints used to generate responsive images. Requires the `experimental.responsiveImages` flag to be enabled. The full list is not normally used,
+ * but is filtered according to the source and output size. The defaults used depend on whether a local or remote image service is used. For remote services
+ * the more comprehensive list is used, because only the required sizes are generated. For local services, the list is shorter to reduce the number of images generated.
+ */
+ experimentalBreakpoints?: number[];
};
/**
@@ -1699,6 +1745,125 @@ export interface ViteUserConfig extends OriginalViteUserConfig {
* To use this feature with the Astro VS Code extension, you must also enable the `astro.content-intellisense` option in your VS Code settings. For editors using the Astro language server directly, pass the `contentIntellisense: true` initialization parameter to enable this feature.
*/
contentIntellisense?: boolean;
+
+ /**
+ * @docs
+ * @name experimental.responsiveImages
+ * @type {boolean}
+ * @default `undefined`
+ * @version 5.0.0
+ * @description
+ *
+ * Enables automatic responsive images in your project.
+ *
+ * ```js title=astro.config.mjs
+ * {
+ * experimental: {
+ * responsiveImages: true,
+ * },
+ * }
+ * ```
+ *
+ * When enabled, you can pass a `layout` props to any `<Image />` or `<Picture />` component to create a responsive image. When a layout is set, images have automatically generated `srcset` and `sizes` attributes based on the image's dimensions and the layout type. Images with `responsive` and `full-width` layouts will have styles applied to ensure they resize according to their container.
+ *
+ * ```astro title=MyComponent.astro
+ * ---
+ * import { Image, Picture } from 'astro:assets';
+ * import myImage from '../assets/my_image.png';
+ * ---
+ * <Image src={myImage} alt="A description of my image." layout='responsive' width={800} height={600} />
+ * <Picture src={myImage} alt="A description of my image." layout='full-width' formats={['avif', 'webp', 'jpeg']} />
+ * ```
+ * This `<Image />` component will generate the following HTML output:
+ * ```html title=Output
+ *
+ * <img
+ * src="/_astro/my_image.hash3.webp"
+ * srcset="/_astro/my_image.hash1.webp 640w,
+ * /_astro/my_image.hash2.webp 750w,
+ * /_astro/my_image.hash3.webp 800w,
+ * /_astro/my_image.hash4.webp 828w,
+ * /_astro/my_image.hash5.webp 1080w,
+ * /_astro/my_image.hash6.webp 1280w,
+ * /_astro/my_image.hash7.webp 1600w"
+ * alt="A description of my image"
+ * sizes="(min-width: 800px) 800px, 100vw"
+ * loading="lazy"
+ * decoding="async"
+ * fetchpriority="auto"
+ * width="800"
+ * height="600"
+ * style="--w: 800; --h: 600; --fit: cover; --pos: center;"
+ * data-astro-image="responsive"
+ * >
+ * ```
+ *
+ * The following styles are applied to ensure the images resize correctly:
+ *
+ * ```css title="Responsive Image Styles"
+ * [data-astro-image] {
+ * width: 100%;
+ * height: auto;
+ * object-fit: var(--fit);
+ * object-position: var(--pos);
+ * aspect-ratio: var(--w) / var(--h)
+ * }
+ *
+ * [data-astro-image=responsive] {
+ * max-width: calc(var(--w) * 1px);
+ * max-height: calc(var(--h) * 1px)
+ * }
+ *
+ * [data-astro-image=fixed] {
+ * width: calc(var(--w) * 1px);
+ * height: calc(var(--h) * 1px)
+ * }
+ * ```
+ * You can enable responsive images for all `<Image />` and `<Picture />` components by setting `image.experimentalLayout` with a default value. This can be overridden by the `layout` prop on each component.
+ *
+ * **Example:**
+ * ```js title=astro.config.mjs
+ * {
+ * image: {
+ * // Used for all `<Image />` and `<Picture />` components unless overridden
+ * experimentalLayout: 'responsive',
+ * },
+ * experimental: {
+ * responsiveImages: true,
+ * },
+ * }
+ * ```
+ *
+ * ```astro title=MyComponent.astro
+ * ---
+ * import { Image } from 'astro:assets';
+ * import myImage from '../assets/my_image.png';
+ * ---
+ *
+ * <Image src={myImage} alt="This will use responsive layout" width={800} height={600} />
+ *
+ * <Image src={myImage} alt="This will use full-width layout" layout="full-width" />
+ *
+ * <Image src={myImage} alt="This will disable responsive images" layout="none" />
+ * ```
+ *
+ * #### Responsive image properties
+ *
+ * These are additional properties available to the `<Image />` and `<Picture />` components when responsive images are enabled:
+ *
+ * - `layout`: The layout type for the image. Can be `responsive`, `fixed`, `full-width` or `none`. Defaults to value of `image.experimentalLayout`.
+ * - `fit`: Defines how the image should be cropped if the aspect ratio is changed. Values match those of CSS `object-fit`. Defaults to `cover`, or the value of `image.experimentalObjectFit` if set.
+ * - `position`: Defines the position of the image crop if the aspect ratio is changed. Values match those of CSS `object-position`. Defaults to `center`, or the value of `image.experimentalObjectPosition` if set.
+ * - `priority`: If set, eagerly loads the image. Otherwise images will be lazy-loaded. Use this for your largest above-the-fold image. Defaults to `false`.
+ *
+ * The following `<Image />` component properties should not be used with responsive images as these are automatically generated:
+ *
+ * - `densities`
+ * - `widths`
+ * - `sizes`
+ */
+
+ responsiveImages?: boolean;
};
}
diff --git a/packages/astro/test/content-collections-render.test.js b/packages/astro/test/content-collections-render.test.js
index 31ed04a15..972e4313a 100644
--- a/packages/astro/test/content-collections-render.test.js
+++ b/packages/astro/test/content-collections-render.test.js
@@ -26,7 +26,7 @@ describe('Content Collections - render()', () => {
assert.equal($('ul li').length, 3);
// Includes styles
- assert.equal($('link[rel=stylesheet]').length, 1);
+ assert.equal($('link[rel=stylesheet]').length, 2);
});
it('Excludes CSS for non-rendered entries', async () => {
@@ -34,7 +34,7 @@ describe('Content Collections - render()', () => {
const $ = cheerio.load(html);
// Excludes styles
- assert.equal($('link[rel=stylesheet]').length, 0);
+ assert.equal($('link[rel=stylesheet]').length, 1);
});
it('De-duplicates CSS used both in layout and directly in target page', async () => {
@@ -110,7 +110,7 @@ describe('Content Collections - render()', () => {
assert.equal($('ul li').length, 3);
// Includes styles
- assert.equal($('link[rel=stylesheet]').length, 1);
+ assert.equal($('link[rel=stylesheet]').length, 2);
});
it('Exclude CSS for non-rendered entries', async () => {
@@ -121,7 +121,7 @@ describe('Content Collections - render()', () => {
const $ = cheerio.load(html);
// Includes styles
- assert.equal($('link[rel=stylesheet]').length, 0);
+ assert.equal($('link[rel=stylesheet]').length, 1);
});
it('De-duplicates CSS used both in layout and directly in target page', async () => {
@@ -202,7 +202,7 @@ describe('Content Collections - render()', () => {
assert.equal($('ul li').length, 3);
// Includes styles
- assert.equal($('head > style').length, 1);
+ assert.equal($('head > style').length, 2);
assert.ok($('head > style').text().includes("font-family: 'Comic Sans MS'"));
});
diff --git a/packages/astro/test/core-image-layout.test.js b/packages/astro/test/core-image-layout.test.js
new file mode 100644
index 000000000..8cbcb8b20
--- /dev/null
+++ b/packages/astro/test/core-image-layout.test.js
@@ -0,0 +1,579 @@
+import assert from 'node:assert/strict';
+import { Writable } from 'node:stream';
+import { after, before, describe, it } from 'node:test';
+import * as cheerio from 'cheerio';
+import parseSrcset from 'parse-srcset';
+import { Logger } from '../dist/core/logger/core.js';
+import { testImageService } from './test-image-service.js';
+import { testRemoteImageService } from './test-remote-image-service.js';
+import { loadFixture } from './test-utils.js';
+
+describe('astro:image:layout', () => {
+ /** @type {import('./test-utils').Fixture} */
+ let fixture;
+
+ describe('local image service', () => {
+ /** @type {import('./test-utils').DevServer} */
+ let devServer;
+
+ before(async () => {
+ fixture = await loadFixture({
+ root: './fixtures/core-image-layout/',
+ image: {
+ service: testImageService({ foo: 'bar' }),
+ domains: ['avatars.githubusercontent.com'],
+ },
+ });
+
+ devServer = await fixture.startDevServer({});
+ });
+
+ after(async () => {
+ await devServer.stop();
+ });
+
+ describe('basics', () => {
+ let $;
+ before(async () => {
+ let res = await fixture.fetch('/');
+ let html = await res.text();
+ $ = cheerio.load(html);
+ });
+
+ it('Adds the <img> tag', () => {
+ let $img = $('#local img');
+ assert.equal($img.length, 1);
+ assert.equal($img.attr('src').startsWith('/_image'), true);
+ });
+
+ it('includes lazy loading attributes', () => {
+ let $img = $('#local img');
+ assert.equal($img.attr('loading'), 'lazy');
+ assert.equal($img.attr('decoding'), 'async');
+ assert.equal($img.attr('fetchpriority'), 'auto');
+ });
+
+ it('includes priority loading attributes', () => {
+ let $img = $('#local-priority img');
+ assert.equal($img.attr('loading'), 'eager');
+ assert.equal($img.attr('decoding'), 'sync');
+ assert.equal($img.attr('fetchpriority'), 'high');
+ });
+
+ it('has width and height - no dimensions set', () => {
+ let $img = $('#local img');
+ assert.equal($img.attr('width'), '2316');
+ assert.equal($img.attr('height'), '1544');
+ });
+
+ it('has proper width and height - only width', () => {
+ let $img = $('#local-width img');
+ assert.equal($img.attr('width'), '350');
+ assert.equal($img.attr('height'), '233');
+ });
+
+ it('has proper width and height - only height', () => {
+ let $img = $('#local-height img');
+ assert.equal($img.attr('width'), '300');
+ assert.equal($img.attr('height'), '200');
+ });
+
+ it('has proper width and height - has both width and height', () => {
+ let $img = $('#local-both img');
+ assert.equal($img.attr('width'), '300');
+ assert.equal($img.attr('height'), '400');
+ });
+
+ it('sets the style', () => {
+ let $img = $('#local-both img');
+ assert.match($img.attr('style'), /--w: 300/);
+ assert.match($img.attr('style'), /--h: 400/);
+ assert.equal($img.data('astro-image'), 'responsive');
+ });
+
+ it('sets the style when no dimensions set', () => {
+ let $img = $('#local img');
+ assert.match($img.attr('style'), /--w: 2316/);
+ assert.match($img.attr('style'), /--h: 1544/);
+ assert.equal($img.data('astro-image'), 'responsive');
+ });
+
+ it('sets style for fixed image', () => {
+ let $img = $('#local-fixed img');
+ assert.match($img.attr('style'), /--w: 800/);
+ assert.match($img.attr('style'), /--h: 600/);
+ assert.equal($img.data('astro-image'), 'fixed');
+ });
+
+ it('sets style for full-width image', () => {
+ let $img = $('#local-full-width img');
+ assert.equal($img.data('astro-image'), 'full-width');
+ });
+
+ it('passes in a parent class', () => {
+ let $img = $('#local-class img');
+ assert.match($img.attr('class'), /green/);
+ });
+
+ it('passes in a parent style', () => {
+ let $img = $('#local-style img');
+ assert.match($img.attr('style'), /border: 2px red solid/);
+ });
+
+ it('passes in a parent style as an object', () => {
+ let $img = $('#local-style-object img');
+ assert.match($img.attr('style'), /border:2px red solid/);
+ });
+
+ it('injects a style tag', () => {
+ const style = $('style').text();
+ assert.match(style, /\[data-astro-image\]/);
+ });
+ });
+
+ describe('srcsets', () => {
+ let $;
+ before(async () => {
+ let res = await fixture.fetch('/');
+ let html = await res.text();
+ $ = cheerio.load(html);
+ });
+
+ it('has srcset', () => {
+ let $img = $('#local img');
+ assert.ok($img.attr('srcset'));
+ const srcset = parseSrcset($img.attr('srcset'));
+ assert.equal(srcset.length, 8);
+ assert.equal(srcset[0].url.startsWith('/_image'), true);
+ const widths = srcset.map((x) => x.w);
+ assert.deepEqual(widths, [640, 750, 828, 1080, 1280, 1668, 2048, 2316]);
+ });
+
+ it('constrained - has max of 2x requested size', () => {
+ let $img = $('#local-constrained img');
+ const widths = parseSrcset($img.attr('srcset')).map((x) => x.w);
+ assert.equal(widths.at(-1), 1600);
+ });
+
+ it('constrained - just has 1x and 2x when smaller than min breakpoint', () => {
+ let $img = $('#local-both img');
+ const widths = parseSrcset($img.attr('srcset')).map((x) => x.w);
+ assert.deepEqual(widths, [300, 600]);
+ });
+
+ it('fixed - has just 1x and 2x', () => {
+ let $img = $('#local-fixed img');
+ const widths = parseSrcset($img.attr('srcset')).map((x) => x.w);
+ assert.deepEqual(widths, [800, 1600]);
+ });
+
+ it('full-width: has all breakpoints below image size, ignoring dimensions', () => {
+ let $img = $('#local-full-width img');
+ const widths = parseSrcset($img.attr('srcset')).map((x) => x.w);
+ assert.deepEqual(widths, [640, 750, 828, 1080, 1280, 1668, 2048]);
+ });
+ });
+
+ describe('generated URLs', () => {
+ let $;
+ before(async () => {
+ let res = await fixture.fetch('/fit');
+ let html = await res.text();
+ $ = cheerio.load(html);
+ });
+ it('generates width and height in image URLs when both are provided', () => {
+ let $img = $('#local-both img');
+ const aspectRatio = 300 / 400;
+ const srcset = parseSrcset($img.attr('srcset'));
+ for (const { url } of srcset) {
+ const params = new URL(url, 'https://example.com').searchParams;
+ const width = parseInt(params.get('w'));
+ const height = parseInt(params.get('h'));
+ assert.equal(width / height, aspectRatio);
+ }
+ });
+
+ it('does not pass through fit and position', async () => {
+ const fit = $('#fit-cover img');
+ assert.ok(!fit.attr('fit'));
+ const position = $('#position img');
+ assert.ok(!position.attr('position'));
+ });
+
+ it('sets a default fit of "cover" when no fit is provided', () => {
+ let $img = $('#fit-default img');
+ const srcset = parseSrcset($img.attr('srcset'));
+ for (const { url } of srcset) {
+ const params = new URL(url, 'https://example.com').searchParams;
+ assert.equal(params.get('fit'), 'cover');
+ }
+ });
+
+ it('sets a fit of "contain" when fit="contain" is provided', () => {
+ let $img = $('#fit-contain img');
+ const srcset = parseSrcset($img.attr('srcset'));
+ for (const { url } of srcset) {
+ const params = new URL(url, 'https://example.com').searchParams;
+ assert.equal(params.get('fit'), 'contain');
+ }
+ });
+
+ it('sets no fit when fit="none" is provided', () => {
+ let $img = $('#fit-none img');
+ const srcset = parseSrcset($img.attr('srcset'));
+ for (const { url } of srcset) {
+ const params = new URL(url, 'https://example.com').searchParams;
+ assert.ok(!params.has('fit'));
+ }
+ });
+ });
+
+ describe('remote images', () => {
+ describe('srcset', () => {
+ let $;
+ before(async () => {
+ let res = await fixture.fetch('/remote');
+ let html = await res.text();
+ $ = cheerio.load(html);
+ });
+ it('has srcset', () => {
+ let $img = $('#constrained img');
+ assert.ok($img.attr('srcset'));
+ const srcset = parseSrcset($img.attr('srcset'));
+ const widths = srcset.map((x) => x.w);
+ assert.deepEqual(widths, [640, 750, 800, 828, 1080, 1280, 1600]);
+ });
+
+ it('constrained - has max of 2x requested size', () => {
+ let $img = $('#constrained img');
+ const widths = parseSrcset($img.attr('srcset')).map((x) => x.w);
+ assert.equal(widths.at(-1), 1600);
+ });
+
+ it('constrained - just has 1x and 2x when smaller than min breakpoint', () => {
+ let $img = $('#small img');
+ const widths = parseSrcset($img.attr('srcset')).map((x) => x.w);
+ assert.deepEqual(widths, [300, 600]);
+ });
+
+ it('fixed - has just 1x and 2x', () => {
+ let $img = $('#fixed img');
+ const widths = parseSrcset($img.attr('srcset')).map((x) => x.w);
+ assert.deepEqual(widths, [800, 1600]);
+ });
+
+ it('full-width: has all breakpoints', () => {
+ let $img = $('#full-width img');
+ const widths = parseSrcset($img.attr('srcset')).map((x) => x.w);
+ assert.deepEqual(widths, [640, 750, 828, 1080, 1280, 1668, 2048, 2560]);
+ });
+ });
+ });
+
+ describe('picture component', () => {
+ /** Original image dimensions */
+ const originalWidth = 2316;
+ const originalHeight = 1544;
+
+ /** @type {import("cheerio").CheerioAPI} */
+ let $;
+ before(async () => {
+ let res = await fixture.fetch('/picture');
+ let html = await res.text();
+ $ = cheerio.load(html);
+ });
+
+ describe('basics', () => {
+ it('creates picture and img elements', () => {
+ let $picture = $('#picture-density-2-format picture');
+ let $img = $('#picture-density-2-format img');
+ assert.equal($picture.length, 1);
+ assert.equal($img.length, 1);
+ });
+
+ it('includes source elements for each format', () => {
+ let $sources = $('#picture-density-2-format source');
+ assert.equal($sources.length, 2); // avif and webp formats
+
+ const types = $sources.map((_, el) => $(el).attr('type')).get();
+ assert.deepEqual(types.sort(), ['image/avif', 'image/webp']);
+ });
+
+ it('generates responsive srcset matching layout breakpoints', () => {
+ let $source = $('#picture-density-2-format source').first();
+ const srcset = parseSrcset($source.attr('srcset'));
+
+ const widths = srcset.map((s) => s.w);
+ assert.deepEqual(widths, [640, 750, 828, 1080, 1158, 1280, 1668, 2048, 2316]);
+ });
+
+ it('has proper width and height attributes', () => {
+ let $img = $('#picture-density-2-format img');
+ // Width is set to half of original in the component
+ const expectedWidth = Math.round(originalWidth / 2);
+ const expectedHeight = Math.round(originalHeight / 2);
+
+ assert.equal($img.attr('width'), expectedWidth.toString());
+ assert.equal($img.attr('height'), expectedHeight.toString());
+ });
+ });
+
+ describe('responsive variants', () => {
+ it('constrained - has max of 2x requested size', () => {
+ let $source = $('#picture-constrained source').first();
+ const widths = parseSrcset($source.attr('srcset')).map((s) => s.w);
+ assert.equal(widths.at(-1), 1600); // Max should be 2x the 800px width
+
+ let $img = $('#picture-constrained img');
+ const aspectRatio = originalWidth / originalHeight;
+ assert.equal($img.attr('width'), '800');
+ assert.equal($img.attr('height'), Math.round(800 / aspectRatio).toString());
+ });
+
+ it('constrained - just has 1x and 2x when smaller than min breakpoint', () => {
+ let $source = $('#picture-both source').first();
+ const widths = parseSrcset($source.attr('srcset')).map((s) => s.w);
+ assert.deepEqual(widths, [300, 600]); // Just 1x and 2x for small images
+
+ let $img = $('#picture-both img');
+ assert.equal($img.attr('width'), '300');
+ assert.equal($img.attr('height'), '400');
+ });
+
+ it('fixed - has just 1x and 2x', () => {
+ let $source = $('#picture-fixed source').first();
+ const widths = parseSrcset($source.attr('srcset')).map((s) => s.w);
+ assert.deepEqual(widths, [400, 800]); // Fixed layout only needs 1x and 2x
+
+ let $img = $('#picture-fixed img');
+ assert.equal($img.attr('width'), '400');
+ assert.equal($img.attr('height'), '300');
+ });
+
+ it('full-width: has all breakpoints below image size', () => {
+ let $source = $('#picture-full-width source').first();
+ const widths = parseSrcset($source.attr('srcset')).map((s) => s.w);
+ assert.deepEqual(widths, [640, 750, 828, 1080, 1280, 1668, 2048]);
+ });
+ });
+
+ describe('fallback format', () => {
+ it('uses specified fallback format', () => {
+ let $img = $('#picture-fallback img');
+ const imageURL = new URL($img.attr('src'), 'http://localhost');
+ assert.equal(imageURL.searchParams.get('f'), 'jpeg');
+ });
+
+ it('does not add fallbackFormat as an attribute', () => {
+ let $img = $('#picture-fallback img');
+ assert.equal($img.attr('fallbackformat'), undefined);
+ });
+
+ it('maintains original aspect ratio', () => {
+ let $img = $('#picture-fallback img');
+ const width = parseInt($img.attr('width'));
+ const height = parseInt($img.attr('height'));
+ const imageAspectRatio = width / height;
+ const originalAspectRatio = originalWidth / originalHeight;
+
+ // Allow for small rounding differences
+ assert.ok(Math.abs(imageAspectRatio - originalAspectRatio) < 0.01);
+ });
+ });
+
+ describe('attributes', () => {
+ it('applies class to img element', () => {
+ let $img = $('#picture-attributes img');
+ assert.ok($img.attr('class').includes('img-comp'));
+ });
+
+ it('applies pictureAttributes to picture element', () => {
+ let $picture = $('#picture-attributes picture');
+ assert.ok($picture.attr('class').includes('picture-comp'));
+ });
+
+ it('adds inline style attributes', () => {
+ let $img = $('#picture-attributes img');
+ const style = $img.attr('style');
+ assert.match(style, /--w:/);
+ assert.match(style, /--h:/);
+ });
+
+ it('passing in style as an object', () => {
+ let $img = $('#picture-style-object img');
+ const style = $img.attr('style');
+ assert.match(style, /border:2px red solid/);
+ });
+
+ it('passing in style as a string', () => {
+ let $img = $('#picture-style img');
+ const style = $img.attr('style');
+ assert.match(style, /border: 2px red solid/);
+ });
+ });
+
+ describe('MIME types', () => {
+ it('creates source elements with correct MIME types', () => {
+ const $sources = $('#picture-mime-types source');
+ const types = $sources.map((_, el) => $(el).attr('type')).get();
+
+ // Should have all specified formats in correct MIME type format
+ const expectedTypes = [
+ // Included twice because we pass jpg and jpeg
+ 'image/jpeg',
+ 'image/jpeg',
+ 'image/png',
+ 'image/avif',
+ 'image/webp',
+ ];
+
+ assert.deepEqual(types.sort(), expectedTypes.sort());
+ });
+
+ it('uses valid MIME type format', () => {
+ const $sources = $('#picture-mime-types source');
+ const validMimeTypes = [
+ 'image/webp',
+ 'image/jpeg',
+ 'image/avif',
+ 'image/png',
+ 'image/gif',
+ 'image/svg+xml',
+ ];
+
+ $sources.each((_, source) => {
+ const type = $(source).attr('type');
+ assert.ok(
+ validMimeTypes.includes(type),
+ `Expected type attribute value to be a valid MIME type: ${type}`,
+ );
+ });
+ });
+ });
+ });
+ });
+
+ describe('remote image service', () => {
+ /** @type {import('./test-utils').DevServer} */
+ let devServer;
+ /** @type {Array<{ type: any, level: 'error', message: string; }>} */
+ let logs = [];
+
+ before(async () => {
+ fixture = await loadFixture({
+ root: './fixtures/core-image-layout/',
+ image: {
+ service: testRemoteImageService({ foo: 'bar' }),
+ domains: ['images.unsplash.com'],
+ },
+ });
+
+ devServer = await fixture.startDevServer({
+ logger: new Logger({
+ level: 'error',
+ dest: new Writable({
+ objectMode: true,
+ write(event, _, callback) {
+ logs.push(event);
+ callback();
+ },
+ }),
+ }),
+ });
+ });
+
+ after(async () => {
+ await devServer.stop();
+ });
+
+ describe('srcsets', () => {
+ let $;
+ before(async () => {
+ let res = await fixture.fetch('/');
+ let html = await res.text();
+ $ = cheerio.load(html);
+ });
+
+ it('has full srcset', () => {
+ let $img = $('#local img');
+ assert.ok($img.attr('srcset'));
+ const srcset = parseSrcset($img.attr('srcset'));
+ assert.equal(srcset.length, 10);
+ assert.equal(srcset[0].url.startsWith('/_image'), true);
+ const widths = srcset.map((x) => x.w);
+ assert.deepEqual(widths, [640, 750, 828, 960, 1080, 1280, 1668, 1920, 2048, 2316]);
+ });
+
+ it('constrained - has max of 2x requested size', () => {
+ let $img = $('#local-constrained img');
+ const widths = parseSrcset($img.attr('srcset')).map((x) => x.w);
+ assert.equal(widths.at(-1), 1600);
+ });
+
+ it('constrained - just has 1x and 2x when smaller than min breakpoint', () => {
+ let $img = $('#local-both img');
+ const widths = parseSrcset($img.attr('srcset')).map((x) => x.w);
+ assert.deepEqual(widths, [300, 600]);
+ });
+
+ it('fixed - has just 1x and 2x', () => {
+ let $img = $('#local-fixed img');
+ const widths = parseSrcset($img.attr('srcset')).map((x) => x.w);
+ assert.deepEqual(widths, [800, 1600]);
+ });
+
+ it('full-width: has all breakpoints below image size, ignoring dimensions', () => {
+ let $img = $('#local-full-width img');
+ const widths = parseSrcset($img.attr('srcset')).map((x) => x.w);
+ assert.deepEqual(widths, [640, 750, 828, 960, 1080, 1280, 1668, 1920, 2048]);
+ });
+ });
+
+ describe('remote', () => {
+ describe('srcset', () => {
+ let $;
+ before(async () => {
+ let res = await fixture.fetch('/remote');
+ let html = await res.text();
+ $ = cheerio.load(html);
+ });
+ it('has srcset', () => {
+ let $img = $('#constrained img');
+ assert.ok($img.attr('srcset'));
+ const srcset = parseSrcset($img.attr('srcset'));
+ assert.equal(srcset.length, 8);
+ assert.equal(srcset[0].url.startsWith('/_image'), true);
+ const widths = srcset.map((x) => x.w);
+ assert.deepEqual(widths, [640, 750, 800, 828, 960, 1080, 1280, 1600]);
+ });
+
+ it('constrained - has max of 2x requested size', () => {
+ let $img = $('#constrained img');
+ const widths = parseSrcset($img.attr('srcset')).map((x) => x.w);
+ assert.equal(widths.at(-1), 1600);
+ });
+
+ it('constrained - just has 1x and 2x when smaller than min breakpoint', () => {
+ let $img = $('#small img');
+ const widths = parseSrcset($img.attr('srcset')).map((x) => x.w);
+ assert.deepEqual(widths, [300, 600]);
+ });
+
+ it('fixed - has just 1x and 2x', () => {
+ let $img = $('#fixed img');
+ const widths = parseSrcset($img.attr('srcset')).map((x) => x.w);
+ assert.deepEqual(widths, [800, 1600]);
+ });
+
+ it('full-width: has all breakpoints', () => {
+ let $img = $('#full-width img');
+ const widths = parseSrcset($img.attr('srcset')).map((x) => x.w);
+ assert.deepEqual(
+ widths,
+ [640, 750, 828, 960, 1080, 1280, 1668, 1920, 2048, 2560, 3200, 3840, 4480, 5120, 6016],
+ );
+ });
+ });
+ });
+ });
+});
diff --git a/packages/astro/test/core-image-service.test.js b/packages/astro/test/core-image-service.test.js
new file mode 100644
index 000000000..0c75ed484
--- /dev/null
+++ b/packages/astro/test/core-image-service.test.js
@@ -0,0 +1,206 @@
+import assert from 'node:assert/strict';
+import { after, before, describe, it } from 'node:test';
+import { removeDir } from '@astrojs/internal-helpers/fs';
+import * as cheerio from 'cheerio';
+import { lookup as probe } from '../dist/assets/utils/vendor/image-size/lookup.js';
+import { loadFixture } from './test-utils.js';
+
+async function getImageDimensionsFromFixture(fixture, path) {
+ /** @type { Response } */
+ const res = await fixture.fetch(path instanceof URL ? path.pathname + path.search : path);
+ const buffer = await res.arrayBuffer();
+ const { width, height } = await probe(new Uint8Array(buffer));
+ return { width, height };
+}
+
+async function getImageDimensionsFromLocalFile(fixture, path) {
+ const buffer = await fixture.readFile(path, null);
+ const { width, height } = await probe(new Uint8Array(buffer));
+ return { width, height };
+}
+
+describe('astro image service', () => {
+ /** @type {import('./test-utils').Fixture} */
+ let fixture;
+
+ describe('dev image service', () => {
+ /** @type {import('./test-utils').DevServer} */
+ let devServer;
+
+ before(async () => {
+ fixture = await loadFixture({
+ root: './fixtures/core-image-layout/',
+ image: {
+ domains: ['unsplash.com'],
+ },
+ });
+
+ devServer = await fixture.startDevServer({});
+ });
+
+ after(async () => {
+ await devServer.stop();
+ });
+
+ describe('generated images', () => {
+ let $;
+ let src;
+ before(async () => {
+ const res = await fixture.fetch('/fit');
+ const html = await res.text();
+ $ = cheerio.load(html);
+ let $img = $('#local-both img');
+ src = new URL($img.attr('src'), 'http://localhost').href;
+ });
+
+ it('generates correct width and height when both are provided', async () => {
+ const url = new URL(src);
+ const { width, height } = await getImageDimensionsFromFixture(fixture, url);
+ assert.equal(width, 300);
+ assert.equal(height, 400);
+ });
+
+ it('generates correct height when only width is provided', async () => {
+ const url = new URL(src);
+ url.searchParams.delete('h');
+ const { width, height } = await getImageDimensionsFromFixture(fixture, url);
+ assert.equal(width, 300);
+ assert.equal(height, 200);
+ });
+
+ it('generates correct width when only height is provided', async () => {
+ const url = new URL(src);
+ url.searchParams.delete('w');
+ url.searchParams.set('h', '400');
+ const { width, height } = await getImageDimensionsFromFixture(fixture, url);
+ assert.equal(width, 600);
+ assert.equal(height, 400);
+ });
+
+ it('preserves aspect ratio when fit=inside', async () => {
+ const url = new URL(src);
+ url.searchParams.set('fit', 'inside');
+ const { width, height } = await getImageDimensionsFromFixture(fixture, url);
+ assert.equal(width, 300);
+ assert.equal(height, 200);
+ });
+
+ it('preserves aspect ratio when fit=contain', async () => {
+ const url = new URL(src);
+ url.searchParams.set('fit', 'contain');
+ const { width, height } = await getImageDimensionsFromFixture(fixture, url);
+ assert.equal(width, 300);
+ assert.equal(height, 200);
+ });
+
+ it('preserves aspect ratio when fit=outside', async () => {
+ const url = new URL(src);
+ url.searchParams.set('fit', 'outside');
+ const { width, height } = await getImageDimensionsFromFixture(fixture, url);
+ assert.equal(width, 600);
+ assert.equal(height, 400);
+ });
+ const originalWidth = 2316;
+ const originalHeight = 1544;
+ it('does not upscale image if requested size is larger than original', async () => {
+ const url = new URL(src);
+ url.searchParams.set('w', '3000');
+ url.searchParams.set('h', '2000');
+ const { width, height } = await getImageDimensionsFromFixture(fixture, url);
+ assert.equal(width, originalWidth);
+ assert.equal(height, originalHeight);
+ });
+
+ // To match old behavior, we should upscale if the requested size is larger than the original
+ it('does upscale image if requested size is larger than original and fit is unset', async () => {
+ const url = new URL(src);
+ url.searchParams.set('w', '3000');
+ url.searchParams.set('h', '2000');
+ url.searchParams.delete('fit');
+ const { width, height } = await getImageDimensionsFromFixture(fixture, url);
+ assert.equal(width, 3000);
+ assert.equal(height, 2000);
+ });
+
+ // To match old behavior, we should upscale if the requested size is larger than the original
+ it('does not upscale is only one dimension is provided and fit is set', async () => {
+ const url = new URL(src);
+ url.searchParams.set('w', '3000');
+ url.searchParams.delete('h');
+ url.searchParams.set('fit', 'cover');
+ const { width, height } = await getImageDimensionsFromFixture(fixture, url);
+ assert.equal(width, originalWidth);
+ assert.equal(height, originalHeight);
+ });
+ });
+ });
+
+ describe('build image service', () => {
+ before(async () => {
+ fixture = await loadFixture({
+ root: './fixtures/core-image-layout/',
+ });
+ removeDir(new URL('./fixtures/core-image-ssg/node_modules/.astro', import.meta.url));
+
+ await fixture.build();
+ });
+
+ describe('generated images', () => {
+ let $;
+ before(async () => {
+ const html = await fixture.readFile('/build/index.html');
+ $ = cheerio.load(html);
+ });
+
+ it('generates correct width and height when both are provided', async () => {
+ const path = $('.both img').attr('src');
+ const { width, height } = await getImageDimensionsFromLocalFile(fixture, path);
+ assert.equal(width, 300);
+ assert.equal(height, 400);
+ });
+
+ it('generates correct height when only width is provided', async () => {
+ const path = $('.width-only img').attr('src');
+ const { width, height } = await getImageDimensionsFromLocalFile(fixture, path);
+ assert.equal(width, 300);
+ assert.equal(height, 200);
+ });
+
+ it('generates correct width when only height is provided', async () => {
+ const path = $('.height-only img').attr('src');
+ const { width, height } = await getImageDimensionsFromLocalFile(fixture, path);
+ assert.equal(width, 600);
+ assert.equal(height, 400);
+ });
+
+ it('preserves aspect ratio when fit=inside', async () => {
+ const path = $('.fit-inside img').attr('src');
+ const { width, height } = await getImageDimensionsFromLocalFile(fixture, path);
+ assert.equal(width, 300);
+ assert.equal(height, 200);
+ });
+
+ it('preserves aspect ratio when fit=contain', async () => {
+ const path = $('.fit-contain img').attr('src');
+ const { width, height } = await getImageDimensionsFromLocalFile(fixture, path);
+ assert.equal(width, 300);
+ assert.equal(height, 200);
+ });
+
+ it('preserves aspect ratio when fit=outside', async () => {
+ const path = $('.fit-outside img').attr('src');
+ const { width, height } = await getImageDimensionsFromLocalFile(fixture, path);
+ assert.equal(width, 600);
+ assert.equal(height, 400);
+ });
+ const originalWidth = 2316;
+ const originalHeight = 1544;
+ it('does not upscale image if requested size is larger than original', async () => {
+ const path = $('.too-large img').attr('src');
+ const { width, height } = await getImageDimensionsFromLocalFile(fixture, path);
+ assert.equal(width, originalWidth);
+ assert.equal(height, originalHeight);
+ });
+ });
+ });
+});
diff --git a/packages/astro/test/fixtures/core-image-layout/astro.config.mjs b/packages/astro/test/fixtures/core-image-layout/astro.config.mjs
new file mode 100644
index 000000000..b32208e5f
--- /dev/null
+++ b/packages/astro/test/fixtures/core-image-layout/astro.config.mjs
@@ -0,0 +1,12 @@
+// @ts-check
+import { defineConfig } from 'astro/config';
+
+export default defineConfig({
+ image: {
+ experimentalLayout: 'responsive',
+ },
+
+ experimental: {
+ responsiveImages: true
+ },
+});
diff --git a/packages/astro/test/fixtures/core-image-layout/package.json b/packages/astro/test/fixtures/core-image-layout/package.json
new file mode 100644
index 000000000..ce5b0f966
--- /dev/null
+++ b/packages/astro/test/fixtures/core-image-layout/package.json
@@ -0,0 +1,8 @@
+{
+ "name": "@test/core-image-layout",
+ "version": "0.0.0",
+ "private": true,
+ "dependencies": {
+ "astro": "workspace:*"
+ }
+}
diff --git a/packages/astro/test/fixtures/core-image-layout/src/assets/penguin.jpg b/packages/astro/test/fixtures/core-image-layout/src/assets/penguin.jpg
new file mode 100644
index 000000000..73f0ee316
--- /dev/null
+++ b/packages/astro/test/fixtures/core-image-layout/src/assets/penguin.jpg
Binary files differ
diff --git a/packages/astro/test/fixtures/core-image-layout/src/assets/walrus.jpg b/packages/astro/test/fixtures/core-image-layout/src/assets/walrus.jpg
new file mode 100644
index 000000000..6479e9212
--- /dev/null
+++ b/packages/astro/test/fixtures/core-image-layout/src/assets/walrus.jpg
Binary files differ
diff --git a/packages/astro/test/fixtures/core-image-layout/src/pages/both.astro b/packages/astro/test/fixtures/core-image-layout/src/pages/both.astro
new file mode 100644
index 000000000..b729b6a19
--- /dev/null
+++ b/packages/astro/test/fixtures/core-image-layout/src/pages/both.astro
@@ -0,0 +1,19 @@
+---
+import { Image, Picture } from "astro:assets";
+import penguin from "../assets/penguin.jpg";
+import walrus from "../assets/walrus.jpg";
+---
+
+
+<div id="image">
+ <Image src={penguin} alt="a penguin" />
+</div>
+<div id="picture">
+ <Picture src={walrus} alt="a walrus" formats={['webp', 'jpeg']}/>
+</div>
+
+<style>
+ .green {
+ border: 2px green solid;
+ }
+</style>
diff --git a/packages/astro/test/fixtures/core-image-layout/src/pages/build.astro b/packages/astro/test/fixtures/core-image-layout/src/pages/build.astro
new file mode 100644
index 000000000..a4a0cc908
--- /dev/null
+++ b/packages/astro/test/fixtures/core-image-layout/src/pages/build.astro
@@ -0,0 +1,66 @@
+---
+import { Image } from "astro:assets";
+import penguin from "../assets/penguin.jpg";
+---
+
+<div class="both">
+ <Image src={penguin} alt="a penguin" width={300} height={400}/>
+</div>
+
+<div class="width-only">
+ <Image src={penguin} alt="a penguin" width={300}/>
+</div>
+
+<div class="height-only">
+ <Image src={penguin} alt="a penguin" height={400}/>
+</div>
+
+<div class="fit-contain">
+ <Image
+ src={penguin}
+ alt="a penguin"
+ width={300}
+ height={400}
+ fit="contain"
+ />
+</div>
+
+<div class="fit-scale-down">
+ <Image
+ src={penguin}
+ alt="a penguin"
+ width={300}
+ height={400}
+ fit="scale-down"
+ />
+</div>
+
+<div class="fit-outside">
+ <Image
+ src={penguin}
+ alt="a penguin"
+ width={300}
+ height={400}
+ fit="outside"
+ />
+</div>
+
+<div class="fit-inside">
+ <Image
+ src={penguin}
+ alt="a penguin"
+ width={300}
+ height={400}
+ fit="inside"
+ />
+</div>
+
+<div class="too-large">
+ <Image
+ src={penguin}
+ alt="a penguin"
+ width={3000}
+ height={2000}
+ fit="cover"
+ />
+</div>
diff --git a/packages/astro/test/fixtures/core-image-layout/src/pages/fit.astro b/packages/astro/test/fixtures/core-image-layout/src/pages/fit.astro
new file mode 100644
index 000000000..442f4ffb0
--- /dev/null
+++ b/packages/astro/test/fixtures/core-image-layout/src/pages/fit.astro
@@ -0,0 +1,35 @@
+---
+import { Image } from "astro:assets";
+import penguin from "../assets/penguin.jpg";
+---
+
+<div id="local-both">
+ <Image src={penguin} alt="a penguin" width={300} height={400}/>
+</div>
+<div id="fit-default">
+ <Image src={penguin} alt="a penguin" />
+</div>
+<div id="fit-fill">
+ <Image src={penguin} alt="a penguin" fit="fill" />
+</div>
+<div id="fit-contain">
+ <Image src={penguin} alt="a penguin" fit="contain" />
+</div>
+<div id="fit-cover">
+ <Image src={penguin} alt="a penguin" fit="cover" />
+</div>
+<div id="fit-scale-down">
+ <Image src={penguin} alt="a penguin" fit="scale-down" />
+</div>
+<div id="fit-inside">
+ <Image src={penguin} alt="a penguin" fit="inside" />
+</div>
+<div id="fit-none">
+ <Image src={penguin} alt="a penguin" fit="none" />
+</div>
+<div id="fit-unsupported">
+ <Image src={penguin} alt="a penguin" fit="unsupported" />
+</div>
+<div id="position">
+ <Image src={penguin} alt="a penguin" position="right top" />
+</div>
diff --git a/packages/astro/test/fixtures/core-image-layout/src/pages/index.astro b/packages/astro/test/fixtures/core-image-layout/src/pages/index.astro
new file mode 100644
index 000000000..7fe5b5626
--- /dev/null
+++ b/packages/astro/test/fixtures/core-image-layout/src/pages/index.astro
@@ -0,0 +1,56 @@
+---
+import { Image, Picture } from "astro:assets";
+import penguin from "../assets/penguin.jpg";
+---
+
+
+<div id="local">
+ <Image src={penguin} alt="a penguin" />
+</div>
+<div id="local-priority">
+ <Image src={penguin} alt="a penguin" priority />
+</div>
+
+<div id="local-width">
+ <Image src={penguin} alt="a penguin" width={350} />
+</div>
+
+<div id="local-height">
+ <Image src={penguin} alt="a penguin" height={200}/>
+</div>
+
+<div id="local-both">
+ <Image src={penguin} alt="a penguin" width={300} height={400}/>
+</div>
+
+<div id="local-class">
+ <Image src={penguin} alt="a penguin" width={300} height={400} class="green"/>
+</div>
+
+<div id="local-style">
+ <Image src={penguin} alt="a penguin" width={300} height={400} style="border: 2px red solid"/>
+</div>
+
+<div id="local-style-object">
+ <Image src={penguin} alt="a penguin" width={300} height={400} style={{
+ border: '2px red solid',
+ }}/>
+</div>
+
+<div id="local-constrained">
+ <Image src={penguin} alt="a penguin" width={800} height={600} />
+</div>
+
+<div id="local-fixed">
+ <Image src={penguin} alt="a penguin" width={800} height={600} layout="fixed"/>
+</div>
+
+<div id="local-full-width">
+ <Image src={penguin} alt="a penguin" width={800} height={600} layout="full-width"/>
+</div>
+
+<style>
+ .green {
+ border: 2px green solid;
+ }
+</style>
diff --git a/packages/astro/test/fixtures/core-image-layout/src/pages/picture.astro b/packages/astro/test/fixtures/core-image-layout/src/pages/picture.astro
new file mode 100644
index 000000000..88d0310ef
--- /dev/null
+++ b/packages/astro/test/fixtures/core-image-layout/src/pages/picture.astro
@@ -0,0 +1,63 @@
+---
+import { Picture } from "astro:assets";
+import myImage from "../assets/penguin.jpg";
+---
+
+<div id="picture-density-2-format">
+<Picture src={myImage} width={Math.floor(myImage.width / 2)} alt="A penguin" formats={['avif', 'webp']} />
+</div>
+
+
+<div id="picture-fallback">
+<Picture src={myImage} fallbackFormat="jpeg" alt="A penguin" />
+</div>
+
+<div id="picture-attributes">
+ <Picture src={myImage} fallbackFormat="jpeg" alt="A penguin" class="img-comp" pictureAttributes={{ class: 'picture-comp' }} />
+</div>
+
+<div id="picture-mime-types">
+ <Picture alt="A penguin" src={myImage} formats={['jpg', 'jpeg', 'png', 'avif', 'webp']} />
+</div>
+
+<div id="picture-constrained">
+ <Picture src={myImage} width={800} alt="A penguin" />
+</div>
+
+<div id="picture-small">
+ <Picture src={myImage} width={300} alt="A penguin" />
+</div>
+
+<div id="picture-both">
+ <Picture src={myImage} width={300} height={400} alt="A penguin" />
+</div>
+
+<div id="picture-fixed">
+ <Picture src={myImage} width={400} height={300} layout="fixed" alt="A penguin" />
+</div>
+
+<div id="picture-full-width">
+ <Picture src={myImage} layout="full-width" alt="A penguin" />
+</div>
+
+<div id="picture-style">
+ <Picture src={myImage} alt="a penguin" width={300} height={400} style="border: 2px red solid"/>
+</div>
+
+<div id="picture-style-object">
+ <Picture src={myImage} alt="a penguin" width={300} height={400} style={{
+ border: '2px red solid',
+ }}/>
+</div>
+
+
+<style>
+ .img-comp {
+ border: 5px solid blue;
+ }
+
+ .picture-comp {
+ border: 5px solid red;
+ display: inline-block;
+ }
+</style>
diff --git a/packages/astro/test/fixtures/core-image-layout/src/pages/remote.astro b/packages/astro/test/fixtures/core-image-layout/src/pages/remote.astro
new file mode 100644
index 000000000..60aa916c8
--- /dev/null
+++ b/packages/astro/test/fixtures/core-image-layout/src/pages/remote.astro
@@ -0,0 +1,25 @@
+---
+import { Image, Picture } from "astro:assets";
+
+const penguin = "https://images.unsplash.com/photo-1670392957807-b0504fc5160a?q=80&w=2670&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D"
+
+---
+
+
+
+<div id="small">
+ <Image src={penguin} alt="a penguin" width={300} height={400}/>
+</div>
+
+<div id="constrained">
+ <Image src={penguin} alt="a penguin" width={800} height={600} />
+</div>
+
+<div id="fixed">
+ <Image src={penguin} alt="a penguin" width={800} height={600} layout="fixed"/>
+</div>
+
+<div id="full-width">
+ <Image src={penguin} alt="a penguin" width={800} height={600} layout="full-width"/>
+</div>
+
diff --git a/packages/astro/test/fixtures/core-image-layout/tsconfig.json b/packages/astro/test/fixtures/core-image-layout/tsconfig.json
new file mode 100644
index 000000000..c193287fc
--- /dev/null
+++ b/packages/astro/test/fixtures/core-image-layout/tsconfig.json
@@ -0,0 +1,11 @@
+{
+ "extends": "astro/tsconfigs/base",
+ "compilerOptions": {
+ "baseUrl": ".",
+ "paths": {
+ "~/assets/*": ["src/assets/*"]
+ },
+ },
+ "include": [".astro/types.d.ts", "**/*"],
+ "exclude": ["dist"]
+}
diff --git a/packages/astro/test/ssr-assets.test.js b/packages/astro/test/ssr-assets.test.js
index d56ad1686..d11fc8673 100644
--- a/packages/astro/test/ssr-assets.test.js
+++ b/packages/astro/test/ssr-assets.test.js
@@ -22,7 +22,7 @@ describe('SSR Assets', () => {
const app = await fixture.loadTestAdapterApp();
/** @type {Set<string>} */
const assets = app.manifest.assets;
- assert.equal(assets.size, 1);
+ assert.equal(assets.size, 2);
assert.equal(Array.from(assets)[0].endsWith('.css'), true);
});
});
diff --git a/packages/astro/test/test-remote-image-service.js b/packages/astro/test/test-remote-image-service.js
new file mode 100644
index 000000000..2534b4085
--- /dev/null
+++ b/packages/astro/test/test-remote-image-service.js
@@ -0,0 +1,26 @@
+import { fileURLToPath } from 'node:url';
+import { baseService } from '../dist/assets/services/service.js';
+
+/**
+ * stub remote image service
+ * @param {{ foo?: string }} [config]
+ */
+export function testRemoteImageService(config = {}) {
+ return {
+ entrypoint: fileURLToPath(import.meta.url),
+ config,
+ };
+}
+
+/** @type {import("../dist/types/public/index.js").LocalImageService} */
+export default {
+ ...baseService,
+ propertiesToHash: [...baseService.propertiesToHash, 'data-custom'],
+ getHTMLAttributes(options, serviceConfig) {
+ options['data-service'] = 'my-custom-service';
+ if (serviceConfig.service.config.foo) {
+ options['data-service-config'] = serviceConfig.service.config.foo;
+ }
+ return baseService.getHTMLAttributes(options);
+ },
+};
diff --git a/packages/astro/test/units/dev/collections-renderentry.test.js b/packages/astro/test/units/dev/collections-renderentry.test.js
index 5af4a1b1d..42e11c2a2 100644
--- a/packages/astro/test/units/dev/collections-renderentry.test.js
+++ b/packages/astro/test/units/dev/collections-renderentry.test.js
@@ -101,7 +101,7 @@ describe('Content Collections - render()', () => {
assert.equal($('ul li').length, 3);
// Rendered the styles
- assert.equal($('style').length, 1);
+ assert.equal($('style').length, 2);
},
);
});
@@ -158,7 +158,7 @@ describe('Content Collections - render()', () => {
assert.equal($('ul li').length, 3);
// Rendered the styles
- assert.equal($('style').length, 1);
+ assert.equal($('style').length, 2);
},
);
});
@@ -225,7 +225,7 @@ describe('Content Collections - render()', () => {
assert.equal($('ul li').length, 3);
// Rendered the styles
- assert.equal($('style').length, 1);
+ assert.equal($('style').length, 2);
},
);
});
@@ -291,7 +291,7 @@ describe('Content Collections - render()', () => {
assert.equal($('ul li').length, 3);
// Rendered the styles
- assert.equal($('style').length, 1);
+ assert.equal($('style').length, 2);
},
);
});
diff --git a/packages/integrations/markdoc/test/image-assets.test.js b/packages/integrations/markdoc/test/image-assets.test.js
index 0f98af4f1..793bf1be6 100644
--- a/packages/integrations/markdoc/test/image-assets.test.js
+++ b/packages/integrations/markdoc/test/image-assets.test.js
@@ -38,7 +38,7 @@ describe('Markdoc - Image assets', () => {
const { document } = parseHTML(html);
assert.match(
document.querySelector('#relative > img')?.src,
- /\/_image\?href=.*%2Fsrc%2Fassets%2Frelative%2Foar.jpg%3ForigWidth%3D420%26origHeight%3D630%26origFormat%3Djpg&f=webp/,
+ /\/_image\?href=.*%2Fsrc%2Fassets%2Frelative%2Foar.jpg%3ForigWidth%3D420%26origHeight%3D630%26origFormat%3Djpg&w=420&h=630&f=webp/,
);
});
@@ -48,7 +48,7 @@ describe('Markdoc - Image assets', () => {
const { document } = parseHTML(html);
assert.match(
document.querySelector('#alias > img')?.src,
- /\/_image\?href=.*%2Fsrc%2Fassets%2Falias%2Fcityscape.jpg%3ForigWidth%3D420%26origHeight%3D280%26origFormat%3Djpg&f=webp/,
+ /\/_image\?href=.*%2Fsrc%2Fassets%2Falias%2Fcityscape.jpg%3ForigWidth%3D420%26origHeight%3D280%26origFormat%3Djpg&w=420&h=280&f=webp/,
);
});
diff --git a/packages/integrations/markdoc/test/propagated-assets.test.js b/packages/integrations/markdoc/test/propagated-assets.test.js
index a0768448f..5fe7369ce 100644
--- a/packages/integrations/markdoc/test/propagated-assets.test.js
+++ b/packages/integrations/markdoc/test/propagated-assets.test.js
@@ -45,12 +45,12 @@ describe('Markdoc - propagated assets', () => {
let styleContents;
if (mode === 'dev') {
const styles = stylesDocument.querySelectorAll('style');
- assert.equal(styles.length, 1);
- styleContents = styles[0].textContent;
+ assert.equal(styles.length, 2);
+ styleContents = styles[1].textContent;
} else {
const links = stylesDocument.querySelectorAll('link[rel="stylesheet"]');
- assert.equal(links.length, 1);
- styleContents = await fixture.readFile(links[0].href);
+ assert.equal(links.length, 2);
+ styleContents = await fixture.readFile(links[1].href);
}
assert.equal(styleContents.includes('--color-base-purple: 269, 79%;'), true);
});
@@ -58,10 +58,10 @@ describe('Markdoc - propagated assets', () => {
it('[fails] Does not bleed styles to other page', async () => {
if (mode === 'dev') {
const styles = scriptsDocument.querySelectorAll('style');
- assert.equal(styles.length, 0);
+ assert.equal(styles.length, 1);
} else {
const links = scriptsDocument.querySelectorAll('link[rel="stylesheet"]');
- assert.equal(links.length, 0);
+ assert.equal(links.length, 1);
}
});
});
diff --git a/packages/integrations/mdx/test/css-head-mdx.test.js b/packages/integrations/mdx/test/css-head-mdx.test.js
index 96ee7c900..d55e2f52a 100644
--- a/packages/integrations/mdx/test/css-head-mdx.test.js
+++ b/packages/integrations/mdx/test/css-head-mdx.test.js
@@ -28,7 +28,7 @@ describe('Head injection w/ MDX', () => {
const { document } = parseHTML(html);
const links = document.querySelectorAll('head link[rel=stylesheet]');
- assert.equal(links.length, 1);
+ assert.equal(links.length, 2);
const scripts = document.querySelectorAll('script[type=module]');
assert.equal(scripts.length, 1);
@@ -39,7 +39,7 @@ describe('Head injection w/ MDX', () => {
const { document } = parseHTML(html);
const links = document.querySelectorAll('head link[rel=stylesheet]');
- assert.equal(links.length, 1);
+ assert.equal(links.length, 2);
});
it('injects content from a component using Content#render()', async () => {
@@ -47,7 +47,7 @@ describe('Head injection w/ MDX', () => {
const { document } = parseHTML(html);
const links = document.querySelectorAll('head link[rel=stylesheet]');
- assert.equal(links.length, 1);
+ assert.equal(links.length, 2);
const scripts = document.querySelectorAll('script[type=module]');
assert.equal(scripts.length, 1);
@@ -67,7 +67,7 @@ describe('Head injection w/ MDX', () => {
const $ = cheerio.load(html);
const headLinks = $('head link[rel=stylesheet]');
- assert.equal(headLinks.length, 1);
+ assert.equal(headLinks.length, 2);
const bodyLinks = $('body link[rel=stylesheet]');
assert.equal(bodyLinks.length, 0);
@@ -79,7 +79,7 @@ describe('Head injection w/ MDX', () => {
const $ = cheerio.load(html);
const headLinks = $('head link[rel=stylesheet]');
- assert.equal(headLinks.length, 1);
+ assert.equal(headLinks.length, 2);
const bodyLinks = $('body link[rel=stylesheet]');
assert.equal(bodyLinks.length, 0);
@@ -92,7 +92,7 @@ describe('Head injection w/ MDX', () => {
const $ = cheerio.load(html);
const headLinks = $('head link[rel=stylesheet]');
- assert.equal(headLinks.length, 1);
+ assert.equal(headLinks.length, 2);
const bodyLinks = $('body link[rel=stylesheet]');
assert.equal(bodyLinks.length, 0);
diff --git a/packages/integrations/mdx/test/mdx-math.test.js b/packages/integrations/mdx/test/mdx-math.test.js
index 5352eca68..a68c5cbe7 100644
--- a/packages/integrations/mdx/test/mdx-math.test.js
+++ b/packages/integrations/mdx/test/mdx-math.test.js
@@ -28,7 +28,7 @@ describe('MDX math', () => {
const mjxContainer = document.querySelector('mjx-container[jax="SVG"]');
assert.notEqual(mjxContainer, null);
- const mjxStyle = document.querySelector('style').innerHTML;
+ const mjxStyle = document.querySelectorAll('style')[1].innerHTML;
assert.equal(
mjxStyle.includes('mjx-container[jax="SVG"]'),
true,
@@ -62,7 +62,7 @@ describe('MDX math', () => {
const mjxContainer = document.querySelector('mjx-container[jax="CHTML"]');
assert.notEqual(mjxContainer, null);
- const mjxStyle = document.querySelector('style').innerHTML;
+ const mjxStyle = document.querySelectorAll('style')[1].innerHTML;
assert.equal(
mjxStyle.includes('mjx-container[jax="CHTML"]'),
true,
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index bae575df2..dfe8af203 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -2719,6 +2719,12 @@ importers:
specifier: workspace:*
version: link:../../..
+ packages/astro/test/fixtures/core-image-layout:
+ dependencies:
+ astro:
+ specifier: workspace:*
+ version: link:../../..
+
packages/astro/test/fixtures/core-image-remark-imgattr:
dependencies:
astro: