@mermaid-js/mermaid-cli 9.1.6 → 9.2.2

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.2.2",
4
4
  "description": "Command-line interface for mermaid",
5
5
  "license": "MIT",
6
6
  "repository": "git@github.com:mermaid-js/mermaid-cli.git",
@@ -14,9 +14,8 @@
14
14
  },
15
15
  "exports": "./src/index.js",
16
16
  "scripts": {
17
- "upgrade": "yarn-upgrade-all && sh copy_modules.sh",
18
- "prepare": "sh copy_modules.sh",
19
- "prepack": "sh copy_modules.sh",
17
+ "prepare": "vite build",
18
+ "prepack": "vite build",
20
19
  "test": "standard && yarn node --experimental-vm-modules $(yarn bin jest)",
21
20
  "lint": "standard",
22
21
  "lint-fix": "standard --fix"
@@ -24,20 +23,22 @@
24
23
  "dependencies": {
25
24
  "chalk": "^5.0.1",
26
25
  "commander": "^9.0.0",
27
- "puppeteer": "^16.1.0"
26
+ "puppeteer": "^19.0.0"
28
27
  },
29
28
  "devDependencies": {
30
29
  "@fortawesome/fontawesome-free-webfonts": "^1.0.9",
31
- "mermaid": "^9.1.2",
32
- "jest": "^28.1.2",
30
+ "@mermaid-js/mermaid-mindmap": "^9.2.2",
31
+ "mermaid": "^9.2.2",
32
+ "jest": "^29.0.1",
33
33
  "standard": "^17.0.0",
34
+ "vite": "^3.2.3",
35
+ "vite-plugin-singlefile": "^0.13.1",
36
+ "vite-svg-loader": "^3.6.0",
34
37
  "yarn-upgrade-all": "^0.7.0"
35
38
  },
36
39
  "files": [
37
40
  "src/",
38
- "mermaid.min.js",
39
- "index.html",
40
- "fontawesome/*"
41
+ "dist/"
41
42
  ],
42
43
  "jest": {
43
44
  "moduleNameMapper": {
@@ -46,7 +47,7 @@
46
47
  },
47
48
  "standard": {
48
49
  "ignore": [
49
- "mermaid.min.js"
50
+ "/dist/"
50
51
  ]
51
52
  }
52
53
  }
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())) {
@@ -103,7 +98,11 @@ async function cli () {
103
98
  // if an input file is defined, it should take precedence, otherwise, input is
104
99
  // coming from stdin and just name the file out.svg, if it hasn't been
105
100
  // specified with the '-o' option
106
- output = input ? (input + '.svg') : 'out.svg'
101
+ if (outputFormat) {
102
+ output = input ? (`${input}.${outputFormat}`) : `out.${outputFormat}`
103
+ } else {
104
+ output = input ? (`${input}.svg`) : 'out.svg'
105
+ }
107
106
  }
108
107
  if (!/\.(?:svg|png|pdf|md)$/.test(output)) {
109
108
  error('Output file must end with ".md", ".svg", ".png" or ".pdf"')
@@ -144,6 +143,7 @@ async function cli () {
144
143
  input, output, {
145
144
  puppeteerConfig,
146
145
  quiet,
146
+ outputFormat,
147
147
  parseMMDOptions: {
148
148
  mermaidConfig, backgroundColor, myCSS, pdfFit, viewport: { width, height, deviceScaleFactor }
149
149
  }
@@ -163,84 +163,172 @@ async function cli () {
163
163
  /**
164
164
  * Parse and render a mermaid diagram.
165
165
  *
166
+ * @deprecated Prefer {@link renderMermaid}, as it also returns useful metadata.
167
+ *
166
168
  * @param {puppeteer.Browser} browser - Puppeteer Browser
167
169
  * @param {string} definition - Mermaid diagram definition
168
170
  * @param {"svg" | "png" | "pdf"} outputFormat - Mermaid output format.
169
171
  * @param {ParseMDDOptions} [opt] - Options, see {@link ParseMDDOptions} for details.
172
+ *
170
173
  * @returns {Promise<Buffer>} The output file in bytes.
171
174
  */
172
- async function parseMMD (browser, definition, outputFormat, { viewport, backgroundColor = 'white', mermaidConfig = {}, myCSS, pdfFit } = {}) {
175
+ async function parseMMD (...args) {
176
+ const { data } = await renderMermaid(...args)
177
+ return data
178
+ }
179
+
180
+ /**
181
+ * Render a mermaid diagram.
182
+ *
183
+ * @param {puppeteer.Browser} browser - Puppeteer Browser
184
+ * @param {string} definition - Mermaid diagram definition
185
+ * @param {"svg" | "png" | "pdf"} outputFormat - Mermaid output format.
186
+ * @param {ParseMDDOptions} [opt] - Options, see {@link ParseMDDOptions} for details.
187
+ * @returns {Promise<{title?: string, desc?: string, data: Buffer}>} The output file in bytes,
188
+ * with optional metadata.
189
+ */
190
+ async function renderMermaid (browser, definition, outputFormat, { viewport, backgroundColor = 'white', mermaidConfig = {}, myCSS, pdfFit } = {}) {
173
191
  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
192
+ page.on('console', (msg) => {
193
+ console.log(msg.text())
194
+ })
195
+ try {
196
+ if (viewport) {
197
+ await page.setViewport(viewport)
198
+ }
199
+ const mermaidHTMLPath = path.join(__dirname, '..', 'dist', 'index.html')
200
+ await page.goto(url.pathToFileURL(mermaidHTMLPath))
201
+ await page.$eval('body', (body, backgroundColor) => {
202
+ body.style.background = backgroundColor
203
+ }, backgroundColor)
204
+ const metadata = await page.$eval('#container', async (container, definition, mermaidConfig, myCSS, backgroundColor) => {
205
+ container.textContent = definition
206
+
207
+ /** @type {import("mermaid")} Already imported mermaid instance */
208
+ const mermaid = globalThis.mermaid
209
+ /** @type {import("@mermaid-js/mermaid-mindmap")} */
210
+ const mermaidMindmap = globalThis.mermaidMindmap
211
+
212
+ await mermaid.registerExternalDiagrams([mermaidMindmap])
213
+
214
+ mermaid.initialize(mermaidConfig)
215
+ // should throw an error if mmd diagram is invalid
216
+ try {
217
+ await mermaid.initThrowsErrorsAsync(undefined, container)
218
+ } catch (error) {
219
+ if (error instanceof Error) {
220
+ // mermaid-js doesn't currently throws JS Errors, but let's leave this
221
+ // here in case it does in the future
222
+ throw error
223
+ } else {
224
+ throw new Error(error?.message ?? 'Unknown mermaid render error')
225
+ }
226
+ }
227
+
228
+ const svg = container.getElementsByTagName?.('svg')?.[0]
229
+ if (svg?.style) {
230
+ svg.style.backgroundColor = backgroundColor
193
231
  } else {
194
- throw new Error(error?.message ?? 'Unknown mermaid render error')
232
+ warn('svg not found. Not applying background color.')
233
+ }
234
+ if (myCSS) {
235
+ // add CSS as a <svg>...<style>... element
236
+ // see https://developer.mozilla.org/en-US/docs/Web/API/SVGStyleElement
237
+ const style = document.createElementNS('http://www.w3.org/2000/svg', 'style')
238
+ style.appendChild(document.createTextNode(myCSS))
239
+ svg.appendChild(style)
195
240
  }
196
- }
197
241
 
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)
242
+ // Finds SVG metadata for accessibility purposes
243
+ /** SVG title */
244
+ let title = null
245
+ // If <title> exists, it must be the first child Node,
246
+ // see https://www.w3.org/TR/SVG11/struct.html#DescriptionAndTitleElements
247
+ /* global SVGTitleElement, SVGDescElement */ // These exist in browser-based code
248
+ if (svg.firstChild instanceof SVGTitleElement) {
249
+ title = svg.firstChild.textContent
250
+ }
251
+ /** SVG description. According to SVG spec, we should use the first one we find */
252
+ let desc = null
253
+ for (const svgNode of svg.children) {
254
+ if (svgNode instanceof SVGDescElement) {
255
+ desc = svgNode.textContent
256
+ }
257
+ }
258
+ return {
259
+ title, desc
260
+ }
261
+ }, definition, mermaidConfig, myCSS, backgroundColor)
213
262
 
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) {
263
+ if (outputFormat === 'svg') {
264
+ const svgXML = await page.$eval('svg', (svg) => {
265
+ // SVG might have HTML <foreignObject> that are not valid XML
266
+ // E.g. <br> must be replaced with <br/>
267
+ // Luckily the DOM Web API has the XMLSerializer for this
268
+ // eslint-disable-next-line no-undef
269
+ const xmlSerializer = new XMLSerializer()
270
+ return xmlSerializer.serializeToString(svg)
271
+ })
272
+ return {
273
+ ...metadata,
274
+ data: Buffer.from(svgXML, 'utf8')
275
+ }
276
+ } else if (outputFormat === 'png') {
229
277
  const clip = await page.$eval('svg', svg => {
230
278
  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'
279
+ return { x: Math.floor(react.left), y: Math.floor(react.top), width: Math.ceil(react.width), height: Math.ceil(react.height) }
242
280
  })
281
+ await page.setViewport({ ...viewport, width: clip.x + clip.width, height: clip.y + clip.height })
282
+ return {
283
+ ...metadata,
284
+ data: await page.screenshot({ clip, omitBackground: backgroundColor === 'transparent' })
285
+ }
286
+ } else { // pdf
287
+ if (pdfFit) {
288
+ const clip = await page.$eval('svg', svg => {
289
+ const react = svg.getBoundingClientRect()
290
+ return { x: react.left, y: react.top, width: react.width, height: react.height }
291
+ })
292
+ return {
293
+ ...metadata,
294
+ data: await page.pdf({
295
+ omitBackground: backgroundColor === 'transparent',
296
+ width: (Math.ceil(clip.width) + clip.x * 2) + 'px',
297
+ height: (Math.ceil(clip.height) + clip.y * 2) + 'px',
298
+ pageRanges: '1-1'
299
+ })
300
+ }
301
+ } else {
302
+ return {
303
+ ...metadata,
304
+ data: await page.pdf({
305
+ omitBackground: backgroundColor === 'transparent'
306
+ })
307
+ }
308
+ }
243
309
  }
310
+ } finally {
311
+ await page.close()
312
+ }
313
+ }
314
+
315
+ /**
316
+ * Creates a markdown image syntax.
317
+ *
318
+ * @param {object} params - Parameters.
319
+ * @param {string} params.url - Path to image.
320
+ * @param {string} params.alt - Image alt text, required.
321
+ * @param {string} [params.title] - Image title text.
322
+ * @returns {`![${string}](${string})`} The markdown image text.
323
+ */
324
+ function markdownImage ({ url, title, alt }) {
325
+ // we can't use String.prototype.replaceAll since it's not supported in Node v14
326
+ const altEscaped = alt.replace(/[[\]\\]/g, '\\$&')
327
+ if (title) {
328
+ const titleEscaped = title.replace(/["\\]/g, '\\$&')
329
+ return `![${altEscaped}](${url} "${titleEscaped}")`
330
+ } else {
331
+ return `![${altEscaped}](${url})`
244
332
  }
245
333
  }
246
334
 
@@ -254,57 +342,80 @@ async function parseMMD (browser, definition, outputFormat, { viewport, backgrou
254
342
  * @param {Object} [opts] - Options
255
343
  * @param {puppeteer.LaunchOptions} [opts.puppeteerConfig] - Puppeteer launch options.
256
344
  * @param {boolean} [opts.quiet] - If set, suppress log output.
345
+ * @param {"svg" | "png" | "pdf"} [opts.outputFormat] - Mermaid output format.
346
+ * Defaults to `output` extension. Overrides `output` extension if set.
257
347
  * @param {ParseMDDOptions} [opts.parseMMDOptions] - Options to pass to {@link parseMMDOptions}.
258
348
  */
259
- async function run (input, output, { puppeteerConfig = {}, quiet = false, parseMMDOptions } = {}) {
349
+ async function run (input, output, { puppeteerConfig = {}, quiet = false, outputFormat, parseMMDOptions } = {}) {
260
350
  const info = message => {
261
351
  if (!quiet) {
262
352
  console.info(message)
263
353
  }
264
354
  }
265
355
 
266
- const mermaidChartsInMarkdown = /^```(?:mermaid)(\r?\n([\s\S]*?))```$/
356
+ const mermaidChartsInMarkdown = /^\s*```(?:mermaid)(\r?\n([\s\S]*?))```\s*$/
267
357
  const mermaidChartsInMarkdownRegexGlobal = new RegExp(mermaidChartsInMarkdown, 'gm')
268
- const mermaidChartsInMarkdownRegex = new RegExp(mermaidChartsInMarkdown)
269
358
  const browser = await puppeteer.launch(puppeteerConfig)
270
359
  try {
271
- // TODO: indent this (currently unindented to make `git diff` smaller)
360
+ if (!outputFormat) {
361
+ outputFormat = path.extname(output).replace('.', '')
362
+ }
363
+ if (outputFormat === 'md') {
364
+ // fallback to svg in case no outputFormat is given and output file is MD
365
+ outputFormat = 'svg'
366
+ }
367
+ if (!/(?:svg|png|pdf)$/.test(outputFormat)) {
368
+ throw new Error('Output format must be one of "svg", "png" or "pdf"')
369
+ }
370
+
272
371
  const definition = await getInputData(input)
273
372
  if (/\.md$/.test(input)) {
274
- const diagrams = []
275
- const outDefinition = definition.replace(mermaidChartsInMarkdownRegexGlobal, (mermaidMd) => {
276
- const md = mermaidChartsInMarkdownRegex.exec(mermaidMd)[1]
373
+ const imagePromises = []
374
+ for (const mermaidCodeblockMatch of definition.matchAll(mermaidChartsInMarkdownRegexGlobal)) {
375
+ const mermaidDefinition = mermaidCodeblockMatch[1]
277
376
 
278
377
  // Output can be either a template image file, or a `.md` output file.
279
378
  // If it is a template image file, use that to created numbered diagrams
280
379
  // I.e. if "out.png", use "out-1.png", "out-2.png", etc
281
380
  // If it is an output `.md` file, use that to base .svg numbered diagrams on
282
381
  // 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')
382
+ const outputFile = output.replace(/(\.(md|png|svg|pdf))$/, `-${imagePromises.length + 1}$1`).replace(/(\.md)$/, `.${outputFormat}`)
284
383
  const outputFileRelative = `./${path.relative(path.dirname(path.resolve(output)), path.resolve(outputFile))}`
285
- diagrams.push([outputFile, md])
286
- return `![diagram](${outputFileRelative})`
287
- })
288
384
 
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
- )
385
+ const imagePromise = (async () => {
386
+ const { title, desc, data } = await renderMermaid(browser, mermaidDefinition, outputFormat, parseMMDOptions)
387
+ await fs.promises.writeFile(outputFile, data)
388
+ info(` ${outputFileRelative}`)
389
+
390
+ return {
391
+ url: outputFileRelative,
392
+ title,
393
+ alt: desc
394
+ }
395
+ })()
396
+ imagePromises.push(imagePromise)
397
+ }
398
+
399
+ if (imagePromises.length) {
400
+ info(`Found ${imagePromises.length} mermaid charts in Markdown input`)
297
401
  } else {
298
402
  info('No mermaid charts found in Markdown input')
299
403
  }
300
404
 
405
+ const images = await Promise.all(imagePromises)
406
+
301
407
  if (/\.md$/.test(output)) {
408
+ const outDefinition = definition.replace(mermaidChartsInMarkdownRegexGlobal, (_mermaidMd) => {
409
+ // pop first image from front of array
410
+ const { url, title, alt } = images.shift()
411
+ return markdownImage({ url, title, alt: alt || 'diagram' })
412
+ })
302
413
  await fs.promises.writeFile(output, outDefinition, 'utf-8')
303
414
  info(` ✅ ${output}`)
304
415
  }
305
416
  } else {
306
417
  info('Generating single mermaid chart')
307
- const data = await parseMMD(browser, definition, path.extname(output).replace('.', ''), parseMMDOptions)
418
+ const data = await parseMMD(browser, definition, outputFormat, parseMMDOptions)
308
419
  await fs.promises.writeFile(output, data)
309
420
  }
310
421
  } finally {
@@ -312,4 +423,4 @@ async function run (input, output, { puppeteerConfig = {}, quiet = false, parseM
312
423
  }
313
424
  }
314
425
 
315
- export { run, parseMMD, cli, error }
426
+ export { run, renderMermaid, parseMMD, cli, error }
@@ -1,9 +0,0 @@
1
- @font-face {
2
- font-family: 'Font Awesome 5 Brands';
3
- font-style: normal;
4
- font-weight: normal;
5
- src: url("../webfonts/fa-brands-400.eot");
6
- src: url("../webfonts/fa-brands-400.eot?#iefix") format("embedded-opentype"), url("../webfonts/fa-brands-400.woff2") format("woff2"), url("../webfonts/fa-brands-400.woff") format("woff"), url("../webfonts/fa-brands-400.ttf") format("truetype"), url("../webfonts/fa-brands-400.svg#fontawesome") format("svg"); }
7
-
8
- .fab {
9
- font-family: 'Font Awesome 5 Brands'; }
@@ -1,10 +0,0 @@
1
- @font-face {
2
- font-family: 'Font Awesome 5 Free';
3
- font-style: normal;
4
- font-weight: 400;
5
- src: url("../webfonts/fa-regular-400.eot");
6
- src: url("../webfonts/fa-regular-400.eot?#iefix") format("embedded-opentype"), url("../webfonts/fa-regular-400.woff2") format("woff2"), url("../webfonts/fa-regular-400.woff") format("woff"), url("../webfonts/fa-regular-400.ttf") format("truetype"), url("../webfonts/fa-regular-400.svg#fontawesome") format("svg"); }
7
-
8
- .far {
9
- font-family: 'Font Awesome 5 Free';
10
- font-weight: 400; }
@@ -1,11 +0,0 @@
1
- @font-face {
2
- font-family: 'Font Awesome 5 Free';
3
- font-style: normal;
4
- font-weight: 900;
5
- src: url("../webfonts/fa-solid-900.eot");
6
- src: url("../webfonts/fa-solid-900.eot?#iefix") format("embedded-opentype"), url("../webfonts/fa-solid-900.woff2") format("woff2"), url("../webfonts/fa-solid-900.woff") format("woff"), url("../webfonts/fa-solid-900.ttf") format("truetype"), url("../webfonts/fa-solid-900.svg#fontawesome") format("svg"); }
7
-
8
- .fa,
9
- .fas {
10
- font-family: 'Font Awesome 5 Free';
11
- font-weight: 900; }