@matfsw/docusaurus-plantuml-plugin 1.0.3 → 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.
@@ -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
  *
@@ -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;
@@ -81,6 +81,27 @@ export function parseBooleanMeta(props, key) {
81
81
  return false;
82
82
  return undefined;
83
83
  }
84
+ /**
85
+ * Reads a `key=value` pair from the fence metastring, accepting `engine=neato`,
86
+ * `engine="neato"` and `engine='neato'` alike.
87
+ *
88
+ * Unlike {@link parseBooleanMeta} this runs against the raw metastring, because the value it
89
+ * is looking for may itself be the quoted part. Returns `undefined` when the key is absent or
90
+ * carries no value, which the caller reads as "fall back to the plugin option".
91
+ */
92
+ export function parseStringMeta(props, key) {
93
+ const metastring = props.metastring;
94
+ if (typeof metastring !== 'string' || metastring === '')
95
+ return undefined;
96
+ // Word boundaries by hand, matching parseBooleanMeta: `subengine=` must not match `engine`.
97
+ const pattern = new RegExp(`(?:^|\\s)${key}=(?:"([^"]*)"|'([^']*)'|(\\S+))(?=\\s|$)`, 'i');
98
+ const match = pattern.exec(metastring);
99
+ if (!match)
100
+ return undefined;
101
+ const value = match[1] ?? match[2] ?? match[3];
102
+ const trimmed = value?.trim();
103
+ return trimmed !== undefined && trimmed !== '' ? trimmed : undefined;
104
+ }
84
105
  /** Reads `title="..."` (or `title='...'`) from the fence metastring. */
85
106
  export function parseTitle(props) {
86
107
  if (typeof props.title === 'string' && props.title !== '')
@@ -1,4 +1,4 @@
1
- import type { ResolvedPlantUmlOptions } from '../options.js';
1
+ import { type ResolvedPlantUmlOptions } from '../options.js';
2
2
  export interface PlantUmlRuntimeConfig {
3
3
  options: ResolvedPlantUmlOptions;
4
4
  /** Absolute, `baseUrl`-prefixed URL of the directory holding the runtime assets. */
@@ -2,6 +2,7 @@ import { useMemo } from 'react';
2
2
  import useBaseUrl from '@docusaurus/useBaseUrl';
3
3
  import { useAllPluginInstancesData } from '@docusaurus/useGlobalData';
4
4
  import { PLUGIN_NAME } from '../constants.js';
5
+ import { DEFAULT_GRAPHVIZ_OPTIONS } from '../options.js';
5
6
  function isGlobalData(value) {
6
7
  if (typeof value !== 'object' || value === null)
7
8
  return false;
@@ -25,5 +26,23 @@ export function usePlantUmlConfig() {
25
26
  // Memoized because consumers use this object as an effect dependency. Returning a fresh
26
27
  // object on every render would restart the render effect on every state update, which
27
28
  // re-enters rendering forever.
28
- return useMemo(() => (first ? { options: first.options, assetsBaseUrl, coreVersion: first.coreVersion } : null), [first, assetsBaseUrl]);
29
+ return useMemo(() => first
30
+ ? {
31
+ options: withGraphvizDefaults(first.options),
32
+ assetsBaseUrl,
33
+ coreVersion: first.coreVersion,
34
+ }
35
+ : null, [first, assetsBaseUrl]);
36
+ }
37
+ /**
38
+ * Fills in the `graphviz` group when it is missing from global data.
39
+ *
40
+ * `resolveOptions` always produces it, so this only matters for data written by an older
41
+ * version of the plugin — a stale `.docusaurus` cache surviving an upgrade, most plausibly.
42
+ * Substituting the defaults keeps such a site rendering instead of failing on a missing key.
43
+ */
44
+ function withGraphvizDefaults(options) {
45
+ if (options.graphviz)
46
+ return options;
47
+ return { ...options, graphviz: DEFAULT_GRAPHVIZ_OPTIONS };
29
48
  }