@mermaid-js/mermaid-cli
Version:
Command-line interface for mermaid
640 lines (584 loc) • 26.4 kB
JavaScript
import { Command, Option, InvalidArgumentError } from 'commander'
import chalk from 'chalk'
import fs from 'fs'
import { resolve } from 'import-meta-resolve'
import os from 'node:os'
import path from 'path'
import pLimit from 'p-limit'
import puppeteer from 'puppeteer'
import url from 'url'
import { promisify } from 'node:util'
import { version } from './version.js'
import { Interceptor } from './puppeteerIntercept.js'
// __dirname is not available in ESM modules by default
const __dirname = url.fileURLToPath(new url.URL('.', import.meta.url))
/**
* CSS paths to embed in the page.
*/
const cssImports = /** @type {const} */ ({
'@fortawesome/fontawesome-free/css/brands.css': { level: 2 },
'@fortawesome/fontawesome-free/css/regular.css': { level: 2 },
'@fortawesome/fontawesome-free/css/solid.css': { level: 2 },
'@fortawesome/fontawesome-free/css/fontawesome.css': { level: 2 },
'katex/dist/katex.css': { level: 2 }
})
/**
* ESM bundles. Our interceptor doesn't support loading ESM modules that load
* other modules using relative paths, so these have to no `dependencies`.
*/
const mermaidESMPath = path.resolve(path.dirname(url.fileURLToPath(resolve('mermaid', import.meta.url))), 'mermaid.esm.mjs')
const elkESMPath = path.resolve(path.dirname(url.fileURLToPath(resolve('@mermaid-js/layout-elk', import.meta.url))), 'mermaid-layout-elk.esm.mjs')
const zenumlESMPath = path.resolve(path.dirname(url.fileURLToPath(resolve('@mermaid-js/mermaid-zenuml', import.meta.url))), 'mermaid-zenuml.esm.mjs')
/** @type {string | undefined} Path to `@mermaid-js/layout-tidy-tree`, if it is installed */
let tidyTreeESMPath
try {
tidyTreeESMPath = path.resolve(path.dirname(url.fileURLToPath(resolve('@mermaid-js/layout-tidy-tree', import.meta.url))), 'mermaid-layout-tidy-tree.esm.mjs')
} catch (error) {
if (error instanceof Error && 'code' in error && error.code === 'ERR_MODULE_NOT_FOUND') {
// optional dependency, this is normal
} else {
throw error
}
}
/**
* 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 a positive integer.')
}
return parsedValue
}
/**
* Commander parser that converts a string to a float.
*
* @param {string} value - The value from commander.
* @param {*} _unused - Unused.
* @returns {number} The value parsed as a number.
* @see https://github.com/tj/commander.js/wiki/Class:-Option#argparserfn
*/
function parseCommanderFloat (value, _unused) {
const parsedValue = parseFloat(value)
if (isNaN(parsedValue) || parsedValue <= 0) {
throw new InvalidArgumentError('Not a positive number.')
}
return parsedValue
}
async function cli () {
const commander = new Command()
commander
.version(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 (...)``` or :::mermaid (...):::) will be extracted and generated. Use `-` to read from stdin.')
.option('-o, --output [output]', 'Output file. It should be either md, svg, png, pdf or use `-` to output to stdout. Optional. Default: input + ".svg"')
.option('-a, --artefacts [artefacts]', 'Output artefacts path. Only used with Markdown input file. Optional. Default: output directory')
.addOption(new Option('-j, --jobs <jobs>', 'Number of parallel jobs to run when rendering multiple diagrams. Defaults to half the available CPUs.').argParser(parseCommanderInt).default(
Math.floor(os.availableParallelism() / 2) || 1
))
.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.')
.option('-I, --svgId [svgId]', 'The id attribute for the SVG element to be rendered.')
.addOption(new Option('-s, --scale [scale]', 'Puppeteer scale factor').argParser(parseCommanderFloat).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.')
.option('--iconPacks <icons...>', 'Icon packs to use, e.g. @iconify-json/logos. These should be Iconify NPM packages that expose a icons.json file, see https://iconify.design/docs/icons/json.html. These will be downloaded from https://unkpg.com when needed.', [])
.option('--iconPacksNamesAndUrls <prefix#iconsurl...>', 'Icon packs to use, e.g. azure#https://raw.githubusercontent.com/NakayamaKento/AzureIcons/refs/heads/main/icons.json where the name (prefix) of the icon pack is defined before the "#" and the url of the json definition after the "#". These should be Iconify json file formatted as IconifyJson, see https://iconify.design/docs/icons/json.html. These will be downloaded when needed.', [])
.parse(process.argv)
const options = commander.opts()
let { theme, width, height, input, output, outputFormat, backgroundColor, configFile, cssFile, svgId, puppeteerConfigFile, scale, pdfFit, quiet, iconPacks, iconPacksNamesAndUrls, artefacts, jobs } = 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'
}
} else if (output === '-') {
// `--output -` means write to stdout.
output = '/dev/stdout'
quiet = true
if (!outputFormat) {
outputFormat = 'svg'
warn('No output format specified, using svg. ' +
'If you want to specify an output format and suppress this warning, ' +
'please use `-e <format>.` '
)
}
} else if (!/\.(?:svg|png|pdf|md|markdown)$/.test(output)) {
error('Output file must end with ".md"/".markdown", ".svg", ".png" or ".pdf"')
}
if (artefacts) {
if (!input || !/\.(?:md|markdown)$/.test(input)) {
error('Artefacts [-a|--artefacts] path can only be used with Markdown input file')
}
if (!fs.existsSync(artefacts)) {
fs.mkdirSync(artefacts, { recursive: true })
}
}
const outputDir = path.dirname(output)
if (output !== '/dev/stdout' && !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')))
}
let puppeteerConfig = /** @type {import('puppeteer').LaunchOptions} */ ({
/*
* `headless: 'shell'` is not officially supported in Puppeteer v19, v20, v21,
* but still works. In Puppeteer v22, it uses the `chrome-headless-shell` package,
* which is much faster than the regular headless mode.
*/
headless: 'shell'
})
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,
limiter: pLimit(jobs),
parseMMDOptions: {
mermaidConfig, backgroundColor, myCSS, pdfFit, viewport: { width, height, deviceScaleFactor: scale }, svgId, iconPacks, iconPacksNamesAndUrls
},
artefacts
}
)
}
/**
* @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.
* @property {string} [svgId] - The id attribute for the SVG element to be rendered.
* @property {string[]} [iconPacks] - Icon packages to use.
* @property {string[]} [iconPacksNamesAndUrls] - IconPack Json file name and url to use.
/**
* Render a mermaid diagram.
*
* @param {import("puppeteer").Browser | import("puppeteer").BrowserContext} 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: Uint8Array}>} The output file in bytes,
* with optional metadata.
*/
async function renderMermaid (browser, definition, outputFormat, { viewport, backgroundColor = 'white', mermaidConfig = {}, myCSS, pdfFit, svgId, iconPacks = [], iconPacksNamesAndUrls = [] } = {}) {
const page = await browser.newPage()
page.on('console', (msg) => {
console.warn(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 interceptor = new Interceptor()
const mermaidUrl = await interceptor.fileUrlToInterceptUrl(url.pathToFileURL(mermaidESMPath))
const elkUrl = await interceptor.fileUrlToInterceptUrl(url.pathToFileURL(elkESMPath))
const zenumlUrl = await interceptor.fileUrlToInterceptUrl(url.pathToFileURL(zenumlESMPath))
const tidyTreeESMUrl = tidyTreeESMPath ? await interceptor.fileUrlToInterceptUrl(url.pathToFileURL(tidyTreeESMPath)) : undefined
page.on('request', interceptor.interceptRequestHandler)
await page.setRequestInterception(true)
await Promise.all(Object.entries(cssImports).map(async ([cssImport, { level }]) => {
const interceptUrl = await interceptor.fileUrlToInterceptUrl(new URL(resolve(cssImport, import.meta.url)), {
allowParentDirectoryLevel: level
})
await page.addStyleTag({
url: interceptUrl
})
}))
const metadata = await page.$eval('#container', async (container, { definition, mermaidConfig, myCSS, backgroundColor, svgId, iconPacks, iconPacksNamesAndUrls, elkUrl, mermaidUrl, zenumlUrl, tidyTreeESMUrl }) => {
const { default: mermaid } = await import(mermaidUrl)
/** @type {typeof import('@mermaid-js/layout-elk')} */
const { default: elkLayouts } = await import(elkUrl)
/** @type {typeof import('@mermaid-js/mermaid-zenuml')} */
const { default: zenuml } = await import(zenumlUrl)
// @ts-ignore -- @mermaid-js/layout-tidy-tree is an optionalDependency and might not be installed
/** @type {typeof import('@mermaid-js/layout-tidy-tree') | {default: undefined}} */
const { default: tidyTree } = tidyTreeESMUrl ? await import(tidyTreeESMUrl) : { default: undefined }
await Promise.all(Array.from(document.fonts, (font) => font.load()))
await mermaid.registerExternalDiagrams([zenuml])
mermaid.registerLayoutLoaders([
...elkLayouts,
...(tidyTree ?? [])
])
// lazy load icon packs
mermaid.registerIconPacks(
iconPacks.map((icon) => ({
name: icon.split('/')[1],
loader: () =>
fetch(`https://unpkg.com/${icon}/icons.json`)
.then((res) => res.json())
.catch(() => error(`Failed to fetch icon: ${icon}`))
}))
)
mermaid.registerIconPacks(
iconPacksNamesAndUrls.map((iconPackInfo) => {
const packName = iconPackInfo.split('#')[0]
const packUrl = iconPackInfo.split('#')[1]
return ({
name: packName,
loader: () =>
fetch(packUrl)
.then((res) => res.json())
.catch(() => {
error(`Failed to fetch icon: ${iconPackInfo}`)
})
}
)
}
)
)
mermaid.initialize({ startOnLoad: false, ...mermaidConfig })
// should throw an error if mmd diagram is invalid
const { svg: svgText } = await mermaid.render(svgId || '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, svgId, iconPacks, iconPacksNamesAndUrls, elkUrl, mermaidUrl, zenumlUrl, tidyTreeESMUrl })
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: new TextEncoder().encode(svgXML)
}
} 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 a markdown image that looks like ``
* @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 {``} 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 ``
} else {
return ``
}
}
/**
* @typedef {<Arguments extends unknown[], ReturnType>(
* function_: (...arguments_: Arguments) => Promise<ReturnType>,
* ...arguments_: Arguments
* ) => Promise<ReturnType>} Limiter - Adapted from `p-limit` package.
*/
/**
* 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"}` | "/dev/stdout"} 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.
* @param {string} [opts.artefacts] - Path to the artefacts directory.
* Defaults to `output` extension. Overrides `output` extension if set.
* @param {import("puppeteer").Browser} [opts.browser] - If set, reuses the given puppeteer browser instance instead of creating a new one.
* This may leak cookies/cache between runs.
* @param {Limiter} [opts.limiter] - If set, limiter function to avoid rendering too many diagrams in parallel.
* @param {ParseMDDOptions} [opts.parseMMDOptions] - Options to pass to {@link parseMMDOptions}.
*/
async function run (input, output, { browser: userPassedBrowser, puppeteerConfig = {}, quiet = false, outputFormat, parseMMDOptions, limiter = (x, ...args) => x(...args), artefacts } = {}) {
/**
* 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]*[`:]{3}(?:mermaid)([^\S\n]*\r?\n([\s\S]*?))[`:]{3}[^\S\n]*$/
const mermaidChartsInMarkdownRegexGlobal = new RegExp(mermaidChartsInMarkdown, 'gm')
/**
* @type {puppeteer.Browser | undefined}
* Lazy-loaded browser instance, only created when needed.
*/
let browser = userPassedBrowser
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)) {
if (output === '/dev/stdout') {
throw new Error('Cannot use `stdout` with markdown input')
}
const imagePromises = []
for (const mermaidCodeblockMatch of definition.matchAll(mermaidChartsInMarkdownRegexGlobal)) {
if (browser === undefined) {
browser = await puppeteer.launch(puppeteerConfig)
}
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}
*/
let outputFile = output.replace(
/(\.(md|markdown|png|svg|pdf))$/,
`-${imagePromises.length + 1}$1`
).replace(/\.(md|markdown)$/, `.${outputFormat}`)
if (artefacts) {
outputFile = path.resolve(artefacts, path.basename(outputFile))
}
const outputFileRelative = `./${path.relative(path.dirname(path.resolve(output)), path.resolve(outputFile))}`
const imagePromise = limiter(async (browser, outputFormat) => {
const { title, desc, data } = await renderMermaid(browser, mermaidDefinition, outputFormat, parseMMDOptions)
await fs.promises.writeFile(outputFile, data)
info(` ✅ ${outputFileRelative}`)
return {
url: outputFileRelative,
title,
alt: desc
}
}, browser, outputFormat)
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')
browser ??= await puppeteer.launch(puppeteerConfig)
const { data } = await renderMermaid(browser, definition, outputFormat, parseMMDOptions)
if (output === '/dev/stdout') {
await promisify(process.stdout.write).call(process.stdout, data)
} else {
await fs.promises.writeFile(output, data)
}
}
} finally {
// Don't close the browser if it was passed in by the user
if (browser !== userPassedBrowser) {
await browser?.close?.()
}
}
}
export { run, renderMermaid, cli, error }