Repository: ember-cli/ember-cli
Pull request #7819 (2018-05-08 08:47:35)

Move styles packaging


Patch:
diff --git a/lib/broccoli/default-packager.js b/lib/broccoli/default-packager.js
index 28fa168cb..283f39d7e 100644
--- a/lib/broccoli/default-packager.js
+++ b/lib/broccoli/default-packager.js
@@ -14,6 +14,7 @@ const addonProcessTree = require('../utilities/addon-process-tree');
 const preprocessCss = p.preprocessCss;
 const preprocessJs = p.preprocessJs;
 const preprocessTemplates = p.preprocessTemplates;
+const preprocessMinifyCss = p.preprocessMinifyCss;
 
 const DEFAULT_BOWER_PATH = 'bower_components';
 const DEFAULT_VENDOR_PATH = 'vendor';
@@ -109,6 +110,7 @@ module.exports = class DefaultPackager {
     this._cachedJavascript = null;
     this._cachedProcessedSrc = null;
     this._cachedProcessedIndex = null;
+    this._cachedProcessedStyles = null;
     this._cachedProcessedTemplates = null;
     this._cachedProcessedJavascript = null;
     this._cachedEmberCliInternalTree = null;
@@ -122,8 +124,10 @@ module.exports = class DefaultPackager {
     this.project = this.options.project;
     this.registry = this.options.registry;
     this.sourcemaps = this.options.sourcemaps;
+    this.minifyCSS = this.options.minifyCSS;
     this.distPaths = this.options.distPaths;
     this.areTestsEnabled = this.options.areTestsEnabled;
+    this.styleOutputFiles = this.options.styleOutputFiles;
     this.scriptOutputFiles = this.options.scriptOutputFiles;
     this.storeConfigInMeta = this.options.storeConfigInMeta;
     this.customTransformsMap = this.options.customTransformsMap;
@@ -487,6 +491,92 @@ module.exports = class DefaultPackager {
     return this._cachedProcessedJavascript;
   }
 
+  /*
+   * Compiles application css files, runs pre/post-processors hooks on the them,
+   * concatenates them into one application and vendor files and returns a
+   * single tree.
+   *
+   * Given an input tree that looks like:
+   *
+   * ```
+   * addon-tree-output/
+   *   ...
+   * bower_components/
+   *   hint.css/
+   *   ...
+   * the-best-app-ever/
+   *   styles/
+   *   ...
+   * vendor/
+   *   font-awesome/
+   *   ...
+   * ```
+   *
+   * Returns:
+   *
+   * ```
+   * assets/
+   *   the-best-app-ever.css
+   *   vendor.css
+   * ```
+   *
+   * @private
+   * @method packageStyles
+   * @return {BroccoliTree}
+  */
+  packageStyles(tree) {
+    if (this._cachedProcessedStyles === null) {
+      let stylesAndVendor = callAddonsPreprocessTreeHook(this.project, 'css', tree);
+      let cssMinificationEnabled = this.minifyCSS.enabled;
+      let options = {
+        outputPaths: this.distPaths.appCssFile,
+        registry: this.registry,
+        minifyCSS: this.minifyCSS.options,
+      };
+
+      let preprocessedStyles = preprocessCss(
+        stylesAndVendor,
+        `/${this.name}/styles`,
+        '/assets',
+        options
+      );
+
+      let vendorStyles = [];
+      for (let outputFile in this.styleOutputFiles) {
+        let isMainVendorFile = outputFile === this.distPaths.vendorCssFile;
+        let headerFiles = this.styleOutputFiles[outputFile];
+        let inputFiles = isMainVendorFile ? ['addon-tree-output/**/*.css'] : [];
+
+        vendorStyles.push(concat(stylesAndVendor, {
+          headerFiles,
+          inputFiles,
+          outputFile,
+          allowNone: true,
+          annotation: `Concat: Vendor Styles${outputFile}`,
+        }));
+      }
+
+      vendorStyles = mergeTrees(vendorStyles, {
+        annotation: 'TreeMerger (vendorStyles)',
+        overwrite: true,
+      });
+
+      if (cssMinificationEnabled === true) {
+        preprocessedStyles = preprocessMinifyCss(preprocessedStyles, options);
+        vendorStyles = preprocessMinifyCss(vendorStyles, options);
+      }
+
+      this._cachedProcessedStyles = callAddonsPostprocessTreeHook(this.project, 'css', mergeTrees([
+        preprocessedStyles,
+        vendorStyles,
+      ], {
+        annotation: 'Packaged Styles',
+      }));
+    }
+
+    return this._cachedProcessedStyles;
+  }
+
   /*
    * Runs pre/post-processors hooks on the application files (javascript,
    * styles, templates) and returns a single tree with the processed ones.
diff --git a/lib/broccoli/ember-app.js b/lib/broccoli/ember-app.js
index 2dccb8a36..ed0e16511 100644
--- a/lib/broccoli/ember-app.js
+++ b/lib/broccoli/ember-app.js
@@ -14,13 +14,10 @@ const Project = require('../models/project');
 const SilentError = require('silent-error');
 
 let preprocessJs = p.preprocessJs;
-let preprocessCss = p.preprocessCss;
 let isType = p.isType;
 
 let preprocessTemplates = p.preprocessTemplates;
 
-let preprocessMinifyCss = p.preprocessMinifyCss;
-
 const concat = require('broccoli-concat');
 const BroccoliDebug = require('broccoli-debug');
 const ModuleNormalizer = require('broccoli-module-normalizer');
@@ -160,7 +157,9 @@ class EmberApp {
       project: this.project,
       registry: this.registry,
       sourcemaps: this.options.sourcemaps,
+      minifyCSS: this.options.minifyCSS,
       areTestsEnabled: this.tests,
+      styleOutputFiles: this._styleOutputFiles,
       scriptOutputFiles: this._scriptOutputFiles,
       autoRun: this.options.autoRun,
       customTransformsMap: this._customTransformsMap,
@@ -171,6 +170,7 @@ class EmberApp {
         appCssFile: this.options.outputPaths.app.css,
         appHtmlFile: this.options.outputPaths.app.html,
         vendorJsFile: this.options.outputPaths.vendor.js,
+        vendorCssFile: this.options.outputPaths.vendor.css,
       },
     });
   }
@@ -308,7 +308,7 @@ class EmberApp {
       outputPaths: {
         app: {
           css: {
-            'app': `/assets/${this.name}.css`,
+            app: `/assets/${this.name}.css`,
           },
           js: `/assets/${this.name}.js`,
         },
@@ -982,6 +982,31 @@ class EmberApp {
     });
   }
 
+  /*
+   * Gather add-ons style (css/sass/less) files and return them in a single
+   * tree.
+   *
+   * Resulting tree:
+   *
+   * ```
+   * the-best-app-ever/
+   * └── app
+   *     └── styles
+   *         ├── ember-basic-dropdown.scss
+   *         └── ember-power-select.scss
+   * ```
+   *
+   * @private
+   * @method getAddonStyles
+   * @return {BroccoliTree}
+  */
+  getAddonStyles() {
+    return mergeTrees(this.addonTreesFor('styles'), {
+      overwrite: true,
+      annotation: 'Addon Styles',
+    });
+  }
+
   /*
    * Gather add-ons template files and return them in a single tree.
    *
@@ -1366,81 +1391,6 @@ class EmberApp {
     return !!this.registry.availablePlugins[addonName];
   }
 
-  /**
-    Returns the tree for styles
-
-    @private
-    @method styles
-    @return {Tree} Merged tree for styles
-  */
-  styles() {
-    if (!this._cachedStylesTree) {
-      let addonTrees = this.addonTreesFor('styles');
-      let external = this.getExternalTree();
-
-      let styles;
-      if (this.trees.styles) {
-        styles = new Funnel(this.trees.styles, {
-          srcDir: '/',
-          destDir: '/app/styles',
-          annotation: 'Funnel (styles)',
-        });
-      }
-
-      let trees = [external].concat(addonTrees, styles).filter(Boolean);
-
-      let cssMinificationEnabled = this.options.minifyCSS.enabled;
-      let options = {
-        outputPaths: this.options.outputPaths.app.css,
-        registry: this.registry,
-        minifyCSS: this.options.minifyCSS.options,
-      };
-
-      let stylesAndVendor = this.addonPreprocessTree('css', mergeTrees(trees, {
-        annotation: 'TreeMerger (stylesAndVendor)',
-        overwrite: true,
-      }));
-
-      let preprocessedStyles = preprocessCss(stylesAndVendor, '/app/styles', '/assets', options);
-
-      let vendorStyles = [];
-      for (let outputFile in this._styleOutputFiles) {
-        let isMainVendorFile = outputFile === this.options.outputPaths.vendor.css;
-        let headerFiles = this._styleOutputFiles[outputFile];
-        let inputFiles = isMainVendorFile ? ['addon-tree-output/**/*.css'] : [];
-
-        vendorStyles.push(this._concatFiles(stylesAndVendor, {
-          headerFiles,
-          inputFiles,
-          outputFile,
-          allowNone: true,
-          annotation: `Concat: Vendor Styles${outputFile}`,
-        }));
-      }
-
-      vendorStyles = this.addonPreprocessTree('css', mergeTrees(vendorStyles, {
-        annotation: 'TreeMerger (vendorStyles)',
-        overwrite: true,
-      }));
-
-      if (cssMinificationEnabled === true) {
-        preprocessedStyles = preprocessMinifyCss(preprocessedStyles, options);
-        vendorStyles = preprocessMinifyCss(vendorStyles, options);
-      }
-
-      let mergedTrees = mergeTrees([
-        preprocessedStyles,
-        vendorStyles,
-      ], {
-        annotation: 'styles',
-      });
-
-      this._cachedStylesTree = this.addonPostprocessTree('css', mergedTrees);
-    }
-
-    return this._cachedStylesTree;
-  }
-
   /**
     Returns the tree for test files
 
@@ -1725,21 +1675,23 @@ class EmberApp {
     let trees = [
       this.getAppJavascript(),
       this.getAddonTemplates(),
+      this.getAddonStyles(),
       this.getExternalTree(),
       this.getSrc(),
     ].filter(Boolean);
 
-    let allJs = mergeTrees(trees, {
+    let fullTree = mergeTrees(trees, {
       overwrite: true,
-      annotation: 'Javascript',
+      annotation: 'Full Application',
     });
 
-    let javascriptTree = this._defaultPackager.packageJavascript(allJs);
+    let javascriptTree = this._defaultPackager.packageJavascript(fullTree);
+    let stylesTree = this._defaultPackager.packageStyles(fullTree);
 
     let sourceTrees = [
-      this._defaultPackager.processIndex(allJs),
+      this._defaultPackager.processIndex(fullTree),
       javascriptTree,
-      this.styles(),
+      stylesTree,
       this.otherAssets(),
       publicTree,
     ].filter(Boolean);


Test patch:
diff --git a/tests/helpers/default-packager.js b/tests/helpers/default-packager.js
index 5a7f6966c..79fcf1368 100644
--- a/tests/helpers/default-packager.js
+++ b/tests/helpers/default-packager.js
@@ -255,8 +255,8 @@ function setupRegistryFor(registryType, fn) {
     load(type) {
       if (type === registryType) {
         return [{
-          toTree(tree) {
-            return fn(tree);
+          toTree() {
+            return fn.apply(this, arguments);
           },
         }];
       }
diff --git a/tests/unit/broccoli/default-packager/styles-test.js b/tests/unit/broccoli/default-packager/styles-test.js
new file mode 100644
index 000000000..fd2edf53f
--- /dev/null
+++ b/tests/unit/broccoli/default-packager/styles-test.js
@@ -0,0 +1,346 @@
+'use strict';
+
+const co = require('co');
+const expect = require('chai').expect;
+const Funnel = require('broccoli-funnel');
+const DefaultPackager = require('../../../../lib/broccoli/default-packager');
+const broccoliTestHelper = require('broccoli-test-helper');
+const defaultPackagerHelpers = require('../../../helpers/default-packager');
+
+const buildOutput = broccoliTestHelper.buildOutput;
+const createTempDir = broccoliTestHelper.createTempDir;
+const setupRegistryFor = defaultPackagerHelpers.setupRegistryFor;
+
+describe('Default Packager: Styles', function() {
+  let input, output;
+
+  let styleOutputFiles = {
+    '/assets/vendor.css': [
+      'vendor/font-awesome/css/font-awesome.css',
+      'bower_components/hint.css/hint.css',
+      'vendor/1.css',
+      'vendor/2.css',
+      'vendor/3.css',
+    ],
+  };
+  let MODULES = {
+    'addon-tree-output': { },
+    'the-best-app-ever': {
+      'router.js': 'router.js',
+      'app.js': 'app.js',
+      'components': {
+        'x-foo.js': 'export default class {}',
+      },
+      'routes': {
+        'application.js': 'export default class {}',
+      },
+      'config': {
+        'environment.js': 'environment.js',
+      },
+      styles: {
+        'app.css': 'html { height: 100%; }',
+      },
+      'templates': {},
+    },
+    'bower_components': {
+      'hint.css': {
+        'hint.css': '',
+      },
+      '1.css': 'first',
+      '2.css': 'second',
+      '3.css': 'third',
+    },
+    'vendor': {
+      'font-awesome': {
+        css: {
+          'font-awesome.css': 'body { height: 100%; }',
+        },
+      },
+    },
+  };
+
+  before(co.wrap(function *() {
+    input = yield createTempDir();
+
+    input.write(MODULES);
+  }));
+
+  after(co.wrap(function *() {
+    yield input.dispose();
+  }));
+
+  afterEach(co.wrap(function *() {
+    yield output.dispose();
+  }));
+
+  it('caches packaged styles tree', co.wrap(function *() {
+    let defaultPackager = new DefaultPackager({
+      name: 'the-best-app-ever',
+      env: 'development',
+
+      distPaths: {
+        appCssFile: '/assets/the-best-app-ever.css',
+        vendorCssFile: '/assets/vendor.css',
+      },
+
+      registry: setupRegistryFor('css', function(tree) {
+        return new Funnel(tree, {
+          getDestinationPath(relativePath) {
+            return relativePath.replace(/scss$/g, 'css');
+          },
+        });
+      }),
+
+      minifyCSS: {
+        enabled: true,
+        options: {},
+      },
+
+      styleOutputFiles,
+
+      project: { addons: [] },
+    });
+
+    expect(defaultPackager._cachedProcessedStyles).to.equal(null);
+
+    output = yield buildOutput(defaultPackager.packageStyles(input.path()));
+
+    expect(defaultPackager._cachedProcessedStyles).to.not.equal(null);
+    expect(defaultPackager._cachedProcessedStyles._annotation).to.equal('Packaged Styles');
+  }));
+
+  it('does not minify css files when minification is disabled', co.wrap(function *() {
+    let defaultPackager = new DefaultPackager({
+      name: 'the-best-app-ever',
+      env: 'development',
+
+      distPaths: {
+        appCssFile: { app: '/assets/the-best-app-ever.css' },
+        vendorCssFile: '/assets/vendor.css',
+      },
+
+      registry: {
+        load: () => [],
+      },
+
+      minifyCSS: {
+        enabled: false,
+        options: {
+          processImport: false,
+          relativeTo: 'assets',
+        },
+      },
+
+      styleOutputFiles,
+
+      project: { addons: [] },
+    });
+
+    expect(defaultPackager._cachedProcessedStyles).to.equal(null);
+
+    output = yield buildOutput(defaultPackager.packageStyles(input.path()));
+
+    let outputFiles = output.read();
+
+    expect(Object.keys(outputFiles.assets)).to.deep.equal([
+      'the-best-app-ever.css',
+      'vendor.css',
+    ]);
+    expect(
+      outputFiles.assets['vendor.css'].trim()
+    ).to.equal('body { height: 100%; }');
+    expect(
+      outputFiles.assets['the-best-app-ever.css'].trim()
+    ).to.equal('html { height: 100%; }');
+  }));
+
+  it('minifies css files when minification is enabled', co.wrap(function *() {
+    let defaultPackager = new DefaultPackager({
+      name: 'the-best-app-ever',
+      env: 'development',
+
+      distPaths: {
+        appCssFile: { app: '/assets/the-best-app-ever.css' },
+        vendorCssFile: '/assets/vendor.css',
+      },
+
+      registry: {
+        load: () => [],
+      },
+
+      minifyCSS: {
+        enabled: true,
+        options: {
+          processImport: false,
+          relativeTo: 'assets',
+        },
+      },
+
+      styleOutputFiles,
+
+      project: { addons: [] },
+    });
+
+    expect(defaultPackager._cachedProcessedStyles).to.equal(null);
+
+    output = yield buildOutput(defaultPackager.packageStyles(input.path()));
+
+    let outputFiles = output.read();
+
+    expect(Object.keys(outputFiles.assets)).to.deep.equal([
+      'the-best-app-ever.css',
+      'vendor.css',
+    ]);
+    expect(
+      outputFiles.assets['vendor.css'].trim()
+    ).to.match(/^\S+$/, 'css file is minified');
+    expect(
+      outputFiles.assets['the-best-app-ever.css'].trim()
+    ).to.match(/^\S+$/, 'css file is minified');
+  }));
+
+  it('processes css according to the registry', co.wrap(function *() {
+    let defaultPackager = new DefaultPackager({
+      name: 'the-best-app-ever',
+      env: 'development',
+
+      distPaths: {
+        appCssFile: { app: '/assets/the-best-app-ever.css' },
+        vendorCssFile: '/assets/vendor.css',
+      },
+
+      registry: setupRegistryFor('css', function(tree, inputPath, outputPath, options) {
+        return new Funnel(tree, {
+          getDestinationPath(relativePath) {
+            if (relativePath.includes('app.css')) {
+              return options.outputPaths.app.replace(/css$/g, 'zss');
+            }
+
+            return relativePath;
+          },
+        });
+      }),
+
+      minifyCSS: {
+        enabled: true,
+        options: {
+          processImport: false,
+          relativeTo: 'assets',
+        },
+      },
+
+      styleOutputFiles,
+
+      project: { addons: [] },
+    });
+
+    expect(defaultPackager._cachedProcessedStyles).to.equal(null);
+
+    output = yield buildOutput(defaultPackager.packageStyles(input.path()));
+
+    let outputFiles = output.read();
+
+    expect(Object.keys(outputFiles.assets)).to.deep.equal([
+      'the-best-app-ever.zss',
+      'vendor.css',
+    ]);
+  }));
+
+  it('runs pre/post-process add-on hooks', co.wrap(function *() {
+    let addonPreprocessTreeHookCalled = false;
+    let addonPostprocessTreeHookCalled = false;
+
+    let defaultPackager = new DefaultPackager({
+      name: 'the-best-app-ever',
+      env: 'development',
+
+      distPaths: {
+        appCssFile: { app: '/assets/the-best-app-ever.css' },
+        vendorCssFile: '/assets/vendor.css',
+      },
+
+      registry: {
+        load: () => [],
+      },
+
+      minifyCSS: {
+        enabled: true,
+        options: {
+          processImport: false,
+          relativeTo: 'assets',
+        },
+      },
+
+      styleOutputFiles,
+
+      // avoid using `testdouble.js` here on purpose; it does not have a "proxy"
+      // option, where a function call would be registered and the original
+      // would be returned
+      project: {
+        addons: [{
+          preprocessTree(type, tree) {
+            addonPreprocessTreeHookCalled = true;
+
+            return tree;
+          },
+          postprocessTree(type, tree) {
+            addonPostprocessTreeHookCalled = true;
+
+            return tree;
+          },
+        }],
+      },
+    });
+
+    expect(defaultPackager._cachedProcessedStyles).to.equal(null);
+
+    output = yield buildOutput(defaultPackager.packageStyles(input.path()));
+
+    expect(addonPreprocessTreeHookCalled).to.equal(true);
+    expect(addonPostprocessTreeHookCalled).to.equal(true);
+  }));
+
+  it('prevents duplicate inclusion, maintains order: CSS', co.wrap(function *() {
+    let importFilesMap = {
+      '/assets/vendor.css': [
+        'bower_components/1.css',
+        'bower_components/2.css',
+        'bower_components/3.css',
+        'bower_components/1.css',
+      ],
+    };
+    let defaultPackager = new DefaultPackager({
+      name: 'the-best-app-ever',
+      env: 'development',
+
+      distPaths: {
+        appCssFile: { app: '/assets/the-best-app-ever.css' },
+        vendorCssFile: '/assets/vendor.css',
+      },
+
+      registry: {
+        load: () => [],
+      },
+
+      minifyCSS: {
+        enabled: true,
+        options: {
+          processImport: false,
+          relativeTo: 'assets',
+        },
+      },
+
+      styleOutputFiles: importFilesMap,
+
+      project: { addons: [] },
+    });
+
+    expect(defaultPackager._cachedProcessedStyles).to.equal(null);
+
+    output = yield buildOutput(defaultPackager.packageStyles(input.path()));
+
+    let outputFiles = output.read();
+
+    expect(outputFiles.assets['vendor.css']).to.equal('first\nsecond\nthird');
+  }));
+});
diff --git a/tests/unit/broccoli/ember-app-test.js b/tests/unit/broccoli/ember-app-test.js
index 4710361b3..63c02c9b2 100644
--- a/tests/unit/broccoli/ember-app-test.js
+++ b/tests/unit/broccoli/ember-app-test.js
@@ -423,43 +423,6 @@ describe('EmberApp', function() {
       });
     });
 
-    describe('default vendor/vendor.css exists', function() {
-      beforeEach(function() {
-        app = new EmberApp({
-          project,
-        });
-
-        mockTemplateRegistry(app);
-      });
-
-      it('has default vendor.css', function() {
-        let styles = app.styles()._inputNodes.map(String);
-
-        expect(styles.length).to.eql(2);
-        expect(styles[0]).to.match(/Funnel/);
-        expect(styles[1]).to.match(/assets\/vendor.css/);
-      });
-    });
-
-    describe('postprocessTree is called properly', function() {
-      beforeEach(function() {
-        app = new EmberApp({
-          project,
-        });
-
-        app.addonPostprocessTree = td.function();
-        td.when(app.addonPostprocessTree(), { ignoreExtraArgs: true }).thenReturn(['batman']);
-
-        mockTemplateRegistry(app);
-      });
-
-      it('from .styles()', function() {
-        let stylesOutput = app.styles();
-
-        expect(stylesOutput).to.eql(['batman']);
-      });
-    });
-
     describe('toTree', function() {
       beforeEach(function() {
         addon = {
@@ -938,56 +901,18 @@ describe('EmberApp', function() {
         mockTemplateRegistry(app);
       });
 
-      it('prevents duplicate inclusion, maintains order: CSS', function() {
-        app.import('files/a.css');
-        app.import('files/e.css'); // should be omitted.
-        app.import('files/b.css');
-        app.import('files/c.css');
-        app.import('files/d.css');
-        app.import('files/c.css', { prepend: true }); // should be omitted.
-        app.import('files/e.css');
-
-        app.styles(); // run
-
-        expect(count).to.eql(1);
-
-        expect(args[0]).to.deep.eql({
-          annotation: 'Concat: Vendor Styles/assets/vendor.css',
-          allowNone: true,
-          headerFiles: [
-            'files/a.css',
-            'files/b.css',
-            'files/c.css',
-            'files/d.css',
-            'files/e.css',
-          ],
-          inputFiles: ['addon-tree-output/**/*.css'],
-          outputFile: '/assets/vendor.css',
-        });
-      });
-
       it('correctly orders concats from app.styles()', function() {
         app.import('files/b.css');
         app.import('files/c.css');
         app.import('files/a.css', { prepend: true });
         app.import('files/d.css');
 
-        app.styles(); // run
-
-        expect(count).to.eql(1);
-
-        expect(args[0]).to.deep.eql({
-          annotation: 'Concat: Vendor Styles/assets/vendor.css',
-          allowNone: true,
-          headerFiles: [
-            'files/a.css',
-            'files/b.css',
-            'files/c.css',
-            'files/d.css',
-          ],
-          inputFiles: ['addon-tree-output/**/*.css'],
-          outputFile: '/assets/vendor.css',
-        });
+        expect(app._styleOutputFiles['/assets/vendor.css']).to.deep.equal([
+          'files/a.css',
+          'files/b.css',
+          'files/c.css',
+          'files/d.css',
+        ]);
       });
 
       it('correctly orders concats from app.testFiles()', function() {