Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/plain-crabs-listen.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@mermaid-js/layout-elk': patch
---

fix(build): externalize `peerDependencies` in core builds so the layout plugins no longer inline a second copy of mermaid

`getBuildConfig` only externalized `dependencies`, so a runtime (non-type) import of the peer-depended mermaid resolved through `exports` to `dist/mermaid.core.mjs` and esbuild inlined the whole bundle. `@mermaid-js/layout-elk`'s core entry had grown to 106 files / 6.6 MB, carrying its own mermaid with separate module-level singletons — so mermaid rendering fixes did not reach the ELK layout path until the plugin itself was republished. The core entry is back to 3 files / ~41 KB and now defers to the host's mermaid. The self-contained `esm` entry is unchanged.
5 changes: 5 additions & 0 deletions .changeset/redux-color-class-flowchart-palette.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'mermaid': minor
---

feat(themes): class boxes and flowchart subgraph containers now take a per-item colour under the `redux-color` and `redux-dark-color` themes, cycling every 12 as ER entities already do. Collapsed subgraphs keep the slot they would have had expanded; nodes inside a subgraph stay uniform, and explicit `classDef` or `style` still wins over the palette.
47 changes: 47 additions & 0 deletions .esbuild/util.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
// @vitest-environment node
// util.ts resolves paths from `import.meta.url`, which is not a file:// URL
// under the default jsdom environment.
import { describe, expect, it } from 'vitest';
import { packageOptions } from '../.build/common.js';
import { defaultOptions, getBuildConfig } from './util.js';

const buildFor = (packageName: keyof typeof packageOptions, core: boolean) =>
getBuildConfig({
...defaultOptions,
core,
format: 'esm',
options: packageOptions[packageName],
});

