Passed
Push — master ( e3de86...7a576b )
by zyt
03:30
created

GoogleFontsOptimizer::hasHtmlTag()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 3
CRAP Score 1

Importance

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