@matfsw/docusaurus-plantuml-plugin 1.0.2 → 1.1.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 +108 -1
- package/README.md +265 -50
- package/dist/assets.js +25 -8
- package/dist/constants.d.ts +12 -1
- package/dist/constants.js +12 -1
- package/dist/index.cjs +116 -17
- package/dist/index.d.cts +41 -1
- package/dist/options.d.ts +42 -1
- package/dist/options.js +105 -8
- package/dist/runtime/assetLoader.d.ts +13 -3
- package/dist/runtime/assetLoader.js +68 -8
- package/dist/runtime/cache.d.ts +21 -0
- package/dist/runtime/cache.js +12 -0
- package/dist/runtime/errors.d.ts +16 -1
- package/dist/runtime/errors.js +32 -0
- package/dist/runtime/graphvizRenderer.d.ts +24 -0
- package/dist/runtime/graphvizRenderer.js +95 -0
- package/dist/runtime/types.d.ts +41 -0
- package/dist/theme/MDXComponents/Code/index.d.ts +8 -3
- package/dist/theme/MDXComponents/Code/index.js +31 -8
- package/dist/theme/PlantUmlDiagram/index.d.ts +16 -4
- package/dist/theme/PlantUmlDiagram/index.js +49 -12
- package/dist/theme/PlantUmlDiagram/styles.module.css +31 -0
- package/dist/theme/PlantUmlDiagram/useZoomPan.js +10 -5
- package/dist/theme/codeBlockMeta.d.ts +9 -0
- package/dist/theme/codeBlockMeta.js +21 -0
- package/dist/theme/usePlantUmlConfig.d.ts +1 -1
- package/dist/theme/usePlantUmlConfig.js +20 -1
- package/package.json +9 -9
package/dist/runtime/cache.d.ts
CHANGED
|
@@ -14,6 +14,27 @@ export interface DiagramCache {
|
|
|
14
14
|
size(): number;
|
|
15
15
|
}
|
|
16
16
|
export declare function computeCacheKey({ source, dark, sanitized, coreVersion }: CacheKeyInput): string;
|
|
17
|
+
export interface GraphvizCacheKeyInput {
|
|
18
|
+
source: string;
|
|
19
|
+
/** Graphviz layout engine; `dot` and `neato` draw different pictures from one source. */
|
|
20
|
+
layout: string;
|
|
21
|
+
sanitized: boolean;
|
|
22
|
+
transparentBackground: boolean;
|
|
23
|
+
/**
|
|
24
|
+
* `@plantuml/core` version. The Graphviz build is bundled inside that package, so its
|
|
25
|
+
* version is what changes when the engine changes — and unlike `Viz.graphvizVersion` it is
|
|
26
|
+
* known before the engine has loaded, which is what lets a cache hit skip loading entirely.
|
|
27
|
+
*/
|
|
28
|
+
coreVersion: string;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Cache key for a Graphviz diagram.
|
|
32
|
+
*
|
|
33
|
+
* The colour mode is deliberately **absent**: Graphviz output is colour-mode independent and
|
|
34
|
+
* adapts through CSS rather than through a re-render, so one entry serves both modes. The
|
|
35
|
+
* `graphviz|` prefix keeps these entries in a separate namespace from PlantUML's.
|
|
36
|
+
*/
|
|
37
|
+
export declare function computeGraphvizCacheKey({ source, layout, sanitized, transparentBackground, coreVersion, }: GraphvizCacheKeyInput): string;
|
|
17
38
|
export declare function createDiagramCache(mode: CacheMode, maxEntries: number): DiagramCache;
|
|
18
39
|
export declare function getSharedCache(mode: CacheMode, maxEntries: number): DiagramCache;
|
|
19
40
|
/** Test-only: drops every shared cache instance. */
|
package/dist/runtime/cache.js
CHANGED
|
@@ -22,6 +22,18 @@ export function computeCacheKey({ source, dark, sanitized, coreVersion }) {
|
|
|
22
22
|
// two different sources that happen to share every other key component.
|
|
23
23
|
return `${coreVersion}|${mode}|${clean}|${source.length}|${hash(source)}`;
|
|
24
24
|
}
|
|
25
|
+
/**
|
|
26
|
+
* Cache key for a Graphviz diagram.
|
|
27
|
+
*
|
|
28
|
+
* The colour mode is deliberately **absent**: Graphviz output is colour-mode independent and
|
|
29
|
+
* adapts through CSS rather than through a re-render, so one entry serves both modes. The
|
|
30
|
+
* `graphviz|` prefix keeps these entries in a separate namespace from PlantUML's.
|
|
31
|
+
*/
|
|
32
|
+
export function computeGraphvizCacheKey({ source, layout, sanitized, transparentBackground, coreVersion, }) {
|
|
33
|
+
const clean = sanitized ? 'san' : 'raw';
|
|
34
|
+
const background = transparentBackground ? 'bg-none' : 'bg-default';
|
|
35
|
+
return `graphviz|${coreVersion}|${layout}|${background}|${clean}|${source.length}|${hash(source)}`;
|
|
36
|
+
}
|
|
25
37
|
class MemoryCache {
|
|
26
38
|
mode = 'memory';
|
|
27
39
|
#entries = new Map();
|
package/dist/runtime/errors.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
|
|
1
|
+
import type { VizRenderError } from './types.js';
|
|
2
|
+
export type PlantUmlErrorKind = 'load' | 'engine' | 'diagram' | 'syntax' | 'timeout' | 'config' | 'aborted' | 'too-large';
|
|
2
3
|
/** Every failure surfaced to the UI is one of these, so the panel can explain what broke. */
|
|
3
4
|
export declare class PlantUmlError extends Error {
|
|
4
5
|
readonly kind: PlantUmlErrorKind;
|
|
@@ -29,3 +30,17 @@ export declare function describeEngineError(raw: unknown): string;
|
|
|
29
30
|
* so their `kind` survives a trip through the render queue.
|
|
30
31
|
*/
|
|
31
32
|
export declare function toError(value: unknown): Error;
|
|
33
|
+
/**
|
|
34
|
+
* Joins Graphviz's own diagnostics into one message.
|
|
35
|
+
*
|
|
36
|
+
* Unlike PlantUML, Graphviz reports failures as structured data rather than as a rendered
|
|
37
|
+
* error picture, so the reader can be shown exactly what the engine said — typically
|
|
38
|
+
* `syntax error in line 3 near '}'`. Warnings are dropped here: they accompany *successful*
|
|
39
|
+
* renders and must not be presented as failures.
|
|
40
|
+
*/
|
|
41
|
+
export declare function formatGraphvizErrors(errors: readonly VizRenderError[]): string;
|
|
42
|
+
/**
|
|
43
|
+
* Extracts the 1-based source line from a Graphviz diagnostic, so the error panel can point
|
|
44
|
+
* at the offending line of the fence. Returns `null` when no line is named.
|
|
45
|
+
*/
|
|
46
|
+
export declare function graphvizErrorLine(message: string): number | null;
|
package/dist/runtime/errors.js
CHANGED
|
@@ -106,3 +106,35 @@ export function describeEngineError(raw) {
|
|
|
106
106
|
export function toError(value) {
|
|
107
107
|
return value instanceof Error ? value : new PlantUmlError('engine', describeEngineError(value));
|
|
108
108
|
}
|
|
109
|
+
const GRAPHVIZ_UNKNOWN_ERROR = 'Graphviz rejected this diagram without reporting a reason.';
|
|
110
|
+
/**
|
|
111
|
+
* Joins Graphviz's own diagnostics into one message.
|
|
112
|
+
*
|
|
113
|
+
* Unlike PlantUML, Graphviz reports failures as structured data rather than as a rendered
|
|
114
|
+
* error picture, so the reader can be shown exactly what the engine said — typically
|
|
115
|
+
* `syntax error in line 3 near '}'`. Warnings are dropped here: they accompany *successful*
|
|
116
|
+
* renders and must not be presented as failures.
|
|
117
|
+
*/
|
|
118
|
+
export function formatGraphvizErrors(errors) {
|
|
119
|
+
const messages = errors
|
|
120
|
+
.filter((entry) => entry.level !== 'warning')
|
|
121
|
+
.map((entry) => entry.message.trim())
|
|
122
|
+
.filter((message) => message.length > 0);
|
|
123
|
+
// A failure with only warning-level diagnostics still failed; show them rather than nothing.
|
|
124
|
+
const fallback = errors
|
|
125
|
+
.map((entry) => entry.message.trim())
|
|
126
|
+
.filter((message) => message.length > 0);
|
|
127
|
+
const chosen = messages.length > 0 ? messages : fallback;
|
|
128
|
+
return chosen.length > 0 ? [...new Set(chosen)].join('\n') : GRAPHVIZ_UNKNOWN_ERROR;
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Extracts the 1-based source line from a Graphviz diagnostic, so the error panel can point
|
|
132
|
+
* at the offending line of the fence. Returns `null` when no line is named.
|
|
133
|
+
*/
|
|
134
|
+
export function graphvizErrorLine(message) {
|
|
135
|
+
const match = /\bin line (\d+)\b/i.exec(message);
|
|
136
|
+
if (!match?.[1])
|
|
137
|
+
return null;
|
|
138
|
+
const line = Number.parseInt(match[1], 10);
|
|
139
|
+
return Number.isInteger(line) && line > 0 ? line : null;
|
|
140
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { type DiagramCache } from './cache.js';
|
|
2
|
+
export interface RenderGraphvizRequest {
|
|
3
|
+
source: string;
|
|
4
|
+
/** Graphviz layout engine to lay the graph out with. */
|
|
5
|
+
layout: string;
|
|
6
|
+
sanitize: boolean;
|
|
7
|
+
transparentBackground: boolean;
|
|
8
|
+
/** Refuse sources larger than this many UTF-8 bytes rather than freeze the main thread. */
|
|
9
|
+
maxSourceBytes: number;
|
|
10
|
+
timeoutMs: number;
|
|
11
|
+
assetsBaseUrl: string;
|
|
12
|
+
coreVersion: string;
|
|
13
|
+
cache: DiagramCache;
|
|
14
|
+
signal?: AbortSignal;
|
|
15
|
+
/** Reports which long-running phase is in progress, so the UI can show an accurate state. */
|
|
16
|
+
onPhase?: (phase: 'loading' | 'rendering') => void;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Renders one Graphviz diagram to sanitized SVG.
|
|
20
|
+
*
|
|
21
|
+
* Cache hits return before the engine is even loaded, which is what keeps a page of repeated
|
|
22
|
+
* diagrams from downloading 1.4 MB it does not need.
|
|
23
|
+
*/
|
|
24
|
+
export declare function renderGraphvizDiagram(request: RenderGraphvizRequest): Promise<string>;
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { loadVizRuntime } from './assetLoader.js';
|
|
2
|
+
import { computeGraphvizCacheKey } from './cache.js';
|
|
3
|
+
import { formatGraphvizErrors, PlantUmlError } from './errors.js';
|
|
4
|
+
import { sanitizeSvgMarkup } from './sanitize.js';
|
|
5
|
+
function utf8ByteLength(value) {
|
|
6
|
+
// `TextEncoder` is universal in browsers and in jsdom; the fallback keeps this honest in
|
|
7
|
+
// any exotic host that lacks it, at the cost of counting code units instead of bytes.
|
|
8
|
+
if (typeof TextEncoder === 'undefined')
|
|
9
|
+
return value.length;
|
|
10
|
+
return new TextEncoder().encode(value).length;
|
|
11
|
+
}
|
|
12
|
+
function abortedError() {
|
|
13
|
+
return new PlantUmlError('aborted', 'Render aborted.');
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Runs one Graphviz layout.
|
|
17
|
+
*
|
|
18
|
+
* Deliberately **not** routed through `runtime/queue.ts`. That queue exists because the
|
|
19
|
+
* PlantUML engine keeps in-flight state in module globals, so overlapping renders corrupt
|
|
20
|
+
* each other. Viz.js has no such defect: `render` is synchronous and has returned before
|
|
21
|
+
* anything else can observe the engine, so serializing would add latency and buy nothing.
|
|
22
|
+
*/
|
|
23
|
+
function renderWithViz(viz, source, layout, transparentBackground) {
|
|
24
|
+
// Checked against the live engine rather than only against the compile-time list, so a
|
|
25
|
+
// fence naming an engine this build lacks fails with the available set spelled out.
|
|
26
|
+
if (!viz.engines.includes(layout)) {
|
|
27
|
+
throw new PlantUmlError('config', `Unknown Graphviz layout engine '${layout}'. This build supports: ` +
|
|
28
|
+
`${viz.engines.join(', ')}.`);
|
|
29
|
+
}
|
|
30
|
+
let result;
|
|
31
|
+
try {
|
|
32
|
+
result = viz.render(source, {
|
|
33
|
+
format: 'svg',
|
|
34
|
+
engine: layout,
|
|
35
|
+
// Graphviz paints an opaque white rectangle over the whole canvas unless told not to,
|
|
36
|
+
// which would show as a white slab on a dark page. `transparent` omits it entirely.
|
|
37
|
+
...(transparentBackground ? { graphAttributes: { bgcolor: 'transparent' } } : {}),
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
catch (error) {
|
|
41
|
+
// `render` reports invalid DOT in its result rather than by throwing, so reaching here
|
|
42
|
+
// means the engine itself broke — a different failure with a different explanation.
|
|
43
|
+
throw new PlantUmlError('engine', 'The Graphviz engine failed while laying out this diagram.', {
|
|
44
|
+
cause: error,
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
if (result.status !== 'success') {
|
|
48
|
+
throw new PlantUmlError('syntax', formatGraphvizErrors(result.errors));
|
|
49
|
+
}
|
|
50
|
+
return result.output;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Renders one Graphviz diagram to sanitized SVG.
|
|
54
|
+
*
|
|
55
|
+
* Cache hits return before the engine is even loaded, which is what keeps a page of repeated
|
|
56
|
+
* diagrams from downloading 1.4 MB it does not need.
|
|
57
|
+
*/
|
|
58
|
+
export async function renderGraphvizDiagram(request) {
|
|
59
|
+
const { source, layout, sanitize, transparentBackground, maxSourceBytes, timeoutMs, cache, coreVersion, signal, onPhase, } = request;
|
|
60
|
+
if (signal?.aborted)
|
|
61
|
+
throw abortedError();
|
|
62
|
+
const cacheKey = computeGraphvizCacheKey({
|
|
63
|
+
source,
|
|
64
|
+
layout,
|
|
65
|
+
sanitized: sanitize,
|
|
66
|
+
transparentBackground,
|
|
67
|
+
coreVersion,
|
|
68
|
+
});
|
|
69
|
+
const cached = cache.get(cacheKey);
|
|
70
|
+
if (cached !== undefined)
|
|
71
|
+
return cached;
|
|
72
|
+
// Checked before loading the engine: refusing a 5 MB graph should not first cost a 1.4 MB
|
|
73
|
+
// download. Graphviz lays out synchronously, so an oversized source freezes the tab rather
|
|
74
|
+
// than merely taking a while — an explanatory panel is the better failure.
|
|
75
|
+
const bytes = utf8ByteLength(source);
|
|
76
|
+
if (bytes > maxSourceBytes) {
|
|
77
|
+
throw new PlantUmlError('too-large', `This diagram's source is ${bytes} bytes, above the ${maxSourceBytes}-byte limit. ` +
|
|
78
|
+
'Graphviz lays out synchronously, so rendering it would freeze the page. Raise ' +
|
|
79
|
+
'`graphviz.maxSourceBytes` if this diagram is genuinely meant to be this large.');
|
|
80
|
+
}
|
|
81
|
+
onPhase?.('loading');
|
|
82
|
+
const viz = await loadVizRuntime({
|
|
83
|
+
assetsBaseUrl: request.assetsBaseUrl,
|
|
84
|
+
timeoutMs,
|
|
85
|
+
});
|
|
86
|
+
// Loading is shared work that a caller-side abort cannot cancel, so the signal is re-checked
|
|
87
|
+
// afterwards: an unmounted diagram must not go on to lay out a graph nobody will see.
|
|
88
|
+
if (signal?.aborted)
|
|
89
|
+
throw abortedError();
|
|
90
|
+
onPhase?.('rendering');
|
|
91
|
+
const raw = renderWithViz(viz, source, layout, transparentBackground);
|
|
92
|
+
const svg = sanitize ? sanitizeSvgMarkup(raw) : raw;
|
|
93
|
+
cache.set(cacheKey, svg);
|
|
94
|
+
return svg;
|
|
95
|
+
}
|
package/dist/runtime/types.d.ts
CHANGED
|
@@ -13,3 +13,44 @@ export interface PlantUmlRenderOptions {
|
|
|
13
13
|
dark?: boolean;
|
|
14
14
|
}
|
|
15
15
|
export type DiagramStatus = 'idle' | 'loading' | 'rendering' | 'ready' | 'error';
|
|
16
|
+
/** Which engine renders a given fence. */
|
|
17
|
+
export type DiagramEngine = 'plantuml' | 'graphviz';
|
|
18
|
+
/**
|
|
19
|
+
* The subset of Viz.js 3.x this plugin uses, verified against the `viz-global.js` that
|
|
20
|
+
* `@plantuml/core` ships (Viz.js 3.24.0 / Graphviz 14.1.1).
|
|
21
|
+
*
|
|
22
|
+
* `render` is deliberately preferred over `renderString`: it reports invalid DOT as a result
|
|
23
|
+
* object carrying Graphviz's own diagnostics — including the line number — instead of throwing
|
|
24
|
+
* a bare `Error`.
|
|
25
|
+
*/
|
|
26
|
+
export interface VizGlobal {
|
|
27
|
+
instance(): Promise<VizInstance>;
|
|
28
|
+
readonly engines: string[];
|
|
29
|
+
readonly formats: string[];
|
|
30
|
+
readonly graphvizVersion: string;
|
|
31
|
+
}
|
|
32
|
+
export interface VizInstance {
|
|
33
|
+
render(input: string, options?: VizRenderOptions): VizRenderResult;
|
|
34
|
+
readonly engines: string[];
|
|
35
|
+
readonly graphvizVersion: string;
|
|
36
|
+
}
|
|
37
|
+
export interface VizRenderOptions {
|
|
38
|
+
format?: string;
|
|
39
|
+
engine?: string;
|
|
40
|
+
graphAttributes?: Record<string, string | number | boolean>;
|
|
41
|
+
nodeAttributes?: Record<string, string | number | boolean>;
|
|
42
|
+
edgeAttributes?: Record<string, string | number | boolean>;
|
|
43
|
+
}
|
|
44
|
+
export interface VizRenderError {
|
|
45
|
+
level?: 'error' | 'warning';
|
|
46
|
+
message: string;
|
|
47
|
+
}
|
|
48
|
+
export type VizRenderResult = {
|
|
49
|
+
status: 'success';
|
|
50
|
+
output: string;
|
|
51
|
+
errors: VizRenderError[];
|
|
52
|
+
} | {
|
|
53
|
+
status: 'failure';
|
|
54
|
+
output: undefined;
|
|
55
|
+
errors: VizRenderError[];
|
|
56
|
+
};
|
|
@@ -3,14 +3,19 @@ import { type CodeBlockProps } from '../../codeBlockMeta.js';
|
|
|
3
3
|
/**
|
|
4
4
|
* Wraps Docusaurus' `MDXComponents/Code` extension point.
|
|
5
5
|
*
|
|
6
|
-
* Only fences whose language is configured
|
|
7
|
-
* inline code, ordinary fenced blocks, JSX-authored blocks —
|
|
8
|
-
* component with its props untouched, so no existing code-block
|
|
6
|
+
* Only fences whose language is configured for one of the two diagram engines are
|
|
7
|
+
* intercepted. Everything else — inline code, ordinary fenced blocks, JSX-authored blocks —
|
|
8
|
+
* is delegated to the original component with its props untouched, so no existing code-block
|
|
9
|
+
* behaviour changes.
|
|
9
10
|
*
|
|
10
11
|
* The delegate is imported from `@theme-init/`, not `@theme-original/`. Docusaurus points
|
|
11
12
|
* *both* `@theme/X` and `@theme-original/X` at the last plugin theme that provides `X`, so
|
|
12
13
|
* a plugin importing `@theme-original/MDXComponents/Code` imports itself and recurses until
|
|
13
14
|
* the stack overflows. `@theme-init/X` is the alias that keeps pointing at the first theme
|
|
14
15
|
* to provide the component — see `docs/adr/0001-theme-init-alias.md`.
|
|
16
|
+
*
|
|
17
|
+
* Handling both engines in this one wrapper is not merely convenient: only one plugin can
|
|
18
|
+
* usefully wrap this component, so two engines shipped as two plugins would silently disable
|
|
19
|
+
* each other. That is the reason Graphviz lives here rather than in a package of its own.
|
|
15
20
|
*/
|
|
16
21
|
export default function Code(props: CodeBlockProps): ReactNode;
|
|
@@ -1,28 +1,51 @@
|
|
|
1
1
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
2
|
import OriginalCode from '@theme-init/MDXComponents/Code';
|
|
3
3
|
import PlantUmlDiagram from '@theme/PlantUmlDiagram';
|
|
4
|
-
import { extractLanguage, extractSource, parseBooleanMeta, parseTitle, } from '../../codeBlockMeta.js';
|
|
4
|
+
import { extractLanguage, extractSource, parseBooleanMeta, parseStringMeta, parseTitle, } from '../../codeBlockMeta.js';
|
|
5
5
|
import { usePlantUmlConfig } from '../../usePlantUmlConfig.js';
|
|
6
6
|
/**
|
|
7
7
|
* Wraps Docusaurus' `MDXComponents/Code` extension point.
|
|
8
8
|
*
|
|
9
|
-
* Only fences whose language is configured
|
|
10
|
-
* inline code, ordinary fenced blocks, JSX-authored blocks —
|
|
11
|
-
* component with its props untouched, so no existing code-block
|
|
9
|
+
* Only fences whose language is configured for one of the two diagram engines are
|
|
10
|
+
* intercepted. Everything else — inline code, ordinary fenced blocks, JSX-authored blocks —
|
|
11
|
+
* is delegated to the original component with its props untouched, so no existing code-block
|
|
12
|
+
* behaviour changes.
|
|
12
13
|
*
|
|
13
14
|
* The delegate is imported from `@theme-init/`, not `@theme-original/`. Docusaurus points
|
|
14
15
|
* *both* `@theme/X` and `@theme-original/X` at the last plugin theme that provides `X`, so
|
|
15
16
|
* a plugin importing `@theme-original/MDXComponents/Code` imports itself and recurses until
|
|
16
17
|
* the stack overflows. `@theme-init/X` is the alias that keeps pointing at the first theme
|
|
17
18
|
* to provide the component — see `docs/adr/0001-theme-init-alias.md`.
|
|
19
|
+
*
|
|
20
|
+
* Handling both engines in this one wrapper is not merely convenient: only one plugin can
|
|
21
|
+
* usefully wrap this component, so two engines shipped as two plugins would silently disable
|
|
22
|
+
* each other. That is the reason Graphviz lives here rather than in a package of its own.
|
|
18
23
|
*/
|
|
19
24
|
export default function Code(props) {
|
|
20
25
|
const config = usePlantUmlConfig();
|
|
21
26
|
const language = extractLanguage(props);
|
|
22
|
-
const
|
|
23
|
-
const source =
|
|
24
|
-
if (
|
|
27
|
+
const engine = matchEngine(config, language);
|
|
28
|
+
const source = engine !== null ? extractSource(props.children) : null;
|
|
29
|
+
if (engine === null || source === null) {
|
|
25
30
|
return _jsx(OriginalCode, { ...props });
|
|
26
31
|
}
|
|
27
|
-
return (_jsx(PlantUmlDiagram, { source: source, title: parseTitle(props), language: language, zoom: parseBooleanMeta(props, 'zoom') }));
|
|
32
|
+
return (_jsx(PlantUmlDiagram, { source: source, title: parseTitle(props), language: language, engine: engine, layout: engine === 'graphviz' ? parseStringMeta(props, 'engine') : undefined, zoom: parseBooleanMeta(props, 'zoom') }));
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Decides which engine — if either — owns this fence.
|
|
36
|
+
*
|
|
37
|
+
* `resolveOptions` rejects a configuration where both engines claim the same language, so the
|
|
38
|
+
* order these are checked in cannot change what a site renders.
|
|
39
|
+
*/
|
|
40
|
+
function matchEngine(config, language) {
|
|
41
|
+
if (config === null || language === undefined)
|
|
42
|
+
return null;
|
|
43
|
+
if (config.options.languages.includes(language))
|
|
44
|
+
return 'plantuml';
|
|
45
|
+
// `usePlantUmlConfig` fills the group in when older global data lacks it, so it is always
|
|
46
|
+
// present here even if the site's `.docusaurus` cache predates Graphviz support.
|
|
47
|
+
const { graphviz } = config.options;
|
|
48
|
+
if (graphviz.enabled && graphviz.languages.includes(language))
|
|
49
|
+
return 'graphviz';
|
|
50
|
+
return null;
|
|
28
51
|
}
|
|
@@ -1,11 +1,19 @@
|
|
|
1
1
|
import { type ReactNode } from 'react';
|
|
2
|
+
import type { DiagramEngine } from '../../runtime/types.js';
|
|
2
3
|
export interface PlantUmlDiagramProps {
|
|
3
|
-
/** Raw
|
|
4
|
+
/** Raw diagram source, exactly as authored between the fence markers. */
|
|
4
5
|
source: string;
|
|
5
6
|
/** `title="..."` from the fence metastring; used as caption and accessible label. */
|
|
6
7
|
title?: string;
|
|
7
|
-
/** The fence language that matched, e.g. `plantuml`
|
|
8
|
+
/** The fence language that matched, e.g. `plantuml`, `puml`, `dot`. */
|
|
8
9
|
language?: string;
|
|
10
|
+
/** Which engine renders this fence. Defaults to `plantuml`. */
|
|
11
|
+
engine?: DiagramEngine;
|
|
12
|
+
/**
|
|
13
|
+
* Graphviz layout engine from the fence metastring (`engine=neato`). Ignored for PlantUML,
|
|
14
|
+
* and ignored entirely when `graphviz.allowEngineOverride` is off.
|
|
15
|
+
*/
|
|
16
|
+
layout?: string;
|
|
9
17
|
/**
|
|
10
18
|
* Overrides the `zoom` plugin option for this diagram. `undefined` follows the option.
|
|
11
19
|
* Set from the fence metastring: `zoom` or `zoom=false`.
|
|
@@ -13,9 +21,13 @@ export interface PlantUmlDiagramProps {
|
|
|
13
21
|
zoom?: boolean;
|
|
14
22
|
}
|
|
15
23
|
/**
|
|
16
|
-
* Renders one
|
|
24
|
+
* Renders one diagram in the browser, with either engine.
|
|
25
|
+
*
|
|
26
|
+
* The component is deliberately engine-agnostic: its job is states, ARIA, zoom and error
|
|
27
|
+
* presentation, none of which differs between PlantUML and Graphviz. Only the render call and
|
|
28
|
+
* the wording around it branch. (The name is historical — it predates Graphviz support.)
|
|
17
29
|
*
|
|
18
30
|
* Nothing happens during server-side rendering: the markup emitted on the server is the
|
|
19
31
|
* same deferred placeholder the client renders first, so hydration never mismatches.
|
|
20
32
|
*/
|
|
21
|
-
export default function PlantUmlDiagram({ source, title, language, zoom: zoomProp, }: PlantUmlDiagramProps): ReactNode;
|
|
33
|
+
export default function PlantUmlDiagram({ source, title, language, engine, layout, zoom: zoomProp, }: PlantUmlDiagramProps): ReactNode;
|
|
@@ -4,11 +4,19 @@ import { useColorMode } from '@docusaurus/theme-common';
|
|
|
4
4
|
import { DATA_ATTR } from '../../constants.js';
|
|
5
5
|
import { getSharedCache } from '../../runtime/cache.js';
|
|
6
6
|
import { PlantUmlError } from '../../runtime/errors.js';
|
|
7
|
+
import { renderGraphvizDiagram } from '../../runtime/graphvizRenderer.js';
|
|
7
8
|
import { renderDiagram } from '../../runtime/renderer.js';
|
|
8
9
|
import { usePlantUmlConfig } from '../usePlantUmlConfig.js';
|
|
9
10
|
import { useZoomPan } from './useZoomPan.js';
|
|
10
11
|
import styles from './styles.module.css';
|
|
11
|
-
|
|
12
|
+
/**
|
|
13
|
+
* Human-readable engine names, used in labels, progress text and error headings so that a
|
|
14
|
+
* reader is told which engine is involved rather than being shown a generic failure.
|
|
15
|
+
*/
|
|
16
|
+
const ENGINE_LABEL = {
|
|
17
|
+
plantuml: 'PlantUML',
|
|
18
|
+
graphviz: 'Graphviz',
|
|
19
|
+
};
|
|
12
20
|
/** Margin large enough that a diagram is usually ready by the time it is scrolled into view. */
|
|
13
21
|
const LAZY_ROOT_MARGIN = '300px';
|
|
14
22
|
const INITIAL_STATE = { status: 'idle', svg: null, error: null };
|
|
@@ -27,12 +35,16 @@ function messageFor(error) {
|
|
|
27
35
|
return 'The diagram could not be rendered.';
|
|
28
36
|
}
|
|
29
37
|
/**
|
|
30
|
-
* Renders one
|
|
38
|
+
* Renders one diagram in the browser, with either engine.
|
|
39
|
+
*
|
|
40
|
+
* The component is deliberately engine-agnostic: its job is states, ARIA, zoom and error
|
|
41
|
+
* presentation, none of which differs between PlantUML and Graphviz. Only the render call and
|
|
42
|
+
* the wording around it branch. (The name is historical — it predates Graphviz support.)
|
|
31
43
|
*
|
|
32
44
|
* Nothing happens during server-side rendering: the markup emitted on the server is the
|
|
33
45
|
* same deferred placeholder the client renders first, so hydration never mismatches.
|
|
34
46
|
*/
|
|
35
|
-
export default function PlantUmlDiagram({ source, title, language, zoom: zoomProp, }) {
|
|
47
|
+
export default function PlantUmlDiagram({ source, title, language, engine = 'plantuml', layout, zoom: zoomProp, }) {
|
|
36
48
|
const config = usePlantUmlConfig();
|
|
37
49
|
const { colorMode } = useColorMode();
|
|
38
50
|
const containerRef = useRef(null);
|
|
@@ -40,6 +52,14 @@ export default function PlantUmlDiagram({ source, title, language, zoom: zoomPro
|
|
|
40
52
|
const themeOption = config?.options.theme ?? 'auto';
|
|
41
53
|
const dark = themeOption === 'auto' ? colorMode === 'dark' : themeOption === 'dark';
|
|
42
54
|
const lazy = config?.options.lazy ?? true;
|
|
55
|
+
const engineName = ENGINE_LABEL[engine];
|
|
56
|
+
const graphvizOptions = config?.options.graphviz;
|
|
57
|
+
// A fence may only pick a layout engine when the site allows it; otherwise the configured
|
|
58
|
+
// default wins silently, exactly as it would if the fence had said nothing.
|
|
59
|
+
const resolvedLayout = (graphvizOptions?.allowEngineOverride ? layout : undefined) ?? graphvizOptions?.engine ?? 'dot';
|
|
60
|
+
// Graphviz output does not depend on the colour mode — it adapts through CSS instead — so a
|
|
61
|
+
// colour-mode toggle must not re-run the render effect for a DOT diagram.
|
|
62
|
+
const renderDark = engine === 'graphviz' ? false : dark;
|
|
43
63
|
// A fence flag wins over the plugin option, which in turn wins over the built-in default.
|
|
44
64
|
const interactive = zoomProp ?? config?.options.zoom ?? true;
|
|
45
65
|
const [state, setState] = useState(INITIAL_STATE);
|
|
@@ -47,7 +67,10 @@ export default function PlantUmlDiagram({ source, title, language, zoom: zoomPro
|
|
|
47
67
|
// Called unconditionally, as hooks must be; it attaches nothing when not interactive.
|
|
48
68
|
const zoom = useZoomPan({
|
|
49
69
|
enabled: interactive && state.status === 'ready',
|
|
50
|
-
|
|
70
|
+
// Keyed on what can change the picture. `renderDark`, not `dark`: a Graphviz diagram is
|
|
71
|
+
// not re-rendered on a colour-mode toggle, so resetting the reader's zoom would be a
|
|
72
|
+
// gratuitous jump.
|
|
73
|
+
resetKey: `${source}|${engine}|${resolvedLayout}|${renderDark ? 'dark' : 'light'}`,
|
|
51
74
|
});
|
|
52
75
|
useEffect(() => {
|
|
53
76
|
if (!lazy || inView)
|
|
@@ -77,7 +100,7 @@ export default function PlantUmlDiagram({ source, title, language, zoom: zoomPro
|
|
|
77
100
|
setState({
|
|
78
101
|
status: 'error',
|
|
79
102
|
svg: null,
|
|
80
|
-
error: '
|
|
103
|
+
error: 'Diagram plugin data is missing. Add the plugin to the `plugins` array in your ' +
|
|
81
104
|
'Docusaurus configuration and restart the build.',
|
|
82
105
|
});
|
|
83
106
|
return undefined;
|
|
@@ -96,9 +119,8 @@ export default function PlantUmlDiagram({ source, title, language, zoom: zoomPro
|
|
|
96
119
|
? previous
|
|
97
120
|
: next);
|
|
98
121
|
};
|
|
99
|
-
|
|
122
|
+
const shared = {
|
|
100
123
|
source,
|
|
101
|
-
dark,
|
|
102
124
|
sanitize: options.sanitizeSvg,
|
|
103
125
|
timeoutMs: options.renderTimeoutMs,
|
|
104
126
|
assetsBaseUrl: config.assetsBaseUrl,
|
|
@@ -106,7 +128,16 @@ export default function PlantUmlDiagram({ source, title, language, zoom: zoomPro
|
|
|
106
128
|
cache: getSharedCache(options.cache, options.cacheMaxEntries),
|
|
107
129
|
signal: controller.signal,
|
|
108
130
|
onPhase: (phase) => commit({ status: phase, svg: null, error: null }),
|
|
109
|
-
}
|
|
131
|
+
};
|
|
132
|
+
const pending = engine === 'graphviz'
|
|
133
|
+
? renderGraphvizDiagram({
|
|
134
|
+
...shared,
|
|
135
|
+
layout: resolvedLayout,
|
|
136
|
+
transparentBackground: options.graphviz.transparentBackground,
|
|
137
|
+
maxSourceBytes: options.graphviz.maxSourceBytes,
|
|
138
|
+
})
|
|
139
|
+
: renderDiagram({ ...shared, dark: renderDark });
|
|
140
|
+
void pending
|
|
110
141
|
.then((svg) => commit({ status: 'ready', svg, error: null }))
|
|
111
142
|
.catch((error) => {
|
|
112
143
|
if (error instanceof PlantUmlError && error.kind === 'aborted')
|
|
@@ -119,7 +150,9 @@ export default function PlantUmlDiagram({ source, title, language, zoom: zoomPro
|
|
|
119
150
|
}, [
|
|
120
151
|
inView,
|
|
121
152
|
source,
|
|
122
|
-
|
|
153
|
+
renderDark,
|
|
154
|
+
engine,
|
|
155
|
+
resolvedLayout,
|
|
123
156
|
config,
|
|
124
157
|
config?.assetsBaseUrl,
|
|
125
158
|
config?.coreVersion,
|
|
@@ -127,22 +160,26 @@ export default function PlantUmlDiagram({ source, title, language, zoom: zoomPro
|
|
|
127
160
|
config?.options.renderTimeoutMs,
|
|
128
161
|
config?.options.cache,
|
|
129
162
|
config?.options.cacheMaxEntries,
|
|
163
|
+
config?.options.graphviz.transparentBackground,
|
|
164
|
+
config?.options.graphviz.maxSourceBytes,
|
|
130
165
|
]);
|
|
131
|
-
const label = title ??
|
|
166
|
+
const label = title ?? `${engineName} diagram`;
|
|
132
167
|
const busy = state.status === 'loading' || state.status === 'rendering';
|
|
133
168
|
// The sanitized SVG always lives in this element, zoomable or not, so `role="img" > svg`
|
|
134
169
|
// stays a stable contract for tests and for author CSS.
|
|
135
170
|
const canvas = state.svg !== null && (_jsx("div", { className: styles.canvas, role: "img", "aria-label": label,
|
|
136
171
|
// Sanitized above unless `sanitizeSvg: false` was explicitly opted into.
|
|
137
172
|
dangerouslySetInnerHTML: { __html: state.svg } }));
|
|
138
|
-
return (_jsxs("figure", { ref: containerRef, className: styles.figure, "aria-busy": busy || undefined, [DATA_ATTR.diagram]: language ??
|
|
173
|
+
return (_jsxs("figure", { ref: containerRef, className: styles.figure, "aria-busy": busy || undefined, [DATA_ATTR.diagram]: language ?? engine,
|
|
174
|
+
[DATA_ATTR.engine]: engine,
|
|
175
|
+
...(engine === 'graphviz' ? { [DATA_ATTR.layout]: resolvedLayout } : {}),
|
|
139
176
|
[DATA_ATTR.status]: state.status,
|
|
140
177
|
[DATA_ATTR.theme]: dark ? 'dark' : 'light',
|
|
141
178
|
...(interactive ? { [DATA_ATTR.interactive]: 'true' } : {}),
|
|
142
179
|
...(zoom.maximized ? { [DATA_ATTR.maximized]: 'true' } : {}), children: [state.status === 'ready' && state.svg !== null && !interactive && canvas, state.status === 'ready' && state.svg !== null && interactive && (_jsxs("div", { ref: zoom.stageRef, className: zoom.maximized ? `${styles.stage} ${styles.maximized}` : styles.stage, children: [_jsx("div", { ref: zoom.viewportRef, className: styles.viewport, tabIndex: 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 }) }), _jsxs("div", { className: styles.toolbar, role: "group", "aria-label": `${label} zoom controls`, children: [_jsx("button", { type: "button", className: styles.toolbarButton, "aria-label": "Zoom out", onClick: zoom.zoomOut, children: _jsx("span", { "aria-hidden": "true", children: "\u2212" }) }), _jsx("button", { type: "button", className: styles.toolbarButton, "aria-label": "Zoom in", onClick: zoom.zoomIn, children: _jsx("span", { "aria-hidden": "true", children: "+" }) }), _jsx("button", { type: "button", className: styles.toolbarButton, "aria-label": "Reset zoom", onClick: zoom.reset, children: _jsx("span", { "aria-hidden": "true", children: "\u27F2" }) }), _jsx("button", { type: "button", className: styles.toolbarButton, "aria-label": "Maximize diagram", "aria-pressed": zoom.maximized, onClick: zoom.toggleMaximize, children: _jsx("span", { "aria-hidden": "true", children: zoom.maximized ? '✕' : '⛶' }) }), _jsx("span", { ref: zoom.readoutRef, className: 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' && (
|
|
143
180
|
// `aria-busy` on the figure already conveys progress; announcing every phase change
|
|
144
181
|
// would make a page with several diagrams unusable with a screen reader.
|
|
145
|
-
_jsxs("div", { className: styles.placeholder, children: [_jsx("span", { className: styles.spinner, "aria-hidden": "true" }), _jsxs("span", { children: [state.status === 'loading' ?
|
|
182
|
+
_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: {
|
|
146
183
|
__html: `<pre class="${styles.source ?? ''}">${escapeHtml(source)}</pre>`,
|
|
147
184
|
} })] }));
|
|
148
185
|
}
|
|
@@ -14,6 +14,37 @@
|
|
|
14
14
|
height: auto;
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
+
/*
|
|
18
|
+
* Graphviz colour adaptation.
|
|
19
|
+
*
|
|
20
|
+
* Graphviz has no dark theme: it draws black on white regardless of the page. Rather than
|
|
21
|
+
* re-rendering the graph for each colour mode — which would double the cache and fight authors
|
|
22
|
+
* who set their own colours — the *defaults* are retargeted at `currentColor`, so a diagram
|
|
23
|
+
* inherits the page's text colour in both modes.
|
|
24
|
+
*
|
|
25
|
+
* The selectors are deliberately narrow. Graphviz emits `stroke="black"` / `fill="black"` for
|
|
26
|
+
* everything it colours by default, and leaves `<text>` with no fill at all; anything the DOT
|
|
27
|
+
* source colours explicitly comes out as that colour (`stroke="red"`, `fill="lightblue"`) and
|
|
28
|
+
* is therefore never matched here. Colours set in the diagram source always win.
|
|
29
|
+
*
|
|
30
|
+
* `fill="none"` is likewise untouched, which is what keeps unfilled node shapes unfilled.
|
|
31
|
+
*/
|
|
32
|
+
[data-diagram-engine='graphviz'] .canvas svg text:not([fill]) {
|
|
33
|
+
fill: currentColor;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
[data-diagram-engine='graphviz'] .canvas svg [stroke='black'],
|
|
37
|
+
[data-diagram-engine='graphviz'] .canvas svg [stroke='#000000'],
|
|
38
|
+
[data-diagram-engine='graphviz'] .canvas svg [stroke='#000'] {
|
|
39
|
+
stroke: currentColor;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
[data-diagram-engine='graphviz'] .canvas svg [fill='black'],
|
|
43
|
+
[data-diagram-engine='graphviz'] .canvas svg [fill='#000000'],
|
|
44
|
+
[data-diagram-engine='graphviz'] .canvas svg [fill='#000'] {
|
|
45
|
+
fill: currentColor;
|
|
46
|
+
}
|
|
47
|
+
|
|
17
48
|
/*
|
|
18
49
|
* Zoom and pan.
|
|
19
50
|
*
|
|
@@ -121,8 +121,6 @@ export function useZoomPan({ enabled, resetKey }) {
|
|
|
121
121
|
const viewport = viewportRef.current;
|
|
122
122
|
if (!viewport)
|
|
123
123
|
return undefined;
|
|
124
|
-
// The layer node is replaced whenever the rendered SVG changes; start from a clean view.
|
|
125
|
-
write(IDENTITY);
|
|
126
124
|
let dragging = false;
|
|
127
125
|
let pointerId = null;
|
|
128
126
|
let startX = 0;
|
|
@@ -255,9 +253,16 @@ export function useZoomPan({ enabled, resetKey }) {
|
|
|
255
253
|
// the listeners against a clean view.
|
|
256
254
|
}, [apply, enabled, measure, resetKey, write]);
|
|
257
255
|
/**
|
|
258
|
-
*
|
|
259
|
-
*
|
|
260
|
-
*
|
|
256
|
+
* The single owner of "reset the view".
|
|
257
|
+
*
|
|
258
|
+
* `resetKey` identifies the rendered picture, so this covers both a new diagram and a
|
|
259
|
+
* colour-mode change — including the case where a cache hit resolves before any phase is
|
|
260
|
+
* reported and the component goes `ready -> ready` without the layer ever unmounting.
|
|
261
|
+
*
|
|
262
|
+
* The listener effect deliberately does *not* also reset. Its dependencies are a superset of
|
|
263
|
+
* this one's, so any re-attach for an unrelated reason — a remount, or a callback identity
|
|
264
|
+
* change from a future refactor — would silently throw away a zoom the reader had chosen.
|
|
265
|
+
* One owner means a reset happens when the picture changes, and at no other time.
|
|
261
266
|
*/
|
|
262
267
|
useEffect(() => {
|
|
263
268
|
if (!enabled)
|
|
@@ -32,5 +32,14 @@ export declare function extractSource(children: ReactNode): string | null;
|
|
|
32
32
|
* unrecognized value is ignored rather than failing the site build.
|
|
33
33
|
*/
|
|
34
34
|
export declare function parseBooleanMeta(props: CodeBlockProps, key: string): boolean | undefined;
|
|
35
|
+
/**
|
|
36
|
+
* Reads a `key=value` pair from the fence metastring, accepting `engine=neato`,
|
|
37
|
+
* `engine="neato"` and `engine='neato'` alike.
|
|
38
|
+
*
|
|
39
|
+
* Unlike {@link parseBooleanMeta} this runs against the raw metastring, because the value it
|
|
40
|
+
* is looking for may itself be the quoted part. Returns `undefined` when the key is absent or
|
|
41
|
+
* carries no value, which the caller reads as "fall back to the plugin option".
|
|
42
|
+
*/
|
|
43
|
+
export declare function parseStringMeta(props: CodeBlockProps, key: string): string | undefined;
|
|
35
44
|
/** Reads `title="..."` (or `title='...'`) from the fence metastring. */
|
|
36
45
|
export declare function parseTitle(props: CodeBlockProps): string | undefined;
|