-
Notifications
You must be signed in to change notification settings - Fork 249
/
index.js
487 lines (450 loc) · 18.7 KB
/
index.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
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 }