prev-cli 0.9.1 → 0.11.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
@@ -2,7 +2,7 @@
2
2
 
3
3
  // src/cli.ts
4
4
  import { parseArgs } from "util";
5
- import path5 from "path";
5
+ import path7 from "path";
6
6
 
7
7
  // src/vite/start.ts
8
8
  import { createServer as createServer2, build, preview } from "vite";
@@ -13,9 +13,9 @@ import react from "@vitejs/plugin-react-swc";
13
13
  import mdx from "@mdx-js/rollup";
14
14
  import remarkGfm from "remark-gfm";
15
15
  import rehypeHighlight from "rehype-highlight";
16
- import path4 from "path";
16
+ import path6 from "path";
17
17
  import { fileURLToPath as fileURLToPath2 } from "url";
18
- import { existsSync as existsSync2, readFileSync as readFileSync2 } from "fs";
18
+ import { existsSync as existsSync4, readFileSync as readFileSync2 } from "fs";
19
19
 
20
20
  // src/utils/cache.ts
21
21
  import { createHash } from "crypto";
@@ -324,10 +324,15 @@ function entryPlugin(rootDir) {
324
324
  if (command === "build" && rootDir) {
325
325
  tempHtmlPath = path3.join(rootDir, "index.html");
326
326
  writeFileSync(tempHtmlPath, getHtml(entryPath, true));
327
+ const existingInput = config.build?.rollupOptions?.input || {};
328
+ const inputObj = typeof existingInput === "string" ? { _original: existingInput } : Array.isArray(existingInput) ? Object.fromEntries(existingInput.map((f, i) => [`entry${i}`, f])) : existingInput;
327
329
  return {
328
330
  build: {
329
331
  rollupOptions: {
330
- input: tempHtmlPath
332
+ input: {
333
+ ...inputObj,
334
+ main: tempHtmlPath
335
+ }
331
336
  }
332
337
  }
333
338
  };
@@ -362,6 +367,73 @@ function entryPlugin(rootDir) {
362
367
  };
363
368
  }
364
369
 
370
+ // src/vite/previews.ts
371
+ import fg2 from "fast-glob";
372
+ import path4 from "path";
373
+ import { existsSync as existsSync2 } from "fs";
374
+ async function scanPreviews(rootDir) {
375
+ const previewsDir = path4.join(rootDir, "previews");
376
+ if (!existsSync2(previewsDir)) {
377
+ return [];
378
+ }
379
+ const htmlFiles = await fg2.glob("**/index.html", {
380
+ cwd: previewsDir,
381
+ ignore: ["node_modules/**"]
382
+ });
383
+ return htmlFiles.map((file) => {
384
+ const dir = path4.dirname(file);
385
+ const name = dir === "." ? path4.basename(path4.dirname(path4.join(previewsDir, file))) : dir;
386
+ return {
387
+ name,
388
+ route: `/_preview/${name}`,
389
+ htmlPath: path4.join(previewsDir, file)
390
+ };
391
+ });
392
+ }
393
+
394
+ // src/vite/plugins/previews-plugin.ts
395
+ import { existsSync as existsSync3, renameSync, mkdirSync, rmSync } from "fs";
396
+ import path5 from "path";
397
+ var VIRTUAL_MODULE_ID2 = "virtual:prev-previews";
398
+ var RESOLVED_VIRTUAL_MODULE_ID2 = "\x00" + VIRTUAL_MODULE_ID2;
399
+ function previewsPlugin(rootDir) {
400
+ return {
401
+ name: "prev-previews",
402
+ resolveId(id) {
403
+ if (id === VIRTUAL_MODULE_ID2) {
404
+ return RESOLVED_VIRTUAL_MODULE_ID2;
405
+ }
406
+ },
407
+ async load(id) {
408
+ if (id === RESOLVED_VIRTUAL_MODULE_ID2) {
409
+ const previews = await scanPreviews(rootDir);
410
+ return `export const previews = ${JSON.stringify(previews)};`;
411
+ }
412
+ },
413
+ handleHotUpdate({ file, server }) {
414
+ if (file.includes("/previews/") && file.endsWith(".html")) {
415
+ const mod = server.moduleGraph.getModuleById(RESOLVED_VIRTUAL_MODULE_ID2);
416
+ if (mod) {
417
+ server.moduleGraph.invalidateModule(mod);
418
+ return [mod];
419
+ }
420
+ }
421
+ },
422
+ closeBundle() {
423
+ const distDir = path5.join(rootDir, "dist");
424
+ const previewsDir = path5.join(distDir, "previews");
425
+ 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 });
430
+ }
431
+ renameSync(previewsDir, targetDir);
432
+ }
433
+ }
434
+ };
435
+ }
436
+
365
437
  // src/vite/config.ts
