@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 +35 -1
- package/README.md +88 -17
- package/dist/constants.d.ts +2 -0
- package/dist/constants.js +2 -0
- package/dist/theme/PlantUmlDiagram/diagramLinks.d.ts +51 -0
- package/dist/theme/PlantUmlDiagram/diagramLinks.js +185 -0
- package/dist/theme/PlantUmlDiagram/index.js +8 -0
- package/dist/theme/PlantUmlDiagram/styles.module.css +31 -1
- package/dist/theme/PlantUmlDiagram/useDiagramLinks.d.ts +29 -0
- package/dist/theme/PlantUmlDiagram/useDiagramLinks.js +62 -0
- package/package.json +1 -1
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.
|
|
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
|
-
###
|
|
700
|
+
### Linking from a node
|
|
701
701
|
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
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 {
|
|
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
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
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
|
|
727
|
-
|
|
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
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
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.
|
package/dist/constants.d.ts
CHANGED
|
@@ -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
|
-
|
|
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