Skip to content

Commit 7b1863b

Browse files
heiskrCopilot
andauthored
Render Mermaid diagrams as inline SVG (#64273)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4def93cf-9edd-4404-9e39-96cc21baafad
1 parent 4a4d5b0 commit 7b1863b

10 files changed

Lines changed: 200 additions & 0 deletions

File tree

‎data/code-languages.yml‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,9 @@ jsonc:
4646
markdown:
4747
name: Markdown
4848
comment: xml
49+
mermaid:
50+
name: Mermaid
51+
comment: percent
4952
perl:
5053
name: Perl
5154
comment: number

‎package-lock.json‎

Lines changed: 10 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎package.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -224,6 +224,7 @@
224224
"mdast-util-to-string": "^4.0.0",
225225
"next": "^16.3.8",
226226
"parse5": "8.0.1",
227+
"pele": "1.3.0",
227228
"quick-lru": "7.0.1",
228229
"react": "^19.2.5",
229230
"react-dom": "^19.2.5",

‎src/content-render/stylesheets/index.scss‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,5 +4,6 @@
44
@import "article-section-framing.scss";
55
@import "syntax-highlighting.scss";
66
@import "alerts.scss";
7+
@import "mermaid.scss";
78
@import "octicon-table-optimization.scss";
89
@import "accessibility.scss";
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
// Maps Pele diagram colors and font to Brand and Primer tokens, so diagrams match the page.
2+
.mermaid-diagram {
3+
margin-bottom: 1rem;
4+
overflow-x: auto;
5+
6+
.pele {
7+
--pele-bg: var(--brand-color-canvas-default);
8+
--pele-fg: var(--brand-color-text-default);
9+
--pele-muted: var(--brand-color-text-muted);
10+
--pele-line: var(--brand-color-text-default);
11+
--pele-surface: var(--brand-color-canvas-subtle);
12+
--pele-surface-alt: var(--bgColor-neutral-muted);
13+
--pele-border: var(--brand-color-border-default);
14+
--pele-accent: var(--fgColor-accent);
15+
--pele-critical: var(--fgColor-danger);
16+
--pele-series-1: var(--data-blue-color-emphasis);
17+
--pele-series-2: var(--data-orange-color-emphasis);
18+
--pele-series-3: var(--data-green-color-emphasis);
19+
--pele-series-4: var(--data-red-color-emphasis);
20+
--pele-series-5: var(--data-teal-color-emphasis);
21+
--pele-series-6: var(--data-yellow-color-emphasis);
22+
--pele-series-7: var(--data-purple-color-emphasis);
23+
--pele-series-8: var(--data-brown-color-emphasis);
24+
// Pele sizes nodes from Helvetica-like metrics on the server. Mona Sans fits inside that size.
25+
--pele-font: var(--brand-body-fontFamily);
26+
}
27+
}
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import { describe, expect, test } from 'vitest'
2+
import { load } from 'cheerio'
3+
4+
import { renderContent } from '@/content-render/index'
5+
6+
const flowchart = `
7+
\`\`\`mermaid
8+
flowchart LR
9+
accTitle: Publishing flow
10+
accDescr: A draft goes to review, then gets published
11+
A[Draft] --> B[Review] --> C[Published]
12+
\`\`\`
13+
`
14+
15+
describe('mermaid', () => {
16+
test('renders a mermaid fence to inline SVG', async () => {
17+
const $ = load(await renderContent(flowchart))
18+
const svg = $('.mermaid-diagram > svg.pele')
19+
expect(svg.length).toBe(1)
20+
expect(svg.attr('viewBox')).toMatch(/^0 0 \d+/)
21+
expect(svg.find('title').text()).toBe('Publishing flow')
22+
expect($('pre').length).toBe(0)
23+
})
24+
25+
test('flips wide flowcharts to vertical so they fit the article column', async () => {
26+
const wide = Array.from({ length: 12 }, (_, i) => ` N${i}[Step number ${i}]`).join(' -->\n')
27+
const $ = load(await renderContent(`\`\`\`mermaid\nflowchart LR\n${wide}\n\`\`\``))
28+
const [, , width] = ($('svg.pele').attr('viewBox') || '').split(' ').map(Number)
29+
expect(width).toBeLessThanOrEqual(720)
30+
})
31+
32+
test('keeps the source direction for flowcharts that fit', async () => {
33+
const $ = load(await renderContent(flowchart))
34+
const [, , width, height] = ($('svg.pele').attr('viewBox') || '').split(' ').map(Number)
35+
expect(width).toBeGreaterThan(height)
36+
})
37+
38+
test('gives each diagram distinct accessibility IDs', async () => {
39+
const $ = load(await renderContent(`${flowchart}\n${flowchart}`))
40+
const ids = $('svg.pele title')
41+
.map((_, el) => $(el).attr('id'))
42+
.get()
43+
expect(ids.length).toBe(2)
44+
expect(new Set(ids).size).toBe(2)
45+
})
46+
47+
test('keeps mermaid source nested in a text fence as code', async () => {
48+
const $ = load(await renderContent(`\`\`\`\`text\n${flowchart}\n\`\`\`\``))
49+
expect($('svg.pele').length).toBe(0)
50+
expect($('pre').text()).toContain('flowchart LR')
51+
})
52+
53+
test('keeps invalid diagrams as code blocks', async () => {
54+
const $ = load(await renderContent('```mermaid\nflowchart LR\n A -->\n```'))
55+
expect($('svg.pele').length).toBe(0)
56+
expect($('pre code.language-mermaid').text()).toContain('A -->')
57+
})
58+
59+
test('renders hexagon nodes escaped from Liquid', async () => {
60+
const source = 'flowchart LR\n A{% raw %}{{Decide}}{% endraw %} --> B[Ship]'
61+
const $ = load(await renderContent(`\`\`\`mermaid\n${source}\n\`\`\``))
62+
expect($('svg.pele').length).toBe(1)
63+
expect($('svg.pele').text()).toContain('Decide')
64+
})
65+
66+
test('does not render images inside diagrams', async () => {
67+
const source = 'flowchart LR\n A@{ img: "https://example.com/logo.png", label: "Logo" }'
68+
const $ = load(await renderContent(`\`\`\`mermaid\n${source}\n\`\`\``))
69+
expect($('svg.pele').length).toBe(1)
70+
expect($('image').length).toBe(0)
71+
})
72+
73+
test('does not render links inside diagrams', async () => {
74+
const source = 'flowchart LR\n A[Docs]\n click A "https://example.com"'
75+
const $ = load(await renderContent(`\`\`\`mermaid\n${source}\n\`\`\``))
76+
expect($('svg.pele').length).toBe(1)
77+
expect($('svg.pele a').length).toBe(0)
78+
})
79+
})

‎src/content-render/tests/render-to-hast.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,10 @@ const fixtures: Array<{ name: string; template: string }> = [
4444
name: 'external image',
4545
template: '![A logo](https://example.com/logo.png)',
4646
},
47+
{
48+
name: 'mermaid diagram',
49+
template: '```mermaid\nflowchart LR\n A[Draft] --> B[Published]\n```',
50+
},
4751
]
4852

