@izumisy/vitepress-plugin-react-preview 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 IzumiSy
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,76 @@
1
+ # @izumisy/vitepress-plugin-react-preview
2
+
3
+ VitePress plugin for rendering live React component previews inside your VitePress documentation site.
4
+
5
+ Transforms `` ```tsx preview `` fenced blocks in Markdown into interactive previews rendered via iframes, with full style isolation and dark mode support.
6
+
7
+ ## Features
8
+
9
+ - **markdown-it plugin** — rewrites `` ```tsx preview `` blocks into `<PreviewBlock>` Vue components at the Markdown parsing stage
10
+ - **Vite plugin** — serves preview modules and standalone preview pages using `@izumisy/vite-plugin-react-preview` under the hood
11
+ - **Style isolation** — inline previews render inside iframes; standalone previews run in a separate page
12
+ - **Dark mode** — syncs with VitePress theme toggle via `postMessage`
13
+ - **Build support** — emits standalone HTML pages (`/__preview/{blockId}.html`) during `vitepress build`
14
+
15
+ ## Install
16
+
17
+ ```bash
18
+ pnpm add -D @izumisy/vitepress-plugin-react-preview
19
+ ```
20
+
21
+ ## Setup
22
+
23
+ In your VitePress config (`.vitepress/config.ts`):
24
+
25
+ ```ts
26
+ import { defineConfig } from "vitepress";
27
+ import { createMrpPlugin } from "@izumisy/vitepress-plugin-react-preview";
28
+
29
+ const mrp = createMrpPlugin({
30
+ css: "@my-lib/styles", // optional: CSS to inject into previews
31
+ });
32
+
33
+ export default defineConfig({
34
+ markdown: {
35
+ config(md) {
36
+ md.use(mrp.markdownIt);
37
+ },
38
+ },
39
+ vite: {
40
+ plugins: [mrp.vite()],
41
+ },
42
+ });
43
+ ```
44
+
45
+ Then use `<PreviewBlock>` in your theme if needed:
46
+
47
+ ```vue
48
+ <script setup>
49
+ import PreviewBlock from "@izumisy/vitepress-plugin-react-preview/PreviewBlock.vue";
50
+ </script>
51
+ ```
52
+
53
+ ## Preview Syntax
54
+
55
+ Works with the same `` ```tsx preview `` syntax as `@izumisy/md-react-preview`:
56
+
57
+ ````md
58
+ ```tsx preview
59
+ import { Button } from "@my-lib"
60
+
61
+ <Button>Click me</Button>
62
+ ```
63
+
64
+ ```tsx preview standalone
65
+ import { Sheet } from "@my-lib"
66
+
67
+ <Sheet.Root>...</Sheet.Root>
68
+ ```
69
+ ````
70
+
71
+ - **inline** (default) — rendered within the page via iframe
72
+ - **standalone** — shows a code block with a link to a full-viewport preview page
73
+
74
+ ## License
75
+
76
+ MIT
@@ -0,0 +1,39 @@
1
+ import { PreviewBlockEntry } from "@izumisy/vite-plugin-react-preview";
2
+ import * as markdown_it0 from "markdown-it";
3
+ import { Plugin } from "vite";
4
+
5
+ //#region src/index.d.ts
6
+ type MrpVitePressOptions = {
7
+ /** CSS file to inject into preview blocks (e.g. your component library's stylesheet) */css?: string;
8
+ };
9
+ /**
10
+ * Recursively find all .md files under a directory.
11
+ * @internal Exported for testing.
12
+ */
13
+ declare function findMarkdownFiles(dir: string): string[];
14
+ /**
15
+ * Pre-scan markdown files to populate the block registry before Rollup
16
+ * resolves the registry virtual module.
17
+ * @internal Exported for testing.
18
+ */
19
+ declare function scanMarkdownBlocks(root: string, blockRegistry: Map<string, PreviewBlockEntry>): void;
20
+ /**
21
+ * Create a md-react-preview plugin instance for VitePress.
22
+ *
23
+ * Returns an object with:
24
+ * - `markdownIt`: markdown-it plugin that transforms ` ```tsx preview ` fences
25
+ * - `vite()`: Vite plugins that serve preview blocks with live React components
26
+ */
27
+ declare function createMrpPlugin(options?: MrpVitePressOptions): {
28
+ /**
29
+ * markdown-it plugin that transforms ` ```tsx preview ` fences into
30
+ * `<PreviewBlock>` Vue components and registers blocks in the shared registry.
31
+ */
32
+ markdownIt: (md: markdown_it0.default) => void;
33
+ /**
34
+ * Vite plugins that provide virtual modules for preview blocks.
35
+ */
36
+ vite(): Plugin[];
37
+ };
38
+ //#endregion
39
+ export { MrpVitePressOptions, createMrpPlugin, findMarkdownFiles, scanMarkdownBlocks };
package/dist/index.mjs ADDED
@@ -0,0 +1,108 @@
1
+ import { readFileSync, readdirSync, statSync } from "node:fs";
2
+ import { join, relative } from "node:path";
3
+ import { createBasePreviewPlugin, createPreviewBuildPlugin, parseMeta, simpleHash } from "@izumisy/vite-plugin-react-preview";
4
+ //#region src/markdown-it-plugin.ts
5
+ /**
6
+ * Create a markdown-it plugin that transforms ` ```tsx preview ` fenced code blocks
7
+ * into a `<PreviewBlock>` Vue component with the code passed as props.
8
+ *
9
+ * Blocks are registered in the shared registry so the preview Vite plugin
10
+ * can serve the preview pages.
11
+ *
12
+ * The code is base64-encoded to avoid escaping issues in HTML attributes.
13
+ */
14
+ function createMarkdownItPlugin(blockRegistry) {
15
+ return function markdownItPreviewPlugin(md) {
16
+ const defaultFence = md.renderer.rules.fence;
17
+ md.renderer.rules.fence = (tokens, idx, options, env, self) => {
18
+ const token = tokens[idx];
19
+ const info = token.info.trim();
20
+ if (info.startsWith("tsx preview")) {
21
+ const code = token.content.replace(/\n$/, "");
22
+ const encodedCode = Buffer.from(code).toString("base64");
23
+ const blockId = simpleHash(`${env.relativePath || "unknown"}:${token.map?.[0] ?? 0}`);
24
+ const meta = parseMeta(info.slice(11));
25
+ const isStandalone = meta.standalone === "true";
26
+ blockRegistry.set(blockId, {
27
+ code,
28
+ sourceFile: "",
29
+ wrap: meta.wrap,
30
+ height: meta.height,
31
+ standalone: isStandalone
32
+ });
33
+ const highlighted = md.options.highlight?.(code, "tsx", "") ?? "";
34
+ return `<PreviewBlock code="${encodedCode}" block-id="${blockId}" highlighted="${Buffer.from(highlighted).toString("base64")}"${meta.height ? ` height="${meta.height}"` : ""}${meta.wrap ? ` wrap="${meta.wrap}"` : ""}${meta.align ? ` align="${meta.align}"` : ""}${isStandalone ? ` standalone="true"` : ""} />\n`;
35
+ }
36
+ return defaultFence(tokens, idx, options, env, self);
37
+ };
38
+ };
39
+ }
40
+ //#endregion
41
+ //#region src/index.ts
42
+ /**
43
+ * Recursively find all .md files under a directory.
44
+ * @internal Exported for testing.
45
+ */
46
+ function findMarkdownFiles(dir) {
47
+ const results = [];
48
+ for (const entry of readdirSync(dir)) {
49
+ if (entry.startsWith(".") || entry === "node_modules") continue;
50
+ const full = join(dir, entry);
51
+ if (statSync(full).isDirectory()) results.push(...findMarkdownFiles(full));
52
+ else if (full.endsWith(".md")) results.push(full);
53
+ }
54
+ return results;
55
+ }
56
+ /**
57
+ * Pre-scan markdown files to populate the block registry before Rollup
58
+ * resolves the registry virtual module.
59
+ * @internal Exported for testing.
60
+ */
61
+ function scanMarkdownBlocks(root, blockRegistry) {
62
+ const mdFiles = findMarkdownFiles(root);
63
+ for (const file of mdFiles) {
64
+ const content = readFileSync(file, "utf-8");
65
+ const relativePath = relative(root, file);
66
+ const fenceRe = /^(`{3,})\s*(\S.*?)?\n([\s\S]*?)^\1\s*$/gm;
67
+ let m;
68
+ while ((m = fenceRe.exec(content)) !== null) {
69
+ const info = (m[2] || "").trim();
70
+ if (info.startsWith("tsx preview")) {
71
+ const code = m[3].replace(/\n$/, "");
72
+ const blockId = simpleHash(`${relativePath}:${content.slice(0, m.index).split("\n").length - 1}`);
73
+ const meta = parseMeta(info.slice(11));
74
+ blockRegistry.set(blockId, {
75
+ code,
76
+ sourceFile: "",
77
+ wrap: meta.wrap,
78
+ height: meta.height,
79
+ standalone: meta.standalone === "true"
80
+ });
81
+ }
82
+ }
83
+ }
84
+ }
85
+ /**
86
+ * Create a md-react-preview plugin instance for VitePress.
87
+ *
88
+ * Returns an object with:
89
+ * - `markdownIt`: markdown-it plugin that transforms ` ```tsx preview ` fences
90
+ * - `vite()`: Vite plugins that serve preview blocks with live React components
91
+ */
92
+ function createMrpPlugin(options = {}) {
93
+ const blockRegistry = /* @__PURE__ */ new Map();
94
+ return {
95
+ markdownIt: createMarkdownItPlugin(blockRegistry),
96
+ vite() {
97
+ return [...createBasePreviewPlugin("mrp-vitepress-preview", {
98
+ blockRegistry,
99
+ cssImport: options.css
100
+ }), createPreviewBuildPlugin({
101
+ blockRegistry,
102
+ scanBlocks: (root) => scanMarkdownBlocks(root, blockRegistry)
103
+ })];
104
+ }
105
+ };
106
+ }
107
+ //#endregion
108
+ export { createMrpPlugin, findMarkdownFiles, scanMarkdownBlocks };
package/package.json ADDED
@@ -0,0 +1,42 @@
1
+ {
2
+ "name": "@izumisy/vitepress-plugin-react-preview",
3
+ "version": "0.1.0",
4
+ "description": "VitePress plugin for rendering React component previews via iframe",
5
+ "type": "module",
6
+ "exports": {
7
+ ".": {
8
+ "types": "./dist/index.d.mts",
9
+ "default": "./dist/index.mjs"
10
+ },
11
+ "./PreviewBlock.vue": "./src/PreviewBlock.vue"
12
+ },
13
+ "files": [
14
+ "dist/**",
15
+ "src/PreviewBlock.vue"
16
+ ],
17
+ "dependencies": {
18
+ "@izumisy/vite-plugin-react-preview": "0.1.0"
19
+ },
20
+ "devDependencies": {
21
+ "@types/markdown-it": "^14.1.2",
22
+ "@types/node": "^22",
23
+ "markdown-it": "^14.1.0",
24
+ "tsdown": "^0.21.3",
25
+ "typescript": "~5.9.3",
26
+ "vite": "^6.3.5",
27
+ "vitest": "^4.1.2"
28
+ },
29
+ "peerDependencies": {
30
+ "markdown-it": ">=14",
31
+ "react": ">=18",
32
+ "react-dom": ">=18",
33
+ "vite": ">=6",
34
+ "vue": ">=3"
35
+ },
36
+ "scripts": {
37
+ "dev": "tsdown --watch",
38
+ "build": "tsdown",
39
+ "type-check": "tsc --incremental",
40
+ "test": "vitest run"
41
+ }
42
+ }
@@ -0,0 +1,281 @@
1
+ <script setup lang="ts">
2
+ import { ref, computed, onMounted, onBeforeUnmount } from "vue";
3
+
4
+ const props = defineProps<{
5
+ code: string;
6
+ blockId: string;
7
+ highlighted?: string;
8
+ height?: string;
9
+ wrap?: string;
10
+ align?: string;
11
+ standalone?: string;
12
+ }>();
13
+
14
+ const isStandalone = computed(() => props.standalone === "true");
15
+ const showCode = ref(true);
16
+ const iframeRef = ref<HTMLIFrameElement | null>(null);
17
+ const iframeHeight = ref(props.height ? Number(props.height) : 150);
18
+
19
+ const decodedCode = computed(() => {
20
+ try {
21
+ return atob(props.code);
22
+ } catch {
23
+ return props.code;
24
+ }
25
+ });
26
+
27
+ const decodedHighlighted = computed(() => {
28
+ if (!props.highlighted) return "";
29
+ try {
30
+ return atob(props.highlighted);
31
+ } catch {
32
+ return "";
33
+ }
34
+ });
35
+
36
+ const currentTheme = ref(
37
+ typeof document !== "undefined" && document.documentElement.classList.contains("dark")
38
+ ? "dark"
39
+ : "light"
40
+ );
41
+
42
+ const previewUrl = computed(() => {
43
+ const params = new URLSearchParams({ theme: currentTheme.value });
44
+ if (props.wrap) params.set("wrap", props.wrap);
45
+ if (props.align) params.set("align", props.align);
46
+ return `/__preview/${props.blockId}?${params}`;
47
+ });
48
+
49
+ let themeObserver: MutationObserver | null = null;
50
+
51
+ function syncThemeToIframe() {
52
+ const iframe = iframeRef.value;
53
+ if (iframe?.contentWindow) {
54
+ iframe.contentWindow.postMessage(
55
+ { type: "mrp-theme", theme: currentTheme.value },
56
+ "*"
57
+ );
58
+ }
59
+ }
60
+
61
+ function onMessage(e: MessageEvent) {
62
+ if (
63
+ e.data?.type === "mrp-resize" &&
64
+ e.data?.blockId === props.blockId
65
+ ) {
66
+ iframeHeight.value = e.data.height;
67
+ }
68
+ }
69
+
70
+ onMounted(() => {
71
+ window.addEventListener("message", onMessage);
72
+
73
+ themeObserver = new MutationObserver(() => {
74
+ const isDark = document.documentElement.classList.contains("dark");
75
+ const newTheme = isDark ? "dark" : "light";
76
+ if (currentTheme.value !== newTheme) {
77
+ currentTheme.value = newTheme;
78
+ syncThemeToIframe();
79
+ }
80
+ });
81
+ themeObserver.observe(document.documentElement, {
82
+ attributes: true,
83
+ attributeFilter: ["class"],
84
+ });
85
+ });
86
+
87
+ onBeforeUnmount(() => {
88
+ window.removeEventListener("message", onMessage);
89
+ themeObserver?.disconnect();
90
+ });
91
+ </script>
92
+
93
+ <template>
94
+ <div class="mrp-preview vp-raw">
95
+ <template v-if="isStandalone">
96
+ <a
97
+ :href="previewUrl"
98
+ target="_blank"
99
+ rel="noopener noreferrer"
100
+ class="mrp-preview-standalone-link"
101
+ >
102
+ <svg width="20" height="20" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" style="opacity:0.5">
103
+ <path d="M7 3H3v10h10V9" />
104
+ <path d="M10 2h4v4" />
105
+ <path d="M14 2L7 9" />
106
+ </svg>
107
+ <span class="mrp-preview-standalone-text">
108
+ <span class="mrp-preview-standalone-title">Open full-page preview</span>
109
+ <span class="mrp-preview-standalone-desc">This component requires a full viewport to render correctly.</span>
110
+ </span>
111
+ </a>
112
+ </template>
113
+ <template v-else>
114
+ <div class="mrp-preview-render">
115
+ <iframe
116
+ ref="iframeRef"
117
+ :src="previewUrl"
118
+ :style="{ height: iframeHeight + 'px' }"
119
+ class="mrp-preview-iframe"
120
+ />
121
+ </div>
122
+ <div class="mrp-preview-actions">
123
+ <a
124
+ :href="previewUrl"
125
+ target="_blank"
126
+ rel="noopener noreferrer"
127
+ class="mrp-preview-fullscreen-link"
128
+ >
129
+ Open full preview
130
+ <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
131
+ <path d="M7 3H3v10h10V9" />
132
+ <path d="M10 2h4v4" />
133
+ <path d="M14 2L7 9" />
134
+ </svg>
135
+ </a>
136
+ </div>
137
+ </template>
138
+ <div class="mrp-preview-code">
139
+ <button
140
+ class="mrp-preview-toggle"
141
+ @click="showCode = !showCode"
142
+ >
143
+ <svg
144
+ width="16"
145
+ height="16"
146
+ viewBox="0 0 16 16"
147
+ fill="none"
148
+ stroke="currentColor"
149
+ stroke-width="1.5"
150
+ stroke-linecap="round"
151
+ stroke-linejoin="round"
152
+ :class="{ 'mrp-chevron-open': showCode }"
153
+ >
154
+ <path d="M6 4l4 4-4 4" />
155
+ </svg>
156
+ Code
157
+ </button>
158
+ <div v-if="showCode && decodedHighlighted" v-html="decodedHighlighted" />
159
+ <div v-else-if="showCode" class="language-tsx vp-adaptive-theme">
160
+ <pre><code>{{ decodedCode }}</code></pre>
161
+ </div>
162
+ </div>
163
+ </div>
164
+ </template>
165
+
166
+ <style scoped>
167
+ .mrp-preview {
168
+ border: 1px solid var(--vp-c-divider);
169
+ border-radius: 8px;
170
+ overflow: hidden;
171
+ margin: 16px 0;
172
+ }
173
+
174
+ .mrp-preview-render {
175
+ background-color: var(--vp-c-bg);
176
+ }
177
+
178
+ .mrp-preview-iframe {
179
+ display: block;
180
+ width: 100%;
181
+ border: none;
182
+ transition: height 0.15s ease;
183
+ }
184
+
185
+ .mrp-preview-code {
186
+ border-top: 1px solid var(--vp-c-divider);
187
+ }
188
+
189
+ .mrp-preview-toggle {
190
+ display: flex;
191
+ align-items: center;
192
+ gap: 4px;
193
+ width: 100%;
194
+ padding: 8px 12px;
195
+ background: none;
196
+ border: none;
197
+ cursor: pointer;
198
+ font-size: 13px;
199
+ color: var(--vp-c-text-2);
200
+ font-family: inherit;
201
+ }
202
+
203
+ .mrp-preview-toggle:hover {
204
+ color: var(--vp-c-text-1);
205
+ }
206
+
207
+ .mrp-chevron-open {
208
+ transform: rotate(90deg);
209
+ }
210
+
211
+ .mrp-preview-actions {
212
+ display: flex;
213
+ justify-content: flex-end;
214
+ padding: 6px 12px;
215
+ border-top: 1px solid var(--vp-c-divider);
216
+ }
217
+
218
+ .mrp-preview-fullscreen-link {
219
+ display: inline-flex;
220
+ align-items: center;
221
+ gap: 4px;
222
+ font-size: 12px;
223
+ color: var(--vp-c-text-3);
224
+ text-decoration: none;
225
+ }
226
+
227
+ .mrp-preview-fullscreen-link:hover {
228
+ color: var(--vp-c-text-1);
229
+ }
230
+
231
+ .mrp-preview-code svg {
232
+ transition: transform 0.15s;
233
+ }
234
+
235
+ .mrp-preview-code pre {
236
+ margin: 0 !important;
237
+ border-radius: 0 !important;
238
+ }
239
+
240
+ .mrp-preview-code :deep(pre) {
241
+ margin: 0 !important;
242
+ border-radius: 0 !important;
243
+ padding: 16px !important;
244
+ }
245
+
246
+ .mrp-preview-code :deep(pre code) {
247
+ font-size: 13px !important;
248
+ line-height: 1.6 !important;
249
+ }
250
+
251
+ .mrp-preview-standalone-link {
252
+ display: flex;
253
+ align-items: center;
254
+ gap: 12px;
255
+ padding: 20px 16px;
256
+ text-decoration: none;
257
+ color: var(--vp-c-text-2);
258
+ transition: background-color 0.15s;
259
+ }
260
+
261
+ .mrp-preview-standalone-link:hover {
262
+ background-color: var(--vp-c-bg-soft);
263
+ }
264
+
265
+ .mrp-preview-standalone-text {
266
+ display: flex;
267
+ flex-direction: column;
268
+ gap: 2px;
269
+ }
270
+
271
+ .mrp-preview-standalone-title {
272
+ font-size: 14px;
273
+ font-weight: 500;
274
+ color: var(--vp-c-text-1);
275
+ }
276
+
277
+ .mrp-preview-standalone-desc {
278
+ font-size: 12px;
279
+ color: var(--vp-c-text-3);
280
+ }
281
+ </style>