specra 0.1.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/LICENSE.MD +21 -0
- package/README.md +157 -0
- package/dist/app/api/mdx-watch/route.d.mts +12 -0
- package/dist/app/api/mdx-watch/route.d.ts +12 -0
- package/dist/app/api/mdx-watch/route.js +98 -0
- package/dist/app/api/mdx-watch/route.js.map +1 -0
- package/dist/app/api/mdx-watch/route.mjs +71 -0
- package/dist/app/api/mdx-watch/route.mjs.map +1 -0
- package/dist/app/docs-page.d.mts +32 -0
- package/dist/app/docs-page.d.ts +32 -0
- package/dist/app/docs-page.js +4072 -0
- package/dist/app/docs-page.js.map +1 -0
- package/dist/app/docs-page.mjs +14 -0
- package/dist/app/docs-page.mjs.map +1 -0
- package/dist/app/layout.css +297 -0
- package/dist/app/layout.css.map +1 -0
- package/dist/app/layout.d.mts +19 -0
- package/dist/app/layout.d.ts +19 -0
- package/dist/app/layout.js +112 -0
- package/dist/app/layout.js.map +1 -0
- package/dist/app/layout.mjs +13 -0
- package/dist/app/layout.mjs.map +1 -0
- package/dist/chunk-DR4EPLMT.mjs +1013 -0
- package/dist/chunk-DR4EPLMT.mjs.map +1 -0
- package/dist/chunk-INL2EC72.mjs +170 -0
- package/dist/chunk-INL2EC72.mjs.map +1 -0
- package/dist/chunk-IZFGEAD6.mjs +61 -0
- package/dist/chunk-IZFGEAD6.mjs.map +1 -0
- package/dist/chunk-KTRWWAGL.mjs +50 -0
- package/dist/chunk-KTRWWAGL.mjs.map +1 -0
- package/dist/chunk-MZJHJ6BV.mjs +21 -0
- package/dist/chunk-MZJHJ6BV.mjs.map +1 -0
- package/dist/chunk-NXRIAL7T.mjs +3119 -0
- package/dist/chunk-NXRIAL7T.mjs.map +1 -0
- package/dist/components/index.d.mts +822 -0
- package/dist/components/index.d.ts +822 -0
- package/dist/components/index.js +3738 -0
- package/dist/components/index.js.map +1 -0
- package/dist/components/index.mjs +3627 -0
- package/dist/components/index.mjs.map +1 -0
- package/dist/index.css +297 -0
- package/dist/index.css.map +1 -0
- package/dist/index.d.mts +545 -0
- package/dist/index.d.ts +545 -0
- package/dist/index.js +4648 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +347 -0
- package/dist/index.mjs.map +1 -0
- package/dist/lib/index.d.mts +798 -0
- package/dist/lib/index.d.ts +798 -0
- package/dist/lib/index.js +1301 -0
- package/dist/lib/index.js.map +1 -0
- package/dist/lib/index.mjs +89 -0
- package/dist/lib/index.mjs.map +1 -0
- package/package.json +119 -0
- package/src/app/api/mdx-watch/route.ts +86 -0
- package/src/app/docs-page.tsx +212 -0
- package/src/app/layout.tsx +74 -0
- package/src/components/docs/accordion.tsx +53 -0
- package/src/components/docs/api/api-endpoint.tsx +59 -0
- package/src/components/docs/api/api-params.tsx +43 -0
- package/src/components/docs/api/api-playground.tsx +233 -0
- package/src/components/docs/api/api-reference.tsx +291 -0
- package/src/components/docs/api/api-response.tsx +48 -0
- package/src/components/docs/api/index.ts +5 -0
- package/src/components/docs/badge.tsx +22 -0
- package/src/components/docs/breadcrumb.tsx +51 -0
- package/src/components/docs/callout.tsx +109 -0
- package/src/components/docs/card.tsx +84 -0
- package/src/components/docs/category-index.tsx +112 -0
- package/src/components/docs/code-block.tsx +129 -0
- package/src/components/docs/columns.tsx +45 -0
- package/src/components/docs/componentTextProps.ts +85 -0
- package/src/components/docs/dev-mode-badge.tsx +35 -0
- package/src/components/docs/doc-layout-wrapper.tsx +54 -0
- package/src/components/docs/doc-layout.tsx +111 -0
- package/src/components/docs/doc-loading.tsx +15 -0
- package/src/components/docs/doc-metadata.tsx +55 -0
- package/src/components/docs/doc-navigation.tsx +62 -0
- package/src/components/docs/doc-tags.tsx +25 -0
- package/src/components/docs/draft-badge.tsx +10 -0
- package/src/components/docs/footer.tsx +47 -0
- package/src/components/docs/frame.tsx +22 -0
- package/src/components/docs/header.tsx +122 -0
- package/src/components/docs/hot-reload-indicator.tsx +77 -0
- package/src/components/docs/icon.tsx +70 -0
- package/src/components/docs/image-card.tsx +95 -0
- package/src/components/docs/image.tsx +73 -0
- package/src/components/docs/index.ts +48 -0
- package/src/components/docs/math.tsx +46 -0
- package/src/components/docs/mdx-components.tsx +166 -0
- package/src/components/docs/mdx-hot-reload.tsx +37 -0
- package/src/components/docs/mermaid.tsx +77 -0
- package/src/components/docs/mobile-doc-layout.tsx +115 -0
- package/src/components/docs/not-found-content.tsx +55 -0
- package/src/components/docs/search-highlight.tsx +127 -0
- package/src/components/docs/search-modal.tsx +223 -0
- package/src/components/docs/sidebar-skeleton.tsx +39 -0
- package/src/components/docs/sidebar.tsx +323 -0
- package/src/components/docs/site-banner.tsx +92 -0
- package/src/components/docs/steps.tsx +29 -0
- package/src/components/docs/tab-context.tsx +28 -0
- package/src/components/docs/tab-groups.tsx +50 -0
- package/src/components/docs/table-of-contents.tsx +104 -0
- package/src/components/docs/tabs.tsx +63 -0
- package/src/components/docs/theme-toggle.tsx +39 -0
- package/src/components/docs/tooltip.tsx +37 -0
- package/src/components/docs/version-switcher.tsx +52 -0
- package/src/components/docs/video.tsx +80 -0
- package/src/components/global/index.ts +3 -0
- package/src/components/global/version-not-found.tsx +26 -0
- package/src/components/index.ts +8 -0
- package/src/components/theme-provider.tsx +11 -0
- package/src/components/ui/badge.tsx +46 -0
- package/src/components/ui/button.tsx +60 -0
- package/src/components/ui/dialog.tsx +143 -0
- package/src/components/ui/index.ts +6 -0
- package/src/components/ui/input.tsx +21 -0
- package/src/components/ui/textarea.tsx +18 -0
- package/src/index.ts +41 -0
- package/src/lib/api-parser.types.ts +78 -0
- package/src/lib/api.types.ts +202 -0
- package/src/lib/category.ts +71 -0
- package/src/lib/config.server.ts +170 -0
- package/src/lib/config.ts +20 -0
- package/src/lib/config.types.ts +295 -0
- package/src/lib/dev-utils.ts +75 -0
- package/src/lib/index.ts +27 -0
- package/src/lib/mdx-cache.ts +200 -0
- package/src/lib/mdx.ts +402 -0
- package/src/lib/parsers/base-parser.ts +16 -0
- package/src/lib/parsers/index.ts +69 -0
- package/src/lib/parsers/openapi-parser.ts +251 -0
- package/src/lib/parsers/postman-parser.ts +301 -0
- package/src/lib/parsers/specra-parser.ts +24 -0
- package/src/lib/redirects.ts +40 -0
- package/src/lib/remark-code-meta.ts +23 -0
- package/src/lib/sidebar-utils.ts +188 -0
- package/src/lib/toc.ts +24 -0
- package/src/lib/utils.ts +36 -0
- package/src/specra.config.json +124 -0
- package/src/styles/globals.css +427 -0
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import { useState, useEffect, useCallback } from "react"
|
|
4
|
+
import { Search, FileText, Loader2 } from "lucide-react"
|
|
5
|
+
import { useRouter } from "next/navigation"
|
|
6
|
+
import type { SpecraConfig } from "@/lib/config"
|
|
7
|
+
import {
|
|
8
|
+
Dialog,
|
|
9
|
+
DialogContent,
|
|
10
|
+
} from "@/components/ui/dialog"
|
|
11
|
+
|
|
12
|
+
interface SearchResult {
|
|
13
|
+
id: string
|
|
14
|
+
title: string
|
|
15
|
+
content: string
|
|
16
|
+
slug: string
|
|
17
|
+
version: string
|
|
18
|
+
category?: string
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
interface SearchModalProps {
|
|
22
|
+
isOpen: boolean
|
|
23
|
+
onClose: () => void
|
|
24
|
+
config: SpecraConfig
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function SearchModal({ isOpen, onClose, config }: SearchModalProps) {
|
|
28
|
+
const [query, setQuery] = useState("")
|
|
29
|
+
const [results, setResults] = useState<SearchResult[]>([])
|
|
30
|
+
const [isLoading, setIsLoading] = useState(false)
|
|
31
|
+
const [selectedIndex, setSelectedIndex] = useState(0)
|
|
32
|
+
const router = useRouter()
|
|
33
|
+
|
|
34
|
+
const searchConfig = config.search
|
|
35
|
+
|
|
36
|
+
// Search function
|
|
37
|
+
const performSearch = useCallback(async (searchQuery: string) => {
|
|
38
|
+
if (!searchQuery.trim() || !searchConfig?.enabled) {
|
|
39
|
+
setResults([])
|
|
40
|
+
return
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
setIsLoading(true)
|
|
44
|
+
try {
|
|
45
|
+
const response = await fetch("/api/search", {
|
|
46
|
+
method: "POST",
|
|
47
|
+
headers: { "Content-Type": "application/json" },
|
|
48
|
+
body: JSON.stringify({
|
|
49
|
+
query: searchQuery,
|
|
50
|
+
// filter: 'version = "v1.0.0"',
|
|
51
|
+
distinct: "version",
|
|
52
|
+
limit: 2
|
|
53
|
+
}),
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
if (response.ok) {
|
|
58
|
+
const data = await response.json()
|
|
59
|
+
console.log("Search response:", data)
|
|
60
|
+
setResults(data.hits || [])
|
|
61
|
+
} else {
|
|
62
|
+
console.error("Search failed:", response.status, await response.text())
|
|
63
|
+
}
|
|
64
|
+
} catch (error) {
|
|
65
|
+
console.error("Search error:", error)
|
|
66
|
+
setResults([])
|
|
67
|
+
} finally {
|
|
68
|
+
setIsLoading(false)
|
|
69
|
+
}
|
|
70
|
+
}, [searchConfig])
|
|
71
|
+
|
|
72
|
+
// Debounced search
|
|
73
|
+
useEffect(() => {
|
|
74
|
+
const timer = setTimeout(() => {
|
|
75
|
+
performSearch(query)
|
|
76
|
+
}, 300)
|
|
77
|
+
|
|
78
|
+
return () => clearTimeout(timer)
|
|
79
|
+
}, [query, performSearch])
|
|
80
|
+
|
|
81
|
+
// Handle keyboard navigation
|
|
82
|
+
useEffect(() => {
|
|
83
|
+
const handleKeyDown = (e: KeyboardEvent) => {
|
|
84
|
+
if (!isOpen) return
|
|
85
|
+
|
|
86
|
+
switch (e.key) {
|
|
87
|
+
case "Escape":
|
|
88
|
+
onClose()
|
|
89
|
+
break
|
|
90
|
+
case "ArrowDown":
|
|
91
|
+
e.preventDefault()
|
|
92
|
+
setSelectedIndex((prev) => Math.min(prev + 1, results.length - 1))
|
|
93
|
+
break
|
|
94
|
+
case "ArrowUp":
|
|
95
|
+
e.preventDefault()
|
|
96
|
+
setSelectedIndex((prev) => Math.max(prev - 1, 0))
|
|
97
|
+
break
|
|
98
|
+
case "Enter":
|
|
99
|
+
e.preventDefault()
|
|
100
|
+
if (results[selectedIndex]) {
|
|
101
|
+
handleResultClick(results[selectedIndex])
|
|
102
|
+
}
|
|
103
|
+
break
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
window.addEventListener("keydown", handleKeyDown)
|
|
108
|
+
return () => window.removeEventListener("keydown", handleKeyDown)
|
|
109
|
+
}, [isOpen, results, selectedIndex, onClose])
|
|
110
|
+
|
|
111
|
+
// Reset on open/close
|
|
112
|
+
useEffect(() => {
|
|
113
|
+
if (isOpen) {
|
|
114
|
+
setQuery("")
|
|
115
|
+
setResults([])
|
|
116
|
+
setSelectedIndex(0)
|
|
117
|
+
}
|
|
118
|
+
}, [isOpen])
|
|
119
|
+
|
|
120
|
+
const handleResultClick = (result: SearchResult) => {
|
|
121
|
+
// Add search query as URL parameter for highlighting
|
|
122
|
+
const url = `/docs/${result.version}/${result.slug}?q=${encodeURIComponent(query)}`
|
|
123
|
+
router.push(url)
|
|
124
|
+
onClose()
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const highlightText = (text: string, query: string) => {
|
|
128
|
+
if (!query.trim()) return text
|
|
129
|
+
|
|
130
|
+
const parts = text.split(new RegExp(`(${query})`, "gi"))
|
|
131
|
+
return parts.map((part, i) =>
|
|
132
|
+
part.toLowerCase() === query.toLowerCase()
|
|
133
|
+
? <mark key={i} className="bg-yellow-200 dark:bg-yellow-900/50 text-foreground">{part}</mark>
|
|
134
|
+
: part
|
|
135
|
+
)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return (
|
|
139
|
+
<Dialog open={isOpen} onOpenChange={onClose} modal={true}>
|
|
140
|
+
<DialogContent
|
|
141
|
+
className="max-w-2xl p-0 gap-0 top-[10vh] translate-y-0"
|
|
142
|
+
showCloseButton={false}
|
|
143
|
+
onOpenAutoFocus={(e) => e.preventDefault()}
|
|
144
|
+
>
|
|
145
|
+
{/* Search Input */}
|
|
146
|
+
<div className="flex items-center gap-3 px-4 py-3 border-b border-border">
|
|
147
|
+
<Search className="h-5 w-5 text-muted-foreground shrink-0" />
|
|
148
|
+
<input
|
|
149
|
+
type="text"
|
|
150
|
+
value={query}
|
|
151
|
+
onChange={(e) => setQuery(e.target.value)}
|
|
152
|
+
placeholder={searchConfig?.placeholder || "Search documentation..."}
|
|
153
|
+
className="flex-1 bg-transparent border-none outline-none text-foreground placeholder:text-muted-foreground"
|
|
154
|
+
autoFocus
|
|
155
|
+
/>
|
|
156
|
+
{isLoading && <Loader2 className="h-5 w-5 text-muted-foreground animate-spin" />}
|
|
157
|
+
</div>
|
|
158
|
+
|
|
159
|
+
{/* Results */}
|
|
160
|
+
<div className="max-h-[60vh] overflow-y-auto">
|
|
161
|
+
{query.trim() && results.length === 0 && !isLoading && (
|
|
162
|
+
<div className="px-4 py-8 text-center text-muted-foreground">
|
|
163
|
+
No results found for "{query}"
|
|
164
|
+
</div>
|
|
165
|
+
)}
|
|
166
|
+
|
|
167
|
+
{results.length > 0 && (
|
|
168
|
+
<div className="py-2">
|
|
169
|
+
{results.map((result, index) => (
|
|
170
|
+
<button
|
|
171
|
+
key={result.id}
|
|
172
|
+
onClick={() => handleResultClick(result)}
|
|
173
|
+
className={`w-full px-4 py-3 text-left hover:bg-muted/50 transition-colors border-l-2 ${index === selectedIndex
|
|
174
|
+
? "bg-muted/50 border-primary"
|
|
175
|
+
: "border-transparent"
|
|
176
|
+
}`}
|
|
177
|
+
onMouseEnter={() => setSelectedIndex(index)}
|
|
178
|
+
>
|
|
179
|
+
<div className="flex items-start gap-3">
|
|
180
|
+
<FileText className="h-5 w-5 text-muted-foreground shrink-0 mt-0.5" />
|
|
181
|
+
<div className="flex-1 min-w-0">
|
|
182
|
+
<div className="font-medium text-foreground mb-1">
|
|
183
|
+
{highlightText(result.title, query)}
|
|
184
|
+
</div>
|
|
185
|
+
{result.content && (
|
|
186
|
+
<div className="text-sm text-muted-foreground line-clamp-2">
|
|
187
|
+
{highlightText(result.content, query)}
|
|
188
|
+
</div>
|
|
189
|
+
)}
|
|
190
|
+
<div className="flex items-center gap-2 mt-1 text-xs text-muted-foreground">
|
|
191
|
+
<span>{result.version}</span>
|
|
192
|
+
{result.category && (
|
|
193
|
+
<>
|
|
194
|
+
<span>•</span>
|
|
195
|
+
<span>{result.category}</span>
|
|
196
|
+
</>
|
|
197
|
+
)}
|
|
198
|
+
</div>
|
|
199
|
+
</div>
|
|
200
|
+
</div>
|
|
201
|
+
</button>
|
|
202
|
+
))}
|
|
203
|
+
</div>
|
|
204
|
+
)}
|
|
205
|
+
|
|
206
|
+
{!query.trim() && (
|
|
207
|
+
<div className="px-4 py-8 text-center text-muted-foreground text-sm">
|
|
208
|
+
<p>Start typing to search documentation...</p>
|
|
209
|
+
<div className="mt-4 flex items-center justify-center gap-4 text-xs">
|
|
210
|
+
<kbd className="px-2 py-1 bg-muted rounded border border-border">↑↓</kbd>
|
|
211
|
+
<span>Navigate</span>
|
|
212
|
+
<kbd className="px-2 py-1 bg-muted rounded border border-border">Enter</kbd>
|
|
213
|
+
<span>Select</span>
|
|
214
|
+
<kbd className="px-2 py-1 bg-muted rounded border border-border">Esc</kbd>
|
|
215
|
+
<span>Close</span>
|
|
216
|
+
</div>
|
|
217
|
+
</div>
|
|
218
|
+
)}
|
|
219
|
+
</div>
|
|
220
|
+
</DialogContent>
|
|
221
|
+
</Dialog>
|
|
222
|
+
)
|
|
223
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
export function SidebarSkeleton() {
|
|
2
|
+
return (
|
|
3
|
+
<aside className="w-64 pr-8 py-6">
|
|
4
|
+
<div className="space-y-6">
|
|
5
|
+
{/* Documentation title */}
|
|
6
|
+
<div className="px-2">
|
|
7
|
+
<div className="h-5 w-32 bg-muted/50 rounded animate-pulse" />
|
|
8
|
+
</div>
|
|
9
|
+
|
|
10
|
+
{/* Skeleton items */}
|
|
11
|
+
<div className="space-y-1">
|
|
12
|
+
{[...Array(8)].map((_, i) => (
|
|
13
|
+
<div key={i} className="px-3 py-2">
|
|
14
|
+
<div
|
|
15
|
+
className="h-4 bg-muted/50 rounded animate-pulse"
|
|
16
|
+
style={{ width: `${60 + Math.random() * 40}%` }}
|
|
17
|
+
/>
|
|
18
|
+
</div>
|
|
19
|
+
))}
|
|
20
|
+
</div>
|
|
21
|
+
|
|
22
|
+
{/* Another section */}
|
|
23
|
+
<div className="space-y-1">
|
|
24
|
+
<div className="px-2 mb-2">
|
|
25
|
+
<div className="h-4 w-24 bg-muted/50 rounded animate-pulse" />
|
|
26
|
+
</div>
|
|
27
|
+
{[...Array(5)].map((_, i) => (
|
|
28
|
+
<div key={i} className="px-3 py-2">
|
|
29
|
+
<div
|
|
30
|
+
className="h-4 bg-muted/50 rounded animate-pulse"
|
|
31
|
+
style={{ width: `${50 + Math.random() * 50}%` }}
|
|
32
|
+
/>
|
|
33
|
+
</div>
|
|
34
|
+
))}
|
|
35
|
+
</div>
|
|
36
|
+
</div>
|
|
37
|
+
</aside>
|
|
38
|
+
)
|
|
39
|
+
}
|
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import Link from "next/link"
|
|
4
|
+
import { usePathname } from "next/navigation"
|
|
5
|
+
import { ChevronRight, ChevronDown, FolderOpen } from "lucide-react"
|
|
6
|
+
import { useState } from "react"
|
|
7
|
+
import type { SpecraConfig } from "@/lib/config"
|
|
8
|
+
import { Icon } from "./icon"
|
|
9
|
+
import { sortSidebarItems, sortSidebarGroups } from "@/lib/sidebar-utils"
|
|
10
|
+
|
|
11
|
+
interface DocItem {
|
|
12
|
+
title: string
|
|
13
|
+
slug: string
|
|
14
|
+
filePath: string
|
|
15
|
+
section?: string
|
|
16
|
+
group?: string
|
|
17
|
+
sidebar?: string
|
|
18
|
+
sidebar_position?: number
|
|
19
|
+
categoryLabel?: string
|
|
20
|
+
categoryPosition?: number
|
|
21
|
+
categoryCollapsible?: boolean
|
|
22
|
+
categoryCollapsed?: boolean
|
|
23
|
+
categoryIcon?: string // Icon from _category_.json
|
|
24
|
+
categoryTabGroup?: string // Tab group from _category_.json
|
|
25
|
+
meta?: {
|
|
26
|
+
icon?: string // Icon name from frontmatter
|
|
27
|
+
tab_group?: string // Tab group from frontmatter
|
|
28
|
+
[key: string]: any
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
interface SidebarProps {
|
|
33
|
+
docs: DocItem[]
|
|
34
|
+
version: string
|
|
35
|
+
onLinkClick?: () => void
|
|
36
|
+
config: SpecraConfig
|
|
37
|
+
activeTabGroup?: string // Current active tab group filter
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
interface SidebarGroup {
|
|
41
|
+
label: string
|
|
42
|
+
path: string // Path for navigation (e.g., "components" for /docs/v1.0.0/components)
|
|
43
|
+
icon?: string // Icon from _category_.json
|
|
44
|
+
items: DocItem[]
|
|
45
|
+
position: number
|
|
46
|
+
collapsible: boolean
|
|
47
|
+
defaultCollapsed: boolean
|
|
48
|
+
children: Record<string, SidebarGroup>
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function Sidebar({ docs, version, onLinkClick, config, activeTabGroup }: SidebarProps) {
|
|
52
|
+
const pathname = usePathname()
|
|
53
|
+
const [collapsed, setCollapsed] = useState<Record<string, boolean>>(() => {
|
|
54
|
+
const initial: Record<string, boolean> = {}
|
|
55
|
+
return initial
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
if (!config.navigation?.showSidebar) {
|
|
59
|
+
return null
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Filter docs by active tab group if tab groups are configured
|
|
63
|
+
const hasTabGroups = config.navigation?.tabGroups && config.navigation.tabGroups.length > 0
|
|
64
|
+
const filteredDocs = hasTabGroups && activeTabGroup
|
|
65
|
+
? docs.filter((doc) => {
|
|
66
|
+
// Get tab group from either frontmatter or category config
|
|
67
|
+
const docTabGroup = doc.meta?.tab_group || doc.categoryTabGroup
|
|
68
|
+
|
|
69
|
+
// If doc has no tab group, include it in the first tab group
|
|
70
|
+
if (!docTabGroup) {
|
|
71
|
+
return activeTabGroup === config.navigation?.tabGroups?.[0]?.id
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return docTabGroup === activeTabGroup
|
|
75
|
+
})
|
|
76
|
+
: docs
|
|
77
|
+
|
|
78
|
+
// Build a hierarchical tree structure
|
|
79
|
+
const rootGroups: Record<string, SidebarGroup> = {}
|
|
80
|
+
const standalone: DocItem[] = []
|
|
81
|
+
|
|
82
|
+
filteredDocs.forEach((doc) => {
|
|
83
|
+
const pathParts = doc.filePath.split("/")
|
|
84
|
+
const isIndexFile = doc.filePath.endsWith("/index") ||
|
|
85
|
+
doc.filePath === "index" ||
|
|
86
|
+
(pathParts.length > 1 && doc.slug === pathParts.slice(0, -1).join("/"))
|
|
87
|
+
|
|
88
|
+
// Use the sidebar or group from frontmatter if provided
|
|
89
|
+
const customGroup = doc.sidebar || doc.group
|
|
90
|
+
|
|
91
|
+
if (customGroup) {
|
|
92
|
+
const groupName = customGroup.charAt(0).toUpperCase() + customGroup.slice(1)
|
|
93
|
+
if (!rootGroups[groupName]) {
|
|
94
|
+
rootGroups[groupName] = {
|
|
95
|
+
label: groupName,
|
|
96
|
+
path: customGroup,
|
|
97
|
+
items: [],
|
|
98
|
+
position: 999,
|
|
99
|
+
collapsible: doc.categoryCollapsible ?? true,
|
|
100
|
+
defaultCollapsed: doc.categoryCollapsed ?? false,
|
|
101
|
+
children: {}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
if (isIndexFile) {
|
|
105
|
+
rootGroups[groupName].position = doc.sidebar_position ?? 999
|
|
106
|
+
rootGroups[groupName].icon = doc.categoryIcon
|
|
107
|
+
} else {
|
|
108
|
+
rootGroups[groupName].items.push(doc)
|
|
109
|
+
}
|
|
110
|
+
return
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Build nested structure based on folder path
|
|
114
|
+
if (pathParts.length > 1) {
|
|
115
|
+
const folderParts = pathParts.slice(0, -1) // All folders except the file
|
|
116
|
+
|
|
117
|
+
// Navigate/create the tree structure
|
|
118
|
+
let currentLevel = rootGroups
|
|
119
|
+
let currentPath = ""
|
|
120
|
+
|
|
121
|
+
for (let i = 0; i < folderParts.length; i++) {
|
|
122
|
+
const folder = folderParts[i]
|
|
123
|
+
currentPath = currentPath ? `${currentPath}/${folder}` : folder
|
|
124
|
+
const folderLabel = folder.split("-").map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(" ")
|
|
125
|
+
|
|
126
|
+
if (!currentLevel[folder]) {
|
|
127
|
+
currentLevel[folder] = {
|
|
128
|
+
label: doc.categoryLabel && i === folderParts.length - 1 ? doc.categoryLabel : folderLabel,
|
|
129
|
+
path: currentPath,
|
|
130
|
+
icon: doc.categoryIcon,
|
|
131
|
+
items: [],
|
|
132
|
+
position: doc.categoryPosition ?? 999,
|
|
133
|
+
collapsible: doc.categoryCollapsible ?? true,
|
|
134
|
+
defaultCollapsed: doc.categoryCollapsed ?? false,
|
|
135
|
+
children: {}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// If this is the deepest folder (where the file lives), add the doc
|
|
140
|
+
if (i === folderParts.length - 1) {
|
|
141
|
+
if (isIndexFile) {
|
|
142
|
+
currentLevel[folder].position = doc.categoryPosition ?? doc.sidebar_position ?? 999
|
|
143
|
+
// Update label and icon from category config if available
|
|
144
|
+
if (doc.categoryLabel) {
|
|
145
|
+
currentLevel[folder].label = doc.categoryLabel
|
|
146
|
+
}
|
|
147
|
+
if (doc.categoryIcon) {
|
|
148
|
+
currentLevel[folder].icon = doc.categoryIcon
|
|
149
|
+
}
|
|
150
|
+
} else {
|
|
151
|
+
currentLevel[folder].items.push(doc)
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
currentLevel = currentLevel[folder].children
|
|
156
|
+
}
|
|
157
|
+
} else {
|
|
158
|
+
if (!isIndexFile) {
|
|
159
|
+
standalone.push(doc)
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
const toggleSection = (section: string) => {
|
|
165
|
+
setCollapsed((prev) => ({ ...prev, [section]: !prev[section] }))
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Recursive component to render nested groups
|
|
169
|
+
const renderGroup = (groupKey: string, group: SidebarGroup, depth: number = 0) => {
|
|
170
|
+
const sortedItems = sortSidebarItems(group.items)
|
|
171
|
+
const sortedChildren = sortSidebarGroups(group.children)
|
|
172
|
+
const hasChildren = sortedChildren.length > 0
|
|
173
|
+
const hasItems = sortedItems.length > 0
|
|
174
|
+
const hasContent = hasChildren || hasItems
|
|
175
|
+
|
|
176
|
+
// Check if any item in this group (or nested children) is active
|
|
177
|
+
const isActiveInGroup = (g: SidebarGroup): boolean => {
|
|
178
|
+
const hasActiveItem = g.items.some((doc) => pathname === `/docs/${version}/${doc.slug}`)
|
|
179
|
+
if (hasActiveItem) return true
|
|
180
|
+
return Object.values(g.children).some(child => isActiveInGroup(child))
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const hasActiveItem = isActiveInGroup(group)
|
|
184
|
+
const isGroupActive = pathname === `/docs/${version}/${group.path}`
|
|
185
|
+
const isCollapsed = hasActiveItem || isGroupActive ? false : (collapsed[groupKey] ?? group.defaultCollapsed)
|
|
186
|
+
const marginLeft = depth > 0 ? "ml-4" : ""
|
|
187
|
+
const groupHref = `/docs/${version}/${group.path}`
|
|
188
|
+
|
|
189
|
+
return (
|
|
190
|
+
<div key={`group-${groupKey}`} className={`space-y-1 ${marginLeft}`}>
|
|
191
|
+
{/* Group header: Docusaurus-style with clickable label and chevron toggle */}
|
|
192
|
+
<div className="flex items-center group">
|
|
193
|
+
{/* Icon + Label (clickable, navigates to index) */}
|
|
194
|
+
<Link
|
|
195
|
+
href={groupHref}
|
|
196
|
+
onClick={onLinkClick}
|
|
197
|
+
className={`flex items-center gap-2 flex-1 px-3 py-2 text-sm font-semibold rounded-l-xl transition-all ${isGroupActive
|
|
198
|
+
? "bg-primary/10 text-primary"
|
|
199
|
+
: "text-foreground hover:bg-accent/50"
|
|
200
|
+
}`}
|
|
201
|
+
>
|
|
202
|
+
{group.icon ? (
|
|
203
|
+
<Icon icon={group.icon} size={16} className="shrink-0" />
|
|
204
|
+
) : (
|
|
205
|
+
<FolderOpen size={16} className="shrink-0" />
|
|
206
|
+
)}
|
|
207
|
+
{group.label}
|
|
208
|
+
</Link>
|
|
209
|
+
|
|
210
|
+
{/* Chevron toggle (only if has content and is collapsible) */}
|
|
211
|
+
{hasContent && group.collapsible && config.navigation?.collapsibleSidebar && (
|
|
212
|
+
<button
|
|
213
|
+
onClick={(e) => {
|
|
214
|
+
e.preventDefault()
|
|
215
|
+
e.stopPropagation()
|
|
216
|
+
toggleSection(groupKey)
|
|
217
|
+
}}
|
|
218
|
+
className={`p-2 rounded-r-xl transition-all ${isGroupActive ? "hover:bg-primary/20" : "hover:bg-accent/50"}`}
|
|
219
|
+
aria-label={isCollapsed ? "Expand section" : "Collapse section"}
|
|
220
|
+
>
|
|
221
|
+
{isCollapsed ? (
|
|
222
|
+
<ChevronRight className={`h-4 w-4 ${isGroupActive ? "text-primary" : "text-muted-foreground"}`} />
|
|
223
|
+
) : (
|
|
224
|
+
<ChevronDown className={`h-4 w-4 ${isGroupActive ? "text-primary" : "text-muted-foreground"}`} />
|
|
225
|
+
)}
|
|
226
|
+
</button>
|
|
227
|
+
)}
|
|
228
|
+
</div>
|
|
229
|
+
|
|
230
|
+
{/* Children (shown when not collapsed) */}
|
|
231
|
+
{!isCollapsed && hasContent && (
|
|
232
|
+
<div className="ml-4 space-y-1">
|
|
233
|
+
{/* Merge and sort both child groups and items by position */}
|
|
234
|
+
{(() => {
|
|
235
|
+
// Create a unified list with type indicators
|
|
236
|
+
const merged: Array<{type: 'group', key: string, group: SidebarGroup, position: number} | {type: 'item', doc: DocItem, position: number}> = [
|
|
237
|
+
...sortedChildren.map(([childKey, childGroup]) => ({
|
|
238
|
+
type: 'group' as const,
|
|
239
|
+
key: childKey,
|
|
240
|
+
group: childGroup,
|
|
241
|
+
position: childGroup.position
|
|
242
|
+
})),
|
|
243
|
+
...sortedItems.map((doc) => ({
|
|
244
|
+
type: 'item' as const,
|
|
245
|
+
doc,
|
|
246
|
+
position: doc.sidebar_position ?? doc.meta?.sidebar_position ?? doc.meta?.order ?? 999
|
|
247
|
+
}))
|
|
248
|
+
]
|
|
249
|
+
|
|
250
|
+
// Sort by position
|
|
251
|
+
merged.sort((a, b) => a.position - b.position)
|
|
252
|
+
|
|
253
|
+
// Render in sorted order
|
|
254
|
+
return merged.map((item) => {
|
|
255
|
+
if (item.type === 'group') {
|
|
256
|
+
return renderGroup(`${groupKey}/${item.key}`, item.group, depth + 1)
|
|
257
|
+
} else {
|
|
258
|
+
const href = `/docs/${version}/${item.doc.slug}`
|
|
259
|
+
const isActive = pathname === href
|
|
260
|
+
|
|
261
|
+
return (
|
|
262
|
+
<Link
|
|
263
|
+
key={`grouped-${item.doc.slug}`}
|
|
264
|
+
href={href}
|
|
265
|
+
onClick={onLinkClick}
|
|
266
|
+
className={`flex items-center gap-2 px-3 py-2 text-sm rounded-xl transition-all ${isActive
|
|
267
|
+
? "bg-primary/10 text-primary font-medium"
|
|
268
|
+
: "text-foreground hover:text-foreground hover:bg-accent/50"
|
|
269
|
+
}`}
|
|
270
|
+
>
|
|
271
|
+
{item.doc.meta?.icon && <Icon icon={item.doc.meta.icon} size={16} className="shrink-0" />}
|
|
272
|
+
{item.doc.title}
|
|
273
|
+
</Link>
|
|
274
|
+
)
|
|
275
|
+
}
|
|
276
|
+
})
|
|
277
|
+
})()}
|
|
278
|
+
</div>
|
|
279
|
+
)}
|
|
280
|
+
</div>
|
|
281
|
+
)
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const sortedRootGroups = sortSidebarGroups(rootGroups)
|
|
285
|
+
const sortedStandalone = sortSidebarItems(standalone)
|
|
286
|
+
|
|
287
|
+
// Adjust top position based on whether tabs are present
|
|
288
|
+
const stickyTop = hasTabGroups ? "top-[7.5rem]" : "top-24"
|
|
289
|
+
const maxHeight = hasTabGroups ? "max-h-[calc(100vh-10rem)]" : "max-h-[calc(100vh-7rem)]"
|
|
290
|
+
|
|
291
|
+
return (
|
|
292
|
+
<aside className={`w-64 shrink-0 sticky ${stickyTop} self-start`}>
|
|
293
|
+
<div className={`${maxHeight} overflow-y-auto bg-muted/30 dark:bg-muted/10 rounded-2xl p-4 border border-border/50`}>
|
|
294
|
+
<h2 className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-4 px-2">Documentation</h2>
|
|
295
|
+
<nav className="space-y-1">
|
|
296
|
+
{/* Standalone pages (not in folders) */}
|
|
297
|
+
{sortedStandalone.length > 0 && sortedStandalone.map((doc) => {
|
|
298
|
+
const href = `/docs/${version}/${doc.slug}`
|
|
299
|
+
const isActive = pathname === href
|
|
300
|
+
|
|
301
|
+
return (
|
|
302
|
+
<Link
|
|
303
|
+
key={`standalone-${doc.slug}`}
|
|
304
|
+
href={href}
|
|
305
|
+
onClick={onLinkClick}
|
|
306
|
+
className={`flex items-center gap-2 px-3 py-2 text-sm rounded-xl transition-all ${isActive
|
|
307
|
+
? "bg-primary/10 text-primary font-medium"
|
|
308
|
+
: "text-foreground hover:text-foreground hover:bg-accent/50"
|
|
309
|
+
}`}
|
|
310
|
+
>
|
|
311
|
+
{doc.meta?.icon && <Icon icon={doc.meta.icon} size={16} className="shrink-0" />}
|
|
312
|
+
{doc.title}
|
|
313
|
+
</Link>
|
|
314
|
+
)
|
|
315
|
+
})}
|
|
316
|
+
|
|
317
|
+
{/* Grouped pages (in folders) - now hierarchical */}
|
|
318
|
+
{sortedRootGroups.map(([groupKey, group]) => renderGroup(groupKey, group, 0))}
|
|
319
|
+
</nav>
|
|
320
|
+
</div>
|
|
321
|
+
</aside>
|
|
322
|
+
)
|
|
323
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import { X, AlertCircle, CheckCircle, Info, XCircle } from "lucide-react"
|
|
4
|
+
import { useState, useEffect } from "react"
|
|
5
|
+
import type { SpecraConfig } from "@/lib/config"
|
|
6
|
+
|
|
7
|
+
interface SiteBannerProps {
|
|
8
|
+
config: SpecraConfig
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function SiteBanner({ config }: SiteBannerProps) {
|
|
12
|
+
const [dismissed, setDismissed] = useState(false)
|
|
13
|
+
const [mounted, setMounted] = useState(false)
|
|
14
|
+
|
|
15
|
+
const banner = config.banner
|
|
16
|
+
const storageKey = "site-banner-dismissed"
|
|
17
|
+
|
|
18
|
+
useEffect(() => {
|
|
19
|
+
setMounted(true)
|
|
20
|
+
// Check if banner was previously dismissed
|
|
21
|
+
const isDismissed = localStorage.getItem(storageKey) === "true"
|
|
22
|
+
setDismissed(isDismissed)
|
|
23
|
+
}, [])
|
|
24
|
+
|
|
25
|
+
const handleDismiss = () => {
|
|
26
|
+
setDismissed(true)
|
|
27
|
+
localStorage.setItem(storageKey, "true")
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Don't render on server or if no banner configured or if dismissed
|
|
31
|
+
if (!mounted || !banner || !banner.enabled || dismissed) {
|
|
32
|
+
return null
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const typeConfig = {
|
|
36
|
+
info: {
|
|
37
|
+
icon: Info,
|
|
38
|
+
bg: "bg-blue-500/10 dark:bg-blue-400/5",
|
|
39
|
+
border: "border-blue-500/30 dark:border-blue-500/20",
|
|
40
|
+
iconColor: "text-blue-600 dark:text-blue-400",
|
|
41
|
+
textColor: "text-blue-900 dark:text-blue-300",
|
|
42
|
+
},
|
|
43
|
+
success: {
|
|
44
|
+
icon: CheckCircle,
|
|
45
|
+
bg: "bg-green-500/10 dark:bg-green-400/5",
|
|
46
|
+
border: "border-green-500/30 dark:border-green-500/20",
|
|
47
|
+
iconColor: "text-green-600 dark:text-green-400",
|
|
48
|
+
textColor: "text-green-900 dark:text-green-300",
|
|
49
|
+
},
|
|
50
|
+
warning: {
|
|
51
|
+
icon: AlertCircle,
|
|
52
|
+
bg: "bg-yellow-500/10 dark:bg-yellow-400/5",
|
|
53
|
+
border: "border-yellow-500/30 dark:border-yellow-500/20",
|
|
54
|
+
iconColor: "text-yellow-600 dark:text-yellow-400",
|
|
55
|
+
textColor: "text-yellow-900 dark:text-yellow-300",
|
|
56
|
+
},
|
|
57
|
+
error: {
|
|
58
|
+
icon: XCircle,
|
|
59
|
+
bg: "bg-red-500/10 dark:bg-red-400/5",
|
|
60
|
+
border: "border-red-500/30 dark:border-red-500/20",
|
|
61
|
+
iconColor: "text-red-600 dark:text-red-400",
|
|
62
|
+
textColor: "text-red-900 dark:text-red-300",
|
|
63
|
+
},
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const type = banner.type || "info"
|
|
67
|
+
const { icon: IconComponent, bg, border, iconColor, textColor } = typeConfig[type]
|
|
68
|
+
|
|
69
|
+
return (
|
|
70
|
+
<div className={`w-full border-b ${border} ${bg}`}>
|
|
71
|
+
<div className="container mx-auto px-6 py-3">
|
|
72
|
+
<div className="flex items-center gap-3">
|
|
73
|
+
<IconComponent className={`h-5 w-5 shrink-0 ${iconColor}`} />
|
|
74
|
+
<div className="flex-1 min-w-0">
|
|
75
|
+
<p className={`text-sm font-medium ${textColor}`}>
|
|
76
|
+
{banner.message}
|
|
77
|
+
</p>
|
|
78
|
+
</div>
|
|
79
|
+
{banner.dismissible && (
|
|
80
|
+
<button
|
|
81
|
+
onClick={handleDismiss}
|
|
82
|
+
className={`shrink-0 p-1 rounded-md hover:bg-black/5 dark:hover:bg-white/5 transition-colors ${iconColor}`}
|
|
83
|
+
aria-label="Dismiss banner"
|
|
84
|
+
>
|
|
85
|
+
<X className="h-4 w-4" />
|
|
86
|
+
</button>
|
|
87
|
+
)}
|
|
88
|
+
</div>
|
|
89
|
+
</div>
|
|
90
|
+
</div>
|
|
91
|
+
)
|
|
92
|
+
}
|