DeferBackend::themedJavascript()   B
last analyzed

Complexity

Conditions 7
Paths 5

Size

Total Lines 20
Code Lines 15

Duplication

Lines 0
Ratio 0 %

Importance

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