@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.
@@ -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. */
@@ -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();
@@ -1,4 +1,5 @@
1
- export type PlantUmlErrorKind = 'load' | 'engine' | 'diagram' | 'timeout' | 'config' | 'aborted';
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;
@@ -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
+ }
@@ -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 as PlantUML are intercepted. Everything else —
7
- * inline code, ordinary fenced blocks, JSX-authored blocks — is delegated to the original
8
- * component with its props untouched, so no existing code-block behaviour changes.
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 as PlantUML are intercepted. Everything else —
10
- * inline code, ordinary fenced blocks, JSX-authored blocks — is delegated to the original
11
- * component with its props untouched, so no existing code-block behaviour changes.
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 isPlantUml = config !== null && language !== undefined && config.options.languages.includes(language);
23
- const source = isPlantUml ? extractSource(props.children) : null;
24
- if (!isPlantUml || source === null) {
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 PlantUML source, exactly as authored between the fence markers. */
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` or `puml`. */
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 PlantUML diagram in the browser.
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
- const DEFAULT_LABEL = 'PlantUML diagram';
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 PlantUML diagram in the browser.
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
- resetKey: `${source}|${dark ? 'dark' : 'light'}`,
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: 'PlantUML plugin data is missing. Add the plugin to the `plugins` array in your ' +
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
- void renderDiagram({
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
- dark,
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 ?? DEFAULT_LABEL;
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 ?? 'plantuml',
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' ? 'Loading PlantUML 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" }), _jsx("span", { children: "Error: PlantUML 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: {
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
- * A cache hit resolves before any phase is reported, so a colour-mode toggle can go
259
- * `ready -> ready` without ever unmounting the layer. The effect above would not re-run in
260
- * that case, which is why the reset is also driven directly by `resetKey`.
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;