* @copyright Copyright (c) 2022 Samuel Marshall / JCH Optimize * @license GNU/GPLv3, or later. See LICENSE file * * If LICENSE file missing, see . */ namespace JchOptimize\Core; use _JchOptimizeVendor\V91\GuzzleHttp\Client; use _JchOptimizeVendor\V91\GuzzleHttp\Exception\GuzzleException; use _JchOptimizeVendor\V91\GuzzleHttp\Psr7\UriResolver; use _JchOptimizeVendor\V91\GuzzleHttp\Psr7\Utils; use _JchOptimizeVendor\V91\GuzzleHttp\RequestOptions; use _JchOptimizeVendor\V91\Joomla\DI\ContainerAwareInterface; use _JchOptimizeVendor\V91\Joomla\DI\ContainerAwareTrait; use _JchOptimizeVendor\V91\Laminas\Stdlib\StringUtils; use _JchOptimizeVendor\V91\Psr\Http\Client\ClientInterface; use _JchOptimizeVendor\V91\Psr\Http\Message\UriInterface; use _JchOptimizeVendor\V91\Psr\Log\LoggerAwareInterface; use _JchOptimizeVendor\V91\Psr\Log\LoggerAwareTrait; use CodeAlfa\Minify\Css; use CodeAlfa\Minify\Html; use CodeAlfa\Minify\Js; use Exception; use JchOptimize\Core\Css\CssProcessor; use JchOptimize\Core\Debug\JchProfilerInterface; use JchOptimize\Core\Exception\FileNotFoundException; use JchOptimize\Core\Exception\PropertyNotFoundException; use JchOptimize\Core\Exception\RuntimeException; use JchOptimize\Core\Html\CacheManager; use JchOptimize\Core\Platform\PathsInterface; use JchOptimize\Core\SystemUri\SystemUri; use JchOptimize\Core\Uri\UriConverter; use Serializable; use function defined; use function file_exists; use function function_exists; use function preg_match; use function sprintf; use function str_ends_with; use function trim; use const PHP_EOL; defined('_JCH_EXEC') or die('Restricted access'); /** * Class to combine CSS/JS files together */ class Combiner implements ContainerAwareInterface, LoggerAwareInterface, Serializable { use ContainerAwareTrait; use LoggerAwareTrait; use SerializableTrait; private bool $isLastKey = false; public function __construct( private Registry $params, /** * @var (Client&ClientInterface)|null */ private $http, private JchProfilerInterface $profiler ) { } /** * @throws Exception */ public function getCssContents(array $fileInfosArray): CacheObject { return $this->profiler->profile( 'getCssContents', fn() => $this->getContents($fileInfosArray, 'css') ); } /** * @throws Exception */ public function getContents(array $fileInfosArray, string $type): CacheObject { $resultObj = $this->combineFiles($fileInfosArray); if ($type == 'css') { if (!$this->params->get('optimizeCssDelivery_enable', '0')) { $resultObj->prependContents($resultObj->getImports()); } else { $resultObj->setCriticalCss($resultObj->getImports() . $resultObj->getCriticalCss()); } $this->addCharset($resultObj); } $resultObj->prepareForCaching(); return $resultObj; } /** * @throws Exception */ public function combineFiles(array $fileInfosArray, bool $cacheItems = true): CacheObject { $cacheObj = new CacheObject(); /** @var FileInfo $fileInfo */ foreach ($fileInfosArray as $fileInfo) { $url = $fileInfo->display(); $truncatedUrl = FileUtils::prepareFileForDisplay(Uri\Utils::uriFor($url)); $this->profiler->measure( 'Combine File - ' . $truncatedUrl, fn() => $this->combineFile($cacheItems, $cacheObj, $fileInfo) ); } return $cacheObj; } private function combineFile(bool $cacheItems, CacheObject $cacheObj, FileInfo $fileInfo): void { if ($cacheItems && $this->params->get('combine_files', '0')) { $cacheManager = $this->getContainer()->get(CacheManager::class); $resultObj = $cacheManager->cacheContent($fileInfo, $this->isLastKey); } else { $resultObj = $this->cacheContent($fileInfo); } $cacheObj->merge($resultObj->getMergedImportedContents()); $cacheObj->appendContents(PHP_EOL); } /** * Optimize and cache contents of individual file/script returning optimized content */ public function cacheContent(FileInfo $fileInfos): CacheObject { $cacheObj = new CacheObject(); try { if ($fileInfos->hasUri()) { $content = $this->getFileContents($fileInfos); } else { $content = $fileInfos->getContent(); } if (!$fileInfos->isAlreadyProcessed()) { $content = $this->removeHtmlComments($content, $fileInfos); if ($fileInfos->getType() == 'css') { $cacheObj->merge($this->processCssInfos($content, $fileInfos)); $content = $cacheObj->getContents(); } if ($fileInfos->getType() == 'js') { $content = $this->addSemiColon($content); //$content = $this->addTryCatch($content, $fileInfos); } } $content = $this->minifyContent($content, $fileInfos); $content = $this->addCommentedUrl($fileInfos) . $content; } catch (FileNotFoundException $e) { $content = $e->getMessage(); } $cacheObj->setContents($content); return $cacheObj; } public function getFileContents(FileInfo $fileInfo): string { $uri = UriResolver::resolve(SystemUri::currentUri(), $fileInfo->getUri()); $paths = $this->getContainer()->get(PathsInterface::class); $filePath = UriConverter::uriToFilePath($uri, $paths); if (file_exists($filePath) && Helper::isStaticFile($filePath)) { try { return $this->readStreamFromDisk($filePath); } catch (RuntimeException $e) { $this->logger?->debug('Couldn\'t open file: ' . $uri . '; error: ' . $e->getMessage()); } } try { return $this->getResponseFromHttpRequest($uri, $fileInfo); } catch (GuzzleException | FileNotFoundException $e) { throw new FileNotFoundException( $this->wrapInComments($fileInfo->display()) . $e->getMessage() ); } } /** * @throws RuntimeException */ private function readStreamFromDisk(string $filePath): string { $stream = Utils::streamFor(Utils::tryFopen($filePath, 'r')); if (!$stream->isReadable()) { throw new RuntimeException('Stream unreadable'); } if ($stream->isSeekable()) { $stream->rewind(); } return $stream->getContents(); } /** * @throws GuzzleException|FileNotFoundException */ private function getResponseFromHttpRequest(UriInterface $uri, FileInfo $fileInfo): string { $options = [ RequestOptions::HEADERS => [ 'Accept-Encoding' => 'identity;q=0' ] ]; try { $response = $this->getHttp()->get($uri, $options); } catch (GuzzleException $e) { throw new FileNotFoundException("/* {$e->getMessage()} */"); } $contentType = $response->getHeader('Content-Type')[0] ?? ''; $expectedContentType = $this->getExpectedContentTypeRegex($fileInfo); if (!preg_match("#$expectedContentType#i", $contentType)) { throw new FileNotFoundException("/* Wrong Content-Type returned: $contentType */"); } if ($response->getStatusCode() === 200) { $body = $response->getBody(); $body->rewind(); return $body->getContents(); } else { throw new FileNotFoundException("/* Response returned status code: {$response->getStatusCode()} */"); } } /** * @return Client&ClientInterface * @throw PropertyNotFoundException */ public function getHttp() { if ($this->http === null) { throw new PropertyNotFoundException('Http Client not set'); } return $this->http; } private function getExpectedContentTypeRegex(FileInfo $fileInfo): string { if ($fileInfo->getType() == 'js') { return '(?:text|application)/(?:x-)??(?:java|ecma|live|j)script'; } else { return 'text/css'; } } private function wrapInComments(string $message): string { $commentStart = '/***!'; $commentEnd = '!***/'; $message = str_replace([$commentStart, $commentEnd], '', $message); return PHP_EOL . "$commentStart $message $commentEnd" . PHP_EOL . PHP_EOL; } private function removeHtmlComments(string $content, FileInfo $fileInfos): string { return Html::cleanScript($content, $fileInfos->getType()); } private function processCssInfos(string $content, FileInfo $fileInfos): CacheObject { /** @var CssProcessor $oCssProcessor */ $oCssProcessor = $this->getContainer()->getNewInstance(CssProcessor::class); $oCssProcessor->setCssInfos($fileInfos); $oCssProcessor->setCss($content); $oCssProcessor->setIsLastKey($this->isLastKey); $oCssProcessor->formatCss(); $oCssProcessor->processAtRules(); $oCssProcessor->processConditionalAtRules(); $oCssProcessor->optimizeCssDelivery(); $oCssProcessor->processUrls(); $oCssProcessor->processSprite(); return $oCssProcessor->getCacheObj(); } public function setIsLastKey(bool $isLastKey): Combiner { $this->isLastKey = $isLastKey; return $this; } private function addSemiColon(string $content): string { $content = trim($content); if ($content !== '' && !str_ends_with($content, ';')) { $content .= ';'; } return $content; } private function addTryCatch(string $content, FileInfo $fileInfo): string { if ( $this->params->get('combine_files', '0') && $this->params->get('try_catch', '1') ) { $content = <<display()}; Error: ' + e.message); } JS; } return $content; } private function minifyContent(string $content, FileInfo $fileInfo): string { if ($this->params->get($fileInfo->getType() . '_minify', 0)) { $url = $fileInfo->display(); try { $minifiedContent = trim( $fileInfo->getType() == 'css' ? Css::optimize($content) : Js::optimize($content) ); } catch (Exception $e) { $this->logger?->error(sprintf('Error occurred trying to minify: %s', $url)); $minifiedContent = $content; } return $minifiedContent; } return $content; } public function addCommentedUrl(FileInfo $fileInfos): string { $comment = ''; if ($this->params->get(Settings::DEBUG)) { $comment = $this->wrapInComments($fileInfos->display()); } return $comment; } private function addCharset(CacheObject $resultObj): void { if ( (function_exists('mb_detect_encoding') && mb_detect_encoding($resultObj->getContents(), 'UTF-8', true) === 'UTF-8') || (!function_exists('mb_detect_encoding') && StringUtils::hasPcreUnicodeSupport() && StringUtils::isValidUtf8($resultObj->getContents())) ) { $resultObj->prependContents('@charset "UTF-8";'); } } /** * @throws Exception */ public function getJsContents(array $fileInfosArray): CacheObject { return $this->profiler->profile( 'getJsContents', fn() => $this->getContents($fileInfosArray, 'js') ); } /** * Used when you want to append the contents of files to some that are already combined, into one file */ public function appendFiles(array $ids, array $fileInfosArray, string $type): CacheObject { $resultObj = new CacheObject(); if (!empty($ids)) { foreach ($ids as $id) { $resultObj->merge($this->handleIdForCombining($id, $type)); } } if (!empty($fileInfosArray)) { try { $resultObj->merge($this->combineFiles($fileInfosArray)); } catch (Exception $e) { $this->logger?->error('Error appending files: ' . $e->getMessage()); } } if ($type == 'css') { $resultObj->prependContents($resultObj->getImports()); $this->addCharset($resultObj); } /* if ($type == 'js') { $resultObj->appendContents("\n" . 'jchOptimizeDynamicScriptLoader.next();'); } */ $resultObj->prepareForCaching(); return $resultObj; } private function handleIdForCombining(mixed $id, string $type): CacheObject { $content = Output::getCombinedFile([ 'f' => $id, 'type' => $type ], false); if ($type == 'css') { /** @var CssProcessor $cssProcessor */ $cssProcessor = $this->getContainer()->getNewInstance(CSSProcessor::class); return $cssProcessor->processDynamicCssFile($content); } else { return (new CacheObject())->setContents($content); } } }