Passed
Push — master ( cf215e...e3de86 )
by zyt
03:08
created

GoogleFontsOptimizer::buildFontStringParts()   A

Complexity

Conditions 4
Paths 4

Size

Total Lines 18
Code Lines 7

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 9
CRAP Score 4

Importance

Changes 0
Metric Value
cc 4
eloc 7
nc 4
nop 2
dl 0
loc 18
ccs 9
cts 9
cp 1
crap 4
rs 9.2
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 3
    public function run()
27 3
    {
28 3
        $mode = apply_filters(self::FILTER_OPERATION_MODE, self::DEFAULT_OPERATION_MODE);
29
30
        switch ($mode) {
31 3
            case 'markup':
32
                /**
33
                 * Scan and optimize requests found in the markup (uses more
34
                 * memory but works on [almost] any theme)
35
                 */
36 1
                add_action('template_redirect', [$this, 'startBuffering'], 11);
37 1
                break;
38
39 2
            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 2
                add_filter('print_styles_array', [$this, 'processStylesHandles']);
46 2
                break;
47
        }
48 3
    }
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 6
    protected function hasEnoughElements(array $candidates = [])
58 6
    {
59 6
        $enough = true;
60
61 6
        if (empty($candidates) || count($candidates) < 2) {
62 2
            $enough = false;
63
        }
64
65 6
        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 2
    public function processStylesHandles(array $handles)
81 2
    {
82 2
        $candidate_handles = $this->findCandidateHandles($handles);
83
84
        // Bail if we don't have anything that makes sense for us to continue
85 2
        if (! $this->hasEnoughElements($candidate_handles)) {
86 1
            return $handles;
87
        }
88
89
        // Set the list of found urls we matched
90 1
        $this->setCandidates(array_values($candidate_handles));
91
92
        // Get fonts array data from candidates
93 1
        $fonts_array = $this->getFontsArray($this->getCandidates());
94 1
        if (isset($fonts_array['complete'])) {
95 1
            $combined_font_url = $this->buildGoogleFontsUrlFromFontsArray($fonts_array);
96 1
            $handle_name       = 'zwf-gfo-combined';
97 1
            $this->enqueueStyle($handle_name, $combined_font_url);
98 1
            $handles[] = $handle_name;
99
        }
100
101 1
        if (isset($fonts_array['partial'])) {
102 1
            $cnt = 0;
103 1
            foreach ($fonts_array['partial']['url'] as $url) {
104 1
                $cnt++;
105 1
                $handle_name = 'zwf-gfo-combined-txt-' . $cnt;
106 1
                $this->enqueueStyle($handle_name, $url);
107 1
                $handles[] = $handle_name;
108
            }
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
    public 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 ($this->isGoogleWebFontUrl($url)) {
142
                    $candidate_handles[$handle] = $url;
143
                }
144
            }
145
        }
146
147
        return $candidate_handles;
148
    }
149
150
    /**
151
     * Dequeue given `$handles`.
152
     *
153
     * @param array $handles
154
     *
155
     * @return void
156
     */
157 1
    public function dequeueStyleHandles(array $handles)
158 1
    {
159 1
        foreach ($handles as $handle => $url) {
160
            // @codeCoverageIgnoreStart
161
            if (function_exists('\wp_deregister_style')) {
162
                /** @scrutinizer ignore-call */
163
                \wp_deregister_style($handle);
164
            }
165
            if (function_exists('\wp_dequeue_style')) {
166
                /** @scrutinizer ignore-call */
167
                \wp_dequeue_style($handle);
168
            }
169
            // @codeCoverageIgnoreEnd
170 1
            unset($this->enqueued[$handle]);
171
        }
172 1
    }
173
174
    /**
175
     * Enqueues a given style using `\wp_enqueue_style()` and keeps it in
176
     * our own `$enqueued` list for reference.
177
     *
178
     * @param string $handle
179
     * @param string $url
180
     * @param array $deps
181
     * @param string|null $version
182
     *
183
     * @return void
184
     */
185 1
    public function enqueueStyle($handle, $url, $deps = [], $version = null)
186 1
    {
187
        // @codeCoverageIgnoreStart
188
        if (function_exists('\wp_enqueue_style')) {
189
            /** @scrutinizer ignore-call */
190
            \wp_enqueue_style($handle, $url, $deps, $version);
191
        }
192
        // @codeCoverageIgnoreEnd
193
194 1
        $this->enqueued[$handle] = $url;
195 1
    }
196
197
    /**
198
     * Get the entire list of enqueued styles or a specific one if $handle is specified.
199
     *
200
     * @param string|null $handle Style "slug"
201
     *
202
     * @return array|string
203
     */
