prev-cli 0.7.0 → 0.7.2

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 };
@@ -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.0",
3
+ "version": "0.7.2",
4
4
  "description": "Transform MDX directories into beautiful documentation websites",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -0,0 +1,146 @@
1
+ import React from 'react'
2
+
3
+ type FrontmatterValue = string | number | boolean | string[] | unknown
4
+
5
+ interface MetadataBlockProps {
6
+ frontmatter: Record<string, FrontmatterValue>
7
+ title?: string
8
+ description?: string
9
+ }
10
+
11
+ // Fields to skip (shown elsewhere or internal)
12
+ const SKIP_FIELDS = new Set(['title', 'description'])
13
+
14
+ // Fields with special icons
15
+ const FIELD_ICONS: Record<string, string> = {
16
+ date: '📅',
17
+ author: '👤',
18
+ tags: '🏷️',
19
+ status: '📌',
20
+ version: '📦',
21
+ repo: '🔗',
22
+ url: '🔗',
23
+ link: '🔗',
24
+ }
25
+
26
+ /**
27
+ * Detect the display type of a value
28
+ */
29
+ function detectType(key: string, value: FrontmatterValue): 'date' | 'url' | 'boolean' | 'array' | 'number' | 'string' {
30
+ if (Array.isArray(value)) return 'array'
31
+ if (typeof value === 'boolean') return 'boolean'
32
+ if (typeof value === 'number') return 'number'
33
+ if (typeof value !== 'string') return 'string'
34
+
35
+ // Check for URL
36
+ if (value.startsWith('http://') || value.startsWith('https://')) return 'url'
37
+
38
+ // Check for date patterns
39
+ if (key === 'date' || key.includes('date') || key.includes('Date')) {
40
+ // ISO date or YYYY-MM-DD
41
+ if (/^\d{4}-\d{2}-\d{2}/.test(value)) return 'date'
42
+ }
43
+
44
+ return 'string'
45
+ }
46
+
47
+ /**
48
+ * Format a date string nicely
49
+ */
50
+ function formatDate(value: string): string {
51
+ try {
52
+ const date = new Date(value)
53
+ if (isNaN(date.getTime())) return value
54
+ return date.toLocaleDateString('en-US', {
55
+ year: 'numeric',
56
+ month: 'short',
57
+ day: 'numeric'
58
+ })
59
+ } catch {
60
+ return value
61
+ }
62
+ }
63
+
64
+ /**
65
+ * Render a single metadata value based on its type
66
+ */
67
+ function renderValue(key: string, value: FrontmatterValue): React.ReactNode {
68
+ const type = detectType(key, value)
69
+
70
+ switch (type) {
71
+ case 'array':
72
+ return (
73
+ <span className="metadata-array">
74
+ {(value as string[]).map((item, i) => (
75
+ <span key={i} className="metadata-chip">{item}</span>
76
+ ))}
77
+ </span>
78
+ )
79
+
80
+ case 'boolean':
81
+ return (
82
+ <span className={`metadata-boolean ${value ? 'is-true' : 'is-false'}`}>
83
+ {value ? '✓' : '✗'}
84
+ </span>
85
+ )
86
+
87
+ case 'url':
88
+ return (
89
+ <a href={value as string} className="metadata-link" target="_blank" rel="noopener noreferrer">
90
+ {(value as string).replace(/^https?:\/\//, '').split('/')[0]}
91
+ </a>
92
+ )
93
+
94
+ case 'date':
95
+ return <span className="metadata-date">{formatDate(value as string)}</span>
96
+
97
+ case 'number':
98
+ return <span className="metadata-number">{(value as number).toLocaleString()}</span>
99
+
100
+ default:
101
+ return <span className="metadata-string">{String(value)}</span>
102
+ }
103
+ }
104
+
105
+ export function MetadataBlock({ frontmatter, title, description }: MetadataBlockProps) {
106
+ // Filter out skipped fields and empty values
107
+ const fields = Object.entries(frontmatter).filter(
108
+ ([key, value]) => !SKIP_FIELDS.has(key) && value !== undefined && value !== null && value !== ''
109
+ )
110
+
111
+ // Check for draft status
112
+ const isDraft = frontmatter.draft === true
113
+
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) {
120
+ return null
121
+ }
122
+
123
+ return (
124
+ <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
+ )}
144
+ </div>
145
+ )
146
+ }
@@ -69,6 +69,14 @@ function createDiagramControls(container: HTMLElement): void {
69
69
  openFullscreenModal(svg.outerHTML)
70
70
  })
71
71
 
72
+ // Scroll wheel zoom on container
73
+ container.addEventListener('wheel', (e) => {
74
+ e.preventDefault()
75
+ const delta = e.deltaY > 0 ? -0.1 : 0.1
76
+ scale = Math.min(Math.max(scale + delta, minScale), maxScale)
77
+ updateScale()
78
+ })
79
+
72
80
  container.appendChild(controls)
73
81
  }
74
82
 
@@ -165,10 +173,13 @@ function openFullscreenModal(svgHtml: string): void {
165
173
  startX = e.clientX - translateX
166
174
  startY = e.clientY - translateY
167
175
  svgContainer.style.cursor = 'grabbing'
176
+ svgContainer.style.userSelect = 'none'
177
+ e.preventDefault() // Prevent text selection on drag start
168
178
  })
