Merge branch 'release/9.4.0' into timeline

This commit is contained in:
ashishj
2023-01-19 19:20:29 +01:00
64 changed files with 3404 additions and 374 deletions

View File

@@ -56,6 +56,7 @@
"d3": "^7.0.0",
"dagre-d3-es": "7.0.6",
"dompurify": "2.4.3",
"elkjs": "^0.8.2",
"khroma": "^2.0.0",
"lodash-es": "^4.17.21",
"moment-mini": "^2.24.0",
@@ -89,6 +90,7 @@
"path-browserify": "^1.0.1",
"prettier": "^2.7.1",
"remark": "^14.0.2",
"remark-gfm": "^3.0.1",
"rimraf": "^3.0.2",
"start-server-and-test": "^1.14.0",
"typedoc": "^0.23.18",

View File

@@ -3,7 +3,8 @@ import { log } from './logger';
import { getDiagram, registerDiagram } from './diagram-api/diagramAPI';
import { detectType, getDiagramLoader } from './diagram-api/detectType';
import { extractFrontMatter } from './diagram-api/frontmatter';
import { isDetailedError, type DetailedError } from './utils';
import { isDetailedError } from './utils';
import type { DetailedError } from './utils';
export type ParseErrorFunction = (err: string | DetailedError, hash?: any) => void;

View File

@@ -107,6 +107,7 @@ export const insertEdgeLabel = (elem, edge) => {
terminalLabels[edge.id].endRight = endEdgeLabelRight;
setTerminalWidth(fo, edge.endLabelRight);
}
return labelElement;
};
/**

View File

@@ -142,7 +142,7 @@ const point = (elem, type) => {
.append('marker')
.attr('id', type + '-pointEnd')
.attr('class', 'marker ' + type)
.attr('viewBox', '0 0 10 10')
.attr('viewBox', '0 0 12 20')
.attr('refX', 10)
.attr('refY', 5)
.attr('markerUnits', 'userSpaceOnUse')

View File

@@ -1064,6 +1064,7 @@ export const insertNode = (elem, node, dir) => {
if (node.haveCallback) {
nodeElems[node.id].attr('class', nodeElems[node.id].attr('class') + ' clickable');
}
return newEl;
};
export const setNodeElem = (elem, node) => {
nodeElems[node.id] = elem;

View File

@@ -247,12 +247,13 @@ const config: Partial<MermaidConfig> = {
/**
* | Parameter | Description | Type | Required | Values |
* | --------------- | ----------- | ------- | -------- | ----------------------- |
* | defaultRenderer | See notes | boolean | 4 | dagre-d3, dagre-wrapper |
* | defaultRenderer | See notes | boolean | 4 | dagre-d3, dagre-wrapper, elk |
*
* **Notes:**
*
* Decides which rendering engine that is to be used for the rendering. Legal values are:
* dagre-d3 dagre-wrapper - wrapper for dagre implemented in mermaid
* dagre-d3 dagre-wrapper - wrapper for dagre implemented in mermaid, elk for layout using
* elkjs
*
* Default value: 'dagre-wrapper'
*/

View File

