dts-bundle
Version:
Export TypeScript .d.ts files as an external module definition
857 lines (736 loc) • 29.4 kB
text/typescript
;
import * as os from 'os';
import * as fs from 'fs';
import * as path from 'path';
import * as util from 'util';
import * as assert from 'assert';
import * as glob from 'glob';
import * as mkdirp from 'mkdirp';
import * as detectIndent from 'detect-indent';
let pkg = require('../package');
const dtsExp = /\.d\.ts$/;
const bomOptExp = /^\uFEFF?/;
const externalExp = /^([ \t]*declare module )(['"])(.+?)(\2[ \t]*{?.*)$/;
const importExp = /^([ \t]*(?:export )?(?:import .+? )= require\()(['"])(.+?)(\2\);.*)$/;
const importEs6Exp = /^([ \t]*(?:export|import) ?(?:(?:\* (?:as [^ ,]+)?)|.*)?,? ?(?:[^ ,]+ ?,?)(?:\{(?:[^ ,]+ ?,?)*\})? ?from )(['"])([^ ,]+)(\2;.*)$/;
const referenceTagExp = /^[ \t]*\/\/\/[ \t]*<reference[ \t]+path=(["'])(.*?)\1?[ \t]*\/>.*$/;
const identifierExp = /^\w+(?:[\.-]\w+)*$/;
const fileExp = /^([\./].*|.:.*)$/;
const privateExp = /^[ \t]*(?:static )?private (?:static )?/;
const publicExp = /^([ \t]*)(static |)(public |)(static |)(.*)/;
export interface Options {
main: string;
name: string;
baseDir?: string;
out?: string;
newline?: string;
indent?: string;
outputAsModuleFolder?: boolean;
prefix?: string;
separator?: string;
externals?: boolean;
exclude?: { (file: string): boolean; } | RegExp;
removeSource?: boolean;
verbose?: boolean;
referenceExternals?: boolean;
emitOnIncludedFileNotFound?: boolean;
emitOnNoIncludedFileNotFound?: boolean;
headerPath: string;
headerText: string;
}
export interface ModLine {
original: string;
modified?: string;
skip?: boolean;
}
export interface Result {
file: string;
name: string;
indent: string;
exp: string;
refs: string[];
externalImports: string[];
relativeImports: string[];
exports: string[];
lines: ModLine[];
importLineRef: ModLine[];
relativeRef: ModLine[];
fileExists: boolean;
}
export interface BundleResult {
fileMap: { [name: string]: Result; };
includeFilesNotFound: string[];
noIncludeFilesNotFound: string[];
emitted?: boolean;
options: Options;
}
export function bundle(options: Options): BundleResult {
assert(typeof options === 'object' && options, 'options must be an object');
// if main ends with **/*.d.ts all .d.ts files will be loaded
const allFiles = stringEndsWith(options.main, "**/*.d.ts");
// option parsing & validation
const main = allFiles ? "*.d.ts" : options.main;
const exportName = options.name;
const _baseDir = (() => {
let baseDir = optValue(options.baseDir, path.dirname(options.main));
if (allFiles) {
baseDir = baseDir.substr(0, baseDir.length - 2);
}
return baseDir;
})();
const out = optValue(options.out, exportName + '.d.ts').replace(/\//g, path.sep);
const newline = optValue(options.newline, os.EOL);
const indent = optValue(options.indent, ' ');
const outputAsModuleFolder = optValue(options.outputAsModuleFolder, false);
const prefix = optValue(options.prefix, '');
const separator = optValue(options.separator, '/');
const externals = optValue(options.externals, false);
const exclude = optValue(options.exclude, null);
const removeSource = optValue(options.removeSource, false);
const referenceExternals = optValue(options.referenceExternals, false);
const emitOnIncludedFileNotFound = optValue(options.emitOnIncludedFileNotFound, false);
const emitOnNoIncludedFileNotFound = optValue(options.emitOnNoIncludedFileNotFound, false);
const _headerPath = optValue(options.headerPath, null);
const headerText = optValue(options.headerText, '');
// regular (non-jsdoc) comments are not actually supported by declaration compiler
const comments = false;
const verbose = optValue(options.verbose, false);
assert.ok(main, 'option "main" must be defined');
assert.ok(exportName, 'option "name" must be defined');
assert(typeof newline === 'string', 'option "newline" must be a string');
assert(typeof indent === 'string', 'option "indent" must be a string');
assert(typeof prefix === 'string', 'option "prefix" must be a string');
assert(separator.length > 0, 'option "separator" must have non-zero length');
// turn relative paths into absolute paths
const baseDir = path.resolve(_baseDir);
let mainFile = allFiles ? path.resolve(baseDir, "**/*.d.ts") : path.resolve(main.replace(/\//g, path.sep));
const outFile = calcOutFilePath(out, baseDir);
let headerData = '// Generated by dts-bundle v' + pkg.version + newline;
const headerPath = _headerPath && _headerPath !== "none" ? path.resolve(_headerPath.replace(/\//g, path.sep)) : _headerPath;
trace('### settings object passed ###');
traceObject(options);
trace('### settings ###');
trace('main: %s', main);
trace('name: %s', exportName);
trace('out: %s', out);
trace('baseDir: %s', baseDir);
trace('mainFile: %s', mainFile);
trace('outFile: %s', outFile);
trace('externals: %s', externals ? 'yes' : 'no');
trace('exclude: %s', exclude);
trace('removeSource: %s', removeSource ? 'yes' : 'no');
trace('comments: %s', comments ? 'yes' : 'no');
trace('emitOnIncludedFileNotFound: %s', emitOnIncludedFileNotFound ? "yes" : "no");
trace('emitOnNoIncludedFileNotFound: %s', emitOnNoIncludedFileNotFound ? "yes" : "no");
trace("headerPath %s", headerPath);
trace("headerText %s", headerText);
if (!allFiles) {
assert(fs.existsSync(mainFile), 'main does not exist: ' + mainFile);
}
if (headerPath) {
if (headerPath === "none") {
headerData = "";
} else {
assert(fs.existsSync(headerPath), 'header does not exist: ' + headerPath);
headerData = fs.readFileSync(headerPath, 'utf8') + headerData;
}
} else if (headerText) {
headerData = '/*' + headerText + '*/\n';
}
let isExclude: (file: string, arg?: boolean) => boolean;
if (typeof exclude === 'function') {
isExclude = <any>exclude;
}
else if (exclude instanceof RegExp) {
isExclude = file => exclude.test(file);
}
else {
isExclude = () => false;
}
const sourceTypings = glob.sync('**/*.d.ts', { cwd: baseDir }).map(file => path.resolve(baseDir, file));
// if all files, generate temporally main file
if (allFiles) {
let mainFileContent = "";
trace("## temporally main file ##");
sourceTypings.forEach(file => {
let generatedLine = "export * from './" + path.relative(baseDir, file.substr(0, file.length - 5)).replace(path.sep, "/") + "';";
trace(generatedLine);
mainFileContent += generatedLine + "\n";
});
mainFile = path.resolve(baseDir, "dts-bundle.tmp." + exportName + ".d.ts");
fs.writeFileSync(mainFile, mainFileContent, 'utf8');
}
trace('\n### find typings ###');
const inSourceTypings = (file: string) => {
return sourceTypings.indexOf(file) !== -1 || sourceTypings.indexOf(path.join(file, 'index.d.ts')) !== -1;
}; // if file reference is a directory assume commonjs index.d.ts
trace('source typings (will be included in output if actually used)');
sourceTypings.forEach(file => trace(' - %s ', file));
trace('excluded typings (will always be excluded from output)');
let fileMap: { [name: string]: Result; } = Object.create(null);
let globalExternalImports: string[] = [];
let mainParse: Result; // will be parsed result of first parsed file
let externalTypings: string[] = [];
let inExternalTypings = (file: string) => externalTypings.indexOf(file) !== -1;
{
// recursively parse files, starting from main file,
// following all references and imports
trace('\n### parse files ###');
let queue: string[] = [mainFile];
let queueSeen: { [name: string]: boolean; } = Object.create(null);
while (queue.length > 0) {
let target = queue.shift();
if (queueSeen[target]) {
continue;
}
queueSeen[target] = true;
// parse the file
let parse = parseFile(target);
if (!mainParse) {
mainParse = parse;
}
fileMap[parse.file] = parse;
pushUniqueArr(queue, parse.refs, parse.relativeImports);
}
}
// map all exports to their file
trace('\n### map exports ###');
let exportMap = Object.create(null);
Object.keys(fileMap).forEach(file => {
let parse = fileMap[file];
parse.exports.forEach(name => {
assert(!(name in exportMap), 'already got export for: ' + name);
exportMap[name] = parse;
trace('- %s -> %s', name, parse.file);
});
});
// build list of typings to include in output later
trace('\n### determine typings to include ###');
let excludedTypings: string[] = [];
let usedTypings: Result[] = [];
let externalDependencies: string[] = []; // lists all source files that we omit due to !externals
{
let queue = [mainParse];
let queueSeen: { [name: string]: boolean; } = Object.create(null);
trace('queue');
trace(queue);
while (queue.length > 0) {
let parse = queue.shift();
if (queueSeen[parse.file]) {
continue;
}
queueSeen[parse.file] = true;
trace('%s (%s)', parse.name, parse.file);
usedTypings.push(parse);
parse.externalImports.forEach(name => {
let p = exportMap[name];
if (!externals) {
trace(' - exclude external %s', name);
pushUnique(externalDependencies, !p ? name : p.file);
return;
}
if (isExclude(path.relative(baseDir, p.file), true)) {
trace(' - exclude external filter %s', name);
pushUnique(excludedTypings, p.file);
return;
}
trace(' - include external %s', name);
assert(p, name);
queue.push(p);
});
parse.relativeImports.forEach(file => {
let p = fileMap[file];
if (isExclude(path.relative(baseDir, p.file), false)) {
trace(' - exclude internal filter %s', file);
pushUnique(excludedTypings, p.file);
return;
}
trace(' - import relative %s', file);
assert(p, file);
queue.push(p);
});
}
}
// rewrite global external modules to a unique name
trace('\n### rewrite global external modules ###');
usedTypings.forEach(parse => {
trace(parse.name);
parse.relativeRef.forEach((line, i) => {
line.modified = replaceExternal(line.original, getLibName);
trace(' - %s ==> %s', line.original, line.modified);
});
parse.importLineRef.forEach((line, i) => {
if (outputAsModuleFolder) {
trace(' - %s was skipped.', line.original);
line.skip = true;
return;
}
if (importExp.test(line.original)) {
line.modified = replaceImportExport(line.original, getLibName);
} else {
line.modified = replaceImportExportEs6(line.original, getLibName);
}
trace(' - %s ==> %s', line.original, line.modified);
});
});
// build collected content
trace('\n### build output ###');
let content = headerData;
if (externalDependencies.length > 0) {
content += '// Dependencies for this module:' + newline;
externalDependencies.forEach(file => {
if (referenceExternals) {
content += formatReference(path.relative(baseDir, file).replace(/\\/g, '/')) + newline;
}
else {
content += '// ' + path.relative(baseDir, file).replace(/\\/g, '/') + newline;
}
});
}
if ( globalExternalImports.length > 0 ) {
content += newline;
content += globalExternalImports.join(newline) + newline;
}
content += newline;
// content += header.stringify(header.importer.packageJSON(pkg)).join(lb) + lb;
// content += lb;
// add wrapped modules to output
content += usedTypings.filter((parse: Result) => {
// Eliminate all the skipped lines
parse.lines = parse.lines.filter((line: ModLine) => {
return (true !== line.skip);
});
// filters empty parse objects.
return ( parse.lines.length > 0 );
}).map((parse: Result) => {
if (inSourceTypings(parse.file)) {
return formatModule(parse.file, parse.lines.map(line => {
return getIndenter(parse.indent, indent)(line);
}));
}
else {
return parse.lines.map(line => {
return getIndenter(parse.indent, indent)(line);
}).join(newline) + newline;
}
}).join(newline) + newline;
// remove internal typings, except the 'regenerated' main typing
if (removeSource) {
trace('\n### remove source typings ###');
sourceTypings.forEach(p => {
// safety check, only delete .d.ts files, leave our outFile intact for now
if (p !== outFile && dtsExp.test(p) && fs.statSync(p).isFile()) {
trace(' - %s', p);
fs.unlinkSync(p);
}
});
}
let inUsed = (file: string): boolean => {
return usedTypings.filter(parse => parse.file === file).length !== 0;
};
let bundleResult: BundleResult = {
fileMap,
includeFilesNotFound: [],
noIncludeFilesNotFound: [],
options
};
trace('## files not found ##');
for (let p in fileMap) {
let parse = fileMap[p];
if (!parse.fileExists) {
if (inUsed(parse.file)) {
bundleResult.includeFilesNotFound.push(parse.file);
warning(' X Included file NOT FOUND %s ', parse.file)
} else {
bundleResult.noIncludeFilesNotFound.push(parse.file);
trace(' X Not used file not found %s', parse.file);
}
}
}
// write main file
trace('\n### write output ###');
// write only if there aren't not found files or there are and option "emit file not found" is true.
if ((bundleResult.includeFilesNotFound.length == 0
|| (bundleResult.includeFilesNotFound.length > 0 && emitOnIncludedFileNotFound))
&& (bundleResult.noIncludeFilesNotFound.length == 0
|| (bundleResult.noIncludeFilesNotFound.length > 0 && emitOnNoIncludedFileNotFound))) {
trace(outFile);
{
let outDir = path.dirname(outFile);
if (!fs.existsSync(outDir)) {
mkdirp.sync(outDir);
}
}
fs.writeFileSync(outFile, content, 'utf8');
bundleResult.emitted = true;
} else {
warning(" XXX Not emit due to exist files not found.")
trace("See documentation for emitOnIncludedFileNotFound and emitOnNoIncludedFileNotFound options.")
bundleResult.emitted = false;
}
// print some debug info
if (verbose) {
trace('\n### statistics ###');
trace('used sourceTypings');
sourceTypings.forEach(p => {
if (inUsed(p)) {
trace(' - %s', p);
}
});
trace('unused sourceTypings');
sourceTypings.forEach(p => {
if (!inUsed(p)) {
trace(' - %s', p);
}
});
trace('excludedTypings');
excludedTypings.forEach(p => {
trace(' - %s', p);
});
trace('used external typings');
externalTypings.forEach(p => {
if (inUsed(p)) {
trace(' - %s', p);
}
});
trace('unused external typings');
externalTypings.forEach(p => {
if (!inUsed(p)) {
trace(' - %s', p);
}
});
trace('external dependencies');
externalDependencies.forEach(p => {
trace(' - %s', p);
});
}
trace('\n### done ###\n');
// remove temporally file.
if (allFiles) {
fs.unlinkSync(mainFile);
}
return bundleResult;
function stringEndsWith(str: string, suffix: string) {
return str.indexOf(suffix, str.length - suffix.length) !== -1;
}
function stringStartsWith(str: string, prefix: string) {
return str.slice(0, prefix.length) == prefix;
}
// Calculate out file path (see #26 https://github.com/TypeStrong/dts-bundle/issues/26)
function calcOutFilePath(out: any, baseDir: any) {
var result = path.resolve(baseDir, out);
// if path start with ~, out parameter is relative from current dir
if (stringStartsWith(out, "~" + path.sep)) {
result = path.resolve(".", out.substr(2));
}
return result;
}
function traceObject(obj: any) {
if (verbose) {
console.log(obj);
}
}
function trace(...args: any[]) {
if (verbose) {
console.log(util.format.apply(null, args));
}
}
function warning(...args: any[]) {
console.log(util.format.apply(null, args));
}
function getModName(file: string) {
return path.relative(baseDir, path.dirname(file) + path.sep + path.basename(file).replace(/\.d\.ts$/, ''));
}
function getExpName(file: string) {
if (file === mainFile) {
return exportName;
}
return getExpNameRaw(file);
}
function getExpNameRaw(file: string) {
return prefix + exportName + separator + cleanupName(getModName(file));
}
function getLibName(ref: string) {
return getExpNameRaw(mainFile) + separator + prefix + separator + ref;
}
function cleanupName(name: string) {
return name.replace(/\.\./g, '--').replace(/[\\\/]/g, separator);
}
function mergeModulesLines(lines: any) {
var i = (outputAsModuleFolder ? '' : indent);
return (lines.length === 0 ? '' : i + lines.join(newline + i)) + newline;
}
function formatModule(file: string, lines: string[]) {
let out = '';
if (outputAsModuleFolder) {
return mergeModulesLines(lines);
}
out += 'declare module \'' + getExpName(file) + '\' {' + newline;
out += mergeModulesLines(lines);
out += '}' + newline;
return out;
}
// main info extractor
function parseFile(file: string): Result {
const name = getModName(file);
trace('%s (%s)', name, file);
const res: Result = {
file: file,
name: name,
indent: indent,
exp: getExpName(file),
refs: [], // triple-slash references
externalImports: [], // import()'s like "events"
relativeImports: [], // import()'s like "./foo"
exports: [],
lines: [],
fileExists: true,
// the next two properties contain single-element arrays, which reference the same single-element in .lines,
// in order to be able to replace their contents later in the bundling process.
importLineRef: [],
relativeRef: []
};
if (!fs.existsSync(file)) {
trace(' X - File not found: %s', file);
res.fileExists = false;
return res;
}
if (fs.lstatSync(file).isDirectory()) { // if file is a directory then lets assume commonjs convention of an index file in the given folder
file = path.join(file, 'index.d.ts');
}
const code = fs.readFileSync(file, 'utf8').replace(bomOptExp, '').replace(/\s*$/, '');
res.indent = detectIndent(code) || indent;
// buffer multi-line comments, handle JSDoc
let multiComment: string[] = [];
let queuedJSDoc: string[];
let inBlockComment = false;
const popBlock = () => {
if (multiComment.length > 0) {
// jsdoc
if (/^[ \t]*\/\*\*/.test(multiComment[0])) {
// flush but hold
queuedJSDoc = multiComment;
}
else if (comments) {
// flush it
multiComment.forEach(line => res.lines.push({ original: line }));
}
multiComment = [];
}
inBlockComment = false;
};
const popJSDoc = () => {
if (queuedJSDoc) {
queuedJSDoc.forEach(line => {
// fix shabby TS JSDoc output
let match = line.match(/^([ \t]*)(\*.*)/);
if (match) {
res.lines.push({ original: match[1] + ' ' + match[2] });
}
else {
res.lines.push({ original: line });
}
});
queuedJSDoc = null;
}
};
code.split(/\r?\n/g).forEach((line: any) => {
let match: string[];
// block comment end
if (/^[((=====)(=*)) \t]*\*+\//.test(line)) {
multiComment.push(line);
popBlock();
return;
}
// block comment start
if (/^[ \t]*\/\*/.test(line)) {
multiComment.push(line);
inBlockComment = true;
// single line block comment
if (/\*+\/[ \t]*$/.test(line)) {
popBlock();
}
return;
}
if (inBlockComment) {
multiComment.push(line);
return;
}
// blankline
if (/^\s*$/.test(line)) {
res.lines.push({ original: '' });
return;
}
// reference tag
if (/^\/\/\//.test(line)) {
let ref = extractReference(line);
if (ref) {
let refPath = path.resolve(path.dirname(file), ref);
if (inSourceTypings(refPath)) {
trace(' - reference source typing %s (%s)', ref, refPath);
} else {
let relPath = path.relative(baseDir, refPath).replace(/\\/g, '/');
trace(' - reference external typing %s (%s) (relative: %s)', ref, refPath, relPath);
if (!inExternalTypings(refPath)) {
externalTypings.push(refPath);
}
}
pushUnique(res.refs, refPath);
return;
}
}
// line comments
if (/^\/\//.test(line)) {
if (comments) {
res.lines.push({ original: line });
}
return;
}
// private member
if (privateExp.test(line)) {
queuedJSDoc = null;
return;
}
popJSDoc();
// import() statement or es6 import
if ((line.indexOf("from") >= 0 && (match = line.match(importEs6Exp))) ||
(line.indexOf("require") >= 0 && (match = line.match(importExp)))) {
const [_, lead, quote, moduleName, trail] = match;
assert(moduleName);
const impPath = path.resolve(path.dirname(file), moduleName);
// filename (i.e. starts with a dot, slash or windows drive letter)
if (fileExp.test(moduleName)) {
// TODO: some module replacing is handled here, whereas the rest is
// done in the "rewrite global external modules" step. It may be
// more clear to do all of it in that step.
let modLine: ModLine = {
original: lead + quote + getExpName(impPath) + trail
};
res.lines.push(modLine);
let full = path.resolve(path.dirname(file), impPath);
// If full is not an existing file, then let's assume the extension .d.ts
if(!fs.existsSync(full) || fs.existsSync(full + '.d.ts')) {
full += '.d.ts';
}
trace(' - import relative %s (%s)', moduleName, full);
pushUnique(res.relativeImports, full);
res.importLineRef.push(modLine);
}
// identifier
else {
let modLine: ModLine = {
original: line
};
trace(' - import external %s', moduleName);
pushUnique(res.externalImports, moduleName);
if (externals) {
res.importLineRef.push(modLine);
}
if (!outputAsModuleFolder) {
res.lines.push(modLine);
} else {
pushUnique(globalExternalImports, line);
}
}
}
// declaring an external module
// this triggers when we're e.g. parsing external module declarations, such as node.d.ts
else if ((match = line.match(externalExp))) {
let [_, declareModule, lead, moduleName, trail] = match;
assert(moduleName);
trace(' - declare %s', moduleName);
pushUnique(res.exports, moduleName);
let modLine: ModLine = {
original: line
};
res.relativeRef.push(modLine); // TODO
res.lines.push(modLine);
}
// clean regular lines
else {
// remove public keyword
if ((match = line.match(publicExp))) {
let [_, sp, static1, pub, static2, ident] = match;
line = sp + static1 + static2 + ident;
}
if (inSourceTypings(file)) {
// for internal typings, remove the 'declare' keyword (but leave 'export' intact)
res.lines.push({ original: line.replace(/^(export )?declare /g, '$1') });
}
else {
res.lines.push({ original: line });
}
}
});
return res;
}
}
function pushUnique<T>(arr: T[], value: T) {
if (arr.indexOf(value) < 0) {
arr.push(value);
}
return arr;
}
function pushUniqueArr<T>(arr: T[], ...values: T[][]) {
values.forEach(vs => vs.forEach(v => pushUnique(arr, v)));
return arr;
}
function formatReference(file: string) {
return '/// <reference path="' + file.replace(/\\/g, '/') + '" />';
}
function extractReference(tag: string) {
let match = tag.match(referenceTagExp);
if (match) {
return match[2];
}
return null;
}
function replaceImportExport(line: string, replacer: (str: string) => string) {
let match = line.match(importExp);
if (match) {
assert(match[4]);
if (identifierExp.test(match[3])) {
return match[1] + match[2] + replacer(match[3]) + match[4];
}
}
return line;
}
function replaceImportExportEs6(line: string, replacer: (str: string) => string) {
if (line.indexOf("from") < 0) {
return line;
}
let match = line.match(importEs6Exp);
if (match) {
assert(match[4]);
if (identifierExp.test(match[3])) {
return match[1] + match[2] + replacer(match[3]) + match[4];
}
}
return line;
}
function replaceExternal(line: string, replacer: (str: string) => string) {
let match = line.match(externalExp);
if (match) {
let [_, declareModule, beforeIndent, moduleName, afterIdent] = match;
assert(afterIdent);
if (identifierExp.test(moduleName)) {
return declareModule + beforeIndent + replacer(moduleName) + afterIdent;
}
}
return line;
}
function getIndenter(actual: string, use: string): (line: ModLine) => string {
if (actual === use || !actual) {
return line => line.modified || line.original;
}
return line => (line.modified || line.original).replace(new RegExp('^' + actual + '+', 'g'), match => match.split(actual).join(use));
}
function optValue<T>(passed: T, def: T): T {
if (typeof passed === 'undefined') {
return def;
}
return passed;
}
function regexEscape(s: string) {
return s.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&');
}