@matfsw/docusaurus-plantuml-plugin 1.3.0 → 1.4.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,32 @@
1
+ import { type MutableRefObject } from 'react';
2
+ import type { ZoomPanHandle } from './useZoomPan.js';
3
+ /**
4
+ * Makes one diagram react to `#graph?highlight-node=…` deep links.
5
+ *
6
+ * On a matching hash the target node is marked (`data-plantuml-focused-node`, styled from
7
+ * the stylesheet), the page scrolls to the first matching figure, and — when the diagram is
8
+ * zoomable — the view snaps to 100% centred on the node. A diagram without the node does
9
+ * nothing, which is what lets every diagram on the page react without any of them carrying
10
+ * an id.
11
+ *
12
+ * The hash comes from the router, not from a `hashchange` listener: Docusaurus `<Link>`
13
+ * navigations are `history.pushState` calls, which fire no `hashchange` — so a listener
14
+ * kept a stale hash whenever a link changed or dropped it while the page (and this
15
+ * component) stayed mounted, leaving the old node highlighted. `useLocation` re-renders on
16
+ * push, replace and pop alike, and native `#…` anchor clicks reach it too, via the
17
+ * `popstate` the browser fires for hash navigations.
18
+ *
19
+ * Highlighting is imperative for the same reason the search's is: the marked elements live
20
+ * in a `dangerouslySetInnerHTML` subtree React cannot render into.
21
+ */
22
+ export interface UseDiagramDeeplinkParams {
23
+ /** Whether the diagram is rendered and its SVG is in the DOM. */
24
+ ready: boolean;
25
+ /** The rendered SVG string — a dependency, so a re-rendered picture is re-matched. */
26
+ svg: string | null;
27
+ /** Whether the diagram is zoomable; centring and the 100% snap need the viewport. */
28
+ interactive: boolean;
29
+ zoom: ZoomPanHandle;
30
+ containerRef: MutableRefObject<HTMLElement | null>;
31
+ }
32
+ export declare function useDiagramDeeplink({ ready, svg, interactive, zoom, containerRef, }: UseDiagramDeeplinkParams): void;
@@ -0,0 +1,54 @@
1
+ import { useEffect } from 'react';
2
+ import { useLocation } from '@docusaurus/router';
3
+ import { DATA_ATTR } from '../../constants.js';
4
+ import { claimDeeplinkScroll, findDeeplinkTarget, parseDiagramHash } from './deeplink.js';
5
+ import { centerViewportOn } from './zoomMath.js';
6
+ export function useDiagramDeeplink({ ready, svg, interactive, zoom, containerRef, }) {
7
+ const location = useLocation();
8
+ const target = parseDiagramHash(location.hash);
9
+ // One value per history entry. Undefined on entries the router did not create itself
10
+ // (native hash clicks, the initial load); those still differ in `target`, which the
11
+ // scroll claim includes.
12
+ const navigationKey = location.key ?? 'pop';
13
+ const pathname = location.pathname;
14
+ /**
15
+ * Marks, scrolls and centres. The cleanup unmarks, so a changed hash, a navigation that
16
+ * drops the hash and an unmounting diagram all sweep up through the same code path; a
17
+ * replaced SVG re-runs the effect (via `svg`) against the new elements.
18
+ *
19
+ * `navigationKey` is a dependency on purpose: following the same deep link again is a new
20
+ * history entry with an unchanged `target`, and it should scroll and re-centre again.
21
+ */
22
+ useEffect(() => {
23
+ if (!ready || target === null)
24
+ return undefined;
25
+ const figure = containerRef.current;
26
+ const canvas = figure?.querySelector('[role="img"]');
27
+ if (!figure || !canvas)
28
+ return undefined;
29
+ const match = findDeeplinkTarget(canvas, target);
30
+ if (!match)
31
+ return undefined;
32
+ for (const element of match.elements) {
33
+ element.setAttribute(DATA_ATTR.focusedNode, 'true');
34
+ }
35
+ claimDeeplinkScroll(`${navigationKey}|${pathname}|${target}`, figure);
36
+ if (interactive) {
37
+ const layer = zoom.layerRef.current;
38
+ if (layer) {
39
+ // The node's offset inside the layer, divided back by the current scale, is its
40
+ // position in content coordinates whatever the zoom was; the view then snaps to
41
+ // 100% centred there — the zoom level a deep link promises.
42
+ const transform = zoom.getTransform();
43
+ const layerBox = layer.getBoundingClientRect();
44
+ const box = match.anchor.getBoundingClientRect();
45
+ zoom.applyTransform(centerViewportOn({ k: 1, x: 0, y: 0 }, (box.left + box.width / 2 - layerBox.left) / transform.k, (box.top + box.height / 2 - layerBox.top) / transform.k, zoom.measure()));
46
+ }
47
+ }
48
+ return () => {
49
+ for (const element of match.elements) {
50
+ element.removeAttribute(DATA_ATTR.focusedNode);
51
+ }
52
+ };
53
+ }, [containerRef, interactive, navigationKey, pathname, ready, svg, target, zoom]);
54
+ }
@@ -0,0 +1,37 @@
1
+ import { type MutableRefObject } from 'react';
2
+ import type { ZoomPanHandle } from './useZoomPan.js';
3
+ /**
4
+ * Text search over one rendered diagram.
5
+ *
6
+ * The search runs against the *rendered SVG's* `<text>` elements rather than the diagram
7
+ * source: what the reader sees is what they are searching, and a match can be highlighted and
8
+ * panned to because it is a real element with a position. Matches are marked with data
9
+ * attributes and coloured from the stylesheet, so the SVG string itself — which the cache
10
+ * shares between diagrams — is never mutated.
11
+ *
12
+ * Highlighting is imperative for the same reason the zoom transform is: the elements live in
13
+ * a `dangerouslySetInnerHTML` subtree that React cannot render into. React state carries only
14
+ * what drives markup — the query, the match count and the current position.
15
+ */
16
+ export interface DiagramSearchHandle {
17
+ /** Whether the search bar is shown. */
18
+ open: boolean;
19
+ query: string;
20
+ /** Number of `<text>` elements matching the query; 0 when the query is empty. */
21
+ matchCount: number;
22
+ /** Zero-based position of the current match, or -1 when there is none. */
23
+ currentIndex: number;
24
+ inputRef: MutableRefObject<HTMLInputElement | null>;
25
+ toggle: () => void;
26
+ close: () => void;
27
+ setQuery: (value: string) => void;
28
+ next: () => void;
29
+ previous: () => void;
30
+ }
31
+ export interface UseDiagramSearchParams {
32
+ enabled: boolean;
33
+ zoom: ZoomPanHandle;
34
+ /** The rendered SVG string — a dependency, so a re-rendered picture is re-searched. */
35
+ svg: string | null;
36
+ }
37
+ export declare function useDiagramSearch({ enabled, zoom, svg, }: UseDiagramSearchParams): DiagramSearchHandle;
@@ -0,0 +1,107 @@
1
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
2
+ import { DATA_ATTR } from '../../constants.js';
3
+ import { centerViewportOn } from './zoomMath.js';
4
+ export function useDiagramSearch({ enabled, zoom, svg, }) {
5
+ const [open, setOpen] = useState(false);
6
+ const [query, setQuery] = useState('');
7
+ const [matchCount, setMatchCount] = useState(0);
8
+ const [currentIndex, setCurrentIndex] = useState(-1);
9
+ const matchesRef = useRef([]);
10
+ const inputRef = useRef(null);
11
+ const toggle = useCallback(() => setOpen((value) => !value), []);
12
+ const close = useCallback(() => setOpen(false), []);
13
+ const next = useCallback(() => {
14
+ setCurrentIndex((index) => {
15
+ const total = matchesRef.current.length;
16
+ return total === 0 ? -1 : (index + 1) % total;
17
+ });
18
+ }, []);
19
+ const previous = useCallback(() => {
20
+ setCurrentIndex((index) => {
21
+ const total = matchesRef.current.length;
22
+ return total === 0 ? -1 : (index - 1 + total) % total;
23
+ });
24
+ }, []);
25
+ // Typing should not require a second click after pressing the toggle.
26
+ useEffect(() => {
27
+ if (open)
28
+ inputRef.current?.focus();
29
+ }, [open]);
30
+ // A diagram that lost its zoom lost the viewport the search pans; the bar goes with it.
31
+ useEffect(() => {
32
+ if (!enabled)
33
+ setOpen(false);
34
+ }, [enabled]);
35
+ /**
36
+ * Finds and marks the matches. The effect's cleanup unmarks them, so closing the bar,
37
+ * changing the query and unmounting all sweep up through the same code path. A replaced
38
+ * SVG needs no sweeping — its marked elements left the document with it — but re-runs the
39
+ * effect (via `svg`) so the new elements are searched.
40
+ */
41
+ useEffect(() => {
42
+ if (!enabled || !open)
43
+ return undefined;
44
+ const layer = zoom.layerRef.current;
45
+ const needle = query.trim().toLowerCase();
46
+ if (!layer || needle === '') {
47
+ matchesRef.current = [];
48
+ setMatchCount(0);
49
+ setCurrentIndex(-1);
50
+ return undefined;
51
+ }
52
+ // `<text>` elements, not their tspans: a PlantUML label is one text element, and marking
53
+ // the whole label keeps the highlight readable instead of colouring a fragment.
54
+ const matches = Array.from(layer.querySelectorAll('svg text')).filter((element) => (element.textContent ?? '').toLowerCase().includes(needle));
55
+ matchesRef.current = matches;
56
+ for (const match of matches)
57
+ match.setAttribute(DATA_ATTR.searchMatch, 'true');
58
+ setMatchCount(matches.length);
59
+ // Every new query starts at its first match, exactly like the browser's own find bar.
60
+ setCurrentIndex(matches.length > 0 ? 0 : -1);
61
+ return () => {
62
+ matchesRef.current = [];
63
+ for (const match of matches)
64
+ match.removeAttribute(DATA_ATTR.searchMatch);
65
+ };
66
+ }, [enabled, open, query, svg, zoom]);
67
+ /**
68
+ * Marks the current match and centres the viewport on it.
69
+ *
70
+ * Declared after the match effect on purpose: effects run in order, so by the time this
71
+ * one reads `matchesRef` the list belongs to the current query. `query` and `svg` are
72
+ * dependencies because a new list can leave the index at 0 — unchanged — while element 0
73
+ * is a different match.
74
+ */
75
+ useEffect(() => {
76
+ if (!enabled || !open)
77
+ return undefined;
78
+ const target = currentIndex >= 0 ? matchesRef.current[currentIndex] : undefined;
79
+ if (!target)
80
+ return undefined;
81
+ target.setAttribute(DATA_ATTR.searchCurrent, 'true');
82
+ const layer = zoom.layerRef.current;
83
+ if (layer) {
84
+ // The match's offset inside the layer, both measured in the same transformed space,
85
+ // divided back by the scale — which yields content coordinates whatever the zoom is.
86
+ const transform = zoom.getTransform();
87
+ const layerBox = layer.getBoundingClientRect();
88
+ const box = target.getBoundingClientRect();
89
+ zoom.applyTransform(centerViewportOn(transform, (box.left + box.width / 2 - layerBox.left) / transform.k, (box.top + box.height / 2 - layerBox.top) / transform.k, zoom.measure()));
90
+ }
91
+ return () => target.removeAttribute(DATA_ATTR.searchCurrent);
92
+ }, [currentIndex, enabled, open, query, svg, zoom]);
93
+ // Stable per set of values, like the zoom handle: consumers may hang callbacks and
94
+ // effects off the handle, and those must not churn on unrelated re-renders.
95
+ return useMemo(() => ({
96
+ open,
97
+ query,
98
+ matchCount,
99
+ currentIndex,
100
+ inputRef,
101
+ toggle,
102
+ close,
103
+ setQuery,
104
+ next,
105
+ previous,
106
+ }), [open, query, matchCount, currentIndex, toggle, close, next, previous]);
107
+ }
@@ -1,5 +1,5 @@
1
1
  import { type KeyboardEvent as ReactKeyboardEvent, type MutableRefObject } from 'react';
