| 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 |
|