204 1
    public function getEnqueued($handle = null)
205 1
    {
206 1
        $data = $this->enqueued;
207
208 1
        if (null !== $handle && isset($this->enqueued[$handle])) {
209 1
            $data = $this->enqueued[$handle];
210
        }
211
212 1
        return $data;
213
    }
214
215
    /**
216
     * Callback to invoke in oder to modify google fonts found in the HTML markup.
217
     * Returns modified markup in which multiple google fonts requests are
218
     * combined into a single one (if multiple requests are found).
219
     *
220
     * @param string $markup
221
     *
222
     * @return string
223
     */
224 4
    public function processMarkup($markup)
225 4
    {
226 4
        $hrefs = $this->parseMarkupForHrefs($markup);
227 4
        if (!empty($hrefs)) {
228 4
            $this->setCandidates($hrefs);
229
        }
230
231
        // See if we found anything
232 4
        $candidates = $this->getCandidates();
233
234
        // Bail and return original markup unmodified if we don't have things to do
235 4
        if (! $this->hasEnoughElements($candidates)) {
236 1
            return $markup;
237
        }
238
239
        // Process what we found and modify original markup with our replacement
240 4
        $fonts_array = $this->getFontsArray($candidates);
241 4
        $font_markup = $this->buildFontsMarkup($fonts_array);
242 4
        $markup      = $this->modifyMarkup($markup, $font_markup, $fonts_array['links']);
243
244 4
        return $markup;
245
    }
246
247
    /**
248
     * Given a string of $markup, returns an array of hrefs we're interested in.
249
     *
250
     * @param string $markup
251
     *
252
     * @return array
253
     */
254 4
    protected function parseMarkupForHrefs($markup)
255 4
    {
256 4
        $hrefs = [];
0 ignored issues
show
Unused Code introduced by
The assignment to $hrefs is dead and can be removed.
Loading history...
257
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
0 ignored issues
show
Bug introduced by
The type ZWF\DOMNodeList was not found. Did you mean DOMNodeList? If so, make sure to prefix the type with \.
Loading history...
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.
0 ignored issues
show
Bug introduced by
The type ZWF\DOMNode was not found. Did you mean DOMNode? If so, make sure to prefix the type with \.
Loading history...
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 && $this->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  = $this->hasHtmlTag($content);
441 2
        $html5 = $this->hasHtml5Doctype($content);
442 2
        $xsl   = $this->hasXslStylesheet($content);
443
444 2
        return (($html || $html5) && ! $xsl);
445
    }
446
447
    /**
448
     * Returns true if given `$string` contains the HTML5 doctype.
449
     *
450
     * @param string $string
451
     *
452
     * @return bool
453
     */
454 2
    protected function hasHtml5Doctype($string)
455 2
    {
456 2
        return (preg_match('/^<!DOCTYPE.+html>/i', $string) > 0);
457
    }
458
459
    /**
460
     * Returns true when given `$string` contains an XSL stylesheet element.
461
     *
462
     * @param string $string
463
     *
464
     * @return bool
465
     */
466 2
    protected function hasXslStylesheet($string)
467 2
    {
468 2
        return (false !== stripos($string, '<xsl:stylesheet'));
469
    }
470
471
    /**
472
     * Returns true when given `$string` contains the beginnings of an `<html>` tag.
473
     *
474
     * @param string $string
475
     * @return bool
476
     */
477 2
    protected function hasHtmlTag($string)
478 2
    {
479 2
        return (false !== stripos($string, '<html'));
480
    }
481
482
    /**
483
     * Returns true if a given url is a google font url.
484
     *
485
     * @param string $url
486
     *
487
     * @return bool
488
     */
489 12
    public function isGoogleWebFontUrl($url)
490 12
    {
491 12
        return (substr_count($url, 'fonts.googleapis.com/css') > 0);
492
    }
493
494
    /**
495
     * @param array $urls
496
     */
497 5
    public function setCandidates(array $urls = [])
498 5
    {
499 5
        $this->candidates = $urls;
500 5
    }
501
502
    /**
503
     * @return array
504
     */
505 5
    public function getCandidates()
506 5
    {
507 5
        return $this->candidates;
508
    }