2
- import { MAX_SCALE, MIN_SCALE } from './zoomMath.js';
2
+ import { MAX_SCALE, MIN_SCALE, type Bounds, type Transform } from './zoomMath.js';
3
3
  export interface ZoomPanHandle {
4
4
  viewportRef: MutableRefObject<HTMLDivElement | null>;
5
5
  layerRef: MutableRefObject<HTMLDivElement | null>;
@@ -8,10 +8,26 @@ export interface ZoomPanHandle {
8
8
  zoomIn: () => void;
9
9
  zoomOut: () => void;
10
10
  reset: () => void;
11
+ /** Fits the whole diagram inside the viewport, at whatever scale that takes to fill it. */
12
+ fit: () => void;
11
13
  toggleMaximize: () => void;
12
14
  onKeyDown: (event: ReactKeyboardEvent<HTMLDivElement>) => void;
13
15
  /** Whether the diagram currently fills the browser viewport. */
14
16
  maximized: boolean;
17
+ /** Reads the live transform without subscribing to it. */
18
+ getTransform: () => Transform;
19
+ /** Applies a transform through the same clamp as every gesture. */
20
+ applyTransform: (next: Transform) => void;
21
+ /** Layout sizes of the viewport and content — the bounds the clamp runs against. */
22
+ measure: () => Bounds;
23
+ /**
24
+ * Calls the listener after every committed transform write; returns the unsubscribe.
25
+ *
26
+ * This is how the minimap tracks the view: the transform is written straight to the DOM on
27
+ * every pan frame, so anything that mirrored it through React state would re-render the
28
+ * component once per frame.
29
+ */
30
+ subscribe: (listener: (transform: Transform) => void) => () => void;
15
31
  }
16
32
  export interface UseZoomPanParams {
17
33
  enabled: boolean;
@@ -1,4 +1,4 @@
1
- import { useCallback, useEffect, useRef, useState, } from 'react';
1
+ import { useCallback, useEffect, useMemo, useRef, useState, } from 'react';
2
2
  import { clampTransform, fitScale, formatPercent, formatZoom, IDENTITY, MAX_SCALE, MIN_SCALE, normalizeWheelDelta, panBy, PAN_STEP, SCALE_STEP, toCssTransform, wheelZoomFactor, zoomAbout, } from './zoomMath.js';
3
3
  /**
4
4
  * Zoom, pan and maximize for one diagram.
@@ -19,12 +19,17 @@ export function useZoomPan({ enabled, resetKey }) {
19
19
  const readoutRef = useRef(null);
20
20
  const [maximized, setMaximized] = useState(false);
21
21
  const transformRef = useRef(IDENTITY);
22
+ const listenersRef = useRef(new Set());
22
23
  /** The view to restore when the diagram is un-maximized. */
23
24
  const beforeMaximizeRef = useRef(null);
24
25
  const frameRef = useRef(null);
25
26
  /**
26
27
  * Layout sizes, never `getBoundingClientRect()`: the layer's rect includes the transform we
27
- * are about to change, which would feed back into the next measurement.
28
+ * are about to change — and, mid-easing, the transition — which would feed back into the
29
+ * next measurement. The layer is `width: fit-content`, so its layout size *is* the rendered
30
+ * picture's, not the full-width frame's; that is what lets fitting fill a maximized screen
31
+ * and keeps the minimap honest about where the diagram ends. The clamp deliberately runs
32
+ * against wider bounds — see {@link clampBounds}.
28
33
  */
29
34
  const measure = useCallback(() => {
30
35
  const viewport = viewportRef.current;
@@ -36,6 +41,20 @@ export function useZoomPan({ enabled, resetKey }) {
36
41
  contentHeight: layer?.offsetHeight ?? 0,
37
42
  };
38
43
  }, []);
44
+ /**
45
+ * The bounds the clamp runs against: the picture or the viewport, whichever is *wider*.
46
+ *
47
+ * Fitting wants the true picture size; the clamp deliberately does not. Focal zoom must
48
+ * hold the point under the pointer even when that slides the picture's right edge inside
49
+ * the viewport — clamping to the picture itself yanks the diagram sideways mid-gesture the
50
+ * moment that edge is reached. Flooring the width at the viewport's restores exactly the
51
+ * geometry the full-width layer always gave. Height is not floored: the layer never
52
+ * stretched vertically, so the height clamp has always run against the real picture.
53
+ */
54
+ const clampBounds = useCallback(() => {
55
+ const bounds = measure();
56
+ return { ...bounds, contentWidth: Math.max(bounds.contentWidth, bounds.viewportWidth) };
57
+ }, [measure]);
39
58
  const write = useCallback((next) => {
40
59
  transformRef.current = next;
41
60
  const layer = layerRef.current;
@@ -47,10 +66,19 @@ export function useZoomPan({ enabled, resetKey }) {
47
66
  const readout = readoutRef.current;
48
67
  if (readout)
49
68
  readout.textContent = formatPercent(next.k);
69
+ for (const listener of listenersRef.current)
70
+ listener(next);
71
+ }, []);
72
+ const subscribe = useCallback((listener) => {
73
+ listenersRef.current.add(listener);
74
+ return () => {
75
+ listenersRef.current.delete(listener);
76
+ };
50
77
  }, []);
78
+ const getTransform = useCallback(() => transformRef.current, []);
51
79
  const apply = useCallback((next) => {
52
- write(clampTransform(next, measure()));
53
- }, [measure, write]);
80
+ write(clampTransform(next, clampBounds()));
81
+ }, [clampBounds, write]);
54
82
  const reset = useCallback(() => {
55
83
  apply(IDENTITY);
56
84
  }, [apply]);
@@ -70,6 +98,18 @@ export function useZoomPan({ enabled, resetKey }) {
70
98
  }, [apply]);
