@mermaid-js/mermaid-cli 9.1.6 → 9.1.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mermaid-js/mermaid-cli",
3
- "version": "9.1.6",
3
+ "version": "9.1.7",
4
4
  "description": "Command-line interface for mermaid",
5
5
  "license": "MIT",
6
6
  "repository": "git@github.com:mermaid-js/mermaid-cli.git",
@@ -24,12 +24,12 @@
24
24
  "dependencies": {
25
25
  "chalk": "^5.0.1",
26
26
  "commander": "^9.0.0",
27
- "puppeteer": "^16.1.0"
27
+ "puppeteer": "^18.0.5"
28
28
  },
29
29
  "devDependencies": {
30
30
  "@fortawesome/fontawesome-free-webfonts": "^1.0.9",
31
31
  "mermaid": "^9.1.2",
32
- "jest": "^28.1.2",
32
+ "jest": "^29.0.1",
33
33
  "standard": "^17.0.0",
34
34
  "yarn-upgrade-all": "^0.7.0"
35
35
  },
package/src/index.js CHANGED
@@ -60,13 +60,7 @@ const getInputData = async inputFile => new Promise((resolve, reject) => {
60
60
  })
61
61
  })
62
62
 
63
- const convertToValidXML = html => {
64
- // <br> tags in valid HTML (from innerHTML) look like <br>, but they must look like <br/> to be valid XML (such as SVG)
65
- return html.replace(/<br>/gi, '<br/>')
66
- }
67
-
68
63
  async function cli () {
69
- // TODO: This is currently unindented to make `git diff` easier for PR reviewers.
70
64
  const commander = new Command()
71
65
  commander
72
66
  .version(pkg.version)
@@ -75,6 +69,7 @@ async function cli () {
75
69
  .option('-H, --height [height]', 'Height of the page. Optional. Default: 600', /^\d+$/, '600')
76
70
  .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. Required.')
77
71
  .option('-o, --output [output]', 'Output file. It should be either md, svg, png or pdf. Optional. Default: input + ".svg"')
72
+ .option('-e, --outputFormat <format>', 'Output format for the generated image. It should be either svg, png or pdf. Optional. Default: output file extension')
78
73
  .option('-b, --backgroundColor [backgroundColor]', 'Background color for pngs/svgs (not pdfs). Example: transparent, red, \'#F0F0F0\'. Optional. Default: white')
79
74
  .option('-c, --configFile [configFile]', 'JSON configuration file for mermaid. Optional')
80
75
  .option('-C, --cssFile [cssFile]', 'CSS file for the page. Optional')
@@ -86,7 +81,7 @@ async function cli () {
86
81
 
87
82
  const options = commander.opts()
88
83
 
89
- let { theme, width, height, input, output, backgroundColor, configFile, cssFile, puppeteerConfigFile, scale, pdfFit, quiet } = options
84
+ let { theme, width, height, input, output, outputFormat, backgroundColor, configFile, cssFile, puppeteerConfigFile, scale, pdfFit, quiet } = options
90
85
 
91
86
  // check input file
92
87
  if (!(input || inputPipedFromStdin())) {
@@ -144,6 +139,7 @@ async function cli () {
144
139
  input, output, {
145
140
  puppeteerConfig,
146
141
  quiet,
142
+ outputFormat,
147
143
  parseMMDOptions: {
148
144
  mermaidConfig, backgroundColor, myCSS, pdfFit, viewport: { width, height, deviceScaleFactor }
149
145
  }
@@ -163,84 +159,161 @@ async function cli () {
163
159
  /**
164
160
  * Parse and render a mermaid diagram.
165
161
  *
162
+ * @deprecated Prefer {@link renderMermaid}, as it also returns useful metadata.
163
+ *
166
164
  * @param {puppeteer.Browser} browser - Puppeteer Browser
167
165
  * @param {string} definition - Mermaid diagram definition
168
166
  * @param {"svg" | "png" | "pdf"} outputFormat - Mermaid output format.
169
167
  * @param {ParseMDDOptions} [opt] - Options, see {@link ParseMDDOptions} for details.
168
+ *
170
169
  * @returns {Promise<Buffer>} The output file in bytes.
171
170
  */
172
- async function parseMMD (browser, definition, outputFormat, { viewport, backgroundColor = 'white', mermaidConfig = {}, myCSS, pdfFit } = {}) {
171
+ async function parseMMD (...args) {
172
+ const { data } = await renderMermaid(...args)
173
+ return data
174
+ }
175
+
176
+ /**
177
+ * Render a mermaid diagram.
178
+ *
179
+ * @param {puppeteer.Browser} browser - Puppeteer Browser
180
+ * @param {string} definition - Mermaid diagram definition
181
+ * @param {"svg" | "png" | "pdf"} outputFormat - Mermaid output format.
182
+ * @param {ParseMDDOptions} [opt] - Options, see {@link ParseMDDOptions} for details.
183
+ * @returns {Promise<{title?: string, desc?: string, data: Buffer}>} The output file in bytes,
184
+ * with optional metadata.
185
+ */
186
+ async function renderMermaid (browser, definition, outputFormat, { viewport, backgroundColor = 'white', mermaidConfig = {}, myCSS, pdfFit } = {}) {
173
187
  const page = await browser.newPage()
174
- if (viewport) {
175
- await page.setViewport(viewport)
176
- }
177
- const mermaidHTMLPath = path.join(__dirname, '..', 'index.html')
178
- await page.goto(url.pathToFileURL(mermaidHTMLPath))
179
- await page.$eval('body', (body, backgroundColor) => {
180
- body.style.background = backgroundColor
181
- }, backgroundColor)
182
- await page.$eval('#container', (container, definition, mermaidConfig, myCSS, backgroundColor) => {
183
- container.textContent = definition
184
- window.mermaid.initialize(mermaidConfig)
185
- // should throw an error if mmd diagram is invalid
186
- try {
187
- window.mermaid.initThrowsErrors(undefined, container)
188
- } catch (error) {
189
- if (error instanceof Error) {
190
- // mermaid-js doesn't currently throws JS Errors, but let's leave this
191
- // here in case it does in the future
192
- throw error
188
+ try {
189
+ if (viewport) {
190
+ await page.setViewport(viewport)
191
+ }
192
+ const mermaidHTMLPath = path.join(__dirname, '..', 'index.html')
193
+ await page.goto(url.pathToFileURL(mermaidHTMLPath))
194
+ await page.$eval('body', (body, backgroundColor) => {
195
+ body.style.background = backgroundColor
196
+ }, backgroundColor)
197
+ const metadata = await page.$eval('#container', (container, definition, mermaidConfig, myCSS, backgroundColor) => {
198
+ container.textContent = definition
199
+ window.mermaid.initialize(mermaidConfig)
200
+ // should throw an error if mmd diagram is invalid
201
+ try {
202
+ window.mermaid.initThrowsErrors(undefined, container)
203
+ } catch (error) {
204
+ if (error instanceof Error) {
205
+ // mermaid-js doesn't currently throws JS Errors, but let's leave this
206
+ // here in case it does in the future
207
+ throw error
208
+ } else {
209
+ throw new Error(error?.message ?? 'Unknown mermaid render error')
210
+ }
211
+ }
212
+
213
+ const svg = container.getElementsByTagName?.('svg')?.[0]
214
+ if (svg?.style) {
215
+ svg.style.backgroundColor = backgroundColor
193
216
  } else {
194
- throw new Error(error?.message ?? 'Unknown mermaid render error')
217
+ warn('svg not found. Not applying background color.')
218
+ }
219
+ if (myCSS) {
220
+ // add CSS as a <svg>...<style>... element
221
+ // see https://developer.mozilla.org/en-US/docs/Web/API/SVGStyleElement
222
+ const style = document.createElementNS('http://www.w3.org/2000/svg', 'style')
223
+ style.appendChild(document.createTextNode(myCSS))
224
+ svg.appendChild(style)
195
225
  }
196
- }
197
226
 
198
- const svg = container.getElementsByTagName?.('svg')?.[0]
199
- if (svg?.style) {
200
- svg.style.backgroundColor = backgroundColor
201
- } else {
202
- warn('svg not found. Not applying background color.')
203
- return
204
- }
205
- if (myCSS) {
206
- // add CSS as a <svg>...<style>... element
207
- // see https://developer.mozilla.org/en-US/docs/Web/API/SVGStyleElement
208
- const style = document.createElementNS('http://www.w3.org/2000/svg', 'style')
209
- style.appendChild(document.createTextNode(myCSS))
210
- svg.appendChild(style)
211
- }
212
- }, definition, mermaidConfig, myCSS, backgroundColor)
227
+ // Finds SVG metadata for accessibility purposes
228
+ /** SVG title */
229
+ let title = null
230
+ // If <title> exists, it must be the first child Node,
231
+ // see https://www.w3.org/TR/SVG11/struct.html#DescriptionAndTitleElements
232
+ /* global SVGTitleElement, SVGDescElement */ // These exist in browser-based code
233
+ if (svg.firstChild instanceof SVGTitleElement) {
234
+ title = svg.firstChild.textContent
235
+ }
236
+ /** SVG description. According to SVG spec, we should use the first one we find */
237
+ let desc = null
238
+ for (const svgNode of svg.children) {
239
+ if (svgNode instanceof SVGDescElement) {
240
+ desc = svgNode.textContent
241
+ }
242
+ }
243
+ return {
244
+ title, desc
245
+ }
246
+ }, definition, mermaidConfig, myCSS, backgroundColor)
213
247
 
214
- if (outputFormat === 'svg') {
215
- const svg = await page.$eval('#container', (container) => {
216
- return container.innerHTML
217
- })
218
- const svgXML = convertToValidXML(svg)
219
- return Buffer.from(svgXML, 'utf8')
220
- } else if (outputFormat === 'png') {
221
- const clip = await page.$eval('svg', svg => {
222
- const react = svg.getBoundingClientRect()
223
- return { x: Math.floor(react.left), y: Math.floor(react.top), width: Math.ceil(react.width), height: Math.ceil(react.height) }
224
- })
225
- await page.setViewport({ ...viewport, width: clip.x + clip.width, height: clip.y + clip.height })
226
- return await page.screenshot({ clip, omitBackground: backgroundColor === 'transparent' })
227
- } else { // pdf
228
- if (pdfFit) {
248
+ if (outputFormat === 'svg') {
249
+ const svgXML = await page.$eval('svg', (svg) => {
250
+ // SVG might have HTML <foreignObject> that are not valid XML
251
+ // E.g. <br> must be replaced with <br/>
252
+ // Luckily the DOM Web API has the XMLSerializer for this
253
+ // eslint-disable-next-line no-undef
254
+ const xmlSerializer = new XMLSerializer()
255
+ return xmlSerializer.serializeToString(svg)
256
+ })
257
+ return {
258
+ ...metadata,
259
+ data: Buffer.from(svgXML, 'utf8')
260
+ }
261
+ } else if (outputFormat === 'png') {
229
262
  const clip = await page.$eval('svg', svg => {
230
263
  const react = svg.getBoundingClientRect()
231
- return { x: react.left, y: react.top, width: react.width, height: react.height }
232
- })
233
- return await page.pdf({
234
- omitBackground: backgroundColor === 'transparent',
235
- width: (Math.ceil(clip.width) + clip.x * 2) + 'px',
236
- height: (Math.ceil(clip.height) + clip.y * 2) + 'px',
237
- pageRanges: '1-1'
238
- })
239
- } else {
240
- return await page.pdf({
241
- omitBackground: backgroundColor === 'transparent'
264
+ return { x: Math.floor(react.left), y: Math.floor(react.top), width: Math.ceil(react.width), height: Math.ceil(react.height) }
242
265
  })
266
+ await page.setViewport({ ...viewport, width: clip.x + clip.width, height: clip.y + clip.height })
267
+ return {
268
+ ...metadata,
269
+ data: await page.screenshot({ clip, omitBackground: backgroundColor === 'transparent' })
270
+ }
271
+ } else { // pdf
272
+ if (pdfFit) {
273
+ const clip = await page.$eval('svg', svg => {
274
+ const react = svg.getBoundingClientRect()
275
+ return { x: react.left, y: react.top, width: react.width, height: react.height }
276
+ })
277
+ return {
278
+ ...metadata,
279
+ data: await page.pdf({
280
+ omitBackground: backgroundColor === 'transparent',
281
+ width: (Math.ceil(clip.width) + clip.x * 2) + 'px',
282
+ height: (Math.ceil(clip.height) + clip.y * 2) + 'px',
283
+ pageRanges: '1-1'
284
+ })
285
+ }
286
+ } else {
287
+ return {
288
+ ...metadata,
289
+ data: await page.pdf({
290
+ omitBackground: backgroundColor === 'transparent'
291
+ })
292
+ }
293
+ }
243
294
  }
295
+ } finally {
296
+ await page.close()
297
+ }
298
+ }
299
+
300
+ /**
301
+ * Creates a markdown image syntax.
302
+ *
303
+ * @param {object} params - Parameters.
304
+ * @param {string} params.url - Path to image.
305
+ * @param {string} params.alt - Image alt text, required.
306
+ * @param {string} [params.title] - Image title text.
307
+ * @returns {`![${string}](${string})`} The markdown image text.
308
+ */
309
+ function markdownImage ({ url, title, alt }) {
310
+ // we can't use String.prototype.replaceAll since it's not supported in Node v14
311
+ const altEscaped = alt.replace(/[[\]\\]/g, '\\$&')
312
+ if (title) {
313
+ const titleEscaped = title.replace(/["\\]/g, '\\$&')
314
+ return `![${altEscaped}](${url} "${titleEscaped}")`
315
+ } else {
316
+ return `![${altEscaped}](${url})`
244
317
  }
245
318
  }
246
319
 
@@ -254,57 +327,80 @@ async function parseMMD (browser, definition, outputFormat, { viewport, backgrou
254
327
  * @param {Object} [opts] - Options
255
328
  * @param {puppeteer.LaunchOptions} [opts.puppeteerConfig] - Puppeteer launch options.
256
329
  * @param {boolean} [opts.quiet] - If set, suppress log output.
330
+ * @param {"svg" | "png" | "pdf"} [opts.outputFormat] - Mermaid output format.
331
+ * Defaults to `output` extension. Overrides `output` extension if set.
257
332
  * @param {ParseMDDOptions} [opts.parseMMDOptions] - Options to pass to {@link parseMMDOptions}.
258
333
  */
259
- async function run (input, output, { puppeteerConfig = {}, quiet = false, parseMMDOptions } = {}) {
334
+ async function run (input, output, { puppeteerConfig = {}, quiet = false, outputFormat, parseMMDOptions } = {}) {
260
335
  const info = message => {
261
336
  if (!quiet) {
262
337
  console.info(message)
263
338
  }
264
339
  }
265
340
 
266
- const mermaidChartsInMarkdown = /^```(?:mermaid)(\r?\n([\s\S]*?))```$/
341
+ const mermaidChartsInMarkdown = /^\s*```(?:mermaid)(\r?\n([\s\S]*?))```\s*$/
267
342
  const mermaidChartsInMarkdownRegexGlobal = new RegExp(mermaidChartsInMarkdown, 'gm')
268
- const mermaidChartsInMarkdownRegex = new RegExp(mermaidChartsInMarkdown)
269
343
  const browser = await puppeteer.launch(puppeteerConfig)
270
344
  try {
271
- // TODO: indent this (currently unindented to make `git diff` smaller)
345
+ if (!outputFormat) {
346
+ outputFormat = path.extname(output).replace('.', '')
347
+ }
348
+ if (outputFormat === 'md') {
349
+ // fallback to svg in case no outputFormat is given and output file is MD
350
+ outputFormat = 'svg'
351
+ }
352
+ if (!/(?:svg|png|pdf)$/.test(outputFormat)) {
353
+ throw new Error('Output format must be one of "svg", "png" or "pdf"')
354
+ }
355
+
272
356
  const definition = await getInputData(input)
273
357
  if (/\.md$/.test(input)) {
274
- const diagrams = []
275
- const outDefinition = definition.replace(mermaidChartsInMarkdownRegexGlobal, (mermaidMd) => {
276
- const md = mermaidChartsInMarkdownRegex.exec(mermaidMd)[1]
358
+ const imagePromises = []
359
+ for (const mermaidCodeblockMatch of definition.matchAll(mermaidChartsInMarkdownRegexGlobal)) {
360
+ const mermaidDefinition = mermaidCodeblockMatch[1]
277
361
 
278
362
  // Output can be either a template image file, or a `.md` output file.
279
363
  // If it is a template image file, use that to created numbered diagrams
280
364
  // I.e. if "out.png", use "out-1.png", "out-2.png", etc
281
365
  // If it is an output `.md` file, use that to base .svg numbered diagrams on
282
366
  // I.e. if "out.md". use "out-1.svg", "out-2.svg", etc
283
- const outputFile = output.replace(/(\.(md|png|svg|pdf))$/, `-${diagrams.length + 1}$1`).replace(/(\.md)$/, '.svg')
367
+ const outputFile = output.replace(/(\.(md|png|svg|pdf))$/, `-${imagePromises.length + 1}$1`).replace(/(\.md)$/, `.${outputFormat}`)
284
368
  const outputFileRelative = `./${path.relative(path.dirname(path.resolve(output)), path.resolve(outputFile))}`
285
- diagrams.push([outputFile, md])
286
- return `![diagram](${outputFileRelative})`
287
- })
288
369
 
289
- if (diagrams.length) {
290
- info(`Found ${diagrams.length} mermaid charts in Markdown input`)
291
- await Promise.all(diagrams.map(async ([imgFile, md]) => {
292
- const data = await parseMMD(browser, md, path.extname(imgFile).replace('.', ''), parseMMDOptions)
293
- await fs.promises.writeFile(imgFile, data)
294
- info(` ✅ ${imgFile}`)
295
- })
296
- )
370
+ const imagePromise = (async () => {
371
+ const { title, desc, data } = await renderMermaid(browser, mermaidDefinition, outputFormat, parseMMDOptions)
372
+ await fs.promises.writeFile(outputFile, data)
373
+ info(` ${outputFileRelative}`)
374
+
375
+ return {
376
+ url: outputFileRelative,
377
+ title,
378
+ alt: desc
379
+ }
380
+ })()
381
+ imagePromises.push(imagePromise)
382
+ }
383
+
384
+ if (imagePromises.length) {
385
+ info(`Found ${imagePromises.length} mermaid charts in Markdown input`)
297
386
  } else {
298
387
  info('No mermaid charts found in Markdown input')
299
388
  }
300
389
 
390
+ const images = await Promise.all(imagePromises)
391
+
301
392
  if (/\.md$/.test(output)) {
393
+ const outDefinition = definition.replace(mermaidChartsInMarkdownRegexGlobal, (_mermaidMd) => {
394
+ // pop first image from front of array
395
+ const { url, title, alt } = images.shift()
396
+ return markdownImage({ url, title, alt: alt || 'diagram' })
397
+ })
302
398
  await fs.promises.writeFile(output, outDefinition, 'utf-8')
303
399
  info(` ✅ ${output}`)
304
400
  }
305
401
  } else {
306
402
  info('Generating single mermaid chart')
307
- const data = await parseMMD(browser, definition, path.extname(output).replace('.', ''), parseMMDOptions)
403
+ const data = await parseMMD(browser, definition, outputFormat, parseMMDOptions)
308
404
  await fs.promises.writeFile(output, data)
309
405
  }
310
406
  } finally {
@@ -312,4 +408,4 @@ async function run (input, output, { puppeteerConfig = {}, quiet = false, parseM
312
408
  }
313
409
  }
314
410
 
315
- export { run, parseMMD, cli, error }
411
+ export { run, renderMermaid, parseMMD, cli, error }