Repository: ember-cli/ember-cli
Pull request #7788 (2018-04-23 08:04:18)

Split template processing and grouping
With this change, application is now only responsible for assembling
templates in one tree, whereas packager know how to properly process
them.


Patch:
diff --git a/lib/broccoli/default-packager.js b/lib/broccoli/default-packager.js
index 1eccf3561..2eb2a0f63 100644
--- a/lib/broccoli/default-packager.js
+++ b/lib/broccoli/default-packager.js
@@ -9,10 +9,19 @@ const ConfigLoader = require('broccoli-config-loader');
 const addonProcessTree = require('../utilities/addon-process-tree');
 
 const preprocessJs = p.preprocessJs;
+const preprocessTemplates = p.preprocessTemplates;
 
 const DEFAULT_BOWER_PATH = 'bower_components';
 const DEFAULT_VENDOR_PATH = 'vendor';
 
+function callAddonsPreprocessTreeHook(project, type, tree) {
+  return addonProcessTree(project, 'preprocessTree', type, tree);
+}
+
+function callAddonsPostprocessTreeHook(project, type, tree) {
+  return addonProcessTree(project, 'postprocessTree', type, tree);
+}
+
 /*
   Creates an object with lists of files to be concatenated into `vendor.js` file.
 
@@ -79,6 +88,7 @@ module.exports = class DefaultPackager {
     this._cachedPublic = null;
     this._cachedConfig = null;
     this._cachedJavascript = null;
+    this._cachedProcessedTemplates = null;
 
     this.options = options || {};
 
@@ -91,6 +101,54 @@ module.exports = class DefaultPackager {
     this.scriptOutputFiles = this.options.scriptOutputFiles;
   }
 
+  /*
+   * Runs pre/post-processors hooks on the template files and returns a single
+   * tree with the processed templates.
+   *
+   * Given a tree:
+   *
+   * ```
+   * the-best-app-ever/
+   * └── templates
+   *     ├── application.hbs
+   *     ├── error.hbs
+   *     ├── index.hbs
+   *     └── loading.hbs
+   * ```
+   *
+   * Returns:
+   *
+   * ```
+   * the-best-app-ever/
+   * └── templates
+   *     ├── application.js
+   *     ├── error.js
+   *     ├── index.js
+   *     └── loading.js
+   * ```
+   *
+   * @private
+   * @method processTemplates
+   * @param {BroccoliTree} tree
+   * @return {BroccoliTree}
+  */
+  processTemplates(templates) {
+    if (this._cachedProcessedTemplates === null) {
+      let options = {
+        registry: this.registry,
+      };
+      let preprocessedTemplatesFromAddons = callAddonsPreprocessTreeHook(this.project, 'template', templates);
+
+      this._cachedProcessedTemplates = callAddonsPostprocessTreeHook(
+        this.project,
+        'template',
+        preprocessTemplates(preprocessedTemplatesFromAddons, options)
+      );
+    }
+
+    return this._cachedProcessedTemplates;
+  }
+
   /*
    * Given an input tree, returns a properly assembled Broccoli tree with bower
    * components.
diff --git a/lib/broccoli/ember-app.js b/lib/broccoli/ember-app.js
index 30bd12dec..661aeb1ed 100644
--- a/lib/broccoli/ember-app.js
+++ b/lib/broccoli/ember-app.js
@@ -1081,12 +1081,29 @@ class EmberApp {
     });
   }
 
-  /**
-    @private
-    @method _processedTemplatesTree
-    @return
+  /*
+   * Gather application and add-ons template files and return them in a single
+   * tree.
+   *
+   * Resulting tree:
+   *
+   * ```
+   * the-best-app-ever/
+   * └── templates
+   *     ├── application.hbs
+   *     ├── error.hbs
+   *     ├── index.hbs
+   *     └── loading.hbs
+   * ```
+   *
+   * Note, files in the example are "made up" and will differ from the real
+   * application.
+   *
+   * @private
+   * @method getTemplates
+   * @return {BroccoliTree}
   */
