Skip to content

fix(cloudflare): honour fit and position in the image endpoint - #2449

Open
afonsojramos wants to merge 2 commits into
emdash-cms:mainfrom
afonsojramos:fix/cf-image-endpoint-fit
Open

fix(cloudflare): honour fit and position in the image endpoint#2449
afonsojramos wants to merge 2 commits into
emdash-cms:mainfrom
afonsojramos:fix/cf-image-endpoint-fit

Conversation

@afonsojramos

Copy link
Copy Markdown
Contributor

What does this PR do?

The Cloudflare image endpoint parsed only w/h/f/q and built its own transform, so the fit Astro asks for never reached the Images binding. A request for a square cover-crop arrived as width and height alone, and the binding fell back to its default fit: the image was scaled down inside the box and letterboxed instead of cropped to fill it. Astro's stock Cloudflare endpoint forwards fit, so identical markup cropped correctly on Node and silently did not on Cloudflare.

fit and position are now parsed alongside the existing params and mapped onto the binding's vocabulary, which only partly overlaps Astro's:

Astro Images binding Why
cover, contain, scale-down same Names line up
fill squeeze Both distort to fill the box
inside contain Not in ImageFit, but Astro's sharp service accepts it
outside dropped Nothing in the binding resizes to exceed the box without cropping
position: north/south/east/west top/bottom/right/left sharp's compass names for the same edges
position: centre / attention center / auto sharp spellings
position: left top dropped Names a corner the binding has no keyword for

Values with no binding equivalent are dropped rather than swapped for something that would crop, so they keep the behaviour they had before this mapping existed instead of gaining a confidently wrong one. Both params are advisory: an unrecognised value is dropped instead of failing the request, because ImageFit is open-ended (string & {}) and each backend supports a different subset.

Closes #2228

Type of change

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

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 — n/a, no UI strings
  • I have added a changeset
  • New features link to an approved Discussion — n/a, bug fix

AI-generated code disclosure

  • This PR includes AI-generated code — model/tool: Claude Opus 5

Screenshots / test output

The regression test reproduces the bug. Reverting only the adapter mapping and re-running:

❯ tests/image-endpoint-fit.test.ts (7 tests | 5 failed)
AssertionError: expected { width: 32, height: 32 } to match object { width: 32, height: 32, fit: 'cover' }
AssertionError: expected undefined to be 'top'

With the fix, pnpm test:unit is green, including 323 tests in @emdash-cms/cloudflare.

Two things worth a maintainer's eye

1. @emdash-cms/cloudflare was not in test:unit. CI runs only that script, so none of the package's 25 test files have been running — a regression test added here would never have executed. I added the package to the filter; all 323 tests pass. Happy to split that into its own PR if you would rather keep this diff to the endpoint.

2. An adjacent divergence I deliberately did not change. Astro only defaults fit to cover when a layout is set (validateOptions: if (options.layout && options.width && options.height) options.fit ??= "cover"). EmDash's own components pass layout="constrained", so the path this issue describes does emit fit and is fixed here. But a bare <Image src={emdashMedia} width={400} height={400} /> with image.layout unset emits no fit at all, and then sharp still crops (its own default is cover) while the binding scale-downs. Defaulting fit to cover here when both dimensions are present would align the two, at the cost of cropping requests that never asked to be cropped. That felt like a behaviour change rather than this bug, so I left it — happy to add it if you'd prefer the platforms match.

The endpoint parsed only w/h/f/q and built its own transform, so the
`fit` Astro asks for never reached the Images binding. A request for a
square cover-crop got width and height alone, and the binding fell back
to its default fit: the image was scaled down inside the box and
letterboxed instead of cropped to fill it. Astro's stock Cloudflare
endpoint forwards `fit`, so the same markup cropped correctly on Node
and did not on Cloudflare.

Parse `fit` and `position` alongside the existing params and map them
onto the binding's vocabulary, which only partly overlaps Astro's:
`fill` is the binding's `squeeze`, `inside` is `contain`, and sharp's
compass names are the edges they describe. Values with no binding
equivalent -- `outside`, a compound `left top` -- are dropped rather
than swapped for a fit that would crop, leaving the previous behaviour
rather than a confidently wrong one.