@@ -1,6 +1,11 @@
import { MermaidConfig } from '../config.type';
import { log } from '../logger';
import { DetectorRecord, DiagramDetector, DiagramLoader } from './types';
import type {
DetectorRecord,
DiagramDetector,
DiagramLoader,
ExternalDiagramDefinition,
} from './types';
import { frontMatterRegex } from './frontmatter';
const directive = /%{2}{\s*(?:(\w+)\s*:|(\w+))\s*(?:(\w+)|((?:(?!}%{2}).|\r?\n)*))?\s*(?:}%{2})?/gi;
@@ -42,6 +47,12 @@ export const detectType = function (text: string, config?: MermaidConfig): strin
throw new Error(`No diagram type detected for text: ${text}`);
};
export const registerLazyLoadedDiagrams = (...diagrams: ExternalDiagramDefinition[]) => {
for (const { id, detector, loader } of diagrams) {
addDetector(id, detector, loader);
}
};
export const addDetector = (key: string, detector: DiagramDetector, loader?: DiagramLoader) => {
if (detectors[key]) {
throw new Error(`Detector with key ${key} already exists`);

View File

@@ -94,6 +94,9 @@ import { setConfig } from '../config';
import errorRenderer from '../diagrams/error/errorRenderer';
import errorStyles from '../diagrams/error/styles';
import flowchartElk from '../diagrams/flowchart/elk/detector';
import { registerLazyLoadedDiagrams } from './detectType';
let hasLoadedDiagrams = false;
export const addDiagrams = () => {
if (hasLoadedDiagrams) {
@@ -102,6 +105,8 @@ export const addDiagrams = () => {
// This is added here to avoid race-conditions.
// We could optimize the loading logic somehow.
hasLoadedDiagrams = true;
registerLazyLoadedDiagrams(flowchartElk);
registerDiagram(
'error',
// Special diagram with error messages but setup as a regular diagram

View File

@@ -0,0 +1,55 @@
import plugin from './detector';
import { describe, it } from 'vitest';
const { detector } = plugin;
describe('flowchart-elk detector', () => {
it('should fail for dagre-d3', () => {
expect(
detector('flowchart', {
flowchart: {
defaultRenderer: 'dagre-d3',
},
})
).toBe(false);
});
it('should fail for dagre-wrapper', () => {
expect(
detector('flowchart', {
flowchart: {
defaultRenderer: 'dagre-wrapper',
},
})
).toBe(false);
});
it('should succeed for elk', () => {
expect(
detector('flowchart', {
flowchart: {
defaultRenderer: 'elk',
},
})
).toBe(true);
expect(
detector('graph', {
flowchart: {
defaultRenderer: 'elk',
},
})
).toBe(true);
});
it('should detect flowchart-elk', () => {
expect(detector('flowchart-elk')).toBe(true);
});
it('should not detect class with defaultRenderer set to elk', () => {
expect(
detector('class', {
flowchart: {
defaultRenderer: 'elk',
},
})
).toBe(false);
});
});

View File

@@ -0,0 +1,29 @@
import type { MermaidConfig } from '../../../config.type';
import type { ExternalDiagramDefinition, DiagramDetector } from '../../../diagram-api/types';
const id = 'flowchart-elk';
const detector: DiagramDetector = (txt: string, config?: MermaidConfig): boolean => {
if (
// If diagram explicitly states flowchart-elk
txt.match(/^\s*flowchart-elk/) ||
// If a flowchart/graph diagram has their default renderer set to elk
(txt.match(/^\s*flowchart|graph/) && config?.flowchart?.defaultRenderer === 'elk')
) {
return true;
}
return false;
};
const loader = async () => {
const { diagram } = await import('./diagram-definition');
return { id, diagram };
};
const plugin: ExternalDiagramDefinition = {
id,
detector,
loader,
};
export default plugin;

View File

@@ -0,0 +1,13 @@
// @ts-ignore: JISON typing missing
import parser from '../parser/flow';
import * as db from '../flowDb';
import renderer from './flowRenderer-elk';
import styles from './styles';
export const diagram = {
db,
renderer,
parser,
styles,
};

View File

@@ -0,0 +1,813 @@
import { select, line, curveLinear } from 'd3';
import { insertNode } from '../../../dagre-wrapper/nodes.js';
import insertMarkers from '../../../dagre-wrapper/markers.js';
import { insertEdgeLabel } from '../../../dagre-wrapper/edges.js';
import { findCommonAncestor } from './render-utils';
import { addHtmlLabel } from 'dagre-d3-es/src/dagre-js/label/add-html-label.js';
import { getConfig } from '../../../config';
import { log } from '../../../logger';
import { setupGraphViewbox } from '../../../setupGraphViewbox';
import common, { evaluate } from '../../common/common';
import { interpolateToCurve, getStylesFromArray } from '../../../utils';
import ELK from 'elkjs/lib/elk.bundled.js';
const elk = new ELK();
const conf = {};
export const setConf = function (cnf) {
const keys = Object.keys(cnf);
for (const key of keys) {
conf[key] = cnf[key];
}
};
let nodeDb = {};
// /**
// * Function that adds the vertices found during parsing to the graph to be rendered.
// *
// * @param vert Object containing the vertices.
// * @param g The graph that is to be drawn.
// * @param svgId
// * @param root
// * @param doc
// * @param diagObj
// */
export const addVertices = function (vert, svgId, root, doc, diagObj, parentLookupDb, graph) {
const svg = root.select(`[id="${svgId}"]`);
const nodes = svg.insert('g').attr('class', 'nodes');
const keys = Object.keys(vert);
// Iterate through each item in the vertex object (containing all the vertices found) in the graph definition
keys.forEach(function (id) {
const vertex = vert[id];
/**
* Variable for storing the classes for the vertex
*
* @type {string}
*/
let classStr = 'default';
if (vertex.classes.length > 0) {
classStr = vertex.classes.join(' ');
}
const styles = getStylesFromArray(vertex.styles);
// Use vertex id as text in the box if no text is provided by the graph definition
let vertexText = vertex.text !== undefined ? vertex.text : vertex.id;
// We create a SVG label, either by delegating to addHtmlLabel or manually
let vertexNode;
const labelData = { width: 0, height: 0 };
if (evaluate(getConfig().flowchart.htmlLabels)) {
// TODO: addHtmlLabel accepts a labelStyle. Do we possibly have that?
const node = {
label: vertexText.replace(
/fa[blrs]?:fa-[\w-]+/g,
(s) => `<i class='${s.replace(':', ' ')}'></i>`
),
};
vertexNode = addHtmlLabel(svg, node).node();
const bbox = vertexNode.getBBox();
labelData.width = bbox.width;
labelData.height = bbox.height;
labelData.labelNode = vertexNode;
vertexNode.parentNode.removeChild(vertexNode);
} else {
const svgLabel = doc.createElementNS('http://www.w3.org/2000/svg', 'text');
svgLabel.setAttribute('style', styles.labelStyle.replace('color:', 'fill:'));
const rows = vertexText.split(common.lineBreakRegex);
for (const row of rows) {
const tspan = doc.createElementNS('http://www.w3.org/2000/svg', 'tspan');
tspan.setAttributeNS('http://www.w3.org/XML/1998/namespace', 'xml:space', 'preserve');
tspan.setAttribute('dy', '1em');
tspan.setAttribute('x', '1');
tspan.textContent = row;
svgLabel.appendChild(tspan);
}
vertexNode = svgLabel;
const bbox = vertexNode.getBBox();
labelData.width = bbox.width;
labelData.height = bbox.height;
labelData.labelNode = vertexNode;
}
let radious = 0;
let _shape = '';
// Set the shape based parameters
switch (vertex.type) {
case 'round':
radious = 5;
_shape = 'rect';
break;
case 'square':
_shape = 'rect';
break;
case 'diamond':
_shape = 'question';
break;
case 'hexagon':
_shape = 'hexagon';
break;
case 'odd':
_shape = 'rect_left_inv_arrow';
break;
case 'lean_right':
_shape = 'lean_right';
break;
case 'lean_left':
_shape = 'lean_left';
break;
case 'trapezoid':
_shape = 'trapezoid';
break;
case 'inv_trapezoid':
_shape = 'inv_trapezoid';
break;
case 'odd_right':
_shape = 'rect_left_inv_arrow';
break;
case 'circle':
_shape = 'circle';
break;
case 'ellipse':
_shape = 'ellipse';
break;
case 'stadium':
_shape = 'stadium';
break;
case 'subroutine':
_shape = 'subroutine';
break;
case 'cylinder':
_shape = 'cylinder';
break;
case 'group':
_shape = 'rect';
break;
case 'doublecircle':
_shape = 'doublecircle';
break;
default:
_shape = 'rect';
}
// // Add the node
const node = {
labelStyle: styles.labelStyle,
shape: _shape,
labelText: vertexText,
rx: radious,
ry: radious,
class: classStr,
style: styles.style,
id: vertex.id,
link: vertex.link,
linkTarget: vertex.linkTarget,
tooltip: diagObj.db.getTooltip(vertex.id) || '',
domId: diagObj.db.lookUpDomId(vertex.id),
haveCallback: vertex.haveCallback,
width: vertex.type === 'group' ? 500 : undefined,
dir: vertex.dir,
type: vertex.type,
props: vertex.props,
padding: getConfig().flowchart.padding,
};
let boundingBox;
let nodeEl;
if (node.type !== 'group') {
nodeEl = insertNode(nodes, node, vertex.dir);
boundingBox = nodeEl.node().getBBox();
}
const data = {
id: vertex.id,
// labelStyle: styles.labelStyle,
// shape: _shape,
labelText: vertexText,
labelData,
// labels: [{ text: vertexText }],
// rx: radius,
// ry: radius,
// class: classStr,
// style: styles.style,
// link: vertex.link,
// linkTarget: vertex.linkTarget,
// tooltip: diagObj.db.getTooltip(vertex.id) || '',
domId: diagObj.db.lookUpDomId(vertex.id),
// haveCallback: vertex.haveCallback,
width: boundingBox?.width,
height: boundingBox?.height,
// dir: vertex.dir,
type: vertex.type,
// props: vertex.props,
// padding: getConfig().flowchart.padding,
// boundingBox,
el: nodeEl,
parent: parentLookupDb.parentById[vertex.id],
};
// if (!Object.keys(parentLookupDb.childrenById).includes(vertex.id)) {
// graph.children.push({
// ...data,
// });
// }
nodeDb[node.id] = data;
// log.trace('setNode', {
// labelStyle: styles.labelStyle,
// shape: _shape,
// labelText: vertexText,
// rx: radius,
// ry: radius,
// class: classStr,
// style: styles.style,
// id: vertex.id,
// domId: diagObj.db.lookUpDomId(vertex.id),
// width: vertex.type === 'group' ? 500 : undefined,
// type: vertex.type,
// dir: vertex.dir,
// props: vertex.props,
// padding: getConfig().flowchart.padding,
// parent: parentLookupDb.parentById[vertex.id],
// });
});
return graph;
};
/**
* Add edges to graph based on parsed graph definition
*
* @param {object} edges The edges to add to the graph
* @param {object} g The graph object
* @param cy
* @param diagObj
* @param graph
* @param svg
*/
export const addEdges = function (edges, diagObj, graph, svg) {
// log.info('abc78 edges = ', edges);
const labelsEl = svg.insert('g').attr('class', 'edgeLabels');
let linkIdCnt = {};
let defaultStyle;
let defaultLabelStyle;
if (edges.defaultStyle !== undefined) {
const defaultStyles = getStylesFromArray(edges.defaultStyle);
defaultStyle = defaultStyles.style;
defaultLabelStyle = defaultStyles.labelStyle;
}
edges.forEach(function (edge) {
// Identify Link
var linkIdBase = 'L-' + edge.start + '-' + edge.end;
// count the links from+to the same node to give unique id
if (linkIdCnt[linkIdBase] === undefined) {
linkIdCnt[linkIdBase] = 0;
log.info('abc78 new entry', linkIdBase, linkIdCnt[linkIdBase]);
} else {
linkIdCnt[linkIdBase]++;
log.info('abc78 new entry', linkIdBase, linkIdCnt[linkIdBase]);
}
let linkId = linkIdBase + '-' + linkIdCnt[linkIdBase];
log.info('abc78 new link id to be used is', linkIdBase, linkId, linkIdCnt[linkIdBase]);
var linkNameStart = 'LS-' + edge.start;
var linkNameEnd = 'LE-' + edge.end;
const edgeData = { style: '', labelStyle: '' };
edgeData.minlen = edge.length || 1;
//edgeData.id = 'id' + cnt;
// Set link type for rendering
if (edge.type === 'arrow_open') {
edgeData.arrowhead = 'none';
} else {
edgeData.arrowhead = 'normal';
}
// Check of arrow types, placed here in order not to break old rendering
edgeData.arrowTypeStart = 'arrow_open';
edgeData.arrowTypeEnd = 'arrow_open';
/* eslint-disable no-fallthrough */
switch (edge.type) {
case 'double_arrow_cross':
edgeData.arrowTypeStart = 'arrow_cross';
case 'arrow_cross':
edgeData.arrowTypeEnd = 'arrow_cross';
break;
case 'double_arrow_point':
edgeData.arrowTypeStart = 'arrow_point';
case 'arrow_point':
edgeData.arrowTypeEnd = 'arrow_point';
break;
case 'double_arrow_circle':
edgeData.arrowTypeStart = 'arrow_circle';
case 'arrow_circle':
edgeData.arrowTypeEnd = 'arrow_circle';
break;
}
let style = '';
let labelStyle = '';
switch (edge.stroke) {
case 'normal':
style = 'fill:none;';
if (defaultStyle !== undefined) {
style = defaultStyle;
}
if (defaultLabelStyle !== undefined) {
labelStyle = defaultLabelStyle;
}
edgeData.thickness = 'normal';
edgeData.pattern = 'solid';
break;
case 'dotted':
edgeData.thickness = 'normal';
edgeData.pattern = 'dotted';
edgeData.style = 'fill:none;stroke-width:2px;stroke-dasharray:3;';
break;
case 'thick':
edgeData.thickness = 'thick';
edgeData.pattern = 'solid';
edgeData.style = 'stroke-width: 3.5px;fill:none;';
break;
}
if (edge.style !== undefined) {
const styles = getStylesFromArray(edge.style);
style = styles.style;
labelStyle = styles.labelStyle;
}
edgeData.style = edgeData.style += style;
edgeData.labelStyle = edgeData.labelStyle += labelStyle;
if (edge.interpolate !== undefined) {
edgeData.curve = interpolateToCurve(edge.interpolate, curveLinear);
} else if (edges.defaultInterpolate !== undefined) {
edgeData.curve = interpolateToCurve(edges.defaultInterpolate, curveLinear);
} else {
edgeData.curve = interpolateToCurve(conf.curve, curveLinear);
}
if (edge.text === undefined) {
if (edge.style !== undefined) {
edgeData.arrowheadStyle = 'fill: #333';
}
} else {
edgeData.arrowheadStyle = 'fill: #333';
edgeData.labelpos = 'c';
}
edgeData.labelType = 'text';
edgeData.label = edge.text.replace(common.lineBreakRegex, '\n');
if (edge.style === undefined) {
edgeData.style = edgeData.style || 'stroke: #333; stroke-width: 1.5px;fill:none;';
}
edgeData.labelStyle = edgeData.labelStyle.replace('color:', 'fill:');
edgeData.id = linkId;
edgeData.classes = 'flowchart-link ' + linkNameStart + ' ' + linkNameEnd;
const labelEl = insertEdgeLabel(labelsEl, edgeData);
// console.log('labelEl', labelEl, edgeData.width);
// Add the edge to the graph
graph.edges.push({
id: 'e' + edge.start + edge.end,
sources: [edge.start],
targets: [edge.end],
labelEl: labelEl,
labels: [
{
width: edgeData.width,
// width: 80,
height: edgeData.height,
orgWidth: edgeData.width,
orgHeight: edgeData.height,
text: edgeData.label,
layoutOptions: {
'edgeLabels.inline': 'true',
'edgeLabels.placement': 'CENTER',
},
},
],
edgeData,
// targetPort: 'PortSide.NORTH',
// id: cnt,
});
});
return graph;
};
// TODO: break out and share with dagre wrapper. The current code in dagre wrapper also adds
// adds the line to the graph, but we don't need that here. This is why we cant use the dagre
// wrapper directly for this
/**
* Add the markers to the edge depending on the type of arrow is
* @param svgPath
* @param edgeData
* @param diagramType
* @param arrowMarkerAbsolute
*/
const addMarkersToEdge = function (svgPath, edgeData, diagramType, arrowMarkerAbsolute) {
let url = '';
// Check configuration for absolute path
if (arrowMarkerAbsolute) {
url =
window.location.protocol +
'//' +
window.location.host +
window.location.pathname +
window.location.search;
url = url.replace(/\(/g, '\\(');
url = url.replace(/\)/g, '\\)');
}
// look in edge data and decide which marker to use
switch (edgeData.arrowTypeStart) {
case 'arrow_cross':
svgPath.attr('marker-start', 'url(' + url + '#' + diagramType + '-crossStart' + ')');
break;
case 'arrow_point':
svgPath.attr('marker-start', 'url(' + url + '#' + diagramType + '-pointStart' + ')');
break;
case 'arrow_barb':
svgPath.attr('marker-start', 'url(' + url + '#' + diagramType + '-barbStart' + ')');
break;
case 'arrow_circle':
svgPath.attr('marker-start', 'url(' + url + '#' + diagramType + '-circleStart' + ')');
break;
case 'aggregation':
svgPath.attr('marker-start', 'url(' + url + '#' + diagramType + '-aggregationStart' + ')');
break;
case 'extension':
svgPath.attr('marker-start', 'url(' + url + '#' + diagramType + '-extensionStart' + ')');
break;
case 'composition':
svgPath.attr('marker-start', 'url(' + url + '#' + diagramType + '-compositionStart' + ')');
break;
case 'dependency':
svgPath.attr('marker-start', 'url(' + url + '#' + diagramType + '-dependencyStart' + ')');
break;
case 'lollipop':
svgPath.attr('marker-start', 'url(' + url + '#' + diagramType + '-lollipopStart' + ')');
break;
default:
}
switch (edgeData.arrowTypeEnd) {
case 'arrow_cross':
svgPath.attr('marker-end', 'url(' + url + '#' + diagramType + '-crossEnd' + ')');
break;
case 'arrow_point':
svgPath.attr('marker-end', 'url(' + url + '#' + diagramType + '-pointEnd' + ')');
break;
case 'arrow_barb':
svgPath.attr('marker-end', 'url(' + url + '#' + diagramType + '-barbEnd' + ')');
break;
case 'arrow_circle':
svgPath.attr('marker-end', 'url(' + url + '#' + diagramType + '-circleEnd' + ')');
break;
case 'aggregation':
svgPath.attr('marker-end', 'url(' + url + '#' + diagramType + '-aggregationEnd' + ')');
break;
case 'extension':
svgPath.attr('marker-end', 'url(' + url + '#' + diagramType + '-extensionEnd' + ')');
break;
case 'composition':
svgPath.attr('marker-end', 'url(' + url + '#' + diagramType + '-compositionEnd' + ')');
break;
case 'dependency':
svgPath.attr('marker-end', 'url(' + url + '#' + diagramType + '-dependencyEnd' + ')');
break;
case 'lollipop':
svgPath.attr('marker-end', 'url(' + url + '#' + diagramType + '-lollipopEnd' + ')');
break;
default:
}
};
/**
* Returns the all the styles from classDef statements in the graph definition.
*
* @param text
* @param diagObj
* @returns {object} ClassDef styles
*/
export const getClasses = function (text, diagObj) {
log.info('Extracting classes');
diagObj.db.clear('ver-2');
try {
// Parse the graph definition
diagObj.parse(text);
return diagObj.db.getClasses();
} catch (e) {
return {};
}
};
const addSubGraphs = function (db) {
const parentLookupDb = { parentById: {}, childrenById: {} };
const subgraphs = db.getSubGraphs();
log.info('Subgraphs - ', subgraphs);
subgraphs.forEach(function (subgraph) {
subgraph.nodes.forEach(function (node) {
parentLookupDb.parentById[node] = subgraph.id;
if (parentLookupDb.childrenById[subgraph.id] === undefined) {
parentLookupDb.childrenById[subgraph.id] = [];
}
parentLookupDb.childrenById[subgraph.id].push(node);
});
});
subgraphs.forEach(function (subgraph) {
const data = { id: subgraph.id };
if (parentLookupDb.parentById[subgraph.id] !== undefined) {
data.parent = parentLookupDb.parentById[subgraph.id];
}
});
return parentLookupDb;
};
const calcOffset = function (src, dest, parentLookupDb) {
const ancestor = findCommonAncestor(src, dest, parentLookupDb);
if (ancestor === undefined || ancestor === 'root') {
return { x: 0, y: 0 };
}
const ancestorOffset = nodeDb[ancestor].offset;
return { x: ancestorOffset.posX, y: ancestorOffset.posY };
};
const insertEdge = function (edgesEl, edge, edgeData, diagObj, parentLookupDb) {
const offset = calcOffset(edge.sources[0], edge.targets[0], parentLookupDb);
const src = edge.sections[0].startPoint;
const dest = edge.sections[0].endPoint;
const segments = edge.sections[0].bendPoints ? edge.sections[0].bendPoints : [];
const segPoints = segments.map((segment) => [segment.x + offset.x, segment.y + offset.y]);
const points = [
[src.x + offset.x, src.y + offset.y],
...segPoints,
[dest.x + offset.x, dest.y + offset.y],
];
// const curve = line().curve(curveBasis);
const curve = line().curve(curveLinear);
const edgePath = edgesEl
.insert('path')
.attr('d', curve(points))
.attr('class', 'path')
.attr('fill', 'none');
const edgeG = edgesEl.insert('g').attr('class', 'edgeLabel');
const edgeWithLabel = select(edgeG.node().appendChild(edge.labelEl));
const box = edgeWithLabel.node().firstChild.getBoundingClientRect();
edgeWithLabel.attr('width', box.width);
edgeWithLabel.attr('height', box.height);
edgeG.attr(
'transform',
`translate(${edge.labels[0].x + offset.x}, ${edge.labels[0].y + offset.y})`
);
addMarkersToEdge(edgePath, edgeData, diagObj.type, diagObj.arrowMarkerAbsolute);
};
/**
* Recursive function that iterates over an array of nodes and inserts the children of each node.
* It also recursively populates the inserts the children of the children and so on.
* @param {*} graph
* @param nodeArray
* @param parentLookupDb
*/
const insertChildren = (nodeArray, parentLookupDb) => {
nodeArray.forEach((node) => {
// Check if we have reached the end of the tree
if (!node.children) {
node.children = [];
}
// Check if the node has children
const childIds = parentLookupDb.childrenById[node.id];
// If the node has children, add them to the node
if (childIds) {
childIds.forEach((childId) => {
node.children.push(nodeDb[childId]);
});
}
// Recursive call
insertChildren(node.children, parentLookupDb);
});
};
/**
* Draws a flowchart in the tag with id: id based on the graph definition in text.
*
* @param text
* @param id
*/
export const draw = function (text, id, _version, diagObj) {
// Add temporary render element
diagObj.db.clear();
nodeDb = {};
diagObj.db.setGen('gen-2');
// Parse the graph definition
diagObj.parser.parse(text);
return new Promise(function (resolve, reject) {
const renderEl = select('body').append('div').attr('style', 'height:400px').attr('id', 'cy');
// .attr('style', 'display:none')
let graph = {
id: 'root',
layoutOptions: {
'elk.hierarchyHandling': 'INCLUDE_CHILDREN',
// 'elk.hierarchyHandling': 'SEPARATE_CHILDREN',
'org.eclipse.elk.padding': '[top=100, left=100, bottom=110, right=110]',
// 'org.eclipse.elk.layered.spacing.nodeNodeBetweenLayers': 120,
// 'elk.layered.spacing.nodeNodeBetweenLayers': '140',
'elk.layered.spacing.edgeNodeBetweenLayers': '30',
// 'elk.algorithm': 'layered',
'elk.direction': 'DOWN',
// 'elk.port.side': 'SOUTH',
// 'nodePlacement.strategy': 'SIMPLE',
// 'org.eclipse.elk.spacing.labelLabel': 120,
// 'org.eclipse.elk.graphviz.concentrate': true,
// 'org.eclipse.elk.spacing.nodeNode': 120,
// 'org.eclipse.elk.spacing.edgeEdge': 120,
// 'org.eclipse.elk.spacing.edgeNode': 120,
// 'org.eclipse.elk.spacing.nodeEdge': 120,
// 'org.eclipse.elk.spacing.componentComponent': 120,
},
children: [],
edges: [],
};
log.info('Drawing flowchart using v3 renderer');
// Set the direction,
// Fetch the default direction, use TD if none was found
let dir = diagObj.db.getDirection();
switch (dir) {
case 'BT':
graph.layoutOptions['elk.direction'] = 'UP';
break;
case 'TB':
graph.layoutOptions['elk.direction'] = 'DOWN';
break;
case 'LR':
graph.layoutOptions['elk.direction'] = 'RIGHT';
break;
case 'RL':
graph.layoutOptions['elk.direction'] = 'LEFT';
break;
}
const { securityLevel, flowchart: conf } = getConfig();
// Find the root dom node to ne used in rendering
// Handle root and document for when rendering in sandbox mode
let sandboxElement;
if (securityLevel === 'sandbox') {
sandboxElement = select('#i' + id);
}
const root =
securityLevel === 'sandbox'
? select(sandboxElement.nodes()[0].contentDocument.body)
: select('body');
const doc = securityLevel === 'sandbox' ? sandboxElement.nodes()[0].contentDocument : document;
const svg = root.select(`[id="${id}"]`);
// Define the supported markers for the diagram
const markers = ['point', 'circle', 'cross'];
// Add the marker definitions to the svg as marker tags
insertMarkers(svg, markers, diagObj.type, diagObj.arrowMarkerAbsolute);
// Fetch the vertices/nodes and edges/links from the parsed graph definition
const vert = diagObj.db.getVertices();
// Setup nodes from the subgraphs with type group, these will be used
// as nodes with children in the subgraph
let subG;
const subGraphs = diagObj.db.getSubGraphs();
log.info('Subgraphs - ', subGraphs);
for (let i = subGraphs.length - 1; i >= 0; i--) {
subG = subGraphs[i];
diagObj.db.addVertex(subG.id, subG.title, 'group', undefined, subG.classes, subG.dir);
}
// Add an element in the svg to be used to hold the subgraphs container
// elements
const subGraphsEl = svg.insert('g').attr('class', 'subgraphs');
// Create the lookup db for the subgraphs and their children to used when creating
// the tree structured graph
const parentLookupDb = addSubGraphs(diagObj.db);
// Add the nodes to the graph, this will entail creating the actual nodes
// in order to get the size of the node. You can't get the size of a node
// that is not in the dom so we need to add it to the dom, get the size
// we will position the nodes when we get the layout from elkjs
graph = addVertices(vert, id, root, doc, diagObj, parentLookupDb, graph);
// Time for the edges, we start with adding an element in the node to hold the edges
const edgesEl = svg.insert('g').attr('class', 'edges edgePath');
// Fetch the edges form the parsed graph definition
const edges = diagObj.db.getEdges();
// Add the edges to the graph, this will entail creating the actual edges
graph = addEdges(edges, diagObj, graph, svg);
// Iterate through all nodes and add the top level nodes to the graph
const nodes = Object.keys(nodeDb);
nodes.forEach((nodeId) => {
const node = nodeDb[nodeId];
if (!node.parent) {
graph.children.push(node);
}
// node.nodePadding = [120, 50, 50, 50];
// node['org.eclipse.elk.spacing.nodeNode'] = 120;
// Subgraph
if (parentLookupDb.childrenById[nodeId] !== undefined) {
node.labels = [
{
text: node.labelText,
layoutOptions: {
'nodeLabels.placement': '[H_CENTER, V_TOP, INSIDE]',
},
width: node.labelData.width,
height: node.labelData.height,
},
];
delete node.x;
delete node.y;
delete node.width;
delete node.height;
}
});
insertChildren(graph.children, parentLookupDb);
elk.layout(graph).then(function (g) {
drawNodes(0, 0, g.children, svg, subGraphsEl, diagObj, 0);
g.edges.map((edge, id) => {
insertEdge(edgesEl, edge, edge.edgeData, diagObj, parentLookupDb);
});
setupGraphViewbox({}, svg, conf.diagramPadding, conf.useMaxWidth);
resolve();
});
// Remove element after layout
renderEl.remove();
});
};
const drawNodes = (relX, relY, nodeArray, svg, subgraphsEl, diagObj, depth) => {
nodeArray.forEach(function (node) {
if (node) {
nodeDb[node.id].offset = {
posX: node.x + relX,
posY: node.y + relY,
x: relX,
y: relY,
depth,
width: node.width,
height: node.height,
};
if (node.type === 'group') {
const subgraphEl = subgraphsEl.insert('g').attr('class', 'subgraph');
subgraphEl
.insert('rect')
.attr('class', 'subgraph subgraph-lvl-' + (depth % 5) + ' node')
.attr('x', node.x + relX)
.attr('y', node.y + relY)
.attr('width', node.width)
.attr('height', node.height);
const label = subgraphEl.insert('g').attr('class', 'label');
label.attr(
'transform',
`translate(${node.labels[0].x + relX + node.x}, ${node.labels[0].y + relY + node.y})`
);
label.node().appendChild(node.labelData.labelNode);
log.info('Id (UGH)= ', node.type, node.labels);
} else {
log.info('Id (UGH)= ', node.id);
node.el.attr(
'transform',
`translate(${node.x + relX + node.width / 2}, ${node.y + relY + node.height / 2})`
);
}
}
});
nodeArray.forEach(function (node) {
if (node && node.type === 'group') {
drawNodes(relX + node.x, relY + node.y, node.children, svg, subgraphsEl, diagObj, depth + 1);
}
});
};
export default {
getClasses,
draw,
};

View File

@@ -0,0 +1,40 @@
import { findCommonAncestor, TreeData } from './render-utils';
describe('when rendering a flowchart using elk ', () => {
let lookupDb: TreeData;
beforeEach(() => {
lookupDb = {
parentById: {
B4: 'inner',
B5: 'inner',
C4: 'inner2',
C5: 'inner2',
B2: 'Ugge',
B3: 'Ugge',
inner: 'Ugge',
inner2: 'Ugge',
B6: 'outer',
},
childrenById: {
inner: ['B4', 'B5'],
inner2: ['C4', 'C5'],
Ugge: ['B2', 'B3', 'inner', 'inner2'],
outer: ['B6'],
},
};
});
it('to find parent of siblings in a subgraph', () => {
expect(findCommonAncestor('B4', 'B5', lookupDb)).toBe('inner');
});
it('to find an uncle', () => {
expect(findCommonAncestor('B4', 'B2', lookupDb)).toBe('Ugge');
});
it('to find a cousin', () => {
expect(findCommonAncestor('B4', 'C4', lookupDb)).toBe('Ugge');
});
it('to find a grandparent', () => {
expect(findCommonAncestor('B4', 'B6', lookupDb)).toBe('root');
});
it('to find ancestor of siblings in the root', () => {
expect(findCommonAncestor('B1', 'outer', lookupDb)).toBe('root');
});
});

View File

@@ -0,0 +1,25 @@
export interface TreeData {
parentById: Record<string, string>;
childrenById: Record<string, string[]>;
}
export const findCommonAncestor = (id1: string, id2: string, treeData: TreeData) => {
const { parentById } = treeData;
const visited = new Set();
let currentId = id1;
while (currentId) {
visited.add(currentId);
if (currentId === id2) {
return currentId;
}
currentId = parentById[currentId];
}
currentId = id2;
while (currentId) {
if (visited.has(currentId)) {
return currentId;
}
currentId = parentById[currentId];
}
return 'root';
};

View File

@@ -0,0 +1,138 @@
/** Returns the styles given options */
export interface FlowChartStyleOptions {
arrowheadColor: string;
border2: string;
clusterBkg: string;
clusterBorder: string;
edgeLabelBackground: string;
fontFamily: string;
lineColor: string;
mainBkg: string;
nodeBorder: string;
nodeTextColor: string;
tertiaryColor: string;
textColor: string;
titleColor: string;
[key: string]: string;
}
const genSections = (options: FlowChartStyleOptions) => {
let sections = '';
for (let i = 0; i < 5; i++) {
sections += `
.subgraph-lvl-${i} {
fill: ${options[`surface${i}`]};
stroke: ${options[`surfacePeer${i}`]};
}
`;
}
return sections;
};
const getStyles = (options: FlowChartStyleOptions) =>
`.label {
font-family: ${options.fontFamily};
color: ${options.nodeTextColor || options.textColor};
}
.cluster-label text {
fill: ${options.titleColor};
}
.cluster-label span {
color: ${options.titleColor};
}
.label text,span {
fill: ${options.nodeTextColor || options.textColor};
color: ${options.nodeTextColor || options.textColor};
}
.node rect,
.node circle,
.node ellipse,
.node polygon,
.node path {
fill: ${options.mainBkg};
stroke: ${options.nodeBorder};
stroke-width: 1px;
}
.node .label {
text-align: center;
}
.node.clickable {
cursor: pointer;
}
.arrowheadPath {
fill: ${options.arrowheadColor};
}
.edgePath .path {
stroke: ${options.lineColor};
stroke-width: 2.0px;
}
.flowchart-link {
stroke: ${options.lineColor};
fill: none;
}
.edgeLabel {
background-color: ${options.edgeLabelBackground};
rect {
opacity: 0.5;
background-color: ${options.edgeLabelBackground};
fill: ${options.edgeLabelBackground};
}
text-align: center;
}
.cluster rect {
fill: ${options.clusterBkg};
stroke: ${options.clusterBorder};
stroke-width: 1px;
}
.cluster text {
fill: ${options.titleColor};
}
.cluster span {
color: ${options.titleColor};
}
/* .cluster div {
color: ${options.titleColor};
} */
div.mermaidTooltip {
position: absolute;
text-align: center;
max-width: 200px;
padding: 2px;
font-family: ${options.fontFamily};
font-size: 12px;
background: ${options.tertiaryColor};
border: 1px solid ${options.border2};
border-radius: 2px;
pointer-events: none;
z-index: 100;
}
.flowchartTitleText {
text-anchor: middle;
font-size: 18px;
fill: ${options.textColor};
}
.subgraph {
stroke-width:2;
rx:3;
}
// .subgraph-lvl-1 {
// fill:#ccc;
// // stroke:black;
// }
${genSections(options)}
`;
export default getStyles;

View File

@@ -439,7 +439,7 @@ export const clear = function (ver = 'gen-1') {
commonClear();
};
export const setGen = (ver) => {
version = ver || 'gen-1';
version = ver || 'gen-2';
};
/** @returns {string} */
export const defaultStyle = function () {
@@ -684,7 +684,7 @@ const destructEndLink = (_str) => {
return { type, stroke, length };
};
const destructLink = (_str, _startStr) => {
export const destructLink = (_str, _startStr) => {
const info = destructEndLink(_str);
let startInfo;
if (_startStr) {
@@ -744,6 +744,9 @@ const makeUniq = (sg, allSubgraphs) => {
return { nodes: res };
};
export const lex = {
firstGraph,
};
export default {
parseDirective,
defaultConfig: () => configApi.defaultConfig.flowchart,
@@ -776,9 +779,7 @@ export default {
indexNodes,
getSubGraphs,
destructLink,
lex: {
firstGraph,
},
lex,
exists,
makeUniq,
setDiagramTitle,

View File

@@ -1,8 +1,15 @@
import type { DiagramDetector } from '../../diagram-api/types';
export const flowDetectorV2: DiagramDetector = (txt, config) => {
if (config?.flowchart?.defaultRenderer === 'dagre-d3') {
return false;
}
if (config?.flowchart?.defaultRenderer === 'elk') {
return false;
}
// If we have configured to use dagre-wrapper then we should return true in this function for graph code thus making it use the new flowchart diagram
if (config?.flowchart?.defaultRenderer === 'dagre-wrapper' && txt.match(/^\s*graph/) !== null) {
if (txt.match(/^\s*graph/) !== null) {
return true;
}
return txt.match(/^\s*flowchart/) !== null;

View File

@@ -6,5 +6,8 @@ export const flowDetector: DiagramDetector = (txt, config) => {
if (config?.flowchart?.defaultRenderer === 'dagre-wrapper') {
return false;
}
if (config?.flowchart?.defaultRenderer === 'elk') {
return false;
}
return txt.match(/^\s*graph/) !== null;
};

View File

@@ -408,7 +408,7 @@ export const draw = function (text, id, _version, diagObj) {
const edges = diagObj.db.getEdges();
log.info(edges);
log.info('Edges', edges);
let i = 0;
for (i = subGraphs.length - 1; i >= 0; i--) {
// for (let i = 0; i < subGraphs.length; i++) {

View File

@@ -82,6 +82,7 @@ that id.
<click>[\s\n] this.popState();
<click>[^\s\n]* return 'CLICK';
"flowchart-elk" {if(yy.lex.firstGraph()){this.begin("dir");} return 'GRAPH';}
"graph" {if(yy.lex.firstGraph()){this.begin("dir");} return 'GRAPH';}
"flowchart" {if(yy.lex.firstGraph()){this.begin("dir");} return 'GRAPH';}
"subgraph" return 'subgraph';

View File

@@ -48,9 +48,19 @@ vi.mock('d3', () => {
return new NewD3();
},
// TODO: In d3 these are CurveFactory types, not strings
curveBasis: 'basis',
curveBasisClosed: 'basisClosed',
curveBasisOpen: 'basisOpen',
curveLinear: 'linear',
curveCardinal: 'cardinal',
curveLinearClosed: 'linearClosed',
curveMonotoneX: 'monotoneX',
curveMonotoneY: 'monotoneY',
curveNatural: 'natural',
curveStep: 'step',
curveStepAfter: 'stepAfter',
curveStepBefore: 'stepBefore',
};
});
// -------------------------------

View File

@@ -1,5 +1,6 @@
import common from '../common/common';
import { addFunction } from '../../interactionDb';
import { parseFontSize } from '../../utils';
import { sanitizeUrl } from '@braintree/sanitize-url';
export const drawRect = function (elem, rectData) {
@@ -156,6 +157,8 @@ export const drawText = function (elem, textData) {
textHeight = 0;
const lines = textData.text.split(common.lineBreakRegex);
const [_textFontSize, _textFontSizePx] = parseFontSize(textData.fontSize);
let textElems = [];
let dy = 0;
let yfunc = () => textData.y;
@@ -218,9 +221,9 @@ export const drawText = function (elem, textData) {
if (
textData.textMargin !== undefined &&
textData.textMargin === 0 &&
textData.fontSize !== undefined
_textFontSize !== undefined
) {
dy = i * textData.fontSize;
dy = i * _textFontSize;
}
const textElem = elem.append('text');
@@ -235,8 +238,8 @@ export const drawText = function (elem, textData) {
if (textData.fontFamily !== undefined) {
textElem.style('font-family', textData.fontFamily);
}
if (textData.fontSize !== undefined) {
textElem.style('font-size', textData.fontSize);
if (_textFontSizePx !== undefined) {
textElem.style('font-size', _textFontSizePx);
}
if (textData.fontWeight !== undefined) {
textElem.style('font-weight', textData.fontWeight);
@@ -840,8 +843,7 @@ const _drawTextCandidateFunc = (function () {
function byTspan(content, g, x, y, width, height, textAttrs, conf) {
const { actorFontSize, actorFontFamily, actorFontWeight } = conf;
let _actorFontSize =
actorFontSize && actorFontSize.replace ? actorFontSize.replace('px', '') : actorFontSize;
const [_actorFontSize, _actorFontSizePx] = parseFontSize(actorFontSize);
const lines = content.split(common.lineBreakRegex);
for (let i = 0; i < lines.length; i++) {
@@ -851,7 +853,7 @@ const _drawTextCandidateFunc = (function () {
.attr('x', x + width / 2)
.attr('y', y)
.style('text-anchor', 'middle')
.style('font-size', actorFontSize)
.style('font-size', _actorFontSizePx)
.style('font-weight', actorFontWeight)
.style('font-family', actorFontFamily);
text

View File

@@ -125,6 +125,30 @@ describe('svgDraw', function () {
expect(text3.attr).toHaveBeenCalledWith('y', 10);
expect(text3.text).toHaveBeenCalledWith('fine lines');
});
it('should work with numeral font sizes', function () {
const svg = MockD3('svg');
svgDraw.drawText(svg, {
x: 10,
y: 10,
dy: '1em',
text: 'One fine text message',
class: 'noteText',
fontFamily: 'courier',
fontSize: 10,
fontWeight: '500',
});
expect(svg.__children.length).toBe(1);
const text = svg.__children[0];
expect(text.__name).toBe('text');
expect(text.attr).toHaveBeenCalledWith('x', 10);
expect(text.attr).toHaveBeenCalledWith('y', 10);
expect(text.attr).toHaveBeenCalledWith('dy', '1em');
expect(text.attr).toHaveBeenCalledWith('class', 'noteText');
expect(text.text).toHaveBeenCalledWith('One fine text message');
expect(text.style).toHaveBeenCalledWith('font-family', 'courier');
expect(text.style).toHaveBeenCalledWith('font-size', '10px');
expect(text.style).toHaveBeenCalledWith('font-weight', '500');
});
});
describe('drawBackgroundRect', function () {
it('should append a rect before the previous element within a given bound', function () {

View File

@@ -37,25 +37,41 @@ import { JSDOM } from 'jsdom';
import type { Code, Root } from 'mdast';
import { posix, dirname, relative, join } from 'path';
import prettier from 'prettier';
import { remark } from 'remark';
import { remark as remarkBuilder } from 'remark';
import remarkGfm from 'remark-gfm';
import chokidar from 'chokidar';
import mm from 'micromatch';
// @ts-ignore No typescript declaration file
import flatmap from 'unist-util-flatmap';
// support tables and other GitHub Flavored Markdown syntax in markdown
const remark = remarkBuilder().use(remarkGfm);
const MERMAID_MAJOR_VERSION = (
JSON.parse(readFileSync('../mermaid/package.json', 'utf8')).version as string
).split('.')[0];
const CDN_URL = 'https://cdn.jsdelivr.net/npm'; // 'https://unpkg.com';
const MERMAID_KEYWORD = 'mermaid';
const MERMAID_CODE_ONLY_KEYWORD = 'mermaid-example';
// These keywords will produce both a mermaid diagram and a code block with the diagram source
const MERMAID_EXAMPLE_KEYWORDS = [MERMAID_KEYWORD, 'mmd', MERMAID_CODE_ONLY_KEYWORD]; // 'mmd' is an old keyword that used to be used
// This keyword will only produce a mermaid diagram
const MERMAID_DIAGRAM_ONLY = 'mermaid-nocode';
// These will be transformed into block quotes
const BLOCK_QUOTE_KEYWORDS = ['note', 'tip', 'warning', 'danger'];
// options for running the main command
const verifyOnly: boolean = process.argv.includes('--verify');
const git: boolean = process.argv.includes('--git');
const watch: boolean = process.argv.includes('--watch');
const vitepress: boolean = process.argv.includes('--vitepress');
const noHeader: boolean = process.argv.includes('--noHeader') || vitepress;
// These paths are from the root of the mono-repo, not from the
// mermaid sub-directory
// These paths are from the root of the mono-repo, not from the mermaid subdirectory
const SOURCE_DOCS_DIR = 'src/docs';
const FINAL_DOCS_DIR = vitepress ? 'src/vitepress' : '../../docs';
@@ -153,7 +169,11 @@ const blockIcons: Record<string, string> = {
const capitalize = (word: string) => word[0].toUpperCase() + word.slice(1);
const transformToBlockQuote = (content: string, type: string, customTitle?: string | null) => {
export const transformToBlockQuote = (
content: string,
type: string,
customTitle?: string | null
) => {
if (vitepress) {
const vitepressType = type === 'note' ? 'info' : type;
return `::: ${vitepressType} ${customTitle || ''}\n${content}\n:::`;
@@ -180,41 +200,67 @@ const transformIncludeStatements = (file: string, text: string): string => {
}
});
};
/**
* Transform code blocks in a Markdown file.
* Use remark.parse() to turn the given content (a String) into an AST.
* For any AST node that is a code block: transform it as needed:
* - blocks marked as MERMAID_DIAGRAM_ONLY will be set to a 'mermaid' code block so it will be rendered as (only) a diagram
* - blocks marked as MERMAID_EXAMPLE_KEYWORDS will be copied and the original node will be a code only block and the copy with be rendered as the diagram
* - blocks marked as BLOCK_QUOTE_KEYWORDS will be transformed into block quotes
*
* Convert the AST back to a string and return it.
*
* @param content - the contents of a Markdown file
* @returns the contents with transformed code blocks
*/
export const transformBlocks = (content: string): string => {
const ast: Root = remark.parse(content);
const astWithTransformedBlocks = flatmap(ast, (node: Code) => {
if (node.type !== 'code' || !node.lang) {
return [node]; // no transformation if this is not a code block
}
if (node.lang === MERMAID_DIAGRAM_ONLY) {
// Set the lang to 'mermaid' so it will be rendered as a diagram.
node.lang = MERMAID_KEYWORD;
return [node];
} else if (MERMAID_EXAMPLE_KEYWORDS.includes(node.lang)) {
// Return 2 nodes:
// 1. the original node with the language now set to 'mermaid-example' (will be rendered as code), and
// 2. a copy of the original node with the language set to 'mermaid' (will be rendered as a diagram)
node.lang = MERMAID_CODE_ONLY_KEYWORD;
return [node, Object.assign({}, node, { lang: MERMAID_KEYWORD })];
}
// Transform these blocks into block quotes.
if (BLOCK_QUOTE_KEYWORDS.includes(node.lang)) {
return [remark.parse(transformToBlockQuote(node.value, node.lang, node.meta))];
}
return [node]; // default is to do nothing to the node
});
return remark.stringify(astWithTransformedBlocks);
};
/**
* Transform a markdown file and write the transformed file to the directory for published
* documentation
*
* 1. Add a `mermaid-example` block before every `mermaid` or `mmd` block On the docsify site (one
* place where the documentation is published), this will show the code used for the mermaid
* diagram
* 2. Add the text that says the file is automatically generated
* 3. Use prettier to format the file Verify that the file has been changed and write out the changes
* 1. include any included files (copy and insert the source)
* 2. Add a `mermaid-example` block before every `mermaid` or `mmd` block On the main documentation site (one
* place where the documentation is published), this will show the code for the mermaid diagram
* 3. Transform blocks to block quotes as needed
* 4. Add the text that says the file is automatically generated
* 5. Use prettier to format the file.
* 6. Verify that the file has been changed and write out the changes
*
* @param file {string} name of the file that will be verified
*/
const transformMarkdown = (file: string) => {
const doc = injectPlaceholders(transformIncludeStatements(file, readSyncedUTF8file(file)));
const ast: Root = remark.parse(doc);
const out = flatmap(ast, (c: Code) => {
if (c.type !== 'code' || !c.lang) {
return [c];
}
// Convert mermaid code blocks to mermaid-example blocks
if (['mermaid', 'mmd', 'mermaid-example'].includes(c.lang)) {
c.lang = 'mermaid-example';
return [c, Object.assign({}, c, { lang: 'mermaid' })];
}
// Transform codeblocks into block quotes.
if (['note', 'tip', 'warning', 'danger'].includes(c.lang)) {
return [remark.parse(transformToBlockQuote(c.value, c.lang, c.meta))];
}
return [c];
});
let transformed = remark.stringify(out);
let transformed = transformBlocks(doc);
if (!noHeader) {
// Add the header to the start of the file
transformed = `${generateHeader(file)}\n${transformed}`;

View File

@@ -0,0 +1,129 @@
import { transformBlocks, transformToBlockQuote } from './docs.mjs';
import { remark as remarkBuilder } from 'remark'; // import it this way so we can mock it
import { vi, afterEach, describe, it, expect } from 'vitest';
const remark = remarkBuilder();
vi.mock('remark', async (importOriginal) => {
const { remark: originalRemarkBuilder } = (await importOriginal()) as {
remark: typeof remarkBuilder;
};
// make sure that both `docs.mts` and this test file are using the same remark
// object so that we can mock it
const sharedRemark = originalRemarkBuilder();
return {
remark: () => sharedRemark,
};
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('docs.mts', () => {
describe('transformBlocks', () => {
it('uses remark.parse to create the AST for the file ', () => {
const remarkParseSpy = vi
.spyOn(remark, 'parse')
.mockReturnValue({ type: 'root', children: [] });
const contents = 'Markdown file contents';
transformBlocks(contents);
expect(remarkParseSpy).toHaveBeenCalledWith(contents);
});
describe('checks each AST node', () => {
it('does no transformation if there are no code blocks', async () => {
const contents = 'Markdown file contents\n';
const result = transformBlocks(contents);
expect(result).toEqual(contents);
});
describe('is a code block', () => {
const beforeCodeLine = 'test\n';
const diagram_text = 'graph\n A --> B\n';
describe('language = "mermaid-nocode"', () => {
const lang_keyword = 'mermaid-nocode';
const contents = beforeCodeLine + '```' + lang_keyword + '\n' + diagram_text + '\n```\n';
it('changes the language to "mermaid"', () => {
const result = transformBlocks(contents);
expect(result).toEqual(
beforeCodeLine + '\n' + '```' + 'mermaid' + '\n' + diagram_text + '\n```\n'
);
});
});
describe('language = "mermaid" | "mmd" | "mermaid-example"', () => {
const mermaid_keywords = ['mermaid', 'mmd', 'mermaid-example'];
mermaid_keywords.forEach((lang_keyword) => {
const contents =
beforeCodeLine + '```' + lang_keyword + '\n' + diagram_text + '\n```\n';
it('changes the language to "mermaid-example" and adds a copy of the code block with language = "mermaid"', () => {
const result = transformBlocks(contents);
expect(result).toEqual(
beforeCodeLine +
'\n' +
'```mermaid-example\n' +
diagram_text +
'\n```\n' +
'\n```mermaid\n' +
diagram_text +
'\n```\n'
);
});
});
});
it('calls transformToBlockQuote with the node information', () => {
const lang_keyword = 'note';
const contents =
beforeCodeLine + '```' + lang_keyword + '\n' + 'This is the text\n' + '```\n';
const result = transformBlocks(contents);
expect(result).toEqual(beforeCodeLine + '\n> **Note**\n' + '> This is the text\n');
});
});
});
});
describe('transformToBlockQuote', () => {
// TODO Is there a way to test this with --vitepress given as a process argument?
describe('vitepress is not given as an argument', () => {
it('everything starts with "> " (= block quote)', () => {
const result = transformToBlockQuote('first line\n\n\nfourth line', 'blorfType');
expect(result).toMatch(/> (.)*\n> first line(?:\n> ){3}fourth line/);
});
it('includes an icon if there is one for the type', () => {
const result = transformToBlockQuote(
'first line\n\n\nfourth line',
'danger',
'Custom Title'
);
expect(result).toMatch(/> \*\*‼️ Custom Title\*\* /);
});
describe('a custom title is given', () => {
it('custom title is surrounded in spaces, in bold', () => {
const result = transformToBlockQuote(
'first line\n\n\nfourth line',
'blorfType',
'Custom Title'
);
expect(result).toMatch(/> \*\*Custom Title\*\* /);
});
});
describe.skip('no custom title is given', () => {
it('title is the icon and the capitalized type, in bold', () => {
const result = transformToBlockQuote('first line\n\n\nfourth line', 'blorf type');
expect(result).toMatch(/> \*\*Blorf Type\*\* /);
});
});
});
});
});

View File

@@ -136,13 +136,10 @@ The theming engine will only recognize hex colors and not color names. So, the v
| fontFamily | trebuchet ms, verdana, arial | |
| fontSize | 16px | Font size in pixels |
| primaryColor | #fff4dd | Color to be used as background in nodes, other colors will be derived from this |
| primaryBorderColor | calculated from primaryColor | Color to be used as border in nodes using `primaryColor` |
| primaryBorderColor | calculated from primaryColor | Color to be used as border in nodes using `primaryColor` |
| primaryTextColor | calculated from darkMode #ddd/#333 | Color to be used as text color in nodes using `primaryColor` |
| secondaryColor | calculated from primaryColor | |
| primaryBorderColor | calculated from primaryColor | Color to be used as border in nodes using `primaryColor` |
| secondaryBorderColor | calculated from secondaryColor | Color to be used as border in nodes using `secondaryColor` |
| primaryBorderColor | calculated from primaryColor | Color to be used as border in nodes using `primaryColor` |
| secondaryTextColor | calculated from secondaryColor | Color to be used as text color in nodes using `secondaryColor` |
| tertiaryColor | calculated from primaryColor | |
| tertiaryBorderColor | calculated from tertiaryColor | Color to be used as border in nodes using `tertiaryColor` |

View File

@@ -669,7 +669,24 @@ flowchart LR
C -->|Two| E[Result two]
```
## Configuration...
## Configuration
### Renderer
The layout of the diagram is done with the renderer. The default renderer is dagre.
Starting with Mermaid version 9.4, you can use an alternate renderer named elk. The elk renderer is better for larger and/or more complex diagrams.
The _elk_ renderer is an experimenal feature.
You can change the renderer to elk by adding this directive:
```
%%{init: {"flowchart": {"defaultRenderer": "elk"}} }%%
```
Note that the site needs to use mermaid version 9.4+ for this to work and have this featured enabled in the lazy-loading configuration.
### Width
It is possible to adjust the width of the rendered flowchart.

View File

@@ -68,16 +68,16 @@ Messages can be of two displayed either solid or with a dotted line.
There are six types of arrows currently supported:
| Type | Description |
| ---- | ------------------------------------------------ |
| -> | Solid line without arrow |
| --> | Dotted line without arrow |
| ->> | Solid line with arrowhead |
| -->> | Dotted line with arrowhead |
| -x | Solid line with a cross at the end |
| --x | Dotted line with a cross at the end. |
| -) | Solid line with an open arrow at the end (async) |
| --) | Dotted line with a open arrow at the end (async) |
| Type | Description |
| ------ | ------------------------------------------------ |
| `->` | Solid line without arrow |
| `-->` | Dotted line without arrow |
| `->>` | Solid line with arrowhead |
| `-->>` | Dotted line with arrowhead |
| `-x` | Solid line with a cross at the end |
| `--x` | Dotted line with a cross at the end. |
| `-)` | Solid line with an open arrow at the end (async) |
| `--)` | Dotted line with a open arrow at the end (async) |
## Activations

View File

@@ -8,9 +8,10 @@ import { MermaidConfig } from './config.type';
import { log } from './logger';
import utils from './utils';
import { mermaidAPI } from './mermaidAPI';
import { addDetector } from './diagram-api/detectType';
import { registerLazyLoadedDiagrams } from './diagram-api/detectType';
import type { ParseErrorFunction } from './Diagram';
import { isDetailedError, type DetailedError } from './utils';
import { isDetailedError } from './utils';
import type { DetailedError } from './utils';
import { registerDiagram } from './diagram-api/diagramAPI';
import { ExternalDiagramDefinition } from './diagram-api/types';
@@ -188,18 +189,7 @@ const initThrowsErrors = function (
* @internal
* @param diagrams - Array of {@link ExternalDiagramDefinition}.
*/
const registerLazyLoadedDiagrams = (diagrams: ExternalDiagramDefinition[]) => {
for (const { id, detector, loader } of diagrams) {
addDetector(id, detector, loader);
}
};
/**
* This is an internal function and should not be made public, as it will likely change.
* @internal
* @param diagrams - Array of {@link ExternalDiagramDefinition}.
*/
const loadExternalDiagrams = async (diagrams: ExternalDiagramDefinition[]) => {
const loadExternalDiagrams = async (...diagrams: ExternalDiagramDefinition[]) => {
log.debug(`Loading ${diagrams.length} external diagrams`);
// Load all lazy loaded diagrams in parallel
const results = await Promise.allSettled(
@@ -342,9 +332,9 @@ const registerExternalDiagrams = async (
} = {}
) => {
if (lazyLoad) {
registerLazyLoadedDiagrams(diagrams);
registerLazyLoadedDiagrams(...diagrams);
} else {
await loadExternalDiagrams(diagrams);
await loadExternalDiagrams(...diagrams);
}
externalDiagramsRegistered = true;
};

View File

@@ -19,7 +19,8 @@ import { addDiagrams } from './diagram-api/diagram-orchestration';
import classDb from './diagrams/class/classDb';
import flowDb from './diagrams/flowchart/flowDb';
import ganttDb from './diagrams/gantt/ganttDb';
import Diagram, { getDiagramFromText, type ParseErrorFunction } from './Diagram';
import Diagram, { getDiagramFromText } from './Diagram';
import type { ParseErrorFunction } from './Diagram';
import errorRenderer from './diagrams/error/errorRenderer';
import { attachFunctions } from './interactionDb';
import { log, setLogLevel } from './logger';

View File

@@ -13,7 +13,6 @@ class Theme {
* deducing colors for instance line color. Default value is #f4f4f4.
*/
this.background = '#f4f4f4';
this.darkMode = false;
this.primaryColor = '#fff4dd';
@@ -169,6 +168,16 @@ class Theme {
this['cScaleLabel' + i] = this['cScaleLabel' + i] || this.scaleLabelColor;
}
const multiplier = this.darkMode ? -4 : -1;
for (let i = 0; i < 5; i++) {
this['surface' + i] =
this['surface' + i] ||
adjust(this.mainBkg, { h: 180, s: -15, l: multiplier * (5 + i * 3) });
this['surfacePeer' + i] =
this['surfacePeer' + i] ||
adjust(this.mainBkg, { h: 180, s: -15, l: multiplier * (8 + i * 3) });
}
/* class */
this.classText = this.classText || this.textColor;

View File

@@ -196,6 +196,13 @@ class Theme {
this['cScalePeer' + i] = this['cScalePeer' + i] || lighten(this['cScale' + i], 10);
}
for (let i = 0; i < 5; i++) {
this['surface' + i] =
this['surface' + i] || adjust(this.mainBkg, { h: 30, s: -30, l: -(-10 + i * 4) });
this['surfacePeer' + i] =
this['surfacePeer' + i] || adjust(this.mainBkg, { h: 30, s: -30, l: -(-7 + i * 4) });
}
// Setup teh label color for the set
this.scaleLabelColor = this.scaleLabelColor || (this.darkMode ? 'black' : this.labelTextColor);

View File

@@ -147,6 +147,11 @@ class Theme {
this['cScaleInv' + i] = this['cScaleInv' + i] || adjust(this['cScale' + i], { h: 180 });
}
for (let i = 0; i < 5; i++) {
this['surface' + i] = this['surface' + i] || adjust(this.mainBkg, { h: 30, l: -(5 + i * 5) });
this['surfacePeer' + i] =
this['surfacePeer' + i] || adjust(this.mainBkg, { h: 30, l: -(7 + i * 5) });
}
// Setup the label color for the set
this.scaleLabelColor =
this.scaleLabelColor !== 'calculated' && this.scaleLabelColor

View File

@@ -130,6 +130,13 @@ class Theme {
this['cScaleLabel' + i] = this['cScaleLabel' + i] || this.scaleLabelColor;
}
for (let i = 0; i < 5; i++) {
this['surface' + i] =
this['surface' + i] || adjust(this.mainBkg, { h: 30, s: -30, l: -(5 + i * 5) });
this['surfacePeer' + i] =
this['surfacePeer' + i] || adjust(this.mainBkg, { h: 30, s: -30, l: -(8 + i * 5) });
}
/* Flowchart variables */
this.nodeBkg = this.mainBkg;

View File

@@ -147,6 +147,12 @@ class Theme {
this['cScaleLabel' + i] = this['cScaleLabel' + i] || this.scaleLabelColor;
}
for (let i = 0; i < 5; i++) {
this['surface' + i] = this['surface' + i] || adjust(this.mainBkg, { l: -(5 + i * 5) });
this['surfacePeer' + i] =
this['surfacePeer' + i] || adjust(this.mainBkg, { l: -(8 + i * 5) });
}
/* Flowchart variables */
this.nodeBkg = this.mainBkg;

View File

@@ -402,3 +402,29 @@ describe('when inserting titles', function () {
expect(titleAttrSpy).toHaveBeenCalledWith('class', 'testClass');
});
});
describe('when parsing font sizes', function () {
it('parses number inputs', function () {
expect(utils.parseFontSize(14)).toEqual([14, '14px']);
});
it('parses string em inputs', function () {
expect(utils.parseFontSize('14em')).toEqual([14, '14em']);
});
it('parses string px inputs', function () {
expect(utils.parseFontSize('14px')).toEqual([14, '14px']);
});
it('parses string inputs without units', function () {
expect(utils.parseFontSize('14')).toEqual([14, '14px']);
});
it('handles undefined input', function () {
expect(utils.parseFontSize(undefined)).toEqual([undefined, undefined]);
});
it('handles unparseable input', function () {
expect(utils.parseFontSize({ fontSize: 14 })).toEqual([undefined, undefined]);
});
});

View File

@@ -543,12 +543,14 @@ export const drawSimpleText = function (
// Remove and ignore br:s
const nText = textData.text.replace(common.lineBreakRegex, ' ');
const [, _fontSizePx] = parseFontSize(textData.fontSize);
const textElem = elem.append('text');
textElem.attr('x', textData.x);
textElem.attr('y', textData.y);
textElem.style('text-anchor', textData.anchor);
textElem.style('font-family', textData.fontFamily);
textElem.style('font-size', textData.fontSize);
textElem.style('font-size', _fontSizePx);
textElem.style('font-weight', textData.fontWeight);
textElem.attr('fill', textData.fill);
if (textData.class !== undefined) {
@@ -722,6 +724,8 @@ export const calculateTextDimensions: (
return { width: 0, height: 0 };
}
const [, _fontSizePx] = parseFontSize(fontSize);
// We can't really know if the user supplied font family will render on the user agent;
// thus, we'll take the max width between the user supplied font family, and a default
// of sans-serif.
@@ -745,7 +749,7 @@ export const calculateTextDimensions: (
const textObj = getTextObj();
textObj.text = line;
const textElem = drawSimpleText(g, textObj)
.style('font-size', fontSize)
.style('font-size', _fontSizePx)
.style('font-weight', fontWeight)
.style('font-family', fontFamily);
@@ -941,6 +945,32 @@ export const insertTitle = (
.attr('class', cssClass);
};
/**
* Parses a raw fontSize configuration value into a number and string value.
*
* @param fontSize - a string or number font size configuration value
*
* @returns parsed number and string style font size values, or nulls if a number value can't
* be parsed from an input string.
*/
export const parseFontSize = (fontSize: string | number | undefined): [number?, string?] => {
// if the font size is a number, assume a px string representation
if (typeof fontSize === 'number') {
return [fontSize, fontSize + 'px'];
}
const fontSizeNumber = parseInt(fontSize, 10);
if (Number.isNaN(fontSizeNumber)) {
// if a number value can't be parsed, return null for both values
return [undefined, undefined];
} else if (fontSize === String(fontSizeNumber)) {
// if a string input doesn't contain any units, assume px units
return [fontSizeNumber, fontSize + 'px'];
} else {
return [fontSizeNumber, fontSize];
}
};
export default {
assignWithDepth,
wrapLabel,
@@ -964,4 +994,5 @@ export default {
directiveSanitizer,
sanitizeCss,
insertTitle,
parseFontSize,
};