From d248952d9e7bd0aeb1db747e5c947e62457dcc5b Mon Sep 17 00:00:00 2001 From: "Ashley Engelund (weedySeaDragon @ github)" Date: Sat, 15 Oct 2022 11:11:54 -0700 Subject: [PATCH 01/18] render: constants --- packages/mermaid/src/mermaidAPI.ts | 70 ++++++++++++++++++++---------- 1 file changed, 46 insertions(+), 24 deletions(-) diff --git a/packages/mermaid/src/mermaidAPI.ts b/packages/mermaid/src/mermaidAPI.ts index 7fe5064de5..0bfa561ccb 100644 --- a/packages/mermaid/src/mermaidAPI.ts +++ b/packages/mermaid/src/mermaidAPI.ts @@ -33,6 +33,29 @@ import DOMPurify from 'dompurify'; import { MermaidConfig } from './config.type'; import { evaluate } from './diagrams/common/common'; +const MAX_TEXTLENGTH_EXCEEDED_MSG = + 'graph TB;a[Maximum text size in diagram exceeded];style a fill:#faa'; + +const SECURITY_LVL_SANDBOX = 'sandbox'; +const SECURITY_LVL_LOOSE = 'loose'; + +const XMLNS_XHTML_STD = 'http://www.w3.org/1999/xhtml'; +const XMLNS_SVG_STD = 'http://www.w3.org/2000/svg'; +const XMLNS_XLINK_STD = 'http://www.w3.org/1999/xlink'; + +// ------------------------------ +// iFrame +const IFRAME_WIDTH = '100%'; +const IFRAME_HEIGHT = '100%'; +const IFRAME_STYLES = 'border:0;margin:0;'; +const IFRAME_BODY_STYLE = 'margin:0'; +const IFRAME_SANDBOX_OPTS = 'allow-top-navigation-by-user-activation allow-popups'; +const IFRAME_NOT_SUPPORTED_MSG = 'The “iframe” tag is not supported by your browser.'; + +const DOMPURIFY_TAGS = 'foreignobject'; +const DOMPURIFY_ATTR = 'dominant-baseline'; +// -------------------------------------------------------------------------------- + /** * @param text * @param parseError @@ -84,6 +107,7 @@ export const decodeEntities = function (text: string): string { return txt; }; + /** * Function that renders an svg with a graph from a chart definition. Usage example below. * @@ -116,6 +140,7 @@ const render = async function ( container?: Element ): Promise { addDiagrams(); + configApi.reset(); text = text.replace(/\r\n?/g, '\n'); // parser problems on CRLF ignore all CR and leave LF;; const graphInit = utils.detectInit(text); @@ -129,19 +154,19 @@ const render = async function ( // Check the maximum allowed text size if (text.length > cnf.maxTextSize!) { - text = 'graph TB;a[Maximum text size in diagram exceeded];style a fill:#faa'; + text = MAX_TEXTLENGTH_EXCEEDED_MSG; } let root: any = select('body'); // In regular execution the container will be the div with a mermaid class if (typeof container !== 'undefined') { - // A container was provided by the caller + // A container was provided by the caller. Clear the inner HTML if there is any if (container) { container.innerHTML = ''; } - if (cnf.securityLevel === 'sandbox') { + if (cnf.securityLevel === SECURITY_LVL_SANDBOX) { // IF we are in sandboxed mode, we do everyting mermaid related // in a sandboxed div const iframe = select(container) @@ -163,8 +188,8 @@ const render = async function ( .append('svg') .attr('id', id) .attr('width', '100%') - .attr('xmlns', 'http://www.w3.org/2000/svg') - .attr('xmlns:xlink', 'http://www.w3.org/1999/xlink') + .attr('xmlns', XMLNS_SVG_STD) + .attr('xmlns:xlink', XMLNS_XLINK_STD) .append('g'); } else { // No container was provided @@ -177,7 +202,7 @@ const render = async function ( // Remove previous tpm element if it exists let element; - if (cnf.securityLevel === 'sandbox') { + if (cnf.securityLevel === SECURITY_LVL_SANDBOX) { element = document.querySelector('#i' + id); } else { element = document.querySelector('#d' + id); @@ -190,7 +215,7 @@ const render = async function ( // Add the tmp div used for rendering with the id `d${id}` // d+id it will contain a svg with the id "id" - if (cnf.securityLevel === 'sandbox') { + if (cnf.securityLevel === SECURITY_LVL_SANDBOX) { // IF we are in sandboxed mode, we do everyting mermaid related // in a sandboxed div const iframe = select('body') @@ -213,7 +238,7 @@ const render = async function ( .append('svg') .attr('id', id) .attr('width', '100%') - .attr('xmlns', 'http://www.w3.org/2000/svg') + .attr('xmlns', XMLNS_SVG_STD) .append('g'); } @@ -305,16 +330,13 @@ const render = async function ( throw e; } - root - .select(`[id="${id}"]`) - .selectAll('foreignobject > *') - .attr('xmlns', 'http://www.w3.org/1999/xhtml'); + root.select(`[id="${id}"]`).selectAll('foreignobject > *').attr('xmlns', XMLNS_XHTML_STD); // Fix for when the base tag is used let svgCode = root.select('#d' + id).node().innerHTML; log.debug('cnf.arrowMarkerAbsolute', cnf.arrowMarkerAbsolute); - if (!evaluate(cnf.arrowMarkerAbsolute) && cnf.securityLevel !== 'sandbox') { + if (!evaluate(cnf.arrowMarkerAbsolute) && cnf.securityLevel !== SECURITY_LVL_SANDBOX) { svgCode = svgCode.replace(/marker-end="url\(.*?#/g, 'marker-end="url(#', 'g'); } @@ -323,23 +345,23 @@ const render = async function ( // Fix for when the br tag is used svgCode = svgCode.replace(/
/g, '
'); - if (cnf.securityLevel === 'sandbox') { + if (cnf.securityLevel === SECURITY_LVL_SANDBOX) { const svgEl = root.select('#d' + id + ' svg').node(); - const width = '100%'; - let height = '100%'; + const width = IFRAME_WIDTH; + let height = IFRAME_HEIGHT; if (svgEl) { height = svgEl.viewBox.baseVal.height + 'px'; } - svgCode = ``; } else { - if (cnf.securityLevel !== 'loose') { + if (cnf.securityLevel !== SECURITY_LVL_LOOSE) { svgCode = DOMPurify.sanitize(svgCode, { - ADD_TAGS: ['foreignobject'], - ADD_ATTR: ['dominant-baseline'], + ADD_TAGS: [DOMPURIFY_TAGS], + ADD_ATTR: [DOMPURIFY_ATTR], }); } } @@ -365,7 +387,7 @@ const render = async function ( } attachFunctions(); - const tmpElementSelector = cnf.securityLevel === 'sandbox' ? '#i' + id : '#d' + id; + const tmpElementSelector = cnf.securityLevel === SECURITY_LVL_SANDBOX ? '#i' + id : '#d' + id; const node = select(tmpElementSelector).node(); if (node && 'remove' in node) { node.remove(); From 8aaa7b1ba314bfe9ee1a9c68f2b318ce98ac315d Mon Sep 17 00:00:00 2001 From: "Ashley Engelund (weedySeaDragon @ github)" Date: Sat, 15 Oct 2022 13:14:01 -0700 Subject: [PATCH 02/18] specs: encodeEntities, decodeEntities --- packages/mermaid/src/mermaidAPI.spec.js | 69 +++++++++++++++++++++++++ 1 file changed, 69 insertions(+) diff --git a/packages/mermaid/src/mermaidAPI.spec.js b/packages/mermaid/src/mermaidAPI.spec.js index 241b5ec864..35473d1bfe 100644 --- a/packages/mermaid/src/mermaidAPI.spec.js +++ b/packages/mermaid/src/mermaidAPI.spec.js @@ -1,9 +1,78 @@ 'use strict'; import mermaid from './mermaid'; import mermaidAPI from './mermaidAPI'; +import { encodeEntities, decodeEntities } from './mermaidAPI'; + import assignWithDepth from './assignWithDepth'; describe('when using mermaidAPI and ', function () { + describe('encodeEntities', () => { + it('removes the ending ; from style [text1]:[optional word]#[text2]; with ', () => { + const text = 'style this; is ; everything :something#not-nothing; and this too;'; + expect(encodeEntities(text)).toEqual( + 'style this; is ; everything :something#not-nothing; and this too' + ); + }); + it('removes the ending ; from classDef [text1]:[optional word]#[text2]; with ', () => { + const text = 'classDef this; is ; everything :something#not-nothing; and this too;'; + expect(encodeEntities(text)).toEqual( + 'classDef this; is ; everything :something#not-nothing; and this too' + ); + }); + + describe('replaces words starting with # and ending with ;', () => { + const testStr = 'Hello #there;'; + + it('removes the #', () => { + const result = encodeEntities(testStr); + expect(result.substring(0, 7)).toEqual('Hello fl'); + }); + + it('prefix is fl°° if is all digits', () => { + const result = encodeEntities('Hello #77653;'); + expect(result.substring(6, result.length)).toEqual('fl°°77653¶ß'); + }); + + it('prefix is fl° if is not all digits', () => { + const result = encodeEntities(testStr); + expect(result.substring(6, result.length)).toEqual('fl°there¶ß'); + }); + it('always removes the semi-colon and ends with ¶ß', () => { + const result = encodeEntities(testStr); + expect(result.substring(result.length - 2, result.length)).toEqual('¶ß'); + }); + }); + + it('does all the replacements on the given text', () => { + const text = + 'style this; is ; everything :something#not-nothing; and this too; \n' + + 'classDef this; is ; everything :something#not-nothing; and this too; \n' + + 'Hello #there; #andHere;#77653;'; + + const result = encodeEntities(text); + expect(result).toEqual( + 'style this; is ; everything :something#not-nothing; and this too \n' + + 'classDef this; is ; everything :something#not-nothing; and this too \n' + + 'Hello fl°there¶ß fl°andHere¶ßfl°°77653¶ß' + ); + }); + }); + + describe('decodeEntities', () => { + it('replaces fl°° with &#', () => { + expect(decodeEntities('fl°°hfl°°ifl°°')).toEqual('&#h&#i&#'); + }); + it('replaces fl° with &', () => { + expect(decodeEntities('fl°hfl°ifl°')).toEqual('&h&i&'); + }); + it('replaces ¶ß with ;', () => { + expect(decodeEntities('¶ßh¶ßi¶ß')).toEqual(';h;i;'); + }); + it('runs all the replacements on the given text', () => { + expect(decodeEntities('¶ßfl°¶ßfl°°¶ß')).toEqual(';&;&#;'); + }); + }); + describe('doing initialize ', function () { beforeEach(function () { document.body.innerHTML = ''; From 978bf4e0ffc3451394ef61e63b13298e96a42a18 Mon Sep 17 00:00:00 2001 From: "Ashley Engelund (weedySeaDragon @ github)" Date: Sat, 15 Oct 2022 13:19:58 -0700 Subject: [PATCH 03/18] render: define const iFrameId, enclosingDivID and _selector to use in function --- packages/mermaid/src/mermaidAPI.ts | 24 +++++++++++++++--------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/packages/mermaid/src/mermaidAPI.ts b/packages/mermaid/src/mermaidAPI.ts index 0bfa561ccb..7dfa720bf1 100644 --- a/packages/mermaid/src/mermaidAPI.ts +++ b/packages/mermaid/src/mermaidAPI.ts @@ -157,6 +157,11 @@ const render = async function ( text = MAX_TEXTLENGTH_EXCEEDED_MSG; } + const iFrameID = 'i' + id; + const iFrameID_selector = '#' + iFrameID; + const enclosingDivID = 'd' + id; + const enclosingDivID_selector = '#' + enclosingDivID; + let root: any = select('body'); // In regular execution the container will be the div with a mermaid class @@ -171,7 +176,7 @@ const render = async function ( // in a sandboxed div const iframe = select(container) .append('iframe') - .attr('id', 'i' + id) + .attr('id', iFrameID) .attr('style', 'width: 100%; height: 100%;') .attr('sandbox', ''); // const iframeBody = ; @@ -183,7 +188,7 @@ const render = async function ( root .append('div') - .attr('id', 'd' + id) + .attr('id', enclosingDivID) .attr('style', 'font-family: ' + cnf.fontFamily) .append('svg') .attr('id', id) @@ -203,7 +208,7 @@ const render = async function ( // Remove previous tpm element if it exists let element; if (cnf.securityLevel === SECURITY_LVL_SANDBOX) { - element = document.querySelector('#i' + id); + element = document.querySelector(iFrameID_selector); } else { element = document.querySelector('#d' + id); } @@ -220,7 +225,7 @@ const render = async function ( // in a sandboxed div const iframe = select('body') .append('iframe') - .attr('id', 'i' + id) + .attr('id', iFrameID) .attr('style', 'width: 100%; height: 100%;') .attr('sandbox', ''); @@ -233,7 +238,7 @@ const render = async function ( // This is the temporary div root .append('div') - .attr('id', 'd' + id) + .attr('id', enclosingDivID) // this is the seed of the svg to be rendered .append('svg') .attr('id', id) @@ -255,7 +260,7 @@ const render = async function ( parseEncounteredException = error; } // Get the tmp element containing the the svg - const element = root.select('#d' + id).node(); + const element = root.select(enclosingDivID_selector).node(); const graphType = diag.type; // insert inline style into svg @@ -333,7 +338,7 @@ const render = async function ( root.select(`[id="${id}"]`).selectAll('foreignobject > *').attr('xmlns', XMLNS_XHTML_STD); // Fix for when the base tag is used - let svgCode = root.select('#d' + id).node().innerHTML; + let svgCode = root.select(enclosingDivID_selector).node().innerHTML; log.debug('cnf.arrowMarkerAbsolute', cnf.arrowMarkerAbsolute); if (!evaluate(cnf.arrowMarkerAbsolute) && cnf.securityLevel !== SECURITY_LVL_SANDBOX) { @@ -346,7 +351,7 @@ const render = async function ( svgCode = svgCode.replace(/
/g, '
'); if (cnf.securityLevel === SECURITY_LVL_SANDBOX) { - const svgEl = root.select('#d' + id + ' svg').node(); + const svgEl = root.select(enclosingDivID_selector + ' svg').node(); const width = IFRAME_WIDTH; let height = IFRAME_HEIGHT; if (svgEl) { @@ -387,7 +392,8 @@ const render = async function ( } attachFunctions(); - const tmpElementSelector = cnf.securityLevel === SECURITY_LVL_SANDBOX ? '#i' + id : '#d' + id; + const tmpElementSelector = + cnf.securityLevel === SECURITY_LVL_SANDBOX ? iFrameID_selector : enclosingDivID_selector; const node = select(tmpElementSelector).node(); if (node && 'remove' in node) { node.remove(); From 2e86031f0038aac5d797ddb5fae9b755fb761d4c Mon Sep 17 00:00:00 2001 From: "Ashley Engelund (weedySeaDragon @ github)" Date: Sat, 15 Oct 2022 13:27:15 -0700 Subject: [PATCH 04/18] comment the main steps (prepare to break into functions that can be tested) --- packages/mermaid/src/mermaidAPI.ts | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/packages/mermaid/src/mermaidAPI.ts b/packages/mermaid/src/mermaidAPI.ts index 7dfa720bf1..0e6aacfc23 100644 --- a/packages/mermaid/src/mermaidAPI.ts +++ b/packages/mermaid/src/mermaidAPI.ts @@ -142,7 +142,10 @@ const render = async function ( addDiagrams(); configApi.reset(); + + // clean up text CRLFs text = text.replace(/\r\n?/g, '\n'); // parser problems on CRLF ignore all CR and leave LF;; + const graphInit = utils.detectInit(text); if (graphInit) { directiveSanitizer(graphInit); @@ -164,6 +167,9 @@ const render = async function ( let root: any = select('body'); + // ------------------------------------------------------------------------------- + // Define the root d3 node + // In regular execution the container will be the div with a mermaid class if (typeof container !== 'undefined') { // A container was provided by the caller. Clear the inner HTML if there is any @@ -247,8 +253,13 @@ const render = async function ( .append('g'); } + // ------------------------------------------------------------------------------- + // text = encodeEntities(text); + // ------------------------------------------------------------------------------- + // Create the diagram + // Important that we do not create the diagram until after the directives have been included let diag; let parseEncounteredException; @@ -259,10 +270,14 @@ const render = async function ( diag = new Diagram('error'); parseEncounteredException = error; } + // Get the tmp element containing the the svg const element = root.select(enclosingDivID_selector).node(); const graphType = diag.type; + // ------------------------------------------------------------------------------- + // Create and insert the styles (user styles, theme styles, config styles) + // insert inline style into svg const svg = element.firstChild; const firstChild = svg.firstChild; @@ -328,6 +343,8 @@ const render = async function ( style1.innerHTML = `#${id} ` + rules; svg.insertBefore(style1, firstChild); + // ------------------------------------------------------------------------------- + // Draw the diagram with the renderer try { await diag.renderer.draw(text, id, pkg.version, diag); } catch (e) { @@ -335,6 +352,8 @@ const render = async function ( throw e; } + // ------------------------------------------------------------------------------- + // Clean up SVG code root.select(`[id="${id}"]`).selectAll('foreignobject > *').attr('xmlns', XMLNS_XHTML_STD); // Fix for when the base tag is used @@ -350,6 +369,8 @@ const render = async function ( // Fix for when the br tag is used svgCode = svgCode.replace(/
/g, '
'); + // ------------------------------------------------------------------------------- + // Inser svgCode into an iFrame if we are sandboxed if (cnf.securityLevel === SECURITY_LVL_SANDBOX) { const svgEl = root.select(enclosingDivID_selector + ' svg').node(); const width = IFRAME_WIDTH; @@ -364,6 +385,8 @@ const render = async function ( `; } else { if (cnf.securityLevel !== SECURITY_LVL_LOOSE) { + // ------------------------------------------------------------------------------- + // Sanitize the svgCode using DOMPurify svgCode = DOMPurify.sanitize(svgCode, { ADD_TAGS: [DOMPURIFY_TAGS], ADD_ATTR: [DOMPURIFY_ATTR], @@ -371,6 +394,8 @@ const render = async function ( } } + // ------------------------------------------------------------------------------- + // Do any callbacks (cb = callback) if (typeof cb !== 'undefined') { switch (graphType) { case 'flowchart': @@ -392,6 +417,8 @@ const render = async function ( } attachFunctions(); + // ------------------------------------------------------------------------------- + // Remove the temporary element if appropriate const tmpElementSelector = cnf.securityLevel === SECURITY_LVL_SANDBOX ? iFrameID_selector : enclosingDivID_selector; const node = select(tmpElementSelector).node(); From 5aff154740c756fa2fdef4ace1ef46a58f53cb09 Mon Sep 17 00:00:00 2001 From: "Ashley Engelund (weedySeaDragon @ github)" Date: Sat, 15 Oct 2022 13:43:40 -0700 Subject: [PATCH 05/18] more meaningful var names; move related lines together; const idSelector --- packages/mermaid/src/mermaidAPI.ts | 95 +++++++++++++++--------------- 1 file changed, 48 insertions(+), 47 deletions(-) diff --git a/packages/mermaid/src/mermaidAPI.ts b/packages/mermaid/src/mermaidAPI.ts index 0e6aacfc23..dca3a02de9 100644 --- a/packages/mermaid/src/mermaidAPI.ts +++ b/packages/mermaid/src/mermaidAPI.ts @@ -124,42 +124,35 @@ export const decodeEntities = function (text: string): string { * }); * ``` * - * @param {string} id The id of the element to be rendered - * @param {string} text The graph definition + * @param {string} id The id for the SVG element (the element to be rendered) + * @param {string} text The text for the graph definition * @param {(svgCode: string, bindFunctions?: (element: Element) => void) => void} cb Callback which * is called after rendering is finished with the svg code as inparam. - * @param {Element} container Selector to element in which a div with the graph temporarily will be - * inserted. If one is provided a hidden div will be inserted in the body of the page instead. The - * element will be removed when rendering is completed. + * @param {Element} svgContainingElement HTML element where the svg will be inserted. (Is usually element with the .mermaid class) + * inserted. If no svgContainingElement is provided then the SVG element will be appended to the body. * @returns {void} */ const render = async function ( id: string, text: string, cb: (svgCode: string, bindFunctions?: (element: Element) => void) => void, - container?: Element + svgContainingElement?: Element ): Promise { addDiagrams(); configApi.reset(); - - // clean up text CRLFs - text = text.replace(/\r\n?/g, '\n'); // parser problems on CRLF ignore all CR and leave LF;; - - const graphInit = utils.detectInit(text); - if (graphInit) { - directiveSanitizer(graphInit); - configApi.addDirective(graphInit); - } - const cnf = configApi.getConfig(); - - log.debug(cnf); + const config = configApi.getConfig(); + log.debug(config); // Check the maximum allowed text size - if (text.length > cnf.maxTextSize!) { + if (text.length > config.maxTextSize!) { text = MAX_TEXTLENGTH_EXCEEDED_MSG; } + // clean up text CRLFs + text = text.replace(/\r\n?/g, '\n'); // parser problems on CRLF ignore all CR and leave LF;; + + const idSelector = '#' + id; const iFrameID = 'i' + id; const iFrameID_selector = '#' + iFrameID; const enclosingDivID = 'd' + id; @@ -170,17 +163,17 @@ const render = async function ( // ------------------------------------------------------------------------------- // Define the root d3 node - // In regular execution the container will be the div with a mermaid class - if (typeof container !== 'undefined') { - // A container was provided by the caller. Clear the inner HTML if there is any - if (container) { - container.innerHTML = ''; + // In regular execution the svgContainingElement will be the element with a mermaid class + if (typeof svgContainingElement !== 'undefined') { + // A svgContainingElement was provided by the caller. Clear the inner HTML if there is any + if (svgContainingElement) { + svgContainingElement.innerHTML = ''; } - if (cnf.securityLevel === SECURITY_LVL_SANDBOX) { + if (config.securityLevel === SECURITY_LVL_SANDBOX) { // IF we are in sandboxed mode, we do everyting mermaid related // in a sandboxed div - const iframe = select(container) + const iframe = select(svgContainingElement) .append('iframe') .attr('id', iFrameID) .attr('style', 'width: 100%; height: 100%;') @@ -189,13 +182,13 @@ const render = async function ( root = select(iframe.nodes()[0]!.contentDocument!.body); root.node().style.margin = 0; } else { - root = select(container); + root = select(svgContainingElement); } root .append('div') .attr('id', enclosingDivID) - .attr('style', 'font-family: ' + cnf.fontFamily) + .attr('style', 'font-family: ' + config.fontFamily) .append('svg') .attr('id', id) .attr('width', '100%') @@ -203,7 +196,7 @@ const render = async function ( .attr('xmlns:xlink', XMLNS_XLINK_STD) .append('g'); } else { - // No container was provided + // No svgContainingElement was provided // If there is an existing element with the id, we remove it // this likely a previously rendered diagram const existingSvg = document.getElementById(id); @@ -211,12 +204,12 @@ const render = async function ( existingSvg.remove(); } - // Remove previous tpm element if it exists + // Remove previous temporary element if it exists let element; - if (cnf.securityLevel === SECURITY_LVL_SANDBOX) { + if (config.securityLevel === SECURITY_LVL_SANDBOX) { element = document.querySelector(iFrameID_selector); } else { - element = document.querySelector('#d' + id); + element = document.querySelector(enclosingDivID_selector); } if (element) { @@ -226,7 +219,7 @@ const render = async function ( // Add the tmp div used for rendering with the id `d${id}` // d+id it will contain a svg with the id "id" - if (cnf.securityLevel === SECURITY_LVL_SANDBOX) { + if (config.securityLevel === SECURITY_LVL_SANDBOX) { // IF we are in sandboxed mode, we do everyting mermaid related // in a sandboxed div const iframe = select('body') @@ -263,6 +256,14 @@ const render = async function ( // Important that we do not create the diagram until after the directives have been included let diag; let parseEncounteredException; + + // Add Directives (Must do this before creating the diagram.) + const graphInit = utils.detectInit(text); + if (graphInit) { + directiveSanitizer(graphInit); + configApi.addDirective(graphInit); + } + try { // diag = new Diagram(text); diag = await getDiagramFromText(text); @@ -286,22 +287,22 @@ const render = async function ( // user provided theme CSS // If you add more configuration driven data into the user styles make sure that the value is // sanitized bye the santiizeCSS function - if (cnf.themeCSS !== undefined) { - userStyles += `\n${cnf.themeCSS}`; + if (config.themeCSS !== undefined) { + userStyles += `\n${config.themeCSS}`; } // user provided theme CSS - if (cnf.fontFamily !== undefined) { - userStyles += `\n:root { --mermaid-font-family: ${cnf.fontFamily}}`; + if (config.fontFamily !== undefined) { + userStyles += `\n:root { --mermaid-font-family: ${config.fontFamily}}`; } // user provided theme CSS - if (cnf.altFontFamily !== undefined) { - userStyles += `\n:root { --mermaid-alt-font-family: ${cnf.altFontFamily}}`; + if (config.altFontFamily !== undefined) { + userStyles += `\n:root { --mermaid-alt-font-family: ${config.altFontFamily}}`; } // classDef if (graphType === 'flowchart' || graphType === 'flowchart-v2' || graphType === 'graph') { const classes: any = flowRenderer.getClasses(text, diag); - const htmlLabels = cnf.htmlLabels || cnf.flowchart?.htmlLabels; + const htmlLabels = config.htmlLabels || config.flowchart?.htmlLabels; for (const className in classes) { if (htmlLabels) { userStyles += `\n.${className} > * { ${classes[className].styles.join( @@ -337,10 +338,10 @@ const render = async function ( const stylis = (selector: string, styles: string) => serialize(compile(`${selector}{${styles}}`), stringify); - const rules = stylis(`#${id}`, getStyles(graphType, userStyles, cnf.themeVariables)); + const rules = stylis(`${idSelector}`, getStyles(graphType, userStyles, config.themeVariables)); const style1 = document.createElement('style'); - style1.innerHTML = `#${id} ` + rules; + style1.innerHTML = `${idSelector} ` + rules; svg.insertBefore(style1, firstChild); // ------------------------------------------------------------------------------- @@ -359,8 +360,8 @@ const render = async function ( // Fix for when the base tag is used let svgCode = root.select(enclosingDivID_selector).node().innerHTML; - log.debug('cnf.arrowMarkerAbsolute', cnf.arrowMarkerAbsolute); - if (!evaluate(cnf.arrowMarkerAbsolute) && cnf.securityLevel !== SECURITY_LVL_SANDBOX) { + log.debug('config.arrowMarkerAbsolute', config.arrowMarkerAbsolute); + if (!evaluate(config.arrowMarkerAbsolute) && config.securityLevel !== SECURITY_LVL_SANDBOX) { svgCode = svgCode.replace(/marker-end="url\(.*?#/g, 'marker-end="url(#', 'g'); } @@ -371,7 +372,7 @@ const render = async function ( // ------------------------------------------------------------------------------- // Inser svgCode into an iFrame if we are sandboxed - if (cnf.securityLevel === SECURITY_LVL_SANDBOX) { + if (config.securityLevel === SECURITY_LVL_SANDBOX) { const svgEl = root.select(enclosingDivID_selector + ' svg').node(); const width = IFRAME_WIDTH; let height = IFRAME_HEIGHT; @@ -384,7 +385,7 @@ const render = async function ( ${IFRAME_NOT_SUPPORTED_MSG} `; } else { - if (cnf.securityLevel !== SECURITY_LVL_LOOSE) { + if (config.securityLevel !== SECURITY_LVL_LOOSE) { // ------------------------------------------------------------------------------- // Sanitize the svgCode using DOMPurify svgCode = DOMPurify.sanitize(svgCode, { @@ -420,7 +421,7 @@ const render = async function ( // ------------------------------------------------------------------------------- // Remove the temporary element if appropriate const tmpElementSelector = - cnf.securityLevel === SECURITY_LVL_SANDBOX ? iFrameID_selector : enclosingDivID_selector; + config.securityLevel === SECURITY_LVL_SANDBOX ? iFrameID_selector : enclosingDivID_selector; const node = select(tmpElementSelector).node(); if (node && 'remove' in node) { node.remove(); From a26673c59a9fcc808d4ea32dcaf9086c753984c5 Mon Sep 17 00:00:00 2001 From: "Ashley Engelund (weedySeaDragon @ github)" Date: Sat, 15 Oct 2022 13:55:00 -0700 Subject: [PATCH 06/18] const isSandboxed, isLooseSecurityLevel, fontFamily; a few more CONSTs --- packages/mermaid/src/mermaidAPI.ts | 59 ++++++++++++++++++++---------- 1 file changed, 39 insertions(+), 20 deletions(-) diff --git a/packages/mermaid/src/mermaidAPI.ts b/packages/mermaid/src/mermaidAPI.ts index dca3a02de9..5a37937870 100644 --- a/packages/mermaid/src/mermaidAPI.ts +++ b/packages/mermaid/src/mermaidAPI.ts @@ -45,6 +45,7 @@ const XMLNS_XLINK_STD = 'http://www.w3.org/1999/xlink'; // ------------------------------ // iFrame +const SANDBOX_IFRAME_STYLE = 'width: 100%; height: 100%;'; const IFRAME_WIDTH = '100%'; const IFRAME_HEIGHT = '100%'; const IFRAME_STYLES = 'border:0;margin:0;'; @@ -52,9 +53,11 @@ const IFRAME_BODY_STYLE = 'margin:0'; const IFRAME_SANDBOX_OPTS = 'allow-top-navigation-by-user-activation allow-popups'; const IFRAME_NOT_SUPPORTED_MSG = 'The “iframe” tag is not supported by your browser.'; -const DOMPURIFY_TAGS = 'foreignobject'; -const DOMPURIFY_ATTR = 'dominant-baseline'; -// -------------------------------------------------------------------------------- +// DOMPurify settings for svgCode +const DOMPURE_TAGS = ['foreignobject']; +const DOMPURE_ATTR = ['dominant-baseline']; + +// ---------------------------------------------------------------------------- /** * @param text @@ -66,6 +69,11 @@ function parse(text: string, parseError?: ParseErrorFunction): boolean { return diagram.parse(text, parseError); } +/** + * + * @param {string} text - text to be encoded + * @returns {string} + */ export const encodeEntities = function (text: string): string { let txt = text; @@ -92,6 +100,11 @@ export const encodeEntities = function (text: string): string { return txt; }; +/** + * + * @param {string} text - text to be decoded + * @returns {string} + */ export const decodeEntities = function (text: string): string { let txt = text; @@ -160,6 +173,11 @@ const render = async function ( let root: any = select('body'); + const isSandboxed = config.securityLevel === SECURITY_LVL_SANDBOX; + const isLooseSecurityLevel = config.securityLevel === SECURITY_LVL_LOOSE; + + const fontFamily = config.fontFamily; + // ------------------------------------------------------------------------------- // Define the root d3 node @@ -170,13 +188,13 @@ const render = async function ( svgContainingElement.innerHTML = ''; } - if (config.securityLevel === SECURITY_LVL_SANDBOX) { + if (isSandboxed) { // IF we are in sandboxed mode, we do everyting mermaid related // in a sandboxed div const iframe = select(svgContainingElement) .append('iframe') .attr('id', iFrameID) - .attr('style', 'width: 100%; height: 100%;') + .attr('style', SANDBOX_IFRAME_STYLE) .attr('sandbox', ''); // const iframeBody = ; root = select(iframe.nodes()[0]!.contentDocument!.body); @@ -188,7 +206,7 @@ const render = async function ( root .append('div') .attr('id', enclosingDivID) - .attr('style', 'font-family: ' + config.fontFamily) + .attr('style', 'font-family: ' + fontFamily) .append('svg') .attr('id', id) .attr('width', '100%') @@ -206,7 +224,7 @@ const render = async function ( // Remove previous temporary element if it exists let element; - if (config.securityLevel === SECURITY_LVL_SANDBOX) { + if (isSandboxed) { element = document.querySelector(iFrameID_selector); } else { element = document.querySelector(enclosingDivID_selector); @@ -219,13 +237,12 @@ const render = async function ( // Add the tmp div used for rendering with the id `d${id}` // d+id it will contain a svg with the id "id" - if (config.securityLevel === SECURITY_LVL_SANDBOX) { - // IF we are in sandboxed mode, we do everyting mermaid related - // in a sandboxed div + if (isSandboxed) { + // IF we are in sandboxed mode, we do everything mermaid relate in a (sandboxed) iFrame const iframe = select('body') .append('iframe') .attr('id', iFrameID) - .attr('style', 'width: 100%; height: 100%;') + .attr('style', SANDBOX_IFRAME_STYLE) .attr('sandbox', ''); root = select(iframe.nodes()[0]!.contentDocument!.body); @@ -291,8 +308,8 @@ const render = async function ( userStyles += `\n${config.themeCSS}`; } // user provided theme CSS - if (config.fontFamily !== undefined) { - userStyles += `\n:root { --mermaid-font-family: ${config.fontFamily}}`; + if (fontFamily !== undefined) { + userStyles += `\n:root { --mermaid-font-family: ${fontFamily}}`; } // user provided theme CSS if (config.altFontFamily !== undefined) { @@ -371,26 +388,29 @@ const render = async function ( svgCode = svgCode.replace(/
/g, '
'); // ------------------------------------------------------------------------------- - // Inser svgCode into an iFrame if we are sandboxed - if (config.securityLevel === SECURITY_LVL_SANDBOX) { + + if (isSandboxed) { const svgEl = root.select(enclosingDivID_selector + ' svg').node(); const width = IFRAME_WIDTH; let height = IFRAME_HEIGHT; + + // set the svg element height to px if (svgEl) { height = svgEl.viewBox.baseVal.height + 'px'; } + // Insert iFrame code into svg code svgCode = ``; } else { - if (config.securityLevel !== SECURITY_LVL_LOOSE) { + if (isLooseSecurityLevel) { // ------------------------------------------------------------------------------- // Sanitize the svgCode using DOMPurify svgCode = DOMPurify.sanitize(svgCode, { - ADD_TAGS: [DOMPURIFY_TAGS], - ADD_ATTR: [DOMPURIFY_ATTR], + ADD_TAGS: DOMPURE_TAGS, + ADD_ATTR: DOMPURE_ATTR, }); } } @@ -420,8 +440,7 @@ const render = async function ( // ------------------------------------------------------------------------------- // Remove the temporary element if appropriate - const tmpElementSelector = - config.securityLevel === SECURITY_LVL_SANDBOX ? iFrameID_selector : enclosingDivID_selector; + const tmpElementSelector = isSandboxed ? iFrameID_selector : enclosingDivID_selector; const node = select(tmpElementSelector).node(); if (node && 'remove' in node) { node.remove(); From d106d3d1b170e627f5c71f46e863712421c8399a Mon Sep 17 00:00:00 2001 From: "Ashley Engelund (weedySeaDragon @ github)" Date: Sat, 15 Oct 2022 19:14:56 -0700 Subject: [PATCH 07/18] add MockedD3.ts --- packages/mermaid/src/tests/MockedD3.ts | 119 +++++++++++++++++++++++++ 1 file changed, 119 insertions(+) create mode 100644 packages/mermaid/src/tests/MockedD3.ts diff --git a/packages/mermaid/src/tests/MockedD3.ts b/packages/mermaid/src/tests/MockedD3.ts new file mode 100644 index 0000000000..e86ba1285c --- /dev/null +++ b/packages/mermaid/src/tests/MockedD3.ts @@ -0,0 +1,119 @@ +/** + * This is a mocked/stubbed version of the d3 Selection type. Each of the main functions are all + * mocked (via vi.fn()) so you can track if they have been called, etc. + */ +export class MockedD3 { + public attribs = new Map(); + public id: string | undefined = ''; + _children: MockedD3[] = []; + + constructor(givenId = 'mock-id') { + this.id = givenId; + } + + /** Helpful utility during development/debugging. This is not a real d3 function */ + public listChildren(): string { + return this._children + .map((child) => { + return child.id; + }) + .join(', '); + } + + select = vi.fn().mockImplementation(({ select_str = '' }): MockedD3 => { + // Get the id from an argument string. if it is of the form [id='some-id'], strip off the + // surrounding id[..] + const stripSurroundRegexp = /\[id='(.*)'\]/; + const matchedSurrounds = select_str.match(stripSurroundRegexp); + const cleanId = matchedSurrounds ? matchedSurrounds[1] : select_str; + return new MockedD3(cleanId); + }); + + append = vi + .fn() + .mockImplementation(function (this: MockedD3, type: string, id = '' + '-appended'): MockedD3 { + const newMock = new MockedD3(id); + newMock.attribs.set('type', type); + this._children.push(newMock); + return newMock; + }); + + // NOTE: The d3 implementation allows for a selector ('beforeSelector' arg below). + // With this mocked implementation, we assume it will always refer to an node id + // and will always be of the form "#[id of the node to insert before]". + // To keep this simple, any leading '#' is removed and the resulting string is the node id searched. + insert = (type: string, beforeSelector?: string, id = this.id + '-inserted'): MockedD3 => { + const newMock = new MockedD3(id); + newMock.attribs.set('type', type); + if (beforeSelector === undefined) { + this._children.push(newMock); + } else { + const idOnly = beforeSelector[0] == '#' ? beforeSelector.substring(1) : beforeSelector; + const foundIndex = this._children.findIndex((child) => child.id === idOnly); + if (foundIndex < 0) this._children.push(newMock); + else this._children.splice(foundIndex, 0, newMock); + } + return newMock; + }; + + attr(attrName: string): null | undefined | string | number; + // attr(attrName: string, attrValue: string): MockedD3; + attr(attrName: string, attrValue?: string): null | undefined | string | number | MockedD3 { + if (arguments.length === 1) { + return this.attribs.get(attrName); + } else { + if (attrName === 'id') this.id = attrValue; // also set the id explicitly + if (attrValue !== undefined) this.attribs.set(attrName, attrValue); + return this; + } + } + + public lower(attrValue = '') { + this.attribs.set('lower', attrValue); + return this; + } + public style(attrValue = '') { + this.attribs.set('style', attrValue); + return this; + } + public text(attrValue = '') { + this.attribs.set('text', attrValue); + return this; + } + // NOTE: Arbitrarily returns an empty object. The return value could be something different with a mockReturnValue() or mockImplementation() + public node = vi.fn().mockReturnValue({}); + + nodes = vi.fn().mockImplementation(function (this: MockedD3): MockedD3[] { + return this._children; + }); + + // This will try to use attrs that have been set. + getBBox = () => { + const x = this.attribs.has('x') ? this.attribs.get('x') : 20; + const y = this.attribs.has('y') ? this.attribs.get('y') : 30; + const width = this.attribs.has('width') ? this.attribs.get('width') : 140; + const height = this.attribs.has('height') ? this.attribs.get('height') : 250; + return { + x: x, + y: y, + width: width, + height: height, + }; + }; + + // -------------------------------------------------------------------------------- + // The following functions are here for completeness. They simply return a vi.fn() + + insertBefore = vi.fn(); + curveBasis = vi.fn(); + curveBasisClosed = vi.fn(); + curveBasisOpen = vi.fn(); + curveLinear = vi.fn(); + curveLinearClosed = vi.fn(); + curveMonotoneX = vi.fn(); + curveMonotoneY = vi.fn(); + curveNatural = vi.fn(); + curveStep = vi.fn(); + curveStepAfter = vi.fn(); + curveStepBefore = vi.fn(); +} From a3b8c301e27f0d7b43372d76b1b75fae4bbfb567 Mon Sep 17 00:00:00 2001 From: "Ashley Engelund (weedySeaDragon @ github)" Date: Sat, 15 Oct 2022 19:15:59 -0700 Subject: [PATCH 08/18] functions and specs: createCssStyles, appendDivSvgG,cleanUpSvgCode, putIntoIFrame [for render] --- packages/mermaid/src/mermaidAPI.spec.js | 334 +++++++++++++++++++++- packages/mermaid/src/mermaidAPI.ts | 362 ++++++++++++++---------- 2 files changed, 543 insertions(+), 153 deletions(-) diff --git a/packages/mermaid/src/mermaidAPI.spec.js b/packages/mermaid/src/mermaidAPI.spec.js index 35473d1bfe..f697891a43 100644 --- a/packages/mermaid/src/mermaidAPI.spec.js +++ b/packages/mermaid/src/mermaidAPI.spec.js @@ -1,10 +1,39 @@ 'use strict'; +import { vi } from 'vitest'; + import mermaid from './mermaid'; import mermaidAPI from './mermaidAPI'; -import { encodeEntities, decodeEntities } from './mermaidAPI'; +import { + encodeEntities, + decodeEntities, + createCssStyles, + appendDivSvgG, + cleanUpSvgCode, + putIntoIFrame, +} from './mermaidAPI'; import assignWithDepth from './assignWithDepth'; +// To mock a module, first define a mock for it, then import it. Be sure the path points to exactly the same file as is imported in mermaidAPI (the module being tested) +vi.mock('./styles', () => { + return { + addStylesForDiagram: vi.fn(), + default: vi.fn().mockReturnValue(' .userStyle { font-weight:bold; }'), + }; +}); +import getStyles from './styles'; + +vi.mock('stylis', () => { + return { + stringify: vi.fn(), + compile: vi.fn(), + serialize: vi.fn().mockReturnValue('stylis serialized css'), + }; +}); +import { compile, serialize } from 'stylis'; + +import { MockedD3 } from './tests/MockedD3'; + describe('when using mermaidAPI and ', function () { describe('encodeEntities', () => { it('removes the ending ; from style [text1]:[optional word]#[text2]; with ', () => { @@ -73,6 +102,309 @@ describe('when using mermaidAPI and ', function () { }); }); + describe('cleanUpSvgCode', () => { + it('replaces marker end URLs with just the anchor if not sandboxed and not useMarkerUrls', () => { + const markerFullUrl = 'marker-end="url(some-URI#that)"'; + let useArrowMarkerUrls = false; + let isSandboxed = false; + let result = cleanUpSvgCode(markerFullUrl, isSandboxed, useArrowMarkerUrls); + expect(result).toEqual('marker-end="url(#that)"'); + + useArrowMarkerUrls = true; + result = cleanUpSvgCode(markerFullUrl, isSandboxed, useArrowMarkerUrls); + expect(result).toEqual(markerFullUrl); // not changed + + useArrowMarkerUrls = false; + isSandboxed = true; + result = cleanUpSvgCode(markerFullUrl, isSandboxed, useArrowMarkerUrls); + expect(result).toEqual(markerFullUrl); // not changed + }); + + it('decodesEntities', () => { + const result = cleanUpSvgCode('¶ß brrrr', true, true); + expect(result).toEqual('; brrrr'); + }); + + it('replaces old style br tags with new style', () => { + const result = cleanUpSvgCode('
brrrr
', true, true); + expect(result).toEqual('
brrrr
'); + }); + }); + + describe('putIntoIFrame', () => { + const inputSvgCode = 'this is the SVG code'; + + it('uses the default SVG iFrame height is used if no svgElement given', () => { + const result = putIntoIFrame(inputSvgCode); + expect(result).toMatch(/style="(.*)height:100%(.*);"/); + }); + it('default style attributes are: width: 100%, height: 100%, border: 0, margin: 0', () => { + const result = putIntoIFrame(inputSvgCode); + expect(result).toMatch(/style="(.*)width:100%(.*);"/); + expect(result).toMatch(/style="(.*)height:100%(.*);"/); + expect(result).toMatch(/style="(.*)border:0(.*);"/); + expect(result).toMatch(/style="(.*)margin:0(.*);"/); + }); + it('sandbox="allow-top-navigation-by-user-activation allow-popups">', () => { + const result = putIntoIFrame(inputSvgCode); + expect(result).toMatch(/sandbox="allow-top-navigation-by-user-activation allow-popups">/); + }); + it('msg shown is "The "iframe" tag is not supported by your browser.\\n" if iFrames are not supported in the browser', () => { + const result = putIntoIFrame(inputSvgCode); + expect(result).toMatch(/\s*The "iframe" tag is not supported by your browser\./); + }); + + it('sets src to base64 version of svgCode', () => { + const base64encodedSrc = btoa('' + inputSvgCode + ''); + const expectedRegExp = new RegExp('src="data:text/html;base64,' + base64encodedSrc + '"'); + + const result = putIntoIFrame(inputSvgCode); + expect(result).toMatch(expectedRegExp); + }); + + it('uses the height and appends px from the svgElement given', () => { + const faux_svgElement = { + viewBox: { + baseVal: { + height: 42, + }, + }, + }; + + const result = putIntoIFrame(inputSvgCode, faux_svgElement); + expect(result).toMatch(/style="(.*)height:42px;/); + }); + }); + + const fauxParentNode = new MockedD3(); + const fauxEnclosingDiv = new MockedD3(); + const fauxSvgNode = new MockedD3(); + + describe('appendDivSvgG', () => { + const fauxGNode = new MockedD3(); + const parent_append_spy = vi.spyOn(fauxParentNode, 'append').mockReturnValue(fauxEnclosingDiv); + const div_append_spy = vi.spyOn(fauxEnclosingDiv, 'append').mockReturnValue(fauxSvgNode); + const div_attr_spy = vi.spyOn(fauxEnclosingDiv, 'attr').mockReturnValue(fauxEnclosingDiv); + const svg_append_spy = vi.spyOn(fauxSvgNode, 'append').mockReturnValue(fauxGNode); + const svg_attr_spy = vi.spyOn(fauxSvgNode, 'attr').mockReturnValue(fauxSvgNode); + + it('appends a div node', () => { + appendDivSvgG(fauxParentNode, 'theId'); + expect(parent_append_spy).toHaveBeenCalledWith('div'); + expect(div_append_spy).toHaveBeenCalledWith('svg'); + }); + it('the id for the div is "d" with the id appended', () => { + appendDivSvgG(fauxParentNode, 'theId', 'dtheId'); + expect(div_attr_spy).toHaveBeenCalledWith('id', 'dtheId'); + }); + + it('sets the style for the div if one is given', () => { + appendDivSvgG(fauxParentNode, 'theId', 'dtheId', 'given div style', 'given x link'); + expect(div_attr_spy).toHaveBeenCalledWith('style', 'given div style'); + }); + + it('appends a svg node to the div node', () => { + appendDivSvgG(fauxParentNode, 'theId', 'dtheId'); + expect(div_attr_spy).toHaveBeenCalledWith('id', 'dtheId'); + }); + it('sets the svg width to 100%', () => { + appendDivSvgG(fauxParentNode, 'theId'); + expect(svg_attr_spy).toHaveBeenCalledWith('width', '100%'); + }); + it('the svg id is the id', () => { + appendDivSvgG(fauxParentNode, 'theId', 'dtheId'); + expect(svg_attr_spy).toHaveBeenCalledWith('id', 'theId'); + }); + it('the svg xml namespace is the 2000 standard', () => { + appendDivSvgG(fauxParentNode, 'theId'); + expect(svg_attr_spy).toHaveBeenCalledWith('xmlns', 'http://www.w3.org/2000/svg'); + }); + it('sets the svg xlink if one is given', () => { + appendDivSvgG(fauxParentNode, 'theId', 'dtheId', 'div style', 'given x link'); + expect(svg_attr_spy).toHaveBeenCalledWith('xmlns:xlink', 'given x link'); + }); + it('appends a g (group) node to the svg node', () => { + appendDivSvgG(fauxParentNode, 'theId', 'dtheId'); + expect(svg_append_spy).toHaveBeenCalledWith('g'); + }); + it('returns the given parentRoot d3 nodes', () => { + expect(appendDivSvgG(fauxParentNode, 'theId', 'dtheId')).toEqual(fauxParentNode); + }); + }); + + describe('createCssStyles', () => { + const serif = 'serif'; + const sansSerif = 'sans-serif'; + const mocked_config_with_htmlLabels = { + themeCSS: 'default', + fontFamily: serif, + altFontFamily: sansSerif, + htmlLabels: '', + }; + + it('gets the cssStyles from the theme', () => { + const styles = createCssStyles(mocked_config_with_htmlLabels, 'graphType', null); + expect(styles).toMatch(/^\ndefault(.*)/); + }); + it('gets the fontFamily from the config', () => { + const styles = createCssStyles(mocked_config_with_htmlLabels, 'graphType', null); + expect(styles).toMatch(/(.*)\n:root \{ --mermaid-font-family: serif(.*)/); + }); + it('gets the alt fontFamily from the config', () => { + const styles = createCssStyles(mocked_config_with_htmlLabels, 'graphType', null); + expect(styles).toMatch(/(.*)\n:root \{ --mermaid-alt-font-family: sans-serif(.*)/); + }); + + describe('there are some classDefs', () => { + const classDef1 = { id: 'classDef1', styles: ['style1-1', 'style1-2'], textStyles: [] }; + const classDef2 = { id: 'classDef2', styles: [], textStyles: ['textStyle2-1'] }; + const classDef3 = { id: 'classDef3', textStyles: ['textStyle3-1', 'textStyle3-2'] }; + const classDefs = [classDef1, classDef2, classDef3]; + + describe('the graph supports classDefs', () => { + const graphType = 'flowchart-v2'; + + const REGEXP_SPECIALS = ['^', '$', '?', '(', '{', '[', '.', '*', '!']; + + // prefix any special RegExp characters in the given string with a \ so we can use the literal character in a RegExp + function escapeForRegexp(str) { + const strChars = str.split(''); // split into array of every char + const strEscaped = strChars.map((char) => { + if (REGEXP_SPECIALS.includes(char)) return `\\${char}`; + else return char; + }); + return strEscaped.join(''); + } + function expect_styles_matchesHtmlElements(styles, htmlElement) { + expect(styles).toMatch( + new RegExp( + `\\.classDef1 ${escapeForRegexp( + htmlElement + )} \\{ style1-1 !important; style1-2 !important; }` + ) + ); + // no CSS styles are created if there are no styles for a classDef + expect(styles).not.toMatch( + new RegExp(`\\.classDef2 ${escapeForRegexp(htmlElement)} \\{ style(.*) !important; }`) + ); + expect(styles).not.toMatch( + new RegExp(`\\.classDef3 ${escapeForRegexp(htmlElement)} \\{ style(.*) !important; }`) + ); + } + + function expect_textStyles_matchesHtmlElements(styles, htmlElement) { + expect(styles).toMatch( + new RegExp( + `\\.classDef2 ${escapeForRegexp(htmlElement)} \\{ textStyle2-1 !important; }` + ) + ); + expect(styles).toMatch( + new RegExp( + `\\.classDef3 ${escapeForRegexp( + htmlElement + )} \\{ textStyle3-1 !important; textStyle3-2 !important; }` + ) + ); + + // no CSS styles are created if there are no textStyles for a classDef + expect(styles).not.toMatch( + new RegExp( + `\\.classDef1 ${escapeForRegexp(htmlElement)} \\{ textStyle(.*) !important; }` + ) + ); + } + + function expect_correct_styles_with_htmlElements(mocked_config) { + describe('creates styles for "> *" and "span" elements', () => { + const htmlElements = ['> *', 'span']; + + it('creates CSS styles for every style and textStyle in every classDef', () => { + // @todo TODO Can't figure out how to spy on the cssImportantStyles method. That would be a much better approach than manually checking the result + + const styles = createCssStyles(mocked_config, graphType, classDefs); + htmlElements.forEach((htmlElement) => { + expect_styles_matchesHtmlElements(styles, htmlElement); + }); + expect_textStyles_matchesHtmlElements(styles, 'tspan'); + }); + }); + } + + it('there are htmlLabels in the configuration', () => { + expect_correct_styles_with_htmlElements(mocked_config_with_htmlLabels); + }); + + it('there are flowchart.htmlLabels in the configuration', () => { + const mocked_config_flowchart_htmlLabels = { + themeCSS: 'default', + fontFamily: 'serif', + altFontFamily: 'sans-serif', + flowchart: { + htmlLabels: 'flowchart-htmlLables', + }, + }; + expect_correct_styles_with_htmlElements(mocked_config_flowchart_htmlLabels); + }); + + describe('no htmlLabels in the configuration', () => { + const mocked_config_no_htmlLabels = { + themeCSS: 'default', + fontFamily: 'serif', + altFontFamily: 'sans-serif', + }; + + describe('creates styles for shape elements "rect", "polygon", "ellipse", and "circle"', () => { + const htmlElements = ['rect', 'polygon', 'ellipse', 'circle']; + + it('creates CSS styles for every style and textStyle in every classDef', () => { + // @todo TODO Can't figure out how to spy on the cssImportantStyles method. That would be a much better approach than manually checking the result + + const styles = createCssStyles(mocked_config_no_htmlLabels, graphType, classDefs); + htmlElements.forEach((htmlElement) => { + expect_styles_matchesHtmlElements(styles, htmlElement); + }); + expect_textStyles_matchesHtmlElements(styles, 'tspan'); + }); + }); + }); + }); + }); + }); + + // describe('createUserStyles', () => { + // const mockConfig = { + // themeCSS: 'default', + // htmlLabels: 'htmlLabels', + // themeVariables: { fontFamily: 'serif' }, + // }; + // const classDef1 = { id: 'classDef1', styles: ['style1-1'], textStyles: [] }; + // + // it('gets the css styles created', () => { + // // @todo TODO if a single function in the module can be mocked, do it for createCssStyles and mock the results. + // + // createUserStyles(mockConfig, 'flowchart-v2', [classDef1], 'someId'); + // const expectedStyles = + // '\ndefault' + + // '\n.classDef1 > * { style1-1 !important; }' + + // '\n.classDef1 span { style1-1 !important; }'; + // expect(getStyles).toHaveBeenCalledWith('flowchart-v2', expectedStyles, { + // fontFamily: 'serif', + // }); + // }); + // + // it('calls getStyles to get css for all graph, user css styles, and config theme variables', () => { + // createUserStyles(mockConfig, 'someDiagram', null, 'someId'); + // expect(getStyles).toHaveBeenCalled(); + // }); + // + // it('returns the result of compiling, stringifying, and serializing the css code with stylis', () => { + // const result = createUserStyles(mockConfig, 'someDiagram', null, 'someId'); + // expect(compile).toHaveBeenCalled(); + // expect(serialize).toHaveBeenCalled(); + // expect(result).toEqual('stylis serialized css'); + // }); + // }); + describe('doing initialize ', function () { beforeEach(function () { document.body.innerHTML = ''; diff --git a/packages/mermaid/src/mermaidAPI.ts b/packages/mermaid/src/mermaidAPI.ts index 5a37937870..0165aaeff4 100644 --- a/packages/mermaid/src/mermaidAPI.ts +++ b/packages/mermaid/src/mermaidAPI.ts @@ -45,18 +45,27 @@ const XMLNS_XLINK_STD = 'http://www.w3.org/1999/xlink'; // ------------------------------ // iFrame -const SANDBOX_IFRAME_STYLE = 'width: 100%; height: 100%;'; const IFRAME_WIDTH = '100%'; const IFRAME_HEIGHT = '100%'; const IFRAME_STYLES = 'border:0;margin:0;'; const IFRAME_BODY_STYLE = 'margin:0'; const IFRAME_SANDBOX_OPTS = 'allow-top-navigation-by-user-activation allow-popups'; -const IFRAME_NOT_SUPPORTED_MSG = 'The “iframe” tag is not supported by your browser.'; +const IFRAME_NOT_SUPPORTED_MSG = 'The "iframe" tag is not supported by your browser.'; // DOMPurify settings for svgCode const DOMPURE_TAGS = ['foreignobject']; const DOMPURE_ATTR = ['dominant-baseline']; +// This is what is returned from getClasses(...) methods. +// It is slightly renamed to ..StyleClassDef instead of just ClassDef because "class" is a greatly ambiguous and overloaded word. +// It makes it clear we're working with a style class definition, even though defining the type is currently difficult. +// @ts-ignore This is an alias for a js construct used in diagrams. +type DiagramStyleClassDef = any; + +// This makes it clear that we're working with a d3 selected element of some kind, even though it's hard to specify the exact type. +// @ts-ignore Could replicate the type definition in d3. This also makes it possible to use the untyped info from the js diagram files. +type D3Element = any; + // ---------------------------------------------------------------------------- /** @@ -121,6 +130,174 @@ export const decodeEntities = function (text: string): string { return txt; }; +// append !important; to each cssClass followed by a final !important, all enclosed in { } +// +/** + * Create a CSS style that starts with the given class name, then the element, + * with an enclosing block that has each of the cssClasses followed by !important; + * @param {string} cssClass + * @param {string} element + * @param {string[]} cssClasses + * @returns {string} + */ +export const cssImportantStyles = ( + cssClass: string, + element: string, + cssClasses: string[] = [] +): string => { + return `\n.${cssClass} ${element} { ${cssClasses.join(' !important; ')} !important; }`; +}; + +/** + * Create the user styles + * + * @param {MermaidConfig} config + * @param {string} graphType + * @param {null | DiagramStyleClassDef[]} classDefs - the classDefs in the diagram text. Might be null if none were defined. Usually is the result of a call to getClasses(...) + * @returns {string} the string with all the user styles + */ +export const createCssStyles = ( + config: MermaidConfig, + graphType: string, + classDefs: DiagramStyleClassDef[] | null | undefined +): string => { + let cssStyles = ''; + + // user provided theme CSS info + // If you add more configuration driven data into the user styles make sure that the value is + // sanitized by the santizeCSS function @todo TODO where is this method? what should be used to replace it? refactor so that it's always sanitized + if (config.themeCSS !== undefined) cssStyles += `\n${config.themeCSS}`; + + if (config.fontFamily !== undefined) + cssStyles += `\n:root { --mermaid-font-family: ${config.fontFamily}}`; + + if (config.altFontFamily !== undefined) + cssStyles += `\n:root { --mermaid-alt-font-family: ${config.altFontFamily}}`; + + // classDefs defined in the diagram text + if (classDefs !== undefined && classDefs !== null && classDefs.length > 0) { + if (graphType === 'flowchart' || graphType === 'flowchart-v2' || graphType === 'graph') { + const htmlLabels = config.htmlLabels || config.flowchart?.htmlLabels; + + const cssHtmlElements = ['> *', 'span']; // @todo TODO make a constant + const cssShapeElements = ['rect', 'polygon', 'ellipse', 'circle']; // @todo TODO make a constant + + const cssElements = htmlLabels ? cssHtmlElements : cssShapeElements; + + // create the CSS styles needed for each styleClass definition and css element + for (const classId in classDefs) { + const styleClassDef = classDefs[classId]; + // create the css styles for each cssElement and the styles (only if there are styles) + if (styleClassDef['styles'] && styleClassDef['styles'].length > 0) { + cssElements.forEach((cssElement) => { + cssStyles += cssImportantStyles( + styleClassDef['id'], + cssElement, + styleClassDef['styles'] + ); + }); + } + // create the css styles for the tspan element and the text styles (only if there are textStyles) + if (styleClassDef['textStyles'] && styleClassDef['textStyles'].length > 0) { + cssStyles += cssImportantStyles( + styleClassDef['id'], + 'tspan', + styleClassDef['textStyles'] + ); + } + } + } + } + return cssStyles; +}; + +export const cleanUpSvgCode = ( + svgCode = '', + inSandboxMode: boolean, + useArrowMarkerUrls: boolean +): string => { + let cleanedUpSvg = svgCode; + + // Replace marker-end urls with just the # anchor (remove the preceding part of the URL) + if (!useArrowMarkerUrls && !inSandboxMode) { + cleanedUpSvg = cleanedUpSvg.replace(/marker-end="url\(.*?#/g, 'marker-end="url(#'); + } + + cleanedUpSvg = decodeEntities(cleanedUpSvg); + + // replace old br tags with newer style + cleanedUpSvg = cleanedUpSvg.replace(/
/g, '
'); + + return cleanedUpSvg; +}; + +/** + * Put the svgCode into an iFrame. Return the iFrame code + * + * @param {string} svgCode + * @param {D3Element} svgElement - the d3 node that has the current svgElement so we can get the height from it + * @returns {string} - the code with the iFrame that now contains the svgCode + * @todo TODO replace btoa(). Replace with buf.toString('base64')? + */ +export const putIntoIFrame = (svgCode = '', svgElement?: D3Element): string => { + let height = IFRAME_HEIGHT; // default iFrame height + if (svgElement) height = svgElement.viewBox.baseVal.height + 'px'; + const base64encodedSrc = btoa('' + svgCode + ''); + return ``; +}; + +/** + * Append an enclosing div, then svg, then g (group) to the d3 parentRoot. Set attributes. + * Only set the style attribute on the enclosing div if divStyle is given. + * Only set the xmlns:xlink attribute on svg if svgXlink is given. + * Return the last node appended + * + * @param {D3Element} parentRoot - the d3 node to append things to + * @param {string} id + * @param enclosingDivId + * @param {string} divStyle + * @param {string} svgXlink + * @returns {D3Element} - returns the parentRoot that had nodes appended + */ +export const appendDivSvgG = ( + parentRoot: D3Element, + id: string, + enclosingDivId: string, + divStyle?: string, + svgXlink?: string +): D3Element => { + const enclosingDiv = parentRoot.append('div'); + enclosingDiv.attr('id', enclosingDivId); + if (divStyle) enclosingDiv.attr('style', divStyle); + + const svgNode = enclosingDiv + .append('svg') + .attr('id', id) + .attr('width', '100%') + .attr('xmlns', XMLNS_SVG_STD); + if (svgXlink) svgNode.attr('xmlns:xlink', svgXlink); + + svgNode.append('g'); + return parentRoot; +}; + +/** Append an iFrame node to the given parentNode and set the id, style, and 'sandbox' attributes + * Return the appended iframe d3 node + * + * @param {D3Element} parentNode + * @param {string} iFrameId - id to use for the iFrame + * @returns {D3Element} the appended iframe d3 node + */ +function sandboxedIframe(parentNode: D3Element, iFrameId: string): D3Element { + return parentNode + .append('iframe') + .attr('id', iFrameId) + .attr('style', 'width: 100%; height: 100%;') + .attr('sandbox', ''); +} + /** * Function that renders an svg with a graph from a chart definition. Usage example below. * @@ -154,13 +331,19 @@ const render = async function ( addDiagrams(); configApi.reset(); + + // Add Directives. Must do this before getting the config and before creating the diagram. + const graphInit = utils.detectInit(text); + if (graphInit) { + directiveSanitizer(graphInit); + configApi.addDirective(graphInit); + } + const config = configApi.getConfig(); log.debug(config); // Check the maximum allowed text size - if (text.length > config.maxTextSize!) { - text = MAX_TEXTLENGTH_EXCEEDED_MSG; - } + if (text.length > config.maxTextSize!) text = MAX_TEXTLENGTH_EXCEEDED_MSG; // clean up text CRLFs text = text.replace(/\r\n?/g, '\n'); // parser problems on CRLF ignore all CR and leave LF;; @@ -183,44 +366,23 @@ const render = async function ( // In regular execution the svgContainingElement will be the element with a mermaid class if (typeof svgContainingElement !== 'undefined') { - // A svgContainingElement was provided by the caller. Clear the inner HTML if there is any - if (svgContainingElement) { - svgContainingElement.innerHTML = ''; - } + if (svgContainingElement) svgContainingElement.innerHTML = ''; if (isSandboxed) { - // IF we are in sandboxed mode, we do everyting mermaid related - // in a sandboxed div - const iframe = select(svgContainingElement) - .append('iframe') - .attr('id', iFrameID) - .attr('style', SANDBOX_IFRAME_STYLE) - .attr('sandbox', ''); - // const iframeBody = ; + // If we are in sandboxed mode, we do everything mermaid related in a (sandboxed )iFrame + const iframe = sandboxedIframe(select(svgContainingElement), iFrameID); root = select(iframe.nodes()[0]!.contentDocument!.body); root.node().style.margin = 0; } else { root = select(svgContainingElement); } - - root - .append('div') - .attr('id', enclosingDivID) - .attr('style', 'font-family: ' + fontFamily) - .append('svg') - .attr('id', id) - .attr('width', '100%') - .attr('xmlns', XMLNS_SVG_STD) - .attr('xmlns:xlink', XMLNS_XLINK_STD) - .append('g'); + appendDivSvgG(root, id, enclosingDivID, `font-family: ${fontFamily}`, XMLNS_XLINK_STD); } else { // No svgContainingElement was provided // If there is an existing element with the id, we remove it // this likely a previously rendered diagram const existingSvg = document.getElementById(id); - if (existingSvg) { - existingSvg.remove(); - } + if (existingSvg) existingSvg.remove(); // Remove previous temporary element if it exists let element; @@ -229,42 +391,22 @@ const render = async function ( } else { element = document.querySelector(enclosingDivID_selector); } + if (element) element.remove(); - if (element) { - element.remove(); - } - - // Add the tmp div used for rendering with the id `d${id}` - // d+id it will contain a svg with the id "id" + // Add the temporary div used for rendering with the enclosingDivID. + // This temporary div will contain a svg with the id == id if (isSandboxed) { - // IF we are in sandboxed mode, we do everything mermaid relate in a (sandboxed) iFrame - const iframe = select('body') - .append('iframe') - .attr('id', iFrameID) - .attr('style', SANDBOX_IFRAME_STYLE) - .attr('sandbox', ''); + // If we are in sandboxed mode, we do everything mermaid related in a (sandboxed) iFrame + const iframe = sandboxedIframe(select('body'), iFrameID); root = select(iframe.nodes()[0]!.contentDocument!.body); root.node().style.margin = 0; - } else { - root = select('body'); - } + } else root = select('body'); - // This is the temporary div - root - .append('div') - .attr('id', enclosingDivID) - // this is the seed of the svg to be rendered - .append('svg') - .attr('id', id) - .attr('width', '100%') - .attr('xmlns', XMLNS_SVG_STD) - .append('g'); + appendDivSvgG(root, id, enclosingDivID); } - // ------------------------------------------------------------------------------- - // text = encodeEntities(text); // ------------------------------------------------------------------------------- @@ -274,13 +416,6 @@ const render = async function ( let diag; let parseEncounteredException; - // Add Directives (Must do this before creating the diagram.) - const graphInit = utils.detectInit(text); - if (graphInit) { - directiveSanitizer(graphInit); - configApi.addDirective(graphInit); - } - try { // diag = new Diagram(text); diag = await getDiagramFromText(text); @@ -289,7 +424,7 @@ const render = async function ( parseEncounteredException = error; } - // Get the tmp element containing the the svg + // Get the tmp div element containing the svg const element = root.select(enclosingDivID_selector).node(); const graphType = diag.type; @@ -300,62 +435,12 @@ const render = async function ( const svg = element.firstChild; const firstChild = svg.firstChild; - let userStyles = ''; - // user provided theme CSS - // If you add more configuration driven data into the user styles make sure that the value is - // sanitized bye the santiizeCSS function - if (config.themeCSS !== undefined) { - userStyles += `\n${config.themeCSS}`; - } - // user provided theme CSS - if (fontFamily !== undefined) { - userStyles += `\n:root { --mermaid-font-family: ${fontFamily}}`; - } - // user provided theme CSS - if (config.altFontFamily !== undefined) { - userStyles += `\n:root { --mermaid-alt-font-family: ${config.altFontFamily}}`; - } - - // classDef - if (graphType === 'flowchart' || graphType === 'flowchart-v2' || graphType === 'graph') { - const classes: any = flowRenderer.getClasses(text, diag); - const htmlLabels = config.htmlLabels || config.flowchart?.htmlLabels; - for (const className in classes) { - if (htmlLabels) { - userStyles += `\n.${className} > * { ${classes[className].styles.join( - ' !important; ' - )} !important; }`; - userStyles += `\n.${className} span { ${classes[className].styles.join( - ' !important; ' - )} !important; }`; - } else { - userStyles += `\n.${className} path { ${classes[className].styles.join( - ' !important; ' - )} !important; }`; - userStyles += `\n.${className} rect { ${classes[className].styles.join( - ' !important; ' - )} !important; }`; - userStyles += `\n.${className} polygon { ${classes[className].styles.join( - ' !important; ' - )} !important; }`; - userStyles += `\n.${className} ellipse { ${classes[className].styles.join( - ' !important; ' - )} !important; }`; - userStyles += `\n.${className} circle { ${classes[className].styles.join( - ' !important; ' - )} !important; }`; - if (classes[className].textStyles) { - userStyles += `\n.${className} tspan { ${classes[className].textStyles.join( - ' !important; ' - )} !important; }`; - } - } - } - } + const userDefClasses: any = flowRenderer.getClasses(text, diag); + const cssStyles = createCssStyles(config, graphType, userDefClasses); const stylis = (selector: string, styles: string) => serialize(compile(`${selector}{${styles}}`), stringify); - const rules = stylis(`${idSelector}`, getStyles(graphType, userStyles, config.themeVariables)); + const rules = stylis(`${idSelector}`, getStyles(graphType, cssStyles, config.themeVariables)); const style1 = document.createElement('style'); style1.innerHTML = `${idSelector} ` + rules; @@ -378,35 +463,13 @@ const render = async function ( let svgCode = root.select(enclosingDivID_selector).node().innerHTML; log.debug('config.arrowMarkerAbsolute', config.arrowMarkerAbsolute); - if (!evaluate(config.arrowMarkerAbsolute) && config.securityLevel !== SECURITY_LVL_SANDBOX) { - svgCode = svgCode.replace(/marker-end="url\(.*?#/g, 'marker-end="url(#', 'g'); - } - - svgCode = decodeEntities(svgCode); - - // Fix for when the br tag is used - svgCode = svgCode.replace(/
/g, '
'); - - // ------------------------------------------------------------------------------- + svgCode = cleanUpSvgCode(svgCode, isSandboxed, evaluate(config.arrowMarkerAbsolute)); if (isSandboxed) { const svgEl = root.select(enclosingDivID_selector + ' svg').node(); - const width = IFRAME_WIDTH; - let height = IFRAME_HEIGHT; - - // set the svg element height to px - if (svgEl) { - height = svgEl.viewBox.baseVal.height + 'px'; - } - // Insert iFrame code into svg code - svgCode = ``; + svgCode = putIntoIFrame(svgCode, svgEl); } else { if (isLooseSecurityLevel) { - // ------------------------------------------------------------------------------- // Sanitize the svgCode using DOMPurify svgCode = DOMPurify.sanitize(svgCode, { ADD_TAGS: DOMPURE_TAGS, @@ -433,22 +496,17 @@ const render = async function ( default: cb(svgCode); } - } else { - log.debug('CB = undefined!'); - } + } else log.debug('CB = undefined!'); + attachFunctions(); // ------------------------------------------------------------------------------- // Remove the temporary element if appropriate const tmpElementSelector = isSandboxed ? iFrameID_selector : enclosingDivID_selector; const node = select(tmpElementSelector).node(); - if (node && 'remove' in node) { - node.remove(); - } + if (node && 'remove' in node) node.remove(); - if (parseEncounteredException) { - throw parseEncounteredException; - } + if (parseEncounteredException) throw parseEncounteredException; return svgCode; }; From 166dca55f237937a2ccccc6cc208c95fc7caebf0 Mon Sep 17 00:00:00 2001 From: "Ashley Engelund (weedySeaDragon @ github)" Date: Sun, 16 Oct 2022 09:09:36 -0700 Subject: [PATCH 09/18] functions and specs: createUserstyles; minor changes --- ...{mermaidAPI.spec.js => mermaidAPI.spec.ts} | 186 ++++++++++++------ packages/mermaid/src/mermaidAPI.ts | 48 +++-- 2 files changed, 157 insertions(+), 77 deletions(-) rename packages/mermaid/src/{mermaidAPI.spec.js => mermaidAPI.spec.ts} (78%) diff --git a/packages/mermaid/src/mermaidAPI.spec.js b/packages/mermaid/src/mermaidAPI.spec.ts similarity index 78% rename from packages/mermaid/src/mermaidAPI.spec.js rename to packages/mermaid/src/mermaidAPI.spec.ts index f697891a43..8361f368c4 100644 --- a/packages/mermaid/src/mermaidAPI.spec.js +++ b/packages/mermaid/src/mermaidAPI.spec.ts @@ -2,11 +2,14 @@ import { vi } from 'vitest'; import mermaid from './mermaid'; +import { MermaidConfig } from './config.type'; + import mermaidAPI from './mermaidAPI'; import { encodeEntities, decodeEntities, createCssStyles, + createUserStyles, appendDivSvgG, cleanUpSvgCode, putIntoIFrame, @@ -14,7 +17,9 @@ import { import assignWithDepth from './assignWithDepth'; -// To mock a module, first define a mock for it, then import it. Be sure the path points to exactly the same file as is imported in mermaidAPI (the module being tested) +// -------------- +// Mocks +// To mock a module, first define a mock for it, then (if used explicitly in the tests) import it. Be sure the path points to exactly the same file as is imported in mermaidAPI (the module being tested) vi.mock('./styles', () => { return { addStylesForDiagram: vi.fn(), @@ -34,6 +39,8 @@ import { compile, serialize } from 'stylis'; import { MockedD3 } from './tests/MockedD3'; +// ------------------------------------------------------------------------------------- + describe('when using mermaidAPI and ', function () { describe('encodeEntities', () => { it('removes the ending ; from style [text1]:[optional word]#[text2]; with ', () => { @@ -184,12 +191,14 @@ describe('when using mermaidAPI and ', function () { const fauxGNode = new MockedD3(); const parent_append_spy = vi.spyOn(fauxParentNode, 'append').mockReturnValue(fauxEnclosingDiv); const div_append_spy = vi.spyOn(fauxEnclosingDiv, 'append').mockReturnValue(fauxSvgNode); + // @ts-ignore @todo TODO why is this getting a type error? const div_attr_spy = vi.spyOn(fauxEnclosingDiv, 'attr').mockReturnValue(fauxEnclosingDiv); const svg_append_spy = vi.spyOn(fauxSvgNode, 'append').mockReturnValue(fauxGNode); + // @ts-ignore @todo TODO why is this getting a type error? const svg_attr_spy = vi.spyOn(fauxSvgNode, 'attr').mockReturnValue(fauxSvgNode); it('appends a div node', () => { - appendDivSvgG(fauxParentNode, 'theId'); + appendDivSvgG(fauxParentNode, 'theId', 'dtheId'); expect(parent_append_spy).toHaveBeenCalledWith('div'); expect(div_append_spy).toHaveBeenCalledWith('svg'); }); @@ -208,7 +217,7 @@ describe('when using mermaidAPI and ', function () { expect(div_attr_spy).toHaveBeenCalledWith('id', 'dtheId'); }); it('sets the svg width to 100%', () => { - appendDivSvgG(fauxParentNode, 'theId'); + appendDivSvgG(fauxParentNode, 'theId', 'dtheId'); expect(svg_attr_spy).toHaveBeenCalledWith('width', '100%'); }); it('the svg id is the id', () => { @@ -216,7 +225,7 @@ describe('when using mermaidAPI and ', function () { expect(svg_attr_spy).toHaveBeenCalledWith('id', 'theId'); }); it('the svg xml namespace is the 2000 standard', () => { - appendDivSvgG(fauxParentNode, 'theId'); + appendDivSvgG(fauxParentNode, 'theId', 'dtheId'); expect(svg_attr_spy).toHaveBeenCalledWith('xmlns', 'http://www.w3.org/2000/svg'); }); it('sets the svg xlink if one is given', () => { @@ -235,11 +244,11 @@ describe('when using mermaidAPI and ', function () { describe('createCssStyles', () => { const serif = 'serif'; const sansSerif = 'sans-serif'; - const mocked_config_with_htmlLabels = { + const mocked_config_with_htmlLabels: MermaidConfig = { themeCSS: 'default', fontFamily: serif, altFontFamily: sansSerif, - htmlLabels: '', + htmlLabels: true, }; it('gets the cssStyles from the theme', () => { @@ -267,7 +276,7 @@ describe('when using mermaidAPI and ', function () { const REGEXP_SPECIALS = ['^', '$', '?', '(', '{', '[', '.', '*', '!']; // prefix any special RegExp characters in the given string with a \ so we can use the literal character in a RegExp - function escapeForRegexp(str) { + function escapeForRegexp(str: string) { const strChars = str.split(''); // split into array of every char const strEscaped = strChars.map((char) => { if (REGEXP_SPECIALS.includes(char)) return `\\${char}`; @@ -275,7 +284,9 @@ describe('when using mermaidAPI and ', function () { }); return strEscaped.join(''); } - function expect_styles_matchesHtmlElements(styles, htmlElement) { + + // Common test expecting given styles to have .classDef1 and .classDef2 statements but not .classDef3 + function expect_styles_matchesHtmlElements(styles: string, htmlElement: string) { expect(styles).toMatch( new RegExp( `\\.classDef1 ${escapeForRegexp( @@ -292,13 +303,14 @@ describe('when using mermaidAPI and ', function () { ); } - function expect_textStyles_matchesHtmlElements(styles, htmlElement) { - expect(styles).toMatch( + // Common test expecting given textStyles to have .classDef2 and .classDef3 statements but not .classDef1 + function expect_textStyles_matchesHtmlElements(textStyles: string, htmlElement: string) { + expect(textStyles).toMatch( new RegExp( `\\.classDef2 ${escapeForRegexp(htmlElement)} \\{ textStyle2-1 !important; }` ) ); - expect(styles).toMatch( + expect(textStyles).toMatch( new RegExp( `\\.classDef3 ${escapeForRegexp( htmlElement @@ -307,14 +319,15 @@ describe('when using mermaidAPI and ', function () { ); // no CSS styles are created if there are no textStyles for a classDef - expect(styles).not.toMatch( + expect(textStyles).not.toMatch( new RegExp( `\\.classDef1 ${escapeForRegexp(htmlElement)} \\{ textStyle(.*) !important; }` ) ); } - function expect_correct_styles_with_htmlElements(mocked_config) { + // common suite and tests to verify that the right styles are created with the right htmlElements + function expect_correct_styles_with_htmlElements(mocked_config: MermaidConfig) { describe('creates styles for "> *" and "span" elements', () => { const htmlElements = ['> *', 'span']; @@ -335,12 +348,12 @@ describe('when using mermaidAPI and ', function () { }); it('there are flowchart.htmlLabels in the configuration', () => { - const mocked_config_flowchart_htmlLabels = { + const mocked_config_flowchart_htmlLabels: MermaidConfig = { themeCSS: 'default', fontFamily: 'serif', altFontFamily: 'sans-serif', flowchart: { - htmlLabels: 'flowchart-htmlLables', + htmlLabels: true, }, }; expect_correct_styles_with_htmlElements(mocked_config_flowchart_htmlLabels); @@ -371,39 +384,75 @@ describe('when using mermaidAPI and ', function () { }); }); - // describe('createUserStyles', () => { - // const mockConfig = { - // themeCSS: 'default', - // htmlLabels: 'htmlLabels', - // themeVariables: { fontFamily: 'serif' }, - // }; - // const classDef1 = { id: 'classDef1', styles: ['style1-1'], textStyles: [] }; - // - // it('gets the css styles created', () => { - // // @todo TODO if a single function in the module can be mocked, do it for createCssStyles and mock the results. - // - // createUserStyles(mockConfig, 'flowchart-v2', [classDef1], 'someId'); - // const expectedStyles = - // '\ndefault' + - // '\n.classDef1 > * { style1-1 !important; }' + - // '\n.classDef1 span { style1-1 !important; }'; - // expect(getStyles).toHaveBeenCalledWith('flowchart-v2', expectedStyles, { - // fontFamily: 'serif', - // }); - // }); - // - // it('calls getStyles to get css for all graph, user css styles, and config theme variables', () => { - // createUserStyles(mockConfig, 'someDiagram', null, 'someId'); - // expect(getStyles).toHaveBeenCalled(); - // }); - // - // it('returns the result of compiling, stringifying, and serializing the css code with stylis', () => { - // const result = createUserStyles(mockConfig, 'someDiagram', null, 'someId'); - // expect(compile).toHaveBeenCalled(); - // expect(serialize).toHaveBeenCalled(); - // expect(result).toEqual('stylis serialized css'); - // }); - // }); + describe('createUserStyles', () => { + const mockConfig = { + themeCSS: 'default', + htmlLabels: true, + themeVariables: { fontFamily: 'serif' }, + }; + + // + // export interface MermaidConfig { + // lazyLoadedDiagrams?: string[]; + // theme?: string; + // themeVariables?: any; + // themeCSS?: string; + // maxTextSize?: number; + // darkMode?: boolean; + // htmlLabels?: boolean; + // fontFamily?: string; + // altFontFamily?: string; + // logLevel?: number; + // securityLevel?: string; + // startOnLoad?: boolean; + // arrowMarkerAbsolute?: boolean; + // secure?: string[]; + // deterministicIds?: boolean; + // deterministicIDSeed?: string; + // flowchart?: FlowchartDiagramConfig; + // sequence?: SequenceDiagramConfig; + // gantt?: GanttDiagramConfig; + // journey?: JourneyDiagramConfig; + // class?: ClassDiagramConfig; + // state?: StateDiagramConfig; + // er?: ErDiagramConfig; + // pie?: PieDiagramConfig; + // requirement?: RequirementDiagramConfig; + // mindmap?: MindmapDiagramConfig; + // gitGraph?: GitGraphDiagramConfig; + // c4?: C4DiagramConfig; + // dompurifyConfig?: DOMPurify.Config; + // wrap?: boolean; + // fontSize?: number; + // } + + const classDef1 = { id: 'classDef1', styles: ['style1-1'], textStyles: [] }; + + it('gets the css styles created', () => { + // @todo TODO if a single function in the module can be mocked, do it for createCssStyles and mock the results. + + createUserStyles(mockConfig, 'flowchart-v2', [classDef1], 'someId'); + const expectedStyles = + '\ndefault' + + '\n.classDef1 > * { style1-1 !important; }' + + '\n.classDef1 span { style1-1 !important; }'; + expect(getStyles).toHaveBeenCalledWith('flowchart-v2', expectedStyles, { + fontFamily: 'serif', + }); + }); + + it('calls getStyles to get css for all graph, user css styles, and config theme variables', () => { + createUserStyles(mockConfig, 'someDiagram', null, 'someId'); + expect(getStyles).toHaveBeenCalled(); + }); + + it('returns the result of compiling, stringifying, and serializing the css code with stylis', () => { + const result = createUserStyles(mockConfig, 'someDiagram', null, 'someId'); + expect(compile).toHaveBeenCalled(); + expect(serialize).toHaveBeenCalled(); + expect(result).toEqual('stylis serialized css'); + }); + }); describe('doing initialize ', function () { beforeEach(function () { @@ -412,16 +461,19 @@ describe('when using mermaidAPI and ', function () { }); it('should copy a literal into the configuration', function () { - const orgConfig = mermaidAPI.getConfig(); + const orgConfig: any = mermaidAPI.getConfig(); expect(orgConfig.testLiteral).toBe(undefined); - mermaidAPI.initialize({ testLiteral: true }); - const config = mermaidAPI.getConfig(); + const testConfig: any = { testLiteral: true }; + + mermaidAPI.initialize(testConfig); + const config: any = mermaidAPI.getConfig(); expect(config.testLiteral).toBe(true); }); + it('should copy a an object into the configuration', function () { - const orgConfig = mermaidAPI.getConfig(); + const orgConfig: any = mermaidAPI.getConfig(); expect(orgConfig.testObject).toBe(undefined); const object = { @@ -429,19 +481,25 @@ describe('when using mermaidAPI and ', function () { test2: false, }; - mermaidAPI.initialize({ testObject: object }); - let config = mermaidAPI.getConfig(); + const testConfig: any = { testObject: object }; + + mermaidAPI.initialize(testConfig); + + let config: any = mermaidAPI.getConfig(); expect(config.testObject.test1).toBe(1); - mermaidAPI.updateSiteConfig({ testObject: { test3: true } }); + + const testObjSetting: any = { testObject: { test3: true } }; + mermaidAPI.updateSiteConfig(testObjSetting); config = mermaidAPI.getConfig(); expect(config.testObject.test1).toBe(1); expect(config.testObject.test2).toBe(false); expect(config.testObject.test3).toBe(true); }); + it('should reset mermaid config to global defaults', function () { - let config = { + const config = { logLevel: 0, securityLevel: 'loose', }; @@ -458,7 +516,7 @@ describe('when using mermaidAPI and ', function () { }); it('should prevent changes to site defaults (sneaky)', function () { - let config = { + const config: any = { logLevel: 0, }; mermaidAPI.initialize(config); @@ -477,11 +535,12 @@ describe('when using mermaidAPI and ', function () { expect(mermaidAPI.getConfig()).toEqual(siteConfig); }); it('should prevent clobbering global defaults (direct)', function () { - let config = assignWithDepth({}, mermaidAPI.defaultConfig); + const config = assignWithDepth({}, mermaidAPI.defaultConfig); assignWithDepth(config, { logLevel: 0 }); - let error = { message: '' }; + let error: any = { message: '' }; try { + // @ts-ignore This is a read-only property. Typescript will not allow assignment, but regular javascript might. mermaidAPI['defaultConfig'] = config; } catch (e) { error = e; @@ -492,7 +551,7 @@ describe('when using mermaidAPI and ', function () { expect(mermaidAPI.defaultConfig['logLevel']).toBe(5); }); it('should prevent changes to global defaults (direct)', function () { - let error = { message: '' }; + let error: any = { message: '' }; try { mermaidAPI.defaultConfig['logLevel'] = 0; } catch (e) { @@ -504,10 +563,10 @@ describe('when using mermaidAPI and ', function () { expect(mermaidAPI.defaultConfig['logLevel']).toBe(5); }); it('should prevent sneaky changes to global defaults (assignWithDepth)', function () { - let config = { + const config = { logLevel: 0, }; - let error = { message: '' }; + let error: any = { message: '' }; try { assignWithDepth(mermaidAPI.defaultConfig, config); } catch (e) { @@ -522,7 +581,8 @@ describe('when using mermaidAPI and ', function () { describe('dompurify config', function () { it('should allow dompurify config to be set', function () { mermaidAPI.initialize({ dompurifyConfig: { ADD_ATTR: ['onclick'] } }); - expect(mermaidAPI.getConfig().dompurifyConfig.ADD_ATTR).toEqual(['onclick']); + + expect(mermaidAPI!.getConfig()!.dompurifyConfig!.ADD_ATTR).toEqual(['onclick']); }); }); describe('test mermaidApi.parse() for checking validity of input ', function () { diff --git a/packages/mermaid/src/mermaidAPI.ts b/packages/mermaid/src/mermaidAPI.ts index 0165aaeff4..73597d1c81 100644 --- a/packages/mermaid/src/mermaidAPI.ts +++ b/packages/mermaid/src/mermaidAPI.ts @@ -39,9 +39,9 @@ const MAX_TEXTLENGTH_EXCEEDED_MSG = const SECURITY_LVL_SANDBOX = 'sandbox'; const SECURITY_LVL_LOOSE = 'loose'; -const XMLNS_XHTML_STD = 'http://www.w3.org/1999/xhtml'; const XMLNS_SVG_STD = 'http://www.w3.org/2000/svg'; const XMLNS_XLINK_STD = 'http://www.w3.org/1999/xlink'; +const XMLNS_XHTML_STD = 'http://www.w3.org/1999/xhtml'; // ------------------------------ // iFrame @@ -87,12 +87,10 @@ export const encodeEntities = function (text: string): string { let txt = text; txt = txt.replace(/style.*:\S*#.*;/g, function (s) { - const innerTxt = s.substring(0, s.length - 1); - return innerTxt; + return s.substring(0, s.length - 1); }); txt = txt.replace(/classDef.*:\S*#.*;/g, function (s) { - const innerTxt = s.substring(0, s.length - 1); - return innerTxt; + return s.substring(0, s.length - 1); }); txt = txt.replace(/#\w+;/g, function (s) { @@ -211,6 +209,29 @@ export const createCssStyles = ( return cssStyles; }; +export const createUserStyles = ( + config: MermaidConfig, + graphType: string, + classDefs: null | DiagramStyleClassDef, + svgId: string +): string => { + const userCSSstyles = createCssStyles(config, graphType, classDefs); + const allStyles = getStyles(graphType, userCSSstyles, config.themeVariables); + + // Now turn all of the styles into a (compiled) string that starts with the id + // use the stylis library to compile the css, turn the results into a valid CSS string (serialize(...., stringify)) + // @see https://github.com/thysultan/stylis + return serialize(compile(`${svgId}{${allStyles}}`), stringify); +}; + +/** + * Clean up svgCode. Do replacements needed + * + * @param {string} svgCode + * @param {boolean} inSandboxMode - security level + * @param {boolean} useArrowMarkerUrls - should arrow marker's use full urls? (vs. just the anchors) + * @returns {string} the cleaned up svgCode + */ export const cleanUpSvgCode = ( svgCode = '', inSandboxMode: boolean, @@ -424,23 +445,22 @@ const render = async function ( parseEncounteredException = error; } - // Get the tmp div element containing the svg + // Get the temporary div element containing the svg const element = root.select(enclosingDivID_selector).node(); const graphType = diag.type; // ------------------------------------------------------------------------------- // Create and insert the styles (user styles, theme styles, config styles) - // insert inline style into svg + // Insert an element into svg. This is where we put the styles const svg = element.firstChild; const firstChild = svg.firstChild; - - const userDefClasses: any = flowRenderer.getClasses(text, diag); - const cssStyles = createCssStyles(config, graphType, userDefClasses); - - const stylis = (selector: string, styles: string) => - serialize(compile(`${selector}{${styles}}`), stringify); - const rules = stylis(`${idSelector}`, getStyles(graphType, cssStyles, config.themeVariables)); + const rules = createUserStyles( + config, + graphType, + flowRenderer.getClasses(text, diag), + idSelector + ); const style1 = document.createElement('style'); style1.innerHTML = `${idSelector} ` + rules; From fcba29f774850c109a51f88746e6732b00efa980 Mon Sep 17 00:00:00 2001 From: "Ashley Engelund (weedySeaDragon @ github)" Date: Sun, 16 Oct 2022 11:08:01 -0700 Subject: [PATCH 10/18] functions and specs: removeExistingElements --- packages/mermaid/src/mermaidAPI.spec.ts | 72 ++++++++++++++++++++++++- packages/mermaid/src/mermaidAPI.ts | 46 ++++++++++------ 2 files changed, 101 insertions(+), 17 deletions(-) diff --git a/packages/mermaid/src/mermaidAPI.spec.ts b/packages/mermaid/src/mermaidAPI.spec.ts index 8361f368c4..08aaef6a7c 100644 --- a/packages/mermaid/src/mermaidAPI.spec.ts +++ b/packages/mermaid/src/mermaidAPI.spec.ts @@ -4,7 +4,7 @@ import { vi } from 'vitest'; import mermaid from './mermaid'; import { MermaidConfig } from './config.type'; -import mermaidAPI from './mermaidAPI'; +import mermaidAPI, { removeExistingElements } from './mermaidAPI'; import { encodeEntities, decodeEntities, @@ -454,6 +454,76 @@ describe('when using mermaidAPI and ', function () { }); }); + describe('removeExistingElements', () => { + const svgId = 'svgId'; + const tempDivId = 'tempDivId'; + const tempIframeId = 'tempIFrameId'; + const givenDocument = new Document(); + const rootHtml = givenDocument.createElement('html'); + givenDocument.append(rootHtml); + + const svgElement = givenDocument.createElement('svg'); // doesn't matter what the tag is in the test + svgElement.id = svgId; + const tempDivElement = givenDocument.createElement('div'); // doesn't matter what the tag is in the test + tempDivElement.id = tempDivId; + const tempiFrameElement = givenDocument.createElement('div'); // doesn't matter what the tag is in the test + tempiFrameElement.id = tempIframeId; + + it('removes an existing element with given id', () => { + rootHtml.appendChild(svgElement); + expect(givenDocument.getElementById(svgElement.id)).toEqual(svgElement); + removeExistingElements(givenDocument, false, svgId, tempDivId, tempIframeId); + expect(givenDocument.getElementById(svgElement.id)).toBeNull(); + }); + + describe('is in sandboxed mode', () => { + const inSandboxedMode = true; + + it('removes an existing element with the given iFrame selector', () => { + tempiFrameElement.append(svgElement); + rootHtml.append(tempiFrameElement); + rootHtml.append(tempDivElement); + + expect(givenDocument.getElementById(tempIframeId)).toEqual(tempiFrameElement); + expect(givenDocument.getElementById(tempDivId)).toEqual(tempDivElement); + expect(givenDocument.getElementById(svgId)).toEqual(svgElement); + removeExistingElements( + givenDocument, + inSandboxedMode, + svgId, + '#' + tempDivId, + '#' + tempIframeId + ); + expect(givenDocument.getElementById(tempDivId)).toEqual(tempDivElement); + expect(givenDocument.getElementById(tempIframeId)).toBeNull(); + expect(givenDocument.getElementById(svgId)).toBeNull(); + }); + }); + describe('not in sandboxed mode', () => { + const inSandboxedMode = false; + + it('removes an existing element with the given enclosing div selector', () => { + tempDivElement.append(svgElement); + rootHtml.append(tempDivElement); + rootHtml.append(tempiFrameElement); + + expect(givenDocument.getElementById(tempIframeId)).toEqual(tempiFrameElement); + expect(givenDocument.getElementById(tempDivId)).toEqual(tempDivElement); + expect(givenDocument.getElementById(svgId)).toEqual(svgElement); + removeExistingElements( + givenDocument, + inSandboxedMode, + svgId, + '#' + tempDivId, + '#' + tempIframeId + ); + expect(givenDocument.getElementById(tempIframeId)).toEqual(tempiFrameElement); + expect(givenDocument.getElementById(tempDivId)).toBeNull(); + expect(givenDocument.getElementById(svgId)).toBeNull(); + }); + }); + }); + describe('doing initialize ', function () { beforeEach(function () { document.body.innerHTML = ''; diff --git a/packages/mermaid/src/mermaidAPI.ts b/packages/mermaid/src/mermaidAPI.ts index 73597d1c81..54e1758fb5 100644 --- a/packages/mermaid/src/mermaidAPI.ts +++ b/packages/mermaid/src/mermaidAPI.ts @@ -86,10 +86,10 @@ function parse(text: string, parseError?: ParseErrorFunction): boolean { export const encodeEntities = function (text: string): string { let txt = text; - txt = txt.replace(/style.*:\S*#.*;/g, function (s) { + txt = txt.replace(/style.*:\S*#.*;/g, function (s): string { return s.substring(0, s.length - 1); }); - txt = txt.replace(/classDef.*:\S*#.*;/g, function (s) { + txt = txt.replace(/classDef.*:\S*#.*;/g, function (s): string { return s.substring(0, s.length - 1); }); @@ -319,6 +319,31 @@ function sandboxedIframe(parentNode: D3Element, iFrameId: string): D3Element { .attr('sandbox', ''); } +/** + * Remove any existing elements from the given document + * + * @param {Document} doc - the document to removed elements from + * @param {string} isSandboxed - whether or not we are in sandboxed mode + * @param {string} id - id for any existing SVG element + * @param {string} divSelector - selector for any existing enclosing div element + * @param {string} iFrameSelector - selector for any existing iFrame element + */ +export const removeExistingElements = ( + doc: Document, + isSandboxed: boolean, + id: string, + divSelector: string, + iFrameSelector: string +) => { + // Remove existing SVG element if it exists + const existingSvg = doc.getElementById(id); + if (existingSvg) existingSvg.remove(); + + // Remove previous temporary element if it exists + const element = isSandboxed ? doc.querySelector(iFrameSelector) : doc.querySelector(divSelector); + if (element) element.remove(); +}; + /** * Function that renders an svg with a graph from a chart definition. Usage example below. * @@ -384,8 +409,8 @@ const render = async function ( // ------------------------------------------------------------------------------- // Define the root d3 node - // In regular execution the svgContainingElement will be the element with a mermaid class + if (typeof svgContainingElement !== 'undefined') { if (svgContainingElement) svgContainingElement.innerHTML = ''; @@ -400,19 +425,9 @@ const render = async function ( appendDivSvgG(root, id, enclosingDivID, `font-family: ${fontFamily}`, XMLNS_XLINK_STD); } else { // No svgContainingElement was provided - // If there is an existing element with the id, we remove it - // this likely a previously rendered diagram - const existingSvg = document.getElementById(id); - if (existingSvg) existingSvg.remove(); - // Remove previous temporary element if it exists - let element; - if (isSandboxed) { - element = document.querySelector(iFrameID_selector); - } else { - element = document.querySelector(enclosingDivID_selector); - } - if (element) element.remove(); + // If there is an existing element with the id, we remove it. This likely a previously rendered diagram + removeExistingElements(document, isSandboxed, id, iFrameID_selector, enclosingDivID_selector); // Add the temporary div used for rendering with the enclosingDivID. // This temporary div will contain a svg with the id == id @@ -420,7 +435,6 @@ const render = async function ( if (isSandboxed) { // If we are in sandboxed mode, we do everything mermaid related in a (sandboxed) iFrame const iframe = sandboxedIframe(select('body'), iFrameID); - root = select(iframe.nodes()[0]!.contentDocument!.body); root.node().style.margin = 0; } else root = select('body'); From ea86ef3995b243ebd185a3fc096aac88dd6be184 Mon Sep 17 00:00:00 2001 From: "Ashley Engelund (weedySeaDragon @ github)" Date: Sun, 16 Oct 2022 11:14:57 -0700 Subject: [PATCH 11/18] change spec descriptions to active voice (= shorter b/c 'should' isn't needed) --- packages/mermaid/src/mermaidAPI.spec.ts | 30 ++++++++++++------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/packages/mermaid/src/mermaidAPI.spec.ts b/packages/mermaid/src/mermaidAPI.spec.ts index 08aaef6a7c..8206499f97 100644 --- a/packages/mermaid/src/mermaidAPI.spec.ts +++ b/packages/mermaid/src/mermaidAPI.spec.ts @@ -41,7 +41,7 @@ import { MockedD3 } from './tests/MockedD3'; // ------------------------------------------------------------------------------------- -describe('when using mermaidAPI and ', function () { +describe('mermaidAPI', function () { describe('encodeEntities', () => { it('removes the ending ; from style [text1]:[optional word]#[text2]; with ', () => { const text = 'style this; is ; everything :something#not-nothing; and this too;'; @@ -524,13 +524,13 @@ describe('when using mermaidAPI and ', function () { }); }); - describe('doing initialize ', function () { + describe('initialize', function () { beforeEach(function () { document.body.innerHTML = ''; mermaidAPI.globalReset(); }); - it('should copy a literal into the configuration', function () { + it('copies a literal into the configuration', function () { const orgConfig: any = mermaidAPI.getConfig(); expect(orgConfig.testLiteral).toBe(undefined); @@ -542,7 +542,7 @@ describe('when using mermaidAPI and ', function () { expect(config.testLiteral).toBe(true); }); - it('should copy a an object into the configuration', function () { + it('copies a an object into the configuration', function () { const orgConfig: any = mermaidAPI.getConfig(); expect(orgConfig.testObject).toBe(undefined); @@ -568,7 +568,7 @@ describe('when using mermaidAPI and ', function () { expect(config.testObject.test3).toBe(true); }); - it('should reset mermaid config to global defaults', function () { + it('resets mermaid config to global defaults', function () { const config = { logLevel: 0, securityLevel: 'loose', @@ -585,7 +585,7 @@ describe('when using mermaidAPI and ', function () { expect(mermaidAPI.getConfig().securityLevel).toBe('strict'); }); - it('should prevent changes to site defaults (sneaky)', function () { + it('prevents changes to site defaults (sneaky)', function () { const config: any = { logLevel: 0, }; @@ -604,7 +604,7 @@ describe('when using mermaidAPI and ', function () { expect(mermaidAPI.getSiteConfig()).toEqual(siteConfig); expect(mermaidAPI.getConfig()).toEqual(siteConfig); }); - it('should prevent clobbering global defaults (direct)', function () { + it('prevents clobbering global defaults (direct)', function () { const config = assignWithDepth({}, mermaidAPI.defaultConfig); assignWithDepth(config, { logLevel: 0 }); @@ -620,7 +620,7 @@ describe('when using mermaidAPI and ', function () { ); expect(mermaidAPI.defaultConfig['logLevel']).toBe(5); }); - it('should prevent changes to global defaults (direct)', function () { + it('prevents changes to global defaults (direct)', function () { let error: any = { message: '' }; try { mermaidAPI.defaultConfig['logLevel'] = 0; @@ -632,7 +632,7 @@ describe('when using mermaidAPI and ', function () { ); expect(mermaidAPI.defaultConfig['logLevel']).toBe(5); }); - it('should prevent sneaky changes to global defaults (assignWithDepth)', function () { + it('prevents sneaky changes to global defaults (assignWithDepth)', function () { const config = { logLevel: 0, }; @@ -649,22 +649,22 @@ describe('when using mermaidAPI and ', function () { }); }); describe('dompurify config', function () { - it('should allow dompurify config to be set', function () { + it('allows dompurify config to be set', function () { mermaidAPI.initialize({ dompurifyConfig: { ADD_ATTR: ['onclick'] } }); expect(mermaidAPI!.getConfig()!.dompurifyConfig!.ADD_ATTR).toEqual(['onclick']); }); }); - describe('test mermaidApi.parse() for checking validity of input ', function () { + describe('parse', function () { mermaid.parseError = undefined; // ensure it parseError undefined - it('should throw for an invalid definition (with no mermaid.parseError() defined)', function () { + it('throws for an invalid definition (with no mermaid.parseError() defined)', function () { expect(mermaid.parseError).toEqual(undefined); expect(() => mermaidAPI.parse('this is not a mermaid diagram definition')).toThrow(); }); - it('should not throw for a valid definition', function () { + it('does not throw for a valid definition', function () { expect(() => mermaidAPI.parse('graph TD;A--x|text including URL space|B;')).not.toThrow(); }); - it('it should return false for invalid definition WITH a parseError() callback defined', function () { + it('returns false for invalid definition WITH a parseError() callback defined', function () { let parseErrorWasCalled = false; // also test setParseErrorHandler() call working to set mermaid.parseError expect( @@ -674,7 +674,7 @@ describe('when using mermaidAPI and ', function () { ).toEqual(false); expect(parseErrorWasCalled).toEqual(true); }); - it('should return true for valid definition', function () { + it('returns true for valid definition', function () { expect(mermaidAPI.parse('graph TD;A--x|text including URL space|B;')).toEqual(true); }); }); From 377b22e82bbea7fee6f9188c734dd83a1e57ff56 Mon Sep 17 00:00:00 2001 From: Sidharth Vinod Date: Wed, 19 Oct 2022 22:31:37 +0530 Subject: [PATCH 12/18] fix: Type of DiagramStyleClassDef, general cleanup --- packages/mermaid/src/defaultConfig.ts | 6 +- .../mermaid/src/diagrams/flowchart/flowDb.js | 2 +- .../src/diagrams/flowchart/flowRenderer.js | 3 +- packages/mermaid/src/mermaidAPI.spec.ts | 12 +-- packages/mermaid/src/mermaidAPI.ts | 97 +++++++++++-------- packages/mermaid/src/utils.ts | 4 + 6 files changed, 70 insertions(+), 54 deletions(-) diff --git a/packages/mermaid/src/defaultConfig.ts b/packages/mermaid/src/defaultConfig.ts index 710557bd98..61322da5f3 100644 --- a/packages/mermaid/src/defaultConfig.ts +++ b/packages/mermaid/src/defaultConfig.ts @@ -22,7 +22,7 @@ import { MermaidConfig } from './config.type'; * * @name Configuration */ -const config: Partial = { +const config: MermaidConfig = { /** * Theme , the CSS style sheet * @@ -1069,6 +1069,7 @@ const config: Partial = { showCommitLabel: true, showBranches: true, rotateCommitLabel: true, + arrowMarkerAbsolute: false, }, /** The object containing configurations specific for c4 diagrams */ @@ -1833,9 +1834,6 @@ const config: Partial = { fontSize: 16, }; -if (config.class) config.class.arrowMarkerAbsolute = config.arrowMarkerAbsolute; -if (config.gitGraph) config.gitGraph.arrowMarkerAbsolute = config.arrowMarkerAbsolute; - const keyify = (obj: any, prefix = ''): string[] => Object.keys(obj).reduce((res: string[], el): string[] => { if (Array.isArray(obj[el])) { diff --git a/packages/mermaid/src/diagrams/flowchart/flowDb.js b/packages/mermaid/src/diagrams/flowchart/flowDb.js index 5aa2032257..9e8f25524e 100644 --- a/packages/mermaid/src/diagrams/flowchart/flowDb.js +++ b/packages/mermaid/src/diagrams/flowchart/flowDb.js @@ -17,7 +17,7 @@ let vertexCounter = 0; let config = configApi.getConfig(); let vertices = {}; let edges = []; -let classes = []; +let classes = {}; let subGraphs = []; let subGraphLookup = {}; let tooltips = {}; diff --git a/packages/mermaid/src/diagrams/flowchart/flowRenderer.js b/packages/mermaid/src/diagrams/flowchart/flowRenderer.js index 0c3aa3623e..c403b7fe32 100644 --- a/packages/mermaid/src/diagrams/flowchart/flowRenderer.js +++ b/packages/mermaid/src/diagrams/flowchart/flowRenderer.js @@ -279,7 +279,8 @@ export const getClasses = function (text, diagObj) { diagObj.parse(text); return diagObj.db.getClasses(); } catch (e) { - return; + log.error(e); + return {}; } }; diff --git a/packages/mermaid/src/mermaidAPI.spec.ts b/packages/mermaid/src/mermaidAPI.spec.ts index 8206499f97..af50a6cc4a 100644 --- a/packages/mermaid/src/mermaidAPI.spec.ts +++ b/packages/mermaid/src/mermaidAPI.spec.ts @@ -256,11 +256,11 @@ describe('mermaidAPI', function () { expect(styles).toMatch(/^\ndefault(.*)/); }); it('gets the fontFamily from the config', () => { - const styles = createCssStyles(mocked_config_with_htmlLabels, 'graphType', null); + const styles = createCssStyles(mocked_config_with_htmlLabels, 'graphType', {}); expect(styles).toMatch(/(.*)\n:root \{ --mermaid-font-family: serif(.*)/); }); it('gets the alt fontFamily from the config', () => { - const styles = createCssStyles(mocked_config_with_htmlLabels, 'graphType', null); + const styles = createCssStyles(mocked_config_with_htmlLabels, 'graphType', undefined); expect(styles).toMatch(/(.*)\n:root \{ --mermaid-alt-font-family: sans-serif(.*)/); }); @@ -268,7 +268,7 @@ describe('mermaidAPI', function () { const classDef1 = { id: 'classDef1', styles: ['style1-1', 'style1-2'], textStyles: [] }; const classDef2 = { id: 'classDef2', styles: [], textStyles: ['textStyle2-1'] }; const classDef3 = { id: 'classDef3', textStyles: ['textStyle3-1', 'textStyle3-2'] }; - const classDefs = [classDef1, classDef2, classDef3]; + const classDefs = { classDef1, classDef2, classDef3 }; describe('the graph supports classDefs', () => { const graphType = 'flowchart-v2'; @@ -431,7 +431,7 @@ describe('mermaidAPI', function () { it('gets the css styles created', () => { // @todo TODO if a single function in the module can be mocked, do it for createCssStyles and mock the results. - createUserStyles(mockConfig, 'flowchart-v2', [classDef1], 'someId'); + createUserStyles(mockConfig, 'flowchart-v2', { classDef1 }, 'someId'); const expectedStyles = '\ndefault' + '\n.classDef1 > * { style1-1 !important; }' + @@ -442,12 +442,12 @@ describe('mermaidAPI', function () { }); it('calls getStyles to get css for all graph, user css styles, and config theme variables', () => { - createUserStyles(mockConfig, 'someDiagram', null, 'someId'); + createUserStyles(mockConfig, 'someDiagram', {}, 'someId'); expect(getStyles).toHaveBeenCalled(); }); it('returns the result of compiling, stringifying, and serializing the css code with stylis', () => { - const result = createUserStyles(mockConfig, 'someDiagram', null, 'someId'); + const result = createUserStyles(mockConfig, 'someDiagram', {}, 'someId'); expect(compile).toHaveBeenCalled(); expect(serialize).toHaveBeenCalled(); expect(result).toEqual('stylis serialized css'); diff --git a/packages/mermaid/src/mermaidAPI.ts b/packages/mermaid/src/mermaidAPI.ts index 54e1758fb5..8f3dcebf7d 100644 --- a/packages/mermaid/src/mermaidAPI.ts +++ b/packages/mermaid/src/mermaidAPI.ts @@ -28,7 +28,7 @@ import { attachFunctions } from './interactionDb'; import { log, setLogLevel } from './logger'; import getStyles from './styles'; import theme from './themes'; -import utils, { directiveSanitizer } from './utils'; +import utils, { directiveSanitizer, isNonEmptyArray } from './utils'; import DOMPurify from 'dompurify'; import { MermaidConfig } from './config.type'; import { evaluate } from './diagrams/common/common'; @@ -59,8 +59,11 @@ const DOMPURE_ATTR = ['dominant-baseline']; // This is what is returned from getClasses(...) methods. // It is slightly renamed to ..StyleClassDef instead of just ClassDef because "class" is a greatly ambiguous and overloaded word. // It makes it clear we're working with a style class definition, even though defining the type is currently difficult. -// @ts-ignore This is an alias for a js construct used in diagrams. -type DiagramStyleClassDef = any; +interface DiagramStyleClassDef { + id: string; + styles?: string[]; + textStyles?: string[]; +} // This makes it clear that we're working with a d3 selected element of some kind, even though it's hard to specify the exact type. // @ts-ignore Could replicate the type definition in d3. This also makes it possible to use the untyped info from the js diagram files. @@ -151,29 +154,32 @@ export const cssImportantStyles = ( * * @param {MermaidConfig} config * @param {string} graphType - * @param {null | DiagramStyleClassDef[]} classDefs - the classDefs in the diagram text. Might be null if none were defined. Usually is the result of a call to getClasses(...) + * @param {Record | null | undefined} classDefs - the classDefs in the diagram text. Might be null if none were defined. Usually is the result of a call to getClasses(...) * @returns {string} the string with all the user styles */ export const createCssStyles = ( config: MermaidConfig, graphType: string, - classDefs: DiagramStyleClassDef[] | null | undefined + classDefs: Record | null | undefined = {} ): string => { let cssStyles = ''; // user provided theme CSS info // If you add more configuration driven data into the user styles make sure that the value is // sanitized by the santizeCSS function @todo TODO where is this method? what should be used to replace it? refactor so that it's always sanitized - if (config.themeCSS !== undefined) cssStyles += `\n${config.themeCSS}`; + if (config.themeCSS !== undefined) { + cssStyles += `\n${config.themeCSS}`; + } - if (config.fontFamily !== undefined) + if (config.fontFamily !== undefined) { cssStyles += `\n:root { --mermaid-font-family: ${config.fontFamily}}`; - - if (config.altFontFamily !== undefined) + } + if (config.altFontFamily !== undefined) { cssStyles += `\n:root { --mermaid-alt-font-family: ${config.altFontFamily}}`; + } // classDefs defined in the diagram text - if (classDefs !== undefined && classDefs !== null && classDefs.length > 0) { + if (classDefs && Object.keys(classDefs).length > 0) { if (graphType === 'flowchart' || graphType === 'flowchart-v2' || graphType === 'graph') { const htmlLabels = config.htmlLabels || config.flowchart?.htmlLabels; @@ -186,22 +192,14 @@ export const createCssStyles = ( for (const classId in classDefs) { const styleClassDef = classDefs[classId]; // create the css styles for each cssElement and the styles (only if there are styles) - if (styleClassDef['styles'] && styleClassDef['styles'].length > 0) { + if (isNonEmptyArray(styleClassDef.styles)) { cssElements.forEach((cssElement) => { - cssStyles += cssImportantStyles( - styleClassDef['id'], - cssElement, - styleClassDef['styles'] - ); + cssStyles += cssImportantStyles(styleClassDef.id, cssElement, styleClassDef.styles); }); } // create the css styles for the tspan element and the text styles (only if there are textStyles) - if (styleClassDef['textStyles'] && styleClassDef['textStyles'].length > 0) { - cssStyles += cssImportantStyles( - styleClassDef['id'], - 'tspan', - styleClassDef['textStyles'] - ); + if (isNonEmptyArray(styleClassDef.textStyles)) { + cssStyles += cssImportantStyles(styleClassDef.id, 'tspan', styleClassDef.textStyles); } } } @@ -212,7 +210,7 @@ export const createCssStyles = ( export const createUserStyles = ( config: MermaidConfig, graphType: string, - classDefs: null | DiagramStyleClassDef, + classDefs: Record, svgId: string ): string => { const userCSSstyles = createCssStyles(config, graphType, classDefs); @@ -261,8 +259,7 @@ export const cleanUpSvgCode = ( * @todo TODO replace btoa(). Replace with buf.toString('base64')? */ export const putIntoIFrame = (svgCode = '', svgElement?: D3Element): string => { - let height = IFRAME_HEIGHT; // default iFrame height - if (svgElement) height = svgElement.viewBox.baseVal.height + 'px'; + const height = svgElement ? svgElement.viewBox.baseVal.height + 'px' : IFRAME_HEIGHT; const base64encodedSrc = btoa('' + svgCode + ''); return `