diff --git a/plugins/performance-lab/includes/admin/load.php b/plugins/performance-lab/includes/admin/load.php index 661b377657..706a88440d 100644 --- a/plugins/performance-lab/includes/admin/load.php +++ b/plugins/performance-lab/includes/admin/load.php @@ -636,4 +636,6 @@ function perflab_print_row_meta_install_notice( string $plugin_file ): void { wp_kses( $message, array( 'a' => array( 'href' => array() ) ) ) ); } + add_action( 'after_plugin_row_meta', 'perflab_print_row_meta_install_notice' ); + diff --git a/plugins/performance-lab/includes/admin/local-plugin-fallback.php b/plugins/performance-lab/includes/admin/local-plugin-fallback.php new file mode 100644 index 0000000000..c6c2d97258 --- /dev/null +++ b/plugins/performance-lab/includes/admin/local-plugin-fallback.php @@ -0,0 +1,15 @@ + Local plugin data keyed by slug. + */ +function perflab_get_local_plugin_fallback_data( array $plugin_slugs ): array { + require_once ABSPATH . 'wp-admin/includes/plugin.php'; + + $local_plugins = get_plugins(); + $fallback_data = array(); + + foreach ( $plugin_slugs as $plugin_slug ) { + // Look for plugin files that match this slug. + $plugin_file = perflab_find_local_plugin_file( $local_plugins, $plugin_slug ); + + if ( false === $plugin_file ) { + continue; // Plugin not installed locally. + } + + $plugin_headers = $local_plugins[ $plugin_file ]; + $is_active = is_plugin_active( $plugin_file ); + + // Build normalized plugin data similar to WordPress.org API response. + $readme_description = perflab_get_plugin_readme_description( $plugin_file ); + $description = '' !== $readme_description ? $readme_description : ( $plugin_headers['Description'] ?? '' ); + + $fallback_data[ $plugin_slug ] = array( + 'name' => $plugin_headers['Name'] ?? $plugin_slug, + 'slug' => $plugin_slug, + 'short_description' => $description, + 'requires' => $plugin_headers['RequiresWP'] ?? false, + 'requires_php' => $plugin_headers['RequiresPHP'] ?? false, + 'requires_plugins' => perflab_get_plugin_dependencies( $plugin_slug, $plugin_headers ), + 'version' => $plugin_headers['Version'] ?? '0.0.0', + 'is_installed' => true, + 'is_active' => $is_active, + ); + } + + return $fallback_data; +} + +/** + * Finds the plugin file for a given slug among installed plugins. + * + * @since n.e.x.t + * + * @param array> $local_plugins Array from get_plugins(). + * @param string $plugin_slug Plugin slug to find. + * @return string|false Plugin file path relative to plugins directory, or false if not found. + */ +function perflab_find_local_plugin_file( array $local_plugins, string $plugin_slug ) { + foreach ( $local_plugins as $plugin_file => $plugin_data ) { + // Extract directory name from plugin file path. + $plugin_dir = strtok( $plugin_file, '/' ); + + if ( $plugin_dir === $plugin_slug ) { + return $plugin_file; + } + } + + return false; +} + +/** + * Gets plugin description from readme file. + * + * @since n.e.x.t + * + * @param string $plugin_file Plugin file path. + * @return string Plugin description from readme or empty string. + */ +function perflab_get_plugin_readme_description( string $plugin_file ): string { + $plugin_dir = dirname( WP_PLUGIN_DIR . '/' . $plugin_file ); + $readme_path = $plugin_dir . '/readme.txt'; + + if ( ! file_exists( $readme_path ) ) { + return ''; + } + + $readme_content = file_get_contents( $readme_path ); + if ( false === $readme_content ) { + return ''; + } + + // Parse description from readme - it's the paragraph BEFORE "== Description ==". + if ( 1 === preg_match( '/^(.+?)\s*==\s*Description\s*==/s', $readme_content, $matches ) ) { + $lines = array_map( 'trim', explode( "\n", trim( $matches[1] ) ) ); + + // Find the first substantial line after the header section. + $description_line = ''; + $in_header = true; + + foreach ( $lines as $line ) { + if ( $in_header && ( '' === $line || false !== strpos( $line, ':' ) ) ) { + continue; // Skip header lines and empty lines. + } + $in_header = false; + + if ( '' !== $line ) { + $description_line = $line; + break; + } + } + + return trim( $description_line ); + } + + return ''; +} + +/** + * Gets plugin dependencies including both header requirements and known soft dependencies. + * + * @since n.e.x.t + * + * @param string $plugin_slug Plugin slug. + * @param array $plugin_headers Plugin headers array. + * @return list Array of required plugin slugs. + */ +function perflab_get_plugin_dependencies( string $plugin_slug, array $plugin_headers ): array { + $requires_plugins = array(); + + // Check for RequiresPlugins header (WordPress 6.5+). + if ( isset( $plugin_headers['RequiresPlugins'] ) && '' !== $plugin_headers['RequiresPlugins'] ) { + $plugins = array_map( 'trim', explode( ',', $plugin_headers['RequiresPlugins'] ) ); + $requires_plugins = array_merge( $requires_plugins, $plugins ); + } + + // Add soft dependencies from standalone plugin data. + $standalone_data = perflab_get_standalone_plugin_data(); + if ( isset( $standalone_data[ $plugin_slug ]['suggests_plugins'] ) ) { + $requires_plugins = array_merge( $requires_plugins, $standalone_data[ $plugin_slug ]['suggests_plugins'] ); + } + + /* @var array $filtered */ + $filtered = array_filter( $requires_plugins ); + $unique = array_unique( $filtered ); + // Reindex to ensure a list without relying on array_values (for PHPStan template inference). + /* @var list $result */ + $result = array(); + foreach ( $unique as $dep ) { + $result[] = $dep; + } + return $result; +} + +/** + * Adds soft dependencies (suggested plugins) to an existing list of required plugins. + * + * @since n.e.x.t + * + * @param string $plugin_slug Plugin slug. + * @param string[] $requires_plugins Current list of required plugin slugs. + * @return list Merged list with suggested plugins included. + */ +function perflab_add_suggested_plugins( string $plugin_slug, array $requires_plugins = array() ): array { + $standalone_data = perflab_get_standalone_plugin_data(); + if ( isset( $standalone_data[ $plugin_slug ]['suggests_plugins'] ) ) { + $requires_plugins = array_merge( $requires_plugins, $standalone_data[ $plugin_slug ]['suggests_plugins'] ); + } + + /* @var array $filtered */ + $filtered = array_filter( $requires_plugins ); + $unique = array_unique( $filtered ); + // Reindex to ensure a list without relying on array_values (for PHPStan template inference). + /* @var list $result */ + $result = array(); + foreach ( $unique as $dep ) { + $result[] = $dep; + } + return $result; +} + +/** + * Parse requires_plugins from plugin headers. + * + * Back-compat wrapper retained for tests; delegates to perflab_get_plugin_dependencies() + * and applies minimal historical behavior. + * + * @since n.e.x.t + * + * @param array $plugin_headers Plugin headers array. + * @param string $plugin_slug Plugin slug to determine dependencies. + * @return list Array of required plugin slugs. + */ +function perflab_parse_requires_plugins( array $plugin_headers, string $plugin_slug ): array { + $deps = perflab_get_plugin_dependencies( $plugin_slug, $plugin_headers ); + + // Historical behavior: Image Prioritizer also depends on Optimization Detective. + // In production this should be declared via RequiresPlugins, but keep here to avoid behavior changes. + if ( 'image-prioritizer' === $plugin_slug ) { + $deps[] = 'optimization-detective'; + } + + /* @var array $filtered */ + $filtered = array_filter( $deps ); + $unique = array_unique( $filtered ); + // Reindex to ensure a list without relying on array_values (for PHPStan template inference). + /* @var list $result */ + $result = array(); + foreach ( $unique as $dep ) { + $result[] = $dep; + } + return $result; +} + +/** + * Sanitize plugin description for display. + * + * Back-compat wrapper retained for tests. + * + * @since n.e.x.t + * + * @param string $description Raw plugin description. + * @return string Sanitized description. + */ +function perflab_sanitize_plugin_description( string $description ): string { + if ( '' === $description ) { + return ''; + } + + // Strip all HTML tags and decode entities. + $description = wp_strip_all_tags( $description ); + $description = html_entity_decode( $description, ENT_QUOTES, 'UTF-8' ); + + return trim( $description ); +} + +/** + * Check if external requests are blocked or likely to fail. + * + * @since n.e.x.t + * + * @return bool True if external requests are blocked or should be avoided. + */ +function perflab_are_external_requests_blocked(): bool { + // Check if external requests are explicitly blocked. + if ( defined( 'WP_HTTP_BLOCK_EXTERNAL' ) && WP_HTTP_BLOCK_EXTERNAL ) { + // Check if wordpress.org is in the allowed hosts. + $allowed_hosts = defined( 'WP_ACCESSIBLE_HOSTS' ) ? WP_ACCESSIBLE_HOSTS : ''; + if ( '' === $allowed_hosts ) { + return true; + } + + $allowed_hosts_array = array_map( 'trim', explode( ',', $allowed_hosts ) ); + if ( ! in_array( 'wordpress.org', $allowed_hosts_array, true ) ) { + return true; + } + } + + return false; +} + +/** + * Get plugin info for the given plugin slug from WordPress.org. + * + * Falls back to local plugin data when external requests are disabled or fail. * * @since 2.8.0 + * @since n.e.x.t Added fallback to local plugin data when external requests fail. * * @param string $plugin_slug The string identifier for the plugin in questions slug. * @return array{name: string, slug: string, short_description: string, requires: string|false, requires_php: string|false, requires_plugins: string[], version: string}|WP_Error Array of plugin data or WP_Error if failed. @@ -35,6 +299,21 @@ function perflab_query_plugin_info( string $plugin_slug ) { return $plugins[ $plugin_slug ]; // Return cached plugin info if found. } + // Check if external requests are blocked and if we should use local fallback. + $should_use_fallback = perflab_are_external_requests_blocked(); + + if ( $should_use_fallback ) { + // Try to get local plugin data instead. + $local_data = perflab_get_local_plugin_fallback_data( array( $plugin_slug ) ); + if ( isset( $local_data[ $plugin_slug ] ) ) { + // Cache the local data with a shorter expiration. + $cached_plugins = is_array( $plugins ) ? $plugins : array(); + $cached_plugins[ $plugin_slug ] = $local_data[ $plugin_slug ]; + set_transient( $transient_key, $cached_plugins, 5 * MINUTE_IN_SECONDS ); + return $local_data[ $plugin_slug ]; + } + } + $fields = array( 'name', 'slug', @@ -60,6 +339,19 @@ function perflab_query_plugin_info( string $plugin_slug ) { $plugins = array(); if ( is_wp_error( $response ) ) { + // Try local fallback first before giving up. + $local_fallback_data = perflab_get_local_plugin_fallback_data( perflab_get_standalone_plugins() ); + + if ( count( $local_fallback_data ) > 0 ) { + // We have some local plugins to show, cache them. + set_transient( $transient_key, $local_fallback_data, 5 * MINUTE_IN_SECONDS ); + + if ( isset( $local_fallback_data[ $plugin_slug ] ) ) { + return $local_fallback_data[ $plugin_slug ]; + } + } + + // No local fallback available, store error. $plugins[ $plugin_slug ] = array( 'error' => array( 'code' => 'api_error', @@ -77,6 +369,19 @@ function perflab_query_plugin_info( string $plugin_slug ) { $has_errors = true; } elseif ( ! is_object( $response ) || ! property_exists( $response, 'plugins' ) ) { + // Try local fallback first before giving up. + $local_fallback_data = perflab_get_local_plugin_fallback_data( perflab_get_standalone_plugins() ); + + if ( count( $local_fallback_data ) > 0 ) { + // We have some local plugins to show, cache them. + set_transient( $transient_key, $local_fallback_data, 5 * MINUTE_IN_SECONDS ); + + if ( isset( $local_fallback_data[ $plugin_slug ] ) ) { + return $local_fallback_data[ $plugin_slug ]; + } + } + + // No local fallback available, store error. $plugins[ $plugin_slug ] = array( 'error' => array( 'code' => 'no_plugins', @@ -156,7 +461,7 @@ function perflab_query_plugin_info( string $plugin_slug ) { } /** - * Returns an array of WPP standalone plugins. + * Return an array of WPP standalone plugins. * * @since 2.8.0 * @@ -169,7 +474,7 @@ function perflab_get_standalone_plugins(): array { } /** - * Renders plugin UI for managing standalone plugins within PL Settings screen. + * Render plugin UI for managing standalone plugins within PL Settings screen. * * @since 2.8.0 */ @@ -427,9 +732,10 @@ function perflab_install_and_activate_plugin( string $plugin_slug, array &$proce } // Add recommended plugins (soft dependencies) to the list of plugins installed and activated. - if ( 'embed-optimizer' === $plugin_slug ) { - $plugin_data['requires_plugins'][] = 'optimization-detective'; - } + $plugin_data['requires_plugins'] = perflab_add_suggested_plugins( + $plugin_slug, + $plugin_data['requires_plugins'] ?? array() + ); // Install and activate plugin dependencies first. foreach ( $plugin_data['requires_plugins'] as $requires_plugin_slug ) { diff --git a/plugins/performance-lab/load.php b/plugins/performance-lab/load.php index f3684b1a25..3212e87ce2 100644 --- a/plugins/performance-lab/load.php +++ b/plugins/performance-lab/load.php @@ -103,8 +103,10 @@ function perflab_get_standalone_plugin_data(): array { 'constant' => 'DOMINANT_COLOR_IMAGES_VERSION', ), 'embed-optimizer' => array( - 'constant' => 'EMBED_OPTIMIZER_VERSION', - 'experimental' => false, + 'constant' => 'EMBED_OPTIMIZER_VERSION', + 'experimental' => false, + // Soft dependency: Optimization Detective enhances functionality. + 'suggests_plugins' => array( 'optimization-detective' ), ), 'image-prioritizer' => array( 'constant' => 'IMAGE_PRIORITIZER_VERSION', diff --git a/plugins/performance-lab/tests/includes/admin/test-local-plugin-fallback.php b/plugins/performance-lab/tests/includes/admin/test-local-plugin-fallback.php new file mode 100644 index 0000000000..860efe14d4 --- /dev/null +++ b/plugins/performance-lab/tests/includes/admin/test-local-plugin-fallback.php @@ -0,0 +1,111 @@ + array( + 'Name' => 'Modern Image Formats', + 'Description' => 'Convert and serve images in modern formats like WebP.', + 'Version' => '2.0.0', + 'Author' => 'WordPress Performance Team', + 'RequiresWP' => '6.0', + 'RequiresPHP' => '7.4', + ), + ); + + // Mock get_plugins() function. + add_filter( + 'pre_option_active_plugins', + static function () { + return array( 'webp-uploads/load.php' ); + } + ); + + // Test that we detect external requests are blocked. + $this->assertTrue( perflab_are_external_requests_blocked() ); + + // Test that we can get local fallback data. + $local_data = perflab_get_local_plugin_fallback_data( array( 'webp-uploads' ) ); + + // We can't directly test this without mocking get_plugins(), but let's ensure the function exists. + $this->assertTrue( function_exists( 'perflab_get_local_plugin_fallback_data' ) ); + $this->assertTrue( function_exists( 'perflab_are_external_requests_blocked' ) ); + } + + /** + * Test that plugin file lookup works correctly. + */ + public function test_find_local_plugin_file(): void { + $mock_plugins = array( + 'webp-uploads/load.php' => array( 'Name' => 'Modern Image Formats' ), + 'optimization-detective/load.php' => array( 'Name' => 'Optimization Detective' ), + 'single-file-plugin.php' => array( 'Name' => 'Single File Plugin' ), + ); + + $this->assertEquals( 'webp-uploads/load.php', perflab_find_local_plugin_file( $mock_plugins, 'webp-uploads' ) ); + $this->assertEquals( 'optimization-detective/load.php', perflab_find_local_plugin_file( $mock_plugins, 'optimization-detective' ) ); + $this->assertFalse( perflab_find_local_plugin_file( $mock_plugins, 'nonexistent-plugin' ) ); + } + + /** + * Test plugin description sanitization. + */ + public function test_sanitize_plugin_description(): void { + $test_cases = array( + 'Simple text' => 'Simple text', + 'Text with HTML tags' => 'Text with HTML tags', + 'Text with & entities' => 'Text with & entities', + '' => '', + ); + + foreach ( $test_cases as $input => $expected ) { + $this->assertEquals( $expected, perflab_sanitize_plugin_description( $input ) ); + } + } + + /** + * Test requires_plugins parsing. + */ + public function test_parse_requires_plugins(): void { + // Test RequiresPlugins header. + $headers = array( + 'RequiresPlugins' => 'optimization-detective, auto-sizes', + ); + $result = perflab_parse_requires_plugins( $headers, 'test-plugin' ); + $this->assertContains( 'optimization-detective', $result ); + $this->assertContains( 'auto-sizes', $result ); + + // Test known dependency: Embed Optimizer. + $headers = array(); + $result = perflab_parse_requires_plugins( $headers, 'embed-optimizer' ); + $this->assertContains( 'optimization-detective', $result ); + + // Test known dependency: Image Prioritizer. + $headers = array(); + $result = perflab_parse_requires_plugins( $headers, 'image-prioritizer' ); + $this->assertContains( 'optimization-detective', $result ); + } +}