Passed
Push — master ( 71e49e...43cd94 )
by zyt
01:52
created

buildFontStringsFromQueryParams()   C

Complexity

Conditions 7
Paths 10

Size

Total Lines 29
Code Lines 16

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 7
eloc 16
nc 10
nop 1
dl 0
loc 29
rs 6.7272
c 0
b 0
f 0
1
<?php
2
3
namespace ZWF;
4
5
class GoogleFontsOptimizer
6
{
7
    const FILTER_MARKUP_TYPE  = 'zwf_gfo_markup_type';
8
    const DEFAULT_MARKUP_TYPE = 'link'; // Anything else generates the WebFont script loader
9
10
    const FILTER_OPERATION_MODE  = 'zwf_gfo_mode';
11
    const DEFAULT_OPERATION_MODE = 'enqueued_styles_only';
12
13
    const FILTER_OB_CLEANER  = 'zwf_gfo_clean_ob';
14
    const DEFAULT_OB_CLEANER = false;
15
16
    protected $candidates = [];
17
18
    protected $enqueued = [];
19
20
    /**
21
     * Like the wind.
22
     * Main entry point when hooked/called from WP action.
23
     *
24
     * @return void
25
     */
26
    public function run()
27
    {
28
        $mode = apply_filters(self::FILTER_OPERATION_MODE, self::DEFAULT_OPERATION_MODE);
29
30
        switch ($mode) {
31
            case 'markup':
32
                /**
33
                 * Scan and optimize requests found in the markup (uses more
34
                 * memory but works on [almost] any theme)
35
                 */
36
                add_action('template_redirect', [$this, 'startBuffering'], 11);
37
                break;
38
39
            case self::DEFAULT_OPERATION_MODE:
40
            default:
41
                /**
42
                 * Scan only things added via wp_enqueue_style (uses slightly
43
                 * less memory usually, but requires a decently coded theme)
44
                 */
45
                add_filter('print_styles_array', [$this, 'processStylesHandles']);
46
                break;
47
        }
48
    }
49
50
    /**
51
     * Returns true when an array isn't empty and has enough elements.
52
     *
53
     * @param array $candidates
54
     *
55
     * @return bool
56
     */
57
    protected function hasEnoughElements(array $candidates = [])
58
    {
59
        $enough = true;
60
61
        if (empty($candidates) || count($candidates) < 2) {
62
            $enough = false;
63
        }
64
65
        return $enough;
66
    }
67
68
    /**
69
     * Callback to hook into `wp_print_styles`.
70
     * It processes enqueued styles and combines any multiple google fonts
71
     * requests into a single one (and removes the enqueued styles handles
72
     * and replaces them with a single combined enqueued style request/handle).
73
     *
74
     * TODO/FIXME: Investigate how this works out when named deps are used somewhere?
75
     *
76
     * @param array $handles
77
     *
78
     * @return array
79
     */
80
    public function processStylesHandles(array $handles)
81
    {
82
        $candidate_handles = $this->findCandidateHandles($handles);
83
84
        // Bail if we don't have anything that makes sense for us to continue
85
        if (! $this->hasEnoughElements($candidate_handles)) {
86
            return $handles;
87
        }
88
89
        $fonts_array = $this->getFontsArray($this->getCandidates());
90
        if (isset($fonts_array['complete'])) {
91
            $combined_font_url = $this->buildGoogleFontsUrlFromFontsArray($fonts_array);
92
            $handle_name       = 'zwf-gfo-combined';
93
            $this->enqueueStyle($handle_name, $combined_font_url);
94
            $handles[] = $handle_name;
95
        }
96
97
        if (isset($fonts_array['partial'])) {
98
            $cnt = 0;
99
            foreach ($fonts_array['partial']['url'] as $url) {
100
                $cnt++;
101
                $handle_name = 'zwf-gfo-combined-txt-' . $cnt;
102
                $this->enqueueStyle($handle_name, $url);
103
                $handles[] = $handle_name;
104
            }
105
        }
106
107
        // Remove/dequeue the ones we just combined above
108
        $this->dequeueStyleHandles($candidate_handles);
109
110
        // Removes processed handles from originally given $handles
111
        $handles = array_diff($handles, array_keys($candidate_handles));
112
113
        return $handles;
114
    }
115
116
    /**
117
     * Given a list of WP style handles return only those handles
118
     * that we're interested in.
119
     *
120
     * TODO/FIXME: See if named deps will need to be taken care of...
121
     *
122
     * @codeCoverageIgnore
123
     *
124
     * @param array $handles
125
     *
126
     * @return array
127
     */
128
    public function findCandidateHandles(array $handles)
129
    {
130
        $handler           = \wp_styles();
0 ignored issues
show
Bug introduced by
The function wp_styles was not found. Maybe you did not declare it correctly or list all dependencies? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

130
        $handler           = /** @scrutinizer ignore-call */ \wp_styles();
Loading history...
131
        $candidate_handles = [];
132
133
        foreach ($handles as $handle) {
134
            // $url = $handler->registered[ $handle ]->src;
135
            $dep = $handler->query($handle, 'registered');
136
            if ($dep) {
137
                $url = $dep->src;
138
                if ($this->isGoogleWebFontUrl($url)) {
139
                    $this->addCandidate($url);
140
                    $candidate_handles[$handle] = $url;
141
                }
142
            }
143
        }
144
145
        return $candidate_handles;
146
    }
147
148
    /**
149
     * Dequeue given `$handles`.
150
     *
151
     * @param array $handles
152
     *
153
     * @return void
154
     */
155
    public function dequeueStyleHandles(array $handles)
156
    {
157
        foreach ($handles as $handle => $url) {
158
            // @codeCoverageIgnoreStart
159
            if (function_exists('\wp_deregister_style')) {
160
                \wp_deregister_style($handle);
0 ignored issues
show
Bug introduced by
The function wp_deregister_style was not found. Maybe you did not declare it correctly or list all dependencies? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

160
                /** @scrutinizer ignore-call */ 
161
                \wp_deregister_style($handle);
Loading history...
161
            }
162
            if (function_exists('\wp_dequeue_style')) {
163
                \wp_dequeue_style($handle);
0 ignored issues
show
Bug introduced by
The function wp_dequeue_style was not found. Maybe you did not declare it correctly or list all dependencies? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

163
                /** @scrutinizer ignore-call */ 
164
                \wp_dequeue_style($handle);
Loading history...
164
            }
165
            // @codeCoverageIgnoreEnd
166
            unset($this->enqueued[$handle]);
167
        }
168
    }
169
170
    /**
171
     * Enqueues a given style using `\wp_enqueue_style()` and keeps it in
172
     * our own `$enqueued` list for reference.
173
     *
174
     * @param string $handle
175
     * @param string $url
176
     * @param array $deps
177
     * @param string|null $version
178
     *
179
     * @return void
180
     */
181
    public function enqueueStyle($handle, $url, $deps = [], $version = null)
182
    {
183
        // @codeCoverageIgnoreStart
184
        // \wp_register_style($handle, $url, $deps, $version);
185
        if (function_exists('\wp_enqueue_style')) {
186
            \wp_enqueue_style($handle, $url, $deps, $version);
0 ignored issues
show
Bug introduced by
The function wp_enqueue_style was not found. Maybe you did not declare it correctly or list all dependencies? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

186
            /** @scrutinizer ignore-call */ 
187
            \wp_enqueue_style($handle, $url, $deps, $version);
Loading history...
187
        }
188
        // @codeCoverageIgnoreEnd
189
190
        $this->enqueued[$handle] = $url;
191
    }
192
193
    /**
194
     * Get the entire list of enqueued styles or a specific one if $handle is specified.
195
     *
196
     * @param string|null $handle Style "slug"
197
     *
198
     * @return array|string
199
     */
200
    public function getEnqueued($handle = null)
201
    {
202
        $data = $this->enqueued;
203
204
        if ($handle && isset($this->enqueued[$handle])) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $handle of type null|string is loosely compared to true; this is ambiguous if the string can be empty. You might want to explicitly use !== null instead.

In PHP, under loose comparison (like ==, or !=, or switch conditions), values of different types might be equal.

For string values, the empty string '' is a special case, in particular the following results might be unexpected:

''   == false // true
''   == null  // true
'ab' == false // false
'ab' == null  // false

// It is often better to use strict comparison
'' === false // false
'' === null  // false
Loading history...
205
            $data = $this->enqueued[$handle];
206
        }
207
208
        return $data;
209
    }
