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 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
- let value = line.slice(colonIndex + 1).trim();
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] = value;
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: true
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] || process.cwd();
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "prev-cli",
3
- "version": "0.7.1",
3
+ "version": "0.8.0",
4
4
  "description": "Transform MDX directories into beautiful documentation websites",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -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 SidebarItemProps {
45
- item: PageTree.Item | PageTree.Folder
115
+ interface DraggableSidebarItemProps {
116
+ item: TreeItem
117
+ index: number
118
+ path: string
46
119
  depth?: number
47
120
  }
48
121
 
49
- function SidebarItem({ item, depth = 0 }: SidebarItemProps) {
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 className="sidebar-folder">
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
- <div className="sidebar-folder-children">
70
- {item.children.map((child, i) => (
71
- <SidebarItem key={i} item={child} depth={depth + 1} />
72
- ))}
73
- </div>
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
- <Link
84
- to={item.url}
85
- className={`sidebar-link ${isActive ? 'active' : ''}`}
86
- style={{ paddingLeft: `${depth * 12 + 16}px` }}
186
+ <div
187
+ className={`sidebar-item-wrapper ${isDropTarget ? 'drop-target' : ''}`}
188
+ draggable
189
+ onDragStart={handleDragStart}
190
+ onDragOver={handleDragOver}
191
+ onDragEnd={handleDragEnd}
87
192
  >
88
- {item.name}
89
- </Link>
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 = tree.children.filter(
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
- <div className={`prev-layout ${collapsed ? 'sidebar-collapsed' : ''}`}>
154
- <aside className="prev-sidebar">
155
- <div className="sidebar-header">
156
- <SidebarToggle collapsed={collapsed} onToggle={toggleCollapsed} />
157
- </div>
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
- <nav className="sidebar-nav">
171
- {tree.children.map((item, i) => (
172
- <SidebarItem key={i} item={item} />
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
- </aside>
181
- <main className="prev-main">
182
- {children}
183
- </main>
184
- </div>
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
+ }
@@ -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 <Component />
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
 
@@ -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
+ }