diff --git a/.gitignore b/.gitignore index 5c5d3b5..9e3482d 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,4 @@ test.html testchapter.md git testfiles +mdconfig.json diff --git a/CHANGELOG.md b/CHANGELOG.md index fc3cff4..101a08b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,5 +10,8 @@ - command to include a stylesheet `[!stylesheet]: file.css` - option to export to pdf `--pdf` - option to watch (and export to html) `--watch` +- option to bundle everything in one file `--bundle` - stylesheets supporting math and code highlighting (with highlightjs) -- auto base64 converting for images for a standalone html +- auto base64 converting for images for a standalone html when bundling flag is set +- support for `mdconf.json`config file +- MathJax script to html diff --git a/README.md b/README.md index ec1624c..ad073f1 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ Optional arguments: -h, --help Show this help message and exit. -w, --watch Watch files for changes --pdf Output as pdf - + --bundle Bundle all images and script in one html ``` @@ -35,6 +35,8 @@ The usage of a markdown-it plugin inside a document can be decleared by using ```markdown [!use]: plugin1, plugin2, plugin3 +or +[!use]: # (plugin1, plugin2, plugin3) ``` The plugin names are listed in the following table. Basically it is just the package name with the markdown-it removed: @@ -56,6 +58,7 @@ The plugin names are listed in the following table. Basically it is just the pac | markdown-it-mathjax | mathjax | markdown-it-math | math | markdown-it-div | div +| markdown-it-multimd-table | multimd-table For example you can declare the use of `markdown-it-emoji` the following: @@ -90,6 +93,24 @@ You can include your own stylesheet. It is applied after the default style. The [!stylesheet]: path/to/style.css ``` +## Configuration file + +You can also define plugins, stylesheets and other stuff by using a `mdconf.json` file in the same directory as the main markdown file. Example config: + +```json +{ + "format": "A4", + "plugins": [ + "emoji", + "footnote", + "markdown-it-multimd-table" + ], + "stylesheets": [ + "customstyle.css" + ] +} +``` + ## Other stuff Images and stylesheets are included within the html file. All image urls are converted into base64 urls. diff --git a/package-lock.json b/package-lock.json index 871937a..49003d2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3456,6 +3456,28 @@ "resolved": "https://registry.npmjs.org/markdown-it-mathjax/-/markdown-it-mathjax-2.0.0.tgz", "integrity": "sha1-ritPTFxxmgP55HXGZPeyaFIx2ek=" }, + "markdown-it-multimd-table": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/markdown-it-multimd-table/-/markdown-it-multimd-table-3.2.2.tgz", + "integrity": "sha512-mqv+/5XE4D75yT6CQP/eyezWIrBJWu6pY6LLL8qDs/Gnh6PxfdxO+T4A0oYoHx8RZLvUZZWWNrqplLPw4euuGQ==", + "requires": { + "markdown-it": "^8.4.2" + }, + "dependencies": { + "markdown-it": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-8.4.2.tgz", + "integrity": "sha512-GcRz3AWTqSUphY3vsUqQSFMbgR38a4Lh3GWlHRh/7MRwz8mcu9n2IO7HOh+bXHrR9kOPDl5RNCaEsrneb+xhHQ==", + "requires": { + "argparse": "^1.0.7", + "entities": "~1.1.1", + "linkify-it": "^2.0.0", + "mdurl": "^1.0.1", + "uc.micro": "^1.0.5" + } + } + } + }, "markdown-it-plantuml": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/markdown-it-plantuml/-/markdown-it-plantuml-1.4.1.tgz", diff --git a/package.json b/package.json index 47ac167..aa2531b 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,7 @@ "markdown-it-mark": "^2.0.0", "markdown-it-math": "^4.1.1", "markdown-it-mathjax": "^2.0.0", + "markdown-it-multimd-table": "^3.2.2", "markdown-it-plantuml": "^1.4.1", "markdown-it-smartarrows": "^1.0.1", "markdown-it-sub": "^1.0.0", diff --git a/src/index.ts b/src/index.ts index 2631b8a..37bf66c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,7 +1,9 @@ -import {Renderer} from "./Renderer"; +import {Renderer} from "./lib/Renderer"; import {writeFile} from 'fs-extra'; -import {extname} from 'path'; +import {extname, dirname} from 'path'; import {ArgumentParser} from "argparse"; +import {MarkdownConfig} from "./lib/MarkdownConfig"; +import {logger} from "./lib/logger"; /** * Returns the filename without the extension. @@ -12,6 +14,7 @@ function noExt(file: string): string { } async function main() { + logger.profile('processing'); const parser = new ArgumentParser({ addHelp: true }); @@ -34,26 +37,42 @@ async function main() { action: 'storeTrue' } ); + parser.addArgument( + ['--bundle'], + { + help: 'bundles the html to a standalone file', + required: false, + action: 'storeTrue' + } + ); let args = parser.parseArgs(); - let renderer = new Renderer(); + let config = new MarkdownConfig(dirname(args.file)); + config.bundle = args.bundle || args.pdf; + let renderer = new Renderer(config); if (args.watch) { + let outputFile = noExt(args.file) + '.html'; renderer.on('rendered', async (html) => { await writeFile(outputFile, html); console.log(`File stored as ${outputFile}`); }); renderer.watch(args.file); + } else if (args.pdf) { + let outputFile = noExt(args.file) + '.pdf'; await renderer.renderPdf(args.file, outputFile); console.log(`File stored as ${outputFile}`); + } else { + let outputFile = noExt(args.file) + '.html'; let html = await renderer.render(args.file); await writeFile(outputFile, html); console.log(`File stored as ${outputFile}`); } + logger.profile('processing'); } - +4 main(); diff --git a/src/CommandParser.ts b/src/lib/CommandParser.ts similarity index 76% rename from src/CommandParser.ts rename to src/lib/CommandParser.ts index a466b29..2f6c1d8 100644 --- a/src/CommandParser.ts +++ b/src/lib/CommandParser.ts @@ -4,18 +4,18 @@ import {Renderer} from "./Renderer"; import {markdownPlugins} from './plugins'; import {pageFormats} from "./formats"; import {PDFFormat} from "puppeteer"; +import {getMarkdownPlugin} from "./utils"; +import {logger} from "./logger"; export class CommandParser { public projectFiles: string[]; public pageFormat: PDFFormat; - public loadedPlugins: string[]; public stylesheets: string[]; - private resolvePath: {path: string, lines: number}[]; + private readonly resolvePath: {path: string, lines: number}[]; constructor() { this.projectFiles = []; - this.loadedPlugins = []; this.resolvePath = []; this.stylesheets = []; } @@ -50,9 +50,9 @@ export class CommandParser { switch(match[1]) { case 'use': let plugins = match[2].split(','); - console.log(`Using plugins: ${match[2]}`); + logger.verbose(`Adding plugins: ${match[2]}`); for (let mdPlugin of plugins) { - this.addMarkdownPlugin(mdPlugin.replace(/^ *| *$/g, ''), renderer); + renderer.addPlugin(getMarkdownPlugin(mdPlugin.replace(/^ *| *$/g, ''))); } break; case 'include': @@ -62,10 +62,10 @@ export class CommandParser { inputLines.unshift(...included); this.resolvePath.push({path: match[2], lines: included.length}); } else { - console.error(`Circular reference detected. Skipping include ${match[2]}`); + logger.warning(`Circular reference detected. Skipping include ${match[2]}`); } } catch (err) { - console.error(err.message); + logger.error(err.message); outputLines.push(inputLine); } break; @@ -74,11 +74,10 @@ export class CommandParser { // @ts-ignore this.pageFormat = match[2]; else - console.log('Invalid page format or format already set: ' + match[2]); + logger.warning('Invalid page format or format already set: ' + match[2]); break; case 'newpage': - if (!this.loadedPlugins.includes(markdownPlugins.div)) - this.addMarkdownPlugin('div', renderer); + renderer.addPlugin(getMarkdownPlugin(markdownPlugins.div)); outputLines.push('::: .newpage \n:::'); break; case 'stylesheet': @@ -95,29 +94,6 @@ export class CommandParser { return outputLines.join('\n'); } - /** - * Adds a markdown-it plugin to the renderer - * @param pluginName - * @param renderer - */ - private addMarkdownPlugin(pluginName: string, renderer: Renderer): void { - try { - // @ts-ignore - let moduleName = markdownPlugins[pluginName]; - if (moduleName) { - let plugin: any = require(moduleName); - if (plugin && !this.loadedPlugins.includes(plugin)) { - renderer.addPlugin(plugin); - this.loadedPlugins.push(plugin); - } - } else { - console.error(`Plugin "${pluginName}" not found.`); - } - } catch (err) { - console.error(err); - } - } - /** * Imports a file into the markdown. * @param filepath diff --git a/src/lib/MarkdownConfig.ts b/src/lib/MarkdownConfig.ts new file mode 100644 index 0000000..d420546 --- /dev/null +++ b/src/lib/MarkdownConfig.ts @@ -0,0 +1,52 @@ +import * as fsx from 'fs-extra'; +import * as path from 'path'; +import {getMarkdownPlugin} from "./utils"; + +const confName = 'mdconfig.json'; + +/** + * Markdown config class for easyer access on the configuration. + */ +export class MarkdownConfig { + public plugins: Set = new Set(); + public stylesheets: Set = new Set(); + public format: string = 'A4'; + public bundle: boolean = false; + private readonly filename: string; + + /** + * Creates a new config with the given directory or the processes work directory. + * @param dirname + */ + constructor(dirname?: string) { + dirname = dirname || process.cwd(); + let confFile: string = path.join(dirname, confName); + + if (fsx.pathExistsSync(confFile)) { + this.filename = confFile; + this.loadData(); + } + } + + /** + * Loads the data from a config file. + */ + private loadData() { + let configData = fsx.readJsonSync(this.filename); + this.format = configData.format || this.format; + this.bundle = configData.bundle; + if (configData.plugins) + this.plugins = new Set(configData.plugins.map(getMarkdownPlugin)); + if (configData.stylesheets) + this.stylesheets = new Set(configData.stylesheets); + } + + /** + * Saves the config file to a config file. + * @param filename + */ + public save(filename?: string) { + let confFile = filename || this.filename; + fsx.writeJsonSync(confFile, this); + } +} diff --git a/src/Renderer.ts b/src/lib/Renderer.ts similarity index 58% rename from src/Renderer.ts rename to src/lib/Renderer.ts index 99eb802..0b5e2bd 100644 --- a/src/Renderer.ts +++ b/src/lib/Renderer.ts @@ -7,16 +7,20 @@ import {JSDOM} from 'jsdom'; import {CommandParser} from "./CommandParser"; import {EventEmitter} from "events"; import {PDFFormat} from "puppeteer"; -import fetch from 'node-fetch'; +import {MarkdownConfig} from "./MarkdownConfig"; +import {bundleImages, delay, includeMathJax} from "./utils"; +import {logger} from "./logger"; export class Renderer extends EventEmitter { private md: MarkdownIt; private readonly beforeRendering: Function[]; private readonly afterRendering: Function[]; private commandParser: CommandParser; + private config: MarkdownConfig; - constructor() { + constructor(config?: MarkdownConfig) { super(); + this.config = config || new MarkdownConfig(); this.md = new MarkdownIt(); this.beforeRendering = []; this.afterRendering = []; @@ -24,6 +28,13 @@ export class Renderer extends EventEmitter { this.configure(); } + /** + * Returns the current config. + */ + public get mdConfig() { + return this.config; + } + /** * Assign a function that should be used before rendering. * @param func @@ -45,7 +56,16 @@ export class Renderer extends EventEmitter { * @param mdPlugin */ public addPlugin(mdPlugin: any) { - this.md.use(mdPlugin); + this.config.plugins.add(mdPlugin); + } + + /** + * Adds a stylesheet to the config. + * The stylesheets will be included after the md-it render. + * @param filepath + */ + public addStylesheet(filepath: string) { + this.config.stylesheets.add(filepath) } /** @@ -56,14 +76,19 @@ export class Renderer extends EventEmitter { filename = path.resolve(filename); let document = await fsx.readFile(filename, 'utf-8'); document = document.replace(/\r\n/g, '\n'); + logger.verbose(`Applying ${this.beforeRendering.length} beforeRendering functions...`); for (let func of this.beforeRendering) { document = await func(document, filename, this); } + this.addConfigPlugins(); + logger.verbose(`Rendering with markdown-it and ${this.config.plugins.size} plugins`); let result: string = this.md.render(document); + logger.verbose(`Applying ${this.afterRendering.length} afterRendering functions...`); for (let func of this.afterRendering) { result = await func(result, filename, this); } + logger.verbose('HTML rendered'); this.emit('rendered', result); return result; } @@ -78,12 +103,25 @@ export class Renderer extends EventEmitter { let html = await this.render(filename); if (this.commandParser.pageFormat) format = this.commandParser.pageFormat; - console.log('Launching puppeteer'); + logger.info('Launching puppeteer'); const browser = await puppeteer.launch(); const page = await browser.newPage(); + logger.info('Setting and evaluating content'); await page.setContent(html); - console.log(`Starting PDF export (format: ${format}) to ${output}`); - await page.pdf({path: output, format: format, margin: {top: '1.5cm', bottom: '1.5cm'}}); + await page.waitForFunction('window.MathJax.isReady === true'); + await delay(1000); + logger.info(`Starting PDF export (format: ${format}) to ${output}`); + await page.pdf({ + path: output, + format: format, + printBackground: true, + margin: { + top: '1.5cm', + bottom: '1.5cm', + left: '1.5cm', + right: '1.5cm' + } + }); await browser.close(); } @@ -94,11 +132,11 @@ export class Renderer extends EventEmitter { public watch(filename: string) { const watcher = chokidar.watch(filename); watcher.on('change', async () => { - console.log('Change detected. Rerendering'); + logger.info('Change detected. Rerendering'); let start = Date.now(); this.md = new MarkdownIt(); await this.render(filename); - console.log(`Rendering took ${Date.now() - start} ms.`); + logger.info(`Rendering took ${Date.now() - start} ms.`); }); this.on('rendered', () => { watcher.add(this.commandParser.projectFiles); @@ -115,51 +153,51 @@ export class Renderer extends EventEmitter { this.useAfter((doc: string) => new JSDOM(doc)); // include default style this.useAfter(async (dom: JSDOM) => { - let styleTag = dom.window.document.createElement('style'); - // append the default style - styleTag.innerHTML = await fsx.readFile(path.join(__dirname, 'styles/default.css'), 'utf-8'); - dom.window.document.head.appendChild(styleTag); + logger.debug('Including default style'); + let stylePath = path.join(__dirname, '../styles/default.css'); + let document = dom.window.document; + + if (this.config.bundle) { + let styleTag = document.createElement('style'); + styleTag.innerHTML = await fsx.readFile(stylePath, 'utf-8'); + document.head.appendChild(styleTag); + } else { + let linkTag = document.createElement('link'); + linkTag.rel = 'stylesheet'; + linkTag.href = stylePath; + document.head.appendChild(linkTag); + } return dom; }); // include user defined styles this.useAfter(async (dom: JSDOM) => { + logger.debug(`Including ${this.config.stylesheets.size} user styles.`); let userStyles = dom.window.document.createElement('style'); userStyles.setAttribute('id', 'user-style'); - // append all user defined stylesheets - for (let stylesheet of this.commandParser.stylesheets) { + for (let stylesheet of this.config.stylesheets) { + logger.debug(`Including ${stylesheet}`); userStyles.innerHTML += await fsx.readFile(stylesheet, 'utf-8'); } dom.window.document.head.appendChild(userStyles); return dom; }); + this.useAfter(includeMathJax); // include all images as base64 - this.useAfter(async (dom: JSDOM, mainfile: string) => { - let document = dom.window.document; - let mainFolder = path.dirname(mainfile); - let imgs = document.querySelectorAll('img'); - for (let img of imgs) { - let source = img.src; - let filepath = source; - let base64Url = source; - if (!path.isAbsolute(filepath)) - filepath = path.join(mainFolder, filepath); - if (await fsx.pathExists(filepath)) { - let type = path.extname(source).slice(1); - base64Url = `data:image/${type};base64,`; - base64Url += (await fsx.readFile(filepath)).toString('base64'); - } else { - try { - let response = await fetch(source); - base64Url = `data:${response.headers.get('content-type')};base64,`; - base64Url += (await response.buffer()).toString('base64'); - } catch (error) { - console.error(error); - } - } - img.src = base64Url; - } - return dom; - }); + if (this.config.bundle) + this.useAfter(bundleImages); this.useAfter((dom: JSDOM) => dom.serialize()); } + + /** + * Adds all plugins from the config to markdown-it. + */ + private addConfigPlugins() { + for (let plugin of this.config.plugins) { + try { + this.md.use(plugin); + } catch (err) { + logger.error(err); + } + } + } } diff --git a/src/formats.ts b/src/lib/formats.ts similarity index 100% rename from src/formats.ts rename to src/lib/formats.ts diff --git a/src/lib/logger.ts b/src/lib/logger.ts new file mode 100644 index 0000000..54a3ba7 --- /dev/null +++ b/src/lib/logger.ts @@ -0,0 +1,15 @@ +import * as winston from "winston"; + +export const logger = winston.createLogger({ + level: 'info', + format: winston.format.json(), + transports: [ + new winston.transports.Console({ + format: winston.format.combine( + winston.format.colorize(), + winston.format.simple() + ), + level: process.env.NODE_ENV === 'production'? 'info' : 'silly' + }) + ] +}); diff --git a/src/plugins.ts b/src/lib/plugins.ts similarity index 100% rename from src/plugins.ts rename to src/lib/plugins.ts diff --git a/src/lib/utils.ts b/src/lib/utils.ts new file mode 100644 index 0000000..3981c30 --- /dev/null +++ b/src/lib/utils.ts @@ -0,0 +1,83 @@ +import {JSDOM} from "jsdom"; +import * as path from "path"; +import * as fsx from "fs-extra"; +import fetch from "node-fetch"; +import {logger} from "./logger"; +import {markdownPlugins} from "./plugins"; + +const mathJaxUrl = 'https://cdnjs.cloudflare.com/ajax/libs/mathjax/2.7.5/MathJax.js?config=TeX-MML-AM_CHTML'; + +/** + * Bundles all images in the image tags. + * @param dom + * @param mainfile + */ +export async function bundleImages(dom: JSDOM, mainfile: string): Promise { + let document = dom.window.document; + let mainFolder = path.dirname(mainfile); + let imgs = document.querySelectorAll('img'); + logger.debug(`Bundling ${imgs.length} images.`); + for (let img of imgs) { + let source = img.src; + let filepath = source; + let base64Url = source; + if (!path.isAbsolute(filepath)) + filepath = path.join(mainFolder, filepath); + if (await fsx.pathExists(filepath)) { + let type = path.extname(source).slice(1); + base64Url = `data:image/${type};base64,`; + base64Url += (await fsx.readFile(filepath)).toString('base64'); + } else { + try { + let response = await fetch(source); + base64Url = `data:${response.headers.get('content-type')};base64,`; + base64Url += (await response.buffer()).toString('base64'); + } catch (error) { + logger.error(error); + } + } + img.src = base64Url; + } + return dom; +} + +/** + * Includes mathjax in the dom. + * @param dom + */ +export function includeMathJax(dom: JSDOM): JSDOM { + let document = dom.window.document; + let scriptTag = document.createElement('script'); + scriptTag.src = mathJaxUrl; + document.head.appendChild(scriptTag); + return dom; +} + +/** + * Returns the markdown plugin associated with the pluginName + * @param pluginName + */ +export function getMarkdownPlugin(pluginName: string) { + logger.debug(`Trying to resolve plugin ${pluginName}`); + if (markdownPlugins[pluginName]) { + return require(markdownPlugins[pluginName]); + } else { + try { + let plugin = require(pluginName); + if (plugin) + return plugin; + } catch (err) { + console.error(`Module ${pluginName} not found.`); + } + } +} + +/** + * Asynchronous elay function + * @param milliseconds + */ +export function delay(milliseconds: number) { + return new Promise(function(resolve) { + setTimeout(resolve, milliseconds) + }); +} diff --git a/src/styles/default.sass b/src/styles/default.sass index ebd0d92..11ca402 100644 --- a/src/styles/default.sass +++ b/src/styles/default.sass @@ -1,11 +1,9 @@ @import includes/vars -@import includes/w3-math +//@import includes/w3-math @import includes/highlighjs body font-family: Arial, sans-serif - width: 80% - margin: auto pre background-color: $backgroundSecondary @@ -23,7 +21,7 @@ nav page-break-after: always img - width: 100% + max-width: 100% height: auto .page