509
510
    /**
511
     * Builds a combined Google Font URL for multiple font families/subsets.
512
     *
513
     * Usage examples:
514
     * ```
515
     * $this->buildGoogleFontsUrl(
516
     *     [
517
     *         'Open Sans' => [ '400', '400italic', '700', '700italic' ],
518
     *         'Ubuntu'    => [ '400', '400italic', '700', '700italic' ],
519
     *     ),
520
     *     [ 'latin', 'latin-ext' ]
521
     * );
522
     * ```
523
     *
524
     * or
525
     *
526
     * ```
527
     * $this->buildGoogleFontsUrl(
528
     *     [
529
     *         'Open Sans' => '400,400italic,700,700italic',
530
     *         'Ubuntu'    => '400,400italic,700,700italic',
531
     *     ],
532
     *     'latin,latin-ext'
533
     * );
534
     * ```
535
     *
536
     * @param array $fonts
537
     * @param array|string $subsets
538
     *
539
     * @return null|string
540
     */
541 6
    public function buildGoogleFontsUrl(array $fonts, $subsets = [])
542 6
    {
543 6
        $base_url  = 'https://fonts.googleapis.com/css';
544 6
        $font_args = [];
545 6
        $family    = [];
546
547 6
        foreach ($fonts as $font_name => $font_weight) {
548 6
            if (is_array($font_weight)) {
549 6
                $font_weight = implode(',', $font_weight);
550
            }
551
            // Trimming end colon handles edge case of being given an empty $font_weight
552 6
            $family[] = trim(trim($font_name) . ':' . trim($font_weight), ':');
553
        }
554
555 6
        $font_args['family'] = implode('|', $family);
556
557 6
        if (! empty($subsets)) {
558 5
            if (is_array($subsets)) {
559 4
                $subsets = array_unique($subsets);
560 4
                $subsets = implode(',', $subsets);
561
            }
562 5
            $font_args['subset'] = trim($subsets);
563
        }
564
565 6
        $url = $base_url . '?' . http_build_query($font_args);
566
567 6
        return $url;
568
    }
569
570
    /**
571
     * Given a list of google fonts urls it returns another array of data
572
     * representing found families/sizes/subsets/urls.
573
     *
574
     * @param array $candidates
575
     *
576
     * @return array
577
     */
578 5
    protected function getFontsArray(array $candidates = [])
579 5
    {
580 5
        $fonts = [];
581
582 5
        foreach ($candidates as $candidate) {
583 5
            $fonts['links'][] = $candidate;
584
585 5
            $params = [];
586 5
            parse_str(parse_url($candidate, PHP_URL_QUERY), $params);
587
588 5
            if (isset($params['text'])) {
589
                // Fonts with character limitations are segregated into
590
                // under 'partial' (when `text` query param is used)
591 3
                $font_family                = explode(':', $params['family']);
592 3
                $fonts['partial']['name'][] = $font_family[0];
593 3
                $fonts['partial']['url'][]  = $this->httpsify($candidate);
594
            } else {
595 5
                $fontstrings = $this->buildFontStringsFromQueryParams($params);
596 5
                foreach ($fontstrings as $font) {
597 5
                    $fonts['complete'][] = $font;
598
                }
599
            }
600
        }
601
602 5
        return $fonts;
603
    }
604
605
    /**
606
     * Looks for and parses the `family` query string value into a string
607
     * that Google Fonts expects (family, weights and subsets separated by
608
     * a semicolon).
609
     *
610
     * @param array $params
611
     *
612
     * @return array
613
     */
614 5
    protected function buildFontStringsFromQueryParams(array $params)
615 5
    {
616 5
        $fonts = [];
617
618 5
        foreach (explode('|', $params['family']) as $family) {
619 5
            $font = $this->parseFontStringFamilyParam($family, $params);
620 5
            if (!empty($font)) {
621 5
                $fonts[] = $font;
622
            }
623
        }
624
625 5
        return $fonts;
626
    }
627
628 5
    protected function parseFontStringFamilyParam($family, array $params)
629 5
    {
630 5
        $font   = null;
0 ignored issues
show
Unused Code introduced by
The assignment to $font is dead and can be removed.
Loading history...
631 5
        $subset = false;
632 5
        $family = explode(':', $family);
633
634 5
        if (isset($params['subset'])) {
635
            // Use the found subset query parameter
636 4
            $subset = $params['subset'];
637 3
        } elseif (isset($family[2])) {
638
            // Use the subset in the family string if present
639 1
            $subset = $family[2];
640
        }
641
642
        // We can have only the name specified in some cases
643 5
        $parts = $this->buildFontStringParts($family, $subset);
644 5
        $font  = implode(':', $parts);
645
646 5
        return $font;
647
    }
648
649
    /**
650
     * Validate and return needed font parts data.
651
     *
652
     * @param array $family
653
     * @param bool $subset
654
     *
655
     * @return array
656
     */
657 5
    protected function buildFontStringParts(array $family, $subset = false)
