diff --git a/.changeset/plain-crabs-listen.md b/.changeset/plain-crabs-listen.md new file mode 100644 index 00000000000..700d18b0df7 --- /dev/null +++ b/.changeset/plain-crabs-listen.md @@ -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. diff --git a/.changeset/redux-color-class-flowchart-palette.md b/.changeset/redux-color-class-flowchart-palette.md new file mode 100644 index 00000000000..3e56712d33c --- /dev/null +++ b/.changeset/redux-color-class-flowchart-palette.md @@ -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. diff --git a/.esbuild/util.spec.ts b/.esbuild/util.spec.ts new file mode 100644 index 00000000000..9213a996592 --- /dev/null +++ b/.esbuild/util.spec.ts @@ -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'); + }); +}); diff --git a/.esbuild/util.ts b/.esbuild/util.ts index f88be638f53..0d568972234 100644 --- a/.esbuild/util.ts +++ b/.esbuild/util.ts @@ -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({ @@ -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; } diff --git a/e2e/rendering/class/classDiagram-neo.spec.js b/e2e/rendering/class/classDiagram-neo.spec.js index c0043ff4267..c2b26c41460 100644 --- a/e2e/rendering/class/classDiagram-neo.spec.js +++ b/e2e/rendering/class/classDiagram-neo.spec.js @@ -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 = { diff --git a/e2e/rendering/flowchart/flowchart-redux-color-subgraphs.spec.ts b/e2e/rendering/flowchart/flowchart-redux-color-subgraphs.spec.ts new file mode 100644 index 00000000000..8b3f4bd646b --- /dev/null +++ b/e2e/rendering/flowchart/flowchart-redux-color-subgraphs.spec.ts @@ -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 }); + }); + } + }); + } +}); diff --git a/packages/mermaid/src/diagrams/class/classDb.ts b/packages/mermaid/src/diagrams/class/classDb.ts index 21f42946ced..c39a0c76df6 100644 --- a/packages/mermaid/src/diagrams/class/classDb.ts +++ b/packages/mermaid/src/diagrams/class/classDb.ts @@ -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 @@ -779,6 +782,7 @@ export class ClassDB implements DiagramDB { isGroup: false, parentId, look: config.look, + colorIndex: classColorIndex++, }; nodes.push(node); } diff --git a/packages/mermaid/src/diagrams/class/classDiagram-colorIndex.spec.ts b/packages/mermaid/src/diagrams/class/classDiagram-colorIndex.spec.ts new file mode 100644 index 00000000000..ac1ad0a73de --- /dev/null +++ b/packages/mermaid/src/diagrams/class/classDiagram-colorIndex.spec.ts @@ -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); + }); +}); diff --git a/packages/mermaid/src/diagrams/class/styles.js b/packages/mermaid/src/diagrams/class/styles.js index ccab6191923..4d55b43f986 100644 --- a/packages/mermaid/src/diagrams/class/styles.js +++ b/packages/mermaid/src/diagrams/class/styles.js @@ -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}; diff --git a/packages/mermaid/src/diagrams/common/colorThemeGate.spec.ts b/packages/mermaid/src/diagrams/common/colorThemeGate.spec.ts new file mode 100644 index 00000000000..7c2adb06f56 --- /dev/null +++ b/packages/mermaid/src/diagrams/common/colorThemeGate.spec.ts @@ -0,0 +1,286 @@ +/** + * The per-item palette is opt-in per diagram: shapes and containers stamp a + * `data-color-id` slot via `stampColorSlot`, and a diagram renders colour only if its + * stylesheet emits the matching rules. + * + * These assertions are about the gate, not the colours: a stylesheet must stay silent for + * every theme outside the colour pair, must emit one rule per palette slot inside it, and + * must never outrank a user's own styling. + */ +import { describe, expect, it } from 'vitest'; +import themes from '../../themes/index.js'; +import classStyles from '../class/styles.js'; +import flowchartStyles from '../flowchart/styles.js'; +import { + COLOR_THEMES, + DEFAULT_COLOR_SLOTS, + MAX_COLOR_SLOTS, + colorSlotCount, + paletteSlotCount, + safeLook, +} from './colorThemeGate.js'; + +const STYLESHEETS = { + class: classStyles, + flowchart: flowchartStyles, +} as const; + +const COLOUR_THEMES = [...COLOR_THEMES]; + +/** + * Derived rather than listed. A hardcoded list stops being exhaustive the moment someone + * registers a new theme — the same "widened without anyone noticing" failure this file + * exists to catch, one level up. + */ +const PLAIN_THEMES = Object.keys(themes).filter((name) => !COLOR_THEMES.has(name)); + +const render = ( + name: keyof typeof STYLESHEETS, + themeName: string, + look = 'classic', + overrides: Record = {} +) => { + const themeVariables = themes[themeName as keyof typeof themes].getThemeVariables({}); + return STYLESHEETS[name]({ + ...(themeVariables as unknown as Record), + theme: themeName, + look, + ...overrides, + } as never); +}; + +/** The distinct `color-N` slots a stylesheet emits rules for. */ +const emittedSlots = (css: string): Set => + new Set([...css.matchAll(/data-color-id="color-(\d+)"/g)].map((m) => Number(m[1]))); + +it('covers every registered theme between the two lists', () => { + expect([...PLAIN_THEMES, ...COLOUR_THEMES].sort()).toEqual(Object.keys(themes).sort()); +}); + +describe.each(Object.keys(STYLESHEETS) as (keyof typeof STYLESHEETS)[])('%s stylesheet', (name) => { + it.each(PLAIN_THEMES)('emits no per-item colour rules for %s', (themeName) => { + expect(render(name, themeName)).not.toContain('data-color-id'); + }); + + it.each(COLOUR_THEMES)('emits one rule per palette slot for %s', (themeName) => { + const css = render(name, themeName); + const slots = new Set([...css.matchAll(/data-color-id="(color-\d+)"/g)].map((m) => m[1])); + expect(slots.size).toBe(12); + }); + + it.each(COLOUR_THEMES)('never marks palette rules !important for %s', (themeName) => { + // User `classDef` / `style` declarations land in an inline `style` attribute. If the + // palette rules were !important they would silently outrank explicit user intent. + // Match only the declaration body of each palette rule -- the rest of the stylesheet + // uses !important legitimately. + const css = render(name, themeName); + const bodies = [...css.matchAll(/\[data-color-id="color-\d+"][^{]*{([^}]*)}/g)].map( + (m) => m[1] + ); + expect(bodies.length).toBeGreaterThan(0); + expect(bodies.filter((body) => body.includes('!important'))).toEqual([]); + }); + + it.each(COLOUR_THEMES)('keeps a hostile look out of the selector for %s', (themeName) => { + const css = render(name, themeName, 'classic"]{a'); + expect(css).not.toContain('classic"]{a'); + expect(css).toContain('[data-look="classic"]'); + }); +}); + +/** + * `look` is interpolated straight into the palette rules' selector, and it is a top-level + * config key — so it is reachable from diagram text through frontmatter or an init + * directive, and `config.sanitize` only drops values containing `<`, `>` or `url(data:`. + * Braces and quotes survive, which is enough to close the attribute selector early and + * open a rule block of the author's choosing, escaping the `#svgId` scoping that stylis + * applies. Every real look is a bare word, so anything else is rejected outright rather + * than escaped. + */ +describe('safeLook', () => { + it.each(['classic', 'handDrawn', 'neo', 'some-look', 'A_1'])( + 'passes the bare word %s', + (look) => { + expect(safeLook(look)).toBe(look); + } + ); + + it.each(['classic"]{a{b', 'classic"] *', 'classic}', 'classic ', '', 'a"]{}'])( + 'falls back to classic for %j', + (look) => { + expect(safeLook(look)).toBe('classic'); + } + ); + + it('falls back to classic when look is absent', () => { + expect(safeLook(undefined)).toBe('classic'); + }); +}); + +/** + * `stampColorSlot` wraps at `palette.length`; the stylesheets emit one rule per slot up to + * `colorSlotCount`. Those two counts have to agree, or an item gets stamped `color-N` with + * no rule emitted for it and renders uncoloured beside its neighbours. + * + * Both shipped colour themes carry exactly `THEME_COLOR_LIMIT` entries, so they agree by + * coincidence -- which is what hid this. A `themeVariables` override with a longer palette + * is where they come apart. + */ +describe('colorSlotCount', () => { + it('floors a missing or non-numeric limit to the default', () => { + expect(colorSlotCount(undefined)).toBe(12); + expect(colorSlotCount('12')).toBe(12); + expect(colorSlotCount(0)).toBe(12); + }); + + it('covers the palette when it is longer than the limit', () => { + expect( + colorSlotCount( + 3, + Array.from({ length: 20 }, () => '#000') + ) + ).toBe(20); + }); + + it('matches the palette when it is shorter than the limit', () => { + // Changed from expecting the limit. `stampColorSlot` assigns + // `colorIndex % palette.length`, so a two-entry palette can only ever produce color-0 + // and color-1 -- enumerated over 500 indices to check. Emitting twelve rules would + // leave ten that nothing can match. + expect(colorSlotCount(12, ['#a', '#b'])).toBe(2); + }); + + it('keeps the plain limit when given no palette', () => { + expect(colorSlotCount(7)).toBe(7); + }); +}); + +describe.each(Object.keys(STYLESHEETS) as (keyof typeof STYLESHEETS)[])( + '%s stylesheet slot coverage', + (name) => { + it('emits a rule for every slot a palette longer than THEME_COLOR_LIMIT can stamp', () => { + const palette = Array.from({ length: 20 }, (_, i) => `#${(i + 16).toString(16)}0000`); + const slots = emittedSlots( + render(name, 'redux-color', 'classic', { + THEME_COLOR_LIMIT: 3, + borderColorArray: palette, + bkgColorArray: palette, + }) + ); + // Named rather than counted, so a failure says which slots lost their rule. + const missing = [...palette.keys()].filter((i) => !slots.has(i)); + expect(missing).toEqual([]); + }); + + it('emits exactly the slots a shorter palette can stamp, and no dead rules', () => { + const slots = emittedSlots( + render(name, 'redux-color', 'classic', { + THEME_COLOR_LIMIT: 12, + borderColorArray: ['#ff0000', '#00ff00'], + bkgColorArray: ['#ffeeee', '#eeffee'], + }) + ); + // Changed from expecting the full limit: slots 2..11 could never be stamped, so + // they were rules nothing could match. + expect([...slots].sort((a, b) => a - b)).toEqual([0, 1]); + }); + } +); + +/** + * Whatever the limit, the result is used directly as a `for` bound, so it has to be a value + * a loop can finish on. `Infinity` is reachable from diagram text: + * `THEME_COLOR_LIMIT: .inf` in front matter parses to it under the `JSON_SCHEMA` mermaid + * loads YAML with, and a large finite value such as `1e9` wedges generation just as + * effectively. + */ +describe('colorSlotCount stays a usable loop bound', () => { + it.each([ + ['Infinity', Number.POSITIVE_INFINITY], + ['-Infinity', Number.NEGATIVE_INFINITY], + ['NaN', Number.NaN], + ['a huge integer', 1e9], + ['a fraction', 12.5], + ['a negative', -1], + ])('falls back to the default for %s', (_label, value) => { + expect(colorSlotCount(value)).toBe(DEFAULT_COLOR_SLOTS); + }); + + it('does not cap the palette path, because the palette is the bound', () => { + // This assertion previously expected MAX_COLOR_SLOTS, which was the bug: capping here + // let a palette longer than the cap stamp `color-64` and above with no rule emitted. + // A palette is inherently finite, so its own length is the bound. + const huge = Array.from({ length: MAX_COLOR_SLOTS + 50 }, () => '#000'); + expect(colorSlotCount(12, huge)).toBe(huge.length); + }); + + it('always yields a finite positive integer within the cap', () => { + for (const value of [Number.POSITIVE_INFINITY, 1e9, Number.NaN, -1, 12.5, 'x', {}]) { + const bound = colorSlotCount(value); + expect(Number.isInteger(bound)).toBe(true); + expect(bound).toBeGreaterThan(0); + expect(bound).toBeLessThanOrEqual(MAX_COLOR_SLOTS); + } + }); +}); + +/** + * The invariant every previous round was missing. + * + * Three bugs came out of the emitted slot count and the stamped slot disagreeing: a limit + * longer than the palette (tail stamped, no rule), a palette longer than the limit (same, + * other direction), and a palette longer than the cap added to bound the limit. Each was + * found by review and fixed one at a time, because nothing asserted the two sides agree -- + * only that each behaved as its author expected. + * + * This ties them together. `stampColorSlot` produces `colorIndex % paletteSlotCount`, and + * a stylesheet emits `0 .. colorSlotCount - 1`; those two sets have to be equal for any + * palette and any limit. Both now derive from the palette, so this holds by construction -- + * and if anyone reintroduces a separate bound, it fails here rather than in review. + */ +describe('emitted slots and stampable slots agree', () => { + const paletteOf = (n: number) => Array.from({ length: n }, (_, i) => `#${i}`); + + it.each([ + [1, 12], + [2, 12], + [11, 12], + [12, 12], + [13, 12], + [MAX_COLOR_SLOTS, 12], + [MAX_COLOR_SLOTS + 50, 12], + [12, 3], + [12, Number.POSITIVE_INFINITY], + [12, undefined], + ])('palette of %s with limit %s', (paletteLength, limit) => { + const palette = paletteOf(paletteLength); + + const emitted = new Set(Array.from({ length: colorSlotCount(limit, palette) }, (_, i) => i)); + // Replicates stampColorSlot's arithmetic across far more items than slots. + const stampable = new Set( + Array.from({ length: paletteLength + 200 }, (_, i) => i % paletteSlotCount(palette)) + ); + + expect([...emitted].sort((a, b) => a - b)).toEqual([...stampable].sort((a, b) => a - b)); + }); + + it.each(Object.keys(STYLESHEETS) as (keyof typeof STYLESHEETS)[])( + 'holds end to end for the %s stylesheet', + (name) => { + for (const paletteLength of [2, 12, MAX_COLOR_SLOTS + 50]) { + const palette = paletteOf(paletteLength); + const emitted = emittedSlots( + render(name, 'redux-color', 'classic', { + THEME_COLOR_LIMIT: 12, + borderColorArray: palette, + bkgColorArray: palette, + }) + ); + const stampable = new Set( + Array.from({ length: paletteLength + 200 }, (_, i) => i % paletteSlotCount(palette)) + ); + expect([...emitted].sort((a, b) => a - b)).toEqual([...stampable].sort((a, b) => a - b)); + } + } + ); +}); diff --git a/packages/mermaid/src/diagrams/common/colorThemeGate.ts b/packages/mermaid/src/diagrams/common/colorThemeGate.ts new file mode 100644 index 00000000000..7bba4757ab9 --- /dev/null +++ b/packages/mermaid/src/diagrams/common/colorThemeGate.ts @@ -0,0 +1,110 @@ +/** + * The per-item colour palette is opt-in per diagram. A shape or container stamps a + * `data-color-id` slot on its rendered element, and the diagram's stylesheet maps that + * slot to a border and fill. Both halves need the same answers to the same three + * questions — is this a colour theme, does it actually carry a palette, and which slot + * does this item get — so they live here rather than being restated per diagram. + * + * Before this module the `COLOR_THEMES` list existed in five copies and the stamping + * block was duplicated verbatim between `clusters.js` and `classBox.ts`. Two idioms for + * the same gate had already appeared: `er/styles.ts` keys off the theme name while + * `requirement/styles.js` keys off the array being non-empty. Anything added here is + * added once. + */ +import type { D3Selection } from '../../types.js'; + +/** Themes that carry a categorical palette for per-item colouring. */ +export const COLOR_THEMES = new Set(['redux-color', 'redux-dark-color']); + +/** How many palette slots to emit when the theme does not say. */ +export const DEFAULT_COLOR_SLOTS = 12; + +/** + * Upper bound on slots. Every shipped palette has 12 entries, so this is generous -- it + * exists to bound the loop, not to express a design limit. See `colorSlotCount`. + */ +export const MAX_COLOR_SLOTS = 64; + +/** + * A palette array is usable only if it is genuinely a non-empty array. A truthy `.length` + * check passes for a plain string too, which would yield a per-character "palette" and + * declarations like `stroke: r;`. + */ +export const hasPalette = (palette: unknown): palette is string[] => + Array.isArray(palette) && palette.length > 0; + +/** Whether `theme` should render per-item colour at all. */ +export const isColorTheme = (theme: string | undefined, palette: unknown): boolean => + theme != null && COLOR_THEMES.has(theme) && hasPalette(palette); + +/** + * `look` is interpolated into a CSS selector by every stylesheet that emits palette + * rules, and it is a top-level config key — so it is settable from diagram text via + * frontmatter or an init directive, and `config.sanitize` only removes values containing + * `<`, `>` or `url(data:`. Braces and quotes survive, which is enough to close the + * attribute selector early and open a rule block of the caller's choosing, escaping the + * `#svgId` scoping stylis applies. + * + * Every real look is a bare word, so anything else is rejected outright rather than + * escaped. Validate here, at the point of interpolation, so no caller has to remember. + */ +const SAFE_LOOK = /^[\w-]+$/; + +export const safeLook = (look: string | undefined): string => + look != null && SAFE_LOOK.test(look) ? look : 'classic'; + +/** + * Number of palette slots a stylesheet should emit. + * + * With a palette, this is exactly `palette.length` -- not the limit, not a cap. That is not + * a policy choice: `stampColorSlot` assigns `colorIndex % palette.length`, so the ids it + * can produce are precisely `0 .. palette.length - 1`. Emitting fewer leaves items stamped + * with no rule to match, and emitting more is dead CSS. Deriving both from the same length + * is what makes the two impossible to disagree, rather than something a bound has to keep + * lined up. + * + * Three separate bugs came out of letting these drift apart -- a limit longer than the + * palette, a palette longer than the limit, and then a palette longer than the cap that was + * added to bound the limit. `paletteSlotCount` below is the single source of truth for both + * sides, and `slotsAgree` in the spec pins that they never diverge again. + * + * `THEME_COLOR_LIMIT` still governs callers that emit slots *without* stamping -- timeline + * numbers `.section-N` classes rather than palette slots -- and is bounded there, because a + * loop runs on it directly and `Infinity` is reachable from front matter + * (`THEME_COLOR_LIMIT: .inf` parses to it under the `JSON_SCHEMA` mermaid uses). + */ +export const paletteSlotCount = (palette: unknown): number => + hasPalette(palette) ? palette.length : 0; + +export const colorSlotCount = (themeColorLimit: unknown, palette?: unknown): number => { + if (hasPalette(palette)) { + return paletteSlotCount(palette); + } + return typeof themeColorLimit === 'number' && + Number.isInteger(themeColorLimit) && + themeColorLimit > 0 && + themeColorLimit <= MAX_COLOR_SLOTS + ? themeColorLimit + : DEFAULT_COLOR_SLOTS; +}; + +/** + * Stamp the element with its palette slot, so the diagram's `[data-color-id]` rules can + * find it. A no-op for every theme without a palette, which is what keeps this safe to + * call from shared rendering code used by diagrams that never opt in. + * + * The slot wraps at the palette length rather than indexing raw, so a palette shorter + * than the emitted slot count cannot produce `stroke: undefined`. + */ +export const stampColorSlot = ( + shapeSvg: D3Selection, + colorIndex: number | undefined, + theme: string | undefined, + palette: unknown +): void => { + if (!isColorTheme(theme, palette)) { + return; + } + const slot = (colorIndex ?? 0) % paletteSlotCount(palette); + shapeSvg.attr('data-color-id', `color-${slot}`); +}; diff --git a/packages/mermaid/src/diagrams/flowchart/flowDb.spec.ts b/packages/mermaid/src/diagrams/flowchart/flowDb.spec.ts index 7833a7b9480..5b40d0b642a 100644 --- a/packages/mermaid/src/diagrams/flowchart/flowDb.spec.ts +++ b/packages/mermaid/src/diagrams/flowchart/flowDb.spec.ts @@ -296,3 +296,93 @@ describe('flow db direction', () => { expect(flowDb.getDirection()).toBe('TB'); }); }); + +/** + * `colorIndex` drives the per-subgraph palette under the `redux-color` / + * `redux-dark-color` themes: `clusters.js` stamps it as `data-color-id` and + * `flowchart/styles.ts` maps it to a container border and fill. + * + * It is deliberately the declaration index rather than a running counter, because + * `getData()` walks subgraphs in reverse and skips ones hidden inside a collapsed + * ancestor. A counter would hand out colours in reverse reading order and reshuffle them + * whenever a subgraph is collapsed. + */ +describe('flow db subgraph colour slots', () => { + let flowDb: FlowDB; + beforeEach(() => { + flowDb = new FlowDB(); + }); + + const addVertex = (id: string) => + flowDb.addVertex(id, { text: id, type: 'text' }, undefined, [], [], '', {}, undefined); + + const attachMeta = (id: string, meta: string) => + flowDb.addVertex(id, undefined as unknown as FlowText, undefined, [], [], '', {}, meta); + + it('numbers flat subgraphs in source order', () => { + for (const id of ['A', 'B', 'C']) { + addVertex(id); + } + flowDb.addSubGraph({ text: 'first' }, ['A'], { text: 'First', type: 'text' }); + flowDb.addSubGraph({ text: 'second' }, ['B'], { text: 'Second', type: 'text' }); + flowDb.addSubGraph({ text: 'third' }, ['C'], { text: 'Third', type: 'text' }); + + const { nodes } = flowDb.getData(); + const slot = (id: string) => nodes.find((n) => n.id === id)?.colorIndex; + expect([slot('first'), slot('second'), slot('third')]).toEqual([0, 1, 2]); + }); + + it('numbers nested subgraphs in source order, parent before its children', () => { + /* The discriminating case. `addSubGraph` is called when a subgraph *closes*, so + * `subGraphs` holds nested ones before their parent -- here [InnerOne, InnerTwo, + * Outer, Sibling]. Taking the array index directly gave Outer slot 2 while its own + * children took 0 and 1. + * + * Three flat subgraphs cannot catch that: for siblings, close order and source order + * are the same. Only nesting separates them. + */ + for (const id of ['A', 'B', 'C', 'D']) { + addVertex(id); + } + flowDb.addSubGraph({ text: 'InnerOne' }, ['A'], { text: 'InnerOne', type: 'text' }); + flowDb.addSubGraph({ text: 'InnerTwo' }, ['B'], { text: 'InnerTwo', type: 'text' }); + flowDb.addSubGraph({ text: 'Outer' }, ['InnerOne', 'InnerTwo'], { + text: 'Outer', + type: 'text', + }); + flowDb.addSubGraph({ text: 'Sibling' }, ['D'], { text: 'Sibling', type: 'text' }); + + const { nodes } = flowDb.getData(); + const slot = (id: string) => nodes.find((n) => n.id === id)?.colorIndex; + expect([slot('Outer'), slot('InnerOne'), slot('InnerTwo'), slot('Sibling')]).toEqual([ + 0, 1, 2, 3, + ]); + }); + + it('keeps a collapsed subgraph on its own slot so the cycle does not shift', () => { + for (const id of ['A', 'B', 'C']) { + addVertex(id); + } + flowDb.addSubGraph({ text: 'first' }, ['A'], { text: 'First', type: 'text' }); + flowDb.addSubGraph({ text: 'second' }, ['B'], { text: 'Second', type: 'text' }); + flowDb.addSubGraph({ text: 'third' }, ['C'], { text: 'Third', type: 'text' }); + attachMeta('second', ' view: collapsed '); + + const { nodes } = flowDb.getData(); + const slot = (id: string) => nodes.find((n) => n.id === id)?.colorIndex; + // `second` is drawn as a compact node rather than a container, but `third` keeps + // slot 2 either way. + expect(slot('first')).toBe(0); + expect(slot('second')).toBe(1); + expect(slot('third')).toBe(2); + }); + + it('leaves plain vertices without a slot', () => { + addVertex('A'); + flowDb.addSubGraph({ text: 'only' }, ['A'], { text: 'Only', type: 'text' }); + + const { nodes } = flowDb.getData(); + expect(nodes.find((n) => n.id === 'A')?.colorIndex).toBeUndefined(); + expect(nodes.find((n) => n.id === 'only')?.colorIndex).toBe(0); + }); +}); diff --git a/packages/mermaid/src/diagrams/flowchart/flowDb.ts b/packages/mermaid/src/diagrams/flowchart/flowDb.ts index 520580999ca..258ef9018c8 100644 --- a/packages/mermaid/src/diagrams/flowchart/flowDb.ts +++ b/packages/mermaid/src/diagrams/flowchart/flowDb.ts @@ -1118,6 +1118,39 @@ You have to call mermaid.initialize.` } } } + /* Colour slots follow the order the subgraphs appear in the source. + * + * `subGraphs` is not in that order: the grammar reduces a subgraph when it *closes*, + * so a nested one lands before its parent -- `Outer { InnerOne, InnerTwo }, Sibling` + * arrives as [InnerOne, InnerTwo, Outer, Sibling]. Taking the array index directly + * would hand Outer slot 2 while its own children took 0 and 1. + * + * A pre-order walk of the containment forest recovers source order: roots complete in + * source order relative to each other, and a parent is always declared before the + * children it contains. Assigned once here so the collapsed and expanded branches + * below cannot drift apart. + */ + const declarationIndex = new Map(); + const childrenOf = new Map(); + for (const sg of subGraphs) { + const parent = subGraphParent.get(sg.id); + if (parent !== undefined) { + childrenOf.set(parent, [...(childrenOf.get(parent) ?? []), sg.id]); + } + } + let nextDeclarationIndex = 0; + const walk = (sgId: string) => { + declarationIndex.set(sgId, nextDeclarationIndex++); + for (const childId of childrenOf.get(sgId) ?? []) { + walk(childId); + } + }; + for (const sg of subGraphs) { + if (!subGraphParent.has(sg.id)) { + walk(sg.id); + } + } + const isCollapsed = (sgId: string) => this.subGraphLookup.get(sgId)?.metadata?.view === 'collapsed'; const outermostCollapsed = (sgId: string): string | undefined => { @@ -1193,6 +1226,9 @@ You have to call mermaid.initialize.` dir: subGraph.dir, isGroup: false, look: config.look, + // A collapsed subgraph still consumes its slot, so the cycle does not shift + // when one is collapsed. + colorIndex: declarationIndex.get(subGraph.id), }); } else { nodes.push({ @@ -1208,6 +1244,7 @@ You have to call mermaid.initialize.` dir: subGraph.dir, isGroup: true, look: config.look, + colorIndex: declarationIndex.get(subGraph.id), // Forwarded so layout engines can read per-container settings such as // `@{ algorithm: elk.box }`. `view` is consumed above; everything else // is opaque here and simply passed through. The cast is the diff --git a/packages/mermaid/src/diagrams/flowchart/styles.ts b/packages/mermaid/src/diagrams/flowchart/styles.ts index d54a79a6738..69b7b43cf7b 100644 --- a/packages/mermaid/src/diagrams/flowchart/styles.ts +++ b/packages/mermaid/src/diagrams/flowchart/styles.ts @@ -1,6 +1,7 @@ // import khroma from 'khroma'; import * as khroma from 'khroma'; import { getIconStyles } from '../globalStyles.js'; +import { colorSlotCount, hasPalette, isColorTheme, safeLook } from '../common/colorThemeGate.js'; /** Returns the styles given options */ export interface FlowChartStyleOptions { @@ -18,8 +19,85 @@ export interface FlowChartStyleOptions { textColor: string; titleColor: string; strokeWidth: string; + theme?: string; + look?: string; + THEME_COLOR_LIMIT?: number; + borderColorArray?: string[]; + bkgColorArray?: string[]; } +/** + * Cycling per-subgraph colour. Only the containers are painted -- the nodes inside keep + * the uniform look, because a flowchart node is a step in a flow rather than a distinct + * participant, and node colour is already how `classDef` / `style` carry meaning. + * + * Emits both the `rect` (classic/neo) and `path` (handDrawn) forms since the container is + * a plain rect in one look and a roughjs path pair in the other. `.collapsed-group` is + * the same container drawn as a compact node by `collapsedGroup.ts` — it is a container, + * not one of the flow's steps, so it takes the palette too; without it a collapsed + * subgraph rendered uncoloured beside tinted siblings. + * + * Not `!important`: `clusters.js` and `collapsedGroup.ts` both put user styles in an + * inline `style` attribute, which has to keep winning over the theme palette. The + * collapsed form's own colours are presentation attributes (`fill=` / `stroke=`), which + * these rules correctly outrank while still losing to that inline style. + */ +const genColor = (options: FlowChartStyleOptions) => { + 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]; + const fill = hasBkgColors ? `fill: ${bkgColorArray[i % bkgColorArray.length]};` : ''; + const slot = `[data-look="${look}"][data-color-id="color-${i}"]`; + /* A collapsed subgraph is drawn by `collapsedGroup.ts` through `getNodeClasses`, which + * returns `rough-node` instead of `node` for the handDrawn look -- so a `.node`-only + * selector leaves handDrawn collapsed containers uncoloured beside their tinted + * siblings. Clusters are unaffected: `clusters.js` sets the `cluster` class directly. + * + * Each descendant has to be appended to *both* prefixes separately. Writing + * `${slot}.node, ${slot}.rough-node .thing` would attach the descendant to the last + * item of the list only, silently matching nothing under the classic look. + */ + const collapsedRule = (suffix: string) => + `${slot}.node ${suffix}, ${slot}.rough-node ${suffix}`; + sections += ` + + ${slot}.cluster rect { + stroke: ${borderColor}; + ${fill} + } + + ${slot}.cluster path { + stroke: ${borderColor}; + ${fill} + } + + ${collapsedRule('.collapsed-group')}, + ${collapsedRule('.collapsed-group path')} { + stroke: ${borderColor}; + ${fill} + } + + /* The ellipsis dots and the separator take clusterBorder further down, so without + these the container is palette-coloured while its own markers are not. */ + ${collapsedRule('.collapsed-indicator')} { + fill: ${borderColor}; + } + + ${collapsedRule('.collapsed-separator')} { + stroke: ${borderColor}; + } + `; + } + return sections; +}; + const fade = (color: string, opacity: number) => { // @ts-ignore TODO: incorrect types from khroma const channel = khroma.channel; @@ -33,7 +111,8 @@ const fade = (color: string, opacity: number) => { }; const getStyles = (options: FlowChartStyleOptions) => - `.label { + `${genColor(options)} + .label { font-family: ${options.fontFamily}; color: ${options.nodeTextColor || options.textColor}; } diff --git a/packages/mermaid/src/rendering-util/rendering-elements/clusters.js b/packages/mermaid/src/rendering-util/rendering-elements/clusters.js index d841f782011..5d0722e1b66 100644 --- a/packages/mermaid/src/rendering-util/rendering-elements/clusters.js +++ b/packages/mermaid/src/rendering-util/rendering-elements/clusters.js @@ -10,12 +10,13 @@ import createLabel from './createLabel.js'; import { createRoundedRectPathD } from './shapes/roundedRectPath.ts'; import { compileStyles, styles2String, userNodeOverrides } from './shapes/handDrawnShapeStyles.js'; import { swimlane } from './clusters/swimlane.js'; +import { stampColorSlot } from '../../diagrams/common/colorThemeGate.js'; const rect = async (parent, node) => { log.info('Creating subgraph rect for ', node.id, node); const siteConfig = getConfig(); - const { themeVariables, handDrawnSeed } = siteConfig; - const { clusterBkg, clusterBorder } = themeVariables; + const { theme, themeVariables, handDrawnSeed } = siteConfig; + const { clusterBkg, clusterBorder, borderColorArray } = themeVariables; const { labelStyles, nodeStyles, borderStyles, backgroundStyles } = styles2String(node); @@ -26,6 +27,11 @@ const rect = async (parent, node) => { .attr('id', node.domId) .attr('data-look', node.look); + // Per-container colour slot. A no-op unless the active theme carries a palette, and + // painted only by diagrams whose stylesheet defines the matching `[data-color-id]` + // rules -- for state, block and class namespaces this is an inert attribute. + stampColorSlot(shapeSvg, node.colorIndex, theme, borderColorArray); + const useHtmlLabels = getEffectiveHtmlLabels(siteConfig); // Create the label and insert it after the rect diff --git a/packages/mermaid/src/rendering-util/rendering-elements/shapes/classBox.ts b/packages/mermaid/src/rendering-util/rendering-elements/shapes/classBox.ts index dd20852c23a..6288bcbfef0 100644 --- a/packages/mermaid/src/rendering-util/rendering-elements/shapes/classBox.ts +++ b/packages/mermaid/src/rendering-util/rendering-elements/shapes/classBox.ts @@ -9,11 +9,12 @@ import intersect from '../intersect/index.js'; import { textHelper } from '../../../diagrams/class/shapeUtil.js'; import { evaluate } from '../../../diagrams/common/common.js'; import type { D3Selection } from '../../../types.js'; +import { stampColorSlot } from '../../../diagrams/common/colorThemeGate.js'; export async function classBox(parent: D3Selection, node: Node) { const config = getConfig(); - const { themeVariables } = config; - const { useGradient } = themeVariables; + const { theme, themeVariables } = config; + const { useGradient, borderColorArray } = themeVariables; const PADDING = config.class!.padding ?? 12; const GAP = PADDING; const useHtmlLabels = node.useHtmlLabels ?? evaluate(config.htmlLabels) ?? true; @@ -25,6 +26,8 @@ export async function classBox(parent: D3Selection const { shapeSvg, bbox } = await textHelper(parent, node, config, useHtmlLabels, GAP); + stampColorSlot(shapeSvg, node.colorIndex, theme, borderColorArray); + const { labelStyles, nodeStyles } = styles2String(node); node.labelStyle = labelStyles; diff --git a/packages/mermaid/src/rendering-util/rendering-elements/shapes/collapsedGroup.ts b/packages/mermaid/src/rendering-util/rendering-elements/shapes/collapsedGroup.ts index a8fff4e5743..8bc7ca70361 100644 --- a/packages/mermaid/src/rendering-util/rendering-elements/shapes/collapsedGroup.ts +++ b/packages/mermaid/src/rendering-util/rendering-elements/shapes/collapsedGroup.ts @@ -8,6 +8,7 @@ import { createRoundedRectPathD } from './roundedRectPath.js'; import { userNodeOverrides, styles2String } from './handDrawnShapeStyles.js'; import rough from 'roughjs'; import { handleUndefinedAttr } from '../../../utils.js'; +import { stampColorSlot } from '../../../diagrams/common/colorThemeGate.js'; /** Height reserved for the ellipsis indicator row below the title */ const INDICATOR_ROW_HEIGHT = 20; @@ -83,6 +84,12 @@ export async function collapsedGroup( const { shapeSvg, bbox } = await labelHelper(parent, node, getNodeClasses(node)); + // A collapsed subgraph is still a container, so it takes the same palette slot its + // expanded form would. Without this it renders uncoloured beside tinted siblings, and + // the seam shows up exactly when someone uses the collapse feature. + const { theme, themeVariables } = getConfig(); + stampColorSlot(shapeSvg, node.colorIndex, theme, themeVariables.borderColorArray); + const padding = node.padding ?? 8; const titleHeight = bbox.height; const totalWidth = Math.max(bbox.width + padding * 2, MIN_WIDTH, node?.width ?? 0);