Skip to content

Commit

Permalink
feat: Add support for config in frontmatter
Browse files Browse the repository at this point in the history
  • Loading branch information
sidharthv96 committed Aug 21, 2023
1 parent 767baa4 commit a6e6c3f
Show file tree
Hide file tree
Showing 7 changed files with 150 additions and 48 deletions.
70 changes: 61 additions & 9 deletions cypress/integration/rendering/conf-and-directives.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -28,7 +27,6 @@ graph TD
end `,
{ theme: 'forest' }
);
cy.get('svg');
});
it('Settings from initialize overriding themeVariable - nodes should be red', () => {
imgSnapshotTest(
Expand All @@ -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(
Expand All @@ -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', () => {
Expand All @@ -79,7 +93,6 @@ graph TD
`,
{}
);
cy.get('svg');
});
it('Settings from initialize and directive - nodes should be grey', () => {
imgSnapshotTest(
Expand All @@ -95,7 +108,6 @@ graph TD
`,
{ theme: 'forest' }
);
cy.get('svg');
});
it('Theme from initialize, directive overriding theme variable - nodes should be red', () => {
imgSnapshotTest(
Expand All @@ -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(
`
Expand All @@ -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');
});
});
Expand Down
2 changes: 1 addition & 1 deletion packages/mermaid/src/Diagram.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
7 changes: 5 additions & 2 deletions packages/mermaid/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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]);
Expand All @@ -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];
}
});
Expand Down
22 changes: 22 additions & 0 deletions packages/mermaid/src/diagram-api/frontmatter.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
Expand Down Expand Up @@ -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] },
});
});
});
53 changes: 33 additions & 20 deletions packages/mermaid/src/diagram-api/frontmatter.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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);
}
8 changes: 7 additions & 1 deletion packages/mermaid/src/mermaidAPI.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand Down Expand Up @@ -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);
Expand Down
36 changes: 21 additions & 15 deletions packages/mermaid/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {};

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

Expand Down Expand Up @@ -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;
}

Expand All @@ -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];
Expand Down

0 comments on commit a6e6c3f

Please sign in to comment.