GoogleFontsOptimizer   D
last analyzed

Complexity

Total Complexity 59

Size/Duplication

Total Lines 483
Duplicated Lines 0 %

Test Coverage

Coverage 100%

Importance

Changes 0
Metric Value
wmc 59
dl 0
loc 483
ccs 123
cts 123
cp 1
rs 4.5454
c 0
b 0
f 0

21 Methods

Rating   Name   Duplication   Size   Complexity  
A findCandidateHandles() 0 16 4
A setCandidates() 0 3 1
A getCandidates() 0 3 1
A run() 0 21 3
A isCandidateLink() 0 6 2
A modifyMarkup() 0 23 2
C shouldBuffer() 0 32 12
A shouldCleanOutputBuffers() 0 3 1
A hasEnoughElements() 0 9 3
A enqueueStyle() 0 10 2
A processMarkup() 0 21 3
A getEnqueued() 0 9 3
A buildFontsMarkup() 0 12 2
B processStylesHandles() 0 36 3
A dequeueStyleHandles() 0 14 4
A startBuffering() 0 18 2
A filterHrefsFromCandidateLinkNodes() 0 11 3
A isMarkupDoable() 0 7 3
A endBuffering() 0 8 2
A cleanOutputBuffers() 0 4 2
A parseMarkupForHrefs() 0 12 1

How to fix   Complexity   

Complex Class

