@hagicode/hagilight-mermaid 0.20.1-dev.193.1.fb8173e → 0.20.1-dev.195.1.06f4ade

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -10,6 +10,8 @@ npm install @hagicode/hagilight-mermaid mermaid
10
10
 
11
11
  `mermaid` is a peer dependency (`^11`). The package never bundles it, and it is imported the first time a non-empty diagram renders.
12
12
 
13
+ `pele` is an optional peer (`^1.13.0`) for the Pele engine described below. Install it only if a site selects Pele: `npm install pele@^1.13.0`. A site that never selects Pele does not need it.
14
+
13
15
  ## Quick start
14
16
 
15
17
  ```ts
@@ -46,8 +48,47 @@ handle.destroy(); // discard pending renders and empty the element
46
48
  | `viewer` | `true` | Adds an enlarge button to the toolbar and makes `handle.expand()` open the viewer. `false` removes both. |
47
49
  | `fit` | `'width'` | `'width'` fills the host's width and lets the height follow. `'contain'` fits the whole diagram in the host's height, for fixed-height tiles: give the host a height. |
48
50
  | `loadMermaid` | `import('mermaid')` | Supplies the `mermaid` module. Use it to control chunking or to test. |
51
+ | `engine` | `'mermaid'` | `'mermaid'` or `'pele'`. Resolved from this option, then the stored choice (see below), then `'mermaid'`. An unrecognized value is ignored. |
52
+ | `peleLoader` | `import('pele')` | Supplies the `pele` module when the Pele engine renders. Use it to control chunking or to test. |
49
53
  | `className` | | Extra class names on the diagram container. |
50
54
 
55
+ ## Pele engine (optional)
56
+
57
+ [Pele](https://pele.run) renders the same Mermaid source to SVG without Mermaid's runtime. It is synchronous and small. Mermaid stays the default, so a site that does nothing keeps its current output.
58
+
59
+ ```ts
60
+ import { mountMermaidDiagrams, writeStoredEngine } from '@hagicode/hagilight-mermaid';
61
+
62
+ mountMermaidDiagrams(document, { engine: 'pele' }); // this page, whatever is stored
63
+ writeStoredEngine('pele'); // remember the choice in this browser for later renders
64
+ ```
65
+
66
+ The engine is resolved in this order, and an unrecognized value is ignored:
67
+
68
+ 1. the `engine` option, when given (so `engine: 'mermaid'` outranks a stored `pele`);
69
+ 2. the choice stored under the key `hagilight-mermaid:engine` in `localStorage`;
70
+ 3. `mermaid`.
71
+
72
+ Rendering never writes the stored choice. Only `writeStoredEngine` does, so `update({ engine })` for one page does not change later pages. `readStoredEngine()` returns the stored value or `undefined`. Storage that throws or is absent is treated as no choice, and no call here throws.
73
+
74
+ **Version.** `pele` is pinned to `^1.13.0` as a peer. The package's tests render one sample per supported type with the installed Pele and fail if a marker the adapters need (node and edge classes, `data-id`, `aria-roledescription`) is missing. Pele has no release page, so a Pele upgrade is checked by those tests before it is accepted.
75
+
76
+ **What Pele changes.** The theme maps `--hagilight-mermaid-*` tokens to Pele's `--pele-*` variables on the container, so a mode or preset change restyles the diagram without calling Pele again. Pie series colors are set explicitly. The error panel shows Pele's position and writes every message with `textContent`.
77
+
78
+ **Flowchart, state, class and ER under Pele.** Tracing, keyboard focus and the dependency-order reveal use the geometry of the rendered SVG: each edge's first and last points are matched to the nearest node box. Edge identifiers are never read for endpoints. For class and ER relations the drawn direction, from the start of the line to its end, is the trace direction. ER relations have no true direction, so this is a convention: in the fixtures the line leaves the entity named first in the relation (`Animal` to `Duck`, `CUSTOMER` to `ORDER`).
79
+
80
+ **Other Pele types** (pie, sequence and the rest) reveal with the generic reveal and have no part focus in this version. Diagram types listed in the degraded set show a notice above the diagram.
81
+
82
+ **Degraded types.** Pele draws these types in a clearly simpler way, so the container shows the notice `peleDegradedNotice` (overridable through `labels`) above the diagram:
83
+
84
+ - `c4`: the scaled-down diagram's text is hard to read at the same width.
85
+ - `gitGraph`: branch labels sit on mid-tone fills and are hard to read.
86
+ - `treemap`: leaf labels are small and light on their fills.
87
+
88
+ The list was set from a same-size screenshot comparison of the 36 demo samples, both engines at 440 px. It is a constant in the package and is extended only with that kind of comparison.
89
+
90
+ **Phase 2 limits.** Part focus and type-specific reveals for pie, sequence and the other types are not included. Pele's own `mount()` is not used, so its zoom and resize behaviour is not available. Layouts differ from Mermaid's, so a Pele diagram is not a pixel match for the Mermaid one.
91
+
51
92
  ## What each diagram type does
52
93
 
53
94
  Every Mermaid diagram type moves and responds, not only the ones with nodes.
@@ -104,6 +145,8 @@ Blue and teal lead the order, then amber and violet, so neighbouring series neve
104
145
  To use a site's own names, pass `tokens` instead of redefining the package tokens:
105
146
 
106
147
  ```ts
148
+ import { mountMermaidDiagrams } from '@hagicode/hagilight-mermaid';
149
+
107
150
  mountMermaidDiagrams(document, {
108
151
  tokens: { background: '--background', foreground: '--foreground', primary: '--primary', accent: '--accent', primaryForeground: '--primary-foreground' },
109
152
  });
@@ -116,6 +159,8 @@ Colors in `oklch()` or `lab()` are converted to `rgb()` through a canvas before
116
159
  All visible text comes from `labels`. Unspecified keys keep the English defaults, and `{value}` is replaced by the count in trace labels.
117
160
 
118
161
  ```ts
162
+ import { mountMermaidDiagrams } from '@hagicode/hagilight-mermaid';
163
+
119
164
  mountMermaidDiagrams(document, {
120
165
  labels: {
121
166
  rendering: 'Rendering diagram...',
@@ -158,6 +203,7 @@ With Mermaid 11.16.1, every diagram type the demo covers renders an SVG, and non
158
203
  - **Part focus and the reveal** read Mermaid 11.16.1's SVG structure, which has no shared shape across diagram types. A later Mermaid release can rename a class: the affected type then loses its part focus, and its reveal falls back to the generic one, until the table in `src/adapters.ts` is updated. The browser checks pin the installed version.
159
204
  - **Thin lines** (a sequence message, a git branch) are hard to hit on their own, so the part includes its label: hover the label or the text beside the line.
160
205
  - **Block**, **kanban**, **architecture** and **Wardley** have no dependency graph the tracing can read, so they get part focus rather than upstream and downstream paths.
206
+ - **Pele engine**: the layout is Pele's own, not Mermaid's. The degraded list above is the only place that says where Pele draws less clearly. Part focus and type-specific reveals wait for a later phase.
161
207
  - **Text halo**: the default preset draws a halo behind diagram text, white in light mode and `#111827` in dark mode. Set `--hagilight-mermaid-halo` to the page color when it differs. Text printed on a colored mark (pie slices, Gantt bars, timeline and branch labels) has no halo.
162
208
 
163
209
  ## Parity
@@ -10,6 +10,7 @@
10
10
  * through the graph model instead. Every selector here was read from Mermaid 11.16.1 output; a type
11
11
  * the table does not know falls back to its top-level pieces, so a new Mermaid diagram still moves.
12
12
  */
13
+ import type { Box, EdgeEnds } from './geometry.js';
13
14
  export type BeatKind = 'fade' | 'rise' | 'drop' | 'pop' | 'growX' | 'growY' | 'draw' | 'swing' | 'wipe';
