refactor: Move directive processing before parsing

Directives and fronmatter will be preprocessed
and removed from the text before parsing.
This commit is contained in:
Sidharth Vinod
2023-08-25 12:54:44 +05:30
parent 2dd1415849
commit 276fd7ad84
17 changed files with 384 additions and 329 deletions

View File

@@ -16,4 +16,4 @@
#### Defined in #### 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)

View File

@@ -39,7 +39,7 @@ bindFunctions?.(div); // To call bindFunctions only if it's present.
#### Defined in #### 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 #### 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)

View File

@@ -25,13 +25,13 @@ Renames and re-exports [mermaidAPI](mermaidAPI.md#mermaidapi)
#### Defined in #### 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 ## Variables
### mermaidAPI ### 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 ## mermaidAPI configuration defaults
@@ -96,7 +96,7 @@ mermaid.initialize(config);
#### Defined in #### 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 ## Functions
@@ -127,7 +127,7 @@ Return the last node appended
#### Defined in #### 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 #### 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 #### 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 #### 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 #### 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 #### 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 #### 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 #### 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 #### 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)

View File

@@ -2,9 +2,7 @@ import * as configApi from './config.js';
import { log } from './logger.js'; import { log } from './logger.js';
import { getDiagram, registerDiagram } from './diagram-api/diagramAPI.js'; import { getDiagram, registerDiagram } from './diagram-api/diagramAPI.js';
import { detectType, getDiagramLoader } from './diagram-api/detectType.js'; import { detectType, getDiagramLoader } from './diagram-api/detectType.js';
import { extractFrontMatter } from './diagram-api/frontmatter.js';
import { UnknownDiagramError } from './errors.js'; import { UnknownDiagramError } from './errors.js';
import { cleanupComments } from './diagram-api/comments.js';
import type { DetailedError } from './utils.js'; import type { DetailedError } from './utils.js';
import type { DiagramDefinition } from './diagram-api/types.js'; import type { DiagramDefinition } from './diagram-api/types.js';
@@ -22,7 +20,7 @@ export class Diagram {
private init?: DiagramDefinition['init']; private init?: DiagramDefinition['init'];
private detectError?: UnknownDiagramError; private detectError?: UnknownDiagramError;
constructor(public text: string) { constructor(public text: string, public metadata: { title?: string } = {}) {
this.text += '\n'; this.text += '\n';
const cnf = configApi.getConfig(); const cnf = configApi.getConfig();
try { try {
@@ -37,19 +35,6 @@ export class Diagram {
this.db = diagram.db; this.db = diagram.db;
this.renderer = diagram.renderer; this.renderer = diagram.renderer;
this.parser = diagram.parser; 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.parser.parser.yy = this.db;
this.init = diagram.init; this.init = diagram.init;
this.parse(); this.parse();
@@ -60,7 +45,15 @@ export class Diagram {
throw this.detectError; throw this.detectError;
} }
this.db.clear?.(); 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); this.parser.parse(this.text);
} }
@@ -86,7 +79,10 @@ export class Diagram {
* @throws {@link UnknownDiagramError} if the diagram type can not be found. * @throws {@link UnknownDiagramError} if the diagram type can not be found.
* @privateRemarks This is exported as part of the public mermaidAPI. * @privateRemarks This is exported as part of the public mermaidAPI.
*/ */
export const getDiagramFromText = async (text: string): Promise<Diagram> => { export const getDiagramFromText = async (
text: string,
metadata: { title?: string } = {}
): Promise<Diagram> => {
const type = detectType(text, configApi.getConfig()); const type = detectType(text, configApi.getConfig());
try { try {
// Trying to find the diagram // Trying to find the diagram
@@ -101,5 +97,5 @@ export const getDiagramFromText = async (text: string): Promise<Diagram> => {
const { id, diagram } = await loader(); const { id, diagram } = await loader();
registerDiagram(id, diagram); registerDiagram(id, diagram);
} }
return new Diagram(text); return new Diagram(text, metadata);
}; };

View File

@@ -13,7 +13,6 @@ export const mermaidAPI = {
svg: '<svg></svg>', svg: '<svg></svg>',
}), }),
parse: mAPI.parse, parse: mAPI.parse,
parseDirective: vi.fn(),
initialize: vi.fn(), initialize: vi.fn(),
getConfig: configApi.getConfig, getConfig: configApi.getConfig,
setConfig: configApi.setConfig, setConfig: configApi.setConfig,

View File

@@ -3,7 +3,7 @@ import { log } from './logger.js';
import theme from './themes/index.js'; import theme from './themes/index.js';
import config from './defaultConfig.js'; import config from './defaultConfig.js';
import type { MermaidConfig } from './config.type.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); export const defaultConfig: MermaidConfig = Object.freeze(config);

View File

@@ -4,5 +4,5 @@
* @returns cleaned text * @returns cleaned text
*/ */
export const cleanupComments = (text: string): string => { export const cleanupComments = (text: string): string => {
return text.trimStart().replace(/^\s*%%(?!{)[^\n]+\n?/gm, ''); return text.replace(/^\s*%%(?!{)[^\n]+\n?/gm, '').trimStart();
}; };

View File

@@ -6,7 +6,6 @@ import { setupGraphViewbox as _setupGraphViewbox } from '../setupGraphViewbox.js
import { addStylesForDiagram } from '../styles.js'; import { addStylesForDiagram } from '../styles.js';
import type { DiagramDefinition, DiagramDetector } from './types.js'; import type { DiagramDefinition, DiagramDetector } from './types.js';
import * as _commonDb from '../commonDb.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 Packaging and exposing resources for external diagrams so that they can import
@@ -21,8 +20,6 @@ export const setupGraphViewbox = _setupGraphViewbox;
export const getCommonDb = () => { export const getCommonDb = () => {
return _commonDb; return _commonDb;
}; };
export const parseDirective = (p: any, statement: string, context: string, type: string) =>
_parseDirective(p, statement, context, type);
const diagrams: Record<string, DiagramDefinition> = {}; const diagrams: Record<string, DiagramDefinition> = {};
export interface Detectors { export interface Detectors {
@@ -52,17 +49,17 @@ export const registerDiagram = (
} }
addStylesForDiagram(id, diagram.styles); addStylesForDiagram(id, diagram.styles);
if (diagram.injectUtils) { diagram.injectUtils?.(
diagram.injectUtils(
log, log,
setLogLevel, setLogLevel,
getConfig, getConfig,
sanitizeText, sanitizeText,
setupGraphViewbox, setupGraphViewbox,
getCommonDb(), getCommonDb(),
parseDirective () => {
); // parseDirective is removed. This is a no-op for legacy support.
} }
);
}; };
export const getDiagram = (name: string): DiagramDefinition => { export const getDiagram = (name: string): DiagramDefinition => {

View File

@@ -1,84 +1,139 @@
import { vi } from 'vitest';
import { extractFrontMatter } from './frontmatter.js'; import { extractFrontMatter } from './frontmatter.js';
const dbMock = () => ({ setDiagramTitle: vi.fn() });
const setConfigMock = vi.fn();
describe('extractFrontmatter', () => { describe('extractFrontmatter', () => {
beforeEach(() => {
setConfigMock.mockClear();
});
it('returns text unchanged if no frontmatter', () => { 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', () => { it('returns text unchanged if frontmatter lacks closing delimiter', () => {
const text = `---\ntitle: foo\ndiagram`; const text = `---\ntitle: foo\ndiagram`;
expect(extractFrontMatter(text, dbMock())).toEqual(text); expect(extractFrontMatter(text)).toMatchInlineSnapshot(`
{
"metadata": {},
"text": "---
title: foo
diagram",
}
`);
}); });
it('handles empty frontmatter', () => { it('handles empty frontmatter', () => {
const db = dbMock();
const text = `---\n\n---\ndiagram`; const text = `---\n\n---\ndiagram`;
expect(extractFrontMatter(text, db)).toEqual('diagram'); expect(extractFrontMatter(text)).toMatchInlineSnapshot(`
expect(db.setDiagramTitle).not.toHaveBeenCalled(); {
"metadata": {},
"text": "diagram",
}
`);
}); });
it('handles frontmatter without mappings', () => { it('handles frontmatter without mappings', () => {
const db = dbMock(); expect(extractFrontMatter(`---\n1\n---\ndiagram`)).toMatchInlineSnapshot(`
const text = `---\n1\n---\ndiagram`; {
expect(extractFrontMatter(text, db)).toEqual('diagram'); "metadata": {},
expect(db.setDiagramTitle).not.toHaveBeenCalled(); "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', () => { it('does not try to parse frontmatter at the end', () => {
const db = dbMock();
const text = `diagram\n---\ntitle: foo\n---\n`; const text = `diagram\n---\ntitle: foo\n---\n`;
expect(extractFrontMatter(text, db)).toEqual(text); expect(extractFrontMatter(text)).toMatchInlineSnapshot(`
expect(db.setDiagramTitle).not.toHaveBeenCalled(); {
"metadata": {},
"text": "diagram
---
title: foo
---
",
}
`);
}); });
it('handles frontmatter with multiple delimiters', () => { it('handles frontmatter with multiple delimiters', () => {
const db = dbMock();
const text = `---\ntitle: foo---bar\n---\ndiagram\n---\ntest`; const text = `---\ntitle: foo---bar\n---\ndiagram\n---\ntest`;
expect(extractFrontMatter(text, db)).toEqual('diagram\n---\ntest'); expect(extractFrontMatter(text)).toMatchInlineSnapshot(`
expect(db.setDiagramTitle).toHaveBeenCalledWith('foo---bar'); {
"metadata": {
"title": "foo---bar",
},
"text": "diagram
---
test",
}
`);
}); });
it('handles frontmatter with multi-line string and multiple delimiters', () => { it('handles frontmatter with multi-line string and multiple delimiters', () => {
const db = dbMock();
const text = `---\ntitle: |\n multi-line string\n ---\n---\ndiagram`; const text = `---\ntitle: |\n multi-line string\n ---\n---\ndiagram`;
expect(extractFrontMatter(text, db)).toEqual('diagram'); expect(extractFrontMatter(text)).toMatchInlineSnapshot(`
expect(db.setDiagramTitle).toHaveBeenCalledWith('multi-line string\n---\n'); {
"metadata": {
"title": "multi-line string
---
",
},
"text": "diagram",
}
`);
}); });
it('handles frontmatter with title', () => { it('handles frontmatter with title', () => {
const db = dbMock();
const text = `---\ntitle: foo\n---\ndiagram`; const text = `---\ntitle: foo\n---\ndiagram`;
expect(extractFrontMatter(text, db)).toEqual('diagram'); expect(extractFrontMatter(text)).toMatchInlineSnapshot(`
expect(db.setDiagramTitle).toHaveBeenCalledWith('foo'); {
"metadata": {
"title": "foo",
},
"text": "diagram",
}
`);
}); });
it('handles booleans in frontmatter properly', () => { it('handles booleans in frontmatter properly', () => {
const db = dbMock();
const text = `---\ntitle: true\n---\ndiagram`; const text = `---\ntitle: true\n---\ndiagram`;
expect(extractFrontMatter(text, db)).toEqual('diagram'); expect(extractFrontMatter(text)).toMatchInlineSnapshot(`
expect(db.setDiagramTitle).toHaveBeenCalledWith('true'); {
"metadata": {
"title": "true",
},
"text": "diagram",
}
`);
}); });
it('ignores unspecified frontmatter keys', () => { it('ignores unspecified frontmatter keys', () => {
const db = dbMock();
const text = `---\ninvalid: true\ntitle: foo\ntest: bar\n---\ndiagram`; const text = `---\ninvalid: true\ntitle: foo\ntest: bar\n---\ndiagram`;
expect(extractFrontMatter(text, db)).toEqual('diagram'); expect(extractFrontMatter(text)).toMatchInlineSnapshot(`
expect(db.setDiagramTitle).toHaveBeenCalledWith('foo'); {
"metadata": {
"title": "foo",
},
"text": "diagram",
}
`);
}); });
it('throws exception for invalid YAML syntax', () => { it('throws exception for invalid YAML syntax', () => {
const text = `---\n!!!\n---\ndiagram`; const text = `---\n!!!\n---\ndiagram`;
expect(() => extractFrontMatter(text, dbMock())).toThrow( expect(() => extractFrontMatter(text)).toThrow('tag suffix cannot contain exclamation marks');
'tag suffix cannot contain exclamation marks'
);
}); });
it('handles frontmatter with config', () => { it('handles frontmatter with config', () => {
@@ -92,9 +147,25 @@ config:
array: [1, 2, 3] array: [1, 2, 3]
--- ---
diagram`; diagram`;
expect(extractFrontMatter(text, {}, setConfigMock)).toEqual('diagram'); expect(extractFrontMatter(text)).toMatchInlineSnapshot(`
expect(setConfigMock).toHaveBeenCalledWith({ {
graph: { string: 'hello', number: 14, boolean: false, array: [1, 2, 3] }, "metadata": {
}); "config": {
"graph": {
"array": [
1,
2,
3,
],
"boolean": false,
"number": 14,
"string": "hello",
},
},
"title": "hello",
},
"text": "diagram",
}
`);
}); });
}); });

View File

@@ -1,6 +1,5 @@
import type { MermaidConfig } from '../config.type.js'; import type { MermaidConfig } from '../config.type.js';
import { frontMatterRegex } from './regexes.js'; import { frontMatterRegex } from './regexes.js';
import type { DiagramDB } from './types.js';
// The "* as yaml" part is necessary for tree-shaking // The "* as yaml" part is necessary for tree-shaking
import * as yaml from 'js-yaml'; import * as yaml from 'js-yaml';
@@ -11,43 +10,51 @@ interface FrontMatterMetadata {
config?: MermaidConfig; config?: MermaidConfig;
} }
export interface FrontMatterResult {
text: string;
metadata: FrontMatterMetadata;
}
/** /**
* Extract and parse frontmatter from text, if present, and sets appropriate * Extract and parse frontmatter from text, if present, and sets appropriate
* properties in the provided db. * properties in the provided db.
* @param text - The text that may have a YAML frontmatter. * @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 * @returns text with frontmatter stripped out
*/ */
export function extractFrontMatter( export function extractFrontMatter(text: string): FrontMatterResult {
text: string,
db: DiagramDB,
setDiagramConfig?: (config: MermaidConfig) => void
): string {
const matches = text.match(frontMatterRegex); const matches = text.match(frontMatterRegex);
if (!matches) { if (!matches) {
return text; return {
text,
metadata: {},
};
} }
const parsed: FrontMatterMetadata = yaml.load(matches[1], { let parsed: FrontMatterMetadata =
yaml.load(matches[1], {
// To support config, we need JSON schema. // To support config, we need JSON schema.
// https://www.yaml.org/spec/1.2/spec.html#id2803231 // https://www.yaml.org/spec/1.2/spec.html#id2803231
schema: yaml.JSON_SCHEMA, schema: yaml.JSON_SCHEMA,
}) as FrontMatterMetadata; }) ?? {};
if (parsed?.title) { // To handle runtime data type changes
// toString() is necessary because YAML could parse the title as a number/boolean parsed = typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
db.setDiagramTitle?.(parsed.title.toString());
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) { return {
// toString() is necessary because YAML could parse the title as a number/boolean text: text.slice(matches[0].length),
db.setDisplayMode?.(parsed.displayMode.toString()); metadata,
} };
if (parsed?.config) {
setDiagramConfig?.(parsed.config);
}
return text.slice(matches[0].length);
} }

View File

@@ -29,6 +29,7 @@ export interface DiagramDB {
getAccDescription?: () => string; getAccDescription?: () => string;
setDisplayMode?: (title: string) => void; setDisplayMode?: (title: string) => void;
setWrap?: (wrap: boolean) => void;
bindFunctions?: (element: Element) => void; bindFunctions?: (element: Element) => void;
} }
@@ -83,15 +84,6 @@ export interface ParserDefinition {
parser: { yy: DiagramDB }; 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<HTMLIFrameElement, unknown, Element | null, unknown>; export type HTML = d3.Selection<HTMLIFrameElement, unknown, Element | null, unknown>;
export type SVG = d3.Selection<SVGSVGElement, unknown, Element | null, unknown>; export type SVG = d3.Selection<SVGSVGElement, unknown, Element | null, unknown>;

View File

@@ -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;
}
};

View File

@@ -23,14 +23,12 @@ import { attachFunctions } from './interactionDb.js';
import { log, setLogLevel } from './logger.js'; import { log, setLogLevel } from './logger.js';
import getStyles from './styles.js'; import getStyles from './styles.js';
import theme from './themes/index.js'; import theme from './themes/index.js';
import utils from './utils.js';
import DOMPurify from 'dompurify'; import DOMPurify from 'dompurify';
import type { MermaidConfig } from './config.type.js'; import type { MermaidConfig } from './config.type.js';
import { evaluate } from './diagrams/common/common.js'; import { evaluate } from './diagrams/common/common.js';
import isEmpty from 'lodash-es/isEmpty.js'; import isEmpty from 'lodash-es/isEmpty.js';
import { setA11yDiagramInfo, addSVGa11yTitleDescription } from './accessibility.js'; import { setA11yDiagramInfo, addSVGa11yTitleDescription } from './accessibility.js';
import { parseDirective } from './directiveUtils.js'; import { preprocessDiagram } from './preprocess.js';
import { extractFrontMatter } from './diagram-api/frontmatter.js';
// diagram names that support classDef statements // diagram names that support classDef statements
const CLASSDEF_DIAGRAMS = [ const CLASSDEF_DIAGRAMS = [
@@ -98,6 +96,13 @@ export interface RenderResult {
bindFunctions?: (element: Element) => void; 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. * Parse the text and validate the syntax.
* @param text - The mermaid diagram definition. * @param text - The mermaid diagram definition.
@@ -108,6 +113,9 @@ export interface RenderResult {
async function parse(text: string, parseOptions?: ParseOptions): Promise<boolean> { async function parse(text: string, parseOptions?: ParseOptions): Promise<boolean> {
addDiagrams(); addDiagrams();
text = processAndSetConfigs(text).code;
try { try {
await getDiagramFromText(text); await getDiagramFromText(text);
} catch (error) { } catch (error) {
@@ -384,18 +392,8 @@ const render = async function (
): Promise<RenderResult> { ): Promise<RenderResult> {
addDiagrams(); addDiagrams();
configApi.reset(); const processed = processAndSetConfigs(text);
text = processed.code;
// 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 config = configApi.getConfig(); const config = configApi.getConfig();
log.debug(config); log.debug(config);
@@ -405,15 +403,6 @@ const render = async function (
text = MAX_TEXTLENGTH_EXCEEDED_MSG; 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 idSelector = '#' + id;
const iFrameID = 'i' + id; const iFrameID = 'i' + id;
const iFrameID_selector = '#' + iFrameID; const iFrameID_selector = '#' + iFrameID;
@@ -476,7 +465,7 @@ const render = async function (
let parseEncounteredException; let parseEncounteredException;
try { try {
diag = await getDiagramFromText(text); diag = await getDiagramFromText(text, { title: processed.title });
} catch (error) { } catch (error) {
diag = new Diagram('error'); diag = new Diagram('error');
parseEncounteredException = error; parseEncounteredException = error;
@@ -673,7 +662,6 @@ function addA11yInfo(
export const mermaidAPI = Object.freeze({ export const mermaidAPI = Object.freeze({
render, render,
parse, parse,
parseDirective,
getDiagramFromText, getDiagramFromText,
initialize, initialize,
getConfig: configApi.getConfig, getConfig: configApi.getConfig,

View File

@@ -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,
};
};

View File

@@ -1,10 +1,11 @@
import { vi } from 'vitest'; import { vi } from 'vitest';
import utils, { cleanAndMerge } from './utils.js'; import utils, { cleanAndMerge, detectDirective } from './utils.js';
import assignWithDepth from './assignWithDepth.js'; import assignWithDepth from './assignWithDepth.js';
import { detectType } from './diagram-api/detectType.js'; import { detectType } from './diagram-api/detectType.js';
import { addDiagrams } from './diagram-api/diagram-orchestration.js'; import { addDiagrams } from './diagram-api/diagram-orchestration.js';
import memoize from 'lodash-es/memoize.js'; import memoize from 'lodash-es/memoize.js';
import { MockedD3 } from './tests/MockedD3.js'; import { MockedD3 } from './tests/MockedD3.js';
import { preprocessDiagram } from './preprocess.js';
addDiagrams(); addDiagrams();
@@ -158,13 +159,38 @@ describe('when detecting chart type ', function () {
const type = detectType(str); const type = detectType(str);
expect(type).toBe('flowchart'); 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 () { it('should handle an initialize definition', function () {
const str = ` const str = `
%%{initialize: { 'logLevel': 0, 'theme': 'dark' }}%% %%{initialize: { 'logLevel': 0, 'theme': 'dark' }}%%
sequenceDiagram sequenceDiagram
Alice->Bob: hi`; Alice->Bob: hi`;
const type = detectType(str); const type = detectType(str);
const init = utils.detectInit(str); const init = preprocessDiagram(str).config;
expect(type).toBe('sequence'); expect(type).toBe('sequence');
expect(init).toEqual({ logLevel: 0, theme: 'dark' }); expect(init).toEqual({ logLevel: 0, theme: 'dark' });
}); });
@@ -174,7 +200,7 @@ Alice->Bob: hi`;
sequenceDiagram sequenceDiagram
Alice->Bob: hi`; Alice->Bob: hi`;
const type = detectType(str); const type = detectType(str);
const init = utils.detectInit(str); const init = preprocessDiagram(str).config;
expect(type).toBe('sequence'); expect(type).toBe('sequence');
expect(init).toEqual({ logLevel: 0, theme: 'dark' }); expect(init).toEqual({ logLevel: 0, theme: 'dark' });
}); });
@@ -184,7 +210,7 @@ Alice->Bob: hi`;
sequenceDiagram sequenceDiagram
Alice->Bob: hi`; Alice->Bob: hi`;
const type = detectType(str); const type = detectType(str);
const init = utils.detectInit(str); const init = preprocessDiagram(str).config;
expect(type).toBe('sequence'); expect(type).toBe('sequence');
expect(init).toEqual({ logLevel: 0, theme: 'dark', sequence: { wrap: true } }); expect(init).toEqual({ logLevel: 0, theme: 'dark', sequence: { wrap: true } });
}); });
@@ -199,7 +225,7 @@ Alice->Bob: hi`;
sequenceDiagram sequenceDiagram
Alice->Bob: hi`; Alice->Bob: hi`;
const type = detectType(str); const type = detectType(str);
const init = utils.detectInit(str); const init = preprocessDiagram(str).config;
expect(type).toBe('sequence'); expect(type).toBe('sequence');
expect(init).toEqual({ logLevel: 0, theme: 'dark' }); expect(init).toEqual({ logLevel: 0, theme: 'dark' });
}); });
@@ -214,7 +240,7 @@ Alice->Bob: hi`;
sequenceDiagram sequenceDiagram
Alice->Bob: hi`; Alice->Bob: hi`;
const type = detectType(str); const type = detectType(str);
const init = utils.detectInit(str); const init = preprocessDiagram(str).config;
expect(type).toBe('sequence'); expect(type).toBe('sequence');
expect(init).toEqual({ logLevel: 0, theme: 'dark' }); expect(init).toEqual({ logLevel: 0, theme: 'dark' });
}); });

View File

@@ -25,7 +25,7 @@ import {
select, select,
} from 'd3'; } from 'd3';
import common from './diagrams/common/common.js'; import common from './diagrams/common/common.js';
import { configKeys } from './defaultConfig.js'; import { sanitizeDirective } from './utils/sanitizeDirective.js';
import { log } from './logger.js'; import { log } from './logger.js';
import { detectType } from './diagram-api/detectType.js'; import { detectType } from './diagram-api/detectType.js';
import assignWithDepth from './assignWithDepth.js'; import assignWithDepth from './assignWithDepth.js';
@@ -62,7 +62,6 @@ const d3CurveTypes = {
const directiveWithoutOpen = const directiveWithoutOpen =
/\s*(?:(\w+)(?=:):|(\w+))\s*(?:(\w+)|((?:(?!}%{2}).|\r?\n)*))?\s*(?:}%{2})?/gi; /\s*(?:(\w+)(?=:):|(\w+))\s*(?:(\w+)|((?:(?!}%{2}).|\r?\n)*))?\s*(?:}%{2})?/gi;
/** /**
* Detects the init config object from the text * 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 * 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); 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 { export interface DetailedError {
str: string; str: string;
hash: any; hash: any;
@@ -1021,8 +942,6 @@ export default {
runFunc, runFunc,
entityDecode, entityDecode,
initIdGenerator, initIdGenerator,
sanitizeDirective,
sanitizeCss,
insertTitle, insertTitle,
parseFontSize, parseFontSize,
}; };

View File

@@ -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;
};