DeferBackend::getDeferBackend()   A
last analyzed

Complexity

Conditions 2
Paths 2

Size

Total Lines 7
Code Lines 4

Duplication

Lines 0
Ratio 0 %

Importance

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