14
15
  export interface Beat {
15
16
  els: Element[];
@@ -40,3 +41,27 @@ export declare function entranceBeats(svg: Element): Beat[];
40
41
  * is the second part, so they name it explicitly.
41
42
  */
42
43
  export declare function describeParts(svg: Element): PartFamily[];
44
+ /** Pele's element for one node or edge of a graph, with what the geometry needs and nothing parsed from ids. */
45
+ export interface PeleGraphNodePart {
46
+ el: SVGGElement;
47
+ /** The node's `data-id`, the stable key. Never parsed for endpoints. */
48
+ key: string;
49
+ label: string;
50
+ box: Box;
51
+ }
52
+ export interface PeleGraphEdgePart {
53
+ /** The line of the edge: the first child path that is not a marker. */
54
+ el: SVGPathElement;
55
+ /** Arrowheads and other `pele-marker` children, which travel with the line. */
56
+ markers: SVGElement[];
57
+ ends: EdgeEnds;
58
+ }
59
+ export interface PeleGraphParts {
60
+ nodes: PeleGraphNodePart[];
61
+ edges: PeleGraphEdgePart[];
62
+ }
63
+ /**
64
+ * The nodes and edges of a Pele flowchart, state, class or ER diagram, for the geometry matcher. Other Pele
65
+ * types have no graph here and return null, so they reveal and focus by their own parts instead.
66
+ */
67
+ export declare function peleGraphParts(svg: SVGSVGElement): PeleGraphParts | null;
package/dist/adapters.js CHANGED
@@ -357,3 +357,76 @@ export function describeParts(svg) {
357
357
  return family;
358
358
  });
359
359
  }
360
+ /** Pele types whose graph is read from geometry: a node box per `g.pele-node`, an edge per `g.pele-edge`. */
361
+ const PELE_GRAPH_TYPES = new Set(['flowchart', 'state', 'class', 'er']);
362
+ /**
363
+ * The element's matrix into the root SVG's user space (its viewBox coordinates, the units Pele draws in). A
364
+ * child's own CTM also carries the viewBox-to-viewport scale, so that scale is divided out here. Every
365
+ * geometry check uses this one space, so distances are measured in the same units as the drawing.
366
+ */
367
+ function toUserSpace(svg, el) {
368
+ const root = svg.getCTM();
369
+ const own = el.getCTM();
370
+ if (!root || !own)
371
+ return null;
372
+ return root.inverse().multiply(own);
373
+ }
374
+ /** A box in the root SVG's user space: the element's bounding box, carried through its own transform. */
375
+ function boxInRoot(svg, el) {
376
+ if (typeof el.getBBox !== 'function' || typeof el.getCTM !== 'function')
377
+ return null;
378
+ const bounds = el.getBBox();
379
+ const matrix = toUserSpace(svg, el);
380
+ if (!matrix || (bounds.width === 0 && bounds.height === 0))
381
+ return null;
382
+ const corners = [
383
+ [bounds.x, bounds.y],
384
+ [bounds.x + bounds.width, bounds.y],
385
+ [bounds.x, bounds.y + bounds.height],
386
+ [bounds.x + bounds.width, bounds.y + bounds.height],
387
+ ].map(([x, y]) => ({ x: matrix.a * x + matrix.c * y + matrix.e, y: matrix.b * x + matrix.d * y + matrix.f }));
388
+ const xs = corners.map((corner) => corner.x);
389
+ const ys = corners.map((corner) => corner.y);
390
+ return { left: Math.min(...xs), top: Math.min(...ys), right: Math.max(...xs), bottom: Math.max(...ys) };
391
+ }
392
+ /** A point along a path, in the same root user space as `boxInRoot`. */
393
+ function pointInRoot(svg, path, distance) {
394
+ const local = path.getPointAtLength(distance);
395
+ const matrix = toUserSpace(svg, path);
396
+ if (!matrix)
397
+ return { x: local.x, y: local.y };
398
+ return { x: matrix.a * local.x + matrix.c * local.y + matrix.e, y: matrix.b * local.x + matrix.d * local.y + matrix.f };
399
+ }
400
+ function peleLabel(node) {
401
+ for (const label of Array.from(node.querySelectorAll('.pele-label'))) {
402
+ const text = textOf(label);
403
+ if (text)
404
+ return text;
405
+ }
406
+ return '';
407
+ }
408
+ /**
409
+ * The nodes and edges of a Pele flowchart, state, class or ER diagram, for the geometry matcher. Other Pele
410
+ * types have no graph here and return null, so they reveal and focus by their own parts instead.
411
+ */
412
+ export function peleGraphParts(svg) {
413
+ if (!PELE_GRAPH_TYPES.has(diagramType(svg)))
414
+ return null;
415
+ const nodes = [];
416
+ svg.querySelectorAll('g.pele-node[data-id]').forEach((el) => {
417
+ const box = boxInRoot(svg, el);
418
+ if (!box)
419
+ return;
420
+ nodes.push({ el, key: el.getAttribute('data-id') ?? '', label: peleLabel(el), box });
421
+ });
422
+ const edges = [];
423
+ svg.querySelectorAll('g.pele-edge').forEach((group) => {
424
+ const line = Array.from(group.children).find((child) => child.tagName.toLowerCase() === 'path' && !child.classList.contains('pele-marker'));
425
+ if (!line || typeof line.getTotalLength !== 'function')
426
+ return;
427
+ const length = line.getTotalLength();
428
+ const markers = Array.from(group.querySelectorAll('.pele-marker'));
429
+ edges.push({ el: line, markers, ends: { start: pointInRoot(svg, line, 0), end: pointInRoot(svg, line, length) } });
430
+ });
431
+ return { nodes, edges };
432
+ }
@@ -0,0 +1,14 @@
1
+ import type { MermaidEngine } from './types.js';
2
+ /** Storage key for the engine a visitor chose. The package never writes it on its own. */
3
+ export declare const ENGINE_STORAGE_KEY = "hagilight-mermaid:engine";
4
+ /** True for `mermaid` and `pele`. Anything else counts as no choice. */
5
+ export declare function isMermaidEngine(value: unknown): value is MermaidEngine;
6
+ /**
7
+ * The engine a render uses: the option when it is recognized, then the stored choice, then `mermaid`.
8
+ * An unrecognized option is ignored, so the next source in the order decides.
9
+ */
10
+ export declare function resolveEngine(option?: unknown): MermaidEngine;
11
+ /** The stored engine choice, or `undefined` when none is stored, the value is unrecognized, or storage is unavailable. */
12
+ export declare function readStoredEngine(): MermaidEngine | undefined;
13
+ /** Stores the engine choice for later renders with no explicit option. Unrecognized values and storage failures do nothing. */
14
+ export declare function writeStoredEngine(engine: MermaidEngine): void;
package/dist/engine.js ADDED
@@ -0,0 +1,38 @@
1
+ /** Storage key for the engine a visitor chose. The package never writes it on its own. */
2
+ export const ENGINE_STORAGE_KEY = 'hagilight-mermaid:engine';
3
+ const ENGINES = ['mermaid', 'pele'];
4
+ /** True for `mermaid` and `pele`. Anything else counts as no choice. */
5
+ export function isMermaidEngine(value) {
6
+ return typeof value === 'string' && ENGINES.includes(value);
7
+ }
8
+ /**
9
+ * The engine a render uses: the option when it is recognized, then the stored choice, then `mermaid`.
10
+ * An unrecognized option is ignored, so the next source in the order decides.
11
+ */
12
+ export function resolveEngine(option) {
13
+ if (isMermaidEngine(option))
14
+ return option;
15
+ return readStoredEngine() ?? 'mermaid';
16
+ }
17
+ /** The stored engine choice, or `undefined` when none is stored, the value is unrecognized, or storage is unavailable. */
18
+ export function readStoredEngine() {
19
+ try {
20
+ const value = globalThis.localStorage?.getItem(ENGINE_STORAGE_KEY);
21
+ return isMermaidEngine(value) ? value : undefined;
22
+ }
23
+ catch {
24
+ // Blocked or throwing storage: rendering falls back to the default engine.
25
+ return undefined;
26
+ }
27
+ }
28
+ /** Stores the engine choice for later renders with no explicit option. Unrecognized values and storage failures do nothing. */
29
+ export function writeStoredEngine(engine) {
30
+ if (!isMermaidEngine(engine))
31
+ return;
32
+ try {
33
+ globalThis.localStorage?.setItem(ENGINE_STORAGE_KEY, engine);
34
+ }
35
+ catch {
36
+ // The control still switches the engine for this page; the choice is just not remembered.
37
+ }
38
+ }
package/dist/entrance.js CHANGED
@@ -76,6 +76,8 @@ export function playMermaidEntrance(svg, graph) {
76
76
  draw.finished.then(land, land);
77
77
  }
