Skip to content

Modern Image Formats: rewrite img tags from wp_get_attachment_image()#2451

Open
adamsilverstein wants to merge 5 commits into
WordPress:trunkfrom
adamsilverstein:issue-523-filter-wp-get-attachment-image
Open

Modern Image Formats: rewrite img tags from wp_get_attachment_image()#2451
adamsilverstein wants to merge 5 commits into
WordPress:trunkfrom
adamsilverstein:issue-523-filter-wp-get-attachment-image

Conversation

@adamsilverstein
Copy link
Copy Markdown
Member

Summary

Fixes #523. The webp-uploads plugin now rewrites <img> tags produced by wp_get_attachment_image() (and, transitively, by the_post_thumbnail() / get_the_post_thumbnail()) to serve modern formats (WebP / AVIF) when those sub-sizes are available.

Until this change, only images that flowed through the_content (via wp_content_img_tag) or featured images (via post_thumbnail_html) were rewritten. Any <img> built directly from a template tag, archive loop, page builder, or custom plugin stayed as its original JPEG. Weston documented the reproduction in #523.

Changes

  • New filter callback webp_uploads_filter_wp_get_attachment_image() in plugins/webp-uploads/hooks.php. Hooks into wp_get_attachment_image at priority 10, dispatches to the existing rewriter pipeline (webp_uploads_img_tag_update_mime_type() in default mode, webp_uploads_wrap_image_in_picture() in picture-element mode), and guards with webp_uploads_in_frontend_body() + a new webp_uploads_filter_wp_get_attachment_image opt-out filter.
  • Removed direct post_thumbnail_html registration. the_post_thumbnail() routes through wp_get_attachment_image(), so the new filter covers featured images and the dedicated hook became redundant. webp_uploads_update_featured_image() is marked @deprecated but kept in place for third-party callers.
  • Idempotency guard in webp_uploads_wrap_image_in_picture(). Bails if the input HTML already contains a <picture> wrapper, preventing double-wrapping when markup flows through both wp_get_attachment_image and wp_content_img_tag.
  • Extended the picture-element context whitelist to include the new wp_get_attachment_image context.
  • Intentionally out of scope: wp_get_attachment_image_url(), wp_get_attachment_image_src(), get_the_post_thumbnail_url() — URL-returning functions feed OG tags, RSS, JSON APIs, and other non-HTML consumers where silently substituting a modern format is unsafe.

Opt-out

Two supported paths:

```php
// Surgical, per-call.
add_filter( 'webp_uploads_filter_wp_get_attachment_image', '__return_false' );

// Wholesale.
remove_filter( 'wp_get_attachment_image', 'webp_uploads_filter_wp_get_attachment_image', 10 );
```

Test plan

  • PHPUnit webp-uploads testsuite passes in CI (added ~8 new tests covering default-mode rewrite, picture-mode wrap, idempotency, opt-out filter, `remove_filter` unhook, frontend-body guard, icon placeholder, and double-processing prevention for featured images).
  • Manual: upload a JPEG, set it as a post's featured image, render via `wp_get_attachment_image( get_post_thumbnail_id(), 'large' )` from `wp_body_open` — confirm `src` and `srcset` end in `.webp`.
  • Manual: same via `get_the_post_thumbnail()` — single ``, no duplicated URL fragments (proves double-processing fix).
  • Manual: toggle picture-element mode on — confirm `wp_get_attachment_image()` now returns a `` wrapper with a ``.
  • Manual: `remove_filter( 'wp_get_attachment_image', 'webp_uploads_filter_wp_get_attachment_image', 10 )` — confirm JPEG URL is restored.
  • Manual: confirm `wp_get_attachment_image_url()` still returns the original JPEG URL (OG / RSS consumers unaffected).
Until now the Modern Image Formats plugin only rewrote images that
flowed through `the_content` (via `wp_content_img_tag`) and featured
images (via `post_thumbnail_html`). Any `<img>` built by a direct call
to `wp_get_attachment_image()` — page builders, archive loops, custom
templates, and many plugins — was left as the original JPEG even when
WebP/AVIF sub-sizes were available.

