Skip to content
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/sweet-paths-open.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'astro': patch
---

Fixes `image.responsiveStyles` emitting invalid `object-position` CSS values for same-axis keyword pairs (`top bottom`, `left right`, etc.)
13 changes: 11 additions & 2 deletions packages/astro/src/assets/utils/generateImageStylesCSS.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,16 @@ import { cssFitValues } from '../internal.js';
// data-attribute-driven CSS rules so that no inline styles are needed (CSP-safe).
const POSITION_KEYWORDS = ['top', 'bottom', 'left', 'right', 'center'];

// CSS <position> two-value syntax requires one keyword per axis.
// Same-axis pairs like `top bottom` or `left right` are invalid.
const VERTICAL_ONLY = new Set(['top', 'bottom']);
const HORIZONTAL_ONLY = new Set(['left', 'right']);

/**
* Builds every 1- and 2-keyword combination of object-position values:
* Builds every valid 1- and 2-keyword combination of object-position values:
* center, top, bottom, left, right, top left, top center, top right, etc.
* Same-axis pairs (e.g. `top bottom`, `left right`) are excluded because
* the CSS `<position>` grammar forbids two keywords on the same axis.
* Returns entries as `[dataAttrValue, cssValue]` pairs where the data-attr
* value has spaces replaced with dashes (matching the normalisation in internal.ts).
*/
Expand All @@ -19,10 +26,12 @@ function getPositionEntries(): Array<[dataAttr: string, cssValue: string]> {
entries.push([kw, kw]);
}

// Two-keyword combinations
// Two-keyword combinations (cross-axis only)
for (const a of POSITION_KEYWORDS) {
for (const b of POSITION_KEYWORDS) {
if (a === b) continue;
if (VERTICAL_ONLY.has(a) && VERTICAL_ONLY.has(b)) continue;
if (HORIZONTAL_ONLY.has(a) && HORIZONTAL_ONLY.has(b)) continue;
const cssValue = `${a} ${b}`;
const dataAttr = `${a}-${b}`;
entries.push([dataAttr, cssValue]);
Expand Down
53 changes: 46 additions & 7 deletions packages/astro/test/units/assets/utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -326,13 +326,30 @@ describe('generateImageStylesCSS', () => {

it('emits position rules for two-keyword combinations', () => {
const css = generateImageStylesCSS();
// Spot-check several common combinations
assert.ok(css.includes('[data-astro-image-pos="top-left"]'));
assert.ok(css.includes('object-position: top left'));
assert.ok(css.includes('[data-astro-image-pos="bottom-right"]'));
assert.ok(css.includes('object-position: bottom right'));
assert.ok(css.includes('[data-astro-image-pos="center-top"]'));
assert.ok(css.includes('object-position: center top'));
// Every two-keyword <position> must use one keyword per axis. `center` can
// pair with a keyword from either axis, so all 16 ordered pairs below are
// valid, in both keyword orders.
const vertical = ['top', 'bottom'];
const horizontal = ['left', 'right'];
for (const v of [...vertical, 'center']) {
for (const h of [...horizontal, 'center']) {
if (v === 'center' && h === 'center') continue;
for (const [a, b] of [
[v, h],
[h, v],
]) {
const dataAttr = `${a}-${b}`;
assert.ok(
css.includes(`[data-astro-image-pos="${dataAttr}"]`),
`missing rule for pos="${dataAttr}"`,
);
assert.ok(
css.includes(`object-position: ${a} ${b}`),
`missing object-position: ${a} ${b}`,
);
}
}
}
});

it('does not emit duplicate single-keyword combinations (e.g. top-top)', () => {
Expand All @@ -341,6 +358,28 @@ describe('generateImageStylesCSS', () => {
assert.ok(!css.includes('[data-astro-image-pos="center-center"]'));
});

it('does not emit invalid same-axis position pairs', () => {
const css = generateImageStylesCSS();
// Two-keyword <position> values must use one keyword per axis, so
// same-axis pairs are invalid and must not be emitted.
for (const [a, b] of [
['top', 'bottom'],
['bottom', 'top'],
['left', 'right'],
['right', 'left'],
]) {
const dataAttr = `${a}-${b}`;
assert.ok(
!css.includes(`[data-astro-image-pos="${dataAttr}"]`),
`${dataAttr} is invalid (same axis)`,
);
assert.ok(
!css.includes(`object-position: ${a} ${b}`),
`object-position: ${a} ${b} is invalid (same axis)`,
);
}
});

it('includes default position fallback when configured', () => {
const css = generateImageStylesCSS(undefined, 'center');
assert.ok(css.includes(':where([data-astro-image]:not([data-astro-image-pos]))'));
Expand Down
Loading