describe('getBuildConfig externals', () => {
it('externalizes peerDependencies in the core build', () => {
// The layout plugins peer-depend on mermaid. If it is not external, a
// runtime import of it resolves through `exports` to dist/mermaid.core.mjs
// and esbuild inlines the whole bundle — shipping a second mermaid, with
// its own module-level singletons, inside the plugin. The plugin then
// renders against its own stale copy instead of the host's, so mermaid
// fixes silently fail to reach it until the plugin is republished.
const external = buildFor('mermaid-layout-elk', true).external ?? [];
expect(external).toContain('mermaid');
});

it('externalizes dependencies in the core build', () => {
const external = buildFor('mermaid-layout-elk', true).external ?? [];
expect(external).toContain('elkjs');
expect(external).toContain('d3');
});

it('bundles everything in the non-core build', () => {
// The esm entry is the self-contained one (standalone + dev server), so it
// must keep inlining mermaid rather than emitting a bare import. Only the
// node built-ins stay external there.
const external = buildFor('mermaid-layout-elk', false).external ?? [];
expect(external).not.toContain('mermaid');
expect(external).not.toContain('elkjs');
});

it('leaves mermaid itself unaffected — it has no peerDependencies', () => {
const external = buildFor('mermaid', true).external ?? [];
expect(external).not.toContain('mermaid');
});
});
12 changes: 10 additions & 2 deletions .esbuild/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ export const getBuildConfig = (options: MermaidBuildOptions): BuildOptions => {

const external: string[] = ['require', 'fs', 'path'];
const outFileName = getFileName(name, options);
const { dependencies, version } = JSON.parse(
const { dependencies, peerDependencies, version } = JSON.parse(
readFileSync(resolve(__dirname, `../packages/${packageName}/package.json`), 'utf-8')
);
const output: BuildOptions = buildOptions({
Expand All @@ -106,7 +106,15 @@ export const getBuildConfig = (options: MermaidBuildOptions): BuildOptions => {
// Core build is used to generate file without bundled dependencies.
// This is used by downstream projects to bundle dependencies themselves.
// Ignore dependencies and any dependencies of dependencies
external.push(...Object.keys(dependencies));
//
// peerDependencies must be external too. The consumer is the one that
// supplies them, so inlining one ships a second copy of that package —
// with its own module-level singletons — inside this bundle. For the
// layout plugins that peer dep is mermaid itself: a runtime (non-type)
// import of it resolves through `exports` to dist/mermaid.core.mjs and
// esbuild would inline the whole thing, so the plugin would run against
// its own stale mermaid rather than the host's.
external.push(...Object.keys(dependencies ?? {}), ...Object.keys(peerDependencies ?? {}));
output.external = external;
}

Expand Down
6 changes: 6 additions & 0 deletions e2e/rendering/class/classDiagram-neo.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,12 @@ const themes = [
{ theme: 'neo-dark', label: 'neo-dark' },
{ theme: 'redux', label: 'redux' },
{ theme: 'redux-dark', label: 'redux-dark' },
// The colour themes give each class box its own border and fill. Without these two
// entries nothing in the suite renders a class diagram under them, so the palette
// wiring — the slot stamped on the element meeting the selector emitted by the
// stylesheet — had no visual coverage at all.
{ theme: 'redux-color', label: 'redux-color' },
{ theme: 'redux-dark-color', label: 'redux-dark-color' },
];

const diagrams = {
Expand Down
115 changes: 115 additions & 0 deletions e2e/rendering/flowchart/flowchart-redux-color-subgraphs.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import { test } from '@playwright/test';
import { imgSnapshotTest } from '../../helpers/util.ts';

/**
* Flowchart subgraph containers take a per-container colour under the redux colour
* themes. Nothing in the suite rendered a flowchart under those themes, so the wiring
* had no visual coverage: the unit tests check that `flowDb` hands out slots and that the
* stylesheet emits rules, but only a render proves the stamped `data-color-id` actually
* meets the emitted selector on the element.
*/
const reduxThemes = ['redux', 'redux-color', 'redux-dark', 'redux-dark-color'] as const;

/**
* Five subgraphs, one more than the four in the demo fixtures, so the ordering is
* unambiguous and a reversed cycle would be obvious. Nodes inside stay uniform by design.
*/
const subgraphs = `
flowchart TB
subgraph Ingest
A[Fetch] --> B[Validate]
end
subgraph Transform
C[Normalise] --> D[Enrich]
end
subgraph Store
E[(Warehouse)]
end
subgraph Serve
F[API] --> G[Cache]
end
subgraph Observe
H[Metrics]
end
B --> C
D --> E
E --> F
F --> H
`;

/** Nested containers, to show the palette applying at more than one depth. */
const nested = `
flowchart LR
subgraph Outer
subgraph InnerOne
A[one] --> B[two]
end
subgraph InnerTwo
C[three]
end
end
subgraph Sibling
D[four]
end
B --> C
C --> D
`;

/**
* A collapsed subgraph renders as a compact node rather than a container. It is still a
* container, so it takes a palette slot too — and it keeps the slot it would have had
* expanded, so collapsing one does not reshuffle its siblings' colours.
*/
const collapsed = `
flowchart TB
subgraph first[First]
A[a] --> B[b]
end
subgraph second[Second]
C[c]
end
second@{ view: collapsed }
subgraph third[Third]
D[d]
end
B --> C
C --> D
`;

/**
* Explicit user styling has to keep winning over the palette: `classDef` / `style`
* declarations become inline `style` attributes and none of the palette rules are
* `!important`. `Two` should stay green here while `One` takes its slot colour.
*/
const userStyled = `
flowchart LR
subgraph One
X[node] --> Y[node]
end
subgraph Two
Z[node]
end
classDef mine fill:#ff0000,stroke:#000000
class X mine
style Two fill:#00ff00,stroke:#0000ff
Y --> Z
`;

const diagrams = {
subgraphs,
nested,
collapsed,
'user-styled': userStyled,
} as const;

test.describe('Flowchart - Redux colour theme subgraphs', () => {
for (const theme of reduxThemes) {
test.describe(`Theme: ${theme}`, () => {
for (const [name, diagram] of Object.entries(diagrams)) {
test(`should render ${name} subgraph containers`, async ({ page }, testInfo) => {
await imgSnapshotTest(page, testInfo, diagram, { theme });
});
}
});
}
});
4 changes: 4 additions & 0 deletions packages/mermaid/src/diagrams/class/classDb.ts
Original file line number Diff line number Diff line change
Expand Up @@ -769,6 +769,9 @@ export class ClassDB implements DiagramDB {
nodes.push(node);
}

// Only classes consume a colour slot -- namespaces are containers and notes have
// their own fixed note colour, so neither should shift the cycle.
let classColorIndex = 0;
for (const classNode of this.classes.values()) {
const parentId = hierarchical
? classNode.parent
Expand All @@ -779,6 +782,7 @@ export class ClassDB implements DiagramDB {
isGroup: false,
parentId,
look: config.look,
colorIndex: classColorIndex++,
};
nodes.push(node);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
/**
* `colorIndex` is what drives the per-class palette under the `redux-color` /
* `redux-dark-color` themes: `classDb` assigns the slot, `classBox` stamps it as
* `data-color-id`, and `class/styles.js` maps it to a border and fill.
*
* The failure mode is silent. If the slots stop being assigned, or start being shared,
* every box falls back to `color-0` and the diagram renders in one colour — which looks
* like a theme problem, not a db problem. So pin the assignment here rather than relying
* on a screenshot to notice.
*/
import { describe, expect, it, beforeEach } from 'vitest';
import { ClassDB } from './classDb.js';

describe('class diagram colour slots', () => {
let classDb: ClassDB;
beforeEach(() => {
classDb = new ClassDB();
});

const colorIndexById = () =>
new Map(classDb.getData().nodes.map((node) => [node.id, node.colorIndex]));

it('gives each class its own slot in declaration order', () => {
classDb.addClass('Order');
classDb.addClass('Customer');
classDb.addClass('Payment');

const slots = colorIndexById();
expect(slots.get('Order')).toBe(0);
expect(slots.get('Customer')).toBe(1);
expect(slots.get('Payment')).toBe(2);
});

it('does not spend a slot on a namespace container', () => {
// `addNamespace` first: `addClassesToNamespace` early-returns when the namespace does
// not exist, so without it no namespace node is created and the assertion below passes
// on absence rather than on behaviour.
classDb.addNamespace('shop');
classDb.addClass('Order');
classDb.addClassesToNamespace('shop', ['Order'], []);
classDb.addClass('Customer');

const slots = colorIndexById();
// The namespace is a container, not a participant -- it must not shift the cycle.
expect(slots.get('shop')).toBeUndefined();
expect(slots.get('Order')).toBe(0);
expect(slots.get('Customer')).toBe(1);
});

it('does not spend a slot on a note', () => {
classDb.addClass('Order');
classDb.addNote('a note', 'Order');
classDb.addClass('Customer');

const slots = colorIndexById();
const noteEntry = [...slots.entries()].find(([id]) => id.startsWith('note'));
// Notes carry the theme's fixed note colour, so they stay outside the cycle.
expect(noteEntry?.[1]).toBeUndefined();
expect(slots.get('Order')).toBe(0);
expect(slots.get('Customer')).toBe(1);
});
});
40 changes: 39 additions & 1 deletion packages/mermaid/src/diagrams/class/styles.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,45 @@
import { getIconStyles } from '../globalStyles.js';
import { colorSlotCount, hasPalette, isColorTheme, safeLook } from '../common/colorThemeGate.js';

/**
* Cycling per-class colour, mirroring `er/styles.ts`. A class box is the structural twin
* of an ER entity -- a titled box with member rows, naming one distinct participant -- so
* the same index-based palette applies.
*
* Targets `.outer-path` and `.divider` rather than a bare `.node path`, so member icons
* and other inner paths are left alone. Nothing here is `!important`: `classBox.ts` puts
* user `classDef` / `style` declarations in an inline `style` attribute, which must keep
* winning over the theme palette.
*/
const genColor = (options) => {
const { theme, bkgColorArray, borderColorArray } = options;
if (!isColorTheme(theme, borderColorArray)) {
return '';
}
const look = safeLook(options.look);
const hasBkgColors = hasPalette(bkgColorArray);
let sections = '';

for (let i = 0; i < colorSlotCount(options.THEME_COLOR_LIMIT, borderColorArray); i++) {
const borderColor = borderColorArray[i % borderColorArray.length];
sections += `

[data-look="${look}"][data-color-id="color-${i}"].node .outer-path path {
stroke: ${borderColor};
${hasBkgColors ? `fill: ${bkgColorArray[i % bkgColorArray.length]};` : ''}
}

[data-look="${look}"][data-color-id="color-${i}"].node .divider path {
stroke: ${borderColor};
}
`;
}
return sections;
};

const getStyles = (options) =>
`g.classGroup text {
`${genColor(options)}
g.classGroup text {
fill: ${options.nodeBorder || options.classText};
stroke: none;
font-family: ${options.fontFamily};
Expand Down
Loading
Loading