mdpdf
Version:
Markdown to PDF command line converter
200 lines (160 loc) • 5.46 kB
JavaScript
import {
readFile as _readFile,
writeFile as _writeFile,
copyFileSync,
unlinkSync
} from 'fs';
import { join, dirname, resolve } from 'path';
import { promisify } from 'util';
import { fileURLToPath } from 'url';
import showdown from 'showdown';
const { setFlavor, Converter } = showdown;
import showdownEmoji from 'showdown-emoji';
import showdownHighlight from 'showdown-highlight';
import { launch } from 'puppeteer';
import handlebars from 'handlebars';
const { SafeString, compile } = handlebars;
import { allowUnsafeNewFunction } from 'loophole';
import { getStyles, getStyleBlock, qualifyImgSources } from './utils.js';
import { getOptions } from './puppeteer-helper.js';
const readFile = promisify(_readFile);
const writeFile = promisify(_writeFile);
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
// Main layout template
const layoutPath = join(__dirname, '/layouts/doc-body.hbs');
const headerLayoutPath = join(__dirname, '/layouts/header.hbs');
const footerLayoutPath = join(__dirname, '/layouts/footer.hbs');
function getAllStyles(options) {
const cssStyleSheets = [];
// GitHub Markdown Style
if (options.ghStyle) {
cssStyleSheets.push(join(__dirname, '/assets/github-markdown-css.css'));
}
// Highlight CSS
cssStyleSheets.push(join(__dirname, '/assets/highlight/styles/github.css'));
// Some additional defaults such as margins
if (options.defaultStyle) {
cssStyleSheets.push(join(__dirname, '/assets/default.css'));
}
// Optional user given CSS
if (options.styles) {
cssStyleSheets.push(options.styles);
}
return {
styles: getStyles(cssStyleSheets),
styleBlock: getStyleBlock(cssStyleSheets),
};
}
function parseMarkdownToHtml(markdown, convertEmojis, enableHighlight, simpleLineBreaks) {
setFlavor('github');
const options = {
prefixHeaderId: false,
ghCompatibleHeaderId: true,
simpleLineBreaks,
extensions: []
};
// Sometimes emojis can mess with time representations
// such as "00:00:00"
if (convertEmojis) {
options.extensions.push(showdownEmoji);
}
if (enableHighlight) {
options.extensions.push(showdownHighlight)
}
const converter = new Converter(options);
return converter.makeHtml(markdown);
}
export async function convert(options) {
options = options || {};
if (!options.source) {
throw new Error('Source path must be provided');
}
if (!options.destination) {
throw new Error('Destination path must be provided');
}
options.assetDir = dirname(resolve(options.source));
const styles = getAllStyles(options);
let css = new SafeString(styles.styleBlock);
const local = {
css: css,
};
let source, template;
// Asynchronously convert
const promises = [
template = readFile(layoutPath, 'utf8').then(compile),
source = readFile(options.source, 'utf8'),
prepareHeader(options, styles.styles).then(v => options.header = v),
prepareFooter(options).then(v => options.footer = v),
];
const emojis = !options.noEmoji;
const syntaxHighlighting = !options.noHighlight;
const simpleLineBreaks = !options.ghStyle;
let content = parseMarkdownToHtml(await source, emojis, syntaxHighlighting, simpleLineBreaks);
// This step awaits so options is valid
await Promise.all(promises);
template = await template;
content = qualifyImgSources(content, options);
local.body = new SafeString(content);
// Use loophole for this body template to avoid issues with editor extensions
const html = allowUnsafeNewFunction(() => template(local));
return createPdf(html, options);
}
function prepareHeader(options, css) {
if (options.header) {
let headerTemplate;
// Get the hbs layout
return readFile(headerLayoutPath, 'utf8')
.then((headerLayout) => {
headerTemplate = compile(headerLayout);
// Get the header html
return readFile(options.header, 'utf8');
})
.then((headerContent) => {
const preparedHeader = qualifyImgSources(headerContent, options);
// Compile the header template
const headerHtml = headerTemplate({
content: new SafeString(preparedHeader),
css: new SafeString(css.replace(/"/gm, "'")),
});
return headerHtml;
});
} else {
return Promise.resolve();
}
}
function prepareFooter(options) {
if (options.footer) {
return readFile(options.footer, 'utf8').then((footerContent) => {
const preparedFooter = qualifyImgSources(footerContent, options);
return preparedFooter;
});
} else {
return Promise.resolve();
}
}
function createPdf(html, options) {
// Write html to a temp file
let browser;
let page;
const tempHtmlPath = resolve(
dirname(options.destination),
'_temp.html'
);
return writeFile(tempHtmlPath, html)
.then(async () => {
const browser = await launch({ headless: 'new' , args: ['--no-sandbox', '--disable-setuid-sandbox'] })
const page = (await browser.pages())[0];
await page.goto('file:' + tempHtmlPath, { waitUntil: options.waitUntil ?? 'networkidle0' });
const puppetOptions = getOptions(options);
await page.pdf(puppetOptions);
return browser.close();
})
.then(() => {
if (options.debug) {
copyFileSync(tempHtmlPath, options.debug);
}
unlinkSync(tempHtmlPath);
return options.destination;
});
}