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 +21 -6
- package/dist/vite/config.d.ts +1 -0
- package/dist/vite/start.d.ts +1 -0
- package/package.json +2 -1
- package/src/theme/Preview.tsx +4 -1
- package/src/theme/TOCPanel.css +34 -6
- package/src/theme/Toolbar.css +44 -0
- package/src/theme/Toolbar.tsx +13 -3
- package/src/theme/entry.tsx +58 -89
- package/src/theme/mdx-components.tsx +74 -0
- package/src/theme/styles.css +182 -0
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
|
-
|
|
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 });
|
package/dist/vite/config.d.ts
CHANGED
package/dist/vite/start.d.ts
CHANGED
|
@@ -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.
|
|
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",
|
package/src/theme/Preview.tsx
CHANGED
|
@@ -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}` :
|
|
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
|
package/src/theme/TOCPanel.css
CHANGED
|
@@ -120,8 +120,20 @@
|
|
|
120
120
|
position: fixed;
|
|
121
121
|
inset: 0;
|
|
122
122
|
z-index: 9998;
|
|
123
|
-
background: rgba(0, 0, 0, 0.
|
|
124
|
-
backdrop-filter: blur(
|
|
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:
|
|
144
|
+
max-height: 70vh;
|
|
133
145
|
background: var(--fd-background);
|
|
134
|
-
border-radius:
|
|
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(
|
|
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) {
|
package/src/theme/Toolbar.css
CHANGED
|
@@ -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
|
}
|
package/src/theme/Toolbar.tsx
CHANGED
|
@@ -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
|
package/src/theme/entry.tsx
CHANGED
|
@@ -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: '
|
|
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: '
|
|
108
|
+
backgroundColor: 'var(--fd-muted)',
|
|
109
109
|
borderRadius: '8px',
|
|
110
|
-
fontFamily: '
|
|
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
|
|
120
|
-
<div style={{ marginBottom: '
|
|
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: '
|
|
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
|
|
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: '
|
|
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: '
|
|
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
|
|
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
|
-
|
|
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
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
{/*
|
|
244
|
-
{
|
|
245
|
-
<div
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
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
|
-
|
|
268
|
-
|
|
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
|
|
281
|
-
|
|
282
|
-
|
|
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
|
}
|
package/src/theme/styles.css
CHANGED
|
@@ -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
|
+
}
|