Skip to content

feat: responsive srcset for media via astro:assets - #1438

Merged
ascorbic merged 3 commits into
mainfrom
feat/responsive-images-astro-assets
Jun 12, 2026
Merged

ascorbic merged 3 commits into
mainfrom
feat/responsive-images-astro-assets

Conversation

@ascorbic

@ascorbic ascorbic commented Jun 12, 2026 •

Copy link
Copy Markdown
Member

What does this PR do?

Makes EmDash media render responsive srcsets automatically. The Image and 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:

  • Resolves media URLs to absolute (via the public origin) before handing them to getImage.
  • Auto-registers image.remotePatterns for: the storage publicUrl host (R2 custom domain, S3/CDN); the site origin scoped to the media route (/_emdash/api/media/file/**) when siteUrl is set; and a dev-only, host-agnostic media-route pattern so astro dev works with no configuration (the dev origin/port isn't known at build time). The dev pattern is gated to command === "dev" and never ships in a production build.
  • Detects passthrough (the service returned the URL unchanged) and falls back to a plain <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

  • Bug fix
  • Feature (requires maintainer-approved Discussion)
  • Refactor (no behavior change)
  • Translation
  • Documentation
  • Performance improvement
  • Tests
  • Chore (dependencies, CI, tooling)

Note: opened at the maintainer's request without a prior Discussion.

Checklist

  • I have read CONTRIBUTING.md
  • pnpm typecheck passes
  • pnpm lint passes
  • pnpm test passes (or targeted tests for my change)
  • pnpm format has been run
  • I have added/updated tests for my changes (if applicable)
  • User-visible strings in the admin UI are wrapped for translation (if applicable) — n/a, no admin UI strings
  • I have added a changeset (if this PR changes a published package)
  • New features link to an approved Discussion — n/a, opened at maintainer request

AI-generated code disclosure

  • This PR includes AI-generated code — model/tool: Claude Opus 4.8 (opencode)

Screenshots / test output

Verified in astro dev against demos/simple (Node + sharp) and infra/blog-demo (Cloudflare Images binding):

  • Featured images render full srcset ladders (w=640…1920) served as WebP via the image endpoint; a 750px candidate returned a real image/webp (31 KB).
  • Without an authorized source (relative proxy URL, no service): plain <img>, 0 srcset — no regression, no no-op srcset.
  • Production-build matrix (no dev pattern leak): build + no siteUrl → []; build + siteUrl → host-scoped media pattern only; dev + no siteUrl → host-agnostic media pattern.
  • 3821 core tests pass (11 new in tests/unit/media/responsive.test.ts), lint 0, typecheck 0, astro check 0.

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.

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.
Copilot AI review requested due to automatic review settings June 12, 2026 13:45
@changeset-bot

changeset-bot Bot commented Jun 12, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 3a81eac

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 14 packages
Name Type
emdash Minor
@emdash-cms/cloudflare Minor
@emdash-cms/sandbox-workerd Patch
@emdash-cms/fixture-perf-site Patch
@emdash-cms/perf-demo-site Patch
@emdash-cms/cache-demo-site Patch
@emdash-cms/admin Minor
@emdash-cms/auth Minor
@emdash-cms/blocks Minor
@emdash-cms/gutenberg-to-portable-text Minor
@emdash-cms/x402 Minor
create-emdash Minor
@emdash-cms/auth-atproto Patch
@emdash-cms/plugin-embeds Patch

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

@github-actions

Copy link
Copy Markdown
Contributor

PR template validation failed

Please fix the following issues by editing your PR description:

See CONTRIBUTING.md for the full contribution policy.

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Jun 12, 2026 •

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Updated (UTC)
✅ Deployment successful!
View logs
docs 3a81eac Jun 12 2026, 02:23 PM

@github-actions github-actions Bot added review/needs-review No maintainer or bot review yet cla: signed labels Jun 12, 2026
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Jun 12, 2026 •

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Updated (UTC)
✅ Deployment successful!
View logs
emdash-playground 3a81eac Jun 12 2026, 02:23 PM

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Jun 12, 2026 •

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Updated (UTC)
✅ Deployment successful!
View logs
emdash-demo-cache 3a81eac Jun 12 2026, 02:25 PM

@pkg-pr-new

pkg-pr-new Bot commented Jun 12, 2026 •

Copy link
Copy Markdown

Open in StackBlitz

@emdash-cms/admin

npm i https://pkg.pr.new/@emdash-cms/admin@1438

@emdash-cms/auth

npm i https://pkg.pr.new/@emdash-cms/auth@1438

@emdash-cms/auth-atproto

npm i https://pkg.pr.new/@emdash-cms/auth-atproto@1438

@emdash-cms/blocks

npm i https://pkg.pr.new/@emdash-cms/blocks@1438

@emdash-cms/cloudflare

npm i https://pkg.pr.new/@emdash-cms/cloudflare@1438

@emdash-cms/contentful-to-portable-text

npm i https://pkg.pr.new/@emdash-cms/contentful-to-portable-text@1438

emdash

npm i https://pkg.pr.new/emdash@1438

create-emdash

npm i https://pkg.pr.new/create-emdash@1438

@emdash-cms/gutenberg-to-portable-text

npm i https://pkg.pr.new/@emdash-cms/gutenberg-to-portable-text@1438

@emdash-cms/plugin-cli

npm i https://pkg.pr.new/@emdash-cms/plugin-cli@1438

@emdash-cms/plugin-types

npm i https://pkg.pr.new/@emdash-cms/plugin-types@1438

@emdash-cms/registry-client

npm i https://pkg.pr.new/@emdash-cms/registry-client@1438

@emdash-cms/registry-lexicons

npm i https://pkg.pr.new/@emdash-cms/registry-lexicons@1438

@emdash-cms/sandbox-workerd

npm i https://pkg.pr.new/@emdash-cms/sandbox-workerd@1438

@emdash-cms/x402

npm i https://pkg.pr.new/@emdash-cms/x402@1438

@emdash-cms/plugin-ai-moderation

npm i https://pkg.pr.new/@emdash-cms/plugin-ai-moderation@1438

@emdash-cms/plugin-atproto

npm i https://pkg.pr.new/@emdash-cms/plugin-atproto@1438

@emdash-cms/plugin-audit-log

npm i https://pkg.pr.new/@emdash-cms/plugin-audit-log@1438

@emdash-cms/plugin-color

npm i https://pkg.pr.new/@emdash-cms/plugin-color@1438

@emdash-cms/plugin-embeds

npm i https://pkg.pr.new/@emdash-cms/plugin-embeds@1438

@emdash-cms/plugin-field-kit

npm i https://pkg.pr.new/@emdash-cms/plugin-field-kit@1438

@emdash-cms/plugin-forms

npm i https://pkg.pr.new/@emdash-cms/plugin-forms@1438

@emdash-cms/plugin-webhook-notifier

npm i https://pkg.pr.new/@emdash-cms/plugin-webhook-notifier@1438

commit: 3a81eac

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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:assets getImage() for local/R2 media to emit srcset/modern formats when available.
  • Auto-registers image.remotePatterns to 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.

Comment on lines +105 to +115
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
}
}
Comment on lines 12 to 15
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];
Comment on lines 25 to 28
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];

@emdashbot emdashbot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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.ts helper 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.
  • buildImageRemotePatterns derives the right image.remotePatterns from the storage descriptor's publicUrl, 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 .astro components keep the original relative src when optimization fails, so there's no regression for unauthorized hosts or missing services.
  • The changeset is a sensible minor bump.

Two non-blocking suggestions:

  1. Both EmDashImage.astro and Image.astro redefine const BREAKPOINTS = [640, 750, …] locally even though responsive.ts already exports the identical RESPONSIVE_BREAKPOINTS. They should import that constant so external-provider srcset generation stays in sync with the new Astro-assets path.
  2. toAbsoluteMediaUrl is 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];

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[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.

Suggested change
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];

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[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.

Suggested change
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();
});
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[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.
@ascorbic

Copy link
Copy Markdown
Member Author

Addressed the review feedback in 84f6d6d:

  • buildImageRemotePatterns now only authorizes http(s) public URLs, and scopes the pattern to the path prefix when the public URL has one (e.g. https://cdn.example.com/assets → { hostname, pathname: "/assets/**" }) so we don't authorize the whole host. Added unit coverage.
  • Both components now source their breakpoint list from the shared RESPONSIVE_BREAKPOINTS constant instead of a duplicated literal.
  • Fixed the failing visual-editing e2e: with optimization active the rendered src is an image-service URL (/_image?href=…, /cdn-cgi/image/…) that encodes the media path, so the assertion now decodes before matching.

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.

@github-actions github-actions Bot added review/needs-rereview New commits since the last review and removed review/needs-review No maintainer or bot review yet labels Jun 12, 2026
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.
@github-actions github-actions Bot added size/XL and removed size/L labels Jun 12, 2026
@ascorbic
ascorbic merged commit 850c1b7 into main Jun 12, 2026
44 of 45 checks passed
@ascorbic
ascorbic deleted the feat/responsive-images-astro-assets branch June 12, 2026 14:36
@emdashbot emdashbot Bot mentioned this pull request Jun 12, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants