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 +136 -28
- package/dist/vite/plugins/previews-plugin.d.ts +2 -0
- package/dist/vite/previews.d.ts +6 -0
- package/package.json +2 -1
- package/src/theme/Preview.tsx +382 -0
- package/src/theme/entry.tsx +4 -2
- package/src/theme/mdx-components.tsx +5 -0
package/dist/cli.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
// src/cli.ts
|
|
4
4
|
import { parseArgs } from "util";
|
|
5
|
-
import
|
|
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
|
|
16
|
+
import path6 from "path";
|
|
17
17
|
import { fileURLToPath as fileURLToPath2 } from "url";
|
|
18
|
-
import { existsSync as
|
|
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:
|
|
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 =
|
|
492
|
+
let dir = path6.dirname(fileURLToPath2(import.meta.url));
|
|
421
493
|
for (let i = 0;i < 10; i++) {
|
|
422
|
-
const pkgPath =
|
|
423
|
-
if (
|
|
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 =
|
|
503
|
+
const parent = path6.dirname(dir);
|
|
432
504
|
if (parent === dir)
|
|
433
505
|
break;
|
|
434
506
|
dir = parent;
|
|
435
507
|
}
|
|
436
|
-
return
|
|
508
|
+
return path6.dirname(path6.dirname(fileURLToPath2(import.meta.url)));
|
|
437
509
|
}
|
|
438
510
|
function findNodeModules(cliRoot2) {
|
|
439
|
-
const localNodeModules =
|
|
440
|
-
if (
|
|
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 =
|
|
517
|
+
const parent = path6.dirname(dir);
|
|
446
518
|
if (parent === dir)
|
|
447
519
|
break;
|
|
448
|
-
if (
|
|
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 =
|
|
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":
|
|
479
|
-
"@prev/theme":
|
|
480
|
-
react:
|
|
481
|
-
"react-dom":
|
|
482
|
-
"@tanstack/react-router":
|
|
483
|
-
mermaid:
|
|
484
|
-
dayjs:
|
|
485
|
-
"@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
|
-
|
|
519
|
-
|
|
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:
|
|
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 =
|
|
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
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "prev-cli",
|
|
3
|
-
"version": "0.
|
|
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
|
+
}
|
package/src/theme/entry.tsx
CHANGED
|
@@ -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
|
|