Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
83.87% |
26 / 31 |
|
66.67% |
2 / 3 |
CRAP | |
0.00% |
0 / 1 |
| Minify | |
83.87% |
26 / 31 |
|
66.67% |
2 / 3 |
17.07 | |
0.00% |
0 / 1 |
| js | |
100.00% |
14 / 14 |
|
100.00% |
1 / 1 |
7 | |||
| fallback_js | |
58.33% |
7 / 12 |
|
0.00% |
0 / 1 |
10.54 | |||
| css | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
2 | |||
| 1 | <?php |
| 2 | /** |
| 3 | * Implement the minify class. |
| 4 | * |
| 5 | * @link https://automattic.com |
| 6 | * @since 0.2 |
| 7 | * @package automattic/jetpack-boost |
| 8 | */ |
| 9 | |
| 10 | namespace Automattic\Jetpack_Boost\Lib; |
| 11 | |
| 12 | use MatthiasMullie\Minify\CSS as CSSMinifier; |
| 13 | use MatthiasMullie\Minify\JS as JSMinifier; |
| 14 | |
| 15 | /** |
| 16 | * Class Minify |
| 17 | */ |
| 18 | class Minify { |
| 19 | |
| 20 | /** |
| 21 | * Reasons passed to the jetpack_boost_js_minify_fallback action. Public so hook |
| 22 | * consumers can compare against Minify::FALLBACK_* instead of bare strings; the |
| 23 | * string values are the stable wire format and must not change. |
| 24 | * |
| 25 | * @since 4.6.0 |
| 26 | */ |
| 27 | public const FALLBACK_EXCEPTION = 'exception'; |
| 28 | public const FALLBACK_ERROR = 'error'; |
| 29 | public const FALLBACK_SCAN_ERROR = 'scan_error'; |
| 30 | public const FALLBACK_EMPTY_OUTPUT = 'empty_output'; |
| 31 | public const FALLBACK_LOOKS_BROKEN = 'looks_broken'; |
| 32 | |
| 33 | /** |
| 34 | * Strips whitespace from JavaScript scripts. |
| 35 | * |
| 36 | * @param string $js Input JS string. |
| 37 | * |
| 38 | * @return string String with whitespace stripped. |
| 39 | */ |
| 40 | public static function js( $js ) { |
| 41 | try { |
| 42 | $minifier = new JSMinifier( $js ); |
| 43 | $minified_js = $minifier->minify(); |
| 44 | } catch ( \Exception $e ) { |
| 45 | // Ordinary failure (e.g. a PCRE backtrack-limit hit on a huge bundle): |
| 46 | // serve the original input rather than nothing. |
| 47 | return self::fallback_js( $js, self::FALLBACK_EXCEPTION, $e ); |
| 48 | } catch ( \Error $e ) { |
| 49 | // \Error subclasses (e.g. \TypeError, \ParseError) signal a genuine bug |
| 50 | // rather than unsupported syntax. We still fall back -- a performance |
| 51 | // optimization must never white-screen the page -- but report a distinct |
| 52 | // reason so the condition is distinguishable from ordinary unsupported |
| 53 | // syntax via the hook. (A true memory-limit fatal is not a \Throwable and |
| 54 | // cannot be caught here at all; the scanner instead bounds its own nesting |
| 55 | // depth so it never provokes one -- see Js_Structure_Scanner.) |
| 56 | return self::fallback_js( $js, self::FALLBACK_ERROR, $e ); |
| 57 | } |
| 58 | |
| 59 | // The bundled MatthiasMullie minifier is regex-based and ES5-era: it can |
| 60 | // SILENTLY corrupt modern JS without throwing. The classic case is a `//` |
| 61 | // inside a (nested) template literal being eaten as a line comment to |
| 62 | // end-of-line, which drops the closing backtick and everything after it, |
| 63 | // truncating the bundle -> "Unexpected end of input" in the browser. The |
| 64 | // try/catch above never fires for this because nothing is thrown. |
| 65 | // |
| 66 | // As a safety net, structurally validate the output: if it looks broken, |
| 67 | // fall back to the original (still concatenated, just not re-minified) |
| 68 | // bytes. A slightly larger working bundle beats a smaller broken one. |
| 69 | if ( '' === (string) $minified_js && '' !== (string) $js ) { |
| 70 | return self::fallback_js( $js, self::FALLBACK_EMPTY_OUTPUT ); |
| 71 | } |
| 72 | |
| 73 | // The structural scan runs outside the minifier try/catch above, so guard it |
| 74 | // too: any \Throwable from the scan itself must degrade to the original rather |
| 75 | // than white-screen the page. It reports the distinct 'scan_error' reason so a |
| 76 | // scanner fault is never conflated with a minifier-level 'error' in the hook's |
| 77 | // (released, immutable) reason contract. (Memory exhaustion is a fatal, not a |
| 78 | // \Throwable, so it cannot be caught -- the scanner instead bounds its own |
| 79 | // nesting depth to avoid provoking one.) Pass the original input so the scanner |
| 80 | // can apply its gross-truncation backstop to bundles too large to scan in full. |
| 81 | // See Js_Structure_Scanner. |
| 82 | try { |
| 83 | $looks_broken = Js_Structure_Scanner::looks_broken( $minified_js, $js ); |
| 84 | } catch ( \Throwable $e ) { |
| 85 | return self::fallback_js( $js, self::FALLBACK_SCAN_ERROR, $e ); |
| 86 | } |
| 87 | if ( $looks_broken ) { |
| 88 | return self::fallback_js( $js, self::FALLBACK_LOOKS_BROKEN ); |
| 89 | } |
| 90 | |
| 91 | return $minified_js; |
| 92 | } |
| 93 | |
| 94 | /** |
| 95 | * Serve the original (un-re-minified) JS when minification is declined, and |
| 96 | * surface the reason so the safety net is observable in production. |
| 97 | * |
| 98 | * Observability policy: the jetpack_boost_js_minify_fallback action is the |
| 99 | * always-on surface and fires on every fallback (including the abnormal |
| 100 | * 'error' arm), so monitoring should hook it. The error_log calls are a debug |
| 101 | * aid only and are gated behind WP_DEBUG: a recurring fallback on a |
| 102 | * no-writable-cache host can fire every request, and unconditional logging |
| 103 | * would flood the log without telling an operator anything the hook cannot. |
| 104 | * |
| 105 | * @param string $js The original JS that will be served. |
| 106 | * @param string $reason One of the FALLBACK_* constants. |
| 107 | * @param \Throwable|null $error The throwable, when the fallback was triggered by one. |
| 108 | * |
| 109 | * @return string The original JS, unchanged. |
| 110 | */ |
| 111 | private static function fallback_js( $js, $reason, $error = null ) { |
| 112 | // js() has no type hint, so a caller can pass a non-string (the \Error-arm |
| 113 | // test does exactly that); measure length defensively rather than assume. |
| 114 | $bytes = is_string( $js ) ? strlen( $js ) : 0; |
| 115 | $is_debug = defined( 'WP_DEBUG' ) && WP_DEBUG; |
| 116 | |
| 117 | try { |
| 118 | /** |
| 119 | * Fires when Minify::js() declines its minified output and serves the |
| 120 | * original JS instead. This is the always-on observability surface for |
| 121 | * the minify safety net: it fires on every fallback, including the |
| 122 | * abnormal 'error' / 'scan_error' arms (e.g. \TypeError / \ParseError), |
| 123 | * so register a callback here to monitor how often -- and why -- it |
| 124 | * engages. Callbacks must not throw; a throwing callback is swallowed so |
| 125 | * it cannot break minification. |
| 126 | * |
| 127 | * @since 4.6.0 |
| 128 | * |
| 129 | * @param string $reason Why the fallback fired: one of the Minify::FALLBACK_* values ('exception', 'error', 'scan_error', 'empty_output', 'looks_broken'). 'error' is a \Throwable raised by the minifier itself; 'scan_error' is a \Throwable raised by the structural scanner -- both carry it in $error. |
| 130 | * @param int $bytes Length of the original JS being served, in bytes. |
| 131 | * @param \Throwable|null $error The throwable when triggered by one, otherwise null. Its message may embed an internal filesystem path (e.g. an IOException), so consumers should not surface it unsanitized. |
| 132 | */ |
| 133 | do_action( 'jetpack_boost_js_minify_fallback', $reason, $bytes, $error ); |
| 134 | } catch ( \Throwable $hook_error ) { |
| 135 | // A misbehaving hook callback must never turn a handled fallback into a |
| 136 | // fatal error -- degrading gracefully to the original bundle is the whole |
| 137 | // point of this method. Surface the hook failure only under WP_DEBUG. |
| 138 | if ( $is_debug ) { |
| 139 | // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log |
| 140 | error_log( 'Jetpack Boost: jetpack_boost_js_minify_fallback hook threw: ' . $hook_error->getMessage() ); |
| 141 | } |
| 142 | } |
| 143 | |
| 144 | if ( $is_debug ) { |
| 145 | $detail = $error instanceof \Throwable |
| 146 | ? sprintf( ' (%s: %s)', get_class( $error ), $error->getMessage() ) |
| 147 | : ''; |
| 148 | // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log |
| 149 | error_log( sprintf( 'Jetpack Boost: Minify::js() fell back to original JS [reason=%s, bytes=%d]%s', $reason, $bytes, $detail ) ); |
| 150 | } |
| 151 | |
| 152 | return $js; |
| 153 | } |
| 154 | |
| 155 | /** |
| 156 | * Minifies the supplied CSS code, returning its minified form. |
| 157 | */ |
| 158 | public static function css( $css ) { |
| 159 | try { |
| 160 | $minifier = new CSSMinifier( $css ); |
| 161 | $minified_css = $minifier->minify(); |
| 162 | } catch ( \Throwable $e ) { |
| 163 | // Unlike js(), CSS uses a single \Throwable catch: there is no structural |
| 164 | // validator for CSS output, and CSS minification has not shown the |
| 165 | // silent-corruption failure mode that motivated splitting js()'s catch. |
| 166 | return $css; |
| 167 | } |
| 168 | |
| 169 | return $minified_css; |
| 170 | } |
| 171 | } |