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.
Files changed (142) hide show
  1. package/LICENSE.MD +21 -0
  2. package/README.md +157 -0
  3. package/dist/app/api/mdx-watch/route.d.mts +12 -0
  4. package/dist/app/api/mdx-watch/route.d.ts +12 -0
  5. package/dist/app/api/mdx-watch/route.js +98 -0
  6. package/dist/app/api/mdx-watch/route.js.map +1 -0
  7. package/dist/app/api/mdx-watch/route.mjs +71 -0
  8. package/dist/app/api/mdx-watch/route.mjs.map +1 -0
  9. package/dist/app/docs-page.d.mts +32 -0
  10. package/dist/app/docs-page.d.ts +32 -0
  11. package/dist/app/docs-page.js +4072 -0
  12. package/dist/app/docs-page.js.map +1 -0
  13. package/dist/app/docs-page.mjs +14 -0
  14. package/dist/app/docs-page.mjs.map +1 -0
  15. package/dist/app/layout.css +297 -0
  16. package/dist/app/layout.css.map +1 -0
  17. package/dist/app/layout.d.mts +19 -0
  18. package/dist/app/layout.d.ts +19 -0
  19. package/dist/app/layout.js +112 -0
  20. package/dist/app/layout.js.map +1 -0
  21. package/dist/app/layout.mjs +13 -0
  22. package/dist/app/layout.mjs.map +1 -0
  23. package/dist/chunk-DR4EPLMT.mjs +1013 -0
  24. package/dist/chunk-DR4EPLMT.mjs.map +1 -0
  25. package/dist/chunk-INL2EC72.mjs +170 -0
  26. package/dist/chunk-INL2EC72.mjs.map +1 -0
  27. package/dist/chunk-IZFGEAD6.mjs +61 -0
  28. package/dist/chunk-IZFGEAD6.mjs.map +1 -0
  29. package/dist/chunk-KTRWWAGL.mjs +50 -0
  30. package/dist/chunk-KTRWWAGL.mjs.map +1 -0
  31. package/dist/chunk-MZJHJ6BV.mjs +21 -0
  32. package/dist/chunk-MZJHJ6BV.mjs.map +1 -0
  33. package/dist/chunk-NXRIAL7T.mjs +3119 -0
  34. package/dist/chunk-NXRIAL7T.mjs.map +1 -0
  35. package/dist/components/index.d.mts +822 -0
  36. package/dist/components/index.d.ts +822 -0
  37. package/dist/components/index.js +3738 -0
  38. package/dist/components/index.js.map +1 -0
  39. package/dist/components/index.mjs +3627 -0
  40. package/dist/components/index.mjs.map +1 -0
  41. package/dist/index.css +297 -0
  42. package/dist/index.css.map +1 -0
  43. package/dist/index.d.mts +545 -0
  44. package/dist/index.d.ts +545 -0
  45. package/dist/index.js +4648 -0
  46. package/dist/index.js.map +1 -0
  47. package/dist/index.mjs +347 -0
  48. package/dist/index.mjs.map +1 -0
  49. package/dist/lib/index.d.mts +798 -0
  50. package/dist/lib/index.d.ts +798 -0
  51. package/dist/lib/index.js +1301 -0
  52. package/dist/lib/index.js.map +1 -0
  53. package/dist/lib/index.mjs +89 -0
  54. package/dist/lib/index.mjs.map +1 -0
  55. package/package.json +119 -0
  56. package/src/app/api/mdx-watch/route.ts +86 -0
  57. package/src/app/docs-page.tsx +212 -0
  58. package/src/app/layout.tsx +74 -0
  59. package/src/components/docs/accordion.tsx +53 -0
  60. package/src/components/docs/api/api-endpoint.tsx +59 -0
  61. package/src/components/docs/api/api-params.tsx +43 -0
  62. package/src/components/docs/api/api-playground.tsx +233 -0
  63. package/src/components/docs/api/api-reference.tsx +291 -0
  64. package/src/components/docs/api/api-response.tsx +48 -0
  65. package/src/components/docs/api/index.ts +5 -0
  66. package/src/components/docs/badge.tsx +22 -0
  67. package/src/components/docs/breadcrumb.tsx +51 -0
  68. package/src/components/docs/callout.tsx +109 -0
  69. package/src/components/docs/card.tsx +84 -0
  70. package/src/components/docs/category-index.tsx +112 -0
  71. package/src/components/docs/code-block.tsx +129 -0
  72. package/src/components/docs/columns.tsx +45 -0
  73. package/src/components/docs/componentTextProps.ts +85 -0
  74. package/src/components/docs/dev-mode-badge.tsx +35 -0
  75. package/src/components/docs/doc-layout-wrapper.tsx +54 -0
  76. package/src/components/docs/doc-layout.tsx +111 -0
  77. package/src/components/docs/doc-loading.tsx +15 -0
  78. package/src/components/docs/doc-metadata.tsx +55 -0
  79. package/src/components/docs/doc-navigation.tsx +62 -0
  80. package/src/components/docs/doc-tags.tsx +25 -0
  81. package/src/components/docs/draft-badge.tsx +10 -0
  82. package/src/components/docs/footer.tsx +47 -0
  83. package/src/components/docs/frame.tsx +22 -0
  84. package/src/components/docs/header.tsx +122 -0
  85. package/src/components/docs/hot-reload-indicator.tsx +77 -0
  86. package/src/components/docs/icon.tsx +70 -0
  87. package/src/components/docs/image-card.tsx +95 -0
  88. package/src/components/docs/image.tsx +73 -0
  89. package/src/components/docs/index.ts +48 -0
  90. package/src/components/docs/math.tsx +46 -0
  91. package/src/components/docs/mdx-components.tsx +166 -0
  92. package/src/components/docs/mdx-hot-reload.tsx +37 -0
  93. package/src/components/docs/mermaid.tsx +77 -0
  94. package/src/components/docs/mobile-doc-layout.tsx +115 -0
  95. package/src/components/docs/not-found-content.tsx +55 -0
  96. package/src/components/docs/search-highlight.tsx +127 -0
  97. package/src/components/docs/search-modal.tsx +223 -0
  98. package/src/components/docs/sidebar-skeleton.tsx +39 -0
  99. package/src/components/docs/sidebar.tsx +323 -0
  100. package/src/components/docs/site-banner.tsx +92 -0
  101. package/src/components/docs/steps.tsx +29 -0
  102. package/src/components/docs/tab-context.tsx +28 -0
  103. package/src/components/docs/tab-groups.tsx +50 -0
  104. package/src/components/docs/table-of-contents.tsx +104 -0
  105. package/src/components/docs/tabs.tsx +63 -0
  106. package/src/components/docs/theme-toggle.tsx +39 -0
  107. package/src/components/docs/tooltip.tsx +37 -0
  108. package/src/components/docs/version-switcher.tsx +52 -0
  109. package/src/components/docs/video.tsx +80 -0
  110. package/src/components/global/index.ts +3 -0
  111. package/src/components/global/version-not-found.tsx +26 -0
  112. package/src/components/index.ts +8 -0
  113. package/src/components/theme-provider.tsx +11 -0
  114. package/src/components/ui/badge.tsx +46 -0
  115. package/src/components/ui/button.tsx +60 -0
  116. package/src/components/ui/dialog.tsx +143 -0
  117. package/src/components/ui/index.ts +6 -0
  118. package/src/components/ui/input.tsx +21 -0
  119. package/src/components/ui/textarea.tsx +18 -0
  120. package/src/index.ts +41 -0
  121. package/src/lib/api-parser.types.ts +78 -0
  122. package/src/lib/api.types.ts +202 -0
  123. package/src/lib/category.ts +71 -0
  124. package/src/lib/config.server.ts +170 -0
  125. package/src/lib/config.ts +20 -0
  126. package/src/lib/config.types.ts +295 -0
  127. package/src/lib/dev-utils.ts +75 -0
  128. package/src/lib/index.ts +27 -0
  129. package/src/lib/mdx-cache.ts +200 -0
  130. package/src/lib/mdx.ts +402 -0
  131. package/src/lib/parsers/base-parser.ts +16 -0
  132. package/src/lib/parsers/index.ts +69 -0
  133. package/src/lib/parsers/openapi-parser.ts +251 -0
  134. package/src/lib/parsers/postman-parser.ts +301 -0
  135. package/src/lib/parsers/specra-parser.ts +24 -0
  136. package/src/lib/redirects.ts +40 -0
  137. package/src/lib/remark-code-meta.ts +23 -0
  138. package/src/lib/sidebar-utils.ts +188 -0
  139. package/src/lib/toc.ts +24 -0
  140. package/src/lib/utils.ts +36 -0
  141. package/src/specra.config.json +124 -0
  142. 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
+ }