@matfsw/docusaurus-plantuml-plugin 1.4.0 → 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.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,39 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [1.5.0] - 2026-08-14
11
+
12
+ ### Added
13
+
14
+ - **Deep links *from* diagram nodes — including to diagrams on other pages.** A node can now
15
+ carry a link to another diagram's deep link, and clicking it navigates there and focuses
16
+ the target node: `component "Order Service" as ORDER_SVC
17
+ [[/docs/orders#graph?highlight-node=ORDER_DETAIL_1]]` in PlantUML, or
18
+ `orders [URL="/docs/orders#graph?highlight-node=ORDER_DETAIL_1"]` in Graphviz.
19
+
20
+ For PlantUML this means the plugin now **synthesizes the anchors the bundled engine
21
+ drops**: `[[url]]` on components, participants, C4 macros and relations is read back out
22
+ of the fence source and wrapped around the rendered element — clickable,
23
+ keyboard-focusable, marked `data-plantuml-diagram-link`, coloured with the site's link
24
+ colour. Correlation runs on the alias the engine writes into `data-qualified-name`, with
25
+ `data-source-line` as the fallback, so it survives `!include` — standard-library diagrams
26
+ included, whose preprocessing shifts every line number. Synthesized hrefs pass a scheme
27
+ allowlist; a link that cannot be correlated attaches to nothing. Links inside note bodies
28
+ remain the engine's styled-but-inert text, and one link per source line is correlatable.
29
+
30
+ Navigation is SPA-aware in both engines: in-diagram links to same-site URLs go through
31
+ the Docusaurus router — no full page load — and site-absolute paths get the site's
32
+ `baseUrl` exactly as markdown links do. External links and pure `#…` anchors stay native,
33
+ and clicks that end a drag never navigate.
34
+
35
+ ### Changed
36
+
37
+ - **The deep-link highlight is green, not red.** The focused-node colour was the theme's
38
+ danger red, which reads as an error on a highlight that persists until the hash changes;
39
+ it now defaults to the theme's success green. Sites can pick any colour by setting
40
+ `--plantuml-focus-color` — the rule reads
41
+ `var(--plantuml-focus-color, var(--ifm-color-success))`.
42
+
10
43
  ## [1.4.0] - 2026-08-13
11
44
 
12
45
  ### Added
@@ -449,7 +482,8 @@ Initial release.
449
482
  opt-out.
450
483
  - Plugin option validation that rejects unknown keys and out-of-range values at build time.
451
484
 
452
- [Unreleased]: https://github.com/matf/docusaurus-plantuml-plugin/compare/v1.4.0...HEAD
485
+ [Unreleased]: https://github.com/matf/docusaurus-plantuml-plugin/compare/v1.5.0...HEAD
486
+ [1.5.0]: https://github.com/matf/docusaurus-plantuml-plugin/compare/v1.4.0...v1.5.0
453
487
  [1.4.0]: https://github.com/matf/docusaurus-plantuml-plugin/compare/v1.3.1...v1.4.0
454
488
  [1.3.1]: https://github.com/matf/docusaurus-plantuml-plugin/compare/v1.3.0...v1.3.1
455
489
  [1.3.0]: https://github.com/matf/docusaurus-plantuml-plugin/compare/v1.2.0...v1.3.0
package/README.md CHANGED
@@ -697,24 +697,86 @@ diagram _fails_ to render.
697
697
 
698
698
  ## Links and deep links
699
699
 
700
- ### Links written in the diagram source
700
+ ### Linking from a node
701
701
 
702
- **Graphviz** emits real hyperlinks: `URL=`/`href=` on a node or edge becomes an `<a>` in the
703
- rendered SVG, preserved through sanitization (`javascript:` URLs are stripped). Links stay
704
- clickable inside the zoomable viewport: a plain click follows the link, while a click that
705
- ends a drag does not.
702
+ A node (or edge) can carry a link — to an external site, another docs page, or another
703
+ diagram's node via a [deep link](#deep-links-graphhighlight-node). What to write differs by
704
+ engine.
705
+
706
+ #### PlantUML
707
+
708
+ **Recommended form — give the node an alias** and put the link on the same line:
709
+
710
+ ````markdown
711
+ ```plantuml
712
+ @startuml
713
+ component "Order Service" as ORDER_SVC [[/docs/orders#graph?highlight-node=ORDER_DETAIL_1]]
714
+
715
+ ' The same through a C4 macro — the alias is the macro's first argument:
716
+ !include <C4/C4_Container>
717
+ Container(billing, "Billing", "Java") [[/docs/billing#graph?highlight-node=INVOICE_QUEUE]]
718
+ @enduml
719
+ ```
720
+ ````
721
+
722
+ The bundled engine parses `[[url]]` but emits no `<a>` elements, so the plugin
723
+ **synthesizes** the anchor: it reads the link back out of the fence source and wraps the
724
+ rendered element in a real anchor — clickable, keyboard-focusable, coloured with the site's
725
+ link colour, underlined on hover, and marked `data-plantuml-diagram-link` for site CSS. The
726
+ alias is the correlation key (the engine writes it into the SVG as `data-qualified-name`),
727
+ which is what makes this form work in **every** diagram — including ones whose
728
+ `!include <…>` shifts the engine's internal line numbering.
729
+
730
+ **Fallback form — no alias.** A plain quoted declaration or an edge label also works:
731
+
732
+ ````markdown
733
+ ```plantuml
734
+ @startuml
735
+ component "Order Service" [[/docs/orders#graph?highlight-node=ORDER_DETAIL_1]]
736
+ "Order Service" --> "Billing" : invoices [[/docs/billing]]
737
+ @enduml
738
+ ```
739
+ ````
740
+
741
+ With nothing alias-shaped on the line, the plugin correlates by the line number the engine
742
+ stamps on each element (`data-source-line`) — which is exact **only in diagrams without
743
+ `!include`**. In a standard-library diagram these links attach to nothing; use the aliased
744
+ form there.
745
+
746
+ Boundaries, by design: hrefs pass a scheme allowlist (`http(s)`, site-relative, `#…` —
747
+ anything else is dropped); links inside _note bodies_ stay the styled-but-inert text the
748
+ engine renders; one link per source line is correlatable; and a link that cannot be
749
+ correlated attaches to nothing rather than to the wrong node.
750
+
751
+ #### Graphviz
752
+
753
+ One form, no fallback involved — the engine emits the anchor itself, preserved through
754
+ sanitization (`javascript:` URLs are stripped):
706
755
 
707
756
  ````markdown
708
757
  ```dot
709
- digraph { docs [URL="https://graphviz.org/doc/info/attrs.html"]; }
758
+ digraph {
759
+ orders [URL="/docs/orders#graph?highlight-node=ORDER_DETAIL_1", label="Order Service", fontcolor=blue];
760
+ }
710
761
  ```
711
762
  ````
712
763
 
713
- **PlantUML** links are an engine limitation today: the bundled browser engine renders
714
- `[[url]]` link _text_ (styled blue and underlined) but emits no `<a>` elements, so PlantUML
715
- links are not clickable. Deep-link addressing does not depend on them - PlantUML nodes are
716
- addressed by alias, below. Should the engine gain anchor output, links will pass through
717
- sanitization unchanged (that path is pinned by tests, `target="_top"` included).
764
+ #### How clicks navigate
765
+
766
+ Both engines share the same navigation behaviour: a click on an in-diagram link to a
767
+ same-site URL goes through the Docusaurus router — no full page load — and site-absolute
768
+ paths get the site's `baseUrl` exactly as markdown links do, so `/docs/orders` means the
769
+ same thing in a diagram as in prose. External links and pure `#…` anchors stay native,
770
+ modified clicks (Ctrl, Cmd, Shift) keep the browser's own behaviour, and a click that ends
771
+ a drag never navigates.
772
+
773
+ Clicking either example node above therefore navigates — without a reload — to
774
+ `/docs/orders`, where the diagram containing `ORDER_DETAIL_1` highlights and centres it.
775
+
776
+ **Rule of thumb: in PlantUML, give a node an alias if you link it or link to it.** The
777
+ aliased spelling is immune to includes on both ends — as the outgoing link's correlation
778
+ key here, and as the incoming deep link's `data-qualified-name` target below. The
779
+ unaliased form exists so quick, plain diagrams work without ceremony.
718
780
 
