feat: Add support for config in frontmatter

This commit is contained in:
Sidharth Vinod
2023-08-21 12:09:38 +05:30
parent 767baa4ec6
commit a6e6c3fb18
7 changed files with 150 additions and 48 deletions

View File

@@ -14,7 +14,6 @@ describe('Configuration and directives - nodes should be light blue', () => {
`, `,
{} {}
); );
cy.get('svg');
}); });
it('Settings from initialize - nodes should be green', () => { it('Settings from initialize - nodes should be green', () => {
imgSnapshotTest( imgSnapshotTest(
@@ -28,7 +27,6 @@ graph TD
end `, end `,
{ theme: 'forest' } { theme: 'forest' }
); );
cy.get('svg');
}); });
it('Settings from initialize overriding themeVariable - nodes should be red', () => { it('Settings from initialize overriding themeVariable - nodes should be red', () => {
imgSnapshotTest( imgSnapshotTest(
@@ -46,7 +44,6 @@ graph TD
`, `,
{ theme: 'base', themeVariables: { primaryColor: '#ff0000' }, logLevel: 0 } { theme: 'base', themeVariables: { primaryColor: '#ff0000' }, logLevel: 0 }
); );
cy.get('svg');
}); });
it('Settings from directive - nodes should be grey', () => { it('Settings from directive - nodes should be grey', () => {
imgSnapshotTest( 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', () => { 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', () => { it('Settings from initialize and directive - nodes should be grey', () => {
imgSnapshotTest( imgSnapshotTest(
@@ -95,7 +108,6 @@ graph TD
`, `,
{ theme: 'forest' } { theme: 'forest' }
); );
cy.get('svg');
}); });
it('Theme from initialize, directive overriding theme variable - nodes should be red', () => { it('Theme from initialize, directive overriding theme variable - nodes should be red', () => {
imgSnapshotTest( imgSnapshotTest(
@@ -111,8 +123,50 @@ graph TD
`, `,
{ theme: 'base' } { 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', () => { it('Theme variable from initialize, theme from directive - nodes should be red', () => {
imgSnapshotTest( imgSnapshotTest(
` `
@@ -127,13 +181,11 @@ graph TD
`, `,
{ themeVariables: { primaryColor: '#ff0000' } } { themeVariables: { primaryColor: '#ff0000' } }
); );
cy.get('svg');
}); });
describe('when rendering several diagrams', () => { describe('when rendering several diagrams', () => {
it('diagrams should not taint later diagrams', () => { it('diagrams should not taint later diagrams', () => {
const url = 'http://localhost:9000/theme-directives.html'; const url = 'http://localhost:9000/theme-directives.html';
cy.visit(url); cy.visit(url);
cy.get('svg');
cy.matchImageSnapshot('conf-and-directives.spec-when-rendering-several-diagrams-diagram-1'); cy.matchImageSnapshot('conf-and-directives.spec-when-rendering-several-diagrams-diagram-1');
}); });
}); });

View File

@@ -48,7 +48,7 @@ export class Diagram {
// extractFrontMatter(). // extractFrontMatter().
this.parser.parse = (text: string) => 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.parser.parser.yy = this.db;
this.init = diagram.init; this.init = diagram.init;

View File

@@ -144,9 +144,12 @@ export const getConfig = (): MermaidConfig => {
* @param options - The potential setConfig parameter * @param options - The potential setConfig parameter
*/ */
export const sanitize = (options: any) => { export const sanitize = (options: any) => {
if (!options) {
return;
}
// Checking that options are not in the list of excluded options // Checking that options are not in the list of excluded options
['secure', ...(siteConfig.secure ?? [])].forEach((key) => { ['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 // 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 // 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]); 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 // Check that there no attempts of prototype pollution
Object.keys(options).forEach((key) => { Object.keys(options).forEach((key) => {
if (key.indexOf('__') === 0) { if (key.startsWith('__')) {
delete options[key]; delete options[key];
} }
}); });

View File

@@ -2,8 +2,13 @@ import { vi } from 'vitest';
import { extractFrontMatter } from './frontmatter.js'; import { extractFrontMatter } from './frontmatter.js';
const dbMock = () => ({ setDiagramTitle: vi.fn() }); 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', dbMock())).toEqual('diagram');
}); });
@@ -75,4 +80,21 @@ describe('extractFrontmatter', () => {
'tag suffix cannot contain exclamation marks' '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] },
});
});
}); });

View File

@@ -1,3 +1,4 @@
import { MermaidConfig } from '../config.type.js';
import { DiagramDB } from './types.js'; import { 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';
@@ -9,38 +10,50 @@ import * as yaml from 'js-yaml';
// Relevant YAML spec: https://yaml.org/spec/1.2.2/#914-explicit-documents // 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; export const frontMatterRegex = /^-{3}\s*[\n\r](.*?)[\n\r]-{3}\s*[\n\r]+/s;
type FrontMatterMetadata = { interface FrontMatterMetadata {
title?: string; title?: string;
// Allows custom display modes. Currently used for compact mode in gantt charts. // Allows custom display modes. Currently used for compact mode in gantt charts.
displayMode?: string; displayMode?: string;
}; config?: MermaidConfig;
}
/** /**
* 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 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(text: string, db: DiagramDB): string { export function extractFrontMatter(
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;
}
const parsed: FrontMatterMetadata = yaml.load(matches[1], { const parsed: FrontMatterMetadata = yaml.load(matches[1], {
// To keep things simple, only allow strings, arrays, and plain objects. // To support config, we need JSON schema.
// https://www.yaml.org/spec/1.2/spec.html#id2802346 // https://www.yaml.org/spec/1.2/spec.html#id2803231
schema: yaml.FAILSAFE_SCHEMA, schema: yaml.JSON_SCHEMA,
}) as FrontMatterMetadata; }) as FrontMatterMetadata;
if (parsed?.title) { if (parsed?.title) {
db.setDiagramTitle?.(parsed.title); // toString() is necessary because YAML could parse the title as a number/boolean
db.setDiagramTitle?.(parsed.title.toString());
} }
if (parsed?.displayMode) { if (parsed?.displayMode) {
db.setDisplayMode?.(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.slice(matches[0].length);
} else {
return text;
}
} }

View File

@@ -30,6 +30,7 @@ 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 { parseDirective } from './directiveUtils.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 = [
@@ -385,7 +386,12 @@ const render = async function (
configApi.reset(); 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); const graphInit = utils.detectInit(text);
if (graphInit) { if (graphInit) {
configApi.addDirective(graphInit); configApi.addDirective(graphInit);

View File

@@ -96,7 +96,10 @@ const directiveWithoutOpen =
* @param config - Optional mermaid configuration object. * @param config - Optional mermaid configuration object.
* @returns The json object representing the init passed to mermaid.initialize() * @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)/); const inits = detectDirective(text, /(?:init\b)|(?:initialize\b)/);
let results = {}; let results = {};
@@ -106,7 +109,11 @@ export const detectInit = function (text: string, config?: MermaidConfig): Merma
} else { } else {
results = inits.args; results = inits.args;
} }
if (results) {
if (!results) {
return;
}
let type = detectType(text, config); let type = detectType(text, config);
['config'].forEach((prop) => { ['config'].forEach((prop) => {
if (results[prop] !== undefined) { if (results[prop] !== undefined) {
@@ -117,9 +124,7 @@ export const detectInit = function (text: string, config?: MermaidConfig): Merma
delete results[prop]; delete results[prop];
} }
}); });
}
// Todo: refactor this, these results are never used
return results; return results;
}; };
@@ -844,7 +849,7 @@ export const sanitizeDirective = (args: unknown): void => {
log.debug('sanitizeDirective called with', args); log.debug('sanitizeDirective called with', args);
// Return if not an object // Return if not an object
if (typeof args !== 'object') { if (typeof args !== 'object' || args == null) {
return; return;
} }
@@ -861,7 +866,8 @@ export const sanitizeDirective = (args: unknown): void => {
key.startsWith('__') || key.startsWith('__') ||
key.includes('proto') || key.includes('proto') ||
key.includes('constr') || key.includes('constr') ||
!configKeys.has(key) !configKeys.has(key) ||
args[key] == null
) { ) {
log.debug('sanitize deleting key: ', key); log.debug('sanitize deleting key: ', key);
delete args[key]; delete args[key];