You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 

617 lines
23 KiB

/*
* grunt
* http://gruntjs.com/
*
* Copyright (c) 2012 "Cowboy" Ben Alman
* Licensed under the MIT license.
* https://github.com/gruntjs/grunt/blob/master/LICENSE-MIT
*/
module.exports = function(grunt) {
// Nodejs libs.
var fs = require('fs');
var path = require('path');
// External libs.
var semver = require('semver');
var prompt = require('prompt');
prompt.message = '[' + '?'.green + ']';
prompt.delimiter = ' ';
// ==========================================================================
// TASKS
// ==========================================================================
// An array of all available license files.
function availableLicenses() {
return grunt.task.expandFiles('init/licenses/*').map(function(obj) {
return path.basename(String(obj)).replace(/^LICENSE-/, '');
});
}
grunt.registerInitTask('init', 'Generate project scaffolding from a predefined template.', function() {
// Extra arguments will be applied to the template file.
var args = grunt.utils.toArray(arguments);
// Template name.
var name = args.shift();
// Default to last-specified grunt.npmTasks plugin name if template name
// was omitted. Note that specifying just a : after init like "grunt init:"
// will allow all available templates to be listed.
if (name == null) {
name = grunt._npmTasks[grunt._npmTasks.length - 1];
}
// Valid init templates (.js files).
var templates = {};
grunt.task.expandFiles('init/*.js').forEach(function(fileobj) {
// Add template (plus its path) to the templates object.
templates[path.basename(fileobj.abs, '.js')] = require(fileobj.abs);
});
var initTemplate = templates[name];
// 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 a valid template was not specified.
if (!initTemplate) {
grunt.log.writeln().write('Loading' + (name ? ' "' + name + '"' : '') + ' init template...').error();
grunt.log.errorlns('A valid template name must be specified, eg. "grunt ' +
'init:commonjs". The currently-available init templates are: ');
Object.keys(templates).forEach(function(name) {
var description = templates[name].description || '(no description)';
grunt.log.errorlns(name.cyan + ' - ' + description);
});
return false;
}
// 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!');
}
// This task is asynchronous.
var taskDone = this.async();
var pathPrefix = 'init/' + name + '/root/';
// Useful init sub-task-specific utilities.
var init = {
// Expose any user-specified default init values.
defaults: grunt.task.readDefaults('init/defaults.json'),
// Expose rename rules for this template.
renames: grunt.task.readDefaults('init', 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 = {};
// Iterate over all source files.
grunt.task.expandFiles({dot: true}, pathPrefix + '**').forEach(function(obj) {
// Get the path relative to the template root.
var relpath = obj.rel.slice(pathPrefix.length);
var rule = init.renames[relpath];
// Omit files that have an empty / false rule value.
if (!rule && relpath in init.renames) { return; }
// Create a property for this file.
files[rule ? grunt.template.process(rule, props, 'init') : relpath] = obj.rel;
});
return files;
},
// Search init template paths for filename.
srcpath: function(arg1) {
if (arg1 == null) { return null; }
var args = ['init', name, 'root'].concat(grunt.utils.toArray(arguments));
return grunt.task.getFile.apply(grunt.file, 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) {
var available = availableLicenses();
licenses.forEach(function(license) {
var fileobj = grunt.task.expandFiles('init/licenses/LICENSE-' + license)[0];
files['LICENSE-' + license] = fileobj ? fileobj.rel : null;
});
},
// 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 = grunt.task.getFile('init/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 = grunt.utils._.defaults(options || {}, {
process: function(contents) {
return grunt.template.process(contents, props, '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(o.noProcess, relpath);
}
srcpath = grunt.task.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) { pkg.repository = {type: 'git', url: 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.node_version) { pkg.engines = {node: props.node_version}; }
if (props.npm_test) {
pkg.scripts = {test: props.npm_test};
if (props.npm_test.split(' ')[0] === 'grunt') {
if (!props.devDependencies) { props.devDependencies = {}; }
props.devDependencies.grunt = '~' + grunt.version;
}
}
if (props.dependencies) { pkg.dependencies = props.dependencies; }
if (props.devDependencies) { pkg.devDependencies = props.devDependencies; }
if (props.keywords) { pkg.keywords = props.keywords; }
// Allow final tweaks to the pkg object.
if (callback) { pkg = callback(pkg, props); }
// Write file.
grunt.file.write(init.destpath(filename), JSON.stringify(pkg, null, 2));
}
};
// 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.writeln().writeln('Initialized from template "' + name + '".');
// All done!
taskDone();
}].concat(args));
});
// ==========================================================================
// HELPERS
// ==========================================================================
// Prompt user to override default values passed in obj.
grunt.registerHelper('prompt', function(defaults, options, done) {
// If defaults are omitted, shuffle arguments a bit.
if (grunt.utils.kindOf(defaults) === 'array') {
done = options;
options = defaults;
defaults = {};
}
// Keep track of any "sanitize" functions for later use.
var sanitize = {};
options.forEach(function(option) {
if (option.sanitize) {
sanitize[option.name] = option.sanitize;
}
});
// Add one final "are you sure?" prompt.
if (options.length > 0) {
options.push({
message: 'Do you need to make any changes to the above before continuing?'.green,
name: 'ANSWERS_VALID',
default: 'y/N'
});
}
// Ask user for input. This is in an IIFE because it has to execute at least
// once, and might be repeated.
(function ask() {
grunt.log.subhead('Please answer the following:');
var result = grunt.utils._.clone(defaults);
// Loop over each prompt option.
grunt.utils.async.forEachSeries(options, function(option, done) {
var defaultValue;
grunt.utils.async.forEachSeries(['default', 'altDefault'], function(prop, next) {
if (typeof option[prop] === 'function') {
// If the value is a function, execute that function, using the
// value passed into the return callback as the new default value.
option[prop](defaultValue, result, function(err, value) {
defaultValue = String(value);
next();
});
} else {
// Otherwise, if the value actually exists, use it.
if (prop in option) {
defaultValue = option[prop];
}
next();
}
}, function() {
// Handle errors (there should never be errors).
option.default = defaultValue;
delete option.altDefault;
// Wrap validator so that answering '?' always fails.
var validator = option.validator;
option.validator = function(line, next) {
if (line === '?') {
return next(false);
} else if (validator) {
if (validator.test) {
return next(validator.test(line));
} else if (typeof validator === 'function') {
return validator.length < 2 ? next(validator(line)) : validator(line, next);
}
}
next(true);
};
// Actually get user input.
prompt.start();
prompt.getInput(option, function(err, line) {
if (err) { return done(err); }
option.validator = validator;
result[option.name] = line;
done();
});
});
}, function(err) {
// After all prompt questions have been answered...
if (/n/i.test(result.ANSWERS_VALID)) {
// User accepted all answers. Suspend prompt.
prompt.pause();
// Clean up.
delete result.ANSWERS_VALID;
// Iterate over all results.
grunt.utils.async.forEachSeries(Object.keys(result), function(name, next) {
// If this value needs to be sanitized, process it now.
if (sanitize[name]) {
sanitize[name](result[name], result, function(err, value) {
if (err) {
result[name] = err;
} else if (arguments.length === 2) {
result[name] = value === 'none' ? '' : value;
}
next();
});
} else {
if (result[name] === 'none') { result[name] = ''; }
next();
}
}, function(err) {
// Done!
grunt.log.writeln();
done(err, result);
});
} else {
// Otherwise update the default value for each user prompt option...
options.slice(0, -1).forEach(function(option) {
option.default = result[option.name];
});
// ...and start over again.
ask();
}
});
}());
});
// Built-in prompt options for the prompt_for helper.
// 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.
var 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');
// 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.utils.spawn({
cmd: 'git',
args: ['describe', '--tags'],
fallback: ''
}, function(err, result, code) {
result = 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.
grunt.helper('git_origin', function(err, result) {
if (err) {
// Attempt to guess at the repo name. Maybe we'll get lucky!
result = 'git://github.com/' + (process.env.USER || process.env.USERNAME || '???') + '/' +
data.name + '.git';
} else {
result = result.replace(/^git@([^:]+):/, 'git://$1/');
}
done(null, result);
});
},
sanitize: function(value, data, done) {
// An additional computed "git_user" property.
var repo = grunt.helper('github_web_url', data.repository);
var parts;
if (repo != null) {
parts = repo.split('/');
data.git_user = parts[parts.length - 2];
data.git_repo = parts[parts.length - 1];
done();
} else {
// Attempt to pull the data from the user's git config.
grunt.utils.spawn({
cmd: 'git',
args: ['config', '--get', 'github.user'],
fallback: ''
}, function(err, result, code) {
data.git_user = result || process.env.USER || process.env.USERNAME || '???';
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, grunt.helper('github_web_url', 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, grunt.helper('github_web_url', data.repository, 'issues') || 'none');
},
warning: 'Should be a public URL.'
},
licenses: {
message: 'Licenses',
default: 'MIT',
validator: /^[\w\-]+(?:\s+[\w\-]+)*$/,
warning: 'Must be zero or more space-separated licenses. Built-in ' +
'licenses are: ' + 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.utils.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.utils.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.6.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 test',
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.'
}
};
// Expose prompts object so that prompt_for prompts can be added or modified.
grunt.registerHelper('prompt_for_obj', function() {
return prompts;
});
// Commonly-used prompt options with meaningful default values.
grunt.registerHelper('prompt_for', function(name, altDefault) {
// Clone the option so the original options object doesn't get modified.
var option = grunt.utils._.clone(prompts[name]);
option.name = name;
var defaults = grunt.task.readDefaults('init/defaults.json');
if (name in defaults) {
// A user default was specified for this option, so use its value.
option.default = defaults[name];
} else if (arguments.length === 2) {
// An alternate default was specified, so use it.
option.altDefault = altDefault;
}
return option;
});
// Get the git origin url from the current repo (if possible).
grunt.registerHelper('git_origin', function(done) {
grunt.utils.spawn({
cmd: 'git',
args: ['remote', '-v']
}, function(err, result, code) {
var re = /^origin\s/;
var lines;
if (!err) {
lines = result.split('\n').filter(re.test, re);
if (lines.length > 0) {
done(null, lines[0].split(/\s/)[1]);
return;
}
}
done(true, 'none');
});
});
// Generate a GitHub web URL from a GitHub repo URI.
var githubWebUrlRe = /^.+(?:@|:\/\/)(github.com)[:\/](.+?)(?:\.git|\/)?$/;
grunt.registerHelper('github_web_url', function(uri, suffix) {
var matches = githubWebUrlRe.exec(uri);
if (!matches) { return null; }
var url = 'https://' + matches[1] + '/' + matches[2];
if (suffix) {
url += '/' + suffix.replace(/^\//, '');
}
return url;
});
};