prev-cli 0.11.1 → 0.12.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/dist/cli.js CHANGED
@@ -5,7 +5,7 @@ import { parseArgs } from "util";
5
5
  import path7 from "path";
6
6
 
7
7
  // src/vite/start.ts
8
- import { createServer as createServer2, build, preview } from "vite";
8
+ import { createServer as createServer2, build as build2, preview } from "vite";
9
9
 
10
10
  // src/vite/config.ts
11
11
  import { createLogger } from "vite";
@@ -15,7 +15,7 @@ import remarkGfm from "remark-gfm";
15
15
  import rehypeHighlight from "rehype-highlight";
16
16
  import path6 from "path";
17
17
  import { fileURLToPath as fileURLToPath2 } from "url";
18
- import { existsSync as existsSync4, readFileSync as readFileSync2 } from "fs";
18
+ import { existsSync as existsSync4, readFileSync as readFileSync3 } from "fs";
19
19
 
20
20
  // src/utils/cache.ts
21
21
  import { createHash } from "crypto";
@@ -348,7 +348,7 @@ function entryPlugin(rootDir) {
348
348
  const html = getHtml(entryPath, false);
349
349
  server.middlewares.use(async (req, res, next) => {
350
350
  const url = req.url || "/";
351
- if (url === "/" || !url.includes(".") && !url.startsWith("/@") && !url.startsWith("/_preview/")) {
351
+ if (url === "/" || !url.includes(".") && !url.startsWith("/@") && !url.startsWith("/_preview")) {
352
352
  try {
353
353
  const transformed = await server.transformIndexHtml(url, html);
354
354
  res.setHeader("Content-Type", "text/html");
@@ -370,7 +370,7 @@ function entryPlugin(rootDir) {
370
370
  // src/vite/previews.ts
371
371
  import fg2 from "fast-glob";
372
372
  import path4 from "path";
373
- import { existsSync as existsSync2 } from "fs";
373
+ import { existsSync as existsSync2, readFileSync as readFileSync2 } from "fs";
374
374
  async function scanPreviews(rootDir) {
375
375
  const previewsDir = path4.join(rootDir, "previews");
376
376
  if (!existsSync2(previewsDir)) {
@@ -390,15 +390,167 @@ async function scanPreviews(rootDir) {
390
390
  };
391
391
  });
392
392
  }
393
+ async function scanPreviewFiles(previewDir) {
394
+ const files = await fg2.glob("**/*.{tsx,ts,jsx,js,css,json}", {
395
+ cwd: previewDir,
396
+ ignore: ["node_modules/**", "dist/**"]
397
+ });
398
+ return files.map((file) => {
399
+ const content = readFileSync2(path4.join(previewDir, file), "utf-8");
400
+ const ext = path4.extname(file).slice(1);
401
+ return {
402
+ path: file,
403
+ content,
404
+ type: ext
405
+ };
406
+ });
407
+ }
408
+ function detectEntry(files) {
409
+ const priorities = ["App.tsx", "App.jsx", "index.tsx", "index.jsx", "main.tsx", "main.jsx"];
410
+ for (const name of priorities) {
411
+ const file = files.find((f) => f.path === name);
412
+ if (file)
413
+ return file.path;
414
+ }
415
+ const jsxFile = files.find((f) => f.type === "tsx" || f.type === "jsx");
416
+ return jsxFile?.path || files[0]?.path || "App.tsx";
417
+ }
418
+ async function buildPreviewConfig(previewDir) {
419
+ const files = await scanPreviewFiles(previewDir);
420
+ const entry = detectEntry(files);
421
+ return {
422
+ files,
423
+ entry,
424
+ tailwind: true
425
+ };
426
+ }
427
+
428
+ // src/preview-runtime/build.ts
429
+ import { build } from "esbuild";
430
+ async function buildPreviewHtml(config) {
431
+ try {
432
+ const virtualFs = {};
433
+ for (const file of config.files) {
434
+ const ext = file.path.split(".").pop()?.toLowerCase();
435
+ const loader = ext === "css" ? "css" : ext === "json" ? "json" : ext || "tsx";
436
+ virtualFs[file.path] = { contents: file.content, loader };
437
+ }
438
+ const entryFile = config.files.find((f) => f.path === config.entry);
439
+ if (!entryFile) {
440
+ return { html: "", error: `Entry file not found: ${config.entry}` };
441
+ }
442
+ const hasDefaultExport = /export\s+default/.test(entryFile.content);
443
+ const entryCode = hasDefaultExport ? `
444
+ import React from 'react'
445
+ import { createRoot } from 'react-dom/client'
446
+ import App from './${config.entry}'
447
+
448
+ const root = createRoot(document.getElementById('root'))
449
+ root.render(React.createElement(App))
450
+ ` : `
451
+ import './${config.entry}'
452
+ `;
453
+ const result = await build({
454
+ stdin: {
455
+ contents: entryCode,
456
+ loader: "tsx",
457
+ resolveDir: "/"
458
+ },
459
+ bundle: true,
460
+ write: false,
461
+ format: "esm",
462
+ jsx: "automatic",
463
+ jsxImportSource: "react",
464
+ target: "es2020",
465
+ minify: true,
466
+ plugins: [{
467
+ name: "virtual-fs",
468
+ setup(build2) {
469
+ build2.onResolve({ filter: /^react(-dom)?(\/.*)?$/ }, (args) => {
470
+ const parts = args.path.split("/");
471
+ const pkg = parts[0];
472
+ const subpath = parts.slice(1).join("/");
473
+ const url = subpath ? `https://esm.sh/${pkg}@18/${subpath}` : `https://esm.sh/${pkg}@18`;
474
+ return { path: url, external: true };
475
+ });
476
+ build2.onResolve({ filter: /^[^./]/ }, (args) => {
477
+ if (args.path.startsWith("https://"))
478
+ return;
479
+ return { path: `https://esm.sh/${args.path}`, external: true };
480
+ });
481
+ build2.onResolve({ filter: /^\./ }, (args) => {
482
+ let resolved = args.path.replace(/^\.\//, "");
483
+ if (!resolved.includes(".")) {
484
+ for (const ext of [".tsx", ".ts", ".jsx", ".js", ".css"]) {
485
+ if (virtualFs[resolved + ext]) {
486
+ resolved = resolved + ext;
487
+ break;
488
+ }
489
+ }
490
+ }
491
+ return { path: resolved, namespace: "virtual" };
492
+ });
493
+ build2.onLoad({ filter: /.*/, namespace: "virtual" }, (args) => {
494
+ const file = virtualFs[args.path];
495
+ if (file) {
496
+ if (file.loader === "css") {
497
+ const css = file.contents.replace(/`/g, "\\`").replace(/\$/g, "\\$");
498
+ return {
499
+ contents: `
500
+ const style = document.createElement('style');
501
+ style.textContent = \`${css}\`;
502
+ document.head.appendChild(style);
503
+ `,
504
+ loader: "js"
505
+ };
506
+ }
507
+ return { contents: file.contents, loader: file.loader };
508
+ }
509
+ return { contents: "", loader: "empty" };
510
+ });
511
+ }
512
+ }]
513
+ });
514
+ const jsFile = result.outputFiles.find((f) => f.path.endsWith(".js")) || result.outputFiles[0];
515
+ const jsCode = jsFile?.text || "";
516
+ const html = `<!DOCTYPE html>
517
+ <html lang="en">
518
+ <head>
519
+ <meta charset="UTF-8">
520
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
521
+ <title>Preview</title>
522
+ <script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
523
+ <style>
524
+ body { margin: 0; }
525
+ #root { min-height: 100vh; }
526
+ </style>
527
+ </head>
528
+ <body>
529
+ <div id="root"></div>
530
+ <script type="module">${jsCode}</script>
531
+ </body>
532
+ </html>`;
533
+ return { html };
534
+ } catch (err) {
535
+ return {
536
+ html: "",
537
+ error: err instanceof Error ? err.message : String(err)
538
+ };
539
+ }
540
+ }
393
541
 
394
542
  // src/vite/plugins/previews-plugin.ts
395
- import { existsSync as existsSync3, renameSync, mkdirSync, rmSync } from "fs";
543
+ import { existsSync as existsSync3, mkdirSync, rmSync, writeFileSync as writeFileSync2 } from "fs";
396
544
  import path5 from "path";
397
545
  var VIRTUAL_MODULE_ID2 = "virtual:prev-previews";
398
546
  var RESOLVED_VIRTUAL_MODULE_ID2 = "\x00" + VIRTUAL_MODULE_ID2;
399
547
  function previewsPlugin(rootDir) {
548
+ let isBuild = false;
400
549
  return {
401
550
  name: "prev-previews",
551
+ config(_, { command }) {
552
+ isBuild = command === "build";
553
+ },
402
554
  resolveId(id) {
403
555
  if (id === VIRTUAL_MODULE_ID2) {
404
556
  return RESOLVED_VIRTUAL_MODULE_ID2;
@@ -411,7 +563,7 @@ function previewsPlugin(rootDir) {
411
563
  }
412
564
  },
413
565
  handleHotUpdate({ file, server }) {
414
- if (file.includes("/previews/") && file.endsWith(".html")) {
566
+ if (file.includes("/previews/") && /\.(html|tsx|ts|jsx|js|css)$/.test(file)) {
415
567
  const mod = server.moduleGraph.getModuleById(RESOLVED_VIRTUAL_MODULE_ID2);
416
568
  if (mod) {
417
569
  server.moduleGraph.invalidateModule(mod);
@@ -419,16 +571,40 @@ function previewsPlugin(rootDir) {
419
571
  }
420
572
  }
421
573
  },
422
- closeBundle() {
574
+ async closeBundle() {
575
+ if (!isBuild)
576
+ return;
423
577
  const distDir = path5.join(rootDir, "dist");
424
- const previewsDir = path5.join(distDir, "previews");
425
578
  const targetDir = path5.join(distDir, "_preview");
426
- if (existsSync3(previewsDir)) {
427
- mkdirSync(path5.dirname(targetDir), { recursive: true });
428
- if (existsSync3(targetDir)) {
429
- rmSync(targetDir, { recursive: true });
579
+ const previewsDir = path5.join(rootDir, "previews");
580
+ const oldPreviewsDir = path5.join(distDir, "previews");
581
+ if (existsSync3(oldPreviewsDir)) {
582
+ rmSync(oldPreviewsDir, { recursive: true });
583
+ }
584
+ if (existsSync3(targetDir)) {
585
+ rmSync(targetDir, { recursive: true });
586
+ }
587
+ const previews = await scanPreviews(rootDir);
588
+ if (previews.length === 0)
589
+ return;
590
+ console.log(`
591
+ Building ${previews.length} preview(s)...`);
592
+ for (const preview of previews) {
593
+ const previewDir = path5.join(previewsDir, preview.name);
594
+ try {
595
+ const config = await buildPreviewConfig(previewDir);
596
+ const result = await buildPreviewHtml(config);
597
+ if (result.error) {
598
+ console.error(` ✗ ${preview.name}: ${result.error}`);
599
+ continue;
600
+ }
601
+ const outputDir = path5.join(targetDir, preview.name);
602
+ mkdirSync(outputDir, { recursive: true });
603
+ writeFileSync2(path5.join(outputDir, "index.html"), result.html);
604
+ console.log(` ✓ ${preview.name}`);
605
+ } catch (err) {
606
+ console.error(` ✗ ${preview.name}: ${err}`);
430
607
  }
431
- renameSync(previewsDir, targetDir);
432
608
  }
433
609
  }
434
610
  };
@@ -494,7 +670,7 @@ function findCliRoot2() {
494
670
  const pkgPath = path6.join(dir, "package.json");
495
671
  if (existsSync4(pkgPath)) {
496
672
  try {
497
- const pkg = JSON.parse(readFileSync2(pkgPath, "utf-8"));
673
+ const pkg = JSON.parse(readFileSync3(pkgPath, "utf-8"));
498
674
  if (pkg.name === "prev-cli") {
499
675
  return dir;
500
676
  }
@@ -530,8 +706,6 @@ var srcRoot2 = path6.join(cliRoot2, "src");
530
706
  async function createViteConfig(options) {
531
707
  const { rootDir, mode, port, include } = options;
532
708
  const cacheDir = await ensureCacheDir(rootDir);
533
- const previews = await scanPreviews(rootDir);
534
- const previewInputs = Object.fromEntries(previews.map((p) => [`_preview/${p.name}`, p.htmlPath]));
535
709
  return {
536
710
  root: rootDir,
537
711
  mode,
@@ -561,8 +735,40 @@ async function createViteConfig(options) {
561
735
  },
562
736
  configureServer(server) {
563
737
  server.middlewares.use(async (req, res, next) => {
564
- if (req.url?.startsWith("/_preview/")) {
565
- const urlPath = req.url.split("?")[0];
738
+ const urlPath = req.url?.split("?")[0] || "";
739
+ if (urlPath === "/_preview-runtime") {
740
+ const templatePath = path6.join(srcRoot2, "preview-runtime/template.html");
741
+ if (existsSync4(templatePath)) {
742
+ const html = readFileSync3(templatePath, "utf-8");
743
+ res.setHeader("Content-Type", "text/html");
744
+ res.end(html);
745
+ return;
746
+ }
747
+ }
748
+ if (urlPath.startsWith("/_preview-config/")) {
749
+ const previewName = decodeURIComponent(urlPath.slice("/_preview-config/".length));
750
+ const previewsDir = path6.join(rootDir, "previews");
751
+ const previewDir = path6.resolve(previewsDir, previewName);
752
+ if (!previewDir.startsWith(previewsDir)) {
753
+ res.statusCode = 403;
754
+ res.end("Forbidden");
755
+ return;
756
+ }
757
+ if (existsSync4(previewDir)) {
758
+ try {
759
+ const config = await buildPreviewConfig(previewDir);
760
+ res.setHeader("Content-Type", "application/json");
761
+ res.end(JSON.stringify(config));
762
+ return;
763
+ } catch (err) {
764
+ console.error("Error building preview config:", err);
765
+ res.statusCode = 500;
766
+ res.end(JSON.stringify({ error: String(err) }));
767
+ return;
768
+ }
769
+ }
770
+ }
771
+ if (urlPath.startsWith("/_preview/")) {
566
772
  const isHtmlRequest = !path6.extname(urlPath) || urlPath.endsWith("/");
567
773
  if (isHtmlRequest) {
568
774
  const previewName = decodeURIComponent(urlPath.slice("/_preview/".length).replace(/\/$/, ""));
@@ -573,7 +779,7 @@ async function createViteConfig(options) {
573
779
  }
574
780
  if (existsSync4(htmlPath)) {
575
781
  try {
576
- let html = readFileSync2(htmlPath, "utf-8");
782
+ let html = readFileSync3(htmlPath, "utf-8");
577
783
  const previewBase = `/_preview/${previewName}/`;
578
784
  html = html.replace(/(src|href)=["']\.\/([^"']+)["']/g, `$1="${previewBase}$2"`);
579
785
  const transformed = await server.transformIndexHtml(req.url, html);
@@ -649,8 +855,7 @@ async function createViteConfig(options) {
649
855
  chunkSizeWarningLimit: 1e4,
650
856
  rollupOptions: {
651
857
  input: {
652
- main: path6.join(srcRoot2, "theme/index.html"),
653
- ...previewInputs
858
+ main: path6.join(srcRoot2, "theme/index.html")
654
859
  }
655
860
  }
656
861
  }
@@ -730,7 +935,7 @@ async function buildSite(rootDir, options = {}) {
730
935
  mode: "production",
731
936
  include: options.include
732
937
  });
733
- await build(config);
938
+ await build2(config);
734
939
  console.log();
735
940
  console.log(" Done! Your site is ready in ./dist");
736
941
  console.log(" You can deploy this folder anywhere.");
@@ -0,0 +1,10 @@
1
+ import type { PreviewConfig } from './types';
2
+ export interface PreviewBuildResult {
3
+ html: string;
4
+ error?: string;
5
+ }
6
+ /**
7
+ * Build a preview into a standalone HTML file for production
8
+ * Uses esbuild (native) to bundle at build time
9
+ */
10
+ export declare function buildPreviewHtml(config: PreviewConfig): Promise<PreviewBuildResult>;
@@ -0,0 +1,32 @@
1
+ export interface PreviewFile {
2
+ path: string;
3
+ content: string;
4
+ type: 'tsx' | 'ts' | 'jsx' | 'js' | 'css' | 'html' | 'json';
5
+ }
6
+ export interface PreviewConfig {
7
+ files: PreviewFile[];
8
+ entry: string;
9
+ tailwind?: boolean;
10
+ }
11
+ export interface BuildResult {
12
+ success: boolean;
13
+ code?: string;
14
+ css?: string;
15
+ error?: string;
16
+ buildTime?: number;
17
+ }
18
+ export type PreviewMessage = {
19
+ type: 'init';
20
+ config: PreviewConfig;
21
+ } | {
22
+ type: 'update';
23
+ files: PreviewFile[];
24
+ } | {
25
+ type: 'ready';
26
+ } | {
27
+ type: 'built';
28
+ result: BuildResult;
29
+ } | {
30
+ type: 'error';
31
+ error: string;
32
+ };
@@ -1,6 +1,23 @@
1
+ import type { PreviewFile, PreviewConfig } from '../preview-runtime/types';
1
2
  export interface Preview {
2
3
  name: string;
3
4
  route: string;
4
5
  htmlPath: string;
5
6
  }
7
+ export interface PreviewWithFiles extends Preview {
8
+ files: PreviewFile[];
9
+ entry: string;
10
+ }
6
11
  export declare function scanPreviews(rootDir: string): Promise<Preview[]>;
12
+ /**
13
+ * Scan all files in a preview directory for WASM bundling
14
+ */
15
+ export declare function scanPreviewFiles(previewDir: string): Promise<PreviewFile[]>;
16
+ /**
17
+ * Detect the entry file for a preview (looks for App.tsx, index.tsx, etc.)
18
+ */
19
+ export declare function detectEntry(files: PreviewFile[]): string;
20
+ /**
21
+ * Build a PreviewConfig for WASM runtime
22
+ */
23
+ export declare function buildPreviewConfig(previewDir: string): Promise<PreviewConfig>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "prev-cli",
3
- "version": "0.11.1",
3
+ "version": "0.12.0",
4
4
  "description": "Transform MDX directories into beautiful documentation websites",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -1,10 +1,12 @@
1
1
  import React, { useState, useEffect, useRef, useCallback } from 'react'
2
- import { Smartphone, Tablet, Monitor, SunMedium, Moon, Maximize2, Minimize2, GripVertical, SlidersHorizontal, X } from 'lucide-react'
2
+ import { Smartphone, Tablet, Monitor, SunMedium, Moon, Maximize2, Minimize2, GripVertical, SlidersHorizontal, X, Loader2 } from 'lucide-react'
3
+ import type { PreviewConfig, PreviewMessage, BuildResult } from '../preview-runtime/types'
3
4
 
4
5
  interface PreviewProps {
5
6
  src: string
6
7
  height?: string | number
7
8
  title?: string
9
+ mode?: 'wasm' | 'legacy' // 'wasm' uses browser bundling, 'legacy' uses Vite
8
10
  }
9
11
 
10
12
  type DeviceMode = 'mobile' | 'tablet' | 'desktop'
@@ -20,7 +22,7 @@ interface Position {
20
22
  y: number
21
23
  }
22
24
 
23
- export function Preview({ src, height = 400, title }: PreviewProps) {
25
+ export function Preview({ src, height = 400, title, mode = 'wasm' }: PreviewProps) {
24
26
  const [isFullscreen, setIsFullscreen] = useState(false)
25
27
  const [deviceMode, setDeviceMode] = useState<DeviceMode>('desktop')
26
28
  const [customWidth, setCustomWidth] = useState<number | null>(null)
@@ -30,11 +32,17 @@ export function Preview({ src, height = 400, title }: PreviewProps) {
30
32
  const [isDragging, setIsDragging] = useState(false)
31
33
  const [dragOffset, setDragOffset] = useState<Position>({ x: 0, y: 0 })
32
34
 
35
+ // WASM preview state
36
+ const [buildStatus, setBuildStatus] = useState<'loading' | 'building' | 'ready' | 'error'>('loading')
37
+ const [buildTime, setBuildTime] = useState<number | null>(null)
38
+ const [buildError, setBuildError] = useState<string | null>(null)
39
+
33
40
  const iframeRef = useRef<HTMLIFrameElement>(null)
34
41
  const pillRef = useRef<HTMLDivElement>(null)
35
42
  const containerRef = useRef<HTMLDivElement>(null)
36
43
 
37
- const previewUrl = `/_preview/${src}`
44
+ // URL depends on mode
45
+ const previewUrl = mode === 'wasm' ? '/_preview-runtime' : `/_preview/${src}`
38
46
  const displayTitle = title || src
39
47
 
40
48
  // Calculate current width
@@ -88,6 +96,60 @@ export function Preview({ src, height = 400, title }: PreviewProps) {
88
96
  }
89
97
  }, [isDarkMode])
90
98
 
99
+ // WASM preview: Initialize and send config to iframe
100
+ useEffect(() => {
101
+ if (mode !== 'wasm') return
102
+
103
+ const iframe = iframeRef.current
104
+ if (!iframe) return
105
+
106
+ let configSent = false
107
+
108
+ // Handle messages from iframe
109
+ const handleMessage = (event: MessageEvent) => {
110
+ const msg = event.data as PreviewMessage
111
+
112
+ if (msg.type === 'ready' && !configSent) {
113
+ // Iframe is ready, fetch and send config
114
+ configSent = true
115
+ setBuildStatus('building')
116
+
117
+ fetch(`/_preview-config/${src}`)
118
+ .then(res => res.json())
119
+ .then((config: PreviewConfig) => {
120
+ iframe.contentWindow?.postMessage({ type: 'init', config } as PreviewMessage, '*')
121
+ })
122
+ .catch(err => {
123
+ setBuildStatus('error')
124
+ setBuildError(`Failed to load preview config: ${err.message}`)
125
+ })
126
+ }
127
+
128
+ if (msg.type === 'built') {
129
+ const result = msg.result as BuildResult
130
+ if (result.success) {
131
+ setBuildStatus('ready')
132
+ setBuildTime(result.buildTime || null)
133
+ setBuildError(null)
134
+ } else {
135
+ setBuildStatus('error')
136
+ setBuildError(result.error || 'Build failed')
137
+ }
138
+ }
139
+
140
+ if (msg.type === 'error') {
141
+ setBuildStatus('error')
142
+ setBuildError(msg.error)
143
+ }
144
+ }
145
+
146
+ window.addEventListener('message', handleMessage)
147
+
148
+ return () => {
149
+ window.removeEventListener('message', handleMessage)
150
+ }
151
+ }, [mode, src])
152
+
91
153
  // Drag handlers
92
154
  const handleMouseDown = useCallback((e: React.MouseEvent) => {
93
155
  if (!pillRef.current) return
@@ -344,14 +406,36 @@ export function Preview({ src, height = 400, title }: PreviewProps) {
344
406
  >
345
407
  {/* Header */}
346
408
  <div className="flex items-center justify-between px-3 py-2 bg-zinc-50 dark:bg-zinc-800 border-b border-zinc-200 dark:border-zinc-700">
347
- <span className="text-sm font-medium text-zinc-600 dark:text-zinc-400">
348
- {displayTitle}
349
- </span>
350
- <span className="text-xs text-zinc-400">
351
- {currentWidth ? `${currentWidth}px` : '100%'}
352
- </span>
409
+ <div className="flex items-center gap-2">
410
+ <span className="text-sm font-medium text-zinc-600 dark:text-zinc-400">
411
+ {displayTitle}
412
+ </span>
413
+ {mode === 'wasm' && buildStatus === 'building' && (
414
+ <Loader2 className="w-3.5 h-3.5 text-blue-500 animate-spin" />
415
+ )}
416
+ {mode === 'wasm' && buildStatus === 'error' && (
417
+ <span className="text-xs text-red-500">Error</span>
418
+ )}
419
+ </div>
420
+ <div className="flex items-center gap-2">
421
+ {mode === 'wasm' && buildTime && (
422
+ <span className="text-xs text-zinc-400">{buildTime}ms</span>
423
+ )}
424
+ <span className="text-xs text-zinc-400">
425
+ {currentWidth ? `${currentWidth}px` : '100%'}
426
+ </span>
427
+ </div>
353
428
  </div>
354
429
 
430
+ {/* Build error display */}
431
+ {mode === 'wasm' && buildError && (
432
+ <div className="px-3 py-2 bg-red-50 dark:bg-red-900/20 border-b border-red-200 dark:border-red-800">
433
+ <pre className="text-xs text-red-600 dark:text-red-400 whitespace-pre-wrap font-mono overflow-auto max-h-32">
434
+ {buildError}
435
+ </pre>
436
+ </div>
437
+ )}
438
+
355
439
  {/* Preview area with checkered background */}
356
440
  <div
357
441
  className="relative bg-zinc-100 dark:bg-zinc-900"