grunt-init
Version:
Generate project scaffolding from a template.
494 lines (469 loc) • 19.3 kB
JavaScript
/*
* grunt-init
* https://gruntjs.com/
*
* Copyright (c) 2014 "Cowboy" Ben Alman, contributors
* Licensed under the MIT license.
*/
;
module.exports = function(grunt) {
// Nodejs libs.
var path = require('path');
// External libs.
var semver = require('semver');
var _ = require('lodash');
// Internal libs.
var git = require('./lib/git').init(grunt);
var helpers = require('./lib/helpers').init(grunt);
var prompt = require('./lib/prompt').init(grunt, helpers);
// The "init" task needs separate delimiters to avoid conflicts, so the <>
// are replaced with {}. Otherwise, they behave the same.
grunt.template.addDelimiters('init', '{%', '%}');
// ==========================================================================
// TASKS
// ==========================================================================
grunt.registerInitTask('init', 'Generate project scaffolding from a template.', function() {
// Extra arguments will be applied to the template file.
var args = grunt.util.toArray(arguments);
// Initialize searchDirs so template assets can be found.
var name = helpers.initSearchDirs(args.shift());
// Valid init templates (.js or .coffee files).
var templates = helpers.getTemplates();
var initTemplate = templates[name];
// Abort if a valid template was not specified.
if (!initTemplate) {
if (name) {
grunt.log.write('Loading "' + name + '" init template...').error();
}
grunt.log.writeln('\nA valid init template name must be specified.');
grunt.help.initTemplates();
grunt.help.initWidths();
grunt.help.templates();
grunt.help.footer();
if (name) {
grunt.log.writeln();
grunt.fatal('A valid init template name must be specified.');
} else {
process.exit();
}
}
// Give the user a little help.
grunt.log.writelns(
'This task will create one or more files in the current directory, ' +
'based on the environment and the answers to a few questions. ' +
'Note that answering "?" to any question will show question-specific ' +
'help and answering "none" to most questions will leave its value blank.'
);
// Abort if matching files or directories were found (to avoid accidentally
// nuking them).
if (initTemplate.warnOn && grunt.file.expand(initTemplate.warnOn).length > 0) {
grunt.log.writeln();
grunt.warn('Existing files may be overwritten!');
}
// Built-in prompt options.
// These generally follow the node "prompt" module convention, except:
// * The "default" value can be a function which is executed at run-time.
// * An optional "sanitize" function has been added to post-process data.
_.extend(prompt.prompts, {
name: {
message: 'Project name',
default: function(value, data, done) {
var types = ['javascript', 'js'];
if (data.type) { types.push(data.type); }
var type = '(?:' + types.join('|') + ')';
// This regexp matches:
// leading type- type. type_
// trailing -type .type _type and/or -js .js _js
var re = new RegExp('^' + type + '[\\-\\._]?|(?:[\\-\\._]?' + type + ')?(?:[\\-\\._]?js)?$', 'ig');
// Strip the above stuff from the current dirname.
var name = path.basename(process.cwd()).replace(re, '');
// Remove anything not a letter, number, dash, dot or underscore.
name = name.replace(/[^\w\-\.]/g, '');
done(null, name);
},
validator: /^[\w\-\.]+$/,
warning: 'Must be only letters, numbers, dashes, dots or underscores.',
sanitize: function(value, data, done) {
// An additional value, safe to use as a JavaScript identifier.
data.js_safe_name = value.replace(/[\W_]+/g, '_').replace(/^(\d)/, '_$1');
// An additional value that won't conflict with NodeUnit unit tests.
data.js_test_safe_name = data.js_safe_name === 'test' ? 'myTest' : data.js_safe_name;
// If no value is passed to `done`, the original property isn't modified.
done();
}
},
title: {
message: 'Project title',
default: function(value, data, done) {
var title = data.name || '';
title = title.replace(/[\W_]+/g, ' ');
title = title.replace(/\w+/g, function(word) {
return word[0].toUpperCase() + word.slice(1).toLowerCase();
});
done(null, title);
},
warning: 'May consist of any characters.'
},
description: {
message: 'Description',
default: 'The best project ever.',
warning: 'May consist of any characters.'
},
version: {
message: 'Version',
default: function(value, data, done) {
// Get a valid semver tag from `git describe --tags` if possible.
grunt.util.spawn({
cmd: 'git',
args: ['describe', '--tags'],
fallback: ''
}, function(err, result) {
result = String(result).split('-')[0];
done(null, semver.valid(result) || '0.1.0');
});
},
validator: semver.valid,
warning: 'Must be a valid semantic version (semver.org).'
},
repository: {
message: 'Project git repository',
default: function(value, data, done) {
// Change any git@...:... uri to git://.../... format.
git.origin(function(err, result) {
if (err) {
// Attempt to pull the data from the user's git config.
git.config('github.user', function (err, user) {
if (err) {
// Attempt to guess at the repo user name. Maybe we'll get lucky!
user = process.env.USER || process.env.USERNAME || '???';
}
// Save as git_user for sanitize step.
data.git_user = user;
result = 'git://github.com/' + user + '/' +
path.basename(process.cwd()) + '.git';
done(null, result);
});
} else {
result = result.replace(/^git@([^:]+):/, 'git://$1/');
done(null, result);
}
});
},
sanitize: function(value, data, done) {
// An additional computed "git_user" property.
var repo = git.githubUrl(data.repository);
var parts;
if (repo != null) {
parts = repo.split('/');
data.git_user = data.git_user || parts[parts.length - 2];
data.git_repo = parts[parts.length - 1];
done();
} else {
data.git_user = data.git_user || '';
data.git_repo = path.basename(process.cwd());
done();
}
},
warning: 'Should be a public git:// URI.'
},
homepage: {
message: 'Project homepage',
// If GitHub is the origin, the (potential) homepage is easy to figure out.
default: function(value, data, done) {
done(null, git.githubUrl(data.repository) || 'none');
},
warning: 'Should be a public URL.'
},
bugs: {
message: 'Project issues tracker',
// If GitHub is the origin, the issues tracker is easy to figure out.
default: function(value, data, done) {
done(null, git.githubUrl(data.repository, 'issues') || 'none');
},
warning: 'Should be a public URL.'
},
licenses: {
message: 'Licenses',
default: 'MIT',
validator: /^[\w\-\.\d]+(?:\s+[\w\-\.\d]+)*$/,
warning: 'Must be zero or more space-separated licenses. Built-in ' +
'licenses are: ' + helpers.availableLicenses().join(' ') + ', but you may ' +
'specify any number of custom licenses.',
// Split the string on spaces.
sanitize: function(value, data, done) { done(value.split(/\s+/)); }
},
author_name: {
message: 'Author name',
default: function(value, data, done) {
// Attempt to pull the data from the user's git config.
grunt.util.spawn({
cmd: 'git',
args: ['config', '--get', 'user.name'],
fallback: 'none'
}, done);
},
warning: 'May consist of any characters.'
},
author_email: {
message: 'Author email',
default: function(value, data, done) {
// Attempt to pull the data from the user's git config.
grunt.util.spawn({
cmd: 'git',
args: ['config', '--get', 'user.email'],
fallback: 'none'
}, done);
},
warning: 'Should be a valid email address.'
},
author_url: {
message: 'Author url',
default: 'none',
warning: 'Should be a public URL.'
},
jquery_version: {
message: 'Required jQuery version',
default: '*',
warning: 'Must be a valid semantic version range descriptor.'
},
node_version: {
message: 'What versions of node does it run on?',
// TODO: pull from grunt's package.json
default: '>= 0.10.0',
warning: 'Must be a valid semantic version range descriptor.'
},
main: {
message: 'Main module/entry point',
default: function(value, data, done) {
done(null, 'lib/' + data.name);
},
warning: 'Must be a path relative to the project root.'
},
bin: {
message: 'CLI script',
default: function(value, data, done) {
done(null, 'bin/' + data.name);
},
warning: 'Must be a path relative to the project root.'
},
npm_test: {
message: 'Npm test command',
default: 'grunt',
warning: 'Must be an executable command.'
},
grunt_version: {
message: 'What versions of grunt does it require?',
default: '~' + grunt.version,
warning: 'Must be a valid semantic version range descriptor.'
}
});
// This task is asynchronous.
var taskDone = this.async();
var pathPrefix = name + '/root/';
// Useful init sub-task-specific utilities.
var init = _.extend(helpers, {
// Expose prompt interface on init object.
process: prompt.process,
prompt: prompt.prompt,
prompts: prompt.prompts,
// Expose any user-specified default init values.
defaults: helpers.readDefaults('defaults.json'),
// Expose rename rules for this template.
renames: helpers.readDefaults(name, 'rename.json'),
// Return an object containing files to copy with their absolute source path
// and relative destination path, renamed (or omitted) according to rules in
// rename.json (if it exists).
filesToCopy: function(props) {
var files = {};
// Include all template files by default.
helpers.expand({filter: 'isFile', dot: true}, [pathPrefix + '**']).forEach(function(obj) {
// Get the source filepath relative to the template root.
var src = obj.rel.slice(pathPrefix.length);
// Get the destination filepath.
var dest = init.renames[src];
// Create a property for this file, but use src if dest evaulates
// to false
var processed = (dest) ? grunt.template.process(dest, {data: props, delimiters: 'init'}) : false;
processed = (processed === 'false' || processed === '') ? false : processed;
files[(processed) ? processed : src] = obj.rel;
});
// Exclude files with a value of false in rename.json.
var exclusions = Object.keys(init.renames).filter(function(key) {
var processed = (init.renames[key]) ?
grunt.template.process(init.renames[key], {data: props, delimiters: 'init'}) : false;
processed = (processed === 'false' || processed === '') ? false : processed;
return (processed === false);
}).map(function(key) {
return pathPrefix + key;
});
// Exclude all exclusion files by deleting them from the files object.
if (exclusions.length > 0) {
helpers.expand({filter: 'isFile', dot: true}, exclusions).forEach(function(obj) {
// Get the source filepath relative to the template root.
var src = obj.rel.slice(pathPrefix.length);
// And remove that file from the files list.
delete files[src];
});
}
return files;
},
// Search init template paths for filename.
srcpath: function(arg1) {
if (arg1 == null) { return null; }
var args = [name, 'root'].concat(grunt.util.toArray(arguments));
return helpers.getFile.apply(helpers, args);
},
// Determine absolute destination file path.
destpath: path.join.bind(path, process.cwd()),
// Given some number of licenses, add properly-named license files to the
// files object.
addLicenseFiles: function(files, licenses) {
licenses.forEach(function(license) {
var fileobj = helpers.expand({filter: 'isFile'}, 'licenses/LICENSE-' + license)[0];
if(fileobj) {
files['LICENSE-' + license] = fileobj.rel;
}
});
},
// Given an absolute or relative source path, and an optional relative
// destination path, copy a file, optionally processing it through the
// passed callback.
copy: function(srcpath, destpath, options) {
// Destpath is optional.
if (typeof destpath !== 'string') {
options = destpath;
destpath = srcpath;
}
// Ensure srcpath is absolute.
if (!grunt.file.isPathAbsolute(srcpath)) {
srcpath = init.srcpath(srcpath);
}
// Use placeholder file if no src exists.
if (!srcpath) {
srcpath = helpers.getFile('misc/placeholder');
}
grunt.verbose.or.write('Writing ' + destpath + '...');
try {
grunt.file.copy(srcpath, init.destpath(destpath), options);
grunt.verbose.or.ok();
} catch(e) {
grunt.verbose.or.error().error(e);
throw e;
}
},
// Iterate over all files in the passed object, copying the source file to
// the destination, processing the contents.
copyAndProcess: function(files, props, options) {
options = _.defaults(options || {}, {
process: function(contents) {
return grunt.template.process(contents, {data: props, delimiters: 'init'});
}
});
Object.keys(files).forEach(function(destpath) {
var o = Object.create(options);
var srcpath = files[destpath];
// If srcpath is relative, match it against options.noProcess if
// necessary, then make srcpath absolute.
var relpath;
if (srcpath && !grunt.file.isPathAbsolute(srcpath)) {
if (o.noProcess) {
relpath = srcpath.slice(pathPrefix.length);
o.noProcess = grunt.file.isMatch({matchBase: true}, o.noProcess, relpath);
}
srcpath = helpers.getFile(srcpath);
}
// Copy!
init.copy(srcpath, destpath, o);
});
},
// Save a package.json file in the destination directory. The callback
// can be used to post-process properties to add/remove/whatever.
writePackageJSON: function(filename, props, callback) {
var pkg = {};
// Basic values.
['name', 'title', 'description', 'version', 'homepage'].forEach(function(prop) {
if (prop in props) { pkg[prop] = props[prop]; }
});
// Author.
var hasAuthor = Object.keys(props).some(function(prop) {
return (/^author_/).test(prop);
});
if (hasAuthor) {
pkg.author = {};
['name', 'email', 'url'].forEach(function(prop) {
if (props['author_' + prop]) {
pkg.author[prop] = props['author_' + prop];
}
});
}
// Other stuff.
if ('repository' in props) {
// Detect whether repository was given as string or object
if (typeof props.repository === 'string') {
pkg.repository = {type: 'git', url: props.repository};
} else {
pkg.repository = props.repository;
}
}
if ('bugs' in props) { pkg.bugs = {url: props.bugs}; }
if (props.licenses) {
pkg.licenses = props.licenses.map(function(license) {
return {type: license, url: props.homepage + '/blob/master/LICENSE-' + license};
});
}
// Node/npm-specific (?)
if (props.main) { pkg.main = props.main; }
if (props.bin) { pkg.bin = props.bin; }
if (props.engines) { pkg.engines = props.engines; }
else if (props.node_version) { pkg.engines = {node: props.node_version}; }
if (props.scripts) { pkg.scripts = props.scripts; }
if (props.npm_test) {
pkg.scripts = pkg.scripts || {};
pkg.scripts.test = props.npm_test;
if (props.npm_test.split(' ')[0] === 'grunt') {
if (!props.devDependencies) { props.devDependencies = {}; }
if (!props.devDependencies.grunt) {
props.devDependencies.grunt = '~' + grunt.version;
}
}
}
if (props.dependencies) { pkg.dependencies = props.dependencies; }
if (props.devDependencies) { pkg.devDependencies = props.devDependencies; }
if (props.peerDependencies) { pkg.peerDependencies = props.peerDependencies; }
if (props.keywords) { pkg.keywords = props.keywords; }
// Allow final tweaks to the pkg object.
if (callback) { pkg = callback(pkg, props); }
// Write file.
grunt.verbose.or.write('Writing ' + filename + '...');
try {
grunt.file.write(init.destpath(filename), JSON.stringify(pkg, null, 2));
grunt.verbose.or.ok();
} catch(e) {
grunt.verbose.or.error().error(e);
throw e;
}
}
});
// Make args available as flags.
init.flags = {};
args.forEach(function(flag) { init.flags[flag] = true; });
// Show any template-specific notes.
if (initTemplate.notes) {
grunt.log.subhead('"' + name + '" template notes:').writelns(initTemplate.notes);
}
// Execute template code, passing in the init object, done function, and any
// other arguments specified after the init:name:???.
initTemplate.template.apply(this, [grunt, init, function() {
// Fail task if errors were logged.
if (grunt.task.current.errorCount) { taskDone(false); }
// Otherwise, print a success message.
grunt.log.subhead('Initialized from template "' + name + '".');
// Show any template-specific notes.
if (initTemplate.after) {
grunt.log.writelns(initTemplate.after);
}
// All done!
taskDone();
}].concat(args));
});
};