Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
83.87% covered (warning)
83.87%
26 / 31
66.67% covered (warning)
66.67%
2 / 3
CRAP
0.00% covered (danger)
0.00%
0 / 1
Minify
83.87% covered (warning)
83.87%
26 / 31
66.67% covered (warning)
66.67%
2 / 3
17.07
0.00% covered (danger)
0.00%
0 / 1
 js
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
7
 fallback_js
58.33% covered (warning)
58.33%
7 / 12
0.00% covered (danger)
0.00%
0 / 1
10.54
 css
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
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
10namespace Automattic\Jetpack_Boost\Lib;
11
12use MatthiasMullie\Minify\CSS as CSSMinifier;
13use MatthiasMullie\Minify\JS as JSMinifier;
14
15/**
16 * Class Minify
17 */
18class 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}