Plugin Directory


Ignore:
Timestamp:
09/27/2026 05:57:08 PM (4 weeks ago)
Author:
shiponkarmakar
Message:

Release v1.0.23

File:
1 edited

Legend:

Unmodified
Added
Removed
  • optimazz/trunk/includes/Modules/Fonts/FontsModule.php

    r3657524 r3715895  
    1616
    1717use Optimazz\Core\AbstractModule;
     18use Optimazz\Core\Paths;
    1819
    1920defined( 'ABSPATH' ) || exit;
    … …  
    6465
    6566                $uses_google = false !== stripos( $html, 'fonts.googleapis.com' );
     67
     68                // Merge every Google Fonts stylesheet into ONE request and drop the font
     69                // weights the page never uses (page builders like Elementor request every
     70                // weight 100–900 plus italics for each family — often 150KB+ of font CSS
     71                // and a render-blocking request per family).
     72                if ( $uses_google && $this->option( 'combine_google', true ) ) {
     73                        $html = $this->optimize_google_fonts( $html, $display );
     74                }
    6675
    6776                // Force the chosen font-display on Google Fonts stylesheet URLs. We
    … …  
    130139        }
    131140
     141        /* --------------------------------------------------------------------- *
     142         *  Google Fonts: combine + trim unused weights
     143         * --------------------------------------------------------------------- */
     144
     145        /**
     146         * Replace all Google Fonts stylesheet <link>s with a single CSS2 API request
     147         * containing only the families/weights actually needed. Anything we can't
     148         * parse with certainty (e.g. `text=` subsetting) is left untouched, and the
     149         * whole step fails open — the page is never left without its fonts.
     150         *
     151         * @param string $html    Page HTML.
     152         * @param string $display font-display value to request ('' keeps original).
     153         * @return string
     154         */
     155        private function optimize_google_fonts( $html, $display ) {
     156                if ( ! preg_match_all( '#<link\b[^>]*>#i', $html, $all ) ) {
     157                        return $html;
     158                }
     159
     160                $links    = array(); // Tags we will merge (in document order).
     161                $families = array(); // name => ['normal'=>[w=>1], 'italic'=>[w=>1], 'raw'=>string|null]
     162                $orig_display = '';
     163
     164                foreach ( $all[0] as $tag ) {
     165                        if ( ! preg_match( '#\brel\s*=\s*["\']?stylesheet\b#i', $tag ) ) {
     166                                continue;
     167                        }
     168                        if ( ! preg_match( '#\bhref\s*=\s*(["\'])((?:https?:)?//fonts\.googleapis\.com/(css2?)\?[^"\']+)\1#i', $tag, $m ) ) {
     169                                continue;
     170                        }
     171                        if ( preg_match( '#\bmedia\s*=\s*["\'](?!all\b|screen\b)[^"\']+#i', $tag ) ) {
     172                                continue; // print-only etc. — leave alone.
     173                        }
     174                        $query  = (string) wp_parse_url( html_entity_decode( $m[2], ENT_QUOTES ), PHP_URL_QUERY ); // decode first: "&#038;" contains a "#" that would truncate the query.
     175                        $parsed = 'css2' === strtolower( $m[3] ) ? self::parse_css2( $query ) : self::parse_css1( $query );
     176                        if ( null === $parsed ) {
     177                                continue; // unsupported form — keep as-is.
     178                        }
     179                        if ( '' === $orig_display && preg_match( '/(?:^|&)display=([a-z]+)/i', $query, $d ) ) {
     180                                $orig_display = strtolower( $d[1] );
     181                        }
     182                        foreach ( $parsed as $name => $spec ) {
     183                                if ( ! isset( $families[ $name ] ) ) {
     184                                        $families[ $name ] = array( 'normal' => array(), 'italic' => array(), 'raw' => null );
     185                                }
     186                                if ( null !== $spec['raw'] ) {
     187                                        $families[ $name ]['raw'] = $spec['raw'];
     188                                }
     189                                $families[ $name ]['normal'] += $spec['normal'];
     190                                $families[ $name ]['italic'] += $spec['italic'];
     191                        }
     192                        $links[] = $tag;
     193                }
     194
     195                if ( empty( $links ) ) {
     196                        return $html;
     197                }
     198                $trim = (bool) $this->option( 'trim_weights', true );
     199                // One link and nothing to trim — nothing to gain.
     200                if ( 1 === count( $links ) && ! $trim ) {
     201                        return $html;
     202                }
     203
     204                $used = $trim ? $this->used_font_weights( $html ) : null;
     205
     206                $params = array();
     207                foreach ( $families as $name => $spec ) {
     208                        $params[] = 'family=' . str_replace( '%20', '+', rawurlencode( $name ) ) . self::css2_axis( $spec, $used );
     209                }
     210                $disp = '' !== $display ? $display : $orig_display;
     211                if ( '' !== $disp ) {
     212                        $params[] = 'display=' . $disp;
     213                }
     214                $url = 'https://fonts.googleapis.com/css2?' . implode( '&', $params );
     215                // phpcs:ignore WordPress.WP.EnqueuedResources.NonEnqueuedStylesheet -- rewriting existing output-buffer markup, not enqueueing.
     216                $new = '<link rel="stylesheet" id="optimazz-google-fonts" href="' . esc_url( $url ) . '" media="all">';
     217
     218                // Swap the first link for the merged one; remove the rest (each once).
     219                $first = true;
     220                foreach ( $links as $tag ) {
     221                        $pos = strpos( $html, $tag );
     222                        if ( false === $pos ) {
     223                                continue;
     224                        }
     225                        $html  = substr_replace( $html, $first ? $new : '', $pos, strlen( $tag ) );
     226                        $first = false;
     227                }
     228                return $html;
     229        }
     230
     231        /**
     232         * Parse a legacy CSS API query (`family=Inter:400,700i|Roboto&subset=…`).
     233         *
     234         * @param string $query URL query string.
     235         * @return array|null Families, or null when the form is unsupported.
     236         */
     237        private static function parse_css1( $query ) {
     238                $out = array();
     239                foreach ( explode( '&', $query ) as $pair ) {
     240                        $kv  = explode( '=', $pair, 2 );
     241                        $key = strtolower( $kv[0] );
     242                        if ( 'text' === $key ) {
     243                                return null; // subset-by-text requests must stay exact.
     244                        }
     245                        if ( 'family' !== $key || ! isset( $kv[1] ) ) {
     246                                continue;
     247                        }
     248                        foreach ( explode( '|', urldecode( $kv[1] ) ) as $fam ) {
     249                                $parts = explode( ':', $fam, 2 );
     250                                $name  = trim( $parts[0] );
     251                                if ( '' === $name ) {
     252                                        continue;
     253                                }
     254                                $spec = array( 'normal' => array(), 'italic' => array(), 'raw' => null );
     255                                $vars = isset( $parts[1] ) ? explode( ',', $parts[1] ) : array( '400' );
     256                                foreach ( $vars as $v ) {
     257                                        $v = strtolower( trim( $v ) );
     258                                        if ( '' === $v ) {
     259                                                continue;
     260                                        }
     261                                        $v    = strtr( $v, array( 'regular' => '400', 'bold' => '700' ) );
     262                                        $ital = (bool) preg_match( '/(italic|i)$/', $v );
     263                                        $w    = (int) preg_replace( '/\D/', '', $v );
     264                                        $w    = $w ? $w : 400; // bare "italic".
     265                                        if ( $w < 100 || $w > 900 ) {
     266                                                continue;
     267                                        }
     268                                        $spec[ $ital ? 'italic' : 'normal' ][ $w ] = 1;
     269                                }
     270                                if ( empty( $spec['normal'] ) && empty( $spec['italic'] ) ) {
     271                                        $spec['normal'][400] = 1;
     272                                }
     273                                $out[ $name ] = $spec;
     274                        }
     275                }
     276                return $out ? $out : null;
     277        }
     278
     279        /**
     280         * Parse a CSS2 API query (`family=Inter:ital,wght@0,400;1,700&family=…`).
     281         *
     282         * @param string $query URL query string.
     283         * @return array|null Families, or null when the form is unsupported.
     284         */
     285        private static function parse_css2( $query ) {
     286                $out = array();
     287                foreach ( explode( '&', $query ) as $pair ) {
     288                        $kv  = explode( '=', $pair, 2 );
     289                        $key = strtolower( $kv[0] );
     290                        if ( 'text' === $key ) {
     291                                return null;
     292                        }
     293                        if ( 'family' !== $key || ! isset( $kv[1] ) ) {
     294                                continue;
     295                        }
     296                        $fam   = urldecode( $kv[1] );
     297                        $parts = explode( ':', $fam, 2 );
     298                        $name  = trim( $parts[0] );
     299                        if ( '' === $name ) {
     300                                continue;
     301                        }
     302                        $spec = array( 'normal' => array(), 'italic' => array(), 'raw' => null );
     303                        if ( ! isset( $parts[1] ) ) {
     304                                $spec['normal'][400] = 1;
     305                        } elseif ( preg_match( '/^ital,wght@(.+)$/', $parts[1], $m ) && false === strpos( $m[1], '..' ) ) {
     306                                foreach ( explode( ';', $m[1] ) as $t ) {
     307                                        $b = explode( ',', $t );
     308                                        if ( 2 === count( $b ) ) {
     309                                                $spec[ '1' === trim( $b[0] ) ? 'italic' : 'normal' ][ (int) $b[1] ] = 1;
     310                                        }
     311                                }
     312                        } elseif ( preg_match( '/^wght@(.+)$/', $parts[1], $m ) && false === strpos( $m[1], '..' ) ) {
     313                                foreach ( explode( ';', $m[1] ) as $w ) {
     314                                        $spec['normal'][ (int) $w ] = 1;
     315                                }
     316                        } else {
     317                                $spec['raw'] = $parts[1]; // variable ranges / other axes — keep verbatim.
     318                        }
     319                        unset( $spec['normal'][0], $spec['italic'][0] );
     320                        $out[ $name ] = $spec;
     321                }
     322                return $out ? $out : null;
     323        }
     324
     325        /**
     326         * Build the CSS2 axis suffix for one family, trimmed to the used weights.
     327         *
     328         * @param array      $spec Family spec.
     329         * @param array|null $used ['weights'=>[w=>1], 'italic'=>bool] or null (no trim).
     330         * @return string e.g. ":ital,wght@0,400;0,700;1,400" or ":wght@400;700" or "".
     331         */
     332        private static function css2_axis( array $spec, $used ) {
     333                if ( null !== $spec['raw'] ) {
     334                        return ':' . $spec['raw'];
     335                }
     336                $normal = $spec['normal'];
     337                $italic = $spec['italic'];
     338                if ( is_array( $used ) ) {
     339                        $n = array_intersect_key( $normal, $used['weights'] );
     340                        $i = $used['italic'] ? array_intersect_key( $italic, $used['weights'] ) : array();
     341                        // Never trim a family down to nothing — keep the original request.
     342                        if ( ! empty( $n ) || ! empty( $i ) ) {
     343                                $normal = $n;
     344                                $italic = $i;
     345                        }
     346                }
     347                ksort( $normal );
     348                ksort( $italic );
     349                if ( empty( $italic ) ) {
     350                        if ( array( 400 ) === array_keys( $normal ) ) {
     351                                return '';
     352                        }
     353                        return ':wght@' . implode( ';', array_keys( $normal ) );
     354                }
     355                $tuples = array();
     356                foreach ( array_keys( $normal ) as $w ) {
     357                        $tuples[] = '0,' . $w;
     358                }
     359                foreach ( array_keys( $italic ) as $w ) {
     360                        $tuples[] = '1,' . $w;
     361                }
     362                return ':ital,wght@' . implode( ';', $tuples );
     363        }
     364
     365        /**
     366         * Work out which font weights (and whether italics) the page can actually
     367         * render, from the page's own markup/inline CSS plus its local stylesheets.
     368         * Deliberately conservative: 400 and 700 are always kept (default text and
     369         * <strong>/<b>/headings), and any weight mentioned anywhere is kept.
     370         *
     371         * @param string $html Page HTML.
     372         * @return array ['weights'=>[w=>1], 'italic'=>bool]
     373         */
     374        private function used_font_weights( $html ) {
     375                $weights = array( 400 => 1, 700 => 1 );
     376                $italic  = (bool) preg_match( '#<(em|cite|dfn|var|address)\b|<i>#i', $html );
     377
     378                self::scan_weights( $html, $weights, $italic );
     379
     380                // Local stylesheets: scanned once per set of files (cached until any of
     381                // them changes), so this adds nothing to normal page views.
     382                $files = array();
     383                if ( preg_match_all( '#<link\b[^>]*\brel\s*=\s*["\']?stylesheet[^>]*>#i', $html, $lm ) ) {
     384                        foreach ( $lm[0] as $tag ) {
     385                                if ( ! preg_match( '#\bhref\s*=\s*["\']([^"\']+)#i', $tag, $h ) ) {
     386                                        continue;
     387                                }
     388                                $path = Paths::url_to_path( html_entity_decode( $h[1], ENT_QUOTES ) );
     389                                if ( $path && is_readable( $path ) && filesize( $path ) < 2097152 ) {
     390                                        $files[ $path ] = (int) filemtime( $path );
     391                                }
     392                                if ( count( $files ) >= 80 ) {
     393                                        break;
     394                                }
     395                        }
     396                }
     397                if ( $files ) {
     398                        $key    = 'optimazz_fw_' . md5( wp_json_encode( $files ) );
     399                        $cached = get_transient( $key );
     400                        if ( is_array( $cached ) && isset( $cached['w'] ) ) {
     401                                $weights += $cached['w'];
     402                                $italic   = $italic || ! empty( $cached['i'] );
     403                        } else {
     404                                $fw = array();
     405                                $fi = false;
     406                                foreach ( array_keys( $files ) as $path ) {
     407                                        self::scan_weights( (string) @file_get_contents( $path ), $fw, $fi ); // phpcs:ignore WordPress.WP.AlternativeFunctions,WordPress.PHP.NoSilencedErrors
     408                                }
     409                                set_transient( $key, array( 'w' => $fw, 'i' => $fi ), DAY_IN_SECONDS );
     410                                $weights += $fw;
     411                                $italic   = $italic || $fi;
     412                        }
     413                }
     414
     415                return array(
     416                        'weights' => $weights,
     417                        'italic'  => $italic,
     418                );
     419        }
     420
     421        /**
     422         * Collect font-weight values and italic usage from CSS/markup text.
     423         *
     424         * @param string $text    CSS or HTML.
     425         * @param array  $weights Weight set (by reference).
     426         * @param bool   $italic  Italic flag (by reference).
     427         * @return void
     428         */
     429        private static function scan_weights( $text, array &$weights, &$italic ) {
     430                if ( preg_match_all( '/font-weight\s*:\s*([1-9]00|bold|normal)\b/i', $text, $m ) ) {
     431                        foreach ( $m[1] as $v ) {
     432                                $v = strtolower( $v );
     433                                $w = 'bold' === $v ? 700 : ( 'normal' === $v ? 400 : (int) $v );
     434                                $weights[ $w ] = 1;
     435                        }
     436                }
     437                // Shorthand, e.g. "font: italic 600 1rem/1.4 Inter".
     438                if ( preg_match_all( '/\bfont\s*:\s*[^;}{]*?\b([1-9]00)\b/i', $text, $m ) ) {
     439                        foreach ( $m[1] as $v ) {
     440                                $weights[ (int) $v ] = 1;
     441                        }
     442                }
     443                if ( ! $italic && preg_match( '/font-style\s*:\s*italic|\bfont\s*:[^;}{]*\bitalic\b/i', $text ) ) {
     444                        $italic = true;
     445                }
     446        }
     447
    132448        /**
    133449         * Guess the MIME type for a font URL.
Note: See TracChangeset for help on using the changeset viewer.