diff --git a/.changeset/gallery-cell-sizes.md b/.changeset/gallery-cell-sizes.md new file mode 100644 index 0000000000..16abd63204 --- /dev/null +++ b/.changeset/gallery-cell-sizes.md @@ -0,0 +1,5 @@ +--- +"emdash": patch +--- + +Fixes the Portable Text `Gallery` component telling browsers each image fills the viewport. Its `sizes` attribute now describes one grid cell, using the gallery's column count and its two-column layout at 640px and below, so browsers stop downloading oversized images for multi-column galleries. Pass the new `sizes` prop to `Gallery` when it renders in a container narrower than the viewport. diff --git a/packages/core/src/components/Gallery.astro b/packages/core/src/components/Gallery.astro index fa2840ea26..c7d5a8541c 100644 --- a/packages/core/src/components/Gallery.astro +++ b/packages/core/src/components/Gallery.astro @@ -11,7 +11,12 @@ import { Image as AstroImage } from "astro:assets"; import type { ImageEmbed } from "../media/types.js"; import { getMediaProvider } from "../media/provider-loader.js"; import { buildRenderMediaUrl } from "../media/url.js"; -import { toAbsoluteMediaUrl, RESPONSIVE_BREAKPOINTS } from "../media/responsive.js"; +import { + toAbsoluteMediaUrl, + gallerySizes, + GALLERY_DEFAULT_COLUMNS, + RESPONSIVE_BREAKPOINTS, +} from "../media/responsive.js"; import { getPublicOrigin } from "../api/public-url.js"; import { focalPointToObjectPosition } from "../media/focal-point.js"; @@ -43,6 +48,12 @@ export interface Props { }; /** Render the LQIP placeholder as an inline background. Disable for strict CSP. */ placeholder?: boolean; + /** + * `sizes` attribute for every image in the gallery. Defaults to an estimate + * from the column count against the viewport; set it when the gallery renders + * in a narrower container, e.g. `"(max-width: 640px) 45vw, 220px"`. + */ + sizes?: string; } /** @@ -61,9 +72,11 @@ function generateSrcset( .join(", "); } -const { node, placeholder = true } = Astro.props; +const { node, placeholder = true, sizes: sizesOverride } = Astro.props; const images = node?.images ?? []; -const columns = node?.columns ?? 3; +const columns = node?.columns ?? GALLERY_DEFAULT_COLUMNS; +// Each image fills one grid cell, not the viewport. +const cellSizes = sizesOverride ?? gallerySizes(columns); if (!images.length) { return null; @@ -102,7 +115,7 @@ const resolvedImages = await Promise.all( if (embed.getSrc) { const maxWidth = width || 1200; srcset = generateSrcset(embed.getSrc, maxWidth, aspectRatio); - sizes = width ? `(min-width: ${width}px) ${width}px, 100vw` : "100vw"; + sizes = cellSizes; } } } catch (error) { @@ -166,6 +179,7 @@ const resolvedImages = await Promise.all( width={image.width!} height={image.height!} layout="constrained" + sizes={cellSizes} style={image.objectPosition ? `object-position: ${image.objectPosition};` : undefined} /> ) : ( diff --git a/packages/core/src/media/responsive.ts b/packages/core/src/media/responsive.ts index fe126a0dd3..de13119f67 100644 --- a/packages/core/src/media/responsive.ts +++ b/packages/core/src/media/responsive.ts @@ -32,6 +32,29 @@ export function responsiveSizes(width: number | undefined): string { return width ? `(min-width: ${width}px) ${width}px, 100vw` : "100vw"; } +/** Column count `Gallery.astro` uses when a gallery block does not set one. */ +export const GALLERY_DEFAULT_COLUMNS = 3; + +/** + * Build the `sizes` attribute for one cell of a Portable Text gallery. + * + * Mirrors the gallery's CSS: `columns` equal cells with a `1rem` gap, and two + * columns at `640px` and below. Each cell is estimated against the viewport, so + * a gallery inside a narrower content column still gets a larger slot than it + * renders at; pass `sizes` to `Gallery` when the container width is known. + */ +export function gallerySizes(columns: number | undefined): string { + const count = + typeof columns === "number" && Number.isInteger(columns) && columns > 0 + ? columns + : GALLERY_DEFAULT_COLUMNS; + return `(max-width: 640px) ${galleryCell(2)}, ${galleryCell(count)}`; +} + +function galleryCell(columns: number): string { + return columns === 1 ? "100vw" : `calc((100vw - ${columns - 1}rem) / ${columns})`; +} + /** * Make a same-origin media URL absolute so Astro's image service can optimize it. * diff --git a/packages/core/tests/repro/gallery-sizes.render.test.ts b/packages/core/tests/repro/gallery-sizes.render.test.ts new file mode 100644 index 0000000000..9dbd968ed1 --- /dev/null +++ b/packages/core/tests/repro/gallery-sizes.render.test.ts @@ -0,0 +1,122 @@ +/** + * The gallery's `sizes` must describe a grid cell, not the viewport: each + * image renders in one of `columns` cells (two at 640px and below), and a + * `100vw` estimate lets the browser pick a far larger srcset candidate than + * the cell needs (#2930). + */ +import { experimental_AstroContainer as AstroContainer } from "astro/container"; +import { describe, expect, test } from "vitest"; + +import Gallery from "../../src/components/Gallery.astro"; + +const testMediaProviders = [ + { + id: "mock-gallery-images", + name: "Mock Gallery Images", + capabilities: { list: false, upload: false, delete: false, metadata: false }, + createProvider: () => ({ + id: "mock-gallery-images", + name: "Mock Gallery Images", + capabilities: { list: false, upload: false, delete: false, metadata: false }, + getEmbed: (_value: unknown, options: { width?: number; height?: number } = {}) => ({ + type: "image", + src: `https://img.example.com/original?w=${options.width ?? "auto"}`, + getSrc: ({ width, height }: { width?: number; height?: number } = {}) => + `https://img.example.com/render?w=${width ?? "auto"}&h=${height ?? "auto"}`, + }), + }), + }, +]; + +const providerGlobal = globalThis as typeof globalThis & { + __emdashTestMediaProviders?: typeof testMediaProviders; +}; +providerGlobal.__emdashTestMediaProviders = [ + ...(providerGlobal.__emdashTestMediaProviders ?? []), + ...testMediaProviders, +]; + +const locals = { + emdash: { getPublicMediaUrl: (k: string) => `/_emdash/api/media/file/${k}` }, +}; + +const imgTags = (html: string) => html.match(/]*>/g) ?? []; +const attr = (tag: string, name: string) => + tag.match(new RegExp(`\\b${name}="([^"]*)"`))?.[1]?.replaceAll("&", "&"); + +function providerImage(key: string) { + return { + _type: "image" as const, + _key: key, + asset: { _ref: `provider-${key}`, provider: "mock-gallery-images" }, + alt: key, + width: 1600, + height: 1200, + }; +} + +async function renderGallery(props: Record) { + const container = await AstroContainer.create(); + return container.renderToString(Gallery, { props, locals }); +} + +describe("Gallery sizes", () => { + test("provider images are sized to one of three default columns", async () => { + const html = await renderGallery({ + node: { _type: "gallery", _key: "g", images: [providerImage("a"), providerImage("b")] }, + }); + const tags = imgTags(html); + + expect(tags).toHaveLength(2); + for (const tag of tags) { + expect(attr(tag, "srcset")).toContain("https://img.example.com/render?w=640"); + expect(attr(tag, "sizes")).toBe( + "(max-width: 640px) calc((100vw - 1rem) / 2), calc((100vw - 2rem) / 3)", + ); + } + }); + + test("the slot follows the block's column count", async () => { + const html = await renderGallery({ + node: { _type: "gallery", _key: "g", columns: 4, images: [providerImage("a")] }, + }); + + expect(attr(imgTags(html)[0]!, "sizes")).toBe( + "(max-width: 640px) calc((100vw - 1rem) / 2), calc((100vw - 3rem) / 4)", + ); + }); + + test("locally stored images with dimensions get the same cell estimate", async () => { + const html = await renderGallery({ + node: { + _type: "gallery", + _key: "g", + images: [ + { + _type: "image", + _key: "local", + asset: { _ref: "media-1", url: "/_emdash/api/media/file/local.jpg" }, + alt: "local", + width: 1600, + height: 1200, + }, + ], + }, + }); + const tag = imgTags(html)[0]!; + + expect(attr(tag, "data-astro-image")).toBe("constrained"); + expect(attr(tag, "sizes")).toBe( + "(max-width: 640px) calc((100vw - 1rem) / 2), calc((100vw - 2rem) / 3)", + ); + }); + + test("a consumer-provided sizes wins", async () => { + const html = await renderGallery({ + node: { _type: "gallery", _key: "g", images: [providerImage("a")] }, + sizes: "(max-width: 640px) 45vw, 220px", + }); + + expect(attr(imgTags(html)[0]!, "sizes")).toBe("(max-width: 640px) 45vw, 220px"); + }); +}); diff --git a/packages/core/tests/unit/media/responsive.test.ts b/packages/core/tests/unit/media/responsive.test.ts index 35379e6096..ff7b87b5a7 100644 --- a/packages/core/tests/unit/media/responsive.test.ts +++ b/packages/core/tests/unit/media/responsive.test.ts @@ -3,6 +3,7 @@ import { describe, it, expect, vi } from "vitest"; import { RESPONSIVE_BREAKPOINTS, buildResponsiveImage, + gallerySizes, responsiveSizes, responsiveWidths, toAbsoluteMediaUrl, @@ -42,6 +43,29 @@ describe("responsiveSizes", () => { }); }); +describe("gallerySizes", () => { + it("estimates one cell of the grid, with two columns on narrow viewports", () => { + expect(gallerySizes(3)).toBe( + "(max-width: 640px) calc((100vw - 1rem) / 2), calc((100vw - 2rem) / 3)", + ); + expect(gallerySizes(5)).toBe( + "(max-width: 640px) calc((100vw - 1rem) / 2), calc((100vw - 4rem) / 5)", + ); + }); + + it("uses the full viewport for a single column above the breakpoint", () => { + expect(gallerySizes(1)).toBe("(max-width: 640px) calc((100vw - 1rem) / 2), 100vw"); + }); + + it("falls back to three columns for a missing or unusable count", () => { + const three = gallerySizes(3); + expect(gallerySizes(undefined)).toBe(three); + expect(gallerySizes(0)).toBe(three); + expect(gallerySizes(2.5)).toBe(three); + expect(gallerySizes(Number.NaN)).toBe(three); + }); +}); + describe("buildResponsiveImage", () => { const ABS = "https://cdn.example.com/a.jpg";