diff --git a/.changeset/cold-pillows-judge.md b/.changeset/cold-pillows-judge.md new file mode 100644 index 00000000000..828a68f3397 --- /dev/null +++ b/.changeset/cold-pillows-judge.md @@ -0,0 +1,8 @@ +--- +'mermaid': patch +--- + +fix: handle CSS sibling combinators in compileCSS + +author: aloisklink +commit: 12d472c9ed43f94814b110da8d7a9ae6dd5266ed diff --git a/.changeset/curly-lands-taste.md b/.changeset/curly-lands-taste.md new file mode 100644 index 00000000000..8194cb614e4 --- /dev/null +++ b/.changeset/curly-lands-taste.md @@ -0,0 +1,12 @@ +--- +'mermaid': patch +--- + +fix: increase protections against prototype pollution + +User-controlled input already has protections against prototype pollution. + +Fixes: GHSA-c4c3-pg64-4m4v + +commit: 2cd6dcf735533b323507e3e889ffdea870540b43 +author: @aloisklink diff --git a/.changeset/dry-ducks-agree.md b/.changeset/dry-ducks-agree.md new file mode 100644 index 00000000000..ea69e7f619d --- /dev/null +++ b/.changeset/dry-ducks-agree.md @@ -0,0 +1,11 @@ +--- +'mermaid': patch +--- + +fix(architecture): use `Map`s and `Set`s to store groups/services + +Services are now rendered in the order they are defined and more service IDs +are now supported. + +author: aloisklink +commit: 99af3fc35ef0a9a9c8c6314521344d67523ddccf diff --git a/.changeset/proud-breads-stick.md b/.changeset/proud-breads-stick.md new file mode 100644 index 00000000000..627f0a7ae74 --- /dev/null +++ b/.changeset/proud-breads-stick.md @@ -0,0 +1,8 @@ +--- +'mermaid': minor +--- + +deprecate: Deprecate the `mermaidAPI.setConfig()` function + +commit: 2cd6dcf735533b323507e3e889ffdea870540b43 +author: @aloisklink diff --git a/.changeset/solid-clubs-care.md b/.changeset/solid-clubs-care.md new file mode 100644 index 00000000000..5fcbda063ee --- /dev/null +++ b/.changeset/solid-clubs-care.md @@ -0,0 +1,8 @@ +--- +'mermaid': patch +--- + +fix(xychart): support zero-width x-axis ranges + +author: aloisklink +commit: 630aa7e5dd417e1f56bff2a1ce8df2c5ad08d289 diff --git a/.changeset/wet-emus-sit.md b/.changeset/wet-emus-sit.md new file mode 100644 index 00000000000..160bcb90f0f --- /dev/null +++ b/.changeset/wet-emus-sit.md @@ -0,0 +1,9 @@ +--- +'mermaid': patch +--- + +fix(radar): limit number of ticks to 32 + +Setting a ticks value higher than this would only show 32 ticks. + +commit: 59b22fad2b3bb04f87a476c84a8a2b24679e607e diff --git a/.cspell/misc-terms.txt b/.cspell/misc-terms.txt index ff21dd8b6f6..1bee84991b5 100644 --- a/.cspell/misc-terms.txt +++ b/.cspell/misc-terms.txt @@ -2,6 +2,7 @@ BRANDES browserlist Buzan circo +GHSA handDrawn KOEPF MMLU diff --git a/cypress/integration/rendering/xychart/xyChart.spec.js b/cypress/integration/rendering/xychart/xyChart.spec.js index e4eb01b9ed2..0736a02025c 100644 --- a/cypress/integration/rendering/xychart/xyChart.spec.js +++ b/cypress/integration/rendering/xychart/xyChart.spec.js @@ -102,6 +102,20 @@ describe('XY Chart', () => { {} ); }); + it('x-axis range with same values is supported', () => { + imgSnapshotTest( + `--- +title: Dirac delta function +--- +xychart-beta + x-axis "Only 0 matters" 0 --> 0 + line "dirac" [0, 0.5, 1] +`, + {} + ); + + cy.get('g.plot g.line-plot-0 path').should('exist'); + }); it('Render spark line with "plotReservedSpacePercent"', () => { imgSnapshotTest( ` diff --git a/demos/dev/example.html b/demos/dev/example.html index cc49ddffb73..487ed70e60a 100644 --- a/demos/dev/example.html +++ b/demos/dev/example.html @@ -1,5 +1,4 @@ - - + Mermaid development page @@ -21,13 +20,16 @@ -
+    
+
 graph TB
       a --> b
       a --> c
       b --> d
       c --> d
-    
+
+
info
+
Type code to view diagram: @@ -35,22 +37,56 @@
-
info
+ + + + + + + + + + + + +
Error +
+
Render time + +
diff --git a/docs/config/setup/config/README.md b/docs/config/setup/config/README.md index fa87267edf5..e84f71a9e8c 100644 --- a/docs/config/setup/config/README.md +++ b/docs/config/setup/config/README.md @@ -25,7 +25,6 @@ - [reset](functions/reset.md) - [sanitize](functions/sanitize.md) - [saveConfigFromInitialize](functions/saveConfigFromInitialize.md) -- [setConfig](functions/setConfig.md) +- [~~setConfig~~](functions/setConfig.md) - [setSiteConfig](functions/setSiteConfig.md) -- [updateCurrentConfig](functions/updateCurrentConfig.md) - [updateSiteConfig](functions/updateSiteConfig.md) diff --git a/docs/config/setup/config/functions/addDirective.md b/docs/config/setup/config/functions/addDirective.md index e5a0f3a70f0..804f797b3fa 100644 --- a/docs/config/setup/config/functions/addDirective.md +++ b/docs/config/setup/config/functions/addDirective.md @@ -12,7 +12,7 @@ > **addDirective**(`directive`): `void` -Defined in: [packages/mermaid/src/config.ts:197](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.ts#L197) +Defined in: [packages/mermaid/src/config.ts:173](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.ts#L173) Pushes in a directive to the configuration diff --git a/docs/config/setup/config/functions/getConfig.md b/docs/config/setup/config/functions/getConfig.md index 4be63b52ed3..bb76243817d 100644 --- a/docs/config/setup/config/functions/getConfig.md +++ b/docs/config/setup/config/functions/getConfig.md @@ -12,18 +12,17 @@ > **getConfig**(): [`MermaidConfig`](../../mermaid/interfaces/MermaidConfig.md) -Defined in: [packages/mermaid/src/config.ts:140](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.ts#L140) +Defined in: [packages/mermaid/src/config.ts:120](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.ts#L120) -## getConfig - -| Function | Description | Type | Return Values | -| --------- | ------------------------- | ----------- | ------------------------------ | -| getConfig | Obtains the currentConfig | Get Request | Any Values from current Config | - -**Notes**: Avoid calling this function repeatedly. Instead, store the result in a variable and use it, and pass it down to function calls. +Returns a copy of the `currentConfig`. ## Returns [`MermaidConfig`](../../mermaid/interfaces/MermaidConfig.md) The currentConfig + +## Remarks + +Avoid calling this function repeatedly. +Instead, store the result in a variable and use it, and pass it down to function calls. diff --git a/docs/config/setup/config/functions/getEffectiveHtmlLabels.md b/docs/config/setup/config/functions/getEffectiveHtmlLabels.md index 689fe7afc16..eb1534ae7a9 100644 --- a/docs/config/setup/config/functions/getEffectiveHtmlLabels.md +++ b/docs/config/setup/config/functions/getEffectiveHtmlLabels.md @@ -12,7 +12,7 @@ > **getEffectiveHtmlLabels**(`config`): `boolean` -Defined in: [packages/mermaid/src/config.ts:282](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.ts#L282) +Defined in: [packages/mermaid/src/config.ts:246](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.ts#L246) Helper function to handle deprecated flowchart.htmlLabels diff --git a/docs/config/setup/config/functions/getSiteConfig.md b/docs/config/setup/config/functions/getSiteConfig.md index 342eaae7e5b..4c740905835 100644 --- a/docs/config/setup/config/functions/getSiteConfig.md +++ b/docs/config/setup/config/functions/getSiteConfig.md @@ -12,15 +12,9 @@ > **getSiteConfig**(): [`MermaidConfig`](../../mermaid/interfaces/MermaidConfig.md) -Defined in: [packages/mermaid/src/config.ts:105](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.ts#L105) +Defined in: [packages/mermaid/src/config.ts:94](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.ts#L94) -## getSiteConfig - -| Function | Description | Type | Values | -| ------------- | ------------------------------------------------- | ----------- | -------------------------------- | -| setSiteConfig | Returns the current siteConfig base configuration | Get Request | Returns Any Values in siteConfig | - -**Notes**: Returns **any** values in siteConfig. +Returns a copy of the current `siteConfig` base configuration. ## Returns diff --git a/docs/config/setup/config/functions/getUserDefinedConfig.md b/docs/config/setup/config/functions/getUserDefinedConfig.md index 2510b57b387..73e8094592c 100644 --- a/docs/config/setup/config/functions/getUserDefinedConfig.md +++ b/docs/config/setup/config/functions/getUserDefinedConfig.md @@ -12,7 +12,7 @@ > **getUserDefinedConfig**(): [`MermaidConfig`](../../mermaid/interfaces/MermaidConfig.md) -Defined in: [packages/mermaid/src/config.ts:263](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.ts#L263) +Defined in: [packages/mermaid/src/config.ts:227](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.ts#L227) ## Returns diff --git a/docs/config/setup/config/functions/reset.md b/docs/config/setup/config/functions/reset.md index fcd4ea109e1..9873e47bfc5 100644 --- a/docs/config/setup/config/functions/reset.md +++ b/docs/config/setup/config/functions/reset.md @@ -12,21 +12,9 @@ > **reset**(`config`): `void` -Defined in: [packages/mermaid/src/config.ts:230](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.ts#L230) +Defined in: [packages/mermaid/src/config.ts:194](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.ts#L194) -## reset - -| Function | Description | Type | Required | Values | -| -------- | ---------------------------- | ----------- | -------- | ------ | -| reset | Resets currentConfig to conf | Put Request | Required | None | - -## conf - -| Parameter | Description | Type | Required | Values | -| --------- | -------------------------------------------------------------- | ---------- | -------- | -------------------------------------------- | -| conf | base set of values, which currentConfig could be **reset** to. | Dictionary | Required | Any Values, with respect to the secure Array | - -**Notes**: (default: current siteConfig ) (optional, default `getSiteConfig()`) +Resets the current config and applied directives to the provided config. ## Parameters @@ -34,8 +22,8 @@ Defined in: [packages/mermaid/src/config.ts:230](https://github.com/mermaid-js/m [`MermaidConfig`](../../mermaid/interfaces/MermaidConfig.md) = `siteConfig` -base set of values, which currentConfig could be **reset** to. -Defaults to the current siteConfig (e.g returned by [getSiteConfig](getSiteConfig.md)). +the value to reset the `currentConfig` to. +Defaults to the current `siteConfig` (e.g the value returned by [getSiteConfig](getSiteConfig.md)). ## Returns diff --git a/docs/config/setup/config/functions/sanitize.md b/docs/config/setup/config/functions/sanitize.md index 4eada26ced2..24e49d3c943 100644 --- a/docs/config/setup/config/functions/sanitize.md +++ b/docs/config/setup/config/functions/sanitize.md @@ -12,16 +12,9 @@ > **sanitize**(`options`): `void` -Defined in: [packages/mermaid/src/config.ts:155](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.ts#L155) +Defined in: [packages/mermaid/src/config.ts:131](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.ts#L131) -## sanitize - -| Function | Description | Type | Values | -| -------- | -------------------------------------- | ----------- | ------ | -| sanitize | Sets the siteConfig to desired values. | Put Request | None | - -Ensures options parameter does not attempt to override siteConfig secure keys **Notes**: modifies -options in-place +Ensures options parameter does not attempt to override `siteConfig` secure keys. ## Parameters @@ -29,8 +22,12 @@ options in-place `any` -The potential setConfig parameter +The potential `setConfig` parameter ## Returns `void` + +## Remarks + +Modifies options in-place. diff --git a/docs/config/setup/config/functions/saveConfigFromInitialize.md b/docs/config/setup/config/functions/saveConfigFromInitialize.md index 08f5528f6c0..209bbabb63f 100644 --- a/docs/config/setup/config/functions/saveConfigFromInitialize.md +++ b/docs/config/setup/config/functions/saveConfigFromInitialize.md @@ -12,7 +12,7 @@ > **saveConfigFromInitialize**(`conf`): `void` -Defined in: [packages/mermaid/src/config.ts:84](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.ts#L84) +Defined in: [packages/mermaid/src/config.ts:78](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.ts#L78) ## Parameters diff --git a/docs/config/setup/config/functions/setConfig.md b/docs/config/setup/config/functions/setConfig.md index aab8d946dde..348954dc645 100644 --- a/docs/config/setup/config/functions/setConfig.md +++ b/docs/config/setup/config/functions/setConfig.md @@ -8,21 +8,13 @@ --- -# Function: setConfig() +# ~~Function: setConfig()~~ > **setConfig**(`conf`): [`MermaidConfig`](../../mermaid/interfaces/MermaidConfig.md) -Defined in: [packages/mermaid/src/config.ts:122](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.ts#L122) +Defined in: [packages/mermaid/src/config.ts:106](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.ts#L106) -## setConfig - -| Function | Description | Type | Values | -| ------------- | ------------------------------------- | ----------- | --------------------------------------- | -| setSiteConfig | Sets the siteConfig to desired values | Put Request | Any Values, except ones in secure array | - -**Notes**: Sets the currentConfig. The parameter conf is sanitized based on the siteConfig.secure -keys. Any values found in conf with key found in siteConfig.secure will be replaced with the -corresponding siteConfig value. +Updates the `currentConfig` with the provided `conf` after sanitization. ## Parameters @@ -37,3 +29,8 @@ The potential currentConfig [`MermaidConfig`](../../mermaid/interfaces/MermaidConfig.md) The currentConfig merged with the sanitized conf + +## Deprecated + +Any changes to the `currentConfig` would be overwritten by the +next call to [addDirective](addDirective.md) or [reset](reset.md). diff --git a/docs/config/setup/config/functions/setSiteConfig.md b/docs/config/setup/config/functions/setSiteConfig.md index fb5001aac88..1ea582ec2d1 100644 --- a/docs/config/setup/config/functions/setSiteConfig.md +++ b/docs/config/setup/config/functions/setSiteConfig.md @@ -12,18 +12,12 @@ > **setSiteConfig**(`conf`): [`MermaidConfig`](../../mermaid/interfaces/MermaidConfig.md) -Defined in: [packages/mermaid/src/config.ts:70](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.ts#L70) +Defined in: [packages/mermaid/src/config.ts:64](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.ts#L64) -## setSiteConfig +Sets the `siteConfig` to the desired values. -| Function | Description | Type | Values | -| ------------- | ------------------------------------- | ----------- | --------------------------------------- | -| setSiteConfig | Sets the siteConfig to desired values | Put Request | Any Values, except ones in secure array | - -**Notes:** Sets the siteConfig. The siteConfig is a protected configuration for repeat use. Calls -to reset() will reset the currentConfig to siteConfig. Calls to reset(configApi.defaultConfig) -will reset siteConfig and currentConfig to the defaultConfig Note: currentConfig is set in this -function _Default value: At default, will mirror Global Config_ +The `siteConfig` is a protected configuration for repeat use. Calls +to [reset](reset.md) will reset the `currentConfig` to `siteConfig`. ## Parameters @@ -31,7 +25,7 @@ function _Default value: At default, will mirror Global Config_ [`MermaidConfig`](../../mermaid/interfaces/MermaidConfig.md) -The base currentConfig to use as siteConfig +The config to use as `siteConfig`. This will be merged with the `defaultConfig`. ## Returns diff --git a/docs/config/setup/config/functions/updateCurrentConfig.md b/docs/config/setup/config/functions/updateCurrentConfig.md deleted file mode 100644 index 6a69dcce5e8..00000000000 --- a/docs/config/setup/config/functions/updateCurrentConfig.md +++ /dev/null @@ -1,29 +0,0 @@ -> **Warning** -> -> ## THIS IS AN AUTOGENERATED FILE. DO NOT EDIT. -> -> ## Please edit the corresponding file in [/packages/mermaid/src/docs/config/setup/config/functions/updateCurrentConfig.md](../../../../../packages/mermaid/src/docs/config/setup/config/functions/updateCurrentConfig.md). - -[**mermaid**](../../README.md) - ---- - -# Function: updateCurrentConfig() - -> **updateCurrentConfig**(`siteCfg`, `_directives`): [`MermaidConfig`](../../mermaid/interfaces/MermaidConfig.md) - -Defined in: [packages/mermaid/src/config.ts:24](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.ts#L24) - -## Parameters - -### siteCfg - -[`MermaidConfig`](../../mermaid/interfaces/MermaidConfig.md) - -### \_directives - -[`MermaidConfig`](../../mermaid/interfaces/MermaidConfig.md)\[] - -## Returns - -[`MermaidConfig`](../../mermaid/interfaces/MermaidConfig.md) diff --git a/docs/config/setup/config/functions/updateSiteConfig.md b/docs/config/setup/config/functions/updateSiteConfig.md index 1ce4720d65a..865beb7c801 100644 --- a/docs/config/setup/config/functions/updateSiteConfig.md +++ b/docs/config/setup/config/functions/updateSiteConfig.md @@ -12,7 +12,7 @@ > **updateSiteConfig**(`conf`): [`MermaidConfig`](../../mermaid/interfaces/MermaidConfig.md) -Defined in: [packages/mermaid/src/config.ts:88](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.ts#L88) +Defined in: [packages/mermaid/src/config.ts:82](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.ts#L82) ## Parameters diff --git a/packages/mermaid/src/assignWithDepth.ts b/packages/mermaid/src/assignWithDepth.ts index b04c38b0eb1..a88690088e7 100644 --- a/packages/mermaid/src/assignWithDepth.ts +++ b/packages/mermaid/src/assignWithDepth.ts @@ -1,12 +1,14 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ - /** - * assignWithDepth Extends the functionality of {@link Object.assign} with the - * ability to merge arbitrary-depth objects For each key in src with path `k` (recursively) - * performs an Object.assign(dst[`k`], src[`k`]) with a slight change from the typical handling of - * undefined for dst[`k`]: instead of raising an error, dst[`k`] is auto-initialized to `{}` and - * effectively merged with src[`k`]

Additionally, dissimilar types will not clobber unless the - * config.clobber parameter === true. Example: + * Extends the functionality of {@link Object.assign} with the + * ability to merge arbitrary-depth objects. + * + * For each key in `src` with path `k` (recursively) + * performs an `Object.assign(dst['k'], src['k'])` with a slight change from the + * typical handling of `undefined` for `dst['k']`: + * instead of raising an error, `dst['k']` is auto-initialized to `{}` and + * effectively merged with `src['k']` + * + * Additionally, dissimilar types will not clobber. Example: * * ``` * const config_0 = { foo: { bar: 'bar' }, bar: 'foo' }; @@ -16,21 +18,22 @@ * //-> result: { foo: { bar: 'bar' }, bar: 'bar' } * ``` * - * Traditional Object.assign would have clobbered foo in config_0 with foo in config_1. If src is a - * destructured array of objects and dst is not an array, assignWithDepth will apply each element - * of src to dst in order. + * Traditional {@link Object.assign} would have clobbered `foo` in `config_0` with `foo` in `config_1`. + * If `src` is a destructured array of objects and `dst` is not an array, + * `assignWithDepth` will apply each element of `src` to `dst` in order. + * * @param dst - The destination of the merge * @param src - The source object(s) to merge into destination * @param config - * * depth: depth to traverse within src and dst for merging - * * clobber: should dissimilar types clobber */ const assignWithDepth = ( - dst: any, - src: any, - { depth = 2, clobber = false }: { depth?: number; clobber?: boolean } = {} -): any => { - const config: { depth: number; clobber: boolean } = { depth, clobber }; + dst: unknown, + src: unknown, + { depth = 2 }: { depth?: number } = {} +): // eslint-disable-next-line @typescript-eslint/no-explicit-any +any => { + const config = { depth }; if (Array.isArray(src) && !Array.isArray(dst)) { src.forEach((s) => assignWithDepth(dst, s, config)); return dst; @@ -42,26 +45,45 @@ const assignWithDepth = ( }); return dst; } - if (dst === undefined || depth <= 0) { + if (dst === undefined || dst === null || depth <= 0) { if (dst !== undefined && dst !== null && typeof dst === 'object' && typeof src === 'object') { return Object.assign(dst, src); } else { return src; } } - if (src !== undefined && typeof dst === 'object' && typeof src === 'object') { - Object.keys(src).forEach((key) => { - if ( - typeof src[key] === 'object' && - src[key] !== null && - (dst[key] === undefined || typeof dst[key] === 'object') - ) { - if (dst[key] === undefined) { - dst[key] = Array.isArray(src[key]) ? [] : {}; + if (src !== undefined && src !== null && typeof dst === 'object' && typeof src === 'object') { + const dstWithKeys = dst as typeof dst & Record; + Object.entries(src).forEach(([key, srcValue]) => { + if (typeof srcValue === 'object') { + if (srcValue === null) { + return; + } + if (!Object.hasOwn(dst, key)) { + Object.defineProperty(dst, key, { + value: undefined, + writable: true, + enumerable: true, + configurable: true, + }); + } + if (dstWithKeys[key] === undefined) { + dstWithKeys[key] = Array.isArray(srcValue) ? [] : {}; + } + if (typeof dstWithKeys[key] === 'object') { + dstWithKeys[key] = assignWithDepth(dstWithKeys[key], srcValue, { depth: depth - 1 }); + } + } else if (typeof dstWithKeys[key] !== 'object') { + if (Object.hasOwn(dst, key)) { + dstWithKeys[key] = srcValue; + } else { + Object.defineProperty(dst, key, { + value: srcValue, + writable: true, + enumerable: true, + configurable: true, + }); } - dst[key] = assignWithDepth(dst[key], src[key], { depth: depth - 1, clobber }); - } else if (clobber || (typeof dst[key] !== 'object' && typeof src[key] !== 'object')) { - dst[key] = src[key]; } }); } diff --git a/packages/mermaid/src/config.spec.ts b/packages/mermaid/src/config.spec.ts index 61327affac1..0c55c52f4ca 100644 --- a/packages/mermaid/src/config.spec.ts +++ b/packages/mermaid/src/config.spec.ts @@ -4,6 +4,7 @@ import type { MermaidConfig } from './config.type.js'; describe('when working with site config', () => { beforeEach(() => { // Resets the site config to default config + configApi.reset(); configApi.setSiteConfig({}); }); it('should set site config and config properly', () => { @@ -29,7 +30,8 @@ describe('when working with site config', () => { fontSize: 54321, securityLevel: 'loose', }; - const cfg: MermaidConfig = configApi.updateCurrentConfig(config_0, [directive]); + configApi.addDirective(directive); + const cfg: MermaidConfig = configApi.getConfig(); expect(cfg.fontFamily).toEqual(directive.fontFamily); expect(cfg.fontSize).toBe(config_0.fontSize); expect(cfg.securityLevel).toBe(config_0.securityLevel); diff --git a/packages/mermaid/src/config.ts b/packages/mermaid/src/config.ts index a898b78a6a4..67b771a08a0 100644 --- a/packages/mermaid/src/config.ts +++ b/packages/mermaid/src/config.ts @@ -21,7 +21,7 @@ let configFromInitialize: MermaidConfig; let directives: MermaidConfig[] = []; let currentConfig: MermaidConfig = assignWithDepth({}, defaultConfig); -export const updateCurrentConfig = (siteCfg: MermaidConfig, _directives: MermaidConfig[]) => { +const updateCurrentConfig = (siteCfg: MermaidConfig, _directives: MermaidConfig[]) => { // start with config being the siteConfig let cfg: MermaidConfig = assignWithDepth({}, siteCfg); // let sCfg = assignWithDepth(defaultConfig, siteConfigDelta); @@ -53,18 +53,12 @@ export const updateCurrentConfig = (siteCfg: MermaidConfig, _directives: Mermaid }; /** - * ## setSiteConfig + * Sets the `siteConfig` to the desired values. * - * | Function | Description | Type | Values | - * | ------------- | ------------------------------------- | ----------- | --------------------------------------- | - * | setSiteConfig | Sets the siteConfig to desired values | Put Request | Any Values, except ones in secure array | + * The `siteConfig` is a protected configuration for repeat use. Calls + * to {@link reset} will reset the `currentConfig` to `siteConfig`. * - * **Notes:** Sets the siteConfig. The siteConfig is a protected configuration for repeat use. Calls - * to reset() will reset the currentConfig to siteConfig. Calls to reset(configApi.defaultConfig) - * will reset siteConfig and currentConfig to the defaultConfig Note: currentConfig is set in this - * function _Default value: At default, will mirror Global Config_ - * - * @param conf - The base currentConfig to use as siteConfig + * @param conf - The config to use as `siteConfig`. This will be merged with the `defaultConfig`. * @returns The new siteConfig */ export const setSiteConfig = (conf: MermaidConfig): MermaidConfig => { @@ -91,66 +85,48 @@ export const updateSiteConfig = (conf: MermaidConfig): MermaidConfig => { return siteConfig; }; + /** - * ## getSiteConfig - * - * | Function | Description | Type | Values | - * | ------------- | ------------------------------------------------- | ----------- | -------------------------------- | - * | setSiteConfig | Returns the current siteConfig base configuration | Get Request | Returns Any Values in siteConfig | - * - * **Notes**: Returns **any** values in siteConfig. + * Returns a copy of the current `siteConfig` base configuration. * * @returns The siteConfig */ export const getSiteConfig = (): MermaidConfig => { return assignWithDepth({}, siteConfig); }; + /** - * ## setConfig - * - * | Function | Description | Type | Values | - * | ------------- | ------------------------------------- | ----------- | --------------------------------------- | - * | setSiteConfig | Sets the siteConfig to desired values | Put Request | Any Values, except ones in secure array | - * - * **Notes**: Sets the currentConfig. The parameter conf is sanitized based on the siteConfig.secure - * keys. Any values found in conf with key found in siteConfig.secure will be replaced with the - * corresponding siteConfig value. + * Updates the `currentConfig` with the provided `conf` after sanitization. * + * @deprecated Any changes to the `currentConfig` would be overwritten by the + * next call to {@link addDirective} or {@link reset}. * @param conf - The potential currentConfig * @returns The currentConfig merged with the sanitized conf */ export const setConfig = (conf: MermaidConfig): MermaidConfig => { - checkConfig(conf); - assignWithDepth(currentConfig, conf); + updateCurrentConfig(currentConfig, [conf]); return getConfig(); }; /** - * ## getConfig + * Returns a copy of the `currentConfig`. * - * | Function | Description | Type | Return Values | - * | --------- | ------------------------- | ----------- | ------------------------------ | - * | getConfig | Obtains the currentConfig | Get Request | Any Values from current Config | - * - * **Notes**: Avoid calling this function repeatedly. Instead, store the result in a variable and use it, and pass it down to function calls. + * @remarks Avoid calling this function repeatedly. + * Instead, store the result in a variable and use it, and pass it down to function calls. * * @returns The currentConfig */ export const getConfig = (): MermaidConfig => { return assignWithDepth({}, currentConfig); }; + /** - * ## sanitize + * Ensures options parameter does not attempt to override `siteConfig` secure keys. * - * | Function | Description | Type | Values | - * | -------- | -------------------------------------- | ----------- | ------ | - * | sanitize | Sets the siteConfig to desired values. | Put Request | None | + * @remarks Modifies options in-place. * - * Ensures options parameter does not attempt to override siteConfig secure keys **Notes**: modifies - * options in-place - * - * @param options - The potential setConfig parameter + * @param options - The potential `setConfig` parameter */ export const sanitize = (options: any) => { if (!options) { @@ -210,22 +186,10 @@ export const addDirective = (directive: MermaidConfig) => { }; /** - * ## reset - * - * | Function | Description | Type | Required | Values | - * | -------- | ---------------------------- | ----------- | -------- | ------ | - * | reset | Resets currentConfig to conf | Put Request | Required | None | - * - * ## conf - * - * | Parameter | Description | Type | Required | Values | - * | --------- | -------------------------------------------------------------- | ---------- | -------- | -------------------------------------------- | - * | conf | base set of values, which currentConfig could be **reset** to. | Dictionary | Required | Any Values, with respect to the secure Array | - * - * **Notes**: (default: current siteConfig ) (optional, default `getSiteConfig()`) + * Resets the current config and applied directives to the provided config. * - * @param config - base set of values, which currentConfig could be **reset** to. - * Defaults to the current siteConfig (e.g returned by {@link getSiteConfig}). + * @param config - the value to reset the `currentConfig` to. + * Defaults to the current `siteConfig` (e.g the value returned by {@link getSiteConfig}). */ export const reset = (config = siteConfig): void => { // Replace current config with siteConfig diff --git a/packages/mermaid/src/diagrams/architecture/architecture.spec.ts b/packages/mermaid/src/diagrams/architecture/architecture.spec.ts index 21787da64ae..d3b58b0d3ca 100644 --- a/packages/mermaid/src/diagrams/architecture/architecture.spec.ts +++ b/packages/mermaid/src/diagrams/architecture/architecture.spec.ts @@ -195,6 +195,51 @@ describe('architecture diagrams', () => { }); }); + it('should throw when missing a group', async () => { + const str = `architecture-beta + group mermaidPrototypePollutionMarker(cloud)[Marker] + service a(server)[A] in __proto__ + service b(server)[B] in mermaidPrototypePollutionMarker + a:R -- L:b`; + // __proto__ group does not exist. + await expect(parser.parse(str)).rejects.toThrow(/The service \[a]'s parent does not exist./); + }); + + it('should allow __proto__ as a group name', async () => { + const str = `architecture-beta + group __proto__(cloud)[Marker] + service a(server)[A] in __proto__ + service b(server)[B] in __proto__ + a:R -- L:b`; + await expect(parser.parse(str)).resolves.not.toThrow(); + expect(db.getGroups().map((g) => g.id)).toContain('__proto__'); + }); + + it('should block proto pollution via service id', async () => { + const str = `architecture-beta + group __proto__(cloud)[P] + group myPrototypePollutionKey(cloud)[Real] + service a(server)[A] in __proto__ + service b(server)[B] in myPrototypePollutionKey + a:R -- L:b`; + + await expect(parser.parse(str)).resolves.not.toThrow(); + // GHSA-3rrr-jr9j-h3q3 happened in this function + const structures = db.getDataStructures(); + expect(structures.groupAlignments.get(`"__proto__"-"myPrototypePollutionKey"`)).toBeDefined(); + expect(Object.prototype).not.toHaveProperty('myPrototypePollutionKey'); + }); + + it('should store services in the order they were added', async () => { + const str = `architecture-beta + service a(server)[A] + service 20(server)[B] + service 10(server)[C]`; + await expect(parser.parse(str)).resolves.not.toThrow(); + // Old object iteration order rules sorts numeric keys first + expect(db.getServices().map((s) => s.id)).toEqual(['a', '20', '10']); + }); + describe('align directive', () => { it('should parse a row alignment and expose it via getLayoutHints', async () => { const str = `architecture-beta diff --git a/packages/mermaid/src/diagrams/architecture/architectureDb.ts b/packages/mermaid/src/diagrams/architecture/architectureDb.ts index 05379b39fca..43d260cddd2 100644 --- a/packages/mermaid/src/diagrams/architecture/architectureDb.ts +++ b/packages/mermaid/src/diagrams/architecture/architectureDb.ts @@ -14,11 +14,11 @@ import { setDiagramTitle, } from '../common/commonDb.js'; import type { - ArchitectureAlignment, - ArchitectureDirectionPair, + ArchitectureAdjacencyList, ArchitectureDirectionPairMap, ArchitectureEdge, ArchitectureGroup, + ArchitectureGroupAlignments, ArchitectureJunction, ArchitectureLayoutHint, ArchitectureNode, @@ -27,6 +27,7 @@ import type { ArchitectureState, } from './architectureTypes.js'; import { + architectureGroupAlignmentKey, getArchitectureDirectionAlignment, getArchitectureDirectionPair, isArchitectureDirection, @@ -38,13 +39,13 @@ import { const DEFAULT_ARCHITECTURE_CONFIG: Required = DEFAULT_CONFIG.architecture; export class ArchitectureDB implements DiagramDB { - private nodes: Record = {}; - private groups: Record = {}; + private nodes = new Map(); + private groups = new Map(); private edges: ArchitectureEdge[] = []; private layoutHints: ArchitectureLayoutHint[] = []; - private registeredIds: Record = {}; + private registeredIds = new Map(); private dataStructures?: ArchitectureState['dataStructures']; - private elements: Record = {}; + private elements = new Map(); private diagramId = ''; constructor() { @@ -60,13 +61,13 @@ export class ArchitectureDB implements DiagramDB { } public clear(): void { - this.nodes = {}; - this.groups = {}; + this.nodes = new Map(); + this.groups = new Map(); this.edges = []; this.layoutHints = []; - this.registeredIds = {}; + this.registeredIds = new Map(); this.dataStructures = undefined; - this.elements = {}; + this.elements = new Map(); this.diagramId = ''; commonClear(); } @@ -78,28 +79,28 @@ export class ArchitectureDB implements DiagramDB { title, iconText, }: Omit): void { - if (this.registeredIds[id] !== undefined) { + if (this.registeredIds.has(id)) { throw new Error( - `The service id [${id}] is already in use by another ${this.registeredIds[id]}` + `The service id [${id}] is already in use by another ${this.registeredIds.get(id)}` ); } if (parent !== undefined) { if (id === parent) { throw new Error(`The service [${id}] cannot be placed within itself`); } - if (this.registeredIds[parent] === undefined) { + if (!this.registeredIds.has(parent)) { throw new Error( `The service [${id}]'s parent does not exist. Please make sure the parent is created before this service` ); } - if (this.registeredIds[parent] === 'node') { + if (this.registeredIds.get(parent) === 'node') { throw new Error(`The service [${id}]'s parent is not a group`); } } - this.registeredIds[id] = 'node'; + this.registeredIds.set(id, 'node'); - this.nodes[id] = { + this.nodes.set(id, { id, type: 'service', icon, @@ -107,86 +108,86 @@ export class ArchitectureDB implements DiagramDB { title, edges: [], in: parent, - }; + }); } public getServices(): ArchitectureService[] { - return Object.values(this.nodes).filter(isArchitectureService); + return [...this.nodes.values()].filter(isArchitectureService); } public addJunction({ id, in: parent }: Omit): void { - if (this.registeredIds[id] !== undefined) { + if (this.registeredIds.has(id)) { throw new Error( - `The junction id [${id}] is already in use by another ${this.registeredIds[id]}` + `The junction id [${id}] is already in use by another ${this.registeredIds.get(id)}` ); } if (parent !== undefined) { if (id === parent) { throw new Error(`The junction [${id}] cannot be placed within itself`); } - if (this.registeredIds[parent] === undefined) { + if (!this.registeredIds.has(parent)) { throw new Error( `The junction [${id}]'s parent does not exist. Please make sure the parent is created before this junction` ); } - if (this.registeredIds[parent] === 'node') { + if (this.registeredIds.get(parent) === 'node') { throw new Error(`The junction [${id}]'s parent is not a group`); } } - this.registeredIds[id] = 'node'; + this.registeredIds.set(id, 'node'); - this.nodes[id] = { + this.nodes.set(id, { id, type: 'junction', edges: [], in: parent, - }; + }); } public getJunctions(): ArchitectureJunction[] { - return Object.values(this.nodes).filter(isArchitectureJunction); + return [...this.nodes.values()].filter(isArchitectureJunction); } public getNodes(): ArchitectureNode[] { - return Object.values(this.nodes); + return [...this.nodes.values()]; } public getNode(id: string): ArchitectureNode | null { - return this.nodes[id] ?? null; + return this.nodes.get(id) ?? null; } public addGroup({ id, icon, in: parent, title }: ArchitectureGroup): void { - if (this.registeredIds?.[id] !== undefined) { + if (this.registeredIds.has(id)) { throw new Error( - `The group id [${id}] is already in use by another ${this.registeredIds[id]}` + `The group id [${id}] is already in use by another ${this.registeredIds.get(id)}` ); } if (parent !== undefined) { if (id === parent) { throw new Error(`The group [${id}] cannot be placed within itself`); } - if (this.registeredIds?.[parent] === undefined) { + if (!this.registeredIds.has(parent)) { throw new Error( `The group [${id}]'s parent does not exist. Please make sure the parent is created before this group` ); } - if (this.registeredIds?.[parent] === 'node') { + if (this.registeredIds.get(parent) === 'node') { throw new Error(`The group [${id}]'s parent is not a group`); } } - this.registeredIds[id] = 'group'; + this.registeredIds.set(id, 'group'); - this.groups[id] = { + this.groups.set(id, { id, icon, title, in: parent, - }; + }); } public getGroups(): ArchitectureGroup[] { - return Object.values(this.groups); + return [...this.groups.values()]; } public addEdge({ lhsId, @@ -210,19 +211,19 @@ export class ArchitectureDB implements DiagramDB { ); } - if (this.nodes[lhsId] === undefined && this.groups[lhsId] === undefined) { + if (!this.nodes.has(lhsId) && !this.groups.has(lhsId)) { throw new Error( `The left-hand id [${lhsId}] does not yet exist. Please create the service/group before declaring an edge to it.` ); } - if (this.nodes[rhsId] === undefined && this.groups[rhsId] === undefined) { + if (!this.nodes.has(rhsId) && !this.groups.has(rhsId)) { throw new Error( `The right-hand id [${rhsId}] does not yet exist. Please create the service/group before declaring an edge to it.` ); } - const lhsGroupId = this.nodes[lhsId].in; - const rhsGroupId = this.nodes[rhsId].in; + const lhsGroupId = this.nodes.get(lhsId)!.in; + const rhsGroupId = this.nodes.get(rhsId)!.in; if (lhsGroup && lhsGroupId && rhsGroupId && lhsGroupId == rhsGroupId) { throw new Error( `The left-hand id [${lhsId}] is modified to traverse the group boundary, but the edge does not pass through two groups.` @@ -247,9 +248,11 @@ export class ArchitectureDB implements DiagramDB { }; this.edges.push(edge); - if (this.nodes[lhsId] && this.nodes[rhsId]) { - this.nodes[lhsId].edges.push(this.edges[this.edges.length - 1]); - this.nodes[rhsId].edges.push(this.edges[this.edges.length - 1]); + const lhsNode = this.nodes.get(lhsId); + const rhsNode = this.nodes.get(rhsId); + if (lhsNode && rhsNode) { + lhsNode.edges.push(this.edges[this.edges.length - 1]); + rhsNode.edges.push(this.edges[this.edges.length - 1]); } } @@ -265,7 +268,7 @@ export class ArchitectureDB implements DiagramDB { } const seen = new Set(); hint.members.forEach((id) => { - if (this.registeredIds[id] !== 'node') { + if (this.registeredIds.get(id) !== 'node') { throw new Error( `align ${hint.direction} references [${id}], which is not a service or junction` ); @@ -290,28 +293,22 @@ export class ArchitectureDB implements DiagramDB { public getDataStructures() { if (this.dataStructures === undefined) { // Tracks how groups are aligned with one another. Generated while creating the adj list - const groupAlignments: Record< - string, - Record> - > = {}; + const groupAlignments: ArchitectureGroupAlignments = new Map(); // Create an adjacency list of the diagram to perform BFS on - // Outer reduce applied on all services - // Inner reduce applied on the edges for a service - const adjList = Object.entries(this.nodes).reduce< - Record - >((prevOuter, [id, service]) => { - prevOuter[id] = service.edges.reduce((prevInner, edge) => { + // Outer Map applied on all services + // Inner Map applied on the edges for a service + const adjList: ArchitectureAdjacencyList = new Map(); + for (const [id, service] of this.nodes.entries()) { + const directionMap: ArchitectureDirectionPairMap = new Map(); + for (const edge of service.edges) { // track the direction groups connect to one another const lhsGroupId = this.getNode(edge.lhsId)?.in; const rhsGroupId = this.getNode(edge.rhsId)?.in; if (lhsGroupId && rhsGroupId && lhsGroupId !== rhsGroupId) { const alignment = getArchitectureDirectionAlignment(edge.lhsDir, edge.rhsDir); if (alignment !== 'bend') { - groupAlignments[lhsGroupId] ??= {}; - groupAlignments[lhsGroupId][rhsGroupId] = alignment; - groupAlignments[rhsGroupId] ??= {}; - groupAlignments[rhsGroupId][lhsGroupId] = alignment; + groupAlignments.set(architectureGroupAlignmentKey(lhsGroupId, rhsGroupId), alignment); } } @@ -319,46 +316,49 @@ export class ArchitectureDB implements DiagramDB { // source is LHS const pair = getArchitectureDirectionPair(edge.lhsDir, edge.rhsDir); if (pair) { - prevInner[pair] = edge.rhsId; + directionMap.set(pair, edge.rhsId); } } else { // source is RHS const pair = getArchitectureDirectionPair(edge.rhsDir, edge.lhsDir); if (pair) { - prevInner[pair] = edge.lhsId; + directionMap.set(pair, edge.lhsId); } } - return prevInner; - }, {}); - return prevOuter; - }, {}); - - // Configuration for the initial pass of BFS - const firstId = Object.keys(adjList)[0]; - const visited = { [firstId]: 1 }; - // If a key is present in this object, it has not been visited - const notVisited = Object.keys(adjList).reduce( - (prev, id) => (id === firstId ? prev : { ...prev, [id]: 1 }), - {} as Record - ); + } + + adjList.set(id, directionMap); + } + + const visited = new Set(); + // If a key is present in this set, it has not been visited + const notVisited = new Set(adjList.keys()); // Perform BFS on the adjacency list const BFS = (startingId: string): ArchitectureSpatialMap => { - const spatialMap = { [startingId]: [0, 0] }; + const spatialMap: ArchitectureSpatialMap = new Map([[startingId, [0, 0]]]); const queue = [startingId]; while (queue.length > 0) { const id = queue.shift(); if (id) { - visited[id] = 1; - delete notVisited[id]; - const adj = adjList[id]; - const [posX, posY] = spatialMap[id]; - Object.entries(adj).forEach(([dir, rhsId]) => { - if (!visited[rhsId]) { - spatialMap[rhsId] = shiftPositionByArchitectureDirectionPair( - [posX, posY], - dir as ArchitectureDirectionPair - ); + visited.add(id); + notVisited.delete(id); + const adj = adjList.get(id); + if (!adj) { + throw new Error( + `BFS error: adjacency list for id ${id} not found. Please report this as a bug.` + ); + } + const pos = spatialMap.get(id); + if (!pos) { + throw new Error( + `BFS error: position for id ${id} not found in spatial map. Please report this as a bug.` + ); + } + const [posX, posY] = pos; + adj.forEach((rhsId, dir) => { + if (!visited.has(rhsId)) { + spatialMap.set(rhsId, shiftPositionByArchitectureDirectionPair([posX, posY], dir)); queue.push(rhsId); } }); @@ -366,12 +366,15 @@ export class ArchitectureDB implements DiagramDB { } return spatialMap; }; - const spatialMaps = [BFS(firstId)]; + const spatialMaps: ArchitectureSpatialMap[] = []; + // Keep performing BFS until all nodes have been visited. // If our diagram is disconnected, keep adding additional spatial maps until all disconnected graphs have been found - while (Object.keys(notVisited).length > 0) { - spatialMaps.push(BFS(Object.keys(notVisited)[0])); + while (notVisited.size > 0) { + const firstId = notVisited.values().next().value!; + spatialMaps.push(BFS(firstId)); } + this.dataStructures = { adjList, spatialMaps, @@ -382,11 +385,11 @@ export class ArchitectureDB implements DiagramDB { } public setElementForId(id: string, element: D3Element): void { - this.elements[id] = element; + this.elements.set(id, element); } public getElementById(id: string): D3Element { - return this.elements[id]; + return this.elements.get(id); } public getConfig(): Required { diff --git a/packages/mermaid/src/diagrams/architecture/architectureRenderer.ts b/packages/mermaid/src/diagrams/architecture/architectureRenderer.ts index f297ecfe401..75795ba8ccd 100644 --- a/packages/mermaid/src/diagrams/architecture/architectureRenderer.ts +++ b/packages/mermaid/src/diagrams/architecture/architectureRenderer.ts @@ -28,6 +28,7 @@ import { type ArchitectureGroup, type ArchitectureService, ArchitectureDirectionName, + architectureGroupAlignmentKey, edgeData, getOppositeArchitectureDirection, isArchitectureDirectionXY, @@ -158,83 +159,89 @@ function getAlignments( layoutHints: ArchitectureLayoutHint[] = [] ): fcose.FcoseAlignmentConstraint { /** - * Flattens the alignment object so nodes in different groups will be in the same alignment array IFF their groups don't connect in a conflicting alignment + * Flattens the alignment map so nodes in different groups will be in the same alignment array IFF their groups don't connect in a conflicting alignment * * i.e., two groups which connect horizontally should not have nodes with vertical alignments to one another * * See: #5952 * - * @param alignmentObj - alignment object with the outer key being the row/col # and the inner key being the group name mapped to the nodes on that axis in the group + * @param alignmentMap - alignment map with the outer key being the row/col # and the inner key being the group name mapped to the nodes on that axis in the group * @param alignmentDir - alignment direction - * @returns flattened alignment object with an arbitrary key mapping to nodes in the same row/col + * @returns flattened alignment map with an arbitrary key mapping to nodes in the same row/col */ const flattenAlignments = ( - alignmentObj: Record>, + alignmentMap: Map>, alignmentDir: ArchitectureAlignment - ): Record => { - return Object.entries(alignmentObj).reduce( - (prev, [dir, alignments]) => { - // prev is the mapping of x/y coordinate to an array of the nodes in that row/column - let cnt = 0; - const arr = Object.entries(alignments); // [group name, array of nodes within the group on axis dir] - if (arr.length === 1) { - // If only one group exists in the row/column, we don't need to do anything else - prev[dir] = arr[0][1]; - return prev; - } - for (let i = 0; i < arr.length - 1; i++) { - for (let j = i + 1; j < arr.length; j++) { - const [aGroupId, aNodeIds] = arr[i]; - const [bGroupId, bNodeIds] = arr[j]; - const alignment = groupAlignments[aGroupId]?.[bGroupId]; // Get how the two groups are intended to align (undefined if they aren't) - - if (alignment === alignmentDir) { - // If the intended alignment between the two groups is the same as the alignment we are parsing - prev[dir] ??= []; - prev[dir] = [...prev[dir], ...aNodeIds, ...bNodeIds]; // add the node ids of both groups to the axis array in prev - } else if (aGroupId === 'default' || bGroupId === 'default') { - // If either of the groups are in the default space (not in a group), use the same behavior as above - prev[dir] ??= []; - prev[dir] = [...prev[dir], ...aNodeIds, ...bNodeIds]; - } else { - // Otherwise, the nodes in the two groups are not intended to align - const keyA = `${dir}-${cnt++}`; - prev[keyA] = aNodeIds; - const keyB = `${dir}-${cnt++}`; - prev[keyB] = bNodeIds; - } + ): Map => { + // flattened is the mapping of x/y coordinate to an array of the nodes in that row/column + const flattened = new Map(); + for (const [numericDir, alignments] of alignmentMap.entries()) { + const dir = `${numericDir}`; + let cnt = 0; + const arr = [...alignments.entries()]; // [group name, array of nodes within the group on axis dir] + if (arr.length === 1) { + // If only one group exists in the row/column, we don't need to do anything else + flattened.set(dir, arr[0][1]); + continue; + } + for (let i = 0; i < arr.length - 1; i++) { + for (let j = i + 1; j < arr.length; j++) { + const [aGroupId, aNodeIds] = arr[i]; + const [bGroupId, bNodeIds] = arr[j]; + // Get how the two groups are intended to align (undefined if they aren't) + const alignment = groupAlignments.get(architectureGroupAlignmentKey(aGroupId, bGroupId)); + + if (alignment === alignmentDir) { + // If the intended alignment between the two groups is the same as the alignment we are parsing + flattened.set(dir, [...(flattened.get(dir) ?? []), ...aNodeIds, ...bNodeIds]); // add the node ids of both groups to the axis array in flattened + } else if (aGroupId === 'default' || bGroupId === 'default') { + // If either of the groups are in the default space (not in a group), use the same behavior as above + flattened.set(dir, [...(flattened.get(dir) ?? []), ...aNodeIds, ...bNodeIds]); + } else { + // Otherwise, the nodes in the two groups are not intended to align + const keyA = `${dir}-${cnt++}`; + flattened.set(keyA, aNodeIds); + const keyB = `${dir}-${cnt++}`; + flattened.set(keyB, bNodeIds); } } - - return prev; - }, - {} as Record - ); + } + } + return flattened; }; const alignments = spatialMaps.map((spatialMap) => { - const horizontalAlignments: Record> = {}; - const verticalAlignments: Record> = {}; + const horizontalAlignments = new Map>(); + const verticalAlignments = new Map>(); - // Group service ids in an object with their x and y coordinate as the key - Object.entries(spatialMap).forEach(([id, [x, y]]) => { + // Group service ids in a map with their x and y coordinate as the key + spatialMap.forEach(([x, y], id) => { const nodeGroup = db.getNode(id)?.in ?? 'default'; + const horizontalAlignment = horizontalAlignments.get(y) ?? new Map(); + if (!horizontalAlignments.has(y)) { + horizontalAlignments.set(y, horizontalAlignment); + } - horizontalAlignments[y] ??= {}; - horizontalAlignments[y][nodeGroup] ??= []; - horizontalAlignments[y][nodeGroup].push(id); + const verticalAlignment = verticalAlignments.get(x) ?? new Map(); + if (!verticalAlignments.has(x)) { + verticalAlignments.set(x, verticalAlignment); + } - verticalAlignments[x] ??= {}; - verticalAlignments[x][nodeGroup] ??= []; - verticalAlignments[x][nodeGroup].push(id); + for (const alignment of [horizontalAlignment, verticalAlignment]) { + const nodeList = alignment.get(nodeGroup) ?? []; + if (!alignment.has(nodeGroup)) { + alignment.set(nodeGroup, nodeList); + } + nodeList.push(id); + } }); - // Merge the values of each object into a list if the inner list has at least 2 elements + // Merge the values of each map into a list if the inner list has at least 2 elements return { - horiz: Object.values(flattenAlignments(horizontalAlignments, 'horizontal')).filter( + horiz: [...flattenAlignments(horizontalAlignments, 'horizontal').values()].filter( (arr) => arr.length > 1 ), - vert: Object.values(flattenAlignments(verticalAlignments, 'vertical')).filter( + vert: [...flattenAlignments(verticalAlignments, 'vertical').values()].filter( (arr) => arr.length > 1 ), }; @@ -311,17 +318,17 @@ function getRelativeConstraints( } } }); - const posToStr = (pos: number[]) => `${pos[0]},${pos[1]}`; + const posToStr = (pos: number[]) => `${pos[0]},${pos[1]}` as const; const strToPos = (pos: string) => pos.split(',').map((p) => parseInt(p)); spatialMaps.forEach((spatialMap) => { - const invSpatialMap = Object.fromEntries( - Object.entries(spatialMap).map(([id, pos]) => [posToStr(pos), id]) + const invSpatialMap = new Map( + [...spatialMap.entries()].map(([key, value]) => [posToStr(value), key]) ); // perform BFS const queue = [posToStr([0, 0])]; - const visited: Record = {}; + const visited: Record, number> = {}; const directions: Record = { L: [-1, 0], R: [1, 0], @@ -332,12 +339,12 @@ function getRelativeConstraints( const curr = queue.shift(); if (curr) { visited[curr] = 1; - const currId = invSpatialMap[curr]; + const currId = invSpatialMap.get(curr); if (currId) { const currPos = strToPos(curr); Object.entries(directions).forEach(([dir, shift]) => { const newPos = posToStr([currPos[0] + shift[0], currPos[1] + shift[1]]); - const newId = invSpatialMap[newPos]; + const newId = invSpatialMap.get(newPos); // If there is an adjacent service to the current one and it has not yet been visited if (newId && !visited[newPos]) { queue.push(newPos); diff --git a/packages/mermaid/src/diagrams/architecture/architectureTypes.ts b/packages/mermaid/src/diagrams/architecture/architectureTypes.ts index db0e81b2e5c..feb11fbcf22 100644 --- a/packages/mermaid/src/diagrams/architecture/architectureTypes.ts +++ b/packages/mermaid/src/diagrams/architecture/architectureTypes.ts @@ -106,7 +106,7 @@ export const isValidArchitectureDirectionPair = function ( return x !== 'LL' && x !== 'RR' && x !== 'TT' && x !== 'BB'; }; -export type ArchitectureDirectionPairMap = Partial>; +export type ArchitectureDirectionPairMap = Map; /** * Creates a pair of the directions of each side of an edge. This function should be used instead of manually creating it to ensure that the source is always the first character. @@ -131,9 +131,9 @@ export const getArchitectureDirectionPair = function ( * @returns a new [x, y] coordinate pair */ export const shiftPositionByArchitectureDirectionPair = function ( - [x, y]: number[], + [x, y]: [number, number], pair: ArchitectureDirectionPair -): number[] { +): [number, number] { const lhs = pair[0] as ArchitectureDirection; const rhs = pair[1] as ArchitectureDirection; if (isArchitectureDirectionX(lhs)) { @@ -268,24 +268,22 @@ export interface ArchitectureDB extends DiagramDBBase getDataStructures: () => ArchitectureDataStructures; } -export type ArchitectureAdjacencyList = Record; -export type ArchitectureSpatialMap = Record; +export type ArchitectureAdjacencyList = Map; +export type ArchitectureSpatialMap = Map; /** * Maps the direction that groups connect from. * - * **Outer key**: ID of group A - * - * **Inner key**: ID of group B - * - * **Value**: 'vertical' or 'horizontal' - * - * Note: tmp[groupA][groupB] == tmp[groupB][groupA] + * **Key**: `groupA-groupB`, where groupA and groupB are the JSON string ids of the two groups in alphabetical order. */ -export type ArchitectureGroupAlignments = Record< - string, - Record> +export type ArchitectureGroupAlignments = Map< + `${string}-${string}`, + Exclude >; +export const architectureGroupAlignmentKey = (groupA: string, groupB: string) => { + const [lowerGroupId, upperGroupId] = [groupA, groupB].sort(); + return `${JSON.stringify(lowerGroupId)}-${JSON.stringify(upperGroupId)}` as const; +}; export interface ArchitectureDataStructures { adjList: ArchitectureAdjacencyList; @@ -294,12 +292,12 @@ export interface ArchitectureDataStructures { } export interface ArchitectureState extends Record { - nodes: Record; - groups: Record; + nodes: Map; + groups: Map; edges: ArchitectureEdge[]; - registeredIds: Record; + registeredIds: Map; dataStructures?: ArchitectureDataStructures; - elements: Record; + elements: Map; config: ArchitectureDiagramConfig; layoutHints: ArchitectureLayoutHint[]; } diff --git a/packages/mermaid/src/diagrams/radar/db.ts b/packages/mermaid/src/diagrams/radar/db.ts index ff95a3be0fa..e6d3faeb7c5 100644 --- a/packages/mermaid/src/diagrams/radar/db.ts +++ b/packages/mermaid/src/diagrams/radar/db.ts @@ -18,6 +18,7 @@ import type { Entry, } from '../../../../parser/dist/src/language/generated/ast.js'; import type { RadarAxis, RadarCurve, RadarOptions, RadarDB, RadarData } from './types.js'; +import { log } from '../../logger.js'; const defaultOptions: RadarOptions = { showLegend: true, @@ -27,6 +28,9 @@ const defaultOptions: RadarOptions = { graticule: 'circle', }; +// Can't see people using more than this, since the gradient makes the diagram unreadable. +const MAX_TICKS = 32; + const defaultRadarData: RadarData = { axes: [], curves: [], @@ -103,6 +107,13 @@ const setOptions = (options: Option[]) => { min: (optionMap.min?.value as number) ?? defaultOptions.min, graticule: (optionMap.graticule?.value as 'circle' | 'polygon') ?? defaultOptions.graticule, }; + + if (data.options.ticks > MAX_TICKS) { + log.warn( + `Radar diagram ticks (${data.options.ticks}) exceeds maximum allowed (${MAX_TICKS}). Using ${MAX_TICKS} instead.` + ); + data.options.ticks = MAX_TICKS; + } }; const clear = () => { diff --git a/packages/mermaid/src/diagrams/radar/radar.spec.ts b/packages/mermaid/src/diagrams/radar/radar.spec.ts index 3a506c69e7a..4284f6287b3 100644 --- a/packages/mermaid/src/diagrams/radar/radar.spec.ts +++ b/packages/mermaid/src/diagrams/radar/radar.spec.ts @@ -160,6 +160,21 @@ describe('radar diagrams', () => { await expect(parser.parse(str)).resolves.not.toThrow(); }); + it('should limit the number of ticks to MAX_TICKS', async () => { + // Otherwise, user can set millions of ticks, which causes DoS + for (const { ticks, expected } of [ + { ticks: 12, expected: 12 }, // under limit + { ticks: 32, expected: 32 }, // at limit + { ticks: 33, expected: 32 }, // over limit + ]) { + const str = `radar-beta + ticks ${ticks} + `; + await expect(parser.parse(str)).resolves.not.toThrow(); + expect(getOptions().ticks).toBe(expected); + } + }); + describe('renderer', () => { describe('relativeRadius', () => { it('should calculate relative radius', () => { diff --git a/packages/mermaid/src/diagrams/xychart/xychartDb.ts b/packages/mermaid/src/diagrams/xychart/xychartDb.ts index d230e609a65..95a8b34d123 100644 --- a/packages/mermaid/src/diagrams/xychart/xychartDb.ts +++ b/packages/mermaid/src/diagrams/xychart/xychartDb.ts @@ -151,12 +151,13 @@ function transformDataWithoutCategory(data: number[]): SimplePlotDataType { if (isLinearAxisData(xyChartData.xAxis)) { const min = xyChartData.xAxis.min; const max = xyChartData.xAxis.max; - const step = (max - min) / (data.length - 1); - const categories: string[] = []; - for (let i = min; i <= max; i += step) { - categories.push(`${i}`); + if (data.length === 1) { + // For backwards compatibility, avoid divide-by-zero error + retData = [[`${min}`, data[0]]]; + } else { + const step = (max - min) / (data.length - 1); + retData = data.map((datum, index) => [`${min + index * step}`, datum]); } - retData = categories.map((c, i) => [c, data[i]]); } return retData; diff --git a/packages/mermaid/src/mermaidAPI.spec.ts b/packages/mermaid/src/mermaidAPI.spec.ts index 246dce120b6..4c8f007db62 100644 --- a/packages/mermaid/src/mermaidAPI.spec.ts +++ b/packages/mermaid/src/mermaidAPI.spec.ts @@ -477,6 +477,34 @@ describe('mermaidAPI', () => { ); }); + it.for([ + // test for CSS sibling components + ['should namespace sibling combinator', '& ~ *', '#someId #someId~*'], + // CSS ignores these whitespace characters + ['should namespace sibling combinator', '& \n\t \r \f \r\n + *', '#someId #someId+*'], + /* + * Experimental column combinator, + * see https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Selectors/Column_combinator + * + * Output whitespace will probably change once JSDOM supports it. + */ + ['should namespace column combinator', '& || *', '#someId #someId || *'], + ['should namespace root if using unsupported declarations', '&', '#someId #someId'], + // child combinators and descent combinators are already namespaced + ['should not namespace child combinator', '& > *', '#someId>*'], + ['should not namespace descent combinator', '& *', '#someId *'], + ] as const)('%s %s to %s', ([_description, inputSelector, expectedSelector]) => { + const result = createUserStyles( + { ...mockConfig, themeCSS: `${inputSelector} { color: red; }` }, + 'someDiagram', + new Map(), + '#someId' + ); + expect(result).toEqual( + `#someId .edge-pattern-dashed{stroke-dasharray:3;}${expectedSelector}{color:red;}` + ); + }); + it('should remove unsupported at-rules from user CSS', () => { const result = createUserStyles( { @@ -625,18 +653,18 @@ describe('mermaidAPI', () => { it('resets mermaid config to global defaults', () => { const config = { logLevel: 0, - securityLevel: 'loose', + htmlLabels: false, } as const; mermaidAPI.initialize(config); - mermaidAPI.setConfig({ securityLevel: 'strict', logLevel: 1 }); + mermaidAPI.setConfig({ logLevel: 1, htmlLabels: true }); expect(mermaidAPI.getConfig().logLevel).toBe(1); - expect(mermaidAPI.getConfig().securityLevel).toBe('strict'); + expect(mermaidAPI.getConfig().htmlLabels).toBe(true); mermaidAPI.reset(); expect(mermaidAPI.getConfig().logLevel).toBe(0); - expect(mermaidAPI.getConfig().securityLevel).toBe('loose'); + expect(mermaidAPI.getConfig().htmlLabels).toBe(false); mermaidAPI.globalReset(); expect(mermaidAPI.getConfig().logLevel).toBe(5); - expect(mermaidAPI.getConfig().securityLevel).toBe('strict'); + expect(mermaidAPI.getConfig().htmlLabels).toBe(mermaidAPI.defaultConfig.htmlLabels); }); it('prevents changes to site defaults (sneaky)', () => { diff --git a/packages/mermaid/src/mermaidAPI.ts b/packages/mermaid/src/mermaidAPI.ts index d8627420bc0..6f6219961bb 100644 --- a/packages/mermaid/src/mermaidAPI.ts +++ b/packages/mermaid/src/mermaidAPI.ts @@ -238,7 +238,37 @@ const compileCSS = (namespace: `#${string}`, css: string) => { return; } element.props = element.props.map((prop) => { - if (!prop.startsWith(namespace)) { + /** + * For the root selector `& { ... }`, allow limited inherited + * styles to not be namespaced. + * These won't do anything to the ``, but will be inherited by + * children with a lower specificity than SVG presentation attributes. + */ + if ( + prop === namespace && + Array.isArray(element.children) && + element.children.every((child) => { + if (child.type !== 'decl') { + return false; + } + const allowedProps = new Set([ + 'font-family', + 'font-size', + 'fill', + ]); + return allowedProps.has(child.props); + }) + ) { + return prop; + } + + const alreadyNamespaced = + // If the prop already starts with the namespace followed by a space or >, then it's already namespaced. + (prop.startsWith(`${namespace} `) || prop.startsWith(`${namespace}>`)) && + // Column combinators are not yet widely supported, it's not yet compressed to `${namespace}||`, + // so we need to add an extra check for that + !prop.startsWith(`${namespace} ||`); + if (!alreadyNamespaced) { return `${namespace} ${prop}`; } return prop; @@ -664,6 +694,10 @@ export const mermaidAPI = Object.freeze({ getDiagramFromText, initialize, getConfig: configApi.getConfig, + /** + * @deprecated This function does nothing. It will be overwritten by the next + * call to {@link render} or {@link parse}. + */ setConfig: configApi.setConfig, getSiteConfig: configApi.getSiteConfig, updateSiteConfig: configApi.updateSiteConfig, diff --git a/packages/mermaid/src/styles.ts b/packages/mermaid/src/styles.ts index 79161702170..0855e13ea5e 100644 --- a/packages/mermaid/src/styles.ts +++ b/packages/mermaid/src/styles.ts @@ -39,7 +39,7 @@ const getStyles = ( } else { log.warn(`No theme found for ${type}`); } - return ` & { + return `& { font-family: ${options.fontFamily}; font-size: ${options.fontSize}; fill: ${options.textColor} diff --git a/packages/mermaid/src/utils.spec.ts b/packages/mermaid/src/utils.spec.ts index e78f55aa9ec..d03a0cb33d1 100644 --- a/packages/mermaid/src/utils.spec.ts +++ b/packages/mermaid/src/utils.spec.ts @@ -63,6 +63,37 @@ describe('when assignWithDepth: should merge objects within objects', function ( boofar: 1, }); }); + it('should handle `null` values', function () { + const config_0 = { foo: 'bar', bar: { foo: 'bar' }, boofar: 1 }; + const config_1 = { foo: 'foo', bar: null, foobar: 'foobar' }; + const result = assignWithDepth(config_0, config_1); + expect(result).toEqual({ + foo: 'foo', + bar: { foo: 'bar' }, + foobar: 'foobar', + boofar: 1, + }); + }); + it('should handle `undefined` values', function () { + // explicit `undefined` values are assignable + const config_0 = { foo: 'bar', bar: undefined, extra: undefined }; + const config_1 = { foo: undefined, bar: { foo: 'bar' } }; + const result = assignWithDepth(config_0, config_1); + expect(result).toEqual({ foo: undefined, bar: { foo: 'bar' }, extra: undefined }); + }); + it('should avoid prototype pollution', function () { + const result = assignWithDepth( + {}, + { + // On browsers/Node.JS, `__proto__` is a special prop in object literals, + // but this syntax declares a new property named `__proto__`. + ['__proto__']: { polluted: 'yes' }, + } + ); + expect(Object.getPrototypeOf(result)).toEqual(Object.prototype); + expect(result).to.deep.equal({ ['__proto__']: { polluted: 'yes' } }); + expect({}).not.toHaveProperty('polluted'); + }); it('should handle depth:3 types (merge with clobber because assignWithDepth::depth == 2)', function () { const config_0 = { foo: 'bar',