210
211
    /**
212
     * Callback to invoke in oder to modify google fonts found in the HTML markup.
213
     * Returns modified markup in which multiple google fonts requests are
214
     * combined into a single one (if multiple requests are found).
215
     *
216
     * @param string $markup
217
     *
218
     * @return string
219
     */
220
    public function processMarkup($markup)
221
    {
222
        // Using DOMDocument to process the HTML, regexing breaks too easily
223
        $dom = new \DOMDocument();
224
        // @codingStandardsIgnoreLine
225
        @$dom->loadHTML($markup);
0 ignored issues
show
Security Best Practice introduced by
It seems like you do not handle an error condition for loadHTML(). This can introduce security issues, and is generally not recommended. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-unhandled  annotation

225
        /** @scrutinizer ignore-unhandled */ @$dom->loadHTML($markup);

If you suppress an error, we recommend checking for the error condition explicitly:

// For example instead of
@mkdir($dir);

// Better use
if (@mkdir($dir) === false) {
    throw new \RuntimeException('The directory '.$dir.' could not be created.');
}
Loading history...
226
        // Looking for all <link> elements
227
        $link_nodes = $dom->getElementsByTagName('link');
228
        foreach ($link_nodes as $link_node) {
229
            // Find all stylesheets
230
            $rel  = null;
231
            $type = null;
232
            $href = null;
233
            $find = ['rel', 'type', 'href'];
234
            foreach ($find as $attr) {
235
                if ($link_node->hasAttribute($attr)) {
236
                    $$attr = $link_node->getAttribute($attr);
237
                }
238
            }
239
240
            if ('stylesheet' === $rel && 'text/css' === $type && $this->isGoogleWebFontUrl($href)) {
241
                $this->addCandidate($href);
242
            }
243
        }
244
245
        // See if we found anything
246
        $candidates = $this->getCandidates();
247
248
        // Bail and return original markup unmodified if we don't have things to do
249
        if (! $this->hasEnoughElements($candidates)) {
250
            return $markup;
251
        }
252
253
        // Process what we found and modify original markup with our replacement
254
        $fonts_array = $this->getFontsArray($candidates);
255
        $font_markup = $this->buildFontsMarkup($fonts_array);
256
        $markup      = $this->modifyMarkup($markup, $font_markup, $fonts_array['links']);
257
258
        return $markup;
259
    }
