Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
83.06% covered (warning)
83.06%
103 / 124
70.00% covered (warning)
70.00%
14 / 20
CRAP
0.00% covered (danger)
0.00%
0 / 1
ResourceContainer
83.06% covered (warning)
83.06%
103 / 124
70.00% covered (warning)
70.00%
14 / 20
90.46
0.00% covered (danger)
0.00%
0 / 1
 addCss
87.50% covered (warning)
87.50%
7 / 8
0.00% covered (danger)
0.00%
0 / 1
7.10
 addJs
87.50% covered (warning)
87.50%
7 / 8
0.00% covered (danger)
0.00%
0 / 1
7.10
 addCssEntry
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 addCssStringEntry
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
5
 addCssArrayEntry
76.47% covered (warning)
76.47%
13 / 17
0.00% covered (danger)
0.00%
0 / 1
7.64
 addJsEntry
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 addJsStringEntry
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
2
 addJsArrayEntry
68.42% covered (warning)
68.42%
13 / 19
0.00% covered (danger)
0.00%
0 / 1
10.02
 removeEntry
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 insertEntry
76.47% covered (warning)
76.47%
13 / 17
0.00% covered (danger)
0.00%
0 / 1
11.30
 getCss
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getJs
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
2
 parseSetting
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
4
 setEncoding
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getEncoding
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setFavicon
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getFavicon
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setGenerator
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getGenerator
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 removeCSS
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
6
1<?php
2
3/**
4 * VuFind Theme Public Resource Handler (for CSS, JS, etc.)
5 *
6 * PHP version 8
7 *
8 * Copyright (C) Villanova University 2010.
9 *
10 * This program is free software; you can redistribute it and/or modify
11 * it under the terms of the GNU General Public License version 2,
12 * as published by the Free Software Foundation.
13 *
14 * This program is distributed in the hope that it will be useful,
15 * but WITHOUT ANY WARRANTY; without even the implied warranty of
16 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
17 * GNU General Public License for more details.
18 *
19 * You should have received a copy of the GNU General Public License
20 * along with this program; if not, write to the Free Software
21 * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
22 *
23 * @category VuFind
24 * @package  Theme
25 * @author   Demian Katz <demian.katz@villanova.edu>
26 * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
27 * @link     https://vufind.org Main Site
28 */
29
30namespace VuFindTheme;
31
32use function count;
33use function is_array;
34
35/**
36 * VuFind Theme Public Resource Handler (for CSS, JS, etc.)
37 *
38 * @category VuFind
39 * @package  Theme
40 * @author   Demian Katz <demian.katz@villanova.edu>
41 * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
42 * @link     https://vufind.org Main Site
43 */
44class ResourceContainer
45{
46    use \VuFind\Log\VarDumperTrait;
47
48    /**
49     * CSS files
50     *
51     * @var array
52     */
53    protected $css = [];
54
55    /**
56     * Javascript files
57     *
58     * @var array
59     */
60    protected $js = [];
61
62    /**
63     * Favicon
64     *
65     * @var string|array|null
66     */
67    protected $favicon = null;
68
69    /**
70     * Encoding type
71     *
72     * @var string
73     */
74    protected $encoding = 'UTF-8';
75
76    /**
77     * Generator value for <meta> tag
78     *
79     * @var string
80     */
81    protected $generator = '';
82
83    /**
84     * Add a CSS file.
85     *
86     * @param array|string $css CSS file (or array of CSS files) to add (possibly
87     * with extra settings from theme config appended to each filename string).
88     *
89     * @return void
90     */
91    public function addCss($css)
92    {
93        if ((!is_array($css) && !is_a($css, 'Traversable')) || isset($css['file'])) {
94            $this->addCssEntry($css);
95        } elseif (isset($css[0])) {
96            foreach ($css as $current) {
97                $this->addCssEntry($current);
98            }
99        } elseif ($css === []) {
100            return;
101        } else {
102            throw new \Exception('Invalid CSS entry format: ' . $this->varDump($css));
103        }
104    }
105
106    /**
107     * Add a Javascript file.
108     *
109     * @param array|string $js Javascript file (or array of files) to add (possibly
110     * with extra settings from theme config appended to each filename string).
111     *
112     * @return void
113     */
114    public function addJs($js)
115    {
116        if ((!is_array($js) && !is_a($js, 'Traversable')) || isset($js['file'])) {
117            $this->addJsEntry($js);
118        } elseif (isset($js[0])) {
119            foreach ($js as $current) {
120                $this->addJsEntry($current);
121            }
122        } elseif ($js === []) {
123            return;
124        } else {
125            throw new \Exception('Invalid JS entry format: ' . $this->varDump($js));
126        }
127    }
128
129    /**
130     * Helper function for adding a CSS file.
131     *
132     * @param string|array $cssEntry Entry to add, either as string with path
133     * or array with additional properties.
134     *
135     * @return void
136     */
137    protected function addCssEntry($cssEntry)
138    {
139        if (!is_array($cssEntry)) {
140            $this->addCssStringEntry($cssEntry);
141        } else {
142            $this->addCssArrayEntry($cssEntry);
143        }
144    }
145
146    /**
147     * Helper function for adding a CSS file which is described as string.
148     *
149     * @param string $cssEntry Entry to add as string.
150     *
151     * @return void
152     */
153    protected function addCssStringEntry($cssEntry)
154    {
155        $parts = $this->parseSetting($cssEntry);
156        // Special case for media with parentheses
157        // ie. (min-width: 768px)
158        if (count($parts) > 1 && str_starts_with($parts[1], '(')) {
159            $parts[1] .= ':' . $parts[2];
160            array_splice($parts, 2, 1);
161        }
162        $cssArray = [
163            'file' => trim($parts[0]),
164        ];
165        if (isset($parts[1])) {
166            $cssArray['media'] = trim($parts[1]);
167        }
168        if (isset($parts[2])) {
169            $cssArray['conditional'] = trim($parts[2]);
170        }
171        $this->addCssArrayEntry($cssArray);
172    }
173
174    /**
175     * Helper function for adding a CSS file which is described as array.
176     *
177     * @param array $cssEntry Entry to add as array.
178     *
179     * @return void
180     */
181    protected function addCssArrayEntry($cssEntry)
182    {
183        if (isset($cssEntry['priority']) && isset($cssEntry['load_after'])) {
184            throw new \Exception(
185                'Using "priority" as well as "load_after" in the same entry '
186                . 'is not supported: "' . $cssEntry['file'] . '"'
187            );
188        }
189
190        // If we are disabling the dependency, remove it now.
191        if ($cssEntry['disabled'] ?? false) {
192            $this->removeEntry($cssEntry, $this->css);
193            return;
194        }
195
196        foreach ($this->css as $existingEntry) {
197            if ($existingEntry['file'] == $cssEntry['file']) {
198                // If we have the same settings as before, just skip this entry.
199                if ($existingEntry == $cssEntry) {
200                    return;
201                }
202
203                throw new \Exception(
204                    'Overriding an existing dependency is not supported: '
205                    . '"' . $cssEntry['file'] . '"'
206                );
207            }
208        }
209
210        $this->insertEntry($cssEntry, $this->css);
211    }
212
213    /**
214     * Helper function for adding a Javascript file.
215     *
216     * @param string|array $jsEntry Entry to add, either as string with path
217     * or array with additional properties.
218     *
219     * @return void
220     */
221    protected function addJsEntry($jsEntry)
222    {
223        if (!is_array($jsEntry)) {
224            $this->addJsStringEntry($jsEntry);
225        } else {
226            $this->addJsArrayEntry($jsEntry);
227        }
228    }
229
230    /**
231     * Helper function for adding a Javascript file which is described as string.
232     *
233     * @param string $jsEntry Entry to add as string.
234     *
235     * @return void
236     */
237    protected function addJsStringEntry($jsEntry)
238    {
239        $parts = $this->parseSetting($jsEntry);
240        if (count($parts) == 1) {
241            $jsEntry = ['file' => $jsEntry];
242        } else {
243            $jsEntry = [
244                'file' => $parts[0],
245                'attributes' => ['conditional' => trim($parts[1])],
246            ];
247        }
248        $this->addJsArrayEntry($jsEntry);
249    }
250
251    /**
252     * Helper function for adding a Javascript file which is described as array.
253     *
254     * @param array $jsEntry Entry to add as array.
255     *
256     * @return void
257     */
258    protected function addJsArrayEntry($jsEntry)
259    {
260        if (!isset($jsEntry['position'])) {
261            $jsEntry['position'] = 'header';
262        }
263
264        if (isset($jsEntry['priority']) && isset($jsEntry['load_after'])) {
265            throw new \Exception(
266                'Using "priority" as well as "load_after" in the same entry '
267                . 'is not supported: "' . $jsEntry['file'] . '"'
268            );
269        }
270
271        // If we are disabling the dependency, remove it now.
272        if ($jsEntry['disabled'] ?? false) {
273            $this->removeEntry($jsEntry, $this->js);
274            return;
275        }
276
277        foreach ($this->js as $existingEntry) {
278            if ($existingEntry['file'] == $jsEntry['file']) {
279                // If we have the same settings as before, just skip this entry.
280                if ($existingEntry == $jsEntry) {
281                    return;
282                }
283
284                throw new \Exception(
285                    'Overriding an existing dependency is not supported: '
286                    . '"' . $jsEntry['file'] . '"'
287                );
288            }
289        }
290
291        $this->insertEntry($jsEntry, $this->js);
292    }
293
294    /**
295     * Helper function to remove an entry from an array based on filename.
296     *
297     * @param array $entry The entry to remove.
298     * @param array $array The array from which the entry shall be removed.
299     *
300     * @return void
301     */
302    protected function removeEntry($entry, &$array)
303    {
304        foreach (array_keys($array) as $i) {
305            if (($array[$i]['file'] ?? '') === ($entry['file'] ?? null)) {
306                unset($array[$i]);
307                return;
308            }
309        }
310    }
311
312    /**
313     * Helper function to insert an entry to an array,
314     * also considering priority and dependency, if existing.
315     *
316     * @param array $entry The entry to insert.
317     * @param array $array The array into which the entry shall be inserted.
318     *
319     * @return void
320     */
321    protected function insertEntry($entry, &$array)
322    {
323        if (isset($entry['priority']) || isset($entry['load_after'])) {
324            foreach (array_keys($array) as $i) {
325                if (isset($entry['priority'])) {
326                    $currentPriority = $array[$i]['priority'] ?? null;
327                    if (
328                        !isset($currentPriority)
329                        || $currentPriority > $entry['priority']
330                    ) {
331                        array_splice($array, $i, 0, [$entry]);
332                        return;
333                    }
334                } elseif (isset($entry['load_after'])) {
335                    if ($entry['load_after'] === $array[$i]['file']) {
336                        array_splice($array, $i + 1, 0, [$entry]);
337                        return;
338                    }
339                }
340            }
341
342            if (isset($entry['load_after'])) {
343                throw new \Exception(
344                    'Dependency not found: ' . $entry['load_after']
345                );
346            }
347        }
348
349        // Insert at end if either no priority/dependency is given
350        // or no other element has been found
351        $array[] = $entry;
352    }
353
354    /**
355     * Get CSS files.
356     *
357     * @return array
358     */
359    public function getCss()
360    {
361        return $this->css;
362    }
363
364    /**
365     * Get Javascript files.
366     *
367     * @param string $position Position where the files should be inserted
368     * (allowed values are 'header' or 'footer').
369     *
370     * @return array
371     */
372    public function getJs(string $position = null)
373    {
374        if (!isset($position)) {
375            return $this->js;
376        } else {
377            return array_filter(
378                $this->js,
379                function ($jsFile) use ($position) {
380                    return $jsFile['position'] == $position;
381                }
382            );
383        }
384    }
385
386    /**
387     * Given a colon-delimited configuration string, break it apart, making sure
388     * that URLs in the first position are not inappropriately split.
389     *
390     * @param string $current Setting to parse
391     *
392     * @return array
393     */
394    public function parseSetting($current)
395    {
396        // TODO: replace this method with a deprecation warning when all configs
397        // have been converted to arrays
398        $parts = explode(':', $current);
399        // Special case: don't explode URLs:
400        if (
401            ($parts[0] === 'http' || $parts[0] === 'https')
402            && str_starts_with($parts[1], '//')
403        ) {
404            $protocol = array_shift($parts);
405            $parts[0] = $protocol . ':' . $parts[0];
406        }
407        return $parts;
408    }
409
410    /**
411     * Set the encoding.
412     *
413     * @param string $e New encoding
414     *
415     * @return void
416     */
417    public function setEncoding($e)
418    {
419        $this->encoding = $e;
420    }
421
422    /**
423     * Get the encoding.
424     *
425     * @return void
426     */
427    public function getEncoding()
428    {
429        return $this->encoding;
430    }
431
432    /**
433     * Set the favicon.
434     *
435     * @param string|array $favicon New favicon path.
436     *
437     * @return void
438     */
439    public function setFavicon($favicon)
440    {
441        $this->favicon = $favicon;
442    }
443
444    /**
445     * Get the favicon (null for none).
446     *
447     * @return string|array|null
448     */
449    public function getFavicon()
450    {
451        return $this->favicon;
452    }
453
454    /**
455     * Set the generator.
456     *
457     * @param string $generator New generator.
458     *
459     * @return void
460     */
461    public function setGenerator($generator)
462    {
463        $this->generator = $generator;
464    }
465
466    /**
467     * Get the generator.
468     *
469     * @return string
470     */
471    public function getGenerator()
472    {
473        return $this->generator;
474    }
475
476    /**
477     * Remove a CSS file if it matches another file's name
478     *
479     * @param string $file Filename to remove
480     *
481     * @return void
482     */
483    protected function removeCSS($file)
484    {
485        [$name, ] = explode('.', $file);
486        $name .= '.css';
487        $index = array_search($name, $this->css);
488        if (false !== $index) {
489            unset($this->css[$index]);
490        }
491    }
492}