PluginProbe
JCH Optimize / trunk
JCH Optimize vtrunk
6.0.2 6.0.1 trunk 1.0.1 1.0.2 1.1.0 1.1.1 1.1.2 1.1.3 1.1.4 1.2.0 1.2.1 2.0.0 2.0.1 2.0.2 2.0.3 2.0.4 2.0.5 2.0.6 2.0.7 2.0.8 2.1.0 2.10.0 2.2.0 2.2.1 All 58 releases
jch-optimize / lib / src / Combiner.php

Combiner.php in JCH Optimize trunk, at lib/src/Combiner.php

451 lines 13.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 /**
4 * JCH Optimize - Performs several front-end optimizations for fast downloads
5 *
6 * @package jchoptimize/core
7 * @author Samuel Marshall <samuel@jch-optimize.net>
8 * @copyright Copyright (c) 2022 Samuel Marshall / JCH Optimize
9 * @license GNU/GPLv3, or later. See LICENSE file
10 *
11 * If LICENSE file missing, see <http://www.gnu.org/licenses/>.
12 */
13
14 namespace JchOptimize\Core;
15
16 use _JchOptimizeVendor\V91\GuzzleHttp\Client;
17 use _JchOptimizeVendor\V91\GuzzleHttp\Exception\GuzzleException;
18 use _JchOptimizeVendor\V91\GuzzleHttp\Psr7\UriResolver;
19 use _JchOptimizeVendor\V91\GuzzleHttp\Psr7\Utils;
20 use _JchOptimizeVendor\V91\GuzzleHttp\RequestOptions;
21 use _JchOptimizeVendor\V91\Joomla\DI\ContainerAwareInterface;
22 use _JchOptimizeVendor\V91\Joomla\DI\ContainerAwareTrait;
23 use _JchOptimizeVendor\V91\Laminas\Stdlib\StringUtils;
24 use _JchOptimizeVendor\V91\Psr\Http\Client\ClientInterface;
25 use _JchOptimizeVendor\V91\Psr\Http\Message\UriInterface;
26 use _JchOptimizeVendor\V91\Psr\Log\LoggerAwareInterface;
27 use _JchOptimizeVendor\V91\Psr\Log\LoggerAwareTrait;
28 use CodeAlfa\Minify\Css;
29 use CodeAlfa\Minify\Html;
30 use CodeAlfa\Minify\Js;
31 use Exception;
32 use JchOptimize\Core\Css\CssProcessor;
33 use JchOptimize\Core\Debug\JchProfilerInterface;
34 use JchOptimize\Core\Exception\FileNotFoundException;
35 use JchOptimize\Core\Exception\PropertyNotFoundException;
36 use JchOptimize\Core\Exception\RuntimeException;
37 use JchOptimize\Core\Html\CacheManager;
38 use JchOptimize\Core\Platform\PathsInterface;
39 use JchOptimize\Core\SystemUri\SystemUri;
40 use JchOptimize\Core\Uri\UriConverter;
41 use Serializable;
42
43 use function defined;
44 use function file_exists;
45 use function function_exists;
46 use function preg_match;
47 use function sprintf;
48 use function str_ends_with;
49 use function trim;
50
51 use const PHP_EOL;
52
53 defined('_JCH_EXEC') or die('Restricted access');
54
55 /**
56 * Class to combine CSS/JS files together
57 */
58 class Combiner implements ContainerAwareInterface, LoggerAwareInterface, Serializable
59 {
60 use ContainerAwareTrait;
61 use LoggerAwareTrait;
62 use SerializableTrait;
63
64 private bool $isLastKey = false;
65
66 public function __construct(
67 private Registry $params,
68 /**
69 * @var (Client&ClientInterface)|null
70 */
71 private $http,
72 private JchProfilerInterface $profiler
73 ) {
74 }
75
76 /**
77 * @throws Exception
78 */
79 public function getCssContents(array $fileInfosArray): CacheObject
80 {
81 return $this->profiler->profile(
82 'getCssContents',
83 fn() => $this->getContents($fileInfosArray, 'css')
84 );
85 }
86
87 /**
88 * @throws Exception
89 */
90 public function getContents(array $fileInfosArray, string $type): CacheObject
91 {
92 $resultObj = $this->combineFiles($fileInfosArray);
93
94 if ($type == 'css') {
95 if (!$this->params->get('optimizeCssDelivery_enable', '0')) {
96 $resultObj->prependContents($resultObj->getImports());
97 } else {
98 $resultObj->setCriticalCss($resultObj->getImports() . $resultObj->getCriticalCss());
99 }
100
101 $this->addCharset($resultObj);
102 }
103
104 $resultObj->prepareForCaching();
105
106 return $resultObj;
107 }
108
109 /**
110 * @throws Exception
111 */
112 public function combineFiles(array $fileInfosArray, bool $cacheItems = true): CacheObject
113 {
114 $cacheObj = new CacheObject();
115
116 /** @var FileInfo $fileInfo */
117 foreach ($fileInfosArray as $fileInfo) {
118 $url = $fileInfo->display();
119 $truncatedUrl = FileUtils::prepareFileForDisplay(Uri\Utils::uriFor($url));
120
121 $this->profiler->measure(
122 'Combine File - ' . $truncatedUrl,
123 fn() => $this->combineFile($cacheItems, $cacheObj, $fileInfo)
124 );
125 }
126
127 return $cacheObj;
128 }
129
130 private function combineFile(bool $cacheItems, CacheObject $cacheObj, FileInfo $fileInfo): void
131 {
132 if ($cacheItems && $this->params->get('combine_files', '0')) {
133 $cacheManager = $this->getContainer()->get(CacheManager::class);
134 $resultObj = $cacheManager->cacheContent($fileInfo, $this->isLastKey);
135 } else {
136 $resultObj = $this->cacheContent($fileInfo);
137 }
138
139 $cacheObj->merge($resultObj->getMergedImportedContents());
140 $cacheObj->appendContents(PHP_EOL);
141 }
142
143 /**
144 * Optimize and cache contents of individual file/script returning optimized content
145 */
146 public function cacheContent(FileInfo $fileInfos): CacheObject
147 {
148 $cacheObj = new CacheObject();
149
150 try {
151 if ($fileInfos->hasUri()) {
152 $content = $this->getFileContents($fileInfos);
153 } else {
154 $content = $fileInfos->getContent();
155 }
156
157 if (!$fileInfos->isAlreadyProcessed()) {
158 $content = $this->removeHtmlComments($content, $fileInfos);
159
160 if ($fileInfos->getType() == 'css') {
161 $cacheObj->merge($this->processCssInfos($content, $fileInfos));
162 $content = $cacheObj->getContents();
163 }
164
165 if ($fileInfos->getType() == 'js') {
166 $content = $this->addSemiColon($content);
167 //$content = $this->addTryCatch($content, $fileInfos);
168 }
169 }
170
171 $content = $this->minifyContent($content, $fileInfos);
172 $content = $this->addCommentedUrl($fileInfos) . $content;
173 } catch (FileNotFoundException $e) {
174 $content = $e->getMessage();
175 }
176
177 $cacheObj->setContents($content);
178
179 return $cacheObj;
180 }
181
182 public function getFileContents(FileInfo $fileInfo): string
183 {
184 $uri = UriResolver::resolve(SystemUri::currentUri(), $fileInfo->getUri());
185 $paths = $this->getContainer()->get(PathsInterface::class);
186 $filePath = UriConverter::uriToFilePath($uri, $paths);
187
188 if (file_exists($filePath) && Helper::isStaticFile($filePath)) {
189 try {
190 return $this->readStreamFromDisk($filePath);
191 } catch (RuntimeException $e) {
192 $this->logger?->debug('Couldn\'t open file: ' . $uri . '; error: ' . $e->getMessage());
193 }
194 }
195
196 try {
197 return $this->getResponseFromHttpRequest($uri, $fileInfo);
198 } catch (GuzzleException |FileNotFoundException $e) {
199 throw new FileNotFoundException(
200 $this->wrapInComments($fileInfo->display()) . $e->getMessage()
201 );
202 }
203 }
204
205 /**
206 * @throws RuntimeException
207 */
208 private function readStreamFromDisk(string $filePath): string
209 {
210 $stream = Utils::streamFor(Utils::tryFopen($filePath, 'r'));
211
212 if (!$stream->isReadable()) {
213 throw new RuntimeException('Stream unreadable');
214 }
215
216 if ($stream->isSeekable()) {
217 $stream->rewind();
218 }
219
220 return $stream->getContents();
221 }
222
223 /**
224 * @throws GuzzleException|FileNotFoundException
225 */
226 private function getResponseFromHttpRequest(UriInterface $uri, FileInfo $fileInfo): string
227 {
228 $options = [
229 RequestOptions::HEADERS => [
230 'Accept-Encoding' => 'identity;q=0'
231 ]
232 ];
233
234 try {
235 $response = $this->getHttp()->get($uri, $options);
236 } catch (GuzzleException $e) {
237 throw new FileNotFoundException("/* {$e->getMessage()} */");
238 }
239
240 $contentType = $response->getHeader('Content-Type')[0] ?? '';
241 $expectedContentType = $this->getExpectedContentTypeRegex($fileInfo);
242
243 if (!preg_match("#$expectedContentType#i", $contentType)) {
244 throw new FileNotFoundException("/* Wrong Content-Type returned: $contentType */");
245 }
246
247 if ($response->getStatusCode() === 200) {
248 $body = $response->getBody();
249 $body->rewind();
250
251 return $body->getContents();
252 } else {
253 throw new FileNotFoundException("/* Response returned status code: {$response->getStatusCode()} */");
254 }
255 }
256
257 /**
258 * @return Client&ClientInterface
259 * @throw PropertyNotFoundException
260 */
261 public function getHttp()
262 {
263 if ($this->http === null) {
264 throw new PropertyNotFoundException('Http Client not set');
265 }
266
267 return $this->http;
268 }
269
270 private function getExpectedContentTypeRegex(FileInfo $fileInfo): string
271 {
272 if ($fileInfo->getType() == 'js') {
273 return '(?:text|application)/(?:x-)??(?:java|ecma|live|j)script';
274 } else {
275 return 'text/css';
276 }
277 }
278
279 private function wrapInComments(string $message): string
280 {
281 $commentStart = '/***!';
282 $commentEnd = '!***/';
283 $message = str_replace([$commentStart, $commentEnd], '', $message);
284
285 return PHP_EOL . "$commentStart $message $commentEnd" . PHP_EOL . PHP_EOL;
286 }
287
288 private function removeHtmlComments(string $content, FileInfo $fileInfos): string
289 {
290 return Html::cleanScript($content, $fileInfos->getType());
291 }
292
293 private function processCssInfos(string $content, FileInfo $fileInfos): CacheObject
294 {
295 /** @var CssProcessor $oCssProcessor */
296 $oCssProcessor = $this->getContainer()->getNewInstance(CssProcessor::class);
297 $oCssProcessor->setCssInfos($fileInfos);
298 $oCssProcessor->setCss($content);
299 $oCssProcessor->setIsLastKey($this->isLastKey);
300 $oCssProcessor->formatCss();
301 $oCssProcessor->processAtRules();
302 $oCssProcessor->processConditionalAtRules();
303 $oCssProcessor->optimizeCssDelivery();
304 $oCssProcessor->processUrls();
305 $oCssProcessor->processSprite();
306
307 return $oCssProcessor->getCacheObj();
308 }
309
310 public function setIsLastKey(bool $isLastKey): Combiner
311 {
312 $this->isLastKey = $isLastKey;
313
314 return $this;
315 }
316
317 private function addSemiColon(string $content): string
318 {
319 $content = trim($content);
320
321 if ($content !== '' && !str_ends_with($content, ';')) {
322 $content .= ';';
323 }
324
325 return $content;
326 }
327
328 private function addTryCatch(string $content, FileInfo $fileInfo): string
329 {
330 if (
331 $this->params->get('combine_files', '0')
332 && $this->params->get('try_catch', '1')
333 ) {
334 $content = <<<JS
335 try{
336 $content
337 } catch (e) {
338 console.error('Error in {$fileInfo->display()}; Error: ' + e.message);
339 }
340 JS;
341 }
342
343 return $content;
344 }
345
346 private function minifyContent(string $content, FileInfo $fileInfo): string
347 {
348 if ($this->params->get($fileInfo->getType() . '_minify', 0)) {
349 $url = $fileInfo->display();
350
351 try {
352 $minifiedContent = trim(
353 $fileInfo->getType() == 'css' ? Css::optimize($content) : Js::optimize($content)
354 );
355 } catch (Exception $e) {
356 $this->logger?->error(sprintf('Error occurred trying to minify: %s', $url));
357 $minifiedContent = $content;
358 }
359
360 return $minifiedContent;
361 }
362
363 return $content;
364 }
365
366 public function addCommentedUrl(FileInfo $fileInfos): string
367 {
368 $comment = '';
369
370 if ($this->params->get(Settings::DEBUG)) {
371 $comment = $this->wrapInComments($fileInfos->display());
372 }
373
374 return $comment;
375 }
376
377 private function addCharset(CacheObject $resultObj): void
378 {
379 if (
380 (function_exists('mb_detect_encoding')
381 && mb_detect_encoding($resultObj->getContents(), 'UTF-8', true) === 'UTF-8')
382 || (!function_exists('mb_detect_encoding')
383 && StringUtils::hasPcreUnicodeSupport()
384 && StringUtils::isValidUtf8($resultObj->getContents()))
385 ) {
386 $resultObj->prependContents('@charset "UTF-8";');
387 }
388 }
389
390 /**
391 * @throws Exception
392 */
393 public function getJsContents(array $fileInfosArray): CacheObject
394 {
395 return $this->profiler->profile(
396 'getJsContents',
397 fn() => $this->getContents($fileInfosArray, 'js')
398 );
399 }
400
401 /**
402 * Used when you want to append the contents of files to some that are already combined, into one file
403 */
404 public function appendFiles(array $ids, array $fileInfosArray, string $type): CacheObject
405 {
406 $resultObj = new CacheObject();
407
408 if (!empty($ids)) {
409 foreach ($ids as $id) {
410 $resultObj->merge($this->handleIdForCombining($id, $type));
411 }
412 }
413
414 if (!empty($fileInfosArray)) {
415 try {
416 $resultObj->merge($this->combineFiles($fileInfosArray));
417 } catch (Exception $e) {
418 $this->logger?->error('Error appending files: ' . $e->getMessage());
419 }
420 }
421
422 if ($type == 'css') {
423 $resultObj->prependContents($resultObj->getImports());
424 $this->addCharset($resultObj);
425 }
426 /* if ($type == 'js') {
427 $resultObj->appendContents("\n" . 'jchOptimizeDynamicScriptLoader.next();');
428 } */
429 $resultObj->prepareForCaching();
430
431 return $resultObj;
432 }
433
434 private function handleIdForCombining(mixed $id, string $type): CacheObject
435 {
436 $content = Output::getCombinedFile([
437 'f' => $id,
438 'type' => $type
439 ], false);
440
441 if ($type == 'css') {
442 /** @var CssProcessor $cssProcessor */
443 $cssProcessor = $this->getContainer()->getNewInstance(CSSProcessor::class);
444
445 return $cssProcessor->processDynamicCssFile($content);
446 } else {
447 return (new CacheObject())->setContents($content);
448 }
449 }
450 }
451