UNPKG

21.8 kBJavaScriptView Raw
1
2/**
3 * The firedoc module
4 * @module firedoc
5 */
6
7const _ = require('underscore');
8const path = require('path');
9const fs = require('graceful-fs');
10const fse = require('fs-extra');
11const mkdirp = require('mkdirp').sync;
12const inspect = require('util').inspect;
13const request = require('needle');
14const Promise = require('bluebird');
15const Handlebars = require('handlebars');
16const EventEmitter = require('events').EventEmitter;
17
18const debug = require('debug')('firedoc:build');
19const utils = require('./utils');
20const DocView = require('./docview').DocView;
21const Locals = require('./locals').Locals;
22const defaultHelpers = require('./helpers');
23
24/**
25 * List of native types to cross link to MDN
26 * @property NATIVES
27 * @type Object
28 */
29const NATIVES = require('./natives.json');
30
31// Promisify
32Promise.promisifyAll(request);
33Promise.promisifyAll(fs);
34Promise.promisifyAll(fse);
35
36/**
37 * The Builder Context
38 * @class BuilderContext
39 * @extend EventEmitter
40 */
41var BuilderContext = {
42
43 /**
44 * @property {AST} ast - The AST object
45 */
46 ast: null,
47
48 /**
49 * @property {Option} options - The command options
50 */
51 options: null,
52
53 /**
54 * @property {Object} helpers - The view helpers function
55 */
56 helpers: {},
57
58 /**
59 * @property {Boolean} cacheView - cache the views
60 */
61 cacheTemplate: true,
62
63 /**
64 * @property {Object} template - The template
65 */
66 template: null,
67
68 /**
69 * @property {Number} files - records the files
70 */
71 files: 0,
72
73 /**
74 * @property {String} viewExtname - The ext name of current view
75 */
76 get extname () {
77 return this.options.markdown ? '.md' : '.html';
78 },
79
80 /**
81 * load the metadata from theme.json
82 * @method metadata
83 * @return {Object} metadata object
84 */
85 metadata: function () {
86 if (!this._metadata) {
87 try {
88 var metadata;
89 var themeJSON = path.join(this.options.theme, 'theme.json');
90 if (fs.existsSync(themeJSON)) {
91 debug('loading theme from ' + themeJSON);
92 if (path.isAbsolute(themeJSON)) {
93 metadata = require(themeJSON);
94 } else {
95 metadata = require(path.join(process.cwd(), themeJSON));
96 }
97 } else {
98 debug('loading the default theme');
99 metadata = require('../themes/default/theme.json');
100 }
101 this._metadata = metadata;
102 } catch (err) {
103 this._metadata = {};
104 console.error(err.stack);
105 }
106 }
107 return this._metadata;
108 },
109
110 /**
111 * Add helper
112 * @method addHelper
113 * @param {String} name - The helper name
114 * @param {Function} helper - The helper logic
115 * @static
116 */
117 addHelper: function (name, helper) {
118 this.helpers[name] = helper;
119 Handlebars.registerHelper(name, helper.bind(this));
120 },
121
122 /**
123 * Add helpers
124 * @method addHelpers
125 * @param {Object} helpers
126 * @static
127 */
128 addHelpers: function (helpers) {
129 _.each(helpers, function (helper, name) {
130 this.addHelper(name, helper);
131 }, this);
132 },
133
134 /**
135 * Ported from [Selleck](https://github.com/rgrove/selleck), this handles ```'s in fields
136 * that are not parsed by the **Markdown** parser.
137 * @method _inlineCode
138 * @private
139 * @param {HTML} html The HTML to parse
140 * @return {HTML} The parsed HTML
141 */
142 inlineCode: function (html) {
143 if (this.options.markdown) return html;
144 html = html.replace(/\\`/g, '__{{SELLECK_BACKTICK}}__');
145 html = html.replace(/`(.+?)`/g, function (match, code) {
146 return '<code>' + utils.escapeHTML(code) + '</code>';
147 });
148 html = html.replace(/__\{\{SELLECK_BACKTICK\}\}__/g, '`');
149 return html;
150 },
151
152 /**
153 * Parse the item to be cross linked and return an HREF linked to the item
154 * @method _parseCrossLink
155 * @private
156 * @static
157 * @param {String} item The item to crossLink
158 * @param {Boolean} [raw=false] Do not wrap it in HTML
159 * @param {String} [content] crossLink helper content
160 */
161 _parseCrossLink: function (item, raw, content) {
162 item = item || 'unknown';
163 var self = this;
164 var base = '../';
165 var baseItem;
166 var newWin = false;
167 var className = 'crosslink';
168
169 // TODO(@yorkie): now remove the unnecessary fixType
170 // will remove this absolutely if this is working for weeks
171 // item = fixType(item);
172 item = baseItem = utils.safetrim(item.replace('{', '').replace('}', ''));
173 item = item.replace('*', '').replace('[', '').replace(']', '');
174 var link = false, href;
175 var typeName = 'classes';
176
177 if (self.ast.classes[item]) {
178 link = true;
179 if (self.ast.classes[item].type === 'enums') {
180 typeName = 'enums';
181 }
182 } else if (self.ast.modules[item]) {
183 link = true;
184 typeName = 'modules';
185 } else {
186 if (self.ast.classes[item.replace('.', '')]) {
187 link = true;
188 item = item.replace('.', '');
189 }
190 }
191 if (self.options.externalData) {
192 if (self.ast.classes[item]) {
193 if (self.ast.classes[item].external) {
194 href = self.ast.classes[item].path;
195 base = self.options.externalData.base;
196 className += ' external';
197 newWin = true;
198 link = true;
199 }
200 }
201 }
202
203 if (item.indexOf('/') > -1) {
204 //We have a class + method to parse
205 var parts = item.split('/'),
206 cls = parts[0],
207 method = parts[1],
208 type = 'method';
209
210 if (method.indexOf(':') > -1) {
211 parts = method.split(':');
212 method = parts[0];
213 type = parts[1];
214 if (type.indexOf('attr') === 0) {
215 type = 'attribute';
216 }
217 }
218
219 if (cls && method) {
220 if (self.ast.classes[cls]) {
221 self.ast.members.forEach(function (i) {
222 if (i.itemtype === type && i.name === method && i.clazz === cls) {
223 link = true;
224 baseItem = method;
225 var t = type;
226 if (t === 'attribute') {
227 t = 'attr';
228 }
229 href = utils.webpath(base, 'classes', cls + '.html#' + t + '_' + method);
230 }
231 });
232 } else if (self.ast.modules[cls]) {
233 self.ast.members.forEach(function (i) {
234 if (i.itemtype === type && i.name === method && i.module === cls) {
235 link = true;
236 baseItem = method;
237 var t = type;
238 if (t === 'attribute') {
239 t = 'attr';
240 }
241 href = utils.webpath(base, 'modules', cls + '.html#' + t + '_' + method);
242 }
243 });
244 }
245 }
246 }
247
248 if (item === 'Object' || item === 'Array') {
249 link = false;
250 }
251 if (!href) {
252 href = utils.webpath(base, typeName, item + '.html');
253 if (base.match(/^https?:\/\//)) {
254 href = base + utils.webpath(typeName, item + '.html');
255 }
256 }
257 if (!link && self.options.linkNatives) {
258 item = utils.fixType(item);
259 if (NATIVES && NATIVES[item]) {
260 href = linkNativeType(item);
261 if (href) {
262 className += ' external';
263 newWin = true;
264 link = true;
265 }
266 }
267 }
268 if (link) {
269 if (content !== undefined) {
270 content = content.trim();
271 }
272 if (!content) {
273 content = baseItem;
274 }
275 item = '<a href="' + href + '" class="' + className + '"' + ((newWin) ? ' target="_blank"' : '') + '>' + content + '</a>';
276 }
277 return (raw) ? href : item;
278 },
279
280 /**
281 * Populate the meta data for classes
282 * @method populateClasses
283 * @param {Object} opts The original options
284 * @return {Object} The modified options
285 */
286 populateClasses: function (opts) {
287 var classes = [];
288 var enums = [];
289 _.each(opts.meta.classes, function (clazz) {
290 if (clazz.external) return;
291 if (clazz.access == 'private') {
292 debug('skipping class ' + clazz.name);
293 return;
294 }
295 if (clazz.isEnum) {
296 clazz.type = 'enums';
297 enums.push(clazz);
298 } else {
299 clazz.type = 'classes';
300 classes.push(clazz);
301 }
302 });
303 opts.meta.classes = classes;
304 opts.meta.enums = enums;
305 return opts;
306 },
307
308 /**
309 * Populate the meta data for modules
310 * @method populateModules
311 * @param {Object} opts The original options
312 * @return {Object} The modified options
313 */
314 populateModules: function (opts) {
315 var self = this;
316 var modules = opts.meta.modules;
317 _.each(modules, function (mod) {
318 if (mod.external) return;
319 if (!mod.isSubmodule && mod.submodules) {
320 var submodules = [];
321 _.each(mod.submodules, function (val, name) {
322 var mod = self.ast.modules[name];
323 if (val && mod) submodules.push(mod);
324 });
325 mod.type = 'modules';
326 mod.submodules = _.sortBy(submodules, 'name');
327 }
328 });
329 opts.meta.modules = _.sortBy(modules, 'name');
330 return opts;
331 },
332
333 /**
334 * Populate the meta data for files
335 * @method populateFiles
336 * @param {Object} opts The original options
337 * @return {Object} The modified options
338 */
339 populateFiles: function (opts) {
340 var self = this;
341 var files = [];
342 _.each(this.ast.files, function (v) {
343 if (v.external) return;
344 v.name = utils.filterFileName(v.name);
345 v.path = v.path || v.name;
346 files.push(v);
347 });
348 files = _.sortBy(files, 'name');
349 opts.meta.fileTree = utils.buildFileTree(files);
350 return opts;
351 },
352
353 /**
354 * Parses file and line number from an item object and build's an HREF
355 * @method addFoundAt
356 * @param {Object} a The item to parse
357 * @return {String} The parsed HREF
358 */
359 addFoundAt: function (a) {
360 a.foundAt = utils.getFoundAt(a, this.options);
361 return a;
362 },
363
364 /**
365 * Fetches the remote data and fires the callback when it's all complete
366 *
367 * @method mixExternal
368 * @async
369 * @param {Function} cb The callback to execute when complete
370 * @return {Promise}
371 */
372 mixExternal: function (callback) {
373 var self = this;
374 var external = this.options.external || {};
375 var current = Promise.resolve();
376 if (!external) return callback();
377
378 external.merge = external.merge || 'mix';
379 if (!external.data) {
380 console.warn('External config found but no data path defined, skipping import.');
381 if (_.isFunction(callback)) {
382 callback();
383 }
384 return current;
385 }
386 if (!_.isArray(external.data)) {
387 external.data = [external.data];
388 }
389 debug('Importing external documentation data');
390
391 return Promise.map(external.data, function (item) {
392 var base;
393 if (_.isObject(item)) {
394 base = item.base;
395 item = item.json;
396 }
397 if (item.match(/^https?:\/\//)) {
398 if (!base) {
399 base = item.replace('data.json', '');
400 }
401 return current.then(function () {
402 debug('fetching ' + item);
403 return request.getAsync(item, {});
404 }).then(function (results) {
405 var data = JSON.parse(results[1]);
406 data.base = base;
407 return data;
408 });
409 } else {
410 if (!base) {
411 base = path.dirname(path.resolve(item));
412 }
413 var data = require(item);
414 data.base = base;
415 return data;
416 }
417 }).then(function (results) {
418 function mixExternal (type, exdata) {
419 self.ast[type] = (exdata[type] || []).map(setExternal);
420 }
421 function setExternal (item) {
422 item.external = true;
423 return item;
424 }
425 _.each(results, function (exdata) {
426 mixExternal('files', exdata);
427 mixExternal('classes', exdata);
428 mixExternal('modules', exdata);
429 mixExternal('members', exdata);
430 });
431 if (_.isFunction(callback)) {
432 callback();
433 }
434 });
435 },
436
437 /**
438 * Makes the default directories needed
439 * @method makeDirs
440 */
441 makeDirs: function (callback) {
442 var dirs = ['assets', 'classes', 'modules', 'enums'];
443 if (this.options.withSrc) {
444 dirs.push('files');
445 }
446 var root = this.options.dest || 'out';
447 debug('Making default directories: ' + dirs.join(','));
448 mkdirp(path.join(root, dirs[0]));
449 mkdirp(path.join(root, dirs[1]));
450 mkdirp(path.join(root, dirs[2]));
451 mkdirp(path.join(root, dirs[3]));
452 if (this.options.withSrc) {
453 mkdirp(path.join(root, dirs[4]));
454 }
455 return dirs;
456 },
457
458 /**
459 * Set `BuilderContext` context and return
460 * @method init
461 * @param {AST} ast
462 * @param {Option} options
463 * @return {BuilderContext}
464 * @static
465 */
466 init: function (ast, options) {
467 this.ast = ast;
468 this.options = options;
469 this.addHelpers(defaultHelpers);
470 this.cacheView = options.cacheView || this.cacheView;
471 this.removeAllListeners();
472 return this;
473 },
474
475 /**
476 * correct the theme
477 * @method correctTheme
478 */
479 correctTheme: function () {
480 var root = path.join(__dirname, '../themes');
481 var theme = this.options.theme;
482 if (fs.existsSync(theme))
483 return theme;
484 var theme = root + '/firedoc-theme-' + this.options.theme;
485 if (fs.existsSync(theme))
486 return this.options.theme = theme;
487 theme = root + '/firedoc-plugin-' + this.options.theme;
488 if (fs.existsSync(theme))
489 return this.options.theme = theme;
490 theme = root + '/' + this.options.theme;
491 if (fs.existsSync(theme))
492 return this.options.theme = theme;
493 this.options.theme = root + '/default';
494 return this.options.theme;
495 },
496
497 /**
498 * Compule the AST
499 * @method compile
500 * @static
501 * @param {Function} callback - The callback
502 */
503 compile: function (callback) {
504 debug('Compiling templates...');
505 var self = this;
506 this
507 .mixExternal()
508 .then(function makeDestDirs () {
509 debug('make dest directories');
510 self.makeDirs.call(self);
511 })
512 .then(function checkThemeDir () {
513 debug('Checking theme folder');
514 var theme = self.correctTheme.call(self);
515 var metadata = self.metadata();
516 debug('Using corrected theme: ' + theme);
517 debug('Using the following metadata:' + inspect(metadata, {
518 colors: true
519 }));
520 })
521 .then(function copyAssets () {
522 debug('Copying assets...');
523 var src = self.options.theme + '/assets';
524 var dest = self.options.dest + '/assets';
525 return fse.copyAsync(src, dest);
526 })
527 .then(function createLocalsForTheme () {
528 debug('Creating locals for theme...');
529 return Locals.create(self);
530 })
531 .then(function render (locals) {
532 debug('Popluating files, classes, modules');
533 if (self.options.withSrc) {
534 locals = self.populateFiles(locals);
535 }
536 locals = self.populateClasses(locals);
537 locals = self.populateModules(locals);
538 return Promise.all(
539 [
540 self.writeApiMeta(locals),
541 self.writeIndex(locals),
542 self.options.withSrc ? self.writeFiles(locals) : null,
543 self.writeEnums(locals),
544 self.writeClasses(locals),
545 self.writeModules(locals)
546 ].filter( function (entry, index) {
547 // this magic number indicates writeFiles
548 if (index === 2 && !self.options.withSrc) {
549 return false;
550 }
551 return true;
552 })
553 );
554 })
555 .then(function onfinish () {
556 debug('Finished the build work');
557 if (_.isFunction(callback)) {
558 callback();
559 }
560 })
561 .caught(callback);
562 return this;
563 },
564
565 /**
566 * Render
567 * @method render
568 */
569 render: function (name, view, locals) {
570 var html = [];
571 var partials = _.extend(locals.partials, {
572 'layout_content': '{{>' + name + '}}'
573 });
574 _.each(partials, function (source, name) {
575 Handlebars.registerPartial(name, source);
576 });
577 if (!this.template || !this.cacheTemplate) {
578 this.template = Handlebars.compile(locals.layouts.main);
579 }
580
581 var _view = {};
582 for (var k in view) {
583 if (_.isFunction(view[k])) {
584 _view[k] = view[k]();
585 } else {
586 _view[k] = view[k];
587 }
588 }
589 return this.inlineCode(this.template(_view));
590 },
591
592 /**
593 * Write api.json
594 * @method writeApiMeta
595 * @param {Locals} locals - The locals
596 */
597 writeApiMeta: function (locals) {
598 var self = this;
599 var apimeta = {
600 enums: [],
601 classes: [],
602 modules: []
603 };
604 _.each(
605 ['classes', 'modules', 'enums'],
606 function (id) {
607 var items = locals.meta[id];
608 var g = function (item) {
609 apimeta[id].push({
610 'name': item.name,
611 'namespace': item.namespace,
612 'module': item.module,
613 'description': item.description,
614 'access': item.access
615 });
616 };
617 _.each(locals.meta[id], g);
618 apimeta[id] = _.sortBy(apimeta[id], 'name');
619 }
620 );
621 return fs.writeFileAsync(
622 this.options.dest + '/api.js',
623 'window.apimeta = ' + JSON.stringify(apimeta, null, 2),
624 'utf8'
625 ).then(function () {
626 self.emit('apimeta', apimeta);
627 debug('api.js finished');
628 });
629 },
630
631 writeIndex: function (locals) {
632 debug('Start writing index');
633 var self = this;
634 var view = new DocView(locals.meta);
635 view.base = '.';
636 var html = this.render('index', view, locals);
637 var filename = this.options.markdown ? '/readme.md' : '/index.html';
638 var dest = this.options.dest + filename;
639
640 debug('Start writing index.html');
641 return fs.writeFileAsync(dest, html, 'utf8').then(function () {
642 self.emit('index', view, html, dest);
643 });
644 },
645
646 writeFiles: function (locals) {
647 debug('Start writing files');
648 var self = this;
649 return Promise.map(
650 locals.meta.files,
651 function (file) {
652 file.globals = locals.meta;
653 var view = new DocView(file, null, '../');
654 view.base = '..';
655 var html = self.render('file', view, locals);
656 var dest = path.join(self.options.dest, 'files', file.name.replace(/\//g, '_') + self.extname);
657
658 debug('Start writing file: ' + file.name);
659 return fs.writeFileAsync(dest, html, 'utf8').then(function () {
660 self.emit('file', view, html, dest);
661 });
662 }
663 );
664 },
665
666 writeEnums: function (locals) {
667 debug('Start writing enums');
668 var self = this;
669 return Promise.map(
670 locals.meta.enums,
671 function (e) {
672 e.globals = locals.meta;
673 var view = new DocView(e, null, '../');
674 view.base = '..';
675 var html = self.render('enum', view, locals);
676 var dest = path.join(self.options.dest, 'enums', e.name + self.extname);
677
678 debug('Start writing enum: ' + e.name);
679 return fs.writeFileAsync(dest, html, 'utf8').then(function () {
680 self.emit('enum', view, html, dest);
681 });
682 }
683 );
684 },
685
686 writeClasses: function (locals) {
687 debug('Start writing classes');
688 var self = this;
689 return Promise.map(
690 locals.meta.classes,
691 function (clazz) {
692 clazz.globals = locals.meta;
693 var view = new DocView(clazz, null, '../');
694 var dest = path.join(self.options.dest, 'classes', clazz.name + self.extname);
695 view.base = '..';
696 var html = self.render('class', view, locals);
697
698 debug('Start writing class: ' + clazz.name);
699 return fs.writeFileAsync(dest, html, 'utf8').then(function () {
700 self.emit('class', view, html, dest);
701 });
702 }
703 );
704 },
705
706 writeModules: function (locals) {
707 debug('Start writing modules');
708 var self = this;
709 return Promise.map(
710 locals.meta.modules,
711 function (mod) {
712 mod.globals = locals.meta;
713 var view = new DocView(mod, null, '../');
714 var dest = path.join(self.options.dest, 'modules', mod.name + self.extname);
715 view.base = '..';
716 var html = self.render('module', view, locals);
717
718 debug('Start writing module: ' + mod.name);
719 return fs.writeFileAsync(dest, html, 'utf8').then(function () {
720 self.emit('module', view, html, dest);
721 });
722 }
723 );
724 }
725
726};
727
728// Extends the `BuilderContext` with `EventEmitter`.
729var emitter = new EventEmitter();
730BuilderContext = _.extend(BuilderContext, emitter);
731
732/**
733 * Function to link an external type uses `NATIVES` object
734 * @method NATIVES_LINKER
735 * @private
736 * @param {String} name The name of the type to link
737 * @return {String} The combined URL
738 */
739function linkNativeType (name) {
740 var url = 'https://developer.mozilla.org/en/JavaScript/Reference/Global_Objects/';
741 if (NATIVES[name] !== 1) {
742 url = NATIVES[name];
743 }
744 return url + name;
745}
746
747/**
748 * compile
749 *
750 * @method compile
751 * @param {AST} ast - The `AST` object
752 * @param {Option} options - The options
753 * @param {Function} onfinish - fired when compile has completed
754 */
755function compile (ast, options, onfinish) {
756 var context = BuilderContext.init(ast, options);
757 setImmediate(function () {
758 context.compile(onfinish);
759 });
760 return context;
761}
762exports.compile = compile;