@matfsw/docusaurus-plantuml-plugin 1.7.1 → 1.8.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.
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Whether a path belongs to the Docusaurus build that is currently running.
3
+ *
4
+ * In-diagram links are navigated through the router, which is only correct for a path this
5
+ * build actually routes. A site published as **several** builds — one per product area, a
6
+ * docs build beside a marketing build, two builds stitched together by a reverse proxy —
7
+ * shares an origin across bundles that each route only their own subtree. Pushing a sibling
8
+ * build's path into this build's router matches no route, renders this build's own "Page not
9
+ * found", and never reaches the server that could have served it. Reloading that URL works,
10
+ * which is the tell.
11
+ *
12
+ * So the rule is: push what this build routes, and hand everything else to the browser.
13
+ */
14
+ /**
15
+ * One entry of the generated route table, narrowed to what matching needs.
16
+ *
17
+ * `path` widens to an array because react-router allows one route to answer to several
18
+ * paths. Docusaurus' own codegen never emits that, but the generated table is typed as
19
+ * react-router's `RouteConfig`, and quietly ignoring the array form would silently treat
20
+ * such a route as unrouted.
21
+ */
22
+ export interface RouteLike {
23
+ path?: string | string[];
24
+ exact?: boolean;
25
+ routes?: RouteLike[];
26
+ }
27
+ /**
28
+ * Whether `pathname` matches any real route of `routes`.
29
+ *
30
+ * A flat walk rather than `react-router-config`'s `matchRoutes`: it gives the same answer for
31
+ * every shape a Docusaurus table takes — verified against a generated `routes.js`, including
32
+ * nested docs trees, non-exact parents and segment-boundary near-misses like `/docsomething` —
33
+ * and it needs only `matchPath`, which `@docusaurus/router` exports for theme code. Reaching
34
+ * past that export into `react-router-config` would mean depending on a transitive package of
35
+ * `@docusaurus/core` that a strict installer need not hoist.
36
+ *
37
+ * Non-exact parent routes match their whole subtree, so an unknown page *inside* this build's
38
+ * own docs tree still counts as routed. That is deliberate: this build owns that prefix, and
39
+ * its own `NotFound` is the right answer there. Only a path no route claims at all is handed
40
+ * back to the browser.
41
+ */
42
+ export declare function isRoutedPathname(routes: readonly RouteLike[], pathname: string): boolean;
@@ -0,0 +1,38 @@
1
+ import { matchPath } from '@docusaurus/router';
2
+ /**
3
+ * The catch-all every generated route table ends with.
4
+ *
5
+ * `codegenRoutes` appends `{path: '*', component: ComponentCreator('*')}` to every build,
6
+ * and that route is what renders `NotFound`. It therefore matches *every* path, which would
7
+ * make the check below vacuous — excluding it is the whole point.
8
+ */
9
+ const CATCH_ALL_PATH = '*';
10
+ /** Depth-first flattening; nested routes are how Docusaurus expresses a docs plugin's tree. */
11
+ function flattenRoutes(routes) {
12
+ return routes.flatMap((route) => [route, ...flattenRoutes(route.routes ?? [])]);
13
+ }
14
+ /**
15
+ * Whether `pathname` matches any real route of `routes`.
16
+ *
17
+ * A flat walk rather than `react-router-config`'s `matchRoutes`: it gives the same answer for
18
+ * every shape a Docusaurus table takes — verified against a generated `routes.js`, including
19
+ * nested docs trees, non-exact parents and segment-boundary near-misses like `/docsomething` —
20
+ * and it needs only `matchPath`, which `@docusaurus/router` exports for theme code. Reaching
21
+ * past that export into `react-router-config` would mean depending on a transitive package of
22
+ * `@docusaurus/core` that a strict installer need not hoist.
23
+ *
24
+ * Non-exact parent routes match their whole subtree, so an unknown page *inside* this build's
25
+ * own docs tree still counts as routed. That is deliberate: this build owns that prefix, and
26
+ * its own `NotFound` is the right answer there. Only a path no route claims at all is handed
27
+ * back to the browser.
28
+ */
29
+ export function isRoutedPathname(routes, pathname) {
30
+ return flattenRoutes(routes).some((route) => {
31
+ if (route.path === undefined)
32
+ return false;
33
+ const paths = (Array.isArray(route.path) ? route.path : [route.path]).filter((path) => path !== CATCH_ALL_PATH);
34
+ if (paths.length === 0)
35
+ return false;
36
+ return matchPath(pathname, { path: paths, exact: route.exact === true }) !== null;
37
+ });
38
+ }
@@ -8,13 +8,16 @@ import type { DiagramEngine } from '../../runtime/types.js';
8
8
  * `diagramLinks.ts` for the correlation. Graphviz needs none of this; `URL=` already emits
