Passed
Push — master ( 7538ae...c405e8 )
by Thomas
02:16
created

DeferBackend   D

Complexity

Total Complexity 58

Size/Duplication

Total Lines 289
Duplicated Lines 0 %

Importance

Changes 9
Bugs 3 Features 0
Metric Value
wmc 58
eloc 130
c 9
b 3
f 0
dl 0
loc 289
rs 4.5599

8 Methods

Rating   Name   Duplication   Size   Complexity  
A replaceBackend() 0 20 6
A getDeferBackend() 0 7 2
A listCookieTypes() 0 3 1
A getCSS() 0 14 4
A listScriptTypes() 0 3 1
B javascript() 0 29 11
F includeInHTML() 0 122 28
A themedJavascript() 0 17 5

How to fix   Complexity   

Complex Class

Complex classes like DeferBackend 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 DeferBackend, and based on these observations, apply Extract Interface, too.

1
<?php
2
3
namespace LeKoala\DeferBackend;
4
5
use Exception;
6
use SilverStripe\View\HTML;
7
use InvalidArgumentException;
8
use SilverStripe\Core\Config\Configurable;
9
use SilverStripe\View\SSViewer;
10
use SilverStripe\View\Requirements;
11
use SilverStripe\View\ThemeResourceLoader;
12
use SilverStripe\View\Requirements_Backend;
13
14
/**
15
 * A backend that defers everything by default
16
 *
17
 * Also insert custom head tags first because order may matter
18
 *
19
 * @link https://flaviocopes.com/javascript-async-defer/
20
 */
