@matfsw/docusaurus-plantuml-plugin 1.3.1 → 1.5.0

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.
@@ -0,0 +1,149 @@
1
+ /**
2
+ * Deep links into diagrams: `#graph?highlight-node=<TARGET>`.
3
+ *
4
+ * Every diagram on the page reacts to the hash — none of them needs an id of its own, and a
5
+ * diagram that does not contain the target simply does nothing. The matcher below is a
6
+ * ladder: the first level that yields a match wins, so a deterministic, author-chosen ID
7
+ * always beats the loose text matching that exists for diagrams nobody annotated.
8
+ *
9
+ * Everything here is DOM-in, elements-out and free of React, which is what makes the ladder
10
+ * testable against synthetic SVG.
11
+ */
12
+ /** The hash prefix that separates diagram deep links from ordinary heading anchors. */
13
+ const HASH_PREFIX = '#graph?';
14
+ /** The query parameter carrying the node identifier. */
15
+ const NODE_PARAM = 'highlight-node';
16
+ /**
17
+ * Extracts the deeplink target from a location hash.
18
+ *
19
+ * Returns the decoded identifier — `%0A` becomes a real newline, so multiline labels can be
20
+ * addressed — or `null` when the hash is absent, not diagram-shaped, or names no node.
21
+ */
22
+ export function parseDiagramHash(hash) {
23
+ if (!hash.startsWith(HASH_PREFIX))
24
+ return null;
25
+ const target = new URLSearchParams(hash.slice(HASH_PREFIX.length)).get(NODE_PARAM);
26
+ return target === null || target.trim() === '' ? null : target;
27
+ }
28
+ /** Escapes a value for use inside a double-quoted CSS attribute selector. */
29
+ function escapeAttributeValue(value) {
30
+ return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
31
+ }
32
+ /** The target of an `<a>` element, whichever spelling the engine used. */
33
+ function hrefOf(anchor) {
34
+ return anchor.getAttribute('href') ?? anchor.getAttribute('xlink:href');
35
+ }
36
+ /** Whether every line of the window contains its target line, case-insensitively. */
37
+ function windowMatches(texts, start, lines) {
38
+ return lines.every((line, index) => (texts[start + index]?.textContent ?? '').toLowerCase().includes(line));
39
+ }
40
+ /**
41
+ * Finds the node a deeplink target refers to inside one rendered diagram.
42
+ *
43
+ * The ladder, from most to least deterministic:
44
+ *
45
+ * 1. **Explicit id** — an element whose `id` equals the target. Graphviz emits these for
46
+ * `node [id="…"]`, giving a hidden, deterministic handle. (PlantUML's own `id`s are
47
+ * generated — `ent0001` — and deliberately not part of the contract.)
48
+ * 2. **PlantUML alias** — the engine writes every entity's alias into
49
+ * `data-qualified-name` on its `g.entity` group, so `component "X" as REACTION_1234`
50
+ * and `note "…" as REACTION_1234` are addressable by an id that never appears in the
51
+ * picture. This is PlantUML's spelling of a hidden deterministic id.
52
+ * 3. **Self-anchor** — an `<a>` whose href's hash parses to the same target, which doubles
53
+ * as the node's own permalink. Graphviz emits these for `node [URL="#graph?…"]`; the
54
+ * bundled PlantUML engine currently emits no anchors at all, so for PlantUML this level
55
+ * is future-proofing rather than an authoring channel.
56
+ * 4. **Graphviz node name** — the `<title>` Graphviz writes into every `g.node`, so plain
57
+ * DOT node names work with no annotation at all.
58
+ * 5. **Multiline label** — a target containing newlines (`%0A` in the URL) matches that many
59
+ * *consecutive* text lines, each containing its line of the target. The window stays
60
+ * inside one node's group where groups exist (Graphviz `g.node`, PlantUML `g.entity`)
61
+ * and runs over the document order otherwise — which is how a two-line label is told
62
+ * apart from two nodes that each contain one of the lines.
63
+ * 6. **Single-line substring** — the first text line containing the target,
64
+ * case-insensitively. This is what lets the unique half of a label (`12345` of
65
+ * `caminus-process-archive\n12345`) address the node without spelling out the rest.
66
+ */
67
+ export function findDeeplinkTarget(root, target) {
68
+ // A target with a newline is a multiline label, never an id or alias — and selector
69
+ // engines reject the literal newline outright.
70
+ if (!target.includes('\n')) {
71
+ // 1. Explicit id. An attribute selector rather than #…: ids here are author-chosen and
72
+ // may start with a digit or contain characters a bare id selector would reject.
73
+ const byId = root.querySelector(`[id="${escapeAttributeValue(target)}"]`);
74
+ if (byId)
75
+ return { elements: [byId], anchor: byId };
76
+ // 2. PlantUML alias.
77
+ const byAlias = root.querySelector(`[data-qualified-name="${escapeAttributeValue(target)}"]`);
78
+ if (byAlias)
79
+ return { elements: [byAlias], anchor: byAlias };
80
+ }
81
+ // 3. Self-anchor.
82
+ for (const anchor of Array.from(root.querySelectorAll('a'))) {
83
+ const href = hrefOf(anchor);
84
+ const hashIndex = href?.indexOf('#') ?? -1;
85
+ if (href !== null && hashIndex >= 0 && parseDiagramHash(href.slice(hashIndex)) === target) {
86
+ return { elements: [anchor], anchor };
87
+ }
88
+ }
89
+ // 4. Graphviz node name.
90
+ for (const title of Array.from(root.querySelectorAll('g.node > title'))) {
91
+ const node = title.parentElement;
92
+ if (node && (title.textContent ?? '').trim() === target) {
93
+ return { elements: [node], anchor: node };
94
+ }
95
+ }
96
+ const lines = target
97
+ .split('\n')
98
+ .map((line) => line.trim().toLowerCase())
99
+ .filter((line) => line !== '');
100
+ if (lines.length === 0)
101
+ return null;
102
+ // 5. Multiline label.
103
+ if (lines.length > 1) {
104
+ const groups = Array.from(root.querySelectorAll('g.node, g.entity'));
105
+ const scopes = groups.length > 0 ? groups : [root];
106
+ for (const scope of scopes) {
107
+ const texts = Array.from(scope.querySelectorAll('text'));
108
+ for (let start = 0; start + lines.length <= texts.length; start += 1) {
109
+ if (windowMatches(texts, start, lines)) {
110
+ const window = texts.slice(start, start + lines.length);
111
+ return { elements: window, anchor: window[0] };
112
+ }
113
+ }
114
+ }
115
+ return null;
116
+ }
117
+ // 6. Single-line substring.
118
+ const needle = lines[0];
119
+ for (const text of Array.from(root.querySelectorAll('text'))) {
120
+ if ((text.textContent ?? '').toLowerCase().includes(needle)) {
121
+ return { elements: [text], anchor: text };
122
+ }
123
+ }
124
+ return null;
125
+ }
126
+ /**
127
+ * Scrolls the page to the first figure that claims a given deeplink navigation — and only
128
+ * that one.
129
+ *
130
+ * Diagrams evaluate the hash independently, so without an arbiter every matching figure
131
+ * would fight over the scroll position. The caller keys the claim on one *navigation* to
132
+ * one target (history entry key + pathname + target): the many diagrams reacting to a
133
+ * single navigation yield a single scroll, while following the same deep link again — a new
134
+ * history entry, unchanged hash — scrolls again. Keying on the target alone got that second
135
+ * follow wrong: the stale claim swallowed the scroll. On a page whose diagrams were
136
+ * force-rendered by the hash, renders complete in mount order, so the first claimant is in
137
+ * practice the first matching figure in the document.
138
+ */
139
+ let scrolledKey = null;
140
+ export function claimDeeplinkScroll(claimKey, figure) {
141
+ if (scrolledKey === claimKey)
142
+ return;
143
+ scrolledKey = claimKey;
144
+ figure.scrollIntoView({ block: 'center' });
145
+ }
146
+ /** Test-only: lets each test start with the scroll unclaimed. */
147
+ export function resetDeeplinkScroll() {
148
+ scrolledKey = null;
149
+ }
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Synthesizes the hyperlinks PlantUML's bundled engine drops.
3
+ *
4
+ * The engine parses `[[url]]` on components, participants and edges but emits no `<a>`
5
+ * elements, so authored links are silently lost. What it *does* emit is enough to put them
6
+ * back: every entity carries its alias in `data-qualified-name` and its source position in
7
+ * `data-source-line`, and every edge carries `data-source-line` plus its endpoints in
8
+ * `data-entity-1`/`data-entity-2`. This module extracts the links from the fence source the
9
+ * plugin already has, correlates each with the rendered element, and wraps that element in a
10
+ * real `<a>` — clickable, keyboard-focusable, and marked for styling.
11
+ *
12
+ * Correlation is a two-layer affair because of `!include`: the engine numbers lines in the
13
+ * *preprocessed* text, so a stdlib include shifts every `data-source-line` after it and
14
+ * line-equality silently stops holding. Aliases survive preprocessing untouched, which makes
15
+ * them the include-proof primary; exact line matching is the fallback that covers unnamed
16
+ * things (edges, mostly) in include-free sources. Everything here fails safe: a link that
17
+ * cannot be correlated attaches to nothing.
18
+ */
19
+ export interface ExtractedLink {
20
+ /** Zero-based index of the line in the fence source, matching `data-source-line`. */
21
+ line: number;
22
+ /** The line's text, used for alias-candidate extraction. */
23
+ text: string;
24
+ href: string;
25
+ }
26
+ /**
27
+ * Schemes a synthesized link may carry. The engine's own output goes through DOMPurify, but
28
+ * these anchors are built by this module *after* sanitization, so the check lives here.
29
+ */
30
+ export declare function isSafeLinkHref(href: string): boolean;
31
+ /**
32
+ * Extracts at most one link per source line.
33
+ *
34
+ * One per line is a deliberate ceiling, not laziness: `data-source-line` identifies a line,
35
+ * not a token, so two links on one line cannot be told apart on the SVG side.
36
+ */
37
+ export declare function extractSourceLinks(source: string): ExtractedLink[];
38
+ /**
39
+ * Attaches extracted links to the rendered SVG. Returns how many elements were wrapped.
40
+ *
41
+ * Resolution per link, in order:
42
+ *
43
+ * 1. **Alias** (include-proof): a declaration line's single candidate that names emitted
44
+ * `data-qualified-name` elements wraps all of them. An arrow line whose identifiers name
45
+ * exactly two entities wraps the `g.link` connecting them.
46
+ * 2. **Exact line**: elements whose `data-source-line` equals the author's line index —
47
+ * which holds whenever the source was not shifted by preprocessing.
48
+ */
49
+ export declare function attachDiagramLinks(root: Element, links: ExtractedLink[]): number;
50
+ /** Undoes {@link attachDiagramLinks}: unwraps every synthesized anchor under `root`. */
51
+ export declare function detachDiagramLinks(root: Element): void;
@@ -0,0 +1,185 @@
1
+ /**
2
+ * Synthesizes the hyperlinks PlantUML's bundled engine drops.
3
+ *
4
+ * The engine parses `[[url]]` on components, participants and edges but emits no `<a>`
5
+ * elements, so authored links are silently lost. What it *does* emit is enough to put them
6
+ * back: every entity carries its alias in `data-qualified-name` and its source position in
7
+ * `data-source-line`, and every edge carries `data-source-line` plus its endpoints in
8
+ * `data-entity-1`/`data-entity-2`. This module extracts the links from the fence source the
9
+ * plugin already has, correlates each with the rendered element, and wraps that element in a
10
+ * real `<a>` — clickable, keyboard-focusable, and marked for styling.
11
+ *
12
+ * Correlation is a two-layer affair because of `!include`: the engine numbers lines in the
13
+ * *preprocessed* text, so a stdlib include shifts every `data-source-line` after it and
14
+ * line-equality silently stops holding. Aliases survive preprocessing untouched, which makes
15
+ * them the include-proof primary; exact line matching is the fallback that covers unnamed
16
+ * things (edges, mostly) in include-free sources. Everything here fails safe: a link that
17
+ * cannot be correlated attaches to nothing.
18
+ */
19
+ import { DATA_ATTR } from '../../constants.js';
20
+ /**
21
+ * Schemes a synthesized link may carry. The engine's own output goes through DOMPurify, but
22
+ * these anchors are built by this module *after* sanitization, so the check lives here.
23
+ */
24
+ export function isSafeLinkHref(href) {
25
+ if (/^(https?:)/i.test(href))
26
+ return true;
27
+ if (/^[a-z][a-z0-9+.-]*:/i.test(href))
28
+ return false; // any other explicit scheme
29
+ return (href.startsWith('/') || href.startsWith('./') || href.startsWith('../') || href.startsWith('#'));
30
+ }
31
+ /** `[[href]]`, `[[href label]]`, `[[href{tooltip}]]` — the href ends at `{`, space, or `]]`. */
32
+ const LINK_PATTERN = /\[\[([^\s{\]]+)[^\]]*\]\]/;
33
+ /** Note lines carry body links the engine renders as styled text; nothing to attach. */
34
+ const NOTE_LINE = /^\s*[rh]?note\b/i;
35
+ /**
36
+ * Extracts at most one link per source line.
37
+ *
38
+ * One per line is a deliberate ceiling, not laziness: `data-source-line` identifies a line,
39
+ * not a token, so two links on one line cannot be told apart on the SVG side.
40
+ */
41
+ export function extractSourceLinks(source) {
42
+ const links = [];
43
+ const lines = source.split(/\r\n|\r|\n/);
44
+ for (let index = 0; index < lines.length; index += 1) {
45
+ const text = lines[index];
46
+ if (NOTE_LINE.test(text))
47
+ continue;
48
+ const match = LINK_PATTERN.exec(text);
49
+ if (!match)
50
+ continue;
51
+ const href = match[1];
52
+ if (!isSafeLinkHref(href))
53
+ continue;
54
+ links.push({ line: index, text, href });
55
+ }
56
+ return links;
57
+ }
58
+ /** `as ALIAS` — the strongest signal a declaration line gives. */
59
+ const AS_ALIAS = /\bas\s+([A-Za-z_][\w]*)/;
60
+ /** C4-style macro: `Container(alias, …)` — the alias is the first argument. */
61
+ const MACRO_ALIAS = /^\s*[A-Za-z_][\w]*\s*\(\s*([A-Za-z_][\w]*)\s*[,)]/;
62
+ /** `component ALIAS [[…]]` — a declaration whose bare name is the alias. */
63
+ const DECL_ALIAS = /^\s*(?:component|actor|participant|database|queue|node|usecase|rectangle|interface|boundary|control|entity|collections|agent|artifact|card|cloud|file|folder|frame|hexagon|package|person|stack|storage|state|object|class|circle|label)\s+([A-Za-z_][\w]*)/i;
64
+ /** Lines with an arrow are relations; their identifiers name the two endpoints. */
65
+ const ARROW = /(-+[->]|\.+[.>]|<[-.]|[-.]{2,})/;
66
+ const IDENTIFIER = /[A-Za-z_][\w]*/g;
67
+ /**
68
+ * The alias candidates a link line offers, most specific first. `null` means the line's
69
+ * shape gives no safe candidates and only exact line matching may be used.
70
+ */
71
+ function aliasCandidates(text) {
72
+ const as = AS_ALIAS.exec(text);
73
+ if (as)
74
+ return [as[1]];
75
+ const macro = MACRO_ALIAS.exec(text);
76
+ if (macro)
77
+ return [macro[1]];
78
+ const decl = DECL_ALIAS.exec(text);
79
+ if (decl)
80
+ return [decl[1]];
81
+ if (ARROW.test(text)) {
82
+ // Identifiers before the link only: the label after `:` may contain arbitrary words.
83
+ const beforeLink = text.slice(0, text.indexOf('[['));
84
+ return beforeLink.match(IDENTIFIER) ?? [];
85
+ }
86
+ return null;
87
+ }
88
+ /**
89
+ * Participant lifelines run the full height of a sequence diagram; wrapping them would turn
90
+ * most of the background into a link.
91
+ */
92
+ function isWrappable(element) {
93
+ return !(element.getAttribute('class') ?? '').includes('lifeline');
94
+ }
95
+ function wrap(element, href) {
96
+ if (!isWrappable(element))
97
+ return false;
98
+ if (element.closest('a'))
99
+ return false; // already linked — never nest anchors
100
+ const parent = element.parentNode;
101
+ if (!parent)
102
+ return false;
103
+ const anchor = element.ownerDocument.createElementNS('http://www.w3.org/2000/svg', 'a');
104
+ anchor.setAttribute('href', href);
105
+ anchor.setAttribute(DATA_ATTR.diagramLink, 'true');
106
+ parent.insertBefore(anchor, element);
107
+ anchor.appendChild(element);
108
+ return true;
109
+ }
110
+ /**
111
+ * Attaches extracted links to the rendered SVG. Returns how many elements were wrapped.
112
+ *
113
+ * Resolution per link, in order:
114
+ *
115
+ * 1. **Alias** (include-proof): a declaration line's single candidate that names emitted
116
+ * `data-qualified-name` elements wraps all of them. An arrow line whose identifiers name
117
+ * exactly two entities wraps the `g.link` connecting them.
118
+ * 2. **Exact line**: elements whose `data-source-line` equals the author's line index —
119
+ * which holds whenever the source was not shifted by preprocessing.
120
+ */
121
+ export function attachDiagramLinks(root, links) {
122
+ const named = new Map();
123
+ for (const element of Array.from(root.querySelectorAll('[data-qualified-name]'))) {
124
+ const name = element.getAttribute('data-qualified-name');
125
+ const list = named.get(name);
126
+ if (list)
127
+ list.push(element);
128
+ else
129
+ named.set(name, [element]);
130
+ }
131
+ // Entity ids (`ent0001`) are what edges reference in `data-entity-1`/`data-entity-2`.
132
+ const idToName = new Map();
133
+ for (const [name, elements] of named) {
134
+ for (const element of elements) {
135
+ const id = element.getAttribute('id');
136
+ if (id)
137
+ idToName.set(id, name);
138
+ }
139
+ }
140
+ let wrapped = 0;
141
+ for (const link of links) {
142
+ const candidates = aliasCandidates(link.text);
143
+ // 1a. A single alias candidate that exists → wrap every element carrying it.
144
+ if (candidates !== null) {
145
+ const matching = candidates.filter((candidate) => named.has(candidate));
146
+ if (matching.length === 1) {
147
+ for (const element of named.get(matching[0]) ?? []) {
148
+ if (wrap(element, link.href))
149
+ wrapped += 1;
150
+ }
151
+ continue;
152
+ }
153
+ // 1b. Exactly two entities on an arrow line → the edge between them.
154
+ if (matching.length === 2) {
155
+ const [a, b] = matching;
156
+ const edge = Array.from(root.querySelectorAll('g.link')).find((element) => {
157
+ const e1 = idToName.get(element.getAttribute('data-entity-1') ?? '');
158
+ const e2 = idToName.get(element.getAttribute('data-entity-2') ?? '');
159
+ return (e1 === a && e2 === b) || (e1 === b && e2 === a);
160
+ });
161
+ if (edge && wrap(edge, link.href)) {
162
+ wrapped += 1;
163
+ continue;
164
+ }
165
+ }
166
+ }
167
+ // 2. Exact line match — the fallback for unnamed elements in unshifted sources.
168
+ for (const element of Array.from(root.querySelectorAll(`[data-source-line="${link.line}"]`))) {
169
+ if (wrap(element, link.href))
170
+ wrapped += 1;
171
+ }
172
+ }
173
+ return wrapped;
174
+ }
175
+ /** Undoes {@link attachDiagramLinks}: unwraps every synthesized anchor under `root`. */
176
+ export function detachDiagramLinks(root) {
177
+ for (const anchor of Array.from(root.querySelectorAll(`a[${DATA_ATTR.diagramLink}]`))) {
178
+ const parent = anchor.parentNode;
179
+ if (!parent)
180
+ continue;
181
+ while (anchor.firstChild)
182
+ parent.insertBefore(anchor.firstChild, anchor);
183
+ anchor.remove();
184
+ }
185
+ }
@@ -24,10 +24,23 @@ export declare function ZoomOutIcon(): ReactElement;
24
24
  export declare function ZoomInIcon(): ReactElement;