366
438
  function createFriendlyLogger() {
367
439
  const logger = createLogger("info", { allowClearScreen: false });
@@ -417,10 +489,10 @@ function createFriendlyLogger() {
417
489
  };
418
490
  }
419
491
  function findCliRoot2() {
420
- let dir = path4.dirname(fileURLToPath2(import.meta.url));
492
+ let dir = path6.dirname(fileURLToPath2(import.meta.url));
421
493
  for (let i = 0;i < 10; i++) {
422
- const pkgPath = path4.join(dir, "package.json");
423
- if (existsSync2(pkgPath)) {
494
+ const pkgPath = path6.join(dir, "package.json");
495
+ if (existsSync4(pkgPath)) {
424
496
  try {
425
497
  const pkg = JSON.parse(readFileSync2(pkgPath, "utf-8"));
426
498
  if (pkg.name === "prev-cli") {
@@ -428,24 +500,24 @@ function findCliRoot2() {
428
500
  }
429
501
  } catch {}
430
502
  }
431
- const parent = path4.dirname(dir);
503
+ const parent = path6.dirname(dir);
432
504
  if (parent === dir)
433
505
  break;
434
506
  dir = parent;
435
507
  }
436
- return path4.dirname(path4.dirname(fileURLToPath2(import.meta.url)));
508
+ return path6.dirname(path6.dirname(fileURLToPath2(import.meta.url)));
437
509
  }
438
510
  function findNodeModules(cliRoot2) {
439
- const localNodeModules = path4.join(cliRoot2, "node_modules");
440
- if (existsSync2(path4.join(localNodeModules, "react"))) {
511
+ const localNodeModules = path6.join(cliRoot2, "node_modules");
512
+ if (existsSync4(path6.join(localNodeModules, "react"))) {
441
513
  return localNodeModules;
442
514
  }
443
515
  let dir = cliRoot2;
444
516
  for (let i = 0;i < 10; i++) {
445
- const parent = path4.dirname(dir);
517
+ const parent = path6.dirname(dir);
446
518
  if (parent === dir)
447
519
  break;
448
- if (path4.basename(parent) === "node_modules" && existsSync2(path4.join(parent, "react"))) {
520
+ if (path6.basename(parent) === "node_modules" && existsSync4(path6.join(parent, "react"))) {
449
521
  return parent;
450
522
  }
451
523
  dir = parent;
@@ -454,10 +526,12 @@ function findNodeModules(cliRoot2) {
454
526
  }
455
527
  var cliRoot2 = findCliRoot2();
456
528
  var cliNodeModules = findNodeModules(cliRoot2);
457
- var srcRoot2 = path4.join(cliRoot2, "src");
529
+ var srcRoot2 = path6.join(cliRoot2, "src");
458
530
  async function createViteConfig(options) {
459
531
  const { rootDir, mode, port, include } = options;
460
532
  const cacheDir = await ensureCacheDir(rootDir);
533
+ const previews = await scanPreviews(rootDir);
534
+ const previewInputs = Object.fromEntries(previews.map((p) => [`_preview/${p.name}`, p.htmlPath]));
461
535
  return {
462
536
  root: rootDir,
463
537
  mode,
@@ -471,18 +545,46 @@ async function createViteConfig(options) {
471
545
  }),
472
546
  react(),
473
547
  pagesPlugin(rootDir, { include }),
474
- entryPlugin(rootDir)
548
+ entryPlugin(rootDir),
549
+ previewsPlugin(rootDir),
550
+ {
551
+ name: "prev-preview-server",
552
+ configureServer(server) {
553
+ server.middlewares.use(async (req, res, next) => {
554
+ if (req.url?.startsWith("/_preview/")) {
555
+ const previewName = decodeURIComponent(req.url.slice("/_preview/".length).split("?")[0]);
556
+ const previewsDir = path6.join(rootDir, "previews");
557
+ const htmlPath = path6.resolve(previewsDir, previewName, "index.html");
558
+ if (!htmlPath.startsWith(previewsDir)) {
559
+ return next();
560
+ }
561
+ if (existsSync4(htmlPath)) {
562
+ try {
563
+ const html = await server.transformIndexHtml(req.url, readFileSync2(htmlPath, "utf-8"));
564
+ res.setHeader("Content-Type", "text/html");
565
+ res.end(html);
566
+ return;
567
+ } catch (err) {
568
+ console.error("Error serving preview:", err);
569
+ return next();
570
+ }
571
+ }
572
+ }
573
+ next();
574
+ });
575
+ }
576
+ }
475
577
  ],
476
578
  resolve: {
477
579
  alias: {
478
- "@prev/ui": path4.join(srcRoot2, "ui"),
479
- "@prev/theme": path4.join(srcRoot2, "theme"),
480
- react: path4.join(cliNodeModules, "react"),
481
- "react-dom": path4.join(cliNodeModules, "react-dom"),
482
- "@tanstack/react-router": path4.join(cliNodeModules, "@tanstack/react-router"),
483
- mermaid: path4.join(cliNodeModules, "mermaid"),
484
- dayjs: path4.join(cliNodeModules, "dayjs"),
485
- "@terrastruct/d2": path4.join(cliNodeModules, "@terrastruct/d2")
580
+ "@prev/ui": path6.join(srcRoot2, "ui"),
581
+ "@prev/theme": path6.join(srcRoot2, "theme"),
582
+ react: path6.join(cliNodeModules, "react"),
583
+ "react-dom": path6.join(cliNodeModules, "react-dom"),
584
+ "@tanstack/react-router": path6.join(cliNodeModules, "@tanstack/react-router"),
585
+ mermaid: path6.join(cliNodeModules, "mermaid"),
586
+ dayjs: path6.join(cliNodeModules, "dayjs"),
587
+ "@terrastruct/d2": path6.join(cliNodeModules, "@terrastruct/d2")
486
588
  },
487
589
  dedupe: [
488
590
  "react",
@@ -515,8 +617,8 @@ async function createViteConfig(options) {
515
617
  },
516
618
  warmup: {
517
619
  clientFiles: [
518
- path4.join(srcRoot2, "theme/entry.tsx"),
519
- path4.join(srcRoot2, "theme/styles.css")
620
+ path6.join(srcRoot2, "theme/entry.tsx"),
621
+ path6.join(srcRoot2, "theme/styles.css")
520
622
  ]
521
623
  }
522
624
  },
@@ -525,9 +627,15 @@ async function createViteConfig(options) {
525
627
  strictPort: false
526
628
  },
527
629
  build: {
528
- outDir: path4.join(rootDir, "dist"),
630
+ outDir: path6.join(rootDir, "dist"),
529
631
  reportCompressedSize: false,
530
- chunkSizeWarningLimit: 1e4
632
+ chunkSizeWarningLimit: 1e4,
633
+ rollupOptions: {
634
+ input: {
635
+ main: path6.join(srcRoot2, "theme/index.html"),
636
+ ...previewInputs
637
+ }
638
+ }
531
639
  }
532
640
  };
533
641
  }
@@ -641,7 +749,7 @@ var { values, positionals } = parseArgs({
641
749
  allowPositionals: true
642
750
  });
643
751
  var command = positionals[0] || "dev";
644
- var rootDir = path5.resolve(values.cwd || positionals[1] || ".");
752
+ var rootDir = path7.resolve(values.cwd || positionals[1] || ".");
645
753
  function printHelp() {
646
754
  console.log(`
647
755
  prev - Zero-config documentation site generator
@@ -0,0 +1,2 @@
1
+ import type { Plugin } from 'vite';
2
+ export declare function previewsPlugin(rootDir: string): Plugin;
@@ -0,0 +1,6 @@
1
+ export interface Preview {
2
+ name: string;
3
+ route: string;
4
+ htmlPath: string;
5
+ }
6
+ export declare function scanPreviews(rootDir: string): Promise<Preview[]>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "prev-cli",
3
- "version": "0.9.1",
3
+ "version": "0.11.0",
4
4
  "description": "Transform MDX directories into beautiful documentation websites",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -40,6 +40,7 @@
40
40
  "test:all": "bun run test && bun run test:integration"
41
41
  },
42
42
  "dependencies": {
43
+ "@mdx-js/react": "^3.1.1",
43
44
  "@mdx-js/rollup": "^3.0.0",
44
45
  "@tailwindcss/vite": "^4.0.0",
45
46
  "@tanstack/react-router": "^1.145.7",
@@ -0,0 +1,382 @@
1
+ import React, { useState, useEffect, useRef, useCallback } from 'react'
2
+ import { Smartphone, Tablet, Monitor, SunMedium, Moon, Maximize2, Minimize2, GripVertical, SlidersHorizontal, X } from 'lucide-react'
3
+
4
+ interface PreviewProps {
5
+ src: string
6
+ height?: string | number
7
+ title?: string
8
+ }
9
+
10
+ type DeviceMode = 'mobile' | 'tablet' | 'desktop'
11
+
12
+ const DEVICE_WIDTHS: Record<DeviceMode, number | '100%'> = {
13
+ mobile: 375,
14
+ tablet: 768,
15
+ desktop: '100%',
16
+ }
17
+
18
+ interface Position {
19
+ x: number
20
+ y: number
21
+ }
22
+
23
+ export function Preview({ src, height = 400, title }: PreviewProps) {
24
+ const [isFullscreen, setIsFullscreen] = useState(false)
25
+ const [deviceMode, setDeviceMode] = useState<DeviceMode>('desktop')
26
+ const [customWidth, setCustomWidth] = useState<number | null>(null)
27
+ const [isDarkMode, setIsDarkMode] = useState(false)
28
+ const [showSlider, setShowSlider] = useState(false)
29
+ const [pillPosition, setPillPosition] = useState<Position>({ x: 0, y: 0 })
30
+ const [isDragging, setIsDragging] = useState(false)
31
+ const [dragOffset, setDragOffset] = useState<Position>({ x: 0, y: 0 })
32
+
33
+ const iframeRef = useRef<HTMLIFrameElement>(null)
34
+ const pillRef = useRef<HTMLDivElement>(null)
35
+ const containerRef = useRef<HTMLDivElement>(null)
36
+
37
+ const previewUrl = `/_preview/${src}`
38
+ const displayTitle = title || src
39
+
40
+ // Calculate current width
41
+ const currentWidth = customWidth ?? (DEVICE_WIDTHS[deviceMode] === '100%' ? null : DEVICE_WIDTHS[deviceMode] as number)
42
+
43
+ // Handle escape key and body scroll lock
44
+ useEffect(() => {
45
+ if (!isFullscreen) return
46
+
47
+ document.body.style.overflow = 'hidden'
48
+
49
+ const handleKeyDown = (e: KeyboardEvent) => {
50
+ if (e.key === 'Escape') {
51
+ setIsFullscreen(false)
52
+ }
53
+ }
54
+ document.addEventListener('keydown', handleKeyDown)
55
+
56
+ return () => {
57
+ document.body.style.overflow = ''
58
+ document.removeEventListener('keydown', handleKeyDown)
59
+ }
60
+ }, [isFullscreen])
61
+
62
+ // Toggle dark mode on iframe
63
+ useEffect(() => {
64
+ const iframe = iframeRef.current
65
+ if (!iframe) return
66
+
67
+ const applyDarkMode = () => {
68
+ try {
69
+ const doc = iframe.contentDocument
70
+ if (doc?.documentElement) {
71
+ if (isDarkMode) {
72
+ doc.documentElement.classList.add('dark')
73
+ } else {
74
+ doc.documentElement.classList.remove('dark')
75
+ }
76
+ }
77
+ } catch {
78
+ // Cross-origin iframe, can't access
79
+ }
80
+ }
81
+
82
+ // Apply on load and when mode changes
83
+ iframe.addEventListener('load', applyDarkMode)
84
+ applyDarkMode()
85
+
86
+ return () => {
87
+ iframe.removeEventListener('load', applyDarkMode)
88
+ }
89
+ }, [isDarkMode])
90
+
91
+ // Drag handlers
92
+ const handleMouseDown = useCallback((e: React.MouseEvent) => {
93
+ if (!pillRef.current) return
94
+ e.preventDefault()
95
+
96
+ const rect = pillRef.current.getBoundingClientRect()
97
+ setDragOffset({
98
+ x: e.clientX - rect.left,
99
+ y: e.clientY - rect.top,
100
+ })
101
+ setIsDragging(true)
102
+ }, [])
103
+
104
+ useEffect(() => {
105
+ if (!isDragging) return
106
+
107
+ const handleMouseMove = (e: MouseEvent) => {
108
+ const container = isFullscreen ? document.body : containerRef.current
109
+ if (!container) return
110
+
111
+ const containerRect = isFullscreen
112
+ ? { left: 0, top: 0, width: window.innerWidth, height: window.innerHeight }
113
+ : container.getBoundingClientRect()
114
+
115
+ const pillWidth = pillRef.current?.offsetWidth || 0
116
+ const pillHeight = pillRef.current?.offsetHeight || 0
117
+
118
+ // Calculate position relative to container
119
+ let newX = e.clientX - containerRect.left - dragOffset.x
120
+ let newY = e.clientY - containerRect.top - dragOffset.y
121
+
122
+ // Constrain to container bounds
123
+ newX = Math.max(0, Math.min(newX, containerRect.width - pillWidth))
124
+ newY = Math.max(0, Math.min(newY, containerRect.height - pillHeight))
125
+
126
+ setPillPosition({ x: newX, y: newY })
127
+ }
128
+
129
+ const handleMouseUp = () => {
130
+ setIsDragging(false)
131
+ }
132
+
133
+ document.addEventListener('mousemove', handleMouseMove)
134
+ document.addEventListener('mouseup', handleMouseUp)
135
+
136
+ return () => {
137
+ document.removeEventListener('mousemove', handleMouseMove)
138
+ document.removeEventListener('mouseup', handleMouseUp)
139
+ }
140
+ }, [isDragging, dragOffset, isFullscreen])
141
+
142
+ // Reset pill position when entering/exiting fullscreen
143
+ useEffect(() => {
144
+ setPillPosition({ x: 0, y: 0 })
145
+ }, [isFullscreen])
146
+
147
+ const handleDeviceChange = (mode: DeviceMode) => {
148
+ setDeviceMode(mode)
149
+ setCustomWidth(null)
150
+ setShowSlider(false)
151
+ }
152
+
153
+ const handleSliderChange = (value: number) => {
154
+ setCustomWidth(value)
155
+ setDeviceMode('desktop') // Clear device mode when using custom
156
+ }
157
+
158
+ // Icon button component
159
+ const IconButton = ({
160
+ onClick,
161
+ active,
162
+ title: btnTitle,
163
+ children
164
+ }: {
165
+ onClick: () => void
166
+ active?: boolean
167
+ title: string
168
+ children: React.ReactNode
169
+ }) => (
170
+ <button
171
+ onClick={onClick}
172
+ className={`p-1.5 rounded transition-colors ${
173
+ active
174
+ ? 'bg-blue-500 text-white'
175
+ : 'text-zinc-500 hover:bg-zinc-200 dark:hover:bg-zinc-700 hover:text-zinc-700 dark:hover:text-zinc-300'
176
+ }`}
177
+ title={btnTitle}
178
+ aria-label={btnTitle}
179
+ >
180
+ {children}
181
+ </button>
182
+ )
183
+
184
+ // Floating DevTools Pill
185
+ const DevToolsPill = () => {
186
+ const pillStyle: React.CSSProperties = pillPosition.x === 0 && pillPosition.y === 0
187
+ ? { bottom: 12, right: 12 }
188
+ : { left: pillPosition.x, top: pillPosition.y }
189
+
190
+ return (
191
+ <div
192
+ ref={pillRef}
193
+ className={`absolute z-50 flex items-center gap-0.5 px-1.5 py-1 bg-white dark:bg-zinc-800 rounded-lg shadow-lg border border-zinc-200 dark:border-zinc-700 ${
194
+ isDragging ? 'cursor-grabbing' : ''
195
+ }`}
196
+ style={pillStyle}
197
+ >
198
+ {/* Drag handle */}
199
+ <div
200
+ onMouseDown={handleMouseDown}
201
+ className="p-1 cursor-grab text-zinc-400 hover:text-zinc-600 dark:hover:text-zinc-300"
202
+ title="Drag to move"
203
+ >
204
+ <GripVertical className="w-3 h-3" />
205
+ </div>
206
+
207
+ <div className="w-px h-4 bg-zinc-200 dark:bg-zinc-700 mx-0.5" />
208
+
209
+ {/* Device modes */}
210
+ <IconButton
211
+ onClick={() => handleDeviceChange('mobile')}
212
+ active={deviceMode === 'mobile' && customWidth === null}
213
+ title="Mobile (375px)"
214
+ >
215
+ <Smartphone className="w-3.5 h-3.5" />
216
+ </IconButton>
217
+
218
+ <IconButton
219
+ onClick={() => handleDeviceChange('tablet')}
220
+ active={deviceMode === 'tablet' && customWidth === null}
221
+ title="Tablet (768px)"
222
+ >
223
+ <Tablet className="w-3.5 h-3.5" />
224
+ </IconButton>
225
+
226
+ <IconButton
227
+ onClick={() => handleDeviceChange('desktop')}
228
+ active={deviceMode === 'desktop' && customWidth === null}
229
+ title="Desktop (100%)"
230
+ >
231
+ <Monitor className="w-3.5 h-3.5" />
232
+ </IconButton>
233
+
234
+ <div className="w-px h-4 bg-zinc-200 dark:bg-zinc-700 mx-0.5" />
235
+
236
+ {/* Width slider toggle */}
237
+ <div className="relative">
238
+ <IconButton
239
+ onClick={() => setShowSlider(!showSlider)}
240
+ active={showSlider || customWidth !== null}
241
+ title="Custom width"
242
+ >
243
+ <SlidersHorizontal className="w-3.5 h-3.5" />
244
+ </IconButton>
245
+
246
+ {/* Slider popup */}
247
+ {showSlider && (
248
+ <div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 p-3 bg-white dark:bg-zinc-800 rounded-lg shadow-lg border border-zinc-200 dark:border-zinc-700 min-w-48">
249
+ <div className="flex items-center justify-between mb-2">
250
+ <span className="text-xs text-zinc-500">Width: {customWidth ?? currentWidth ?? '100%'}px</span>
251
+ <button
252
+ onClick={() => setShowSlider(false)}
253
+ className="p-0.5 text-zinc-400 hover:text-zinc-600"
254
+ >
255
+ <X className="w-3 h-3" />
256
+ </button>
257
+ </div>
258
+ <input
259
+ type="range"
260
+ min={320}
261
+ max={1920}
262
+ value={customWidth ?? (typeof currentWidth === 'number' ? currentWidth : 1920)}
263
+ onChange={(e) => handleSliderChange(parseInt(e.target.value))}
264
+ className="w-full accent-blue-500"
265
+ />
266
+ <div className="flex justify-between text-xs text-zinc-400 mt-1">
267
+ <span>320px</span>
268
+ <span>1920px</span>
269
+ </div>
270
+ </div>
271
+ )}
272
+ </div>
273
+
274
+ <div className="w-px h-4 bg-zinc-200 dark:bg-zinc-700 mx-0.5" />
275
+
276
+ {/* Dark mode toggle */}
277
+ <IconButton
278
+ onClick={() => setIsDarkMode(!isDarkMode)}
279
+ active={isDarkMode}
280
+ title={isDarkMode ? 'Light mode' : 'Dark mode'}
281
+ >
282
+ {isDarkMode ? <Moon className="w-3.5 h-3.5" /> : <SunMedium className="w-3.5 h-3.5" />}
283
+ </IconButton>
284
+
285
+ {/* Fullscreen toggle */}
286
+ <IconButton
287
+ onClick={() => setIsFullscreen(!isFullscreen)}
288
+ active={isFullscreen}
289
+ title={isFullscreen ? 'Exit fullscreen' : 'Fullscreen'}
290
+ >
291
+ {isFullscreen ? <Minimize2 className="w-3.5 h-3.5" /> : <Maximize2 className="w-3.5 h-3.5" />}
292
+ </IconButton>
293
+ </div>
294
+ )
295
+ }
296
+
297
+ // Calculate iframe style
298
+ const getIframeContainerStyle = (): React.CSSProperties => {
299
+ if (currentWidth === null) {
300
+ return { width: '100%' }
301
+ }
302
+ return {
303
+ width: currentWidth,
304
+ maxWidth: '100%',
305
+ margin: '0 auto',
306
+ }
307
+ }
308
+
309
+ if (isFullscreen) {
310
+ return (
311
+ <div className="fixed inset-0 z-40 bg-zinc-100 dark:bg-zinc-900 flex items-start justify-center overflow-auto">
312
+ {/* Checkered background pattern */}
313
+ <div
314
+ className="absolute inset-0 opacity-50"
315
+ style={{
316
+ backgroundImage: 'linear-gradient(45deg, #e5e5e5 25%, transparent 25%), linear-gradient(-45deg, #e5e5e5 25%, transparent 25%), linear-gradient(45deg, transparent 75%, #e5e5e5 75%), linear-gradient(-45deg, transparent 75%, #e5e5e5 75%)',
317
+ backgroundSize: '20px 20px',
318
+ backgroundPosition: '0 0, 0 10px, 10px -10px, -10px 0px',
319
+ }}
320
+ />
321
+
322
+ {/* Iframe container */}
323
+ <div
324
+ className="relative bg-white dark:bg-zinc-900 shadow-2xl transition-all duration-300 h-full"
325
+ style={getIframeContainerStyle()}
326
+ >
327
+ <iframe
328
+ ref={iframeRef}
329
+ src={previewUrl}
330
+ className="w-full h-full"
331
+ title={displayTitle}
332
+ />
333
+ </div>
334
+
335
+ <DevToolsPill />
336
+ </div>
337
+ )
338
+ }
339
+
340
+ return (
341
+ <div
342
+ ref={containerRef}
343
+ className="my-4 border border-zinc-200 dark:border-zinc-700 rounded-lg overflow-hidden relative"
344
+ >
345
+ {/* Header */}
346
+ <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>
353
+ </div>
354
+
355
+ {/* Preview area with checkered background */}
356
+ <div
357
+ className="relative bg-zinc-100 dark:bg-zinc-900"
358
+ style={{
359
+ backgroundImage: 'linear-gradient(45deg, #e5e5e5 25%, transparent 25%), linear-gradient(-45deg, #e5e5e5 25%, transparent 25%), linear-gradient(45deg, transparent 75%, #e5e5e5 75%), linear-gradient(-45deg, transparent 75%, #e5e5e5 75%)',
360
+ backgroundSize: '16px 16px',
361
+ backgroundPosition: '0 0, 0 8px, 8px -8px, -8px 0px',
362
+ }}
363
+ >
364
+ {/* Iframe container */}
365
+ <div
366
+ className="bg-white dark:bg-zinc-900 transition-all duration-300"
367
+ style={getIframeContainerStyle()}
368
+ >
369
+ <iframe
370
+ ref={iframeRef}
371
+ src={previewUrl}
372
+ style={{ height: typeof height === 'number' ? `${height}px` : height }}
373
+ className="w-full"
374
+ title={displayTitle}
375
+ />
376
+ </div>
377
+
378
+ <DevToolsPill />
379
+ </div>
380
+ </div>
381
+ )
382
+ }
@@ -7,10 +7,12 @@ import {
7
7
  createRoute,
8
8
  Outlet,
9
9
  } from '@tanstack/react-router'
10
+ import { MDXProvider } from '@mdx-js/react'
10
11
  import { pages, sidebar } from 'virtual:prev-pages'
11
12
  import { useDiagrams } from './diagrams'
12
13
  import { Layout } from './Layout'
13
14
  import { MetadataBlock } from './MetadataBlock'
15
+ import { mdxComponents } from './mdx-components'
14
16
  import './styles.css'
15
17
 
16
18
  // PageTree types (simplified from fumadocs-core)
@@ -72,12 +74,12 @@ interface PageMeta {
72
74
  function PageWrapper({ Component, meta }: { Component: React.ComponentType; meta: PageMeta }) {
73
75
  useDiagrams()
74
76
  return (
75
- <>
77
+ <MDXProvider components={mdxComponents}>
76
78
  {meta.frontmatter && Object.keys(meta.frontmatter).length > 0 && (
77
79
  <MetadataBlock frontmatter={meta.frontmatter} />
78
80
  )}
79
81
  <Component />
80
- </>
82
+ </MDXProvider>
81
83
  )
82
84
  }
83
85
 
@@ -0,0 +1,5 @@
1
+ import { Preview } from './Preview'
2
+
3
+ export const mdxComponents = {
4
+ Preview,
5
+ }