719
781
  ### Deep links: `#graph?highlight-node=…`
720
782
 
@@ -723,8 +785,16 @@ A URL hash of the form `#graph?highlight-node=REACTION_1234` focuses a node:
723
785
  - **Every diagram on the page reacts** - no diagram needs an id of its own. A diagram that
724
786
  does not contain the node simply does nothing.
725
787
  - The page scrolls to the first matching figure, the node is visibly highlighted
726
- (`data-plantuml-focused-node`, restylable from site CSS), and a zoomable diagram snaps to
727
- **100% centred on the node**. The highlight stays until the hash changes.
788
+ (`data-plantuml-focused-node`), and a zoomable diagram snaps to **100% centred on the
789
+ node**. The highlight stays until the hash changes. It uses the theme's success green by
790
+ default; set `--plantuml-focus-color` in site CSS to pick any other colour:
791
+
792
+ ```css
793
+ :root {
794
+ --plantuml-focus-color: #3578e5; /* a light blue instead */
795
+ }
796
+ ```
797
+
728
798
  - The hash is watched live, so following a `#graph?…` link anywhere on the page - including
729
799
  one inside a Graphviz diagram - focuses the node without a reload.
730
800
  - A `#graph?…` hash defeats lazy loading for the page's diagrams, so a target below the
@@ -1253,10 +1323,11 @@ package.
1253
1323
  - **Diagrams are not rendered during static-site generation.** The HTML Docusaurus emits
1254
1324
  contains the deferred placeholder and the `<noscript>` source. Search engines that do not
1255
1325
  execute JavaScript will not see the diagram image.