25
25
  /** Replaces `⟲` U+27F2: a circular arrow, open at the top right where the arrowhead sits. */
26
26
  export declare function ResetZoomIcon(): ReactElement;
27
+ /**
28
+ * Fit to view: the maximize corners with the diagram drawn inside them, so the two controls
29
+ * read as siblings — both are about the frame, one keeps you in the page.
30
+ */
31
+ export declare function FitIcon(): ReactElement;
27
32
  /** Replaces `⛶` U+26F6, the character that had no glyph. */
28
33
  export declare function MaximizeIcon(): ReactElement;
29
34
  /** Replaces `✕` U+2715, shown in place of maximize while the diagram fills the viewport. */
30
35
  export declare function CloseIcon(): ReactElement;
36
+ /** The search toggle: a lens. */
37
+ export declare function SearchIcon(): ReactElement;
38
+ /** Previous search match. */
39
+ export declare function ChevronUpIcon(): ReactElement;
40
+ /** Next search match. */
41
+ export declare function ChevronDownIcon(): ReactElement;
42
+ /** The minimap toggle: a map frame with the viewport rectangle marked inside it. */
43
+ export declare function MinimapIcon(): ReactElement;
31
44
  /**
32
45
  * Replaces the literal `</>` of the source toggle.
33
46
  *
@@ -24,6 +24,13 @@ export function ZoomInIcon() {
24
24
  export function ResetZoomIcon() {
25
25
  return (_jsxs("svg", { ...iconProps, children: [_jsx("path", { d: "M13 8A5 5 0 1 1 11.4 4.3" }), _jsx("path", { d: "M13 2v3h-3" })] }));
26
26
  }
27
+ /**
28
+ * Fit to view: the maximize corners with the diagram drawn inside them, so the two controls
29
+ * read as siblings — both are about the frame, one keeps you in the page.
30
+ */
31
+ export function FitIcon() {
32
+ return (_jsxs("svg", { ...iconProps, children: [_jsx("path", { d: "M2 6V2h4M14 6V2h-4M2 10v4h4M14 10v4h-4" }), _jsx("rect", { x: "5.5", y: "5.5", width: "5", height: "5", rx: "1" })] }));
33
+ }
27
34
  /** Replaces `⛶` U+26F6, the character that had no glyph. */
