@crowi/plugin-renderer-plantuml 0.1.0-alpha.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/LICENSE +21 -0
- package/README.md +121 -0
- package/dist/index.d.mts +48 -0
- package/dist/index.d.ts +48 -0
- package/dist/index.js +156 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +137 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +46 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2013 Sotaro KARASAWA <sotaro.k@gmail.com>
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
|
13
|
+
all copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
21
|
+
THE SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
# @crowi/plugin-renderer-plantuml
|
|
2
|
+
|
|
3
|
+
PlantUML diagram renderer for Crowi 2.x. Sends `` ```plantuml ``
|
|
4
|
+
fenced code blocks to an operator-configured PlantUML server and
|
|
5
|
+
inlines the returned SVG (or PNG) into the rendered page.
|
|
6
|
+
|
|
7
|
+
## What it does
|
|
8
|
+
|
|
9
|
+
Given a fence like:
|
|
10
|
+
|
|
11
|
+
````markdown
|
|
12
|
+
```plantuml
|
|
13
|
+
@startuml
|
|
14
|
+
A -> B: hello
|
|
15
|
+
B --> A: reply
|
|
16
|
+
@enduml
|
|
17
|
+
```
|
|
18
|
+
````
|
|
19
|
+
|
|
20
|
+
The plugin:
|
|
21
|
+
|
|
22
|
+
1. Deflate+base64-encodes the diagram source via `plantuml-encoder`.
|
|
23
|
+
2. Fetches `${serverUrl}/${outputFormat}/${encoded}` from the
|
|
24
|
+
configured server.
|
|
25
|
+
3. For SVG, runs a minimal regex sanitizer (strips `<script>`,
|
|
26
|
+
`on*=` attributes, `javascript:` URLs, `<foreignObject>`) and
|
|
27
|
+
wraps the result in `<div class="plantuml-embed">`.
|
|
28
|
+
4. For PNG, base64-encodes the body and emits a `<img>` data URL.
|
|
29
|
+
5. Caches the result in Crowi's `PluginRenderCache` with a 1h fresh
|
|
30
|
+
TTL (4h stale-while-revalidate window).
|
|
31
|
+
|
|
32
|
+
Network or server errors are cached as `RenderError` (`network` /
|
|
33
|
+
`timeout` / `not_found`) with the per-code TTL from
|
|
34
|
+
`packages/api/src/renderer/cache/index.ts:RENDER_ERROR_TTL`, so a
|
|
35
|
+
brief PlantUML outage doesn't hammer the server.
|
|
36
|
+
|
|
37
|
+
## Install
|
|
38
|
+
|
|
39
|
+
Bundled in the Crowi monorepo:
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
# in the Crowi monorepo (dev path):
|
|
43
|
+
pnpm --filter @crowi/api add -D @crowi/plugin-renderer-plantuml
|
|
44
|
+
# or in a standalone runner:
|
|
45
|
+
npm install @crowi/plugin-renderer-plantuml
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## Configure
|
|
49
|
+
|
|
50
|
+
### Enable in `crowi.config.json`
|
|
51
|
+
|
|
52
|
+
```jsonc
|
|
53
|
+
{
|
|
54
|
+
"plugins": [
|
|
55
|
+
"@crowi/plugin-renderer-plantuml"
|
|
56
|
+
]
|
|
57
|
+
}
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
A server restart is required for plugin-list changes.
|
|
61
|
+
|
|
62
|
+
### Per-plugin config (admin UI)
|
|
63
|
+
|
|
64
|
+
Open `/admin/plugins → @crowi/plugin-renderer-plantuml` and set:
|
|
65
|
+
|
|
66
|
+
| Field | Default | Notes |
|
|
67
|
+
|----------------|--------------------------|-------------------------------------------------------------|
|
|
68
|
+
| `serverUrl` | `http://plantuml:8080` | Base URL of your PlantUML server. Matches the docker-compose service hostname Crowi ships. |
|
|
69
|
+
| `outputFormat` | `svg` | `svg` (preferred) or `png` (fallback for installs whose server only serves PNG). |
|
|
70
|
+
|
|
71
|
+
### Migrating from Crowi v1 (`PLANTUML_URI` env)
|
|
72
|
+
|
|
73
|
+
v1 used the `PLANTUML_URI` environment variable to point at the
|
|
74
|
+
PlantUML server. v2 reads the URL from this plugin's `serverUrl`
|
|
75
|
+
config field instead:
|
|
76
|
+
|
|
77
|
+
1. Read your existing `PLANTUML_URI` value.
|
|
78
|
+
2. In `/admin/plugins → @crowi/plugin-renderer-plantuml`, paste the
|
|
79
|
+
URL into `serverUrl`.
|
|
80
|
+
3. Save. The renderer picks up the new value on next boot (or on
|
|
81
|
+
`reconfigure` once Phase 7's hot-reload lands).
|
|
82
|
+
|
|
83
|
+
The env variable is no longer consulted by the plugin.
|
|
84
|
+
|
|
85
|
+
## Cache behaviour
|
|
86
|
+
|
|
87
|
+
- Cache key: `sha256(diagramSource)`. Editing the diagram body
|
|
88
|
+
invalidates the slot naturally; editing `serverUrl` / `outputFormat`
|
|
89
|
+
does NOT invalidate immediately — wait for the 1h TTL to roll over
|
|
90
|
+
or bump `cacheVersion` (developer-side; restart required).
|
|
91
|
+
- Error responses (network / 5xx / 404) are cached for 5 minutes per
|
|
92
|
+
the Phase 4 error-cache table.
|
|
93
|
+
|
|
94
|
+
## SVG sanitization
|
|
95
|
+
|
|
96
|
+
The bundled sanitizer is intentionally minimal (regex-only, no DOM):
|
|
97
|
+
|
|
98
|
+
- Strips `<script>` blocks.
|
|
99
|
+
- Strips `<foreignObject>` content.
|
|
100
|
+
- Strips `on*=` event-handler attributes.
|
|
101
|
+
- Strips `href="javascript:..."` URL schemes.
|
|
102
|
+
|
|
103
|
+
This is defence-in-depth — the PlantUML server is operator-owned, so
|
|
104
|
+
the trust model is "trusted upstream" rather than "user-uploaded
|
|
105
|
+
content". If your threat model demands stricter sanitization,
|
|
106
|
+
deploy a reverse proxy that runs DOMPurify in front of the PlantUML
|
|
107
|
+
server, or wait for the Phase 6.1+ DOMPurify integration.
|
|
108
|
+
|
|
109
|
+
## Out of scope (Phase 6)
|
|
110
|
+
|
|
111
|
+
- Mermaid (` ```mermaid `) — Phase 6.1, separate plugin.
|
|
112
|
+
- PlantUML PNG → SVG auto-fallback when SVG endpoint 404s — Phase 6.1.
|
|
113
|
+
- Per-server-host trust list / CORS / proxying — operator's network
|
|
114
|
+
responsibility.
|
|
115
|
+
|
|
116
|
+
## See also
|
|
117
|
+
|
|
118
|
+
- RFC-0002 §"Phase 6 — bundled renderer plugins" for the design
|
|
119
|
+
rationale + cache contract.
|
|
120
|
+
- [`plantuml-encoder`](https://www.npmjs.com/package/plantuml-encoder)
|
|
121
|
+
— the upstream encoder this plugin wraps.
|
package/dist/index.d.mts
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { z } from 'zod/v3';
|
|
2
|
+
import { CodeBlockRenderer, CrowiPlugin } from '@crowi/plugin-api';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* @crowi/plugin-renderer-plantuml
|
|
6
|
+
*
|
|
7
|
+
* Renders ```plantuml fenced code blocks via an operator-configured
|
|
8
|
+
* PlantUML server. The diagram source is deflate+base64-encoded
|
|
9
|
+
* (`plantuml-encoder`) and fetched from `${serverUrl}/${format}/${encoded}`,
|
|
10
|
+
* then either inlined as SVG (sanitized) or embedded as a base64 PNG.
|
|
11
|
+
*
|
|
12
|
+
* Phase 6 ships this as the first user of `addCodeBlockRenderer`
|
|
13
|
+
* and the cache contract (Phase 4 PluginRenderCache). Failures are
|
|
14
|
+
* cached as `RenderError` for 5 minutes (network / timeout); the
|
|
15
|
+
* placeholder is rendered through `crowi-embed-placeholder-error-*`.
|
|
16
|
+
*
|
|
17
|
+
* Operator install:
|
|
18
|
+
* 1. Run the official PlantUML server (e.g. `docker compose` from
|
|
19
|
+
* Crowi's compose file ships one at `http://plantuml:8080`).
|
|
20
|
+
* 2. List this plugin in `crowi.config.json:plugins`.
|
|
21
|
+
* 3. In the admin UI, fill in `serverUrl` if your server isn't at
|
|
22
|
+
* the default hostname.
|
|
23
|
+
*/
|
|
24
|
+
/**
|
|
25
|
+
* Schema-driven config. The admin UI in `/admin/plugins` builds the
|
|
26
|
+
* form by walking this object. Defaults match the docker-compose
|
|
27
|
+
* service hostname that ships with the Crowi 2.x dev runner.
|
|
28
|
+
*/
|
|
29
|
+
declare const plantumlConfigSchema: z.ZodObject<{
|
|
30
|
+
serverUrl: z.ZodDefault<z.ZodString>;
|
|
31
|
+
outputFormat: z.ZodDefault<z.ZodEnum<["svg", "png"]>>;
|
|
32
|
+
}, "strip", z.ZodTypeAny, {
|
|
33
|
+
serverUrl: string;
|
|
34
|
+
outputFormat: "svg" | "png";
|
|
35
|
+
}, {
|
|
36
|
+
serverUrl?: string | undefined;
|
|
37
|
+
outputFormat?: "svg" | "png" | undefined;
|
|
38
|
+
}>;
|
|
39
|
+
type PlantUmlConfig = z.infer<typeof plantumlConfigSchema>;
|
|
40
|
+
/**
|
|
41
|
+
* Build the CodeBlockRenderer instance, bound to a resolved config.
|
|
42
|
+
* Exported so unit tests can construct a renderer without going through
|
|
43
|
+
* the full plugin registration flow.
|
|
44
|
+
*/
|
|
45
|
+
declare function createPlantUmlRenderer(config: PlantUmlConfig): CodeBlockRenderer;
|
|
46
|
+
declare const plugin: CrowiPlugin;
|
|
47
|
+
|
|
48
|
+
export { type PlantUmlConfig, createPlantUmlRenderer, plugin as default, plantumlConfigSchema };
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { z } from 'zod/v3';
|
|
2
|
+
import { CodeBlockRenderer, CrowiPlugin } from '@crowi/plugin-api';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* @crowi/plugin-renderer-plantuml
|
|
6
|
+
*
|
|
7
|
+
* Renders ```plantuml fenced code blocks via an operator-configured
|
|
8
|
+
* PlantUML server. The diagram source is deflate+base64-encoded
|
|
9
|
+
* (`plantuml-encoder`) and fetched from `${serverUrl}/${format}/${encoded}`,
|
|
10
|
+
* then either inlined as SVG (sanitized) or embedded as a base64 PNG.
|
|
11
|
+
*
|
|
12
|
+
* Phase 6 ships this as the first user of `addCodeBlockRenderer`
|
|
13
|
+
* and the cache contract (Phase 4 PluginRenderCache). Failures are
|
|
14
|
+
* cached as `RenderError` for 5 minutes (network / timeout); the
|
|
15
|
+
* placeholder is rendered through `crowi-embed-placeholder-error-*`.
|
|
16
|
+
*
|
|
17
|
+
* Operator install:
|
|
18
|
+
* 1. Run the official PlantUML server (e.g. `docker compose` from
|
|
19
|
+
* Crowi's compose file ships one at `http://plantuml:8080`).
|
|
20
|
+
* 2. List this plugin in `crowi.config.json:plugins`.
|
|
21
|
+
* 3. In the admin UI, fill in `serverUrl` if your server isn't at
|
|
22
|
+
* the default hostname.
|
|
23
|
+
*/
|
|
24
|
+
/**
|
|
25
|
+
* Schema-driven config. The admin UI in `/admin/plugins` builds the
|
|
26
|
+
* form by walking this object. Defaults match the docker-compose
|
|
27
|
+
* service hostname that ships with the Crowi 2.x dev runner.
|
|
28
|
+
*/
|
|
29
|
+
declare const plantumlConfigSchema: z.ZodObject<{
|
|
30
|
+
serverUrl: z.ZodDefault<z.ZodString>;
|
|
31
|
+
outputFormat: z.ZodDefault<z.ZodEnum<["svg", "png"]>>;
|
|
32
|
+
}, "strip", z.ZodTypeAny, {
|
|
33
|
+
serverUrl: string;
|
|
34
|
+
outputFormat: "svg" | "png";
|
|
35
|
+
}, {
|
|
36
|
+
serverUrl?: string | undefined;
|
|
37
|
+
outputFormat?: "svg" | "png" | undefined;
|
|
38
|
+
}>;
|
|
39
|
+
type PlantUmlConfig = z.infer<typeof plantumlConfigSchema>;
|
|
40
|
+
/**
|
|
41
|
+
* Build the CodeBlockRenderer instance, bound to a resolved config.
|
|
42
|
+
* Exported so unit tests can construct a renderer without going through
|
|
43
|
+
* the full plugin registration flow.
|
|
44
|
+
*/
|
|
45
|
+
declare function createPlantUmlRenderer(config: PlantUmlConfig): CodeBlockRenderer;
|
|
46
|
+
declare const plugin: CrowiPlugin;
|
|
47
|
+
|
|
48
|
+
export { type PlantUmlConfig, createPlantUmlRenderer, plugin as default, plantumlConfigSchema };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
|
+
var __export = (target, all) => {
|
|
7
|
+
for (var name in all)
|
|
8
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
9
|
+
};
|
|
10
|
+
var __copyProps = (to, from, except, desc) => {
|
|
11
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
12
|
+
for (let key of __getOwnPropNames(from))
|
|
13
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
14
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
15
|
+
}
|
|
16
|
+
return to;
|
|
17
|
+
};
|
|
18
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
|
+
|
|
20
|
+
// src/index.ts
|
|
21
|
+
var index_exports = {};
|
|
22
|
+
__export(index_exports, {
|
|
23
|
+
createPlantUmlRenderer: () => createPlantUmlRenderer,
|
|
24
|
+
default: () => index_default,
|
|
25
|
+
plantumlConfigSchema: () => plantumlConfigSchema
|
|
26
|
+
});
|
|
27
|
+
module.exports = __toCommonJS(index_exports);
|
|
28
|
+
var import_node_crypto = require("crypto");
|
|
29
|
+
var import_v3 = require("zod/v3");
|
|
30
|
+
|
|
31
|
+
// src/encoder.ts
|
|
32
|
+
var plantumlEncoder = require("plantuml-encoder");
|
|
33
|
+
function encode(source) {
|
|
34
|
+
return plantumlEncoder.encode(source);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// src/sanitize.ts
|
|
38
|
+
var SCRIPT_TAG_RE = /<script\b[^>]*>[\s\S]*?<\/script\s*>/gi;
|
|
39
|
+
var FOREIGN_OBJECT_RE = /<foreignObject\b[^>]*>[\s\S]*?<\/foreignObject\s*>/gi;
|
|
40
|
+
var ON_EVENT_ATTR_RE = /\son\w+\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/gi;
|
|
41
|
+
var JAVASCRIPT_URL_ATTR_RE = /\s(?:xlink:)?href\s*=\s*(?:"\s*javascript:[^"]*"|'\s*javascript:[^']*'|javascript:[^\s>]+)/gi;
|
|
42
|
+
function sanitizeSvg(input) {
|
|
43
|
+
let out = input;
|
|
44
|
+
out = out.replace(SCRIPT_TAG_RE, "");
|
|
45
|
+
out = out.replace(FOREIGN_OBJECT_RE, "");
|
|
46
|
+
out = out.replace(ON_EVENT_ATTR_RE, "");
|
|
47
|
+
out = out.replace(JAVASCRIPT_URL_ATTR_RE, "");
|
|
48
|
+
return out;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// src/index.ts
|
|
52
|
+
var plantumlConfigSchema = import_v3.z.object({
|
|
53
|
+
serverUrl: import_v3.z.string().url().default("http://plantuml:8080").describe("Base URL of the PlantUML server."),
|
|
54
|
+
outputFormat: import_v3.z.enum(["svg", "png"]).default("svg").describe("Image format the server returns. SVG is preferred (smaller, interactive); PNG is a fallback for installs whose server only serves PNG.")
|
|
55
|
+
});
|
|
56
|
+
var FETCH_TIMEOUT_MS = 1e4;
|
|
57
|
+
var CACHE_TTL_SEC = 60 * 60;
|
|
58
|
+
function createPlantUmlRenderer(config) {
|
|
59
|
+
return {
|
|
60
|
+
cacheVersion: 1,
|
|
61
|
+
reservation: { variant: "aspect", aspectRatio: 16 / 9 },
|
|
62
|
+
computeEmbedKey: (info) => {
|
|
63
|
+
return (0, import_node_crypto.createHash)("sha256").update(info.source).digest("hex");
|
|
64
|
+
},
|
|
65
|
+
async render(info, _ctx) {
|
|
66
|
+
const encoded = encode(info.source);
|
|
67
|
+
const url = `${trimTrailingSlash(config.serverUrl)}/${config.outputFormat}/${encoded}`;
|
|
68
|
+
const controller = new AbortController();
|
|
69
|
+
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
|
70
|
+
let response;
|
|
71
|
+
try {
|
|
72
|
+
response = await fetch(url, { signal: controller.signal });
|
|
73
|
+
} catch (err) {
|
|
74
|
+
clearTimeout(timer);
|
|
75
|
+
const isAbort = err instanceof Error && (err.name === "AbortError" || /abort/i.test(err.message));
|
|
76
|
+
const code = isAbort ? "timeout" : "network";
|
|
77
|
+
return {
|
|
78
|
+
html: "",
|
|
79
|
+
error: { code, message: stringifyError(err) }
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
clearTimeout(timer);
|
|
83
|
+
if (!response.ok) {
|
|
84
|
+
const code = response.status === 404 ? "not_found" : "network";
|
|
85
|
+
return {
|
|
86
|
+
html: "",
|
|
87
|
+
error: { code, message: `PlantUML server responded with HTTP ${response.status}` }
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
if (config.outputFormat === "svg") {
|
|
91
|
+
const svg = await response.text();
|
|
92
|
+
const sanitized = sanitizeSvg(svg);
|
|
93
|
+
return {
|
|
94
|
+
html: `<div class="plantuml-embed">${sanitized}</div>`,
|
|
95
|
+
ttlSec: CACHE_TTL_SEC
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
const buf = Buffer.from(await response.arrayBuffer());
|
|
99
|
+
const b64 = buf.toString("base64");
|
|
100
|
+
return {
|
|
101
|
+
html: `<img class="plantuml-embed" alt="" src="data:image/png;base64,${b64}">`,
|
|
102
|
+
ttlSec: CACHE_TTL_SEC
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
var plugin = {
|
|
108
|
+
name: "@crowi/plugin-renderer-plantuml",
|
|
109
|
+
version: "0.1.0-dev",
|
|
110
|
+
configSchema: plantumlConfigSchema,
|
|
111
|
+
adminPlacement: {
|
|
112
|
+
section: "renderer",
|
|
113
|
+
label: "PlantUML diagrams",
|
|
114
|
+
icon: "diagram-3"
|
|
115
|
+
},
|
|
116
|
+
configI18n: {
|
|
117
|
+
ja: {
|
|
118
|
+
serverUrl: { label: "\u30B5\u30FC\u30D0\u30FC URL", description: "PlantUML \u30B5\u30FC\u30D0\u30FC\u306E\u30D9\u30FC\u30B9 URL\u3002" },
|
|
119
|
+
format: {
|
|
120
|
+
label: "\u753B\u50CF\u5F62\u5F0F",
|
|
121
|
+
description: "\u30B5\u30FC\u30D0\u30FC\u304C\u8FD4\u3059\u753B\u50CF\u5F62\u5F0F\u3002SVG \u63A8\u5968\uFF08\u8EFD\u91CF\u3067\u5BFE\u8A71\u7684\uFF09\u3002PNG \u306F SVG \u3092\u8FD4\u305B\u306A\u3044\u30B5\u30FC\u30D0\u30FC\u5411\u3051\u306E\u30D5\u30A9\u30FC\u30EB\u30D0\u30C3\u30AF\u3067\u3059\u3002"
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
},
|
|
125
|
+
registerRenderer: (registry, ctx) => {
|
|
126
|
+
const config = ctx.config();
|
|
127
|
+
registry.addCodeBlockRenderer("plantuml", createPlantUmlRenderer(config));
|
|
128
|
+
ctx.log.debug(`registered PlantUML code-block renderer (serverUrl=${config.serverUrl}, format=${config.outputFormat})`);
|
|
129
|
+
}
|
|
130
|
+
// When admin saves new config, re-register so the renderer closure
|
|
131
|
+
// picks up the new serverUrl / outputFormat. Phase 4's registry
|
|
132
|
+
// last-wins + boot warn applies here too — re-registering for the
|
|
133
|
+
// same lang produces a warn each time. We accept that noise because
|
|
134
|
+
// operator-driven reconfig is rare and the warn is informational.
|
|
135
|
+
// NOTE: a richer reconfigure surface (replace-in-place without warn)
|
|
136
|
+
// is Phase 7+ work.
|
|
137
|
+
};
|
|
138
|
+
var index_default = plugin;
|
|
139
|
+
function trimTrailingSlash(s) {
|
|
140
|
+
return s.endsWith("/") ? s.slice(0, -1) : s;
|
|
141
|
+
}
|
|
142
|
+
function stringifyError(err) {
|
|
143
|
+
if (err instanceof Error) return err.message;
|
|
144
|
+
if (typeof err === "string") return err;
|
|
145
|
+
try {
|
|
146
|
+
return JSON.stringify(err);
|
|
147
|
+
} catch {
|
|
148
|
+
return String(err);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
152
|
+
0 && (module.exports = {
|
|
153
|
+
createPlantUmlRenderer,
|
|
154
|
+
plantumlConfigSchema
|
|
155
|
+
});
|
|
156
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/index.ts","../src/encoder.ts","../src/sanitize.ts"],"sourcesContent":["import { createHash } from 'node:crypto';\nimport { z } from 'zod/v3';\nimport type { CodeBlockInfo, CodeBlockRenderer, CrowiPlugin, RenderError, RenderResult } from '@crowi/plugin-api';\nimport { encode as encodePlantUml } from './encoder';\nimport { sanitizeSvg } from './sanitize';\n\n/**\n * @crowi/plugin-renderer-plantuml\n *\n * Renders ```plantuml fenced code blocks via an operator-configured\n * PlantUML server. The diagram source is deflate+base64-encoded\n * (`plantuml-encoder`) and fetched from `${serverUrl}/${format}/${encoded}`,\n * then either inlined as SVG (sanitized) or embedded as a base64 PNG.\n *\n * Phase 6 ships this as the first user of `addCodeBlockRenderer`\n * and the cache contract (Phase 4 PluginRenderCache). Failures are\n * cached as `RenderError` for 5 minutes (network / timeout); the\n * placeholder is rendered through `crowi-embed-placeholder-error-*`.\n *\n * Operator install:\n * 1. Run the official PlantUML server (e.g. `docker compose` from\n * Crowi's compose file ships one at `http://plantuml:8080`).\n * 2. List this plugin in `crowi.config.json:plugins`.\n * 3. In the admin UI, fill in `serverUrl` if your server isn't at\n * the default hostname.\n */\n\n/**\n * Schema-driven config. The admin UI in `/admin/plugins` builds the\n * form by walking this object. Defaults match the docker-compose\n * service hostname that ships with the Crowi 2.x dev runner.\n */\nexport const plantumlConfigSchema = z.object({\n serverUrl: z.string().url().default('http://plantuml:8080').describe('Base URL of the PlantUML server.'),\n outputFormat: z\n .enum(['svg', 'png'])\n .default('svg')\n .describe('Image format the server returns. SVG is preferred (smaller, interactive); PNG is a fallback for installs whose server only serves PNG.'),\n});\n\nexport type PlantUmlConfig = z.infer<typeof plantumlConfigSchema>;\n\n/** Render-side timeout for the PlantUML server fetch. */\nconst FETCH_TIMEOUT_MS = 10_000;\n/** Fresh cache TTL — 1 hour. SWR window = 4h via cachedRender default. */\nconst CACHE_TTL_SEC = 60 * 60;\n\n/**\n * Build the CodeBlockRenderer instance, bound to a resolved config.\n * Exported so unit tests can construct a renderer without going through\n * the full plugin registration flow.\n */\nexport function createPlantUmlRenderer(config: PlantUmlConfig): CodeBlockRenderer {\n return {\n cacheVersion: 1,\n reservation: { variant: 'aspect', aspectRatio: 16 / 9 },\n computeEmbedKey: (info: CodeBlockInfo) => {\n // Hash the diagram source only — operator changing serverUrl /\n // outputFormat invalidates implicitly via the 1h TTL rather than\n // explicit invalidation. cacheVersion bump is the operator's\n // escape hatch when they need immediate invalidation.\n return createHash('sha256').update(info.source).digest('hex');\n },\n async render(info, _ctx): Promise<RenderResult> {\n const encoded = encodePlantUml(info.source);\n const url = `${trimTrailingSlash(config.serverUrl)}/${config.outputFormat}/${encoded}`;\n const controller = new AbortController();\n const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);\n let response: Response;\n try {\n response = await fetch(url, { signal: controller.signal });\n } catch (err) {\n clearTimeout(timer);\n // AbortController fired → timeout. Otherwise → network.\n const isAbort = err instanceof Error && (err.name === 'AbortError' || /abort/i.test(err.message));\n const code: RenderError['code'] = isAbort ? 'timeout' : 'network';\n return {\n html: '',\n error: { code, message: stringifyError(err) },\n };\n }\n clearTimeout(timer);\n\n if (!response.ok) {\n const code: RenderError['code'] = response.status === 404 ? 'not_found' : 'network';\n return {\n html: '',\n error: { code, message: `PlantUML server responded with HTTP ${response.status}` },\n };\n }\n\n if (config.outputFormat === 'svg') {\n const svg = await response.text();\n const sanitized = sanitizeSvg(svg);\n return {\n html: `<div class=\"plantuml-embed\">${sanitized}</div>`,\n ttlSec: CACHE_TTL_SEC,\n };\n }\n\n // PNG path — base64 the binary body. The plugin returns a\n // self-contained `<img>` tag with a data: URL. Cache entries\n // get larger for PNGs, but the per-entry size cap from Phase 4\n // (cache/mongodb-cache.ts) caps that automatically.\n const buf = Buffer.from(await response.arrayBuffer());\n const b64 = buf.toString('base64');\n return {\n html: `<img class=\"plantuml-embed\" alt=\"\" src=\"data:image/png;base64,${b64}\">`,\n ttlSec: CACHE_TTL_SEC,\n };\n },\n };\n}\n\nconst plugin: CrowiPlugin = {\n name: '@crowi/plugin-renderer-plantuml',\n version: '0.1.0-dev',\n configSchema: plantumlConfigSchema,\n adminPlacement: {\n section: 'renderer',\n label: 'PlantUML diagrams',\n icon: 'diagram-3',\n },\n configI18n: {\n ja: {\n serverUrl: { label: 'サーバー URL', description: 'PlantUML サーバーのベース URL。' },\n format: {\n label: '画像形式',\n description: 'サーバーが返す画像形式。SVG 推奨(軽量で対話的)。PNG は SVG を返せないサーバー向けのフォールバックです。',\n },\n },\n },\n registerRenderer: (registry, ctx) => {\n // PluginContext.config<T>() parses the configSchema-typed config\n // row and returns it. The plantuml plugin closes over the config\n // here; admin edits trigger `reconfigure(ctx)` (Phase 4 base plugin\n // hook) which we use to refresh the cached renderer.\n const config = ctx.config<PlantUmlConfig>();\n registry.addCodeBlockRenderer('plantuml', createPlantUmlRenderer(config));\n ctx.log.debug(`registered PlantUML code-block renderer (serverUrl=${config.serverUrl}, format=${config.outputFormat})`);\n },\n // When admin saves new config, re-register so the renderer closure\n // picks up the new serverUrl / outputFormat. Phase 4's registry\n // last-wins + boot warn applies here too — re-registering for the\n // same lang produces a warn each time. We accept that noise because\n // operator-driven reconfig is rare and the warn is informational.\n // NOTE: a richer reconfigure surface (replace-in-place without warn)\n // is Phase 7+ work.\n};\n\nexport default plugin;\n\nfunction trimTrailingSlash(s: string): string {\n return s.endsWith('/') ? s.slice(0, -1) : s;\n}\n\nfunction stringifyError(err: unknown): string {\n if (err instanceof Error) return err.message;\n if (typeof err === 'string') return err;\n try {\n return JSON.stringify(err);\n } catch {\n return String(err);\n }\n}\n","/**\n * Typed wrapper around the untyped `plantuml-encoder` npm package\n * (no `@types/plantuml-encoder` exists on DefinitelyTyped). The package\n * exports a single CJS function `{ encode(source: string): string }`\n * that deflate-encodes the diagram source and re-encodes with PlantUML's\n * custom base64-ish alphabet (`{ 0..9, A..Z, a..z, -, _ }`).\n *\n * We re-export `encode` so the rest of the plugin code can rely on a\n * single typed surface instead of casting at every call site.\n */\n// eslint-disable-next-line @typescript-eslint/no-require-imports\nconst plantumlEncoder = require('plantuml-encoder') as { encode(source: string): string };\n\n/**\n * Encode a PlantUML diagram source into the URL-safe token the\n * PlantUML server reads from `/${format}/${encoded}`.\n *\n * Example:\n * encode('@startuml\\nA -> B\\n@enduml')\n * // → 'SoWkIImgAStDuNBAJrBGjLDmpCbCJbMmKiX8pSd9vt98pKi1IW00'\n */\nexport function encode(source: string): string {\n return plantumlEncoder.encode(source);\n}\n","/**\n * Minimal regex-based SVG sanitizer.\n *\n * The PlantUML server is operator-trusted (they ran the docker-compose\n * container). The sanitizer is defence-in-depth for the case where the\n * operator's PlantUML server is compromised or returns user-controlled\n * content. It is NOT a substitute for DOMPurify — Phase 6.1+ may switch\n * to `isomorphic-dompurify` when the JSDOM cost is justified.\n *\n * What we strip (in order):\n * 1. `<script>...</script>` blocks (case-insensitive, multiline,\n * tolerates whitespace + attributes on the open tag).\n * 2. `<foreignObject>...</foreignObject>` (can carry HTML, easy to\n * smuggle script via). PlantUML diagrams never legitimately use\n * foreignObject.\n * 3. `on*=` event-handler attributes from any element (onclick,\n * onload, onerror, …). Strips both single and double-quoted\n * values, plus unquoted bareword values.\n * 4. `javascript:` URL values from `href` / `xlink:href`. The full\n * attribute pair is removed so the resulting element doesn't carry\n * a dangling broken attribute.\n *\n * The implementation is pure-regex; no DOM, no jsdom, suitable for any\n * Node.js process. The expected input is server SVG output (well-\n * formed-ish XML), not arbitrary HTML — so the regex passes are\n * acceptably safe within that constrained shape.\n *\n * If the operator's threat model demands stricter sanitization, they\n * can wrap the output with a reverse proxy that runs DOMPurify, or wait\n * for the Phase 6.1+ DOMPurify integration.\n */\n\n/** Strip `<script ...>...</script>` blocks. */\nconst SCRIPT_TAG_RE = /<script\\b[^>]*>[\\s\\S]*?<\\/script\\s*>/gi;\n/** Strip `<foreignObject ...>...</foreignObject>` blocks. */\nconst FOREIGN_OBJECT_RE = /<foreignObject\\b[^>]*>[\\s\\S]*?<\\/foreignObject\\s*>/gi;\n/**\n * Strip `on<word>=\"...\"` / `on<word>='...'` / `on<word>=<value>` event\n * handler attributes. The leading `\\s` requirement prevents stripping\n * a substring like `son=\"...\"` that happens to contain `on=`.\n */\nconst ON_EVENT_ATTR_RE = /\\son\\w+\\s*=\\s*(?:\"[^\"]*\"|'[^']*'|[^\\s>]+)/gi;\n/**\n * Strip `href=\"javascript:...\"` / `xlink:href=\"javascript:...\"`\n * (case-insensitive, tolerates whitespace + quoting). Drops the entire\n * key-value pair so no dangling `href=` remains.\n */\nconst JAVASCRIPT_URL_ATTR_RE = /\\s(?:xlink:)?href\\s*=\\s*(?:\"\\s*javascript:[^\"]*\"|'\\s*javascript:[^']*'|javascript:[^\\s>]+)/gi;\n\n/**\n * Sanitize an SVG string. Returns a copy with the disallowed\n * constructs removed. Idempotent: running twice produces the same\n * output as running once.\n */\nexport function sanitizeSvg(input: string): string {\n let out = input;\n out = out.replace(SCRIPT_TAG_RE, '');\n out = out.replace(FOREIGN_OBJECT_RE, '');\n out = out.replace(ON_EVENT_ATTR_RE, '');\n out = out.replace(JAVASCRIPT_URL_ATTR_RE, '');\n return out;\n}\n"],"mappings":"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","names":[]}
|
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, {
|
|
2
|
+
get: (a, b) => (typeof require !== "undefined" ? require : a)[b]
|
|
3
|
+
}) : x)(function(x) {
|
|
4
|
+
if (typeof require !== "undefined") return require.apply(this, arguments);
|
|
5
|
+
throw Error('Dynamic require of "' + x + '" is not supported');
|
|
6
|
+
});
|
|
7
|
+
|
|
8
|
+
// src/index.ts
|
|
9
|
+
import { createHash } from "crypto";
|
|
10
|
+
import { z } from "zod/v3";
|
|
11
|
+
|
|
12
|
+
// src/encoder.ts
|
|
13
|
+
var plantumlEncoder = __require("plantuml-encoder");
|
|
14
|
+
function encode(source) {
|
|
15
|
+
return plantumlEncoder.encode(source);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// src/sanitize.ts
|
|
19
|
+
var SCRIPT_TAG_RE = /<script\b[^>]*>[\s\S]*?<\/script\s*>/gi;
|
|
20
|
+
var FOREIGN_OBJECT_RE = /<foreignObject\b[^>]*>[\s\S]*?<\/foreignObject\s*>/gi;
|
|
21
|
+
var ON_EVENT_ATTR_RE = /\son\w+\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/gi;
|
|
22
|
+
var JAVASCRIPT_URL_ATTR_RE = /\s(?:xlink:)?href\s*=\s*(?:"\s*javascript:[^"]*"|'\s*javascript:[^']*'|javascript:[^\s>]+)/gi;
|
|
23
|
+
function sanitizeSvg(input) {
|
|
24
|
+
let out = input;
|
|
25
|
+
out = out.replace(SCRIPT_TAG_RE, "");
|
|
26
|
+
out = out.replace(FOREIGN_OBJECT_RE, "");
|
|
27
|
+
out = out.replace(ON_EVENT_ATTR_RE, "");
|
|
28
|
+
out = out.replace(JAVASCRIPT_URL_ATTR_RE, "");
|
|
29
|
+
return out;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// src/index.ts
|
|
33
|
+
var plantumlConfigSchema = z.object({
|
|
34
|
+
serverUrl: z.string().url().default("http://plantuml:8080").describe("Base URL of the PlantUML server."),
|
|
35
|
+
outputFormat: z.enum(["svg", "png"]).default("svg").describe("Image format the server returns. SVG is preferred (smaller, interactive); PNG is a fallback for installs whose server only serves PNG.")
|
|
36
|
+
});
|
|
37
|
+
var FETCH_TIMEOUT_MS = 1e4;
|
|
38
|
+
var CACHE_TTL_SEC = 60 * 60;
|
|
39
|
+
function createPlantUmlRenderer(config) {
|
|
40
|
+
return {
|
|
41
|
+
cacheVersion: 1,
|
|
42
|
+
reservation: { variant: "aspect", aspectRatio: 16 / 9 },
|
|
43
|
+
computeEmbedKey: (info) => {
|
|
44
|
+
return createHash("sha256").update(info.source).digest("hex");
|
|
45
|
+
},
|
|
46
|
+
async render(info, _ctx) {
|
|
47
|
+
const encoded = encode(info.source);
|
|
48
|
+
const url = `${trimTrailingSlash(config.serverUrl)}/${config.outputFormat}/${encoded}`;
|
|
49
|
+
const controller = new AbortController();
|
|
50
|
+
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
|
51
|
+
let response;
|
|
52
|
+
try {
|
|
53
|
+
response = await fetch(url, { signal: controller.signal });
|
|
54
|
+
} catch (err) {
|
|
55
|
+
clearTimeout(timer);
|
|
56
|
+
const isAbort = err instanceof Error && (err.name === "AbortError" || /abort/i.test(err.message));
|
|
57
|
+
const code = isAbort ? "timeout" : "network";
|
|
58
|
+
return {
|
|
59
|
+
html: "",
|
|
60
|
+
error: { code, message: stringifyError(err) }
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
clearTimeout(timer);
|
|
64
|
+
if (!response.ok) {
|
|
65
|
+
const code = response.status === 404 ? "not_found" : "network";
|
|
66
|
+
return {
|
|
67
|
+
html: "",
|
|
68
|
+
error: { code, message: `PlantUML server responded with HTTP ${response.status}` }
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
if (config.outputFormat === "svg") {
|
|
72
|
+
const svg = await response.text();
|
|
73
|
+
const sanitized = sanitizeSvg(svg);
|
|
74
|
+
return {
|
|
75
|
+
html: `<div class="plantuml-embed">${sanitized}</div>`,
|
|
76
|
+
ttlSec: CACHE_TTL_SEC
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
const buf = Buffer.from(await response.arrayBuffer());
|
|
80
|
+
const b64 = buf.toString("base64");
|
|
81
|
+
return {
|
|
82
|
+
html: `<img class="plantuml-embed" alt="" src="data:image/png;base64,${b64}">`,
|
|
83
|
+
ttlSec: CACHE_TTL_SEC
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
var plugin = {
|
|
89
|
+
name: "@crowi/plugin-renderer-plantuml",
|
|
90
|
+
version: "0.1.0-dev",
|
|
91
|
+
configSchema: plantumlConfigSchema,
|
|
92
|
+
adminPlacement: {
|
|
93
|
+
section: "renderer",
|
|
94
|
+
label: "PlantUML diagrams",
|
|
95
|
+
icon: "diagram-3"
|
|
96
|
+
},
|
|
97
|
+
configI18n: {
|
|
98
|
+
ja: {
|
|
99
|
+
serverUrl: { label: "\u30B5\u30FC\u30D0\u30FC URL", description: "PlantUML \u30B5\u30FC\u30D0\u30FC\u306E\u30D9\u30FC\u30B9 URL\u3002" },
|
|
100
|
+
format: {
|
|
101
|
+
label: "\u753B\u50CF\u5F62\u5F0F",
|
|
102
|
+
description: "\u30B5\u30FC\u30D0\u30FC\u304C\u8FD4\u3059\u753B\u50CF\u5F62\u5F0F\u3002SVG \u63A8\u5968\uFF08\u8EFD\u91CF\u3067\u5BFE\u8A71\u7684\uFF09\u3002PNG \u306F SVG \u3092\u8FD4\u305B\u306A\u3044\u30B5\u30FC\u30D0\u30FC\u5411\u3051\u306E\u30D5\u30A9\u30FC\u30EB\u30D0\u30C3\u30AF\u3067\u3059\u3002"
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
},
|
|
106
|
+
registerRenderer: (registry, ctx) => {
|
|
107
|
+
const config = ctx.config();
|
|
108
|
+
registry.addCodeBlockRenderer("plantuml", createPlantUmlRenderer(config));
|
|
109
|
+
ctx.log.debug(`registered PlantUML code-block renderer (serverUrl=${config.serverUrl}, format=${config.outputFormat})`);
|
|
110
|
+
}
|
|
111
|
+
// When admin saves new config, re-register so the renderer closure
|
|
112
|
+
// picks up the new serverUrl / outputFormat. Phase 4's registry
|
|
113
|
+
// last-wins + boot warn applies here too — re-registering for the
|
|
114
|
+
// same lang produces a warn each time. We accept that noise because
|
|
115
|
+
// operator-driven reconfig is rare and the warn is informational.
|
|
116
|
+
// NOTE: a richer reconfigure surface (replace-in-place without warn)
|
|
117
|
+
// is Phase 7+ work.
|
|
118
|
+
};
|
|
119
|
+
var index_default = plugin;
|
|
120
|
+
function trimTrailingSlash(s) {
|
|
121
|
+
return s.endsWith("/") ? s.slice(0, -1) : s;
|
|
122
|
+
}
|
|
123
|
+
function stringifyError(err) {
|
|
124
|
+
if (err instanceof Error) return err.message;
|
|
125
|
+
if (typeof err === "string") return err;
|
|
126
|
+
try {
|
|
127
|
+
return JSON.stringify(err);
|
|
128
|
+
} catch {
|
|
129
|
+
return String(err);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
export {
|
|
133
|
+
createPlantUmlRenderer,
|
|
134
|
+
index_default as default,
|
|
135
|
+
plantumlConfigSchema
|
|
136
|
+
};
|
|
137
|
+
//# sourceMappingURL=index.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/index.ts","../src/encoder.ts","../src/sanitize.ts"],"sourcesContent":["import { createHash } from 'node:crypto';\nimport { z } from 'zod/v3';\nimport type { CodeBlockInfo, CodeBlockRenderer, CrowiPlugin, RenderError, RenderResult } from '@crowi/plugin-api';\nimport { encode as encodePlantUml } from './encoder';\nimport { sanitizeSvg } from './sanitize';\n\n/**\n * @crowi/plugin-renderer-plantuml\n *\n * Renders ```plantuml fenced code blocks via an operator-configured\n * PlantUML server. The diagram source is deflate+base64-encoded\n * (`plantuml-encoder`) and fetched from `${serverUrl}/${format}/${encoded}`,\n * then either inlined as SVG (sanitized) or embedded as a base64 PNG.\n *\n * Phase 6 ships this as the first user of `addCodeBlockRenderer`\n * and the cache contract (Phase 4 PluginRenderCache). Failures are\n * cached as `RenderError` for 5 minutes (network / timeout); the\n * placeholder is rendered through `crowi-embed-placeholder-error-*`.\n *\n * Operator install:\n * 1. Run the official PlantUML server (e.g. `docker compose` from\n * Crowi's compose file ships one at `http://plantuml:8080`).\n * 2. List this plugin in `crowi.config.json:plugins`.\n * 3. In the admin UI, fill in `serverUrl` if your server isn't at\n * the default hostname.\n */\n\n/**\n * Schema-driven config. The admin UI in `/admin/plugins` builds the\n * form by walking this object. Defaults match the docker-compose\n * service hostname that ships with the Crowi 2.x dev runner.\n */\nexport const plantumlConfigSchema = z.object({\n serverUrl: z.string().url().default('http://plantuml:8080').describe('Base URL of the PlantUML server.'),\n outputFormat: z\n .enum(['svg', 'png'])\n .default('svg')\n .describe('Image format the server returns. SVG is preferred (smaller, interactive); PNG is a fallback for installs whose server only serves PNG.'),\n});\n\nexport type PlantUmlConfig = z.infer<typeof plantumlConfigSchema>;\n\n/** Render-side timeout for the PlantUML server fetch. */\nconst FETCH_TIMEOUT_MS = 10_000;\n/** Fresh cache TTL — 1 hour. SWR window = 4h via cachedRender default. */\nconst CACHE_TTL_SEC = 60 * 60;\n\n/**\n * Build the CodeBlockRenderer instance, bound to a resolved config.\n * Exported so unit tests can construct a renderer without going through\n * the full plugin registration flow.\n */\nexport function createPlantUmlRenderer(config: PlantUmlConfig): CodeBlockRenderer {\n return {\n cacheVersion: 1,\n reservation: { variant: 'aspect', aspectRatio: 16 / 9 },\n computeEmbedKey: (info: CodeBlockInfo) => {\n // Hash the diagram source only — operator changing serverUrl /\n // outputFormat invalidates implicitly via the 1h TTL rather than\n // explicit invalidation. cacheVersion bump is the operator's\n // escape hatch when they need immediate invalidation.\n return createHash('sha256').update(info.source).digest('hex');\n },\n async render(info, _ctx): Promise<RenderResult> {\n const encoded = encodePlantUml(info.source);\n const url = `${trimTrailingSlash(config.serverUrl)}/${config.outputFormat}/${encoded}`;\n const controller = new AbortController();\n const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);\n let response: Response;\n try {\n response = await fetch(url, { signal: controller.signal });\n } catch (err) {\n clearTimeout(timer);\n // AbortController fired → timeout. Otherwise → network.\n const isAbort = err instanceof Error && (err.name === 'AbortError' || /abort/i.test(err.message));\n const code: RenderError['code'] = isAbort ? 'timeout' : 'network';\n return {\n html: '',\n error: { code, message: stringifyError(err) },\n };\n }\n clearTimeout(timer);\n\n if (!response.ok) {\n const code: RenderError['code'] = response.status === 404 ? 'not_found' : 'network';\n return {\n html: '',\n error: { code, message: `PlantUML server responded with HTTP ${response.status}` },\n };\n }\n\n if (config.outputFormat === 'svg') {\n const svg = await response.text();\n const sanitized = sanitizeSvg(svg);\n return {\n html: `<div class=\"plantuml-embed\">${sanitized}</div>`,\n ttlSec: CACHE_TTL_SEC,\n };\n }\n\n // PNG path — base64 the binary body. The plugin returns a\n // self-contained `<img>` tag with a data: URL. Cache entries\n // get larger for PNGs, but the per-entry size cap from Phase 4\n // (cache/mongodb-cache.ts) caps that automatically.\n const buf = Buffer.from(await response.arrayBuffer());\n const b64 = buf.toString('base64');\n return {\n html: `<img class=\"plantuml-embed\" alt=\"\" src=\"data:image/png;base64,${b64}\">`,\n ttlSec: CACHE_TTL_SEC,\n };\n },\n };\n}\n\nconst plugin: CrowiPlugin = {\n name: '@crowi/plugin-renderer-plantuml',\n version: '0.1.0-dev',\n configSchema: plantumlConfigSchema,\n adminPlacement: {\n section: 'renderer',\n label: 'PlantUML diagrams',\n icon: 'diagram-3',\n },\n configI18n: {\n ja: {\n serverUrl: { label: 'サーバー URL', description: 'PlantUML サーバーのベース URL。' },\n format: {\n label: '画像形式',\n description: 'サーバーが返す画像形式。SVG 推奨(軽量で対話的)。PNG は SVG を返せないサーバー向けのフォールバックです。',\n },\n },\n },\n registerRenderer: (registry, ctx) => {\n // PluginContext.config<T>() parses the configSchema-typed config\n // row and returns it. The plantuml plugin closes over the config\n // here; admin edits trigger `reconfigure(ctx)` (Phase 4 base plugin\n // hook) which we use to refresh the cached renderer.\n const config = ctx.config<PlantUmlConfig>();\n registry.addCodeBlockRenderer('plantuml', createPlantUmlRenderer(config));\n ctx.log.debug(`registered PlantUML code-block renderer (serverUrl=${config.serverUrl}, format=${config.outputFormat})`);\n },\n // When admin saves new config, re-register so the renderer closure\n // picks up the new serverUrl / outputFormat. Phase 4's registry\n // last-wins + boot warn applies here too — re-registering for the\n // same lang produces a warn each time. We accept that noise because\n // operator-driven reconfig is rare and the warn is informational.\n // NOTE: a richer reconfigure surface (replace-in-place without warn)\n // is Phase 7+ work.\n};\n\nexport default plugin;\n\nfunction trimTrailingSlash(s: string): string {\n return s.endsWith('/') ? s.slice(0, -1) : s;\n}\n\nfunction stringifyError(err: unknown): string {\n if (err instanceof Error) return err.message;\n if (typeof err === 'string') return err;\n try {\n return JSON.stringify(err);\n } catch {\n return String(err);\n }\n}\n","/**\n * Typed wrapper around the untyped `plantuml-encoder` npm package\n * (no `@types/plantuml-encoder` exists on DefinitelyTyped). The package\n * exports a single CJS function `{ encode(source: string): string }`\n * that deflate-encodes the diagram source and re-encodes with PlantUML's\n * custom base64-ish alphabet (`{ 0..9, A..Z, a..z, -, _ }`).\n *\n * We re-export `encode` so the rest of the plugin code can rely on a\n * single typed surface instead of casting at every call site.\n */\n// eslint-disable-next-line @typescript-eslint/no-require-imports\nconst plantumlEncoder = require('plantuml-encoder') as { encode(source: string): string };\n\n/**\n * Encode a PlantUML diagram source into the URL-safe token the\n * PlantUML server reads from `/${format}/${encoded}`.\n *\n * Example:\n * encode('@startuml\\nA -> B\\n@enduml')\n * // → 'SoWkIImgAStDuNBAJrBGjLDmpCbCJbMmKiX8pSd9vt98pKi1IW00'\n */\nexport function encode(source: string): string {\n return plantumlEncoder.encode(source);\n}\n","/**\n * Minimal regex-based SVG sanitizer.\n *\n * The PlantUML server is operator-trusted (they ran the docker-compose\n * container). The sanitizer is defence-in-depth for the case where the\n * operator's PlantUML server is compromised or returns user-controlled\n * content. It is NOT a substitute for DOMPurify — Phase 6.1+ may switch\n * to `isomorphic-dompurify` when the JSDOM cost is justified.\n *\n * What we strip (in order):\n * 1. `<script>...</script>` blocks (case-insensitive, multiline,\n * tolerates whitespace + attributes on the open tag).\n * 2. `<foreignObject>...</foreignObject>` (can carry HTML, easy to\n * smuggle script via). PlantUML diagrams never legitimately use\n * foreignObject.\n * 3. `on*=` event-handler attributes from any element (onclick,\n * onload, onerror, …). Strips both single and double-quoted\n * values, plus unquoted bareword values.\n * 4. `javascript:` URL values from `href` / `xlink:href`. The full\n * attribute pair is removed so the resulting element doesn't carry\n * a dangling broken attribute.\n *\n * The implementation is pure-regex; no DOM, no jsdom, suitable for any\n * Node.js process. The expected input is server SVG output (well-\n * formed-ish XML), not arbitrary HTML — so the regex passes are\n * acceptably safe within that constrained shape.\n *\n * If the operator's threat model demands stricter sanitization, they\n * can wrap the output with a reverse proxy that runs DOMPurify, or wait\n * for the Phase 6.1+ DOMPurify integration.\n */\n\n/** Strip `<script ...>...</script>` blocks. */\nconst SCRIPT_TAG_RE = /<script\\b[^>]*>[\\s\\S]*?<\\/script\\s*>/gi;\n/** Strip `<foreignObject ...>...</foreignObject>` blocks. */\nconst FOREIGN_OBJECT_RE = /<foreignObject\\b[^>]*>[\\s\\S]*?<\\/foreignObject\\s*>/gi;\n/**\n * Strip `on<word>=\"...\"` / `on<word>='...'` / `on<word>=<value>` event\n * handler attributes. The leading `\\s` requirement prevents stripping\n * a substring like `son=\"...\"` that happens to contain `on=`.\n */\nconst ON_EVENT_ATTR_RE = /\\son\\w+\\s*=\\s*(?:\"[^\"]*\"|'[^']*'|[^\\s>]+)/gi;\n/**\n * Strip `href=\"javascript:...\"` / `xlink:href=\"javascript:...\"`\n * (case-insensitive, tolerates whitespace + quoting). Drops the entire\n * key-value pair so no dangling `href=` remains.\n */\nconst JAVASCRIPT_URL_ATTR_RE = /\\s(?:xlink:)?href\\s*=\\s*(?:\"\\s*javascript:[^\"]*\"|'\\s*javascript:[^']*'|javascript:[^\\s>]+)/gi;\n\n/**\n * Sanitize an SVG string. Returns a copy with the disallowed\n * constructs removed. Idempotent: running twice produces the same\n * output as running once.\n */\nexport function sanitizeSvg(input: string): string {\n let out = input;\n out = out.replace(SCRIPT_TAG_RE, '');\n out = out.replace(FOREIGN_OBJECT_RE, '');\n out = out.replace(ON_EVENT_ATTR_RE, '');\n out = out.replace(JAVASCRIPT_URL_ATTR_RE, '');\n return out;\n}\n"],"mappings":"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","names":[]}
|
package/package.json
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@crowi/plugin-renderer-plantuml",
|
|
3
|
+
"version": "0.1.0-alpha.0",
|
|
4
|
+
"description": "PlantUML diagram renderer for Crowi 2.x. Sends ```plantuml fenced blocks to a PlantUML server and inlines the returned SVG (or PNG). Cache TTL 1h.",
|
|
5
|
+
"main": "dist/index.js",
|
|
6
|
+
"module": "dist/index.mjs",
|
|
7
|
+
"types": "dist/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"import": "./dist/index.mjs",
|
|
12
|
+
"require": "./dist/index.js"
|
|
13
|
+
}
|
|
14
|
+
},
|
|
15
|
+
"files": [
|
|
16
|
+
"dist",
|
|
17
|
+
"README.md"
|
|
18
|
+
],
|
|
19
|
+
"publishConfig": {
|
|
20
|
+
"access": "public"
|
|
21
|
+
},
|
|
22
|
+
"peerDependencies": {
|
|
23
|
+
"zod": "^4.4.3"
|
|
24
|
+
},
|
|
25
|
+
"dependencies": {
|
|
26
|
+
"plantuml-encoder": "^1.4.0",
|
|
27
|
+
"@crowi/plugin-api": "^0.1.0-alpha.0"
|
|
28
|
+
},
|
|
29
|
+
"devDependencies": {
|
|
30
|
+
"@types/jest": "^29.5.14",
|
|
31
|
+
"@types/node": "^24",
|
|
32
|
+
"jest": "^29.7.0",
|
|
33
|
+
"ts-jest": "^29.3.4",
|
|
34
|
+
"tsup": "^8.3.5",
|
|
35
|
+
"typescript": "^5.8.3",
|
|
36
|
+
"zod": "^4.4.3",
|
|
37
|
+
"@crowi/tsconfig": "0.1.0-alpha.0",
|
|
38
|
+
"@crowi/plugin-api": "0.1.0-alpha.0"
|
|
39
|
+
},
|
|
40
|
+
"scripts": {
|
|
41
|
+
"build": "tsup",
|
|
42
|
+
"dev": "tsup --watch --no-clean",
|
|
43
|
+
"type-check": "tsc --noEmit",
|
|
44
|
+
"test": "jest --passWithNoTests"
|
|
45
|
+
}
|
|
46
|
+
}
|