78
78
  }
79
+ // Pele draws its arrowhead apart from the line; it arrives as the line lands, so it never leads the edge.
80
+ edge.markers.forEach((marker) => add(marker, [{ opacity: 0 }, { opacity: 1 }], { duration: edgeMs, delay: delay + edgeMs * 0.8 }));
79
81
  if (edge.labelEl) {
80
82
  add(edge.labelEl, [{ opacity: 0 }, { opacity: 1 }], { duration: LABEL_MS, delay: delay + edgeMs * 0.65 });
81
83
  }
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Pure matching of edge end points to node boxes, for Pele graphs. Plain numbers in, index out: the DOM layer
3
+ * (adapters.ts) reads the boxes and end points in one coordinate space, and this file decides which node
4
+ * each end belongs to. Edge identifiers are never read, because they do not reliably name their ends.
5
+ */
6
+ export interface Box {
7
+ left: number;
8
+ top: number;
9
+ right: number;
10
+ bottom: number;
11
+ }
12
+ export interface Point {
13
+ x: number;
14
+ y: number;
15
+ }
16
+ export interface EdgeEnds {
17
+ start: Point;
18
+ end: Point;
19
+ }
20
+ export interface LinkedEdge {
21
+ /** Index into the edges passed in. */
22
+ edge: number;
23
+ /** Index into the boxes passed in: the node the edge leaves. */
24
+ from: number;
25
+ /** Index into the boxes passed in: the node the edge enters. */
26
+ to: number;
27
+ }
28
+ /** A point this close outside a box still counts as touching it, for rounded borders and sub-pixel error. */
29
+ export declare const CONTAINMENT_TOLERANCE = 2;
30
+ /**
31
+ * The widest gap, in viewBox units, between an end point and a box that still matches. Pele stops a line short
32
+ * of its node to leave room for the arrowhead: about 7 units for flowchart arrows and about 12 for class
33
+ * relations in the probe, so 16 covers both with a margin.
34
+ */
35
+ export declare const MATCH_LIMIT = 16;
36
+ /** Distance from a point to a box: zero inside it, otherwise the gap to the nearest edge or corner. */
37
+ export declare function distanceToBox(point: Point, box: Box): number;
38
+ /**
39
+ * The box a point belongs to, as an index, or -1 when none is close enough. A point inside a box (with the
40
+ * tolerance) wins, and of several such boxes the smallest one, so a node nested in a cluster is the node.
41
+ * Otherwise the nearest box within `limit` wins, and between equally near boxes the smaller one.
42
+ */
43
+ export declare function matchPoint(point: Point, boxes: readonly Box[], { tolerance, limit }?: {
44
+ tolerance?: number;
45
+ limit?: number;
46
+ }): number;
47
+ /**
48
+ * Links each edge from the node its start point belongs to to the node its end point belongs to. An edge whose
49
+ * end matches no box, or whose two ends match the same box (a self-loop), is left out; the rest still link.
50
+ */
51
+ export declare function linkEdges(edges: readonly EdgeEnds[], boxes: readonly Box[], options?: {
52
+ tolerance?: number;
53
+ limit?: number;
54
+ }): LinkedEdge[];
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Pure matching of edge end points to node boxes, for Pele graphs. Plain numbers in, index out: the DOM layer
3
+ * (adapters.ts) reads the boxes and end points in one coordinate space, and this file decides which node
4
+ * each end belongs to. Edge identifiers are never read, because they do not reliably name their ends.
5
+ */
6
+ /** A point this close outside a box still counts as touching it, for rounded borders and sub-pixel error. */
7
+ export const CONTAINMENT_TOLERANCE = 2;
8
+ /**
9
+ * The widest gap, in viewBox units, between an end point and a box that still matches. Pele stops a line short
10
+ * of its node to leave room for the arrowhead: about 7 units for flowchart arrows and about 12 for class
11
+ * relations in the probe, so 16 covers both with a margin.
12
+ */
13
+ export const MATCH_LIMIT = 16;
14
+ const area = (box) => (box.right - box.left) * (box.bottom - box.top);
15
+ /** Distance from a point to a box: zero inside it, otherwise the gap to the nearest edge or corner. */
16
+ export function distanceToBox(point, box) {
17
+ const dx = Math.max(box.left - point.x, 0, point.x - box.right);
18
+ const dy = Math.max(box.top - point.y, 0, point.y - box.bottom);
19
+ return Math.hypot(dx, dy);
20
+ }
21
+ /**
22
+ * The box a point belongs to, as an index, or -1 when none is close enough. A point inside a box (with the
23
+ * tolerance) wins, and of several such boxes the smallest one, so a node nested in a cluster is the node.
24
+ * Otherwise the nearest box within `limit` wins, and between equally near boxes the smaller one.
25
+ */
26
+ export function matchPoint(point, boxes, { tolerance = CONTAINMENT_TOLERANCE, limit = MATCH_LIMIT } = {}) {
27
+ let best = -1;
28
+ let bestArea = Infinity;
29
+ boxes.forEach((box, index) => {
30
+ const inside = point.x >= box.left - tolerance && point.x <= box.right + tolerance && point.y >= box.top - tolerance && point.y <= box.bottom + tolerance;
31
+ if (inside && area(box) < bestArea) {
32
+ best = index;
33
+ bestArea = area(box);
34
+ }
35
+ });
36
+ if (best !== -1)
37
+ return best;
38
+ let bestDistance = Infinity;
39
+ boxes.forEach((box, index) => {
40
+ const distance = distanceToBox(point, box);
41
+ if (distance > limit)
42
+ return;
43
+ if (distance < bestDistance || (distance === bestDistance && area(box) < bestArea)) {
44
+ best = index;
45
+ bestDistance = distance;
46
+ bestArea = area(box);
47
+ }
48
+ });
49
+ return best;
50
+ }
51
+ /**
52
+ * Links each edge from the node its start point belongs to to the node its end point belongs to. An edge whose
53
+ * end matches no box, or whose two ends match the same box (a self-loop), is left out; the rest still link.
54
+ */
55
+ export function linkEdges(edges, boxes, options) {
56
+ const linked = [];
57
+ edges.forEach((ends, edge) => {
58
+ const from = matchPoint(ends.start, boxes, options);
59
+ const to = matchPoint(ends.end, boxes, options);
60
+ if (from === -1 || to === -1 || from === to)
61
+ return;
62
+ linked.push({ edge, from, to });
63
+ });
64
+ return linked;
65
+ }
package/dist/graph.d.ts CHANGED
@@ -18,6 +18,8 @@ export interface MermaidGraphEdge {
18
18
  from: number;
19
19
  to: number;
20
20
  labelEl: SVGGElement | null;
21
+ /** Arrowheads drawn apart from the line (Pele). They reveal and dim with their edge. Empty for Mermaid. */
22
+ markers: SVGElement[];
21
23
  }