260
261
    /**
262
     * Modifies given $markup to include given $font_markup in the <head>.
263
     * Also removes any existing stylesheets containing the given $font_links (if found).
264
     *
265
     * @param string $markup
266
     * @param string $font_markup
267
     * @param array $font_links
268
     *
269
     * @return string
270
     */
271
    public function modifyMarkup($markup, $font_markup, array $font_links)
272
    {
273
        $new_markup = $markup;
274
275
        // Remove existing stylesheets
276
        foreach ($font_links as $font_link) {
277
            $font_link = preg_quote($font_link, '/');
278
279
            // Tweak back what DOMDocument replaces sometimes
280
            $font_link = str_replace('&#038;', '&', $font_link);
281
            // This adds an extra capturing group in the pattern actually
282
            $font_link = str_replace('&', '(&|&#038;|&amp;)', $font_link);
283
            // Match this url's link tag, including optional newlines at the end of the string
284
            $pattern = '/<link([^>]*?)href[\s]?=[\s]?[\'\"\\\]*' . $font_link . '([^>]*?)>([\s]+)?/is';
285
            // Now replace
286
            $new_markup = preg_replace($pattern, '', $new_markup);
287
        }
288
289
        // Adding the font markup to top of <head> for now
290
        // TODO/FIXME: This could easily break when someone uses `<head>` in HTML comments and such?
291
        $new_markup = str_ireplace('<head>', '<head>' . $font_markup, trim($new_markup));
292
293
        return $new_markup;
294
    }
295
296
    public function startBuffering()
297
    {
298
        $started = false;
299
300
        if ($this->shouldBuffer()) {
301
            // In theory, others might've already started buffering before us,
302
            // which can prevent us from getting the markup
303
            /*if ($this->shouldCleanOutputBuffers()) {
304
                $this->cleanOutputBuffers();
305
            }*/
306
307
            // Start our own buffering
308
            $started = ob_start([$this, 'endBuffering']);
309
        }
310
311
        return $started;
312
    }
