Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
0.00% |
0 / 152 |
|
0.00% |
0 / 12 |
CRAP | |
0.00% |
0 / 1 |
ConcatTrait | |
0.00% |
0 / 152 |
|
0.00% |
0 / 12 |
2550 | |
0.00% |
0 / 1 |
isExcludedFromConcat | n/a |
0 / 0 |
n/a |
0 / 0 |
0 | |||||
getFileType | n/a |
0 / 0 |
n/a |
0 / 0 |
0 | |||||
getResourceFilePath | n/a |
0 / 0 |
n/a |
0 / 0 |
0 | |||||
setResourceFilePath | n/a |
0 / 0 |
n/a |
0 / 0 |
0 | |||||
getMinifier | n/a |
0 / 0 |
n/a |
0 / 0 |
0 | |||||
addNonce | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
getType | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
enabledInConfig | |
0.00% |
0 / 7 |
|
0.00% |
0 / 1 |
56 | |||
filterItems | |
0.00% |
0 / 38 |
|
0.00% |
0 / 1 |
42 | |||
getResourceCacheDir | |
0.00% |
0 / 9 |
|
0.00% |
0 / 1 |
30 | |||
getConcatenatedFilePath | |
0.00% |
0 / 28 |
|
0.00% |
0 / 1 |
56 | |||
createConcatenatedFile | |
0.00% |
0 / 10 |
|
0.00% |
0 / 1 |
6 | |||
getMinifiedData | |
0.00% |
0 / 10 |
|
0.00% |
0 / 1 |
12 | |||
outputInOrder | |
0.00% |
0 / 30 |
|
0.00% |
0 / 1 |
56 | |||
isMinifiable | |
0.00% |
0 / 2 |
|
0.00% |
0 / 1 |
2 | |||
isPipelineActive | |
0.00% |
0 / 9 |
|
0.00% |
0 / 1 |
30 | |||
toString | |
0.00% |
0 / 7 |
|
0.00% |
0 / 1 |
30 |
1 | <?php |
2 | |
3 | /** |
4 | * Trait to add asset pipeline functionality (concatenation / minification) to |
5 | * a HeadLink/HeadScript-style view helper. |
6 | * |
7 | * PHP version 8 |
8 | * |
9 | * Copyright (C) Villanova University 2016. |
10 | * Copyright (C) The National Library of Finland 2017. |
11 | * |
12 | * This program is free software; you can redistribute it and/or modify |
13 | * it under the terms of the GNU General Public License version 2, |
14 | * as published by the Free Software Foundation. |
15 | * |
16 | * This program is distributed in the hope that it will be useful, |
17 | * but WITHOUT ANY WARRANTY; without even the implied warranty of |
18 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
19 | * GNU General Public License for more details. |
20 | * |
21 | * You should have received a copy of the GNU General Public License |
22 | * along with this program; if not, write to the Free Software |
23 | * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA |
24 | * |
25 | * @category VuFind |
26 | * @package View_Helpers |
27 | * @author Demian Katz <demian.katz@villanova.edu> |
28 | * @author Ere Maijala <ere.maijala@helsinki.fi> |
29 | * @license http://opensource.org/licenses/gpl-2.0.php GNU General Public License |
30 | * @link https://vufind.org/wiki/development Wiki |
31 | */ |
32 | |
33 | namespace VuFindTheme\View\Helper; |
34 | |
35 | use VuFindTheme\ThemeInfo; |
36 | |
37 | use function count; |
38 | use function defined; |
39 | use function in_array; |
40 | use function is_resource; |
41 | |
42 | /** |
43 | * Trait to add asset pipeline functionality (concatenation / minification) to |
44 | * a HeadLink/HeadScript-style view helper. |
45 | * |
46 | * @category VuFind |
47 | * @package View_Helpers |
48 | * @author Demian Katz <demian.katz@villanova.edu> |
49 | * @author Ere Maijala <ere.maijala@helsinki.fi> |
50 | * @license http://opensource.org/licenses/gpl-2.0.php GNU General Public License |
51 | * @link https://vufind.org/wiki/development:testing:unit_tests Wiki |
52 | */ |
53 | trait ConcatTrait |
54 | { |
55 | /** |
56 | * Returns true if file should not be included in the compressed concat file |
57 | * |
58 | * @param stdClass $item Element object |
59 | * |
60 | * @return bool |
61 | */ |
62 | abstract protected function isExcludedFromConcat($item); |
63 | |
64 | /** |
65 | * Get the folder name and file extension |
66 | * |
67 | * @return string |
68 | */ |
69 | abstract protected function getFileType(); |
70 | |
71 | /** |
72 | * Get the file path from the element object |
73 | * |
74 | * @param stdClass $item Element object |
75 | * |
76 | * @return string |
77 | */ |
78 | abstract protected function getResourceFilePath($item); |
79 | |
80 | /** |
81 | * Set the file path of the element object |
82 | * |
83 | * @param stdClass $item Element object |
84 | * @param string $path New path string |
85 | * |
86 | * @return stdClass |
87 | */ |
88 | abstract protected function setResourceFilePath($item, $path); |
89 | |
90 | /** |
91 | * Get the minifier that can handle these file types |
92 | * |
93 | * @return minifying object like \MatthiasMullie\Minify\JS |
94 | */ |
95 | abstract protected function getMinifier(); |
96 | |
97 | /** |
98 | * Add a content security policy nonce to the item |
99 | * |
100 | * @param stdClass $item Item |
101 | * |
102 | * @return void |
103 | * |
104 | * @SuppressWarnings(PHPMD.UnusedFormalParameter) |
105 | */ |
106 | protected function addNonce($item) |
107 | { |
108 | // Default implementation does nothing |
109 | } |
110 | |
111 | /** |
112 | * Set the file path of the link object |
113 | * |
114 | * @param stdClass $item Link element object |
115 | * |
116 | * @return string |
117 | * |
118 | * @SuppressWarnings(PHPMD.UnusedFormalParameter) |
119 | */ |
120 | public function getType($item) |
121 | { |
122 | return 'default'; |
123 | } |
124 | |
125 | /** |
126 | * Should we use the asset pipeline to join files together and minify them? |
127 | * |
128 | * @var bool |
129 | */ |
130 | protected $usePipeline = false; |
131 | |
132 | /** |
133 | * Array of resource items by type, contains key as well |
134 | * |
135 | * @var array |
136 | */ |
137 | protected $groups = []; |
138 | |
139 | /** |
140 | * Future order of the concatenated file |
141 | * |
142 | * @var number |
143 | */ |
144 | protected $concatIndex = null; |
145 | |
146 | /** |
147 | * Check if config is enabled for this file type |
148 | * |
149 | * @param string|bool $config Config for current application environment |
150 | * |
151 | * @return bool |
152 | */ |
153 | protected function enabledInConfig($config) |
154 | { |
155 | if ($config === false || $config == 'off') { |
156 | return false; |
157 | } |
158 | if ( |
159 | $config == '*' || $config == 'on' |
160 | || $config == 'true' || $config === true |
161 | ) { |
162 | return true; |
163 | } |
164 | $settings = array_map('trim', explode(',', $config)); |
165 | return in_array($this->getFileType(), $settings); |
166 | } |
167 | |
168 | /** |
169 | * Initialize class properties related to concatenation of resources. |
170 | * All of the elements to be concatenated into groups and |
171 | * and those that need to remain on their own special group 'other'. |
172 | * |
173 | * @return bool True if there are items |
174 | */ |
175 | protected function filterItems() |
176 | { |
177 | $this->groups = []; |
178 | $groupTypes = []; |
179 | |
180 | $this->getContainer()->ksort(); |
181 | |
182 | foreach ($this as $item) { |
183 | if ($this->isExcludedFromConcat($item)) { |
184 | $this->groups[] = [ |
185 | 'other' => true, |
186 | 'item' => $item, |
187 | ]; |
188 | $groupTypes[] = 'other'; |
189 | continue; |
190 | } |
191 | |
192 | $path = $this->getFileType() . '/' . $this->getResourceFilePath($item); |
193 | $details = $this->themeInfo->findContainingTheme( |
194 | $path, |
195 | ThemeInfo::RETURN_ALL_DETAILS |
196 | ); |
197 | // Deal with special case: $path was not found in any theme. |
198 | if (null === $details) { |
199 | $errorMsg = "Could not find file '$path' in theme files"; |
200 | method_exists($this, 'logError') |
201 | ? $this->logError($errorMsg) : error_log($errorMsg); |
202 | $this->groups[] = [ |
203 | 'other' => true, |
204 | 'item' => $item, |
205 | ]; |
206 | $groupTypes[] = 'other'; |
207 | continue; |
208 | } |
209 | |
210 | $type = $this->getType($item); |
211 | $index = array_search($type, $groupTypes); |
212 | if ($index === false) { |
213 | $this->groups[] = [ |
214 | 'items' => [$item], |
215 | 'key' => $details['path'] . filemtime($details['path']), |
216 | ]; |
217 | $groupTypes[] = $type; |
218 | } else { |
219 | $this->groups[$index]['items'][] = $item; |
220 | $this->groups[$index]['key'] .= |
221 | $details['path'] . filemtime($details['path']); |
222 | } |
223 | } |
224 | |
225 | return count($groupTypes) > 0; |
226 | } |
227 | |
228 | /** |
229 | * Get the path to the directory where we can cache files generated by |
230 | * this trait. The directory will be created if it does not already exist. |
231 | * |
232 | * @return string |
233 | */ |
234 | protected function getResourceCacheDir() |
235 | { |
236 | if (!defined('LOCAL_CACHE_DIR')) { |
237 | throw new \Exception( |
238 | 'Asset pipeline feature depends on the LOCAL_CACHE_DIR constant.' |
239 | ); |
240 | } |
241 | // TODO: it might be better to use \VuFind\Cache\Manager here. |
242 | $cacheDir = LOCAL_CACHE_DIR . '/public/'; |
243 | if (!is_dir($cacheDir) && !file_exists($cacheDir)) { |
244 | if (!mkdir($cacheDir)) { |
245 | throw new \Exception("Unexpected problem creating cache directory: $cacheDir"); |
246 | } |
247 | } |
248 | return $cacheDir; |
249 | } |
250 | |
251 | /** |
252 | * Using the concatKey, return the path of the concatenated file. |
253 | * Generate if it does not yet exist. |
254 | * |
255 | * @param array $group Object containing 'key' and stdobj file 'items' |
256 | * |
257 | * @return string |
258 | */ |
259 | protected function getConcatenatedFilePath($group) |
260 | { |
261 | $urlHelper = $this->getView()->plugin('url'); |
262 | |
263 | // Don't recompress individual files |
264 | if (count($group['items']) === 1) { |
265 | $path = $this->getResourceFilePath($group['items'][0]); |
266 | $details = $this->themeInfo->findContainingTheme( |
267 | $this->getFileType() . '/' . $path, |
268 | ThemeInfo::RETURN_ALL_DETAILS |
269 | ); |
270 | return $urlHelper('home') . 'themes/' . $details['theme'] |
271 | . '/' . $this->getFileType() . '/' . $path; |
272 | } |
273 | // Locate/create concatenated asset file |
274 | $filename = md5($group['key']) . '.min.' . $this->getFileType(); |
275 | // Minifier uses realpath, so do that here too to make sure we're not |
276 | // pointing to a symlink. Otherwise the path converter won't find the correct |
277 | // shared directory part. |
278 | $concatPath = realpath($this->getResourceCacheDir()) . '/' . $filename; |
279 | if (!file_exists($concatPath)) { |
280 | $lockfile = "$concatPath.lock"; |
281 | $handle = fopen($lockfile, 'c+'); |
282 | if (!is_resource($handle)) { |
283 | throw new \Exception("Could not open lock file $lockfile"); |
284 | } |
285 | if (!flock($handle, LOCK_EX)) { |
286 | fclose($handle); |
287 | throw new \Exception("Could not lock file $lockfile"); |
288 | } |
289 | // Check again if file exists after acquiring the lock |
290 | if (!file_exists($concatPath)) { |
291 | try { |
292 | $this->createConcatenatedFile($concatPath, $group); |
293 | } catch (\Exception $e) { |
294 | flock($handle, LOCK_UN); |
295 | fclose($handle); |
296 | throw $e; |
297 | } |
298 | } |
299 | flock($handle, LOCK_UN); |
300 | fclose($handle); |
301 | } |
302 | |
303 | return $urlHelper('home') . 'cache/' . $filename; |
304 | } |
305 | |
306 | /** |
307 | * Create a concatenated file from the given group of files |
308 | * |
309 | * @param string $concatPath Resulting file path |
310 | * @param array $group Object containing 'key' and stdobj file 'items' |
311 | * |
312 | * @throws \Exception |
313 | * @return void |
314 | */ |
315 | protected function createConcatenatedFile($concatPath, $group) |
316 | { |
317 | $data = []; |
318 | foreach ($group['items'] as $item) { |
319 | $details = $this->themeInfo->findContainingTheme( |
320 | $this->getFileType() . '/' |
321 | . $this->getResourceFilePath($item), |
322 | ThemeInfo::RETURN_ALL_DETAILS |
323 | ); |
324 | $details['path'] = realpath($details['path']); |
325 | $data[] = $this->getMinifiedData($details, $concatPath); |
326 | } |
327 | // Separate each file's data with a new line so that e.g. a file |
328 | // ending in a comment doesn't cause the next one to also get commented out. |
329 | file_put_contents($concatPath, implode("\n", $data)); |
330 | } |
331 | |
332 | /** |
333 | * Get minified data for a file |
334 | * |
335 | * @param array $details File details |
336 | * @param string $concatPath Target path for the resulting file (used in minifier |
337 | * for path mapping) |
338 | * |
339 | * @throws \Exception |
340 | * @return string |
341 | */ |
342 | protected function getMinifiedData($details, $concatPath) |
343 | { |
344 | if ($this->isMinifiable($details['path'])) { |
345 | $minifier = $this->getMinifier(); |
346 | $minifier->add($details['path']); |
347 | $data = $minifier->execute($concatPath); |
348 | } else { |
349 | $data = file_get_contents($details['path']); |
350 | if (false === $data) { |
351 | throw new \Exception( |
352 | "Could not read file {$details['path']}" |
353 | ); |
354 | } |
355 | } |
356 | return $data; |
357 | } |
358 | |
359 | /** |
360 | * Process and return items in index order |
361 | * |
362 | * @param string|int $indent Amount of whitespace/string to use for indentation |
363 | * |
364 | * @return string |
365 | */ |
366 | protected function outputInOrder($indent) |
367 | { |
368 | // Some of this logic was copied from HeadScript; it does not all apply |
369 | // when incorporated into HeadLink, but it has no harmful side effects. |
370 | $indent = (null !== $indent) |
371 | ? $this->getWhitespace($indent) |
372 | : $this->getIndent(); |
373 | |
374 | if ($this->view) { |
375 | $useCdata = $this->view->plugin('doctype')->isXhtml(); |
376 | } else { |
377 | $useCdata = $this->useCdata ?? false; |
378 | } |
379 | |
380 | $escapeStart = ($useCdata) ? '//<![CDATA[' : '//<!--'; |
381 | $escapeEnd = ($useCdata) ? '//]]>' : '//-->'; |
382 | |
383 | $output = []; |
384 | foreach ($this->groups as $group) { |
385 | if (isset($group['other'])) { |
386 | /** |
387 | * PHPStan doesn't like this because of incompatible itemToString |
388 | * signatures in HeadLink/HeadScript, but it is safe to use because |
389 | * the extra parameters will be ignored appropriately. |
390 | * |
391 | * @phpstan-ignore-next-line |
392 | */ |
393 | $output[] = $this->itemToString( |
394 | $group['item'], |
395 | $indent, |
396 | $escapeStart, |
397 | $escapeEnd |
398 | ); |
399 | } else { |
400 | // Note that we use parent::itemToString() below instead of |
401 | // $this->itemToString() to bypass VuFind logic that determines |
402 | // file paths within the theme (not appropriate for concatenated |
403 | // files, which are stored in a theme-independent cache). |
404 | $path = $this->getConcatenatedFilePath($group); |
405 | $item = $this->setResourceFilePath($group['items'][0], $path); |
406 | $this->addNonce($item); |
407 | /** |
408 | * PHPStan doesn't like this because of incompatible itemToString |
409 | * signatures in HeadLink/HeadScript, but it is safe to use because |
410 | * the extra parameters will be ignored appropriately. |
411 | * |
412 | * @phpstan-ignore-next-line |
413 | */ |
414 | $output[] = parent::itemToString( |
415 | $item, |
416 | $indent, |
417 | $escapeStart, |
418 | $escapeEnd |
419 | ); |
420 | } |
421 | } |
422 | |
423 | return $indent . implode( |
424 | $this->escape($this->getSeparator()) . $indent, |
425 | $output |
426 | ); |
427 | } |
428 | |
429 | /** |
430 | * Check if a file is minifiable i.e. does not have a pattern that denotes it's |
431 | * already minified |
432 | * |
433 | * @param string $filename File name |
434 | * |
435 | * @return bool |
436 | */ |
437 | protected function isMinifiable($filename) |
438 | { |
439 | $basename = basename($filename); |
440 | return preg_match('/\.min\.(js|css)/', $basename) === 0; |
441 | } |
442 | |
443 | /** |
444 | * Can we use the asset pipeline? |
445 | * |
446 | * @return bool |
447 | */ |
448 | protected function isPipelineActive() |
449 | { |
450 | if ($this->usePipeline) { |
451 | try { |
452 | $cacheDir = $this->getResourceCacheDir(); |
453 | } catch (\Exception $e) { |
454 | $this->usePipeline = $cacheDir = false; |
455 | error_log($e->getMessage()); |
456 | } |
457 | if ($cacheDir && !is_writable($cacheDir)) { |
458 | $this->usePipeline = false; |
459 | error_log("Cannot write to $cacheDir; disabling asset pipeline."); |
460 | } |
461 | } |
462 | return $this->usePipeline; |
463 | } |
464 | |
465 | /** |
466 | * Render link elements as string |
467 | * Customized to minify and concatenate |
468 | * |
469 | * @param string|int $indent Amount of whitespace or string to use for indentation |
470 | * |
471 | * @return string |
472 | */ |
473 | public function toString($indent = null) |
474 | { |
475 | // toString must not throw exception |
476 | try { |
477 | if ( |
478 | !$this->isPipelineActive() || !$this->filterItems() |
479 | || count($this) == 1 |
480 | ) { |
481 | return parent::toString($indent); |
482 | } |
483 | |
484 | return $this->outputInOrder($indent); |
485 | } catch (\Exception $e) { |
486 | error_log($e->getMessage()); |
487 | } |
488 | |
489 | return ''; |
490 | } |
491 | } |