658 5
    {
659 5
        $parts = [];
660
661
        // First part is the font name, which should always be present
662 5
        $parts[] = $family[0];
663
664
        // Check if sizes are specified
665 5
        if (isset($family[1]) && strlen($family[1]) > 0) {
666 4
            $parts[] = $family[1];
667
        }
668
669
        // Add the subset if specified
670 5
        if ($subset) {
671 4
            $parts[] = $subset;
672
        }
673
674 5
        return $parts;
675
    }
676
677
    /**
678
     * Creates a single google fonts url from data returned by `getFontsArray()`.
679
     *
680
     * @param array $fonts_array
681
     *
682
     * @return string
683
     */
684 3
    protected function buildGoogleFontsUrlFromFontsArray(array $fonts_array)
685 3
    {
686 3
        list($fonts, $subsets) = $this->consolidateFontsArray($fonts_array);
687
688 3
        return $this->buildGoogleFontsUrl($fonts, $subsets);
689
    }
690
691
    /**
692
     * Given a "raw" getFontsArray(), deduplicate and sort the data
693
     * and return a new array with three keys:
694
     * - the first key contains sorted list of fonts/sizes
695
     * - the second key contains the global list of requested subsets
696
     * - the third key contains the map of requested font names and their subsets
697
     *
698
     * @param array $fonts_array
699
     *
700
     * @return array
701
     */
702 5
    protected function consolidateFontsArray(array $fonts_array)
703 5
    {
704 5
        $fonts         = [];
705 5
        $subsets       = [];
706 5
        $fonts_to_subs = [];
707
708 5
        foreach ($fonts_array['complete'] as $font_string) {
709 5
            $parts = explode(':', $font_string);
710 5
            $name  = $parts[0];
711 5
            $size  = isset($parts[1]) ? $parts[1] : '';
712
713 5
            if (isset($fonts[$name])) {
714
                // If a name already exists, append the new size
715 1
                $fonts[$name] .= ',' . $size;
716
            } else {
717
                // Create a new key for the name and size
718 5
                $fonts[$name] = $size;
719
            }
720
721
            // Check if subset is specified as the third element
722 5
            if (isset($parts[2])) {
723 4
                $subset = $parts[2];
724
                // Collect all the subsets defined into a single array
725 4
                $elements = explode(',', $subset);
726 4
                foreach ($elements as $sub) {
727 4
                    $subsets[] = $sub;
728
                }
729
                // Keeping a separate map of names => requested subsets for
730
                // webfontloader purposes
731 4
                if (isset($fonts_to_subs[$name])) {
732 1
                    $fonts_to_subs[$name] .= ',' . $subset;
733
                } else {
734 5
                    $fonts_to_subs[$name] = $subset;
735
                }
736
            }
737
        }
738
739
        // Remove duplicate subsets
740 5
        $subsets = array_unique($subsets);
741
742
        // Sanitize and de-dup name/sizes pairs
743 5
        $fonts = $this->dedupValues($fonts, SORT_REGULAR); // sorts values (sizes)
744
745
        // Sorts by font names alphabetically
746 5
        ksort($fonts);
747
748
        // Sanitize and de-dup $fonts_to_subs mapping
749 5
        $fonts_to_subs = $this->dedupValues($fonts_to_subs, false); // no sort
750
751 5
        return [$fonts, $subsets, $fonts_to_subs];
752
    }
753
754
    /**
755
     * Given a key => value map in which the value is a single string or
756
     * a list of comma-separeted strings, it returns a new array with the
757
     * given keys, but the values are transformed into an array and any
758
     * potential duplicate values are removed.
759
     * If the $sort parameter is given, the list of values is sorted using
760
     * `sort()` and the $sort param is treated as a sort flag.
761
     *
762
     * @param array $data
763
     * @param bool|int $sort If false, no sorting, otherwise an int representing
764
     *                       sort flags. See http://php.net/sort
765
     *
766
     * @return array
767
     */
768 5
    protected function dedupValues(array $data, $sort = false)
769 5
    {
770 5
        foreach ($data as $key => $values) {
771 5
            $parts = explode(',', $values);
772 5
            $parts = array_unique($parts);
773 5
            if (false !== $sort) {
774 5
                sort($parts, (int) $sort);
775
            }
776 5
            $data[$key] = $parts;
777
        }
778
779 5
        return $data;
780
    }
781
782
    /**
783
     * Replaces any occurences of un-encoded ampersands in the given string
784
     * with the value given in the `$amp` parameter (`&amp;` by default).
785
     *
786
     * @param string $url
787
     * @param string $amp
788
     *
789
     * @return string
790
     */
791 7
    public function encodeUnencodedAmpersands($url, $amp = '&amp;')
