diff options
Diffstat (limited to 'lib/output')
70 files changed, 2518 insertions, 1353 deletions
diff --git a/lib/output/__tests__/ebook.js b/lib/output/__tests__/ebook.js new file mode 100644 index 0000000..dabf360 --- /dev/null +++ b/lib/output/__tests__/ebook.js @@ -0,0 +1,16 @@ +var generateMock = require('../generateMock'); +var EbookGenerator = require('../ebook'); + +describe('EbookGenerator', function() { + + pit('should generate a SUMMARY.html', function() { + return generateMock(EbookGenerator, { + 'README.md': 'Hello World' + }) + .then(function(folder) { + expect(folder).toHaveFile('SUMMARY.html'); + expect(folder).toHaveFile('index.html'); + }); + }); +}); + diff --git a/lib/output/__tests__/json.js b/lib/output/__tests__/json.js new file mode 100644 index 0000000..94a0362 --- /dev/null +++ b/lib/output/__tests__/json.js @@ -0,0 +1,29 @@ +var generateMock = require('../generateMock'); +var JSONGenerator = require('../json'); + +describe('JSONGenerator', function() { + + pit('should generate a README.json', function() { + return generateMock(JSONGenerator, { + 'README.md': 'Hello World' + }) + .then(function(folder) { + expect(folder).toHaveFile('README.json'); + }); + }); + + pit('should generate a json file for each articles', function() { + return generateMock(JSONGenerator, { + 'README.md': 'Hello World', + 'SUMMARY.md': '# Summary\n\n* [Page](test/page.md)', + 'test': { + 'page.md': 'Hello 2' + } + }) + .then(function(folder) { + expect(folder).toHaveFile('README.json'); + expect(folder).toHaveFile('test/page.json'); + }); + }); +}); + diff --git a/lib/output/__tests__/website.js b/lib/output/__tests__/website.js new file mode 100644 index 0000000..6b949a4 --- /dev/null +++ b/lib/output/__tests__/website.js @@ -0,0 +1,71 @@ +var generateMock = require('../generateMock'); +var WebsiteGenerator = require('../website'); + +describe('WebsiteGenerator', function() { + + pit('should generate an index.html', function() { + return generateMock(WebsiteGenerator, { + 'README.md': 'Hello World' + }) + .then(function(folder) { + expect(folder).toHaveFile('index.html'); + }); + }); + + pit('should generate an HTML file for each articles', function() { + return generateMock(WebsiteGenerator, { + 'README.md': 'Hello World', + 'SUMMARY.md': '# Summary\n\n* [Page](test/page.md)', + 'test': { + 'page.md': 'Hello 2' + } + }) + .then(function(folder) { + expect(folder).toHaveFile('index.html'); + expect(folder).toHaveFile('test/page.html'); + }); + }); + + pit('should not generate file if entry file doesn\'t exist', function() { + return generateMock(WebsiteGenerator, { + 'README.md': 'Hello World', + 'SUMMARY.md': '# Summary\n\n* [Page 1](page.md)\n* [Page 2](test/page.md)', + 'test': { + 'page.md': 'Hello 2' + } + }) + .then(function(folder) { + expect(folder).toHaveFile('index.html'); + expect(folder).not.toHaveFile('page.html'); + expect(folder).toHaveFile('test/page.html'); + }); + }); + + pit('should generate a multilingual book', function() { + return generateMock(WebsiteGenerator, { + 'LANGS.md': '# Languages\n\n* [en](en)\n* [fr](fr)', + 'en': { + 'README.md': 'Hello' + }, + 'fr': { + 'README.md': 'Bonjour' + } + }) + .then(function(folder) { + // It should generate languages + expect(folder).toHaveFile('en/index.html'); + expect(folder).toHaveFile('fr/index.html'); + + // Should not copy languages as assets + expect(folder).not.toHaveFile('en/README.md'); + expect(folder).not.toHaveFile('fr/README.md'); + + // Should copy assets only once + expect(folder).toHaveFile('gitbook/style.css'); + expect(folder).not.toHaveFile('en/gitbook/style.css'); + + expect(folder).toHaveFile('index.html'); + }); + }); +}); + diff --git a/lib/output/assets-inliner.js b/lib/output/assets-inliner.js deleted file mode 100644 index 6f1f02d..0000000 --- a/lib/output/assets-inliner.js +++ /dev/null @@ -1,140 +0,0 @@ -var util = require('util'); -var path = require('path'); -var crc = require('crc'); - -var FolderOutput = require('./folder')(); -var Promise = require('../utils/promise'); -var fs = require('../utils/fs'); -var imagesUtil = require('../utils/images'); -var location = require('../utils/location'); - -var DEFAULT_ASSETS_FOLDER = 'assets'; - -/* -Mixin to inline all the assets in a book: - - Outline <svg> tags - - Download remote images - - Convert .svg images as png -*/ - -module.exports = function assetsInliner(Base) { - Base = Base || FolderOutput; - - function AssetsInliner() { - Base.apply(this, arguments); - - // Map of svg already converted - this.svgs = {}; - this.inlineSvgs = {}; - - // Map of images already downloaded - this.downloaded = {}; - } - util.inherits(AssetsInliner, Base); - - // Output a SVG buffer as a file - AssetsInliner.prototype.onOutputSVG = function(page, svg) { - this.log.debug.ln('output svg from', page.path); - - // Convert svg buffer to a png file - return this.convertSVGBuffer(svg) - - // Return relative path from the page - .then(function(filename) { - return page.relative('/' + filename); - }); - }; - - - // Output an image as a file - AssetsInliner.prototype.onOutputImage = function(page, src) { - var that = this; - - return Promise() - - // Download file if external - .then(function() { - if (!location.isExternal(src)) return; - - return that.downloadAsset(src) - .then(function(_asset) { - src = '/' + _asset; - }); - - }) - .then(function() { - // Resolve src to a relative filepath to the book's root - src = page.resolveLocal(src); - - // Already a PNG/JPG/.. ? - if (path.extname(src).toLowerCase() != '.svg') { - return src; - } - - // Convert SVG to PNG - return that.convertSVGFile(that.resolve(src)); - }) - - // Return relative path from the page - .then(function(filename) { - return page.relative(filename); - }); - }; - - // Download an asset if not already download; returns the output file - AssetsInliner.prototype.downloadAsset = function(src) { - if (this.downloaded[src]) return Promise(this.downloaded[src]); - - var that = this; - var ext = path.extname(src); - var hash = crc.crc32(src).toString(16); - - // Create new file - return this.createNewFile(DEFAULT_ASSETS_FOLDER, hash + ext) - .then(function(filename) { - that.downloaded[src] = filename; - - that.log.debug.ln('downloading asset', src); - return fs.download(src, that.resolve(filename)) - .thenResolve(filename); - }); - }; - - // Convert a .svg into an .png - // Return the output filename for the .png - AssetsInliner.prototype.convertSVGFile = function(src) { - if (this.svgs[src]) return Promise(this.svgs[src]); - - var that = this; - var hash = crc.crc32(src).toString(16); - - // Create new file - return this.createNewFile(DEFAULT_ASSETS_FOLDER, hash + '.png') - .then(function(filename) { - that.svgs[src] = filename; - - return imagesUtil.convertSVGToPNG(src, that.resolve(filename)) - .thenResolve(filename); - }); - }; - - // Convert an inline svg into an .png - // Return the output filename for the .png - AssetsInliner.prototype.convertSVGBuffer = function(buf) { - var that = this; - var hash = crc.crc32(buf).toString(16); - - // Already converted? - if (this.inlineSvgs[hash]) return Promise(this.inlineSvgs[hash]); - - return this.createNewFile(DEFAULT_ASSETS_FOLDER, hash + '.png') - .then(function(filename) { - that.inlineSvgs[hash] = filename; - - return imagesUtil.convertSVGBufferToPNG(buf, that.resolve(filename)) - .thenResolve(filename); - }); - }; - - return AssetsInliner; -}; diff --git a/lib/output/base.js b/lib/output/base.js deleted file mode 100644 index 868b85b..0000000 --- a/lib/output/base.js +++ /dev/null @@ -1,309 +0,0 @@ -var _ = require('lodash'); -var Ignore = require('ignore'); -var path = require('path'); - -var Promise = require('../utils/promise'); -var pathUtil = require('../utils/path'); -var location = require('../utils/location'); -var error = require('../utils/error'); -var PluginsManager = require('../plugins'); -var TemplateEngine = require('../template'); -var gitbook = require('../gitbook'); - -/* -Output is like a stream interface for a parsed book -to output "something". - -The process is mostly on the behavior of "onPage" and "onAsset" -*/ - -function Output(book, opts, parent) { - _.bindAll(this); - this.parent = parent; - - this.opts = _.defaults({}, opts || {}, { - directoryIndex: true - }); - - this.book = book; - book.output = this; - this.log = this.book.log; - - // Create plugins manager - this.plugins = new PluginsManager(this.book); - - // Create template engine - this.template = new TemplateEngine(this); - - // Files to ignore in output - this.ignore = Ignore(); - - // Hack to inherits from rules of the book - this.ignore.add(this.book.ignore); -} - -// Default name for generator -Output.prototype.name = 'base'; - -// Default extension for output -Output.prototype.defaultExtension = '.html'; - -// Start the generation, for a parsed book -Output.prototype.generate = function() { - var that = this; - var isMultilingual = this.book.isMultilingual(); - - return Promise() - - // Load all plugins - .then(function() { - return that.plugins.loadAll() - .then(function() { - that.template.addFilters(that.plugins.getFilters()); - that.template.addBlocks(that.plugins.getBlocks()); - }); - }) - - // Transform the configuration - .then(function() { - return that.plugins.hook('config', that.book.config.dump()) - .then(function(cfg) { - that.book.config.replace(cfg); - }); - }) - - // Initialize the generation - .then(function() { - return that.plugins.hook('init'); - }) - .then(function() { - that.log.info.ln('preparing the generation'); - return that.prepare(); - }) - - // Process all files - .then(function() { - that.log.debug.ln('listing files'); - return that.book.fs.listAllFiles(that.book.root); - }) - - // We want to process assets first, then pages - // Since pages can have logic based on existance of assets - .then(function(files) { - // Split into pages/assets - var byTypes = _.chain(files) - .filter(that.ignore.createFilter()) - - // Ignore file present in a language book - .filter(function(filename) { - return !(isMultilingual && that.book.isInLanguageBook(filename)); - }) - - .groupBy(function(filename) { - return (that.book.hasPage(filename)? 'page' : 'asset'); - }) - - .value(); - - return Promise.serie(byTypes.asset, function(filename) { - that.log.debug.ln('copy asset', filename); - return that.onAsset(filename); - }) - .then(function() { - return Promise.serie(byTypes.page, function(filename) { - that.log.debug.ln('process page', filename); - return that.onPage(that.book.getPage(filename)); - }); - }); - }) - - // Generate sub-books - .then(function() { - if (!that.book.isMultilingual()) return; - - return Promise.serie(that.book.books, function(subbook) { - that.log.info.ln(''); - that.log.info.ln('start generation of language "' + path.relative(that.book.root, subbook.root) + '"'); - - var out = that.onLanguageBook(subbook); - return out.generate(); - }); - }) - - // Finish the generation - .then(function() { - return that.plugins.hook('finish:before'); - }) - .then(function() { - that.log.debug.ln('finishing the generation'); - return that.finish(); - }) - .then(function() { - return that.plugins.hook('finish'); - }) - - .then(function() { - if (!that.book.isLanguageBook()) that.log.info.ln(''); - that.log.info.ok('generation finished with success!'); - }); -}; - -// Prepare the generation -Output.prototype.prepare = function() { - this.ignore.addPattern(_.compact([ - '.gitignore', - '.ignore', - '.bookignore', - 'node_modules', - '_layouts', - - // The configuration file should not be copied in the output - '/' + this.book.config.path, - - // Structure file to ignore - '/' + this.book.summary.path, - '/' + this.book.langs.path - ])); -}; - -// Write a page (parsable file), ex: markdown, etc -Output.prototype.onPage = function(page) { - return page.toHTML(this); -}; - -// Copy an asset file (non-parsable), ex: images, etc -Output.prototype.onAsset = function(filename) { - -}; - -// Finish the generation -Output.prototype.finish = function() { - -}; - -// Resolve an HTML link -Output.prototype.onRelativeLink = function(currentPage, href) { - var to = currentPage.followPage(href); - - // Replace by an .html link - if (to) { - href = to.path; - - // Change README path to be "index.html" - if (href == this.book.readme.path) { - href = 'index.html'; - } - - // Recalcul as relative link - href = currentPage.relative(href); - - // Replace .md by .html - href = this.toURL(href); - } - - return href; -}; - -// Output a SVG buffer as a file -Output.prototype.onOutputSVG = function(page, svg) { - return null; -}; - -// Output an image as a file -// Normalize the relative link -Output.prototype.onOutputImage = function(page, imgFile) { - if (location.isExternal(imgFile)) { - return imgFile; - } - - imgFile = page.resolveLocal(imgFile); - return page.relative(imgFile); -}; - -// Read a template by its source URL -Output.prototype.onGetTemplate = function(sourceUrl) { - throw new Error('template not found '+sourceUrl); -}; - -// Generate a source URL for a template -Output.prototype.onResolveTemplate = function(from, to) { - return path.resolve(path.dirname(from), to); -}; - -// Prepare output for a language book -Output.prototype.onLanguageBook = function(book) { - return new this.constructor(book, this.opts, this); -}; - - -// ---- Utilities ---- - -// Return conetxt for the output itself -Output.prototype.getSelfContext = function() { - return { - name: this.name - }; -}; - -// Return a default context for templates -Output.prototype.getContext = function() { - var ctx = _.extend( - { - output: this.getSelfContext() - }, - this.book.getContext(), - (this.book.isLanguageBook()? this.book.parent: this.book).langs.getContext(), - this.book.readme.getContext(), - this.book.summary.getContext(), - this.book.glossary.getContext(), - this.book.config.getContext(), - gitbook.getContext() - ); - - // Deprecated fields - error.deprecateField(ctx.gitbook, 'generator', this.name, '"gitbook.generator" property is deprecated, use "output.name" instead'); - - return ctx; -}; - -// Resolve a file path in the context of a specific page -// Result is an "absolute path relative to the output folder" -Output.prototype.resolveForPage = function(page, href) { - if (_.isString(page)) page = this.book.getPage(page); - - href = page.relative(href); - return this.onRelativeLink(page, href); -}; - -// Filename for output -// READMEs are replaced by index.html -// /test/README.md -> /test/index.html -Output.prototype.outputPath = function(filename, ext) { - ext = ext || this.defaultExtension; - var output = filename; - - if ( - path.basename(filename, path.extname(filename)) == 'README' || - output == this.book.readme.path - ) { - output = path.join(path.dirname(output), 'index'+ext); - } else { - output = pathUtil.setExtension(output, ext); - } - - return output; -}; - -// Filename for output -// /test/index.html -> /test/ -Output.prototype.toURL = function(filename, ext) { - var href = this.outputPath(filename, ext); - - if (path.basename(href) == 'index.html' && this.opts.directoryIndex) { - href = path.dirname(href) + '/'; - } - - return location.normalize(href); -}; - -module.exports = Output; diff --git a/lib/output/callHook.js b/lib/output/callHook.js new file mode 100644 index 0000000..4914e52 --- /dev/null +++ b/lib/output/callHook.js @@ -0,0 +1,60 @@ +var Promise = require('../utils/promise'); +var timing = require('../utils/timing'); +var Api = require('../api'); + +function defaultGetArgument() { + return undefined; +} + +function defaultHandleResult(output, result) { + return output; +} + +/** + Call a "global" hook for an output + + @param {String} name + @param {Function(Output) -> Mixed} getArgument + @param {Function(Output, result) -> Output} handleResult + @param {Output} output + @return {Promise<Output>} +*/ +function callHook(name, getArgument, handleResult, output) { + getArgument = getArgument || defaultGetArgument; + handleResult = handleResult || defaultHandleResult; + + var logger = output.getLogger(); + var plugins = output.getPlugins(); + + logger.debug.ln('calling hook "' + name + '"'); + + // Create the JS context for plugins + var context = Api.encodeGlobal(output); + + return timing.measure( + 'call.hook.' + name, + + // Get the arguments + Promise(getArgument(output)) + + // Call the hooks in serie + .then(function(arg) { + return Promise.reduce(plugins, function(prev, plugin) { + var hook = plugin.getHook(name); + if (!hook) { + return prev; + } + + return hook.call(context, prev); + }, arg); + }) + + // Handle final result + .then(function(result) { + output = Api.decodeGlobal(output, context); + return handleResult(output, result); + }) + ); +} + +module.exports = callHook; diff --git a/lib/output/callPageHook.js b/lib/output/callPageHook.js new file mode 100644 index 0000000..c66cef0 --- /dev/null +++ b/lib/output/callPageHook.js @@ -0,0 +1,28 @@ +var Api = require('../api'); +var callHook = require('./callHook'); + +/** + Call a hook for a specific page + + @param {String} name + @param {Output} output + @param {Page} page + @return {Promise<Page>} +*/ +function callPageHook(name, output, page) { + return callHook( + name, + + function(out) { + return Api.encodePage(out, page); + }, + + function(out, result) { + return Api.decodePage(out, page, result); + }, + + output + ); +} + +module.exports = callPageHook; diff --git a/lib/output/conrefs.js b/lib/output/conrefs.js deleted file mode 100644 index e58f836..0000000 --- a/lib/output/conrefs.js +++ /dev/null @@ -1,67 +0,0 @@ -var path = require('path'); -var util = require('util'); - -var folderOutput = require('./folder'); -var Git = require('../utils/git'); -var fs = require('../utils/fs'); -var pathUtil = require('../utils/path'); -var location = require('../utils/location'); - -/* -Mixin for output to resolve git conrefs -*/ - -module.exports = function conrefsLoader(Base) { - Base = folderOutput(Base); - - function ConrefsLoader() { - Base.apply(this, arguments); - - this.git = new Git(); - } - util.inherits(ConrefsLoader, Base); - - // Read a template by its source URL - ConrefsLoader.prototype.onGetTemplate = function(sourceURL) { - var that = this; - - return this.git.resolve(sourceURL) - .then(function(filepath) { - // Is local file - if (!filepath) { - filepath = that.book.resolve(sourceURL); - } else { - that.book.log.debug.ln('resolve from git', sourceURL, 'to', filepath); - } - - // Read file from absolute path - return fs.readFile(filepath) - .then(function(source) { - return { - src: source.toString('utf8'), - path: filepath - }; - }); - }); - }; - - // Generate a source URL for a template - ConrefsLoader.prototype.onResolveTemplate = function(from, to) { - // If origin is in the book, we enforce result file to be in the book - if (this.book.isInBook(from)) { - var href = location.toAbsolute(to, path.dirname(from), ''); - return this.book.resolve(href); - } - - // If origin is in a git repository, we resolve file in the git repository - var gitRoot = this.git.resolveRoot(from); - if (gitRoot) { - return pathUtil.resolveInRoot(gitRoot, to); - } - - // If origin is not in the book (include from a git content ref) - return path.resolve(path.dirname(from), to); - }; - - return ConrefsLoader; -}; diff --git a/lib/output/createTemplateEngine.js b/lib/output/createTemplateEngine.js new file mode 100644 index 0000000..37b3c27 --- /dev/null +++ b/lib/output/createTemplateEngine.js @@ -0,0 +1,44 @@ +var Templating = require('../templating'); +var TemplateEngine = require('../models/templateEngine'); + +var Api = require('../api'); +var Plugins = require('../plugins'); + +var defaultBlocks = require('../constants/defaultBlocks'); +var defaultFilters = require('../constants/defaultFilters'); + +/** + Create template engine for an output. + It adds default filters/blocks, then add the ones from plugins + + @param {Output} output + @return {TemplateEngine} +*/ +function createTemplateEngine(output) { + var plugins = output.getPlugins(); + var book = output.getBook(); + var rootFolder = book.getContentRoot(); + var logger = book.getLogger(); + + var filters = Plugins.listFilters(plugins); + var blocks = Plugins.listBlocks(plugins); + + // Extend with default + blocks = defaultBlocks.merge(blocks); + filters = defaultFilters.merge(filters); + + // Create loader + var loader = new Templating.ConrefsLoader(rootFolder, logger); + + // Create API context + var context = Api.encodeGlobal(output); + + return new TemplateEngine({ + filters: filters, + blocks: blocks, + loader: loader, + context: context + }); +} + +module.exports = createTemplateEngine; diff --git a/lib/output/ebook.js b/lib/output/ebook.js deleted file mode 100644 index 2b8fac9..0000000 --- a/lib/output/ebook.js +++ /dev/null @@ -1,193 +0,0 @@ -var _ = require('lodash'); -var util = require('util'); -var juice = require('juice'); - -var command = require('../utils/command'); -var fs = require('../utils/fs'); -var Promise = require('../utils/promise'); -var error = require('../utils/error'); -var WebsiteOutput = require('./website'); -var assetsInliner = require('./assets-inliner'); - -function _EbookOutput() { - WebsiteOutput.apply(this, arguments); - - // ebook-convert does not support link like "./" - this.opts.directoryIndex = false; -} -util.inherits(_EbookOutput, WebsiteOutput); - -var EbookOutput = assetsInliner(_EbookOutput); - -EbookOutput.prototype.name = 'ebook'; - -// Return context for templating -// Incldue type of ebbook generated -EbookOutput.prototype.getSelfContext = function() { - var ctx = EbookOutput.super_.prototype.getSelfContext.apply(this); - ctx.format = this.opts.format; - - return ctx; -}; - -// Finish generation, create ebook using ebook-convert -EbookOutput.prototype.finish = function() { - var that = this; - if (that.book.isMultilingual()) { - return EbookOutput.super_.prototype.finish.apply(that); - } - - return Promise() - .then(function() { - return EbookOutput.super_.prototype.finish.apply(that); - }) - - // Generate SUMMARY.html - .then(function() { - return that.render('summary', 'SUMMARY.html', that.getContext()); - }) - - // Start ebook-convert - .then(function() { - return that.ebookConvertOption(); - }) - - .then(function(options) { - if (!that.opts.format) return; - - var cmd = [ - 'ebook-convert', - that.resolve('SUMMARY.html'), - that.resolve('index.'+that.opts.format), - command.optionsToShellArgs(options) - ].join(' '); - - return command.exec(cmd) - .progress(function(data) { - that.book.log.debug(data); - }) - .fail(function(err) { - if (err.code == 127) { - throw error.RequireInstallError({ - cmd: 'ebook-convert', - install: 'Install it from Calibre: https://calibre-ebook.com' - }); - } - - throw error.EbookError(err); - }); - }); -}; - -// Generate header/footer for PDF -EbookOutput.prototype.getPDFTemplate = function(tpl) { - var that = this; - var context = _.extend( - { - // Nunjucks context mapping to ebook-convert templating - page: { - num: '_PAGENUM_', - title: '_TITLE_', - section: '_SECTION_' - } - }, - this.getContext() - ); - - return this.renderAsString('pdf_'+tpl, context) - - // Inline css, include css relative to the output folder - .then(function(output) { - return Promise.nfcall(juice.juiceResources, output, { - webResources: { - relativeTo: that.root() - } - }); - }); -}; - -// Locate the cover file to use -// Use configuration or search a "cover.jpg" file -// For multi-lingual book, it can use the one from the main book -EbookOutput.prototype.locateCover = function() { - var cover = this.book.config.get('cover', 'cover.jpg'); - - // Resolve to absolute - cover = this.resolve(cover); - - // Cover doesn't exist and multilingual? - if (!fs.existsSync(cover)) { - if (this.parent) return this.parent.locateCover(); - else return undefined; - } - - return cover; -}; - -// Generate options for ebook-convert -EbookOutput.prototype.ebookConvertOption = function() { - var that = this; - - var options = { - '--cover': this.locateCover(), - '--title': that.book.config.get('title'), - '--comments': that.book.config.get('description'), - '--isbn': that.book.config.get('isbn'), - '--authors': that.book.config.get('author'), - '--language': that.book.config.get('language'), - '--book-producer': 'GitBook', - '--publisher': 'GitBook', - '--chapter': 'descendant-or-self::*[contains(concat(\' \', normalize-space(@class), \' \'), \' book-chapter \')]', - '--level1-toc': 'descendant-or-self::*[contains(concat(\' \', normalize-space(@class), \' \'), \' book-chapter-1 \')]', - '--level2-toc': 'descendant-or-self::*[contains(concat(\' \', normalize-space(@class), \' \'), \' book-chapter-2 \')]', - '--level3-toc': 'descendant-or-self::*[contains(concat(\' \', normalize-space(@class), \' \'), \' book-chapter-3 \')]', - '--no-chapters-in-toc': true, - '--max-levels': '1', - '--breadth-first': true - }; - - if (that.opts.format == 'epub') { - options = _.extend(options, { - '--dont-split-on-page-breaks': true - }); - } - - if (that.opts.format != 'pdf') return Promise(options); - - var pdfOptions = that.book.config.get('pdf'); - - options = _.extend(options, { - '--chapter-mark': String(pdfOptions.chapterMark), - '--page-breaks-before': String(pdfOptions.pageBreaksBefore), - '--margin-left': String(pdfOptions.margin.left), - '--margin-right': String(pdfOptions.margin.right), - '--margin-top': String(pdfOptions.margin.top), - '--margin-bottom': String(pdfOptions.margin.bottom), - '--pdf-default-font-size': String(pdfOptions.fontSize), - '--pdf-mono-font-size': String(pdfOptions.fontSize), - '--paper-size': String(pdfOptions.paperSize), - '--pdf-page-numbers': Boolean(pdfOptions.pageNumbers), - '--pdf-header-template': that.getPDFTemplate('header'), - '--pdf-footer-template': that.getPDFTemplate('footer'), - '--pdf-sans-family': String(pdfOptions.fontFamily) - }); - - return that.getPDFTemplate('header') - .then(function(tpl) { - options['--pdf-header-template'] = tpl; - - return that.getPDFTemplate('footer'); - }) - .then(function(tpl) { - options['--pdf-footer-template'] = tpl; - - return options; - }); -}; - -// Don't write multi-lingual index for wbook -EbookOutput.prototype.outputMultilingualIndex = function() { - -}; - -module.exports = EbookOutput; diff --git a/lib/output/ebook/getConvertOptions.js b/lib/output/ebook/getConvertOptions.js new file mode 100644 index 0000000..bc80493 --- /dev/null +++ b/lib/output/ebook/getConvertOptions.js @@ -0,0 +1,73 @@ +var extend = require('extend'); + +var Promise = require('../../utils/promise'); +var getPDFTemplate = require('./getPDFTemplate'); +var getCoverPath = require('./getCoverPath'); + +/** + Generate options for ebook-convert + + @param {Output} + @return {Promise<Object>} +*/ +function getConvertOptions(output) { + var options = output.getOptions(); + var format = options.get('format'); + + var book = output.getBook(); + var config = book.getConfig(); + + return Promise() + .then(function() { + var coverPath = getCoverPath(output); + var options = { + '--cover': coverPath, + '--title': config.getValue('title'), + '--comments': config.getValue('description'), + '--isbn': config.getValue('isbn'), + '--authors': config.getValue('author'), + '--language': book.getLanguage() || config.getValue('language'), + '--book-producer': 'GitBook', + '--publisher': 'GitBook', + '--chapter': 'descendant-or-self::*[contains(concat(\' \', normalize-space(@class), \' \'), \' book-chapter \')]', + '--level1-toc': 'descendant-or-self::*[contains(concat(\' \', normalize-space(@class), \' \'), \' book-chapter-1 \')]', + '--level2-toc': 'descendant-or-self::*[contains(concat(\' \', normalize-space(@class), \' \'), \' book-chapter-2 \')]', + '--level3-toc': 'descendant-or-self::*[contains(concat(\' \', normalize-space(@class), \' \'), \' book-chapter-3 \')]', + '--max-levels': '1', + '--no-chapters-in-toc': true, + '--breadth-first': true, + '--dont-split-on-page-breaks': format === 'epub'? true : undefined + }; + + if (format !== 'pdf') { + return options; + } + + return Promise.all([ + getPDFTemplate(output, 'header'), + getPDFTemplate(output, 'footer') + ]) + .spread(function(headerTpl, footerTpl) { + var pdfOptions = config.getValue('pdf').toJS(); + + return options = extend(options, { + '--chapter-mark': String(pdfOptions.chapterMark), + '--page-breaks-before': String(pdfOptions.pageBreaksBefore), + '--margin-left': String(pdfOptions.margin.left), + '--margin-right': String(pdfOptions.margin.right), + '--margin-top': String(pdfOptions.margin.top), + '--margin-bottom': String(pdfOptions.margin.bottom), + '--pdf-default-font-size': String(pdfOptions.fontSize), + '--pdf-mono-font-size': String(pdfOptions.fontSize), + '--paper-size': String(pdfOptions.paperSize), + '--pdf-page-numbers': Boolean(pdfOptions.pageNumbers), + '--pdf-sans-family': String(pdfOptions.fontFamily), + '--pdf-header-template': headerTpl, + '--pdf-footer-template': footerTpl + }); + }); + }); +} + + +module.exports = getConvertOptions; diff --git a/lib/output/ebook/getCoverPath.js b/lib/output/ebook/getCoverPath.js new file mode 100644 index 0000000..c2192d4 --- /dev/null +++ b/lib/output/ebook/getCoverPath.js @@ -0,0 +1,30 @@ +var path = require('path'); +var fs = require('../../utils/fs'); + +/** + Resolve path to cover file to use + + @param {Output} + @return {String} +*/ +function getCoverPath(output) { + var outputRoot = output.getRoot(); + var book = output.getBook(); + var config = book.getConfig(); + var cover = config.getValue('cover', 'cover.jpg'); + + // Resolve to absolute + cover = fs.pickFile(outputRoot, cover); + if (cover) { + return cover; + } + + // Multilingual? try parent folder + if (book.isLanguageBook()) { + cover = fs.pickFile(path.join(outputRoot, '..'), cover); + } + + return cover; +} + +module.exports = getCoverPath; diff --git a/lib/output/ebook/getPDFTemplate.js b/lib/output/ebook/getPDFTemplate.js new file mode 100644 index 0000000..f7a450d --- /dev/null +++ b/lib/output/ebook/getPDFTemplate.js @@ -0,0 +1,42 @@ +var juice = require('juice'); + +var WebsiteGenerator = require('../website'); +var JSONUtils = require('../../json'); +var Templating = require('../../templating'); +var Promise = require('../../utils/promise'); + + +/** + Generate PDF header/footer templates + + @param {Output} output + @param {String} type + @return {String} +*/ +function getPDFTemplate(output, type) { + var filePath = 'pdf_' + type + '.html'; + var outputRoot = output.getRoot(); + var engine = WebsiteGenerator.createTemplateEngine(output, filePath); + + // Generate context + var context = JSONUtils.encodeOutput(output); + context.page = { + num: '_PAGENUM_', + title: '_TITLE_', + section: '_SECTION_' + }; + + // Render the theme + return Templating.renderFile(engine, 'ebook/' + filePath, context) + + // Inline css and assets + .then(function(html) { + return Promise.nfcall(juice.juiceResources, html, { + webResources: { + relativeTo: outputRoot + } + }); + }); +} + +module.exports = getPDFTemplate; diff --git a/lib/output/ebook/index.js b/lib/output/ebook/index.js new file mode 100644 index 0000000..786a10a --- /dev/null +++ b/lib/output/ebook/index.js @@ -0,0 +1,9 @@ +var extend = require('extend'); +var WebsiteGenerator = require('../website'); + +module.exports = extend({}, WebsiteGenerator, { + name: 'ebook', + Options: require('./options'), + onPage: require('./onPage'), + onFinish: require('./onFinish') +}); diff --git a/lib/output/ebook/onFinish.js b/lib/output/ebook/onFinish.js new file mode 100644 index 0000000..17a8e5e --- /dev/null +++ b/lib/output/ebook/onFinish.js @@ -0,0 +1,90 @@ +var path = require('path'); + +var WebsiteGenerator = require('../website'); +var JSONUtils = require('../../json'); +var Templating = require('../../templating'); +var Promise = require('../../utils/promise'); +var error = require('../../utils/error'); +var command = require('../../utils/command'); +var writeFile = require('../helper/writeFile'); + +var getConvertOptions = require('./getConvertOptions'); + +/** + Write the SUMMARY.html + + @param {Output} + @return {Output} +*/ +function writeSummary(output) { + var options = output.getOptions(); + var prefix = options.get('prefix'); + + var filePath = 'SUMMARY.html'; + var engine = WebsiteGenerator.createTemplateEngine(output, filePath); + var context = JSONUtils.encodeOutput(output); + + // Render the theme + return Templating.renderFile(engine, prefix + '/SUMMARY.html', context) + + // Write it to the disk + .then(function(html) { + return writeFile(output, filePath, html); + }); +} + +/** + Generate the ebook file as "index.pdf" + + @param {Output} + @return {Output} +*/ +function runEbookConvert(output) { + var logger = output.getLogger(); + var options = output.getOptions(); + var format = options.get('format'); + var outputFolder = output.getRoot(); + + if (!format) { + return Promise(output); + } + + return getConvertOptions(output) + .then(function(options) { + var cmd = [ + 'ebook-convert', + path.resolve(outputFolder, 'SUMMARY.html'), + path.resolve(outputFolder, 'index.' + format), + command.optionsToShellArgs(options) + ].join(' '); + + return command.exec(cmd) + .progress(function(data) { + logger.debug(data); + }) + .fail(function(err) { + if (err.code == 127) { + throw error.RequireInstallError({ + cmd: 'ebook-convert', + install: 'Install it from Calibre: https://calibre-ebook.com' + }); + } + + throw error.EbookError(err); + }); + }) + .thenResolve(output); +} + +/** + Finish the generation, generates the SUMMARY.html + + @param {Output} + @return {Output} +*/ +function onFinish(output) { + return writeSummary(output) + .then(runEbookConvert); +} + +module.exports = onFinish; diff --git a/lib/output/ebook/onPage.js b/lib/output/ebook/onPage.js new file mode 100644 index 0000000..21fd34c --- /dev/null +++ b/lib/output/ebook/onPage.js @@ -0,0 +1,24 @@ +var WebsiteGenerator = require('../website'); +var Modifiers = require('../modifiers'); + +/** + Write a page for ebook output + + @param {Output} output + @param {Output} +*/ +function onPage(output, page) { + var options = output.getOptions(); + + // Inline assets + return Modifiers.modifyHTML(page, [ + Modifiers.inlineAssets(options.get('root')) + ]) + + // Write page using website generator + .then(function(resultPage) { + return WebsiteGenerator.onPage(output, resultPage); + }); +} + +module.exports = onPage; diff --git a/lib/output/ebook/options.js b/lib/output/ebook/options.js new file mode 100644 index 0000000..ea7b8b4 --- /dev/null +++ b/lib/output/ebook/options.js @@ -0,0 +1,17 @@ +var Immutable = require('immutable'); + +var Options = Immutable.Record({ + // Root folder for the output + root: String(), + + // Prefix for generation + prefix: String('ebook'), + + // Format to generate using ebook-convert + format: String(), + + // Force use of absolute urls ("index.html" instead of "/") + directoryIndex: Boolean(false) +}); + +module.exports = Options; diff --git a/lib/output/folder.js b/lib/output/folder.js deleted file mode 100644 index 8303ed2..0000000 --- a/lib/output/folder.js +++ /dev/null @@ -1,152 +0,0 @@ -var _ = require('lodash'); -var util = require('util'); -var path = require('path'); - -var Output = require('./base'); -var fs = require('../utils/fs'); -var pathUtil = require('../utils/path'); -var Promise = require('../utils/promise'); - -/* -This output requires the native fs module to output -book as a directory (mapping assets and pages) -*/ - -module.exports = function folderOutput(Base) { - Base = Base || Output; - - function FolderOutput() { - Base.apply(this, arguments); - - this.opts.root = path.resolve(this.opts.root || this.book.resolve('_book')); - } - util.inherits(FolderOutput, Base); - - // Copy an asset file (non-parsable), ex: images, etc - FolderOutput.prototype.onAsset = function(filename) { - return this.copyFile( - this.book.resolve(filename), - filename - ); - }; - - // Prepare the generation by creating the output folder - FolderOutput.prototype.prepare = function() { - var that = this; - - return Promise() - .then(function() { - return FolderOutput.super_.prototype.prepare.apply(that); - }) - - // Cleanup output folder - .then(function() { - that.log.debug.ln('removing previous output directory'); - return fs.rmDir(that.root()) - .fail(function() { - return Promise(); - }); - }) - - // Create output folder - .then(function() { - that.log.debug.ln('creating output directory'); - return fs.mkdirp(that.root()); - }) - - // Add output folder to ignored files - .then(function() { - that.ignore.addPattern([ - path.relative(that.book.root, that.root()) - ]); - }); - }; - - // Prepare output for a language book - FolderOutput.prototype.onLanguageBook = function(book) { - return new this.constructor(book, _.extend({}, this.opts, { - - // Language output should be output in sub-directory of output - root: path.resolve(this.root(), book.language) - }), this); - }; - - // ----- Utility methods ----- - - // Return path to the root folder - FolderOutput.prototype.root = function() { - return this.opts.root; - }; - - // Resolve a file in the output directory - FolderOutput.prototype.resolve = function(filename) { - return pathUtil.resolveInRoot.apply(null, [this.root()].concat(_.toArray(arguments))); - }; - - // Copy a file to the output - FolderOutput.prototype.copyFile = function(from, to) { - var that = this; - - return Promise() - .then(function() { - to = that.resolve(to); - var folder = path.dirname(to); - - // Ensure folder exists - return fs.mkdirp(folder); - }) - .then(function() { - return fs.copy(from, to); - }); - }; - - // Write a file/buffer to the output folder - FolderOutput.prototype.writeFile = function(filename, buf) { - var that = this; - - return Promise() - .then(function() { - filename = that.resolve(filename); - var folder = path.dirname(filename); - - // Ensure folder exists - return fs.mkdirp(folder); - }) - - // Write the file - .then(function() { - return fs.writeFile(filename, buf); - }); - }; - - // Return true if a file exists in the output folder - FolderOutput.prototype.hasFile = function(filename) { - var that = this; - - return Promise() - .then(function() { - return fs.exists(that.resolve(filename)); - }); - }; - - // Create a new unique file - // Returns its filename - FolderOutput.prototype.createNewFile = function(base, filename) { - var that = this; - - if (!filename) { - filename = path.basename(filename); - base = path.dirname(base); - } - - return fs.uniqueFilename(this.resolve(base), filename) - .then(function(out) { - out = path.join(base, out); - - return fs.ensure(that.resolve(out)) - .thenResolve(out); - }); - }; - - return FolderOutput; -}; diff --git a/lib/output/generateAssets.js b/lib/output/generateAssets.js new file mode 100644 index 0000000..7a6e104 --- /dev/null +++ b/lib/output/generateAssets.js @@ -0,0 +1,26 @@ +var Promise = require('../utils/promise'); + +/** + Output all assets using a generator + + @param {Generator} generator + @param {Output} output + @return {Promise<Output>} +*/ +function generateAssets(generator, output) { + var assets = output.getAssets(); + var logger = output.getLogger(); + + // Is generator ignoring assets? + if (!generator.onAsset) { + return Promise(output); + } + + return Promise.reduce(assets, function(out, assetFile) { + logger.debug.ln('copy asset "' + assetFile + '"'); + + return generator.onAsset(out, assetFile); + }, output); +} + +module.exports = generateAssets; diff --git a/lib/output/generateBook.js b/lib/output/generateBook.js new file mode 100644 index 0000000..6fcade0 --- /dev/null +++ b/lib/output/generateBook.js @@ -0,0 +1,181 @@ +var path = require('path'); +var Immutable = require('immutable'); + +var Output = require('../models/output'); +var Config = require('../models/config'); +var Promise = require('../utils/promise'); + +var callHook = require('./callHook'); +var preparePlugins = require('./preparePlugins'); +var preparePages = require('./preparePages'); +var prepareAssets = require('./prepareAssets'); +var generateAssets = require('./generateAssets'); +var generatePages = require('./generatePages'); + +/** + Process an output to generate the book + + @param {Generator} generator + @param {Output} output + + @return {Promise<Output>} +*/ +function processOutput(generator, startOutput) { + return Promise(startOutput) + .then(preparePlugins) + .then(preparePages) + .then(prepareAssets) + + .then( + callHook.bind(null, + 'config', + function(output) { + var book = output.getBook(); + var config = book.getConfig(); + var values = config.getValues(); + + return values.toJS(); + }, + function(output, result) { + var book = output.getBook(); + var config = book.getConfig(); + + config = Config.updateValues(config, result); + book = book.set('config', config); + return output.set('book', book); + } + ) + ) + + .then( + callHook.bind(null, + 'init', + function(output) { + return {}; + }, + function(output) { + return output; + } + ) + ) + + .then(function(output) { + if (!generator.onInit) { + return output; + } + + return generator.onInit(output); + }) + + .then(generateAssets.bind(null, generator)) + .then(generatePages.bind(null, generator)) + + .tap(function(output) { + var book = output.getBook(); + + if (!book.isMultilingual()) { + return; + } + + var books = book.getBooks(); + var outputRoot = output.getRoot(); + var plugins = output.getPlugins(); + var state = output.getState(); + var options = output.getOptions(); + + return Promise.forEach(books, function(langBook) { + // Inherits plugins list, options and state + var langOptions = options.set('root', path.join(outputRoot, langBook.getLanguage())); + var langOutput = new Output({ + book: langBook, + options: langOptions, + state: state, + generator: generator.name, + plugins: plugins + }); + + return processOutput(generator, langOutput); + }); + }) + + .then(callHook.bind(null, + 'finish:before', + function(output) { + return {}; + }, + function(output) { + return output; + } + ) + ) + + .then(function(output) { + if (!generator.onFinish) { + return output; + } + + return generator.onFinish(output); + }) + + .then(callHook.bind(null, + 'finish', + function(output) { + return {}; + }, + function(output) { + return output; + } + ) + ); +} + +/** + Generate a book using a generator. + + The overall process is: + 1. List and load plugins for this book + 2. Call hook "config" + 3. Call hook "init" + 4. Initialize generator + 5. List all assets and pages + 6. Copy all assets to output + 7. Generate all pages + 8. Call hook "finish:before" + 9. Finish generation + 10. Call hook "finish" + + + @param {Generator} generator + @param {Book} book + @param {Object} options + + @return {Promise<Output>} +*/ +function generateBook(generator, book, options) { + options = generator.Options(options); + var state = generator.State? generator.State({}) : Immutable.Map(); + var start = Date.now(); + + return Promise( + new Output({ + book: book, + options: options, + state: state, + generator: generator.name + }) + ) + .then(processOutput.bind(null, generator)) + + // Log duration and end message + .then(function(output) { + var logger = output.getLogger(); + var end = Date.now(); + var duration = (end - start)/1000; + + logger.info.ok('generation finished with success in ' + duration.toFixed(1) + 's !'); + + return output; + }); +} + +module.exports = generateBook; diff --git a/lib/output/generateMock.js b/lib/output/generateMock.js new file mode 100644 index 0000000..47d29dc --- /dev/null +++ b/lib/output/generateMock.js @@ -0,0 +1,35 @@ +var tmp = require('tmp'); + +var Book = require('../models/book'); +var createMockFS = require('../fs/mock'); +var parseBook = require('../parse/parseBook'); +var generateBook = require('./generateBook'); + + +/** + Generate a book using JSON generator + And returns the path to the output dir. + + FOR TESTING PURPOSE ONLY + + @param {Generator} + @param {Map<String:String|Map>} files + @return {Promise<String>} +*/ +function generateMock(Generator, files) { + var fs = createMockFS(files); + var book = Book.createForFS(fs); + var dir = tmp.dirSync(); + + book = book.setLogLevel('disabled'); + + return parseBook(book) + .then(function(resultBook) { + return generateBook(Generator, resultBook, { + root: dir.name + }); + }) + .thenResolve(dir.name); +} + +module.exports = generateMock; diff --git a/lib/output/generatePage.js b/lib/output/generatePage.js new file mode 100644 index 0000000..a93d4b0 --- /dev/null +++ b/lib/output/generatePage.js @@ -0,0 +1,71 @@ +var Promise = require('../utils/promise'); +var error = require('../utils/error'); +var timing = require('../utils/timing'); + +var Parse = require('../parse'); +var Templating = require('../templating'); +var JSONUtils = require('../json'); +var createTemplateEngine = require('./createTemplateEngine'); +var callPageHook = require('./callPageHook'); + +/** + Prepare and generate HTML for a page + + @param {Output} output + @param {Page} page + @return {Promise<Page>} +*/ +function generatePage(output, page) { + var book = output.getBook(); + var engine = createTemplateEngine(output); + + return timing.measure( + 'page.generate', + Parse.parsePage(book, page) + .then(function(resultPage) { + var file = resultPage.getFile(); + var filePath = file.getPath(); + var parser = file.getParser(); + var context = JSONUtils.encodeBookWithPage(book, resultPage); + + if (!parser) { + return Promise.reject(error.FileNotParsableError({ + filename: filePath + })); + } + + // Call hook "page:before" + return callPageHook('page:before', output, resultPage) + + // Escape code blocks with raw tags + .then(function(currentPage) { + return parser.page.prepare(currentPage.getContent()); + }) + + // Render templating syntax + .then(function(content) { + return Templating.render(engine, filePath, content, context); + }) + + // Render page using parser (markdown -> HTML) + .then(parser.page).get('content') + + // Post processing for templating syntax + .then(function(content) { + return Templating.postRender(engine, content); + }) + + // Return new page + .then(function(content) { + return resultPage.set('content', content); + }) + + // Call final hook + .then(function(currentPage) { + return callPageHook('page', output, currentPage); + }); + }) + ); +} + +module.exports = generatePage; diff --git a/lib/output/generatePages.js b/lib/output/generatePages.js new file mode 100644 index 0000000..73c5c09 --- /dev/null +++ b/lib/output/generatePages.js @@ -0,0 +1,36 @@ +var Promise = require('../utils/promise'); +var generatePage = require('./generatePage'); + +/** + Output all pages using a generator + + @param {Generator} generator + @param {Output} output + @return {Promise<Output>} +*/ +function generatePages(generator, output) { + var pages = output.getPages(); + var logger = output.getLogger(); + + // Is generator ignoring assets? + if (!generator.onPage) { + return Promise(output); + } + + return Promise.reduce(pages, function(out, page) { + var file = page.getFile(); + + logger.debug.ln('generate page "' + file.getPath() + '"'); + + return generatePage(out, page) + .then(function(resultPage) { + return generator.onPage(out, resultPage); + }) + .fail(function(err) { + logger.error.ln('error while generating page "' + file.getPath() + '":'); + throw err; + }); + }, output); +} + +module.exports = generatePages; diff --git a/lib/output/getModifiers.js b/lib/output/getModifiers.js new file mode 100644 index 0000000..e649df6 --- /dev/null +++ b/lib/output/getModifiers.js @@ -0,0 +1,68 @@ +var Modifiers = require('./modifiers'); +var resolveFileToURL = require('./helper/resolveFileToURL'); +var Api = require('../api'); +var Plugins = require('../plugins'); +var Promise = require('../utils/promise'); +var defaultBlocks = require('../constants/defaultBlocks'); + +var CODEBLOCK = 'code'; + +/** + Return default modifier to prepare a page for + rendering. + + @return {Array<Modifier>} +*/ +function getModifiers(output, page) { + var book = output.getBook(); + var plugins = output.getPlugins(); + var glossary = book.getGlossary(); + var entries = glossary.getEntries(); + var file = page.getFile(); + + // Current file path + var currentFilePath = file.getPath(); + + // Get TemplateBlock for highlighting + var blocks = Plugins.listBlocks(plugins); + var code = blocks.get(CODEBLOCK) || defaultBlocks.get(CODEBLOCK); + + // Current context + var context = Api.encodeGlobal(output); + + return [ + // Normalize IDs on headings + Modifiers.addHeadingId, + + // Resolve links (.md -> .html) + Modifiers.resolveLinks.bind(null, + currentFilePath, + resolveFileToURL.bind(null, output) + ), + + // Resolve images + Modifiers.resolveImages.bind(null, currentFilePath), + + // Annotate text with glossary entries + Modifiers.annotateText.bind(null, entries), + + // Highlight code blocks using "code" block + Modifiers.highlightCode.bind(null, function(lang, source) { + return Promise(code.applyBlock({ + body: source, + kwargs: { + language: lang + } + }, context)) + .then(function(result) { + if (result.html === false) { + return { text: result.body }; + } else { + return { html: result.body }; + } + }); + }) + ]; +} + +module.exports = getModifiers; diff --git a/lib/output/helper/fileToOutput.js b/lib/output/helper/fileToOutput.js new file mode 100644 index 0000000..9673162 --- /dev/null +++ b/lib/output/helper/fileToOutput.js @@ -0,0 +1,32 @@ +var path = require('path'); + +var PathUtils = require('../../utils/path'); +var LocationUtils = require('../../utils/location'); + +var OUTPUT_EXTENSION = '.html'; + +/** + Convert a filePath (absolute) to a filename for output + + @param {Output} output + @param {String} filePath + @return {String} +*/ +function fileToOutput(output, filePath) { + var book = output.getBook(); + var readme = book.getReadme(); + var fileReadme = readme.getFile(); + + if ( + path.basename(filePath, path.extname(filePath)) == 'README' || + (fileReadme.exists() && filePath == fileReadme.getPath()) + ) { + filePath = path.join(path.dirname(filePath), 'index' + OUTPUT_EXTENSION); + } else { + filePath = PathUtils.setExtension(filePath, OUTPUT_EXTENSION); + } + + return LocationUtils.normalize(filePath); +} + +module.exports = fileToOutput; diff --git a/lib/output/helper/fileToURL.js b/lib/output/helper/fileToURL.js new file mode 100644 index 0000000..44ad2d8 --- /dev/null +++ b/lib/output/helper/fileToURL.js @@ -0,0 +1,31 @@ +var path = require('path'); +var LocationUtils = require('../../utils/location'); + +var fileToOutput = require('./fileToOutput'); + +/** + Convert a filePath (absolute) to an url (without hostname). + It returns an absolute path. + + "README.md" -> "/" + "test/hello.md" -> "test/hello.html" + "test/README.md" -> "test/" + + @param {Output} output + @param {String} filePath + @return {String} +*/ +function fileToURL(output, filePath) { + var options = output.getOptions(); + var directoryIndex = options.get('directoryIndex'); + + filePath = fileToOutput(output, filePath); + + if (directoryIndex && path.basename(filePath) == 'index.html') { + filePath = path.dirname(filePath) + '/'; + } + + return LocationUtils.normalize(filePath); +} + +module.exports = fileToURL; diff --git a/lib/output/helper/index.js b/lib/output/helper/index.js new file mode 100644 index 0000000..f8bc109 --- /dev/null +++ b/lib/output/helper/index.js @@ -0,0 +1,2 @@ + +module.exports = {}; diff --git a/lib/output/helper/resolveFileToUrl.js b/lib/output/helper/resolveFileToUrl.js new file mode 100644 index 0000000..3dba8f7 --- /dev/null +++ b/lib/output/helper/resolveFileToUrl.js @@ -0,0 +1,27 @@ +var LocationUtils = require('../../utils/location'); + +var fileToURL = require('./fileToURL'); + +/** + Resolve an absolute path (extracted from a link) + + @param {Output} output + @param {String} filePath + @return {String} +*/ +function resolveFileToURL(output, filePath) { + // Convert /test.png -> test.png + filePath = LocationUtils.toAbsolute(filePath, '', ''); + + var pages = output.getPages(); + var page = pages.get(filePath); + + // if file is a page, return correct .html url + if (page) { + filePath = fileToURL(output, filePath); + } + + return LocationUtils.normalize(filePath); +} + +module.exports = resolveFileToURL; diff --git a/lib/output/helper/writeFile.js b/lib/output/helper/writeFile.js new file mode 100644 index 0000000..a6d4645 --- /dev/null +++ b/lib/output/helper/writeFile.js @@ -0,0 +1,23 @@ +var path = require('path'); +var fs = require('../../utils/fs'); + +/** + Write a file to the output folder + + @param {Output} output + @param {String} filePath + @param {Buffer|String} content + @return {Promise} +*/ +function writeFile(output, filePath, content) { + var rootFolder = output.getRoot(); + filePath = path.join(rootFolder, filePath); + + return fs.ensureFile(filePath) + .then(function() { + return fs.writeFile(filePath, content); + }) + .thenResolve(output); +} + +module.exports = writeFile; diff --git a/lib/output/index.js b/lib/output/index.js new file mode 100644 index 0000000..9b8ec17 --- /dev/null +++ b/lib/output/index.js @@ -0,0 +1,24 @@ +var Immutable = require('immutable'); + +var generators = Immutable.List([ + require('./json'), + require('./website'), + require('./ebook') +]); + +/** + Return a specific generator by its name + + @param {String} + @return {Generator} +*/ +function getGenerator(name) { + return generators.find(function(generator) { + return generator.name == name; + }); +} + +module.exports = { + generate: require('./generateBook'), + getGenerator: getGenerator +}; diff --git a/lib/output/json.js b/lib/output/json.js deleted file mode 100644 index 7061141..0000000 --- a/lib/output/json.js +++ /dev/null @@ -1,47 +0,0 @@ -var conrefsLoader = require('./conrefs'); - -var JSONOutput = conrefsLoader(); - -JSONOutput.prototype.name = 'json'; - -// Don't copy asset on JSON output -JSONOutput.prototype.onAsset = function(filename) {}; - -// Write a page (parsable file) -JSONOutput.prototype.onPage = function(page) { - var that = this; - - // Parse the page - return page.toHTML(this) - - // Write as json - .then(function() { - var json = page.getOutputContext(that); - - // Delete some private properties - delete json.config; - - // Specify JSON output version - json.version = '3'; - - return that.writeFile( - page.withExtension('.json'), - JSON.stringify(json, null, 4) - ); - }); -}; - -// At the end of generation, generate README.json for multilingual books -JSONOutput.prototype.finish = function() { - if (!this.book.isMultilingual()) return; - - // Copy README.json from main book - var mainLanguage = this.book.langs.getDefault().id; - return this.copyFile( - this.resolve(mainLanguage, 'README.json'), - 'README.json' - ); -}; - - -module.exports = JSONOutput; diff --git a/lib/output/json/index.js b/lib/output/json/index.js new file mode 100644 index 0000000..e24c127 --- /dev/null +++ b/lib/output/json/index.js @@ -0,0 +1,6 @@ + +module.exports = { + name: 'json', + Options: require('./options'), + onPage: require('./onPage') +}; diff --git a/lib/output/json/onFinish.js b/lib/output/json/onFinish.js new file mode 100644 index 0000000..ff336a2 --- /dev/null +++ b/lib/output/json/onFinish.js @@ -0,0 +1,32 @@ +var path = require('path'); + +var Promise = require('../../utils/promise'); +var fs = require('../../utils/fs'); + +/** + Finish the generation + + @param {Output} + @return {Output} +*/ +function onFinish(output) { + var book = output.getBook(); + var outputRoot = output.getRoot(); + + if (!book.isMultilingual()) { + return Promise(output); + } + + // Get main language + var languages = book.getLanguages(); + var mainLanguage = languages.getDefaultLanguage(); + + // Copy README.json from it + return fs.copy( + path.resolve(outputRoot, mainLanguage.getID(), 'README.json'), + path.resolve(outputRoot, 'README.json') + ) + .thenResolve(output); +} + +module.exports = onFinish; diff --git a/lib/output/json/onPage.js b/lib/output/json/onPage.js new file mode 100644 index 0000000..fece540 --- /dev/null +++ b/lib/output/json/onPage.js @@ -0,0 +1,43 @@ +var JSONUtils = require('../../json'); +var PathUtils = require('../../utils/path'); +var Modifiers = require('../modifiers'); +var writeFile = require('../helper/writeFile'); +var getModifiers = require('../getModifiers'); + +var JSON_VERSION = '3'; + +/** + Write a page as a json file + + @param {Output} output + @param {Page} page +*/ +function onPage(output, page) { + var file = page.getFile(); + var readme = output.getBook().getReadme().getFile(); + + return Modifiers.modifyHTML(page, getModifiers(output, page)) + .then(function(resultPage) { + // Generate the JSON + var json = JSONUtils.encodeBookWithPage(output.getBook(), resultPage); + + // Delete some private properties + delete json.config; + + // Specify JSON output version + json.version = JSON_VERSION; + + // File path in the output folder + var filePath = file.getPath() == readme.getPath()? 'README.json' : file.getPath(); + filePath = PathUtils.setExtension(filePath, '.json'); + + // Write it to the disk + return writeFile( + output, + filePath, + JSON.stringify(json, null, 4) + ); + }); +} + +module.exports = onPage; diff --git a/lib/output/json/options.js b/lib/output/json/options.js new file mode 100644 index 0000000..79167b1 --- /dev/null +++ b/lib/output/json/options.js @@ -0,0 +1,8 @@ +var Immutable = require('immutable'); + +var Options = Immutable.Record({ + // Root folder for the output + root: String() +}); + +module.exports = Options; diff --git a/lib/output/modifiers/__tests__/addHeadingId.js b/lib/output/modifiers/__tests__/addHeadingId.js new file mode 100644 index 0000000..7277440 --- /dev/null +++ b/lib/output/modifiers/__tests__/addHeadingId.js @@ -0,0 +1,29 @@ +jest.autoMockOff(); + +var cheerio = require('cheerio'); + +describe('addHeadingId', function() { + var addHeadingId = require('../addHeadingId'); + + pit('should add an ID if none', function() { + var $ = cheerio.load('<h1>Hello World</h1><h2>Cool !!</h2>'); + + return addHeadingId($) + .then(function() { + var html = $.html(); + expect(html).toBe('<h1 id="hello-world">Hello World</h1><h2 id="cool-">Cool !!</h2>'); + }); + }); + + pit('should not change existing IDs', function() { + var $ = cheerio.load('<h1 id="awesome">Hello World</h1>'); + + return addHeadingId($) + .then(function() { + var html = $.html(); + expect(html).toBe('<h1 id="awesome">Hello World</h1>'); + }); + }); +}); + + diff --git a/lib/output/modifiers/__tests__/annotateText.js b/lib/output/modifiers/__tests__/annotateText.js new file mode 100644 index 0000000..15d4c30 --- /dev/null +++ b/lib/output/modifiers/__tests__/annotateText.js @@ -0,0 +1,49 @@ +jest.autoMockOff(); + +var Immutable = require('immutable'); +var cheerio = require('cheerio'); +var GlossaryEntry = require('../../../models/glossaryEntry'); + +describe('annotateText', function() { + var annotateText = require('../annotateText'); + + var entries = Immutable.List([ + GlossaryEntry({ name: 'Word' }), + GlossaryEntry({ name: 'Multiple Words' }) + ]); + + it('should annotate text', function() { + var $ = cheerio.load('<p>This is a word, and multiple words</p>'); + + annotateText(entries, $); + + var links = $('a'); + expect(links.length).toBe(2); + + var word = $(links.get(0)); + expect(word.attr('href')).toBe('/GLOSSARY.md#word'); + expect(word.text()).toBe('word'); + expect(word.hasClass('glossary-term')).toBeTruthy(); + + var words = $(links.get(1)); + expect(words.attr('href')).toBe('/GLOSSARY.md#multiple-words'); + expect(words.text()).toBe('multiple words'); + expect(words.hasClass('glossary-term')).toBeTruthy(); + }); + + it('should not annotate scripts', function() { + var $ = cheerio.load('<script>This is a word, and multiple words</script>'); + + annotateText(entries, $); + expect($('a').length).toBe(0); + }); + + it('should not annotate when has class "no-glossary"', function() { + var $ = cheerio.load('<p class="no-glossary">This is a word, and multiple words</p>'); + + annotateText(entries, $); + expect($('a').length).toBe(0); + }); +}); + + diff --git a/lib/output/modifiers/__tests__/fetchRemoteImages.js b/lib/output/modifiers/__tests__/fetchRemoteImages.js new file mode 100644 index 0000000..f5610a2 --- /dev/null +++ b/lib/output/modifiers/__tests__/fetchRemoteImages.js @@ -0,0 +1,40 @@ +var cheerio = require('cheerio'); +var tmp = require('tmp'); +var path = require('path'); + +var URL = 'https://upload.wikimedia.org/wikipedia/commons/thumb/4/47/PNG_transparency_demonstration_1.png/280px-PNG_transparency_demonstration_1.png'; + +describe('fetchRemoteImages', function() { + var dir; + var fetchRemoteImages = require('../fetchRemoteImages'); + + beforeEach(function() { + dir = tmp.dirSync(); + }); + + pit('should download image file', function() { + var $ = cheerio.load('<img src="' + URL + '" />'); + + return fetchRemoteImages(dir.name, 'index.html', $) + .then(function() { + var $img = $('img'); + var src = $img.attr('src'); + + expect(dir.name).toHaveFile(src); + }); + }); + + pit('should download image file and replace with relative path', function() { + var $ = cheerio.load('<img src="' + URL + '" />'); + + return fetchRemoteImages(dir.name, 'test/index.html', $) + .then(function() { + var $img = $('img'); + var src = $img.attr('src'); + + expect(dir.name).toHaveFile(path.join('test', src)); + }); + }); +}); + + diff --git a/lib/output/modifiers/__tests__/highlightCode.js b/lib/output/modifiers/__tests__/highlightCode.js new file mode 100644 index 0000000..bd7d422 --- /dev/null +++ b/lib/output/modifiers/__tests__/highlightCode.js @@ -0,0 +1,63 @@ +jest.autoMockOff(); + +var cheerio = require('cheerio'); +var Promise = require('../../../utils/promise'); + +describe('highlightCode', function() { + var highlightCode = require('../highlightCode'); + + function doHighlight(lang, code) { + return { + text: '' + (lang || '') + '$' + code + }; + } + + function doHighlightAsync(lang, code) { + return Promise() + .then(function() { + return doHighlight(lang, code); + }); + } + + pit('should call it for normal code element', function() { + var $ = cheerio.load('<p>This is a <code>test</code></p>'); + + return highlightCode(doHighlight, $) + .then(function() { + var $code = $('code'); + expect($code.text()).toBe('$test'); + }); + }); + + pit('should call it for markdown code block', function() { + var $ = cheerio.load('<pre><code class="lang-js">test</code></pre>'); + + return highlightCode(doHighlight, $) + .then(function() { + var $code = $('code'); + expect($code.text()).toBe('js$test'); + }); + }); + + pit('should call it for asciidoc code block', function() { + var $ = cheerio.load('<pre><code class="language-python">test</code></pre>'); + + return highlightCode(doHighlight, $) + .then(function() { + var $code = $('code'); + expect($code.text()).toBe('python$test'); + }); + }); + + pit('should accept async highlighter', function() { + var $ = cheerio.load('<pre><code class="language-python">test</code></pre>'); + + return highlightCode(doHighlightAsync, $) + .then(function() { + var $code = $('code'); + expect($code.text()).toBe('python$test'); + }); + }); +}); + + diff --git a/lib/output/modifiers/__tests__/resolveLinks.js b/lib/output/modifiers/__tests__/resolveLinks.js new file mode 100644 index 0000000..3d50d80 --- /dev/null +++ b/lib/output/modifiers/__tests__/resolveLinks.js @@ -0,0 +1,71 @@ +jest.autoMockOff(); + +var path = require('path'); +var cheerio = require('cheerio'); + +describe('resolveLinks', function() { + var resolveLinks = require('../resolveLinks'); + + function resolveFileBasic(href) { + return href; + } + + function resolveFileCustom(href) { + if (path.extname(href) == '.md') { + return href.slice(0, -3) + '.html'; + } + + return href; + } + + describe('Absolute path', function() { + var TEST = '<p>This is a <a href="/test/cool.md"></a></p>'; + + pit('should resolve path starting by "/" in root directory', function() { + var $ = cheerio.load(TEST); + + return resolveLinks('hello.md', resolveFileBasic, $) + .then(function() { + var link = $('a'); + expect(link.attr('href')).toBe('test/cool.md'); + }); + }); + + pit('should resolve path starting by "/" in child directory', function() { + var $ = cheerio.load(TEST); + + return resolveLinks('afolder/hello.md', resolveFileBasic, $) + .then(function() { + var link = $('a'); + expect(link.attr('href')).toBe('../test/cool.md'); + }); + }); + }); + + describe('Custom Resolver', function() { + var TEST = '<p>This is a <a href="/test/cool.md"></a> <a href="afile.png"></a></p>'; + + pit('should resolve path correctly for absolute path', function() { + var $ = cheerio.load(TEST); + + return resolveLinks('hello.md', resolveFileCustom, $) + .then(function() { + var link = $('a').first(); + expect(link.attr('href')).toBe('test/cool.html'); + }); + }); + + pit('should resolve path correctly for absolute path (2)', function() { + var $ = cheerio.load(TEST); + + return resolveLinks('afodler/hello.md', resolveFileCustom, $) + .then(function() { + var link = $('a').first(); + expect(link.attr('href')).toBe('../test/cool.html'); + }); + }); + }); + +}); + + diff --git a/lib/output/modifiers/__tests__/svgToImg.js b/lib/output/modifiers/__tests__/svgToImg.js new file mode 100644 index 0000000..793395e --- /dev/null +++ b/lib/output/modifiers/__tests__/svgToImg.js @@ -0,0 +1,25 @@ +var cheerio = require('cheerio'); +var tmp = require('tmp'); + +describe('svgToImg', function() { + var dir; + var svgToImg = require('../svgToImg'); + + beforeEach(function() { + dir = tmp.dirSync(); + }); + + pit('should write svg as a file', function() { + var $ = cheerio.load('<svg xmlns="http://www.w3.org/2000/svg" width="200" height="100" version="1.1"><rect width="200" height="100" stroke="black" stroke-width="6" fill="green"/></svg>'); + + return svgToImg(dir.name, 'index.html', $) + .then(function() { + var $img = $('img'); + var src = $img.attr('src'); + + expect(dir.name).toHaveFile(src); + }); + }); +}); + + diff --git a/lib/output/modifiers/__tests__/svgToPng.js b/lib/output/modifiers/__tests__/svgToPng.js new file mode 100644 index 0000000..163d72e --- /dev/null +++ b/lib/output/modifiers/__tests__/svgToPng.js @@ -0,0 +1,32 @@ +var cheerio = require('cheerio'); +var tmp = require('tmp'); +var path = require('path'); + +describe('svgToPng', function() { + var dir; + var svgToImg = require('../svgToImg'); + var svgToPng = require('../svgToPng'); + + beforeEach(function() { + dir = tmp.dirSync(); + }); + + pit('should write svg as png file', function() { + var $ = cheerio.load('<svg xmlns="http://www.w3.org/2000/svg" width="200" height="100" version="1.1"><rect width="200" height="100" stroke="black" stroke-width="6" fill="green"/></svg>'); + var fileName = 'index.html'; + + return svgToImg(dir.name, fileName, $) + .then(function() { + return svgToPng(dir.name, fileName, $); + }) + .then(function() { + var $img = $('img'); + var src = $img.attr('src'); + + expect(dir.name).toHaveFile(src); + expect(path.extname(src)).toBe('.png'); + }); + }); +}); + + diff --git a/lib/output/modifiers/addHeadingId.js b/lib/output/modifiers/addHeadingId.js new file mode 100644 index 0000000..e2e2720 --- /dev/null +++ b/lib/output/modifiers/addHeadingId.js @@ -0,0 +1,23 @@ +var slug = require('github-slugid'); +var editHTMLElement = require('./editHTMLElement'); + +/** + Add ID to an heading + + @param {HTMLElement} heading +*/ +function addId(heading) { + if (heading.attr('id')) return; + heading.attr('id', slug(heading.text())); +} + +/** + Add ID to all headings + + @param {HTMLDom} $ +*/ +function addHeadingId($) { + return editHTMLElement($, 'h1,h2,h3,h4,h5,h6', addId); +} + +module.exports = addHeadingId; diff --git a/lib/output/modifiers/annotateText.js b/lib/output/modifiers/annotateText.js new file mode 100644 index 0000000..d8443cf --- /dev/null +++ b/lib/output/modifiers/annotateText.js @@ -0,0 +1,94 @@ +var escape = require('escape-html'); + +// Selector to ignore +var ANNOTATION_IGNORE = '.no-glossary,code,pre,a,script,h1,h2,h3,h4,h5,h6'; + +function pregQuote( str ) { + return (str+'').replace(/([\\\.\+\*\?\[\^\]\$\(\)\{\}\=\!\<\>\|\:])/g, '\\$1'); +} + +function replaceText($, el, search, replace, text_only ) { + return $(el).each(function(){ + var node = this.firstChild, + val, + new_val, + + // Elements to be removed at the end. + remove = []; + + // Only continue if firstChild exists. + if ( node ) { + + // Loop over all childNodes. + while (node) { + + // Only process text nodes. + if ( node.nodeType === 3 ) { + + // The original node value. + val = node.nodeValue; + + // The new value. + new_val = val.replace( search, replace ); + + // Only replace text if the new value is actually different! + if ( new_val !== val ) { + + if ( !text_only && /</.test( new_val ) ) { + // The new value contains HTML, set it in a slower but far more + // robust way. + $(node).before( new_val ); + + // Don't remove the node yet, or the loop will lose its place. + remove.push( node ); + } else { + // The new value contains no HTML, so it can be set in this + // very fast, simple way. + node.nodeValue = new_val; + } + } + } + + node = node.nextSibling; + } + } + + // Time to remove those elements! + if (remove.length) $(remove).remove(); + }); +} + +/** + Annotate text using a list of GlossaryEntry + + @param {List<GlossaryEntry>} + @param {HTMLDom} $ +*/ +function annotateText(entries, $) { + entries.forEach(function(entry) { + var entryId = entry.getID(); + var name = entry.getName(); + var description = entry.getDescription(); + + var searchRegex = new RegExp( '\\b(' + pregQuote(name.toLowerCase()) + ')\\b' , 'gi' ); + + $('*').each(function() { + var $this = $(this); + + if ( + $this.is(ANNOTATION_IGNORE) || + $this.parents(ANNOTATION_IGNORE).length > 0 + ) return; + + replaceText($, this, searchRegex, function(match) { + return '<a href="/GLOSSARY.md#' + entryId + '" ' + + 'class="glossary-term" title="' + escape(description) + '">' + + match + + '</a>'; + }); + }); + + }); +} + +module.exports = annotateText; diff --git a/lib/output/modifiers/editHTMLElement.js b/lib/output/modifiers/editHTMLElement.js new file mode 100644 index 0000000..755598e --- /dev/null +++ b/lib/output/modifiers/editHTMLElement.js @@ -0,0 +1,15 @@ +var Promise = require('../../utils/promise'); + +/** + Edit all elements matching a selector +*/ +function editHTMLElement($, selector, fn) { + var $elements = $(selector); + + return Promise.forEach($elements, function(el) { + var $el = $(el); + return fn($el); + }); +} + +module.exports = editHTMLElement; diff --git a/lib/output/modifiers/fetchRemoteImages.js b/lib/output/modifiers/fetchRemoteImages.js new file mode 100644 index 0000000..ef868b9 --- /dev/null +++ b/lib/output/modifiers/fetchRemoteImages.js @@ -0,0 +1,44 @@ +var path = require('path'); +var crc = require('crc'); + +var editHTMLElement = require('./editHTMLElement'); +var fs = require('../../utils/fs'); +var LocationUtils = require('../../utils/location'); + +/** + Fetch all remote images + + @param {String} rootFolder + @param {String} currentFile + @param {HTMLDom} $ + @return {Promise} +*/ +function fetchRemoteImages(rootFolder, currentFile, $) { + var currentDirectory = path.dirname(currentFile); + + return editHTMLElement($, 'img', function($img) { + var src = $img.attr('src'); + var extension = path.extname(src); + + if (!LocationUtils.isExternal(src)) { + return; + } + + // We avoid generating twice the same PNG + var hash = crc.crc32(src).toString(16); + var fileName = hash + extension; + var filePath = path.join(rootFolder, fileName); + + return fs.assertFile(filePath, function() { + return fs.download(src, filePath); + }) + .then(function() { + // Convert to relative + src = LocationUtils.relative(currentDirectory, fileName); + + $img.replaceWith('<img src="' + src + '" />'); + }); + }); +} + +module.exports = fetchRemoteImages; diff --git a/lib/output/modifiers/highlightCode.js b/lib/output/modifiers/highlightCode.js new file mode 100644 index 0000000..dcd9d24 --- /dev/null +++ b/lib/output/modifiers/highlightCode.js @@ -0,0 +1,56 @@ +var is = require('is'); +var Promise = require('../../utils/promise'); +var editHTMLElement = require('./editHTMLElement'); + +/** + Return language for a code blocks from a list of class names + + @param {Array<String>} + @return {String} +*/ +function getLanguageForClass(classNames) { + return classNames + .map(function(cl) { + // Markdown + if (cl.search('lang-') === 0) { + return cl.slice('lang-'.length); + } + + // Asciidoc + if (cl.search('language-') === 0) { + return cl.slice('language-'.length); + } + + return null; + }) + .find(function(cl) { + return Boolean(cl); + }); +} + + +/** + Highlight all code elements + + @param {Function(lang, body) -> String} highlight + @param {HTMLDom} $ + @return {Promise} +*/ +function highlightCode(highlight, $) { + return editHTMLElement($, 'code', function($code) { + var classNames = ($code.attr('class') || '').split(' '); + var lang = getLanguageForClass(classNames); + var source = $code.text(); + + return Promise(highlight(lang, source)) + .then(function(r) { + if (is.string(r.html)) { + $code.html(r.html); + } else { + $code.text(r.text); + } + }); + }); +} + +module.exports = highlightCode; diff --git a/lib/output/modifiers/index.js b/lib/output/modifiers/index.js new file mode 100644 index 0000000..f1daa2b --- /dev/null +++ b/lib/output/modifiers/index.js @@ -0,0 +1,15 @@ + +module.exports = { + modifyHTML: require('./modifyHTML'), + inlineAssets: require('./inlineAssets'), + + // HTML transformations + addHeadingId: require('./addHeadingId'), + svgToImg: require('./svgToImg'), + fetchRemoteImages: require('./fetchRemoteImages'), + svgToPng: require('./svgToPng'), + resolveLinks: require('./resolveLinks'), + resolveImages: require('./resolveImages'), + annotateText: require('./annotateText'), + highlightCode: require('./highlightCode') +}; diff --git a/lib/output/modifiers/inlineAssets.js b/lib/output/modifiers/inlineAssets.js new file mode 100644 index 0000000..9f19fd7 --- /dev/null +++ b/lib/output/modifiers/inlineAssets.js @@ -0,0 +1,27 @@ +var svgToImg = require('./svgToImg'); +var svgToPng = require('./svgToPng'); +var resolveImages = require('./resolveImages'); +var fetchRemoteImages = require('./fetchRemoteImages'); + +var Promise = require('../../utils/promise'); + +/** + Inline all assets in a page + + @param {String} rootFolder +*/ +function inlineAssets(rootFolder, currentFile) { + return function($) { + return Promise() + + // Resolving images and fetching external images should be + // done before svg conversion + .then(resolveImages.bind(null, currentFile)) + .then(fetchRemoteImages.bind(null, rootFolder, currentFile)) + + .then(svgToImg.bind(null, rootFolder, currentFile)) + .then(svgToPng.bind(null, rootFolder, currentFile)); + }; +} + +module.exports = inlineAssets; diff --git a/lib/output/modifiers/modifyHTML.js b/lib/output/modifiers/modifyHTML.js new file mode 100644 index 0000000..0fcf994 --- /dev/null +++ b/lib/output/modifiers/modifyHTML.js @@ -0,0 +1,25 @@ +var cheerio = require('cheerio'); +var Promise = require('../../utils/promise'); + +/** + Apply a list of operations to a page and + output the new page. + + @param {Page} + @param {List|Array<Transformation>} + @return {Promise<Page>} +*/ +function modifyHTML(page, operations) { + var html = page.getContent(); + var $ = cheerio.load(html); + + return Promise.forEach(operations, function(op) { + op($); + }) + .then(function() { + var resultHTML = $.html(); + return page.set('content', resultHTML); + }); +} + +module.exports = modifyHTML; diff --git a/lib/output/modifiers/resolveImages.js b/lib/output/modifiers/resolveImages.js new file mode 100644 index 0000000..e401cf5 --- /dev/null +++ b/lib/output/modifiers/resolveImages.js @@ -0,0 +1,33 @@ +var path = require('path'); + +var LocationUtils = require('../../utils/location'); +var editHTMLElement = require('./editHTMLElement'); + +/** + Resolve all HTML images: + - /test.png in hello -> ../test.html + + @param {String} currentFile + @param {HTMLDom} $ +*/ +function resolveImages(currentFile, $) { + var currentDirectory = path.dirname(currentFile); + + return editHTMLElement($, 'img', function($img) { + var src = $img.attr('src'); + + if (LocationUtils.isExternal(src)) { + return; + } + + // Calcul absolute path for this + src = LocationUtils.toAbsolute(src, currentDirectory, '.'); + + // Convert back to relative + src = LocationUtils.relative(currentDirectory, src); + + $img.attr('src', src); + }); +} + +module.exports = resolveImages; diff --git a/lib/output/modifiers/resolveLinks.js b/lib/output/modifiers/resolveLinks.js new file mode 100644 index 0000000..bf3fd10 --- /dev/null +++ b/lib/output/modifiers/resolveLinks.js @@ -0,0 +1,38 @@ +var path = require('path'); + +var LocationUtils = require('../../utils/location'); +var editHTMLElement = require('./editHTMLElement'); + +/** + Resolve all HTML links: + - /test.md in hello -> ../test.html + + @param {String} currentFile + @param {Function(String) -> String} resolveFile + @param {HTMLDom} $ +*/ +function resolveLinks(currentFile, resolveFile, $) { + var currentDirectory = path.dirname(currentFile); + + return editHTMLElement($, 'a', function($a) { + var href = $a.attr('href'); + + if (LocationUtils.isExternal(href)) { + $a.attr('_target', 'blank'); + return; + } + + // Calcul absolute path for this + href = LocationUtils.toAbsolute(href, currentDirectory, '.'); + + // Resolve file + href = resolveFile(href); + + // Convert back to relative + href = LocationUtils.relative(currentDirectory, href); + + $a.attr('href', href); + }); +} + +module.exports = resolveLinks; diff --git a/lib/output/modifiers/svgToImg.js b/lib/output/modifiers/svgToImg.js new file mode 100644 index 0000000..f31b06d --- /dev/null +++ b/lib/output/modifiers/svgToImg.js @@ -0,0 +1,56 @@ +var path = require('path'); +var crc = require('crc'); +var domSerializer = require('dom-serializer'); + +var editHTMLElement = require('./editHTMLElement'); +var fs = require('../../utils/fs'); +var LocationUtils = require('../../utils/location'); + +/** + Render a cheerio DOM as html + + @param {HTMLDom} $ + @param {HTMLElement} dom + @param {Object} + @return {String} +*/ +function renderDOM($, dom, options) { + if (!dom && $._root && $._root.children) { + dom = $._root.children; + } + options = options|| dom.options || $._options; + return domSerializer(dom, options); +} + +/** + Replace SVG tag by IMG + + @param {String} baseFolder + @param {HTMLDom} $ +*/ +function svgToImg(baseFolder, currentFile, $) { + var currentDirectory = path.dirname(currentFile); + + return editHTMLElement($, 'svg', function($svg) { + var content = '<?xml version="1.0" encoding="UTF-8"?>' + + renderDOM($, $svg); + + // We avoid generating twice the same PNG + var hash = crc.crc32(content).toString(16); + var fileName = hash + '.svg'; + var filePath = path.join(baseFolder, fileName); + + // Write the svg to the file + return fs.assertFile(filePath, function() { + return fs.writeFile(filePath, content, 'utf8'); + }) + + // Return as image + .then(function() { + var src = LocationUtils.relative(currentDirectory, fileName); + $svg.replaceWith('<img src="' + src + '" />'); + }); + }); +} + +module.exports = svgToImg; diff --git a/lib/output/modifiers/svgToPng.js b/lib/output/modifiers/svgToPng.js new file mode 100644 index 0000000..1093106 --- /dev/null +++ b/lib/output/modifiers/svgToPng.js @@ -0,0 +1,53 @@ +var crc = require('crc'); +var path = require('path'); + +var imagesUtil = require('../../utils/images'); +var fs = require('../../utils/fs'); +var LocationUtils = require('../../utils/location'); + +var editHTMLElement = require('./editHTMLElement'); + +/** + Convert all SVG images to PNG + + @param {String} rootFolder + @param {HTMLDom} $ + @return {Promise} +*/ +function svgToPng(rootFolder, currentFile, $) { + var currentDirectory = path.dirname(currentFile); + + return editHTMLElement($, 'img', function($img) { + var src = $img.attr('src'); + if (path.extname(src) !== '.svg') { + return; + } + + // Calcul absolute path for this + src = LocationUtils.toAbsolute(src, currentDirectory, '.'); + + // We avoid generating twice the same PNG + var hash = crc.crc32(src).toString(16); + var fileName = hash + '.png'; + + // Input file path + var inputPath = path.join(rootFolder, src); + + // Result file path + var filePath = path.join(rootFolder, fileName); + + return fs.assertFile(filePath, function() { + return imagesUtil.convertSVGToPNG(inputPath, filePath); + }) + .then(function() { + // Convert filename to a relative filename + fileName = LocationUtils.relative(currentDirectory, fileName); + + // Replace src + $img.attr('src', fileName); + }); + }); +} + + +module.exports = svgToPng; diff --git a/lib/output/prepareAssets.js b/lib/output/prepareAssets.js new file mode 100644 index 0000000..ae9b55a --- /dev/null +++ b/lib/output/prepareAssets.js @@ -0,0 +1,22 @@ +var Parse = require('../parse'); + +/** + List all assets in the book + + @param {Output} + @return {Promise<Output>} +*/ +function prepareAssets(output) { + var book = output.getBook(); + var pages = output.getPages(); + var logger = output.getLogger(); + + return Parse.listAssets(book, pages) + .then(function(assets) { + logger.info.ln('found', assets.size, 'asset files'); + + return output.set('assets', assets); + }); +} + +module.exports = prepareAssets; diff --git a/lib/output/preparePages.js b/lib/output/preparePages.js new file mode 100644 index 0000000..8ad5f8c --- /dev/null +++ b/lib/output/preparePages.js @@ -0,0 +1,21 @@ +var Parse = require('../parse'); + +/** + List and prepare all pages + + @param {Output} + @return {Promise<Output>} +*/ +function preparePages(output) { + var book = output.getBook(); + var logger = book.getLogger(); + + return Parse.parsePagesList(book) + .then(function(pages) { + logger.info.ln('found', pages.size, 'pages'); + + return output.set('pages', pages); + }); +} + +module.exports = preparePages; diff --git a/lib/output/preparePlugins.js b/lib/output/preparePlugins.js new file mode 100644 index 0000000..54837ed --- /dev/null +++ b/lib/output/preparePlugins.js @@ -0,0 +1,36 @@ +var Plugins = require('../plugins'); +var Promise = require('../utils/promise'); + +/** + Load and setup plugins + + @param {Output} + @return {Promise<Output>} +*/ +function preparePlugins(output) { + var book = output.getBook(); + + return Promise() + + // Only load plugins for main book + .then(function() { + if (book.isLanguageBook()) { + return output.getPlugins(); + } else { + return Plugins.loadForBook(book); + } + }) + + // Update book's configuration using the plugins + .then(function(plugins) { + return Plugins.validateConfig(book, plugins) + .then(function(newBook) { + return output.merge({ + book: newBook, + plugins: plugins + }); + }); + }); +} + +module.exports = preparePlugins; diff --git a/lib/output/website/copyPluginAssets.js b/lib/output/website/copyPluginAssets.js new file mode 100644 index 0000000..9dc876f --- /dev/null +++ b/lib/output/website/copyPluginAssets.js @@ -0,0 +1,115 @@ +var path = require('path'); + +var ASSET_FOLDER = require('../../constants/pluginAssetsFolder'); +var Promise = require('../../utils/promise'); +var fs = require('../../utils/fs'); + +/** + Copy all assets from plugins. + Assets are files stored in "_assets" + nd resources declared in the plugin itself. + + @param {Output} + @return {Promise} +*/ +function copyPluginAssets(output) { + var book = output.getBook(); + + // Don't copy plugins assets for language book + // It'll be resolved to the parent folder + if (book.isLanguageBook()) { + return Promise(output); + } + + var plugins = output.getPlugins() + + // We reverse the order of plugins to copy + // so that first plugins can replace assets from other plugins. + .reverse(); + + return Promise.forEach(plugins, function(plugin) { + return copyAssets(output, plugin) + .then(function() { + return copyResources(output, plugin); + }); + }) + .thenResolve(output); +} + +/** + Copy assets from a plugin + + @param {Plugin} + @return {Promise} +*/ +function copyAssets(output, plugin) { + var logger = output.getLogger(); + var pluginRoot = plugin.getPath(); + var options = output.getOptions(); + + var outputRoot = options.get('root'); + var assetOutputFolder = path.join(outputRoot, 'gitbook'); + var prefix = options.get('prefix'); + + var assetFolder = path.join(pluginRoot, ASSET_FOLDER, prefix); + + if (!fs.existsSync(assetFolder)) { + return Promise(); + } + + logger.debug.ln('copy assets from theme', assetFolder); + return fs.copyDir( + assetFolder, + assetOutputFolder, + { + deleteFirst: false, + overwrite: true, + confirm: true + } + ); +} + +/** + Copy resources from a plugin + + @param {Plugin} + @return {Promise} +*/ +function copyResources(output, plugin) { + var logger = output.getLogger(); + + var options = output.getOptions(); + var prefix = options.get('prefix'); + var outputRoot = options.get('root'); + + var pluginRoot = plugin.getPath(); + var resources = plugin.getResources(prefix); + + var assetsFolder = resources.get('assets'); + var assetOutputFolder = path.join(outputRoot, 'gitbook', plugin.getNpmID()); + + if (!assetsFolder) { + return Promise(); + } + + // Resolve assets folder + assetsFolder = path.resolve(pluginRoot, assetsFolder); + if (!fs.existsSync(assetsFolder)) { + logger.warn.ln('assets folder for plugin "' + plugin.getName() + '" doesn\'t exist'); + return Promise(); + } + + logger.debug.ln('copy resources from plugin', assetsFolder); + + return fs.copyDir( + assetsFolder, + assetOutputFolder, + { + deleteFirst: false, + overwrite: true, + confirm: true + } + ); +} + +module.exports = copyPluginAssets; diff --git a/lib/output/website/createTemplateEngine.js b/lib/output/website/createTemplateEngine.js new file mode 100644 index 0000000..334ec13 --- /dev/null +++ b/lib/output/website/createTemplateEngine.js @@ -0,0 +1,118 @@ +var path = require('path'); +var nunjucks = require('nunjucks'); +var DoExtension = require('nunjucks-do')(nunjucks); + +var Api = require('../../api'); +var JSONUtils = require('../../json'); +var LocationUtils = require('../../utils/location'); +var fs = require('../../utils/fs'); +var PathUtils = require('../../utils/path'); +var TemplateEngine = require('../../models/templateEngine'); +var templatesFolder = require('../../constants/templatesFolder'); +var defaultFilters = require('../../constants/defaultFilters'); +var Templating = require('../../templating'); +var listSearchPaths = require('./listSearchPaths'); + +var fileToURL = require('../helper/fileToURL'); +var resolveFileToURL = require('../helper/resolveFileToURL'); + +/** + Directory for a theme with the templates +*/ +function templateFolder(dir) { + return path.join(dir, templatesFolder); +} + +/** + Create templating engine to render themes + + @param {Output} output + @param {String} currentFile + @return {TemplateEngine} +*/ +function createTemplateEngine(output, currentFile) { + var book = output.getBook(); + var state = output.getState(); + var i18n = state.getI18n(); + var config = book.getConfig(); + var summary = book.getSummary(); + var outputFolder = output.getRoot(); + + // Search paths for templates + var searchPaths = listSearchPaths(output); + var tplSearchPaths = searchPaths.map(templateFolder); + + // Create loader + var loader = new Templating.ThemesLoader(tplSearchPaths); + + // Get languages + var language = config.get('language'); + + // Create API context + var context = Api.encodeGlobal(output); + + return TemplateEngine.create({ + loader: loader, + + context: context, + + filters: defaultFilters.merge({ + /** + Translate a sentence + */ + t: function t(s) { + return i18n.t(language, s); + }, + + /** + Resolve an absolute file path into a + relative path. + it also resolve pages + */ + resolveFile: function(filePath) { + filePath = resolveFileToURL(output, filePath); + return LocationUtils.relativeForFile(currentFile, filePath); + }, + + resolveAsset: function(filePath) { + filePath = LocationUtils.toAbsolute(filePath, '', ''); + filePath = path.join('gitbook', filePath); + filePath = LocationUtils.relativeForFile(currentFile, filePath); + + // Use assets from parent if language book + if (book.isLanguageBook()) { + filePath = path.join('../', filePath); + } + + return LocationUtils.normalize(filePath); + }, + + /** + Check if a file exists + */ + fileExists: function(fileName) { + var filePath = PathUtils.resolveInRoot(outputFolder, fileName); + return fs.existsSync(filePath); + }, + + contentURL: function(filePath) { + return fileToURL(output, filePath); + }, + + /** + Return an article by its path + */ + getArticleByPath: function(s) { + var article = summary.getByPath(s); + if (!article) return undefined; + return JSONUtils.encodeSummaryArticle(article); + } + }), + + extensions: { + 'DoExtension': new DoExtension() + } + }); +} + +module.exports = createTemplateEngine; diff --git a/lib/output/website/index.js b/lib/output/website/index.js index 0a8618c..7818a28 100644 --- a/lib/output/website/index.js +++ b/lib/output/website/index.js @@ -1,225 +1,11 @@ -var _ = require('lodash'); -var path = require('path'); -var util = require('util'); -var I18n = require('i18n-t'); -var Promise = require('../../utils/promise'); -var location = require('../../utils/location'); -var fs = require('../../utils/fs'); -var conrefsLoader = require('../conrefs'); -var Output = require('../base'); -var setupTemplateEnv = require('./templateEnv'); - -function _WebsiteOutput() { - Output.apply(this, arguments); - - // Nunjucks environment - this.env; - - // Plugin instance for the main theme - this.theme; - - // Plugin instance for the default theme - this.defaultTheme; - - // Resources loaded from plugins - this.resources; - - // i18n for themes - this.i18n = new I18n(); -} -util.inherits(_WebsiteOutput, Output); - -var WebsiteOutput = conrefsLoader(_WebsiteOutput); - -// Name of the generator -// It's being used as a prefix for templates -WebsiteOutput.prototype.name = 'website'; - -// Load and setup the theme -WebsiteOutput.prototype.prepare = function() { - var that = this; - - return Promise() - .then(function() { - return WebsiteOutput.super_.prototype.prepare.apply(that); - }) - - .then(function() { - // This list is ordered to give priority to templates in the book - var searchPaths = _.pluck(that.plugins.list(), 'root'); - - // The book itself can contains a "_layouts" folder - searchPaths.unshift(that.book.root); - - // Load i18n - _.each(searchPaths.concat().reverse(), function(searchPath) { - var i18nRoot = path.resolve(searchPath, '_i18n'); - - if (!fs.existsSync(i18nRoot)) return; - that.i18n.load(i18nRoot); - }); - - that.searchPaths = searchPaths; - }) - - // Copy assets from themes before copying files from book - .then(function() { - if (that.book.isLanguageBook()) return; - - // Assets from the book are already copied - // Copy assets from plugins (start with default plugins) - return Promise.serie(that.plugins.list().reverse(), function(plugin) { - // Copy assets only if exists (don't fail otherwise) - var assetFolder = path.join(plugin.root, '_assets', that.name); - if (!fs.existsSync(assetFolder)) return; - - that.log.debug.ln('copy assets from theme', assetFolder); - return fs.copyDir( - assetFolder, - that.resolve('gitbook'), - { - deleteFirst: false, - overwrite: true, - confirm: true - } - ); - }); - }) - - // Load resources for plugins - .then(function() { - return that.plugins.getResources(that.name) - .then(function(resources) { - that.resources = resources; - }); - }); -}; - -// Write a page (parsable file) -WebsiteOutput.prototype.onPage = function(page) { - var that = this; - - // Parse the page - return page.toHTML(this) - - // Render the page template with the same context as the json output - .then(function() { - return that.render('page', that.outputPath(page.path), page.getOutputContext(that)); - }); -}; - -// Finish generation, create ebook using ebook-convert -WebsiteOutput.prototype.finish = function() { - var that = this; - - return Promise() - .then(function() { - return WebsiteOutput.super_.prototype.finish.apply(that); - }) - - // Copy assets from plugins - .then(function() { - if (that.book.isLanguageBook()) return; - return that.plugins.copyResources(that.name, that.resolve('gitbook')); - }) - - // Generate homepage to select languages - .then(function() { - if (!that.book.isMultilingual()) return; - return that.outputMultilingualIndex(); - }); -}; - -// ----- Utilities ---- - -// Write multi-languages index -WebsiteOutput.prototype.outputMultilingualIndex = function() { - var that = this; - - return that.render('languages', 'index.html', that.getContext()); -}; - -/* - Render a template as an HTML string - Templates are stored in `_layouts` folders - - - @param {String} tpl: template name (ex: "page") - @param {String} outputFile: filename to write, relative to the output - @param {Object} context: context for the page - @return {Promise} -*/ -WebsiteOutput.prototype.renderAsString = function(tpl, context) { - // Calcul template name - var filename = this.templateName(tpl); - - context = _.extend(context, { - plugins: { - resources: this.resources - }, - - options: this.opts - }); - - // Create environment - var env = setupTemplateEnv(this, context); - - return Promise.nfcall(env.render.bind(env), filename, context); +module.exports = { + name: 'website', + State: require('./state'), + Options: require('./options'), + onInit: require('./onInit'), + onFinish: require('./onFinish'), + onPage: require('./onPage'), + onAsset: require('./onAsset'), + createTemplateEngine: require('./createTemplateEngine') }; - -/* - Render a template using nunjucks - Templates are stored in `_layouts` folders - - - @param {String} tpl: template name (ex: "page") - @param {String} outputFile: filename to write, relative to the output - @param {Object} context: context for the page - @return {Promise} -*/ -WebsiteOutput.prototype.render = function(tpl, outputFile, context) { - var that = this; - - // Calcul relative path to the root - var outputDirName = path.dirname(outputFile); - var basePath = location.normalize(path.relative(outputDirName, './')); - - // Setup complete context - context = _.extend(context, { - basePath: basePath, - - template: { - getJSContext: function() { - return { - page: _.omit(context.page, 'content'), - config: context.config, - file: context.file, - gitbook: context.gitbook, - basePath: basePath, - book: { - language: context.book.language - } - }; - } - } - }); - - return this.renderAsString(tpl, context) - .then(function(html) { - return that.writeFile( - outputFile, - html - ); - }); -}; - -// Return a complete name for a template -WebsiteOutput.prototype.templateName = function(name) { - return path.join(this.name, name+'.html'); -}; - -module.exports = WebsiteOutput; - - - diff --git a/lib/output/website/listSearchPaths.js b/lib/output/website/listSearchPaths.js new file mode 100644 index 0000000..c45f39c --- /dev/null +++ b/lib/output/website/listSearchPaths.js @@ -0,0 +1,23 @@ + +/** + List search paths for templates / i18n, etc + + @param {Output} output + @return {List<String>} +*/ +function listSearchPaths(output) { + var book = output.getBook(); + var plugins = output.getPlugins(); + + var searchPaths = plugins + .valueSeq() + .map(function(plugin) { + return plugin.getPath(); + }) + .toList(); + + return searchPaths.unshift(book.getContentRoot()); +} + + +module.exports = listSearchPaths; diff --git a/lib/output/website/onAsset.js b/lib/output/website/onAsset.js new file mode 100644 index 0000000..17b6ba7 --- /dev/null +++ b/lib/output/website/onAsset.js @@ -0,0 +1,27 @@ +var path = require('path'); +var fs = require('../../utils/fs'); + +/** + Copy an asset to the output folder + + @param {Output} output + @param {Page} page +*/ +function onAsset(output, asset) { + var book = output.getBook(); + var options = output.getOptions(); + + var rootFolder = book.getContentRoot(); + var outputFolder = options.get('root'); + + var filePath = path.resolve(rootFolder, asset); + var outputPath = path.resolve(outputFolder, asset); + + return fs.ensureFile(outputPath) + .then(function() { + return fs.copy(filePath, outputPath); + }) + .thenResolve(output); +} + +module.exports = onAsset; diff --git a/lib/output/website/onFinish.js b/lib/output/website/onFinish.js new file mode 100644 index 0000000..e3560e2 --- /dev/null +++ b/lib/output/website/onFinish.js @@ -0,0 +1,35 @@ +var Promise = require('../../utils/promise'); +var JSONUtils = require('../../json'); +var Templating = require('../../templating'); +var writeFile = require('../helper/writeFile'); +var createTemplateEngine = require('./createTemplateEngine'); + +/** + Finish the generation, write the languages index + + @param {Output} + @return {Output} +*/ +function onFinish(output) { + var book = output.getBook(); + var options = output.getOptions(); + var prefix = options.get('prefix'); + + if (!book.isMultilingual()) { + return Promise(output); + } + + var filePath = 'index.html'; + var engine = createTemplateEngine(output, filePath); + var context = JSONUtils.encodeOutput(output); + + // Render the theme + return Templating.renderFile(engine, prefix + '/languages.html', context) + + // Write it to the disk + .then(function(html) { + return writeFile(output, filePath, html); + }); +} + +module.exports = onFinish; diff --git a/lib/output/website/onInit.js b/lib/output/website/onInit.js new file mode 100644 index 0000000..979a90d --- /dev/null +++ b/lib/output/website/onInit.js @@ -0,0 +1,18 @@ +var Promise = require('../../utils/promise'); + +var copyPluginAssets = require('./copyPluginAssets'); +var prepareI18n = require('./prepareI18n'); + +/** + Initialize the generator + + @param {Output} + @return {Output} +*/ +function onInit(output) { + return Promise(output) + .then(prepareI18n) + .then(copyPluginAssets); +} + +module.exports = onInit; diff --git a/lib/output/website/onPage.js b/lib/output/website/onPage.js new file mode 100644 index 0000000..64b4e04 --- /dev/null +++ b/lib/output/website/onPage.js @@ -0,0 +1,72 @@ +var path = require('path'); +var omit = require('omit-keys'); + +var Templating = require('../../templating'); +var Plugins = require('../../plugins'); +var JSONUtils = require('../../json'); +var LocationUtils = require('../../utils/location'); +var Modifiers = require('../modifiers'); +var writeFile = require('../helper/writeFile'); +var getModifiers = require('../getModifiers'); +var createTemplateEngine = require('./createTemplateEngine'); +var fileToOutput = require('../helper/fileToOutput'); + +/** + Write a page as a json file + + @param {Output} output + @param {Page} page +*/ +function onPage(output, page) { + var options = output.getOptions(); + var file = page.getFile(); + var prefix = options.get('prefix'); + var book = output.getBook(); + var plugins = output.getPlugins(); + + var engine = createTemplateEngine(output, page.getPath()); + + // Output file path + var filePath = fileToOutput(output, file.getPath()); + + // Calcul relative path to the root + var outputDirName = path.dirname(filePath); + var basePath = LocationUtils.normalize(path.relative(outputDirName, './')); + + return Modifiers.modifyHTML(page, getModifiers(output, page)) + .then(function(resultPage) { + // Generate the context + var context = JSONUtils.encodeBookWithPage(output.getBook(), resultPage); + context.plugins = { + resources: Plugins.listResources(plugins, prefix).toJS() + }; + + context.template = { + getJSContext: function() { + return { + page: omit(context.page, 'content'), + config: context.config, + file: context.file, + gitbook: context.gitbook, + basePath: basePath, + book: { + language: book.getLanguage() + } + }; + } + }; + + // We should probabbly move it to "template" or a "site" namespace + context.basePath = basePath; + + // Render the theme + return Templating.renderFile(engine, prefix + '/page.html', context) + + // Write it to the disk + .then(function(html) { + return writeFile(output, filePath, html); + }); + }); +} + +module.exports = onPage; diff --git a/lib/output/website/options.js b/lib/output/website/options.js new file mode 100644 index 0000000..ac9cdad --- /dev/null +++ b/lib/output/website/options.js @@ -0,0 +1,14 @@ +var Immutable = require('immutable'); + +var Options = Immutable.Record({ + // Root folder for the output + root: String(), + + // Prefix for generation + prefix: String('website'), + + // Use directory index url instead of "index.html" + directoryIndex: Boolean(true) +}); + +module.exports = Options; diff --git a/lib/output/website/prepareI18n.js b/lib/output/website/prepareI18n.js new file mode 100644 index 0000000..b57d178 --- /dev/null +++ b/lib/output/website/prepareI18n.js @@ -0,0 +1,30 @@ +var path = require('path'); + +var fs = require('../../utils/fs'); +var Promise = require('../../utils/promise'); +var listSearchPaths = require('./listSearchPaths'); + +/** + Prepare i18n, load translations from plugins and book + + @param {Output} + @return {Promise<Output>} +*/ +function prepareI18n(output) { + var state = output.getState(); + var i18n = state.getI18n(); + var searchPaths = listSearchPaths(output); + + searchPaths + .reverse() + .forEach(function(searchPath) { + var i18nRoot = path.resolve(searchPath, '_i18n'); + + if (!fs.existsSync(i18nRoot)) return; + i18n.load(i18nRoot); + }); + + return Promise(output); +} + +module.exports = prepareI18n; diff --git a/lib/output/website/state.js b/lib/output/website/state.js new file mode 100644 index 0000000..99e7f04 --- /dev/null +++ b/lib/output/website/state.js @@ -0,0 +1,12 @@ +var I18n = require('i18n-t'); +var Immutable = require('immutable'); + +var GeneratorState = Immutable.Record({ + i18n: I18n() +}); + +GeneratorState.prototype.getI18n = function() { + return this.get('i18n'); +}; + +module.exports = GeneratorState; diff --git a/lib/output/website/templateEnv.js b/lib/output/website/templateEnv.js deleted file mode 100644 index d385108..0000000 --- a/lib/output/website/templateEnv.js +++ /dev/null @@ -1,95 +0,0 @@ -var _ = require('lodash'); -var nunjucks = require('nunjucks'); -var path = require('path'); -var fs = require('fs'); -var DoExtension = require('nunjucks-do')(nunjucks); - - -var location = require('../../utils/location'); -var defaultFilters = require('../../template/filters'); - -var ThemeLoader = require('./themeLoader'); - -// Directory for a theme with the templates -function templatesPath(dir) { - return path.join(dir, '_layouts'); -} - -/* - Create and setup at Nunjucks template environment - - @return {Nunjucks.Environment} -*/ -function setupTemplateEnv(output, context) { - context = _.defaults(context || {}, { - // Required by ThemeLoader - template: {} - }); - - var loader = new ThemeLoader( - _.map(output.searchPaths, templatesPath) - ); - var env = new nunjucks.Environment(loader); - - env.addExtension('DoExtension', new DoExtension()); - - // Add context as global - _.each(context, function(value, key) { - env.addGlobal(key, value); - }); - - // Add GitBook default filters - _.each(defaultFilters, function(fn, filter) { - env.addFilter(filter, fn); - }); - - // Translate using _i18n locales - env.addFilter('t', function t(s) { - return output.i18n.t(output.book.config.get('language'), s); - }); - - // Transform an absolute path into a relative path - // using this.ctx.page.path - env.addFilter('resolveFile', function resolveFile(href) { - return location.normalize(output.resolveForPage(context.file.path, href)); - }); - - // Test if a file exists - env.addFilter('fileExists', function fileExists(href) { - return fs.existsSync(output.resolve(href)); - }); - - // Transform a '.md' into a '.html' (README -> index) - env.addFilter('contentURL', function contentURL(s) { - return output.toURL(s); - }); - - // Get an article using its path - env.addFilter('getArticleByPath', function getArticleByPath(s) { - var article = output.book.summary.getArticle(s); - if (!article) return undefined; - - return article.getContext(); - }); - - // Relase path to an asset - env.addFilter('resolveAsset', function resolveAsset(href) { - href = path.join('gitbook', href); - - // Resolve for current file - if (context.file) { - href = output.resolveForPage(context.file.path, '/' + href); - } - - // Use assets from parent - if (output.book.isLanguageBook()) { - href = path.join('../', href); - } - - return location.normalize(href); - }); - - return env; -} - -module.exports = setupTemplateEnv; diff --git a/lib/output/website/themeLoader.js b/lib/output/website/themeLoader.js deleted file mode 100644 index 774a39e..0000000 --- a/lib/output/website/themeLoader.js +++ /dev/null @@ -1,127 +0,0 @@ -var _ = require('lodash'); -var fs = require('fs'); -var path = require('path'); -var nunjucks = require('nunjucks'); - -/* - Nunjucks loader similar to FileSystemLoader, but avoid infinite looping -*/ - -/* - Return true if a filename is relative. -*/ -function isRelative(filename) { - return (filename.indexOf('./') === 0 || filename.indexOf('../') === 0); -} - -var ThemeLoader = nunjucks.Loader.extend({ - init: function(searchPaths) { - this.searchPaths = _.map(searchPaths, path.normalize); - }, - - /* - Read source of a resolved filepath - - @param {String} - @return {Object} - */ - getSource: function(fullpath) { - if (!fullpath) return null; - - fullpath = this.resolve(null, fullpath); - var templateName = this.getTemplateName(fullpath); - - if(!fullpath) { - return null; - } - - var src = fs.readFileSync(fullpath, 'utf-8'); - - src = '{% do %}var template = template || {}; template.stack = template.stack || []; template.stack.push(template.self); template.self = ' + JSON.stringify(templateName) + '{% enddo %}\n' + - src + - '\n{% do %}template.self = template.stack.pop();{% enddo %}'; - - return { - src: src, - path: fullpath, - noCache: true - }; - }, - - /* - Nunjucks calls "isRelative" to determine when to call "resolve". - We handle absolute paths ourselves in ".resolve" so we always return true - */ - isRelative: function() { - return true; - }, - - /* - Get original search path containing a template - - @param {String} filepath - @return {String} searchPath - */ - getSearchPath: function(filepath) { - return _.chain(this.searchPaths) - .sortBy(function(s) { - return -s.length; - }) - .find(function(basePath) { - return (filepath && filepath.indexOf(basePath) === 0); - }) - .value(); - }, - - /* - Get template name from a filepath - - @param {String} filepath - @return {String} name - */ - getTemplateName: function(filepath) { - var originalSearchPath = this.getSearchPath(filepath); - return originalSearchPath? path.relative(originalSearchPath, filepath) : null; - }, - - /* - Resolve a template from a current template - - @param {String|null} from - @param {String} to - @return {String|null} - */ - resolve: function(from, to) { - var searchPaths = this.searchPaths; - - // Relative template like "./test.html" - if (isRelative(to) && from) { - return path.resolve(path.dirname(from), to); - } - - // Determine in which search folder we currently are - var originalSearchPath = this.getSearchPath(from); - var originalFilename = this.getTemplateName(from); - - // If we are including same file from a different search path - // Slice the search paths to avoid including from previous ones - if (originalFilename == to) { - var currentIndex = searchPaths.indexOf(originalSearchPath); - searchPaths = searchPaths.slice(currentIndex + 1); - } - - // Absolute template to resolve in root folder - var resultFolder = _.find(searchPaths, function(basePath) { - var p = path.resolve(basePath, to); - - return ( - p.indexOf(basePath) === 0 - && fs.existsSync(p) - ); - }); - if (!resultFolder) return null; - return path.resolve(resultFolder, to); - } -}); - -module.exports = ThemeLoader; |