71
99
  const zoomIn = useCallback(() => zoomByStep(SCALE_STEP), [zoomByStep]);
72
100
  const zoomOut = useCallback(() => zoomByStep(1 / SCALE_STEP), [zoomByStep]);
101
+ /**
102
+ * The fitted view maximizing opens with: the diagram scaled to fill the screen, magnified
103
+ * or shrunk as the picture demands, capped at {@link MAX_SCALE}.
104
+ *
105
+ * Only offered while maximized. Inline it would duplicate Reset, because the inline frame
106
+ * grows with the diagram — at 100% everything is already visible, so there is nothing for
107
+ * a fit to add. Translation returns to the origin because a diagram that fits is
108
+ * left-aligned, exactly as `clampTransform` would force anyway.
109
+ */
110
+ const fit = useCallback(() => {
111
+ apply({ ...IDENTITY, k: fitScale(measure()) });
112
+ }, [apply, measure]);
73
113
  const toggleMaximize = useCallback(() => {
74
114
  setMaximized((previous) => !previous);
75
115
  }, []);
@@ -309,7 +349,10 @@ export function useZoomPan({ enabled, resetKey }) {
309
349
  if (!enabled)
310
350
  setMaximized(false);
311
351
  }, [enabled]);
312
- return {
352
+ // One stable object per set of values: consumers hang effects off the handle (the search
353
+ // hook, the minimap), and an object recreated every render would re-run all of them — with
354
+ // visible consequences, such as a search that resets to its first match on any re-render.
355
+ return useMemo(() => ({
313
356
  stageRef,
314
357
  viewportRef,
315
358
  layerRef,
@@ -317,9 +360,26 @@ export function useZoomPan({ enabled, resetKey }) {
317
360
  zoomIn,
318
361
  zoomOut,
319
362
  reset,
363
+ fit,
364
+ toggleMaximize,
365
+ onKeyDown,
366
+ maximized,
367
+ getTransform,
368
+ applyTransform: apply,
369
+ measure,
370
+ subscribe,
371
+ }), [
372
+ zoomIn,
373
+ zoomOut,
374
+ reset,
375
+ fit,
320
376
  toggleMaximize,
321
377
  onKeyDown,
322
378
  maximized,
323
- };
379
+ getTransform,
380
+ apply,
381
+ measure,
382
+ subscribe,
383
+ ]);
324
384
  }
325
385
  export { MAX_SCALE, MIN_SCALE };
@@ -67,6 +67,34 @@ export declare function wheelZoomFactor(normalizedDelta: number): number;
67
67
  * `max`, and never below {@link MIN_SCALE}.
68
68
  */
69
69
  export declare function fitScale(bounds: Bounds, max?: number): number;
70
+ export interface Rect {
71
+ x: number;
72
+ y: number;
73
+ width: number;
74
+ height: number;
75
+ }
76
+ /**
77
+ * Scale of the minimap image: the largest scale that fits the content into the given box,
78
+ * but never past 1 — a small diagram is shown at its own size, not blown up into a blurrier
79
+ * copy of itself sitting next to the original.
80
+ *
81
+ * Returns 0 when there is nothing to draw, which callers treat as "no minimap".
82
+ */
83
+ export declare function minimapScale(bounds: Bounds, maxWidth: number, maxHeight: number): number;
84
+ /**
85
+ * The part of the content the viewport currently shows, in content coordinates.
86
+ *
87
+ * Clamped to the content box, so the minimap's viewport rectangle can never poke outside the
88
+ * picture — not even mid-gesture, when the transform itself may be momentarily out of range.
89
+ */
90
+ export declare function visibleContentRect(transform: Transform, bounds: Bounds): Rect;
91
+ /**
92
+ * The transform that centres the viewport on a content point, at the current scale.
93
+ *
94
+ * Deliberately unclamped: callers apply it through the same clamp as every gesture, which
95
+ * pins edge cases exactly the way the end of a drag is pinned.
96
+ */
97
+ export declare function centerViewportOn(transform: Transform, contentX: number, contentY: number, bounds: Bounds): Transform;
70
98
  export declare function toCssTransform(transform: Transform): string;
