Passed
Push — master ( 968590...7e07a3 )
by zyt
03:14
created

GoogleFontsOptimizer::run()   A

Complexity

Conditions 3
Paths 2

Size

Total Lines 21
Code Lines 9

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 9
CRAP Score 3

Importance

Changes 0
Metric Value
cc 3
eloc 9
nc 2
nop 0
dl 0
loc 21
ccs 9
cts 9
cp 1
crap 3
rs 9.3142
c 0
b 0
f 0
1
<?php
2
3
namespace ZWF;
4
5
use ZWF\GoogleFontsOptimizerUtils as Utils;
6
use ZWF\GoogleFontsCollection as Collection;
7
8
class GoogleFontsOptimizer
9
{
10
    const FILTER_MARKUP_TYPE  = 'zwf_gfo_markup_type';
11
    const DEFAULT_MARKUP_TYPE = 'link'; // Anything else generates the WebFont script loader
12
13
    const FILTER_OPERATION_MODE  = 'zwf_gfo_mode';
14
    const DEFAULT_OPERATION_MODE = 'enqueued_styles_only';
15
16
    const FILTER_OB_CLEANER  = 'zwf_gfo_clean_ob';
17
    const DEFAULT_OB_CLEANER = false;
18
19
    protected $candidates = [];
20
21
    protected $enqueued = [];
22
23
    /**
24
     * @param array $urls
25
     */
26 5
    public function setCandidates(array $urls = [])
27 5
    {
28 5
        $this->candidates = $urls;
29 5
    }
30
31
    /**
32
     * @return array
33
     */
34 5
    public function getCandidates()
35 5
    {
36 5
        return $this->candidates;
37
    }
38
39
    /**
40
     * Like the wind.
41
     * Main entry point when hooked/called from WP action.
42
     *
43
     * @return void
44
     */
45 3
    public function run()
46 3
    {
47 3
        $mode = apply_filters(self::FILTER_OPERATION_MODE, self::DEFAULT_OPERATION_MODE);
48
49
        switch ($mode) {
50 3
            case 'markup':
51
                /**
52
                 * Scan and optimize requests found in the markup (uses more
53
                 * memory but works on [almost] any theme)
54
                 */
55 1
                add_action('template_redirect', [$this, 'startBuffering'], 11);
56 1
                break;
57
58 2
            case self::DEFAULT_OPERATION_MODE:
59
            default:
60
                /**
61
                 * Scan only things added via wp_enqueue_style (uses slightly
62
                 * less memory usually, but requires a decently coded theme)
63
                 */
64 2
                add_filter('print_styles_array', [$this, 'processStylesHandles']);
65 2
                break;
66
        }
67 3
    }
68
69
    /**
70
     * Callback to hook into `wp_print_styles`.
71
     * It processes enqueued styles and combines any multiple google fonts
72
     * requests into a single one (and removes the enqueued styles handles
73
     * and replaces them with a single combined enqueued style request/handle).
74
     *
75
     * TODO/FIXME: Investigate how this works out when named deps are used somewhere?
76
     *
77
     * @param array $handles
78
     *
79
     * @return array
80
     */
81 2
    public function processStylesHandles(array $handles)
82 2
    {
83 2
        $candidate_handles = $this->findCandidateHandles($handles);
84
85
        // Bail if we don't have anything that makes sense for us to continue
86 2
        if (! $this->hasEnoughElements($candidate_handles)) {
87 1
            return $handles;
88
        }
89
90
        // Set the list of found urls we matched
91 1
        $this->setCandidates(array_values($candidate_handles));
92
93
        // Get collection from candidates
94 1
        $collection = new GoogleFontsCollection($this->getCandidates());
95
96
        // Grab combined url for all the fonts in the collection
97 1
        $font_url    = $collection->getCombinedUrl();
98 1
        $handle_name = 'zwf-gfo-combined';
99 1
        $this->enqueueStyle($handle_name, $font_url);
100 1
        $handles[] = $handle_name;
101
102
        // Process 'text-only' requests if there are any...
103 1
        $texts = $collection->getTextUrls();
104 1
        foreach ($texts as $k => $url) {
105 1
            $handle_name = 'zwf-gfo-combined-txt-' . ($k + 1);
106 1
            $this->enqueueStyle($handle_name, $url);
107 1
            $handles[] = $handle_name;
108
        }
109
110
        // Remove/dequeue the ones we just combined above
111 1
        $this->dequeueStyleHandles($candidate_handles);
112
113
        // Removes processed handles from originally given $handles
114 1
        $handles = array_diff($handles, array_keys($candidate_handles));
115
116 1
        return $handles;
117
    }
118
119
    /**
120
     * Given a list of WP style handles return a new "named map" of handles
121
     * we care about along with their urls.
122
     *
123
     * TODO/FIXME: See if named deps will need to be taken care of...
124
     *
125
     * @codeCoverageIgnore
126
     *
127
     * @param array $handles
128
     *
129
     * @return array
130
     */
131
    protected function findCandidateHandles(array $handles)
132
    {
133
        $handler           = /** @scrutinizer ignore-call */ \wp_styles();
134
        $candidate_handles = [];
135
136
        foreach ($handles as $handle) {
137
            $dep = $handler->query($handle, 'registered');
138
            if ($dep) {
139
                $url = $dep->src;
140
                if (Utils::isGoogleWebFontUrl($url)) {
141
                    $candidate_handles[$handle] = $url;
142
                }
143
            }
144
        }
145
146
        return $candidate_handles;
147
    }
148
149
    /**
150
     * Returns true when an array isn't empty and has enough elements.
151
     *
152
     * @param array $candidates
153
     *
154
     * @return bool
155
     */
156 6
    protected function hasEnoughElements(array $candidates = [])
157 6
    {
158 6
        $enough = true;
159
160 6
        if (empty($candidates) || count($candidates) < 2) {
161 2
            $enough = false;
162
        }
163
164 6
        return $enough;
165
    }
166
167
    /**
168
     * Dequeue given `$handles`.
169
     *
170
     * @param array $handles
171
     *
172
     * @return void
173
     */
174 1
    protected function dequeueStyleHandles(array $handles)
175 1
    {
176 1
        foreach ($handles as $handle => $url) {
177
            // @codeCoverageIgnoreStart
178
            if (function_exists('\wp_deregister_style')) {
179
                /** @scrutinizer ignore-call */
180
                \wp_deregister_style($handle);
181
            }
182
            if (function_exists('\wp_dequeue_style')) {
183
                /** @scrutinizer ignore-call */
184
                \wp_dequeue_style($handle);
185
            }
186
            // @codeCoverageIgnoreEnd
187 1
            unset($this->enqueued[$handle]);
188
        }
189 1
    }
190
191
    /**
192
     * Enqueues a given style using `\wp_enqueue_style()` and keeps it in
193
     * our own `$enqueued` list for reference.
194
     *
195
     * @param string $handle
196
     * @param string $url
197
     * @param array $deps
198
     * @param string|null $version
199
     *
200
     * @return void
201
     */
202 1
    protected function enqueueStyle($handle, $url, $deps = [], $version = null)
203 1
    {
204
        // @codeCoverageIgnoreStart
205
        if (function_exists('\wp_enqueue_style')) {
206
            /** @scrutinizer ignore-call */
207
            \wp_enqueue_style($handle, $url, $deps, $version);
208
        }
209
        // @codeCoverageIgnoreEnd
210
211 1
        $this->enqueued[$handle] = $url;
212 1
    }
213
214
    /**
215
     * Get the list of enqueued styles or a specific one if $handle is specified.
216
     *
217
     * @param string|null $handle Style "slug"
218
     *
219
     * @return array|string
220
     */
221 1
    public function getEnqueued($handle = null)
222 1
    {
223 1
        $data = $this->enqueued;
224
225 1
        if (null !== $handle && isset($this->enqueued[$handle])) {
226 1
            $data = $this->enqueued[$handle];
227
        }
228
229 1
        return $data;
230
    }
231
232
    /**
233
     * Callback to invoke in oder to modify google fonts found in the HTML markup.
234
     * Returns modified markup in which multiple google fonts requests are
235
     * combined into a single one (if multiple requests are found).
236
     *
237
     * @param string $markup
238
     *
239
     * @return string
240
     */
241 4
    public function processMarkup($markup)
242 4
    {
243 4
        $hrefs = $this->parseMarkupForHrefs($markup);
244 4
        if (! empty($hrefs)) {
245 4
            $this->setCandidates($hrefs);
246
        }
247
248
        // See if we found anything
249 4
        $candidates = $this->getCandidates();
250
251
        // Bail and return original markup unmodified if we don't have things to do
252 4
        if (! $this->hasEnoughElements($candidates)) {
253 1
            return $markup;
254
        }
255
256
        // Process what we found and modify original markup with our replacement
257 4
        $collection  = new GoogleFontsCollection($candidates);
258 4
        $font_markup = $this->buildFontsMarkup($collection);
259 4
        $markup      = $this->modifyMarkup($markup, $font_markup, $collection->getOriginalLinks());
260
261 4
        return $markup;
262
    }
263
264
    /**
265
     * Given a string of $markup, returns an array of hrefs we're interested in.
266
     *
267
     * @param string $markup
268
     *
269
     * @return array
270
     */
271 4
    protected function parseMarkupForHrefs($markup)
272 4
    {
273 4
        $dom = new \DOMDocument();
274
275
        // @codingStandardsIgnoreLine
276 4
        /** @scrutinizer ignore-unhandled */ @$dom->loadHTML($markup);
277
278
        // Looking for all <link> elements
279 4
        $links = $dom->getElementsByTagName('link');
280 4
        $hrefs = $this->filterHrefsFromCandidateLinkNodes($links);
281
282 4
        return $hrefs;
283
    }
284
285
    /**
286
     * Returns the list of google web fonts stylesheet hrefs found.
287
     *
288
     * @param \DOMNodeList $nodes
289
     *
290
     * @return array
291
     */
292 4
    protected function filterHrefsFromCandidateLinkNodes(\DOMNodeList $nodes)
293 4
    {
294 4
        $hrefs = [];
295
296 4
        foreach ($nodes as $node) {
297 4
            if ($this->isCandidateLink($node)) {
298 4
                $hrefs[] = $node->getAttribute('href');
299
            }
300
        }
301
302 4
        return $hrefs;
303
    }
304
305
    /**
306
     * Returns true if given DOMNode is a stylesheet and points to a Google Web Fonts url.
307
     *
308
     * @param \DOMNode $node
309
     *
310
     * @return bool
311
     */
312 4
    protected function isCandidateLink(\DOMNode $node)
313 4
    {
314 4
        $rel  = $node->getAttribute('rel');
315 4
        $href = $node->getAttribute('href');
316
317 4
        return ('stylesheet' === $rel && Utils::isGoogleWebFontUrl($href));
318
    }
319
320
    /**
321
     * Modifies given $markup to include given $font_markup in the <head>.
322
     * Also removes any existing stylesheets containing the given $font_links (if found).
323
     *
324
     * @param string $markup
325
     * @param string $font_markup
326
     * @param array $font_links
327
     *
328
     * @return string
329
     */
330 4
    protected function modifyMarkup($markup, $font_markup, array $font_links)
331 4
    {
332 4
        $new_markup = $markup;
333
334
        // Remove existing stylesheets
335 4
        foreach ($font_links as $font_link) {
336 4
            $font_link = preg_quote($font_link, '/');
337
338
            // Tweak back what DOMDocument replaces sometimes
339 4
            $font_link = str_replace('&#038;', '&', $font_link);
340
            // This adds an extra capturing group in the pattern actually
341 4
            $font_link = str_replace('&', '(&|&#038;|&amp;)', $font_link);
342
            // Match this url's link tag, including optional newlines at the end of the string
343 4
            $pattern = '/<link([^>]*?)href[\s]?=[\s]?[\'\"\\\]*' . $font_link . '([^>]*?)>([\s]+)?/is';
344
            // Now replace
345 4
            $new_markup = preg_replace($pattern, '', $new_markup);
346
        }
347
348
        // Adding the font markup to top of <head> for now
349
        // TODO/FIXME: This could easily break when someone uses `<head>` in HTML comments and such?
350 4
        $new_markup = str_ireplace('<head>', '<head>' . $font_markup, trim($new_markup));
351
352 4
        return $new_markup;
353
    }
354
355 1
    public function startBuffering()
356 1
    {
357 1
        $started = false;
358
359 1
        if ($this->shouldBuffer()) {
360
            /**
361
             * N.B.
362
             * In theory, others might've already started buffering before us,
363
             * which can prevent us from getting the markup.
364
             * If that becomes an issue, we can call shouldCleanOutputBuffers()
365
             * and cleanOutputBuffers() here before starting our buffering.
366
             */
367
368
            // Start our own buffering
369 1
            $started = ob_start([$this, 'endBuffering']);
370
        }
371
372 1
        return $started;
373
    }
374
375 2
    public function endBuffering($markup)
376 2
    {
377
        // Bail early on things we don't want to parse
378 2
        if (! $this->isMarkupDoable($markup)) {
379 2
            return $markup;
380
        }
381
382 1
        return $this->processMarkup($markup);
383
    }
384
385
    /**
386
     * Determines whether the current WP request should be buffered.
387
     *
388
     * @return bool
389
     *
390
     * @codeCoverageIgnore
391
     */
392
    protected function shouldBuffer()
393
    {
394
        // is_admin() returns true for ajax requests too (which we skip)
395
        if (function_exists('\is_admin') && /** @scrutinizer ignore-call */ \is_admin()) {
396
            return false;
397
        }
398
399
        if (function_exists('\is_feed') && /** @scrutinizer ignore-call */ \is_feed()) {
400
            return false;
401
        }
402
403
        if (defined('\DOING_CRON') && \DOING_CRON) {
1 ignored issue
show
Bug introduced by
The constant DOING_CRON was not found. Maybe you did not declare it correctly or list all dependencies?
Loading history...
404
            return false;
405
        }
406
407
        if (defined('\WP_CLI')) {
408
            return false;
409
        }
410
411
        if (defined('\APP_REQUEST')) {
412
            return false;
413
        }
414
415
        if (defined('\XMLRPC_REQUEST')) {
416
            return false;
417
        }
418
419
        if (defined('\SHORTINIT') && \SHORTINIT) {
1 ignored issue
show
Bug introduced by
The constant SHORTINIT was not found. Maybe you did not declare it correctly or list all dependencies?
Loading history...
420
            return false;
421
        }
422
423
        return true;
424
    }
425
426
    /**
427
     * @codeCoverageIgnore
428
     */
429
    protected function shouldCleanOutputBuffers()
430
    {
431
        return apply_filters(self::FILTER_OB_CLEANER, self::DEFAULT_OB_CLEANER);
432
    }
433
434
    /**
435
     * @codeCoverageIgnore
436
     */
437
    protected function cleanOutputBuffers()
438
    {
439
        while (ob_get_level() > 0) {
440
            ob_end_clean();
441
        }
442
    }
443
444
    /**
445
     * Returns true if given markup should be processed.
446
     *
447
     * @param string $content
448
     *
449
     * @return bool
450
     */
451 2
    protected function isMarkupDoable($content)
452 2
    {
453 2
        $html  = Utils::hasHtmlTag($content);
454 2
        $html5 = Utils::hasHtml5Doctype($content);
455 2
        $xsl   = Utils::hasXslStylesheet($content);
456
457 2
        return (($html || $html5) && ! $xsl);
458
    }
459
460
    /**
461
     * Given a GoogleFontsCollection builds HTML markup for found google fonts.
462
     *
463
     * @param Collection $fonts
464
     *
465
     * @return string
466
     */
467 4
    protected function buildFontsMarkup(Collection $fonts)
468 4
    {
469 4
        $markup_type = apply_filters(self::FILTER_MARKUP_TYPE, self::DEFAULT_MARKUP_TYPE);
470 4
        if ('link' === $markup_type) {
471
            // Build standard link markup
472 2
            $markup = Utils::buildFontsMarkupLinks($fonts);
473
        } else {
474
            // Bulding WebFont script loader
475 2
            $markup = Utils::buildFontsMarkupScript($fonts);
476
        }
477
478 4
        return $markup;
479
    }
480
}
481