diff --git a/cSpell.json b/cSpell.json index a8c28dc3e4..08fce1d1c0 100644 --- a/cSpell.json +++ b/cSpell.json @@ -66,6 +66,7 @@ "sidharth", "sphinxcontrib", "statediagram", + "stylis", "substate", "sveidqvist", "techn", diff --git a/docs/config/setup/modules/mermaidAPI.md b/docs/config/setup/modules/mermaidAPI.md index 1ef1853edf..c4e512f3f2 100644 --- a/docs/config/setup/modules/mermaidAPI.md +++ b/docs/config/setup/modules/mermaidAPI.md @@ -16,7 +16,7 @@ Renames and re-exports [mermaidAPI](mermaidAPI.md#mermaidapi) ### mermaidAPI -• `Const` **mermaidAPI**: `Readonly`<{ `defaultConfig`: `MermaidConfig` = configApi.defaultConfig; `getConfig`: () => `MermaidConfig` = configApi.getConfig; `getSiteConfig`: () => `MermaidConfig` = configApi.getSiteConfig; `globalReset`: () => `void` ; `initialize`: (`options`: `MermaidConfig`) => `Promise`<`void`> ; `parse`: (`text`: `string`, `parseError?`: `ParseErrorFunction`) => `boolean` ; `parseDirective`: (`p`: `any`, `statement`: `string`, `context`: `string`, `type`: `string`) => `void` ; `render`: (`id`: `string`, `text`: `string`, `cb`: (`svgCode`: `string`, `bindFunctions?`: (`element`: `Element`) => `void`) => `void`, `container?`: `Element`) => `Promise`<`void`> ; `reset`: () => `void` ; `setConfig`: (`conf`: `MermaidConfig`) => `MermaidConfig` = configApi.setConfig; `updateSiteConfig`: (`conf`: `MermaidConfig`) => `MermaidConfig` = configApi.updateSiteConfig }> +• `Const` **mermaidAPI**: `Readonly`<{ `defaultConfig`: `MermaidConfig` = configApi.defaultConfig; `getConfig`: () => `MermaidConfig` = configApi.getConfig; `getSiteConfig`: () => `MermaidConfig` = configApi.getSiteConfig; `globalReset`: () => `void` ; `initialize`: (`options`: `MermaidConfig`) => `Promise`<`void`> ; `parse`: (`text`: `string`, `parseError?`: `ParseErrorFunction`) => `boolean` ; `parseDirective`: (`p`: `any`, `statement`: `string`, `context`: `string`, `type`: `string`) => `void` ; `render`: (`id`: `string`, `text`: `string`, `cb`: (`svgCode`: `string`, `bindFunctions?`: (`element`: `Element`) => `void`) => `void`, `svgContainingElement?`: `Element`) => `Promise`<`void`> ; `reset`: () => `void` ; `setConfig`: (`conf`: `MermaidConfig`) => `MermaidConfig` = configApi.setConfig; `updateSiteConfig`: (`conf`: `MermaidConfig`) => `MermaidConfig` = configApi.updateSiteConfig }> ## mermaidAPI configuration defaults @@ -80,19 +80,152 @@ mermaid.initialize(config); #### Defined in -[mermaidAPI.ts:546](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/mermaidAPI.ts#L546) +[mermaidAPI.ts:740](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/mermaidAPI.ts#L740) ## Functions +### appendDivSvgG + +▸ **appendDivSvgG**(`parentRoot`, `id`, `enclosingDivId`, `divStyle?`, `svgXlink?`): `any` + +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 + +#### Parameters + +| Name | Type | Description | +| :--------------- | :------- | :----------------------------------------------- | +| `parentRoot` | `any` | the d3 node to append things to | +| `id` | `string` | the value to set the id attr to | +| `enclosingDivId` | `string` | the id to set the enclosing div to | +| `divStyle?` | `string` | if given, the style to set the enclosing div to | +| `svgXlink?` | `string` | if given, the link to set the new svg element to | + +#### Returns + +`any` + +- returns the parentRoot that had nodes appended + +#### Defined in + +[mermaidAPI.ts:283](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/mermaidAPI.ts#L283) + +--- + +### cleanUpSvgCode + +▸ **cleanUpSvgCode**(`svgCode?`, `inSandboxMode`, `useArrowMarkerUrls`): `string` + +Clean up svgCode. Do replacements needed + +#### Parameters + +| Name | Type | Default value | Description | +| :------------------- | :-------- | :------------ | :---------------------------------------------------------- | +| `svgCode` | `string` | `''` | the code to clean up | +| `inSandboxMode` | `boolean` | `undefined` | security level | +| `useArrowMarkerUrls` | `boolean` | `undefined` | should arrow marker's use full urls? (vs. just the anchors) | + +#### Returns + +`string` + +the cleaned up svgCode + +#### Defined in + +[mermaidAPI.ts:234](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/mermaidAPI.ts#L234) + +--- + +### createCssStyles + +▸ **createCssStyles**(`config`, `graphType`, `classDefs?`): `string` + +Create the user styles + +#### Parameters + +| Name | Type | Description | +| :---------- | :-------------- | :----------------------------------------------------- | ------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------- | +| `config` | `MermaidConfig` | configuration that has style and theme settings to use | +| `graphType` | `string` | used for checking if classDefs should be applied | +| `classDefs` | `undefined` | `null` | `Record`<`string`, `DiagramStyleClassDef`> | 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 + +#### Defined in + +[mermaidAPI.ts:161](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/mermaidAPI.ts#L161) + +--- + +### createUserStyles + +▸ **createUserStyles**(`config`, `graphType`, `classDefs`, `svgId`): `string` + +#### Parameters + +| Name | Type | +| :---------- | :----------------------------------------- | +| `config` | `MermaidConfig` | +| `graphType` | `string` | +| `classDefs` | `Record`<`string`, `DiagramStyleClassDef`> | +| `svgId` | `string` | + +#### Returns + +`string` + +#### Defined in + +[mermaidAPI.ts:211](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/mermaidAPI.ts#L211) + +--- + +### cssImportantStyles + +▸ **cssImportantStyles**(`cssClass`, `element`, `cssClasses?`): `string` + +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; + +#### Parameters + +| Name | Type | Default value | Description | +| :----------- | :---------- | :------------ | :--------------------------------------------- | +| `cssClass` | `string` | `undefined` | CSS class name | +| `element` | `string` | `undefined` | CSS element | +| `cssClasses` | `string`\[] | `[]` | list of CSS styles to append after the element | + +#### Returns + +`string` + +- the constructed string + +#### Defined in + +[mermaidAPI.ts:145](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/mermaidAPI.ts#L145) + +--- + ### decodeEntities ▸ **decodeEntities**(`text`): `string` #### Parameters -| Name | Type | -| :----- | :------- | -| `text` | `string` | +| Name | Type | Description | +| :----- | :------- | :----------------- | +| `text` | `string` | text to be decoded | #### Returns @@ -100,7 +233,7 @@ mermaid.initialize(config); #### Defined in -[mermaidAPI.ts:72](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/mermaidAPI.ts#L72) +[mermaidAPI.ts:119](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/mermaidAPI.ts#L119) --- @@ -110,9 +243,9 @@ mermaid.initialize(config); #### Parameters -| Name | Type | -| :----- | :------- | -| `text` | `string` | +| Name | Type | Description | +| :----- | :------- | :----------------- | +| `text` | `string` | text to be encoded | #### Returns @@ -120,4 +253,56 @@ mermaid.initialize(config); #### Defined in -[mermaidAPI.ts:46](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/mermaidAPI.ts#L46) +[mermaidAPI.ts:90](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/mermaidAPI.ts#L90) + +--- + +### putIntoIFrame + +▸ **putIntoIFrame**(`svgCode?`, `svgElement?`): `string` + +Put the svgCode into an iFrame. Return the iFrame code + +#### Parameters + +| Name | Type | Default value | Description | +| :------------ | :------- | :------------ | :--------------------------------------------------------------------------- | +| `svgCode` | `string` | `''` | the svg code to put inside the iFrame | +| `svgElement?` | `any` | `undefined` | 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 replace btoa(). Replace with buf.toString('base64')? + +#### Defined in + +[mermaidAPI.ts:262](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/mermaidAPI.ts#L262) + +--- + +### removeExistingElements + +▸ **removeExistingElements**(`doc`, `isSandboxed`, `id`, `divSelector`, `iFrameSelector`): `void` + +Remove any existing elements from the given document + +#### Parameters + +| Name | Type | Description | +| :--------------- | :--------- | :---------------------------------------------- | +| `doc` | `Document` | the document to removed elements from | +| `isSandboxed` | `boolean` | whether or not we are in sandboxed mode | +| `id` | `string` | id for any existing SVG element | +| `divSelector` | `string` | selector for any existing enclosing div element | +| `iFrameSelector` | `string` | selector for any existing iFrame element | + +#### Returns + +`void` + +#### Defined in + +[mermaidAPI.ts:334](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/mermaidAPI.ts#L334) diff --git a/packages/mermaid/src/diagrams/flowchart/flowDb.js b/packages/mermaid/src/diagrams/flowchart/flowDb.js index e91ab2fefd..6abc22659a 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.js b/packages/mermaid/src/mermaidAPI.spec.js deleted file mode 100644 index 241b5ec864..0000000000 --- a/packages/mermaid/src/mermaidAPI.spec.js +++ /dev/null @@ -1,150 +0,0 @@ -'use strict'; -import mermaid from './mermaid'; -import mermaidAPI from './mermaidAPI'; -import assignWithDepth from './assignWithDepth'; - -describe('when using mermaidAPI and ', function () { - describe('doing initialize ', function () { - beforeEach(function () { - document.body.innerHTML = ''; - mermaidAPI.globalReset(); - }); - - it('should copy a literal into the configuration', function () { - const orgConfig = mermaidAPI.getConfig(); - expect(orgConfig.testLiteral).toBe(undefined); - - mermaidAPI.initialize({ testLiteral: true }); - const config = mermaidAPI.getConfig(); - - expect(config.testLiteral).toBe(true); - }); - it('should copy a an object into the configuration', function () { - const orgConfig = mermaidAPI.getConfig(); - expect(orgConfig.testObject).toBe(undefined); - - const object = { - test1: 1, - test2: false, - }; - - mermaidAPI.initialize({ testObject: object }); - let config = mermaidAPI.getConfig(); - - expect(config.testObject.test1).toBe(1); - mermaidAPI.updateSiteConfig({ testObject: { test3: true } }); - 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 = { - logLevel: 0, - securityLevel: 'loose', - }; - mermaidAPI.initialize(config); - mermaidAPI.setConfig({ securityLevel: 'strict', logLevel: 1 }); - expect(mermaidAPI.getConfig().logLevel).toBe(1); - expect(mermaidAPI.getConfig().securityLevel).toBe('strict'); - mermaidAPI.reset(); - expect(mermaidAPI.getConfig().logLevel).toBe(0); - expect(mermaidAPI.getConfig().securityLevel).toBe('loose'); - mermaidAPI.globalReset(); - expect(mermaidAPI.getConfig().logLevel).toBe(5); - expect(mermaidAPI.getConfig().securityLevel).toBe('strict'); - }); - - it('should prevent changes to site defaults (sneaky)', function () { - let config = { - logLevel: 0, - }; - mermaidAPI.initialize(config); - const siteConfig = mermaidAPI.getSiteConfig(); - expect(mermaidAPI.getConfig().logLevel).toBe(0); - config.secure = { - toString: function () { - mermaidAPI.initialize({ securityLevel: 'loose' }); - }, - }; - // mermaidAPI.reinitialize(config); - expect(mermaidAPI.getConfig().secure).toEqual(mermaidAPI.getSiteConfig().secure); - expect(mermaidAPI.getConfig().securityLevel).toBe('strict'); - mermaidAPI.reset(); - expect(mermaidAPI.getSiteConfig()).toEqual(siteConfig); - expect(mermaidAPI.getConfig()).toEqual(siteConfig); - }); - it('should prevent clobbering global defaults (direct)', function () { - let config = assignWithDepth({}, mermaidAPI.defaultConfig); - assignWithDepth(config, { logLevel: 0 }); - - let error = { message: '' }; - try { - mermaidAPI['defaultConfig'] = config; - } catch (e) { - error = e; - } - expect(error.message).toBe( - "Cannot assign to read only property 'defaultConfig' of object '#'" - ); - expect(mermaidAPI.defaultConfig['logLevel']).toBe(5); - }); - it('should prevent changes to global defaults (direct)', function () { - let error = { message: '' }; - try { - mermaidAPI.defaultConfig['logLevel'] = 0; - } catch (e) { - error = e; - } - expect(error.message).toBe( - "Cannot assign to read only property 'logLevel' of object '#'" - ); - expect(mermaidAPI.defaultConfig['logLevel']).toBe(5); - }); - it('should prevent sneaky changes to global defaults (assignWithDepth)', function () { - let config = { - logLevel: 0, - }; - let error = { message: '' }; - try { - assignWithDepth(mermaidAPI.defaultConfig, config); - } catch (e) { - error = e; - } - expect(error.message).toBe( - "Cannot assign to read only property 'logLevel' of object '#'" - ); - expect(mermaidAPI.defaultConfig['logLevel']).toBe(5); - }); - }); - 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']); - }); - }); - describe('test mermaidApi.parse() for checking validity of input ', function () { - mermaid.parseError = undefined; // ensure it parseError undefined - it('should throw 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 () { - 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 () { - let parseErrorWasCalled = false; - // also test setParseErrorHandler() call working to set mermaid.parseError - expect( - mermaidAPI.parse('this is not a mermaid diagram definition', () => { - parseErrorWasCalled = true; - }) - ).toEqual(false); - expect(parseErrorWasCalled).toEqual(true); - }); - it('should return true for valid definition', function () { - expect(mermaidAPI.parse('graph TD;A--x|text including URL space|B;')).toEqual(true); - }); - }); -}); diff --git a/packages/mermaid/src/mermaidAPI.spec.ts b/packages/mermaid/src/mermaidAPI.spec.ts new file mode 100644 index 0000000000..786b163c4c --- /dev/null +++ b/packages/mermaid/src/mermaidAPI.spec.ts @@ -0,0 +1,649 @@ +'use strict'; +import { vi } from 'vitest'; + +import mermaid from './mermaid'; +import { MermaidConfig } from './config.type'; + +import mermaidAPI, { removeExistingElements } from './mermaidAPI'; +import { + encodeEntities, + decodeEntities, + createCssStyles, + createUserStyles, + appendDivSvgG, + cleanUpSvgCode, + putIntoIFrame, +} from './mermaidAPI'; + +import assignWithDepth from './assignWithDepth'; + +// -------------- +// 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(), + 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('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;'; + 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('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); + // @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', 'dtheId'); + 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', 'dtheId'); + 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', 'dtheId'); + 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: MermaidConfig = { + themeCSS: 'default', + fontFamily: serif, + altFontFamily: sansSerif, + htmlLabels: true, + }; + + 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', {}); + 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', undefined); + 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: string) { + 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(''); + } + + // 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( + 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; }`) + ); + } + + // 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(textStyles).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(textStyles).not.toMatch( + new RegExp( + `\\.classDef1 ${escapeForRegexp(htmlElement)} \\{ textStyle(.*) !important; }` + ) + ); + } + + // 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']; + + 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: MermaidConfig = { + themeCSS: 'default', + fontFamily: 'serif', + altFontFamily: 'sans-serif', + flowchart: { + htmlLabels: true, + }, + }; + 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: true, + 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', {}, 'someId'); + expect(getStyles).toHaveBeenCalled(); + }); + + it('returns the result of compiling, stringifying, and serializing the css code with stylis', () => { + const result = createUserStyles(mockConfig, 'someDiagram', {}, 'someId'); + expect(compile).toHaveBeenCalled(); + expect(serialize).toHaveBeenCalled(); + expect(result).toEqual('stylis serialized css'); + }); + }); + + 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('initialize', function () { + beforeEach(function () { + document.body.innerHTML = ''; + mermaidAPI.globalReset(); + }); + + it('copies a literal into the configuration', function () { + const orgConfig: any = mermaidAPI.getConfig(); + expect(orgConfig.testLiteral).toBe(undefined); + + const testConfig: any = { testLiteral: true }; + + mermaidAPI.initialize(testConfig); + const config: any = mermaidAPI.getConfig(); + + expect(config.testLiteral).toBe(true); + }); + + it('copies a an object into the configuration', function () { + const orgConfig: any = mermaidAPI.getConfig(); + expect(orgConfig.testObject).toBe(undefined); + + const object = { + test1: 1, + test2: false, + }; + + const testConfig: any = { testObject: object }; + + mermaidAPI.initialize(testConfig); + + let config: any = mermaidAPI.getConfig(); + + expect(config.testObject.test1).toBe(1); + + 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('resets mermaid config to global defaults', function () { + const config = { + logLevel: 0, + securityLevel: 'loose', + }; + mermaidAPI.initialize(config); + mermaidAPI.setConfig({ securityLevel: 'strict', logLevel: 1 }); + expect(mermaidAPI.getConfig().logLevel).toBe(1); + expect(mermaidAPI.getConfig().securityLevel).toBe('strict'); + mermaidAPI.reset(); + expect(mermaidAPI.getConfig().logLevel).toBe(0); + expect(mermaidAPI.getConfig().securityLevel).toBe('loose'); + mermaidAPI.globalReset(); + expect(mermaidAPI.getConfig().logLevel).toBe(5); + expect(mermaidAPI.getConfig().securityLevel).toBe('strict'); + }); + + it('prevents changes to site defaults (sneaky)', function () { + const config: any = { + logLevel: 0, + }; + mermaidAPI.initialize(config); + const siteConfig = mermaidAPI.getSiteConfig(); + expect(mermaidAPI.getConfig().logLevel).toBe(0); + config.secure = { + toString: function () { + mermaidAPI.initialize({ securityLevel: 'loose' }); + }, + }; + // mermaidAPI.reinitialize(config); + expect(mermaidAPI.getConfig().secure).toEqual(mermaidAPI.getSiteConfig().secure); + expect(mermaidAPI.getConfig().securityLevel).toBe('strict'); + mermaidAPI.reset(); + expect(mermaidAPI.getSiteConfig()).toEqual(siteConfig); + expect(mermaidAPI.getConfig()).toEqual(siteConfig); + }); + it('prevents clobbering global defaults (direct)', function () { + const config = assignWithDepth({}, mermaidAPI.defaultConfig); + assignWithDepth(config, { logLevel: 0 }); + + 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; + } + expect(error.message).toBe( + "Cannot assign to read only property 'defaultConfig' of object '#'" + ); + expect(mermaidAPI.defaultConfig['logLevel']).toBe(5); + }); + it('prevents changes to global defaults (direct)', function () { + let error: any = { message: '' }; + try { + mermaidAPI.defaultConfig['logLevel'] = 0; + } catch (e) { + error = e; + } + expect(error.message).toBe( + "Cannot assign to read only property 'logLevel' of object '#'" + ); + expect(mermaidAPI.defaultConfig['logLevel']).toBe(5); + }); + it('prevents sneaky changes to global defaults (assignWithDepth)', function () { + const config = { + logLevel: 0, + }; + let error: any = { message: '' }; + try { + assignWithDepth(mermaidAPI.defaultConfig, config); + } catch (e) { + error = e; + } + expect(error.message).toBe( + "Cannot assign to read only property 'logLevel' of object '#'" + ); + expect(mermaidAPI.defaultConfig['logLevel']).toBe(5); + }); + }); + describe('dompurify config', function () { + it('allows dompurify config to be set', function () { + mermaidAPI.initialize({ dompurifyConfig: { ADD_ATTR: ['onclick'] } }); + + expect(mermaidAPI!.getConfig()!.dompurifyConfig!.ADD_ATTR).toEqual(['onclick']); + }); + }); + describe('parse', function () { + mermaid.parseError = undefined; // ensure it parseError undefined + 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('does not throw for a valid definition', function () { + expect(() => mermaidAPI.parse('graph TD;A--x|text including URL space|B;')).not.toThrow(); + }); + 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( + mermaidAPI.parse('this is not a mermaid diagram definition', () => { + parseErrorWasCalled = true; + }) + ).toEqual(false); + expect(parseErrorWasCalled).toEqual(true); + }); + it('returns true for valid definition', function () { + expect(mermaidAPI.parse('graph TD;A--x|text including URL space|B;')).toEqual(true); + }); + }); +}); diff --git a/packages/mermaid/src/mermaidAPI.ts b/packages/mermaid/src/mermaidAPI.ts index 384b456f82..b921655ab3 100644 --- a/packages/mermaid/src/mermaidAPI.ts +++ b/packages/mermaid/src/mermaidAPI.ts @@ -29,10 +29,49 @@ import utils, { directiveSanitizer } from './utils'; import DOMPurify from 'dompurify'; import { MermaidConfig } from './config.type'; import { evaluate } from './diagrams/common/common'; +import { isEmpty } from 'lodash'; // diagram names that support classDef statements const CLASSDEF_DIAGRAMS = ['graph', 'flowchart', 'flowchart-v2', 'stateDiagram']; +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_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 +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.'; + +// 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. +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. +type D3Element = any; + +// ---------------------------------------------------------------------------- + /** * @param text - The mermaid diagram definition. * @param parseError - If set, handles errors. @@ -43,16 +82,19 @@ function parse(text: string, parseError?: ParseErrorFunction): boolean { return diagram.parse(text, parseError); } +/** + * + * @param text - text to be encoded + * @returns + */ 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; + txt = txt.replace(/style.*:\S*#.*;/g, function (s): string { + 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; + txt = txt.replace(/classDef.*:\S*#.*;/g, function (s): string { + return s.substring(0, s.length - 1); }); txt = txt.replace(/#\w+;/g, function (s) { @@ -69,6 +111,11 @@ export const encodeEntities = function (text: string): string { return txt; }; +/** + * + * @param text - text to be decoded + * @returns + */ export const decodeEntities = function (text: string): string { let txt = text; @@ -84,6 +131,226 @@ 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 cssClass - CSS class name + * @param element - CSS element + * @param cssClasses - list of CSS styles to append after the element + * @returns - the constructed string + */ +export const cssImportantStyles = ( + cssClass: string, + element: string, + cssClasses: string[] = [] +): string => { + return `\n.${cssClass} ${element} { ${cssClasses.join(' !important; ')} !important; }`; +}; + +/** + * Create the user styles + * + * @param config - configuration that has style and theme settings to use + * @param graphType - used for checking if classDefs should be applied + * @param classDefs - the classDefs in the diagram text. Might be null if none were defined. Usually is the result of a call to getClasses(...) + * @returns the string with all the user styles + */ +export const createCssStyles = ( + config: MermaidConfig, + graphType: string, + 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 sanitize CSS function 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 (!isEmpty(classDefs)) { + if (CLASSDEF_DIAGRAMS.includes(graphType)) { + const htmlLabels = config.htmlLabels || config.flowchart?.htmlLabels; // TODO why specifically check the Flowchart diagram config? + + const cssHtmlElements = ['> *', 'span']; // TODO make a constant + const cssShapeElements = ['rect', 'polygon', 'ellipse', 'circle']; // 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 (!isEmpty(styleClassDef.styles)) { + 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 (!isEmpty(styleClassDef.textStyles)) { + cssStyles += cssImportantStyles(styleClassDef.id, 'tspan', styleClassDef.textStyles); + } + } + } + } + return cssStyles; +}; + +export const createUserStyles = ( + config: MermaidConfig, + graphType: string, + classDefs: Record, + 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 svgCode - the code to clean up + * @param inSandboxMode - security level + * @param useArrowMarkerUrls - should arrow marker's use full urls? (vs. just the anchors) + * @returns the cleaned up svgCode + */ +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 svgCode - the svg code to put inside the iFrame + * @param svgElement - the d3 node that has the current svgElement so we can get the height from it + * @returns - the code with the iFrame that now contains the svgCode + * TODO replace btoa(). Replace with buf.toString('base64')? + */ +export const putIntoIFrame = (svgCode = '', svgElement?: D3Element): string => { + const height = svgElement ? svgElement.viewBox.baseVal.height + 'px' : IFRAME_HEIGHT; + 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 parentRoot - the d3 node to append things to + * @param id - the value to set the id attr to + * @param enclosingDivId - the id to set the enclosing div to + * @param divStyle - if given, the style to set the enclosing div to + * @param svgXlink - if given, the link to set the new svg element to + * @returns - 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 parentNode - the d3 node to append the iFrame node to + * @param iFrameId - id to use for the iFrame + * @returns 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', ''); +} + +/** + * Remove any existing elements from the given document + * + * @param doc - the document to removed elements from + * @param isSandboxed - whether or not we are in sandboxed mode + * @param id - id for any existing SVG element + * @param divSelector - selector for any existing enclosing div element + * @param 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. * @@ -100,10 +367,12 @@ export const decodeEntities = function (text: string): string { * }); * ``` * - * @param id - The id of the element to be rendered - * @param text - The graph definition - * @param cb - Callback which is called after rendering is finished with the svg code as param. - * @param container - Selector to element in which a div with the graph temporarily will be + * @param id - The id for the SVG element (the element to be rendered) + * @param text - The text for the graph definition + * @param cb - Callback which is called after rendering is finished with the svg code as in param. + * @param svgContainingElement - HTML element where the svg will be inserted. (Is usually element with the .mermaid class) + * If no svgContainingElement is provided then the SVG element will be appended to the body. + * 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. * @returns - Resolves when finished rendering. @@ -112,115 +381,92 @@ const render = async function ( id: string, text: string, cb: (svgCode: string, bindFunctions?: (element: Element) => void) => void, - container?: Element + svgContainingElement?: Element ): Promise { addDiagrams(); + configApi.reset(); - text = text.replace(/\r\n?/g, '\n'); // parser problems on CRLF ignore all CR and leave LF;; + + // 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 cnf = configApi.getConfig(); - log.debug(cnf); + const config = configApi.getConfig(); + log.debug(config); // 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'; + // TODO: Remove magic number + if (text.length > (config?.maxTextSize ?? 50000)) { + 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; + const enclosingDivID_selector = '#' + enclosingDivID; + 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 - if (container) { - container.innerHTML = ''; + const isSandboxed = config.securityLevel === SECURITY_LVL_SANDBOX; + const isLooseSecurityLevel = config.securityLevel === SECURITY_LVL_LOOSE; + + const fontFamily = config.fontFamily; + + // ------------------------------------------------------------------------------- + // 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 = ''; } - if (cnf.securityLevel === 'sandbox') { - // IF we are in sandboxed mode, we do everything mermaid related - // in a sandboxed div - const iframe = select(container) - .append('iframe') - .attr('id', 'i' + id) - .attr('style', 'width: 100%; height: 100%;') - .attr('sandbox', ''); - // const iframeBody = ; + if (isSandboxed) { + // 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(container); + root = select(svgContainingElement); } - - root - .append('div') - .attr('id', 'd' + id) - .attr('style', 'font-family: ' + cnf.fontFamily) - .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') - .append('g'); + appendDivSvgG(root, id, enclosingDivID, `font-family: ${fontFamily}`, XMLNS_XLINK_STD); } else { - // No container 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 tpm element if it exists - let element; - if (cnf.securityLevel === 'sandbox') { - element = document.querySelector('#i' + id); - } else { - element = document.querySelector('#d' + id); - } - - if (element) { - element.remove(); - } + // No svgContainingElement was provided - // Add the tmp div used for rendering with the id `d${id}` - // d+id it will contain a svg with the id "id" + // 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); - if (cnf.securityLevel === 'sandbox') { - // IF we are in sandboxed mode, we do everything mermaid related - // in a sandboxed div - const iframe = select('body') - .append('iframe') - .attr('id', 'i' + id) - .attr('style', 'width: 100%; height: 100%;') - .attr('sandbox', ''); + // 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 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'); } - // This is the temporary div - root - .append('div') - .attr('id', 'd' + id) - // this is the seed of the svg to be rendered - .append('svg') - .attr('id', id) - .attr('width', '100%') - .attr('xmlns', 'http://www.w3.org/2000/svg') - .append('g'); + appendDivSvgG(root, id, enclosingDivID); } 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; + try { // diag = new Diagram(text); diag = await getDiagramFromText(text); @@ -228,75 +474,35 @@ const render = async function ( diag = new Diagram('error'); parseEncounteredException = error; } - // Get the tmp element containing the the svg - const element = root.select('#d' + id).node(); + + // Get the temporary div element containing the svg + const element = root.select(enclosingDivID_selector).node(); const graphType = diag.type; - // insert inline style into svg + // ------------------------------------------------------------------------------- + // Create and insert the styles (user styles, theme styles, config styles) + + // Insert an element into svg. This is where we put the styles 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 sanitizeCSS function - if (cnf.themeCSS !== undefined) { - userStyles += `\n${cnf.themeCSS}`; - } - // user provided theme CSS - if (cnf.fontFamily !== undefined) { - userStyles += `\n:root { --mermaid-font-family: ${cnf.fontFamily}}`; - } - // user provided theme CSS - if (cnf.altFontFamily !== undefined) { - userStyles += `\n:root { --mermaid-alt-font-family: ${cnf.altFontFamily}}`; - } - - // classDef - if (CLASSDEF_DIAGRAMS.includes(graphType)) { - const classes: any = diag.renderer.getClasses(text, diag); - const htmlLabels = cnf.htmlLabels || cnf.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 stylis = (selector: string, styles: string) => - serialize(compile(`${selector}{${styles}}`), stringify); - const rules = stylis(`#${id}`, getStyles(graphType, userStyles, cnf.themeVariables)); + const diagramClassDefs = CLASSDEF_DIAGRAMS.includes(graphType) + ? diag.renderer.getClasses(text, diag) + : {}; + + const rules = createUserStyles( + config, + graphType, + // @ts-ignore convert renderer to TS. + diagramClassDefs, + idSelector + ); const style1 = document.createElement('style'); - style1.innerHTML = `#${id} ` + rules; + style1.innerHTML = `${idSelector} ` + rules; svg.insertBefore(style1, firstChild); + // ------------------------------------------------------------------------------- + // Draw the diagram with the renderer try { await diag.renderer.draw(text, id, pkg.version, diag); } catch (e) { @@ -304,45 +510,29 @@ const render = async function ( throw e; } - root - .select(`[id="${id}"]`) - .selectAll('foreignobject > *') - .attr('xmlns', 'http://www.w3.org/1999/xhtml'); + // ------------------------------------------------------------------------------- + // Clean up SVG code + 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') { - 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, '
'); - - if (cnf.securityLevel === 'sandbox') { - const svgEl = root.select('#d' + id + ' svg').node(); - const width = '100%'; - let height = '100%'; - if (svgEl) { - height = svgEl.viewBox.baseVal.height + 'px'; - } - svgCode = ``; - } else { - if (cnf.securityLevel !== 'loose') { - svgCode = DOMPurify.sanitize(svgCode, { - ADD_TAGS: ['foreignobject'], - ADD_ATTR: ['dominant-baseline'], - }); - } + let svgCode = root.select(enclosingDivID_selector).node().innerHTML; + + log.debug('config.arrowMarkerAbsolute', config.arrowMarkerAbsolute); + svgCode = cleanUpSvgCode(svgCode, isSandboxed, evaluate(config.arrowMarkerAbsolute)); + + if (isSandboxed) { + const svgEl = root.select(enclosingDivID_selector + ' svg').node(); + svgCode = putIntoIFrame(svgCode, svgEl); + } else if (isLooseSecurityLevel) { + // Sanitize the svgCode using DOMPurify + svgCode = DOMPurify.sanitize(svgCode, { + ADD_TAGS: DOMPURE_TAGS, + ADD_ATTR: DOMPURE_ATTR, + }); } + // ------------------------------------------------------------------------------- + // Do any callbacks (cb = callback) if (typeof cb !== 'undefined') { switch (graphType) { case 'flowchart': @@ -364,7 +554,9 @@ const render = async function ( } attachFunctions(); - const tmpElementSelector = cnf.securityLevel === 'sandbox' ? '#i' + id : '#d' + id; + // ------------------------------------------------------------------------------- + // 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(); @@ -454,7 +646,9 @@ const handleDirective = function (p: any, directive: any, type: string): void { } }; -/** @param options - Initial Mermaid options */ +/** + * @param options - Initial Mermaid options + * */ async function initialize(options: MermaidConfig) { // Handle legacy location of font-family configuration if (options?.fontFamily) { diff --git a/packages/mermaid/src/tests/MockedD3.ts b/packages/mermaid/src/tests/MockedD3.ts new file mode 100644 index 0000000000..d7c16b3a87 --- /dev/null +++ b/packages/mermaid/src/tests/MockedD3.ts @@ -0,0 +1,126 @@ +/** + * 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(); +} diff --git a/packages/mermaid/typedoc.json b/packages/mermaid/typedoc.json index 0e3b12b919..971f6d562f 100644 --- a/packages/mermaid/typedoc.json +++ b/packages/mermaid/typedoc.json @@ -2,7 +2,7 @@ "plugin": ["typedoc-plugin-markdown"], "readme": "none", "githubPages": false, - "gitRemote": "origin", + "sourceLinkTemplate": "https://github.com/mermaid-js/mermaid/blob/{gitRevision}/{path}#L{line}", "gitRevision": "master", "out": "src/docs/config/setup", "entryPointStrategy": "expand",