Add a new `wp_get_attachment_image` filter that dispatches to the
existing rewriter pipeline: `webp_uploads_img_tag_update_mime_type()`
in default mode, or `webp_uploads_wrap_image_in_picture()` when picture
element output is enabled. URL-returning functions
(`wp_get_attachment_image_url()`, `wp_get_attachment_image_src()`,
`get_the_post_thumbnail_url()`) are intentionally left untouched, since
their return values feed OG tags, RSS, JSON APIs, and other non-HTML
consumers where silent format substitution is unsafe.

Because `the_post_thumbnail()` routes through `wp_get_attachment_image()`,
the dedicated `post_thumbnail_html` registration is now redundant and
has been removed; `webp_uploads_update_featured_image()` is marked
`@deprecated` but kept in place for any third-party callers.

Also make `webp_uploads_wrap_image_in_picture()` idempotent so that
markup flowing through both `wp_get_attachment_image` and
`wp_content_img_tag` isn't double-wrapped, and extend the context
whitelist to include the new `wp_get_attachment_image` context.

Fixes WordPress#523.
@adamsilverstein adamsilverstein requested a review from b1ink0 as a code owner April 14, 2026 03:21
@github-actions
Copy link
Copy Markdown

github-actions Bot commented Apr 14, 2026

The following accounts have interacted with this PR and/or linked issues. I will continue to update these lists as activity occurs. You can also manually ask me to refresh this list by adding the props-bot label.

Unlinked Accounts

The following contributors have not linked their GitHub and WordPress.org accounts: @felixarntz.

Contributors, please read how to link your accounts to ensure your work is properly credited in WordPress releases.

If you're merging code through a pull request on GitHub, copy and paste the following into the bottom of the merge commit message.

Unlinked contributors: felixarntz.

Co-authored-by: adamsilverstein <adamsilverstein@git.wordpress.org>
Co-authored-by: MarcinDudekDev <myththrazz@git.wordpress.org>
Co-authored-by: jjgrainger <joegrainger@git.wordpress.org>
Co-authored-by: mxbclang <mxbclang@git.wordpress.org>
Co-authored-by: mukeshpanchal27 <mukesh27@git.wordpress.org>
Co-authored-by: westonruter <westonruter@git.wordpress.org>

To understand the WordPress project's expectations around crediting contributors, please review the Contributor Attribution page in the Core Handbook.

…tests

Subsequent test fixtures uploading leaves.jpg get renamed to leaves-NN.jpg
when leftover files remain on disk, which made the 'leaves.jpg' substring
assertion fail in CI. Match on '.jpg' instead — combined with the existing
'.webp' negative assertion, the intent (original format preserved) is
still verified.
@codecov
Copy link
Copy Markdown

codecov Bot commented Apr 14, 2026

Codecov Report

❌ Patch coverage is 95.83333% with 1 line in your changes missing coverage. Please review.
✅ Project coverage is 69.40%. Comparing base (8d1741e) to head (943fd7d).
⚠️ Report is 67 commits behind head on trunk.

Files with missing lines Patch % Lines
plugins/webp-uploads/deprecated.php 75.00% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##            trunk    #2451      +/-   ##
==========================================
+ Coverage   69.33%   69.40%   +0.06%     
==========================================
  Files          90       90              
  Lines        7749     7768      +19     