313
314
    public function endBuffering($markup)
315
    {
316
        // Bail early on things we don't want to parse
317
        if (! $this->isMarkupDoable($markup)) {
318
            return $markup;
319
        }
320
321
        return $this->processMarkup($markup);
322
    }
323
324
    /**
325
     * Determines whether the current WP request should be buffered.
326
     *
327
     * @return bool
328
     * @codeCoverageIgnore
329
     */
330
    protected function shouldBuffer()
331
    {
332
        $buffer = true;
333
334
        if ($buffer && function_exists('\is_admin') && is_admin()) {
0 ignored issues
show
Bug introduced by
The function is_admin was not found. Maybe you did not declare it correctly or list all dependencies? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

334
        if ($buffer && function_exists('\is_admin') && /** @scrutinizer ignore-call */ is_admin()) {
Loading history...
335
            $buffer = false;
336
        }
337
338
        if ($buffer && function_exists('\is_feed') && is_feed()) {
0 ignored issues
show
Bug introduced by
The function is_feed was not found. Maybe you did not declare it correctly or list all dependencies? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

338
        if ($buffer && function_exists('\is_feed') && /** @scrutinizer ignore-call */ is_feed()) {
Loading history...
339
            $buffer = false;
340
        }
341
342
        if ($buffer && defined('\DOING_AJAX')) {
343
            $buffer = false;
344
        }
345
346
        if ($buffer && defined('\DOING_CRON')) {
347
            $buffer = false;
348
        }
349
350
        if ($buffer && defined('\WP_CLI')) {
351
            $buffer = false;
352
        }
353
354
        if ($buffer && defined('\APP_REQUEST')) {
355
            $buffer = false;
356
        }
357
358
        if ($buffer && defined('\XMLRPC_REQUEST')) {
359
            $buffer = false;
360
        }
361
362
        if ($buffer && defined('\SHORTINIT') && SHORTINIT) {
0 ignored issues
show
Bug introduced by
The constant ZWF\SHORTINIT was not found. Maybe you did not declare it correctly or list all dependencies?
Loading history...
363
            $buffer = false;
364
        }
365
366
        return $buffer;
367
    }
368
369
    /**
370
     * @codeCoverageIgnore
371
     */
372
    protected function shouldCleanOutputBuffers()
373
    {
374
        return apply_filters(self::FILTER_OB_CLEANER, self::DEFAULT_OB_CLEANER);
375
    }
376
377
    /**
378
     * @codeCoverageIgnore
379
     */
380
    protected function cleanOutputBuffers()
381
    {
382
        while (ob_get_level() > 0) {
383
            ob_end_clean();
384
        }
385
    }
386
387
    protected function isMarkupDoable($content)
388
    {
389
        $doable = true;
390
391
        $has_no_html_tag    = (false === stripos($content, '<html'));
392
        $has_xsl_stylesheet = (false !== stripos($content, '<xsl:stylesheet'));
393
        $has_html5_doctype  = (preg_match('/^<!DOCTYPE.+html>/i', $content) > 0);
394
395
        if ($has_no_html_tag) {
396
            // Can't be valid amp markup without an html tag preceding it
397
            $is_amp_markup = false;
398
        } else {
399
            $is_amp_markup = $this->isAmpMarkup($content);
400
        }
401
402
        if ($has_no_html_tag && ! $has_html5_doctype || $is_amp_markup || $has_xsl_stylesheet) {
403
            $doable = false;
404
        }
405
406
        return $doable;
407
    }
408
409
    /**
410
     * Returns true if `$markup` is considered to be AMP.
411
     * This is far from actual validation againts AMP spec, but it'll do for now.
412
     */
413
    protected function isAmpMarkup($content)
414
    {
415
        $is_amp_markup = preg_match('/<html[^>]*(?:amp|⚡)/i', $content);
416
417
        return (bool) $is_amp_markup;
418
    }
419
420
    /**
421
     * Returns true if a given url is a google font url.
422
     *
423
     * @param string $url
424
     *
425
     * @return bool
426
     */
427
    public function isGoogleWebFontUrl($url)
428
    {
429
        return (substr_count($url, 'fonts.googleapis.com/css') > 0);
430
    }
431
432
    /**
433
     * @param array $urls
434
     */
435
    public function setCandidates(array $urls = [])
436
    {
437
        $this->candidates = $urls;
438
    }
439
440
    /**
441
     * @param string $url
442
     */
443
    public function addCandidate($url)
444
    {
445
        $this->candidates[] = $url;
446
    }
447
448
    /**
449
     * @return array
450
     */
451
    public function getCandidates()
452
    {
453
        return $this->candidates;
454
    }
455
456
    /**
457
     * Builds a combined Google Font URL for multiple font families/subsets.
458
     *
459
     * Usage examples:
460
     * ```
461
     * $this->buildGoogleFontsUrl(
462
     *     [
463
     *         'Open Sans' => [ '400', '400italic', '700', '700italic' ],
464
     *         'Ubuntu'    => [ '400', '400italic', '700', '700italic' ],
465
     *     ),
466
     *     [ 'latin', 'latin-ext' ]
467
     * );
468
     * ```
469
     *
470
     * or
471
     *
472
     * ```
473
     * $this->buildGoogleFontsUrl(
474
     *     [
475
     *         'Open Sans' => '400,400italic,700,700italic',
476
     *         'Ubuntu'    => '400,400italic,700,700italic',
477
     *     ],
478
     *     'latin,latin-ext'
479
     * );
480
     * ```
481
     *
482
     * @param array $fonts
483
     * @param array|string $subsets
484
     *
485
     * @return null|string
486
     */
487
    public function buildGoogleFontsUrl(array $fonts, $subsets = [])
488
    {
489
        $base_url  = 'https://fonts.googleapis.com/css';
490
        $font_args = [];
491
        $family    = [];
492
        $url       = null;
0 ignored issues
show
Unused Code introduced by
The assignment to $url is dead and can be removed.
Loading history...
493
494
        foreach ($fonts as $font_name => $font_weight) {
495
            if (is_array($font_weight)) {
496
                $font_weight = implode(',', $font_weight);
497
            }
498
            // Trimming end colon handles edge case of being given an empty $font_weight
499
            $family[] = trim(trim($font_name) . ':' . trim($font_weight), ':');
500
        }
501
502
        $font_args['family'] = implode('|', $family);
503
504
        if (! empty($subsets)) {
505
            if (is_array($subsets)) {
506
                $subsets = array_unique($subsets);
507
                $subsets = implode(',', $subsets);
508
            }
509
            $font_args['subset'] = trim($subsets);
510
        }
511
512
        $url = $base_url . '?' . http_build_query($font_args);
513
514
        return $url;
515
    }
516
517
    /**
518
     * Given a list of google fonts urls it returns another array of data
519
     * representing found families/sizes/subsets/urls.
520
     *
521
     * @param array $candidates
522
     *
523
     * @return array
524
     */
525
    protected function getFontsArray(array $candidates = [])
526
    {
527
        $fonts = [];
528
529
        foreach ($candidates as $candidate) {
530
            $fonts['links'][] = $candidate;
531
532
            $params = [];
533
            parse_str(parse_url($candidate, PHP_URL_QUERY), $params);
534
535
            if (isset($params['text'])) {
536
                // Fonts with character limitations are segregated into
537
                // under 'partial' (when `text` query param is used)
538
                $font_family                = explode(':', $params['family']);
539
                $fonts['partial']['name'][] = $font_family[0];
540
                $fonts['partial']['url'][]  = $this->httpsify($candidate);
541
            } else {
542
                $fontstrings = $this->buildFontStringsFromQueryParams($params);
543
                foreach ($fontstrings as $font) {
544
                    $fonts['complete'][] = $font;
545
                }
546
            }
547
        }
548
549
        return $fonts;
550
    }
551
552
    /**
553
     * Looks for and parses the `family` query string value into a string
554
     * that Google Fonts expects (family, weights and subsets separated by
555
     * a semicolon).
556
     *
557
     * @param array $params
558
     *
559
     * @return array
560
     */
561
    protected function buildFontStringsFromQueryParams(array $params)
562
    {
563
        $fonts = [];
564
565
        foreach (explode('|', $params['family']) as $families) {
566
            $font_family = explode(':', $families);
567
            $subset      = false;
568
            if (isset($params['subset'])) {
569
                // Use the found subset parameter
570
                $subset = $params['subset'];
571
            } elseif (isset($font_family[2])) {
572
                // Use the subset in the family string
573
                $subset = $font_family[2];
574
            }
575
576
            // We need to have a name and a size
577
            if (strlen($font_family[0]) > 0 && strlen($font_family[1]) > 0) {
578
                $parts = [
579
                    $font_family[0],
580
                    $font_family[1]
581
                ];
582
                if ($subset) {
583
                    $parts[] = $subset;
584
                }
585
                $fonts[] = implode(':', $parts);
586
            }
587
        }
588
589
        return $fonts;
590
    }
591
592
    /**
593
     * Creates a single google fonts url from data returned by `getFontsArray()`.
594
     *
595
     * @param array $fonts_array
596
     *
597
     * @return string
598
     */
599
    protected function buildGoogleFontsUrlFromFontsArray(array $fonts_array)
600
    {
601
        list($fonts, $subsets) = $this->consolidateFontsArray($fonts_array);
602
603
        return $this->buildGoogleFontsUrl($fonts, $subsets);
604
    }
605
606
    /**
607
     * Given a "raw" getFontsArray(), deduplicate and sort the data
608
     * and return a new array with three keys:
609
     * - the first key contains sorted list of fonts/sizes
610
     * - the second key contains the global list of requested subsets
611
     * - the third key contains the map of requested font names and their subsets
612
     *
613
     * @param array $fonts_array
614
     *
615
     * @return array
616
     */
617
    protected function consolidateFontsArray(array $fonts_array)
618
    {
619
        $fonts         = [];
620
        $subsets       = [];
621
        $fonts_to_subs = [];
622
623
        foreach ($fonts_array['complete'] as $font_string) {
624
            $parts = explode(':', $font_string);
625
            $name  = $parts[0];
626
            $size  = $parts[1];
627
628
            if (isset($fonts[$name])) {
629
                // If a name already exists, append the new size
630
                $fonts[$name] .= ',' . $size;
631
            } else {
632
                // Create a new key for the name and size
633
                $fonts[$name] = $size;
634
            }
635
636
            // Check if subset is specified as the third element
637
            if (isset($parts[2])) {
638
                $subset = $parts[2];
639
                // Collect all the subsets defined into a single array
640
                $elements = explode(',', $subset);
641
                foreach ($elements as $sub) {
642
                    $subsets[] = $sub;
643
                }
644
                // Keeping a separate map of names => requested subsets for
645
                // webfontloader purposes
646
                if (isset($fonts_to_subs[$name])) {
647
                    $fonts_to_subs[$name] .= ',' . $subset;
648
                } else {
649
                    $fonts_to_subs[$name] = $subset;
650
                }
651
            }
652
        }
653
654
        // Remove duplicate subsets
655
        $subsets = array_unique($subsets);
656
657
        // Sanitize and de-dup name/sizes pairs
658
        $fonts = $this->dedupValues($fonts, SORT_REGULAR); // sorts values (sizes)
659
660
        // Sorts by font names alphabetically
661
        ksort($fonts);
662
663
        // Sanitize and de-dup $fonts_to_subs mapping
664
        $fonts_to_subs = $this->dedupValues($fonts_to_subs, false); // no sort
665
666
        return [$fonts, $subsets, $fonts_to_subs];
667
    }
668
669
    /**
670
     * Given a key => value map in which the value is a single string or
671
     * a list of comma-separeted strings, it returns a new array with the
672
     * given keys, but the values are transformed into an array and any
673
     * potential duplicate values are removed.
674
     * If the $sort parameter is given, the list of values is sorted using
675
     * `sort()` and the $sort param is treated as a sort flag.
676
     *
677
     * @param array $data
678
     * @param bool|int $sort If false, no sorting, otherwise an int representing
679
     *                       sort flags. See http://php.net/sort
680
     *
681
     * @return array
682
     */
683
    protected function dedupValues(array $data, $sort = false)
684
    {
685
        foreach ($data as $key => $values) {
686
            $parts = explode(',', $values);
687
            $parts = array_unique($parts);
688
            if (false !== $sort) {
689
                sort($parts, $sort);
0 ignored issues
show
Bug introduced by
It seems like $sort can also be of type true; however, parameter $sort_flags of sort() does only seem to accept integer, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

689
                sort($parts, /** @scrutinizer ignore-type */ $sort);
Loading history...
690
            }
691
            $data[$key] = $parts;
692
        }
693
694
        return $data;
695
    }
696
697
    /**
698
     * Replaces any occurences of un-encoded ampersands in the given string
699
     * with the value given in the `$amp` parameter (`&amp;` by default).
700
     *
701
     * @param string $url
702
     * @param string $amp
703
     *
704
     * @return string
705
     */
706
    public function encodeUnencodedAmpersands($url, $amp = '&amp;')
707
    {
708
        $amp = trim($amp);
709
        if (empty($amp)) {
710
            $amp = '&amp;';
711
        }
712
713
        return preg_replace('/&(?!#?\w+;)/', $amp, $url);
714
    }
715
716
    /**
717
     * Turns protocol-relative or non-https URLs into their https versions.
718
     *
719
     * @param string $link
720
     *
721
     * @return string
722
     */
723
    public function httpsify($link)
724
    {
725
        $is_protocol_relative = ('/' === $link{0} && '/' === $link{1});
726
727
        if ($is_protocol_relative) {
728
            $link = 'https:' . $link;
729
        } else {
730
            $link = preg_replace('/^(https?):\/\//mi', '', $link);
731
            $link = 'https://' . $link;
732
        }
733
734
        return $link;
735
    }
736
737
    /**
738
     * Given data from `getFontsArray()` builds HTML markup for found google fonts.
739
     *
740
     * @param array $fonts_array
741
     *
742
     * @return string
743
     */
744
    protected function buildFontsMarkup(array $fonts_array)
745
    {
746
        $markup_type = apply_filters(self::FILTER_MARKUP_TYPE, self::DEFAULT_MARKUP_TYPE);
747
        if ('link' === $markup_type) {
748
            // Build standard link markup
749
            $font_url = $this->buildGoogleFontsUrlFromFontsArray($fonts_array);
750
            $href     = $this->encodeUnencodedAmpersands($font_url);
751
            $markup   = '<link rel="stylesheet" type="text/css" href="' . $href . '">';
752
753
            if (isset($fonts_array['partial'])) {
754
                if (is_array($fonts_array['partial']['url'])) {
755
                    foreach ($fonts_array['partial']['url'] as $other) {
756
                        $markup .= '<link rel="stylesheet" type="text/css"';
757
                        $markup .= ' href="' . $this->encodeUnencodedAmpersands($other) . '">';
758
                    }
759
                }
760
            }
761
        } else {
762
            // Bulding WebFont script loader
763
            $families_array = [];
764
765
            list($fonts, $subsets, $mapping) = $this->consolidateFontsArray($fonts_array);
766
            foreach ($fonts as $name => $sizes) {
767
                $family = $name . ':' . implode(',', $sizes);
768
                if (isset($mapping[$name])) {
769
                    $family .= ':' . implode(',', $mapping[$name]);
770
                }
771
                $families_array[] = $family;
772
            }
773
            $families = "'" . implode("', '", $families_array) . "'";
774
775
            // Load 'text' requests with the "custom" module
776
            $custom = '';
777
            if (isset($fonts_array['partial'])) {
778
                $custom  = ",\n    custom: {\n";
779
                $custom .= "        families: [ '" . implode("', '", $fonts_array['partial']['name']) . "' ],\n";
780
                $custom .= "        urls: [ '" . implode("', '", $fonts_array['partial']['url']) . "' ]\n";
781
                $custom .= '    }';
782
            }
783
784
            $markup = <<<HTML
785
<script type="text/javascript">
786
WebFontConfig = {
787
    google: { families: [ {$families} ] }{$custom}
788
};
789
(function() {
790
    var wf = document.createElement('script');
791
    wf.src = ('https:' == document.location.protocol ? 'https' : 'http') +
792
        '://ajax.googleapis.com/ajax/libs/webfont/1/webfont.js';
793
    wf.type = 'text/javascript';
794
    wf.async = 'true';
795
    var s = document.getElementsByTagName('script')[0];
796
    s.parentNode.insertBefore(wf, s);
797
})();
798
</script>
799
HTML;
800
        }
801
802
        return $markup;
803
    }
804
}
805