diff --git a/docs/config/setup/interfaces/mermaidAPI.ParseOptions.md b/docs/config/setup/interfaces/mermaidAPI.ParseOptions.md index 2082a081e..56f914641 100644 --- a/docs/config/setup/interfaces/mermaidAPI.ParseOptions.md +++ b/docs/config/setup/interfaces/mermaidAPI.ParseOptions.md @@ -16,4 +16,4 @@ #### Defined in -[mermaidAPI.ts:78](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/mermaidAPI.ts#L78) +[mermaidAPI.ts:76](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/mermaidAPI.ts#L76) diff --git a/docs/config/setup/interfaces/mermaidAPI.RenderResult.md b/docs/config/setup/interfaces/mermaidAPI.RenderResult.md index f84a51b87..2c1504285 100644 --- a/docs/config/setup/interfaces/mermaidAPI.RenderResult.md +++ b/docs/config/setup/interfaces/mermaidAPI.RenderResult.md @@ -39,7 +39,7 @@ bindFunctions?.(div); // To call bindFunctions only if it's present. #### Defined in -[mermaidAPI.ts:98](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/mermaidAPI.ts#L98) +[mermaidAPI.ts:96](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/mermaidAPI.ts#L96) --- @@ -51,4 +51,4 @@ The svg code for the rendered graph. #### Defined in -[mermaidAPI.ts:88](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/mermaidAPI.ts#L88) +[mermaidAPI.ts:86](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/mermaidAPI.ts#L86) diff --git a/docs/config/setup/modules/mermaidAPI.md b/docs/config/setup/modules/mermaidAPI.md index d5d4a1cbc..3c2b5bb84 100644 --- a/docs/config/setup/modules/mermaidAPI.md +++ b/docs/config/setup/modules/mermaidAPI.md @@ -25,13 +25,13 @@ Renames and re-exports [mermaidAPI](mermaidAPI.md#mermaidapi) #### Defined in -[mermaidAPI.ts:82](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/mermaidAPI.ts#L82) +[mermaidAPI.ts:80](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/mermaidAPI.ts#L80) ## Variables ### mermaidAPI -• `Const` **mermaidAPI**: `Readonly`<{ `defaultConfig`: `MermaidConfig` = configApi.defaultConfig; `getConfig`: () => `MermaidConfig` = configApi.getConfig; `getDiagramFromText`: (`text`: `string`) => `Promise`<`Diagram`> ; `getSiteConfig`: () => `MermaidConfig` = configApi.getSiteConfig; `globalReset`: () => `void` ; `initialize`: (`options`: `MermaidConfig`) => `void` ; `parse`: (`text`: `string`, `parseOptions?`: [`ParseOptions`](../interfaces/mermaidAPI.ParseOptions.md)) => `Promise`<`boolean`> ; `parseDirective`: (`p`: `any`, `statement`: `string`, `context`: `string`, `type`: `string`) => `void` ; `render`: (`id`: `string`, `text`: `string`, `svgContainingElement?`: `Element`) => `Promise`<[`RenderResult`](../interfaces/mermaidAPI.RenderResult.md)> ; `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; `getDiagramFromText`: (`text`: `string`, `metadata`: { `title?`: `string` }) => `Promise`<`Diagram`> ; `getSiteConfig`: () => `MermaidConfig` = configApi.getSiteConfig; `globalReset`: () => `void` ; `initialize`: (`options`: `MermaidConfig`) => `void` ; `parse`: (`text`: `string`, `parseOptions?`: [`ParseOptions`](../interfaces/mermaidAPI.ParseOptions.md)) => `Promise`<`boolean`> ; `render`: (`id`: `string`, `text`: `string`, `svgContainingElement?`: `Element`) => `Promise`<[`RenderResult`](../interfaces/mermaidAPI.RenderResult.md)> ; `reset`: () => `void` ; `setConfig`: (`conf`: `MermaidConfig`) => `MermaidConfig` = configApi.setConfig; `updateSiteConfig`: (`conf`: `MermaidConfig`) => `MermaidConfig` = configApi.updateSiteConfig }> ## mermaidAPI configuration defaults @@ -96,7 +96,7 @@ mermaid.initialize(config); #### Defined in -[mermaidAPI.ts:673](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/mermaidAPI.ts#L673) +[mermaidAPI.ts:662](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/mermaidAPI.ts#L662) ## Functions @@ -127,7 +127,7 @@ Return the last node appended #### Defined in -[mermaidAPI.ts:310](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/mermaidAPI.ts#L310) +[mermaidAPI.ts:318](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/mermaidAPI.ts#L318) --- @@ -153,7 +153,7 @@ the cleaned up svgCode #### Defined in -[mermaidAPI.ts:256](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/mermaidAPI.ts#L256) +[mermaidAPI.ts:264](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/mermaidAPI.ts#L264) --- @@ -179,7 +179,7 @@ the string with all the user styles #### Defined in -[mermaidAPI.ts:185](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/mermaidAPI.ts#L185) +[mermaidAPI.ts:193](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/mermaidAPI.ts#L193) --- @@ -202,7 +202,7 @@ the string with all the user styles #### Defined in -[mermaidAPI.ts:233](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/mermaidAPI.ts#L233) +[mermaidAPI.ts:241](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/mermaidAPI.ts#L241) --- @@ -229,7 +229,7 @@ with an enclosing block that has each of the cssClasses followed by !important; #### Defined in -[mermaidAPI.ts:169](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/mermaidAPI.ts#L169) +[mermaidAPI.ts:177](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/mermaidAPI.ts#L177) --- @@ -249,7 +249,7 @@ with an enclosing block that has each of the cssClasses followed by !important; #### Defined in -[mermaidAPI.ts:155](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/mermaidAPI.ts#L155) +[mermaidAPI.ts:163](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/mermaidAPI.ts#L163) --- @@ -269,7 +269,7 @@ with an enclosing block that has each of the cssClasses followed by !important; #### Defined in -[mermaidAPI.ts:126](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/mermaidAPI.ts#L126) +[mermaidAPI.ts:134](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/mermaidAPI.ts#L134) --- @@ -295,7 +295,7 @@ Put the svgCode into an iFrame. Return the iFrame code #### Defined in -[mermaidAPI.ts:287](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/mermaidAPI.ts#L287) +[mermaidAPI.ts:295](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/mermaidAPI.ts#L295) --- @@ -320,4 +320,4 @@ Remove any existing elements from the given document #### Defined in -[mermaidAPI.ts:360](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/mermaidAPI.ts#L360) +[mermaidAPI.ts:368](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/mermaidAPI.ts#L368) diff --git a/packages/mermaid/src/Diagram.ts b/packages/mermaid/src/Diagram.ts index 308e141d0..9d665c71c 100644 --- a/packages/mermaid/src/Diagram.ts +++ b/packages/mermaid/src/Diagram.ts @@ -2,9 +2,7 @@ import * as configApi from './config.js'; import { log } from './logger.js'; import { getDiagram, registerDiagram } from './diagram-api/diagramAPI.js'; import { detectType, getDiagramLoader } from './diagram-api/detectType.js'; -import { extractFrontMatter } from './diagram-api/frontmatter.js'; import { UnknownDiagramError } from './errors.js'; -import { cleanupComments } from './diagram-api/comments.js'; import type { DetailedError } from './utils.js'; import type { DiagramDefinition } from './diagram-api/types.js'; @@ -22,7 +20,7 @@ export class Diagram { private init?: DiagramDefinition['init']; private detectError?: UnknownDiagramError; - constructor(public text: string) { + constructor(public text: string, public metadata: { title?: string } = {}) { this.text += '\n'; const cnf = configApi.getConfig(); try { @@ -37,19 +35,6 @@ export class Diagram { this.db = diagram.db; this.renderer = diagram.renderer; this.parser = diagram.parser; - const originalParse = this.parser.parse.bind(this.parser); - // Wrap the jison parse() method to handle extracting frontmatter. - // - // This can't be done in this.parse() because some code - // directly calls diagram.parser.parse(), bypassing this.parse(). - // - // Similarly, we can't do this in getDiagramFromText() because some code - // calls diagram.db.clear(), which would reset anything set by - // extractFrontMatter(). - - this.parser.parse = (text: string) => - originalParse(cleanupComments(extractFrontMatter(text, this.db, configApi.addDirective))); - this.parser.parser.yy = this.db; this.init = diagram.init; this.parse(); @@ -60,7 +45,15 @@ export class Diagram { throw this.detectError; } this.db.clear?.(); - this.init?.(configApi.getConfig()); + const config = configApi.getConfig(); + this.init?.(config); + // These 2 blocks were added for legacy compatibility. Do not add more such blocks. Use frontmatter instead. + if (this.metadata.title) { + this.db.setDiagramTitle?.(this.metadata.title); + } + if (config.wrap) { + this.db.setWrap?.(config.wrap); + } this.parser.parse(this.text); } @@ -86,7 +79,10 @@ export class Diagram { * @throws {@link UnknownDiagramError} if the diagram type can not be found. * @privateRemarks This is exported as part of the public mermaidAPI. */ -export const getDiagramFromText = async (text: string): Promise => { +export const getDiagramFromText = async ( + text: string, + metadata: { title?: string } = {} +): Promise => { const type = detectType(text, configApi.getConfig()); try { // Trying to find the diagram @@ -101,5 +97,5 @@ export const getDiagramFromText = async (text: string): Promise => { const { id, diagram } = await loader(); registerDiagram(id, diagram); } - return new Diagram(text); + return new Diagram(text, metadata); }; diff --git a/packages/mermaid/src/__mocks__/mermaidAPI.ts b/packages/mermaid/src/__mocks__/mermaidAPI.ts index a2d19ef24..de4cb61df 100644 --- a/packages/mermaid/src/__mocks__/mermaidAPI.ts +++ b/packages/mermaid/src/__mocks__/mermaidAPI.ts @@ -13,7 +13,6 @@ export const mermaidAPI = { svg: '', }), parse: mAPI.parse, - parseDirective: vi.fn(), initialize: vi.fn(), getConfig: configApi.getConfig, setConfig: configApi.setConfig, diff --git a/packages/mermaid/src/config.ts b/packages/mermaid/src/config.ts index eb24b6268..ede3a568d 100644 --- a/packages/mermaid/src/config.ts +++ b/packages/mermaid/src/config.ts @@ -3,7 +3,7 @@ import { log } from './logger.js'; import theme from './themes/index.js'; import config from './defaultConfig.js'; import type { MermaidConfig } from './config.type.js'; -import { sanitizeDirective } from './utils.js'; +import { sanitizeDirective } from './utils/sanitizeDirective.js'; export const defaultConfig: MermaidConfig = Object.freeze(config); diff --git a/packages/mermaid/src/diagram-api/comments.ts b/packages/mermaid/src/diagram-api/comments.ts index be39b0a0f..8141deee0 100644 --- a/packages/mermaid/src/diagram-api/comments.ts +++ b/packages/mermaid/src/diagram-api/comments.ts @@ -4,5 +4,5 @@ * @returns cleaned text */ export const cleanupComments = (text: string): string => { - return text.trimStart().replace(/^\s*%%(?!{)[^\n]+\n?/gm, ''); + return text.replace(/^\s*%%(?!{)[^\n]+\n?/gm, '').trimStart(); }; diff --git a/packages/mermaid/src/diagram-api/diagramAPI.ts b/packages/mermaid/src/diagram-api/diagramAPI.ts index 00da66ffe..0cb20b3b2 100644 --- a/packages/mermaid/src/diagram-api/diagramAPI.ts +++ b/packages/mermaid/src/diagram-api/diagramAPI.ts @@ -6,7 +6,6 @@ import { setupGraphViewbox as _setupGraphViewbox } from '../setupGraphViewbox.js import { addStylesForDiagram } from '../styles.js'; import type { DiagramDefinition, DiagramDetector } from './types.js'; import * as _commonDb from '../commonDb.js'; -import { parseDirective as _parseDirective } from '../directiveUtils.js'; /* Packaging and exposing resources for external diagrams so that they can import @@ -21,8 +20,6 @@ export const setupGraphViewbox = _setupGraphViewbox; export const getCommonDb = () => { return _commonDb; }; -export const parseDirective = (p: any, statement: string, context: string, type: string) => - _parseDirective(p, statement, context, type); const diagrams: Record = {}; export interface Detectors { @@ -52,17 +49,17 @@ export const registerDiagram = ( } addStylesForDiagram(id, diagram.styles); - if (diagram.injectUtils) { - diagram.injectUtils( - log, - setLogLevel, - getConfig, - sanitizeText, - setupGraphViewbox, - getCommonDb(), - parseDirective - ); - } + diagram.injectUtils?.( + log, + setLogLevel, + getConfig, + sanitizeText, + setupGraphViewbox, + getCommonDb(), + () => { + // parseDirective is removed. This is a no-op for legacy support. + } + ); }; export const getDiagram = (name: string): DiagramDefinition => { diff --git a/packages/mermaid/src/diagram-api/frontmatter.spec.ts b/packages/mermaid/src/diagram-api/frontmatter.spec.ts index 03d46c300..90ef97cb6 100644 --- a/packages/mermaid/src/diagram-api/frontmatter.spec.ts +++ b/packages/mermaid/src/diagram-api/frontmatter.spec.ts @@ -1,84 +1,139 @@ -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'); + expect(extractFrontMatter('diagram')).toMatchInlineSnapshot(` + { + "metadata": {}, + "text": "diagram", + } + `); }); it('returns text unchanged if frontmatter lacks closing delimiter', () => { const text = `---\ntitle: foo\ndiagram`; - expect(extractFrontMatter(text, dbMock())).toEqual(text); + expect(extractFrontMatter(text)).toMatchInlineSnapshot(` + { + "metadata": {}, + "text": "--- + title: foo + diagram", + } + `); }); it('handles empty frontmatter', () => { - const db = dbMock(); const text = `---\n\n---\ndiagram`; - expect(extractFrontMatter(text, db)).toEqual('diagram'); - expect(db.setDiagramTitle).not.toHaveBeenCalled(); + expect(extractFrontMatter(text)).toMatchInlineSnapshot(` + { + "metadata": {}, + "text": "diagram", + } + `); }); it('handles frontmatter without mappings', () => { - const db = dbMock(); - const text = `---\n1\n---\ndiagram`; - expect(extractFrontMatter(text, db)).toEqual('diagram'); - expect(db.setDiagramTitle).not.toHaveBeenCalled(); + expect(extractFrontMatter(`---\n1\n---\ndiagram`)).toMatchInlineSnapshot(` + { + "metadata": {}, + "text": "diagram", + } + `); + expect(extractFrontMatter(`---\n-1\n-2\n---\ndiagram`)).toMatchInlineSnapshot(` + { + "metadata": {}, + "text": "diagram", + } + `); + expect(extractFrontMatter(`---\nnull\n---\ndiagram`)).toMatchInlineSnapshot(` + { + "metadata": {}, + "text": "diagram", + } + `); }); it('does not try to parse frontmatter at the end', () => { - const db = dbMock(); const text = `diagram\n---\ntitle: foo\n---\n`; - expect(extractFrontMatter(text, db)).toEqual(text); - expect(db.setDiagramTitle).not.toHaveBeenCalled(); + expect(extractFrontMatter(text)).toMatchInlineSnapshot(` + { + "metadata": {}, + "text": "diagram + --- + title: foo + --- + ", + } + `); }); it('handles frontmatter with multiple delimiters', () => { - const db = dbMock(); const text = `---\ntitle: foo---bar\n---\ndiagram\n---\ntest`; - expect(extractFrontMatter(text, db)).toEqual('diagram\n---\ntest'); - expect(db.setDiagramTitle).toHaveBeenCalledWith('foo---bar'); + expect(extractFrontMatter(text)).toMatchInlineSnapshot(` + { + "metadata": { + "title": "foo---bar", + }, + "text": "diagram + --- + test", + } + `); }); it('handles frontmatter with multi-line string and multiple delimiters', () => { - const db = dbMock(); const text = `---\ntitle: |\n multi-line string\n ---\n---\ndiagram`; - expect(extractFrontMatter(text, db)).toEqual('diagram'); - expect(db.setDiagramTitle).toHaveBeenCalledWith('multi-line string\n---\n'); + expect(extractFrontMatter(text)).toMatchInlineSnapshot(` + { + "metadata": { + "title": "multi-line string + --- + ", + }, + "text": "diagram", + } + `); }); it('handles frontmatter with title', () => { - const db = dbMock(); const text = `---\ntitle: foo\n---\ndiagram`; - expect(extractFrontMatter(text, db)).toEqual('diagram'); - expect(db.setDiagramTitle).toHaveBeenCalledWith('foo'); + expect(extractFrontMatter(text)).toMatchInlineSnapshot(` + { + "metadata": { + "title": "foo", + }, + "text": "diagram", + } + `); }); it('handles booleans in frontmatter properly', () => { - const db = dbMock(); const text = `---\ntitle: true\n---\ndiagram`; - expect(extractFrontMatter(text, db)).toEqual('diagram'); - expect(db.setDiagramTitle).toHaveBeenCalledWith('true'); + expect(extractFrontMatter(text)).toMatchInlineSnapshot(` + { + "metadata": { + "title": "true", + }, + "text": "diagram", + } + `); }); it('ignores unspecified frontmatter keys', () => { - const db = dbMock(); const text = `---\ninvalid: true\ntitle: foo\ntest: bar\n---\ndiagram`; - expect(extractFrontMatter(text, db)).toEqual('diagram'); - expect(db.setDiagramTitle).toHaveBeenCalledWith('foo'); + expect(extractFrontMatter(text)).toMatchInlineSnapshot(` + { + "metadata": { + "title": "foo", + }, + "text": "diagram", + } + `); }); it('throws exception for invalid YAML syntax', () => { const text = `---\n!!!\n---\ndiagram`; - expect(() => extractFrontMatter(text, dbMock())).toThrow( - 'tag suffix cannot contain exclamation marks' - ); + expect(() => extractFrontMatter(text)).toThrow('tag suffix cannot contain exclamation marks'); }); it('handles frontmatter with config', () => { @@ -92,9 +147,25 @@ config: 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] }, - }); + expect(extractFrontMatter(text)).toMatchInlineSnapshot(` + { + "metadata": { + "config": { + "graph": { + "array": [ + 1, + 2, + 3, + ], + "boolean": false, + "number": 14, + "string": "hello", + }, + }, + "title": "hello", + }, + "text": "diagram", + } + `); }); }); diff --git a/packages/mermaid/src/diagram-api/frontmatter.ts b/packages/mermaid/src/diagram-api/frontmatter.ts index 0fd2917ea..c95e05f2c 100644 --- a/packages/mermaid/src/diagram-api/frontmatter.ts +++ b/packages/mermaid/src/diagram-api/frontmatter.ts @@ -1,6 +1,5 @@ import type { MermaidConfig } from '../config.type.js'; import { frontMatterRegex } from './regexes.js'; -import type { DiagramDB } from './types.js'; // The "* as yaml" part is necessary for tree-shaking import * as yaml from 'js-yaml'; @@ -11,43 +10,51 @@ interface FrontMatterMetadata { config?: MermaidConfig; } +export interface FrontMatterResult { + text: string; + metadata: FrontMatterMetadata; +} + /** * 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, - setDiagramConfig?: (config: MermaidConfig) => void -): string { +export function extractFrontMatter(text: string): FrontMatterResult { const matches = text.match(frontMatterRegex); if (!matches) { - return text; + return { + text, + metadata: {}, + }; } - 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; + let 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, + }) ?? {}; - if (parsed?.title) { - // toString() is necessary because YAML could parse the title as a number/boolean - db.setDiagramTitle?.(parsed.title.toString()); + // To handle runtime data type changes + parsed = typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {}; + + const metadata: FrontMatterMetadata = {}; + + // Only add properties that are explicitly supported, if they exist + if (parsed.displayMode) { + metadata.displayMode = parsed.displayMode.toString(); + } + if (parsed.title) { + metadata.title = parsed.title.toString(); + } + if (parsed.config) { + metadata.config = parsed.config; } - 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); + return { + text: text.slice(matches[0].length), + metadata, + }; } diff --git a/packages/mermaid/src/diagram-api/types.ts b/packages/mermaid/src/diagram-api/types.ts index 2ac7fba12..e9def2421 100644 --- a/packages/mermaid/src/diagram-api/types.ts +++ b/packages/mermaid/src/diagram-api/types.ts @@ -29,6 +29,7 @@ export interface DiagramDB { getAccDescription?: () => string; setDisplayMode?: (title: string) => void; + setWrap?: (wrap: boolean) => void; bindFunctions?: (element: Element) => void; } @@ -83,15 +84,6 @@ export interface ParserDefinition { parser: { yy: DiagramDB }; } -/** - * Type for function parse directive from diagram code. - * - * @param statement - - * @param context - - * @param type - - */ -export type ParseDirectiveDefinition = (statement: string, context: string, type: string) => void; - export type HTML = d3.Selection; export type SVG = d3.Selection; diff --git a/packages/mermaid/src/directiveUtils.ts b/packages/mermaid/src/directiveUtils.ts deleted file mode 100644 index baf628e74..000000000 --- a/packages/mermaid/src/directiveUtils.ts +++ /dev/null @@ -1,82 +0,0 @@ -import * as configApi from './config.js'; -import { log } from './logger.js'; - -let currentDirective: { type?: string; args?: any } | undefined = {}; - -export const parseDirective = function ( - p: any, - statement: string, - context: string, - type: string -): void { - log.debug('parseDirective is being called', statement, context, type); - try { - if (statement !== undefined) { - statement = statement.trim(); - switch (context) { - case 'open_directive': - currentDirective = {}; - break; - case 'type_directive': - if (!currentDirective) { - throw new Error('currentDirective is undefined'); - } - currentDirective.type = statement.toLowerCase(); - break; - case 'arg_directive': - if (!currentDirective) { - throw new Error('currentDirective is undefined'); - } - currentDirective.args = JSON.parse(statement); - break; - case 'close_directive': - handleDirective(p, currentDirective, type); - currentDirective = undefined; - break; - } - } - } catch (error) { - log.error( - `Error while rendering sequenceDiagram directive: ${statement} jison context: ${context}` - ); - // @ts-ignore: TODO Fix ts errors - log.error(error.message); - } -}; - -const handleDirective = function (p: any, directive: any, type: string): void { - log.info(`Directive type=${directive.type} with args:`, directive.args); - switch (directive.type) { - case 'init': - case 'initialize': { - ['config'].forEach((prop) => { - if (directive.args[prop] !== undefined) { - if (type === 'flowchart-v2') { - type = 'flowchart'; - } - directive.args[type] = directive.args[prop]; - delete directive.args[prop]; - } - }); - configApi.addDirective(directive.args); - break; - } - case 'wrap': - case 'nowrap': - if (p && p['setWrap']) { - p.setWrap(directive.type === 'wrap'); - } - break; - case 'themeCss': - log.warn('themeCss encountered'); - break; - default: - log.warn( - `Unhandled directive: source: '%%{${directive.type}: ${JSON.stringify( - directive.args ? directive.args : {} - )}}%%`, - directive - ); - break; - } -}; diff --git a/packages/mermaid/src/mermaidAPI.ts b/packages/mermaid/src/mermaidAPI.ts index bb7570034..f71fe27a7 100644 --- a/packages/mermaid/src/mermaidAPI.ts +++ b/packages/mermaid/src/mermaidAPI.ts @@ -23,14 +23,12 @@ import { attachFunctions } from './interactionDb.js'; import { log, setLogLevel } from './logger.js'; import getStyles from './styles.js'; import theme from './themes/index.js'; -import utils from './utils.js'; import DOMPurify from 'dompurify'; import type { MermaidConfig } from './config.type.js'; 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'; +import { preprocessDiagram } from './preprocess.js'; // diagram names that support classDef statements const CLASSDEF_DIAGRAMS = [ @@ -98,6 +96,13 @@ export interface RenderResult { bindFunctions?: (element: Element) => void; } +function processAndSetConfigs(text: string) { + const processed = preprocessDiagram(text); + configApi.reset(); + configApi.addDirective(processed.config); + return processed; +} + /** * Parse the text and validate the syntax. * @param text - The mermaid diagram definition. @@ -108,6 +113,9 @@ export interface RenderResult { async function parse(text: string, parseOptions?: ParseOptions): Promise { addDiagrams(); + + text = processAndSetConfigs(text).code; + try { await getDiagramFromText(text); } catch (error) { @@ -384,18 +392,8 @@ const render = async function ( ): Promise { addDiagrams(); - configApi.reset(); - - // 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); - } + const processed = processAndSetConfigs(text); + text = processed.code; const config = configApi.getConfig(); log.debug(config); @@ -405,15 +403,6 @@ const render = async function ( 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;; - - // clean up html tags so that all attributes use single quotes, parser throws error on double quotes - text = text.replace( - /<(\w+)([^>]*)>/g, - (match, tag, attributes) => '<' + tag + attributes.replace(/="([^"]*)"/g, "='$1'") + '>' - ); - const idSelector = '#' + id; const iFrameID = 'i' + id; const iFrameID_selector = '#' + iFrameID; @@ -476,7 +465,7 @@ const render = async function ( let parseEncounteredException; try { - diag = await getDiagramFromText(text); + diag = await getDiagramFromText(text, { title: processed.title }); } catch (error) { diag = new Diagram('error'); parseEncounteredException = error; @@ -673,7 +662,6 @@ function addA11yInfo( export const mermaidAPI = Object.freeze({ render, parse, - parseDirective, getDiagramFromText, initialize, getConfig: configApi.getConfig, diff --git a/packages/mermaid/src/preprocess.ts b/packages/mermaid/src/preprocess.ts new file mode 100644 index 000000000..3c33ce30f --- /dev/null +++ b/packages/mermaid/src/preprocess.ts @@ -0,0 +1,58 @@ +import { cleanupComments } from './diagram-api/comments.js'; +import { extractFrontMatter } from './diagram-api/frontmatter.js'; +import utils, { cleanAndMerge, removeDirectives } from './utils.js'; + +const cleanupText = (code: string) => { + return ( + code + // parser problems on CRLF ignore all CR and leave LF;; + .replace(/\r\n?/g, '\n') + // clean up html tags so that all attributes use single quotes, parser throws error on double quotes + .replace( + /<(\w+)([^>]*)>/g, + (match, tag, attributes) => '<' + tag + attributes.replace(/="([^"]*)"/g, "='$1'") + '>' + ) + ); +}; + +const processFrontmatter = (code: string) => { + const { text, metadata } = extractFrontMatter(code); + const { displayMode, title, config = {} } = metadata; + if (displayMode) { + // Needs to be supported for legacy reasons + if (!config.gantt) { + config.gantt = {}; + } + config.gantt.displayMode = displayMode; + } + return { title, config, text }; +}; + +const processDirectives = (code: string) => { + const initDirective = utils.detectInit(code) ?? {}; + const wrapDirectives = utils.detectDirective(code, 'wrap'); + if (Array.isArray(wrapDirectives)) { + initDirective.wrap = wrapDirectives.some(({ type }) => { + type === 'wrap'; + }); + } else if (wrapDirectives?.type === 'wrap') { + initDirective.wrap = true; + } + return { + text: removeDirectives(code), + directive: initDirective, + }; +}; + +export const preprocessDiagram = (code: string) => { + const cleanedCode = cleanupText(code); + const frontMatterResult = processFrontmatter(cleanedCode); + const directiveResult = processDirectives(frontMatterResult.text); + const config = cleanAndMerge(frontMatterResult.config, directiveResult.directive); + code = cleanupComments(directiveResult.text); + return { + code, + title: frontMatterResult.title, + config, + }; +}; diff --git a/packages/mermaid/src/utils.spec.ts b/packages/mermaid/src/utils.spec.ts index 271dc588c..e1398efc7 100644 --- a/packages/mermaid/src/utils.spec.ts +++ b/packages/mermaid/src/utils.spec.ts @@ -1,10 +1,11 @@ import { vi } from 'vitest'; -import utils, { cleanAndMerge } from './utils.js'; +import utils, { cleanAndMerge, detectDirective } from './utils.js'; import assignWithDepth from './assignWithDepth.js'; import { detectType } from './diagram-api/detectType.js'; import { addDiagrams } from './diagram-api/diagram-orchestration.js'; import memoize from 'lodash-es/memoize.js'; import { MockedD3 } from './tests/MockedD3.js'; +import { preprocessDiagram } from './preprocess.js'; addDiagrams(); @@ -158,13 +159,38 @@ describe('when detecting chart type ', function () { const type = detectType(str); expect(type).toBe('flowchart'); }); + it('should handle a wrap directive', () => { + const wrap = { type: 'wrap', args: null }; + expect(detectDirective('%%{wrap}%%', 'wrap')).toEqual(wrap); + expect( + detectDirective( + `%%{ + wrap + }%%`, + 'wrap' + ) + ).toEqual(wrap); + expect( + detectDirective( + `%%{ + + wrap + + }%%`, + 'wrap' + ) + ).toEqual(wrap); + expect(detectDirective('%%{wrap:}%%', 'wrap')).toEqual(wrap); + expect(detectDirective('%%{wrap: }%%', 'wrap')).toEqual(wrap); + expect(detectDirective('graph', 'wrap')).not.toEqual(wrap); + }); it('should handle an initialize definition', function () { const str = ` %%{initialize: { 'logLevel': 0, 'theme': 'dark' }}%% sequenceDiagram Alice->Bob: hi`; const type = detectType(str); - const init = utils.detectInit(str); + const init = preprocessDiagram(str).config; expect(type).toBe('sequence'); expect(init).toEqual({ logLevel: 0, theme: 'dark' }); }); @@ -174,7 +200,7 @@ Alice->Bob: hi`; sequenceDiagram Alice->Bob: hi`; const type = detectType(str); - const init = utils.detectInit(str); + const init = preprocessDiagram(str).config; expect(type).toBe('sequence'); expect(init).toEqual({ logLevel: 0, theme: 'dark' }); }); @@ -184,7 +210,7 @@ Alice->Bob: hi`; sequenceDiagram Alice->Bob: hi`; const type = detectType(str); - const init = utils.detectInit(str); + const init = preprocessDiagram(str).config; expect(type).toBe('sequence'); expect(init).toEqual({ logLevel: 0, theme: 'dark', sequence: { wrap: true } }); }); @@ -199,7 +225,7 @@ Alice->Bob: hi`; sequenceDiagram Alice->Bob: hi`; const type = detectType(str); - const init = utils.detectInit(str); + const init = preprocessDiagram(str).config; expect(type).toBe('sequence'); expect(init).toEqual({ logLevel: 0, theme: 'dark' }); }); @@ -214,7 +240,7 @@ Alice->Bob: hi`; sequenceDiagram Alice->Bob: hi`; const type = detectType(str); - const init = utils.detectInit(str); + const init = preprocessDiagram(str).config; expect(type).toBe('sequence'); expect(init).toEqual({ logLevel: 0, theme: 'dark' }); }); diff --git a/packages/mermaid/src/utils.ts b/packages/mermaid/src/utils.ts index 42b4ee67e..70de197da 100644 --- a/packages/mermaid/src/utils.ts +++ b/packages/mermaid/src/utils.ts @@ -25,7 +25,7 @@ import { select, } from 'd3'; import common from './diagrams/common/common.js'; -import { configKeys } from './defaultConfig.js'; +import { sanitizeDirective } from './utils/sanitizeDirective.js'; import { log } from './logger.js'; import { detectType } from './diagram-api/detectType.js'; import assignWithDepth from './assignWithDepth.js'; @@ -62,7 +62,6 @@ const d3CurveTypes = { const directiveWithoutOpen = /\s*(?:(\w+)(?=:):|(\w+))\s*(?:(\w+)|((?:(?!}%{2}).|\r?\n)*))?\s*(?:}%{2})?/gi; - /** * Detects the init config object from the text * @@ -197,6 +196,10 @@ export const detectDirective = function ( } }; +export const removeDirectives = function (text: string): string { + return text.replace(directiveRegex, ''); +}; + /** * Detects whether a substring in present in a given array * @@ -842,88 +845,6 @@ export const entityDecode = function (html: string): string { return unescape(decoder.textContent); }; -/** - * Sanitizes directive objects - * - * @param args - Directive's JSON - */ -export const sanitizeDirective = (args: unknown): void => { - log.debug('sanitizeDirective called with', args); - - // Return if not an object - if (typeof args !== 'object' || args == null) { - return; - } - - // Sanitize each element if an array - if (Array.isArray(args)) { - args.forEach((arg) => sanitizeDirective(arg)); - return; - } - - // Sanitize each key if an object - for (const key of Object.keys(args)) { - log.debug('Checking key', key); - if ( - key.startsWith('__') || - key.includes('proto') || - key.includes('constr') || - !configKeys.has(key) || - args[key] == null - ) { - log.debug('sanitize deleting key: ', key); - delete args[key]; - continue; - } - - // Recurse if an object - if (typeof args[key] === 'object') { - log.debug('sanitizing object', key); - sanitizeDirective(args[key]); - continue; - } - - const cssMatchers = ['themeCSS', 'fontFamily', 'altFontFamily']; - for (const cssKey of cssMatchers) { - if (key.includes(cssKey)) { - log.debug('sanitizing css option', key); - args[key] = sanitizeCss(args[key]); - } - } - } - - if (args.themeVariables) { - for (const k of Object.keys(args.themeVariables)) { - const val = args.themeVariables[k]; - if (val?.match && !val.match(/^[\d "#%(),.;A-Za-z]+$/)) { - args.themeVariables[k] = ''; - } - } - } - log.debug('After sanitization', args); -}; - -export const sanitizeCss = (str: string): string => { - let startCnt = 0; - let endCnt = 0; - - for (const element of str) { - if (startCnt < endCnt) { - return '{ /* ERROR: Unbalanced CSS */ }'; - } - if (element === '{') { - startCnt++; - } else if (element === '}') { - endCnt++; - } - } - if (startCnt !== endCnt) { - return '{ /* ERROR: Unbalanced CSS */ }'; - } - // Todo add more checks here - return str; -}; - export interface DetailedError { str: string; hash: any; @@ -1021,8 +942,6 @@ export default { runFunc, entityDecode, initIdGenerator, - sanitizeDirective, - sanitizeCss, insertTitle, parseFontSize, }; diff --git a/packages/mermaid/src/utils/sanitizeDirective.ts b/packages/mermaid/src/utils/sanitizeDirective.ts new file mode 100644 index 000000000..9b7e7da5c --- /dev/null +++ b/packages/mermaid/src/utils/sanitizeDirective.ts @@ -0,0 +1,84 @@ +import { configKeys } from '../defaultConfig.js'; +import { log } from '../logger.js'; + +/** + * Sanitizes directive objects + * + * @param args - Directive's JSON + */ +export const sanitizeDirective = (args: any): void => { + log.debug('sanitizeDirective called with', args); + + // Return if not an object + if (typeof args !== 'object' || args == null) { + return; + } + + // Sanitize each element if an array + if (Array.isArray(args)) { + args.forEach((arg) => sanitizeDirective(arg)); + return; + } + + // Sanitize each key if an object + for (const key of Object.keys(args)) { + log.debug('Checking key', key); + if ( + key.startsWith('__') || + key.includes('proto') || + key.includes('constr') || + !configKeys.has(key) || + args[key] == null + ) { + log.debug('sanitize deleting key: ', key); + delete args[key]; + continue; + } + + // Recurse if an object + if (typeof args[key] === 'object') { + log.debug('sanitizing object', key); + sanitizeDirective(args[key]); + continue; + } + + const cssMatchers = ['themeCSS', 'fontFamily', 'altFontFamily']; + for (const cssKey of cssMatchers) { + if (key.includes(cssKey)) { + log.debug('sanitizing css option', key); + args[key] = sanitizeCss(args[key]); + } + } + } + + if (args.themeVariables) { + for (const k of Object.keys(args.themeVariables)) { + const val = args.themeVariables[k]; + if (val?.match && !val.match(/^[\d "#%(),.;A-Za-z]+$/)) { + args.themeVariables[k] = ''; + } + } + } + log.debug('After sanitization', args); +}; + +export const sanitizeCss = (str: string): string => { + let startCnt = 0; + let endCnt = 0; + + for (const element of str) { + if (startCnt < endCnt) { + return '{ /* ERROR: Unbalanced CSS */ }'; + } + if (element === '{') { + startCnt++; + } else if (element === '}') { + endCnt++; + } + } + if (startCnt !== endCnt) { + return '{ /* ERROR: Unbalanced CSS */ }'; + } + // Todo add more checks here + return str; +};