@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 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.
@@ -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 };
@@ -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
+ }