diff --git a/cypress/integration/rendering/conf-and-directives.spec.js b/cypress/integration/rendering/conf-and-directives.spec.js index bc17f62361..ca1fccf495 100644 --- a/cypress/integration/rendering/conf-and-directives.spec.js +++ b/cypress/integration/rendering/conf-and-directives.spec.js @@ -14,7 +14,6 @@ describe('Configuration and directives - nodes should be light blue', () => { `, {} ); - cy.get('svg'); }); it('Settings from initialize - nodes should be green', () => { imgSnapshotTest( @@ -28,7 +27,6 @@ graph TD end `, { theme: 'forest' } ); - cy.get('svg'); }); it('Settings from initialize overriding themeVariable - nodes should be red', () => { imgSnapshotTest( @@ -46,7 +44,6 @@ graph TD `, { theme: 'base', themeVariables: { primaryColor: '#ff0000' }, logLevel: 0 } ); - cy.get('svg'); }); it('Settings from directive - nodes should be grey', () => { imgSnapshotTest( @@ -62,7 +59,24 @@ graph TD `, {} ); - cy.get('svg'); + }); + it('Settings from frontmatter - nodes should be grey', () => { + imgSnapshotTest( + ` +--- +config: + theme: neutral +--- +graph TD + A(Start) --> B[/Another/] + A[/Another/] --> C[End] + subgraph section + B + C + end + `, + {} + ); }); it('Settings from directive overriding theme variable - nodes should be red', () => { @@ -79,7 +93,6 @@ graph TD `, {} ); - cy.get('svg'); }); it('Settings from initialize and directive - nodes should be grey', () => { imgSnapshotTest( @@ -95,7 +108,6 @@ graph TD `, { theme: 'forest' } ); - cy.get('svg'); }); it('Theme from initialize, directive overriding theme variable - nodes should be red', () => { imgSnapshotTest( @@ -111,8 +123,50 @@ graph TD `, { theme: 'base' } ); - cy.get('svg'); }); + it('Theme from initialize, frontmatter overriding theme variable - nodes should be red', () => { + imgSnapshotTest( + ` +--- +config: + theme: base + themeVariables: + primaryColor: '#ff0000' +--- +graph TD + A(Start) --> B[/Another/] + A[/Another/] --> C[End] + subgraph section + B + C + end + `, + { theme: 'forest' } + ); + }); + + it('should render if values are not quoted properly', () => { + // #ff0000 is not quoted properly, and will evaluate to null. + // This test ensures that the rendering still works. + imgSnapshotTest( + `--- +config: + theme: base + themeVariables: + primaryColor: #ff0000 +--- +graph TD + A(Start) --> B[/Another/] + A[/Another/] --> C[End] + subgraph section + B + C + end + `, + { theme: 'forest' } + ); + }); + it('Theme variable from initialize, theme from directive - nodes should be red', () => { imgSnapshotTest( ` @@ -127,13 +181,11 @@ graph TD `, { themeVariables: { primaryColor: '#ff0000' } } ); - cy.get('svg'); }); describe('when rendering several diagrams', () => { it('diagrams should not taint later diagrams', () => { const url = 'http://localhost:9000/theme-directives.html'; cy.visit(url); - cy.get('svg'); cy.matchImageSnapshot('conf-and-directives.spec-when-rendering-several-diagrams-diagram-1'); }); }); diff --git a/packages/mermaid/src/Diagram.ts b/packages/mermaid/src/Diagram.ts index 13fd3232b5..308e141d03 100644 --- a/packages/mermaid/src/Diagram.ts +++ b/packages/mermaid/src/Diagram.ts @@ -48,7 +48,7 @@ export class Diagram { // extractFrontMatter(). this.parser.parse = (text: string) => - originalParse(cleanupComments(extractFrontMatter(text, this.db))); + originalParse(cleanupComments(extractFrontMatter(text, this.db, configApi.addDirective))); this.parser.parser.yy = this.db; this.init = diagram.init; diff --git a/packages/mermaid/src/config.ts b/packages/mermaid/src/config.ts index c426e9122a..eb24b6268f 100644 --- a/packages/mermaid/src/config.ts +++ b/packages/mermaid/src/config.ts @@ -144,9 +144,12 @@ export const getConfig = (): MermaidConfig => { * @param options - The potential setConfig parameter */ export const sanitize = (options: any) => { + if (!options) { + return; + } // Checking that options are not in the list of excluded options ['secure', ...(siteConfig.secure ?? [])].forEach((key) => { - if (options[key] !== undefined) { + if (Object.hasOwn(options, key)) { // DO NOT attempt to print options[key] within `${}` as a malicious script // can exploit the logger's attempt to stringify the value and execute arbitrary code log.debug(`Denied attempt to modify a secure key ${key}`, options[key]); @@ -156,7 +159,7 @@ export const sanitize = (options: any) => { // Check that there no attempts of prototype pollution Object.keys(options).forEach((key) => { - if (key.indexOf('__') === 0) { + if (key.startsWith('__')) { delete options[key]; } }); diff --git a/packages/mermaid/src/diagram-api/frontmatter.spec.ts b/packages/mermaid/src/diagram-api/frontmatter.spec.ts index ef05c8f149..03d46c300c 100644 --- a/packages/mermaid/src/diagram-api/frontmatter.spec.ts +++ b/packages/mermaid/src/diagram-api/frontmatter.spec.ts @@ -2,8 +2,13 @@ import { vi } from 'vitest'; import { extractFrontMatter } from './frontmatter.js'; const dbMock = () => ({ setDiagramTitle: vi.fn() }); +const setConfigMock = vi.fn(); describe('extractFrontmatter', () => { + beforeEach(() => { + setConfigMock.mockClear(); + }); + it('returns text unchanged if no frontmatter', () => { expect(extractFrontMatter('diagram', dbMock())).toEqual('diagram'); }); @@ -75,4 +80,21 @@ describe('extractFrontmatter', () => { 'tag suffix cannot contain exclamation marks' ); }); + + it('handles frontmatter with config', () => { + const text = `--- +title: hello +config: + graph: + string: hello + number: 14 + boolean: false + array: [1, 2, 3] +--- +diagram`; + expect(extractFrontMatter(text, {}, setConfigMock)).toEqual('diagram'); + expect(setConfigMock).toHaveBeenCalledWith({ + graph: { string: 'hello', number: 14, boolean: false, array: [1, 2, 3] }, + }); + }); }); diff --git a/packages/mermaid/src/diagram-api/frontmatter.ts b/packages/mermaid/src/diagram-api/frontmatter.ts index f8d2e9c415..1ce673d4fd 100644 --- a/packages/mermaid/src/diagram-api/frontmatter.ts +++ b/packages/mermaid/src/diagram-api/frontmatter.ts @@ -1,3 +1,4 @@ +import { MermaidConfig } from '../config.type.js'; import { DiagramDB } from './types.js'; // The "* as yaml" part is necessary for tree-shaking import * as yaml from 'js-yaml'; @@ -9,38 +10,50 @@ import * as yaml from 'js-yaml'; // Relevant YAML spec: https://yaml.org/spec/1.2.2/#914-explicit-documents export const frontMatterRegex = /^-{3}\s*[\n\r](.*?)[\n\r]-{3}\s*[\n\r]+/s; -type FrontMatterMetadata = { +interface FrontMatterMetadata { title?: string; // Allows custom display modes. Currently used for compact mode in gantt charts. displayMode?: string; -}; + config?: MermaidConfig; +} /** * Extract and parse frontmatter from text, if present, and sets appropriate * properties in the provided db. * @param text - The text that may have a YAML frontmatter. * @param db - Diagram database, could be of any diagram. + * @param setDiagramConfig - Optional function to set diagram config. * @returns text with frontmatter stripped out */ -export function extractFrontMatter(text: string, db: DiagramDB): string { +export function extractFrontMatter( + text: string, + db: DiagramDB, + setDiagramConfig?: (config: MermaidConfig) => void +): string { const matches = text.match(frontMatterRegex); - if (matches) { - const parsed: FrontMatterMetadata = yaml.load(matches[1], { - // To keep things simple, only allow strings, arrays, and plain objects. - // https://www.yaml.org/spec/1.2/spec.html#id2802346 - schema: yaml.FAILSAFE_SCHEMA, - }) as FrontMatterMetadata; - - if (parsed?.title) { - db.setDiagramTitle?.(parsed.title); - } - - if (parsed?.displayMode) { - db.setDisplayMode?.(parsed.displayMode); - } - - return text.slice(matches[0].length); - } else { + if (!matches) { return text; } + + const parsed: FrontMatterMetadata = yaml.load(matches[1], { + // To support config, we need JSON schema. + // https://www.yaml.org/spec/1.2/spec.html#id2803231 + schema: yaml.JSON_SCHEMA, + }) as FrontMatterMetadata; + + if (parsed?.title) { + // toString() is necessary because YAML could parse the title as a number/boolean + db.setDiagramTitle?.(parsed.title.toString()); + } + + if (parsed?.displayMode) { + // toString() is necessary because YAML could parse the title as a number/boolean + db.setDisplayMode?.(parsed.displayMode.toString()); + } + + if (parsed?.config) { + setDiagramConfig?.(parsed.config); + } + + return text.slice(matches[0].length); } diff --git a/packages/mermaid/src/mermaidAPI.ts b/packages/mermaid/src/mermaidAPI.ts index 35b251bc7f..0a5fae5758 100644 --- a/packages/mermaid/src/mermaidAPI.ts +++ b/packages/mermaid/src/mermaidAPI.ts @@ -30,6 +30,7 @@ import { evaluate } from './diagrams/common/common.js'; import isEmpty from 'lodash-es/isEmpty.js'; import { setA11yDiagramInfo, addSVGa11yTitleDescription } from './accessibility.js'; import { parseDirective } from './directiveUtils.js'; +import { extractFrontMatter } from './diagram-api/frontmatter.js'; // diagram names that support classDef statements const CLASSDEF_DIAGRAMS = [ @@ -385,7 +386,12 @@ const render = async function ( configApi.reset(); - // Add Directives. Must do this before getting the config and before creating the diagram. + // We need to add the directives before creating the diagram. + // So extractFrontMatter is called twice. Once here and once in the diagram parser. + // This can be fixed in a future refactor. + extractFrontMatter(text, {}, configApi.addDirective); + + // Add Directives. const graphInit = utils.detectInit(text); if (graphInit) { configApi.addDirective(graphInit); diff --git a/packages/mermaid/src/utils.ts b/packages/mermaid/src/utils.ts index dd574f86a8..cae3d5bfe8 100644 --- a/packages/mermaid/src/utils.ts +++ b/packages/mermaid/src/utils.ts @@ -96,7 +96,10 @@ const directiveWithoutOpen = * @param config - Optional mermaid configuration object. * @returns The json object representing the init passed to mermaid.initialize() */ -export const detectInit = function (text: string, config?: MermaidConfig): MermaidConfig { +export const detectInit = function ( + text: string, + config?: MermaidConfig +): MermaidConfig | undefined { const inits = detectDirective(text, /(?:init\b)|(?:initialize\b)/); let results = {}; @@ -106,20 +109,22 @@ export const detectInit = function (text: string, config?: MermaidConfig): Merma } else { results = inits.args; } - if (results) { - let type = detectType(text, config); - ['config'].forEach((prop) => { - if (results[prop] !== undefined) { - if (type === 'flowchart-v2') { - type = 'flowchart'; - } - results[type] = results[prop]; - delete results[prop]; - } - }); + + if (!results) { + return; } - // Todo: refactor this, these results are never used + let type = detectType(text, config); + ['config'].forEach((prop) => { + if (results[prop] !== undefined) { + if (type === 'flowchart-v2') { + type = 'flowchart'; + } + results[type] = results[prop]; + delete results[prop]; + } + }); + return results; }; @@ -844,7 +849,7 @@ export const sanitizeDirective = (args: unknown): void => { log.debug('sanitizeDirective called with', args); // Return if not an object - if (typeof args !== 'object') { + if (typeof args !== 'object' || args == null) { return; } @@ -861,7 +866,8 @@ export const sanitizeDirective = (args: unknown): void => { key.startsWith('__') || key.includes('proto') || key.includes('constr') || - !configKeys.has(key) + !configKeys.has(key) || + args[key] == null ) { log.debug('sanitize deleting key: ', key); delete args[key];