9
9
  * real anchors.
10
10
  *
11
- * **Navigation** (both engines): a click on any in-diagram link to a same-site URL goes
12
- * through the router instead of a full page load, so a node can link to a diagram on
11
+ * **Navigation** (both engines): a click on any in-diagram link to a URL *this build routes*
12
+ * goes through the router instead of a full page load, so a node can link to a diagram on
13
13
  * another page and arrive instantly — with the target page's diagrams reacting to the
14
14
  * hash through the same router-driven deep-link tracking. Site-absolute paths get the
15
15
  * site's `baseUrl`, exactly as markdown links do, so authors write the same `/docs/…`
16
16
  * everywhere. External links and pure `#…` anchors stay native: the browser handles both
17
17
  * correctly on its own, and the router observes hash navigations anyway.
18
+ *
19
+ * A same-origin path that this build does *not* route stays native too. See
20
+ * `routeMatching.ts` for why that matters on a site published as several builds.
18
21
  */
19
22
  export interface UseDiagramLinksParams {
20
23
  /** Whether the diagram is rendered and its SVG is in the DOM. */
@@ -1,7 +1,9 @@
1
1
  import { useEffect } from 'react';
2
+ import generatedRoutes from '@generated/routes';
2
3
  import { useHistory } from '@docusaurus/router';
3
4
  import { useBaseUrlUtils } from '@docusaurus/useBaseUrl';
4
5
  import { attachDiagramLinks, detachDiagramLinks, extractSourceLinks } from './diagramLinks.js';
6
+ import { isRoutedPathname } from './routeMatching.js';
5
7
  export function useDiagramLinks({ ready, svg, engine, source, containerRef, }) {
6
8
  const history = useHistory();
7
9
  const { withBaseUrl } = useBaseUrlUtils();
@@ -53,6 +55,10 @@ export function useDiagramLinks({ ready, svg, engine, source, containerRef, }) {
53
55
  }
54
56
  if (url.origin !== window.location.origin)
55
57
  return;
58
+ // Same origin is not the same bundle. A path this build has no route for must reach
59
+ // the server, which can serve it, rather than this build's own "Page not found".
60
+ if (!isRoutedPathname(generatedRoutes, url.pathname))
61
+ return;
56
62
  event.preventDefault();
57
63
  history.push(url.pathname + url.search + url.hash);
58
64
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@matfsw/docusaurus-plantuml-plugin",
3
- "version": "1.7.1",
3
+ "version": "1.8.0",
4
4
  "description": "Render PlantUML diagrams in Docusaurus 3 entirely in the browser - no Java, no PlantUML server, no CDN.",
5
5
  "keywords": [
6
6
  "docusaurus",
@@ -71,7 +71,7 @@
71
71
  "prepublishOnly": "node scripts/assert-built.mjs"
72
72
  },
73
73
  "dependencies": {
74
- "@plantuml/core": "^1.2026.6",
74
+ "@plantuml/core": "^1.2026.8",
75
75
  "copy-webpack-plugin": "^14.0.0",
76
76
  "dompurify": "^3.2.7"
77
77
  },
@@ -96,7 +96,7 @@
96
96
  "@types/node": "^26.2.0",
97
97
  "@types/react": "^19.2.18",
98
98
  "@types/react-dom": "^19.2.4",
99
- "@vitest/coverage-v8": "^4.1.10",
99
+ "@vitest/coverage-v8": "^5.0.0",
100
100
  "eslint": "^9.39.5",
101
101
  "eslint-config-prettier": "^10.1.8",
102
102
  "eslint-plugin-react": "^7.37.5",
@@ -109,7 +109,7 @@
109
109
  "tsup": "^8.5.0",
110
110
  "typescript": "~5.9.3",
111
111
  "typescript-eslint": "^8.67.0",
112
- "vitest": "^4.1.10"
112
+ "vitest": "^5.0.0"
113
113
  },
114
114
  "overrides": {
115
115
  "serialize-javascript": "^7.1.1",
@@ -1,41 +0,0 @@
1
- /**
2
- * How many bytes the patch adds. Every replacement is ASCII, so the byte delta is the
3
- * character delta, and it is fixed for a given {@link MAX_DIAGRAM_SIZE}. This is what makes
4
- * an already-generated file cheap to validate: its size is an exact fingerprint.
5
- */
6
- export declare const PATCH_SIZE_DELTA: number;
7
- /**
8
- * Rewrites the engine source so it accepts diagrams up to {@link MAX_DIAGRAM_SIZE} points.
9
- *
10
- * Both literals are counted *before* anything is replaced, so an engine that changed shape
11
- * fails the build with a name attached rather than being silently half-patched. Patching the
12
- * message as well as the comparison is not cosmetic — an engine that still reported
13
- * `(max 4096)` while refusing at 32768 would send every future reader of that error down the
14
- * wrong path. It is also safe: TeaVM's string pool is a plain list of JavaScript string
15
- * literals, so a literal that changes length does not disturb its neighbours.
16
- */
17
- export declare function patchEngineSource(source: string, coreVersion: string): string;
18
- export interface ResolvePatchedEngineOptions {
19
- /** Absolute path of the vendored `plantuml.js` inside the installed `@plantuml/core`. */
20
- vendoredPath: string;
21
- /** Installed `@plantuml/core` version, which names the cache directory. */
22
- coreVersion: string;
23
- /** Site directory, used when `cacheDir` is absent. */
24
- siteDir: string;
25
- /**
26
- * Where the patched engine is cached. Docusaurus passes its `generatedFilesDir`; when it
27
- * is absent the site's `.docusaurus` directory is assumed, as in `src/stdlib.ts`.
28
- */
29
- cacheDir?: string;
30
- }
31
- /**
32
- * Returns the path of the `plantuml.js` this build should serve, generating it if needed.
33
- *
34
- * The basename stays `plantuml.js`: the copy pattern emits `[name][ext]`, and the browser
35
- * loader joins {@link PLANTUML_MODULE_FILENAME} onto the assets directory.
36
- *
37
- * Generation is skipped when the cached file is already the right size. That check is exact
38
- * rather than approximate — {@link PATCH_SIZE_DELTA} is fixed — and it is O(1) where reading
39
- * 7 MB back to inspect its contents would not be.
40
- */
41
- export declare function resolvePatchedEngine(options: ResolvePatchedEngineOptions): string;
@@ -1,120 +0,0 @@
1
- import fs from 'node:fs';
2
- import path from 'node:path';
3
- import { MAX_DIAGRAM_SIZE, PLANTUML_MODULE_FILENAME, PLUGIN_NAME, UPSTREAM_MAX_DIAGRAM_SIZE, } from './constants.js';
4
- /**
5
- * Raises the diagram size ceiling compiled into `@plantuml/core`.
6
- *
7
- * The engine measures the laid-out diagram and refuses to serialize anything wider or taller
8
- * than 4096 points, reporting `Diagram too large for browser rendering: 78x12916 (max 4096)`
9
- * through its error callback. `renderToString` takes only `{dark}`, so there is no option to
10
- * turn this off, and no way to work around it from diagram source: the check runs *before* a
11
- * `scale` directive is applied, `skinparam dpi` does not move it, and `left to right
12
- * direction` only transposes the two numbers. Shrinking the Graphviz layout by hand —
13
- * `skinparam ranksep`, fewer nodes, smaller fonts — is the only lever an author has.
14
- *
15
- * The ceiling is arbitrary. It is not an SVG or a browser constraint; patched to 65536 the
16
- * same engine produced correct SVG for a 79x43157 diagram. So this plugin patches it, and
17
- * serves the patched file rather than the vendored one.
18
- *
19
- * See `docs/adr/0007-engine-size-ceiling-patch.md` for the evidence and the rejected
20
- * alternatives.
21
- */
22
- /**
23
- * The comparison the engine guards on, as it appears in the minified bundle:
24
- *
25
- * ```js
26
- * p=o.bBY;if(!(p>4096.0)){q=o.bB0;if(!(q>4096.0)){ …serialize… }}
27
- * ```
28
- *
29
- * Deliberately anchored on `>4096.0)` rather than on the surrounding code: `p`, `q` and `o`
30
- * are TeaVM-mangled locals that are renamed by every `@plantuml/core` build, so a pattern
31
- * that named them would break on a release that changed nothing meaningful.
32
- */
33
- const COMPARISON = `>${UPSTREAM_MAX_DIAGRAM_SIZE}.0)`;
34
- /**
35
- * `4096.0` occurs exactly twice in the whole 7 MB bundle, and both occurrences are the width
36
- * and height halves of this one guard. That makes the count the safety property: two matches
37
- * means the engine still has the shape this patch was written against, and any other number
38
- * means it does not.
39
- */
40
- const COMPARISON_OCCURRENCES = 2;
41
- /** The suffix of the engine's own error message, in TeaVM's string pool. */
42
- const MESSAGE_SUFFIX = ` (max ${UPSTREAM_MAX_DIAGRAM_SIZE})`;
43
- const MESSAGE_SUFFIX_OCCURRENCES = 1;
44
- const PATCHED_COMPARISON = `>${MAX_DIAGRAM_SIZE}.0)`;
45
- const PATCHED_MESSAGE_SUFFIX = ` (max ${MAX_DIAGRAM_SIZE})`;
46
- /**
47
- * How many bytes the patch adds. Every replacement is ASCII, so the byte delta is the
48
- * character delta, and it is fixed for a given {@link MAX_DIAGRAM_SIZE}. This is what makes
49
- * an already-generated file cheap to validate: its size is an exact fingerprint.
50
- */
51
- export const PATCH_SIZE_DELTA = COMPARISON_OCCURRENCES * (PATCHED_COMPARISON.length - COMPARISON.length) +
52
- MESSAGE_SUFFIX_OCCURRENCES * (PATCHED_MESSAGE_SUFFIX.length - MESSAGE_SUFFIX.length);
53
- /** Directory under the Docusaurus cache that generated engine builds live in. */
54
- const CACHE_SUBDIRECTORY = 'plantuml-engine';
55
- function countOccurrences(haystack, needle) {
56
- let count = 0;
57
- let index = haystack.indexOf(needle);
58
- while (index !== -1) {
59
- count += 1;
60
- index = haystack.indexOf(needle, index + needle.length);
61
- }
62
- return count;
63
- }
64
- function assertOccurrences(source, needle, expected, coreVersion) {
65
- const found = countOccurrences(source, needle);
66
- if (found === expected)
67
- return;
68
- throw new Error(`[${PLUGIN_NAME}] Cannot raise the diagram size ceiling of ` +
69
- `'@plantuml/core@${coreVersion}': expected ${expected} occurrence(s) of ` +
70
- `${JSON.stringify(needle)} in '${PLANTUML_MODULE_FILENAME}', found ${found}. ` +
71
- 'The engine no longer has the shape this plugin patches, which means the installed ' +
72
- '@plantuml/core is not one this plugin supports. Pin @plantuml/core to the last ' +
73
- 'version that worked, or upgrade this plugin.');
74
- }
75
- /**
76
- * Rewrites the engine source so it accepts diagrams up to {@link MAX_DIAGRAM_SIZE} points.
77
- *
78
- * Both literals are counted *before* anything is replaced, so an engine that changed shape
79
- * fails the build with a name attached rather than being silently half-patched. Patching the
80
- * message as well as the comparison is not cosmetic — an engine that still reported
81
- * `(max 4096)` while refusing at 32768 would send every future reader of that error down the
82
- * wrong path. It is also safe: TeaVM's string pool is a plain list of JavaScript string
83
- * literals, so a literal that changes length does not disturb its neighbours.
84
- */
85
- export function patchEngineSource(source, coreVersion) {
86
- assertOccurrences(source, COMPARISON, COMPARISON_OCCURRENCES, coreVersion);
87
- assertOccurrences(source, MESSAGE_SUFFIX, MESSAGE_SUFFIX_OCCURRENCES, coreVersion);
88
- return source
89
- .split(COMPARISON)
90
- .join(PATCHED_COMPARISON)
91
- .split(MESSAGE_SUFFIX)
92
- .join(PATCHED_MESSAGE_SUFFIX);
93
- }
94
- /**
95
- * Returns the path of the `plantuml.js` this build should serve, generating it if needed.
96
- *
97
- * The basename stays `plantuml.js`: the copy pattern emits `[name][ext]`, and the browser
98
- * loader joins {@link PLANTUML_MODULE_FILENAME} onto the assets directory.
99
- *
100
- * Generation is skipped when the cached file is already the right size. That check is exact
101
- * rather than approximate — {@link PATCH_SIZE_DELTA} is fixed — and it is O(1) where reading
102
- * 7 MB back to inspect its contents would not be.
103
- */
104
- export function resolvePatchedEngine(options) {
105
- const { vendoredPath, coreVersion, siteDir, cacheDir } = options;
106
- const generatedDir = cacheDir ?? path.join(siteDir, '.docusaurus');
107
- const target = path.join(generatedDir, CACHE_SUBDIRECTORY, coreVersion, PLANTUML_MODULE_FILENAME);
108
- const expectedSize = fs.statSync(vendoredPath).size + PATCH_SIZE_DELTA;
109
- if (fs.existsSync(target) && fs.statSync(target).size === expectedSize)
110
- return target;
111
- const patched = patchEngineSource(fs.readFileSync(vendoredPath, 'utf8'), coreVersion);
112
- fs.mkdirSync(path.dirname(target), { recursive: true });
113
- // `.docusaurus` is shared by every locale, and Docusaurus builds locales in separate
114
- // processes. Writing 7 MB in place would let a concurrent build read a torn file — and a
115
- // torn file of the right length would pass the size check above. Rename is atomic.
116
- const temporary = `${target}.${process.pid}.tmp`;
117
- fs.writeFileSync(temporary, patched, 'utf8');
118
- fs.renameSync(temporary, target);
119
- return target;
120
- }