Summary
EmDash's Cloudflare _image endpoint wrapper (@emdash-cms/cloudflare/src/image-endpoint.ts) parses w, h, f, and q from Astro's image transform URLs but ignores fit and position. As a result, square/avatar crops served through the EmDash media pipeline don't get the same crop semantics as Astro's stock Cloudflare endpoint, and there's no way to opt into Cloudflare gravity: "face" for face-aware cropping.
Background
PR #1438 added responsive srcset for EmDash media via astro:assets. PR #1549 wrapped Astro's image endpoint so EmDash media bytes load from storage (Access-safe) instead of over HTTP. PR #2037 added a default quality of 85.
Astro's image service generates /_image URLs like:
/_image?href=/_emdash/api/media/file/{key}&w=32&h=32&q=60&f=webp&fit=cover&position=center
Astro's stock Cloudflare binding endpoint (@astrojs/cloudflare/dist/utils/image-binding-transform.js) already forwards fit to ImagesBinding.transform():
.transform({
width: ...,
height: ...,
fit: url.searchParams.get("fit")
})
But EmDash's Cloudflare wrapper (@emdash-cms/cloudflare/src/image-endpoint.ts) only parses w/h/f/q via parseTransformParams and builds its own ImageTransform object without fit or position:
const transform: ImageTransform = {};
if (width) transform.width = width;
if (height) transform.height = height;
// no fit, no gravity
So when Astro requests a square 32×32 avatar with fit=cover, the Cloudflare Images binding receives width=32, height=32 but no fit — it defaults to scale-down (or the binding's default), which preserves aspect ratio and doesn't crop to the requested dimensions.
Impact
- Avatars and square crops rendered from EmDash media on Cloudflare don't crop correctly — they get letterboxed/scale-down instead of
cover-cropped.
- DPR variance: DPR 1 screens fetch the
32w candidate (32×32, no fit), DPR 2 screens fetch 64w (64×64, no fit), producing inconsistent crop/quality across devices.
- No face gravity: Cloudflare's
ImagesBinding supports gravity: "face" for face-aware cropping, but there's no EmDash config to enable it. The images?: boolean integration option has no sub-config.
- Astro
position=center is always emitted for constrained images, so even if gravity were configurable, it would need to take precedence over the default position=center to take effect.
Proposed fix
1. Forward fit from _image URLs
Extend parseTransformParams in emdash/src/media/image-endpoint.ts to parse fit and position, and forward fit to ImagesBinding.transform() in @emdash-cms/cloudflare/src/image-endpoint.ts.
Astro's ImageFit values: fill | contain | cover | none | scale-down. Cloudflare's ImageTransform.fit accepts: scale-down | contain | pad | squeeze | cover | crop. Most map directly; fill → squeeze, none → omit.
2. Map position to Cloudflare gravity
Astro's position is a free string mirroring CSS object-position. Map only the subset Cloudflare supports: center → center, top → top, bottom → bottom, left → left, right → right. Ignore unsupported values (fall back to binding default).
3. Add configurable defaults via images option
Extend images?: boolean to images?: boolean | ImageConfig where:
interface ImageConfig {
/** Default gravity for all EmDash media transforms. */
gravity?: "face" | "center" | "auto" | "entropy" | "left" | "right" | "top" | "bottom";
/** Default quality override (explicit `?q=` still wins). */
quality?: number;
}
Preserve images: false (opt-out) and images: true (current default behavior).
When gravity: "face" is configured, it should override Astro's default position=center (since Astro always emits position=center for constrained images, a configured gravity would never take effect otherwise). An explicit non-center position from the URL should still take precedence over the configured gravity.
4. Tests
- Unit test:
parseTransformParams parses fit and position
- Unit test: Cloudflare endpoint forwards
fit to ImagesBinding.transform()
- Unit test: configured
gravity: "face" overrides default position=center
- Unit test: explicit
position=top overrides configured gravity
Related
Summary
EmDash's Cloudflare
_imageendpoint wrapper (@emdash-cms/cloudflare/src/image-endpoint.ts) parsesw,h,f, andqfrom Astro's image transform URLs but ignoresfitandposition. As a result, square/avatar crops served through the EmDash media pipeline don't get the same crop semantics as Astro's stock Cloudflare endpoint, and there's no way to opt into Cloudflaregravity: "face"for face-aware cropping.Background
PR #1438 added responsive
srcsetfor EmDash media viaastro:assets. PR #1549 wrapped Astro's image endpoint so EmDash media bytes load from storage (Access-safe) instead of over HTTP. PR #2037 added a default quality of 85.Astro's image service generates
/_imageURLs like:Astro's stock Cloudflare binding endpoint (
@astrojs/cloudflare/dist/utils/image-binding-transform.js) already forwardsfittoImagesBinding.transform():But EmDash's Cloudflare wrapper (
@emdash-cms/cloudflare/src/image-endpoint.ts) only parsesw/h/f/qviaparseTransformParamsand builds its ownImageTransformobject withoutfitorposition:So when Astro requests a square
32×32avatar withfit=cover, the Cloudflare Images binding receiveswidth=32, height=32but nofit— it defaults toscale-down(or the binding's default), which preserves aspect ratio and doesn't crop to the requested dimensions.Impact
cover-cropped.32wcandidate (32×32, nofit), DPR 2 screens fetch64w(64×64, nofit), producing inconsistent crop/quality across devices.ImagesBindingsupportsgravity: "face"for face-aware cropping, but there's no EmDash config to enable it. Theimages?: booleanintegration option has no sub-config.position=centeris always emitted for constrained images, so even ifgravitywere configurable, it would need to take precedence over the defaultposition=centerto take effect.Proposed fix
1. Forward
fitfrom_imageURLsExtend
parseTransformParamsinemdash/src/media/image-endpoint.tsto parsefitandposition, and forwardfittoImagesBinding.transform()in@emdash-cms/cloudflare/src/image-endpoint.ts.Astro's
ImageFitvalues:fill | contain | cover | none | scale-down. Cloudflare'sImageTransform.fitaccepts:scale-down | contain | pad | squeeze | cover | crop. Most map directly;fill→squeeze,none→ omit.2. Map
positionto CloudflaregravityAstro's
positionis a free string mirroring CSSobject-position. Map only the subset Cloudflare supports:center→center,top→top,bottom→bottom,left→left,right→right. Ignore unsupported values (fall back to binding default).3. Add configurable defaults via
imagesoptionExtend
images?: booleantoimages?: boolean | ImageConfigwhere:Preserve
images: false(opt-out) andimages: true(current default behavior).When
gravity: "face"is configured, it should override Astro's defaultposition=center(since Astro always emitsposition=centerfor constrained images, a configured gravity would never take effect otherwise). An explicit non-centerpositionfrom the URL should still take precedence over the configured gravity.4. Tests
parseTransformParamsparsesfitandpositionfittoImagesBinding.transform()gravity: "face"overrides defaultposition=centerposition=topoverrides configuredgravityRelated