ncat
Version:
Node CLI to concatenate multiple files, with their sourcemaps and optionally stdin, a banner and a footer
340 lines (317 loc) • 10.6 kB
JavaScript
const {EOL} = require('os');
const path = require('path');
const fs = require('fs-extra');
const pify = require('pify');
const Concat = require('concat-with-sourcemaps');
const unixify = require('unixify');
const getStdin = require('get-stdin');
const sourceMappingURL = require('source-map-url');
const sourceMapResolve = require('source-map-resolve');
const readPkg = require('read-pkg-up');
const globby = require('globby');
const yargonaut = require('yargonaut');
const yargs = require('yargs');
yargonaut.helpStyle('bold.green').errorsStyle('red');
const chalk = yargonaut.chalk();
/**
* Produce the default banner based on package.json info.
*
* @param {Object} packageJson the parsed package.json.
* @returns {String} the default banner.
*/
const getDefaultBanner = packageJson =>
`/*!
* ${
packageJson.name ? packageJson.name.charAt(0).toUpperCase() + packageJson.name.slice(1) : 'unknown'
} v${packageJson.version || '0.0.0'}${
packageJson.homepage || packageJson.name
? `${EOL} * ${packageJson.homepage || `https://npm.com/${packageJson.name}`}`
: ''
}
*
* Copyright (c) ${new Date().getFullYear()}${
packageJson.author && packageJson.author.name ? ` ${packageJson.author.name}` : ''
}
*${packageJson.license ? ` Licensed under the ${packageJson.license} license${EOL} *` : ''}/${EOL}`;
/**
* Log messages.
*
* @type {Object}
*/
const LOG = {
add: args => `Concat file ${chalk.cyan(args[0])}`,
write: args => `Write ${chalk.bold.green(args[0])}`,
map: args => `Concat sourcemap ${chalk.cyan(args[0])}`,
mapInline: args => `Concat inline sourcemap from ${chalk.cyan(args[0])}`,
footer: args => `Concat footer from ${chalk.cyan(args[0])}`,
banner: args => `Concat banner from ${chalk.cyan(args[0])}`,
dbanner: args => `Concat default banner for ${chalk.cyan(args[0])}`,
};
const {argv} = yargs
.usage(
`${chalk.bold.green('Usage:')}
ncat [<FILES ...>] [OPTIONS] [-o|--output <OUTPUT_FILE>]`
)
.option('o', {
alias: 'output',
desc: 'Output file',
type: 'string',
})
.option('m', {
alias: 'map',
desc: 'Create an external sourcemap (including the sourcemaps of existing files)',
type: 'boolean',
})
.option('e', {
alias: 'map-embed',
desc: 'Embed the code in the sourcemap (only apply to code without an existing sourcemap)',
type: 'boolean',
})
.option('b', {
alias: 'banner',
desc: `Add a banner built with the package.json file. Optionally pass the path
to a .js file containing custom banner that can be called with 'require()'`,
type: 'string',
})
.option('f', {
alias: 'footer',
desc: "The path to .js file containing custom footer that can be called with 'require()'",
type: 'string',
})
.epilog(
chalk.yellow(
`If a file is a single dash ('-'), it reads from stdin.
If -o is not passed, the sourcemap is disabled and it writes to stdout.`
)
)
.example('ncat file_1.js file_2.js -o dist/bundle.js', 'Basic usage')
.example('ncat file_1.js file_2.js -m -o dist/bundle.js', 'Basic usage with sourcemap')
.example('cat file_1.js | ncat - input_2.js > bundle.js', 'Piping input & output')
.example('ncat file_1.js -b -o dist/bundle.js', 'Add the default banner')
.example('ncat file_1.js -b ./banner.js -o dist/bundle.js', 'Add a custom banner')
.example('ncat file_1.js -f ./footer.js -o dist/bundle.js', 'Add a footer')
.help('h')
.alias('h', 'help')
.version()
.alias('v', 'version');
/**
* Concat object to wich will be added banner, footer and files and their sourcemaps.
* Will produce the final output.
*
* @type {Concat}
*/
const concat = new Concat(
argv.output !== undefined && argv.output !== null && argv.map,
argv.output ? path.basename(argv.output) : '',
EOL
);
/**
* Cache the content of stdin the first it's retrieve.
* Allow to concatenate the content of stdin multiple times.
*
* @type {String}
*/
const stdinCache = getStdin.buffer();
/**
* Main function of the CLI. Concat banner, then files, then footer and finnaly output concatenated file.
*
* @method main
* @return {Promise} Promise that resolve when the output file is written.
*/
module.exports = async () => {
await concatBanner();
await concatFiles();
concatFooter();
await output();
};
/**
* Concatenate a default or custom banner.
*
* @return {Promise<Any>} Promise that resolve once the banner has been generated and concatenated.
*/
async function concatBanner() {
if (typeof argv.banner !== 'undefined') {
if (argv.banner) {
concat.add(null, require(path.join(process.cwd(), argv.banner)));
return log('banner', argv.banner);
}
const pkg = await readPkg();
concat.add(null, getDefaultBanner(pkg.packageJson));
return log('dbanner', pkg.path);
}
}
/**
* Concatenate a custom banner.
*
* @return {Promise<Any>} Promise that resolve once the footer has been generated and concatenated.
*/
function concatFooter() {
if (argv.footer) {
concat.add(null, require(path.join(process.cwd(), argv.footer)));
return log('footer', `Concat footer from ${argv.footer}`);
}
}
/**
* Concatenate the files in order.
* Exit process with error if there is less than two files, banner or footer to concatenate.
*
* @return {Promise<Any>} Promise that resolve once the files have been read/created and concatenated.
*/
async function concatFiles() {
const globs = await Promise.all(argv._.map(handleGlob));
const files = globs.reduce((acc, cur) => acc.concat(cur), []);
if (
(files.length < 2 && typeof argv.banner === 'undefined' && !argv.footer) ||
(files.length === 0 && (typeof argv.banner === 'undefined' || !argv.footer))
) {
throw new Error(
chalk.bold.red(`Require at least 2 file, banner or footer to concatenate. ("ncat --help" for help)${EOL}`)
);
}
return files.forEach(file => {
concat.add(file.file, file.content, file.map);
});
}
/**
* FileToConcat describe the filename, content and sourcemap to concatenate.
*
* @typedef {Object} FileToConcat
* @property {String} file
* @property {String} content
* @property {Object} sourcemap
*/
/**
* Retrieve files matched by the gloc and call {@link handleFile} for each one found.
* If the glob is '-' return one FileToConcat with stdin as its content.
*
* @param {String} glob the glob expression for which to retrive files.
* @return {Promise<FileToConcat[]>} a Promise that resolve to an Array of FileToConcat.
*/
async function handleGlob(glob) {
if (glob === '-') {
return [{content: await stdinCache}];
}
const files = await globby(glob.split(' '), {nodir: true});
return Promise.all(files.map(handleFile));
}
/**
* Update all the sources path to be relative to the file that will be written (parameter --output).
*
* @param {String} file path of the file being processed.
* @param {Object} map existing sourcemap associated with the file.
*/
function prepareMap(file, map) {
map.map.sources.forEach((source, i) => {
map.map.sources[i] = unixify(path.relative(path.dirname(argv.output), path.join(path.dirname(file), source)));
});
}
/**
* Read a file to concatenate then, if the file content reference a sourcemap, read the sourcemap and
* returns a FileToConcat with the retrieve filename, content and sourcemap.
* In addition:
* - If the file content reference a sourcemap, but it cannot be read, the sourcemap is ignore
* and a warning message is displayed.
* - The sourceMap URL are removed from the file content.
* - If a sourcemap exists, {@link prepareMap} is called to update the sources path.
* - If no sourcemap exists, a new one is created (if map parameter is set)
* and the file content is added to its sourceContent attribute if the map-embed parameter is set.
*
* @param {String} file path of the file to concat.
* @return {Promise<FileToConcat>} A Promise that resolve to a FileToConcat with filename, content and sourcemap to concatenate.
*/
async function handleFile(file) {
if (argv.map && argv.output) {
const content = await fs.readFile(file);
try {
const map = await pify(sourceMapResolve.resolveSourceMap)(content.toString(), file, fs.readFile);
if (map) {
prepareMap(file, map);
log('add', file);
if (map.url) {
log('map', map.url);
} else {
log('mapInline', file);
}
return {
file: path.relative(path.dirname(argv.output), file),
content: removeMapURL(content),
map: map.map,
};
}
} catch (error) {
console.log(
chalk.bold.yellow(`The sourcemap ${error.path} referenced in ${file} cannot be read and will be ignored`)
);
}
log('add', file);
return {
file: path.relative(path.dirname(argv.output), file),
content: removeMapURL(content),
map: argv['map-embed'] ? {sourcesContent: [removeMapURL(content)]} : undefined,
};
}
const content = await fs.readFile(file);
log('add', file);
return {
file,
content: removeMapURL(content),
};
}
/**
* If --output is set, write the concatenated file to disk.
* If --map is also is set, write the concatenated sourcemap file to disk.
* If --output is not set, output concatenated to stdout.
*
* @return {Promise<Any>} Promise that resolves when the file(s) are written.
*/
function output() {
if (argv.output) {
return Promise.all([
(async () => {
await fs.outputFile(
argv.output,
argv.map ? Buffer.concat([concat.content, Buffer.from(getSourceMappingURL())]) : concat.content
);
log('write', argv.output);
})(),
(async () => {
if (argv.map) {
await fs.outputFile(`${argv.output}.map`, concat.sourceMap);
log('write', `${argv.output}.map`);
}
})(),
]);
}
process.stdout.write(concat.content);
}
/**
* Return a source mapping URL comment based on the output file extension.
*
* @return {String} the sourceMappingURL comment.
*/
function getSourceMappingURL() {
if (path.extname(argv.output) === '.css') {
return `${EOL}/*# sourceMappingURL=${path.basename(argv.output)}.map */`;
}
return `${EOL}//# sourceMappingURL=${path.basename(argv.output)}.map`;
}
/**
* Removes the sourceMappingURL comment in code and eventual double new line character.
*
* @param {Buffer} code the code to modify.
* @return {String} the modified code.
*/
function removeMapURL(code) {
return sourceMappingURL.removeFrom(code.toString()).replace(new RegExp(`${EOL}${EOL}$`), EOL);
}
/**
* Log to the console, only if --output is set.
*
* @param {String} type Type of log (add, write, map, footer, banner, dbanner).
* @param {...String} msg Value to interpolate.
*/
function log(type, ...rest) {
if (argv.output) {
console.log(LOG[type](rest));
}
}