src/plugin.js
const path = require('path');
const { AureliaPlugin } = require('aurelia-webpack-plugin');
/**
* It updates a target webpack and Babel configuration in order to work with the Aurelia framework.
*/
class ProjextAureliaPlugin {
/**
* Class constructor.
* @ignore
*/
constructor() {
/**
* A dictionary with familiar names for all the events the plugin will listen and use in order
* to modify the target configurations.
* @type {Object}
* @access protected
* @ignore
*/
this._events = {
htmlSettings: 'target-default-html-settings',
htmlRules: 'webpack-html-rules-configuration-for-browser',
allRules: 'webpack-rules-configuration-for-browser',
baseConfiguration: 'webpack-base-configuration-for-browser',
configuration: 'webpack-browser-configuration',
babelConfiguration: 'babel-configuration',
externalSettings: 'webpack-externals-configuration-for-browser',
};
/**
* The list of Aurelia packages that should never end up on the bundle if the target is a
* library.
* @type {Array}
* @access protected
* @ignore
*/
this._externalModules = [
'aurelia-framework',
'aurelia-pal',
];
/**
* A dictionary with familiar names for the loaders that will be added to the webpack
* configuration.
* @type {Object}
* @access protected
* @ignore
*/
this._loaders = {
cleanExtract: 'aurelia-extract-clean-loader',
htmlRequires: 'aurelia-webpack-plugin/html-requires-loader',
htmlModulesFix: path.resolve(path.join(__dirname, 'htmlLoader')),
};
/**
* A list of Babel plugins that need to be on the target Babel configuration in order to
* work with Aurelia.
* @type {Array}
* @access protected
* @ignore
*/
this._babelRequiredPlugins = [
'transform-class-inject-directive',
['@babel/plugin-proposal-decorators', {
legacy: true,
}],
['@babel/plugin-proposal-class-properties', {
loose: true,
}],
];
/**
* The required value a target `framework` setting needs to have in order for the plugin to
* modify a configuration.
* @type {string}
* @access protected
* @ignore
*/
this._frameworkProperty = 'aurelia';
/**
* The name of the entry point that webpack will use in order for Aurelia to resolve all the
* imports.
* @type {string}
* @access protected
* @ignore
*/
this._aureliaEntry = 'aurelia-bootstrapper';
/**
* The default values for the options a target can use to customize the default HTML projext
* generates.
* @type {Object}
* @property {?string} title A custom value for the `<title />` tag. If the target
* doesn't define it, the plugin will use the one projext
* sets by default (The name of the target).
* @property {boolean} useBody Whether or not the `body` should be used as the app tag
* (`aurelia-app`).
* @access protected
* @ignore
*/
this._frameworkOptions = {
title: null,
useBody: true,
};
}
/**
* This is the method called when the plugin is loaded by projext. It setups all the listeners
* for the events the plugin needs to intercept in order to:
* 1. Update the target HTML rules to include the `aurelia-extract-clean-loader` loader.
* 2. Manually add the `aurelia-webpack-plugin/html-requires-loader`.
* 3. Add the target source directory for modules resolution.
* 4. Update the webpack entry point and add the Aurelia plugin for webpack.
* 5. Update the target Babel configuration.
* 6. Filter Aurelia packages if the target is a library.
* @param {Projext} app The projext main container
*/
register(app) {
// Get the `events` service to listen for the events.
const events = app.get('events');
// Get the `babelHelper` to send to the method that adds support validates the plugins.
const babelHelper = app.get('babelHelper');
// Add the listener for the default HTML settings.
events.on(this._events.htmlSettings, (settings, target, buildType) => (
this._updateHTMLSettings(settings, target, buildType)
));
// Add the listener that will push the _"extract clean loader"_.
events.on(this._events.htmlRules, (rules, params) => this._filterEvent(
this._updateHTMLRules,
rules,
params
));
// Add the listener that will push the Aurelia HTML loader.
events.on(this._events.allRules, (data, params) => this._filterEvent(
this._addExtraHTMLRules,
data,
params
));
// Add the listener that will update the modules resolution directories list.
events.on(this._events.baseConfiguration, (config, params) => this._filterEvent(
this._addModulesResolution,
config,
params
));
// Add the listener that will update the webpack entry point and add the Aurelia plugin.
events.on(this._events.configuration, (config, params) => this._filterEvent(
this._updateTargetEntryAndPlugins,
config,
params
));
// Add the listener that will update the target Babel configuration.
events.on(this._events.babelConfiguration, (config, target) => this._filterEvent(
this._updateBabelConfiguration,
config,
{ target },
babelHelper
));
// Add the listener that will push the Aurelia packages to the _"externals"_ list.
events.on(this._events.externalSettings, (externals, params) => this._filterEvent(
this._updateExternals,
externals,
params
));
}
/**
* Reads the settings projext usess to build a browser target default HTML file and updates them
* based on the framework options defined by the target in order to run an Aurelia app.
* @param {TargetDefaultHTMLSettings} currentSettings The settings projext uses to build a target
* default HTML file.
* @param {Target} target The target information.
* @param {string} buildType The type of build being generated:
* 'development' or 'production'.
* @return {TargetDefaultHTMLSettings}
* @access protected
* @ignore
*/
_updateHTMLSettings(currentSettings, target, buildType) {
let updatedSettings;
if (target.is.browser && target.framework === this._frameworkProperty) {
updatedSettings = Object.assign({}, currentSettings);
const useBuildType = ['production', 'development'].includes(buildType) ?
buildType :
'production';
const options = Object.assign(
{
appName: path.parse(target.entry[useBuildType]).name.split('.').shift(),
},
this._frameworkOptions,
(target.frameworkOptions || {})
);
if (options.title) {
updatedSettings.title = options.title;
}
const attributes = `aurelia-app="${options.appName}"`;
if (options.useBody) {
updatedSettings.bodyAttributes = attributes;
updatedSettings.bodyContents = '';
} else {
updatedSettings.bodyContents = `<div id="app" ${attributes}></div>`;
}
} else {
updatedSettings = currentSettings;
}
return updatedSettings;
}
/**
* Updates a target HTML rules and adds:
* - The `aurelia-extract-clean-loader` loader and the, which allows you to extract all your CSS
* imported from HTML with `mini-css-extract-plugin`.
* - The custom loader the fixes HTML modules being exported with ES modules syntax, so they
* won't break the Aurelia's loader (which doesn't support `export default`).
* @param {Array} rules The original rules.
* @return {Array}
* @access protected
* @ignore
*/
_updateHTMLRules(rules) {
const newRules = rules.slice();
const [firstRule] = newRules;
firstRule.use.unshift(...[
this._loaders.cleanExtract,
this._loaders.htmlModulesFix,
]);
return newRules;
}
/**
* Updates a target rules configuration and pushes a new one to manually add the
* `aurelia-webpack-plugin/html-requires-loader` loader. The reason we do this is because
* if the `HtmlWebpackPlugin` detects another loader for HTML, it doesn't use the `html-loader`
* on the target HTML file.
* @param {Object} config The target rules configuration.
* @param {Array} config.rules The list of rules.
* @return {Object}
* @access protected
* @ignore
*/
_addExtraHTMLRules(config) {
const newConfig = Object.assign({}, config);
newConfig.rules.push({
test: /\.html?$/,
exclude: /\.tpl\.html/,
use: [this._loaders.htmlRequires],
});
return newConfig;
}
/**
* Updates the paths from where webpack can resolve modules in order to add the target source
* directory. This is so aurelia can automatically find components and views.
* @param {Object} config The webpack configuration to
* update.
* @param {Object} config.resolve The webpack configuration for
* resolution.
* @param {Object} config.resolve.modules The list of paths where webpack
* can find modules.
* @param {WebpackConfigurationParams} params A dictionary generated by the
* webpack plugin with all the
* information about the bundle: The
* target, the build type, the output
* paths, etc.
* @return {Object}
* @access protected
* @ignore
*/
_addModulesResolution(config, params) {
const newConfig = Object.assign({}, config);
newConfig.resolve.modules.unshift(params.target.paths.source);
return newConfig;
}
/**
* Updates webpack entry point and plugins in order to add the Aurelia specific entry and plugin
* (required in order to work with the framework).
* @param {Object} config The webpack configuration for a target.
* @param {WebpackConfigurationParams} params A dictionary generated by the webpack plugin with
* all the information about the bundle: The target,
* the build type, the output paths, etc.
* @return {Object}
* @access protected
* @ignore
*/
_updateTargetEntryAndPlugins(config, params) {
const { target, buildType } = params;
const newConfig = Object.assign({}, config);
const targetEntryFile = path.join(target.paths.source, target.entry[buildType]);
const entries = newConfig.entry[target.name];
// If the entry is on a list...
if (Array.isArray(entries)) {
// ...find the target entry.
const targetEntryIndex = entries.findIndex((entry) => entry === targetEntryFile);
/**
* ...if the target entry was found, replace it with the one for Aurelia; otherwise, just
* push the one for Aurelia to the list.
*/
if (targetEntryIndex > -1) {
newConfig.entry[target.name][targetEntryIndex] = this._aureliaEntry;
} else {
newConfig.entry[target.name].push(this._aureliaEntry);
}
} else {
// ...otherwise, replace the entry with the one for Aurelia.
newConfig.entry[target.name] = this._aureliaEntry;
}
/**
* Filter the plugins by type, as the `HtmlWebpackPlugin` and its plugins need to be before
* the one for Aurelia, and rebuild the plugins list.
*/
const plugins = this._filterHTMLPlugins(newConfig.plugins);
newConfig.plugins = [
...plugins.html,
new AureliaPlugin({
aureliaApp: path.parse(targetEntryFile).name,
noHtmlLoader: true,
}),
...plugins.others,
];
return newConfig;
}
/**
* Update a target Babel's configuration in order to push the necessary plugins to work
* with Aurelia.
* @param {Object} currentConfiguration The current Babel configuration for the target.
* @param {Object} params An object with the information of the target
* being bundled.
* @param {Target} params.target The target information.
* @param {BabelHelper} babelHelper To update the target configuration and add the
* required preset and plugin.
* @return {Object}
* @access protected
* @ignore
*/
_updateBabelConfiguration(currentConfiguration, params, babelHelper) {
let newConfig = Object.assign({}, currentConfiguration);
this._babelRequiredPlugins.forEach((plugin) => {
newConfig = babelHelper.addPlugin(newConfig, plugin);
});
return newConfig;
}
/**
* Updates the dictionary of external modules to ensure non Aurelia packages will end up inside
* the bundle when the target is a library.
* @param {Object} currentExternals A dictionary of external dependencies
* with the format webpack uses:
* `{ 'module': 'commonjs module'}`.
* @param {WebpackConfigurationParams} params A dictionary generated by the webpack
* plugin with all the information about
* the bundle: The target, the build type,
* the output paths, etc.
* @return {Object}
* @access protected
* @ignore
*/
_updateExternals(currentExternals, params) {
let updatedExternals;
if (params.target.library) {
updatedExternals = Object.assign({}, currentExternals);
this._externalModules.forEach((name) => {
updatedExternals[name] = `commonjs ${name}`;
});
} else {
updatedExternals = currentExternals;
}
return updatedExternals;
}
/**
* This is a helper method that all the event listeners use in order to prevent a method to
* be called if the target for that event doesn't use Aurelia. It does it by checking the
* target `framework` property.
* @param {Function} method The method to be called if the target
* uses Aurelia.
* @param {Object|Array} obj The object the event is updating.
* @param {WebpackConfigurationParams|Object} params A dictionary generated by the webpack
* plugin with all the information
* about the bundle: The target, the
* build type, the output paths, etc.
* @param {Target} params.target The target information.
* @param {...*} args Extra parameters for the method that
* will process the event in case the
* target uses Aurelia.
* @return {Object|Array} If the target uses Aurelia, it will call the `method` parameter and
* return whatever it returns; but if the target doesn't use Aurelia,
* it will return the original `obj`.
* @access protected
* @ignore
*/
_filterEvent(method, obj, params, ...args) {
let result;
if (params.target.framework === this._frameworkProperty) {
result = method.bind(this)(obj, params, ...args);
} else {
result = obj;
}
return result;
}
/**
* This is a helper method that _"categorizes"_ a list of plugins: the `HtmlWebpackPlugin` and
* its plugins on one side and then the other plugins.
* The reason is that the configuration needs to have, first the `HtmlWebpackPlugin` and its
* plugins, then the Aurelia plugin and then the other ones.
* @param {Array} plugins The list of plugins to _"categorize"_.
* @return {Object} A dictionary with the categories.
* @property {Array} html The list with the `HtmlWebpackPlugin` and its plugin.
* @property {Array} others The other plugins.
* @access protected
* @ignore
*/
_filterHTMLPlugins(plugins) {
const html = [];
const others = [];
plugins.forEach((instance) => {
if (
instance.constructor &&
instance.constructor.name &&
instance.constructor.name.match(/HtmlWebpackPlugin/)
) {
html.push(instance);
} else {
others.push(instance);
}
});
return {
html,
others,
};
}
}
module.exports = ProjextAureliaPlugin;