21
class DeferBackend extends Requirements_Backend
22
{
23
    use Configurable;
24
25
    /**
26
     * @config
27
     * @var boolean
28
     */
29
    private static $enable_js_modules = false;
30
31
    // It's better to write to the head with defer
32
    public $writeJavascriptToBody = false;
33
34
    /**
35
     * @return $this
36
     */
37
    public static function getDeferBackend()
38
    {
39
        $backend = Requirements::backend();
40
        if (!$backend instanceof self) {
41
            throw new Exception("Requirements backend is currently of class " . get_class($backend));
42
        }
43
        return $backend;
44
    }
45
46
    /**
47
     * @param Requirements_Backend $oldBackend defaults to current backend
48
     * @return $this
49
     */
50
    public static function replaceBackend(Requirements_Backend $oldBackend = null)
51
    {
52
        if ($oldBackend === null) {
53
            $oldBackend = Requirements::backend();
54
        }
55
        $deferBackend = new static;
56
        foreach ($oldBackend->getCSS() as $file => $opts) {
57
            $deferBackend->css($file, null, $opts);
58
        }
59
        foreach ($oldBackend->getJavascript() as $file => $opts) {
60
            $deferBackend->javascript($file, null, $opts);
0 ignored issues
show
Unused Code introduced by
The call to LeKoala\DeferBackend\DeferBackend::javascript() has too many arguments starting with $opts. ( Ignorable by Annotation )

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

60
            $deferBackend->/** @scrutinizer ignore-call */ 
61
                           javascript($file, null, $opts);

This check compares calls to functions or methods with their respective definitions. If the call has more arguments than are defined, it raises an issue.

If a function is defined several times with a different number of parameters, the check may pick up the wrong definition and report false positives. One codebase where this has been known to happen is Wordpress. Please note the @ignore annotation hint above.

Loading history...
61
        }
62
        foreach ($oldBackend->getCustomCSS() as $id => $script) {
63
            $deferBackend->customCSS($script, $id);
64
        }
65
        foreach ($oldBackend->getCustomScripts() as $id => $script) {
66
            $deferBackend->customScript($script, $id);
67
        }
68
        Requirements::set_backend($deferBackend);
69
        return $deferBackend;
70
    }
71
72
    /**
73
     * @return array
74
     */
75
    public static function listCookieTypes()
76
    {
77
        return ['strictly-necessary', 'functionality', 'tracking', 'targeting'];
78
    }
79
80
    /**
81
     * @return array
82
     */
83
    public static function listScriptTypes()
84
    {
85
        return ['module', 'application/javascript'];
86
    }
87
88
    /**
89
     * Register the given JavaScript file as required.
90
     *
91
     * @param string $file Either relative to docroot or in the form "vendor/package:resource"
92
     * @param array $options List of options. Available options include:
93
     * - 'provides' : List of scripts files included in this file
94
     * - 'async' : Boolean value to set async attribute to script tag
95
     * - 'defer' : Boolean value to set defer attribute to script tag (true by default)
96
     * - 'type' : Override script type= value.
97
     * - 'integrity' : SubResource Integrity hash
98
     * - 'crossorigin' : Cross-origin policy for the resource
99
     * - 'cookie-consent' : Type of cookie for conditionnal loading : strictly-necessary,functionality,tracking,targeting
100
     */
101
    public function javascript($file, $options = array())
102
    {
103
        if (!is_array($options)) {
0 ignored issues
show
introduced by
The condition is_array($options) is always true.
Loading history...
104
            $options = [];
105
        }
106
        if (self::config()->enable_js_modules) {
107
            if (empty($options['type']) && self::config()->enable_js_modules) {
108
                $options['type'] = 'module';
109
            }
110
            // Modules are deferred by default
111
            if (isset($options['defer']) && $options['type'] == "module") {
112
                unset($options['defer']);
113
            }
114
        } else {
115
            // We want to defer by default, but we can disable it if needed
116
            if (!isset($options['defer'])) {
117
                $options['defer'] = true;
118
            }
119
        }
120
        if (isset($options['cookie-consent'])) {
121
            if (!in_array($options['cookie-consent'], self::listCookieTypes())) {
122
                throw new InvalidArgumentException("The cookie-consent value is invalid, it must be one of: strictly-necessary,functionality,tracking,targeting");
123
            }
124
            // switch to text plain for conditional loading
125
            $options['type'] = 'text/plain';
126
        }
127
        parent::javascript($file, $options);
128
        if (isset($options['cookie-consent'])) {
129
            $this->javascript[$file]['cookie-consent'] = $options['cookie-consent'];
130
        }
131
    }
132
133
    /**
134
     * @param string $name
135
     * @param string|array $type Pass the type or an array of options
136
     * @return void
137
     */
138
    public function themedJavascript($name, $type = null)
139
    {
140
        $path = ThemeResourceLoader::inst()->findThemedJavascript($name, SSViewer::get_themes());
141
        if ($path) {
142
            $options = [];
143
            if ($type) {
144
                if (is_string($type)) {
145
                    $options['type'] = $type;
146
                } elseif (is_array($type)) {
0 ignored issues
show
introduced by
The condition is_array($type) is always true.
Loading history...
147
                    $options = $type;
148
                }
149
            }
150
            $this->javascript($path, $options);
151
        } else {
152
            throw new InvalidArgumentException(
153
                "The javascript file doesn't exist. Please check if the file $name.js exists in any "
154
                    . "context or search for themedJavascript references calling this file in your templates."
155
            );
156
        }
157
    }
158
159
    /**
160
     * Get all css files
161
     *
162
     * @return array
163
     */
164
    public function getCSS()
165
    {
166
        $css = array_diff_key($this->css, $this->blocked);
167
        // Theme and assets files should always come last to have a proper cascade
168
        $allCss = [];
169
        $themeCss = [];
170
        foreach ($css as $file => $arr) {
171
            if (strpos($file, 'themes') === 0 || strpos($file, '/assets') === 0) {
172
                $themeCss[$file] = $arr;
173
            } else {
174
                $allCss[$file] = $arr;
175
            }
176
        }
177
        return array_merge($allCss, $themeCss);
178
    }
179
180
    /**
181
     * Update the given HTML content with the appropriate include tags for the registered
182
     * requirements. Needs to receive a valid HTML/XHTML template in the $content parameter,
183
     * including a head and body tag.
184
     *
185
     * @param string $content HTML content that has already been parsed from the $templateFile through {@link SSViewer}
186
     * @return string HTML content augmented with the requirements tags
187
     */
188
    public function includeInHTML($content)
189
    {
190
        // Get our CSP nonce, it's always good to have even if we don't use it :-)
191
        $nonce = CspProvider::getCspNonce();
192
193
        // Skip if content isn't injectable, or there is nothing to inject
194
        $tagsAvailable = preg_match('#</head\b#', $content);
195
        $hasFiles = $this->css || $this->javascript || $this->customCSS || $this->customScript || $this->customHeadTags;
0 ignored issues
show
Bug Best Practice introduced by
The expression $this->javascript of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using ! empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
Bug Best Practice introduced by
The expression $this->css of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using ! empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
Bug Best Practice introduced by
The expression $this->customHeadTags of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using ! empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
Bug Best Practice introduced by
The expression $this->customScript of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using ! empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
Bug Best Practice introduced by
The expression $this->customCSS of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using ! empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
196
        if (!$tagsAvailable || !$hasFiles) {
197
            return $content;
198
        }
199
        $requirements = '';
200
        $jsRequirements = '';
201
202
        // Combine files - updates $this->javascript and $this->css
203
        $this->processCombinedFiles();
204
205
        // Script tags for js links
206
        foreach ($this->getJavascript() as $file => $attributes) {
207
            // Build html attributes
208
            $htmlAttributes = [
209
                'type' => isset($attributes['type']) ? $attributes['type'] : "application/javascript",
210
                'src' => $this->pathForFile($file),
211
                'nonce' => $nonce,
212
            ];
213
            if (!empty($attributes['async'])) {
214
                $htmlAttributes['async'] = 'async';
215
            }
216
            if (!empty($attributes['defer'])) {
217
                $htmlAttributes['defer'] = 'defer';
218
            }
219
            if (!empty($attributes['integrity'])) {
220
                $htmlAttributes['integrity'] = $attributes['integrity'];
221
            }
222
            if (!empty($attributes['crossorigin'])) {
223
                $htmlAttributes['crossorigin'] = $attributes['crossorigin'];
224
            }
225
            if (!empty($attributes['cookie-consent'])) {
226
                $htmlAttributes['cookie-consent'] = $attributes['cookie-consent'];
227
            }
228
            $jsRequirements .= HTML::createTag('script', $htmlAttributes);
229
            $jsRequirements .= "\n";
230
        }
231
232
        // Add all inline JavaScript *after* including external files they might rely on
233
        foreach ($this->getCustomScripts() as $scriptId => $script) {
234
            $type = self::config()->enable_js_modules ? 'module' : 'application/javascript';
235
            $attributes = [
236
                'type' => $type,
237
                'nonce' => $nonce,
238
            ];
239
            // For cookie-consent, since the Requirements API does not support passing variables
240
            // we rely on last part of uniqueness id
241
            if ($scriptId) {
242
                $parts = explode("_", $scriptId);
243
                $lastPart = array_pop($parts);
244
                if (in_array($lastPart, self::listCookieTypes())) {
245
                    $attributes['type'] = 'text/plain';
246
                    $attributes['cookie-consent'] = $lastPart;
247
                }
248
                if (in_array($lastPart, self::listScriptTypes())) {
249
                    $attributes['type'] = $lastPart;
250
                }
251
            }
252
253
            // Wrap script in a DOMContentLoaded
254
            // Make sure we don't add the eventListener twice (this will only work for simple scripts)
255
            // Make sure we don't wrap scripts concerned by security policies
256
            // Js modules are deferred by default, even if they are inlined, so not wrapping needed
257
            // @link https://stackoverflow.com/questions/41394983/how-to-defer-inline-javascript
258
            if (empty($attributes['cookie-consent']) && strpos($script, 'window.addEventListener') === false && !self::config()->enable_js_modules) {
259
                $script = "window.addEventListener('DOMContentLoaded', function() { $script });";
260
            }
261
262
            // Remove comments if any
263
            $script = preg_replace('/(?:(?:\/\*(?:[^*]|(?:\*+[^*\/]))*\*+\/)|(?:(?<!\:|\\\|\'|\")\/\/.*))/', '', $script);
264
265
            $jsRequirements .= HTML::createTag(
266
                'script',
267
                $attributes,
268
                "//<![CDATA[\n{$script}\n//]]>"
269
            );
270
            $jsRequirements .= "\n";
271
        }
272
273
        // Custom head tags (comes first)
274
        foreach ($this->getCustomHeadTags() as $customHeadTag) {
275
            $requirements .= "{$customHeadTag}\n";
276
        }
277
278
        // CSS file links
279
        foreach ($this->getCSS() as $file => $params) {
280
            $htmlAttributes = [
281
                'rel' => 'stylesheet',
282
                'type' => 'text/css',
283
                'href' => $this->pathForFile($file),
284
            ];
285
            if (!empty($params['media'])) {
286
                $htmlAttributes['media'] = $params['media'];
287
            }
288
            $requirements .= HTML::createTag('link', $htmlAttributes);
289
            $requirements .= "\n";
290
        }
291
292
        // Literal custom CSS content
293
        foreach ($this->getCustomCSS() as $css) {
294
            $requirements .= HTML::createTag('style', ['type' => 'text/css'], "\n{$css}\n");
295
            $requirements .= "\n";
296
        }
297
298
        // Inject CSS  into body
299
        $content = $this->insertTagsIntoHead($requirements, $content);
300
301
        // Inject scripts
302
        if ($this->getForceJSToBottom()) {
303
            $content = $this->insertScriptsAtBottom($jsRequirements, $content);
304
        } elseif ($this->getWriteJavascriptToBody()) {
305
            $content = $this->insertScriptsIntoBody($jsRequirements, $content);
306
        } else {
307
            $content = $this->insertTagsIntoHead($jsRequirements, $content);
308
        }
309
        return $content;
310
    }
311
}
312