Both params are advisory: an unrecognised value is dropped instead of
failing the request, because Astro's ImageFit is open-ended and each
backend supports a different subset.

Also adds @emdash-cms/cloudflare to test:unit. CI runs only that script,
so the package's tests -- including this regression test -- never ran.

Closes emdash-cms#2228
Copilot AI lite review requested due to automatic review settings August 13, 2026 00:17
@changeset-bot

changeset-bot Bot commented Aug 13, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 6c86e35

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

This PR includes changesets to release 17 packages
Name Type
@emdash-cms/cloudflare Patch
emdash Patch
@emdash-cms/fixture-perf-site Patch
@emdash-cms/perf-demo-site Patch
@emdash-cms/cache-demo-site Patch
@emdash-cms/do-demo-site Patch
@emdash-cms/do-solo-demo-site Patch
@emdash-cms/sandbox-workerd Patch
@emdash-cms/plugin-mcp-smoke Patch
@emdash-cms/admin Patch
@emdash-cms/auth Patch
@emdash-cms/blocks Patch
@emdash-cms/gutenberg-to-portable-text Patch
@emdash-cms/x402 Patch
create-emdash Patch
@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

@pkg-pr-new

pkg-pr-new Bot commented Aug 13, 2026

Copy link
Copy Markdown

Open in StackBlitz

@emdash-cms/admin

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

@emdash-cms/auth

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

@emdash-cms/auth-atproto

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

@emdash-cms/blocks

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

@emdash-cms/cloudflare

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

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

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

emdash

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

create-emdash

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

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

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

@emdash-cms/plugin-cli

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

@emdash-cms/plugin-types

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

@emdash-cms/registry-client

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

@emdash-cms/registry-lexicons

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

@emdash-cms/registry-verification

npm i https://pkg.pr.new/@emdash-cms/registry-verification@2449

@emdash-cms/sandbox-workerd

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

@emdash-cms/x402

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

@emdash-cms/plugin-ai-moderation

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

@emdash-cms/plugin-atproto

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

@emdash-cms/plugin-audit-log

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

@emdash-cms/plugin-color

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

@emdash-cms/plugin-embeds

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

@emdash-cms/plugin-field-kit

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

@emdash-cms/plugin-forms

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

@emdash-cms/plugin-webhook-notifier

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

commit: 6c86e35

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

Fixes a Cloudflare-specific regression where EmDash’s _image endpoint ignored Astro’s fit and position parameters, causing transforms (notably square cover crops) to letterbox instead of crop when using the Cloudflare Images binding.

Changes:

  • Extend shared parseTransformParams() to parse fit and position (advisory/allowlisted for fit).
  • Map Astro fit/position onto Cloudflare Images binding fit/gravity and add Cloudflare adapter regression tests.
  • Ensure @emdash-cms/cloudflare unit tests run in the root test:unit script; add a changeset for the fix.

Reviewed changes

Copilot reviewed 6 out of 6 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
packages/core/tests/unit/media/image-endpoint.test.ts Adds unit coverage for parsing fit and position.
packages/core/src/media/image-endpoint.ts Parses fit/position alongside existing transform params.
packages/cloudflare/tests/image-endpoint-fit.test.ts Adds regression tests asserting fit/position forwarding/mapping to the Images binding.
packages/cloudflare/src/image-endpoint.ts Maps Astro fit → binding fit and Astro position → binding gravity.
package.json Includes @emdash-cms/cloudflare in the test:unit filter so its unit tests run in CI.
.changeset/cf-image-endpoint-fit.md Publishes patch release notes for the behavior fix.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment on lines +234 to +243
// `fit` and `position` come straight from Astro's image service. Both are
// advisory: an unrecognised value is dropped rather than failing the
// request, so a rendition still resolves on a backend that doesn't know it.
const fitRaw = params.get("fit");
const fit = fitRaw !== null && isTransformFit(fitRaw) ? fitRaw : undefined;

