Skip to content

Commit 3317486

Browse files
committed
feat: adding mermaid support
1 parent 949b06b commit 3317486

File tree

2 files changed

+122
-53
lines changed

2 files changed

+122
-53
lines changed

mod.ts

+70-17
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ export class Renderer extends Marked.Renderer {
3131
allowMath: boolean;
3232
baseUrl: string | undefined;
3333
#slugger: GitHubSlugger;
34+
mermaidImport: boolean = false;
3435

3536
constructor(options: Marked.MarkedOptions & RenderOptions = {}) {
3637
super(options);
@@ -63,23 +64,41 @@ export class Renderer extends Marked.Renderer {
6364
// a language of `ts, ignore` should really be `ts`
6465
// and it should be lowercase to ensure it has parity with regular github markdown
6566
language = language?.split(",")?.[0].toLocaleLowerCase();
67+
const isMermaid = language === "mermaid";
6668

6769
// transform math code blocks into HTML+MathML
6870
// https://github.blog/changelog/2022-06-28-fenced-block-syntax-for-mathematical-expressions/
6971
if (language === "math" && this.allowMath) {
7072
return katex.renderToString(code, { displayMode: true });
7173
}
74+
if (isMermaid) {
75+
this.mermaidImport = true;
76+
}
7277
const grammar =
7378
language && Object.hasOwnProperty.call(Prism.languages, language)
7479
? Prism.languages[language]
7580
: undefined;
7681
if (grammar === undefined) {
82+
if (isMermaid) {
83+
return minify(`<div class="mermaid-container">
84+
<pre><code class="notranslate">${code}</code></pre>
85+
<div class="mermaid-code">${code}</div>
86+
</div>`);
87+
}
7788
return `<pre><code class="notranslate">${he.encode(code)}</code></pre>`;
7889
}
7990
const html = Prism.highlight(code, grammar, language!);
8091
const titleHtml = title
8192
? `<div class="markdown-code-title">${title}</div>`
8293
: ``;
94+
if (isMermaid) {
95+
return minify(`
96+
<div class="mermaid-container">
97+
<div class="highlight highlight-source-${language} notranslate">${titleHtml}<pre>${html}</pre></div>
98+
<div class="mermaid-code">${code}</div>
99+
</div>
100+
`);
101+
}
83102
return `<div class="highlight highlight-source-${language} notranslate">${titleHtml}<pre>${html}</pre></div>`;
84103
}
85104

@@ -99,6 +118,10 @@ export class Renderer extends Marked.Renderer {
99118
}
100119
}
101120

121+
function minify(str: string): string {
122+
return str.replace(/^\s+|\s+$|\n/gm, "");
123+
}
124+
102125
const BLOCK_MATH_REGEXP = /\$\$\s(.+?)\s\$\$/g;
103126
const INLINE_MATH_REGEXP = /\s\$((?=\S).*?(?=\S))\$/g;
104127

@@ -169,8 +192,33 @@ export function render(markdown: string, opts: RenderOptions = {}): string {
169192
: Marked.marked.parse(markdown, marked_opts)
170193
) as string;
171194

195+
let additionalCode = "";
196+
if (marked_opts.renderer.mermaidImport) {
197+
additionalCode = minify(`
198+
<script type="module">
199+
import mermaid from "https://cdn.jsdelivr.net/npm/mermaid/dist/mermaid.esm.min.mjs";
200+
mermaid.initialize({ startOnLoad: false, theme: "neutral" });
201+
202+
const elements = document.querySelectorAll(".mermaid-container");
203+
elements.forEach((element) => {
204+
const code = element.querySelector(".mermaid-code")?.textContent || "";
205+
if (code) {
206+
element.innerHTML = \`<div class="mermaid">\${code}</div>\`;
207+
}
208+
});
209+
210+
await mermaid.run();
211+
</script>
212+
<style>
213+
.mermaid-code {
214+
display: none;
215+
}
216+
</style>
217+
`);
218+
}
219+
172220
if (opts.disableHtmlSanitization) {
173-
return html;
221+
return additionalCode + html;
174222
}
175223

176224
let defaultAllowedTags = sanitizeHtml.defaults.allowedTags.concat([
@@ -243,6 +291,8 @@ export function render(markdown: string, opts: RenderOptions = {}): string {
243291
"markdown-alert",
244292
"markdown-alert-*",
245293
"markdown-code-title",
294+
"mermaid-code",
295+
"mermaid-container",
246296
],
247297
span: [
248298
"token",
@@ -334,22 +384,25 @@ export function render(markdown: string, opts: RenderOptions = {}): string {
334384
],
335385
};
336386

337-
return sanitizeHtml(html, {
338-
transformTags: {
339-
img: transformMedia,
340-
video: transformMedia,
341-
},
342-
allowedTags: [...defaultAllowedTags, ...(opts.allowedTags ?? [])],
343-
allowedAttributes: mergeAttributes(
344-
defaultAllowedAttributes,
345-
opts.allowedAttributes ?? {},
346-
),
347-
allowedClasses: { ...defaultAllowedClasses, ...opts.allowedClasses },
348-
allowProtocolRelative: false,
349-
parser: {
350-
lowerCaseAttributeNames: false,
351-
},
352-
});
387+
return (
388+
additionalCode +
389+
sanitizeHtml(html, {
390+
transformTags: {
391+
img: transformMedia,
392+
video: transformMedia,
393+
},
394+
allowedTags: [...defaultAllowedTags, ...(opts.allowedTags ?? [])],
395+
allowedAttributes: mergeAttributes(
396+
defaultAllowedAttributes,
397+
opts.allowedAttributes ?? {},
398+
),
399+
allowedClasses: { ...defaultAllowedClasses, ...opts.allowedClasses },
400+
allowProtocolRelative: false,
401+
parser: {
402+
lowerCaseAttributeNames: false,
403+
},
404+
})
405+
);
353406
}
354407

355408
function mergeAttributes(

test/test.ts

+52-36
Original file line numberDiff line numberDiff line change
@@ -57,16 +57,14 @@ Deno.test(
5757

5858
Deno.test("bug #61 generate a tag", () => {
5959
const markdown = "[link](https://example.com)";
60-
const expected =
61-
`<p><a href="https://example.com" rel="noopener noreferrer">link</a></p>\n`;
60+
const expected = `<p><a href="https://example.com" rel="noopener noreferrer">link</a></p>\n`;
6261
const html = render(markdown);
6362
assertEquals(html, expected);
6463
});
6564

6665
Deno.test("bug #61 generate a tag with disableHtmlSanitization", () => {
6766
const markdown = "[link](https://example.com)";
68-
const expected =
69-
`<p><a href="https://example.com" rel="noopener noreferrer">link</a></p>\n`;
67+
const expected = `<p><a href="https://example.com" rel="noopener noreferrer">link</a></p>\n`;
7068
const html = render(markdown, { disableHtmlSanitization: true });
7169
assertEquals(html, expected);
7270
});
@@ -114,8 +112,7 @@ Deno.test("alerts rendering", async () => {
114112
Deno.test("Iframe rendering", () => {
115113
const markdown =
116114
'Here is an iframe:\n\n<iframe src="https://example.com" width="300" height="200"></iframe>';
117-
const expected =
118-
`<p>Here is an iframe:</p>\n<iframe src="https://example.com" width="300" height="200"></iframe>`;
115+
const expected = `<p>Here is an iframe:</p>\n<iframe src="https://example.com" width="300" height="200"></iframe>`;
119116

120117
const html = render(markdown, { allowIframes: true });
121118
assertEquals(html, expected);
@@ -133,17 +130,15 @@ Deno.test("Iframe rendering disabled", () => {
133130
Deno.test("Media URL transformation", () => {
134131
const markdown = "![Image](image.jpg)\n\n![Video](video.mp4)";
135132
const mediaBaseUrl = "https://cdn.example.com/";
136-
const expected =
137-
`<p><img src="https://cdn.example.com/image.jpg" alt="Image" /></p>\n<p><img src="https://cdn.example.com/video.mp4" alt="Video" /></p>\n`;
133+
const expected = `<p><img src="https://cdn.example.com/image.jpg" alt="Image" /></p>\n<p><img src="https://cdn.example.com/video.mp4" alt="Video" /></p>\n`;
138134

139135
const html = render(markdown, { mediaBaseUrl: mediaBaseUrl });
140136
assertEquals(html, expected);
141137
});
142138

143139
Deno.test("Media URL transformation without base URL", () => {
144140
const markdown = "![Image](image.jpg)\n\n![Video](video.mp4)";
145-
const expectedWithoutTransformation =
146-
`<p><img src="image.jpg" alt="Image" /></p>\n<p><img src="video.mp4" alt="Video" /></p>\n`;
141+
const expectedWithoutTransformation = `<p><img src="image.jpg" alt="Image" /></p>\n<p><img src="video.mp4" alt="Video" /></p>\n`;
147142

148143
const html = render(markdown);
149144
assertEquals(html, expectedWithoutTransformation);
@@ -168,17 +163,15 @@ Deno.test("Media URL transformation with invalid URL", () => {
168163

169164
Deno.test("Inline rendering", () => {
170165
const markdown = "My [Deno](https://deno.land) Blog";
171-
const expected =
172-
`My <a href="https://deno.land" rel="noopener noreferrer">Deno</a> Blog`;
166+
const expected = `My <a href="https://deno.land" rel="noopener noreferrer">Deno</a> Blog`;
173167

174168
const html = render(markdown, { inline: true });
175169
assertEquals(html, expected);
176170
});
177171

178172
Deno.test("Inline rendering false", () => {
179173
const markdown = "My [Deno](https://deno.land) Blog";
180-
const expected =
181-
`<p>My <a href="https://deno.land" rel="noopener noreferrer">Deno</a> Blog</p>\n`;
174+
const expected = `<p>My <a href="https://deno.land" rel="noopener noreferrer">Deno</a> Blog</p>\n`;
182175

183176
const html = render(markdown, { inline: false });
184177
assertEquals(html, expected);
@@ -187,17 +180,15 @@ Deno.test("Inline rendering false", () => {
187180
Deno.test("Link URL resolution with base URL", () => {
188181
const markdown = "[Test Link](/path/to/resource)";
189182
const baseUrl = "https://example.com/";
190-
const expected =
191-
`<p><a href="https://example.com/path/to/resource" rel="noopener noreferrer">Test Link</a></p>\n`;
183+
const expected = `<p><a href="https://example.com/path/to/resource" rel="noopener noreferrer">Test Link</a></p>\n`;
192184

193185
const html = render(markdown, { baseUrl: baseUrl });
194186
assertEquals(html, expected);
195187
});
196188

197189
Deno.test("Link URL resolution without base URL", () => {
198190
const markdown = "[Test Link](/path/to/resource)";
199-
const expected =
200-
`<p><a href="/path/to/resource" rel="noopener noreferrer">Test Link</a></p>\n`;
191+
const expected = `<p><a href="/path/to/resource" rel="noopener noreferrer">Test Link</a></p>\n`;
201192

202193
const html = render(markdown);
203194
assertEquals(html, expected);
@@ -206,8 +197,7 @@ Deno.test("Link URL resolution without base URL", () => {
206197
Deno.test("Link URL resolution with invalid URL and base URL", () => {
207198
const markdown = "[Test Link](/path/to/resource)";
208199
const baseUrl = "this is an invalid url";
209-
const expected =
210-
`<p><a href="/path/to/resource" rel="noopener noreferrer">Test Link</a></p>\n`;
200+
const expected = `<p><a href="/path/to/resource" rel="noopener noreferrer">Test Link</a></p>\n`;
211201

212202
const html = render(markdown, { baseUrl: baseUrl });
213203
assertEquals(html, expected);
@@ -252,26 +242,57 @@ Deno.test("image title and no alt", () => {
252242

253243
Deno.test("js language", () => {
254244
const markdown = "```js\nconst foo = 'bar';\n```";
255-
const expected =
256-
`<div class="highlight highlight-source-js notranslate"><pre><span class="token keyword">const</span> foo <span class="token operator">=</span> <span class="token string">'bar'</span><span class="token punctuation">;</span></pre></div>`;
245+
const expected = `<div class="highlight highlight-source-js notranslate"><pre><span class="token keyword">const</span> foo <span class="token operator">=</span> <span class="token string">'bar'</span><span class="token punctuation">;</span></pre></div>`;
257246

258247
const html = render(markdown);
259248
assertEquals(html, expected);
260249
});
261250

262251
Deno.test("code fence with a title", () => {
263252
const markdown = "```js title=\"index.ts\"\nconst foo = 'bar';\n```";
264-
const expected =
265-
`<div class="highlight highlight-source-js notranslate"><div class="markdown-code-title">index.ts</div><pre><span class="token keyword">const</span> foo <span class="token operator">=</span> <span class="token string">'bar'</span><span class="token punctuation">;</span></pre></div>`;
253+
const expected = `<div class="highlight highlight-source-js notranslate"><div class="markdown-code-title">index.ts</div><pre><span class="token keyword">const</span> foo <span class="token operator">=</span> <span class="token string">'bar'</span><span class="token punctuation">;</span></pre></div>`;
266254

267255
const html = render(markdown);
268256
assertEquals(html, expected);
269257
});
270258

259+
Deno.test("code containing mermaid", () => {
260+
// test with two code blocks to see if the script and styles are not replicated
261+
const markdown =
262+
"```mermaid\ngraph TD;A-->B;A-->C;B-->D;C-->D;\n```\n\n```mermaid\ngraph TD;A-->B;A-->C;B-->D;C-->D;\n```";
263+
const expected = `<script type="module">
264+
import mermaid from "https://cdn.jsdelivr.net/npm/mermaid/dist/mermaid.esm.min.mjs";
265+
mermaid.initialize({ startOnLoad: false, theme: "neutral" });
266+
const elements = document.querySelectorAll(".mermaid-container");
267+
elements.forEach((element) => {
268+
const code = element.querySelector(".mermaid-code")?.textContent || "";
269+
if (code) {
270+
element.innerHTML = \`<div class="mermaid">\${code}</div>\`;
271+
}
272+
});
273+
await mermaid.run();
274+
</script>
275+
<style>
276+
.mermaid-code {
277+
display: none;
278+
}
279+
</style>
280+
<div class="mermaid-container"><pre><code>graph TD;A--&gt;B;A--&gt;C;B--&gt;D;C--&gt;D;</code></pre><div class="mermaid-code">graph TD;A--&gt;B;A--&gt;C;B--&gt;D;C--&gt;D;</div></div>
281+
<div class="mermaid-container"><pre><code>graph TD;A--&gt;B;A--&gt;C;B--&gt;D;C--&gt;D;</code></pre><div class="mermaid-code">graph TD;A--&gt;B;A--&gt;C;B--&gt;D;C--&gt;D;</div></div>`;
282+
283+
const html = render(markdown);
284+
assertEquals(
285+
html,
286+
expected
287+
.split("\n")
288+
.map((line) => line.trim())
289+
.join(""),
290+
);
291+
});
292+
271293
Deno.test("link with title", () => {
272294
const markdown = `[link](https://example.com "asdf")`;
273-
const expected =
274-
`<p><a href="https://example.com" title="asdf" rel="noopener noreferrer">link</a></p>\n`;
295+
const expected = `<p><a href="https://example.com" title="asdf" rel="noopener noreferrer">link</a></p>\n`;
275296
const html = render(markdown);
276297
assertEquals(html, expected);
277298
});
@@ -284,8 +305,7 @@ Deno.test("expect console warning from invalid math", () => {
284305
};
285306

286307
const html = render("$$ +& $$", { allowMath: true });
287-
const expected =
288-
`<p>$$ +&amp; <span class="katex"><span class="katex-mathml"><math xmlns="http://www.w3.org/1998/Math/MathML"><semantics><mrow></mrow><annotation encoding="application/x-tex"></annotation></semantics></math></span><span class="katex-html" aria-hidden="true"></span></span></p>\n`;
308+
const expected = `<p>$$ +&amp; <span class="katex"><span class="katex-mathml"><math xmlns="http://www.w3.org/1998/Math/MathML"><semantics><mrow></mrow><annotation encoding="application/x-tex"></annotation></semantics></math></span><span class="katex-html" aria-hidden="true"></span></span></p>\n`;
289309
assertEquals(html, expected);
290310
assertStringIncludes(
291311
warnCalls[0],
@@ -306,8 +326,7 @@ Deno.test("expect console warning from invalid math", () => {
306326
Deno.test("render github-slugger not reused", function () {
307327
for (let i = 0; i < 2; i++) {
308328
const html = render("## Hello");
309-
const expected =
310-
`<h2 id="hello"><a class="anchor" aria-hidden="true" tabindex="-1" href="#hello"><svg class="octicon octicon-link" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path fill-rule="evenodd" d="M7.775 3.275a.75.75 0 001.06 1.06l1.25-1.25a2 2 0 112.83 2.83l-2.5 2.5a2 2 0 01-2.83 0 .75.75 0 00-1.06 1.06 3.5 3.5 0 004.95 0l2.5-2.5a3.5 3.5 0 00-4.95-4.95l-1.25 1.25zm-4.69 9.64a2 2 0 010-2.83l2.5-2.5a2 2 0 012.83 0 .75.75 0 001.06-1.06 3.5 3.5 0 00-4.95 0l-2.5 2.5a3.5 3.5 0 004.95 4.95l1.25-1.25a.75.75 0 00-1.06-1.06l-1.25 1.25a2 2 0 01-2.83 0z"></path></svg></a>Hello</h2>\n`;
329+
const expected = `<h2 id="hello"><a class="anchor" aria-hidden="true" tabindex="-1" href="#hello"><svg class="octicon octicon-link" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path fill-rule="evenodd" d="M7.775 3.275a.75.75 0 001.06 1.06l1.25-1.25a2 2 0 112.83 2.83l-2.5 2.5a2 2 0 01-2.83 0 .75.75 0 00-1.06 1.06 3.5 3.5 0 004.95 0l2.5-2.5a3.5 3.5 0 00-4.95-4.95l-1.25 1.25zm-4.69 9.64a2 2 0 010-2.83l2.5-2.5a2 2 0 012.83 0 .75.75 0 001.06-1.06 3.5 3.5 0 00-4.95 0l-2.5 2.5a3.5 3.5 0 004.95 4.95l1.25-1.25a.75.75 0 00-1.06-1.06l-1.25 1.25a2 2 0 01-2.83 0z"></path></svg></a>Hello</h2>\n`;
311330
assertEquals(html, expected);
312331
}
313332
});
@@ -379,8 +398,7 @@ Deno.test("del tag test", () => {
379398

380399
Deno.test("h1 test", () => {
381400
const markdown = "# Hello";
382-
const result =
383-
`<h1 id="hello"><a class="anchor" aria-hidden="true" tabindex="-1" href="#hello"><svg class="octicon octicon-link" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path fill-rule="evenodd" d="M7.775 3.275a.75.75 0 001.06 1.06l1.25-1.25a2 2 0 112.83 2.83l-2.5 2.5a2 2 0 01-2.83 0 .75.75 0 00-1.06 1.06 3.5 3.5 0 004.95 0l2.5-2.5a3.5 3.5 0 00-4.95-4.95l-1.25 1.25zm-4.69 9.64a2 2 0 010-2.83l2.5-2.5a2 2 0 012.83 0 .75.75 0 001.06-1.06 3.5 3.5 0 00-4.95 0l-2.5 2.5a3.5 3.5 0 004.95 4.95l1.25-1.25a.75.75 0 00-1.06-1.06l-1.25 1.25a2 2 0 01-2.83 0z"></path></svg></a>Hello</h1>\n`;
401+
const result = `<h1 id="hello"><a class="anchor" aria-hidden="true" tabindex="-1" href="#hello"><svg class="octicon octicon-link" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path fill-rule="evenodd" d="M7.775 3.275a.75.75 0 001.06 1.06l1.25-1.25a2 2 0 112.83 2.83l-2.5 2.5a2 2 0 01-2.83 0 .75.75 0 00-1.06 1.06 3.5 3.5 0 004.95 0l2.5-2.5a3.5 3.5 0 00-4.95-4.95l-1.25 1.25zm-4.69 9.64a2 2 0 010-2.83l2.5-2.5a2 2 0 012.83 0 .75.75 0 001.06-1.06 3.5 3.5 0 00-4.95 0l-2.5 2.5a3.5 3.5 0 004.95 4.95l1.25-1.25a.75.75 0 00-1.06-1.06l-1.25 1.25a2 2 0 01-2.83 0z"></path></svg></a>Hello</h1>\n`;
384402

385403
const html = render(markdown);
386404
assertEquals(html, result);
@@ -409,10 +427,8 @@ Deno.test("task list", () => {
409427
});
410428

411429
Deno.test("anchor test raw", () => {
412-
const markdown =
413-
`<a class="anchor" aria-hidden="true" tabindex="-1" href="#hello">foo</a>`;
414-
const result =
415-
`<p><a class="anchor" aria-hidden="true" tabindex="-1" href="#hello">foo</a></p>\n`;
430+
const markdown = `<a class="anchor" aria-hidden="true" tabindex="-1" href="#hello">foo</a>`;
431+
const result = `<p><a class="anchor" aria-hidden="true" tabindex="-1" href="#hello">foo</a></p>\n`;
416432

417433
const html = render(markdown);
418434
assertEquals(html, result);

0 commit comments

Comments
 (0)