diff --git a/composer.json b/composer.json index 0b64664..84e0b20 100644 --- a/composer.json +++ b/composer.json @@ -1,13 +1,11 @@ { - "name": "m2-boilerplate/module-critical-css", + "name": "studioraz/magento2-critical-css", "description": "Magento 2 module to automatically generate critical css with the addyosmani/critical npm package", + "type": "magento2-module", + "prefer-stable": true, + "version": "2.1.0", "keywords": [ ], - "require": { - "magento/framework": "^102.0|^103.0", - "php": ">=7.1.0" - }, - "type": "magento2-module", "license": [ "MIT" ], diff --git a/src/Config/Config.php b/src/Config/Config.php index a7864d7..fd230bd 100644 --- a/src/Config/Config.php +++ b/src/Config/Config.php @@ -14,6 +14,7 @@ class Config const CONFIG_PATH_USERNAME = 'dev/css/critical_css_username'; const CONFIG_PATH_PASSWORD = 'dev/css/critical_css_password'; const CONFIG_PATH_DIMENSIONS = 'dev/css/critical_css_dimensions'; + const CONFIG_PATH_FORCE_INCLUDE_CSS_SELECTORS = 'dev/css/critical_css_force_include_css_selectors'; /** * @var ScopeConfigInterface @@ -38,6 +39,16 @@ public function isEnabled(): bool return (bool) $this->scopeConfig->isSetFlag(self::CONFIG_PATH_ENABLED); } + public function getForceIncludeCssSelectors(): array + { + $cssSelectors = $this->scopeConfig->getValue(self::CONFIG_PATH_FORCE_INCLUDE_CSS_SELECTORS); + $cssSelectors = explode(',', $cssSelectors); + $cssSelectors = array_map('trim', $cssSelectors); + $cssSelectors = array_filter($cssSelectors); + + return $cssSelectors; + } + public function getDimensions(): array { $dimensions = $this->scopeConfig->getValue(self::CONFIG_PATH_DIMENSIONS); @@ -78,4 +89,4 @@ public function getCriticalBinary(): string { return $this->scopeConfig->getValue(self::CONFIG_PATH_CRITICAL_BINARY); } -} \ No newline at end of file +} diff --git a/src/Console/Command/GenerateCommand.php b/src/Console/Command/GenerateCommand.php index 9ee08c7..866f78e 100644 --- a/src/Console/Command/GenerateCommand.php +++ b/src/Console/Command/GenerateCommand.php @@ -7,17 +7,20 @@ use M2Boilerplate\CriticalCss\Service\CriticalCss; use M2Boilerplate\CriticalCss\Service\ProcessManager; use M2Boilerplate\CriticalCss\Service\ProcessManagerFactory; +use Magento\Framework\App\Cache\Manager; use Magento\Framework\App\Config\Storage\WriterInterface; -use Magento\Framework\ObjectManagerInterface; use Magento\Framework\App\State; +use Magento\Framework\FlagManager; +use Magento\Framework\ObjectManagerInterface; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; -use Magento\Framework\FlagManager; -use Magento\Framework\App\Cache\Manager; class GenerateCommand extends Command { + public const INPUT_OPTION_KEY_STORE_IDS = 'store-id'; + /** * @var ProcessManagerFactory */ @@ -88,6 +91,7 @@ public function __construct( protected function configure() { $this->setName('m2bp:critical-css:generate'); + $this->getDefinition()->addOptions($this->getOptionsList()); parent::configure(); } @@ -96,25 +100,76 @@ protected function execute(InputInterface $input, OutputInterface $output) try { $this->state->setAreaCode(\Magento\Framework\App\Area::AREA_ADMINHTML); - $this->cacheManager->flush($this->cacheManager->getAvailableTypes()); + // TODO: decide whether cache flushing is really required. temporally commented. + //$this->cacheManager->flush($this->cacheManager->getAvailableTypes()); $this->criticalCssService->test($this->config->getCriticalBinary()); + $consoleHandler = $this->consoleHandlerFactory->create(['output' => $output]); + $logger = $this->objectManager->create('M2Boilerplate\CriticalCss\Logger\Console', ['handlers' => ['console' => $consoleHandler]]); - $output->writeln('Generating Critical CSS'); /** @var ProcessManager $processManager */ $processManager = $this->processManagerFactory->create(['logger' => $logger]); + + + $output->writeln('\'Use CSS critical path\' config is ' . ($this->config->isEnabled() ? 'Enabled' : 'Disabled') . ''); + $output->writeln("-----------------------------------------"); + $output->writeln('Critical Command Configured Options'); + $output->writeln("-----------------------------------------"); + $output->writeln('Screen Dimensions: ' . implode('', $this->config->getDimensions()) . ''); + $output->writeln('Force Include Css Selectors: ' . implode('', $this->config->getForceIncludeCssSelectors()) . ''); + + $output->writeln('HTTP Auth Username: ' . $this->config->getUsername() . ''); + $output->writeln('HTTP Auth Password: ' . $this->config->getPassword() . ''); + + $output->writeln("-----------------------------------------"); $output->writeln('Gathering URLs...'); - $processes = $processManager->createProcesses(); + $output->writeln("-----------------------------------------"); + + $processes = $processManager->createProcesses( + $this->getStoreIds($input) ?: null + ); + + $output->writeln("-----------------------------------------"); $output->writeln('Generating Critical CSS for ' . count($processes) . ' URLs...'); + $output->writeln("-----------------------------------------"); $processManager->executeProcesses($processes, true); - $this->cacheManager->flush($this->cacheManager->getAvailableTypes()); + // TODO: decide whether cache flushing is really required. temporally commented. + // $this->cacheManager->flush($this->cacheManager->getAvailableTypes()); } catch (\Throwable $e) { throw $e; } return 0; } + + /** + * Returns list of options and arguments for the command + * + * @return mixed + */ + public function getOptionsList() + { + return [ + new InputOption( + self::INPUT_OPTION_KEY_STORE_IDS, + null, + InputOption::VALUE_REQUIRED, + 'Coma-separated list of Magento Store IDs or single value to process specific Store.' + ), + ]; + } + + /** + * @param InputInterface $input + * @return int[] + */ + private function getStoreIds(InputInterface $input): array + { + $ids = $input->getOption(self::INPUT_OPTION_KEY_STORE_IDS) ?: ''; + $ids = explode(',', $ids); + return array_map('intval', array_filter($ids)); + } } diff --git a/src/Plugin/AsyncCssPlugin.php b/src/Plugin/AsyncCssPlugin.php new file mode 100644 index 0000000..88d8cb2 --- /dev/null +++ b/src/Plugin/AsyncCssPlugin.php @@ -0,0 +1,125 @@ +scopeConfig = $scopeConfig; + $this->httpHeader = $httpHeader; + } + + /** + * @inheritDoc + */ + public function afterRenderResult(Layout $subject, Layout $result, ResponseInterface $httpResponse) + { + if ($this->canBeProcessed($httpResponse)) { + return parent::afterRenderResult($subject, $result, $httpResponse); + } + + return $result; + } + + /** + * @return bool + */ + private function canBeProcessed(ResponseInterface $httpResponse): bool + { + // NOTE: validate Critical Css activity Flag + if (!$this->isCssCriticalEnabled()) { + return false; + } + + // NOTE: validate Request, it MUST NOT be initiated by NPM CRITICAL-CSS + // check on user-agent value + if ($this->httpHeader->getHttpUserAgent() === 'got (https://github.com/sindresorhus/got)') { + return false; + } + + // NOTE: validate if CriticalCss node includes not-empty content + $content = (string)$httpResponse->getContent(); + if ($this->isCriticalCssNodeEmpty($content)) { + return false; + } + + return true; + } + + /** + * NOTE: + * @see \Magento\Theme\Controller\Result\AsyncCssPlugin::isCssCriticalEnabled + * + * Returns information whether css critical path is enabled + * + * @return bool + */ + private function isCssCriticalEnabled(): bool + { + return $this->scopeConfig->isSetFlag( + 'dev/css/use_css_critical_path', + ScopeInterface::SCOPE_STORE + ); + } + + /** + * Validates if STYLE CriticalCss-node exists and is NOT empty + * + * @param string $content + * @return bool + */ + private function isCriticalCssNodeEmpty(string $content): bool + { + $styles = ''; + $styleOpen = ''); + + while ($styleOpenPos !== false) { + // NOTE: no need to proceed in case lines on HEAD-node have been processed + if ($styleOpenPos >= $headClosePos) { + break; + } + + $styleClosePos = strpos($content, $styleClose, $styleOpenPos); + $style = substr($content, $styleOpenPos, $styleClosePos - $styleOpenPos + strlen($styleClose)); + + // NOTE: validation of STYLE-node string (tags exactly and node's inner content). + // in case match - fetch the styles and filter the string + if (preg_match('@(.+)@s', $style, $matches)) { + $styles = str_replace( + ["\n","\r\n","\r"], + '', + trim((string)($matches[2] ?? null))); + break; + } + + // NOTE: remove processed style-node from HTML. + $content = str_replace($style, '', $content); + + // NOTE: style-node was cut out, search for the next one at its former position. + $styleOpenPos = strpos($content, $styleOpen, $styleOpenPos); + } + + return empty($styles); + } +} diff --git a/src/Plugin/CriticalCss.php b/src/Plugin/CriticalCss.php index e3a8149..3f305bd 100644 --- a/src/Plugin/CriticalCss.php +++ b/src/Plugin/CriticalCss.php @@ -65,9 +65,18 @@ public function __construct( $this->storeManager = $storeManager; } + /** + * @param \Magento\Theme\Block\Html\Header\CriticalCss $subject + * @param $result generated CSS code to be inline injected to page head + * @return string|null + * @throws \Magento\Framework\Exception\FileSystemException + */ public function afterGetCriticalCssData(\Magento\Theme\Block\Html\Header\CriticalCss $subject, $result) { + $result = ''; + $providers = $this->container->getProviders(); + try { $store = $this->storeManager->getStore(); } catch (NoSuchEntityException $e) { @@ -77,9 +86,9 @@ public function afterGetCriticalCssData(\Magento\Theme\Block\Html\Header\Critica foreach ($providers as $provider) { if ($identifier = $provider->getCssIdentifierForRequest($this->request, $this->layout)) { $identifier = $this->identifier->generateIdentifier($provider, $store, $identifier); - $css = $this->storage->getCriticalCss($identifier); - if ($css) { - return $css; + $result = $this->storage->getCriticalCss($identifier); + if ($result) { + break; } } } diff --git a/src/Service/CriticalCss.php b/src/Service/CriticalCss.php index c50de56..e427d37 100644 --- a/src/Service/CriticalCss.php +++ b/src/Service/CriticalCss.php @@ -22,6 +22,7 @@ public function __construct(ProcessFactory $processFactory) public function createCriticalCssProcess( string $url, array $dimensions, + array $forceIncludeCssSelectors, string $criticalBinary = 'critical', ?string $username = null, ?string $password = null @@ -30,6 +31,12 @@ public function createCriticalCssProcess( $criticalBinary, $url ]; + + foreach ($forceIncludeCssSelectors as $selector) { + $command[] = '--penthouse-forceInclude'; + $command[] = $selector; + } + foreach ($dimensions as $dimension) { $command[] = '--dimensions'; $command[] = $dimension; @@ -44,8 +51,12 @@ public function createCriticalCssProcess( $command[] = '--strict'; $command[] = '--no-request-https.rejectUnauthorized'; - $command[] = '--ignore-rule'; - $command[] = '[data-role=main-css-loader]'; + + $command[] = '--ignore-atrule'; + $command[] = '@font-face'; + + //$command[] = '--penthouse-blockJSRequests'; + //$command[] = 'true'; /** @var Process $process */ $process = $this->processFactory->create(['command' => $command, 'commandline' => $command]); diff --git a/src/Service/ProcessManager.php b/src/Service/ProcessManager.php index f1fa3e1..86f17c9 100644 --- a/src/Service/ProcessManager.php +++ b/src/Service/ProcessManager.php @@ -2,9 +2,9 @@ namespace M2Boilerplate\CriticalCss\Service; -use M2Boilerplate\CriticalCss\Model\ProcessContextFactory; use M2Boilerplate\CriticalCss\Config\Config; use M2Boilerplate\CriticalCss\Model\ProcessContext; +use M2Boilerplate\CriticalCss\Model\ProcessContextFactory; use M2Boilerplate\CriticalCss\Provider\Container; use M2Boilerplate\CriticalCss\Provider\ProviderInterface; use Magento\Store\Api\Data\StoreInterface; @@ -121,10 +121,20 @@ public function executeProcesses(array $processList, bool $deleteOldFiles = fals } - public function createProcesses(): array + /** + * @param array|null $storeIds + * @return array + */ + public function createProcesses(?array $storeIds = null): array { $processList = []; foreach ($this->storeManager->getStores() as $storeId => $store) { + // NOTE: skip Store in case specific StoreIds to process are provided, + // but current StoreId is not in the List + if ($storeIds !== null && !in_array($storeId, $storeIds, true)) { + continue; + } + // Skip store if store is not active if (!$store->getIsActive()) continue; $this->emulation->startEnvironmentEmulation($storeId,\Magento\Framework\App\Area::AREA_FRONTEND, true); @@ -145,10 +155,18 @@ public function createProcessesForProvider(ProviderInterface $provider, StoreInt $processList = []; $urls = $provider->getUrls($store); foreach ($urls as $identifier => $url) { + // NOTE: start: SR WORKAROUND + // to add random query string to generated URLs to get non cached page content + $qParamConcatChar = mb_strpos($url, '?') === false ? '?' : '&'; + $url .= $qParamConcatChar . 'm2bp_t=' . time(); + // end: SR WORKAROUND + + $this->logger->info(sprintf('[%s:%s|%s] - %s', $store->getCode(), $provider->getName(), $identifier, $url)); $process = $this->criticalCssService->createCriticalCssProcess( $url, $this->config->getDimensions(), + $this->config->getForceIncludeCssSelectors(), $this->config->getCriticalBinary(), $this->config->getUsername(), $this->config->getPassword() diff --git a/src/etc/adminhtml/system.xml b/src/etc/adminhtml/system.xml index 0abb77c..cc88d2b 100644 --- a/src/etc/adminhtml/system.xml +++ b/src/etc/adminhtml/system.xml @@ -3,40 +3,48 @@
- - + + 1 required-entry Installation instructions can be found here: https://github.com/addyosmani/critical#install - - + + 1 - required-entry - Comma separated List, e.g.: 375x812,576x1152,768x1024,1024x768,1280x720 + validate-digits required-entry - - + + 1 - validate-digits required-entry - - + + 1 - - + + 1 + required-entry + Comma separated List, e.g.: 375x812,576x1152,768x1024,1024x768,1280x720 + + + + + 1 + + required-entry + Comma separated css selectors to keep in critical css, even if not appearing in critical viewport. Strings or regex (f.e. '.keepMeEvenIfNotSeenInDom', /^\.button/)
diff --git a/src/etc/di.xml b/src/etc/di.xml index 9d195f2..ffde044 100644 --- a/src/etc/di.xml +++ b/src/etc/di.xml @@ -9,7 +9,7 @@ - M2Boilerplate\CriticalCss\Provider\DefaultProvider + M2Boilerplate\CriticalCss\Provider\CmsPageProvider M2Boilerplate\CriticalCss\Provider\CustomerProvider M2Boilerplate\CriticalCss\Provider\ContactProvider @@ -44,4 +44,4 @@ M2Boilerplate\CriticalCss\Logger\File - \ No newline at end of file + diff --git a/src/etc/frontend/di.xml b/src/etc/frontend/di.xml index c413f92..5f8b056 100644 --- a/src/etc/frontend/di.xml +++ b/src/etc/frontend/di.xml @@ -2,4 +2,14 @@ - \ No newline at end of file + + + + + + + + diff --git a/src/etc/module.xml b/src/etc/module.xml index 5581f13..d96095c 100644 --- a/src/etc/module.xml +++ b/src/etc/module.xml @@ -1,4 +1,8 @@ - - \ No newline at end of file + + + + + +