22
24
  export interface MermaidGraph {
23
25
  nodes: MermaidGraphNode[];
package/dist/graph.js CHANGED
@@ -1,10 +1,5 @@
1
- /**
2
- * Graph model of a rendered Mermaid SVG. Mermaid's unified renderer (flowchart, state, class, ER,
3
- * requirement, mindmap, swimlane) emits `g.nodes > g.node` and `g.edgePaths > path`, and Sankey draws
4
- * its links as `g.links > g.link > path`, so the model is read from the DOM instead of re-parsing the
5
- * diagram source. Diagram types without that structure (sequence, gantt, ...) yield an empty graph;
6
- * `adapters.ts` describes their parts instead.
7
- */
1
+ import { linkEdges } from './geometry.js';
2
+ import { peleGraphParts } from './adapters.js';
8
3
  const NODE_KEY = /-(?:flowchart|state|class|classId|er)-(.+?)-\d+$/;
9
4
  /** Flowchart-style edges are `L_<from>_<to>_<n>`; class diagrams use `id_<from>_<to>_<n>`. */
10
5
  const EDGE_ID = /^(?:L|id)_(.+)_\d+$/;
@@ -64,7 +59,40 @@ function nearestNode(boxes, x, y) {
64
59
  });
65
60
  return best;
66
61
  }
62
+ /** Pele output carries the `data-mermaid-engine` marker the post-processor sets, or at least its `pele` root class. */
63
+ function isPeleSvg(svg) {
64
+ return svg.getAttribute('data-mermaid-engine') === 'pele' || svg.classList.contains('pele');
65
+ }
66
+ /** Builds the graph of a Pele flowchart, state, class or ER diagram from geometry. Ids are keys, never endpoints. */
67
+ function buildPeleGraph(svg) {
68
+ const parts = peleGraphParts(svg);
69
+ const nodes = (parts?.nodes ?? []).map((part, index) => ({
70
+ index,
71
+ el: part.el,
72
+ key: part.key,
73
+ label: part.label,
74
+ }));
75
+ const edges = [];
76
+ for (const link of linkEdges(parts?.edges.map((edge) => edge.ends) ?? [], parts?.nodes.map((node) => node.box) ?? [])) {
77
+ const edge = parts.edges[link.edge];
78
+ edges.push({ index: edges.length, el: edge.el, from: link.from, to: link.to, labelEl: null, markers: edge.markers });
79
+ }
80
+ return finishGraph(nodes, edges);
81
+ }
82
+ /** Per-node adjacency from the edge list. */
83
+ function finishGraph(nodes, edges) {
84
+ const out = nodes.map(() => []);
85
+ const inc = nodes.map(() => []);
86
+ edges.forEach((edge) => {
87
+ out[edge.from].push(edge.index);
88
+ inc[edge.to].push(edge.index);
89
+ });
90
+ return { nodes, edges, out, inc };
91
+ }
67
92
  export function buildMermaidGraph(svg) {
93
+ // The adapter set follows the SVG's origin: Pele's geometry for Pele output, Mermaid's classes otherwise.
94
+ if (isPeleSvg(svg))
95
+ return buildPeleGraph(svg);
68
96
  const nodeEls = Array.from(svg.querySelectorAll('g.nodes > g.node'));
69
97
  const edgeEls = Array.from(svg.querySelectorAll(EDGE_PATHS));
70
98
  const nodes = nodeEls.map((el, index) => ({
@@ -113,15 +141,10 @@ export function buildMermaidGraph(svg) {
113
141
  from: ends[0],
114
142
  to: ends[1],
115
143
  labelEl: labelById.get(dataId)?.closest('g.edgeLabel') ?? null,
144
+ markers: [],
116
145
  });
117
146
  });
118
- const out = nodes.map(() => []);
119
- const inc = nodes.map(() => []);
120
- edges.forEach((edge) => {
121
- out[edge.from].push(edge.index);
122
- inc[edge.to].push(edge.index);
123
- });
124
- return { nodes, edges, out, inc };
147
+ return finishGraph(nodes, edges);
125
148
  }
