first commit

This commit is contained in:
2026-02-08 21:16:11 +01:00
commit e17b7026fd
8881 changed files with 1160453 additions and 0 deletions

View File

@@ -0,0 +1,38 @@
<?php
/**
* JCH Optimize - Performs several front-end optimizations for fast downloads.
*
* @author Samuel Marshall <samuel@jch-optimize.net>
* @copyright Copyright (c) 2021 Samuel Marshall / JCH Optimize
* @license GNU/GPLv3, or later. See LICENSE file
*
* If LICENSE file missing, see <http://www.gnu.org/licenses/>.
*/
namespace JchOptimize;
use JchOptimize\Core\Container\Container;
use JchOptimize\Service\ConfigurationProvider;
use JchOptimize\Service\DatabaseProvider;
use JchOptimize\Service\LoggerProvider;
use JchOptimize\Service\ModeSwitcherProvider;
use JchOptimize\Service\MvcProvider;
use JchOptimize\Service\ReCacheProvider;
\defined('_JEXEC') or exit('Restricted access');
/**
* A class to easily fetch a Joomla\DI\Container with all dependencies registered.
*/
class ContainerFactory extends \JchOptimize\Core\Container\AbstractContainerFactory
{
protected function registerPlatformProviders(Container $container): void
{
$container->registerServiceProvider(new DatabaseProvider())->registerServiceProvider(new ConfigurationProvider())->registerServiceProvider(new LoggerProvider())->registerServiceProvider(new MvcProvider());
if (\JCH_PRO) {
$container->registerServiceProvider(new ReCacheProvider());
$container->registerServiceProvider(new ModeSwitcherProvider());
}
}
}

View File

@@ -0,0 +1,79 @@
<?php
/**
* JCH Optimize - Performs several front-end optimizations for fast downloads.
*
* @author Samuel Marshall <samuel@jch-optimize.net>
* @copyright Copyright (c) 2020 Samuel Marshall / JCH Optimize
* @license GNU/GPLv3, or later. See LICENSE file
*
* If LICENSE file missing, see <http://www.gnu.org/licenses/>.
*/
namespace JchOptimize\Controller;
use _JchOptimizeVendor\Joomla\Controller\AbstractController;
use JchOptimize\Core\Admin\Ajax\Ajax as AdminAjax;
use Joomla\CMS\Application\AdministratorApplication;
use Joomla\Input\Input;
\defined('_JEXEC') or exit('Restricted Access');
class Ajax extends AbstractController
{
/**
* @var string[]
*/
private $taskMap;
public function __construct(Input $input, AdministratorApplication $app)
{
parent::__construct($input, $app);
$this->taskMap = ['filetree' => 'doFileTree', 'multiselect' => 'doMultiSelect', 'optimizeimage' => 'doOptimizeImage', 'smartcombine' => 'doSmartCombine', 'garbagecron' => 'doGarbageCron'];
}
public function execute(): bool
{
/** @var Input $input */
$input = $this->getInput();
/** @var string $task */
$task = $input->get('task');
// @see self::doFileTree()
// @see self::doMultiSelect()
// @see self::doOptimizeImage()
// @see self::doSmartCombine()
// @see self::doGarbageCron()
$this->{$this->taskMap[$task]}();
/** @var AdministratorApplication $app */
$app = $this->getApplication();
$app->close();
return \true;
}
private function doFileTree(): void
{
echo AdminAjax::getInstance('FileTree')->run();
}
private function doMultiSelect(): void
{
echo AdminAjax::getInstance('MultiSelect')->run();
}
private function doOptimizeImage(): void
{
echo AdminAjax::getInstance('OptimizeImage')->run();
}
private function doSmartCombine(): void
{
echo AdminAjax::getInstance('SmartCombine')->run();
}
private function doGarbageCron(): void
{
echo AdminAjax::getInstance('GarbageCron')->run();
}
}

View File

@@ -0,0 +1,56 @@
<?php
/**
* JCH Optimize - Performs several front-end optimizations for fast downloads.
*
* @author Samuel Marshall <samuel@jch-optimize.net>
* @copyright Copyright (c) 2021 Samuel Marshall / JCH Optimize
* @license GNU/GPLv3, or later. See LICENSE file
*
* If LICENSE file missing, see <http://www.gnu.org/licenses/>.
*/
namespace JchOptimize\Controller;
use _JchOptimizeVendor\Joomla\Controller\AbstractController;
use JchOptimize\Core\Exception\ExceptionInterface;
use JchOptimize\Model\Configure;
use Joomla\Application\AbstractApplication;
use Joomla\CMS\Application\AdministratorApplication;
use Joomla\Input\Input;
\defined('_JEXEC') or exit('Restricted Access');
class ApplyAutoSetting extends AbstractController
{
private Configure $model;
public function __construct(Configure $model, ?Input $input = null, ?AbstractApplication $app = null)
{
$this->model = $model;
parent::__construct($input, $app);
}
public function execute(): bool
{
/** @var Input $input */
$input = $this->getInput();
/** @var AdministratorApplication $app */
$app = $this->getApplication();
try {
$this->model->applyAutoSettings((string) $input->get('autosetting', 's1'));
} catch (ExceptionInterface $e) {
}
$body = \json_encode(['success' => \true]);
$app->clearHeaders();
$app->setHeader('Content-Type', 'application/json');
$app->setHeader('Content-Length', (string) \strlen($body));
$app->setBody($body);
$app->allowCache(\false);
echo $app->toString();
$app->close();
return \true;
}
}

View File

@@ -0,0 +1,46 @@
<?php
/**
* JCH Optimize - Performs several front-end optimizations for fast downloads.
*
* @author Samuel Marshall <samuel@jch-optimize.net>
* @copyright Copyright (c) 2023 Samuel Marshall / JCH Optimize
* @license GNU/GPLv3, or later. See LICENSE file
*
* If LICENSE file missing, see <http://www.gnu.org/licenses/>.
*/
namespace JchOptimize\Controller;
use _JchOptimizeVendor\Joomla\Controller\AbstractController;
use JchOptimize\Model\Cache;
use Joomla\CMS\Application\AdministratorApplication;
use Joomla\Input\Input;
class CacheInfo extends AbstractController
{
private Cache $cacheModel;
public function __construct(Cache $cacheModel, Input $input = null, AdministratorApplication $app = null)
{
$this->cacheModel = $cacheModel;
parent::__construct($input, $app);
}
public function execute(): bool
{
/** @var AdministratorApplication $app */
$app = $this->getApplication();
[$size, $numFiles] = $this->cacheModel->getCacheSize();
$body = \json_encode(['size' => $size, 'numFiles' => $numFiles]);
$app->clearHeaders();
$app->setHeader('Content-Type', 'application/json');
$app->setHeader('Content-Length', (string) \strlen($body));
$app->setBody($body);
$app->allowCache(\false);
echo $app->toString();
$app->close();
return \true;
}
}

View File

@@ -0,0 +1,101 @@
<?php
/**
* JCH Optimize - Performs several front-end optimizations for fast downloads.
*
* @author Samuel Marshall <samuel@jch-optimize.net>
* @copyright Copyright (c) 2020 Samuel Marshall / JCH Optimize
* @license GNU/GPLv3, or later. See LICENSE file
*
* If LICENSE file missing, see <http://www.gnu.org/licenses/>.
*/
namespace JchOptimize\Controller;
use _JchOptimizeVendor\Joomla\Controller\AbstractController;
use _JchOptimizeVendor\Joomla\DI\ContainerAwareInterface;
use _JchOptimizeVendor\Joomla\DI\ContainerAwareTrait;
use JchOptimize\Core\Admin\Icons;
use JchOptimize\Core\Cdn;
use JchOptimize\Core\PageCache\CaptureCache;
use JchOptimize\Joomla\Plugin\PluginHelper;
use JchOptimize\Model\Updates;
use JchOptimize\View\ControlPanelHtml;
use Joomla\CMS\Application\AdministratorApplication;
use Joomla\CMS\Language\Text;
use Joomla\CMS\Router\Route;
use Joomla\CMS\Uri\Uri;
use Joomla\Input\Input;
\defined('_JEXEC') or exit('Restricted Access');
class ControlPanel extends AbstractController implements ContainerAwareInterface
{
use ContainerAwareTrait;
private ControlPanelHtml $view;
private Updates $updatesModel;
private Icons $icons;
private Cdn $cdn;
/**
* Constructor.
*/
public function __construct(Updates $updatesModel, ControlPanelHtml $view, Icons $icons, Cdn $cdn, Input $input = null, AdministratorApplication $app = null)
{
$this->updatesModel = $updatesModel;
$this->view = $view;
$this->icons = $icons;
$this->cdn = $cdn;
parent::__construct($input, $app);
}
public function execute(): bool
{
$this->manageUpdates();
if (\JCH_PRO) {
/** @var CaptureCache $captureCache */
$captureCache = $this->container->get(CaptureCache::class);
$captureCache->updateHtaccess();
}
$this->cdn->updateHtaccess();
$this->view->setData(['view' => 'ControlPanel', 'icons' => $this->icons]);
$this->view->loadResources();
$this->view->loadToolBar();
if (!PluginHelper::isEnabled('system', 'jchoptimize')) {
if (\JCH_PRO) {
$editUrl = Route::_('index.php?option=com_jchoptimize&view=ModeSwitcher&task=setProduction&return='.\base64_encode((string) Uri::getInstance()), \false);
} else {
$editUrl = Route::_('index.php?option=com_plugins&filter[search]=JCH Optimize&filter[folder]=system');
}
/** @var AdministratorApplication $app */
$app = $this->getApplication();
$app->enqueueMessage(Text::sprintf('COM_JCHOPTIMIZE_PLUGIN_NOT_ENABLED', $editUrl), 'warning');
}
echo $this->view->render();
return \true;
}
private function manageUpdates(): void
{
$this->updatesModel->upgradeLicenseKey();
$this->updatesModel->refreshUpdateSite();
$this->updatesModel->removeObsoleteUpdateSites();
if (\JCH_PRO) {
if ('' == $this->updatesModel->getLicenseKey()) {
if (\version_compare(JVERSION, '4.0', 'lt')) {
$dlidEditUrl = Route::_('index.php?option=com_config&view=component&component=com_jchoptimize');
} else {
$dlidEditUrl = Route::_('index.php?option=com_installer&view=updatesites&filter[search]=JCH Optimize&filter[supported]=1');
}
/** @var AdministratorApplication $app */
$app = $this->getApplication();
$app->enqueueMessage(Text::sprintf('COM_JCHOPTIMIZE_DOWNLOADID_MISSING', $dlidEditUrl), 'warning');
}
}
}
}

View File

@@ -0,0 +1,52 @@
<?php
/**
* JCH Optimize - Performs several front-end optimizations for fast downloads.
*
* @author Samuel Marshall <samuel@jch-optimize.net>
* @copyright Copyright (c) 2021 Samuel Marshall / JCH Optimize
* @license GNU/GPLv3, or later. See LICENSE file
*
* If LICENSE file missing, see <http://www.gnu.org/licenses/>.
*/
namespace JchOptimize\Controller;
use _JchOptimizeVendor\Joomla\Controller\AbstractController;
use JchOptimize\Core\Admin\Ajax\Ajax as AdminAjax;
use Joomla\CMS\Application\AdministratorApplication;
use Joomla\CMS\Language\Text as JText;
use Joomla\CMS\Router\Route as JRoute;
use Joomla\Input\Input;
\defined('_JEXEC') or exit('Restricted Access');
class OptimizeImage extends AbstractController
{
public function execute(): bool
{
/** @var Input $input */
$input = $this->getInput();
/** @var null|string $status */
$status = $input->get('status', null);
/** @var AdministratorApplication $app */
$app = $this->getApplication();
if (\is_null($status)) {
echo AdminAjax::getInstance('OptimizeImage')->run();
$app->close();
} else {
if ('success' == $status) {
$dir = \rtrim((string) $input->get('dir', ''), '/').'/';
$cnt = (int) $input->get('cnt', 0);
$app->enqueueMessage(\sprintf(JText::_('%1$d images optimized in %2$s'), $cnt, $dir));
} else {
$msg = (string) $input->get('msg', '');
$app->enqueueMessage(JText::_('The Optimize Image function failed with message "'.$msg), 'error');
}
$app->redirect(JRoute::_('index.php?option=com_jchoptimize&view=OptimizeImages', \false));
}
return \true;
}
}

View File

@@ -0,0 +1,49 @@
<?php
/**
* JCH Optimize - Performs several front-end optimizations for fast downloads.
*
* @author Samuel Marshall <samuel@jch-optimize.net>
* @copyright Copyright (c) 2020 Samuel Marshall / JCH Optimize
* @license GNU/GPLv3, or later. See LICENSE file
*
* If LICENSE file missing, see <http://www.gnu.org/licenses/>.
*/
namespace JchOptimize\Controller;
use _JchOptimizeVendor\Joomla\Controller\AbstractController;
use JchOptimize\Core\Admin\Icons;
use JchOptimize\Model\ApiParams;
use JchOptimize\View\OptimizeImagesHtml;
\defined('_JEXEC') or exit('Restricted Access');
class OptimizeImages extends AbstractController
{
private OptimizeImagesHtml $view;
private ApiParams $model;
private Icons $icons;
/**
* Constructor.
*/
public function __construct(ApiParams $model, OptimizeImagesHtml $view, Icons $icons)
{
$this->model = $model;
$this->view = $view;
$this->icons = $icons;
parent::__construct();
}
public function execute(): bool
{
$this->view->setData(['view' => 'OptimizeImages', 'apiParams' => \json_encode($this->model->getCompParams()), 'icons' => $this->icons]);
$this->view->loadResources();
$this->view->loadToolBar();
echo $this->view->render();
return \true;
}
}

View File

@@ -0,0 +1,109 @@
<?php
/**
* JCH Optimize - Performs several front-end optimizations for fast downloads.
*
* @author Samuel Marshall <samuel@jch-optimize.net>
* @copyright Copyright (c) 2022 Samuel Marshall / JCH Optimize
* @license GNU/GPLv3, or later. See LICENSE file
*
* If LICENSE file missing, see <http://www.gnu.org/licenses/>.
*/
namespace JchOptimize\Controller;
use _JchOptimizeVendor\Joomla\Controller\AbstractController;
use _JchOptimizeVendor\Joomla\DI\ContainerAwareInterface;
use _JchOptimizeVendor\Joomla\DI\ContainerAwareTrait;
use _JchOptimizeVendor\Laminas\Paginator\Adapter\ArrayAdapter;
use _JchOptimizeVendor\Laminas\Paginator\Paginator;
use JchOptimize\Joomla\Plugin\PluginHelper;
use JchOptimize\Model\ModeSwitcher;
use JchOptimize\Model\PageCache as PageCacheModel;
use JchOptimize\Model\ReCache;
use JchOptimize\View\PageCacheHtml;
use Joomla\Application\AbstractApplication;
use Joomla\CMS\Application\AdministratorApplication;
use Joomla\CMS\Language\Text;
use Joomla\CMS\Router\Route;
use Joomla\CMS\Uri\Uri;
use Joomla\Input\Input;
\defined('_JEXEC') or exit('Restricted Access');
class PageCache extends AbstractController implements ContainerAwareInterface
{
use ContainerAwareTrait;
private PageCacheHtml $view;
private PageCacheModel $pageCacheModel;
public function __construct(PageCacheModel $pageCacheModel, PageCacheHtml $view, ?Input $input = null, ?AbstractApplication $app = null)
{
$this->pageCacheModel = $pageCacheModel;
$this->view = $view;
parent::__construct($input, $app);
}
public function execute()
{
/** @var Input $input */
$input = $this->getInput();
/** @var AdministratorApplication $app */
$app = $this->getApplication();
if ('remove' == $input->get('task')) {
$success = $this->pageCacheModel->delete((array) $input->get('cid', []));
}
if ('deleteAll' == $input->get('task')) {
$success = $this->pageCacheModel->deleteAll();
}
if (\JCH_PRO && 'recache' == $input->get('task')) {
/** @var ReCache $reCacheModel */
$reCacheModel = $this->container->get(ReCache::class);
$redirectUrl = Route::_('index.php?option=com_jchoptimize&view=PageCache', \false, 0, \true);
$reCacheModel->reCache($redirectUrl);
}
if (isset($success)) {
if ($success) {
$message = Text::_('COM_JCHOPTIMIZE_PAGECACHE_DELETED_SUCCESSFULLY');
$messageType = 'success';
} else {
$message = Text::_('COM_JCHOPTIMIZE_PAGECACHE_DELETE_ERROR');
$messageType = 'error';
}
$app->enqueueMessage($message, $messageType);
$app->redirect(Route::_('index.php?option=com_jchoptimize&view=PageCache', \false));
}
$integratedPageCache = 'jchoptimizepagecache';
if (\JCH_PRO) {
/** @var ModeSwitcher $modeSwitcher */
$modeSwitcher = $this->container->get(ModeSwitcher::class);
$integratedPageCache = $modeSwitcher->getIntegratedPageCachePlugin();
}
if ('jchoptimizepagecache' == $integratedPageCache) {
if (!PluginHelper::isEnabled('system', 'jchoptimizepagecache')) {
if (\JCH_PRO === '1') {
$editUrl = Route::_('index.php?option=com_jchoptimize&view=Utility&task=togglepagecache&return='.\base64_encode((string) Uri::getInstance()), \false);
} else {
$editUrl = Route::_('index.php?option=com_plugins&filter[search]=JCH Optimize Page Cache&filter[folder]=system');
}
$app->enqueueMessage(Text::sprintf('COM_JCHOPTIMIZE_PAGECACHE_NOT_ENABLED', $editUrl), 'warning');
}
} elseif (\JCH_PRO) {
/** @var ModeSwitcher $modeSwitcher */
$modeSwitcher = $this->container->get(ModeSwitcher::class);
$app->enqueueMessage(Text::sprintf('COM_JCHOPTIMIZE_INTEGRATED_PAGE_CACHE_NOT_JCHOPTIMIZE', Text::_($modeSwitcher->pageCachePlugins[$integratedPageCache])), 'info');
}
/** @var int $defaultListLimit */
$defaultListLimit = $app->get('list_limit');
$paginator = new Paginator(new ArrayAdapter($this->pageCacheModel->getItems()));
$paginator->setCurrentPageNumber((int) $input->get('list_page', '1'))->setItemCountPerPage((int) $this->pageCacheModel->getState()->get('list_limit', $defaultListLimit));
$this->view->setData(['items' => $paginator, 'view' => 'PageCache', 'paginator' => $paginator->getPages(), 'pageLink' => 'index.php?option=com_jchoptimize&view=PageCache', 'adapter' => $this->pageCacheModel->getAdaptorName(), 'httpRequest' => $this->pageCacheModel->isCaptureCacheEnabled()]);
$this->view->renderStatefulElements($this->pageCacheModel->getState());
$this->view->loadResources();
$this->view->loadToolBar();
echo $this->view->render();
return \true;
}
}

View File

@@ -0,0 +1,113 @@
<?php
/**
* JCH Optimize - Performs several front-end optimizations for fast downloads.
*
* @author Samuel Marshall <samuel@jch-optimize.net>
* @copyright Copyright (c) 2020 Samuel Marshall / JCH Optimize
* @license GNU/GPLv3, or later. See LICENSE file
*
* If LICENSE file missing, see <http://www.gnu.org/licenses/>.
*/
namespace JchOptimize\Controller;
use _JchOptimizeVendor\Joomla\Controller\AbstractController;
use _JchOptimizeVendor\Joomla\DI\ContainerAwareInterface;
use _JchOptimizeVendor\Joomla\DI\ContainerAwareTrait;
use JchOptimize\Core\Admin\Icons;
use JchOptimize\Core\Exception\ExceptionInterface;
use JchOptimize\Model\Configure;
use JchOptimize\Model\ModeSwitcher;
use JchOptimize\Platform\Cache;
use Joomla\Application\AbstractApplication;
use Joomla\CMS\Application\AdministratorApplication;
use Joomla\CMS\Language\Text;
use Joomla\Input\Input;
\defined('_JEXEC') or exit('Restricted Access');
class ToggleSetting extends AbstractController implements ContainerAwareInterface
{
use ContainerAwareTrait;
private Configure $model;
public function __construct(Configure $model, ?Input $input = null, ?AbstractApplication $application = null)
{
$this->model = $model;
parent::__construct($input, $application);
}
public function execute(): bool
{
/** @var Input $input */
$input = $this->getInput();
/** @var string $setting */
$setting = $input->get('setting');
try {
$this->model->toggleSetting($setting);
} catch (ExceptionInterface $e) {
}
$currentSettingValue = (string) $this->model->getState()->get($setting);
if ('integrated_page_cache_enable' == $setting) {
$currentSettingValue = Cache::isPageCacheEnabled($this->model->getState());
}
$class = $currentSettingValue ? 'enabled' : 'disabled';
$class2 = '';
$auto = \false;
$pageCacheStatus = '';
$statusClass = '';
if ('pro_reduce_unused_css' == $setting) {
$class2 = $this->model->getState()->get('optimizeCssDelivery_enable') ? 'enabled' : 'disabled';
}
if ('optimizeCssDelivery_enable' == $setting) {
$class2 = $this->model->getState()->get('pro_reduce_unused_css') ? 'enabled' : 'disabled';
}
if ('combine_files_enable' == $setting && $currentSettingValue) {
$auto = $this->getEnabledAutoSetting();
}
if (JCH_PRO && 'integrated_page_cache_enable' == $setting) {
/** @var ModeSwitcher $modeSwitcher */
$modeSwitcher = $this->container->get(ModeSwitcher::class);
[, , $pageCacheStatus, $statusClass] = $modeSwitcher->getIndicators();
$pageCacheStatus = Text::sprintf('MOD_JCHMODESWITCHER_PAGECACHE_STATUS', $pageCacheStatus);
}
$body = \json_encode(['class' => $class, 'class2' => $class2, 'auto' => $auto, 'page_cache_status' => $pageCacheStatus, 'status_class' => $statusClass]);
/** @var AdministratorApplication $app */
$app = $this->getApplication();
$app->clearHeaders();
$app->setHeader('Content-Type', 'application/json');
$app->setHeader('Content-Length', (string) \strlen($body));
$app->setBody($body);
$app->allowCache(\false);
echo $app->toString();
$app->close();
return \true;
}
/**
* @return false|string
*/
private function getEnabledAutoSetting()
{
$autoSettingsMap = Icons::autoSettingsArrayMap();
$autoSettingsInitialized = \array_map(function ($a) {
return '0';
}, $autoSettingsMap);
$currentAutoSettings = \array_intersect_key($this->model->getState()->toArray(), $autoSettingsInitialized);
// order array
$orderedCurrentAutoSettings = \array_merge($autoSettingsInitialized, $currentAutoSettings);
$autoSettings = ['minimum', 'intermediate', 'average', 'deluxe', 'premium', 'optimum'];
for ($j = 0; $j < 6; ++$j) {
if (\array_values($orderedCurrentAutoSettings) === \array_column($autoSettingsMap, 's'.($j + 1))) {
return $autoSettings[$j];
}
}
// No auto setting configured
return \false;
}
}

View File

@@ -0,0 +1,274 @@
<?php
/**
* JCH Optimize - Performs several front-end optimizations for fast downloads.
*
* @author Samuel Marshall <samuel@jch-optimize.net>
* @copyright Copyright (c) 2020 Samuel Marshall / JCH Optimize
* @license GNU/GPLv3, or later. See LICENSE file
*
* If LICENSE file missing, see <http://www.gnu.org/licenses/>.
*/
namespace JchOptimize\Controller;
use _JchOptimizeVendor\GuzzleHttp\Psr7\UploadedFile;
use _JchOptimizeVendor\Joomla\Controller\AbstractController;
use _JchOptimizeVendor\Joomla\DI\ContainerAwareInterface;
use _JchOptimizeVendor\Joomla\DI\ContainerAwareTrait;
use JchOptimize\Core\Admin\Tasks;
use JchOptimize\Model\BulkSettings;
use JchOptimize\Model\Cache;
use JchOptimize\Model\ModeSwitcher;
use JchOptimize\Model\OrderPlugins;
use JchOptimize\Model\ReCache;
use JchOptimize\Model\TogglePlugins;
use Joomla\CMS\Application\AdministratorApplication;
use Joomla\CMS\Language\Text;
use Joomla\CMS\Router\Route;
use Joomla\Filesystem\File;
use Joomla\Input\Input;
\defined('_JEXEC') or exit('Restricted Access');
class Utility extends AbstractController implements ContainerAwareInterface
{
use ContainerAwareTrait;
/**
* Message to enqueue by application.
*/
private string $message = '';
/**
* Message type.
*/
private string $messageType = 'success';
/**
* Url to redirect to.
*/
private string $redirectUrl;
private OrderPlugins $orderPluginsModel;
private Cache $cacheModel;
private TogglePlugins $togglePluginsModel;
private BulkSettings $bulkSettings;
/**
* Constructor.
*/
public function __construct(OrderPlugins $orderPluginsModel, Cache $cacheModel, TogglePlugins $togglePluginsModel, BulkSettings $bulkSettings, ?Input $input = null, ?AdministratorApplication $app = null)
{
$this->orderPluginsModel = $orderPluginsModel;
$this->cacheModel = $cacheModel;
$this->togglePluginsModel = $togglePluginsModel;
$this->bulkSettings = $bulkSettings;
$this->redirectUrl = Route::_('index.php?option=com_jchoptimize', \false);
parent::__construct($input, $app);
}
public function execute()
{
/** @var Input $input */
$input = $this->getInput();
$this->{$input->get('task', 'default')}();
/** @var string $return */
$return = $input->get('return', '', 'base64');
if ($return) {
$this->redirectUrl = Route::_(\base64_decode($return));
}
/** @var AdministratorApplication $app */
$app = $this->getApplication();
$app->enqueueMessage($this->message, $this->messageType);
$app->redirect($this->redirectUrl);
return \true;
}
private function browsercaching(): void
{
$success = null;
$expires = Tasks::leverageBrowserCaching($success);
if (\false === $success) {
$this->message = Text::_('COM_JCHOPTIMIZE_LEVERAGEBROWSERCACHE_FAILED');
$this->messageType = 'error';
} elseif ('FILEDOESNTEXIST' === $expires) {
$this->message = Text::_('COM_JCHOPTIMIZE_LEVERAGEBROWSERCACHE_FILEDOESNTEXIST');
$this->messageType = 'warning';
} elseif ('CODEUPDATEDSUCCESS' === $expires) {
$this->message = Text::_('COM_JCHOPTIMIZE_LEVERAGEBROWSERCACHE_CODEUPDATEDSUCCESS');
} elseif ('CODEUPDATEDFAIL' === $expires) {
$this->message = Text::_('COM_JCHOPTIMIZE_LEVERAGEBROWSERCACHE_CODEUPDATEDFAIL');
$this->messageType = 'notice';
} else {
$this->message = Text::_('COM_JCHOPTIMIZE_LEVERAGEBROWSERCACHE_SUCCESS');
}
}
private function cleancache(): void
{
$deleted = $this->cacheModel->cleanCache();
if (!$deleted) {
$this->message = Text::_('COM_JCHOPTIMIZE_CACHECLEAN_FAILED');
$this->messageType = 'error';
} else {
$this->message = Text::_('COM_JCHOPTIMIZE_CACHECLEAN_SUCCESS');
}
}
private function togglepagecache(): void
{
$this->message = Text::_('COM_JCHOPTIMIZE_TOGGLE_PAGE_CACHE_FAILURE');
$this->messageType = 'error';
if (JCH_PRO === '1') {
/** @var ModeSwitcher $modeSwitcher */
$modeSwitcher = $this->container->get(ModeSwitcher::class);
$result = $modeSwitcher->togglePageCacheState();
} else {
$result = $this->togglePluginsModel->togglePageCacheState('jchoptimizepagecache');
}
if ($result) {
$this->message = Text::sprintf('COM_JCHOPTIMIZE_TOGGLE_PAGE_CACHE_SUCCESS', 'enabled');
$this->messageType = 'success';
}
}
private function keycache(): void
{
Tasks::generateNewCacheKey();
$this->message = Text::_('COM_JCHOPTIMIZE_CACHE_KEY_GENERATED');
}
private function orderplugins(): void
{
$saved = $this->orderPluginsModel->orderPlugins();
if (\false === $saved) {
$this->message = Text::_('JLIB_APPLICATION_ERROR_REORDER_FAILED');
$this->messageType = 'error';
} else {
$this->message = Text::_('JLIB_APPLICATION_SUCCESS_ORDERING_SAVED');
}
}
private function restoreimages(): void
{
$mResult = Tasks::restoreBackupImages();
if ('SOMEIMAGESDIDNTRESTORE' === $mResult) {
$this->message = Text::_('COM_JCHOPTIMIZE_SOMERESTOREIMAGE_FAILED');
$this->messageType = 'warning';
} elseif ('BACKUPPATHDOESNTEXIST' === $mResult) {
$this->message = Text::_('COM_JCHOPTIMIZE_BACKUPPATH_DOESNT_EXIST');
$this->messageType = 'warning';
} else {
$this->message = Text::_('COM_JCHOPTIMIZE_RESTOREIMAGE_SUCCESS');
}
$this->redirectUrl = Route::_('index.php?option=com_jchoptimize&view=OptimizeImages', \false);
}
private function deletebackups(): void
{
$mResult = Tasks::deleteBackupImages();
if (\false === $mResult) {
$this->message = Text::_('COM_JCHOPTIMIZE_DELETEBACKUPS_FAILED');
$this->messageType = 'error';
} elseif ('BACKUPPATHDOESNTEXIST' === $mResult) {
$this->message = Text::_('COM_JCHOPTIMIZE_BACKUPPATH_DOESNT_EXIST');
$this->messageType = 'warning';
} else {
$this->message = Text::_('COM_JCHOPTIMIZE_DELETEBACKUPS_SUCCESS');
}
$this->redirectUrl = Route::_('index.php?option=com_jchoptimize&view=OptimizeImages', \false);
}
private function recache(): void
{
if (JCH_PRO === '1') {
/** @var ReCache $reCacheModel */
$reCacheModel = $this->container->get(ReCache::class);
$redirectUrl = Route::_('index.php?option=com_jchoptimize', \false, 0, \true);
$reCacheModel->reCache($redirectUrl);
}
$this->redirectUrl = Route::_('index.php?options=');
}
private function importsettings()
{
$input = $this->getInput();
\assert($input instanceof Input);
/** @psalm-var array{tmp_name:string, size:int, error:int, name:string|null, type:string|null}|null $file */
$file = $input->files->get('file');
if (empty($file)) {
$this->message = Text::_('COM_JCHOPTIMIZE_UPLOAD_ERR_NO_FILE');
$this->messageType = 'error';
return;
}
$uploadErrorMap = [\UPLOAD_ERR_OK => Text::_('COM_JCHOPTIMIZE_UPLOAD_ERR_OK'), \UPLOAD_ERR_INI_SIZE => Text::_('COM_JCHOPTIMIZE_UPLOAD_ERR_INI_SIZE'), \UPLOAD_ERR_FORM_SIZE => Text::_('COM_JCHOPTIMIZE_UPLOAD_ERR_FORM_SIZE'), \UPLOAD_ERR_PARTIAL => Text::_('COM_JCHOPTIMIZE_UPLOAD_ERR_PARTIAL'), \UPLOAD_ERR_NO_FILE => Text::_('COM_JCHOPTIMIZE_UPLOAD_ERR_NO_FILE'), \UPLOAD_ERR_NO_TMP_DIR => Text::_('COM_JCHOPTIMIZE_UPLOAD_ERR_NO_TMP_DIR'), \UPLOAD_ERR_CANT_WRITE => Text::_('COM_JCHOPTIMIZE_UPLOAD_ERR_CANT_WRITE'), \UPLOAD_ERR_EXTENSION => Text::_('COM_JCHOPTIMIZE_UPLOAD_ERR_EXTENSION')];
try {
$uploadedFile = new UploadedFile($file['tmp_name'], $file['size'], $file['error'], $file['name'], $file['type']);
if (\UPLOAD_ERR_OK !== $uploadedFile->getError()) {
throw new \Exception(Text::_($uploadErrorMap[$uploadedFile->getError()]));
}
} catch (\Exception $e) {
$this->message = Text::sprintf('COM_JCHOPTIMIZE_UPLOADED_FILE_ERROR', $e->getMessage());
$this->messageType = 'error';
return;
}
try {
$this->bulkSettings->importSettings($uploadedFile);
} catch (\Exception $e) {
$this->message = Text::sprintf('COM_JCHOPTIMIZE_IMPORT_SETTINGS_ERROR', $e->getMessage());
$this->messageType = 'error';
return;
}
$this->message = Text::_('COM_JCHOPTIMIZE_SUCCESSFULLY_IMPORTED_SETTINGS');
}
private function exportsettings(): void
{
$file = $this->bulkSettings->exportSettings();
if (\file_exists($file)) {
/** @var AdministratorApplication $app */
$app = $this->getApplication();
$app->setHeader('Content-Description', 'FileTransfer');
$app->setHeader('Content-Type', 'application/json');
$app->setHeader('Content-Disposition', 'attachment; filename="'.\basename($file).'"');
$app->setHeader('Expires', '0');
$app->setHeader('Cache-Control', 'must-revalidate');
$app->setHeader('Pragma', 'public');
$app->setHeader('Content-Length', (string) \filesize($file));
$app->sendHeaders();
\ob_clean();
\flush();
\readfile($file);
File::delete($file);
$app->close();
}
}
private function setdefaultsettings()
{
try {
$this->bulkSettings->setDefaultSettings();
} catch (\Exception $e) {
$this->message = Text::_('COM_JCHOPTIMIZE_RESTORE_DEFAULT_SETTINGS_FAILED');
$this->messageType = 'error';
return;
}
$this->message = Text::_('COM_JCHOPTIMIZE_DEFAULT_SETTINGS_RESTORED');
}
private function default(): void
{
$this->redirectUrl = Route::_('index.php?option=com_jchoptimize', \false);
}
}

View File

@@ -0,0 +1,52 @@
<?php
/**
* JCH Optimize - Performs several front-end optimizations for fast downloads.
*
* @author Samuel Marshall <samuel@jch-optimize.net>
* @copyright Copyright (c) 2021 Samuel Marshall / JCH Optimize
* @license GNU/GPLv3, or later. See LICENSE file
*
* If LICENSE file missing, see <http://www.gnu.org/licenses/>.
*/
namespace JchOptimize;
use _JchOptimizeVendor\Psr\Container\ContainerExceptionInterface;
use _JchOptimizeVendor\Psr\Container\ContainerInterface;
use _JchOptimizeVendor\Psr\Container\NotFoundExceptionInterface;
use Joomla\Input\Input;
\defined('_JEXEC') or exit('Restricted access');
class ControllerResolver
{
private ContainerInterface $container;
private Input $input;
public function __construct(ContainerInterface $container, Input $input)
{
$this->container = $container;
$this->input = $input;
}
public function resolve()
{
$controller = $this->getController();
if ($this->container->has($controller)) {
try {
\call_user_func([$this->container->get($controller), 'execute']);
} catch (NotFoundExceptionInterface|ContainerExceptionInterface $e) {
throw new \InvalidArgumentException(\sprintf('Controller %s not found', $controller));
}
} else {
throw new \InvalidArgumentException(\sprintf('Cannot resolve controller: %s', $controller));
}
}
private function getController(): string
{
// @var string
return $this->input->get('view', 'ControlPanel');
}
}

View File

@@ -0,0 +1,89 @@
<?php
/**
* JCH Optimize - Performs several front-end optimizations for fast downloads.
*
* @author Samuel Marshall <samuel@jch-optimize.net>
* @copyright Copyright (c) 2022 Samuel Marshall / JCH Optimize
* @license GNU/GPLv3, or later. See LICENSE file
*
* If LICENSE file missing, see <http://www.gnu.org/licenses/>.
*/
namespace JchOptimize\Core\Admin;
use _JchOptimizeVendor\GuzzleHttp\Client;
use _JchOptimizeVendor\GuzzleHttp\Psr7\Uri;
use _JchOptimizeVendor\GuzzleHttp\RequestOptions;
use _JchOptimizeVendor\Joomla\DI\ContainerAwareInterface;
use _JchOptimizeVendor\Joomla\DI\ContainerAwareTrait;
use _JchOptimizeVendor\Psr\Http\Client\ClientInterface;
use _JchOptimizeVendor\Spatie\Crawler\Crawler;
use _JchOptimizeVendor\Spatie\Crawler\CrawlProfiles\CrawlInternalUrls;
use JchOptimize\Core\Interfaces\Html;
use JchOptimize\Core\Spatie\Crawlers\HtmlCollector;
use JchOptimize\Core\Spatie\CrawlQueues\NonOptimizedCacheCrawlQueue;
use JchOptimize\Core\SystemUri;
use JchOptimize\Core\Uri\UriComparator;
use Joomla\Registry\Registry;
use Psr\Log\LoggerAwareInterface;
use Psr\Log\LoggerAwareTrait;
use Psr\Log\NullLogger;
\defined('_JCH_EXEC') or exit('Restricted access');
abstract class AbstractHtml implements Html, LoggerAwareInterface, ContainerAwareInterface
{
use LoggerAwareTrait;
use ContainerAwareTrait;
/**
* JCH Optimize settings.
*/
protected Registry $params;
/**
* Http client transporter object.
*
* @var Client&ClientInterface
*/
protected $http;
private bool $logging = \false;
/**
* @param Client&ClientInterface $http
*/
public function __construct(Registry $params, $http)
{
$this->params = $params;
$this->http = $http;
}
/**
* @param array{base_url?:string, crawl_limit?:int} $options
*
* @return array{list<array{url:string, html:string}>, list<Json>}
*
* @throws \Exception
*/
public function getCrawledHtmls(array $options = []): array
{
$defaultOptions = ['crawl_limit' => 10, 'base_url' => SystemUri::currentBaseFull()];
$options = \array_merge($defaultOptions, $options);
if (UriComparator::isCrossOrigin(new Uri($options['base_url']))) {
throw new \Exception('Cross origin URLs not allowed');
}
$htmlCollector = new HtmlCollector();
$this->logger = $this->logger ?? new NullLogger();
$logger = $this->logging ? $this->logger : new NullLogger();
$htmlCollector->setLogger($logger);
$clientOptions = [RequestOptions::COOKIES => \false, RequestOptions::CONNECT_TIMEOUT => 10, RequestOptions::TIMEOUT => 10, RequestOptions::ALLOW_REDIRECTS => \true, RequestOptions::HEADERS => ['User-Agent' => $_SERVER['HTTP_USER_AGENT'] ?? '*']];
Crawler::create($clientOptions)->setCrawlObserver($htmlCollector)->setParseableMimeTypes(['text/html'])->ignoreRobots()->setTotalCrawlLimit($options['crawl_limit'])->setCrawlQueue($this->container->get(NonOptimizedCacheCrawlQueue::class))->setCrawlProfile(new CrawlInternalUrls($options['base_url']))->startCrawling($options['base_url']);
return [$htmlCollector->getHtmls(), $htmlCollector->getMessages()];
}
public function setLogging(bool $state = \true): void
{
$this->logging = $state;
}
}

View File

@@ -0,0 +1,58 @@
<?php
/**
* JCH Optimize - Performs several front-end optimizations for fast downloads.
*
* @author Samuel Marshall <samuel@jch-optimize.net>
* @copyright Copyright (c) 2022 Samuel Marshall / JCH Optimize
* @license GNU/GPLv3, or later. See LICENSE file
*
* If LICENSE file missing, see <http://www.gnu.org/licenses/>.
*/
namespace JchOptimize\Core\Admin\Ajax;
use _JchOptimizeVendor\Joomla\DI\ContainerAwareInterface;
use _JchOptimizeVendor\Joomla\DI\ContainerAwareTrait;
use JchOptimize\ContainerFactory;
use JchOptimize\Core\Admin\Json;
use Joomla\Input\Input;
use Psr\Log\LoggerAwareInterface;
use Psr\Log\LoggerAwareTrait;
use Psr\Log\LoggerInterface;
\defined('_JCH_EXEC') or exit('Restricted access');
abstract class Ajax implements ContainerAwareInterface, LoggerAwareInterface
{
use ContainerAwareTrait;
use LoggerAwareTrait;
protected Input $input;
private function __construct()
{
\ini_set('pcre.backtrack_limit', '1000000');
\ini_set('pcre.recursion_limit', '1000000');
if (!\JCH_DEVELOP) {
\error_reporting(0);
}
if (\version_compare(\PHP_VERSION, '7.0.0', '>=')) {
\ini_set('pcre.jit', '0');
}
$this->container = ContainerFactory::getContainer();
$this->logger = $this->container->get(LoggerInterface::class);
$this->input = $this->container->get(Input::class);
}
public static function getInstance(string $sClass): Ajax
{
$sFullClass = '\\JchOptimize\\Core\\Admin\\Ajax\\'.$sClass;
// @var Ajax
return new $sFullClass();
}
/**
* @return Json|string
*/
abstract public function run();
}

View File

@@ -0,0 +1,164 @@
<?php
/**
* JCH Optimize - Performs several front-end optimizations for fast downloads.
*
* @author Samuel Marshall <samuel@jch-optimize.net>
* @copyright Copyright (c) 2022 Samuel Marshall / JCH Optimize
* @license GNU/GPLv3, or later. See LICENSE file
*
* If LICENSE file missing, see <http://www.gnu.org/licenses/>.
*/
namespace JchOptimize\Core\Admin\Ajax;
use JchOptimize\Core\Admin\Helper as AdminHelper;
use JchOptimize\Platform\Paths;
use JchOptimize\Platform\Utility;
\defined('_JCH_EXEC') or exit('Restricted access');
class FileTree extends \JchOptimize\Core\Admin\Ajax\Ajax
{
public function run(): string
{
// Website document root
$root = Paths::rootPath();
// The expanded directory in the folder tree
$dir = \urldecode($this->input->getString('dir', '')).'/';
// Which side of the Explorer view are we rendering? Folder tree or subdirectories and files
$view = \urldecode($this->input->getWord('jchview', ''));
// Will be set to 1 if this is the root directory
$initial = \urldecode($this->input->getString('initial', '0'));
$files = \array_diff(\scandir($root.$dir), ['..', '.']);
$directories = [];
$imageFiles = [];
$i = 0;
$j = 0;
foreach ($files as $file) {
if (\is_dir($root.$dir.$file) && 'jch_optimize_backup_images' != $file && '.jch' != $file) {
/*if ($i > 500) {
if ($j > 1000) {
break;
}
continue;
}*/
$directories[$i]['name'] = $file;
$directories[$i]['file_path'] = $dir.$file;
++$i;
} elseif ('tree' != $view && \preg_match('#\\.(?:gif|jpe?g|png)$#i', $file) && @\file_exists($root.$dir.$file)) {
/* if ($j > 1000) {
if ($i > 500) {
break;
}
continue;
} */
$imageFiles[$j]['ext'] = \preg_replace('/^.*\\./', '', $file);
$imageFiles[$j]['name'] = $file;
$imageFiles[$j]['file_path'] = $dir.$file;
$imageFiles[$j]['optimized'] = AdminHelper::isAlreadyOptimized($root.$dir.$file);
++$j;
}
}
$items = function (string $view, array $directories, array $imageFiles): string {
$item = '<ul class="jqueryFileTree">';
foreach ($directories as $directory) {
$item .= '<li class="directory collapsed">';
if ('tree' != $view) {
$item .= '<input type="checkbox" value="'.$directory['file_path'].'">';
}
$item .= '<a href="#" data-url="'.$directory['file_path'].'">'.\htmlentities($directory['name']).'</a>';
$item .= '</li>';
}
if ('tree' != $view) {
foreach ($imageFiles as $image) {
$style = $image['optimized'] ? ' class="already-optimized"' : '';
$file_name = \htmlentities($image['name']);
$item .= <<<HTML
<li class="file ext_{$image['ext']}">
\t<input type="checkbox" value="{$image['file_path']}">
\t<span{$style}><a href="#" data-url="{$image['file_path']}">{$file_name}</a> </span>\t
\t<span><input type="text" size="10" maxlength="5" name="width"></span>
\t<span><input type="text" size="10" maxlength="5" name="height"></span>
</li>\t\t
HTML;
}
}
$item .= '</ul>';
return $item;
};
// generate the response
$response = '';
if ('tree' != $view) {
$width = Utility::translate('Width');
$height = Utility::translate('Height');
$response .= <<<HTML
<div id="files-container-header">
<ul class="jqueryFileTree">
<li class="check-all">
<input type="checkbox"><span><em>Check all</em></span>
<span><em>{$width}</em></span>
<span><em>{$height}</em></span>
</li>
</ul>
</div>
HTML;
}
if ($initial && 'tree' == $view) {
$response .= <<<HTML
<div class="files-content">
<ul class="jqueryFileTree">
<li class="directory expanded root"><a href="#" data-root="{$root}" data-url="">&lt;root&gt;</a>
{$items($view, $directories, $imageFiles)}
</li>
</ul>
</div>
HTML;
} elseif ('tree' != $view) {
$response .= <<<HTML
\t<div class="files-content">
\t
\t{$items($view, $directories, $imageFiles)}
\t
\t</div>
HTML;
} else {
$response .= $items($view, $directories, $imageFiles);
}
return $response;
}
/**
* @param string $dir
* @param string $view
* @param string $path
*/
private function item(string $file, $dir, $view, $path): string
{
$file_path = $dir.$file;
$root = Paths::rootPath();
$anchor = '<a href="#" data-url="'.$file_path.'">'.\htmlentities($file).'</a>';
$html = '';
if ('tree' == $view) {
$html .= $anchor;
} else {
$html .= '<input type="checkbox" value="'.$file_path.'">';
if ('dir' == $path) {
$html .= $anchor;
} else {
$html .= '<span';
if (AdminHelper::isAlreadyOptimized($root.$dir.$file)) {
$html .= ' class="already-optimized"';
}
$html .= '>'.\htmlentities($file).'</span><span><input type="text" size="10" maxlength="5" name="width" ></span><span><input type="text" size="10" maxlength="5" name="height" ></span>';
}
}
return $html;
}
}

View File

@@ -0,0 +1,41 @@
<?php
/**
* JCH Optimize - Performs several front-end optimizations for fast downloads.
*
* @author Samuel Marshall <samuel@jch-optimize.net>
* @copyright Copyright (c) 2022 Samuel Marshall / JCH Optimize
* @license GNU/GPLv3, or later. See LICENSE file
*
* If LICENSE file missing, see <http://www.gnu.org/licenses/>.
*/
namespace JchOptimize\Core\Admin\Ajax;
use JchOptimize\Core\Admin\Json;
use JchOptimize\Core\Admin\MultiSelectItems;
\defined('_JCH_EXEC') or exit('Restricted access');
class MultiSelect extends \JchOptimize\Core\Admin\Ajax\Ajax
{
public function run(): Json
{
$aData = $this->input->get('data', [], 'array');
$container = $this->getContainer();
/** @var MultiSelectItems $oAdmin */
$oAdmin = $container->buildObject(MultiSelectItems::class);
try {
$oAdmin->getAdminLinks();
} catch (\Exception $e) {
}
$response = [];
foreach ($aData as $sData) {
$options = $oAdmin->prepareFieldOptions($sData['type'], $sData['param'], $sData['group'], \false);
$response[$sData['id']] = new Json($options);
}
return new Json($response);
}
}

View File

@@ -0,0 +1,244 @@
<?php
/**
* JCH Optimize - Performs several front-end optimizations for fast downloads.
*
* @author Samuel Marshall <samuel@jch-optimize.net>
* @copyright Copyright (c) 2022 Samuel Marshall / JCH Optimize
* @license GNU/GPLv3, or later. See LICENSE file
*
* If LICENSE file missing, see <http://www.gnu.org/licenses/>.
*/
namespace JchOptimize\Core\Admin;
use _JchOptimizeVendor\Composer\CaBundle\CaBundle;
use _JchOptimizeVendor\GuzzleHttp\Client;
use _JchOptimizeVendor\GuzzleHttp\Psr7\Utils as GuzzleUtils;
use _JchOptimizeVendor\GuzzleHttp\RequestOptions;
use JchOptimize\Core\SystemUri;
use JchOptimize\Core\Uri\Utils;
use JchOptimize\Platform\Paths;
use Joomla\Filesystem\Exception\FilesystemException;
use Joomla\Filesystem\File;
use Joomla\Filesystem\Folder;
use function file;
\defined('_JCH_EXEC') or exit('Restricted access');
class Helper
{
/**
* @return null|array|string|string[]
*
* @deprecated
*/
public static function expandFileNameLegacy($sFile)
{
$sSanitizedFile = \str_replace('//', '/', $sFile);
$aPathParts = \pathinfo($sSanitizedFile);
$sRelFile = \str_replace(['_', '//'], ['/', '_'], $aPathParts['basename']);
return \preg_replace('#^'.\preg_quote(\ltrim(SystemUri::basePath(), \DIRECTORY_SEPARATOR)).'#', Paths::rootPath().\DIRECTORY_SEPARATOR, $sRelFile);
}
public static function expandFileName(string $file): string
{
$sanitizedFile = \str_replace('//', '/', $file);
$aPathParts = \pathinfo($sanitizedFile);
$expandedBasename = \str_replace(['_', '//'], [\DIRECTORY_SEPARATOR, '_'], $aPathParts['basename']);
return Paths::rootPath().\DIRECTORY_SEPARATOR.\ltrim($expandedBasename, \DIRECTORY_SEPARATOR);
}
/**
* @param null|(mixed|string)[]|string $dest
*
* @psalm-param array<mixed|string>|null|string $dest
*/
public static function copyImage(string $src, $dest): bool
{
try {
$client = new Client([RequestOptions::VERIFY => CaBundle::getBundledCaBundlePath()]);
$uri = Utils::uriFor($src);
if (0 === \strpos($uri->getScheme(), 'http')) {
$response = $client->get($uri);
$srcStream = $response->getBody();
} else {
$srcStream = GuzzleUtils::streamFor(GuzzleUtils::tryFopen($src, 'rb'));
}
// Let's ensure parent directory for dest exists
if (!\file_exists(\dirname($dest))) {
Folder::create(\dirname($dest));
}
GuzzleUtils::copyToStream(GuzzleUtils::streamFor($srcStream), GuzzleUtils::streamFor(GuzzleUtils::tryFopen($dest, 'wb')));
} catch (\Exception $e) {
return \false;
}
return \true;
}
/**
* @deprecated
*/
public static function contractFileNameLegacy(string $fileName): string
{
return \str_replace([Paths::rootPath().\DIRECTORY_SEPARATOR, '_', \DIRECTORY_SEPARATOR], [\ltrim(SystemUri::basePath(), \DIRECTORY_SEPARATOR), '__', '_'], $fileName);
}
/**
* Returns the 'contracted' path of the file relative to the Uri base as opposed to the web root as in legacy.
*/
public static function contractFileName(string $filePath): string
{
$difference = self::subtractPath($filePath, Paths::rootPath().'/');
return \str_replace(['_', '\\', '/'], ['__', '_', '_'], $difference);
}
/**
* @return array{path:string}
*/
public static function prepareImageUrl(string $image): array
{
// return array('path' => Utility::encrypt($image));
return ['path' => $image];
}
/**
* @param false|string $sValue
*
* @return float|int
*/
public static function stringToBytes($sValue)
{
$sUnit = \strtolower(\substr($sValue, -1, 1));
return (int) $sValue * \pow(1024, \array_search($sUnit, [1 => 'k', 'm', 'g']));
}
public static function markOptimized(string $file): void
{
$metafile = self::getMetaFile();
$metafileDir = \dirname($metafile);
try {
if (!\file_exists($metafileDir.'/index.html') || !\file_exists($metafileDir.'/.htaccess')) {
$html = <<<'HTML'
<html><head><title></title></head><body></body></html>
HTML;
File::write($metafileDir.'/index.html', $html);
$htaccess = <<<APACHECONFIG
order deny,allow
deny from all
<IfModule mod_autoindex.c>
\tOptions -Indexes
</IfModule>
APACHECONFIG;
File::write($metafileDir.'/.htaccess', $htaccess);
}
} catch (FilesystemException $e) {
}
if (\is_dir($metafileDir)) {
$file = self::normalizePath($file);
$file = '[ROOT]/'.\ltrim(self::subtractPath($file, Paths::rootPath()), '/').\PHP_EOL;
if (!\in_array($file, self::getOptimizedFiles())) {
File::write($metafile, $file, \false, \true);
}
}
}
public static function getMetaFile(): string
{
return Paths::rootPath().\DIRECTORY_SEPARATOR.'.jch'.\DIRECTORY_SEPARATOR.'jch-api2.txt';
}
public static function getOptimizedFiles(): array
{
static $optimizeds = null;
if (\is_null($optimizeds)) {
$optimizeds = self::getCurrentOptimizedFiles();
}
return $optimizeds;
}
public static function filterOptimizedFiles(array $images): array
{
$normalizedImages = \array_map(function ($image) {
return self::normalizePath($image);
}, $images);
return \array_diff($normalizedImages, self::getOptimizedFiles());
}
public static function isAlreadyOptimized(string $image): bool
{
return \in_array(self::normalizePath($image), self::getOptimizedFiles());
}
/**
* @param null|(mixed|string)[]|string $file
*
* @psalm-param array<mixed|string>|null|string $file
*/
public static function unmarkOptimized($file)
{
$metafile = self::getMetaFile();
if (!@\file_exists($metafile)) {
return;
}
$aOptimizedFile = self::getCurrentOptimizedFiles();
if (($key = \array_search($file, $aOptimizedFile)) !== \false) {
unset($aOptimizedFile[$key]);
}
$sContents = \implode(\PHP_EOL, $aOptimizedFile).\PHP_EOL;
\file_put_contents($metafile, $sContents);
}
public static function proOnlyField(): string
{
return '<fieldset style="padding: 5px 5px 0 0; color:darkred"><em>Only available in Pro Version!</em></fieldset>';
}
public static function subtractPath(string $minuend, string $subtrahend): string
{
$minuendNormalized = self::normalizePath($minuend);
$subtrahendNormalized = self::normalizePath($subtrahend);
if (0 === \strpos($minuendNormalized, $subtrahendNormalized)) {
return \substr($minuend, \strlen($subtrahend));
}
return $minuend;
}
public static function normalizePath(string $path): string
{
return \rawurldecode((string) Utils::uriFor($path));
}
/**
* @return string[]
*
* @psalm-return list<string>
*/
protected static function getCurrentOptimizedFiles(): array
{
$metafile = self::getMetaFile();
if (!\file_exists($metafile)) {
return [];
}
$optimizeds = \file($metafile, \FILE_IGNORE_NEW_LINES);
if (\false === $optimizeds) {
$optimizeds = [];
} else {
$optimizeds = \array_map(function (string $value) {
return \str_replace('[ROOT]', Paths::rootPath(), $value);
}, $optimizeds);
}
return $optimizeds;
}
}

View File

@@ -0,0 +1,243 @@
<?php
/**
* JCH Optimize - Performs several front-end optimizations for fast downloads.
*
* @author Samuel Marshall <samuel@jch-optimize.net>
* @copyright Copyright (c) 2022 Samuel Marshall / JCH Optimize
* @license GNU/GPLv3, or later. See LICENSE file
*
* If LICENSE file missing, see <http://www.gnu.org/licenses/>.
*/
namespace JchOptimize\Core\Admin;
use _JchOptimizeVendor\Joomla\DI\ContainerAwareInterface;
use _JchOptimizeVendor\Joomla\DI\ContainerAwareTrait;
use JchOptimize\Model\ModeSwitcher;
use JchOptimize\Platform\Cache;
use JchOptimize\Platform\Paths;
use JchOptimize\Platform\Utility;
use Joomla\CMS\Language\Text;
use Joomla\Registry\Registry;
\defined('_JCH_EXEC') or exit('Restricted access');
class Icons implements ContainerAwareInterface
{
use ContainerAwareTrait;
private Registry $params;
public function __construct(Registry $params)
{
$this->params = $params;
}
public static function getAutoSettingsArray(): array
{
return [['name' => 'Minimum', 'icon' => 'minimum.png', 'setting' => 1], ['name' => 'Intermediate', 'icon' => 'intermediate.png', 'setting' => 2], ['name' => 'Average', 'icon' => 'average.png', 'setting' => 3], ['name' => 'Deluxe', 'icon' => 'deluxe.png', 'setting' => 4], ['name' => 'Premium', 'icon' => 'premium.png', 'setting' => 5], ['name' => 'Optimum', 'icon' => 'optimum.png', 'setting' => 6]];
}
public function printIconsHTML($buttons): string
{
$sIconsHTML = '';
foreach ($buttons as $button) {
$sContentAttr = Utility::bsTooltipContentAttribute();
$sTooltip = @$button['tooltip'] ? " class=\"hasPopover fig-caption\" title=\"{$button['name']}\" {$sContentAttr}=\"{$button['tooltip']}\" " : ' class="fig-caption"';
$sIconSrc = Paths::iconsUrl().'/'.$button['icon'];
$sToggle = '<span class="toggle-wrapper" ><i class="toggle fa"></i></span>';
$onClickFalse = '';
if (!JCH_PRO && !empty($button['proonly'])) {
$button['link'] = '';
$button['script'] = '';
$button['class'] = 'disabled proonly';
$sToggle = '<span id="proonly-span"><em>(Pro Only)</em></span>';
$onClickFalse = ' onclick="return false;"';
}
$sIconsHTML .= <<<HTML
<figure id="{$button['id']}" class="icon {$button['class']}">
\t<a href="{$button['link']}" class="btn" {$button['script']}{$onClickFalse}>
\t\t<img src="{$sIconSrc}" alt="" width="50" height="50" />
\t\t<span{$sTooltip}>{$button['name']}</span>
\t\t{$sToggle}
\t</a>
</figure>
HTML;
}
return $sIconsHTML;
}
public function compileAutoSettingsIcons($settings): array
{
$buttons = [];
for ($i = 0; $i < \count($settings); ++$i) {
$id = $this->generateIdFromName($settings[$i]['name']);
$buttons[$i]['link'] = '';
$buttons[$i]['icon'] = $settings[$i]['icon'];
$buttons[$i]['name'] = $settings[$i]['name'];
$buttons[$i]['script'] = "onclick=\"jchPlatform.applyAutoSettings('{$settings[$i]['setting']}', '{$id}', '".Utility::getNonce('s'.$settings[$i]['setting'])."'); return false;\"";
$buttons[$i]['id'] = $id;
$buttons[$i]['class'] = 'auto-setting disabled';
$buttons[$i]['tooltip'] = \htmlspecialchars(self::generateAutoSettingTooltip($settings[$i]['setting']));
}
$sCombineFilesEnable = $this->params->get('combine_files_enable', '0');
$aParamsArray = $this->params->toArray();
$aAutoSettings = self::autoSettingsArrayMap();
$aAutoSettingsInit = \array_map(function ($a) {
return '0';
}, $aAutoSettings);
$aCurrentAutoSettings = \array_intersect_key($aParamsArray, $aAutoSettingsInit);
// order array
$aCurrentAutoSettings = \array_merge($aAutoSettingsInit, $aCurrentAutoSettings);
if ($sCombineFilesEnable) {
for ($j = 0; $j < 6; ++$j) {
if (\array_values($aCurrentAutoSettings) === \array_column($aAutoSettings, 's'.($j + 1))) {
$buttons[$j]['class'] = 'auto-setting enabled';
break;
}
}
}
return $buttons;
}
public static function autoSettingsArrayMap(): array
{
return ['css' => ['s1' => '1', 's2' => '1', 's3' => '1', 's4' => '1', 's5' => '1', 's6' => '1'], 'javascript' => ['s1' => '1', 's2' => '1', 's3' => '1', 's4' => '1', 's5' => '1', 's6' => '1'], 'gzip' => ['s1' => '0', 's2' => '1', 's3' => '1', 's4' => '1', 's5' => '1', 's6' => '1'], 'css_minify' => ['s1' => '0', 's2' => '1', 's3' => '1', 's4' => '1', 's5' => '1', 's6' => '1'], 'js_minify' => ['s1' => '0', 's2' => '1', 's3' => '1', 's4' => '1', 's5' => '1', 's6' => '1'], 'html_minify' => ['s1' => '0', 's2' => '1', 's3' => '1', 's4' => '1', 's5' => '1', 's6' => '1'], 'includeAllExtensions' => ['s1' => '0', 's2' => '0', 's3' => '1', 's4' => '1', 's5' => '1', 's6' => '1'], 'replaceImports' => ['s1' => '0', 's2' => '0', 's3' => '0', 's4' => '1', 's5' => '1', 's6' => '1'], 'phpAndExternal' => ['s1' => '0', 's2' => '0', 's3' => '0', 's4' => '1', 's5' => '1', 's6' => '1'], 'inlineStyle' => ['s1' => '0', 's2' => '0', 's3' => '0', 's4' => '1', 's5' => '1', 's6' => '1'], 'inlineScripts' => ['s1' => '0', 's2' => '0', 's3' => '0', 's4' => '1', 's5' => '1', 's6' => '1'], 'bottom_js' => ['s1' => '0', 's2' => '0', 's3' => '0', 's4' => '0', 's5' => '1', 's6' => '1'], 'loadAsynchronous' => ['s1' => '0', 's2' => '0', 's3' => '0', 's4' => '0', 's5' => '0', 's6' => '1']];
}
public function getApi2UtilityArray(): array
{
return self::getUtilityArray(['restoreimages', 'deletebackups']);
}
/**
* @param string[] $actions
*
* @psalm-param list{0?: 'restoreimages', 1?: 'deletebackups'} $actions
*/
public function getUtilityArray(array $actions = []): array
{
$aUtilities = [$action = 'browsercaching' => ['action' => $action, 'icon' => 'browser_caching.png', 'name' => 'Optimize .htaccess', 'tooltip' => Utility::translate('Use this button to add codes to your htaccess file to enable leverage browser caching and gzip compression.')], $action = 'filepermissions' => ['action' => $action, 'icon' => 'file_permissions.png', 'name' => 'Fix file permissions', 'tooltip' => Utility::translate("If your site has lost CSS formatting after enabling the plugin, the problem could be that the plugin files were installed with incorrect file permissions so the browser cannot access the cached combined file. Click here to correct the plugin's file permissions.")], $action = 'cleancache' => ['action' => $action, 'icon' => 'clean_cache.png', 'name' => 'Clean Cache', 'tooltip' => Utility::translate("Click this button to clean the plugin's cache and page cache. If you have edited any CSS or JavaScript files you need to clean the cache so the changes can be visible.")], $action = 'orderplugins' => ['action' => $action, 'icon' => 'order_plugin.png', 'name' => 'Order Plugin', 'tooltip' => Utility::translate('The published order of the plugin is important! When you click on this icon, it will attempt to order the plugin correctly.')], $action = 'keycache' => ['action' => $action, 'icon' => 'keycache.png', 'name' => 'Generate new cache key', 'tooltip' => Utility::translate("If you've made any changes to your files generate a new cache key to counter browser caching of the old content.")], $action = 'recache' => ['action' => $action, 'icon' => 'recache.png', 'name' => 'Recache', 'proonly' => \true, 'tooltip' => Utility::translate('Rebuild the cache for all the pages of the site.')], $action = 'bulksettings' => ['action' => $action, 'icon' => 'bulk_settings.png', 'name' => 'Bulk Setting Operations', 'tooltip' => Utility::translate('Opens a modal that provides options to import/export settings, or restore to default values.'), 'script' => 'onclick="loadBulkSettingsModal(); return false;"'], $action = 'restoreimages' => ['action' => $action, 'icon' => 'restoreimages.png', 'name' => 'Restore Original Images,', 'tooltip' => Utility::translate("If you're not satisfied with the images that were optimized you can restore the original ones by clicking this button if they were not deleted. This will also remove any webp image created from the restored file."), 'proonly' => \true], $action = 'deletebackups' => ['action' => $action, 'icon' => 'deletebackups.png', 'name' => 'Delete Backup Images', 'tooltip' => Utility::translate("This will permanently delete the images that were backed up. There's no way to undo this so be sure you're satisfied with the ones that were optimized before clicking this button."), 'proonly' => \true, 'script' => 'onclick="return confirm(\'Are you sure? This cannot be undone!\');"']];
if (empty($actions)) {
return $aUtilities;
}
return \array_intersect_key($aUtilities, \array_flip($actions));
}
public function compileUtilityIcons($utilities): array
{
$icons = [];
$i = 0;
foreach ($utilities as $utility) {
$icons[$i]['link'] = Paths::adminController($utility['action']);
$icons[$i]['icon'] = $utility['icon'];
$icons[$i]['name'] = Utility::translate($utility['name']);
$icons[$i]['id'] = $this->generateIdFromName($utility['name']);
$icons[$i]['tooltip'] = @$utility['tooltip'] ?: \false;
$icons[$i]['script'] = @$utility['script'] ?: '';
$icons[$i]['class'] = '';
$icons[$i]['proonly'] = @$utility['proonly'] ?: \false;
++$i;
}
return $icons;
}
public function getToggleSettings(): array
{
$pageCacheTooltip = '';
if (\JCH_PLATFORM == 'Joomla!') {
$pageCacheTooltip = '<strong>[';
if (JCH_PRO) {
$modeSwitcher = $this->container->get(ModeSwitcher::class);
$integratedPageCache = $modeSwitcher->getIntegratedPageCachePlugin();
$pageCacheTooltip .= Text::_($modeSwitcher->pageCachePlugins[$integratedPageCache]);
} else {
$pageCacheTooltip .= Text::_('COM_JCHOPTIMIZE_SYSTEM_PAGE_CACHE');
}
$pageCacheTooltip .= ']</strong><br><br>';
}
$pageCacheTooltip .= Utility::translate('Toggles on/off the Page Cache feature.');
return [['name' => 'Add Image Attributes', 'setting' => $setting = 'img_attributes_enable', 'icon' => 'img_attributes.png', 'enabled' => $this->params->get($setting, '0'), 'tooltip' => Utility::translate('Adds \'height\' and/or \'width\' attributes to &lt:img&gt;\'s, if missing, to reduce CLS.')], ['name' => 'Sprite Generator', 'setting' => $setting = 'csg_enable', 'icon' => 'sprite_gen.png', 'enabled' => $this->params->get($setting, '0'), 'tooltip' => Utility::translate('Combines select background images into a sprite.')], ['name' => 'Http/2 Push', 'setting' => $setting = 'http2_push_enable', 'icon' => 'http2_push.png', 'enabled' => $this->params->get($setting, '0'), 'tooltip' => Utility::translate('Preloads critical assets using the http/2 protocol to improve LCP.')], ['name' => 'Lazy Load Images', 'setting' => $setting = 'lazyload_enable', 'icon' => 'lazyload.png', 'enabled' => $this->params->get($setting, '0'), 'tooltip' => Utility::translate('Defer images that fall below the fold.')], ['name' => 'Optimize CSS Delivery', 'setting' => $setting = 'optimizeCssDelivery_enable', 'icon' => 'optimize_css_delivery.png', 'enabled' => $this->params->get($setting, '0'), 'tooltip' => Utility::translate('Eliminates CSS render-blocking')], ['name' => 'Optimize Fonts', 'setting' => $setting = 'pro_optimizeFonts_enable', 'icon' => 'optimize_gfont.png', 'enabled' => $this->params->get($setting, '0'), 'proonly' => \true, 'tooltip' => Utility::translate('Optimizes the loading of fonts, including Google Fonts.')], ['name' => 'CDN', 'setting' => $setting = 'cookielessdomain_enable', 'icon' => 'cdn.png', 'enabled' => $this->params->get($setting, '0'), 'tooltip' => Utility::translate('Loads static assets from a CDN server. Requires the CDN domain(s) to be configured on the Configuration tab.')], ['name' => 'Smart Combine', 'setting' => $setting = 'pro_smart_combine', 'icon' => 'smart_combine.png', 'enabled' => $this->params->get($setting, '0'), 'proonly' => \true, 'tooltip' => Utility::translate('Intelligently combines files in a number of smaller files, instead of one large file for better http2 delivery.')], ['name' => 'Load Webp', 'setting' => $setting = 'pro_load_webp_images', 'icon' => 'webp.png', 'enabled' => $this->params->get($setting, '0'), 'proonly' => \true, 'tooltip' => Utility::translate('Loads generated WEBP images in place of the original ones. These images must be generated on the Optimize Image tab first.')], ['name' => 'Page Cache', 'setting' => 'integrated_page_cache_enable', 'icon' => 'cache.png', 'enabled' => Cache::isPageCacheEnabled($this->params), 'tooltip' => $pageCacheTooltip]];
}
public function getCombineFilesEnableSetting(): array
{
return [['name' => 'Combine Files Enable', 'setting' => $setting = 'combine_files_enable', 'icon' => 'combine_files_enable.png', 'enabled' => $this->params->get($setting, '1')]];
}
public function getAdvancedToggleSettings(): array
{
return [['name' => 'Reduce Unused CSS', 'setting' => $setting = 'pro_reduce_unused_css', 'icon' => 'reduce_unused_css.png', 'enabled' => $this->params->get($setting, '0'), 'proonly' => \true, 'tooltip' => Utility::translate('Loads only the critical CSS required for rendering the page above the fold until user interacts with the page. Requires Optimize CSS Delivery to be enabled and may need the CSS Dynamic Selectors setting to be configured to work properly.')], ['name' => 'Reduce Unused JavaScript', 'setting' => $setting = 'pro_reduce_unused_js_enable', 'icon' => 'reduce_unused_js.png', 'enabled' => $this->params->get($setting, '0'), 'proonly' => \true, 'tooltip' => Utility::translate('Will defer the loading of JavaScript until the user interacts with the page to improve performance affected by unused JavaScript. If your site uses JavaScript to perform the initial render you may need to \'exclude\' these critical JavaScript. These will be bundled together, preloaded and loaded asynchronously.')], ['name' => 'Reduce DOM', 'setting' => $setting = 'pro_reduce_dom', 'icon' => 'reduce_dom.png', 'enabled' => $this->params->get($setting, '0'), 'proonly' => \true, 'tooltip' => Utility::translate('\'Defers\' the loading of some HTML block elements to speed up page rendering.')]];
}
public function compileToggleFeaturesIcons($settings): array
{
$buttons = [];
for ($i = 0; $i < \count($settings); ++$i) {
// id of figure icon
$id = $this->generateIdFromName($settings[$i]['name']);
$setting = $settings[$i]['setting'];
$nonce = Utility::getNonce($setting);
// script to run when icon is clicked
$script = <<<JS
onclick="jchPlatform.toggleSetting('{$setting}', '{$id}', '{$nonce}'); return false;"
JS;
$buttons[$i]['link'] = '';
$buttons[$i]['icon'] = $settings[$i]['icon'];
$buttons[$i]['name'] = Utility::translate($settings[$i]['name']);
$buttons[$i]['id'] = $id;
$buttons[$i]['script'] = $script;
$buttons[$i]['class'] = $settings[$i]['enabled'] ? 'enabled' : 'disabled';
$buttons[$i]['proonly'] = !empty($settings[$i]['proonly']);
$buttons[$i]['tooltip'] = @$settings[$i]['tooltip'] ?: \false;
}
return $buttons;
}
private function generateIdFromName($name): string
{
return \strtolower(\str_replace([' ', '/'], ['-', ''], \trim($name)));
}
private function generateAutoSettingTooltip($setting): string
{
$aAutoSettingsMap = self::autoSettingsArrayMap();
$aCurrentSettingValues = \array_column($aAutoSettingsMap, 's'.$setting);
$aCurrentSettingArray = \array_combine(\array_keys($aAutoSettingsMap), $aCurrentSettingValues);
$aSetting = \array_map(function ($v) {
return '1' == $v ? 'on' : 'off';
}, $aCurrentSettingArray);
return <<<HTML
<h4 class="list-header">CSS</h4>
<ul class="unstyled list-unstyled">
<li>Combine CSS <i class="toggle fa {$aSetting['css']}"></i></li>
<li>Minify CSS <i class="toggle fa {$aSetting['css_minify']}"></i></li>
<li>Resolve @imports <i class="toggle fa {$aSetting['replaceImports']}"></i></li>
<li>Include in-page styles <i class="toggle fa {$aSetting['inlineStyle']}"></i></li>
</ul>
<h4 class="list-header">JavaScript</h4>
<ul class="unstyled list-unstyled">
<li>Combine JavaScript <i class="toggle fa {$aSetting['javascript']}"></i></li>
<li>Minify JavaScript <i class="toggle fa {$aSetting['js_minify']}"></i></li>
<li>Include in-page scripts <i class="toggle fa {$aSetting['inlineScripts']}"></i></li>
<li>Place JavaScript at bottom <i class="toggle fa {$aSetting['bottom_js']}"></i></li>
<li>Defer/Async JavaScript <i class="toggle fa {$aSetting['loadAsynchronous']}"></i></li>
</ul>
<h4 class="list-header">Combine files</h4>
<ul class="unstyled list-unstyled">
<li>Gzip JavaScript/CSS <i class="toggle fa {$aSetting['gzip']}"></i> </li>
<li>Minify HTML <i class="toggle fa {$aSetting['html_minify']}"></i> </li>
<li>Include third-party files <i class="toggle fa {$aSetting['includeAllExtensions']}"></i></li>
<li>Include external files <i class="toggle fa {$aSetting['phpAndExternal']}"></i></li>
</ul>
HTML;
}
}

View File

@@ -0,0 +1,76 @@
<?php
/**
* JCH Optimize - Performs several front-end optimizations for fast downloads.
*
* @author Samuel Marshall <samuel@jch-optimize.net>
* @copyright Copyright (c) 2022 Samuel Marshall / JCH Optimize
* @license GNU/GPLv3, or later. See LICENSE file
*
* If LICENSE file missing, see <http://www.gnu.org/licenses/>.
*/
namespace JchOptimize\Core\Admin;
// No direct access
\defined('_JCH_EXEC') or exit('Restricted access');
class Json
{
/**
* Determines whether the request was successful.
*/
public bool $success = \true;
/**
* The response message.
*/
public string $message = '';
/**
* The error code.
*/
public int $code = 0;
/**
* The response data.
*
* @var mixed
*/
public $data = '';
/**
* Constructor.
*
* @param mixed $response The Response data
* @param string $message The response message
*/
public function __construct($response = null, $message = '')
{
$this->message = $message;
// Check if we are dealing with an error
if ($response instanceof \Exception) {
// Prepare the error response
$this->success = \false;
$this->message = $response->getMessage();
$this->code = $response->getCode();
} else {
$this->data = $response;
}
}
/**
* Magic toString method for sending the response in JSON format.
*
* @return string The response in JSON format
*/
public function __toString()
{
@\header('Content-Type: application/json; charset=utf-8');
if (\version_compare(\PHP_VERSION, '7.2', '>=')) {
return \json_encode($this, \JSON_INVALID_UTF8_SUBSTITUTE);
}
return \json_encode($this);
}
}

View File

@@ -0,0 +1,395 @@
<?php
/**
* JCH Optimize - Performs several front-end optimizations for fast downloads.
*
* @author Samuel Marshall <samuel@jch-optimize.net>
* @copyright Copyright (c) 2022 Samuel Marshall / JCH Optimize
* @license GNU/GPLv3, or later. See LICENSE file
*
* If LICENSE file missing, see <http://www.gnu.org/licenses/>.
*/
namespace JchOptimize\Core\Admin;
use _JchOptimizeVendor\Laminas\Cache\Pattern\CallbackCache;
use CodeAlfa\Minify\Css;
use CodeAlfa\Minify\Html;
use CodeAlfa\Minify\Js;
use JchOptimize\ContainerFactory;
use JchOptimize\Core\Combiner;
use JchOptimize\Core\Css\Sprite\Generator;
use JchOptimize\Core\Exception;
use JchOptimize\Core\FeatureHelpers\LazyLoadExtended;
use JchOptimize\Core\FileUtils;
use JchOptimize\Core\Helper;
use JchOptimize\Core\Html\ElementObject;
use JchOptimize\Core\Html\FilesManager;
use JchOptimize\Core\Html\Parser;
use JchOptimize\Core\Html\Processor as HtmlProcessor;
use JchOptimize\Core\SerializableTrait;
use JchOptimize\Core\SystemUri;
use JchOptimize\Core\Uri\Utils;
use JchOptimize\Platform\Excludes;
use JchOptimize\Platform\Profiler;
use Joomla\Registry\Registry;
use function explode;
\defined('_JCH_EXEC') or exit('Restricted access');
class MultiSelectItems implements \Serializable
{
use SerializableTrait;
protected array $links = [];
private Registry $params;
private CallbackCache $callbackCache;
private FileUtils $fileUtils;
/**
* Constructor.
*/
public function __construct(Registry $params, CallbackCache $callbackCache, FileUtils $fileUtils)
{
$this->params = $params;
$this->callbackCache = $callbackCache;
$this->fileUtils = $fileUtils;
}
public function prepareStyleValues(string $style): string
{
return $this->prepareScriptValues($style);
}
public function prepareScriptValues(string $script): string
{
if (\strlen($script) > 52) {
$script = \substr($script, 0, 52);
$eps = '...';
$script = $script.$eps;
}
if (\strlen($script) > 26) {
$script = \str_replace($script[26], $script[26]."\n", $script);
}
return $script;
}
public function prepareImagesValues(string $image): string
{
return $image;
}
public function prepareFolderValues($folder): string
{
return $this->fileUtils->prepareForDisplay(Utils::uriFor($folder));
}
public function prepareFileValues($file): string
{
return $this->fileUtils->prepareForDisplay(Utils::uriFor($file));
}
public function prepareClassValues($class): string
{
return $this->fileUtils->prepareForDisplay(null, $class, \false);
}
/**
* Returns a multidimensional array of items to populate the multi-select exclude lists in the
* admin settings section.
*
* @param string $html HTML before it's optimized by JCH Optimize
* @param string $css Combined css contents
* @param bool $bCssJsOnly True if we're only interested in css and js values only as in smart combine
*
* @throws \Exception
*/
public function getAdminLinks(string $html = '', string $css = '', bool $bCssJsOnly = \false): array
{
if (empty($this->links)) {
$aFunction = [$this, 'generateAdminLinks'];
$aArgs = [$html, $css, $bCssJsOnly];
$this->links = $this->callbackCache->call($aFunction, $aArgs);
}
return $this->links;
}
public function generateAdminLinks(string $html, string $css, bool $bCssJsOnly): array
{
!JCH_DEBUG ?: Profiler::start('GenerateAdminLinks');
// We need to get a new instance of the container here as we'll be changing the params, and we don't want to mess things up
$container = ContainerFactory::getNewContainerInstance();
$params = $container->get('params');
$params->set('combine_files_enable', '1');
$params->set('pro_smart_combine', '0');
$params->set('javascript', '1');
$params->set('css', '1');
$params->set('gzip', '0');
$params->set('css_minify', '0');
$params->set('js_minify', '0');
$params->set('html_minify', '0');
$params->set('defer_js', '0');
$params->set('debug', '0');
$params->set('bottom_js', '1');
$params->set('includeAllExtensions', '1');
$params->set('excludeCss', []);
$params->set('excludeJs', []);
$params->set('excludeAllStyles', []);
$params->set('excludeAllScripts', []);
$params->set('excludeJs_peo', []);
$params->set('excludeJsComponents_peo', []);
$params->set('excludeScripts_peo', []);
$params->set('excludeCssComponents', []);
$params->set('excludeJsComponents', []);
$params->set('csg_exclude_images', []);
$params->set('csg_include_images', []);
$params->set('phpAndExternal', '1');
$params->set('inlineScripts', '1');
$params->set('inlineStyle', '1');
$params->set('replaceImports', '0');
$params->set('loadAsynchronous', '0');
$params->set('cookielessdomain_enable', '0');
$params->set('lazyload_enable', '0');
$params->set('optimizeCssDelivery_enable', '0');
$params->set('pro_excludeLazyLoad', []);
$params->set('pro_excludeLazyLoadFolders', []);
$params->set('pro_excludeLazyLoadClass', []);
$params->set('pro_reduce_unused_css', '0');
$params->set('pro_reduce_unused_js_enable', '0');
$params->set('pro_reduce_dom', '0');
try {
// If we're doing multiselect it's better to fetch the HTML here than send it as an args
// to prevent different cache keys generating when passed through callback cache
if ('' == $html) {
/** @var \JchOptimize\Platform\Html $oHtml */
$oHtml = $container->get(\JchOptimize\Core\Admin\AbstractHtml::class);
$html = $oHtml->getHomePageHtml();
}
/** @var HtmlProcessor $oHtmlProcessor */
$oHtmlProcessor = $container->get(HtmlProcessor::class);
$oHtmlProcessor->setHtml($html);
$oHtmlProcessor->processCombineJsCss();
/** @var FilesManager $oFilesManager */
$oFilesManager = $container->get(FilesManager::class);
$aLinks = ['css' => $oFilesManager->aCss, 'js' => $oFilesManager->aJs];
// Only need css and js links if we're doing smart combine
if ($bCssJsOnly) {
return $aLinks;
}
if ('' == $css && !empty($aLinks['css'][0])) {
$oCombiner = $container->get(Combiner::class);
$aResult = $oCombiner->combineFiles($aLinks['css'][0], 'css');
$css = $aResult['content'];
}
if (\JCH_PRO) {
$aLinks['criticaljs'] = $aLinks['js'];
$aLinks['modules'] = [];
foreach ($oFilesManager->defers as $deferGroup) {
if ('defer' == $deferGroup[0]['attributeType'] || 'async' == $deferGroup[0]['attributeType']) {
foreach ($deferGroup as $defer) {
$aLinks['criticaljs'][0][]['url'] = $defer['url'];
}
}
if ('module' == $deferGroup[0]['attributeType']) {
foreach ($deferGroup as $defer) {
if (isset($defer['url'])) {
$aLinks['modules'][0][]['url'] = $defer['url'];
}
}
}
}
}
/** @var Generator $oSpriteGenerator */
$oSpriteGenerator = $container->get(Generator::class);
$aLinks['images'] = $oSpriteGenerator->processCssUrls($css, \true);
$oHtmlParser = new Parser();
$oHtmlParser->addExclude(Parser::htmlCommentToken());
$oHtmlParser->addExclude(Parser::htmlElementsToken(['script', 'noscript', 'textarea']));
$oElement = new ElementObject();
$oElement->setNamesArray(['img', 'iframe', 'input']);
$oElement->bSelfClosing = \true;
$oElement->addNegAttrCriteriaRegex('(?:data-(?:src|original))');
$oElement->setCaptureAttributesArray(['class', 'src']);
$oHtmlParser->addElementObject($oElement);
$aMatches = $oHtmlParser->findMatches($oHtmlProcessor->getBodyHtml());
if (\JCH_PRO) {
$aLinks['lazyloadclass'] = LazyLoadExtended::getLazyLoadClass($aMatches);
}
$aLinks['lazyload'] = \array_map(function ($a) {
return Utils::uriFor($a);
}, $aMatches[7]);
} catch (Exception\ExceptionInterface $e) {
$aLinks = [];
}
!JCH_DEBUG ?: Profiler::stop('GenerateAdminLinks', \true);
return $aLinks;
}
public function prepareFieldOptions(string $type, string $excludeParams, string $group = '', bool $bIncludeExcludes = \true): array
{
if ('lazyload' == $type) {
$fieldOptions = $this->getLazyLoad($group);
$group = 'file';
} elseif ('images' == $type) {
$group = 'file';
$aM = \explode('_', $excludeParams);
$fieldOptions = $this->getImages($aM[1]);
} else {
$fieldOptions = $this->getOptions($type, $group.'s');
}
$options = [];
$excludes = Helper::getArray($this->params->get($excludeParams, []));
foreach ($excludes as $exclude) {
if (\is_array($exclude)) {
foreach ($exclude as $key => $value) {
if ('url' == $key && \is_string($value)) {
$options[$value] = $this->prepareGroupValues($group, $value);
}
}
} else {
$options[$exclude] = $this->prepareGroupValues($group, $exclude);
}
}
// Should we include saved exclude parameters?
if ($bIncludeExcludes) {
return \array_merge($fieldOptions, $options);
}
return \array_diff($fieldOptions, $options);
}
public function getLazyLoad(string $group): array
{
$aLinks = $this->links;
$aFieldOptions = [];
if ('file' == $group || 'folder' == $group) {
if (!empty($aLinks['lazyload'])) {
foreach ($aLinks['lazyload'] as $imageUri) {
if ('folder' == $group) {
$regex = '#(?<!/)/[^/\\n]++$|(?<=^)[^/.\\n]++$#';
$i = 0;
$imageUrl = $this->fileUtils->prepareForDisplay($imageUri, '', \false);
$folder = \preg_replace($regex, '', $imageUrl);
while (\preg_match($regex, $folder)) {
$aFieldOptions[$folder] = $this->fileUtils->prepareForDisplay(Utils::uriFor($folder));
$folder = \preg_replace($regex, '', $folder);
++$i;
if (12 == $i) {
break;
}
}
} else {
$imageUrl = $this->fileUtils->prepareForDisplay($imageUri, '', \false);
$aFieldOptions[$imageUrl] = $this->fileUtils->prepareForDisplay($imageUri);
}
}
}
} elseif ('class' == $group) {
if (!empty($aLinks['lazyloadclass'])) {
foreach ($aLinks['lazyloadclass'] as $sClasses) {
$aClass = \preg_split('# #', $sClasses, -1, \PREG_SPLIT_NO_EMPTY);
foreach ($aClass as $sClass) {
$aFieldOptions[$sClass] = $sClass;
}
}
}
}
return \array_filter($aFieldOptions);
}
/**
* @staticvar string $sUriBase
* @staticvar string $sUriPath
*
* @return false|string
*/
public function prepareExtensionValues(string $url, bool $return = \true)
{
if ($return) {
return $url;
}
static $host = '';
$oUri = SystemUri::currentUri();
$host = '' == $host ? $oUri->getHost() : $host;
$result = \preg_match('#^(?:https?:)?//([^/]+)#', $url, $m1);
$extension = $m1[1] ?? '';
if (0 === $result || $extension == $host) {
$result2 = \preg_match('#'.Excludes::extensions().'([^/]+)#', $url, $m);
if (0 === $result2) {
return \false;
}
$extension = $m[1];
}
return $extension;
}
protected function getImages(string $action = 'exclude'): array
{
$aLinks = $this->links;
$aOptions = [];
if (!empty($aLinks['images'][$action])) {
foreach ($aLinks['images'][$action] as $sImage) {
// $aImage = explode('/', $sImage);
// $sImage = array_pop($aImage);
$aOptions = \array_merge($aOptions, [$sImage => $this->fileUtils->prepareForDisplay($sImage)]);
}
}
return \array_unique($aOptions);
}
protected function getOptions(string $type, string $group = 'files'): array
{
$aLinks = $this->links;
$aOptions = [];
if (!empty($aLinks[$type][0])) {
foreach ($aLinks[$type][0] as $aLink) {
if (isset($aLink['url']) && '' != (string) $aLink['url']) {
if ('files' == $group) {
$file = $this->fileUtils->prepareForDisplay($aLink['url'], '', \false);
$aOptions[$file] = $this->fileUtils->prepareForDisplay($aLink['url']);
} elseif ('extensions' == $group) {
$extension = $this->prepareExtensionValues($aLink['url'], \false);
if (\false === $extension) {
continue;
}
$aOptions[$extension] = $extension;
}
} elseif (isset($aLink['content']) && '' != $aLink['content']) {
if ('scripts' == $group) {
$script = Html::cleanScript($aLink['content'], 'js');
$script = \trim(Js::optimize($script));
} elseif ('styles' == $group) {
$script = Html::cleanScript($aLink['content'], 'css');
$script = \trim(Css::optimize($script));
}
if (isset($script)) {
if (\strlen($script) > 60) {
$script = \substr($script, 0, 60);
}
$script = \htmlspecialchars($script);
$aOptions[\addslashes($script)] = $this->prepareScriptValues($script);
}
}
}
}
return $aOptions;
}
private function prepareGroupValues(string $group, string $value)
{
return $this->{'prepare'.\ucfirst($group).'Values'}($value);
}
}

View File

@@ -0,0 +1,270 @@
<?php
/**
* JCH Optimize - Performs several front-end optimizations for fast downloads.
*
* @author Samuel Marshall <samuel@jch-optimize.net>
* @copyright Copyright (c) 2022 Samuel Marshall / JCH Optimize
* @license GNU/GPLv3, or later. See LICENSE file
*
* If LICENSE file missing, see <http://www.gnu.org/licenses/>.
*/
namespace JchOptimize\Core\Admin;
use JchOptimize\ContainerFactory;
use JchOptimize\Core\Admin\Ajax\OptimizeImage;
use JchOptimize\Core\Admin\Helper as AdminHelper;
use JchOptimize\Core\FeatureHelpers\Webp;
use JchOptimize\Platform\Paths;
use JchOptimize\Platform\Plugin;
use Joomla\Filesystem\Exception\FilesystemException;
use Joomla\Filesystem\File;
use Joomla\Filesystem\Folder;
use Joomla\Registry\Registry;
use Psr\Log\LoggerInterface;
use Psr\Log\NullLogger;
\defined('_JCH_EXEC') or exit('Restricted access');
class Tasks
{
public static string $startHtaccessLine = '## BEGIN EXPIRES CACHING - JCH OPTIMIZE ##';
public static string $endHtaccessLine = '## END EXPIRES CACHING - JCH OPTIMIZE ##';
/**
* @return ?string
*/
public static function leverageBrowserCaching(?bool &$success = null): ?string
{
$htaccess = Paths::rootPath().'/.htaccess';
if (\file_exists($htaccess)) {
$contents = \file_get_contents($htaccess);
$cleanContents = \preg_replace(self::getHtaccessRegex(), \PHP_EOL, $contents);
$startLine = self::$startHtaccessLine;
$endLine = self::$endHtaccessLine;
$expires = <<<APACHECONFIG
{$startLine}
<IfModule mod_expires.c>
\tExpiresActive on
\t# Your document html
\tExpiresByType text/html "access plus 0 seconds"
\t# Data
\tExpiresByType text/xml "access plus 0 seconds"
\tExpiresByType application/xml "access plus 0 seconds"
\tExpiresByType application/json "access plus 0 seconds"
\t# Feed
\tExpiresByType application/rss+xml "access plus 1 hour"
\tExpiresByType application/atom+xml "access plus 1 hour"
\t# Favicon (cannot be renamed)
\tExpiresByType image/x-icon "access plus 1 week"
\t# Media: images, video, audio
\tExpiresByType image/gif "access plus 1 year"
\tExpiresByType image/png "access plus 1 year"
\tExpiresByType image/jpg "access plus 1 year"
\tExpiresByType image/jpeg "access plus 1 year"
\tExpiresByType image/webp "access plus 1 year"
\tExpiresByType audio/ogg "access plus 1 year"
\tExpiresByType video/ogg "access plus 1 year"
\tExpiresByType video/mp4 "access plus 1 year"
\tExpiresByType video/webm "access plus 1 year"
\t# HTC files (css3pie)
\tExpiresByType text/x-component "access plus 1 year"
\t# Webfonts
\tExpiresByType image/svg+xml "access plus 1 year"
\tExpiresByType font/* "access plus 1 year"
\tExpiresByType application/x-font-ttf "access plus 1 year"
\tExpiresByType application/x-font-truetype "access plus 1 year"
\tExpiresByType application/x-font-opentype "access plus 1 year"
\tExpiresByType application/font-ttf "access plus 1 year"
\tExpiresByType application/font-woff "access plus 1 year"
\tExpiresByType application/font-woff2 "access plus 1 year"
\tExpiresByType application/vnd.ms-fontobject "access plus 1 year"
\tExpiresByType application/font-sfnt "access plus 1 year"
\t# CSS and JavaScript
\tExpiresByType text/css "access plus 1 year"
\tExpiresByType text/javascript "access plus 1 year"
\tExpiresByType application/javascript "access plus 1 year"
\t<IfModule mod_headers.c>
\t\tHeader set Cache-Control "no-cache, no-store, must-revalidate"
\t\t
\t\t<FilesMatch "\\.(js|css|ttf|woff2?|svg|png|jpe?g|webp|webm|mp4|ogg)(\\.gz)?\$">
\t\t\tHeader set Cache-Control "public"\t
\t\t\tHeader set Vary: Accept-Encoding
\t\t</FilesMatch>
\t\t#Some server not properly recognizing WEBPs
\t\t<FilesMatch "\\.webp\$">
\t\t\tHeader set Content-Type "image/webp"
\t\t\tExpiresDefault "access plus 1 year"
\t\t</FilesMatch>\t
\t</IfModule>
</IfModule>
<IfModule mod_brotli.c>
\t<IfModule mod_filter.c>
\t\tAddOutputFilterByType BROTLI_COMPRESS text/html text/xml text/plain
\t\tAddOutputFilterByType BROTLI_COMPRESS application/rss+xml application/xml application/xhtml+xml
\t\tAddOutputFilterByType BROTLI_COMPRESS text/css
\t\tAddOutputFilterByType BROTLI_COMPRESS text/javascript application/javascript application/x-javascript
\t\tAddOutputFilterByType BROTLI_COMPRESS image/x-icon image/svg+xml
\t\tAddOutputFilterByType BROTLI_COMPRESS application/rss+xml
\t\tAddOutputFilterByType BROTLI_COMPRESS application/font application/font-truetype application/font-ttf
\t\tAddOutputFilterByType BROTLI_COMPRESS application/font-otf application/font-opentype
\t\tAddOutputFilterByType BROTLI_COMPRESS application/font-woff application/font-woff2
\t\tAddOutputFilterByType BROTLI_COMPRESS application/vnd.ms-fontobject
\t\tAddOutputFilterByType BROTLI_COMPRESS font/ttf font/otf font/opentype font/woff font/woff2
\t</IfModule>
</IfModule>
<IfModule mod_deflate.c>
\t<IfModule mod_filter.c>
\t\tAddOutputFilterByType DEFLATE text/html text/xml text/plain
\t\tAddOutputFilterByType DEFLATE application/rss+xml application/xml application/xhtml+xml
\t\tAddOutputFilterByType DEFLATE text/css
\t\tAddOutputFilterByType DEFLATE text/javascript application/javascript application/x-javascript
\t\tAddOutputFilterByType DEFLATE image/x-icon image/svg+xml
\t\tAddOutputFilterByType DEFLATE application/rss+xml
\t\tAddOutputFilterByType DEFLATE application/font application/font-truetype application/font-ttf
\t\tAddOutputFilterByType DEFLATE application/font-otf application/font-opentype
\t\tAddOutputFilterByType DEFLATE application/font-woff application/font-woff2
\t\tAddOutputFilterByType DEFLATE application/vnd.ms-fontobject
\t\tAddOutputFilterByType DEFLATE font/ttf font/otf font/opentype font/woff font/woff2
\t</IfModule>
</IfModule>
# Don't compress files with extension .gz or .br
<IfModule mod_rewrite.c>
\tRewriteRule "\\.(gz|br)\$" "-" [E=no-gzip:1,E=no-brotli:1]
</IfModule>
<IfModule !mod_rewrite.c>
\t<IfModule mod_setenvif.c>
\t\tSetEnvIfNoCase Request_URI \\.(gz|br)\$ no-gzip no-brotli
\t</IfModule>
</IfModule>
{$endLine}
APACHECONFIG;
$expires = \str_replace(["\r\n", "\n"], \PHP_EOL, $expires);
$str = $expires.$cleanContents;
$success = File::write($htaccess, $str);
return null;
}
return 'FILEDOESNTEXIST';
}
public static function cleanHtaccess(): void
{
$htaccess = Paths::rootPath().'/.htaccess';
if (\file_exists($htaccess)) {
$contents = \file_get_contents($htaccess);
$cleanContents = \preg_replace(self::getHtaccessRegex(), '', $contents, -1, $count);
if ($count > 0) {
File::write($htaccess, $cleanContents);
}
}
}
/**
* @return string|true
*
* @psalm-return 'BACKUPPATHDOESNTEXIST'|'SOMEIMAGESDIDNTRESTORE'|true
*/
public static function restoreBackupImages(?LoggerInterface $logger = null)
{
if (\is_null($logger)) {
$logger = new NullLogger();
}
$backupPath = Paths::backupImagesParentDir().OptimizeImage::$backup_folder_name;
if (!\is_dir($backupPath)) {
return 'BACKUPPATHDOESNTEXIST';
}
$aFiles = Folder::files($backupPath, '.', \false, \true, []);
$failure = \false;
foreach ($aFiles as $backupContractedFile) {
$success = \false;
$aPotentialOriginalFilePaths = [AdminHelper::expandFileName($backupContractedFile), AdminHelper::expandFileNameLegacy($backupContractedFile)];
foreach ($aPotentialOriginalFilePaths as $originalFilePath) {
if (@\file_exists($originalFilePath)) {
// Attempt to restore backup images
if (AdminHelper::copyImage($backupContractedFile, $originalFilePath)) {
try {
if (\file_exists(Webp::getWebpPath($originalFilePath))) {
File::delete(Webp::getWebpPath($originalFilePath));
}
if (\file_exists(Webp::getWebpPathLegacy($originalFilePath))) {
File::delete(Webp::getWebpPathLegacy($originalFilePath));
}
if (\file_exists($backupContractedFile)) {
File::delete($backupContractedFile);
}
AdminHelper::unmarkOptimized($originalFilePath);
$success = \true;
break;
} catch (FilesystemException $e) {
$logger->debug('Error deleting '.Webp::getWebpPath($originalFilePath).' with message: '.$e->getMessage());
}
} else {
$logger->debug('Error copying image '.$backupContractedFile);
}
}
}
if (!$success) {
$logger->debug('File not found: '.$backupContractedFile);
$logger->debug('Potential file paths: '.\print_r($aPotentialOriginalFilePaths, \true));
$failure = \true;
}
}
\clearstatcache();
if ($failure) {
return 'SOMEIMAGESDIDNTRESTORE';
}
self::deleteBackupImages();
return \true;
}
/**
* @return bool|string
*
* @psalm-return 'BACKUPPATHDOESNTEXIST'|bool
*/
public static function deleteBackupImages()
{
$backupPath = Paths::backupImagesParentDir().OptimizeImage::$backup_folder_name;
if (!\is_dir($backupPath)) {
return 'BACKUPPATHDOESNTEXIST';
}
return Folder::delete($backupPath);
}
public static function generateNewCacheKey(): void
{
$container = ContainerFactory::getContainer();
$rand = \rand();
/** @var Registry $params */
$params = $container->get('params');
$params->set('cache_random_key', $rand);
Plugin::saveSettings($params);
}
private static function getHtaccessRegex(): string
{
return '#[\\r\\n]*'.\preg_quote(self::$startHtaccessLine).'.*?'.\preg_quote(\rtrim(self::$endHtaccessLine, "# \n\r\t\v\x00")).'[^\\r\\n]*[\\r\\n]*#s';
}
}

View File

@@ -0,0 +1,57 @@
<?php
/**
* JCH Optimize - Performs several front-end optimizations for fast downloads.
*
* @author Samuel Marshall <samuel@jch-optimize.net>
* @copyright Copyright (c) 2022 Samuel Marshall / JCH Optimize
* @license GNU/GPLv3, or later. See LICENSE file
*
* If LICENSE file missing, see <http://www.gnu.org/licenses/>.
*/
namespace JchOptimize\Core;
use JchOptimize\Platform\Utility;
\defined('_JCH_EXEC') or exit('Restricted access');
class Browser
{
/**
* @var Browser[]
*/
protected static array $instances = [];
/**
* @var object{browser: string, browserVersion: string, os: string}
*/
protected object $oClient;
public function __construct(string $userAgent)
{
$this->oClient = Utility::userAgent($userAgent);
}
public static function getInstance(string $userAgent = ''): Browser
{
if ('' == $userAgent && isset($_SERVER['HTTP_USER_AGENT'])) {
$userAgent = \trim($_SERVER['HTTP_USER_AGENT']);
}
$signature = \md5($userAgent);
if (!isset(self::$instances[$signature])) {
self::$instances[$signature] = new \JchOptimize\Core\Browser($userAgent);
}
return self::$instances[$signature];
}
public function getBrowser(): string
{
return $this->oClient->browser;
}
public function getVersion(): string
{
return $this->oClient->browserVersion;
}
}

View File

@@ -0,0 +1,236 @@
<?php
/**
* JCH Optimize - Performs several front-end optimizations for fast downloads.
*
* @author Samuel Marshall <samuel@jch-optimize.net>
* @copyright Copyright (c) 2022 Samuel Marshall / JCH Optimize
* @license GNU/GPLv3, or later. See LICENSE file
*
* If LICENSE file missing, see <http://www.gnu.org/licenses/>.
*/
namespace JchOptimize\Core;
use _JchOptimizeVendor\GuzzleHttp\Psr7\Uri;
use _JchOptimizeVendor\GuzzleHttp\Psr7\UriResolver;
use _JchOptimizeVendor\Joomla\DI\ContainerAwareInterface;
use _JchOptimizeVendor\Joomla\DI\ContainerAwareTrait;
use _JchOptimizeVendor\Psr\Http\Message\UriInterface;
use JchOptimize\Core\Exception\RuntimeException;
use JchOptimize\Core\FeatureHelpers\CdnDomains;
use JchOptimize\Core\Uri\Utils;
use Joomla\Registry\Registry;
\defined('_JCH_EXEC') or exit('Restricted access');
class Cdn implements ContainerAwareInterface
{
use ContainerAwareTrait;
public string $scheme = '';
/** @var null|array<string, array{domain:UriInterface, extensions:string}> */
protected ?array $domains = null;
/** @var array<string, UriInterface> */
protected array $filePaths = [];
/** @var null|string[] */
protected ?array $cdnFileTypes = null;
private Registry $params;
private bool $enabled;
private string $startHtaccessLine = '## BEGIN CDN CORS POLICY - JCH OPTIMIZE ##';
private string $endHtaccessLine = '## END CDN CORS POLICY - JCH OPTIMIZE ##';
public function __construct(Registry $params)
{
$this->params = $params;
$this->enabled = (bool) $this->params->get('cookielessdomain_enable', '0');
switch ($params->get('cdn_scheme', '0')) {
case '1':
$this->scheme = 'http';
break;
case '2':
$this->scheme = 'https';
break;
case '0':
default:
$this->scheme = '';
break;
}
}
/**
* Returns an array of file types that will be loaded by CDN.
*
* @return string[]
*
* @throws RuntimeException
*/
public function getCdnFileTypes(): array
{
if (null === $this->cdnFileTypes) {
$this->initialize();
}
if (null !== $this->cdnFileTypes) {
return $this->cdnFileTypes;
}
throw new RuntimeException('CDN file types not initialized');
}
/**
* Returns array of default static files to load from CDN.
*
* @return string[] Array of file type extensions
*/
public static function getStaticFiles(): array
{
return ['css', 'js', 'jpe?g', 'gif', 'png', 'ico', 'bmp', 'pdf', 'webp', 'svg'];
}
public function prepareDomain(string $domain): UriInterface
{
// If scheme not included then we need to add forward slashes to make UriInterfaces
// implementations recognize the domain
if (!\preg_match('#^(?:[^:/]++:|//)#', \trim($domain))) {
$domain = '//'.$domain;
}
return Utils::uriFor($domain)->withScheme($this->scheme);
}
public function loadCdnResource(UriInterface $uri, ?UriInterface $origPath = null): UriInterface
{
$domains = $this->getCdnDomains();
if (empty($origPath)) {
$origPath = $uri;
}
// if disabled or no domain is configured abort
if (!$this->enabled || empty($domains) || null === $this->domains) {
return $origPath;
}
// If file already loaded on CDN return
if ($this->isFileOnCdn($uri)) {
return $origPath;
}
// We're now ready to load path on CDN but let's remove query first
$path = $uri->getPath();
// If we haven't matched a cdn domain to this file yet then find one.
if (!isset($this->filePaths[$path])) {
$this->filePaths[$path] = $this->selectDomain($this->domains, $uri);
}
if ('' === (string) $this->filePaths[$path]) {
return $origPath;
}
return $this->filePaths[$path];
}
/**
* @return array<string, array{domain:UriInterface, extensions:string}>
*/
public function getCdnDomains(): array
{
if (null === $this->domains) {
$this->initialize();
}
if (null !== $this->domains) {
return $this->domains;
}
throw new RuntimeException('CDN Domains not initialized');
}
public function isFileOnCdn(UriInterface $uri): bool
{
foreach ($this->getCdnDomains() as $domainArray) {
if ($uri->getHost() === $domainArray['domain']->getHost()) {
return \true;
}
}
return \false;
}
public function updateHtaccess(): void
{
$htaccessDelimiters = ['## BEGIN CDN CORS POLICY - JCH OPTIMIZE ##', '## END CDN CORS POLICY - JCH OPTIMIZE ##'];
$origin = \JchOptimize\Core\SystemUri::currentUri()->withPort(null)->withPath('')->withQuery('')->withFragment('');
if ($this->enabled && !empty($this->getCdnDomains())) {
$htaccessContents = <<<APACHECONFIG
<IfModule mod_headers.c>
Header append Access-Control-Allow-Origin "{$origin}"
Header append Vary "Origin"
</IfModule>
APACHECONFIG;
\JchOptimize\Core\Htaccess::updateHtaccess($htaccessContents, $htaccessDelimiters);
} else {
\JchOptimize\Core\Htaccess::cleanHtaccess($htaccessDelimiters);
}
}
private function initialize(): void
{
/** @var string[] $staticFiles1Array */
$staticFiles1Array = $this->params->get('staticfiles', self::getStaticFiles());
/** @var array<string, array{domain:UriInterface, extensions:string}> $domainArray */
$domainArray = [];
$this->cdnFileTypes = [];
if ($this->enabled) {
/** @var string $domain1 */
$domain1 = $this->params->get('cookielessdomain', '');
if ('' != \trim($domain1)) {
/** @var string[] $customExtns */
$customExtns = $this->params->get('pro_customcdnextensions', []);
$sStaticFiles1 = \implode('|', \array_merge($staticFiles1Array, $customExtns));
$domainArray['domain1']['domain'] = $this->prepareDomain($domain1);
$domainArray['domain1']['extensions'] = $sStaticFiles1;
}
if (JCH_PRO) {
$this->container->get(CdnDomains::class)->addCdnDomains($domainArray);
}
}
$this->domains = $domainArray;
if (!empty($this->domains)) {
foreach ($this->domains as $domains) {
$this->cdnFileTypes = \array_merge($this->cdnFileTypes, \explode('|', $domains['extensions']));
}
$this->cdnFileTypes = \array_unique($this->cdnFileTypes);
}
}
/**
* @param array<string, array{domain:UriInterface, extensions:string}> $domainArray
*/
private function selectDomain(array &$domainArray, UriInterface $uri): UriInterface
{
// If no domain is matched to a configured file type then we'll just return the file
$cdnUri = new Uri();
for ($i = 0; \count($domainArray) > $i; ++$i) {
$domain = \current($domainArray);
$staticFiles = $domain['extensions'];
\next($domainArray);
if (\false === \current($domainArray)) {
\reset($domainArray);
}
if (\preg_match('#\\.(?>'.$staticFiles.')#i', $uri->getPath())) {
// Prepend the cdn domain to the file path if a match is found.
$cdnDomain = $domain['domain'];
// Some CDNs like Cloudinary includes path to the CDN domain to be prepended to the asset
$uri = $uri->withPath(\rtrim($cdnDomain->getPath(), '/').'/'.\ltrim($uri->getPath(), '/'));
$cdnUri = UriResolver::resolve($cdnDomain, $uri->withScheme('')->withHost(''));
break;
}
}
return $cdnUri;
}
}

View File

@@ -0,0 +1,377 @@
<?php
/**
* JCH Optimize - Performs several front-end optimizations for fast downloads.
*
* @author Samuel Marshall <samuel@jch-optimize.net>
* @copyright Copyright (c) 2022 Samuel Marshall / JCH Optimize
* @license GNU/GPLv3, or later. See LICENSE file
*
* If LICENSE file missing, see <http://www.gnu.org/licenses/>.
*/
namespace JchOptimize\Core;
use _JchOptimizeVendor\GuzzleHttp\Client;
use _JchOptimizeVendor\GuzzleHttp\Exception\GuzzleException;
use _JchOptimizeVendor\GuzzleHttp\Psr7\UriResolver;
use _JchOptimizeVendor\GuzzleHttp\Psr7\Utils;
use _JchOptimizeVendor\GuzzleHttp\RequestOptions;
use _JchOptimizeVendor\Joomla\DI\ContainerAwareInterface;
use _JchOptimizeVendor\Joomla\DI\ContainerAwareTrait;
use _JchOptimizeVendor\Laminas\Cache\Pattern\CallbackCache;
use _JchOptimizeVendor\Laminas\Cache\Storage\IterableInterface;
use _JchOptimizeVendor\Laminas\Cache\Storage\StorageInterface;
use _JchOptimizeVendor\Laminas\Cache\Storage\TaggableInterface;
use _JchOptimizeVendor\Psr\Http\Client\ClientInterface;
use _JchOptimizeVendor\Psr\Http\Message\UriInterface;
use CodeAlfa\Minify\Css;
use CodeAlfa\Minify\Js;
use CodeAlfa\RegexTokenizer\Debug\Debug;
use Exception;
use JchOptimize\Core\Css\Processor as CssProcessor;
use JchOptimize\Core\Css\Sprite\Generator;
use JchOptimize\Core\Uri\UriComparator;
use JchOptimize\Core\Uri\UriConverter;
use JchOptimize\Platform\Profiler;
use Joomla\Registry\Registry;
use Psr\Log\LoggerAwareInterface;
use Psr\Log\LoggerAwareTrait;
\defined('_JCH_EXEC') or exit('Restricted access');
/**
* Class to combine CSS/JS files together.
*/
class Combiner implements ContainerAwareInterface, LoggerAwareInterface, \Serializable
{
use ContainerAwareTrait;
use LoggerAwareTrait;
use Debug;
use \JchOptimize\Core\FileInfosUtilsTrait;
use \JchOptimize\Core\SerializableTrait;
use \JchOptimize\Core\StorageTaggingTrait;
public bool $isBackend;
private Registry $params;
private CallbackCache $callbackCache;
/**
* @var null|(Client&ClientInterface)
*/
private $http;
/**
* @var IterableInterface&StorageInterface&TaggableInterface
*/
private $taggableCache;
/**
* Constructor.
*
* @param IterableInterface&StorageInterface&TaggableInterface $taggableCache
* @param null|(Client&ClientInterface) $http
*/
public function __construct(Registry $params, CallbackCache $callbackCache, $taggableCache, FileUtils $fileUtils, $http, bool $isBackend = \false)
{
$this->params = $params;
$this->callbackCache = $callbackCache;
$this->taggableCache = $taggableCache;
$this->fileUtils = $fileUtils;
$this->http = $http;
$this->isBackend = $isBackend;
}
public function getCssContents(array $urlArray): array
{
return $this->getContents($urlArray, 'css');
}
/**
* Get aggregated and possibly minified content from js and css files.
*
* @param array $urlArray Indexed multidimensional array of urls of css or js files for aggregation
* @param string $type css or js
*
* @return array Aggregated (and possibly minified) contents of files
*
* @throws \Exception
*/
public function getContents(array $urlArray, string $type): array
{
!JCH_DEBUG ?: Profiler::start('GetContents - '.$type, \true);
$aResult = $this->combineFiles($urlArray, $type);
$sContents = $this->prepareContents($aResult['content']);
if ('css' == $type) {
if ($this->params->get('csg_enable', 0)) {
try {
/** @var Generator $oSpriteGenerator */
$oSpriteGenerator = $this->container->get(Generator::class);
$aSpriteCss = $oSpriteGenerator->getSprite($sContents);
if (!empty($aSpriteCss) && !empty($aSpriteCss['needles']) && !empty($aSpriteCss['replacements'])) {
$sContents = \str_replace($aSpriteCss['needles'], $aSpriteCss['replacements'], $sContents);
}
} catch (\Exception $ex) {
$this->logger->error($ex->getMessage());
}
}
$sContents = $aResult['import'].$sContents;
if (\function_exists('mb_convert_encoding')) {
$sContents = '@charset "utf-8";'.$sContents;
}
}
// Save contents in array to store in cache
$aContents = ['filemtime' => \time(), 'etag' => \md5($sContents), 'contents' => $sContents, 'images' => \array_unique($aResult['images']), 'font-face' => $aResult['font-face'], 'preconnects' => $aResult['preconnects'], 'gfonts' => $aResult['gfonts'], 'bgselectors' => $aResult['bgselectors']];
!JCH_DEBUG ?: Profiler::stop('GetContents - '.$type);
return $aContents;
}
/**
* Aggregate contents of CSS and JS files.
*
* @param array $fileInfosArray Array of links of files to combine
* @param string $type css|js
* @param mixed $cacheItems
*
* @return array Aggregated contents
*
* @throws \Exception
*/
public function combineFiles(array $fileInfosArray, string $type, $cacheItems = \true): array
{
$responses = ['content' => '', 'import' => '', 'font-face' => [], 'preconnects' => [], 'images' => [], 'gfonts' => [], 'bgselectors' => []];
// Iterate through each file/script to optimize and combine
foreach ($fileInfosArray as $fileInfos) {
// Truncate url to less than 40 characters
$sUrl = $this->prepareFileUrl($fileInfos, $type);
!JCH_DEBUG ?: Profiler::start('CombineFile - '.$sUrl);
// Try to store tags first
$function = [$this, 'cacheContent'];
$args = [$fileInfos, $type, \true];
$id = $this->callbackCache->generateKey($function, $args);
$this->tagStorage($id);
// If caching set and tagging was successful we attempt to cache
if ($cacheItems && !empty($this->taggableCache->getTags($id))) {
// Optimize and cache file/script returning the optimized content
$results = $this->callbackCache->call($function, $args);
// Append to combined contents
$responses['content'] .= $this->addCommentedUrl($type, $fileInfos).$results['content']."\n".'DELIMITER';
} else {
// If we're not caching just get the optimized content
$results = $this->cacheContent($fileInfos, $type, \false);
$responses['content'] .= $this->addCommentedUrl($type, $fileInfos).$results['content'].'|"LINE_END"|';
}
if ('css' == $type) {
$responses['import'] .= $results['import'];
$responses['images'] = \array_merge($responses['images'], $results['images']);
$responses['gfonts'] = \array_merge($responses['gfonts'], $results['gfonts']);
$responses['font-face'] = \array_merge($responses['font-face'], $results['font-face']);
$responses['preconnects'] = \array_merge($responses['preconnects'], $results['preconnects']);
$responses['bgselectors'] = \array_merge($responses['bgselectors'], $results['bgselectors']);
}
!JCH_DEBUG ?: Profiler::stop('CombineFile - '.$sUrl, \true);
}
return $responses;
}
/**
* Optimize and cache contents of individual file/script returning optimized content.
*/
public function cacheContent(array $fileInfos, string $type, bool $bPrepare): array
{
// Initialize content string
$content = '';
$responses = [];
// If it's a file fetch the contents of the file
if (isset($fileInfos['url'])) {
$content .= $this->getFileContents($fileInfos['url']);
// Remove zero-width non-breaking space
$content = \trim($content, '');
if (\defined('TEST_SITE_DOMAIN') && \defined('TEST_SITE_ALT_DOMAIN') && \defined('TEST_SITE_BASE')) {
$content = \str_replace(['{{{domain}}}', '{{{altdomain}}}', '{{{base}}}'], [TEST_SITE_DOMAIN, TEST_SITE_ALT_DOMAIN, TEST_SITE_BASE], $content);
}
} else {
// If it's a declaration just use it
$content .= $fileInfos['content'];
}
if ('css' == $type) {
/** @var CssProcessor $oCssProcessor */
$oCssProcessor = $this->container->get(CssProcessor::class);
$oCssProcessor->setCssInfos($fileInfos);
$oCssProcessor->setCss($content);
$oCssProcessor->formatCss();
$oCssProcessor->processUrls(\false, \false, $this->isBackend);
$oCssProcessor->processMediaQueries();
$oCssProcessor->processAtRules();
$content = $oCssProcessor->getCss();
$responses['import'] = $oCssProcessor->getImports();
$responses['images'] = $oCssProcessor->getImages();
$responses['font-face'] = $oCssProcessor->getFontFace();
$responses['gfonts'] = $oCssProcessor->getGFonts();
$responses['preconnects'] = $oCssProcessor->getPreconnects();
$responses['bgselectors'] = $oCssProcessor->getCssBgImagesSelectors();
}
if ('js' == $type && '' != \trim($content)) {
if ($this->params->get('try_catch', '1')) {
$content = $this->addErrorHandler($content, $fileInfos);
} else {
$content = $this->addSemiColon($content);
}
}
if ($bPrepare) {
$content = $this->minifyContent($content, $type, $fileInfos);
$content = $this->prepareContents($content);
}
$responses['content'] = $content;
return $responses;
}
/**
* Remove placeholders from aggregated file for caching.
*
* @param string $contents Aggregated file contents
*/
public function prepareContents(string $contents, bool $test = \false): string
{
return \str_replace(['|"COMMENT_START', '|"COMMENT_IMPORT_START', 'COMMENT_END"|', 'DELIMITER', '|"LINE_END"|'], ["\n".'/***! ', "\n\n".'/***! @import url', ' !***/'."\n\n", $test ? 'DELIMITER' : '', "\n"], \trim($contents));
}
public function getJsContents(array $urlArray): array
{
return $this->getContents($urlArray, 'js');
}
/**
* Used when you want to append the contents of files to some that are already combined, into one file.
*
* @param array $ids Array of ids of files that were already combined
* @param array $fileMatches Array of file matches to be combined
* @param string $type Type of files css|js
*
* @return array The contents of the combined files
*/
public function appendFiles(array $ids, array $fileMatches, string $type): array
{
$contents = '';
foreach ($ids as $id) {
$contents .= \JchOptimize\Core\Output::getCombinedFile(['f' => $id, 'type' => $type], \false);
}
try {
$results = $this->combineFiles($fileMatches, $type);
} catch (\Exception $e) {
$this->logger->error('Error appending files: '.$e->getMessage());
$results = ['content' => '', 'font-face' => [], 'gfonts' => [], 'images' => []];
}
$contents .= $this->prepareContents($results['content']);
$contents .= "\n".'jchOptimizeDynamicScriptLoader.next()';
return ['filemtime' => \time(), 'etag' => \md5($contents), 'contents' => $contents, 'font-face' => $results['font-face'], 'preconnects' => $results['preconnects'], 'images' => $results['images']];
}
protected function addCommentedUrl(string $type, array $fileInfos): string
{
$comment = '';
if ($this->params->get('debug', '1')) {
$fileInfos = $fileInfos['url'] ?? ('js' == $type ? 'script' : 'style').' declaration';
$comment = '|"COMMENT_START '.$fileInfos.' COMMENT_END"|';
}
return $comment;
}
private function getFileContents(UriInterface $uri): string
{
$uri = UriResolver::resolve(\JchOptimize\Core\SystemUri::currentUri(), $uri);
if (!UriComparator::isCrossOrigin($uri)) {
$filePath = UriConverter::uriToFilePath($uri);
if (\file_exists($filePath) && \JchOptimize\Core\Helper::isStaticFile($filePath)) {
try {
$stream = Utils::streamFor(Utils::tryFopen($filePath, 'r'));
if (!$stream->isReadable()) {
throw new \Exception('Stream unreadable');
}
if ($stream->isSeekable()) {
$stream->rewind();
}
return $stream->getContents();
} catch (\Exception $e) {
$this->logger->warning('Couldn\'t open file: '.$uri.'; error: '.$e->getMessage());
}
}
}
try {
$options = [RequestOptions::HEADERS => ['Accept-Enconding' => 'identity;q=0']];
$response = $this->http->get($uri, $options);
if (200 === $response->getStatusCode()) {
// Get body and set pointer to beginning of stream
$body = $response->getBody();
$body->rewind();
return $body->getContents();
}
return '|"COMMENT_START Response returned status code: '.$response->getStatusCode().' COMMENT_END"|';
} catch (GuzzleException $e) {
return '|"COMMENT_START Exception fetching file with message: '.$e->getMessage().' COMMENT_END"|';
}
}
/**
* Add try catch to contents of javascript file.
*/
private function addErrorHandler(string $content, array $fileInfos): string
{
if (empty($fileInfos['module']) || 'module' != $fileInfos['module']) {
$content = 'try {'."\n".$content."\n".'} catch (e) {'."\n";
$content .= 'console.error(\'Error in ';
$content .= isset($fileInfos['url']) ? 'file:'.$fileInfos['url'] : 'script declaration';
$content .= '; Error:\' + e.message);'."\n".'};';
}
return $content;
}
/**
* Add semicolon to end of js files if non exists;.
*/
private function addSemiColon(string $content): string
{
$content = \rtrim($content);
if (';' != \substr($content, -1) && !\preg_match('#\\|"COMMENT_START File[^"]+not found COMMENT_END"\\|#', $content)) {
$content = $content.';';
}
return $content;
}
/**
* Minify contents of fil.
*
* @return string $sMinifiedContent Minified content or original content if failed
*/
private function minifyContent(string $content, string $type, array $fileInfos): string
{
if ($this->params->get($type.'_minify', 0)) {
$url = $this->prepareFileUrl($fileInfos, $type);
$minifiedContent = \trim('css' == $type ? Css::optimize($content) : Js::optimize($content));
// @TODO inject Exception class into minifier libraries
if (0 !== \preg_last_error()) {
$this->logger->error(\sprintf('Error occurred trying to minify: %s', $url));
$minifiedContent = $content;
}
$this->_debug($url, '', 'minifyContent');
return $minifiedContent;
}
return $content;
}
}

View File

@@ -0,0 +1,80 @@
<?php
/**
* JCH Optimize - Performs several front-end optimizations for fast downloads.
*
* @author Samuel Marshall <samuel@jch-optimize.net>
* @copyright Copyright (c) 2023 Samuel Marshall / JCH Optimize
* @license GNU/GPLv3, or later. See LICENSE file
*
* If LICENSE file missing, see <http://www.gnu.org/licenses/>.
*/
namespace JchOptimize\Core\Container;
use _JchOptimizeVendor\Laminas\EventManager\SharedEventManager;
use _JchOptimizeVendor\Laminas\EventManager\SharedEventManagerInterface;
use JchOptimize\ContainerFactory;
use JchOptimize\Core\Service\CachingConfigurationProvider;
use JchOptimize\Core\Service\CachingProvider;
use JchOptimize\Core\Service\CallbackProvider;
use JchOptimize\Core\Service\CoreProvider;
use JchOptimize\Core\Service\FeatureHelpersProvider;
use JchOptimize\Core\Service\IlluminateViewFactoryProvider;
use JchOptimize\Core\Service\SpatieProvider;
abstract class AbstractContainerFactory
{
/**
* @var null|Container
*/
protected static ?\JchOptimize\Core\Container\Container $instance = null;
/**
* Used to create a new global instance of Joomla/DI/Container or in cases where the container isn't
* accessible by dependency injection.
*/
public static function getContainer(): Container
{
if (\is_null(self::$instance)) {
self::$instance = self::getNewContainerInstance();
}
return self::$instance;
}
/**
* Used to return a new instance of the Container when we're making changes we don't want to affect the
* global container.
*/
public static function getNewContainerInstance(): Container
{
$ContainerFactory = new ContainerFactory();
$container = new \JchOptimize\Core\Container\Container();
$ContainerFactory->registerCoreProviders($container);
$ContainerFactory->registerPlatformProviders($container);
return $container;
}
/**
* For use with test cases.
*/
public static function destroy(): void
{
self::$instance = null;
}
protected function registerCoreProviders(Container $container): void
{
$container->alias(SharedEventManager::class, SharedEventManagerInterface::class)->share(SharedEventManagerInterface::class, new SharedEventManager(), \true)->registerServiceProvider(new CoreProvider())->registerServiceProvider(new CachingConfigurationProvider())->registerServiceProvider(new CallbackProvider())->registerServiceProvider(new CachingProvider())->registerServiceProvider(new IlluminateViewFactoryProvider());
if (JCH_PRO) {
$container->registerServiceProvider(new FeatureHelpersProvider())->registerServiceProvider(new SpatieProvider());
}
}
/**
* To be implemented by JchOptimize/Container to attach service providers specific to the particular platform.
*/
abstract protected function registerPlatformProviders(Container $container): void;
}

View File

@@ -0,0 +1,17 @@
<?php
/**
* JCH Optimize - Performs several front-end optimizations for fast downloads.
*
* @author Samuel Marshall <samuel@jch-optimize.net>
* @copyright Copyright (c) 2023 Samuel Marshall / JCH Optimize
* @license GNU/GPLv3, or later. See LICENSE file
*
* If LICENSE file missing, see <http://www.gnu.org/licenses/>.
*/
namespace JchOptimize\Core\Container;
class Container extends \_JchOptimizeVendor\Joomla\DI\Container
{
}

View File

@@ -0,0 +1,37 @@
<?php
/**
* JCH Optimize - Performs several front-end optimizations for fast downloads.
*
* @author Samuel Marshall <samuel@jch-optimize.net>
* @copyright Copyright (c) 2022 Samuel Marshall / JCH Optimize
* @license GNU/GPLv3, or later. See LICENSE file
*
* If LICENSE file missing, see <http://www.gnu.org/licenses/>.
*/
namespace JchOptimize\Core\Css\Callbacks;
use _JchOptimizeVendor\Joomla\DI\ContainerAwareInterface;
use _JchOptimizeVendor\Joomla\DI\ContainerAwareTrait;
use JchOptimize\Core\Container\Container;
use Joomla\Registry\Registry;
\defined('_JCH_EXEC') or exit('Restricted access');
abstract class AbstractCallback implements ContainerAwareInterface
{
use ContainerAwareTrait;
protected Registry $params;
public function __construct(Container $container, Registry $params)
{
$this->container = $container;
$this->params = $params;
}
/**
* @param string[] $matches
*/
abstract public function processMatches(array $matches, string $context): string;
}

View File

@@ -0,0 +1,124 @@
<?php
/**
* JCH Optimize - Performs several front-end optimizations for fast downloads.
*
* @author Samuel Marshall <samuel@jch-optimize.net>
* @copyright Copyright (c) 2022 Samuel Marshall / JCH Optimize
* @license GNU/GPLv3, or later. See LICENSE file
*
* If LICENSE file missing, see <http://www.gnu.org/licenses/>.
*/
namespace JchOptimize\Core\Css\Callbacks;
\defined('_JCH_EXEC') or exit('Restricted access');
class CombineMediaQueries extends \JchOptimize\Core\Css\Callbacks\AbstractCallback
{
/**
* @var string[]
*/
private array $cssInfos = [];
public function processMatches(array $matches, string $context): string
{
if (empty($this->cssInfos['media'])) {
return $matches[0];
}
if ('media' == $context) {
return '@media '.$this->combineMediaQueries($this->cssInfos['media'], \trim(\substr($matches[2], 6))).'{'.$matches[4].'}';
}
if ('import' == $context) {
$sMediaQuery = $matches[7];
$sAtImport = \substr($matches[0], 0, -\strlen($sMediaQuery.';'));
return $sAtImport.' '.$this->combineMediaQueries($this->cssInfos['media'], $sMediaQuery).';';
}
return '@media '.$this->cssInfos['media'].'{'.$matches[0].'}';
}
public function setCssInfos($cssInfos): void
{
$this->cssInfos = $cssInfos;
}
protected function combineMediaQueries(string $parentMediaQueryList, string $childMediaQueryList): string
{
$parentMediaQueries = \preg_split('#\\s++or\\s++|,#i', $parentMediaQueryList);
$childMediaQueries = \preg_split('#\\s++or\\s++|,#i', $childMediaQueryList);
// $aMediaTypes = array('all', 'aural', 'braille', 'handheld', 'print', 'projection', 'screen', 'tty', 'tv', 'embossed');
$mediaQueries = [];
foreach ($parentMediaQueries as $parentMediaQuery) {
$parentMediaQueryMatches = $this->parseMediaQuery(\trim($parentMediaQuery));
foreach ($childMediaQueries as $childMediaQuery) {
$mediaQuery = '';
$childMediaQueryMatches = $this->parseMediaQuery(\trim($childMediaQuery));
if ('only' == $parentMediaQueryMatches['keyword'] || 'only' == $childMediaQueryMatches['keyword']) {
$mediaQuery .= 'only ';
}
if ('not' == $parentMediaQueryMatches['keyword'] && '' == $childMediaQueryMatches['keyword']) {
if ('all' == $parentMediaQueryMatches['media_type']) {
$mediaQuery .= '(not '.$parentMediaQueryMatches['media_type'].')';
} elseif ($parentMediaQueryMatches['media_type'] == $childMediaQueryMatches['media_type']) {
$mediaQuery .= '(not '.$parentMediaQueryMatches['media_type'].') and '.$childMediaQueryMatches['media_type'];
} else {
$mediaQuery .= $childMediaQueryMatches['media_type'];
}
} elseif ('' == $parentMediaQueryMatches['keyword'] && 'not' == $childMediaQueryMatches['keyword']) {
if ('all' == $childMediaQueryMatches['media_type']) {
$mediaQuery .= '(not '.$childMediaQueryMatches['media_type'].')';
} elseif ($parentMediaQueryMatches['media_type'] == $childMediaQueryMatches['media_type']) {
$mediaQuery .= $parentMediaQueryMatches['media_type'].' and (not '.$childMediaQueryMatches['media_type'].')';
} else {
$mediaQuery .= $childMediaQueryMatches['media_type'];
}
} elseif ('not' == $parentMediaQueryMatches['keyword'] && 'not' == $childMediaQueryMatches['keyword']) {
$mediaQuery .= 'not '.$childMediaQueryMatches['keyword'];
} else {
if ($parentMediaQueryMatches['media_type'] == $childMediaQueryMatches['media_type'] || 'all' == $parentMediaQueryMatches['media_type']) {
$mediaQuery .= $childMediaQueryMatches['media_type'];
} elseif ('all' == $childMediaQueryMatches['media_type']) {
$mediaQuery .= $parentMediaQueryMatches['media_type'];
} else {
// Two different media types are nested and neither is 'all' then
// the enclosed rule will not be applied on any media type
// We put 'not all' to maintain a syntactically correct combined media type
$mediaQuery .= 'not all';
// Don't bother including media features in the media query
$mediaQueries[] = $mediaQuery;
continue;
}
}
if (isset($parentMediaQueryMatches['expression'])) {
$mediaQuery .= ' and '.$parentMediaQueryMatches['expression'];
}
if (isset($childMediaQueryMatches['expression'])) {
$mediaQuery .= ' and '.$childMediaQueryMatches['expression'];
}
$mediaQueries[] = $mediaQuery;
}
}
return \implode(', ', \array_unique($mediaQueries));
}
protected function parseMediaQuery(string $sMediaQuery): array
{
$aParts = [];
$sMediaQuery = \preg_replace(['#\\(\\s++#', '#\\s++\\)#'], ['(', ')'], $sMediaQuery);
\preg_match('#(?:\\(?(not|only)\\)?)?\\s*+(?:\\(?(all|screen|print|speech|aural|tv|tty|projection|handheld|braille|embossed)\\)?)?(?:\\s++and\\s++)?(.++)?#si', $sMediaQuery, $aMatches);
$aParts['keyword'] = isset($aMatches[1]) ? \strtolower($aMatches[1]) : '';
if (isset($aMatches[2]) && '' != $aMatches[2]) {
$aParts['media_type'] = \strtolower($aMatches[2]);
} else {
$aParts['media_type'] = 'all';
}
if (isset($aMatches[3]) && '' != $aMatches[3]) {
$aParts['expression'] = $aMatches[3];
}
return $aParts;
}
}

View File

@@ -0,0 +1,152 @@
<?php
/**
* JCH Optimize - Performs several front-end optimizations for fast downloads.
*
* @author Samuel Marshall <samuel@jch-optimize.net>
* @copyright Copyright (c) 2022 Samuel Marshall / JCH Optimize
* @license GNU/GPLv3, or later. See LICENSE file
*
* If LICENSE file missing, see <http://www.gnu.org/licenses/>.
*/
namespace JchOptimize\Core\Css\Callbacks;
use _JchOptimizeVendor\GuzzleHttp\Psr7\Uri;
use _JchOptimizeVendor\GuzzleHttp\Psr7\UriResolver;
use _JchOptimizeVendor\Joomla\DI\Container;
use JchOptimize\Core\Cdn;
use JchOptimize\Core\Css\Parser;
use JchOptimize\Core\FeatureHelpers\LazyLoadExtended;
use JchOptimize\Core\FeatureHelpers\Webp;
use JchOptimize\Core\Http2Preload;
use JchOptimize\Core\SystemUri;
use JchOptimize\Core\Uri\UriComparator;
use JchOptimize\Core\Uri\Utils;
use Joomla\Registry\Registry;
\defined('_JCH_EXEC') or exit('Restricted access');
class CorrectUrls extends \JchOptimize\Core\Css\Callbacks\AbstractCallback
{
/** @var bool True if this callback is called when preloading assets for HTTP/2 */
public bool $isHttp2 = \false;
/** @var bool If Optimize CSS Delivery is disabled, only fonts are preloaded */
public bool $isFontsOnly = \false;
/** @var bool If run from admin we populate the array */
public bool $isBackend = \false;
public Cdn $cdn;
public Http2Preload $http2Preload;
public array $cssBgImagesSelectors = [];
private array $images = [];
/** @var array An array of external domains that we'll add preconnects for */
private array $preconnects = [];
private array $cssInfos;
public function __construct(Container $container, Registry $params, Cdn $cdn, Http2Preload $http2Preload)
{
parent::__construct($container, $params);
$this->cdn = $cdn;
$this->http2Preload = $http2Preload;
}
public function processMatches(array $matches, string $context): string
{
$sRegex = '(?>u?[^u]*+)*?\\K(?:'.Parser::cssUrlWithCaptureValueToken(\true).'|$)';
if ('import' == $context) {
$sRegex = Parser::cssAtImportWithCaptureValueToken(\true);
}
$css = \preg_replace_callback('#'.$sRegex.'#i', function ($aInnerMatches) use ($context) {
return $this->processInnerMatches($aInnerMatches, $context);
}, $matches[0]);
// Lazy-load background images
if (JCH_PRO && $this->params->get('lazyload_enable', '0') && $this->params->get('pro_lazyload_bgimages', '0') && !\in_array($context, ['font-face', 'import'])) {
// @see LazyLoadExtended::handleCssBgImages()
return $this->getContainer()->get(LazyLoadExtended::class)->handleCssBgImages($this, $css);
}
return $css;
}
public function setCssInfos($cssInfos): void
{
$this->cssInfos = $cssInfos;
}
public function getImages(): array
{
return $this->images;
}
public function getPreconnects(): array
{
return $this->preconnects;
}
public function getCssBgImagesSelectors(): array
{
return $this->cssBgImagesSelectors;
}
/**
* @param string[] $matches
* @param mixed $context
*
* @psalm-param array<string> $matches
*/
protected function processInnerMatches(array $matches, $context)
{
if (empty($matches[0])) {
return $matches[0];
}
$originalUri = Utils::uriFor($matches[1]);
if ('data' !== $originalUri->getScheme() && '' != $originalUri->getPath() && '/' != $originalUri->getPath()) {
// The urls were already corrected on a previous run, we're only preloading assets in critical CSS and return
if ($this->isHttp2) {
$sFileType = 'font-face' == $context ? 'font' : 'image';
// If Optimize CSS Delivery not enabled, we'll only preload fonts.
if ($this->isFontsOnly && 'font' != $sFileType) {
return \false;
}
$this->http2Preload->add($originalUri, $sFileType);
return \true;
}
// Get the url of the file that contained the CSS
$cssFileUri = empty($this->cssInfos['url']) ? new Uri() : $this->cssInfos['url'];
$cssFileUri = UriResolver::resolve(SystemUri::currentUri(), $cssFileUri);
$imageUri = UriResolver::resolve($cssFileUri, $originalUri);
if (!UriComparator::isCrossOrigin($imageUri)) {
$imageUri = $this->cdn->loadCdnResource($imageUri);
} elseif ($this->params->get('pro_optimizeFonts_enable', '0')) {
// Cache external domains to add preconnects for them
$domain = Uri::composeComponents($imageUri->getScheme(), $imageUri->getAuthority(), '', '', '');
if (!\in_array($domain, $this->preconnects)) {
$this->preconnects[] = $domain;
}
}
if ($this->isBackend && 'font-face' != $context) {
$this->images[] = $imageUri;
}
if (JCH_PRO && $this->params->get('pro_load_webp_images', '0')) {
/** @see Webp::getWebpImages() */
$imageUri = $this->getContainer()->get(Webp::class)->getWebpImages($imageUri) ?? $imageUri;
}
// If URL without quotes and contains any parentheses, whitespace characters,
// single quotes (') and double quotes (") that are part of the URL, quote URL
if (\false !== \strpos($matches[0], 'url('.$originalUri.')') && \preg_match('#[()\\s\'"]#', $imageUri)) {
$imageUri = '"'.$imageUri.'"';
}
return \str_replace($matches[1], $imageUri, $matches[0]);
}
return $matches[0];
}
}

View File

@@ -0,0 +1,343 @@
<?php
/**
* JCH Optimize - Performs several front-end optimizations for fast downloads.
*
* @author Samuel Marshall <samuel@jch-optimize.net>
* @copyright Copyright (c) 2022 Samuel Marshall / JCH Optimize
* @license GNU/GPLv3, or later. See LICENSE file
*
* If LICENSE file missing, see <http://www.gnu.org/licenses/>.
*/
namespace JchOptimize\Core\Css\Callbacks;
use CodeAlfa\RegexTokenizer\Debug\Debug;
use JchOptimize\Core\Css\Parser;
use JchOptimize\Core\FeatureHelpers\DynamicSelectors;
use function str_replace;
\defined('_JCH_EXEC') or exit('Restricted access');
class ExtractCriticalCss extends \JchOptimize\Core\Css\Callbacks\AbstractCallback
{
use Debug;
public string $sHtmlAboveFold;
public string $sFullHtml;
public \DOMXPath $oXPath;
public string $postCss = '';
public string $preCss = '';
public bool $isPostProcessing = \false;
protected string $criticalCss = '';
public function processMatches(array $matches, string $context): string
{
$this->_debug($matches[0], $matches[0], 'beginExtractCriticalCss');
if ('font-face' == $context || 'keyframes' == $context) {
if (!$this->isPostProcessing) {
// If we're not processing font-face or keyframes yet let's just save them for later until after we've done getting all the
// critical css
$this->postCss .= $matches[0];
return '';
}
if ('font-face' == $context) {
\preg_match('#font-family\\s*+:\\s*+[\'"]?('.Parser::stringValueToken().'|[^;}]++)[\'"]?#i', $matches[0], $aM);
// Only include fonts in the critical CSS that are being used above the fold
// @TODO prevent duplication of fonts in critical css
if (!empty($aM[1]) && \false !== \stripos($this->criticalCss, $aM[1])) {
// $this->aFonts[] = $aM[1];
return $matches[0];
}
return '';
}
$sRule = \preg_replace('#@[^\\s{]*+\\s*+#', '', $matches[2]);
if (!empty($sRule) && \false !== \stripos($this->criticalCss, $sRule)) {
return $matches[0];
}
return '';
}
// We'll compile these to prepend to the critical CSS, imported Google font files will never be expanded.
if ('import' == $context) {
$this->preCss .= $matches[0];
}
// We're only interested in global and conditional css
if (!\in_array($context, ['global', 'media', 'supports', 'document'])) {
return '';
}
if (JCH_PRO) {
// @see DynamicSelectors::getDynamicSelectors()
if ($this->getContainer()->get(DynamicSelectors::class)->getDynamicSelectors($matches)) {
$this->appendToCriticalCss($matches[0]);
$this->_debug('', '', 'afterAddDynamicCss');
return $matches[0];
}
$this->_debug('', '', 'afterSearchDynamicCss');
}
$sSelectorGroup = $matches[2];
// Split selector groups into individual selector chains
$aSelectorChains = \array_filter(\explode(',', $sSelectorGroup));
$aFoundSelectorChains = [];
// Iterate through each selector chain
foreach ($aSelectorChains as $sSelectorChain) {
// If selector chain is a pseudo selector we'll add this group
if (\preg_match('#^:#', $sSelectorChain)) {
$this->appendToCriticalCss($matches[0]);
return $matches[0];
}
// Remove pseudo-selectors
$sSelectorChain = \preg_replace('#::?[a-zA-Z0-9-]++(\\((?>[^()]|(?1))*\\))?#', '', $sSelectorChain);
// If Selector chain is already in critical css just go ahead and add this group
if (\false !== \strpos($this->criticalCss, $sSelectorChain)) {
$this->appendToCriticalCss($matches[0]);
// Retain matched CSS in combined CSS
return $matches[0];
}
// Check CSS selector chain against HTMl above the fold to find a match
if ($this->checkCssAgainstHtml($sSelectorChain, $this->sHtmlAboveFold)) {
// Match found, add selector chain to array
$aFoundSelectorChains[] = $sSelectorChain;
}
}
// If no valid selector chain was found in the group then we don't add this selector group to the critical CSS
if (empty($aFoundSelectorChains)) {
$this->_debug($sSelectorGroup, $matches[0], 'afterSelectorNotFound');
// Don't add to critical css
return '';
}
// Group the found selector chains
$sFoundSelectorGroup = \implode(',', \array_unique($aFoundSelectorChains));
// remove any backslash used for escaping
// $sFoundSelectorGroup = str_replace('\\', '', $sFoundSelectorGroup);
$this->_debug($sFoundSelectorGroup, $matches[0], 'afterSelectorFound');
$success = null;
// Convert the selector group to Xpath
$sXPath = $this->convertCss2XPath($sFoundSelectorGroup, $success);
$this->_debug($sXPath, $matches[0], 'afterConvertCss2XPath');
if (\false !== $success) {
$aXPaths = \array_unique(\explode(' | ', \str_replace('\\', '', $sXPath)));
foreach ($aXPaths as $sXPathValue) {
$oElement = $this->oXPath->query($sXPathValue);
// if ($oElement === FALSE)
// {
// echo $aMatches[1] . "\n";
// echo $sXPath . "\n";
// echo $sXPathValue . "\n";
// echo "\n\n";
// }
// Match found! Add to critical CSS
if (\false !== $oElement && $oElement->length) {
$this->appendToCriticalCss($matches[0]);
$this->_debug($sXPathValue, $matches[0], 'afterCriticalCssFound');
return $matches[0];
}
$this->_debug($sXPathValue, $matches[0], 'afterCriticalCssNotFound');
}
}
// No match found for critical CSS.
return '';
}
public function appendToCriticalCss(string $css): void
{
$this->criticalCss .= $css;
}
/**
* @return string
*/
public function convertCss2XPath(string $sSelector, ?bool &$success = null): ?string
{
$sSelector = \preg_replace('#\\s*([>+~,])\\s*#', '$1', $sSelector);
$sSelector = \trim($sSelector);
$sSelector = \preg_replace('#\\s+#', ' ', $sSelector);
if (null === $sSelector) {
$success = \false;
return null;
}
$sSelectorRegex = '#(?!$)([>+~, ]?)([*_a-z0-9-]*)(?:(([.\\#])((?:[_a-z0-9-]|\\\\[^\\r\\n\\f0-9a-z])+))(([.\\#])((?:[_a-z0-9-]|\\\\[^\\r\\n\\f0-9a-z])+))?|(\\[((?:[_a-z0-9-]|\\\\[^\\r\\n\\f0-9a-z])+)(([~|^$*]?=)["\']?([^\\]"\']+)["\']?)?\\]))*#i';
$result = \preg_replace_callback($sSelectorRegex, [$this, '_tokenizer'], $sSelector).'[1]';
if (null === $result) {
$success = \false;
return null;
}
return $result;
}
/**
* Do a preliminary simple check to see if a CSS declaration is used by the HTML.
*
* @return bool True is all parts of the CSS selector is found in the HTML, false if not
*/
protected function checkCssAgainstHtml(string $selectorChain, string $html): bool
{
// Split selector chain into simple selectors
$aSimpleSelectors = \preg_split('#[^\\[ >+]*+(?:\\[[^\\]]*+\\])?\\K(?:[ >+]*+|$)#', \trim($selectorChain), -1, \PREG_SPLIT_NO_EMPTY);
// We'll do a quick check first if all parts of each simple selector is found in the HTML
// Iterate through each simple selector
foreach ($aSimpleSelectors as $sSimpleSelector) {
// Match the simple selector into its components
$sSimpleSelectorRegex = '#([_a-z0-9-]*)(?:([.\\#]((?:[_a-z0-9-]|\\\\[^\\r\\n\\f0-9a-z])+))|(\\[((?:[_a-z0-9-]|\\\\[^\\r\\n\\f0-9a-z])+)(?:[~|^$*]?=(?|"([^"\\]]*+)"|\'([^\'\\]]*+)\'|([^\\]]*+)))?\\]))*#i';
if (\preg_match($sSimpleSelectorRegex, $sSimpleSelector, $aS)) {
// Elements
if (!empty($aS[1])) {
$sNeedle = '<'.$aS[1];
// Just include elements that will be generated by the browser
$aDynamicElements = ['<tbody'];
if (\in_array($sNeedle, $aDynamicElements)) {
continue;
}
if (\false === \strpos($html, $sNeedle)) {
// Element part of selector not found,
// abort and check next selector chain
return \false;
}
}
// Attribute selectors
if (!empty($aS[4])) {
// If the value of the attribute is set we'll look for that
// otherwise just look for the attribute
$sNeedle = !empty($aS[6]) ? $aS[6] : $aS[5];
// . '="';
if (!empty($sNeedle) && \false === \strpos($html, \str_replace('\\', '', $sNeedle))) {
// Attribute part of selector not found,
// abort and check next selector chain
return \false;
}
}
// Ids or Classes
if (!empty($aS[2])) {
$sNeedle = ' '.$aS[3].' ';
if (\false === \strpos($html, \str_replace('\\', '', $sNeedle))) {
// The id or class part of selector not found,
// abort and check next selector chain
return \false;
}
}
// we found this Selector so let's remove it from the chain in case we need to check it
// against the HTML below the fold
\str_replace($sSimpleSelector, '', $selectorChain);
}
}
// If we get to this point then we've found a simple selector that has all parts in the
// HTML. Let's save this selector chain and refine its search with Xpath.
return \true;
}
/**
* @param string[] $aM
*/
protected function _tokenizer(array $aM): string
{
$sXPath = '';
switch ($aM[1]) {
case '>':
$sXPath .= '/';
break;
case '+':
$sXPath .= '/following-sibling::*';
break;
case '~':
$sXPath .= '/following-sibling::';
break;
case ',':
$sXPath .= '[1] | descendant-or-self::';
break;
case ' ':
$sXPath .= '/descendant::';
break;
default:
$sXPath .= 'descendant-or-self::';
break;
}
if ('+' != $aM[1]) {
$sXPath .= '' == $aM[2] ? '*' : $aM[2];
}
if (isset($aM[3]) || isset($aM[9])) {
$sXPath .= '[';
$aPredicates = [];
if (isset($aM[4]) && '.' == $aM[4]) {
$aPredicates[] = "contains(@class, ' ".$aM[5]." ')";
}
if (isset($aM[7]) && '.' == $aM[7]) {
$aPredicates[] = "contains(@class, ' ".$aM[8]." ')";
}
if (isset($aM[4]) && '#' == $aM[4]) {
$aPredicates[] = "@id = ' ".$aM[5]." '";
}
if (isset($aM[7]) && '#' == $aM[7]) {
$aPredicates[] = "@id = ' ".$aM[8]." '";
}
if (isset($aM[9])) {
if (!isset($aM[11])) {
$aPredicates[] = '@'.$aM[10];
} else {
switch ($aM[12]) {
case '=':
$aPredicates[] = "@{$aM[10]} = ' {$aM[13]} '";
break;
case '|=':
$aPredicates[] = "(@{$aM[10]} = ' {$aM[13]} ' or starts-with(@{$aM[10]}, ' {$aM[13]}'))";
break;
case '^=':
$aPredicates[] = "starts-with(@{$aM[10]}, ' {$aM[13]}')";
break;
case '$=':
$aPredicates[] = "substring(@{$aM[10]}, string-length(@{$aM[10]})-".\strlen($aM[13]).") = '{$aM[13]} '";
break;
case '~=':
$aPredicates[] = "contains(@{$aM[10]}, ' {$aM[13]} ')";
break;
case '*=':
$aPredicates[] = "contains(@{$aM[10]}, '{$aM[13]}')";
break;
default:
break;
}
}
}
if ('+' == $aM[1]) {
if ('' != $aM[2]) {
$aPredicates[] = "(name() = '".$aM[2]."')";
}
$aPredicates[] = '(position() = 1)';
}
$sXPath .= \implode(' and ', $aPredicates);
$sXPath .= ']';
}
return $sXPath;
}
}

View File

@@ -0,0 +1,28 @@
<?php
/**
* JCH Optimize - Performs several front-end optimizations for fast downloads.
*
* @author Samuel Marshall <samuel@jch-optimize.net>
* @copyright Copyright (c) 2022 Samuel Marshall / JCH Optimize
* @license GNU/GPLv3, or later. See LICENSE file
*
* If LICENSE file missing, see <http://www.gnu.org/licenses/>.
*/
namespace JchOptimize\Core\Css\Callbacks;
\defined('_JCH_EXEC') or exit('Restricted access');
class FormatCss extends \JchOptimize\Core\Css\Callbacks\AbstractCallback
{
public string $validCssRules;
public function processMatches(array $matches, string $context): string
{
if (isset($matches[7]) && !\preg_match('#'.$this->validCssRules.'#i', $matches[7])) {
return '';
}
return $matches[0];
}
}

View File

@@ -0,0 +1,110 @@
<?php
/**
* JCH Optimize - Performs several front-end optimizations for fast downloads.
*
* @author Samuel Marshall <samuel@jch-optimize.net>
* @copyright Copyright (c) 2022 Samuel Marshall / JCH Optimize
* @license GNU/GPLv3, or later. See LICENSE file
*
* If LICENSE file missing, see <http://www.gnu.org/licenses/>.
*/
namespace JchOptimize\Core\Css\Callbacks;
use JchOptimize\Core\Combiner;
use JchOptimize\Core\Html\FilesManager;
use JchOptimize\Core\Uri\Utils;
\defined('_JCH_EXEC') or exit('Restricted access');
class HandleAtRules extends \JchOptimize\Core\Css\Callbacks\AbstractCallback
{
private array $atImports = [];
private array $gFonts = [];
private array $fontFace = [];
private array $cssInfos;
public function processMatches(array $matches, string $context): string
{
if ('charset' == $context) {
return '';
}
if ('font-face' == $context) {
if (!\preg_match('#font-display#i', $matches[0])) {
$matches[0] = \preg_replace('#;?\\s*}$#', ';font-display:swap;}', $matches[0]);
} elseif (\preg_match('#font-display#i', $matches[0]) && $this->params->get('pro_force_swap_policy', '1')) {
$matches[0] = \preg_replace('#font-display[^;}]++#i', 'font-display:swap', $matches[0]);
}
if ($this->params->get('pro_optimizeFonts_enable', '0') && empty($this->cssInfos['combining-fontface'])) {
$this->fontFace[] = ['content' => $matches[0], 'media' => $this->cssInfos['media']];
return '';
}
return $matches[0];
}
// At this point we should be in import context
$uri = Utils::uriFor($matches[3]);
$media = $matches[4];
// If we're importing a Google font file we may need to optimize it
if ($this->params->get('pro_optimizeFonts_enable', '0') && 'fonts.googleapis.com' == $uri->getHost()) {
// We have to add Gfonts here so this info will be cached
$this->gFonts[] = ['url' => $uri, 'media' => $media];
return '';
}
// Don't import Google font files even if replaceImports is enabled
if (!$this->params->get('replaceImports', '0') || 'fonts.googleapis.com' == $uri->getHost()) {
$this->atImports[] = $matches[0];
return '';
}
/** @var FilesManager $oFilesManager */
$oFilesManager = $this->getContainer()->get(FilesManager::class);
if ('' == (string) $uri || 'https' == $uri->getScheme() && !\extension_loaded('openssl')) {
return $matches[0];
}
if ($oFilesManager->isDuplicated($uri)) {
return '';
}
$aUrlArray = [];
$aUrlArray[0]['url'] = $uri;
$aUrlArray[0]['media'] = $media;
/** @var Combiner $oCombiner */
$oCombiner = $this->getContainer()->get(Combiner::class);
try {
$importContents = $oCombiner->combineFiles($aUrlArray, 'css');
} catch (\Exception $e) {
return $matches[0];
}
$this->atImports = \array_merge($this->atImports, [$importContents['import']]);
$this->fontFace = \array_merge($this->fontFace, $importContents['font-face']);
$this->gFonts = \array_merge($this->gFonts, $importContents['gfonts']);
return $importContents['content'];
}
public function setCssInfos($cssInfos): void
{
$this->cssInfos = $cssInfos;
}
public function getImports(): array
{
return $this->atImports;
}
public function getGFonts(): array
{
return $this->gFonts;
}
public function getFontFace(): array
{
return $this->fontFace;
}
}

View File

@@ -0,0 +1,80 @@
<?php
/**
* JCH Optimize - Performs several front-end optimizations for fast downloads.
*
* @author Samuel Marshall <samuel@jch-optimize.net>
* @copyright Copyright (c) 2022 Samuel Marshall / JCH Optimize
* @license GNU/GPLv3, or later. See LICENSE file
*
* If LICENSE file missing, see <http://www.gnu.org/licenses/>.
*/
namespace JchOptimize\Core\Css;
\defined('_JCH_EXEC') or exit('Restricted access');
class CssSearchObject
{
protected array $aCssRuleCriteria = [];
protected array $aCssAtRuleCriteria = [];
protected array $aCssNestedRuleNames = [];
protected array $aCssCustomRule = [];
protected bool $bIsCssCommentSet = \false;
public function setCssRuleCriteria(string $sCriteria): void
{
$this->aCssRuleCriteria[] = $sCriteria;
}
public function getCssRuleCriteria(): array
{
return $this->aCssRuleCriteria;
}
public function setCssAtRuleCriteria(string $sCriteria): void
{
$this->aCssAtRuleCriteria[] = $sCriteria;
}
public function getCssAtRuleCriteria(): array
{
return $this->aCssAtRuleCriteria;
}
public function setCssNestedRuleName(string $sNestedRule, bool $bRecurse = \false, bool $bEmpty = \false): void
{
$this->aCssNestedRuleNames[] = ['name' => $sNestedRule, 'recurse' => $bRecurse, 'empty-value' => $bEmpty];
}
public function getCssNestedRuleNames(): array
{
return $this->aCssNestedRuleNames;
}
public function setCssCustomRule(string $sCssCustomRule): void
{
$this->aCssCustomRule[] = $sCssCustomRule;
}
public function getCssCustomRule(): array
{
return $this->aCssCustomRule;
}
public function setCssComment(): void
{
$this->bIsCssCommentSet = \true;
}
/**
* @return false|string
*/
public function getCssComment()
{
if ($this->bIsCssCommentSet) {
return \JchOptimize\Core\Css\Parser::blockCommentToken();
}
return \false;
}
}

View File

@@ -0,0 +1,311 @@
<?php
/**
* JCH Optimize - Performs several front-end optimizations for fast downloads.
*
* @author Samuel Marshall <samuel@jch-optimize.net>
* @copyright Copyright (c) 2022 Samuel Marshall / JCH Optimize
* @license GNU/GPLv3, or later. See LICENSE file
*
* If LICENSE file missing, see <http://www.gnu.org/licenses/>.
*/
namespace JchOptimize\Core\Css;
use CodeAlfa\RegexTokenizer\Css;
use JchOptimize\Core\Exception;
\defined('_JCH_EXEC') or exit('Restricted access');
class Parser
{
use Css;
protected array $aExcludes = [];
/** @var CssSearchObject */
protected \JchOptimize\Core\Css\CssSearchObject $oCssSearchObject;
protected bool $bBranchReset = \true;
protected string $sParseTerm = '\\s*+';
public function __construct()
{
$this->aExcludes = [
self::blockCommentToken(),
self::lineCommentToken(),
self::cssRuleWithCaptureValueToken(),
self::cssAtRulesToken(),
self::cssNestedAtRulesWithCaptureValueToken(),
// Custom exclude
'\\|"(?>[^"{}]*+"?)*?[^"{}]*+"\\|',
self::cssInvalidCssToken(),
];
}
// language=RegExp
public static function cssRuleWithCaptureValueToken(bool $bCaptureValue = \false, string $sCriteria = ''): string
{
$sCssRule = '<<(?<=^|[{}/\\s;|])[^@/\\s{}]'.self::parseNoStrings().'>>\\{'.$sCriteria.'<<'.self::parse().'>>\\}';
return self::prepare($sCssRule, $bCaptureValue);
}
// language=RegExp
public static function cssAtRulesToken(): string
{
return '@\\w++\\b\\s++(?:'.self::cssIdentToken().')?(?:'.self::stringWithCaptureValueToken().'|'.self::cssUrlWithCaptureValueToken().')[^;]*+;';
}
// language=RegExp
/**
* @param (mixed|string)[] $aAtRules
*
* @psalm-param list{0?: 'font-face'|'media'|mixed, 1?: 'keyframes'|mixed, 2?: 'page'|mixed, 3?: 'font-feature-values'|mixed, 4?: 'counter-style'|mixed, 5?: 'viewport'|mixed, 6?: 'property'|mixed,...} $aAtRules
*/
public static function cssNestedAtRulesWithCaptureValueToken(array $aAtRules = [], bool $bCV = \false, bool $bEmpty = \false): string
{
$sAtRules = !empty($aAtRules) ? '(?>'.\implode('|', $aAtRules).')' : '';
$iN = $bCV ? 2 : 1;
$sValue = $bEmpty ? '\\s*+' : '(?>'.self::parse('', \true).'|(?-'.$iN.'))*+';
$sAtRules = '<<@(?:-[^-]++-)??'.$sAtRules.'[^{};]*+>>(\\{<<'.$sValue.'>>\\})';
return self::prepare($sAtRules, $bCV);
}
// language=RegExp
public static function cssInvalidCssToken(): string
{
return '[^;}@\\r\\n]*+[;}@\\r\\n]';
}
// language=RegExp
public static function cssAtImportWithCaptureValueToken(bool $bCV = \false): string
{
$sAtImport = '@import\\s++<<<'.self::stringWithCaptureValueToken($bCV).'|'.self::cssUrlWithCaptureValueToken($bCV).'>>><<[^;]*+>>;';
return self::prepare($sAtImport, $bCV);
}
// language=RegExp
public static function cssAtFontFaceWithCaptureValueToken($sCaptureValue = \false): string
{
return self::cssNestedAtRulesWithCaptureValueToken(['font-face'], $sCaptureValue);
}
// language=RegExp
public static function cssAtMediaWithCaptureValueToken($sCaptureValue = \false): string
{
return self::cssNestedAtRulesWithCaptureValueToken(['media'], $sCaptureValue);
}
// language=RegExp
public static function cssAtCharsetWithCaptureValueToken($sCaptureValue = \false): string
{
return '@charset\\s++'.self::stringWithCaptureValueToken($sCaptureValue).'[^;]*+;';
}
// language=RegExp
public static function cssAtNameSpaceToken(): string
{
return '@namespace\\s++(?:'.self::cssIdentToken().')?(?:'.self::stringWithCaptureValueToken().'|'.self::cssUrlWithCaptureValueToken().')[^;]*+;';
}
// language=RegExp
public static function cssStatementsToken(): string
{
return '(?:'.self::cssRuleWithCaptureValueToken().'|'.self::cssAtRulesToken().'|'.self::cssNestedAtRulesWithCaptureValueToken().')';
}
// language=RegExp
public static function cssMediaTypesToken(): string
{
return '(?>all|screen|print|speech|aural|tv|tty|projection|handheld|braille|embossed)';
}
public function disableBranchReset(): void
{
$this->bBranchReset = \false;
}
public function setExcludesArray($aExcludes): void
{
$this->aExcludes = $aExcludes;
}
/**
* @param Callbacks\CombineMediaQueries|Callbacks\CorrectUrls|Callbacks\ExtractCriticalCss|Callbacks\FormatCss|Callbacks\HandleAtRules $oCallback
*
* @throws Exception\PregErrorException
*/
public function processMatchesWithCallback(string $sCss, $oCallback, string $sContext = 'global'): ?string
{
$sRegex = $this->getCssSearchRegex();
$sProcessedCss = \preg_replace_callback('#'.$sRegex.'#six', function ($aMatches) use ($oCallback, $sContext): string {
if (empty(\trim($aMatches[0]))) {
return $aMatches[0];
}
if ('@' == \substr($aMatches[0], 0, 1)) {
$sContext = $this->getContext($aMatches[0]);
foreach ($this->oCssSearchObject->getCssNestedRuleNames() as $aAtRule) {
if ($aAtRule['name'] == $sContext) {
if ($aAtRule['recurse']) {
return $aMatches[2].'{'.$this->processMatchesWithCallback($aMatches[4], $oCallback, $sContext).'}';
}
return $oCallback->processMatches($aMatches, $sContext);
}
}
}
return $oCallback->processMatches($aMatches, $sContext);
}, $sCss);
try {
self::throwExceptionOnPregError();
} catch (\Exception $exception) {
throw new Exception\PregErrorException($exception->getMessage());
}
return $sProcessedCss;
}
/**
* @psalm-param '' $sReplace
*
* @param mixed $sCss
*
* @throws Exception\PregErrorException
*/
public function replaceMatches($sCss, string $sReplace): ?string
{
$sProcessedCss = \preg_replace('#'.$this->getCssSearchRegex().'#i', $sReplace, $sCss);
try {
self::throwExceptionOnPregError();
} catch (\Exception $exception) {
throw new Exception\PregErrorException($exception->getMessage());
}
return $sProcessedCss;
}
public function setCssSearchObject(CssSearchObject $oCssSearchObject): void
{
$this->oCssSearchObject = $oCssSearchObject;
}
// language=RegExp
public function setExcludes(array $aExcludes): void
{
$this->aExcludes = $aExcludes;
}
public function setParseTerm(string $sParseTerm): void
{
$this->sParseTerm = $sParseTerm;
}
// language=RegExp
protected static function parseNoStrings(): string
{
return '(?>(?:[^{}/]++|/)(?>'.self::blockCommentToken().')?)*?';
}
// language=RegExp
/**
* @psalm-param '' $sInclude
*/
protected static function parse(string $sInclude = '', bool $bNoEmpty = \false): string
{
$sRepeat = $bNoEmpty ? '+' : '*';
return '(?>(?:[^{}"\'/'.$sInclude.']++|/)(?>'.self::blockCommentToken().'|'.self::stringWithCaptureValueToken().')?)'.$sRepeat.'?';
}
// language=RegExp
protected static function _parseCss($sInclude = '', $bNoEmpty = \false): string
{
return self::parse($sInclude, $bNoEmpty);
}
protected function getCssSearchRegex(): string
{
return $this->parseCss($this->getExcludes()).'\\K(?:'.$this->getCriteria().'|$)';
}
protected function parseCSS($aExcludes = []): string
{
if (!empty($aExcludes)) {
$aExcludes = '(?>'.\implode('|', $aExcludes).')?';
} else {
$aExcludes = '';
}
return '(?>'.$this->sParseTerm.$aExcludes.')*?'.$this->sParseTerm;
}
protected function getExcludes(): array
{
return $this->aExcludes;
}
protected function getCriteria(): string
{
$oObj = $this->oCssSearchObject;
$aCriteria = [];
// We need to add Nested Rules criteria first to avoid trouble with recursion and branch capture reset
$aNestedRules = $oObj->getCssNestedRuleNames();
if (!empty($aNestedRules)) {
if (1 == \count($aNestedRules) && \true == $aNestedRules[0]['empty-value']) {
$aCriteria[] = self::cssNestedAtRulesWithCaptureValueToken([$aNestedRules[0]['name']], \false, \true);
} elseif (1 == \count($aNestedRules) && '*' == $aNestedRules[0]['name']) {
$aCriteria[] = self::cssNestedAtRulesWithCaptureValueToken([]);
} else {
$aCriteria[] = self::cssNestedAtRulesWithCaptureValueToken(\array_column($aNestedRules, 'name'), \true);
}
}
$aAtRules = $oObj->getCssAtRuleCriteria();
if (!empty($aAtRules)) {
$aCriteria[] = '('.\implode('|', $aAtRules).')';
}
$aCssRules = $oObj->getCssRuleCriteria();
if (!empty($aCssRules)) {
if (1 == \count($aCssRules) && '.' == $aCssRules[0]) {
$aCriteria[] = self::cssRuleWithCaptureValueToken(\true);
} elseif (1 == \count($aCssRules) && '*' == $aCssRules[0]) {
// Array of nested rules we don't want to recurse in
$aNestedRules = ['font-face', 'keyframes', 'page', 'font-feature-values', 'counter-style', 'viewport', 'property'];
$aCriteria[] = '(?:(?:'.self::cssRuleWithCaptureValueToken().'\\s*+|'.self::blockCommentToken().'\\s*+|'.self::cssNestedAtRulesWithCaptureValueToken($aNestedRules).'\\s*+)++)';
} else {
$sStr = self::getParseStr($aCssRules);
$sRulesCriteria = '(?=(?>['.$sStr.']?[^{}'.$sStr.']*+)*?('.\implode('|', $aCssRules).'))';
$aCriteria[] = self::cssRuleWithCaptureValueToken(\true, $sRulesCriteria);
}
}
$aCssCustomRules = $oObj->getCssCustomRule();
if (!empty($aCssCustomRules)) {
$aCriteria[] = '('.\implode('|', $aCssCustomRules).')';
}
return ($this->bBranchReset ? '(?|' : '(?:').\implode('|', $aCriteria).')';
}
// language=RegExp
protected static function getParseStr(array $aExcludes): string
{
$aStr = [];
foreach ($aExcludes as $sExclude) {
$sSubStr = \substr($sExclude, 0, 1);
if (!\in_array($sSubStr, $aStr)) {
$aStr[] = $sSubStr;
}
}
return \implode('', $aStr);
}
protected function getContext(string $sMatch): string
{
\preg_match('#^@(?:-[^-]+-)?([^\\s{(]++)#i', $sMatch, $aMatches);
return !empty($aMatches[1]) ? \strtolower($aMatches[1]) : 'global';
}
}

View File

@@ -0,0 +1,306 @@
<?php
/**
* JCH Optimize - Performs several front-end optimizations for fast downloads.
*
* @author Samuel Marshall <samuel@jch-optimize.net>
* @copyright Copyright (c) 2022 Samuel Marshall / JCH Optimize
* @license GNU/GPLv3, or later. See LICENSE file
*
* If LICENSE file missing, see <http://www.gnu.org/licenses/>.
*/
namespace JchOptimize\Core\Css;
use _JchOptimizeVendor\Joomla\DI\ContainerAwareInterface;
use _JchOptimizeVendor\Joomla\DI\ContainerAwareTrait;
use CodeAlfa\RegexTokenizer\Debug\Debug;
use JchOptimize\Core\Css\Callbacks\CombineMediaQueries;
use JchOptimize\Core\Css\Callbacks\CorrectUrls;
use JchOptimize\Core\Css\Callbacks\ExtractCriticalCss;
use JchOptimize\Core\Css\Callbacks\FormatCss;
use JchOptimize\Core\Css\Callbacks\HandleAtRules;
use JchOptimize\Core\Exception;
use JchOptimize\Core\FileInfosUtilsTrait;
use JchOptimize\Core\FileUtils;
use JchOptimize\Core\Html\Processor as HtmlProcessor;
use JchOptimize\Core\SerializableTrait;
use JchOptimize\Platform\Profiler;
use Joomla\Registry\Registry;
use Psr\Log\LoggerAwareInterface;
use Psr\Log\LoggerAwareTrait;
\defined('_JCH_EXEC') or exit('Restricted access');
class Processor implements LoggerAwareInterface, ContainerAwareInterface, \Serializable
{
use ContainerAwareTrait;
use LoggerAwareTrait;
use Debug;
use FileInfosUtilsTrait;
use SerializableTrait;
protected string $css;
private Registry $params;
private string $debugUrl = '';
private CombineMediaQueries $combineMediaQueries;
private CorrectUrls $correctUrls;
private ExtractCriticalCss $extractCriticalCss;
private FormatCss $formatCss;
private HandleAtRules $handleAtRules;
public function __construct(Registry $params, CombineMediaQueries $combineMediaQueries, CorrectUrls $correctUrls, ExtractCriticalCss $extractCriticalCss, FormatCss $formatCss, HandleAtRules $handleAtRules)
{
$this->params = $params;
$this->combineMediaQueries = $combineMediaQueries;
$this->correctUrls = $correctUrls;
$this->extractCriticalCss = $extractCriticalCss;
$this->formatCss = $formatCss;
$this->handleAtRules = $handleAtRules;
}
public function setCssInfos(array $cssInfos): void
{
$this->combineMediaQueries->setCssInfos($cssInfos);
$this->correctUrls->setCssInfos($cssInfos);
$this->handleAtRules->setCssInfos($cssInfos);
$this->fileUtils = $this->container->get(FileUtils::class);
$this->debugUrl = $this->prepareFileUrl($cssInfos, 'css');
// initialize debug
$this->_debug($this->debugUrl, '', 'CssProcessorConstructor');
}
public function getCss(): string
{
return $this->css;
}
public function setCss(string $css): void
{
if (\function_exists('mb_convert_encoding')) {
$sEncoding = \mb_detect_encoding($css);
if (\false === $sEncoding) {
$sEncoding = \mb_internal_encoding();
}
$css = \mb_convert_encoding($css, 'utf-8', $sEncoding);
}
$this->css = $css;
}
public function formatCss(): void
{
$oParser = new \JchOptimize\Core\Css\Parser();
$oParser->setExcludes([\JchOptimize\Core\Css\Parser::blockCommentToken(), \JchOptimize\Core\Css\Parser::lineCommentToken(), \JchOptimize\Core\Css\Parser::cssNestedAtRulesWithCaptureValueToken()]);
$sPrepareExcludeRegex = '\\|"(?>[^"{}]*+"?)*?[^"{}]*+"\\|';
$oSearchObject = new \JchOptimize\Core\Css\CssSearchObject();
$oSearchObject->setCssNestedRuleName('media', \true);
$oSearchObject->setCssNestedRuleName('supports', \true);
$oSearchObject->setCssNestedRuleName('document', \true);
$oSearchObject->setCssAtRuleCriteria(\JchOptimize\Core\Css\Parser::cssAtRulesToken());
$oSearchObject->setCssRuleCriteria('*');
$oSearchObject->setCssCustomRule($sPrepareExcludeRegex);
$oSearchObject->setCssCustomRule(\JchOptimize\Core\Css\Parser::cssInvalidCssToken());
$oParser->setCssSearchObject($oSearchObject);
$oParser->disableBranchReset();
$this->formatCss->validCssRules = $sPrepareExcludeRegex;
try {
$this->css = $oParser->processMatchesWithCallback($this->css.'}', $this->formatCss);
} catch (Exception\PregErrorException $oException) {
$this->logger->error('FormatCss failed - '.$this->debugUrl.': '.$oException->getMessage());
}
$this->_debug($this->debugUrl, '', 'formatCss');
}
/**
* Preload resources in CSS.
*
* @param string $css Css to process
* @param bool $isFontsOnly If Optimize CSS not enabled then lets just preload only fonts
*/
public function preloadHttp2(string $css, bool $isFontsOnly = \false)
{
$this->css = $css;
$this->processUrls(\true, $isFontsOnly);
}
/**
* The path to the combined CSS files differs from the original path so relative paths to images in the files are
* converted to absolute paths. This method is used again to preload assets found in the Critical CSS after Optimize
* CSS Delivery is performed.
*
* @param bool $isHttp2 Indicates if we're doing the run to preload assets
* @param bool $isFontsOnly If Optimize CSS Delivery disabled, only preload fonts
* @param bool $isBackend True if this is done from admin to populate the drop-down lists
*/
public function processUrls(bool $isHttp2 = \false, bool $isFontsOnly = \false, bool $isBackend = \false)
{
$oParser = new \JchOptimize\Core\Css\Parser();
$oSearchObject = new \JchOptimize\Core\Css\CssSearchObject();
$oSearchObject->setCssNestedRuleName('font-face');
$oSearchObject->setCssNestedRuleName('keyframes');
$oSearchObject->setCssNestedRuleName('media', \true);
$oSearchObject->setCssNestedRuleName('supports', \true);
$oSearchObject->setCssNestedRuleName('document', \true);
$oSearchObject->setCssRuleCriteria(\JchOptimize\Core\Css\Parser::cssUrlWithCaptureValueToken());
$oSearchObject->setCssAtRuleCriteria(\JchOptimize\Core\Css\Parser::cssAtImportWithCaptureValueToken());
$oParser->setCssSearchObject($oSearchObject);
$this->correctUrls->isHttp2 = $isHttp2;
$this->correctUrls->isFontsOnly = $isFontsOnly;
$this->correctUrls->isBackend = $isBackend;
try {
$this->css = $oParser->processMatchesWithCallback($this->css, $this->correctUrls);
} catch (Exception\PregErrorException $oException) {
$sPreMessage = $isHttp2 ? 'Http/2 preload failed' : 'ProcessUrls failed';
$this->logger->error($sPreMessage.' - '.$this->debugUrl.': '.$oException->getMessage());
}
$this->_debug($this->debugUrl, '', 'processUrls');
}
public function processAtRules(): void
{
$oParser = new \JchOptimize\Core\Css\Parser();
$oSearchObject = new \JchOptimize\Core\Css\CssSearchObject();
$oSearchObject->setCssAtRuleCriteria(\JchOptimize\Core\Css\Parser::cssAtImportWithCaptureValueToken(\true));
$oSearchObject->setCssAtRuleCriteria(\JchOptimize\Core\Css\Parser::cssAtCharsetWithCaptureValueToken());
$oSearchObject->setCssNestedRuleName('font-face');
$oSearchObject->setCssNestedRuleName('media', \true);
$oParser->setCssSearchObject($oSearchObject);
try {
$this->css = $this->cleanEmptyMedias($oParser->processMatchesWithCallback($this->css, $this->handleAtRules));
} catch (Exception\PregErrorException $oException) {
$this->logger->error('ProcessAtRules failed - '.$this->debugUrl.': '.$oException->getMessage());
}
$this->_debug($this->debugUrl, '', 'ProcessAtRules');
}
public function cleanEmptyMedias($css)
{
$oParser = new \JchOptimize\Core\Css\Parser();
$oParser->setExcludes([\JchOptimize\Core\Css\Parser::blockCommentToken(), '[@/]']);
$oParser->setParseTerm('[^@/]*+');
$oCssEmptyMediaObject = new \JchOptimize\Core\Css\CssSearchObject();
$oCssEmptyMediaObject->setCssNestedRuleName('media', \false, \true);
$oParser->setCssSearchObject($oCssEmptyMediaObject);
return $oParser->replaceMatches($css, '');
}
public function processMediaQueries(): void
{
$oParser = new \JchOptimize\Core\Css\Parser();
$oSearchObject = new \JchOptimize\Core\Css\CssSearchObject();
$oSearchObject->setCssNestedRuleName('media');
$oSearchObject->setCssAtRuleCriteria(\JchOptimize\Core\Css\Parser::cssAtImportWithCaptureValueToken(\true));
$oSearchObject->setCssRuleCriteria('*');
$oParser->setCssSearchObject($oSearchObject);
$oParser->disableBranchReset();
try {
$this->css = $oParser->processMatchesWithCallback($this->css, $this->combineMediaQueries);
} catch (Exception\PregErrorException $oException) {
$this->logger->error('HandleMediaQueries failed - '.$this->debugUrl.': '.$oException->getMessage());
}
$this->_debug($this->debugUrl, '', 'handleMediaQueries');
}
/**
* @param mixed $css
* @param mixed $html
*
* @throws Exception\PregErrorException
*/
public function optimizeCssDelivery($css, $html): string
{
!JCH_DEBUG ?: Profiler::start('OptimizeCssDelivery');
$this->_debug('', '', 'StartCssDelivery');
// We can't use the $html coming in as argument as that was used to generate cache key. Let's get the
// HTML from the HTML processor
/** @var HtmlProcessor $htmlProcessor */
$htmlProcessor = $this->container->get(HtmlProcessor::class);
$html = $htmlProcessor->cleanHtml();
// Place space around HTML attributes for easy processing with XPath
$html = \preg_replace('#\\s*=\\s*(?|"([^"]*+)"|\'([^\']*+)\'|([^\\s/>]*+))#i', '=" $1 "', $html);
// Truncate HTML to number of elements set in params
$sHtmlAboveFold = '';
\preg_replace_callback('#<[a-z0-9]++[^>]*+>(?><?[^<]*+(<ul\\b[^>]*+>(?>[^<]*+<(?!ul)[^<]*+|(?1))*?</ul>)?)*?(?=<[a-z0-9])#i', function ($aM) use (&$sHtmlAboveFold) {
$sHtmlAboveFold .= $aM[0];
return $aM[0];
}, $html, (int) $this->params->get('optimizeCssDelivery', '800'));
$this->_debug('', '', 'afterHtmlTruncated');
$oDom = new \DOMDocument();
// Load HTML in DOM
\libxml_use_internal_errors(\true);
$oDom->loadHtml($sHtmlAboveFold);
\libxml_clear_errors();
$oXPath = new \DOMXPath($oDom);
$this->_debug('', '', 'afterLoadHtmlDom');
$sFullHtml = $html;
$oParser = new \JchOptimize\Core\Css\Parser();
$oCssSearchObject = new \JchOptimize\Core\Css\CssSearchObject();
$oCssSearchObject->setCssNestedRuleName('media', \true);
$oCssSearchObject->setCssNestedRuleName('supports', \true);
$oCssSearchObject->setCssNestedRuleName('document', \true);
$oCssSearchObject->setCssNestedRuleName('font-face');
$oCssSearchObject->setCssNestedRuleName('keyframes');
$oCssSearchObject->setCssNestedRuleName('page');
$oCssSearchObject->setCssNestedRuleName('font-feature-values');
$oCssSearchObject->setCssNestedRuleName('counter-style');
$oCssSearchObject->setCssAtRuleCriteria(\JchOptimize\Core\Css\Parser::cssAtImportWithCaptureValueToken());
$oCssSearchObject->setCssAtRuleCriteria(\JchOptimize\Core\Css\Parser::cssAtCharsetWithCaptureValueToken());
$oCssSearchObject->setCssAtRuleCriteria(\JchOptimize\Core\Css\Parser::cssAtNameSpaceToken());
$oCssSearchObject->setCssRuleCriteria('.');
$this->extractCriticalCss->sHtmlAboveFold = $sHtmlAboveFold;
$this->extractCriticalCss->sFullHtml = $sFullHtml;
$this->extractCriticalCss->oXPath = $oXPath;
$oParser->setCssSearchObject($oCssSearchObject);
$sCriticalCss = $oParser->processMatchesWithCallback($css, $this->extractCriticalCss);
$sCriticalCss = $this->cleanEmptyMedias($sCriticalCss);
// Process Font-Face and Key frames
$this->extractCriticalCss->isPostProcessing = \true;
$preCss = $this->extractCriticalCss->preCss;
$sPostCss = $oParser->processMatchesWithCallback($this->extractCriticalCss->postCss, $this->extractCriticalCss);
!JCH_DEBUG ?: Profiler::stop('OptimizeCssDelivery', \true);
return $preCss.$sCriticalCss.$sPostCss;
// $this->_debug(self::cssRulesRegex(), '', 'afterCleanCriticalCss');
}
public function getImports(): string
{
return \implode($this->handleAtRules->getImports());
}
public function getImages(): array
{
return $this->correctUrls->getImages();
}
public function getFontFace(): array
{
return $this->handleAtRules->getFontFace();
}
public function getGFonts(): array
{
return $this->handleAtRules->getGFonts();
}
public function getPreconnects(): array
{
return $this->correctUrls->getPreconnects();
}
public function getCssBgImagesSelectors(): array
{
return $this->correctUrls->getCssBgImagesSelectors();
}
}

View File

@@ -0,0 +1,484 @@
<?php
/**
* JCH Optimize - Performs several front-end optimizations for fast downloads.
*
* @author Samuel Marshall <samuel@jch-optimize.net>
* @copyright Copyright (c) 2022 Samuel Marshall / JCH Optimize
* @license GNU/GPLv3, or later. See LICENSE file
*
* If LICENSE file missing, see <http://www.gnu.org/licenses/>.
*/
namespace JchOptimize\Core\Css\Sprite;
use _JchOptimizeVendor\GuzzleHttp\Psr7\UriResolver;
use _JchOptimizeVendor\Joomla\DI\ContainerAwareInterface;
use _JchOptimizeVendor\Joomla\DI\ContainerAwareTrait;
use Exception;
use JchOptimize\Core\Exception\MissingDependencyException;
use JchOptimize\Core\SystemUri;
use JchOptimize\Core\Uri\Utils;
use JchOptimize\Platform\Paths;
use JchOptimize\Platform\Profiler;
use Joomla\Filesystem\Folder;
use Joomla\Registry\Registry;
use Psr\Log\LoggerAwareInterface;
use Psr\Log\LoggerAwareTrait;
use Psr\Log\LoggerInterface;
use function count;
use function md5;
\defined('_JCH_EXEC') or exit('Restricted access');
class Controller implements LoggerAwareInterface, ContainerAwareInterface
{
use ContainerAwareTrait;
use LoggerAwareTrait;
public array $options = [];
public bool $bTransparent;
protected array $imageTypes = [];
protected array $aFormErrors = [];
protected string $sZipFolder = '';
protected $sCss;
protected string $sTempSpriteName = '';
protected bool $bValidImages;
protected array $aBackground = [];
protected array $aPosition = [];
/**
* @var HandlerInterface
*/
protected \JchOptimize\Core\Css\Sprite\HandlerInterface $imageHandler;
protected Registry $params;
/**
* @var array To store CSS rules
*/
private array $aCss = [];
/**
* Controller constructor.
*
* @throws \Exception
*/
public function __construct(Registry $params, LoggerInterface $logger)
{
$this->params = $params;
$this->setLogger($logger);
$this->options = [
'path' => '',
'sub' => '',
'file-regex' => '',
'wrap-columns' => $this->params->get('csg_wrap_images', 'off'),
'build-direction' => $this->params->get('csg_direction', 'vertical'),
'use-transparency' => 'on',
'use-optipng' => '',
'vertical-offset' => 50,
'horizontal-offset' => 50,
'background' => '',
'image-output' => 'PNG',
// $this->params->get('csg_file_output'),
'image-num-colours' => 'true-colour',
'image-quality' => 100,
'width-resize' => 100,
'height-resize' => 100,
'ignore-duplicates' => 'merge',
'class-prefix' => '',
'selector-prefix' => '',
'selector-suffix' => '',
'add-width-height-to-css' => 'off',
'sprite-path' => Paths::spritePath(),
];
// Should the sprite be transparent
$this->options['is-transparent'] = \in_array($this->options['image-output'], ['GIF', 'PNG']);
$imageLibrary = $this->getImageLibrary();
$class = 'JchOptimize\\Core\\Css\\Sprite\\Handler\\'.\ucfirst($imageLibrary);
// @var HandlerInterface&LoggerAwareInterface $class imageHandler
$this->imageHandler = new $class($this->params, $this->options);
$this->imageHandler->setLogger($logger);
$this->imageTypes = $this->imageHandler->getSupportedFormats();
}
public function GetImageTypes(): array
{
return $this->imageTypes;
}
public function GetSpriteFormats()
{
return $this->imageHandler->spriteFormats;
}
/**
* @param string[] $aFilePaths
*
* @psalm-param array<int<0, max>, string> $aFilePaths
*
* @return null|array
*
* @psalm-return list<mixed>|null
*/
public function CreateSprite(array $aFilePaths, bool $returnValues = \false)
{
// set up variable defaults used when calculating offsets etc
$aFilesInfo = [];
$aFilesMD5 = [];
$bResize = \false;
$aValidImages = [];
// $this->aFormValues['build-direction'] = 'horizontal'
$iRowCount = 1;
$aMaxRowHeight = [];
$iMaxVOffset = 0;
// $this->aFormValues['build-direction'] = 'vertical'
$iColumnCount = 1;
$aMaxColumnWidth = [];
$iMaxHOffset = 0;
$iTotalWidth = 0;
$iTotalHeight = 0;
$iMaxWidth = 0;
$iMaxHeight = 0;
$i = 0;
$k = 0;
$bValidImages = \false;
$sOutputFormat = \strtolower($this->options['image-output']);
$optimize = \false;
// this section calculates all offsets etc
foreach ($aFilePaths as $sFile) {
JCH_DEBUG ? Profiler::start('CalculateSprite') : null;
$fileUri = Utils::uriFor($sFile);
$fileUri = $fileUri->withScheme('')->withHost('');
$fileUri = UriResolver::resolve(SystemUri::currentUri(), $fileUri);
$filePath = \str_replace(SystemUri::baseFull(), '', (string) $fileUri);
$sFilePath = Paths::rootPath().\DIRECTORY_SEPARATOR.$filePath;
$bFileExists = \true;
if (@\file_exists($sFilePath)) {
// do we want to scale down the source images
// scaling up isn't supported as that would result in poorer quality images
$bResize = 100 != $this->options['width-resize'] && 100 != $this->options['height-resize'];
// grab path information
// $sFilePath = $sFolderMD5.$sFile;
$aPathParts = \pathinfo($sFilePath);
$sFileBaseName = $aPathParts['basename'];
$aImageInfo = @\getimagesize($sFilePath);
if ($aImageInfo) {
$iWidth = $aImageInfo[0];
$iHeight = $aImageInfo[1];
$iImageType = $aImageInfo[2];
// are we matching filenames against a regular expression
// if so it's likely not all images from the ZIP file will end up in the generated sprite image
if (!empty($this->options['file-regex'])) {
// forward slashes should be escaped - it's likely not doing this might be a security risk also
// one might be able to break out and change the modifiers (to for example run PHP code)
$this->options['file-regex'] = \str_replace('/', '\\/', $this->options['file-regex']);
// if the regular expression matches grab the first match and store for use as the class name
if (\preg_match('/^'.$this->options['file-regex'].'$/i', $sFileBaseName, $aMatches)) {
$sFileClass = $aMatches[1];
} else {
$sFileClass = '';
}
} else {
// not using regular expressions - set the class name to the base part of the filename (excluding extension)
$sFileClass = $aPathParts['basename'];
}
// format the class name - it should only contain certain characters
// this strips out any which aren't
$sFileClass = $this->FormatClassName($sFileClass);
} else {
$bFileExists = \false;
}
} else {
$bFileExists = \false;
}
// the file also isn't valid if its extension doesn't match one of the image formats supported by the tool
// discard images whose height or width is greater than 50px
if ($bFileExists && !empty($sFileClass) && \in_array(\strtoupper($aPathParts['extension']), $this->imageTypes) && \in_array($iImageType, [\IMAGETYPE_GIF, \IMAGETYPE_JPEG, \IMAGETYPE_PNG]) && '.' != \substr($sFileBaseName, 0, 1) && $iWidth < 50 && $iHeight < 50 && $iWidth > 0 && $iHeight > 0) {
// grab the file extension
$sExtension = $aPathParts['extension'];
// get MD5 of file (this can be used to compare if a file's content is exactly the same as another's)
$sFileMD5 = \md5(\file_get_contents($sFilePath));
// check if this file's MD5 already exists in array of MD5s recorded so far
// if so it's a duplicate of another file in the ZIP
if (($sKey = \array_search($sFileMD5, $aFilesMD5)) !== \false) {
// do we want to drop duplicate files and merge CSS rules
// if so CSS will end up like .filename1, .filename2 { }
if ('merge' == $this->options['ignore-duplicates']) {
if (isset($aFilesInfo[$sKey]['class'])) {
$aFilesInfo[$sKey]['class'] = $aFilesInfo[$sKey]['class'].$this->options['selector-suffix'].', '.$this->options['selector-prefix'].'.'.$this->options['class-prefix'].$sFileClass;
}
$this->aBackground[$k] = $sKey;
++$k;
continue;
}
} else {
$this->aBackground[$k] = $i;
++$k;
}
// add MD5 to array to check future files against
$aFilesMD5[$i] = $sFileMD5;
// store generated class selector details
// $aFilesInfo[$i]['class'] = ".{$this->aFormValues['class-prefix']}$sFileClass";
// store file path information and extension
$aFilesInfo[$i]['path'] = $sFilePath;
$aFilesInfo[$i]['ext'] = $sExtension;
if ('horizontal' == $this->options['build-direction']) {
// get the current width of the sprite image - after images processed so far
$iCurrentWidth = $iTotalWidth + $this->options['horizontal-offset'] + $iWidth;
// store the maximum width reached so far
// if we're on a new column current height might be less than the maximum
if ($iMaxWidth < $iCurrentWidth) {
$iMaxWidth = $iCurrentWidth;
}
} else {
// get the current height of the sprite image - after images processed so far
$iCurrentHeight = $iTotalHeight + $this->options['vertical-offset'] + $iHeight;
// store the maximum height reached so far
// if we're on a new column current height might be less than the maximum
if ($iMaxHeight < $iCurrentHeight) {
$iMaxHeight = $iCurrentHeight;
}
}
// store the original width and height of the image
// we'll need this later if the image is to be resized
$aFilesInfo[$i]['original-width'] = $iWidth;
$aFilesInfo[$i]['original-height'] = $iHeight;
// store the width and height of the image
// if we're resizing they'll be less than the original
$aFilesInfo[$i]['width'] = $bResize ? \round($iWidth / 100 * $this->options['width-resize']) : $iWidth;
$aFilesInfo[$i]['height'] = $bResize ? \round($iHeight / 100 * $this->options['height-resize']) : $iHeight;
if ('horizontal' == $this->options['build-direction']) {
// opera (9.0 and below) has a bug which prevents it recognising offsets of less than -2042px
// all subsequent values are treated as -2042px
// if we've hit 2000 pixels and we care about this (as set in the interface) then wrap to a new row
// increment row count and reset current height
if ($iTotalWidth + $this->options['horizontal-offset'] >= 2000 && !empty($this->options['wrap-columns'])) {
++$iRowCount;
$iTotalWidth = 0;
}
// if the current image is higher than any other in the current row then set the maximum height to that
// it will be used to set the height of the current row
if ($aFilesInfo[$i]['height'] > $iMaxHeight) {
$iMaxHeight = $aFilesInfo[$i]['height'];
}
// keep track of the height of rows added so far
$aMaxRowHeight[$iRowCount] = $iMaxHeight;
// calculate the current maximum vertical offset so far
$iMaxVOffset = $this->options['vertical-offset'] * ($iRowCount - 1);
// get the x position of current image in overall sprite
$aFilesInfo[$i]['x'] = $iTotalWidth;
$iTotalWidth += $aFilesInfo[$i]['width'] + $this->options['horizontal-offset'];
// get the y position of current image in overall sprite
if (1 == $iRowCount) {
$aFilesInfo[$i]['y'] = 0;
} else {
$aFilesInfo[$i]['y'] = $this->options['vertical-offset'] * ($iRowCount - 1) + (\array_sum($aMaxRowHeight) - $aMaxRowHeight[$iRowCount]);
}
$aFilesInfo[$i]['currentCombinedWidth'] = $iTotalWidth;
$aFilesInfo[$i]['rowNumber'] = $iRowCount;
} else {
if ($iTotalHeight + $this->options['vertical-offset'] >= 2000 && !empty($this->options['wrap-columns'])) {
++$iColumnCount;
$iTotalHeight = 0;
}
// if the current image is wider than any other in the current column then set the maximum width to that
// it will be used to set the width of the current column
if ($aFilesInfo[$i]['width'] > $iMaxWidth) {
$iMaxWidth = $aFilesInfo[$i]['width'];
}
// keep track of the width of columns added so far
$aMaxColumnWidth[$iColumnCount] = $iMaxWidth;
// calculate the current maximum horizontal offset so far
$iMaxHOffset = $this->options['horizontal-offset'] * ($iColumnCount - 1);
// get the y position of current image in overall sprite
$aFilesInfo[$i]['y'] = $iTotalHeight;
$iTotalHeight += $aFilesInfo[$i]['height'] + $this->options['vertical-offset'];
// get the x position of current image in overall sprite
if (1 == $iColumnCount) {
$aFilesInfo[$i]['x'] = 0;
} else {
$aFilesInfo[$i]['x'] = $this->options['horizontal-offset'] * ($iColumnCount - 1) + (\array_sum($aMaxColumnWidth) - $aMaxColumnWidth[$iColumnCount]);
}
$aFilesInfo[$i]['currentCombinedHeight'] = $iTotalHeight;
$aFilesInfo[$i]['columnNumber'] = $iColumnCount;
}
++$i;
$aValidImages[] = $sFile;
} else {
$this->aBackground[$k] = null;
++$k;
}
if ($i > 30) {
break;
}
}
JCH_DEBUG ? Profiler::stop('CalculateSprite', \true) : null;
if ($returnValues) {
return $aValidImages;
}
JCH_DEBUG ? Profiler::start('CreateSprite') : null;
// this section generates the sprite image
// and CSS rules
// if $i is greater than 1 then we managed to generate enough info to create a sprite
if (\count($aFilesInfo) > 1) {
// if Imagick throws an exception we want the script to terminate cleanly so that
// temporary files are cleaned up
try {
// get the sprite width and height
if ('horizontal' == $this->options['build-direction']) {
$iSpriteWidth = $iMaxWidth - $this->options['horizontal-offset'];
$iSpriteHeight = \array_sum($aMaxRowHeight) + $iMaxVOffset;
} else {
$iSpriteHeight = $iMaxHeight - $this->options['vertical-offset'];
$iSpriteWidth = \array_sum($aMaxColumnWidth) + $iMaxHOffset;
}
// get background colour - remove # if added
$sBgColour = \str_replace('#', '', $this->options['background']);
// convert 3 digit hex values to 6 digit equivalent
if (3 == \strlen($sBgColour)) {
$sBgColour = \substr($sBgColour, 0, 1).\substr($sBgColour, 0, 1).\substr($sBgColour, 1, 1).\substr($sBgColour, 1, 1).\substr($sBgColour, 2, 1).\substr($sBgColour, 2, 1);
}
// should the image be transparent
$this->bTransparent = !empty($this->options['use-transparency']) && \in_array($this->options['image-output'], ['GIF', 'PNG']);
$oSprite = $this->imageHandler->createSprite($iSpriteWidth, $iSpriteHeight, $sBgColour, $sOutputFormat);
// loop through file info for valid images
for ($i = 0; $i < \count($aFilesInfo); ++$i) {
// create a new image object for current file
if (!($oCurrentImage = $this->imageHandler->createImage($aFilesInfo[$i]))) {
// if we've got here then a valid but corrupt image was found
// at this stage we've already allocated space for the image so create
// a blank one to fill the space instead
// this should happen very rarely
$oCurrentImage = $this->imageHandler->createBlankImage($aFilesInfo[$i]);
}
// if resizing get image width and height and resample to new dimensions (percentage of original)
// and copy to sprite image
if ($bResize) {
$this->imageHandler->resizeImage($oSprite, $oCurrentImage, $aFilesInfo[$i]);
}
// copy image to sprite
$this->imageHandler->copyImageToSprite($oSprite, $oCurrentImage, $aFilesInfo[$i], $bResize);
// get CSS x & y values
$iX = 0 != $aFilesInfo[$i]['x'] ? '-'.$aFilesInfo[$i]['x'].'px' : '0';
$iY = 0 != $aFilesInfo[$i]['y'] ? '-'.$aFilesInfo[$i]['y'].'px' : '0';
$this->aPosition[$i] = $iX.' '.$iY;
// create CSS rules and append to overall CSS rules
// $this->sCss .= "{$this->aFormValues['selector-prefix']}{$aFilesInfo[$i]['class']} "
// . "{$this->aFormValues['selector-suffix']}{ background-position: $iX $iY; ";
//
// // If add widths and heights the sprite image width and height are added to the CSS
// if ($this->aFormValues['add-width-height-to-css'] == 'on')
// {
// $this->sCss .= "width: {$aFilesInfo[$i]['width']}px; height: {$aFilesInfo[$i]['height']}px;";
// }
//
// $this->sCss .= " } \n";
// destroy object created for current image to save memory
$this->imageHandler->destroy($oCurrentImage);
}
$path = $this->options['sprite-path'];
// See if image already exists
//
// create a unqiue filename for sprite image
$sSpriteMD5 = \md5(\implode($aFilesMD5).\implode($this->options));
$this->sTempSpriteName = $path.\DIRECTORY_SEPARATOR.'csg-'.$sSpriteMD5.".{$sOutputFormat}";
if (!\file_exists($path)) {
Folder::create($path);
}
// write image to file
if (!\file_exists($this->sTempSpriteName)) {
$this->imageHandler->writeImage($oSprite, $sOutputFormat, $this->sTempSpriteName);
$optimize = \true;
}
// destroy object created for sprite image to save memory
$this->imageHandler->destroy($oSprite);
// set flag to indicate valid images created
$this->bValidImages = \true;
} catch (\Exception $e) {
$this->logger->error($e->getMessage());
}
JCH_DEBUG ? Profiler::stop('CreateSprite', \true) : null;
}
}
public function ValidImages(): bool
{
return $this->bValidImages;
}
public function GetSpriteFilename(): string
{
$aFileParts = \pathinfo($this->sTempSpriteName);
return $aFileParts['basename'];
}
public function GetSpriteHash(): void
{
// return md5($this->GetSpriteFilename().ConfigHelper::Get('/checksum'));
}
public function GetCss(): array
{
return $this->aCss;
}
public function GetAllErrors(): array
{
return $this->aFormErrors;
}
public function GetZipFolder(): string
{
return $this->sZipFolder;
}
public function GetCssBackground(): array
{
$aCssBackground = [];
foreach ($this->aBackground as $background) {
// if(!empty($background))
// {
$aCssBackground[] = @$this->aPosition[$background];
// }
}
return $aCssBackground;
}
protected function FormatClassName(string $sClassName): ?string
{
$aExtensions = [];
foreach ($this->imageTypes as $sType) {
$aExtensions[] = ".{$sType}";
}
return \preg_replace('/[^a-z0-9_-]+/i', '', \str_ireplace($aExtensions, '', $sClassName));
}
/**
* Returns the name of the Image library imagick|gd that is available, false if failed.
*
* @throws \Exception
*/
private function getImageLibrary(): string
{
if (!\extension_loaded('exif')) {
throw new MissingDependencyException('EXIF extension not loaded');
}
if (\extension_loaded('imagick')) {
$sImageLibrary = 'imagick';
} else {
if (!\extension_loaded('gd')) {
throw new MissingDependencyException('No image manipulation library installed');
}
$sImageLibrary = 'gd';
}
return $sImageLibrary;
}
}

View File

@@ -0,0 +1,205 @@
<?php
/**
* JCH Optimize - Performs several front-end optimizations for fast downloads.
*
* @author Samuel Marshall <samuel@jch-optimize.net>
* @copyright Copyright (c) 2022 Samuel Marshall / JCH Optimize
* @license GNU/GPLv3, or later. See LICENSE file
*
* If LICENSE file missing, see <http://www.gnu.org/licenses/>.
*/
namespace JchOptimize\Core\Css\Sprite;
use _JchOptimizeVendor\Joomla\DI\ContainerAwareInterface;
use _JchOptimizeVendor\Joomla\DI\ContainerAwareTrait;
use JchOptimize\Core\Cdn;
use JchOptimize\Core\Exception;
use JchOptimize\Core\Helper;
use JchOptimize\Core\Uri\Utils;
use JchOptimize\Platform\Paths;
use JchOptimize\Platform\Profiler;
use Joomla\Registry\Registry;
use Psr\Log\LoggerAwareInterface;
use Psr\Log\LoggerAwareTrait;
\defined('_JCH_EXEC') or exit('Restricted access');
class Generator implements LoggerAwareInterface, ContainerAwareInterface
{
use ContainerAwareTrait;
use LoggerAwareTrait;
/**
* @var null|Controller
*/
private ?\JchOptimize\Core\Css\Sprite\Controller $spriteController;
private Registry $params;
public function __construct(Registry $params, ?Controller $spriteController)
{
$this->params = $params;
$this->spriteController = $spriteController;
}
/**
* Grabs background images with no-repeat attribute from css and merge them in one file called a sprite.
* Css is updated with sprite url and correct background positions for affected images.
*
* @param string $sCss Aggregated css file before sprite generation
*/
public function getSprite(string $sCss): array
{
$aMatches = $this->processCssUrls($sCss);
if (empty($aMatches)) {
return [];
}
if (\is_null($this->spriteController)) {
return [];
}
return $this->generateSprite($aMatches);
}
/**
* Uses regex to find css declarations containing background images to include in sprite.
*
* @param string $css Aggregated css file
* @param bool $isBackend True if running in admin
*
* @return array Array of css declarations and image urls to replace with sprite
*
* @throws Exception\RuntimeException
*/
public function processCssUrls(string $css, bool $isBackend = \false): array
{
JCH_DEBUG ? Profiler::start('ProcessCssUrls') : null;
$aRegexStart = [];
$aRegexStart[0] = '
#(?:{
(?=\\s*+(?>[^}\\s:]++[\\s:]++)*?url\\(
(?=[^)]+\\.(?:png|gif|jpe?g))
([^)]+)\\))';
$aRegexStart[1] = '
(?=\\s*+(?>[^}\\s:]++[\\s:]++)*?no-repeat)';
$aRegexStart[2] = '
(?(?=\\s*(?>[^};]++[;\\s]++)*?background(?:-position)?\\s*+:\\s*+(?:[^;}\\s]++[^}\\S]++)*?
(?<p>(?:[tblrc](?:op|ottom|eft|ight|enter)|-?[0-9]+(?:%|[c-x]{2})?))(?:\\s+(?&p))?)
(?=\\s*(?>[^};]++[;\\s]++)*?background(?:-position)?\\s*+:\\s*+(?>[^;}\\s]++[\\s]++)*?
(?:left|top|0(?:%|[c-x]{2})?)\\s+(?:left|top|0(?:%|[c-x]{2})?)
)
)';
$sRegexMiddle = '
[^{}]++} )';
$sRegexEnd = '#isx';
$aIncludeImages = Helper::getArray($this->params->get('csg_include_images', ''));
$aExcludeImages = Helper::getArray($this->params->get('csg_exclude_images', ''));
$sIncImagesRegex = '';
if (!empty($aIncludeImages[0])) {
foreach ($aIncludeImages as &$sImage) {
$sImage = \preg_quote($sImage, '#');
}
$sIncImagesRegex .= '
|(?:{
(?=\\s*+(?>[^}\\s:]++[\\s:]++)*?url';
$sIncImagesRegex .= ' (?=[^)]* [/(](?:'.\implode('|', $aIncludeImages).')\\))';
$sIncImagesRegex .= '\\(([^)]+)\\)
)
[^{}]++ })';
}
$sExImagesRegex = '';
if (!empty($aExcludeImages[0])) {
$sExImagesRegex .= '(?=\\s*+(?>[^}\\s:]++[\\s:]++)*?url\\(
[^)]++ (?<!';
foreach ($aExcludeImages as &$sImage) {
$sImage = \preg_quote($sImage, '#');
}
$sExImagesRegex .= \implode('|', $aExcludeImages).')\\)
)';
}
$sRegexStart = \implode('', $aRegexStart);
$sRegex = $sRegexStart.$sExImagesRegex.$sRegexMiddle.$sIncImagesRegex.$sRegexEnd;
if (\false === \preg_match_all($sRegex, $css, $aMatches)) {
throw new Exception\RuntimeException('Error occurred matching for images to sprite');
}
if (isset($aMatches[3])) {
$total = \count($aMatches[1]);
for ($i = 0; $i < $total; ++$i) {
$aMatches[1][$i] = \trim($aMatches[1][$i]) ? $aMatches[1][$i] : $aMatches[3][$i];
}
}
if ($isBackend) {
if (\is_null($this->spriteController)) {
return ['include' => [], 'exclude' => []];
}
$aImages = [];
$aImagesMatches = [];
$aImgRegex = [];
$aImgRegex[0] = $aRegexStart[0];
$aImgRegex[2] = $aRegexStart[1];
$aImgRegex[4] = $sRegexMiddle;
$aImgRegex[5] = $sRegexEnd;
$sImgRegex = \implode('', $aImgRegex);
if (\false === \preg_match_all($sImgRegex, $css, $aImagesMatches)) {
throw new Exception\RuntimeException('Error occurred matching for images to include');
}
$aImagesMatches[0] = \array_diff($aImagesMatches[0], $aMatches[0]);
$aImagesMatches[1] = \array_diff($aImagesMatches[1], $aMatches[1]);
$aImages['include'] = $this->spriteController->CreateSprite($aImagesMatches[1], \true);
$aImages['exclude'] = $this->spriteController->CreateSprite($aMatches[1], \true);
return $aImages;
}
JCH_DEBUG ? Profiler::stop('ProcessCssUrls', \true) : null;
return $aMatches;
}
/**
* Generates sprite image and return background positions for image replaced with sprite.
*
* @param array $matches Array of css declarations and image url to be included in sprite
*
* @throws Exception\RuntimeException
*/
public function generateSprite(array $matches): array
{
JCH_DEBUG ? Profiler::start('GenerateSprite') : null;
$aDeclaration = $matches[0];
$aImages = $matches[1];
$this->spriteController->CreateSprite($aImages);
$aSpriteCss = $this->spriteController->GetCssBackground();
$aPatterns = [];
$aPatterns[0] = '#background-position:[^;}]+;?#i';
// Background position declaration regex
$aPatterns[1] = '#(background:[^;}]*)\\b((?:top|bottom|left|right|center|-?[0-9]+(?:%|[c-x]{2})?)\\s(?:top|bottom|left|right|center|-?[0-9]+(?:%|[c-x]{2})?))([^;}]*[;}])#i';
$aPatterns[2] = '#(background-image:)[^;}]+;?#i';
// Background image declaration regex
$aPatterns[3] = '#(background:[^;}]*)\\burl\\((?=[^\\)]+\\.(?:png|gif|jpe?g))[^\\)]+\\)([^;}]*[;}])#i';
// Background image regex
$sSpriteName = $this->spriteController->GetSpriteFilename();
$aSearch = [];
$sRelSpritePath = Paths::spritePath(\true).\DIRECTORY_SEPARATOR.$sSpriteName;
$cdn = $this->container->get(Cdn::class);
$sRelSpritePath = $cdn->loadCdnResource(Utils::uriFor($sRelSpritePath));
for ($i = 0; $i < \count($aSpriteCss); ++$i) {
if (isset($aSpriteCss[$i])) {
$aSearch['needles'][] = $aDeclaration[$i];
$aReplacements = [];
$aReplacements[0] = '';
$aReplacements[1] = '$1$3';
$aReplacements[2] = '$1 url('.$sRelSpritePath.'); background-position: '.$aSpriteCss[$i].';';
$aReplacements[3] = '$1url('.$sRelSpritePath.') '.$aSpriteCss[$i].'$2';
$sReplacement = \preg_replace($aPatterns, $aReplacements, $aDeclaration[$i]);
if (\is_null($sReplacement)) {
throw new Exception\RuntimeException('Error finding replacements for sprite background positions');
}
$aSearch['replacements'][] = $sReplacement;
}
}
JCH_DEBUG ? Profiler::stop('GenerateSprite', \true) : null;
return $aSearch;
}
}

View File

@@ -0,0 +1,31 @@
<?php
/**
* @copyright A copyright
* @license A "Slug" license name e.g. GPL2
*/
namespace JchOptimize\Core\Css\Sprite\Handler;
use JchOptimize\Core\Css\Sprite\HandlerInterface;
use Joomla\Registry\Registry;
use Psr\Log\LoggerAwareInterface;
use Psr\Log\LoggerAwareTrait;
\defined('_JCH_EXEC') or exit('Restricted access');
abstract class AbstractHandler implements HandlerInterface, LoggerAwareInterface
{
use LoggerAwareTrait;
public array $spriteFormats = [];
protected Registry $params;
protected array $options;
public function __construct(Registry $params, array $options)
{
$this->params = $params;
$this->options = $options;
}
}

View File

@@ -0,0 +1,191 @@
<?php
/**
* JCH Optimize - Performs several front-end optimizations for fast downloads.
*
* @author Samuel Marshall <samuel@jch-optimize.net>
* @copyright Copyright (c) 2022 Samuel Marshall / JCH Optimize
* @license GNU/GPLv3, or later. See LICENSE file
*
* If LICENSE file missing, see <http://www.gnu.org/licenses/>.
*/
namespace JchOptimize\Core\Css\Sprite\Handler;
\defined('_JCH_EXEC') or exit('Restricted access');
class Gd extends \JchOptimize\Core\Css\Sprite\Handler\AbstractHandler
{
public function getSupportedFormats(): array
{
// get info about installed GD library to get image types (some versions of GD don't include GIF support)
$oGD = \gd_info();
$imageTypes = [];
// store supported formats for populating drop downs etc later
if (isset($oGD['PNG Support'])) {
$imageTypes[] = 'PNG';
$this->spriteFormats[] = 'PNG';
}
if (isset($oGD['GIF Create Support'])) {
$imageTypes[] = 'GIF';
}
if (isset($oGD['JPG Support']) || isset($oGD['JPEG Support'])) {
$imageTypes[] = 'JPG';
}
return $imageTypes;
}
/**
* @param mixed $spriteWidth
* @param mixed $spriteHeight
* @param mixed $bgColour
* @param mixed $outputFormat
*
* @return false|resource
*/
public function createSprite($spriteWidth, $spriteHeight, $bgColour, $outputFormat)
{
if ($this->options['is-transparent'] && !empty($this->options['background'])) {
$oSprite = \imagecreate($spriteWidth, $spriteHeight);
} else {
$oSprite = \imagecreatetruecolor($spriteWidth, $spriteHeight);
}
// check for transparency option
if ($this->options['is-transparent']) {
if ('png' == $outputFormat) {
\imagealphablending($oSprite, \false);
$colorTransparent = \imagecolorallocatealpha($oSprite, 0, 0, 0, 127);
\imagefill($oSprite, 0, 0, $colorTransparent);
\imagesavealpha($oSprite, \true);
} elseif ('gif' == $outputFormat) {
$iBgColour = \imagecolorallocate($oSprite, 0, 0, 0);
\imagecolortransparent($oSprite, $iBgColour);
}
} else {
if (empty($bgColour)) {
$bgColour = 'ffffff';
}
$iBgColour = \hexdec($bgColour);
$iBgColour = \imagecolorallocate($oSprite, 0xFF & $iBgColour >> 0x10, 0xFF & $iBgColour >> 0x8, 0xFF & $iBgColour);
\imagefill($oSprite, 0, 0, $iBgColour);
}
return $oSprite;
}
/**
* @param mixed $fileInfos
*
* @return false|resource
*/
public function createBlankImage($fileInfos)
{
$oCurrentImage = \imagecreatetruecolor($fileInfos['original-width'], $fileInfos['original-height']);
\imagecolorallocate($oCurrentImage, 255, 255, 255);
return $oCurrentImage;
}
/**
* @param mixed $spriteObject
* @param mixed $currentImage
* @param mixed $fileInfos
*/
public function resizeImage($spriteObject, $currentImage, $fileInfos)
{
\imagecopyresampled($spriteObject, $currentImage, $fileInfos['x'], $fileInfos['y'], 0, 0, $fileInfos['width'], $fileInfos['height'], $fileInfos['original-width'], $fileInfos['original-height']);
}
/**
* @param mixed $spriteObject
* @param mixed $currentImage
* @param mixed $fileInfos
* @param mixed $resize
*/
public function copyImageToSprite($spriteObject, $currentImage, $fileInfos, $resize)
{
// if already resized the image will have been copied as part of the resize
if (!$resize) {
\imagecopy($spriteObject, $currentImage, $fileInfos['x'], $fileInfos['y'], 0, 0, $fileInfos['width'], $fileInfos['height']);
}
}
/**
* @param mixed $imageObject
*/
public function destroy($imageObject)
{
\imagedestroy($imageObject);
}
/**
* @param mixed $fileInfos
*
* @return false|resource
*/
public function createImage($fileInfos)
{
$sFile = $fileInfos['path'];
switch ($fileInfos['ext']) {
case 'jpg':
case 'jpeg':
$oImage = @\imagecreatefromjpeg($sFile);
break;
case 'gif':
$oImage = @\imagecreatefromgif($sFile);
break;
case 'png':
$oImage = @\imagecreatefrompng($sFile);
break;
default:
$oImage = @\imagecreatefromstring($sFile);
}
return $oImage;
}
/**
* @param mixed $imageObject
* @param mixed $extension
* @param mixed $fileName
*/
public function writeImage($imageObject, $extension, $fileName)
{
// check if we want to resample image to lower number of colours (to reduce file size)
if (\in_array($extension, ['gif', 'png']) && 'true-colour' != $this->options['image-num-colours']) {
\imagetruecolortopalette($imageObject, \true, $this->options['image-num-colours']);
}
switch ($extension) {
case 'jpg':
case 'jpeg':
// GD takes quality setting in main creation function
\imagejpeg($imageObject, $fileName, $this->options['image-quality']);
break;
case 'gif':
// force colour palette to 256 colours if saving sprite image as GIF
// this will happen anyway (as GIFs can't be more than 256 colours)
// but the quality will be better if pre-forcing
if ($this->options['is-transparent'] && (-1 == $this->options['image-num-colours'] || $this->options['image-num-colours'] > 256 || 'true-colour' == $this->options['image-num-colours'])) {
\imagetruecolortopalette($imageObject, \true, 256);
}
\imagegif($imageObject, $fileName);
break;
case 'png':
\imagepng($imageObject, $fileName, 0);
break;
}
}
}

View File

@@ -0,0 +1,142 @@
<?php
/**
* JCH Optimize - Performs several front-end optimizations for fast downloads.
*
* @author Samuel Marshall <samuel@jch-optimize.net>
* @copyright Copyright (c) 2022 Samuel Marshall / JCH Optimize
* @license GNU/GPLv3, or later. See LICENSE file
*
* If LICENSE file missing, see <http://www.gnu.org/licenses/>.
*/
namespace JchOptimize\Core\Css\Sprite\Handler;
\defined('_JCH_EXEC') or exit('Restricted access');
class Imagick extends \JchOptimize\Core\Css\Sprite\Handler\AbstractHandler
{
public function getSupportedFormats(): array
{
$imageTypes = [];
try {
$oImagick = new \Imagick();
$aImageFormats = $oImagick->queryFormats();
} catch (\ImagickException $e) {
$this->logger->error($e->getMessage());
}
// store supported formats for populating drop downs etc later
if (\in_array('PNG', $aImageFormats)) {
$imageTypes[] = 'PNG';
$this->spriteFormats[] = 'PNG';
}
if (\in_array('GIF', $aImageFormats)) {
$imageTypes[] = 'GIF';
$this->spriteFormats[] = 'GIF';
}
if (\in_array('JPG', $aImageFormats) || \in_array('JPEG', $aImageFormats)) {
$imageTypes[] = 'JPG';
}
return $imageTypes;
}
public function createSprite($spriteWidth, $spriteHeight, $bgColour, $outputFormat): \Imagick
{
$spriteObject = new \Imagick();
// create a new image - set background according to transparency
if (!empty($this->options['background'])) {
$spriteObject->newImage($spriteWidth, $spriteHeight, new \ImagickPixel("#{$bgColour}"), $outputFormat);
} else {
if ($this->options['is-transparent']) {
$spriteObject->newImage($spriteWidth, $spriteHeight, new \ImagickPixel('#000000'), $outputFormat);
} else {
$spriteObject->newImage($spriteWidth, $spriteHeight, new \ImagickPixel('#ffffff'), $outputFormat);
}
}
// check for transparency option
if ($this->options['is-transparent']) {
// set background colour to transparent
// if no background colour use black
if (!empty($this->options['background'])) {
$spriteObject->transparentPaintImage(new \ImagickPixel("#{$bgColour}"), 0.0, 0, \false);
} else {
$spriteObject->transparentPaintImage(new \ImagickPixel('#000000'), 0.0, 0, \false);
}
}
return $spriteObject;
}
public function createBlankImage($fileInfos): \Imagick
{
$currentImage = new \Imagick();
$currentImage->newImage($fileInfos['original-width'], $fileInfos['original-height'], new \ImagickPixel('#ffffff'));
return $currentImage;
}
/**
* @param \Imagick $currentImage
*
* @throws \ImagickException
*
* @since version
*/
public function resizeImage($spriteObject, $currentImage, $fileInfos)
{
$currentImage->thumbnailImage($fileInfos['width'], $fileInfos['height']);
}
/**
* @param \Imagick $spriteObject
* @param \Imagick $currentImage
*
* @throws \ImagickException
*/
public function copyImageToSprite($spriteObject, $currentImage, $fileInfos, $resize)
{
$spriteObject->compositeImage($currentImage, $currentImage->getImageCompose(), $fileInfos['x'], $fileInfos['y']);
}
/**
* @param \Imagick $imageObject
*
* @since version
*/
public function destroy($imageObject)
{
$imageObject->destroy();
}
public function createImage($fileInfos): \Imagick
{
// Imagick auto-detects file extension when creating object from image
$oImage = new \Imagick();
$oImage->readImage($fileInfos['path']);
return $oImage;
}
/**
* @param \Imagick $imageObject
* @param string $extension
* @param string $fileName
*
* @throws \ImagickException
*/
public function writeImage($imageObject, $extension, $fileName)
{
// check if we want to resample image to lower number of colours (to reduce file size)
if (\in_array($extension, ['gif', 'png']) && 'true-colour' != $this->options['image-num-colours']) {
$imageObject->quantizeImage($this->options['image-num-colours'], \Imagick::COLORSPACE_RGB, 0, \false, \false);
}
// if we're creating a JEPG set image quality - 0% - 100%
if (\in_array($extension, ['jpg', 'jpeg'])) {
$imageObject->setCompression(\Imagick::COMPRESSION_JPEG);
$imageObject->SetCompressionQuality($this->options['image-quality']);
}
// write out image to file
$imageObject->writeImage($fileName);
}
}

View File

@@ -0,0 +1,33 @@
<?php
/**
* JCH Optimize - Performs several front-end optimizations for fast downloads.
*
* @author Samuel Marshall <samuel@jch-optimize.net>
* @copyright Copyright (c) 2022 Samuel Marshall / JCH Optimize
* @license GNU/GPLv3, or later. See LICENSE file
*
* If LICENSE file missing, see <http://www.gnu.org/licenses/>.
*/
namespace JchOptimize\Core\Css\Sprite;
\defined('_JCH_EXEC') or exit('Restricted access');
interface HandlerInterface
{
public function getSupportedFormats();
public function createSprite($spriteWidth, $spriteHeight, $bgColour, $outputFormat);
public function createBlankImage($fileInfos);
public function resizeImage($spriteObject, $currentImage, $fileInfos);
public function copyImageToSprite($spriteObject, $currentImage, $fileInfos, $resize);
public function destroy($imageObject);
public function createImage($fileInfos);
public function writeImage($imageObject, $extension, $fileName);
}

View File

@@ -0,0 +1,92 @@
<?php
/**
* JCH Optimize - Performs several front-end optimizations for fast downloads.
*
* @author Samuel Marshall <samuel@jch-optimize.net>
* @copyright Copyright (c) 2022 Samuel Marshall / JCH Optimize
* @license GNU/GPLv3, or later. See LICENSE file
*
* If LICENSE file missing, see <http://www.gnu.org/licenses/>.
*/
namespace JchOptimize\Core;
use JchOptimize\ContainerFactory;
use Psr\Log\LoggerInterface;
\defined('_JCH_EXEC') or exit('Restricted access');
abstract class Debugger
{
private static bool $dieOnError = \false;
public static function printr($var, $label = null, $condition = \true): void
{
if ($condition) {
self::debug('printr', $var, $label);
}
}
public static function vdump($var, $label = null, $condition = \true): void
{
if ($condition) {
self::debug('vdump', $var, $label);
}
}
public static function attachErrorHandler(bool $dieOnError = \false): void
{
self::$dieOnError = $dieOnError;
\set_error_handler([\JchOptimize\Core\Debugger::class, 'debuggerErrorHandler'], \E_ALL);
\register_shutdown_function([\JchOptimize\Core\Debugger::class, 'debuggerCatchFatalErrors']);
}
public static function debuggerErrorHandler(int $errno, string $errstr, string $errfile, int $errline): void
{
/** @var LoggerInterface $logger */
$logger = ContainerFactory::getContainer()->get(LoggerInterface::class);
$msg = 'Error no: '.$errno.', Message: '.$errstr.' in file: '.$errfile.' at line: '.$errline."\n";
$logger->error($msg);
if (self::$dieOnError) {
exit;
}
}
public static function debuggerCatchFatalErrors(): void
{
/** @var LoggerInterface $logger */
$logger = ContainerFactory::getContainer()->get(LoggerInterface::class);
$error = \error_get_last();
$msg = 'Error type: '.$error['type'].', Message: '.$error['message'].' in file: '.$error['file'].' at line: '.$error['line']."\n";
$logger->error($msg);
}
/**
* @psalm-suppress ForbiddenCode
*/
private static function debug(string $method, $var, $label = null): void
{
/** @var LoggerInterface $logger */
$logger = ContainerFactory::getContainer()->get(LoggerInterface::class);
if (\is_null($label)) {
$name = '';
} else {
$name = $label.': ';
}
switch ($method) {
case 'vdump':
\ob_start();
\var_dump($var);
$logger->debug($name.\ob_get_clean());
break;
case 'printr':
default:
$logger->debug($name.\print_r($var, \true));
break;
}
}
}

View File

@@ -0,0 +1,18 @@
<?php
/**
* JCH Optimize - Performs several front-end optimizations for fast downloads.
*
* @author Samuel Marshall <samuel@jch-optimize.net>
* @copyright Copyright (c) 2022 Samuel Marshall / JCH Optimize
* @license GNU/GPLv3, or later. See LICENSE file
*
* If LICENSE file missing, see <http://www.gnu.org/licenses/>.
*/
namespace JchOptimize\Core\Exception;
\defined('_JCH_EXEC') or exit('Restricted access');
interface ExceptionInterface extends \Throwable
{
}

View File

@@ -0,0 +1,18 @@
<?php
/**
* JCH Optimize - Performs several front-end optimizations for fast downloads.
*
* @author Samuel Marshall <samuel@jch-optimize.net>
* @copyright Copyright (c) 2022 Samuel Marshall / JCH Optimize
* @license GNU/GPLv3, or later. See LICENSE file
*
* If LICENSE file missing, see <http://www.gnu.org/licenses/>.
*/
namespace JchOptimize\Core\Exception;
\defined('_JCH_EXEC') or exit('Restricted access');
class ExcludeException extends \Exception implements \JchOptimize\Core\Exception\ExceptionInterface
{
}

View File

@@ -0,0 +1,18 @@
<?php
/**
* JCH Optimize - Performs several front-end optimizations for fast downloads.
*
* @author Samuel Marshall <samuel@jch-optimize.net>
* @copyright Copyright (c) 2022 Samuel Marshall / JCH Optimize
* @license GNU/GPLv3, or later. See LICENSE file
*
* If LICENSE file missing, see <http://www.gnu.org/licenses/>.
*/
namespace JchOptimize\Core\Exception;
\defined('_JCH_EXEC') or exit('Restricted access');
class InvalidArgumentException extends \InvalidArgumentException implements \JchOptimize\Core\Exception\ExceptionInterface
{
}

View File

@@ -0,0 +1,18 @@
<?php
/**
* JCH Optimize - Performs several front-end optimizations for fast downloads.
*
* @author Samuel Marshall <samuel@jch-optimize.net>
* @copyright Copyright (c) 2022 Samuel Marshall / JCH Optimize
* @license GNU/GPLv3, or later. See LICENSE file
*
* If LICENSE file missing, see <http://www.gnu.org/licenses/>.
*/
namespace JchOptimize\Core\Exception;
\defined('_JCH_EXEC') or exit('Restricted access');
class MissingDependencyException extends \JchOptimize\Core\Exception\RuntimeException implements \JchOptimize\Core\Exception\ExceptionInterface
{
}

View File

@@ -0,0 +1,22 @@
<?php
/**
* JCH Optimize - Performs several front-end optimizations for fast downloads.
*
* @author Samuel Marshall <samuel@jch-optimize.net>
* @copyright Copyright (c) 2022 Samuel Marshall / JCH Optimize
* @license GNU/GPLv3, or later. See LICENSE file
*
* If LICENSE file missing, see <http://www.gnu.org/licenses/>.
*/
namespace JchOptimize\Core\Exception;
\defined('_JCH_EXEC') or exit('Restricted access');
class PregErrorException extends \ErrorException implements \JchOptimize\Core\Exception\ExceptionInterface
{
public function __construct($message = '', $code = 0, $severity = \E_WARNING)
{
parent::__construct($message, $code, $severity);
}
}

View File

@@ -0,0 +1,18 @@
<?php
/**
* JCH Optimize - Performs several front-end optimizations for fast downloads.
*
* @author Samuel Marshall <samuel@jch-optimize.net>
* @copyright Copyright (c) 2022 Samuel Marshall / JCH Optimize
* @license GNU/GPLv3, or later. See LICENSE file
*
* If LICENSE file missing, see <http://www.gnu.org/licenses/>.
*/
namespace JchOptimize\Core\Exception;
\defined('_JCH_EXEC') or exit('Restricted access');
class RuntimeException extends \RuntimeException implements \JchOptimize\Core\Exception\ExceptionInterface
{
}

View File

@@ -0,0 +1,23 @@
<?php
/**
* JCH Optimize - Performs several front-end optimizations for fast downloads.
*
* @author Samuel Marshall <samuel@jch-optimize.net>
* @copyright Copyright (c) 2022 Samuel Marshall / JCH Optimize
* @license GNU/GPLv3, or later. See LICENSE file
*
* If LICENSE file missing, see <http://www.gnu.org/licenses/>.
*/
namespace JchOptimize\Core\Exception;
\defined('_JCH_EXEC') or exit('Restricted access');
trait StringableTrait
{
public function __toString()
{
return \get_class($this)." '{$this->getMessage()}' in {$this->getFile()}({$this->getLine()})\n{$this->getTraceAsString()}";
}
}

View File

@@ -0,0 +1,30 @@
<?php
/**
* JCH Optimize - Performs several front-end optimizations for fast downloads.
*
* @author Samuel Marshall <samuel@jch-optimize.net>
* @copyright Copyright (c) 2022 Samuel Marshall / JCH Optimize
* @license GNU/GPLv3, or later. See LICENSE file
*
* If LICENSE file missing, see <http://www.gnu.org/licenses/>.
*/
namespace JchOptimize\Core;
\defined('_JCH_EXEC') or exit('Restricted access');
trait FileInfosUtilsTrait
{
/**
* @var FileUtils
*/
private \JchOptimize\Core\FileUtils $fileUtils;
/**
* Truncate url at the '/' less than 40 characters prepending '...' to the string.
*/
public function prepareFileUrl(array $fileInfos, string $type): string
{
return isset($fileInfos['url']) ? $this->fileUtils->prepareForDisplay($fileInfos['url'], '', \true, 40) : ('css' == $type ? 'Style' : 'Script').' Declaration';
}
}

View File

@@ -0,0 +1,66 @@
<?php
/**
* JCH Optimize - Performs several front-end optimizations for fast downloads.
*
* @author Samuel Marshall <samuel@jch-optimize.net>
* @copyright Copyright (c) 2022 Samuel Marshall / JCH Optimize
* @license GNU/GPLv3, or later. See LICENSE file
*
* If LICENSE file missing, see <http://www.gnu.org/licenses/>.
*/
namespace JchOptimize\Core;
use _JchOptimizeVendor\GuzzleHttp\Psr7\Uri;
use _JchOptimizeVendor\GuzzleHttp\Psr7\UriComparator;
use _JchOptimizeVendor\GuzzleHttp\Psr7\UriResolver;
use _JchOptimizeVendor\Psr\Http\Message\UriInterface;
\defined('_JCH_EXEC') or exit('Restricted access');
class FileUtils
{
/**
* Prepare a representation of a file URL or value for display, possibly truncated.
*
* @param null|UriInterface $uri The string being prepared
* @param bool $truncate If true will be truncated at specified length, prepending with an epsilon
* @param int $length the length in number of characters
*/
public function prepareForDisplay(?UriInterface $uri = null, string $content = '', bool $truncate = \true, int $length = 27): string
{
$eps = '';
if ($uri) {
/* $uri = UriResolver::resolve(SystemUri::currentUri(), $uri);
if ( ! UriComparator::isCrossOrigin(SystemUri::currentUri(), $uri)) {
$url = $uri->getPath();
} else {
$url = Uri::composeComponents($uri->getScheme(), $uri->getAuthority(), $uri->getPath(), '', '');
}*/
$url = (string) $uri->withQuery('')->withFragment('');
if (!$truncate) {
return $url;
}
if (\strlen($url) > $length) {
$url = \substr($url, -$length);
$url = \preg_replace('#^[^/]*+/#', '/', $url);
$eps = '...';
}
return $eps.$url;
}
if (!$truncate) {
return $content;
}
if (\strlen($content) > 52) {
$content = \substr($content, 0, 52);
$eps = '...';
$content = $content.$eps;
}
if (\strlen($content) > 26) {
$content = \str_replace($content[26], $content[26]."\n", $content);
}
return $eps.$content;
}
}

View File

@@ -0,0 +1,286 @@
<?php
/**
* JCH Optimize - Performs several front-end optimizations for fast downloads.
*
* @author Samuel Marshall <samuel@jch-optimize.net>
* @copyright Copyright (c) 2022 Samuel Marshall / JCH Optimize
* @license GNU/GPLv3, or later. See LICENSE file
*
* If LICENSE file missing, see <http://www.gnu.org/licenses/>.
*/
namespace JchOptimize\Core;
use _JchOptimizeVendor\Psr\Http\Message\UriInterface;
use JchOptimize\Platform\Paths;
use Joomla\Filesystem\Folder;
use Joomla\Registry\Registry;
\defined('_JCH_EXEC') or exit('Restricted access');
/**
* Some helper functions.
*/
class Helper
{
/**
* Checks if file (can be external) exists.
*/
public static function fileExists(string $sPath): bool
{
if (0 === \strpos($sPath, 'http')) {
$sFileHeaders = @\get_headers($sPath);
return \false !== $sFileHeaders && \false === \strpos($sFileHeaders[0], '404');
}
return \file_exists($sPath);
}
public static function isMsieLT10(): bool
{
// $browser = Browser::getInstance( 'Mozilla/5.0 (Macintosh; Intel Mac OS X10_15_7) AppleWebkit/605.1.15 (KHTML, like Gecko) Version/14.1 Safari/605.1.15' );
/** @var Browser $browser */
$browser = \JchOptimize\Core\Browser::getInstance();
return 'Internet Explorer' == $browser->getBrowser() && \version_compare($browser->getVersion(), '10', '<');
}
public static function cleanReplacement(string $string): string
{
return \strtr($string, ['\\' => '\\\\', '$' => '\\$']);
}
/**
* @deprecated
*/
public static function getBaseFolder(): string
{
return \JchOptimize\Core\SystemUri::basePath();
}
public static function strReplace(string $search, string $replace, string $subject): string
{
return \str_replace(self::cleanPath($search), $replace, self::cleanPath($subject));
}
/**
* @return string|string[]
*/
public static function cleanPath(string $str)
{
return \str_replace(['\\\\', '\\'], '/', $str);
}
/**
* Determine if document is of XHTML doctype.
*/
public static function isXhtml(string $html): bool
{
return (bool) \preg_match('#^\\s*+(?:<!DOCTYPE(?=[^>]+XHTML)|<\\?xml.*?\\?>)#i', \trim($html));
}
/**
* Determines if document is of html5 doctype.
*
* @return bool True if doctype is html5
*/
public static function isHtml5(string $html): bool
{
return (bool) \preg_match('#^<!DOCTYPE html>#i', \trim($html));
}
/**
* Splits a string into an array using any regular delimiter or whitespace.
*
* @param array|string $string Delimited string of components
*
* @return string[] An array of the components
*/
public static function getArray($string): array
{
if (\is_array($string)) {
$array = $string;
} elseif (\is_string($string)) {
$array = \explode(',', \trim($string));
} else {
$array = [];
}
if (!empty($array)) {
$array = \array_map(function ($value) {
if (\is_string($value)) {
return \trim($value);
}
if (\is_object($value)) {
return (array) $value;
}
return $value;
}, $array);
}
return \array_filter($array);
}
/**
* @deprecated
* //Being used in Sprite Controller
*/
public static function postAsync(string $url, Registry $params, array $posts, $logger): void
{
$post_params = [];
foreach ($posts as $key => &$val) {
if (\is_array($val)) {
$val = \implode(',', $val);
}
$post_params[] = $key.'='.\urlencode($val);
}
$post_string = \implode('&', $post_params);
$parts = \JchOptimize\Core\Helper::parseUrl($url);
if (isset($parts['scheme']) && 'https' == $parts['scheme']) {
$protocol = 'ssl://';
$default_port = 443;
} else {
$protocol = '';
$default_port = 80;
}
$fp = @\fsockopen($protocol.$parts['host'], $parts['port'] ?? $default_port, $errno, $errstr, 1);
if (!$fp) {
$logger->error($errno.': '.$errstr, $params);
$logger->debug($errno.': '.$errstr, 'JCH_post-error');
} else {
$out = 'POST '.$parts['path'].'?'.$parts['query']." HTTP/1.1\r\n";
$out .= 'Host: '.$parts['host']."\r\n";
$out .= "Content-Type: application/x-www-form-urlencoded\r\n";
$out .= 'Content-Length: '.\strlen($post_string)."\r\n";
$out .= "Connection: Close\r\n\r\n";
if (isset($post_string)) {
$out .= $post_string;
}
\fwrite($fp, $out);
\fclose($fp);
$logger->debug($out, 'JCH_post');
}
}
public static function parseUrl(string $sUrl): array
{
\preg_match('#^(?:([a-z][a-z0-9+.-]*+):)?(?://(?:([^:@/]*+)(?::([^@/]*+))?@)?([^:/]++)(?::([^/]*+))?)?([^?\\#\\n]*+)?(?:\\?([^\\#\\n]*+))?(?:\\#(.*+))?$#i', $sUrl, $m);
$parts = [];
$parts['scheme'] = !empty($m[1]) ? $m[1] : null;
$parts['user'] = !empty($m[2]) ? $m[2] : null;
$parts['pass'] = !empty($m[3]) ? $m[3] : null;
$parts['host'] = !empty($m[4]) ? $m[4] : null;
$parts['port'] = !empty($m[5]) ? $m[5] : null;
$parts['path'] = !empty($m[6]) ? $m[6] : '';
$parts['query'] = !empty($m[7]) ? $m[7] : null;
$parts['fragment'] = !empty($m[8]) ? $m[8] : null;
return $parts;
}
/**
* @return false|int
*/
public static function validateHtml(string $html)
{
return \preg_match('#^(?>(?><?[^<]*+)*?<html(?><?[^<]*+)*?<head(?><?[^<]*+)*?</head\\s*+>)(?><?[^<]*+)*?<body.*</body\\s*+>(?><?[^<]*+)*?</html\\s*+>#is', $html);
}
/**
* @param array $excludedStringsArray Array of excluded values to compare against
* @param string $testString The string we're testing to see if it was excluded
* @param string $type (css|js) No longer used
*/
public static function findExcludes(array $excludedStringsArray, string $testString, string $type = ''): bool
{
if (empty($excludedStringsArray)) {
return \false;
}
foreach ($excludedStringsArray as $excludedString) {
// Remove all spaces from test string and excluded string
$excludedString = \preg_replace('#\\s#', '', $excludedString);
$testString = \preg_replace('#\\s#', '', $testString);
if ($excludedString && \false !== \strpos(\htmlspecialchars_decode($testString), $excludedString)) {
return \true;
}
}
return \false;
}
public static function extractUrlsFromSrcset($srcSet): array
{
$strings = \explode(',', $srcSet);
return \array_map(function ($v) {
$aUrlString = \explode(' ', \trim($v));
return \array_shift($aUrlString);
}, $strings);
}
/**
* Utility function to convert a rule set to a unique class.
*/
public static function cssSelectorsToClass(string $selectorGroup): string
{
return '_jch-'.\preg_replace('#[^0-9a-z_-]#i', '', $selectorGroup);
}
public static function deleteFolder(string $folder): bool
{
$it = new \RecursiveDirectoryIterator($folder, \FilesystemIterator::SKIP_DOTS);
$files = new \RecursiveIteratorIterator($it, \RecursiveIteratorIterator::CHILD_FIRST);
/** @var \SplFileInfo $file */
foreach ($files as $file) {
if ($file->isDir()) {
\rmdir($file->getPathname());
} else {
\unlink($file->getPathname());
}
}
\rmdir($folder);
return !\file_exists($folder);
}
/**
* Checks if a Uri is valid.
*/
public static function uriInvalid(UriInterface $uri): bool
{
if ('' == (string) $uri) {
return \true;
}
if ('' == $uri->getScheme() && '' == $uri->getAuthority() && '' == $uri->getQuery() && '' == $uri->getFragment()) {
if ('/' == $uri->getPath() || $uri->getPath() == \JchOptimize\Core\SystemUri::basePath()) {
return \true;
}
}
return \false;
}
/**
* @return false|int
*
* @psalm-return 0|1|false
*/
public static function isStaticFile(string $filePath)
{
return \preg_match('#\\.(?:css|js|png|jpe?g|gif|bmp|webp|svg)$#i', $filePath);
}
public static function createCacheFolder(): void
{
if (!\file_exists(Paths::cacheDir())) {
try {
Folder::create(Paths::cacheDir());
} catch (\Exception $exception) {
}
}
}
}

View File

@@ -0,0 +1,54 @@
<?php
/**
* JCH Optimize - Performs several front-end optimizations for fast downloads.
*
* @author Samuel Marshall <samuel@jch-optimize.net>
* @copyright Copyright (c) 2023 Samuel Marshall / JCH Optimize
* @license GNU/GPLv3, or later. See LICENSE file
*
* If LICENSE file missing, see <http://www.gnu.org/licenses/>.
*/
namespace JchOptimize\Core;
use JchOptimize\Platform\Paths;
use Joomla\Filesystem\File;
abstract class Htaccess
{
public static function updateHtaccess(string $content, array $lineDelimiters, bool $append = \true): void
{
$htaccessFile = self::getHtaccessFile();
if (\file_exists($htaccessFile)) {
$delimitedContent = $lineDelimiters[0].\PHP_EOL.$content.\PHP_EOL.$lineDelimiters[1];
$cleanedContents = self::getCleanedHtaccessContents($lineDelimiters, $htaccessFile);
if ($append) {
$content = $cleanedContents.\PHP_EOL.$delimitedContent;
File::write($htaccessFile, $content);
}
}
}
public static function cleanHtaccess(array $lineDelimiters): void
{
$htaccessFile = self::getHtaccessFile();
if (\file_exists($htaccessFile)) {
$cleanedContents = self::getCleanedHtaccessContents($lineDelimiters, $htaccessFile);
File::write($htaccessFile, $cleanedContents);
}
}
private static function getCleanedHtaccessContents(array $lineDelimiters, string $htaccessFile): string
{
$contents = \file_get_contents($htaccessFile);
$regex = '#[\\r\\n]*'.\preg_quote($lineDelimiters[0]).'.*?'.\preg_quote(\rtrim($lineDelimiters[1], "# \n\r\t\v\x00")).'[^\\r\\n]*[r\\n]*#s';
return \preg_replace($regex, \PHP_EOL, $contents, -1, $count);
}
private static function getHtaccessFile(): string
{
return Paths::rootPath().'/.htaccess';
}
}

View File

@@ -0,0 +1,408 @@
<?php
/**
* JCH Optimize - Performs several front-end optimizations for fast downloads.
*
* @author Samuel Marshall <samuel@jch-optimize.net>
* @copyright Copyright (c) 2022 Samuel Marshall / JCH Optimize
* @license GNU/GPLv3, or later. See LICENSE file
*
* If LICENSE file missing, see <http://www.gnu.org/licenses/>.
*/
namespace JchOptimize\Core\Html;
use _JchOptimizeVendor\GuzzleHttp\Psr7\UriResolver;
use _JchOptimizeVendor\Joomla\DI\ContainerAwareInterface;
use _JchOptimizeVendor\Joomla\DI\ContainerAwareTrait;
use _JchOptimizeVendor\Laminas\Cache\Pattern\CallbackCache;
use _JchOptimizeVendor\Laminas\Cache\Storage\IterableInterface;
use _JchOptimizeVendor\Laminas\Cache\Storage\StorageInterface;
use _JchOptimizeVendor\Laminas\Cache\Storage\TaggableInterface;
use JchOptimize\Core\Combiner;
use JchOptimize\Core\Css\Processor as CssProcessor;
use JchOptimize\Core\Exception as CoreException;
use JchOptimize\Core\FeatureHelpers\DynamicJs;
use JchOptimize\Core\FeatureHelpers\Fonts;
use JchOptimize\Core\FeatureHelpers\LazyLoadExtended;
use JchOptimize\Core\Helper;
use JchOptimize\Core\Http2Preload;
use JchOptimize\Core\PageCache\PageCache;
use JchOptimize\Core\SerializableTrait;
use JchOptimize\Core\StorageTaggingTrait;
use JchOptimize\Core\SystemUri;
use JchOptimize\Core\Uri\UriComparator;
use JchOptimize\Core\Uri\UriConverter;
use JchOptimize\Core\Uri\Utils;
use JchOptimize\Platform\Paths;
use JchOptimize\Platform\Profiler;
use Joomla\Registry\Registry;
use Psr\Log\LoggerAwareInterface;
use Psr\Log\LoggerAwareTrait;
use function getimagesize;
\defined('_JCH_EXEC') or exit('Restricted access');
/**
* Class CacheManager.
*/
class CacheManager implements LoggerAwareInterface, ContainerAwareInterface, \Serializable
{
use ContainerAwareTrait;
use LoggerAwareTrait;
use SerializableTrait;
use StorageTaggingTrait;
private Registry $params;
/**
* @var LinkBuilder
*/
private \JchOptimize\Core\Html\LinkBuilder $linkBuilder;
/**
* @var FilesManager
*/
private \JchOptimize\Core\Html\FilesManager $filesManager;
private CallbackCache $callbackCache;
private Combiner $combiner;
private Http2Preload $http2Preload;
/**
* @var Processor
*/
private \JchOptimize\Core\Html\Processor $processor;
/**
* @var IterableInterface&StorageInterface&TaggableInterface
*/
private $taggableCache;
/**
* @param IterableInterface&StorageInterface&TaggableInterface $taggableCache
*/
public function __construct(Registry $params, LinkBuilder $linkBuilder, Combiner $combiner, FilesManager $filesManager, CallbackCache $callbackCache, $taggableCache, Http2Preload $http2Preload, Processor $processor)
{
$this->params = $params;
$this->linkBuilder = $linkBuilder;
$this->combiner = $combiner;
$this->filesManager = $filesManager;
$this->callbackCache = $callbackCache;
$this->taggableCache = $taggableCache;
$this->http2Preload = $http2Preload;
$this->processor = $processor;
}
/**
* @throws CoreException\ExceptionInterface
*/
public function handleCombineJsCss(): void
{
// If amp page we don't generate combined files
if ($this->processor->isAmpPage) {
return;
}
// Indexed multidimensional array of files to be combined
$aCssLinksArray = $this->filesManager->aCss;
$aJsLinksArray = $this->filesManager->aJs;
$section = '1' == $this->params->get('bottom_js', '0') ? 'body' : 'head';
if (!Helper::isMsieLT10() && $this->params->get('combine_files_enable', '1')) {
$bCombineCss = (bool) $this->params->get('css', 1);
$bCombineJs = (bool) $this->params->get('js', 1);
if ($bCombineCss && !empty($aCssLinksArray[0])) {
/** @var CssProcessor $oCssProcessor */
$oCssProcessor = $this->container->get(CssProcessor::class);
$pageCss = '';
$cssUrls = [];
foreach ($aCssLinksArray as $aCssLinks) {
// Optimize and cache css files
$aCssCache = $this->getCombinedFiles($aCssLinks, $sCssCacheId, 'css');
if (JCH_PRO) {
// @see Fonts::generateCombinedFilesForFonts()
$this->container->get(Fonts::class)->generateCombinedFilesForFonts($aCssCache);
/** @var LazyLoadExtended $lazyLoadExtended */
$lazyLoadExtended = $this->container->get(LazyLoadExtended::class);
$lazyLoadExtended->cssBgImagesSelectors = \array_merge($lazyLoadExtended->cssBgImagesSelectors, $aCssCache['bgselectors']);
}
// If Optimize CSS Delivery feature not enabled then we'll need to insert the link to
// the combined css file in the HTML
if (!$this->params->get('optimizeCssDelivery_enable', '0')) {
// Http2Preload push
$oCssProcessor->preloadHttp2($aCssCache['contents'], \true);
$this->linkBuilder->replaceLinks($sCssCacheId, 'css');
} else {
$pageCss .= $aCssCache['contents'];
$cssUrls[] = $this->linkBuilder->buildUrl($sCssCacheId, 'css');
}
}
$css_delivery_enabled = $this->params->get('optimizeCssDelivery_enable', '0');
if ($css_delivery_enabled) {
try {
$sCriticalCss = $this->getCriticalCss($oCssProcessor, $pageCss, $id);
// Http2Preload push fonts in critical css
$oCssProcessor->preloadHttp2($sCriticalCss);
$this->linkBuilder->addCriticalCssToHead($sCriticalCss, $id);
$this->linkBuilder->loadCssAsync($cssUrls);
} catch (CoreException\ExceptionInterface $oException) {
$this->logger->error('Optimize CSS Delivery failed: '.$oException->getMessage());
// @TODO Just add CssUrls to HEAD section of document
}
}
}
if ($bCombineJs) {
$this->linkBuilder->addExcludedJsToSection($section);
if (!empty($aJsLinksArray[0])) {
foreach ($aJsLinksArray as $aJsLinksKey => $aJsLinks) {
// Dynamically load files after the last excluded files if param is enabled
if ($this->params->get('pro_reduce_unused_js_enable', '0') && $aJsLinksKey >= $this->filesManager->jsExcludedIndex && !empty($this->filesManager->aJs[$this->filesManager->iIndex_js])) {
DynamicJs::$dynamicJs[] = $aJsLinks;
continue;
}
// Optimize and cache javascript files
$this->getCombinedFiles($aJsLinks, $sJsCacheId, 'js');
// Insert link to combined javascript file in HTML
$this->linkBuilder->replaceLinks($sJsCacheId, 'js', $section, $aJsLinksKey);
}
}
// We also now append any deferred javascript files below the
// last combined javascript file
$this->linkBuilder->addDeferredJs($section);
}
}
if ($this->params->get('lazyload_enable', '0')) {
$jsLazyLoadAssets = $this->getJsLazyLoadAssets();
$this->getCombinedFiles($jsLazyLoadAssets, $lazyLoadCacheId, 'js');
$this->linkBuilder->addJsLazyLoadAssetsToHtml($lazyLoadCacheId, $section);
}
$this->linkBuilder->appendAsyncScriptsToHead();
}
/**
* Returns contents of the combined files from cache.
*
* @param array $links Indexed multidimensional array of file urls to combine
* @param null|string $id Id of generated cache file
* @param string $type css or js
*
* @return array|string Contents in array from cache containing combined file(s)
*/
public function getCombinedFiles(array $links, ?string &$id, string $type)
{
!JCH_DEBUG ?: Profiler::start('GetCombinedFiles - '.$type);
$aArgs = [$links];
/**
* @see Combiner::getCssContents()
* @see Combiner::getJsContents()
*/
$aFunction = [$this->combiner, 'get'.\ucfirst($type).'Contents'];
$aCachedContents = $this->loadCache($aFunction, $aArgs, $id);
!JCH_DEBUG ?: Profiler::stop('GetCombinedFiles - '.$type, \true);
return $aCachedContents;
}
/**
* @param array $ids Ids of files that are already combined
* @param array $fileMatches Array matches of file to be appended to the combined file
*
* @return array|bool|string
*/
public function getAppendedFiles(array $ids, array $fileMatches, ?string &$id)
{
!JCH_DEBUG ?: Profiler::start('GetAppendedFiles');
$args = [$ids, $fileMatches, 'js'];
$function = [$this->combiner, 'appendFiles'];
$cachedContents = $this->loadCache($function, $args, $id);
!JCH_DEBUG ?: Profiler::stop('GetAppendedFiles', \true);
return $cachedContents;
}
public function handleImgAttributes(): void
{
if (!empty($this->processor->images)) {
!JCH_DEBUG ?: Profiler::start('AddImgAttributes');
try {
$aImgAttributes = $this->loadCache([$this, 'getCachedImgAttributes'], [$this->processor->images], $id);
} catch (CoreException\ExceptionInterface $e) {
return;
}
$this->linkBuilder->setImgAttributes($aImgAttributes);
}
!JCH_DEBUG ?: Profiler::stop('AddImgAttributes', \true);
}
public function getCachedImgAttributes(array $aImages): array
{
$aImgAttributes = [];
$total = \count($aImages[0]);
for ($i = 0; $i < $total; ++$i) {
if ($aImages[2][$i]) {
// delimiter
$delim = $aImages[3][$i];
// Image url
$url = $aImages[4][$i];
} else {
$delim = $aImages[6][$i];
$url = $aImages[7][$i];
}
$uri = Utils::uriFor($url);
$uri = UriResolver::resolve(SystemUri::currentUri(), $uri);
if (UriComparator::isCrossOrigin($uri)) {
$aImgAttributes[] = $aImages[0][$i];
continue;
}
$path = UriConverter::uriToFilePath($uri);
if (!\file_exists($path)) {
$aImgAttributes[] = $aImages[0][$i];
continue;
}
$aSize = @\getimagesize(\htmlentities($path));
if (empty($aSize) || '1' == $aSize[0] && '1' == $aSize[1]) {
$aImgAttributes[] = $aImages[0][$i];
continue;
}
$isImageAttrEnabled = $this->params->get('img_attributes_enable', '0');
// Let's start with the assumption there are no attributes
$existingAttributes = \false;
// Checks for any existing width attribute
if (\JchOptimize\Core\Html\FilesManager::hasAttributes($aImages[0][$i], ['width'], $aMatches)) {
// Calculate height based on aspect ratio
$iWidthAttrValue = \preg_replace('#[^0-9]#', '', $aMatches[1]);
// Check if a value was found for the attribute
if ($iWidthAttrValue) {
// Value found so we try to add the height attribute
$height = \round($aSize[1] / $aSize[0] * (int) $iWidthAttrValue, 2);
// If add attributes not enabled put data-height instead
$heightAttribute = $isImageAttrEnabled ? 'height=' : 'data-height=';
$heightAttribute .= $delim.$height.$delim;
// Add height attribute to the img element and save in array
$aImgAttributes[] = \preg_replace('#\\s*+/?>$#', ' '.$heightAttribute.' />', $aImages[0][$i]);
// We found an attribute
$existingAttributes = \true;
} else {
// No value found, so we remove the attribute and add it later
$aImages[0][$i] = \str_replace($aMatches[0], '', $aImages[0][$i]);
}
} elseif (\JchOptimize\Core\Html\FilesManager::hasAttributes($aImages[0][$i], ['height'], $aMatches)) {
// Calculate width based on aspect ratio
$iHeightAttrValue = \preg_replace('#[^0-9]#', '', $aMatches[1]);
// Check if a value was found for the height
if ($iHeightAttrValue) {
$width = \round($aSize[0] / $aSize[1] * (int) $iHeightAttrValue, 2);
// if add attributes not enabled put data-width instead
$widthAttribute = $isImageAttrEnabled ? 'width=' : 'data-width=';
$widthAttribute .= $delim.$width.$delim;
// Add width attribute to the img element and save in array
$aImgAttributes[] = \preg_replace('#\\s*+/?>$#', ' '.$widthAttribute.' />', $aImages[0][$i]);
} else {
// No value found, we remove the attribute and add it later
$aImages[0][$i] = \str_replace($aMatches[0], '', $aImages[0][$i]);
}
}
// No existing attributes, just go ahead and add attributes from getimagesize
if (!$existingAttributes) {
// It's best to use the same delimiter for the width/height attributes that the urls used
if ($delim) {
$sReplace = ' '.\str_replace('"', $delim, $aSize[3]);
} else {
$sReplace = ' '.$aSize[3];
}
// Add the width and height attributes from the getimagesize function
$sReplace = \preg_replace('#\\s*+/?>$#', $sReplace.' />', $aImages[0][$i]);
if (!$isImageAttrEnabled) {
$sReplace = \str_replace(['width=', 'height='], ['data-width=', 'data-height='], $sReplace);
}
$aImgAttributes[] = $sReplace;
}
}
return $aImgAttributes;
}
/**
* @return array|bool|string
*
* @throws CoreException\MissingDependencyException
*/
protected function getCriticalCss(CssProcessor $oCssProcessor, string $pageCss, ?string &$iCacheId)
{
if (!\class_exists('DOMDocument') || !\class_exists('DOMXPath')) {
throw new CoreException\MissingDependencyException('Document Object Model not supported');
}
$html = $this->processor->cleanHtml();
// Remove all attributes from HTML elements to avoid randomly generated characters from creating excess cache
$html = \preg_replace('#<([a-z0-9]++)[^>]*+>#i', '<\\1>', $html);
// Truncate HTML to 400 elements to key cache
$htmlKey = '';
\preg_replace_callback('#<[a-z0-9]++[^>]*+>(?><?[^<]*+(<ul\\b[^>]*+>(?>[^<]*+<(?!ul)[^<]*+|(?1))*?</ul>)?)*?(?=<[a-z0-9])#i', function ($aM) use (&$htmlKey) {
$htmlKey .= $aM[0];
return $aM[0];
}, $html, 400);
$aArgs = [$pageCss, $htmlKey];
/** @see CssProcessor::optimizeCssDelivery() */
$aFunction = [$oCssProcessor, 'optimizeCssDelivery'];
return $this->loadCache($aFunction, $aArgs, $iCacheId);
}
/**
* Create and cache aggregated file if it doesn't exist and also tag the cache with the current page url.
*
* @param callable $function Name of function used to aggregate filesG
* @param array $args Arguments used by function above
* @param null|string $id Generated id to identify cached file
*
* @return array|bool|string
*
* @throws CoreException\RuntimeException
*/
private function loadCache(callable $function, array $args, ?string &$id)
{
try {
$id = $this->callbackCache->generateKey($function, $args);
$results = $this->callbackCache->call($function, $args);
$this->tagStorage($id);
// if Tagging wasn't successful, best we abort
if (empty($this->taggableCache->getTags($id))) {
/** @var PageCache $pageCache */
$pageCache = $this->container->get(PageCache::class);
$pageCache->disableCaching();
throw new \Exception('Tagging failed');
}
// Returns the contents of the combined file or false if failure
return $results;
} catch (\Exception $e) {
throw new CoreException\RuntimeException('Error creating cache files: '.$e->getMessage());
}
}
private function getJsLazyLoadAssets(): array
{
$assets = [];
$assets[]['url'] = Utils::uriFor(Paths::mediaUrl().'/core/js/ls.loader.js?'.JCH_VERSION);
if (JCH_PRO && $this->params->get('pro_lazyload_effects', '0')) {
$assets[]['url'] = Utils::uriFor(Paths::mediaUrl().'/core/js/ls.loader.effects.js?'.JCH_VERSION);
}
if (JCH_PRO && ($this->params->get('pro_lazyload_bgimages', '0') || $this->params->get('pro_lazyload_audiovideo', '0'))) {
$assets[]['url'] = Utils::uriFor(Paths::mediaUrl().'/lazysizes/ls.unveilhooks.min.js?'.JCH_VERSION);
}
$assets[]['url'] = Utils::uriFor(Paths::mediaUrl().'/lazysizes/lazysizes.min.js?'.JCH_VERSION);
return $assets;
}
}

View File

@@ -0,0 +1,53 @@
<?php
/**
* JCH Optimize - Performs several front-end optimizations for fast downloads.
*
* @author Samuel Marshall <samuel@jch-optimize.net>
* @copyright Copyright (c) 2022 Samuel Marshall / JCH Optimize
* @license GNU/GPLv3, or later. See LICENSE file
*
* If LICENSE file missing, see <http://www.gnu.org/licenses/>.
*/
namespace JchOptimize\Core\Html\Callbacks;
use _JchOptimizeVendor\Joomla\DI\Container;
use _JchOptimizeVendor\Joomla\DI\ContainerAwareInterface;
use _JchOptimizeVendor\Joomla\DI\ContainerAwareTrait;
use Joomla\Registry\Registry;
\defined('_JCH_EXEC') or exit('Restricted access');
abstract class AbstractCallback implements ContainerAwareInterface
{
use ContainerAwareTrait;
/**
* @var string RegEx used to process HTML
*/
protected string $regex;
/**
* @var Registry Plugin parameters
*/
protected Registry $params;
/**
* Constructor.
*/
public function __construct(Container $container, Registry $params)
{
$this->container = $container;
$this->params = $params;
}
public function setRegex(string $regex): void
{
$this->regex = $regex;
}
/**
* @param string[] $matches
*/
abstract public function processMatches(array $matches): string;
}

View File

@@ -0,0 +1,162 @@
<?php
/**
* JCH Optimize - Performs several front-end optimizations for fast downloads.
*
* @author Samuel Marshall <samuel@jch-optimize.net>
* @copyright Copyright (c) 2022 Samuel Marshall / JCH Optimize
* @license GNU/GPLv3, or later. See LICENSE file
*
* If LICENSE file missing, see <http://www.gnu.org/licenses/>.
*/
namespace JchOptimize\Core\Html\Callbacks;
use _JchOptimizeVendor\GuzzleHttp\Psr7\UriResolver;
use _JchOptimizeVendor\Joomla\DI\Container;
use _JchOptimizeVendor\Psr\Http\Message\UriInterface;
use JchOptimize\Core\Cdn as CdnCore;
use JchOptimize\Core\Css\Parser as CssParser;
use JchOptimize\Core\Uri\Utils;
use Joomla\Registry\Registry;
\defined('_JCH_EXEC') or exit('Restricted access');
class Cdn extends \JchOptimize\Core\Html\Callbacks\AbstractCallback
{
protected string $context = 'default';
protected UriInterface $baseUri;
protected string $searchRegex = '';
protected string $localhost = '';
private CdnCore $cdn;
public function __construct(Container $container, Registry $params, CdnCore $cdn)
{
parent::__construct($container, $params);
$this->cdn = $cdn;
}
public function processMatches(array $matches): string
{
if ('' === \trim($matches[0])) {
return $matches[0];
}
switch ($this->context) {
case 'cssurl':
// This would be either a <style> element, or an HTML element with a style attribute, containing one or more CSS urls
$styleOrElement = $matches[0];
$regex = 'url\\([\'"]?('.$this->searchRegex.CssParser::cssUrlValueToken().')([\'"]?\\))';
// Find all css urls in content
\preg_match_all('#'.$regex.'#i', $styleOrElement, $aCssUrls, \PREG_SET_ORDER);
// Prevent modifying the same url multiple times
$aCssUrls = \array_unique($aCssUrls, \SORT_REGULAR);
foreach ($aCssUrls as $aCssUrlMatch) {
$cssUrl = @$aCssUrlMatch[0] ?: \false;
$urlWithQuery = @$aCssUrlMatch[1] ?: \false;
$url = @$aCssUrlMatch[2];
if (\false !== $cssUrl && \false !== $url) {
$uri = Utils::uriFor($url);
$resolvedUri = $this->resolvePathToBase($uri);
$cdnUrl = $this->cdn->loadCdnResource($resolvedUri, $uri);
// First replace the url in the css url
$cdnCssUrl = \str_replace($urlWithQuery, (string) $cdnUrl, $cssUrl);
// Replace the css url in content
$styleOrElement = \str_replace($cssUrl, $cdnCssUrl, $styleOrElement);
}
}
return $styleOrElement;
case 'srcset':
$fullMatch = $matches[0];
$srcSetAttr = @$matches[2] ?: \false;
$srcSetValue = @$matches[4] ?: \false;
$dataSrcSetAttr = (@$matches[5] ?: @$matches[8]) ?: \false;
$dataSrcSetValue = (@$matches[7] ?: @$matches[10]) ?: \false;
$returnMatch = $fullMatch;
if (\false !== $srcSetAttr && \false !== $srcSetValue) {
$returnMatch = $this->handleSrcSetValues($srcSetAttr, $srcSetValue, $returnMatch);
}
if (\false !== $dataSrcSetAttr && \false !== $dataSrcSetValue) {
$returnMatch = $this->handleSrcSetValues($dataSrcSetAttr, $dataSrcSetValue, $returnMatch);
}
return $returnMatch;
default:
$fullMatch = $matches[0];
$hrefSrcAttr = @$matches[3] ?: \false;
$hrefSrcValue = @$matches[5] ?: \false;
$hrefSrcValueWithQuery = @$matches[6] ?: \false;
$dataSrcAttr = (@$matches[7] ?: @$matches[11]) ?: \false;
$dataSrcValue = (@$matches[9] ?: @$matches[13]) ?: \false;
$dataSrcValueWithQuery = (@$matches[10] ?: @$matches[14]) ?: \false;
$returnMatch = $fullMatch;
if (\false !== $hrefSrcAttr && \false !== $hrefSrcValue) {
$returnMatch = $this->srcValueToCdnValue($hrefSrcValue, $hrefSrcValueWithQuery, $hrefSrcAttr, $returnMatch);
}
if (\false !== $dataSrcAttr && \false !== $dataSrcValue) {
$returnMatch = $this->srcValueToCdnValue($dataSrcValue, $dataSrcValueWithQuery, $dataSrcAttr, $returnMatch);
}
return $returnMatch;
}
}
public function setBaseUri(UriInterface $baseUri): void
{
$this->baseUri = $baseUri;
}
public function setLocalhost(string $sLocalhost): void
{
$this->localhost = $sLocalhost;
}
public function setContext(string $sContext): void
{
$this->context = $sContext;
}
public function setSearchRegex(string $sSearchRegex): void
{
$this->searchRegex = $sSearchRegex;
}
protected function srcValueToCdnValue(string $srcValue, string $srcValueWithQuery, string $srcAttr, string $returnMatch): string
{
$srcUri = Utils::uriFor($srcValue);
$resolvedSrcValue = $this->resolvePathToBase($srcUri);
$cdnSrcValue = $this->cdn->loadCdnResource($resolvedSrcValue, $srcUri);
// First replace the url in the data-src attribute
$cdnDataSrcAttr = \str_replace($srcValueWithQuery, (string) $cdnSrcValue, $srcAttr);
// Then replace the original attribute with the attribute containing CDN url
return \str_replace($srcAttr, $cdnDataSrcAttr, $returnMatch);
}
protected function resolvePathToBase(UriInterface $uri): UriInterface
{
return UriResolver::resolve($this->baseUri, $uri);
}
protected function handleSrcSetValues(string $attribute, $uri, string $returnMatch): string
{
$cdnSrcSetAttr = $attribute;
$regex = '(?:^|,)\\s*+('.$this->searchRegex.'([^,]++))';
\preg_match_all('#'.$regex.'#i', $uri, $aUrls, \PREG_SET_ORDER);
// Cache urls in the srcset as we process them to ensure we don't process the same url twice
$processedUrls = [];
foreach ($aUrls as $aUrlMatch) {
$uri = Utils::uriFor($aUrlMatch[2]);
if (!empty($aUrlMatch[0]) && !\in_array((string) $uri, $processedUrls)) {
$processedUrls[] = $uri;
$resolvedUri = $this->resolvePathToBase($uri);
$cdnUrl = $this->cdn->loadCdnResource($resolvedUri, $uri);
$cdnSrcSetAttr = \str_replace($aUrlMatch[2], (string) $cdnUrl, $cdnSrcSetAttr);
}
}
return \str_replace($attribute, $cdnSrcSetAttr, $returnMatch);
}
}

View File

@@ -0,0 +1,184 @@
<?php
/**
* JCH Optimize - Performs several front-end optimizations for fast downloads.
*
* @author Samuel Marshall <samuel@jch-optimize.net>
* @copyright Copyright (c) 2022 Samuel Marshall / JCH Optimize
* @license GNU/GPLv3, or later. See LICENSE file
*
* If LICENSE file missing, see <http://www.gnu.org/licenses/>.
*/
namespace JchOptimize\Core\Html\Callbacks;
use _JchOptimizeVendor\Joomla\DI\Container;
use _JchOptimizeVendor\Psr\Http\Message\UriInterface;
use JchOptimize\Core\Helper;
use JchOptimize\Core\Html\FilesManager;
use JchOptimize\Core\Html\Processor as HtmlProcessor;
use JchOptimize\Core\Http2Preload;
use JchOptimize\Core\Uri\Utils;
use JchOptimize\Platform\Excludes;
use JchOptimize\Platform\Profiler;
use Joomla\Registry\Registry;
\defined('_JCH_EXEC') or exit('Restricted access');
class CombineJsCss extends \JchOptimize\Core\Html\Callbacks\AbstractCallback
{
/**
* @var array<string, array{
* excludes_peo:array{
* js:list<array{ url?:string, script?:string, ieo?:string, dontmove?:string }>,
* css:string[],
* js_script:list<array{ url?:string, script?:string, ieo?:string, dontmove?:string }>,
* css_script:string[]
* },
* critical_js:array{
* js:string[],
* script:string[]
* },
* remove:array{
* js:string[],
* css:string[]
* }
*}> Array of excludes parameters
*/
private array $excludes = ['head' => ['excludes_peo' => ['js' => [[]], 'css' => [], 'js_script' => [[]], 'css_script' => []], 'critical_js' => ['js' => [], 'script' => []], 'remove' => ['js' => [], 'css' => []]]];
/**
* @var string Section of the HTML being processed
*/
private string $section = 'head';
private FilesManager $filesManager;
private Http2Preload $http2Preload;
private HtmlProcessor $htmlProcessor;
/**
* CombineJsCss constructor.
*/
public function __construct(Container $container, Registry $params, FilesManager $filesManager, Http2Preload $http2Preload, HtmlProcessor $htmlProcessor)
{
parent::__construct($container, $params);
$this->filesManager = $filesManager;
$this->http2Preload = $http2Preload;
$this->htmlProcessor = $htmlProcessor;
$this->setupExcludes();
}
public function processMatches(array $matches): string
{
if ('' === \trim($matches[0])) {
return $matches[0];
}
if (isset($matches[4])) {
$uri = $matches['url'] = Utils::uriFor($matches[4]);
} else {
$matches['content'] = $matches[2];
}
if (\preg_match('#^<!--#', $matches[0])) {
return $matches[0];
}
// If url is invalid just remove it, sometimes they cause the page to download again so most likely
// would be better
if (isset($uri) && $uri instanceof UriInterface && Helper::uriInvalid($uri)) {
return '';
}
$type = 0 == \strcasecmp($matches[1], 'script') ? 'js' : 'css';
// Remove js files
if ('js' == $type && isset($uri) && $uri instanceof UriInterface && Helper::findExcludes(@$this->excludes[$this->section]['remove']['js'], (string) $uri)) {
return '';
}
// Remove css files
if ('css' == $type && isset($uri) && $uri instanceof UriInterface && Helper::findExcludes(@$this->excludes[$this->section]['remove']['css'], (string) $uri)) {
return '';
}
if ('js' == $type && (!$this->params->get('javascript', '1') || !$this->params->get('combine_files_enable', '1') || $this->htmlProcessor->isAmpPage)) {
$deferred = $this->filesManager->isFileDeferred($matches[0]);
if (isset($uri) && $uri instanceof UriInterface) {
$this->http2Preload->add($uri, 'script', $deferred);
}
return $matches[0];
}
if ('css' == $type && (!$this->params->get('css', '1') || !$this->params->get('combine_files_enable', '1') || $this->htmlProcessor->isAmpPage)) {
if (isset($uri)) {
$this->http2Preload->add($uri, 'style');
}
return $matches[0];
}
$this->filesManager->setExcludes($this->excludes[$this->section]);
return $this->filesManager->processFiles($type, $matches);
}
public function setSection(string $section): void
{
$this->section = $section;
}
/**
* Retrieves all exclusion parameters for the Combine Files feature.
*/
private function setupExcludes()
{
JCH_DEBUG ? Profiler::start('SetUpExcludes') : null;
$aExcludes = [];
$params = $this->params;
// These parameters will be excluded while preserving execution order
$aExJsComp = $this->getExComp($params->get('excludeJsComponents_peo', ''));
$aExCssComp = $this->getExComp($params->get('excludeCssComponents', ''));
$aExcludeJs_peo = Helper::getArray($params->get('excludeJs_peo', ''));
$aExcludeCss_peo = Helper::getArray($params->get('excludeCss', ''));
$aExcludeScript_peo = Helper::getArray($params->get('excludeScripts_peo', ''));
$aExcludeStyle_peo = Helper::getArray($params->get('excludeStyles', ''));
$aExcludeScript_peo = \array_map(function ($script) {
if (isset($script['script'])) {
$script['script'] = \stripslashes($script['script']);
}
return $script;
}, $aExcludeScript_peo);
$aExcludes['excludes_peo']['js'] = \array_merge($aExcludeJs_peo, $aExJsComp, [['url' => '.com/maps/api/js'], ['url' => '.com/jsapi'], ['url' => '.com/uds'], ['url' => 'typekit.net'], ['url' => 'cdn.ampproject.org'], ['url' => 'googleadservices.com/pagead/conversion']], Excludes::head('js'));
$aExcludes['excludes_peo']['css'] = \array_merge($aExcludeCss_peo, $aExCssComp, Excludes::head('css'));
$aExcludes['excludes_peo']['js_script'] = $aExcludeScript_peo;
$aExcludes['excludes_peo']['css_script'] = $aExcludeStyle_peo;
$aExcludes['critical_js']['js'] = Helper::getArray($params->get('pro_criticalJs', ''));
$aExcludes['critical_js']['script'] = Helper::getArray($params->get('pro_criticalScripts', ''));
$aExcludes['remove']['js'] = Helper::getArray($params->get('remove_js', ''));
$aExcludes['remove']['css'] = Helper::getArray($params->get('remove_css', ''));
$this->excludes['head'] = $aExcludes;
if (1 == $this->params->get('bottom_js', '0')) {
$aExcludes['excludes_peo']['js_script'] = \array_merge($aExcludes['excludes_peo']['js_script'], [['script' => 'var google_conversion'], ['script' => '.write(', 'dontmove' => 'on']], Excludes::body('js', 'script'));
$aExcludes['excludes_peo']['js'] = \array_merge($aExcludes['excludes_peo']['js'], [['url' => '.com/recaptcha/api']], Excludes::body('js'));
$this->excludes['body'] = $aExcludes;
}
JCH_DEBUG ? Profiler::stop('SetUpExcludes', \true) : null;
}
/**
* Generates regex for excluding components set in plugin params.
*/
private function getExComp($excludedComParams): array
{
$components = Helper::getArray($excludedComParams);
$excludedComponents = [];
if (!empty($components)) {
$excludedComponents = \array_map(function ($value) {
if (isset($value['url'])) {
$value['url'] = \rtrim($value['url'], '/').'/';
} else {
$value = \rtrim($value, '/').'/';
}
return $value;
}, $components);
}
return $excludedComponents;
}
}

View File

@@ -0,0 +1,340 @@
<?php
/**
* JCH Optimize - Performs several front-end optimizations for fast downloads.
*
* @author Samuel Marshall <samuel@jch-optimize.net>
* @copyright Copyright (c) 2022 Samuel Marshall / JCH Optimize
* @license GNU/GPLv3, or later. See LICENSE file
*
* If LICENSE file missing, see <http://www.gnu.org/licenses/>.
*/
namespace JchOptimize\Core\Html\Callbacks;
use _JchOptimizeVendor\Joomla\DI\Container;
use _JchOptimizeVendor\Psr\Http\Message\UriInterface;
use JchOptimize\Core\Css\Processor;
use JchOptimize\Core\Exception\PregErrorException;
use JchOptimize\Core\FeatureHelpers\LazyLoadExtended;
use JchOptimize\Core\FeatureHelpers\Webp;
use JchOptimize\Core\Helper;
use JchOptimize\Core\Html\ElementObject;
use JchOptimize\Core\Html\Parser;
use JchOptimize\Core\Http2Preload;
use JchOptimize\Core\Uri\Utils;
use Joomla\Registry\Registry;
\defined('_JCH_EXEC') or exit('Restricted access');
class LazyLoad extends \JchOptimize\Core\Html\Callbacks\AbstractCallback
{
/**
* @var bool Used to indicate when the child of a parent element is excluded so the whole element
* can be excluded
*/
public bool $isExcluded = \false;
public Http2Preload $http2Preload;
/**
* @var int Width of <img> element inside <picture>
*/
public int $width = 1;
/**
* @var int Height of <img> element inside picture
*/
public int $height = 1;
protected array $excludes;
protected array $args;
public function __construct(Container $container, Registry $params, Http2Preload $http2Preload)
{
parent::__construct($container, $params);
$this->http2Preload = $http2Preload;
$this->getLazyLoadExcludes();
}
public function processMatches(array $matches): string
{
if (empty($matches[0])) {
return $matches[0];
}
// If we're lazyloading background images in a style that wasn't combined
if ('style' == $matches[1] && \JCH_PRO && ($this->params->get('pro_lazyload_bgimages', '0') || $this->params->get('pro_load_webp_images', '0'))) {
/** @var Processor $cssProcessor */
$cssProcessor = $this->getContainer()->get(Processor::class);
$cssProcessor->setCss($matches[2]);
$cssProcessor->processUrls();
$processedCss = $cssProcessor->getCss();
return \str_replace($matches[2], $processedCss, $matches[0]);
}
// Let's assign more readily recognizable names to the matches
$matches['fullMatch'] = $matches[0];
$matches['elementName'] = !empty($matches[1]) ? $matches[1] : \false;
$matches['classAttribute'] = !empty($matches[2]) ? $matches[2] : \false;
$matches['classDelimiter'] = !empty($matches[3]) ? $matches[3] : '"';
$matches['classValue'] = !empty($matches[4]) ? $matches[4] : \false;
$matches['srcAttribute'] = $matches['innerContent'] = $matches['styleAttribute'] = !empty($matches[5]) ? $matches[5] : \false;
$matches['srcDelimiter'] = $matches['styleDelimiter'] = !empty($matches[6]) ? $matches[6] : '"';
$matches['srcValue'] = $matches['bgDeclaration'] = !empty($matches[7]) ? $matches[7] : \false;
$matches['srcsetAttribute'] = $matches['posterAttribute'] = $matches['cssUrl'] = !empty($matches[8]) ? $matches[8] : \false;
$matches['srcsetDelimiter'] = $matches['posterDelimiter'] = $matches['cssUrlValue'] = !empty($matches[9]) ? $matches[9] : \false;
$matches['srcsetValue'] = $matches['posterValue'] = !empty($matches[10]) ? $matches[10] : \false;
$matches['autoloadAttribute'] = $matches['preloadAttribute'] = $matches['widthAttribute'] = !empty($matches[11]) ? $matches[11] : \false;
$matches['widthDelimiter'] = $matches['preloadDelimiter'] = !empty($matches[12]) ? $matches[12] : \false;
$matches['widthValue'] = $matches['preloadValue'] = !empty($matches[13]) ? (int) $matches[13] : 1;
$matches['heightAttribute'] = $matches['autoplayAttribute'] = !empty($matches[14]) ? $matches[14] : \false;
$matches['heightDelimiter'] = $matches['autoplayDelimiter'] = !empty($matches[15]) ? $matches[15] : \false;
$matches['heightValue'] = $matches['autoplayValue'] = !empty($matches[16]) ? (int) $matches[16] : 1;
// if source, assign the width and height value of related img element
if ('source' == $matches['elementName']) {
if ($matches['widthValue'] <= 1) {
$matches['widthValue'] = $this->width;
}
if ($matches['heightValue'] <= 1) {
$matches['heightValue'] = $this->height;
}
}
$isLazyLoaded = \false;
// Return match if it isn't an HTML element
if (\false === $matches['elementName']) {
return $matches['fullMatch'];
}
switch ($matches['elementName']) {
case 'img':
case 'input':
case 'iframe':
case 'source':
$imgType = 'embed';
break;
case 'picture':
$imgType = 'picture';
break;
case 'video':
case 'audio':
$imgType = 'audiovideo';
break;
default:
$imgType = 'background';
break;
}
if ('embed' == $imgType && \false !== $matches['srcValue']) {
$matches['srcValue'] = Utils::uriFor(\trim($matches['srcValue']));
}
if ('background' == $imgType && \false !== $matches['cssUrlValue']) {
$matches['cssUrlValue'] = Utils::uriFor(\trim($matches['cssUrlValue']));
}
if ('audiovideo' == $imgType && \false !== $matches['posterValue']) {
$matches['posterValue'] = Utils::uriFor(\trim($matches['posterValue']));
}
if (\JCH_PRO && $this->params->get('pro_load_webp_images', '0') && 'picture' != $matches['elementName']) {
/** @see Webp::convert() */
$matches = $this->getContainer()->get(Webp::class)->convert($matches);
}
if ($this->args['lazyload']) {
if (\false !== $matches['srcValue'] && ('img' == $matches['elementName'] || 'input' == $matches['elementName'])) {
$this->http2Preload->add($matches['srcValue'], 'image', \true);
}
// Start modifying the element to return
$return = $matches['fullMatch'];
// Exclude based on class
if (\false !== $matches['classValue']) {
if ($matches['elementName'] && Helper::findExcludes($this->excludes['class'], $matches['classValue'])) {
// If element child of a parent element set excluded flag
if ('' != $this->args['parent']) {
$this->isExcluded = \true;
}
// Remove any lazy loading from excluded images
$matches['fullMatch'] = $this->removeLoadingAttribute($matches['fullMatch']);
return $matches['fullMatch'];
}
}
if ('picture' != $matches['elementName']) {
// If a src attribute is found
if (\false !== $matches['srcAttribute']) {
$sImgName = 'background' == $imgType ? $matches['cssUrlValue'] : $matches['srcValue'];
// Abort if this file is excluded
if (Helper::findExcludes($this->excludes['url'], $sImgName)) {
// If element child of a parent element set excluded flag
if ('' != $this->args['parent']) {
$this->isExcluded = \true;
}
$matches['fullMatch'] = $this->removeLoadingAttribute($matches['fullMatch']);
return $matches['fullMatch'];
}
// If no srcset attribute was found, modify the src attribute and add a data-src attribute
if (\false === $matches['srcsetAttribute'] && 'embed' == $imgType) {
$svg = '<svg xmlns="http://www.w3.org/2000/svg" width="'.$matches['widthValue'].'" height="'.$matches['heightValue'].'"></svg>';
$sNewSrcValue = 'iframe' == $matches['elementName'] ? 'about:blank' : 'data:image/svg+xml;base64,'.\base64_encode($svg);
$sNewSrcAttribute = 'src='.$matches['srcDelimiter'].$sNewSrcValue.$matches['srcDelimiter'].' data-'.$matches['srcAttribute'];
$return = \str_replace($matches['srcAttribute'], $sNewSrcAttribute, $return);
$isLazyLoaded = \true;
}
}
// If poster attribute was found we can also exclude using poster value
if (\false !== $matches['posterAttribute']) {
if (Helper::findExcludes($this->excludes['url'], $matches['posterValue'])) {
return $matches['fullMatch'];
}
}
// Modern browsers will lazy-load without loading the src attribute
if (\false !== $matches['srcsetAttribute'] && \false !== $matches['srcsetValue'] && 'embed' == $imgType) {
$sSvgSrcset = '<svg xmlns="http://www.w3.org/2000/svg" width="'.$matches['widthValue'].'" height="'.$matches['heightValue'].'"></svg>';
$matches['srcsetDelimiter'] = $matches['srcsetDelimiter'] ?: '"';
$sNewSrcsetAttribute = 'srcset='.$matches['srcsetDelimiter'].'data:image/svg+xml;base64,'.\base64_encode($sSvgSrcset).$matches['srcsetDelimiter'].' data-'.$matches['srcsetAttribute'];
$return = \str_replace($matches['srcsetAttribute'], $sNewSrcsetAttribute, $return);
$isLazyLoaded = \true;
}
if (\JCH_PRO && 'audiovideo' == $imgType) {
/** @see LazyLoadExtended::lazyLoadAudioVideo() */
$return = $this->getContainer()->get(LazyLoadExtended::class)->lazyLoadAudioVideo($matches, $return);
$isLazyLoaded = \true;
}
}
// Process and add content of element if not self-closing
if ('picture' == $matches['elementName'] && \false !== $matches['innerContent']) {
$args = ['lazyload' => \true, 'deferred' => \true, 'parent' => 'picture'];
$sInnerContentLazyLoaded = $this->lazyLoadInnerContent($matches, $args);
// If any child element were lazyloaded this function will return false
if (\false === $sInnerContentLazyLoaded) {
// Remove any lazyloading attributes
return $this->removeLoadingAttribute($matches['fullMatch']);
}
return \str_replace($matches['innerContent'], $sInnerContentLazyLoaded, $matches['fullMatch']);
}
if (\JCH_PRO && 'background' == $imgType && $this->params->get('pro_lazyload_bgimages', '0')) {
/** @see LazyLoadExtended::lazyLoadBgImages() */
$return = $this->getContainer()->get(LazyLoadExtended::class)->lazyLoadBgImages($matches, $return);
$isLazyLoaded = \true;
}
if ($isLazyLoaded) {
// If class attribute not on the appropriate element add it
if ('source' != $matches['elementName'] && \false === $matches['classAttribute']) {
$return = \str_replace('<'.$matches['elementName'], '<'.$matches['elementName'].' class="jch-lazyload"', $return);
}
// If class already on element add the lazy-load class
if ('source' != $matches['elementName'] && \false !== $matches['classAttribute']) {
$sNewClassAttribute = 'class='.$matches['classDelimiter'].$matches['classValue'].' jch-lazyload'.$matches['classDelimiter'];
$return = \str_replace($matches['classAttribute'], $sNewClassAttribute, $return);
}
}
if ('picture' != $this->args['parent'] && $isLazyLoaded) {
// Wrap and add img elements in noscript
if ('img' == $matches['elementName'] || 'iframe' == $matches['elementName']) {
$return .= '<noscript>'.$matches['fullMatch'].'</noscript>';
}
}
return $return;
}
if ($matches['srcValue'] instanceof UriInterface && ('img' == $matches['elementName'] || 'input' == $matches['elementName'])) {
$this->http2Preload->add($matches['srcValue'], 'image', $this->args['deferred']);
}
if ('background' == $imgType && $matches['cssUrlValue'] instanceof UriInterface) {
$this->http2Preload->add($matches['cssUrlValue'], 'image', $this->args['deferred']);
}
// If lazy-load enabled, remove loading="lazy" attributes from above the fold
if ($this->params->get('lazyload_enable', '0') && !$this->args['deferred'] && 'img' == $matches['elementName']) {
// Remove any lazy loading
$matches['fullMatch'] = $this->removeLoadingAttribute($matches['fullMatch']);
}
// We may need to convert images to WEBP in picture elements
if ('picture' == $matches['elementName'] && \false !== $matches['innerContent']) {
$args = ['lazyload' => \false, 'deferred' => $this->args['deferred'], 'parent' => 'picture'];
$innerContentWebp = $this->lazyLoadInnerContent($matches, $args);
if (\false !== $innerContentWebp) {
$matches['fullMatch'] = \str_replace($matches['innerContent'], $innerContentWebp, $matches['fullMatch']);
}
}
return $matches['fullMatch'];
}
public function setLazyLoadArgs(array $args): void
{
$this->args = $args;
}
protected function getLazyLoadExcludes(): void
{
$aExcludesFiles = Helper::getArray($this->params->get('excludeLazyLoad', []));
$aExcludesFolders = Helper::getArray($this->params->get('pro_excludeLazyLoadFolders', []));
$aExcludesUrl = \array_merge(['data:image'], $aExcludesFiles, $aExcludesFolders);
$aExcludeClass = Helper::getArray($this->params->get('pro_excludeLazyLoadClass', []));
$this->excludes = ['url' => $aExcludesUrl, 'class' => $aExcludeClass];
}
/**
* @psalm-return array<mixed|string>|false|null|string
*
* @param mixed $matches
*
* @return null|(mixed|string)[]|false|string
*
* @throws PregErrorException
*/
protected function lazyLoadInnerContent($matches, array $args)
{
// Let's first get the width and height from the img element, we'll need it to provide proper aspect
// ratio to any source elements
try {
$parser = new Parser();
$element = new ElementObject();
$element->bSelfClosing = \true;
$element->setNamesArray(['img']);
$element->setCaptureAttributesArray(['(?:data-)?width', '(?:data-)?height']);
$parser->addElementObject($element);
$dimensions = $parser->findMatches($matches['innerContent']);
$width = $dimensions[4][0];
$height = $dimensions[7][0];
if ($width > 1) {
$this->width = $width;
}
if ($height > 1) {
$this->height = $height;
}
} catch (PregErrorException $e) {
}
$oParser = new Parser();
$oImgElement = new ElementObject();
$oImgElement->bSelfClosing = \true;
$oImgElement->setNamesArray(['img', 'source']);
// language=RegExp
$oImgElement->addNegAttrCriteriaRegex('(?:data-(?:src|original))');
$oImgElement->setCaptureAttributesArray(['class', 'src', 'srcset', '(?:data-)?width', '(?:data-)?height']);
$oParser->addElementObject($oImgElement);
/** @var LazyLoad $lazyLoadCallback */
$lazyLoadCallback = $this->getContainer()->get(\JchOptimize\Core\Html\Callbacks\LazyLoad::class);
$lazyLoadCallback->setLazyLoadArgs($args);
$lazyLoadCallback->width = $this->width;
$lazyLoadCallback->height = $this->height;
$result = $oParser->processMatchesWithCallback($matches['innerContent'], $lazyLoadCallback);
// if any child element were excluded return false
if ($lazyLoadCallback->isExcluded) {
return \false;
}
return $result;
}
protected function removeLoadingAttribute(string $htmlElement): ?string
{
return \preg_replace('#loading\\s*+=\\s*+["\']?lazy["\']?#i', '', $htmlElement);
}
}

View File

@@ -0,0 +1,120 @@
<?php
/**
* JCH Optimize - Performs several front-end optimizations for fast downloads.
*
* @author Samuel Marshall <samuel@jch-optimize.net>
* @copyright Copyright (c) 2022 Samuel Marshall / JCH Optimize
* @license GNU/GPLv3, or later. See LICENSE file
*
* If LICENSE file missing, see <http://www.gnu.org/licenses/>.
*/
namespace JchOptimize\Core\Html;
\defined('_JCH_EXEC') or exit('Restricted access');
class ElementObject
{
/**
* @var bool True if element is self-closing
*/
public bool $bSelfClosing = \false;
/**
* @var bool True to capture inside content of elements
*/
public bool $bCaptureContent = \false;
public bool $bNegateCriteria = \false;
/**
* @var array Name or names of element to search for
*/
protected array $aNames = ['[a-z0-9]++'];
/**
* @var array Array of negative criteria to test against the attributes
*/
protected array $aNegAttrCriteria = [];
/**
* @var array Array of positive criteria to check against the attributes
*/
protected array $aPosAttrCriteria = [];
/**
* @var array Array of attributes to capture values
*/
protected array $aCaptureAttributes = [];
/**
* @var array|string Regex criteria for target value
*/
protected $mValueCriteria = '';
protected array $aCaptureOneOrBothAttributes = [];
/**
* @param $aNames array Name(s) of elements to search for
*/
public function setNamesArray(array $aNames): void
{
$this->aNames = $aNames;
}
public function getNamesArray(): array
{
return $this->aNames;
}
public function addNegAttrCriteriaRegex(string $sCriteria): void
{
$this->aNegAttrCriteria[] = $sCriteria;
}
public function getNegAttrCriteriaArray(): array
{
return $this->aNegAttrCriteria;
}
public function addPosAttrCriteriaRegex(string $sCriteria): void
{
$this->aPosAttrCriteria[] = $sCriteria;
}
public function getPosAttrCriteriaArray(): array
{
return $this->aPosAttrCriteria;
}
public function setCaptureAttributesArray(array $aAttributes): void
{
$this->aCaptureAttributes = $aAttributes;
}
public function getCaptureAttributesArray(): array
{
return $this->aCaptureAttributes;
}
public function setValueCriteriaRegex($mCriteria): void
{
$this->mValueCriteria = $mCriteria;
}
/**
* @return array|string
*/
public function getValueCriteriaRegex()
{
return $this->mValueCriteria;
}
public function setCaptureOneOrBothAttributesArray(array $aAttributes): void
{
$this->aCaptureOneOrBothAttributes = $aAttributes;
}
public function getCaptureOneOrBothAttributesArray(): array
{
return $this->aCaptureOneOrBothAttributes;
}
}

View File

@@ -0,0 +1,601 @@
<?php
/**
* JCH Optimize - Performs several front-end optimizations for fast downloads.
*
* @author Samuel Marshall <samuel@jch-optimize.net>
* @copyright Copyright (c) 2022 Samuel Marshall / JCH Optimize
* @license GNU/GPLv3, or later. See LICENSE file
*
* If LICENSE file missing, see <http://www.gnu.org/licenses/>.
*/
namespace JchOptimize\Core\Html;
use _JchOptimizeVendor\GuzzleHttp\Psr7\Uri;
use _JchOptimizeVendor\GuzzleHttp\Psr7\UriResolver;
use _JchOptimizeVendor\Joomla\DI\ContainerAwareInterface;
use _JchOptimizeVendor\Joomla\DI\ContainerAwareTrait;
use _JchOptimizeVendor\Psr\Http\Client\ClientInterface;
use _JchOptimizeVendor\Psr\Http\Message\UriInterface;
use CodeAlfa\Minify\Html;
use JchOptimize\Core\Exception\ExcludeException;
use JchOptimize\Core\FeatureHelpers\Fonts;
use JchOptimize\Core\FileUtils;
use JchOptimize\Core\Helper;
use JchOptimize\Core\Http2Preload;
use JchOptimize\Core\SystemUri;
use JchOptimize\Core\Uri\UriComparator;
use JchOptimize\Platform\Excludes;
use Joomla\Registry\Registry;
\defined('_JCH_EXEC') or exit('Restricted access');
/**
* Handles the exclusion and replacement of files in the HTML based on set parameters.
*/
class FilesManager implements ContainerAwareInterface
{
use ContainerAwareTrait;
/**
* @var bool Indicates if we can load the last javascript files asynchronously
*/
public bool $bLoadJsAsync = \true;
/**
* @var bool Flagged anytime JavaScript files are excluded PEI
*/
public bool $jsFilesExcludedPei = \false;
/**
* @var array Multidimensional array of css files to combine
*/
public array $aCss = [[]];
/**
* @var array Multidimensional array of js files to combine
*/
public array $aJs = [[]];
/**
* @var int Current index of js files to be combined
*/
public int $iIndex_js = 0;
/**
* @var int Current index of css files to be combined
*/
public int $iIndex_css = 0;
/** @var array Javascript matches that will be excluded.
* Will be moved to the bottom of section if not selected in "don't move"
*/
public array $aExcludedJs = ['ieo' => [], 'peo' => []];
/**
* @var int Recorded incremented index of js files when the last file was excluded
*/
public int $jsExcludedIndex = 0;
/**
* @var array Javascript files having the defer attribute
*/
public array $defers = [];
/**
* @var string[] Current match being worked on
*/
public array $aMatch = [];
/**
* @var array{
* excludes_peo:array{
* js:array<array-key, array{url?:string, script?:string, ieo?:string, dontmove?:string}>,
* css:string[],
* js_script:array<array-key, array{url?:string, script?:string, ieo?:string, dontmove?:string}>,
* css_script:string[]
* },
* critical_js:array{
* js:string[],
* script:string[]
* },
* remove:array{
* js:string[],
* css:string[]
* }
* } $aExcludes Multidimensional array of excludes set in the parameters
*/
public array $aExcludes = ['excludes_peo' => ['js' => [[]], 'css' => [], 'js_script' => [[]], 'css_script' => []], 'critical_js' => ['js' => [], 'script' => []], 'remove' => ['js' => [], 'css' => []]];
/**
* @var string Type of file being processed (css|js)
*/
protected string $type = '';
/**
* @var array Array of matched elements holding links to CSS/Js files on the page
*/
protected array $aMatches = [];
/**
* @var int Current index of matches
*/
protected int $iIndex = -1;
/**
* @var array Array of replacements of matched links
*/
protected array $aReplacements = [];
/**
* @var string String to replace the matched link
*/
protected string $replacement = '';
/**
* @var string Type of exclude being processed (peo|ieo)
*/
protected string $sCssExcludeType = '';
/**
* @var string Type of exclude being processed (peo|ieo)
*/
protected string $sJsExcludeType = '';
/**
* @var array Array to hold files to check for duplicates
*/
protected array $aUrls = [];
private Registry $params;
private ?ClientInterface $http;
private Http2Preload $http2Preload;
private FileUtils $fileUtils;
/**
* @var string Previous match of a script with module/async/defer attribute
*/
private static string $prevDeferMatches = '';
/**
* @var int Current index of the defers array
*/
private static int $deferIndex = -1;
/**
* Private constructor, need to implement a singleton of this class.
*/
public function __construct(Registry $params, Http2Preload $http2Preload, FileUtils $fileUtils, ?ClientInterface $http)
{
$this->params = $params;
$this->http2Preload = $http2Preload;
$this->fileUtils = $fileUtils;
$this->http = $http;
}
public function setExcludes(array $aExcludes): void
{
$this->aExcludes = $aExcludes;
}
public function processFiles(string $type, array $aMatch): string
{
$this->aMatch = $aMatch;
$this->type = $type;
++$this->iIndex;
$this->aMatches[$this->iIndex] = $aMatch[0];
// Initialize replacement
$this->replacement = '';
try {
if (isset($aMatch['url'])) {
$this->checkUrls($aMatch['url']);
/*
* @see FilesManager::processJsUrl()
* @see FilesManager::processCssUrl()
*/
$this->{'process'.\ucfirst($type).'Url'}($aMatch['url']);
} elseif (isset($aMatch['content'])) {
/*
* @see FilesManager::processJsContent()
* @see FilesManager::processCssContent()
*/
$this->{'process'.\ucfirst($type).'Content'}($aMatch['content']);
}
} catch (ExcludeException $e) {
}
return $this->replacement;
}
/**
* Determines if the given url requires an http wrapper to fetch it and if an http adapter is available.
*/
public function isHttpAdapterAvailable(UriInterface $uri): bool
{
return !\is_null($this->http);
}
public function isPHPFile(string $url): bool
{
return (bool) \preg_match('#\\.php|^(?![^?\\#]*\\.(?:css|js|png|jpe?g|gif|bmp)(?:[?\\#]|$)).++#i', $url);
}
/**
* Checks if a file appears more than once on the page so that it's not duplicated in the combined files.
*
* @param UriInterface $uri Url of file
*
* @return bool True if already included
*
* @since
*/
public function isDuplicated(UriInterface $uri): bool
{
$url = Uri::composeComponents('', $uri->getAuthority(), $uri->getPath(), $uri->getQuery(), '');
$return = \in_array($url, $this->aUrls);
if (!$return) {
$this->aUrls[] = $url;
}
return $return;
}
/**
* @return never
*
* @throws ExcludeException
*/
public function excludeJsIEO()
{
$this->sJsExcludeType = 'ieo';
throw new ExcludeException();
}
public function isFileDeferred(string $script, bool $bIgnoreAsync = \false): bool
{
// File is deferred if it has async or defer attributes
$attributes = ['defer'];
if (!$bIgnoreAsync) {
$attributes = \array_merge($attributes, ['async']);
}
return $this->hasAttributes($script, $attributes, $matches);
}
public static function hasAttributes(string $element, array $attributes, ?array &$matches): bool
{
$a = \JchOptimize\Core\Html\Parser::htmlAttributeWithCaptureValueToken();
$attrRegex = \implode('|', \array_map(function ($a) {
$b = \preg_replace('#=(.*)#', '\\s*=\\s*(?|"(\\1)"|\'(\\1)\'|(\\1))', $a, 1, $count);
if (!$count) {
return '('.$a.')(?:\\s*=\\s*(?|"[^"]*+"|\'[^\']*+\'|[^\\s/>]*+))?';
}
return $b;
}, $attributes));
$regex = "#<\\w++\\b(?>\\s*+{$a})*?\\s*+\\K(?|{$attrRegex})#i";
return (bool) \preg_match($regex, $element, $matches);
}
private function checkUrls(UriInterface $uri): void
{
// Exclude invalid urls
if ('data' == $uri->getScheme()) {
$this->{'exclude'.\ucfirst($this->type).'IEO'}();
}
}
/**
* @throws ExcludeException
*/
private function processCssUrl(UriInterface $uri): void
{
// Get media value if attribute set
$sMedia = $this->getMediaAttribute();
// process google font files or other CSS files added to be optimized
if ('fonts.googleapis.com' == $uri->getHost() || Helper::findExcludes(Helper::getArray($this->params->get('pro_optimize_font_files', [])), (string) $uri)) {
if (JCH_PRO) {
// @see Fonts::pushFileToFontsArray()
$this->container->get(Fonts::class)->pushFileToFontsArray($uri, $sMedia);
}
// if Optimize Fonts not enabled just return Google Font files. Google fonts will serve a different version
// for different browsers and creates problems when we try to cache it.
if ('fonts.googleapis.com' == $uri->getHost() && !$this->params->get('pro_optimizeFonts_enable', '0')) {
$this->replacement = $this->aMatch[0];
}
$this->excludeCssIEO();
}
if ($this->isDuplicated($uri)) {
$this->excludeCssIEO();
}
// process excludes for css urls
if ($this->excludeGenericUrls($uri) || Helper::findExcludes(@$this->aExcludes['excludes_peo']['css'], (string) $uri)) {
$this->excludeCssPEO();
}
$this->prepareCssPEO();
$this->processSmartCombine($uri);
$this->aCss[$this->iIndex_css][] = ['url' => $uri, 'media' => $sMedia];
}
private function getMediaAttribute(): string
{
$sMedia = '';
if (\preg_match('#media=(?(?=["\'])(?:["\']([^"\']+))|(\\w+))#i', $this->aMatch[0], $aMediaTypes) > 0) {
$sMedia .= $aMediaTypes[1] ?: $aMediaTypes[2];
}
return $sMedia;
}
/**
* @return never
*
* @throws ExcludeException
*/
private function excludeCssIEO()
{
$this->sCssExcludeType = 'ieo';
throw new ExcludeException();
}
private function excludeGenericUrls(UriInterface $uri): bool
{
// Exclude unsupported urls
if ('https' == $uri->getScheme() && !\extension_loaded('openssl')) {
return \true;
}
$resolvedUri = UriResolver::resolve(SystemUri::currentUri(), $uri);
// Exclude files from external extensions if parameter not set (PEO)
if (!$this->params->get('includeAllExtensions', '0')) {
if (!UriComparator::isCrossOrigin($resolvedUri) && \preg_match('#'.Excludes::extensions().'#i', (string) $uri)) {
return \true;
}
}
// Exclude all external and dynamic files
if (!$this->params->get('phpAndExternal', '0')) {
if (UriComparator::isCrossOrigin($resolvedUri) || !Helper::isStaticFile($uri->getPath())) {
return \true;
}
}
return \false;
}
/**
* @return never
*
* @throws ExcludeException
*/
private function excludeCssPEO()
{
// if previous file was excluded increment css index
if (!empty($this->aCss[$this->iIndex_css]) && !$this->params->get('optimizeCssDelivery_enable', '0')) {
++$this->iIndex_css;
}
// Just return the match at same location
$this->replacement = $this->aMatch[0];
$this->sCssExcludeType = 'peo';
throw new ExcludeException();
}
private function prepareCssPEO(): void
{
// return marker for combined file
if (empty($this->aCss[$this->iIndex_css]) && !$this->params->get('optimizeCssDelivery_enable', '0')) {
$this->replacement = '<JCH_CSS'.$this->iIndex_css.'>';
}
}
private function processSmartCombine(UriInterface $uri): void
{
if ($this->params->get('pro_smart_combine', '0')) {
$sType = $this->type;
$aSmartCombineValues = $this->params->get('pro_smart_combine_values', '');
$aSmartCombineValues = '' != $aSmartCombineValues ? \json_decode(\rawurldecode($aSmartCombineValues)) : [];
// Index of files currently being smart combined
static $iSmartCombineIndex_js = \false;
static $iSmartCombineIndex_css = \false;
$sBaseUrl = Uri::composeComponents($uri->getScheme(), $uri->getAuthority(), $uri->getPath(), '', '');
foreach (Excludes::smartCombine() as $iIndex => $sRegex) {
if (\preg_match('#'.$sRegex.'#i', (string) $uri) && \in_array($sBaseUrl, $aSmartCombineValues)) {
// We're in a batch
// Is this the first file in this batch?
if (!empty($this->{'a'.\ucfirst($sType)}[$this->{'iIndex_'.$sType}]) && ${'iSmartCombineIndex_'.$sType} !== $iIndex) {
++$this->{'iIndex_'.$sType};
if ('css' == $sType && '' == $this->replacement && !$this->params->get('optimizeCssDelivery_enable', '0')) {
$this->replacement = '<JCH_CSS'.$this->iIndex_css.'>';
}
}
if ('js' == $sType) {
$this->bLoadJsAsync = \false;
}
// Save index
${'iSmartCombineIndex_'.$sType} = $iIndex;
break;
}
if (${'iSmartCombineIndex_'.$sType} === $iIndex) {
// Have we just finished a batch?
${'iSmartCombineIndex_'.$sType} = \false;
if (!empty($this->{'a'.\ucfirst($sType)}[$this->{'iIndex_'.$sType}])) {
++$this->{'iIndex_'.$sType};
if ('css' == $sType && '' == $this->replacement && !$this->params->get('optimizeCssDelivery_enable', '0')) {
$this->replacement = '<JCH_CSS'.$this->iIndex_css.'>';
}
}
}
}
}
}
/**
* @throws ExcludeException
*/
private function processCssContent(string $content): void
{
$media = $this->getMediaAttribute();
if (Helper::findExcludes(@$this->aExcludes['excludes_peo']['css_script'], $content, 'css') || !$this->params->get('inlineStyle', '0') || $this->params->get('excludeAllStyles', '0')) {
$this->excludeCssPEO();
}
$this->prepareCssPEO();
$this->aCss[$this->iIndex_css][] = ['content' => Html::cleanScript($content, 'css'), 'media' => $media];
}
/**
* @throws ExcludeException
*/
private function processJsUrl(UriInterface $uri): void
{
if ($this->isDuplicated($uri)) {
$this->excludeJsIEO();
}
foreach ($this->aExcludes['excludes_peo']['js'] as $exclude) {
if (!empty($exclude['url']) && Helper::findExcludes([$exclude['url']], (string) $uri)) {
// Handle js files PEO
if (!isset($exclude['ieo'])) {
$this->http2Preload->add($uri, 'js');
// prepare js match for excluding PEO
$this->prepareJsPEO();
// Return match if selected as "don't move"
if (isset($exclude['dontmove'])) {
// Need to make sure execution order is maintained
$this->prepareJsDontMoveReplacement();
} else {
$this->aExcludedJs['peo'][] = $this->aMatch[0];
}
$this->excludeJsPEO();
// Prepare IEO excludes for js urls
} else {
$deferred = $this->isFileDeferred($this->aMatch[0]);
$this->http2Preload->add($uri, 'js', $deferred);
// Return match if selected as "don't move"
if (isset($exclude['dontmove'])) {
$this->replacement = $this->aMatch[0];
// Else add to array of excluded js files
} else {
$this->aExcludedJs['ieo'][] = $this->aMatch[0];
}
$this->excludeJsIEO();
}
}
}
// Add all defers, modules and nomodules to the defer array, incrementing the index each time a
// different type is encountered
if ($this->hasAttributes($this->aMatch[0], ['type=module', 'nomodule'], $matches) || $this->hasAttributes($this->aMatch[0], ['async'], $matches) || $this->hasAttributes($this->aMatch[0], ['defer'], $matches)) {
if ($matches[1] != self::$prevDeferMatches) {
++self::$deferIndex;
self::$prevDeferMatches = $matches[1];
}
$this->defers[self::$deferIndex][] = ['attribute' => $matches[0], 'attributeType' => $matches[1], 'script' => $this->aMatch[0], 'url' => $uri];
$this->bLoadJsAsync = \false;
$this->excludeJsIEO();
}
if ($this->excludeGenericUrls($uri)) {
$this->prepareJsPEO();
$this->aExcludedJs['peo'][] = $this->aMatch[0];
$this->excludeJsPEO();
}
$this->processSmartCombine($uri);
$this->aJs[$this->iIndex_js][] = ['url' => $uri];
}
private function prepareJsPEO(): void
{
// If files were previously added for combine in the current index
// then place marker for combined file(s) above match marked for exclude
if (!empty($this->aJs[$this->iIndex_js])) {
$jsReturn = '';
for ($i = $this->jsExcludedIndex; $i <= $this->iIndex_js; ++$i) {
$jsReturn .= '<JCH_JS'.$i.'>'."\n\t";
}
$this->aMatch[0] = $jsReturn.$this->aMatch[0];
// increment index of combined files and record it
$this->jsExcludedIndex = ++$this->iIndex_js;
}
}
private function prepareJsDontMoveReplacement(): void
{
// We'll need to put all the PEO excluded files above this one
$this->aMatch[0] = \implode("\n", $this->aExcludedJs['peo'])."\n".$this->aMatch[0];
$this->replacement = $this->aMatch[0];
// reinitialize array of PEO excludes
$this->aExcludedJs['peo'] = [];
}
/**
* @return never
*
* @throws ExcludeException
*/
private function excludeJsPEO()
{
// Can no longer load last combined file asynchronously
$this->bLoadJsAsync = \false;
$this->jsFilesExcludedPei = \true;
$this->sJsExcludeType = 'peo';
throw new ExcludeException();
}
/**
* @throws ExcludeException
*/
private function processJsContent(string $content): void
{
foreach ($this->aExcludes['excludes_peo']['js_script'] as $exclude) {
if (!empty($exclude['script']) && Helper::findExcludes([$exclude['script']], $content)) {
// process PEO excludes for js scripts
if (!isset($exclude['ieo'])) {
$this->prepareJsPEO();
// Return match if selected as don't move
if (isset($exclude['dontmove'])) {
// Need to make sure execution order is maintained
$this->prepareJsDontMoveReplacement();
// Else add to array of excluded js
} else {
$this->aExcludedJs['peo'][] = $this->aMatch[0];
}
$this->excludeJsPEO();
// Prepare IEO excludes for js scripts
} else {
// Return match if select as don't move
if (isset($exclude['dontmove'])) {
$this->replacement = $this->aMatch[0];
// Else add to array of excluded js
} else {
$this->aExcludedJs['ieo'][] = $this->aMatch[0];
}
$this->excludeJsIEO();
}
}
}
// Exclude all scripts if options set
if (!$this->params->get('inlineScripts', '0') || $this->params->get('excludeAllScripts', '0')) {
$this->prepareJsPEO();
$this->aExcludedJs['peo'][] = $this->aMatch[0];
$this->excludeJsPEO();
}
// Add all modules and nomodules to the defer array, incrementing the index each time a
// different type is encountered. The defer and async attribute on inline scripts are ignored
if ($this->hasAttributes($this->aMatch[0], ['type=module', 'nomodule'], $matches)) {
if ($matches[1] != self::$prevDeferMatches) {
++self::$deferIndex;
self::$prevDeferMatches = $matches[1];
}
$this->defers[self::$deferIndex][] = ['attribute' => $matches[0], 'attributeType' => $matches[1], 'script' => $this->aMatch[0], 'content' => $content];
$this->bLoadJsAsync = \false;
$this->excludeJsIEO();
}
$this->aJs[$this->iIndex_js][] = ['content' => Html::cleanScript($content, 'js')];
}
}

View File

@@ -0,0 +1,408 @@
<?php
/**
* JCH Optimize - Performs several front-end optimizations for fast downloads.
*
* @author Samuel Marshall <samuel@jch-optimize.net>
* @copyright Copyright (c) 2022 Samuel Marshall / JCH Optimize
* @license GNU/GPLv3, or later. See LICENSE file
*
* If LICENSE file missing, see <http://www.gnu.org/licenses/>.
*/
namespace JchOptimize\Core\Html;
use _JchOptimizeVendor\GuzzleHttp\Psr7\Uri;
use _JchOptimizeVendor\Joomla\DI\ContainerAwareInterface;
use _JchOptimizeVendor\Joomla\DI\ContainerAwareTrait;
use _JchOptimizeVendor\Laminas\Cache\Storage\FlushableInterface;
use _JchOptimizeVendor\Laminas\Cache\Storage\StorageInterface;
use _JchOptimizeVendor\Laminas\EventManager\EventManager;
use _JchOptimizeVendor\Laminas\EventManager\EventManagerAwareInterface;
use _JchOptimizeVendor\Laminas\EventManager\EventManagerAwareTrait;
use _JchOptimizeVendor\Laminas\EventManager\SharedEventManagerInterface;
use _JchOptimizeVendor\Psr\Http\Message\UriInterface;
use JchOptimize\Core\Cdn;
use JchOptimize\Core\Exception;
use JchOptimize\Core\FeatureHelpers\DynamicJs;
use JchOptimize\Core\Helper;
use JchOptimize\Core\Http2Preload;
use JchOptimize\Core\Output;
use JchOptimize\Core\Uri\Utils;
use JchOptimize\Platform\Paths;
use JchOptimize\Platform\Profiler;
use Joomla\Filesystem\File;
use Joomla\Registry\Registry;
\defined('_JCH_EXEC') or exit('Restricted access');
class LinkBuilder implements ContainerAwareInterface, EventManagerAwareInterface
{
use ContainerAwareTrait;
use EventManagerAwareTrait;
/**
* @var Processor
*/
private \JchOptimize\Core\Html\Processor $oProcessor;
private Registry $params;
/**
* @var AsyncManager
*/
private \JchOptimize\Core\Html\AsyncManager $asyncManager;
/**
* @var FilesManager
*/
private \JchOptimize\Core\Html\FilesManager $filesManager;
private StorageInterface $cache;
private Cdn $cdn;
private Http2Preload $http2Preload;
/**
* Constructor.
*/
public function __construct(Registry $params, Processor $processor, FilesManager $filesManager, Cdn $cdn, Http2Preload $http2Preload, StorageInterface $cache, SharedEventManagerInterface $sharedEventManager)
{
$this->params = $params;
$this->oProcessor = $processor;
$this->filesManager = $filesManager;
$this->cdn = $cdn;
$this->http2Preload = $http2Preload;
$this->cache = $cache;
if (JCH_PRO) {
$this->asyncManager = new \JchOptimize\Core\Html\AsyncManager($params);
}
$this->setEventManager(new EventManager($sharedEventManager));
}
public function prependChildToHead(string $child): void
{
$headHtml = \preg_replace('#<title[^>]*+>#i', $child."\n\t".'\\0', $this->oProcessor->getHeadHtml(), 1);
$this->oProcessor->setHeadHtml($headHtml);
}
public function addCriticalCssToHead(string $criticalCss, ?string $id): void
{
$criticalStyle = '<style id="jch-optimize-critical-css" data-id="'.$id.'">'."\n".$criticalCss."\n".'</style>';
$this->appendChildToHead($criticalStyle, \true);
}
public function appendChildToHead(string $sChild, bool $bCleanReplacement = \false): void
{
if ($bCleanReplacement) {
$sChild = Helper::cleanReplacement($sChild);
}
$sHeadHtml = $this->oProcessor->getHeadHtml();
$sHeadHtml = \preg_replace('#'.\JchOptimize\Core\Html\Parser::htmlClosingHeadTagToken().'#i', $sChild."\n\t".'</head>', $sHeadHtml, 1);
$this->oProcessor->setHeadHtml($sHeadHtml);
}
public function addExcludedJsToSection(string $section): void
{
$aExcludedJs = $this->filesManager->aExcludedJs;
// Add excluded javascript files to the bottom of the HTML section
$sExcludedJs = \implode("\n", $aExcludedJs['ieo']).\implode("\n", $aExcludedJs['peo']);
$sExcludedJs = Helper::cleanReplacement($sExcludedJs);
if ('' != $sExcludedJs) {
$this->appendChildToHTML($sExcludedJs, $section);
}
}
public function appendChildToHTML(string $child, string $section): void
{
$sSearchArea = \preg_replace(
// @see Parser::htmlClosingHeadTagToken()
// @see Parser::htmlClosingBodyTagToken()
'#'.\JchOptimize\Core\Html\Parser::{'htmlClosing'.\strtoupper($section).'TagToken'}().'#si',
"\t".$child."\n".'</'.$section.'>',
$this->oProcessor->getFullHtml(),
1
);
$this->oProcessor->setFullHtml($sSearchArea);
}
public function addDeferredJs(string $section): void
{
$defers = $this->filesManager->defers;
// If we're loading javascript dynamically add the deferred javascript files to array of files to load dynamically instead
if ($this->params->get('pro_reduce_unused_js_enable', '0')) {
// @see DynamicJs::prepareJsDynamicUrls()
$this->container->get(DynamicJs::class)->prepareJsDynamicUrls($defers);
} elseif (!empty($defers[0])) {
foreach ($defers as $deferGroup) {
foreach ($deferGroup as $deferArray) {
$this->appendChildToHTML($deferArray['script'], $section);
}
}
}
}
public function setImgAttributes($aCachedImgAttributes): void
{
$sHtml = $this->oProcessor->getBodyHtml();
$this->oProcessor->setBodyHtml(\str_replace($this->oProcessor->images[0], $aCachedImgAttributes, $sHtml));
}
/**
* Insert url of aggregated file in html.
*
* @param string $section Whether section being processed is head|body
* @param int $jsLinksKey Index key of javascript combined file
*
* @throws Exception\RuntimeException
*/
public function replaceLinks(string $id, string $type, string $section = 'head', int $jsLinksKey = 0): void
{
JCH_DEBUG ? Profiler::start('ReplaceLinks - '.$type) : null;
$searchArea = $this->oProcessor->getFullHtml();
// All js files after the last excluded js will be placed at bottom of section
if ('js' == $type && $jsLinksKey >= $this->filesManager->jsExcludedIndex && !empty($this->filesManager->aJs[$this->filesManager->iIndex_js])) {
$url = $this->buildUrl($id, 'js');
// If last combined file is being inserted at the bottom of the page then
// add the async or defer attribute
if ('body' == $section) {
$defer = \false;
$async = \false;
if ($this->params->get('loadAsynchronous', '0')) {
if ($this->filesManager->bLoadJsAsync) {
$async = \true;
} else {
$defer = \true;
}
}
// Add async attribute to last combined js file if option is set
$newLink = $this->getNewJsLink((string) $url, $defer, $async);
} else {
$newLink = $this->getNewJsLink((string) $url);
}
// Insert script tag at the appropriate section in the HTML
$searchArea = \preg_replace(
// @see Parser::htmlClosingHeadTagToken()
// @see Parser::htmlClosingBodyTagToken()
'#'.\JchOptimize\Core\Html\Parser::{'htmlClosing'.\strtoupper($section).'TagToken'}().'#si',
"\t".$newLink."\n".'</'.$section.'>',
$searchArea,
1
);
$deferred = $this->filesManager->isFileDeferred($newLink);
$this->http2Preload->add($url, $type, $deferred);
} else {
$url = $this->buildUrl($id, $type);
$this->http2Preload->add($url, $type);
$newLink = $this->{'getNew'.\ucfirst($type).'Link'}($url);
// Replace placeholders in HTML with combined files
$searchArea = \preg_replace('#<JCH_'.\strtoupper($type).'([^>]++)>#', $newLink, $searchArea, 1);
}
$this->oProcessor->setFullHtml($searchArea);
JCH_DEBUG ? Profiler::stop('ReplaceLinks - '.$type, \true) : null;
}
/**
* Returns url of aggregated file.
*
* @param string $type css or js
*
* @return UriInterface Url of aggregated file
*/
public function buildUrl(string $id, string $type): UriInterface
{
$htaccess = $this->params->get('htaccess', 2);
$uri = Utils::uriFor(Paths::relAssetPath());
switch ($htaccess) {
case '1':
case '3':
$uri = 3 == $htaccess ? $uri->withPath($uri->getPath().'3') : $uri;
$uri = $uri->withPath($uri->getPath().Paths::rewriteBaseFolder().($this->isGz() ? 'gz' : 'nz').'/'.$id.'.'.$type);
break;
case '0':
$uri = $uri->withPath($uri->getPath().'2/jscss.php');
$aVar = [];
$aVar['f'] = $id;
$aVar['type'] = $type;
$aVar['gz'] = $this->isGZ() ? 'gz' : 'nz';
$uri = Uri::withQueryValues($uri, $aVar);
break;
case '2':
default:
// Get cache Url, this will be embedded in the HTML
$uri = Utils::uriFor(Paths::cachePath());
$uri = $uri->withPath($uri->getPath().'/'.$type.'/'.$id.'.'.$type);
// . ($this->isGz() ? '.gz' : '');
$this->createStaticFiles($id, $type);
break;
}
return $this->cdn->loadCdnResource($uri);
}
/**
* Check if gzip is set or enabled.
*
* @return bool True if gzip parameter set and server is enabled
*/
public function isGZ(): bool
{
return $this->params->get('gzip', 0) && \extension_loaded('zlib') && !\ini_get('zlib.output_compression') && 'ob_gzhandler' != \ini_get('output_handler');
}
/**
* @param string $url Url of file
* @param bool $isDefer If true the 'defer attribute will be added to the script element
* @param bool $isASync If true the 'async' attribute will be added to the script element
*/
public function getNewJsLink(string $url, bool $isDefer = \false, bool $isASync = \false): string
{
$deferAttr = $isDefer ? $this->getFormattedHtmlAttribute('defer') : '';
$asyncAttr = $isASync ? $this->getFormattedHtmlAttribute('async') : '';
return '<script src="'.$url.'"'.$asyncAttr.$deferAttr.'></script>';
}
/**
* @param UriInterface[] $cssUrls
*
* @psalm-param list{0?: UriInterface,...} $cssUrls
*/
public function loadCssAsync(array $cssUrls): void
{
if (!$this->params->get('pro_reduce_unused_css', '0')) {
foreach ($cssUrls as $url) {
$this->appendChildToHead($this->getPreloadStyleSheet($url, 'all'));
}
} else {
$this->asyncManager->loadCssAsync($cssUrls);
}
}
public function getPreloadStyleSheet(string $url, string $media): string
{
$attr = ['as' => 'style', 'onload' => 'this.rel=\'stylesheet\'', 'href' => $url, 'media' => $media];
return $this->getPreloadLink($attr);
}
public function getPreloadLink(array $attr): string
{
$crossorigin = !empty($attr['crossorigin']) ? ' '.$this->getFormattedHtmlAttribute('crossorigin') : '';
$media = !empty($attr['media']) ? ' media="'.$attr['media'].'"' : '';
$type = !empty($attr['type']) ? ' type="'.$attr['type'].'"' : '';
$onload = !empty($attr['onload']) ? ' onload="'.$attr['onload'].'"' : '';
return "<link rel=\"preload\" href=\"{$attr['href']}\" as=\"{$attr['as']}\"{$type}{$media}{$crossorigin}{$onload} />";
}
public function appendAsyncScriptsToHead(): void
{
if (JCH_PRO) {
$sScript = $this->cleanScript($this->asyncManager->printHeaderScript());
$this->appendChildToHead($sScript);
}
}
public function addJsLazyLoadAssetsToHtml(string $id, string $section): void
{
$url = $this->buildUrl($id, 'js');
$script = $this->getNewJsLink((string) $url, \false, \true);
$this->appendChildToHTML($script, $section);
}
/**
* @param string $url Url of file
*/
public function getNewCssLink(string $url): string
{
// language=HTML
return '<link rel="stylesheet" href="'.$url.'" />';
}
public function getPreconnectLink(UriInterface $domainUri): string
{
$crossorigin = $this->getFormattedHtmlAttribute('crossorigin');
$domain = Uri::composeComponents($domainUri->getScheme(), $domainUri->getHost(), '', '', '');
// language=HTML
return '<link rel="preconnect" href="'.$domain.'" '.$crossorigin.' />';
}
public function getModulePreloadLink(string $url): string
{
// language=HTML
return '<link rel="modulepreload" href="'.$url.'" />';
}
public function preProcessHtml(): void
{
JCH_DEBUG ? Profiler::start('PreProcessHtml') : null;
$this->getEventManager()->trigger(__FUNCTION__, $this);
JCH_DEBUG ? Profiler::start('PreProcessHtml', \true) : null;
}
public function postProcessHtml(): void
{
JCH_DEBUG ? Profiler::start('PostProcessHtml') : null;
$this->getEventManager()->trigger(__FUNCTION__, $this);
JCH_DEBUG ? Profiler::stop('PostProcessHtml', \true) : null;
}
/**
* Create static combined file if not yet exists.
*
* @param string $id Cache id of file
* @param string $type Type of file css|js
*/
protected function createStaticFiles(string $id, string $type): void
{
JCH_DEBUG ? Profiler::start('CreateStaticFiles - '.$type) : null;
// Get cache filesystem path to create file
$uri = Utils::uriFor(Paths::cachePath(\false));
$uri = $uri->withPath($uri->getPath().'/'.$type.'/'.$id.'.'.$type);
// File path of combined file
$combinedFile = (string) $uri;
if (!\file_exists($combinedFile)) {
$vars = ['f' => $id, 'type' => $type];
$content = Output::getCombinedFile($vars, \false);
if (\false === $content) {
throw new Exception\RuntimeException('Error retrieving combined contents');
}
// Create file and any directory
if (!File::write($combinedFile, $content)) {
if ($this->cache instanceof FlushableInterface) {
$this->cache->flush();
}
throw new Exception\RuntimeException('Error creating static file');
}
}
JCH_DEBUG ? Profiler::stop('CreateStaticFiles - '.$type, \true) : null;
}
protected function cleanScript(string $script): string
{
if (!Helper::isXhtml($this->oProcessor->getHtml())) {
$script = \str_replace(['<script type="text/javascript"><![CDATA[', '<script><![CDATA[', ']]></script>'], ['<script type="text/javascript">', '<script>', '</script>'], $script);
}
return $script;
}
/**
* Returns HTML attribute properly formatted for XHTML/XML or HTML5.
*/
private function getFormattedHtmlAttribute(string $attr): string
{
$attributeMap = ['async' => 'async', 'defer' => 'defer', 'crossorigin' => 'anonymous'];
return Helper::isXhtml($this->oProcessor->getHtml()) ? ' '.$attr.'="'.(@$attributeMap[$attr] ?: $attr).'"' : ' '.$attr;
}
}

View File

@@ -0,0 +1,270 @@
<?php
/**
* JCH Optimize - Performs several front-end optimizations for fast downloads.
*
* @author Samuel Marshall <samuel@jch-optimize.net>
* @copyright Copyright (c) 2022 Samuel Marshall / JCH Optimize
* @license GNU/GPLv3, or later. See LICENSE file
*
* If LICENSE file missing, see <http://www.gnu.org/licenses/>.
*/
namespace JchOptimize\Core\Html;
use CodeAlfa\RegexTokenizer\Html;
use JchOptimize\Core\Exception;
use JchOptimize\Core\Html\Callbacks\AbstractCallback;
\defined('_JCH_EXEC') or exit('Restricted access');
class Parser
{
use Html;
/** @var string Regex criteria of search */
protected string $sCriteria = '';
/** @var array Array of regex of excludes in search */
protected array $aExcludes = [];
/** @var array Array of ElementObjects containing criteria for elements to search for */
protected array $aElementObjects = [];
public function __construct()
{
}
// language=RegExp
public static function htmlBodyElementToken(): string
{
return self::htmlHeadElementToken().'\\K.*+$';
}
// language=RegExp
public static function htmlHeadElementToken(): string
{
$aExcludes = [self::htmlElementToken('script'), self::htmlCommentToken()];
return '<head\\b'.self::parseHtml($aExcludes).'</head\\b\\s*+>';
}
// language=RegExp
public static function htmlClosingHeadTagToken(): string
{
$excludes = [self::htmlElementToken('script'), self::htmlCommentToken()];
return self::parseHtml($excludes).'\\K(?:</head\\s*+>|$)';
}
// language=RegExp
public static function htmlClosingBodyTagToken(): string
{
return '.*\\K</body\\s*+>(?=(?>[^<]*+(?:'.self::htmlCommentToken().')?)*?</html\\s*+>)';
}
public function addElementObject(ElementObject $oElementObject): void
{
$this->aElementObjects[] = $oElementObject;
}
public function addExclude(string $sExclude): void
{
$this->aExcludes[] = $sExclude;
}
/**
* @return null|array|string|string[]
*
* @throws Exception\PregErrorException
*/
public function processMatchesWithCallback(string $html, AbstractCallback $callbackObject)
{
$regex = $this->getHtmlSearchRegex();
$callbackObject->setRegex($regex);
$sProcessedHtml = \preg_replace_callback('#'.$regex.'#six', [$callbackObject, 'processMatches'], $html);
try {
self::throwExceptionOnPregError();
} catch (\Exception $exception) {
throw new Exception\PregErrorException($exception->getMessage());
}
return $sProcessedHtml;
}
/**
* @param int|mixed $iFlags
*
* @psalm-param 1|2|mixed $iFlags
*
* @return ((null|(int|string)[]|mixed|string)[]|mixed)[]
*
* @psalm-return array<array<list{string, int}|mixed|null|string>|mixed>
*
* @throws Exception\PregErrorException
*/
public function findMatches(string $sHtml, $iFlags = \PREG_PATTERN_ORDER): array
{
\preg_match_all('#'.$this->getHtmlSearchRegex().'#six', $sHtml, $aMatches, $iFlags);
try {
self::throwExceptionOnPregError();
} catch (\Exception $exception) {
throw new Exception\PregErrorException($exception->getMessage());
}
// Last array will always be an empty string so let's remove that
if (\PREG_PATTERN_ORDER == $iFlags) {
return \array_map(function ($a) {
return \array_slice($a, 0, -1);
}, $aMatches);
} elseif (\PREG_SET_ORDER == $iFlags) {
\array_pop($aMatches);
return $aMatches;
} else {
return $aMatches;
}
}
public function getElementWithCriteria(): string
{
$this->setCriteria(\false);
return $this->sCriteria;
}
// language=RegExp
protected static function parseHtml(array $excludes = [], $lazy = \true): string
{
$excludes[] = self::htmlSelfClosingElementToken();
$excludes[] = '<';
$excludes = '(?:'.\implode('|', $excludes).')?';
$lazily = $lazy ? '?' : '';
return '(?>[^<]*+'.$excludes.')*'.$lazily.'[^<]*+';
}
protected function getHtmlSearchRegex(): string
{
$this->setCriteria();
// language=RegExp
return self::parseHtml($this->getExcludes()).'\\K(?:'.$this->getCriteria().'|$)';
}
// language=RegExp
protected function setCriteria(bool $bBranchReset = \true): void
{
$aCriteria = [];
/** @var ElementObject $oElement */
foreach ($this->aElementObjects as $oElement) {
$sRegex = '<';
$aNames = \implode('|', $oElement->getNamesArray());
$sRegex .= '('.$aNames.')\\b\\s*+';
$sRegex .= $this->compileCriteria($oElement);
$aCaptureAttributes = $oElement->getCaptureAttributesArray();
if (!empty($aCaptureAttributes)) {
$mValueCriteria = $oElement->getValueCriteriaRegex();
if (\is_string($mValueCriteria)) {
$aValueCriteria = ['.' => $mValueCriteria];
} else {
$aValueCriteria = $mValueCriteria;
}
foreach ($aCaptureAttributes as $sCaptureAttribute) {
foreach ($aValueCriteria as $sRegexKey => $sValueCriteria) {
if ('' != $sValueCriteria && \preg_match('#'.$sRegexKey.'#i', $sCaptureAttribute)) {
// If criteria is specified for attribute it must match
$sRegex .= '(?='.$this->parseAttributes().'('.self::htmlAttributeWithCaptureValueToken($sCaptureAttribute, \true, \true, $sValueCriteria).'))';
} else {
// If no criteria specified matching is optional
$sRegex .= '(?=(?:'.$this->parseAttributes().'('.self::htmlAttributeWithCaptureValueToken($sCaptureAttribute, \true, \true).'))?)';
}
}
}
}
if (!empty($aCaptureOneOrBothAttributes = $oElement->getCaptureOneOrBothAttributesArray())) {
// Has to be either a string for both attributes or associative array of criteria for both attributes
$mValueCriteria = $oElement->getValueCriteriaRegex();
if (\is_string($mValueCriteria)) {
$aValueCriteria = [$aCaptureOneOrBothAttributes[0] => $mValueCriteria, $aCaptureOneOrBothAttributes[1] => $mValueCriteria];
} else {
$aValueCriteria = $mValueCriteria;
}
$sRegex .= '(?(?='.$this->parseAttributes().'('.self::htmlAttributeWithCaptureValueToken($aCaptureOneOrBothAttributes[0], \true, \true, $aValueCriteria[$aCaptureOneOrBothAttributes[0]]).'))(?='.$this->parseAttributes().'('.self::htmlAttributeWithCaptureValueToken($aCaptureOneOrBothAttributes[1], \true, \true, $aValueCriteria[$aCaptureOneOrBothAttributes[1]]).'))?|(?='.$this->parseAttributes().'('.self::htmlAttributeWithCaptureValueToken($aCaptureOneOrBothAttributes[1], \true, \true, $aValueCriteria[$aCaptureOneOrBothAttributes[1]]).')))';
}
$sRegex .= $this->parseAttributes();
$sRegex .= '/?>';
if (!$oElement->bSelfClosing) {
if ($oElement->bCaptureContent) {
$sRegex .= $oElement->getValueCriteriaRegex().'('.self::parseHtml().')';
} else {
$sRegex .= self::parseHtml();
}
$sRegex .= '</(?:'.$aNames.')\\s*+>';
}
$aCriteria[] = $sRegex;
}
$sCriteria = \implode('|', $aCriteria);
if ($bBranchReset) {
$this->sCriteria = '(?|'.$sCriteria.')';
} else {
$this->sCriteria = $sCriteria;
}
}
// language=RegExp
protected function compileCriteria(ElementObject $oElement): string
{
$sCriteria = '';
$aAttrNegCriteria = $oElement->getNegAttrCriteriaArray();
if (!empty($aAttrNegCriteria)) {
foreach ($aAttrNegCriteria as $sAttrNegCriteria) {
$sCriteria .= $this->processNegCriteria($sAttrNegCriteria);
}
}
$aAttrPosCriteria = $oElement->getPosAttrCriteriaArray();
if (!empty($aAttrPosCriteria)) {
foreach ($aAttrPosCriteria as $sAttrPosCriteria) {
$sCriteria .= $this->processPosCriteria($sAttrPosCriteria);
}
}
if ($oElement->bNegateCriteria) {
$sCriteria = '(?!'.$sCriteria.')';
}
return $sCriteria;
}
// language=RegExp
protected function processNegCriteria($sCriteria): string
{
return '(?!'.$this->processCriteria($sCriteria).')';
}
protected function processCriteria($sCriteria): string
{
return $this->parseAttributes().'(?:'.\str_replace('==', '\\s*+=\\s*+', $sCriteria).')';
}
// language=RegExp
protected function parseAttributes(): string
{
return self::parseAttributesStatic();
}
// language=RegExp
protected function processPosCriteria($sCriteria): string
{
return '(?='.$this->processCriteria($sCriteria).')';
}
protected function getExcludes(): array
{
return $this->aExcludes;
}
protected function getCriteria(): string
{
return $this->sCriteria;
}
}

View File

@@ -0,0 +1,552 @@
<?php
/**
* JCH Optimize - Performs several front-end optimizations for fast downloads.
*
* @author Samuel Marshall <samuel@jch-optimize.net>
* @copyright Copyright (c) 2022 Samuel Marshall / JCH Optimize
* @license GNU/GPLv3, or later. See LICENSE file
*
* If LICENSE file missing, see <http://www.gnu.org/licenses/>.
*/
namespace JchOptimize\Core\Html;
use _JchOptimizeVendor\Joomla\DI\ContainerAwareInterface;
use _JchOptimizeVendor\Joomla\DI\ContainerAwareTrait;
use JchOptimize\Core\Cdn as CdnCore;
use JchOptimize\Core\Css\Parser as CssParser;
use JchOptimize\Core\Exception;
use JchOptimize\Core\FeatureHelpers\LazyLoadExtended;
use JchOptimize\Core\Helper;
use JchOptimize\Core\Html\Callbacks\Cdn as CdnCallback;
use JchOptimize\Core\Html\Callbacks\CombineJsCss;
use JchOptimize\Core\Html\Callbacks\LazyLoad;
use JchOptimize\Core\SystemUri;
use JchOptimize\Core\Uri\Utils;
use JchOptimize\Platform\Profiler;
use Joomla\Registry\Registry;
use Psr\Log\LoggerAwareInterface;
use Psr\Log\LoggerAwareTrait;
\defined('_JCH_EXEC') or exit('Restricted access');
/**
* Class Processor.
*/
class Processor implements LoggerAwareInterface, ContainerAwareInterface
{
use ContainerAwareTrait;
use LoggerAwareTrait;
/**
* @var bool Indicates if the page is an Amp page
*/
public bool $isAmpPage = \false;
/**
* @var array Array of IMG elements requiring width/height attribute
*/
public array $images = [];
/**
* @var Registry Plugin parameters
*/
private Registry $params;
/**
* @var string Used to determine the end of useful string after parsing
*/
private string $sRegexMarker = 'JCHREGEXMARKER';
/**
* @var string HTML being processed
*/
private string $html;
/**
* Processor constructor.
*
* @param Registry $params Plugin parameters
*/
public function __construct(Registry $params)
{
$this->params = $params;
}
/**
* Returns the HTML being processed.
*/
public function getHtml(): string
{
return $this->html;
}
public function setHtml(string $html): void
{
$this->html = $html;
// If amp page then combine CSS and JavaScript is disabled and any feature dependent of processing generated combined files,
// and also lazy load images.
$this->isAmpPage = (bool) \preg_match('#<html [^>]*?(?:&\\#26A1;|\\bamp\\b)#i', $html);
}
public function processCombineJsCss(): void
{
if ($this->params->get('combine_files_enable', '1') || $this->params->get('pro_http2_push_enable', '0') || $this->params->get('remove_css', []) || $this->params->get('remove_js', [])) {
try {
$oParser = new \JchOptimize\Core\Html\Parser();
$oParser->addExclude(\JchOptimize\Core\Html\Parser::htmlCommentToken());
$oParser->addExclude(\JchOptimize\Core\Html\Parser::htmlElementToken('noscript'));
$oParser->addExclude(\JchOptimize\Core\Html\Parser::htmlElementToken('template'));
$oParser->addExclude(\JchOptimize\Core\Html\Parser::htmlElementToken('script'));
$this->setUpJsCssCriteria($oParser);
/** @var CombineJsCss $combineJsCss */
$combineJsCss = $this->container->get(CombineJsCss::class);
$combineJsCss->setSection('head');
$sProcessedHeadHtml = $oParser->processMatchesWithCallback($this->getHeadHtml(), $combineJsCss);
$this->setHeadHtml($sProcessedHeadHtml);
if ($this->params->get('bottom_js', '0')) {
$combineJsCss->setSection('body');
$sProcessedBodyHtml = $oParser->processMatchesWithCallback($this->getBodyHtml(), $combineJsCss);
$this->setBodyHtml($sProcessedBodyHtml);
}
} catch (Exception\ExceptionInterface $oException) {
$this->logger->error('CombineJsCss failed '.$oException->getMessage());
}
}
}
public function getHeadHtml(): string
{
\preg_match('#'.\JchOptimize\Core\Html\Parser::htmlHeadElementToken().'#i', $this->html, $aMatches);
return $aMatches[0].$this->sRegexMarker;
}
public function setHeadHtml(string $sHtml): void
{
$sHtml = $this->cleanRegexMarker($sHtml);
$this->html = \preg_replace('#'.\JchOptimize\Core\Html\Parser::htmlHeadElementToken().'#i', Helper::cleanReplacement($sHtml), $this->html, 1);
}
public function getBodyHtml(): string
{
\preg_match('#'.\JchOptimize\Core\Html\Parser::htmlBodyElementToken().'#si', $this->html, $aMatches);
return $aMatches[0].$this->sRegexMarker;
}
public function setBodyHtml(string $sHtml): void
{
$sHtml = $this->cleanRegexMarker($sHtml);
$this->html = \preg_replace('#'.\JchOptimize\Core\Html\Parser::htmlBodyElementToken().'#si', Helper::cleanReplacement($sHtml), $this->html, 1);
}
/**
* @deprecated
*/
public function isCombineFilesSet(): bool
{
return !Helper::isMsieLT10() && $this->params->get('combine_files_enable', '1');
}
public function processImagesForApi(): array
{
try {
$oParser = new \JchOptimize\Core\Html\Parser();
$oParser->addExclude(\JchOptimize\Core\Html\Parser::htmlCommentToken());
$oParser->addExclude(\JchOptimize\Core\Html\Parser::htmlElementsToken(['script', 'noscript', 'style']));
$oImgElement = new \JchOptimize\Core\Html\ElementObject();
$oImgElement->bSelfClosing = \true;
$oImgElement->setNamesArray(['img']);
$oImgElement->setCaptureOneOrBothAttributesArray(['src', 'srcset']);
$oParser->addElementObject($oImgElement);
unset($oImgElement);
$oBgElement = new \JchOptimize\Core\Html\ElementObject();
$oBgElement->setNamesArray(['[^\\s/"\'=<>]++']);
$oBgElement->bSelfClosing = \true;
$oBgElement->setCaptureAttributesArray(['style']);
// language=RegExp
$sValueCriteriaRegex = '(?=(?>[^b>]*+b?)*?[^b>]*+(background(?:-image)?))(?=(?>[^u>]*+u?)*?[^u>]*+('.CssParser::cssUrlWithCaptureValueToken(\true).'))';
$oBgElement->setValueCriteriaRegex(['style' => $sValueCriteriaRegex]);
$oParser->addElementObject($oBgElement);
unset($oBgElement);
return $oParser->findMatches($this->getBodyHtml(), \PREG_SET_ORDER);
} catch (Exception\PregErrorException $oException) {
$this->logger->error('ProcessApiImages failed '.$oException->getMessage());
}
return [];
}
public function processLazyLoad(): void
{
$bLazyLoad = $this->params->get('lazyload_enable', '0') && !$this->isAmpPage;
if ($bLazyLoad || $this->params->get('pro_http2_push_enable', '0') || $this->params->get('pro_load_webp_images', '0')) {
!JCH_DEBUG ?: Profiler::start('LazyLoadImages');
$sHtml = $this->getBodyHtml();
$sAboveFoldBody = '';
\preg_replace_callback('#[^<]*+(?:<[0-9a-z!]++[^>]*+>[^<]*+(?><[^0-9a-z][^<]*+)*+)#six', function ($m) use (&$sAboveFoldBody) {
$sAboveFoldBody .= $m[0];
return $m[0];
}, $sHtml, (int) $this->params->get('lazyload_num_elements', 50));
$sBelowFoldHtml = \substr($sHtml, \strlen($sAboveFoldBody));
$fullHtml = $this->getFullHtml();
$aboveFoldHtml = \substr($fullHtml, 0, \strlen($fullHtml) - \strlen($sBelowFoldHtml)).$this->sRegexMarker;
try {
$http2Args = ['lazyload' => \false, 'deferred' => \false, 'parent' => ''];
$oAboveFoldParser = new \JchOptimize\Core\Html\Parser();
// language=RegExp
$this->setupLazyLoadCriteria($oAboveFoldParser, \false);
/** @var LazyLoad $http2Callback */
$http2Callback = $this->container->get(LazyLoad::class);
$http2Callback->setLazyLoadArgs($http2Args);
$processedAboveFoldHtml = $oAboveFoldParser->processMatchesWithCallback($aboveFoldHtml, $http2Callback);
$oBelowFoldParser = new \JchOptimize\Core\Html\Parser();
$lazyLoadArgs = ['lazyload' => $bLazyLoad, 'deferred' => \true, 'parent' => ''];
$this->setupLazyLoadCriteria($oBelowFoldParser, \true);
/** @var LazyLoad $lazyLoadCallback */
$lazyLoadCallback = $this->container->get(LazyLoad::class);
$lazyLoadCallback->setLazyLoadArgs($lazyLoadArgs);
$processedBelowFoldHtml = $oBelowFoldParser->processMatchesWithCallback($sBelowFoldHtml, $lazyLoadCallback);
$this->setFullHtml($this->cleanRegexMarker($processedAboveFoldHtml).$processedBelowFoldHtml);
} catch (Exception\PregErrorException $oException) {
$this->logger->error('Lazy-load failed: '.$oException->getMessage());
}
!JCH_DEBUG ?: Profiler::stop('LazyLoadImages', \true);
}
}
public function processImageAttributes(): void
{
if ($this->params->get('img_attributes_enable', '0') || $this->params->get('lazyload_enable', '0') && $this->params->get('lazyload_autosize', '0')) {
!JCH_DEBUG ?: Profiler::start('ProcessImageAttributes');
$oParser = new \JchOptimize\Core\Html\Parser();
$oParser->addExclude(\JchOptimize\Core\Html\Parser::htmlCommentToken());
$oParser->addExclude(\JchOptimize\Core\Html\Parser::htmlElementToken('script'));
$oParser->addExclude(\JchOptimize\Core\Html\Parser::htmlElementToken('noscript'));
$oParser->addExclude(\JchOptimize\Core\Html\Parser::htmlElementToken('textarea'));
$oParser->addExclude(\JchOptimize\Core\Html\Parser::htmlElementToken('template'));
$oImgElement = new \JchOptimize\Core\Html\ElementObject();
$oImgElement->setNamesArray(['img']);
$oImgElement->bSelfClosing = \true;
// language=RegExp
$oImgElement->addPosAttrCriteriaRegex('width');
// language=RegExp
$oImgElement->addPosAttrCriteriaRegex('height');
$oImgElement->bNegateCriteria = \true;
$oImgElement->setCaptureAttributesArray(['data-src', 'src']);
$oImgElement->addNegAttrCriteriaRegex('src-set');
$oParser->addElementObject($oImgElement);
try {
$this->images = $oParser->findMatches($this->getBodyHtml());
} catch (Exception\PregErrorException $oException) {
$this->logger->error('Image Attributes matches failed: '.$oException->getMessage());
}
!JCH_DEBUG ?: Profiler::stop('ProcessImageAttributes', \true);
}
}
public function processCdn(): void
{
if (!$this->params->get('cookielessdomain_enable', '0') || '' == \trim($this->params->get('cookielessdomain', '')) && '' == \trim($this->params->get('pro_cookielessdomain_2', '')) && '' == \trim($this->params->get('pro_cookieless_3', ''))) {
return;
}
!JCH_DEBUG ?: Profiler::start('RunCookieLessDomain');
$cdnCore = $this->container->get(CdnCore::class);
$staticFiles = $cdnCore->getCdnFileTypes();
$sf = \implode('|', $staticFiles);
$oUri = SystemUri::currentUri();
$port = $oUri->getPort();
if (empty($port)) {
$port = ':80';
}
$host = '(?:www\\.)?'.\preg_quote(\preg_replace('#^www\\.#i', '', $oUri->getHost()), '#').'(?:'.$port.')?';
// Find base value in HTML
$oBaseParser = new \JchOptimize\Core\Html\Parser();
$oBaseElement = new \JchOptimize\Core\Html\ElementObject();
$oBaseElement->setNamesArray(['base']);
$oBaseElement->bSelfClosing = \true;
$oBaseElement->setCaptureAttributesArray(['href']);
$oBaseParser->addElementObject($oBaseElement);
$aMatches = $oBaseParser->findMatches($this->getHeadHtml());
unset($oBaseParser, $oBaseElement);
$baseUri = SystemUri::currentUri();
// Adjust $dir if necessary based on <base/>
if (!empty($aMatches[0])) {
$uri = Utils::uriFor($aMatches[4][0]);
if ('' != (string) $uri) {
$baseUri = $uri;
}
}
// This part should match the scheme and host of a local file
// language=RegExp
$localhost = '(?:\\s*+(?:(?>https?:)?//'.$host.')?)(?!http|//)';
// language=RegExp
$valueMatch = '(?!data:image)(?='.$localhost.')(?=((?<=")(?>\\.?[^.>"?]*+)*?\\.(?>'.$sf.')(?:[?\\#][^>"]*+)?(?=")|(?<=\')(?>\\.?[^.>\'?]*+)*?\\.(?>'.$sf.')(?:[?\\#][^>\']*+)?(?=\')|(?<=\\()(?>\\.?[^.>)?]*+)*?\\.(?>'.$sf.')(?:[?\\#][^>)]*+)?(?=\\))|(?<=^|[=\\s,])(?>\\.?[^.>\\s?]*+)*?\\.(?>'.$sf.')(?:[?\\#][^>\\s]*+)?(?=[\\s>]|$)))';
try {
// Get regex for <script> without src attribute
$oElementParser = new \JchOptimize\Core\Html\Parser();
$oElementWithCriteria = new \JchOptimize\Core\Html\ElementObject();
$oElementWithCriteria->setNamesArray(['script']);
$oElementWithCriteria->addNegAttrCriteriaRegex('src');
$oElementParser->addElementObject($oElementWithCriteria);
$sScriptWithoutSrc = $oElementParser->getElementWithCriteria();
unset($oElementParser, $oElementWithCriteria);
// Process cdn for elements with href or src attributes
$oSrcHrefParser = new \JchOptimize\Core\Html\Parser();
$oSrcHrefParser->addExclude(\JchOptimize\Core\Html\Parser::htmlCommentToken());
$oSrcHrefParser->addExclude($sScriptWithoutSrc);
$this->setUpCdnSrcHrefCriteria($oSrcHrefParser, $valueMatch);
/** @var CdnCallback $cdnCallback */
$cdnCallback = $this->container->get(CdnCallback::class);
$cdnCallback->setBaseUri($baseUri);
$cdnCallback->setLocalhost($host);
$sCdnHtml = $oSrcHrefParser->processMatchesWithCallback($this->getFullHtml(), $cdnCallback);
unset($oSrcHrefParser);
$this->setFullHtml($sCdnHtml);
// Process cdn for CSS urls in style attributes or <style/> elements
// language=RegExp
$sUrlSearchRegex = '(?=((?>[^()<>]*+[()]?)*?[^()<>]*+(?<=url)\\((?>[\'"]?)'.$valueMatch.'))';
$oUrlParser = new \JchOptimize\Core\Html\Parser();
$oUrlParser->addExclude(\JchOptimize\Core\Html\Parser::htmlCommentToken());
$oUrlParser->addExclude(\JchOptimize\Core\Html\Parser::htmlElementsToken(['script', 'link', 'meta']));
$this->setUpCdnUrlCriteria($oUrlParser, $sUrlSearchRegex);
$cdnCallback->setContext('cssurl');
$cdnCallback->setSearchRegex($valueMatch);
$sCdnUrlHtml = $oUrlParser->processMatchesWithCallback($this->getFullHtml(), $cdnCallback);
unset($oUrlParser);
$this->setFullHtml($sCdnUrlHtml);
// Process cdn for elements with srcset attributes
$oSrcsetParser = new \JchOptimize\Core\Html\Parser();
$oSrcsetParser->addExclude(\JchOptimize\Core\Html\Parser::htmlCommentToken());
$oSrcsetParser->addExclude(\JchOptimize\Core\Html\Parser::htmlElementToken('script'));
$oSrcsetParser->addExclude(\JchOptimize\Core\Html\Parser::htmlElementToken('style'));
$oSrcsetElement = new \JchOptimize\Core\Html\ElementObject();
$oSrcsetElement->bSelfClosing = \true;
$oSrcsetElement->setNamesArray(['img', 'source']);
$oSrcsetElement->setCaptureOneOrBothAttributesArray(['srcset', 'data-srcset']);
$oSrcsetElement->setValueCriteriaRegex('(?=.)');
$oSrcsetParser->addElementObject($oSrcsetElement);
$cdnCallback->setContext('srcset');
$sCdnSrcsetHtml = $oSrcsetParser->processMatchesWithCallback($this->getBodyHtml(), $cdnCallback);
unset($oSrcsetParser, $oSrcsetElement);
$this->setBodyHtml($sCdnSrcsetHtml);
} catch (Exception\PregErrorException $oException) {
$this->logger->error('Cdn failed :'.$oException->getMessage());
}
!JCH_DEBUG ?: Profiler::stop('RunCookieLessDomain', \true);
}
public function getFullHtml(): string
{
return $this->html.$this->sRegexMarker;
}
public function setFullHtml(string $sHtml): void
{
$this->html = $this->cleanRegexMarker($sHtml);
}
public function processModulesForPreload(): array
{
try {
$parser = new \JchOptimize\Core\Html\Parser();
$parser->addExclude(\JchOptimize\Core\Html\Parser::htmlCommentToken());
$parser->addExclude(\JchOptimize\Core\Html\Parser::htmlElementToken('noscript'));
$element = new \JchOptimize\Core\Html\ElementObject();
$element->setNamesArray(['script']);
$element->addPosAttrCriteriaRegex('type==[\'"]?module');
$element->setCaptureAttributesArray(['src']);
$element->setValueCriteriaRegex('(?=.)');
$parser->addElementObject($element);
return $parser->findMatches($this->getFullHtml(), \PREG_PATTERN_ORDER);
} catch (Exception\PregErrorException $e) {
$this->logger->error('ProcessModulesForPreload feiled '.$e->getMessage());
}
return [];
}
public function processDataFromCacheScriptToken(string $token): void
{
try {
$parser = new \JchOptimize\Core\Html\Parser();
$element = new \JchOptimize\Core\Html\ElementObject();
$element->setNamesArray(['script']);
$element->addPosAttrCriteriaRegex('type==(?>[\'"]?)application/(?:ld\\+)?json');
$element->addPosAttrCriteriaRegex('class==(?>[\'"]?)[^\'"<>]*?joomla-script-options');
$parser->addElementObject($element);
$headHtml = $this->getHeadHtml();
$matches = $parser->findMatches($this->getHeadHtml());
if (!empty($matches[0])) {
$tokenized = \preg_replace('#"csrf.token":"\\K[^"]++#', $token, $matches[0]);
$newHeadHtml = \str_replace($matches[0], $tokenized, $headHtml);
$this->setHeadHtml($newHeadHtml);
}
} catch (Exception\PregErrorException $e) {
$this->logger->error('ProcessDataFromCache failed '.$e->getMessage());
}
}
public function cleanHtml(): string
{
$aSearch = ['#'.\JchOptimize\Core\Html\Parser::htmlHeadElementToken().'#ix', '#'.\JchOptimize\Core\Html\Parser::htmlCommentToken().'#ix', '#'.\JchOptimize\Core\Html\Parser::htmlElementToken('script').'#ix', '#'.\JchOptimize\Core\Html\Parser::htmlElementToken('style').'#ix', '#'.\JchOptimize\Core\Html\Parser::htmlElementToken('link', \true).'#six'];
$aReplace = ['<head><title></title></head>', '', '', '', ''];
$html = \preg_replace($aSearch, $aReplace, $this->html);
// Remove any hidden element from HtmL
$html = \preg_replace_callback('#(<[^>]*+>)[^<>]*+#ix', function ($aMatches) {
if (\preg_match('#type\\s*+=\\s*+["\']?hidden["\'\\s>]|\\shidden(?=[\\s>=])[^>\'"=]*+[>=]#i', $aMatches[1])) {
return '';
}
// Add linebreak for readability during debugging
return $aMatches[1]."\n";
}, $html);
// Remove Text nodes
// Remove text nodes from HTML elements
return \preg_replace_callback('#(<(?>[^<>]++|(?1))*+>)|((?<=>)(?=[^<>\\S]*+[^<>\\s])[^<>]++)#', function ($m) {
if (!empty($m[1])) {
return $m[0];
}
if (!empty($m[2])) {
return ' ';
}
return '';
}, $html);
}
protected function setUpJsCssCriteria(Parser $oParser): void
{
$oJsFilesElement = new \JchOptimize\Core\Html\ElementObject();
$oJsFilesElement->setNamesArray(['script']);
// language=RegExp
$oJsFilesElement->addNegAttrCriteriaRegex('type==(?!(?>[\'"]?)(?:(?:text|application)/javascript|module)[\'"> ])');
$oJsFilesElement->setCaptureAttributesArray(['src']);
$oJsFilesElement->setValueCriteriaRegex('(?=.)');
$oParser->addElementObject($oJsFilesElement);
$oJsContentElement = new \JchOptimize\Core\Html\ElementObject();
$oJsContentElement->setNamesArray(['script']);
// language=RegExp
$oJsContentElement->addNegAttrCriteriaRegex('src|type==(?!(?>[\'"]?)(?:(?:text|application)/javascript|module)[\'"> ])');
$oJsContentElement->bCaptureContent = \true;
$oParser->addElementObject($oJsContentElement);
$oCssFileElement = new \JchOptimize\Core\Html\ElementObject();
$oCssFileElement->bSelfClosing = \true;
$oCssFileElement->setNamesArray(['link']);
// language=RegExp
$oCssFileElement->addNegAttrCriteriaRegex('itemprop|disabled|type==(?!(?>[\'"]?)text/css[\'"> ])|rel==(?!(?>[\'"]?)stylesheet[\'"> ])');
$oCssFileElement->setCaptureAttributesArray(['href']);
$oCssFileElement->setValueCriteriaRegex('(?=.)');
$oParser->addElementObject($oCssFileElement);
$oStyleElement = new \JchOptimize\Core\Html\ElementObject();
$oStyleElement->setNamesArray(['style']);
// language=RegExp
$oStyleElement->addNegAttrCriteriaRegex('scope|amp|type==(?!(?>[\'"]?)text/(?:css|stylesheet)[\'"> ])');
$oStyleElement->bCaptureContent = \true;
$oParser->addElementObject($oStyleElement);
}
protected function cleanRegexMarker(string $sHtml): ?string
{
return \preg_replace('#'.\preg_quote($this->sRegexMarker, '#').'.*+$#', '', $sHtml);
}
protected function setupLazyLoadCriteria(Parser $oParser, bool $bDeferred): void
{
$oParser->addExclude(\JchOptimize\Core\Html\Parser::htmlCommentToken());
$oParser->addExclude(\JchOptimize\Core\Html\Parser::htmlElementToken('script'));
$oParser->addExclude(\JchOptimize\Core\Html\Parser::htmlElementToken('noscript'));
$oParser->addExclude(\JchOptimize\Core\Html\Parser::htmlElementToken('textarea'));
$oParser->addExclude(\JchOptimize\Core\Html\Parser::htmlElementToken('template'));
$oImgElement = new \JchOptimize\Core\Html\ElementObject();
$oImgElement->bSelfClosing = \true;
$oImgElement->setNamesArray(['img']);
// language=RegExp
$oImgElement->addNegAttrCriteriaRegex('(?:data-(?:original-)?src)');
$oImgElement->setCaptureAttributesArray(['class', 'src', 'srcset', '(?:data-)?width', '(?:data-)?height']);
$oParser->addElementObject($oImgElement);
unset($oImgElement);
$oInputElement = new \JchOptimize\Core\Html\ElementObject();
$oInputElement->bSelfClosing = \true;
$oInputElement->setNamesArray(['input']);
// language=RegExp
$oInputElement->addPosAttrCriteriaRegex('type=(?>[\'"]?)image[\'"> ]');
$oInputElement->setCaptureAttributesArray(['class', 'src']);
$oParser->addElementObject($oInputElement);
unset($oInputElement);
$oPictureElement = new \JchOptimize\Core\Html\ElementObject();
$oPictureElement->setNamesArray(['picture']);
$oPictureElement->setCaptureAttributesArray(['class']);
$oPictureElement->bCaptureContent = \true;
$oParser->addElementObject($oPictureElement);
unset($oPictureElement);
if (JCH_PRO) {
// @see LazyLoadExtended::setupLazyLoadExtended()
$this->container->get(LazyLoadExtended::class)->setupLazyLoadExtended($oParser, $bDeferred);
}
}
protected function setUpCdnSrcHrefCriteria(Parser $oParser, string $sValueMatch): void
{
$oSrcElement = new \JchOptimize\Core\Html\ElementObject();
$oSrcElement->bSelfClosing = \true;
$oSrcElement->setNamesArray(['img', 'script', 'source', 'input']);
$oSrcElement->setCaptureOneOrBothAttributesArray(['src', 'data-src']);
$oSrcElement->setValueCriteriaRegex($sValueMatch);
$oParser->addElementObject($oSrcElement);
unset($oSrcElement);
$oHrefElement = new \JchOptimize\Core\Html\ElementObject();
$oHrefElement->bSelfClosing = \true;
$oHrefElement->setNamesArray(['a', 'link', 'image']);
$oHrefElement->setCaptureAttributesArray(['(?:xlink:)?href']);
$oHrefElement->setValueCriteriaRegex($sValueMatch);
$oParser->addElementObject($oHrefElement);
unset($oHrefElement);
$oVideoElement = new \JchOptimize\Core\Html\ElementObject();
$oVideoElement->bSelfClosing = \true;
$oVideoElement->setNamesArray(['video']);
$oVideoElement->setCaptureAttributesArray(['(?:src|poster)']);
$oVideoElement->setValueCriteriaRegex($sValueMatch);
$oParser->addElementObject($oVideoElement);
unset($oVideoElement);
$oMediaElement = new \JchOptimize\Core\Html\ElementObject();
$oMediaElement->bSelfClosing = \true;
$oMediaElement->setNamesArray(['meta']);
$oMediaElement->setCaptureAttributesArray(['content']);
$oMediaElement->setValueCriteriaRegex($sValueMatch);
$oParser->addElementObject($oMediaElement);
unset($oMediaElement);
}
protected function setUpCdnUrlCriteria(Parser $oParser, string $sValueMatch): void
{
$oElements = new \JchOptimize\Core\Html\ElementObject();
$oElements->bSelfClosing = \true;
// language=RegExp
$oElements->setNamesArray(['(?!style|script|link|meta)[^\\s/"\'=<>]++']);
$oElements->setCaptureAttributesArray(['style']);
$oElements->setValueCriteriaRegex($sValueMatch);
$oParser->addElementObject($oElements);
unset($oElements);
$oStyleElement = new \JchOptimize\Core\Html\ElementObject();
$oStyleElement->setNamesArray(['style']);
$oStyleElement->bCaptureContent = \true;
$oStyleElement->setValueCriteriaRegex($sValueMatch);
$oParser->addElementObject($oStyleElement);
unset($oStyleElement);
}
}

View File

@@ -0,0 +1,273 @@
<?php
/**
* JCH Optimize - Performs several front-end optimizations for fast downloads.
*
* @author Samuel Marshall <samuel@jch-optimize.net>
* @copyright Copyright (c) 2022 Samuel Marshall / JCH Optimize
* @license GNU/GPLv3, or later. See LICENSE file
*
* If LICENSE file missing, see <http://www.gnu.org/licenses/>.
*/
namespace JchOptimize\Core;
use _JchOptimizeVendor\GuzzleHttp\Psr7\UriResolver;
use _JchOptimizeVendor\Joomla\DI\ContainerAwareInterface;
use _JchOptimizeVendor\Joomla\DI\ContainerAwareTrait;
use _JchOptimizeVendor\Laminas\EventManager\Event;
use _JchOptimizeVendor\Psr\Http\Message\UriInterface;
use JchOptimize\Core\FeatureHelpers\Http2Excludes;
use JchOptimize\Core\Html\LinkBuilder;
use JchOptimize\Core\Html\Processor;
use JchOptimize\Core\Uri\UriComparator;
use JchOptimize\Core\Uri\UriNormalizer;
use JchOptimize\Platform\Cache;
use JchOptimize\Platform\Hooks;
use Joomla\Registry\Registry;
// No direct access
\defined('_JCH_EXEC') or exit('Restricted access');
class Http2Preload implements ContainerAwareInterface
{
use ContainerAwareTrait;
private bool $enable = \false;
private Registry $params;
/**
* @var array multidimensional array of files to be preloaded whether by using a <link> element in the HTML or
* sending a Link Request Header to the server
*/
private array $aPreloads = ['html' => [], 'link' => []];
/**
* @var Cdn
*/
private \JchOptimize\Core\Cdn $cdn;
private bool $includesAdded = \false;
public function __construct(Registry $params, Cdn $cdn)
{
$this->params = $params;
$this->cdn = $cdn;
if ($params->get('http2_push_enable', '0')) {
$this->enable = \true;
}
}
/**
* @param UriInterface $uri Url of file
*
* @return false|void
*/
public function add(UriInterface $uri, string $type, bool $isDeferred = \false)
{
if (!$this->enable) {
return;
}
if ('' == (string) $uri || 'data' == $uri->getScheme()) {
return \false;
}
if (JCH_PRO) {
// @see Http2Excludes::findHttp2Excludes()
if ($this->container->get(Http2Excludes::class)->findHttp2Excludes($uri, $isDeferred)) {
return \false;
}
}
$uri = UriResolver::resolve(\JchOptimize\Core\SystemUri::currentUri(), $uri);
// Skip external files
if (UriComparator::isCrossOrigin($uri)) {
return \false;
}
if ($this->params->get('cookielessdomain_enable', '0')) {
static $sCdnFileTypesRegex = '';
if (empty($sCdnFileTypesRegex)) {
$sCdnFileTypesRegex = \implode('|', $this->cdn->getCdnFileTypes());
}
// If this file type will be loaded by CDN don't push if option not set
if ('' != $sCdnFileTypesRegex && \preg_match('#\\.(?>'.$sCdnFileTypesRegex.')#i', $uri->getPath()) && !$this->params->get('pro_http2_push_cdn', '0')) {
return \false;
}
}
if ('image' == $type) {
static $no_image = 0;
if ($no_image++ > 10) {
return \false;
}
}
if ('js' == $type) {
static $no_js = 0;
if ($no_js++ > 10) {
return \false;
}
$type = 'script';
}
if ('css' == $type) {
static $no_css = 0;
if ($no_css++ > 10) {
return \false;
}
$type = 'style';
}
if (!\in_array($type, $this->params->get('pro_http2_file_types', ['style', 'script', 'font', 'image']))) {
return \false;
}
if ('font' == $type) {
// Only push fonts of type woff/woff2
if ('1' == \preg_match('#\\.\\K(?:woff2?|ttf)(?=$|[\\#?])#', $uri->getPath(), $m)) {
static $no_font = 0;
if ($no_font++ > 10) {
return \false;
}
$this->internalAdd($uri, $type, $m[0]);
} else {
return \false;
}
} else {
// Populate preload variable
$this->internalAdd($uri, $type);
}
}
public function addAdditional(UriInterface $uri, string $type, string $ext): void
{
$this->internalAdd($uri, $type, $ext);
}
public function isEnabled(): bool
{
return $this->enable;
}
public function addPreloadsToHtml(Event $event): void
{
$preloads = $this->getPreloads();
/** @var LinkBuilder $linkBuilder */
$linkBuilder = $event->getTarget();
foreach ($preloads['html'] as $preload) {
$link = $linkBuilder->getPreloadLink($preload);
$linkBuilder->prependChildToHead($link);
}
}
public function getPreloads(): array
{
if (!$this->includesAdded) {
$this->addIncludesToPreload();
$this->includesAdded = \true;
$this->aPreloads = Hooks::onHttp2GetPreloads($this->aPreloads);
}
return $this->aPreloads;
}
public function addIncludesToPreload(): void
{
if (JCH_PRO) {
// @see Http2Excludes::addHttp2Includes()
$this->container->get(Http2Excludes::class)->addHttp2Includes();
}
}
public function addModulePreloadsToHtml(Event $event): void
{
if ($this->enable && JCH_PRO && $this->params->get('pro_http2_preload_modules', '1')) {
/** @var Processor $htmlProcessor */
$htmlProcessor = $this->container->get(Processor::class);
$modules = $htmlProcessor->processModulesForPreload();
/** @var LinkBuilder $linkBuilder */
$linkBuilder = $event->getTarget();
foreach ($modules[4] as $module) {
$link = $linkBuilder->getModulePreloadLink($module);
$linkBuilder->prependChildToHead($link);
}
}
}
private function internalAdd(UriInterface $uri, string $type, string $ext = ''): void
{
$RR_uri = $this->cdn->loadCdnResource(UriNormalizer::normalize($uri));
// If resource not on CDN we can remove scheme and host
if (!$this->cdn->isFileOnCdn($RR_uri) && !UriComparator::isCrossOrigin($RR_uri)) {
$RR_uri = $RR_uri->withScheme('')->withHost('');
}
$preload = ['href' => (string) $RR_uri, 'as' => $type, 'crossorigin' => \false];
if ('font' == $type) {
$preload['crossorigin'] = \true;
$ttfVersion = $preload;
$woffVersion = $preload;
$woff2Version = $preload;
$ttfVersion['href'] = \preg_replace('#(?<=\\.)'.\preg_quote($ext).'#', 'ttf', $preload['href']);
$ttfVersion['type'] = 'font/ttf';
$woffVersion['href'] = \preg_replace('#(?<=\\.)'.\preg_quote($ext).'#', 'woff', $preload['href']);
$woffVersion['type'] = 'font/woff';
$woff2Version['href'] = \preg_replace('#(?<=\\.)'.\preg_quote($ext).'#', 'woff2', $preload['href']);
$woff2Version['type'] = 'font/woff2';
switch ($ext) {
case 'ttf':
foreach ($this->aPreloads as $preloads) {
// If we already have the woff or woff2 version, abort
if (\in_array($woffVersion, $preloads) || \in_array($woff2Version, $preloads)) {
return;
}
}
$preload = $ttfVersion;
break;
case 'woff':
foreach ($this->aPreloads as $preloadKey => $preloads) {
// If we already have the woff2 version of this file, abort
if (\in_array($woff2Version, $preloads)) {
return;
}
// if we already have the ttf version of this file, let's remove
// it and preload the woff version instead
$key = \array_search($ttfVersion, $preloads);
if (\false !== $key) {
unset($this->aPreloads[$preloadKey][$key]);
}
}
$preload = $woffVersion;
break;
case 'woff2':
foreach ($this->aPreloads as $preloadsKey => $preloads) {
// If we already have the woff version of this file,
// let's remove it and preload the woff2 version instead
$woff_key = \array_search($woffVersion, $preloads);
if (\false !== $woff_key) {
unset($this->aPreloads[$preloadsKey][$woff_key]);
}
// If we already have the ttf version of this file,
// let's remove it also
$ttf_key = \array_search($ttfVersion, $preloads);
if (\false !== $ttf_key) {
unset($this->aPreloads[$preloadsKey][$ttf_key]);
}
}
$preload = $woff2Version;
break;
default:
break;
}
}
// We need to decide how we're going to preload this file. If it's loaded by CDN or if we're using Capture cache we need
// to put it in the HTML, otherwise we can send a link header, better IMO.
// Let's make the default method 'link'
$method = 'link';
if ($this->cdn->isFileOnCdn($RR_uri) || UriComparator::isCrossOrigin($RR_uri) || Cache::isPageCacheEnabled($this->params, \true) && JCH_PRO && $this->params->get('pro_capture_cache_enable', '1') && !$this->params->get('pro_cache_platform', '0')) {
$method = 'html';
}
if (!\in_array($preload, $this->aPreloads[$method])) {
$this->aPreloads[$method][] = $preload;
}
}
}

View File

@@ -0,0 +1,31 @@
<?php
/**
* JCH Optimize - Performs several front-end optimizations for fast downloads.
*
* @author Samuel Marshall <samuel@jch-optimize.net>
* @copyright Copyright (c) 2022 Samuel Marshall / JCH Optimize
* @license GNU/GPLv3, or later. See LICENSE file
*
* If LICENSE file missing, see <http://www.gnu.org/licenses/>.
*/
namespace JchOptimize\Core\Interfaces;
use Joomla\Registry\Registry;
\defined('_JCH_EXEC') or exit('Restricted access');
interface Cache
{
public static function cleanThirdPartyPageCache(): void;
public static function prepareDataFromCache(?array $data): ?array;
public static function outputData(array $data): void;
public static function isPageCacheEnabled(Registry $params, bool $nativeCache = \false): bool;
public static function getCacheNamespace(bool $pageCache = \false): string;
public static function isCaptureCacheIncompatible(): bool;
}

View File

@@ -0,0 +1,27 @@
<?php
/**
* JCH Optimize - Performs several front-end optimizations for fast downloads.
*
* @author Samuel Marshall <samuel@jch-optimize.net>
* @copyright Copyright (c) 2022 Samuel Marshall / JCH Optimize
* @license GNU/GPLv3, or later. See LICENSE file
*
* If LICENSE file missing, see <http://www.gnu.org/licenses/>.
*/
namespace JchOptimize\Core\Interfaces;
\defined('_JCH_EXEC') or exit('Restricted access');
interface Excludes
{
public static function extensions(): string;
public static function head(string $type, string $section = 'file'): array;
public static function body(string $type, string $section = 'file'): array;
public static function editors(string $url): bool;
public static function smartCombine();
}

View File

@@ -0,0 +1,48 @@
<?php
/**
* JCH Optimize - Performs several front-end optimizations for fast downloads.
*
* @author Samuel Marshall <samuel@jch-optimize.net>
* @copyright Copyright (c) 2022 Samuel Marshall / JCH Optimize
* @license GNU/GPLv3, or later. See LICENSE file
*
* If LICENSE file missing, see <http://www.gnu.org/licenses/>.
*/
namespace JchOptimize\Core\Interfaces;
\defined('_JCH_EXEC') or exit('Restricted access');
interface Hooks
{
/**
* Set Page Caching enabled or disabled.
*/
public static function onPageCacheSetCaching(): bool;
/**
* Add an item to a given array that will be used in generating the key for page cache.
*
* @param array<array-key, mixed> $parts
*
* @return array<array-key, mixed>
*/
public static function onPageCacheGetKey(array $parts): array;
/**
* Set a cookie when a user posts a form to prevent caching for user.
*/
public static function onUserPostForm(): void;
/**
* Deletes the user_posted_form cookie if the setting is disabled.
*/
public static function onUserPostFormDeleteCookie(): void;
/**
* Allows filtering of the HTTP2 $preloads array.
*
* @param array $preloads Multidimensional array of files for preloads
*/
public static function onHttp2GetPreloads(array $preloads): array;
}

View File

@@ -0,0 +1,30 @@
<?php
/**
* JCH Optimize - Performs several front-end optimizations for fast downloads.
*
* @author Samuel Marshall <samuel@jch-optimize.net>
* @copyright Copyright (c) 2022 Samuel Marshall / JCH Optimize
* @license GNU/GPLv3, or later. See LICENSE file
*
* If LICENSE file missing, see <http://www.gnu.org/licenses/>.
*/
namespace JchOptimize\Core\Interfaces;
\defined('_JCH_EXEC') or exit('Restricted access');
interface Html
{
/**
* Returns HTML of the front page.
*/
public function getHomePageHtml(): string;
/**
* Returns an array of all the html of the page on the main menu.
*
* @param mixed $iLimit
* @param mixed $bIncludeUrls
*/
public function getMainMenuItemsHtmls($iLimit = 5, $bIncludeUrls = \false): array;
}

View File

@@ -0,0 +1,20 @@
<?php
/**
* JCH Optimize - Performs several front-end optimizations for fast downloads.
*
* @author Samuel Marshall <samuel@jch-optimize.net>
* @copyright Copyright (c) 2022 Samuel Marshall / JCH Optimize
* @license GNU/GPLv3, or later. See LICENSE file
*
* If LICENSE file missing, see <http://www.gnu.org/licenses/>.
*/
namespace JchOptimize\Core\Interfaces;
use Psr\Log\LoggerInterface;
\defined('_JCH_EXEC') or exit('Restricted access');
interface MvcLoggerInterface extends LoggerInterface
{
}

View File

@@ -0,0 +1,125 @@
<?php
/**
* JCH Optimize - Performs several front-end optimizations for fast downloads.
*
* @author Samuel Marshall <samuel@jch-optimize.net>
* @copyright Copyright (c) 2022 Samuel Marshall / JCH Optimize
* @license GNU/GPLv3, or later. See LICENSE file
*
* If LICENSE file missing, see <http://www.gnu.org/licenses/>.
*/
namespace JchOptimize\Core\Interfaces;
\defined('_JCH_EXEC') or exit('Restricted access');
/**
* Interface PathsInterface.
*/
interface Paths
{
/**
* Returns url to the media folder (Can be root relative based on platform).
*/
public static function mediaUrl(): string;
/**
* Returns root relative path to the /assets/ folder.
*/
public static function relAssetPath(bool $pathonly = \false): string;
/**
* Path to the directory where generated sprite images are saved.
*
* @param bool $isRootRelative if true, return the root relative path with trailing slash; if false, return the absolute path without trailing slash
*/
public static function spritePath(bool $isRootRelative = \false): string;
/**
* Find the absolute path to a resource given a root relative path.
*
* @param string $url Root relative path of resource on the site
*/
public static function absolutePath(string $url): string;
/**
* The base folder for rewrites when the combined files are delivered with PHP using mod_rewrite. Generally the parent directory for the
* /media/ folder with a root relative path.
*/
public static function rewriteBaseFolder(): string;
/**
* Convert the absolute filepath of a resource to a url.
*
* @param string $path Absolute path of resource
*/
public static function path2Url(string $path): string;
/**
* @return string Absolute path to root of site
*/
public static function rootPath(): string;
/**
* Parent directory of the folder where the original images are backed up in the Optimize Image Feature.
*/
public static function backupImagesParentDir(): string;
/**
* Returns path to the directory where static combined css/js files are saved.
*
* @param bool $isRootRelative If true, returns root relative path, otherwise, the absolute path
*/
public static function cachePath(bool $isRootRelative = \true): string;
/**
* Path to the directory where next generation images are stored in the Optimize Image Feature.
*/
public static function nextGenImagesPath(bool $isRootRelative = \false): string;
/**
* Path to the directory where icons for Icon Buttons are found.
*/
public static function iconsUrl(): string;
/**
* Path to the logs file.
*/
public static function getLogsPath(): string;
/**
* Returns base path of the home page excluding host.
*/
public static function homeBasePath(): string;
/**
* Returns base path of home page including host.
*/
public static function homeBaseFullPath(): string;
/**
* Url used in administrator settings page to perform certain tasks.
*/
public static function adminController(string $name): string;
/**
* The directory where CaptureCache will store HTML files.
*/
public static function captureCacheDir(): string;
/**
* The directory for storing cache.
*/
public static function cacheDir(): string;
/**
* The directory where blade templates are kept.
*/
public static function templatePath(): string;
/**
* The directory where compiled versions of blade templates are stored.
*/
public static function templateCachePath(): string;
}

View File

@@ -0,0 +1,27 @@
<?php
/**
* JCH Optimize - Performs several front-end optimizations for fast downloads.
*
* @author Samuel Marshall <samuel@jch-optimize.net>
* @copyright Copyright (c) 2022 Samuel Marshall / JCH Optimize
* @license GNU/GPLv3, or later. See LICENSE file
*
* If LICENSE file missing, see <http://www.gnu.org/licenses/>.
*/
namespace JchOptimize\Core\Interfaces;
use Joomla\Registry\Registry;
\defined('_JCH_EXEC') or exit('Restricted access');
interface Plugin
{
public static function getPluginId();
public static function getPlugin();
public static function saveSettings(Registry $params);
public static function getPluginParams();
}

View File

@@ -0,0 +1,25 @@
<?php
/**
* JCH Optimize - Performs several front-end optimizations for fast downloads.
*
* @author Samuel Marshall <samuel@jch-optimize.net>
* @copyright Copyright (c) 2022 Samuel Marshall / JCH Optimize
* @license GNU/GPLv3, or later. See LICENSE file
*
* If LICENSE file missing, see <http://www.gnu.org/licenses/>.
*/
namespace JchOptimize\Core\Interfaces;
\defined('_JCH_EXEC') or exit('Restricted access');
interface Profiler
{
public static function mark($text);
public static function attachProfiler(&$html, $isAmpPage = \false);
public static function start($text, $mark = \false);
public static function stop($text, $mark = \false);
}

View File

@@ -0,0 +1,92 @@
<?php
/**
* JCH Optimize - Performs several front-end optimizations for fast downloads.
*
* @author Samuel Marshall <samuel@jch-optimize.net>
* @copyright Copyright (c) 2022 Samuel Marshall / JCH Optimize
* @license GNU/GPLv3, or later. See LICENSE file
*
* If LICENSE file missing, see <http://www.gnu.org/licenses/>.
*/
namespace JchOptimize\Core\Interfaces;
use Joomla\Registry\Registry;
\defined('_JCH_EXEC') or exit('Restricted access');
interface Utility
{
public static function translate(string $text): string;
/**
* Returns true if current user is not logged in.
*/
public static function isGuest(): bool;
public static function sendHeaders(array $headers): void;
/**
* Returns array of response headers that are set or already sent.
*/
public static function getHeaders(): array;
public static function userAgent(string $userAgent): \stdClass;
/**
* Indicates if current client is mobile.
*/
public static function isMobile(): bool;
/**
* Indicates if page cache is enabled. If nativeCache is true then we're specifically checking the
* jchoptimize page cache.
*
* @deprecated Use Cache::isPageCacheEnabled() instead
*/
public static function isPageCacheEnabled(Registry $params, bool $nativeCache = \false): bool;
/**
* Should return one of the following based on the current configuration
* filesystem, memcached, apcu, redis, wincache.
*
* @deprecated Use Cache::getCacheStorage() instead
*/
public static function getCacheStorage(Registry $params): string;
/**
* Should return the attribute used to store content values for popover that the version of Bootstrap
* is using.
*/
public static function bsTooltipContentAttribute(): string;
public static function publishAdminMessages(string $message, string $messageType);
/**
* Determines if the site is currently configured to compress the HTML using gzip.
*/
public static function isSiteGzipEnabled(): bool;
/**
* We may need to do some manipulation of the data retrieved from Page cache depending on the platform.
*
* @return array
*
* @deprecated Use Cache::prepareDataFromCache() instead
*/
public static function prepareDataFromCache(?array $data): ?array;
/**
* Output data from PageCache.
*
* @deprecated Use Cache::outputData() instead
*/
public static function outputData(array $data): void;
/**
* Determines if request is on the admin site.
*/
public static function isAdmin(): bool;
public static function getNonce(string $id): string;
}

View File

@@ -0,0 +1,14 @@
<!--
JCH Optimize - Performs several front-end optimizations for fast downloads
@package jchoptimize/core
@author Samuel Marshall <samuel@jch-optimize.net>
@copyright Copyright (c) 2022 Samuel Marshall / JCH Optimize
@license GNU/GPLv3, or later. See LICENSE file
If LICENSE file missing, see <http://www.gnu.org/licenses/>.
-->
<html>
<body style="background: #fff;"></body>
</html>

View File

@@ -0,0 +1,193 @@
<?php
/**
* JCH Optimize - Performs several front-end optimizations for fast downloads.
*
* @author Samuel Marshall <samuel@jch-optimize.net>
* @copyright Copyright (c) 2022 Samuel Marshall / JCH Optimize
* @license GNU/GPLv3, or later. See LICENSE file
*
* If LICENSE file missing, see <http://www.gnu.org/licenses/>.
*/
namespace JchOptimize\Core\Laminas\Plugins;
use _JchOptimizeVendor\Joomla\DI\ContainerAwareInterface;
use _JchOptimizeVendor\Joomla\DI\ContainerAwareTrait;
use _JchOptimizeVendor\Laminas\Cache\Storage\IterableInterface;
use _JchOptimizeVendor\Laminas\Cache\Storage\Plugin\AbstractPlugin;
use _JchOptimizeVendor\Laminas\Cache\Storage\PostEvent;
use _JchOptimizeVendor\Laminas\Cache\Storage\StorageInterface;
use _JchOptimizeVendor\Laminas\Cache\Storage\TaggableInterface;
use _JchOptimizeVendor\Laminas\EventManager\EventManagerInterface;
use JchOptimize\Core\PageCache\PageCache;
use JchOptimize\Platform\Cache;
use JchOptimize\Platform\Paths;
use JchOptimize\Platform\Profiler;
use Joomla\Filesystem\File;
use Joomla\Registry\Registry;
use Psr\Log\LoggerAwareInterface;
use Psr\Log\LoggerAwareTrait;
\defined('_JCH_EXEC') or exit('Restricted access');
class ClearExpiredByFactor extends AbstractPlugin implements ContainerAwareInterface, LoggerAwareInterface
{
use ContainerAwareTrait;
use LoggerAwareTrait;
public const FLAG = '__CLEAR_EXPIRED_BY_FACTOR_RUNNING__';
public function attach(EventManagerInterface $events, $priority = 1)
{
$callback = [$this, 'clearExpiredByFactor'];
$this->listeners[] = $events->attach('setItem.post', $callback, $priority);
$this->listeners[] = $events->attach('setItems.post', $callback, $priority);
}
/**
* @throws \Exception
*/
public function clearExpiredByFactor(PostEvent $event): void
{
$factor = $this->getOptions()->getClearingFactor();
if ($factor && 1 === \random_int(1, $factor)) {
$this->clearExpired();
}
}
public static function getFlagId(): string
{
return \md5(self::FLAG);
}
private function clearExpired()
{
!\JCH_DEBUG ?: Profiler::start('ClearExpired');
/** @var Registry $params */
$params = $this->container->get('params');
/** @var IterableInterface&StorageInterface&TaggableInterface $taggableCache */
$taggableCache = $this->container->get(TaggableInterface::class);
$cache = $this->container->get(StorageInterface::class);
$pageCache = $this->container->get(PageCache::class);
$pageCacheStorage = $pageCache->getStorage();
$pageCacheStorageOptions = $pageCacheStorage->getOptions();
$ttlPageCache = $pageCacheStorageOptions->getTtl();
// This flag must expire after 3 minutes if not deleted
$pageCacheStorageOptions->setTtl(180);
// If plugin already running in another instance, abort
if ($pageCacheStorage->hasItem(self::getFlagId())) {
$pageCacheStorageOptions->setTtl($ttlPageCache);
return;
}
// else set flag to disable page caching while running to prevent
// errors with race conditions
$pageCacheStorage->setItem(self::getFlagId(), self::FLAG);
// reset TTL
$pageCacheStorageOptions->setTtl($ttlPageCache);
$ttl = $cache->getOptions()->getTtl();
$time = \time();
// Let's build an array of items to delete
/** @var array<string, array{page_cache_id?:string[], items_on_page?:string[]}> $itemsToDelete */
$itemsToDelete = [];
/** @var array<string, array{mtime:int, id:string}> $pageItems */
$pageItems = [];
/** @var string[] $iterator */
$iterator = $taggableCache->getIterator();
foreach ($iterator as $item) {
$tags = $taggableCache->getTags($item);
if (!\is_array($tags) || empty($tags)) {
continue;
}
$metaData = $taggableCache->getMetadata($item);
if (!\is_array($metaData) || empty($metaData)) {
continue;
}
$mtime = (int) $metaData['mtime'];
$threshold = 900;
// Handle items that are not page cache
if (isset($tags[0]) && 'pagecache' != $tags[0]) {
// If item was only used on the page once more than 30 minutes ago it's safe to delete
// in conservative mode
if ('0' == $params->get('delete_expiry_mode', '0') && 1 === \count($tags) && $time > $mtime + $threshold || ('1' == $params->get('delete_expiry_mode', '0') && (1 === \count($tags) && $time > $mtime + $threshold) || $time >= $mtime + $ttl)) {
// Add each tag as index of array and attach cache item
foreach ($tags as $tag) {
$itemsToDelete[$tag]['items_on_page'][] = $item;
}
}
}
// Record each page item for now with their mtime
if (isset($tags[0]) && 'pagecache' == $tags[0]) {
$pageItems[$tags[1]] = ['mtime' => $mtime, 'id' => $item];
}
}
// Collate page cache items
foreach ($pageItems as $url => $pageItem) {
if (isset($itemsToDelete[$url]) || $time >= $pageItem['mtime'] + $ttlPageCache) {
$itemsToDelete[$url]['page_cache_id'][] = $pageItem['id'];
}
}
// Collect items that were on a page that wasn't deleted successfully
$dontDeleteItems = [];
// Delete page cache
foreach ($itemsToDelete as $url => $itemsStack) {
// If page cache exists and wasn't successfully deleted, don't delete items on page
if (isset($itemsStack['page_cache_id'])) {
foreach ($itemsStack['page_cache_id'] as $pageCacheId) {
if (!$pageCache->deleteItemById($pageCacheId) && isset($itemsStack['items_on_page'])) {
$dontDeleteItems = \array_merge($dontDeleteItems, $itemsStack['items_on_page']);
unset($itemsToDelete[$url]);
}
}
}
/** @var string[] $itemsOnPages */
$itemsOnPages = \array_unique(\array_reduce(\array_column($itemsToDelete, 'items_on_page'), 'array_merge', []));
// Delete items on page
foreach ($itemsOnPages as $key => $itemOnPage) {
if (\in_array($itemOnPage, $dontDeleteItems)) {
unset($itemsOnPages[$key]);
continue;
}
$cache->removeItem($itemOnPage);
$deleteTag = !$cache->hasItem($itemOnPage);
// We need to also delete the static css/js file if that option is set
if ('2' == $params->get('htaccess', '2')) {
$files = [Paths::cachePath(\false).'/css/'.$itemOnPage.'.css', Paths::cachePath(\false).'/js/'.$itemOnPage.'.js'];
try {
foreach ($files as $file) {
if (\file_exists($file)) {
File::delete($file);
// If for some reason the file still exists don't delete tags
if (\file_exists($file)) {
$deleteTag = \false;
}
break;
}
}
} catch (\Throwable $e) {
// Don't bother to delete the tags if this didn't work
$deleteTag = \false;
}
}
if ($deleteTag) {
$taggableCache->removeItem($itemOnPage);
}
}
if (!empty($itemsOnPages)) {
// Finally attempt to clean any third party page cache
Cache::cleanThirdPartyPageCache();
}
!\JCH_DEBUG ?: Profiler::stop('ClearExpired', \true);
}
// remove flag
$pageCacheStorage->removeItem(self::getFlagId());
}
}

View File

@@ -0,0 +1,28 @@
<?php
/**
* JCH Optimize - Performs several front-end optimizations for fast downloads.
*
* @author Samuel Marshall <samuel@jch-optimize.net>
* @copyright Copyright (c) 2023 Samuel Marshall / JCH Optimize
* @license GNU/GPLv3, or later. See LICENSE file
*
* If LICENSE file missing, see <http://www.gnu.org/licenses/>.
*/
namespace JchOptimize\Core\Laminas\Plugins;
use JchOptimize\ContainerFactory;
use Psr\Log\LoggerInterface;
class ExceptionHandler
{
public static function logException(\Exception $e): void
{
$container = ContainerFactory::getContainer();
/** @var LoggerInterface $logger */
$logger = $container->get(LoggerInterface::class);
$logger->error((string) $e);
}
}

View File

@@ -0,0 +1,147 @@
<?php
/**
* JCH Optimize - Performs several front-end optimizations for fast downloads.
*
* @author Samuel Marshall <samuel@jch-optimize.net>
* @copyright Copyright (c) 2022 Samuel Marshall / JCH Optimize
* @license GNU/GPLv3, or later. See LICENSE file
*
* If LICENSE file missing, see <http://www.gnu.org/licenses/>.
*/
namespace JchOptimize\Core\Model;
use _JchOptimizeVendor\Laminas\Cache\Exception\ExceptionInterface;
use _JchOptimizeVendor\Laminas\Cache\Storage\Adapter\Filesystem;
use _JchOptimizeVendor\Laminas\Cache\Storage\Adapter\FilesystemOptions;
use _JchOptimizeVendor\Laminas\Cache\Storage\ClearByNamespaceInterface;
use _JchOptimizeVendor\Laminas\Cache\Storage\FlushableInterface;
use _JchOptimizeVendor\Laminas\Cache\Storage\IterableInterface;
use JchOptimize\Core\Helper;
use JchOptimize\Platform\Cache;
use JchOptimize\Platform\Paths;
use Joomla\Filesystem\Folder;
\defined('_JCH_EXEC') or exit('Restricted access');
trait CacheModelTrait
{
protected int $size = 0;
protected int $numFiles = 0;
public function getCacheSize(): array
{
if ($this->cache instanceof IterableInterface) {
$this->getIterableCacheSize($this->cache);
}
if ($this->pageCacheStorage instanceof IterableInterface) {
$this->getIterableCacheSize($this->pageCacheStorage);
}
// Iterate through the static files
if (\file_exists(Paths::cachePath(\false))) {
$directory = new \RecursiveDirectoryIterator(Paths::cachePath(\false), \FilesystemIterator::SKIP_DOTS);
$iterator = new \RecursiveIteratorIterator($directory);
$i = 0;
foreach ($iterator as $file) {
if (\in_array($file->getFilename(), ['index.html', '.htaccess'])) {
++$i;
continue;
}
$this->size += $file->getSize();
}
$this->numFiles += \iterator_count($iterator) - $i;
}
$decimals = 2;
$sz = 'BKMGTP';
$factor = (int) \floor((\strlen((string) $this->size) - 1) / 3);
$size = \sprintf("%.{$decimals}f", $this->size / \pow(1024, $factor)).\str_split($sz)[$factor];
$numFiles = \number_format($this->numFiles);
return [$size, $numFiles];
}
/**
* Cleans cache from the server.
*/
public function cleanCache(): bool
{
$success = 1;
// First try to delete the Http request cache
// Delete any static combined files
$staticCachePath = Paths::cachePath(\false);
try {
if (\file_exists($staticCachePath)) {
Folder::delete($staticCachePath);
}
} catch (\Exception $e) {
try {
// Didn't work, Joomla can't handle paths containing backslash, let's try another way
Helper::deleteFolder($staticCachePath);
} catch (\Exception $e) {
}
}
$success &= (int) (!\file_exists($staticCachePath));
try {
// Clean all cache generated by Storage
if ($this->cache instanceof ClearByNamespaceInterface) {
$success &= (int) $this->cache->clearByNamespace(Cache::getCacheNamespace());
} elseif ($this->cache instanceof FlushableInterface) {
$success &= (int) $this->cache->flush();
}
// And page cache
if ($this->pageCacheStorage instanceof ClearByNamespaceInterface) {
$success &= (int) $this->pageCacheStorage->clearByNamespace(Cache::getCacheNamespace(\true));
} elseif ($this->cache instanceof FlushableInterface) {
$success &= (int) $this->pageCache->deleteAllItems();
}
} catch (\Exception $e) {
$success = \false;
}
// If all goes well, also delete tags
if ($success) {
if ($this->taggableCache instanceof ClearByNamespaceInterface) {
$this->taggableCache->clearByNamespace('jchoptimizetags');
} elseif ($this->taggableCache instanceof FlushableInterface) {
$this->taggableCache->flush();
}
}
// Clean third party cache
Cache::cleanThirdPartyPageCache();
return (bool) $success;
}
private function getIterableCacheSize($cache): void
{
try {
$iterator = $cache->getIterator();
$this->numFiles += \iterator_count($iterator);
foreach ($iterator as $item) {
// Let's skip the 'test' cache set on instantiation in container
if ($item == \md5('__ITEM__')) {
--$this->numFiles;
continue;
}
$metaData = $cache->getMetadata($item);
if (!\is_array($metaData)) {
continue;
}
if (isset($metaData['size'])) {
$this->size += $metaData['size'];
} elseif ($cache instanceof Filesystem) {
/** @var FilesystemOptions $cacheOptions */
$cacheOptions = $cache->getOptions();
$suffix = $cacheOptions->getSuffix();
if (isset($metaData['filespec']) && \file_exists($metaData['filespec'].'.'.$suffix)) {
$this->size += \filesize($metaData['filespec'].'.'.$suffix);
}
}
}
} catch (ExceptionInterface|\Exception $e) {
}
}
}

View File

@@ -0,0 +1,194 @@
<?php
/**
* JCH Optimize - Performs several front-end optimizations for fast downloads.
*
* @author Samuel Marshall <samuel@jch-optimize.net>
* @copyright Copyright (c) 2022 Samuel Marshall / JCH Optimize
* @license GNU/GPLv3, or later. See LICENSE file
*
* If LICENSE file missing, see <http://www.gnu.org/licenses/>.
*/
namespace JchOptimize\Core;
// No direct access
use _JchOptimizeVendor\Joomla\DI\ContainerAwareInterface;
use _JchOptimizeVendor\Joomla\DI\ContainerAwareTrait;
use CodeAlfa\Minify\Html;
use JchOptimize\Core\FeatureHelpers\ReduceDom;
use JchOptimize\Core\Html\CacheManager;
use JchOptimize\Core\Html\LinkBuilder;
use JchOptimize\Core\Html\Processor as HtmlProcessor;
use JchOptimize\Platform\Profiler;
use JchOptimize\Platform\Utility;
use Joomla\Registry\Registry;
use Psr\Log\LoggerAwareInterface;
use Psr\Log\LoggerAwareTrait;
\defined('_JCH_EXEC') or exit('Restricted access');
/**
* Main plugin file.
*/
class Optimize implements LoggerAwareInterface, ContainerAwareInterface
{
use LoggerAwareTrait;
use ContainerAwareTrait;
private Registry $params;
private HtmlProcessor $htmlProcessor;
private CacheManager $cacheManager;
private LinkBuilder $linkBuilder;
private string $html;
private string $jit = '1';
/**
* @var Http2Preload
*
* @since version
*/
private \JchOptimize\Core\Http2Preload $http2Preload;
/**
* Constructor.
*
* @throws Exception\RuntimeException
*/
public function __construct(Registry $params, HtmlProcessor $htmlProcessor, CacheManager $cacheManager, LinkBuilder $linkBuilder, Http2Preload $http2Preload)
{
\ini_set('pcre.backtrack_limit', '1000000');
\ini_set('pcre.recursion_limit', '1000000');
if (\version_compare(\PHP_VERSION, '7.0.0', '>=')) {
$this->jit = \ini_get('pcre.jit');
\ini_set('pcre.jit', '0');
}
if (\version_compare(\PHP_VERSION, '7.3', '<')) {
throw new Exception\RuntimeException('PHP Version less than 7.3, Exiting plugin...');
}
$pcre_version = \preg_replace('#(^\\d++\\.\\d++).++$#', '$1', \PCRE_VERSION);
if (\version_compare($pcre_version, '7.2', '<')) {
throw new Exception\RuntimeException('PCRE Version less than 7.2. Exiting plugin...');
}
$this->params = $params;
$this->htmlProcessor = $htmlProcessor;
$this->cacheManager = $cacheManager;
$this->linkBuilder = $linkBuilder;
$this->http2Preload = $http2Preload;
}
/**
* Optimize website by aggregating css and js.
*/
public function process(): string
{
JCH_DEBUG ? Profiler::start('Process', \true) : null;
try {
if (!$this->html) {
$this->logger->error('No HTML received.');
return $this->html;
}
$this->htmlProcessor->setHtml($this->html);
$this->linkBuilder->preProcessHtml();
$this->htmlProcessor->processCombineJsCss();
$this->htmlProcessor->processImageAttributes();
$this->cacheManager->handleCombineJsCss();
$this->cacheManager->handleImgAttributes();
$this->htmlProcessor->processCdn();
$this->htmlProcessor->processLazyLoad();
$this->linkBuilder->postProcessHtml();
$optimizedHtml = $this->reduceDom($this->minifyHtml($this->htmlProcessor->getHtml()));
$this->sendHeaders();
JCH_DEBUG ? Profiler::stop('Process', \true) : null;
JCH_DEBUG ? Profiler::attachProfiler($optimizedHtml, $this->htmlProcessor->isAmpPage) : null;
} catch (Exception\ExceptionInterface $e) {
$this->logger->error((string) $e);
$optimizedHtml = $this->html;
}
if (\version_compare(\PHP_VERSION, '7.0.0', '>=')) {
\ini_set('pcre.jit', (string) $this->jit);
}
return $optimizedHtml;
}
public function setHtml($html): void
{
$this->html = $html;
}
/**
* If parameter is set will minify HTML before sending to browser;
* Inline CSS and JS will also be minified if respective parameters are set.
*
* @return string Optimized HTML
*/
public function minifyHtml(string $html): string
{
JCH_DEBUG ? Profiler::start('MinifyHtml') : null;
if ($this->params->get('combine_files_enable', '1') && $this->params->get('html_minify', 0)) {
$aOptions = [];
if ($this->params->get('css_minify', 0)) {
$aOptions['cssMinifier'] = ['CodeAlfa\\Minify\\Css', 'optimize'];
}
if ($this->params->get('js_minify', 0)) {
$aOptions['jsMinifier'] = ['CodeAlfa\\Minify\\Js', 'optimize'];
}
$aOptions['jsonMinifier'] = ['CodeAlfa\\Minify\\Json', 'optimize'];
$aOptions['minifyLevel'] = $this->params->get('html_minify_level', 0);
$aOptions['isXhtml'] = \JchOptimize\Core\Helper::isXhtml($html);
$aOptions['isHtml5'] = \JchOptimize\Core\Helper::isHtml5($html);
$htmlMin = Html::optimize($html, $aOptions);
if ('' == $htmlMin) {
$this->logger->error('Error while minifying HTML');
$htmlMin = $html;
}
$html = $htmlMin;
JCH_DEBUG ? Profiler::stop('MinifyHtml', \true) : null;
}
return $html;
}
protected function reduceDom(string $html)
{
if (JCH_PRO) {
/** @see ReduceDom::process() */
$html = $this->container->get(ReduceDom::class)->process($html);
}
return $html;
}
protected function sendHeaders(): void
{
$headers = [];
if ($this->http2Preload->isEnabled()) {
$preloads = $this->http2Preload->getPreloads();
$preloadHeaders = [];
foreach ($preloads['link'] as $preload) {
$preloadHeader = "<{$preload['href']}>; rel=preload; as={$preload['as']}";
if ($preload['crossorigin']) {
$preloadHeader .= '; crossorigin';
}
if (!empty($preload['type'])) {
$preloadHeader .= '; type="'.$preload['type'].'"';
}
$preloadHeaders[] = $preloadHeader;
}
if (!empty($preloadHeaders)) {
$headers['Link'] = \implode(',', $preloadHeaders);
}
}
if (!empty($headers)) {
Utility::sendHeaders($headers);
}
}
}

View File

@@ -0,0 +1,155 @@
<?php
/**
* JCH Optimize - Performs several front-end optimizations for fast downloads.
*
* @author Samuel Marshall <samuel@jch-optimize.net>
* @copyright Copyright (c) 2022 Samuel Marshall / JCH Optimize
* @license GNU/GPLv3, or later. See LICENSE file
*
* If LICENSE file missing, see <http://www.gnu.org/licenses/>.
*/
namespace JchOptimize\Core;
use _JchOptimizeVendor\Laminas\Cache\Exception\ExceptionInterface;
use _JchOptimizeVendor\Laminas\Cache\Storage\StorageInterface;
use JchOptimize\ContainerFactory;
use Joomla\Input\Input;
use Joomla\Registry\Registry;
\defined('_JCH_EXEC') or exit('Restricted access');
class Output
{
/**
* @param bool $bSend True to output to browser otherwise return result
*
* @return bool|string|string[]|void
*/
public static function getCombinedFile(array $vars = [], bool $bSend = \true)
{
$container = ContainerFactory::getContainer();
/** @var StorageInterface $cache */
$cache = $container->get(StorageInterface::class);
/** @var Registry $params */
$params = $container->get('params');
$input = new Input();
if (empty($vars)) {
$vars = ['f' => $input->getBase64('f'), 'type' => $input->getWord('type'), 'gz' => $input->getWord('gz', 'nz')];
}
try {
// Temporarily set lifetime to 0 and fetch cache
$lifetime = $cache->getOptions()->getTtl();
$cache->getOptions()->setTtl(0);
$results = $cache->getItem($vars['f']);
$cache->getOptions()->setTtl($lifetime);
} catch (ExceptionInterface $e) {
$results = null;
}
if (\is_null($results)) {
if ($bSend) {
\header('HTTP/1.0 404 Not Found');
echo 'File not found';
}
return \false;
}
if ($bSend) {
$aTimeMFile = self::RFC1123DateAdd($results[0]['filemtime'], '1 year');
$timeMfile = $aTimeMFile['filemtime'].' GMT';
$expiryDate = $aTimeMFile['expiry'].' GMT';
$modifiedSinceTime = '';
$noneMatch = '';
if (\function_exists('apache_request_headers')) {
$headers = \apache_request_headers();
if (isset($headers['If-Modified-Since'])) {
$modifiedSinceTime = \strtotime($headers['If-Modified-Since']);
}
if (isset($headers['If-None-Match'])) {
$noneMatch = $headers['If-None-Match'];
}
}
if ('' == $modifiedSinceTime && !\is_null($input->server->getString('HTTP_IF_MODIFIED_SINCE'))) {
$modifiedSinceTime = \strtotime($input->server->getString('HTTP_IF_MODIFIED_SINCE'));
}
if ('' == $noneMatch && !\is_null($input->server->getString('HTTP_IF_NONE_MATCH'))) {
$noneMatch = $input->server->getString('HTTP_IF_NONE_MATCH');
}
$etag = $results[0]['etag'];
if ($modifiedSinceTime == \strtotime($timeMfile) || \trim($noneMatch) == $etag) {
// Client's cache IS current, so we just respond '304 Not Modified'.
\header('HTTP/1.1 304 Not Modified');
\header('Content-Length: 0');
return;
}
\header('Last-Modified: '.$timeMfile);
}
$file = $results[0]['contents'];
// Return file if we're not outputting to browser
if (!$bSend) {
return $file;
}
if ('css' == $vars['type']) {
\header('Content-type: text/css');
} elseif ('js' == $vars['type']) {
\header('Content-type: text/javascript');
}
\header('Expires: '.$expiryDate);
\header('Accept-Ranges: bytes');
\header('Cache-Control: Public');
\header('Vary: Accept-Encoding');
\header('Etag: '.$etag);
$gzip = \true;
if (!\is_null($input->server->getString('HTTP_USER_AGENT'))) {
/* Facebook User Agent
* facebookexternalhit/1.1 (+http://www.facebook.com/externalhit_uatext.php)
* LinkedIn User Agent
* LinkedInBot/1.0 (compatible; Mozilla/5.0; Jakarta Commons-HttpClient/3.1 +http://www.linkedin.com)
*/
$pattern = \strtolower('/facebookexternalhit|LinkedInBot/x');
if (\preg_match($pattern, \strtolower($input->server->getString('HTTP_USER_AGENT')))) {
$gzip = \false;
}
}
if (isset($vars['gz']) && 'gz' == $vars['gz'] && $gzip) {
$supported = ['x-gzip' => 'gz', 'gzip' => 'gz', 'deflate' => 'deflate'];
if (!\is_null($input->server->getString('HTTP_ACCEPT_ENCODING'))) {
$aAccepted = \array_map('trim', (array) \explode(',', $input->server->getString('HTTP_ACCEPT_ENCODING')));
$encodings = \array_intersect($aAccepted, \array_keys($supported));
} else {
$encodings = ['gzip'];
}
if (!empty($encodings)) {
foreach ($encodings as $encoding) {
if ('gz' == $supported[$encoding] || 'deflate' == $supported[$encoding]) {
$compressedFile = \gzencode($file, 4, 'gz' == $supported[$encoding] ? \FORCE_GZIP : \FORCE_DEFLATE);
if (\false === $compressedFile) {
continue;
}
\header('Content-Encoding: '.$encoding);
$file = $compressedFile;
break;
}
}
}
}
echo $file;
}
public static function RFC1123DateAdd(int $fileMTime, string $period): array
{
$times = [];
$date = new \DateTime();
$date->setTimestamp($fileMTime);
$times['filemtime'] = $date->format('D, d M Y H:i:s');
$date->add(\DateInterval::createFromDateString($period));
$times['expiry'] = $date->format('D, d M Y H:i:s');
return $times;
}
}

View File

@@ -0,0 +1,444 @@
<?php
/**
* JCH Optimize - Performs several front-end optimizations for fast downloads.
*
* @author Samuel Marshall <samuel@jch-optimize.net>
* @copyright Copyright (c) 2022 Samuel Marshall / JCH Optimize
* @license GNU/GPLv3, or later. See LICENSE file
*
* If LICENSE file missing, see <http://www.gnu.org/licenses/>.
*/
namespace JchOptimize\Core\PageCache;
use _JchOptimizeVendor\GuzzleHttp\Psr7\Uri;
use _JchOptimizeVendor\Joomla\DI\ContainerAwareInterface;
use _JchOptimizeVendor\Joomla\DI\ContainerAwareTrait;
use _JchOptimizeVendor\Laminas\Cache\Exception\ExceptionInterface;
use _JchOptimizeVendor\Laminas\Cache\Storage\IterableInterface;
use _JchOptimizeVendor\Laminas\Cache\Storage\StorageInterface;
use _JchOptimizeVendor\Laminas\Cache\Storage\TaggableInterface;
use _JchOptimizeVendor\Psr\Http\Message\UriInterface;
use JchOptimize\Core\Helper;
use JchOptimize\Core\Laminas\Plugins\ClearExpiredByFactor;
use JchOptimize\Core\SystemUri;
use JchOptimize\Core\Uri\UriNormalizer;
use JchOptimize\Platform\Cache;
use JchOptimize\Platform\Hooks;
use JchOptimize\Platform\Utility;
use Joomla\Input\Input;
use Joomla\Registry\Registry;
use Psr\Log\LoggerAwareInterface;
use Psr\Log\LoggerAwareTrait;
use function time;
\defined('_JCH_EXEC') or exit('Restricted access');
class PageCache implements ContainerAwareInterface, LoggerAwareInterface
{
use ContainerAwareTrait;
use LoggerAwareTrait;
protected Registry $params;
protected StorageInterface $pageCacheStorage;
/**
* Cache id.
*/
protected string $cacheId;
/**
* Files system cache adapter used to store tags when another adapter is being used that isn't taggable and iterable.
*
* @var IterableInterface&StorageInterface&TaggableInterface
*/
protected $taggableCache;
/**
* Name of currently used cache adapter.
*/
protected string $adapter;
/**
* Indicates whether CaptureCache is used to store cache.
*/
protected bool $captureCacheEnabled = \false;
protected array $filters = [];
protected array $lists = ['list_fullordering' => 'mtime ASC'];
protected bool $enabled = \true;
protected bool $isCachingSet = \false;
protected Input $input;
/**
* Constructor.
*
* @param IterableInterface&StorageInterface&TaggableInterface $taggableCache
*/
public function __construct(Registry $params, Input $input, StorageInterface $pageCacheStorage, $taggableCache)
{
$this->params = $params;
$this->input = $input;
$this->pageCacheStorage = $pageCacheStorage;
$this->taggableCache = $taggableCache;
$reflection = new \ReflectionClass($this->pageCacheStorage);
$this->adapter = $reflection->getShortName();
}
public function setFilter(string $key, string $filter): void
{
$this->filters[$key] = $filter;
}
public function setList(string $key, string $list): void
{
$this->lists[$key] = $list;
}
/**
* @return list<array{id:string, url:string, device:string, adapter:string, http-request:string, mtime:int}>
*
* @throws ExceptionInterface
*/
public function getItems(): array
{
$items = [];
/** @var string[] $iterator */
$iterator = $this->taggableCache->getIterator();
foreach ($iterator as $cacheItem) {
$tags = $this->taggableCache->getTags($cacheItem);
/** @var array{mtime:int} $metaData */
$metaData = $this->taggableCache->getMetadata($cacheItem);
if (empty($tags)) {
continue;
}
if ('pagecache' != $tags[0]) {
continue;
}
if ($tags[4] != Cache::getCacheNamespace(\true)) {
continue;
}
$url = $tags[1];
$mtime = $metaData['mtime'];
// Filter bu Time 1
if (!empty($this->filters['filter_time-1'])) {
if (\time() < $mtime + (int) $this->filters['filter_time-1']) {
continue;
}
}
// Filter by Time 2
if (!empty($this->filters['filter_time-2'])) {
if (\time() >= $mtime + (int) $this->filters['filter_time-2']) {
continue;
}
}
// Filter by URL
if (!empty($this->filters['filter_search'])) {
if (\false === \strpos($url, $this->filters['filter_search'])) {
continue;
}
}
// Filter by device
if (!empty($this->filters['filter_device'])) {
if ($tags[2] != $this->filters['filter_device']) {
continue;
}
}
// Filter by adapter
if (!empty($this->filters['filter_adapter'])) {
if ($tags[3] != $this->filters['filter_adapter']) {
continue;
}
}
$item = [];
$item['id'] = $cacheItem;
$item['url'] = $tags[1];
$item['device'] = $tags[2];
$item['adapter'] = $tags[3];
$item['http-request'] = 'no';
$item['mtime'] = $metaData['mtime'];
$items[] = $item;
}
$this->sortItems($items, $this->lists['list_fullordering']);
if (!empty($this->lists['list_limit'])) {
$items = \array_slice($items, 0, (int) $this->lists['list_limit']);
}
return $items;
}
public function store(string $html): string
{
if ($this->getCachingEnabled()) {
$html = $this->tagHtml($html);
$data = ['body' => $html, 'headers' => Utility::getHeaders()];
// Save an empty page using the same id then tag it
$this->taggableCache->setItem($this->cacheId, '<html lang><head><title></title></head><body></body></html>');
$this->taggableCache->setTags($this->cacheId, $this->getPageCacheTags());
// If tag successfully saved then save page cache
if (!empty($this->taggableCache->getTags($this->cacheId))) {
$this->pageCacheStorage->setItem($this->cacheId, $data);
}
} else {
// Ensure Capture cache doesn't cache either
$this->captureCacheEnabled = \false;
}
return $html;
}
/**
* Returns the caching status if enabled or disabled.` If caching wasn't explicitly set it will be set on
* first call to this function.
*
* @throws ExceptionInterface
*/
public function getCachingEnabled(): bool
{
if (!$this->isCachingSet) {
$this->setCaching();
}
// Disable page caching anytime clear expired plugin is running.
return $this->enabled && !$this->pageCacheStorage->hasItem(ClearExpiredByFactor::getFlagId());
}
public function setCaching(): void
{
// just return false with this filter if you don't want the page to be cached
if (!Hooks::onPageCacheSetCaching()) {
$this->disableCaching();
return;
}
if ('POST' == $this->input->server->get('REQUEST_METHOD') || 'user_posted_form' == $this->input->cookie->get('jch_optimize_no_cache_user_activity')) {
$this->disableCaching();
return;
}
$this->enabled = $this->params->get('page_cache_select', 'jchoptimizepagecache') && Cache::isPageCacheEnabled($this->params) && Utility::isGuest() && !self::isExcluded($this->params) && 'GET' === $this->input->server->get('REQUEST_METHOD');
$this->isCachingSet = \true;
}
public function disableCaching(): void
{
$this->enabled = \false;
$this->isCachingSet = \true;
}
public function getCurrentPage(): UriInterface
{
$pageUri = SystemUri::currentUri();
/** @var string[] $ignoredQueries */
$ignoredQueries = $this->params->get('page_cache_ignore_query_values', []);
foreach ($ignoredQueries as $queryValue) {
$pageUri = Uri::withoutQueryValue($pageUri, $queryValue);
}
return $pageUri;
}
public function tagHtml(string $html)
{
if (JCH_DEBUG) {
$now = \date('l, F d, Y h:i:s A');
$tag = "\n".'<!-- Cached by JCH Optimize on '.$now.' GMT -->'."\n".'</body>';
$html = \str_replace('</body>', $tag, $html);
}
return $html;
}
/**
* @throws \Exception
*/
public function deleteCurrentPage(): void
{
$this->deleteItemsByUrls([$this->getCurrentPage()]);
}
/**
* @throws \Exception
*/
public function deleteItemsByUrls(array $urls): void
{
foreach ($this->taggableCache->getIterator() as $item) {
$tags = $this->taggableCache->getTags($item);
if (isset($tags[0]) && 'pagecache' == $tags[0] && \in_array($tags[1], $urls)) {
$this->deleteItemById($item);
}
}
}
public function deleteItemsByIds(array $ids): bool
{
$result = 1;
foreach ($ids as $id) {
$result &= (int) $this->deleteItemById($id);
}
return (bool) $result;
}
public function deleteItemById(string $id): bool
{
$result = 1;
$tags = $this->taggableCache->getTags($id);
if (!empty($tags) && $tags[3] != $this->adapter) {
$this->container->get($tags[3])->removeItem($id);
$result &= (int) (!$this->container->get($tags[3])->hasItem($id));
} else {
$this->pageCacheStorage->removeItem($id);
$result &= (int) (!$this->pageCacheStorage->hasItem($id));
}
// Only delete tag if successful
if ($result) {
$this->taggableCache->removeItem($id);
}
return (bool) $result;
}
public function removeHtmlTag($html): ?string
{
$search = '#<!-- Cached by JCH Optimize on .*? GMT -->\\n#';
return \preg_replace($search, '', $html);
}
/**
* @throws ExceptionInterface
*/
public function initialize(): void
{
$this->setCaching();
$this->cacheId = $this->getPageCacheId();
if ('POST' == $this->input->server->get('REQUEST_METHOD')) {
if ($this->params->get('page_cache_exclude_form_users', '1')) {
Hooks::onUserPostForm();
if ('user_posted_form' == !$this->input->cookie->get('jch_optimize_no_cache_user_activity')) {
$options = ['httponly' => \true, 'expires' => \time() + (int) $this->params->get('page_cache_lifetime', '900')];
$this->input->cookie->set('jch_optimize_no_cache_user_activity', 'user_posted_form', $options);
}
}
return;
}
if (!$this->params->get('page_cache_exclude_form_users', '0') && 'user_posted_form' == $this->input->cookie->get('jch_optimize_no_cache_user_activity')) {
Hooks::onUserPostFormDeleteCookie();
$this->input->cookie->set('jch_optimize_no_cache_user_activity', '', ['expires' => 1]);
}
if (!$this->enabled) {
return;
}
/** @var null|array $data */
$data = $this->pageCacheStorage->getItem($this->cacheId);
$data = Cache::prepareDataFromCache($data);
if (!\is_null($data) && 'user_posted_form' != $this->input->cookie->get('jch_optimize_no_cache_user_activity')) {
if (!empty($data['body'])) {
$this->setCaptureCache($data['body']);
}
while (@\ob_end_clean());
Cache::outputData($data);
}
}
public function getAdapterName(): string
{
return $this->adapter;
}
public function deleteAllItems(): bool
{
$return = 1;
/** @var string[] $iterator */
$iterator = $this->taggableCache->getIterator();
foreach ($iterator as $item) {
$tags = $this->taggableCache->getTags($item);
if (!empty($tags) && 'pagecache' == $tags[0] && $tags[4] == Cache::getCacheNamespace(\true)) {
$return &= (int) $this->deleteItemById($item);
}
}
return (bool) $return;
}
public function isCaptureCacheEnabled(): bool
{
return $this->captureCacheEnabled;
}
public function disableCaptureCache(): void
{
$this->captureCacheEnabled = \false;
}
public function getStorage(): StorageInterface
{
return $this->pageCacheStorage;
}
/**
* @param list<array{id:string, url:string, device:string, adapter:string, http-request:string, mtime:int}> $items
*/
protected function sortItems(array &$items, string $fullOrdering): void
{
[$orderBy, $dir] = \explode(' ', $fullOrdering);
\usort($items, function ($a, $b) use ($orderBy, $dir) {
if ('ASC' == $dir) {
return $a[$orderBy] <=> $b[$orderBy];
}
return $b[$orderBy] <=> $a[$orderBy];
});
}
protected function isExcluded(Registry $params): bool
{
$cache_exclude = $params->get('cache_exclude', []);
if (Helper::findExcludes($cache_exclude, (string) $this->getCurrentPage())) {
return \true;
}
return \false;
}
protected function getPageCacheTags(): array
{
$device = Utility::isMobile() ? 'Mobile' : 'Desktop';
return ['pagecache', $this->getCurrentPage(), $device, $this->adapter, Cache::getCacheNamespace(\true)];
}
protected function getPageCacheId(): string
{
// Add a value to the array that will be used to determine the page cache id
$parts = Hooks::onPageCacheGetKey([]);
$parts[] = $this->adapter;
$parts[] = (string) UriNormalizer::pageCacheIdNormalize($this->getCurrentPage());
$parts[] = \serialize($this->params);
if (JCH_PRO === '1' && $this->params->get('pro_cache_platform', '0') && Utility::isMobile()) {
$parts[] = '__MOBILE__';
}
return \md5(\serialize($parts));
}
/**
* To be overwritten by the CaptureCache class.
*/
protected function setCaptureCache(string $html)
{
}
}

View File

@@ -0,0 +1,37 @@
<?php
/**
* @copyright A copyright
* @license A "Slug" license name e.g. GPL2
*/
namespace JchOptimize\Core;
\defined('_JCH_EXEC') or exit('Restricted access');
trait SerializableTrait
{
public function __serialize()
{
return $this->serializedArray();
}
public function __unserialize($data)
{
$this->params = $data['params'];
}
public function serialize()
{
return \json_encode($this->serializedArray());
}
public function unserialize($data)
{
$this->params = \json_decode($data, \true)['params'];
}
private function serializedArray(): array
{
return ['params' => $this->params->jsonSerialize(), 'version' => JCH_VERSION, 'scheme' => \JchOptimize\Core\SystemUri::currentUri()->getScheme(), 'authority' => \JchOptimize\Core\SystemUri::currentUri()->getAuthority()];
}
}

View File

@@ -0,0 +1,52 @@
<?php
/**
* JCH Optimize - Performs several front-end optimizations for fast downloads.
*
* @author Samuel Marshall <samuel@jch-optimize.net>
* @copyright Copyright (c) 2023 Samuel Marshall / JCH Optimize
* @license GNU/GPLv3, or later. See LICENSE file
*
* If LICENSE file missing, see <http://www.gnu.org/licenses/>.
*/
namespace JchOptimize\Core\Service;
use _JchOptimizeVendor\Joomla\DI\Container;
use _JchOptimizeVendor\Joomla\DI\ServiceProviderInterface;
use _JchOptimizeVendor\Laminas\Cache\Storage\Adapter\Apcu;
use _JchOptimizeVendor\Laminas\Cache\Storage\Adapter\BlackHole;
use _JchOptimizeVendor\Laminas\Cache\Storage\Adapter\Filesystem;
use _JchOptimizeVendor\Laminas\Cache\Storage\Adapter\Memcached;
use _JchOptimizeVendor\Laminas\Cache\Storage\Adapter\Redis;
use _JchOptimizeVendor\Laminas\Cache\Storage\Adapter\WinCache;
use _JchOptimizeVendor\Laminas\ServiceManager\Factory\InvokableFactory;
use JchOptimize\Core\Laminas\Plugins\ExceptionHandler;
use JchOptimize\Platform\Paths;
\defined('_JCH_EXEC') or exit('Restricted access');
class CachingConfigurationProvider implements ServiceProviderInterface
{
public function register(Container $container)
{
$container->share('config', function ($container) {
$params = $container->get('params');
$dirPermission = \octdec(\substr(\sprintf('%o', \fileperms(__DIR__)), -4)) ?: 0755;
$filePermission = \octdec(\substr(\sprintf('%o', \fileperms(__FILE__)), -4)) ?: 0644;
// Ensure owner has permissions to execute, read, and write directory
$dirPermission = $dirPermission | 0700;
// Ensure owner has permissions to read and write files
$filePermission = $filePermission | 0600;
// Ensure files are not executable
$filePermission = $filePermission & ~0111;
$redisServerHost = (string) $params->get('redis_server_host', '127.0.0.1');
if ('.sock' == \substr(\trim($redisServerHost), -5)) {
$redisServer = $redisServerHost;
} else {
$redisServer = ['host' => $redisServerHost, 'port' => (int) $params->get('redis_server_port', 6379)];
}
return ['caches' => ['filesystem' => ['name' => 'filesystem', 'options' => ['cache_dir' => Paths::cacheDir(), 'dir_level' => 2, 'dir_permission' => $dirPermission, 'file_permission' => $filePermission], 'plugins' => [['name' => 'serializer'], ['name' => 'optimizebyfactor', 'options' => ['optimizing_factor' => 50]], ['name' => 'exception_handler', 'options' => ['exception_callback' => [ExceptionHandler::class, 'logException'], 'throw_exceptions' => \false]]]], 'memcached' => ['name' => 'memcached', 'options' => ['servers' => [[(string) $params->get('memcached_server_host', '127.0.0.1'), (int) $params->get('memcached_server_port', 11211)]]], 'plugins' => [['name' => 'exception_handler', 'options' => ['exception_callback' => [ExceptionHandler::class, 'logException'], 'throw_exceptions' => \false]]]], 'apcu' => ['name' => 'apcu', 'options' => [], 'plugins' => [['name' => 'exception_handler', 'options' => ['exception_callback' => [ExceptionHandler::class, 'logException'], 'throw_exceptions' => \false]]]], 'wincache' => ['name' => 'wincache', 'options' => [], 'plugins' => [['name' => 'exception_handler', 'options' => ['exception_callback' => [ExceptionHandler::class, 'logException'], 'throw_exceptions' => \false]]]], 'redis' => ['name' => 'redis', 'options' => ['server' => $redisServer, 'password' => (string) $params->get('redis_server_password', ''), 'database' => (int) $params->get('redis_server_db', 0)], 'plugins' => [['name' => 'serializer'], ['name' => 'exception_handler', 'options' => ['exception_callback' => [ExceptionHandler::class, 'logException'], 'throw_exceptions' => \false]]]], 'blackhole' => ['name' => 'blackhole', 'options' => [], 'plugins' => [['name' => 'exception_handler', 'options' => ['exception_callback' => [ExceptionHandler::class, 'logException'], 'throw_exceptions' => \false]]]]], 'dependencies' => ['factories' => [Filesystem::class => InvokableFactory::class, Memcached::class => InvokableFactory::class, Apcu::class => InvokableFactory::class, Redis::class => InvokableFactory::class, WinCache::class => InvokableFactory::class, BlackHole::class => InvokableFactory::class], 'aliases' => ['filesystem' => Filesystem::class, 'memcached' => Memcached::class, 'apcu' => Apcu::class, 'redis' => Redis::class, 'wincache' => WinCache::class, 'blackhole' => BlackHole::class]]];
}, \true);
}
}

View File

@@ -0,0 +1,259 @@
<?php
/**
* JCH Optimize - Performs several front-end optimizations for fast downloads.
*
* @author Samuel Marshall <samuel@jch-optimize.net>
* @copyright Copyright (c) 2022 Samuel Marshall / JCH Optimize
* @license GNU/GPLv3, or later. See LICENSE file
*
* If LICENSE file missing, see <http://www.gnu.org/licenses/>.
*/
namespace JchOptimize\Core\Service;
use _JchOptimizeVendor\Joomla\DI\Container;
use _JchOptimizeVendor\Joomla\DI\ServiceProviderInterface;
use _JchOptimizeVendor\Laminas\Cache\Pattern\CallbackCache;
use _JchOptimizeVendor\Laminas\Cache\Pattern\CaptureCache;
use _JchOptimizeVendor\Laminas\Cache\Pattern\PatternOptions;
use _JchOptimizeVendor\Laminas\Cache\Service\StorageAdapterFactory;
use _JchOptimizeVendor\Laminas\Cache\Service\StorageAdapterFactoryInterface;
use _JchOptimizeVendor\Laminas\Cache\Service\StorageCacheAbstractServiceFactory;
use _JchOptimizeVendor\Laminas\Cache\Service\StoragePluginFactory;
use _JchOptimizeVendor\Laminas\Cache\Service\StoragePluginFactoryInterface;
use _JchOptimizeVendor\Laminas\Cache\Storage\Adapter\Apcu;
use _JchOptimizeVendor\Laminas\Cache\Storage\Adapter\Filesystem;
use _JchOptimizeVendor\Laminas\Cache\Storage\Adapter\Memcached;
use _JchOptimizeVendor\Laminas\Cache\Storage\Adapter\Redis;
use _JchOptimizeVendor\Laminas\Cache\Storage\Adapter\WinCache;
use _JchOptimizeVendor\Laminas\Cache\Storage\AdapterPluginManager;
use _JchOptimizeVendor\Laminas\Cache\Storage\IterableInterface;
use _JchOptimizeVendor\Laminas\Cache\Storage\PluginAwareInterface;
use _JchOptimizeVendor\Laminas\Cache\Storage\PluginManager;
use _JchOptimizeVendor\Laminas\Cache\Storage\StorageInterface;
use _JchOptimizeVendor\Laminas\Cache\Storage\TaggableInterface;
use _JchOptimizeVendor\Laminas\ServiceManager\PluginManagerInterface;
use JchOptimize\Core\Exception;
use JchOptimize\Core\Helper;
use JchOptimize\Core\Laminas\Plugins\ClearExpiredByFactor;
use JchOptimize\Platform\Cache;
use JchOptimize\Platform\Paths;
use JchOptimize\Platform\Utility;
use Joomla\Filesystem\File;
use Joomla\Registry\Registry;
use Psr\Log\LoggerInterface;
\defined('_JCH_EXEC') or exit('Restricted access');
class CachingProvider implements ServiceProviderInterface
{
public function register(Container $container)
{
$container->alias(StorageAdapterFactoryInterface::class, StorageAdapterFactory::class)->share(StorageAdapterFactory::class, [$this, 'getStorageAdapterFactoryService'], \true);
$container->alias(PluginManagerInterface::class, AdapterPluginManager::class)->share(AdapterPluginManager::class, [$this, 'getAdapterPluginManagerService'], \true);
$container->alias(StoragePluginFactoryInterface::class, StoragePluginFactory::class)->share(StoragePluginFactory::class, [$this, 'getStoragePluginFactoryService'], \true);
$container->share(PluginManager::class, [$this, 'getPluginManagerService'], \true);
$container->share(StorageInterface::class, [$this, 'getStorageInterfaceService'], \true);
$container->share(CallbackCache::class, [$this, 'getCallbackCacheService'], \true);
$container->share(CaptureCache::class, [$this, 'getCaptureCacheService'], \true);
$container->share('page_cache', [$this, 'getPageCacheStorageService'], \true);
$container->alias('Filesystem', Filesystem::class)->share(Filesystem::class, [$this, 'getFilesystemService']);
$container->alias('Redis', Redis::class)->share(Redis::class, [$this, 'getRedisService']);
$container->alias('Apcu', Apcu::class)->share(Apcu::class, [$this, 'getApcuService']);
$container->alias('Memcached', Memcached::class)->share(Memcached::class, [$this, 'getMemcachedService']);
$container->alias('WinCache', WinCache::class)->share(WinCache::class, [$this, 'getWinCacheService']);
$container->share(TaggableInterface::class, [$this, 'getTaggableInterfaceService'], \true);
}
public function getStorageAdapterFactoryService(Container $container): StorageAdapterFactoryInterface
{
return new StorageAdapterFactory($container->get(PluginManagerInterface::class), $container->get(StoragePluginFactoryInterface::class));
}
public function getAdapterPluginManagerService(Container $container): PluginManagerInterface
{
return new AdapterPluginManager($container, $container->get('config')['dependencies']);
}
/**
* This will always fetch the Filesystem storage adapter.
*
* @throws Exception\RuntimeException
*/
public function getFilesystemService(Container $container): StorageInterface
{
$fsCache = $this->getCacheAdapter($container, 'filesystem');
$fsCache->getOptions()->setTtl(0);
return $fsCache;
}
/**
* @throws Exception\RuntimeException
*/
public function getRedisService(Container $container): StorageInterface
{
$redisCache = $this->getCacheAdapter($container, 'redis');
$redisCache->getOptions()->setTtl(0);
return $redisCache;
}
/**
* @throws Exception\RuntimeException
*/
public function getApcuService(Container $container): StorageInterface
{
$apcuCache = $this->getCacheAdapter($container, 'apcu');
$apcuCache->getOptions()->setTtl(0);
return $apcuCache;
}
/**
* @throws Exception\RuntimeException
*/
public function getMemcachedService(Container $container): StorageInterface
{
$memcachedCache = $this->getCacheAdapter($container, 'memcached');
$memcachedCache->getOptions()->setTtl(0);
return $memcachedCache;
}
/**
* @throws Exception\RuntimeException
*/
public function getWinCacheService(Container $container): StorageInterface
{
$winCacheCache = $this->getCacheAdapter($container, 'wincache');
$winCacheCache->getOptions()->setTtl(0);
return $winCacheCache;
}
public function getStoragePluginFactoryService(Container $container): StoragePluginFactoryInterface
{
return new StoragePluginFactory($container->get(PluginManager::class));
}
public function getPluginManagerService(Container $container): PluginManagerInterface
{
return new PluginManager($container, $container->get('config')['dependencies']);
}
/**
* This will get the storage adapter that is configured in the plugin parameters.
*
* @throws Exception\RuntimeException
*/
public function getStorageInterfaceService(Container $container): StorageInterface
{
$params = $container->get(Registry::class);
// Use whichever lifetime is greater to ensure page cache expires before
$pageCacheTtl = (int) $params->get('page_cache_lifetime', '900');
$globalTtl = (int) $params->get('cache_lifetime', '900');
$lifetime = \max($pageCacheTtl, $globalTtl);
$cache = $this->getCacheAdapter($container, $container->get(Registry::class)->get('pro_cache_storage_adapter', 'filesystem'));
$cache->getOptions()->setNamespace(Cache::getCacheNamespace())->setTtl($lifetime);
if ($cache instanceof PluginAwareInterface) {
if ($params->get('delete_expiry', '1')) {
$plugin = (new ClearExpiredByFactor())->setContainer($container);
$plugin->setLogger($container->get(LoggerInterface::class));
$plugin->getOptions()->setClearingFactor(50);
$cache->addPlugin($plugin);
}
}
return $cache;
}
public function getCallbackCacheService(Container $container): CallbackCache
{
return new CallbackCache($container->get(StorageInterface::class), new PatternOptions(['cache_output' => \false]));
}
public function getCaptureCacheService(Container $container): CaptureCache
{
$publicDir = Paths::captureCacheDir();
if (!\file_exists($publicDir)) {
$html = <<<'HTML'
<html><head><title></title></head><body></body></html>';
HTML;
try {
File::write($publicDir.'/index.html', $html);
} catch (\Exception $e) {
}
$htaccess = <<<APACHECONFIG
<IfModule mod_autoindex.c>
\tOptions -Indexes
</IfModule>
<IfModule mod_headers.c>
Header always unset Content-Security-Policy
</IfModule>
APACHECONFIG;
try {
File::write($publicDir.'/.htaccess', $htaccess);
} catch (\Exception $e) {
}
}
return new CaptureCache(new PatternOptions(['public_dir' => $publicDir, 'file_locking' => \true, 'file_permission' => 0644, 'dir_permission' => 0755, 'umask' => \false]));
}
/**
* @return IterableInterface&StorageInterface&TaggableInterface
*/
public function getTaggableInterfaceService(Container $container)
{
$cache = $this->getCacheAdapter($container, $container->get(Registry::class)->get('pro_cache_storage_adapter', 'filesystem'));
if (!$cache instanceof TaggableInterface || !$cache instanceof IterableInterface) {
$cache = $this->getCacheAdapter($container, 'filesystem');
}
// @var StorageInterface&TaggableInterface&IterableInterface $cache
$cache->getOptions()->setNamespace('jchoptimizetags')->setTtl(0);
return $cache;
}
public function getPageCacheStorageService(Container $container): StorageInterface
{
$cache = $this->getCacheAdapter($container, $container->get(Registry::class)->get('pro_cache_storage_adapter', 'filesystem'));
$cache->getOptions()->setNamespace(Cache::getCacheNamespace(\true))->setTtl((int) $container->get(Registry::class)->get('page_cache_lifetime', '900'));
return $cache;
}
private function getCacheAdapter(Container $container, string $adapter): StorageInterface
{
if ('filesystem' == $adapter) {
Helper::createCacheFolder();
}
try {
$factory = new StorageCacheAbstractServiceFactory();
/** @var StorageInterface $cache */
$cache = $factory($container, $adapter);
// Let's make sure we can connect
$cache->addItem(\md5('__ITEM__'), '__ITEM__');
return $cache;
} catch (\Throwable $e) {
$logger = $container->get(LoggerInterface::class);
$message = 'Error in JCH Optimize retrieving configured storage adapter with message: '.$e->getMessage();
if ('filesystem' != $adapter) {
$message .= ': Using the filesystem storage instead';
}
$logger->error($message);
Utility::publishAdminMessages($message, 'error');
if ('filesystem' != $adapter) {
return $this->getCacheAdapter($container, 'filesystem');
}
throw new Exception\RuntimeException($message);
}
}
}

View File

@@ -0,0 +1,87 @@
<?php
/**
* JCH Optimize - Performs several front-end optimizations for fast downloads.
*
* @author Samuel Marshall <samuel@jch-optimize.net>
* @copyright Copyright (c) 2022 Samuel Marshall / JCH Optimize
* @license GNU/GPLv3, or later. See LICENSE file
*
* If LICENSE file missing, see <http://www.gnu.org/licenses/>.
*/
namespace JchOptimize\Core\Service;
use _JchOptimizeVendor\Joomla\DI\Container;
use _JchOptimizeVendor\Joomla\DI\ServiceProviderInterface;
use JchOptimize\Core\Cdn;
use JchOptimize\Core\Css\Callbacks\CombineMediaQueries;
use JchOptimize\Core\Css\Callbacks\CorrectUrls;
use JchOptimize\Core\Css\Callbacks\ExtractCriticalCss;
use JchOptimize\Core\Css\Callbacks\FormatCss;
use JchOptimize\Core\Css\Callbacks\HandleAtRules;
use JchOptimize\Core\Html\Callbacks\Cdn as CdnCallback;
use JchOptimize\Core\Html\Callbacks\CombineJsCss;
use JchOptimize\Core\Html\Callbacks\LazyLoad;
use JchOptimize\Core\Html\FilesManager;
use JchOptimize\Core\Html\Processor as HtmlProcessor;
use JchOptimize\Core\Http2Preload;
use Joomla\Registry\Registry;
\defined('_JCH_EXEC') or exit('Restricted access');
class CallbackProvider implements ServiceProviderInterface
{
public function register(Container $container)
{
// Html callback
$container->protect(CdnCallback::class, [$this, 'getCdnCallbackService']);
$container->protect(CombineJsCss::class, [$this, 'getCombineJsCssService']);
$container->protect(LazyLoad::class, [$this, 'getLazyLoadService']);
// Css Callback;
$container->protect(CombineMediaQueries::class, [$this, 'getCombineMediaQueriesService']);
$container->protect(CorrectUrls::class, [$this, 'getCorrectUrlsService']);
$container->protect(ExtractCriticalCss::class, [$this, 'getExtractCriticalCssService']);
$container->protect(FormatCss::class, [$this, 'getFormatCssService']);
$container->protect(HandleAtRules::class, [$this, 'getHandleAtRulesService']);
}
public function getCdnCallbackService(Container $container): CdnCallback
{
return new CdnCallback($container, $container->get(Registry::class), $container->get(Cdn::class));
}
public function getCombineJsCssService(Container $container): CombineJsCss
{
return new CombineJsCss($container, $container->get(Registry::class), $container->get(FilesManager::class), $container->get(Http2Preload::class), $container->get(HtmlProcessor::class));
}
public function getLazyLoadService(Container $container): LazyLoad
{
return new LazyLoad($container, $container->get(Registry::class), $container->get(Http2Preload::class));
}
public function getCombineMediaQueriesService(Container $container): CombineMediaQueries
{
return new CombineMediaQueries($container, $container->get(Registry::class));
}
public function getCorrectUrlsService(Container $container): CorrectUrls
{
return new CorrectUrls($container, $container->get(Registry::class), $container->get(Cdn::class), $container->get(Http2Preload::class));
}
public function getExtractCriticalCssService(Container $container): ExtractCriticalCss
{
return new ExtractCriticalCss($container, $container->get(Registry::class));
}
public function getFormatCssService(Container $container): FormatCss
{
return new FormatCss($container, $container->get(Registry::class));
}
public function getHandleAtRulesService(Container $container): HandleAtRules
{
return new HandleAtRules($container, $container->get(Registry::class));
}
}

View File

@@ -0,0 +1,245 @@
<?php
/**
* JCH Optimize - Performs several front-end optimizations for fast downloads.
*
* @author Samuel Marshall <samuel@jch-optimize.net>
* @copyright Copyright (c) 2022 Samuel Marshall / JCH Optimize
* @license GNU/GPLv3, or later. See LICENSE file
*
* If LICENSE file missing, see <http://www.gnu.org/licenses/>.
*/
namespace JchOptimize\Core\Service;
use _JchOptimizeVendor\GuzzleHttp\Client;
use _JchOptimizeVendor\GuzzleHttp\RequestOptions;
use _JchOptimizeVendor\Joomla\DI\Container;
use _JchOptimizeVendor\Joomla\DI\ServiceProviderInterface;
use _JchOptimizeVendor\Laminas\Cache\Pattern\CallbackCache;
use _JchOptimizeVendor\Laminas\Cache\Pattern\CaptureCache;
use _JchOptimizeVendor\Laminas\Cache\Storage\StorageInterface;
use _JchOptimizeVendor\Laminas\Cache\Storage\TaggableInterface;
use _JchOptimizeVendor\Laminas\EventManager\LazyListener;
use _JchOptimizeVendor\Laminas\EventManager\SharedEventManager;
use _JchOptimizeVendor\Laminas\EventManager\SharedEventManagerInterface;
use _JchOptimizeVendor\Psr\Http\Client\ClientInterface;
use JchOptimize\Core\Admin\AbstractHtml;
use JchOptimize\Core\Admin\Icons;
use JchOptimize\Core\Admin\ImageUploader;
use JchOptimize\Core\Admin\MultiSelectItems;
use JchOptimize\Core\Cdn;
use JchOptimize\Core\Combiner;
use JchOptimize\Core\Css\Callbacks\CombineMediaQueries;
use JchOptimize\Core\Css\Callbacks\CorrectUrls;
use JchOptimize\Core\Css\Callbacks\ExtractCriticalCss;
use JchOptimize\Core\Css\Callbacks\FormatCss;
use JchOptimize\Core\Css\Callbacks\HandleAtRules;
use JchOptimize\Core\Css\Processor as CssProcessor;
use JchOptimize\Core\Css\Sprite\Controller;
use JchOptimize\Core\Css\Sprite\Generator;
use JchOptimize\Core\Exception;
use JchOptimize\Core\FileUtils;
use JchOptimize\Core\Html\CacheManager;
use JchOptimize\Core\Html\FilesManager;
use JchOptimize\Core\Html\LinkBuilder;
use JchOptimize\Core\Html\Processor as HtmlProcessor;
use JchOptimize\Core\Http2Preload;
use JchOptimize\Core\Optimize;
use JchOptimize\Core\PageCache\CaptureCache as CoreCaptureCache;
use JchOptimize\Core\PageCache\PageCache;
use JchOptimize\Core\SystemUri;
use JchOptimize\Platform\Cache;
use JchOptimize\Platform\Html;
use Joomla\Input\Input;
use Joomla\Registry\Registry;
use Psr\Log\LoggerInterface;
\defined('_JCH_EXEC') or exit('Restricted access');
class CoreProvider implements ServiceProviderInterface
{
public function register(Container $container)
{
// Html
$container->share(CacheManager::class, [$this, 'getCacheManagerService'], \true);
$container->share(FilesManager::class, [$this, 'getFilesManagerService'], \true);
$container->share(LinkBuilder::class, [$this, 'getLinkBuilderService'], \true);
$container->share(HtmlProcessor::class, [$this, 'getHtmlProcessorService']);
// Css
$container->protect(CssProcessor::class, [$this, 'getCssProcessorService']);
// Core
$container->share(Cdn::class, [$this, 'getCdnService'], \true);
$container->share(Combiner::class, [$this, 'getCombinerService'], \true);
$container->share(FileUtils::class, [$this, 'getFileUtilsService'], \true);
$container->share(Http2Preload::class, [$this, 'getHttp2PreloadService'], \true);
$container->share(Optimize::class, [$this, 'getOptimizeService'], \true);
// PageCache
$container->share(PageCache::class, [$this, 'getPageCacheService'], \true);
$container->share(CoreCaptureCache::class, [$this, 'getCaptureCacheService'], \true);
// Admin
$container->share(AbstractHtml::class, [$this, 'getAbstractHtmlService'], \true);
$container->share(ImageUploader::class, [$this, 'getImageUploaderService'], \true);
$container->share(Icons::class, [$this, 'getIconsService'], \true);
$container->share(MultiSelectItems::class, [$this, 'getMultiSelectItemsService'], \true);
// Sprite
$container->protect(Generator::class, [$this, 'getSpriteGeneratorService']);
$container->set(Controller::class, [$this, 'getSpriteControllerService'], \false, \false);
// Vendor
$container->share(ClientInterface::class, [$this, 'getClientInterfaceService']);
// Set up events management
/** @var SharedEventManager $sharedEvents */
$sharedEvents = $container->get(SharedEventManager::class);
$sharedEvents->attach(LinkBuilder::class, 'postProcessHtml', new LazyListener([
// @see Http2Preload::addPreloadsToHtml()
'listener' => Http2Preload::class,
'method' => 'addPreloadsToHtml',
], $container), 200);
if (JCH_PRO) {
$sharedEvents->attach(LinkBuilder::class, 'postProcessHtml', new LazyListener([
// @see Http2Preload::addModulePreloadsToHtml()
'listener' => Http2Preload::class,
'method' => 'addModulePreloadsToHtml',
], $container), 100);
}
}
public function getCacheManagerService(Container $container): CacheManager
{
$cacheManager = new CacheManager($container->get(Registry::class), $container->get(LinkBuilder::class), $container->get(Combiner::class), $container->get(FilesManager::class), $container->get(CallbackCache::class), $container->get(TaggableInterface::class), $container->get(Http2Preload::class), $container->get(HtmlProcessor::class));
$cacheManager->setContainer($container);
$cacheManager->setLogger($container->get(LoggerInterface::class));
return $cacheManager;
}
public function getFilesManagerService(Container $container): FilesManager
{
return (new FilesManager($container->get(Registry::class), $container->get(Http2Preload::class), $container->get(FileUtils::class), $container->get(ClientInterface::class)))->setContainer($container);
}
public function getLinkBuilderService(Container $container): LinkBuilder
{
return (new LinkBuilder($container->get(Registry::class), $container->get(HtmlProcessor::class), $container->get(FilesManager::class), $container->get(Cdn::class), $container->get(Http2Preload::class), $container->get(StorageInterface::class), $container->get(SharedEventManagerInterface::class)))->setContainer($container);
}
public function getHtmlProcessorService(Container $container): HtmlProcessor
{
$htmlProcessor = new HtmlProcessor($container->get(Registry::class));
$htmlProcessor->setContainer($container)->setLogger($container->get(LoggerInterface::class));
return $htmlProcessor;
}
public function getCssProcessorService(Container $container): CssProcessor
{
$cssProcessor = new CssProcessor($container->get(Registry::class), $container->get(CombineMediaQueries::class), $container->get(CorrectUrls::class), $container->get(ExtractCriticalCss::class), $container->get(FormatCss::class), $container->get(HandleAtRules::class));
$cssProcessor->setContainer($container)->setLogger($container->get(LoggerInterface::class));
return $cssProcessor;
}
public function getCdnService(Container $container): Cdn
{
return (new Cdn($container->get(Registry::class)))->setContainer($container);
}
public function getCombinerService(Container $container): Combiner
{
$combiner = new Combiner($container->get(Registry::class), $container->get(CallbackCache::class), $container->get(TaggableInterface::class), $container->get(FileUtils::class), $container->get(ClientInterface::class));
$combiner->setContainer($container)->setLogger($container->get(LoggerInterface::class));
return $combiner;
}
public function getFileUtilsService(): FileUtils
{
return new FileUtils();
}
public function getHttp2PreloadService(Container $container): Http2Preload
{
return (new Http2Preload($container->get(Registry::class), $container->get(Cdn::class)))->setContainer($container);
}
public function getOptimizeService(Container $container): Optimize
{
$optimize = new Optimize($container->get(Registry::class), $container->get(HtmlProcessor::class), $container->get(CacheManager::class), $container->get(LinkBuilder::class), $container->get(Http2Preload::class));
$optimize->setContainer($container)->setLogger($container->get(LoggerInterface::class));
return $optimize;
}
public function getPageCacheService(Container $container): PageCache
{
$params = $container->get(Registry::class);
if (JCH_PRO && $params->get('pro_capture_cache_enable', '0') && !Cache::isCaptureCacheIncompatible()) {
return $container->get(CoreCaptureCache::class);
}
$pageCache = (new PageCache($container->get(Registry::class), $container->get(Input::class), $container->get('page_cache'), $container->get(TaggableInterface::class)))->setContainer($container);
$pageCache->setLogger($container->get(LoggerInterface::class));
return $pageCache;
}
public function getCaptureCacheService(Container $container): CoreCaptureCache
{
$captureCache = (new CoreCaptureCache($container->get(Registry::class), $container->get(Input::class), $container->get('page_cache'), $container->get(TaggableInterface::class), $container->get(CaptureCache::class)))->setContainer($container);
$captureCache->setLogger($container->get(LoggerInterface::class));
return $captureCache;
}
public function getAbstractHtmlService(Container $container): AbstractHtml
{
$html = new Html($container->get(Registry::class), $container->get(ClientInterface::class));
$html->setContainer($container)->setLogger($container->get(LoggerInterface::class));
return $html;
}
/**
* @throws Exception\InvalidArgumentException
*/
public function getImageUploaderService(Container $container): ImageUploader
{
return new ImageUploader($container->get(Registry::class), $container->get(ClientInterface::class));
}
public function getIconsService(Container $container): Icons
{
return (new Icons($container->get(Registry::class)))->setContainer($container);
}
public function getMultiSelectItemsService(Container $container): MultiSelectItems
{
return new MultiSelectItems($container->get(Registry::class), $container->get(CallbackCache::class), $container->get(FileUtils::class));
}
public function getSpriteGeneratorService(Container $container): Generator
{
$spriteGenerator = new Generator($container->get(Registry::class), $container->get(Controller::class));
$spriteGenerator->setContainer($container)->setLogger($container->get(LoggerInterface::class));
return $spriteGenerator;
}
/**
* @throws \Exception
*/
public function getSpriteControllerService(Container $container): ?Controller
{
try {
return (new Controller($container->get(Registry::class), $container->get(LoggerInterface::class)))->setContainer($container);
} catch (\Exception $e) {
return null;
}
}
/**
* @return Client&ClientInterface
*/
public function getClientInterfaceService()
{
return new Client(['base_uri' => SystemUri::currentUri(), RequestOptions::HTTP_ERRORS => \false, RequestOptions::VERIFY => \false, RequestOptions::HEADERS => ['User-Agent' => $_SERVER['HTTP_USER_AGENT'] ?? '*']]);
}
}

View File

@@ -0,0 +1,49 @@
<?php
/**
* JCH Optimize - Performs several front-end optimizations for fast downloads.
*
* @author Samuel Marshall <samuel@jch-optimize.net>
* @copyright Copyright (c) 2022 Samuel Marshall / JCH Optimize
* @license GNU/GPLv3, or later. See LICENSE file
*
* If LICENSE file missing, see <http://www.gnu.org/licenses/>.
*/
namespace JchOptimize\Core\Service;
use _JchOptimizeVendor\Illuminate\Contracts\View\Engine;
use _JchOptimizeVendor\Illuminate\Events\Dispatcher;
use _JchOptimizeVendor\Illuminate\Filesystem\Filesystem;
use _JchOptimizeVendor\Illuminate\View\Compilers\BladeCompiler;
use _JchOptimizeVendor\Illuminate\View\Engines\CompilerEngine;
use _JchOptimizeVendor\Illuminate\View\Engines\EngineResolver;
use _JchOptimizeVendor\Illuminate\View\Factory;
use _JchOptimizeVendor\Illuminate\View\FileViewFinder;
use _JchOptimizeVendor\Joomla\DI\Container;
use _JchOptimizeVendor\Joomla\DI\ServiceProviderInterface;
use JchOptimize\Platform\Paths;
use Joomla\Filesystem\Folder;
\defined('_JCH_EXEC') or exit('Restricted access');
class IlluminateViewFactoryProvider implements ServiceProviderInterface
{
public function register(Container $container)
{
$container->set(Factory::class, function () {
$templateCachePath = Paths::templateCachePath();
// Make sure cache path exists
if (!\file_exists($templateCachePath)) {
// Create folder including parent folders if they don't exist
Folder::create($templateCachePath);
}
$filesystem = new Filesystem();
$resolver = new EngineResolver();
$resolver->register('blade', static function () use ($filesystem, $templateCachePath): Engine {
return new CompilerEngine(new BladeCompiler($filesystem, $templateCachePath));
});
return new Factory($resolver, new FileViewFinder($filesystem, []), new Dispatcher());
});
}
}

View File

@@ -0,0 +1,41 @@
<?php
/**
* JCH Optimize - Performs several front-end optimizations for fast downloads.
*
* @author Samuel Marshall <samuel@jch-optimize.net>
* @copyright Copyright (c) 2022 Samuel Marshall / JCH Optimize
* @license GNU/GPLv3, or later. See LICENSE file
*
* If LICENSE file missing, see <http://www.gnu.org/licenses/>.
*/
namespace JchOptimize\Core;
use JchOptimize\Core\PageCache\PageCache;
\defined('_JCH_EXEC') or exit('Restricted access');
trait StorageTaggingTrait
{
protected function tagStorage($id): void
{
// If item not already set for tagging, set it
$this->taggableCache->addItem($id, 'tag');
// Always attempt to store tags, item could be set on another page
$this->setStorageTags($id);
}
private function setStorageTags(string $id): void
{
$tags = $this->taggableCache->getTags($id);
$pageCache = $this->getContainer()->get(PageCache::class);
$currentUrl = $pageCache->getCurrentPage();
// If current url not yet tagged, tag it for this item. If it was only tagged once tag it again, so we
// know this item was requested at least twice so shouldn't be removed until expired.
if (\is_array($tags) && (!\in_array($currentUrl, $tags) || 1 == \count($tags))) {
$this->taggableCache->setTags($id, \array_merge($tags, [$currentUrl]));
} elseif (empty($tags)) {
$this->taggableCache->setTags($id, [$currentUrl]);
}
}
}

View File

@@ -0,0 +1,219 @@
<?php
/**
* JCH Optimize - Performs several front-end optimizations for fast downloads.
*
* @author Samuel Marshall <samuel@jch-optimize.net>
* @copyright Copyright (c) 2022 Samuel Marshall / JCH Optimize
* @license GNU/GPLv3, or later. See LICENSE file
*
* If LICENSE file missing, see <http://www.gnu.org/licenses/>.
*/
namespace JchOptimize\Core;
use _JchOptimizeVendor\GuzzleHttp\Psr7\Uri;
use _JchOptimizeVendor\Psr\Http\Message\UriInterface;
use JchOptimize\Core\Uri\UriNormalizer;
use JchOptimize\Platform\Paths;
use Joomla\Input\Input;
\defined('_JCH_EXEC') or exit('Restricted access');
/**
* Class to provide info about the current request URI from the server.
*/
class SystemUri
{
/**
* private instance of class.
*
* @var ?SystemUri
*/
private static ?\JchOptimize\Core\SystemUri $instance = null;
/**
* Input object used internally.
*/
private Input $input;
/**
* The detected current url.
*/
private string $requestUrl;
/**
* The detected current uri.
*/
private UriInterface $requestUri;
/**
* Path to index.php including host relative to the home page.
*/
private string $baseFull;
/**
* Path to index.php excluding the host relative to the home page.
*/
private string $basePath;
/**
* Path to index.php including host relative to the current request.
*/
private string $currentBaseFull;
/**
* Path to the index.php excluding host relative to the current request.
*/
private string $currentBasePath;
/**
* Constructor.
*/
private function __construct()
{
$this->input = new Input();
$this->requestUrl = $this->detectRequestUri();
$uri = new Uri($this->requestUrl);
$requestUri = $this->input->server->getString('REQUEST_URI', '');
// If we are working from a CGI SAPI with the 'cgi.fix_pathinfo' directive disabled we use PHP_SELF.
if (\false !== \strpos(\PHP_SAPI, 'cgi') && !\ini_get('cgi.fix_pathinfo') && !empty($requestUri)) {
// We aren't expecting PATH_INFO within PHP_SELF so this should work.
$path = \dirname($this->input->server->getString('PHP_SELF', ''));
} else {
// Pretty much everything else should be handled with SCRIPT_NAME.
$path = \dirname($this->input->server->getString('SCRIPT_NAME', ''));
}
// get the host from the URI
$host = Uri::composeComponents($uri->getScheme(), $uri->getAuthority(), '', '', '');
// Check if the path includes "index.php".
if (\false !== \strpos($path, 'index.php')) {
// Remove the index.php portion of the path.
$path = \substr_replace($path, '', \strpos($path, 'index.php'), 9);
}
$path = \rtrim($path, '/\\');
$this->requestUri = UriNormalizer::systemUriNormalize($uri);
$this->baseFull = $host.$path.'/';
$this->basePath = $path.'/';
// Platform specific bases that may not correspond to those above such as multisite wp
$this->currentBaseFull = \rtrim(Paths::homeBaseFullPath(), '/').'/';
$this->currentBasePath = \rtrim(Paths::homeBasePath(), '/').'/';
}
/**
* Static method to return the full request url.
*/
public static function toString(): string
{
return self::getInstance()->requestUrl;
}
/**
* Static method to return current url of server (without query).
*/
public static function currentUrl(): string
{
$uri = new Uri(self::getInstance()->requestUrl);
return Uri::composeComponents($uri->getScheme(), $uri->getAuthority(), $uri->getPath(), '', '');
}
public static function currentUri(): UriInterface
{
return self::getInstance()->requestUri;
}
/**
* Static method to return path to home page script including the host.
*/
public static function baseFull(): string
{
return self::getInstance()->baseFull;
}
/**
* Static method to return path to home page script without the host.
*/
public static function basePath(): string
{
return self::getInstance()->basePath;
}
/**
* Returns path to script including host based on current request.
*/
public static function currentBaseFull(): string
{
return self::getInstance()->currentBaseFull;
}
/**
* Returns path to script excluding host based on current request.
*/
public static function currentBasePath(): string
{
return self::getInstance()->currentBasePath;
}
/**
* Method to detect the requested URI from server environment variables.
*
* @return string The requested URI
*/
private function detectRequestUri(): string
{
// First we need to detect the URI scheme.
$scheme = $this->isSslConnection() ? 'https://' : 'http://';
/*
* There are some differences in the way that Apache and IIS populate server environment variables. To
* properly detect the requested URI we need to adjust our algorithm based on whether we are getting
* information from Apache or IIS.
*/
$phpSelf = $this->input->server->getString('PHP_SELF', '');
$requestUri = $this->input->server->getString('REQUEST_URI', '');
// If PHP_SELF and REQUEST_URI are both populated then we will assume "Apache Mode".
if (!empty($phpSelf) && !empty($requestUri)) {
// The URI is built from the HTTP_HOST and REQUEST_URI environment variables in an Apache environment.
$uri = $scheme.$this->input->server->getString('HTTP_HOST').$requestUri;
} else {
// If not in "Apache Mode" we will assume that we are in an IIS environment and proceed.
// IIS uses the SCRIPT_NAME variable instead of a REQUEST_URI variable... thanks, MS
$uri = $scheme.$this->input->server->getString('HTTP_HOST').$this->input->server->getString('SCRIPT_NAME');
$queryHost = $this->input->server->getString('QUERY_STRING', '');
// If the QUERY_STRING variable exists append it to the URI string.
if (!empty($queryHost)) {
$uri .= '?'.$queryHost;
}
}
return \trim($uri);
}
/**
* Determine if we are using a secure (SSL) connection.
*
* @return bool true if using SSL, false if not
*/
private function isSslConnection(): bool
{
$serverSSLVar = $this->input->server->getString('HTTPS', '');
if (!empty($serverSSLVar) && 'off' !== \strtolower($serverSSLVar)) {
return \true;
}
$serverForwarderProtoVar = $this->input->server->getString('HTTP_X_FORWARDED_PROTO', '');
return !empty($serverForwarderProtoVar) && 'https' === \strtolower($serverForwarderProtoVar);
}
/**
* Instance of class only used internally.
*/
private static function getInstance(): SystemUri
{
if (\is_null(self::$instance)) {
self::$instance = new \JchOptimize\Core\SystemUri();
}
return self::$instance;
}
}

View File

@@ -0,0 +1,30 @@
<?php
/**
* JCH Optimize - Performs several front-end optimizations for fast downloads.
*
* @author Samuel Marshall <samuel@jch-optimize.net>
* @copyright Copyright (c) 2023 Samuel Marshall / JCH Optimize
* @license GNU/GPLv3, or later. See LICENSE file
*
* If LICENSE file missing, see <http://www.gnu.org/licenses/>.
*/
namespace JchOptimize\Core\Uri;
use _JchOptimizeVendor\GuzzleHttp\Psr7\UriComparator as GuzzleComparator;
use _JchOptimizeVendor\Psr\Http\Message\UriInterface;
final class UriComparator
{
public static function isCrossOrigin(UriInterface $modified): bool
{
foreach (\JchOptimize\Core\Uri\Utils::originDomains() as $originDomain) {
if (!GuzzleComparator::isCrossOrigin($originDomain, $modified)) {
return \false;
}
}
return \true;
}
}

View File

@@ -0,0 +1,29 @@
<?php
/**
* JCH Optimize - Performs several front-end optimizations for fast downloads.
*
* @author Samuel Marshall <samuel@jch-optimize.net>
* @copyright Copyright (c) 2023 Samuel Marshall / JCH Optimize
* @license GNU/GPLv3, or later. See LICENSE file
*
* If LICENSE file missing, see <http://www.gnu.org/licenses/>.
*/
namespace JchOptimize\Core\Uri;
use _JchOptimizeVendor\GuzzleHttp\Psr7\UriResolver;
use _JchOptimizeVendor\Psr\Http\Message\UriInterface;
use JchOptimize\Core\SystemUri;
use JchOptimize\Platform\Paths;
final class UriConverter
{
public static function uriToFilePath(UriInterface $uri): string
{
$resolvedUri = UriResolver::resolve(SystemUri::currentUri(), $uri);
$path = \str_replace(\JchOptimize\Core\Uri\Utils::originDomains(), Paths::rootPath().'/', (string) $resolvedUri->withQuery('')->withFragment(''));
// convert all directory to unix style
return \strtr(\rawurldecode($path), '\\', '/');
}
}

View File

@@ -0,0 +1,34 @@
<?php
/**
* JCH Optimize - Performs several front-end optimizations for fast downloads.
*
* @author Samuel Marshall <samuel@jch-optimize.net>
* @copyright Copyright (c) 2023 Samuel Marshall / JCH Optimize
* @license GNU/GPLv3, or later. See LICENSE file
*
* If LICENSE file missing, see <http://www.gnu.org/licenses/>.
*/
namespace JchOptimize\Core\Uri;
use _JchOptimizeVendor\GuzzleHttp\Psr7\UriNormalizer as GuzzleNormalizer;
use _JchOptimizeVendor\Psr\Http\Message\UriInterface;
class UriNormalizer
{
public static function normalize(UriInterface $uri): UriInterface
{
return GuzzleNormalizer::normalize($uri, GuzzleNormalizer::CAPITALIZE_PERCENT_ENCODING | GuzzleNormalizer::DECODE_UNRESERVED_CHARACTERS | GuzzleNormalizer::REMOVE_DOT_SEGMENTS | GuzzleNormalizer::REMOVE_DUPLICATE_SLASHES);
}
public static function pageCacheIdNormalize(UriInterface $uri): UriInterface
{
return GuzzleNormalizer::normalize($uri->withPath(\rtrim($uri->getPath(), '/\\')), GuzzleNormalizer::PRESERVING_NORMALIZATIONS | GuzzleNormalizer::REMOVE_DUPLICATE_SLASHES | GuzzleNormalizer::SORT_QUERY_PARAMETERS);
}
public static function systemUriNormalize(UriInterface $uri): UriInterface
{
return GuzzleNormalizer::normalize($uri, GuzzleNormalizer::CAPITALIZE_PERCENT_ENCODING | GuzzleNormalizer::DECODE_UNRESERVED_CHARACTERS | GuzzleNormalizer::CONVERT_EMPTY_PATH | GuzzleNormalizer::REMOVE_DEFAULT_HOST | GuzzleNormalizer::REMOVE_DEFAULT_PORT | GuzzleNormalizer::REMOVE_DOT_SEGMENTS | GuzzleNormalizer::REMOVE_DUPLICATE_SLASHES);
}
}

View File

@@ -0,0 +1,64 @@
<?php
/**
* JCH Optimize - Performs several front-end optimizations for fast downloads.
*
* @author Samuel Marshall <samuel@jch-optimize.net>
* @copyright Copyright (c) 2023 Samuel Marshall / JCH Optimize
* @license GNU/GPLv3, or later. See LICENSE file
*
* If LICENSE file missing, see <http://www.gnu.org/licenses/>.
*/
namespace JchOptimize\Core\Uri;
use _JchOptimizeVendor\GuzzleHttp\Psr7\Uri;
use _JchOptimizeVendor\GuzzleHttp\Psr7\UriResolver;
use _JchOptimizeVendor\GuzzleHttp\Psr7\Utils as GuzzleUtils;
use _JchOptimizeVendor\Psr\Http\Message\UriInterface;
use JchOptimize\ContainerFactory;
use JchOptimize\Core\Cdn;
use JchOptimize\Core\SystemUri;
class Utils
{
public static function originDomains(): array
{
$container = ContainerFactory::getContainer();
/** @var Cdn $cdn */
$cdn = $container->get(Cdn::class);
$domains = $cdn->getCdnDomains();
$cdnDomains = \array_column($domains, 'domain');
$systemDomain = new Uri(SystemUri::currentBaseFull());
$originDomains = [$systemDomain];
// We count each configured CDN domain as 'equivalent' to the system domain, so we just
// build an array by swapping the CDN domains
foreach ($cdnDomains as $cdnDomain) {
$originDomains[] = UriResolver::resolve($systemDomain, $cdnDomain)->withPath($systemDomain->getPath());
}
return $originDomains;
}
/**
* Returns a UriInterface for an accepted value. If there's an error processing the
* received value, an '_invalidUri' string is returned,
* Use this whenever possible as Windows paths are converted to unix style so Uris can be created.
*
* @param string|UriInterface $uri
*/
public static function uriFor($uri): UriInterface
{
// convert Window directory to unix style
if (\is_string($uri)) {
$uri = \strtr(\trim($uri), '\\', '/');
}
try {
return \JchOptimize\Core\Uri\UriNormalizer::normalize(GuzzleUtils::uriFor($uri));
} catch (\InvalidArgumentException $e) {
return new Uri('_invalidUri');
}
}
}

View File

@@ -0,0 +1,14 @@
<!--
JCH Optimize - Performs several front-end optimizations for fast downloads
@package jchoptimize/core
@author Samuel Marshall <samuel@jch-optimize.net>
@copyright Copyright (c) 2022 Samuel Marshall / JCH Optimize
@license GNU/GPLv3, or later. See LICENSE file
If LICENSE file missing, see <http://www.gnu.org/licenses/>.
-->
<html>
<body style="background: #fff;"></body>
</html>

View File

@@ -0,0 +1,36 @@
<?php
/**
* JCH Optimize - Performs several front-end optimizations for fast downloads.
*
* @author Samuel Marshall <samuel@jch-optimize.net>
* @copyright Copyright (c) 2023 Samuel Marshall / JCH Optimize
* @license GNU/GPLv3, or later. See LICENSE file
*
* If LICENSE file missing, see <http://www.gnu.org/licenses/>.
*/
namespace JchOptimize;
use Joomla\CMS\Application\CMSApplication;
use Joomla\CMS\Application\ConsoleApplication;
use Joomla\CMS\Factory;
trait GetApplicationTrait
{
/**
* @return CMSApplication|ConsoleApplication
*/
protected static function getApplication()
{
$app = null;
try {
$app = Factory::getApplication();
} catch (\Exception $e) {
}
\assert($app instanceof CMSApplication || $app instanceof ConsoleApplication);
return $app;
}
}

View File

@@ -0,0 +1,253 @@
<?php
/**
* @copyright Copyright (c)2010-2021 Nicholas K. Dionysopoulos / Akeeba Ltd
* @license GNU General Public License version 2, or later
*/
namespace JchOptimize\Helper;
use _JchOptimizeVendor\Joomla\DI\Exception\KeyNotFoundException;
use JConfig;
use Joomla\Application\AbstractApplication;
use Joomla\Application\ConfigurationAwareApplicationInterface;
use Joomla\CMS\Cache\Cache;
use Joomla\CMS\Cache\CacheControllerFactoryInterface;
use Joomla\CMS\Cache\Controller\CallbackController;
use Joomla\CMS\Factory;
use Joomla\Registry\Registry;
use function defined;
\defined('_JEXEC') or exit('Restricted Access');
/**
* A utility class to help you quickly clean the Joomla! cache, borrowed and modified a bit from FOF40.
*
* @psalm-suppress all
*/
class CacheCleaner
{
/**
* Clears the com_modules and com_plugins cache. You need to call this whenever you alter the publish state or
* parameters of a module or plugin from your code.
*/
public static function clearPluginsAndModulesCache()
{
self::clearPluginsCache();
self::clearModulesCache();
}
/**
* Clears the com_plugins cache. You need to call this whenever you alter the publish state or parameters of a
* plugin from your code.
*
* @throws \Exception
*/
public static function clearPluginsCache()
{
self::clearCacheGroups(['com_plugins'], [0, 1]);
}
/**
* Clears the specified cache groups.
*
* @param array $clearGroups Which cache groups to clear. Usually this is com_yourcomponent to clear
* your component's cache.
* @param array $cacheClients Which cache clients to clear. 0 is the back-end, 1 is the front-end. If you
* do not specify anything, both cache clients will be cleared.
* @param null|string $event An event to run upon trying to clear the cache. Empty string to disable. If
* NULL and the group is "com_content" I will trigger onContentCleanCache.
*
* @throws \Exception
*/
public static function clearCacheGroups(array $clearGroups, array $cacheClients = [0, 1], ?string $event = null): void
{
// Early return on nonsensical input
if (empty($clearGroups) || empty($cacheClients)) {
return;
}
// Make sure I have an application object
try {
$app = Factory::getApplication();
} catch (\Exception $e) {
return;
}
// If there's no application object things will break; let's get outta here.
if (!\is_object($app)) {
return;
}
$isJoomla4 = \version_compare(JVERSION, '3.9999.9999', 'gt');
// Loop all groups to clean
foreach ($clearGroups as $group) {
// Groups must be non-empty strings
if (empty($group) || !\is_string($group)) {
continue;
}
// Loop all clients (applications)
foreach ($cacheClients as $client_id) {
$client_id = (int) ($client_id ?? 0);
$options = $isJoomla4 ? self::clearCacheGroupJoomla4($group, $client_id, $app) : self::clearCacheGroupJoomla3($group, $client_id, $app);
}
}
}
/**
* Clears the com_modules cache. You need to call this whenever you alter the publish state or parameters of a
* module from your code.
*/
public static function clearModulesCache()
{
self::clearCacheGroups(['com_modules'], [0, 1]);
}
/**
* Clean a cache group on Joomla 4.
*
* @param string $group The cache to clean, e.g. com_content
* @param int $client_id The application ID for which the cache will be cleaned
* @param object $app The current CMS application. DO NOT TYPEHINT MORE SPECIFICALLY!
*
* @return array Cache controller options, including cleaning result
*
* @throws \Exception
*/
private static function clearCacheGroupJoomla4(string $group, int $client_id, object $app): array
{
// Get the default cache folder. Start by using the JPATH_CACHE constant.
$cacheBaseDefault = JPATH_CACHE;
$appClientId = 0;
if (\method_exists($app, 'getClientId')) {
$appClientId = $app->getClientId();
}
// -- If we are asked to clean cache on the other side of the application we need to find a new cache base
if ($client_id != $appClientId) {
$cacheBaseDefault = ($client_id ? JPATH_SITE : JPATH_ADMINISTRATOR).'/cache';
}
// Get the cache controller's options
$options = ['defaultgroup' => $group, 'cachebase' => self::getAppConfigParam($app, 'cache_path', $cacheBaseDefault), 'result' => \true];
try {
$container = Factory::getContainer();
try {
/** @var CacheControllerFactoryInterface $cacheControllerFactory */
$cacheControllerFactory = $container->get('cache.controller.factory');
} catch (KeyNotFoundException $e) {
throw new \RuntimeException('Cannot get Joomla 4 cache controller factory');
}
try {
/** @var CallbackController $cache */
$cache = $cacheControllerFactory->createCacheController('callback', $options);
if (!\property_exists($cache, 'cache') || !\method_exists($cache->cache, 'clean')) {
throw new \RuntimeException('Cache controller not valid');
}
} catch (KeyNotFoundException|\RuntimeException $e) {
throw new \RuntimeException('Cannot get Joomla 4 cache controller');
}
$cache->cache->clean();
} catch (\Exception|\Throwable $exception) {
$options['result'] = \false;
}
return $options;
}
/**
* @return null|mixed|\stdClass|string
*/
private static function getAppConfigParam(?object $app, string $key, ?string $default = null)
{
/*
* Any kind of Joomla CMS, Web, API or CLI application extends from AbstractApplication and has the get()
* method to return application configuration parameters.
*/
if (\is_object($app) && $app instanceof AbstractApplication) {
return $app->get($key, $default);
}
/*
* A custom application may instead implement the Joomla\Application\ConfigurationAwareApplicationInterface
* interface (Joomla 4+), in whihc case it has the get() method to return application configuration parameters.
*/
if (\is_object($app) && \interface_exists('Joomla\\Application\\ConfigurationAwareApplicationInterface', \true) && $app instanceof ConfigurationAwareApplicationInterface) {
return $app->get($key, $default);
}
// A Joomla 3 custom application may simply implement the get() method without implementing an interface.
if (\is_object($app) && \method_exists($app, 'get')) {
return $app->get($key, $default);
}
/*
* At this point the $app variable is not an object or is something I can't use. Does the Joomla Factory still
* has the legacy static method getConfig() to get the application configuration? If so, use it.
*/
if (\method_exists(Factory::class, 'getConfig')) {
try {
$jConfig = Factory::getConfig();
if (\is_object($jConfig) && $jConfig instanceof Registry) {
$jConfig->get($key, $default);
}
} catch (\Throwable $e) {
/*
* Factory tries to go through the application object. It might fail if there is a custom application
* which doesn't implement the interfaces Factory expects. In this case we get a Fatal Error whcih we
* can trap and fall through to the next if-block.
*/
}
}
/**
* When we are here all hope is nearly lost. We have to do a crude approximation of Joomla Factory's code to
* create an application configuration Registry object and retrieve the configuration values. This will work as
* long as the JConfig class (defined in configuration.php) has been loaded.
*/
$configPath = \defined('JPATH_CONFIGURATION') ? JPATH_CONFIGURATION : (\defined('JPATH_ROOT') ? JPATH_ROOT : null);
$configPath = $configPath ?? __DIR__.'/../../..';
$configFile = $configPath.'/configuration.php';
if (!\class_exists('JConfig') && @\file_exists($configFile) && @\is_file($configFile) && @\is_readable($configFile)) {
require_once $configFile;
}
if (\class_exists('JConfig')) {
try {
$jConfig = new Registry();
$configObject = new \JConfig();
$jConfig->loadObject($configObject);
return $jConfig->get($key, $default);
} catch (\Throwable $e) {
return $default;
}
}
/*
* All hope is lost. I can't find the application configuration. I am returning the default value and hope stuff
* won't break spectacularly...
*/
return $default;
}
/**
* Clean a cache group on Joomla 3.
*
* @param string $group The cache to clean, e.g. com_content
* @param int $client_id The application ID for which the cache will be cleaned
* @param object $app The current CMS application. DO NOT TYPEHINT MORE SPECIFICALLY!
*
* @return array Cache controller options, including cleaning result
*
* @throws \Exception
*/
private static function clearCacheGroupJoomla3(string $group, int $client_id, object $app): array
{
$options = ['defaultgroup' => $group, 'cachebase' => $client_id ? JPATH_ADMINISTRATOR.'/cache' : self::getAppConfigParam($app, 'cache_path', JPATH_SITE.'/cache'), 'result' => \true];
try {
$cache = Cache::getInstance('callback', $options);
// @noinspection PhpUndefinedMethodInspection Available via __call(), not tagged in Joomla core
$cache->clean();
} catch (\Throwable $e) {
$options['result'] = \false;
}
return $options;
}
}

View File

@@ -0,0 +1,322 @@
<?php
/**
* JCH Optimize - Performs several front-end optimizations for fast downloads.
*
* @author Samuel Marshall <samuel@jch-optimize.net>
* @copyright Copyright (c) 2022 Samuel Marshall / JCH Optimize
* @license GNU/GPLv3, or later. See LICENSE file
*
* If LICENSE file missing, see <http://www.gnu.org/licenses/>.
*/
namespace JchOptimize\Joomla\Database;
use Joomla\CMS\Factory;
use Joomla\Database\DatabaseInterface;
\defined('_JEXEC') or exit('Restricted Access');
/**
* Decorator for DatabaseInterface to use in Joomla3.
*/
class Database implements DatabaseInterface
{
protected $db;
public function __construct($db)
{
$this->db = $db;
}
/**
* Call static methods magically.
*
* @param string $name Name of method
* @param array $args Arguments
*
* @return mixed
*/
public static function __callStatic(string $name, array $args)
{
return Factory::getDbo()::$name(\implode(',', $args));
}
/**
* Call any other method magically.
*
* @param string $name Name of method
* @param array $args Arguments
*
* @return mixed
*/
public function __call(string $name, array $args)
{
return $this->db->{$name}(\implode(',', $args));
}
public static function isSupported(): bool
{
return Factory::getDbo()::isSupported();
}
public function connect()
{
$this->db->connect();
}
public function connected(): bool
{
return $this->db->connected();
}
public function createDatabase($options, $utf = \true)
{
return $this->db->createDatabase($options, $utf);
}
public function decodeBinary($data): string
{
return $data;
}
public function disconnect()
{
$this->db->disconnect();
}
public function dropTable($table, $ifExists = \true)
{
return $this->dropTable($table, $ifExists);
}
public function escape($text, $extra = \false): string
{
return $this->db->escape($text, $extra);
}
public function execute()
{
return $this->db->execute();
}
public function getAffectedRows(): int
{
return $this->db->getAffectedRows();
}
public function getCollation()
{
return $this->db->getCollation();
}
public function getConnection()
{
return $this->db->getConnection();
}
public function getConnectionCollation(): string
{
return $this->db->getConnectionCollation();
}
public function getConnectionEncryption(): string
{
return '';
}
public function isConnectionEncryptionSupported(): bool
{
return \false;
}
public function isMinimumVersion(): bool
{
return $this->db->isMinimumVersion();
}
public function getCount(): int
{
return $this->db->getCount();
}
public function getDateFormat(): string
{
return $this->db->getDateFormat();
}
public function getMinimum(): string
{
return $this->db->getMinimum();
}
public function getName(): string
{
return $this->db->getName();
}
public function getNullDate(): string
{
return $this->db->getNullDate();
}
public function getNumRows($cursor = null): int
{
return $this->db->getNumRows($cursor);
}
public function getQuery($new = \false)
{
return $this->db->getQuery($new);
}
public function getServerType(): string
{
return $this->db->getServerType();
}
public function getTableColumns($table, $typeOnly = \true): array
{
return $this->db->getTableColumns($table, $typeOnly);
}
public function getTableKeys($tables): array
{
return $this->db->getTableKeys($tables);
}
public function getTableList(): array
{
return $this->db->getTableList();
}
public function getVersion(): string
{
return $this->db->getVersion();
}
public function hasUtfSupport(): bool
{
return $this->db->hasUtfSupport();
}
public function insertid()
{
return $this->db->insertid();
}
public function insertObject($table, &$object, $key = null): bool
{
return $this->db->insertObject($table, $object, $key);
}
public function loadAssoc()
{
return $this->db->loadAssoc();
}
public function loadAssocList($key = null, $column = null)
{
return $this->db->loadAssocList($key, $column);
}
public function loadColumn($offset = 0)
{
return $this->db->loadColumn($offset);
}
public function loadObject($class = \stdClass::class)
{
return $this->db->loadObject($class);
}
public function loadObjectList($key = '', $class = \stdClass::class)
{
return $this->db->loadObjectList($key, $class);
}
public function loadResult()
{
return $this->db->loadResult();
}
public function loadRow()
{
return $this->db->loadRow();
}
public function loadRowList($key = null)
{
return $this->db->loadRowList($key);
}
public function lockTable($tableName)
{
return $this->db->lockTable($tableName);
}
public function quote($text, $escape = \true)
{
return $this->db->quote($text, $escape);
}
public function quoteBinary($data): string
{
return $this->db->quoteBinary($data);
}
public function quoteName($name, $as = null)
{
return $this->db->quoteName($name, $as);
}
public function renameTable($oldTable, $newTable, $backup = null, $prefix = null)
{
return $this->db->renameTable($oldTable, $newTable, $backup, $prefix);
}
public function replacePrefix($sql, $prefix = '#__'): string
{
return $this->db->replacePrefix($sql, $prefix);
}
public function select($database): bool
{
return $this->db->select($database);
}
public function setQuery($query, $offset = 0, $limit = 0)
{
return $this->db->setQuery($query, $offset, $limit);
}
public function transactionCommit($toSavepoint = \false)
{
$this->db->transactionCommit($toSavepoint);
}
public function transactionRollback($toSavepoint = \false)
{
$this->db->transactionRollback($toSavepoint);
}
public function transactionStart($asSavepoint = \false)
{
$this->db->transactionStart($asSavepoint);
}
public function truncateTable($table)
{
$this->db->truncateTable($table);
}
public function unlockTables()
{
return $this->db->unlockTables();
}
public function updateObject($table, &$object, $key, $nulls = \false): bool
{
return $this->db->updateObject($table, $object, $key, $nulls);
}
}

View File

@@ -0,0 +1,27 @@
<?php
/**
* JCH Optimize - Performs several front-end optimizations for fast downloads.
*
* @author Samuel Marshall <samuel@jch-optimize.net>
* @copyright Copyright (c) 2022 Samuel Marshall / JCH Optimize
* @license GNU/GPLv3, or later. See LICENSE file
*
* If LICENSE file missing, see <http://www.gnu.org/licenses/>.
*/
namespace JchOptimize\Joomla\Plugin;
use Joomla\CMS\Plugin\PluginHelper as JPluginHelper;
abstract class PluginHelper extends JPluginHelper
{
/**
* Used to reset the plugins list after one has been modified to
* force a reload from the database.
*/
public static function reload(): void
{
static::$plugins = null;
}
}

View File

@@ -0,0 +1,29 @@
<?php
/**
* JCH Optimize - Performs several front-end optimizations for fast downloads.
*
* @author Samuel Marshall <samuel@jch-optimize.net>
* @copyright Copyright (c) 2022 Samuel Marshall / JCH Optimize
* @license GNU/GPLv3, or later. See LICENSE file
*
* If LICENSE file missing, see <http://www.gnu.org/licenses/>.
*/
namespace JchOptimize\Log;
use Joomla\CMS\Log\DelegatingPsrLogger;
\defined('_JEXEC') or exit('Restricted Access');
/**
* @psalm-suppress all
*/
class DelegatingPsrLoggerExtended extends DelegatingPsrLogger
{
public function log($level, $message, array $context = [])
{
$context = \array_merge($context, ['category' => 'com_jchoptimize']);
parent::log($level, $message, $context);
}
}

View File

@@ -0,0 +1,34 @@
<?php
/**
* JCH Optimize - Performs several front-end optimizations for fast downloads.
*
* @author Samuel Marshall <samuel@jch-optimize.net>
* @copyright Copyright (c) 2022 Samuel Marshall / JCH Optimize
* @license GNU/GPLv3, or later. See LICENSE file
*
* If LICENSE file missing, see <http://www.gnu.org/licenses/>.
*/
namespace JchOptimize\Log;
use Joomla\CMS\Log\Log;
use Psr\Log\LoggerInterface;
\defined('_JEXEC') or exit('Restricted Access');
/**
* @psalm-suppress all
*/
class JoomlaLogger extends Log
{
public static function createDelegatedLogger(): LoggerInterface
{
// Ensure a singleton instance has been created first
if (empty(static::$instance)) {
static::setInstance(new static());
}
return new \JchOptimize\Log\DelegatingPsrLoggerExtended(static::$instance);
}
}

View File

@@ -0,0 +1,36 @@
<?php
/**
* JCH Optimize - Performs several front-end optimizations for fast downloads.
*
* @author Samuel Marshall <samuel@jch-optimize.net>
* @copyright Copyright (c) 2020 Samuel Marshall / JCH Optimize
* @license GNU/GPLv3, or later. See LICENSE file
*
* If LICENSE file missing, see <http://www.gnu.org/licenses/>.
*/
namespace JchOptimize\Model;
use _JchOptimizeVendor\Joomla\Model\StatefulModelInterface;
use _JchOptimizeVendor\Joomla\Model\StatefulModelTrait;
\defined('_JEXEC') or exit('Restricted Access');
class ApiParams implements StatefulModelInterface
{
use StatefulModelTrait;
private \JchOptimize\Model\Updates $updates;
public function __construct(Updates $updates)
{
$this->updates = $updates;
}
public function getCompParams(): array
{
$apiParams = ['pro_downloadid' => $this->updates->getLicenseKey(), 'hidden_api_secret' => '0aad0284', 'ignore_optimized' => '1', 'recursive' => '1', 'pro_api_resize_mode' => '1', 'pro_next_gen_images' => '1', 'lossy' => '1', 'save_metadata' => '0'];
$aSetParams = \array_intersect_key($this->state->toArray(), $apiParams);
return \array_replace($apiParams, $aSetParams);
}
}

View File

@@ -0,0 +1,72 @@
<?php
/**
* JCH Optimize - Performs several front-end optimizations for fast downloads.
*
* @author Samuel Marshall <samuel@jch-optimize.net>
* @copyright Copyright (c) 2023 Samuel Marshall / JCH Optimize
* @license GNU/GPLv3, or later. See LICENSE file
*
* If LICENSE file missing, see <http://www.gnu.org/licenses/>.
*/
namespace JchOptimize\Model;
use _JchOptimizeVendor\Joomla\Model\DatabaseModelInterface;
use _JchOptimizeVendor\Joomla\Model\DatabaseModelTrait;
use _JchOptimizeVendor\Joomla\Model\StatefulModelInterface;
use _JchOptimizeVendor\Joomla\Model\StatefulModelTrait;
use _JchOptimizeVendor\Psr\Http\Message\UploadedFileInterface;
use JchOptimize\Core\Exception\ExceptionInterface;
use JchOptimize\Core\SystemUri;
use Joomla\Filesystem\File;
use Joomla\Registry\Registry;
class BulkSettings implements DatabaseModelInterface, StatefulModelInterface
{
use DatabaseModelTrait;
use StatefulModelTrait;
use \JchOptimize\Model\SaveSettingsTrait;
public function __construct(Registry $params)
{
$this->setState($params);
$this->name = 'buk_settings';
}
/**
* @throws ExceptionInterface
*/
public function importSettings(UploadedFileInterface $file): void
{
$tmpDir = \JPATH_ROOT.'/tmp';
$fileName = $file->getClientFilename() ?? \tempnam($tmpDir, 'jchoptimize_');
$targetPath = $tmpDir.'/'.$fileName;
// if file not already at target path move it
if (!\file_exists($targetPath)) {
$file->moveTo($targetPath);
}
$params = (new Registry())->loadFile($targetPath);
File::delete($targetPath);
$this->setState($params);
$this->saveSettings();
}
public function exportSettings(): string
{
$file = \JPATH_SITE.'/tmp/'.SystemUri::currentUri()->getHost().'_jchoptimize_settings.json';
$params = $this->state->toString();
File::write($file, $params);
return $file;
}
/**
* @throws ExceptionInterface
*/
public function setDefaultSettings(): void
{
$this->setState(new Registry([]));
$this->saveSettings();
}
}

Some files were not shown because too many files have changed in this diff Show More