diff options
author | Samy Pessé <samypesse@gmail.com> | 2016-09-05 12:32:25 +0200 |
---|---|---|
committer | Samy Pessé <samypesse@gmail.com> | 2016-09-05 12:32:25 +0200 |
commit | b796069adeadd655ad34dc425ff7a05d7a54cd47 (patch) | |
tree | 30edaffa584e7d3db3f4c65f401f4a8f58f4eef9 /packages/gitbook/src/output | |
parent | 839904a70419aaef1006be820ec092d978236a20 (diff) | |
download | gitbook-b796069adeadd655ad34dc425ff7a05d7a54cd47.zip gitbook-b796069adeadd655ad34dc425ff7a05d7a54cd47.tar.gz gitbook-b796069adeadd655ad34dc425ff7a05d7a54cd47.tar.bz2 |
Move gitbook source code to es6 (linted)
Diffstat (limited to 'packages/gitbook/src/output')
67 files changed, 2947 insertions, 0 deletions
diff --git a/packages/gitbook/src/output/__tests__/createMock.js b/packages/gitbook/src/output/__tests__/createMock.js new file mode 100644 index 0000000..09b93da --- /dev/null +++ b/packages/gitbook/src/output/__tests__/createMock.js @@ -0,0 +1,38 @@ +const Immutable = require('immutable'); + +const Output = require('../../models/output'); +const Book = require('../../models/book'); +const parseBook = require('../../parse/parseBook'); +const createMockFS = require('../../fs/mock'); +const preparePlugins = require('../preparePlugins'); + +/** + * Create an output using a generator + * + * FOR TESTING PURPOSE ONLY + * + * @param {Generator} generator + * @param {Map<String:String|Map>} files + * @return {Promise<Output>} + */ +function createMockOutput(generator, files, options) { + const fs = createMockFS(files); + let book = Book.createForFS(fs); + const state = generator.State ? generator.State({}) : Immutable.Map(); + + book = book.setLogLevel('disabled'); + options = generator.Options(options); + + return parseBook(book) + .then(function(resultBook) { + return new Output({ + book: resultBook, + options, + state, + generator: generator.name + }); + }) + .then(preparePlugins); +} + +module.exports = createMockOutput; diff --git a/packages/gitbook/src/output/__tests__/ebook.js b/packages/gitbook/src/output/__tests__/ebook.js new file mode 100644 index 0000000..425ab6b --- /dev/null +++ b/packages/gitbook/src/output/__tests__/ebook.js @@ -0,0 +1,16 @@ +const generateMock = require('./generateMock'); +const EbookGenerator = require('../ebook'); + +describe('EbookGenerator', function() { + + it('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/packages/gitbook/src/output/__tests__/generateMock.js b/packages/gitbook/src/output/__tests__/generateMock.js new file mode 100644 index 0000000..a0be244 --- /dev/null +++ b/packages/gitbook/src/output/__tests__/generateMock.js @@ -0,0 +1,40 @@ +const tmp = require('tmp'); + +const Book = require('../../models/book'); +const createMockFS = require('../../fs/mock'); +const parseBook = require('../../parse/parseBook'); +const generateBook = require('../generateBook'); + +/** + * Generate a book using a 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) { + const fs = createMockFS(files); + let book = Book.createForFS(fs); + let dir; + + try { + dir = tmp.dirSync(); + } catch (err) { + throw err; + } + + 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/packages/gitbook/src/output/__tests__/json.js b/packages/gitbook/src/output/__tests__/json.js new file mode 100644 index 0000000..d4992ec --- /dev/null +++ b/packages/gitbook/src/output/__tests__/json.js @@ -0,0 +1,46 @@ +const generateMock = require('./generateMock'); +const JSONGenerator = require('../json'); + +describe('JSONGenerator', function() { + + it('should generate a README.json', function() { + return generateMock(JSONGenerator, { + 'README.md': 'Hello World' + }) + .then(function(folder) { + expect(folder).toHaveFile('README.json'); + }); + }); + + it('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'); + }); + }); + + it('should generate a multilingual book', function() { + return generateMock(JSONGenerator, { + 'LANGS.md': '# Languages\n\n* [en](en)\n* [fr](fr)', + 'en': { + 'README.md': 'Hello' + }, + 'fr': { + 'README.md': 'Bonjour' + } + }) + .then(function(folder) { + expect(folder).toHaveFile('en/README.json'); + expect(folder).toHaveFile('fr/README.json'); + expect(folder).toHaveFile('README.json'); + }); + }); +}); + diff --git a/packages/gitbook/src/output/__tests__/website.js b/packages/gitbook/src/output/__tests__/website.js new file mode 100644 index 0000000..d39842f --- /dev/null +++ b/packages/gitbook/src/output/__tests__/website.js @@ -0,0 +1,144 @@ +const fs = require('fs'); +const generateMock = require('./generateMock'); +const WebsiteGenerator = require('../website'); + +describe('WebsiteGenerator', function() { + + it('should generate an index.html', function() { + return generateMock(WebsiteGenerator, { + 'README.md': 'Hello World' + }) + .then(function(folder) { + expect(folder).toHaveFile('index.html'); + }); + }); + + describe('Glossary', function() { + let folder; + + before(function() { + return generateMock(WebsiteGenerator, { + 'README.md': 'Hello World', + 'SUMMARY.md': '* [Deep](folder/page.md)', + 'folder': { + 'page.md': 'Hello World' + }, + 'GLOSSARY.md': '# Glossary\n\n## Hello\n\nHello World' + }) + .then(function(_folder) { + folder = _folder; + }); + }); + + it('should generate a GLOSSARY.html', function() { + expect(folder).toHaveFile('GLOSSARY.html'); + }); + + it('should correctly resolve glossary links in README', function() { + const html = fs.readFileSync(folder + '/index.html', 'utf8'); + expect(html).toHaveDOMElement('.page-inner a[href="GLOSSARY.html#hello"]'); + }); + + it('should correctly resolve glossary links in directory', function() { + const html = fs.readFileSync(folder + '/folder/page.html', 'utf8'); + expect(html).toHaveDOMElement('.page-inner a[href="../GLOSSARY.html#hello"]'); + }); + + it('should accept a custom glossary file', function() { + return generateMock(WebsiteGenerator, { + 'README.md': 'Hello World', + 'book.json': '{ "structure": { "glossary": "custom.md" } }', + 'custom.md': '# Glossary\n\n## Hello\n\nHello World' + }) + .then(function(folder) { + expect(folder).toHaveFile('custom.html'); + expect(folder).toNotHaveFile('GLOSSARY.html'); + + const html = fs.readFileSync(folder + '/index.html', 'utf8'); + expect(html).toHaveDOMElement('.page-inner a[href="custom.html#hello"]'); + }); + }); + }); + + + it('should copy asset files', function() { + return generateMock(WebsiteGenerator, { + 'README.md': 'Hello World', + 'myJsFile.js': 'var a = "test";', + 'folder': { + 'AnotherAssetFile.md': '# Even md' + } + }) + .then(function(folder) { + expect(folder).toHaveFile('index.html'); + expect(folder).toHaveFile('myJsFile.js'); + expect(folder).toHaveFile('folder/AnotherAssetFile.md'); + }); + }); + + it('should generate an index.html for AsciiDoc', function() { + return generateMock(WebsiteGenerator, { + 'README.adoc': 'Hello World' + }) + .then(function(folder) { + expect(folder).toHaveFile('index.html'); + }); + }); + + it('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'); + }); + }); + + it('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).toNotHaveFile('page.html'); + expect(folder).toHaveFile('test/page.html'); + }); + }); + + it('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).toNotHaveFile('en/README.md'); + expect(folder).toNotHaveFile('fr/README.md'); + + // Should copy assets only once + expect(folder).toHaveFile('gitbook/style.css'); + expect(folder).toNotHaveFile('en/gitbook/style.css'); + + expect(folder).toHaveFile('index.html'); + }); + }); +}); + diff --git a/packages/gitbook/src/output/callHook.js b/packages/gitbook/src/output/callHook.js new file mode 100644 index 0000000..2180102 --- /dev/null +++ b/packages/gitbook/src/output/callHook.js @@ -0,0 +1,60 @@ +const Promise = require('../utils/promise'); +const timing = require('../utils/timing'); +const 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; + + const logger = output.getLogger(); + const plugins = output.getPlugins(); + + logger.debug.ln('calling hook "' + name + '"'); + + // Create the JS context for plugins + const 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) { + const 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/packages/gitbook/src/output/callPageHook.js b/packages/gitbook/src/output/callPageHook.js new file mode 100644 index 0000000..af249c9 --- /dev/null +++ b/packages/gitbook/src/output/callPageHook.js @@ -0,0 +1,28 @@ +const Api = require('../api'); +const 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/packages/gitbook/src/output/createTemplateEngine.js b/packages/gitbook/src/output/createTemplateEngine.js new file mode 100644 index 0000000..03e7d84 --- /dev/null +++ b/packages/gitbook/src/output/createTemplateEngine.js @@ -0,0 +1,45 @@ +const Templating = require('../templating'); +const TemplateEngine = require('../models/templateEngine'); + +const Api = require('../api'); +const Plugins = require('../plugins'); + +const defaultBlocks = require('../constants/defaultBlocks'); +const 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) { + const plugins = output.getPlugins(); + const book = output.getBook(); + const rootFolder = book.getContentRoot(); + const logger = book.getLogger(); + + let filters = Plugins.listFilters(plugins); + let blocks = Plugins.listBlocks(plugins); + + // Extend with default + blocks = defaultBlocks.merge(blocks); + filters = defaultFilters.merge(filters); + + // Create loader + const transformFn = Templating.replaceShortcuts.bind(null, blocks); + const loader = new Templating.ConrefsLoader(rootFolder, transformFn, logger); + + // Create API context + const context = Api.encodeGlobal(output); + + return new TemplateEngine({ + filters, + blocks, + loader, + context + }); +} + +module.exports = createTemplateEngine; diff --git a/packages/gitbook/src/output/ebook/getConvertOptions.js b/packages/gitbook/src/output/ebook/getConvertOptions.js new file mode 100644 index 0000000..b37c68e --- /dev/null +++ b/packages/gitbook/src/output/ebook/getConvertOptions.js @@ -0,0 +1,73 @@ +const extend = require('extend'); + +const Promise = require('../../utils/promise'); +const getPDFTemplate = require('./getPDFTemplate'); +const getCoverPath = require('./getCoverPath'); + +/** + Generate options for ebook-convert + + @param {Output} + @return {Promise<Object>} +*/ +function getConvertOptions(output) { + const options = output.getOptions(); + const format = options.get('format'); + + const book = output.getBook(); + const config = book.getConfig(); + + return Promise() + .then(function() { + const coverPath = getCoverPath(output); + let 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) { + const 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/packages/gitbook/src/output/ebook/getCoverPath.js b/packages/gitbook/src/output/ebook/getCoverPath.js new file mode 100644 index 0000000..cf18c8d --- /dev/null +++ b/packages/gitbook/src/output/ebook/getCoverPath.js @@ -0,0 +1,30 @@ +const path = require('path'); +const fs = require('../../utils/fs'); + +/** + Resolve path to cover file to use + + @param {Output} + @return {String} +*/ +function getCoverPath(output) { + const outputRoot = output.getRoot(); + const book = output.getBook(); + const config = book.getConfig(); + const coverName = config.getValue('cover', 'cover.jpg'); + + // Resolve to absolute + let cover = fs.pickFile(outputRoot, coverName); + if (cover) { + return cover; + } + + // Multilingual? try parent folder + if (book.isLanguageBook()) { + cover = fs.pickFile(path.join(outputRoot, '..'), coverName); + } + + return cover; +} + +module.exports = getCoverPath; diff --git a/packages/gitbook/src/output/ebook/getPDFTemplate.js b/packages/gitbook/src/output/ebook/getPDFTemplate.js new file mode 100644 index 0000000..c0faed3 --- /dev/null +++ b/packages/gitbook/src/output/ebook/getPDFTemplate.js @@ -0,0 +1,41 @@ +const juice = require('juice'); + +const WebsiteGenerator = require('../website'); +const JSONUtils = require('../../json'); +const Templating = require('../../templating'); +const Promise = require('../../utils/promise'); + + +/** + Generate PDF header/footer templates + + @param {Output} output + @param {String} type + @return {String} +*/ +function getPDFTemplate(output, type) { + const filePath = 'pdf_' + type + '.html'; + const outputRoot = output.getRoot(); + const engine = WebsiteGenerator.createTemplateEngine(output, filePath); + + // Generate context + const context = JSONUtils.encodeOutput(output); + context.page = { + num: '_PAGENUM_', + title: '_SECTION_' + }; + + // Render the theme + return Templating.renderFile(engine, 'ebook/' + filePath, context) + + // Inline css and assets + .then(function(tplOut) { + return Promise.nfcall(juice.juiceResources, tplOut.getContent(), { + webResources: { + relativeTo: outputRoot + } + }); + }); +} + +module.exports = getPDFTemplate; diff --git a/packages/gitbook/src/output/ebook/index.js b/packages/gitbook/src/output/ebook/index.js new file mode 100644 index 0000000..c5c07c2 --- /dev/null +++ b/packages/gitbook/src/output/ebook/index.js @@ -0,0 +1,9 @@ +const extend = require('extend'); +const WebsiteGenerator = require('../website'); + +module.exports = extend({}, WebsiteGenerator, { + name: 'ebook', + Options: require('./options'), + onPage: require('./onPage'), + onFinish: require('./onFinish') +}); diff --git a/packages/gitbook/src/output/ebook/onFinish.js b/packages/gitbook/src/output/ebook/onFinish.js new file mode 100644 index 0000000..adff798 --- /dev/null +++ b/packages/gitbook/src/output/ebook/onFinish.js @@ -0,0 +1,91 @@ +const path = require('path'); + +const WebsiteGenerator = require('../website'); +const JSONUtils = require('../../json'); +const Templating = require('../../templating'); +const Promise = require('../../utils/promise'); +const error = require('../../utils/error'); +const command = require('../../utils/command'); +const writeFile = require('../helper/writeFile'); + +const getConvertOptions = require('./getConvertOptions'); +const SUMMARY_FILE = 'SUMMARY.html'; + +/** + Write the SUMMARY.html + + @param {Output} + @return {Output} +*/ +function writeSummary(output) { + const options = output.getOptions(); + const prefix = options.get('prefix'); + + const filePath = SUMMARY_FILE; + const engine = WebsiteGenerator.createTemplateEngine(output, filePath); + const context = JSONUtils.encodeOutput(output); + + // Render the theme + return Templating.renderFile(engine, prefix + '/summary.html', context) + + // Write it to the disk + .then(function(tplOut) { + return writeFile(output, filePath, tplOut.getContent()); + }); +} + +/** + Generate the ebook file as "index.pdf" + + @param {Output} + @return {Output} +*/ +function runEbookConvert(output) { + const logger = output.getLogger(); + const options = output.getOptions(); + const format = options.get('format'); + const outputFolder = output.getRoot(); + + if (!format) { + return Promise(output); + } + + return getConvertOptions(output) + .then(function(options) { + const cmd = [ + 'ebook-convert', + path.resolve(outputFolder, SUMMARY_FILE), + 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/packages/gitbook/src/output/ebook/onPage.js b/packages/gitbook/src/output/ebook/onPage.js new file mode 100644 index 0000000..520d296 --- /dev/null +++ b/packages/gitbook/src/output/ebook/onPage.js @@ -0,0 +1,24 @@ +const WebsiteGenerator = require('../website'); +const Modifiers = require('../modifiers'); + +/** + Write a page for ebook output + + @param {Output} output + @param {Output} +*/ +function onPage(output, page) { + const options = output.getOptions(); + + // Inline assets + return Modifiers.modifyHTML(page, [ + Modifiers.inlineAssets(options.get('root'), page.getFile().getPath()) + ]) + + // Write page using website generator + .then(function(resultPage) { + return WebsiteGenerator.onPage(output, resultPage); + }); +} + +module.exports = onPage; diff --git a/packages/gitbook/src/output/ebook/options.js b/packages/gitbook/src/output/ebook/options.js new file mode 100644 index 0000000..4156fac --- /dev/null +++ b/packages/gitbook/src/output/ebook/options.js @@ -0,0 +1,17 @@ +const Immutable = require('immutable'); + +const 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/packages/gitbook/src/output/generateAssets.js b/packages/gitbook/src/output/generateAssets.js new file mode 100644 index 0000000..2129553 --- /dev/null +++ b/packages/gitbook/src/output/generateAssets.js @@ -0,0 +1,26 @@ +const Promise = require('../utils/promise'); + +/** + Output all assets using a generator + + @param {Generator} generator + @param {Output} output + @return {Promise<Output>} +*/ +function generateAssets(generator, output) { + const assets = output.getAssets(); + const 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/packages/gitbook/src/output/generateBook.js b/packages/gitbook/src/output/generateBook.js new file mode 100644 index 0000000..ea8c78e --- /dev/null +++ b/packages/gitbook/src/output/generateBook.js @@ -0,0 +1,193 @@ +const path = require('path'); +const Immutable = require('immutable'); + +const Output = require('../models/output'); +const Promise = require('../utils/promise'); +const fs = require('../utils/fs'); + +const callHook = require('./callHook'); +const preparePlugins = require('./preparePlugins'); +const preparePages = require('./preparePages'); +const prepareAssets = require('./prepareAssets'); +const generateAssets = require('./generateAssets'); +const 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) { + const book = output.getBook(); + const config = book.getConfig(); + const values = config.getValues(); + + return values.toJS(); + }, + function(output, result) { + let book = output.getBook(); + let config = book.getConfig(); + + config = config.updateValues(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) { + const book = output.getBook(); + + if (!book.isMultilingual()) { + return; + } + + const logger = book.getLogger(); + const books = book.getBooks(); + const outputRoot = output.getRoot(); + const plugins = output.getPlugins(); + const state = output.getState(); + const options = output.getOptions(); + + return Promise.forEach(books, function(langBook) { + // Inherits plugins list, options and state + const langOptions = options.set('root', path.join(outputRoot, langBook.getLanguage())); + const langOutput = new Output({ + book: langBook, + options: langOptions, + state, + generator: generator.name, + plugins + }); + + logger.info.ln(''); + logger.info.ln('generating language "' + langBook.getLanguage() + '"'); + 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); + const state = generator.State ? generator.State({}) : Immutable.Map(); + const start = Date.now(); + + return Promise( + new Output({ + book, + options, + state, + generator: generator.name + }) + ) + + // Cleanup output folder + .then(function(output) { + const logger = output.getLogger(); + const rootFolder = output.getRoot(); + + logger.debug.ln('cleanup folder "' + rootFolder + '"'); + return fs.ensureFolder(rootFolder) + .thenResolve(output); + }) + + .then(processOutput.bind(null, generator)) + + // Log duration and end message + .then(function(output) { + const logger = output.getLogger(); + const end = Date.now(); + const duration = (end - start) / 1000; + + logger.info.ok('generation finished with success in ' + duration.toFixed(1) + 's !'); + + return output; + }); +} + +module.exports = generateBook; diff --git a/packages/gitbook/src/output/generatePage.js b/packages/gitbook/src/output/generatePage.js new file mode 100644 index 0000000..0fc99a3 --- /dev/null +++ b/packages/gitbook/src/output/generatePage.js @@ -0,0 +1,79 @@ +const path = require('path'); + +const Promise = require('../utils/promise'); +const error = require('../utils/error'); +const timing = require('../utils/timing'); + +const Templating = require('../templating'); +const JSONUtils = require('../json'); +const createTemplateEngine = require('./createTemplateEngine'); +const callPageHook = require('./callPageHook'); + +/** + * Prepare and generate HTML for a page + * + * @param {Output} output + * @param {Page} page + * @return {Promise<Page>} + */ +function generatePage(output, page) { + const book = output.getBook(); + const engine = createTemplateEngine(output); + + return timing.measure( + 'page.generate', + Promise(page) + .then(function(resultPage) { + const file = resultPage.getFile(); + const filePath = file.getPath(); + const parser = file.getParser(); + const context = JSONUtils.encodeOutputWithPage(output, 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.preparePage(currentPage.getContent()); + }) + + // Render templating syntax + .then(function(content) { + const absoluteFilePath = path.join(book.getContentRoot(), filePath); + return Templating.render(engine, absoluteFilePath, content, context); + }) + + .then(function(output) { + const content = output.getContent(); + + return parser.parsePage(content) + .then(function(result) { + return output.setContent(result.content); + }); + }) + + // Post processing for templating syntax + .then(function(output) { + return Templating.postRender(engine, output); + }) + + // 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/packages/gitbook/src/output/generatePages.js b/packages/gitbook/src/output/generatePages.js new file mode 100644 index 0000000..21b6610 --- /dev/null +++ b/packages/gitbook/src/output/generatePages.js @@ -0,0 +1,36 @@ +const Promise = require('../utils/promise'); +const generatePage = require('./generatePage'); + +/** + Output all pages using a generator + + @param {Generator} generator + @param {Output} output + @return {Promise<Output>} +*/ +function generatePages(generator, output) { + const pages = output.getPages(); + const logger = output.getLogger(); + + // Is generator ignoring assets? + if (!generator.onPage) { + return Promise(output); + } + + return Promise.reduce(pages, function(out, page) { + const 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/packages/gitbook/src/output/getModifiers.js b/packages/gitbook/src/output/getModifiers.js new file mode 100644 index 0000000..df32fcb --- /dev/null +++ b/packages/gitbook/src/output/getModifiers.js @@ -0,0 +1,73 @@ +const Modifiers = require('./modifiers'); +const resolveFileToURL = require('./helper/resolveFileToURL'); +const Api = require('../api'); +const Plugins = require('../plugins'); +const Promise = require('../utils/promise'); +const defaultBlocks = require('../constants/defaultBlocks'); +const fileToOutput = require('./helper/fileToOutput'); + +const CODEBLOCK = 'code'; + +/** + * Return default modifier to prepare a page for + * rendering. + * + * @return {Array<Modifier>} + */ +function getModifiers(output, page) { + const book = output.getBook(); + const plugins = output.getPlugins(); + const glossary = book.getGlossary(); + const file = page.getFile(); + + // Glossary entries + const entries = glossary.getEntries(); + const glossaryFile = glossary.getFile(); + const glossaryFilename = fileToOutput(output, glossaryFile.getPath()); + + // Current file path + const currentFilePath = file.getPath(); + + // Get TemplateBlock for highlighting + const blocks = Plugins.listBlocks(plugins); + const code = blocks.get(CODEBLOCK) || defaultBlocks.get(CODEBLOCK); + + // Current context + const context = Api.encodeGlobal(output); + + return [ + // Normalize IDs on headings + Modifiers.addHeadingId, + + // Annotate text with glossary entries + Modifiers.annotateText.bind(null, entries, glossaryFilename), + + // Resolve images + Modifiers.resolveImages.bind(null, currentFilePath), + + // Resolve links (.md -> .html) + Modifiers.resolveLinks.bind(null, + currentFilePath, + resolveFileToURL.bind(null, output) + ), + + // 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/packages/gitbook/src/output/helper/fileToOutput.js b/packages/gitbook/src/output/helper/fileToOutput.js new file mode 100644 index 0000000..a514854 --- /dev/null +++ b/packages/gitbook/src/output/helper/fileToOutput.js @@ -0,0 +1,32 @@ +const path = require('path'); + +const PathUtils = require('../../utils/path'); +const LocationUtils = require('../../utils/location'); + +const OUTPUT_EXTENSION = '.html'; + +/** + * Convert a filePath (absolute) to a filename for output + * + * @param {Output} output + * @param {String} filePath + * @return {String} + */ +function fileToOutput(output, filePath) { + const book = output.getBook(); + const readme = book.getReadme(); + const 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/packages/gitbook/src/output/helper/fileToURL.js b/packages/gitbook/src/output/helper/fileToURL.js new file mode 100644 index 0000000..a42bca6 --- /dev/null +++ b/packages/gitbook/src/output/helper/fileToURL.js @@ -0,0 +1,31 @@ +const path = require('path'); +const LocationUtils = require('../../utils/location'); + +const 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) { + const options = output.getOptions(); + const 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/packages/gitbook/src/output/helper/index.js b/packages/gitbook/src/output/helper/index.js new file mode 100644 index 0000000..f8bc109 --- /dev/null +++ b/packages/gitbook/src/output/helper/index.js @@ -0,0 +1,2 @@ + +module.exports = {}; diff --git a/packages/gitbook/src/output/helper/resolveFileToURL.js b/packages/gitbook/src/output/helper/resolveFileToURL.js new file mode 100644 index 0000000..907cfdd --- /dev/null +++ b/packages/gitbook/src/output/helper/resolveFileToURL.js @@ -0,0 +1,26 @@ +const LocationUtils = require('../../utils/location'); + +const 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, '', ''); + + const page = output.getPage(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/packages/gitbook/src/output/helper/writeFile.js b/packages/gitbook/src/output/helper/writeFile.js new file mode 100644 index 0000000..01a8e68 --- /dev/null +++ b/packages/gitbook/src/output/helper/writeFile.js @@ -0,0 +1,23 @@ +const path = require('path'); +const 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) { + const 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/packages/gitbook/src/output/index.js b/packages/gitbook/src/output/index.js new file mode 100644 index 0000000..574b3df --- /dev/null +++ b/packages/gitbook/src/output/index.js @@ -0,0 +1,24 @@ +const Immutable = require('immutable'); + +const 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 +}; diff --git a/packages/gitbook/src/output/json/index.js b/packages/gitbook/src/output/json/index.js new file mode 100644 index 0000000..361da06 --- /dev/null +++ b/packages/gitbook/src/output/json/index.js @@ -0,0 +1,7 @@ + +module.exports = { + name: 'json', + Options: require('./options'), + onPage: require('./onPage'), + onFinish: require('./onFinish') +}; diff --git a/packages/gitbook/src/output/json/onFinish.js b/packages/gitbook/src/output/json/onFinish.js new file mode 100644 index 0000000..b05057a --- /dev/null +++ b/packages/gitbook/src/output/json/onFinish.js @@ -0,0 +1,47 @@ +const path = require('path'); + +const Promise = require('../../utils/promise'); +const fs = require('../../utils/fs'); +const JSONUtils = require('../../json'); + +/** + Finish the generation + + @param {Output} + @return {Output} +*/ +function onFinish(output) { + const book = output.getBook(); + const outputRoot = output.getRoot(); + + if (!book.isMultilingual()) { + return Promise(output); + } + + // Get main language + const languages = book.getLanguages(); + const mainLanguage = languages.getDefaultLanguage(); + + // Read the main JSON + return fs.readFile(path.resolve(outputRoot, mainLanguage.getID(), 'README.json'), 'utf8') + + // Extend the JSON + .then(function(content) { + const json = JSON.parse(content); + + json.languages = JSONUtils.encodeLanguages(languages); + + return json; + }) + + .then(function(json) { + return fs.writeFile( + path.resolve(outputRoot, 'README.json'), + JSON.stringify(json, null, 4) + ); + }) + + .thenResolve(output); +} + +module.exports = onFinish; diff --git a/packages/gitbook/src/output/json/onPage.js b/packages/gitbook/src/output/json/onPage.js new file mode 100644 index 0000000..8bee711 --- /dev/null +++ b/packages/gitbook/src/output/json/onPage.js @@ -0,0 +1,43 @@ +const JSONUtils = require('../../json'); +const PathUtils = require('../../utils/path'); +const Modifiers = require('../modifiers'); +const writeFile = require('../helper/writeFile'); +const getModifiers = require('../getModifiers'); + +const JSON_VERSION = '3'; + +/** + * Write a page as a json file + * + * @param {Output} output + * @param {Page} page + */ +function onPage(output, page) { + const file = page.getFile(); + const readme = output.getBook().getReadme().getFile(); + + return Modifiers.modifyHTML(page, getModifiers(output, page)) + .then(function(resultPage) { + // Generate the JSON + const 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 + let 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/packages/gitbook/src/output/json/options.js b/packages/gitbook/src/output/json/options.js new file mode 100644 index 0000000..2a9de0e --- /dev/null +++ b/packages/gitbook/src/output/json/options.js @@ -0,0 +1,8 @@ +const Immutable = require('immutable'); + +const Options = Immutable.Record({ + // Root folder for the output + root: String() +}); + +module.exports = Options; diff --git a/packages/gitbook/src/output/modifiers/__tests__/addHeadingId.js b/packages/gitbook/src/output/modifiers/__tests__/addHeadingId.js new file mode 100644 index 0000000..4d77e75 --- /dev/null +++ b/packages/gitbook/src/output/modifiers/__tests__/addHeadingId.js @@ -0,0 +1,25 @@ +const cheerio = require('cheerio'); +const addHeadingId = require('../addHeadingId'); + +describe('addHeadingId', function() { + it('should add an ID if none', function() { + const $ = cheerio.load('<h1>Hello World</h1><h2>Cool !!</h2>'); + + return addHeadingId($) + .then(function() { + const html = $.html(); + expect(html).toBe('<h1 id="hello-world">Hello World</h1><h2 id="cool-">Cool !!</h2>'); + }); + }); + + it('should not change existing IDs', function() { + const $ = cheerio.load('<h1 id="awesome">Hello World</h1>'); + + return addHeadingId($) + .then(function() { + const html = $.html(); + expect(html).toBe('<h1 id="awesome">Hello World</h1>'); + }); + }); +}); + diff --git a/packages/gitbook/src/output/modifiers/__tests__/annotateText.js b/packages/gitbook/src/output/modifiers/__tests__/annotateText.js new file mode 100644 index 0000000..28a5cc5 --- /dev/null +++ b/packages/gitbook/src/output/modifiers/__tests__/annotateText.js @@ -0,0 +1,45 @@ +const Immutable = require('immutable'); +const cheerio = require('cheerio'); +const GlossaryEntry = require('../../../models/glossaryEntry'); +const annotateText = require('../annotateText'); + +describe('annotateText', function() { + const entries = Immutable.List([ + GlossaryEntry({ name: 'Word' }), + GlossaryEntry({ name: 'Multiple Words' }) + ]); + + it('should annotate text', function() { + const $ = cheerio.load('<p>This is a word, and multiple words</p>'); + + annotateText(entries, 'GLOSSARY.md', $); + + const links = $('a'); + expect(links.length).toBe(2); + + const word = $(links.get(0)); + expect(word.attr('href')).toBe('/GLOSSARY.md#word'); + expect(word.text()).toBe('word'); + expect(word.hasClass('glossary-term')).toBeTruthy(); + + const 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() { + const $ = cheerio.load('<script>This is a word, and multiple words</script>'); + + annotateText(entries, 'GLOSSARY.md', $); + expect($('a').length).toBe(0); + }); + + it('should not annotate when has class "no-glossary"', function() { + const $ = cheerio.load('<p class="no-glossary">This is a word, and multiple words</p>'); + + annotateText(entries, 'GLOSSARY.md', $); + expect($('a').length).toBe(0); + }); +}); + diff --git a/packages/gitbook/src/output/modifiers/__tests__/fetchRemoteImages.js b/packages/gitbook/src/output/modifiers/__tests__/fetchRemoteImages.js new file mode 100644 index 0000000..9145cae --- /dev/null +++ b/packages/gitbook/src/output/modifiers/__tests__/fetchRemoteImages.js @@ -0,0 +1,39 @@ +const cheerio = require('cheerio'); +const tmp = require('tmp'); +const path = require('path'); + +const URL = 'https://upload.wikimedia.org/wikipedia/commons/thumb/4/47/PNG_transparency_demonstration_1.png/280px-PNG_transparency_demonstration_1.png'; + +describe('fetchRemoteImages', function() { + let dir; + const fetchRemoteImages = require('../fetchRemoteImages'); + + beforeEach(function() { + dir = tmp.dirSync(); + }); + + it('should download image file', function() { + const $ = cheerio.load('<img src="' + URL + '" />'); + + return fetchRemoteImages(dir.name, 'index.html', $) + .then(function() { + const $img = $('img'); + const src = $img.attr('src'); + + expect(dir.name).toHaveFile(src); + }); + }); + + it('should download image file and replace with relative path', function() { + const $ = cheerio.load('<img src="' + URL + '" />'); + + return fetchRemoteImages(dir.name, 'test/index.html', $) + .then(function() { + const $img = $('img'); + const src = $img.attr('src'); + + expect(dir.name).toHaveFile(path.join('test', src)); + }); + }); +}); + diff --git a/packages/gitbook/src/output/modifiers/__tests__/highlightCode.js b/packages/gitbook/src/output/modifiers/__tests__/highlightCode.js new file mode 100644 index 0000000..d93417b --- /dev/null +++ b/packages/gitbook/src/output/modifiers/__tests__/highlightCode.js @@ -0,0 +1,59 @@ +const cheerio = require('cheerio'); +const Promise = require('../../../utils/promise'); +const highlightCode = require('../highlightCode'); + +describe('highlightCode', function() { + function doHighlight(lang, code) { + return { + text: '' + (lang || '') + '$' + code + }; + } + + function doHighlightAsync(lang, code) { + return Promise() + .then(function() { + return doHighlight(lang, code); + }); + } + + it('should call it for normal code element', function() { + const $ = cheerio.load('<p>This is a <code>test</code></p>'); + + return highlightCode(doHighlight, $) + .then(function() { + const $code = $('code'); + expect($code.text()).toBe('$test'); + }); + }); + + it('should call it for markdown code block', function() { + const $ = cheerio.load('<pre><code class="lang-js">test</code></pre>'); + + return highlightCode(doHighlight, $) + .then(function() { + const $code = $('code'); + expect($code.text()).toBe('js$test'); + }); + }); + + it('should call it for asciidoc code block', function() { + const $ = cheerio.load('<pre><code class="language-python">test</code></pre>'); + + return highlightCode(doHighlight, $) + .then(function() { + const $code = $('code'); + expect($code.text()).toBe('python$test'); + }); + }); + + it('should accept async highlighter', function() { + const $ = cheerio.load('<pre><code class="language-python">test</code></pre>'); + + return highlightCode(doHighlightAsync, $) + .then(function() { + const $code = $('code'); + expect($code.text()).toBe('python$test'); + }); + }); +}); + diff --git a/packages/gitbook/src/output/modifiers/__tests__/inlinePng.js b/packages/gitbook/src/output/modifiers/__tests__/inlinePng.js new file mode 100644 index 0000000..fd031b0 --- /dev/null +++ b/packages/gitbook/src/output/modifiers/__tests__/inlinePng.js @@ -0,0 +1,24 @@ +const cheerio = require('cheerio'); +const tmp = require('tmp'); +const inlinePng = require('../inlinePng'); + +describe('inlinePng', function() { + let dir; + + beforeEach(function() { + dir = tmp.dirSync(); + }); + + it('should write an inline PNG using data URI as a file', function() { + const $ = cheerio.load('<img alt="GitBook Logo 20x20" src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABQAAAAUEAYAAADdGcFOAAAABGdBTUEAALGPC/xhBQAAACBjSFJNAAB6JgAAgIQAAPoAAACA6AAAdTAAAOpgAAA6mAAAF3CculE8AAAACXBIWXMAAAsTAAALEwEAmpwYAAABWWlUWHRYTUw6Y29tLmFkb2JlLnhtcAAAAAAAPHg6eG1wbWV0YSB4bWxuczp4PSJhZG9iZTpuczptZXRhLyIgeDp4bXB0az0iWE1QIENvcmUgNS40LjAiPgogICA8cmRmOlJERiB4bWxuczpyZGY9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkvMDIvMjItcmRmLXN5bnRheC1ucyMiPgogICAgICA8cmRmOkRlc2NyaXB0aW9uIHJkZjphYm91dD0iIgogICAgICAgICAgICB4bWxuczp0aWZmPSJodHRwOi8vbnMuYWRvYmUuY29tL3RpZmYvMS4wLyI+CiAgICAgICAgIDx0aWZmOk9yaWVudGF0aW9uPjE8L3RpZmY6T3JpZW50YXRpb24+CiAgICAgIDwvcmRmOkRlc2NyaXB0aW9uPgogICA8L3JkZjpSREY+CjwveDp4bXBtZXRhPgpMwidZAAAF+klEQVRIDY3Wf5CVVR3H8c9z791fyI9dQwdQ4TTI7wEWnQZZAa/mJE4Z0OaKUuN1KoaykZxUGGHay+iIVFMoEYrUPhDCKEKW2ChT8dA0RCSxWi6EW3sYYpcfxq5C+4O9957O+7m7O/qHQ9/XzH1+nHuec57z8wkWTsKw0y6N/LxXN6KzTnEUHi8eP/l3YStSU/MdsYvBbGh8six2YXcbcgc++QkfTQkWz/81KtqDA0hlUoWnsX+5uxe5X365BB9my2bjrHNHccLk16BpS9CExjcmXMDbD6wehdyEjxbjz1uK1zn9qga6dcfnMLXeXY/qjuQqTF4W1MKke8ZgeNhjMCxMPIWSd4OF78C55CFI/1kF6WwXpMqjkAZ/CKniNDrCsmU4lE1YbPlgR2x7R39FF23D4mq3A1+Z35PGTNs1E1XhxcGQOh6HNPwXkK56BVJhOaRg/pvoHXNxHFw410B25EYE2RMvI0i/twFJvXcrFObykEa+DmnQGLwYqR0l2a6JqItaj8C/4E2QxtZCofkC8tF1t8HZc/fAZaLnIF2xEsoEtW1w7vBSSFtfhDTnCki9cSi81Ain1uko2Ld+Dmf2rkUq0/5t+PYbFtPQdkjzNiAXTWtDEF49FgkzJInAVPwNyhzcDOmrdZCm/Rn+ebWtcPs+/U24hmg2XL0rRkPPELh9R8fDtXR2oC/VuZbGaci79Ajkb6lZgfyYtyzy/X9s6T/pO/ZfN/RdNxxIwTWM2wbX8KVmuIaEqmKm6zEondwGpd0SyOy5DrJ//TFkX9kMhd3XQHbEVCSsm4OECV5HIv2p15CwfWPSntoHRbv2Q1HzSvSlSqZwATIuBxk/zZBOBbdB+u9hSKU3Q7pwAjInZkFm6U8hu7MSMqe/Dqn8fUj5GVCmpxK+4N/F1LMa0p5eSOPqIPP7NGSunAI/+R6GnzQzIBt8A1LC/QZ+6HwLst1rITv0n5CtXgSZ78yFTNkR+FdeDZneJkip3fAtsQ5Scilkek7CH9dAmjIWvkK7IXXOh6/IzZDNPQdZXR1TQmdjKv0ZfEu0YKDpNflpyG5aDtnRv8VAuu3dBV+huyBbvgdS97tQNLQc0mfugKy5Cb4BipPIXvsUpK5N8Mvao/Bd3QDZRH9Rrtj3Cl6FHwPFMLmNkKrj8BnHoT+XX6f2wl+XxFS4Ab7C72Dgf7bi+5DpTkNm8kQMpCs/BzIlz8LfPxnzLdh3EjwMX4GX4Ju4GNb9A1L7k/D3J8b6kv2LFCtmCmcgUzoJsr2z4MfwFsh87xikZefg188fYaAhpPUxm3ge/vFnYkoED0HqeQiyJYcwkNGWnoNv6s9C1p1Bf/389VYoCjohW7UfMms3wXdpBv7+FEiPLIHs4DIMNERUNhbSpY3wk6QOsqlCDVx2xCrInMpBmfNPQOnzKxBkkrugdOl9GKigSZZCUWIm/GqwDtLUI5D+WAOlb9wKP0YvQLbjZSjsaYaL/n0/FA3fDtnCGihK5UYjCK+ZDr+TDIKLdm2Fs1UOzo76F5wO74XSZj0S6d7RCMLkCshcXALZxaWQRjXDZQ62oRAdCeG/Ju5HELX2QFH3C0hkRy6GovyfwF58AoVbguOxyB2H7/I34Gf11yANnQSp7Vr4MbQH0vg7kbNNp5AM3UrIVDchnz56B1Jm573wW9gZSFVPwO/hefg5FsIvN09CchtQCIOFw/F5U8ii3CZn4cqo7C8YlXEPYkx9cacZl00+iwnprrtwVdj1Q/gXmAs/pu6LZc9XQOGgSvh19n2cDZN341g2EcfxTEGwH/RewqlMsUfbbWIGLjUG+j/j9nokD1beiOvLS5dhjr30Gu6ZnivgdtM/6VJvY1+6pBHbH+h9CX84vfMxNJtisYVFlys+WNCIZJNmIsjohlhNSQC3f8R55H+y/hjkN8GPR9ndCLJxT4/3n0Px51ay8XQnNrYfDJHf//Fc0oMrEZSeeQGJ7+Z+gKCgLbHNWgXnB9FlYt5JaN38JIINC95EakjtAqQeuUx21c5B6tEFf0fSfbEFQf28Z6D6y+X/H0jf40QQJhYwAAAAAElFTkSuQmCC"/>'); + + return inlinePng(dir.name, 'index.html', $) + .then(function() { + const $img = $('img'); + const src = $img.attr('src'); + + expect(dir.name).toHaveFile(src); + }); + }); +}); + diff --git a/packages/gitbook/src/output/modifiers/__tests__/resolveLinks.js b/packages/gitbook/src/output/modifiers/__tests__/resolveLinks.js new file mode 100644 index 0000000..167af5d --- /dev/null +++ b/packages/gitbook/src/output/modifiers/__tests__/resolveLinks.js @@ -0,0 +1,104 @@ +const path = require('path'); +const cheerio = require('cheerio'); +const resolveLinks = require('../resolveLinks'); + +describe('resolveLinks', function() { + function resolveFileBasic(href) { + return 'fakeDir/' + href; + } + + function resolveFileCustom(href) { + if (path.extname(href) == '.md') { + return href.slice(0, -3) + '.html'; + } + + return href; + } + + describe('Absolute path', function() { + const TEST = '<p>This is a <a href="/test/cool.md"></a></p>'; + + it('should resolve path starting by "/" in root directory', function() { + const $ = cheerio.load(TEST); + + return resolveLinks('hello.md', resolveFileBasic, $) + .then(function() { + const link = $('a'); + expect(link.attr('href')).toBe('fakeDir/test/cool.md'); + }); + }); + + it('should resolve path starting by "/" in child directory', function() { + const $ = cheerio.load(TEST); + + return resolveLinks('afolder/hello.md', resolveFileBasic, $) + .then(function() { + const link = $('a'); + expect(link.attr('href')).toBe('../fakeDir/test/cool.md'); + }); + }); + }); + + describe('Anchor', function() { + it('should prevent anchors in resolution', function() { + const TEST = '<p>This is a <a href="test/cool.md#an-anchor"></a></p>'; + const $ = cheerio.load(TEST); + + return resolveLinks('hello.md', resolveFileCustom, $) + .then(function() { + const link = $('a'); + expect(link.attr('href')).toBe('test/cool.html#an-anchor'); + }); + }); + + it('should ignore pure anchor links', function() { + const TEST = '<p>This is a <a href="#an-anchor"></a></p>'; + const $ = cheerio.load(TEST); + + return resolveLinks('hello.md', resolveFileCustom, $) + .then(function() { + const link = $('a'); + expect(link.attr('href')).toBe('#an-anchor'); + }); + }); + }); + + describe('Custom Resolver', function() { + const TEST = '<p>This is a <a href="/test/cool.md"></a> <a href="afile.png"></a></p>'; + + it('should resolve path correctly for absolute path', function() { + const $ = cheerio.load(TEST); + + return resolveLinks('hello.md', resolveFileCustom, $) + .then(function() { + const link = $('a').first(); + expect(link.attr('href')).toBe('test/cool.html'); + }); + }); + + it('should resolve path correctly for absolute path (2)', function() { + const $ = cheerio.load(TEST); + + return resolveLinks('afodler/hello.md', resolveFileCustom, $) + .then(function() { + const link = $('a').first(); + expect(link.attr('href')).toBe('../test/cool.html'); + }); + }); + }); + + describe('External link', function() { + const TEST = '<p>This is a <a href="http://www.github.com">external link</a></p>'; + + it('should have target="_blank" attribute', function() { + const $ = cheerio.load(TEST); + + return resolveLinks('hello.md', resolveFileBasic, $) + .then(function() { + const link = $('a'); + expect(link.attr('target')).toBe('_blank'); + }); + }); + }); + +}); diff --git a/packages/gitbook/src/output/modifiers/__tests__/svgToImg.js b/packages/gitbook/src/output/modifiers/__tests__/svgToImg.js new file mode 100644 index 0000000..4bdab59 --- /dev/null +++ b/packages/gitbook/src/output/modifiers/__tests__/svgToImg.js @@ -0,0 +1,24 @@ +const cheerio = require('cheerio'); +const tmp = require('tmp'); + +describe('svgToImg', function() { + let dir; + const svgToImg = require('../svgToImg'); + + beforeEach(function() { + dir = tmp.dirSync(); + }); + + it('should write svg as a file', function() { + const $ = 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() { + const $img = $('img'); + const src = $img.attr('src'); + + expect(dir.name).toHaveFile(src); + }); + }); +}); + diff --git a/packages/gitbook/src/output/modifiers/__tests__/svgToPng.js b/packages/gitbook/src/output/modifiers/__tests__/svgToPng.js new file mode 100644 index 0000000..0a12938 --- /dev/null +++ b/packages/gitbook/src/output/modifiers/__tests__/svgToPng.js @@ -0,0 +1,32 @@ +const cheerio = require('cheerio'); +const tmp = require('tmp'); +const path = require('path'); + +const svgToImg = require('../svgToImg'); +const svgToPng = require('../svgToPng'); + +describe('svgToPng', function() { + let dir; + + beforeEach(function() { + dir = tmp.dirSync(); + }); + + it('should write svg as png file', function() { + const $ = 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>'); + const fileName = 'index.html'; + + return svgToImg(dir.name, fileName, $) + .then(function() { + return svgToPng(dir.name, fileName, $); + }) + .then(function() { + const $img = $('img'); + const src = $img.attr('src'); + + expect(dir.name).toHaveFile(src); + expect(path.extname(src)).toBe('.png'); + }); + }); +}); + diff --git a/packages/gitbook/src/output/modifiers/addHeadingId.js b/packages/gitbook/src/output/modifiers/addHeadingId.js new file mode 100644 index 0000000..e5bab3e --- /dev/null +++ b/packages/gitbook/src/output/modifiers/addHeadingId.js @@ -0,0 +1,23 @@ +const slug = require('github-slugid'); +const 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/packages/gitbook/src/output/modifiers/annotateText.js b/packages/gitbook/src/output/modifiers/annotateText.js new file mode 100644 index 0000000..36ee4e9 --- /dev/null +++ b/packages/gitbook/src/output/modifiers/annotateText.js @@ -0,0 +1,91 @@ +const escape = require('escape-html'); + +// Selector to ignore +const 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() { + let node = this.firstChild, val, new_val; + // Elements to be removed at the end. + const 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 {String} glossaryFilePath + * @param {HTMLDom} $ + */ +function annotateText(entries, glossaryFilePath, $) { + entries.forEach(function(entry) { + const entryId = entry.getID(); + const name = entry.getName(); + const description = entry.getDescription(); + const searchRegex = new RegExp('\\b(' + pregQuote(name.toLowerCase()) + ')\\b' , 'gi'); + + $('*').each(function() { + const $this = $(this); + + if ( + $this.is(ANNOTATION_IGNORE) || + $this.parents(ANNOTATION_IGNORE).length > 0 + ) return; + + replaceText($, this, searchRegex, function(match) { + return '<a href="/' + glossaryFilePath + '#' + entryId + '" ' + + 'class="glossary-term" title="' + escape(description) + '">' + + match + + '</a>'; + }); + }); + + }); +} + +module.exports = annotateText; diff --git a/packages/gitbook/src/output/modifiers/editHTMLElement.js b/packages/gitbook/src/output/modifiers/editHTMLElement.js new file mode 100644 index 0000000..d0d2b19 --- /dev/null +++ b/packages/gitbook/src/output/modifiers/editHTMLElement.js @@ -0,0 +1,15 @@ +const Promise = require('../../utils/promise'); + +/** + Edit all elements matching a selector +*/ +function editHTMLElement($, selector, fn) { + const $elements = $(selector); + + return Promise.forEach($elements, function(el) { + const $el = $(el); + return fn($el); + }); +} + +module.exports = editHTMLElement; diff --git a/packages/gitbook/src/output/modifiers/fetchRemoteImages.js b/packages/gitbook/src/output/modifiers/fetchRemoteImages.js new file mode 100644 index 0000000..1732247 --- /dev/null +++ b/packages/gitbook/src/output/modifiers/fetchRemoteImages.js @@ -0,0 +1,44 @@ +const path = require('path'); +const crc = require('crc'); + +const editHTMLElement = require('./editHTMLElement'); +const fs = require('../../utils/fs'); +const LocationUtils = require('../../utils/location'); + +/** + Fetch all remote images + + @param {String} rootFolder + @param {String} currentFile + @param {HTMLDom} $ + @return {Promise} +*/ +function fetchRemoteImages(rootFolder, currentFile, $) { + const currentDirectory = path.dirname(currentFile); + + return editHTMLElement($, 'img', function($img) { + let src = $img.attr('src'); + const extension = path.extname(src); + + if (!LocationUtils.isExternal(src)) { + return; + } + + // We avoid generating twice the same PNG + const hash = crc.crc32(src).toString(16); + const fileName = hash + extension; + const 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/packages/gitbook/src/output/modifiers/highlightCode.js b/packages/gitbook/src/output/modifiers/highlightCode.js new file mode 100644 index 0000000..622432d --- /dev/null +++ b/packages/gitbook/src/output/modifiers/highlightCode.js @@ -0,0 +1,58 @@ +const is = require('is'); +const Immutable = require('immutable'); + +const Promise = require('../../utils/promise'); +const editHTMLElement = require('./editHTMLElement'); + +/** + Return language for a code blocks from a list of class names + + @param {Array<String>} + @return {String} +*/ +function getLanguageForClass(classNames) { + return Immutable.List(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) { + const classNames = ($code.attr('class') || '').split(' '); + const lang = getLanguageForClass(classNames); + const 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/packages/gitbook/src/output/modifiers/index.js b/packages/gitbook/src/output/modifiers/index.js new file mode 100644 index 0000000..f1daa2b --- /dev/null +++ b/packages/gitbook/src/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/packages/gitbook/src/output/modifiers/inlineAssets.js b/packages/gitbook/src/output/modifiers/inlineAssets.js new file mode 100644 index 0000000..1ed4344 --- /dev/null +++ b/packages/gitbook/src/output/modifiers/inlineAssets.js @@ -0,0 +1,29 @@ +const svgToImg = require('./svgToImg'); +const svgToPng = require('./svgToPng'); +const inlinePng = require('./inlinePng'); +const resolveImages = require('./resolveImages'); +const fetchRemoteImages = require('./fetchRemoteImages'); + +const 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, $)) + .then(inlinePng.bind(null, rootFolder, currentFile, $)); + }; +} + +module.exports = inlineAssets; diff --git a/packages/gitbook/src/output/modifiers/inlinePng.js b/packages/gitbook/src/output/modifiers/inlinePng.js new file mode 100644 index 0000000..218aaa2 --- /dev/null +++ b/packages/gitbook/src/output/modifiers/inlinePng.js @@ -0,0 +1,47 @@ +const crc = require('crc'); +const path = require('path'); + +const imagesUtil = require('../../utils/images'); +const fs = require('../../utils/fs'); +const LocationUtils = require('../../utils/location'); + +const editHTMLElement = require('./editHTMLElement'); + +/** + Convert all inline PNG images to PNG file + + @param {String} rootFolder + @param {HTMLDom} $ + @return {Promise} +*/ +function inlinePng(rootFolder, currentFile, $) { + const currentDirectory = path.dirname(currentFile); + + return editHTMLElement($, 'img', function($img) { + const src = $img.attr('src'); + if (!LocationUtils.isDataURI(src)) { + return; + } + + // We avoid generating twice the same PNG + const hash = crc.crc32(src).toString(16); + let fileName = hash + '.png'; + + // Result file path + const filePath = path.join(rootFolder, fileName); + + return fs.assertFile(filePath, function() { + return imagesUtil.convertInlinePNG(src, filePath); + }) + .then(function() { + // Convert filename to a relative filename + fileName = LocationUtils.relative(currentDirectory, fileName); + + // Replace src + $img.attr('src', fileName); + }); + }); +} + + +module.exports = inlinePng; diff --git a/packages/gitbook/src/output/modifiers/modifyHTML.js b/packages/gitbook/src/output/modifiers/modifyHTML.js new file mode 100644 index 0000000..00177fc --- /dev/null +++ b/packages/gitbook/src/output/modifiers/modifyHTML.js @@ -0,0 +1,25 @@ +const cheerio = require('cheerio'); +const 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) { + const html = page.getContent(); + const $ = cheerio.load(html); + + return Promise.forEach(operations, function(op) { + return op($); + }) + .then(function() { + const resultHTML = $.html(); + return page.set('content', resultHTML); + }); +} + +module.exports = modifyHTML; diff --git a/packages/gitbook/src/output/modifiers/resolveImages.js b/packages/gitbook/src/output/modifiers/resolveImages.js new file mode 100644 index 0000000..339ddeb --- /dev/null +++ b/packages/gitbook/src/output/modifiers/resolveImages.js @@ -0,0 +1,33 @@ +const path = require('path'); + +const LocationUtils = require('../../utils/location'); +const editHTMLElement = require('./editHTMLElement'); + +/** + Resolve all HTML images: + - /test.png in hello -> ../test.html + + @param {String} currentFile + @param {HTMLDom} $ +*/ +function resolveImages(currentFile, $) { + const currentDirectory = path.dirname(currentFile); + + return editHTMLElement($, 'img', function($img) { + let src = $img.attr('src'); + + if (LocationUtils.isExternal(src) || LocationUtils.isDataURI(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/packages/gitbook/src/output/modifiers/resolveLinks.js b/packages/gitbook/src/output/modifiers/resolveLinks.js new file mode 100644 index 0000000..8b15315 --- /dev/null +++ b/packages/gitbook/src/output/modifiers/resolveLinks.js @@ -0,0 +1,53 @@ +const path = require('path'); +const url = require('url'); + +const LocationUtils = require('../../utils/location'); +const 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, $) { + const currentDirectory = path.dirname(currentFile); + + return editHTMLElement($, 'a', function($a) { + let href = $a.attr('href'); + + // Don't change a tag without href + if (!href) { + return; + } + + if (LocationUtils.isExternal(href)) { + $a.attr('target', '_blank'); + return; + } + + // Split anchor + const parsed = url.parse(href); + href = parsed.pathname || ''; + + if (href) { + // Calcul absolute path for this + href = LocationUtils.toAbsolute(href, currentDirectory, '.'); + + // Resolve file + href = resolveFile(href); + + // Convert back to relative + href = LocationUtils.relative(currentDirectory, href); + } + + // Add back anchor + href = href + (parsed.hash || ''); + + $a.attr('href', href); + }); +} + +module.exports = resolveLinks; diff --git a/packages/gitbook/src/output/modifiers/svgToImg.js b/packages/gitbook/src/output/modifiers/svgToImg.js new file mode 100644 index 0000000..ac37d07 --- /dev/null +++ b/packages/gitbook/src/output/modifiers/svgToImg.js @@ -0,0 +1,56 @@ +const path = require('path'); +const crc = require('crc'); +const domSerializer = require('dom-serializer'); + +const editHTMLElement = require('./editHTMLElement'); +const fs = require('../../utils/fs'); +const 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, $) { + const currentDirectory = path.dirname(currentFile); + + return editHTMLElement($, 'svg', function($svg) { + const content = '<?xml version="1.0" encoding="UTF-8"?>' + + renderDOM($, $svg); + + // We avoid generating twice the same PNG + const hash = crc.crc32(content).toString(16); + const fileName = hash + '.svg'; + const 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() { + const src = LocationUtils.relative(currentDirectory, fileName); + $svg.replaceWith('<img src="' + src + '" />'); + }); + }); +} + +module.exports = svgToImg; diff --git a/packages/gitbook/src/output/modifiers/svgToPng.js b/packages/gitbook/src/output/modifiers/svgToPng.js new file mode 100644 index 0000000..ad3f31f --- /dev/null +++ b/packages/gitbook/src/output/modifiers/svgToPng.js @@ -0,0 +1,53 @@ +const crc = require('crc'); +const path = require('path'); + +const imagesUtil = require('../../utils/images'); +const fs = require('../../utils/fs'); +const LocationUtils = require('../../utils/location'); + +const editHTMLElement = require('./editHTMLElement'); + +/** + Convert all SVG images to PNG + + @param {String} rootFolder + @param {HTMLDom} $ + @return {Promise} +*/ +function svgToPng(rootFolder, currentFile, $) { + const currentDirectory = path.dirname(currentFile); + + return editHTMLElement($, 'img', function($img) { + let 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 + const hash = crc.crc32(src).toString(16); + let fileName = hash + '.png'; + + // Input file path + const inputPath = path.join(rootFolder, src); + + // Result file path + const 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/packages/gitbook/src/output/prepareAssets.js b/packages/gitbook/src/output/prepareAssets.js new file mode 100644 index 0000000..6694a74 --- /dev/null +++ b/packages/gitbook/src/output/prepareAssets.js @@ -0,0 +1,22 @@ +const Parse = require('../parse'); + +/** + List all assets in the book + + @param {Output} + @return {Promise<Output>} +*/ +function prepareAssets(output) { + const book = output.getBook(); + const pages = output.getPages(); + const 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/packages/gitbook/src/output/preparePages.js b/packages/gitbook/src/output/preparePages.js new file mode 100644 index 0000000..e65367e --- /dev/null +++ b/packages/gitbook/src/output/preparePages.js @@ -0,0 +1,26 @@ +const Parse = require('../parse'); +const Promise = require('../utils/promise'); + +/** + List and prepare all pages + + @param {Output} + @return {Promise<Output>} +*/ +function preparePages(output) { + const book = output.getBook(); + const logger = book.getLogger(); + + if (book.isMultilingual()) { + return Promise(output); + } + + 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/packages/gitbook/src/output/preparePlugins.js b/packages/gitbook/src/output/preparePlugins.js new file mode 100644 index 0000000..c84bade --- /dev/null +++ b/packages/gitbook/src/output/preparePlugins.js @@ -0,0 +1,36 @@ +const Plugins = require('../plugins'); +const Promise = require('../utils/promise'); + +/** + * Load and setup plugins + * + * @param {Output} + * @return {Promise<Output>} + */ +function preparePlugins(output) { + const 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 + }); + }); + }); +} + +module.exports = preparePlugins; diff --git a/packages/gitbook/src/output/website/__tests__/i18n.js b/packages/gitbook/src/output/website/__tests__/i18n.js new file mode 100644 index 0000000..24f01f3 --- /dev/null +++ b/packages/gitbook/src/output/website/__tests__/i18n.js @@ -0,0 +1,38 @@ +const createMockOutput = require('../../__tests__/createMock'); +const prepareI18n = require('../prepareI18n'); +const createTemplateEngine = require('../createTemplateEngine'); + +const WebsiteGenerator = require('../'); + +describe('i18n', function() { + it('should correctly use english as default language', function() { + return createMockOutput(WebsiteGenerator, { + 'README.md': 'Hello World' + }) + .then(function(output) { + return prepareI18n(output); + }) + .then(function(output) { + const engine = createTemplateEngine(output, 'README.md'); + const t = engine.getFilters().get('t'); + + expect(t('SUMMARY_INTRODUCTION')).toEqual('Introduction'); + }); + }); + + it('should correctly use language from book.json', function() { + return createMockOutput(WebsiteGenerator, { + 'README.md': 'Hello World', + 'book.json': JSON.stringify({ language: 'fr' }) + }) + .then(function(output) { + return prepareI18n(output); + }) + .then(function(output) { + const engine = createTemplateEngine(output, 'README.md'); + const t = engine.getFilters().get('t'); + + expect(t('GITBOOK_LINK')).toEqual('Publié avec GitBook'); + }); + }); +}); diff --git a/packages/gitbook/src/output/website/copyPluginAssets.js b/packages/gitbook/src/output/website/copyPluginAssets.js new file mode 100644 index 0000000..315804a --- /dev/null +++ b/packages/gitbook/src/output/website/copyPluginAssets.js @@ -0,0 +1,117 @@ +const path = require('path'); + +const ASSET_FOLDER = require('../../constants/pluginAssetsFolder'); +const Promise = require('../../utils/promise'); +const 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) { + const 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); + } + + const 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) { + const logger = output.getLogger(); + const pluginRoot = plugin.getPath(); + const options = output.getOptions(); + + const outputRoot = options.get('root'); + const assetOutputFolder = path.join(outputRoot, 'gitbook'); + const prefix = options.get('prefix'); + + const 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) { + const logger = output.getLogger(); + + const options = output.getOptions(); + const outputRoot = options.get('root'); + + const state = output.getState(); + const resources = state.getResources(); + + const pluginRoot = plugin.getPath(); + const pluginResources = resources.get(plugin.getName()); + + let assetsFolder = pluginResources.get('assets'); + const 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/packages/gitbook/src/output/website/createTemplateEngine.js b/packages/gitbook/src/output/website/createTemplateEngine.js new file mode 100644 index 0000000..42a0bea --- /dev/null +++ b/packages/gitbook/src/output/website/createTemplateEngine.js @@ -0,0 +1,152 @@ +const path = require('path'); +const nunjucks = require('nunjucks'); +const DoExtension = require('nunjucks-do')(nunjucks); + +const Api = require('../../api'); +const deprecate = require('../../api/deprecate'); +const JSONUtils = require('../../json'); +const LocationUtils = require('../../utils/location'); +const fs = require('../../utils/fs'); +const PathUtils = require('../../utils/path'); +const TemplateEngine = require('../../models/templateEngine'); +const templatesFolder = require('../../constants/templatesFolder'); +const defaultFilters = require('../../constants/defaultFilters'); +const Templating = require('../../templating'); +const listSearchPaths = require('./listSearchPaths'); + +const fileToURL = require('../helper/fileToURL'); +const 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) { + const book = output.getBook(); + const state = output.getState(); + const i18n = state.getI18n(); + const config = book.getConfig(); + const summary = book.getSummary(); + const outputFolder = output.getRoot(); + + // Search paths for templates + const searchPaths = listSearchPaths(output); + const tplSearchPaths = searchPaths.map(templateFolder); + + // Create loader + const loader = new Templating.ThemesLoader(tplSearchPaths); + + // Get languages + const language = config.getValue('language'); + + // Create API context + const context = Api.encodeGlobal(output); + + + /** + * Check if a file exists + * @param {String} fileName + * @return {Boolean} + */ + function fileExists(fileName) { + if (!fileName) { + return false; + } + + const filePath = PathUtils.resolveInRoot(outputFolder, fileName); + return fs.existsSync(filePath); + } + + /** + * Return an article by its path + * @param {String} filePath + * @return {Object|undefined} + */ + function getArticleByPath(filePath) { + const article = summary.getByPath(filePath); + if (!article) return undefined; + + return JSONUtils.encodeSummaryArticle(article); + } + + /** + * Return a page by its path + * @param {String} filePath + * @return {Object|undefined} + */ + function getPageByPath(filePath) { + const page = output.getPage(filePath); + if (!page) return undefined; + + return JSONUtils.encodePage(page, summary); + } + + return TemplateEngine.create({ + loader, + + context, + + globals: { + getArticleByPath, + getPageByPath, + fileExists + }, + + 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(filePath) { + filePath = resolveFileToURL(output, filePath); + return LocationUtils.relativeForFile(currentFile, filePath); + }, + + resolveAsset(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); + }, + + + fileExists: deprecate.method(book, 'fileExists', fileExists, 'Filter "fileExists" is deprecated, use "fileExists(filename)" '), + getArticleByPath: deprecate.method(book, 'getArticleByPath', fileExists, 'Filter "getArticleByPath" is deprecated, use "getArticleByPath(filename)" '), + + contentURL(filePath) { + return fileToURL(output, filePath); + } + }), + + extensions: { + 'DoExtension': new DoExtension() + } + }); +} + +module.exports = createTemplateEngine; diff --git a/packages/gitbook/src/output/website/index.js b/packages/gitbook/src/output/website/index.js new file mode 100644 index 0000000..7818a28 --- /dev/null +++ b/packages/gitbook/src/output/website/index.js @@ -0,0 +1,11 @@ + +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') +}; diff --git a/packages/gitbook/src/output/website/listSearchPaths.js b/packages/gitbook/src/output/website/listSearchPaths.js new file mode 100644 index 0000000..c07dade --- /dev/null +++ b/packages/gitbook/src/output/website/listSearchPaths.js @@ -0,0 +1,23 @@ + +/** + List search paths for templates / i18n, etc + + @param {Output} output + @return {List<String>} +*/ +function listSearchPaths(output) { + const book = output.getBook(); + const plugins = output.getPlugins(); + + const searchPaths = plugins + .valueSeq() + .map(function(plugin) { + return plugin.getPath(); + }) + .toList(); + + return searchPaths.unshift(book.getContentRoot()); +} + + +module.exports = listSearchPaths; diff --git a/packages/gitbook/src/output/website/onAsset.js b/packages/gitbook/src/output/website/onAsset.js new file mode 100644 index 0000000..b996375 --- /dev/null +++ b/packages/gitbook/src/output/website/onAsset.js @@ -0,0 +1,28 @@ +const path = require('path'); +const fs = require('../../utils/fs'); + +/** + Copy an asset to the output folder + + @param {Output} output + @param {Page} page +*/ +function onAsset(output, asset) { + const book = output.getBook(); + const options = output.getOptions(); + const bookFS = book.getContentFS(); + + const outputFolder = options.get('root'); + const outputPath = path.resolve(outputFolder, asset); + + return fs.ensureFile(outputPath) + .then(function() { + return bookFS.readAsStream(asset) + .then(function(stream) { + return fs.writeStream(outputPath, stream); + }); + }) + .thenResolve(output); +} + +module.exports = onAsset; diff --git a/packages/gitbook/src/output/website/onFinish.js b/packages/gitbook/src/output/website/onFinish.js new file mode 100644 index 0000000..b032c90 --- /dev/null +++ b/packages/gitbook/src/output/website/onFinish.js @@ -0,0 +1,35 @@ +const Promise = require('../../utils/promise'); +const JSONUtils = require('../../json'); +const Templating = require('../../templating'); +const writeFile = require('../helper/writeFile'); +const createTemplateEngine = require('./createTemplateEngine'); + +/** + Finish the generation, write the languages index + + @param {Output} + @return {Output} +*/ +function onFinish(output) { + const book = output.getBook(); + const options = output.getOptions(); + const prefix = options.get('prefix'); + + if (!book.isMultilingual()) { + return Promise(output); + } + + const filePath = 'index.html'; + const engine = createTemplateEngine(output, filePath); + const context = JSONUtils.encodeOutput(output); + + // Render the theme + return Templating.renderFile(engine, prefix + '/languages.html', context) + + // Write it to the disk + .then(function(tplOut) { + return writeFile(output, filePath, tplOut.getContent()); + }); +} + +module.exports = onFinish; diff --git a/packages/gitbook/src/output/website/onInit.js b/packages/gitbook/src/output/website/onInit.js new file mode 100644 index 0000000..3f6d26e --- /dev/null +++ b/packages/gitbook/src/output/website/onInit.js @@ -0,0 +1,20 @@ +const Promise = require('../../utils/promise'); + +const copyPluginAssets = require('./copyPluginAssets'); +const prepareI18n = require('./prepareI18n'); +const prepareResources = require('./prepareResources'); + +/** + Initialize the generator + + @param {Output} + @return {Output} +*/ +function onInit(output) { + return Promise(output) + .then(prepareI18n) + .then(prepareResources) + .then(copyPluginAssets); +} + +module.exports = onInit; diff --git a/packages/gitbook/src/output/website/onPage.js b/packages/gitbook/src/output/website/onPage.js new file mode 100644 index 0000000..3b40536 --- /dev/null +++ b/packages/gitbook/src/output/website/onPage.js @@ -0,0 +1,76 @@ +const path = require('path'); +const omit = require('omit-keys'); + +const Templating = require('../../templating'); +const Plugins = require('../../plugins'); +const JSONUtils = require('../../json'); +const LocationUtils = require('../../utils/location'); +const Modifiers = require('../modifiers'); +const writeFile = require('../helper/writeFile'); +const getModifiers = require('../getModifiers'); +const createTemplateEngine = require('./createTemplateEngine'); +const fileToOutput = require('../helper/fileToOutput'); + +/** + * Write a page as a json file + * + * @param {Output} output + * @param {Page} page + */ +function onPage(output, page) { + const options = output.getOptions(); + const prefix = options.get('prefix'); + + const file = page.getFile(); + + const book = output.getBook(); + const plugins = output.getPlugins(); + const state = output.getState(); + const resources = state.getResources(); + + const engine = createTemplateEngine(output, page.getPath()); + + // Output file path + const filePath = fileToOutput(output, file.getPath()); + + // Calcul relative path to the root + const outputDirName = path.dirname(filePath); + const basePath = LocationUtils.normalize(path.relative(outputDirName, './')); + + return Modifiers.modifyHTML(page, getModifiers(output, page)) + .then(function(resultPage) { + // Generate the context + const context = JSONUtils.encodeOutputWithPage(output, resultPage); + context.plugins = { + resources: Plugins.listResources(plugins, resources).toJS() + }; + + context.template = { + getJSContext() { + return { + page: omit(context.page, 'content'), + config: context.config, + file: context.file, + gitbook: context.gitbook, + 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(tplOut) { + return writeFile(output, filePath, tplOut.getContent()); + }); + }); +} + +module.exports = onPage; diff --git a/packages/gitbook/src/output/website/options.js b/packages/gitbook/src/output/website/options.js new file mode 100644 index 0000000..43314df --- /dev/null +++ b/packages/gitbook/src/output/website/options.js @@ -0,0 +1,14 @@ +const Immutable = require('immutable'); + +const 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/packages/gitbook/src/output/website/prepareI18n.js b/packages/gitbook/src/output/website/prepareI18n.js new file mode 100644 index 0000000..b02ef77 --- /dev/null +++ b/packages/gitbook/src/output/website/prepareI18n.js @@ -0,0 +1,30 @@ +const path = require('path'); + +const fs = require('../../utils/fs'); +const Promise = require('../../utils/promise'); +const listSearchPaths = require('./listSearchPaths'); + +/** + * Prepare i18n, load translations from plugins and book + * + * @param {Output} + * @return {Promise<Output>} + */ +function prepareI18n(output) { + const state = output.getState(); + const i18n = state.getI18n(); + const searchPaths = listSearchPaths(output); + + searchPaths + .reverse() + .forEach(function(searchPath) { + const i18nRoot = path.resolve(searchPath, '_i18n'); + + if (!fs.existsSync(i18nRoot)) return; + i18n.load(i18nRoot); + }); + + return Promise(output); +} + +module.exports = prepareI18n; diff --git a/packages/gitbook/src/output/website/prepareResources.js b/packages/gitbook/src/output/website/prepareResources.js new file mode 100644 index 0000000..e93f45f --- /dev/null +++ b/packages/gitbook/src/output/website/prepareResources.js @@ -0,0 +1,54 @@ +const is = require('is'); +const Immutable = require('immutable'); +const Promise = require('../../utils/promise'); + +const Api = require('../../api'); + +/** + Prepare plugins resources, add all output corresponding type resources + + @param {Output} + @return {Promise<Output>} +*/ +function prepareResources(output) { + const plugins = output.getPlugins(); + const options = output.getOptions(); + const type = options.get('prefix'); + let state = output.getState(); + const context = Api.encodeGlobal(output); + + let result = Immutable.Map(); + + return Promise.forEach(plugins, function(plugin) { + const pluginResources = plugin.getResources(type); + + return Promise() + .then(function() { + // Apply resources if is a function + if (is.fn(pluginResources)) { + return Promise() + .then(pluginResources.bind(context)); + } + else { + return pluginResources; + } + }) + .then(function(resources) { + result = result.set(plugin.getName(), Immutable.Map(resources)); + }); + }) + .then(function() { + // Set output resources + state = state.merge({ + resources: result + }); + + output = output.merge({ + state + }); + + return output; + }); +} + +module.exports = prepareResources; diff --git a/packages/gitbook/src/output/website/state.js b/packages/gitbook/src/output/website/state.js new file mode 100644 index 0000000..813b850 --- /dev/null +++ b/packages/gitbook/src/output/website/state.js @@ -0,0 +1,19 @@ +const I18n = require('i18n-t'); +const Immutable = require('immutable'); + +const GeneratorState = Immutable.Record({ + i18n: I18n(), + + // List of plugins' resources + resources: Immutable.Map() +}); + +GeneratorState.prototype.getI18n = function() { + return this.get('i18n'); +}; + +GeneratorState.prototype.getResources = function() { + return this.get('resources'); +}; + +module.exports = GeneratorState; |