126
149
  /**
127
150
  * Longest-path layering with back edges ignored, so cycles (retry loops) cannot push a node past
package/dist/index.d.ts CHANGED
@@ -1,3 +1,4 @@
1
1
  export { renderMermaid } from './render.js';
2
2
  export { mountMermaidDiagrams } from './mount.js';
3
- export type { MermaidHandle, MermaidInteractionConfig, MermaidLabelOverrides, MermaidLabels, MermaidMode, MermaidMountHandle, MermaidMountOptions, MermaidPreset, MermaidRenderOptions, MermaidTokenRole, MermaidTokens, MermaidTraceSummary, MermaidUpdate, } from './types.js';
3
+ export { ENGINE_STORAGE_KEY, readStoredEngine, resolveEngine, writeStoredEngine } from './engine.js';
4
+ export type { MermaidEngine, MermaidHandle, MermaidInteractionConfig, MermaidLabelOverrides, MermaidLabels, MermaidMode, MermaidMountHandle, MermaidMountOptions, MermaidPreset, MermaidRenderOptions, MermaidTokenRole, MermaidTokens, MermaidTraceSummary, MermaidUpdate, PeleErrorShape, PeleModule, PeleRenderResult, } from './types.js';
package/dist/index.js CHANGED
@@ -1,2 +1,3 @@
1
1
  export { renderMermaid } from './render.js';
2
2
  export { mountMermaidDiagrams } from './mount.js';
3
+ export { ENGINE_STORAGE_KEY, readStoredEngine, resolveEngine, writeStoredEngine } from './engine.js';
@@ -129,7 +129,8 @@ export function attachMermaidDiagramInteraction(root, config = {}, prebuilt) {
129
129
  graph.edges.forEach((edge) => {
130
130
  const up = trace.upstreamEdges.has(edge.index);
131
131
  const down = trace.downstreamEdges.has(edge.index);
132
- const targets = edge.labelEl ? [edge.el, edge.labelEl] : [edge.el];
132
+ // Arrowheads drawn apart from the line (Pele) dim and light with their edge.
133
+ const targets = [edge.el, ...edge.markers, ...(edge.labelEl ? [edge.labelEl] : [])];
133
134
  targets.forEach((el) => {
134
135
  if (!up && !down)
135
136
  el.classList.add('mermaid-trace-dim');
@@ -155,7 +156,7 @@ export function attachMermaidDiagramInteraction(root, config = {}, prebuilt) {
155
156
  return;
156
157
  layer = svgEl('g', 'mermaid-trace-layer');
157
158
  layer.setAttribute('aria-hidden', 'true');
158
- host.insertBefore(layer, svg.querySelector('g.edgePaths')?.nextSibling ?? null);
159
+ host.insertBefore(layer, svg.querySelector('g.edgePaths, g.pele-edges')?.nextSibling ?? null);
159
160
  edgesWithHop.forEach(({ index, delay, direction }) => {
160
161
  const source = graph.edges[index].el;
161
162
  const length = typeof source.getTotalLength === 'function' ? source.getTotalLength() : 0;
package/dist/labels.js CHANGED
@@ -7,6 +7,7 @@ export const DEFAULT_LABELS = {
7
7
  renderingErrorPrefix: 'Rendering error:',
8
8
  noValidDiagram: 'No valid diagram detected',
9
9
  unknownDiagramType: 'Unknown diagram type',
10
+ peleDegradedNotice: 'This diagram type is simplified by the Pele engine. Switch to Mermaid for the full layout.',
10
11
  diagramLabel: 'Diagram',
11
12
  replay: 'Replay animation',
12
13
  expand: 'Enlarge diagram',
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Pele diagram types that render in a clearly degraded way, so the container shows a notice above them. The list
3
+ * is filled from the same-size screenshot comparison of the demo samples (see the change's design notes), and
4
+ * each entry is a Pele type name, as `PeleRenderResult.type` reports it. Falling back to Mermaid is not used,
5
+ * because it would load `mermaid` on a Pele-only site.
6
+ */
7
+ export declare const PELE_DEGRADED_TYPES: readonly string[];
8
+ /** True when a Pele diagram of this type should show the degraded notice. */
9
+ export declare function isPeleDegraded(type: string | undefined): boolean;
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Pele diagram types that render in a clearly degraded way, so the container shows a notice above them. The list
3
+ * is filled from the same-size screenshot comparison of the demo samples (see the change's design notes), and
4
+ * each entry is a Pele type name, as `PeleRenderResult.type` reports it. Falling back to Mermaid is not used,
5
+ * because it would load `mermaid` on a Pele-only site.
6
+ */
7
+ // Found in the 2026-10-31 same-size comparison of examples/demo-web/src/mermaid-samples.ts (36 samples, both engines
8
+ // at 440px): c4 scales to unreadable text, gitGraph branch labels are white on mid-tone fills, and treemap leaf
9
+ // labels are white and too small on their fills.
10
+ export const PELE_DEGRADED_TYPES = Object.freeze(['c4', 'gitGraph', 'treemap']);
11
+ /** True when a Pele diagram of this type should show the degraded notice. */
12
+ export function isPeleDegraded(type) {
13
+ return type !== undefined && PELE_DEGRADED_TYPES.includes(type);
14
+ }
@@ -0,0 +1,32 @@
1
+ import type { MermaidPreset, MermaidThemeMode, MermaidTokens } from './types.js';
2
+ /** Text on a surface, and slice labels on a series color, must hold this ratio (WCAG AA for normal text). */
3
+ export declare const PELE_MIN_CONTRAST = 4.5;
4
+ /** Pele reads `--pele-series-1` to `--pele-series-8` for categorical fills. */
5
+ export declare const PELE_SERIES_COUNT = 8;
6
+ /** Every custom property the mapping writes, in order. Pele reads each of them, with its own fallback. */
7
+ export declare const PELE_VARIABLE_NAMES: readonly string[];
8
+ export interface PeleThemeOptions {
9
+ /** Site token names for the theme roles, the same option Mermaid takes. */
10
+ tokens?: MermaidTokens;
11
+ /** Font stack for the diagram text. Defaults to `sans-serif`; the render path passes the container's font. */
12
+ font?: string;
13
+ }
14
+ /**
15
+ * Moves `color` toward black or white, whichever gives more contrast on `background`, until the pair reaches
16
+ * `minimum`. A color that already passes is returned unchanged. The palette's colors are hex, so the steps
17
+ * are exact; any other form is passed through, as the contrast check cannot read it.
18
+ */
19
+ export declare function ensureContrast(color: string, background: string, minimum?: number): string;
20
+ /**
21
+ * The custom properties for Pele in one mode and preset. Series colors come from the preset palette, adjusted per
22
+ * background so the slice label (drawn in the background color) holds 4.5:1 on each slice.
23
+ */
24
+ export declare function peleVariables(mode: MermaidThemeMode, preset?: MermaidPreset, options?: PeleThemeOptions): Record<string, string>;
25
+ /** Writes the variables as inline custom properties on the container. Pele's SVG inherits them; no render call. */
26
+ export declare function applyPeleVariables(element: {
27
+ style: {
28
+ setProperty: (name: string, value: string) => void;
29
+ };
30
+ }, variables: Record<string, string>): void;
31
+ /** The container's computed font stack, so Pele text matches the page. Falls back to `sans-serif` without a DOM. */
32
+ export declare function peleFontFamily(element: unknown): string;
@@ -0,0 +1,75 @@
1
+ import { mixColors } from './palette.js';
2
+ import { getContrastRatio, getMermaidSemanticTheme } from './theme.js';
3
+ /** Text on a surface, and slice labels on a series color, must hold this ratio (WCAG AA for normal text). */
4
+ export const PELE_MIN_CONTRAST = 4.5;
5
+ /** Pele reads `--pele-series-1` to `--pele-series-8` for categorical fills. */
6
+ export const PELE_SERIES_COUNT = 8;
7
+ const DEFAULT_RADIUS = '4px';
8
+ const DEFAULT_FONT = 'sans-serif';
9
+ /** Every custom property the mapping writes, in order. Pele reads each of them, with its own fallback. */
10
+ export const PELE_VARIABLE_NAMES = [
11
+ '--pele-bg',
12
+ '--pele-fg',
13
+ '--pele-muted',
14
+ '--pele-line',
15
+ '--pele-surface',
16
+ '--pele-surface-alt',
17
+ '--pele-border',
18
+ '--pele-accent',
19
+ '--pele-font',
20
+ '--pele-radius',
21
+ ...Array.from({ length: PELE_SERIES_COUNT }, (_, index) => `--pele-series-${index + 1}`),
22
+ ];
23
+ /**
24
+ * Moves `color` toward black or white, whichever gives more contrast on `background`, until the pair reaches
25
+ * `minimum`. A color that already passes is returned unchanged. The palette's colors are hex, so the steps
26
+ * are exact; any other form is passed through, as the contrast check cannot read it.
27
+ */
28
+ export function ensureContrast(color, background, minimum = PELE_MIN_CONTRAST) {
29
+ if (getContrastRatio(background, color) >= minimum)
30
+ return color;
31
+ const target = getContrastRatio(background, '#000000') >= getContrastRatio(background, '#ffffff') ? '#000000' : '#ffffff';
32
+ let candidate = color;
33
+ for (let step = 1; step <= 50; step += 1) {
34
+ candidate = mixColors(color, target, step / 50);
35
+ if (getContrastRatio(background, candidate) >= minimum)
36
+ return candidate;
37
+ }
38
+ return candidate;
39
+ }
40
+ /**
41
+ * The custom properties for Pele in one mode and preset. Series colors come from the preset palette, adjusted per
42
+ * background so the slice label (drawn in the background color) holds 4.5:1 on each slice.
43
+ */
44
+ export function peleVariables(mode, preset = 'default', options = {}) {
45
+ const theme = getMermaidSemanticTheme(mode, preset, { tokens: options.tokens });
46
+ const { background, foreground } = theme;
47
+ // Secondary text: a mix toward the background, kept only when it still reads at 4.5:1.
48
+ const mutedMix = mixColors(foreground, background, 0.35);
49
+ const muted = getContrastRatio(mutedMix, background) >= PELE_MIN_CONTRAST ? mutedMix : foreground;
50
+ const series = Object.fromEntries(theme.series.slice(0, PELE_SERIES_COUNT).map((tone, index) => [`--pele-series-${index + 1}`, ensureContrast(tone.mark, background)]));
51
+ return {
52
+ '--pele-bg': background,
53
+ '--pele-fg': foreground,
54
+ '--pele-muted': muted,
55
+ '--pele-line': theme.edge.line,
56
+ '--pele-surface': theme.node.fill,
57
+ '--pele-surface-alt': theme.cluster.fill,
58
+ '--pele-border': theme.node.border,
59
+ '--pele-accent': theme.accent,
60
+ '--pele-font': options.font ?? DEFAULT_FONT,
61
+ '--pele-radius': DEFAULT_RADIUS,
62
+ ...series,
63
+ };
64
+ }
65
+ /** Writes the variables as inline custom properties on the container. Pele's SVG inherits them; no render call. */
66
+ export function applyPeleVariables(element, variables) {
67
+ for (const [name, value] of Object.entries(variables))
68
+ element.style.setProperty(name, value);
69
+ }
70
+ /** The container's computed font stack, so Pele text matches the page. Falls back to `sans-serif` without a DOM. */
71
+ export function peleFontFamily(element) {
72
+ if (typeof getComputedStyle !== 'function' || typeof Element === 'undefined' || !(element instanceof Element))
73
+ return DEFAULT_FONT;
74
+ return getComputedStyle(element).fontFamily || DEFAULT_FONT;
75
+ }
package/dist/render.js CHANGED
@@ -5,8 +5,11 @@ import { playMermaidEntrance } from './entrance.js';
5
5
  import { attachMermaidPartFocus } from './focus.js';
6
6
  import { buildMermaidGraph } from './graph.js';
7
7
  import { attachMermaidDiagramInteraction } from './interaction.js';
8
+ import { resolveEngine } from './engine.js';
8
9
  import { resolveLabels } from './labels.js';
9
10
  import { resolveMode, watchMode } from './mode.js';
11
+ import { isPeleDegraded } from './pele-degraded.js';
12
+ import { applyPeleVariables, peleFontFamily, peleVariables } from './pele-theme.js';
10
13
  import { ensureSvgViewBox, normalizeMermaidSvgViewBox, postProcessMermaidSvg } from './svg.js';
11
14
  import { getMermaidThemeConfig, resolvePreset } from './theme.js';
12
15
  import { openMermaidViewer } from './viewer.js';
@@ -50,6 +53,38 @@ async function initializeOnce(mermaid, config) {
50
53
  function defaultLoader() {
51
54
  return import('mermaid');
52
55
  }
56
+ function defaultPeleLoader() {
57
+ return import('pele');
58
+ }
59
+ /** Pele is loaded once per loader. A failed import is not kept, so the next render tries again. */
60
+ const PELE_MODULES = new WeakMap();
61
+ function loadPele(loader) {
62
+ let pending = PELE_MODULES.get(loader);
63
+ if (!pending) {
64
+ pending = loader().catch((error) => {
65
+ PELE_MODULES.delete(loader);
66
+ throw error;
67
+ });
68
+ PELE_MODULES.set(loader, pending);
69
+ }
70
+ return pending;
71
+ }
72
+ /** Mermaid backend: the shared Mermaid instance, queued so renders never overlap. */
73
+ function renderWithMermaid(source, options, mode, preset) {
74
+ return enqueue(async () => {
75
+ const { default: mermaid } = await (options.loadMermaid ?? defaultLoader)();
76
+ const config = getMermaidThemeConfig(mode, preset, { tokens: options.tokens });
77
+ await initializeOnce(mermaid, config);
78
+ const id = `mermaid-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`;
79
+ return (await mermaid.render(id, source)).svg;
80
+ });
81
+ }
82
+ /** Pele backend: a direct synchronous call, never Pele's own mount. Mermaid is not loaded on this path. */
83
+ async function renderWithPele(source, options) {
84
+ const pele = await loadPele(options.peleLoader ?? defaultPeleLoader);
85
+ const { svg, type } = pele.render(source);
86
+ return { svg, type };
87
+ }
53
88
  /** Maps Mermaid's parser messages onto the labels, as the web app does. */
54
89
  /** Trace for a graph the model can read, part focus for every other type, nothing when interaction is off. */
55
90
  function attachInteraction(host, svg, config, onTraceChange, graph = buildMermaidGraph(svg)) {
@@ -58,17 +93,42 @@ function attachInteraction(host, svg, config, onTraceChange, graph = buildMermai
58
93
  return attachMermaidDiagramInteraction(host, wired, graph);
59
94
  return attachMermaidPartFocus(host, wired, describeParts(svg));
60
95
  }
96
+ /** Pele's `PeleError` is recognized by its shape, because the class is only reachable once `pele` has loaded. */
97
+ function isPeleError(error) {
98
+ if (!(error instanceof Error))
99
+ return false;
100
+ const candidate = error;
101
+ return error.name === 'PeleError' || (typeof candidate.code === 'string' && typeof candidate.line === 'number');
102
+ }
103
+ /**
104
+ * Keeps the position visible after the Mermaid label replaces "Parse error on line N:": the first line gets
105
+ * "(line N, column M)" unless the message already names a line. Only the first line changes.
106
+ */
107
+ function withPosition(message, error) {
108
+ if (!error.line || /\bline \d+/i.test(message))
109
+ return message;
110
+ const position = `line ${error.line}${error.column ? `, column ${error.column}` : ''}`;
111
+ const [first = '', ...rest] = message.split('\n');
112
+ const head = first.endsWith(':') ? `${first.slice(0, -1)} (${position}):` : `${first} (${position})`;
113
+ return [head, ...rest].join('\n');
114
+ }
61
115
  function describeError(error, labels) {
62
116
  let message = labels.unableToRender;
63
117
  if (error instanceof Error)
64
118
  message = error.message;
65
119
  else if (typeof error === 'string')
66
120
  message = error;
67
- return message
121
+ const described = message
68
122
  .replace(/^Parse error on line \d+:/, labels.syntaxErrorPrefix)
69
123
  .replace(/^Error:/, labels.renderingErrorPrefix)
70
124
  .replace(/no diagram type detected/i, labels.noValidDiagram)
71
125
  .replace(/unknown diagram type/i, labels.unknownDiagramType);
126
+ return isPeleError(error) ? withPosition(described, error) : described;
127
+ }
128
+ /** The options a Pele restyle may change without a render. Everything else still needs a render. */
129
+ function withoutRestyleKeys(options) {
130
+ const { mode: _mode, preset: _preset, tokens: _tokens, ...rest } = options;
131
+ return rest;
72
132
  }
73
133
  /** Objects compare by value and functions by identity, so a new loader or callback counts as a change. */
74
134
  function sameValue(left, right) {
@@ -99,6 +159,11 @@ class MermaidDiagram {
99
159
  entrance = null;
100
160
  observer = null;
101
161
  unwatchMode = null;
162
+ /** What the shown diagram was rendered from, so a Pele restyle is only taken when nothing else changed. */
163
+ renderedEngine = null;
164
+ renderedSource = '';
165
+ renderedOptions = {};
166
+ renderedPreset = 'default';
102
167
  constructor(host, source, options) {
103
168
  this.host = host;
104
169
  this.source = source;
@@ -153,6 +218,8 @@ class MermaidDiagram {
153
218
  }
154
219
  async render() {
155
220
  const version = ++this.version;
221
+ if (this.restylePele())
222
+ return;
156
223
  this.teardownDiagram();
157
224
  if (!this.source.trim()) {
158
225
  this.host.replaceChildren();
@@ -162,17 +229,15 @@ class MermaidDiagram {
162
229
  const { source, options } = this;
163
230
  const mode = resolveMode(options.mode ?? 'auto');
164
231
  const preset = resolvePreset(options.preset);
232
+ const engine = resolveEngine(options.engine);
165
233
  try {
166
- const svg = await enqueue(async () => {
167
- const { default: mermaid } = await (options.loadMermaid ?? defaultLoader)();
168
- const config = getMermaidThemeConfig(mode, preset, { tokens: options.tokens });
169
- await initializeOnce(mermaid, config);
170
- const id = `mermaid-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`;
171
- return (await mermaid.render(id, source)).svg;
172
- });
234
+ const rendered = engine === 'pele' ? await renderWithPele(source, options) : { svg: await renderWithMermaid(source, options, mode, preset), type: '' };
173
235
  if (this.isStale(version))
174
236
  return;
175
- this.showDiagram(postProcessMermaidSvg(svg), mode, preset);
237
+ this.renderedSource = source;
238
+ this.renderedOptions = withoutRestyleKeys(options);
239
+ this.renderedEngine = engine;
240
+ this.showDiagram(postProcessMermaidSvg(rendered.svg, {}, engine), mode, preset, rendered.type);
176
241
  }
177
242
  catch (error) {
178
243
  if (this.isStale(version))
@@ -181,8 +246,9 @@ class MermaidDiagram {
181
246
  this.host.replaceChildren(this.errorPanel(describeError(error, this.labels)));
182
247
  }
183
248
  }
184
- showDiagram(svg, mode, preset) {
249
+ showDiagram(svg, mode, preset, peleType = '') {
185
250
  this.renderedMode = mode;
251
+ this.renderedPreset = preset;
186
252
  this.markup = svg;
187
253
  const container = el('div', [this.baseClasses(), `mermaid-preset-${preset}`].join(' '));
188
254
  container.dataset.mermaidPreset = preset;
@@ -193,6 +259,14 @@ class MermaidDiagram {
193
259
  container.appendChild(diagramHost);
194
260
  this.container = container;
195
261
  this.diagramHost = diagramHost;
262
+ // Pele's SVG reads its colors from these variables, so they are written once here and restyled in place.
263
+ if (this.renderedEngine === 'pele')
264
+ this.paintPele(container, mode, preset);
265
+ if (this.renderedEngine === 'pele' && isPeleDegraded(peleType)) {
266
+ const notice = el('p', 'mermaid-engine-notice', this.labels.peleDegradedNotice);
267
+ notice.setAttribute('role', 'note');
268
+ container.insertBefore(notice, diagramHost);
269
+ }
196
270
  // Layout-dependent work runs once the SVG is in the document: the viewBox needs getBBox, and the
197
271
  // reveal's hidden first frame has to be in place before the browser paints.
198
272
  const svgElement = diagramHost.querySelector('svg');
@@ -218,6 +292,33 @@ class MermaidDiagram {
218
292
  }
219
293
  this.scheduleEntrance(svgElement, graph, diagramHost);
220
294
  }
295
+ /** Writes the Pele variables for a mode and preset onto the container. No Pele call is made. */
296
+ paintPele(container, mode, preset) {
297
+ applyPeleVariables(container, peleVariables(mode, preset, { tokens: this.options.tokens, font: peleFontFamily(container) }));
298
+ }
299
+ /**
300
+ * A mode, preset or token change on a shown Pele diagram only rewrites its variables and classes. Source,
301
+ * engine and every other option still take the full render path. Returns false when a render is needed.
302
+ */
303
+ restylePele() {
304
+ if (this.renderedEngine !== 'pele' || !this.container || this.renderedSource !== this.source)
305
+ return false;
306
+ if (resolveEngine(this.options.engine) !== 'pele')
307
+ return false;
308
+ if (!sameOptions(withoutRestyleKeys(this.options), this.renderedOptions))
309
+ return false;
310
+ const mode = resolveMode(this.options.mode ?? 'auto');
311
+ const preset = resolvePreset(this.options.preset);
312
+ const container = this.container;
313
+ container.classList.remove(`mermaid-preset-${this.renderedPreset}`);
314
+ container.classList.add(`mermaid-preset-${preset}`);
315
+ container.dataset.mermaidPreset = preset;
316
+ container.dataset.mermaidMode = mode;
317
+ this.renderedMode = mode;
318
+ this.renderedPreset = preset;
319
+ this.paintPele(container, mode, preset);
320
+ return true;
321
+ }
221
322
  scheduleEntrance(svg, graph, diagramHost) {
222
323
  if (this.options.entrance === false)
223
324
  return;
package/dist/svg.d.ts CHANGED
@@ -1,9 +1,11 @@
1
- import type { MermaidSvgPostProcessorConfig } from './types.js';
1
+ import type { MermaidEngine, MermaidSvgPostProcessorConfig } from './types.js';
2
2
  export declare function normalizeMermaidSvgViewBox(svg: SVGSVGElement, padding?: number): void;
3
3
  /** Gives an SVG that Mermaid left without a viewBox one from its content, so it can be fitted and scaled. */
4
4
  export declare function ensureSvgViewBox(svg: SVGSVGElement, padding?: number): void;
5
5
  /**
6
- * Adds scoped semantic classes to Mermaid output. Non-SVG input is returned
7
- * unchanged so unrelated application SVGs can never be affected.
6
+ * Adds scoped semantic classes to Mermaid or Pele output. Non-SVG input is returned
7
+ * unchanged so unrelated application SVGs can never be affected. Pele output gets only the
8
+ * engine-neutral steps: Mermaid's node, cluster and edge classes, gradients and shadows are skipped,
9
+ * because they target Mermaid's markup.
8
10
  */
9
- export declare function postProcessMermaidSvg(svg: string, options?: MermaidSvgPostProcessorConfig): string;
11
+ export declare function postProcessMermaidSvg(svg: string, options?: MermaidSvgPostProcessorConfig, engine?: MermaidEngine): string;
package/dist/svg.js CHANGED
@@ -23,10 +23,12 @@ export function ensureSvgViewBox(svg, padding = 8) {
23
23
  svg.setAttribute('viewBox', `${bounds.x - padding} ${bounds.y - padding} ${bounds.width + padding * 2} ${bounds.height + padding * 2}`);
24
24
  }
25
25
  /**
26
- * Adds scoped semantic classes to Mermaid output. Non-SVG input is returned
27
- * unchanged so unrelated application SVGs can never be affected.
26
+ * Adds scoped semantic classes to Mermaid or Pele output. Non-SVG input is returned
27
+ * unchanged so unrelated application SVGs can never be affected. Pele output gets only the
28
+ * engine-neutral steps: Mermaid's node, cluster and edge classes, gradients and shadows are skipped,
29
+ * because they target Mermaid's markup.
28
30
  */
29
- export function postProcessMermaidSvg(svg, options = {}) {
31
+ export function postProcessMermaidSvg(svg, options = {}, engine = 'mermaid') {
30
32
  if (typeof DOMParser === 'undefined')
31
33
  return svg;
32
34
  // C4 diagrams use `xlink:href` on their icons without declaring the prefix, which an XML parser rejects.
@@ -51,6 +53,26 @@ export function postProcessMermaidSvg(svg, options = {}) {
51
53
  else
52
54
  root.removeAttribute('style');
53
55
  }
56
+ if (engine === 'pele') {
57
+ // Pele's root carries a fixed width and height; the viewBox keeps the proportions, so both attributes go.
58
+ // The origin marker lets graph.ts choose Pele's geometry adapters for this SVG.
59
+ root.setAttribute('data-mermaid-engine', 'pele');
60
+ const pele = root.getAttribute('aria-roledescription');
61
+ if (pele)
62
+ root.setAttribute('data-mermaid-type', pele);
63
+ if (!root.getAttribute('viewBox')) {
64
+ root.setAttribute('viewBox', `0 0 ${root.getAttribute('width') ?? 0} ${root.getAttribute('height') ?? 0}`);
65
+ }
66
+ root.removeAttribute('height');
67
+ root.setAttribute('width', '100%');
68
+ // The neutral node class lets hover, pinning and keyboard focus find Pele nodes. Core emphasis stays Mermaid's.
69
+ document.querySelectorAll('g.pele-node').forEach((node) => node.classList.add('mermaid-node'));
70
+ document.querySelectorAll('text, foreignObject').forEach((label) => {
71
+ label.classList.add('mermaid-label');
72
+ label.setAttribute('aria-hidden', 'true');
73
+ });
74
+ return new XMLSerializer().serializeToString(root);
75
+ }
54
76
  root.setAttribute('width', '100%');
55
77
  document.querySelectorAll('.node').forEach((node, index) => {
56
78
  node.classList.add('mermaid-node');
package/dist/theme.d.ts CHANGED
@@ -1,4 +1,6 @@
1
1
  import type { MermaidConfig } from 'mermaid';
2
+ import { ARCHIFY_DARK_SERIES, ARCHIFY_LIGHT_SERIES, DEFAULT_DARK_SERIES, DEFAULT_LIGHT_SERIES } from './palette.js';
3
+ export { ARCHIFY_DARK_SERIES, ARCHIFY_LIGHT_SERIES, DEFAULT_DARK_SERIES, DEFAULT_LIGHT_SERIES };
2
4
  import type { MermaidLayoutConfig, MermaidPreset, MermaidSemanticTheme, MermaidThemeMode, MermaidTokenRole, MermaidTokens } from './types.js';
3
5
  export interface MermaidThemeOptions {
4
6
  /** Custom property names for the theme roles. Unset roles read `--hagilight-mermaid-*`. */
@@ -10,6 +12,8 @@ export declare const MERMAID_LAYOUT_DEFAULTS: MermaidLayoutConfig;
10
12
  /** Unknown names select the default preset, so a stale stored value never breaks rendering. */
11
13
  export declare function resolvePreset(value: unknown): MermaidPreset;
12
14
  export declare function getContrastRatio(foreground: string, background: string): number;
15
+ /** Resolves the site tokens for a mode into the semantic theme. Shared by Mermaid and Pele. */
16
+ export declare function buildTheme(mode: MermaidThemeMode, tokens: MermaidTokens): MermaidSemanticTheme;
13
17
  export declare function getMermaidSemanticTheme(mode: MermaidThemeMode, preset?: MermaidPreset, options?: MermaidThemeOptions): MermaidSemanticTheme;
14
18
  /**
15
19
  * Builds the Mermaid configuration for a mode and preset. Configurations are reused while the
package/dist/theme.js CHANGED
@@ -1,4 +1,6 @@
1
1
  import { ARCHIFY_DARK_SERIES, ARCHIFY_LIGHT_SERIES, DEFAULT_DARK_SERIES, DEFAULT_LIGHT_SERIES, buildSeriesVariables, } from './palette.js';
2
+ // Shared with the Pele mapping (pele-theme.ts): the preset palettes are read from here, not copied.
3
+ export { ARCHIFY_DARK_SERIES, ARCHIFY_LIGHT_SERIES, DEFAULT_DARK_SERIES, DEFAULT_LIGHT_SERIES };
2
4
  /** Custom properties read for each role when a site does not map its own. */
3
5
  export const TOKEN_PROPERTIES = {
4
6
  background: '--hagilight-mermaid-background',
@@ -140,7 +142,8 @@ export function getContrastRatio(foreground, background) {
140
142
  const darker = Math.min(foregroundLuminance, backgroundLuminance);
141
143
  return (lighter + 0.05) / (darker + 0.05);
142
144
  }
143
- function buildTheme(mode, tokens) {
145
+ /** Resolves the site tokens for a mode into the semantic theme. Shared by Mermaid and Pele. */
146
+ export function buildTheme(mode, tokens) {
144
147
  const fallback = mode === 'dark' ? DARK_THEME : LIGHT_THEME;
145
148
  const read = (role) => readCssToken(tokens[role] ?? TOKEN_PROPERTIES[role], fallback[role]);
146
149
  const background = read('background');
package/dist/types.d.ts CHANGED
@@ -5,6 +5,35 @@ export type MermaidMode = 'light' | 'dark' | 'auto';
5
5
  export type MermaidThemeMode = 'light' | 'dark';
6
6
  /** Appearance preset. `archify` uses fixed palettes and its own stylesheet. */
7
7
  export type MermaidPreset = 'default' | 'archify';
8
+ /** Diagram renderer. `mermaid` is the default; `pele` is the optional Pele engine (`pele` must be installed). */
9
+ export type MermaidEngine = 'mermaid' | 'pele';
10
+ /**
11
+ * Shape of the `pele` module this package calls. Declared here instead of imported, because `pele` is an
12
+ * optional peer and its types must not be required when a site never selects it.
13
+ */
14
+ export interface PeleRenderResult {
15
+ svg: string;
16
+ width: number;
17
+ height: number;
18
+ /** Pele's diagram type, for example `flowchart` or `pie`. */
19
+ type: string;
20
+ mermaidType: string;
21
+ }
22
+ export interface PeleModule {
23
+ render: (source: string) => PeleRenderResult;
24
+ }
25
+ /**
26
+ * Shape of a `PeleError`, recognized by its fields because the class itself is only reachable after `pele`
27
+ * loads. `line` and `column` are 1-based; Pele reports 0 when it could not locate the error.
28
+ */
29
+ export interface PeleErrorShape extends Error {
30
+ code: string;
31
+ line?: number;
32
+ column?: number;
33
+ type?: string | null;
34
+ mermaidType?: string | null;
35
+ snippet?: string;
36
+ }
8
37
  /** Semantic roles a site color can map to. */
9
38
  export type MermaidTokenRole = 'background' | 'foreground' | 'primary' | 'accent' | 'primaryForeground';
10
39
  /** Maps each role to the custom property a site defines, for example `{ background: '--background' }`. */
@@ -19,6 +48,8 @@ export interface MermaidLabels {
19
48
  renderingErrorPrefix: string;
20
49
  noValidDiagram: string;
21
50
  unknownDiagramType: string;
51
+ /** Shown above a diagram of a type the Pele engine draws in a simplified way. */
52
+ peleDegradedNotice: string;
22
53
  diagramLabel: string;
23
54
  replay: string;
24
55
  /** Opens the enlarged viewer. */
@@ -154,8 +185,15 @@ export interface MermaidRenderOptions {
154
185
  * Defaults to `width`.
155
186
  */
156
187
  fit?: 'width' | 'contain';
188
+ /**
189
+ * Diagram renderer. Resolved from this option, then the choice stored in this browser by
190
+ * `writeStoredEngine`, then `mermaid`. An unrecognized value is ignored. Explicit `mermaid` outranks storage.
191
+ */
192
+ engine?: MermaidEngine;
157
193
  /** Supplies the `mermaid` module. Defaults to a dynamic import of `mermaid`. */
158
194
  loadMermaid?: () => Promise<MermaidModule>;
195
+ /** Supplies the `pele` module when the Pele engine renders. Defaults to a dynamic import of `pele`. */
196
+ peleLoader?: () => Promise<PeleModule>;
159
197
  /** Extra class names for the diagram container. */
160
198
  className?: string;
161
199
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hagicode/hagilight-mermaid",
3
- "version": "0.20.1-dev.193.1.fb8173e",
3
+ "version": "0.20.1-dev.195.1.06f4ade",
4
4
  "type": "module",
5
5
  "description": "Framework-agnostic Mermaid rendering with the HagiCode look: themes, presets, entrance reveal and path tracing for Astro, Starlight and Docusaurus sites",
6
6
  "repository": {
@@ -59,7 +59,13 @@
59
59
  "dist/**/*.d.ts"
60
60
  ],
61
61
  "peerDependencies": {
62
- "mermaid": "^11"
62
+ "mermaid": "^11",
63
+ "pele": "^1.13.0"
64
+ },
65
+ "peerDependenciesMeta": {
66
+ "pele": {
67
+ "optional": true
68
+ }
63
69
  },
64
70
  "keywords": [
65
71
  "hagicode",
package/styles.css CHANGED
@@ -749,6 +749,16 @@
749
749
  line-height: 1.5;
750
750
  }
751
751
 
752
+ .mermaid-container .mermaid-engine-notice {
753
+ margin: 0 0 8px;
754
+ padding: 8px 12px;
755
+ border: 1px dashed var(--hagilight-mermaid-muted-ink, #64748b);
756
+ border-radius: 6px;
757
+ color: var(--hagilight-mermaid-muted-ink, #64748b);
758
+ font-size: 0.8125rem;
759
+ line-height: 1.5;
760
+ }
761
+
752
762
  .mermaid-container .mermaid-error-message {
753
763
  margin: 0 0 12px 0;
754
764
  font-size: 0.875rem;