src/services/targets/targetsFinder.js
const fs = require('fs-extra');
const path = require('path');
const ObjectUtils = require('wootils/shared/objectUtils');
const { provider } = require('jimple');
/**
* This is used to find targets information on an specific directory. It not only reads the
* directory tree but also tries to identify the targets types by analyzing the contents of
* indentified targets entry files.
*/
class TargetsFinder {
/**
* Class constructor.
* @param {Object} packageInfo If there's only one target and is not on a sub folder, the way
* the service names it is by using the project name that's on the
* `package.json`.
* @param {PathUtils} pathUtils To build the path to the directory that will be read.
* @ignore
*/
constructor(packageInfo, pathUtils) {
/**
* The contents of the project `package.json`. If there's only one target and is not on a sub
* folder, the way the service names it is by using the project name.
* @type {Object}
*/
this.packageInfo = packageInfo;
/**
* A local reference for the `pathUtils` service.
* @type {PathUtils}
*/
this.pathUtils = pathUtils;
/**
* A list of items that should be ignored when reading a directory.
* @type {Array}
* @ignore
* @access protected
*/
this._ignoredItems = ['.', '..', 'thumbs.db', '.ds_store'];
/**
* A dictionary of known file types and regular expressions that match their extensions.
* @type {Object}
* @ignore
* @access protected
*/
this._extensions = {
js: /\.[jt]sx?$/i,
typeScript: /\.tsx?$/i,
typeScriptReact: /\.tsx$/i,
asset: /\.(png|jpe?g|gif|s?css|html|svg|woff2?|ttf|eot)$/i,
};
/**
* A dictionary of _"import methods"_ a file can use. They're separated in two categories:
* 'native' and 'next', so the service can identify if a Node target requires bundling or not.
* @type {Object}
* @ignore
* @access protected
*/
this._imports = {
native: [
/\s*require\s*\(\s*['|"](.*?)['|"]\s*\)/ig,
],
next: [
/\s*from\s*['|"](.*?)['|"]/ig,
/\s*import\s*\(\s*['|"](.*?)['|"]\s*\)/ig,
],
};
/**
* A dictionary of _"export methods"_ a file can use. They're separated in two categories:
* 'native' and 'next', so the service can identify if a Node target requires bundling or not.
* If a target entry file implements any kind of _"export method"_, it will be marked as a
* library target.
* @type {Object}
* @ignore
* @access protected
*/
this._exports = {
native: [/(?:^|\s)(module\.exports\s*=)/ig],
next: [/(?:^|\s)(export(?: default)?\s*(?:.*?))/ig],
};
/**
* A dictionary of known browser frameworks and regular expressions that match their module
* name.
* @type {Object}
* @ignore
* @access protected
*/
this._browserFrameworks = {
angular: /@angular(?:\/(?:\w+))?$/i,
angularjs: /angular/i,
react: /react(?:(?!(?:-dom\/server)))/i,
aurelia: /aurelia/i,
};
/**
* A list of known browser frameworks that need to export something on the entry point in order
* to work. This is list is used to prevent the service from thinking an app is a library.
* @type {Array}
* @ignore
* @access protected
*/
this._browserFrameworksWithExports = ['aurelia'];
/**
* A dictionary of known frameworks that can be used on Node, and regular expressions that
* match their module name.
* @type {Object}
*/
this._nodeFrameworks = {
react: /react-dom\/server/i,
};
/**
* A list of regular expressions that would only match code present on a browser target.
* @type {Array}
* @ignore
* @access protected
*/
this._browserExpressions = [
/(?:^|\s|=)doc(?:ument?)\s*\.\s*(?:getElementBy(?:Id|ClassName)|querySelector(?:All)?)\s*\(/ig,
/(?:^|\s|=)(?:window|global)\s*\.(?:document)?/i,
/['|"]whatwg-fetch['|"]/i,
];
/**
* @ignore
*/
this.find = this.find.bind(this);
}
/**
* Given a directory path relative to the project root, this method will try to identify
* targets and their properties.
* @param {string} directory A directory path relative to the project root.
* @return {Array} Each item will be a {@link TargetsFinderTarget}.
*/
find(directory) {
// Build the full path.
const dirpath = this.pathUtils.join(directory);
// Define the list that will be returned.
const targets = [];
// If the directory exists...
if (fs.pathExistsSync(dirpath)) {
// ...get all the items inside it.
const items = this._getItems(dirpath);
/**
* Check if there's a JS file inside, which means that the directory is a target itself and
* that it doesn't contain _"sub targets"_.
*/
const jsFile = items.find((item) => item.name.match(this._extensions.js));
// If there's a JS file...
if (jsFile) {
// ...try to parse a target on that directory.
const target = this._parseTarget(this.packageInfo.name, dirpath, false);
// If there was a target in there, add it to the list.
if (target) {
targets.push(target);
}
} else {
// ...otherwise, loop all the items on the directory.
items.forEach((item) => {
// If the item is a directory...
if (item.stats.isDirectory()) {
// ...try to parse a target on that directory.
const target = this._parseTarget(item.name, item.path);
// If there was a target in there, add it to the list.
if (target) {
targets.push(target);
}
}
});
}
}
// Return the list of found targets.
return targets;
}
/**
* Get all the items on a given path.
* @param {string} directoryPath The path to the directory to read.
* @return {Array} A list of {@link TargetsFinderItem}.
* @ignore
* @access protected
*/
_getItems(directoryPath) {
// Read the directory.
return fs.readdirSync(directoryPath)
// Filter the ignored items.
.filter((item) => !this._ignoredItems.includes(item.toLowerCase()))
// For each found item, build its full path, get its stats and return it on an object.
.map((item) => {
const filepath = path.join(directoryPath, item);
const stats = fs.lstatSync(filepath);
return {
name: item,
path: filepath,
stats,
};
});
}
/**
* This method tries to get a target information from a given directory.
* @param {string} name The name of the target.
* @param {string} directory The absolute path to the directory to parse.
* @param {boolean} [hasFolder=true] The value of the target `hasFolder` and `createFolder`
* properties.
* @return {?TargetsFinderTarget} If the target can't be identified because there's no JS files
* or a valid entry file can't be found, the method will return
* `null`.
* @ignore
* @access protected
*/
_parseTarget(name, directory, hasFolder = true) {
// Define the base structure of the target data this method can handle.
let target = {
name,
hasFolder,
createFolder: hasFolder,
entry: {
default: 'index.js',
development: null,
production: null,
},
};
/**
* Define a dictionary that will contain all the found JS files on the directory. The keys
* will be the name of the files without extension and the values will be the real name of the
* file.
* This way it makes it easier to test using the key as _"falsy value"_ without having to call
* `includes`.
*/
const jsFiles = {};
// Get all the items on the directory.
this._getItems(directory)
// Filter the JS files.
.filter((item) => item.name.match(this._extensions.js))
// Add them to the dictionary.
.forEach((item) => {
const itemName = item.name.replace(this._extensions.js, '').toLowerCase();
jsFiles[itemName] = item.name;
});
// Get all extension-less names on a list (because we need the `length`).
const jsFilesNames = Object.keys(jsFiles);
// Only process the target if there are JS files on the directory.
if (jsFilesNames.length) {
// If there's only one JS file...
if (jsFilesNames.length === 1) {
// ...set it as the default entry file.
const [defaultJSFile] = jsFilesNames;
target.entry.default = jsFiles[defaultJSFile];
} else {
// If there's a development entry file, set it.
if (jsFiles['index.development']) {
target.entry.development = jsFiles['index.development'];
}
// If there's a production entry file, set it.
if (jsFiles['index.production']) {
target.entry.production = jsFiles['index.production'];
}
// If there's a index, set it as the default.
if (jsFiles.index) {
target.entry.default = jsFiles.index;
}
}
// Define the entry to be analyzed in order to identify the target type.
const entry = target.entry.production || target.entry.default;
// Build the absolute path to the entry file.
const entryPath = path.join(directory, entry);
// If the file exists...
if (fs.pathExistsSync(entryPath)) {
// Merge the target structure created by this method with the results of the analysis.
target = ObjectUtils.merge(target, this._parseTargetEntry(entryPath));
}
}
/**
* If there's a type, which means that there was at least one JS file and a valid entry file,
* return the target, otherwise return `null`.
*/
return target.type ? target : null;
}
/**
* Parse a target entry file and try to identify the target type, if it's a library and if it
* requires bundling.
* @param {string} entryPath The absolute path to the target entry file.
* @return {Object}
* @property {string} type The target type: `node` or `browser`.
* @property {boolean} library Whether the target is a library or not.
* @property {?string} framework If the target type is `browser` and a framework was identified,
* this property will have the name of the framework.
* @property {?boolean} transpile If the target type is `node`, this flag will indicate if the
* method identified syntax not yet supported by Node.
* @ignore
* @access protected
*/
_parseTargetEntry(entryPath) {
// Get the contents of the file.
const contents = fs.readFileSync(entryPath, 'utf-8');
// Try to find information from the `@projext` comment.
const comments = this._findSettingsComment(contents);
// Get the information of all the import statements.
const importInfo = this._getFileImports(contents);
// Get the information of all the export statements
const exportInfo = this._getFileExports(contents);
// Try to find a browser framework
const framework = this._findBrowserFramework(comments, importInfo);
// Detect whether the target is a library or not.
const library = this._isLibrary(comments, exportInfo, framework);
// Try to find a framework that can also be used on Node.
const nodeFramework = this._findNodeFramework(importInfo);
/**
* Try to determine if the target type is `browser` by either checking if a browser framework
* was found or by trying to find a known browser code.
*/
const isBrowser = !nodeFramework && this._isABrowserTarget(comments, contents, framework);
// Define the basic properties of the return object.
let info = {
type: isBrowser ? 'browser' : 'node',
library,
};
// If a browser framework was found...
if (framework) {
// ...set it as the framework property.
info.framework = framework;
} else if (!isBrowser) {
// .. so the target is for Node; check if it needs bundling or transpilation.
if (this._needsBundling(importInfo)) {
info.bundle = true;
} else if (this._needsTranspilation(importInfo, exportInfo)) {
/**
* If the target is using `import` or `export` but is not importing assets, then turn
* the `transpile` flag to true.
*/
info.transpile = true;
}
}
/**
* If the target is a library, normalize the output so it won't add sub directories nor hashes.
* A library path is usually set on the `package.json` as the `main` setting, so the path
* shouldn't be dynamic.
*/
if (info.library) {
info.output = {
default: {
js: '[target-name].js',
},
development: {
js: '[target-name].js',
},
};
}
// If the target uses TypeScript or Flow, add the necessary settings for it.
if (entryPath.match(this._extensions.typeScript)) {
info = Object.assign({}, info, this._getTypescriptSettings(
!isBrowser,
info.bundle,
entryPath,
framework
));
} else if (comments.flow) {
info = Object.assign({}, info, this._getFlowSettings(!isBrowser, info.bundle));
}
// Return the result of the analysis.
return info;
}
/**
* Tries to find a browser framework from an entry file comments or by its import statements.
* @param {Object} comments The dictionary of comments
* extracted from the file.
* @param {TargetsFinderExtractInformation} importInformation The information of the file import
* statements.
* @return {?String}
* @access protected
* @ignore
*/
_findBrowserFramework(comments, importInformation) {
// Loop all the known browser frameworks.
const result = comments.framework || Object.keys(this._browserFrameworks)
// Try to find an import statement that matches the browser framework regular expression.
.find((name) => {
const regex = this._browserFrameworks[name];
return !!importInformation.items.find((file) => file.match(regex));
});
return result || null;
}
/**
* Tries to find a Node framework from an entry file import statements.
* @param {TargetsFinderExtractInformation} importInformation The information of the file import
* statements.
* @return {?String}
* @access protected
* @ignore
*/
_findNodeFramework(importInformation) {
const result = Object.keys(this._nodeFrameworks)
.find((name) => {
const regex = this._nodeFrameworks[name];
return !!importInformation.items.find((file) => file.match(regex));
});
return result || null;
}
/**
* Checks if a target should be a library or not based on its entry file comments, export
* statements information and/or the framework it uses.
* @param {Object} comments The dictionary of comments
* extracted from the file.
* @param {TargetsFinderExtractInformation} exportInformation The information of the file export
* statements.
* @param {?String} framework The name of a framework the target
* uses.
* @return {Boolean}
* @access protected
* @ignore
*/
_isLibrary(comments, exportInformation, framework) {
// If the comment says it's a library, then it's a library.
return comments.library || (
/**
* If there's no comment, check if the framework doesn't require exports (like Aurelia),
* and that there are actual export statements.
*/
(framework === null || !this._browserFrameworksWithExports.includes(framework)) &&
exportInformation.items.length > 0
);
}
/**
* Checks if a target type is `browser` based on its entry file comments, contents and/or
* the framework it uses.
*
* @param {Object} comments The dictionary of comments extracted from the file.
* @param {String} contents The contents of the file.
* @param {?String} framework The name of a framework the target uses.
* @return {Boolean}
* @access protected
* @ignore
*/
_isABrowserTarget(comments, contents, framework) {
// If the comment says it's for browser, then it's for browser.
return comments.type === 'browser' || (
/**
* If there's no comment, check if the target doesn't use a known browser framework or if
* its content have code that can be recognized as browser-only code.
*/
framework !== null ||
this._browserExpressions.find((expression) => contents.match(expression))
);
}
/**
* Checks if a Node target needs bundling by trying to find an import statement for an asset
* (like an image file).
* @param {TargetsFinderExtractInformation} importInformation The information of the file import
* statements.
* @return {Boolean}
* @access protected
* @ignore
*/
_needsBundling(importInformation) {
return importInformation.items.find((file) => file.match(this._extensions.asset));
}
/**
* Checks if a Node target needs transpilation by trying to find import or export statements
* that use ESModules.
* @param {TargetsFinderExtractInformation} importInformation The information of the file import
* statements.
* @param {TargetsFinderExtractInformation} exportInformation The information of the file export
* statements.
* @return {Boolean}
* @access protected
* @ignore
*/
_needsTranspilation(importInformation, exportInformation) {
return importInformation.from.includes('next') || exportInformation.from.includes('next');
}
/**
* Gets the necessary settings for a target to use TypeScript.
* @param {Boolean} isANodeTarget Whether or not the target is for Node.
* @param {Boolean} needsBundling Whether or not the target needs bundling.
* @param {String} entryPath The path of the target entry file.
* @param {?String} framework The name of a framework the target uses.
* @return {Object}
* @property {Boolean} [typeScript=true]
* The flag that indicates the target uses TypeScript.
* @property {ProjectConfigurationTargetTemplateSourceMapSettings} [sourceMap]
* The settings for source maps all set to `true` as they are needed for the types.
* @property {?String} [framework]
* If the method detects a `.tsx` extension, it will add this property with `react` as value.
* @property {?Boolean} [transpile=true]
* If the target is for Node and doesn't need bundling, then it needs at least transpilation in
* order to use TypeScript.
* @access protected
* @ignore
*/
_getTypescriptSettings(isANodeTarget, needsBundling, entryPath, framework) {
const settings = {
typeScript: true,
sourceMap: {
development: true,
production: true,
},
};
if (isANodeTarget && !needsBundling) {
settings.transpile = true;
}
if (framework === null && entryPath.match(this._extensions.typeScriptReact)) {
settings.framework = 'react';
}
return settings;
}
/**
* Gets the necessary settings for a target to use Flow.
* @param {Boolean} isANodeTarget Whether or not the target is for Node.
* @param {Boolean} needsBundling Whether or not the target needs bundling.
* @return {Object}
* @property {Boolean} [flow=true]
* The flag that indicates the target uses TypeScript.
* @property {?Boolean} [transpile=true]
* If the target is for Node and doesn't need bundling, then it needs at least transpilation in
* order to use TypeScript.
* @access protected
* @ignore
*/
_getFlowSettings(isANodeTarget, needsBundling) {
const settings = {
flow: true,
};
if (isANodeTarget && !needsBundling) {
settings.transpile = true;
}
return settings;
}
/**
* This method tries to find and parse settings on a "@projext comment" inside a target entry
* file.
* @param {String} contents The contents of the target entry file.
* @return {Object}
* @access protected
* @ignore
*/
_findSettingsComment(contents) {
let result;
const match = /\/\*\*\n\s*\*\s*@projext\n([\s\S]*?)\n\s*\*\//.exec(contents);
if (match) {
const [, lines] = match;
result = lines
.split('\n')
.map((line) => {
let newLine;
const lineMatch = /\s*\*\s*(\w+)\s*:\s*(.*?)$/.exec(line);
if (lineMatch) {
const [, name, value] = lineMatch;
newLine = { name, value };
} else {
newLine = null;
}
return newLine;
})
.filter((line) => line !== null)
.reduce(
(acc, line) => {
let useValue;
if (['true', 'false'].includes(line.value)) {
useValue = line.value === 'true';
} else {
useValue = line.value;
}
return Object.assign({}, acc, {
[line.name]: useValue,
});
},
{}
);
} else {
result = {};
}
return result;
}
/**
* Get the information of all the export statements from a given code.
* @param {string} contents The code from where to extract the statements.
* @return {TargetsFinderExtractInformation}
* @ignore
* @access protected
*/
_getFileExports(contents) {
return this._extractFromCode(contents, this._exports);
}
/**
* Get the information of all the import statements from a given code.
* @param {string} contents The code from where to extract the statements.
* @return {TargetsFinderExtractInformation}
* @ignore
* @access protected
*/
_getFileImports(contents) {
return this._extractFromCode(contents, this._imports);
}
/**
* Given a dictionary of regular expressions lists and a source code, this method will try to
* identify and match the expressions in order to return all the matched results and the keys
* of the lists that returned results.
* @example
* const dictionary = {
* listOne: [/(goodbye)/ig, /(Batman)/ig],
* listTwo: [/(hello)/ig, /(Nightwing)/ig],
* };
* const code = 'hello Batman';
* console.log(this._extractFromCode(code, dictionary));
* // This would output { from: ['listOne', 'listTwo'], items: ['hello', 'Batman'] }
*
* @param {string} contents The source code to parse.
* @param {Object} expressionsDictionary A dictionary of regular expressions lists.
* @return {TargetsFinderExtractInformation}
* @throws {Error} if a regular expression doesn't have a capturing group.
* @ignore
* @access protected
*/
_extractFromCode(contents, expressionsDictionary) {
// Setup the return object and its properties.
const result = {
from: [],
items: [],
};
// Loop all the dictionaries.
Object.keys(expressionsDictionary).forEach((dictionaryName) => {
// Get the list of expressions.
const expressions = expressionsDictionary[dictionaryName];
// Set a list to hold all the found results of the current directionary.
const items = [];
// Loop all the expressions.
expressions.forEach((regex) => {
// Execute the expression.
let match = regex.exec(contents);
while (match) {
// Get the first capturing group.
const [, extract] = match;
// Normalize the extracted text.
const normalized = extract.toLowerCase().trim();
// Add it to the list.
items.push(normalized);
// Continue the execution loop.
match = regex.exec(contents);
}
});
// If the current dictionary found results...
if (items.length) {
// Add the dictionary name to the return object.
result.from.push(dictionaryName);
// Add the found items to the return object.
result.items.push(...items);
}
});
return result;
}
}
/**
* The service provider that once registered on the app container will create an instance of
* `TargetsFinder` and set its `find` method as the `targetsFinder` service.
* @example
* // Register it on the container
* container.register(targetsFinder);
* // Getting access to the service function
* const targetsFinder = container.get('targetsFinder');
* @type {Provider}
*/
const targetsFinder = provider((app) => {
app.set('targetsFinder', () => new TargetsFinder(
app.get('packageInfo'),
app.get('pathUtils')
).find);
});
module.exports = {
TargetsFinder,
targetsFinder,
};