prev-cli 0.19.2 → 0.21.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
@@ -11,7 +11,7 @@ import { createServer as createServer2, build as build2, preview } from "vite";
11
11
 
12
12
  // src/vite/config.ts
13
13
  import { createLogger } from "vite";
14
- import react from "@vitejs/plugin-react-swc";
14
+ import react from "@vitejs/plugin-react";
15
15
  import mdx from "@mdx-js/rollup";
16
16
  import remarkGfm from "remark-gfm";
17
17
  import rehypeHighlight from "rehype-highlight";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "prev-cli",
3
- "version": "0.19.2",
3
+ "version": "0.21.0",
4
4
  "description": "Transform MDX directories into beautiful documentation websites",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -45,9 +45,10 @@
45
45
  "@tailwindcss/vite": "^4.0.0",
46
46
  "@tanstack/react-router": "^1.145.7",
47
47
  "@terrastruct/d2": "^0.1.33",
48
- "@vitejs/plugin-react-swc": "^4.2.2",
48
+ "@vitejs/plugin-react": "^5.1.2",
49
49
  "class-variance-authority": "^0.7.0",
50
50
  "clsx": "^2.1.0",
51
+ "esbuild": "^0.27.2",
51
52
  "fast-glob": "^3.3.0",
52
53
  "fumadocs-core": "^16.4.3",
53
54
  "fumadocs-ui": "^16.4.3",
@@ -59,9 +60,9 @@
59
60
  "react-router-dom": "^7.0.0",
60
61
  "rehype-highlight": "^7.0.0",
61
62
  "remark-gfm": "^4.0.0",
62
- "rolldown-vite": "^7.3.0",
63
63
  "tailwind-merge": "^2.5.0",
64
- "tailwindcss": "^4.0.0"
64
+ "tailwindcss": "^4.0.0",
65
+ "vite": "npm:rolldown-vite@^7.3.1"
65
66
  },
