*/ private $assets = array(); /** * Known shared libraries: label => name pattern (regex fragment). A handle or a file name is the * library when it is that name followed only by a version and neutral parts (TAIL), so * `jquery.min.js`, `jquery-3.7.1.min.js` and `jquery-core` are jQuery while add-ons such as * `jquery-blockui`, `jquery.cookie.min.js` or `bootstrap-datepicker` are not. * * @var array */ const LIBRARIES = array( 'jQuery' => 'jquery', 'jQuery UI' => 'jquery[-_.]?ui(?:[-_.](?:accordion|autocomplete|button|checkboxradio|controlgroup|core|datepicker|dialog|draggable|droppable|effect(?:[-_.][a-z]+)?|i18n|menu|mouse|position|progressbar|resizable|selectable|selectmenu|slider|sortable|spinner|structure|tabs|theme|tooltip|widget))?', 'Font Awesome' => '(?:[a-z0-9]+[-_.])*font[-_.]?awesome', 'Swiper' => 'swiper', 'Slick' => 'slick(?:[-_.]?carousel)?', 'Owl Carousel' => 'owl[-_.]?carousel', 'Bootstrap' => 'bootstrap(?:[-_.](?:grid|reboot|utilities|rtl))*', 'Fancybox' => '(?:jquery[-_.])?fancybox', 'Magnific Popup' => '(?:jquery[-_.])?magnific(?:[-_.]?popup)?', 'PhotoSwipe' => 'photoswipe(?:[-_.](?:ui[-_.]default|default[-_.]skin|lightbox))?', 'GLightbox' => 'glightbox', 'Lightbox2' => 'lightbox(?:[-_.]plus[-_.]jquery)?', 'GSAP' => 'gsap|tweenmax|tweenlite', 'Chart.js' => 'chart(?:[-_.]?js)?', 'Lodash' => 'lodash', 'Underscore.js' => 'underscore', 'Moment.js' => 'moment(?:[-_.]with[-_.]locales)?', 'Select2' => 'select2|selectwoo', 'Animate.css' => 'animate(?=(?:[-_.]min)?[-_.]css$)|animate[-_.]?css', 'Isotope' => '(?:jquery[-_.])?isotope', 'Masonry' => '(?:jquery[-_.])?masonry(?:[-_.]layout)?', 'AOS' => 'aos', ); /** * What may follow a library name in a handle or file name: a version, then neutral parts such as * `.min`, `.js`, `-bundle` or `-css`. */ const TAIL = '(?:[-_.]?v?\d+(?:\.\d+)*)?(?:[-_.](?:v?\d+(?:\.\d+)*|min|js|css|bundle|pkgd|umd|esm|all|core|full|slim|custom|dist|lib|script|scripts|style|styles|cdn|free|pro|theme))*'; /** * Hosted services recognised by URL: label => regex on host + path. * * @var array */ const HOSTED = array( 'Google Fonts' => '#^fonts\.googleapis\.com/#i', 'Google Maps API' => '#^maps\.googleapis\.com/#i', 'reCAPTCHA' => '#^(?:www\.)?(?:google\.com|gstatic\.com|recaptcha\.net)/recaptcha/#i', ); /** * Remote size cache (transient) TTL. */ const REMOTE_TTL = DAY_IN_SECONDS; /** * Cached value for "HEAD request sent, size unknown" (a transient cannot hold false or null). */ const UNKNOWN_SIZE = -1; /** * Remote assets whose size is still to be looked up: asset id => true. Looked up by * resolve_remote_sizes(), which the profiler calls only after the request's timings were taken. * * @var array */ private $pending = array(); /** * Constructor. * * @param Owner $owner Owner resolver. */ public function __construct( Owner $owner ) { $this->owner = $owner; } /** * Hook collection points. */ public function start(): void { add_action( 'wp_print_scripts', array( $this, 'collect' ), PHP_INT_MAX ); add_action( 'wp_print_styles', array( $this, 'collect' ), PHP_INT_MAX ); add_action( 'wp_print_footer_scripts', array( $this, 'collect' ), PHP_INT_MAX ); add_action( 'admin_print_scripts', array( $this, 'collect' ), PHP_INT_MAX ); add_action( 'admin_print_styles', array( $this, 'collect' ), PHP_INT_MAX ); add_action( 'admin_print_footer_scripts', array( $this, 'collect' ), PHP_INT_MAX ); add_action( 'shutdown', array( $this, 'collect' ), -100 ); } /** * Read done/queued handles from both dependency managers. */ public function collect(): void { global $wp_scripts, $wp_styles; if ( $wp_scripts instanceof \WP_Scripts ) { $this->read( $wp_scripts, 'script' ); } if ( $wp_styles instanceof \WP_Styles ) { $this->read( $wp_styles, 'style' ); } } /** * Read one dependency manager. * * @param \WP_Dependencies $deps Manager. * @param string $type script|style. */ private function read( \WP_Dependencies $deps, string $type ): void { $handles = array_unique( array_merge( (array) $deps->done, (array) $deps->to_do, (array) $deps->queue ) ); foreach ( $handles as $handle ) { $handle = (string) $handle; $id = $type . ':' . $handle; if ( isset( $this->assets[ $id ] ) || ! isset( $deps->registered[ $handle ] ) ) { continue; } $dep = $deps->registered[ $handle ]; $src = (string) $dep->src; if ( '' === $src ) { continue; // Alias handle. } if ( 0 === strpos( $src, '//' ) ) { $src = ( is_ssl() ? 'https:' : 'http:' ) . $src; } elseif ( 0 === strpos( $src, '/' ) ) { $src = site_url( $src ); } $local = $this->owner->local_path( $src ); $remote = '' === $local; $bytes = $remote ? null : $this->local_size( $local ); // Null: unknown until resolve_remote_sizes() (and after, when not measured). if ( $remote ) { $this->pending[ $id ] = true; } $inline = 0; $extra = (array) $dep->extra; foreach ( array( 'data', 'before', 'after' ) as $k ) { if ( isset( $extra[ $k ] ) ) { $inline += strlen( is_array( $extra[ $k ] ) ? implode( "\n", array_filter( array_map( 'strval', $extra[ $k ] ) ) ) : (string) $extra[ $k ] ); } } $strategy = (string) ( $extra['strategy'] ?? '' ); $in_footer = 'script' === $type && ! empty( $extra['group'] ); $blocking = 'script' === $type ? ( '' === $strategy && ! $in_footer ) : ( empty( $dep->args ) || in_array( (string) $dep->args, array( 'all', 'screen', '' ), true ) ); $this->assets[ $id ] = array( 'handle' => $handle, 'type' => $type, 'src' => $src, 'owner' => $remote ? $this->owner_for_remote( $handle, $deps ) : $this->owner->for_file( $local ), 'bytes' => $bytes, 'inline' => $inline, 'remote' => $remote, 'blocking' => $blocking, 'strategy' => $strategy ?: ( $in_footer ? 'footer' : '' ), 'library' => self::library( $handle, $src ), ); } } /** * Owner of a remote asset: the owner of whoever registered it is unknowable, so fall back to the * plugin that enqueued a dependent local asset, else unknown. * * @param string $handle Handle. * @param \WP_Dependencies $deps Manager. */ private function owner_for_remote( string $handle, \WP_Dependencies $deps ): string { foreach ( $deps->registered as $other ) { if ( in_array( $handle, (array) $other->deps, true ) ) { $src = (string) $other->src; if ( 0 === strpos( $src, '/' ) ) { $src = site_url( $src ); } $local = $this->owner->local_path( $src ); if ( '' !== $local ) { return $this->owner->for_file( $local ); } } } $hint = apply_filters( 'bloatwright_remote_asset_owner', null, $handle ); return is_string( $hint ) ? $hint : Owner::UNKNOWN; } /** * Size of a local file (query strings stripped). * * @param string $local Local path. */ private function local_size( string $local ): int { $path = strtok( $local, '?' ); return is_file( (string) $path ) ? (int) filesize( (string) $path ) : 0; } /** * User-Agent of the optional HEAD requests: names the plugin, never the site. */ public static function remote_user_agent(): string { return 'Mozilla/5.0 (compatible; Bloatwright/' . BLOATWRIGHT_VERSION . ')'; } /** * Size of a remote asset via a cached HEAD request, or null when unknown. Only sent when "Measure * remote asset sizes" is on; redirects are not followed (size unknown), and a response without a * Content-Length leaves the size unknown too. The cache is written only when a request was * actually sent, so switching the setting on takes effect on the next run. * * @param string $src URL. */ private function remote_size( string $src ): ?int { $key = 'bloatwright_sz_' . md5( $src ); $cache = get_transient( $key ); if ( false !== $cache ) { return (int) $cache >= 0 ? (int) $cache : null; } $scheme = strtolower( (string) wp_parse_url( $src, PHP_URL_SCHEME ) ); if ( ! in_array( $scheme, array( 'http', 'https' ), true ) || ! apply_filters( 'bloatwright_remote_head', (bool) \Bloatwright\Services\Settings::get( 'remote_head', false ), $src ) ) { return null; // Nothing requested, nothing cached. } $r = wp_safe_remote_head( $src, array( 'timeout' => 5, 'redirection' => 0, 'reject_unsafe_urls' => true, 'user-agent' => self::remote_user_agent(), ) ); $bytes = null; if ( ! is_wp_error( $r ) ) { $code = (int) wp_remote_retrieve_response_code( $r ); $length = wp_remote_retrieve_header( $r, 'content-length' ); if ( $code >= 200 && $code < 300 && is_string( $length ) && preg_match( '/^\d+$/', trim( $length ) ) ) { // A 3xx is not followed: size unknown. $bytes = (int) trim( $length ); } } set_transient( $key, null === $bytes ? self::UNKNOWN_SIZE : $bytes, self::REMOTE_TTL ); return $bytes; } /** * Look up the sizes of remote assets (optional HEAD requests). Call only once the request's * timings have been taken: Bootstrap::collect() does, and results() does for anything left. */ public function resolve_remote_sizes(): void { foreach ( array_keys( $this->pending ) as $id ) { if ( isset( $this->assets[ $id ] ) ) { $this->assets[ $id ]['bytes'] = $this->remote_size( (string) $this->assets[ $id ]['src'] ); } } $this->pending = array(); } /** * Shared library label for a handle/src, or ''. * * @param string $handle Handle. * @param string $src Src. */ public static function library( string $handle, string $src ): string { $path = (string) wp_parse_url( $src, PHP_URL_PATH ); $file = basename( $path ); foreach ( self::LIBRARIES as $label => $name ) { $re = '/^(?:' . $name . ')' . self::TAIL . '$/i'; if ( preg_match( $re, $handle ) || ( '' !== $file && preg_match( $re, $file ) ) ) { return $label; } } $url = (string) wp_parse_url( $src, PHP_URL_HOST ) . $path; foreach ( self::HOSTED as $label => $re ) { if ( preg_match( $re, $url ) ) { return $label; } } return ''; } /** * Results grouped by owner plus duplicate libraries. * * Per owner, "blocking" counts the render-blocking assets and "blocking_bytes" is their size (file + inline code). * An asset whose size is unknown (a remote file that was not or could not be measured) has * "bytes" null: it is left out of "bytes" and "blocking_bytes" and counted in "unknown". * * @return array{owners: array, duplicates: array} */ public function results(): array { $this->resolve_remote_sizes(); $owners = array(); $libs = array(); foreach ( $this->assets as $a ) { $k = $a['owner']; if ( ! isset( $owners[ $k ] ) ) { $owners[ $k ] = array( 'bytes' => 0, 'inline' => 0, 'count' => 0, 'unknown' => 0, 'blocking' => 0, 'blocking_bytes' => 0, 'assets' => array(), ); } $known = is_int( $a['bytes'] ) && $a['bytes'] >= 0; $bytes = $known ? (int) $a['bytes'] : 0; if ( ! $known ) { $a['bytes'] = null; ++$owners[ $k ]['unknown']; } $owners[ $k ]['bytes'] += $bytes; $owners[ $k ]['inline'] += $a['inline']; ++$owners[ $k ]['count']; if ( $a['blocking'] ) { ++$owners[ $k ]['blocking']; $owners[ $k ]['blocking_bytes'] += $bytes + $a['inline']; } $owners[ $k ]['assets'][] = $a; if ( '' !== $a['library'] ) { $libs[ $a['library'] ][ $k ][] = $a['type'] . ':' . $a['handle']; } } $duplicates = array(); foreach ( $libs as $label => $by_owner ) { if ( count( $by_owner ) > 1 ) { $handles = array(); foreach ( $by_owner as $h ) { $handles = array_merge( $handles, $h ); } $duplicates[] = array( 'library' => $label, 'owners' => array_keys( $by_owner ), 'handles' => $handles, ); } } return array( 'owners' => $owners, 'duplicates' => $duplicates, ); } /** * Inject a synthetic asset (tests). * * @param array $asset Asset row. */ public function add( array $asset ): void { $this->assets[ $asset['type'] . ':' . $asset['handle'] ] = $asset + array( 'bytes' => 0, 'inline' => 0, 'remote' => false, 'blocking' => true, 'strategy' => '', 'library' => self::library( (string) $asset['handle'], (string) ( $asset['src'] ?? '' ) ), 'src' => '', ); } }