const positionRaw = params.get("position");
const position = positionRaw !== null && positionRaw !== "" ? positionRaw : undefined;

return { ok: true, options: { width, height, format, quality, fit, position } };
Comment thread .changeset/cf-image-endpoint-fit.md Outdated
"emdash": patch
---

Fixes cropping for EmDash media on Cloudflare. Images asked for a `cover` crop — square avatars, fixed-ratio thumbnails — came back scaled down and letterboxed inside the requested box instead of filling it, because the endpoint never passed the requested fit to the Images binding. `fit` and `position` are now honoured, so a crop crops and its focal side is respected.

@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-scoped bug fix for the right problem. Moving fit/position through the shared parseTransformParams parser and mapping them to the Cloudflare Images binding in the adapter is the idiomatic split for EmDash: core validates/allowlists, the platform adapter translates backend vocabulary. The tests reproduce the reported letterboxing scenario and cover the mapping edge cases (outside, compound positions, compass names). Including @emdash-cms/cloudflare in test:unit is a sensible fix, since the regression test would otherwise have been skipped in CI.

I checked the changed source, the Node endpoint (packages/core/src/astro/image-endpoint.ts) to confirm it doesn't need the same change—it delegates to Astro's astro/assets/endpoint/generic, which already handles fit/position—and searched for other consumers of parseTransformParams. No logic bugs, security issues, or query-count regressions found.

The only real issues are AGENTS.md comment-discipline violations: the new JSDoc blocks for ALLOWED_TRANSFORM_FITS, FIT_TO_BINDING, and GRAVITY_BY_POSITION contain review-facing justification and rejected-alternative narrative ("rather than ...", "better than ...", "Astro passes sharp's vocabulary straight through"). Those should be trimmed to the concise "why" a future reader needs.

Comment on lines +40 to +49
/**
* The `fit` values Astro's image service emits. Astro's `ImageFit` is
* open-ended (`string & {}`), so this is an allowlist: an unrecognised fit is
* dropped rather than forwarded to a backend that would reject it.
*
* `inside` and `outside` are not in `ImageFit` but are accepted by Astro's
* sharp service, so a site can already use them on Node. `none` is absent on
* purpose: Astro deletes it before building the URL, so it never reaches an
* endpoint.
*/

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.

[needs fixing] This JSDoc block explains the allowlist, but most of it is review-facing justification: it argues why inside/outside are included, why none is excluded, and why unknown values are dropped. AGENTS.md says comments are for future readers, not PR reviewers—avoid justifying decisions and narrating rejected alternatives.

Suggested change
/**
* The `fit` values Astro's image service emits. Astro's `ImageFit` is
* open-ended (`string & {}`), so this is an allowlist: an unrecognised fit is
* dropped rather than forwarded to a backend that would reject it.
*
* `inside` and `outside` are not in `ImageFit` but are accepted by Astro's
* sharp service, so a site can already use them on Node. `none` is absent on
* purpose: Astro deletes it before building the URL, so it never reaches an
* endpoint.
*/
/**
* Fit values Astro's image service may emit. Unknown values are dropped so
* backends keep their default behaviour.
*/

Comment on lines +37 to +46
/**
* Astro's `fit` vocabulary mapped onto the Images binding's.
*
* Most names line up. Astro's `fill` distorts the image to fill the box, which
* the binding calls `squeeze`. `inside` (accepted by Astro's sharp service)
* resizes to fit within the box, which is the binding's `contain`. `outside`
* has no binding equivalent -- nothing there resizes to *exceed* the box
* without cropping -- so it is dropped and the dimensions apply on their own,
* rather than substituting a fit that would crop.
*/

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.

[needs fixing] The block contains a clear justification/rejected-alternative narrative ("rather than substituting a fit that would crop"). The Record type and outside: undefined entry already express that unmapped values fall through to the binding default, so the comment only needs to say what the map does.

