Skip to content

Commit aedbbd8

Browse files
ematipicoascorbic
andauthored
feat(csp): support responsive images (#15407)
Co-authored-by: Matt Kane <[email protected]>
1 parent 5f071e4 commit aedbbd8

File tree

24 files changed

+353
-96
lines changed

24 files changed

+353
-96
lines changed

.changeset/ripe-nights-feel.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
'astro': minor
3+
---
4+
5+
Responsive images are now supported when `security.csp` is enabled, out of the box.
6+
7+
Before, the styles for responsive images were injected using the `style="""` attribute, and the image would look like this:
8+
9+
```html
10+
<img style="--fit: <value>; --pos: <value>" >
11+
```
12+
13+
After this change, styles now use a combination of `class=""` and data attributes. The image would look like this:
14+
15+
```html
16+
<img class="__a_HaSh350" data-atro-fit="value" data-astro-pos="value" >
17+
```

.changeset/upset-dodos-rhyme.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'astro': major
3+
---
4+
5+
Changes how styles of responsive images are emitted - ([v6 upgrade guidance](https://v6.docs.astro.build/en/guides/upgrade-to/v6/#changed-how-responsive-image-styles-are-emitted))

packages/astro/client.d.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,11 @@ declare module 'astro:assets' {
8484
}: AstroAssets;
8585
}
8686

87+
declare module 'virtual:astro:image-styles.css' {
88+
const styles: string;
89+
export default styles;
90+
}
91+
8792
type ImageMetadata = import('./dist/assets/types.js').ImageMetadata;
8893

8994
declare module '*.gif' {

packages/astro/components/ResponsiveImage.astro

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,7 @@ import Image from './Image.astro';
44
55
type Props = LocalImageProps | RemoteImageProps;
66
7-
const { class: className, ...props } = Astro.props;
8-
9-
import './image.css';
7+
const { class: className, ...props} = Astro.props;
108
---
119

1210
{/* Applying class outside of the spread prevents it from applying unnecessary astro-* classes */}

packages/astro/components/ResponsivePicture.astro

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@ import { default as Picture, type Props as PictureProps } from './Picture.astro'
44
type Props = PictureProps;
55
66
const { class: className, ...props } = Astro.props;
7-
import './image.css';
87
---
98

109
{/* Applying class outside of the spread prevents it from applying unnecessary astro-* classes */}

packages/astro/components/image.css

Lines changed: 0 additions & 11 deletions
This file was deleted.
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import { expect } from '@playwright/test';
2+
import { testFactory } from './test-utils.js';
3+
4+
const test = testFactory(import.meta.url, {
5+
root: '../test/fixtures/core-image-layout/',
6+
devToolbar: {
7+
enabled: false,
8+
},
9+
});
10+
11+
let devServer;
12+
13+
test.beforeAll(async ({ astro }) => {
14+
devServer = await astro.startDevServer();
15+
});
16+
17+
test.afterAll(async () => {
18+
await devServer.stop();
19+
});
20+
21+
test.describe('Image styles injection', () => {
22+
test('injects a style tag with [data-astro-image] CSS', async ({ page, astro }) => {
23+
await page.goto(astro.resolveUrl('/'));
24+
25+
// Wait for client-side CSS injection
26+
await page.waitForLoadState('networkidle');
27+
28+
// Check all style tags for image styles
29+
const styleTags = await page.locator('style').all();
30+
let foundImageStyles = false;
31+
32+
for (const styleTag of styleTags) {
33+
const content = await styleTag.textContent();
34+
if (content && content.includes('[data-astro-image]')) {
35+
foundImageStyles = true;
36+
break;
37+
}
38+
}
39+
40+
expect(foundImageStyles).toBe(true);
41+
});
42+
});

packages/astro/src/assets/consts.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
export const VIRTUAL_MODULE_ID = 'astro:assets';
22
export const RESOLVED_VIRTUAL_MODULE_ID = '\0' + VIRTUAL_MODULE_ID;
33
export const VIRTUAL_SERVICE_ID = 'virtual:image-service';
4+
// Must keep the extension so we trigger the pipeline of CSS files
5+
export const VIRTUAL_IMAGE_STYLES_ID = 'virtual:astro:image-styles.css';
6+
export const RESOLVED_VIRTUAL_IMAGE_STYLES_ID = '\0' + VIRTUAL_IMAGE_STYLES_ID;
47
export const VALID_INPUT_FORMATS = [
58
'jpeg',
69
'jpg',

packages/astro/src/assets/internal.ts

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -17,11 +17,12 @@ import {
1717
type SrcSetValue,
1818
type UnresolvedImageTransform,
1919
} from './types.js';
20-
import { addCSSVarsToStyle, cssFitValues } from './utils/imageAttributes.js';
2120
import { isESMImportedImage, isRemoteImage, resolveSrc } from './utils/imageKind.js';
2221
import { inferRemoteSize } from './utils/remoteProbe.js';
2322
import { createPlaceholderURL, stringifyPlaceholderURL } from './utils/url.js';
2423

24+
export const cssFitValues = ['fill', 'contain', 'cover', 'scale-down'];
25+
2526
export async function getConfiguredImageService(): Promise<ImageService> {
2627
if (!globalThis?.astroAsset?.imageService) {
2728
const { default: service }: { default: ImageService } = await import(
@@ -149,14 +150,19 @@ export async function getImage(
149150
resolvedOptions.sizes ||= getSizesAttribute({ width: resolvedOptions.width, layout });
150151
// The densities option is incompatible with the `layout` option
151152
delete resolvedOptions.densities;
152-
resolvedOptions.style = addCSSVarsToStyle(
153-
{
154-
fit: cssFitValues.includes(resolvedOptions.fit ?? '') && resolvedOptions.fit,
155-
pos: resolvedOptions.position,
156-
},
157-
resolvedOptions.style,
158-
);
153+
154+
// Set data attribute for layout
159155
resolvedOptions['data-astro-image'] = layout;
156+
157+
// Set data attributes for fit and position for CSP-compliant styling
158+
if (resolvedOptions.fit && cssFitValues.includes(resolvedOptions.fit)) {
159+
resolvedOptions['data-astro-image-fit'] = resolvedOptions.fit;
160+
}
161+
162+
if (resolvedOptions.position) {
163+
// Normalize position value for data attribute (spaces to dashes)
164+
resolvedOptions['data-astro-image-pos'] = resolvedOptions.position.replace(/\s+/g, '-');
165+
}
160166
}
161167

162168
const validatedOptions = service.validateOptions
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { cssFitValues } from '../internal.js';
2+
3+
export function generateImageStylesCSS(
4+
defaultObjectFit?: string,
5+
defaultObjectPosition?: string,
6+
): string {
7+
const fitStyles = cssFitValues
8+
.map(
9+
(fit) => `
10+
[data-astro-image-fit="${fit}"] {
11+
object-fit: ${fit};
12+
}`,
13+
)
14+
.join('\n');
15+
16+
const defaultFitStyle =
17+
defaultObjectFit && cssFitValues.includes(defaultObjectFit)
18+
? `
19+
:where([data-astro-image]:not([data-astro-image-fit])) {
20+
object-fit: ${defaultObjectFit};
21+
}`
22+
: '';
23+
24+
const positionStyle = defaultObjectPosition
25+
? `
26+
[data-astro-image-pos="${defaultObjectPosition.replace(/\s+/g, '-')}"] {
27+
object-position: ${defaultObjectPosition};
28+
}
29+
30+
:where([data-astro-image]:not([data-astro-image-pos])) {
31+
object-position: ${defaultObjectPosition};
32+
}`
33+
: '';
34+
return `
35+
:where([data-astro-image]) {
36+
height: auto;
37+
}
38+
:where([data-astro-image="full-width"]) {
39+
width: 100%;
40+
}
41+
:where([data-astro-image="constrained"]) {
42+
max-width: 100%;
43+
}
44+
${fitStyles}
45+
${defaultFitStyle}
46+
${positionStyle}
47+
`.trim();
48+
}

0 commit comments

Comments
 (0)