self::normalize_score( $score ), 'first-contentful-paint' => self::collect_core_metric( $data, 'first-contentful-paint' ), 'largest-contentful-paint' => self::collect_core_metric( $data, 'largest-contentful-paint' ), 'interactive' => self::collect_core_metric( $data, 'interactive' ), 'cumulative-layout-shift' => self::collect_core_metric( $data, 'cumulative-layout-shift' ), 'total-blocking-time' => self::collect_core_metric( $data, 'total-blocking-time' ), 'speed-index' => self::collect_core_metric( $data, 'speed-index' ), 'screenshots' => array( 'final' => array( 'title' => Util_PageSpeed::get_value_recursive( $data, array( 'lighthouseResult', 'audits', 'final-screenshot', 'title' ) ), 'screenshot' => Util_PageSpeed::get_value_recursive( $data, array( 'lighthouseResult', 'audits', 'final-screenshot', 'details', 'data' ) ), ), 'other' => array( 'title' => Util_PageSpeed::get_value_recursive( $data, array( 'lighthouseResult', 'audits', 'screenshot-thumbnails', 'title' ) ), 'screenshots' => Util_PageSpeed::get_value_recursive( $data, array( 'lighthouseResult', 'audits', 'screenshot-thumbnails', 'details', 'items' ) ), ), ), 'insights' => self::collect_audits_by_group( $data, 'insights' ), 'diagnostics' => self::collect_audits_by_group( $data, 'diagnostics' ), ); $pagespeed_data['insights'] = self::filter_metrics_by_title( $pagespeed_data['insights'] ); $pagespeed_data['diagnostics'] = self::filter_metrics_by_title( $pagespeed_data['diagnostics'] ); if ( defined( 'W3TC_GPS_KEYS_DEBUG' ) ) { self::debug_metric_keys( $pagespeed_data ); } return self::merge_instructions( $pagespeed_data ); } /** * Collect core web vital metrics in a consistent format. * * @since 2.9.1 * * @param array $data PageSpeed data payload. * @param string $metric Lighthouse audit identifier. * * @return array */ private static function collect_core_metric( $data, $metric ) { return array( 'score' => Util_PageSpeed::get_value_recursive( $data, array( 'lighthouseResult', 'audits', $metric, 'score' ) ), 'scoreDisplayMode' => Util_PageSpeed::get_value_recursive( $data, array( 'lighthouseResult', 'audits', $metric, 'scoreDisplayMode' ) ), 'displayValue' => Util_PageSpeed::get_value_recursive( $data, array( 'lighthouseResult', 'audits', $metric, 'displayValue' ) ), ); } /** * Log the raw metric keys and configured instruction keys when debugging is enabled. * * @since 2.9.1 * * @param array $pagespeed_data Prepared PageSpeed data. * * @return void */ private static function debug_metric_keys( $pagespeed_data ) { $gps_insight_ids = array_keys( $pagespeed_data['insights'] ?? array() ); $gps_diagnostic_ids = array_keys( $pagespeed_data['diagnostics'] ?? array() ); $instruction_config = PageSpeed_Instructions::get_pagespeed_instructions(); $w3tc_insight_ids = ! empty( $instruction_config['insights'] ) ? array_keys( $instruction_config['insights'] ) : array(); $w3tc_diagnostic_ids = ! empty( $instruction_config['diagnostics'] ) ? array_keys( $instruction_config['diagnostics'] ) : array(); \sort( $gps_insight_ids ); \sort( $gps_diagnostic_ids ); \sort( $w3tc_insight_ids ); \sort( $w3tc_diagnostic_ids ); Util_Debug::debug( 'pagespeed_metric_keys', array( 'gps' => array( 'insights' => $gps_insight_ids, 'diagnostics' => $gps_diagnostic_ids, ), 'w3tc' => array( 'insights' => $w3tc_insight_ids, 'diagnostics' => $w3tc_diagnostic_ids, ), ) ); } /** * Collect audits belonging to the given Lighthouse category group. * * @since 2.9.1 * * @param array $data Raw Lighthouse API payload. * @param string $group Lighthouse category group identifier. * * @return array */ private static function collect_audits_by_group( $data, $group ) { $audit_refs = Util_PageSpeed::get_value_recursive( $data, array( 'lighthouseResult', 'categories', 'performance', 'auditRefs' ) ); $audits = Util_PageSpeed::get_value_recursive( $data, array( 'lighthouseResult', 'audits' ) ); if ( empty( $audit_refs ) || ! \is_array( $audit_refs ) || empty( $audits ) || ! \is_array( $audits ) ) { return array(); } $metrics = array(); foreach ( $audit_refs as $audit_ref ) { if ( empty( $audit_ref['id'] ) || empty( $audit_ref['group'] ) || $group !== $audit_ref['group'] ) { continue; } $audit_id = $audit_ref['id']; if ( empty( $audits[ $audit_id ] ) || ! \is_array( $audits[ $audit_id ] ) ) { continue; } $metrics[ $audit_id ] = self::format_audit_metric( $audit_id, $audits[ $audit_id ] ); } return $metrics; } /** * Format a single Lighthouse audit into the structure expected by the UI. * * @since 2.9.1 * * @param string $audit_id Lighthouse audit identifier. * @param array $audit Lighthouse audit payload. * * @return array */ private static function format_audit_metric( $audit_id, $audit ) { $metric = array( 'id' => $audit_id, 'title' => $audit['title'] ?? null, 'description' => $audit['description'] ?? null, 'score' => $audit['score'] ?? null, 'scoreDisplayMode' => $audit['scoreDisplayMode'] ?? null, 'displayValue' => $audit['displayValue'] ?? null, 'details' => self::extract_audit_details( $audit['details'] ?? array() ), ); if ( 'network-dependency-tree-insight' === $audit_id ) { $metric['networkDependency'] = self::format_network_dependency_details( $audit['details'] ?? array() ); $metric['details'] = array(); } $types = self::resolve_metric_types( $audit_id, $audit ); if ( ! empty( $types ) ) { $metric['type'] = $types; } return $metric; } /** * Normalize Lighthouse audit details to a list structure. * * @since 2.9.1 * * @param mixed $details Lighthouse audit details. * * @return array */ private static function extract_audit_details( $details ) { if ( empty( $details ) || ! \is_array( $details ) ) { return array(); } if ( isset( $details['items'] ) && \is_array( $details['items'] ) ) { return $details['items']; } $alternative_keys = array( 'chains', 'nodes', 'entries', 'timings' ); foreach ( $alternative_keys as $key ) { if ( isset( $details[ $key ] ) && \is_array( $details[ $key ] ) ) { return $details[ $key ]; } } return array( $details ); } /** * Determine which Core Web Vitals an audit influences. * * @since 2.9.1 * * @param string $audit_id Lighthouse audit identifier. * @param array $audit Lighthouse audit payload. * * @return array */ private static function resolve_metric_types( $audit_id, $audit ) { $type_map = self::get_metric_type_map(); $types = array(); if ( isset( $type_map[ $audit_id ] ) ) { $types = $type_map[ $audit_id ]; } if ( empty( $types ) && isset( $audit['metricSavings'] ) && \is_array( $audit['metricSavings'] ) ) { foreach ( $audit['metricSavings'] as $metric => $value ) { if ( \in_array( $metric, array( 'FCP', 'LCP', 'TBT', 'CLS' ), true ) ) { $types[] = $metric; } } } return \array_values( \array_unique( $types ) ); } /** * Normalize the network dependency tree insight payload. * * @since 2.9.1 * * @param array $details Lighthouse network dependency tree details payload. * * @return array */ private static function format_network_dependency_details( $details ) { if ( empty( $details ) || ! \is_array( $details ) ) { return array(); } $items = $details['items'] ?? array(); $tree_section = $items[0]['value'] ?? array(); $preconnected_section = $items[1] ?? array(); $candidates_section = $items[2] ?? array(); $chains = $tree_section['chains'] ?? array(); $normalized_chains = array(); if ( ! empty( $chains ) && \is_array( $chains ) ) { foreach ( $chains as $chain ) { $normalized_chains[] = self::normalize_network_chain_node( $chain ); } } return array( 'longestChainDuration' => $tree_section['longestChain']['duration'] ?? null, 'chains' => $normalized_chains, 'preconnected' => self::format_preconnect_section( $preconnected_section ), 'candidates' => self::format_preconnect_section( $candidates_section ), ); } /** * Normalize a network dependency chain node recursively. * * @since 2.9.1 * * @param array $node Node payload. * * @return array */ private static function normalize_network_chain_node( $node ) { $children = array(); if ( ! empty( $node['children'] ) && \is_array( $node['children'] ) ) { foreach ( $node['children'] as $child ) { $children[] = self::normalize_network_chain_node( $child ); } } return array( 'url' => $node['url'] ?? '', 'duration' => $node['navStartToEndTime'] ?? null, 'transferSize' => $node['transferSize'] ?? null, 'isLongest' => (bool) ( $node['isLongest'] ?? false ), 'children' => $children, ); } /** * Normalize preconnect insight sections. * * @since 2.9.1 * * @param array $section Section payload from Lighthouse. * * @return array */ private static function format_preconnect_section( $section ) { if ( empty( $section ) || ! \is_array( $section ) ) { return array(); } $value = $section['value'] ?? array(); $data = array( 'title' => $section['title'] ?? '', 'description' => $section['description'] ?? '', 'entries' => array(), ); if ( isset( $value['value'] ) && ! empty( $value['value'] ) && \is_string( $value['value'] ) ) { $data['entries'] = $value['value']; return $data; } if ( isset( $value['items'] ) && \is_array( $value['items'] ) ) { $entries = array(); foreach ( $value['items'] as $item ) { if ( \is_string( $item ) ) { $entries[] = $item; } elseif ( isset( $item['origin'] ) ) { $entries[] = $item['origin']; } elseif ( isset( $item['value'] ) && \is_string( $item['value'] ) ) { $entries[] = $item['value']; } } if ( ! empty( $entries ) ) { $data['entries'] = $entries; } elseif ( ! empty( $value ) ) { $data['entries'] = \wp_json_encode( $value ); } } elseif ( ! empty( $value ) && \is_string( $value ) ) { $data['entries'] = $value; } return $data; } /** * Provide a mapping of audit identifiers to Core Web Vital type tags. * * @since 2.9.1 * * @return array */ private static function get_metric_type_map() { return array( 'render-blocking-insight' => array( 'FCP', 'LCP' ), 'render-blocking-resources' => array( 'FCP', 'LCP' ), 'unused-css-rules' => array( 'FCP', 'LCP' ), 'unminified-css' => array( 'FCP', 'LCP' ), 'unminified-javascript' => array( 'FCP', 'LCP' ), 'unused-javascript' => array( 'LCP' ), 'uses-text-compression' => array( 'FCP', 'LCP' ), 'uses-rel-preconnect' => array( 'FCP', 'LCP' ), 'server-response-time' => array( 'FCP', 'LCP' ), 'redirects' => array( 'FCP', 'LCP' ), 'efficient-animated-content' => array( 'LCP' ), 'duplicated-javascript' => array( 'TBT' ), 'duplicated-javascript-insight' => array( 'TBT' ), 'legacy-javascript' => array( 'TBT' ), 'legacy-javascript-insight' => array( 'TBT' ), 'total-byte-weight' => array( 'LCP' ), 'dom-size' => array( 'TBT' ), 'dom-size-insight' => array( 'TBT' ), 'bootup-time' => array( 'TBT' ), 'mainthread-work-breakdown' => array( 'TBT' ), 'third-party-summary' => array( 'TBT' ), 'third-parties-insight' => array( 'TBT' ), 'third-party-facades' => array( 'TBT' ), 'non-composited-animations' => array( 'CLS' ), 'unsized-images' => array( 'CLS' ), 'cls-culprits-insight' => array( 'CLS' ), 'font-display' => array( 'FCP', 'LCP' ), 'font-display-insight' => array( 'FCP', 'LCP' ), 'cache-insight' => array( 'FCP', 'LCP' ), 'document-latency-insight' => array( 'FCP', 'LCP' ), 'network-dependency-tree-insight' => array( 'FCP', 'LCP' ), 'viewport' => array( 'TBT' ), 'viewport-insight' => array( 'TBT' ), 'lcp-breakdown-insight' => array( 'LCP' ), 'lcp-discovery-insight' => array( 'LCP' ), 'image-delivery-insight' => array( 'LCP' ), 'forced-reflow-insight' => array( 'TBT' ), ); } /** * Normalize score values to 0-100 scale while avoiding PHP warnings when score is missing. * * @since 2.9.1 * * @param mixed $score Score from the Lighthouse payload. * * @return int */ private static function normalize_score( $score ) { if ( ! isset( $score ) || ! \is_numeric( $score ) ) { return 0; } return $score * 100; } /** * Drop metrics that Google didn't include in the latest payload. * * @since 2.9.1 * * @param array $metrics Raw metrics bucket. * * @return array */ private static function filter_metrics_by_title( $metrics ) { if ( empty( $metrics ) || ! \is_array( $metrics ) ) { return array(); } return \array_filter( $metrics, static function ( $metric ) { return \is_array( $metric ) && isset( $metric['title'] ) && '' !== $metric['title']; } ); } /** * Attach instructions for metrics that survived the filtering step. * * @since 2.9.1 * * @param array $pagespeed_data Prepared PageSpeed data. * * @return array */ private static function merge_instructions( $pagespeed_data ) { $instructions = PageSpeed_Instructions::get_pagespeed_instructions(); $default_instruction = '

' . \esc_html__( 'W3 Total Cache does not yet have guidance for this audit.', 'w3-total-cache' ) . '

'; foreach ( array( 'insights', 'diagnostics' ) as $bucket ) { if ( empty( $pagespeed_data[ $bucket ] ) ) { continue; } if ( empty( $instructions[ $bucket ] ) ) { foreach ( $pagespeed_data[ $bucket ] as $key => $metric ) { $pagespeed_data[ $bucket ][ $key ]['instructions'] = $default_instruction; } continue; } foreach ( $pagespeed_data[ $bucket ] as $key => $metric ) { if ( isset( $instructions[ $bucket ][ $key ] ) ) { $pagespeed_data[ $bucket ][ $key ] = \array_merge( $metric, $instructions[ $bucket ][ $key ] ); } else { $pagespeed_data[ $bucket ][ $key ]['instructions'] = $default_instruction; } } } return $pagespeed_data; } }