Add dynamic social share images for plugin pages (Meta #5926) - #706
Add dynamic social share images for plugin pages (Meta #5926)#706mathetos wants to merge 7 commits into
Conversation
Plugin pages now get a proper preview image when someone shares a link on social media. You get the title, description, icon, and a few key stats in one clean 1200x630 image. This follows what Dion asked for on #5926: build it in PHP, keep it simple, and serve dynamic images the same way we already do with geopattern icons so the CDN can cache them. Jetpack stays installed, and on plugin pages we let it step aside for Open Graph and Twitter tags so social platforms use our image. The layout uses a small set of constants and a straightforward stats grid that holds up across different plugin titles and descriptions. The change stays focused on the plugin directory and theme meta tags, and it passes PHPUnit and PHPCS. Sample output (Gutenberg): 
|
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 Core Committers: Use this line as a base for the props when committing in SVN: To understand the WordPress project's expectations around crediting contributors, please review the Contributor Attribution page in the Core Handbook. |
obenland
left a comment
There was a problem hiding this comment.
This looks like a good start!
Overall I think it could benefit from a few iterations on simplification, WordPress coding and docs standards, and tests. Removing abstractions, be more specific to what we actually need, have tests be meaningful, etc. Can Plugin_Share_Image be half the size?
Rely on the wporg Inter font only (drop system-font and bitmap fallbacks), inline the layout constants and remove the layout class, simplify the footer gradient to a horizontal blend, use a fixed stat icon baseline, and make title truncation multibyte-safe. Revert dev-env changes except the style mapping needed for the WordPress logotype, remove the sample image and node lifecycle script, and rewrite tests against the public surface with fixture posts. Co-authored-by: Cursor <cursoragent@cursor.com>
|
Working on this based on @obenland 's comments above. |
Keep the share-image tests on PHPUnit TestCase so they run in the plugin-directory suite (PHPUnit 11). Map Inter into the test env so render() can produce a real JPEG, guard locale counts when GlotPress is absent, and clear the remaining PHPCS nits from the review pass. Co-authored-by: Cursor <cursoragent@cursor.com>
|
Thanks so much @obenland for the detailed review. The class went from roughly 950 lines (with its layout class) to about 640, so not literally half, but what's left is far more efficient and easier to navigate and troubleshoot. The biggest wins in this pass was committing to the Inter font that wporg-mu-plugins already ships, instead of probing system fonts. That deleted the bitmap fallback, the fake width math, and all the “font might be missing” branching. The footer is a simple horizontal blend now, and the stat icons use a fixed baseline instead of bounding-box math. On the env file, I kept one thing that really is still needed: style, so the WordPress logotype exists inside wp-env. I also mapped wporg-mu-plugins into the test env so PHPUnit can find Inter.ttf and render() can produce a real JPEG. The tests now stay on One trade-off worth saying out loud: with a single font dependency, a missing Inter.ttf means no share image at all (the route errors rather than serving a degraded image), and Inter doesn't cover CJK, so non-Latin plugin titles on locale sites will render with gaps. The old DejaVu fallback had the same CJK limitation, but now it's a deliberate choice rather than an accident, so flagging it in case you see it differently. PHPCS and the Plugin Directory PHPUnit tests all pass now. Happy to keep iterating if find anything else. |
There was a problem hiding this comment.
A few ways class-plugin-share-image.php could be simplified significantly:
- Reuse existing stats logic instead of reimplementing it:
count_locales()duplicates the Meta widget'savailable_languages()query,count_contributors()is a third variant of the widget/API contributor logic, andformat_install_count()is mostlyTemplate::format_active_installs_for_display()plus K+/M+ compaction. With shared helpers this class becomes a pure renderer (~350 lines instead of ~640). - Inline the one-shot constants — roughly 20 of the 24 class constants are used exactly once and nothing is configurable; folding the layout numbers into
render()cuts a lot of indirection. - Gradient footer: stretching a 2×1 image with
imagecopyresampled()gets GD to interpolate the gradient in one call, replacing 1,200imagecolorallocate()/imageline()iterations. - Fake bold:
draw_stats_row()duplicates the double-draw trick fromdraw_text_block(); a small shared text helper removes it. Dropping the dashicon glyphs from the stats row would remove another ~45 lines if the labels alone are acceptable visually.
| } | ||
|
|
||
| $icons = Template::get_plugin_icon( $plugin ); | ||
| $icon = $icons['icon_2x'] ?? $icons['icon'] ?? ''; |
There was a problem hiding this comment.
Template::get_plugin_icon() returns false (not null) for a missing icon_2x, so ?? never falls back — plugins with only a 1x raster icon render with an empty icon box. Use ?: or explicit falsy checks.
| public static function get_url( $post = null ) { | ||
| $plugin = get_post( $post ); | ||
|
|
||
| if ( ! $plugin || 'plugin' !== $plugin->post_type ) { |
There was a problem hiding this comment.
This checks post_type but not post_status. Closed/disabled are public statuses, so those pages now emit og:image/twitter:image URLs that the route 404s — previously no image tag was emitted for them. Checking for publish here would match get_data() and the route.
|
|
||
| status_header( 200 ); | ||
| header( 'Content-Type: image/jpeg' ); | ||
| header( 'Cache-Control: public, max-age=' . YEAR_IN_SECONDS ); |
There was a problem hiding this comment.
A year-long public cache on a mutable image whose URL has no version token pins the first render at CDNs/scrapers with no purge path. The geopattern route this mirrors is a pure function of its URL (slug + color act as the cache buster); this image embeds live title, installs, rating, and locale count. A single 5s icon-fetch timeout also produces an icon-less JPEG that then caches for a year. Consider a version token in the URL (e.g. hash of last_updated) or a short TTL.
| ) | ||
| ); | ||
|
|
||
| if ( $count ) { |
There was a problem hiding this comment.
if ( $count ) conflates a legitimate zero Rosetta-site count with "no global network" and falls through to the unfiltered language-pack count — e.g. a plugin whose packs are all variant locales would show a Locales stat while the Meta widget (same query) shows none. Return (int) $count unconditionally when the constant is defined.
| * @return int | ||
| */ | ||
| protected static function count_contributors( $plugin ) { | ||
| $contributors = get_terms( |
There was a problem hiding this comment.
This is a third divergent copy of the contributors-plus-owner logic (widgets/class-contributors.php, api/routes/class-plugin.php). Unlike the widget it doesn't filter out nicenames that no longer resolve to a user, so the count can disagree with the sidebar. Worth extracting a shared helper instead.
| return; | ||
| } | ||
|
|
||
| $plugin = get_posts( |
There was a problem hiding this comment.
Consider self::get_plugin_post( $slug ) instead — it sanitizes the slug and is backed by the plugin-slugs cache group including negative caching, so bot probes of nonexistent slugs don't each run a fresh WP_Query. Then: if ( ! $plugin || 'publish' !== $plugin->post_status ) { 404 }.
| printf( '<meta name="twitter:site" content="@WordPress">' . "\n" ); | ||
|
|
||
| if ( $banner['banner_2x'] ) { | ||
| $share_image = Template::get_share_image_url(); |
There was a problem hiding this comment.
get_url() never checks renderability, so if render() fails systematically (no GD, or the Inter.ttf build-input path in wporg-mu-plugins goes away), every plugin page points at a 500 and the banner branches below never run — scrapers then get no image at all. The elseif chain also drops og:image entirely in the banner_2x fallback path, where the old code emitted both tags independently.
Reuse Template for stats and icons, version the image URL so CDNs can bust, and fall back to banners when the JPEG cannot be served. Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>

Fixes https://meta.trac.wordpress.org/ticket/5926
Plugin pages now get a proper preview image when someone shares a link on social media. You get the title, description, icon, and a few key stats in one clean 1200x630 image.
This follows what Dion asked for on #5926: build it in PHP, keep it simple, and serve dynamic images the same way we already do with geopattern icons so the CDN can cache them. Jetpack stays installed, and on plugin pages we let it step aside for Open Graph and Twitter tags so social platforms use our image.
The layout uses a small set of constants and a straightforward stats grid that holds up across different plugin titles and descriptions. The change stays focused on the plugin directory and theme meta tags, and it passes PHPUnit and PHPCS.
Sample output
Test plan
/share-image/{slug}.jpgon a plugin page and confirm JPEG rendersog:imagepoints at the share image URLog:imagetags on plugin pages