66
67
  "devDependencies": {
67
68
  "@types/js-yaml": "^4.0.9",
@@ -0,0 +1,39 @@
1
+ import React, { createContext, useContext, useState, useCallback } from 'react'
2
+
3
+ interface DevToolsContextValue {
4
+ devToolsContent: React.ReactNode | null
5
+ setDevToolsContent: (content: React.ReactNode | null) => void
6
+ }
7
+
8
+ const DevToolsContext = createContext<DevToolsContextValue | null>(null)
9
+
10
+ export function DevToolsProvider({ children }: { children: React.ReactNode }) {
11
+ const [devToolsContent, setDevToolsContent] = useState<React.ReactNode | null>(null)
12
+
13
+ return (
14
+ <DevToolsContext.Provider value={{ devToolsContent, setDevToolsContent }}>
15
+ {children}
16
+ </DevToolsContext.Provider>
17
+ )
18
+ }
19
+
20
+ // Fallback for when context isn't available (e.g., SSR or standalone usage)
21
+ const fallbackContext: DevToolsContextValue = {
22
+ devToolsContent: null,
23
+ setDevToolsContent: () => {},
24
+ }
25
+
26
+ export function useDevTools() {
27
+ const context = useContext(DevToolsContext)
28
+ // Return fallback instead of throwing - safer for SSR and edge cases
29
+ return context ?? fallbackContext
30
+ }
31
+
32
+ export function useRegisterDevTools(content: React.ReactNode | null, deps: any[] = []) {
33
+ const { setDevToolsContent } = useDevTools()
34
+
35
+ React.useEffect(() => {
36
+ setDevToolsContent(content)
37
+ return () => setDevToolsContent(null)
38
+ }, deps)
39
+ }
@@ -1,6 +1,7 @@
1
1
  import React, { useState, useEffect, useRef } from 'react'
2
2
  import { Link } from '@tanstack/react-router'
3
3
  import { Icon, IconSprite } from './icons'
4
+ import { useDevTools } from './DevToolsContext'
4
5
  import type { PreviewConfig, PreviewMessage, BuildResult } from '../preview-runtime/types'
5
6
 
6
7
  interface PreviewProps {
@@ -32,8 +33,16 @@ export function Preview({ src, height = 400, title, mode = 'wasm', showHeader =
32
33
 
33
34
  const iframeRef = useRef<HTMLIFrameElement>(null)
34
35
 
35
- // URL depends on mode - wasm mode needs src param
36
- const previewUrl = mode === 'wasm' ? `/_preview-runtime?src=${src}` : `/_preview/${src}`
36
+ // Get devtools context for toolbar integration
37
+ const { setDevToolsContent } = useDevTools()
38
+
39
+ // In production, always use pre-built static previews
40
+ // In dev, use WASM runtime for live bundling
41
+ const isDev = import.meta.env?.DEV ?? false
42
+ const effectiveMode = isDev ? mode : 'legacy'
43
+
44
+ // URL depends on mode - wasm mode needs src param, legacy uses pre-built files
45
+ const previewUrl = effectiveMode === 'wasm' ? `/_preview-runtime?src=${src}` : `/_preview/${src}/`
37
46
  const displayTitle = title || src
38
47
 
39
48
  // Calculate current width
@@ -89,9 +98,9 @@ export function Preview({ src, height = 400, title, mode = 'wasm', showHeader =
89
98
  }
90
99
  }, [isDarkMode])
91
100
 
92
- // WASM preview: Initialize and send config to iframe
101
+ // WASM preview: Initialize and send config to iframe (dev mode only)
93
102
  useEffect(() => {
94
- if (mode !== 'wasm') return
103
+ if (effectiveMode !== 'wasm') return
95
104
 
96
105
  const iframe = iframeRef.current
97
106
  if (!iframe) return
@@ -141,8 +150,8 @@ export function Preview({ src, height = 400, title, mode = 'wasm', showHeader =
141
150
  }
142
151
  }, [mode, src])
143
152
 
144
- const handleDeviceChange = (mode: DeviceMode) => {
145
- setDeviceMode(mode)
153
+ const handleDeviceChange = (newMode: DeviceMode) => {
154
+ setDeviceMode(newMode)
146
155
  setCustomWidth(null)
147
156
  setShowSlider(false)
148
157
  }
@@ -152,7 +161,7 @@ export function Preview({ src, height = 400, title, mode = 'wasm', showHeader =
152
161
  setDeviceMode('desktop')
153
162
  }
154
163
 
155
- // Icon button component
164
+ // Icon button component for devtools
156
165
  const IconButton = ({
157
166
  onClick,
158
167
  active,
@@ -176,7 +185,7 @@ export function Preview({ src, height = 400, title, mode = 'wasm', showHeader =
176
185
  justifyContent: 'center',
177
186
  transition: 'background-color 0.15s, color 0.15s',
178
187
  backgroundColor: active ? 'var(--fd-primary, #3b82f6)' : 'transparent',
179
- color: active ? '#fff' : 'var(--fd-muted-foreground, #71717a)',
188
+ color: active ? 'var(--fd-primary-foreground, #fff)' : 'var(--fd-muted-foreground, #71717a)',
180
189
  }}
181
190
  title={btnTitle}
182
191
  aria-label={btnTitle}
@@ -275,6 +284,14 @@ export function Preview({ src, height = 400, title, mode = 'wasm', showHeader =
275
284
  </div>
276
285
  )
277
286
 
287
+ // Register DevTools in toolbar when on detail page (showHeader mode)
288
+ useEffect(() => {
289
+ if (showHeader && !isFullscreen) {
290
+ setDevToolsContent(<DevTools />)
291
+ return () => setDevToolsContent(null)
292
+ }
293
+ }, [showHeader, isFullscreen, deviceMode, customWidth, showSlider])
294
+
278
295
  // Calculate iframe style
279
296
  const getIframeContainerStyle = (): React.CSSProperties => {
280
297
  if (currentWidth === null) {
@@ -346,10 +363,11 @@ export function Preview({ src, height = 400, title, mode = 'wasm', showHeader =
346
363
  }
347
364
 
348
365
  // Non-fullscreen with showHeader (individual preview page)
366
+ // DevTools are rendered in the toolbar via context, not in this header
349
367
  if (showHeader) {
350
368
  return (
351
369
  <div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
352
- {/* Header with back button and devtools */}
370
+ {/* Header with back button and build status - DevTools are in toolbar */}
353
371
  <div style={{
354
372
  display: 'flex',
355
373
  alignItems: 'center',
@@ -357,7 +375,6 @@ export function Preview({ src, height = 400, title, mode = 'wasm', showHeader =
357
375
  padding: '12px 16px',
358
376
  backgroundColor: 'var(--fd-card, #fafafa)',
359
377
  borderBottom: '1px solid var(--fd-border, #e4e4e7)',
360
- borderRadius: '8px 8px 0 0',
361
378
  }}>
362
379
  <div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
363
380
  <Link
@@ -385,7 +402,6 @@ export function Preview({ src, height = 400, title, mode = 'wasm', showHeader =
385
402
  <span style={{ fontSize: '12px', color: 'var(--fd-muted-foreground, #a1a1aa)' }}>{buildTime}ms</span>
386
403
  )}
387
404
  </div>
388
- <DevTools />
389
405
  </div>
390
406
 
391
407
  {/* Build error display */}
@@ -43,13 +43,53 @@
43
43
  }
44
44
 
45
45
  .toolbar-devtools-slot {
46
- display: contents;
46
+ display: flex;
47
+ align-items: center;
48
+ gap: 0.125rem;
49
+ padding-left: 0.25rem;
50
+ margin-left: 0.25rem;
51
+ border-left: 1px solid var(--fd-border);
47
52
  }
48
53
 
49
54
  .toolbar-devtools-slot:empty {
50
55
  display: none;
51
56
  }
52
57
 
58
+ /* Style devtools buttons in toolbar */
59
+ .toolbar-devtools-slot button {
60
+ width: 32px;
61
+ height: 32px;
62
+ padding: 4px;
63
+ border-radius: 50%;
64
+ background: transparent;
65
+ border: none;
66
+ color: var(--fd-muted-foreground);
67
+ cursor: pointer;
68
+ transition: all 0.15s ease;
69
+ display: flex;
70
+ align-items: center;
71
+ justify-content: center;
72
+ }
73
+
74
+ .toolbar-devtools-slot button:hover {
75
+ background: var(--fd-muted);
76
+ color: var(--fd-foreground);
77
+ }
78
+
79
+ /* Override active state for toolbar devtools buttons */
80
+ .toolbar-devtools-slot button[style*="background-color: var(--fd-primary"] {
81
+ background: var(--fd-accent) !important;
82
+ color: var(--fd-accent-foreground) !important;
83
+ }
84
+
85
+ /* Adjust slider dropdown position when in toolbar (opens upward) */
86
+ .toolbar-devtools-slot [style*="position: absolute"] {
87
+ bottom: 100% !important;
88
+ top: auto !important;
89
+ margin-bottom: 8px !important;
90
+ margin-top: 0 !important;
91
+ }
92
+
53
93
  /* Hide width toggle on mobile */
54
94
  @media (max-width: 768px) {
55
95
  .toolbar-btn.desktop-only {
@@ -3,6 +3,7 @@ import { Link, useLocation } from '@tanstack/react-router'
3
3
  import type { PageTree } from 'fumadocs-core/server'
4
4
  import { previews } from 'virtual:prev-previews'
5
5
  import { Icon } from './icons'
6
+ import { useDevTools } from './DevToolsContext'
6
7
  import './Toolbar.css'
7
8
 
8
9
  interface ToolbarProps {
@@ -22,6 +23,7 @@ export function Toolbar({ tree, onThemeToggle, onWidthToggle, isDark, isFullWidt
22
23
  const toolbarRef = useRef<HTMLDivElement>(null)
23
24
  const location = useLocation()
24
25
  const isOnPreviews = location.pathname.startsWith('/previews')
26
+ const { devToolsContent } = useDevTools()
25
27
 
26
28
  const handleMouseDown = (e: React.MouseEvent) => {
27
29
  if ((e.target as HTMLElement).closest('button, a')) return
@@ -70,8 +72,12 @@ export function Toolbar({ tree, onThemeToggle, onWidthToggle, isDark, isFullWidt
70
72
  </Link>
71
73
  )}
72
74
 
73
- {/* Contextual devtools slot - populated by preview context */}
74
- <div className="toolbar-devtools-slot" id="toolbar-devtools" />
75
+ {/* Contextual devtools - rendered from preview context */}
76
+ {devToolsContent && (
77
+ <div className="toolbar-devtools-slot">
78
+ {devToolsContent}
79
+ </div>
80
+ )}
75
81
 
76
82
  <button
77
83
  className="toolbar-btn desktop-only"
@@ -8,6 +8,9 @@ import {
8
8
  Outlet,
9
9
  redirect,
10
10
  Navigate,
11
+ useLocation,
12
+ useParams,
13
+ Link,
11
14
  } from '@tanstack/react-router'
12
15
  import { MDXProvider } from '@mdx-js/react'
13
16
  import { pages, sidebar } from 'virtual:prev-pages'
@@ -17,6 +20,7 @@ import { useDiagrams } from './diagrams'
17
20
  import { Layout } from './Layout'
18
21
  import { MetadataBlock } from './MetadataBlock'
19
22
  import { mdxComponents } from './mdx-components'
23
+ import { DevToolsProvider } from './DevToolsContext'
20
24
  import './styles.css'
21
25
 
22
26
  // PageTree types (simplified from fumadocs-core)
@@ -151,10 +155,61 @@ function PreviewsCatalog() {
151
155
  )
152
156
  }
153
157
 
154
- // Individual preview card - clickable thumbnail
155
- import { Link, useParams } from '@tanstack/react-router'
158
+ // Individual preview card - clickable thumbnail with WASM preview communication
159
+ import type { PreviewConfig, PreviewMessage } from '../preview-runtime/types'
156
160
 
157
161
  function PreviewCard({ name }: { name: string }) {
162
+ const iframeRef = React.useRef<HTMLIFrameElement>(null)
163
+ const [isLoaded, setIsLoaded] = React.useState(false)
164
+
165
+ // In production, use pre-built static files; in dev, use WASM runtime
166
+ const isDev = import.meta.env?.DEV ?? false
167
+ const previewUrl = isDev ? `/_preview-runtime?src=${name}` : `/_preview/${name}/`
168
+
169
+ // Set up WASM preview communication for thumbnail (dev mode only)
170
+ React.useEffect(() => {
171
+ if (!isDev) {
172
+ // In production, just mark as loaded when iframe loads
173
+ const iframe = iframeRef.current
174
+ if (iframe) {
175
+ iframe.onload = () => setIsLoaded(true)
176
+ }
177
+ return
178
+ }
179
+
180
+ const iframe = iframeRef.current
181
+ if (!iframe) return
182
+
183
+ let configSent = false
184
+
185
+ const handleMessage = (event: MessageEvent) => {
186
+ const msg = event.data as PreviewMessage
187
+
188
+ if (msg.type === 'ready' && !configSent) {
189
+ configSent = true
190
+
191
+ fetch(`/_preview-config/${name}`)
192
+ .then(res => res.json())
193
+ .then((config: PreviewConfig) => {
194
+ iframe.contentWindow?.postMessage({ type: 'init', config } as PreviewMessage, '*')
195
+ })
196
+ .catch(() => {
197
+ // Silently fail for thumbnails
198
+ })
199
+ }
200
+
201
+ if (msg.type === 'built') {
202
+ setIsLoaded(true)
203
+ }
204
+ }
205
+
206
+ window.addEventListener('message', handleMessage)
207
+
208
+ return () => {
209
+ window.removeEventListener('message', handleMessage)
210
+ }
211
+ }, [name, isDev])
212
+
158
213
  return (
159
214
  <Link
160
215
  to={`/previews/${name}`}
@@ -185,19 +240,40 @@ function PreviewCard({ name }: { name: string }) {
185
240
  backgroundColor: 'var(--fd-muted)',
186
241
  pointerEvents: 'none',
187
242
  }}>
243
+ {/* Loading spinner */}
244
+ {!isLoaded && (
245
+ <div style={{
246
+ position: 'absolute',
247
+ inset: 0,
248
+ display: 'flex',
249
+ alignItems: 'center',
250
+ justifyContent: 'center',
251
+ backgroundColor: 'var(--fd-muted)',
252
+ zIndex: 1,
253
+ }}>
254
+ <div style={{
255
+ width: '24px',
256
+ height: '24px',
257
+ border: '2px solid var(--fd-border)',
258
+ borderTopColor: 'var(--fd-primary)',
259
+ borderRadius: '50%',
260
+ animation: 'spin 1s linear infinite',
261
+ }} />
262
+ </div>
263
+ )}
188
264
  <iframe
189
- src={`/_preview-runtime?src=${name}`}
265
+ ref={iframeRef}
266
+ src={previewUrl}
190
267
  style={{
191
- width: '100%',
192
- height: '100%',
193
268
  border: 'none',
194
269
  transform: 'scale(0.5)',
195
270
  transformOrigin: 'top left',
196
271
  width: '200%',
197
272
  height: '200%',
273
+ opacity: isLoaded ? 1 : 0,
274
+ transition: 'opacity 0.3s',
198
275
  }}
199
276
  title={name}
200
- loading="lazy"
201
277
  />
202
278
  </div>
203
279
  {/* Card footer */}
@@ -221,15 +297,37 @@ function PreviewCard({ name }: { name: string }) {
221
297
  )
222
298
  }
223
299
 
224
- // Individual preview page - full view with devtools in header
300
+ // Individual preview page - full view with devtools in toolbar
225
301
  function PreviewPage() {
226
- const { name } = useParams({ from: '/previews/$name' })
227
- return <Preview src={name} height="calc(100vh - 200px)" showHeader />
302
+ const params = useParams({ strict: false })
303
+ // Splat param captures the full path after /previews/
304
+ const name = (params as any)['_splat'] || (params as any)['*'] || params.name as string
305
+
306
+ if (!name) {
307
+ return <Navigate to="/previews" />
308
+ }
309
+
310
+ return (
311
+ <div className="preview-detail-page">
312
+ <Preview src={name} height="100%" showHeader />
313
+ </div>
314
+ )
228
315
  }
229
316
 
230
317
  // Root layout with custom lightweight Layout
231
318
  function RootLayout() {
232
319
  const pageTree = convertToPageTree(sidebar)
320
+ const location = useLocation()
321
+ const isPreviewDetail = location.pathname.startsWith('/previews/') && location.pathname !== '/previews'
322
+
323
+ // Preview detail page gets full viewport layout
324
+ if (isPreviewDetail) {
325
+ return (
326
+ <Layout tree={pageTree}>
327
+ <Outlet />
328
+ </Layout>
329
+ )
330
+ }
233
331
 
234
332
  return (
235
333
  <Layout tree={pageTree}>
@@ -252,10 +350,10 @@ const previewsRoute = createRoute({
252
350
  component: PreviewsCatalog,
253
351
  })
254
352
 
255
- // Individual preview route
353
+ // Individual preview route (uses splat to capture nested paths like buttons/primary)
256
354
  const previewDetailRoute = createRoute({
257
355
  getParentRoute: () => rootRoute,
258
- path: '/previews/$name',
356
+ path: '/previews/$',
259
357
  component: PreviewPage,
260
358
  })
261
359
 
@@ -307,5 +405,9 @@ const router = createRouter({
307
405
  const container = document.getElementById('root')
308
406
  if (container) {
309
407
  const root = createRoot(container)
310
- root.render(<RouterProvider router={router} />)
408
+ root.render(
409
+ <DevToolsProvider>
410
+ <RouterProvider router={router} />
411
+ </DevToolsProvider>
412
+ )
311
413
  }
@@ -523,6 +523,13 @@ body {
523
523
  max-width: 100%;
524
524
  }
525
525
 
526
+ /* Mobile: add bottom padding for floating toolbar */
527
+ @media (max-width: 768px) {
528
+ .prev-main-floating {
529
+ padding-bottom: 5rem;
530
+ }
531
+ }
532
+
526
533
  .prev-main-floating .prev-content {
527
534
  max-width: 72ch;
528
535
  margin: 0 auto;
@@ -531,3 +538,32 @@ body {
531
538
  .full-width .prev-main-floating .prev-content {
532
539
  max-width: none;
533
540
  }
541
+
542
+ /* Preview detail page - full available space */
543
+ .preview-detail-page {
544
+ height: calc(100vh - 4rem);
545
+ padding: 0;
546
+ margin: -2rem; /* Negate parent padding */
547
+ margin-bottom: 3rem; /* Space for toolbar */
548
+ }
549
+
550
+ .preview-detail-page > div {
551
+ height: 100%;
552
+ border-radius: 0;
553
+ overflow: hidden;
554
+ }
555
+
556
+ @media (min-width: 769px) {
557
+ .preview-detail-page {
558
+ margin: -2rem;
559
+ margin-bottom: 0;
560
+ height: calc(100vh - 2rem);
561
+ padding: 1rem;
562
+ }
563
+
564
+ .preview-detail-page > div {
565
+ border-radius: 12px;
566
+ border: 1px solid var(--fd-border);
567
+ box-shadow: 0 4px 24px rgba(0, 0, 0, 0.08);
568
+ }
569
+ }