Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: experimental responsive images #12377

Merged
merged 15 commits into from
Nov 15, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/proud-terms-swim.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'astro': patch
---

Adds experimental reponsive image support
4 changes: 3 additions & 1 deletion packages/astro/client.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
29 changes: 26 additions & 3 deletions packages/astro/components/Image.astro
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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) {
Expand All @@ -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} />
39 changes: 33 additions & 6 deletions packages/astro/components/Picture.astro
Original file line number Diff line number Diff line change
@@ -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[];
Expand Down Expand Up @@ -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];
Expand All @@ -53,7 +70,7 @@ const optimizedImages: GetImageResult[] = await Promise.all(
format: format,
widths: props.widths,
densities: props.densities,
}),
} as UnresolvedImageTransform),
),
);

Expand All @@ -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'> = {};
Expand All @@ -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';
}
Expand All @@ -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 (
Expand All @@ -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>
17 changes: 17 additions & 0 deletions packages/astro/components/image.css
Original file line number Diff line number Diff line change
@@ -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);
}
10 changes: 9 additions & 1 deletion packages/astro/src/assets/consts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
];
102 changes: 89 additions & 13 deletions packages/astro/src/assets/internal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -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 &&
Expand All @@ -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
}

Expand All @@ -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;
Expand All @@ -100,13 +162,23 @@ export async function getImage(
: [];

let imageURL = await service.getURL(validatedOptions, imageConfig);

const matchesOriginal = (transform: ImageTransform) =>
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In theory, couldn't the quality be different here? Or any other parameters that affects the image, for image services who support more.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think in this scenario it can't, as this is just checking if it's the srcset entry which is the same as the src. I don't think we expose a way to set this separately.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can't an image service set a different quality for a srcset transform?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess in theory. I'm comfortable with not supporting that though. It's a terrible idea.

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 (
Expand All @@ -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 {
Expand Down
Loading
Loading