|
| 1 | +import $ from 'jquery'; |
| 2 | +import { once, countBy } from 'lodash'; |
| 3 | +import { __ } from '~/locale'; |
| 4 | +import { |
| 5 | + getBaseURL, |
| 6 | + relativePathToAbsolute, |
| 7 | + setUrlParams, |
| 8 | + joinPaths, |
| 9 | +} from '~/lib/utils/url_utility'; |
| 10 | +import { darkModeEnabled } from '~/lib/utils/color_utils'; |
| 11 | +import { setAttributes } from '~/lib/utils/dom_utils'; |
| 12 | + |
| 13 | +// Renders diagrams and flowcharts from text using Mermaid in any element with the |
| 14 | +// `js-render-mermaid` class. |
| 15 | +// |
| 16 | +// Example markup: |
| 17 | +// |
| 18 | +// <pre class="js-render-mermaid"> |
| 19 | +// graph TD; |
| 20 | +// A-- > B; |
| 21 | +// A-- > C; |
| 22 | +// B-- > D; |
| 23 | +// C-- > D; |
| 24 | +// </pre> |
| 25 | +// |
| 26 | + |
| 27 | +const SANDBOX_FRAME_PATH = '/-/sandbox/mermaid'; |
| 28 | +// This is an arbitrary number; Can be iterated upon when suitable. |
| 29 | +const MAX_CHAR_LIMIT = 2000; |
| 30 | +// Max # of mermaid blocks that can be rendered in a page. |
| 31 | +const MAX_MERMAID_BLOCK_LIMIT = 50; |
| 32 | +// Max # of `&` allowed in Chaining of links syntax |
| 33 | +const MAX_CHAINING_OF_LINKS_LIMIT = 30; |
| 34 | +// Keep a map of mermaid blocks we've already rendered. |
| 35 | +const elsProcessingMap = new WeakMap(); |
| 36 | +let renderedMermaidBlocks = 0; |
| 37 | + |
| 38 | +// Pages without any restrictions on mermaid rendering |
| 39 | +const PAGES_WITHOUT_RESTRICTIONS = [ |
| 40 | + // Group wiki |
| 41 | + 'groups:wikis:show', |
| 42 | + 'groups:wikis:edit', |
| 43 | + 'groups:wikis:create', |
| 44 | + |
| 45 | + // Project wiki |
| 46 | + 'projects:wikis:show', |
| 47 | + 'projects:wikis:edit', |
| 48 | + 'projects:wikis:create', |
| 49 | + |
| 50 | + // Project files |
| 51 | + 'projects:show', |
| 52 | + 'projects:blob:show', |
| 53 | +]; |
| 54 | + |
| 55 | +function shouldLazyLoadMermaidBlock(source) { |
| 56 | + /** |
| 57 | + * If source contains `&`, which means that it might |
| 58 | + * contain Chaining of links a new syntax in Mermaid. |
| 59 | + */ |
| 60 | + if (countBy(source)['&'] > MAX_CHAINING_OF_LINKS_LIMIT) { |
| 61 | + return true; |
| 62 | + } |
| 63 | + |
| 64 | + return false; |
| 65 | +} |
| 66 | + |
| 67 | +function fixElementSource(el) { |
| 68 | + // Mermaid doesn't like `<br />` tags, so collapse all like tags into `<br>`, which is parsed correctly. |
| 69 | + const source = el.textContent?.replace(/<br\s*\/>/g, '<br>'); |
| 70 | + |
| 71 | + // Remove any extra spans added by the backend syntax highlighting. |
| 72 | + Object.assign(el, { textContent: source }); |
| 73 | + |
| 74 | + return { source }; |
| 75 | +} |
| 76 | + |
| 77 | +function getSandboxFrameSrc() { |
| 78 | + const path = joinPaths(gon.relative_url_root || '', SANDBOX_FRAME_PATH); |
| 79 | + if (!darkModeEnabled()) { |
| 80 | + return path; |
| 81 | + } |
| 82 | + const absoluteUrl = relativePathToAbsolute(path, getBaseURL()); |
| 83 | + return setUrlParams({ darkMode: darkModeEnabled() }, absoluteUrl); |
| 84 | +} |
| 85 | + |
| 86 | +function renderMermaidEl(el, source) { |
| 87 | + const iframeEl = document.createElement('iframe'); |
| 88 | + setAttributes(iframeEl, { |
| 89 | + src: getSandboxFrameSrc(), |
| 90 | + sandbox: 'allow-scripts', |
| 91 | + frameBorder: 0, |
| 92 | + scrolling: 'no', |
| 93 | + }); |
| 94 | + |
| 95 | + // Add the original source into the DOM |
| 96 | + // to allow Copy-as-GFM to access it. |
| 97 | + const sourceEl = document.createElement('text'); |
| 98 | + sourceEl.textContent = source; |
| 99 | + sourceEl.classList.add('gl-display-none'); |
| 100 | + |
| 101 | + const wrapper = document.createElement('div'); |
| 102 | + wrapper.appendChild(iframeEl); |
| 103 | + wrapper.appendChild(sourceEl); |
| 104 | + |
| 105 | + el.closest('pre').replaceWith(wrapper); |
| 106 | + |
| 107 | + // Event Listeners |
| 108 | + iframeEl.addEventListener('load', () => { |
| 109 | + // Potential risk associated with '*' discussed in below thread |
| 110 | + // https://gitlab.com/gitlab-org/gitlab/-/merge_requests/74414#note_735183398 |
| 111 | + iframeEl.contentWindow.postMessage(source, '*'); |
| 112 | + }); |
| 113 | + |
| 114 | + window.addEventListener( |
| 115 | + 'message', |
| 116 | + (event) => { |
| 117 | + if (event.origin !== 'null' || event.source !== iframeEl.contentWindow) { |
| 118 | + return; |
| 119 | + } |
| 120 | + const { h, w } = event.data; |
| 121 | + iframeEl.width = w; |
| 122 | + iframeEl.height = h; |
| 123 | + }, |
| 124 | + false, |
| 125 | + ); |
| 126 | +} |
| 127 | + |
| 128 | +function renderMermaids($els) { |
| 129 | + if (!$els.length) return; |
| 130 | + |
| 131 | + const pageName = document.querySelector('body').dataset.page; |
| 132 | + |
| 133 | + // A diagram may have been truncated in search results which will cause errors, so abort the render. |
| 134 | + if (pageName === 'search:show') return; |
| 135 | + |
| 136 | + let renderedChars = 0; |
| 137 | + |
| 138 | + $els.each((i, el) => { |
| 139 | + // Skipping all the elements which we've already queued in requestIdleCallback |
| 140 | + if (elsProcessingMap.has(el)) { |
| 141 | + return; |
| 142 | + } |
| 143 | + |
| 144 | + const { source } = fixElementSource(el); |
| 145 | + /** |
| 146 | + * Restrict the rendering to a certain amount of character |
| 147 | + * and mermaid blocks to prevent mermaidjs from hanging |
| 148 | + * up the entire thread and causing a DoS. |
| 149 | + */ |
| 150 | + if ( |
| 151 | + !PAGES_WITHOUT_RESTRICTIONS.includes(pageName) && |
| 152 | + ((source && source.length > MAX_CHAR_LIMIT) || |
| 153 | + renderedChars > MAX_CHAR_LIMIT || |
| 154 | + renderedMermaidBlocks >= MAX_MERMAID_BLOCK_LIMIT || |
| 155 | + shouldLazyLoadMermaidBlock(source)) |
| 156 | + ) { |
| 157 | + const html = ` |
| 158 | + <div class="alert gl-alert gl-alert-warning alert-dismissible lazy-render-mermaid-container js-lazy-render-mermaid-container fade show" role="alert"> |
| 159 | + <div> |
| 160 | + <div> |
| 161 | + <div class="js-warning-text"></div> |
| 162 | + <div class="gl-alert-actions"> |
| 163 | + <button type="button" class="js-lazy-render-mermaid btn gl-alert-action btn-warning btn-md gl-button">Display</button> |
| 164 | + </div> |
| 165 | + </div> |
| 166 | + <button type="button" class="close" data-dismiss="alert" aria-label="Close"> |
| 167 | + <span aria-hidden="true">×</span> |
| 168 | + </button> |
| 169 | + </div> |
| 170 | + </div> |
| 171 | + `; |
| 172 | + |
| 173 | + const $parent = $(el).parent(); |
| 174 | + |
| 175 | + if (!$parent.hasClass('lazy-alert-shown')) { |
| 176 | + $parent.after(html); |
| 177 | + $parent |
| 178 | + .siblings() |
| 179 | + .find('.js-warning-text') |
| 180 | + .text( |
| 181 | + __('Warning: Displaying this diagram might cause performance issues on this page.'), |
| 182 | + ); |
| 183 | + $parent.addClass('lazy-alert-shown'); |
| 184 | + } |
| 185 | + |
| 186 | + return; |
| 187 | + } |
| 188 | + |
| 189 | + renderedChars += source.length; |
| 190 | + renderedMermaidBlocks += 1; |
| 191 | + |
| 192 | + const requestId = window.requestIdleCallback(() => { |
| 193 | + renderMermaidEl(el, source); |
| 194 | + }); |
| 195 | + |
| 196 | + elsProcessingMap.set(el, requestId); |
| 197 | + }); |
| 198 | +} |
| 199 | + |
| 200 | +const hookLazyRenderMermaidEvent = once(() => { |
| 201 | + $(document.body).on('click', '.js-lazy-render-mermaid', function eventHandler() { |
| 202 | + const parent = $(this).closest('.js-lazy-render-mermaid-container'); |
| 203 | + const pre = parent.prev(); |
| 204 | + |
| 205 | + const el = pre.find('.js-render-mermaid'); |
| 206 | + |
| 207 | + parent.remove(); |
| 208 | + |
| 209 | + // sandbox update |
| 210 | + const element = el.get(0); |
| 211 | + const { source } = fixElementSource(element); |
| 212 | + |
| 213 | + renderMermaidEl(element, source); |
| 214 | + }); |
| 215 | +}); |
| 216 | + |
| 217 | +export default function renderMermaid($els) { |
| 218 | + if (!$els.length) return; |
| 219 | + |
| 220 | + const visibleMermaids = $els.filter(function filter() { |
| 221 | + return $(this).closest('details').length === 0 && $(this).is(':visible'); |
| 222 | + }); |
| 223 | + |
| 224 | + renderMermaids(visibleMermaids); |
| 225 | + |
| 226 | + $els.closest('details').one('toggle', function toggle() { |
| 227 | + if (this.open) { |
| 228 | + renderMermaids($(this).find('.js-render-mermaid')); |
| 229 | + } |
| 230 | + }); |
| 231 | + |
| 232 | + hookLazyRenderMermaidEvent(); |
| 233 | +} |
0 commit comments