fuse-box
Version:
Fuse-Box a bundler that does it right
535 lines (476 loc) • 19 kB
text/typescript
import { IPackageInformation, IPathInformation } from "./PathMaster";
import { WorkFlowContext } from "./WorkflowContext";
import { ensurePublicExtension, ensureFuseBoxPath } from "../Utils";
import { Config } from "../Config";
import * as path from "path";
import * as fs from "fs";
import { BundleData } from "../arithmetic/Arithmetic";
import { File } from "./File";
/**
* If a import url isn't relative
* and only has ascii + @ in the name it is considered a node module
*/
const NODE_MODULE = /^([a-z@](?!:).*)$/;
const isRelative = /^[\.\/\\]+$/
const jsExtensions = ['js', 'jsx'];
const tsExtensions = jsExtensions.concat(['ts', 'tsx']);
export interface INodeModuleRequire {
name: string;
fuseBoxPath?: string;
target?: string;
}
export interface IPathInformation {
fuseBoxAlias?: string;
overrideStatement?: { key: string, value: string }
isRemoteFile?: boolean;
remoteURL?: string;
tsMode?: boolean;
isNodeModule: boolean;
nodeModuleName?: string;
nodeModuleInfo?: IPackageInformation;
nodeModuleExplicitOriginal?: string;
absDir?: string;
fuseBoxPath?: string;
params?: Map<string, string>;
absPath?: string;
}
export interface IPackageInformation {
name: string;
missing?: boolean;
bundleData?: BundleData;
entry: string;
version: string;
jsNext?: boolean;
root: string;
entryRoot: string,
custom: boolean;
browserOverrides?: any;
customBelongsTo?: string;
}
/**
* Manages the allowed extensions e.g.
* should user be allowed to do `require('./foo.ts')`
*/
export class AllowedExtenstions {
/**
* Users are allowed to require files with these extensions by default
**/
public static list: Set<string> = new Set([".js", ".jsx", ".ts", ".tsx", ".json", ".xml", ".css", ".html"]);
public static add(name: string) {
if (!this.list.has(name)) {
this.list.add(name);
}
}
public static has(name) {
return this.list.has(name);
}
}
/**
* PathMaster
*/
export class PathMaster {
private tsMode: boolean = false;
private fuseBoxAlias: string;
constructor(public context: WorkFlowContext, public rootPackagePath?: string) { }
public init(name: string, fuseBoxPath?: string) {
const resolved = this.resolve(name, this.rootPackagePath);
if (fuseBoxPath) {
resolved.fuseBoxPath = fuseBoxPath;
}
return resolved;
}
public setTypeScriptMode() {
this.tsMode = true;
}
public resolve(name: string, root?: string, rootEntryLimit?: string, parent?: File): IPathInformation {
let data = <IPathInformation>{};
if (/^(http(s)?:|\/\/)/.test(name)) {
data.isRemoteFile = true;
data.remoteURL = name;
data.absPath = name;
return data;
}
// if (/\?/.test(name)) {
// let paramsSplit = name.split(/\?(.+)/);
// name = paramsSplit[0];
// data.params = parseQuery(paramsSplit[1]);
// }
data.isNodeModule = NODE_MODULE.test(name);
if (data.isNodeModule) {
let info = this.getNodeModuleInfo(name);
data.nodeModuleName = info.name;
// A trick to avoid one nasty situation
// Imagine lodash@1.0.0 that is set as a custom depedency for 2 libraries
// We need to make sure there, that we use one source (either or)
// We don't want to take modules from 2 different places (in case if versions match)
let nodeModuleInfo = this.getNodeModuleInformation(info.name);
let cachedInfo = this.context.getLibInfo(nodeModuleInfo.name, nodeModuleInfo.version);
if (cachedInfo) { // Modules has been defined already
data.nodeModuleInfo = cachedInfo;
} else {
data.nodeModuleInfo = nodeModuleInfo; // First time that module is mentioned
// Caching module information
// Which will override paths
this.context.setLibInfo(nodeModuleInfo.name, nodeModuleInfo.version, nodeModuleInfo);
}
if (info.target) {
// Explicit require from a libary e.g "lodash/dist/each" -> "dist/each"
const absPath = this.getAbsolutePath(info.target, data.nodeModuleInfo.root, undefined, true);
if (absPath.alias) {
// console.log(name, nodeModuleInfo);
// nodeModuleInfo.browserOverrides = {}
// nodeModuleInfo.browserOverrides[name] = info.name + "/" + absPath.alias;
if (parent) {
parent.analysis.
replaceAliases(new Set([{from : name, to : info.name + "/" + absPath.alias }]))
}
data.fuseBoxAlias = absPath.alias;
}
data.absPath = absPath.resolved;
data.absDir = path.dirname(data.absPath);
data.nodeModuleExplicitOriginal = info.target;
} else {
data.absPath = data.nodeModuleInfo.entry;
data.absDir = data.nodeModuleInfo.root;
}
if (data.absPath) {
data.fuseBoxPath = this.getFuseBoxPath(data.absPath, data.nodeModuleInfo.root);
}
if (this.fuseBoxAlias) {
data.fuseBoxPath = this.fuseBoxAlias;
}
} else {
if (root) {
const absPath = this.getAbsolutePath(name, root, rootEntryLimit);
if (absPath.alias) {
data.fuseBoxAlias = absPath.alias;
if (parent) {
parent.analysis.
replaceAliases(new Set([{from : name, to : `~/` + absPath.alias }]))
}
}
data.absPath = absPath.resolved;
data.absDir = path.dirname(data.absPath);
data.fuseBoxPath = this.getFuseBoxPath(data.absPath, this.rootPackagePath);
if (path.relative(this.rootPackagePath, data.absPath).match(/^\.\.(\\|\/)/)) {
this.context.fuse.producer.addWarning('unresolved',
`File "${data.absPath}" cannot be bundled because it's out of your project directory (homeDir)`);
}
}
}
if (data.fuseBoxAlias) {
data.overrideStatement = { key: name, value: data.fuseBoxAlias }
}
return data;
}
public getFuseBoxPath(name: string, root: string) {
if (!root) {
return;
}
name = name.replace(/\\/g, "/");
root = root.replace(/\\/g, "/");
name = name.replace(root, "").replace(/^\/|\\/, "");
name = ensurePublicExtension(name);
// Some smart asses like "react-router"
// Skip .js for their main entry points.
let ext = path.extname(name);
if (!ext) {
name += ".js";
}
return name;
}
/**
* Make sure that all extensions are in place
* Returns a valid absolute path
*
* @param {string} name
* @param {string} root
* @returns
*
* @memberOf PathMaster
*/
public getAbsolutePath(name: string, root: string, rootEntryLimit?: string, explicit = false): {
resolved: string,
alias?: string
} {
const data = this.ensureFolderAndExtensions(name, root, explicit);
const url = data.resolved;
const alias = data.alias;
let result = path.resolve(root, url);
// Fixing node_modules package .json limits.
if (rootEntryLimit && name.match(/\.\.\/$/)) {
if (result.indexOf(path.dirname(rootEntryLimit)) < 0) {
return { resolved: rootEntryLimit, alias: alias };
}
}
const output = { resolved: result, alias: alias };
//RESOLUTION_CACHE.set(cacheKey, output);
return output;
}
public getParentFolderName(): string {
if (this.rootPackagePath) {
let s = this.rootPackagePath.split(/\/|\\/g);
return s[s.length - 1];
}
return "";
}
private testFolder(folder: string, name: string) {
let extensions = jsExtensions;
if (this.tsMode) {
extensions = tsExtensions
}
if (fs.existsSync(folder)) {
for (let i = 0; i < extensions.length; i++) {
const index = "index." + extensions[i]
if (fs.existsSync(path.join(folder, index))) {
const result = path.join(name, index);
const [a, b] = name
if (a === "." && b !== ".") {
//add relative './' from `name`, back onto joined path
return "./" + result;
}
return result;
}
}
}
}
private checkFileName(root: string, name: string) {
let extensions = jsExtensions;
if (this.tsMode) {
extensions = tsExtensions;
}
for (let i = 0; i < extensions.length; i++) {
let ext = extensions[i];
let fileName = `${name}.${ext}`;
let target = path.isAbsolute(name) ? fileName : path.join(root, fileName);
if (fs.existsSync(target)) {
if (fileName[0] === ".") {
fileName = `./${fileName}`;
}
return fileName;
}
}
}
private ensureNodeModuleExtension(input: string) {
let ext = path.extname(input);
if (!ext && !isRelative.test(input)) {
return input + ".js";
}
return input;
}
private extractRelativeFuseBoxPath(root: string, target: string): string {
root = ensureFuseBoxPath(root)
target = ensureFuseBoxPath(target);
const sResult = target.split(root)
if (sResult[1]) {
let fusePath = sResult[1];
if (fusePath[0] === "/") {
fusePath = fusePath.slice(1)
}
return this.ensureNodeModuleExtension(fusePath);
}
}
private ensureFolderAndExtensions(name: string, root: string, explicit = false): { resolved: string, alias?: string } {
let ext = path.extname(name);
if (ext === ".ts") {
this.tsMode = true;
}
let fileExt = this.tsMode && !explicit ? ".ts" : ".js";
if (name[0] === "~" && name[1] === "/" && this.rootPackagePath) {
name = "." + name.slice(1, name.length);
name = path.join(this.rootPackagePath, name);
}
if (!ext) {
// handle cases with
// require("@angular/platform-browser/animations");
// where animation contains package.json pointing to a different file
const folderJsonPath = path.join(root, name, "package.json");
//1
if (fs.existsSync(folderJsonPath)) {
const folderJSON = require(folderJsonPath);
if (folderJSON.main) {
const resolved = path.resolve(root, name, folderJSON.main);
const opts = {
resolved: resolved,
alias: this.extractRelativeFuseBoxPath(root, resolved)
}
return opts;
}
}
}
if (!AllowedExtenstions.has(ext)) {
let fileNameCheck = this.checkFileName(root, name);
if (fileNameCheck) {
return { resolved: fileNameCheck };
} else {
let folder = path.isAbsolute(name) ? name : path.join(root, name);
const folderPath = this.testFolder(folder, name);
if (folderPath) {
return { resolved: folderPath }
} else {
name += fileExt;
return { resolved: name };
}
}
}
return { resolved: name };
}
private getNodeModuleInfo(name: string): INodeModuleRequire {
// Handle scope requires
if (name[0] === "@") {
let s = name.split("/");
let target = s.splice(2, s.length).join("/");
// let fuseBoxPath;
// if (target) {
// fuseBoxPath = this.ensureNodeModuleExtension(target);
// }
return {
name: `${s[0]}/${s[1]}`,
target: target
};
}
let data = name.split(/\/(.+)?/);
return {
name: data[0],
target: data[1],
};
}
private fixBrowserOverrides(browserOverrides: { [key: string]: string }): { [key: string]: string } {
let newOverrides = {};
for (let key in browserOverrides) {
let value = browserOverrides[key];
if (typeof value === "string") {
if (/\.\//.test(key)) {
key = key.slice(2);
}
if (/\.\//.test(value)) {
value = "~/" + value.slice(2);
} else {
value = "~/" + value;
}
if (!/.js$/.test(value)) {
value = value + ".js";
}
}
newOverrides[key] = value;
}
return newOverrides;
}
private getNodeModuleInformation(name: string): IPackageInformation {
const readMainFile = (folder, isCustom: boolean) => {
// package.json path
const packageJSONPath = path.join(folder, "package.json");
if (fs.existsSync(packageJSONPath)) {
// read contents
const json: any = require(packageJSONPath);
// Getting an entry point
let entryFile;
let entryRoot;
let jsNext = false;
let browserOverrides;
if (this.context.target !== "server") {
if (json.browser && !this.context.isBrowserTarget()) {
this.context.fuse.producer.addWarning("json.browser",
`Library "${name}" contains "browser" field. Set .target("browser") to avoid problems with your browser build!`);
}
}
if (this.context.isBrowserTarget() && json.browser) {
if (typeof json.browser === "object") {
browserOverrides = this.fixBrowserOverrides(json.browser);
if (json.browser[json.main]) {
entryFile = json.browser[json.main];
}
}
if (typeof json.browser === "string") {
entryFile = json.browser;
}
}
if (this.context.shouldUseJsNext(name) && (json["jsnext:main"] || json.module)) {
jsNext = true;
entryFile = path.join(folder, json["jsnext:main"] || json.module);
} else {
entryFile = path.join(folder, entryFile || json.main || "index.js");
}
if (json["ts:main"]) {
entryFile = json["ts:main"];
if (entryFile[0] !== ".") { // safety check to avoid consfusion with node_module
entryFile = `./${entryFile}`
}
}
entryRoot = path.dirname(entryFile);
const ext = path.extname(entryFile);
return {
browserOverrides: browserOverrides,
name,
tsMode: ext === ".ts",
jsNext,
custom: isCustom,
root: folder,
missing: false,
entryRoot,
entry: entryFile,
version: json.version || "1.0.0",
};
}
let defaultEntry = path.join(folder, "index.js");
let entryFile = fs.existsSync(defaultEntry) ? defaultEntry : undefined;
let defaultEntryRoot = entryFile ? path.dirname(entryFile) : undefined;
let packageExists = fs.existsSync(folder);
return {
name,
missing: !packageExists,
custom: isCustom,
root: folder,
entry: entryFile,
entryRoot: defaultEntryRoot,
version: "0.0.0",
};
};
let localLib = path.join(Config.FUSEBOX_MODULES, name);
let modulePath = path.join(Config.NODE_MODULES_DIR, name);
// check for custom shared packages
const producer = this.context.bundle && this.context.bundle.producer
if (producer && producer.isShared(name)) {
let shared = producer.getSharedPackage(name);
return {
name,
custom: false,
bundleData: shared.data,
root: shared.homeDir,
entry: shared.mainPath,
entryRoot: shared.mainDir,
version: "0.0.0",
}
}
if (this.context.customModulesFolder) {
let customFolder = path.join(this.context.customModulesFolder, name);
if (fs.existsSync(customFolder)) {
return readMainFile(customFolder, false);
}
}
if (fs.existsSync(localLib)) {
return readMainFile(localLib, false);
}
// handle a conflicting library
if (this.rootPackagePath) {
let nodeModules = path.join(this.rootPackagePath, "node_modules");
let nestedNodeModule = path.join(nodeModules, name);
if (fs.existsSync(nestedNodeModule)) {
return readMainFile(nestedNodeModule, nodeModules !== Config.NODE_MODULES_DIR);
} else {
// climb up (sometimes it can be in a parent)
let upperNodeModule = path.join(this.rootPackagePath, "../", name);
if (path.dirname(upperNodeModule) !== Config.NODE_MODULES_DIR) {
if (fs.existsSync(upperNodeModule)) {
let isCustom = false;
if (path.dirname(upperNodeModule).match(/node_modules$/)) {
isCustom = path.dirname(this.rootPackagePath) !== Config.NODE_MODULES_DIR;
return readMainFile(upperNodeModule, isCustom);
}
}
}
}
}
return readMainFile(modulePath, false);
}
}