diff --git a/frontend/src/components/expert/components/messages/components/AnswerWrapper.vue b/frontend/src/components/expert/components/messages/components/AnswerWrapper.vue
index cb04c7f8bd..499cb2078b 100644
--- a/frontend/src/components/expert/components/messages/components/AnswerWrapper.vue
+++ b/frontend/src/components/expert/components/messages/components/AnswerWrapper.vue
@@ -5,6 +5,7 @@
@@ -25,6 +26,11 @@ export default {
required: true,
type: String
},
+ diagrams: {
+ type: Array,
+ required: false,
+ default: () => []
+ },
messageUuid: {
required: true,
type: String
diff --git a/frontend/src/components/expert/components/messages/components/resources/StreamableContent.vue b/frontend/src/components/expert/components/messages/components/resources/StreamableContent.vue
index 039f31c20c..3745eb00a6 100644
--- a/frontend/src/components/expert/components/messages/components/resources/StreamableContent.vue
+++ b/frontend/src/components/expert/components/messages/components/resources/StreamableContent.vue
@@ -12,11 +12,21 @@
import { mapState } from 'pinia'
import { CODE_BLOCK_ICONS, useMarkdownHelper } from '@/composables/strings/Markdown.js'
+import { THEME_VARIABLES, sanitizeDiagram, serializeSvg, svgToPngBlob } from '@/composables/strings/Mermaid.js'
import useStreamingWords from '@/composables/strings/StreamingWords.js'
import { sanitize } from '@/composables/strings/String.js'
import { useProductAssistantStore } from '@/stores/product-assistant.js'
+// Sources are compared ignoring whitespace, so a diagram still matches its block if the spacing differs
+function normalizeSource (source) {
+ return source.replace(/\s+/g, ' ').trim()
+}
+
+function diagramButton (label, className, attributes) {
+ return ``
+}
+
export default {
name: 'StreamableContent',
props: {
@@ -64,12 +74,19 @@ export default {
type: Boolean,
required: false,
default: false
+ },
+ // SVGs rendered by the Expert, matched to mermaid blocks by their source
+ diagrams: {
+ type: Array,
+ required: false,
+ default: () => []
}
},
emits: ['streaming-complete', 'update:modelValue'],
setup () {
const { stream, text, isStreaming } = useStreamingWords({
- delayMs: 30
+ delayMs: 10,
+ wordsPerTick: 4
})
const { markedInstance } = useMarkdownHelper()
@@ -86,6 +103,13 @@ export default {
return rawText
},
+ // Streaming re-renders the content on every tick, so each diagram is sanitized once up front
+ safeDiagrams () {
+ return this.diagrams.map(diagram => ({
+ source: normalizeSource(diagram.source || ''),
+ svg: sanitizeDiagram(diagram.svg || '')
+ }))
+ },
text () {
const text = this.shouldStream ? this.streamedText : this.rawText
@@ -104,6 +128,7 @@ export default {
isStreaming (isStreaming) {
if (!isStreaming) {
this.$emit('streaming-complete')
+ this.renderDiagrams()
if (this.modelValue) {
this.$emit('update:modelValue', {
...this.modelValue,
@@ -117,9 +142,124 @@ export default {
if (this.shouldStream) {
this.stream(this.rawText)
}
+ this.renderDiagrams()
+ },
+ updated () {
+ this.renderDiagrams()
},
methods: {
+ // Mermaid blocks stay as plain code until their closing fence has streamed in, then
+ // are swapped for the SVG the Expert rendered, so earlier diagrams show while later text streams.
+ renderDiagrams () {
+ if (!this.richContent) return
+ const blocks = [...this.$el.querySelectorAll('.ff-code-block--mermaid')]
+ const ready = this.isStreaming ? this.completedMermaidBlocks() : blocks.length
+ for (const block of blocks.slice(0, ready)) {
+ if (block.hasAttribute('data-mermaid-state')) continue
+ const source = block.querySelector('pre code')?.textContent || ''
+ const svg = this.findDiagramSvg(source)
+ if (svg) {
+ this.showDiagram(block, svg)
+ } else {
+ block.setAttribute('data-mermaid-state', 'failed')
+ }
+ }
+ },
+ findDiagramSvg (source) {
+ const key = normalizeSource(source)
+ return this.safeDiagrams.find(diagram => diagram.source === key)?.svg
+ },
+ // The page theme, so exports look like the diagram on screen
+ diagramTheme (block) {
+ const style = getComputedStyle(block.querySelector('.ff-code-block--diagram'))
+ const theme = {}
+ for (const name of THEME_VARIABLES) {
+ const value = style.getPropertyValue(name).trim()
+ if (value) {
+ theme[name] = value
+ }
+ }
+ return theme
+ },
+ // The streamed text is cut mid-block, so count the mermaid blocks whose end has arrived.
+ completedMermaidBlocks () {
+ const parts = this.streamedText.split('ff-code-block--mermaid').slice(1)
+ return parts.filter(part => part.includes('')).length
+ },
+ showDiagram (block, svg) {
+ const diagram = document.createElement('div')
+ diagram.className = 'ff-code-block--diagram'
+ diagram.innerHTML = svg
+ block.appendChild(diagram)
+ block.querySelector('pre').hidden = true
+ const actions = document.createElement('span')
+ actions.className = 'ff-code-block--diagram-actions'
+ actions.innerHTML = [
+ diagramButton('Code', 'ff-code-block--toggle', 'aria-pressed="false"'),
+ diagramButton('Copy SVG', 'ff-code-block--export', 'data-export="svg"'),
+ diagramButton('Copy image', 'ff-code-block--export', 'data-export="image"'),
+ diagramButton('Download PNG', 'ff-code-block--export', 'data-export="png"')
+ ].join('')
+ block.querySelector('.ff-code-block--copy').after(actions)
+ block.setAttribute('data-mermaid-state', 'rendered')
+ },
+ async exportDiagram (btn) {
+ const block = btn.closest('.ff-code-block')
+ const source = block.querySelector('pre code')?.textContent || ''
+ const svg = this.findDiagramSvg(source)
+ if (!svg) return
+ const theme = this.diagramTheme(block)
+ const label = btn.textContent
+ const flash = text => {
+ btn.textContent = text
+ setTimeout(() => { btn.textContent = label }, 2000)
+ }
+ try {
+ switch (btn.dataset.export) {
+ case 'svg':
+ await navigator.clipboard.writeText(serializeSvg(svg, theme))
+ flash('Copied')
+ break
+ case 'image': {
+ const blob = svgToPngBlob(svg, { theme })
+ await navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })])
+ flash('Copied')
+ break
+ }
+ case 'png': {
+ const url = URL.createObjectURL(await svgToPngBlob(svg, { theme }))
+ const a = document.createElement('a')
+ a.href = url
+ a.download = 'diagram.png'
+ a.click()
+ // Revoking straight after the click can cancel the download in Safari and Firefox
+ setTimeout(() => URL.revokeObjectURL(url), 40 * 1000)
+ break
+ }
+ }
+ } catch (_) {
+ flash('Failed')
+ }
+ },
handleClick (event) {
+ const toggleBtn = event.target.closest('.ff-code-block--toggle')
+ if (toggleBtn) {
+ const block = toggleBtn.closest('.ff-code-block')
+ const showCode = toggleBtn.getAttribute('aria-pressed') !== 'true'
+ block.querySelector('pre').hidden = !showCode
+ block.querySelector('.ff-code-block--diagram').hidden = showCode
+ block.querySelectorAll('.ff-code-block--export').forEach(btn => { btn.hidden = showCode })
+ toggleBtn.setAttribute('aria-pressed', String(showCode))
+ toggleBtn.classList.toggle('active', showCode)
+ toggleBtn.blur()
+ return
+ }
+ const exportBtn = event.target.closest('.ff-code-block--export')
+ if (exportBtn) {
+ this.exportDiagram(exportBtn)
+ exportBtn.blur()
+ return
+ }
const copyBtn = event.target.closest('.ff-code-block--copy') || event.target.closest('.ff-table-block--copy')
if (!copyBtn) return
@@ -265,6 +405,40 @@ export default {
}
}
+ // The diagram takes its colours from these variables, so it follows the theme without being re-rendered
+ :deep(.ff-code-block--diagram) {
+ --pele-bg: var(--ff-color-bg-surface);
+ --pele-fg: var(--ff-color-text);
+ --pele-muted: var(--ff-color-text-subtle);
+ --pele-line: var(--ff-color-text-subtle);
+ --pele-surface: var(--ff-color-bg-surface-raised);
+ --pele-surface-alt: var(--ff-color-bg-surface-raised);
+ --pele-border: var(--ff-color-accent);
+ --pele-accent: var(--ff-color-accent);
+
+ padding: 1rem;
+ overflow-x: auto;
+ background: var(--ff-color-bg-surface);
+
+ svg {
+ display: block;
+ max-width: 100%;
+ height: auto;
+ margin: 0 auto;
+ }
+ }
+
+ :deep(.ff-code-block--diagram-actions) {
+ display: inline-flex;
+ gap: 0.5rem;
+ margin: 0 auto 0 0.5rem;
+
+ // .ff-btn sets display, which would override the hidden attribute
+ [hidden] {
+ display: none;
+ }
+ }
+
:deep(.ff-code-block--header) {
display: flex;
align-items: center;
diff --git a/frontend/src/composables/strings/Markdown.js b/frontend/src/composables/strings/Markdown.js
index d467dfb6a0..1badfefd6a 100644
--- a/frontend/src/composables/strings/Markdown.js
+++ b/frontend/src/composables/strings/Markdown.js
@@ -19,6 +19,48 @@ hljs.registerLanguage('typescript', typescript)
hljs.registerLanguage('ts', typescript)
hljs.registerLanguage('xml', xml)
hljs.registerLanguage('html', xml)
+// highlight.js ships no mermaid grammar
+hljs.registerLanguage('mermaid', () => ({
+ name: 'Mermaid',
+ keywords: {
+ keyword: [
+ // diagram types, the ones with a dash are matched by their own mode below
+ 'graph', 'flowchart', 'sequenceDiagram', 'classDiagram', 'stateDiagram', 'erDiagram', 'journey', 'gantt',
+ 'pie', 'quadrantChart', 'requirementDiagram', 'gitGraph', 'mindmap', 'timeline', 'sankey', 'xychart',
+ 'block', 'C4Context', 'C4Container', 'C4Component', 'C4Dynamic', 'C4Deployment', 'packet', 'kanban',
+ 'treemap', 'ishikawa', 'info',
+ // statements
+ 'subgraph', 'end', 'direction', 'participant', 'actor', 'autonumber', 'activate', 'deactivate', 'create',
+ 'destroy', 'box', 'loop', 'alt', 'else', 'opt', 'par', 'critical', 'option', 'break', 'rect', 'note',
+ 'links', 'link', 'class', 'classDef', 'cssClass', 'style', 'linkStyle',
+ 'click', 'call', 'href', 'callback', 'namespace', 'state', 'section', 'title', 'accTitle', 'accDescr',
+ 'dateFormat', 'axisFormat', 'tickInterval', 'excludes', 'includes', 'todayMarker', 'weekday', 'showData',
+ 'commit', 'branch', 'checkout', 'merge', 'requirement', 'functionalRequirement', 'interfaceRequirement', 'performanceRequirement',
+ 'physicalRequirement', 'designConstraint', 'verifymethod', 'satisfies', 'traces', 'copies', 'derives',
+ 'refines', 'verifies', 'columns', 'service', 'junction',
+ 'Person', 'Person_Ext', 'System', 'System_Ext', 'SystemDb', 'SystemQueue', 'Container', 'ContainerDb',
+ 'ContainerQueue', 'Component', 'ComponentDb', 'Boundary', 'System_Boundary', 'Container_Boundary',
+ 'Enterprise_Boundary', 'Deployment_Node', 'Rel', 'BiRel', 'Rel_U', 'Rel_D', 'Rel_L', 'Rel_R',
+ 'Rel_Back', 'UpdateLayoutConfig', 'UpdateRelStyle', 'UpdateElementStyle'
+ ],
+ literal: ['TB', 'TD', 'BT', 'RL', 'LR', 'true', 'false']
+ },
+ contains: [
+ hljs.COMMENT('%%', '$'),
+ // keywords with a dash, which would otherwise read as a name followed by an arrow
+ { className: 'keyword', begin: /\b(?:[A-Za-z]+-(?:beta|v2)|[xy]-axis|cherry-pick)\b/ },
+ hljs.QUOTE_STRING_MODE,
+ hljs.NUMBER_MODE,
+ // entity relationship cardinalities such as ||--o{ and }|..|{, before the |label| mode takes the pipes
+ { className: 'operator', begin: /[|}][|o](?:--|\.\.)[|o][|{]/ },
+ // flowchart, sequence, class and state arrows such as -->, -.->, ==>, --o, <-->, ->>, -x, -), <|--, *--, ..|>
+ { className: 'operator', begin: /(?:<\||[<*ox])?(?:[-=.~]{2,}(?:\|>|[>ox)]{1,2})?|-(?:>>?|x|\)))(?=[\s\w"[({|:]|$)/ },
+ { className: 'meta', begin: /:::\w+|\[\*\]/ },
+ // edge labels and node text
+ { className: 'string', begin: /\|/, end: /\|/ },
+ { className: 'string', begin: /\[/, end: /\]/ }
+ ]
+}))
// Inline SVGs are intentional here — the copy button lives inside v-html rendered
// content where Vue components cannot be used.
@@ -33,6 +75,7 @@ export function useMarkdownHelper () {
renderer: {
code ({ text, lang }) {
const language = lang ? lang.split(/\s/)[0].toLowerCase() : ''
+ const isMermaid = language === 'mermaid'
let highlighted
if (language && hljs.getLanguage(language)) {
highlighted = hljs.highlight(text, { language }).value
@@ -40,7 +83,8 @@ export function useMarkdownHelper () {
highlighted = hljs.highlightAuto(text).value
}
const langLabel = language ? `${language}` : ''
- return `
${highlighted.replace(/\n/g, '
')}
`
+ const blockClass = isMermaid ? 'ff-code-block ff-code-block--mermaid' : 'ff-code-block'
+ return `${highlighted.replace(/\n/g, '
')}
`
},
checkbox ({ checked }) {
return `${checked ? '☑' : '☐'}`
diff --git a/frontend/src/composables/strings/Mermaid.js b/frontend/src/composables/strings/Mermaid.js
new file mode 100644
index 0000000000..b25b1f1910
--- /dev/null
+++ b/frontend/src/composables/strings/Mermaid.js
@@ -0,0 +1,64 @@
+import DOMPurify from 'dompurify'
+
+// The CSS variables the diagram takes its colours from
+export const THEME_VARIABLES = [
+ '--pele-bg',
+ '--pele-fg',
+ '--pele-muted',
+ '--pele-line',
+ '--pele-surface',
+ '--pele-surface-alt',
+ '--pele-border',
+ '--pele-accent'
+]
+
+// The SVG is rendered by the Expert and arrives with the message, so it is untrusted markup.
+// Ids are dropped because nothing in the SVG refers to them and they repeat across the diagrams on a page.
+export function sanitizeDiagram (svg) {
+ return DOMPurify.sanitize(svg, { USE_PROFILES: { svg: true, svgFilters: true }, FORBID_ATTR: ['id'] })
+}
+
+function parseSvg (svg) {
+ const doc = new DOMParser().parseFromString(svg, 'image/svg+xml')
+ return doc.documentElement
+}
+
+// Returns the diagram as standalone SVG markup with explicit pixel dimensions. The theme
+// variables are written onto the SVG, as outside the page it no longer inherits them.
+export function serializeSvg (svg, theme = {}) {
+ const el = parseSvg(svg)
+ const [, , width, height] = (el.getAttribute('viewBox') || '').split(/\s+/).map(Number)
+ if (width && height) {
+ el.setAttribute('width', width)
+ el.setAttribute('height', height)
+ }
+ el.style.maxWidth = ''
+ for (const [name, value] of Object.entries(theme)) {
+ el.style.setProperty(name, value)
+ }
+ el.setAttribute('xmlns', 'http://www.w3.org/2000/svg')
+ return new XMLSerializer().serializeToString(el)
+}
+
+export async function svgToPngBlob (svg, { scale = 2, theme = {} } = {}) {
+ const standalone = serializeSvg(svg, theme)
+ const el = parseSvg(standalone)
+ const width = Number(el.getAttribute('width'))
+ const height = Number(el.getAttribute('height'))
+ const image = new Image()
+ await new Promise((resolve, reject) => {
+ image.onload = resolve
+ image.onerror = reject
+ image.src = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(standalone)
+ })
+ const canvas = document.createElement('canvas')
+ canvas.width = Math.ceil(width * scale)
+ canvas.height = Math.ceil(height * scale)
+ const ctx = canvas.getContext('2d')
+ ctx.fillStyle = theme['--pele-bg'] || '#ffffff'
+ ctx.fillRect(0, 0, canvas.width, canvas.height)
+ ctx.drawImage(image, 0, 0, canvas.width, canvas.height)
+ return new Promise((resolve, reject) => {
+ canvas.toBlob(blob => blob ? resolve(blob) : reject(new Error('PNG export failed')), 'image/png')
+ })
+}
diff --git a/frontend/src/composables/strings/StreamingWords.js b/frontend/src/composables/strings/StreamingWords.js
index 731fbbcda6..05d0aae239 100644
--- a/frontend/src/composables/strings/StreamingWords.js
+++ b/frontend/src/composables/strings/StreamingWords.js
@@ -1,6 +1,6 @@
import { nextTick, onBeforeUnmount, ref } from 'vue'
-export default function useStreamingWords ({ delayMs = 30, splitRegex = /\s+/, joinWith = ' ' } = {}) {
+export default function useStreamingWords ({ delayMs = 30, wordsPerTick = 1 } = {}) {
const text = ref('')
const isStreaming = ref(false)
@@ -20,7 +20,8 @@ export default function useStreamingWords ({ delayMs = 30, splitRegex = /\s+/, j
stop()
text.value = ''
- words = String(message).trim().split(splitRegex).filter(Boolean)
+ // Each word keeps the whitespace before it, so code indentation survives streaming
+ words = String(message).trim().match(/\s*\S+/g) || []
index = 0
isStreaming.value = true
@@ -34,8 +35,8 @@ export default function useStreamingWords ({ delayMs = 30, splitRegex = /\s+/, j
return resolve()
}
- text.value += (text.value ? joinWith : '') + words[index]
- index += 1
+ text.value += words.slice(index, index + wordsPerTick).join('')
+ index += wordsPerTick
timer = setTimeout(tick, delayMs)
}
diff --git a/test/unit/frontend/components/expert/StreamableContent.spec.js b/test/unit/frontend/components/expert/StreamableContent.spec.js
new file mode 100644
index 0000000000..9573ad38ae
--- /dev/null
+++ b/test/unit/frontend/components/expert/StreamableContent.spec.js
@@ -0,0 +1,113 @@
+import { createTestingPinia } from '@pinia/testing'
+import { flushPromises, mount } from '@vue/test-utils'
+import { afterEach, describe, expect, test, vi } from 'vitest'
+
+import * as Mermaid from '../../../../../frontend/src/composables/strings/Mermaid.js'
+
+vi.mock('../../../../../frontend/src/composables/strings/Mermaid.js', async (importOriginal) => {
+ const original = await importOriginal()
+ return {
+ ...original,
+ sanitizeDiagram: vi.fn(original.sanitizeDiagram),
+ svgToPngBlob: vi.fn(async () => new Blob(['png'], { type: 'image/png' }))
+ }
+})
+
+const StreamableContent = (await import('../../../../../frontend/src/components/expert/components/messages/components/resources/StreamableContent.vue')).default
+
+const source = 'flowchart LR\n A[Start] --> B[End]'
+const content = '```mermaid\n' + source + '\n```\n\nDone.'
+const diagrams = [{ source, svg: '' }]
+
+function mountContent ({ shouldStream, diagrams }) {
+ return mount(StreamableContent, {
+ props: { string: content, richContent: true, shouldStream, diagrams },
+ global: { plugins: [createTestingPinia({ createSpy: vi.fn })] }
+ })
+}
+
+describe('StreamableContent mermaid diagrams', () => {
+ afterEach(() => {
+ vi.useRealTimers()
+ vi.clearAllMocks()
+ vi.unstubAllGlobals()
+ })
+
+ test('shows the diagram the Expert rendered', async () => {
+ const wrapper = mountContent({ shouldStream: false, diagrams })
+ await flushPromises()
+ const block = wrapper.element.querySelector('.ff-code-block--mermaid')
+ expect(block.getAttribute('data-mermaid-state')).toBe('rendered')
+ expect(block.querySelector('.ff-code-block--diagram svg')).not.toBeNull()
+ expect(block.querySelector('pre').hidden).toBe(true)
+ expect(block.querySelectorAll('.ff-code-block--export')).toHaveLength(3)
+ })
+
+ test('switches between the diagram and its code', async () => {
+ const wrapper = mountContent({ shouldStream: false, diagrams })
+ await flushPromises()
+ const block = wrapper.element.querySelector('.ff-code-block--mermaid')
+ block.querySelector('.ff-code-block--toggle').click()
+ expect(block.querySelector('pre').hidden).toBe(false)
+ expect(block.querySelector('.ff-code-block--diagram').hidden).toBe(true)
+ block.querySelector('.ff-code-block--toggle').click()
+ expect(block.querySelector('pre').hidden).toBe(true)
+ expect(block.querySelector('.ff-code-block--diagram').hidden).toBe(false)
+ })
+
+ test('shows the diagram while streaming and keeps the code indentation', async () => {
+ vi.useFakeTimers()
+ const wrapper = mountContent({ shouldStream: true, diagrams })
+ await vi.advanceTimersByTimeAsync(5000)
+ await flushPromises()
+ const block = wrapper.element.querySelector('.ff-code-block--mermaid')
+ expect(block.getAttribute('data-mermaid-state')).toBe('rendered')
+ expect(block.querySelector('pre code').textContent).toBe(source)
+ })
+
+ test('sanitizes each diagram once while streaming', async () => {
+ vi.useFakeTimers()
+ mountContent({ shouldStream: true, diagrams })
+ await vi.advanceTimersByTimeAsync(5000)
+ await flushPromises()
+ expect(Mermaid.sanitizeDiagram).toHaveBeenCalledTimes(1)
+ })
+
+ test('copies the SVG with the theme colours of the page', async () => {
+ const writeText = vi.fn()
+ vi.stubGlobal('navigator', { clipboard: { writeText } })
+ const wrapper = mountContent({ shouldStream: false, diagrams })
+ await flushPromises()
+ const block = wrapper.element.querySelector('.ff-code-block--mermaid')
+ block.querySelector('.ff-code-block--diagram').style.setProperty('--pele-bg', '#101010')
+ block.querySelector('[data-export="svg"]').click()
+ await flushPromises()
+ expect(writeText).toHaveBeenCalledTimes(1)
+ expect(writeText.mock.calls[0][0]).toContain('--pele-bg: #101010')
+ })
+
+ test('revokes the PNG download URL only after the download has started', async () => {
+ vi.useFakeTimers()
+ const createObjectURL = vi.fn(() => 'blob:diagram')
+ const revokeObjectURL = vi.fn()
+ vi.stubGlobal('URL', { ...URL, createObjectURL, revokeObjectURL })
+ const click = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {})
+ const wrapper = mountContent({ shouldStream: false, diagrams })
+ await flushPromises()
+ wrapper.element.querySelector('[data-export="png"]').click()
+ await flushPromises()
+ expect(click).toHaveBeenCalledTimes(1)
+ expect(revokeObjectURL).not.toHaveBeenCalled()
+ await vi.advanceTimersByTimeAsync(40 * 1000)
+ expect(revokeObjectURL).toHaveBeenCalledWith('blob:diagram')
+ click.mockRestore()
+ })
+
+ test('keeps the source when there is no diagram for the block', async () => {
+ const wrapper = mountContent({ shouldStream: false, diagrams: [] })
+ await flushPromises()
+ const block = wrapper.element.querySelector('.ff-code-block--mermaid')
+ expect(block.getAttribute('data-mermaid-state')).toBe('failed')
+ expect(block.querySelector('.ff-code-block--toggle')).toBeNull()
+ })
+})
diff --git a/test/unit/frontend/composables/Markdown.spec.js b/test/unit/frontend/composables/Markdown.spec.js
new file mode 100644
index 0000000000..f547bd65dc
--- /dev/null
+++ b/test/unit/frontend/composables/Markdown.spec.js
@@ -0,0 +1,20 @@
+import { describe, expect, test } from 'vitest'
+
+import { useMarkdownHelper } from '../../../../frontend/src/composables/strings/Markdown.js'
+
+describe('useMarkdownHelper', () => {
+ const { markedInstance } = useMarkdownHelper()
+
+ test('marks mermaid fences so they can be rendered as diagrams', () => {
+ const html = markedInstance.parse('```mermaid\nflowchart LR\n A[Inject] --> B[Debug]\n```')
+ expect(html).toContain('ff-code-block ff-code-block--mermaid')
+ expect(html).toContain('ff-code-block--copy')
+ expect(html).toContain('hljs-keyword">flowchart')
+ expect(html).toContain('hljs-operator">-->')
+ })
+
+ test('does not mark other fences as mermaid', () => {
+ const html = markedInstance.parse('```js\nconst a = 1\n```')
+ expect(html).not.toContain('ff-code-block--mermaid')
+ })
+})
diff --git a/test/unit/frontend/composables/Mermaid.spec.js b/test/unit/frontend/composables/Mermaid.spec.js
new file mode 100644
index 0000000000..f8a316e20b
--- /dev/null
+++ b/test/unit/frontend/composables/Mermaid.spec.js
@@ -0,0 +1,36 @@
+import { describe, expect, test } from 'vitest'
+
+import { sanitizeDiagram, serializeSvg } from '../../../../frontend/src/composables/strings/Mermaid.js'
+
+describe('Mermaid', () => {
+ describe('sanitizeDiagram', () => {
+ test('removes scripts and event handlers', () => {
+ const svg = sanitizeDiagram('')
+ expect(svg).not.toContain('script')
+ expect(svg).not.toContain('onclick')
+ expect(svg).toContain('')
+ })
+
+ test('removes ids so they do not repeat across diagrams', () => {
+ const svg = sanitizeDiagram('')
+ expect(svg).not.toContain('id=')
+ expect(svg).toContain('class="node"')
+ })
+ })
+
+ describe('serializeSvg', () => {
+ test('sets the pixel size from the viewBox', () => {
+ const svg = serializeSvg('')
+ expect(svg).toContain('width="120"')
+ expect(svg).toContain('height="80"')
+ expect(svg).toContain('xmlns="http://www.w3.org/2000/svg"')
+ expect(svg).not.toContain('max-width')
+ })
+
+ test('writes the theme variables onto the SVG', () => {
+ const svg = serializeSvg('', { '--pele-bg': '#101010', '--pele-fg': 'rgb(255, 255, 255)' })
+ expect(svg).toContain('--pele-bg: #101010')
+ expect(svg).toContain('--pele-fg: rgb(255, 255, 255)')
+ })
+ })
+})