Skip to content

Conversation

@westonruter
Copy link
Member

@westonruter westonruter commented May 17, 2024

Previously: #1125

  • Bump stable tag in readme.txt
  • Bump versions in load.php
  • Run npm run since
  • Run npm run readme
  • Review changelogs for accuracy.
  • Review diff with previous plugin versions.
  • Test the builds.

Fixes #1212

@westonruter westonruter added the skip changelog PRs that should not be mentioned in changelogs label May 17, 2024
@westonruter westonruter added this to the performance-lab 3.1.0 milestone May 17, 2024
@westonruter westonruter changed the title Publish/3.1.0 Publish 3.1.0 release May 17, 2024
@westonruter westonruter added the [Type] Documentation Documentation to be added or enhanced label May 17, 2024
@westonruter
Copy link
Member Author

westonruter commented May 17, 2024

Diff overview via #1227:

auto-sizes

svn status:

M       auto-sizes.php
M       hooks.php
M       readme.txt
svn diff
Index: auto-sizes.php
===================================================================
--- auto-sizes.php	(revision 3088546)
+++ auto-sizes.php	(working copy)
@@ -4,8 +4,8 @@
  * Plugin URI: https://github.com/WordPress/performance/tree/trunk/plugins/auto-sizes
  * Description: Instructs browsers to automatically choose the right image size for lazy-loaded images.
  * Requires at least: 6.4
- * Requires PHP: 7.0
- * Version: 1.0.1
+ * Requires PHP: 7.2
+ * Version: 1.0.2
  * Author: WordPress Performance Team
  * Author URI: https://make.wordpress.org/performance/
  * License: GPLv2 or later
@@ -25,6 +25,6 @@
 	return;
 }
 
-define( 'IMAGE_AUTO_SIZES_VERSION', '1.0.1' );
+define( 'IMAGE_AUTO_SIZES_VERSION', '1.0.2' );
 
 require_once __DIR__ . '/hooks.php';
Index: hooks.php
===================================================================
--- hooks.php	(revision 3088546)
+++ hooks.php	(working copy)
@@ -15,10 +15,14 @@
  *
  * @since 1.0.0
  *
- * @param array $attr Attributes for the image markup.
- * @return array The filtered attributes for the image markup.
+ * @param array<string, string>|mixed $attr Attributes for the image markup.
+ * @return array<string, string> The filtered attributes for the image markup.
  */
