diff --git a/packages/astro/components/Image.astro b/packages/astro/components/Image.astro
index 4e55f5608b..b873e1ebad 100644
--- a/packages/astro/components/Image.astro
+++ b/packages/astro/components/Image.astro
@@ -1,5 +1,5 @@
---
-import { type LocalImageProps, type RemoteImageProps, getImage } from 'astro:assets';
+import { type LocalImageProps, type RemoteImageProps, getImage, imageConfig } from 'astro:assets';
import { AstroError, AstroErrorData } from '../dist/core/errors/index.js';
import type { HTMLAttributes } from '../types';
@@ -33,6 +33,60 @@ if (image.srcSet.values.length > 0) {
if (import.meta.env.DEV) {
additionalAttributes['data-image-component'] = 'true';
}
+
+const { experimentalResponsiveImages } = imageConfig;
+
+const layoutClassMap = {
+ fixed: 'aim-fi',
+ responsive: 'aim-re',
+};
+
+const cssFitValues = ['fill', 'contain', 'cover', 'scale-down'];
+const objectFit = props.fit ?? imageConfig.experimentalObjectFit ?? 'cover';
+const objectPosition = props.position ?? imageConfig.experimentalObjectPosition ?? 'center';
+
+// The style prop can't be spread when using define:vars, so we need to extract it here
+// @see https://github.com/withastro/compiler/issues/1050
+const { style = '', class: className, ...attrs } = { ...additionalAttributes, ...image.attributes };
---
-
+{
+ experimentalResponsiveImages ? (
+
+ ) : (
+
+ )
+}
+
+
diff --git a/packages/astro/src/assets/internal.ts b/packages/astro/src/assets/internal.ts
index 07584c4e5f..235f1115be 100644
--- a/packages/astro/src/assets/internal.ts
+++ b/packages/astro/src/assets/internal.ts
@@ -12,6 +12,12 @@ import {
} from './types.js';
import { isESMImportedImage, isRemoteImage, resolveSrc } from './utils/imageKind.js';
import { inferRemoteSize } from './utils/remoteProbe.js';
+import {
+ DEFAULT_RESOLUTIONS,
+ getSizesAttribute,
+ getWidths,
+ LIMITED_RESOLUTIONS,
+} from './layout.js';
export async function getConfiguredImageService(): Promise {
if (!globalThis?.astroAsset?.imageService) {
@@ -32,9 +38,13 @@ export async function getConfiguredImageService(): Promise {
return globalThis.astroAsset.imageService;
}
+type ImageConfig = AstroConfig['image'] & {
+ experimentalResponsiveImages: boolean;
+};
+
export async function getImage(
options: UnresolvedImageTransform,
- imageConfig: AstroConfig['image'],
+ imageConfig: ImageConfig,
): Promise {
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,49 @@ 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: 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 +158,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 +188,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 0000000000..a7fabf4e13
--- /dev/null
+++ b/packages/astro/src/assets/layout.ts
@@ -0,0 +1,118 @@
+import type { ImageLayout } from '../types/public/index.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;
+ originalWidth?: number;
+}): Array => {
+ 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 e22bada898..23c983b8c5 100644
--- a/packages/astro/src/assets/services/service.ts
+++ b/packages/astro/src/assets/services/service.ts
@@ -118,6 +118,8 @@ export type BaseServiceTransform = {
quality?: string | null;
};
+const sortNumeric = (a: number, b: number) => a - b;
+
/**
* Basic local service using the included `_image` endpoint.
* This service intentionally does not implement `transform`.
@@ -224,9 +226,19 @@ export const baseService: Omit = {
},
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,
+ ...attributes
+ } = options;
return {
...attributes,
width: targetWidth,
@@ -235,12 +247,14 @@ export const baseService: Omit = {
decoding: attributes.decoding ?? 'async',
};
},
- getSrcSet(options) {
- const srcSet: UnresolvedSrcSetValue[] = [];
- const { targetWidth } = getTargetDimensions(options);
+ getSrcSet(options): Array {
+ 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 +264,18 @@ export const baseService: Omit = {
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 +285,10 @@ export const baseService: Omit = {
} = 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 +301,31 @@ export const baseService: Omit = {
// 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();
diff --git a/packages/astro/src/assets/types.ts b/packages/astro/src/assets/types.ts
index 6dcb88f432..e4687adfdf 100644
--- a/packages/astro/src/assets/types.ts
+++ b/packages/astro/src/assets/types.ts
@@ -162,6 +162,18 @@ type ImageSharedProps = T & {
position?: string;
} & (
| {
+ /**
+ * The layout type for responsive images. Overrides any default set in the Astro config.
+ * 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.
+ */
+ layout?: ImageLayout;
+ fit?: 'fill' | 'contain' | 'cover' | 'none' | 'scale-down' | (string & {});
+ position?: string;
+ 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.
*
@@ -178,6 +190,9 @@ type ImageSharedProps = T & {
*/
densities?: (number | `${number}x`)[];
widths?: never;
+ layout?: never;
+ fit?: never;
+ position?: never;
}
);
diff --git a/packages/astro/test/content-collections-render.test.js b/packages/astro/test/content-collections-render.test.js
index 31ed04a15a..972e4313a5 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 0000000000..205336c0c7
--- /dev/null
+++ b/packages/astro/test/core-image-layout.test.js
@@ -0,0 +1,349 @@
+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 { loadFixture } from './test-utils.js';
+import { testRemoteImageService } from './test-remote-image-service.js';
+
+describe('astro:image:layout', () => {
+ /** @type {import('./test-utils').Fixture} */
+ let fixture;
+
+ describe('local 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: 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
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/);
+ const classes = $img.attr('class').split(' ');
+ assert.ok(classes.includes('aim'));
+ assert.ok(classes.includes('aim-re'));
+ });
+
+ 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/);
+ const classes = $img.attr('class').split(' ');
+ assert.ok(classes.includes('aim'));
+ assert.ok(classes.includes('aim-re'));
+ });
+
+ 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/);
+ const classes = $img.attr('class').split(' ');
+ assert.ok(classes.includes('aim'));
+ assert.ok(classes.includes('aim-fi'));
+ });
+
+ it('sets style for full-width image', () => {
+ let $img = $('#local-full-width img');
+ const classes = $img.attr('class').split(' ');
+ assert.deepEqual(classes, ['aim']);
+ });
+
+ 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('injects a style tag', () => {
+ const style = $('style').text();
+ assert.match(style, /\.aim\[/);
+ assert.match(style, /\.aim-re\[/);
+ assert.match(style, /\.aim-fi\[/);
+ })
+ });
+
+ 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('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('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/fixtures/core-image-layout/astro.config.mjs b/packages/astro/test/fixtures/core-image-layout/astro.config.mjs
new file mode 100644
index 0000000000..b32208e5f6
--- /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 0000000000..ce5b0f966d
--- /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 0000000000..73f0ee316c
Binary files /dev/null and b/packages/astro/test/fixtures/core-image-layout/src/assets/penguin.jpg 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 0000000000..6479e92126
Binary files /dev/null and b/packages/astro/test/fixtures/core-image-layout/src/assets/walrus.jpg differ
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 0000000000..4f28bc8dc3
--- /dev/null
+++ b/packages/astro/test/fixtures/core-image-layout/src/pages/index.astro
@@ -0,0 +1,50 @@
+---
+import { Image, Picture } from "astro:assets";
+import penguin from "../assets/penguin.jpg";
+---
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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 0000000000..60aa916c81
--- /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"
+
+---
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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 0000000000..c193287fcc
--- /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/test-remote-image-service.js b/packages/astro/test/test-remote-image-service.js
new file mode 100644
index 0000000000..2534b4085e
--- /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 5af4a1b1d1..42e11c2a22 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 0f98af4f16..793bf1be6d 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 a0768448f1..5fe7369ceb 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 96ee7c9001..d55e2f52ac 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 5352eca68c..a68c5cbe74 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 953ead5c23..12212e788d 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -2722,6 +2722,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: