@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/README.md ADDED
@@ -0,0 +1,781 @@
1
+ # @matfsw/docusaurus-plantuml-plugin
2
+
3
+ Render PlantUML diagrams in Docusaurus 3 entirely in the browser — no Java, no PlantUML
4
+ server, no Kroki, no CDN.
5
+
6
+ Write a `plantuml` fence in any `.md` or `.mdx` file and the code block is replaced, in the
7
+ reader's browser, with an SVG rendered by the official [`@plantuml/core`][plantuml-core]
8
+ package:
9
+
10
+ ````markdown
11
+ ```plantuml title="Authentication sequence"
12
+ @startuml
13
+ actor User
14
+ participant Browser
15
+ participant API
16
+
17
+ User -> Browser: Sign in
18
+ Browser -> API: POST /sessions
19
+ API --> Browser: Access token
20
+ Browser --> User: Signed in
21
+ @enduml
22
+ ```
23
+ ````
24
+
25
+ **Rendering is 100% same-origin and client-side. Your diagram source never leaves the
26
+ browser.** It is not sent to plantuml.com, to a Kroki instance, or to any other service, and
27
+ the engine itself is served from your own site's `baseUrl`.
28
+
29
+ ## Status and compatibility
30
+
31
+ Status: **beta**. The public surface (plugin options, `data-*` attributes, fence syntax) is
32
+ stable enough to build on, but this is a `0.x` release and breaking changes will happen in
33
+ minor versions until `1.0.0`.
34
+
35
+ | Requirement | Supported |
36
+ | --------------- | -------------------------------------------------------------------------------------- |
37
+ | Docusaurus | `3.5.0` and later `3.x` |
38
+ | Node.js (build) | `>= 20` |
39
+ | React | `18.x` or `19.x` (matching your Docusaurus install) |
40
+ | Bundler | webpack (default) and Rspack (`future.v4` / `@docusaurus/faster`) |
41
+ | PlantUML engine | `@plantuml/core` `1.2026.6` |
42
+ | Browsers | Modern evergreen browsers; no IE (see [Browser compatibility](#browser-compatibility)) |
43
+
44
+ The plugin requires a theme that already provides `MDXComponents/Code` — in practice
45
+ `@docusaurus/theme-classic`, which you almost certainly have through `preset-classic`.
46
+
47
+ CI runs the full browser suite against Docusaurus `3.5.2` (the oldest supported release), the
48
+ version the example pins, and the newest `3.x` resolved at run time.
49
+
50
+ ### Docusaurus 3.5–3.10.0 need a `webpackbar` override
51
+
52
+ This is an upstream issue that has nothing to do with this plugin, but it will stop your build
53
+ before the plugin is ever reached, so it is worth knowing about.
54
+
55
+ `@docusaurus/bundler` depended on `webpackbar@^6.0.1` up to and including Docusaurus `3.10.0`,
56
+ and `webpackbar` 6 fails `ProgressPlugin`'s option validation against `webpack >= 5.109`. A
57
+ fresh install of any Docusaurus `3.5`–`3.10.0` site therefore fails to build with:
58
+
59
+ ```text
60
+ ValidationError: Invalid options object.
61
+ Progress Plugin has been initialized using an options object that does not match the API schema.
62
+ ```
63
+
64
+ If you are on one of those versions and see this, add an override to your **site's**
65
+ `package.json` and reinstall:
66
+
67
+ ```json
68
+ {
69
+ "overrides": {
70
+ "webpackbar": "^7.0.0"
71
+ }
72
+ }
73
+ ```
74
+
75
+ Docusaurus `3.10.2` and later depend on `webpackbar@^7` already and need no override. This is
76
+ exactly what the compatibility job in CI does to test the `3.5.2` leg.
77
+
78
+ ### A note on the engine's licence
79
+
80
+ `@plantuml/core` is MIT-licensed **from version `1.2026.6` onwards**. Earlier versions were
81
+ published under GPL-3.0-or-later. This plugin depends on `^1.2026.6` for that reason; if you
82
+ pin or dedupe `@plantuml/core` to an older version yourself, you inherit the older licence.
83
+
84
+ ## Installation
85
+
86
+ ```bash
87
+ npm install @matfsw/docusaurus-plantuml-plugin
88
+ ```
89
+
90
+ `@plantuml/core`, `copy-webpack-plugin` and `dompurify` are ordinary dependencies of this
91
+ package and are installed for you. `@docusaurus/core`, `react` and `react-dom` are peer
92
+ dependencies and come from your site.
93
+
94
+ ## Minimal configuration
95
+
96
+ ```ts
97
+ // docusaurus.config.ts
98
+ import type {Config} from '@docusaurus/types';
99
+
100
+ const config: Config = {
101
+ // ...
102
+ plugins: ['@matfsw/docusaurus-plantuml-plugin'],
103
+ };
104
+
105
+ export default config;
106
+ ```
107
+
108
+ That is the whole setup. Every option has a default; pass an options object only to change
109
+ one:
110
+
111
+ ```ts
112
+ // docusaurus.config.ts
113
+ import type {Config} from '@docusaurus/types';
114
+ import type {PlantUmlPluginOptions} from '@matfsw/docusaurus-plantuml-plugin';
115
+
116
+ const config: Config = {
117
+ // ...
118
+ plugins: [
119
+ [
120
+ '@matfsw/docusaurus-plantuml-plugin',
121
+ {
122
+ languages: ['plantuml', 'puml'],
123
+ theme: 'auto',
124
+ lazy: true,
125
+ cache: 'memory',
126
+ sanitizeSvg: true,
127
+ showSourceOnError: true,
128
+ renderTimeoutMs: 20_000,
129
+ cacheMaxEntries: 50,
130
+ } satisfies PlantUmlPluginOptions,
131
+ ],
132
+ ],
133
+ };
134
+
135
+ export default config;
136
+ ```
137
+
138
+ A plain JavaScript `docusaurus.config.js` works too — the package ships both an ESM and a
139
+ CommonJS build of the Node-side entry point.
140
+
141
+ ## Authoring diagrams
142
+
143
+ ### Markdown (`.md`)
144
+
145
+ ````markdown
146
+ # Sequence diagram
147
+
148
+ ```plantuml title="Authentication sequence"
149
+ @startuml
150
+ actor User
151
+ participant API
152
+ User -> API: POST /sessions
153
+ API --> User: Access token
154
+ @enduml
155
+ ```
156
+ ````
157
+
158
+ The `title="..."` in the fence metastring becomes both the `<figcaption>` and the accessible
159
+ label of the diagram. It is optional.
160
+
161
+ ### MDX (`.mdx`)
162
+
163
+ The same fence syntax works in MDX, alongside JSX:
164
+
165
+ ````mdx
166
+ import Admonition from '@theme/Admonition';
167
+
168
+ <Admonition type="info" title="MDX">
169
+ Fenced PlantUML works here too.
170
+ </Admonition>
171
+
172
+ ```puml
173
+ @startuml
174
+ Alice -> Bob : puml alias in MDX
175
+ @enduml
176
+ ```
177
+ ````
178
+
179
+ ### Supported fence aliases
180
+
181
+ By default the plugin claims two fence languages:
182
+
183
+ - `plantuml`
184
+ - `puml`
185
+
186
+ Matching is **case-insensitive**, so ` ```PlantUML `, ` ```PUML ` and ` ```plantuml ` are all
187
+ recognised. Change the set with the `languages` option; entries are normalised to lower case,
188
+ must be non-empty, and must not repeat.
189
+
190
+ Everything else — inline code, ordinary fenced blocks in any other language, and code blocks
191
+ authored as JSX children — is delegated to the original Docusaurus `Code` component with its
192
+ props untouched. Existing code-block behaviour (highlighting, line numbers, titles,
193
+ `showLineNumbers`, copy button) is unchanged.
194
+
195
+ ## Options
196
+
197
+ | Option | Type | Default | Description |
198
+ | ------------------- | --------------------------------- | ---------------------- | -------------------------------------------------------------------------------------------------------------------------- |
199
+ | `languages` | `string[]` | `['plantuml', 'puml']` | Fence languages treated as PlantUML. Matched case-insensitively; must contain at least one non-empty, non-duplicate entry. |
200
+ | `theme` | `'auto' \| 'light' \| 'dark'` | `'auto'` | Diagram colour scheme. `auto` follows the Docusaurus colour mode; `light`/`dark` pin it. |
201
+ | `lazy` | `boolean` | `true` | Render a diagram only once it scrolls near the viewport (300 px root margin). |
202
+ | `cache` | `'none' \| 'memory' \| 'session'` | `'memory'` | Where rendered SVG is cached. |
203
+ | `sanitizeSvg` | `boolean` | `true` | Run rendered SVG through DOMPurify before inserting it. See [Security model](#svg-sanitization-and-security-model). |
204
+ | `showSourceOnError` | `boolean` | `true` | Include the diagram source in a `<details>` block on the error panel. |
205
+ | `renderTimeoutMs` | `number` | `20000` | Abort a single render (and the runtime load) after this many milliseconds. Integer, `100`–`600000`. |
206
+ | `cacheMaxEntries` | `number` | `50` | Upper bound on cached SVG entries. Positive integer. |
207
+ | `id` | `string` | `'default'` | Docusaurus plugin instance id; only relevant if you register the plugin more than once. |
208
+
209
+ Options are validated during the Docusaurus configuration phase, before anything is built.
210
+ **Unknown keys are rejected** rather than ignored, because a typo in `docusaurus.config.ts`
211
+ would otherwise silently disable the option you meant to set:
212
+
213
+ ```text
214
+ [docusaurus-plugin-plantuml-client] Unknown option 'sanitiseSvg'. Supported options:
215
+ 'languages', 'theme', 'lazy', 'cache', 'sanitizeSvg', 'showSourceOnError',
216
+ 'renderTimeoutMs', 'cacheMaxEntries'.
217
+ ```
218
+
219
+ `PlantUmlPluginOptions`, `ResolvedPlantUmlOptions`, `CacheMode`, `DiagramTheme`,
220
+ `DEFAULT_OPTIONS`, `resolveOptions` and `PlantUmlOptionsError` are exported from the package
221
+ root. The renderer, queue and cache internals are deliberately not exported.
222
+
223
+ ## Light and dark mode
224
+
225
+ With `theme: 'auto'` (the default), the diagram follows the site's colour mode through
226
+ Docusaurus' own `useColorMode()` hook. Toggling the site theme re-renders every visible
227
+ diagram with the engine's `{dark}` flag flipped, which changes PlantUML's own fill and stroke
228
+ palette rather than merely inverting the picture with CSS.
229
+
230
+ The colour mode is part of the cache key, so a toggle can never serve the other mode's SVG,
231
+ and the engine itself is loaded once per page regardless of how often you toggle.
232
+
233
+ Set `theme: 'light'` or `theme: 'dark'` to pin the diagram palette independently of the site
234
+ theme.
235
+
236
+ The current mode is exposed on the wrapper as `data-plantuml-theme="light|dark"`.
237
+
238
+ ## Lazy loading
239
+
240
+ With `lazy: true` (the default), a diagram is not rendered until it scrolls within 300 px of
241
+ the viewport, observed with `IntersectionObserver`. Consequently:
242
+
243
+ - The ~8 MB PlantUML runtime is fetched only on pages that actually contain a diagram, and
244
+ only once such a diagram approaches the viewport.
245
+ - Diagrams far down a long page cost nothing until the reader gets there.
246
+
247
+ If `IntersectionObserver` is unavailable (older browsers, some test environments), the
248
+ component renders immediately rather than staying blank forever.
249
+
250
+ Set `lazy: false` to start rendering every diagram as soon as it mounts.
251
+
252
+ ## Caching
253
+
254
+ Rendered SVG is cached under a deterministic key:
255
+
256
+ ```text
257
+ <coreVersion>|<light|dark>|<san|raw>|<sourceLength>|<FNV-1a hash of source>
258
+ ```
259
+
260
+ Everything that can change the output is in the key: the `@plantuml/core` version (so an
261
+ engine upgrade invalidates every entry), the colour mode, whether the output was sanitized,
262
+ and the source itself. The source length is included alongside the 32-bit hash so that a hash
263
+ collision alone cannot serve the wrong diagram.
264
+
265
+ | Mode | Behaviour |
266
+ | ----------- | ---------------------------------------------------------------------------------------------------------------------------------------------- |
267
+ | `'none'` | Nothing is stored. Every mount re-renders. |
268
+ | `'memory'` | Default. Process-lifetime `Map`, LRU-evicted down to `cacheMaxEntries` (50). Cleared on full page load. |
269
+ | `'session'` | `sessionStorage` under `plantuml-client:` keys, FIFO-trimmed to `cacheMaxEntries`. Survives client-side navigation and reloads within the tab. |
270
+
271
+ The cache is shared by every diagram on the page, so the same source rendered twice is
272
+ rendered once. A cache hit skips the render queue entirely.
273
+
274
+ `sessionStorage` is treated as best-effort: entries that fail to parse or carry the wrong
275
+ entry version are discarded and re-rendered, and if `sessionStorage` throws at all (private
276
+ browsing modes, quota exhaustion, embedded browsers) the cache silently degrades to an
277
+ in-memory cache for the rest of the session. A storage failure never breaks rendering.
278
+
279
+ ## Accessibility
280
+
281
+ Each diagram renders as:
282
+
283
+ ```html
284
+ <figure
285
+ aria-busy="true"
286
+ data-plantuml-diagram="plantuml"
287
+ data-plantuml-status="rendering"
288
+ data-plantuml-theme="light"
289
+ >
290
+ <div role="img" aria-label="Authentication sequence"><svg>…</svg></div>
291
+ <figcaption>Authentication sequence</figcaption>
292
+ <noscript><pre>@startuml …</pre></noscript>
293
+ </figure>
294
+ ```
295
+
296
+ - The fence `title="..."` becomes the `<figcaption>` and the `aria-label`. Without a title,
297
+ the accessible label falls back to **`PlantUML diagram`**.
298
+ - Progress is conveyed with `aria-busy` on the `<figure>`, deliberately **not** with an
299
+ `aria-live` region: a page with a dozen diagrams would otherwise announce a dozen state
300
+ changes and make the page unusable with a screen reader.
301
+ - Errors are announced once, through `role="alert"` on the error panel.
302
+ - Failure is never signalled by colour alone — the panel carries a literal `Error:` label and
303
+ a ⚠ glyph in addition to the danger colouring, so it survives colour-blindness and
304
+ forced-colours mode.
305
+ - Readers without JavaScript get the diagram source in a `<noscript>` block.
306
+ - The spinner animation is disabled under `prefers-reduced-motion: reduce`.
307
+
308
+ ### `data-*` contract
309
+
310
+ These attributes are part of the public surface; write your own end-to-end tests against
311
+ them.
312
+
313
+ | Attribute | Values |
314
+ | ----------------------- | ---------------------------------------------------------- |
315
+ | `data-plantuml-diagram` | the fence language that matched, e.g. `plantuml` or `puml` |
316
+ | `data-plantuml-status` | `idle` \| `loading` \| `rendering` \| `ready` \| `error` |
317
+ | `data-plantuml-theme` | `light` \| `dark` |
318
+
319
+ ## SVG sanitization and security model
320
+
321
+ PlantUML output is generated from diagram source, and diagram source can express markup —
322
+ labels, `<style>` blocks, hyperlinks. The rendered SVG is therefore treated as untrusted and,
323
+ by default, passed through [DOMPurify][dompurify] before it reaches the DOM:
324
+
325
+ - `USE_PROFILES: {svg: true, svgFilters: true}` — geometry, text, gradients, filters, markers
326
+ and links survive.
327
+ - Additionally forbidden: `foreignObject`, `script`, `iframe`, `object`, `embed`, `audio`,
328
+ `video`. `foreignObject` matters most: it is the one SVG element that can host arbitrary
329
+ HTML, which would reintroduce precisely the injection surface the SVG profile exists to
330
+ remove. PlantUML does not need it.
331
+ - `formaction`, `xlink:show` and `ping` attributes are dropped; `role` and the `aria-label` /
332
+ `aria-labelledby` / `aria-describedby` attributes are preserved so the diagram stays
333
+ accessible.
334
+ - Event-handler attributes (`onload`, `onclick`, …) and `javascript:` URLs are removed by
335
+ DOMPurify itself.
336
+
337
+ If sanitization removes so much that no `<svg>` root remains, the plugin raises an error
338
+ rather than showing a silently blank figure.
339
+
340
+ ### Disabling sanitization
341
+
342
+ `sanitizeSvg: false` is a supported, documented opt-out — and a genuine risk. With it off,
343
+ **whatever HTML or JavaScript a diagram author can express in PlantUML's output is injected
344
+ into your page verbatim** and executes with your site's origin, cookies and storage. Turn it
345
+ off only if every diagram source in your site is fully trusted — that is, if nobody who could
346
+ not already commit arbitrary code to the site can influence a diagram. If you accept diagrams
347
+ from external contributors, from generated content, or from anything user-supplied, leave it
348
+ on.
349
+
350
+ ### What leaves the browser
351
+
352
+ Nothing. The engine is copied into your own build output and served from your own origin; the
353
+ renderer calls it in-process and inserts the result into the DOM. There is no HTTP fallback
354
+ to plantuml.com or anywhere else, and none can be configured.
355
+
356
+ ## Error behaviour
357
+
358
+ A failed diagram produces a contained error panel. It never crashes the page, never blocks
359
+ the render queue, and never silently disappears.
360
+
361
+ The panel shows a heading (`⚠ Error: PlantUML diagram could not be rendered`), the message,
362
+ and — unless `showSourceOnError: false` — the original source in a collapsed `<details>`
363
+ element. The wrapper carries `data-plantuml-status="error"`.
364
+
365
+ Failures are classified internally as one of:
366
+
367
+ | Kind | Cause |
368
+ | --------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- |
369
+ | `load` | `viz-global.js` or `plantuml.js` could not be fetched, timed out, or was not the expected module (usually a proxy or service worker returning the wrong file). |
370
+ | `engine` | The engine threw, or its output could not be used at all. |
371
+ | `diagram` | The source is not valid PlantUML. |
372
+ | `timeout` | One render exceeded `renderTimeoutMs`. |
373
+ | `config` | The plugin's global data is missing — usually the plugin is not registered. |
374
+ | `aborted` | The component unmounted or re-rendered; not surfaced to the reader. |
375
+
376
+ Note that **invalid PlantUML is not reported through the engine's error callback**. The
377
+ engine renders an "error picture" and reports success, so the rendered SVG is inspected for
378
+ error markers before it is trusted. See
379
+ [ADR 0002](docs/adr/0002-render-to-string.md) for the exact detection rules.
380
+
381
+ ## `baseUrl` support
382
+
383
+ The runtime assets are emitted into
384
+
385
+ ```text
386
+ <baseUrl>assets/plantuml-client-<coreVersion>/
387
+ ```
388
+
389
+ and resolved in the browser through Docusaurus' own `useBaseUrl()`. A site deployed at
390
+ `baseUrl: '/plantuml-test/'` therefore fetches:
391
+
392
+ ```text
393
+ /plantuml-test/assets/plantuml-client-1.2026.6/viz-global.js
394
+ /plantuml-test/assets/plantuml-client-1.2026.6/plantuml.js
395
+ ```
396
+
397
+ No path is hard-coded to the site root, so project-pages deployments and reverse-proxied
398
+ subpaths work without configuration. The engine version is part of the directory name, so an
399
+ upgrade changes the URL and stale caches are defeated automatically. The bundled example site
400
+ deploys under a non-root `baseUrl` precisely so this path is exercised on every build.
401
+
402
+ ## Browser compatibility
403
+
404
+ The runtime requires:
405
+
406
+ - ES modules and dynamic `import()`
407
+ - `AbortController`
408
+ - `MutationObserver`
409
+ - `IntersectionObserver` — optional; a graceful fallback renders immediately when it is absent
410
+ - `sessionStorage` — optional; only for `cache: 'session'`, which degrades to memory without it
411
+
412
+ That is modern evergreen Chrome, Edge, Firefox and Safari. Internet Explorer is not
413
+ supported and will not be.
414
+
415
+ Nothing in this plugin affects readers on pages without diagrams — for them the site behaves
416
+ exactly as it did before installation.
417
+
418
+ ## Bundle-size implications
419
+
420
+ The PlantUML runtime is large:
421
+
422
+ | File | Size (uncompressed) |
423
+ | --------------- | ------------------- |
424
+ | `plantuml.js` | ≈ 6.8 MB |
425
+ | `viz-global.js` | ≈ 1.4 MB |
426
+ | **Total** | **≈ 8.2 MB** |
427
+
428
+ Crucially, **none of that is in your site's initial bundle**. The files are emitted as static
429
+ assets, not imported into the webpack graph (the dynamic import is marked `webpackIgnore`), so:
430
+
431
+ - Pages with no diagram never request them.
432
+ - Pages with diagrams request them once, on first render — and with `lazy: true`, only when a
433
+ diagram nears the viewport.
434
+ - The files are served from your origin with your own cache headers, and are re-fetched only
435
+ when the engine version changes.
436
+
437
+ The published npm package itself is small (the compiled plugin and theme components only);
438
+ the 8 MB comes from the `@plantuml/core` dependency and lands in your build output, not in
439
+ your JavaScript bundle. Budget for roughly 8 MB of extra static assets per deployed site.
440
+
441
+ Serve these assets with compression enabled — they are ordinary JavaScript and compress well.
442
+
443
+ ## Content Security Policy
444
+
445
+ Everything is same-origin. You need no `connect-src` entry for any third party, no CDN
446
+ allowance, and this plugin's own code uses no `eval` or `new Function`.
447
+
448
+ What you do need:
449
+
450
+ ```text
451
+ script-src 'self';
452
+ ```
453
+
454
+ `viz-global.js` is injected as a classic `<script>` element pointing at your own origin, so
455
+ `'self'` must be allowed for scripts. `plantuml.js` is then loaded with a dynamic `import()`
456
+ of a same-origin URL.
457
+
458
+ You may additionally need:
459
+
460
+ ```text
461
+ script-src 'self' 'wasm-unsafe-eval';
462
+ ```
463
+
464
+ The engine is asm.js/TeaVM-compiled JavaScript plus Viz.js. Depending on the engine build and
465
+ how your CSP treats WebAssembly compilation, `'wasm-unsafe-eval'` may be required — treat
466
+ this as "add it if diagrams fail with a CSP error mentioning WebAssembly", not as a
467
+ certainty. If you use a nonce- or hash-based `script-src`, note that the injected
468
+ `<script>` element is created at runtime and will not carry your nonce; a `'self'` source
469
+ expression is the straightforward configuration.
470
+
471
+ No `unsafe-eval` from this plugin's own code is required.
472
+
473
+ ## Development
474
+
475
+ Clone the repository and install with `npm ci`. Node 20 or newer is required (see `.nvmrc`).
476
+
477
+ | Command | What it does |
478
+ | -------------------------- | --------------------------------------------------------------------------------- |
479
+ | `npm run build` | Clean, `tsc` the ESM + theme output, `tsup` the CommonJS entry, finalize |
480
+ | `npm run clean` | Remove build output |
481
+ | `npm run typecheck` | `tsc --noEmit` over the whole project |
482
+ | `npm run lint` | ESLint |
483
+ | `npm run lint:fix` | ESLint with `--fix` |
484
+ | `npm run format` | Prettier write |
485
+ | `npm run format:check` | Prettier check |
486
+ | `npm test` | Unit and component tests (alias for `test:unit`) |
487
+ | `npm run test:unit` | `vitest run` |
488
+ | `npm run test:watch` | `vitest` in watch mode |
489
+ | `npm run test:coverage` | `vitest run --coverage` |
490
+ | `npm run test:integration` | Pack the tarball, install it into a clean fixture, build and serve it |
491
+ | `npm run test:e2e` | Playwright against the served fixture |
492
+ | `npm run test:all` | Format, lint, typecheck, unit, build, pack check, integration — the CI gate |
493
+ | `npm run pack:check` | Inspect `npm pack --json` output for missing or unexpected files and size drift |
494
+ | `npm run verify:tag` | Assert the pushed `v<version>` tag matches `package.json` |
495
+ | `npm run sync:meta` | Propagate `project.config.json` into every file that repeats the project identity |
496
+ | `npm run sync:check` | Fail if any derived file has drifted (run in CI) |
497
+ | `npm run example:install` | Install the example site's dependencies |
498
+ | `npm run example:start` | Docusaurus dev server for the example site |
499
+ | `npm run example:build` | Production build of the example site |
500
+ | `npm run example:serve` | Serve the example site's production build |
501
+
502
+ ### Renaming the package or repository
503
+
504
+ Project identity lives in `project.config.json` — package name, GitHub repository, plugin id,
505
+ licence, description. Change the one line you need and run:
506
+
507
+ ```bash
508
+ npm run sync:meta
509
+ ```
510
+
511
+ This rewrites `package.json`, the example site's dependency and `docusaurus.config.ts`.
512
+ `npm run sync:check` fails the build if anything has drifted.
513
+
514
+ The internal Docusaurus plugin name — `docusaurus-plugin-plantuml-client`, the key under
515
+ which the plugin publishes its global data — is deliberately **independent** of the npm
516
+ package name, so renaming the package cannot break global-data lookups in already-built sites.
517
+
518
+ ## Test strategy
519
+
520
+ Four layers, all deterministic and none of them calling an external PlantUML service:
521
+
522
+ 1. **Unit and component tests** (Vitest + jsdom). Language detection and case handling,
523
+ delegation of non-PlantUML blocks, fence source and title extraction, option defaults and
524
+ validation, singleton asset loading and concurrent load deduplication, loader failure and
525
+ retry, FIFO queue ordering, queue continuation after failure, timeouts, unmount during
526
+ render, light/dark cache separation, memory cache eviction, `sessionStorage` failure and
527
+ corruption handling, SVG sanitization against synthetic malicious input, the loading/ready/
528
+ error UI, SSR without browser globals, and the `IntersectionObserver` fallback.
529
+ Docusaurus `@theme/*` and `@docusaurus/*` aliases only exist inside a real Docusaurus
530
+ webpack build, so the Vitest config points them at local stubs.
531
+ 2. **Package verification** (`npm run pack:check`). Runs `npm pack --dry-run --json`, prints
532
+ the file list with sizes, and fails on missing compiled files or declarations, a missing
533
+ README or LICENSE, published tests, source maps, example build output, nested tarballs, CI
534
+ configuration, `exports` entries pointing at unpacked files, or size budget overruns.
535
+ 3. **Packed-tarball integration** (`npm run test:integration`). Builds the package, runs
536
+ `npm pack`, installs the resulting `.tgz` — not a workspace link — into a clean Docusaurus
537
+ fixture in a temporary directory, runs a full `docusaurus build`, and serves it. This is
538
+ what proves the package works as consumers actually install it.
539
+ 4. **Browser end-to-end** (`npm run test:e2e`, Playwright against the served production
540
+ build). Asserts that real `<svg>` elements appear inside the expected container with the
541
+ expected diagram labels, that multiple diagrams render on one page, that `.md` and `.mdx`
542
+ both work, that ordinary code blocks are untouched, that an invalid diagram produces the
543
+ documented error state, that toggling dark mode re-renders, that client-side navigation
544
+ away and back still works, that the runtime is not loaded on a diagram-free page and is
545
+ loaded at most once when diagrams are present, that every asset URL carries the configured
546
+ `baseUrl`, that no request goes to an external host, and that the build hydrates without
547
+ React mismatch warnings.
548
+
549
+ The example site under `examples/docusaurus/` is the fixture: it deploys under
550
+ `baseUrl: '/plantuml-test/'` and contains a sequence diagram, a Graphviz-dependent class
551
+ diagram, a component diagram, several diagrams on one page, `.md` and `.mdx` documents, a
552
+ `puml` alias, title metadata, an invalid diagram, an ordinary TypeScript block, and a page
553
+ with no diagram at all.
554
+
555
+ ## Release process
556
+
557
+ Releases are triggered by pushing a version tag:
558
+
559
+ ```bash
560
+ npm version 1.2.3 # updates package.json and creates the v1.2.3 tag
561
+ git push --follow-tags
562
+ ```
563
+
564
+ Pushing `v<version>` runs `.github/workflows/publish.yml`, which:
565
+
566
+ 1. Installs cleanly with `npm ci`.
567
+ 2. Runs formatting, lint, type checking, unit tests, the build, the package verification and
568
+ the packed-package Docusaurus integration test. **No publish happens until all of them
569
+ pass.**
570
+ 3. Runs `scripts/verify-tag-version.mjs`, which fails the release if the tag does not exactly
571
+ match the `version` in `package.json` (tag `v1.2.3` ⇒ version `1.2.3`).
572
+ 4. Publishes with `npm publish --access public` — the package is scoped, so `--access public`
573
+ is required for the first and every subsequent publish.
574
+
575
+ Prereleases follow the same path: a version like `1.2.3-beta.1` is published under a
576
+ non-`latest` dist-tag, so `npm install @matfsw/docusaurus-plantuml-plugin` keeps resolving to
577
+ the last stable release.
578
+
579
+ Authentication is npm **trusted publishing** over GitHub Actions OIDC. There is no long-lived
580
+ npm token anywhere in this repository, in its workflows, or in its secrets. The workflow runs
581
+ in a GitHub environment named `npm`, so maintainers can require manual approval before a
582
+ release proceeds. Provenance is attested automatically by trusted publishing.
583
+
584
+ ## npm trusted publisher: one-time setup
585
+
586
+ Do this once, as the npm package owner. It is not needed for day-to-day contribution.
587
+
588
+ 1. **Create the package on npm.** Trusted publishing is configured per package, so the
589
+ package must exist first. Publish the initial version manually (`npm publish
590
+ --access public` from a local checkout with a personal login) or create it through
591
+ whatever bootstrap process you prefer.
592
+ 2. **Open the package settings on npmjs.com** — `https://www.npmjs.com/package/@matfsw/docusaurus-plantuml-plugin`
593
+ → _Settings_.
594
+ 3. **Add a trusted publisher** and choose **GitHub Actions** as the provider.
595
+ 4. **Enter the owner and repository**: owner `matf`, repository `docusaurus-plantuml-plugin`.
596
+ They must match the GitHub repository exactly.
597
+ 5. **Enter the workflow filename**: `publish.yml`. This is the **filename only**, not
598
+ `.github/workflows/publish.yml`. npm matches on the filename, which is why the workflow
599
+ file must never be renamed.
600
+ 6. **Set the environment name**: `npm`. It must match the `environment:` in the workflow.
601
+ 7. **Allow `npm publish`** for this trusted publisher.
602
+ 8. **Confirm `package.json` `repository.url` matches the GitHub repository exactly** —
603
+ `git+https://github.com/matf/docusaurus-plantuml-plugin.git`. Provenance verification
604
+ compares it. `npm run sync:meta` keeps it correct.
605
+ 9. **Enable publishing-access restrictions** once you have verified a trusted-publish
606
+ actually works: in the package settings, require two-factor authentication or trusted
607
+ publishing for publication, so no token can publish behind your back.
608
+ 10. **Remove obsolete automation tokens.** Any granular or classic token that existed only to
609
+ publish this package should be deleted from your npm account.
610
+
611
+ Trusted publishing authenticates **publication**. It does **not** provide credentials for
612
+ _installing_ private dependencies. This repository has no private dependencies, so **do not**
613
+ add a read token to CI. Add one only if the repository genuinely starts consuming a private
614
+ package.
615
+
616
+ ## Limitations
617
+
618
+ - **A theme providing `MDXComponents/Code` must already be installed.** The plugin wraps that
619
+ component via `@theme-init/`; with no underlying implementation there is nothing to wrap.
620
+ `@docusaurus/theme-classic` (via `preset-classic`) satisfies this.
621
+ - **A site-level swizzle of `MDXComponents/Code` wins.** If your site swizzles that component
622
+ itself, the site's version is what Docusaurus uses and this plugin is bypassed.
623
+ - **Two plugins wrapping the same component do not compose.** If another plugin also wraps
624
+ `MDXComponents/Code`, only one wrapper survives. See
625
+ [ADR 0001](docs/adr/0001-theme-init-alias.md).
626
+ - **`docusaurus swizzle` is not supported for this plugin's components.**
627
+ `getTypeScriptThemePath` is intentionally not implemented, to keep the published package
628
+ lean. Fork the component if you need to change it.
629
+ - **Code blocks authored as JSX children are not intercepted.** When a fence's children
630
+ contain React elements rather than plain text, the source cannot be recovered reliably, so
631
+ the block is handed back to the original component untouched.
632
+ - **Diagrams are not rendered during static-site generation.** The HTML Docusaurus emits
633
+ contains the deferred placeholder and the `<noscript>` source. Search engines that do not
634
+ execute JavaScript will not see the diagram image.
635
+ - **One diagram renders at a time.** The engine has module-level shared state, so a page with
636
+ many large diagrams renders them sequentially. This is a correctness requirement, not a
637
+ tuning knob.
638
+ - **`renderTimeoutMs` covers both loading and rendering.** On a very slow connection, the
639
+ 8 MB download is subject to the same budget as a render.
640
+ - **Consuming the plugin through a `file:` link needs a webpack tweak.** See the entry in
641
+ Troubleshooting below.
642
+
643
+ ## Troubleshooting
644
+
645
+ ### Diagrams stay on "Loading PlantUML runtime…" and the assets 404 under a subpath
646
+
647
+ Open the network tab and check the failing URL. It should be
648
+ `<baseUrl>assets/plantuml-client-<version>/viz-global.js`. If the request goes to
649
+ `/assets/...` when your site lives at `/docs/`, the site's `baseUrl` is wrong — not the
650
+ plugin's paths, which are always resolved through `useBaseUrl()`.
651
+
652
+ ### Incorrect `baseUrl`
653
+
654
+ Docusaurus' `baseUrl` must be the deployment subpath with **leading and trailing slashes**:
655
+ `baseUrl: '/plantuml-test/'`, not `'plantuml-test'` or `'/plantuml-test'`. A wrong value
656
+ breaks the PlantUML assets the same way it breaks every other Docusaurus asset. Fix it and
657
+ rebuild.
658
+
659
+ ### A reverse proxy rewrites asset paths
660
+
661
+ If nginx, Apache or a CDN strips or rewrites a path prefix, the browser will request a URL
662
+ your origin does not serve. Make the proxy pass `<baseUrl>assets/**` through unmodified, and
663
+ verify by requesting the exact `viz-global.js` URL from the network tab with `curl`.
664
+
665
+ ### A reverse proxy serves JavaScript with the wrong MIME type
666
+
667
+ `plantuml.js` is loaded with a dynamic `import()`, and browsers refuse to execute an ES module
668
+ served as `text/plain`, `application/octet-stream` or `text/html`. The console shows a strict
669
+ MIME type checking error. Check with:
670
+
671
+ ```bash
672
+ curl -I https://example.com/your-base-url/assets/plantuml-client-1.2026.6/plantuml.js
673
+ ```
674
+
675
+ The `content-type` must be a JavaScript type (`text/javascript` or
676
+ `application/javascript`). If you get `text/html`, the proxy is serving your SPA fallback
677
+ instead of the file — the file is not reaching the origin at all.
678
+
679
+ ### A restrictive Content Security Policy
680
+
681
+ Symptom: a console message naming a violated directive, and diagrams stuck loading. Ensure
682
+ `script-src` allows `'self'`; add `'wasm-unsafe-eval'` if the violation mentions WebAssembly.
683
+ See [Content Security Policy](#content-security-policy). No `connect-src` change for third
684
+ parties is ever needed, because nothing third-party is contacted.
685
+
686
+ ### A corporate proxy breaks `npm install` or GitHub Actions
687
+
688
+ `@plantuml/core` is an ~8 MB dependency and slow or MITM-ing proxies time out on it or serve
689
+ a truncated tarball. Symptoms are an install failure, an integrity mismatch, or a build-time
690
+ `Could not resolve '@plantuml/core'`. Configure `npm config set proxy` / `https-proxy` and
691
+ your corporate CA (`npm config set cafile`), raise `npm config set fetch-timeout`, and delete
692
+ `node_modules` plus the npm cache before retrying. On self-hosted GitHub Actions runners,
693
+ give the runner the same proxy and CA configuration.
694
+
695
+ ### A browser extension blocks worker or WASM execution
696
+
697
+ Script-blocking and privacy extensions sometimes block large inline-compiled JavaScript,
698
+ WebAssembly compilation or worker creation. If diagrams fail for one reader but work in a
699
+ private window with extensions disabled, that is the cause. Ask the reader to allow-list the
700
+ site.
701
+
702
+ ### The build fails with "Progress Plugin ... does not match the API schema"
703
+
704
+ Not this plugin. `@docusaurus/bundler` pinned `webpackbar@^6.0.1` until Docusaurus `3.10.2`,
705
+ and that version of `webpackbar` is rejected by `webpack >= 5.109`. Any Docusaurus
706
+ `3.5`–`3.10.0` site fails this way from a fresh install, with or without this plugin
707
+ installed. Add `"overrides": {"webpackbar": "^7.0.0"}` to your site's `package.json` and
708
+ reinstall, or upgrade Docusaurus to `3.10.2` or later. See
709
+ [Status and compatibility](#docusaurus-3510-need-a-webpackbar-override).
710
+
711
+ ### Duplicate or mismatched Docusaurus / React dependencies
712
+
713
+ Symptom: the page fails with something like _`useColorMode` is called outside the
714
+ `ColorModeProvider`_, or React complains about invalid hook calls. This means two copies of
715
+ React or of `@docusaurus/theme-common` are loaded.
716
+
717
+ This bites specifically when the plugin is consumed through a local `file:` link. webpack
718
+ resolves symlinks to their real path by default, so the plugin's theme components resolve
719
+ `@docusaurus/theme-common` and `react` from the _linked package's_ directory instead of from
720
+ the site — two module instances, two React contexts. The example site works around it with a
721
+ tiny inline plugin:
722
+
723
+ ```ts
724
+ function resolveLinkedPluginFromSite() {
725
+ return {
726
+ name: 'example-resolve-linked-plugin',
727
+ configureWebpack() {
728
+ return {resolve: {symlinks: false}};
729
+ },
730
+ };
731
+ }
732
+ ```
733
+
734
+ A normal `npm install` from the registry has no symlink and needs none of this — which is
735
+ exactly what the packed-tarball integration test proves. If you hit this on a real install
736
+ instead, run `npm ls react @docusaurus/core` and dedupe.
737
+
738
+ ### Invalid PlantUML source
739
+
740
+ The error panel shows PlantUML's own message (with the engine's version-nag lines stripped),
741
+ and the source is available under _Show diagram source_. Common causes: a missing `@enduml`,
742
+ a typo that makes PlantUML guess the wrong diagram type, or a diagram kind this engine build
743
+ does not implement. Paste the source into any PlantUML tool to confirm, and note that an
744
+ unterminated `@startuml` surfaces as a Java exception rather than a syntax error.
745
+
746
+ ### Stale browser or service-worker caches
747
+
748
+ The asset directory carries the engine version, so an engine upgrade cannot be served from a
749
+ stale cache. Your own site's JavaScript can be, though — particularly behind a service worker
750
+ registered by a PWA plugin. Hard-reload, or unregister the service worker under _Application →
751
+ Service Workers_ in DevTools, before concluding that a fix did not land.
752
+
753
+ ### A stale build cache serves an old copy of the plugin
754
+
755
+ After upgrading the plugin, webpack's persistent cache in `node_modules/.cache` (and
756
+ `.docusaurus/`) can keep serving the previous version's theme components — you change the
757
+ plugin and nothing changes in the browser. This is a real failure mode we have hit. Clear it:
758
+
759
+ ```bash
760
+ npx docusaurus clear
761
+ ```
762
+
763
+ Then rebuild. If it persists, remove `node_modules/.cache` and `.docusaurus/` by hand.
764
+
765
+ ## Further reading
766
+
767
+ - [`docs/architecture.md`](docs/architecture.md) — SSR boundary, asset loading, render queue,
768
+ caching, sanitization, dark-mode re-rendering.
769
+ - [`docs/adr/0001-theme-init-alias.md`](docs/adr/0001-theme-init-alias.md) — why the plugin
770
+ wraps `@theme-init/`, not `@theme-original/`.
771
+ - [`docs/adr/0002-render-to-string.md`](docs/adr/0002-render-to-string.md) — why the string
772
+ API is used instead of the DOM `render()` API, and how invalid diagrams are detected.
773
+ - [`CONTRIBUTING.md`](CONTRIBUTING.md)
774
+ - [`SECURITY.md`](SECURITY.md)
775
+
776
+ ## Licence
777
+
778
+ MIT. See [LICENSE](LICENSE).
779
+
780
+ [plantuml-core]: https://www.npmjs.com/package/@plantuml/core
781
+ [dompurify]: https://github.com/cure53/DOMPurify