diff --git a/environments/plugin-directory/.wp-env.json b/environments/plugin-directory/.wp-env.json index 56e763990b..207483fd78 100644 --- a/environments/plugin-directory/.wp-env.json +++ b/environments/plugin-directory/.wp-env.json @@ -15,7 +15,8 @@ "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" diff --git a/environments/plugin-directory/.wp-env.test.json b/environments/plugin-directory/.wp-env.test.json index 7964656076..5dd18ebd53 100644 --- a/environments/plugin-directory/.wp-env.test.json +++ b/environments/plugin-directory/.wp-env.test.json @@ -6,6 +6,7 @@ "../wordpress.org/public_html/wp-content/plugins/plugin-directory" ], "mappings": { + "wp-content/mu-plugins/wporg-mu-plugins": "WordPress/wporg-mu-plugins#build", "wp-content/env-bin": "./plugin-directory/bin" }, "lifecycleScripts": { diff --git a/wordpress.org/public_html/wp-content/plugins/plugin-directory/api/routes/class-plugin.php b/wordpress.org/public_html/wp-content/plugins/plugin-directory/api/routes/class-plugin.php index 8014357ea5..61a9dc27f0 100644 --- a/wordpress.org/public_html/wp-content/plugins/plugin-directory/api/routes/class-plugin.php +++ b/wordpress.org/public_html/wp-content/plugins/plugin-directory/api/routes/class-plugin.php @@ -116,27 +116,7 @@ public function plugin_info_data( $request, $post ) { $result['author_profile'] = $profile_url; $result['contributors'] = array(); - $contributors = get_terms( array( - 'taxonomy' => 'plugin_contributors', - 'object_ids' => array( $post->ID ), - 'orderby' => 'term_order', - 'fields' => 'names', - ) ); - - if ( is_wp_error( $contributors ) ) { - $contributors = array(); - } - - if ( ! $contributors ) { - $contributors = [ $author->user_nicename ]; - } - - foreach ( $contributors as $contributor ) { - $user = get_user_by( 'slug', $contributor ); - if ( ! $user ) { - continue; - } - + foreach ( Template::get_plugin_contributors( $post ) as $user ) { $result['contributors'][ $user->user_nicename ] = array( 'profile' => $this->get_user_profile_link( $user ), 'avatar' => get_avatar_url( $user, array( 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 d1969f4c15..49feb97ba0 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' ); + // Share images. share-image/demo.jpg | share-image/demo_a1b2c3d4.jpg (token is a cache buster). + add_rewrite_rule( '^share-image/([^/_]+)(_([a-f0-9]{8}))?\.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,30 @@ function geopattern_icon_route() { die(); } + /** + * Output a JPEG share image for a given plugin. + */ + public function share_image_route() { + $slug = get_query_var( 'plugin_share_image' ); + + if ( ! $slug ) { + return; + } + + $plugin = self::get_plugin_post( $slug ); + if ( ! $plugin || 'publish' !== $plugin->post_status ) { + status_header( 404 ); + die(); + } + + if ( ! Plugin_Share_Image::output( $plugin ) ) { + status_header( 500 ); + die(); + } + + die(); + } + /** * The array of post types to be included in the sitemap. * @@ -1543,6 +1574,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.php b/wordpress.org/public_html/wp-content/plugins/plugin-directory/class-plugin-share-image.php new file mode 100644 index 0000000000..0d7a14cf86 --- /dev/null +++ b/wordpress.org/public_html/wp-content/plugins/plugin-directory/class-plugin-share-image.php @@ -0,0 +1,590 @@ +post_type || 'publish' !== $plugin->post_status ) { + return false; + } + + if ( ! self::can_render() ) { + return false; + } + + return home_url( '/share-image/' . $plugin->post_name . '_' . self::cache_token( $plugin ) . '.jpg' ); + } + + /** + * Collect the fields drawn onto the card. + * + * @param \WP_Post $plugin Plugin post object. + * @return array{title: string, description: string, icon_url: string, stats: 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 ( ! empty( $icons['generated'] ) || str_ends_with( (string) $icon, '.svg' ) ) { + $icon = ''; + } + + return array( + 'title' => get_the_title( $plugin ), + 'description' => wp_strip_all_tags( get_the_excerpt( $plugin ) ), + 'icon_url' => $icon, + 'stats' => self::stats( $plugin ), + ); + } + + /** + * 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 ) { + $result = self::paint( $plugin ); + + return $result ? $result['bytes'] : false; + } + + /** + * 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 ) { + $result = self::paint( $plugin ); + + if ( ! $result ) { + return false; + } + + status_header( 200 ); + header( 'Content-Type: image/jpeg' ); + header( 'Cache-Control: public, max-age=' . $result['max_age'] ); + header( 'Expires: ' . gmdate( 'D, d M Y H:i:s \G\M\T', time() + $result['max_age'] ) ); + echo $result['bytes']; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped + + return true; + } + + /** + * Cache-buster embedded in the URL (slug + token, same idea as geopattern). + * + * @param \WP_Post $plugin Plugin post object. + * @return string + */ + protected static function cache_token( $plugin ) { + $last_updated = get_post_meta( $plugin->ID, 'last_updated', true ) ?: $plugin->post_modified_gmt; + + return substr( + md5( + implode( + '|', + array( + self::LAYOUT, + $last_updated, + (int) get_post_meta( $plugin->ID, 'active_installs', true ), + (string) self::rating( $plugin ), + get_locale(), + ) + ) + ), + 0, + 8 + ); + } + + /** + * Paint the card and return bytes plus cache TTL. + * + * A missing icon is fine and stays year-cached. A *failed* icon fetch is + * not: that JPEG would otherwise pin an icon-less card at CDNs for a year. + * + * @param \WP_Post $plugin Plugin post object. + * @return array{bytes: string, max_age: int}|false + */ + protected static function paint( $plugin ) { + if ( ! self::can_render() ) { + return false; + } + + $data = self::get_data( $plugin ); + if ( ! $data ) { + return false; + } + + $image = imagecreatetruecolor( self::WIDTH, self::HEIGHT ); + if ( ! $image ) { + return false; + } + + $font = self::font_path(); + if ( ! $font ) { + imagedestroy( $image ); + return false; + } + + $margin = 72; + $icon_size = 128; + $footer_h = 16; + $icon_x = self::WIDTH - $margin - $icon_size; + $content_width = $icon_x - 48 - $margin; + $title_y = $margin + 44; + $stats_value_y = self::HEIGHT - $footer_h - 78; + $stats_label_y = self::HEIGHT - $footer_h - 38; + $column_width = (int) floor( ( self::WIDTH - ( 2 * $margin ) - 108 ) / 4 ); + + $white = imagecolorallocate( $image, 255, 255, 255 ); + $dark = imagecolorallocate( $image, 30, 30, 30 ); + $muted = imagecolorallocate( $image, 80, 87, 94 ); + $value = imagecolorallocate( $image, 50, 55, 60 ); + $label = imagecolorallocate( $image, 113, 116, 127 ); + $surface = imagecolorallocate( $image, 246, 247, 247 ); + + imagefilledrectangle( $image, 0, 0, self::WIDTH, self::HEIGHT, $white ); + self::draw_footer( $image, $footer_h ); + + $title_lines = self::wrap_text( $data['title'], $content_width, 40, $font, 2 ); + self::draw_lines( $image, $title_lines, $margin, $title_y, 40, 52, $font, $dark, $content_width, true ); + + $desc_y = $title_y + ( count( $title_lines ) * 52 ) + 20; + $desc_lines = self::wrap_text( $data['description'], $content_width, 22, $font, 3 ); + self::draw_lines( $image, $desc_lines, $margin, $desc_y, 22, 34, $font, $muted, $content_width ); + + foreach ( $data['stats'] as $index => $stat ) { + if ( $index >= 4 ) { + break; + } + $x = $margin + ( $index * $column_width ); + $cell = max( 0, $column_width - 16 ); + $value_text = self::truncate_text( $stat['value'], $cell, 26, $font ); + $label_text = self::truncate_text( $stat['label'], $cell, 14, $font ); + self::draw_string( $image, $value_text, $x, $stats_value_y, 26, $font, $value, true ); + self::draw_string( $image, $label_text, $x, $stats_label_y, 14, $font, $label ); + } + + $icon_ok = self::draw_icon( $image, $data['icon_url'], $icon_x, $margin, $icon_size, $surface ); + self::draw_logo( $image, $stats_value_y - 18, $margin ); + + ob_start(); + imagejpeg( $image, null, 88 ); + $bytes = ob_get_clean(); + imagedestroy( $image ); + + if ( ! $bytes ) { + return false; + } + + return array( + 'bytes' => $bytes, + 'max_age' => ( $data['icon_url'] && ! $icon_ok ) ? 5 * MINUTE_IN_SECONDS : YEAR_IN_SECONDS, + ); + } + + /** + * Stat cells for the footer row, using directory helpers. + * + * @param \WP_Post $plugin Plugin post object. + * @return array + */ + protected static function stats( $plugin ) { + $items = array(); + + $contributors = count( Template::get_plugin_contributors( $plugin ) ); + $items[] = array( + 'value' => number_format_i18n( $contributors ), + 'label' => _n( 'Contributor', 'Contributors', $contributors, 'wporg-plugins' ), + ); + + $locales = Template::count_plugin_locales( $plugin ); + if ( $locales > 0 ) { + $items[] = array( + 'value' => number_format_i18n( $locales ), + 'label' => _n( 'Locale', 'Locales', $locales, 'wporg-plugins' ), + ); + } + + $rating = self::rating( $plugin ); + if ( $rating > 0 ) { + $items[] = array( + 'value' => number_format_i18n( $rating, 1 ), + 'label' => __( 'Rating', 'wporg-plugins' ), + ); + } + + $installs = (int) get_post_meta( $plugin->ID, 'active_installs', true ); + $items[] = array( + 'value' => Template::format_active_installs_for_display( $installs ), + 'label' => __( 'Installs', 'wporg-plugins' ), + ); + + return $items; + } + + /** + * Average rating on the 0–5 scale used by the directory. + * + * @param \WP_Post $plugin Plugin post object. + * @return float + */ + protected static function rating( $plugin ) { + if ( class_exists( '\WPORG_Ratings' ) ) { + $rating = \WPORG_Ratings::get_avg_rating( 'plugin', $plugin->post_name ); + return $rating ? (float) $rating : 0.0; + } + + $rating = get_post_meta( $plugin->ID, 'rating', true ); + return $rating ? (float) $rating : 0.0; + } + + /** + * Draw a left-to-right midnight gradient footer. + * + * GD's resample of a 2×1 strip uses nearest-neighbour here, which produced + * a hard split. Blend on a 1px-high strip, then stretch it vertically. + * + * @param \GdImage $image Destination image. + * @param int $footer_h Footer height in pixels. + */ + protected static function draw_footer( $image, $footer_h ) { + $strip = imagecreatetruecolor( self::WIDTH, 1 ); + if ( ! $strip ) { + return; + } + + $start = array( 2, 3, 129 ); + $end = array( 40, 116, 252 ); + $span = self::WIDTH - 1; + + for ( $x = 0; $x < self::WIDTH; $x++ ) { + $t = $x / $span; + imagesetpixel( + $strip, + $x, + 0, + imagecolorallocate( + $strip, + (int) round( $start[0] + ( ( $end[0] - $start[0] ) * $t ) ), + (int) round( $start[1] + ( ( $end[1] - $start[1] ) * $t ) ), + (int) round( $start[2] + ( ( $end[2] - $start[2] ) * $t ) ) + ) + ); + } + + imagecopyresampled( $image, $strip, 0, self::HEIGHT - $footer_h, 0, 0, self::WIDTH, $footer_h, self::WIDTH, 1 ); + imagedestroy( $strip ); + } + + /** + * Draw wrapped lines, truncating the last line to width. + * + * @param \GdImage $image Destination image. + * @param string[] $lines Pre-wrapped lines. + * @param int $x X position. + * @param int $baseline_y Starting baseline. + * @param int $size Font size. + * @param int $line_h Line height. + * @param string $font Font path. + * @param int $color Text color. + * @param int $max_width Content width for truncation. + * @param bool $bold Whether to simulate bold via a 1px double-draw. + */ + protected static function draw_lines( $image, $lines, $x, $baseline_y, $size, $line_h, $font, $color, $max_width, $bold = false ) { + if ( $lines ) { + $last = array_key_last( $lines ); + $lines[ $last ] = self::truncate_text( $lines[ $last ], $max_width, $size, $font ); + } + + foreach ( $lines as $line ) { + self::draw_string( $image, $line, $x, $baseline_y, $size, $font, $color, $bold ); + $baseline_y += $line_h; + } + } + + /** + * Draw a string. Inter ships as one TTF; a 1px double-draw approximates bold. + * + * @param \GdImage $image Destination image. + * @param string $text Text. + * @param int $x X position. + * @param int $y Baseline Y. + * @param int $size Font size. + * @param string $font Font path. + * @param int $color Text color. + * @param bool $bold Whether to simulate bold. + */ + protected static function draw_string( $image, $text, $x, $y, $size, $font, $color, $bold = false ) { + imagettftext( $image, $size, 0, $x, $y, $color, $font, $text ); + if ( $bold ) { + imagettftext( $image, $size, 0, $x + 1, $y, $color, $font, $text ); + } + } + + /** + * Draw a raster plugin icon. Returns false when a URL was given but fetch/decode failed. + * + * @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 $surface Background fill color. + * @return bool + */ + protected static function draw_icon( $image, $url, $x, $y, $size, $surface ) { + imagefilledrectangle( $image, $x, $y, $x + $size, $y + $size, $surface ); + + if ( ! $url || str_ends_with( $url, '.svg' ) ) { + return true; + } + + $host = wp_parse_url( $url, PHP_URL_HOST ); + if ( ! in_array( $host, array( 'ps.w.org', 'plugins.svn.wordpress.org' ), true ) ) { + return true; + } + + $response = wp_remote_get( + $url, + array( + 'timeout' => 5, + 'limit_response_size' => MB_IN_BYTES, + ) + ); + if ( is_wp_error( $response ) || 200 !== (int) wp_remote_retrieve_response_code( $response ) ) { + return false; + } + + $body = wp_remote_retrieve_body( $response ); + if ( ! $body ) { + return false; + } + + $icon = @imagecreatefromstring( $body ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged + if ( ! $icon ) { + return false; + } + + imagecopyresampled( $image, $icon, $x, $y, 0, 0, $size, $size, imagesx( $icon ), imagesy( $icon ) ); + imagedestroy( $icon ); + + return true; + } + + /** + * Draw the WordPress logotype, aligned with the stats value row. + * + * @param \GdImage $image Destination image. + * @param int $center_y Vertical center for the logo. + * @param int $margin Right margin. + */ + protected static function draw_logo( $image, $center_y, $margin ) { + $path = dirname( __DIR__, 3 ) . '/style/images/about/WordPress-logotype-simplified.png'; + if ( ! file_exists( $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 = 56; + $target_w = (int) round( $src_w * ( $target_h / $src_h ) ); + $x = self::WIDTH - $margin - $target_w; + $y = $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 Inter font shipped with wporg-mu-plugins. + * + * @return string|false + */ + protected static function font_path() { + if ( ! defined( 'WP_CONTENT_DIR' ) ) { + return false; + } + + $path = WP_CONTENT_DIR . '/mu-plugins/wporg-mu-plugins/fonts/Inter.ttf'; + + return file_exists( $path ) ? $path : false; + } + + /** + * Measure rendered text width in pixels. + * + * @param string $text Text. + * @param int $size Font size in points. + * @param string $font Font path. + * @return int + */ + protected static function text_width( $text, $size, $font ) { + $box = imagettfbbox( $size, 0, $font, $text ); + if ( false === $box ) { + return '' === $text ? 0 : PHP_INT_MAX; + } + + return abs( $box[2] - $box[0] ); + } + + /** + * Truncate text to fit within a pixel width. + * + * Character-based shortening; byte-based substr() can split a multibyte + * character and imagettfbbox() fatals on invalid UTF-8. + * + * @param string $text Input text. + * @param int $max_width Maximum width in pixels. + * @param int $size Font size in points. + * @param string $font Font path. + * @return string + */ + protected static function truncate_text( $text, $max_width, $size, $font ) { + if ( self::text_width( $text, $size, $font ) <= $max_width ) { + return $text; + } + + $ellipsis = '…'; + $length = mb_strlen( $text ); + + while ( $length > 0 ) { + $candidate = rtrim( mb_substr( $text, 0, $length ) ) . $ellipsis; + if ( self::text_width( $candidate, $size, $font ) <= $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 $size Font size in points. + * @param string $font Font path. + * @param int $max_lines Maximum number of lines. + * @return string[] + */ + protected static function wrap_text( $text, $max_width, $size, $font, $max_lines = 3 ) { + $text = trim( (string) $text ); + if ( '' === $text ) { + return array(); + } + + $words = preg_split( '/\s+/u', $text ); + if ( ! $words ) { + return array(); + } + + $lines = array(); + $line = ''; + $overflow = false; + + foreach ( $words as $word ) { + $candidate = $line ? $line . ' ' . $word : $word; + + if ( self::text_width( $candidate, $size, $font ) > $max_width ) { + if ( $line ) { + $lines[] = $line; + $line = $word; + } else { + $lines[] = self::truncate_text( $word, $max_width, $size, $font ); + $line = ''; + } + } else { + $line = $candidate; + } + + if ( count( $lines ) >= $max_lines ) { + $overflow = true; + break; + } + } + + if ( $line && count( $lines ) < $max_lines ) { + $lines[] = $line; + } elseif ( $line ) { + $overflow = true; + } + + if ( $overflow && $lines ) { + $last = array_key_last( $lines ); + $lines[ $last ] = self::truncate_text( rtrim( $lines[ $last ] ) . '…', $max_width, $size, $font ); + } + + return $lines; + } +} 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 afd8bbf564..3cd69325b6 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 @@ -489,6 +489,101 @@ 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 ); + } + + /** + * Plugin contributors, owner first, excluding nicenames that no longer resolve. + * + * Shared by the Contributors widget and the share-image renderer so the + * count on the card matches the sidebar. + * + * @param int|\WP_Post|null $post Optional. Post ID or post object. Defaults to global $post. + * @return \WP_User[] + */ + public static function get_plugin_contributors( $post = null ) { + $post = get_post( $post ); + if ( ! $post ) { + return array(); + } + + $contributors = get_terms( + array( + 'taxonomy' => 'plugin_contributors', + 'object_ids' => array( $post->ID ), + 'orderby' => 'term_order', + 'fields' => 'names', + ) + ); + + if ( is_wp_error( $contributors ) ) { + $contributors = array(); + } + + $plugin_owner = get_the_author_meta( 'user_nicename', $post->post_author ); + if ( $plugin_owner && 0 !== array_search( $plugin_owner, $contributors, true ) ) { + $contributors = array_unique( + array_merge( + array( $plugin_owner ), + $contributors + ) + ); + } + + $contributors = array_map( + function ( $user_nicename ) { + return get_user_by( 'slug', $user_nicename ); + }, + $contributors + ); + + return array_values( array_filter( $contributors ) ); + } + + /** + * Count locales with a public Rosetta site for this plugin's translations. + * + * Matches the Meta widget's `get_sites()` query. When the global network + * is available, a zero count is a real zero (variant-only packs), not a + * signal to fall back to the unfiltered language-pack list. + * + * @param int|\WP_Post|null $post Optional. Post ID or post object. Defaults to global $post. + * @return int + */ + public static function count_plugin_locales( $post = null ) { + $post = get_post( $post ); + if ( ! $post || ! defined( 'GLOTPRESS_LOCALES_PATH' ) ) { + return 0; + } + + $translations = Plugin_I18n::instance()->get_translations( $post->post_name ); + if ( empty( $translations ) ) { + return 0; + } + + if ( defined( 'WPORG_GLOBAL_NETWORK_ID' ) ) { + return (int) get_sites( + array( + 'network_id' => WPORG_GLOBAL_NETWORK_ID, + 'public' => 1, + 'path' => '/', + 'locale__in' => wp_list_pluck( $translations, 'wp_locale' ), + 'number' => '', + 'count' => true, + ) + ); + } + + return count( $translations ); + } + /** * Retrieve the Plugin banner details for a plugin. * 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..1b434389ab --- /dev/null +++ b/wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Plugin_Share_Image_Test.php @@ -0,0 +1,284 @@ + 'plugin', + 'post_status' => 'publish', + 'post_title' => 'Share Image Test Plugin', + 'post_name' => 'share-image-test-plugin-' . wp_generate_password( 6, false ), + 'post_excerpt' => 'A short description for the share image test.', + 'post_date' => $now, + 'post_date_gmt' => $now, + 'post_modified' => $now, + 'post_modified_gmt' => $now, + ), + $args + ), + true + ); + + $this->assertFalse( is_wp_error( $post_id ) ); + $this->assertIsInt( $post_id ); + + update_post_meta( $post_id, 'active_installs', $active_installs ); + update_post_meta( $post_id, 'last_updated', $now ); + + if ( null !== $assets_icons ) { + update_post_meta( $post_id, 'assets_icons', $assets_icons ); + } + + return get_post( $post_id ); + } + + /** + * Build a WP_Post that is not persisted, for negative get_data/get_url/render cases. + * + * @param string $post_type Post type. + * @param string $post_status Post status. + * @return \WP_Post + */ + protected function fake_post( $post_type, $post_status ) { + return new \WP_Post( + (object) array( + 'ID' => 0, + 'post_type' => $post_type, + 'post_status' => $post_status, + 'post_name' => 'fake-share-image-post', + 'post_title' => 'Fake', + 'filter' => 'raw', + ) + ); + } + + /** + * Require Inter so render tests fail loudly instead of skipping. + */ + protected function require_inter_font() { + $this->assertFileExists( + WP_CONTENT_DIR . '/mu-plugins/wporg-mu-plugins/fonts/Inter.ttf', + 'Inter.ttf is required to render share images; map wporg-mu-plugins fonts into this environment.' + ); + } + + /** + * Non-plugin posts must not produce share-image data. + */ + public function test_get_data_returns_null_for_non_plugin_post() { + $this->assertNull( Plugin_Share_Image::get_data( $this->fake_post( 'post', 'publish' ) ) ); + } + + /** + * Unpublished plugins must not produce share-image data. + */ + public function test_get_data_returns_null_for_unpublished_plugin() { + $this->assertNull( Plugin_Share_Image::get_data( $this->fake_post( 'plugin', 'draft' ) ) ); + } + + /** + * Closed and disabled plugins must not emit a share-image URL. + */ + public function test_get_url_returns_false_for_closed_plugin() { + $this->assertFalse( Plugin_Share_Image::get_url( $this->fake_post( 'plugin', 'closed' ) ) ); + $this->assertFalse( Plugin_Share_Image::get_url( $this->fake_post( 'plugin', 'disabled' ) ) ); + } + + /** + * Non-plugin posts must not emit a share-image URL. + */ + public function test_get_url_returns_false_for_non_plugin_post() { + $this->assertFalse( Plugin_Share_Image::get_url( $this->fake_post( 'post', 'publish' ) ) ); + } + + /** + * Published plugins get a versioned share-image URL. + */ + public function test_get_url_includes_cache_token_for_published_plugin() { + $this->require_inter_font(); + + $plugin = $this->create_plugin( array( 'active_installs' => 150000 ) ); + $url = Plugin_Share_Image::get_url( $plugin ); + + $this->assertNotFalse( $url ); + $this->assertMatchesRegularExpression( + '#/share-image/' . preg_quote( $plugin->post_name, '#' ) . '_[a-f0-9]{8}\.jpg$#', + $url + ); + } + + /** + * Changing installs must bust the share-image URL token. + */ + public function test_get_url_changes_when_install_count_changes() { + $this->require_inter_font(); + + $plugin = $this->create_plugin( array( 'active_installs' => 1000 ) ); + $first = Plugin_Share_Image::get_url( $plugin ); + + update_post_meta( $plugin->ID, 'active_installs', 2000000 ); + $second = Plugin_Share_Image::get_url( $plugin ); + + $this->assertNotFalse( $first ); + $this->assertNotFalse( $second ); + $this->assertNotSame( $first, $second ); + } + + /** + * A 1x raster icon is used when icon_2x is false. + */ + public function test_get_data_falls_back_to_1x_icon_when_2x_is_false() { + $plugin = $this->create_plugin( + array( + 'assets_icons' => array( + array( + 'filename' => 'icon-128x128.png', + 'revision' => 1, + 'resolution' => '128x128', + 'locale' => '', + ), + ), + ) + ); + + $icons = Template::get_plugin_icon( $plugin ); + $this->assertFalse( $icons['icon_2x'] ); + $this->assertNotEmpty( $icons['icon'] ); + + $data = Plugin_Share_Image::get_data( $plugin ); + $this->assertSame( $icons['icon'], $data['icon_url'] ); + } + + /** + * Install counts use the directory display formatting. + */ + public function test_get_data_uses_directory_install_formatting() { + $plugin = $this->create_plugin( array( 'active_installs' => 150000 ) ); + $data = Plugin_Share_Image::get_data( $plugin ); + $last = end( $data['stats'] ); + + $this->assertSame( Template::format_active_installs_for_display( 150000 ), $last['value'] ); + $this->assertSame( 'Installs', $last['label'] ); + } + + /** + * Contributor nicenames that do not resolve to a user are dropped. + */ + public function test_contributors_helper_drops_nicenames_that_do_not_resolve() { + $plugin = $this->create_plugin(); + wp_set_object_terms( $plugin->ID, array( 'not-a-real-wporg-user-xyz' ), 'plugin_contributors' ); + + $contributors = Template::get_plugin_contributors( $plugin ); + + foreach ( $contributors as $user ) { + $this->assertInstanceOf( \WP_User::class, $user ); + $this->assertNotSame( 'not-a-real-wporg-user-xyz', $user->user_nicename ); + } + } + + /** + * Plugins with no translations report zero locales. + */ + public function test_count_plugin_locales_returns_zero_without_translations() { + $plugin = $this->create_plugin(); + $this->assertSame( 0, Template::count_plugin_locales( $plugin ) ); + } + + /** + * Rendering a non-plugin post must fail. + */ + public function test_render_returns_false_for_non_plugin_post() { + $this->assertFalse( Plugin_Share_Image::render( $this->fake_post( 'post', 'publish' ) ) ); + } + + /** + * Rendering an unpublished plugin must fail. + */ + public function test_render_returns_false_for_unpublished_plugin() { + $this->assertFalse( Plugin_Share_Image::render( $this->fake_post( 'plugin', 'draft' ) ) ); + } + + /** + * A published plugin renders a 1200x630 JPEG. + */ + public function test_render_returns_jpeg_for_published_plugin() { + $this->require_inter_font(); + + $plugin = $this->create_plugin( array( 'active_installs' => 150000 ) ); + $bytes = Plugin_Share_Image::render( $plugin ); + + $this->assertNotFalse( $bytes ); + $this->assertNotEmpty( $bytes ); + + $info = getimagesizefromstring( $bytes ); + $this->assertIsArray( $info ); + $this->assertSame( 1200, $info[0] ); + $this->assertSame( 630, $info[1] ); + $this->assertSame( IMAGETYPE_JPEG, $info[2] ); + } + + /** + * An empty excerpt must still render a JPEG. + */ + public function test_render_handles_empty_excerpt() { + $this->require_inter_font(); + + $plugin = $this->create_plugin( + array( + 'post_excerpt' => '', + 'post_content' => '', + ) + ); + + $bytes = Plugin_Share_Image::render( $plugin ); + + $this->assertNotFalse( $bytes ); + $this->assertSame( IMAGETYPE_JPEG, getimagesizefromstring( $bytes )[2] ); + } + + /** + * Overlong multibyte titles must truncate without fatalling in GD. + */ + public function test_render_handles_multibyte_title_that_requires_truncation() { + $this->require_inter_font(); + + $plugin = $this->create_plugin( + array( + 'post_title' => str_repeat( 'Übérpluginäö', 20 ), + 'post_excerpt' => str_repeat( 'Ünïcödé ', 40 ), + ) + ); + + $bytes = Plugin_Share_Image::render( $plugin ); + + $this->assertNotFalse( $bytes ); + $this->assertSame( IMAGETYPE_JPEG, getimagesizefromstring( $bytes )[2] ); + } +} diff --git a/wordpress.org/public_html/wp-content/plugins/plugin-directory/widgets/class-contributors.php b/wordpress.org/public_html/wp-content/plugins/plugin-directory/widgets/class-contributors.php index f40165a67f..8c28b3a3df 100644 --- a/wordpress.org/public_html/wp-content/plugins/plugin-directory/widgets/class-contributors.php +++ b/wordpress.org/public_html/wp-content/plugins/plugin-directory/widgets/class-contributors.php @@ -1,6 +1,8 @@ 'plugin_contributors', - 'object_ids' => array( $post->ID ), - 'orderby' => 'term_order', - 'fields' => 'names', - ) ); - - if ( is_wp_error( $contributors ) ) { - $contributors = []; - } - - // The owner of the plugin is always a contributor, and shown first. - $plugin_owner = get_the_author_meta( 'user_nicename', $post->post_author ); - if ( $plugin_owner && 0 !== array_search( $plugin_owner, $contributors, true ) ) { - $contributors = array_unique( - array_merge( - [ $plugin_owner ], - $contributors - ) - ); - } - - // Convert the user_nicenames to user objects. - $contributors = array_map( - function( $user_nicename ) { - return get_user_by( 'slug', $user_nicename ); - }, - $contributors - ); - - // Remove any users that didn't exist. - $contributors = array_filter( $contributors ); + $contributors = Template::get_plugin_contributors(); $title = apply_filters( 'widget_title', empty( $instance['title'] ) ? __( 'Contributors', 'wporg-plugins' ) : $instance['title'], $instance, $this->id_base ); 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..55a26aa36c 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,11 +337,17 @@ function social_meta_data() { printf( '' . "\n" ); printf( '' . "\n" ); - if ( $banner['banner_2x'] ) { - printf( '' . "\n", esc_url( $banner['banner_2x'] ) ); - } - if ( $banner['banner'] ) { - printf( '' . "\n", esc_url( $banner['banner'] ) ); + $share_image = Template::get_share_image_url(); + if ( $share_image ) { + printf( '' . "\n", esc_url( $share_image ) ); + printf( '' . "\n", esc_url( $share_image ) ); + } else { + if ( $banner['banner_2x'] ) { + printf( '' . "\n", esc_url( $banner['banner_2x'] ) ); + } + if ( $banner['banner'] ) { + printf( '' . "\n", esc_url( $banner['banner'] ) ); + } } if ( ! $icon['generated'] && ( $icon['icon_2x'] || $icon['icon'] ) ) { printf( '' . "\n", esc_url( $icon['icon_2x'] ?: $icon['icon'] ) );