docma
Version:
A powerful dev-tool to easily generate beautiful HTML documentation from Javascript (JSDoc), Markdown and HTML files.
463 lines (427 loc) • 14.4 kB
JavaScript
;
// core modules
const Path = require('path');
// dep modules
const _ = require('lodash');
const Promise = require('bluebird');
const fs = require('fs-extra');
const marked = require('marked');
const uglify = require('uglify-js');
const stripJsonComments = require('strip-json-comments');
const Less = require('less');
const LessCleanCssPlugin = require('less-plugin-clean-css');
const gzipSize = require('gzip-size');
const GLOB = Promise.promisify(require('glob'));
const lessCleanCssPlugin = new LessCleanCssPlugin({ advanced: true });
const INVALID = {
// https://docs.microsoft.com/en-us/windows/desktop/FileIO/naming-a-file
WINDOWS: {
CHARS: /[<>:"\\|?*]/, // char / is left out (allowed in Docma as path separator)
NAME: /^(nul|prn|aux|con|lpt[0-9]|com[0-9]|(clock|keybd|screen|idle|config)\$)(\.|$)/i,
MAX_LEN: 260 - 12
},
// https://en.wikipedia.org/wiki/Filename#Reserved_characters_and_words
// https://blog.josephscott.org/2007/02/12/things-that-shouldnt-be-in-file-names-for-1000-alex/
UNIX: {
CHARS: /[\\|?*]/, // char / is left out (allowed in Docma as path separator)
NAME: /^[.]+$/,
MAX_LEN: 255
}
};
let utils = null;
// ---------------------------
// Helpers
// ---------------------------
// used for utils._findDocmaConfigFileSync()
function getFileSync(filename, basePath = null) {
const cPath = Path.resolve(Path.join(basePath || process.cwd(), filename));
return fs.pathExistsSync(cPath) ? cPath : null;
}
// ---------------------------
// path
// ---------------------------
const path = {
// filename without the extension
basename(filepath) {
const ext = Path.extname(filepath);
return Path.basename(filepath, ext);
},
parent(p) {
p = Path.normalize(p);
const arr = p.split(/[\\/]+/);
// remove last item
arr.splice(-1, 1);
return arr.join(Path.sep);
},
ensureLeadSlash(p, slash) {
if (slash === undefined) slash = '/';
return /^[\\/]/.test(p) === false ? `${slash}${p}` : p;
},
ensureEndSlash(p, slash) {
if (slash === undefined) slash = '/';
return /[\\/]$/.test(p) === false ? `${p}${slash}` : p;
},
removeEndSlash(p) {
return p.replace(/[\\/]$/, '');
},
removeLeadSlash(p) {
return p.replace(/^[\\/]/, '');
},
parentOfBase(p, base) {
p = Path.normalize(path.removeEndSlash(p));
if (!base) return p;
base = Path.normalize(path.removeEndSlash(base));
return p.slice(-base.length) === base
? p.slice(0, -base.length)
: p;
},
hasValidName(p, windows) {
const re = windows ? INVALID.WINDOWS : INVALID.UNIX;
return p
&& p.trim() !== ''
&& !(/(^[. ]|[. ]$)/).test(p) // cannot start or end with dot or space
&& !re.CHARS.test(p)
&& !re.NAME.test(p)
&& p.length <= re.MAX_LEN;
}
};
// ---------------------------
// fs
// ---------------------------
const _fs = {
// currently, this method is not used.
// mergeFiles(fileList) {
// const merged = [];
// // using Promise.each instead of Promise.map, to ensure order guarantee
// // in sequential execution.
// return Promise
// .each(fileList, filePath => {
// return fs.readFile(filePath, 'utf8')
// .then(content => {
// merged.push(content);
// });
// })
// .then(() => { // (fileList)
// return merged.join('\n');
// });
// },
isEmptyDir(dirPath) {
return Promise.resolve(fs.readdir(dirPath)) // wrap with Bluebird
.then(files => !files.length);
}
};
// ---------------------------
// json
// ---------------------------
const json = {
read(filePath) {
return fs.readFile(filePath, 'utf8')
.then(string => JSON.parse(stripJsonComments(string)));
},
readSync(filePath) {
const string = fs.readFileSync(filePath, 'utf8');
return JSON.parse(stripJsonComments(string));
},
write(object, filePath) {
const json = JSON.stringify(object, null, ' ');
return fs.writeFile(filePath, json, 'utf8');
},
removeComments(string) {
return stripJsonComments(string);
}
};
// ---------------------------
// script / js
// ---------------------------
// https://github.com/mishoo/UglifyJS2
const defaultMinifyOpts = {
compress: {},
output: {
// only output the commets with a @license directive
comments: /@license/g
}
};
const js = {
minify(strScript, options = {}) {
const opts = _.extend({}, defaultMinifyOpts, options);
const minified = uglify.minify(strScript, opts);
if (minified.error instanceof Error) throw minified.error;
return minified;
// returns { code, map }
},
minifyFile(filePath, options = {}) {
const opts = _.extend({}, defaultMinifyOpts, options);
return fs.readFile(filePath, 'utf8')
.then(strScript => js.minify(strScript, opts)); // returns { code, map }
},
/**
* Gets the file path for the minified version of the given file.
*
* @param {String} filePath - Path of the original (unminified) file.
*
* @returns {String} - Path of the minified version.
*/
getMinPath(filePath) {
const fPath = filePath.toLowerCase();
if (_.endsWith(fPath, '.min.js')) return fPath;
return Path.join(Path.dirname(fPath), `${Path.basename(fPath, '.js')}.min.js`);
},
/**
* Reads the given JS file into memory.
*
* @param {String} filePath
* Path of the JS file.
* @param {Boolean} [noMinify=false]
* Whether not to minify the JS content.
* @param {Boolean} [checkMinFile=false]
* Whether to first check for an existing minified file (with a
* `min.js` suffix).
*
* @returns {Promise} - Promise that returns JS code content.
*/
readFile(filePath, noMinify = false, checkMinFile = false) {
if (noMinify) return fs.readFile(filePath, 'utf8');
if (!checkMinFile) {
return js.minifyFile(filePath).then(minified => minified.code);
}
const minFilePath = js.getMinPath(filePath);
return fs.pathExists(minFilePath)
.then(exists => {
if (exists) return fs.readFile(minFilePath, 'utf8');
return js.minifyFile(filePath).then(minified => minified.code);
});
}
};
// ---------------------------
// Markdown
// ---------------------------
const md = {
/**
* Parses the given markdown content.
*
* @param {String} string - Markdown content.
* @param {Object} [options] - Markdown options for `marked`.
*
* @returns {Promise} - Promise that returns the markdown, parsed to HTML.
*/
parse(string, options = {}) {
return new Promise((resolve, reject) => {
marked(string, options, (err, content) => {
if (err) return reject(err);
resolve(content);
});
});
}
};
// ---------------------------
// glob
// ---------------------------
const glob = {
// glob uses forward slash only. (in Windows too)
// base is optional.
normalize(base, globPath) {
const gpSet = utils.isset(globPath);
if (!gpSet) {
globPath = base;
base = '';
} else {
base += '/';
}
return Path.normalize(base + globPath).replace(/\\+/g, '/');
},
isNegated(pattern) {
return pattern.slice(0, 1) === '!';
},
inspect(pattern, options = {}) {
const negated = glob.isNegated(pattern);
pattern = negated ? pattern.slice(1) : pattern;
pattern = glob.normalize(pattern);
return {
hasMagic: GLOB.hasMagic(pattern, options),
negated,
pattern
};
},
/**
* Expands the given glob pattern(s) into file-paths array.
* This uses the node-glob library but unlike it, this also accepts an
* Array input and negated globs are allowed.
* @param {String|Array} patterns - One or more patterns to be expanded.
* @param {Object} [options] - Glob options.
* @returns {Array} - Expanded list of file paths.
*/
match(patterns, options = {}) {
patterns = utils.ensureArray(patterns);
// normalize ignored globs (glob paths sep should be `/` even in
// windows.)
options.ignore = (options.ignore || []).map(item => {
return glob.normalize(item);
});
let files = [];
const properPatterns = [];
// We'll pick negated patterns into the ignore list and proper patterns
// into a separate array. Also, we'll add non-glob patters (normal file
// paths) to the final output array; bec. glob() will not process
// non-glob patterns.
_.each(patterns, p => {
const g = glob.inspect(p);
if (g.negated) {
options.ignore.push(g.pattern);
} else if (!g.hasMagic) {
files.push(g.pattern);
} else {
properPatterns.push(g.pattern);
}
});
return Promise.each(properPatterns, p => {
return GLOB(p, options) // eslint-disable-line
.then(expandedFiles => {
files = files.concat(expandedFiles);
});
}).then(() => files);
// .thenReturn(files); // wrong! returns the result of Promise.each
}
};
// ---------------------------
// less
// ---------------------------
// http://lesscss.org/usage/#programmatic-usage
// http://onedayitwillmake.com/blog/2013/03/compiling-less-from-a-node-js-script/
// http://lesscss.org/#using-less-command-line-usage
const defaultLessOpts = {
// root .less file name (not the path).
// filename: 'styles.less',
// optimization level, higher is better but more volatile - 1 is a
// good value.
optimization: 1,
// minify output css
compress: true,
// use YUI compressor?
yuicompress: true,
// additional paths to look for imported less files.
paths: [],
// less plugins
plugins: []
};
const less = {
updateOptions(options = {}, filePath = null) {
const opts = _.defaults(options, defaultLessOpts);
// also pass lessCleanCssPlugin if compress enabled.
if (options.compress) options.plugins.push(lessCleanCssPlugin);
if (filePath) {
const parentDir = Path.resolve(Path.dirname(filePath));
// http://lesscss.org/usage/#programmatic-usage
opts.filename = Path.basename(filePath); // less file name not the path
opts.paths = (opts.paths || []).concat([parentDir]);
}
return opts;
},
compile(strLess, options) {
// convert promise to Bluebird promise.
return Promise.resolve(Less.render(strLess, options));
// resolves { css, map, imports }
},
// filePath: root .less file path.
compileFile(filePath, options) {
return fs.readFile(filePath, 'utf8')
.then(strLess => {
return less.compile(strLess, options);
});
// resolves { css, map, imports }
}
};
// ---------------------------
// utils export
// ---------------------------
utils = {
isset(value) {
return value !== undefined && value !== null;
},
type(obj) {
return Object.prototype.toString.call(obj).match(/\s(\w+)/i)[1].toLowerCase();
},
// e.g.
// let symbol = { code: { meta: { type: "MethodDefinition" } } };
// notate(symbol, "code.meta.type") => "MethodDefinition"
// See https://github.com/onury/notation for an advanced library.
notate(obj, notation) {
if (typeof obj !== 'object') return;
// console.log('n', notation);
const props = !Array.isArray(notation)
? notation.split('.')
: notation;
const prop = props[0];
if (!prop) return;
const o = obj[prop];
if (props.length > 1) {
props.shift();
return utils.notate(o, props);
}
return o;
},
ensureArray(o) {
if (o === undefined || o === null) return o;
return Array.isArray(o) ? o : [o];
},
round(num, decimals = 0) {
if (!decimals) return Math.round(num);
const factor = decimals * 10;
return Math.round(num * factor) / factor;
},
// in KB
getContentSize(str, gzipped = false) {
const bytes = gzipped
? gzipSize.sync(str)
: Buffer.byteLength(str, 'utf8');
return utils.round(bytes / 1024, 1);
},
safeRequire(name) {
try {
return require(name);
} catch (err) {
if (err.code === 'MODULE_NOT_FOUND' && err.message.indexOf(name) !== -1) {
return undefined;
}
throw err;
}
},
removeNewLines(string, replaceWith = '') {
return (string || '').replace(/[\r\n]+/, replaceWith);
},
/**
* Gets a safe string ID value from the given string.
*
* @param {String} [str] - String to be converted.
*
* @returns {String} -
*/
idify(str) {
return str.trim()
.replace(/[.,;'"`]/g, '')
.replace(/[^a-z0-9$_]/gi, '-')
.toLowerCase();
},
/**
* Used by CLI. Attempts to find a Docma config file with one of the
* default names.
* @private
*
* @param {String} [basePath=process.cwd()] - Base directory path to search
* in.
*
* @returns {String} - `null` if not found.
*/
_findDocmaConfigFileSync(basePath = null) {
return getFileSync('docma.json', basePath)
|| getFileSync('.docma.json', basePath)
|| getFileSync('.docma', basePath);
},
path,
fs: _fs,
js,
json,
md,
glob,
less
};
module.exports = utils;