Markdown + Mermaid

Markdown + Mermaid for Confluence

Markdown + Mermaid is a Forge app for Confluence Cloud. It adds two macros, and both render in the browser.

The app has no backend, no storage and no egress. All JavaScript ships as Forge static resources, so it's built to qualify for Runs on Atlassian. Confluence still applies the badge automatically after deploy, and you can check it with forge eligibility.

---

How it works

Confluence page ──► Custom UI iframe (static/ui/build/view)
                      ├─ @forge/bridge view.getContext() → macro config {source, theme, maxHeight, showSource}
                      ├─ marked (GFM) + marked-footnote + highlight.js core (20 languages), with a cost guard (render.js RENDER_GUARD)
                      ├─ DOMPurify (HTML profile, hooks for links/images/inputs, SANITIZE_NAMED_PROPS)
                      └─ mermaid (lazy chunk, prefetched while Markdown renders; securityLevel 'strict',
                         locked themeCSS/fontFamily/dompurifyConfig, image-free labels) → DOMPurify SVG pass + CSS clean
Macro editor ──────► Custom UI config (static/ui/build/config): editor + options + live preview → view.submit({config})

There's no resolver and no src/index.js. The app has no backend functions, so permissions.scopes is [] and it has no external permission.

Config values are saved as strings in the macro's parameters, which Confluence stores in the page itself. The app has no storage of its own.

Content security policy

Scripts load only from the app's own resource directory ('self'). The manifest sets permissions.content.styles: [unsafe-inline] because Mermaid writes <style> elements and style attributes into its SVGs. Raw HTML in Markdown can't use it, since style tags and attributes are stripped from Markdown output. In diagram labels, author style attributes and images are removed before Mermaid draws them.

Mermaid's own CSS (from style/classDef statements) stays. But before Mermaid sees the diagram, every url(, image(, image-set(, cross-fade(, element(, src( and @import in its source gets an invisible word joiner (U+2060), so a browser doesn't treat it as CSS that loads a file.

One case is left alone. In flowchart/graph, architecture and mindmap diagrams, a node or service id written before its shape (src(Source) --> build, service url(internet)[Web]) stays as written, because Mermaid's parser would reject the joiner there. The exception only applies outside quotes, brackets, HTML tags, frontmatter, directives and comments. It never applies on lines with classDef/style/linkStyle/class, or after : or ,. Mermaid's flowchart parser also rejects ( in style/classDef values.

Flowchart image shapes (A@{ img: "https://…" }) get an error before Mermaid runs, because Mermaid would load that image during layout. Base64 PNG/GIF/JPEG/WebP data URLs work. Diagrams that spell these functions with CSS escapes (\75 rl() get an error too. The final SVG is cleaned again, with remote url(), @import and fixed/sticky positioning removed, and directives can't set themeCSS or fontFamily.

Security model

ThreatMitigationTest
<script>, <iframe>, <object>, <form>, <style>, <base>, <meta> in MarkdownDOMPurify plus FORBID_TAGSrender.test.js sanitization suite
javascript: / vbscript: / data: links (incl. entity-encoded, mixed case)DOMPurify URI allow-listcovered
onerror / onclick / onload / ontoggle handlersDOMPurifycovered
Raw SVG/MathML (mXSS vectors) in Markdownforbidden in Markdown; only Mermaid's SVG is allowed, via a separate SVG profilecovered
DOM clobbering (id/name)SANITIZE_NAMED_PROPS (ids get the user-content- prefix)covered
Tracking pixels / egress via <img>URL resolved like a browser does (WHATWG URL); only same-origin or base64 raster data images load, others shown as a linkfix1-regression.test.js F03
Egress via <video poster>, <audio>, <source>, <track>tags removedF03
Forged diagram slots / "blocked image" links in raw HTMLapp classes (mm-*) and data attributes removed from author HTML; diagram slots carry a per-render random nonceC09, C10
Mermaid label injectionsecurityLevel: 'strict', label DOMPurify config without images/styles (locked via secure), plus a DOMPurify SVG pass (no <a>, no href, no <script>)dom.test.js, F04
CSS injection / egress via Mermaid directivesthemeCSS, fontFamily, dompurifyConfig locked; remote url(), @import, fixed positioning removed from SVG CSSF04
Egress via classDef / style / themeVariables CSS while Mermaid lays out the diagramurl(, image(, image-set(, cross-fade(, element(, src(, @import neutralized in the diagram source before Mermaid runs (node or service ids before a shape excepted, see CSP above); CSS-escaped forms rejectedfix2-regression.test.js R2-01, fix3-regression.test.js R3-01
Egress via a flowchart image shape (@{ img: "https://…" }), which Mermaid loads with new Image() during layoutRefused before Mermaid runs (precheck and the guarded Mermaid handle); data URLs allowedfix3-regression.test.js R3-02
Deep-nesting DoSraw HTML nesting capped at 100 levels; lists/quotes at 24; a sanitizer failure falls back to escaped textcovered
Quadratic Markdown parsing (e.g. *a_ × 70,000 took 311 s)marked's emphasis search is metered (deterministic work units, per paragraph and per page); a paragraph over the limit is shown as plain text with a notice. The e-mail lookahead cost is estimated in O(n) before marked runsF01, R2-03
Huge input1 MB input cap, 50 diagrams, 50k chars and 500 edges per diagram, 5,000 table rows / list items, 2,000 linked footnotes, highlighting off above 50k charscovered

Support Zovo

Zovo builds and maintains this app. If it saves you time, you can support the work on GitHub Sponsors. Sponsorship is optional, and the tool works the same without it.

Zovo is made by theluckystrike on GitHub.

More tools by Zovo

Support this work

If this app saves you time, you can support the work on GitHub Sponsors. Monthly tiers start at $5, and one-time tiers start at $15. Sponsorship is optional, and the tool works the same without it.

Sponsor on GitHubZovo on GitHub