prev-cli 0.7.2 → 0.9.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
@@ -136,14 +136,30 @@ function fileToRoute(file) {
136
136
  }
137
137
  return "/" + withoutExt;
138
138
  }
139
- async function scanPages(rootDir) {
140
- const files = await fg.glob("**/*.{md,mdx}", {
139
+ async function scanPages(rootDir, options = {}) {
140
+ const { include = [] } = options;
141
+ const includeDirs = include.map((dir) => dir.startsWith(".") ? dir : `.${dir}`);
142
+ const patterns = ["**/*.{md,mdx}"];
143
+ for (const dir of includeDirs) {
144
+ patterns.push(`${dir}/**/*.{md,mdx}`);
145
+ }
146
+ const ignore = ["node_modules/**", "dist/**", ".cache/**"];
147
+ const files = await fg.glob(patterns, {
141
148
  cwd: rootDir,
142
- ignore: ["node_modules/**", "dist/**", ".cache/**"],
149
+ ignore,
143
150
  dot: true
144
151
  });
152
+ const filteredFiles = files.filter((file) => {
153
+ const parts = file.split("/");
154
+ for (const part of parts) {
155
+ if (part.startsWith(".") && part !== ".") {
156
+ return includeDirs.some((dir) => file.startsWith(dir.slice(1)) || file.startsWith(dir));
157
+ }
158
+ }
159
+ return true;
160
+ });
145
161
  const routeMap = new Map;
146
- for (const file of files) {
162
+ for (const file of filteredFiles) {
147
163
  const route = fileToRoute(file);
148
164
  const basename = path2.basename(file, path2.extname(file)).toLowerCase();
149
165
  const priority = basename === "index" ? 1 : basename === "readme" ? 2 : 0;
@@ -222,7 +238,8 @@ function buildSidebarTree(pages) {
222
238
  // src/vite/plugins/pages-plugin.ts
223
239
  var VIRTUAL_MODULE_ID = "virtual:prev-pages";
224
240
  var RESOLVED_VIRTUAL_MODULE_ID = "\x00" + VIRTUAL_MODULE_ID;
225
- function pagesPlugin(rootDir) {
241
+ function pagesPlugin(rootDir, options = {}) {
242
+ const { include } = options;
226
243
  return {
227
244
  name: "prev-pages",
228
245
  resolveId(id) {
@@ -232,7 +249,7 @@ function pagesPlugin(rootDir) {
232
249
  },
233
250
  async load(id) {
234
251
  if (id === RESOLVED_VIRTUAL_MODULE_ID) {
235
- const pages = await scanPages(rootDir);
252
+ const pages = await scanPages(rootDir, { include });
236
253
  const sidebar = buildSidebarTree(pages);
237
254
  return `
238
255
  export const pages = ${JSON.stringify(pages)};
@@ -439,7 +456,7 @@ var cliRoot2 = findCliRoot2();
439
456
  var cliNodeModules = findNodeModules(cliRoot2);
440
457
  var srcRoot2 = path4.join(cliRoot2, "src");
441
458
  async function createViteConfig(options) {
442
- const { rootDir, mode, port } = options;
459
+ const { rootDir, mode, port, include } = options;
443
460
  const cacheDir = await ensureCacheDir(rootDir);
444
461
  return {
445
462
  root: rootDir,
@@ -453,7 +470,7 @@ async function createViteConfig(options) {
453
470
  rehypePlugins: [rehypeHighlight]
454
471
  }),
455
472
  react(),
456
- pagesPlugin(rootDir),
473
+ pagesPlugin(rootDir, { include }),
457
474
  entryPlugin(rootDir)
458
475
  ],
459
476
  resolve: {
@@ -568,7 +585,8 @@ async function startDev(rootDir, options = {}) {
568
585
  const config = await createViteConfig({
569
586
  rootDir,
570
587
  mode: "development",
571
- port
588
+ port,
589
+ include: options.include
572
590
  });
573
591
  const server = await createServer2(config);
574
592
  await server.listen();
@@ -577,14 +595,15 @@ async function startDev(rootDir, options = {}) {
577
595
  printReady();
578
596
  return server;
579
597
  }
580
- async function buildSite(rootDir) {
598
+ async function buildSite(rootDir, options = {}) {
581
599
  console.log();
582
600
  console.log(" ✨ prev build");
583
601
  console.log();
584
602
  console.log(" Building your documentation site...");
585
603
  const config = await createViteConfig({
586
604
  rootDir,
587
- mode: "production"
605
+ mode: "production",
606
+ include: options.include
588
607
  });
589
608
  await build(config);
590
609
  console.log();
@@ -597,7 +616,8 @@ async function previewSite(rootDir, options = {}) {
597
616
  const config = await createViteConfig({
598
617
  rootDir,
599
618
  mode: "production",
600
- port
619
+ port,
620
+ include: options.include
601
621
  });
602
622
  const server = await preview(config);
603
623
  printWelcome("preview");
@@ -615,6 +635,7 @@ var { values, positionals } = parseArgs({
615
635
  port: { type: "string", short: "p" },
616
636
  days: { type: "string", short: "d" },
617
637
  cwd: { type: "string", short: "c" },
638
+ include: { type: "string", short: "i", multiple: true },
618
639
  help: { type: "boolean", short: "h" }
619
640
  },
620
641
  allowPositionals: true
@@ -635,17 +656,19 @@ Commands:
635
656
  clean Remove old cache directories
636
657
 
637
658
  Options:
638
- -c, --cwd <path> Set working directory
639
- -p, --port <port> Specify port (dev/preview)
640
- -d, --days <days> Cache age threshold for clean (default: 30)
641
- -h, --help Show this help message
659
+ -c, --cwd <path> Set working directory
660
+ -p, --port <port> Specify port (dev/preview)
661
+ -i, --include <dir> Include dot-prefixed directory (can use multiple times)
662
+ -d, --days <days> Cache age threshold for clean (default: 30)
663
+ -h, --help Show this help message
642
664
 
643
665
  Examples:
644
- prev Start dev server on random port
645
- prev dev -p 3000 Start dev server on port 3000
646
- prev build Build static site to ./dist
647
- prev clean Remove caches older than 30 days
648
- prev clean -d 7 Remove caches older than 7 days
666
+ prev Start dev server on random port
667
+ prev dev -p 3000 Start dev server on port 3000
668
+ prev build Build static site to ./dist
669
+ prev dev -i .c3 Include .c3 directory in docs
670
+ prev dev -i .c3 -i .notes Include multiple dot directories
671
+ prev clean -d 7 Remove caches older than 7 days
649
672
  `);
650
673
  }
651
674
  async function main() {
@@ -655,16 +678,17 @@ async function main() {
655
678
  }
656
679
  const port = values.port ? parseInt(values.port, 10) : undefined;
657
680
  const days = values.days ? parseInt(values.days, 10) : 30;
681
+ const include = values.include || [];
658
682
  try {
659
683
  switch (command) {
660
684
  case "dev":
661
- await startDev(rootDir, { port });
685
+ await startDev(rootDir, { port, include });
662
686
  break;
663
687
  case "build":
664
- await buildSite(rootDir);
688
+ await buildSite(rootDir, { include });
665
689
  break;
666
690
  case "preview":
667
- await previewSite(rootDir, { port });
691
+ await previewSite(rootDir, { port, include });
668
692
  break;
669
693
  case "clean":
670
694
  const removed = await cleanCache({ maxAgeDays: days });
@@ -3,5 +3,6 @@ export interface ConfigOptions {
3
3
  rootDir: string;
4
4
  mode: 'development' | 'production';
5
5
  port?: number;
6
+ include?: string[];
6
7
  }
7
8
  export declare function createViteConfig(options: ConfigOptions): Promise<InlineConfig>;
@@ -23,5 +23,8 @@ export declare function parseFrontmatter(content: string): {
23
23
  content: string;
24
24
  };
25
25
  export declare function fileToRoute(file: string): string;
26
- export declare function scanPages(rootDir: string): Promise<Page[]>;
26
+ export interface ScanOptions {
27
+ include?: string[];
28
+ }
29
+ export declare function scanPages(rootDir: string, options?: ScanOptions): Promise<Page[]>;
27
30
  export declare function buildSidebarTree(pages: Page[]): SidebarItem[];
@@ -1,2 +1,5 @@
1
1
  import type { Plugin } from 'vite';
2
- export declare function pagesPlugin(rootDir: string): Plugin;
2
+ export interface PagesPluginOptions {
3
+ include?: string[];
4
+ }
5
+ export declare function pagesPlugin(rootDir: string, options?: PagesPluginOptions): Plugin;
@@ -1,6 +1,10 @@
1
1
  export interface DevOptions {
2
2
  port?: number;
3
+ include?: string[];
4
+ }
5
+ export interface BuildOptions {
6
+ include?: string[];
3
7
  }
4
8
  export declare function startDev(rootDir: string, options?: DevOptions): Promise<import("vite").ViteDevServer>;
5
- export declare function buildSite(rootDir: string): Promise<void>;
9
+ export declare function buildSite(rootDir: string, options?: BuildOptions): Promise<void>;
6
10
  export declare function previewSite(rootDir: string, options?: DevOptions): Promise<import("vite").PreviewServer>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "prev-cli",
3
- "version": "0.7.2",
3
+ "version": "0.9.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
  }
@@ -4,8 +4,6 @@ type FrontmatterValue = string | number | boolean | string[] | unknown
4
4
 
5
5
  interface MetadataBlockProps {
6
6
  frontmatter: Record<string, FrontmatterValue>
7
- title?: string
8
- description?: string
9
7
  }
10
8
 
11
9
  // Fields to skip (shown elsewhere or internal)
@@ -102,7 +100,7 @@ function renderValue(key: string, value: FrontmatterValue): React.ReactNode {
102
100
  }
103
101
  }
104
102
 
105
- export function MetadataBlock({ frontmatter, title, description }: MetadataBlockProps) {
103
+ export function MetadataBlock({ frontmatter }: MetadataBlockProps) {
106
104
  // Filter out skipped fields and empty values
107
105
  const fields = Object.entries(frontmatter).filter(
108
106
  ([key, value]) => !SKIP_FIELDS.has(key) && value !== undefined && value !== null && value !== ''
@@ -111,36 +109,27 @@ export function MetadataBlock({ frontmatter, title, description }: MetadataBlock
111
109
  // Check for draft status
112
110
  const isDraft = frontmatter.draft === true
113
111
 
114
- // Only show title/description if they came from frontmatter (not extracted from H1)
115
- const hasExplicitTitle = 'title' in frontmatter && frontmatter.title
116
- const hasExplicitDescription = 'description' in frontmatter && frontmatter.description
117
-
118
- // If no explicit metadata and no other fields, don't render the block
119
- if (fields.length === 0 && !hasExplicitTitle && !hasExplicitDescription && !isDraft) {
112
+ // If no fields to show, don't render
113
+ if (fields.length === 0 && !isDraft) {
120
114
  return null
121
115
  }
122
116
 
123
117
  return (
124
118
  <div className="metadata-block">
125
- {hasExplicitTitle && <h1 className="metadata-title">{title}</h1>}
126
- {hasExplicitDescription && <p className="metadata-description">{description}</p>}
127
-
128
- {(fields.length > 0 || isDraft) && (
129
- <div className="metadata-fields">
130
- {isDraft && (
131
- <span className="metadata-field metadata-draft">
132
- <span className="metadata-chip is-draft">Draft</span>
133
- </span>
134
- )}
135
- {fields.filter(([key]) => key !== 'draft').map(([key, value]) => (
136
- <span key={key} className="metadata-field">
137
- {FIELD_ICONS[key] && <span className="metadata-icon">{FIELD_ICONS[key]}</span>}
138
- <span className="metadata-key">{key}:</span>
139
- {renderValue(key, value)}
140
- </span>
141
- ))}
142
- </div>
143
- )}
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>
144
133
  </div>
145
134
  )
146
135
  }
@@ -74,11 +74,7 @@ function PageWrapper({ Component, meta }: { Component: React.ComponentType; meta
74
74
  return (
75
75
  <>
76
76
  {meta.frontmatter && Object.keys(meta.frontmatter).length > 0 && (
77
- <MetadataBlock
78
- frontmatter={meta.frontmatter}
79
- title={meta.title}
80
- description={meta.description}
81
- />
77
+ <MetadataBlock frontmatter={meta.frontmatter} />
82
78
  )}
83
79
  <Component />
84
80
  </>
@@ -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 {
@@ -597,25 +599,7 @@ body {
597
599
  =========================================== */
598
600
 
599
601
  .metadata-block {
600
- margin-bottom: 2rem;
601
- padding-bottom: 1.5rem;
602
- border-bottom: 1px solid var(--fd-border);
603
- }
604
-
605
- .metadata-title {
606
- font-size: 2.25rem;
607
- font-weight: 700;
608
- margin: 0 0 0.5rem 0;
609
- line-height: 1.2;
610
- letter-spacing: -0.025em;
611
- color: var(--fd-foreground);
612
- }
613
-
614
- .metadata-description {
615
- font-size: 1.125rem;
616
- color: var(--fd-muted-foreground);
617
- margin: 0 0 1rem 0;
618
- line-height: 1.5;
602
+ margin-bottom: 1.5rem;
619
603
  }
620
604
 
621
605
  .metadata-fields {
@@ -697,3 +681,54 @@ body {
697
681
  .metadata-number {
698
682
  font-variant-numeric: tabular-nums;
699
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
+ }