@mermaid-js/mermaid-cli 9.1.5 → 9.1.6

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,27 +1,32 @@
1
1
  {
2
2
  "name": "@mermaid-js/mermaid-cli",
3
- "version": "9.1.5",
3
+ "version": "9.1.6",
4
4
  "description": "Command-line interface for mermaid",
5
5
  "license": "MIT",
6
6
  "repository": "git@github.com:mermaid-js/mermaid-cli.git",
7
+ "type": "module",
7
8
  "author": "Tyler Long <tyler4long@gmail.com>",
8
9
  "bin": {
9
- "mmdc": "./index.bundle.js"
10
+ "mmdc": "./src/cli.js"
10
11
  },
12
+ "engines": {
13
+ "node": ">=14.1.0"
14
+ },
15
+ "exports": "./src/index.js",
11
16
  "scripts": {
12
- "upgrade": "yarn-upgrade-all && source copy_modules.sh",
13
- "prepublishOnly": "babel ./src/index.js --out-file ./index.bundle.js",
14
- "test": "jest"
17
+ "upgrade": "yarn-upgrade-all && sh copy_modules.sh",
18
+ "prepare": "sh copy_modules.sh",
19
+ "prepack": "sh copy_modules.sh",
20
+ "test": "standard && yarn node --experimental-vm-modules $(yarn bin jest)",
21
+ "lint": "standard",
22
+ "lint-fix": "standard --fix"
15
23
  },
16
24
  "dependencies": {
17
- "chalk": "^4.1.0",
25
+ "chalk": "^5.0.1",
18
26
  "commander": "^9.0.0",
19
27
  "puppeteer": "^16.1.0"
20
28
  },
21
29
  "devDependencies": {
22
- "@babel/cli": "^7.0.0",
23
- "@babel/core": "^7.0.0",
24
- "@babel/preset-env": "^7.0.0",
25
30
  "@fortawesome/fontawesome-free-webfonts": "^1.0.9",
26
31
  "mermaid": "^9.1.2",
27
32
  "jest": "^28.1.2",
@@ -29,14 +34,19 @@
29
34
  "yarn-upgrade-all": "^0.7.0"
30
35
  },
31
36
  "files": [
32
- "index.bundle.js",
37
+ "src/",
33
38
  "mermaid.min.js",
34
39
  "index.html",
35
40
  "fontawesome/*"
36
41
  ],
42
+ "jest": {
43
+ "moduleNameMapper": {
44
+ "#(.*)": "<rootDir>/node_modules/$1"
45
+ }
46
+ },
37
47
  "standard": {
38
48
  "ignore": [
39
- "index.bundle.js"
49
+ "mermaid.min.js"
40
50
  ]
41
51
  }
42
52
  }
