├── .gitignore ├── dev └── tools │ ├── .babelrc │ ├── gulp │ ├── config │ │ └── browser-sync.json │ ├── tasks │ │ ├── clean.js │ │ ├── deploy.js │ │ ├── exec.js │ │ ├── scss.js │ │ ├── less.js │ │ └── watch.js │ └── utils │ │ ├── task-register.js │ │ ├── sync.js │ │ ├── theme-registry.js │ │ └── asset-deployer.js │ ├── package.json │ └── gulpfile.babel.js ├── Test └── Unit │ ├── Adapter │ └── Scss │ │ ├── _files │ │ ├── variables.min.css │ │ ├── variables.css │ │ ├── _import-partial.scss │ │ ├── mixins.min.css │ │ ├── import.min.css │ │ ├── mixins.css │ │ ├── import.scss │ │ ├── operators.min.css │ │ ├── nesting.min.css │ │ ├── variables.scss │ │ ├── extend.min.css │ │ ├── import.css │ │ ├── operators.css │ │ ├── mixins.scss │ │ ├── nesting.css │ │ ├── operators.scss │ │ ├── extend.css │ │ ├── nesting.scss │ │ └── extend.scss │ │ └── ProcessorTest.php │ ├── Helper │ ├── CliTest.php │ └── FileTest.php │ ├── Console │ └── Command │ │ ├── GulpInstallCommandTest.php │ │ └── GulpThemeCommandTest.php │ ├── Instruction │ ├── ImportTest.php │ └── MagentoImportTest.php │ └── Plugin │ └── ChainPluginTest.php ├── registration.php ├── etc ├── module.xml └── di.xml ├── phpunit.xml.dist ├── phpmd.xml ├── composer.json ├── Helper ├── Cli.php └── File.php ├── Instruction ├── Import.php └── MagentoImport.php ├── .travis.yml ├── Plugin └── ChainPlugin.php ├── Adapter └── Scss │ └── Processor.php ├── Readme.md └── Console └── Command ├── GulpInstallCommand.php └── GulpThemeCommand.php /.gitignore: -------------------------------------------------------------------------------- 1 | composer.lock 2 | /vendor 3 | -------------------------------------------------------------------------------- /dev/tools/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["@babel/preset-env"] 3 | } 4 | -------------------------------------------------------------------------------- /Test/Unit/Adapter/Scss/_files/variables.min.css: -------------------------------------------------------------------------------- 1 | body{font:100% Helvetica, sans-serif;color:#333} 2 | -------------------------------------------------------------------------------- /Test/Unit/Adapter/Scss/_files/variables.css: -------------------------------------------------------------------------------- 1 | body { 2 | font: 100% Helvetica, sans-serif; 3 | color: #333; } 4 | -------------------------------------------------------------------------------- /dev/tools/gulp/config/browser-sync.json: -------------------------------------------------------------------------------- 1 | { 2 | "port": 3000, 3 | "notify": true, 4 | "injectChanges": true 5 | } 6 | -------------------------------------------------------------------------------- /Test/Unit/Adapter/Scss/_files/_import-partial.scss: -------------------------------------------------------------------------------- 1 | html, 2 | body, 3 | ul, 4 | ol { 5 | margin: 0; 6 | padding: 0; 7 | } 8 | -------------------------------------------------------------------------------- /Test/Unit/Adapter/Scss/_files/mixins.min.css: -------------------------------------------------------------------------------- 1 | .box{-webkit-transform:rotate(30deg);-ms-transform:rotate(30deg);transform:rotate(30deg)} 2 | -------------------------------------------------------------------------------- /Test/Unit/Adapter/Scss/_files/import.min.css: -------------------------------------------------------------------------------- 1 | html,body,ul,ol{margin:0;padding:0}body{font:100% Helvetica, sans-serif;background-color:#efefef} 2 | -------------------------------------------------------------------------------- /Test/Unit/Adapter/Scss/_files/mixins.css: -------------------------------------------------------------------------------- 1 | .box { 2 | -webkit-transform: rotate(30deg); 3 | -ms-transform: rotate(30deg); 4 | transform: rotate(30deg); } 5 | -------------------------------------------------------------------------------- /Test/Unit/Adapter/Scss/_files/import.scss: -------------------------------------------------------------------------------- 1 | @import 'import-partial'; 2 | 3 | body { 4 | font: 100% Helvetica, sans-serif; 5 | background-color: #efefef; 6 | } 7 | -------------------------------------------------------------------------------- /Test/Unit/Adapter/Scss/_files/operators.min.css: -------------------------------------------------------------------------------- 1 | .container{width:100%}article[role="main"]{float:left;width:62.5%}aside[role="complementary"]{float:right;width:31.25%} 2 | -------------------------------------------------------------------------------- /Test/Unit/Adapter/Scss/_files/nesting.min.css: -------------------------------------------------------------------------------- 1 | nav ul{margin:0;padding:0;list-style:none}nav li{display:inline-block}nav a{display:block;padding:6px 12px;text-decoration:none} 2 | -------------------------------------------------------------------------------- /Test/Unit/Adapter/Scss/_files/variables.scss: -------------------------------------------------------------------------------- 1 | $font-stack: Helvetica, sans-serif; 2 | $primary-color: #333; 3 | 4 | body { 5 | font: 100% $font-stack; 6 | color: $primary-color; 7 | } 8 | -------------------------------------------------------------------------------- /Test/Unit/Adapter/Scss/_files/extend.min.css: -------------------------------------------------------------------------------- 1 | .message,.success,.error,.warning{border:1px solid #ccc;padding:10px;color:#333}.success{border-color:green}.error{border-color:red}.warning{border-color:yellow} 2 | -------------------------------------------------------------------------------- /Test/Unit/Adapter/Scss/_files/import.css: -------------------------------------------------------------------------------- 1 | html, 2 | body, 3 | ul, 4 | ol { 5 | margin: 0; 6 | padding: 0; } 7 | 8 | body { 9 | font: 100% Helvetica, sans-serif; 10 | background-color: #efefef; } 11 | -------------------------------------------------------------------------------- /registration.php: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /dev/tools/gulp/tasks/deploy.js: -------------------------------------------------------------------------------- 1 | import ThemeRegistry from "../utils/theme-registry"; 2 | import { exec } from 'child_process'; 3 | 4 | export default function (done, theme) { 5 | const themeRegistry = new ThemeRegistry(); 6 | const themeConfig = themeRegistry.getTheme(theme); 7 | 8 | exec(`php bin/magento setup:static-content:deploy -f --theme="${themeConfig.name}"`, (err, stdout, stderr) => { 9 | console.log(stdout); 10 | console.log(stderr); 11 | done(err); 12 | }); 13 | } 14 | -------------------------------------------------------------------------------- /Test/Unit/Adapter/Scss/_files/extend.scss: -------------------------------------------------------------------------------- 1 | %equal-heights { 2 | display: flex; 3 | flex-wrap: wrap; 4 | } 5 | 6 | %message-shared { 7 | border: 1px solid #ccc; 8 | padding: 10px; 9 | color: #333; 10 | } 11 | 12 | .message { 13 | @extend %message-shared; 14 | } 15 | 16 | .success { 17 | @extend %message-shared; 18 | border-color: green; 19 | } 20 | 21 | .error { 22 | @extend %message-shared; 23 | border-color: red; 24 | } 25 | 26 | .warning { 27 | @extend %message-shared; 28 | border-color: yellow; 29 | } 30 | -------------------------------------------------------------------------------- /dev/tools/gulp/tasks/exec.js: -------------------------------------------------------------------------------- 1 | import ThemeRegistry from "../utils/theme-registry"; 2 | import { exec } from 'child_process'; 3 | 4 | export default function (done, theme) { 5 | const themeRegistry = new ThemeRegistry(); 6 | const themeConfig = themeRegistry.getTheme(theme); 7 | 8 | exec(`php bin/magento dev:source-theme:deploy --type="${themeConfig.dsl}" --locale="${themeConfig.locale}" --area="${themeConfig.area}" --theme="${themeConfig.name}" ${themeConfig.files.join(' ')}`, (err, stdout, stderr) => { 9 | console.log(stdout); 10 | console.log(stderr); 11 | done(err); 12 | }); 13 | } 14 | -------------------------------------------------------------------------------- /dev/tools/gulp/tasks/scss.js: -------------------------------------------------------------------------------- 1 | import gulp from 'gulp'; 2 | import sass from 'gulp-sass'; 3 | import sourceMaps from 'gulp-sourcemaps'; 4 | import ThemeRegistry from '../utils/theme-registry'; 5 | import { syncStream } from '../utils/sync'; 6 | 7 | export default function (done, theme) { 8 | const themeRegistry = new ThemeRegistry(); 9 | const themeConfig = themeRegistry.getTheme(theme); 10 | 11 | return syncStream(gulp.src(themeConfig.preprocessorFiles) 12 | .pipe(sourceMaps.init()) 13 | .pipe(sass().on('error', sass.logError)) 14 | .pipe(sourceMaps.write('.')) 15 | .pipe(gulp.dest(themeConfig.path + 'css/'))); 16 | } 17 | -------------------------------------------------------------------------------- /dev/tools/gulp/tasks/less.js: -------------------------------------------------------------------------------- 1 | import gulp from 'gulp'; 2 | import ThemeRegistry from '../utils/theme-registry'; 3 | import less from 'gulp-less'; 4 | import sourceMaps from 'gulp-sourcemaps'; 5 | import log from 'fancy-log'; 6 | import { syncStream } from '../utils/sync'; 7 | 8 | export default function (done, theme) { 9 | const themeRegistry = new ThemeRegistry(); 10 | const themeConfig = themeRegistry.getTheme(theme); 11 | 12 | return syncStream(gulp.src(themeConfig.preprocessorFiles) 13 | .pipe(sourceMaps.init()) 14 | .pipe(less().on('error', log.error)) 15 | .pipe(sourceMaps.write('.')) 16 | .pipe(gulp.dest(themeConfig.path + 'css/'))); 17 | } 18 | -------------------------------------------------------------------------------- /dev/tools/gulp/utils/task-register.js: -------------------------------------------------------------------------------- 1 | import gulp from 'gulp'; 2 | import filesRouter from '../../grunt/tools/files-router'; 3 | 4 | export default function(task, callback, deps = []) { 5 | filesRouter.set('themes', './dev/tools/grunt/configs/themes'); 6 | const themes = filesRouter.get('themes'); 7 | 8 | Object.keys(themes).forEach(theme => { 9 | const tasks = [...deps.map(el => `${el}:${theme}`)]; 10 | if (typeof callback === 'function') { 11 | tasks.push(Object.defineProperty((done) => { 12 | return callback(done, theme); 13 | }, 'name', { value: task })); 14 | } 15 | gulp.task(`${task}:${theme}`, gulp.series.apply(this, tasks)); 16 | }); 17 | } 18 | -------------------------------------------------------------------------------- /dev/tools/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "magento2-sass", 3 | "description": "Magento2 sass compilation", 4 | "keywords": [ 5 | "magento2", 6 | "sass", 7 | "scss", 8 | "gulp", 9 | "gulpfile", 10 | "es6", 11 | "preprocessing" 12 | ], 13 | "author": "Marcin Makałowski", 14 | "license": "MIT", 15 | "devDependencies": { 16 | "@babel/core": "^7.4.0", 17 | "@babel/preset-env": "^7.4.2", 18 | "@babel/register": "^7.4.0", 19 | "browser-sync": "^2.26.3", 20 | "chokidar": "2.1.5", 21 | "del": "^4.0.0", 22 | "fancy-log": "^1.3.3", 23 | "gulp": "^4.0.0", 24 | "gulp-less": "^4.0.1", 25 | "gulp-sass": "^4.0.2", 26 | "gulp-sourcemaps": "^2.6.5", 27 | "yargs": "^13.2.2" 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /phpunit.xml.dist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Test/Unit 6 | 7 | 8 | 9 | 10 | Adapter 11 | Console 12 | Helper 13 | Instruction 14 | Plugin 15 | 16 | Test 17 | 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /dev/tools/gulpfile.babel.js: -------------------------------------------------------------------------------- 1 | import cleanTask from './dev/tools/gulp/tasks/clean'; 2 | import deployTask from './dev/tools/gulp/tasks/deploy'; 3 | import execTask from './dev/tools/gulp/tasks/exec'; 4 | import lessTask from './dev/tools/gulp/tasks/less'; 5 | import scssTask from './dev/tools/gulp/tasks/scss'; 6 | import watchTask from './dev/tools/gulp/tasks/watch'; 7 | import taskRegister from './dev/tools/gulp/utils/task-register'; 8 | 9 | taskRegister('clean', cleanTask); 10 | taskRegister('deploy', deployTask); 11 | taskRegister('exec', execTask, ['clean']); 12 | taskRegister('less', lessTask); 13 | taskRegister('scss', scssTask); 14 | taskRegister('watch', watchTask); 15 | 16 | taskRegister('build:scss', null, ['exec', 'scss']); 17 | taskRegister('build:less', null, ['exec', 'less']); 18 | 19 | taskRegister('dev:scss', null, ['exec', 'scss', 'watch']); 20 | taskRegister('dev:less', null, ['exec', 'less', 'watch']); 21 | -------------------------------------------------------------------------------- /phpmd.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | Magento2 Sass Preprocessor ruleset 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /dev/tools/gulp/utils/sync.js: -------------------------------------------------------------------------------- 1 | import browserSync from 'browser-sync'; 2 | import log from 'fancy-log'; 3 | import chalk from 'chalk'; 4 | import { argv } from 'yargs'; 5 | import bsConfig from '../config/browser-sync'; 6 | 7 | const bs = browserSync.create('magento2'); 8 | 9 | export function isSyncEnabled() { 10 | return !!argv.proxy; 11 | } 12 | 13 | export function initSync() { 14 | if (!argv.proxy) { 15 | log.info(chalk.yellow('BrowserSync is disabled, please specify proxy argument.')); 16 | 17 | return; 18 | } 19 | 20 | const domain = `.${argv.proxy.split('//')[1]}`; 21 | 22 | const config = Object.assign(bsConfig, { 23 | rewriteRules: [{ 24 | match: domain, 25 | replace: "" 26 | }], 27 | proxy: argv.proxy 28 | }); 29 | 30 | bs.init(config); 31 | } 32 | 33 | export function syncStream(stream) { 34 | return stream.pipe(bs.stream()); 35 | } 36 | 37 | export function syncReload() { 38 | return bs.reload(); 39 | } 40 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "clawrock/magento2-sass-preprocessor", 3 | "description": "Module for Sass processing during static content deployment", 4 | "require": { 5 | "magento/framework": "~101.0|~102.0|~103.0", 6 | "leafo/scssphp": "^0.7.7", 7 | "magento/module-developer": "^100.2", 8 | "magento/module-store": "^100.2|^101.0" 9 | }, 10 | "type": "magento2-module", 11 | "license": [ 12 | "OSL-3.0", 13 | "AFL-3.0" 14 | ], 15 | "repositories": [ 16 | { 17 | "type": "composer", 18 | "url": "https://repo.magento.com/" 19 | } 20 | ], 21 | "autoload": { 22 | "files": [ 23 | "registration.php" 24 | ], 25 | "psr-4": { 26 | "ClawRock\\SassPreprocessor\\": "" 27 | } 28 | }, 29 | "require-dev": { 30 | "phpunit/phpunit": "^6.5", 31 | "squizlabs/php_codesniffer": "^3.3", 32 | "phpmd/phpmd": "^2.6", 33 | "sebastian/phpcpd": "^3.0", 34 | "php-coveralls/php-coveralls": "^2.1" 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /dev/tools/gulp/utils/theme-registry.js: -------------------------------------------------------------------------------- 1 | import filesRouter from '../../grunt/tools/files-router'; 2 | 3 | const pubPath = `pub/static`; 4 | const sourcePath = `app/design`; 5 | 6 | class ThemeRegistry { 7 | constructor() { 8 | this.themes = filesRouter.get('themes'); 9 | } 10 | 11 | getTheme(theme) { 12 | if (!theme) { 13 | throw new Error(`Theme not specified`); 14 | } 15 | 16 | const themeConfig = this.themes[theme]; 17 | if (!themeConfig) { 18 | throw new Error(`Theme ${theme} not defined`); 19 | } 20 | 21 | themeConfig.path = `${pubPath}/${themeConfig.area}/${themeConfig.name}/${themeConfig.locale}/`; 22 | themeConfig.sourcePath = `${sourcePath}/${themeConfig.area}/${themeConfig.name}/`; 23 | themeConfig.preprocessorFiles = []; 24 | themeConfig.sourceFiles = []; 25 | themeConfig.files.forEach(file => { 26 | themeConfig.preprocessorFiles.push(`${themeConfig.path}${file}.${themeConfig.dsl}`); 27 | themeConfig.sourceFiles.push(`${themeConfig.sourcePath}web/${file}.${themeConfig.dsl}`); 28 | }); 29 | 30 | return themeConfig; 31 | } 32 | } 33 | 34 | export default ThemeRegistry; 35 | -------------------------------------------------------------------------------- /Helper/Cli.php: -------------------------------------------------------------------------------- 1 | request = $request; 29 | $this->argvInputFactory = $argvInputFactory; 30 | } 31 | 32 | public function getInput(InputDefinition $definition) 33 | { 34 | if ($this->input === null) { 35 | $this->input = $this->argvInputFactory->create(); 36 | $this->input->bind($definition); 37 | } 38 | 39 | return $this->input; 40 | } 41 | 42 | public function isCli() 43 | { 44 | return PHP_SAPI === 'cli'; 45 | } 46 | 47 | public function isCommand($name) 48 | { 49 | return in_array($name, $this->request->getServerValue('argv', [])); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /Instruction/Import.php: -------------------------------------------------------------------------------- 1 | assetRepository = $assetRepository; 27 | $this->fileHelper = $fileHelper; 28 | } 29 | 30 | /** 31 | * @inheritdoc 32 | */ 33 | protected function replace(array $matchedContent, LocalInterface $asset, $contentType) 34 | { 35 | $matchedFileId = $this->fileHelper->fixFileExtension($matchedContent['path'], $contentType); 36 | $relatedAsset = $this->assetRepository->createRelated($matchedFileId, $asset); 37 | 38 | if ($this->fileHelper->assetFileExists($relatedAsset)) { 39 | return parent::replace($matchedContent, $asset, $contentType); 40 | } 41 | 42 | $matchedContent['path'] = $this->fileHelper->getUnderscoreNotation($matchedContent['path']); 43 | 44 | return parent::replace($matchedContent, $asset, $contentType); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /dev/tools/gulp/utils/asset-deployer.js: -------------------------------------------------------------------------------- 1 | import ThemeRegistry from './theme-registry'; 2 | import fs from 'fs'; 3 | 4 | class AssetDeployer { 5 | constructor(theme) { 6 | this.theme = (new ThemeRegistry()).getTheme(theme); 7 | this.magentoImport = {}; 8 | this.resolveMagentoImport(); 9 | } 10 | 11 | resolveSymlinkPath(sourceFile) { 12 | const destinationParts = sourceFile.split('/').splice(5).filter(part => part !== 'web'); 13 | destinationParts.pop(); 14 | 15 | return `${this.theme.path}${destinationParts.join('/')}`; 16 | } 17 | 18 | isMagentoImportFile(path) { 19 | return Object.keys(this.magentoImport).some(file => { 20 | return this.magentoImport[file].some(pattern => { 21 | return path.includes(pattern); 22 | }); 23 | }); 24 | } 25 | 26 | resolveMagentoImport() { 27 | this.theme.sourceFiles.forEach((file) => { 28 | const data = fs.readFileSync(file, 'UTF-8'); 29 | const importRe = new RegExp('\/\/@magento_import[^;]*', 'gm'); 30 | const result = data.match(importRe); 31 | if (!result) { 32 | return; 33 | } 34 | result.forEach((line) => { 35 | const lineRe = new RegExp('[\'"](.*)[\'"]'); 36 | const lineResult = line.match(lineRe); 37 | if (lineResult) { 38 | if (this.magentoImport[file] === undefined) { 39 | this.magentoImport[file] = []; 40 | } 41 | this.magentoImport[file].push(lineResult[1]); 42 | } 43 | }); 44 | }); 45 | } 46 | } 47 | 48 | export default AssetDeployer; 49 | -------------------------------------------------------------------------------- /Helper/File.php: -------------------------------------------------------------------------------- 1 | ioFile = $ioFile; 18 | } 19 | 20 | public function fixFileExtension($file, $contentType) 21 | { 22 | $pathInfo = $this->ioFile->getPathInfo($file); 23 | 24 | if (!isset($pathInfo['extension'])) { 25 | $file .= '.' . $contentType; 26 | } 27 | 28 | return $file; 29 | } 30 | 31 | public function getUnderscoreNotation($path) 32 | { 33 | $pathInfo = $this->ioFile->getPathInfo($path); 34 | 35 | return $pathInfo['dirname'] . '/_' . $pathInfo['basename']; 36 | } 37 | 38 | public function assetFileExists(\Magento\Framework\View\Asset\File $asset) 39 | { 40 | try { 41 | $asset->getSourceFile(); 42 | } catch (NotFoundException $e) { 43 | return false; 44 | } 45 | return true; 46 | } 47 | 48 | public function isPartial($filePath) 49 | { 50 | $pathInfo = $this->ioFile->getPathInfo($filePath); 51 | 52 | return !isset($pathInfo['basename'][0]) ? false : $pathInfo['basename'][0] === '_'; 53 | } 54 | 55 | public function readFileAsArray($path, $extension = null) 56 | { 57 | $result = @file($path); 58 | if (!$result && $extension) { 59 | $path .= '.' . $extension; 60 | $result = @file($path); 61 | } 62 | 63 | return $result ?: []; 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: php 2 | 3 | php: 4 | - 7.0 5 | - 7.1 6 | - 7.2 7 | 8 | install: 9 | - echo "{\"http-basic\":{\"repo.magento.com\":{\"username\":\"${MAGENTO_USERNAME}\",\"password\":\"${MAGENTO_PASSWORD}\"}}}" > auth.json 10 | - composer install --prefer-dist 11 | 12 | script: 13 | - php vendor/bin/phpcs --standard=PSR2 Adapter/ Console/ Helper/ Instruction/ Plugin/ Test/ 14 | - php vendor/bin/phpmd Adapter/,Console/,Helper/,Instruction/,Plugin/,Test/ text phpmd.xml 15 | - php vendor/bin/phpcpd Adapter/ Console/ Helper/ Instruction/ Plugin/ Test/ 16 | - php vendor/bin/phpunit --coverage-clover build/logs/clover.xml 17 | 18 | after_script: 19 | - php vendor/bin/php-coveralls 20 | 21 | env: 22 | global: 23 | - secure: Ghnbn8esZrq2ZlaBGhj+Zr9BH/MdeE2I/a41q/JZ6l4OzEGqX9hKmtqLhVbIU/Q4mzk9GlwyE7oxvP0KrHZKWY5Vg5GtCV8BLdRQbf9B/1gt5jihiACf5DzIzKTQpyJKczqtbNnTanPp+6yUakIYIQLkwBcvJ24YLpKhvLnIWJr+y5XTTa2VG6MnsUsdKrJoXU4sLBWIOjjnIHgHGYf5KqCVahe8e3A9Zq3IlG+2TNsDBsqq+aUzWh78dbIhdZkKUFbuXweK67N0WMHTEh4fsePITEDIMoO7jdYkWQRA+xW9WftOOOd3FIOScJC14Y9RLaLBP4mS7V/jYfm4kqw8BgzGg/X/eZWCrTuyb9UW1HIG665H//FlammNv93rAwp+HUpaKGQlebObn55uUO8I5dgxsgE1E0QkwRhJJHqQ7TholUcsASywnAxxK0OjuKB41Y5cxb/Ef7v7mvyLBISn1RvMPzonzv8pWzDYOVp0I+yAXtc+M9PfVWxFqKoqyXwbs95R5WqcFfoZcu/F+QyfOzLdThuNCK0oa7yvn8Eb+uGct11s0LiCQGiFBdXI++Nu4j7bHtWgXi/efbVOlkjUaDRWVZpixvtQTymLR7+gQUU5LbD5HGD2paF6rY1mj+SBzKwj0A9rUFqEPV/AQoH+WJ6/PjtVcPNA3pYYemUU9qY= 24 | - secure: BNEJvBrC8Y9ZxMQozv1kKeaYZWvfjUkt9x0BPKcBDQRvze/W2q2axXKa93wiCAQEUEMr6a3UyEtGNn7KEGe35KWRgHm7RRY+33ljS3uki679H2/ZFAZ5+cvvKeprDeuHsxPqS3BZ0qb6+TmKjRnjEhlJvz/HJ1Vzo4sXhqoiZrvn6LS+DqFGK1V4pQWGrZYodZpZIIv+MmtDqcOoxeZ0ETng4YE/faAqSGRweUNWPGQv6oOG2Rw/Ie47pGoa8+Uca5wnnXkKZg2YgfmwSLZsOHdjzfbEZjC5mu9BnQTu7KQV8imRMdQ3NZLgmBYs9rS4i3DfLClkQRYjhWNMPKuiaxoTsESdPQlzIrNSYFre774dnICvxgzqCIAmSc4ASu2vNRbIMS870YT2/xJnxETfnbsqCyA2zH7M5R0aFpAuCHci6mFRtnQ2/CF3jxQqPc7cfyoZB93th5qnxZo9gjZyL7mE8o7YKUxDAvQgXD8VjNKplKvMJC2VMKSPJ2lzyIj8bRsDY+Hg7bhcyUl+1aYbBsjLYvx2uJOe5tj+Y2M9AIABB9J1RkRTdNEfe5TEos4EBSfDrVAs+5OITUJu3HJHtA7Y3tEKpaGrmWyyrG7KonSiCx/+dm5R0DwVd31/tQ05143qD7qIgULSsgSfZxISWZJNlTHi/rAKoDLGw9K7++4= 25 | -------------------------------------------------------------------------------- /Plugin/ChainPlugin.php: -------------------------------------------------------------------------------- 1 | sourceThemeDeployCommand = $sourceThemeDeployCommand; 36 | $this->fileHelper = $fileHelper; 37 | $this->cliHelper = $cliHelper; 38 | } 39 | 40 | public function afterIsChanged(Chain $subject, $result) 41 | { 42 | if (!$this->cliHelper->isCli() || !$this->cliHelper->isCommand('dev:source-theme:deploy')) { 43 | return $result; 44 | } 45 | 46 | if (!$this->isEntryFile($subject->getAsset()->getFilePath())) { 47 | return false; 48 | } 49 | 50 | return $result; 51 | } 52 | 53 | private function isEntryFile($path) 54 | { 55 | $input = $this->cliHelper->getInput($this->sourceThemeDeployCommand->getDefinition()); 56 | $files = $input->getArgument(SourceThemeDeployCommand::FILE_ARGUMENT); 57 | $contentType = $input->getOption(SourceThemeDeployCommand::TYPE_ARGUMENT); 58 | 59 | foreach ($files as $file) { 60 | if ($file === $path || $this->fileHelper->fixFileExtension($file, $contentType) === $path) { 61 | return true; 62 | } 63 | } 64 | 65 | return false; 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /Test/Unit/Helper/CliTest.php: -------------------------------------------------------------------------------- 1 | requestMock = $this->getMockBuilder(\Magento\Framework\App\RequestInterface::class) 41 | ->setMethods(['getServerValue']) 42 | ->getMockForAbstractClass(); 43 | 44 | $this->argvInputFactoryMock = $this->getMockBuilder(\Symfony\Component\Console\Input\ArgvInputFactory::class) 45 | ->setMethods(['create']) 46 | ->getMock(); 47 | 48 | $this->inputMock = $this->getMockBuilder(\Symfony\Component\Console\Input\Input::class) 49 | ->disableOriginalConstructor() 50 | ->getMock(); 51 | 52 | $this->inputDefinitionMock = $this->getMockBuilder(\Symfony\Component\Console\Input\InputDefinition::class) 53 | ->disableOriginalConstructor() 54 | ->getMock(); 55 | 56 | $this->helper = (new ObjectManager($this))->getObject(Cli::class, [ 57 | 'request' => $this->requestMock, 58 | 'argvInputFactory' => $this->argvInputFactoryMock 59 | ]); 60 | } 61 | 62 | public function testGetInput() 63 | { 64 | $this->argvInputFactoryMock->expects(self::once())->method('create')->willReturn($this->inputMock); 65 | $this->inputMock->expects(self::once())->method('bind')->with($this->inputDefinitionMock); 66 | 67 | $this->assertInstanceOf( 68 | \Symfony\Component\Console\Input\Input::class, 69 | $this->helper->getInput($this->inputDefinitionMock) 70 | ); 71 | } 72 | 73 | public function testIsCli() 74 | { 75 | $this->assertTrue($this->helper->isCli()); 76 | } 77 | 78 | public function testIsCommand() 79 | { 80 | $this->requestMock->expects(self::once())->method('getServerValue')->willReturn(['php', 'test:command', 'arg']); 81 | $this->assertTrue($this->helper->isCommand('test:command')); 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /Adapter/Scss/Processor.php: -------------------------------------------------------------------------------- 1 | logger = $logger; 59 | $this->appState = $appState; 60 | $this->assetSource = $assetSource; 61 | $this->directoryList = $directoryList; 62 | $this->config = $config; 63 | $this->ioFile = $ioFile; 64 | $this->compilerFactory = $compilerFactory; 65 | } 66 | 67 | /** 68 | * @inheritdoc 69 | * @throws \Magento\Framework\View\Asset\ContentProcessorException 70 | */ 71 | public function processContent(File $asset) 72 | { 73 | $path = $asset->getPath(); 74 | try { 75 | /** @var \Leafo\ScssPhp\Compiler $compiler */ 76 | $compiler = $this->compilerFactory->create(); 77 | 78 | if ($this->appState->getMode() !== State::MODE_DEVELOPER) { 79 | $compiler->setFormatter(\Leafo\ScssPhp\Formatter\Compressed::class); 80 | } 81 | 82 | $compiler->setImportPaths([ 83 | $this->directoryList->getPath(DirectoryList::VAR_DIR) 84 | . '/' . $this->config->getMaterializationRelativePath() 85 | . '/' . $this->ioFile->dirname($path) 86 | ]); 87 | 88 | $content = $this->assetSource->getContent($asset); 89 | 90 | if (trim($content) === '') { 91 | return ''; 92 | } 93 | 94 | gc_disable(); 95 | $content = $compiler->compile($content); 96 | gc_enable(); 97 | 98 | if (trim($content) === '') { 99 | $this->logger->warning('Parsed scss file is empty: ' . $path); 100 | return ''; 101 | } 102 | 103 | return $content; 104 | } catch (\Exception $e) { 105 | throw new ContentProcessorException(new Phrase($e->getMessage())); 106 | } 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /dev/tools/gulp/tasks/watch.js: -------------------------------------------------------------------------------- 1 | import gulp from 'gulp'; 2 | import AssetDeployer from '../utils/asset-deployer'; 3 | import ThemeRegistry from '../utils/theme-registry'; 4 | import { initSync, syncReload, isSyncEnabled } from '../utils/sync'; 5 | import path from 'path'; 6 | import log from 'fancy-log'; 7 | import chalk from 'chalk'; 8 | import del from 'del'; 9 | import { argv } from 'yargs'; 10 | 11 | function relativizePath(absolutePath) { 12 | return path.relative(process.cwd(), absolutePath); 13 | } 14 | 15 | export default function (done, theme) { 16 | initSync(); 17 | const assetDeployer = new AssetDeployer(theme); 18 | const themeRegistry = new ThemeRegistry(); 19 | const themeConfig = themeRegistry.getTheme(theme); 20 | const mainWatcher = gulp.watch(`${themeConfig.path}**/*.${themeConfig.dsl}`, gulp.series([`${themeConfig.dsl}:${theme}`])) 21 | .on('change', path => { 22 | log.info(chalk.white(`File ${relativizePath(path)} was changed`)); 23 | }); 24 | 25 | gulp.watch(`${themeConfig.sourcePath}**/*.${themeConfig.dsl}`) 26 | .on('add', path => { 27 | if (assetDeployer.isMagentoImportFile(path)) { 28 | mainWatcher.unwatch(`${themeConfig.path}**/*.${themeConfig.dsl}`); 29 | log.info(chalk.white(`File ${relativizePath(path)} detected as @magento_import, deploying source theme...`)); 30 | gulp.task(`exec:${theme}`)(() => { 31 | mainWatcher.add(`${themeConfig.path}**/*.${themeConfig.dsl}`); 32 | gulp.task(`${themeConfig.dsl}:${theme}`)(); 33 | }); 34 | return; 35 | } 36 | 37 | gulp.src(path).pipe(gulp.symlink(assetDeployer.resolveSymlinkPath(path))); 38 | log.info(chalk.white(`File ${relativizePath(path)} was created and linked pub`)); 39 | }).on('unlink', path => { 40 | mainWatcher.unwatch(`${themeConfig.path}**/*.${themeConfig.dsl}`); 41 | del([assetDeployer.resolveSymlinkPath(path)]).then(() => { 42 | log.info(chalk.white(`File ${relativizePath(path)} was deleted`)); 43 | if (assetDeployer.isMagentoImportFile(path)) { 44 | log.info(chalk.white(`File ${relativizePath(path)} detected as @magento_import, deploying source theme...`)); 45 | gulp.task(`exec:${theme}`)(() => { 46 | mainWatcher.add(`${themeConfig.path}**/*.${themeConfig.dsl}`); 47 | gulp.task(`${themeConfig.dsl}:${theme}`)(); 48 | }); 49 | return; 50 | } 51 | mainWatcher.add(`${themeConfig.path}**/*.${themeConfig.dsl}`); 52 | }); 53 | }); 54 | 55 | const requireJsCallback = cb => { 56 | del([`${themeConfig.path}requirejs-config.js`]).then(() => { 57 | log.info(chalk.white(`Combined RequireJS configuration file removed from pub/static.`)); 58 | cb(); 59 | }); 60 | }; 61 | 62 | gulp.watch([ 63 | 'app/code/**/requirejs-config.js', 64 | `${themeConfig.sourcePath}**/requirejs-config.js` 65 | ], requireJsCallback); 66 | 67 | if (!isSyncEnabled()) { 68 | return; 69 | } 70 | 71 | const reload = cb => { 72 | syncReload(); 73 | cb(); 74 | }; 75 | 76 | if (argv.phtml) { 77 | gulp.watch([ 78 | 'app/code/**/*.phtml', 79 | `${themeConfig.sourcePath}**/*.phtml` 80 | ], reload); 81 | } 82 | 83 | if (argv.js) { 84 | gulp.watch([ 85 | 'app/code/**/*.js', 86 | `${themeConfig.sourcePath}**/*.js` 87 | ], reload); 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /Test/Unit/Helper/FileTest.php: -------------------------------------------------------------------------------- 1 | ioFileMock = $this->getMockBuilder(\Magento\Framework\Filesystem\Io\File::class)->getMock(); 26 | 27 | $this->helper = (new ObjectManager($this))->getObject(File::class, [ 28 | 'ioFile' => $this->ioFileMock 29 | ]); 30 | } 31 | 32 | public function testIsPartialTrue() 33 | { 34 | $path = 'web/css/source/styles/_hello.scss'; 35 | 36 | $this->ioFileMock->expects(self::once())->method('getPathInfo')->willReturn(pathinfo($path)); 37 | $this->assertTrue($this->helper->isPartial($path)); 38 | } 39 | 40 | public function testIsPartialFalse() 41 | { 42 | $path = 'web/css/source/styles/hello.scss'; 43 | 44 | $this->ioFileMock->expects(self::once())->method('getPathInfo')->willReturn(pathinfo($path)); 45 | $this->assertFalse($this->helper->isPartial($path)); 46 | } 47 | 48 | public function testAssetFileNotExists() 49 | { 50 | /** @var \Magento\Framework\View\Asset\File|\PHPUnit_Framework_MockObject_MockObject $asset */ 51 | $asset = $this->getMockBuilder(\Magento\Framework\View\Asset\File::class) 52 | ->disableOriginalConstructor() 53 | ->getMock(); 54 | 55 | $asset->expects(self::once())->method('getSourceFile')->willThrowException(new NotFoundException()); 56 | 57 | $this->assertFalse($this->helper->assetFileExists($asset)); 58 | } 59 | 60 | public function testAssetFileExists() 61 | { 62 | /** @var \Magento\Framework\View\Asset\File|\PHPUnit_Framework_MockObject_MockObject $asset */ 63 | $asset = $this->getMockBuilder(\Magento\Framework\View\Asset\File::class) 64 | ->disableOriginalConstructor() 65 | ->getMock(); 66 | 67 | $asset->expects(self::once())->method('getSourceFile')->willReturn('file_path'); 68 | 69 | $this->assertTrue($this->helper->assetFileExists($asset)); 70 | } 71 | 72 | public function testFixFileExtension() 73 | { 74 | $path = 'web/css/source/styles/hello'; 75 | $this->ioFileMock->expects(self::once())->method('getPathInfo')->willReturn(pathinfo($path)); 76 | $this->assertEquals('web/css/source/styles/hello.scss', $this->helper->fixFileExtension($path, 'scss')); 77 | } 78 | 79 | public function testFixFileExtensionNoChange() 80 | { 81 | $path = 'web/css/source/styles/hello.scss'; 82 | $this->ioFileMock->expects(self::once())->method('getPathInfo')->willReturn(pathinfo($path)); 83 | $this->assertEquals('web/css/source/styles/hello.scss', $this->helper->fixFileExtension($path, 'scss')); 84 | } 85 | 86 | public function testGetUnderscoreNotation() 87 | { 88 | $path = 'web/css/source/styles/hello.scss'; 89 | $this->ioFileMock->expects(self::once())->method('getPathInfo')->willReturn(pathinfo($path)); 90 | $this->assertEquals('web/css/source/styles/_hello.scss', $this->helper->getUnderscoreNotation($path)); 91 | } 92 | 93 | public function testReadFileAsArray() 94 | { 95 | $this->assertEquals([], $this->helper->readFileAsArray('path/to/file', 'js')); 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /Readme.md: -------------------------------------------------------------------------------- 1 | [![Packagist](https://img.shields.io/packagist/v/clawrock/magento2-sass-preprocessor.svg)](https://packagist.org/packages/clawrock/magento2-sass-preprocessor) 2 | [![Packagist](https://img.shields.io/packagist/dt/clawrock/magento2-sass-preprocessor.svg)](https://packagist.org/packages/clawrock/magento2-sass-preprocessor) 3 | [![Build Status](https://travis-ci.org/clawrock/magento2-sass-preprocessor.svg?branch=master)](https://travis-ci.org/clawrock/magento2-sass-preprocessor) 4 | [![Coverage Status](https://coveralls.io/repos/github/clawrock/magento2-sass-preprocessor/badge.svg)](https://coveralls.io/github/clawrock/magento2-sass-preprocessor) 5 | 6 | # Magento 2 - Sass Preprocessor module 7 | Module for Sass processing during static content deployment with additional Gulp workflow to improve Magento 2 development speed. It compiles SCSS using `scssphp` and process standard `@import` instruction as well as `@magento_import`. 8 | 9 | ## Installation 10 | 1. Install module via composer `composer require clawrock/magento2-sass-preprocessor` 11 | 2. Register module `php bin/magento setup:upgrade` 12 | 3. Compile Sass theme using `php bin/magento setup:static-content:deploy -f` 13 | 14 | ## Example theme 15 | * [clawrock/magento2-theme-blank-sass](https://github.com/clawrock/magento2-theme-blank-sass) 16 | 17 | ## Works with 18 | #### Preprocessor 19 | * Magento 2.2 - 2.3 20 | * PHP 7.0 - 7.2 21 | #### Gulp 22 | * Node.js 10+ 23 | 24 | ## Gulp 25 | 1. Install Node.js 26 | 2. Install Gulp configuration `php bin/magento dev:gulp:install` 27 | 3. Install Gulp and required dependencies `npm install` 28 | 4. Define theme configuration `php bin/magento dev:gulp:theme` 29 | 5. Symlink theme to pub/static folder `gulp exec:[theme_key]` 30 | 6. Compile SCSS `gulp scss:[theme_key]` 31 | 7. Watch for changes `gulp watch:[theme_key]` 32 | 33 | It also supports LESS, instead of SCSS use less like `gulp less:[theme_key]` 34 | 35 | Use additional flags to enable more watchers: 36 | - `--phtml`: reload when phtml file is changed 37 | - `--js`: reload when js file is changed 38 | 39 | #### Configure theme 40 | You can manually configure theme like in Gruntfile which is shipped with Magento or use `php bin/magento dev:gulp:theme` command which will configure it for you. 41 | 42 | Reference: [Grunt configuration file](https://devdocs.magento.com/guides/v2.3/frontend-dev-guide/tools/using_grunt.html#grunt_config) 43 | 44 | #### Commands 45 | | Shortcut | Full command | 46 | | ------------------------- | -------------------------------------------------------------- | 47 | | `gulp build:scss:[theme]` | `gulp exec:[theme] && gulp scss:[theme]` | 48 | | `gulp dev:scss:[theme]` | `gulp exec:[theme] && gulp scss:[theme] && gulp watch:[theme]` | 49 | 50 | List of gulp commands: 51 | - `gulp clean:[theme_key]` 52 | - `gulp deploy:[theme_key]` 53 | - `gulp exec:[theme_key]` 54 | - `gulp scss:[theme_key]` 55 | - `gulp less:[theme_key]` 56 | - `gulp watch:[theme_key]` 57 | - `gulp build:scss:[theme_key]` 58 | - `gulp build:less:[theme_key]` 59 | - `gulp dev:scss:[theme_key]` 60 | - `gulp dev:less:[theme_key]` 61 | 62 | #### BrowserSync 63 | Pass `--proxy http://magento.test` argument to `gulp watch:[theme_key]` or `gulp dev:scss[theme_key]` where http://magento.test is Magento base url and BrowserSync will be enabled. 64 | 65 | You can configure BrowserSync in `dev/tools/gulp/config/browser-sync.json`. 66 | [Reference](https://www.browsersync.io/docs/options) 67 | 68 | #### Example usage 69 | `gulp dev:scss:my_theme --proxy http://m2.test --phtml` 70 | 71 | ## Troubleshooting 72 | If you had previously installed Grunt, please make sure you have removed package-lock.json and node_modules folder. Then run `npm install`. 73 | 74 | For development with enabled SSL please [provide path to SSL key and certificate](https://www.browsersync.io/docs/options/#option-https) in BrowserSync configuration file. 75 | -------------------------------------------------------------------------------- /Instruction/MagentoImport.php: -------------------------------------------------------------------------------- 1 | design = $design; 49 | $this->fileSource = $fileSource; 50 | $this->errorHandler = $errorHandler; 51 | $this->assetRepository = $assetRepository; 52 | $this->themeProvider = $themeProvider; 53 | $this->fileHelper = $fileHelper; 54 | } 55 | 56 | /** 57 | * @inheritdoc 58 | */ 59 | public function process(\Magento\Framework\View\Asset\PreProcessor\Chain $chain) 60 | { 61 | $asset = $chain->getAsset(); 62 | $contentType = $chain->getContentType(); 63 | $replaceCallback = function ($matchContent) use ($asset, $contentType) { 64 | return $this->replace($matchContent, $asset, $contentType); 65 | }; 66 | 67 | $chain->setContent(preg_replace_callback( 68 | \Magento\Framework\Css\PreProcessor\Instruction\MagentoImport::REPLACE_PATTERN, 69 | $replaceCallback, 70 | $chain->getContent() 71 | )); 72 | } 73 | 74 | private function replace(array $matchedContent, LocalInterface $asset, $contentType) 75 | { 76 | $imports = []; 77 | try { 78 | $matchedFileId = $matchedContent['path']; 79 | 80 | if (!$this->fileHelper->isPartial($matchedFileId)) { 81 | $matchedFileId = $this->fileHelper->getUnderscoreNotation($matchedFileId); 82 | } 83 | 84 | $matchedFileId = $this->fileHelper->fixFileExtension($matchedFileId, $contentType); 85 | $relatedAsset = $this->assetRepository->createRelated($matchedFileId, $asset); 86 | $resolvedPath = $relatedAsset->getFilePath(); 87 | $files = $this->fileSource->getFiles($this->getTheme($relatedAsset), $resolvedPath); 88 | 89 | /** @var \Magento\Framework\View\File */ 90 | foreach ($files as $file) { 91 | $imports[] = $file->getModule() 92 | ? "@import '{$file->getModule()}::{$resolvedPath}';" 93 | : "@import '{$matchedFileId}';"; 94 | } 95 | } catch (\Exception $e) { 96 | $this->errorHandler->processException($e); 97 | } 98 | 99 | return implode("\n", $imports); 100 | } 101 | 102 | private function getTheme(LocalInterface $asset) 103 | { 104 | $context = $asset->getContext(); 105 | if ($context instanceof FallbackContext) { 106 | return $this->themeProvider->getThemeByFullPath( 107 | $context->getAreaCode() . '/' . $context->getThemePath() 108 | ); 109 | } 110 | return $this->design->getDesignTheme(); 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /etc/di.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | ClawRock\SassPreprocessor\Adapter\Scss\Processor 8 | 9 | 10 | scss 11 | Magento\Framework\Css\PreProcessor\Adapter\Less\Processor 12 | 13 | 14 | 15 | 16 | 17 | 18 | FileGeneratorPublicationDecoratorForSourceThemeDeploy 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | ClawRock\SassPreprocessor\Instruction\MagentoImport 27 | 28 | 29 | magento_import 30 | SassPreProcessorInstructionImportForSourceThemeDeploy 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | FileGeneratorPublicationDecoratorForBaseFlow 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | ClawRock\SassPreprocessor\Instruction\MagentoImport 47 | 48 | 49 | magento_import 50 | SassPreProcessorInstructionImportForBaseFlow 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | Magento\Framework\Css\PreProcessor\File\Collector\Aggregated 59 | 60 | 61 | 62 | 63 | 64 | ClawRock\SassPreprocessor\Console\Command\GulpInstallCommand 65 | ClawRock\SassPreprocessor\Console\Command\GulpThemeCommand 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | -------------------------------------------------------------------------------- /Console/Command/GulpInstallCommand.php: -------------------------------------------------------------------------------- 1 | filesystem = $filesystem; 60 | $this->directoryList = $directoryList; 61 | $this->componentRegistrar = $componentRegistrar; 62 | $this->styleFactory = $styleFactory; 63 | } 64 | 65 | protected function configure() 66 | { 67 | parent::configure(); 68 | 69 | $this->setDescription('Install Gulp'); 70 | } 71 | 72 | /** 73 | * @param \Symfony\Component\Console\Input\InputInterface $input 74 | * @param \Symfony\Component\Console\Output\OutputInterface $output 75 | * @return int|null|void 76 | * @throws \Magento\Framework\Exception\FileSystemException 77 | */ 78 | protected function execute(InputInterface $input, OutputInterface $output) 79 | { 80 | $this->io = $this->styleFactory->create(['input' => $input, 'output' => $output]); 81 | 82 | $this->io->title('Gulp install'); 83 | if (!$this->io->confirm('It may overwrite your files, are you sure?', true)) { 84 | return; 85 | } 86 | 87 | $this->modulePath = $this->componentRegistrar->getPath(ComponentRegistrar::MODULE, 'ClawRock_SassPreprocessor'); 88 | $this->filesystem->createDirectory($this->directoryList->getRoot() . '/' . self::GULP_PATH); 89 | $this->recursiveCopyToRoot($this->modulePath . '/' . self::GULP_PATH); 90 | 91 | foreach (self::ROOT_FILES as $file) { 92 | $this->copy($this->modulePath . '/dev/tools/' . $file, $this->directoryList->getRoot() . '/' . $file); 93 | } 94 | 95 | $this->io->success(array_map(function ($file) { 96 | return $file . ' created!'; 97 | }, $this->copiedFiles)); 98 | } 99 | 100 | /** 101 | * @param string $source 102 | * @throws \Magento\Framework\Exception\FileSystemException 103 | * 104 | * @SuppressWarnings(PHPMD.ElseExpression) 105 | */ 106 | private function recursiveCopyToRoot($source) 107 | { 108 | foreach ($this->filesystem->readDirectory($source) as $path) { 109 | $destination = $this->directoryList->getRoot() . str_replace($this->modulePath, '', $path); 110 | if ($this->filesystem->isDirectory($path)) { 111 | $this->filesystem->createDirectory($destination); 112 | $this->recursiveCopyToRoot($path); 113 | } else { 114 | $this->copy($path, $destination); 115 | } 116 | } 117 | } 118 | 119 | /** 120 | * @param string $source 121 | * @param string $destination 122 | * @throws \Magento\Framework\Exception\FileSystemException 123 | */ 124 | private function copy($source, $destination) 125 | { 126 | $this->filesystem->copy($source, $destination); 127 | $this->copiedFiles[] = $destination; 128 | } 129 | } 130 | -------------------------------------------------------------------------------- /Test/Unit/Console/Command/GulpInstallCommandTest.php: -------------------------------------------------------------------------------- 1 | filesystemMock = $this->getMockBuilder(\Magento\Framework\Filesystem\Driver\File::class) 65 | ->disableOriginalConstructor() 66 | ->getMock(); 67 | 68 | $this->directoryListMock = $this->getMockBuilder(\Magento\Framework\App\Filesystem\DirectoryList::class) 69 | ->disableOriginalConstructor() 70 | ->getMock(); 71 | 72 | $this->componentRegistrarMock = $this->getMockBuilder(ComponentRegistrarInterface::class) 73 | ->getMockForAbstractClass(); 74 | 75 | $this->styleMock = $this->getMockBuilder(\Symfony\Component\Console\Style\SymfonyStyle::class) 76 | ->disableOriginalConstructor() 77 | ->getMock(); 78 | 79 | $this->styleFactoryMock = $this->getMockBuilder(\Symfony\Component\Console\Style\SymfonyStyleFactory::class) 80 | ->setMethods(['create']) 81 | ->getMock(); 82 | 83 | $this->styleFactoryMock->expects(self::once()) 84 | ->method('create') 85 | ->willReturn($this->styleMock); 86 | 87 | $this->application = new Application(); 88 | $this->commandObject = (new ObjectManager($this))->getObject(GulpInstallCommand::class, [ 89 | 'filesystem' => $this->filesystemMock, 90 | 'directoryList' => $this->directoryListMock, 91 | 'componentRegistrar' => $this->componentRegistrarMock, 92 | 'styleFactory' => $this->styleFactoryMock, 93 | ]); 94 | 95 | $this->application->add($this->commandObject); 96 | $this->command = $this->application->find('dev:gulp:install'); 97 | $this->commandTester = new CommandTester($this->command); 98 | } 99 | 100 | public function testExecute() 101 | { 102 | $this->styleMock->expects(self::once())->method('confirm')->willReturn(true); 103 | 104 | $this->componentRegistrarMock->expects(self::once()) 105 | ->method('getPath') 106 | ->with(ComponentRegistrar::MODULE, 'ClawRock_SassPreprocessor'); 107 | 108 | $this->filesystemMock->expects(self::exactly(2)) 109 | ->method('createDirectory'); 110 | 111 | $this->filesystemMock->expects(self::exactly(2)) 112 | ->method('readDirectory') 113 | ->willReturnOnConsecutiveCalls(['path/to/directory', 'path/to/file1'], ['path/to/directory/file2']); 114 | 115 | $this->filesystemMock->expects(self::exactly(3)) 116 | ->method('isDirectory') 117 | ->withConsecutive(['path/to/directory'], ['path/to/directory/file2'], ['path/to/file1']) 118 | ->willReturnOnConsecutiveCalls(true, false, false); 119 | 120 | $this->filesystemMock->expects(self::exactly(count(GulpInstallCommand::ROOT_FILES) + 2)) 121 | ->method('copy'); 122 | 123 | $this->styleMock->expects(self::once()) 124 | ->method('success'); 125 | 126 | $this->commandTester->execute(['command' => $this->command->getName()]); 127 | } 128 | 129 | public function testExecuteNoConfirm() 130 | { 131 | $this->styleMock->expects(self::once())->method('confirm')->willReturn(false); 132 | 133 | $this->componentRegistrarMock->expects(self::never()) 134 | ->method('getPath') 135 | ->with(ComponentRegistrar::MODULE, 'ClawRock_SassPreprocessor'); 136 | 137 | $this->filesystemMock->expects(self::never())->method('createDirectory'); 138 | $this->filesystemMock->expects(self::never())->method('readDirectory'); 139 | $this->filesystemMock->expects(self::never())->method('isDirectory'); 140 | $this->filesystemMock->expects(self::never())->method('copy'); 141 | $this->styleMock->expects(self::never())->method('success'); 142 | 143 | $this->commandTester->execute(['command' => $this->command->getName()]); 144 | } 145 | } 146 | -------------------------------------------------------------------------------- /Test/Unit/Instruction/ImportTest.php: -------------------------------------------------------------------------------- 1 | moduleResolverMock = $this->getMockBuilder(\Magento\Framework\View\Asset\NotationResolver\Module::class) 56 | ->disableOriginalConstructor() 57 | ->getMock(); 58 | 59 | $this->relatedGeneratorMock = $this 60 | ->getMockBuilder(\Magento\Framework\Css\PreProcessor\FileGenerator\RelatedGenerator::class) 61 | ->disableOriginalConstructor() 62 | ->getMock(); 63 | 64 | $this->assetRepositoryMock = $this->getMockBuilder(\Magento\Framework\View\Asset\Repository::class) 65 | ->disableOriginalConstructor() 66 | ->getMock(); 67 | 68 | $this->fileHelperMock = $this->getMockBuilder(\ClawRock\SassPreprocessor\Helper\File::class) 69 | ->disableOriginalConstructor() 70 | ->getMock(); 71 | 72 | $this->chainMock = $this->getMockBuilder(\Magento\Framework\View\Asset\PreProcessor\Chain::class) 73 | ->disableOriginalConstructor() 74 | ->getMock(); 75 | 76 | $this->assetMock = $this->getMockBuilder(\Magento\Framework\View\Asset\LocalInterface::class) 77 | ->getMockForAbstractClass(); 78 | 79 | $this->assetFileMock = $this->getMockBuilder(\Magento\Framework\View\Asset\File::class) 80 | ->disableOriginalConstructor() 81 | ->getMock(); 82 | 83 | $this->chainMock->expects(self::once())->method('getContentType')->willReturn('scss'); 84 | $this->chainMock->expects(self::once())->method('getAsset')->willReturn($this->assetMock); 85 | 86 | $this->instruction = (new ObjectManager($this))->getObject(Import::class, [ 87 | 'notationResolver' => $this->moduleResolverMock, 88 | 'relatedFileGenerator' => $this->relatedGeneratorMock, 89 | 'assetRepository' => $this->assetRepositoryMock, 90 | 'fileHelper' => $this->fileHelperMock, 91 | ]); 92 | 93 | $this->relatedGeneratorMock->expects(self::once())->method('generate')->with($this->instruction); 94 | } 95 | 96 | public function testProcessNoImports() 97 | { 98 | $this->chainMock->expects(self::once()) 99 | ->method('getContent') 100 | ->willReturn('.class { color: #f00; }'); 101 | 102 | $this->fileHelperMock->expects(self::never())->method('fixFileExtension'); 103 | $this->assetRepositoryMock->expects(self::never())->method('createRelated'); 104 | $this->fileHelperMock->expects(self::never())->method('assetFileExists'); 105 | $this->fileHelperMock->expects(self::never())->method('getUnderscoreNotation'); 106 | $this->chainMock->expects(self::never())->method('setContent'); 107 | 108 | $this->instruction->process($this->chainMock); 109 | } 110 | 111 | public function testProcessImportExistingFile() 112 | { 113 | $this->chainMock->expects(self::once()) 114 | ->method('getContent') 115 | ->willReturn('@import \'_partial\';'); 116 | 117 | $this->fileHelperMock->expects(self::once())->method('fixFileExtension'); 118 | 119 | $this->assetRepositoryMock->expects(self::once()) 120 | ->method('createRelated') 121 | ->willReturn($this->assetFileMock); 122 | 123 | $this->fileHelperMock->expects(self::once())->method('assetFileExists')->willReturn(true); 124 | $this->fileHelperMock->expects(self::never())->method('getUnderscoreNotation'); 125 | 126 | $this->moduleResolverMock->expects(self::once()) 127 | ->method('convertModuleNotationToPath') 128 | ->willReturn('../Module/path'); 129 | 130 | $this->chainMock->expects(self::once()) 131 | ->method('setContent') 132 | ->with('@import \'../Module/path\';'); 133 | 134 | $this->instruction->process($this->chainMock); 135 | $this->assertNotEmpty($this->instruction->getRelatedFiles()); 136 | } 137 | 138 | public function testProcessImportFileWithoutUnderscore() 139 | { 140 | $this->chainMock->expects(self::once()) 141 | ->method('getContent') 142 | ->willReturn('@import \'partial\';'); 143 | 144 | $this->fileHelperMock->expects(self::once())->method('fixFileExtension'); 145 | $this->fileHelperMock->expects(self::once())->method('getUnderscoreNotation'); 146 | 147 | $this->assetRepositoryMock->expects(self::once()) 148 | ->method('createRelated') 149 | ->willReturn($this->assetFileMock); 150 | 151 | $this->fileHelperMock->expects(self::once()) 152 | ->method('assetFileExists') 153 | ->willReturn(false); 154 | 155 | $this->moduleResolverMock->expects(self::once()) 156 | ->method('convertModuleNotationToPath') 157 | ->willReturn('../Module/path'); 158 | 159 | $this->fileHelperMock->expects(self::once()) 160 | ->method('assetFileExists') 161 | ->willReturn(true); 162 | 163 | $this->instruction->process($this->chainMock); 164 | $this->assertNotEmpty($this->instruction->getRelatedFiles()); 165 | } 166 | } 167 | -------------------------------------------------------------------------------- /Test/Unit/Plugin/ChainPluginTest.php: -------------------------------------------------------------------------------- 1 | sourceThemeDeployCommandMock = $this 56 | ->getMockBuilder(\Magento\Developer\Console\Command\SourceThemeDeployCommand::class) 57 | ->disableOriginalConstructor() 58 | ->getMock(); 59 | 60 | $this->cliHelperMock = $this->getMockBuilder(\ClawRock\SassPreprocessor\Helper\Cli::class) 61 | ->disableOriginalConstructor() 62 | ->getMock(); 63 | 64 | $this->fileHelperMock = $this->getMockBuilder(\ClawRock\SassPreprocessor\Helper\File::class) 65 | ->disableOriginalConstructor() 66 | ->getMock(); 67 | 68 | $this->subjectMock = $this->getMockBuilder(\Magento\Framework\View\Asset\PreProcessor\Chain::class) 69 | ->disableOriginalConstructor() 70 | ->getMock(); 71 | 72 | $this->assetMock = $this->getMockBuilder(\Magento\Framework\View\Asset::class) 73 | ->disableOriginalConstructor() 74 | ->getMock(); 75 | 76 | $this->inputMock = $this->getMockBuilder(\Symfony\Component\Console\Input\Input::class) 77 | ->disableOriginalConstructor() 78 | ->getMock(); 79 | 80 | $this->inputDefinitionMock = $this->getMockBuilder(\Symfony\Component\Console\Input\InputDefinition::class) 81 | ->disableOriginalConstructor() 82 | ->getMock(); 83 | 84 | $this->plugin = (new ObjectManager($this))->getObject(ChainPlugin::class, [ 85 | 'sourceThemeDeployCommand' => $this->sourceThemeDeployCommandMock, 86 | 'fileHelper' => $this->fileHelperMock, 87 | 'cliHelper' => $this->cliHelperMock 88 | ]); 89 | } 90 | 91 | public function testNotCli() 92 | { 93 | $this->subjectMock->expects(self::never()) 94 | ->method('getAsset') 95 | ->willReturn($this->assetMock); 96 | 97 | $this->cliHelperMock->expects(self::once()) 98 | ->method('isCli') 99 | ->willReturn(false); 100 | 101 | $this->assertTrue($this->plugin->afterIsChanged($this->subjectMock, true)); 102 | } 103 | 104 | public function testNotSourceThemeDeployCommand() 105 | { 106 | $this->subjectMock->expects(self::never()) 107 | ->method('getAsset') 108 | ->willReturn($this->assetMock); 109 | 110 | $this->cliHelperMock->expects(self::once()) 111 | ->method('isCli') 112 | ->willReturn(true); 113 | 114 | $this->cliHelperMock->expects(self::once()) 115 | ->method('isCommand') 116 | ->with('dev:source-theme:deploy') 117 | ->willReturn(false); 118 | 119 | $this->assertTrue($this->plugin->afterIsChanged($this->subjectMock, true)); 120 | } 121 | 122 | public function testEntryFile() 123 | { 124 | $this->subjectMock->expects(self::once()) 125 | ->method('getAsset') 126 | ->willReturn($this->assetMock); 127 | 128 | $this->assetMock->expects(self::once()) 129 | ->method('getFilePath') 130 | ->willReturn('filepath.scss'); 131 | 132 | $this->expectSourceThemeDeployCommand(); 133 | $this->expectFileFromInput(); 134 | 135 | $this->assertTrue($this->plugin->afterIsChanged($this->subjectMock, true)); 136 | } 137 | 138 | public function testPartialFile() 139 | { 140 | $this->subjectMock->expects(self::once()) 141 | ->method('getAsset') 142 | ->willReturn($this->assetMock); 143 | 144 | $this->assetMock->expects(self::once()) 145 | ->method('getFilePath') 146 | ->willReturn('_partial.scss'); 147 | 148 | $this->expectSourceThemeDeployCommand(); 149 | $this->expectFileFromInput(); 150 | 151 | $this->assertFalse($this->plugin->afterIsChanged($this->subjectMock, true)); 152 | } 153 | 154 | private function expectSourceThemeDeployCommand() 155 | { 156 | $this->cliHelperMock->expects(self::once()) 157 | ->method('isCli') 158 | ->willReturn(true); 159 | 160 | $this->cliHelperMock->expects(self::once()) 161 | ->method('isCommand') 162 | ->with('dev:source-theme:deploy') 163 | ->willReturn(true); 164 | } 165 | 166 | private function expectFileFromInput() 167 | { 168 | $this->sourceThemeDeployCommandMock->expects(self::once()) 169 | ->method('getDefinition') 170 | ->willReturn($this->inputDefinitionMock); 171 | 172 | $this->cliHelperMock->expects(self::once()) 173 | ->method('getInput') 174 | ->with($this->inputDefinitionMock) 175 | ->willReturn($this->inputMock); 176 | 177 | $this->inputMock->expects(self::once()) 178 | ->method('getArgument') 179 | ->with(\Magento\Developer\Console\Command\SourceThemeDeployCommand::FILE_ARGUMENT) 180 | ->willReturn(['filepath']); 181 | 182 | $this->inputMock->expects(self::once()) 183 | ->method('getOption') 184 | ->with(\Magento\Developer\Console\Command\SourceThemeDeployCommand::TYPE_ARGUMENT) 185 | ->willReturn('scss'); 186 | 187 | $this->fileHelperMock->expects(self::once()) 188 | ->method('fixFileExtension') 189 | ->with('filepath', 'scss') 190 | ->willReturn('filepath.scss'); 191 | } 192 | } 193 | -------------------------------------------------------------------------------- /Console/Command/GulpThemeCommand.php: -------------------------------------------------------------------------------- 1 | storeManager = $storeManager; 74 | $this->scopeConfig = $scopeConfig; 75 | $this->filesystem = $filesystem; 76 | $this->directoryList = $directoryList; 77 | $this->componentRegistrar = $componentRegistrar; 78 | $this->styleFactory = $styleFactory; 79 | $this->questionFactory = $questionFactory; 80 | $this->fileHelper = $fileHelper; 81 | } 82 | 83 | protected function configure() 84 | { 85 | parent::configure(); 86 | 87 | $this->setDescription('Install Theme'); 88 | } 89 | 90 | protected function execute(InputInterface $input, OutputInterface $output) 91 | { 92 | $this->io = $this->styleFactory->create(['input' => $input, 'output' => $output]); 93 | 94 | $this->io->title('Gulp theme'); 95 | $this->installTheme(); 96 | } 97 | 98 | private function installTheme() 99 | { 100 | $themeConfig = $this->createThemeConfig(); 101 | $gruntConfigPath = $this->directoryList->getRoot() . '/' . self::GRUNT_CONFIG_FILE; 102 | if ($this->filesystem->isExists($gruntConfigPath)) { 103 | $gruntConfig = json_decode($this->filesystem->fileGetContents($gruntConfigPath), true); 104 | if (isset($gruntConfig['themes'])) { 105 | $this->writeThemeConfig($themeConfig, $gruntConfig['themes']); 106 | return; 107 | } 108 | throw new LocalizedException(new Phrase('Grunt config file corrupted.')); 109 | } 110 | $this->writeThemeConfig($themeConfig, $this->directoryList->getRoot() . '/' . self::DEFAULT_THEMES_CONFIG); 111 | } 112 | 113 | private function writeThemeConfig($config, $file) 114 | { 115 | $themeConfigPath = $this->directoryList->getRoot() . '/' . $file; 116 | 117 | $themeConfig = $this->fileHelper->readFileAsArray($themeConfigPath, 'js'); 118 | 119 | if (empty($themeConfig)) { 120 | throw new FileSystemException(new Phrase('Theme config not found')); 121 | } 122 | 123 | foreach ($themeConfig as $line => $content) { 124 | if (strpos($content, 'module.exports') !== false) { 125 | array_splice($themeConfig, $line+1, 0, $this->convertConfigToJson($config)); 126 | $this->filesystem->filePutContents($themeConfigPath, $themeConfig); 127 | break; 128 | } 129 | } 130 | } 131 | 132 | private function convertConfigToJson($config) 133 | { 134 | $jsonConfig = $config['key'] . ': '; 135 | unset($config['key']); 136 | $jsonConfig .= json_encode($config, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . ','; 137 | $lines = explode("\n", $jsonConfig); 138 | foreach ($lines as &$line) { 139 | $line = ' ' . $line . PHP_EOL; 140 | } 141 | unset($line); 142 | 143 | return $lines; 144 | } 145 | 146 | private function createThemeConfig() 147 | { 148 | $themeKey = $this->io->ask('Theme key', null, [$this, 'validateEmpty']); 149 | $themeArea = $this->io->choice('Theme area', ['frontend', 'adminhtml'], 'frontend'); 150 | 151 | /** @var \Symfony\Component\Console\Question\Question $question */ 152 | $question = $this->questionFactory->create(['question' => 'Theme name']); 153 | $question->setValidator([$this, 'validateEmpty']); 154 | $question->setAutocompleterValues($this->getInstalledThemes()); 155 | $themeName = $this->io->askQuestion($question); 156 | 157 | /** @var \Symfony\Component\Console\Question\Question $question */ 158 | $question = $this->questionFactory->create(['question' => 'Theme locale']); 159 | $question->setValidator([$this, 'validateEmpty']); 160 | $question->setAutocompleterValues($this->getLocales()); 161 | $themeLocale = $this->io->askQuestion($question); 162 | 163 | $themeDsl = $this->io->choice('Theme DSL', ['less', 'scss'], 'scss'); 164 | $themeFiles = $this->io->ask( 165 | 'Theme files (separated by comma)', 166 | 'css/styles, css/print', 167 | [$this, 'validateEmpty'] 168 | ); 169 | 170 | return [ 171 | 'key' => $themeKey, 172 | 'area' => $themeArea, 173 | 'name' => $themeName, 174 | 'locale' => $themeLocale, 175 | 'dsl' => $themeDsl, 176 | 'files' => array_map('trim', explode(',', $themeFiles)) 177 | ]; 178 | } 179 | 180 | private function getInstalledThemes() 181 | { 182 | return array_map(function ($theme) { 183 | return preg_replace('/frontend\/|adminhtml\//', '', $theme); 184 | }, array_keys($this->componentRegistrar->getPaths(\Magento\Framework\Component\ComponentRegistrar::THEME))); 185 | } 186 | 187 | private function getLocales() 188 | { 189 | $locales = []; 190 | foreach ($this->storeManager->getStores() as $store) { 191 | $locales[] = $this->scopeConfig->getValue( 192 | \Magento\Directory\Helper\Data::XML_PATH_DEFAULT_LOCALE, 193 | \Magento\Store\Model\ScopeInterface::SCOPE_STORE, 194 | $store->getId() 195 | ); 196 | } 197 | 198 | return array_unique($locales); 199 | } 200 | 201 | public function validateEmpty($answer) 202 | { 203 | if (empty($answer)) { 204 | throw new \RuntimeException('Please provide value.'); 205 | } 206 | 207 | return $answer; 208 | } 209 | } 210 | -------------------------------------------------------------------------------- /Test/Unit/Adapter/Scss/ProcessorTest.php: -------------------------------------------------------------------------------- 1 | appStateMock = $this->getMockBuilder(\Magento\Framework\App\State::class) 63 | ->disableOriginalConstructor() 64 | ->getMock(); 65 | 66 | $this->compilerMock = $this->getMockBuilder(\Leafo\ScssPhp\Compiler::class)->getMock(); 67 | 68 | $this->compiler = new \Leafo\ScssPhp\Compiler(); 69 | 70 | $this->directoryListMock = $this->getMockBuilder(\Magento\Framework\App\Filesystem\DirectoryList::class) 71 | ->disableOriginalConstructor() 72 | ->getMock(); 73 | 74 | $this->compilerFactoryMock = $this->getMockBuilder(\Leafo\ScssPhp\CompilerFactory::class) 75 | ->setMethods(['create']) 76 | ->getMock(); 77 | 78 | $this->assetSourceMock = $this->getMockBuilder(\Magento\Framework\View\Asset\Source::class) 79 | ->disableOriginalConstructor() 80 | ->getMock(); 81 | 82 | $this->assetMock = $this->getMockBuilder(\Magento\Framework\View\Asset\File::class) 83 | ->disableOriginalConstructor() 84 | ->getMock(); 85 | 86 | $this->loggerMock = $this->getMockBuilder(\Psr\Log\LoggerInterface::class)->getMockForAbstractClass(); 87 | 88 | $this->processor = (new ObjectManager($this))->getObject(Processor::class, [ 89 | 'logger' => $this->loggerMock, 90 | 'appState' => $this->appStateMock, 91 | 'assetSource' => $this->assetSourceMock, 92 | 'directoryList' => $this->directoryListMock, 93 | 'compilerFactory' => $this->compilerFactoryMock 94 | ]); 95 | } 96 | 97 | /** 98 | * @dataProvider compressedDataProvider 99 | */ 100 | public function testProcessContentCompressed($input, $output) 101 | { 102 | $this->compilerFactoryMock->expects(self::once()) 103 | ->method('create') 104 | ->willReturn($this->compiler); 105 | 106 | $this->appStateMock->expects(self::once()) 107 | ->method('getMode') 108 | ->willReturn(\Magento\Framework\App\State::MODE_PRODUCTION); 109 | 110 | $this->directoryListMock->expects(self::once()) 111 | ->method('getPath') 112 | ->with(\Magento\Framework\App\Filesystem\DirectoryList::VAR_DIR) 113 | ->willReturn(__DIR__ . '/_files'); 114 | 115 | $this->assetSourceMock->expects(self::once()) 116 | ->method('getContent') 117 | ->with($this->assetMock) 118 | ->willReturn($input); 119 | 120 | $this->assertEquals($output, $this->processor->processContent($this->assetMock)); 121 | } 122 | 123 | /** 124 | * @dataProvider dataProvider 125 | */ 126 | public function testProcessContent($input, $output) 127 | { 128 | $this->compilerFactoryMock->expects(self::once()) 129 | ->method('create') 130 | ->willReturn($this->compiler); 131 | 132 | $this->appStateMock->expects(self::once()) 133 | ->method('getMode') 134 | ->willReturn(\Magento\Framework\App\State::MODE_DEVELOPER); 135 | 136 | $this->directoryListMock->expects(self::once()) 137 | ->method('getPath') 138 | ->with(\Magento\Framework\App\Filesystem\DirectoryList::VAR_DIR) 139 | ->willReturn(__DIR__ . '/_files'); 140 | 141 | $this->assetSourceMock->expects(self::once()) 142 | ->method('getContent') 143 | ->with($this->assetMock) 144 | ->willReturn($input); 145 | 146 | // Assert without whitespaces 147 | $this->assertEquals( 148 | preg_replace('/\s+/', ' ', trim($output)), 149 | preg_replace('/\s+/', ' ', trim($this->processor->processContent($this->assetMock))) 150 | ); 151 | } 152 | 153 | public function testProcessEmptyContent() 154 | { 155 | $this->compilerFactoryMock->expects(self::once()) 156 | ->method('create') 157 | ->willReturn($this->compilerMock); 158 | 159 | $this->compilerMock->expects(self::never()) 160 | ->method('compile'); 161 | 162 | $this->assetSourceMock->expects(self::once()) 163 | ->method('getContent') 164 | ->with($this->assetMock) 165 | ->willReturn(''); 166 | 167 | $this->assertEmpty($this->processor->processContent($this->assetMock)); 168 | } 169 | 170 | public function testProcessContentEmptyResult() 171 | { 172 | $this->compilerFactoryMock->expects(self::once()) 173 | ->method('create') 174 | ->willReturn($this->compiler); 175 | 176 | $this->assetSourceMock->expects(self::once()) 177 | ->method('getContent') 178 | ->with($this->assetMock) 179 | ->willReturn('@mixin empty() {}'); 180 | 181 | $this->loggerMock->expects(self::once()) 182 | ->method('warning'); 183 | 184 | $this->assertEmpty($this->processor->processContent($this->assetMock)); 185 | } 186 | 187 | public function testProcessContentException() 188 | { 189 | $this->expectException(\Magento\Framework\View\Asset\ContentProcessorException::class); 190 | 191 | $this->compilerFactoryMock->expects(self::once()) 192 | ->method('create') 193 | ->willReturn($this->compiler); 194 | 195 | $this->assetSourceMock->expects(self::once()) 196 | ->method('getContent') 197 | ->with($this->assetMock) 198 | ->willReturn('.invalid# {'); 199 | 200 | $this->processor->processContent($this->assetMock); 201 | } 202 | 203 | public function compressedDataProvider() 204 | { 205 | return $this->scssProvider(true); 206 | } 207 | 208 | public function dataProvider() 209 | { 210 | return $this->scssProvider(false); 211 | } 212 | 213 | public function scssProvider($compress) 214 | { 215 | $files = []; 216 | foreach (self::TEST_FILES as $file) { 217 | $files[] = [ 218 | rtrim(file_get_contents(__DIR__ . '/_files/' . $file . '.scss')), 219 | rtrim(file_get_contents(__DIR__ . '/_files/' . $file . ($compress ? '.min' : '') . '.css')), 220 | ]; 221 | } 222 | 223 | return $files; 224 | } 225 | } 226 | -------------------------------------------------------------------------------- /Test/Unit/Instruction/MagentoImportTest.php: -------------------------------------------------------------------------------- 1 | designMock = $this->getMockBuilder(\Magento\Framework\View\DesignInterface::class) 71 | ->getMockForAbstractClass(); 72 | 73 | $this->fileSourceMock = $this->getMockBuilder(\Magento\Framework\View\File\CollectorInterface::class) 74 | ->getMockForAbstractClass(); 75 | 76 | $this->errHandlerMock = $this->getMockBuilder(\Magento\Framework\Css\PreProcessor\ErrorHandlerInterface::class) 77 | ->getMockForAbstractClass(); 78 | 79 | $this->assetRepositoryMock = $this->getMockBuilder(\Magento\Framework\View\Asset\Repository::class) 80 | ->disableOriginalConstructor() 81 | ->getMock(); 82 | 83 | $this->themeProviderMock = $this 84 | ->getMockBuilder(\Magento\Framework\View\Design\Theme\ThemeProviderInterface::class) 85 | ->getMockForAbstractClass(); 86 | 87 | $this->fileHelperMock = $this->getMockBuilder(\ClawRock\SassPreprocessor\Helper\File::class) 88 | ->disableOriginalConstructor() 89 | ->getMock(); 90 | 91 | $this->chainMock = $this->getMockBuilder(\Magento\Framework\View\Asset\PreProcessor\Chain::class) 92 | ->disableOriginalConstructor() 93 | ->getMock(); 94 | 95 | $this->assetMock = $this->getMockBuilder(\Magento\Framework\View\Asset\LocalInterface::class) 96 | ->getMockForAbstractClass(); 97 | 98 | $this->chainMock->expects(self::once()) 99 | ->method('getAsset') 100 | ->willReturn($this->assetMock); 101 | 102 | $this->chainMock->expects(self::once()) 103 | ->method('getContentType') 104 | ->willReturn('scss'); 105 | 106 | $this->fileMock = $this->getMockBuilder(\Magento\Framework\View\Asset\File::class) 107 | ->disableOriginalConstructor() 108 | ->getMock(); 109 | 110 | $this->themeMock = $this->getMockBuilder(\Magento\Framework\View\Design\ThemeInterface::class) 111 | ->getMockForAbstractClass(); 112 | 113 | $this->instruction = (new ObjectManager($this))->getObject(MagentoImport::class, [ 114 | 'design' => $this->designMock, 115 | 'fileSource' => $this->fileSourceMock, 116 | 'errorHandler' => $this->errHandlerMock, 117 | 'assetRepository' => $this->assetRepositoryMock, 118 | 'themeProvider' => $this->themeProviderMock, 119 | 'fileHelper' => $this->fileHelperMock, 120 | ]); 121 | } 122 | 123 | public function testProcess() 124 | { 125 | $this->expectMagentoImport(); 126 | 127 | $this->designMock->expects(self::once()) 128 | ->method('getDesignTheme') 129 | ->willReturn($this->themeMock); 130 | 131 | $this->chainMock->expects(self::once()) 132 | ->method('setContent') 133 | ->with("@import 'Vendor_Module::source/_module.scss';\n@import 'Another_Module::source/_module.scss';"); 134 | 135 | $this->instruction->process($this->chainMock); 136 | } 137 | 138 | public function testProcessFallbackTheme() 139 | { 140 | $this->expectMagentoImport(); 141 | 142 | $contextMock = $this->getMockBuilder(\Magento\Framework\View\Asset\File\FallbackContext::class) 143 | ->disableOriginalConstructor() 144 | ->getMock(); 145 | 146 | $this->fileMock->expects(self::once()) 147 | ->method('getContext') 148 | ->willReturn($contextMock); 149 | 150 | $contextMock->expects(self::once()) 151 | ->method('getAreaCode') 152 | ->willReturn('frontend'); 153 | 154 | $contextMock->expects(self::once()) 155 | ->method('getThemePath') 156 | ->willReturn('ClawRock/blank'); 157 | 158 | $this->themeProviderMock->expects(self::once()) 159 | ->method('getThemeByFullPath') 160 | ->with('frontend/ClawRock/blank') 161 | ->willReturn($this->themeMock); 162 | 163 | $this->chainMock->expects(self::once()) 164 | ->method('setContent') 165 | ->with("@import 'Vendor_Module::source/_module.scss';\n@import 'Another_Module::source/_module.scss';"); 166 | 167 | $this->instruction->process($this->chainMock); 168 | } 169 | 170 | public function testProcessWithoutMagentoImport() 171 | { 172 | $css = '.class { color: #f00; }'; 173 | 174 | $this->chainMock->expects(self::atLeastOnce()) 175 | ->method('getContent') 176 | ->willReturn($css); 177 | 178 | $this->instruction->process($this->chainMock); 179 | $this->assertEquals($css, $this->chainMock->getContent()); 180 | } 181 | 182 | public function testProcessException() 183 | { 184 | $this->errHandlerMock->expects(self::once())->method('processException'); 185 | 186 | $this->chainMock->expects(self::atLeastOnce()) 187 | ->method('getContent') 188 | ->willReturn('//@magento_import \'source/module\';'); 189 | 190 | $this->fileHelperMock->expects(self::once()) 191 | ->method('isPartial') 192 | ->willThrowException(new \Exception()); 193 | 194 | $this->instruction->process($this->chainMock); 195 | } 196 | 197 | private function expectMagentoImport() 198 | { 199 | $this->errHandlerMock->expects(self::never())->method('processException'); 200 | 201 | $this->chainMock->expects(self::atLeastOnce()) 202 | ->method('getContent') 203 | ->willReturn('//@magento_import \'source/module\';'); 204 | 205 | $this->fileHelperMock->expects(self::once()) 206 | ->method('isPartial') 207 | ->with('source/module')->willReturn(false); 208 | 209 | $this->fileHelperMock->expects(self::once()) 210 | ->method('getUnderscoreNotation') 211 | ->with('source/module') 212 | ->willReturn('source/_module'); 213 | 214 | $this->fileHelperMock->expects(self::once()) 215 | ->method('fixFileExtension') 216 | ->with('source/_module') 217 | ->willReturn('source/_module.scss'); 218 | 219 | $this->assetRepositoryMock->expects(self::once()) 220 | ->method('createRelated') 221 | ->with('source/_module.scss', $this->assetMock) 222 | ->willReturn($this->fileMock); 223 | 224 | $this->fileMock->expects(self::once()) 225 | ->method('getFilePath') 226 | ->willReturn('source/_module.scss'); 227 | 228 | $fileMock1 = $this->getMockBuilder(\Magento\Framework\View\File::class) 229 | ->disableOriginalConstructor() 230 | ->getMock(); 231 | 232 | $fileMock2 = $this->getMockBuilder(\Magento\Framework\View\File::class) 233 | ->disableOriginalConstructor() 234 | ->getMock(); 235 | 236 | $fileMock1->expects(self::atLeastOnce()) 237 | ->method('getModule') 238 | ->willReturn('Vendor_Module'); 239 | 240 | $fileMock2->expects(self::atLeastOnce()) 241 | ->method('getModule') 242 | ->willReturn('Another_Module'); 243 | 244 | $this->fileSourceMock->expects(self::once()) 245 | ->method('getFiles') 246 | ->with($this->themeMock, 'source/_module.scss') 247 | ->willReturn([$fileMock1, $fileMock2]); 248 | } 249 | } 250 | -------------------------------------------------------------------------------- /Test/Unit/Console/Command/GulpThemeCommandTest.php: -------------------------------------------------------------------------------- 1 | storeManagerMock = $this->getMockBuilder(\Magento\Store\Model\StoreManagerInterface::class) 90 | ->getMockForAbstractClass(); 91 | 92 | $this->scopeConfigMock = $this->getMockBuilder(\Magento\Framework\App\Config\ScopeConfigInterface::class) 93 | ->getMockForAbstractClass(); 94 | 95 | $this->filesystemMock = $this->getMockBuilder(\Magento\Framework\Filesystem\Driver\File::class) 96 | ->disableOriginalConstructor() 97 | ->getMock(); 98 | 99 | $this->directoryListMock = $this->getMockBuilder(\Magento\Framework\App\Filesystem\DirectoryList::class) 100 | ->disableOriginalConstructor() 101 | ->getMock(); 102 | 103 | $this->questionMock = $this->getMockBuilder(\Symfony\Component\Console\Question\Question::class) 104 | ->disableOriginalConstructor() 105 | ->getMock(); 106 | 107 | $this->componentRegistrarMock = $this->getMockBuilder(ComponentRegistrarInterface::class) 108 | ->getMockForAbstractClass(); 109 | 110 | $this->styleMock = $this->getMockBuilder(\Symfony\Component\Console\Style\SymfonyStyle::class) 111 | ->disableOriginalConstructor() 112 | ->getMock(); 113 | 114 | $this->styleFactoryMock = $this->getMockBuilder(\Symfony\Component\Console\Style\SymfonyStyleFactory::class) 115 | ->setMethods(['create']) 116 | ->getMock(); 117 | 118 | $this->questionFactoryMock = $this->getMockBuilder(\Symfony\Component\Console\Question\QuestionFactory::class) 119 | ->setMethods(['create']) 120 | ->getMock(); 121 | 122 | $this->fileHelperMock = $this->getMockBuilder(\ClawRock\SassPreprocessor\Helper\File::class) 123 | ->disableOriginalConstructor() 124 | ->getMock(); 125 | 126 | $this->application = new Application(); 127 | $this->commandObject = (new ObjectManager($this))->getObject(GulpThemeCommand::class, [ 128 | 'storeManager' => $this->storeManagerMock, 129 | 'scopeConfig' => $this->scopeConfigMock, 130 | 'filesystem' => $this->filesystemMock, 131 | 'directoryList' => $this->directoryListMock, 132 | 'componentRegistrar' => $this->componentRegistrarMock, 133 | 'styleFactory' => $this->styleFactoryMock, 134 | 'questionFactory' => $this->questionFactoryMock, 135 | 'fileHelper' => $this->fileHelperMock, 136 | ]); 137 | 138 | $this->application->add($this->commandObject); 139 | $this->command = $this->application->find('dev:gulp:theme'); 140 | $this->commandTester = new CommandTester($this->command); 141 | } 142 | 143 | public function testExecute() 144 | { 145 | $this->expectThemeConfig(); 146 | 147 | $this->fileHelperMock->expects(self::once()) 148 | ->method('readFileAsArray') 149 | ->willReturn(['module.exports = {', '"blank":', '{}', '}']); 150 | 151 | $this->filesystemMock->expects(self::once()) 152 | ->method('isExists') 153 | ->willReturn(false); 154 | 155 | $this->filesystemMock->expects(self::once())->method('filePutContents'); 156 | 157 | $this->commandTester->execute(['command' => $this->command->getName()]); 158 | } 159 | 160 | public function testExecuteAlternativeConfig() 161 | { 162 | $this->expectThemeConfig(); 163 | 164 | $this->fileHelperMock->expects(self::once()) 165 | ->method('readFileAsArray') 166 | ->willReturn(['module.exports = {', '"blank":', '{}', '}']); 167 | 168 | $this->filesystemMock->expects(self::once()) 169 | ->method('isExists') 170 | ->willReturn(true); 171 | 172 | $this->filesystemMock->expects(self::once()) 173 | ->method('fileGetContents') 174 | ->willReturn('{"themes": "path/to/config/file"}'); 175 | 176 | $this->filesystemMock->expects(self::once())->method('filePutContents'); 177 | 178 | $this->commandTester->execute(['command' => $this->command->getName()]); 179 | } 180 | 181 | public function testExecuteCorruptedConfig() 182 | { 183 | $this->expectException(\Magento\Framework\Exception\LocalizedException::class); 184 | 185 | $this->expectThemeConfig(); 186 | 187 | $this->fileHelperMock->expects(self::never())->method('readFileAsArray'); 188 | $this->filesystemMock->expects(self::once())->method('isExists')->willReturn(true); 189 | $this->filesystemMock->expects(self::once())->method('fileGetContents')->willReturn('{}'); 190 | $this->filesystemMock->expects(self::never())->method('filePutContents'); 191 | 192 | $this->commandTester->execute(['command' => $this->command->getName()]); 193 | } 194 | 195 | public function testExecuteEmptyConfig() 196 | { 197 | $this->expectException(\Magento\Framework\Exception\FileSystemException::class); 198 | 199 | $this->expectThemeConfig(); 200 | 201 | $this->fileHelperMock->expects(self::once())->method('readFileAsArray')->willReturn([]); 202 | $this->filesystemMock->expects(self::once())->method('isExists')->willReturn(false); 203 | $this->filesystemMock->expects(self::never())->method('filePutContents'); 204 | 205 | $this->commandTester->execute(['command' => $this->command->getName()]); 206 | } 207 | 208 | public function testValidateEmpty() 209 | { 210 | $this->assertEquals('lipsum', $this->commandObject->validateEmpty('lipsum')); 211 | } 212 | 213 | public function testValidateEmptyFailure() 214 | { 215 | $this->expectException(\RuntimeException::class); 216 | $this->commandObject->validateEmpty(null); 217 | } 218 | 219 | private function expectThemeConfig() 220 | { 221 | $this->styleFactoryMock->expects(self::once())->method('create')->willReturn($this->styleMock); 222 | 223 | $this->styleMock->expects(self::exactly(2)) 224 | ->method('ask') 225 | ->willReturnOnConsecutiveCalls('themeKey', 'css/styles, css/print'); 226 | 227 | $this->styleMock->expects(self::exactly(2)) 228 | ->method('choice') 229 | ->willReturnOnConsecutiveCalls('frontend', 'scss'); 230 | 231 | $this->questionFactoryMock->expects(self::exactly(2)) 232 | ->method('create') 233 | ->willReturn($this->questionMock); 234 | 235 | $this->styleMock->expects(self::exactly(2)) 236 | ->method('askQuestion') 237 | ->with($this->questionMock) 238 | ->willReturnOnConsecutiveCalls('test', 'en_US'); 239 | 240 | $this->componentRegistrarMock->expects(self::once()) 241 | ->method('getPaths') 242 | ->with(\Magento\Framework\Component\ComponentRegistrar::THEME) 243 | ->willReturn([ 244 | 'frontend/Magento/blank' => [], 245 | 'frontend/Magento/Luma' => [], 246 | 'frontend/ClawRock/blank' => [] 247 | ]); 248 | 249 | $this->storeManagerMock->expects(self::once()) 250 | ->method('getStores') 251 | ->willReturn([new DataObject(['id' => 1]), new DataObject(['id' => 2]), new DataObject(['id' => 3])]); 252 | 253 | $this->scopeConfigMock->expects(self::exactly(3)) 254 | ->method('getValue') 255 | ->willReturnOnConsecutiveCalls('en_US', 'pl_PL', 'en_US'); 256 | } 257 | } 258 | --------------------------------------------------------------------------------