Suggested change
/**
* Astro's `fit` vocabulary mapped onto the Images binding's.
*
* Most names line up. Astro's `fill` distorts the image to fill the box, which
* the binding calls `squeeze`. `inside` (accepted by Astro's sharp service)
* resizes to fit within the box, which is the binding's `contain`. `outside`
* has no binding equivalent -- nothing there resizes to *exceed* the box
* without cropping -- so it is dropped and the dimensions apply on their own,
* rather than substituting a fit that would crop.
*/
/**
* Maps Astro `fit` values to the Cloudflare Images binding's fit vocabulary.
* Unmapped values (e.g. `outside`) become `undefined`, leaving the binding's
* default behaviour unchanged.
*/

Comment on lines +56 to +68
/**
* Astro's `position` mapped onto the binding's `gravity`.
*
* Astro passes sharp's vocabulary straight through, and the two only partly
* overlap. The shared keywords pass unchanged; sharp's compass names mean the
* same edges under different words; `centre` is the same as `center`; and
* `attention` (sharp's saliency-based crop) is the binding's `auto`.
*
* A compound position such as `left top` names a corner, which the binding can
* only express as coordinates. Rather than guess at them, it is dropped and the
* binding keeps its own gravity -- the same outcome as before this mapping
* existed, and better than cropping to a confidently wrong edge.
*/

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.

[needs fixing] This comment is mostly a vocabulary walk-through and a justification ("better than cropping to a confidently wrong edge"). The map below is self-describing; the reader only needs a short pointer to what it maps and what happens to unknown positions.

Suggested change
/**
* Astro's `position` mapped onto the binding's `gravity`.
*
* Astro passes sharp's vocabulary straight through, and the two only partly
* overlap. The shared keywords pass unchanged; sharp's compass names mean the
* same edges under different words; `centre` is the same as `center`; and
* `attention` (sharp's saliency-based crop) is the binding's `auto`.
*
* A compound position such as `left top` names a corner, which the binding can
* only express as coordinates. Rather than guess at them, it is dropped and the
* binding keeps its own gravity -- the same outcome as before this mapping
* existed, and better than cropping to a confidently wrong edge.
*/
/**
* Maps Astro `position` values to the Cloudflare Images binding's gravity
* vocabulary. Compound or unknown positions are dropped.
*/

Per review: the new JSDoc argued for the mapping rather than describing
it, which belongs in the PR rather than the source. Also normalises a
whitespace-only `position` at parse time and fixes the changeset wording.
@github-actions github-actions Bot added review/needs-rereview Author pushed changes since the last review and removed review/needs-review No maintainer or bot review yet labels Aug 13, 2026
@afonsojramos

Copy link
Copy Markdown
Contributor Author

Thanks both — pushed in 6c86e35.

Comment discipline (@emdashbot, 3 threads): applied all three suggestions. You're right that those blocks argued for the mapping instead of describing it; the rationale belongs in this PR, which is where it now lives. I trimmed the same narrative style out of the parseTransformParams docstring and dropped an inline comment in the endpoint for the same reason.

Changeset grammar (@copilot): reworded to "An image asked to fill a fixed box — a square avatar, a fixed-ratio thumbnail — came back scaled down and letterboxed inside it".

Whitespace-only position (@copilot): now normalised at parse time — params.get("position")?.trim(), empty becomes undefined.

Optional keys in the returned options (@copilot): not changed, deliberately. The object literal already returned height unconditionally before this PR, so the shape is unchanged in kind, and the existing tests pass as written — toEqual treats an undefined-valued key as equivalent to an absent one, which is why parse("w=800") still matches { width: 800, format: "webp", quality: undefined }. Rebuilding the literal incrementally would be a refactor of untouched code, so it seemed better left out of a bug fix. Happy to do it if you'd prefer the shape tightened.


One unrelated thing worth flagging: tests/integration/mcp/taxonomy.test.ts > paginates correctly when multiple terms share the same label fails in a full packages/core run and passes in isolation. It reproduces on a clean main with this branch stashed — same assertion, same 1 failed | 5585 passed | 5 skipped — so it is pre-existing and not from this PR. The dropped entry is a shared-* slug, so it looks like pagination over terms sharing a label depends on a tiebreak that isn't stable under load. Happy to open a separate issue if that's useful.

