Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
quarto-dev
GitHub Repository: quarto-dev/quarto-cli
Path: blob/main/src/core/handlers/mermaid.ts
3583 views
1
/*
2
* mermaid.ts
3
*
4
* Copyright (C) 2022 Posit Software, PBC
5
*/
6
7
import {
8
LanguageCellHandlerContext,
9
LanguageCellHandlerOptions,
10
LanguageHandler,
11
} from "./types.ts";
12
import { baseHandler, install } from "./base.ts";
13
import { formatResourcePath } from "../resources.ts";
14
import { join } from "../../deno_ral/path.ts";
15
import {
16
isIpynbOutput,
17
isJavascriptCompatible,
18
isLatexOutput,
19
isMarkdownOutput,
20
isRevealjsOutput,
21
} from "../../config/format.ts";
22
import { QuartoMdCell } from "../lib/break-quarto-md.ts";
23
import { asMappedString, mappedConcat } from "../lib/mapped-text.ts";
24
import {
25
fixupAlignment,
26
makeResponsive,
27
resolveSize,
28
setSvgSize,
29
} from "../svg.ts";
30
import {
31
kFigAlign,
32
kFigHeight,
33
kFigResponsive,
34
kFigWidth,
35
kMermaidFormat,
36
} from "../../config/constants.ts";
37
import { Element } from "../deno-dom.ts";
38
import { convertFromYaml } from "../lib/yaml-schema/from-yaml.ts";
39
import { readYamlFromString } from "../yaml.ts";
40
import { pandocHtmlBlock, pandocRawStr } from "../pandoc/codegen.ts";
41
import { LocalizedError } from "../lib/located-error.ts";
42
import { warning } from "../../deno_ral/log.ts";
43
import { FormatDependency } from "../../config/types.ts";
44
import { mappedDiff } from "../mapped-text.ts";
45
import { escape } from "../../core/lodash.ts";
46
47
const mermaidHandler: LanguageHandler = {
48
...baseHandler,
49
50
schema() {
51
return Promise.resolve(convertFromYaml(readYamlFromString(`
52
object:
53
properties:
54
mermaid-format:
55
enum: [png, svg, js]
56
theme:
57
anyOf:
58
- null
59
- string
60
`)));
61
},
62
63
type: "cell",
64
stage: "post-engine",
65
66
languageName: "mermaid",
67
languageClass: (options: LanguageCellHandlerOptions) => {
68
if (isMarkdownOutput(options.format, ["gfm"])) {
69
return "mermaid-source"; // sidestep github's in-band signaling of mermaid diagrams
70
} else {
71
return "default"; // no pandoc highlighting yet so we use 'default' to get grey shading
72
}
73
},
74
75
defaultOptions: {
76
echo: false,
77
eval: true,
78
include: true,
79
},
80
81
comment: "%%",
82
83
async cell(
84
handlerContext: LanguageCellHandlerContext,
85
cell: QuartoMdCell, // this has unmerged cell options
86
options: Record<string, unknown>, // this merges default and cell options, we have to be careful.
87
) {
88
const mermaidOpts: Record<string, string> = {};
89
if (
90
typeof handlerContext.options.format.metadata.mermaid === "object" &&
91
handlerContext.options.format.metadata.mermaid
92
) {
93
const { mermaid } = handlerContext.options.format.metadata as {
94
mermaid: Record<string, string>;
95
};
96
if (mermaid.theme) {
97
mermaidOpts.theme = mermaid.theme;
98
} else {
99
mermaidOpts.theme = "neutral";
100
}
101
}
102
103
const cellContent = handlerContext.cellContent(cell);
104
// TODO escaping removes MappedString information.
105
// create puppeteer target page
106
const content = `<html>
107
<head>
108
<script src="./mermaid.min.js"></script>
109
</head>
110
<body>
111
<pre class="mermaid">\n${escape(cellContent.value)}\n</pre>
112
<script>
113
mermaid.initialize(${JSON.stringify(mermaidOpts)});
114
</script>
115
</html>`;
116
const selector = "pre.mermaid svg";
117
const resources: [string, string][] = [[
118
"mermaid.min.js",
119
Deno.readTextFileSync(
120
formatResourcePath("html", join("mermaid", "mermaid.min.js")),
121
),
122
]];
123
124
const setupMermaidSvgJsRuntime = () => {
125
if (handlerContext.getState().hasSetupMermaidSvgJsRuntime) {
126
return;
127
}
128
handlerContext.getState().hasSetupMermaidSvgJsRuntime = true;
129
130
const dep: FormatDependency = {
131
name: "quarto-diagram",
132
scripts: [
133
{
134
name: "mermaid-postprocess-shim.js",
135
path: formatResourcePath(
136
"html",
137
join("mermaid", "mermaid-postprocess-shim.js"),
138
),
139
afterBody: true,
140
},
141
],
142
};
143
handlerContext.addHtmlDependency(dep);
144
};
145
146
const setupMermaidJsRuntime = () => {
147
if (handlerContext.getState().hasSetupMermaidJsRuntime) {
148
return;
149
}
150
handlerContext.getState().hasSetupMermaidJsRuntime = true;
151
152
const jsName =
153
handlerContext.options.context.format.metadata?.["mermaid-debug"]
154
? "mermaid.js"
155
: "mermaid.min.js";
156
157
if (mermaidOpts.theme) {
158
const mermaidMeta: Record<string, string> = {};
159
mermaidMeta["mermaid-theme"] = mermaidOpts.theme;
160
handlerContext.addHtmlDependency({
161
name: "quarto-mermaid-conf",
162
meta: mermaidMeta,
163
});
164
}
165
166
const dep: FormatDependency = {
167
name: "quarto-diagram",
168
scripts: [
169
{
170
name: jsName,
171
path: formatResourcePath("html", join("mermaid", jsName)),
172
},
173
{
174
name: "mermaid-init.js",
175
path: formatResourcePath(
176
"html",
177
join("mermaid", "mermaid-init.js"),
178
),
179
afterBody: true,
180
},
181
],
182
stylesheets: [
183
{
184
name: "mermaid.css",
185
path: formatResourcePath("html", join("mermaid", "mermaid.css")),
186
},
187
],
188
};
189
handlerContext.addHtmlDependency(dep);
190
};
191
192
const makeFigLink = (
193
sourceName: string,
194
width?: number,
195
height?: number,
196
includeCaption?: boolean,
197
) => {
198
const figEnvSpecifier =
199
isLatexOutput(handlerContext.options.format.pandoc)
200
? ` fig-env='${cell.options?.["fig-env"] || "figure"}'`
201
: "";
202
let posSpecifier = "";
203
if (
204
isLatexOutput(handlerContext.options.format.pandoc) &&
205
cell.options?.["fig-pos"] !== false
206
) {
207
const v = Array.isArray(cell.options?.["fig-pos"])
208
? cell.options?.["fig-pos"].join("")
209
: cell.options?.["fig-pos"];
210
posSpecifier = ` fig-pos='${v || "H"}'`;
211
}
212
const idSpecifier = (cell.options?.label && includeCaption)
213
? ` #${cell.options?.label}`
214
: "";
215
const widthSpecifier = width
216
? `width="${Math.round(width * 100) / 100}in"`
217
: "";
218
const heightSpecifier = height
219
? ` height="${Math.round(height * 100) / 100}in"`
220
: "";
221
const captionSpecifier = includeCaption
222
? (cell.options?.["fig-cap"] || "")
223
: "";
224
225
return `\n![${captionSpecifier}](${sourceName}){${widthSpecifier}${heightSpecifier}${posSpecifier}${figEnvSpecifier}${idSpecifier}}\n`;
226
};
227
const responsive = handlerContext.options.context.format.metadata
228
?.[kFigResponsive];
229
230
const makeSvg = async () => {
231
setupMermaidSvgJsRuntime();
232
let svg = asMappedString(
233
(await handlerContext.extractHtml({
234
html: content,
235
selector,
236
resources,
237
}))[0],
238
);
239
240
const fixupRevealAlignment = (svg: Element) => {
241
if (isRevealjsOutput(handlerContext.options.context.format.pandoc)) {
242
const align = (options?.[kFigAlign] as string) ?? "center";
243
fixupAlignment(svg, align);
244
}
245
};
246
247
let newId: string | undefined = undefined;
248
const idsToPatch: string[] = [];
249
250
const fixupMermaidSvg = (svg: Element) => {
251
// replace mermaid id with a consistent one.
252
const { baseName: newMermaidId } = handlerContext.uniqueFigureName(
253
"mermaid-figure-",
254
"",
255
);
256
newId = newMermaidId;
257
fixupRevealAlignment(svg);
258
const oldId = svg.getAttribute("id") as string;
259
svg.setAttribute("id", newMermaidId);
260
const style = svg.querySelector("style")!;
261
style.innerHTML = style.innerHTML.replaceAll(oldId, () => newMermaidId);
262
263
for (const defNode of svg.querySelectorAll("defs")) {
264
const defEl = defNode as Element;
265
// because this is a defs node and deno-dom doesn't like non-html elements,
266
// we can't use the standard API
267
const m = defEl.innerHTML.match(/id="([^\"]+)"/);
268
if (m) {
269
const id = m[1];
270
idsToPatch.push(id);
271
}
272
}
273
};
274
275
if (
276
responsive && options[kFigWidth] === undefined &&
277
options[kFigHeight] === undefined
278
) {
279
svg = await makeResponsive(svg, fixupMermaidSvg);
280
} else {
281
svg = await setSvgSize(svg, options, (svg: Element) => {
282
// mermaid comes with too much styling wrt to max width. remove it.
283
svg.removeAttribute("style");
284
285
fixupMermaidSvg(svg);
286
});
287
}
288
289
// This is a preposterously ugly fix to a mermaid issue where
290
// duplicate definition ids are emitted, which causes diagrams to step
291
// on one another's toes.
292
if (idsToPatch.length) {
293
let oldSvgSrc = svg.value;
294
for (const idToPatch of idsToPatch) {
295
const to = `${newId}-${idToPatch}`;
296
// this string substitution is fraught, but I don't know how else to fix the problem.
297
oldSvgSrc = oldSvgSrc.replaceAll(
298
`"${idToPatch}"`,
299
() => `"${to}"`,
300
);
301
oldSvgSrc = oldSvgSrc.replaceAll(
302
`#${idToPatch}`,
303
() => `#${to}`,
304
);
305
}
306
svg = mappedDiff(svg, oldSvgSrc);
307
}
308
309
if (isMarkdownOutput(handlerContext.options.format, ["gfm"])) {
310
const { sourceName, fullName } = handlerContext
311
.uniqueFigureName(
312
"mermaid-figure-",
313
".svg",
314
);
315
Deno.writeTextFileSync(fullName, svg.value);
316
317
const {
318
widthInInches,
319
heightInInches,
320
} = await resolveSize(svg.value, cell.options ?? {});
321
322
return asMappedString(
323
makeFigLink(sourceName, widthInInches, heightInInches, true),
324
);
325
} else {
326
return this.build(
327
handlerContext,
328
cell,
329
svg,
330
options,
331
undefined,
332
new Set(["fig-width", "fig-height", "mermaid-format"]),
333
);
334
}
335
};
336
337
const makePng = async () => {
338
const {
339
filenames: [sourceName],
340
elements: [svgText],
341
} = await handlerContext.createPngsFromHtml({
342
prefix: "mermaid-figure-",
343
selector,
344
count: 1,
345
deviceScaleFactor: Number(options.deviceScaleFactor) || 4,
346
html: content,
347
resources,
348
});
349
350
const {
351
widthInInches,
352
heightInInches,
353
} = await resolveSize(svgText, cell.options ?? {});
354
355
if (isMarkdownOutput(handlerContext.options.format, ["gfm"])) {
356
return asMappedString(makeFigLink(
357
sourceName,
358
widthInInches,
359
heightInInches,
360
true,
361
));
362
} else {
363
return this.build(
364
handlerContext,
365
cell,
366
asMappedString(makeFigLink(
367
sourceName,
368
widthInInches,
369
heightInInches,
370
)),
371
options,
372
undefined,
373
new Set(["fig-width", "fig-height", "mermaid-format"]),
374
);
375
}
376
};
377
378
// deno-lint-ignore require-await
379
const makeJs = async () => {
380
setupMermaidJsRuntime();
381
// removed until we use mermaid 10.0.0
382
//
383
// const { baseName: tooltipName } = handlerContext
384
// .uniqueFigureName(
385
// "mermaid-tooltip-",
386
// "",
387
// );
388
const preAttrs = [];
389
if (options.label) {
390
preAttrs.push(`label="${options.label}"`);
391
}
392
const preEl = pandocHtmlBlock("pre")({
393
classes: ["mermaid", "mermaid-js"],
394
attrs: preAttrs,
395
});
396
397
const content = handlerContext.cellContent(cell);
398
preEl.push(pandocRawStr(escape(content.value))); // TODO escaping removes MappedString information.
399
400
const attrs: Record<string, unknown> = {};
401
if (isRevealjsOutput(handlerContext.options.context.format.pandoc)) {
402
attrs.reveal = true;
403
}
404
405
return this.build(
406
handlerContext,
407
cell,
408
mappedConcat([
409
preEl.mappedString(),
410
// tooltips appear to be broken in mermaid 9.2.2?
411
// They don't even work on their website: https://mermaid-js.github.io/mermaid/#/flowchart
412
// we drop them for now.
413
// `\n<div id="${tooltipName}" class="mermaidTooltip"></div>`,
414
]),
415
options,
416
attrs,
417
new Set(["mermaid-format"]),
418
);
419
};
420
421
const makeDefault = async () => {
422
if (isIpynbOutput(handlerContext.options.format.pandoc)) {
423
return await makePng();
424
} else if (isJavascriptCompatible(handlerContext.options.format)) {
425
return await makeJs();
426
} else if (
427
isMarkdownOutput(handlerContext.options.format, ["gfm"])
428
) {
429
return mappedConcat([
430
"\n``` mermaid\n",
431
cellContent,
432
"\n```\n",
433
]);
434
} else {
435
return await makePng();
436
}
437
};
438
439
const format = options[kMermaidFormat] ||
440
handlerContext.options.format.execute[kMermaidFormat];
441
442
if (format === "svg") {
443
return await makeSvg();
444
} else if (format === "png") {
445
return await makePng();
446
} else if (format === "js") {
447
if (!isJavascriptCompatible(handlerContext.options.format)) {
448
const error = new LocalizedError(
449
"IncompatibleOutput",
450
`\`mermaid-format: js\` not supported in format ${
451
handlerContext.options.format.pandoc.to ?? ""
452
}`,
453
cell.sourceVerbatim,
454
0,
455
);
456
warning(error.message);
457
console.log("");
458
return await makeDefault();
459
} else {
460
if (isRevealjsOutput(handlerContext.options.context.format.pandoc)) {
461
const error = new LocalizedError(
462
"NotRecommended",
463
`\`mermaid-format: js\` not recommended in format ${
464
handlerContext.options.format.pandoc.to ?? ""
465
}`,
466
cell.sourceVerbatim,
467
0,
468
);
469
warning(error.message);
470
console.log("");
471
}
472
return await makeJs();
473
}
474
} else {
475
return await makeDefault();
476
}
477
},
478
};
479
480
install(mermaidHandler);
481
482