==========================================
+ Hits         5373     5391      +18     
- Misses       2376     2377       +1     
Flag Coverage Δ
multisite 69.40% <95.83%> (+0.06%) ⬆️
single 35.82% <62.50%> (+0.09%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.
@adamsilverstein adamsilverstein added [Type] Enhancement A suggestion for improvement of an existing feature [Plugin] Modern Image Formats Issues for the Modern Image Formats plugin (formerly WebP Uploads) labels Apr 14, 2026
@adamsilverstein adamsilverstein self-assigned this Apr 14, 2026
Featured images are now rewritten through the wp_get_attachment_image
filter (via webp_uploads_filter_wp_get_attachment_image()), making this
function obsolete. Document the removal as a breaking change in the
changelog for third-party callers, who should switch to
webp_uploads_img_tag_update_mime_type() or webp_uploads_wrap_image_in_picture()
directly, or rely on the new wp_get_attachment_image filter.
@adamsilverstein adamsilverstein added this to the webp-uploads n.e.x.t milestone Apr 14, 2026
@MarcinDudekDev
Copy link
Copy Markdown

Took a proper look at this and ran it on a real WooCommerce site (around 500 products, picture mode + AVIF). Good direction, the wp_get_attachment_image() gap is worth closing. Found three things though, two of them I'd treat as blockers.

webp_uploads_update_featured_image() gets deleted, not deprecated. The PR description says @deprecated, but commit 785e945 removes the function outright. It's public and @since 1.0.0, so any third-party code calling it will fatal. I'd keep it as a thin _deprecated_function() shim for a release or two.

Nested <picture>. This one produces invalid HTML. When a <picture> from wp_get_attachment_image() ends up in post content, wp_filter_content_tags() pulls the inner <img> back out and runs it through wp_content_img_tag again, so webp_uploads_wrap_image_in_picture() wraps it a second time. The stripos('<picture') guard never catches it because it only ever sees the bare <img>. I reproduced it on the live site - any in-content wp_get_attachment_image() call with a wp-image-{ID} class double-wraps. What worked for me: tag the inner <img> with data-wp-picture-wrapped and bail on that marker.

Empty <picture> wrappers. Attachments with no modern sub-size still get wrapped - <picture><img></picture> with zero <source>. Small thing, but worth bailing when there's no source to add.

One more thing, not a bug - the filter rewrites every wp_get_attachment_image() caller by default, which kind of reverses the "let's not touch other plugins' output" point from #2299. Might be worth saying explicitly in the description that this is intentional now.

I put fixes for all three on a branch, with tests. 148/148 green, and the double-wrap regression test fails without the fix so it actually catches it. Also validated end to end on the demo site. Branch: https://github.com/MarcinDudekDev/performance/tree/fix/2451-review-followups - happy to open it as a PR against this branch if that's easier.

AI assistance: Yes
Tool(s): Claude Code (Anthropic)
Model(s): Claude Opus 4.7
Used for: reviewing this PR, drafting the fix branch and tests. I reviewed and tested all of it myself.

@adamsilverstein
Copy link
Copy Markdown
Member Author

Took a proper look at this and ran it on a real WooCommerce site (around 500 products, picture mode + AVIF). Good direction, the wp_get_attachment_image() gap is worth closing. Found three things though, two of them I'd treat as blockers.

@MarcinDudekDev Thanks for reviewing and testing. I will bring in your changes, thanks for providing those.

MarcinDudekDev and others added 2 commits May 15, 2026 09:34
…ted shim

PR WordPress#2451 removed the public function webp_uploads_update_featured_image()
outright. It was hooked on `post_thumbnail_html` and is documented `@since
1.0.0`, so deleting it without a deprecation cycle fatal-errors any
third-party code that still calls it directly.

Restore it in deprecated.php as a thin wrapper that emits a deprecation
notice via _deprecated_function() and delegates to the current pipeline
(webp_uploads_wrap_image_in_picture() / webp_uploads_img_tag_update_mime_type()).
It is no longer registered as a `post_thumbnail_html` filter -- featured
images are handled by the new `wp_get_attachment_image` filter -- so this
restores backward compatibility without double-processing.

readme.txt: move the entry from "Breaking Changes" to "Deprecated".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two robustness fixes to webp_uploads_wrap_image_in_picture().

Nested <picture> (invalid HTML)
------------------------------
When a <picture> produced for a wp_get_attachment_image() call is embedded
in post content, wp_filter_content_tags() extracts the inner <img> and runs
it back through this function via `wp_content_img_tag`. The existing
`stripos( $image, '<picture' )` guard never sees the wrapper, because only
the bare inner <img> substring is passed -- so the image is wrapped twice,
producing nested <picture><picture>...</picture></picture>.

The inner <img> is now tagged with a `data-wp-picture-wrapped` attribute,
and the idempotency guard bails when that marker is present. Context
detection (doing_filter('the_content')) was considered but rejected: it
would skip page-builder images that render during `the_content`. The marker
is documented in the readme changelog.

Empty <picture> wrapper
-----------------------
When no modern-format source resolves for an attachment, the function still
emitted `<picture><img></picture>` with no <source> children. It now returns
the original <img> when $picture_sources is empty.

Adds three tests: a regression test for the in-content double-wrap (embed in
content, run `the_content`, assert exactly one <picture>), an empty-wrapper
test, and a test for the deprecated webp_uploads_update_featured_image() shim.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

[Plugin] Modern Image Formats Issues for the Modern Image Formats plugin (formerly WebP Uploads) [Type] Enhancement A suggestion for improvement of an existing feature

2 participants