vitepress-plugin-plantuml 0.2.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
+ MIT License
2
+
3
+ Copyright (c) 2026 pengzhanbo
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 all
13
+ 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 THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,141 @@
1
+ # vitepress-plugin-plantuml
2
+
3
+ Render PlantUML diagrams in your VitePress site.
4
+
5
+ 在 VitePress 中渲染 PlantUML 图表。
6
+
7
+ ## Usage
8
+
9
+ ### With Vitepress-tuck
10
+
11
+ **Installation:**
12
+
13
+ ```bash
14
+ # npm
15
+ npm install -D vitepress-tuck vitepress-plugin-plantuml
16
+ # pnpm
17
+ pnpm add -D vitepress-tuck vitepress-plugin-plantuml
18
+ # yarn
19
+ yarn add -D vitepress-tuck vitepress-plugin-plantuml
20
+ ```
21
+
22
+ **Configuration:**
23
+
24
+ ```ts
25
+ // .vitepress/config.ts
26
+ import plantuml from 'vitepress-plugin-plantuml'
27
+ import { defineConfig } from 'vitepress-tuck'
28
+
29
+ export default defineConfig({
30
+ plugins: [plantuml()],
31
+ })
32
+ ```
33
+
34
+ ```ts
35
+ // .vitepress/theme/index.ts
36
+ import type { Theme } from 'vitepress'
37
+ import enhanceApp from 'virtual:enhance-app'
38
+ import DefaultTheme from 'vitepress/theme'
39
+
40
+ export default {
41
+ extends: DefaultTheme,
42
+ enhanceApp(ctx) {
43
+ enhanceApp(ctx)
44
+ },
45
+ } satisfies Theme
46
+ ```
47
+
48
+ ### With Vitepress
49
+
50
+ **Installation:**
51
+
52
+ ```bash
53
+ # npm
54
+ npm install -D vitepress-plugin-plantuml
55
+ # pnpm
56
+ pnpm add -D vitepress-plugin-plantuml
57
+ # yarn
58
+ yarn add -D vitepress-plugin-plantuml
59
+ ```
60
+
61
+ **Configuration:**
62
+
63
+ ```ts
64
+ // .vitepress/config.ts
65
+ import { defineConfig } from 'vitepress'
66
+ import { plantumlMarkdownPlugin, plantumlVitePlugin } from 'vitepress-plugin-plantuml'
67
+
68
+ export default defineConfig({
69
+ markdown: {
70
+ config: (md) => {
71
+ md.use(plantumlMarkdownPlugin)
72
+ },
73
+ },
74
+ vite: {
75
+ plugins: [plantumlVitePlugin()],
76
+ },
77
+ })
78
+ ```
79
+
80
+ ```ts
81
+ // .vitepress/theme/index.ts
82
+ import type { Theme } from 'vitepress'
83
+ import { enhanceAppWithPlantuml } from 'vitepress-plugin-plantuml/client'
84
+ import DefaultTheme from 'vitepress/theme'
85
+
86
+ export default {
87
+ extends: DefaultTheme,
88
+ enhanceApp(ctx) {
89
+ enhanceAppWithPlantuml(ctx)
90
+ },
91
+ } satisfies Theme
92
+ ```
93
+
94
+ ## Syntax
95
+
96
+ Use `plantuml` code blocks to render diagrams.
97
+
98
+ ````md
99
+ ```plantuml
100
+ @startuml
101
+ Alice -> Bob: Authentication Request
102
+ Bob --> Alice: Authentication Response
103
+
104
+ Alice -> Bob: Another authentication Request
105
+ Alice <-- Bob: Another authentication Response
106
+ @enduml
107
+ ```
108
+ ````
109
+
110
+ ### Output Format
111
+
112
+ You can specify the output format (`svg` or `png`) for individual diagrams:
113
+
114
+ ````md
115
+ ```plantuml png
116
+ @startuml
117
+ class Example {
118
+ +attribute: string
119
+ +method(): void
120
+ }
121
+ @enduml
122
+ ```
123
+ ````
124
+
125
+ Or set a global default when configuring the plugin:
126
+
127
+ ```ts
128
+ plantuml('png') // default is 'svg'
129
+ ```
130
+
131
+ Supported formats: `svg`, `png`
132
+
133
+ ## Features
134
+
135
+ - **Dark / Light mode** — Automatically generates both dark and light variants of diagrams, matching the current VitePress theme.
136
+ - **Chart / Source tabs** — Toggle between the rendered diagram and its source code.
137
+ - **Fullscreen mode** — Click the fullscreen button to view the diagram in a full-screen overlay.
138
+ - **Download** — Download the current diagram as an image file.
139
+ - **Multi-language** — Built-in support for English, Chinese, Japanese, Korean, Spanish, French, Russian, German, and Portuguese.
140
+ - **SVG optimization** — SVGs are automatically optimized via SVGO for cleaner output.
141
+ - **Caching** — Rendered diagrams are cached to disk for fast rebuilds.
Binary file
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="400" height="200" viewBox="0 0 400 200"><rect width="100%" height="100%" fill="#f8f9fa" rx="8" ry="8"/><circle cx="200" cy="70" r="24" fill="#ffc107" stroke="#e5a800" stroke-width="2"/><text x="200" y="78" text-anchor="middle" font-size="28" font-family="Arial, sans-serif" fill="#5a3e00" font-weight="bold">!</text><text x="200" y="130" text-anchor="middle" font-size="18" font-family="Arial, Helvetica, sans-serif" fill="#333" font-weight="500">Load Fail</text><text x="200" y="155" text-anchor="middle" font-size="14" font-family="Arial, Helvetica, sans-serif" fill="#666">Please try again</text></svg>
@@ -0,0 +1,24 @@
1
+ import { EnhanceAppContext } from "vitepress";
2
+
3
+ //#region src/client/VPPlantUML.vue.d.ts
4
+ declare var __VLS_6: {}, __VLS_8: {};
5
+ type __VLS_Slots = {} & {
6
+ default?: (props: typeof __VLS_6) => any;
7
+ } & {
8
+ source?: (props: typeof __VLS_8) => any;
9
+ };
10
+ declare const __VLS_base: import("vue").DefineComponent<{}, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<{}> & Readonly<{}>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, true, {}, any>;
11
+ declare const __VLS_export: __VLS_WithSlots<typeof __VLS_base, __VLS_Slots>;
12
+ declare const _default: typeof __VLS_export;
13
+ type __VLS_WithSlots<T, S> = T & {
14
+ new (): {
15
+ $slots: S;
16
+ };
17
+ };
18
+ //#endregion
19
+ //#region src/client/index.d.ts
20
+ declare function enhanceAppWithPlantuml({
21
+ app
22
+ }: EnhanceAppContext): void;
23
+ //#endregion
24
+ export { _default as VPPlantUML, enhanceAppWithPlantuml };
@@ -0,0 +1,94 @@
1
+ import "virtual:tuck-icons.css";
2
+ import "../style.css";
3
+ import { computed, createCommentVNode, createElementBlock, createElementVNode, createTextVNode, createVNode, defineComponent, isRef, nextTick, normalizeClass, normalizeStyle, onMounted, openBlock, ref, renderSlot, toDisplayString, unref, useTemplateRef, vShow, watch, withDirectives } from "vue";
4
+ import { useFullscreen } from "@vueuse/core";
5
+ import { VPTabSwitch, useZoomAndDrag } from "vitepress-plugin-toolkit/client";
6
+ import { useData } from "vitepress/client";
7
+ import { locales } from "virtual:vitepress-plantuml";
8
+ //#region src/client/composables/locales.ts
9
+ function useLocale() {
10
+ const { localeIndex } = useData();
11
+ return computed(() => locales[localeIndex.value] || locales.root || {});
12
+ }
13
+ //#endregion
14
+ //#region src/client/composables/tabs.ts
15
+ function useTabs() {
16
+ const locale = useLocale();
17
+ return {
18
+ tab: ref("chart"),
19
+ tabs: computed(() => [{
20
+ label: locale.value.chart,
21
+ value: "chart"
22
+ }, {
23
+ label: locale.value.source,
24
+ value: "source"
25
+ }])
26
+ };
27
+ }
28
+ //#endregion
29
+ //#region src/client/VPPlantUML.vue
30
+ const _hoisted_1 = { class: "vp-plantuml" };
31
+ const _hoisted_2 = { class: "plantuml-header" };
32
+ const _hoisted_3 = { class: "plantuml-actions" };
33
+ const _hoisted_4 = { class: "plantuml-source" };
34
+ const _sfc_main = /* @__PURE__ */ defineComponent({
35
+ __name: "VPPlantUML",
36
+ setup(__props) {
37
+ const { isDark } = useData();
38
+ const { tab, tabs } = useTabs();
39
+ const locale = useLocale();
40
+ const el = useTemplateRef("el");
41
+ const { actorStyle, reset, zoom, zoomIn, zoomOut, resetZoom } = useZoomAndDrag(el);
42
+ const { isFullscreen, isSupported, enter } = useFullscreen(el);
43
+ watch(isFullscreen, (newVal) => reset(newVal));
44
+ onMounted(() => watch(isDark, () => {
45
+ const img = el.value?.querySelector(isDark.value ? ".dark" : ".light");
46
+ if (!img) return;
47
+ img.onload = () => nextTick(reset);
48
+ }, { immediate: true }));
49
+ function download() {
50
+ const img = el.value?.querySelector(isDark.value ? ".dark" : ".light");
51
+ if (!img) return;
52
+ const url = img.src;
53
+ const a = document.createElement("a");
54
+ a.href = url;
55
+ a.download = "";
56
+ a.click();
57
+ a.remove();
58
+ }
59
+ return (_ctx, _cache) => {
60
+ return openBlock(), createElementBlock("div", _hoisted_1, [
61
+ createElementVNode("div", _hoisted_2, [createVNode(unref(VPTabSwitch), {
62
+ modelValue: unref(tab),
63
+ "onUpdate:modelValue": _cache[0] || (_cache[0] = ($event) => isRef(tab) ? tab.value = $event : null),
64
+ items: unref(tabs)
65
+ }, null, 8, ["modelValue", "items"]), createElementVNode("div", _hoisted_3, [createElementVNode("button", { onClick: download }, [_cache[5] || (_cache[5] = createElementVNode("span", { class: "vpi-download" }, null, -1)), createTextVNode(" " + toDisplayString(unref(locale).download), 1)]), unref(isSupported) ? (openBlock(), createElementBlock("button", {
66
+ key: 0,
67
+ class: "fullscreen",
68
+ onClick: _cache[1] || (_cache[1] = (...args) => unref(enter) && unref(enter)(...args))
69
+ }, [_cache[6] || (_cache[6] = createElementVNode("span", { class: "vpi-fullscreen" }, null, -1)), createTextVNode(" " + toDisplayString(unref(locale).fullscreen), 1)])) : createCommentVNode("v-if", true)])]),
70
+ withDirectives(createElementVNode("div", {
71
+ ref_key: "el",
72
+ ref: el,
73
+ class: "plantuml-view"
74
+ }, [createElementVNode("div", {
75
+ class: "content",
76
+ style: normalizeStyle(unref(actorStyle))
77
+ }, [renderSlot(_ctx.$slots, "default")], 4), createElementVNode("div", { class: normalizeClass(["plantuml-zoom", { fullscreen: unref(isFullscreen) }]) }, [
78
+ createElementVNode("button", { onClick: _cache[2] || (_cache[2] = (...args) => unref(zoomOut) && unref(zoomOut)(...args)) }, [..._cache[7] || (_cache[7] = [createElementVNode("span", { class: "vpi-zoom-out" }, null, -1)])]),
79
+ createElementVNode("span", null, toDisplayString(unref(zoom)), 1),
80
+ createElementVNode("button", { onClick: _cache[3] || (_cache[3] = (...args) => unref(zoomIn) && unref(zoomIn)(...args)) }, [..._cache[8] || (_cache[8] = [createElementVNode("span", { class: "vpi-zoom-in" }, null, -1)])]),
81
+ createElementVNode("button", { onClick: _cache[4] || (_cache[4] = (...args) => unref(resetZoom) && unref(resetZoom)(...args)) }, [..._cache[9] || (_cache[9] = [createElementVNode("span", { class: "vpi-zoom-reset" }, null, -1)])])
82
+ ], 2)], 512), [[vShow, unref(tab) === "chart"]]),
83
+ withDirectives(createElementVNode("div", _hoisted_4, [renderSlot(_ctx.$slots, "source")], 512), [[vShow, unref(tab) === "source"]])
84
+ ]);
85
+ };
86
+ }
87
+ });
88
+ //#endregion
89
+ //#region src/client/index.ts
90
+ function enhanceAppWithPlantuml({ app }) {
91
+ app.component("VPPlantUML", _sfc_main);
92
+ }
93
+ //#endregion
94
+ export { _sfc_main as VPPlantUML, enhanceAppWithPlantuml };
@@ -0,0 +1,24 @@
1
+ import { EnhanceAppContext } from "vitepress";
2
+
3
+ //#region src/client/VPPlantUML.vue.d.ts
4
+ declare var __VLS_6: {}, __VLS_8: {};
5
+ type __VLS_Slots = {} & {
6
+ default?: (props: typeof __VLS_6) => any;
7
+ } & {
8
+ source?: (props: typeof __VLS_8) => any;
9
+ };
10
+ declare const __VLS_base: import("vue").DefineComponent<{}, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<{}> & Readonly<{}>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, true, {}, any>;
11
+ declare const __VLS_export: __VLS_WithSlots<typeof __VLS_base, __VLS_Slots>;
12
+ declare const _default: typeof __VLS_export;
13
+ type __VLS_WithSlots<T, S> = T & {
14
+ new (): {
15
+ $slots: S;
16
+ };
17
+ };
18
+ //#endregion
19
+ //#region src/client/index.d.ts
20
+ declare function enhanceAppWithPlantuml({
21
+ app
22
+ }: EnhanceAppContext): void;
23
+ //#endregion
24
+ export { _default as VPPlantUML, enhanceAppWithPlantuml };
@@ -0,0 +1,75 @@
1
+ import { computed, defineComponent, isRef, mergeProps, nextTick, onMounted, ref, unref, useSSRContext, useTemplateRef, watch } from "vue";
2
+ import { ssrInterpolate, ssrRenderAttrs, ssrRenderClass, ssrRenderComponent, ssrRenderSlot, ssrRenderStyle } from "vue/server-renderer";
3
+ import { useFullscreen } from "@vueuse/core";
4
+ import { VPTabSwitch, useZoomAndDrag } from "vitepress-plugin-toolkit/client";
5
+ import { useData } from "vitepress/client";
6
+ import { locales } from "virtual:vitepress-plantuml";
7
+ //#region src/client/composables/locales.ts
8
+ function useLocale() {
9
+ const { localeIndex } = useData();
10
+ return computed(() => locales[localeIndex.value] || locales.root || {});
11
+ }
12
+ //#endregion
13
+ //#region src/client/composables/tabs.ts
14
+ function useTabs() {
15
+ const locale = useLocale();
16
+ return {
17
+ tab: ref("chart"),
18
+ tabs: computed(() => [{
19
+ label: locale.value.chart,
20
+ value: "chart"
21
+ }, {
22
+ label: locale.value.source,
23
+ value: "source"
24
+ }])
25
+ };
26
+ }
27
+ //#endregion
28
+ //#region src/client/VPPlantUML.vue
29
+ const _sfc_main = /* @__PURE__ */ defineComponent({
30
+ __name: "VPPlantUML",
31
+ __ssrInlineRender: true,
32
+ setup(__props) {
33
+ const { isDark } = useData();
34
+ const { tab, tabs } = useTabs();
35
+ const locale = useLocale();
36
+ const el = useTemplateRef("el");
37
+ const { actorStyle, reset, zoom, zoomIn, zoomOut, resetZoom } = useZoomAndDrag(el);
38
+ const { isFullscreen, isSupported, enter } = useFullscreen(el);
39
+ watch(isFullscreen, (newVal) => reset(newVal));
40
+ onMounted(() => watch(isDark, () => {
41
+ const img = el.value?.querySelector(isDark.value ? ".dark" : ".light");
42
+ if (!img) return;
43
+ img.onload = () => nextTick(reset);
44
+ }, { immediate: true }));
45
+ return (_ctx, _push, _parent, _attrs) => {
46
+ _push(`<div${ssrRenderAttrs(mergeProps({ class: "vp-plantuml" }, _attrs))}><div class="plantuml-header">`);
47
+ _push(ssrRenderComponent(unref(VPTabSwitch), {
48
+ modelValue: unref(tab),
49
+ "onUpdate:modelValue": ($event) => isRef(tab) ? tab.value = $event : null,
50
+ items: unref(tabs)
51
+ }, null, _parent));
52
+ _push(`<div class="plantuml-actions"><button><span class="vpi-download"></span> ${ssrInterpolate(unref(locale).download)}</button>`);
53
+ if (unref(isSupported)) _push(`<button class="fullscreen"><span class="vpi-fullscreen"></span> ${ssrInterpolate(unref(locale).fullscreen)}</button>`);
54
+ else _push(`<!---->`);
55
+ _push(`</div></div><div class="plantuml-view" style="${ssrRenderStyle(unref(tab) === "chart" ? null : { display: "none" })}"><div class="content" style="${ssrRenderStyle(unref(actorStyle))}">`);
56
+ ssrRenderSlot(_ctx.$slots, "default", {}, null, _push, _parent);
57
+ _push(`</div><div class="${ssrRenderClass([{ fullscreen: unref(isFullscreen) }, "plantuml-zoom"])}"><button><span class="vpi-zoom-out"></span></button><span>${ssrInterpolate(unref(zoom))}</span><button><span class="vpi-zoom-in"></span></button><button><span class="vpi-zoom-reset"></span></button></div></div><div class="plantuml-source" style="${ssrRenderStyle(unref(tab) === "source" ? null : { display: "none" })}">`);
58
+ ssrRenderSlot(_ctx.$slots, "source", {}, null, _push, _parent);
59
+ _push(`</div></div>`);
60
+ };
61
+ }
62
+ });
63
+ const _sfc_setup = _sfc_main.setup;
64
+ _sfc_main.setup = (props, ctx) => {
65
+ const ssrContext = useSSRContext();
66
+ (ssrContext.modules || (ssrContext.modules = /* @__PURE__ */ new Set())).add("src/client/VPPlantUML.vue");
67
+ return _sfc_setup ? _sfc_setup(props, ctx) : void 0;
68
+ };
69
+ //#endregion
70
+ //#region src/client/index.ts
71
+ function enhanceAppWithPlantuml({ app }) {
72
+ app.component("VPPlantUML", _sfc_main);
73
+ }
74
+ //#endregion
75
+ export { _sfc_main as VPPlantUML, enhanceAppWithPlantuml };
@@ -0,0 +1,176 @@
1
+ @import url("vitepress-plugin-toolkit/styles/tab-switch.css");
2
+
3
+ .vp-plantuml {
4
+ display: flex;
5
+ flex-direction: column;
6
+ margin-block: 16px;
7
+ margin-inline: -24px;
8
+ overflow: hidden;
9
+ background: var(--vp-code-block-bg);
10
+ }
11
+
12
+ @media (min-width: 640px) {
13
+ .vp-plantuml {
14
+ margin-inline: 0;
15
+ border-radius: 8px;
16
+ }
17
+ }
18
+
19
+ html:not(.dark) .vp-plantuml img.dark,
20
+ html.dark .vp-plantuml img.light {
21
+ display: none;
22
+ }
23
+
24
+ .vp-plantuml .plantuml-header {
25
+ display: flex;
26
+ align-items: flex-start;
27
+ justify-content: space-between;
28
+ padding-block: 4px;
29
+ padding-inline: 8px;
30
+ background: var(--vp-code-block-bg);
31
+ }
32
+
33
+ .vp-plantuml .plantuml-actions {
34
+ display: flex;
35
+ gap: 4px;
36
+ align-items: center;
37
+ justify-content: center;
38
+ opacity: 1;
39
+ transition: opacity 0.25s ease-in-out;
40
+ }
41
+
42
+ @media (min-width: 640px) {
43
+ .vp-plantuml .plantuml-actions {
44
+ gap: 8px;
45
+ opacity: 0;
46
+ }
47
+ }
48
+
49
+ .vp-plantuml:hover .plantuml-actions {
50
+ opacity: 1;
51
+ }
52
+
53
+ .vp-plantuml .plantuml-actions button {
54
+ display: flex;
55
+ align-items: center;
56
+ justify-content: center;
57
+ min-width: 20px;
58
+ height: 20px;
59
+ padding-inline: 2px;
60
+ font-size: 12px;
61
+ background-color: transparent;
62
+ border-radius: 12px;
63
+ transition: background-color 0.25s ease-in-out;
64
+ }
65
+
66
+ .vp-plantuml .plantuml-actions button:hover {
67
+ background-color: var(--vp-c-gray-soft);
68
+ }
69
+
70
+ @media (min-width: 640px) {
71
+ .vp-plantuml .plantuml-actions button {
72
+ gap: 2px;
73
+ min-width: 24px;
74
+ height: 24px;
75
+ padding-inline: 4px;
76
+ font-size: 14px;
77
+ }
78
+ }
79
+
80
+ @media (max-width: 639px) {
81
+ .vp-plantuml .plantuml-actions button.fullscreen {
82
+ display: none;
83
+ }
84
+ }
85
+
86
+ .vp-plantuml .plantuml-view {
87
+ position: relative;
88
+ max-width: 100%;
89
+ min-height: 150px;
90
+ max-height: 50vh;
91
+ padding-block: 12px;
92
+ overflow: hidden;
93
+ text-align: center;
94
+ background: var(--vp-code-block-bg);
95
+ }
96
+
97
+ @media (min-width: 640px) {
98
+ .vp-plantuml .plantuml-view {
99
+ max-height: calc(100vh - 58px - var(--vp-nav-height) - var(--vp-layout-top-height, 0px));
100
+ }
101
+ }
102
+
103
+ .vp-plantuml .plantuml-view .content {
104
+ position: absolute;
105
+ width: max-content;
106
+ overflow: hidden;
107
+ will-change: width, height, top, left;
108
+ }
109
+
110
+ .vp-plantuml .plantuml-view .content.zooming {
111
+ transition: 0.25s ease-in-out;
112
+ transition-property: width, height, top, left;
113
+ }
114
+
115
+ .vp-plantuml .plantuml-view .content img {
116
+ width: 100%;
117
+ height: 100%;
118
+ margin-block: 0;
119
+ }
120
+
121
+ .vp-plantuml .plantuml-zoom {
122
+ position: absolute;
123
+ right: 8px;
124
+ bottom: 8px;
125
+ display: flex;
126
+ gap: 4px;
127
+ align-items: center;
128
+ justify-content: center;
129
+ padding-block: 4px;
130
+ padding-inline: 8px;
131
+ background-color: var(--vp-c-bg);
132
+ border-radius: 4px;
133
+ box-shadow: var(--vp-shadow-3);
134
+ }
135
+
136
+ .vp-plantuml .plantuml-zoom.fullscreen {
137
+ right: 50%;
138
+ bottom: 24px;
139
+ font-size: 18px;
140
+ transform: translateX(50%);
141
+ }
142
+
143
+ .vp-plantuml .plantuml-zoom > :where(button, span) {
144
+ display: flex;
145
+ align-items: center;
146
+ justify-content: center;
147
+ min-width: 20px;
148
+ height: 20px;
149
+ padding-inline: 2px;
150
+ font-size: inherit;
151
+ }
152
+
153
+ .vp-plantuml .plantuml-zoom > span {
154
+ width: 48px;
155
+ text-align: right;
156
+ }
157
+
158
+ .vp-plantuml .plantuml-zoom.fullscreen > span {
159
+ width: 64px;
160
+ }
161
+
162
+ .vp-plantuml .plantuml-source div[class*="language-"] {
163
+ margin: 0;
164
+ }
165
+
166
+
167
+ .vp-plantuml [class*="vpi-"] {
168
+ display: inline-block;
169
+ width: 1em;
170
+ height: 1em;
171
+ }
172
+
173
+ .vp-plantuml .vpi-download {
174
+ width: 1.1em;
175
+ height: 1.1em;
176
+ }
@@ -0,0 +1,35 @@
1
+ import { PluginWithOptions } from "markdown-it";
2
+ import { Plugin } from "vitepress";
3
+
4
+ //#region src/node/plugin.d.ts
5
+ /**
6
+ * @example
7
+ * ```ts
8
+ * import { plantuml } from 'vitepress-plugin-plantuml'
9
+ * import { defineConfig } from 'vitepress-tuck'
10
+ *
11
+ * export default defineConfig({
12
+ * plugins: [plantuml()],
13
+ * })
14
+ * ```
15
+ */
16
+ declare const plantuml: (options?: "svg" | "png" | undefined) => import("vitepress-tuck").VitepressPlugin;
17
+ //#endregion
18
+ //#region src/node/types.d.ts
19
+ type PlantumlFormat = 'svg' | 'png';
20
+ interface PlantumlLocaleData extends Record<string, unknown> {
21
+ chart: string;
22
+ source: string;
23
+ fullscreen: string;
24
+ download: string;
25
+ }
26
+ //#endregion
27
+ //#region src/node/markdown.d.ts
28
+ declare const plantumlMarkdownPlugin: PluginWithOptions<PlantumlFormat>;
29
+ //#endregion
30
+ //#region src/node/vite.d.ts
31
+ declare function plantumlVitePlugin(options?: {
32
+ locales?: Record<string, PlantumlLocaleData>;
33
+ }): Plugin[];
34
+ //#endregion
35
+ export { plantuml as default, plantuml, plantumlMarkdownPlugin, plantumlVitePlugin };
@@ -0,0 +1,336 @@
1
+ import { definePlugin } from "vitepress-tuck";
2
+ import ansis from "ansis";
3
+ import { createLocales, genHash, getVitepressConfig, iconPlugin, isBuild } from "vitepress-plugin-toolkit";
4
+ import path from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ import { Buffer } from "node:buffer";
7
+ import { deflateRawSync } from "node:zlib";
8
+ import { LRUCache, attemptAsync, combineURLs, indent, remove } from "@pengzhanbo/utils";
9
+ import fs, { createReadStream } from "node:fs";
10
+ import { optimize } from "svgo";
11
+ //#region src/node/constants.ts
12
+ const __filename = fileURLToPath(import.meta.url);
13
+ const __dirname = path.join(path.dirname(__filename), "../");
14
+ const OUTPUT_DIR = "plantuml";
15
+ const SERVER_PREFIX = "/vitepress-plantuml/";
16
+ const plantumlUrl = "https://www.plantuml.com/plantuml";
17
+ const fallbackSVG = path.join(__dirname, "../assets", "fallback.svg");
18
+ const fallbackPNG = path.join(__dirname, "../assets", "fallback.png");
19
+ //#endregion
20
+ //#region src/node/utils.ts
21
+ const cache = new LRUCache({ maxSize: 1024 });
22
+ function getFilename(hash, format, isDark) {
23
+ return `${hash}.${isDark ? "dark" : "light"}.${format}`;
24
+ }
25
+ function getOutputPath(dir, filename) {
26
+ return combineURLs(dir, OUTPUT_DIR, filename);
27
+ }
28
+ function parseFilename(filename) {
29
+ const [hash, isDark, format] = filename.split(".");
30
+ return {
31
+ hash,
32
+ format,
33
+ isDark: isDark === "dark"
34
+ };
35
+ }
36
+ function deflate(data) {
37
+ return deflateRawSync(Buffer.from(data, "utf-8"), { level: 9 }).toString("binary");
38
+ }
39
+ /**
40
+ * @param byte - 6-bit byte value
41
+ * @returns Encoded character
42
+ * @see https://plantuml.com/en/text-encoding
43
+ *
44
+ * PlantUML uses a custom Base64 encoding scheme for text data.
45
+ */
46
+ function encode6bit(byte) {
47
+ return byte < 10 ? String.fromCharCode(48 + byte) : byte < 36 ? String.fromCharCode(65 + byte - 10) : byte < 62 ? String.fromCharCode(97 + byte - 36) : byte === 62 ? "-" : "_";
48
+ }
49
+ function append3bytes(b1, b2, b3) {
50
+ const c1 = b1 >> 2;
51
+ const c2 = (b1 & 3) << 4 | b2 >> 4;
52
+ const c3 = (b2 & 15) << 2 | b3 >> 6;
53
+ const c4 = b3 & 63;
54
+ return encode6bit(c1 & 63) + encode6bit(c2 & 63) + encode6bit(c3 & 63) + encode6bit(c4 & 63);
55
+ }
56
+ /**
57
+ * Custom Base64 encoding for PlantUML
58
+ *
59
+ * Mapping: 0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz-_
60
+ *
61
+ * @param data The input string to encode
62
+ * @returns The Base64 encoded string
63
+ */
64
+ function customEncodeBase64(data) {
65
+ let result = "";
66
+ for (let i = 0; i < data.length; i += 3) if (i + 2 === data.length) result += append3bytes(data.charCodeAt(i), data.charCodeAt(i + 1), 0);
67
+ else if (i + 1 === data.length) result += append3bytes(data.charCodeAt(i), 0, 0);
68
+ else result += append3bytes(data.charCodeAt(i), data.charCodeAt(i + 1), data.charCodeAt(i + 2));
69
+ return result;
70
+ }
71
+ function encodePlantuml(data) {
72
+ return customEncodeBase64(deflate(data));
73
+ }
74
+ //#endregion
75
+ //#region src/node/markdown.ts
76
+ const plantumlMarkdownPlugin = (md, format = "svg") => {
77
+ const config = getVitepressConfig();
78
+ const cacheDir = config.cacheDir;
79
+ const name = "plantuml";
80
+ const supportedFormats = ["svg", "png"];
81
+ const rawFence = md.renderer.rules.fence;
82
+ md.renderer.rules.fence = (...args) => {
83
+ const [tokens, idx, _, env] = args;
84
+ const { content, info } = tokens[idx];
85
+ const code = rawFence(...args);
86
+ if (!info.trim().startsWith(name)) return code;
87
+ const resolvedFormat = info.trim().slice(8).trim() || format;
88
+ if (resolvedFormat && !supportedFormats.includes(resolvedFormat)) {
89
+ config.logger.warn(`PlantUML format ${ansis.red(resolvedFormat)} is not supported, please use ${ansis.green(supportedFormats.join(", "))}`);
90
+ return code;
91
+ }
92
+ const hash = genHash(content, 12);
93
+ const light = getFilename(hash, resolvedFormat, false);
94
+ const dark = getFilename(hash, resolvedFormat, true);
95
+ let cached = cache.get(light);
96
+ if (!cached) cache.set(light, cached = {
97
+ content,
98
+ paths: /* @__PURE__ */ new Set()
99
+ });
100
+ cached.paths.add(env.path);
101
+ cached = cache.get(dark);
102
+ if (!cached) cache.set(dark, cached = {
103
+ content,
104
+ paths: /* @__PURE__ */ new Set()
105
+ });
106
+ cached.paths.add(env.path);
107
+ return `<VPPlantUML><img ${isBuild ? `src="${getOutputPath(cacheDir, light)}"` : `:src="\`${SERVER_PREFIX}${light}\`"`} alt="PlantUML light" class="light"><img ${isBuild ? `src="${getOutputPath(cacheDir, dark)}"` : `:src="\`${SERVER_PREFIX}${dark}\`"`} alt="PlantUML dark" class="dark"><template #source>${code}</template></VPPlantUML>`;
108
+ };
109
+ };
110
+ //#endregion
111
+ //#region src/node/locales.ts
112
+ const builtinLocales = [
113
+ [["en", "en-US"], {
114
+ chart: "Chart",
115
+ source: "Code",
116
+ fullscreen: "Fullscreen",
117
+ download: "Download"
118
+ }],
119
+ [["zh", "zh-CN"], {
120
+ chart: "图表",
121
+ source: "代码",
122
+ fullscreen: "全屏",
123
+ download: "下载"
124
+ }],
125
+ [["ja", "ja-JP"], {
126
+ chart: "グラフ",
127
+ source: "コード",
128
+ fullscreen: "全画面",
129
+ download: "ダウンロード"
130
+ }],
131
+ [["ko", "ko-KR"], {
132
+ chart: "차트",
133
+ source: "코드",
134
+ fullscreen: "전체화면",
135
+ download: "다운로드"
136
+ }],
137
+ [["es", "es-ES"], {
138
+ chart: "Gráfico",
139
+ source: "Código",
140
+ fullscreen: "Pantalla completa",
141
+ download: "Descargar"
142
+ }],
143
+ [["fr", "fr-FR"], {
144
+ chart: "Graphique",
145
+ source: "Code",
146
+ fullscreen: "Écran plein",
147
+ download: "Télécharger"
148
+ }],
149
+ [["ru", "ru-RU"], {
150
+ chart: "График",
151
+ source: "Код",
152
+ fullscreen: "Полный экран",
153
+ download: "Скачать"
154
+ }],
155
+ [["de", "de-DE"], {
156
+ chart: "Diagramm",
157
+ source: "Code",
158
+ fullscreen: "Vollbild",
159
+ download: "Herunterladen"
160
+ }],
161
+ [["pt", "pt-BR"], {
162
+ chart: "Gráfico",
163
+ source: "Código",
164
+ fullscreen: "Tela cheia",
165
+ download: "Baixar"
166
+ }]
167
+ ];
168
+ //#endregion
169
+ //#region src/node/vite.ts
170
+ function plantumlVitePlugin(options = {}) {
171
+ const moduleId = "virtual:vitepress-plantuml";
172
+ const resolveId = `\0${moduleId}`;
173
+ return [
174
+ {
175
+ name: "vitepress:plantuml-locales",
176
+ resolveId(id) {
177
+ if (id === moduleId) return resolveId;
178
+ },
179
+ load(id) {
180
+ if (id === resolveId) return `export const locales = ${JSON.stringify(createLocales(builtinLocales, options.locales))}
181
+ `;
182
+ }
183
+ },
184
+ iconPlugin([{
185
+ name: "download",
186
+ svg: `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24'%3E%3Cpath fill='%23000' d='m12 16l-5-5l1.4-1.45l2.6 2.6V4h2v8.15l2.6-2.6L17 11zm-6 4q-.825 0-1.412-.587T4 18v-3h2v3h12v-3h2v3q0 .825-.587 1.413T18 20z'/%3E%3C/svg%3E")`
187
+ }, {
188
+ name: "fullscreen",
189
+ svg: `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24'%3E%3Cpath fill='%23000' d='M3 21v-5h2v3h3v2zm13 0v-2h3v-3h2v5zM3 8V3h5v2H5v3zm16 0V5h-3V3h5v5z'/%3E%3C/svg%3E")`
190
+ }]),
191
+ isBuild ? plantumlVitePluginWithBuild() : plantumlVitePluginWithServer()
192
+ ];
193
+ }
194
+ function plantumlVitePluginWithServer() {
195
+ let config;
196
+ return {
197
+ name: "vitepress:plantuml",
198
+ configResolved(_config) {
199
+ config = _config;
200
+ fs.mkdirSync(path.join(config.cacheDir, OUTPUT_DIR), { recursive: true });
201
+ },
202
+ configureServer(server) {
203
+ server.middlewares.use(async (req, res, next) => {
204
+ const url = req.url;
205
+ if (!url.startsWith("/vitepress-plantuml/")) return next();
206
+ const filename = url.slice(20);
207
+ const { format, isDark } = parseFilename(filename);
208
+ const cached = cache.get(filename);
209
+ if (!cached) return next();
210
+ const type = format === "svg" ? "image/svg+xml" : `image/${format}`;
211
+ const outputPath = getOutputPath(config.cacheDir, filename);
212
+ if (fs.existsSync(outputPath)) {
213
+ res.setHeader("Content-Type", type);
214
+ return createReadStream(outputPath).pipe(res);
215
+ }
216
+ const [, buffer] = await (cached.promise ?? attemptAsync(async () => {
217
+ const buffer = await fetchPlantuml(cached.content, (isDark ? "d" : "") + format);
218
+ buffer && await fs.promises.writeFile(outputPath, buffer);
219
+ return buffer;
220
+ }));
221
+ if (!buffer) {
222
+ config.logger.error(`Failed to render: \n${indent(cached.content, " ".repeat(4))}`);
223
+ cached.promise = null;
224
+ return next();
225
+ }
226
+ res.setHeader("Content-Type", type);
227
+ return createReadStream(outputPath).pipe(res);
228
+ });
229
+ }
230
+ };
231
+ }
232
+ function plantumlVitePluginWithBuild() {
233
+ let config;
234
+ return {
235
+ name: "vitepress:plantuml",
236
+ enforce: "post",
237
+ configResolved(_config) {
238
+ config = _config;
239
+ fs.mkdirSync(path.join(config.cacheDir, OUTPUT_DIR), { recursive: true });
240
+ },
241
+ transform: {
242
+ filter: { id: /\.md($|\?.*)/ },
243
+ async handler(code, id) {
244
+ const pagePath = id.split("?")[0];
245
+ const tasks = [];
246
+ for (const [filename, cached] of cache.entries()) {
247
+ if (cached.loaded && cached.paths.has(pagePath)) continue;
248
+ const outputPath = getOutputPath(config.cacheDir, filename);
249
+ if (fs.existsSync(outputPath)) {
250
+ cached.loaded = true;
251
+ continue;
252
+ }
253
+ const { isDark, format } = parseFilename(filename);
254
+ const promise = cached.promise ?? attemptAsync(async () => {
255
+ const buffer = await fetchPlantuml(cached.content, (isDark ? "d" : "") + format);
256
+ if (!buffer) {
257
+ config.logger.error(`Failed to render: \n${indent(cached.content, " ")}`);
258
+ await fs.promises.copyFile(format === "png" ? fallbackPNG : fallbackSVG, outputPath);
259
+ cached.promise = null;
260
+ return;
261
+ }
262
+ await fs.promises.writeFile(outputPath, buffer);
263
+ cached.loaded = true;
264
+ });
265
+ cached.promise = promise;
266
+ tasks.push(promise);
267
+ }
268
+ await Promise.all(tasks);
269
+ return code;
270
+ }
271
+ }
272
+ };
273
+ }
274
+ const RE_PLANTUML_TAG = /<\?plantuml.*?\?>/g;
275
+ async function fetchPlantuml(source, format) {
276
+ const url = `${plantumlUrl}/${format}/${encodePlantuml(source)}`;
277
+ const res = await fetch(url);
278
+ if (!res.ok) return null;
279
+ if (format === "svg" || format === "dsvg") {
280
+ const data = svgo(await res.text());
281
+ return Buffer.from(data);
282
+ }
283
+ return Buffer.from(await res.arrayBuffer());
284
+ }
285
+ function svgo(svg) {
286
+ return optimize(svg, {
287
+ multipass: true,
288
+ plugins: [{
289
+ name: "removeRootStyle",
290
+ fn: () => {
291
+ let width;
292
+ let height;
293
+ let background;
294
+ return { element: { enter(element, parent) {
295
+ const attrs = element.attributes;
296
+ if (element.name === "svg") {
297
+ width = `${Number.parseInt(attrs.width)}`;
298
+ height = `${Number.parseInt(attrs.height)}`;
299
+ background = attrs.style?.split(";").filter((item) => item.trim().startsWith("background"))[0]?.split(":")[1]?.trim() || "";
300
+ delete attrs.style;
301
+ }
302
+ if (element.name === "rect" && attrs.fill === background && attrs.width === width && attrs.height === height && attrs.x === "0" && attrs.y === "0") remove(parent.children, element);
303
+ } } };
304
+ }
305
+ }, "preset-default"]
306
+ }).data.replaceAll(RE_PLANTUML_TAG, "");
307
+ }
308
+ //#endregion
309
+ //#region src/node/plugin.ts
310
+ /**
311
+ * @example
312
+ * ```ts
313
+ * import { plantuml } from 'vitepress-plugin-plantuml'
314
+ * import { defineConfig } from 'vitepress-tuck'
315
+ *
316
+ * export default defineConfig({
317
+ * plugins: [plantuml()],
318
+ * })
319
+ * ```
320
+ */
321
+ const plantuml = definePlugin((format) => ({
322
+ name: "vitepress-plugin-plantuml",
323
+ client: { enhance: "enhanceAppWithPlantuml" },
324
+ markdown: {
325
+ config(md) {
326
+ md.use(plantumlMarkdownPlugin, format);
327
+ },
328
+ languageAlias: { plantuml: "txt" }
329
+ },
330
+ vite: { plugins: [plantumlVitePlugin()] }
331
+ }));
332
+ //#endregion
333
+ //#region src/node/index.ts
334
+ var node_default = plantuml;
335
+ //#endregion
336
+ export { node_default as default, plantuml, plantumlMarkdownPlugin, plantumlVitePlugin };
package/package.json ADDED
@@ -0,0 +1,56 @@
1
+ {
2
+ "name": "vitepress-plugin-plantuml",
3
+ "type": "module",
4
+ "version": "0.2.0",
5
+ "description": "Render PlantUML diagrams in your VitePress site.",
6
+ "author": "pengzhanbo <q942450674@outlook.com> (https://github.com/pengzhanbo/)",
7
+ "license": "MIT",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/pengzhanbo/vitepress-tuck.git",
11
+ "directory": "packages/plugin-plantuml"
12
+ },
13
+ "keywords": [
14
+ "vitepress",
15
+ "vitepress-plugin",
16
+ "plantuml"
17
+ ],
18
+ "exports": {
19
+ ".": "./dist/node/index.js",
20
+ "./client": {
21
+ "browser": "./dist/client/browser/index.js",
22
+ "default": "./dist/client/ssr/index.js"
23
+ },
24
+ "./style.css": "./dist/client/style.css"
25
+ },
26
+ "module": "./dist/node/index.js",
27
+ "types": "./dist/node/index.d.ts",
28
+ "files": [
29
+ "assets",
30
+ "dist"
31
+ ],
32
+ "peerDependencies": {
33
+ "vitepress": "^1.6.4 || ^2.0.0-alpha.17",
34
+ "vue": "^3.5.0"
35
+ },
36
+ "dependencies": {
37
+ "@pengzhanbo/utils": "^3.7.3",
38
+ "@vueuse/core": "^14.3.0",
39
+ "ansis": "^4.3.1",
40
+ "svgo": "^4.0.1",
41
+ "vitepress-plugin-toolkit": "0.2.0",
42
+ "vitepress-tuck": "0.2.0"
43
+ },
44
+ "publishConfig": {
45
+ "access": "public"
46
+ },
47
+ "scripts": {
48
+ "clean": "rimraf --glob ./dist",
49
+ "dev": "pnpm '/(tsdown|copy):watch/'",
50
+ "build": "pnpm tsdown && pnpm copy",
51
+ "copy": "cpx \"src/**/*.css\" dist",
52
+ "copy:watch": "pnpm copy -w",
53
+ "tsdown": "tsdown --config-loader unrun",
54
+ "tsdown:watch": "pnpm tsdown -w"
55
+ }
56
+ }