lib/broccoli/ember-app.js
'use strict';
/**
@module ember-cli
*/
const fs = require('fs');
const path = require('path');
const p = require('ember-cli-preprocess-registry/preprocessors');
const chalk = require('chalk');
const resolve = require('resolve');
const { assert, deprecate } = require('../debug');
const Project = require('../models/project');
const concat = require('broccoli-concat');
const BroccoliDebug = require('broccoli-debug');
const mergeTrees = require('./merge-trees');
const broccoliMergeTrees = require('broccoli-merge-trees');
const WatchedDir = require('broccoli-source').WatchedDir;
const UnwatchedDir = require('broccoli-source').UnwatchedDir;
const { merge, defaultsDeep, omitBy, isNull } = require('ember-cli-lodash-subset');
const Funnel = require('broccoli-funnel');
const logger = require('heimdalljs-logger')('ember-cli:ember-app');
const addonProcessTree = require('../utilities/addon-process-tree');
const lintAddonsByType = require('../utilities/lint-addons-by-type');
const DefaultPackager = require('./default-packager');
let DEFAULT_CONFIG = {
storeConfigInMeta: true,
autoRun: true,
outputPaths: {
app: {
html: 'index.html',
},
tests: {
js: '/assets/tests.js',
},
vendor: {
css: '/assets/vendor.css',
js: '/assets/vendor.js',
},
testSupport: {
css: '/assets/test-support.css',
js: {
testSupport: '/assets/test-support.js',
testLoader: '/assets/test-loader.js',
},
},
},
minifyCSS: {
options: { relativeTo: 'assets' },
},
sourcemaps: {},
trees: {},
addons: {},
};
class EmberApp {
/**
EmberApp is the main class Ember CLI uses to manage the Broccoli trees
for your application. It is very tightly integrated with Broccoli and has
a `toTree()` method you can use to get the entire tree for your application.
Available init options:
- storeConfigInMeta, defaults to `true`
- autoRun, defaults to `true`
- outputPaths, defaults to `{}`
- minifyCSS, defaults to `{enabled: !!isProduction,options: { relativeTo: 'assets' }}
- sourcemaps, defaults to `{}`
- trees, defaults to `{}`
- vendorFiles, defaults to `{}`
- addons, defaults to `{ exclude: [], include: [] }`
@class EmberApp
@constructor
@param {Object} [defaults]
@param {Object} [options={}] Configuration options
*/
constructor(defaults, options) {
if (arguments.length === 0) {
options = {};
} else if (arguments.length === 1) {
options = defaults;
} else {
defaultsDeep(options, defaults);
}
this._initProject(options);
this.name = options.name || this.project.name();
this.env = EmberApp.env();
this.isProduction = this.env === 'production';
this.registry = options.registry || p.defaultRegistry(this);
this._initTestsAndHinting(options);
this._initOptions(options);
this._initVendorFiles();
this._styleOutputFiles = {};
// ensure addon.css always gets concated
this._styleOutputFiles[this.options.outputPaths.vendor.css] = [];
this._scriptOutputFiles = {};
this._customTransformsMap = new Map();
this.otherAssetPaths = [];
this.legacyTestFilesToAppend = [];
this.vendorTestStaticStyles = [];
this._nodeModules = new Map();
this.trees = this.options.trees;
this.populateLegacyFiles();
this.initializeAddons();
this.project.addons.forEach((addon) => (addon.app = this));
p.setupRegistry(this);
this._importAddonTransforms();
this._notifyAddonIncluded();
this._debugTree = BroccoliDebug.buildDebugCallback('ember-app');
this._defaultPackager = new DefaultPackager({
env: this.env,
name: this.name,
autoRun: this.options.autoRun,
project: this.project,
registry: this.registry,
sourcemaps: this.options.sourcemaps,
minifyCSS: this.options.minifyCSS,
areTestsEnabled: this.tests,
styleOutputFiles: this._styleOutputFiles,
scriptOutputFiles: this._scriptOutputFiles,
storeConfigInMeta: this.options.storeConfigInMeta,
customTransformsMap: this._customTransformsMap,
additionalAssetPaths: this.otherAssetPaths,
vendorTestStaticStyles: this.vendorTestStaticStyles,
legacyTestFilesToAppend: this.legacyTestFilesToAppend,
distPaths: {
appJsFile: this.options.outputPaths.app.js,
appCssFile: this.options.outputPaths.app.css,
testJsFile: this.options.outputPaths.tests.js,
appHtmlFile: this.options.outputPaths.app.html,
vendorJsFile: this.options.outputPaths.vendor.js,
vendorCssFile: this.options.outputPaths.vendor.css,
testSupportJsFile: this.options.outputPaths.testSupport.js,
testSupportCssFile: this.options.outputPaths.testSupport.css,
},
});
this._cachedAddonBundles = {};
if (this.project.perBundleAddonCache && this.project.perBundleAddonCache.numProxies > 0) {
if (this.options.addons.include && this.options.addons.include.length) {
throw new Error(
[
`[ember-cli] addon bundle caching is disabled for apps that specify an addon "include"`,
'',
'All addons using bundle caching:',
...this.project.perBundleAddonCache.getPathsToAddonsOptedIn(),
].join('\n')
);
}
if (this.options.addons.exclude && this.options.addons.exclude.length) {
throw new Error(
[
`[ember-cli] addon bundle caching is disabled for apps that specify an addon "exclude"`,
'',
'All addons using bundle caching:',
...this.project.perBundleAddonCache.getPathsToAddonsOptedIn(),
].join('\n')
);
}
}
}
/**
Initializes the `tests` and `hinting` properties.
Defaults to `false` unless `ember test` was used or this is *not* a production build.
@private
@method _initTestsAndHinting
@param {Object} options
*/
_initTestsAndHinting(options) {
let testsEnabledDefault = process.env.EMBER_CLI_TEST_COMMAND === 'true' || !this.isProduction;
this.tests = 'tests' in options ? options.tests : testsEnabledDefault;
this.hinting = 'hinting' in options ? options.hinting : testsEnabledDefault;
}
/**
Initializes the `project` property from `options.project` or the
closest Ember CLI project from the current working directory.
@private
@method _initProject
@param {Object} options
*/
_initProject(options) {
let app = this;
this.project = options.project || Project.closestSync(process.cwd());
if (options.configPath) {
this.project.configPath = function () {
return app._resolveLocal(options.configPath);
};
this.project.configCache.clear();
}
}
/**
Initializes the `options` property from the `options` parameter and
a set of default values from Ember CLI.
@private
@method _initOptions
@param {Object} options
*/
_initOptions(options) {
deprecate(
'Using the `outputPaths` build option is deprecated, as output paths will no longer be predetermined under Embroider.',
typeof options.outputPaths === 'undefined',
{
for: 'ember-cli',
id: 'ember-cli.outputPaths-build-option',
since: {
available: '5.3.0',
enabled: '5.3.0',
},
until: '6.0.0',
}
);
let resolvePathFor = (defaultPath, specified) => {
let path = defaultPath;
if (specified && typeof specified === 'string') {
path = specified;
}
let resolvedPath = this._resolveLocal(path);
return resolvedPath;
};
let buildTreeFor = (defaultPath, specified, shouldWatch) => {
if (specified !== null && specified !== undefined && typeof specified !== 'string') {
return specified;
}
let tree = null;
let resolvedPath = resolvePathFor(defaultPath, specified);
if (fs.existsSync(resolvedPath)) {
if (shouldWatch !== false) {
tree = new WatchedDir(resolvedPath);
} else {
tree = new UnwatchedDir(resolvedPath);
}
}
return tree;
};
let trees = (options && options.trees) || {};
let appTree = buildTreeFor('app', trees.app);
let testsTree = buildTreeFor('tests', trees.tests, options.tests);
// these are contained within app/ no need to watch again
// (we should probably have the builder or the watcher dedup though)
this._stylesPath = resolvePathFor('app/styles', trees.styles);
let stylesTree = null;
if (fs.existsSync(this._stylesPath)) {
stylesTree = new UnwatchedDir(this._stylesPath);
}
let templatesTree = buildTreeFor('app/templates', trees.templates, false);
let vendorTree = buildTreeFor('vendor', trees.vendor);
let publicTree = buildTreeFor('public', trees.public);
let detectedDefaultOptions = {
babel: {},
minifyCSS: {
enabled: this.isProduction,
options: { processImport: false },
},
outputPaths: {
app: {
css: {
app: `/assets/${this.name}.css`,
},
js: `/assets/${this.name}.js`,
},
},
sourcemaps: {
enabled: !this.isProduction,
extensions: ['js'],
},
trees: {
app: appTree,
tests: testsTree,
styles: stylesTree,
templates: templatesTree,
vendor: vendorTree,
public: publicTree,
},
};
let emberCLIBabelInstance = this.project.findAddonByName('ember-cli-babel');
if (emberCLIBabelInstance) {
detectedDefaultOptions['ember-cli-babel'] = detectedDefaultOptions['ember-cli-babel'] || {};
detectedDefaultOptions['ember-cli-babel'].compileModules = true;
}
this.options = defaultsDeep(options, detectedDefaultOptions, DEFAULT_CONFIG);
// For now we must disable Babel sourcemaps due to unforeseen
// performance regressions.
if (!('sourceMaps' in this.options.babel)) {
this.options.babel.sourceMaps = false;
}
// Add testem.js to excludes for broccoli-asset-rev.
// This will allow tests to run against the production builds.
this.options.fingerprint = this.options.fingerprint || {};
this.options.fingerprint.exclude = this.options.fingerprint.exclude || [];
this.options.fingerprint.exclude.push('testem');
}
/**
Resolves a path relative to the project's root
@private
@method _resolveLocal
*/
_resolveLocal(to) {
return path.join(this.project.root, to);
}
/**
@private
@method _initVendorFiles
*/
_initVendorFiles() {
let emberSource = this.project.findAddonByName('ember-source');
assert(
'Could not find `ember-source`. Please install `ember-source` by running `ember install ember-source`.',
emberSource
);
this.vendorFiles = omitBy(
merge(
{
'ember.js': {
development: emberSource.paths.debug,
production: emberSource.paths.prod,
},
'ember-testing.js': [emberSource.paths.testing, { type: 'test' }],
},
this.options.vendorFiles
),
isNull
);
}
/**
Returns the environment name
@public
@static
@method env
@return {String} Environment name
*/
static env() {
return process.env.EMBER_ENV || 'development';
}
/**
Delegates to `broccoli-concat` with the `sourceMapConfig` option set to `options.sourcemaps`.
@private
@method _concatFiles
@param tree
@param options
@return
*/
_concatFiles(tree, options) {
options.sourceMapConfig = this.options.sourcemaps;
return concat(tree, options);
}
/**
Checks the result of `addon.isEnabled()` if it exists, defaults to `true` otherwise.
@private
@method _addonEnabled
@param {Addon} addon
@return {Boolean}
*/
_addonEnabled(addon) {
return !addon.isEnabled || addon.isEnabled();
}
/**
@private
@method _addonDisabledByExclude
@param {Addon} addon
@return {Boolean}
*/
_addonDisabledByExclude(addon) {
let exclude = this.options.addons.exclude;
return !!exclude && exclude.indexOf(addon.name) !== -1;
}
/**
@private
@method _addonDisabledByInclude
@param {Addon} addon
@return {Boolean}
*/
_addonDisabledByInclude(addon) {
let include = this.options.addons.include;
return !!include && include.indexOf(addon.name) === -1;
}
/**
Returns whether an addon should be added to the project
@private
@method shouldIncludeAddon
@param {Addon} addon
@return {Boolean}
*/
shouldIncludeAddon(addon) {
if (!this._addonEnabled(addon)) {
return false;
}
return !this._addonDisabledByExclude(addon) && !this._addonDisabledByInclude(addon);
}
/**
Calls the included hook on addons.
@private
@method _notifyAddonIncluded
*/
_notifyAddonIncluded() {
let addonNames = this.project.addons.map((addon) => addon.name);
if (this.options.addons.exclude) {
this.options.addons.exclude.forEach((addonName) => {
if (addonNames.indexOf(addonName) === -1) {
throw new Error(`Addon "${addonName}" defined in "exclude" is not found`);
}
});
}
if (this.options.addons.include) {
this.options.addons.include.forEach((addonName) => {
if (addonNames.indexOf(addonName) === -1) {
throw new Error(`Addon "${addonName}" defined in "include" is not found`);
}
});
}
// the addons must be filtered before the `included` hook is called
// in case an addon caches the project.addons list
this.project.addons = this.project.addons.filter((addon) => this.shouldIncludeAddon(addon));
this.project.addons.forEach((addon) => {
if (addon.included) {
addon.included(this);
}
});
}
/**
Calls the importTransforms hook on addons.
@private
@method _importAddonTransforms
*/
_importAddonTransforms() {
this.project.addons.forEach((addon) => {
if (this.shouldIncludeAddon(addon)) {
if (addon.importTransforms) {
let transforms = addon.importTransforms();
if (!transforms) {
throw new Error(`Addon "${addon.name}" did not return a transform map from importTransforms function`);
}
Object.keys(transforms).forEach((transformName) => {
let transformConfig = {
files: [],
options: {},
};
// store the transform info
if (typeof transforms[transformName] === 'object') {
transformConfig['callback'] = transforms[transformName].transform;
transformConfig['processOptions'] = transforms[transformName].processOptions;
} else if (typeof transforms[transformName] === 'function') {
transformConfig['callback'] = transforms[transformName];
transformConfig['processOptions'] = (assetPath, entry, options) => options;
} else {
throw new Error(
`Addon "${addon.name}" did not return a callback function correctly for transform "${transformName}".`
);
}
if (this._customTransformsMap.has(transformName)) {
// there is already a transform with a same name, therefore we warn the user
this.project.ui.writeWarnLine(
`Addon "${addon.name}" is defining a transform name: ${transformName} that is already being defined. Using transform from addon: "${addon.name}".`
);
}
this._customTransformsMap.set(transformName, transformConfig);
});
}
}
});
}
/**
Loads and initializes addons for this project.
Calls initializeAddons on the Project.
@private
@method initializeAddons
*/
initializeAddons() {
this.project.initializeAddons();
}
_addonTreesFor(type) {
return this.project.addons.reduce((sum, addon) => {
if (addon.treeFor) {
let tree = addon.treeFor(type);
if (tree && !mergeTrees.isEmptyTree(tree)) {
sum.push({
name: addon.name,
tree,
root: addon.root,
});
}
}
return sum;
}, []);
}
/**
Returns a list of trees for a given type, returned by all addons.
@private
@method addonTreesFor
@param {String} type Type of tree
@return {Array} List of trees
*/
addonTreesFor(type) {
return this._addonTreesFor(type).map((addonBundle) => addonBundle.tree);
}
/**
Runs addon post-processing on a given tree and returns the processed tree.
This enables addons to do process immediately **after** the preprocessor for a
given type is run, but before concatenation occurs. If an addon wishes to
apply a transform before the preprocessors run, they can instead implement the
preprocessTree hook.
To utilize this addons implement `postprocessTree` hook.
An example, would be to apply some broccoli transform on all JS files, but
only after the existing pre-processors have run.
```js
module.exports = {
name: 'my-cool-addon',
postprocessTree(type, tree) {
if (type === 'js') {
return someBroccoliTransform(tree);
}
return tree;
}
}
```
@private
@method addonPostprocessTree
@param {String} type Type of tree
@param {Tree} tree Tree to process
@return {Tree} Processed tree
*/
addonPostprocessTree(type, tree) {
return addonProcessTree(this.project, 'postprocessTree', type, tree);
}
/**
Runs addon pre-processing on a given tree and returns the processed tree.
This enables addons to do process immediately **before** the preprocessor for a
given type is run. If an addon wishes to apply a transform after the
preprocessors run, they can instead implement the postprocessTree hook.
To utilize this addons implement `preprocessTree` hook.
An example, would be to remove some set of files before the preprocessors run.
```js
var stew = require('broccoli-stew');
module.exports = {
name: 'my-cool-addon',
preprocessTree(type, tree) {
if (type === 'js' && type === 'template') {
return stew.rm(tree, someGlobPattern);
}
return tree;
}
}
```
@private
@method addonPreprocessTree
@param {String} type Type of tree
@param {Tree} tree Tree to process
@return {Tree} Processed tree
*/
addonPreprocessTree(type, tree) {
return addonProcessTree(this.project, 'preprocessTree', type, tree);
}
/**
Runs addon lintTree hooks and returns a single tree containing all
their output.
@private
@method addonLintTree
@param {String} type Type of tree
@param {Tree} tree Tree to process
@return {Tree} Processed tree
*/
addonLintTree(type, tree) {
let output = lintAddonsByType(this.project.addons, type, tree);
return mergeTrees(output, {
overwrite: true,
annotation: `TreeMerger (lint ${type})`,
});
}
/**
Imports legacy imports in this.vendorFiles
@private
@method populateLegacyFiles
*/
populateLegacyFiles() {
let name;
for (name in this.vendorFiles) {
let args = this.vendorFiles[name];
if (args === null) {
continue;
}
this.import.apply(this, [].concat(args));
}
}
podTemplates() {
return new Funnel(this.trees.app, {
include: this._podTemplatePatterns(),
exclude: ['templates/**/*'],
destDir: this.name,
annotation: 'Funnel: Pod Templates',
});
}
_templatesTree() {
if (!this._cachedTemplateTree) {
let trees = [];
if (this.trees.templates) {
let standardTemplates = new Funnel(this.trees.templates, {
srcDir: '/',
destDir: `${this.name}/templates`,
annotation: 'Funnel: Templates',
});
trees.push(standardTemplates);
}
if (this.trees.app) {
trees.push(this.podTemplates());
}
this._cachedTemplateTree = mergeTrees(trees, {
annotation: 'TreeMerge (templates)',
});
}
return this._cachedTemplateTree;
}
/*
* Gather application and add-ons javascript files and return them in a single
* tree.
*
* Resulting tree:
*
* ```
* the-best-app-ever/
* ├── adapters
* │ └── application.js
* ├── app.js
* ├── components
* ├── controllers
* ├── helpers
* │ ├── and.js
* │ ├── app-version.js
* │ ├── await.js
* │ ├── camelize.js
* │ ├── cancel-all.js
* │ ├── dasherize.js
* │ ├── dec.js
* │ ├── drop.js
* │ └── eq.js
* ...
* ```
*
* Note, files in the example are "made up" and will differ from the real
* application.
*
* @private
* @method getAppJavascript
* @return {BroccoliTree}
*/
getAppJavascript() {
let appTrees = [].concat(this.addonTreesFor('app'), this.trees.app).filter(Boolean);
let mergedApp = mergeTrees(appTrees, {
overwrite: true,
annotation: 'TreeMerger (app)',
});
let appTree = new Funnel(mergedApp, {
srcDir: '/',
destDir: this.name,
annotation: 'ProcessedAppTree',
});
return appTree;
}
/*
* 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 getStyles
* @return {BroccoliTree}
*/
getStyles() {
let styles;
if (this.trees.styles) {
styles = new Funnel(this.trees.styles, {
srcDir: '/',
destDir: '/app/styles',
annotation: 'Funnel (styles)',
});
}
let addons = this.addonTreesFor('styles');
styles = mergeTrees(addons.concat(styles), {
overwrite: true,
annotation: 'Styles',
});
return styles;
}
/*
* Gather 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 getAddonTemplates
* @return {BroccoliTree}
*/
getAddonTemplates() {
let addonTrees = this.addonTreesFor('templates');
let mergedTemplates = mergeTrees(addonTrees, {
overwrite: true,
annotation: 'TreeMerger (templates)',
});
let addonTemplates = new Funnel(mergedTemplates, {
srcDir: '/',
destDir: `${this.name}/templates`,
annotation: 'ProcessedTemplateTree',
});
return addonTemplates;
}
/**
@private
@method _podTemplatePatterns
@return {Array} An array of regular expressions.
*/
_podTemplatePatterns() {
return this.registry.extensionsForType('template').map((extension) => `**/*/template.${extension}`);
}
_nodeModuleTrees() {
if (!this._cachedNodeModuleTrees) {
this._cachedNodeModuleTrees = Array.from(
this._nodeModules.values(),
(module) =>
new Funnel(module.path, {
srcDir: '/',
destDir: `node_modules/${module.name}/`,
annotation: `Funnel (node_modules/${module.name})`,
})
);
}
return this._cachedNodeModuleTrees;
}
_addonBundles(type) {
if (!this._cachedAddonBundles[type]) {
let addonBundles = this._addonTreesFor(type);
this._cachedAddonBundles[type] = addonBundles;
}
return this._cachedAddonBundles[type];
}
/*
* @private
* @method @createAddonTree
*/
createAddonTree(type, outputDir, options) {
let addonBundles = this._addonBundles(type, options);
let tree = mergeTrees(
addonBundles.map(({ tree }) => tree),
{
overwrite: true,
annotation: `TreeMerger (${type})`,
}
);
return new Funnel(tree, {
destDir: outputDir,
annotation: `Funnel: ${outputDir} ${type}`,
});
}
addonTree() {
if (!this._cachedAddonTree) {
this._cachedAddonTree = this.createAddonTree('addon', 'addon-tree-output');
}
return this._cachedAddonTree;
}
addonTestSupportTree() {
if (!this._cachedAddonTestSupportTree) {
this._cachedAddonTestSupportTree = this.createAddonTree('addon-test-support', 'addon-test-support');
}
return this._cachedAddonTestSupportTree;
}
/*
* Gather all dependencies external to `ember-cli`, namely:
*
* + app `vendor` files
* + add-ons' `vendor` files
* + node modules
*
* Resulting tree:
*
* ```
* /
* ├── addon-tree-output/
* └── vendor/
* ```
*
* @private
* @method getExternalTree
* @return {BroccoliTree}
*/
getExternalTree() {
if (!this._cachedExternalTree) {
let vendorTrees = this.addonTreesFor('vendor');
vendorTrees.push(this.trees.vendor);
let vendor = this._defaultPackager.packageVendor(
mergeTrees(vendorTrees, {
overwrite: true,
annotation: 'TreeMerger (vendor)',
})
);
let addons = this.addonTree();
let trees = [vendor].concat(addons);
trees = this._nodeModuleTrees().concat(trees);
this._cachedExternalTree = mergeTrees(trees, {
annotation: 'TreeMerger (ExternalTree)',
overwrite: true,
});
}
return this._cachedExternalTree;
}
/*
* Gather all tests under `tests` folder.
*
* Resulting tree:
*
* ```
* /
* └── tests/
* ├── acceptance/
* ├── addon-test-support/
* ├── helpers/
* ├── integration/
* ├── lint/
* ├── unit/
* ├── index.html
* └── test-helper.js
* ```
*
* @private
* @method getTests
* @return {BroccoliTree}
*/
getTests() {
let addonTrees = this.addonTreesFor('test-support');
if (this.hinting) {
addonTrees.push(this.getLintTests());
}
let addonTestSupportFiles = this.addonTestSupportTree();
let allTests = mergeTrees(addonTrees.concat(this.trees.tests, addonTestSupportFiles), {
overwrite: true,
annotation: 'TreeMerger (tests)',
});
return new Funnel(allTests, {
destDir: 'tests',
});
}
/*
* Merges both application and add-ons public files and returns them in a
* single tree.
*
* Given a tree:
*
* ```
* ├── 500.html
* ├── images
* ├── maintenance.html
* └── robots.txt
* ```
*
* And add-on tree:
*
* ```
* ember-fetch/
* └── fastboot-fetch.js
* ```
*
* Returns:
*
* ```
* ├── 500.html
* ├── ember-fetch
* │ └── fastboot-fetch.js
* ├── images
* ├── maintenance.html
* └── robots.txt
* ```
*
* @private
* @method getPublic
* @return {BroccoliTree}
*/
getPublic() {
let addonPublicTrees = this.addonTreesFor('public');
addonPublicTrees = addonPublicTrees.concat(this.trees.public);
let mergedPublicTrees = mergeTrees(addonPublicTrees, {
annotation: 'Public',
overwrite: true,
});
return new Funnel(mergedPublicTrees, {
destDir: 'public',
});
}
/**
Runs the `app`, `tests` and `templates` trees through the chain of addons that produces lint trees.
Those lint trees are afterwards funneled into the `tests` folder, babel-ified and returned as an array.
@private
@method getLintTests
@return {Array}
*/
getLintTests() {
let lintTrees = [];
if (this.trees.app) {
let lintedApp = this.addonLintTree('app', this.trees.app);
lintedApp = new Funnel(lintedApp, {
destDir: 'lint',
annotation: 'Funnel (lint app)',
});
lintTrees.push(lintedApp);
}
let lintedTests = this.addonLintTree('tests', this.trees.tests);
let lintedTemplates = this.addonLintTree('templates', this._templatesTree());
lintedTests = new Funnel(lintedTests, {
destDir: 'lint',
annotation: 'Funnel (lint tests)',
});
lintedTemplates = new Funnel(lintedTemplates, {
destDir: 'lint',
annotation: 'Funnel (lint templates)',
});
return mergeTrees([lintedTests, lintedTemplates].concat(lintTrees), {
overwrite: true,
});
}
/**
@public
@method dependencies
@return {Object} Alias to the project's dependencies function
*/
dependencies(pkg) {
return this.project.dependencies(pkg);
}
/**
Imports an asset into the application.
@public
@method import
@param {Object|String} asset Either a path to the asset or an object with environment names and paths as key-value pairs.
@param {Object} [options] Options object
@param {String} [options.type='vendor'] Either 'vendor' or 'test'
@param {Boolean} [options.prepend=false] Whether or not this asset should be prepended
@param {String} [options.destDir] Destination directory, defaults to the name of the directory the asset is in
@param {String} [options.outputFile] Specifies the output file for given import. Defaults to assets/vendor.{js,css}
@param {Array} [options.using] Specifies the array of transformations to be done on the asset. Can do an amd shim and/or custom transformation
*/
import(asset, options) {
let assetPath = this._getAssetPath(asset);
if (!assetPath) {
return;
}
options = defaultsDeep(options || {}, {
type: 'vendor',
prepend: false,
});
let match = assetPath.match(/^node_modules\/((@[^/]+\/)?[^/]+)\//);
if (match !== null) {
let basedir = options.resolveFrom || this.project.root;
let name = match[1];
let _path = path.dirname(resolve.sync(`${name}/package.json`, { basedir }));
this._nodeModules.set(_path, { name, path: _path });
}
let directory = path.dirname(assetPath);
let subdirectory = directory.replace(new RegExp(`^vendor/|node_modules/`), '');
let extension = path.extname(assetPath);
if (!extension) {
throw new Error(
'You must pass a file to `app.import`. For directories specify them to the constructor under the `trees` option.'
);
}
this._import(assetPath, options, directory, subdirectory, extension);
}
/**
@private
@method _import
@param {String} assetPath
@param {Object} options
@param {String} directory
@param {String} subdirectory
@param {String} extension
*/
_import(assetPath, options, directory, subdirectory, extension) {
// TODO: refactor, this has gotten very messy. Relevant tests: tests/unit/broccoli/ember-app-test.js
let basename = path.basename(assetPath);
if (p.isType(assetPath, 'js', { registry: this.registry })) {
if (options.using) {
if (!Array.isArray(options.using)) {
throw new Error('You must pass an array of transformations for `using` option');
}
options.using.forEach((entry) => {
if (!entry.transformation) {
throw new Error(
`while importing ${assetPath}: each entry in the \`using\` list must have a \`transformation\` name`
);
}
let transformName = entry.transformation;
if (!this._customTransformsMap.has(transformName)) {
let availableTransformNames = Array.from(this._customTransformsMap.keys()).join(',');
throw new Error(
`while import ${assetPath}: found an unknown transformation name ${transformName}. Available transformNames are: ${availableTransformNames}`
);
}
// process options for the transform and update the options
let customTransforms = this._customTransformsMap.get(transformName);
customTransforms.options = customTransforms.processOptions(assetPath, entry, customTransforms.options);
customTransforms.files.push(assetPath);
});
}
if (options.type === 'vendor') {
options.outputFile = options.outputFile || this.options.outputPaths.vendor.js;
addOutputFile('firstOneWins', this._scriptOutputFiles, assetPath, options);
} else if (options.type === 'test') {
if (!allowImport('firstOneWins', this.legacyTestFilesToAppend, assetPath, options)) {
return;
}
if (options.prepend) {
this.legacyTestFilesToAppend.unshift(assetPath);
} else {
this.legacyTestFilesToAppend.push(assetPath);
}
} else {
throw new Error(
`You must pass either \`vendor\` or \`test\` for options.type in your call to \`app.import\` for file: ${basename}`
);
}
} else if (extension === '.css') {
if (options.type === 'vendor') {
options.outputFile = options.outputFile || this.options.outputPaths.vendor.css;
addOutputFile('lastOneWins', this._styleOutputFiles, assetPath, options);
} else {
if (!allowImport('lastOneWins', this.vendorTestStaticStyles, assetPath, options)) {
return;
}
if (options.prepend) {
this.vendorTestStaticStyles.unshift(assetPath);
} else {
this.vendorTestStaticStyles.push(assetPath);
}
}
} else {
let destDir = options.destDir;
if (destDir === '') {
destDir = '/';
}
this.otherAssetPaths.push({
src: directory,
file: basename,
dest: destDir || subdirectory,
});
}
}
/**
@private
@method _getAssetPath
@param {(Object|String)} asset
@return {(String|undefined)} assetPath
*/
_getAssetPath(asset) {
/* @type {String} */
let assetPath;
if (typeof asset !== 'object') {
assetPath = asset;
} else if (this.env in asset) {
assetPath = asset[this.env];
} else {
assetPath = asset.development;
}
if (!assetPath) {
return;
}
assetPath = assetPath.split('\\').join('/');
if (assetPath.split('/').length < 2) {
console.log(
chalk.red(
`Using \`app.import\` with a file in the root of \`vendor/\` causes a significant performance penalty. Please move \`${assetPath}\` into a subdirectory.`
)
);
}
if (/[*,]/.test(assetPath)) {
throw new Error(
`You must pass a file path (without glob pattern) to \`app.import\`. path was: \`${assetPath}\``
);
}
return assetPath;
}
/**
Returns an array of trees for this application
@private
@method toArray
@return {Array} An array of trees
*/
toArray() {
return [
this.getAddonTemplates(),
this.getStyles(),
this.getTests(),
this.getExternalTree(),
this.getPublic(),
this.getAppJavascript(),
].filter(Boolean);
}
_legacyPackage(fullTree) {
let javascriptTree = this._defaultPackager.packageJavascript(fullTree);
let stylesTree = this._defaultPackager.packageStyles(fullTree);
let appIndex = this._defaultPackager.processIndex(fullTree);
let additionalAssets = this._defaultPackager.importAdditionalAssets(fullTree);
let publicTree = this._defaultPackager.packagePublic(fullTree);
let sourceTrees = [appIndex, javascriptTree, stylesTree, additionalAssets, publicTree].filter(Boolean);
if (this.tests && this.trees.tests) {
sourceTrees.push(this._defaultPackager.packageTests(fullTree));
}
return mergeTrees(sourceTrees, {
overwrite: true,
annotation: 'Application Dist',
});
}
/**
Returns the merged tree for this application
@public
@method toTree
@param {Array} [additionalTrees] Array of additional trees to merge
@return {Tree} Merged tree for this application
*/
toTree(additionalTrees) {
let packagedTree;
let fullTree = mergeTrees(this.toArray(), {
overwrite: true,
annotation: 'Full Application',
});
fullTree = this._debugTree(fullTree, 'prepackage');
if (!packagedTree) {
packagedTree = this._legacyPackage(fullTree);
}
let trees = [].concat(packagedTree, additionalTrees).filter(Boolean);
let combinedPackageTree = broccoliMergeTrees(trees);
return this.addonPostprocessTree('all', combinedPackageTree);
}
}
module.exports = EmberApp;
function addOutputFile(strategy, container, assetPath, options) {
let outputFile = options.outputFile;
if (!outputFile) {
throw new Error('outputFile is not specified');
}
if (!container[outputFile]) {
container[outputFile] = [];
}
if (!allowImport(strategy, container[outputFile], assetPath, options)) {
return;
}
if (options.prepend) {
container[outputFile].unshift(assetPath);
} else {
container[outputFile].push(assetPath);
}
}
// In this strategy the last instance of the asset in the array is the one which will be used.
// This applies to CSS where the last asset always "wins" no matter what.
function _lastOneWins(fileList, assetPath, options) {
let assetIndex = fileList.indexOf(assetPath);
// Doesn't exist in the current fileList. Safe to remove.
if (assetIndex === -1) {
return true;
}
logger.info(`Highlander Rule: duplicate \`app.import(${assetPath})\`. Only including the last by order.`);
if (options.prepend) {
// The existing asset is _already after_ this inclusion and would win.
// Therefore this branch is a no-op.
return false;
} else {
// The existing asset is _before_ this inclusion and needs to be removed.
fileList.splice(fileList.indexOf(assetPath), 1);
return true;
}
}
// In JS the asset which would be first will win.
// If it is something which includes globals we want those defined as early as
// possible. Any initialization would likely be repeated. Any mutation of global
// state that occurs on initialization is likely _fixed_.
// Any module definitions will be identical except in the scenario where they'red
// reified to reassignment. This is likely fine.
function _firstOneWins(fileList, assetPath, options) {
let assetIndex = fileList.indexOf(assetPath);
// Doesn't exist in the current fileList. Safe to remove.
if (assetIndex === -1) {
return true;
}
logger.info(`Highlander Rule: duplicate \`app.import(${assetPath})\`. Only including the first by order.`);
if (options.prepend) {
// The existing asset is _after_ this inclusion and needs to be removed.
fileList.splice(fileList.indexOf(assetPath), 1);
return true;
} else {
// The existing asset is _already before_ this inclusion and would win.
// Therefore this branch is a no-op.
return false;
}
}
function allowImport(strategy, fileList, assetPath, options) {
if (strategy === 'firstOneWins') {
// We must find all occurrences and decide what to do with each.
return _firstOneWins.call(undefined, fileList, assetPath, options);
} else if (strategy === 'lastOneWins') {
// We can simply use the "last one wins" strategy.
return _lastOneWins.call(undefined, fileList, assetPath, options);
} else {
return true;
}
}