-function auto_sizes_update_image_attributes( $attr ) {
+function auto_sizes_update_image_attributes( $attr ): array {
+	if ( ! is_array( $attr ) ) {
+		$attr = array();
+	}
+
 	// Bail early if the image is not lazy-loaded.
 	if ( ! isset( $attr['loading'] ) || 'lazy' !== $attr['loading'] ) {
 		return $attr;
@@ -30,7 +34,7 @@
 	}
 
 	// Don't add 'auto' to the sizes attribute if it already exists.
-	if ( false !== strpos( $attr['sizes'], 'auto,' ) ) {
+	if ( str_contains( $attr['sizes'], 'auto,' ) ) {
 		return $attr;
 	}
 
@@ -45,10 +49,14 @@
  *
  * @since 1.0.0
  *
- * @param string $html The HTML image tag markup being filtered.
+ * @param string|mixed $html The HTML image tag markup being filtered.
  * @return string The filtered HTML image tag markup.
  */
-function auto_sizes_update_content_img_tag( $html ) {
+function auto_sizes_update_content_img_tag( $html ): string {
+	if ( ! is_string( $html ) ) {
+		$html = '';
+	}
+
 	// Bail early if the image is not lazy-loaded.
 	if ( false === strpos( $html, 'loading="lazy"' ) ) {
 		return $html;
@@ -77,7 +85,7 @@
  *
  * @since 1.0.1
  */
-function auto_sizes_render_generator() {
+function auto_sizes_render_generator(): void {
 	// Use the plugin slug as it is immutable.
 	echo '<meta name="generator" content="auto-sizes ' . esc_attr( IMAGE_AUTO_SIZES_VERSION ) . '">' . "\n";
 }
Index: readme.txt
===================================================================
--- readme.txt	(revision 3088546)
+++ readme.txt	(working copy)
@@ -3,8 +3,8 @@
 Contributors:      wordpressdotorg
 Requires at least: 6.4
 Tested up to:      6.5
-Requires PHP:      7.0
-Stable tag:        1.0.1
+Requires PHP:      7.2
+Stable tag:        1.0.2
 License:           GPLv2 or later
 License URI:       https://www.gnu.org/licenses/gpl-2.0.html
 Tags:              performance, images, auto-sizes
@@ -48,6 +48,10 @@
 
 == Changelog ==
 
+= 1.0.2 =
+
+* Improve overall code quality with stricter static analysis checks. ([775](https://github.com/WordPress/performance/issues/775))
+
 = 1.0.1 =
 
 * Add auto-sizes generator tag. ([1105](https://github.com/WordPress/performance/pull/1105))

dominant-color-images

svn status:

M       class-dominant-color-image-editor-gd.php
M       class-dominant-color-image-editor-imagick.php
M       helper.php
M       hooks.php
M       load.php
M       readme.txt
svn diff
Index: class-dominant-color-image-editor-gd.php
===================================================================
--- class-dominant-color-image-editor-gd.php	(revision 3088546)
+++ class-dominant-color-image-editor-gd.php	(working copy)
@@ -32,9 +32,17 @@
 		}
 		// The logic here is resize the image to 1x1 pixel, then get the color of that pixel.
 		$shorted_image = imagecreatetruecolor( 1, 1 );
-		imagecopyresampled( $shorted_image, $this->image, 0, 0, 0, 0, 1, 1, imagesx( $this->image ), imagesy( $this->image ) );
+		$image_width   = imagesx( $this->image );
+		$image_height  = imagesy( $this->image );
+		if ( false === $shorted_image || false === $image_width || false === $image_height ) {
+			return new WP_Error( 'image_editor_dominant_color_error', __( 'Dominant color detection failed.', 'dominant-color-images' ) );
+		}
+		imagecopyresampled( $shorted_image, $this->image, 0, 0, 0, 0, 1, 1, $image_width, $image_height );
 
 		$rgb = imagecolorat( $shorted_image, 0, 0 );
+		if ( false === $rgb ) {
+			return new WP_Error( 'image_editor_dominant_color_error', __( 'Dominant color detection failed.', 'dominant-color-images' ) );
+		}
 		$r   = ( $rgb >> 16 ) & 0xFF;
 		$g   = ( $rgb >> 8 ) & 0xFF;
 		$b   = $rgb & 0xFF;
@@ -46,7 +54,6 @@
 		return $hex;
 	}
 
-
 	/**
 	 * Looks for transparent pixels in the image.
 	 * If there are none, it returns false.
@@ -66,8 +73,14 @@
 		$h = imagesy( $this->image );
 		for ( $x = 0; $x < $w; $x++ ) {
 			for ( $y = 0; $y < $h; $y++ ) {
-				$rgb  = imagecolorat( $this->image, $x, $y );
+				$rgb = imagecolorat( $this->image, $x, $y );
+				if ( false === $rgb ) {
+					return new WP_Error( 'unable_to_obtain_rgb_via_imagecolorat' );
+				}
 				$rgba = imagecolorsforindex( $this->image, $rgb );
+				if ( ! is_array( $rgba ) ) {
+					return new WP_Error( 'unable_to_obtain_rgba_via_imagecolorsforindex' );
+				}
 				if ( $rgba['alpha'] > 0 ) {
 					return true;
 				}
Index: class-dominant-color-image-editor-imagick.php
===================================================================
--- class-dominant-color-image-editor-imagick.php	(revision 3088546)
+++ class-dominant-color-image-editor-imagick.php	(working copy)
@@ -82,7 +82,7 @@
 			for ( $x = 0; $x < $w; $x++ ) {
 				for ( $y = 0; $y < $h; $y++ ) {
 					$pixel = $this->image->getImagePixelColor( $x, $y );
-					$color = $pixel->getColor();
+					$color = $pixel->getColor( 2 );
 					if ( $color['a'] > 0 ) {
 						return true;
 					}
Index: helper.php
===================================================================
--- helper.php	(revision 3088546)
+++ helper.php	(working copy)
@@ -15,7 +15,7 @@
  * @param string[] $editors Array of available image editor class names. Defaults are 'WP_Image_Editor_Imagick', 'WP_Image_Editor_GD'.
  * @return string[] Registered image editors class names.
  */
-function dominant_color_set_image_editors( $editors ) {
+function dominant_color_set_image_editors( array $editors ): array {
 	if ( ! class_exists( 'Dominant_Color_Image_Editor_GD' ) ) {
 		require_once __DIR__ . '/class-dominant-color-image-editor-gd.php';
 	}
@@ -24,8 +24,8 @@
 	}
 
 	$replaces = array(
-		'WP_Image_Editor_GD'      => 'Dominant_Color_Image_Editor_GD',
-		'WP_Image_Editor_Imagick' => 'Dominant_Color_Image_Editor_Imagick',
+		WP_Image_Editor_GD::class      => Dominant_Color_Image_Editor_GD::class,
+		WP_Image_Editor_Imagick::class => Dominant_Color_Image_Editor_Imagick::class,
 	);
 
 	foreach ( $replaces as $old => $new ) {
@@ -46,9 +46,9 @@
  * @access private
  *
  * @param int $attachment_id The attachment ID.
- * @return array|WP_Error Array with the dominant color and has transparency values or WP_Error on error.
+ * @return array{ has_transparency?: bool, dominant_color?: string }|WP_Error Array with the dominant color and has transparency values or WP_Error on error.
  */
-function dominant_color_get_dominant_color_data( $attachment_id ) {
+function dominant_color_get_dominant_color_data( int $attachment_id ) {
 	$mime_type = get_post_mime_type( $attachment_id );
 	if ( 'application/pdf' === $mime_type ) {
 		return new WP_Error( 'no_image_found', __( 'Unable to load image.', 'dominant-color-images' ) );
@@ -57,7 +57,17 @@
 	if ( ! $file ) {
 		$file = get_attached_file( $attachment_id );
 	}
+	if ( ! $file ) {
+		return new WP_Error( 'no_image_found', __( 'Unable to load image.', 'dominant-color-images' ) );
+	}
 	add_filter( 'wp_image_editors', 'dominant_color_set_image_editors' );
+
+	/**
+	 * Editor.
+	 *
+	 * @see dominant_color_set_image_editors()
+	 * @var WP_Image_Editor|Dominant_Color_Image_Editor_GD|Dominant_Color_Image_Editor_Imagick|WP_Error $editor
+	 */
 	$editor = wp_get_image_editor(
 		$file,
 		array(
@@ -73,6 +83,10 @@
 		return $editor;
 	}
 
+	if ( ! ( $editor instanceof Dominant_Color_Image_Editor_GD || $editor instanceof Dominant_Color_Image_Editor_Imagick ) ) {
+		return new WP_Error( 'image_no_editor', __( 'No editor could be selected.', 'default' ) );
+	}
+
 	$has_transparency = $editor->has_transparency();
 	if ( is_wp_error( $has_transparency ) ) {
 		return $has_transparency;
@@ -97,7 +111,7 @@
  * @param string $size          Optional. Image size. Default 'medium'.
  * @return false|string Path to an image or false if not found.
  */
-function dominant_color_get_attachment_file_path( $attachment_id, $size = 'medium' ) {
+function dominant_color_get_attachment_file_path( int $attachment_id, string $size = 'medium' ) {
 	$imagedata = wp_get_attachment_metadata( $attachment_id );
 	if ( ! is_array( $imagedata ) ) {
 		return false;
@@ -108,6 +122,9 @@
 	}
 
 	$file = get_attached_file( $attachment_id );
+	if ( ! $file ) {
+		return false;
+	}
 
 	$filepath = str_replace( wp_basename( $file ), $imagedata['sizes'][ $size ]['file'], $file );
 
@@ -122,7 +139,7 @@
  * @param int $attachment_id Attachment ID for image.
  * @return string|null Hex value of dominant color or null if not set.
  */
-function dominant_color_get_dominant_color( $attachment_id ) {
+function dominant_color_get_dominant_color( int $attachment_id ): ?string {
 	if ( ! wp_attachment_is_image( $attachment_id ) ) {
 		return null;
 	}
@@ -146,7 +163,7 @@
  * @param int $attachment_id Attachment ID for image.
  * @return bool|null Whether the image has transparency, or null if not set.
  */
-function dominant_color_has_transparency( $attachment_id ) {
+function dominant_color_has_transparency( int $attachment_id ): ?bool {
 	$image_meta = wp_get_attachment_metadata( $attachment_id );
 	if ( ! is_array( $image_meta ) ) {
 		return null;
@@ -171,9 +188,12 @@
  *
  * @return string|null Hex color or null if error.
  */
-function dominant_color_rgb_to_hex( $red, $green, $blue ) {
-	$range = range( 0, 255 );
-	if ( ! in_array( $red, $range, true ) || ! in_array( $green, $range, true ) || ! in_array( $blue, $range, true ) ) {
+function dominant_color_rgb_to_hex( int $red, int $green, int $blue ): ?string {
+	if ( ! (
+		$red >= 0 && $red <= 255
+		&& $green >= 0 && $green <= 255
+		&& $blue >= 0 && $blue <= 255
+	) ) {
 		return null;
 	}
 
Index: hooks.php
===================================================================
--- hooks.php	(revision 3088546)
+++ hooks.php	(working copy)
@@ -16,11 +16,15 @@
  *
  * @since 1.0.0
  *
- * @param array $metadata      The attachment metadata.
- * @param int   $attachment_id The attachment ID.
- * @return array $metadata The attachment metadata.
+ * @param array|mixed $metadata      The attachment metadata.
+ * @param int         $attachment_id The attachment ID.
+ * @return array{ has_transparency?: bool, dominant_color?: string } $metadata The attachment metadata.
  */
-function dominant_color_metadata( $metadata, $attachment_id ) {
+function dominant_color_metadata( $metadata, int $attachment_id ): array {
+	if ( ! is_array( $metadata ) ) {
+		$metadata = array();
+	}
+
 	$dominant_color_data = dominant_color_get_dominant_color_data( $attachment_id );
 	if ( ! is_wp_error( $dominant_color_data ) ) {
 		if ( isset( $dominant_color_data['dominant_color'] ) ) {
@@ -41,11 +45,15 @@
  *
  * @since 1.0.0
  *
- * @param array  $attr       Attributes for the image markup.
- * @param object $attachment Image attachment post.
- * @return mixed $attr Attributes for the image markup.
+ * @param array|mixed $attr       Attributes for the image markup.
+ * @param WP_Post     $attachment Image attachment post.
+ * @return array{ 'data-has-transparency'?: string, class?: string, 'data-dominant-color'?: string, style?: string } Attributes for the image markup.
  */
-function dominant_color_update_attachment_image_attributes( $attr, $attachment ) {
+function dominant_color_update_attachment_image_attributes( $attr, WP_Post $attachment ): array {
+	if ( ! is_array( $attr ) ) {
+		$attr = array();
+	}
+
 	$image_meta = wp_get_attachment_metadata( $attachment->ID );
 	if ( ! is_array( $image_meta ) ) {
 		return $attr;
@@ -77,12 +85,15 @@
  *
  * @since 1.0.0
  *
- * @param string $filtered_image The filtered image.
- * @param string $context        The context of the image.
- * @param int    $attachment_id  The attachment ID.
+ * @param string|mixed $filtered_image The filtered image.
+ * @param string       $context        The context of the image.
+ * @param int          $attachment_id  The attachment ID.
  * @return string image tag
  */
-function dominant_color_img_tag_add_dominant_color( $filtered_image, $context, $attachment_id ) {
+function dominant_color_img_tag_add_dominant_color( $filtered_image, string $context, int $attachment_id ): string {
+	if ( ! is_string( $filtered_image ) ) {
+		$filtered_image = '';
+	}
 
 	// Only apply this in `the_content` for now, since otherwise it can result in duplicate runs due to a problem with full site editing logic.
 	if ( 'the_content' !== $context ) {
@@ -142,11 +153,11 @@
 		$extra_class  = $image_meta['has_transparency'] ? 'has-transparency' : 'not-transparent';
 	}
 
-	if ( ! empty( $data ) ) {
+	if ( $data ) {
 		$filtered_image = str_replace( '<img ', '<img ' . $data, $filtered_image );
 	}
 
-	if ( ! empty( $extra_class ) ) {
+	if ( $extra_class ) {
 		$filtered_image = str_replace( ' class="', ' class="' . $extra_class . ' ', $filtered_image );
 	}
 
@@ -159,7 +170,7 @@
  *
  * @since 1.0.0
  */
-function dominant_color_add_inline_style() {
+function dominant_color_add_inline_style(): void {
 	$handle = 'dominant-color-styles';
 	// PHPCS ignore reason: Version not used since this handle is only registered for adding an inline style.
 	// phpcs:ignore WordPress.WP.EnqueuedResourceParameters.MissingVersion
@@ -168,7 +179,7 @@
 	$custom_css = 'img[data-dominant-color]:not(.has-transparency) { background-color: var(--dominant-color); }';
 	wp_add_inline_style( $handle, $custom_css );
 }
-add_filter( 'wp_enqueue_scripts', 'dominant_color_add_inline_style' );
+add_action( 'wp_enqueue_scripts', 'dominant_color_add_inline_style' );
 
 /**
  * Displays the HTML generator tag for the Image Placeholders plugin.
@@ -177,7 +188,7 @@
  *
  * @since 1.0.0
  */
-function dominant_color_render_generator() {
+function dominant_color_render_generator(): void {
 	// Use the plugin slug as it is immutable.
 	echo '<meta name="generator" content="dominant-color-images ' . esc_attr( DOMINANT_COLOR_IMAGES_VERSION ) . '">' . "\n";
 }
Index: load.php
===================================================================
--- load.php	(revision 3088546)
+++ load.php	(working copy)
@@ -4,8 +4,8 @@
  * Plugin URI: https://github.com/WordPress/performance/tree/trunk/plugins/dominant-color-images
  * Description: Displays placeholders based on an image's dominant color while the image is loading.
  * Requires at least: 6.4
- * Requires PHP: 7.0
- * Version: 1.1.0
+ * Requires PHP: 7.2
+ * Version: 1.1.1
  * Author: WordPress Performance Team
  * Author URI: https://make.wordpress.org/performance/
  * License: GPLv2 or later
@@ -25,7 +25,7 @@
 	return;
 }
 
-define( 'DOMINANT_COLOR_IMAGES_VERSION', '1.1.0' );
+define( 'DOMINANT_COLOR_IMAGES_VERSION', '1.1.1' );
 
 require_once __DIR__ . '/helper.php';
 require_once __DIR__ . '/hooks.php';
Index: readme.txt
===================================================================
--- readme.txt	(revision 3088546)
+++ readme.txt	(working copy)
@@ -3,8 +3,8 @@
 Contributors:      wordpressdotorg
 Requires at least: 6.4
 Tested up to:      6.5
-Requires PHP:      7.0
-Stable tag:        1.1.0
+Requires PHP:      7.2
+Stable tag:        1.1.1
 License:           GPLv2 or later
 License URI:       https://www.gnu.org/licenses/gpl-2.0.html
 Tags:              performance, images, dominant color
@@ -49,6 +49,17 @@
 
 == Changelog ==
 
+= 1.1.1 =
+
+**Enhancements**
+
+* Avoid needless array allocation in rgb to hex conversion. ([1104](https://github.com/WordPress/performance/pull/1104))
+* Improve overall code quality with stricter static analysis checks. ([775](https://github.com/WordPress/performance/issues/775))
+
+**Bug Fixes**
+
+* Fix Imagick detecting partial transparency. ([1215](https://github.com/WordPress/performance/pull/1215))
+
 = 1.1.0 =
 
 * Rename plugin to "Image Placeholders". ([1101](https://github.com/WordPress/performance/pull/1101))

embed-optimizer

svn status:

M       hooks.php
M       load.php
M       readme.txt
svn diff
Index: hooks.php
===================================================================
--- hooks.php	(revision 3088546)
+++ hooks.php	(working copy)
@@ -19,7 +19,7 @@
  * @since 0.1.0
  *
  * @param string $html The oEmbed HTML.
- * @return string
+ * @return string Filtered oEmbed HTML.
  */
 function embed_optimizer_filter_oembed_html( string $html ): string {
 	$html_processor = new WP_HTML_Tag_Processor( $html );
@@ -75,6 +75,22 @@
 	if ( 1 === $iframe_count && $html_processor->has_bookmark( 'iframe' ) ) {
 		if ( $html_processor->seek( 'iframe' ) ) {
 			$html_processor->set_attribute( 'loading', 'lazy' );
+
+			// For post embeds, use visibility:hidden instead of clip since browsers will consistently load the
+			// lazy-loaded iframe (where Chromium is unreliably with clip) while at the same time improve accessibility
+			// by preventing links in the hidden iframe from receiving focus.
+			if ( $html_processor->has_class( 'wp-embedded-content' ) ) {
+				$style = $html_processor->get_attribute( 'style' );
+				if ( is_string( $style ) ) {
+					// WordPress core injects this clip CSS property:
+					// <https://github.com/WordPress/wordpress-develop/blob/6974b994de5/src/wp-includes/embed.php#L968>.
+					$style = str_replace( 'clip: rect(1px, 1px, 1px, 1px);', 'visibility: hidden;', $style );
+
+					// Note: wp-embed.js removes the style attribute entirely when the iframe is loaded:
+					// <https://github.com/WordPress/wordpress-develop/blob/6974b994d/src/js/_enqueues/wp/embed.js#L60>.
+					$html_processor->set_attribute( 'style', $style );
+				}
+			}
 		} else {
 			embed_optimizer_trigger_error( __FUNCTION__, esc_html__( 'Embed Optimizer unable to seek to iframe bookmark.', 'embed-optimizer' ) );
 		}
@@ -89,7 +105,7 @@
  *
  * @since 0.1.0
  */
-function embed_optimizer_lazy_load_scripts() {
+function embed_optimizer_lazy_load_scripts(): void {
 	$js = <<<JS
 		const lazyEmbedsScripts = document.querySelectorAll( 'script[type="application/vnd.embed-optimizer.javascript"]' );
 		const lazyEmbedScriptsByParents = new Map();
@@ -147,7 +163,7 @@
  * @param int    $error_level   Optional. The designated error type for this error.
  *                              Only works with E_USER family of constants. Default E_USER_NOTICE.
  */
-function embed_optimizer_trigger_error( string $function_name, string $message, int $error_level = E_USER_NOTICE ) {
+function embed_optimizer_trigger_error( string $function_name, string $message, int $error_level = E_USER_NOTICE ): void {
 	if ( ! function_exists( 'wp_trigger_error' ) ) {
 		return;
 	}
@@ -161,7 +177,7 @@
  *
  * @since 0.1.0
  */
-function embed_optimizer_render_generator() {
+function embed_optimizer_render_generator(): void {
 	// Use the plugin slug as it is immutable.
 	echo '<meta name="generator" content="embed-optimizer ' . esc_attr( EMBED_OPTIMIZER_VERSION ) . '">' . "\n";
 }
Index: load.php
===================================================================
--- load.php	(revision 3088546)
+++ load.php	(working copy)
@@ -4,8 +4,8 @@
  * Plugin URI: https://github.com/WordPress/performance/tree/trunk/plugins/embed-optimizer
  * Description: Optimizes the performance of embeds by lazy-loading iframes and scripts.
  * Requires at least: 6.4
- * Requires PHP: 7.0
- * Version: 0.1.1
+ * Requires PHP: 7.2
+ * Version: 0.1.2
  * Author: WordPress Performance Team
  * Author URI: https://make.wordpress.org/performance/
  * License: GPLv2 or later
@@ -20,7 +20,7 @@
 	exit;
 }
 
-define( 'EMBED_OPTIMIZER_VERSION', '0.1.1' );
+define( 'EMBED_OPTIMIZER_VERSION', '0.1.2' );
 
 // Load in the Embed Optimizer plugin hooks.
 require_once __DIR__ . '/hooks.php';
Index: readme.txt
===================================================================
--- readme.txt	(revision 3088546)
+++ readme.txt	(working copy)
@@ -3,8 +3,8 @@
 Contributors:      wordpressdotorg
 Requires at least: 6.4
 Tested up to:      6.5
-Requires PHP:      7.0
-Stable tag:        0.1.1
+Requires PHP:      7.2
+Stable tag:        0.1.2
 License:           GPLv2 or later
 License URI:       https://www.gnu.org/licenses/gpl-2.0.html
 Tags:              performance, embeds
@@ -49,6 +49,16 @@
 
 == Changelog ==
 
+= 0.1.2 =
+
+**Enhancements**
+
+* Improve overall code quality with stricter static analysis checks. ([775](https://github.com/WordPress/performance/issues/775))
+
+**Bug Fixes**
+
+* Hide post embed iframes with visibility:hidden instead of clipping. ([1192](https://github.com/WordPress/performance/pull/1192))
+
 = 0.1.1 =
 
 * Use plugin slug for generator tag. ([1103](https://github.com/WordPress/performance/pull/1103))

optimization-detective

svn status:

M       class-od-html-tag-processor.php
M       class-od-html-tag-walker.php
M       class-od-url-metric.php
M       class-od-url-metrics-group-collection.php
M       class-od-url-metrics-group.php
M       helper.php
M       load.php
M       optimization.php
M       readme.txt
M       storage/class-od-storage-lock.php
M       storage/class-od-url-metrics-post-type.php
M       storage/data.php
M       storage/rest-api.php
M       uninstall.php
svn diff
Index: class-od-html-tag-processor.php
===================================================================
--- class-od-html-tag-processor.php	(revision 3088546)
+++ class-od-html-tag-processor.php	(working copy)
@@ -33,7 +33,7 @@
 	 *
 	 * @param string $html HTML to process.
 	 */
-	public function __construct( $html ) {
+	public function __construct( string $html ) {
 		$this->old_text_replacement_signature_needed = version_compare( get_bloginfo( 'version' ), '6.5', '<' );
 		parent::__construct( $html );
 	}
Index: class-od-html-tag-walker.php
===================================================================
--- class-od-html-tag-walker.php	(revision 3088546)
+++ class-od-html-tag-walker.php	(working copy)
@@ -212,6 +212,9 @@
 		$this->open_stack_indices = array();
 		while ( $p->next_tag( array( 'tag_closers' => 'visit' ) ) ) {
 			$tag_name = $p->get_tag();
+			if ( ! is_string( $tag_name ) ) {
+				continue;
+			}
 			if ( ! $p->is_tag_closer() ) {
 
 				// Close an open P tag when a P-closing tag is encountered.
@@ -220,7 +223,7 @@
 				if ( in_array( $tag_name, self::P_CLOSING_TAGS, true ) ) {
 					$i = array_search( 'P', $this->open_stack_tags, true );
 					if ( false !== $i ) {
-						array_splice( $this->open_stack_tags, $i );
+						array_splice( $this->open_stack_tags, (int) $i );
 						array_splice( $this->open_stack_indices, count( $this->open_stack_tags ) );
 					}
 				}
@@ -287,7 +290,7 @@
 	 *
 	 * @param string $message Warning message.
 	 */
-	private function warn( string $message ) {
+	private function warn( string $message ): void {
 		wp_trigger_error(
 			__CLASS__ . '::open_tags',
 			esc_html( $message )
@@ -338,7 +341,7 @@
 	public function get_xpath(): string {
 		$xpath = '';
 		foreach ( $this->get_breadcrumbs() as list( $tag_name, $index ) ) {
-			$xpath .= sprintf( '/*[%d][self::%s]', $index, $tag_name );
+			$xpath .= sprintf( '/*[%d][self::%s]', $index + 1, $tag_name );
 		}
 		return $xpath;
 	}
Index: class-od-url-metric.php
===================================================================
--- class-od-url-metric.php	(revision 3088546)
+++ class-od-url-metric.php	(working copy)
@@ -25,7 +25,7 @@
  *                           }
  * @phpstan-type Data        array{
  *                               url: string,
- *                               timestamp: int,
+ *                               timestamp: float,
  *                               viewport: RectData,
  *                               elements: ElementData[]
  *                           }
@@ -45,22 +45,36 @@
 	/**
 	 * Constructor.
 	 *
-	 * @param array $data URL metric data.
+	 * @phpstan-param Data|array<string, mixed> $data Valid data or invalid data (in which case an exception is thrown).
 	 *
+	 * @param array<string, mixed> $data URL metric data.
+	 *
 	 * @throws OD_Data_Validation_Exception When the input is invalid.
 	 */
 	public function __construct( array $data ) {
+		$this->validate_data( $data );
+		$this->data = $data;
+	}
+
+	/**
+	 * Validate data.
+	 *
+	 * @phpstan-assert Data $data
+	 *
+	 * @param array<string, mixed> $data Data to validate.
+	 * @throws OD_Data_Validation_Exception When the input is invalid.
+	 */
+	private function validate_data( array $data ): void {
 		$valid = rest_validate_object_value_from_schema( $data, self::get_json_schema(), self::class );
 		if ( is_wp_error( $valid ) ) {
 			throw new OD_Data_Validation_Exception( esc_html( $valid->get_error_message() ) );
 		}
-		$this->data = $data;
 	}
 
 	/**
 	 * Gets JSON schema for URL Metric.
 	 *
-	 * @return array Schema.
+	 * @return array<string, mixed> Schema.
 	 */
 	public static function get_json_schema(): array {
 		$dom_rect_schema = array(
Index: class-od-url-metrics-group-collection.php
===================================================================
--- class-od-url-metrics-group-collection.php	(revision 3088546)
+++ class-od-url-metrics-group-collection.php	(working copy)
@@ -38,8 +38,13 @@
 	/**
 	 * Breakpoints in max widths.
 	 *
-	 * Valid values are from 1 to PHP_INT_MAX.
+	 * Valid values are from 1 to PHP_INT_MAX - 1. This is because:
 	 *
+	 * 1. It doesn't make sense for there to be a viewport width of zero, so the first breakpoint (max width) must be at least 1.
+	 * 2. After the last breakpoint, the final breakpoint group is set to be spanning one plus the last breakpoint max width up
+	 *    until PHP_INT_MAX. So a breakpoint cannot be PHP_INT_MAX because then the minimum viewport width for the final group
+	 *    would end up being larger than PHP_INT_MAX.
+	 *
 	 * @var int[]
 	 * @phpstan-var positive-int[]
 	 */
@@ -78,7 +83,7 @@
 		sort( $breakpoints );
 		$breakpoints = array_values( array_unique( $breakpoints, SORT_NUMERIC ) );
 		foreach ( $breakpoints as $breakpoint ) {
-			if ( $breakpoint <= 1 || PHP_INT_MAX === $breakpoint ) {
+			if ( ! is_int( $breakpoint ) || $breakpoint < 1 || PHP_INT_MAX === $breakpoint ) {
 				throw new InvalidArgumentException(
 					esc_html(
 						sprintf(
@@ -93,6 +98,11 @@
 				);
 			}
 		}
+		/**
+		 * Validated breakpoints.
+		 *
+		 * @var positive-int[] $breakpoints
+		 */
 		$this->breakpoints = $breakpoints;
 
 		// Set sample size.
@@ -133,6 +143,8 @@
 	/**
 	 * Create groups.
 	 *
+	 * @phpstan-return non-empty-array<OD_URL_Metrics_Group>
+	 *
 	 * @return OD_URL_Metrics_Group[] Groups.
 	 */
 	private function create_groups(): array {
@@ -155,7 +167,7 @@
 	 *
 	 * @param OD_URL_Metric $new_url_metric New URL metric.
 	 */
-	public function add_url_metric( OD_URL_Metric $new_url_metric ) {
+	public function add_url_metric( OD_URL_Metric $new_url_metric ): void {
 		foreach ( $this->groups as $group ) {
 			if ( $group->is_viewport_width_in_range( $new_url_metric->get_viewport_width() ) ) {
 				$group->add_url_metric( $new_url_metric );
Index: class-od-url-metrics-group.php
===================================================================
--- class-od-url-metrics-group.php	(revision 3088546)
+++ class-od-url-metrics-group.php	(working copy)
@@ -157,7 +157,7 @@
 	 *
 	 * @param OD_URL_Metric $url_metric URL metric.
 	 */
-	public function add_url_metric( OD_URL_Metric $url_metric ) {
+	public function add_url_metric( OD_URL_Metric $url_metric ): void {
 		if ( ! $this->is_viewport_width_in_range( $url_metric->get_viewport_width() ) ) {
 			throw new InvalidArgumentException(
 				esc_html__( 'URL metric is not in the viewport range for group.', 'optimization-detective' )
Index: helper.php
===================================================================
--- helper.php	(revision 3088546)
+++ helper.php	(working copy)
@@ -17,7 +17,7 @@
  *
  * @since 0.1.0
  */
-function od_render_generator_meta_tag() {
+function od_render_generator_meta_tag(): void {
 	// Use the plugin slug as it is immutable.
 	echo '<meta name="generator" content="optimization-detective ' . esc_attr( OPTIMIZATION_DETECTIVE_VERSION ) . '">' . "\n";
 }
Index: load.php
===================================================================
--- load.php	(revision 3088546)
+++ load.php	(working copy)
@@ -4,8 +4,8 @@
  * Plugin URI: https://github.com/WordPress/performance/issues/869
  * Description: Uses real user metrics to improve heuristics WordPress applies on the frontend to improve image loading priority.
  * Requires at least: 6.4
- * Requires PHP: 7.0
- * Version: 0.1.1
+ * Requires PHP: 7.2
+ * Version: 0.2.0
  * Author: WordPress Performance Team
  * Author URI: https://make.wordpress.org/performance/
  * License: GPLv2 or later
@@ -20,52 +20,102 @@
 	exit;
 }
 
-// Define the constant.
-if ( defined( 'OPTIMIZATION_DETECTIVE_VERSION' ) ) {
-	return;
-}
+(
+	/**
+	 * Register this copy of the plugin among other potential copies embedded in plugins or themes.
+	 *
+	 * @param string  $global_var_name Global variable name for storing the plugin pending loading.
+	 * @param string  $version         Version.
+	 * @param Closure $load            Callback that loads the plugin.
+	 */
+	static function ( string $global_var_name, string $version, Closure $load ): void {
+		if ( ! isset( $GLOBALS[ $global_var_name ] ) ) {
+			$bootstrap = static function () use ( $global_var_name ): void {
+				if (
+					isset( $GLOBALS[ $global_var_name ]['load'], $GLOBALS[ $global_var_name ]['version'] )
+					&&
+					$GLOBALS[ $global_var_name ]['load'] instanceof Closure
+					&&
+					is_string( $GLOBALS[ $global_var_name ]['version'] )
+				) {
+					call_user_func( $GLOBALS[ $global_var_name ]['load'], $GLOBALS[ $global_var_name ]['version'] );
+					unset( $GLOBALS[ $global_var_name ] );
+				}
+			};
 
-if (
-	( is_admin() || ( defined( 'WP_CLI' ) && WP_CLI ) ) &&
-	! file_exists( __DIR__ . '/build/web-vitals.asset.php' )
-) {
-	// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_trigger_error
-	trigger_error(
-		esc_html(
-			sprintf(
-				/* translators: 1: File path. 2: CLI command. */
-				'[Optimization Detective] ' . __( 'Unable to load %1$s. Please make sure you have run %2$s.', 'optimization-detective' ),
-				'build/web-vitals.asset.php',
-				'`npm install && npm run build:optimization-detective`'
-			)
-		),
-		E_USER_ERROR
-	);
-}
+			// Wait until after the plugins have loaded and the theme has loaded. The after_setup_theme action is used
+			// because it is the first action that fires once the theme is loaded.
+			add_action( 'after_setup_theme', $bootstrap, PHP_INT_MIN );
+		}
 
-define( 'OPTIMIZATION_DETECTIVE_VERSION', '0.1.1' );
+		// Register this copy of the plugin.
+		if (
+			// Register this copy if none has been registered yet.
+			! isset( $GLOBALS[ $global_var_name ]['version'] )
+			||
+			// Or register this copy if the version greater than what is currently registered.
+			version_compare( $version, $GLOBALS[ $global_var_name ]['version'], '>' )
+			||
+			// Otherwise, register this copy if it is actually the one installed in the directory for plugins.
+			rtrim( WP_PLUGIN_DIR, '/' ) === dirname( __DIR__ )
+		) {
+			$GLOBALS[ $global_var_name ]['version'] = $version;
+			$GLOBALS[ $global_var_name ]['load']    = $load;
+		}
+	}
+)(
+	'optimization_detective_pending_plugin',
+	'0.2.0',
+	static function ( string $version ): void {
 
-require_once __DIR__ . '/helper.php';
+		// Define the constant.
+		if ( defined( 'OPTIMIZATION_DETECTIVE_VERSION' ) ) {
+			return;
+		}
 
-// Core infrastructure classes.
-require_once __DIR__ . '/class-od-data-validation-exception.php';
-require_once __DIR__ . '/class-od-html-tag-processor.php';
-require_once __DIR__ . '/class-od-url-metric.php';
-require_once __DIR__ . '/class-od-url-metrics-group.php';
-require_once __DIR__ . '/class-od-url-metrics-group-collection.php';
+		if (
+			( is_admin() || ( defined( 'WP_CLI' ) && WP_CLI ) ) &&
+			! file_exists( __DIR__ . '/build/web-vitals.asset.php' )
+		) {
+			// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_trigger_error
+			trigger_error(
+				esc_html(
+					sprintf(
+						/* translators: 1: File path. 2: CLI command. */
+						'[Optimization Detective] ' . __( 'Unable to load %1$s. Please make sure you have run %2$s.', 'optimization-detective' ),
+						'build/web-vitals.asset.php',
+						'`npm install && npm run build:optimization-detective`'
+					)
+				),
+				E_USER_ERROR
+			);
+		}
 
-// Storage logic.
-require_once __DIR__ . '/storage/class-od-url-metrics-post-type.php';
-require_once __DIR__ . '/storage/class-od-storage-lock.php';
-require_once __DIR__ . '/storage/data.php';
-require_once __DIR__ . '/storage/rest-api.php';
+		define( 'OPTIMIZATION_DETECTIVE_VERSION', $version );
 
-// Detection logic.
-require_once __DIR__ . '/detection.php';
+		require_once __DIR__ . '/helper.php';
 
-// Optimization logic.
-require_once __DIR__ . '/class-od-html-tag-walker.php';
-require_once __DIR__ . '/optimization.php';
+		// Core infrastructure classes.
+		require_once __DIR__ . '/class-od-data-validation-exception.php';
+		require_once __DIR__ . '/class-od-html-tag-processor.php';
+		require_once __DIR__ . '/class-od-url-metric.php';
+		require_once __DIR__ . '/class-od-url-metrics-group.php';
+		require_once __DIR__ . '/class-od-url-metrics-group-collection.php';
 
-// Add hooks for the above requires.
-require_once __DIR__ . '/hooks.php';
+		// Storage logic.
+		require_once __DIR__ . '/storage/class-od-url-metrics-post-type.php';
+		require_once __DIR__ . '/storage/class-od-storage-lock.php';
+		require_once __DIR__ . '/storage/data.php';
+		require_once __DIR__ . '/storage/rest-api.php';
+
+		// Detection logic.
+		require_once __DIR__ . '/detection.php';
+
+		// Optimization logic.
+		require_once __DIR__ . '/class-od-html-tag-walker.php';
+		require_once __DIR__ . '/optimization.php';
+
+		// Add hooks for the above requires.
+		require_once __DIR__ . '/hooks.php';
+	}
+);
Index: optimization.php
===================================================================
--- optimization.php	(revision 3088546)
+++ optimization.php	(working copy)
@@ -27,7 +27,7 @@
  * @access private
  * @link https://core.trac.wordpress.org/ticket/43258
  *
- * @param string $passthrough Optional. Filter value. Default null.
+ * @param string $passthrough Value for the template_include filter which is passed through.
  * @return string Unmodified value of $passthrough.
  */
 function od_buffer_output( string $passthrough ): string {
@@ -53,12 +53,18 @@
  * @since 0.1.0
  * @access private
  */
-function od_maybe_add_template_output_buffer_filter() {
-	if ( ! od_can_optimize_response() ) {
+function od_maybe_add_template_output_buffer_filter(): void {
+	if ( ! od_can_optimize_response() || isset( $_GET['optimization_detective_disabled'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
 		return;
 	}
 	$callback = 'od_optimize_template_output_buffer';
-	if ( function_exists( 'perflab_wrap_server_timing' ) ) {
+	if (
+		function_exists( 'perflab_wrap_server_timing' )
+		&&
+		function_exists( 'perflab_server_timing_use_output_buffer' )
+		&&
+		perflab_server_timing_use_output_buffer()
+	) {
 		$callback = perflab_wrap_server_timing( $callback, 'optimization-detective', 'exist' );
 	}
 	add_filter( 'od_template_output_buffer', $callback );
@@ -80,7 +86,7 @@
 		// Since injection of inline-editing controls interfere with breadcrumbs, while also just not necessary in this context.
 		is_customize_preview() ||
 		// Since the images detected in the response body of a POST request cannot, by definition, be cached.
-		'GET' !== $_SERVER['REQUEST_METHOD'] ||
+		( isset( $_SERVER['REQUEST_METHOD'] ) && 'GET' !== $_SERVER['REQUEST_METHOD'] ) ||
 		// The aim is to optimize pages for the majority of site visitors, not those who administer the site. For admin
 		// users, additional elements will be present like the script from wp_customize_support_script() which will
 		// interfere with the XPath indices. Note that od_get_normalized_query_vars() is varied by is_user_logged_in()
@@ -173,6 +179,31 @@
 }
 
 /**
+ * Determines whether the response has an HTML Content-Type.
+ *
+ * @since 0.2.0
+ * @private
+ *
+ * @return bool Whether Content-Type is HTML.
+ */
+function od_is_response_html_content_type(): bool {
+	$is_html_content_type = false;
+
+	$headers_list = array_merge(
+		array( 'Content-Type: ' . ini_get( 'default_mimetype' ) ),
+		headers_list()
+	);
+	foreach ( $headers_list as $header ) {
+		$header_parts = preg_split( '/\s*[:;]\s*/', strtolower( $header ) );
+		if ( is_array( $header_parts ) && count( $header_parts ) >= 2 && 'content-type' === $header_parts[0] ) {
+			$is_html_content_type = in_array( $header_parts[1], array( 'text/html', 'application/xhtml+xml' ), true );
+		}
+	}
+
+	return $is_html_content_type;
+}
+
+/**
  * Optimizes template output buffer.
  *
  * @since 0.1.0
@@ -182,6 +213,10 @@
  * @return string Filtered template output buffer.
  */
 function od_optimize_template_output_buffer( string $buffer ): string {
+	if ( ! od_is_response_html_content_type() ) {
+		return $buffer;
+	}
+
 	$slug = od_get_url_metrics_slug( od_get_normalized_query_vars() );
 	$post = OD_URL_Metrics_Post_Type::get_post( $slug );
 
@@ -244,7 +279,7 @@
 			&&
 			$walker->get_attribute( 'src' )
 			&&
-			! str_starts_with( $walker->get_attribute( 'src' ), 'data:' )
+			! str_starts_with( (string) $walker->get_attribute( 'src' ), 'data:' )
 		);
 
 		/*
@@ -260,7 +295,7 @@
 		if (
 			$style
 			&&
-			preg_match( '/background(-image)?\s*:[^;]*?url\(\s*[\'"]?(?<background_image>.+?)[\'"]?\s*\)/', $style, $matches )
+			preg_match( '/background(-image)?\s*:[^;]*?url\(\s*[\'"]?(?<background_image>.+?)[\'"]?\s*\)/', (string) $style, $matches )
 			&&
 			! str_starts_with( $matches['background_image'], 'data:' )
 		) {
@@ -307,16 +342,20 @@
 				$img_attributes = array();
 				foreach ( array( 'src', 'srcset', 'sizes', 'crossorigin' ) as $attr_name ) {
 					$value = $walker->get_attribute( $attr_name );
-					if ( null !== $value ) {
+					if ( is_string( $value ) ) {
 						$img_attributes[ $attr_name ] = $value;
 					}
 				}
 				foreach ( $lcp_element_minimum_viewport_widths_by_xpath[ $xpath ] as $minimum_viewport_width ) {
-					$lcp_elements_by_minimum_viewport_widths[ $minimum_viewport_width ]['img_attributes'] = $img_attributes;
+					if ( is_array( $lcp_elements_by_minimum_viewport_widths[ $minimum_viewport_width ] ) ) {
+						$lcp_elements_by_minimum_viewport_widths[ $minimum_viewport_width ]['img_attributes'] = $img_attributes;
+					}
 				}
 			} elseif ( $background_image_url ) {
 				foreach ( $lcp_element_minimum_viewport_widths_by_xpath[ $xpath ] as $minimum_viewport_width ) {
-					$lcp_elements_by_minimum_viewport_widths[ $minimum_viewport_width ]['background_image'] = $background_image_url;
+					if ( is_array( $lcp_elements_by_minimum_viewport_widths[ $minimum_viewport_width ] ) ) {
+						$lcp_elements_by_minimum_viewport_widths[ $minimum_viewport_width ]['background_image'] = $background_image_url;
+					}
 				}
 			}
 		}
Index: readme.txt
===================================================================
--- readme.txt	(revision 3088546)
+++ readme.txt	(working copy)
@@ -3,8 +3,8 @@
 Contributors:      wordpressdotorg
 Requires at least: 6.4
 Tested up to:      6.5
-Requires PHP:      7.0
-Stable tag:        0.1.1
+Requires PHP:      7.2
+Stable tag:        0.2.0
 License:           GPLv2 or later
 License URI:       https://www.gnu.org/licenses/gpl-2.0.html
 Tags:              performance, images
@@ -137,6 +137,21 @@
 
 == Changelog ==
 
+= 0.2.0 =
+
+**Enhancements**
+
+* Add optimization_detective_disabled query var to disable behavior. ([1193](https://github.com/WordPress/performance/pull/1193))
+* Facilitate embedding Optimization Detective in other plugins/themes. ([1185](https://github.com/WordPress/performance/pull/1185))
+* Use PHP 7.2 features in Optimization Detective. ([1162](https://github.com/WordPress/performance/pull/1162))
+* Improve overall code quality with stricter static analysis checks. ([775](https://github.com/WordPress/performance/issues/775))
+
+**Bug Fixes**
+
+* Avoid _doing_it_wrong() for Server-Timing in Optimization Detective when output buffering is not enabled. ([1194](https://github.com/WordPress/performance/pull/1194))
+* Ensure only HTML responses are optimized. ([1189](https://github.com/WordPress/performance/pull/1189))
+* Fix XPath indices to be 1-based instead of 0-based. ([1191](https://github.com/WordPress/performance/pull/1191))
+
 = 0.1.1 =
 
 * Use plugin slug for generator tag. ([1103](https://github.com/WordPress/performance/pull/1103))
Index: storage/class-od-storage-lock.php
===================================================================
--- storage/class-od-storage-lock.php	(revision 3088546)
+++ storage/class-od-storage-lock.php	(working copy)
@@ -67,7 +67,7 @@
 	 * @since 0.1.0
 	 * @access private
 	 */
-	public static function set_lock() {
+	public static function set_lock(): void {
 		$ttl = self::get_ttl();
 		$key = self::get_transient_key();
 		if ( 0 === $ttl ) {
Index: storage/class-od-url-metrics-post-type.php
===================================================================
--- storage/class-od-url-metrics-post-type.php	(revision 3088546)
+++ storage/class-od-url-metrics-post-type.php	(working copy)
@@ -44,7 +44,7 @@
 	 *
 	 * @since 0.1.0
 	 */
-	public static function add_hooks() {
+	public static function add_hooks(): void {
 		add_action( 'init', array( __CLASS__, 'register_post_type' ) );
 		add_action( 'admin_init', array( __CLASS__, 'schedule_garbage_collection' ) );
 		add_action( self::GC_CRON_EVENT_NAME, array( __CLASS__, 'delete_stale_posts' ) );
@@ -57,7 +57,7 @@
 	 *
 	 * @since 0.1.0
 	 */
-	public static function register_post_type() {
+	public static function register_post_type(): void {
 		register_post_type(
 			self::SLUG,
 			array(
@@ -85,7 +85,7 @@
 	 * @param string $slug URL metrics slug.
 	 * @return WP_Post|null Post object if exists.
 	 */
-	public static function get_post( string $slug ) {
+	public static function get_post( string $slug ): ?WP_Post {
 		$post_query = new WP_Query(
 			array(
 				'post_type'              => self::SLUG,
@@ -118,7 +118,7 @@
 	 */
 	public static function get_url_metrics_from_post( WP_Post $post ): array {
 		$this_function   = __FUNCTION__;
-		$trigger_warning = static function ( $message ) use ( $this_function ) {
+		$trigger_warning = static function ( $message ) use ( $this_function ): void {
 			wp_trigger_error( $this_function, esc_html( $message ), E_USER_WARNING );
 		};
 
@@ -225,6 +225,9 @@
 			),
 			JSON_UNESCAPED_SLASHES // No need for escaped slashes since not printed to frontend.
 		);
+		if ( ! is_string( $post_data['post_content'] ) ) {
+			return new WP_Error( 'json_encode_error', json_last_error_msg() );
+		}
 
 		$has_kses = false !== has_filter( 'content_save_pre', 'wp_filter_post_kses' );
 		if ( $has_kses ) {
@@ -253,7 +256,7 @@
 	 *
 	 * @since 0.1.0
 	 */
-	public static function schedule_garbage_collection() {
+	public static function schedule_garbage_collection(): void {
 		if ( ! is_user_logged_in() ) {
 			return;
 		}
@@ -275,7 +278,7 @@
 	 *
 	 * @since 0.1.0
 	 */
-	public static function delete_stale_posts() {
+	public static function delete_stale_posts(): void {
 		$one_month_ago = gmdate( 'Y-m-d H:i:s', strtotime( '-1 month' ) );
 
 		$query = new WP_Query(
@@ -290,7 +293,7 @@
 		);
 
 		foreach ( $query->posts as $post ) {
-			if ( self::SLUG === $post->post_type ) { // Sanity check.
+			if ( $post instanceof WP_Post && self::SLUG === $post->post_type ) { // Sanity check.
 				wp_delete_post( $post->ID, true );
 			}
 		}
@@ -303,7 +306,7 @@
 	 *
 	 * @since 0.1.0
 	 */
-	public static function delete_all_posts() {
+	public static function delete_all_posts(): void {
 		global $wpdb;
 
 		// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
Index: storage/data.php
===================================================================
--- storage/data.php	(revision 3088546)
+++ storage/data.php	(working copy)
@@ -61,7 +61,7 @@
  * @since 0.1.0
  * @access private
  *
- * @return array Normalized query vars.
+ * @return array<string, mixed> Normalized query vars.
  */
 function od_get_normalized_query_vars(): array {
 	global $wp;
@@ -133,11 +133,11 @@
  *
  * @see od_get_normalized_query_vars()
  *
- * @param array $query_vars Normalized query vars.
+ * @param array<string, mixed> $query_vars Normalized query vars.
  * @return string Slug.
  */
 function od_get_url_metrics_slug( array $query_vars ): string {
-	return md5( wp_json_encode( $query_vars ) );
+	return md5( (string) wp_json_encode( $query_vars ) );
 }
 
 /**
@@ -312,7 +312,10 @@
  * @access private
  *
  * @param OD_URL_Metrics_Group_Collection $group_collection URL metrics group collection.
- * @return array LCP elements keyed by its minimum viewport width. If there is no supported LCP element at a breakpoint, then `false` is used.
+ * @return array<int, array{xpath: string}|false> LCP elements keyed by its minimum viewport width. If there is no
+ *                                                supported LCP element at a breakpoint, then `false` is used. Note that
+ *                                                the array shape is actually an ElementData from OD_URL_Metric but
+ *                                                PHPStan does not support importing a type onto a function.
  */
 function od_get_lcp_elements_by_minimum_viewport_widths( OD_URL_Metrics_Group_Collection $group_collection ): array {
 	$lcp_element_by_viewport_minimum_width = array();
Index: storage/rest-api.php
===================================================================
--- storage/rest-api.php	(revision 3088546)
+++ storage/rest-api.php	(working copy)
@@ -35,7 +35,7 @@
  * @since 0.1.0
  * @access private
  */
-function od_register_endpoint() {
+function od_register_endpoint(): void {
 
 	$args = array(
 		'slug'  => array(
@@ -94,6 +94,8 @@
  * @since 0.1.0
  * @access private
  *
+ * @phpstan-param WP_REST_Request<array<string, mixed>> $request
+ *
  * @param WP_REST_Request $request Request.
  * @return WP_REST_Response|WP_Error Response.
  */
Index: uninstall.php
===================================================================
--- uninstall.php	(revision 3088546)
+++ uninstall.php	(working copy)
@@ -13,7 +13,7 @@
 
 require_once __DIR__ . '/storage/class-od-url-metrics-post-type.php';
 
-$od_delete_site_data = static function () {
+$od_delete_site_data = static function (): void {
 	// Delete all URL Metrics posts for the current site.
 	OD_URL_Metrics_Post_Type::delete_all_posts();
 	wp_unschedule_hook( OD_URL_Metrics_Post_Type::GC_CRON_EVENT_NAME );

speculation-rules

svn status:

M       class-plsr-url-pattern-prefixer.php
M       helper.php
M       hooks.php
M       load.php
M       readme.txt
M       settings.php
M       uninstall.php
svn diff
Index: class-plsr-url-pattern-prefixer.php
===================================================================
--- class-plsr-url-pattern-prefixer.php	(revision 3088546)
+++ class-plsr-url-pattern-prefixer.php	(working copy)
@@ -22,7 +22,7 @@
 	 * Map of `$context_string => $base_path` pairs.
 	 *
 	 * @since 1.0.0
-	 * @var array
+	 * @var array<string, string>
 	 */
 	private $contexts;
 
@@ -31,8 +31,8 @@
 	 *
 	 * @since 1.0.0
 	 *
-	 * @param array $contexts Optional. Map of `$context_string => $base_path` pairs. Default is the contexts returned
-	 *                        by the {@see PLSR_URL_Pattern_Prefixer::get_default_contexts()} method.
+	 * @param array<string, string> $contexts Optional. Map of `$context_string => $base_path` pairs. Default is the contexts returned
+	 *                                        by the {@see PLSR_URL_Pattern_Prefixer::get_default_contexts()} method.
 	 */
 	public function __construct( array $contexts = array() ) {
 		if ( $contexts ) {
@@ -103,12 +103,17 @@
 	 *
 	 * @since 1.0.0
 	 *
-	 * @return array Map of `$context_string => $base_path` pairs.
+	 * @return array<string, string> Map of `$context_string => $base_path` pairs.
 	 */
 	public static function get_default_contexts(): array {
 		return array(
-			'home' => self::escape_pattern_string( trailingslashit( wp_parse_url( home_url( '/' ), PHP_URL_PATH ) ) ),
-			'site' => self::escape_pattern_string( trailingslashit( wp_parse_url( site_url( '/' ), PHP_URL_PATH ) ) ),
+			'home'       => self::escape_pattern_string( trailingslashit( (string) wp_parse_url( home_url( '/' ), PHP_URL_PATH ) ) ),
+			'site'       => self::escape_pattern_string( trailingslashit( (string) wp_parse_url( site_url( '/' ), PHP_URL_PATH ) ) ),
+			'uploads'    => self::escape_pattern_string( trailingslashit( (string) wp_parse_url( wp_upload_dir( null, false )['baseurl'], PHP_URL_PATH ) ) ),
+			'content'    => self::escape_pattern_string( trailingslashit( (string) wp_parse_url( content_url(), PHP_URL_PATH ) ) ),
+			'plugins'    => self::escape_pattern_string( trailingslashit( (string) wp_parse_url( plugins_url(), PHP_URL_PATH ) ) ),
+			'template'   => self::escape_pattern_string( trailingslashit( (string) wp_parse_url( get_stylesheet_directory_uri(), PHP_URL_PATH ) ) ),
+			'stylesheet' => self::escape_pattern_string( trailingslashit( (string) wp_parse_url( get_template_directory_uri(), PHP_URL_PATH ) ) ),
 		);
 	}
 
Index: helper.php
===================================================================
--- helper.php	(revision 3088546)
+++ helper.php	(working copy)
@@ -19,9 +19,9 @@
  *
  * @since 1.0.0
  *
- * @return array Associative array of speculation rules by type.
+ * @return array<string, array<int, array<string, mixed>>> Associative array of speculation rules by type.
  */
-function plsr_get_speculation_rules() {
+function plsr_get_speculation_rules(): array {
 	$option = get_option( 'plsr_speculation_rules' );
 
 	/*
@@ -34,7 +34,7 @@
 		$option = array_merge( plsr_get_setting_default(), $option );
 	}
 
-	$mode      = $option['mode'];
+	$mode      = (string) $option['mode'];
 	$eagerness = $option['eagerness'];
 
 	$prefixer = new PLSR_URL_Pattern_Prefixer();
@@ -43,6 +43,11 @@
 		$prefixer->prefix_path_pattern( '/wp-login.php', 'site' ),
 		$prefixer->prefix_path_pattern( '/wp-admin/*', 'site' ),
 		$prefixer->prefix_path_pattern( '/*\\?*(^|&)_wpnonce=*', 'home' ),
+		$prefixer->prefix_path_pattern( '/*', 'uploads' ),
+		$prefixer->prefix_path_pattern( '/*', 'content' ),
+		$prefixer->prefix_path_pattern( '/*', 'plugins' ),
+		$prefixer->prefix_path_pattern( '/*', 'template' ),
+		$prefixer->prefix_path_pattern( '/*', 'stylesheet' ),
 	);
 
 	/**
Index: hooks.php
===================================================================
--- hooks.php	(revision 3088546)
+++ hooks.php	(working copy)
@@ -18,7 +18,7 @@
  *
  * @since 1.0.0
  */
-function plsr_print_speculation_rules() {
+function plsr_print_speculation_rules(): void {
 	$rules = plsr_get_speculation_rules();
 	if ( empty( $rules ) ) {
 		return;
@@ -27,8 +27,8 @@
 	// This workaround is needed for WP 6.4. See <https://core.trac.wordpress.org/ticket/60320>.
 	$needs_html5_workaround = (
 		! current_theme_supports( 'html5', 'script' ) &&
-		version_compare( strtok( get_bloginfo( 'version' ), '-' ), '6.4', '>=' ) &&
-		version_compare( strtok( get_bloginfo( 'version' ), '-' ), '6.5', '<' )
+		version_compare( (string) strtok( (string) get_bloginfo( 'version' ), '-' ), '6.4', '>=' ) &&
+		version_compare( (string) strtok( (string) get_bloginfo( 'version' ), '-' ), '6.5', '<' )
 	);
 	if ( $needs_html5_workaround ) {
 		$backup_wp_theme_features = $GLOBALS['_wp_theme_features'];
@@ -36,7 +36,7 @@
 	}
 
 	wp_print_inline_script_tag(
-		wp_json_encode( $rules ),
+		(string) wp_json_encode( $rules ),
 		array( 'type' => 'speculationrules' )
 	);
 
@@ -53,7 +53,7 @@
  *
  * @since 1.1.0
  */
-function plsr_render_generator_meta_tag() {
+function plsr_render_generator_meta_tag(): void {
 	// Use the plugin slug as it is immutable.
 	echo '<meta name="generator" content="speculation-rules ' . esc_attr( SPECULATION_RULES_VERSION ) . '">' . "\n";
 }
Index: load.php
===================================================================
--- load.php	(revision 3088546)
+++ load.php	(working copy)
@@ -5,7 +5,7 @@
  * Description: Enables browsers to speculatively prerender or prefetch pages when hovering over links.
  * Requires at least: 6.4
  * Requires PHP: 7.2
- * Version: 1.2.2
+ * Version: 1.3.0
  * Author: WordPress Performance Team
  * Author URI: https://make.wordpress.org/performance/
  * License: GPLv2 or later
@@ -20,15 +20,65 @@
 	exit;
 }
 
-// Define the constant.
-if ( defined( 'SPECULATION_RULES_VERSION' ) ) {
-	return;
-}
+(
+	/**
+	 * Register this copy of the plugin among other potential copies embedded in plugins or themes.
+	 *
+	 * @param string  $global_var_name Global variable name for storing the plugin pending loading.
+	 * @param string  $version         Version.
+	 * @param Closure $load            Callback that loads the plugin.
+	 */
+	static function ( string $global_var_name, string $version, Closure $load ): void {
+		if ( ! isset( $GLOBALS[ $global_var_name ] ) ) {
+			$bootstrap = static function () use ( $global_var_name ): void {
+				if (
+					isset( $GLOBALS[ $global_var_name ]['load'], $GLOBALS[ $global_var_name ]['version'] )
+					&&
+					$GLOBALS[ $global_var_name ]['load'] instanceof Closure
+					&&
+					is_string( $GLOBALS[ $global_var_name ]['version'] )
+				) {
+					call_user_func( $GLOBALS[ $global_var_name ]['load'], $GLOBALS[ $global_var_name ]['version'] );
+					unset( $GLOBALS[ $global_var_name ] );
+				}
+			};
 
-define( 'SPECULATION_RULES_VERSION', '1.2.2' );
-define( 'SPECULATION_RULES_MAIN_FILE', plugin_basename( __FILE__ ) );
+			// Wait until after the plugins have loaded and the theme has loaded. The after_setup_theme action is used
+			// because it is the first action that fires once the theme is loaded.
+			add_action( 'after_setup_theme', $bootstrap, PHP_INT_MIN );
+		}
 
-require_once __DIR__ . '/class-plsr-url-pattern-prefixer.php';
-require_once __DIR__ . '/helper.php';
-require_once __DIR__ . '/hooks.php';
-require_once __DIR__ . '/settings.php';
+		// Register this copy of the plugin.
+		if (
+			// Register this copy if none has been registered yet.
+			! isset( $GLOBALS[ $global_var_name ]['version'] )
+			||
+			// Or register this copy if the version greater than what is currently registered.
+			version_compare( $version, $GLOBALS[ $global_var_name ]['version'], '>' )
+			||
+			// Otherwise, register this copy if it is actually the one installed in the directory for plugins.
+			rtrim( WP_PLUGIN_DIR, '/' ) === dirname( __DIR__ )
+		) {
+			$GLOBALS[ $global_var_name ]['version'] = $version;
+			$GLOBALS[ $global_var_name ]['load']    = $load;
+		}
+	}
+)(
+	'plsr_pending_plugin_info',
+	'1.3.0',
+	static function ( string $version ): void {
+
+		// Define the constant.
+		if ( defined( 'SPECULATION_RULES_VERSION' ) ) {
+			return;
+		}
+
+		define( 'SPECULATION_RULES_VERSION', $version );
+		define( 'SPECULATION_RULES_MAIN_FILE', plugin_basename( __FILE__ ) );
+
+		require_once __DIR__ . '/class-plsr-url-pattern-prefixer.php';
+		require_once __DIR__ . '/helper.php';
+		require_once __DIR__ . '/hooks.php';
+		require_once __DIR__ . '/settings.php';
+	}
+);
Index: readme.txt
===================================================================
--- readme.txt	(revision 3088546)
+++ readme.txt	(working copy)
@@ -4,7 +4,7 @@
 Requires at least: 6.4
 Tested up to:      6.5
 Requires PHP:      7.2
-Stable tag:        1.2.2
+Stable tag:        1.3.0
 License:           GPLv2 or later
 License URI:       https://www.gnu.org/licenses/gpl-2.0.html
 Tags:              performance, javascript, speculation rules, prerender, prefetch
@@ -114,6 +114,14 @@
 
 == Changelog ==
 
+= 1.3.0 =
+
+**Enhancements**
+
+* Prevent speculatively loading links to the uploads, content, plugins, template, or stylesheet directories. ([1167](https://github.com/WordPress/performance/pull/1167))
+* Facilitate embedding Speculative Loading in other plugins/themes. ([1159](https://github.com/WordPress/performance/pull/1159))
+* Improve overall code quality with stricter static analysis checks. ([775](https://github.com/WordPress/performance/issues/775))
+
 = 1.2.2 =
 
 **Bug Fixes**
Index: settings.php
===================================================================
--- settings.php	(revision 3088546)
+++ settings.php	(working copy)
@@ -18,7 +18,7 @@
  *
  * @return array<string, string> Associative array of `$mode => $label` pairs.
  */
-function plsr_get_mode_labels() {
+function plsr_get_mode_labels(): array {
 	return array(
 		'prefetch'  => _x( 'Prefetch', 'setting label', 'speculation-rules' ),
 		'prerender' => _x( 'Prerender', 'setting label', 'speculation-rules' ),
@@ -32,7 +32,7 @@
  *
  * @return array<string, string> Associative array of `$eagerness => $label` pairs.
  */
-function plsr_get_eagerness_labels() {
+function plsr_get_eagerness_labels(): array {
 	return array(
 		'conservative' => _x( 'Conservative (typically on click)', 'setting label', 'speculation-rules' ),
 		'moderate'     => _x( 'Moderate (typically on hover)', 'setting label', 'speculation-rules' ),
@@ -52,7 +52,7 @@
  *     @type string $eagerness Eagerness.
  * }
  */
-function plsr_get_setting_default() {
+function plsr_get_setting_default(): array {
 	return array(
 		'mode'      => 'prerender',
 		'eagerness' => 'moderate',
@@ -72,7 +72,7 @@
  *     @type string $eagerness Eagerness.
  * }
  */
-function plsr_sanitize_setting( $input ) {
+function plsr_sanitize_setting( $input ): array {
 	$default_value = plsr_get_setting_default();
 
 	if ( ! is_array( $input ) ) {
@@ -102,7 +102,7 @@
  * @since 1.0.0
  * @access private
  */
-function plsr_register_setting() {
+function plsr_register_setting(): void {
 	register_setting(
 		'reading',
 		'plsr_speculation_rules',
@@ -138,11 +138,11 @@
  * @since 1.0.0
  * @access private
  */
-function plsr_add_setting_ui() {
+function plsr_add_setting_ui(): void {
 	add_settings_section(
 		'plsr_speculation_rules',
 		__( 'Speculative Loading', 'speculation-rules' ),
-		static function () {
+		static function (): void {
 			?>
 			<p class="description">
 				<?php esc_html_e( 'This section allows you to control how URLs that your users navigate to are speculatively loaded to improve performance.', 'speculation-rules' ); ?>
@@ -196,7 +196,7 @@
  *     @type string $description Optional. A description to show for the field.
  * }
  */
-function plsr_render_settings_field( array $args ) {
+function plsr_render_settings_field( array $args ): void {
 	if ( empty( $args['field'] ) || empty( $args['title'] ) ) { // Invalid.
 		return;
 	}
@@ -206,8 +206,12 @@
 		return;
 	}
 
-	$value   = $option[ $args['field'] ];
-	$choices = call_user_func( "plsr_get_{$args['field']}_labels" );
+	$value    = $option[ $args['field'] ];
+	$callback = "plsr_get_{$args['field']}_labels";
+	if ( ! is_callable( $callback ) ) {
+		return;
+	}
+	$choices = call_user_func( $callback );
 
 	?>
 	<fieldset>
Index: uninstall.php
===================================================================
--- uninstall.php	(revision 3088546)
+++ uninstall.php	(working copy)
@@ -36,6 +36,6 @@
  *
  * @since 1.2.0
  */
-function plsr_delete_plugin_option() {
+function plsr_delete_plugin_option(): void {
 	delete_option( 'plsr_speculation_rules' );
 }

webp-uploads

svn status:

?       deprecated.php
M       helper.php
M       hooks.php
M       image-edit.php
M       load.php
M       readme.txt
M       rest-api.php
M       settings.php
M       uninstall.php
svn diff
Index: helper.php
===================================================================
--- helper.php	(revision 3088546)
+++ helper.php	(working copy)
@@ -19,7 +19,7 @@
  *
  * @return array<string, array<string>> An array of valid mime types, where the key is the mime type and the value is the extension type.
  */
-function webp_uploads_get_upload_image_mime_transforms() {
+function webp_uploads_get_upload_image_mime_transforms(): array {
 	$default_transforms = array(
 		'image/jpeg' => array( 'image/webp' ),
 		'image/webp' => array( 'image/webp' ),
@@ -71,15 +71,15 @@
  * @since 1.0.0
  * @access private
  *
- * @param int         $attachment_id         The ID of the attachment from where this image would be created.
- * @param string      $image_size            The size name that would be used to create the image source, out of the registered subsizes.
- * @param array       $size_data             An array with the dimensions of the image: height, width and crop.
- * @param string      $mime                  The target mime in which the image should be created.
- * @param string|null $destination_file_name The path where the file would be stored, including the extension. If null, `generate_filename` is used to create the destination file name.
+ * @param int                                          $attachment_id         The ID of the attachment from where this image would be created.
+ * @param string                                       $image_size            The size name that would be used to create the image source, out of the registered subsizes.
+ * @param array{ width: int, height: int, crop: bool } $size_data             An array with the dimensions of the image: height, width and crop.
+ * @param string                                       $mime                  The target mime in which the image should be created.
+ * @param string|null                                  $destination_file_name The path where the file would be stored, including the extension. If null, `generate_filename` is used to create the destination file name.
  *
- * @return array|WP_Error An array with the file and filesize if the image was created correctly, otherwise a WP_Error.
+ * @return array{ file: string, filesize: int }|WP_Error An array with the file and filesize if the image was created correctly, otherwise a WP_Error.
  */
-function webp_uploads_generate_additional_image_source( $attachment_id, $image_size, array $size_data, $mime, $destination_file_name = null ) {
+function webp_uploads_generate_additional_image_source( int $attachment_id, string $image_size, array $size_data, string $mime, ?string $destination_file_name = null ) {
 	/**
 	 * Filter to allow the generation of additional image sources, in which a defined mime type
 	 * can be transformed and create additional mime types for the file.
@@ -88,31 +88,40 @@
 	 *
 	 * @since 1.1.0
 	 *
-	 * @param array|null|WP_Error $image         Image data {'path'=>string, 'file'=>string, 'width'=>int, 'height'=>int, 'mime-type'=>string} or null or WP_Error.
-	 * @param int                 $attachment_id The ID of the attachment from where this image would be created.
-	 * @param string              $image_size    The size name that would be used to create this image, out of the registered subsizes.
-	 * @param array               $size_data     An array with the dimensions of the image: height, width and crop {'height'=>int, 'width'=>int, 'crop'}.
-	 * @param string              $mime          The target mime in which the image should be created.
+	 * @param array{
+	 *            file: string,
+	 *            path?: string,
+	 *            filesize?: int
+	 *        }|null|WP_Error $image         Image data, null, or WP_Error.
+	 * @param int             $attachment_id The ID of the attachment from where this image would be created.
+	 * @param string          $image_size    The size name that would be used to create this image, out of the registered subsizes.
+	 * @param array{
+	 *            width: int,
+	 *            height: int,
+	 *            crop: bool
+	 *        }               $size_data     An array with the dimensions of the image.
+	 * @param string          $mime          The target mime in which the image should be created.
 	 */
 	$image = apply_filters( 'webp_uploads_pre_generate_additional_image_source', null, $attachment_id, $image_size, $size_data, $mime );
 	if ( is_wp_error( $image ) ) {
 		return $image;
 	}
+	if ( is_array( $image ) && array_key_exists( 'file', $image ) && is_string( $image['file'] ) ) {
+		// The filtered image provided all we need to short-circuit here.
+		if ( array_key_exists( 'filesize', $image ) && is_int( $image['filesize'] ) && $image['filesize'] > 0 ) {
+			return $image;
+		}
 
-	if (
-		is_array( $image ) &&
-		! empty( $image['file'] ) &&
-		(
-			! empty( $image['path'] ) ||
-			array_key_exists( 'filesize', $image )
-		)
-	) {
-		return array(
-			'file'     => $image['file'],
-			'filesize' => array_key_exists( 'filesize', $image )
-				? $image['filesize']
-				: wp_filesize( $image['path'] ),
-		);
+		// Supply the filesize based on the filter-provided path.
+		if ( array_key_exists( 'path', $image ) && is_int( $image['path'] ) ) {
+			$filesize = wp_filesize( $image['path'] );
+			if ( $filesize > 0 ) {
+				return array(
+					'file'     => $image['file'],
+					'filesize' => $filesize,
+				);
+			}
+		}
 	}
 
 	$allowed_mimes = array_flip( wp_get_mime_types() );
@@ -125,7 +134,7 @@
 	}
 
 	$image_path = wp_get_original_image_path( $attachment_id );
-	if ( ! file_exists( $image_path ) ) {
+	if ( ! $image_path || ! file_exists( $image_path ) ) {
 		return new WP_Error( 'original_image_file_not_found', __( 'The original image file does not exists, subsizes are created out of the original image.', 'webp-uploads' ) );
 	}
 
@@ -157,7 +166,7 @@
 		$destination_file_name = $editor->generate_filename( $suffix, null, $extension[0] );
 	}
 
-	remove_filter( 'image_editor_output_format', 'webp_uploads_filter_image_editor_output_format', 10, 3 );
+	remove_filter( 'image_editor_output_format', 'webp_uploads_filter_image_editor_output_format', 10 );
 	$image = $editor->save( $destination_file_name, $mime );
 	add_filter( 'image_editor_output_format', 'webp_uploads_filter_image_editor_output_format', 10, 3 );
 
@@ -188,9 +197,9 @@
  * @param string $size          The size name that would be used to create this image, out of the registered subsizes.
  * @param string $mime          A mime type we are looking to use to create this image.
  *
- * @return array|WP_Error
+ * @return array{ file: string, filesize: int }|WP_Error
  */
-function webp_uploads_generate_image_size( $attachment_id, $size, $mime ) {
+function webp_uploads_generate_image_size( int $attachment_id, string $size, string $mime ) {
 	$sizes    = wp_get_registered_image_subsizes();
 	$metadata = wp_get_attachment_metadata( $attachment_id );
 
@@ -228,33 +237,6 @@
 }
 
 /**
- * Returns the attachment sources array ordered by filesize.
- *
- * @since 1.0.0
- *
- * @param int    $attachment_id The attachment ID.
- * @param string $size          The attachment size.
- * @return array The attachment sources array.
- */
-function webp_uploads_get_attachment_sources( $attachment_id, $size = 'thumbnail' ) {
-	// Check for the sources attribute in attachment metadata.
-	$metadata = wp_get_attachment_metadata( $attachment_id );
-
-	// Return full image size sources.
-	if ( 'full' === $size && ! empty( $metadata['sources'] ) ) {
-		return $metadata['sources'];
-	}
-
-	// Return the resized image sources.
-	if ( ! empty( $metadata['sizes'][ $size ]['sources'] ) ) {
-		return $metadata['sizes'][ $size ]['sources'];
-	}
-
-	// Return an empty array if no sources found.
-	return array();
-}
-
-/**
  * Returns mime types that should be used for an image in the specific context.
  *
  * @since 1.0.0
@@ -261,9 +243,9 @@
  *
  * @param int    $attachment_id The attachment ID.
  * @param string $context       The current context.
- * @return array Mime types to use for the image.
+ * @return string[] Mime types to use for the image.
  */
-function webp_uploads_get_content_image_mimes( $attachment_id, $context ) {
+function webp_uploads_get_content_image_mimes( int $attachment_id, string $context ): array {
 	$target_mimes = array( 'image/webp', 'image/jpeg' );
 
 	/**
@@ -293,7 +275,7 @@
  *
  * @return bool True if in the <body> within a frontend request, false otherwise.
  */
-function webp_uploads_in_frontend_body() {
+function webp_uploads_in_frontend_body(): bool {
 	global $wp_query;
 
 	// Check if this request is generally outside (or before) any frontend context.
@@ -314,11 +296,11 @@
  *
  * @since 1.0.0
  *
- * @param array $original   An array with the metadata of the attachment.
- * @param array $additional An array containing the filename and file size for additional mime.
+ * @param array{ filesize?: int } $original   An array with the metadata of the attachment.
+ * @param array{ filesize?: int } $additional An array containing the filename and file size for additional mime.
  * @return bool True if the additional image is larger than the original image, otherwise false.
  */
-function webp_uploads_should_discard_additional_image_file( array $original, array $additional ) {
+function webp_uploads_should_discard_additional_image_file( array $original, array $additional ): bool {
 	$original_image_filesize   = isset( $original['filesize'] ) ? (int) $original['filesize'] : 0;
 	$additional_image_filesize = isset( $additional['filesize'] ) ? (int) $additional['filesize'] : 0;
 	if ( $original_image_filesize > 0 && $additional_image_filesize > 0 ) {
Index: hooks.php
===================================================================
--- hooks.php	(revision 3088546)
+++ hooks.php	(working copy)
@@ -26,23 +26,44 @@
  * @see   wp_generate_attachment_metadata()
  * @see   webp_uploads_get_upload_image_mime_transforms()
  *
- * @param array $metadata      An array with the metadata from this attachment.
- * @param int   $attachment_id The ID of the attachment where the hook was dispatched.
- * @return array An array with the updated structure for the metadata before is stored in the database.
+ * @phpstan-param array{
+ *      width: int,
+ *      height: int,
+ *      file: string,
+ *      sizes: array<string, array{ file: string, width: int, height: int, 'mime-type': string }>,
+ *      image_meta: array<string, mixed>,
+ *      filesize: int
+ *  } $metadata
+ *
+ * @param array<string, mixed> $metadata      An array with the metadata from this attachment.
+ * @param int                  $attachment_id The ID of the attachment where the hook was dispatched.
+ *
+ * @return array{
+ *     width: int,
+ *     height: int,
+ *     file: string,
+ *     sizes: array<string, array{ file: string, width: int, height: int, 'mime-type': string, sources?: array<string, array{ file: string, filesize: int }> }>,
+ *     image_meta: array<string, mixed>,
+ *     filesize: int,
+ *     sources?: array<string, array{
+ *         file: string,
+ *         filesize: int
+ *     }>
+ * } An array with the updated structure for the metadata before is stored in the database.
  */
-function webp_uploads_create_sources_property( array $metadata, $attachment_id ) {
+function webp_uploads_create_sources_property( array $metadata, int $attachment_id ): array {
 	// This should take place only on the JPEG image.
 	$valid_mime_transforms = webp_uploads_get_upload_image_mime_transforms();
 
 	// Not a supported mime type to create the sources property.
 	$mime_type = get_post_mime_type( $attachment_id );
-	if ( ! isset( $valid_mime_transforms[ $mime_type ] ) ) {
+	if ( ! is_string( $mime_type ) || ! isset( $valid_mime_transforms[ $mime_type ] ) ) {
 		return $metadata;
 	}
 
 	$file = get_attached_file( $attachment_id, true );
 	// File does not exist.
-	if ( ! file_exists( $file ) ) {
+	if ( ! $file || ! file_exists( $file ) ) {
 		return $metadata;
 	}
 
@@ -105,7 +126,9 @@
 	if (
 		! in_array( $mime_type, $valid_mime_transforms[ $mime_type ], true ) &&
 		isset( $valid_mime_transforms[ $mime_type ][0] ) &&
-		isset( $allowed_mimes[ $mime_type ] )
+		isset( $allowed_mimes[ $mime_type ] ) &&
+		array_key_exists( 'file', $metadata ) &&
+		is_string( $metadata['file'] )
 	) {
 		$valid_mime_type = $valid_mime_transforms[ $mime_type ][0];
 
@@ -123,12 +146,17 @@
 
 			// If WordPress already modified the original itself, keep the original and discard WordPress's generated version.
 			if ( ! empty( $metadata['original_image'] ) ) {
-				$uploadpath = wp_get_upload_dir();
-				wp_delete_file_from_directory( get_attached_file( $attachment_id ), $uploadpath['basedir'] );
+				$uploadpath    = wp_get_upload_dir();
+				$attached_file = get_attached_file( $attachment_id );
+				if ( $attached_file ) {
+					wp_delete_file_from_directory( $attached_file, $uploadpath['basedir'] );
+				}
 			}
 
 			// Replace the attached file with the custom MIME type version.
-			$metadata = _wp_image_meta_replace_original( $saved_data, $original_image, $metadata, $attachment_id );
+			if ( $original_image ) {
+				$metadata = _wp_image_meta_replace_original( $saved_data, $original_image, $metadata, $attachment_id );
+			}
 
 			// Unset sources entry for the original MIME type, then save (to avoid inconsistent data
 			// in case of an error after this logic).
@@ -222,12 +250,25 @@
  *
  * @see wp_get_missing_image_subsizes()
  *
- * @param array $missing_sizes Associative array of arrays of image sub-sizes.
- * @param array $image_meta The metadata from the image.
- * @param int   $attachment_id The ID of the attachment.
- * @return array Associative array of arrays of image sub-sizes.
+ * @phpstan-param array{
+ *     width: int,
+ *     height: int,
+ *     file: string,
+ *     sizes: array<string, array{file: string, width: int, height: int, mime-type: string}>,
+ *     image_meta: array<string, mixed>,
+ *     filesize: int
+ * } $image_meta
+ *
+ * @param array|mixed          $missing_sizes Associative array of arrays of image sub-sizes.
+ * @param array<string, mixed> $image_meta    The metadata from the image.
+ * @param int                  $attachment_id The ID of the attachment.
+ * @return array<string, array{ width: int, height: int, crop: bool }> Associative array of arrays of image sub-sizes.
  */
-function webp_uploads_wp_get_missing_image_subsizes( $missing_sizes, $image_meta, $attachment_id ) {
+function webp_uploads_wp_get_missing_image_subsizes( $missing_sizes, array $image_meta, int $attachment_id ): array {
+	if ( ! is_array( $missing_sizes ) ) {
+		$missing_sizes = array();
+	}
+
 	// Only setup the trace array if we no longer have more sizes.
 	if ( ! empty( $missing_sizes ) ) {
 		return $missing_sizes;
@@ -252,7 +293,7 @@
 	$trace = debug_backtrace( DEBUG_BACKTRACE_IGNORE_ARGS, 10 );
 
 	foreach ( $trace as $element ) {
-		if ( isset( $element['function'] ) && 'wp_update_image_subsizes' === $element['function'] ) {
+		if ( 'wp_update_image_subsizes' === $element['function'] ) {
 			webp_uploads_create_sources_property( $image_meta, $attachment_id );
 			break;
 		}
@@ -269,12 +310,16 @@
  *
  * @since 1.0.0
  *
- * @param string $output_format The image editor default output format mapping.
- * @param string $filename      Path to the image.
- * @param string $mime_type     The source image mime type.
- * @return string The new output format mapping.
+ * @param array<string, string>|mixed $output_format An array of mime type mappings. Maps a source mime type to a new destination mime type. Default empty array.
+ * @param string|null                 $filename      Path to the image.
+ * @param string|null                 $mime_type     The source image mime type.
+ * @return array<string, string> The new output format mapping.
  */
-function webp_uploads_filter_image_editor_output_format( $output_format, $filename, $mime_type ) {
+function webp_uploads_filter_image_editor_output_format( $output_format, ?string $filename, ?string $mime_type ): array {
+	if ( ! is_array( $output_format ) ) {
+		$output_format = array();
+	}
+
 	// Use the original mime type if this type is allowed.
 	$valid_mime_transforms = webp_uploads_get_upload_image_mime_transforms();
 	if (
@@ -309,7 +354,7 @@
  *
  * @param int $attachment_id The ID of the attachment the sources are going to be deleted.
  */
-function webp_uploads_remove_sources_files( $attachment_id ) {
+function webp_uploads_remove_sources_files( int $attachment_id ): void {
 	$file = get_attached_file( $attachment_id );
 
 	if ( empty( $file ) ) {
@@ -352,7 +397,7 @@
 			}
 
 			$intermediate_file = str_replace( $basename, $properties['file'], $file );
-			if ( empty( $intermediate_file ) ) {
+			if ( ! $intermediate_file ) {
 				continue;
 			}
 
@@ -384,7 +429,7 @@
 		}
 
 		$full_size = str_replace( $basename, $properties['file'], $file );
-		if ( empty( $full_size ) ) {
+		if ( ! $full_size ) {
 			continue;
 		}
 
@@ -477,7 +522,7 @@
  * @param string $content The content of the current post.
  * @return string The content with the updated references to the images.
  */
-function webp_uploads_update_image_references( $content ) {
+function webp_uploads_update_image_references( string $content ): string {
 	// Bail early if request is not for the frontend.
 	if ( ! webp_uploads_in_frontend_body() ) {
 		return $content;
@@ -537,7 +582,7 @@
  * @param int    $attachment_id  The ID of the attachment being modified.
  * @return string The updated img tag.
  */
-function webp_uploads_img_tag_update_mime_type( $original_image, $context, $attachment_id ) {
+function webp_uploads_img_tag_update_mime_type( string $original_image, string $context, int $attachment_id ): string {
 	$image    = $original_image;
 	$metadata = wp_get_attachment_metadata( $attachment_id );
 
@@ -654,7 +699,7 @@
  * @param int    $attachment_id The ID of the attachment image.
  * @return string The updated HTML markup.
  */
-function webp_uploads_update_featured_image( $html, $post_id, $attachment_id ) {
+function webp_uploads_update_featured_image( string $html, int $post_id, int $attachment_id ): string {
 	return webp_uploads_img_tag_update_mime_type( $html, 'post_thumbnail_html', $attachment_id );
 }
 add_filter( 'post_thumbnail_html', 'webp_uploads_update_featured_image', 10, 3 );
@@ -664,7 +709,7 @@
  *
  * @since 1.0.0
  */
-function webp_uploads_wepb_fallback() {
+function webp_uploads_wepb_fallback(): void {
 	// Get mime type transforms for the site.
 	$transforms = webp_uploads_get_upload_image_mime_transforms();
 
@@ -697,7 +742,7 @@
 	$javascript = ob_get_clean();
 
 	wp_print_inline_script_tag(
-		preg_replace( '/\s+/', '', $javascript ),
+		(string) preg_replace( '/\s+/', '', (string) $javascript ),
 		array(
 			'id'            => 'webpUploadsFallbackWebpImages',
 			'data-rest-api' => esc_url_raw( trailingslashit( get_rest_url() ) ),
@@ -714,9 +759,9 @@
  *
  * @since 1.0.0
  *
- * @return array An array of image sizes that can have additional mime types.
+ * @return array<string, bool> An array of image sizes that can have additional mime types.
  */
-function webp_uploads_get_image_sizes_additional_mime_type_support() {
+function webp_uploads_get_image_sizes_additional_mime_type_support(): array {
 	$additional_sizes = wp_get_additional_image_sizes();
 	$allowed_sizes    = array(
 		'thumbnail'      => true,
@@ -735,9 +780,9 @@
 	 *
 	 * @since 1.0.0
 	 *
-	 * @param array $allowed_sizes A map of image size names and whether they are allowed to have additional mime types.
+	 * @param array<string, bool> $allowed_sizes A map of image size names and whether they are allowed to have additional mime types.
 	 */
-	$allowed_sizes = apply_filters( 'webp_uploads_image_sizes_with_additional_mime_type_support', $allowed_sizes );
+	$allowed_sizes = (array) apply_filters( 'webp_uploads_image_sizes_with_additional_mime_type_support', $allowed_sizes );
 
 	return $allowed_sizes;
 }
@@ -751,7 +796,7 @@
  * @param string $mime_type Image mime type.
  * @return int The updated quality for mime types.
  */
-function webp_uploads_modify_webp_quality( $quality, $mime_type ) {
+function webp_uploads_modify_webp_quality( int $quality, string $mime_type ): int {
 	// For WebP images, always return 82 (other MIME types were already using 82 by default anyway).
 	if ( 'image/webp' === $mime_type ) {
 		return 82;
@@ -769,29 +814,8 @@
  *
  * @since 1.0.0
  */
-function webp_uploads_render_generator() {
+function webp_uploads_render_generator(): void {
 	// Use the plugin slug as it is immutable.
 	echo '<meta name="generator" content="webp-uploads ' . esc_attr( WEBP_UPLOADS_VERSION ) . '">' . "\n";
 }
 add_action( 'wp_head', 'webp_uploads_render_generator' );
-
-/**
- * Adds a settings link to the plugin's action links.
- *
- * @since 1.1.0
- *
- * @param array $links An array of plugin action links.
- * @return array The modified list of actions.
- */
-function webp_uploads_settings_link( $links ) {
-	if ( ! is_array( $links ) ) {
-		return $links;
-	}
-	$links[] = sprintf(
-		'<a href="%1$s">%2$s</a>',
-		esc_url( admin_url( 'options-media.php#perflab_generate_webp_and_jpeg' ) ),
-		esc_html__( 'Settings', 'webp-uploads' )
-	);
-	return $links;
-}
-add_filter( 'plugin_action_links_' . WEBP_UPLOADS_MAIN_FILE, 'webp_uploads_settings_link' );
Index: image-edit.php
===================================================================
--- image-edit.php	(revision 3088546)
+++ image-edit.php	(working copy)
@@ -16,13 +16,36 @@
  *
  * @since 1.0.0
  *
- * @param array $metadata              Metadata of the attachment.
- * @param array $valid_mime_transforms List of valid mime transforms for current image mime type.
- * @param array $main_images           Path of all main image files of all mime types.
- * @param array $subsized_images       Path of all subsized image file of all mime types.
- * @return array Metadata with sources added.
+ * @phpstan-param array{
+ *      width: int,
+ *      height: int,
+ *      file: string,
+ *      sizes: array<string, array{ file: string, width: int, height: int, 'mime-type': string, sources?: array<string, array{ file: string, filesize: int }> }>,
+ *      image_meta: array<string, mixed>,
+ *      filesize: int,
+ *      sources?: array<string, array{ file: string, filesize: int }>,
+ *      original_image?: string
+ * } $metadata
+ * @phpstan-param array<string, array{ file: string, path: string }> $main_images
+ * @phpstan-param array<string, array<string, array{ file: string }>> $subsized_images
+ *
+ * @param array    $metadata              Metadata of the attachment.
+ * @param string[] $valid_mime_transforms List of valid mime transforms for current image mime type.
+ * @param array    $main_images           Path of all main image files of all mime types.
+ * @param array    $subsized_images       Path of all subsized image file of all mime types.
+ *
+ * @return array{
+ *     width: int,
+ *     height: int,
+ *     file: string,
+ *     sizes: array<string, array{ file: string, width: int, height: int, 'mime-type': string, sources?: array<string, array{ file: string, filesize: int }> }>,
+ *     image_meta: array<string, mixed>,
+ *     filesize: int,
+ *     original_image?: string,
+ *     sources?: array<string, array{ file: string, filesize: int }>
+ * } Metadata with sources added.
  */
-function webp_uploads_update_sources( $metadata, $valid_mime_transforms, $main_images, $subsized_images ) {
+function webp_uploads_update_sources( array $metadata, array $valid_mime_transforms, array $main_images, array $subsized_images ): array {
 	foreach ( $valid_mime_transforms as $targeted_mime ) {
 		// Make sure the path and file exists as those values are required.
 		$image_directory = null;
@@ -86,7 +109,7 @@
  *
  * @since 1.0.0
  *
- * @param bool|null       $override  Value to return instead of saving. Default null.
+ * @param bool|null|mixed $override  Value to return instead of saving. Default null.
  * @param string          $file_path Name of the file to be saved.
  * @param WP_Image_Editor $editor    The image editor instance.
  * @param string          $mime_type The mime type of the image.
@@ -93,14 +116,14 @@
  * @param int             $post_id   Attachment post ID.
  * @return bool|null Potentially modified $override value.
  */
-function webp_uploads_update_image_onchange( $override, $file_path, $editor, $mime_type, $post_id ) {
+function webp_uploads_update_image_onchange( $override, string $file_path, WP_Image_Editor $editor, string $mime_type, int $post_id ): ?bool {
 	if ( null !== $override ) {
-		return $override;
+		return (bool) $override;
 	}
 
 	$transforms = webp_uploads_get_upload_image_mime_transforms();
 	if ( empty( $transforms[ $mime_type ] ) ) {
-		return $override;
+		return null;
 	}
 
 	$mime_transforms = $transforms[ $mime_type ];
@@ -130,15 +153,19 @@
 			// phpcs:ignore WordPress.Security.NonceVerification.Recommended
 			$target = isset( $_REQUEST['target'] ) ? sanitize_key( $_REQUEST['target'] ) : 'all';
 
-			foreach ( $old_metadata['sizes'] as $size_name => $size_details ) {
-				// If the target is 'nothumb', skip generating the 'thumbnail' size.
-				if ( webp_uploads_image_edit_thumbnails_separately() && 'nothumb' === $target && 'thumbnail' === $size_name ) {
-					continue;
-				}
+			if ( isset( $old_metadata['sizes'] ) ) {
+				foreach ( $old_metadata['sizes'] as $size_name => $size_details ) {
+					// If the target is 'nothumb', skip generating the 'thumbnail' size.
+					if ( webp_uploads_image_edit_thumbnails_separately() && 'nothumb' === $target && 'thumbnail' === $size_name ) {
+						continue;
+					}
 
-				if ( isset( $metadata['sizes'][ $size_name ] ) && ! empty( $metadata['sizes'][ $size_name ] ) &&
-					$metadata['sizes'][ $size_name ]['file'] !== $old_metadata['sizes'][ $size_name ]['file'] ) {
-					$resize_sizes[ $size_name ] = $metadata['sizes'][ $size_name ];
+					if (
+						isset( $metadata['sizes'][ $size_name ] ) && ! empty( $metadata['sizes'][ $size_name ] ) &&
+						$metadata['sizes'][ $size_name ]['file'] !== $old_metadata['sizes'][ $size_name ]['file']
+					) {
+						$resize_sizes[ $size_name ] = $metadata['sizes'][ $size_name ];
+					}
 				}
 			}
 
@@ -171,7 +198,7 @@
 					continue;
 				}
 
-				if ( ! $editor::supports_mime_type( $targeted_mime ) ) {
+				if ( $editor instanceof WP_Image_Editor && ! $editor::supports_mime_type( $targeted_mime ) ) {
 					continue;
 				}
 
@@ -196,7 +223,7 @@
 					$target_file_name     = preg_replace( "/\.$current_extension$/", ".$extension", $thumbnail_file );
 					$target_file_location = path_join( $original_directory, $target_file_name );
 
-					remove_filter( 'image_editor_output_format', 'webp_uploads_filter_image_editor_output_format', 10, 3 );
+					remove_filter( 'image_editor_output_format', 'webp_uploads_filter_image_editor_output_format', 10 );
 					$result = $editor->save( $target_file_location, $targeted_mime );
 					add_filter( 'image_editor_output_format', 'webp_uploads_filter_image_editor_output_format', 10, 3 );
 
@@ -205,10 +232,10 @@
 					}
 
 					$subsized_images[ $targeted_mime ] = array( 'thumbnail' => $result );
-				} else {
+				} elseif ( $editor instanceof WP_Image_Editor ) {
 					$destination = trailingslashit( $original_directory ) . "{$filename}.{$extension}";
 
-					remove_filter( 'image_editor_output_format', 'webp_uploads_filter_image_editor_output_format', 10, 3 );
+					remove_filter( 'image_editor_output_format', 'webp_uploads_filter_image_editor_output_format', 10 );
 					$result = $editor->save( $destination, $targeted_mime );
 					add_filter( 'image_editor_output_format', 'webp_uploads_filter_image_editor_output_format', 10, 3 );
 
@@ -227,7 +254,7 @@
 		2
 	);
 
-	return $override;
+	return null;
 }
 add_filter( 'wp_save_image_editor_file', 'webp_uploads_update_image_onchange', 10, 5 );
 
@@ -240,20 +267,35 @@
  *
  * @see wp_update_attachment_metadata()
  *
- * @param array $data          The current metadata of the attachment.
- * @param int   $attachment_id The ID of the current attachment.
- * @return array The updated metadata for the attachment to be stored in the meta table.
+ * @phpstan-param array{
+ *        width: int,
+ *        height: int,
+ *        file: string,
+ *        sizes: array<string, array{ file: string, width: int, height: int, 'mime-type': string, sources?: array<string, array{ file: string, filesize: int }> }>,
+ *        image_meta: array<string, mixed>,
+ *        filesize: int,
+ *        original_image: string
+ *    } $data
+ *
+ * @param array<string, mixed> $data          The current metadata of the attachment.
+ * @param int                  $attachment_id The ID of the current attachment.
+ *
+ * @return array{
+ *     width: int,
+ *     height: int,
+ *     file: string,
+ *     sizes: array<string, array{ file: string, width: int, height: int, 'mime-type': string, sources?: array<string, array{ file: string, filesize: int }> }>,
+ *     image_meta: array<string, mixed>,
+ *     filesize: int,
+ *     original_image: string
+ * } The updated metadata for the attachment to be stored in the meta table.
  */
-function webp_uploads_update_attachment_metadata( $data, $attachment_id ) {
+function webp_uploads_update_attachment_metadata( array $data, int $attachment_id ): array {
 	// PHPCS ignore reason: Update the attachment's metadata by either restoring or editing it.
 	// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_debug_backtrace
 	$trace = debug_backtrace( DEBUG_BACKTRACE_IGNORE_ARGS, 10 );
 
 	foreach ( $trace as $element ) {
-		if ( ! isset( $element['function'] ) ) {
-			continue;
-		}
-
 		switch ( $element['function'] ) {
 			case 'wp_save_image':
 				// Right after an image has been edited.
@@ -277,11 +319,31 @@
  *
  * @since 1.0.0
  *
- * @param int   $attachment_id The ID representing the attachment.
- * @param array $data          The current metadata of the attachment.
- * @return array The updated metadata for the attachment.
+ * @phpstan-param array{
+ *       width: int,
+ *       height: int,
+ *       file: string,
+ *       sizes: array<string, array{ file: string, width: int, height: int, 'mime-type': string }>,
+ *       image_meta: array<string, mixed>,
+ *       filesize: int,
+ *       original_image: string,
+ *       sources?: array<string, array{ file: string, filesize: int }>
+ *   } $data
+ *
+ * @param int                  $attachment_id The ID representing the attachment.
+ * @param array<string, mixed> $data          The current metadata of the attachment.
+ *
+ * @return array{
+ *     width: int,
+ *     height: int,
+ *     file: string,
+ *     sizes: array<string, array{ file: string, width: int, height: int, 'mime-type': string }>,
+ *     image_meta: array<string, mixed>,
+ *     filesize: int,
+ *     original_image: string
+ * } The updated metadata for the attachment.
  */
-function webp_uploads_backup_sources( $attachment_id, $data ) {
+function webp_uploads_backup_sources( int $attachment_id, array $data ): array {
 	// PHPCS ignore reason: A nonce check is not necessary here as this logic directly ties in with WordPress core
 	// function `wp_ajax_image_editor()` which already has one.
 	// phpcs:ignore WordPress.Security.NonceVerification.Recommended
@@ -302,7 +364,7 @@
 	// Prevent execution of the callbacks more than once if the callback was already executed.
 	$has_been_processed = false;
 
-	$hook = static function ( $meta_id, $post_id, $meta_name ) use ( $attachment_id, $sources, &$has_been_processed ) {
+	$hook = static function ( $meta_id, $post_id, $meta_name ) use ( $attachment_id, $sources, &$has_been_processed ): void {
 		// Make sure this hook is only executed in the same context for the provided $attachment_id.
 		if ( $post_id !== $attachment_id ) {
 			return;
@@ -337,10 +399,10 @@
  *
  * @since 1.0.0
  *
- * @param int   $attachment_id The ID of the attachment.
- * @param array $sources       An array with the full sources to be stored on the next available key.
+ * @param int                                                 $attachment_id The ID of the attachment.
+ * @param array<string, array{ file: string, filesize: int }> $sources       An array with the full sources to be stored on the next available key.
  */
-function webp_uploads_backup_full_image_sources( $attachment_id, $sources ) {
+function webp_uploads_backup_full_image_sources( int $attachment_id, array $sources ): void {
 	if ( empty( $sources ) ) {
 		return;
 	}
@@ -367,7 +429,7 @@
  * @param int $attachment_id The ID of the attachment.
  * @return null|string The next available full size name.
  */
-function webp_uploads_get_next_full_size_key_from_backup( $attachment_id ) {
+function webp_uploads_get_next_full_size_key_from_backup( int $attachment_id ): ?string {
 	$backup_sizes = get_post_meta( $attachment_id, '_wp_attachment_backup_sizes', true );
 	$backup_sizes = is_array( $backup_sizes ) ? $backup_sizes : array();
 
@@ -401,11 +463,30 @@
  *
  * @since 1.0.0
  *
- * @param int   $attachment_id The ID of the attachment.
- * @param array $data          The current metadata to be stored in the attachment.
- * @return array The updated metadata of the attachment.
+ * @phpstan-param array{
+ *        width: int,
+ *        height: int,
+ *        file: string,
+ *        sizes: array<string, array{ file: string, width: int, height: int, 'mime-type': string }>,
+ *        image_meta: array<string, mixed>,
+ *        filesize: int,
+ *        original_image: string
+ *    } $data
+ *
+ * @param int                  $attachment_id The ID of the attachment.
+ * @param array<string, mixed> $data          The current metadata to be stored in the attachment.
+ * @return array{
+ *     width: int,
+ *     height: int,
+ *     file: string,
+ *     sizes: array<string, array{ file: string, width: int, height: int, 'mime-type': string }>,
+ *     image_meta: array<string, mixed>,
+ *     filesize: int,
+ *     sources?: array<string, array{ file: string, filesize: int }>,
+ *     original_image: string
+ * } The updated metadata of the attachment.
  */
-function webp_uploads_restore_image( $attachment_id, $data ) {
+function webp_uploads_restore_image( int $attachment_id, array $data ): array {
 	$backup_sources = get_post_meta( $attachment_id, '_wp_attachment_backup_sources', true );
 	if ( ! is_array( $backup_sources ) ) {
 		$backup_sources = array();
@@ -434,7 +515,7 @@
  *
  * @return bool True if editing image thumbnails is enabled, false otherwise.
  */
-function webp_uploads_image_edit_thumbnails_separately() {
+function webp_uploads_image_edit_thumbnails_separately(): bool {
 	/** This filter is documented in wp-admin/includes/image-edit.php */
 	return (bool) apply_filters( 'image_edit_thumbnails_separately', false );
 }
Index: load.php
===================================================================
--- load.php	(revision 3088546)
+++ load.php	(working copy)
@@ -4,8 +4,8 @@
  * Plugin URI: https://github.com/WordPress/performance/tree/trunk/plugins/webp-uploads
  * Description: Converts images to more modern formats such as WebP or AVIF during upload.
  * Requires at least: 6.4
- * Requires PHP: 7.0
- * Version: 1.1.0
+ * Requires PHP: 7.2
+ * Version: 1.1.1
  * Author: WordPress Performance Team
  * Author URI: https://make.wordpress.org/performance/
  * License: GPLv2 or later
@@ -25,7 +25,7 @@
 	return;
 }
 
-define( 'WEBP_UPLOADS_VERSION', '1.1.0' );
+define( 'WEBP_UPLOADS_VERSION', '1.1.1' );
 define( 'WEBP_UPLOADS_MAIN_FILE', plugin_basename( __FILE__ ) );
 
 require_once __DIR__ . '/helper.php';
@@ -33,3 +33,4 @@
 require_once __DIR__ . '/image-edit.php';
 require_once __DIR__ . '/settings.php';
 require_once __DIR__ . '/hooks.php';
+require_once __DIR__ . '/deprecated.php';
Index: readme.txt
===================================================================
--- readme.txt	(revision 3088546)
+++ readme.txt	(working copy)
@@ -3,8 +3,8 @@
 Contributors:      wordpressdotorg
 Requires at least: 6.4
 Tested up to:      6.5
-Requires PHP:      7.0
-Stable tag:        1.1.0
+Requires PHP:      7.2
+Stable tag:        1.1.1
 License:           GPLv2 or later
 License URI:       https://www.gnu.org/licenses/gpl-2.0.html
 Tags:              performance, images, webp
@@ -60,6 +60,17 @@
 
 == Changelog ==
 
+= 1.1.1 =
+
+**Enhancements**
+
+* Prepend Settings link in webp-uploads. ([1146](https://github.com/WordPress/performance/pull/1146))
+* Improve overall code quality with stricter static analysis checks. ([775](https://github.com/WordPress/performance/issues/775))
+
+**Documentation**
+
+* Updated inline documentation. ([1160](https://github.com/WordPress/performance/pull/1160))
+
 = 1.1.0 =
 
 * Add link to WebP settings to plugins table. ([1036](https://github.com/WordPress/performance/pull/1036))
Index: rest-api.php
===================================================================
--- rest-api.php	(revision 3088546)
+++ rest-api.php	(working copy)
@@ -20,7 +20,7 @@
  * @param WP_Post          $post     The post object.
  * @return WP_REST_Response A new response object for the attachment with additional sources.
  */
-function webp_uploads_update_rest_attachment( WP_REST_Response $response, WP_Post $post ) {
+function webp_uploads_update_rest_attachment( WP_REST_Response $response, WP_Post $post ): WP_REST_Response {
 	$data = $response->get_data();
 	if ( ! isset( $data['media_details'] ) || ! is_array( $data['media_details'] ) || ! isset( $data['media_details']['sizes'] ) || ! is_array( $data['media_details']['sizes'] ) ) {
 		return $response;
@@ -49,6 +49,6 @@
 		unset( $data['media_details']['sources'] );
 	}
 
-	return rest_ensure_response( $data );
+	return new WP_REST_Response( $data );
 }
 add_filter( 'rest_prepare_attachment', 'webp_uploads_update_rest_attachment', 10, 2 );
Index: settings.php
===================================================================
--- settings.php	(revision 3088546)
+++ settings.php	(working copy)
@@ -16,7 +16,7 @@
  *
  * @since 1.0.0
  */
-function webp_uploads_register_media_settings_field() {
+function webp_uploads_register_media_settings_field(): void {
 	register_setting(
 		'media',
 		'perflab_generate_webp_and_jpeg',
@@ -34,7 +34,7 @@
  *
  * @since 1.0.0
  */
-function webp_uploads_add_media_settings_field() {
+function webp_uploads_add_media_settings_field(): void {
 	// Add settings field.
 	add_settings_field(
 		'perflab_generate_webp_and_jpeg',
@@ -52,7 +52,7 @@
  *
  * @since 1.0.0
  */
-function webp_uploads_generate_webp_jpeg_setting_callback() {
+function webp_uploads_generate_webp_jpeg_setting_callback(): void {
 	if ( ! is_multisite() ) {
 		?>
 			</td>
@@ -69,11 +69,11 @@
 }
 
 /**
- * Adds custom style for media settings.
+ * Adds custom styles to hide specific elements in media settings.
  *
  * @since 1.0.0
  */
-function webp_uploads_media_setting_style() {
+function webp_uploads_media_setting_style(): void {
 	if ( is_multisite() ) {
 		return;
 	}
@@ -87,3 +87,30 @@
 	<?php
 }
 add_action( 'admin_head-options-media.php', 'webp_uploads_media_setting_style' );
+
+/**
+ * Adds a settings link to the plugin's action links.
+ *
+ * @since 1.1.0
+ * @since 1.1.1 Renamed from webp_uploads_settings_link() to webp_uploads_add_settings_action_link()
+ *
+ * @param string[]|mixed $links An array of plugin action links.
+ * @return string[]|mixed The modified list of actions.
+ */
+function webp_uploads_add_settings_action_link( $links ) {
+	if ( ! is_array( $links ) ) {
+		return $links;
+	}
+
+	$settings_link = sprintf(
+		'<a href="%1$s">%2$s</a>',
+		esc_url( admin_url( 'options-media.php#perflab_generate_webp_and_jpeg' ) ),
+		esc_html__( 'Settings', 'webp-uploads' )
+	);
+
+	return array_merge(
+		array( 'settings' => $settings_link ),
+		$links
+	);
+}
+add_filter( 'plugin_action_links_' . WEBP_UPLOADS_MAIN_FILE, 'webp_uploads_add_settings_action_link' );
Index: uninstall.php
===================================================================
--- uninstall.php	(revision 3088546)
+++ uninstall.php	(working copy)
@@ -36,6 +36,6 @@
  *
  * @since 1.1.0
  */
-function webp_uploads_delete_plugin_option() {
+function webp_uploads_delete_plugin_option(): void {
 	delete_option( 'perflab_generate_webp_and_jpeg' );
 }

performance-lab

svn status:

M       includes/admin/load.php
M       includes/admin/plugins.php
M       includes/admin/server-timing.php
M       includes/server-timing/class-perflab-server-timing-metric.php
M       includes/server-timing/class-perflab-server-timing.php
M       includes/server-timing/defaults.php
?       includes/server-timing/hooks.php
M       includes/server-timing/load.php
M       includes/server-timing/object-cache.copy.php
M       includes/site-health/audit-autoloaded-options/helper.php
M       includes/site-health/audit-autoloaded-options/hooks.php
M       includes/site-health/audit-enqueued-assets/helper.php
M       includes/site-health/audit-enqueued-assets/hooks.php
?       includes/site-health/avif-support
M       includes/site-health/load.php
M       includes/site-health/webp-support/helper.php
M       includes/site-health/webp-support/hooks.php
M       load.php
!       plugins.json
M       readme.txt
svn diff
Index: includes/admin/load.php
===================================================================
--- includes/admin/load.php	(revision 3088546)
+++ includes/admin/load.php	(working copy)
@@ -15,7 +15,7 @@
  * @since 1.0.0
  * @since 3.0.0 Renamed to perflab_add_features_page().
  */
-function perflab_add_features_page() {
+function perflab_add_features_page(): void {
 	$hook_suffix = add_options_page(
 		__( 'Performance Features', 'performance-lab' ),
 		__( 'Performance', 'performance-lab' ),
@@ -29,9 +29,8 @@
 		add_action( "load-{$hook_suffix}", 'perflab_load_features_page', 10, 0 );
 		add_filter( 'plugin_action_links_' . plugin_basename( PERFLAB_MAIN_FILE ), 'perflab_plugin_action_links_add_settings' );
 	}
+}
 
-	return $hook_suffix;
-}
 add_action( 'admin_menu', 'perflab_add_features_page' );
 
 /**
@@ -41,7 +40,7 @@
  * @since 3.0.0 Renamed to perflab_load_features_page(), and the
  *              $module and $hook_suffix parameters were removed.
  */
-function perflab_load_features_page() {
+function perflab_load_features_page(): void {
 	// Handle script enqueuing for settings page.
 	add_action( 'admin_enqueue_scripts', 'perflab_enqueue_features_page_scripts' );
 
@@ -50,6 +49,9 @@
 
 	// Handle style for settings page.
 	add_action( 'admin_head', 'perflab_print_features_page_style' );
+
+	// Handle script for settings page.
+	add_action( 'admin_footer', 'perflab_print_plugin_progress_indicator_script' );
 }
 
 /**
@@ -58,7 +60,7 @@
  * @since 1.0.0
  * @since 3.0.0 Renamed to perflab_render_settings_page().
  */
-function perflab_render_settings_page() {
+function perflab_render_settings_page(): void {
 	?>
 	<div class="wrap">
 		<?php perflab_render_plugins_ui(); ?>
@@ -76,23 +78,29 @@
  *
  * @param string $hook_suffix The current admin page.
  */
-function perflab_admin_pointer( $hook_suffix ) {
-	if ( ! in_array( $hook_suffix, array( 'index.php', 'plugins.php' ), true ) ) {
-		return;
-	}
-
+function perflab_admin_pointer( string $hook_suffix ): void {
 	// Do not show admin pointer in multisite Network admin or User admin UI.
 	if ( is_network_admin() || is_user_admin() ) {
 		return;
 	}
-
 	$current_user = get_current_user_id();
-	$dismissed    = explode( ',', (string) get_user_meta( $current_user, 'dismissed_wp_pointers', true ) );
+	$dismissed    = array_filter( explode( ',', (string) get_user_meta( get_current_user_id(), 'dismissed_wp_pointers', true ) ) );
 
 	if ( in_array( 'perflab-admin-pointer', $dismissed, true ) ) {
 		return;
 	}
 
+	if ( ! in_array( $hook_suffix, array( 'index.php', 'plugins.php' ), true ) ) {
+
+		// Do not show on the settings page and dismiss the pointer.
+		if ( isset( $_GET['page'] ) && PERFLAB_SCREEN === $_GET['page'] && ( ! in_array( 'perflab-admin-pointer', $dismissed, true ) ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
+			$dismissed[] = 'perflab-admin-pointer';
+			update_user_meta( $current_user, 'dismissed_wp_pointers', implode( ',', $dismissed ) );
+		}
+
+		return;
+	}
+
 	// Enqueue pointer CSS and JS.
 	wp_enqueue_style( 'wp-pointer' );
 	wp_enqueue_script( 'wp-pointer' );
@@ -108,11 +116,11 @@
  * @since 1.0.0
  * @since 2.4.0 Optional arguments were added to make the function reusable for different pointers.
  *
- * @param string $pointer_id Optional. ID of the pointer. Default 'perflab-admin-pointer'.
- * @param array  $args       Optional. Pointer arguments. Supports 'heading' and 'content' entries.
- *                           Defaults are the heading and content for the 'perflab-admin-pointer'.
+ * @param string                                    $pointer_id Optional. ID of the pointer. Default 'perflab-admin-pointer'.
+ * @param array{heading?: string, content?: string} $args       Optional. Pointer arguments. Supports 'heading' and 'content' entries.
+ *                                                              Defaults are the heading and content for the 'perflab-admin-pointer'.
  */
-function perflab_render_pointer( $pointer_id = 'perflab-admin-pointer', $args = array() ) {
+function perflab_render_pointer( string $pointer_id = 'perflab-admin-pointer', array $args = array() ): void {
 	if ( ! isset( $args['heading'] ) ) {
 		$args['heading'] = __( 'Performance Lab', 'performance-lab' );
 	}
@@ -169,10 +177,14 @@
  *
  * @see perflab_add_features_page()
  *
- * @param array $links List of plugin action links HTML.
- * @return array Modified list of plugin action links HTML.
+ * @param string[]|mixed $links List of plugin action links HTML.
+ * @return string[]|mixed Modified list of plugin action links HTML.
  */
 function perflab_plugin_action_links_add_settings( $links ) {
+	if ( ! is_array( $links ) ) {
+		return $links;
+	}
+
 	// Add link as the first plugin action link.
 	$settings_link = sprintf(
 		'<a href="%s">%s</a>',
@@ -179,9 +191,11 @@
 		esc_url( add_query_arg( 'page', PERFLAB_SCREEN, admin_url( 'options-general.php' ) ) ),
 		esc_html__( 'Settings', 'performance-lab' )
 	);
-	array_unshift( $links, $settings_link );
 
-	return $links;
+	return array_merge(
+		array( 'settings' => $settings_link ),
+		$links
+	);
 }
 
 /**
@@ -192,7 +206,7 @@
  *
  * @since 2.3.0
  */
-function perflab_dismiss_wp_pointer_wrapper() {
+function perflab_dismiss_wp_pointer_wrapper(): void {
 	if ( isset( $_POST['pointer'] ) && 'perflab-admin-pointer' !== $_POST['pointer'] ) {
 		// Another plugin's pointer, do nothing.
 		return;
@@ -207,21 +221,41 @@
  * @since 2.8.0
  * @since 3.0.0 Renamed to perflab_enqueue_features_page_scripts().
  */
-function perflab_enqueue_features_page_scripts() {
+function perflab_enqueue_features_page_scripts(): void {
 	// These assets are needed for the "Learn more" popover.
 	wp_enqueue_script( 'thickbox' );
 	wp_enqueue_style( 'thickbox' );
 	wp_enqueue_script( 'plugin-install' );
+
+	// Enqueue the a11y script.
+	wp_enqueue_script( 'wp-a11y' );
 }
 
 /**
+ * Sanitizes a plugin slug.
+ *
+ * @since 3.1.0
+ *
+ * @param mixed $unsanitized_plugin_slug Unsanitized plugin slug.
+ * @return string|null Validated and sanitized slug or else null.
+ */
+function perflab_sanitize_plugin_slug( $unsanitized_plugin_slug ): ?string {
+	if ( in_array( $unsanitized_plugin_slug, perflab_get_standalone_plugins(), true ) ) {
+		return $unsanitized_plugin_slug;
+	} else {
+		return null;
+	}
+}
+
+/**
  * Callback for handling installation/activation of plugin.
  *
  * @since 3.0.0
  */
-function perflab_install_activate_plugin_callback() {
+function perflab_install_activate_plugin_callback(): void {
 	check_admin_referer( 'perflab_install_activate_plugin' );
 
+	require_once ABSPATH . 'wp-admin/includes/plugin.php';
 	require_once ABSPATH . 'wp-admin/includes/plugin-install.php';
 	require_once ABSPATH . 'wp-admin/includes/class-wp-upgrader.php';
 	require_once ABSPATH . 'wp-admin/includes/class-wp-ajax-upgrader-skin.php';
@@ -230,75 +264,21 @@
 		wp_die( esc_html__( 'Missing required parameter.', 'performance-lab' ) );
 	}
 
-	$plugin_slug = sanitize_text_field( wp_unslash( $_GET['slug'] ) );
-
+	$plugin_slug = perflab_sanitize_plugin_slug( wp_unslash( $_GET['slug'] ) );
 	if ( ! $plugin_slug ) {
 		wp_die( esc_html__( 'Invalid plugin.', 'performance-lab' ) );
 	}
 
-	$is_plugin_installed = isset( $_GET['file'] ) && $_GET['file'];
-
-	// Install the plugin if it is not installed yet.
-	if ( ! $is_plugin_installed ) {
-		// Check if the user have plugin installation capability.
-		if ( ! current_user_can( 'install_plugins' ) ) {
-			wp_die( esc_html__( 'Sorry, you are not allowed to install plugins on this site.', 'default' ) );
-		}
-
-		$api = perflab_query_plugin_info( $plugin_slug );
-
-		// Return early if plugin API returns an error.
-		if ( ! $api ) {
-			wp_die(
-				wp_kses(
-					sprintf(
-						/* translators: %s: Support forums URL. */
-						__( 'An unexpected error occurred. Something may be wrong with WordPress.org or this server&#8217;s configuration. If you continue to have problems, please try the <a href="%s">support forums</a>.', 'default' ),
-						__( 'https://wordpress.org/support/forums/', 'default' )
-					),
-					array( 'a' => array( 'href' => true ) )
-				)
-			);
-		}
-
-		// Replace new Plugin_Installer_Skin with new Quiet_Upgrader_Skin when output needs to be suppressed.
-		$skin     = new WP_Ajax_Upgrader_Skin( array( 'api' => $api ) );
-		$upgrader = new Plugin_Upgrader( $skin );
-		$result   = $upgrader->install( $api['download_link'] );
-
-		if ( is_wp_error( $result ) ) {
-			wp_die( esc_html( $result->get_error_message() ) );
-		} elseif ( is_wp_error( $skin->result ) ) {
-			wp_die( esc_html( $skin->result->get_error_message() ) );
-		} elseif ( $skin->get_errors()->has_errors() ) {
-			wp_die( esc_html( $skin->get_error_messages() ) );
-		}
-
-		$plugins = get_plugins( '/' . $plugin_slug );
-
-		if ( empty( $plugins ) ) {
-			wp_die( esc_html__( 'Plugin not found.', 'default' ) );
-		}
-
-		$plugin_file_names = array_keys( $plugins );
-		$plugin_basename   = $plugin_slug . '/' . $plugin_file_names[0];
-	} else {
-		$plugin_basename = sanitize_text_field( wp_unslash( $_GET['file'] ) );
+	// Install and activate the plugin and its dependencies.
+	$result = perflab_install_and_activate_plugin( $plugin_slug );
+	if ( $result instanceof WP_Error ) {
+		wp_die( wp_kses_post( $result->get_error_message() ) );
 	}
 
-	if ( ! current_user_can( 'activate_plugin', $plugin_basename ) ) {
-		wp_die( esc_html__( 'Sorry, you are not allowed to activate this plugin.', 'default' ) );
-	}
-
-	$result = activate_plugin( $plugin_basename );
-	if ( is_wp_error( $result ) ) {
-		wp_die( esc_html( $result->get_error_message() ) );
-	}
-
 	$url = add_query_arg(
 		array(
 			'page'     => PERFLAB_SCREEN,
-			'activate' => 'true',
+			'activate' => $plugin_slug,
 		),
 		admin_url( 'options-general.php' )
 	);
@@ -314,9 +294,9 @@
  *
  * @since 3.0.0
  */
-function perflab_print_features_page_style() {
+function perflab_print_features_page_style(): void {
 	?>
-<style type="text/css">
+<style>
 	.plugin-card .name,
 	.plugin-card .desc, /* For WP <6.5 versions */
 	.plugin-card .desc > p {
@@ -323,12 +303,31 @@
 		margin-left: 0;
 	}
 	.plugin-card-top {
-		min-height: auto;
+		/* This is required to ensure the Settings link does not extend below the bottom of a plugin card on a wide screen. */
+		min-height: 90px;
 	}
+	@media screen and (max-width: 782px) {
+		.plugin-card-top {
+			/* Same reason as above, but now the button is taller to make it easier to tap on touch screens. */
+			min-height: 110px;
+		}
+	}
 	.plugin-card .perflab-plugin-experimental {
 		font-size: 80%;
 		font-weight: normal;
 	}
+
+	@media screen and (max-width: 1100px) and (min-width: 782px), (max-width: 480px) {
+		.plugin-card .action-links {
+			margin-left: auto;
+		}
+		/* Make sure the settings link gets spaced out from the Learn more link. */
+		.plugin-card .plugin-action-buttons > li:nth-child(3) {
+			margin-left: 2ex;
+			border-left: solid 1px;
+			padding-left: 2ex;
+		}
+	}
 </style>
 	<?php
 }
@@ -338,20 +337,56 @@
  *
  * @since 2.8.0
  */
-function perflab_plugin_admin_notices() {
+function perflab_plugin_admin_notices(): void {
 	if ( ! current_user_can( 'install_plugins' ) ) {
-		wp_admin_notice(
-			esc_html__( 'Due to your site\'s configuration, you may not be able to activate the performance features, unless the underlying plugin is already installed. Please install the relevant plugins manually.', 'performance-lab' ),
-			array(
-				'type' => 'warning',
-			)
+		$are_all_plugins_installed = true;
+		$installed_plugin_slugs    = array_map(
+			static function ( $name ) {
+				return strtok( $name, '/' );
+			},
+			array_keys( get_plugins() )
 		);
-		return;
+		foreach ( perflab_get_standalone_plugin_version_constants() as $plugin_slug => $constant_name ) {
+			if ( ! in_array( $plugin_slug, $installed_plugin_slugs, true ) ) {
+				$are_all_plugins_installed = false;
+				break;
+			}
+		}
+
+		if ( ! $are_all_plugins_installed ) {
+			wp_admin_notice(
+				esc_html__( 'Due to your site\'s configuration, you may not be able to activate the performance features, unless the underlying plugin is already installed. Please install the relevant plugins manually.', 'performance-lab' ),
+				array(
+					'type' => 'warning',
+				)
+			);
+			return;
+		}
 	}
 
+	$activated_plugin_slug = null;
 	if ( isset( $_GET['activate'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
+		$activated_plugin_slug = perflab_sanitize_plugin_slug( wp_unslash( $_GET['activate'] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended
+	}
+
+	if ( $activated_plugin_slug ) {
+		$message = __( 'Feature activated.', 'performance-lab' );
+
+		$plugin_settings_url = perflab_get_plugin_settings_url( $activated_plugin_slug );
+		if ( $plugin_settings_url ) {
+			/* translators: %s is the settings URL */
+			$message .= ' ' . sprintf( __( 'Review <a href="%s">settings</a>.', 'performance-lab' ), esc_url( $plugin_settings_url ) );
+		}
+
 		wp_admin_notice(
-			esc_html__( 'Feature activated.', 'performance-lab' ),
+			wp_kses(
+				$message,
+				array(
+					'a' => array(
+						'href' => array(),
+					),
+				)
+			),
 			array(
 				'type'        => 'success',
 				'dismissible' => true,
@@ -359,3 +394,80 @@
 		);
 	}
 }
+
+/**
+ * Callback function that print plugin progress indicator script.
+ *
+ * @since 3.1.0
+ */
+function perflab_print_plugin_progress_indicator_script(): void {
+	$js_function = <<<JS
+		function addPluginProgressIndicator( message ) {
+			document.addEventListener( 'DOMContentLoaded', function () {
+				document.addEventListener( 'click', function ( event ) {
+					if (
+						event.target.classList.contains(
+							'perflab-install-active-plugin'
+						)
+					) {
+						const target = event.target;
+						target.classList.add( 'updating-message' );
+						target.textContent = message;
+
+						wp.a11y.speak( message );
+					}
+				} );
+			} );
+		}
+JS;
+
+	wp_print_inline_script_tag(
+		sprintf(
+			'( %s )( %s );',
+			$js_function,
+			wp_json_encode( __( 'Activating...', 'default' ) )
+		),
+		array( 'type' => 'module' )
+	);
+}
+
+/**
+ * Gets the URL to the plugin settings screen if one exists.
+ *
+ * @since 3.1.0
+ *
+ * @param string $plugin_slug Plugin slug passed to generate the settings link.
+ * @return string|null Either the plugin settings URL or null if not available.
+ */
+function perflab_get_plugin_settings_url( string $plugin_slug ): ?string {
+	$plugin_file = null;
+
+	foreach ( array_keys( get_plugins() ) as $file ) {
+		if ( strtok( $file, '/' ) === $plugin_slug ) {
+			$plugin_file = $file;
+			break;
+		}
+	}
+
+	if ( null === $plugin_file ) {
+		return null;
+	}
+
+	/** This filter is documented in wp-admin/includes/class-wp-plugins-list-table.php */
+	$plugin_links = apply_filters( "plugin_action_links_{$plugin_file}", array() );
+
+	if ( ! is_array( $plugin_links ) || ! array_key_exists( 'settings', $plugin_links ) ) {
+		return null;
+	}
+
+	$p = new WP_HTML_Tag_Processor( $plugin_links['settings'] );
+	if ( ! $p->next_tag( array( 'tag_name' => 'A' ) ) ) {
+		return null;
+	}
+	$href = $p->get_attribute( 'href' );
+	if ( $href && is_string( $href ) ) {
+		return $href;
+	}
+
+	return null;
+}
Index: includes/admin/plugins.php
===================================================================
--- includes/admin/plugins.php	(revision 3088546)
+++ includes/admin/plugins.php	(working copy)
@@ -3,6 +3,7 @@
  * Admin settings helper functions.
  *
  * @package performance-lab
+ * @noinspection PhpRedundantOptionalArgumentInspection
  */
 
 if ( ! defined( 'ABSPATH' ) ) {
@@ -15,7 +16,7 @@
  * @since 2.8.0
  *
  * @param string $plugin_slug The string identifier for the plugin in questions slug.
- * @return array Array of plugin data, or empty if none/error.
+ * @return array{name: string, slug: string, short_description: string, requires: string|false, requires_php: string|false, requires_plugins: string[], download_link: string, version: string}|WP_Error Array of plugin data or WP_Error if failed.
  */
 function perflab_query_plugin_info( string $plugin_slug ) {
 	$plugin = get_transient( 'perflab_plugin_info_' . $plugin_slug );
@@ -24,19 +25,27 @@
 		return $plugin;
 	}
 
+	$fields = array(
+		'name',
+		'slug',
+		'short_description',
+		'requires',
+		'requires_php',
+		'requires_plugins',
+		'download_link',
+		'version', // Needed by install_plugin_install_status().
+	);
+
 	$plugin = plugins_api(
 		'plugin_information',
 		array(
 			'slug'   => $plugin_slug,
-			'fields' => array(
-				'short_description' => true,
-				'icons'             => true,
-			),
+			'fields' => array_fill_keys( $fields, true ),
 		)
 	);
 
 	if ( is_wp_error( $plugin ) ) {
-		return array();
+		return $plugin;
 	}
 
 	if ( is_object( $plugin ) ) {
@@ -43,8 +52,19 @@
 		$plugin = (array) $plugin;
 	}
 
+	// Only store what we need.
+	$plugin = wp_array_slice_assoc( $plugin, $fields );
+
+	// Make sure all fields default to false in case another plugin is modifying the response from WordPress.org via the plugins_api filter.
+	$plugin = array_merge( array_fill_keys( $fields, false ), $plugin );
+
 	set_transient( 'perflab_plugin_info_' . $plugin_slug, $plugin, HOUR_IN_SECONDS );
 
+	/**
+	 * Validated (mostly) plugin data.
+	 *
+	 * @var array{name: string, slug: string, short_description: string, requires: string|false, requires_php: string|false, requires_plugins: string[], download_link: string, version: string} $plugin
+	 */
 	return $plugin;
 }
 
@@ -53,9 +73,9 @@
  *
  * @since 2.8.0
  *
- * @return array List of WPP standalone plugins as slugs.
+ * @return string[] List of WPP standalone plugins as slugs.
  */
-function perflab_get_standalone_plugins() {
+function perflab_get_standalone_plugins(): array {
 	return array_keys(
 		perflab_get_standalone_plugin_data()
 	);
@@ -66,7 +86,7 @@
  *
  * @since 2.8.0
  */
-function perflab_render_plugins_ui() {
+function perflab_render_plugins_ui(): void {
 	require_once ABSPATH . 'wp-admin/includes/plugin-install.php';
 	require_once ABSPATH . 'wp-admin/includes/plugin.php';
 
@@ -74,13 +94,34 @@
 	$experimental_plugins = array();
 
 	foreach ( perflab_get_standalone_plugin_data() as $plugin_slug => $plugin_data ) {
+		$api_data = perflab_query_plugin_info( $plugin_slug ); // Data from wordpress.org.
+
+		// Skip if the plugin is not on WordPress.org or there was a network error.
+		if ( $api_data instanceof WP_Error ) {
+			wp_admin_notice(
+				esc_html(
+					sprintf(
+						/* translators: 1: plugin slug. 2: error message. */
+						__( 'Failed to query WordPress.org Plugin Directory for plugin "%1$s". %2$s', 'performance-lab' ),
+						$plugin_slug,
+						$api_data->get_error_message()
+					)
+				),
+				array( 'type' => 'error' )
+			);
+			continue;
+		}
+
 		$plugin_data = array_merge(
+			array(
+				'experimental' => false,
+			),
 			$plugin_data, // Data defined within Performance Lab.
-			perflab_query_plugin_info( $plugin_slug ) // Data from wordpress.org.
+			$api_data
 		);
 
 		// Separate experimental plugins so that they're displayed after non-experimental plugins.
-		if ( isset( $plugin_data['experimental'] ) && $plugin_data['experimental'] ) {
+		if ( $plugin_data['experimental'] ) {
 			$experimental_plugins[ $plugin_slug ] = $plugin_data;
 		} else {
 			$plugins[ $plugin_slug ] = $plugin_data;
@@ -87,7 +128,7 @@
 		}
 	}
 
-	if ( empty( $plugins ) ) {
+	if ( ! $plugins && ! $experimental_plugins ) {
 		return;
 	}
 	?>
@@ -116,6 +157,148 @@
 }
 
 /**
+ * Checks if a given plugin is available.
+ *
+ * @since 3.1.0
+ * @see perflab_install_and_activate_plugin()
+ *
+ * @param array{name: string, slug: string, short_description: string, requires_php: string|false, requires: string|false, requires_plugins: string[], version: string} $plugin_data                     Plugin data from the WordPress.org API.
+ * @param array<string, array{compatible_php: bool, compatible_wp: bool, can_install: bool, can_activate: bool, activated: bool, installed: bool}>                      $processed_plugin_availabilities Plugin availabilities already processed. This param is only used by recursive calls.
+ * @return array{compatible_php: bool, compatible_wp: bool, can_install: bool, can_activate: bool, activated: bool, installed: bool} Availability.
+ */
+function perflab_get_plugin_availability( array $plugin_data, array &$processed_plugin_availabilities = array() ): array {
+	if ( array_key_exists( $plugin_data['slug'], $processed_plugin_availabilities ) ) {
+		// Prevent infinite recursion by returning the previously-computed value.
+		return $processed_plugin_availabilities[ $plugin_data['slug'] ];
+	}
+
+	$availability = array(
+		'compatible_php' => (
+			! $plugin_data['requires_php'] ||
+			is_php_version_compatible( $plugin_data['requires_php'] )
+		),
+		'compatible_wp'  => (
+			! $plugin_data['requires'] ||
+			is_wp_version_compatible( $plugin_data['requires'] )
+		),
+	);
+
+	$plugin_status = install_plugin_install_status( $plugin_data );
+
+	$availability['installed'] = ( 'install' !== $plugin_status['status'] );
+	$availability['activated'] = $plugin_status['file'] && is_plugin_active( $plugin_status['file'] );
+
+	// The plugin is already installed or the user can install plugins.
+	$availability['can_install'] = (
+		$availability['installed'] ||
+		current_user_can( 'install_plugins' )
+	);
+
+	// The plugin is activated or the user can activate plugins.
+	$availability['can_activate'] = (
+		$availability['activated'] ||
+		$plugin_status['file'] // When not false, the plugin is installed.
+			? current_user_can( 'activate_plugin', $plugin_status['file'] )
+			: current_user_can( 'activate_plugins' )
+	);
+
+	// Store pending availability before recursing.
+	$processed_plugin_availabilities[ $plugin_data['slug'] ] = $availability;
+
+	foreach ( $plugin_data['requires_plugins'] as $requires_plugin ) {
+		$dependency_plugin_data = perflab_query_plugin_info( $requires_plugin );
+		if ( $dependency_plugin_data instanceof WP_Error ) {
+			continue;
+		}
+
+		$dependency_availability = perflab_get_plugin_availability( $dependency_plugin_data );
+		foreach ( array( 'compatible_php', 'compatible_wp', 'can_install', 'can_activate', 'installed', 'activated' ) as $key ) {
+			$availability[ $key ] = $availability[ $key ] && $dependency_availability[ $key ];
+		}
+	}
+
+	$processed_plugin_availabilities[ $plugin_data['slug'] ] = $availability;
+	return $availability;
+}
+
+/**
+ * Installs and activates a plugin by its slug.
+ *
+ * Dependencies are recursively installed and activated as well.
+ *
+ * @since 3.1.0
+ * @see perflab_get_plugin_availability()
+ *
+ * @param string   $plugin_slug       Plugin slug.
+ * @param string[] $processed_plugins Slugs for plugins which have already been processed. This param is only used by recursive calls.
+ * @return WP_Error|null WP_Error on failure.
+ */
+function perflab_install_and_activate_plugin( string $plugin_slug, array &$processed_plugins = array() ): ?WP_Error {
+	if ( in_array( $plugin_slug, $processed_plugins, true ) ) {
+		// Prevent infinite recursion from possible circular dependency.
+		return null;
+	}
+	$processed_plugins[] = $plugin_slug;
+
+	$plugin_data = perflab_query_plugin_info( $plugin_slug );
+	if ( $plugin_data instanceof WP_Error ) {
+		return $plugin_data;
+	}
+
+	// Install and activate plugin dependencies first.
+	foreach ( $plugin_data['requires_plugins'] as $requires_plugin_slug ) {
+		$result = perflab_install_and_activate_plugin( $requires_plugin_slug );
+		if ( $result instanceof WP_Error ) {
+			return $result;
+		}
+	}
+
+	// Install the plugin.
+	$plugin_status = install_plugin_install_status( $plugin_data );
+	$plugin_file   = $plugin_status['file'];
+	if ( 'install' === $plugin_status['status'] ) {
+		if ( ! current_user_can( 'install_plugins' ) ) {
+			return new WP_Error( 'cannot_install_plugin', __( 'Sorry, you are not allowed to install plugins on this site.', 'default' ) );
+		}
+
+		// Replace new Plugin_Installer_Skin with new Quiet_Upgrader_Skin when output needs to be suppressed.
+		$skin     = new WP_Ajax_Upgrader_Skin( array( 'api' => $plugin_data ) );
+		$upgrader = new Plugin_Upgrader( $skin );
+		$result   = $upgrader->install( $plugin_data['download_link'] );
+
+		if ( is_wp_error( $result ) ) {
+			return $result;
+		} elseif ( is_wp_error( $skin->result ) ) {
+			return $skin->result;
+		} elseif ( $skin->get_errors()->has_errors() ) {
+			return $skin->get_errors();
+		}
+
+		$plugins = get_plugins( '/' . $plugin_slug );
+		if ( empty( $plugins ) ) {
+			return new WP_Error( 'plugin_not_found', __( 'Plugin not found.', 'default' ) );
+		}
+
+		$plugin_file_names = array_keys( $plugins );
+		$plugin_file       = $plugin_slug . '/' . $plugin_file_names[0];
+	}
+
+	// Activate the plugin.
+	if ( ! is_plugin_active( $plugin_file ) ) {
+		if ( ! current_user_can( 'activate_plugin', $plugin_file ) ) {
+			return new WP_Error( 'cannot_activate_plugin', __( 'Sorry, you are not allowed to activate this plugin.', 'default' ) );
+		}
+
+		$result = activate_plugin( $plugin_file );
+		if ( $result instanceof WP_Error ) {
+			return $result;
+		}
+	}
+
+	return null;
+}
+
+/**
  * Renders individual plugin cards.
  *
  * This is adapted from `WP_Plugin_Install_List_Table::display_rows()` in core.
@@ -125,48 +308,32 @@
  * @see WP_Plugin_Install_List_Table::display_rows()
  * @link https://github.com/WordPress/wordpress-develop/blob/0b8ca16ea3bd9722bd1a38f8ab68901506b1a0e7/src/wp-admin/includes/class-wp-plugin-install-list-table.php#L467-L830
  *
- * @param array $plugin_data Plugin data from the WordPress.org API.
+ * @param array{name: string, slug: string, short_description: string, requires_php: string|false, requires: string|false, requires_plugins: string[], version: string, experimental: bool} $plugin_data Plugin data augmenting data from the WordPress.org API.
  */
-function perflab_render_plugin_card( array $plugin_data ) {
-	// If no plugin data is returned, return.
-	if ( empty( $plugin_data ) ) {
-		return;
-	}
+function perflab_render_plugin_card( array $plugin_data ): void {
 
-	// Remove any HTML from the description.
+	$name        = wp_strip_all_tags( $plugin_data['name'] );
 	$description = wp_strip_all_tags( $plugin_data['short_description'] );
-	$title       = $plugin_data['name'];
 
 	/** This filter is documented in wp-admin/includes/class-wp-plugin-install-list-table.php */
 	$description = apply_filters( 'plugin_install_description', $description, $plugin_data );
-	$version     = $plugin_data['version'];
-	$name        = wp_strip_all_tags( $title . ' ' . $version );
 
-	$requires_php = isset( $plugin_data['requires_php'] ) ? $plugin_data['requires_php'] : null;
-	$requires_wp  = isset( $plugin_data['requires'] ) ? $plugin_data['requires'] : null;
+	$availability   = perflab_get_plugin_availability( $plugin_data );
+	$compatible_php = $availability['compatible_php'];
+	$compatible_wp  = $availability['compatible_wp'];
 
-	$compatible_php = is_php_version_compatible( $requires_php );
-	$compatible_wp  = is_wp_version_compatible( $requires_wp );
-	$action_links   = array();
+	$action_links = array();
 
-	$status = install_plugin_install_status( $plugin_data );
-
-	if ( is_plugin_active( $status['file'] ) ) {
+	if ( $availability['activated'] ) {
 		$action_links[] = sprintf(
 			'<button type="button" class="button button-disabled" disabled="disabled">%s</button>',
 			esc_html( _x( 'Active', 'plugin', 'default' ) )
 		);
 	} elseif (
-		$compatible_php &&
-		$compatible_wp &&
-		(
-			( $status['file'] && current_user_can( 'activate_plugin', $status['file'] ) ) ||
-			current_user_can( 'activate_plugins' )
-		) &&
-		(
-			'install' !== $status['status'] ||
-			current_user_can( 'install_plugins' )
-		)
+		$availability['compatible_php'] &&
+		$availability['compatible_wp'] &&
+		$availability['can_install'] &&
+		$availability['can_activate']
 	) {
 		$url = esc_url_raw(
 			add_query_arg(
@@ -174,7 +341,6 @@
 					'action'   => 'perflab_install_activate_plugin',
 					'_wpnonce' => wp_create_nonce( 'perflab_install_activate_plugin' ),
 					'slug'     => $plugin_data['slug'],
-					'file'     => $status['file'],
 				),
 				admin_url( 'options-general.php' )
 			)
@@ -186,7 +352,7 @@
 			esc_html__( 'Activate', 'default' )
 		);
 	} else {
-		$explanation    = 'install' !== $status['status'] || current_user_can( 'install_plugins' ) ? _x( 'Cannot Activate', 'plugin', 'default' ) : _x( 'Cannot Install', 'plugin', 'default' );
+		$explanation    = $availability['can_install'] ? _x( 'Cannot Activate', 'plugin', 'default' ) : _x( 'Cannot Install', 'plugin', 'default' );
 		$action_links[] = sprintf(
 			'<button type="button" class="button button-disabled" disabled="disabled">%s</button>',
 			esc_html( $explanation )
@@ -193,27 +359,52 @@
 		);
 	}
 
-	$details_link = esc_url_raw(
-		add_query_arg(
-			array(
-				'tab'       => 'plugin-information',
-				'plugin'    => $plugin_data['slug'],
-				'TB_iframe' => 'true',
-				'width'     => 600,
-				'height'    => 550,
-			),
-			admin_url( 'plugin-install.php' )
-		)
-	);
+	if ( current_user_can( 'install_plugins' ) ) {
+		$title_link_attr = ' class="thickbox open-plugin-details-modal"';
+		$details_link    = esc_url_raw(
+			add_query_arg(
+				array(
+					'tab'       => 'plugin-information',
+					'plugin'    => $plugin_data['slug'],
+					'TB_iframe' => 'true',
+					'width'     => 600,
+					'height'    => 550,
+				),
+				admin_url( 'plugin-install.php' )
+			)
+		);
 
-	$action_links[] = sprintf(
-		'<a href="%s" class="thickbox open-plugin-details-modal" aria-label="%s" data-title="%s">%s</a>',
-		esc_url( $details_link ),
-		/* translators: %s: Plugin name and version. */
-		esc_attr( sprintf( __( 'More information about %s', 'default' ), $name ) ),
-		esc_attr( $name ),
-		esc_html__( 'Learn more', 'performance-lab' )
-	);
+		$action_links[] = sprintf(
+			'<a href="%s" class="thickbox open-plugin-details-modal" aria-label="%s" data-title="%s">%s</a>',
+			esc_url( $details_link ),
+			/* translators: %s: Plugin name and version. */
+			esc_attr( sprintf( __( 'More information about %s', 'default' ), $name ) ),
+			esc_attr( $name ),
+			esc_html__( 'Learn more', 'performance-lab' )
+		);
+	} else {
+		$title_link_attr = ' target="_blank"';
+
+		/* translators: %s: Plugin name. */
+		$aria_label = sprintf( __( 'Visit plugin site for %s', 'default' ), $name );
+
+		$details_link = __( 'https://wordpress.org/plugins/', 'default' ) . $plugin_data['slug'] . '/';
+
+		$action_links[] = sprintf(
+			'<a href="%s" aria-label="%s" target="_blank">%s</a>',
+			esc_url( $details_link ),
+			esc_attr( $aria_label ),
+			esc_html__( 'Visit plugin site', 'default' )
+		);
+	}
+
+	if ( $availability['activated'] ) {
+		$settings_url = perflab_get_plugin_settings_url( $plugin_data['slug'] );
+		if ( $settings_url ) {
+			/* translators: %s is the settings URL */
+			$action_links[] = sprintf( '<a href="%s">%s</a>', esc_url( $settings_url ), esc_html__( 'Settings', 'performance-lab' ) );
+		}
+	}
 	?>
 	<div class="plugin-card plugin-card-<?php echo sanitize_html_class( $plugin_data['slug'] ); ?>">
 		<?php
@@ -279,26 +470,22 @@
 		<div class="plugin-card-top">
 			<div class="name column-name">
 				<h3>
-					<a href="<?php echo esc_url( $details_link ); ?>" class="thickbox open-plugin-details-modal">
-						<?php echo wp_kses_post( $title ); ?>
+					<a href="<?php echo esc_url( $details_link ); ?>"<?php echo $title_link_attr; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>>
+						<?php echo wp_kses_post( $name ); ?>
 					</a>
-					<?php
-					if ( isset( $plugin_data['experimental'] ) && $plugin_data['experimental'] ) {
-						?>
+					<?php if ( $plugin_data['experimental'] ) : ?>
 						<em class="perflab-plugin-experimental">
 							<?php echo esc_html( _x( '(experimental)', 'plugin suffix', 'performance-lab' ) ); ?>
 						</em>
-						<?php
-					}
-					?>
+					<?php endif; ?>
 				</h3>
 			</div>
 			<div class="action-links">
-				<?php
-				if ( ! empty( $action_links ) ) {
-					echo wp_kses_post( '<ul class="plugin-action-buttons"><li>' . implode( '</li><li>', $action_links ) . '</li></ul>' );
-				}
-				?>
+				<ul class="plugin-action-buttons">
+					<?php foreach ( $action_links as $action_link ) : ?>
+						<li><?php echo wp_kses_post( $action_link ); ?></li>
+					<?php endforeach; ?>
+				</ul>
 			</div>
 			<div class="desc column-description">
 				<p><?php echo wp_kses_post( $description ); ?></p>
Index: includes/admin/server-timing.php
===================================================================
--- includes/admin/server-timing.php	(revision 3088546)
+++ includes/admin/server-timing.php	(working copy)
@@ -19,7 +19,7 @@
  *
  * @since 2.6.0
  */
-function perflab_add_server_timing_page() {
+function perflab_add_server_timing_page(): void {
 	$hook_suffix = add_management_page(
 		__( 'Server-Timing', 'performance-lab' ),
 		__( 'Server-Timing', 'performance-lab' ),
@@ -32,9 +32,8 @@
 	if ( false !== $hook_suffix ) {
 		add_action( "load-{$hook_suffix}", 'perflab_load_server_timing_page' );
 	}
+}
 
-	return $hook_suffix;
-}
 add_action( 'admin_menu', 'perflab_add_server_timing_page' );
 
 /**
@@ -42,7 +41,7 @@
  *
  * @since 2.6.0
  */
-function perflab_load_server_timing_page() {
+function perflab_load_server_timing_page(): void {
 	/*
 	 * This settings section technically includes a field, however it is directly rendered as part of the section
 	 * callback due to requiring custom markup.
@@ -57,7 +56,7 @@
 	// Minor style tweaks to improve appearance similar to other core settings screen instances.
 	add_action(
 		'admin_print_styles',
-		static function () {
+		static function (): void {
 			?>
 			<style>
 				.wrap p {
@@ -74,7 +73,7 @@
 	add_settings_section(
 		'benchmarking',
 		__( 'Benchmarking', 'performance-lab' ),
-		static function () {
+		static function (): void {
 			?>
 			<p>
 				<?php
@@ -125,7 +124,7 @@
 	add_settings_field(
 		'benchmarking_actions',
 		__( 'Actions', 'performance-lab' ),
-		static function () {
+		static function (): void {
 			perflab_render_server_timing_page_hooks_field( 'benchmarking_actions' );
 		},
 		PERFLAB_SERVER_TIMING_SCREEN,
@@ -135,7 +134,7 @@
 	add_settings_field(
 		'benchmarking_filters',
 		__( 'Filters', 'performance-lab' ),
-		static function () {
+		static function (): void {
 			perflab_render_server_timing_page_hooks_field( 'benchmarking_filters' );
 		},
 		PERFLAB_SERVER_TIMING_SCREEN,
@@ -149,7 +148,7 @@
  *
  * @since 2.6.0
  */
-function perflab_render_server_timing_page() {
+function perflab_render_server_timing_page(): void {
 	?>
 	<div class="wrap">
 		<?php settings_errors(); ?>
@@ -173,7 +172,7 @@
  *
  * @param string $slug Slug of the field and sub-key in the Server-Timing option.
  */
-function perflab_render_server_timing_page_hooks_field( $slug ) {
+function perflab_render_server_timing_page_hooks_field( string $slug ): void {
 	$options = (array) get_option( PERFLAB_SERVER_TIMING_SETTING, array() );
 
 	// Value for the sub-key is an array of hook names.
@@ -205,7 +204,7 @@
  *
  * @since 2.6.0
  */
-function perflab_render_server_timing_page_output_buffering_section() {
+function perflab_render_server_timing_page_output_buffering_section(): void {
 	$slug           = 'output_buffering';
 	$field_id       = "server_timing_{$slug}";
 	$field_name     = PERFLAB_SERVER_TIMING_SETTING . '[' . $slug . ']';
Index: includes/server-timing/class-perflab-server-timing-metric.php
===================================================================
--- includes/server-timing/class-perflab-server-timing-metric.php	(revision 3088546)
+++ includes/server-timing/class-perflab-server-timing-metric.php	(working copy)
@@ -44,7 +44,7 @@
 	 *
 	 * @param string $slug The metric slug.
 	 */
-	public function __construct( $slug ) {
+	public function __construct( string $slug ) {
 		$this->slug = $slug;
 	}
 
@@ -55,7 +55,7 @@
 	 *
 	 * @return string The metric slug.
 	 */
-	public function get_slug() {
+	public function get_slug(): string {
 		return $this->slug;
 	}
 
@@ -67,9 +67,9 @@
 	 *
 	 * @since 1.8.0
 	 *
-	 * @param int|float $value The metric value to set, in milliseconds.
+	 * @param int|float|mixed $value The metric value to set, in milliseconds.
 	 */
-	public function set_value( $value ) {
+	public function set_value( $value ): void {
 		if ( ! is_numeric( $value ) ) {
 			_doing_it_wrong(
 				__METHOD__,
@@ -110,7 +110,7 @@
 	}
 
 	/**
-	 * Captures the current time, as a reference point to calculate the duration of a task afterwards.
+	 * Captures the current time, as a reference point to calculate the duration of a task afterward.
 	 *
 	 * This should be used in combination with {@see Perflab_Server_Timing_Metric::measure_after()}. Alternatively,
 	 * {@see Perflab_Server_Timing_Metric::set_value()} can be used to set a calculated value manually.
@@ -117,7 +117,7 @@
 	 *
 	 * @since 1.8.0
 	 */
-	public function measure_before() {
+	public function measure_before(): void {
 		$this->before_value = microtime( true );
 	}
 
@@ -129,7 +129,7 @@
 	 *
 	 * @since 1.8.0
 	 */
-	public function measure_after() {
+	public function measure_after(): void {
 		if ( ! $this->before_value ) {
 			_doing_it_wrong(
 				__METHOD__,
Index: includes/server-timing/class-perflab-server-timing.php
===================================================================
--- includes/server-timing/class-perflab-server-timing.php	(revision 3088546)
+++ includes/server-timing/class-perflab-server-timing.php	(working copy)
@@ -9,6 +9,11 @@
 /**
  * Class controlling the Server-Timing header.
  *
+ * @phpstan-type MetricArguments array{
+ *                   measure_callback: callable( Perflab_Server_Timing_Metric ): void,
+ *                   access_cap: string
+ *               }
+ *
  * @since 1.8.0
  */
 class Perflab_Server_Timing {
@@ -17,7 +22,7 @@
 	 * Map of registered metric slugs and their metric instances.
 	 *
 	 * @since 1.8.0
-	 * @var array
+	 * @var array<string, Perflab_Server_Timing_Metric>
 	 */
 	private $registered_metrics = array();
 
@@ -25,7 +30,8 @@
 	 * Map of registered metric slugs and their registered data.
 	 *
 	 * @since 1.8.0
-	 * @var array
+	 * @phpstan-var array<string, MetricArguments>
+	 * @var array<string, array>
 	 */
 	private $registered_metrics_data = array();
 
@@ -36,8 +42,10 @@
 	 *
 	 * @since 1.8.0
 	 *
-	 * @param string $metric_slug The metric slug.
-	 * @param array  $args        {
+	 * @phpstan-param MetricArguments $args
+	 *
+	 * @param string                         $metric_slug The metric slug.
+	 * @param array<string, callable|string> $args        {
 	 *     Arguments for the metric.
 	 *
 	 *     @type callable $measure_callback The callback that initiates calculating the metric value. It will receive
@@ -48,7 +56,7 @@
 	 *                                      needs to be set to "exist".
 	 * }
 	 */
-	public function register_metric( $metric_slug, array $args ) {
+	public function register_metric( string $metric_slug, array $args ): void {
 		if ( isset( $this->registered_metrics[ $metric_slug ] ) ) {
 			_doing_it_wrong(
 				__METHOD__,
@@ -94,11 +102,16 @@
 			);
 			return;
 		}
+		/**
+		 * Validated args.
+		 *
+		 * @var MetricArguments $args
+		 */
 
 		$this->registered_metrics[ $metric_slug ]      = new Perflab_Server_Timing_Metric( $metric_slug );
 		$this->registered_metrics_data[ $metric_slug ] = $args;
 
-		// If the current user has already been determined and they lack the necessary access,
+		// If the current user has already been determined, and they lack the necessary access,
 		// do not even attempt to calculate the metric.
 		if ( did_action( 'set_current_user' ) && ! current_user_can( $args['access_cap'] ) ) {
 			return;
@@ -116,7 +129,7 @@
 	 * @param string $metric_slug The metric slug.
 	 * @return bool True if registered, false otherwise.
 	 */
-	public function has_registered_metric( $metric_slug ) {
+	public function has_registered_metric( string $metric_slug ): bool {
 		return isset( $this->registered_metrics[ $metric_slug ] ) && isset( $this->registered_metrics_data[ $metric_slug ] );
 	}
 
@@ -127,7 +140,7 @@
 	 *
 	 * @since 1.8.0
 	 */
-	public function send_header() {
+	public function send_header(): void {
 		if ( headers_sent() ) {
 			_doing_it_wrong(
 				__METHOD__,
@@ -161,7 +174,7 @@
 	 *
 	 * @return string The Server-Timing header value.
 	 */
-	public function get_header() {
+	public function get_header(): string {
 		// Get all metric header values, as long as the current user has access to the metric.
 		$metric_header_values = array_filter(
 			array_map(
@@ -187,7 +200,7 @@
 	 * Returns whether an output buffer should be used to gather Server-Timing metrics during template rendering.
 	 *
 	 * Without an output buffer, it is only possible to cover metrics from before serving the template, i.e. before
-	 * the HTML output starts. Therefore sites that would like to gather metrics while serving the template should
+	 * the HTML output starts. Therefore, sites that would like to gather metrics while serving the template should
 	 * enable this via the {@see 'perflab_server_timing_use_output_buffer'} filter.
 	 *
 	 * @since 1.8.0
@@ -194,7 +207,7 @@
 	 *
 	 * @return bool True if an output buffer should be used, false otherwise.
 	 */
-	public function use_output_buffer() {
+	public function use_output_buffer(): bool {
 		$options = (array) get_option( PERFLAB_SERVER_TIMING_SETTING, array() );
 		$enabled = ! empty( $options['output_buffering'] );
 
@@ -202,7 +215,7 @@
 		 * Filters whether an output buffer should be used to be able to gather additional Server-Timing metrics.
 		 *
 		 * Without an output buffer, it is only possible to cover metrics from before serving the template, i.e. before
-		 * the HTML output starts. Therefore sites that would like to gather metrics while serving the template should
+		 * the HTML output starts. Therefore, sites that would like to gather metrics while serving the template should
 		 * enable this.
 		 *
 		 * @since 1.8.0
@@ -247,7 +260,7 @@
 	 * @param Perflab_Server_Timing_Metric $metric The metric to format.
 	 * @return string|null Segment for the Server-Timing header, or null if no value set.
 	 */
-	private function format_metric_header_value( Perflab_Server_Timing_Metric $metric ) {
+	private function format_metric_header_value( Perflab_Server_Timing_Metric $metric ): ?string {
 		$value = $metric->get_value();
 
 		// If no value is set, make sure it's just passed through.
Index: includes/server-timing/defaults.php
===================================================================
--- includes/server-timing/defaults.php	(revision 3088546)
+++ includes/server-timing/defaults.php	(working copy)
@@ -22,13 +22,13 @@
  *
  * @since 1.8.0
  */
-function perflab_register_default_server_timing_before_template_metrics() {
-	$calculate_before_template_metrics = static function () {
+function perflab_register_default_server_timing_before_template_metrics(): void {
+	$calculate_before_template_metrics = static function (): void {
 		// WordPress execution prior to serving the template.
 		perflab_server_timing_register_metric(
 			'before-template',
 			array(
-				'measure_callback' => static function ( $metric ) {
+				'measure_callback' => static function ( $metric ): void {
 					// The 'timestart' global is set right at the beginning of WordPress execution.
 					$metric->set_value( ( microtime( true ) - $GLOBALS['timestart'] ) * 1000.0 );
 				},
@@ -42,7 +42,7 @@
 			perflab_server_timing_register_metric(
 				'before-template-db-queries',
 				array(
-					'measure_callback' => static function ( $metric ) {
+					'measure_callback' => static function ( $metric ): void {
 						// This should never happen, but some odd database implementations may be doing it wrong.
 						if ( ! isset( $GLOBALS['wpdb']->queries ) || ! is_array( $GLOBALS['wpdb']->queries ) ) {
 							return;
@@ -80,7 +80,7 @@
 	);
 	add_action(
 		'perflab_server_timing_send_header',
-		static function () use ( $calculate_before_template_metrics ) {
+		static function () use ( $calculate_before_template_metrics ): void {
 			if ( ! perflab_server_timing_use_output_buffer() ) {
 				$calculate_before_template_metrics();
 			}
@@ -106,7 +106,7 @@
 				perflab_server_timing_register_metric(
 					'load-alloptions-query',
 					array(
-						'measure_callback' => static function ( $metric ) {
+						'measure_callback' => static function ( $metric ): void {
 							$metric->measure_before();
 							add_filter(
 								'pre_cache_alloptions',
@@ -135,7 +135,7 @@
  *
  * @since 1.8.0
  */
-function perflab_register_default_server_timing_template_metrics() {
+function perflab_register_default_server_timing_template_metrics(): void {
 	// Template-related metrics can only be recorded if output buffering is used.
 	if ( ! perflab_server_timing_use_output_buffer() ) {
 		return;
@@ -148,7 +148,7 @@
 			perflab_server_timing_register_metric(
 				'template',
 				array(
-					'measure_callback' => static function ( $metric ) {
+					'measure_callback' => static function ( Perflab_Server_Timing_Metric $metric ): void {
 						$metric->measure_before();
 						add_action( 'perflab_server_timing_send_header', array( $metric, 'measure_after' ), PHP_INT_MAX );
 					},
@@ -163,12 +163,12 @@
 
 	add_action(
 		'perflab_server_timing_send_header',
-		static function () {
+		static function (): void {
 			// WordPress total load time.
 			perflab_server_timing_register_metric(
 				'total',
 				array(
-					'measure_callback' => static function ( $metric ) {
+					'measure_callback' => static function ( $metric ): void {
 						// The 'timestart' global is set right at the beginning of WordPress execution.
 						$metric->set_value( ( microtime( true ) - $GLOBALS['timestart'] ) * 1000.0 );
 					},
@@ -182,12 +182,12 @@
 	if ( defined( 'SAVEQUERIES' ) && SAVEQUERIES ) {
 		add_action(
 			'perflab_server_timing_send_header',
-			static function () {
+			static function (): void {
 				// WordPress database query time within template.
 				perflab_server_timing_register_metric(
 					'template-db-queries',
 					array(
-						'measure_callback' => static function ( $metric ) {
+						'measure_callback' => static function ( $metric ): void {
 							// This global should typically be set when this is called, but check just in case.
 							if ( ! isset( $GLOBALS['perflab_query_time_before_template'] ) ) {
 								return;
@@ -225,7 +225,7 @@
  *
  * @since 2.6.0
  */
-function perflab_register_additional_server_timing_metrics_from_setting() {
+function perflab_register_additional_server_timing_metrics_from_setting(): void {
 	$options = (array) get_option( PERFLAB_SERVER_TIMING_SETTING, array() );
 
 	$hooks_to_measure = array();
@@ -265,7 +265,7 @@
 	 */
 	add_action(
 		'all',
-		static function ( $hook_name ) use ( $hooks_to_measure ) {
+		static function ( $hook_name ) use ( $hooks_to_measure ): void {
 			if ( ! isset( $hooks_to_measure[ $hook_name ] ) ) {
 				return;
 			}
@@ -277,11 +277,11 @@
 				return;
 			}
 
-			$measure_callback = static function ( $metric ) use ( $hook_name, $hook_type ) {
+			$measure_callback = static function ( $metric ) use ( $hook_name, $hook_type ): void {
 				$metric->measure_before();
 
 				if ( 'action' === $hook_type ) {
-					$cb = static function () use ( $metric, $hook_name, &$cb ) {
+					$cb = static function () use ( $metric, $hook_name, &$cb ): void {
 						$metric->measure_after();
 						remove_action( $hook_name, $cb, PHP_INT_MAX );
 					};
Index: includes/server-timing/load.php
===================================================================
--- includes/server-timing/load.php	(revision 3088546)
+++ includes/server-timing/load.php	(working copy)
@@ -18,6 +18,8 @@
 define( 'PERFLAB_SERVER_TIMING_SETTING', 'perflab_server_timing_settings' );
 define( 'PERFLAB_SERVER_TIMING_SCREEN', 'perflab-server-timing' );
 
+require_once __DIR__ . '/hooks.php';
+
 /**
  * Provides access the Server-Timing API.
  *
@@ -27,7 +29,7 @@
  *
  * @since 1.8.0
  */
-function perflab_server_timing() {
+function perflab_server_timing(): Perflab_Server_Timing {
 	static $server_timing;
 
 	if ( null === $server_timing ) {
@@ -48,9 +50,19 @@
 
 	return $server_timing;
 }
-add_action( 'wp_loaded', 'perflab_server_timing' );
 
 /**
+ * Initializes the Server-Timing API.
+ *
+ * @since 3.1.0
+ */
+function perflab_server_timing_init(): void {
+	perflab_server_timing();
+}
+
+add_action( 'wp_loaded', 'perflab_server_timing_init' );
+
+/**
  * Registers a metric to calculate for the Server-Timing header.
  *
  * This method must be called before the {@see 'perflab_server_timing_send_header'} hook.
@@ -57,8 +69,8 @@
  *
  * @since 1.8.0
  *
- * @param string $metric_slug The metric slug.
- * @param array  $args        {
+ * @param string                                                $metric_slug The metric slug.
+ * @param array{measure_callback: callable, access_cap: string} $args        {
  *     Arguments for the metric.
  *
  *     @type callable $measure_callback The callback that initiates calculating the metric value. It will receive
@@ -69,7 +81,7 @@
  *                                      needs to be set to "exist".
  * }
  */
-function perflab_server_timing_register_metric( $metric_slug, array $args ) {
+function perflab_server_timing_register_metric( string $metric_slug, array $args ): void {
 	perflab_server_timing()->register_metric( $metric_slug, $args );
 }
 
@@ -80,7 +92,7 @@
  *
  * @return bool True if an output buffer should be used, false otherwise.
  */
-function perflab_server_timing_use_output_buffer() {
+function perflab_server_timing_use_output_buffer(): bool {
 	return perflab_server_timing()->use_output_buffer();
 }
 
@@ -93,9 +105,9 @@
  * @param string   $metric_slug The metric slug to use within the Server-Timing header.
  * @param string   $access_cap  Capability required to view the metric. If this is a public metric, this needs to be
  *                              set to "exist".
- * @return callable Callback function that will run $callback and measure its execution time once called.
+ * @return Closure Callback function that will run $callback and measure its execution time once called.
  */
-function perflab_wrap_server_timing( $callback, $metric_slug, $access_cap ) {
+function perflab_wrap_server_timing( callable $callback, string $metric_slug, string $access_cap ): Closure {
 	return static function ( ...$callback_args ) use ( $callback, $metric_slug, $access_cap ) {
 		// Gain access to Perflab_Server_Timing_Metric instance.
 		$server_timing_metric = null;
@@ -106,7 +118,7 @@
 			perflab_server_timing_register_metric(
 				$metric_slug,
 				array(
-					'measure_callback' => static function ( $metric ) use ( &$server_timing_metric ) {
+					'measure_callback' => static function ( $metric ) use ( &$server_timing_metric ): void {
 						$server_timing_metric = $metric;
 					},
 					'access_cap'       => $access_cap,
@@ -134,11 +146,26 @@
 }
 
 /**
+ * Gets default value for server timing setting.
+ *
+ * @since 3.1.0
+ *
+ * @return array{benchmarking_actions: string[], benchmarking_filters: string[], output_buffering: bool} Default value.
+ */
+function perflab_get_server_timing_setting_default_value(): array {
+	return array(
+		'benchmarking_actions' => array(),
+		'benchmarking_filters' => array(),
+		'output_buffering'     => false,
+	);
+}
+
+/**
  * Registers the Server-Timing setting.
  *
  * @since 2.6.0
  */
-function perflab_register_server_timing_setting() {
+function perflab_register_server_timing_setting(): void {
 	register_setting(
 		PERFLAB_SERVER_TIMING_SCREEN,
 		PERFLAB_SERVER_TIMING_SETTING,
@@ -145,7 +172,7 @@
 		array(
 			'type'              => 'object',
 			'sanitize_callback' => 'perflab_sanitize_server_timing_setting',
-			'default'           => array(),
+			'default'           => perflab_get_server_timing_setting_default_value(),
 		)
 	);
 }
@@ -156,22 +183,18 @@
  *
  * @since 2.6.0
  *
- * @param mixed $value Server-Timing setting value.
- * @return array Sanitized Server-Timing setting value.
+ * @param array|mixed $value Server-Timing setting value.
+ * @return array{benchmarking_actions: string[], benchmarking_filters: string[], output_buffering: bool} Sanitized Server-Timing setting value.
  */
-function perflab_sanitize_server_timing_setting( $value ) {
-	static $allowed_keys = array(
-		'benchmarking_actions' => true,
-		'benchmarking_filters' => true,
-		'output_buffering'     => true,
-	);
-
+function perflab_sanitize_server_timing_setting( $value ): array {
 	if ( ! is_array( $value ) ) {
-		return array();
+		$value = array();
 	}
+	$value = wp_array_slice_assoc(
+		array_merge( perflab_get_server_timing_setting_default_value(), $value ),
+		array_keys( perflab_get_server_timing_setting_default_value() )
+	);
 
-	$value = array_intersect_key( $value, $allowed_keys );
-
 	/*
 	 * Ensure that every element is an indexed array of hook names.
 	 * Any duplicates across a group of hooks are removed.
@@ -184,7 +207,7 @@
 			array_unique(
 				array_filter(
 					array_map(
-						static function ( $hookname ) {
+						static function ( string $hook_name ): string {
 							/*
 							 * Allow any characters except whitespace.
 							 * While most hooks use a limited set of characters, hook names in plugins are not
@@ -191,10 +214,10 @@
 							 * restricted to them, therefore the sanitization does not limit the characters
 							 * used.
 							 */
-							return preg_replace(
+							return (string) preg_replace(
 								'/\s/',
 								'',
-								sanitize_text_field( $hookname )
+								sanitize_text_field( $hook_name )
 							);
 						},
 						$hooks
@@ -204,7 +227,12 @@
 		);
 	}
 
-	$value['output_buffering'] = ! empty( $value['output_buffering'] );
+	$value['output_buffering'] = (bool) $value['output_buffering'];
 
+	/**
+	 * Validated value.
+	 *
+	 * @var array{benchmarking_actions: string[], benchmarking_filters: string[], output_buffering: bool} $value
+	 */
 	return $value;
 }
Index: includes/server-timing/object-cache.copy.php
===================================================================
--- includes/server-timing/object-cache.copy.php	(revision 3088546)
+++ includes/server-timing/object-cache.copy.php	(working copy)
@@ -47,7 +47,7 @@
 	 *
 	 * @since 1.8.0
 	 */
-	function perflab_load_server_timing_api_from_dropin() {
+	function perflab_load_server_timing_api_from_dropin(): void {
 		if ( defined( 'PERFLAB_DISABLE_SERVER_TIMING' ) && PERFLAB_DISABLE_SERVER_TIMING ) {
 			return;
 		}
Index: includes/site-health/audit-autoloaded-options/helper.php
===================================================================
--- includes/site-health/audit-autoloaded-options/helper.php	(revision 3088546)
+++ includes/site-health/audit-autoloaded-options/helper.php	(working copy)
@@ -15,9 +15,9 @@
  *
  * @since 1.0.0
  *
- * @return array
+ * @return array{label: string, status: string, badge: array{label: string, color: string}, description: string, actions: string, test: string} Result.
  */
-function perflab_aao_autoloaded_options_test() {
+function perflab_aao_autoloaded_options_test(): array {
 
 	$autoloaded_options_size  = perflab_aao_autoloaded_options_size();
 	$autoloaded_options_count = count( wp_load_alloptions() );
@@ -73,7 +73,7 @@
 	$result['description'] = apply_filters( 'perflab_aao_autoloaded_options_limit_description', $result['description'] );
 
 	$result['actions'] = sprintf(
-	/* translators: 1: HelpHub URL. 2: Link description. */
+		/* translators: 1: HelpHub URL. 2: Link description. */
 		'<p><a target="_blank" href="%1$s">%2$s</a></p>',
 		esc_url( __( 'https://wordpress.org/support/article/optimization/#autoloaded-options', 'performance-lab' ) ),
 		__( 'More info about performance optimization', 'performance-lab' )
@@ -95,24 +95,18 @@
  *
  * @since 1.0.0
  *
- * @global wpdb $wpdb WordPress database abstraction object.
- *
  * @return int autoloaded data in bytes.
  */
-function perflab_aao_autoloaded_options_size() {
-	global $wpdb;
+function perflab_aao_autoloaded_options_size(): int {
+	$all_options = wp_load_alloptions();
 
-	$autoload_values = perflab_aao_get_autoload_values_to_autoload();
+	$total_length = 0;
 
-	return (int) $wpdb->get_var(
-		$wpdb->prepare(
-			sprintf(
-				"SELECT SUM(LENGTH(option_value)) FROM $wpdb->options WHERE autoload IN (%s)",
-				implode( ',', array_fill( 0, count( $autoload_values ), '%s' ) )
-			),
-			$autoload_values
-		)
-	);
+	foreach ( $all_options as $option_name => $option_value ) {
+		$total_length += strlen( $option_value );
+	}
+
+	return $total_length;
 }
 
 /**
@@ -120,12 +114,9 @@
  *
  * @since 1.5.0
  *
- * @global wpdb $wpdb WordPress database abstraction object.
- *
- * @return array Autoloaded data as option names and their sizes.
+ * @return array<object{option_name: string, option_value_length: int}> Autoloaded data as option names and their sizes.
  */
-function perflab_aao_query_autoloaded_options() {
-	global $wpdb;
+function perflab_aao_query_autoloaded_options(): array {
 
 	/**
 	 * Filters the threshold for an autoloaded option to be considered large.
@@ -141,17 +132,27 @@
 	 */
 	$option_threshold = apply_filters( 'perflab_aao_autoloaded_options_table_threshold', 100 );
 
-	$autoload_values = perflab_aao_get_autoload_values_to_autoload();
+	$all_options = wp_load_alloptions();
 
-	return $wpdb->get_results(
-		$wpdb->prepare(
-			sprintf(
-				"SELECT option_name, LENGTH(option_value) AS option_value_length FROM {$wpdb->options} WHERE autoload IN (%s)",
-				implode( ',', array_fill( 0, count( $autoload_values ), '%s' ) )
-			) . ' AND LENGTH(option_value) > %d ORDER BY option_value_length DESC LIMIT 20',
-			array_merge( $autoload_values, array( $option_threshold ) )
-		)
+	$large_options = array();
+
+	foreach ( $all_options as $option_name => $option_value ) {
+		if ( strlen( $option_value ) > $option_threshold ) {
+			$large_options[] = (object) array(
+				'option_name'         => $option_name,
+				'option_value_length' => strlen( $option_value ),
+			);
+		}
+	}
+
+	usort(
+		$large_options,
+		static function ( $a, $b ) {
+			return $b->option_value_length - $a->option_value_length;
+		}
 	);
+
+	return array_slice( $large_options, 0, 20 );
 }
 
 /**
@@ -161,7 +162,7 @@
  *
  * @return string HTML formatted table.
  */
-function perflab_aao_get_autoloaded_options_table() {
+function perflab_aao_get_autoloaded_options_table(): string {
 	$autoload_summary = perflab_aao_query_autoloaded_options();
 
 	$html_table = sprintf(
@@ -197,29 +198,36 @@
  *
  * @since 3.0.0
  *
- * @global wpdb $wpdb WordPress database abstraction object.
- *
  * @return string HTML formatted table.
  */
-function perflab_aao_get_disabled_autoloaded_options_table() {
-	global $wpdb;
-
+function perflab_aao_get_disabled_autoloaded_options_table(): string {
 	$disabled_options = get_option( 'perflab_aao_disabled_options', array() );
 
-	if ( empty( $disabled_options ) ) {
+	if ( ! is_array( $disabled_options ) ) {
 		return '';
 	}
 
-	$disabled_options_summary = $wpdb->get_results(
-		$wpdb->prepare(
-			sprintf(
-				"SELECT option_name, LENGTH(option_value) AS option_value_length FROM $wpdb->options WHERE option_name IN (%s) ORDER BY option_value_length DESC",
-				implode( ',', array_fill( 0, count( $disabled_options ), '%s' ) )
-			),
-			$disabled_options
-		)
-	);
+	$disabled_options_summary = array();
+	wp_prime_option_caches( $disabled_options );
 
+	foreach ( $disabled_options as $option_name ) {
+		if ( ! is_string( $option_name ) ) {
+			continue;
+		}
+		$option_value = get_option( $option_name );
+
+		if ( false !== $option_value ) {
+			$option_length                            = strlen( maybe_serialize( $option_value ) );
+			$disabled_options_summary[ $option_name ] = $option_length;
+		}
+	}
+
+	if ( count( $disabled_options_summary ) === 0 ) {
+		return '';
+	}
+
+	arsort( $disabled_options_summary );
+
 	$html_table = sprintf(
 		'<p>%s</p><table class="widefat striped"><thead><tr><th scope="col">%s</th><th scope="col">%s</th><th scope="col">%s</th></tr></thead><tbody>',
 		__( 'The following table shows the options for which you have previously disabled Autoload.', 'performance-lab' ),
@@ -229,13 +237,14 @@
 	);
 
 	$nonce = wp_create_nonce( 'perflab_aao_update_autoload' );
-	foreach ( $disabled_options_summary as $value ) {
+
+	foreach ( $disabled_options_summary as $option_name => $option_length ) {
 		$url            = esc_url_raw(
 			add_query_arg(
 				array(
 					'action'      => 'perflab_aao_update_autoload',
 					'_wpnonce'    => $nonce,
-					'option_name' => $value->option_name,
+					'option_name' => $option_name,
 					'autoload'    => 'true',
 				),
 				admin_url( 'site-health.php' )
@@ -242,8 +251,9 @@
 			)
 		);
 		$disable_button = sprintf( '<a class="button" href="%s">%s</a>', esc_url( $url ), esc_html__( 'Revert to Autoload', 'performance-lab' ) );
-		$html_table    .= sprintf( '<tr><td>%s</td><td>%s</td><td>%s</td></tr>', esc_html( $value->option_name ), size_format( $value->option_value_length, 2 ), $disable_button );
+		$html_table    .= sprintf( '<tr><td>%s</td><td>%s</td><td>%s</td></tr>', esc_html( $option_name ), size_format( $option_length, 2 ), $disable_button );
 	}
+
 	$html_table .= '</tbody></table>';
 
 	return $html_table;
@@ -254,9 +264,9 @@
  *
  * @since 3.0.0
  *
- * @return array List of autoload values.
+ * @return string[] List of autoload values.
  */
-function perflab_aao_get_autoload_values_to_autoload() {
+function perflab_aao_get_autoload_values_to_autoload(): array {
 	if ( function_exists( 'wp_autoload_values_to_autoload' ) ) {
 		return wp_autoload_values_to_autoload();
 	}
Index: includes/site-health/audit-autoloaded-options/hooks.php
===================================================================
--- includes/site-health/audit-autoloaded-options/hooks.php	(revision 3088546)
+++ includes/site-health/audit-autoloaded-options/hooks.php	(working copy)
@@ -15,10 +15,10 @@
  *
  * @since 1.0.0
  *
- * @param array $tests Site Health Tests.
- * @return array
+ * @param array{direct: array<string, array{label: string, test: string}>} $tests Site Health Tests.
+ * @return array{direct: array<string, array{label: string, test: string}>} Amended tests.
  */
-function perflab_aao_add_autoloaded_options_test( $tests ) {
+function perflab_aao_add_autoloaded_options_test( array $tests ): array {
 	$tests['direct']['autoloaded_options'] = array(
 		'label' => __( 'Autoloaded options', 'performance-lab' ),
 		'test'  => 'perflab_aao_autoloaded_options_test',
@@ -32,7 +32,7 @@
  *
  * @since 3.0.0
  */
-function perflab_aao_register_admin_actions() {
+function perflab_aao_register_admin_actions(): void {
 	add_action( 'admin_action_perflab_aao_update_autoload', 'perflab_aao_handle_update_autoload' );
 }
 add_action( 'admin_init', 'perflab_aao_register_admin_actions' );
@@ -42,7 +42,7 @@
  *
  * @since 3.0.0
  */
-function perflab_aao_handle_update_autoload() {
+function perflab_aao_handle_update_autoload(): void {
 	check_admin_referer( 'perflab_aao_update_autoload' );
 
 	if ( ! isset( $_GET['option_name'], $_GET['autoload'] ) ) {
@@ -50,7 +50,7 @@
 	}
 
 	$option_name = sanitize_text_field( wp_unslash( $_GET['option_name'] ) );
-	$autoload    = isset( $_GET['autoload'] ) ? rest_sanitize_boolean( $_GET['autoload'] ) : false;
+	$autoload    = rest_sanitize_boolean( $_GET['autoload'] );
 
 	if ( ! current_user_can( 'manage_options' ) ) {
 		wp_die( esc_html__( 'Permission denied.', 'performance-lab' ) );
@@ -96,7 +96,7 @@
  *
  * @global string $pagenow The filename of the current screen.
  */
-function perflab_aao_admin_notices() {
+function perflab_aao_admin_notices(): void {
 	if ( 'site-health.php' !== $GLOBALS['pagenow'] ) {
 		return;
 	}
Index: includes/site-health/audit-enqueued-assets/helper.php
===================================================================
--- includes/site-health/audit-enqueued-assets/helper.php	(revision 3088546)
+++ includes/site-health/audit-enqueued-assets/helper.php	(working copy)
@@ -15,15 +15,19 @@
  *
  * @since 1.0.0
  *
- * @return array
+ * @return array{label: string, status: string, badge: array{label: string, color: string}, description: string, actions: string, test: string}|array{omitted: true} Result.
  */
-function perflab_aea_enqueued_js_assets_test() {
+function perflab_aea_enqueued_js_assets_test(): array {
 	/**
 	 * If the test didn't run yet, deactivate.
 	 */
 	$enqueued_scripts = perflab_aea_get_total_enqueued_scripts();
-	if ( false === $enqueued_scripts ) {
-		return array();
+	$bytes_enqueued   = perflab_aea_get_total_size_bytes_enqueued_scripts();
+	if ( false === $enqueued_scripts || false === $bytes_enqueued ) {
+		// The return value is validated in JavaScript at:
+		// <https://github.com/WordPress/wordpress-develop/blob/d1e0a6241dcc34f4a5ed464a741116461a88d43b/src/js/_enqueues/admin/site-health.js#L65-L114>
+		// If the value lacks the required keys of test, label, and description then it is omitted.
+		return array( 'omitted' => true );
 	}
 
 	$result = array(
@@ -45,7 +49,7 @@
 						'performance-lab'
 					),
 					$enqueued_scripts,
-					size_format( perflab_aea_get_total_size_bytes_enqueued_scripts() )
+					size_format( $bytes_enqueued )
 				)
 			)
 		),
@@ -71,7 +75,7 @@
 	 */
 	$scripts_size_threshold = apply_filters( 'perflab_aea_enqueued_scripts_byte_size_threshold', 300000 );
 
-	if ( $enqueued_scripts > $scripts_threshold || perflab_aea_get_total_size_bytes_enqueued_scripts() > $scripts_size_threshold ) {
+	if ( $enqueued_scripts > $scripts_threshold || $bytes_enqueued > $scripts_size_threshold ) {
 		$result['status'] = 'recommended';
 
 		$result['description'] = sprintf(
@@ -86,7 +90,7 @@
 						'performance-lab'
 					),
 					$enqueued_scripts,
-					size_format( perflab_aea_get_total_size_bytes_enqueued_scripts() )
+					size_format( $bytes_enqueued )
 				)
 			)
 		);
@@ -109,15 +113,17 @@
  *
  * @since 1.0.0
  *
- * @return array
+ * @return array{label: string, status: string, badge: array{label: string, color: string}, description: string, actions: string, test: string}|array{omitted: true} Result.
  */
-function perflab_aea_enqueued_css_assets_test() {
-	/**
-	 * If the test didn't run yet, deactivate.
-	 */
+function perflab_aea_enqueued_css_assets_test(): array {
+	// Omit if the test didn't run yet, omit.
 	$enqueued_styles = perflab_aea_get_total_enqueued_styles();
-	if ( false === $enqueued_styles ) {
-		return array();
+	$bytes_enqueued  = perflab_aea_get_total_size_bytes_enqueued_styles();
+	if ( false === $enqueued_styles || false === $bytes_enqueued ) {
+		// The return value is validated in JavaScript at:
+		// <https://github.com/WordPress/wordpress-develop/blob/d1e0a6241dcc34f4a5ed464a741116461a88d43b/src/js/_enqueues/admin/site-health.js#L65-L114>
+		// If the value lacks the required keys of test, label, and description then it is omitted.
+		return array( 'omitted' => true );
 	}
 	$result = array(
 		'label'       => __( 'Enqueued styles', 'performance-lab' ),
@@ -138,7 +144,7 @@
 						'performance-lab'
 					),
 					$enqueued_styles,
-					size_format( perflab_aea_get_total_size_bytes_enqueued_styles() )
+					size_format( $bytes_enqueued )
 				)
 			)
 		),
@@ -178,7 +184,7 @@
 						'performance-lab'
 					),
 					$enqueued_styles,
-					size_format( perflab_aea_get_total_size_bytes_enqueued_styles() )
+					size_format( $bytes_enqueued )
 				)
 			)
 		);
@@ -206,7 +212,7 @@
 function perflab_aea_get_total_enqueued_scripts() {
 	$enqueued_scripts      = false;
 	$list_enqueued_scripts = get_transient( 'aea_enqueued_front_page_scripts' );
-	if ( $list_enqueued_scripts ) {
+	if ( is_array( $list_enqueued_scripts ) ) {
 		$enqueued_scripts = count( $list_enqueued_scripts );
 	}
 	return $enqueued_scripts;
@@ -222,10 +228,12 @@
 function perflab_aea_get_total_size_bytes_enqueued_scripts() {
 	$total_size            = false;
 	$list_enqueued_scripts = get_transient( 'aea_enqueued_front_page_scripts' );
-	if ( $list_enqueued_scripts ) {
+	if ( is_array( $list_enqueued_scripts ) ) {
 		$total_size = 0;
 		foreach ( $list_enqueued_scripts as $enqueued_script ) {
-			$total_size += $enqueued_script['size'];
+			if ( is_array( $enqueued_script ) && array_key_exists( 'size', $enqueued_script ) && is_int( $enqueued_script['size'] ) ) {
+				$total_size += $enqueued_script['size'];
+			}
 		}
 	}
 	return $total_size;
@@ -257,10 +265,12 @@
 function perflab_aea_get_total_size_bytes_enqueued_styles() {
 	$total_size           = false;
 	$list_enqueued_styles = get_transient( 'aea_enqueued_front_page_styles' );
-	if ( $list_enqueued_styles ) {
+	if ( is_array( $list_enqueued_styles ) ) {
 		$total_size = 0;
 		foreach ( $list_enqueued_styles as $enqueued_style ) {
-			$total_size += $enqueued_style['size'];
+			if ( is_array( $enqueued_style ) && array_key_exists( 'size', $enqueued_style ) && is_int( $enqueued_style['size'] ) ) {
+				$total_size += $enqueued_style['size'];
+			}
 		}
 	}
 	return $total_size;
@@ -276,7 +286,7 @@
  * @param string $resource_url URl resource link.
  * @return string Returns absolute path to the resource.
  */
-function perflab_aea_get_path_from_resource_url( $resource_url ) {
+function perflab_aea_get_path_from_resource_url( string $resource_url ): string {
 	if ( ! $resource_url ) {
 		return '';
 	}
Index: includes/site-health/audit-enqueued-assets/hooks.php
===================================================================
--- includes/site-health/audit-enqueued-assets/hooks.php	(revision 3088546)
+++ includes/site-health/audit-enqueued-assets/hooks.php	(working copy)
@@ -19,7 +19,7 @@
  *
  * @global WP_Scripts $wp_scripts
  */
-function perflab_aea_audit_enqueued_scripts() {
+function perflab_aea_audit_enqueued_scripts(): void {
 	if ( ! is_admin() && is_front_page() && current_user_can( 'view_site_health_checks' ) && false === get_transient( 'aea_enqueued_front_page_scripts' ) ) {
 		global $wp_scripts;
 		$enqueued_scripts = array();
@@ -64,7 +64,7 @@
  *
  * @global WP_Styles $wp_styles The WP_Styles current instance.
  */
-function perflab_aea_audit_enqueued_styles() {
+function perflab_aea_audit_enqueued_styles(): void {
 	if ( ! is_admin() && is_front_page() && current_user_can( 'view_site_health_checks' ) && false === get_transient( 'aea_enqueued_front_page_styles' ) ) {
 		global $wp_styles;
 		$enqueued_styles = array();
@@ -108,10 +108,10 @@
  *
  * @since 1.0.0
  *
- * @param array $tests Site Health Tests.
- * @return array
+ * @param array{direct: array<string, array{label: string, test: string}>} $tests Site Health Tests.
+ * @return array{direct: array<string, array{label: string, test: string}>} Amended tests.
  */
-function perflab_aea_add_enqueued_assets_test( $tests ) {
+function perflab_aea_add_enqueued_assets_test( array $tests ): array {
 	$tests['direct']['enqueued_js_assets']  = array(
 		'label' => __( 'JS assets', 'performance-lab' ),
 		'test'  => 'perflab_aea_enqueued_js_assets_test',
@@ -131,7 +131,7 @@
  *
  * @since 1.0.0
  */
-function perflab_aea_clean_aea_audit_action() {
+function perflab_aea_clean_aea_audit_action(): void {
 	if ( isset( $_GET['action'] ) && 'clean_aea_audit' === $_GET['action'] && current_user_can( 'view_site_health_checks' ) ) {
 		check_admin_referer( 'clean_aea_audit' );
 		perflab_aea_invalidate_cache_transients();
@@ -145,7 +145,7 @@
  *
  * @since 1.0.0
  */
-function perflab_aea_invalidate_cache_transients() {
+function perflab_aea_invalidate_cache_transients(): void {
 	delete_transient( 'aea_enqueued_front_page_scripts' );
 	delete_transient( 'aea_enqueued_front_page_styles' );
 }
Index: includes/site-health/load.php
===================================================================
--- includes/site-health/load.php	(revision 3088546)
+++ includes/site-health/load.php	(working copy)
@@ -21,3 +21,7 @@
 // WebP Support site health check.
 require_once __DIR__ . '/webp-support/helper.php';
 require_once __DIR__ . '/webp-support/hooks.php';
+
+// AVIF Support site health check.
+require_once __DIR__ . '/avif-support/helper.php';
+require_once __DIR__ . '/avif-support/hooks.php';
Index: includes/site-health/webp-support/helper.php
===================================================================
--- includes/site-health/webp-support/helper.php	(revision 3088546)
+++ includes/site-health/webp-support/helper.php	(working copy)
@@ -15,9 +15,9 @@
  *
  * @since 1.0.0
  *
- * @return array
+ * @return array{label: string, status: string, badge: array{label: string, color: string}, description: string, actions: string, test: string} Result.
  */
-function webp_uploads_check_webp_supported_test() {
+function webp_uploads_check_webp_supported_test(): array {
 	$result = array(
 		'label'       => __( 'Your site supports WebP', 'performance-lab' ),
 		'status'      => 'good',
Index: includes/site-health/webp-support/hooks.php
===================================================================
--- includes/site-health/webp-support/hooks.php	(revision 3088546)
+++ includes/site-health/webp-support/hooks.php	(working copy)
@@ -15,10 +15,10 @@
  *
  * @since 1.0.0
  *
- * @param array $tests Site Health Tests.
- * @return array
+ * @param array{direct: array<string, array{label: string, test: string}>} $tests Site Health Tests.
+ * @return array{direct: array<string, array{label: string, test: string}>} Amended tests.
  */
-function webp_uploads_add_is_webp_supported_test( $tests ) {
+function webp_uploads_add_is_webp_supported_test( array $tests ): array {
 	$tests['direct']['webp_supported'] = array(
 		'label' => __( 'WebP Support', 'performance-lab' ),
 		'test'  => 'webp_uploads_check_webp_supported_test',
Index: load.php
===================================================================
--- load.php	(revision 3088546)
+++ load.php	(working copy)
@@ -4,8 +4,8 @@
  * Plugin URI: https://github.com/WordPress/performance
  * Description: Performance plugin from the WordPress Performance Team, which is a collection of standalone performance features.
  * Requires at least: 6.4
- * Requires PHP: 7.0
- * Version: 3.0.0
+ * Requires PHP: 7.2
+ * Version: 3.1.0
  * Author: WordPress Performance Team
  * Author URI: https://make.wordpress.org/performance/
  * License: GPLv2 or later
@@ -19,7 +19,7 @@
 	exit; // Exit if accessed directly.
 }
 
-define( 'PERFLAB_VERSION', '3.0.0' );
+define( 'PERFLAB_VERSION', '3.1.0' );
 define( 'PERFLAB_MAIN_FILE', __FILE__ );
 define( 'PERFLAB_PLUGIN_DIR_PATH', plugin_dir_path( PERFLAB_MAIN_FILE ) );
 define( 'PERFLAB_SCREEN', 'performance-lab' );
@@ -48,7 +48,7 @@
  * @since 2.9.0 The generator tag now includes the active standalone plugin slugs.
  * @since 3.0.0 The generator tag no longer includes module slugs.
  */
-function perflab_get_generator_content() {
+function perflab_get_generator_content(): string {
 	$active_plugins = array();
 	foreach ( perflab_get_standalone_plugin_version_constants() as $plugin_slug => $constant_name ) {
 		if ( defined( $constant_name ) && ! str_starts_with( constant( $constant_name ), 'Performance Lab ' ) ) {
@@ -71,7 +71,7 @@
  *
  * @since 1.1.0
  */
-function perflab_render_generator() {
+function perflab_render_generator(): void {
 	$content = perflab_get_generator_content();
 
 	echo '<meta name="generator" content="' . esc_attr( $content ) . '">' . "\n";
@@ -85,7 +85,7 @@
  *
  * @return array<string, array{'constant': string, 'experimental'?: bool}> Associative array of $plugin_slug => $plugin_data pairs.
  */
-function perflab_get_standalone_plugin_data() {
+function perflab_get_standalone_plugin_data(): array {
 	/*
 	 * Alphabetically sorted list of plugin slugs and their data.
 	 * Supported keys per plugin are:
@@ -125,7 +125,7 @@
  *
  * @return array<string, string> Map of plugin slug and the version constant used.
  */
-function perflab_get_standalone_plugin_version_constants() {
+function perflab_get_standalone_plugin_version_constants(): array {
 	return wp_list_pluck( perflab_get_standalone_plugin_data(), 'constant' );
 }
 
@@ -144,7 +144,7 @@
  *
  * @global WP_Filesystem_Base $wp_filesystem WordPress filesystem subclass.
  */
-function perflab_maybe_set_object_cache_dropin() {
+function perflab_maybe_set_object_cache_dropin(): void {
 	global $wp_filesystem;
 
 	// Bail if Server-Timing is disabled entirely.
@@ -255,7 +255,7 @@
  *
  * @global WP_Filesystem_Base $wp_filesystem WordPress filesystem subclass.
  */
-function perflab_maybe_remove_object_cache_dropin() {
+function perflab_maybe_remove_object_cache_dropin(): void {
 	global $wp_filesystem;
 
 	// Bail if disabled via constant.
@@ -298,7 +298,7 @@
  *
  * @global $plugin_page
  */
-function perflab_no_access_redirect_module_to_performance_feature_page() {
+function perflab_no_access_redirect_module_to_performance_feature_page(): void {
 	global $plugin_page;
 
 	if ( 'perflab-modules' !== $plugin_page ) {
@@ -319,7 +319,7 @@
  *
  * @since 3.0.0
  */
-function perflab_cleanup_option() {
+function perflab_cleanup_option(): void {
 	if ( current_user_can( 'manage_options' ) ) {
 		delete_option( 'perflab_modules_settings' );
 	}
Index: readme.txt
===================================================================
--- readme.txt	(revision 3088546)
+++ readme.txt	(working copy)
@@ -3,8 +3,8 @@
 Contributors:      wordpressdotorg
 Requires at least: 6.4
 Tested up to:      6.5
-Requires PHP:      7.0
-Stable tag:        3.0.0
+Requires PHP:      7.2
+Stable tag:        3.1.0
 License:           GPLv2 or later
 License URI:       https://www.gnu.org/licenses/gpl-2.0.html
 Tags:              performance, site health, measurement, optimization, diagnostics
@@ -60,6 +60,25 @@
 
 == Changelog ==
 
+= 3.1.0 =
+
+**Enhancements**
+
+* Add progress indicator when activating a feature. ([1190](https://github.com/WordPress/performance/pull/1190))
+* Display plugin settings links in the features screen and fix responsive layout for mobile. ([1208](https://github.com/WordPress/performance/pull/1208))
+* Add plugin dependency support for activating performance features. ([1184](https://github.com/WordPress/performance/pull/1184))
+* Add support for AVIF image format in site health. ([1177](https://github.com/WordPress/performance/pull/1177))
+* Add server timing to REST API response. ([1206](https://github.com/WordPress/performance/pull/1206))
+* Bump minimum PHP requirement to 7.2. ([1130](https://github.com/WordPress/performance/pull/1130))
+* Refine logic in perflab_install_activate_plugin_callback() to rely only on validated slug. ([1170](https://github.com/WordPress/performance/pull/1170))
+* Improve overall code quality with stricter static analysis checks. ([775](https://github.com/WordPress/performance/issues/775))
+
+**Bug Fixes**
+
+* Avoid passing incomplete data to perflab_render_plugin_card() and show error when plugin directory API query fails. ([1175](https://github.com/WordPress/performance/pull/1175))
+* Do not show admin pointer on the Performance screen and dismiss the pointer when visited. ([1147](https://github.com/WordPress/performance/pull/1147))
+* Fix `WordPress.DB.DirectDatabaseQuery.DirectQuery` warning for Autoloaded Options Health Check. ([1179](https://github.com/WordPress/performance/pull/1179))
+
 = 3.0.0 =
 
 **Enhancements**

@westonruter
Copy link
Member Author

@westonruter westonruter marked this pull request as ready for review May 18, 2024 01:09
@github-actions
Copy link

github-actions bot commented May 18, 2024

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

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

Co-authored-by: westonruter <[email protected]>
Co-authored-by: mukeshpanchal27 <[email protected]>
Co-authored-by: joemcgill <[email protected]>
Co-authored-by: sstopfer <[email protected]>

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

Copy link
Member

@mukeshpanchal27 mukeshpanchal27 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The changelog for PHP version bump is missing on all plugins.

@westonruter westonruter changed the base branch from trunk to release/3.1.0 May 20, 2024 16:16
@westonruter
Copy link
Member Author

✅ Smoke testing of Performance Lab successful:

  • Admin pointer successfully auto-dismissed when landing on the Performance screen.
  • I see the AVIF Site Health test: image
  • I enabled Server-Timing and added the rest_pre_dispatch filter to be timed, and when going to /wp-json/ I see it show up:
    image
  • Responsive layout of Settings screen is working, and Settings links appear:
Screen.recording.2024-05-20.09.37.14.webm
  • Loading spinner appears when activating a feature:
Screen.recording.2024-05-20.09.39.02.webm
  • The settings link appears in the activated admin notice:
Screen.recording.2024-05-20.09.40.49.webm

Copy link
Member

@joemcgill joemcgill left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍🏻

@westonruter
Copy link
Member Author

westonruter commented May 20, 2024

✅ Smoke testing other plugins:

  • Auto Sizes continues to include auto in the sizes attribute when lazy-loading.
  • Image Placeholders is generating the dominant color and partially-transparent images are getting data-has-transparent=true.
  • Embed Optimizer: Post Embeds are lazy-loaded and are initially-hidden with visibility:hidden.
  • Optimization Detective: Detection script is being injected, XPath attributes are being added, and od_url_metrics posts are being added. Adding ?optimization_detective_disabled to a URL now disables the functionality. XPath indices are starting at 1 instead of 0.
  • Speculative Loading: Linked images in uploads directory are now successfully omitted from being speculatively loaded.
  • Modern Image Formats: Uploaded images are automatically converted to WebP.

@westonruter westonruter merged commit be0727f into release/3.1.0 May 20, 2024
@westonruter westonruter deleted the publish/3.1.0 branch May 20, 2024 17:05
@westonruter westonruter mentioned this pull request Jun 5, 2024
7 tasks
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

skip changelog PRs that should not be mentioned in changelogs [Type] Documentation Documentation to be added or enhanced

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Prepare 3.1.0 release

4 participants