prev-cli 0.11.0 → 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("/@")) {
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,
@@ -549,25 +723,74 @@ async function createViteConfig(options) {
549
723
  previewsPlugin(rootDir),
550
724
  {
551
725
  name: "prev-preview-server",
726
+ resolveId(id) {
727
+ if (id.startsWith("/_preview/")) {
728
+ const relativePath = id.slice("/_preview/".length);
729
+ const previewsDir = path6.join(rootDir, "previews");
730
+ const resolved = path6.resolve(previewsDir, relativePath);
731
+ if (resolved.startsWith(previewsDir)) {
732
+ return resolved;
733
+ }
734
+ }
735
+ },
552
736
  configureServer(server) {
553
737
  server.middlewares.use(async (req, res, next) => {
554
- if (req.url?.startsWith("/_preview/")) {
555
- const previewName = decodeURIComponent(req.url.slice("/_preview/".length).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));
556
750
  const previewsDir = path6.join(rootDir, "previews");
557
- const htmlPath = path6.resolve(previewsDir, previewName, "index.html");
558
- if (!htmlPath.startsWith(previewsDir)) {
559
- return next();
751
+ const previewDir = path6.resolve(previewsDir, previewName);
752
+ if (!previewDir.startsWith(previewsDir)) {
753
+ res.statusCode = 403;
754
+ res.end("Forbidden");
755
+ return;
560
756
  }
561
- if (existsSync4(htmlPath)) {
757
+ if (existsSync4(previewDir)) {
562
758
  try {
563
- const html = await server.transformIndexHtml(req.url, readFileSync2(htmlPath, "utf-8"));
564
- res.setHeader("Content-Type", "text/html");
565
- res.end(html);
759
+ const config = await buildPreviewConfig(previewDir);
760
+ res.setHeader("Content-Type", "application/json");
761
+ res.end(JSON.stringify(config));
566
762
  return;
567
763
  } catch (err) {
568
- console.error("Error serving preview:", 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/")) {
772
+ const isHtmlRequest = !path6.extname(urlPath) || urlPath.endsWith("/");
773
+ if (isHtmlRequest) {
774
+ const previewName = decodeURIComponent(urlPath.slice("/_preview/".length).replace(/\/$/, ""));
775
+ const previewsDir = path6.join(rootDir, "previews");
776
+ const htmlPath = path6.resolve(previewsDir, previewName, "index.html");
777
+ if (!htmlPath.startsWith(previewsDir)) {
569
778
  return next();
570
779
  }
780
+ if (existsSync4(htmlPath)) {
781
+ try {
782
+ let html = readFileSync3(htmlPath, "utf-8");
783
+ const previewBase = `/_preview/${previewName}/`;
784
+ html = html.replace(/(src|href)=["']\.\/([^"']+)["']/g, `$1="${previewBase}$2"`);
785
+ const transformed = await server.transformIndexHtml(req.url, html);
786
+ res.setHeader("Content-Type", "text/html");
787
+ res.end(transformed);
788
+ return;
789
+ } catch (err) {
790
+ console.error("Error serving preview:", err);
791
+ return next();
792
+ }
793
+ }
571
794
  }
572
795
  }
573
796
  next();
@@ -632,8 +855,7 @@ async function createViteConfig(options) {
632
855
  chunkSizeWarningLimit: 1e4,
633
856
  rollupOptions: {
634
857
  input: {
635
- main: path6.join(srcRoot2, "theme/index.html"),
636
- ...previewInputs
858
+ main: path6.join(srcRoot2, "theme/index.html")
637
859
  }
638
860
  }
639
861
  }
@@ -713,7 +935,7 @@ async function buildSite(rootDir, options = {}) {
713
935
  mode: "production",
714
936
  include: options.include
715
937
  });
716
- await build(config);
938
+ await build2(config);
717
939
  console.log();
718
940
  console.log(" Done! Your site is ready in ./dist");
719
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.0",
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"