├── registration.php ├── etc ├── module.xml └── di.xml ├── LICENSE.txt ├── composer.json ├── Css └── PreProcessor │ └── Adapter │ └── Less │ └── Processor.php └── README.md /registration.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /etc/di.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | Baldwin\LessJsCompiler\Css\PreProcessor\Adapter\Less\Processor 9 | 10 | 11 | 12 | 13 | 14 | 15 | Magento\Framework\Shell 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2016 Baldwin bvba 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 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "baldwin/magento2-module-less-js-compiler", 3 | "description": "Allows Magento 2 to compile less files using the less nodejs compiler", 4 | "license": "MIT", 5 | "type": "magento2-module", 6 | "keywords": [ 7 | "magento", 8 | "magento2", 9 | "less", 10 | "compiler" 11 | ], 12 | "authors": [ 13 | { 14 | "name": "Pieter Hoste", 15 | "email": "pieter@baldwin.be", 16 | "role": "Problem Solver" 17 | } 18 | ], 19 | "require": { 20 | "php": "~5.5.0 || ~5.6.0 || ~7.0.0 || ~7.1.0 || ~7.2.0 || ~7.3.0 || ~7.4.0 || ~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0", 21 | "magento/framework": "^100.0.9 || ^101.0.0 || ^102.0.0 || ^103.0.0", 22 | "magento/module-developer": "^100.0.5", 23 | "symfony/process": "^2.8 || ^4.1 || ^5.0 || ^6.0" 24 | }, 25 | "require-dev": { 26 | "bamarni/composer-bin-plugin": "^1.8", 27 | "ergebnis/composer-normalize": "^2.20" 28 | }, 29 | "repositories": [ 30 | { 31 | "type": "composer", 32 | "url": "https://repo.magento.com/" 33 | } 34 | ], 35 | "autoload": { 36 | "psr-4": { 37 | "Baldwin\\LessJsCompiler\\": "" 38 | }, 39 | "files": [ 40 | "registration.php" 41 | ] 42 | }, 43 | "config": { 44 | "allow-plugins": { 45 | "bamarni/composer-bin-plugin": true, 46 | "ergebnis/composer-normalize": true, 47 | "magento/composer-dependency-version-audit-plugin": true 48 | }, 49 | "sort-packages": true 50 | }, 51 | "extra": { 52 | "bamarni-bin": { 53 | "bin-links": false, 54 | "forward-command": true 55 | } 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /Css/PreProcessor/Adapter/Less/Processor.php: -------------------------------------------------------------------------------- 1 | logger = $logger; 45 | $this->assetSource = $assetSource; 46 | $this->temporaryFile = $temporaryFile; 47 | $this->shell = $shell; 48 | $this->productMetadata = $productMetadata; 49 | $this->filesystem = $filesystem; 50 | $this->directoryList = $directoryList; 51 | $this->scopeConfig = $scopeConfig; 52 | } 53 | 54 | /** 55 | * @throws ContentProcessorException 56 | */ 57 | public function processContent(AssetFile $asset) 58 | { 59 | $path = $asset->getPath(); 60 | try { 61 | $content = (string) $this->assetSource->getContent($asset); 62 | 63 | if (trim($content) === '') { 64 | throw new ContentProcessorException( 65 | new Phrase('Compilation from source: LESS file is empty: ' . $path) 66 | ); 67 | } 68 | 69 | $tmpFilePath = $this->temporaryFile->createFile($path, $content); 70 | 71 | $content = $this->compileFile($tmpFilePath, $path); 72 | 73 | if (trim($content) === '') { 74 | throw new ContentProcessorException( 75 | new Phrase('Compilation from source: CSS is empty from LESS file: ' . $path) 76 | ); 77 | } else { 78 | return $content; 79 | } 80 | } catch (\Exception $e) { 81 | $previousExceptionMessage = $e->getPrevious() !== null ? (PHP_EOL . $e->getPrevious()->getMessage()) : ''; 82 | $errorMessage = $e->getMessage() . $previousExceptionMessage; 83 | 84 | $this->outputErrorMessage($errorMessage, $asset); 85 | throw new ContentProcessorException(new Phrase($errorMessage)); 86 | } 87 | } 88 | 89 | /** 90 | * Compiles less file and returns output as a string 91 | * 92 | * @param string $filePath 93 | * @param string $assetPath 94 | * 95 | * @return string 96 | * 97 | * @throws NotFoundException if the nodejs or less compiler binaries can't be found 98 | * @throws LocalizedException if the shell command returns non-zero exit code 99 | */ 100 | protected function compileFile($filePath, $assetPath) 101 | { 102 | $nodeCmdArgs = $this->getNodeArgsAsArray(); 103 | $lessCmdArgs = $this->getCompilerArgsAsArray(); 104 | 105 | $command = []; 106 | $command[] = $this->getPathToNodeBinary(); 107 | $command = array_merge($command, $nodeCmdArgs); 108 | $command[] = $this->getPathToLessCompiler(); 109 | $command = array_merge($command, $lessCmdArgs); 110 | $command[] = $filePath; 111 | 112 | $process = $this->getProcess($command); 113 | 114 | try { 115 | $process->mustRun(); 116 | } catch (ProcessFailedException $ex) { 117 | throw new ContentProcessorException( 118 | new Phrase('LESS compilation process failed with: %1', [$ex->getMessage()]) 119 | ); 120 | } 121 | 122 | $errorOutput = $process->getErrorOutput(); 123 | 124 | if ($errorOutput !== '') { 125 | $errorMessage = new Phrase('LESS compilation ran into some problems: %1', [$errorOutput]); 126 | if ($this->isThrowOnErrorEnabled()) { 127 | throw new ContentProcessorException($errorMessage); 128 | } else { 129 | $this->logger->error($errorMessage, [ 130 | 'asset' => $assetPath, 131 | 'file' => $filePath, 132 | ]); 133 | } 134 | } 135 | 136 | return $process->getOutput(); 137 | } 138 | 139 | /** 140 | * Get all arguments which will be used in the cli call to the lessc compiler 141 | * 142 | * @return string 143 | */ 144 | protected function getCompilerArgsAsString() 145 | { 146 | $args = $this->getConfigValueFromPath('dev/less_js_compiler/less_arguments'); 147 | if ($args === null) { 148 | // default supplied args 149 | $args = '--no-color'; // for example: --ie-compat --compress --math="always", ... 150 | } 151 | 152 | return $args; 153 | } 154 | 155 | /** 156 | * Get all arguments which will be used in the cli call to the lessc compiler 157 | * 158 | * @return array 159 | */ 160 | protected function getCompilerArgsAsArray() 161 | { 162 | return explode(' ', $this->getCompilerArgsAsString()); 163 | } 164 | 165 | /** 166 | * Get the path to the lessc nodejs compiler 167 | * 168 | * @return string 169 | * 170 | * @throws NotFoundException 171 | */ 172 | protected function getPathToLessCompiler() 173 | { 174 | $rootDir = $this->directoryList->getRoot(); 175 | 176 | $lesscLocations = [ 177 | $rootDir . '/node_modules/.bin/lessc', 178 | $rootDir . '/node_modules/less/bin/lessc', 179 | ]; 180 | 181 | foreach ($lesscLocations as $lesscLocation) { 182 | if ($this->filesystem->fileExists($lesscLocation)) { 183 | return $lesscLocation; 184 | } 185 | } 186 | 187 | throw new NotFoundException(__('Less compiler not found, make sure the node package "less" is installed')); 188 | } 189 | 190 | /** 191 | * Get all arguments which will be used in the cli call with the nodejs binary 192 | * 193 | * @return string 194 | */ 195 | protected function getNodeArgsAsString() 196 | { 197 | $args = $this->getConfigValueFromPath('dev/less_js_compiler/node_arguments'); 198 | if ($args === null) { 199 | // default supplied args 200 | $args = '--no-deprecation'; // squelch warnings about deprecated modules being used 201 | } 202 | 203 | return $args; 204 | } 205 | 206 | /** 207 | * Get all arguments which will be used in the cli call with the nodejs binary 208 | * 209 | * @return array 210 | */ 211 | protected function getNodeArgsAsArray() 212 | { 213 | return explode(' ', $this->getNodeArgsAsString()); 214 | } 215 | 216 | /** 217 | * Get the path to the nodejs binary 218 | * 219 | * @return string 220 | * 221 | * @throws NotFoundException 222 | */ 223 | protected function getPathToNodeBinary() 224 | { 225 | $nodeJsBinary = 'node'; 226 | 227 | try { 228 | $cmd = 'command -v %s'; 229 | $nodeJsBinary = $this->shell->execute($cmd, [$nodeJsBinary]); 230 | } catch (LocalizedException $ex) { 231 | throw new NotFoundException(__( 232 | "Node.js binary '$nodeJsBinary' not found, " 233 | . 'make sure it exists in the PATH of the user executing this command' 234 | )); 235 | } 236 | 237 | return $nodeJsBinary; 238 | } 239 | 240 | /** 241 | * In Magento 2.0.x and 2.1.x simply throwing a ContentProcessorException didn't output the error to a log file 242 | * So for those versions, we still need to output the error message ourselves to the logger 243 | * In Magento 2.2.x this was changed and the thrown ContentProcessorException is outputted to a log file, 244 | * so in those versions it already happens "automatically" 245 | * See MAGETWO-54937 - https://github.com/magento/magento2/commit/19ccc61e4208ce570fa040f9ccfdf972da99f7de#diff-e4bf695b706792374f33d6eca9bd9006L345 246 | * 247 | * @param string $errorMessage 248 | * 249 | * @return void 250 | */ 251 | protected function outputErrorMessage($errorMessage, AssetFile $file) 252 | { 253 | $version = $this->productMetadata->getVersion(); 254 | if (version_compare($version, '2.2.0', '>=') === true) { 255 | return; 256 | } 257 | 258 | $errorMessage = __('Compilation from source: ') 259 | . $file->getSourceFile() 260 | . PHP_EOL . $errorMessage; 261 | 262 | $this->logger->critical($errorMessage); 263 | } 264 | 265 | /** 266 | * @param string $path 267 | * 268 | * @return ?string 269 | */ 270 | private function getConfigValueFromPath($path) 271 | { 272 | $value = $this->scopeConfig->getValue($path); 273 | if (is_string($value)) { 274 | return $value; 275 | } 276 | 277 | return null; 278 | } 279 | 280 | /** 281 | * @param string $path 282 | * 283 | * @return bool 284 | */ 285 | private function getConfigValueFromPathAsBool($path) 286 | { 287 | return filter_var($this->scopeConfig->getValue($path), FILTER_VALIDATE_BOOLEAN); 288 | } 289 | 290 | /** 291 | * @param array $commandArgs 292 | * 293 | * @return Process 294 | */ 295 | private function getProcess(array $commandArgs) 296 | { 297 | // We can't use Process class in symfony/process 2.x because it takes a string and not an array 298 | // therefore we use ProcessBuilder, which exists only in symfony/process >= 2.1 < 4.0 299 | if (class_exists(ProcessBuilder::class)) { 300 | return (new ProcessBuilder($commandArgs))->getProcess(); 301 | } 302 | 303 | return new Process($commandArgs); 304 | } 305 | 306 | /** 307 | * @return bool 308 | */ 309 | private function isThrowOnErrorEnabled() 310 | { 311 | return $this->getConfigValueFromPathAsBool('dev/less_js_compiler/throw_on_error'); 312 | } 313 | } 314 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Magento 2 module which allows compiling less files using the less.js compiler 2 | 3 | ## Description 4 | 5 | This module was built out of frustration about the slow deployments of static assets to a production environment while running `bin/magento setup:static-content:deploy`. In particular this module tries to tackle the slowness which comes with compiling less files using the [less.php](https://github.com/oyejorge/less.php) library, which Magento 2 uses by default. 6 | This module provides a solution by using the [original less.js compiler](https://github.com/less/less.js) which was written in javascript and is executed through node.js 7 | We have [benchmarked](#benchmarks) the difference between the less.php and less.js compilers, and the less.js compiler is somewhere between 1.5 and 3 times as fast as the less.php compiler, although it depends on your PHP version. If you run PHP 5.x the performance increase will be much higher, PHP 7.x is actually quite fast by itself, but the nodejs version still beats it. 8 | 9 | **Update**: Since Magento 2.3.0, it seems like the performance differences between less.php and less.js are not very big anymore. I have the suspicion this might have something to do with newer [releases of the less.php](https://github.com/oyejorge/less.php/releases) library. Which got released somewhere around the period when Magento 2.2.3 & 2.1.12 were released. So it's possible that the performance difference for Magento 2.2.x and 2.1.x might also be less significant then how it's displayed [below](#benchmarks) if you upgrade the less.php library to the latest version (v1.7.0.14 at the time of writing). 10 | This is a bit of speculation, since unfortunately I didn't keep track of the version of the less.php library which I've used in the benchmarks, so I'm not sure about this statement. 11 | 12 | Since Magento 2.4.0 (or newer PHP versions?), the performance differences seems to have changed in favor of less.php. But you might still want to use this module so that the outputted css is the same between your local development environment and a production environment. 13 | 14 | ## Requirements 15 | 16 | You'll need at least Magento 2.0.7. We didn't bother with testing this module on older versions. 17 | If you want to use this module, you'll need to be able to install [node.js](https://nodejs.org/) and [npm](https://www.npmjs.com/) on the machine(s) on which you will build your static assets. 18 | You'll also need to make sure that the `node` binary is available in the `$PATH` environment variable of the user which will build the static assets. 19 | You'll also need [composer](https://getcomposer.org/) to add this module to your Magento 2 shop. 20 | 21 | ## Installation 22 | 23 | First, we recommend you to install the less compiler itself, and save it into your `package.json` file as a production dependency: 24 | 25 | ```sh 26 | npm install --save less@3.13.1 27 | ``` 28 | 29 | > Watch out: from Magento 2.1 onwards, the `package.json` file is being renamed to `package.json.sample` to enable you to have your own nodejs dependencies without Magento overwriting this every time with its own version each time you update Magento. So if you use Magento >= 2.1 make sure you copy the `package.json.sample` file to `package.json` before running the above command if you want to work with Magento's `grunt` setup. 30 | 31 | Then run a shrinkwrap, so the version of less (and its dependencies) are fixed, this will produce a file `npm-shrinkwrap.json` with the exact versions of all your nodejs production dependencies and their own dependencies, so you can be sure it will use those exact versions when you install this on another machine. 32 | 33 | ```sh 34 | npm shrinkwrap 35 | ``` 36 | 37 | And make sure you add these two files to your version control system. 38 | 39 | > For an analogy with composer, you can compare the `package.json` file with `composer.json`, and `npm-shrinkwrap.json` with `composer.lock` 40 | 41 | Now install this module 42 | 43 | ```sh 44 | composer require baldwin/magento2-module-less-js-compiler 45 | ``` 46 | 47 | And enable it in Magento 48 | 49 | ```sh 50 | bin/magento module:enable Baldwin_LessJsCompiler 51 | bin/magento setup:upgrade 52 | ``` 53 | 54 | As the last step, in your deploy scripts, make sure you call `npm install --production`. You should execute this somewhere between `composer install` and `bin/magento setup:static-content:deploy` 55 | 56 | ## Configuration 57 | 58 | You have the opportunity to configure the arguments we send to the `lessc` and `node` commands. 59 | The defaults are: 60 | 61 | - `lessc --no-color` 62 | - `node --no-deprecation` 63 | 64 | If you want to override the default arguments, you can do this by modifying your `app/etc/config.php` or `app/etc/env.php` file and adding something like this: 65 | 66 | ```php 67 | 'system' => [ 68 | 'default' => [ 69 | 'dev' => [ 70 | 'less_js_compiler' => [ 71 | 'less_arguments' => '--no-color --ie-compat', 72 | 'node_arguments' => '--no-deprecation --no-warnings', 73 | ] 74 | ] 75 | ] 76 | ] 77 | ``` 78 | 79 | You can also enable throwing an exception when the less compilation runs into warnings/errors, by default this is disabled and you should find the problems logged in your `var/log/system.log` file. 80 | But if you want to enable throwing an exception during the compilation process, you can by configuring this in your `app/etc/config.php` or `app/etc/env.php` file: 81 | 82 | ```php 83 | 'system' => [ 84 | 'default' => [ 85 | 'dev' => [ 86 | 'less_js_compiler' => [ 87 | 'throw_on_error' => true, 88 | ] 89 | ] 90 | ] 91 | ] 92 | ``` 93 | 94 | ## Investigating less compilation errors 95 | 96 | When your `.less` files have a syntax error or contain something which doesn't allow it to compile properly, please have a look at the `var/log/system.log` file, it will contain some output about what caused the problem. 97 | 98 | ## Remarks 99 | 100 | 1. Installing this module will effectively replace the default less compiler from Magento2. If you want to go back to the default less compiler, you need to disable this module or uninstall it. 101 | 2. This module expects the less compiler to exist in `{MAGENTO_ROOT_DIR}/node_modules/.bin/lessc`, this is a hard coded path which is being used in the module. The compiler will end up there if you follow the installation steps above, but if for some reason you prefer to install your nodejs modules someplace else, then this module won't work. If somebody actually has this problem and wants us to make this configurable, please let me know! 102 | 3. The default less processor in Magento 2 passes an option to the less compiler, which says it should [compress the resulting css file](https://github.com/magento/magento2/blob/6a40b41f6281c7d405cd78029d6becab1d837c87/lib/internal/Magento/Framework/Css/PreProcessor/Adapter/Less/Processor.php#L73). In this module, we have chosen not to do so, as we believe this isn't a task to be executed while compiling less files. It should be done further down the line, like for example during the minification phase. If someone disagrees with this, please let me know, I'm open to discussion about this. 103 | 4. This module was tested against Magento versions 2.0.7, 2.1.x, 2.2.x, 2.3.x, 2.4.x 104 | 105 | ## Benchmarks 106 | 107 | ### Intro 108 | 109 | This is by no means very professionaly conducted, but here are some tests performed on some Magento 2 shops we are working on. 110 | We disabled parallelism to make the comparison between different Magento versions easier to understand. 111 | We only measured the duration of the `bin/magento setup:static-content:deploy` command, xdebug is disabled as it causes a massive slowdown, and before every run we make sure all caches are clean, by running: 112 | 113 | ```sh 114 | rm -R pub/static/* var/cache/* var/view_preprocessed/* var/generation/* var/di/* var/page_cache/* generated/* 115 | ``` 116 | 117 | ### Machines 118 | 119 | - The _older_ server is a server which is in constant use and has older software installed on it. 120 | - The _newer_ server is a new server which currently receives no traffic and has al the sparkly new software versions installed (at the time of writing). 121 | - The _older-local_ machine is a 2011 Macbook Pro (HDD has been upgraded to SSD, no vagrant or docker, just native software using Macports) 122 | - The _newer-local_ machine is a 2017 Macbook Pro (native software using Homebrew or Macports) 123 | 124 | ### Results 125 | 126 | | magento | themes | locales | strategy | machine | php | nodejs | less.php | less.js | 127 | |:---------:|:------:|:-------:|:--------:|:-----------:|:------:|:-------:|:---------:|:---------:| 128 | | 2.0.7 | 5 | 1 | - | older | 5.5.30 | 0.10.33 | 8m22s | **3m14s** | 129 | | 2.0.7 | 5 | 2 | - | older | 5.5.30 | 0.10.33 | 16m24s | **6m11s** | 130 | | 2.0.7 | 4 | 3 | - | older | 5.5.30 | 0.10.33 | 18m44s | **6m26s** | 131 | | 2.0.7 | 5 | 1 | - | newer | 7.0.7 | 4.2.6 | 1m30s | **1m00s** | 132 | | 2.0.7 | 5 | 2 | - | newer | 7.0.7 | 4.2.6 | 3m06s | **1m51s** | 133 | | 2.0.7 | 5 | 3 | - | newer | 7.0.7 | 4.2.6 | 4m52s | **2m52s** | 134 | | 2.1.0-rc1 | 3 | 1 | - | older-local | 5.5.36 | 4.4.3 | 4m39s | **2m01s** | 135 | | 2.1.0-rc1 | 3 | 1 | - | older-local | 5.6.22 | 4.4.3 | 4m17s | **2m02s** | 136 | | 2.1.0-rc1 | 3 | 1 | - | older-local | 7.0.7 | 4.4.3 | 2m01s | **1m26s** | 137 | | 2.1.9 | 3 | 1 | - | newer-local | 7.0.23 | 4.8.4 | 2m35s | **1m14s** | 138 | | 2.1.9 | 3 | 2 | - | newer-local | 7.0.23 | 4.8.4 | 2m44s | **1m05s** | 139 | | 2.2.0 | 3 | 1 | standard | newer-local | 7.0.23 | 4.8.4 | 1m42s | **0m38s** | 140 | | 2.2.0 | 3 | 1 | quick* | newer-local | 7.0.23 | 4.8.4 | 1m42s | **0m38s** | 141 | | 2.2.0 | 3 | 1 | compact | newer-local | 7.0.23 | 4.8.4 | 1m42s | **0m38s** | 142 | | 2.2.0 | 3 | 2 | standard | newer-local | 7.0.23 | 4.8.4 | 3m30s | **1m05s** | 143 | | 2.2.0 | 3 | 2 | quick* | newer-local | 7.0.23 | 4.8.4 | 3m29s | **1m07s** | 144 | | 2.2.0 | 3 | 2 | compact | newer-local | 7.0.23 | 4.8.4 | 1m52s | **0m40s** | 145 | | 2.3.0 | 3 | 2 | standard | newer-local | 7.2.12 | 8.12.0 | 1m35s | **1m26s** | 146 | | 2.3.0 | 3 | 2 | quick* | newer-local | 7.2.12 | 8.12.0 | 1m35s | **1m28s** | 147 | | 2.3.0 | 3 | 2 | compact | newer-local | 7.2.12 | 8.12.0 | 0m43s | **0m42s** | 148 | | 2.4.0 | 3 | 2 | standard | newer-local | 7.4.4 | 8.17.0 | **0m49s** | 0m58s | 149 | | 2.4.0 | 3 | 2 | quick* | newer-local | 7.4.4 | 8.17.0 | **0m49s** | 1m03s | 150 | | 2.4.0 | 3 | 2 | compact | newer-local | 7.4.4 | 8.17.0 | **0m31s** | 0m37s | 151 | 152 | 153 | _*_ The [quick strategy](http://devdocs.magento.com/guides/v2.4/config-guide/cli/config-cli-subcommands-static-deploy-strategies.html) deployment is [currently bugged in Magento 2.2.x and 2.3.x and 2.4.x](https://github.com/magento/magento2/issues/10674) and behaves the same as the standard strategy 154 | --------------------------------------------------------------------------------