4953
describe('renderUnifiedToHast', () => {

‎src/content-render/unified/README.md‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,3 +21,18 @@ Authoring rules:
2121
- For HTML fences, add a line such as `<!-- -->` after the annotations to keep syntax highlighting.
2222

2323
`parse-info-string.ts` must run before `remark-rehype`, and `annotate` must run before `highlight`.
24+
25+
## Mermaid diagrams
26+
27+
The `mermaid` plugin renders fenced code blocks with the `mermaid` language to inline SVG with [Pele](https://pele.run). Pages need no client JavaScript, and diagrams follow the page color mode through the variables in `stylesheets/mermaid.scss`.
28+
29+
Authoring rules:
30+
31+
- Use a `mermaid` fence. To show Mermaid source without rendering it, nest the fence in a `text` fence.
32+
- Add `accTitle:` and `accDescr:` lines, so screen readers get a title and description.
33+
- Liquid runs first, so `{% data %}` and `{% ifversion %}` work inside diagrams. Escape hexagon nodes such as `A{{label}}` with `{% raw %}`, or Liquid treats them as output tags.
34+
- Pele lays out diagrams to fit the 720px article column. Flowcharts too wide for the column switch to a vertical layout.
35+
- Links and images inside diagrams do not render.
36+
- Invalid diagrams render as code blocks, and the server logs a warning.
37+
38+
`mermaid` must run before `codeHeader` and `highlight`.
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
// Renders ```mermaid fences to inline SVG with Pele, so diagrams need no client JavaScript.
2+
// Fences nested in a ````text fence keep their language as text and stay as source.
3+
4+
import { visit, SKIP } from 'unist-util-visit'
5+
import { h } from 'hastscript'
6+
import { fromHtml } from 'hast-util-from-html'
7+
import { toString } from 'hast-util-to-string'
8+
import murmur from 'imurmurhash'
9+
import { PeleError, render } from 'pele'
10+
import type { Element, Root } from 'hast'
11+
import { createLogger } from '@/observability/logger'
12+
13+
const logger = createLogger(import.meta.url)
14+
15+
// Matches the max-width of the article column in ArticleGridLayout and ArticleInlineLayout.
16+
const ARTICLE_WIDTH = 720
17+
18+
export default function mermaid() {
19+
return (tree: Root) => {
20+
let count = 0
21+
visit(tree, 'element', (node, index, parent) => {
22+
if (index === undefined || !parent || !isMermaidBlock(node)) return
23+
const source = toString(node)
24+
// Diagrams on one page need distinct title and description IDs.
25+
const idPrefix = `mermaid-${murmur(source).result().toString(36)}-${count++}`
26+
try {
27+
const { svg } = render(source, {
28+
maxWidth: ARTICLE_WIDTH,
29+
// Pele flips layouts to vertical only below the breakpoint and only when they do not fit.
30+
directionBreakpoint: ARTICLE_WIDTH + 1,
31+
idPrefix,
32+
// Diagram links and images skip docs link rewriting and checks.
33+
images: false,
34+
links: false,
35+
})
36+
const diagram = fromHtml(svg, { fragment: true }).children as Element[]
37+
parent.children[index] = h('div', { className: 'mermaid-diagram' }, diagram)
38+
return SKIP
39+
} catch (error) {
40+
if (!(error instanceof PeleError)) throw error
41+
// Leave the source as a code block, so one bad diagram does not break the page.
42+
logger.warn('Could not render Mermaid diagram', {
43+
code: error.code,
44+
line: error.line,
45+
column: error.column,
46+
})
47+
}
48+
})
49+
}
50+
}
51+
52+
function isMermaidBlock(node: Element): boolean {
53+
if (node.tagName !== 'pre') return false
54+
const code = node.children[0]
55+
if (code?.type !== 'element' || code.tagName !== 'code') return false
56+
const classNames = code.properties.className
57+
return Array.isArray(classNames) && classNames.includes('language-mermaid')
58+
}

‎src/content-render/unified/processor.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import powershell from 'highlight.js/lib/languages/powershell'
1515
import graphql from 'highlight.js/lib/languages/graphql'
1616
import html from 'rehype-stringify'
1717
import codeHeader from './code-header'
18+
import mermaid from './mermaid'
1819
import rewriteLocalLinks from './rewrite-local-links'
1920
import rewriteImgSources from './rewrite-asset-urls'
2021
import rewriteAssetImgTags from './rewrite-asset-img-tags'
@@ -52,6 +53,7 @@ export function createProcessor(context: Context): UnifiedProcessor {
5253
.use(slug)
5354
.use(useEnglishHeadings as unknown as (ctx: Context) => void, context || {})
5455
.use(headingLinks)
56+
.use(mermaid)
5557
.use(codeHeader)
5658
.use(annotate as unknown as (ctx: Context) => void, context)
5759
// TypeScript's unified plugin types do not model rehype-highlight's lowlight options.

0 commit comments

Comments
 (0)