Complex classes like GoogleFontsOptimizer often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use GoogleFontsOptimizer, and based on these observations, apply Extract Interface, too.

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 the `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.
73
     * It removes the enqueued styles handles and replaces them with a single
74
     * 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 collection from candidates
95 1
        $collection = new GoogleFontsCollection($this->getCandidates());
96
97
        // Grab combined url for all the fonts in the collection
98 1
        $font_url    = $collection->getCombinedUrl();
99 1
        $handle_name = 'zwf-gfo-combined';
100 1
        $this->enqueueStyle($handle_name, $font_url);
101 1
        $handles[] = $handle_name;
102
103
        // Process 'text-only' requests if there are any...
104 1
        $texts = $collection->getTextUrls();
105 1
        foreach ($texts as $k => $url) {
106 1
            $handle_name = 'zwf-gfo-combined-txt-' . ($k + 1);
107 1
            $this->enqueueStyle($handle_name, $url);
108 1
            $handles[] = $handle_name;
109
        }
110
111
        // Remove/dequeue the ones we just combined above
112 1
        $this->dequeueStyleHandles($candidate_handles);
113
114
        // Removes processed handles from originally given $handles
115 1
        $handles = array_diff($handles, array_keys($candidate_handles));
116
117 1
        return $handles;
118
    }
119
120
    /**
121
     * Given a list of WP style handles return a new "named map" of handles
122
     * we care about along with their URLs.
123
     *
124
     * TODO/FIXME: See if named deps will need to be taken care of...
125
     *
126
     * @codeCoverageIgnore
127
     *
128
     * @param array $handles
129
     *
130
     * @return array
131
     */
132
    protected function findCandidateHandles(array $handles)
133
    {
134
        $handler           = /** @scrutinizer ignore-call */ \wp_styles();
135
        $candidate_handles = [];
136
137
        foreach ($handles as $handle) {
138
            $dep = $handler->query($handle, 'registered');
139
            if ($dep) {
140
                $url = $dep->src;
141
                if (Utils::isGoogleWebFontUrl($url)) {
142
                    $candidate_handles[$handle] = $url;
143
                }
144
            }
145
        }
146
147
        return $candidate_handles;
148
    }
149
150
    /**
151
     * Returns true when an array isn't empty and has enough elements.
152
     *
153
     * @param array $candidates
154
     *
155
     * @return bool
156
     */
157 6
    protected function hasEnoughElements(array $candidates = [])
158 6
    {
159 6
        $enough = true;
160
161 6
        if (empty($candidates) || count($candidates) < 2) {
162 2
            $enough = false;
163
        }
164
165 6
        return $enough;
166
    }
167
168
    /**
169
     * Dequeue given `$handles`.
170
     *
171
     * @param array $handles
172
     *
173
     * @return void
174
     */
175 1
    protected function dequeueStyleHandles(array $handles)
176 1
    {
177 1
        foreach ($handles as $handle => $url) {
178
            // @codeCoverageIgnoreStart
179
            if (function_exists('\wp_deregister_style')) {
180
                /** @scrutinizer ignore-call */
181
                \wp_deregister_style($handle);
182
            }
183
            if (function_exists('\wp_dequeue_style')) {
184
                /** @scrutinizer ignore-call */
185
                \wp_dequeue_style($handle);
186
            }
187
            // @codeCoverageIgnoreEnd
188 1
            unset($this->enqueued[$handle]);
189
        }
190 1
    }
191
192
    /**
193
     * Enqueues a given style using `\wp_enqueue_style()` and keeps it in
194
     * our own `$enqueued` list for reference.
195
     *
196
     * @param string $handle
197
     * @param string $url
198
     * @param array $deps
199
     * @param string|null $version
200
     *
201
     * @return void
202
     */
203 1
    protected function enqueueStyle($handle, $url, $deps = [], $version = null)
204 1
    {
205
        // @codeCoverageIgnoreStart
206
        if (function_exists('\wp_enqueue_style')) {
207
            /** @scrutinizer ignore-call */
208
            \wp_enqueue_style($handle, $url, $deps, $version);
209
        }
210
        // @codeCoverageIgnoreEnd
211
212 1
        $this->enqueued[$handle] = $url;
213 1
    }
214
215
    /**
216
     * Get the list of enqueued styles or a specific one if `$handle` is specified.
217
     *
218
     * @param string|null $handle Style "slug"
219
     *
220
     * @return array|string
221
     */
222 1
    public function getEnqueued($handle = null)
223 1
    {
224 1
        $data = $this->enqueued;
225
226 1
        if (null !== $handle && isset($this->enqueued[$handle])) {
227 1
            $data = $this->enqueued[$handle];
228
        }
229
230 1
        return $data;
231
    }
232
233
    /**
234
     * Callback to invoke in oder to modify Google Fonts stylesheets found in
235
     * the HTML markup.
236
     * Returns modified markup if it contains multiple Google Fonts stylesheets.
237
     *
238
     * @param string $markup
239
     *
240
     * @return string
241
     */
242 4
    public function processMarkup($markup)
243 4
    {
244 4
        $hrefs = $this->parseMarkupForHrefs($markup);
245 4
        if (! empty($hrefs)) {
246 4
            $this->setCandidates($hrefs);
247
        }
248
249
        // See if we found anything
250 4
        $candidates = $this->getCandidates();
251
252
        // Bail and return original markup unmodified if we don't have things to do
253 4
        if (! $this->hasEnoughElements($candidates)) {
254 1
            return $markup;
255
        }
256
257
        // Process what we found and modify original markup with our replacement
258 4
        $collection  = new GoogleFontsCollection($candidates);
259 4
        $font_markup = $this->buildFontsMarkup($collection);
260 4
        $markup      = $this->modifyMarkup($markup, $font_markup, $collection->getOriginalLinks());
261
262 4
        return $markup;
263
    }
264
265
    /**
266
     * Given a `$markup` string it returns an array of hrefs we're interested in.
267
     *
268
     * @param string $markup
269
     *
270
     * @return array
271
     */
272 4
    protected function parseMarkupForHrefs($markup)
273 4
    {
274 4
        $dom = new \DOMDocument();
275
276
        // @codingStandardsIgnoreLine
277 4
        /** @scrutinizer ignore-unhandled */ @$dom->loadHTML($markup);
278
279
        // Looking for all <link> elements
280 4
        $links = $dom->getElementsByTagName('link');
281 4
        $hrefs = $this->filterHrefsFromCandidateLinkNodes($links);
282
283 4
        return $hrefs;
284
    }
285
286
    /**
287
     * Returns the list of Google Fonts stylesheet hrefs found.
288
     *
289
     * @param \DOMNodeList $nodes
290
     *
291
     * @return array
292
     */
293 4
    protected function filterHrefsFromCandidateLinkNodes(\DOMNodeList $nodes)
294 4
    {
295 4
        $hrefs = [];
296
297 4
        foreach ($nodes as $node) {
298 4
            if ($this->isCandidateLink($node)) {
299 4
                $hrefs[] = $node->getAttribute('href');
300
            }
301
        }
302
303 4
        return $hrefs;
304
    }
305
306
    /**
307
     * Returns true if given DOMNode is a stylesheet and points to a Google Web Fonts url.
308
     *
309
     * @param \DOMNode $node
310
     *
311
     * @return bool
312
     */
313 4
    protected function isCandidateLink(\DOMNode $node)
314 4
    {
315 4
        $rel  = $node->getAttribute('rel');
316 4
        $href = $node->getAttribute('href');
317
318 4
        return ('stylesheet' === $rel && Utils::isGoogleWebFontUrl($href));
319
    }
320
321
    /**
322
     * Modifies given $markup to include given $font_markup in the <head>.
323
     * Also removes any existing stylesheets containing the given $font_links (if found).
324
     *
325
     * @param string $markup
326
     * @param string $font_markup
327
     * @param array $font_links
328
     *
329
     * @return string
330
     */
331 4
    protected function modifyMarkup($markup, $font_markup, array $font_links)
332 4
    {
333 4
        $new_markup = $markup;
334
335
        // Remove existing stylesheets
336 4
        foreach ($font_links as $font_link) {
337 4
            $font_link = preg_quote($font_link, '/');
338
339
            // Tweak back what DOMDocument replaces sometimes
340 4
            $font_link = str_replace('&#038;', '&', $font_link);
341
            // This adds an extra capturing group in the pattern actually
342 4
            $font_link = str_replace('&', '(&|&#038;|&amp;)', $font_link);
343
            // Match this url's link tag, including optional newlines at the end of the string
344 4
            $pattern = '/<link([^>]*?)href[\s]?=[\s]?[\'\"\\\]*' . $font_link . '([^>]*?)>([\s]+)?/is';
345
            // Now replace
346 4
            $new_markup = preg_replace($pattern, '', $new_markup);
347
        }
348
349
        // Adding the font markup to top of <head> for now
350
        // TODO/FIXME: This could easily break when someone uses `<head>` in HTML comments and such?
351 4
        $new_markup = str_ireplace('<head>', '<head>' . $font_markup, trim($new_markup));
352
353 4
        return $new_markup;
354
    }
355
356
    /**
357
     * Returns true if output buffering has been started, false otherwise.
358
     *
359
     * @return bool
360
     */
361 1
    public function startBuffering()
362 1
    {
363 1
        $started = false;
364
365 1
        if ($this->shouldBuffer()) {
366
            /**
367
             * N.B.
368
             * In theory, others might've already started buffering before us,
369
             * which can prevent us from getting the markup.
370
             * If that becomes an issue, we can call shouldCleanOutputBuffers()
371
             * and cleanOutputBuffers() here before starting our buffering.
372
             */
373
374
            // Start our own buffering
375 1
            $started = ob_start([$this, 'endBuffering']);
376
        }
377
378 1
        return $started;
379
    }
380
381
    /**
382
     * Callback given to `ob_start()`.
383
     *
384
     * @param string $markup
385
     * @return string
386
     */
387 2
    public function endBuffering($markup)
388 2
    {
389
        // Bail early on things we don't want to parse
390 2
        if (! $this->isMarkupDoable($markup)) {
391 2
            return $markup;
392
        }
393
394 1
        return $this->processMarkup($markup);
395
    }
396
397
    /**
398
     * Determines whether the current WP request should be buffered.
399
     *
400
     * @return bool
401
     *
402
     * @codeCoverageIgnore
403
     */
404
    protected function shouldBuffer()
405
    {
406
        // is_admin() returns true for ajax requests too (which we skip)
407
        if (function_exists('\is_admin') && /** @scrutinizer ignore-call */ \is_admin()) {
408
            return false;
409
        }
410
411
        if (function_exists('\is_feed') && /** @scrutinizer ignore-call */ \is_feed()) {
412
            return false;
413
        }
414
415
        if (defined('\DOING_CRON') && \DOING_CRON) {
416
            return false;
417
        }
418
419
        if (defined('\WP_CLI')) {
420
            return false;
421
        }
422
423
        if (defined('\APP_REQUEST')) {
424
            return false;
425
        }
426
427
        if (defined('\XMLRPC_REQUEST')) {
428
            return false;
429
        }
430
431
        if (defined('\SHORTINIT') && \SHORTINIT) {
432
            return false;
433
        }
434
435
        return true;
436
    }
437
438
    /**
439
     * @codeCoverageIgnore
440
     */
441
    protected function shouldCleanOutputBuffers()
442
    {
443
        return apply_filters(self::FILTER_OB_CLEANER, self::DEFAULT_OB_CLEANER);
444
    }
445
446
    /**
447
     * @codeCoverageIgnore
448
     */
449
    protected function cleanOutputBuffers()
450
    {
451
        while (ob_get_level() > 0) {
452
            ob_end_clean();
453
        }
454
    }
455
456
    /**
457
     * Returns true if given markup should be processed.
458
     *
459
     * @param string $content
460
     *
461
     * @return bool
462
     */
463 2
    protected function isMarkupDoable($content)
464 2
    {
465 2
        $html  = Utils::hasHtmlTag($content);
466 2
        $html5 = Utils::hasHtml5Doctype($content);
467 2
        $xsl   = Utils::hasXslStylesheet($content);
468
469 2
        return (($html || $html5) && ! $xsl);
470
    }
471
472
    /**
473
     * Given a GoogleFontsCollection builds HTML markup for found google fonts.
474
     *
475
     * @param Collection $fonts
476
     *
477
     * @return string
478
     */
479 4
    protected function buildFontsMarkup(Collection $fonts)
480 4
    {
481 4
        $markup_type = apply_filters(self::FILTER_MARKUP_TYPE, self::DEFAULT_MARKUP_TYPE);
482 4
        if ('link' === $markup_type) {
483
            // Build standard link markup
484 2
            $markup = Utils::buildFontsMarkupLinks($fonts);
485
        } else {
486
            // Bulding WebFont script loader
487 2
            $markup = Utils::buildFontsMarkupScript($fonts);
488
        }
489
490 4
        return $markup;
491
    }
492
}
493