-  _processedTemplatesTree() {
+  getTemplates() {
     let addonTrees = this.addonTreesFor('templates');
     let mergedTemplates = mergeTrees(addonTrees, {
       overwrite: true,
@@ -1099,20 +1116,13 @@ class EmberApp {
       annotation: 'ProcessedTemplateTree',
     });
 
-    let combinedTemplates = mergeTrees([
+    return mergeTrees([
       addonTemplates,
       this._templatesTree(),
     ], {
-      annotation: 'addonPreprocessTree(template)',
+      annotation: 'Application Templates',
       overwrite: true,
     });
-
-    let templates = this.addonPreprocessTree('template', combinedTemplates);
-
-    return this.addonPostprocessTree('template', preprocessTemplates(templates, {
-      registry: this.registry,
-      annotation: 'TreeMerger (pod & standard templates)',
-    }));
   }
 
   /**
@@ -1386,7 +1396,7 @@ class EmberApp {
   */
   appAndDependencies() {
     let config = this._defaultPackager.packageConfig(this.tests);
-    let templates = this._processedTemplatesTree();
+    let templates = this._defaultPackager.processTemplates(this.getTemplates());
 
     let srcTree = this._processedSrcTree();
     let trees = [this._processedAppTree(), srcTree, templates].filter(Boolean);


Test patch:
diff --git a/tests/helpers/default-packager.js b/tests/helpers/default-packager.js
index f4fbac07d..ff15c7f1f 100644
--- a/tests/helpers/default-packager.js
+++ b/tests/helpers/default-packager.js
@@ -231,6 +231,33 @@ function getDependencyFor(key, value) {
   };
 }
 
+/*
+ * Generates the object that represents an application's registry where all
+ * file processors are stored.
+ *
+ * It takes two arguments: a type of files you want to register custom processor
+ * for and a function that takes a Broccoli tree and must return a Broccoli tree
+ * as well.
+ *
+ * @param {String} registryType i.e. 'template', 'js', 'css', 'src', 'all'
+ * @param {Function} fn Transormation that is applied to the input tree
+*/
+function setupRegistryFor(registryType, fn) {
+  return {
+    load(type) {
+      if (type === registryType) {
+        return [{
+          toTree(tree) {
+            return fn(tree);
+          },
+        }];
+      }
+
+      return [];
+    },
+  };
+}
+
 function setupProject(rootPath) {
   const path = require('path');
   const Project = require('../../lib/models/project');
@@ -244,6 +271,7 @@ function setupProject(rootPath) {
 
 module.exports = {
   setupProject,
+  setupRegistryFor,
   validateDefaultPackagedDist,
   getDefaultUnpackagedDist,
   getDependencyFor,
diff --git a/tests/unit/broccoli/default-packager/templates-test.js b/tests/unit/broccoli/default-packager/templates-test.js
new file mode 100644
index 000000000..5f0c2225d
--- /dev/null
+++ b/tests/unit/broccoli/default-packager/templates-test.js
@@ -0,0 +1,134 @@
+'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: Templates', function() {
+  let input, output;
+
+  let TEMPLATES = {
+    'the-best-app-ever': {
+      templates: {
+        'application.hbs': '',
+        'error.hbs': '',
+        'index.hbs': '',
+        'loading.hbs': '',
+      },
+    },
+  };
+
+  before(co.wrap(function *() {
+    input = yield createTempDir();
+
+    input.write(TEMPLATES);
+  }));
+
+  after(co.wrap(function *() {
+    yield input.dispose();
+  }));
+
+  afterEach(co.wrap(function *() {
+    yield output.dispose();
+  }));
+
+  it('caches processed templates tree', co.wrap(function *() {
+    let defaultPackager = new DefaultPackager({
+      name: 'the-best-app-ever',
+
+      registry: setupRegistryFor('template', function(tree) {
+        return new Funnel(tree, {
+          getDestinationPath(relativePath) {
+            return relativePath.replace(/hbs$/g, 'js');
+          },
+        });
+      }),
+
+      project: { addons: [] },
+    });
+
+    expect(defaultPackager._cachedProcessedTemplates).to.equal(null);
+
+    output = yield buildOutput(defaultPackager.processTemplates(input.path()));
+
+    expect(defaultPackager._cachedProcessedTemplates).to.not.equal(null);
+  }));
+
+  it('processes templates according to the registry', co.wrap(function *() {
+    let defaultPackager = new DefaultPackager({
+      name: 'the-best-app-ever',
+
+      registry: setupRegistryFor('template', function(tree) {
+        return new Funnel(tree, {
+          getDestinationPath(relativePath) {
+            return relativePath.replace(/hbs$/g, 'js');
+          },
+        });
+      }),
+
+      project: { addons: [] },
+    });
+
+    expect(defaultPackager._cachedProcessedTemplates).to.equal(null);
+
+    output = yield buildOutput(defaultPackager.processTemplates(input.path()));
+
+    let outputFiles = output.read();
+
+    expect(Object.keys(outputFiles['the-best-app-ever'].templates)).to.deep.equal([
+      'application.js',
+      'error.js',
+      'index.js',
+      'loading.js',
+    ]);
+  }));
+
+  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',
+
+      registry: setupRegistryFor('template', function(tree) {
+        return new Funnel(tree, {
+          getDestinationPath(relativePath) {
+            return relativePath.replace(/hbs$/g, 'js');
+          },
+        });
+      }),
+
+      // 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._cachedProcessedTemplates).to.equal(null);
+
+    output = yield buildOutput(defaultPackager.processTemplates(input.path()));
+
+    expect(addonPreprocessTreeHookCalled).to.equal(true);
+    expect(addonPostprocessTreeHookCalled).to.equal(true);
+  }));
+});
diff --git a/tests/unit/broccoli/ember-app-test.js b/tests/unit/broccoli/ember-app-test.js
index d3c49f458..d2498155b 100644
--- a/tests/unit/broccoli/ember-app-test.js
+++ b/tests/unit/broccoli/ember-app-test.js
@@ -2,10 +2,15 @@
 
 'use strict';
 
+const co = require('co');
 const path = require('path');
 const Project = require('../../../lib/models/project');
 const expect = require('chai').expect;
 const td = require('testdouble');
+const broccoliTestHelper = require('broccoli-test-helper');
+
+const buildOutput = broccoliTestHelper.buildOutput;
+const createTempDir = broccoliTestHelper.createTempDir;
 
 const MockCLI = require('../../helpers/mock-cli');
 
@@ -27,8 +32,8 @@ function mockTemplateRegistry(app) {
     }
     return oldLoad.apply(app.registry, arguments);
   };
-}
 
+}
 describe('EmberApp', function() {
   let project, projectPath, app, addon;
 
@@ -52,6 +57,53 @@ describe('EmberApp', function() {
     project = setupProject(projectPath);
   });
 
+  describe('getTemplates()', function() {
+    it('returns application and add-ons template files', co.wrap(function *() {
+      let input = yield createTempDir();
+      let addonFooTemplates = yield createTempDir();
+      let addonBarTemplates = yield createTempDir();
+
+      input.write({
+        'application.hbs': '',
+        'error.hbs': '',
+        'loading.hbs': '',
+      });
+      addonFooTemplates.write({
+        'foo.hbs': 'foo',
+      });
+      addonBarTemplates.write({
+        'bar.hbs': 'bar',
+      });
+
+      let app = new EmberApp({
+        project,
+      });
+      app.trees.templates = input.path();
+      app.addonTreesFor = function() {
+        return [
+          addonFooTemplates.path(),
+          addonBarTemplates.path(),
+        ];
+      };
+
+      let output = yield buildOutput(app.getTemplates());
+      let outputFiles = output.read();
+
+      expect(outputFiles['test-project'].templates).to.deep.equal({
+        'application.hbs': '',
+        'error.hbs': '',
+        'loading.hbs': '',
+        'foo.hbs': 'foo',
+        'bar.hbs': 'bar',
+      });
+
+      yield input.dispose();
+      yield addonFooTemplates.dispose();
+      yield addonBarTemplates.dispose();
+      yield output.dispose();
+    }));
+  });
+
   describe('constructor', function() {
     it('should override project.configPath if configPath option is specified', function() {
       project.configPath = function() { return 'original value'; };
@@ -414,15 +466,6 @@ describe('EmberApp', function() {
 
         expect(stylesOutput).to.eql(['batman']);
       });
-
-      it('template type is called', function() {
-        app._processedTemplatesTree();
-
-        let captor = td.matchers.captor();
-        td.verify(app.addonPostprocessTree('template', captor.capture()));
-
-        expect(captor.value.description).to.equal('template', 'should be called with consolidated tree');
-      });
     });
 
     describe('toTree', function() {
@@ -465,9 +508,9 @@ describe('EmberApp', function() {
         mockTemplateRegistry(app);
 
         app.index = td.function();
-        app._processedTemplatesTree = td.function();
+        app._defaultPackager.processTemplates = td.function();
 
-        td.when(app._processedTemplatesTree(), { ignoreExtraArgs: true }).thenReturn('x');
+        td.when(app._defaultPackager.processTemplates(), { ignoreExtraArgs: true }).thenReturn('x');
         td.when(addon.postprocessTree(), { ignoreExtraArgs: true }).thenReturn('blap');
         td.when(app.index(), { ignoreExtraArgs: true }).thenReturn(null);