package/src/cli.js ADDED
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { cli, error } from './index.js'
4
+
5
+ process.title = 'mmdc'
6
+ cli().catch((exception) => error(exception instanceof Error ? exception.stack : exception))
package/src/index.js ADDED
@@ -0,0 +1,315 @@
1
+ import { Command } from 'commander'
2
+ import chalk from 'chalk'
3
+ import fs from 'fs'
4
+ import path from 'path'
5
+ import puppeteer from 'puppeteer'
6
+ import url from 'url'
7
+
8
+ // importing JSON is still experimental in Node.JS https://nodejs.org/docs/latest-v16.x/api/esm.html#json-modules
9
+ import { createRequire } from 'module'
10
+ const require = createRequire(import.meta.url)
11
+ const pkg = require('../package.json')
12
+ // __dirname is not available in ESM modules by default
13
+ const __dirname = url.fileURLToPath(new URL('.', import.meta.url))
14
+
15
+ const error = message => {
16
+ console.error(chalk.red(`\n${message}\n`))
17
+ process.exit(1)
18
+ }
19
+
20
+ const warn = message => {
21
+ console.log(chalk.yellow(`\n${message}\n`))
22
+ }
23
+
24
+ const checkConfigFile = file => {
25
+ if (!fs.existsSync(file)) {
26
+ error(`Configuration file "${file}" doesn't exist`)
27
+ }
28
+ }
29
+
30
+ const inputPipedFromStdin = () => fs.fstatSync(0).isFIFO()
31
+
32
+ const getInputData = async inputFile => new Promise((resolve, reject) => {
33
+ // if an input file has been specified using '-i', it takes precedence over
34
+ // piping from stdin
35
+ if (typeof inputFile !== 'undefined') {
36
+ return fs.readFile(inputFile, 'utf-8', (err, data) => {
37
+ if (err) {
38
+ return reject(err)
39
+ }
40
+
41
+ return resolve(data)
42
+ })
43
+ }
44
+
45
+ let data = ''
46
+ process.stdin.on('readable', function () {
47
+ const chunk = this.read()
48
+
49
+ if (chunk !== null) {
50
+ data += chunk
51
+ }
52
+ })
53
+
54
+ process.stdin.on('error', function (err) {
55
+ reject(err)
56
+ })
57
+
58
+ process.stdin.on('end', function () {
59
+ resolve(data)
60
+ })
61
+ })
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
+ async function cli () {
69
+ // TODO: This is currently unindented to make `git diff` easier for PR reviewers.
70
+ const commander = new Command()
71
+ commander
72
+ .version(pkg.version)
73
+ .option('-t, --theme [theme]', 'Theme of the chart, could be default, forest, dark or neutral. Optional. Default: default', /^default|forest|dark|neutral$/, 'default')
74
+ .option('-w, --width [width]', 'Width of the page. Optional. Default: 800', /^\d+$/, '800')
75
+ .option('-H, --height [height]', 'Height of the page. Optional. Default: 600', /^\d+$/, '600')
76
+ .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
+ .option('-o, --output [output]', 'Output file. It should be either md, svg, png or pdf. Optional. Default: input + ".svg"')
78
+ .option('-b, --backgroundColor [backgroundColor]', 'Background color for pngs/svgs (not pdfs). Example: transparent, red, \'#F0F0F0\'. Optional. Default: white')
79
+ .option('-c, --configFile [configFile]', 'JSON configuration file for mermaid. Optional')
80
+ .option('-C, --cssFile [cssFile]', 'CSS file for the page. Optional')
81
+ .option('-s, --scale [scale]', 'Puppeteer scale factor, default 1. Optional')
82
+ .option('-f, --pdfFit [pdfFit]', 'Scale PDF to fit chart')
83
+ .option('-q, --quiet', 'Suppress log output')
84
+ .option('-p --puppeteerConfigFile [puppeteerConfigFile]', 'JSON configuration file for puppeteer. Optional')
85
+ .parse(process.argv)
86
+
87
+ const options = commander.opts()
88
+
89
+ let { theme, width, height, input, output, backgroundColor, configFile, cssFile, puppeteerConfigFile, scale, pdfFit, quiet } = options
90
+
91
+ // check input file
92
+ if (!(input || inputPipedFromStdin())) {
93
+ console.error(chalk.red('\nPlease specify input file: -i <input>\n'))
94
+ // Log to stderr, and return with error exitCode
95
+ commander.help({ error: true })
96
+ }
97
+ if (input && !fs.existsSync(input)) {
98
+ error(`Input file "${input}" doesn't exist`)
99
+ }
100
+
101
+ // check output file
102
+ if (!output) {
103
+ // if an input file is defined, it should take precedence, otherwise, input is
104
+ // coming from stdin and just name the file out.svg, if it hasn't been
105
+ // specified with the '-o' option
106
+ output = input ? (input + '.svg') : 'out.svg'
107
+ }
108
+ if (!/\.(?:svg|png|pdf|md)$/.test(output)) {
109
+ error('Output file must end with ".md", ".svg", ".png" or ".pdf"')
110
+ }
111
+ const outputDir = path.dirname(output)
112
+ if (!fs.existsSync(outputDir)) {
113
+ error(`Output directory "${outputDir}/" doesn't exist`)
114
+ }
115
+
116
+ // check config files
117
+ let mermaidConfig = { theme }
118
+ if (configFile) {
119
+ checkConfigFile(configFile)
120
+ mermaidConfig = Object.assign(mermaidConfig, JSON.parse(fs.readFileSync(configFile, 'utf-8')))
121
+ }
122
+ let puppeteerConfig = {}
123
+ if (puppeteerConfigFile) {
124
+ checkConfigFile(puppeteerConfigFile)
125
+ puppeteerConfig = JSON.parse(fs.readFileSync(puppeteerConfigFile, 'utf-8'))
126
+ }
127
+
128
+ // check cssFile
129
+ let myCSS
130
+ if (cssFile) {
131
+ if (!fs.existsSync(cssFile)) {
132
+ error(`CSS file "${cssFile}" doesn't exist`)
133
+ }
134
+ myCSS = fs.readFileSync(cssFile, 'utf-8')
135
+ }
136
+
137
+ // normalize args
138
+ width = parseInt(width)
139
+ height = parseInt(height)
140
+ backgroundColor = backgroundColor || 'white'
141
+ const deviceScaleFactor = parseInt(scale || 1, 10)
142
+
143
+ await run(
144
+ input, output, {
145
+ puppeteerConfig,
146
+ quiet,
147
+ parseMMDOptions: {
148
+ mermaidConfig, backgroundColor, myCSS, pdfFit, viewport: { width, height, deviceScaleFactor }
149
+ }
150
+ }
151
+ )
152
+ }
153
+
154
+ /**
155
+ * @typedef {Object} ParseMDDOptions Options to pass to {@link parseMMD}
156
+ * @property {puppeteer.Viewport} [viewport] - Puppeteer viewport (e.g. `width`, `height`, `deviceScaleFactor`)
157
+ * @property {string | "transparent"} [backgroundColor] - Background color.
158
+ * @property {Parameters<import("mermaid").Mermaid["initialize"]>[0]} [mermaidConfig] - Mermaid config.
159
+ * @property {CSSStyleDeclaration["cssText"]} [myCSS] - Optional CSS text.
160
+ * @property {boolean} pdfFit - If set, scale PDF to fit chart.
161
+ */
162
+
163
+ /**
164
+ * Parse and render a mermaid diagram.
165
+ *
166
+ * @param {puppeteer.Browser} browser - Puppeteer Browser
167
+ * @param {string} definition - Mermaid diagram definition
168
+ * @param {"svg" | "png" | "pdf"} outputFormat - Mermaid output format.
169
+ * @param {ParseMDDOptions} [opt] - Options, see {@link ParseMDDOptions} for details.
170
+ * @returns {Promise<Buffer>} The output file in bytes.
171
+ */
172
+ async function parseMMD (browser, definition, outputFormat, { viewport, backgroundColor = 'white', mermaidConfig = {}, myCSS, pdfFit } = {}) {
173
+ 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
193
+ } else {
194
+ throw new Error(error?.message ?? 'Unknown mermaid render error')
195
+ }
196
+ }
197
+
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)
213
+
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) {
229
+ const clip = await page.$eval('svg', svg => {
230
+ 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'
242
+ })
243
+ }
244
+ }
245
+ }
246
+
247
+ /**
248
+ * Renders a mermaid diagram or mermaid markdown file.
249
+ *
250
+ * @param {`${string}.md` | string} [input] - If this ends with `.md`, path to a markdown file containing mermaid.
251
+ * If this is a string, loads the mermaid definition from the given file.
252
+ * If this is `undefined`, loads the mermaid definition from stdin.
253
+ * @param {`${string}.${"md" | "svg" | "png" | "pdf"}`} output - Path to the output file.
254
+ * @param {Object} [opts] - Options
255
+ * @param {puppeteer.LaunchOptions} [opts.puppeteerConfig] - Puppeteer launch options.
256
+ * @param {boolean} [opts.quiet] - If set, suppress log output.
257
+ * @param {ParseMDDOptions} [opts.parseMMDOptions] - Options to pass to {@link parseMMDOptions}.
258
+ */
259
+ async function run (input, output, { puppeteerConfig = {}, quiet = false, parseMMDOptions } = {}) {
260
+ const info = message => {
261
+ if (!quiet) {
262
+ console.info(message)
263
+ }
264
+ }
265
+
266
+ const mermaidChartsInMarkdown = /^```(?:mermaid)(\r?\n([\s\S]*?))```$/
267
+ const mermaidChartsInMarkdownRegexGlobal = new RegExp(mermaidChartsInMarkdown, 'gm')
268
+ const mermaidChartsInMarkdownRegex = new RegExp(mermaidChartsInMarkdown)
269
+ const browser = await puppeteer.launch(puppeteerConfig)
270
+ try {
271
+ // TODO: indent this (currently unindented to make `git diff` smaller)
272
+ const definition = await getInputData(input)
273
+ if (/\.md$/.test(input)) {
274
+ const diagrams = []
275
+ const outDefinition = definition.replace(mermaidChartsInMarkdownRegexGlobal, (mermaidMd) => {
276
+ const md = mermaidChartsInMarkdownRegex.exec(mermaidMd)[1]
277
+
278
+ // Output can be either a template image file, or a `.md` output file.
279
+ // If it is a template image file, use that to created numbered diagrams
280
+ // I.e. if "out.png", use "out-1.png", "out-2.png", etc
281
+ // If it is an output `.md` file, use that to base .svg numbered diagrams on
282
+ // 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')
284
+ const outputFileRelative = `./${path.relative(path.dirname(path.resolve(output)), path.resolve(outputFile))}`
285
+ diagrams.push([outputFile, md])
286
+ return `![diagram](${outputFileRelative})`
287
+ })
288
+
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
+ )
297
+ } else {
298
+ info('No mermaid charts found in Markdown input')
299
+ }
300
+
301
+ if (/\.md$/.test(output)) {
302
+ await fs.promises.writeFile(output, outDefinition, 'utf-8')
303
+ info(` ✅ ${output}`)
304
+ }
305
+ } else {
306
+ info('Generating single mermaid chart')
307
+ const data = await parseMMD(browser, definition, path.extname(output).replace('.', ''), parseMMDOptions)
308
+ await fs.promises.writeFile(output, data)
309
+ }
310
+ } finally {
311
+ await browser.close()
312
+ }
313
+ }
314
+
315
+ export { run, parseMMD, cli, error }