UNPKG

23.5 kBJavaScriptView Raw
1var Q = require('q');
2var _ = require('lodash');
3var path = require('path');
4var parsers = require('gitbook-parsers');
5
6var fs = require('./utils/fs');
7var parseNavigation = require('./utils/navigation');
8var parseProgress = require('./utils/progress');
9var pageUtil = require('./utils/page');
10var pathUtil = require('./utils/path');
11var links = require('./utils/links');
12var i18n = require('./utils/i18n');
13var logger = require('./utils/logger');
14
15var Configuration = require('./configuration');
16var TemplateEngine = require('./template');
17var PluginsList = require('./pluginslist');
18
19var generators = require('./generators');
20
21var Book = function(root, context, parent) {
22 this.context = _.defaults(context || {}, {
23 // Extend book configuration
24 config: {},
25
26 // Log function
27 log: function(msg) {
28 process.stdout.write(msg);
29 },
30
31 // Log level
32 logLevel: 'info'
33 });
34
35 // Log
36 this.log = logger(this.context.log, this.context.logLevel);
37
38 // Root folder of the book
39 this.root = path.resolve(root);
40
41 // Parent book
42 this.parent = parent;
43
44 // Configuration
45 this.config = new Configuration(this, this.context.config);
46 Object.defineProperty(this, 'options', {
47 get: function () {
48 return this.config.options;
49 }
50 });
51
52 // Template
53 this.template = new TemplateEngine(this);
54
55 // Summary
56 this.summary = {};
57 this.navigation = [];
58
59 // Glossary
60 this.glossary = [];
61
62 // Langs
63 this.langs = [];
64
65 // Sub-books
66 this.books = [];
67
68 // Files in the book
69 this.files = [];
70
71 // List of plugins
72 this.plugins = new PluginsList(this);
73
74 // Structure files
75 this.summaryFile = null;
76 this.glossaryFile = null;
77 this.readmeFile = null;
78 this.langsFile = null;
79
80 // Bind methods
81 _.bindAll(this);
82};
83
84// Return string representation
85Book.prototype.toString = function() {
86 return '[Book '+this.root+']';
87};
88
89// Initialize and parse the book: config, summary, glossary
90Book.prototype.parse = function() {
91 var that = this;
92 var multilingual = false;
93
94 return this.parseConfig()
95
96 .then(function() {
97 return that.parsePlugins();
98 })
99
100 .then(function() {
101 return that.parseLangs()
102 .then(function() {
103 multilingual = that.langs.length > 0;
104 if (multilingual) that.log.info.ln('Parsing multilingual book, with', that.langs.length, 'languages');
105
106 // Sub-books that inherit from the current book configuration
107 that.books = _.map(that.langs, function(lang) {
108 that.log.info.ln('Preparing language book', lang.lang);
109 return new Book(
110 path.join(that.root, lang.path),
111 _.merge({}, that.context, {
112 config: _.extend({}, that.options, {
113 'output': path.join(that.options.output, lang.lang),
114 'language': lang.lang
115 })
116 }),
117 that
118 );
119 });
120 });
121 })
122
123 .then(function() {
124 if (multilingual) return;
125 return that.listAllFiles();
126 })
127 .then(function() {
128 if (multilingual) return;
129 return that.parseReadme();
130 })
131 .then(function() {
132 if (multilingual) return;
133 return that.parseSummary();
134 })
135 .then(function() {
136 if (multilingual) return;
137 return that.parseGlossary();
138 })
139
140 .then(function() {
141 // Init sub-books
142 return _.reduce(that.books, function(prev, book) {
143 return prev.then(function() {
144 return book.parse();
145 });
146 }, Q());
147 })
148
149 .thenResolve(this);
150};
151
152// Generate the output
153Book.prototype.generate = function(generator) {
154 var that = this;
155 that.options.generator = generator || that.options.generator;
156
157 that.log.info.ln('start generation with', that.options.generator, 'generator');
158 return Q()
159
160 // Clean output folder
161 .then(function() {
162 that.log.info('clean', that.options.generator, 'generator');
163 return fs.clean(that.options.output)
164 .progress(function(p) {
165 that.log.debug.ln('remove', p.file, '('+p.i+'/'+p.count+')');
166 })
167 .then(function() {
168 that.log.info.ok();
169 });
170 })
171
172 // Create generator
173 .then(function() {
174 var Generator = generators[generator];
175 if (!Generator) throw 'Generator \''+that.options.generator+'\' doesn\'t exist';
176 generator = new Generator(that);
177
178 return generator.prepare();
179 })
180
181 // Transform configuration
182 .then(function() {
183 return that.callHook('config', that.config.dump())
184 .then(function(newConfig) {
185 that.config.replace(newConfig);
186 });
187 })
188
189 // Generate content
190 .then(function() {
191 if (that.isMultilingual()) {
192 return that.generateMultiLingual(generator);
193 } else {
194 // Separate list of files into the different operations needed
195 var ops = _.groupBy(that.files, function(file) {
196 if (file[file.length -1] == '/') {
197 return 'directories';
198 } else if (_.contains(parsers.extensions, path.extname(file)) && that.navigation[file]) {
199 return 'content';
200 } else {
201 return 'files';
202 }
203 });
204
205
206 return Q()
207
208 // First, let's create folder
209 .then(function() {
210 return _.reduce(ops.directories || [], function(prev, folder) {
211 return prev.then(function() {
212 that.log.debug.ln('transferring folder', folder);
213 return Q(generator.transferFolder(folder));
214 });
215 }, Q());
216 })
217
218 // Then, let's copy other files
219 .then(function() {
220 return Q.all(_.map(ops.files || [], function(file) {
221 that.log.debug.ln('transferring file', file);
222 return Q(generator.transferFile(file));
223 }));
224 })
225
226 // Finally let's generate content
227 .then(function() {
228 var nFiles = (ops.content || []).length;
229 return _.reduce(ops.content || [], function(prev, file, i) {
230 return prev.then(function() {
231 var p = ((i*100)/nFiles).toFixed(0)+'%';
232 that.log.debug.ln('processing', file, p);
233
234 return Q(generator.convertFile(file))
235 .fail(function(err) {
236 // Transform error message to signal file
237 throw that.normError(err, {
238 fileName: file
239 });
240 });
241 });
242 }, Q());
243 });
244 }
245 })
246
247 // Finish generation
248 .then(function() {
249 return that.callHook('finish:before');
250 })
251 .then(function() {
252 return generator.finish();
253 })
254 .then(function() {
255 return that.callHook('finish');
256 })
257 .then(function() {
258 that.log.info.ln('generation is finished');
259 });
260};
261
262// Generate the output for a multilingual book
263Book.prototype.generateMultiLingual = function() {
264 var that = this;
265
266 return Q()
267 .then(function() {
268 // Generate sub-books
269 return _.reduce(that.books, function(prev, book) {
270 return prev.then(function() {
271 return book.generate(that.options.generator);
272 });
273 }, Q());
274 });
275};
276
277// Extract files from ebook generated
278Book.prototype.generateFile = function(output, options) {
279 var book = this;
280
281 options = _.defaults(options || {}, {
282 ebookFormat: path.extname(output).slice(1)
283 });
284 output = output || path.resolve(book.root, 'book.'+options.ebookFormat);
285
286 return fs.tmp.dir()
287 .then(function(tmpDir) {
288 book.setOutput(tmpDir);
289
290 return book.generate(options.ebookFormat)
291 .then(function() {
292 var copyFile = function(lang) {
293 var _outputFile = output;
294 var _tmpDir = tmpDir;
295
296 if (lang) {
297 _outputFile = _outputFile.slice(0, -path.extname(_outputFile).length)+'_'+lang+path.extname(_outputFile);
298 _tmpDir = path.join(_tmpDir, lang);
299 }
300
301 book.log.debug.ln('copy ebook to', _outputFile);
302 return fs.copy(
303 path.join(_tmpDir, 'index.'+options.ebookFormat),
304 _outputFile
305 );
306 };
307
308 // Multi-langs book
309 return Q()
310 .then(function() {
311 if (book.isMultilingual()) {
312 return Q.all(
313 _.map(book.langs, function(lang) {
314 return copyFile(lang.lang);
315 })
316 )
317 .thenResolve(book.langs.length);
318 } else {
319 return copyFile().thenResolve(1);
320 }
321 })
322 .then(function(n) {
323 book.log.info.ok(n+' file(s) generated');
324
325 return fs.remove(tmpDir);
326 });
327 });
328 });
329};
330
331// Parse configuration
332Book.prototype.parseConfig = function() {
333 var that = this;
334
335 that.log.info('loading book configuration....');
336 return that.config.load()
337 .then(function() {
338 that.log.info.ok();
339 });
340};
341
342// Parse list of plugins
343Book.prototype.parsePlugins = function() {
344 var that = this;
345
346 // Load plugins
347 return that.plugins.load(that.options.plugins)
348 .then(function() {
349 if (_.size(that.plugins.failed) > 0) return Q.reject(new Error('Error loading plugins: '+that.plugins.failed.join(',')+'. Run \'gitbook install\' to install plugins from NPM.'));
350
351 that.log.info.ok(that.plugins.count()+' plugins loaded');
352 that.log.debug.ln('normalize plugins list');
353 });
354};
355
356// Parse readme to extract defaults title and description
357Book.prototype.parseReadme = function() {
358 var that = this;
359 var structure = that.config.getStructure('readme');
360 that.log.debug.ln('start parsing readme:', structure);
361
362 return that.findFile(structure)
363 .then(function(readme) {
364 if (!readme) throw 'No README file';
365 if (!_.contains(that.files, readme.path)) throw 'README file is ignored';
366
367 that.readmeFile = readme.path;
368 that._defaultsStructure(that.readmeFile);
369
370 that.log.debug.ln('readme located at', that.readmeFile);
371 return that.template.renderFile(that.readmeFile)
372 .then(function(content) {
373 return readme.parser.readme(content)
374 .fail(function(err) {
375 throw that.normError(err, {
376 name: err.name || 'Readme Parse Error',
377 fileName: that.readmeFile
378 });
379 });
380 });
381 })
382 .then(function(readme) {
383 that.options.title = that.options.title || readme.title;
384 that.options.description = that.options.description || readme.description;
385 });
386};
387
388
389// Parse langs to extract list of sub-books
390Book.prototype.parseLangs = function() {
391 var that = this;
392
393 var structure = that.config.getStructure('langs');
394 that.log.debug.ln('start parsing languages index:', structure);
395
396 return that.findFile(structure)
397 .then(function(langs) {
398 if (!langs) return [];
399
400 that.langsFile = langs.path;
401 that._defaultsStructure(that.langsFile);
402
403 that.log.debug.ln('languages index located at', that.langsFile);
404 return that.template.renderFile(that.langsFile)
405 .then(function(content) {
406 return langs.parser.langs(content)
407 .fail(function(err) {
408 throw that.normError(err, {
409 name: err.name || 'Langs Parse Error',
410 fileName: that.langsFile
411 });
412 });
413 });
414 })
415 .then(function(langs) {
416 that.langs = langs;
417 });
418};
419
420// Parse summary to extract list of chapters
421Book.prototype.parseSummary = function() {
422 var that = this;
423
424 var structure = that.config.getStructure('summary');
425 that.log.debug.ln('start parsing summary:', structure);
426
427 return that.findFile(structure)
428 .then(function(summary) {
429 if (!summary) throw 'No SUMMARY file';
430
431 // Remove the summary from the list of files to parse
432 that.summaryFile = summary.path;
433 that._defaultsStructure(that.summaryFile);
434 that.files = _.without(that.files, that.summaryFile);
435
436 that.log.debug.ln('summary located at', that.summaryFile);
437 return that.template.renderFile(that.summaryFile)
438 .then(function(content) {
439 return summary.parser.summary(content, {
440 entryPoint: that.readmeFile,
441 entryPointTitle: that.i18n('SUMMARY_INTRODUCTION'),
442 files: that.files
443 })
444 .fail(function(err) {
445 throw that.normError(err, {
446 name: err.name || 'Summary Parse Error',
447 fileName: that.summaryFile
448 });
449 });
450 });
451 })
452 .then(function(summary) {
453 that.summary = summary;
454 that.navigation = parseNavigation(that.summary, that.files);
455 });
456};
457
458// Parse glossary to extract terms
459Book.prototype.parseGlossary = function() {
460 var that = this;
461
462 var structure = that.config.getStructure('glossary');
463 that.log.debug.ln('start parsing glossary: ', structure);
464
465 return that.findFile(structure)
466 .then(function(glossary) {
467 if (!glossary) return [];
468
469 // Remove the glossary from the list of files to parse
470 that.glossaryFile = glossary.path;
471 that._defaultsStructure(that.glossaryFile);
472 that.files = _.without(that.files, that.glossaryFile);
473
474 that.log.debug.ln('glossary located at', that.glossaryFile);
475 return that.template.renderFile(that.glossaryFile)
476 .then(function(content) {
477 return glossary.parser.glossary(content)
478 .fail(function(err) {
479 throw that.normError(err, {
480 name: err.name || 'Glossary Parse Error',
481 fileName: that.glossaryFile
482 });
483 });
484 });
485 })
486 .then(function(glossary) {
487 that.glossary = glossary;
488 });
489};
490
491// Parse a page
492Book.prototype.parsePage = function(filename, options) {
493 var that = this, page = {};
494 options = _.defaults(options || {}, {
495 // Transform svg images
496 convertImages: false,
497
498 // Interpolate before templating
499 interpolateTemplate: _.identity,
500
501 // Interpolate after templating
502 interpolateContent: _.identity
503 });
504
505 var interpolate = function(fn) {
506 return Q(fn(page))
507 .then(function(_page) {
508 page = _page || page;
509 });
510 };
511
512 that.log.debug.ln('start parsing file', filename);
513
514 var extension = path.extname(filename);
515 var filetype = parsers.get(extension);
516
517 if (!filetype) return Q.reject(new Error('Can\'t parse file: '+filename));
518
519 // Type of parser used
520 page.type = filetype.name;
521
522 // Path relative to book
523 page.path = filename;
524
525 // Path absolute in the system
526 page.rawPath = path.resolve(that.root, filename);
527
528 // Progress in the book
529 page.progress = parseProgress(that.navigation, filename);
530
531 that.log.debug.ln('render template', filename);
532
533 // Read file content
534 return that.readFile(page.path)
535 .then(function(content) {
536 page.content = content;
537
538 return interpolate(options.interpolateTemplate);
539 })
540
541 // Prepare page markup
542 .then(function() {
543 return filetype.page.prepare(page.content)
544 .then(function(content) {
545 page.content = content;
546 });
547 })
548
549 // Generate template
550 .then(function() {
551 return that.template.renderPage(page);
552 })
553
554 // Prepare and Parse markup
555 .then(function(content) {
556 page.content = content;
557
558 that.log.debug.ln('use file parser', filetype.name, 'for', filename);
559 return filetype.page(page.content);
560 })
561
562 // Post process sections
563 .then(function(_page) {
564 return _.reduce(_page.sections, function(prev, section) {
565 return prev.then(function(_sections) {
566 return that.template.postProcess(section.content || '')
567 .then(function(content) {
568 section.content = content;
569 return _sections.concat([section]);
570 });
571 });
572 }, Q([]));
573 })
574
575 // Prepare html
576 .then(function(_sections) {
577 return pageUtil.normalize(_sections, {
578 book: that,
579 convertImages: options.convertImages,
580 input: filename,
581 navigation: that.navigation,
582 base: path.dirname(filename) || './',
583 output: path.dirname(filename) || './',
584 glossary: that.glossary
585 });
586 })
587
588 // Interpolate output
589 .then(function(_sections) {
590 page.sections = _sections;
591 return interpolate(options.interpolateContent);
592 })
593
594 .then(function() {
595 return page;
596 });
597};
598
599// Find file that can be parsed with a specific filename
600Book.prototype.findFile = function(filename) {
601 var that = this;
602
603 return _.reduce(parsers.extensions, function(prev, ext) {
604 return prev.then(function(output) {
605 // Stop if already find a parser
606 if (output) return output;
607
608 var filepath = filename+ext;
609
610 return that.fileExists(filepath)
611 .then(function(exists) {
612 if (!exists) return null;
613 return {
614 parser: parsers.get(ext),
615 path: filepath
616 };
617 });
618 });
619 }, Q(null));
620};
621
622// Format a string using a specific markup language
623Book.prototype.formatString = function(extension, content) {
624 return Q()
625 .then(function() {
626 var filetype = parsers.get(extension);
627 if (!filetype) throw new Error('Filetype doesn\'t exist: '+filetype);
628
629 return filetype.page(content);
630 })
631
632 // Merge sections
633 .then(function(page) {
634 return _.reduce(page.sections, function(content, section) {
635 return content + section.content;
636 }, '');
637 });
638};
639
640// Check if a file exists in the book
641Book.prototype.fileExists = function(filename) {
642 return fs.exists(
643 this.resolve(filename)
644 );
645};
646
647// Check if a file path is inside the book
648Book.prototype.fileIsInBook = function(filename) {
649 return pathUtil.isInRoot(this.root, filename);
650};
651
652// Read a file
653Book.prototype.readFile = function(filename) {
654 return fs.readFile(
655 this.resolve(filename),
656 { encoding: 'utf8' }
657 );
658};
659
660// Return stat for a file
661Book.prototype.statFile = function(filename) {
662 return fs.stat(this.resolve(filename));
663};
664
665// List all files in the book
666Book.prototype.listAllFiles = function() {
667 var that = this;
668
669 return fs.list(this.root, {
670 ignoreFiles: ['.ignore', '.gitignore', '.bookignore'],
671 ignoreRules: [
672 // Skip Git stuff
673 '.git/',
674 '.gitignore',
675
676 // Skip OS X meta data
677 '.DS_Store',
678
679 // Skip stuff installed by plugins
680 'node_modules',
681
682 // Skip book outputs
683 '_book',
684 '*.pdf',
685 '*.epub',
686 '*.mobi',
687
688 // Skip config files
689 '.ignore',
690 '.bookignore',
691 'book.json',
692 ]
693 })
694 .then(function(_files) {
695 that.files = _files;
696 });
697};
698
699// Return true if the book is a multilingual book
700Book.prototype.isMultilingual = function() {
701 return this.books.length > 0;
702};
703
704// Return root of the parent
705Book.prototype.parentRoot = function() {
706 if (this.parent) return this.parent.parentRoot();
707 return this.root;
708};
709
710// Return true if it's a sub-book
711Book.prototype.isSubBook = function() {
712 return !!this.parent;
713};
714
715// Test if the file is the entry point
716Book.prototype.isEntryPoint = function(fp) {
717 return fp == this.readmeFile;
718};
719
720// Alias to book.config.get
721Book.prototype.getConfig = function(key, def) {
722 return this.config.get(key, def);
723};
724
725// Resolve a path in the book source
726// Enforce that the output path in the root folder
727Book.prototype.resolve = function() {
728 return pathUtil.resolveInRoot.apply(null, [this.root].concat(_.toArray(arguments)));
729};
730
731// Convert an abslute path into a relative path to this
732Book.prototype.relative = function(p) {
733 return path.relative(this.root, p);
734};
735
736// Normalize a path to .html and convert README -> index
737Book.prototype.contentPath = function(link) {
738 if (
739 path.basename(link, path.extname(link)) == 'README' ||
740 link == this.readmeFile
741 ) {
742 link = path.join(path.dirname(link), 'index'+path.extname(link));
743 }
744
745 link = links.changeExtension(link, '.html');
746 return link;
747};
748
749// Normalize a link to .html and convert README -> index
750Book.prototype.contentLink = function(link) {
751 return links.normalize(this.contentPath(link));
752};
753
754// Default structure paths to an extension
755Book.prototype._defaultsStructure = function(filename) {
756 var that = this;
757 var extension = path.extname(filename);
758
759 that.readmeFile = that.readmeFile || that.config.getStructure('readme')+extension;
760 that.summaryFile = that.summaryFile || that.config.getStructure('summary')+extension;
761 that.glossaryFile = that.glossaryFile || that.config.getStructure('glossary')+extension;
762 that.langsFile = that.langsFile || that.config.getStructure('langs')+extension;
763};
764
765// Change output path
766Book.prototype.setOutput = function(p) {
767 var that = this;
768 this.options.output = path.resolve(p);
769
770 _.each(this.books, function(book) {
771 book.setOutput(path.join(that.options.output, book.options.language));
772 });
773};
774
775// Translate a strign according to the book language
776Book.prototype.i18n = function() {
777 var args = Array.prototype.slice.call(arguments);
778 return i18n.__.apply({}, [this.config.normalizeLanguage()].concat(args));
779};
780
781// Normalize error
782Book.prototype.normError = function(err, opts, defs) {
783 if (_.isString(err)) err = new Error(err);
784
785 // Extend err
786 _.extend(err, opts || {});
787 _.defaults(err, defs || {});
788
789 err.lineNumber = err.lineNumber || err.lineno;
790 err.columnNumber = err.columnNumber || err.colno;
791
792 err.toString = function() {
793 var attributes = [];
794
795 if (this.fileName) attributes.push('In file \''+this.fileName+'\'');
796 if (this.lineNumber) attributes.push('Line '+this.lineNumber);
797 if (this.columnNumber) attributes.push('Column '+this.columnNumber);
798 return (this.name || 'Error')+': '+this.message+((attributes.length > 0)? ' ('+attributes.join(', ')+')' : '');
799 };
800
801 return err;
802};
803
804// Call a hook in plugins
805Book.prototype.callHook = function(name, data) {
806 return this.plugins.hook(name, data);
807};
808
809module.exports= Book;