import { Command, Option, InvalidArgumentError } from 'commander' import chalk from 'chalk' import fs from 'fs' import path from 'path' import puppeteer from 'puppeteer' import url from 'url' // importing JSON is still experimental in Node.JS https://nodejs.org/docs/latest-v16.x/api/esm.html#json-modules import { createRequire } from 'module' const require = createRequire(import.meta.url) const pkg = require('../package.json') // __dirname is not available in ESM modules by default const __dirname = url.fileURLToPath(new url.URL('.', import.meta.url)) /** * Prints an error to stderr, then closes with exit code 1 * * @param {string} message - The message to print to `stderr`. * @returns {never} Quits Node.JS, so never returns. */ const error = message => { console.error(chalk.red(`\n${message}\n`)) process.exit(1) } /** * Prints a warning to stderr. * * @param {string} message - The message to print to `stderr`. */ const warn = message => { console.warn(chalk.yellow(`\n${message}\n`)) } /** * Checks if the given file exists. * * @param {string} file - The file to check. * @returns {never | void} If the file doesn't exist, closes Node.JS with * exit code 1. */ const checkConfigFile = file => { if (!fs.existsSync(file)) { error(`Configuration file "${file}" doesn't exist`) } } /** * Gets the data in the given file. * * @param {string | undefined} inputFile - The file to read. * If `undefined`, reads from `stdin` instead. * @returns {Promise<string>} The contents of `inputFile` parsed as `utf8`. */ async function getInputData (inputFile) { // if an input file has been specified using '-i', it takes precedence over // piping from stdin if (typeof inputFile !== 'undefined') { return await fs.promises.readFile(inputFile, 'utf-8') } return await new Promise((resolve, reject) => { let data = '' process.stdin.on('readable', function () { const chunk = process.stdin.read() if (chunk !== null) { data += chunk } }) process.stdin.on('error', function (err) { reject(err) }) process.stdin.on('end', function () { resolve(data) }) }) } /** * Commander parser that converts a string to an integer. * * @param {string} value - The value from commander. * @param {*} _unused - Unused. * @returns {number} The value parsed as a number. * @throws {InvalidArgumentError} If the arg is not valid. * @see https://github.com/tj/commander.js/wiki/Class:-Option#argparserfn */ function parseCommanderInt (value, _unused) { const parsedValue = parseInt(value, 10) if (isNaN(parsedValue) || parsedValue < 1) { throw new InvalidArgumentError('Not an positive integer.') } return parsedValue } async function cli () { const commander = new Command() commander .version(pkg.version) .addOption(new Option('-t, --theme [theme]', 'Theme of the chart').choices(['default', 'forest', 'dark', 'neutral']).default('default')) .addOption(new Option('-w, --width [width]', 'Width of the page').argParser(parseCommanderInt).default(800)) .addOption(new Option('-H, --height [height]', 'Height of the page').argParser(parseCommanderInt).default(600)) .option('-i, --input <input>', 'Input mermaid file. Files ending in .md will be treated as Markdown and all charts (e.g. ```mermaid (...)```) will be extracted and generated. Use `-` to read from stdin.') .option('-o, --output [output]', 'Output file. It should be either md, svg, png or pdf. Optional. Default: input + ".svg"') .addOption(new Option('-e, --outputFormat [format]', 'Output format for the generated image.').choices(['svg', 'png', 'pdf']).default(null, 'Loaded from the output file extension')) .addOption(new Option('-b, --backgroundColor [backgroundColor]', 'Background color for pngs/svgs (not pdfs). Example: transparent, red, \'#F0F0F0\'.').default('white')) .option('-c, --configFile [configFile]', 'JSON configuration file for mermaid.') .option('-C, --cssFile [cssFile]', 'CSS file for the page.') .addOption(new Option('-s, --scale [scale]', 'Puppeteer scale factor').argParser(parseCommanderInt).default(1)) .option('-f, --pdfFit', 'Scale PDF to fit chart') .option('-q, --quiet', 'Suppress log output') .option('-p --puppeteerConfigFile [puppeteerConfigFile]', 'JSON configuration file for puppeteer.') .parse(process.argv) const options = commander.opts() let { theme, width, height, input, output, outputFormat, backgroundColor, configFile, cssFile, puppeteerConfigFile, scale, pdfFit, quiet } = options // check input file if (!input) { warn('No input file specified, reading from stdin. ' + 'If you want to specify an input file, please use `-i <input>.` ' + 'You can use `-i -` to read from stdin and to suppress this warning.' ) } else if (input === '-') { // `--input -` means read from stdin, but suppress the above warning input = undefined } else if (!fs.existsSync(input)) { error(`Input file "${input}" doesn't exist`) } // check output file if (!output) { // if an input file is defined, it should take precedence, otherwise, input is // coming from stdin and just name the file out.svg, if it hasn't been // specified with the '-o' option if (outputFormat) { output = input ? (`${input}.${outputFormat}`) : `out.${outputFormat}` } else { output = input ? (`${input}.svg`) : 'out.svg' } } if (!/\.(?:svg|png|pdf|md|markdown)$/.test(output)) { error('Output file must end with ".md"/".markdown", ".svg", ".png" or ".pdf"') } const outputDir = path.dirname(output) if (!fs.existsSync(outputDir)) { error(`Output directory "${outputDir}/" doesn't exist`) } // check config files let mermaidConfig = { theme } if (configFile) { checkConfigFile(configFile) mermaidConfig = Object.assign(mermaidConfig, JSON.parse(fs.readFileSync(configFile, 'utf-8'))) } // @ts-expect-error Setting headless to `1` is not officially supported let puppeteerConfig = /** @type {import('puppeteer').PuppeteerLaunchOptions} */ ({ /* * `headless: 1` is not officially supported, but setting this to any * non-`true` truthy value doesn't change any behavior, but it hides the * Puppeteer old Headless deprecation warning, * see https://github.com/argos-ci/jest-puppeteer/issues/553#issuecomment-1561826456 */ headless: 1 }) if (puppeteerConfigFile) { checkConfigFile(puppeteerConfigFile) puppeteerConfig = Object.assign(puppeteerConfig, JSON.parse(fs.readFileSync(puppeteerConfigFile, 'utf-8'))) } // check cssFile let myCSS if (cssFile) { if (!fs.existsSync(cssFile)) { error(`CSS file "${cssFile}" doesn't exist`) } myCSS = fs.readFileSync(cssFile, 'utf-8') } await run( input, output, { puppeteerConfig, quiet, outputFormat, parseMMDOptions: { mermaidConfig, backgroundColor, myCSS, pdfFit, viewport: { width, height, deviceScaleFactor: scale } } } ) } /** * @typedef {Object} ParseMDDOptions Options to pass to {@link parseMMD} * @property {import("puppeteer").Viewport} [viewport] - Puppeteer viewport (e.g. `width`, `height`, `deviceScaleFactor`) * @property {string | "transparent"} [backgroundColor] - Background color. * @property {Parameters<import("mermaid")["default"]["initialize"]>[0]} [mermaidConfig] - Mermaid config. * @property {CSSStyleDeclaration["cssText"]} [myCSS] - Optional CSS text. * @property {boolean} [pdfFit] - If set, scale PDF to fit chart. */ /** * Parse and render a mermaid diagram. * * @deprecated Prefer {@link renderMermaid}, as it also returns useful metadata. * * @param {import("puppeteer").Browser} browser - Puppeteer Browser * @param {string} definition - Mermaid diagram definition * @param {"svg" | "png" | "pdf"} outputFormat - Mermaid output format. * @param {ParseMDDOptions} [opt] - Options, see {@link ParseMDDOptions} for details. * * @returns {Promise<Buffer>} The output file in bytes. */ async function parseMMD (browser, definition, outputFormat, opt) { const { data } = await renderMermaid(browser, definition, outputFormat, opt) return data } /** * Render a mermaid diagram. * * @param {import("puppeteer").Browser} browser - Puppeteer Browser * @param {string} definition - Mermaid diagram definition * @param {"svg" | "png" | "pdf"} outputFormat - Mermaid output format. * @param {ParseMDDOptions} [opt] - Options, see {@link ParseMDDOptions} for details. * @returns {Promise<{title: string | null, desc: string | null, data: Buffer}>} The output file in bytes, * with optional metadata. */ async function renderMermaid (browser, definition, outputFormat, { viewport, backgroundColor = 'white', mermaidConfig = {}, myCSS, pdfFit } = {}) { const page = await browser.newPage() page.on('console', (msg) => { console.log(msg.text()) }) try { if (viewport) { await page.setViewport(viewport) } const mermaidHTMLPath = path.join(__dirname, '..', 'dist', 'index.html') await page.goto(url.pathToFileURL(mermaidHTMLPath).href) await page.$eval('body', (body, backgroundColor) => { body.style.background = backgroundColor }, backgroundColor) const metadata = await page.$eval('#container', async (container, definition, mermaidConfig, myCSS, backgroundColor) => { /** * @typedef {Object} GlobalThisWithMermaid * We've already imported these modules in our `index.html` file, so that they * get correctly bundled. * @property {import("mermaid")["default"]} mermaid Already imported mermaid instance * @property {import("@mermaid-js/mermaid-zenuml")["default"]} zenuml Already imported mermaid-zenuml instance */ const { mermaid, zenuml } = /** @type {GlobalThisWithMermaid & typeof globalThis} */ (globalThis) await mermaid.registerExternalDiagrams([zenuml]) mermaid.initialize({ startOnLoad: false, ...mermaidConfig }) // should throw an error if mmd diagram is invalid const { svg: svgText } = await mermaid.render('my-svg', definition, container) container.innerHTML = svgText const svg = container.getElementsByTagName?.('svg')?.[0] if (svg?.style) { svg.style.backgroundColor = backgroundColor } else { warn('svg not found. Not applying background color.') } if (myCSS) { // add CSS as a <svg>...<style>... element // see https://developer.mozilla.org/en-US/docs/Web/API/SVGStyleElement const style = document.createElementNS('http://www.w3.org/2000/svg', 'style') style.appendChild(document.createTextNode(myCSS)) svg.appendChild(style) } // Finds SVG metadata for accessibility purposes /** SVG title */ let title = null // If <title> exists, it must be the first child Node, // see https://www.w3.org/TR/SVG11/struct.html#DescriptionAndTitleElements /* global SVGTitleElement, SVGDescElement */ // These exist in browser-based code if (svg.firstChild instanceof SVGTitleElement) { title = svg.firstChild.textContent } /** SVG description. According to SVG spec, we should use the first one we find */ let desc = null for (const svgNode of svg.children) { if (svgNode instanceof SVGDescElement) { desc = svgNode.textContent } } return { title, desc } }, definition, mermaidConfig, myCSS, backgroundColor) if (outputFormat === 'svg') { const svgXML = await page.$eval('svg', (svg) => { // SVG might have HTML <foreignObject> that are not valid XML // E.g. <br> must be replaced with <br/> // Luckily the DOM Web API has the XMLSerializer for this // eslint-disable-next-line no-undef const xmlSerializer = new XMLSerializer() return xmlSerializer.serializeToString(svg) }) return { ...metadata, data: Buffer.from(svgXML, 'utf8') } } else if (outputFormat === 'png') { const clip = await page.$eval('svg', svg => { const react = svg.getBoundingClientRect() return { x: Math.floor(react.left), y: Math.floor(react.top), width: Math.ceil(react.width), height: Math.ceil(react.height) } }) await page.setViewport({ ...viewport, width: clip.x + clip.width, height: clip.y + clip.height }) return { ...metadata, data: await page.screenshot({ clip, omitBackground: backgroundColor === 'transparent' }) } } else { // pdf if (pdfFit) { const clip = await page.$eval('svg', svg => { const react = svg.getBoundingClientRect() return { x: react.left, y: react.top, width: react.width, height: react.height } }) return { ...metadata, data: await page.pdf({ omitBackground: backgroundColor === 'transparent', width: (Math.ceil(clip.width) + clip.x * 2) + 'px', height: (Math.ceil(clip.height) + clip.y * 2) + 'px', pageRanges: '1-1' }) } } else { return { ...metadata, data: await page.pdf({ omitBackground: backgroundColor === 'transparent' }) } } } } finally { await page.close() } } /** * @typedef {object} MarkdownImageProps Markdown image properties * Used to create an markdown image that looks like `![alt](url "title")` * @property {string} url - Path to image. * @property {string} alt - Image alt text, required. * @property {string | null} [title] - Optional image title text. */ /** * Creates a markdown image syntax. * * @param {MarkdownImageProps} params - Parameters. * @returns {`![${string}](${string})`} The markdown image text. */ function markdownImage ({ url, title, alt }) { // we can't use String.prototype.replaceAll since it's not supported in Node v14 const altEscaped = alt.replace(/[[\]\\]/g, '\\$&') if (title) { const titleEscaped = title.replace(/["\\]/g, '\\$&') return `![${altEscaped}](${url} "${titleEscaped}")` } else { return `![${altEscaped}](${url})` } } /** * Renders a mermaid diagram or mermaid markdown file. * * @param {`${string}.${"md" | "markdown"}` | string | undefined} input - If this ends with `.md`/`.markdown`, * path to a markdown file containing mermaid. * If this is a string, loads the mermaid definition from the given file. * If this is `undefined`, loads the mermaid definition from stdin. * @param {`${string}.${"md" | "markdown" | "svg" | "png" | "pdf"}`} output - Path to the output file. * @param {Object} [opts] - Options * @param {import("puppeteer").LaunchOptions} [opts.puppeteerConfig] - Puppeteer launch options. * @param {boolean} [opts.quiet] - If set, suppress log output. * @param {"svg" | "png" | "pdf"} [opts.outputFormat] - Mermaid output format. * Defaults to `output` extension. Overrides `output` extension if set. * @param {ParseMDDOptions} [opts.parseMMDOptions] - Options to pass to {@link parseMMDOptions}. */ async function run (input, output, { puppeteerConfig = {}, quiet = false, outputFormat, parseMMDOptions } = {}) { /** * Logs the given message to stdout, unless `quiet` is set to `true`. * * @param {string} message - The message to maybe log. */ const info = message => { if (!quiet) { console.info(message) } } // TODO: should we use a Markdown parser like remark instead of rolling our own parser? const mermaidChartsInMarkdown = /^[^\S\n]*```(?:mermaid)([^\S\n]*\r?\n([\s\S]*?))```[^\S\n]*$/ const mermaidChartsInMarkdownRegexGlobal = new RegExp(mermaidChartsInMarkdown, 'gm') const browser = await puppeteer.launch(puppeteerConfig) try { if (!outputFormat) { const outputFormatFromFilename = /** * @type {"md" | "markdown" | "svg" | "png" | "pdf"} */ (path.extname(output).replace('.', '')) if (outputFormatFromFilename === 'md' || outputFormatFromFilename === 'markdown') { // fallback to svg in case no outputFormat is given and output file is MD outputFormat = 'svg' } else { outputFormat = outputFormatFromFilename } } if (!/(?:svg|png|pdf)$/.test(outputFormat)) { throw new Error('Output format must be one of "svg", "png" or "pdf"') } const definition = await getInputData(input) if (input && /\.(md|markdown)$/.test(input)) { const imagePromises = [] for (const mermaidCodeblockMatch of definition.matchAll(mermaidChartsInMarkdownRegexGlobal)) { const mermaidDefinition = mermaidCodeblockMatch[2] /** Output can be either a template image file, or a `.md` output file. * If it is a template image file, use that to created numbered diagrams * I.e. if "out.png", use "out-1.png", "out-2.png", etc * If it is an output `.md` file, use that to base .svg numbered diagrams on * I.e. if "out.md". use "out-1.svg", "out-2.svg", etc * @type {string} */ const outputFile = output.replace( /(\.(md|markdown|png|svg|pdf))$/, `-${imagePromises.length + 1}$1` ).replace(/\.(md|markdown)$/, `.${outputFormat}`) const outputFileRelative = `./${path.relative(path.dirname(path.resolve(output)), path.resolve(outputFile))}` const imagePromise = (async () => { const { title, desc, data } = await renderMermaid(browser, mermaidDefinition, outputFormat, parseMMDOptions) await fs.promises.writeFile(outputFile, data) info(` ✅ ${outputFileRelative}`) return { url: outputFileRelative, title, alt: desc } })() imagePromises.push(imagePromise) } if (imagePromises.length) { info(`Found ${imagePromises.length} mermaid charts in Markdown input`) } else { info('No mermaid charts found in Markdown input') } const images = await Promise.all(imagePromises) if (/\.(md|markdown)$/.test(output)) { const outDefinition = definition.replace(mermaidChartsInMarkdownRegexGlobal, (_mermaidMd) => { // pop first image from front of array const { url, title, alt } = /** * @type {MarkdownImageProps} We use the same regex, * so we will never try to get too many objects from the array. * (aka `images.shift()` will never return `undefined`) */ (images.shift()) return markdownImage({ url, title, alt: alt || 'diagram' }) }) await fs.promises.writeFile(output, outDefinition, 'utf-8') info(` ✅ ${output}`) } } else { info('Generating single mermaid chart') const data = await parseMMD(browser, definition, outputFormat, parseMMDOptions) await fs.promises.writeFile(output, data) } } finally { await browser.close() } } export { run, renderMermaid, parseMMD, cli, error }