prev-cli 0.22.4 → 0.23.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
@@ -130,13 +130,18 @@ function isIndexFile(basename) {
130
130
  const lower = basename.toLowerCase();
131
131
  return lower === "index" || lower === "readme";
132
132
  }
133
+ var CONTENT_ROOT_DIRS = ["docs", "documentation", "content", "pages"];
133
134
  function fileToRoute(file) {
134
- const normalizedFile = file.replace(/^\./, "").replace(/\/\./g, "/");
135
+ let normalizedFile = file.replace(/^\./, "").replace(/\/\./g, "/");
136
+ const firstDir = normalizedFile.split("/")[0]?.toLowerCase();
137
+ if (CONTENT_ROOT_DIRS.includes(firstDir)) {
138
+ normalizedFile = normalizedFile.slice(firstDir.length + 1) || normalizedFile;
139
+ }
135
140
  const withoutExt = normalizedFile.replace(/\.mdx?$/, "");
136
141
  const basename = path2.basename(withoutExt).toLowerCase();
137
142
  if (basename === "index" || basename === "readme") {
138
143
  const dir = path2.dirname(withoutExt);
139
- if (dir === ".") {
144
+ if (dir === "." || dir === "") {
140
145
  return "/";
141
146
  }
142
147
  return "/" + dir;
@@ -867,13 +872,14 @@ var cliRoot2 = findCliRoot2();
867
872
  var cliNodeModules = findNodeModules(cliRoot2);
868
873
  var srcRoot2 = path8.join(cliRoot2, "src");
869
874
  async function createViteConfig(options) {
870
- const { rootDir, mode, port, include } = options;
875
+ const { rootDir, mode, port, include, base } = options;
871
876
  const cacheDir = await ensureCacheDir(rootDir);
872
877
  const config = loadConfig(rootDir);
873
878
  return {
874
879
  root: rootDir,
875
880
  mode,
876
881
  cacheDir,
882
+ base: base || "/",
877
883
  customLogger: createFriendlyLogger(),
878
884
  logLevel: mode === "production" ? "silent" : "info",
879
885
  plugins: [
@@ -1118,7 +1124,7 @@ async function findAvailablePort(minPort, maxPort) {
1118
1124
 
1119
1125
  // src/vite/start.ts
1120
1126
  import { exec as exec2 } from "child_process";
1121
- import { existsSync as existsSync6, rmSync as rmSync2 } from "fs";
1127
+ import { existsSync as existsSync6, rmSync as rmSync2, copyFileSync } from "fs";
1122
1128
  import path9 from "path";
1123
1129
  function printWelcome(type) {
1124
1130
  console.log();
@@ -1224,9 +1230,16 @@ async function buildSite(rootDir, options = {}) {
1224
1230
  const config = await createViteConfig({
1225
1231
  rootDir,
1226
1232
  mode: "production",
1227
- include: options.include
1233
+ include: options.include,
1234
+ base: options.base
1228
1235
  });
1229
1236
  await build2(config);
1237
+ const distDir = path9.join(rootDir, "dist");
1238
+ const indexPath = path9.join(distDir, "index.html");
1239
+ const notFoundPath = path9.join(distDir, "404.html");
1240
+ if (existsSync6(indexPath)) {
1241
+ copyFileSync(indexPath, notFoundPath);
1242
+ }
1230
1243
  console.log();
1231
1244
  console.log(" Done! Your site is ready in ./dist");
1232
1245
  console.log(" You can deploy this folder anywhere.");
@@ -1272,6 +1285,7 @@ var { values, positionals } = parseArgs({
1272
1285
  port: { type: "string", short: "p" },
1273
1286
  days: { type: "string", short: "d" },
1274
1287
  cwd: { type: "string", short: "c" },
1288
+ base: { type: "string", short: "b" },
1275
1289
  help: { type: "boolean", short: "h" },
1276
1290
  version: { type: "boolean", short: "v" }
1277
1291
  },
@@ -1301,6 +1315,7 @@ Config subcommands:
1301
1315
  Options:
1302
1316
  -c, --cwd <path> Set working directory
1303
1317
  -p, --port <port> Specify port (dev/preview)
1318
+ -b, --base <path> Base path for deployment (e.g., /repo-name/ for GitHub Pages)
1304
1319
  -d, --days <days> Cache age threshold for clean (default: 30)
1305
1320
  -h, --help Show this help message
1306
1321
  -v, --version Show version number
@@ -1660,7 +1675,7 @@ async function main() {
1660
1675
  await startDev(rootDir, { port, include });
1661
1676
  break;
1662
1677
  case "build":
1663
- await buildSite(rootDir, { include });
1678
+ await buildSite(rootDir, { include, base: values.base });
1664
1679
  break;
1665
1680
  case "preview":
1666
1681
  await previewSite(rootDir, { port, include });
@@ -4,5 +4,6 @@ export interface ConfigOptions {
4
4
  mode: 'development' | 'production';
5
5
  port?: number;
6
6
  include?: string[];
7
+ base?: string;
7
8
  }
8
9
  export declare function createViteConfig(options: ConfigOptions): Promise<InlineConfig>;
@@ -4,6 +4,7 @@ export interface DevOptions {
4
4
  }
5
5
  export interface BuildOptions {
6
6
  include?: string[];
7
+ base?: string;
7
8
  }
8
9
  export declare function startDev(rootDir: string, options?: DevOptions): Promise<import("vite").ViteDevServer>;
9
10
  export declare function buildSite(rootDir: string, options?: BuildOptions): Promise<void>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "prev-cli",
3
- "version": "0.22.4",
3
+ "version": "0.23.0",
4
4
  "description": "Transform MDX directories into beautiful documentation websites",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -35,6 +35,7 @@
35
35
  },
36
36
  "scripts": {
37
37
  "build": "bun build src/cli.ts --outdir dist --target node --packages external && tsc --emitDeclarationOnly",
38
+ "build:docs": "bun run build && bun ./dist/cli.js build",
38
39
  "dev": "tsc --watch",
39
40
  "test": "bun test src",
40
41
  "test:integration": "bun run build && bun test test/integration.test.ts",
@@ -41,8 +41,11 @@ export function Preview({ src, height = 400, title, mode = 'wasm', showHeader =
41
41
  const isDev = import.meta.env?.DEV ?? false
42
42
  const effectiveMode = isDev ? mode : 'legacy'
43
43
 
44
+ // Get base URL for proper subpath deployment support
45
+ const baseUrl = (import.meta.env?.BASE_URL ?? '/').replace(/\/$/, '')
46
+
44
47
  // 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}/`
48
+ const previewUrl = effectiveMode === 'wasm' ? `/_preview-runtime?src=${src}` : `${baseUrl}/_preview/${src}/`
46
49
  const displayTitle = title || src
47
50
 
48
51
  // Calculate current width
@@ -120,8 +120,20 @@
120
120
  position: fixed;
121
121
  inset: 0;
122
122
  z-index: 9998;
123
- background: rgba(0, 0, 0, 0.5);
124
- backdrop-filter: blur(4px);
123
+ background: rgba(0, 0, 0, 0.4);
124
+ backdrop-filter: blur(8px);
125
+ -webkit-backdrop-filter: blur(8px);
126
+ animation: fade-in-overlay 0.2s ease;
127
+ }
128
+
129
+ @keyframes fade-in-overlay {
130
+ from { opacity: 0; }
131
+ to { opacity: 1; }
132
+ }
133
+
134
+ @keyframes slide-up-sheet {
135
+ from { transform: translateY(100%); }
136
+ to { transform: translateY(0); }
125
137
  }
126
138
 
127
139
  .toc-overlay-content {
@@ -129,15 +141,31 @@
129
141
  bottom: 0;
130
142
  left: 0;
131
143
  right: 0;
132
- max-height: 80vh;
144
+ max-height: 70vh;
133
145
  background: var(--fd-background);
134
- border-radius: 1rem 1rem 0 0;
146
+ border-radius: 1.25rem 1.25rem 0 0;
135
147
  overflow: hidden;
148
+ box-shadow: 0 -4px 24px rgba(0, 0, 0, 0.12);
149
+ animation: slide-up-sheet 0.25s ease;
150
+ /* Bottom safe area for toolbar overlap prevention */
151
+ padding-bottom: calc(env(safe-area-inset-bottom, 0px) + 72px);
152
+ }
153
+
154
+ /* Bottom sheet drag indicator */
155
+ .toc-overlay-content::before {
156
+ content: '';
157
+ display: block;
158
+ width: 36px;
159
+ height: 4px;
160
+ background: var(--fd-border);
161
+ border-radius: 2px;
162
+ margin: 8px auto;
136
163
  }
137
164
 
138
165
  .toc-overlay-content .toc-nav {
139
- max-height: calc(80vh - 60px);
140
- padding: 1rem;
166
+ max-height: calc(70vh - 120px);
167
+ padding: 0.5rem 1rem 1rem;
168
+ overscroll-behavior: contain;
141
169
  }
142
170
 
143
171
  @media (max-width: 768px) {
@@ -11,12 +11,31 @@
11
11
  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
12
12
  cursor: grab;
13
13
  user-select: none;
14
+ transition: transform 0.2s ease, box-shadow 0.2s ease;
14
15
  }
15
16
 
16
17
  .prev-toolbar:active {
17
18
  cursor: grabbing;
18
19
  }
19
20
 
21
+ /* Mobile: center toolbar at bottom with safe area */
22
+ @media (max-width: 768px) {
23
+ .prev-toolbar {
24
+ left: 50% !important;
25
+ bottom: calc(env(safe-area-inset-bottom, 0px) + 16px) !important;
26
+ top: auto !important;
27
+ transform: translateX(-50%);
28
+ cursor: default;
29
+ padding: 0.5rem 0.75rem;
30
+ gap: 0.375rem;
31
+ box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
32
+ }
33
+
34
+ .prev-toolbar:active {
35
+ cursor: default;
36
+ }
37
+ }
38
+
20
39
  .toolbar-btn {
21
40
  display: flex;
22
41
  align-items: center;
@@ -30,6 +49,7 @@
30
49
  cursor: pointer;
31
50
  transition: all 0.15s ease;
32
51
  text-decoration: none;
52
+ -webkit-tap-highlight-color: transparent;
33
53
  }
34
54
 
35
55
  .toolbar-btn:hover {
@@ -37,11 +57,23 @@
37
57
  color: var(--fd-foreground);
38
58
  }
39
59
 
60
+ .toolbar-btn:active {
61
+ transform: scale(0.92);
62
+ }
63
+
40
64
  .toolbar-btn.active {
41
65
  background: var(--fd-accent);
42
66
  color: var(--fd-accent-foreground);
43
67
  }
44
68
 
69
+ /* Mobile: larger touch targets */
70
+ @media (max-width: 768px) {
71
+ .toolbar-btn {
72
+ width: 40px;
73
+ height: 40px;
74
+ }
75
+ }
76
+
45
77
  .toolbar-devtools-slot {
46
78
  display: flex;
47
79
  align-items: center;
@@ -95,4 +127,16 @@
95
127
  .toolbar-btn.desktop-only {
96
128
  display: none;
97
129
  }
130
+
131
+ /* Simplify devtools on mobile - hide separator and some controls */
132
+ .toolbar-devtools-slot {
133
+ gap: 0.25rem;
134
+ margin-left: 0.375rem;
135
+ padding-left: 0.375rem;
136
+ }
137
+
138
+ /* Hide custom width slider button on mobile */
139
+ .toolbar-devtools-slot > div:has(button[title="Custom width"]) {
140
+ display: none;
141
+ }
98
142
  }
@@ -19,20 +19,30 @@ interface ToolbarProps {
19
19
  export function Toolbar({ tree, onThemeToggle, onWidthToggle, isDark, isFullWidth, onTocToggle, tocOpen }: ToolbarProps) {
20
20
  const [position, setPosition] = useState({ x: 20, y: typeof window !== 'undefined' ? window.innerHeight - 80 : 600 })
21
21
  const [dragging, setDragging] = useState(false)
22
+ const [isMobile, setIsMobile] = useState(typeof window !== 'undefined' ? window.innerWidth <= 768 : false)
22
23
  const dragStart = useRef({ x: 0, y: 0 })
23
24
  const toolbarRef = useRef<HTMLDivElement>(null)
24
25
  const location = useLocation()
25
26
  const isOnPreviews = location.pathname.startsWith('/previews')
26
27
  const { devToolsContent } = useDevTools()
27
28
 
29
+ // Track mobile state
30
+ useEffect(() => {
31
+ const handleResize = () => setIsMobile(window.innerWidth <= 768)
32
+ window.addEventListener('resize', handleResize)
33
+ return () => window.removeEventListener('resize', handleResize)
34
+ }, [])
35
+
28
36
  const handleMouseDown = (e: React.MouseEvent) => {
37
+ // Disable dragging on mobile
38
+ if (isMobile) return
29
39
  if ((e.target as HTMLElement).closest('button, a')) return
30
40
  setDragging(true)
31
41
  dragStart.current = { x: e.clientX - position.x, y: e.clientY - position.y }
32
42
  }
33
43
 
34
44
  useEffect(() => {
35
- if (!dragging) return
45
+ if (!dragging || isMobile) return
36
46
 
37
47
  const handleMouseMove = (e: MouseEvent) => {
38
48
  setPosition({
@@ -49,13 +59,13 @@ export function Toolbar({ tree, onThemeToggle, onWidthToggle, isDark, isFullWidt
49
59
  document.removeEventListener('mousemove', handleMouseMove)
50
60
  document.removeEventListener('mouseup', handleMouseUp)
51
61
  }
52
- }, [dragging])
62
+ }, [dragging, isMobile])
53
63
 
54
64
  return (
55
65
  <div
56
66
  ref={toolbarRef}
57
67
  className="prev-toolbar"
58
- style={{ left: position.x, top: position.y }}
68
+ style={isMobile ? undefined : { left: position.x, top: position.y }}
59
69
  onMouseDown={handleMouseDown}
60
70
  >
61
71
  <button
@@ -99,15 +99,15 @@ function PreviewsCatalog() {
99
99
  <h1 style={{ fontSize: '24px', fontWeight: 'bold', marginBottom: '16px' }}>
100
100
  No Previews Found
101
101
  </h1>
102
- <p style={{ color: '#666', marginBottom: '24px' }}>
102
+ <p style={{ color: 'var(--fd-muted-foreground)', marginBottom: '24px' }}>
103
103
  Create your first preview with:
104
104
  </p>
105
105
  <code style={{
106
106
  display: 'inline-block',
107
107
  padding: '12px 20px',
108
- backgroundColor: '#f4f4f5',
108
+ backgroundColor: 'var(--fd-muted)',
109
109
  borderRadius: '8px',
110
- fontFamily: 'monospace',
110
+ fontFamily: 'var(--fd-font-mono)',
111
111
  }}>
112
112
  prev create my-demo
113
113
  </code>
@@ -116,37 +116,33 @@ function PreviewsCatalog() {
116
116
  }
117
117
 
118
118
  return (
119
- <div style={{ padding: '20px' }}>
120
- <div style={{ marginBottom: '32px' }}>
119
+ <div className="previews-catalog">
120
+ <div style={{ marginBottom: '24px' }}>
121
121
  <h1 style={{ fontSize: '28px', fontWeight: 'bold', marginBottom: '8px' }}>
122
122
  Previews
123
123
  </h1>
124
- <p style={{ color: '#666' }}>
124
+ <p style={{ color: 'var(--fd-muted-foreground)', margin: 0 }}>
125
125
  {previews.length} component preview{previews.length !== 1 ? 's' : ''} available.
126
126
  Click any preview to open it.
127
127
  </p>
128
128
  </div>
129
129
 
130
- <div style={{
131
- display: 'grid',
132
- gridTemplateColumns: 'repeat(auto-fill, minmax(320px, 1fr))',
133
- gap: '20px',
134
- }}>
130
+ <div className="previews-grid">
135
131
  {previews.map((preview: { name: string; route: string }) => (
136
132
  <PreviewCard key={preview.name} name={preview.name} />
137
133
  ))}
138
134
  </div>
139
135
 
140
136
  <div style={{
141
- marginTop: '40px',
142
- padding: '16px',
137
+ marginTop: '32px',
138
+ padding: '14px 16px',
143
139
  backgroundColor: 'var(--fd-muted)',
144
140
  border: '1px solid var(--fd-border)',
145
- borderRadius: '8px',
141
+ borderRadius: '10px',
146
142
  }}>
147
143
  <p style={{ margin: 0, fontSize: '14px', color: 'var(--fd-muted-foreground)' }}>
148
- <strong>Tip:</strong> Embed any preview in your MDX docs with{' '}
149
- <code style={{ backgroundColor: 'var(--fd-accent)', padding: '2px 6px', borderRadius: '4px' }}>
144
+ <strong style={{ color: 'var(--fd-foreground)' }}>Tip:</strong> Embed any preview in your MDX docs with{' '}
145
+ <code style={{ backgroundColor: 'var(--fd-accent)', padding: '2px 6px', borderRadius: '4px', fontFamily: 'var(--fd-font-mono)' }}>
150
146
  {'<Preview src="name" />'}
151
147
  </code>
152
148
  </p>
@@ -161,10 +157,23 @@ import type { PreviewConfig, PreviewMessage } from '../preview-runtime/types'
161
157
  function PreviewCard({ name }: { name: string }) {
162
158
  const iframeRef = React.useRef<HTMLIFrameElement>(null)
163
159
  const [isLoaded, setIsLoaded] = React.useState(false)
160
+ const [loadError, setLoadError] = React.useState(false)
164
161
 
165
162
  // In production, use pre-built static files; in dev, use WASM runtime
166
163
  const isDev = import.meta.env?.DEV ?? false
167
- const previewUrl = isDev ? `/_preview-runtime?src=${name}` : `/_preview/${name}/`
164
+ const baseUrl = (import.meta.env?.BASE_URL ?? '/').replace(/\/$/, '')
165
+ const previewUrl = isDev ? `/_preview-runtime?src=${name}` : `${baseUrl}/_preview/${name}/`
166
+
167
+ // Timeout for loading - show placeholder if too slow
168
+ React.useEffect(() => {
169
+ const timeout = setTimeout(() => {
170
+ if (!isLoaded) {
171
+ setLoadError(true)
172
+ }
173
+ }, 5000) // 5 second timeout
174
+
175
+ return () => clearTimeout(timeout)
176
+ }, [isLoaded])
168
177
 
169
178
  // Set up WASM preview communication for thumbnail (dev mode only)
170
179
  React.useEffect(() => {
@@ -173,6 +182,7 @@ function PreviewCard({ name }: { name: string }) {
173
182
  const iframe = iframeRef.current
174
183
  if (iframe) {
175
184
  iframe.onload = () => setIsLoaded(true)
185
+ iframe.onerror = () => setLoadError(true)
176
186
  }
177
187
  return
178
188
  }
@@ -194,13 +204,17 @@ function PreviewCard({ name }: { name: string }) {
194
204
  iframe.contentWindow?.postMessage({ type: 'init', config } as PreviewMessage, '*')
195
205
  })
196
206
  .catch(() => {
197
- // Silently fail for thumbnails
207
+ setLoadError(true)
198
208
  })
199
209
  }
200
210
 
201
211
  if (msg.type === 'built') {
202
212
  setIsLoaded(true)
203
213
  }
214
+
215
+ if (msg.type === 'error') {
216
+ setLoadError(true)
217
+ }
204
218
  }
205
219
 
206
220
  window.addEventListener('message', handleMessage)
@@ -211,87 +225,38 @@ function PreviewCard({ name }: { name: string }) {
211
225
  }, [name, isDev])
212
226
 
213
227
  return (
214
- <Link
215
- to={`/previews/${name}`}
216
- style={{
217
- display: 'block',
218
- border: '1px solid var(--fd-border)',
219
- borderRadius: '12px',
220
- overflow: 'hidden',
221
- backgroundColor: 'var(--fd-background)',
222
- textDecoration: 'none',
223
- color: 'inherit',
224
- transition: 'box-shadow 0.2s, transform 0.2s',
225
- }}
226
- onMouseOver={(e) => {
227
- e.currentTarget.style.boxShadow = '0 8px 24px rgba(0,0,0,0.12)'
228
- e.currentTarget.style.transform = 'translateY(-2px)'
229
- }}
230
- onMouseOut={(e) => {
231
- e.currentTarget.style.boxShadow = ''
232
- e.currentTarget.style.transform = ''
233
- }}
234
- >
228
+ <Link to={`/previews/${name}`} className="preview-card">
235
229
  {/* Thumbnail preview */}
236
- <div style={{
237
- height: '180px',
238
- overflow: 'hidden',
239
- position: 'relative',
240
- backgroundColor: 'var(--fd-muted)',
241
- pointerEvents: 'none',
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
- }} />
230
+ <div className="preview-card-thumbnail">
231
+ {/* Loading state */}
232
+ {!isLoaded && !loadError && (
233
+ <div className="preview-card-loading">
234
+ <div className="preview-card-spinner" />
235
+ </div>
236
+ )}
237
+ {/* Error/timeout placeholder */}
238
+ {loadError && (
239
+ <div className="preview-card-placeholder">
240
+ <svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
241
+ <rect x="3" y="3" width="18" height="18" rx="2" />
242
+ <path d="M9 9l6 6m0-6l-6 6" />
243
+ </svg>
244
+ <span>Preview</span>
262
245
  </div>
263
246
  )}
264
247
  <iframe
265
248
  ref={iframeRef}
266
249
  src={previewUrl}
267
- style={{
268
- border: 'none',
269
- transform: 'scale(0.5)',
270
- transformOrigin: 'top left',
271
- width: '200%',
272
- height: '200%',
273
- opacity: isLoaded ? 1 : 0,
274
- transition: 'opacity 0.3s',
275
- }}
250
+ className="preview-card-iframe"
251
+ style={{ opacity: isLoaded && !loadError ? 1 : 0 }}
276
252
  title={name}
253
+ loading="lazy"
277
254
  />
278
255
  </div>
279
256
  {/* Card footer */}
280
- <div style={{
281
- padding: '12px 16px',
282
- borderTop: '1px solid var(--fd-border)',
283
- backgroundColor: 'var(--fd-card)',
284
- }}>
285
- <h3 style={{ fontSize: '14px', fontWeight: 600, margin: 0 }}>
286
- {name}
287
- </h3>
288
- <code style={{
289
- fontSize: '11px',
290
- color: 'var(--fd-muted-foreground)',
291
- fontFamily: 'monospace',
292
- }}>
293
- previews/{name}/
294
- </code>
257
+ <div className="preview-card-footer">
258
+ <h3 className="preview-card-title">{name}</h3>
259
+ <code className="preview-card-path">previews/{name}/</code>
295
260
  </div>
296
261
  </Link>
297
262
  )
@@ -408,8 +373,12 @@ const routeTree = rootRoute.addChildren([
408
373
  ...(indexRedirectRoute ? [indexRedirectRoute] : []),
409
374
  ...pageRoutes,
410
375
  ])
376
+ // Get base path for subpath deployments (e.g., GitHub Pages)
377
+ const basepath = (import.meta.env?.BASE_URL ?? '/').replace(/\/$/, '') || '/'
378
+
411
379
  const router = createRouter({
412
380
  routeTree,
381
+ basepath,
413
382
  defaultNotFoundComponent: NotFoundPage,
414
383
  })
415
384
 
@@ -1,5 +1,79 @@
1
+ import React from 'react'
2
+ import { Link } from '@tanstack/react-router'
1
3
  import { Preview } from './Preview'
4
+ import { pages } from 'virtual:prev-pages'
5
+
6
+ // Get valid routes from pages
7
+ const validRoutes = new Set(pages.map((p: { route: string }) => p.route))
8
+
9
+ // Also add /previews routes
10
+ validRoutes.add('/previews')
11
+
12
+ // Check if a path is an internal link
13
+ function isInternalLink(href: string): boolean {
14
+ if (!href) return false
15
+ // External links start with http://, https://, mailto:, tel:, etc.
16
+ if (/^(https?:|mailto:|tel:|#)/.test(href)) return false
17
+ // Relative or absolute internal paths
18
+ return href.startsWith('/') || !href.includes(':')
19
+ }
20
+
21
+ // Check if an internal route exists
22
+ function routeExists(href: string): boolean {
23
+ if (!href) return true
24
+ // Remove hash and query string
25
+ const path = href.split(/[?#]/)[0]
26
+ // Check exact match or if it's a valid preview route
27
+ if (validRoutes.has(path)) return true
28
+ // Check if it starts with /previews/ (dynamic preview routes)
29
+ if (path.startsWith('/previews/')) return true
30
+ return false
31
+ }
32
+
33
+ // Custom link component that validates internal links and uses router
34
+ function MdxLink({ href, children, ...props }: React.AnchorHTMLAttributes<HTMLAnchorElement>) {
35
+ const isInternal = isInternalLink(href || '')
36
+ const isDev = import.meta.env?.DEV ?? false
37
+
38
+ // For internal links, use TanStack Router's Link
39
+ if (isInternal && href) {
40
+ const exists = routeExists(href)
41
+
42
+ // In dev mode, show warning for dead links
43
+ if (isDev && !exists) {
44
+ return (
45
+ <span
46
+ className="dead-link"
47
+ title={`Dead link: "${href}" does not match any known route`}
48
+ {...props}
49
+ >
50
+ {children}
51
+ <span className="dead-link-icon" aria-label="Dead link">⚠️</span>
52
+ </span>
53
+ )
54
+ }
55
+
56
+ return (
57
+ <Link to={href} {...props}>
58
+ {children}
59
+ </Link>
60
+ )
61
+ }
62
+
63
+ // External links open in new tab
64
+ return (
65
+ <a
66
+ href={href}
67
+ target="_blank"
68
+ rel="noopener noreferrer"
69
+ {...props}
70
+ >
71
+ {children}
72
+ </a>
73
+ )
74
+ }
2
75
 
3
76
  export const mdxComponents = {
4
77
  Preview,
78
+ a: MdxLink,
5
79
  }
@@ -567,3 +567,185 @@ body {
567
567
  box-shadow: 0 4px 24px rgba(0, 0, 0, 0.08);
568
568
  }
569
569
  }
570
+
571
+ /* ===========================================
572
+ Previews Catalog - Mobile-First Grid
573
+ =========================================== */
574
+
575
+ .previews-catalog {
576
+ padding: 16px;
577
+ }
578
+
579
+ @media (min-width: 640px) {
580
+ .previews-catalog {
581
+ padding: 20px;
582
+ }
583
+ }
584
+
585
+ .previews-grid {
586
+ display: grid;
587
+ grid-template-columns: 1fr;
588
+ gap: 16px;
589
+ }
590
+
591
+ @media (min-width: 480px) {
592
+ .previews-grid {
593
+ grid-template-columns: repeat(2, 1fr);
594
+ }
595
+ }
596
+
597
+ @media (min-width: 768px) {
598
+ .previews-grid {
599
+ grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
600
+ gap: 20px;
601
+ }
602
+ }
603
+
604
+ /* Preview card enhancements */
605
+ .previews-grid a {
606
+ display: block;
607
+ border: 1px solid var(--fd-border);
608
+ border-radius: 12px;
609
+ overflow: hidden;
610
+ background-color: var(--fd-background);
611
+ text-decoration: none;
612
+ color: inherit;
613
+ transition: box-shadow 0.2s ease, transform 0.2s ease, border-color 0.2s ease;
614
+ }
615
+
616
+ .previews-grid a:hover {
617
+ box-shadow: 0 8px 24px rgba(0, 0, 0, 0.1);
618
+ transform: translateY(-2px);
619
+ border-color: var(--fd-primary);
620
+ }
621
+
622
+ .previews-grid a:active {
623
+ transform: translateY(0);
624
+ }
625
+
626
+ /* Preview Card Component Styles */
627
+ .preview-card-thumbnail {
628
+ height: 140px;
629
+ overflow: hidden;
630
+ position: relative;
631
+ background-color: var(--fd-muted);
632
+ pointer-events: none;
633
+ }
634
+
635
+ @media (min-width: 480px) {
636
+ .preview-card-thumbnail {
637
+ height: 160px;
638
+ }
639
+ }
640
+
641
+ @media (min-width: 768px) {
642
+ .preview-card-thumbnail {
643
+ height: 180px;
644
+ }
645
+ }
646
+
647
+ .preview-card-loading {
648
+ position: absolute;
649
+ inset: 0;
650
+ display: flex;
651
+ align-items: center;
652
+ justify-content: center;
653
+ background-color: var(--fd-muted);
654
+ z-index: 1;
655
+ }
656
+
657
+ .preview-card-spinner {
658
+ width: 24px;
659
+ height: 24px;
660
+ border: 2px solid var(--fd-border);
661
+ border-top-color: var(--fd-primary);
662
+ border-radius: 50%;
663
+ animation: spin 1s linear infinite;
664
+ }
665
+
666
+ .preview-card-placeholder {
667
+ position: absolute;
668
+ inset: 0;
669
+ display: flex;
670
+ flex-direction: column;
671
+ align-items: center;
672
+ justify-content: center;
673
+ gap: 8px;
674
+ background-color: var(--fd-muted);
675
+ color: var(--fd-muted-foreground);
676
+ z-index: 1;
677
+ }
678
+
679
+ .preview-card-placeholder span {
680
+ font-size: 12px;
681
+ font-weight: 500;
682
+ }
683
+
684
+ .preview-card-iframe {
685
+ border: none;
686
+ transform: scale(0.5);
687
+ transform-origin: top left;
688
+ width: 200%;
689
+ height: 200%;
690
+ transition: opacity 0.3s ease;
691
+ }
692
+
693
+ .preview-card-footer {
694
+ padding: 12px 14px;
695
+ border-top: 1px solid var(--fd-border);
696
+ background-color: var(--fd-card);
697
+ }
698
+
699
+ .preview-card-title {
700
+ font-size: 14px;
701
+ font-weight: 600;
702
+ margin: 0 0 2px 0;
703
+ color: var(--fd-foreground);
704
+ }
705
+
706
+ .preview-card-path {
707
+ font-size: 11px;
708
+ color: var(--fd-muted-foreground);
709
+ font-family: var(--fd-font-mono);
710
+ }
711
+
712
+ /* ===========================================
713
+ Dead Link Warning (Dev Mode Only)
714
+ =========================================== */
715
+
716
+ .dead-link {
717
+ color: #dc2626;
718
+ text-decoration: line-through;
719
+ text-decoration-color: #dc2626;
720
+ cursor: not-allowed;
721
+ position: relative;
722
+ }
723
+
724
+ .dead-link-icon {
725
+ font-size: 0.75em;
726
+ margin-left: 0.25em;
727
+ vertical-align: super;
728
+ }
729
+
730
+ /* Tooltip on hover */
731
+ .dead-link::after {
732
+ content: attr(title);
733
+ position: absolute;
734
+ bottom: 100%;
735
+ left: 50%;
736
+ transform: translateX(-50%);
737
+ padding: 6px 10px;
738
+ background: #1f2937;
739
+ color: #fff;
740
+ font-size: 12px;
741
+ border-radius: 6px;
742
+ white-space: nowrap;
743
+ opacity: 0;
744
+ pointer-events: none;
745
+ transition: opacity 0.15s ease;
746
+ z-index: 100;
747
+ }
748
+
749
+ .dead-link:hover::after {
750
+ opacity: 1;
751
+ }