prev-cli 0.7.1 → 0.8.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 +32 -14
- package/package.json +1 -1
- package/src/theme/Layout.tsx +317 -46
- package/src/theme/MetadataBlock.tsx +135 -0
- package/src/theme/entry.tsx +23 -4
- package/src/theme/styles.css +141 -0
package/dist/cli.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
// src/cli.ts
|
|
4
4
|
import { parseArgs } from "util";
|
|
5
|
+
import path5 from "path";
|
|
5
6
|
|
|
6
7
|
// src/vite/start.ts
|
|
7
8
|
import { createServer as createServer2, build, preview } from "vite";
|
|
@@ -71,6 +72,32 @@ async function ensureCacheDir(rootDir) {
|
|
|
71
72
|
import fg from "fast-glob";
|
|
72
73
|
import { readFile } from "fs/promises";
|
|
73
74
|
import path2 from "path";
|
|
75
|
+
function parseValue(value) {
|
|
76
|
+
const trimmed = value.trim();
|
|
77
|
+
if (trimmed.startsWith("[") && trimmed.endsWith("]")) {
|
|
78
|
+
const inner = trimmed.slice(1, -1);
|
|
79
|
+
if (inner.trim() === "")
|
|
80
|
+
return [];
|
|
81
|
+
return inner.split(",").map((item) => {
|
|
82
|
+
let v2 = item.trim();
|
|
83
|
+
if (v2.startsWith('"') && v2.endsWith('"') || v2.startsWith("'") && v2.endsWith("'")) {
|
|
84
|
+
v2 = v2.slice(1, -1);
|
|
85
|
+
}
|
|
86
|
+
return v2;
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
let v = trimmed;
|
|
90
|
+
if (v.startsWith('"') && v.endsWith('"') || v.startsWith("'") && v.endsWith("'")) {
|
|
91
|
+
v = v.slice(1, -1);
|
|
92
|
+
}
|
|
93
|
+
if (v === "true")
|
|
94
|
+
return true;
|
|
95
|
+
if (v === "false")
|
|
96
|
+
return false;
|
|
97
|
+
if (!isNaN(Number(v)) && v !== "")
|
|
98
|
+
return Number(v);
|
|
99
|
+
return v;
|
|
100
|
+
}
|
|
74
101
|
function parseFrontmatter(content) {
|
|
75
102
|
const frontmatterRegex = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/;
|
|
76
103
|
const match = content.match(frontmatterRegex);
|
|
@@ -86,18 +113,9 @@ function parseFrontmatter(content) {
|
|
|
86
113
|
if (colonIndex === -1)
|
|
87
114
|
continue;
|
|
88
115
|
const key = line.slice(0, colonIndex).trim();
|
|
89
|
-
|
|
90
|
-
if (value.startsWith('"') && value.endsWith('"') || value.startsWith("'") && value.endsWith("'")) {
|
|
91
|
-
value = value.slice(1, -1);
|
|
92
|
-
}
|
|
93
|
-
if (value === "true")
|
|
94
|
-
value = true;
|
|
95
|
-
else if (value === "false")
|
|
96
|
-
value = false;
|
|
97
|
-
else if (!isNaN(Number(value)) && value !== "")
|
|
98
|
-
value = Number(value);
|
|
116
|
+
const rawValue = line.slice(colonIndex + 1);
|
|
99
117
|
if (key) {
|
|
100
|
-
frontmatter[key] =
|
|
118
|
+
frontmatter[key] = parseValue(rawValue);
|
|
101
119
|
}
|
|
102
120
|
}
|
|
103
121
|
return { frontmatter, content: restContent };
|
|
@@ -121,8 +139,8 @@ function fileToRoute(file) {
|
|
|
121
139
|
async function scanPages(rootDir) {
|
|
122
140
|
const files = await fg.glob("**/*.{md,mdx}", {
|
|
123
141
|
cwd: rootDir,
|
|
124
|
-
ignore: ["node_modules/**", "dist/**", ".cache/**"],
|
|
125
|
-
dot:
|
|
142
|
+
ignore: ["node_modules/**", "dist/**", ".cache/**", ".*/**"],
|
|
143
|
+
dot: false
|
|
126
144
|
});
|
|
127
145
|
const routeMap = new Map;
|
|
128
146
|
for (const file of files) {
|
|
@@ -602,7 +620,7 @@ var { values, positionals } = parseArgs({
|
|
|
602
620
|
allowPositionals: true
|
|
603
621
|
});
|
|
604
622
|
var command = positionals[0] || "dev";
|
|
605
|
-
var rootDir = values.cwd || positionals[1] ||
|
|
623
|
+
var rootDir = path5.resolve(values.cwd || positionals[1] || ".");
|
|
606
624
|
function printHelp() {
|
|
607
625
|
console.log(`
|
|
608
626
|
prev - Zero-config documentation site generator
|
package/package.json
CHANGED
package/src/theme/Layout.tsx
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import React, { useState, useEffect } from 'react'
|
|
1
|
+
import React, { useState, useEffect, useRef, createContext, useContext } from 'react'
|
|
2
2
|
import { Link, useLocation } from '@tanstack/react-router'
|
|
3
3
|
import type { PageTree } from 'fumadocs-core/server'
|
|
4
4
|
|
|
@@ -7,6 +7,77 @@ interface LayoutProps {
|
|
|
7
7
|
children: React.ReactNode
|
|
8
8
|
}
|
|
9
9
|
|
|
10
|
+
type TreeItem = PageTree.Item | PageTree.Folder
|
|
11
|
+
|
|
12
|
+
// Get unique ID for a tree item
|
|
13
|
+
function getItemId(item: TreeItem): string {
|
|
14
|
+
return item.type === 'folder' ? `folder:${item.name}` : item.url
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// Storage key for a specific path (root or folder)
|
|
18
|
+
function getStorageKey(path: string): string {
|
|
19
|
+
return `sidebar-order:${path}`
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Apply saved order to items for a specific path
|
|
23
|
+
// Handles: new items (appended), removed items (skipped), stale storage (cleaned)
|
|
24
|
+
function applyOrder(items: TreeItem[], path: string): TreeItem[] {
|
|
25
|
+
if (typeof window === 'undefined') return items
|
|
26
|
+
|
|
27
|
+
const saved = localStorage.getItem(getStorageKey(path))
|
|
28
|
+
if (!saved) return items
|
|
29
|
+
|
|
30
|
+
const order: string[] = JSON.parse(saved)
|
|
31
|
+
if (order.length === 0) return items
|
|
32
|
+
|
|
33
|
+
const itemMap = new Map(items.map(item => [getItemId(item), item]))
|
|
34
|
+
const ordered: TreeItem[] = []
|
|
35
|
+
|
|
36
|
+
// Add items in saved order (skip removed ones)
|
|
37
|
+
for (const id of order) {
|
|
38
|
+
const item = itemMap.get(id)
|
|
39
|
+
if (item) {
|
|
40
|
+
ordered.push(item)
|
|
41
|
+
itemMap.delete(id)
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Add remaining items (new ones not in saved order)
|
|
46
|
+
for (const item of itemMap.values()) {
|
|
47
|
+
ordered.push(item)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Clean up stale entries: if order changed, update storage
|
|
51
|
+
const currentIds = ordered.map(getItemId)
|
|
52
|
+
const hasChanges = order.length !== currentIds.length ||
|
|
53
|
+
order.some((id, i) => currentIds[i] !== id)
|
|
54
|
+
|
|
55
|
+
if (hasChanges) {
|
|
56
|
+
localStorage.setItem(getStorageKey(path), JSON.stringify(currentIds))
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return ordered
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Save order for a specific path
|
|
63
|
+
function saveOrder(items: TreeItem[], path: string): void {
|
|
64
|
+
const order = items.map(getItemId)
|
|
65
|
+
localStorage.setItem(getStorageKey(path), JSON.stringify(order))
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Drag context for nested components
|
|
69
|
+
interface DragContextType {
|
|
70
|
+
dragPath: string | null
|
|
71
|
+
dragIndex: number | null
|
|
72
|
+
dragOverPath: string | null
|
|
73
|
+
dragOverIndex: number | null
|
|
74
|
+
onDragStart: (path: string, index: number) => void
|
|
75
|
+
onDragOver: (path: string, index: number) => void
|
|
76
|
+
onDragEnd: () => void
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const DragContext = createContext<DragContextType | null>(null)
|
|
80
|
+
|
|
10
81
|
function SidebarToggle({ collapsed, onToggle }: { collapsed: boolean; onToggle: () => void }) {
|
|
11
82
|
return (
|
|
12
83
|
<button
|
|
@@ -41,23 +112,55 @@ function CollapsedFolderIcon({ item, onClick }: { item: PageTree.Folder; onClick
|
|
|
41
112
|
)
|
|
42
113
|
}
|
|
43
114
|
|
|
44
|
-
interface
|
|
45
|
-
item:
|
|
115
|
+
interface DraggableSidebarItemProps {
|
|
116
|
+
item: TreeItem
|
|
117
|
+
index: number
|
|
118
|
+
path: string
|
|
46
119
|
depth?: number
|
|
47
120
|
}
|
|
48
121
|
|
|
49
|
-
function
|
|
122
|
+
function DraggableSidebarItem({ item, index, path, depth = 0 }: DraggableSidebarItemProps) {
|
|
50
123
|
const location = useLocation()
|
|
51
124
|
const [isOpen, setIsOpen] = useState(true)
|
|
125
|
+
const dragCtx = useContext(DragContext)
|
|
126
|
+
|
|
127
|
+
const handleDragStart = (e: React.DragEvent) => {
|
|
128
|
+
e.stopPropagation()
|
|
129
|
+
e.dataTransfer.effectAllowed = 'move'
|
|
130
|
+
dragCtx?.onDragStart(path, index)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const handleDragOver = (e: React.DragEvent) => {
|
|
134
|
+
e.preventDefault()
|
|
135
|
+
e.stopPropagation()
|
|
136
|
+
e.dataTransfer.dropEffect = 'move'
|
|
137
|
+
dragCtx?.onDragOver(path, index)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const handleDragEnd = (e: React.DragEvent) => {
|
|
141
|
+
e.stopPropagation()
|
|
142
|
+
dragCtx?.onDragEnd()
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const isDropTarget = dragCtx?.dragOverPath === path && dragCtx?.dragOverIndex === index
|
|
52
146
|
|
|
53
147
|
if (item.type === 'folder') {
|
|
148
|
+
const folderPath = `${path}/${item.name}`
|
|
149
|
+
|
|
54
150
|
return (
|
|
55
|
-
<div
|
|
151
|
+
<div
|
|
152
|
+
className={`sidebar-folder ${isDropTarget ? 'drop-target' : ''}`}
|
|
153
|
+
draggable
|
|
154
|
+
onDragStart={handleDragStart}
|
|
155
|
+
onDragOver={handleDragOver}
|
|
156
|
+
onDragEnd={handleDragEnd}
|
|
157
|
+
>
|
|
56
158
|
<button
|
|
57
159
|
className="sidebar-folder-toggle"
|
|
58
160
|
onClick={() => setIsOpen(!isOpen)}
|
|
59
161
|
style={{ paddingLeft: `${depth * 12 + 8}px` }}
|
|
60
162
|
>
|
|
163
|
+
<span className="drag-handle">⠿</span>
|
|
61
164
|
<span className={`folder-icon ${isOpen ? 'open' : ''}`}>
|
|
62
165
|
<svg width="12" height="12" viewBox="0 0 12 12" fill="currentColor">
|
|
63
166
|
<path d="M4.5 2L8.5 6L4.5 10" stroke="currentColor" strokeWidth="1.5" fill="none"/>
|
|
@@ -66,11 +169,11 @@ function SidebarItem({ item, depth = 0 }: SidebarItemProps) {
|
|
|
66
169
|
{item.name}
|
|
67
170
|
</button>
|
|
68
171
|
{isOpen && (
|
|
69
|
-
<
|
|
70
|
-
{item.children
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
172
|
+
<DraggableFolderChildren
|
|
173
|
+
items={item.children}
|
|
174
|
+
path={folderPath}
|
|
175
|
+
depth={depth + 1}
|
|
176
|
+
/>
|
|
74
177
|
)}
|
|
75
178
|
</div>
|
|
76
179
|
)
|
|
@@ -80,13 +183,81 @@ function SidebarItem({ item, depth = 0 }: SidebarItemProps) {
|
|
|
80
183
|
(item.url === '/' && location.pathname === '/')
|
|
81
184
|
|
|
82
185
|
return (
|
|
83
|
-
<
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
186
|
+
<div
|
|
187
|
+
className={`sidebar-item-wrapper ${isDropTarget ? 'drop-target' : ''}`}
|
|
188
|
+
draggable
|
|
189
|
+
onDragStart={handleDragStart}
|
|
190
|
+
onDragOver={handleDragOver}
|
|
191
|
+
onDragEnd={handleDragEnd}
|
|
87
192
|
>
|
|
88
|
-
|
|
89
|
-
|
|
193
|
+
<Link
|
|
194
|
+
to={item.url}
|
|
195
|
+
className={`sidebar-link ${isActive ? 'active' : ''}`}
|
|
196
|
+
style={{ paddingLeft: `${depth * 12 + 16}px` }}
|
|
197
|
+
>
|
|
198
|
+
<span className="drag-handle">⠿</span>
|
|
199
|
+
{item.name}
|
|
200
|
+
</Link>
|
|
201
|
+
</div>
|
|
202
|
+
)
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Draggable folder children with their own order state
|
|
206
|
+
function DraggableFolderChildren({ items, path, depth }: { items: TreeItem[]; path: string; depth: number }) {
|
|
207
|
+
const [orderedItems, setOrderedItems] = useState<TreeItem[]>(() => applyOrder(items, path))
|
|
208
|
+
const dragCtx = useContext(DragContext)
|
|
209
|
+
|
|
210
|
+
// Update when items change
|
|
211
|
+
useEffect(() => {
|
|
212
|
+
setOrderedItems(applyOrder(items, path))
|
|
213
|
+
}, [items, path])
|
|
214
|
+
|
|
215
|
+
// Handle reorder when drag ends in this folder
|
|
216
|
+
useEffect(() => {
|
|
217
|
+
if (
|
|
218
|
+
dragCtx?.dragPath === path &&
|
|
219
|
+
dragCtx?.dragOverPath === path &&
|
|
220
|
+
dragCtx?.dragIndex !== null &&
|
|
221
|
+
dragCtx?.dragOverIndex !== null &&
|
|
222
|
+
dragCtx?.dragIndex !== dragCtx?.dragOverIndex
|
|
223
|
+
) {
|
|
224
|
+
// Will be handled by parent's onDragEnd
|
|
225
|
+
}
|
|
226
|
+
}, [dragCtx, path])
|
|
227
|
+
|
|
228
|
+
// Listen for successful drops in this path
|
|
229
|
+
const prevDragCtx = useRef(dragCtx)
|
|
230
|
+
useEffect(() => {
|
|
231
|
+
const prev = prevDragCtx.current
|
|
232
|
+
if (
|
|
233
|
+
prev?.dragPath === path &&
|
|
234
|
+
prev?.dragOverPath === path &&
|
|
235
|
+
prev?.dragIndex !== null &&
|
|
236
|
+
prev?.dragOverIndex !== null &&
|
|
237
|
+
prev?.dragIndex !== prev?.dragOverIndex &&
|
|
238
|
+
dragCtx?.dragPath === null // drag ended
|
|
239
|
+
) {
|
|
240
|
+
const newItems = [...orderedItems]
|
|
241
|
+
const [removed] = newItems.splice(prev.dragIndex, 1)
|
|
242
|
+
newItems.splice(prev.dragOverIndex, 0, removed)
|
|
243
|
+
setOrderedItems(newItems)
|
|
244
|
+
saveOrder(newItems, path)
|
|
245
|
+
}
|
|
246
|
+
prevDragCtx.current = dragCtx
|
|
247
|
+
}, [dragCtx, path, orderedItems])
|
|
248
|
+
|
|
249
|
+
return (
|
|
250
|
+
<div className="sidebar-folder-children">
|
|
251
|
+
{orderedItems.map((child, i) => (
|
|
252
|
+
<DraggableSidebarItem
|
|
253
|
+
key={getItemId(child)}
|
|
254
|
+
item={child}
|
|
255
|
+
index={i}
|
|
256
|
+
path={path}
|
|
257
|
+
depth={depth}
|
|
258
|
+
/>
|
|
259
|
+
))}
|
|
260
|
+
</div>
|
|
90
261
|
)
|
|
91
262
|
}
|
|
92
263
|
|
|
@@ -121,6 +292,41 @@ function ThemeToggle() {
|
|
|
121
292
|
)
|
|
122
293
|
}
|
|
123
294
|
|
|
295
|
+
function WidthToggle() {
|
|
296
|
+
const [isFullWidth, setIsFullWidth] = useState(() => {
|
|
297
|
+
if (typeof window !== 'undefined') {
|
|
298
|
+
return localStorage.getItem('content-full-width') === 'true'
|
|
299
|
+
}
|
|
300
|
+
return false
|
|
301
|
+
})
|
|
302
|
+
|
|
303
|
+
useEffect(() => {
|
|
304
|
+
document.documentElement.classList.toggle('full-width', isFullWidth)
|
|
305
|
+
}, [isFullWidth])
|
|
306
|
+
|
|
307
|
+
const toggle = () => {
|
|
308
|
+
const newFullWidth = !isFullWidth
|
|
309
|
+
setIsFullWidth(newFullWidth)
|
|
310
|
+
localStorage.setItem('content-full-width', String(newFullWidth))
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
return (
|
|
314
|
+
<button className="theme-toggle" onClick={toggle} aria-label="Toggle content width">
|
|
315
|
+
{isFullWidth ? (
|
|
316
|
+
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
317
|
+
<path d="M8 3H5a2 2 0 00-2 2v3m18 0V5a2 2 0 00-2-2h-3m0 18h3a2 2 0 002-2v-3M3 16v3a2 2 0 002 2h3"/>
|
|
318
|
+
</svg>
|
|
319
|
+
) : (
|
|
320
|
+
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
321
|
+
<path d="M15 3h6v6M9 21H3v-6M21 3l-7 7M3 21l7-7"/>
|
|
322
|
+
</svg>
|
|
323
|
+
)}
|
|
324
|
+
</button>
|
|
325
|
+
)
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
const ROOT_PATH = 'root'
|
|
329
|
+
|
|
124
330
|
export function Layout({ tree, children }: LayoutProps) {
|
|
125
331
|
const [collapsed, setCollapsed] = useState(() => {
|
|
126
332
|
if (typeof window !== 'undefined') {
|
|
@@ -129,6 +335,13 @@ export function Layout({ tree, children }: LayoutProps) {
|
|
|
129
335
|
return false
|
|
130
336
|
})
|
|
131
337
|
|
|
338
|
+
const [orderedItems, setOrderedItems] = useState<TreeItem[]>(() => applyOrder(tree.children, ROOT_PATH))
|
|
339
|
+
|
|
340
|
+
const [dragPath, setDragPath] = useState<string | null>(null)
|
|
341
|
+
const [dragIndex, setDragIndex] = useState<number | null>(null)
|
|
342
|
+
const [dragOverPath, setDragOverPath] = useState<string | null>(null)
|
|
343
|
+
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null)
|
|
344
|
+
|
|
132
345
|
// Initialize theme from localStorage
|
|
133
346
|
useEffect(() => {
|
|
134
347
|
const saved = localStorage.getItem('theme')
|
|
@@ -137,6 +350,11 @@ export function Layout({ tree, children }: LayoutProps) {
|
|
|
137
350
|
document.documentElement.classList.toggle('dark', isDark)
|
|
138
351
|
}, [])
|
|
139
352
|
|
|
353
|
+
// Update ordered items when tree changes
|
|
354
|
+
useEffect(() => {
|
|
355
|
+
setOrderedItems(applyOrder(tree.children, ROOT_PATH))
|
|
356
|
+
}, [tree.children])
|
|
357
|
+
|
|
140
358
|
// Persist collapsed state
|
|
141
359
|
useEffect(() => {
|
|
142
360
|
localStorage.setItem('sidebar-collapsed', String(collapsed))
|
|
@@ -144,43 +362,96 @@ export function Layout({ tree, children }: LayoutProps) {
|
|
|
144
362
|
|
|
145
363
|
const toggleCollapsed = () => setCollapsed(!collapsed)
|
|
146
364
|
|
|
365
|
+
const handleDragStart = (path: string, index: number) => {
|
|
366
|
+
setDragPath(path)
|
|
367
|
+
setDragIndex(index)
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
const handleDragOver = (path: string, index: number) => {
|
|
371
|
+
// Only allow dropping in the same path (no cross-folder drops)
|
|
372
|
+
if (dragPath === path && dragIndex !== index) {
|
|
373
|
+
setDragOverPath(path)
|
|
374
|
+
setDragOverIndex(index)
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
const handleDragEnd = () => {
|
|
379
|
+
// Handle root-level reordering
|
|
380
|
+
if (
|
|
381
|
+
dragPath === ROOT_PATH &&
|
|
382
|
+
dragOverPath === ROOT_PATH &&
|
|
383
|
+
dragIndex !== null &&
|
|
384
|
+
dragOverIndex !== null &&
|
|
385
|
+
dragIndex !== dragOverIndex
|
|
386
|
+
) {
|
|
387
|
+
const newItems = [...orderedItems]
|
|
388
|
+
const [removed] = newItems.splice(dragIndex, 1)
|
|
389
|
+
newItems.splice(dragOverIndex, 0, removed)
|
|
390
|
+
setOrderedItems(newItems)
|
|
391
|
+
saveOrder(newItems, ROOT_PATH)
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
setDragPath(null)
|
|
395
|
+
setDragIndex(null)
|
|
396
|
+
setDragOverPath(null)
|
|
397
|
+
setDragOverIndex(null)
|
|
398
|
+
}
|
|
399
|
+
|
|
147
400
|
// Get top-level folders for collapsed rail
|
|
148
|
-
const topFolders =
|
|
401
|
+
const topFolders = orderedItems.filter(
|
|
149
402
|
(item): item is PageTree.Folder => item.type === 'folder'
|
|
150
403
|
)
|
|
151
404
|
|
|
405
|
+
const dragContextValue: DragContextType = {
|
|
406
|
+
dragPath,
|
|
407
|
+
dragIndex,
|
|
408
|
+
dragOverPath,
|
|
409
|
+
dragOverIndex,
|
|
410
|
+
onDragStart: handleDragStart,
|
|
411
|
+
onDragOver: handleDragOver,
|
|
412
|
+
onDragEnd: handleDragEnd,
|
|
413
|
+
}
|
|
414
|
+
|
|
152
415
|
return (
|
|
153
|
-
<
|
|
154
|
-
<
|
|
155
|
-
<
|
|
156
|
-
<
|
|
157
|
-
|
|
158
|
-
{collapsed ? (
|
|
159
|
-
<div className="sidebar-rail">
|
|
160
|
-
{topFolders.map((folder, i) => (
|
|
161
|
-
<CollapsedFolderIcon
|
|
162
|
-
key={i}
|
|
163
|
-
item={folder}
|
|
164
|
-
onClick={toggleCollapsed}
|
|
165
|
-
/>
|
|
166
|
-
))}
|
|
416
|
+
<DragContext.Provider value={dragContextValue}>
|
|
417
|
+
<div className={`prev-layout ${collapsed ? 'sidebar-collapsed' : ''}`}>
|
|
418
|
+
<aside className="prev-sidebar">
|
|
419
|
+
<div className="sidebar-header">
|
|
420
|
+
<SidebarToggle collapsed={collapsed} onToggle={toggleCollapsed} />
|
|
167
421
|
</div>
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
422
|
+
{collapsed ? (
|
|
423
|
+
<div className="sidebar-rail">
|
|
424
|
+
{topFolders.map((folder, i) => (
|
|
425
|
+
<CollapsedFolderIcon
|
|
426
|
+
key={i}
|
|
427
|
+
item={folder}
|
|
428
|
+
onClick={toggleCollapsed}
|
|
429
|
+
/>
|
|
173
430
|
))}
|
|
174
|
-
</nav>
|
|
175
|
-
<div className="sidebar-footer">
|
|
176
|
-
<ThemeToggle />
|
|
177
431
|
</div>
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
432
|
+
) : (
|
|
433
|
+
<>
|
|
434
|
+
<nav className="sidebar-nav">
|
|
435
|
+
{orderedItems.map((item, i) => (
|
|
436
|
+
<DraggableSidebarItem
|
|
437
|
+
key={getItemId(item)}
|
|
438
|
+
item={item}
|
|
439
|
+
index={i}
|
|
440
|
+
path={ROOT_PATH}
|
|
441
|
+
/>
|
|
442
|
+
))}
|
|
443
|
+
</nav>
|
|
444
|
+
<div className="sidebar-footer">
|
|
445
|
+
<WidthToggle />
|
|
446
|
+
<ThemeToggle />
|
|
447
|
+
</div>
|
|
448
|
+
</>
|
|
449
|
+
)}
|
|
450
|
+
</aside>
|
|
451
|
+
<main className="prev-main">
|
|
452
|
+
{children}
|
|
453
|
+
</main>
|
|
454
|
+
</div>
|
|
455
|
+
</DragContext.Provider>
|
|
185
456
|
)
|
|
186
457
|
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
|
|
3
|
+
type FrontmatterValue = string | number | boolean | string[] | unknown
|
|
4
|
+
|
|
5
|
+
interface MetadataBlockProps {
|
|
6
|
+
frontmatter: Record<string, FrontmatterValue>
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
// Fields to skip (shown elsewhere or internal)
|
|
10
|
+
const SKIP_FIELDS = new Set(['title', 'description'])
|
|
11
|
+
|
|
12
|
+
// Fields with special icons
|
|
13
|
+
const FIELD_ICONS: Record<string, string> = {
|
|
14
|
+
date: '📅',
|
|
15
|
+
author: '👤',
|
|
16
|
+
tags: '🏷️',
|
|
17
|
+
status: '📌',
|
|
18
|
+
version: '📦',
|
|
19
|
+
repo: '🔗',
|
|
20
|
+
url: '🔗',
|
|
21
|
+
link: '🔗',
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Detect the display type of a value
|
|
26
|
+
*/
|
|
27
|
+
function detectType(key: string, value: FrontmatterValue): 'date' | 'url' | 'boolean' | 'array' | 'number' | 'string' {
|
|
28
|
+
if (Array.isArray(value)) return 'array'
|
|
29
|
+
if (typeof value === 'boolean') return 'boolean'
|
|
30
|
+
if (typeof value === 'number') return 'number'
|
|
31
|
+
if (typeof value !== 'string') return 'string'
|
|
32
|
+
|
|
33
|
+
// Check for URL
|
|
34
|
+
if (value.startsWith('http://') || value.startsWith('https://')) return 'url'
|
|
35
|
+
|
|
36
|
+
// Check for date patterns
|
|
37
|
+
if (key === 'date' || key.includes('date') || key.includes('Date')) {
|
|
38
|
+
// ISO date or YYYY-MM-DD
|
|
39
|
+
if (/^\d{4}-\d{2}-\d{2}/.test(value)) return 'date'
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return 'string'
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Format a date string nicely
|
|
47
|
+
*/
|
|
48
|
+
function formatDate(value: string): string {
|
|
49
|
+
try {
|
|
50
|
+
const date = new Date(value)
|
|
51
|
+
if (isNaN(date.getTime())) return value
|
|
52
|
+
return date.toLocaleDateString('en-US', {
|
|
53
|
+
year: 'numeric',
|
|
54
|
+
month: 'short',
|
|
55
|
+
day: 'numeric'
|
|
56
|
+
})
|
|
57
|
+
} catch {
|
|
58
|
+
return value
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Render a single metadata value based on its type
|
|
64
|
+
*/
|
|
65
|
+
function renderValue(key: string, value: FrontmatterValue): React.ReactNode {
|
|
66
|
+
const type = detectType(key, value)
|
|
67
|
+
|
|
68
|
+
switch (type) {
|
|
69
|
+
case 'array':
|
|
70
|
+
return (
|
|
71
|
+
<span className="metadata-array">
|
|
72
|
+
{(value as string[]).map((item, i) => (
|
|
73
|
+
<span key={i} className="metadata-chip">{item}</span>
|
|
74
|
+
))}
|
|
75
|
+
</span>
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
case 'boolean':
|
|
79
|
+
return (
|
|
80
|
+
<span className={`metadata-boolean ${value ? 'is-true' : 'is-false'}`}>
|
|
81
|
+
{value ? '✓' : '✗'}
|
|
82
|
+
</span>
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
case 'url':
|
|
86
|
+
return (
|
|
87
|
+
<a href={value as string} className="metadata-link" target="_blank" rel="noopener noreferrer">
|
|
88
|
+
{(value as string).replace(/^https?:\/\//, '').split('/')[0]}
|
|
89
|
+
</a>
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
case 'date':
|
|
93
|
+
return <span className="metadata-date">{formatDate(value as string)}</span>
|
|
94
|
+
|
|
95
|
+
case 'number':
|
|
96
|
+
return <span className="metadata-number">{(value as number).toLocaleString()}</span>
|
|
97
|
+
|
|
98
|
+
default:
|
|
99
|
+
return <span className="metadata-string">{String(value)}</span>
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function MetadataBlock({ frontmatter }: MetadataBlockProps) {
|
|
104
|
+
// Filter out skipped fields and empty values
|
|
105
|
+
const fields = Object.entries(frontmatter).filter(
|
|
106
|
+
([key, value]) => !SKIP_FIELDS.has(key) && value !== undefined && value !== null && value !== ''
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
// Check for draft status
|
|
110
|
+
const isDraft = frontmatter.draft === true
|
|
111
|
+
|
|
112
|
+
// If no fields to show, don't render
|
|
113
|
+
if (fields.length === 0 && !isDraft) {
|
|
114
|
+
return null
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return (
|
|
118
|
+
<div className="metadata-block">
|
|
119
|
+
<div className="metadata-fields">
|
|
120
|
+
{isDraft && (
|
|
121
|
+
<span className="metadata-field metadata-draft">
|
|
122
|
+
<span className="metadata-chip is-draft">Draft</span>
|
|
123
|
+
</span>
|
|
124
|
+
)}
|
|
125
|
+
{fields.filter(([key]) => key !== 'draft').map(([key, value]) => (
|
|
126
|
+
<span key={key} className="metadata-field">
|
|
127
|
+
{FIELD_ICONS[key] && <span className="metadata-icon">{FIELD_ICONS[key]}</span>}
|
|
128
|
+
<span className="metadata-key">{key}:</span>
|
|
129
|
+
{renderValue(key, value)}
|
|
130
|
+
</span>
|
|
131
|
+
))}
|
|
132
|
+
</div>
|
|
133
|
+
</div>
|
|
134
|
+
)
|
|
135
|
+
}
|
package/src/theme/entry.tsx
CHANGED
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
import { pages, sidebar } from 'virtual:prev-pages'
|
|
11
11
|
import { useDiagrams } from './diagrams'
|
|
12
12
|
import { Layout } from './Layout'
|
|
13
|
+
import { MetadataBlock } from './MetadataBlock'
|
|
13
14
|
import './styles.css'
|
|
14
15
|
|
|
15
16
|
// PageTree types (simplified from fumadocs-core)
|
|
@@ -61,10 +62,23 @@ function getPageComponent(file: string): React.ComponentType | null {
|
|
|
61
62
|
return mod?.default || null
|
|
62
63
|
}
|
|
63
64
|
|
|
65
|
+
interface PageMeta {
|
|
66
|
+
title?: string
|
|
67
|
+
description?: string
|
|
68
|
+
frontmatter?: Record<string, unknown>
|
|
69
|
+
}
|
|
70
|
+
|
|
64
71
|
// Page wrapper that renders diagrams after content loads
|
|
65
|
-
function PageWrapper({ Component }: { Component: React.ComponentType }) {
|
|
72
|
+
function PageWrapper({ Component, meta }: { Component: React.ComponentType; meta: PageMeta }) {
|
|
66
73
|
useDiagrams()
|
|
67
|
-
return
|
|
74
|
+
return (
|
|
75
|
+
<>
|
|
76
|
+
{meta.frontmatter && Object.keys(meta.frontmatter).length > 0 && (
|
|
77
|
+
<MetadataBlock frontmatter={meta.frontmatter} />
|
|
78
|
+
)}
|
|
79
|
+
<Component />
|
|
80
|
+
</>
|
|
81
|
+
)
|
|
68
82
|
}
|
|
69
83
|
|
|
70
84
|
// Root layout with custom lightweight Layout
|
|
@@ -86,12 +100,17 @@ const rootRoute = createRootRoute({
|
|
|
86
100
|
})
|
|
87
101
|
|
|
88
102
|
// Create routes from pages
|
|
89
|
-
const pageRoutes = pages.map((page: { route: string; file: string }) => {
|
|
103
|
+
const pageRoutes = pages.map((page: { route: string; file: string; title?: string; description?: string; frontmatter?: Record<string, unknown> }) => {
|
|
90
104
|
const Component = getPageComponent(page.file)
|
|
105
|
+
const meta: PageMeta = {
|
|
106
|
+
title: page.title,
|
|
107
|
+
description: page.description,
|
|
108
|
+
frontmatter: page.frontmatter,
|
|
109
|
+
}
|
|
91
110
|
return createRoute({
|
|
92
111
|
getParentRoute: () => rootRoute,
|
|
93
112
|
path: page.route === '/' ? '/' : page.route,
|
|
94
|
-
component: Component ? () => <PageWrapper Component={Component} /> : () => null,
|
|
113
|
+
component: Component ? () => <PageWrapper Component={Component} meta={meta} /> : () => null,
|
|
95
114
|
})
|
|
96
115
|
})
|
|
97
116
|
|
package/src/theme/styles.css
CHANGED
|
@@ -165,6 +165,8 @@ body {
|
|
|
165
165
|
.sidebar-footer {
|
|
166
166
|
padding: 1rem;
|
|
167
167
|
border-top: 1px solid var(--fd-border);
|
|
168
|
+
display: flex;
|
|
169
|
+
gap: 0.5rem;
|
|
168
170
|
}
|
|
169
171
|
|
|
170
172
|
.theme-toggle {
|
|
@@ -591,3 +593,142 @@ body {
|
|
|
591
593
|
background: var(--fd-accent);
|
|
592
594
|
color: var(--fd-accent-foreground);
|
|
593
595
|
}
|
|
596
|
+
|
|
597
|
+
/* ===========================================
|
|
598
|
+
Metadata Block Styles
|
|
599
|
+
=========================================== */
|
|
600
|
+
|
|
601
|
+
.metadata-block {
|
|
602
|
+
margin-bottom: 1.5rem;
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
.metadata-fields {
|
|
606
|
+
display: flex;
|
|
607
|
+
flex-wrap: wrap;
|
|
608
|
+
gap: 0.75rem;
|
|
609
|
+
align-items: center;
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
.metadata-field {
|
|
613
|
+
display: inline-flex;
|
|
614
|
+
align-items: center;
|
|
615
|
+
gap: 0.35rem;
|
|
616
|
+
font-size: 0.875rem;
|
|
617
|
+
color: var(--fd-muted-foreground);
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
.metadata-icon {
|
|
621
|
+
font-size: 0.875rem;
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
.metadata-key {
|
|
625
|
+
font-weight: 500;
|
|
626
|
+
color: var(--fd-foreground);
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
.metadata-chip {
|
|
630
|
+
display: inline-block;
|
|
631
|
+
padding: 0.15rem 0.5rem;
|
|
632
|
+
background: var(--fd-muted);
|
|
633
|
+
border-radius: 0.25rem;
|
|
634
|
+
font-size: 0.8rem;
|
|
635
|
+
color: var(--fd-foreground);
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
.metadata-chip.is-draft {
|
|
639
|
+
background: oklch(0.75 0.15 50);
|
|
640
|
+
color: oklch(0.25 0.05 50);
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
.dark .metadata-chip.is-draft {
|
|
644
|
+
background: oklch(0.45 0.12 50);
|
|
645
|
+
color: oklch(0.95 0.02 50);
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
.metadata-array {
|
|
649
|
+
display: inline-flex;
|
|
650
|
+
flex-wrap: wrap;
|
|
651
|
+
gap: 0.35rem;
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
.metadata-boolean {
|
|
655
|
+
font-weight: 600;
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
.metadata-boolean.is-true {
|
|
659
|
+
color: oklch(0.55 0.15 145);
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
.metadata-boolean.is-false {
|
|
663
|
+
color: oklch(0.55 0.15 25);
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
.metadata-link {
|
|
667
|
+
color: var(--fd-primary);
|
|
668
|
+
text-decoration: underline;
|
|
669
|
+
text-decoration-color: var(--fd-border);
|
|
670
|
+
text-underline-offset: 2px;
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
.metadata-link:hover {
|
|
674
|
+
text-decoration-color: var(--fd-primary);
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
.metadata-date {
|
|
678
|
+
font-variant-numeric: tabular-nums;
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
.metadata-number {
|
|
682
|
+
font-variant-numeric: tabular-nums;
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
/* ===========================================
|
|
686
|
+
Full Width Mode
|
|
687
|
+
=========================================== */
|
|
688
|
+
|
|
689
|
+
.full-width .prev-content {
|
|
690
|
+
max-width: none;
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
/* ===========================================
|
|
694
|
+
Drag and Drop Sidebar
|
|
695
|
+
=========================================== */
|
|
696
|
+
|
|
697
|
+
.sidebar-item-wrapper {
|
|
698
|
+
position: relative;
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
.drag-handle {
|
|
702
|
+
opacity: 0;
|
|
703
|
+
cursor: grab;
|
|
704
|
+
color: var(--fd-muted-foreground);
|
|
705
|
+
font-size: 0.75rem;
|
|
706
|
+
margin-right: 0.25rem;
|
|
707
|
+
transition: opacity 0.15s ease;
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
.sidebar-folder:hover .drag-handle,
|
|
711
|
+
.sidebar-item-wrapper:hover .drag-handle,
|
|
712
|
+
.sidebar-link:hover .drag-handle {
|
|
713
|
+
opacity: 0.5;
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
.drag-handle:hover {
|
|
717
|
+
opacity: 1 !important;
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
.sidebar-folder.drop-target,
|
|
721
|
+
.sidebar-item-wrapper.drop-target {
|
|
722
|
+
background: var(--fd-accent);
|
|
723
|
+
border-radius: 0.375rem;
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
.sidebar-folder[draggable="true"],
|
|
727
|
+
.sidebar-item-wrapper[draggable="true"] {
|
|
728
|
+
cursor: grab;
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
.sidebar-folder[draggable="true"]:active,
|
|
732
|
+
.sidebar-item-wrapper[draggable="true"]:active {
|
|
733
|
+
cursor: grabbing;
|
|
734
|
+
}
|