├── .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 | [](https://packagist.org/packages/clawrock/magento2-sass-preprocessor)
2 | [](https://packagist.org/packages/clawrock/magento2-sass-preprocessor)
3 | [](https://travis-ci.org/clawrock/magento2-sass-preprocessor)
4 | [](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 |
--------------------------------------------------------------------------------