@matfsw/docusaurus-plantuml-plugin 1.2.0 → 1.3.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/dist/index.js CHANGED
@@ -4,6 +4,7 @@ import CopyWebpackPlugin from 'copy-webpack-plugin';
4
4
  import { locatePlantUmlCore } from './assets.js';
5
5
  import { assetsDirForVersion, DEFAULT_PLUGIN_ID, PLUGIN_NAME } from './constants.js';
6
6
  import { resolveOptions, } from './options.js';
7
+ import { resolveStdlibAssets } from './stdlib.js';
7
8
  export { DEFAULT_OPTIONS, PlantUmlOptionsError, resolveOptions } from './options.js';
8
9
  export { PLUGIN_ID, PLUGIN_NAME } from './constants.js';
9
10
  const currentDir = path.dirname(fileURLToPath(import.meta.url));
@@ -25,12 +26,22 @@ export function createCopyPlugin(currentBundler, patterns) {
25
26
  }
26
27
  return new CopyWebpackPlugin({ patterns });
27
28
  }
28
- export default function plantumlPlugin(_context, options = {}) {
29
+ export default function plantumlPlugin(context, options = {}) {
29
30
  // Docusaurus already ran `validateOptions`, but the plugin function is also callable
30
31
  // directly (and from JS configs that skip validation), so normalize defensively.
31
32
  const resolved = resolveOptions(options);
32
33
  const core = locatePlantUmlCore();
33
34
  const assetsDir = assetsDirForVersion(core.version);
35
+ const stdlib = resolveStdlibAssets({
36
+ options: resolved.stdlib,
37
+ currentDir,
38
+ siteDir: context.siteDir,
39
+ cacheDir: context.generatedFilesDir,
40
+ });
41
+ // The revision is part of the directory name, not just of the cache key: refreshing the
42
+ // standard library without moving the URL would leave readers on a cached bundle from
43
+ // before the refresh, and the mismatch would surface as an unresolved include.
44
+ const stdlibDir = stdlib ? `${assetsDir}/stdlib-${stdlib.manifest.revision}` : null;
34
45
  return {
35
46
  name: PLUGIN_NAME,
36
47
  getThemePath() {
@@ -41,6 +52,7 @@ export default function plantumlPlugin(_context, options = {}) {
41
52
  options: resolved,
42
53
  assetsDir,
43
54
  coreVersion: core.version,
55
+ stdlib: stdlib && stdlibDir ? { dir: stdlibDir, manifest: stdlib.manifest } : null,
44
56
  };
45
57
  actions.setGlobalData(globalData);
46
58
  },
@@ -56,6 +68,13 @@ export default function plantumlPlugin(_context, options = {}) {
56
68
  // These files are already minified upstream; re-processing 8 MB is wasted work.
57
69
  info: { minimized: true },
58
70
  }));
71
+ if (stdlib && stdlibDir) {
72
+ patterns.push(...stdlib.files.map((absolutePath) => ({
73
+ from: absolutePath,
74
+ to: `${stdlibDir}/[name][ext]`,
75
+ info: { minimized: true },
76
+ })));
77
+ }
59
78
  // Optional chaining, not laziness: `currentBundler` is absent on Docusaurus 3.5.x.
60
79
  return { plugins: [createCopyPlugin(utils?.currentBundler, patterns)] };
61
80
  },
package/dist/options.d.ts CHANGED
@@ -39,6 +39,50 @@ export type ResolvedGraphvizOptions = Required<Omit<GraphvizOptions, 'languages'
39
39
  /** Normalized to lower case; matching is case-insensitive. */
40
40
  languages: string[];
41
41
  };
42
+ /**
43
+ * PlantUML standard library support: `!include <C4/C4_Container>` and friends.
44
+ *
45
+ * On by default and normally not worth configuring. The two lists exist for the namespaces
46
+ * this package does not vendor — the standard library is 265 MB in full, and several of its
47
+ * namespaces carry no upstream licence — which a site can still opt into from its own
48
+ * checkout. See `assets/stdlib/LICENSES.md` for what is vendored and under what terms.
49
+ */
50
+ export interface StdlibOptions {
51
+ /**
52
+ * Resolve `!include <namespace/…>` at all. `false` (or `stdlib: false`) leaves such
53
+ * diagrams to fail in the engine, exactly as they did before this feature existed.
54
+ */
55
+ enabled?: boolean;
56
+ /**
57
+ * Extra namespaces to make available, beyond the vendored ones — `['aws', 'tupadr3']`.
58
+ * Each must be found in one of {@link StdlibOptions.source}, and the build fails naming
59
+ * the namespace if it is not.
60
+ */
61
+ include?: string[];
62
+ /**
63
+ * Directories to resolve {@link StdlibOptions.include} from: the `stdlib/` directory of a
64
+ * `plantuml-stdlib` checkout, relative to the site directory or absolute. Searched in
65
+ * order, before the vendored bundles, so a namespace can also be replaced with a newer one
66
+ * by naming it in `include`.
67
+ */
68
+ source?: string | string[];
69
+ /**
70
+ * Narrow the vendored namespaces emitted into the build, for sites that care more about
71
+ * build output size than about which diagrams keep working. Dependencies of the named
72
+ * namespaces are kept regardless. Defaults to all of them; a reader only downloads the
73
+ * ones a page uses either way.
74
+ */
75
+ namespaces?: string[];
76
+ }
77
+ /** Standard library options after validation, with every default applied. */
78
+ export interface ResolvedStdlibOptions {
79
+ enabled: boolean;
80
+ /** Normalized to lower case. */
81
+ include: string[];
82
+ source: string[];
83
+ /** Normalized to lower case. `null` means "every vendored namespace". */
84
+ namespaces: string[] | null;
85
+ }
42
86
  /**
43
87
  * Options accepted by the plugin in `docusaurus.config.*`.
44
88
  *
@@ -73,16 +117,23 @@ export interface PlantUmlPluginOptions {
73
117
  showSource?: boolean;
74
118
  /** Graphviz/DOT diagram support. See {@link GraphvizOptions}. */
75
119
  graphviz?: GraphvizOptions;
120
+ /**
121
+ * PlantUML standard library support. See {@link StdlibOptions}. `false` is shorthand for
122
+ * `{enabled: false}`.
123
+ */
124
+ stdlib?: StdlibOptions | false;
76
125
  /** Docusaurus plugin instance id, used when the plugin is registered more than once. */
77
126
  id?: string;
78
127
  }
79
128
  /** Plugin options after validation, with every default applied. */
80
- export type ResolvedPlantUmlOptions = Required<Omit<PlantUmlPluginOptions, 'id' | 'languages' | 'graphviz'>> & {
129
+ export type ResolvedPlantUmlOptions = Required<Omit<PlantUmlPluginOptions, 'id' | 'languages' | 'graphviz' | 'stdlib'>> & {
81
130
  /** Normalized to lower case; matching is case-insensitive. */
82
131
  languages: string[];
83
132
  graphviz: ResolvedGraphvizOptions;
133
+ stdlib: ResolvedStdlibOptions;
84
134
  };
85
135
  export declare const DEFAULT_GRAPHVIZ_OPTIONS: ResolvedGraphvizOptions;
136
+ export declare const DEFAULT_STDLIB_OPTIONS: ResolvedStdlibOptions;
86
137
  export declare const DEFAULT_OPTIONS: ResolvedPlantUmlOptions;
87
138
  /** Thrown for invalid plugin options so Docusaurus fails the build with a useful message. */
88
139
  export declare class PlantUmlOptionsError extends Error {
package/dist/options.js CHANGED
@@ -27,6 +27,12 @@ export const DEFAULT_GRAPHVIZ_OPTIONS = {
27
27
  maxSourceBytes: 100_000,
28
28
  transparentBackground: true,
29
29
  };
30
+ export const DEFAULT_STDLIB_OPTIONS = {
31
+ enabled: true,
32
+ include: [],
33
+ source: [],
34
+ namespaces: null,
35
+ };
30
36
  export const DEFAULT_OPTIONS = {
31
37
  languages: ['plantuml', 'puml'],
32
38
  theme: 'auto',
@@ -39,6 +45,7 @@ export const DEFAULT_OPTIONS = {
39
45
  zoom: true,
40
46
  showSource: true,
41
47
  graphviz: DEFAULT_GRAPHVIZ_OPTIONS,
48
+ stdlib: DEFAULT_STDLIB_OPTIONS,
42
49
  };
43
50
  const CACHE_MODES = ['none', 'memory', 'session'];
44
51
  const THEMES = ['auto', 'light', 'dark'];
@@ -140,6 +147,76 @@ function validateGraphviz(value) {
140
147
  transparentBackground: validateBoolean(value.transparentBackground, 'graphviz.transparentBackground', DEFAULT_GRAPHVIZ_OPTIONS.transparentBackground),
141
148
  };
142
149
  }
150
+ /** Shared by `stdlib.include` and `stdlib.namespaces`, which are both lists of namespaces. */
151
+ function validateNamespaceList(value, key) {
152
+ if (!Array.isArray(value)) {
153
+ fail(`options.${key} must be an array of strings, received ${JSON.stringify(value)}.`);
154
+ }
155
+ const normalized = value.map((entry, index) => {
156
+ if (typeof entry !== 'string' || entry.trim() === '') {
157
+ fail(`options.${key}[${index}] must be a non-empty string, received ${JSON.stringify(entry)}.`);
158
+ }
159
+ // Namespaces are matched case-insensitively: the engine lower-cases `<C4/…>` before
160
+ // looking it up, so `'C4'` and `'c4'` have to mean the same thing here too.
161
+ return entry.trim().toLowerCase();
162
+ });
163
+ const duplicates = normalized.filter((entry, index) => normalized.indexOf(entry) !== index);
164
+ if (duplicates.length > 0) {
165
+ fail(`options.${key} contains duplicate entries: ${quoteList([...new Set(duplicates)])}.`);
166
+ }
167
+ return normalized;
168
+ }
169
+ function validateStdlibSource(value) {
170
+ if (value === undefined)
171
+ return [];
172
+ const entries = Array.isArray(value) ? value : [value];
173
+ return entries.map((entry, index) => {
174
+ if (typeof entry !== 'string' || entry.trim() === '') {
175
+ const where = Array.isArray(value) ? `stdlib.source[${index}]` : 'stdlib.source';
176
+ fail(`options.${where} must be a non-empty string, received ${JSON.stringify(entry)}.`);
177
+ }
178
+ return entry.trim();
179
+ });
180
+ }
181
+ /**
182
+ * Validates the nested `stdlib` group, accepting `false` as shorthand for `{enabled: false}`.
183
+ */
184
+ function validateStdlib(value) {
185
+ if (value === undefined)
186
+ return { ...DEFAULT_STDLIB_OPTIONS, include: [], source: [] };
187
+ if (value === false)
188
+ return { ...DEFAULT_STDLIB_OPTIONS, enabled: false, include: [], source: [] };
189
+ if (!isPlainObject(value)) {
190
+ fail(`options.stdlib must be an object or false, received ${JSON.stringify(value)}.`);
191
+ }
192
+ const allowedKeys = ['enabled', 'include', 'source', 'namespaces'];
193
+ const unknownKeys = Object.keys(value).filter((key) => !allowedKeys.includes(key));
194
+ if (unknownKeys.length > 0) {
195
+ fail(`Unknown option${unknownKeys.length > 1 ? 's' : ''} ` +
196
+ `${quoteList(unknownKeys.map((key) => `stdlib.${key}`))}. ` +
197
+ `Supported options: ${quoteList(allowedKeys.map((key) => `stdlib.${key}`))}.`);
198
+ }
199
+ const include = value.include === undefined ? [] : validateNamespaceList(value.include, 'stdlib.include');
200
+ const source = validateStdlibSource(value.source);
201
+ const namespaces = value.namespaces === undefined
202
+ ? null
203
+ : validateNamespaceList(value.namespaces, 'stdlib.namespaces');
204
+ if (namespaces !== null && namespaces.length === 0) {
205
+ fail('options.stdlib.namespaces must name at least one namespace. To turn the standard ' +
206
+ 'library off entirely, use `stdlib: false`.');
207
+ }
208
+ if (include.length > 0 && source.length === 0) {
209
+ fail(`options.stdlib.include names ${quoteList(include)}, but options.stdlib.source is not ` +
210
+ 'set, so there is nowhere to read them from. Point it at the `stdlib` directory of a ' +
211
+ 'plantuml-stdlib checkout.');
212
+ }
213
+ return {
214
+ enabled: validateBoolean(value.enabled, 'stdlib.enabled', DEFAULT_STDLIB_OPTIONS.enabled),
215
+ include,
216
+ source,
217
+ namespaces,
218
+ };
219
+ }
143
220
  function validateRenderTimeout(value) {
144
221
  if (value === undefined)
145
222
  return DEFAULT_OPTIONS.renderTimeoutMs;
@@ -175,6 +252,7 @@ export function resolveOptions(rawOptions) {
175
252
  ...DEFAULT_OPTIONS,
176
253
  languages: [...DEFAULT_OPTIONS.languages],
177
254
  graphviz: { ...DEFAULT_GRAPHVIZ_OPTIONS, languages: [...DEFAULT_GRAPHVIZ_OPTIONS.languages] },
255
+ stdlib: { ...DEFAULT_STDLIB_OPTIONS, include: [], source: [] },
178
256
  };
179
257
  }
180
258
  if (!isPlainObject(rawOptions)) {
@@ -192,6 +270,7 @@ export function resolveOptions(rawOptions) {
192
270
  'zoom',
193
271
  'showSource',
194
272
  'graphviz',
273
+ 'stdlib',
195
274
  // Docusaurus injects `id` for multi-instance plugins.
196
275
  'id',
197
276
  ]);
@@ -209,6 +288,7 @@ export function resolveOptions(rawOptions) {
209
288
  }
210
289
  const languages = validateLanguages(rawOptions.languages, 'languages', DEFAULT_OPTIONS.languages);
211
290
  const graphviz = validateGraphviz(rawOptions.graphviz);
291
+ const stdlib = validateStdlib(rawOptions.stdlib);
212
292
  // A fence has exactly one language, so a language claimed by both engines would make the
213
293
  // rendered output depend on the order the wrapper happens to check them in. Reject it.
214
294
  if (graphviz.enabled) {
@@ -221,6 +301,7 @@ export function resolveOptions(rawOptions) {
221
301
  return {
222
302
  languages,
223
303
  graphviz,
304
+ stdlib,
224
305
  theme: theme ?? DEFAULT_OPTIONS.theme,
225
306
  lazy: validateBoolean(rawOptions.lazy, 'lazy', DEFAULT_OPTIONS.lazy),
226
307
  cache: cache ?? DEFAULT_OPTIONS.cache,
@@ -10,6 +10,17 @@ export interface LoadRuntimeOptions {
10
10
  */
11
11
  importModule?: (url: string) => Promise<unknown>;
12
12
  }
13
+ /**
14
+ * Injects a classic script once, reusing an existing tag for the same URL.
15
+ *
16
+ * Client-side navigation re-mounts diagram components but does not reload the document, so
17
+ * the marker attribute is what keeps a second `<script>` from ever being appended. The URL is
18
+ * the marker's value, because the standard library loader adds a tag per namespace and a
19
+ * bare marker could not tell them apart.
20
+ *
21
+ * @param label how the asset is named in an error message, e.g. `Graphviz runtime`
22
+ */
23
+ export declare function loadClassicScript(url: string, timeoutMs: number, label: string): Promise<void>;
13
24
  /**
14
25
  * Resolves a ready-to-use Graphviz instance, loading `viz-global.js` on first use.
15
26
  *
@@ -29,5 +40,6 @@ export declare function loadPlantUmlRuntime(options: LoadRuntimeOptions): Promis
29
40
  export declare function isRuntimeRequested(): boolean;
30
41
  /** Whether the Graphviz runtime has been requested on this page. For tests and diagnostics. */
31
42
  export declare function isVizRuntimeRequested(): boolean;
32
- /** Test-only: forgets both cached runtimes and removes the injected script tag. */
43
+ export declare function onRuntimeReset(hook: () => void): void;
44
+ /** Test-only: forgets every cached runtime and removes the injected script tags. */
33
45
  export declare function resetRuntimeLoader(): void;
@@ -28,31 +28,41 @@ function defaultImportModule(url) {
28
28
  return import(/* webpackIgnore: true */ url);
29
29
  }
30
30
  /**
31
- * Injects `viz-global.js` as a classic script, reusing an existing tag.
31
+ * Injects a classic script once, reusing an existing tag for the same URL.
32
32
  *
33
33
  * Client-side navigation re-mounts diagram components but does not reload the document, so
34
- * the marker attribute is what keeps a second `<script>` from ever being appended.
34
+ * the marker attribute is what keeps a second `<script>` from ever being appended. The URL is
35
+ * the marker's value, because the standard library loader adds a tag per namespace and a
36
+ * bare marker could not tell them apart.
37
+ *
38
+ * @param label how the asset is named in an error message, e.g. `Graphviz runtime`
35
39
  */
36
- function loadVizGlobal(url, timeoutMs) {
37
- const existing = document.querySelector(`script[${SCRIPT_MARKER}]`);
40
+ export function loadClassicScript(url, timeoutMs, label) {
41
+ const existing = document.querySelector(`script[${SCRIPT_MARKER}=${JSON.stringify(url)}]`);
38
42
  if (existing) {
39
43
  if (existing.dataset.plantumlRuntimeState === 'loaded')
40
44
  return Promise.resolve();
41
- if (existing.dataset.plantumlRuntimeState === 'error') {
42
- return Promise.reject(new PlantUmlError('load', `Failed to load the Graphviz runtime from ${url}.`));
43
- }
44
- return waitForScript(existing, url, timeoutMs);
45
+ // A failed tag is discarded rather than reused. The caller that reaches this point has
46
+ // already dropped its cached promise in order to retry, and reusing the dead tag would
47
+ // make that retry impossible — a single flaky response would poison the page.
48
+ if (existing.dataset.plantumlRuntimeState === 'error')
49
+ existing.remove();
50
+ else
51
+ return waitForScript(existing, url, timeoutMs, label);
45
52
  }
46
53
  const script = document.createElement('script');
47
- script.setAttribute(SCRIPT_MARKER, '');
54
+ script.setAttribute(SCRIPT_MARKER, url);
48
55
  script.dataset.plantumlRuntimeState = 'loading';
49
56
  script.async = false;
50
57
  script.src = url;
51
- const promise = waitForScript(script, url, timeoutMs);
58
+ const promise = waitForScript(script, url, timeoutMs, label);
52
59
  document.head.appendChild(script);
53
60
  return promise;
54
61
  }
55
- function waitForScript(script, url, timeoutMs) {
62
+ function loadVizGlobal(url, timeoutMs) {
63
+ return loadClassicScript(url, timeoutMs, 'Graphviz runtime');
64
+ }
65
+ function waitForScript(script, url, timeoutMs, label) {
56
66
  return new Promise((resolve, reject) => {
57
67
  let done = false;
58
68
  const finish = (fn) => {
@@ -70,7 +80,7 @@ function waitForScript(script, url, timeoutMs) {
70
80
  };
71
81
  const onError = () => {
72
82
  script.dataset.plantumlRuntimeState = 'error';
73
- finish(() => reject(new PlantUmlError('load', `Failed to load the Graphviz runtime from ${url}. ` +
83
+ finish(() => reject(new PlantUmlError('load', `Failed to load the ${label} from ${url}. ` +
74
84
  'Check that the asset is served under your Docusaurus baseUrl with a JavaScript MIME type.')));
75
85
  };
76
86
  const timer = setTimeout(() => {
@@ -175,10 +185,23 @@ export function isRuntimeRequested() {
175
185
  export function isVizRuntimeRequested() {
176
186
  return cachedViz !== null;
177
187
  }
178
- /** Test-only: forgets both cached runtimes and removes the injected script tag. */
188
+ /**
189
+ * Registers a callback for {@link resetRuntimeLoader}.
190
+ *
191
+ * The standard library loader keeps its own per-namespace cache but shares the script tags
192
+ * this module injects, so a reset that removed the tags without clearing that cache would
193
+ * leave it claiming namespaces are loaded when their globals are gone. The dependency points
194
+ * this way — loader to registry — so that `assetLoader` needs no import of its callers.
195
+ */
196
+ const resetHooks = [];
197
+ export function onRuntimeReset(hook) {
198
+ resetHooks.push(hook);
199
+ }
200
+ /** Test-only: forgets every cached runtime and removes the injected script tags. */
179
201
  export function resetRuntimeLoader() {
180
202
  cachedRuntime = null;
181
203
  cachedViz = null;
204
+ resetHooks.forEach((hook) => hook());
182
205
  if (typeof document !== 'undefined') {
183
206
  document.querySelectorAll(`script[${SCRIPT_MARKER}]`).forEach((node) => node.remove());
184
207
  }
@@ -4,6 +4,11 @@ export interface CacheKeyInput {
4
4
  dark: boolean;
5
5
  sanitized: boolean;
6
6
  coreVersion: string;
7
+ /**
8
+ * Identifies the standard library the diagram was rendered against, so refreshing it
9
+ * invalidates the entries whose pictures it shaped. `null` when it is switched off.
10
+ */
11
+ stdlibRevision: string | null;
7
12
  }
8
13
  export interface DiagramCache {
9
14
  readonly mode: CacheMode;
@@ -13,7 +18,7 @@ export interface DiagramCache {
13
18
  /** Number of entries currently held. Exposed for tests and the documented size limit. */
14
19
  size(): number;
15
20
  }
16
- export declare function computeCacheKey({ source, dark, sanitized, coreVersion }: CacheKeyInput): string;
21
+ export declare function computeCacheKey({ source, dark, sanitized, coreVersion, stdlibRevision, }: CacheKeyInput): string;
17
22
  export interface GraphvizCacheKeyInput {
18
23
  source: string;
19
24
  /** Graphviz layout engine; `dot` and `neato` draw different pictures from one source. */
@@ -15,12 +15,12 @@ function hash(input) {
15
15
  }
16
16
  return value.toString(36).padStart(7, '0');
17
17
  }
18
- export function computeCacheKey({ source, dark, sanitized, coreVersion }) {
18
+ export function computeCacheKey({ source, dark, sanitized, coreVersion, stdlibRevision, }) {
19
19
  const mode = dark ? 'dark' : 'light';
20
20
  const clean = sanitized ? 'san' : 'raw';
21
21
  // The length guards against the (astronomically unlikely) 32-bit hash collision between
22
22
  // two different sources that happen to share every other key component.
23
- return `${coreVersion}|${mode}|${clean}|${source.length}|${hash(source)}`;
23
+ return `${coreVersion}|${stdlibRevision ?? 'nostd'}|${mode}|${clean}|${source.length}|${hash(source)}`;
24
24
  }
25
25
  /**
26
26
  * Cache key for a Graphviz diagram.
@@ -1,5 +1,7 @@
1
1
  import type { VizRenderError } from './types.js';
2
- export type PlantUmlErrorKind = 'load' | 'engine' | 'diagram' | 'syntax' | 'timeout' | 'config' | 'aborted' | 'too-large';
2
+ export type PlantUmlErrorKind = 'load' | 'engine' | 'diagram' | 'syntax' | 'timeout' | 'config' | 'aborted' | 'too-large'
3
+ /** A `!include <namespace/…>` the site cannot resolve. */
4
+ | 'stdlib';
3
5
  /** Every failure surfaced to the UI is one of these, so the panel can explain what broke. */
4
6
  export declare class PlantUmlError extends Error {
5
7
  readonly kind: PlantUmlErrorKind;
@@ -18,6 +18,24 @@ const BOILERPLATE_PATTERNS = [
18
18
  /^\s*$/,
19
19
  ];
20
20
  const NOT_SUPPORTED_HEADING = 'Diagram not supported by this release of PlantUML';
21
+ /**
22
+ * Failures raised by PlantUML's *preprocessor* rather than by its parser.
23
+ *
24
+ * These matter far more now that `!include <namespace/…>` is resolvable. Calling a macro the
25
+ * included version does not define, naming a file that is not in the namespace, and an
26
+ * include the preprocessor could not resolve at all are the three likeliest ways a standard
27
+ * library diagram goes wrong. *None* of them produces the `Syntax Error?` marker below — the
28
+ * picture says only `Function not found RelIndex`, `cannot include <…>` or `Fatal parsing
29
+ * error` — so without this signature the engine's error card was passed through as a
30
+ * successful render.
31
+ */
32
+ const PREPROCESSOR_FAILURE_PATTERN = /^\s*(?:function not found|cannot include|fatal parsing error)\b/i;
33
+ /**
34
+ * The header PlantUML puts above the source listing in every error picture. Required to
35
+ * co-occur with the line above, so that a diagram merely containing the words "cannot
36
+ * include" in a note is not mistaken for a failure.
37
+ */
38
+ const SOURCE_LISTING_PATTERN = /^\[From .+\(line \d+\)/;
21
39
  function textNodesOf(svg) {
22
40
  if (typeof DOMParser === 'undefined')
23
41
  return [];
@@ -49,8 +67,16 @@ export function detectDiagramError(svg) {
49
67
  const isEmptyDescription = texts.some((text) => /Empty description/.test(text) && text.includes('(Assumed diagram type:'));
50
68
  const isUnsupported = texts.some((text) => text.includes(NOT_SUPPORTED_HEADING)) &&
51
69
  texts.some((text) => text.includes('is not recognized.'));
52
- if (!isSyntaxError && !isEmptyDescription && !isUnsupported)
70
+ const preprocessorFailures = texts.filter((text) => PREPROCESSOR_FAILURE_PATTERN.test(text));
71
+ const isPreprocessorError = preprocessorFailures.length > 0 && texts.some((text) => SOURCE_LISTING_PATTERN.test(text));
72
+ if (!isSyntaxError && !isEmptyDescription && !isUnsupported && !isPreprocessorError)
53
73
  return null;
74
+ // A preprocessor failure lists the *expanded* source, which for a standard library macro
75
+ // runs to thousands of lines. The failure itself is the only part worth showing; the
76
+ // reader still has the original fence a click away in the source view.
77
+ if (isPreprocessorError && !isSyntaxError) {
78
+ return preprocessorFailures.map((text) => text.trim()).join('\n');
79
+ }
54
80
  const meaningful = texts
55
81
  .map((text) => text.replace(/\s+$/, ''))
56
82
  .filter((text) => !BOILERPLATE_PATTERNS.some((pattern) => pattern.test(text)));
@@ -1,5 +1,6 @@
1
1
  import { type LoadRuntimeOptions } from './assetLoader.js';
2
2
  import { type DiagramCache } from './cache.js';
3
+ import { type StdlibRuntime } from './stdlibLoader.js';
3
4
  export interface RenderDiagramRequest {
4
5
  source: string;
5
6
  dark: boolean;
@@ -7,6 +8,8 @@ export interface RenderDiagramRequest {
7
8
  timeoutMs: number;
8
9
  assetsBaseUrl: string;
9
10
  coreVersion: string;
11
+ /** Standard library assets for this site, or `null` when the feature is switched off. */
12
+ stdlib: StdlibRuntime | null;
10
13
  cache: DiagramCache;
11
14
  signal?: AbortSignal;
12
15
  /** Reports which long-running phase is in progress, so the UI can show an accurate state. */
@@ -3,6 +3,7 @@ import { computeCacheKey } from './cache.js';
3
3
  import { describeEngineError, detectDiagramError, PlantUmlError } from './errors.js';
4
4
  import { enqueueRender } from './queue.js';
5
5
  import { sanitizeSvgMarkup } from './sanitize.js';
6
+ import { loadStdlibForSource } from './stdlibLoader.js';
6
7
  /**
7
8
  * `renderToString` takes its options as a *fourth* argument, after the two callbacks.
8
9
  * This was verified against the installed `@plantuml/core` rather than assumed: the
@@ -41,19 +42,32 @@ function renderWithEngine(engine, source, dark) {
41
42
  * {@link enqueueRender} because the engine cannot render two diagrams at once.
42
43
  */
43
44
  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 });
45
+ const { source, dark, sanitize, timeoutMs, cache, coreVersion, stdlib, signal, onPhase } = request;
46
+ const cacheKey = computeCacheKey({
47
+ source,
48
+ dark,
49
+ sanitized: sanitize,
50
+ coreVersion,
51
+ stdlibRevision: stdlib?.manifest.revision ?? null,
52
+ });
46
53
  const cached = cache.get(cacheKey);
47
54
  if (cached !== undefined)
48
55
  return cached;
49
56
  // Loading happens outside the queue: it is shared, idempotent work, and holding the
50
57
  // single render slot for an 8 MB download would stall every other diagram on the page.
58
+ //
59
+ // The standard library loads alongside the engine rather than after it. The two are
60
+ // independent downloads, and the engine cannot be asked for a diagram whose includes are
61
+ // not resolvable yet — by the time it looks, the namespaces have to be in place already.
51
62
  onPhase?.('loading');
52
- const engine = await loadPlantUmlRuntime({
53
- assetsBaseUrl: request.assetsBaseUrl,
54
- timeoutMs,
55
- importModule: request.importModule,
56
- });
63
+ const [engine] = await Promise.all([
64
+ loadPlantUmlRuntime({
65
+ assetsBaseUrl: request.assetsBaseUrl,
66
+ timeoutMs,
67
+ importModule: request.importModule,
68
+ }),
69
+ loadStdlibForSource({ source, stdlib, timeoutMs }),
70
+ ]);
57
71
  onPhase?.('rendering');
58
72
  const svg = await enqueueRender(async () => {
59
73
  const raw = await renderWithEngine(engine, source, dark);
@@ -0,0 +1,31 @@
1
+ import { type StdlibRuntimeManifest } from '../stdlibShared.js';
2
+ export interface StdlibRuntime {
3
+ /** Absolute, `baseUrl`-prefixed URL of the directory holding the namespace bundles. */
4
+ baseUrl: string;
5
+ manifest: StdlibRuntimeManifest;
6
+ }
7
+ /**
8
+ * The namespaces a source needs, in load order, including what those namespaces need in turn.
9
+ *
10
+ * Dependencies come from the manifest rather than from parsing the library sources in the
11
+ * browser: `k8s` includes `<c4/…>` from its own `Common.puml`, and the engine would ask for
12
+ * it mid-render, far too late to fetch anything. Example-only dependencies are pulled in
13
+ * solely when the diagram includes something from `_examples_/` — C4's examples reference
14
+ * `office`, and charging every C4 diagram 160 KB for that would be absurd.
15
+ *
16
+ * A dependency the site does not provide is skipped rather than raised: it may well sit
17
+ * behind an `!if` the diagram never takes, and failing here would break diagrams that work.
18
+ */
19
+ export declare function collectStdlibNamespaces(source: string, manifest: StdlibRuntimeManifest): string[];
20
+ export interface LoadStdlibOptions {
21
+ source: string;
22
+ stdlib: StdlibRuntime | null;
23
+ timeoutMs: number;
24
+ }
25
+ /**
26
+ * Loads whatever the source needs from the standard library. Resolves immediately when it
27
+ * needs nothing, which is the common case and must stay free.
28
+ */
29
+ export declare function loadStdlibForSource({ source, stdlib, timeoutMs, }: LoadStdlibOptions): Promise<void>;
30
+ /** Whether a namespace bundle has been requested on this page. For tests and diagnostics. */
31
+ export declare function isStdlibNamespaceRequested(url: string): boolean;
@@ -0,0 +1,99 @@
1
+ import { findStdlibIncludes } from '../stdlibShared.js';
2
+ import { loadClassicScript, onRuntimeReset } from './assetLoader.js';
3
+ import { PlantUmlError } from './errors.js';
4
+ /** One in-flight or settled load per bundle URL, shared by every diagram on the page. */
5
+ const loaded = new Map();
6
+ onRuntimeReset(() => loaded.clear());
7
+ function bundleFileName(namespace) {
8
+ return `${namespace}.min.js`;
9
+ }
10
+ function joinUrl(base, file) {
11
+ return `${base.replace(/\/+$/, '')}/${file}`;
12
+ }
13
+ /**
14
+ * Tells the engine the namespace bundle is already in the document.
15
+ *
16
+ * The key is the bare file name because that is exactly the `src` the engine would have set,
17
+ * and its bookkeeping is keyed by that string rather than by the resolved URL.
18
+ */
19
+ function markLoadedForEngine(namespace) {
20
+ const engineWindow = window;
21
+ const state = (engineWindow.__pl_script_state ??= Object.create(null));
22
+ state[bundleFileName(namespace)] = { state: 'loaded', ok: [], err: [] };
23
+ }
24
+ /**
25
+ * The namespaces a source needs, in load order, including what those namespaces need in turn.
26
+ *
27
+ * Dependencies come from the manifest rather than from parsing the library sources in the
28
+ * browser: `k8s` includes `<c4/…>` from its own `Common.puml`, and the engine would ask for
29
+ * it mid-render, far too late to fetch anything. Example-only dependencies are pulled in
30
+ * solely when the diagram includes something from `_examples_/` — C4's examples reference
31
+ * `office`, and charging every C4 diagram 160 KB for that would be absurd.
32
+ *
33
+ * A dependency the site does not provide is skipped rather than raised: it may well sit
34
+ * behind an `!if` the diagram never takes, and failing here would break diagrams that work.
35
+ */
36
+ export function collectStdlibNamespaces(source, manifest) {
37
+ const references = findStdlibIncludes(source);
38
+ const ordered = [];
39
+ const seen = new Set();
40
+ const visit = (namespace, withExamples, direct) => {
41
+ const entry = manifest.namespaces[namespace];
42
+ if (!entry) {
43
+ if (!direct)
44
+ return;
45
+ throw new PlantUmlError('stdlib', `This diagram includes <${namespace}/…>, but the '${namespace}' standard library ` +
46
+ "namespace is not available on this site. Add it to the plugin's " +
47
+ `\`stdlib.include\` option and point \`stdlib.source\` at a plantuml-stdlib ` +
48
+ 'checkout that contains it.');
49
+ }
50
+ if (seen.has(namespace))
51
+ return;
52
+ seen.add(namespace);
53
+ for (const dependency of entry.dependencies)
54
+ visit(dependency, false, false);
55
+ if (withExamples) {
56
+ for (const dependency of entry.exampleDependencies)
57
+ visit(dependency, false, false);
58
+ }
59
+ ordered.push(namespace);
60
+ };
61
+ for (const reference of references) {
62
+ visit(reference.namespace, reference.fromExamples, true);
63
+ }
64
+ return ordered;
65
+ }
66
+ /**
67
+ * Loads whatever the source needs from the standard library. Resolves immediately when it
68
+ * needs nothing, which is the common case and must stay free.
69
+ */
70
+ export async function loadStdlibForSource({ source, stdlib, timeoutMs, }) {
71
+ const references = findStdlibIncludes(source);
72
+ if (references.length === 0)
73
+ return;
74
+ if (!stdlib) {
75
+ const names = [...new Set(references.map((reference) => reference.namespace))];
76
+ throw new PlantUmlError('stdlib', `This diagram includes ${names.map((name) => `<${name}/…>`).join(', ')} from the ` +
77
+ 'PlantUML standard library, which is switched off for this site (`stdlib: false`).');
78
+ }
79
+ const namespaces = collectStdlibNamespaces(source, stdlib.manifest);
80
+ await Promise.all(namespaces.map(async (namespace) => {
81
+ const url = joinUrl(stdlib.baseUrl, bundleFileName(namespace));
82
+ let pending = loaded.get(url);
83
+ if (!pending) {
84
+ pending = loadClassicScript(url, timeoutMs, `standard library namespace '${namespace}'`)
85
+ .then(() => markLoadedForEngine(namespace))
86
+ .catch((error) => {
87
+ // Clearing the entry lets a later diagram retry rather than inherit the failure.
88
+ loaded.delete(url);
89
+ throw error;
90
+ });
91
+ loaded.set(url, pending);
92
+ }
93
+ await pending;
94
+ }));
95
+ }
96
+ /** Whether a namespace bundle has been requested on this page. For tests and diagnostics. */
97
+ export function isStdlibNamespaceRequested(url) {
98
+ return loaded.has(url);
99
+ }