import { countBy } from 'lodash'; import { __ } from '~/locale'; import { getBaseURL, relativePathToAbsolute, setUrlParams, joinPaths, } from '~/lib/utils/url_utility'; import { darkModeEnabled } from '~/lib/utils/color_utils'; import { setAttributes, isElementVisible } from '~/lib/utils/dom_utils'; import { createAlert, VARIANT_WARNING } from '~/alert'; import { unrestrictedPages } from './constants'; // Renders diagrams and flowcharts from text using Mermaid in any element with the // `js-render-mermaid` class. // // Example markup: // //
//  graph TD;
//    A-- > B;
//    A-- > C;
//    B-- > D;
//    C-- > D;
// 
// const SANDBOX_FRAME_PATH = '/-/sandbox/mermaid'; // This is an arbitrary number; Can be iterated upon when suitable. export const MAX_CHAR_LIMIT = 2000; // Max # of mermaid blocks that can be rendered in a page. export const MAX_MERMAID_BLOCK_LIMIT = 50; // Max # of `&` allowed in Chaining of links syntax const MAX_CHAINING_OF_LINKS_LIMIT = 30; export const BUFFER_IFRAME_HEIGHT = 10; export const SANDBOX_ATTRIBUTES = 'allow-scripts allow-popups'; const ALERT_CONTAINER_CLASS = 'mermaid-alert-container'; export const LAZY_ALERT_SHOWN_CLASS = 'lazy-alert-shown'; // Keep a map of mermaid blocks we've already rendered. const elsProcessingMap = new WeakMap(); let renderedMermaidBlocks = 0; /** * Determines whether a given Mermaid diagram is visible. * * @param {Element} el The Mermaid DOM node * @returns */ const isVisibleMermaid = (el) => el.closest('details') === null && isElementVisible(el); function shouldLazyLoadMermaidBlock(source) { /** * If source contains `&`, which means that it might * contain Chaining of links a new syntax in Mermaid. */ if (countBy(source)['&'] > MAX_CHAINING_OF_LINKS_LIMIT) { return true; } return false; } function fixElementSource(el) { // Mermaid doesn't like `
` tags, so collapse all like tags into `
`, which is parsed correctly. const source = el.textContent?.replace(//g, '
'); return { source }; } export function getSandboxFrameSrc() { const path = joinPaths(gon.relative_url_root || '', SANDBOX_FRAME_PATH); let absoluteUrl = relativePathToAbsolute(path, getBaseURL()); if (darkModeEnabled()) { absoluteUrl = setUrlParams({ darkMode: darkModeEnabled() }, absoluteUrl); } if (window.gon?.relative_url_root) { absoluteUrl = setUrlParams({ relativeRootPath: window.gon.relative_url_root }, absoluteUrl); } return absoluteUrl; } function renderMermaidEl(el, source) { const iframeEl = document.createElement('iframe'); setAttributes(iframeEl, { src: getSandboxFrameSrc(), sandbox: SANDBOX_ATTRIBUTES, frameBorder: 0, scrolling: 'no', width: '100%', }); const wrapper = document.createElement('div'); wrapper.appendChild(iframeEl); // Hide the markdown but keep it "visible enough" to allow Copy-as-GFM // https://gitlab.com/gitlab-org/gitlab/-/merge_requests/83202 el.closest('pre').classList.add('gl-sr-only'); el.closest('pre').parentNode.appendChild(wrapper); // Event Listeners iframeEl.addEventListener('load', () => { // Potential risk associated with '*' discussed in below thread // https://gitlab.com/gitlab-org/gitlab/-/merge_requests/74414#note_735183398 iframeEl.contentWindow.postMessage(source, '*'); }); window.addEventListener( 'message', (event) => { if (event.origin !== 'null' || event.source !== iframeEl.contentWindow) { return; } const { h } = event.data; iframeEl.height = `${h + BUFFER_IFRAME_HEIGHT}px`; }, false, ); } function renderMermaids(els) { if (!els.length) return; const pageName = document.querySelector('body').dataset.page; // A diagram may have been truncated in search results which will cause errors, so abort the render. if (pageName === 'search:show') return; let renderedChars = 0; els.forEach((el) => { // Skipping all the elements which we've already queued in requestIdleCallback if (elsProcessingMap.has(el)) { return; } const { source } = fixElementSource(el); /** * Restrict the rendering to a certain amount of character * and mermaid blocks to prevent mermaidjs from hanging * up the entire thread and causing a DoS. */ if ( !unrestrictedPages.includes(pageName) && ((source && source.length > MAX_CHAR_LIMIT) || renderedChars > MAX_CHAR_LIMIT || renderedMermaidBlocks >= MAX_MERMAID_BLOCK_LIMIT || shouldLazyLoadMermaidBlock(source)) ) { const parent = el.parentNode; if (!parent.classList.contains(LAZY_ALERT_SHOWN_CLASS)) { const alertContainer = document.createElement('div'); alertContainer.classList.add(ALERT_CONTAINER_CLASS); alertContainer.classList.add('gl-mb-5'); parent.before(alertContainer); createAlert({ message: __( 'Warning: Displaying this diagram might cause performance issues on this page.', ), variant: VARIANT_WARNING, parent: parent.parentNode, containerSelector: `.${ALERT_CONTAINER_CLASS}`, primaryButton: { text: __('Display'), clickHandler: () => { alertContainer.remove(); renderMermaidEl(el, source); }, }, }); parent.classList.add(LAZY_ALERT_SHOWN_CLASS); } return; } renderedChars += source.length; renderedMermaidBlocks += 1; const requestId = window.requestIdleCallback(() => { renderMermaidEl(el, source); }); elsProcessingMap.set(el, requestId); }); } export default function renderMermaid(els) { if (!els.length) return; const visibleMermaids = []; const hiddenMermaids = []; for (const el of els) { if (isVisibleMermaid(el)) { visibleMermaids.push(el); } else { hiddenMermaids.push(el); } } renderMermaids(visibleMermaids); hiddenMermaids.forEach((el) => { el.closest('details')?.addEventListener( 'toggle', ({ target: details }) => { if (details.open) { renderMermaids([...details.querySelectorAll('.js-render-mermaid')]); } }, { once: true, }, ); }); }