792 7
    {
793 7
        $amp = trim($amp);
794 7
        if (empty($amp)) {
795 3
            $amp = '&amp;';
796
        }
797
798 7
        return preg_replace('/&(?!#?\w+;)/', $amp, $url);
799
    }
800
801
    /**
802
     * Turns protocol-relative or non-https URLs into their https versions.
803
     *
804
     * @param string $link
805
     *
806
     * @return string
807
     */
808 6
    public function httpsify($link)
809 6
    {
810 6
        $is_protocol_relative = ('/' === $link{0} && '/' === $link{1});
811
812 6
        if ($is_protocol_relative) {
813 1
            $link = 'https:' . $link;
814
        } else {
815 5
            $link = preg_replace('/^(https?):\/\//mi', '', $link);
816 5
            $link = 'https://' . $link;
817
        }
818
819 6
        return $link;
820
    }
821
822
    /**
823
     * Given data from `getFontsArray()` builds HTML markup for found google fonts.
824
     *
825
     * @param array $fonts_array
826
     *
827
     * @return string
828
     */
829 4
    protected function buildFontsMarkup(array $fonts_array)
830 4
    {
831 4
        $markup_type = apply_filters(self::FILTER_MARKUP_TYPE, self::DEFAULT_MARKUP_TYPE);
832 4
        if ('link' === $markup_type) {
833
            // Build standard link markup
834 2
            $markup = $this->buildFontsMarkupLinks($fonts_array);
835
        } else {
836
            // Bulding WebFont script loader
837 2
            $markup = $this->buildFontsMarkupScript($fonts_array);
838
        }
839
840 4
        return $markup;
841
    }
842
843
    /**
844
     * Given data from `getFontsArray()` builds `<link rel="stylesheet">` markup.
845
     *
846
     * @param array $fonts
847
     *
848
     * @return string
849
     */
850 2
    protected function buildFontsMarkupLinks(array $fonts)
851 2
    {
852 2
        $font_url = $this->buildGoogleFontsUrlFromFontsArray($fonts);
853 2
        $href     = $this->encodeUnencodedAmpersands($font_url);
854 2
        $markup   = '<link rel="stylesheet" type="text/css" href="' . $href . '">';
855
856 2
        if (isset($fonts['partial'])) {
857 1
            if (is_array($fonts['partial']['url'])) {
858 1
                foreach ($fonts['partial']['url'] as $other) {
859 1
                    $markup .= '<link rel="stylesheet" type="text/css"';
860 1
                    $markup .= ' href="' . $this->encodeUnencodedAmpersands($other) . '">';
861
                }
862
            }
863
        }
864
865 2
        return $markup;
866
    }
867
868
    /**
869
     * Given data from `getFontsArray()` builds WebFont loader script markup.
870
     *
871
     * @param array $fonts
872
     *
873
     * @return string
874
     */
875 2
    protected function buildFontsMarkupScript(array $fonts)
876 2
    {
877 2
        $families_array = [];
878
879 2
        list($names, $subsets, $mapping) = $this->consolidateFontsArray($fonts);
880 2
        foreach ($names as $name => $sizes) {
881 2
            $family = $name . ':' . implode(',', $sizes);
882 2
            if (isset($mapping[$name])) {
883 2
                $family .= ':' . implode(',', $mapping[$name]);
884
            }
885 2
            $families_array[] = $family;
886
        }
887 2
        $families = "'" . implode("', '", $families_array) . "'";
888
889
        // Load 'text' requests with the "custom" module
890 2
        $custom = '';
891 2
        if (isset($fonts['partial'])) {
892 1
            $custom  = ",\n    custom: {\n";
893 1
            $custom .= "        families: [ '" . implode("', '", $fonts['partial']['name']) . "' ],\n";
894 1
            $custom .= "        urls: [ '" . implode("', '", $fonts['partial']['url']) . "' ]\n";
895 1
            $custom .= '    }';
896
        }
897
898
        $markup = <<<MARKUP
899
<script type="text/javascript">
900
WebFontConfig = {
901 2
    google: { families: [ {$families} ] }{$custom}
902
};
903
(function() {
904
    var wf = document.createElement('script');
905
    wf.src = ('https:' == document.location.protocol ? 'https' : 'http') +
906
        '://ajax.googleapis.com/ajax/libs/webfont/1/webfont.js';
907
    wf.type = 'text/javascript';
908
    wf.async = 'true';
909
    var s = document.getElementsByTagName('script')[0];
910
    s.parentNode.insertBefore(wf, s);
911
})();
912
</script>
913
MARKUP;
914
915 2
        return $markup;
916
    }
917
}
918