├── .gitignore
├── LICENSE
├── README.md
├── composer.json
└── src
├── Config
└── Config.php
├── Console
└── Command
│ └── GenerateCommand.php
├── Controller
└── CriticalCss
│ └── DefaultAction.php
├── Logger
└── Handler
│ ├── ConsoleHandler.php
│ └── FileHandler.php
├── Model
└── ProcessContext.php
├── Plugin
└── CriticalCss.php
├── Provider
├── CatalogSearchProvider.php
├── CategoryProvider.php
├── CmsPageProvider.php
├── ContactProvider.php
├── Container.php
├── CustomerProvider.php
├── DefaultProvider.php
├── ProductProvider.php
└── ProviderInterface.php
├── Service
├── CriticalCss.php
├── CssProcessor.php
├── Identifier.php
├── ProcessManager.php
└── Storage.php
├── etc
├── adminhtml
│ └── system.xml
├── config.xml
├── di.xml
├── frontend
│ ├── di.xml
│ └── routes.xml
└── module.xml
├── registration.php
└── view
└── frontend
├── layout
└── m2bp_criticalcss_default.xml
└── templates
└── default.phtml
/.gitignore:
--------------------------------------------------------------------------------
1 | # OS or Editor folders
2 | .DS_Store
3 | .idea
4 | vendor
5 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | The MIT License (MIT)
2 |
3 | Copyright (c) 2020 Thomas Hampe
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy of
6 | this software and associated documentation files (the "Software"), to deal in
7 | the Software without restriction, including without limitation the rights to
8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
9 | the Software, and to permit persons to whom the Software is furnished to do so,
10 | subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
21 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # M2Boilerplate Critical CSS
2 |
3 | Magento 2 module to automatically generate critical css with [critical](https://github.com/addyosmani/critical)
4 |
5 | ## Features:
6 | * generate critical css with a simple command
7 | * Fallback critical css for "empty" pages
8 | * Add urls by creating a custom provider
9 |
10 | ## Installation
11 |
12 | Install the critical binary. Install instructions can be found on the [critical website](https://github.com/addyosmani/critical#install). Only versions >=2.0.3 are supported.
13 |
14 | Add this extension to your Magento installation with Composer:
15 |
16 | composer require m2-boilerplate/module-critical-css
17 |
18 | ## Usage
19 |
20 | ### Configuration
21 |
22 | The critical css feature needs to be enabled (available in 2.3.4+):
23 |
24 | bin/magento config:set dev/css/use_css_critical_path 1
25 |
26 | Features can be customised in *Stores > Configuration > Developer > CSS*.
27 |
28 | ### Generate critical css
29 |
30 | Run the following command
31 |
32 | bin/magento m2bp:critical-css:generate
33 |
34 | Afterwards you should find the the generated css in ``var/critical-css``. The css will now be integrated automatically on your website.
35 |
36 | ## Add additional URLs via a custom provider
37 |
38 | The following example adds the magento contact page via a custom provider:
39 |
40 | ```php
41 | url = $url;
64 | }
65 |
66 |
67 | public function getUrls(StoreInterface $store): array
68 | {
69 | return [
70 | 'contact_index_index' => $this->url->getUrl('contact'),
71 | ];
72 | }
73 |
74 | public function getName(): string
75 | {
76 | return self::NAME;
77 | }
78 |
79 | public function isAvailable(): bool
80 | {
81 | return true;
82 | }
83 |
84 | public function getPriority(): int
85 | {
86 | return 1200;
87 | }
88 |
89 | public function getCssIdentifierForRequest(RequestInterface $request, LayoutInterface $layout): ?string
90 | {
91 | if ($request->getModuleName() !== 'contact' || !$request instanceof Http) {
92 | return null;
93 | }
94 |
95 | if ($request->getFullActionName('_') === 'contact_index_index') {
96 | return 'contact_index_index';
97 | }
98 |
99 | return null;
100 | }
101 | }
102 | ```
103 |
104 | Add the new provider via DI:
105 |
106 | in your module´s etc/di.xml add the following:
107 |
108 | ```xml
109 |
110 |
111 |
112 |
113 | - Vendor\Module\Provider\CustomProvider
114 |
115 |
116 |
117 |
118 | ```
119 |
120 | ## License
121 | See the [LICENSE](LICENSE) file for license info (it's the MIT license).
122 |
--------------------------------------------------------------------------------
/composer.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "m2-boilerplate/module-critical-css",
3 | "description": "Magento 2 module to automatically generate critical css with the addyosmani/critical npm package",
4 | "keywords": [
5 | ],
6 | "require": {
7 | "magento/framework": "^102.0|^103.0",
8 | "php": ">=7.1.0"
9 | },
10 | "type": "magento2-module",
11 | "license": [
12 | "MIT"
13 | ],
14 | "autoload": {
15 | "files": [
16 | "src/registration.php"
17 | ],
18 | "psr-4": {
19 | "M2Boilerplate\\CriticalCss\\": "src"
20 | }
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/src/Config/Config.php:
--------------------------------------------------------------------------------
1 | scopeConfig = $scopeConfig;
34 | }
35 |
36 | public function isEnabled(): bool
37 | {
38 | return (bool) $this->scopeConfig->isSetFlag(self::CONFIG_PATH_ENABLED);
39 | }
40 |
41 | public function getDimensions(): array
42 | {
43 | $dimensions = $this->scopeConfig->getValue(self::CONFIG_PATH_DIMENSIONS);
44 | $dimensions = explode(',', $dimensions);
45 | $dimensions = array_map('trim', $dimensions);
46 | $dimensions = array_filter($dimensions);
47 | if (count($dimensions) === 0) {
48 | return $this->defaultDimensions;
49 | }
50 | return $dimensions;
51 | }
52 |
53 | public function getNumberOfParallelProcesses(): int
54 | {
55 | $processes = (int) $this->scopeConfig->getValue(self::CONFIG_PATH_PARALLEL_PROCESSES);
56 | return max(1, $processes);
57 | }
58 |
59 | public function getUsername($scopeCode = null): ?string
60 | {
61 | $username = $this->scopeConfig->getValue(self::CONFIG_PATH_USERNAME, ScopeInterface::SCOPE_STORE, $scopeCode);
62 | if (!$username) {
63 | return null;
64 | }
65 | return (string) $username;
66 | }
67 |
68 | public function getPassword($scopeCode = null): ?string
69 | {
70 | $password = $this->scopeConfig->getValue(self::CONFIG_PATH_PASSWORD, ScopeInterface::SCOPE_STORE, $scopeCode);
71 | if (!$password) {
72 | return null;
73 | }
74 | return (string) $password;
75 | }
76 |
77 | public function getCriticalBinary(): string
78 | {
79 | return $this->scopeConfig->getValue(self::CONFIG_PATH_CRITICAL_BINARY);
80 | }
81 | }
--------------------------------------------------------------------------------
/src/Console/Command/GenerateCommand.php:
--------------------------------------------------------------------------------
1 | flagManager = $flagManager;
77 | $this->processManagerFactory = $processManagerFactory;
78 | $this->consoleHandlerFactory = $consoleHandlerFactory;
79 | $this->objectManager = $objectManager;
80 | $this->config = $config;
81 | $this->criticalCssService = $criticalCssService;
82 | $this->state = $state;
83 | $this->configWriter = $configWriter;
84 | $this->cacheManager = $cacheManager;
85 | }
86 |
87 |
88 | protected function configure()
89 | {
90 | $this->setName('m2bp:critical-css:generate');
91 | parent::configure();
92 | }
93 |
94 | protected function execute(InputInterface $input, OutputInterface $output)
95 | {
96 | try {
97 | $this->state->setAreaCode(\Magento\Framework\App\Area::AREA_ADMINHTML);
98 |
99 | $this->cacheManager->flush($this->cacheManager->getAvailableTypes());
100 |
101 | $this->criticalCssService->test($this->config->getCriticalBinary());
102 | $consoleHandler = $this->consoleHandlerFactory->create(['output' => $output]);
103 | $logger = $this->objectManager->create('M2Boilerplate\CriticalCss\Logger\Console', ['handlers' => ['console' => $consoleHandler]]);
104 | $output->writeln('Generating Critical CSS');
105 |
106 | /** @var ProcessManager $processManager */
107 | $processManager = $this->processManagerFactory->create(['logger' => $logger]);
108 | $output->writeln('Gathering URLs...');
109 | $processes = $processManager->createProcesses();
110 | $output->writeln('Generating Critical CSS for ' . count($processes) . ' URLs...');
111 | $processManager->executeProcesses($processes, true);
112 |
113 | $this->cacheManager->flush($this->cacheManager->getAvailableTypes());
114 |
115 | } catch (\Throwable $e) {
116 | throw $e;
117 | }
118 | return 0;
119 | }
120 | }
121 |
--------------------------------------------------------------------------------
/src/Controller/CriticalCss/DefaultAction.php:
--------------------------------------------------------------------------------
1 | pageFactory = $pageFactory;
23 | }
24 |
25 | public function execute()
26 | {
27 | $page = $this->pageFactory->create();
28 | $pageLayout = $this->getRequest()->getParam('page_layout');
29 | if ($pageLayout) {
30 | $page->getConfig()->setPageLayout($pageLayout);
31 | $page->getLayout()->getUpdate()->addHandle('m2bp-'.$pageLayout);
32 | }
33 | return $page;
34 | }
35 |
36 |
37 | }
--------------------------------------------------------------------------------
/src/Logger/Handler/ConsoleHandler.php:
--------------------------------------------------------------------------------
1 |
32 | */
33 | class ConsoleHandler extends AbstractProcessingHandler implements EventSubscriberInterface
34 | {
35 | protected $output;
36 | protected $verbosityLevelMap = [
37 | OutputInterface::VERBOSITY_QUIET => Logger::ERROR,
38 | OutputInterface::VERBOSITY_NORMAL => Logger::INFO,
39 | OutputInterface::VERBOSITY_VERBOSE => Logger::NOTICE,
40 | OutputInterface::VERBOSITY_VERY_VERBOSE => Logger::DEBUG,
41 | OutputInterface::VERBOSITY_DEBUG => Logger::DEBUG,
42 | ];
43 | protected $consoleFormaterOptions;
44 |
45 | /**
46 | * @param OutputInterface|null $output The console output to use (the handler remains disabled when passing null
47 | * until the output is set, e.g. by using console events)
48 | * @param bool $bubble Whether the messages that are handled can bubble up the stack
49 | * @param array $verbosityLevelMap Array that maps the OutputInterface verbosity to a minimum logging
50 | * level (leave empty to use the default mapping)
51 | */
52 | public function __construct(OutputInterface $output = null, bool $bubble = true, array $verbosityLevelMap = [], array $consoleFormaterOptions = [])
53 | {
54 | parent::__construct(Logger::DEBUG, $bubble);
55 | $this->output = $output;
56 |
57 | if ($verbosityLevelMap) {
58 | $this->verbosityLevelMap = $verbosityLevelMap;
59 | }
60 |
61 | $this->consoleFormaterOptions = $consoleFormaterOptions;
62 | }
63 |
64 | /**
65 | * {@inheritdoc}
66 | */
67 | public function isHandling(array $record): bool
68 | {
69 | return $this->updateLevel() && parent::isHandling($record);
70 | }
71 |
72 | /**
73 | * {@inheritdoc}
74 | */
75 | public function handle(array $record): bool
76 | {
77 | // we have to update the logging level each time because the verbosity of the
78 | // console output might have changed in the meantime (it is not immutable)
79 | return $this->updateLevel() && parent::handle($record);
80 | }
81 |
82 | /**
83 | * Sets the console output to use for printing logs.
84 | */
85 | public function setOutput(OutputInterface $output)
86 | {
87 | $this->output = $output;
88 | }
89 |
90 | /**
91 | * Disables the output.
92 | */
93 | public function close(): void
94 | {
95 | $this->output = null;
96 |
97 | parent::close();
98 | }
99 |
100 | /**
101 | * Before a command is executed, the handler gets activated and the console output
102 | * is set in order to know where to write the logs.
103 | */
104 | public function onCommand(ConsoleCommandEvent $event)
105 | {
106 | $output = $event->getOutput();
107 | if ($output instanceof ConsoleOutputInterface) {
108 | $output = $output->getErrorOutput();
109 | }
110 |
111 | $this->setOutput($output);
112 | }
113 |
114 | /**
115 | * After a command has been executed, it disables the output.
116 | */
117 | public function onTerminate(ConsoleTerminateEvent $event)
118 | {
119 | $this->close();
120 | }
121 |
122 | /**
123 | * {@inheritdoc}
124 | */
125 | public static function getSubscribedEvents()
126 | {
127 | return [
128 | ConsoleEvents::COMMAND => ['onCommand', 255],
129 | ConsoleEvents::TERMINATE => ['onTerminate', -255],
130 | ];
131 | }
132 |
133 | /**
134 | * {@inheritdoc}
135 | */
136 | protected function write(array $record): void
137 | {
138 | // at this point we've determined for sure that we want to output the record, so use the output's own verbosity
139 | $this->output->write((string) $record['formatted'], false, $this->output->getVerbosity());
140 | }
141 |
142 | /**
143 | * {@inheritdoc}
144 | */
145 | protected function getDefaultFormatter(): FormatterInterface
146 | {
147 | return new LineFormatter("%message%\n");
148 | }
149 |
150 | /**
151 | * Updates the logging level based on the verbosity setting of the console output.
152 | *
153 | * @return bool Whether the handler is enabled and verbosity is not set to quiet
154 | */
155 | protected function updateLevel(): bool
156 | {
157 | if (null === $this->output) {
158 | return false;
159 | }
160 |
161 | $verbosity = $this->output->getVerbosity();
162 | if (isset($this->verbosityLevelMap[$verbosity])) {
163 | $this->setLevel($this->verbosityLevelMap[$verbosity]);
164 | } else {
165 | $this->setLevel(Logger::DEBUG);
166 | }
167 |
168 | return true;
169 | }
170 | }
--------------------------------------------------------------------------------
/src/Logger/Handler/FileHandler.php:
--------------------------------------------------------------------------------
1 | process = $process;
42 | $this->provider = $provider;
43 | $this->store = $store;
44 | $this->identifier = $identifier;
45 | $this->identifierService = $identifierService;
46 | }
47 |
48 | /**
49 | * @return StoreInterface
50 | */
51 | public function getStore()
52 | {
53 | return $this->store;
54 | }
55 |
56 | /**
57 | * @return ProviderInterface
58 | */
59 | public function getProvider()
60 | {
61 | return $this->provider;
62 | }
63 |
64 | /**
65 | * @return Process
66 | */
67 | public function getProcess()
68 | {
69 | return $this->process;
70 | }
71 |
72 | public function getOrigIdentifier()
73 | {
74 | return $this->identifier;
75 | }
76 |
77 | public function getIdentifier()
78 | {
79 | return $this->identifierService->generateIdentifier(
80 | $this->provider,
81 | $this->store,
82 | $this->identifier
83 | );
84 | }
85 |
86 | }
--------------------------------------------------------------------------------
/src/Plugin/CriticalCss.php:
--------------------------------------------------------------------------------
1 | flagManager = $flagManager;
60 | $this->layout = $layout;
61 | $this->request = $request;
62 | $this->container = $container;
63 | $this->storage = $storage;
64 | $this->identifier = $identifier;
65 | $this->storeManager = $storeManager;
66 | }
67 |
68 | public function afterGetCriticalCssData(\Magento\Theme\Block\Html\Header\CriticalCss $subject, $result)
69 | {
70 | $providers = $this->container->getProviders();
71 | try {
72 | $store = $this->storeManager->getStore();
73 | } catch (NoSuchEntityException $e) {
74 | return $result;
75 | }
76 |
77 | foreach ($providers as $provider) {
78 | if ($identifier = $provider->getCssIdentifierForRequest($this->request, $this->layout)) {
79 | $identifier = $this->identifier->generateIdentifier($provider, $store, $identifier);
80 | $css = $this->storage->getCriticalCss($identifier);
81 | if ($css) {
82 | return $css;
83 | }
84 | }
85 | }
86 |
87 | return $result;
88 | }
89 |
90 | }
91 |
--------------------------------------------------------------------------------
/src/Provider/CatalogSearchProvider.php:
--------------------------------------------------------------------------------
1 | url = $url;
29 | $this->queryCollectionFactory = $queryCollectionFactory;
30 | }
31 |
32 |
33 | public function getUrls(StoreInterface $store): array
34 | {
35 | $urls = [
36 | 'catalogsearch_advanced_index' => $store->getUrl('catalogsearch/advanced'),
37 | 'search_term_popular' => $store->getUrl('search/term/popular'),
38 | ];
39 | /** @var \Magento\Search\Model\Query $term */
40 | $term = $this->queryCollectionFactory
41 | ->create()
42 | ->setPopularQueryFilter($store->getId())
43 | ->setPageSize(1)->load()->getFirstItem();
44 | if ($term->getQueryText()) {
45 | $urls['catalogsearch_result_index'] = $store->getUrl(
46 | 'catalogsearch/result',
47 | ['_query' => ['q' => $term->getQueryText()]]
48 | );
49 | }
50 | return $urls;
51 | }
52 |
53 | public function getName(): string
54 | {
55 | return self::NAME;
56 | }
57 |
58 | public function isAvailable(): bool
59 | {
60 | return true;
61 | }
62 |
63 | public function getPriority(): int
64 | {
65 | return 1300;
66 | }
67 |
68 | public function getCssIdentifierForRequest(RequestInterface $request, LayoutInterface $layout): ?string
69 | {
70 | if ($request->getModuleName() !== 'catalogsearch' || !$request instanceof Http) {
71 | return null;
72 | }
73 |
74 | $actionName = $request->getFullActionName('_');
75 | $supportedActions = [
76 | 'catalogsearch_advanced_index',
77 | 'catalogsearch_result_index',
78 | 'search_term_popular',
79 | ];
80 | if (in_array($actionName, $supportedActions)) {
81 | return $actionName;
82 | }
83 |
84 | return null;
85 | }
86 |
87 |
88 | }
89 |
--------------------------------------------------------------------------------
/src/Provider/CategoryProvider.php:
--------------------------------------------------------------------------------
1 | categoryCollectionFactory = $categoryCollectionFactory;
33 | $this->registry = $registry;
34 | }
35 |
36 | /**
37 | * @return string[]
38 | */
39 | public function getUrls(StoreInterface $store): array
40 | {
41 | $urls = [];
42 | try {
43 | // Get all Product Listings grouped by appearance settings
44 | $collection = $this->categoryCollectionFactory->create();
45 | $collection->setStore($store);
46 | $collection->addIsActiveFilter();
47 | $collection->addAttributeToFilter(
48 | [
49 | ['attribute' => 'display_mode', 'eq' => Category::DM_PRODUCT],
50 | ['attribute' => 'display_mode', 'null' => true]
51 | ],
52 | null,
53 | 'left'
54 | );
55 | $collection->addAttributeToFilter('level', ['gt' => 1]);
56 | $collection->addUrlRewriteToResult();
57 |
58 | $collection->addAttributeToSelect('is_anchor','left');
59 | $collection->groupByAttribute('is_anchor');
60 | $collection->addAttributeToSelect('page_layout','left');
61 | $collection->groupByAttribute('page_layout');
62 | $collection->addAttributeToSelect('custom_design','left');
63 | $collection->groupByAttribute('custom_design');
64 | $collection->addAttributeToSort('children_count', \Magento\Framework\Data\Collection::SORT_ORDER_DESC);
65 | $collection->groupByAttribute('entity_id');
66 | foreach ($collection->getItems() as $category) {
67 | /** @var $category Category */
68 | $urls[$this->getIdentifier($category)] = $store->getUrl("catalog/category/view/",["id" => $category->getId()]);
69 | }
70 | } catch (LocalizedException $e) {}
71 |
72 | try {
73 | // Get all Landing Pages
74 | $collection = $this->categoryCollectionFactory->create();
75 | $collection->setStore($store);
76 | $collection->addIsActiveFilter();
77 | $collection->addAttributeToFilter('display_mode', ['neq' => Category::DM_PRODUCT]);
78 | $collection->addAttributeToFilter('level', ['gt' => 1]);
79 | $collection->addUrlRewriteToResult();
80 | $collection->groupByAttribute('entity_id');
81 |
82 | foreach ($collection->getItems() as $category) {
83 | /** @var $category Category */
84 | $urls[$this->getIdentifier($category)] = $category->getUrl();
85 | }
86 | } catch (LocalizedException $e) {}
87 |
88 | return $urls;
89 | }
90 |
91 | /**
92 | * @return string
93 | */
94 | public function getName(): string
95 | {
96 | return self::NAME;
97 | }
98 |
99 | /**
100 | * @return bool
101 | */
102 | public function isAvailable(): bool
103 | {
104 | return true;
105 | }
106 |
107 | /**
108 | * @return int
109 | */
110 | public function getPriority(): int
111 | {
112 | return 1500;
113 | }
114 |
115 | public function getCssIdentifierForRequest(RequestInterface $request, LayoutInterface $layout): ?string
116 | {
117 | if (!$request instanceof Http) {
118 | return null;
119 | }
120 |
121 | if ($request->getFullActionName('_') === 'catalog_category_view') {
122 |
123 | $category = $this->registry->registry('current_category');
124 | if (!$category instanceof Category) {
125 | return null;
126 | }
127 |
128 | return (string) $this->getIdentifier($category);
129 | }
130 | return null;
131 | }
132 |
133 | protected function getIdentifier(Category $category): string
134 | {
135 | if ($category->getDisplayMode() !== Category::DM_PRODUCT && $category->getDisplayMode() !== null) {
136 | return $category->getId();
137 | }
138 |
139 | return sprintf(
140 | 'is_anchor:%s,page_layout:%s,custom_design:%s',
141 | (int) $category->getData('is_anchor'),
142 | (string) $category->getData('page_layout'),
143 | (string) $category->getData('custom_design')
144 | );
145 | }
146 | }
147 |
--------------------------------------------------------------------------------
/src/Provider/CmsPageProvider.php:
--------------------------------------------------------------------------------
1 | url = $url;
41 | $this->pageRepository = $pageRepository;
42 | $this->searchCriteriaBuilder = $searchCriteriaBuilder;
43 | $this->pageHelper = $pageHelper;
44 | }
45 |
46 |
47 | public function getUrls(StoreInterface $store): array
48 | {
49 | $urls = [];
50 | /*$searchCriteria = $this->searchCriteriaBuilder
51 | ->addFilter('is_active', '1')
52 | ->addFilter('store_id', [$store->getId(), 0], 'in')
53 | ->setPageSize(30)
54 | ->setCurrentPage(0)
55 | ->create();
56 | try {
57 | $pages = $this->pageRepository->getList($searchCriteria);
58 | }
59 |
60 | catch (LocalizedException $e) {
61 | return [];
62 | }*/
63 | $urls['cms_index_index'] = $store->getUrl('/');
64 | /*foreach ($pages->getItems() as $page) {
65 | $url = $this->pageHelper->getPageUrl($page->getId());
66 | if (!$url) {
67 | continue;
68 | }
69 | $urls[$page->getId()] = $store->getUrl("cms/page/view", ["id" => $page->getId()]);
70 | }*/
71 | return $urls;
72 | }
73 |
74 | public function getName(): string
75 | {
76 | return self::NAME;
77 | }
78 |
79 | public function isAvailable(): bool
80 | {
81 | return true;
82 | }
83 |
84 | public function getPriority(): int
85 | {
86 | return 1000;
87 | }
88 |
89 | public function getCssIdentifierForRequest(RequestInterface $request, LayoutInterface $layout): ?string
90 | {
91 | if ($request->getModuleName() !== 'cms' || !$request instanceof Http) {
92 | return null;
93 | }
94 | if ($request->getFullActionName('_') === 'cms_index_index') {
95 | // home page
96 | return 'cms_index_index';
97 | }
98 | /*if ($request->getFullActionName('_') === 'cms_page_view') {
99 | // home page
100 | return $request->getParam('page_id');
101 | }*/
102 |
103 | return null;
104 | }
105 |
106 | }
107 |
--------------------------------------------------------------------------------
/src/Provider/ContactProvider.php:
--------------------------------------------------------------------------------
1 | url = $url;
24 | }
25 |
26 |
27 | public function getUrls(StoreInterface $store): array
28 | {
29 | return [
30 | 'contact_index_index' => $store->getUrl('contact'),
31 | ];
32 | }
33 |
34 | public function getName(): string
35 | {
36 | return self::NAME;
37 | }
38 |
39 | public function isAvailable(): bool
40 | {
41 | return true;
42 | }
43 |
44 | public function getPriority(): int
45 | {
46 | return 1200;
47 | }
48 |
49 | public function getCssIdentifierForRequest(RequestInterface $request, LayoutInterface $layout): ?string
50 | {
51 | if ($request->getModuleName() !== 'contact' || !$request instanceof Http) {
52 | return null;
53 | }
54 | if (
55 | $request->getFullActionName('_') === 'contact_index_index'
56 | ) {
57 | return 'contact_index_index';
58 | }
59 |
60 | return null;
61 | }
62 | }
63 |
--------------------------------------------------------------------------------
/src/Provider/Container.php:
--------------------------------------------------------------------------------
1 | addProvider($provider);
20 | }
21 | }
22 |
23 | /**
24 | * @return ProviderInterface[]
25 | */
26 | public function getProviders(): array
27 | {
28 | usort($this->providers, function (ProviderInterface $a, ProviderInterface $b) {
29 | if ($a->getPriority() === $b->getPriority()) {
30 | return 0;
31 | }
32 | return ($a->getPriority() < $b->getPriority()) ? 1 : -1;
33 | });
34 | return $this->providers;
35 | }
36 |
37 | /**
38 | * @param ProviderInterface $provider
39 | */
40 | public function addProvider(ProviderInterface $provider): void
41 | {
42 | $this->providers[$provider->getName()] = $provider;
43 | }
44 |
45 | /**
46 | * @param string $name
47 | *
48 | * @return ProviderInterface|null
49 | */
50 | public function getProvider(string $name): ?ProviderInterface
51 | {
52 | return isset($this->providers[$name]) ? $this->providers[$name] : null;
53 | }
54 |
55 |
56 | }
--------------------------------------------------------------------------------
/src/Provider/CustomerProvider.php:
--------------------------------------------------------------------------------
1 | url = $url;
23 | }
24 |
25 |
26 | public function getUrls(StoreInterface $store): array
27 | {
28 | return [
29 | 'customer_account_login' => $store->getUrl('customer/account/login'),
30 | 'customer_account_create' => $store->getUrl('customer/account/create'),
31 | 'customer_account_forgotpassword' => $store->getUrl('customer/account/forgotpassword'),
32 | ];
33 | }
34 |
35 | public function getName(): string
36 | {
37 | return self::NAME;
38 | }
39 |
40 | public function isAvailable(): bool
41 | {
42 | return true;
43 | }
44 |
45 | public function getPriority(): int
46 | {
47 | return 1100;
48 | }
49 |
50 | public function getCssIdentifierForRequest(RequestInterface $request, LayoutInterface $layout): ?string
51 | {
52 | if ($request->getModuleName() !== 'customer' || !$request instanceof Http) {
53 | return null;
54 | }
55 |
56 | $actionName = $request->getFullActionName('_');
57 | $supportedActions = [
58 | 'customer_account_login',
59 | 'customer_account_create',
60 | 'customer_account_forgotpassword'
61 | ];
62 | if (in_array($actionName, $supportedActions)) {
63 | return $actionName;
64 | }
65 |
66 | return null;
67 | }
68 |
69 | }
70 |
--------------------------------------------------------------------------------
/src/Provider/DefaultProvider.php:
--------------------------------------------------------------------------------
1 | url = $url;
29 | $this->pageLayoutBuilder = $pageLayoutBuilder;
30 | }
31 |
32 |
33 | public function getUrls(StoreInterface $store): array
34 | {
35 | $options = array_keys($this->pageLayoutBuilder->getPageLayoutsConfig()->getOptions());
36 |
37 | $urls = [];
38 | foreach ($options as $option) {
39 | $urls[$option] = $store->getUrl('m2bp/criticalCss/default', ['page_layout' => $option]);
40 | }
41 | return $urls;
42 | }
43 |
44 | public function getName(): string
45 | {
46 | return self::NAME;
47 | }
48 |
49 | public function isAvailable(): bool
50 | {
51 | return true;
52 | }
53 |
54 | public function getPriority(): int
55 | {
56 | return PHP_INT_MIN;
57 | }
58 |
59 | public function getCssIdentifierForRequest(RequestInterface $request, LayoutInterface $layout): ?string
60 | {
61 | return $layout->getUpdate()->getPageLayout();
62 | }
63 |
64 |
65 | }
66 |
--------------------------------------------------------------------------------
/src/Provider/ProductProvider.php:
--------------------------------------------------------------------------------
1 | productCollectionFactory = $productCollectionFactory;
54 | $this->productStatus = $productStatus;
55 | $this->productVisibility = $productVisibility;
56 | $this->url = $url;
57 | $this->registry = $registry;
58 | }
59 |
60 | /**
61 | * @return string[]
62 | */
63 | public function getUrls(StoreInterface $store): array
64 | {
65 | $collection = $this->productCollectionFactory->create();
66 | $collection->setStore($store);
67 | $collection->addAttributeToFilter('status', ['in' => $this->productStatus->getVisibleStatusIds()]);
68 | $collection->setVisibility($this->productVisibility->getVisibleInSiteIds());
69 | $collection->groupByAttribute('type_id');
70 |
71 | $urls = [];
72 | foreach ($collection->getItems() as $product) {
73 | /** @var $product \Magento\Catalog\Model\Product */
74 | $urls[$product->getTypeId()] = $product->getProductUrl();
75 | }
76 | return $urls;
77 | }
78 |
79 | /**
80 | * @return string
81 | */
82 | public function getName(): string
83 | {
84 | return self::NAME;
85 | }
86 |
87 | /**
88 | * @return bool
89 | */
90 | public function isAvailable(): bool
91 | {
92 | return true;
93 | }
94 |
95 | /**
96 | * @return int
97 | */
98 | public function getPriority(): int
99 | {
100 | return 1400;
101 | }
102 |
103 | public function getCssIdentifierForRequest(RequestInterface $request, LayoutInterface $layout): ?string
104 | {
105 | if (!$request instanceof Http) {
106 | return null;
107 | }
108 |
109 | if ($request->getFullActionName('_') === 'catalog_product_view') {
110 |
111 | $product = $this->registry->registry('current_product');
112 | if (!$product instanceof Product && $product->getTypeId()) {
113 | return null;
114 | }
115 |
116 | return (string) $product->getTypeId();
117 | }
118 |
119 | return null;
120 | }
121 | }
--------------------------------------------------------------------------------
/src/Provider/ProviderInterface.php:
--------------------------------------------------------------------------------
1 | processFactory = $processFactory;
19 | }
20 |
21 |
22 | public function createCriticalCssProcess(
23 | string $url,
24 | array $dimensions,
25 | string $criticalBinary = 'critical',
26 | ?string $username = null,
27 | ?string $password = null
28 | ) {
29 | $command = [
30 | $criticalBinary,
31 | $url
32 | ];
33 | foreach ($dimensions as $dimension) {
34 | $command[] = '--dimensions';
35 | $command[] = $dimension;
36 | }
37 |
38 | if ($username && $password) {
39 | $command[] = '--user';
40 | $command[] = $username;
41 | $command[] = '--pass';
42 | $command[] = $password;
43 | }
44 |
45 | $command[] = '--strict';
46 | $command[] = '--no-request-https.rejectUnauthorized';
47 | $command[] = '--ignore-rule';
48 | $command[] = '[data-role=main-css-loader]';
49 |
50 | /** @var Process $process */
51 | $process = $this->processFactory->create(['command' => $command, 'commandline' => $command]);
52 |
53 | return $process;
54 | }
55 |
56 | public function getVersion(string $criticalBinary = 'critical'): string
57 | {
58 | $command = [$criticalBinary, '--version'];
59 | $process = $this->processFactory->create(['command' => $command, 'commandline' => $command]);
60 | $process->mustRun();
61 | return trim($process->getOutput());
62 | }
63 |
64 | public function test(string $criticalBinary = 'critical'): void
65 | {
66 | $version = $this->getVersion($criticalBinary);
67 | if (version_compare($version, '2.0.6', '<')) {
68 | throw new \RuntimeException('critical version 2.0.6 is the minimum requirement, got: '.$version);
69 | }
70 | }
71 |
72 | }
73 |
--------------------------------------------------------------------------------
/src/Service/CssProcessor.php:
--------------------------------------------------------------------------------
1 | cssResolver = $cssResolver;
25 | $this->storeManager = $storeManager;
26 | }
27 |
28 | public function process(StoreInterface $store, string $cssContent)
29 | {
30 | $pattern = '@(\.\./)*(static|/static|/pub/static)/(.+)$@i'; // matches paths that contain pub/static/ or just static/
31 | $store = $this->storeManager->getStore(); /** @var Store $store */
32 | $baseUrl = $store->getBaseUrl(UrlInterface::URL_TYPE_WEB);
33 | return $this->cssResolver->replaceRelativeUrls($cssContent, function ($path) use ($pattern, $baseUrl) {
34 | $matches = [];
35 | if(preg_match($pattern, $path, $matches[0])) {
36 | /**
37 | * ../../../../../../pub/static/version/frontend/XXX/YYY/de_DE/ZZZ/asset.ext
38 | * becomes
39 | * https://base.url/pub/static/version/frontend/XXX/YYY/de_DE/ZZZ/asset.ext
40 | */
41 | if (isset($matches[0][3])) {
42 | return $baseUrl . ltrim($matches[0][2].'/'.$matches[0][3], '/');
43 | }
44 | return $baseUrl . ltrim($matches[0][0], '/');
45 | }
46 | return $path;
47 | });
48 | }
49 |
50 | }
51 |
--------------------------------------------------------------------------------
/src/Service/Identifier.php:
--------------------------------------------------------------------------------
1 | encryptor = $encryptor;
19 | }
20 |
21 |
22 | public function generateIdentifier(ProviderInterface $provider, StoreInterface $store, $identifier)
23 | {
24 | $uniqueIdentifier = sprintf('[%s]%s_%s', $store->getCode(), $provider->getName(), $identifier);
25 | return $this->encryptor->hash($uniqueIdentifier, Encryptor::HASH_VERSION_MD5);
26 | }
27 |
28 | }
--------------------------------------------------------------------------------
/src/Service/ProcessManager.php:
--------------------------------------------------------------------------------
1 | emulation = $emulation;
76 | $this->storeManager = $storeManager;
77 | $this->container = $container;
78 | $this->criticalCssService = $criticalCssService;
79 | $this->config = $config;
80 | $this->contextFactory = $contextFactory;
81 | $this->storage = $storage;
82 | $this->logger = $logger;
83 | $this->cssProcessor = $cssProcessor;
84 | }
85 |
86 | /**
87 | * @param ProcessContext[] $processList
88 | * @param bool $deleteOldFiles
89 | */
90 | public function executeProcesses(array $processList, bool $deleteOldFiles = false): void
91 | {
92 |
93 | if ($deleteOldFiles) {
94 | $this->storage->clean();
95 | }
96 | /** @var ProcessContext[] $batch */
97 | $batch = array_splice($processList, 0, $this->config->getNumberOfParallelProcesses());
98 | foreach ($batch as $context) {
99 | $context->getProcess()->start();
100 | $this->logger->debug(sprintf('[%s|%s] > %s', $context->getProvider()->getName(), $context->getOrigIdentifier(), $context->getProcess()->getCommandLine()));
101 | }
102 | while (count($processList) > 0 || count($batch) > 0) {
103 | foreach ($batch as $key => $context) {
104 | if (!$context->getProcess()->isRunning()) {
105 | try {
106 | $this->handleEndedProcess($context);
107 | } catch (ProcessFailedException $e) {
108 | $this->logger->error($e);
109 | }
110 | unset($batch[$key]);
111 | if (count($processList) > 0) {
112 | $newProcess = array_shift($processList);
113 | $newProcess->getProcess()->start();
114 | $this->logger->debug(sprintf('[%s|%s] - %s', $context->getProvider()->getName(), $context->getOrigIdentifier(), $context->getProcess()->getCommandLine()));
115 | $batch[] = $newProcess;
116 | }
117 | }
118 | }
119 | usleep(500); // wait for processes to finish
120 | }
121 |
122 | }
123 |
124 | public function createProcesses(): array
125 | {
126 | $processList = [];
127 | foreach ($this->storeManager->getStores() as $storeId => $store) {
128 | // Skip store if store is not active
129 | if (!$store->getIsActive()) continue;
130 | $this->emulation->startEnvironmentEmulation($storeId,\Magento\Framework\App\Area::AREA_FRONTEND, true);
131 | $this->storeManager->setCurrentStore($storeId);
132 |
133 |
134 | foreach ($this->container->getProviders() as $provider) {
135 | $processList = array_merge($processList, $this->createProcessesForProvider($provider, $store));
136 | }
137 | $this->emulation->stopEnvironmentEmulation();
138 | }
139 |
140 | return $processList;
141 | }
142 |
143 | public function createProcessesForProvider(ProviderInterface $provider, StoreInterface $store): array
144 | {
145 | $processList = [];
146 | $urls = $provider->getUrls($store);
147 | foreach ($urls as $identifier => $url) {
148 | $this->logger->info(sprintf('[%s:%s|%s] - %s', $store->getCode(), $provider->getName(), $identifier, $url));
149 | $process = $this->criticalCssService->createCriticalCssProcess(
150 | $url,
151 | $this->config->getDimensions(),
152 | $this->config->getCriticalBinary(),
153 | $this->config->getUsername(),
154 | $this->config->getPassword()
155 | );
156 | $context = $this->contextFactory->create([
157 | 'process' => $process,
158 | 'store' => $store,
159 | 'provider' => $provider,
160 | 'identifier' => $identifier
161 | ]);
162 | $processList[] = $context;
163 | }
164 | return $processList;
165 | }
166 |
167 | protected function handleEndedProcess(ProcessContext $context)
168 | {
169 | $process = $context->getProcess();
170 | if (!$process->isSuccessful()) {
171 | throw new ProcessFailedException($process);
172 | }
173 |
174 | $criticalCss = $process->getOutput();
175 | $this->storage->saveCriticalCss($context->getIdentifier(), $this->cssProcessor->process($context->getStore(), $criticalCss));
176 | $size = $this->storage->getFileSize($context->getIdentifier());
177 | if (!$size) {
178 | $size = '?';
179 | }
180 | $this->logger->info(
181 | sprintf('[%s:%s|%s] Finished: %s.css (%s bytes)',
182 | $context->getStore()->getCode(),
183 | $context->getProvider()->getName(),
184 | $context->getOrigIdentifier(),
185 | $context->getIdentifier(),
186 | $size
187 | )
188 | );
189 | }
190 |
191 | }
192 |
--------------------------------------------------------------------------------
/src/Service/Storage.php:
--------------------------------------------------------------------------------
1 | filesystem = $filesystem;
34 | $this->directory = $this->filesystem->getDirectoryWrite(DirectoryList::VAR_DIR);
35 | }
36 |
37 | /**
38 | * @throws \Magento\Framework\Exception\FileSystemException
39 | */
40 | public function clean()
41 | {
42 | $this->directory->delete(self::DIRECTORY);
43 | }
44 |
45 | /**
46 | * @param $identifier
47 | * @param $content
48 | *
49 | * @return bool
50 | * @throws \Magento\Framework\Exception\FileSystemException
51 | */
52 | public function saveCriticalCss(string $identifier, ?string $content): bool
53 | {
54 | $this->directory->create(self::DIRECTORY);
55 | $this->directory->writeFile(self::DIRECTORY.'/'.$identifier.'.css', $content);
56 | return true;
57 | }
58 |
59 | /**
60 | * @param $identifier
61 | *
62 | * @return null|string
63 | * @throws \Magento\Framework\Exception\FileSystemException
64 | */
65 | public function getCriticalCss($identifier): ?string
66 | {
67 | $file = self::DIRECTORY.'/'.$identifier.'.css';
68 | if (!$this->directory->isReadable($file)) {
69 | return null;
70 | }
71 | return $this->directory->readFile($file);
72 | }
73 |
74 | public function getFileSize($identifier): ?string
75 | {
76 | $file = self::DIRECTORY.'/'.$identifier.'.css';
77 | $stat = $this->directory->stat($file);
78 | if (!isset($stat['size'])) {
79 | return null;
80 | }
81 |
82 | return $stat['size'];
83 | }
84 | }
--------------------------------------------------------------------------------
/src/etc/adminhtml/system.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 | 1
10 |
11 | required-entry
12 | Installation instructions can be found here: https://github.com/addyosmani/critical#install
13 |
14 |
15 |
16 |
17 | 1
18 |
19 | required-entry
20 | Comma separated List, e.g.: 375x812,576x1152,768x1024,1024x768,1280x720
21 |
22 |
23 |
24 |
25 | 1
26 |
27 | validate-digits required-entry
28 |
29 |
30 |
31 |
32 | 1
33 |
34 |
35 |
36 |
37 |
38 | 1
39 |
40 |
41 |
42 |
43 |
44 |
45 |
--------------------------------------------------------------------------------
/src/etc/config.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | critical
7 | 375x812,576x1152,768x1024,1024x768,1280x720
8 | 4
9 |
10 |
11 |
12 |
--------------------------------------------------------------------------------
/src/etc/di.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | - M2Boilerplate\CriticalCss\Console\Command\GenerateCommand
6 |
7 |
8 |
9 |
10 |
11 |
12 | - M2Boilerplate\CriticalCss\Provider\DefaultProvider
13 | - M2Boilerplate\CriticalCss\Provider\CmsPageProvider
14 | - M2Boilerplate\CriticalCss\Provider\CustomerProvider
15 | - M2Boilerplate\CriticalCss\Provider\ContactProvider
16 | - M2Boilerplate\CriticalCss\Provider\CatalogSearchProvider
17 | - M2Boilerplate\CriticalCss\Provider\ProductProvider
18 | - M2Boilerplate\CriticalCss\Provider\CategoryProvider
19 |
20 |
21 |
22 |
23 |
24 |
25 | /var/log/critical-css.log
26 |
27 |
28 |
29 |
30 |
31 | - M2Boilerplate\CriticalCss\Logger\Handler\FileHandler
32 |
33 |
34 |
35 |
36 |
37 |
38 | - M2Boilerplate\CriticalCss\Logger\Handler\ConsoleHandler
39 |
40 |
41 |
42 |
43 |
44 | M2Boilerplate\CriticalCss\Logger\File
45 |
46 |
47 |
--------------------------------------------------------------------------------
/src/etc/frontend/di.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/src/etc/frontend/routes.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
--------------------------------------------------------------------------------
/src/etc/module.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
--------------------------------------------------------------------------------
/src/registration.php:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/src/view/frontend/templates/default.phtml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
--------------------------------------------------------------------------------