Passed
Push — master ( 74ff06...9d557b )
by Thomas
01:46
created

DeferBackend::listCookieTypes()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
eloc 1
c 1
b 0
f 0
dl 0
loc 3
rs 10
cc 1
nc 1
nop 0
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\View\ThemeResourceLoader;
11
use SilverStripe\View\Requirements_Backend;
12
13
/**
14
 * A backend that defers everything by default
15
 *
16
 * Also insert custom head tags first because order may matter
17
 *
18
 * @link https://flaviocopes.com/javascript-async-defer/
19
 */
20
class DeferBackend extends Requirements_Backend
21
{
22
    // It's better to write to the head with defer
23
    public $writeJavascriptToBody = false;
24
25
    /**
26
     * @return $this
27
     */
28
    public static function getDeferBackend()
29
    {
30
        $backend = Requirements::backend();
31
        if (!$backend instanceof self) {
32
            throw new Exception("Requirements backend is currently of class " . get_class($backend));
33
        }
34
        return $backend;
35
    }
36
37
    /**
38
     * @param Requirements_Backend $oldBackend defaults to current backend
39
     * @return $this
40
     */
41
    public static function replaceBackend(Requirements_Backend $oldBackend = null)
42
    {
43
        if ($oldBackend === null) {
44
            $oldBackend = Requirements::backend();
45
        }
46
        $deferBackend = new static;
47
        foreach ($oldBackend->getCSS() as $file => $opts) {
48
            $deferBackend->css($file, null, $opts);
49
        }
50
        foreach ($oldBackend->getJavascript() as $file => $opts) {
51
            $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

51
            $deferBackend->/** @scrutinizer ignore-call */ 
52
                           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...
52
        }
53
        foreach ($oldBackend->getCustomCSS() as $id => $script) {
54
            $deferBackend->customCSS($script, $id);
55
        }
56
        foreach ($oldBackend->getCustomScripts() as $id => $script) {
57
            $deferBackend->customScript($script, $id);
58
        }
59
        Requirements::set_backend($deferBackend);
60
        return $deferBackend;
61
    }
62
63
    /**
64
     * @return array
65
     */
66
    public static function listCookieTypes()
67
    {
68
        return ['strictly-necessary', 'functionality', 'tracking', 'targeting'];
69
    }
70
71
    /**
72
     * Register the given JavaScript file as required.
73
     *
74
     * @param string $file Either relative to docroot or in the form "vendor/package:resource"
75
     * @param array $options List of options. Available options include:
76
     * - 'provides' : List of scripts files included in this file
77
     * - 'async' : Boolean value to set async attribute to script tag
78
     * - 'defer' : Boolean value to set defer attribute to script tag (true by default)
79
     * - 'type' : Override script type= value.
80
     * - 'integrity' : SubResource Integrity hash
81
     * - 'crossorigin' : Cross-origin policy for the resource
82
     * - 'cookie-consent' : Type of cookie for conditionnal loading : strictly-necessary,functionality,tracking,targeting
83
     */
84
    public function javascript($file, $options = array())
85
    {
86
        // We want to defer by default, but we can disable it if needed
87
        if (!isset($options['defer'])) {
88
            $options['defer'] = true;
89
        }
90
        if (isset($options['cookie-consent'])) {
91
            if (!in_array($options['cookie-consent'], self::listCookieTypes())) {
92
                throw new InvalidArgumentException("The cookie-consent value is invalid, it must be one of: strictly-necessary,functionality,tracking,targeting");
93
            }
94
            // switch to text plain for conditional loading
95
            $options['type'] = 'text/plain';
96
        }
97
        parent::javascript($file, $options);
98
        if (isset($options['cookie-consent'])) {
99
            $this->javascript[$file]['cookie-consent'] = $options['cookie-consent'];
100
        }
101
    }
102
103
    /**
104
     * @param string $name
105
     * @param string|array $type Pass the type or an array of options
106
     * @return void
107
     */
108
    public function themedJavascript($name, $type = null)
109
    {
110
        $path = ThemeResourceLoader::inst()->findThemedJavascript($name, SSViewer::get_themes());
111
        if ($path) {
112
            $opts = [];
113
            if ($type) {
114
                if (is_string($type)) {
115
                    $opts['type'] = $type;
116
                } elseif (is_array($type)) {
0 ignored issues
show
introduced by
The condition is_array($type) is always true.
Loading history...
117
                    $opts = $type;
118
                }
119
            }
120
            $this->javascript($path, $opts);
121
        } else {
122
            throw new InvalidArgumentException(
123
                "The javascript file doesn't exist. Please check if the file $name.js exists in any "
124
                    . "context or search for themedJavascript references calling this file in your templates."
125
            );
126
        }
127
    }
128
129
    /**
130
     * Register the given JavaScript code into the list of requirements
131
     *
132
     * @param string $script The script content as a string (without enclosing `<script>` tag)
133
     * @param string $uniquenessID A unique ID that ensures a piece of code is only added once. Append -cookie-type for consent support
134
     */
135
    public function customScript($script, $uniquenessID = null)
136
    {
137
        // Wrap script in a DOMContentLoaded
138
        // Make sure we don't add the eventListener twice (this will only work for simple scripts)
139
        // @link https://stackoverflow.com/questions/41394983/how-to-defer-inline-javascript
140
        if (strpos($script, 'window.addEventListener') === false) {
141
            $script = "window.addEventListener('DOMContentLoaded', function() { $script });";
142
        }
143
144
        // Remove comments if any
145
        $script = preg_replace('/(?:(?:\/\*(?:[^*]|(?:\*+[^*\/]))*\*+\/)|(?:(?<!\:|\\\|\'|\")\/\/.*))/', '', $script);
146
147
        return parent::customScript($script, $uniquenessID);
0 ignored issues
show
Bug introduced by
Are you sure the usage of parent::customScript($script, $uniquenessID) targeting SilverStripe\View\Requir...Backend::customScript() seems to always return null.

This check looks for function or method calls that always return null and whose return value is used.

class A
{
    function getObject()
    {
        return null;
    }

}

$a = new A();
if ($a->getObject()) {

The method getObject() can return nothing but null, so it makes no sense to use the return value.

The reason is most likely that a function or method is imcomplete or has been reduced for debug purposes.

Loading history...
148
    }
149
150
    /**
151
     * Get all css files
152
     *
153
     * @return array
154
     */
155
    public function getCSS()
156
    {
157
        $css = array_diff_key($this->css, $this->blocked);
158
        // Theme and assets files should always come last to have a proper cascade
159
        $allCss = [];
160
        $themeCss = [];
161
        foreach ($css as $file => $arr) {
162
            if (strpos($file, 'themes') === 0 || strpos($file, '/assets') === 0) {
163
                $themeCss[$file] = $arr;
164
            } else {
165
                $allCss[$file] = $arr;
166
            }
167
        }
168
        return array_merge($allCss, $themeCss);
169
    }
170
171
    /**
172
     * Update the given HTML content with the appropriate include tags for the registered
173
     * requirements. Needs to receive a valid HTML/XHTML template in the $content parameter,
174
     * including a head and body tag.
175
     *
176
     * @param string $content HTML content that has already been parsed from the $templateFile through {@link SSViewer}
177
     * @return string HTML content augmented with the requirements tags
178
     */
179
    public function includeInHTML($content)
180
    {
181
        // Get our CSP nonce, it's always good to have even if we don't use it :-)
182
        $nonce = CspProvider::getCspNonce();
183
184
        // Skip if content isn't injectable, or there is nothing to inject
185
        $tagsAvailable = preg_match('#</head\b#', $content);
186
        $hasFiles = $this->css || $this->javascript || $this->customCSS || $this->customScript || $this->customHeadTags;
0 ignored issues
show
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...
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->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->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->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...
187
        if (!$tagsAvailable || !$hasFiles) {
188
            return $content;
189
        }
190
        $requirements = '';
191
        $jsRequirements = '';
192
193
        // Combine files - updates $this->javascript and $this->css
194
        $this->processCombinedFiles();
195
196
        // Script tags for js links
197
        foreach ($this->getJavascript() as $file => $attributes) {
198
            // Build html attributes
199
            $htmlAttributes = [
200
                'type' => isset($attributes['type']) ? $attributes['type'] : "application/javascript",
201
                'src' => $this->pathForFile($file),
202
                'nonce' => $nonce,
203
            ];
204
            if (!empty($attributes['async'])) {
205
                $htmlAttributes['async'] = 'async';
206
            }
207
            if (!empty($attributes['defer'])) {
208
                $htmlAttributes['defer'] = 'defer';
209
            }
210
            if (!empty($attributes['integrity'])) {
211
                $htmlAttributes['integrity'] = $attributes['integrity'];
212
            }
213
            if (!empty($attributes['crossorigin'])) {
214
                $htmlAttributes['crossorigin'] = $attributes['crossorigin'];
215
            }
216
            if (!empty($attributes['cookie-consent'])) {
217
                $htmlAttributes['cookie-consent'] = $attributes['cookie-consent'];
218
            }
219
            $jsRequirements .= HTML::createTag('script', $htmlAttributes);
220
            $jsRequirements .= "\n";
221
        }
222
223
        // Add all inline JavaScript *after* including external files they might rely on
224
        foreach ($this->getCustomScripts() as $scriptId => $script) {
225
            if (is_numeric($scriptId)) {
226
                $script = $scriptId;
227
                $scriptId = null;
228
            }
229
            $attributes = [
230
                'type' => 'application/javascript',
231
                'nonce' => $nonce,
232
            ];
233
            // For cookie-consent, since the Requirements API does not support passing variables
234
            // we rely on last part of uniquness id
235
            if ($scriptId) {
236
                $parts = explode("-", $scriptId);
237
                $lastPart = array_pop($parts);
238
                if (in_array($lastPart, self::listCookieTypes())) {
239
                    $attributes['type'] = 'text/plain';
240
                    $attributes['cookie-consent'] = $lastPart;
241
                }
242
            }
243
            $jsRequirements .= HTML::createTag(
244
                'script',
245
                $attributes,
246
                "//<![CDATA[\n{$script}\n//]]>"
247
            );
248
            $jsRequirements .= "\n";
249
        }
250
251
        // Custom head tags (comes first)
252
        foreach ($this->getCustomHeadTags() as $customHeadTag) {
253
            $requirements .= "{$customHeadTag}\n";
254
        }
255
256
        // CSS file links
257
        foreach ($this->getCSS() as $file => $params) {
258
            $htmlAttributes = [
259
                'rel' => 'stylesheet',
260
                'type' => 'text/css',
261
                'href' => $this->pathForFile($file),
262
            ];
263
            if (!empty($params['media'])) {
264
                $htmlAttributes['media'] = $params['media'];
265
            }
266
            $requirements .= HTML::createTag('link', $htmlAttributes);
267
            $requirements .= "\n";
268
        }
269
270
        // Literal custom CSS content
271
        foreach ($this->getCustomCSS() as $css) {
272
            $requirements .= HTML::createTag('style', ['type' => 'text/css'], "\n{$css}\n");
273
            $requirements .= "\n";
274
        }
275
276
        // Inject CSS  into body
277
        $content = $this->insertTagsIntoHead($requirements, $content);
278
279
        // Inject scripts
280
        if ($this->getForceJSToBottom()) {
281
            $content = $this->insertScriptsAtBottom($jsRequirements, $content);
282
        } elseif ($this->getWriteJavascriptToBody()) {
283
            $content = $this->insertScriptsIntoBody($jsRequirements, $content);
284
        } else {
285
            $content = $this->insertTagsIntoHead($jsRequirements, $content);
286
        }
287
        return $content;
288
    }
289
}
290