* @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);
}
}
}