UNPKG

23.2 kBJavaScriptView Raw
1/*
2 * grunt
3 * http://gruntjs.com/
4 *
5 * Copyright (c) 2012 "Cowboy" Ben Alman
6 * Licensed under the MIT license.
7 * https://github.com/gruntjs/grunt/blob/master/LICENSE-MIT
8 */
9
10module.exports = function(grunt) {
11
12 // Nodejs libs.
13 var fs = require('fs');
14 var path = require('path');
15
16 // External libs.
17 var semver = require('semver');
18
19 var prompt = require('prompt');
20 prompt.message = '[' + '?'.green + ']';
21 prompt.delimiter = ' ';
22
23 // ==========================================================================
24 // TASKS
25 // ==========================================================================
26
27 // An array of all available license files.
28 function availableLicenses() {
29 return grunt.task.expandFiles('init/licenses/*').map(function(obj) {
30 return path.basename(String(obj)).replace(/^LICENSE-/, '');
31 });
32 }
33
34 grunt.registerInitTask('init', 'Generate project scaffolding from a predefined template.', function() {
35 // Extra arguments will be applied to the template file.
36 var args = grunt.utils.toArray(arguments);
37 // Template name.
38 var name = args.shift();
39 // Default to last-specified grunt.npmTasks plugin name if template name
40 // was omitted. Note that specifying just a : after init like "grunt init:"
41 // will allow all available templates to be listed.
42 if (name == null) {
43 name = grunt._npmTasks[grunt._npmTasks.length - 1];
44 }
45 // Valid init templates (.js files).
46 var templates = {};
47 grunt.task.expandFiles('init/*.js').forEach(function(fileobj) {
48 // Add template (plus its path) to the templates object.
49 templates[path.basename(fileobj.abs, '.js')] = require(fileobj.abs);
50 });
51 var initTemplate = templates[name];
52
53 // Give the user a little help.
54 grunt.log.writelns(
55 'This task will create one or more files in the current directory, ' +
56 'based on the environment and the answers to a few questions. ' +
57 'Note that answering "?" to any question will show question-specific ' +
58 'help and answering "none" to most questions will leave its value blank.'
59 );
60
61 // Abort if a valid template was not specified.
62 if (!initTemplate) {
63 grunt.log.writeln().write('Loading' + (name ? ' "' + name + '"' : '') + ' init template...').error();
64 grunt.log.errorlns('A valid template name must be specified, eg. "grunt ' +
65 'init:commonjs". The currently-available init templates are: ');
66 Object.keys(templates).forEach(function(name) {
67 var description = templates[name].description || '(no description)';
68 grunt.log.errorlns(name.cyan + ' - ' + description);
69 });
70 return false;
71 }
72
73 // Abort if matching files or directories were found (to avoid accidentally
74 // nuking them).
75 if (initTemplate.warnOn && grunt.file.expand(initTemplate.warnOn).length > 0) {
76 grunt.log.writeln();
77 grunt.warn('Existing files may be overwritten!');
78 }
79
80 // This task is asynchronous.
81 var taskDone = this.async();
82
83 var pathPrefix = 'init/' + name + '/root/';
84
85 // Useful init sub-task-specific utilities.
86 var init = {
87 // Expose any user-specified default init values.
88 defaults: grunt.task.readDefaults('init/defaults.json'),
89 // Expose rename rules for this template.
90 renames: grunt.task.readDefaults('init', name, 'rename.json'),
91 // Return an object containing files to copy with their absolute source path
92 // and relative destination path, renamed (or omitted) according to rules in
93 // rename.json (if it exists).
94 filesToCopy: function(props) {
95 var files = {};
96 // Iterate over all source files.
97 grunt.task.expandFiles({dot: true}, pathPrefix + '**').forEach(function(obj) {
98 // Get the path relative to the template root.
99 var relpath = obj.rel.slice(pathPrefix.length);
100 var rule = init.renames[relpath];
101 // Omit files that have an empty / false rule value.
102 if (!rule && relpath in init.renames) { return; }
103 // Create a property for this file.
104 files[rule ? grunt.template.process(rule, props, 'init') : relpath] = obj.rel;
105 });
106 return files;
107 },
108 // Search init template paths for filename.
109 srcpath: function(arg1) {
110 if (arg1 == null) { return null; }
111 var args = ['init', name, 'root'].concat(grunt.utils.toArray(arguments));
112 return grunt.task.getFile.apply(grunt.file, args);
113 },
114 // Determine absolute destination file path.
115 destpath: path.join.bind(path, process.cwd()),
116 // Given some number of licenses, add properly-named license files to the
117 // files object.
118 addLicenseFiles: function(files, licenses) {
119 var available = availableLicenses();
120 licenses.forEach(function(license) {
121 var fileobj = grunt.task.expandFiles('init/licenses/LICENSE-' + license)[0];
122 files['LICENSE-' + license] = fileobj ? fileobj.rel : null;
123 });
124 },
125 // Given an absolute or relative source path, and an optional relative
126 // destination path, copy a file, optionally processing it through the
127 // passed callback.
128 copy: function(srcpath, destpath, options) {
129 // Destpath is optional.
130 if (typeof destpath !== 'string') {
131 options = destpath;
132 destpath = srcpath;
133 }
134 // Ensure srcpath is absolute.
135 if (!grunt.file.isPathAbsolute(srcpath)) {
136 srcpath = init.srcpath(srcpath);
137 }
138 // Use placeholder file if no src exists.
139 if (!srcpath) {
140 srcpath = grunt.task.getFile('init/misc/placeholder');
141 }
142 grunt.verbose.or.write('Writing ' + destpath + '...');
143 try {
144 grunt.file.copy(srcpath, init.destpath(destpath), options);
145 grunt.verbose.or.ok();
146 } catch(e) {
147 grunt.verbose.or.error().error(e);
148 throw e;
149 }
150 },
151 // Iterate over all files in the passed object, copying the source file to
152 // the destination, processing the contents.
153 copyAndProcess: function(files, props, options) {
154 options = grunt.utils._.defaults(options || {}, {
155 process: function(contents) {
156 return grunt.template.process(contents, props, 'init');
157 }
158 });
159 Object.keys(files).forEach(function(destpath) {
160 var o = Object.create(options);
161 var srcpath = files[destpath];
162 // If srcpath is relative, match it against options.noProcess if
163 // necessary, then make srcpath absolute.
164 var relpath;
165 if (srcpath && !grunt.file.isPathAbsolute(srcpath)) {
166 if (o.noProcess) {
167 relpath = srcpath.slice(pathPrefix.length);
168 o.noProcess = grunt.file.isMatch(o.noProcess, relpath);
169 }
170 srcpath = grunt.task.getFile(srcpath);
171 }
172 // Copy!
173 init.copy(srcpath, destpath, o);
174 });
175 },
176 // Save a package.json file in the destination directory. The callback
177 // can be used to post-process properties to add/remove/whatever.
178 writePackageJSON: function(filename, props, callback) {
179 var pkg = {};
180 // Basic values.
181 ['name', 'title', 'description', 'version', 'homepage'].forEach(function(prop) {
182 if (prop in props) { pkg[prop] = props[prop]; }
183 });
184 // Author.
185 var hasAuthor = Object.keys(props).some(function(prop) {
186 return (/^author_/).test(prop);
187 });
188 if (hasAuthor) {
189 pkg.author = {};
190 ['name', 'email', 'url'].forEach(function(prop) {
191 if (props['author_' + prop]) {
192 pkg.author[prop] = props['author_' + prop];
193 }
194 });
195 }
196 // Other stuff.
197 if ('repository' in props) { pkg.repository = {type: 'git', url: props.repository}; }
198 if ('bugs' in props) { pkg.bugs = {url: props.bugs}; }
199 if (props.licenses) {
200 pkg.licenses = props.licenses.map(function(license) {
201 return {type: license, url: props.homepage + '/blob/master/LICENSE-' + license};
202 });
203 }
204
205 // Node/npm-specific (?)
206 if (props.main) { pkg.main = props.main; }
207 if (props.bin) { pkg.bin = props.bin; }
208 if (props.node_version) { pkg.engines = {node: props.node_version}; }
209 if (props.npm_test) {
210 pkg.scripts = {test: props.npm_test};
211 if (props.npm_test.split(' ')[0] === 'grunt') {
212 if (!props.devDependencies) { props.devDependencies = {}; }
213 props.devDependencies.grunt = '~' + grunt.version;
214 }
215 }
216
217 if (props.dependencies) { pkg.dependencies = props.dependencies; }
218 if (props.devDependencies) { pkg.devDependencies = props.devDependencies; }
219 if (props.keywords) { pkg.keywords = props.keywords; }
220
221 // Allow final tweaks to the pkg object.
222 if (callback) { pkg = callback(pkg, props); }
223
224 // Write file.
225 grunt.file.write(init.destpath(filename), JSON.stringify(pkg, null, 2));
226 }
227 };
228
229 // Make args available as flags.
230 init.flags = {};
231 args.forEach(function(flag) { init.flags[flag] = true; });
232
233 // Show any template-specific notes.
234 if (initTemplate.notes) {
235 grunt.log.subhead('"' + name + '" template notes:').writelns(initTemplate.notes);
236 }
237
238 // Execute template code, passing in the init object, done function, and any
239 // other arguments specified after the init:name:???.
240 initTemplate.template.apply(this, [grunt, init, function() {
241 // Fail task if errors were logged.
242 if (grunt.task.current.errorCount) { taskDone(false); }
243 // Otherwise, print a success message.
244 grunt.log.writeln().writeln('Initialized from template "' + name + '".');
245 // All done!
246 taskDone();
247 }].concat(args));
248 });
249
250 // ==========================================================================
251 // HELPERS
252 // ==========================================================================
253
254 // Prompt user to override default values passed in obj.
255 grunt.registerHelper('prompt', function(defaults, options, done) {
256 // If defaults are omitted, shuffle arguments a bit.
257 if (grunt.utils.kindOf(defaults) === 'array') {
258 done = options;
259 options = defaults;
260 defaults = {};
261 }
262
263 // Keep track of any "sanitize" functions for later use.
264 var sanitize = {};
265 options.forEach(function(option) {
266 if (option.sanitize) {
267 sanitize[option.name] = option.sanitize;
268 }
269 });
270
271 // Add one final "are you sure?" prompt.
272 if (options.length > 0) {
273 options.push({
274 message: 'Do you need to make any changes to the above before continuing?'.green,
275 name: 'ANSWERS_VALID',
276 default: 'y/N'
277 });
278 }
279
280 // Ask user for input. This is in an IIFE because it has to execute at least
281 // once, and might be repeated.
282 (function ask() {
283 grunt.log.subhead('Please answer the following:');
284 var result = grunt.utils._.clone(defaults);
285 // Loop over each prompt option.
286 grunt.utils.async.forEachSeries(options, function(option, done) {
287 var defaultValue;
288 grunt.utils.async.forEachSeries(['default', 'altDefault'], function(prop, next) {
289 if (typeof option[prop] === 'function') {
290 // If the value is a function, execute that function, using the
291 // value passed into the return callback as the new default value.
292 option[prop](defaultValue, result, function(err, value) {
293 defaultValue = String(value);
294 next();
295 });
296 } else {
297 // Otherwise, if the value actually exists, use it.
298 if (prop in option) {
299 defaultValue = option[prop];
300 }
301 next();
302 }
303 }, function() {
304 // Handle errors (there should never be errors).
305 option.default = defaultValue;
306 delete option.altDefault;
307 // Wrap validator so that answering '?' always fails.
308 var validator = option.validator;
309 option.validator = function(line, next) {
310 if (line === '?') {
311 return next(false);
312 } else if (validator) {
313 if (validator.test) {
314 return next(validator.test(line));
315 } else if (typeof validator === 'function') {
316 return validator.length < 2 ? next(validator(line)) : validator(line, next);
317 }
318 }
319 next(true);
320 };
321 // Actually get user input.
322 prompt.start();
323 prompt.getInput(option, function(err, line) {
324 if (err) { return done(err); }
325 option.validator = validator;
326 result[option.name] = line;
327 done();
328 });
329 });
330 }, function(err) {
331 // After all prompt questions have been answered...
332 if (/n/i.test(result.ANSWERS_VALID)) {
333 // User accepted all answers. Suspend prompt.
334 prompt.pause();
335 // Clean up.
336 delete result.ANSWERS_VALID;
337 // Iterate over all results.
338 grunt.utils.async.forEachSeries(Object.keys(result), function(name, next) {
339 // If this value needs to be sanitized, process it now.
340 if (sanitize[name]) {
341 sanitize[name](result[name], result, function(err, value) {
342 if (err) {
343 result[name] = err;
344 } else if (arguments.length === 2) {
345 result[name] = value === 'none' ? '' : value;
346 }
347 next();
348 });
349 } else {
350 if (result[name] === 'none') { result[name] = ''; }
351 next();
352 }
353 }, function(err) {
354 // Done!
355 grunt.log.writeln();
356 done(err, result);
357 });
358 } else {
359 // Otherwise update the default value for each user prompt option...
360 options.slice(0, -1).forEach(function(option) {
361 option.default = result[option.name];
362 });
363 // ...and start over again.
364 ask();
365 }
366 });
367 }());
368 });
369
370 // Built-in prompt options for the prompt_for helper.
371 // These generally follow the node "prompt" module convention, except:
372 // * The "default" value can be a function which is executed at run-time.
373 // * An optional "sanitize" function has been added to post-process data.
374 var prompts = {
375 name: {
376 message: 'Project name',
377 default: function(value, data, done) {
378 var types = ['javascript', 'js'];
379 if (data.type) { types.push(data.type); }
380 var type = '(?:' + types.join('|') + ')';
381 // This regexp matches:
382 // leading type- type. type_
383 // trailing -type .type _type and/or -js .js _js
384 var re = new RegExp('^' + type + '[\\-\\._]?|(?:[\\-\\._]?' + type + ')?(?:[\\-\\._]?js)?$', 'ig');
385 // Strip the above stuff from the current dirname.
386 var name = path.basename(process.cwd()).replace(re, '');
387 // Remove anything not a letter, number, dash, dot or underscore.
388 name = name.replace(/[^\w\-\.]/g, '');
389 done(null, name);
390 },
391 validator: /^[\w\-\.]+$/,
392 warning: 'Must be only letters, numbers, dashes, dots or underscores.',
393 sanitize: function(value, data, done) {
394 // An additional value, safe to use as a JavaScript identifier.
395 data.js_safe_name = value.replace(/[\W_]+/g, '_').replace(/^(\d)/, '_$1');
396 // If no value is passed to `done`, the original property isn't modified.
397 done();
398 }
399 },
400 title: {
401 message: 'Project title',
402 default: function(value, data, done) {
403 var title = data.name || '';
404 title = title.replace(/[\W_]+/g, ' ');
405 title = title.replace(/\w+/g, function(word) {
406 return word[0].toUpperCase() + word.slice(1).toLowerCase();
407 });
408 done(null, title);
409 },
410 warning: 'May consist of any characters.'
411 },
412 description: {
413 message: 'Description',
414 default: 'The best project ever.',
415 warning: 'May consist of any characters.'
416 },
417 version: {
418 message: 'Version',
419 default: function(value, data, done) {
420 // Get a valid semver tag from `git describe --tags` if possible.
421 grunt.utils.spawn({
422 cmd: 'git',
423 args: ['describe', '--tags'],
424 fallback: ''
425 }, function(err, result, code) {
426 result = result.split('-')[0];
427 done(null, semver.valid(result) || '0.1.0');
428 });
429 },
430 validator: semver.valid,
431 warning: 'Must be a valid semantic version (semver.org).'
432 },
433 repository: {
434 message: 'Project git repository',
435 default: function(value, data, done) {
436 // Change any git@...:... uri to git://.../... format.
437 grunt.helper('git_origin', function(err, result) {
438 if (err) {
439 // Attempt to guess at the repo name. Maybe we'll get lucky!
440 result = 'git://github.com/' + (process.env.USER || process.env.USERNAME || '???') + '/' +
441 data.name + '.git';
442 } else {
443 result = result.replace(/^git@([^:]+):/, 'git://$1/');
444 }
445 done(null, result);
446 });
447 },
448 sanitize: function(value, data, done) {
449 // An additional computed "git_user" property.
450 var repo = grunt.helper('github_web_url', data.repository);
451 var parts;
452 if (repo != null) {
453 parts = repo.split('/');
454 data.git_user = parts[parts.length - 2];
455 data.git_repo = parts[parts.length - 1];
456 done();
457 } else {
458 // Attempt to pull the data from the user's git config.
459 grunt.utils.spawn({
460 cmd: 'git',
461 args: ['config', '--get', 'github.user'],
462 fallback: ''
463 }, function(err, result, code) {
464 data.git_user = result || process.env.USER || process.env.USERNAME || '???';
465 data.git_repo = path.basename(process.cwd());
466 done();
467 });
468 }
469 },
470 warning: 'Should be a public git:// URI.'
471 },
472 homepage: {
473 message: 'Project homepage',
474 // If GitHub is the origin, the (potential) homepage is easy to figure out.
475 default: function(value, data, done) {
476 done(null, grunt.helper('github_web_url', data.repository) || 'none');
477 },
478 warning: 'Should be a public URL.'
479 },
480 bugs: {
481 message: 'Project issues tracker',
482 // If GitHub is the origin, the issues tracker is easy to figure out.
483 default: function(value, data, done) {
484 done(null, grunt.helper('github_web_url', data.repository, 'issues') || 'none');
485 },
486 warning: 'Should be a public URL.'
487 },
488 licenses: {
489 message: 'Licenses',
490 default: 'MIT',
491 validator: /^[\w\-]+(?:\s+[\w\-]+)*$/,
492 warning: 'Must be zero or more space-separated licenses. Built-in ' +
493 'licenses are: ' + availableLicenses().join(' ') + ', but you may ' +
494 'specify any number of custom licenses.',
495 // Split the string on spaces.
496 sanitize: function(value, data, done) { done(value.split(/\s+/)); }
497 },
498 author_name: {
499 message: 'Author name',
500 default: function(value, data, done) {
501 // Attempt to pull the data from the user's git config.
502 grunt.utils.spawn({
503 cmd: 'git',
504 args: ['config', '--get', 'user.name'],
505 fallback: 'none'
506 }, done);
507 },
508 warning: 'May consist of any characters.'
509 },
510 author_email: {
511 message: 'Author email',
512 default: function(value, data, done) {
513 // Attempt to pull the data from the user's git config.
514 grunt.utils.spawn({
515 cmd: 'git',
516 args: ['config', '--get', 'user.email'],
517 fallback: 'none'
518 }, done);
519 },
520 warning: 'Should be a valid email address.'
521 },
522 author_url: {
523 message: 'Author url',
524 default: 'none',
525 warning: 'Should be a public URL.'
526 },
527 jquery_version: {
528 message: 'Required jQuery version',
529 default: '*',
530 warning: 'Must be a valid semantic version range descriptor.'
531 },
532 node_version: {
533 message: 'What versions of node does it run on?',
534 // TODO: pull from grunt's package.json
535 default: '>= 0.6.0',
536 warning: 'Must be a valid semantic version range descriptor.'
537 },
538 main: {
539 message: 'Main module/entry point',
540 default: function(value, data, done) {
541 done(null, 'lib/' + data.name);
542 },
543 warning: 'Must be a path relative to the project root.'
544 },
545 bin: {
546 message: 'CLI script',
547 default: function(value, data, done) {
548 done(null, 'bin/' + data.name);
549 },
550 warning: 'Must be a path relative to the project root.'
551 },
552 npm_test: {
553 message: 'Npm test command',
554 default: 'grunt test',
555 warning: 'Must be an executable command.'
556 },
557 grunt_version: {
558 message: 'What versions of grunt does it require?',
559 default: '~' + grunt.version,
560 warning: 'Must be a valid semantic version range descriptor.'
561 }
562 };
563
564 // Expose prompts object so that prompt_for prompts can be added or modified.
565 grunt.registerHelper('prompt_for_obj', function() {
566 return prompts;
567 });
568
569 // Commonly-used prompt options with meaningful default values.
570 grunt.registerHelper('prompt_for', function(name, altDefault) {
571 // Clone the option so the original options object doesn't get modified.
572 var option = grunt.utils._.clone(prompts[name]);
573 option.name = name;
574
575 var defaults = grunt.task.readDefaults('init/defaults.json');
576 if (name in defaults) {
577 // A user default was specified for this option, so use its value.
578 option.default = defaults[name];
579 } else if (arguments.length === 2) {
580 // An alternate default was specified, so use it.
581 option.altDefault = altDefault;
582 }
583 return option;
584 });
585
586 // Get the git origin url from the current repo (if possible).
587 grunt.registerHelper('git_origin', function(done) {
588 grunt.utils.spawn({
589 cmd: 'git',
590 args: ['remote', '-v']
591 }, function(err, result, code) {
592 var re = /^origin\s/;
593 var lines;
594 if (!err) {
595 lines = result.split('\n').filter(re.test, re);
596 if (lines.length > 0) {
597 done(null, lines[0].split(/\s/)[1]);
598 return;
599 }
600 }
601 done(true, 'none');
602 });
603 });
604
605 // Generate a GitHub web URL from a GitHub repo URI.
606 var githubWebUrlRe = /^.+(?:@|:\/\/)(github.com)[:\/](.+?)(?:\.git|\/)?$/;
607 grunt.registerHelper('github_web_url', function(uri, suffix) {
608 var matches = githubWebUrlRe.exec(uri);
609 if (!matches) { return null; }
610 var url = 'https://' + matches[1] + '/' + matches[2];
611 if (suffix) {
612 url += '/' + suffix.replace(/^\//, '');
613 }
614 return url;
615 });
616
617};