From e623dbc424bc5c5e696e2432690ce024fafde336 Mon Sep 17 00:00:00 2001 From: Matt Cromwell Date: Wed, 8 Jul 2026 23:41:30 +0200 Subject: [PATCH 1/2] Add dynamic social share images for plugin pages (Meta #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 (Gutenberg): ![Gutenberg share image sample](wordpress.org/public_html/wp-content/plugins/plugin-directory/docs/share-image-sample-gutenberg.png) --- environments/plugin-directory/.wp-env.json | 7 +- .../plugin-directory/bin/after-start.js | 172 ++++ .../class-plugin-directory.php | 77 ++ .../class-plugin-share-image-layout.php | 139 +++ .../class-plugin-share-image.php | 966 ++++++++++++++++++ .../plugin-directory/class-template.php | 10 + .../docs/share-image-sample-gutenberg.png | Bin 0 -> 47207 bytes .../tests/Plugin_Share_Image_Layout_Test.php | 70 ++ .../tests/Plugin_Share_Image_Test.php | 59 ++ .../pub/wporg-plugins-2024/functions.php | 9 +- 10 files changed, 1503 insertions(+), 6 deletions(-) create mode 100644 environments/plugin-directory/bin/after-start.js create mode 100644 wordpress.org/public_html/wp-content/plugins/plugin-directory/class-plugin-share-image-layout.php create mode 100644 wordpress.org/public_html/wp-content/plugins/plugin-directory/class-plugin-share-image.php create mode 100644 wordpress.org/public_html/wp-content/plugins/plugin-directory/docs/share-image-sample-gutenberg.png create mode 100644 wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Plugin_Share_Image_Layout_Test.php create mode 100644 wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Plugin_Share_Image_Test.php diff --git a/environments/plugin-directory/.wp-env.json b/environments/plugin-directory/.wp-env.json index 56e763990b..9add0303f8 100644 --- a/environments/plugin-directory/.wp-env.json +++ b/environments/plugin-directory/.wp-env.json @@ -15,14 +15,15 @@ "wp-content/mu-plugins": "./mocks/mu-plugins", "wp-content/mu-plugins/pub": "../wordpress.org/public_html/wp-content/mu-plugins/pub", "wp-content/mu-plugins/wporg-mu-plugins": "WordPress/wporg-mu-plugins#build", - "wp-content/env-bin": "./plugin-directory/bin" + "wp-content/env-bin": "./plugin-directory/bin", + "style": "../wordpress.org/public_html/style" }, "lifecycleScripts": { - "afterStart": "bash plugin-directory/bin/after-start.sh" + "afterStart": "node plugin-directory/bin/after-start.js" }, "config": { "WP_DEBUG": true, - "JETPACK_DEV_DEBUG": true, + "JETPACK_DEV_DEBUG": false, "WP_TESTS_TITLE": "WordPress.org Plugins", "GLOTPRESS_LOCALES_PATH": "/var/www/html/wp-content/mu-plugins/pub/locales/locales.php", "PLUGINS_TABLE_PREFIX": "wp_" diff --git a/environments/plugin-directory/bin/after-start.js b/environments/plugin-directory/bin/after-start.js new file mode 100644 index 0000000000..988ec757c2 --- /dev/null +++ b/environments/plugin-directory/bin/after-start.js @@ -0,0 +1,172 @@ +#!/usr/bin/env node +/** + * Runs after wp-env start. Sets up permalinks, creates pages, and imports plugins. + * + * Cross-platform replacement for after-start.sh (Windows lacks /bin/bash in wp-env's shell). + */ + +const { spawnSync } = require( 'child_process' ); +const path = require( 'path' ); + +const ROOT = path.resolve( __dirname, '..', '..' ); +const CONFIG = '--config plugin-directory/.wp-env.json'; + +function run( args, options = {} ) { + const result = spawnSync( 'npx', [ 'wp-env', ...CONFIG.split( ' ' ), ...args ], { + cwd: ROOT, + shell: true, + encoding: 'utf8', + ...options, + } ); + + return result; +} + +function wp( ...wpArgs ) { + return run( [ 'run', 'cli', '--', 'wp', ...wpArgs ], { + stdio: [ 'pipe', 'pipe', 'pipe' ], + } ); +} + +function wpQuiet( ...wpArgs ) { + return wp( ...wpArgs ); +} + +function tryWp( message, ...wpArgs ) { + const result = wpQuiet( ...wpArgs ); + + if ( result.status === 0 ) { + console.log( message ); + } + + return result; +} + +function wpOutput( ...wpArgs ) { + const result = wp( ...wpArgs ); + return ( result.stdout || '' ).trim(); +} + +// Install CLI tools needed by the plugin directory (svn, unzip, etc.). +console.log( 'Installing CLI tools...' ); +run( [ + 'run', + 'wordpress', + 'sudo', + 'bash', + '-c', + 'command -v svn > /dev/null || (apt-get -qy update && apt-get -qy install subversion unzip zip) > /dev/null 2>&1', +], { stdio: 'inherit' } ); + +run( [ + 'run', + 'cli', + 'sudo', + 'sh', + '-c', + 'command -v svn > /dev/null || apk add --no-cache -q subversion unzip zip coreutils > /dev/null 2>&1', +], { stdio: 'inherit' } ); + +// Set up permalinks. +wp( 'rewrite', 'structure', '/%postname%/', '--hard' ); + +// Create pages that exist on wordpress.org/plugins (if they don't already exist). +console.log( 'Creating pages...' ); +tryWp( + ' Created page: /developers/', + 'post', + 'create', + '--post_type=page', + '--post_status=publish', + '--post_title=Developer Information', + '--post_name=developers', + '--porcelain' +); + +const developersId = wpOutput( + 'post', + 'list', + '--post_type=page', + '--name=developers', + '--field=ID' +); + +if ( developersId ) { + tryWp( + ' Created page: /developers/add/', + 'post', + 'create', + '--post_type=page', + '--post_status=publish', + '--post_title=Add your Plugin', + '--post_name=add', + `--post_parent=${ developersId }`, + '--porcelain' + ); + tryWp( + ' Created page: /developers/readme-validator/', + 'post', + 'create', + '--post_type=page', + '--post_status=publish', + '--post_title=Readme Validator', + '--post_content=[readme-validator]', + '--post_name=readme-validator', + `--post_parent=${ developersId }`, + '--porcelain' + ); + tryWp( + ' Created page: /developers/block-plugin-validator/', + 'post', + 'create', + '--post_type=page', + '--post_status=publish', + '--post_title=Block Plugin Checker', + '--post_content=[block-validator]', + '--post_name=block-plugin-validator', + `--post_parent=${ developersId }`, + '--porcelain' + ); + tryWp( + ' Created page: /developers/releases/', + 'post', + 'create', + '--post_type=page', + '--post_status=publish', + '--post_title=Release Management', + '--post_content=[release-confirmation]', + '--post_name=releases', + `--post_parent=${ developersId }`, + '--porcelain' + ); +} + +// Create stub database tables that exist outside WordPress on production. +wp( 'db', 'import', 'wp-content/env-bin/database-tables.sql' ); + +// Create browse section terms with proper display names. +console.log( 'Creating browse sections...' ); +const sections = { + featured: 'Featured', + popular: 'Popular', + beta: 'Beta', + blocks: 'Block-Enabled', + new: 'New', + updated: 'Recently Updated', + favorites: 'Favorites', + 'dashboard-widgets': 'Dashboard Widgets', +}; + +for ( const [ slug, name ] of Object.entries( sections ) ) { + tryWp( + ` Created section: ${ name } (${ slug })`, + 'term', + 'create', + 'plugin_section', + name, + `--slug=${ slug }` + ); +} + +// Import plugins from wordpress.org. +wp( 'eval-file', 'wp-content/env-bin/import-plugins.php' ); diff --git a/wordpress.org/public_html/wp-content/plugins/plugin-directory/class-plugin-directory.php b/wordpress.org/public_html/wp-content/plugins/plugin-directory/class-plugin-directory.php index 105c678dfc..19377576cf 100644 --- a/wordpress.org/public_html/wp-content/plugins/plugin-directory/class-plugin-directory.php +++ b/wordpress.org/public_html/wp-content/plugins/plugin-directory/class-plugin-directory.php @@ -42,9 +42,12 @@ private function __construct() { add_filter( 'pre_update_option_jetpack_options', array( $this, 'filter_jetpack_options' ) ); add_filter( 'jetpack_sitemap_post_types', array( $this, 'jetpack_sitemap_post_types' ) ); add_filter( 'jetpack_sitemap_skip_post', array( $this, 'jetpack_sitemap_skip_post' ), 10, 2 ); + add_filter( 'jetpack_enable_open_graph', array( $this, 'disable_jetpack_open_graph_for_plugins' ), 99 ); + add_filter( 'jetpack_disable_twitter_cards', array( $this, 'disable_jetpack_twitter_cards_for_plugins' ), 99 ); add_action( 'template_redirect', array( $this, 'prevent_canonical_for_plugins' ), 9 ); add_action( 'template_redirect', array( $this, 'custom_redirects' ), 1 ); add_action( 'template_redirect', array( $this, 'geopattern_icon_route' ), 0 ); + add_action( 'template_redirect', array( $this, 'share_image_route' ), 0 ); add_filter( 'query_vars', array( $this, 'filter_query_vars' ), 1 ); add_filter( 'single_term_title', array( $this, 'filter_single_term_title' ) ); add_filter( 'get_the_archive_title_prefix', array( $this, 'filter_get_the_archive_title_prefix' ) ); @@ -428,6 +431,9 @@ public function init() { // Add a rule for generated plugin icons. geopattern-icon/demo.svg | geopattern-icon/demo_abc123.svg add_rewrite_rule( '^geopattern-icon/([^/_]+)(_([a-f0-9]{6}))?\.svg$', 'index.php?name=$matches[1]&geopattern_icon=$matches[3]', 'top' ); + // Add a rule for generated plugin share images. share-image/demo.jpg + add_rewrite_rule( '^share-image/([^/]+)\.jpg$', 'index.php?plugin_share_image=$matches[1]', 'top' ); + // Handle plugin admin requests add_rewrite_rule( '^([^/]+)/advanced/?$', 'index.php?name=$matches[1]&plugin_advanced=1', 'top' ); @@ -1207,6 +1213,7 @@ public function filter_query_vars( $vars ) { $vars[] = 'redirect_plugin_tab'; $vars[] = 'plugin_advanced'; $vars[] = 'geopattern_icon'; + $vars[] = 'plugin_share_image'; $vars[] = 'block_search'; // Remove support for any query vars the Plugin Directory doesn't support/need on the front-end. @@ -1509,6 +1516,38 @@ function geopattern_icon_route() { die(); } + /** + * Output a JPEG share image for a given plugin. + */ + function share_image_route() { + $slug = get_query_var( 'plugin_share_image' ); + + if ( ! $slug ) { + return; + } + + $plugin = get_posts( + array( + 'post_type' => 'plugin', + 'name' => $slug, + 'post_status' => 'publish', + 'posts_per_page' => 1, + ) + ); + + if ( empty( $plugin ) ) { + status_header( 404 ); + die(); + } + + if ( ! Plugin_Share_Image::output( $plugin[0] ) ) { + status_header( 500 ); + die(); + } + + die(); + } + /** * The array of post types to be included in the sitemap. * @@ -1543,6 +1582,44 @@ public function jetpack_sitemap_skip_post( $skip, $plugin_db_row ) { return $skip; } + /** + * Disable Jetpack Open Graph on plugin pages. + * + * The theme outputs custom Open Graph tags, including the dynamic share image. + * + * @param bool $enabled Whether Jetpack Open Graph is enabled. + * @return bool + */ + public function disable_jetpack_open_graph_for_plugins( $enabled ) { + if ( ! did_action( 'wp' ) ) { + return $enabled; + } + + if ( is_singular( 'plugin' ) ) { + return false; + } + + return $enabled; + } + + /** + * Disable Jetpack Twitter Cards on plugin pages. + * + * @param bool $disabled Whether Jetpack Twitter Cards are disabled. + * @return bool + */ + public function disable_jetpack_twitter_cards_for_plugins( $disabled ) { + if ( ! did_action( 'wp' ) ) { + return $disabled; + } + + if ( is_singular( 'plugin' ) ) { + return true; + } + + return $disabled; + } + /** * Whitelists the oembed providers whitelist. * diff --git a/wordpress.org/public_html/wp-content/plugins/plugin-directory/class-plugin-share-image-layout.php b/wordpress.org/public_html/wp-content/plugins/plugin-directory/class-plugin-share-image-layout.php new file mode 100644 index 0000000000..287106c97d --- /dev/null +++ b/wordpress.org/public_html/wp-content/plugins/plugin-directory/class-plugin-share-image-layout.php @@ -0,0 +1,139 @@ + 72, + 'margin_top' => 72, + 'footer_height' => 16, + 'icon_size' => 128, + 'icon_gap' => 48, + 'logo_reserve' => 108, + 'logo_size' => 56, + 'title_size' => 40, + 'title_line' => 52, + 'title_max' => 2, + 'description_size' => 22, + 'description_line' => 34, + 'description_max' => 3, + 'description_gap' => 20, + 'stat_icon_size' => 26, + 'stat_value_size' => 26, + 'stat_label_size' => 14, + 'stat_icon_gap' => 8, + 'stat_icon_slot' => 32, + 'colors' => array( + 'title' => array( 30, 30, 30 ), + 'description' => array( 80, 87, 94 ), + 'stat_icon' => array( 113, 116, 127 ), + 'stat_value' => array( 50, 55, 60 ), + 'stat_label' => array( 113, 116, 127 ), + 'icon_surface' => array( 246, 247, 247 ), + ), + ); + + /** + * Resolve layout measurements for the canvas. + * + * @return array + */ + public static function resolve() { + $margin_x = self::SPEC['margin_x']; + $margin_top = self::SPEC['margin_top']; + $footer_h = self::SPEC['footer_height']; + $icon_size = self::SPEC['icon_size']; + $icon_gap = self::SPEC['icon_gap']; + $logo_reserve = self::SPEC['logo_reserve']; + + $icon_x = self::CANVAS_WIDTH - $margin_x - $icon_size; + $content_width = $icon_x - $icon_gap - $margin_x; + $stats_right = self::CANVAS_WIDTH - $margin_x - $logo_reserve; + $stats_width = $stats_right - $margin_x; + $column_width = (int) floor( $stats_width / self::STAT_COLUMNS ); + + $stats_value_y = self::CANVAS_HEIGHT - $footer_h - 78; + $stats_label_y = self::CANVAS_HEIGHT - $footer_h - 38; + + return array( + 'canvas' => array( + 'width' => self::CANVAS_WIDTH, + 'height' => self::CANVAS_HEIGHT, + ), + 'colors' => self::SPEC['colors'], + 'type' => array( + 'title' => array( + 'size' => self::SPEC['title_size'], + 'line_height' => self::SPEC['title_line'], + 'max_lines' => self::SPEC['title_max'], + ), + 'description' => array( + 'size' => self::SPEC['description_size'], + 'line_height' => self::SPEC['description_line'], + 'max_lines' => self::SPEC['description_max'], + ), + 'stat' => array( + 'icon_size' => self::SPEC['stat_icon_size'], + 'value_size' => self::SPEC['stat_value_size'], + 'label_size' => self::SPEC['stat_label_size'], + 'icon_gap' => self::SPEC['stat_icon_gap'], + 'icon_slot' => self::SPEC['stat_icon_slot'], + ), + ), + 'zones' => array( + 'content' => array( + 'x' => $margin_x, + 'y' => $margin_top, + 'width' => $content_width, + ), + 'plugin_icon' => array( + 'x' => $icon_x, + 'y' => $margin_top, + 'size' => $icon_size, + ), + 'stats' => array( + 'x' => $margin_x, + 'column_width' => $column_width, + 'value_y' => $stats_value_y, + 'label_y' => $stats_label_y, + ), + 'branding' => array( + 'center_y' => $stats_value_y - 18, + 'size' => self::SPEC['logo_size'], + ), + 'footer' => array( + 'y' => self::CANVAS_HEIGHT - $footer_h, + 'height' => $footer_h, + ), + ), + 'title' => array( + 'x' => $margin_x, + 'y' => $margin_top + 44, + ), + 'description_gap' => self::SPEC['description_gap'], + ); + } + + /** + * Get the X origin for a stat column index. + * + * @param array $layout Resolved layout. + * @param int $index Zero-based column index. + * @return int + */ + public static function stat_column_x( $layout, $index ) { + $stats = $layout['zones']['stats']; + + return $stats['x'] + ( $index * $stats['column_width'] ); + } +} diff --git a/wordpress.org/public_html/wp-content/plugins/plugin-directory/class-plugin-share-image.php b/wordpress.org/public_html/wp-content/plugins/plugin-directory/class-plugin-share-image.php new file mode 100644 index 0000000000..9035a9ce0c --- /dev/null +++ b/wordpress.org/public_html/wp-content/plugins/plugin-directory/class-plugin-share-image.php @@ -0,0 +1,966 @@ + 135, + 'start' => array( 2, 3, 129 ), + 'end' => array( 40, 116, 252 ), + ); + + /** + * Collect share-image data for a plugin. + * + * @param \WP_Post $plugin Plugin post object. + * @return array|null + */ + public static function get_data( $plugin ) { + $plugin = get_post( $plugin ); + + if ( ! $plugin || 'plugin' !== $plugin->post_type || 'publish' !== $plugin->post_status ) { + return null; + } + + $icons = Template::get_plugin_icon( $plugin ); + $icon = $icons['icon_2x'] ?? $icons['icon'] ?? ''; + + if ( $icons['generated'] && str_ends_with( (string) $icon, '.svg' ) ) { + $icon = ''; + } + + $active_installs = (int) get_post_meta( $plugin->ID, 'active_installs', true ); + + return array( + 'title' => get_the_title( $plugin ), + 'description' => wp_strip_all_tags( get_the_excerpt( $plugin ) ), + 'icon_url' => $icon, + 'stats' => self::get_stat_items( $plugin, $active_installs ), + ); + } + + /** + * Build structured stat items for the stats row. + * + * @param \WP_Post $plugin Plugin post object. + * @param int $active_installs Active install count. + * @return array + */ + protected static function get_stat_items( $plugin, $active_installs ) { + $stats = array(); + + $contributors = self::count_contributors( $plugin ); + $stats[] = array( + 'icon' => 'groups', + 'value' => number_format_i18n( $contributors ), + 'label' => _n( 'Contributor', 'Contributors', $contributors, 'wporg-plugins' ), + ); + + $locales = self::count_locales( $plugin ); + if ( $locales > 0 ) { + $stats[] = array( + 'icon' => 'translation', + 'value' => number_format_i18n( $locales ), + 'label' => _n( 'Locale', 'Locales', $locales, 'wporg-plugins' ), + ); + } + + $rating = self::get_rating_value( $plugin ); + if ( $rating > 0 ) { + $stats[] = array( + 'icon' => 'star-filled', + 'value' => number_format_i18n( $rating, 1 ), + 'label' => __( 'Rating', 'wporg-plugins' ), + ); + } + + $stats[] = self::get_install_stat_item( $active_installs ); + + return $stats; + } + + /** + * Build the installs stat item. + * + * @param int $active_installs Active install count. + * @return array{icon: string, value: string, label: string} + */ + protected static function get_install_stat_item( $active_installs ) { + if ( $active_installs < 10 ) { + return array( + 'icon' => 'download', + 'value' => '<10', + 'label' => __( 'Installs', 'wporg-plugins' ), + ); + } + + if ( $active_installs >= 1000000 ) { + $millions = intdiv( $active_installs, 1000000 ); + + return array( + 'icon' => 'download', + 'value' => sprintf( '%dM+', $millions ), + 'label' => __( 'Installs', 'wporg-plugins' ), + ); + } + + if ( $active_installs >= 100000 ) { + $thousands = intdiv( $active_installs, 1000 ); + + return array( + 'icon' => 'download', + 'value' => sprintf( '%dK+', $thousands ), + 'label' => __( 'Installs', 'wporg-plugins' ), + ); + } + + return array( + 'icon' => 'download', + 'value' => Template::format_active_installs_for_display( $active_installs ), + 'label' => __( 'Installs', 'wporg-plugins' ), + ); + } + + /** + * Count plugin contributors, including the plugin owner. + * + * @param \WP_Post $plugin Plugin post object. + * @return int + */ + protected static function count_contributors( $plugin ) { + $contributors = get_terms( + array( + 'taxonomy' => 'plugin_contributors', + 'object_ids' => array( $plugin->ID ), + 'fields' => 'names', + ) + ); + + if ( is_wp_error( $contributors ) ) { + $contributors = array(); + } + + $plugin_owner = get_the_author_meta( 'user_nicename', $plugin->post_author ); + if ( $plugin_owner && ! in_array( $plugin_owner, $contributors, true ) ) { + $contributors = array_merge( array( $plugin_owner ), $contributors ); + } + + return count( array_unique( $contributors ) ); + } + + /** + * Count locales with active translations for a plugin. + * + * @param \WP_Post $plugin Plugin post object. + * @return int + */ + protected static function count_locales( $plugin ) { + $translations = Plugin_I18n::instance()->get_translations( $plugin->post_name ); + + if ( empty( $translations ) ) { + return 0; + } + + if ( defined( 'WPORG_GLOBAL_NETWORK_ID' ) ) { + $wp_locales = wp_list_pluck( $translations, 'wp_locale' ); + $count = get_sites( + array( + 'network_id' => WPORG_GLOBAL_NETWORK_ID, + 'public' => 1, + 'path' => '/', + 'locale__in' => $wp_locales, + 'number' => '', + 'count' => true, + ) + ); + + if ( $count ) { + return (int) $count; + } + } + + return count( $translations ); + } + + /** + * Get the average plugin rating. + * + * @param \WP_Post $plugin Plugin post object. + * @return float + */ + protected static function get_rating_value( $plugin ) { + if ( class_exists( '\WPORG_Ratings' ) ) { + return (float) ( \WPORG_Ratings::get_avg_rating( 'plugin', $plugin->post_name ) ?: 0 ); + } + + return (float) ( get_post_meta( $plugin->ID, 'rating', true ) ?: 0 ); + } + + /** + * Build the public share-image URL for a plugin. + * + * @param int|\WP_Post|null $post Optional. Post ID or post object. + * @return string|false + */ + public static function get_url( $post = null ) { + $plugin = get_post( $post ); + + if ( ! $plugin || 'plugin' !== $plugin->post_type ) { + return false; + } + + return home_url( "/share-image/{$plugin->post_name}.jpg" ); + } + + /** + * Render a JPEG share image for a plugin. + * + * @param \WP_Post $plugin Plugin post object. + * @return string|false JPEG bytes, or false on failure. + */ + public static function render( $plugin ) { + if ( ! function_exists( 'imagecreatetruecolor' ) ) { + return false; + } + + $data = self::get_data( $plugin ); + + if ( ! $data ) { + return false; + } + + $image = imagecreatetruecolor( self::WIDTH, self::HEIGHT ); + + if ( ! $image ) { + return false; + } + + $layout = Plugin_Share_Image_Layout::resolve(); + $fonts = self::get_font_paths(); + $type = $layout['type']; + + $allocate = static function ( $rgb ) use ( $image ) { + return imagecolorallocate( $image, $rgb[0], $rgb[1], $rgb[2] ); + }; + + $white = imagecolorallocate( $image, 255, 255, 255 ); + $text_dark = $allocate( $layout['colors']['title'] ); + $text_muted = $allocate( $layout['colors']['description'] ); + $stat_icon_color = $allocate( $layout['colors']['stat_icon'] ); + $stat_value_color = $allocate( $layout['colors']['stat_value'] ); + $stat_label_color = $allocate( $layout['colors']['stat_label'] ); + $icon_surface = $allocate( $layout['colors']['icon_surface'] ); + + imagefilledrectangle( $image, 0, 0, self::WIDTH, self::HEIGHT, $white ); + self::draw_footer_gradient( $image, $layout ); + + $title_lines = self::wrap_text_by_width( + $data['title'], + $layout['zones']['content']['width'], + $type['title']['size'], + $fonts['bold'] ?: $fonts['regular'], + $type['title']['max_lines'] + ); + $layout['description_y'] = $layout['title']['y'] + + ( count( $title_lines ) * $type['title']['line_height'] ) + + $layout['description_gap']; + + self::draw_title_block( $image, $data['title'], $fonts, $text_dark, $layout, $title_lines ); + self::draw_description_block( $image, $data['description'], $fonts, $text_muted, $layout ); + self::draw_stats_row( + $image, + $data['stats'], + $fonts, + array( + 'icon' => $stat_icon_color, + 'value' => $stat_value_color, + 'label' => $stat_label_color, + ), + $layout + ); + self::draw_icon( + $image, + $data['icon_url'], + $layout['zones']['plugin_icon']['x'], + $layout['zones']['plugin_icon']['y'], + $layout['zones']['plugin_icon']['size'], + $icon_surface + ); + self::draw_wordpress_logo( $image, $layout ); + + ob_start(); + imagejpeg( $image, null, 88 ); + $bytes = ob_get_clean(); + imagedestroy( $image ); + + return $bytes; + } + + /** + * Output HTTP headers and JPEG body for a plugin share image. + * + * @param \WP_Post $plugin Plugin post object. + * @return bool Whether output was sent. + */ + public static function output( $plugin ) { + $bytes = self::render( $plugin ); + + if ( false === $bytes ) { + return false; + } + + status_header( 200 ); + header( 'Content-Type: image/jpeg' ); + header( 'Cache-Control: public, max-age=' . YEAR_IN_SECONDS ); + header( 'Expires: ' . gmdate( 'D, d M Y H:i:s \G\M\T', time() + YEAR_IN_SECONDS ) ); + echo $bytes; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped + + return true; + } + + /** + * Draw the footer bar using the midnight theme gradient. + * + * Mirrors --wp--preset--gradient--midnight from wporg-parent-2021, which is + * available on the Plugin Directory via the parent theme global styles. + * + * @param \GdImage $image Destination image. + */ + protected static function draw_footer_gradient( $image, $layout = null ) { + $gradient = self::get_midnight_gradient_stops(); + $footer = ( $layout ?? Plugin_Share_Image_Layout::resolve() )['zones']['footer']; + $y_start = $footer['y']; + $y_mid = $y_start + ( $footer['height'] / 2 ); + $angle = deg2rad( $gradient['angle'] ); + + // CSS linear-gradient angles are measured clockwise from north. + $dx = sin( $angle ); + $dy = -cos( $angle ); + + $projections = array( + 0, + self::WIDTH * $dx, + self::HEIGHT * $dy, + ( self::WIDTH * $dx ) + ( self::HEIGHT * $dy ), + ); + + $min_proj = min( $projections ); + $max_proj = max( $projections ); + $range = $max_proj - $min_proj ?: 1; + + for ( $x = 0; $x < self::WIDTH; $x++ ) { + $t = ( ( $x * $dx ) + ( $y_mid * $dy ) - $min_proj ) / $range; + $t = max( 0, min( 1, $t ) ); + $color = imagecolorallocate( + $image, + (int) round( $gradient['start'][0] + ( ( $gradient['end'][0] - $gradient['start'][0] ) * $t ) ), + (int) round( $gradient['start'][1] + ( ( $gradient['end'][1] - $gradient['start'][1] ) * $t ) ), + (int) round( $gradient['start'][2] + ( ( $gradient['end'][2] - $gradient['start'][2] ) * $t ) ) + ); + + imageline( $image, $x, $y_start, $x, $y_start + $footer['height'] - 1, $color ); + } + } + + /** + * Resolve midnight gradient stops from theme presets when available. + * + * @return array{angle: int, start: int[], end: int[]} + */ + protected static function get_midnight_gradient_stops() { + static $stops = null; + + if ( null !== $stops ) { + return $stops; + } + + $stops = self::MIDNIGHT_GRADIENT; + + if ( class_exists( '\WP_Theme_JSON_Resolver' ) ) { + $raw = \WP_Theme_JSON_Resolver::get_merged_data()->get_raw_data(); + $gradients = $raw['settings']['color']['gradients'] ?? array(); + + foreach ( $gradients as $gradient ) { + if ( 'midnight' !== ( $gradient['slug'] ?? '' ) || empty( $gradient['gradient'] ) ) { + continue; + } + + $parsed = self::parse_linear_gradient( $gradient['gradient'] ); + if ( $parsed ) { + $stops = $parsed; + break; + } + } + } + + return $stops; + } + + /** + * Parse a two-stop CSS linear-gradient declaration. + * + * @param string $gradient CSS linear-gradient() value. + * @return array{angle: int, start: int[], end: int[]}|null + */ + protected static function parse_linear_gradient( $gradient ) { + if ( ! preg_match( + '/linear-gradient\(\s*([0-9.]+)deg\s*,\s*rgb\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)\s*0%\s*,\s*rgb\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)\s*100%\s*\)/i', + $gradient, + $matches + ) ) { + return null; + } + + return array( + 'angle' => (int) round( (float) $matches[1] ), + 'start' => array( (int) $matches[2], (int) $matches[3], (int) $matches[4] ), + 'end' => array( (int) $matches[5], (int) $matches[6], (int) $matches[7] ), + ); + } + + /** + * Draw the plugin title block. + * + * @param \GdImage $image Destination image. + * @param string $title Plugin title. + * @param array $fonts Font paths. + * @param int $text_color Text color. + * @param array $layout Layout measurements. + * @param string[]|null $lines Pre-wrapped title lines. + */ + protected static function draw_title_block( $image, $title, $fonts, $text_color, $layout, $lines = null ) { + $type = $layout['type']['title']; + $font_path = $fonts['bold'] ?: $fonts['regular']; + $lines = $lines ?? self::wrap_text_by_width( + $title, + $layout['zones']['content']['width'], + $type['size'], + $font_path, + $type['max_lines'] + ); + + $baseline_y = $layout['title']['y']; + + if ( count( $lines ) >= $type['max_lines'] ) { + $last_index = array_key_last( $lines ); + $lines[ $last_index ] = self::truncate_text_to_width( + $lines[ $last_index ], + $layout['zones']['content']['width'], + $type['size'], + $font_path + ); + } + + foreach ( $lines as $line ) { + self::draw_text_line( + $image, + $line, + $layout['title']['x'], + $baseline_y, + $type['size'], + $fonts['bold'], + $text_color, + true + ); + + $baseline_y += $type['line_height']; + } + } + + /** + * Draw the plugin description block. + * + * @param \GdImage $image Destination image. + * @param string $description Plugin description. + * @param array $fonts Font paths. + * @param int $text_color Text color. + * @param array $layout Layout measurements. + */ + protected static function draw_description_block( $image, $description, $fonts, $text_color, $layout ) { + $type = $layout['type']['description']; + $font_path = $fonts['regular'] ?: $fonts['bold']; + $lines = self::wrap_text_by_width( + $description, + $layout['zones']['content']['width'], + $type['size'], + $font_path, + $type['max_lines'] + ); + + $baseline_y = $layout['description_y']; + + if ( ! empty( $lines ) ) { + $last_index = array_key_last( $lines ); + $lines[ $last_index ] = self::truncate_text_to_width( + $lines[ $last_index ], + $layout['zones']['content']['width'], + $type['size'], + $font_path + ); + } + + foreach ( $lines as $line ) { + self::draw_text_line( + $image, + $line, + $layout['zones']['content']['x'], + $baseline_y, + $type['size'], + $fonts['regular'], + $text_color + ); + + $baseline_y += $type['line_height']; + } + } + + /** + * Draw a single line of text with TTF or bitmap fallback. + * + * @param \GdImage $image Destination image. + * @param string $text Text to render. + * @param int $x X position. + * @param int $baseline_y Baseline Y position. + * @param int $font_size Font size in points. + * @param string|false $font_path Font path. + * @param int $color Text color. + * @param bool $emphasize Whether to simulate a heavier weight. + */ + protected static function draw_text_line( $image, $text, $x, $baseline_y, $font_size, $font_path, $color, $emphasize = false ) { + if ( $font_path ) { + imagettftext( $image, $font_size, 0, $x, $baseline_y, $color, $font_path, $text ); + + if ( $emphasize ) { + imagettftext( $image, $font_size, 0, $x + 1, $baseline_y, $color, $font_path, $text ); + } + + return; + } + + $bitmap_size = $font_size >= 30 ? 5 : 4; + imagestring( $image, $bitmap_size, $x, $baseline_y - ( $font_size + 4 ), $text, $color ); + } + + /** + * Draw a plugin icon onto the canvas. + * + * @param \GdImage $image Destination image. + * @param string $url Icon URL. + * @param int $x X position. + * @param int $y Y position. + * @param int $size Target size in pixels. + * @param int|null $surface_color Optional background fill color. + */ + protected static function draw_icon( $image, $url, $x, $y, $size, $surface_color = null ) { + if ( $surface_color ) { + imagefilledrectangle( $image, $x, $y, $x + $size, $y + $size, $surface_color ); + } + + if ( ! $url || str_ends_with( $url, '.svg' ) ) { + return; + } + + $response = wp_remote_get( + $url, + array( + 'timeout' => 5, + ) + ); + + if ( is_wp_error( $response ) ) { + return; + } + + $body = wp_remote_retrieve_body( $response ); + + if ( ! $body ) { + return; + } + + $icon = @imagecreatefromstring( $body ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged + + if ( ! $icon ) { + return; + } + + $src_w = imagesx( $icon ); + $src_h = imagesy( $icon ); + + imagecopyresampled( $image, $icon, $x, $y, 0, 0, $size, $size, $src_w, $src_h ); + imagedestroy( $icon ); + } + + /** + * Draw the WordPress logo in the bottom-right corner. + * + * @param \GdImage $image Destination image. + * @param array $layout Layout measurements. + */ + protected static function draw_wordpress_logo( $image, $layout ) { + $path = self::get_logo_path(); + + if ( ! $path ) { + return; + } + + $logo = @imagecreatefrompng( $path ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged + + if ( ! $logo ) { + return; + } + + $src_w = imagesx( $logo ); + $src_h = imagesy( $logo ); + + if ( ! $src_w || ! $src_h ) { + imagedestroy( $logo ); + return; + } + + $target_h = $layout['zones']['branding']['size']; + $target_w = (int) round( $src_w * ( $target_h / $src_h ) ); + $x = self::WIDTH - $layout['zones']['content']['x'] - $target_w; + $y = $layout['zones']['branding']['center_y'] - (int) round( $target_h / 2 ); + + imagealphablending( $image, true ); + imagecopyresampled( $image, $logo, $x, $y, 0, 0, $target_w, $target_h, $src_w, $src_h ); + imagedestroy( $logo ); + } + + /** + * Locate the WordPress logo asset bundled with WordPress.org. + * + * @return string|false + */ + protected static function get_logo_path() { + $path = dirname( __DIR__, 3 ) . '/style/images/about/WordPress-logotype-simplified.png'; + + return file_exists( $path ) ? $path : false; + } + + /** + * Draw the plugin stats row above the footer bar. + * + * @param \GdImage $image Destination image. + * @param array $stats Stat items. + * @param array $fonts Font paths. + * @param array{icon: int, value: int, label: int} $colors Stat colors. + * @param array $layout Layout measurements. + */ + protected static function draw_stats_row( $image, $stats, $fonts, $colors, $layout ) { + if ( empty( $stats ) ) { + return; + } + + $dashicons_path = self::get_dashicons_font_path(); + $value_font = $fonts['bold'] ?: $fonts['regular']; + + foreach ( $stats as $index => $stat ) { + if ( $index >= Plugin_Share_Image_Layout::STAT_COLUMNS ) { + break; + } + + self::draw_stat_item( + $image, + $stat, + Plugin_Share_Image_Layout::stat_column_x( $layout, $index ), + $layout, + $dashicons_path, + $value_font, + $fonts['regular'], + $colors + ); + } + } + + /** + * Draw a single stat item with icon, value, and label. + * + * @param \GdImage $image Destination image. + * @param array{icon: string, value: string, label: string} $stat Stat item. + * @param int $x Column X position. + * @param array $layout Layout measurements. + * @param string|false $dashicons_path Dashicons font path. + * @param string|false $value_font Value font path. + * @param string|false $label_font Label font path. + * @param array{icon: int, value: int, label: int} $colors Stat colors. + */ + protected static function draw_stat_item( $image, $stat, $x, $layout, $dashicons_path, $value_font, $label_font, $colors ) { + $type = $layout['type']['stat']; + $stats_zone = $layout['zones']['stats']; + $glyph = self::get_dashicon_glyph( $stat['icon'] ); + $value_x = $x; + $icon_slot = $type['icon_slot'] ?? $type['icon_size']; + + if ( $glyph && $dashicons_path ) { + $icon_y = self::align_baseline_to_target( + $glyph, + $type['icon_size'], + $dashicons_path, + $stats_zone['value_y'], + $type['value_size'], + $stat['value'], + $value_font + ); + + imagettftext( $image, $type['icon_size'], 0, $x, $icon_y, $colors['icon'], $dashicons_path, $glyph ); + $value_x = $x + $icon_slot + $type['icon_gap']; + } + + self::draw_text_line( + $image, + $stat['value'], + $value_x, + $stats_zone['value_y'], + $type['value_size'], + $value_font, + $colors['value'], + true + ); + + self::draw_text_line( + $image, + $stat['label'], + $value_x, + $stats_zone['label_y'], + $type['label_size'], + $label_font ?: $value_font, + $colors['label'] + ); + } + + /** + * Align a dashicon baseline to visually center with a value glyph. + * + * @param string $icon_glyph Dashicon glyph. + * @param int $icon_font_size Dashicon font size. + * @param string|false $icon_font_path Dashicon font path. + * @param int $value_baseline Value text baseline. + * @param int $value_font_size Value font size. + * @param string $value_text Value text. + * @param string|false $value_font_path Value font path. + * @return int + */ + protected static function align_baseline_to_target( $icon_glyph, $icon_font_size, $icon_font_path, $value_baseline, $value_font_size, $value_text, $value_font_path ) { + if ( ! $icon_font_path || ! $value_font_path ) { + return $value_baseline; + } + + $icon_box = imagettfbbox( $icon_font_size, 0, $icon_font_path, $icon_glyph ); + $value_box = imagettfbbox( $value_font_size, 0, $value_font_path, $value_text ); + + $icon_center = ( min( $icon_box[5], $icon_box[7] ) + max( $icon_box[1], $icon_box[3] ) ) / 2; + $value_center = ( min( $value_box[5], $value_box[7] ) + max( $value_box[1], $value_box[3] ) ) / 2; + + return (int) round( $value_baseline + $value_center - $icon_center ); + } + + /** + * Locate the Dashicons font bundled with WordPress. + * + * @return string|false + */ + protected static function get_dashicons_font_path() { + $path = ABSPATH . 'wp-includes/fonts/dashicons.ttf'; + + return file_exists( $path ) ? $path : false; + } + + /** + * Map a dashicon slug to its font glyph. + * + * @param string $icon Dashicon slug. + * @return string|false + */ + protected static function get_dashicon_glyph( $icon ) { + $codepoints = array( + 'groups' => 0xF307, + 'translation' => 0xF326, + 'star-filled' => 0xF155, + 'download' => 0xF316, + ); + + if ( ! isset( $codepoints[ $icon ] ) ) { + return false; + } + + return function_exists( 'mb_chr' ) + ? mb_chr( $codepoints[ $icon ], 'UTF-8' ) + : false; + } + + /** + * Locate usable TrueType fonts in the container. + * + * @return array{regular: string|false, bold: string|false} + */ + protected static function get_font_paths() { + $wporg_fonts = defined( 'WP_CONTENT_DIR' ) + ? WP_CONTENT_DIR . '/mu-plugins/wporg-mu-plugins/fonts/' + : ''; + + $candidates = array( + 'regular' => array_filter( + array( + $wporg_fonts ? $wporg_fonts . 'Inter.ttf' : '', + '/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf', + '/usr/share/fonts/TTF/DejaVuSans.ttf', + 'C:\\Windows\\Fonts\\arial.ttf', + ) + ), + 'bold' => array_filter( + array( + $wporg_fonts ? $wporg_fonts . 'Inter.ttf' : '', + '/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf', + '/usr/share/fonts/TTF/DejaVuSans-Bold.ttf', + 'C:\\Windows\\Fonts\\arialbd.ttf', + ) + ), + ); + + $fonts = array( + 'regular' => false, + 'bold' => false, + ); + + foreach ( $candidates['regular'] as $path ) { + if ( file_exists( $path ) ) { + $fonts['regular'] = $path; + break; + } + } + + foreach ( $candidates['bold'] as $path ) { + if ( file_exists( $path ) ) { + $fonts['bold'] = $path; + break; + } + } + + if ( ! $fonts['bold'] && $fonts['regular'] ) { + $fonts['bold'] = $fonts['regular']; + } + + return $fonts; + } + + /** + * Measure rendered text width in pixels. + * + * @param string $text Input text. + * @param int $font_size Font size in points. + * @param string|false $font_path Font path. + * @return int + */ + protected static function measure_text_width( $text, $font_size, $font_path ) { + if ( ! $font_path ) { + return (int) ( strlen( $text ) * ( $font_size * 0.55 ) ); + } + + $box = imagettfbbox( $font_size, 0, $font_path, $text ); + + return abs( $box[2] - $box[0] ); + } + + /** + * Truncate text to fit within a pixel width. + * + * @param string $text Input text. + * @param int $max_width Maximum width in pixels. + * @param int $font_size Font size in points. + * @param string|false $font_path Font path. + * @return string + */ + protected static function truncate_text_to_width( $text, $max_width, $font_size, $font_path ) { + if ( self::measure_text_width( $text, $font_size, $font_path ) <= $max_width ) { + return $text; + } + + $ellipsis = '…'; + $length = strlen( $text ); + + while ( $length > 0 ) { + $candidate = rtrim( substr( $text, 0, $length ) ) . $ellipsis; + + if ( self::measure_text_width( $candidate, $font_size, $font_path ) <= $max_width ) { + return $candidate; + } + + --$length; + } + + return $ellipsis; + } + + /** + * Wrap text into lines that fit within a pixel width. + * + * @param string $text Input text. + * @param int $max_width Maximum line width in pixels. + * @param int $font_size Font size in points. + * @param string|false $font_path Font path. + * @param int $max_lines Maximum number of lines. + * @return string[] + */ + protected static function wrap_text_by_width( $text, $max_width, $font_size, $font_path, $max_lines = 3 ) { + $words = preg_split( '/\s+/', trim( $text ) ); + $lines = array(); + $line = ''; + + foreach ( $words as $word ) { + $candidate = $line ? $line . ' ' . $word : $word; + + if ( self::measure_text_width( $candidate, $font_size, $font_path ) > $max_width ) { + if ( $line ) { + $lines[] = $line; + $line = $word; + } else { + $lines[] = self::truncate_text_to_width( $word, $max_width, $font_size, $font_path ); + $line = ''; + } + } else { + $line = $candidate; + } + + if ( count( $lines ) >= $max_lines ) { + break; + } + } + + if ( $line && count( $lines ) < $max_lines ) { + $lines[] = $line; + } + + return $lines; + } + + /** + * Truncate text to a maximum character length. + * + * @param string $text Input text. + * @param int $length Maximum length. + * @return string + */ + protected static function truncate_text( $text, $length ) { + if ( strlen( $text ) <= $length ) { + return $text; + } + + return rtrim( substr( $text, 0, $length - 1 ) ) . '…'; + } +} diff --git a/wordpress.org/public_html/wp-content/plugins/plugin-directory/class-template.php b/wordpress.org/public_html/wp-content/plugins/plugin-directory/class-template.php index 194f25dc61..0e17ec90f5 100644 --- a/wordpress.org/public_html/wp-content/plugins/plugin-directory/class-template.php +++ b/wordpress.org/public_html/wp-content/plugins/plugin-directory/class-template.php @@ -482,6 +482,16 @@ public static function get_geopattern_icon_url( $post = null, $color = null ) { return $url; } + /** + * Retrieve the dynamic share image URL for a plugin. + * + * @param int|\WP_Post|null $post Optional. Post ID or post object. + * @return string|false + */ + public static function get_share_image_url( $post = null ) { + return Plugin_Share_Image::get_url( $post ); + } + /** * Retrieve the Plugin banner details for a plugin. * diff --git a/wordpress.org/public_html/wp-content/plugins/plugin-directory/docs/share-image-sample-gutenberg.png b/wordpress.org/public_html/wp-content/plugins/plugin-directory/docs/share-image-sample-gutenberg.png new file mode 100644 index 0000000000000000000000000000000000000000..c8c59a513e093707fa27e5317289fd4748c8750d GIT binary patch literal 47207 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 literal 0 HcmV?d00001 diff --git a/wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Plugin_Share_Image_Layout_Test.php b/wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Plugin_Share_Image_Layout_Test.php new file mode 100644 index 0000000000..adfc482d7c --- /dev/null +++ b/wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Plugin_Share_Image_Layout_Test.php @@ -0,0 +1,70 @@ +assertSame( 1200, Plugin_Share_Image_Layout::CANVAS_WIDTH ); + $this->assertSame( 630, Plugin_Share_Image_Layout::CANVAS_HEIGHT ); + $this->assertSame( 4, Plugin_Share_Image_Layout::STAT_COLUMNS ); + } + + public function test_resolve_returns_expected_zones() { + $layout = Plugin_Share_Image_Layout::resolve(); + + $this->assertSame( 1200, $layout['canvas']['width'] ); + $this->assertSame( 630, $layout['canvas']['height'] ); + $this->assertSame( 72, $layout['zones']['content']['x'] ); + $this->assertSame( 128, $layout['zones']['plugin_icon']['size'] ); + $this->assertSame( 26, $layout['type']['stat']['icon_size'] ); + $this->assertSame( 26, $layout['type']['stat']['value_size'] ); + $this->assertSame( 32, $layout['type']['stat']['icon_slot'] ); + $this->assertSame( 8, $layout['type']['stat']['icon_gap'] ); + } + + public function test_content_width_avoids_plugin_icon() { + $layout = Plugin_Share_Image_Layout::resolve(); + + $icon_zone = $layout['zones']['plugin_icon']; + $content_zone = $layout['zones']['content']; + + $this->assertLessThan( + $icon_zone['x'], + $content_zone['x'] + $content_zone['width'] + ); + } + + public function test_stat_columns_are_equal_width() { + $layout = Plugin_Share_Image_Layout::resolve(); + $stats = $layout['zones']['stats']; + + $first_x = Plugin_Share_Image_Layout::stat_column_x( $layout, 0 ); + $second_x = Plugin_Share_Image_Layout::stat_column_x( $layout, 1 ); + $third_x = Plugin_Share_Image_Layout::stat_column_x( $layout, 2 ); + $fourth_x = Plugin_Share_Image_Layout::stat_column_x( $layout, 3 ); + + $this->assertSame( $stats['x'], $first_x ); + $this->assertSame( $first_x + $stats['column_width'], $second_x ); + $this->assertSame( $second_x + $stats['column_width'], $third_x ); + $this->assertSame( $third_x + $stats['column_width'], $fourth_x ); + } + + public function test_branding_center_y_aligns_with_stat_value_row() { + $layout = Plugin_Share_Image_Layout::resolve(); + + $this->assertSame( + $layout['zones']['stats']['value_y'] - 18, + $layout['zones']['branding']['center_y'] + ); + } +} diff --git a/wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Plugin_Share_Image_Test.php b/wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Plugin_Share_Image_Test.php new file mode 100644 index 0000000000..182451b8bb --- /dev/null +++ b/wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Plugin_Share_Image_Test.php @@ -0,0 +1,59 @@ +setAccessible( true ); + + return $reflection->invoke( null, $active_installs ); + } + + public function test_install_stat_item_under_ten() { + $stat = $this->get_install_stat_item( 0 ); + + $this->assertSame( 'download', $stat['icon'] ); + $this->assertSame( '<10', $stat['value'] ); + $this->assertSame( 'Installs', $stat['label'] ); + } + + public function test_install_stat_item_hundreds() { + $stat = $this->get_install_stat_item( 500 ); + + $this->assertSame( 'download', $stat['icon'] ); + $this->assertSame( '500+', $stat['value'] ); + } + + public function test_install_stat_item_fifty_thousand_uses_formatted_display() { + $stat = $this->get_install_stat_item( 50000 ); + + $this->assertSame( '50,000+', $stat['value'] ); + } + + public function test_install_stat_item_hundred_thousands() { + $stat = $this->get_install_stat_item( 150000 ); + + $this->assertSame( '150K+', $stat['value'] ); + } + + public function test_install_stat_item_millions() { + $stat = $this->get_install_stat_item( 2500000 ); + + $this->assertSame( '2M+', $stat['value'] ); + } +} diff --git a/wordpress.org/public_html/wp-content/themes/pub/wporg-plugins-2024/functions.php b/wordpress.org/public_html/wp-content/themes/pub/wporg-plugins-2024/functions.php index e798728a31..30805d0ea8 100644 --- a/wordpress.org/public_html/wp-content/themes/pub/wporg-plugins-2024/functions.php +++ b/wordpress.org/public_html/wp-content/themes/pub/wporg-plugins-2024/functions.php @@ -337,10 +337,13 @@ function social_meta_data() { printf( '' . "\n" ); printf( '' . "\n" ); - if ( $banner['banner_2x'] ) { + $share_image = Template::get_share_image_url(); + if ( $share_image ) { + printf( '' . "\n", esc_url( $share_image ) ); + printf( '' . "\n", esc_url( $share_image ) ); + } elseif ( $banner['banner_2x'] ) { printf( '' . "\n", esc_url( $banner['banner_2x'] ) ); - } - if ( $banner['banner'] ) { + } elseif ( $banner['banner'] ) { printf( '' . "\n", esc_url( $banner['banner'] ) ); } if ( ! $icon['generated'] && ( $icon['icon_2x'] || $icon['icon'] ) ) { From 41fd3c566607a8e02e4de29e601cdf9124bda8e7 Mon Sep 17 00:00:00 2001 From: Matt Cromwell Date: Wed, 8 Jul 2026 23:44:25 +0200 Subject: [PATCH 2/2] Declare public visibility on share_image_route for PHPCS. --- .../plugins/plugin-directory/class-plugin-directory.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/wordpress.org/public_html/wp-content/plugins/plugin-directory/class-plugin-directory.php b/wordpress.org/public_html/wp-content/plugins/plugin-directory/class-plugin-directory.php index 19377576cf..b88c4ca7ec 100644 --- a/wordpress.org/public_html/wp-content/plugins/plugin-directory/class-plugin-directory.php +++ b/wordpress.org/public_html/wp-content/plugins/plugin-directory/class-plugin-directory.php @@ -1519,7 +1519,7 @@ function geopattern_icon_route() { /** * Output a JPEG share image for a given plugin. */ - function share_image_route() { + public function share_image_route() { $slug = get_query_var( 'plugin_share_image' ); if ( ! $slug ) {