debian-mirror-gitlab/app/assets/javascripts/behaviors/markdown/render_mermaid.js

245 lines
6.8 KiB
JavaScript
Raw Normal View History

2020-03-13 15:44:24 +05:30
import $ from 'jquery';
2021-06-08 01:23:25 +05:30
import { once, countBy } from 'lodash';
2020-10-24 23:57:45 +05:30
import { deprecatedCreateFlash as flash } from '~/flash';
2021-04-29 21:17:54 +05:30
import { darkModeEnabled } from '~/lib/utils/color_utils';
2020-10-24 23:57:45 +05:30
import { __, sprintf } from '~/locale';
2018-05-09 12:01:36 +05:30
2018-03-17 18:26:18 +05:30
// Renders diagrams and flowcharts from text using Mermaid in any element with the
// `js-render-mermaid` class.
//
// Example markup:
//
// <pre class="js-render-mermaid">
// graph TD;
// A-- > B;
// A-- > C;
// B-- > D;
// C-- > D;
// </pre>
//
2019-09-04 21:01:54 +05:30
// This is an arbitrary number; Can be iterated upon when suitable.
2020-12-08 15:28:05 +05:30
const MAX_CHAR_LIMIT = 2000;
// Max # of mermaid blocks that can be rendered in a page.
const MAX_MERMAID_BLOCK_LIMIT = 50;
2021-06-08 01:23:25 +05:30
// Max # of `&` allowed in Chaining of links syntax
const MAX_CHAINING_OF_LINKS_LIMIT = 30;
2020-12-08 15:28:05 +05:30
// Keep a map of mermaid blocks we've already rendered.
const elsProcessingMap = new WeakMap();
let renderedMermaidBlocks = 0;
2020-04-08 14:13:33 +05:30
let mermaidModule = {};
2019-03-13 22:55:13 +05:30
2021-09-04 01:27:46 +05:30
// Whitelist pages where we won't impose any restrictions
// on mermaid rendering
const WHITELISTED_PAGES = [
// Group wiki
'groups:wikis:show',
'groups:wikis:edit',
'groups:wikis:create',
// Project wiki
'projects:wikis:show',
'projects:wikis:edit',
'projects:wikis:create',
// Project files
'projects:show',
'projects:blob:show',
];
2021-04-29 21:17:54 +05:30
export function initMermaid(mermaid) {
let theme = 'neutral';
2020-05-24 23:13:21 +05:30
2021-04-29 21:17:54 +05:30
if (darkModeEnabled()) {
theme = 'dark';
}
2020-05-24 23:13:21 +05:30
2021-04-29 21:17:54 +05:30
mermaid.initialize({
// mermaid core options
mermaid: {
startOnLoad: false,
},
// mermaidAPI options
theme,
flowchart: {
useMaxWidth: true,
2021-09-04 01:27:46 +05:30
htmlLabels: true,
2021-04-29 21:17:54 +05:30
},
2021-08-04 16:29:09 +05:30
secure: ['secure', 'securityLevel', 'startOnLoad', 'maxTextSize', 'htmlLabels'],
2021-04-29 21:17:54 +05:30
securityLevel: 'strict',
});
2018-03-17 18:26:18 +05:30
2021-04-29 21:17:54 +05:30
return mermaid;
}
2020-04-08 14:13:33 +05:30
2021-04-29 21:17:54 +05:30
function importMermaidModule() {
return import(/* webpackChunkName: 'mermaid' */ 'mermaid')
.then((mermaid) => {
mermaidModule = initMermaid(mermaid);
2020-04-08 14:13:33 +05:30
})
2021-03-08 18:12:59 +05:30
.catch((err) => {
2020-04-08 14:13:33 +05:30
flash(sprintf(__("Can't load mermaid module: %{err}"), { err }));
// eslint-disable-next-line no-console
console.error(err);
});
}
2021-06-08 01:23:25 +05:30
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;
}
2020-04-08 14:13:33 +05:30
function fixElementSource(el) {
// Mermaid doesn't like `<br />` tags, so collapse all like tags into `<br>`, which is parsed correctly.
const source = el.textContent.replace(/<br\s*\/>/g, '<br>');
// Remove any extra spans added by the backend syntax highlighting.
Object.assign(el, { textContent: source });
return { source };
}
function renderMermaidEl(el) {
2021-03-08 18:12:59 +05:30
mermaidModule.init(undefined, el, (id) => {
2020-04-08 14:13:33 +05:30
const source = el.textContent;
const svg = document.getElementById(id);
// As of https://github.com/knsv/mermaid/commit/57b780a0d,
// Mermaid will make two init callbacks:one to initialize the
// flow charts, and another to initialize the Gannt charts.
// Guard against an error caused by double initialization.
if (svg.classList.contains('mermaid')) {
return;
}
svg.classList.add('mermaid');
// pre > code > svg
svg.closest('pre').replaceWith(svg);
// We need to add the original source into the DOM to allow Copy-as-GFM
// to access it.
const sourceEl = document.createElement('text');
sourceEl.classList.add('source');
sourceEl.setAttribute('display', 'none');
sourceEl.textContent = source;
svg.appendChild(sourceEl);
});
}
function renderMermaids($els) {
if (!$els.length) return;
2021-09-04 01:27:46 +05:30
const pageName = document.querySelector('body').dataset.page;
2020-04-08 14:13:33 +05:30
// A diagram may have been truncated in search results which will cause errors, so abort the render.
2021-09-04 01:27:46 +05:30
if (pageName === 'search:show') return;
2020-04-08 14:13:33 +05:30
importMermaidModule()
.then(() => {
2019-09-30 23:59:55 +05:30
let renderedChars = 0;
2018-12-13 13:39:08 +05:30
$els.each((i, el) => {
2020-12-08 15:28:05 +05:30
// Skipping all the elements which we've already queued in requestIdleCallback
if (elsProcessingMap.has(el)) {
return;
}
2020-04-08 14:13:33 +05:30
const { source } = fixElementSource(el);
2019-03-13 22:55:13 +05:30
/**
2020-12-08 15:28:05 +05:30
* 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.
2019-03-13 22:55:13 +05:30
*/
2020-12-08 15:28:05 +05:30
if (
2021-09-04 01:27:46 +05:30
!WHITELISTED_PAGES.includes(pageName) &&
((source && source.length > MAX_CHAR_LIMIT) ||
renderedChars > MAX_CHAR_LIMIT ||
renderedMermaidBlocks >= MAX_MERMAID_BLOCK_LIMIT ||
shouldLazyLoadMermaidBlock(source))
2020-12-08 15:28:05 +05:30
) {
2020-04-08 14:13:33 +05:30
const html = `
<div class="alert gl-alert gl-alert-warning alert-dismissible lazy-render-mermaid-container js-lazy-render-mermaid-container fade show" role="alert">
<div>
<div class="display-flex">
<div>${__(
'Warning: Displaying this diagram might cause performance issues on this page.',
)}</div>
<div class="gl-alert-actions">
2021-03-11 19:13:27 +05:30
<button class="js-lazy-render-mermaid btn gl-alert-action btn-warning btn-md gl-button">Display</button>
2020-04-08 14:13:33 +05:30
</div>
</div>
<button type="button" class="close" data-dismiss="alert" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
</div>
</div>
`;
const $parent = $(el).parent();
if (!$parent.hasClass('lazy-alert-shown')) {
$parent.after(html);
$parent.addClass('lazy-alert-shown');
}
2019-03-13 22:55:13 +05:30
return;
}
2019-09-30 23:59:55 +05:30
renderedChars += source.length;
2020-12-08 15:28:05 +05:30
renderedMermaidBlocks += 1;
const requestId = window.requestIdleCallback(() => {
renderMermaidEl(el);
});
2018-03-17 18:26:18 +05:30
2020-12-08 15:28:05 +05:30
elsProcessingMap.set(el, requestId);
2018-03-17 18:26:18 +05:30
});
2018-12-13 13:39:08 +05:30
})
2021-03-08 18:12:59 +05:30
.catch((err) => {
2020-04-08 14:13:33 +05:30
flash(sprintf(__('Encountered an error while rendering: %{err}'), { err }));
// eslint-disable-next-line no-console
console.error(err);
2018-03-17 18:26:18 +05:30
});
}
2020-03-13 15:44:24 +05:30
2020-04-08 14:13:33 +05:30
const hookLazyRenderMermaidEvent = once(() => {
$(document.body).on('click', '.js-lazy-render-mermaid', function eventHandler() {
const parent = $(this).closest('.js-lazy-render-mermaid-container');
const pre = parent.prev();
const el = pre.find('.js-render-mermaid');
parent.remove();
renderMermaidEl(el);
});
});
2020-03-13 15:44:24 +05:30
export default function renderMermaid($els) {
if (!$els.length) return;
const visibleMermaids = $els.filter(function filter() {
2020-07-28 23:09:34 +05:30
return $(this).closest('details').length === 0 && $(this).is(':visible');
2020-03-13 15:44:24 +05:30
});
renderMermaids(visibleMermaids);
$els.closest('details').one('toggle', function toggle() {
if (this.open) {
renderMermaids($(this).find('.js-render-mermaid'));
}
});
2020-04-08 14:13:33 +05:30
hookLazyRenderMermaidEvent();
2020-03-13 15:44:24 +05:30
}