src/services/building/buildTranspiler.js
const path = require('path');
const babel = require('@babel/core');
const fs = require('fs-extra');
const glob = require('glob');
const { provider } = require('jimple');
/**
* Manages the transpilation of target files using Babel.
*/
class BuildTranspiler {
/**
* Class constructor.
* @param {BabelConfiguration} babelConfiguration To get a target Babel configuration.
* @param {Logger} appLogger To print information messages after transpiling
* files.
* @param {Targets} targets To access targets information.
* @param {Utils} utils To normalize file extensions.
*/
constructor(
babelConfiguration,
appLogger,
targets,
utils
) {
/**
* A local reference for the `babelConfiguration` service.
* @type {BabelConfiguration}
*/
this.babelConfiguration = babelConfiguration;
/**
* A local reference for the `appLogger` service.
* @type {Logger}
*/
this.appLogger = appLogger;
/**
* A local reference for the `targets` service.
* @type {Targets}
*/
this.targets = targets;
/**
* A local reference for the `utils` service.
* @type {Utils}
*/
this.utils = utils;
}
/**
* Transpile a target files for a given build type. This requires the target files to have been
* previously copied to the distribution directory.
* @param {Target} target The target information.
* @param {string} [buildType='development'] The build type for which the target is being
* transpiled for. This will be used to read the source
* map settings of the target and tell Babel if it needs
* to create them.
* @return {Promise<undefined,Error}
*/
transpileTargetFiles(target, buildType = 'development') {
const {
paths: { build: buildPath },
folders: { build: buildFolder },
includeTargets,
sourceMap,
} = target;
// Define the variable to return.
let result;
// Get the information of all the targets on the `includeTargets` list.
const includedTargets = includeTargets.map((name) => this.targets.getTarget(name));
// Try to find one that requires bundling.
const bundledTarget = includedTargets.find((info) => info.bundle);
if (bundledTarget) {
// If there's one that requires bundling, set to return a rejected promise.
const errorMessage = `The target ${bundledTarget.name} requires bundling so it can't be ` +
`included by ${target.name}`;
result = Promise.reject(new Error(errorMessage));
} else {
// Find all the JS files on the target path inside the distribution directory.
result = this.findFiles(buildPath)
.then((files) => {
// Get the Babel configuration for the target.
const babelConfig = this.babelConfiguration.getConfigForTarget(target);
// Enable source map if the target requires it for the specified build type.
if (sourceMap[buildType]) {
babelConfig.sourceMaps = true;
}
// Loop all the files and transpile them
return Promise.all(files.map((file) => this.transpileFile(
file,
buildType,
babelConfig
)));
})
.then((files) => {
this.appLogger.success('The following files have been successfully transpiled:');
// Log all the files that have been transpiled.
files.forEach((file) => {
const filepath = file.substr(buildPath.length);
this.appLogger.info(`> ${buildFolder}${filepath}`);
});
let nextStep;
if (includedTargets.length) {
// ...chain their promises.
nextStep = Promise.all(includedTargets.map((info) => this.transpileTargetFiles(info)));
}
return nextStep;
})
.catch((error) => {
this.appLogger.error(
`There was an error while transpiling the target '${target.name}' code`
);
return Promise.reject(error);
});
}
return result;
}
/**
* Transpile a file.
* @param {string|Object} filepath If used as a string, it's the path to the
* file to transpile; if used as an object, it
* should have `source` and `output` properties
* to define from where to where the file is
* transpiled.
* @param {string} [buildType='development'] The build type for which the file is being
* transpiled for. If `options` is not
* specified, the method will try to load the
* target configuration based on the file path,
* and if the target has source maps enabled for
* the build type, it will tell Babel to
* create them.
* @param {?Object} [options=null] The Babel configuration to use. If not
* defined, the method will try to find a target
* configuration using the path of the file.
* @param {boolean} [writeFile=true] If `true`, it will write the transpile code,
* otherwise, it will return it on the promise.
* @return {Promise<Object|string,Error>} If `writeFile` is true, the promise will resolve on
* an object with the keys `filepath` (the path where it
* was transpiled) and `code`; but if the parameter is
* `false`, the promise will resolve on a string with
* the path to the file.
*/
transpileFile(filepath, buildType = 'development', options = null, writeFile = true) {
let from = '';
let originalTo = '';
let to = '';
/**
* Check if the file is a string or an object and define the from where to where the
* transpilation should happen.
*/
if (typeof filepath === 'string') {
from = filepath;
originalTo = filepath;
} else {
from = filepath.source;
originalTo = filepath.output;
}
// Normalize custom JS extensions (jsx, ts or tsx) to `.js`
to = this.utils.ensureExtension(originalTo);
// If no options were defined, try to get them from a target, using the path of the file.
const babelOptions = options || this.getTargetConfigurationForFile(from, buildType);
// First, transform the file with Babel.
const firstStep = new Promise((resolve, reject) => {
babel.transformFile(from, babelOptions, (error, transpiled) => {
if (error) {
reject(error);
} else {
resolve(transpiled);
}
});
});
let result;
// If the file should be written...
if (writeFile) {
result = firstStep
.then((transpiled) => {
// Define the list of promises that need to be executed.
const nextSteps = [];
// Extract the code and the source map from the transpilation results.
const { code, map } = transpiled;
let newCode = code;
// If there's a map...
if (map) {
// ...parse and normalize it.
const sourceMap = this._normalizeSourceMap(to, map);
// ...update the code to include the link for the map.
newCode = `${code}\n${sourceMap.link}\n`;
// ...push the writing of the map onto the promises list.
nextSteps.push(fs.writeFile(sourceMap.filepath, sourceMap.code));
}
// Push the writing of the transpiled code on the promises list.
nextSteps.unshift(fs.writeFile(to, newCode));
// Process all the _"writing promises"_.
return Promise.all(nextSteps);
})
// ...if the file wasn't a normal `.js` and the original still exists, delete it.
.then(() => (to !== originalTo ? fs.pathExists(originalTo) : false))
.then((exists) => (exists ? fs.remove(originalTo) : null))
// And return the path to the transpiled file.
.then(() => to);
} else {
result = firstStep
// Return the code and the path it should've been saved.
.then((transpiled) => Object.assign({}, transpiled, { filepath: to }));
}
return result;
}
/**
* Synchronous version of `transpileFile`.
* @param {string|Object} filepath If used as a string, it's the path to the
* file to transpile; if used as an object, it
* should have `source` and `output` properties
* to define from where to where the file is
* transpiled.
* @param {string} [buildType='development'] The build type for which the file is being
* transpiled for. If `options` is not
* specified, the method will try to load the
* target configuration based on the file path,
* and if the target has source maps enabled for
* the build type, it will tell Babel to
* create them.
* @param {?Object} [options=null] The Babel configuration to use. If not
* defined, the method will try to find a
* target configuration using the path of the
* file.
* @param {boolean} [writeFile=true] If `true`, it will write the transpile code,
* otherwise, it will return it.
* @return {Object|string} If `writeFile` is true, it will return an object with the keys
* `filepath` (the path where it was transpiled) and `code`; but if the
* parameter is `false`, it will return a string with the path to the
* file.
*/
transpileFileSync(filepath, buildType = 'development', options = null, writeFile = true) {
let from = '';
let originalTo = '';
let to = '';
/**
* Check if the file is a string or an object and define the from where to where the
* transpilation should happen.
*/
if (typeof filepath === 'string') {
from = filepath;
originalTo = filepath;
} else {
from = filepath.source;
originalTo = filepath.output;
}
// Normalize custom JS extensions (jsx, ts or tsx) to `.js`
to = this.utils.ensureExtension(originalTo);
// If no options were defined, try to get them from a target, using the path of the file.
const babelOptions = options || this.getTargetConfigurationForFile(from, buildType);
// First, transform the file with Babel.
const transpiled = babel.transformFileSync(from, babelOptions);
let result;
// If the file should be written...
if (writeFile) {
// Extract the code and the source map from the transpilation results
const { code, map } = transpiled;
let newCode = code;
// If there's a map...
if (map) {
// ...parse and normalize it.
const sourceMap = this._normalizeSourceMap(to, map);
// ...update the code to include the link for the map.
newCode = `${code}\n${sourceMap.link}\n`;
// ...write the source map.
fs.writeFileSync(sourceMap.filepath, sourceMap.code);
}
// ...write the file.
fs.writeFileSync(to, newCode);
// ...if the file wasn't a normal `.js` and the original still exists, delete it.
if (to !== originalTo && fs.pathExistsSync(originalTo)) {
fs.removeSync(originalTo);
}
// And set to return the path to the transpiled file.
result = to;
} else {
// Set to return the code and the path it should've been saved.
result = Object.assign({}, transpiled, { filepath: to });
}
return result;
}
/**
* Find files of a given type on a directory.
* @param {string} directory The directory where the files will be
* searched for.
* @param {string} [pattern='**\/*.{js,jsx,ts,tsx}'] A glob pattern to match the files.
* @return {Promise<Array,Error>} If everything goes well, the promise will resolve on the list
* of files found.
*/
findFiles(directory, pattern = '**/*.{js,jsx,ts,tsx}') {
return new Promise((resolve, reject) => {
glob(pattern, { cwd: directory }, (error, files) => {
if (error) {
reject(error);
} else {
let newFiles = files
// Filter out TypeScript declaration files.
.filter((file) => !file.match(/\.d\.tsx?$/i));
// Generate a list of the TypeScript files.
const tsFiles = newFiles
.filter((file) => file.match(/\.tsx?$/i))
// Remove their extensions for the next validation.
.map((file) => file.replace(/\.tsx?$/i, ''));
// If there are TypeScript files...
if (tsFiles.length) {
/**
* Filter the file list by removing those `.js` which have a `.tsx?` file with the
* same name, as they were generated by transpilation and they don't need to be
* transpilated again.
*/
newFiles = newFiles.filter((file) => (
!file.match(/\.js$/i) ||
!tsFiles.includes(file.replace(/\.js$/i, ''))
));
}
// Add the full path to all the files.
newFiles = newFiles.map((file) => path.join(directory, file));
resolve(newFiles);
}
});
});
}
/**
* Get a target Babel configuration based on a filepath.
* @param {string} file The file that will be used to obtain the target and
* then the Babel configuration.
* @param {string} [buildType='development'] The build type for which the configuration is
* needed for. This allows the method to check if the
* target has source map enabled for the build type,
* and if this happens, it will also enable it on the
* configuration it returns.
* @return {Object}
*/
getTargetConfigurationForFile(file, buildType = 'development') {
/**
* Find target using the received filepath. The method will throw an error if a target is not
* found.
*/
const target = this.targets.findTargetForFile(file);
// Return the Babel configuration for the found target.
const config = this.babelConfiguration.getConfigForTarget(target);
if (target.sourceMap[buildType]) {
config.sourceMaps = true;
}
return config;
}
/**
* This is a helper method that prepares all the source map information needed to link it on the
* transpiled file and write it on the file system.
* @param {string} filepath The path to the file the map is for.
* @param {Object} mapProperties The map properties generated by Babel.
* @return {Object}
* @property {string} filepath The complete path to the source map.
* @property {string} filename The name of the source map.
* @property {string} link The comment needed on the original source to link the source map.
* @property {string} code The actual code of the source map.
* @access protected
* @ignore
*/
_normalizeSourceMap(filepath, mapProperties) {
const mapPath = `${filepath}.map`;
const mapName = path.basename(mapPath);
const link = `//# sourceMappingURL=${mapName}`;
const code = JSON.stringify(Object.assign({}, mapProperties, { sources: [] }));
return {
filepath: mapPath,
filename: mapName,
link,
code,
};
}
}
/**
* The service provider that once registered on the app container will set an instance of
* `BuildTranspiler` as the `buildTranspiler` service.
* @example
* // Register it on the container
* container.register(buildTranspiler);
* // Getting access to the service instance
* const buildTranspiler = container.get('buildTranspiler');
* @type {Provider}
*/
const buildTranspiler = provider((app) => {
app.set('buildTranspiler', () => new BuildTranspiler(
app.get('babelConfiguration'),
app.get('appLogger'),
app.get('targets'),
app.get('utils')
));
});
module.exports = {
BuildTranspiler,
buildTranspiler,
};