169
179
 
170
180
  document.addEventListener('mousemove', (e) => {
171
181
  if (!isDragging) return
182
+ e.preventDefault()
172
183
  translateX = e.clientX - startX
173
184
  translateY = e.clientY - startY
174
185
  updateTransform()
@@ -176,7 +187,10 @@ function openFullscreenModal(svgHtml: string): void {
176
187
 
177
188
  document.addEventListener('mouseup', () => {
178
189
  isDragging = false
179
- if (svgContainer) svgContainer.style.cursor = 'grab'
190
+ if (svgContainer) {
191
+ svgContainer.style.cursor = 'grab'
192
+ svgContainer.style.userSelect = ''
193
+ }
180
194
  })
181
195
 
182
196
  // Mouse wheel zoom
@@ -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,27 @@ 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
78
+ frontmatter={meta.frontmatter}
79
+ title={meta.title}
80
+ description={meta.description}
81
+ />
82
+ )}
83
+ <Component />
84
+ </>
85
+ )
68
86
  }
69
87
 
70
88
  // Root layout with custom lightweight Layout
@@ -86,12 +104,17 @@ const rootRoute = createRootRoute({
86
104
  })
87
105
 
88
106
  // Create routes from pages
89
- const pageRoutes = pages.map((page: { route: string; file: string }) => {
107
+ const pageRoutes = pages.map((page: { route: string; file: string; title?: string; description?: string; frontmatter?: Record<string, unknown> }) => {
90
108
  const Component = getPageComponent(page.file)
109
+ const meta: PageMeta = {
110
+ title: page.title,
111
+ description: page.description,
112
+ frontmatter: page.frontmatter,
113
+ }
91
114
  return createRoute({
92
115
  getParentRoute: () => rootRoute,
93
116
  path: page.route === '/' ? '/' : page.route,
94
- component: Component ? () => <PageWrapper Component={Component} /> : () => null,
117
+ component: Component ? () => <PageWrapper Component={Component} meta={meta} /> : () => null,
95
118
  })
96
119
  })
97
120
 
@@ -349,6 +349,8 @@ body {
349
349
  justify-content: center;
350
350
  cursor: grab;
351
351
  padding: 2rem;
352
+ -webkit-user-select: none;
353
+ user-select: none;
352
354
  }
353
355
 
354
356
  .diagram-modal-svg-container svg {
@@ -589,3 +591,109 @@ body {
589
591
  background: var(--fd-accent);
590
592
  color: var(--fd-accent-foreground);
591
593
  }
594
+
595
+ /* ===========================================
596
+ Metadata Block Styles
597
+ =========================================== */
598
+
599
+ .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;
619
+ }
620
+
621
+ .metadata-fields {
622
+ display: flex;
623
+ flex-wrap: wrap;
624
+ gap: 0.75rem;
625
+ align-items: center;
626
+ }
627
+
628
+ .metadata-field {
629
+ display: inline-flex;
630
+ align-items: center;
631
+ gap: 0.35rem;
632
+ font-size: 0.875rem;
633
+ color: var(--fd-muted-foreground);
634
+ }
635
+
636
+ .metadata-icon {
637
+ font-size: 0.875rem;
638
+ }
639
+
640
+ .metadata-key {
641
+ font-weight: 500;
642
+ color: var(--fd-foreground);
643
+ }
644
+
645
+ .metadata-chip {
646
+ display: inline-block;
647
+ padding: 0.15rem 0.5rem;
648
+ background: var(--fd-muted);
649
+ border-radius: 0.25rem;
650
+ font-size: 0.8rem;
651
+ color: var(--fd-foreground);
652
+ }
653
+
654
+ .metadata-chip.is-draft {
655
+ background: oklch(0.75 0.15 50);
656
+ color: oklch(0.25 0.05 50);
657
+ }
658
+
659
+ .dark .metadata-chip.is-draft {
660
+ background: oklch(0.45 0.12 50);
661
+ color: oklch(0.95 0.02 50);
662
+ }
663
+
664
+ .metadata-array {
665
+ display: inline-flex;
666
+ flex-wrap: wrap;
667
+ gap: 0.35rem;
668
+ }
669
+
670
+ .metadata-boolean {
671
+ font-weight: 600;
672
+ }
673
+
674
+ .metadata-boolean.is-true {
675
+ color: oklch(0.55 0.15 145);
676
+ }
677
+
678
+ .metadata-boolean.is-false {
679
+ color: oklch(0.55 0.15 25);
680
+ }
681
+
682
+ .metadata-link {
683
+ color: var(--fd-primary);
684
+ text-decoration: underline;
685
+ text-decoration-color: var(--fd-border);
686
+ text-underline-offset: 2px;
687
+ }
688
+
689
+ .metadata-link:hover {
690
+ text-decoration-color: var(--fd-primary);
691
+ }
692
+
693
+ .metadata-date {
694
+ font-variant-numeric: tabular-nums;
695
+ }
696
+
697
+ .metadata-number {
698
+ font-variant-numeric: tabular-nums;
699
+ }