@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
package/dist/options.js
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { PLUGIN_NAME } from './constants.js';
|
|
2
|
+
export const DEFAULT_OPTIONS = {
|
|
3
|
+
languages: ['plantuml', 'puml'],
|
|
4
|
+
theme: 'auto',
|
|
5
|
+
lazy: true,
|
|
6
|
+
cache: 'memory',
|
|
7
|
+
sanitizeSvg: true,
|
|
8
|
+
showSourceOnError: true,
|
|
9
|
+
renderTimeoutMs: 20_000,
|
|
10
|
+
cacheMaxEntries: 50,
|
|
11
|
+
};
|
|
12
|
+
const CACHE_MODES = ['none', 'memory', 'session'];
|
|
13
|
+
const THEMES = ['auto', 'light', 'dark'];
|
|
14
|
+
const MIN_RENDER_TIMEOUT_MS = 100;
|
|
15
|
+
const MAX_RENDER_TIMEOUT_MS = 600_000;
|
|
16
|
+
/** Thrown for invalid plugin options so Docusaurus fails the build with a useful message. */
|
|
17
|
+
export class PlantUmlOptionsError extends Error {
|
|
18
|
+
constructor(message) {
|
|
19
|
+
super(`[${PLUGIN_NAME}] ${message}`);
|
|
20
|
+
this.name = 'PlantUmlOptionsError';
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
function fail(message) {
|
|
24
|
+
throw new PlantUmlOptionsError(message);
|
|
25
|
+
}
|
|
26
|
+
function isPlainObject(value) {
|
|
27
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
28
|
+
}
|
|
29
|
+
function quoteList(values) {
|
|
30
|
+
return values.map((value) => `'${value}'`).join(', ');
|
|
31
|
+
}
|
|
32
|
+
function validateBoolean(value, key, fallback) {
|
|
33
|
+
if (value === undefined)
|
|
34
|
+
return fallback;
|
|
35
|
+
if (typeof value !== 'boolean') {
|
|
36
|
+
fail(`options.${key} must be a boolean, received ${JSON.stringify(value)}.`);
|
|
37
|
+
}
|
|
38
|
+
return value;
|
|
39
|
+
}
|
|
40
|
+
function validateLanguages(value) {
|
|
41
|
+
if (value === undefined)
|
|
42
|
+
return [...DEFAULT_OPTIONS.languages];
|
|
43
|
+
if (!Array.isArray(value)) {
|
|
44
|
+
fail(`options.languages must be an array of strings, received ${JSON.stringify(value)}.`);
|
|
45
|
+
}
|
|
46
|
+
if (value.length === 0) {
|
|
47
|
+
fail('options.languages must contain at least one language.');
|
|
48
|
+
}
|
|
49
|
+
const normalized = value.map((entry, index) => {
|
|
50
|
+
if (typeof entry !== 'string' || entry.trim() === '') {
|
|
51
|
+
fail(`options.languages[${index}] must be a non-empty string, received ${JSON.stringify(entry)}.`);
|
|
52
|
+
}
|
|
53
|
+
return entry.trim().toLowerCase();
|
|
54
|
+
});
|
|
55
|
+
const duplicates = normalized.filter((entry, index) => normalized.indexOf(entry) !== index);
|
|
56
|
+
if (duplicates.length > 0) {
|
|
57
|
+
fail(`options.languages contains duplicate entries: ${quoteList([...new Set(duplicates)])}.`);
|
|
58
|
+
}
|
|
59
|
+
return normalized;
|
|
60
|
+
}
|
|
61
|
+
function validateRenderTimeout(value) {
|
|
62
|
+
if (value === undefined)
|
|
63
|
+
return DEFAULT_OPTIONS.renderTimeoutMs;
|
|
64
|
+
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
|
65
|
+
fail(`options.renderTimeoutMs must be a finite number, received ${JSON.stringify(value)}.`);
|
|
66
|
+
}
|
|
67
|
+
if (!Number.isInteger(value)) {
|
|
68
|
+
fail(`options.renderTimeoutMs must be an integer, received ${value}.`);
|
|
69
|
+
}
|
|
70
|
+
if (value < MIN_RENDER_TIMEOUT_MS || value > MAX_RENDER_TIMEOUT_MS) {
|
|
71
|
+
fail(`options.renderTimeoutMs must be between ${MIN_RENDER_TIMEOUT_MS} and ` +
|
|
72
|
+
`${MAX_RENDER_TIMEOUT_MS} milliseconds, received ${value}.`);
|
|
73
|
+
}
|
|
74
|
+
return value;
|
|
75
|
+
}
|
|
76
|
+
function validateCacheMaxEntries(value) {
|
|
77
|
+
if (value === undefined)
|
|
78
|
+
return DEFAULT_OPTIONS.cacheMaxEntries;
|
|
79
|
+
if (typeof value !== 'number' || !Number.isInteger(value) || value < 1) {
|
|
80
|
+
fail(`options.cacheMaxEntries must be a positive integer, received ${JSON.stringify(value)}.`);
|
|
81
|
+
}
|
|
82
|
+
return value;
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Validates raw plugin options and applies defaults.
|
|
86
|
+
*
|
|
87
|
+
* Unknown keys are rejected rather than ignored: a typo in `docusaurus.config.ts` would
|
|
88
|
+
* otherwise silently disable the option the author meant to set.
|
|
89
|
+
*/
|
|
90
|
+
export function resolveOptions(rawOptions) {
|
|
91
|
+
if (rawOptions === undefined || rawOptions === null) {
|
|
92
|
+
return { ...DEFAULT_OPTIONS, languages: [...DEFAULT_OPTIONS.languages] };
|
|
93
|
+
}
|
|
94
|
+
if (!isPlainObject(rawOptions)) {
|
|
95
|
+
fail(`options must be an object, received ${JSON.stringify(rawOptions)}.`);
|
|
96
|
+
}
|
|
97
|
+
const allowedKeys = new Set([
|
|
98
|
+
'languages',
|
|
99
|
+
'theme',
|
|
100
|
+
'lazy',
|
|
101
|
+
'cache',
|
|
102
|
+
'sanitizeSvg',
|
|
103
|
+
'showSourceOnError',
|
|
104
|
+
'renderTimeoutMs',
|
|
105
|
+
'cacheMaxEntries',
|
|
106
|
+
// Docusaurus injects `id` for multi-instance plugins.
|
|
107
|
+
'id',
|
|
108
|
+
]);
|
|
109
|
+
const unknownKeys = Object.keys(rawOptions).filter((key) => !allowedKeys.has(key));
|
|
110
|
+
if (unknownKeys.length > 0) {
|
|
111
|
+
fail(`Unknown option${unknownKeys.length > 1 ? 's' : ''} ${quoteList(unknownKeys)}. ` +
|
|
112
|
+
`Supported options: ${quoteList([...allowedKeys].filter((key) => key !== 'id'))}.`);
|
|
113
|
+
}
|
|
114
|
+
const { theme, cache } = rawOptions;
|
|
115
|
+
if (theme !== undefined && !THEMES.includes(theme)) {
|
|
116
|
+
fail(`options.theme must be one of ${quoteList(THEMES)}, received ${JSON.stringify(theme)}.`);
|
|
117
|
+
}
|
|
118
|
+
if (cache !== undefined && !CACHE_MODES.includes(cache)) {
|
|
119
|
+
fail(`options.cache must be one of ${quoteList(CACHE_MODES)}, received ${JSON.stringify(cache)}.`);
|
|
120
|
+
}
|
|
121
|
+
return {
|
|
122
|
+
languages: validateLanguages(rawOptions.languages),
|
|
123
|
+
theme: theme ?? DEFAULT_OPTIONS.theme,
|
|
124
|
+
lazy: validateBoolean(rawOptions.lazy, 'lazy', DEFAULT_OPTIONS.lazy),
|
|
125
|
+
cache: cache ?? DEFAULT_OPTIONS.cache,
|
|
126
|
+
sanitizeSvg: validateBoolean(rawOptions.sanitizeSvg, 'sanitizeSvg', DEFAULT_OPTIONS.sanitizeSvg),
|
|
127
|
+
showSourceOnError: validateBoolean(rawOptions.showSourceOnError, 'showSourceOnError', DEFAULT_OPTIONS.showSourceOnError),
|
|
128
|
+
renderTimeoutMs: validateRenderTimeout(rawOptions.renderTimeoutMs),
|
|
129
|
+
cacheMaxEntries: validateCacheMaxEntries(rawOptions.cacheMaxEntries),
|
|
130
|
+
};
|
|
131
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { PlantUmlCoreModule } from './types.js';
|
|
2
|
+
export interface LoadRuntimeOptions {
|
|
3
|
+
/** Absolute, `baseUrl`-prefixed directory URL containing the two runtime files. */
|
|
4
|
+
assetsBaseUrl: string;
|
|
5
|
+
/** Give up if the runtime has not loaded within this many milliseconds. */
|
|
6
|
+
timeoutMs: number;
|
|
7
|
+
/**
|
|
8
|
+
* Seam for tests: the real implementation is a bundler-ignored dynamic import, which
|
|
9
|
+
* cannot run under a Node test runner.
|
|
10
|
+
*/
|
|
11
|
+
importModule?: (url: string) => Promise<unknown>;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Resolves the PlantUML engine, loading it on first use.
|
|
15
|
+
*
|
|
16
|
+
* Concurrent callers share one in-flight promise; a failed load clears the cache so a later
|
|
17
|
+
* diagram can retry rather than inheriting a permanent failure.
|
|
18
|
+
*/
|
|
19
|
+
export declare function loadPlantUmlRuntime(options: LoadRuntimeOptions): Promise<PlantUmlCoreModule>;
|
|
20
|
+
/** Whether the runtime has been requested on this page. Used by tests and diagnostics. */
|
|
21
|
+
export declare function isRuntimeRequested(): boolean;
|
|
22
|
+
/** Test-only: forgets the cached runtime and removes the injected script tag. */
|
|
23
|
+
export declare function resetRuntimeLoader(): void;
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { PLANTUML_MODULE_FILENAME, VIZ_SCRIPT_FILENAME } from '../constants.js';
|
|
2
|
+
import { PlantUmlError } from './errors.js';
|
|
3
|
+
/**
|
|
4
|
+
* Loads the PlantUML browser runtime exactly once per page session.
|
|
5
|
+
*
|
|
6
|
+
* `viz-global.js` is a classic script that installs the Graphviz layout engine on `window`,
|
|
7
|
+
* and it must have finished executing before `plantuml.js` is imported. Both files are
|
|
8
|
+
* served from the Docusaurus origin under `baseUrl`, never from a CDN.
|
|
9
|
+
*/
|
|
10
|
+
const SCRIPT_MARKER = 'data-plantuml-runtime';
|
|
11
|
+
let cachedRuntime = null;
|
|
12
|
+
function isBrowser() {
|
|
13
|
+
return typeof window !== 'undefined' && typeof document !== 'undefined';
|
|
14
|
+
}
|
|
15
|
+
function joinUrl(base, file) {
|
|
16
|
+
return `${base.replace(/\/+$/, '')}/${file}`;
|
|
17
|
+
}
|
|
18
|
+
function defaultImportModule(url) {
|
|
19
|
+
// webpackIgnore keeps this a real runtime import of a copied asset instead of letting
|
|
20
|
+
// webpack try to bundle the 6.8 MB engine into the site's JavaScript.
|
|
21
|
+
return import(/* webpackIgnore: true */ url);
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Injects `viz-global.js` as a classic script, reusing an existing tag.
|
|
25
|
+
*
|
|
26
|
+
* Client-side navigation re-mounts diagram components but does not reload the document, so
|
|
27
|
+
* the marker attribute is what keeps a second `<script>` from ever being appended.
|
|
28
|
+
*/
|
|
29
|
+
function loadVizGlobal(url, timeoutMs) {
|
|
30
|
+
const existing = document.querySelector(`script[${SCRIPT_MARKER}]`);
|
|
31
|
+
if (existing) {
|
|
32
|
+
if (existing.dataset.plantumlRuntimeState === 'loaded')
|
|
33
|
+
return Promise.resolve();
|
|
34
|
+
if (existing.dataset.plantumlRuntimeState === 'error') {
|
|
35
|
+
return Promise.reject(new PlantUmlError('load', `Failed to load the PlantUML Graphviz runtime from ${url}.`));
|
|
36
|
+
}
|
|
37
|
+
return waitForScript(existing, url, timeoutMs);
|
|
38
|
+
}
|
|
39
|
+
const script = document.createElement('script');
|
|
40
|
+
script.setAttribute(SCRIPT_MARKER, '');
|
|
41
|
+
script.dataset.plantumlRuntimeState = 'loading';
|
|
42
|
+
script.async = false;
|
|
43
|
+
script.src = url;
|
|
44
|
+
const promise = waitForScript(script, url, timeoutMs);
|
|
45
|
+
document.head.appendChild(script);
|
|
46
|
+
return promise;
|
|
47
|
+
}
|
|
48
|
+
function waitForScript(script, url, timeoutMs) {
|
|
49
|
+
return new Promise((resolve, reject) => {
|
|
50
|
+
let done = false;
|
|
51
|
+
const finish = (fn) => {
|
|
52
|
+
if (done)
|
|
53
|
+
return;
|
|
54
|
+
done = true;
|
|
55
|
+
clearTimeout(timer);
|
|
56
|
+
script.removeEventListener('load', onLoad);
|
|
57
|
+
script.removeEventListener('error', onError);
|
|
58
|
+
fn();
|
|
59
|
+
};
|
|
60
|
+
const onLoad = () => {
|
|
61
|
+
script.dataset.plantumlRuntimeState = 'loaded';
|
|
62
|
+
finish(resolve);
|
|
63
|
+
};
|
|
64
|
+
const onError = () => {
|
|
65
|
+
script.dataset.plantumlRuntimeState = 'error';
|
|
66
|
+
finish(() => reject(new PlantUmlError('load', `Failed to load the PlantUML Graphviz runtime from ${url}. ` +
|
|
67
|
+
'Check that the asset is served under your Docusaurus baseUrl with a JavaScript MIME type.')));
|
|
68
|
+
};
|
|
69
|
+
const timer = setTimeout(() => {
|
|
70
|
+
finish(() => reject(new PlantUmlError('load', `Timed out after ${timeoutMs} ms loading ${url}.`)));
|
|
71
|
+
}, timeoutMs);
|
|
72
|
+
script.addEventListener('load', onLoad);
|
|
73
|
+
script.addEventListener('error', onError);
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
function assertCoreModule(value, url) {
|
|
77
|
+
const candidate = value;
|
|
78
|
+
if (!candidate ||
|
|
79
|
+
typeof candidate.render !== 'function' ||
|
|
80
|
+
typeof candidate.renderToString !== 'function') {
|
|
81
|
+
throw new PlantUmlError('load', `The module at ${url} does not export the expected PlantUML render functions. ` +
|
|
82
|
+
'This usually means a proxy or service worker returned the wrong file.');
|
|
83
|
+
}
|
|
84
|
+
return candidate;
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Resolves the PlantUML engine, loading it on first use.
|
|
88
|
+
*
|
|
89
|
+
* Concurrent callers share one in-flight promise; a failed load clears the cache so a later
|
|
90
|
+
* diagram can retry rather than inheriting a permanent failure.
|
|
91
|
+
*/
|
|
92
|
+
export function loadPlantUmlRuntime(options) {
|
|
93
|
+
if (!isBrowser()) {
|
|
94
|
+
return Promise.reject(new PlantUmlError('load', 'The PlantUML runtime cannot be loaded during server-side rendering.'));
|
|
95
|
+
}
|
|
96
|
+
if (cachedRuntime)
|
|
97
|
+
return cachedRuntime;
|
|
98
|
+
const { assetsBaseUrl, timeoutMs, importModule = defaultImportModule } = options;
|
|
99
|
+
const vizUrl = joinUrl(assetsBaseUrl, VIZ_SCRIPT_FILENAME);
|
|
100
|
+
const moduleUrl = joinUrl(assetsBaseUrl, PLANTUML_MODULE_FILENAME);
|
|
101
|
+
const runtime = loadVizGlobal(vizUrl, timeoutMs)
|
|
102
|
+
.then(() => importModule(moduleUrl))
|
|
103
|
+
.then((module) => assertCoreModule(module, moduleUrl))
|
|
104
|
+
.catch((error) => {
|
|
105
|
+
cachedRuntime = null;
|
|
106
|
+
throw error instanceof PlantUmlError
|
|
107
|
+
? error
|
|
108
|
+
: new PlantUmlError('load', `Failed to load the PlantUML engine from ${moduleUrl}.`, {
|
|
109
|
+
cause: error,
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
cachedRuntime = runtime;
|
|
113
|
+
return runtime;
|
|
114
|
+
}
|
|
115
|
+
/** Whether the runtime has been requested on this page. Used by tests and diagnostics. */
|
|
116
|
+
export function isRuntimeRequested() {
|
|
117
|
+
return cachedRuntime !== null;
|
|
118
|
+
}
|
|
119
|
+
/** Test-only: forgets the cached runtime and removes the injected script tag. */
|
|
120
|
+
export function resetRuntimeLoader() {
|
|
121
|
+
cachedRuntime = null;
|
|
122
|
+
if (typeof document !== 'undefined') {
|
|
123
|
+
document.querySelectorAll(`script[${SCRIPT_MARKER}]`).forEach((node) => node.remove());
|
|
124
|
+
}
|
|
125
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { CacheMode } from '../options.js';
|
|
2
|
+
export interface CacheKeyInput {
|
|
3
|
+
source: string;
|
|
4
|
+
dark: boolean;
|
|
5
|
+
sanitized: boolean;
|
|
6
|
+
coreVersion: string;
|
|
7
|
+
}
|
|
8
|
+
export interface DiagramCache {
|
|
9
|
+
readonly mode: CacheMode;
|
|
10
|
+
get(key: string): string | undefined;
|
|
11
|
+
set(key: string, svg: string): void;
|
|
12
|
+
clear(): void;
|
|
13
|
+
/** Number of entries currently held. Exposed for tests and the documented size limit. */
|
|
14
|
+
size(): number;
|
|
15
|
+
}
|
|
16
|
+
export declare function computeCacheKey({ source, dark, sanitized, coreVersion }: CacheKeyInput): string;
|
|
17
|
+
export declare function createDiagramCache(mode: CacheMode, maxEntries: number): DiagramCache;
|
|
18
|
+
export declare function getSharedCache(mode: CacheMode, maxEntries: number): DiagramCache;
|
|
19
|
+
/** Test-only: drops every shared cache instance. */
|
|
20
|
+
export declare function resetSharedCaches(): void;
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rendered SVG is cached under a key that folds in everything that can change the output.
|
|
3
|
+
* Colour mode is part of the key, so toggling dark mode can never serve the other mode's
|
|
4
|
+
* picture, and the `@plantuml/core` version is part of it so an engine upgrade invalidates
|
|
5
|
+
* every stored entry.
|
|
6
|
+
*/
|
|
7
|
+
const STORAGE_PREFIX = 'plantuml-client:';
|
|
8
|
+
const ENTRY_VERSION = 1;
|
|
9
|
+
/** FNV-1a. Not cryptographic — this only needs to be fast, stable and short. */
|
|
10
|
+
function hash(input) {
|
|
11
|
+
let value = 0x811c9dc5;
|
|
12
|
+
for (let index = 0; index < input.length; index += 1) {
|
|
13
|
+
value ^= input.charCodeAt(index);
|
|
14
|
+
value = Math.imul(value, 0x01000193) >>> 0;
|
|
15
|
+
}
|
|
16
|
+
return value.toString(36).padStart(7, '0');
|
|
17
|
+
}
|
|
18
|
+
export function computeCacheKey({ source, dark, sanitized, coreVersion }) {
|
|
19
|
+
const mode = dark ? 'dark' : 'light';
|
|
20
|
+
const clean = sanitized ? 'san' : 'raw';
|
|
21
|
+
// The length guards against the (astronomically unlikely) 32-bit hash collision between
|
|
22
|
+
// two different sources that happen to share every other key component.
|
|
23
|
+
return `${coreVersion}|${mode}|${clean}|${source.length}|${hash(source)}`;
|
|
24
|
+
}
|
|
25
|
+
class MemoryCache {
|
|
26
|
+
mode = 'memory';
|
|
27
|
+
#entries = new Map();
|
|
28
|
+
#maxEntries;
|
|
29
|
+
constructor(maxEntries) {
|
|
30
|
+
this.#maxEntries = maxEntries;
|
|
31
|
+
}
|
|
32
|
+
get(key) {
|
|
33
|
+
const value = this.#entries.get(key);
|
|
34
|
+
if (value === undefined)
|
|
35
|
+
return undefined;
|
|
36
|
+
// Re-insert so the Map's insertion order doubles as LRU recency.
|
|
37
|
+
this.#entries.delete(key);
|
|
38
|
+
this.#entries.set(key, value);
|
|
39
|
+
return value;
|
|
40
|
+
}
|
|
41
|
+
set(key, svg) {
|
|
42
|
+
this.#entries.delete(key);
|
|
43
|
+
this.#entries.set(key, svg);
|
|
44
|
+
while (this.#entries.size > this.#maxEntries) {
|
|
45
|
+
const oldest = this.#entries.keys().next();
|
|
46
|
+
if (oldest.done)
|
|
47
|
+
break;
|
|
48
|
+
this.#entries.delete(oldest.value);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
clear() {
|
|
52
|
+
this.#entries.clear();
|
|
53
|
+
}
|
|
54
|
+
size() {
|
|
55
|
+
return this.#entries.size;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
class NoopCache {
|
|
59
|
+
mode = 'none';
|
|
60
|
+
get() {
|
|
61
|
+
return undefined;
|
|
62
|
+
}
|
|
63
|
+
set() {
|
|
64
|
+
/* intentionally not stored */
|
|
65
|
+
}
|
|
66
|
+
clear() {
|
|
67
|
+
/* nothing to clear */
|
|
68
|
+
}
|
|
69
|
+
size() {
|
|
70
|
+
return 0;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* `sessionStorage` is best-effort by design: it is unavailable in private modes and some
|
|
75
|
+
* embedded browsers, it throws on quota exhaustion, and its contents can be edited by hand.
|
|
76
|
+
* Every access is therefore guarded, and a bad read is discarded rather than propagated.
|
|
77
|
+
*/
|
|
78
|
+
class SessionCache {
|
|
79
|
+
mode = 'session';
|
|
80
|
+
#maxEntries;
|
|
81
|
+
#fallback;
|
|
82
|
+
constructor(maxEntries) {
|
|
83
|
+
this.#maxEntries = maxEntries;
|
|
84
|
+
this.#fallback = new MemoryCache(maxEntries);
|
|
85
|
+
}
|
|
86
|
+
#storage() {
|
|
87
|
+
try {
|
|
88
|
+
const storage = globalThis.sessionStorage;
|
|
89
|
+
return storage ?? null;
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
get(key) {
|
|
96
|
+
const storage = this.#storage();
|
|
97
|
+
if (!storage)
|
|
98
|
+
return this.#fallback.get(key);
|
|
99
|
+
let raw;
|
|
100
|
+
try {
|
|
101
|
+
raw = storage.getItem(STORAGE_PREFIX + key);
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
return this.#fallback.get(key);
|
|
105
|
+
}
|
|
106
|
+
// A miss in storage still has to consult the fallback: entries written while storage was
|
|
107
|
+
// rejecting writes (quota, private mode) live only there.
|
|
108
|
+
if (raw === null)
|
|
109
|
+
return this.#fallback.get(key);
|
|
110
|
+
try {
|
|
111
|
+
const parsed = JSON.parse(raw);
|
|
112
|
+
if (parsed?.v !== ENTRY_VERSION || typeof parsed.svg !== 'string') {
|
|
113
|
+
this.#drop(storage, key);
|
|
114
|
+
return this.#fallback.get(key);
|
|
115
|
+
}
|
|
116
|
+
return parsed.svg;
|
|
117
|
+
}
|
|
118
|
+
catch {
|
|
119
|
+
this.#drop(storage, key);
|
|
120
|
+
return this.#fallback.get(key);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
set(key, svg) {
|
|
124
|
+
const storage = this.#storage();
|
|
125
|
+
if (!storage) {
|
|
126
|
+
this.#fallback.set(key, svg);
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
const entry = { v: ENTRY_VERSION, svg };
|
|
130
|
+
try {
|
|
131
|
+
this.#evictIfNeeded(storage);
|
|
132
|
+
storage.setItem(STORAGE_PREFIX + key, JSON.stringify(entry));
|
|
133
|
+
}
|
|
134
|
+
catch {
|
|
135
|
+
// Quota exceeded or storage disabled mid-session: keep rendering, just stop persisting.
|
|
136
|
+
this.#fallback.set(key, svg);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
#ownKeys(storage) {
|
|
140
|
+
const keys = [];
|
|
141
|
+
for (let index = 0; index < storage.length; index += 1) {
|
|
142
|
+
const key = storage.key(index);
|
|
143
|
+
if (key?.startsWith(STORAGE_PREFIX))
|
|
144
|
+
keys.push(key);
|
|
145
|
+
}
|
|
146
|
+
return keys;
|
|
147
|
+
}
|
|
148
|
+
#evictIfNeeded(storage) {
|
|
149
|
+
const keys = this.#ownKeys(storage);
|
|
150
|
+
// `sessionStorage` has no recency information, so this is a plain FIFO trim of our own
|
|
151
|
+
// namespace. The documented bound is what matters; exact eviction order is not.
|
|
152
|
+
for (let index = 0; index <= keys.length - this.#maxEntries; index += 1) {
|
|
153
|
+
const key = keys[index];
|
|
154
|
+
if (key !== undefined)
|
|
155
|
+
storage.removeItem(key);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
#drop(storage, key) {
|
|
159
|
+
try {
|
|
160
|
+
storage.removeItem(STORAGE_PREFIX + key);
|
|
161
|
+
}
|
|
162
|
+
catch {
|
|
163
|
+
/* nothing more we can do */
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
clear() {
|
|
167
|
+
this.#fallback.clear();
|
|
168
|
+
const storage = this.#storage();
|
|
169
|
+
if (!storage)
|
|
170
|
+
return;
|
|
171
|
+
try {
|
|
172
|
+
this.#ownKeys(storage).forEach((key) => storage.removeItem(key));
|
|
173
|
+
}
|
|
174
|
+
catch {
|
|
175
|
+
/* nothing more we can do */
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
size() {
|
|
179
|
+
const storage = this.#storage();
|
|
180
|
+
if (!storage)
|
|
181
|
+
return this.#fallback.size();
|
|
182
|
+
try {
|
|
183
|
+
return this.#ownKeys(storage).length;
|
|
184
|
+
}
|
|
185
|
+
catch {
|
|
186
|
+
return this.#fallback.size();
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
export function createDiagramCache(mode, maxEntries) {
|
|
191
|
+
switch (mode) {
|
|
192
|
+
case 'none':
|
|
193
|
+
return new NoopCache();
|
|
194
|
+
case 'session':
|
|
195
|
+
return new SessionCache(maxEntries);
|
|
196
|
+
case 'memory':
|
|
197
|
+
default:
|
|
198
|
+
return new MemoryCache(maxEntries);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
/**
|
|
202
|
+
* One cache per (mode, limit) pair, shared by every diagram on the page so that the same
|
|
203
|
+
* source rendered twice is only rendered once.
|
|
204
|
+
*/
|
|
205
|
+
const sharedCaches = new Map();
|
|
206
|
+
export function getSharedCache(mode, maxEntries) {
|
|
207
|
+
const key = `${mode}:${maxEntries}`;
|
|
208
|
+
let cache = sharedCaches.get(key);
|
|
209
|
+
if (!cache) {
|
|
210
|
+
cache = createDiagramCache(mode, maxEntries);
|
|
211
|
+
sharedCaches.set(key, cache);
|
|
212
|
+
}
|
|
213
|
+
return cache;
|
|
214
|
+
}
|
|
215
|
+
/** Test-only: drops every shared cache instance. */
|
|
216
|
+
export function resetSharedCaches() {
|
|
217
|
+
sharedCaches.forEach((cache) => cache.clear());
|
|
218
|
+
sharedCaches.clear();
|
|
219
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
export type PlantUmlErrorKind = 'load' | 'engine' | 'diagram' | 'timeout' | 'config' | 'aborted';
|
|
2
|
+
/** Every failure surfaced to the UI is one of these, so the panel can explain what broke. */
|
|
3
|
+
export declare class PlantUmlError extends Error {
|
|
4
|
+
readonly kind: PlantUmlErrorKind;
|
|
5
|
+
constructor(kind: PlantUmlErrorKind, message: string, options?: {
|
|
6
|
+
cause?: unknown;
|
|
7
|
+
});
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* PlantUML reports invalid diagrams by *successfully* rendering an error picture rather
|
|
11
|
+
* than by calling the error callback, so a rendered SVG still has to be inspected.
|
|
12
|
+
*
|
|
13
|
+
* Each signature requires two co-occurring markers to avoid misreading a legitimate
|
|
14
|
+
* diagram that merely contains the words "syntax error" in a note.
|
|
15
|
+
*
|
|
16
|
+
* @returns a human-readable message, or `null` when the SVG is a real diagram.
|
|
17
|
+
*/
|
|
18
|
+
export declare function detectDiagramError(svg: string): string | null;
|
|
19
|
+
/**
|
|
20
|
+
* Normalizes whatever the engine hands to its error callback into a readable string.
|
|
21
|
+
*
|
|
22
|
+
* The engine is TeaVM-compiled Java, so a rejection can be an `Error`, a bare string, or a
|
|
23
|
+
* host object with its own `toString`. Anything without a meaningful string form is
|
|
24
|
+
* described rather than rendered as `[object Object]`.
|
|
25
|
+
*/
|
|
26
|
+
export declare function describeEngineError(raw: unknown): string;
|
|
27
|
+
/**
|
|
28
|
+
* Coerces an unknown rejection into an `Error`, preserving {@link PlantUmlError} instances
|
|
29
|
+
* so their `kind` survives a trip through the render queue.
|
|
30
|
+
*/
|
|
31
|
+
export declare function toError(value: unknown): Error;
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/** Every failure surfaced to the UI is one of these, so the panel can explain what broke. */
|
|
2
|
+
export class PlantUmlError extends Error {
|
|
3
|
+
kind;
|
|
4
|
+
constructor(kind, message, options) {
|
|
5
|
+
super(message, options);
|
|
6
|
+
this.name = 'PlantUmlError';
|
|
7
|
+
this.kind = kind;
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Noise that PlantUML prepends to its error diagrams. Dropping it keeps the message about
|
|
12
|
+
* the user's diagram rather than about the engine's build metadata.
|
|
13
|
+
*/
|
|
14
|
+
const BOILERPLATE_PATTERNS = [
|
|
15
|
+
/^PlantUML version /,
|
|
16
|
+
/^This version of PlantUML is \d+ days old/,
|
|
17
|
+
/^consider upgrading from /,
|
|
18
|
+
/^\s*$/,
|
|
19
|
+
];
|
|
20
|
+
const NOT_SUPPORTED_HEADING = 'Diagram not supported by this release of PlantUML';
|
|
21
|
+
function textNodesOf(svg) {
|
|
22
|
+
if (typeof DOMParser === 'undefined')
|
|
23
|
+
return [];
|
|
24
|
+
let doc;
|
|
25
|
+
try {
|
|
26
|
+
doc = new DOMParser().parseFromString(svg, 'image/svg+xml');
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
return [];
|
|
30
|
+
}
|
|
31
|
+
if (doc.querySelector('parsererror'))
|
|
32
|
+
return [];
|
|
33
|
+
return Array.from(doc.querySelectorAll('text')).map((node) => node.textContent ?? '');
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* PlantUML reports invalid diagrams by *successfully* rendering an error picture rather
|
|
37
|
+
* than by calling the error callback, so a rendered SVG still has to be inspected.
|
|
38
|
+
*
|
|
39
|
+
* Each signature requires two co-occurring markers to avoid misreading a legitimate
|
|
40
|
+
* diagram that merely contains the words "syntax error" in a note.
|
|
41
|
+
*
|
|
42
|
+
* @returns a human-readable message, or `null` when the SVG is a real diagram.
|
|
43
|
+
*/
|
|
44
|
+
export function detectDiagramError(svg) {
|
|
45
|
+
const texts = textNodesOf(svg);
|
|
46
|
+
if (texts.length === 0)
|
|
47
|
+
return null;
|
|
48
|
+
const isSyntaxError = texts.some((text) => /Syntax Error\?/.test(text) && text.includes('(Assumed diagram type:'));
|
|
49
|
+
const isEmptyDescription = texts.some((text) => /Empty description/.test(text) && text.includes('(Assumed diagram type:'));
|
|
50
|
+
const isUnsupported = texts.some((text) => text.includes(NOT_SUPPORTED_HEADING)) &&
|
|
51
|
+
texts.some((text) => text.includes('is not recognized.'));
|
|
52
|
+
if (!isSyntaxError && !isEmptyDescription && !isUnsupported)
|
|
53
|
+
return null;
|
|
54
|
+
const meaningful = texts
|
|
55
|
+
.map((text) => text.replace(/\s+$/, ''))
|
|
56
|
+
.filter((text) => !BOILERPLATE_PATTERNS.some((pattern) => pattern.test(text)));
|
|
57
|
+
const message = meaningful.join('\n').trim();
|
|
58
|
+
return message.length > 0 ? message : 'PlantUML reported an error for this diagram.';
|
|
59
|
+
}
|
|
60
|
+
const UNKNOWN_ERROR = 'The PlantUML engine reported an unknown error.';
|
|
61
|
+
const UNPRINTABLE_ERROR = 'The PlantUML engine reported an unstringifiable error.';
|
|
62
|
+
/**
|
|
63
|
+
* Normalizes whatever the engine hands to its error callback into a readable string.
|
|
64
|
+
*
|
|
65
|
+
* The engine is TeaVM-compiled Java, so a rejection can be an `Error`, a bare string, or a
|
|
66
|
+
* host object with its own `toString`. Anything without a meaningful string form is
|
|
67
|
+
* described rather than rendered as `[object Object]`.
|
|
68
|
+
*/
|
|
69
|
+
export function describeEngineError(raw) {
|
|
70
|
+
if (raw instanceof Error)
|
|
71
|
+
return raw.message;
|
|
72
|
+
if (typeof raw === 'string')
|
|
73
|
+
return raw;
|
|
74
|
+
if (raw === null || raw === undefined)
|
|
75
|
+
return UNKNOWN_ERROR;
|
|
76
|
+
if (typeof raw === 'number' || typeof raw === 'boolean' || typeof raw === 'bigint') {
|
|
77
|
+
return String(raw);
|
|
78
|
+
}
|
|
79
|
+
if (typeof raw === 'symbol')
|
|
80
|
+
return raw.toString();
|
|
81
|
+
if (typeof raw === 'function')
|
|
82
|
+
return UNKNOWN_ERROR;
|
|
83
|
+
const candidate = raw;
|
|
84
|
+
if (typeof candidate.toString === 'function' &&
|
|
85
|
+
candidate.toString !== Object.prototype.toString) {
|
|
86
|
+
try {
|
|
87
|
+
const text = candidate.toString();
|
|
88
|
+
if (typeof text === 'string' && text !== '')
|
|
89
|
+
return text;
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
return UNPRINTABLE_ERROR;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
try {
|
|
96
|
+
return JSON.stringify(raw) ?? UNKNOWN_ERROR;
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
return UNPRINTABLE_ERROR;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Coerces an unknown rejection into an `Error`, preserving {@link PlantUmlError} instances
|
|
104
|
+
* so their `kind` survives a trip through the render queue.
|
|
105
|
+
*/
|
|
106
|
+
export function toError(value) {
|
|
107
|
+
return value instanceof Error ? value : new PlantUmlError('engine', describeEngineError(value));
|
|
108
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The PlantUML engine keeps its in-flight render state in module-level globals, so two
|
|
3
|
+
* overlapping calls corrupt each other: in a browser spike, three concurrent
|
|
4
|
+
* `renderToString` calls produced exactly one callback and two permanent hangs.
|
|
5
|
+
*
|
|
6
|
+
* Every render therefore goes through this module-level FIFO queue, which runs one task at
|
|
7
|
+
* a time and always advances — a rejected, timed-out or aborted task can never wedge it.
|
|
8
|
+
*/
|
|
9
|
+
export interface EnqueueOptions {
|
|
10
|
+
/** Reject the task if it has not settled this many milliseconds after it started. */
|
|
11
|
+
timeoutMs: number;
|
|
12
|
+
/** Drop the task before it starts, and stop waiting for it once it has. */
|
|
13
|
+
signal?: AbortSignal;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Queues `task` behind every previously queued task.
|
|
17
|
+
*
|
|
18
|
+
* `task` receives a signal that is aborted on timeout or caller abort, so long-running
|
|
19
|
+
* work can bail out instead of writing into a discarded render.
|
|
20
|
+
*/
|
|
21
|
+
export declare function enqueueRender<T>(task: (signal: AbortSignal) => Promise<T>, options: EnqueueOptions): Promise<T>;
|
|
22
|
+
/** Number of tasks waiting to start. Exposed for tests and diagnostics. */
|
|
23
|
+
export declare function pendingRenderCount(): number;
|
|
24
|
+
/** Whether a task is currently running. Exposed for tests and diagnostics. */
|
|
25
|
+
export declare function isRenderActive(): boolean;
|
|
26
|
+
/** Test-only: drops queued work and clears the active flag. */
|
|
27
|
+
export declare function resetRenderQueue(): void;
|