1256
- - **PlantUML `[[url]]` hyperlinks are not clickable.** The bundled browser engine renders
1257
- the link text (styled blue and underlined) but emits no `<a>` elements into its SVG.
1258
- Graphviz links work; PlantUML deep-link addressing works through aliases and does not
1259
- need links. See [Links and deep links](#links-and-deep-links).
1326
+ - **PlantUML links inside note bodies are not clickable.** The plugin synthesizes the
1327
+ `[[url]]` anchors the bundled engine drops on components, participants, macros and
1328
+ relations — but a link in a note's _text_ renders as the styled-but-inert text the engine
1329
+ emits, with nothing to correlate it to. One link per source line is correlatable, for the
1330
+ same reason. See [Links and deep links](#links-and-deep-links).
1260
1331
  - **One PlantUML diagram renders at a time.** The PlantUML engine has module-level shared
1261
1332
  state, so a page with many large diagrams renders them sequentially. This is a correctness
1262
1333
  requirement, not a tuning knob. Graphviz has no such constraint and is not queued.
@@ -51,4 +51,6 @@ export declare const DATA_ATTR: {
51
51
  readonly searchCurrent: "data-plantuml-search-current";
52
52
  /** On the node (or its text lines) a `#graph?highlight-node=…` deep link resolved to. */
53
53
  readonly focusedNode: "data-plantuml-focused-node";
54
+ /** On anchors the plugin synthesized from `[[url]]` links the PlantUML engine dropped. */
55
+ readonly diagramLink: "data-plantuml-diagram-link";
54
56
  };
package/dist/constants.js CHANGED
@@ -53,4 +53,6 @@ export const DATA_ATTR = {
53
53
  searchCurrent: 'data-plantuml-search-current',
54
54
  /** On the node (or its text lines) a `#graph?highlight-node=…` deep link resolved to. */
55
55
  focusedNode: 'data-plantuml-focused-node',
56
+ /** On anchors the plugin synthesized from `[[url]]` links the PlantUML engine dropped. */
57
+ diagramLink: 'data-plantuml-diagram-link',
56
58
  };
@@ -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
+ }
@@ -12,6 +12,7 @@ import { ChevronDownIcon, ChevronUpIcon, CloseIcon, FitIcon, MaximizeIcon, Minim
12
12
  import { parseDiagramHash } from './deeplink.js';
13
13
  import Minimap from './Minimap.js';
14
14
  import { useDiagramDeeplink } from './useDiagramDeeplink.js';
15
+ import { useDiagramLinks } from './useDiagramLinks.js';
15
16
  import { useDiagramSearch } from './useDiagramSearch.js';
16
17
  import { useZoomPan } from './useZoomPan.js';
17
18
  import styles from './styles.module.css';
@@ -131,6 +132,13 @@ export default function PlantUmlDiagram({ source, title, language, engine = 'pla
131
132
  zoom,
132
133
  containerRef,
133
134
  });
135
+ useDiagramLinks({
136
+ ready: state.status === 'ready' && state.svg !== null,
137
+ svg: state.svg,
138
+ engine,
139
+ source,
140
+ containerRef,
141
+ });
134
142
  // A hash arriving *after* mount must defeat lazy rendering too — a deep link followed
135
143
  // from another diagram on the same page may point below the fold. Driven by the router
136
144
  // location rather than `hashchange`, which `<Link>` navigations never fire.
@@ -196,7 +196,37 @@
196
196
  */
197
197
  .figure .canvas svg text[data-plantuml-focused-node],
198
198
  .figure .canvas svg [data-plantuml-focused-node] text {
199
- fill: var(--ifm-color-danger-dark);
199
+ /*
200
+ * The theme's success green, not danger red: this highlight persists until the hash
201
+ * changes, and a standing red reads as an error. Text stays *filled* with an accent
202
+ * rather than boxed in a light tint because SVG text has no background to tint — and
203
+ * the variable lets a site pick its own colour without fighting specificity.
204
+ */
205
+ fill: var(--plantuml-focus-color, var(--ifm-color-success));
206
+ text-decoration: underline;
207
+ }
208
+
209
+ /*
210
+ * Links inside the picture — Graphviz-native and the anchors synthesized from PlantUML
211
+ * `[[url]]` links the engine drops. The synthesized ones get the site's link colour so a
212
+ * linked node is discoverable; Graphviz authors colour their own (`fontcolor=`), so only
213
+ * the cursor and focus treatment apply there.
214
+ */
215
+ .canvas svg a {
216
+ cursor: pointer;
217
+ }
218
+
219
+ .canvas svg a:focus-visible {
220
+ outline: 2px solid var(--ifm-color-primary);
221
+ outline-offset: 1px;
222
+ }
223
+
224
+ .canvas svg a[data-plantuml-diagram-link] text {
225
+ fill: var(--ifm-link-color);
226
+ }
227
+
228
+ .canvas svg a[data-plantuml-diagram-link]:hover text,
229
+ .canvas svg a[data-plantuml-diagram-link]:focus-visible text {
200
230
  text-decoration: underline;
201
231
  }
202
232
 
@@ -0,0 +1,29 @@
1
+ import { type MutableRefObject } from 'react';
2
+ import type { DiagramEngine } from '../../runtime/types.js';
3
+ /**
4
+ * Author-written links inside one diagram: synthesis and navigation.
5
+ *
6
+ * **Synthesis** (PlantUML only): the bundled engine drops `[[url]]` links, so they are
7
+ * rebuilt from the fence source and attached to the rendered elements — see
8
+ * `diagramLinks.ts` for the correlation. Graphviz needs none of this; `URL=` already emits
9
+ * real anchors.
10
+ *
11
+ * **Navigation** (both engines): a click on any in-diagram link to a same-site URL goes
12
+ * through the router instead of a full page load, so a node can link to a diagram on
13
+ * another page and arrive instantly — with the target page's diagrams reacting to the
14
+ * hash through the same router-driven deep-link tracking. Site-absolute paths get the
15
+ * site's `baseUrl`, exactly as markdown links do, so authors write the same `/docs/…`
16
+ * everywhere. External links and pure `#…` anchors stay native: the browser handles both
17
+ * correctly on its own, and the router observes hash navigations anyway.
18
+ */
19
+ export interface UseDiagramLinksParams {
20
+ /** Whether the diagram is rendered and its SVG is in the DOM. */
21
+ ready: boolean;
22
+ /** The rendered SVG string — a dependency, so a re-rendered picture is re-linked. */
23
+ svg: string | null;
24
+ engine: DiagramEngine;
25
+ /** The fence source, exactly as authored — where the links are read from. */
26
+ source: string;
27
+ containerRef: MutableRefObject<HTMLElement | null>;
28
+ }
29
+ export declare function useDiagramLinks({ ready, svg, engine, source, containerRef, }: UseDiagramLinksParams): void;
@@ -0,0 +1,62 @@
1
+ import { useEffect } from 'react';
2
+ import { useHistory } from '@docusaurus/router';
3
+ import { useBaseUrlUtils } from '@docusaurus/useBaseUrl';
4
+ import { attachDiagramLinks, detachDiagramLinks, extractSourceLinks } from './diagramLinks.js';
5
+ export function useDiagramLinks({ ready, svg, engine, source, containerRef, }) {
6
+ const history = useHistory();
7
+ const { withBaseUrl } = useBaseUrlUtils();
8
+ // Synthesis. The cleanup unwraps, so a re-rendered or unmounting diagram never leaks
9
+ // anchors; a replaced SVG re-runs the effect (via `svg`) against the new elements.
10
+ useEffect(() => {
11
+ if (!ready || engine !== 'plantuml')
12
+ return undefined;
13
+ const canvas = containerRef.current?.querySelector('[role="img"]');
14
+ if (!canvas)
15
+ return undefined;
16
+ const links = extractSourceLinks(source);
17
+ if (links.length === 0)
18
+ return undefined;
19
+ attachDiagramLinks(canvas, links);
20
+ return () => detachDiagramLinks(canvas);
21
+ }, [containerRef, engine, ready, source, svg]);
22
+ // Navigation. One listener on the figure covers synthesized and engine-native anchors
23
+ // alike. Bubble phase on purpose: the zoom hook suppresses post-drag clicks in the
24
+ // capture phase, and a suppressed click must not navigate.
25
+ useEffect(() => {
26
+ if (!ready)
27
+ return undefined;
28
+ const figure = containerRef.current;
29
+ if (!figure)
30
+ return undefined;
31
+ const onClick = (event) => {
32
+ // Modified clicks are the reader asking the browser for its own behaviour
33
+ // (new tab, download); never take those over.
34
+ if (event.defaultPrevented || event.button !== 0)
35
+ return;
36
+ if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey)
37
+ return;
38
+ const target = event.target instanceof Element ? event.target : null;
39
+ const anchor = target?.closest('a');
40
+ if (!anchor || !figure.querySelector('[role="img"]')?.contains(anchor))
41
+ return;
42
+ const href = anchor.getAttribute('href') ?? anchor.getAttribute('xlink:href');
43
+ // Hash-only anchors navigate natively; the router sees hash navigations by itself.
44
+ if (!href || href.startsWith('#'))
45
+ return;
46
+ const resolved = href.startsWith('/') ? withBaseUrl(href) : href;
47
+ let url;
48
+ try {
49
+ url = new URL(resolved, window.location.href);
50
+ }
51
+ catch {
52
+ return;
53
+ }
54
+ if (url.origin !== window.location.origin)
55
+ return;
56
+ event.preventDefault();
57
+ history.push(url.pathname + url.search + url.hash);
58
+ };
59
+ figure.addEventListener('click', onClick);
60
+ return () => figure.removeEventListener('click', onClick);
61
+ }, [containerRef, history, ready, withBaseUrl]);
62
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@matfsw/docusaurus-plantuml-plugin",
3
- "version": "1.4.0",
3
+ "version": "1.5.0",
4
4
  "description": "Render PlantUML diagrams in Docusaurus 3 entirely in the browser - no Java, no PlantUML server, no CDN.",
5
5
  "keywords": [
6
6
  "docusaurus",