28
35
  export function MaximizeIcon() {
29
36
  return (_jsx("svg", { ...iconProps, children: _jsx("path", { d: "M2 6V2h4M14 6V2h-4M2 10v4h4M14 10v4h-4" }) }));
@@ -32,6 +39,22 @@ export function MaximizeIcon() {
32
39
  export function CloseIcon() {
33
40
  return (_jsx("svg", { ...iconProps, children: _jsx("path", { d: "M4 4l8 8M12 4l-8 8" }) }));
34
41
  }
42
+ /** The search toggle: a lens. */
43
+ export function SearchIcon() {
44
+ return (_jsxs("svg", { ...iconProps, children: [_jsx("circle", { cx: "7", cy: "7", r: "4" }), _jsx("path", { d: "M10 10l4 4" })] }));
45
+ }
46
+ /** Previous search match. */
47
+ export function ChevronUpIcon() {
48
+ return (_jsx("svg", { ...iconProps, children: _jsx("path", { d: "M4 10l4-4 4 4" }) }));
49
+ }
50
+ /** Next search match. */
51
+ export function ChevronDownIcon() {
52
+ return (_jsx("svg", { ...iconProps, children: _jsx("path", { d: "M4 6l4 4 4-4" }) }));
53
+ }
54
+ /** The minimap toggle: a map frame with the viewport rectangle marked inside it. */
55
+ export function MinimapIcon() {
56
+ return (_jsxs("svg", { ...iconProps, children: [_jsx("rect", { x: "2", y: "3", width: "12", height: "10", rx: "1" }), _jsx("rect", { x: "4.5", y: "7.5", width: "4", height: "3", fill: "currentColor", stroke: "none" })] }));
57
+ }
35
58
  /**
36
59
  * Replaces the literal `</>` of the source toggle.
37
60
  *
@@ -1,5 +1,6 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { useCallback, useEffect, useId, useRef, useState } from 'react';
2
+ import { useCallback, useEffect, useId, useMemo, useRef, useState, } from 'react';
3
+ import { useLocation } from '@docusaurus/router';
3
4
  import { useColorMode } from '@docusaurus/theme-common';
4
5
  import { DATA_ATTR } from '../../constants.js';
5
6
  import { getSharedCache } from '../../runtime/cache.js';
@@ -7,7 +8,12 @@ import { PlantUmlError } from '../../runtime/errors.js';
7
8
  import { renderGraphvizDiagram } from '../../runtime/graphvizRenderer.js';
8
9
  import { renderDiagram } from '../../runtime/renderer.js';
9
10
  import { usePlantUmlConfig } from '../usePlantUmlConfig.js';
10
- import { CloseIcon, MaximizeIcon, ResetZoomIcon, SourceIcon, ZoomInIcon, ZoomOutIcon, } from './icons.js';
11
+ import { ChevronDownIcon, ChevronUpIcon, CloseIcon, FitIcon, MaximizeIcon, MinimapIcon, ResetZoomIcon, SearchIcon, SourceIcon, ZoomInIcon, ZoomOutIcon, } from './icons.js';
12
+ import { parseDiagramHash } from './deeplink.js';
13
+ import Minimap from './Minimap.js';
14
+ import { useDiagramDeeplink } from './useDiagramDeeplink.js';
15
+ import { useDiagramLinks } from './useDiagramLinks.js';
16
+ import { useDiagramSearch } from './useDiagramSearch.js';
11
17
  import { useZoomPan } from './useZoomPan.js';
12
18
  import styles from './styles.module.css';
13
19
  /**
@@ -50,6 +56,9 @@ function messageFor(error) {
50
56
  export default function PlantUmlDiagram({ source, title, language, engine = 'plantuml', layout, zoom: zoomProp, showSource: showSourceProp, }) {
51
57
  const config = usePlantUmlConfig();
52
58
  const { colorMode } = useColorMode();
59
+ // The router, not `window.location`: `<Link>` navigations are `history.pushState` calls
60
+ // that fire no DOM event, so only the router sees every way the hash can change.
61
+ const routerLocation = useLocation();
53
62
  const containerRef = useRef(null);
54
63
  const hintId = useId();
55
64
  const sourcePanelId = useId();
@@ -68,8 +77,12 @@ export default function PlantUmlDiagram({ source, title, language, engine = 'pla
68
77
  const interactive = zoomProp ?? config?.options.zoom ?? true;
69
78
  const sourceAvailable = showSourceProp ?? config?.options.showSource ?? true;
70
79
  const [state, setState] = useState(INITIAL_STATE);
71
- const [inView, setInView] = useState(!lazy);
80
+ // A diagram deep link must be able to reach a diagram below the fold, so a `#graph?…`
81
+ // hash defeats lazy rendering. The router's server-side location has no hash, and the
82
+ // first paint is the placeholder either way, so hydration cannot mismatch.
83
+ const [inView, setInView] = useState(() => !lazy || parseDiagramHash(routerLocation.hash) !== null);
72
84
  const [sourceOpen, setSourceOpen] = useState(false);
85
+ const [minimapOpen, setMinimapOpen] = useState(false);
73
86
  const [copyState, setCopyState] = useState('idle');
74
87
  const copyTimer = useRef(undefined);
75
88
  // A pending "Copied" message must not fire into an unmounted component.
@@ -107,6 +120,55 @@ export default function PlantUmlDiagram({ source, title, language, engine = 'pla
107
120
  // gratuitous jump.
108
121
  resetKey: `${source}|${engine}|${resolvedLayout}|${renderDark ? 'dark' : 'light'}`,
109
122
  });
123
+ const search = useDiagramSearch({
124
+ enabled: interactive && state.status === 'ready',
125
+ zoom,
126
+ svg: state.svg,
127
+ });
128
+ useDiagramDeeplink({
129
+ ready: state.status === 'ready' && state.svg !== null,
130
+ svg: state.svg,
131
+ interactive,
132
+ zoom,
133
+ containerRef,
134
+ });
135
+ useDiagramLinks({
136
+ ready: state.status === 'ready' && state.svg !== null,
137
+ svg: state.svg,
138
+ engine,
139
+ source,
140
+ containerRef,
141
+ });
142
+ // A hash arriving *after* mount must defeat lazy rendering too — a deep link followed
143
+ // from another diagram on the same page may point below the fold. Driven by the router
144
+ // location rather than `hashchange`, which `<Link>` navigations never fire.
145
+ useEffect(() => {
146
+ if (!inView && parseDiagramHash(routerLocation.hash) !== null)
147
+ setInView(true);
148
+ }, [inView, routerLocation.hash]);
149
+ const searchToggleRef = useRef(null);
150
+ // Escape and the ✕ hand focus back to the toggle, so the keyboard is not left stranded on
151
+ // a control that just left the page.
152
+ const closeSearch = useCallback(() => {
153
+ search.close();
154
+ searchToggleRef.current?.focus();
155
+ }, [search]);
156
+ const onSearchKeyDown = useCallback((event) => {
157
+ if (event.key === 'Enter') {
158
+ event.preventDefault();
159
+ if (event.shiftKey)
160
+ search.previous();
161
+ else
162
+ search.next();
163
+ }
164
+ else if (event.key === 'Escape') {
165
+ event.preventDefault();
166
+ // Escape in the search closes the search. Without this, the document-level listener
167
+ // would also un-maximize the diagram in the same keystroke.
168
+ event.stopPropagation();
169
+ closeSearch();
170
+ }
171
+ }, [closeSearch, search]);
110
172
  useEffect(() => {
111
173
  if (!lazy || inView)
112
174
  return undefined;
@@ -201,11 +263,18 @@ export default function PlantUmlDiagram({ source, title, language, engine = 'pla
201
263
  ]);
202
264
  const label = title ?? `${engineName} diagram`;
203
265
  const busy = state.status === 'loading' || state.status === 'rendering';
266
+ /**
267
+ * One wrapper object per SVG string, not a fresh literal in the JSX: React 19 compares
268
+ * `dangerouslySetInnerHTML` by the wrapper's identity, not by `__html`, so an inline
269
+ * literal makes every re-render of the figure re-parse the whole SVG subtree — visibly
270
+ * so once anything marks the rendered elements, as the search's highlights do.
271
+ */
272
+ const svgHtml = useMemo(() => (state.svg === null ? null : { __html: state.svg }), [state.svg]);
204
273
  // The sanitized SVG always lives in this element, zoomable or not, so `role="img" > svg`
205
274
  // stays a stable contract for tests and for author CSS.
206
- const canvas = state.svg !== null && (_jsx("div", { className: !interactive && sourceOpen ? `${styles.canvas} ${styles.hiddenView}` : styles.canvas, role: "img", "aria-label": label,
275
+ const canvas = state.svg !== null && svgHtml !== null && (_jsx("div", { className: !interactive && sourceOpen ? `${styles.canvas} ${styles.hiddenView}` : styles.canvas, role: "img", "aria-label": label,
207
276
  // Sanitized above unless `sanitizeSvg: false` was explicitly opted into.
208
- dangerouslySetInnerHTML: { __html: state.svg } }));
277
+ dangerouslySetInnerHTML: svgHtml }));
209
278
  const copyControl = sourceAvailable && sourceOpen && state.status === 'ready' && (_jsx("button", { type: "button", className: `${styles.toolbarButton} ${styles.toolbarTextButton}`, "aria-label": `Copy ${engineName} source to clipboard`, onClick: copySource, children: "Copy" }));
210
279
  const sourceToggle = sourceAvailable && (_jsx("button", { type: "button", className: styles.toolbarButton, "aria-label": sourceOpen ? 'Hide diagram source' : 'Show diagram source', "aria-expanded": sourceOpen, "aria-controls": sourcePanelId, onClick: () => setSourceOpen((open) => !open), children: _jsx(SourceIcon, {}) }));
211
280
  /**
@@ -225,7 +294,11 @@ export default function PlantUmlDiagram({ source, title, language, engine = 'pla
225
294
  [DATA_ATTR.theme]: dark ? 'dark' : 'light',
226
295
  ...(interactive ? { [DATA_ATTR.interactive]: 'true' } : {}),
227
296
  ...(zoom.maximized ? { [DATA_ATTR.maximized]: 'true' } : {}),
228
- ...(sourceOpen ? { [DATA_ATTR.sourceOpen]: 'true' } : {}), children: [state.status === 'ready' && state.svg !== null && !interactive && canvas, state.status === 'ready' && state.svg !== null && !interactive && sourceView, state.status === 'ready' && state.svg !== null && !interactive && sourceAvailable && (_jsxs("div", { className: styles.plainToolbar, role: "group", "aria-label": `${label} source controls`, children: [sourceToggle, copyControl] })), state.status === 'ready' && state.svg !== null && interactive && (_jsxs("div", { ref: zoom.stageRef, className: zoom.maximized ? `${styles.stage} ${styles.maximized}` : styles.stage, children: [_jsxs("div", { className: styles.stageBody, children: [_jsx("div", { ref: zoom.viewportRef, className: sourceOpen ? `${styles.viewport} ${styles.invisibleView}` : styles.viewport, tabIndex: sourceOpen ? -1 : 0, "aria-describedby": hintId, "aria-keyshortcuts": "Plus Minus 0 ArrowUp ArrowDown ArrowLeft ArrowRight", onKeyDown: zoom.onKeyDown, [DATA_ATTR.zoom]: '1', children: _jsx("div", { ref: zoom.layerRef, className: styles.layer, children: canvas }) }), sourceView] }), _jsxs("div", { className: styles.toolbar, role: "group", "aria-label": `${label} zoom controls`, children: [!sourceOpen && (_jsx("button", { type: "button", className: styles.toolbarButton, "aria-label": "Zoom out", onClick: zoom.zoomOut, children: _jsx(ZoomOutIcon, {}) })), !sourceOpen && (_jsx("button", { type: "button", className: styles.toolbarButton, "aria-label": "Zoom in", onClick: zoom.zoomIn, children: _jsx(ZoomInIcon, {}) })), !sourceOpen && (_jsx("button", { type: "button", className: styles.toolbarButton, "aria-label": "Reset zoom", onClick: zoom.reset, children: _jsx(ResetZoomIcon, {}) })), _jsx("button", { type: "button", className: styles.toolbarButton, "aria-label": "Maximize diagram", "aria-pressed": zoom.maximized, onClick: zoom.toggleMaximize, children: zoom.maximized ? _jsx(CloseIcon, {}) : _jsx(MaximizeIcon, {}) }), sourceToggle, copyControl, _jsx("span", { ref: zoom.readoutRef, className: sourceOpen ? `${styles.readout} ${styles.hiddenView}` : styles.readout, "aria-hidden": "true", children: "100%" })] })] })), state.status === 'ready' && state.svg !== null && interactive && (_jsx("p", { id: hintId, className: styles.visuallyHidden, children: "Zoomable diagram. Use the zoom controls, or press plus and minus to zoom, the arrow keys to pan, and zero to reset." })), state.status !== 'ready' && state.status !== 'error' && (
297
+ ...(sourceOpen ? { [DATA_ATTR.sourceOpen]: 'true' } : {}),
298
+ ...(minimapOpen ? { [DATA_ATTR.minimapOpen]: 'true' } : {}),
299
+ ...(search.open ? { [DATA_ATTR.searchOpen]: 'true' } : {}), children: [state.status === 'ready' && state.svg !== null && !interactive && canvas, state.status === 'ready' && state.svg !== null && !interactive && sourceView, state.status === 'ready' && state.svg !== null && !interactive && sourceAvailable && (_jsxs("div", { className: styles.plainToolbar, role: "group", "aria-label": `${label} source controls`, children: [sourceToggle, copyControl] })), state.status === 'ready' && state.svg !== null && interactive && (_jsxs("div", { ref: zoom.stageRef, className: zoom.maximized ? `${styles.stage} ${styles.maximized}` : styles.stage, children: [_jsxs("div", { className: styles.stageBody, children: [_jsx("div", { ref: zoom.viewportRef, className: sourceOpen ? `${styles.viewport} ${styles.invisibleView}` : styles.viewport, tabIndex: sourceOpen ? -1 : 0, "aria-describedby": hintId, "aria-keyshortcuts": "Plus Minus 0 ArrowUp ArrowDown ArrowLeft ArrowRight", onKeyDown: zoom.onKeyDown, [DATA_ATTR.zoom]: '1', children: _jsx("div", { ref: zoom.layerRef, className: styles.layer, children: canvas }) }), sourceView] }), _jsxs("div", { className: styles.controls, children: [search.open && !sourceOpen && (_jsxs("div", { className: styles.searchBar, role: "search", "aria-label": `${label} search`, children: [_jsx("input", { ref: search.inputRef, type: "text", className: styles.searchInput, placeholder: "Search diagram", "aria-label": "Search diagram text", value: search.query, onChange: (event) => search.setQuery(event.target.value), onKeyDown: onSearchKeyDown }), _jsx("span", { className: styles.searchCount, role: "status", children: search.matchCount === 0
300
+ ? '0/0'
301
+ : `${search.currentIndex + 1}/${search.matchCount}` }), _jsx("button", { type: "button", className: styles.toolbarButton, "aria-label": "Previous match", onClick: search.previous, children: _jsx(ChevronUpIcon, {}) }), _jsx("button", { type: "button", className: styles.toolbarButton, "aria-label": "Next match", onClick: search.next, children: _jsx(ChevronDownIcon, {}) }), _jsx("button", { type: "button", className: styles.toolbarButton, "aria-label": "Close search", onClick: closeSearch, children: _jsx(CloseIcon, {}) })] })), _jsxs("div", { className: styles.toolbar, role: "group", "aria-label": `${label} zoom controls`, children: [!sourceOpen && (_jsx("button", { ref: searchToggleRef, type: "button", className: styles.toolbarButton, "aria-label": "Search diagram", "aria-expanded": search.open, onClick: search.toggle, children: _jsx(SearchIcon, {}) })), !sourceOpen && (_jsx("button", { type: "button", className: styles.toolbarButton, "aria-label": "Zoom out", onClick: zoom.zoomOut, children: _jsx(ZoomOutIcon, {}) })), !sourceOpen && (_jsx("button", { type: "button", className: styles.toolbarButton, "aria-label": "Zoom in", onClick: zoom.zoomIn, children: _jsx(ZoomInIcon, {}) })), !sourceOpen && (_jsx("button", { type: "button", className: styles.toolbarButton, "aria-label": "Reset zoom", onClick: zoom.reset, children: _jsx(ResetZoomIcon, {}) })), !sourceOpen && zoom.maximized && (_jsx("button", { type: "button", className: styles.toolbarButton, "aria-label": "Fit diagram to screen", onClick: zoom.fit, children: _jsx(FitIcon, {}) })), _jsx("button", { type: "button", className: styles.toolbarButton, "aria-label": "Maximize diagram", "aria-pressed": zoom.maximized, onClick: zoom.toggleMaximize, children: zoom.maximized ? _jsx(CloseIcon, {}) : _jsx(MaximizeIcon, {}) }), sourceToggle, copyControl, _jsx("span", { ref: zoom.readoutRef, className: sourceOpen ? `${styles.readout} ${styles.hiddenView}` : styles.readout, "aria-hidden": "true", children: "100%" })] })] }), !sourceOpen && minimapOpen && (_jsx(Minimap, { svg: state.svg, zoom: zoom, onClose: () => setMinimapOpen(false) })), !sourceOpen && (_jsx("div", { className: styles.minimapBar, children: _jsx("button", { type: "button", className: styles.toolbarButton, "aria-label": minimapOpen ? 'Hide minimap' : 'Show minimap', "aria-expanded": minimapOpen, onClick: () => setMinimapOpen((open) => !open), children: _jsx(MinimapIcon, {}) }) }))] })), state.status === 'ready' && state.svg !== null && interactive && (_jsx("p", { id: hintId, className: styles.visuallyHidden, children: "Zoomable diagram. Use the zoom controls, or press plus and minus to zoom, the arrow keys to pan, and zero to reset." })), state.status !== 'ready' && state.status !== 'error' && (
229
302
  // `aria-busy` on the figure already conveys progress; announcing every phase change
230
303
  // would make a page with several diagrams unusable with a screen reader.
231
304
  _jsxs("div", { className: styles.placeholder, children: [_jsx("span", { className: styles.spinner, "aria-hidden": "true" }), _jsxs("span", { children: [state.status === 'loading' ? `Loading ${engineName} runtime…` : null, state.status === 'rendering' ? `Rendering ${label}…` : null, state.status === 'idle' ? `${label} (waiting to render)` : null] })] })), state.status === 'error' && (_jsxs("div", { className: styles.error, role: "alert", children: [_jsxs("p", { className: styles.errorHeading, children: [_jsx("span", { className: styles.errorIcon, "aria-hidden": "true", children: "\u26A0" }), _jsxs("span", { children: ["Error: ", engineName, " diagram could not be rendered"] })] }), _jsx("pre", { className: styles.errorMessage, children: state.error }), config?.options.showSourceOnError !== false && (_jsxs("details", { className: styles.errorDetails, children: [_jsx("summary", { children: "Show diagram source" }), _jsx("pre", { className: styles.source, children: source })] }))] })), title !== undefined && _jsx("figcaption", { className: styles.caption, children: title }), _jsx("noscript", { dangerouslySetInnerHTML: {