Passed
Push — master ( c786bc...7731b4 )
by Thomas
02:22
created

DeferBackend   F

Complexity

Total Complexity 63

Size/Duplication

Total Lines 307
Duplicated Lines 0 %

Importance

Changes 14
Bugs 3 Features 1
Metric Value
wmc 63
eloc 142
c 14
b 3
f 1
dl 0
loc 307
rs 3.36

7 Methods

Rating   Name   Duplication   Size   Complexity  
A getCSS() 0 14 4
C javascript() 0 39 13
A replaceBackend() 0 20 6
F includeInHTML() 0 134 30
A getDeferBackend() 0 7 2
B themedJavascript() 0 20 7
A listCookieTypes() 0 3 1

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\View\SSViewer;
9
use SilverStripe\View\Requirements;
10
use SilverStripe\Core\Config\Configurable;
11
use SilverStripe\View\ThemeResourceLoader;
12
use SilverStripe\View\Requirements_Backend;
13
use SilverStripe\Core\Manifest\ModuleResourceLoader;
14
15
/**
16
 * A backend that defers everything by default
17
 *
18
 * Also insert custom head tags first because order may matter
19
 *
20
 * @link https://flaviocopes.com/javascript-async-defer/
21
 */
22
class DeferBackend extends Requirements_Backend
23
{
24
    use Configurable;
25
26
    /**
27
     * @config
28
     * @var boolean
29
     */
30
    private static $enable_js_modules = false;
31
32
    // It's better to write to the head with defer
33
    public $writeJavascriptToBody = false;
34
35
    /**
36
     * @return $this
37
     */
38
    public static function getDeferBackend()
39
    {
40
        $backend = Requirements::backend();
41
        if (!$backend instanceof self) {
42
            throw new Exception("Requirements backend is currently of class " . get_class($backend));
43
        }
44
        return $backend;
45
    }
46
47
    /**
48
     * @param Requirements_Backend $oldBackend defaults to current backend
49
     * @return $this
50
     */
51
    public static function replaceBackend(Requirements_Backend $oldBackend = null)
52
    {
53
        if ($oldBackend === null) {
54
            $oldBackend = Requirements::backend();
55
        }
56
        $deferBackend = new static;
57
        foreach ($oldBackend->getCSS() as $file => $opts) {
58
            $deferBackend->css($file, null, $opts);
59
        }
60
        foreach ($oldBackend->getJavascript() as $file => $opts) {
61
            $deferBackend->javascript($file, $opts);
62
        }
63
        foreach ($oldBackend->getCustomCSS() as $id => $script) {
64
            $deferBackend->customCSS($script, $id);
65
        }
66
        foreach ($oldBackend->getCustomScripts() as $id => $script) {
67
            $deferBackend->customScript($script, $id);
68
        }
69
        Requirements::set_backend($deferBackend);
70
        return $deferBackend;
71
    }
72
73
    /**
74
     * @return array
75
     */
76
    public static function listCookieTypes()
77
    {
78
        return ['strictly-necessary', 'functionality', 'tracking', 'targeting'];
79
    }
80
81
    /**
82
     * Register the given JavaScript file as required.
83
     *
84
     * @param string $file Either relative to docroot or in the form "vendor/package:resource"
85
     * @param array $options List of options. Available options include:
86
     * - 'provides' : List of scripts files included in this file
87
     * - 'async' : Boolean value to set async attribute to script tag
88
     * - 'defer' : Boolean value to set defer attribute to script tag (true by default)
89
     * - 'type' : Override script type= value.
90
     * - 'integrity' : SubResource Integrity hash
91
     * - 'crossorigin' : Cross-origin policy for the resource
92
     * - 'cookie-consent' : Type of cookie for conditionnal loading : strictly-necessary,functionality,tracking,targeting
93
     * - 'nomodule' : Boolean value to set nomodule attribute to script tag
94
     */
95
    public function javascript($file, $options = [])
96
    {
97
        if (!is_array($options)) {
0 ignored issues
show
introduced by
The condition is_array($options) is always true.
Loading history...
98
            $options = [];
99
        }
100
        if (self::config()->enable_js_modules) {
101
            if (empty($options['type']) && self::config()->enable_js_modules) {
102
                $options['type'] = 'module';
103
            }
104
            // Modules are deferred by default
105
            if (isset($options['defer']) && $options['type'] == "module") {
106
                unset($options['defer']);
107
            }
108
        } else {
109
            // We want to defer by default, but we can disable it if needed
110
            if (!isset($options['defer'])) {
111
                $options['defer'] = true;
112
            }
113
        }
114
        if (isset($options['cookie-consent'])) {
115
            if (!in_array($options['cookie-consent'], self::listCookieTypes())) {
116
                throw new InvalidArgumentException("The cookie-consent value is invalid, it must be one of: strictly-necessary,functionality,tracking,targeting");
117
            }
118
            // switch to text plain for conditional loading
119
            $options['type'] = 'text/plain';
120
        }
121
        if (isset($options['nomodule'])) {
122
            // Force type regardless of global setting
123
            $options['type'] = 'application/javascript';
124
        }
125
        parent::javascript($file, $options);
126
127
        $resolvedFile = ModuleResourceLoader::singleton()->resolvePath($file);
128
        // Parent call doesn't store all attributes, so we adjust ourselves
129
        if (isset($options['cookie-consent'])) {
130
            $this->javascript[$resolvedFile]['cookie-consent'] = $options['cookie-consent'];
131
        }
132
        if (isset($options['nomodule'])) {
133
            $this->javascript[$resolvedFile]['nomodule'] = $options['nomodule'];
134
        }
135
    }
136
137
    /**
138
     * @param string $name
139
     * @param string|array $type Pass the type or an array of options
140
     * @return void
141
     */
142
    public function themedJavascript($name, $type = null)
143
    {
144
        if ($type !== null && (!is_string($type) && !is_array($type))) {
0 ignored issues
show
introduced by
The condition is_array($type) is always true.
Loading history...
145
            throw new InvalidArgumentException("Type must be a string or an array");
146
        }
147
        $path = ThemeResourceLoader::inst()->findThemedJavascript($name, SSViewer::get_themes());
148
        if ($path) {
149
            $options = [];
150
            if ($type) {
151
                if (is_string($type)) {
152
                    $options['type'] = $type;
153
                } else {
154
                    $options = $type;
155
                }
156
            }
157
            $this->javascript($path, $options);
158
        } else {
159
            throw new InvalidArgumentException(
160
                "The javascript file doesn't exist. Please check if the file $name.js exists in any "
161
                    . "context or search for themedJavascript references calling this file in your templates."
162
            );
163
        }
164
    }
165
166
    /**
167
     * Get all css files
168
     *
169
     * @return array
170
     */
171
    public function getCSS()
172
    {
173
        $css = array_diff_key($this->css, $this->blocked);
174
        // Theme and assets files should always come last to have a proper cascade
175
        $allCss = [];
176
        $themeCss = [];
177
        foreach ($css as $file => $arr) {
178
            if (strpos($file, 'themes') === 0 || strpos($file, '/assets') === 0) {
179
                $themeCss[$file] = $arr;
180
            } else {
181
                $allCss[$file] = $arr;
182
            }
183
        }
184
        return array_merge($allCss, $themeCss);
185
    }
186
187
    /**
188
     * Update the given HTML content with the appropriate include tags for the registered
189
     * requirements. Needs to receive a valid HTML/XHTML template in the $content parameter,
190
     * including a head and body tag.
191
     *
192
     * @param string $content HTML content that has already been parsed from the $templateFile through {@link SSViewer}
193
     * @return string HTML content augmented with the requirements tags
194
     */
195
    public function includeInHTML($content)
196
    {
197
        // Get our CSP nonce, it's always good to have even if we don't use it :-)
198
        $nonce = CspProvider::getCspNonce();
199
200
        // Skip if content isn't injectable, or there is nothing to inject
201
        $tagsAvailable = preg_match('#</head\b#', $content);
202
        $hasFiles = !empty($this->css)
203
            || !empty($this->javascript)
204
            || !empty($this->customCSS)
205
            || !empty($this->customScript)
206
            || !empty($this->customHeadTags);
207
208
        if (!$tagsAvailable || !$hasFiles) {
209
            return $content;
210
        }
211
        $requirements = '';
212
        $jsRequirements = '';
213
214
        // Combine files - updates $this->javascript and $this->css
215
        $this->processCombinedFiles();
216
217
        // Script tags for js links
218
        foreach ($this->getJavascript() as $file => $attributes) {
219
            // Build html attributes
220
            $htmlAttributes = [
221
                'type' => isset($attributes['type']) ? $attributes['type'] : "application/javascript",
222
                'src' => $this->pathForFile($file),
223
                'nonce' => $nonce,
224
            ];
225
            if (!empty($attributes['async'])) {
226
                $htmlAttributes['async'] = 'async';
227
            }
228
            // defer is not allowed for module, ignore it as it does the same anyway
229
            if (!empty($attributes['defer']) && $htmlAttributes['type'] !== 'module') {
230
                $htmlAttributes['defer'] = 'defer';
231
            }
232
            if (!empty($attributes['integrity'])) {
233
                $htmlAttributes['integrity'] = $attributes['integrity'];
234
            }
235
            if (!empty($attributes['crossorigin'])) {
236
                $htmlAttributes['crossorigin'] = $attributes['crossorigin'];
237
            }
238
            if (!empty($attributes['cookie-consent'])) {
239
                $htmlAttributes['cookie-consent'] = $attributes['cookie-consent'];
240
            }
241
            if (!empty($attributes['nomodule'])) {
242
                $htmlAttributes['nomodule'] = 'nomodule';
243
            }
244
            $jsRequirements .= str_replace(' />', '>', HTML::createTag('script', $htmlAttributes));
245
            $jsRequirements .= "\n";
246
        }
247
248
        // Add all inline JavaScript *after* including external files they might rely on
249
        foreach ($this->getCustomScripts() as $scriptId => $script) {
250
            $type = self::config()->enable_js_modules ? 'module' : 'application/javascript';
251
            $attributes = [
252
                'type' => $type,
253
                'nonce' => $nonce,
254
            ];
255
256
            // since the Requirements API does not support passing variables, we use naming conventions
257
            if ($scriptId) {
258
                // Check for jsmodule in the name, since we have no other way to pass arguments
259
                if (strpos($scriptId, "jsmodule") !== false) {
260
                    $attributes['type'] = 'module';
261
                }
262
263
                // For cookie-consent, we rely on last part of uniquness id
264
                $parts = explode("-", $scriptId);
265
                $lastPart = array_pop($parts);
266
                if (in_array($lastPart, self::listCookieTypes())) {
267
                    $attributes['type'] = 'text/plain';
268
                    $attributes['cookie-consent'] = $lastPart;
269
                }
270
            }
271
272
            // Wrap script in a DOMContentLoaded
273
            // Make sure we don't add the eventListener twice (this will only work for simple scripts)
274
            // Make sure we don't wrap scripts concerned by security policies
275
            // Js modules are deferred by default, even if they are inlined, so not wrapping needed
276
            // @link https://stackoverflow.com/questions/41394983/how-to-defer-inline-javascript
277
            if (empty($attributes['cookie-consent']) && strpos($script, 'window.addEventListener') === false && $attributes['type'] !== 'module') {
278
                $script = "window.addEventListener('DOMContentLoaded', function() { $script });";
279
            }
280
281
            // Remove comments if any
282
            $script = preg_replace('/(?:(?:\/\*(?:[^*]|(?:\*+[^*\/]))*\*+\/)|(?:(?<!\:|\\\|\'|\")\/\/.*))/', '', $script);
283
284
            $jsRequirements .= HTML::createTag(
285
                'script',
286
                $attributes,
287
                "//<![CDATA[\n{$script}\n//]]>"
288
            );
289
            $jsRequirements .= "\n";
290
        }
291
292
        // Custom head tags (comes first)
293
        foreach ($this->getCustomHeadTags() as $customHeadTag) {
294
            $requirements .= "{$customHeadTag}\n";
295
        }
296
297
        // CSS file links
298
        foreach ($this->getCSS() as $file => $params) {
299
            $htmlAttributes = [
300
                'rel' => 'stylesheet',
301
                'type' => 'text/css',
302
                'href' => $this->pathForFile($file),
303
            ];
304
            if (!empty($params['media'])) {
305
                $htmlAttributes['media'] = $params['media'];
306
            }
307
            $requirements .= str_replace(' />', '>', HTML::createTag('link', $htmlAttributes));
308
            $requirements .= "\n";
309
        }
310
311
        // Literal custom CSS content
312
        foreach ($this->getCustomCSS() as $css) {
313
            $requirements .= HTML::createTag('style', ['type' => 'text/css'], "\n{$css}\n");
314
            $requirements .= "\n";
315
        }
316
317
        // Inject CSS  into body
318
        $content = $this->insertTagsIntoHead($requirements, $content);
319
320
        // Inject scripts
321
        if ($this->getForceJSToBottom()) {
322
            $content = $this->insertScriptsAtBottom($jsRequirements, $content);
323
        } elseif ($this->getWriteJavascriptToBody()) {
324
            $content = $this->insertScriptsIntoBody($jsRequirements, $content);
325
        } else {
326
            $content = $this->insertTagsIntoHead($jsRequirements, $content);
327
        }
328
        return $content;
329
    }
330
}
331