Repository navigation
feat: responsive srcset for media via astro:assets - #1438
Conversation
Route locally/R2-stored media through Astro's configured image service (astro:assets) so the Image and Portable Text image components emit a responsive srcset and modern formats instead of a single full-size img. Auto-registers image.remotePatterns for the storage publicUrl host, the site origin (scoped to the media route) when siteUrl is set, and a dev-only host-agnostic media pattern so it works in astro dev with no config. Falls back to a plain img when optimization isn't possible (no service, unauthorized host, unknown dimensions), so existing sites are unaffected.
🦋 Changeset detectedLatest commit: 3a81eac The changes in this PR will be included in the next version bump. This PR includes changesets to release 14 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
PR template validation failedPlease fix the following issues by editing your PR description:
See CONTRIBUTING.md for the full contribution policy. |
Deploying with
|
| Status | Name | Latest Commit | Updated (UTC) |
|---|---|---|---|
| ✅ Deployment successful! View logs |
docs | 3a81eac | Jun 12 2026, 02:23 PM |
Deploying with
|
| Status | Name | Latest Commit | Updated (UTC) |
|---|---|---|---|
| ✅ Deployment successful! View logs |
emdash-playground | 3a81eac | Jun 12 2026, 02:23 PM |
Deploying with
|
| Status | Name | Latest Commit | Updated (UTC) |
|---|---|---|---|
| ✅ Deployment successful! View logs |
emdash-demo-cache | 3a81eac | Jun 12 2026, 02:25 PM |
@emdash-cms/admin
@emdash-cms/auth
@emdash-cms/auth-atproto
@emdash-cms/blocks
@emdash-cms/cloudflare
@emdash-cms/contentful-to-portable-text
emdash
create-emdash
@emdash-cms/gutenberg-to-portable-text
@emdash-cms/plugin-cli
@emdash-cms/plugin-types
@emdash-cms/registry-client
@emdash-cms/registry-lexicons
@emdash-cms/sandbox-workerd
@emdash-cms/x402
@emdash-cms/plugin-ai-moderation
@emdash-cms/plugin-atproto
@emdash-cms/plugin-audit-log
@emdash-cms/plugin-color
@emdash-cms/plugin-embeds
@emdash-cms/plugin-field-kit
@emdash-cms/plugin-forms
@emdash-cms/plugin-webhook-notifier
commit: |
There was a problem hiding this comment.
Pull request overview
Adds responsive image srcset generation for EmDash-rendered media by routing eligible media URLs through Astro’s configured image service (astro:assets), with safe fallbacks when optimization isn’t possible.
Changes:
- Introduces shared responsive image helpers (
buildResponsiveImage, width/sizes selection, relative→absolute URL resolver). - Updates Image components to use
astro:assetsgetImage()for local/R2 media to emitsrcset/modern formats when available. - Auto-registers
image.remotePatternsto authorize EmDash media sources (including a dev-only media-route pattern) and adds unit tests + a changeset.
Reviewed changes
Copilot reviewed 6 out of 6 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| packages/core/tests/unit/media/responsive.test.ts | Adds unit coverage for responsive helper behavior and fallback paths. |
| packages/core/src/media/responsive.ts | Implements responsive widths/sizes helpers and getImage delegation with passthrough detection. |
| packages/core/src/components/Image.astro | Routes local/R2 media through getImage and applies returned src/srcset/sizes when optimized. |
| packages/core/src/components/EmDashImage.astro | Extends responsive optimization to local/direct-src images via getImage, with fallback behavior. |
| packages/core/src/astro/integration/index.ts | Auto-configures image.remotePatterns to authorize EmDash media URLs for Astro optimization. |
| .changeset/responsive-images.md | Publishes the feature as a minor version change with usage notes. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| if (typeof publicUrl === "string" && publicUrl) { | ||
| try { | ||
| const url = new URL(publicUrl); | ||
| patterns.push({ | ||
| protocol: url.protocol === "http:" ? "http" : "https", | ||
| hostname: url.hostname, | ||
| }); | ||
| } catch { | ||
| // ignore an unparseable public URL | ||
| } | ||
| } |
| import { buildResponsiveImage, toAbsoluteMediaUrl } from "../media/responsive.js"; | ||
| import { getPublicOrigin } from "../api/public-url.js"; | ||
| // Standard responsive breakpoints | ||
| const BREAKPOINTS = [640, 750, 828, 960, 1080, 1280, 1600, 1920]; |
| import { buildResponsiveImage, toAbsoluteMediaUrl } from "../media/responsive.js"; | ||
| import { getPublicOrigin } from "../api/public-url.js"; | ||
| // Standard responsive breakpoints | ||
| const BREAKPOINTS = [640, 750, 828, 960, 1080, 1280, 1600, 1920]; |
There was a problem hiding this comment.
This is a well-architected change. Routing locally/R2-stored media through Astro's image service (astro:assets) is idiomatic for an Astro-native CMS, and the passthrough-detection + graceful fallback to a plain <img> means existing sites won't break when optimization isn't available.
I checked:
- The
responsive.tshelper correctly limits optimization to absolute http(s) URLs, bails when dimensions are unknown, and detects no-op passthrough so we never emit a useless same-URL srcset. buildImageRemotePatternsderives the rightimage.remotePatternsfrom the storage descriptor'spublicUrl, the site origin scoped to the media route, and a dev-only host-agnostic fallback. It correctly skips the dev pattern for production builds.- Both
.astrocomponents keep the original relativesrcwhen optimization fails, so there's no regression for unauthorized hosts or missing services. - The changeset is a sensible
minorbump.
Two non-blocking suggestions:
- Both
EmDashImage.astroandImage.astroredefineconst BREAKPOINTS = [640, 750, …]locally even thoughresponsive.tsalready exports the identicalRESPONSIVE_BREAKPOINTS. They should import that constant so external-provider srcset generation stays in sync with the new Astro-assets path. toAbsoluteMediaUrlis an exported public helper with nontrivial edge-case handling, but it has zero test coverage. A few unit tests for relative paths, already-absolute URLs, empty inputs, and invalid origins would be worthwhile.
| import { buildResponsiveImage, toAbsoluteMediaUrl } from "../media/responsive.js"; | ||
| import { getPublicOrigin } from "../api/public-url.js"; | ||
| // Standard responsive breakpoints | ||
| const BREAKPOINTS = [640, 750, 828, 960, 1080, 1280, 1600, 1920]; |
There was a problem hiding this comment.
[suggestion] This local BREAKPOINTS constant is identical to RESPONSIVE_BREAKPOINTS exported from ../media/responsive.js, which this file already imports. Using the shared constant keeps the external-provider srcset generation in sync with the new Astro-assets breakpoints.
| const BREAKPOINTS = [640, 750, 828, 960, 1080, 1280, 1600, 1920]; | |
| import { buildResponsiveImage, toAbsoluteMediaUrl, RESPONSIVE_BREAKPOINTS } from "../media/responsive.js"; |
And replace the local declaration with the imported value.
| import { buildResponsiveImage, toAbsoluteMediaUrl } from "../media/responsive.js"; | ||
| import { getPublicOrigin } from "../api/public-url.js"; | ||
| // Standard responsive breakpoints | ||
| const BREAKPOINTS = [640, 750, 828, 960, 1080, 1280, 1600, 1920]; |
There was a problem hiding this comment.
[suggestion] This local BREAKPOINTS constant duplicates RESPONSIVE_BREAKPOINTS from ../media/responsive.js. Since this file already imports from that module, prefer importing the shared constant so the external-provider path stays in sync with the Astro-assets breakpoints.
| const BREAKPOINTS = [640, 750, 828, 960, 1080, 1280, 1600, 1920]; | |
| import { buildResponsiveImage, toAbsoluteMediaUrl, RESPONSIVE_BREAKPOINTS } from "../media/responsive.js"; |
And remove the local declaration.
| }; | ||
| expect(await buildResponsiveImage(getImage, { src: ABS, width: 800, height: 600 })).toBeNull(); | ||
| }); | ||
| }); |
There was a problem hiding this comment.
[suggestion] toAbsoluteMediaUrl is exported and handles several edge cases (empty strings, missing origin, already-absolute URLs, protocol-relative URLs), but it currently has no test coverage. Consider adding a small describe block for it, e.g.:
describe("toAbsoluteMediaUrl", () => {
const ORIGIN = "https://example.com";
it("resolves a relative path against the origin", () => {
expect(toAbsoluteMediaUrl("/_emdash/api/media/file/01ABC.jpg", ORIGIN)).toBe("https://example.com/_emdash/api/media/file/01ABC.jpg");
});
it("returns already-absolute URLs unchanged", () => {
expect(toAbsoluteMediaUrl("https://cdn.example.com/a.jpg", ORIGIN)).toBe("https://cdn.example.com/a.jpg");
});
it("returns the source unchanged when origin is missing", () => {
expect(toAbsoluteMediaUrl("/path.jpg", undefined)).toBe("/path.jpg");
});
it("returns the source unchanged for non-path values (data:, blob:)", () => {
expect(toAbsoluteMediaUrl("data:image/png;base64,abc", ORIGIN)).toBe("data:image/png;base64,abc");
});
});- Update the visual-editing e2e assertion: optimized media srcs are now image-service URLs (/_image, /cdn-cgi/image) that encode the media path, so decode before matching MEDIA_FILE_PATTERN. - buildImageRemotePatterns: only authorize http(s) public URLs, and scope the pattern to the path prefix when the public URL has one (avoids authorizing the whole CDN host). - Source the components' breakpoint list from the shared RESPONSIVE_BREAKPOINTS constant instead of duplicating it. - Add unit coverage for buildImageRemotePatterns.
|
Addressed the review feedback in 84f6d6d:
The remaining red "Validate PR" check is the Feature-requires-Discussion gate; this PR was opened at the maintainer's request without a prior Discussion. |
toAbsoluteMediaUrl resolved any root-relative path against the origin, but protocol-relative URLs (//evil.com/x) and backslash tricks (/\evil.com) also start with / and resolve to a different origin -- a classic SSRF vector once an image-service remotePattern authorizes the media path. Only return the absolutized URL when it stays on the same origin; otherwise leave it untouched (and buildResponsiveImage then skips it, since it only accepts absolute http(s) URLs). Adds a toAbsoluteMediaUrl describe block covering same-origin resolution, already-absolute/empty/non-path passthrough, and the protocol-relative + backslash SSRF guards.
What does this PR do?
Makes EmDash media render responsive
srcsets automatically. TheImageand Portable Text image components now route locally/R2-stored media through Astro's configured image service (astro:assets) — the Cloudflare Images binding on Workers, sharp on Node — so they emit width-appropriate candidates and modern formats (e.g. WebP) instead of a single full-size<img>.Astro's image services only optimize absolute URLs whose host is authorized via
image.remotePatterns; relative same-origin proxy URLs are passed through unchanged. So this PR:getImage.image.remotePatternsfor: the storagepublicUrlhost (R2 custom domain, S3/CDN); the site origin scoped to the media route (/_emdash/api/media/file/**) whensiteUrlis set; and a dev-only, host-agnostic media-route pattern soastro devworks with no configuration (the dev origin/port isn't known at build time). The dev pattern is gated tocommand === "dev"and never ships in a production build.<img>, so an unauthorized host / missing image service / unknown dimensions never produces a useless same-URL srcset.No template changes are required, and existing sites render exactly as before when optimization isn't possible.
Closes #
Type of change
Checklist
pnpm typecheckpassespnpm lintpassespnpm testpasses (or targeted tests for my change)pnpm formathas been runAI-generated code disclosure
Screenshots / test output
Verified in
astro devagainstdemos/simple(Node + sharp) andinfra/blog-demo(Cloudflare Images binding):srcsetladders (w=640…1920) served as WebP via the image endpoint; a 750px candidate returned a realimage/webp(31 KB).<img>, 0srcset— no regression, no no-op srcset.build+ nositeUrl→[];build+siteUrl→ host-scoped media pattern only;dev+ nositeUrl→ host-agnostic media pattern.tests/unit/media/responsive.test.ts), lint 0, typecheck 0,astro check0.Try this PR
Open a fresh playground →
A full working EmDash site, deployed from this branch. Each visit gets its own session-scoped sandbox: no login needed and no shared state. Try the admin, edit content, hit the public site.
Tracks
feat/responsive-images-astro-assets. Updated automatically when the playground redeploys.