@matfsw/docusaurus-plantuml-plugin 0.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 +33 -0
- package/LICENSE +21 -0
- package/README.md +781 -0
- package/dist/assets.d.ts +15 -0
- package/dist/assets.js +32 -0
- package/dist/constants.d.ts +22 -0
- package/dist/constants.js +24 -0
- package/dist/index.cjs +288 -0
- package/dist/index.d.cts +97 -0
- package/dist/index.d.ts +40 -0
- package/dist/index.js +74 -0
- package/dist/options.d.ts +46 -0
- package/dist/options.js +131 -0
- package/dist/runtime/assetLoader.d.ts +23 -0
- package/dist/runtime/assetLoader.js +125 -0
- package/dist/runtime/cache.d.ts +20 -0
- package/dist/runtime/cache.js +219 -0
- package/dist/runtime/errors.d.ts +31 -0
- package/dist/runtime/errors.js +108 -0
- package/dist/runtime/queue.d.ts +27 -0
- package/dist/runtime/queue.js +101 -0
- package/dist/runtime/renderer.d.ts +23 -0
- package/dist/runtime/renderer.js +70 -0
- package/dist/runtime/sanitize.d.ts +12 -0
- package/dist/runtime/sanitize.js +47 -0
- package/dist/runtime/types.d.ts +15 -0
- package/dist/runtime/types.js +1 -0
- package/dist/theme/MDXComponents/Code/index.d.ts +16 -0
- package/dist/theme/MDXComponents/Code/index.js +28 -0
- package/dist/theme/PlantUmlDiagram/index.d.ts +16 -0
- package/dist/theme/PlantUmlDiagram/index.js +134 -0
- package/dist/theme/PlantUmlDiagram/styles.module.css +107 -0
- package/dist/theme/codeBlockMeta.d.ts +27 -0
- package/dist/theme/codeBlockMeta.js +58 -0
- package/dist/theme/usePlantUmlConfig.d.ts +14 -0
- package/dist/theme/usePlantUmlConfig.js +29 -0
- package/package.json +112 -0
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { PlantUmlError, toError } from './errors.js';
|
|
2
|
+
const pending = [];
|
|
3
|
+
let active = false;
|
|
4
|
+
function pump() {
|
|
5
|
+
if (active)
|
|
6
|
+
return;
|
|
7
|
+
const next = pending.shift();
|
|
8
|
+
if (!next)
|
|
9
|
+
return;
|
|
10
|
+
active = true;
|
|
11
|
+
next.start();
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Queues `task` behind every previously queued task.
|
|
15
|
+
*
|
|
16
|
+
* `task` receives a signal that is aborted on timeout or caller abort, so long-running
|
|
17
|
+
* work can bail out instead of writing into a discarded render.
|
|
18
|
+
*/
|
|
19
|
+
export function enqueueRender(task, options) {
|
|
20
|
+
const { timeoutMs, signal } = options;
|
|
21
|
+
return new Promise((resolve, reject) => {
|
|
22
|
+
let settled = false;
|
|
23
|
+
let advanced = false;
|
|
24
|
+
let timer;
|
|
25
|
+
const controller = new AbortController();
|
|
26
|
+
const onExternalAbort = () => {
|
|
27
|
+
controller.abort();
|
|
28
|
+
// Deliberately does not advance the queue: the engine may still be mid-render, and
|
|
29
|
+
// starting the next diagram now would reintroduce the concurrency corruption.
|
|
30
|
+
settleWith(() => reject(new PlantUmlError('aborted', 'Render aborted.')));
|
|
31
|
+
};
|
|
32
|
+
function settleWith(fn) {
|
|
33
|
+
if (settled)
|
|
34
|
+
return;
|
|
35
|
+
settled = true;
|
|
36
|
+
if (timer !== undefined)
|
|
37
|
+
clearTimeout(timer);
|
|
38
|
+
signal?.removeEventListener('abort', onExternalAbort);
|
|
39
|
+
fn();
|
|
40
|
+
}
|
|
41
|
+
/** Releases the single execution slot. Idempotent, so timeout + late settle is safe. */
|
|
42
|
+
function advance() {
|
|
43
|
+
if (advanced)
|
|
44
|
+
return;
|
|
45
|
+
advanced = true;
|
|
46
|
+
active = false;
|
|
47
|
+
// Yield so a synchronously-resolving task cannot grow the stack without bound.
|
|
48
|
+
queueMicrotask(pump);
|
|
49
|
+
}
|
|
50
|
+
if (signal?.aborted) {
|
|
51
|
+
// Never occupy a queue slot for work whose result is already unwanted.
|
|
52
|
+
reject(new PlantUmlError('aborted', 'Render aborted before it started.'));
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
signal?.addEventListener('abort', onExternalAbort, { once: true });
|
|
56
|
+
pending.push({
|
|
57
|
+
start: () => {
|
|
58
|
+
if (settled) {
|
|
59
|
+
advance();
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
timer = setTimeout(() => {
|
|
63
|
+
controller.abort();
|
|
64
|
+
settleWith(() => reject(new PlantUmlError('timeout', `PlantUML rendering timed out after ${timeoutMs} ms.`)));
|
|
65
|
+
// A hung engine call must not block every later diagram on the page.
|
|
66
|
+
advance();
|
|
67
|
+
}, timeoutMs);
|
|
68
|
+
let taskPromise;
|
|
69
|
+
try {
|
|
70
|
+
taskPromise = task(controller.signal);
|
|
71
|
+
}
|
|
72
|
+
catch (error) {
|
|
73
|
+
settleWith(() => reject(toError(error)));
|
|
74
|
+
advance();
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
void taskPromise.then((value) => {
|
|
78
|
+
settleWith(() => resolve(value));
|
|
79
|
+
advance();
|
|
80
|
+
}, (error) => {
|
|
81
|
+
settleWith(() => reject(toError(error)));
|
|
82
|
+
advance();
|
|
83
|
+
});
|
|
84
|
+
},
|
|
85
|
+
});
|
|
86
|
+
pump();
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
/** Number of tasks waiting to start. Exposed for tests and diagnostics. */
|
|
90
|
+
export function pendingRenderCount() {
|
|
91
|
+
return pending.length;
|
|
92
|
+
}
|
|
93
|
+
/** Whether a task is currently running. Exposed for tests and diagnostics. */
|
|
94
|
+
export function isRenderActive() {
|
|
95
|
+
return active;
|
|
96
|
+
}
|
|
97
|
+
/** Test-only: drops queued work and clears the active flag. */
|
|
98
|
+
export function resetRenderQueue() {
|
|
99
|
+
pending.length = 0;
|
|
100
|
+
active = false;
|
|
101
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { type LoadRuntimeOptions } from './assetLoader.js';
|
|
2
|
+
import { type DiagramCache } from './cache.js';
|
|
3
|
+
export interface RenderDiagramRequest {
|
|
4
|
+
source: string;
|
|
5
|
+
dark: boolean;
|
|
6
|
+
sanitize: boolean;
|
|
7
|
+
timeoutMs: number;
|
|
8
|
+
assetsBaseUrl: string;
|
|
9
|
+
coreVersion: string;
|
|
10
|
+
cache: DiagramCache;
|
|
11
|
+
signal?: AbortSignal;
|
|
12
|
+
/** Reports which long-running phase is in progress, so the UI can show an accurate state. */
|
|
13
|
+
onPhase?: (phase: 'loading' | 'rendering') => void;
|
|
14
|
+
/** Test seam mirroring {@link LoadRuntimeOptions.importModule}. */
|
|
15
|
+
importModule?: LoadRuntimeOptions['importModule'];
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Renders one diagram to sanitized SVG.
|
|
19
|
+
*
|
|
20
|
+
* Cache hits skip the queue entirely; everything else is serialized behind
|
|
21
|
+
* {@link enqueueRender} because the engine cannot render two diagrams at once.
|
|
22
|
+
*/
|
|
23
|
+
export declare function renderDiagram(request: RenderDiagramRequest): Promise<string>;
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { loadPlantUmlRuntime } from './assetLoader.js';
|
|
2
|
+
import { computeCacheKey } from './cache.js';
|
|
3
|
+
import { describeEngineError, detectDiagramError, PlantUmlError } from './errors.js';
|
|
4
|
+
import { enqueueRender } from './queue.js';
|
|
5
|
+
import { sanitizeSvgMarkup } from './sanitize.js';
|
|
6
|
+
/**
|
|
7
|
+
* `renderToString` takes its options as a *fourth* argument, after the two callbacks.
|
|
8
|
+
* This was verified against the installed `@plantuml/core` rather than assumed: the
|
|
9
|
+
* function's arity is 4 and `{dark: true}` produces different fill colours from `{dark:
|
|
10
|
+
* false}`, which itself is byte-identical to omitting the argument.
|
|
11
|
+
*/
|
|
12
|
+
function renderWithEngine(engine, source, dark) {
|
|
13
|
+
return new Promise((resolve, reject) => {
|
|
14
|
+
let settled = false;
|
|
15
|
+
const lines = source.split(/\r\n|\r|\n/);
|
|
16
|
+
try {
|
|
17
|
+
engine.renderToString(lines, (svg) => {
|
|
18
|
+
if (settled)
|
|
19
|
+
return;
|
|
20
|
+
settled = true;
|
|
21
|
+
resolve(svg);
|
|
22
|
+
}, (raw) => {
|
|
23
|
+
if (settled)
|
|
24
|
+
return;
|
|
25
|
+
settled = true;
|
|
26
|
+
reject(new PlantUmlError('engine', describeEngineError(raw)));
|
|
27
|
+
}, { dark });
|
|
28
|
+
}
|
|
29
|
+
catch (error) {
|
|
30
|
+
if (settled)
|
|
31
|
+
return;
|
|
32
|
+
settled = true;
|
|
33
|
+
reject(new PlantUmlError('engine', describeEngineError(error), { cause: error }));
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Renders one diagram to sanitized SVG.
|
|
39
|
+
*
|
|
40
|
+
* Cache hits skip the queue entirely; everything else is serialized behind
|
|
41
|
+
* {@link enqueueRender} because the engine cannot render two diagrams at once.
|
|
42
|
+
*/
|
|
43
|
+
export async function renderDiagram(request) {
|
|
44
|
+
const { source, dark, sanitize, timeoutMs, cache, coreVersion, signal, onPhase } = request;
|
|
45
|
+
const cacheKey = computeCacheKey({ source, dark, sanitized: sanitize, coreVersion });
|
|
46
|
+
const cached = cache.get(cacheKey);
|
|
47
|
+
if (cached !== undefined)
|
|
48
|
+
return cached;
|
|
49
|
+
// Loading happens outside the queue: it is shared, idempotent work, and holding the
|
|
50
|
+
// single render slot for an 8 MB download would stall every other diagram on the page.
|
|
51
|
+
onPhase?.('loading');
|
|
52
|
+
const engine = await loadPlantUmlRuntime({
|
|
53
|
+
assetsBaseUrl: request.assetsBaseUrl,
|
|
54
|
+
timeoutMs,
|
|
55
|
+
importModule: request.importModule,
|
|
56
|
+
});
|
|
57
|
+
onPhase?.('rendering');
|
|
58
|
+
const svg = await enqueueRender(async () => {
|
|
59
|
+
const raw = await renderWithEngine(engine, source, dark);
|
|
60
|
+
// Invalid PlantUML is reported as a successfully rendered "error picture", not through
|
|
61
|
+
// the error callback, so a rendered SVG still has to be inspected before it is trusted.
|
|
62
|
+
const diagramError = detectDiagramError(raw);
|
|
63
|
+
if (diagramError !== null) {
|
|
64
|
+
throw new PlantUmlError('diagram', diagramError);
|
|
65
|
+
}
|
|
66
|
+
return sanitize ? sanitizeSvgMarkup(raw) : raw;
|
|
67
|
+
}, { timeoutMs, signal });
|
|
68
|
+
cache.set(cacheKey, svg);
|
|
69
|
+
return svg;
|
|
70
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sanitizes a rendered SVG string.
|
|
3
|
+
*
|
|
4
|
+
* Scripts, event-handler attributes and `javascript:` URLs are removed; ordinary PlantUML
|
|
5
|
+
* geometry, text, gradients, markers, links and `data-*`/ARIA attributes are preserved.
|
|
6
|
+
*
|
|
7
|
+
* @throws PlantUmlError when the input contains no SVG root at all, which would otherwise
|
|
8
|
+
* surface as a silently blank diagram.
|
|
9
|
+
*/
|
|
10
|
+
export declare function sanitizeSvgMarkup(svg: string): string;
|
|
11
|
+
/** Test-only: forces the DOMPurify instance to be rebuilt against the current window. */
|
|
12
|
+
export declare function resetSanitizer(): void;
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import createDOMPurify from 'dompurify';
|
|
2
|
+
import { PlantUmlError } from './errors.js';
|
|
3
|
+
let purifier = null;
|
|
4
|
+
function getPurifier() {
|
|
5
|
+
if (purifier)
|
|
6
|
+
return purifier;
|
|
7
|
+
if (typeof window === 'undefined') {
|
|
8
|
+
throw new PlantUmlError('config', 'SVG sanitization requires a browser environment.');
|
|
9
|
+
}
|
|
10
|
+
purifier = createDOMPurify(window);
|
|
11
|
+
return purifier;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* `foreignObject` is the one SVG element that can host arbitrary HTML, which reintroduces
|
|
15
|
+
* exactly the injection surface the SVG profile exists to remove. PlantUML does not need it.
|
|
16
|
+
*/
|
|
17
|
+
const FORBIDDEN_TAGS = ['foreignobject', 'script', 'iframe', 'object', 'embed', 'audio', 'video'];
|
|
18
|
+
/**
|
|
19
|
+
* Sanitizes a rendered SVG string.
|
|
20
|
+
*
|
|
21
|
+
* Scripts, event-handler attributes and `javascript:` URLs are removed; ordinary PlantUML
|
|
22
|
+
* geometry, text, gradients, markers, links and `data-*`/ARIA attributes are preserved.
|
|
23
|
+
*
|
|
24
|
+
* @throws PlantUmlError when the input contains no SVG root at all, which would otherwise
|
|
25
|
+
* surface as a silently blank diagram.
|
|
26
|
+
*/
|
|
27
|
+
export function sanitizeSvgMarkup(svg) {
|
|
28
|
+
const purify = getPurifier();
|
|
29
|
+
const clean = purify.sanitize(svg, {
|
|
30
|
+
USE_PROFILES: { svg: true, svgFilters: true },
|
|
31
|
+
FORBID_TAGS: FORBIDDEN_TAGS,
|
|
32
|
+
FORBID_ATTR: ['formaction', 'xlink:show', 'ping'],
|
|
33
|
+
ADD_ATTR: ['role', 'aria-label', 'aria-labelledby', 'aria-describedby'],
|
|
34
|
+
ALLOW_DATA_ATTR: true,
|
|
35
|
+
KEEP_CONTENT: true,
|
|
36
|
+
RETURN_DOM: false,
|
|
37
|
+
RETURN_DOM_FRAGMENT: false,
|
|
38
|
+
});
|
|
39
|
+
if (!/<svg[\s>]/i.test(clean)) {
|
|
40
|
+
throw new PlantUmlError('engine', 'Sanitization removed the rendered diagram: the engine output contained no usable <svg> root.');
|
|
41
|
+
}
|
|
42
|
+
return clean;
|
|
43
|
+
}
|
|
44
|
+
/** Test-only: forces the DOMPurify instance to be rebuilt against the current window. */
|
|
45
|
+
export function resetSanitizer() {
|
|
46
|
+
purifier = null;
|
|
47
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The public surface of `@plantuml/core@1.2026.x`, verified against the installed package.
|
|
3
|
+
*
|
|
4
|
+
* `plantuml.js` exports exactly two functions. Both take an optional trailing options
|
|
5
|
+
* object whose only recognized member is `dark`; see `docs/architecture.md` for how this
|
|
6
|
+
* was established and `tests/e2e/engine-contract.spec.ts` for the regression guard.
|
|
7
|
+
*/
|
|
8
|
+
export interface PlantUmlCoreModule {
|
|
9
|
+
render(lines: string[], targetId: string, options?: PlantUmlRenderOptions): void;
|
|
10
|
+
renderToString(lines: string[], onSuccess: (svg: string) => void, onError: (message: unknown) => void, options?: PlantUmlRenderOptions): void;
|
|
11
|
+
}
|
|
12
|
+
export interface PlantUmlRenderOptions {
|
|
13
|
+
dark?: boolean;
|
|
14
|
+
}
|
|
15
|
+
export type DiagramStatus = 'idle' | 'loading' | 'rendering' | 'ready' | 'error';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { ReactNode } from 'react';
|
|
2
|
+
import { type CodeBlockProps } from '../../codeBlockMeta.js';
|
|
3
|
+
/**
|
|
4
|
+
* Wraps Docusaurus' `MDXComponents/Code` extension point.
|
|
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.
|
|
9
|
+
*
|
|
10
|
+
* The delegate is imported from `@theme-init/`, not `@theme-original/`. Docusaurus points
|
|
11
|
+
* *both* `@theme/X` and `@theme-original/X` at the last plugin theme that provides `X`, so
|
|
12
|
+
* a plugin importing `@theme-original/MDXComponents/Code` imports itself and recurses until
|
|
13
|
+
* the stack overflows. `@theme-init/X` is the alias that keeps pointing at the first theme
|
|
14
|
+
* to provide the component — see `docs/adr/0001-theme-init-alias.md`.
|
|
15
|
+
*/
|
|
16
|
+
export default function Code(props: CodeBlockProps): ReactNode;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import OriginalCode from '@theme-init/MDXComponents/Code';
|
|
3
|
+
import PlantUmlDiagram from '@theme/PlantUmlDiagram';
|
|
4
|
+
import { extractLanguage, extractSource, parseTitle, } from '../../codeBlockMeta.js';
|
|
5
|
+
import { usePlantUmlConfig } from '../../usePlantUmlConfig.js';
|
|
6
|
+
/**
|
|
7
|
+
* Wraps Docusaurus' `MDXComponents/Code` extension point.
|
|
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.
|
|
12
|
+
*
|
|
13
|
+
* The delegate is imported from `@theme-init/`, not `@theme-original/`. Docusaurus points
|
|
14
|
+
* *both* `@theme/X` and `@theme-original/X` at the last plugin theme that provides `X`, so
|
|
15
|
+
* a plugin importing `@theme-original/MDXComponents/Code` imports itself and recurses until
|
|
16
|
+
* the stack overflows. `@theme-init/X` is the alias that keeps pointing at the first theme
|
|
17
|
+
* to provide the component — see `docs/adr/0001-theme-init-alias.md`.
|
|
18
|
+
*/
|
|
19
|
+
export default function Code(props) {
|
|
20
|
+
const config = usePlantUmlConfig();
|
|
21
|
+
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) {
|
|
25
|
+
return _jsx(OriginalCode, { ...props });
|
|
26
|
+
}
|
|
27
|
+
return _jsx(PlantUmlDiagram, { source: source, title: parseTitle(props), language: language });
|
|
28
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { type ReactNode } from 'react';
|
|
2
|
+
export interface PlantUmlDiagramProps {
|
|
3
|
+
/** Raw PlantUML source, exactly as authored between the fence markers. */
|
|
4
|
+
source: string;
|
|
5
|
+
/** `title="..."` from the fence metastring; used as caption and accessible label. */
|
|
6
|
+
title?: string;
|
|
7
|
+
/** The fence language that matched, e.g. `plantuml` or `puml`. */
|
|
8
|
+
language?: string;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Renders one PlantUML diagram in the browser.
|
|
12
|
+
*
|
|
13
|
+
* Nothing happens during server-side rendering: the markup emitted on the server is the
|
|
14
|
+
* same deferred placeholder the client renders first, so hydration never mismatches.
|
|
15
|
+
*/
|
|
16
|
+
export default function PlantUmlDiagram({ source, title, language, }: PlantUmlDiagramProps): ReactNode;
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useEffect, useRef, useState } from 'react';
|
|
3
|
+
import { useColorMode } from '@docusaurus/theme-common';
|
|
4
|
+
import { DATA_ATTR } from '../../constants.js';
|
|
5
|
+
import { getSharedCache } from '../../runtime/cache.js';
|
|
6
|
+
import { PlantUmlError } from '../../runtime/errors.js';
|
|
7
|
+
import { renderDiagram } from '../../runtime/renderer.js';
|
|
8
|
+
import { usePlantUmlConfig } from '../usePlantUmlConfig.js';
|
|
9
|
+
import styles from './styles.module.css';
|
|
10
|
+
const DEFAULT_LABEL = 'PlantUML diagram';
|
|
11
|
+
/** Margin large enough that a diagram is usually ready by the time it is scrolled into view. */
|
|
12
|
+
const LAZY_ROOT_MARGIN = '300px';
|
|
13
|
+
const INITIAL_STATE = { status: 'idle', svg: null, error: null };
|
|
14
|
+
function escapeHtml(value) {
|
|
15
|
+
return value
|
|
16
|
+
.replace(/&/g, '&')
|
|
17
|
+
.replace(/</g, '<')
|
|
18
|
+
.replace(/>/g, '>')
|
|
19
|
+
.replace(/"/g, '"');
|
|
20
|
+
}
|
|
21
|
+
function messageFor(error) {
|
|
22
|
+
if (error instanceof PlantUmlError)
|
|
23
|
+
return error.message;
|
|
24
|
+
if (error instanceof Error)
|
|
25
|
+
return error.message;
|
|
26
|
+
return 'The diagram could not be rendered.';
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Renders one PlantUML diagram in the browser.
|
|
30
|
+
*
|
|
31
|
+
* Nothing happens during server-side rendering: the markup emitted on the server is the
|
|
32
|
+
* same deferred placeholder the client renders first, so hydration never mismatches.
|
|
33
|
+
*/
|
|
34
|
+
export default function PlantUmlDiagram({ source, title, language, }) {
|
|
35
|
+
const config = usePlantUmlConfig();
|
|
36
|
+
const { colorMode } = useColorMode();
|
|
37
|
+
const containerRef = useRef(null);
|
|
38
|
+
const themeOption = config?.options.theme ?? 'auto';
|
|
39
|
+
const dark = themeOption === 'auto' ? colorMode === 'dark' : themeOption === 'dark';
|
|
40
|
+
const lazy = config?.options.lazy ?? true;
|
|
41
|
+
const [state, setState] = useState(INITIAL_STATE);
|
|
42
|
+
const [inView, setInView] = useState(!lazy);
|
|
43
|
+
useEffect(() => {
|
|
44
|
+
if (!lazy || inView)
|
|
45
|
+
return undefined;
|
|
46
|
+
const element = containerRef.current;
|
|
47
|
+
if (!element)
|
|
48
|
+
return undefined;
|
|
49
|
+
// jsdom and older browsers have no IntersectionObserver; rendering immediately is the
|
|
50
|
+
// correct fallback, since a missing observer must not mean a permanently blank diagram.
|
|
51
|
+
if (typeof IntersectionObserver === 'undefined') {
|
|
52
|
+
setInView(true);
|
|
53
|
+
return undefined;
|
|
54
|
+
}
|
|
55
|
+
const observer = new IntersectionObserver((entries) => {
|
|
56
|
+
if (entries.some((entry) => entry.isIntersecting)) {
|
|
57
|
+
setInView(true);
|
|
58
|
+
observer.disconnect();
|
|
59
|
+
}
|
|
60
|
+
}, { rootMargin: LAZY_ROOT_MARGIN });
|
|
61
|
+
observer.observe(element);
|
|
62
|
+
return () => observer.disconnect();
|
|
63
|
+
}, [lazy, inView]);
|
|
64
|
+
useEffect(() => {
|
|
65
|
+
if (!inView)
|
|
66
|
+
return undefined;
|
|
67
|
+
if (!config) {
|
|
68
|
+
setState({
|
|
69
|
+
status: 'error',
|
|
70
|
+
svg: null,
|
|
71
|
+
error: 'PlantUML plugin data is missing. Add the plugin to the `plugins` array in your ' +
|
|
72
|
+
'Docusaurus configuration and restart the build.',
|
|
73
|
+
});
|
|
74
|
+
return undefined;
|
|
75
|
+
}
|
|
76
|
+
const controller = new AbortController();
|
|
77
|
+
const { options } = config;
|
|
78
|
+
// The signal is the single guard against both stale results and post-unmount updates:
|
|
79
|
+
// the cleanup aborts it, and every state write checks it first. Identical states are
|
|
80
|
+
// dropped so a repeated phase report cannot cause a pointless re-render.
|
|
81
|
+
const commit = (next) => {
|
|
82
|
+
if (controller.signal.aborted)
|
|
83
|
+
return;
|
|
84
|
+
setState((previous) => previous.status === next.status &&
|
|
85
|
+
previous.svg === next.svg &&
|
|
86
|
+
previous.error === next.error
|
|
87
|
+
? previous
|
|
88
|
+
: next);
|
|
89
|
+
};
|
|
90
|
+
void renderDiagram({
|
|
91
|
+
source,
|
|
92
|
+
dark,
|
|
93
|
+
sanitize: options.sanitizeSvg,
|
|
94
|
+
timeoutMs: options.renderTimeoutMs,
|
|
95
|
+
assetsBaseUrl: config.assetsBaseUrl,
|
|
96
|
+
coreVersion: config.coreVersion,
|
|
97
|
+
cache: getSharedCache(options.cache, options.cacheMaxEntries),
|
|
98
|
+
signal: controller.signal,
|
|
99
|
+
onPhase: (phase) => commit({ status: phase, svg: null, error: null }),
|
|
100
|
+
})
|
|
101
|
+
.then((svg) => commit({ status: 'ready', svg, error: null }))
|
|
102
|
+
.catch((error) => {
|
|
103
|
+
if (error instanceof PlantUmlError && error.kind === 'aborted')
|
|
104
|
+
return;
|
|
105
|
+
commit({ status: 'error', svg: null, error: messageFor(error) });
|
|
106
|
+
});
|
|
107
|
+
return () => controller.abort();
|
|
108
|
+
// Depends on the individual values rather than the `config` object, so that an
|
|
109
|
+
// unmemoized config from a future refactor cannot restart rendering on every render.
|
|
110
|
+
}, [
|
|
111
|
+
inView,
|
|
112
|
+
source,
|
|
113
|
+
dark,
|
|
114
|
+
config,
|
|
115
|
+
config?.assetsBaseUrl,
|
|
116
|
+
config?.coreVersion,
|
|
117
|
+
config?.options.sanitizeSvg,
|
|
118
|
+
config?.options.renderTimeoutMs,
|
|
119
|
+
config?.options.cache,
|
|
120
|
+
config?.options.cacheMaxEntries,
|
|
121
|
+
]);
|
|
122
|
+
const label = title ?? DEFAULT_LABEL;
|
|
123
|
+
const busy = state.status === 'loading' || state.status === 'rendering';
|
|
124
|
+
return (_jsxs("figure", { ref: containerRef, className: styles.figure, "aria-busy": busy || undefined, [DATA_ATTR.diagram]: language ?? 'plantuml',
|
|
125
|
+
[DATA_ATTR.status]: state.status,
|
|
126
|
+
[DATA_ATTR.theme]: dark ? 'dark' : 'light', children: [state.status === 'ready' && state.svg !== null && (_jsx("div", { className: styles.canvas, role: "img", "aria-label": label,
|
|
127
|
+
// Sanitized above unless `sanitizeSvg: false` was explicitly opted into.
|
|
128
|
+
dangerouslySetInnerHTML: { __html: state.svg } })), state.status !== 'ready' && state.status !== 'error' && (
|
|
129
|
+
// `aria-busy` on the figure already conveys progress; announcing every phase change
|
|
130
|
+
// would make a page with several diagrams unusable with a screen reader.
|
|
131
|
+
_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: {
|
|
132
|
+
__html: `<pre class="${styles.source ?? ''}">${escapeHtml(source)}</pre>`,
|
|
133
|
+
} })] }));
|
|
134
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
.figure {
|
|
2
|
+
margin: 0 0 1.25rem;
|
|
3
|
+
padding: 0;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
.canvas {
|
|
7
|
+
display: flex;
|
|
8
|
+
justify-content: flex-start;
|
|
9
|
+
overflow-x: auto;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
.canvas svg {
|
|
13
|
+
max-width: 100%;
|
|
14
|
+
height: auto;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
.placeholder {
|
|
18
|
+
display: flex;
|
|
19
|
+
align-items: center;
|
|
20
|
+
gap: 0.5rem;
|
|
21
|
+
min-height: 4rem;
|
|
22
|
+
padding: 0.75rem 1rem;
|
|
23
|
+
border: 1px dashed var(--ifm-color-emphasis-300);
|
|
24
|
+
border-radius: var(--ifm-global-radius);
|
|
25
|
+
color: var(--ifm-color-emphasis-700);
|
|
26
|
+
font-size: 0.875rem;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
.spinner {
|
|
30
|
+
width: 0.875rem;
|
|
31
|
+
height: 0.875rem;
|
|
32
|
+
border: 2px solid var(--ifm-color-emphasis-400);
|
|
33
|
+
border-top-color: transparent;
|
|
34
|
+
border-radius: 50%;
|
|
35
|
+
animation: plantuml-spin 0.8s linear infinite;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
@keyframes plantuml-spin {
|
|
39
|
+
to {
|
|
40
|
+
transform: rotate(360deg);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
@media (prefers-reduced-motion: reduce) {
|
|
45
|
+
.spinner {
|
|
46
|
+
animation: none;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
.caption {
|
|
51
|
+
margin-top: 0.5rem;
|
|
52
|
+
color: var(--ifm-color-emphasis-700);
|
|
53
|
+
font-size: 0.875rem;
|
|
54
|
+
text-align: left;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/*
|
|
58
|
+
* Failure is signalled by the "Error" label and the icon as well as by colour, so the state
|
|
59
|
+
* remains readable for colour-blind readers and in forced-colours mode.
|
|
60
|
+
*/
|
|
61
|
+
.error {
|
|
62
|
+
padding: 0.75rem 1rem;
|
|
63
|
+
border: 1px solid var(--ifm-color-danger-dark);
|
|
64
|
+
border-left-width: 4px;
|
|
65
|
+
border-radius: var(--ifm-global-radius);
|
|
66
|
+
background: var(--ifm-color-danger-contrast-background);
|
|
67
|
+
color: var(--ifm-color-danger-contrast-foreground);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
.errorHeading {
|
|
71
|
+
display: flex;
|
|
72
|
+
align-items: center;
|
|
73
|
+
gap: 0.5rem;
|
|
74
|
+
margin: 0 0 0.5rem;
|
|
75
|
+
font-size: 0.9375rem;
|
|
76
|
+
font-weight: 600;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
.errorIcon {
|
|
80
|
+
flex: none;
|
|
81
|
+
font-size: 1rem;
|
|
82
|
+
line-height: 1;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
.errorMessage {
|
|
86
|
+
margin: 0;
|
|
87
|
+
overflow-x: auto;
|
|
88
|
+
font-family: var(--ifm-font-family-monospace);
|
|
89
|
+
font-size: 0.8125rem;
|
|
90
|
+
white-space: pre-wrap;
|
|
91
|
+
word-break: break-word;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
.errorDetails {
|
|
95
|
+
margin-top: 0.75rem;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
.errorDetails summary {
|
|
99
|
+
cursor: pointer;
|
|
100
|
+
font-size: 0.875rem;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
.source {
|
|
104
|
+
margin: 0.5rem 0 0;
|
|
105
|
+
overflow-x: auto;
|
|
106
|
+
font-size: 0.8125rem;
|
|
107
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { type ReactNode } from 'react';
|
|
2
|
+
export interface CodeBlockProps {
|
|
3
|
+
children?: ReactNode;
|
|
4
|
+
className?: string;
|
|
5
|
+
metastring?: string;
|
|
6
|
+
language?: string;
|
|
7
|
+
title?: string;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Extracts the fence language.
|
|
11
|
+
*
|
|
12
|
+
* MDX preserves the case the author typed (```PlantUML yields `language-PlantUML`), and
|
|
13
|
+
* Docusaurus lower-cases it before handing it to Prism. Matching is therefore
|
|
14
|
+
* case-insensitive: `plantuml`, `PlantUML` and `PUML` all resolve to the same language.
|
|
15
|
+
*/
|
|
16
|
+
export declare function extractLanguage(props: CodeBlockProps): string | undefined;
|
|
17
|
+
export declare function isPlantUmlLanguage(props: CodeBlockProps, languages: readonly string[]): boolean;
|
|
18
|
+
/**
|
|
19
|
+
* Flattens fence children into the raw diagram source.
|
|
20
|
+
*
|
|
21
|
+
* Returns `null` when the children contain React elements, which happens for JSX-authored
|
|
22
|
+
* code blocks. Those are handed back to the original Docusaurus component untouched rather
|
|
23
|
+
* than being rendered from a partially-known source string.
|
|
24
|
+
*/
|
|
25
|
+
export declare function extractSource(children: ReactNode): string | null;
|
|
26
|
+
/** Reads `title="..."` (or `title='...'`) from the fence metastring. */
|
|
27
|
+
export declare function parseTitle(props: CodeBlockProps): string | undefined;
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { Children, isValidElement } from 'react';
|
|
2
|
+
/**
|
|
3
|
+
* Mirrors Docusaurus' own code-block metadata parsing so that a `plantuml` fence behaves
|
|
4
|
+
* exactly like any other fence up to the point where this plugin takes over.
|
|
5
|
+
*/
|
|
6
|
+
/** Same expression Docusaurus uses in `theme-common`'s `parseCodeBlockTitle`. */
|
|
7
|
+
const TITLE_REGEX = /title=(?<quote>["'])(?<title>.*?)\1/;
|
|
8
|
+
const LANGUAGE_CLASS_PREFIX = 'language-';
|
|
9
|
+
/**
|
|
10
|
+
* Extracts the fence language.
|
|
11
|
+
*
|
|
12
|
+
* MDX preserves the case the author typed (```PlantUML yields `language-PlantUML`), and
|
|
13
|
+
* Docusaurus lower-cases it before handing it to Prism. Matching is therefore
|
|
14
|
+
* case-insensitive: `plantuml`, `PlantUML` and `PUML` all resolve to the same language.
|
|
15
|
+
*/
|
|
16
|
+
export function extractLanguage(props) {
|
|
17
|
+
if (typeof props.language === 'string' && props.language.trim() !== '') {
|
|
18
|
+
return props.language.trim().toLowerCase();
|
|
19
|
+
}
|
|
20
|
+
const fromClass = props.className
|
|
21
|
+
?.split(' ')
|
|
22
|
+
.find((entry) => entry.startsWith(LANGUAGE_CLASS_PREFIX));
|
|
23
|
+
if (!fromClass)
|
|
24
|
+
return undefined;
|
|
25
|
+
const language = fromClass.slice(LANGUAGE_CLASS_PREFIX.length).trim();
|
|
26
|
+
return language === '' ? undefined : language.toLowerCase();
|
|
27
|
+
}
|
|
28
|
+
export function isPlantUmlLanguage(props, languages) {
|
|
29
|
+
const language = extractLanguage(props);
|
|
30
|
+
return language !== undefined && languages.includes(language);
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Flattens fence children into the raw diagram source.
|
|
34
|
+
*
|
|
35
|
+
* Returns `null` when the children contain React elements, which happens for JSX-authored
|
|
36
|
+
* code blocks. Those are handed back to the original Docusaurus component untouched rather
|
|
37
|
+
* than being rendered from a partially-known source string.
|
|
38
|
+
*/
|
|
39
|
+
export function extractSource(children) {
|
|
40
|
+
const parts = Children.toArray(children);
|
|
41
|
+
if (parts.length === 0)
|
|
42
|
+
return null;
|
|
43
|
+
if (parts.some((part) => isValidElement(part)))
|
|
44
|
+
return null;
|
|
45
|
+
if (!parts.every((part) => typeof part === 'string' || typeof part === 'number'))
|
|
46
|
+
return null;
|
|
47
|
+
const source = parts.join('');
|
|
48
|
+
// MDX always terminates a fence with a newline that is not part of the diagram.
|
|
49
|
+
return source.replace(/\n$/, '');
|
|
50
|
+
}
|
|
51
|
+
/** Reads `title="..."` (or `title='...'`) from the fence metastring. */
|
|
52
|
+
export function parseTitle(props) {
|
|
53
|
+
if (typeof props.title === 'string' && props.title !== '')
|
|
54
|
+
return props.title;
|
|
55
|
+
const match = props.metastring?.match(TITLE_REGEX);
|
|
56
|
+
const title = match?.groups?.['title'];
|
|
57
|
+
return title !== undefined && title !== '' ? title : undefined;
|
|
58
|
+
}
|