@github-actions

Copy link
Copy Markdown
Contributor

Overlapping PRs

This PR modifies files that are also changed by other open PRs:

This may cause merge conflicts or duplicated work. A maintainer will coordinate.

@ascorbic ascorbic added the bot:review Trigger an emdashbot code review on this PR label Aug 16, 2026

@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 the right fix in the right place: adding fit and position to the shared parseTransformParams parser and mapping them to Cloudflare Images vocabulary in the adapter keeps platform-specific translation where it belongs. The regression tests cover the reported cover-crop letterboxing bug and the dropped-value edge cases, and adding @emdash-cms/cloudflare to test:unit is necessary for the new test to run in CI.

Most of the prior review's comment-discipline findings have been trimmed out of the constant JSDoc blocks. One slipped through in the parseTransformParams docstring, which still uses "Unlike the others" / "dropped rather than failing" — rejected-alternative/justification prose that AGENTS.md asks us to delete. I also spotted a mock-hoisting style gap in the new test: it references top-level consts from vi.mock factories without vi.hoisted, unlike the sibling cache/kv-timeout.test.ts which explicitly does this for the same reason.

No logic bugs, security issues, query-count regressions, or API-contract breaks found.

Comment on lines +195 to +197
* `fit` and `position` describe how the rendition fills its box. Unlike the
* others they are advisory: an unrecognised value is dropped rather than
* failing the request. The platform endpoint maps them onto its backend's

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.

[needs fixing] The parseTransformParams JSDoc contains AGENTS.md-prohibited reviewer-facing justification: "Unlike the others ... dropped rather than failing the request" frames a rejected alternative rather than stating the concise "why". This is the same pattern the prior review flagged in the constant JSDoc blocks; trim it here too.

Suggested change
* `fit` and `position` describe how the rendition fills its box. Unlike the
* others they are advisory: an unrecognised value is dropped rather than
* failing the request. The platform endpoint maps them onto its backend's
* `fit` and `position` describe how the rendition fills its box. Unrecognised
* values are ignored because each backend supports a different subset. The
* platform endpoint maps them onto its backend's

Comment on lines +4 to +22
const adapterGET = vi.fn(() => new Response("adapter", { status: 200 }));
vi.mock("@astrojs/cloudflare/image-transform-endpoint", () => ({ GET: adapterGET }));

/** Records the transform the endpoint hands to the Images binding. */
const transform = vi.fn();
const output = vi.fn(() => ({
response: () => new Response("bytes", { headers: { "Content-Type": "image/webp" } }),
}));

const images = {
input: () => ({
transform: (options: unknown) => {
transform(options);
return { output };
},
}),
};

vi.mock("cloudflare:workers", () => ({ env: { IMAGES: images } }));

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] These vi.mock factories reference top-level consts (adapterGET, images, transform, output) that are not hoisted with the mock. The sibling packages/cloudflare/tests/cache/kv-timeout.test.ts already uses vi.hoisted for the same cloudflare:workers mock pattern. Follow that convention so a future import reordering can't hit a TDZ/access-before-initialization issue.

const { adapterGET, images, transform, output } = vi.hoisted(() => ({
  adapterGET: vi.fn(() => new Response("adapter", { status: 200 })),
  transform: vi.fn(),
  output: vi.fn(() => ({ response: () => new Response("bytes", { headers: { "Content-Type": "image/webp" } }) })),
  images: { /* ... */ },
}));

@emdashbot emdashbot Bot removed the bot:review Trigger an emdashbot code review on this PR label Aug 16, 2026
@github-actions github-actions Bot added review/awaiting-author Reviewed; waiting on the author to respond and removed review/needs-rereview Author pushed changes since the last review labels Aug 16, 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.

Cloudflare image endpoint ignores Astro fit/position params for EmDash media transforms

3 participants