71
99
  /** Machine-readable scale for `data-plantuml-zoom`, used by tests and author CSS. */
72
100
  export declare function formatZoom(scale: number): string;
@@ -94,6 +94,48 @@ export function fitScale(bounds, max = MAX_SCALE) {
94
94
  return 1;
95
95
  return clamp(scale, MIN_SCALE, max);
96
96
  }
97
+ /**
98
+ * Scale of the minimap image: the largest scale that fits the content into the given box,
99
+ * but never past 1 — a small diagram is shown at its own size, not blown up into a blurrier
100
+ * copy of itself sitting next to the original.
101
+ *
102
+ * Returns 0 when there is nothing to draw, which callers treat as "no minimap".
103
+ */
104
+ export function minimapScale(bounds, maxWidth, maxHeight) {
105
+ if (bounds.contentWidth <= 0 || bounds.contentHeight <= 0)
106
+ return 0;
107
+ const scale = Math.min(maxWidth / bounds.contentWidth, maxHeight / bounds.contentHeight, 1);
108
+ return Number.isFinite(scale) && scale > 0 ? scale : 0;
109
+ }
110
+ /**
111
+ * The part of the content the viewport currently shows, in content coordinates.
112
+ *
113
+ * Clamped to the content box, so the minimap's viewport rectangle can never poke outside the
114
+ * picture — not even mid-gesture, when the transform itself may be momentarily out of range.
115
+ */
116
+ export function visibleContentRect(transform, bounds) {
117
+ const width = Math.min(bounds.viewportWidth / transform.k, bounds.contentWidth);
118
+ const height = Math.min(bounds.viewportHeight / transform.k, bounds.contentHeight);
119
+ return {
120
+ x: clamp(-transform.x / transform.k, 0, Math.max(0, bounds.contentWidth - width)),
121
+ y: clamp(-transform.y / transform.k, 0, Math.max(0, bounds.contentHeight - height)),
122
+ width,
123
+ height,
124
+ };
125
+ }
126
+ /**
127
+ * The transform that centres the viewport on a content point, at the current scale.
128
+ *
129
+ * Deliberately unclamped: callers apply it through the same clamp as every gesture, which
130
+ * pins edge cases exactly the way the end of a drag is pinned.
131
+ */
132
+ export function centerViewportOn(transform, contentX, contentY, bounds) {
133
+ return {
134
+ k: transform.k,
135
+ x: bounds.viewportWidth / 2 - contentX * transform.k,
136
+ y: bounds.viewportHeight / 2 - contentY * transform.k,
137
+ };
138
+ }
97
139
  /** Rounds away floating-point noise so repeated zoom in/out returns to exactly 1. */
98
140
  function tidy(value) {
99
141
  return Math.abs(value) < 1e-6 ? 0 : Math.round(value * 1e6) / 1e6;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@matfsw/docusaurus-plantuml-plugin",
3
- "version": "1.3.0",
3
+ "version": "1.4.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",