├── Block
└── Adminhtml
│ └── System
│ ├── ExcludeControllers.php
│ ├── ExcludePaths.php
│ └── ExcludeUrlPattern.php
├── Helper
└── Config.php
├── LICENSE
├── Model
├── Config
│ └── Backend
│ │ └── Serialized
│ │ └── ArraySerialized.php
├── Minify
│ ├── MinifyJs.php
│ └── MinifyJsInterface.php
├── MoveJsToFooter.php
├── MoveJsToFooterInterface.php
└── PassesValidator
│ ├── EntityList.php
│ ├── ValidateSkipper.php
│ ├── ValidatorInterface.php
│ └── Validators
│ ├── SkipGoogleTagManager.php
│ ├── SkipScriptByAttribute.php
│ ├── SkipScriptsByController.php
│ ├── SkipScriptsByPath.php
│ └── SkipScriptsByURLPattern.php
├── Plugin
└── MoveJsToFooter.php
├── README.md
├── Test
└── Unit
│ └── Model
│ ├── MoveJsToFooterTest.php
│ └── PassesValidator
│ └── Validators
│ ├── SkipGoogleTagManagerTest.php
│ ├── SkipScriptByAttributeTest.php
│ ├── SkipScriptsByControllerTest.php
│ ├── SkipScriptsByPathTest.php
│ └── SkipScriptsByURLPatternTest.php
├── composer.json
├── etc
├── acl.xml
├── adminhtml
│ └── system.xml
├── config.xml
├── di.xml
├── frontend
│ └── di.xml
└── module.xml
└── registration.php
/Block/Adminhtml/System/ExcludeControllers.php:
--------------------------------------------------------------------------------
1 |
5 | * @github:
6 | */
7 |
8 | declare(strict_types=1);
9 |
10 | namespace Hryvinskyi\DeferJs\Block\Adminhtml\System;
11 |
12 | use Magento\Config\Block\System\Config\Form\Field\FieldArray\AbstractFieldArray;
13 |
14 | /**
15 | * Class ExcludeControllers
16 | */
17 | class ExcludeControllers extends AbstractFieldArray
18 | {
19 | /**
20 | * @return void
21 | */
22 | protected function _construct()
23 | {
24 | $this->addColumn('controller', [
25 | 'label' => __('Expression'),
26 | ]);
27 |
28 | $this->_addAfter = false;
29 | $this->_addButtonLabel = __('Add');
30 |
31 | parent::_construct();
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/Block/Adminhtml/System/ExcludePaths.php:
--------------------------------------------------------------------------------
1 |
5 | * @github:
6 | */
7 |
8 | declare(strict_types=1);
9 |
10 | namespace Hryvinskyi\DeferJs\Block\Adminhtml\System;
11 |
12 | use Magento\Config\Block\System\Config\Form\Field\FieldArray\AbstractFieldArray;
13 |
14 | /**
15 | * Class ExcludeControllers
16 | */
17 | class ExcludePaths extends AbstractFieldArray
18 | {
19 | /**
20 | * @return void
21 | */
22 | protected function _construct()
23 | {
24 | $this->addColumn('path', [
25 | 'label' => __('Expression'),
26 | ]);
27 |
28 | $this->_addAfter = false;
29 | $this->_addButtonLabel = __('Add');
30 |
31 | parent::_construct();
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/Block/Adminhtml/System/ExcludeUrlPattern.php:
--------------------------------------------------------------------------------
1 |
5 | * @github:
6 | */
7 |
8 | declare(strict_types=1);
9 |
10 | namespace Hryvinskyi\DeferJs\Block\Adminhtml\System;
11 |
12 | use Magento\Config\Block\System\Config\Form\Field\FieldArray\AbstractFieldArray;
13 |
14 | /**
15 | * Class ExcludeUrlPattern
16 | */
17 | class ExcludeUrlPattern extends AbstractFieldArray
18 | {
19 | /**
20 | * @return void
21 | */
22 | protected function _construct()
23 | {
24 | $this->addColumn('pattern', [
25 | 'label' => __('Expression')
26 | ]);
27 |
28 | $this->_addAfter = false;
29 | $this->_addButtonLabel = __('Add');
30 |
31 | parent::_construct();
32 | }
33 | }
--------------------------------------------------------------------------------
/Helper/Config.php:
--------------------------------------------------------------------------------
1 |
5 | * @github:
6 | */
7 |
8 | declare(strict_types=1);
9 |
10 | namespace Hryvinskyi\DeferJs\Helper;
11 |
12 | use Magento\Framework\App\Helper\AbstractHelper;
13 | use Magento\Store\Model\ScopeInterface;
14 |
15 | /**
16 | * Class Config
17 | */
18 | class Config extends AbstractHelper
19 | {
20 | /**
21 | * Configuration paths
22 | */
23 | const XML_HRYVINSKYI_DEFER_JS_GENERAL_ENABLED = 'hryvinskyi_defer_js/general/enabled';
24 | const XML_HRYVINSKYI_DEFER_JS_DISABLE_ATTRIBUTE = 'hryvinskyi_defer_js/general/disable_attribute';
25 | const XML_HRYVINSKYI_DEFER_JS_MINIFY_BODY_SCRIPTS = 'hryvinskyi_defer_js/general/minify_body_scripts';
26 | const XML_HRYVINSKYI_DEFER_JS_OPTIMIZE_X_MAGENTO_INIT_SCRIPTS
27 | = 'hryvinskyi_defer_js/general/optimize_x_magento_init_scripts';
28 | const XML_HRYVINSKYI_DEFER_JS_EXCLUDE_CONTROLLERS = 'hryvinskyi_defer_js/general/exclude_controllers';
29 | const XML_HRYVINSKYI_DEFER_JS_EXCLUDE_PATHS = 'hryvinskyi_defer_js/general/exclude_paths';
30 | const XML_HRYVINSKYI_DEFER_JS_EXCLUDE_URL_PATTERN = 'hryvinskyi_defer_js/general/exclude_url_pattern';
31 |
32 | /**
33 | * @param string $scopeType
34 | * @param null|string $scopeCode
35 | *
36 | * @return bool
37 | */
38 | public function isEnabled(
39 | string $scopeType = ScopeInterface::SCOPE_STORE,
40 | $scopeCode = null
41 | ): bool {
42 | return $this->scopeConfig->isSetFlag(
43 | self::XML_HRYVINSKYI_DEFER_JS_GENERAL_ENABLED,
44 | $scopeType,
45 | $scopeCode
46 | );
47 | }
48 |
49 | /**
50 | * @param string $scopeType
51 | * @param null|string $scopeCode
52 | *
53 | * @return string
54 | */
55 | public function getDisableAttribute(
56 | string $scopeType = ScopeInterface::SCOPE_STORE,
57 | $scopeCode = null
58 | ): string {
59 | return (string)$this->scopeConfig->getValue(
60 | self::XML_HRYVINSKYI_DEFER_JS_DISABLE_ATTRIBUTE,
61 | $scopeType,
62 | $scopeCode
63 | );
64 | }
65 |
66 | /**
67 | * @param string $scopeType
68 | * @param null|string $scopeCode
69 | *
70 | * @return bool
71 | */
72 | public function isMinifyBodyScript(
73 | string $scopeType = ScopeInterface::SCOPE_STORE,
74 | $scopeCode = null
75 | ): bool {
76 | return $this->scopeConfig->isSetFlag(
77 | self::XML_HRYVINSKYI_DEFER_JS_MINIFY_BODY_SCRIPTS,
78 | $scopeType,
79 | $scopeCode
80 | );
81 | }
82 |
83 | /**
84 | * @param string $scopeType
85 | * @param null|string $scopeCode
86 | *
87 | * @return bool
88 | */
89 | public function isOptimizeXMagentoInitScripts(
90 | string $scopeType = ScopeInterface::SCOPE_STORE,
91 | $scopeCode = null
92 | ): bool {
93 | return $this->scopeConfig->isSetFlag(
94 | self::XML_HRYVINSKYI_DEFER_JS_OPTIMIZE_X_MAGENTO_INIT_SCRIPTS,
95 | $scopeType,
96 | $scopeCode
97 | );
98 | }
99 |
100 | /**
101 | * Return Excluded Controllers
102 | *
103 | * @param string $scopeType
104 | * @param null|string $scopeCode
105 | *
106 | * @return string
107 | */
108 | public function getExcludeControllers(
109 | string $scopeType = ScopeInterface::SCOPE_STORE,
110 | $scopeCode = null
111 | ): string {
112 | return (string)$this->scopeConfig->getValue(
113 | self::XML_HRYVINSKYI_DEFER_JS_EXCLUDE_CONTROLLERS,
114 | $scopeType,
115 | $scopeCode
116 | );
117 | }
118 |
119 | /**
120 | * Return Excluded Paths
121 | *
122 | * @param string $scopeType
123 | * @param null|string $scopeCode
124 | *
125 | * @return string
126 | */
127 | public function getExcludePaths(
128 | string $scopeType = ScopeInterface::SCOPE_STORE,
129 | $scopeCode = null
130 | ): string {
131 | return (string)$this->scopeConfig->getValue(
132 | self::XML_HRYVINSKYI_DEFER_JS_EXCLUDE_PATHS,
133 | $scopeType,
134 | $scopeCode
135 | );
136 | }
137 |
138 | /**
139 | * Return Excluded URL pattern
140 | *
141 | * @param string $scopeType
142 | * @param null|string $scopeCode
143 | *
144 | * @return string
145 | */
146 | public function getExcludeUrlPattern(
147 | string $scopeType = ScopeInterface::SCOPE_STORE,
148 | $scopeCode = null
149 | ): string {
150 | return (string)$this->scopeConfig->getValue(
151 | self::XML_HRYVINSKYI_DEFER_JS_EXCLUDE_URL_PATTERN,
152 | $scopeType,
153 | $scopeCode
154 | );
155 | }
156 | }
157 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | The MIT License (MIT)
2 |
3 | Copyright (c) 2016
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, 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,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/Model/Config/Backend/Serialized/ArraySerialized.php:
--------------------------------------------------------------------------------
1 |
5 | * @github:
6 | */
7 |
8 | declare(strict_types=1);
9 |
10 | namespace Hryvinskyi\DeferJs\Model\Config\Backend\Serialized;
11 |
12 | use Magento\Config\Model\Config\Backend\Serialized\ArraySerialized as BaseArraySerialized;
13 |
14 | /**
15 | * Class ArraySerialized
16 | */
17 | class ArraySerialized extends BaseArraySerialized
18 | {
19 |
20 | }
21 |
--------------------------------------------------------------------------------
/Model/Minify/MinifyJs.php:
--------------------------------------------------------------------------------
1 |
5 | * @github:
6 | */
7 |
8 | declare(strict_types=1);
9 |
10 | namespace Hryvinskyi\DeferJs\Model\Minify;
11 |
12 | use JShrink\Minifier;
13 |
14 | /**
15 | * Class MinifyJs
16 | */
17 | class MinifyJs implements MinifyJsInterface
18 | {
19 | /**
20 | * @param array $scripts
21 | *
22 | * @return array
23 | * @throws \Exception
24 | */
25 | public function execute(array $scripts): array
26 | {
27 | foreach ($scripts as &$script) {
28 | try {
29 | $script = Minifier::minify($script);
30 | } catch (\Exception $exception) {
31 | // Remove comments
32 | $script = preg_replace("/[^:']\/\/.*/", '', $script);
33 | }
34 |
35 | $script = str_replace('src=', ' src=', $script);
36 | $script = str_replace('type=', ' type=', $script);
37 | $script = preg_replace('!\s+!', ' ', $script);
38 | }
39 |
40 | return $scripts;
41 | }
42 | }
43 |
--------------------------------------------------------------------------------
/Model/Minify/MinifyJsInterface.php:
--------------------------------------------------------------------------------
1 |
5 | * @github:
6 | */
7 |
8 | declare(strict_types=1);
9 |
10 | namespace Hryvinskyi\DeferJs\Model\Minify;
11 |
12 | /**
13 | * Class MinifyJsInterface
14 | */
15 | interface MinifyJsInterface
16 | {
17 | /**
18 | * @param array $scripts
19 | *
20 | * @return array
21 | */
22 | public function execute(array $scripts): array;
23 | }
24 |
--------------------------------------------------------------------------------
/Model/MoveJsToFooter.php:
--------------------------------------------------------------------------------
1 |
5 | * GitHub: https://github.com/hryvinskyi
6 | */
7 |
8 | declare(strict_types=1);
9 |
10 | namespace Hryvinskyi\DeferJs\Model;
11 |
12 | use Hryvinskyi\Base\Helper\ArrayHelper;
13 | use Hryvinskyi\Base\Helper\Json;
14 | use Hryvinskyi\DeferJs\Helper\Config;
15 | use Hryvinskyi\DeferJs\Model\Minify\MinifyJsInterface;
16 | use Hryvinskyi\DeferJs\Model\PassesValidator\ValidateSkipper;
17 | use Magento\Framework\App\Request\Http as RequestHttp;
18 | use Magento\Framework\App\RequestInterface;
19 | use Magento\Framework\App\Response\Http;
20 |
21 | class MoveJsToFooter implements MoveJsToFooterInterface
22 | {
23 | private Config $config;
24 | private MinifyJsInterface $minifyJs;
25 | private ValidateSkipper $validateSkipper;
26 | private RequestInterface $request;
27 |
28 | public function __construct(
29 | Config $config,
30 | MinifyJsInterface $minifyJs,
31 | ValidateSkipper $validateSkipper,
32 | RequestInterface $request
33 | ) {
34 | $this->config = $config;
35 | $this->minifyJs = $minifyJs;
36 | $this->validateSkipper = $validateSkipper;
37 | $this->request = $request;
38 | }
39 |
40 | /**
41 | * Executes the JS to footer movement process on the HTTP response
42 | *
43 | * @param Http $http The HTTP response object to process
44 | * @return void
45 | */
46 | public function execute(Http $http): void
47 | {
48 | if (!$this->request instanceof RequestHttp || $this->request->isAjax()) {
49 | return;
50 | }
51 |
52 | $html = $http->getBody();
53 |
54 | // Process all scripts in a single pass
55 | $processedHtml = $this->processScripts($html, $http);
56 |
57 | if ($processedHtml !== $html) {
58 | $http->setBody($processedHtml);
59 | }
60 | }
61 |
62 | /**
63 | * Processes script tags in the HTML to move them to footer
64 | *
65 | * @param string $html The original HTML content
66 | * @param Http $http The HTTP response object
67 | * @return string The processed HTML with scripts moved to footer
68 | */
69 | private function processScripts(string $html, Http $http): string
70 | {
71 | // Find all script tags
72 | preg_match_all('//is', $html, $matches, PREG_OFFSET_CAPTURE);
73 |
74 | if (empty($matches[0])) {
75 | return $html;
76 | }
77 |
78 | // Prepare collections
79 | $scriptsToMove = [];
80 | $jsonScripts = [];
81 | $positions = [];
82 |
83 | // Process matches in reverse order to avoid offset issues
84 | foreach (array_reverse($matches[0]) as [$script, $offset]) {
85 | if ($this->shouldExtractJson($script)) {
86 | $jsonScripts[] = strip_tags($script);
87 | $positions[] = [$offset, strlen($script)];
88 | continue;
89 | }
90 |
91 | if (!$this->validateSkipper->execute($script, $http)) {
92 | $scriptsToMove[] = $script;
93 | $positions[] = [$offset, strlen($script)];
94 | }
95 | }
96 |
97 | // If nothing to move, return original
98 | if (empty($scriptsToMove) && empty($jsonScripts)) {
99 | return $html;
100 | }
101 |
102 | // Prepare footer content
103 | $footerContent = '';
104 |
105 | // Add merged JSON scripts if any
106 | if (!empty($jsonScripts)) {
107 | $mergedJson = $this->mergeJsons($jsonScripts);
108 | if (!empty($mergedJson)) {
109 | $footerContent .= $mergedJson;
110 | }
111 | }
112 |
113 | // Add scripts if any
114 | if (!empty($scriptsToMove)) {
115 | if ($this->config->isMinifyBodyScript()) {
116 | $footerContent .= implode(PHP_EOL, $this->minifyJs->execute(array_reverse($scriptsToMove)));
117 | } else {
118 | $footerContent .= implode(PHP_EOL, array_reverse($scriptsToMove));
119 | }
120 | }
121 |
122 | // Remove scripts from original positions
123 | foreach ($positions as [$pos, $len]) {
124 | $html = substr_replace($html, '', $pos, $len);
125 | }
126 |
127 | // Insert scripts before closing body tag
128 | return $this->insertBeforeBodyEnd($html, $footerContent);
129 | }
130 |
131 | /**
132 | * Determines if a script should be extracted as JSON
133 | *
134 | * @param string $script The script tag to check
135 | * @return bool True if the script should be extracted as JSON
136 | */
137 | private function shouldExtractJson(string $script): bool
138 | {
139 | return $this->config->isOptimizeXMagentoInitScripts() &&
140 | preg_match('/'
161 | : '';
162 | }
163 |
164 | /**
165 | * Inserts content before the closing body tag
166 | *
167 | * @param string $html The HTML to modify
168 | * @param string $content The content to insert before the body end tag
169 | * @return string The modified HTML
170 | */
171 | private function insertBeforeBodyEnd(string $html, string $content): string
172 | {
173 | $bodyEndPos = stripos($html, '