prev-cli 0.10.0 → 0.11.1

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
@@ -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");
@@ -549,25 +549,42 @@ async function createViteConfig(options) {
549
549
  previewsPlugin(rootDir),
550
550
  {
551
551
  name: "prev-preview-server",
552
+ resolveId(id) {
553
+ if (id.startsWith("/_preview/")) {
554
+ const relativePath = id.slice("/_preview/".length);
555
+ const previewsDir = path6.join(rootDir, "previews");
556
+ const resolved = path6.resolve(previewsDir, relativePath);
557
+ if (resolved.startsWith(previewsDir)) {
558
+ return resolved;
559
+ }
560
+ }
561
+ },
552
562
  configureServer(server) {
553
563
  server.middlewares.use(async (req, res, next) => {
554
564
  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);
565
+ const urlPath = req.url.split("?")[0];
566
+ const isHtmlRequest = !path6.extname(urlPath) || urlPath.endsWith("/");
567
+ if (isHtmlRequest) {
568
+ const previewName = decodeURIComponent(urlPath.slice("/_preview/".length).replace(/\/$/, ""));
569
+ const previewsDir = path6.join(rootDir, "previews");
570
+ const htmlPath = path6.resolve(previewsDir, previewName, "index.html");
571
+ if (!htmlPath.startsWith(previewsDir)) {
569
572
  return next();
570
573
  }
574
+ if (existsSync4(htmlPath)) {
575
+ try {
576
+ let html = readFileSync2(htmlPath, "utf-8");
577
+ const previewBase = `/_preview/${previewName}/`;
578
+ html = html.replace(/(src|href)=["']\.\/([^"']+)["']/g, `$1="${previewBase}$2"`);
579
+ const transformed = await server.transformIndexHtml(req.url, html);
580
+ res.setHeader("Content-Type", "text/html");
581
+ res.end(transformed);
582
+ return;
583
+ } catch (err) {
584
+ console.error("Error serving preview:", err);
585
+ return next();
586
+ }
587
+ }
571
588
  }
572
589
  }
573
590
  next();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "prev-cli",
3
- "version": "0.10.0",
3
+ "version": "0.11.1",
4
4
  "description": "Transform MDX directories into beautiful documentation websites",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -1,5 +1,5 @@
1
- import React, { useState, useEffect } from 'react'
2
- import { Maximize2, Minimize2, ExternalLink } from 'lucide-react'
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
3
 
4
4
  interface PreviewProps {
5
5
  src: string
@@ -7,18 +7,45 @@ interface PreviewProps {
7
7
  title?: string
8
8
  }
9
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
+
10
23
  export function Preview({ src, height = 400, title }: PreviewProps) {
11
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
+
12
37
  const previewUrl = `/_preview/${src}`
13
38
  const displayTitle = title || src
14
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
15
44
  useEffect(() => {
16
45
  if (!isFullscreen) return
17
46
 
18
- // Lock body scroll
19
47
  document.body.style.overflow = 'hidden'
20
48
 
21
- // Handle escape key
22
49
  const handleKeyDown = (e: KeyboardEvent) => {
23
50
  if (e.key === 'Escape') {
24
51
  setIsFullscreen(false)
@@ -32,74 +59,324 @@ export function Preview({ src, height = 400, title }: PreviewProps) {
32
59
  }
33
60
  }, [isFullscreen])
34
61
 
35
- if (isFullscreen) {
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
+
36
190
  return (
37
- <div className="fixed inset-0 z-50 bg-white dark:bg-zinc-900">
38
- <div className="flex items-center justify-between p-2 border-b border-zinc-200 dark:border-zinc-700">
39
- <span className="text-sm font-medium">{displayTitle}</span>
40
- <div className="flex gap-2">
41
- <a
42
- href={previewUrl}
43
- target="_blank"
44
- rel="noopener noreferrer"
45
- className="p-1.5 hover:bg-zinc-100 dark:hover:bg-zinc-800 rounded"
46
- title="Open in new tab"
47
- aria-label="Open in new tab"
48
- >
49
- <ExternalLink className="w-4 h-4" />
50
- </a>
51
- <button
52
- onClick={() => setIsFullscreen(false)}
53
- className="p-1.5 hover:bg-zinc-100 dark:hover:bg-zinc-800 rounded"
54
- title="Exit fullscreen"
55
- aria-label="Exit fullscreen"
56
- >
57
- <Minimize2 className="w-4 h-4" />
58
- </button>
59
- </div>
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" />
60
205
  </div>
61
- <iframe
62
- src={previewUrl}
63
- className="w-full h-[calc(100vh-49px)]"
64
- title={displayTitle}
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
+ }}
65
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 />
66
336
  </div>
67
337
  )
68
338
  }
69
339
 
70
340
  return (
71
- <div className="my-4 border border-zinc-200 dark:border-zinc-700 rounded-lg overflow-hidden">
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 */}
72
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">
73
347
  <span className="text-sm font-medium text-zinc-600 dark:text-zinc-400">
74
348
  {displayTitle}
75
349
  </span>
76
- <div className="flex gap-1">
77
- <a
78
- href={previewUrl}
79
- target="_blank"
80
- rel="noopener noreferrer"
81
- className="p-1.5 hover:bg-zinc-200 dark:hover:bg-zinc-700 rounded"
82
- title="Open in new tab"
83
- aria-label="Open in new tab"
84
- >
85
- <ExternalLink className="w-4 h-4 text-zinc-500" />
86
- </a>
87
- <button
88
- onClick={() => setIsFullscreen(true)}
89
- className="p-1.5 hover:bg-zinc-200 dark:hover:bg-zinc-700 rounded"
90
- title="Fullscreen"
91
- aria-label="Enter fullscreen"
92
- >
93
- <Maximize2 className="w-4 h-4 text-zinc-500" />
94
- </button>
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
+ />
95
376
  </div>
377
+
378
+ <DevToolsPill />
96
379
  </div>
97
- <iframe
98
- src={previewUrl}
99
- style={{ height: typeof height === 'number' ? `${height}px` : height }}
100
- className="w-full"
101
- title={displayTitle}
102
- />
103
380
  </div>
104
381
  )
105
382
  }