@usecross/docs 0.6.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,239 @@
1
+ import { useState, useRef, useEffect } from 'react'
2
+ import { router } from '@inertiajs/react'
3
+ import { cn } from '../lib/utils'
4
+ import type { DocSetMeta } from '../types'
5
+
6
+ interface DocSetSelectorProps {
7
+ docSets: DocSetMeta[]
8
+ currentDocSet: string
9
+ className?: string
10
+ }
11
+
12
+ // Chevron icon with up/down indicators like Fumadocs
13
+ const ChevronUpDownIcon = ({ className }: { className?: string }) => (
14
+ <svg
15
+ className={className}
16
+ viewBox="0 0 16 16"
17
+ fill="none"
18
+ xmlns="http://www.w3.org/2000/svg"
19
+ >
20
+ <path
21
+ d="M5 6l3-3 3 3M5 10l3 3 3-3"
22
+ stroke="currentColor"
23
+ strokeWidth="1.5"
24
+ strokeLinecap="round"
25
+ strokeLinejoin="round"
26
+ />
27
+ </svg>
28
+ )
29
+
30
+ // Checkmark for selected state
31
+ const CheckIcon = ({ className }: { className?: string }) => (
32
+ <svg
33
+ className={className}
34
+ viewBox="0 0 16 16"
35
+ fill="none"
36
+ xmlns="http://www.w3.org/2000/svg"
37
+ >
38
+ <path
39
+ d="M3.5 8.5l3 3 6-6.5"
40
+ stroke="currentColor"
41
+ strokeWidth="1.75"
42
+ strokeLinecap="round"
43
+ strokeLinejoin="round"
44
+ />
45
+ </svg>
46
+ )
47
+
48
+ // Default package/docs icon when no iconUrl is provided
49
+ const PackageIcon = ({ className }: { className?: string }) => (
50
+ <svg
51
+ className={className}
52
+ viewBox="0 0 20 20"
53
+ fill="none"
54
+ xmlns="http://www.w3.org/2000/svg"
55
+ >
56
+ <path
57
+ d="M10 2L17 6v8l-7 4-7-4V6l7-4z"
58
+ stroke="currentColor"
59
+ strokeWidth="1.5"
60
+ strokeLinejoin="round"
61
+ />
62
+ <path
63
+ d="M10 10v8M10 10l7-4M10 10L3 6"
64
+ stroke="currentColor"
65
+ strokeWidth="1.5"
66
+ strokeLinecap="round"
67
+ strokeLinejoin="round"
68
+ />
69
+ </svg>
70
+ )
71
+
72
+ /**
73
+ * Dropdown selector for switching between documentation sets.
74
+ * Inspired by Fumadocs design - clean and minimal.
75
+ */
76
+ export function DocSetSelector({ docSets, currentDocSet, className }: DocSetSelectorProps) {
77
+ const [isOpen, setIsOpen] = useState(false)
78
+ const dropdownRef = useRef<HTMLDivElement>(null)
79
+
80
+ const current = docSets.find((ds) => ds.slug === currentDocSet) || docSets[0]
81
+
82
+ // Close dropdown when clicking outside
83
+ useEffect(() => {
84
+ const handleClickOutside = (event: MouseEvent) => {
85
+ if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
86
+ setIsOpen(false)
87
+ }
88
+ }
89
+
90
+ if (isOpen) {
91
+ document.addEventListener('mousedown', handleClickOutside)
92
+ return () => document.removeEventListener('mousedown', handleClickOutside)
93
+ }
94
+ }, [isOpen])
95
+
96
+ // Close on escape key
97
+ useEffect(() => {
98
+ const handleEscape = (event: KeyboardEvent) => {
99
+ if (event.key === 'Escape') setIsOpen(false)
100
+ }
101
+
102
+ if (isOpen) {
103
+ document.addEventListener('keydown', handleEscape)
104
+ return () => document.removeEventListener('keydown', handleEscape)
105
+ }
106
+ }, [isOpen])
107
+
108
+ const handleSelect = (docSet: DocSetMeta) => {
109
+ setIsOpen(false)
110
+ if (docSet.slug !== currentDocSet) {
111
+ router.visit(`${docSet.prefix}/`)
112
+ }
113
+ }
114
+
115
+ return (
116
+ <div className={cn('relative', className)} ref={dropdownRef}>
117
+ {/* Trigger Button - Clean, flat design like Fumadocs */}
118
+ <button
119
+ onClick={() => setIsOpen(!isOpen)}
120
+ className={cn(
121
+ 'w-full flex items-center gap-2.5 px-3 py-2',
122
+ 'bg-gray-100/80 dark:bg-white/5',
123
+ 'border border-gray-200 dark:border-white/10',
124
+ 'rounded-lg',
125
+ 'hover:bg-gray-200/80 dark:hover:bg-white/10',
126
+ 'transition-colors duration-150',
127
+ 'focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500/50'
128
+ )}
129
+ aria-label="Select documentation"
130
+ aria-expanded={isOpen}
131
+ aria-haspopup="listbox"
132
+ >
133
+ {/* Icon */}
134
+ <div className="flex-shrink-0 w-5 h-5 flex items-center justify-center text-gray-600 dark:text-gray-400">
135
+ {current.icon ? (
136
+ <span className="text-base leading-none">{current.icon}</span>
137
+ ) : current.iconUrl ? (
138
+ <img src={current.iconUrl} alt="" className="w-5 h-5" />
139
+ ) : (
140
+ <PackageIcon className="w-5 h-5" />
141
+ )}
142
+ </div>
143
+
144
+ {/* Text */}
145
+ <span className="flex-1 text-left text-sm font-medium text-gray-900 dark:text-white truncate">
146
+ {current.name}
147
+ </span>
148
+
149
+ {/* Chevron */}
150
+ <ChevronUpDownIcon className="flex-shrink-0 w-4 h-4 text-gray-400 dark:text-gray-500" />
151
+ </button>
152
+
153
+ {/* Dropdown Menu */}
154
+ <div
155
+ className={cn(
156
+ 'absolute left-0 right-0 mt-1.5',
157
+ 'py-1',
158
+ 'bg-white dark:bg-[#1a1a1a]',
159
+ 'border border-gray-200 dark:border-white/10',
160
+ 'rounded-lg',
161
+ 'shadow-lg shadow-black/5 dark:shadow-black/30',
162
+ 'z-50',
163
+ 'transition-all duration-150 ease-out origin-top',
164
+ isOpen
165
+ ? 'opacity-100 scale-100'
166
+ : 'opacity-0 scale-95 pointer-events-none'
167
+ )}
168
+ role="listbox"
169
+ aria-label="Select documentation set"
170
+ >
171
+ {docSets.map((docSet) => {
172
+ const isSelected = docSet.slug === currentDocSet
173
+
174
+ return (
175
+ <button
176
+ key={docSet.slug || '_root'}
177
+ onClick={() => handleSelect(docSet)}
178
+ className={cn(
179
+ 'w-full flex items-center gap-2.5 px-3 py-2',
180
+ 'transition-colors duration-100',
181
+ 'focus:outline-none',
182
+ isSelected
183
+ ? 'bg-primary-50 dark:bg-primary-500/10'
184
+ : 'hover:bg-gray-50 dark:hover:bg-white/5'
185
+ )}
186
+ role="option"
187
+ aria-selected={isSelected}
188
+ >
189
+ {/* Icon */}
190
+ <div className={cn(
191
+ 'flex-shrink-0 w-5 h-5 flex items-center justify-center',
192
+ isSelected
193
+ ? 'text-primary-600 dark:text-primary-400'
194
+ : 'text-gray-500 dark:text-gray-400'
195
+ )}>
196
+ {docSet.icon ? (
197
+ <span className="text-base leading-none">{docSet.icon}</span>
198
+ ) : docSet.iconUrl ? (
199
+ <img src={docSet.iconUrl} alt="" className="w-5 h-5" />
200
+ ) : (
201
+ <PackageIcon className="w-5 h-5" />
202
+ )}
203
+ </div>
204
+
205
+ {/* Text Content */}
206
+ <div className="flex-1 text-left min-w-0">
207
+ <div
208
+ className={cn(
209
+ 'text-sm font-medium truncate',
210
+ isSelected
211
+ ? 'text-primary-700 dark:text-primary-300'
212
+ : 'text-gray-900 dark:text-white'
213
+ )}
214
+ >
215
+ {docSet.name}
216
+ </div>
217
+ {docSet.description && (
218
+ <div className={cn(
219
+ 'text-xs truncate',
220
+ isSelected
221
+ ? 'text-primary-600/70 dark:text-primary-400/70'
222
+ : 'text-gray-500 dark:text-gray-400'
223
+ )}>
224
+ {docSet.description}
225
+ </div>
226
+ )}
227
+ </div>
228
+
229
+ {/* Checkmark indicator */}
230
+ {isSelected && (
231
+ <CheckIcon className="flex-shrink-0 w-4 h-4 text-primary-600 dark:text-primary-400" />
232
+ )}
233
+ </button>
234
+ )
235
+ })}
236
+ </div>
237
+ </div>
238
+ )
239
+ }
@@ -1,6 +1,7 @@
1
1
  import { Head, Link, usePage } from '@inertiajs/react'
2
2
  import { useState } from 'react'
3
3
  import { Sidebar } from './Sidebar'
4
+ import { TableOfContents } from './TableOfContents'
4
5
  import { ThemeToggle } from './ThemeToggle'
5
6
  import { useTheme } from './ThemeProvider'
6
7
  import type { DocsLayoutProps, SharedProps } from '../types'
@@ -52,9 +53,10 @@ export function DocsLayout({
52
53
  githubUrl: propGithubUrl,
53
54
  navLinks: propNavLinks,
54
55
  footer,
56
+ toc,
55
57
  }: DocsLayoutProps) {
56
58
  const sharedProps = usePage<{ props: SharedProps }>().props as unknown as SharedProps
57
- const { nav, currentPath } = sharedProps
59
+ const { nav, currentPath, docSets, currentDocSet } = sharedProps
58
60
  const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
59
61
  const { resolvedTheme } = useTheme()
60
62
 
@@ -132,28 +134,37 @@ export function DocsLayout({
132
134
  {mobileMenuOpen && (
133
135
  <div className="fixed inset-0 z-40 lg:hidden">
134
136
  <div className="fixed inset-0 bg-black/50 dark:bg-black/70" onClick={() => setMobileMenuOpen(false)} />
135
- <div className="fixed inset-y-0 left-0 w-72 overflow-y-auto bg-white dark:bg-[#0f0f0f] px-4 lg:px-10 py-6 pt-20 border-r border-gray-200 dark:border-gray-800 transition-colors">
136
- <Sidebar nav={nav} currentPath={currentPath} />
137
+ <div className="fixed inset-y-0 left-0 w-64 overflow-y-auto bg-white dark:bg-[#0f0f0f] px-4 py-6 pt-20 border-r border-gray-200 dark:border-gray-800 transition-colors">
138
+ <Sidebar nav={nav} currentPath={currentPath} docSets={docSets} currentDocSet={currentDocSet} />
137
139
  </div>
138
140
  </div>
139
141
  )}
140
142
 
141
143
  {/* Main content area */}
142
144
  <div className="bg-white dark:bg-[#0f0f0f] pt-16 w-full flex-1 transition-colors">
143
- <div className="grid grid-cols-12">
144
- {/* Desktop sidebar */}
145
- <aside className="hidden lg:block lg:col-span-3 xl:col-span-2 border-r border-gray-200 dark:border-gray-800 min-h-[calc(100vh-4rem)] transition-colors">
146
- <nav className="sticky top-16 px-4 lg:px-10 py-6 max-h-[calc(100vh-4rem)] overflow-y-auto">
147
- <Sidebar nav={nav} currentPath={currentPath} />
145
+ <div className="flex">
146
+ {/* Desktop sidebar - fixed width */}
147
+ <aside className="hidden lg:block w-72 flex-shrink-0 border-r border-gray-200 dark:border-gray-800 min-h-[calc(100vh-4rem)] transition-colors">
148
+ <nav className="sticky top-16 px-4 py-6 max-h-[calc(100vh-4rem)] overflow-y-auto">
149
+ <Sidebar nav={nav} currentPath={currentPath} docSets={docSets} currentDocSet={currentDocSet} />
148
150
  </nav>
149
151
  </aside>
150
152
 
151
153
  {/* Main content */}
152
- <main className="col-span-12 lg:col-span-9 xl:col-span-10 p-4 lg:px-10 lg:py-6">
154
+ <main className="flex-1 min-w-0 p-4 lg:px-10 lg:py-6">
153
155
  <article className="prose prose-lg max-w-3xl prose-headings:font-bold prose-headings:tracking-tight prose-h1:text-3xl prose-h1:mb-4 prose-h2:text-2xl prose-h2:mt-10 first:prose-h2:mt-0 prose-h3:text-xl prose-a:text-primary-600 dark:prose-a:text-primary-400 prose-a:no-underline hover:prose-a:underline prose-code:bg-gray-100 dark:prose-code:bg-gray-800 prose-code:px-1.5 prose-code:py-0.5 prose-code:rounded prose-code:before:content-none prose-code:after:content-none dark:prose-headings:text-white dark:prose-strong:text-white dark:text-gray-300">
154
156
  {children}
155
157
  </article>
156
158
  </main>
159
+
160
+ {/* Table of Contents - desktop only */}
161
+ {toc && toc.length > 0 && (
162
+ <aside className="hidden xl:block w-64 flex-shrink-0 min-h-[calc(100vh-4rem)] transition-colors">
163
+ <div className="sticky top-16 px-4 py-6 max-h-[calc(100vh-4rem)] overflow-y-auto">
164
+ <TableOfContents items={toc} />
165
+ </div>
166
+ </aside>
167
+ )}
157
168
  </div>
158
169
  </div>
159
170
 
@@ -15,7 +15,12 @@ export function DocsPage({ content, ...layoutProps }: DocsPageProps) {
15
15
  const { components } = useComponents()
16
16
 
17
17
  return (
18
- <DocsLayout title={content?.title ?? ''} description={content?.description} {...layoutProps}>
18
+ <DocsLayout
19
+ title={content?.title ?? ''}
20
+ description={content?.description}
21
+ toc={content?.toc}
22
+ {...layoutProps}
23
+ >
19
24
  <Markdown content={content?.body ?? ''} components={components} />
20
25
  </DocsLayout>
21
26
  )
@@ -4,6 +4,32 @@ import rehypeRaw from 'rehype-raw'
4
4
  import { CodeBlock } from './CodeBlock'
5
5
  import type { MarkdownProps } from '../types'
6
6
 
7
+ /**
8
+ * Convert heading text to URL-safe slug.
9
+ * Must match the Python slugify function in markdown.py.
10
+ */
11
+ function slugify(text: string): string {
12
+ return text
13
+ .toLowerCase()
14
+ .replace(/[\s_]+/g, '-')
15
+ .replace(/[^a-z0-9-]/g, '')
16
+ .replace(/-+/g, '-')
17
+ .replace(/^-|-$/g, '')
18
+ }
19
+
20
+ /**
21
+ * Extract text content from React children.
22
+ */
23
+ function getTextContent(children: React.ReactNode): string {
24
+ if (typeof children === 'string') return children
25
+ if (typeof children === 'number') return String(children)
26
+ if (Array.isArray(children)) return children.map(getTextContent).join('')
27
+ if (children && typeof children === 'object' && 'props' in children) {
28
+ return getTextContent((children as React.ReactElement).props.children)
29
+ }
30
+ return ''
31
+ }
32
+
7
33
  /**
8
34
  * Markdown renderer with syntax highlighting and GFM support.
9
35
  */
@@ -96,6 +122,25 @@ export function Markdown({ content, components }: MarkdownProps) {
96
122
  </td>
97
123
  )
98
124
  },
125
+ // Headings with anchor IDs for TOC
126
+ h2({ children }) {
127
+ const text = getTextContent(children)
128
+ const id = slugify(text)
129
+ return (
130
+ <h2 id={id}>
131
+ {children}
132
+ </h2>
133
+ )
134
+ },
135
+ h3({ children }) {
136
+ const text = getTextContent(children)
137
+ const id = slugify(text)
138
+ return (
139
+ <h3 id={id}>
140
+ {children}
141
+ </h3>
142
+ )
143
+ },
99
144
  }}
100
145
  >
101
146
  {content}
@@ -1,13 +1,22 @@
1
1
  import { Link } from '@inertiajs/react'
2
2
  import { cn } from '../lib/utils'
3
+ import { DocSetSelector } from './DocSetSelector'
3
4
  import type { SidebarProps } from '../types'
4
5
 
5
6
  /**
6
7
  * Documentation sidebar with section-based navigation.
8
+ * In multi-docs mode, includes a dropdown selector at the top.
7
9
  */
8
- export function Sidebar({ nav, currentPath, className }: SidebarProps) {
10
+ export function Sidebar({ nav, currentPath, className, docSets, currentDocSet }: SidebarProps) {
9
11
  return (
10
- <nav className={cn('space-y-8', className)}>
12
+ <nav className={cn('space-y-6', className)}>
13
+ {/* Doc Set Selector - only shown in multi-docs mode */}
14
+ {docSets && docSets.length > 1 && (
15
+ <DocSetSelector docSets={docSets} currentDocSet={currentDocSet ?? ''} className="mb-6" />
16
+ )}
17
+
18
+ {/* Navigation Sections */}
19
+ <div className="space-y-8">
11
20
  {nav.map((section) => (
12
21
  <div key={section.title}>
13
22
  <h3 className="mb-3 text-xs font-mono uppercase tracking-widest text-gray-500 dark:text-gray-400">
@@ -32,6 +41,7 @@ export function Sidebar({ nav, currentPath, className }: SidebarProps) {
32
41
  </ul>
33
42
  </div>
34
43
  ))}
44
+ </div>
35
45
  </nav>
36
46
  )
37
47
  }
@@ -0,0 +1,180 @@
1
+ import { useEffect, useState, useRef } from 'react'
2
+ import type { TableOfContentsProps } from '../types'
3
+
4
+ /**
5
+ * Table of contents component with scroll spy functionality.
6
+ * Displays "ON THIS PAGE" sidebar with heading links.
7
+ */
8
+ export function TableOfContents({ items, className = '' }: TableOfContentsProps) {
9
+ const [activeId, setActiveId] = useState<string>(() => {
10
+ // Initialize with hash from URL if present
11
+ if (typeof window !== 'undefined' && window.location.hash) {
12
+ return window.location.hash.slice(1)
13
+ }
14
+ return ''
15
+ })
16
+
17
+ // Track if we're currently scrolling from a click
18
+ const isClickScrolling = useRef(false)
19
+
20
+ useEffect(() => {
21
+ if (items.length === 0) return
22
+
23
+ // Listen for hash changes
24
+ const handleHashChange = () => {
25
+ const hash = window.location.hash.slice(1)
26
+ if (hash) {
27
+ setActiveId(hash)
28
+ }
29
+ }
30
+ window.addEventListener('hashchange', handleHashChange)
31
+
32
+ // Scroll-based detection - find the heading closest to top of viewport
33
+ const handleScroll = () => {
34
+ // Skip if we're in the middle of a click-initiated scroll
35
+ if (isClickScrolling.current) return
36
+
37
+ const headerOffset = 100
38
+ let currentId = ''
39
+
40
+ // Check if we're at the bottom of the page
41
+ const scrollTop = window.scrollY
42
+ const scrollHeight = document.documentElement.scrollHeight
43
+ const clientHeight = document.documentElement.clientHeight
44
+ const isAtBottom = scrollTop + clientHeight >= scrollHeight - 50
45
+
46
+ if (isAtBottom) {
47
+ // At bottom of page - find the last heading that's visible in viewport
48
+ for (let i = items.length - 1; i >= 0; i--) {
49
+ const element = document.getElementById(items[i].id)
50
+ if (element) {
51
+ const rect = element.getBoundingClientRect()
52
+ // If this heading is visible in the viewport
53
+ if (rect.top < clientHeight && rect.bottom > 0) {
54
+ currentId = items[i].id
55
+ break
56
+ }
57
+ }
58
+ }
59
+ } else {
60
+ // Normal scroll detection
61
+ for (const item of items) {
62
+ const element = document.getElementById(item.id)
63
+ if (element) {
64
+ const rect = element.getBoundingClientRect()
65
+ // If the heading is at or above the threshold, it's the current section
66
+ if (rect.top <= headerOffset) {
67
+ currentId = item.id
68
+ } else {
69
+ // Once we find a heading below the threshold, stop
70
+ break
71
+ }
72
+ }
73
+ }
74
+ }
75
+
76
+ // If no heading is above threshold, use the first one
77
+ if (!currentId && items.length > 0) {
78
+ currentId = items[0].id
79
+ }
80
+
81
+ if (currentId) {
82
+ setActiveId(currentId)
83
+ }
84
+ }
85
+
86
+ // Throttle scroll handler
87
+ let ticking = false
88
+ const scrollListener = () => {
89
+ if (!ticking) {
90
+ requestAnimationFrame(() => {
91
+ handleScroll()
92
+ ticking = false
93
+ })
94
+ ticking = true
95
+ }
96
+ }
97
+
98
+ window.addEventListener('scroll', scrollListener, { passive: true })
99
+
100
+ // Initial check (only if no hash in URL)
101
+ if (!window.location.hash) {
102
+ handleScroll()
103
+ }
104
+
105
+ return () => {
106
+ window.removeEventListener('scroll', scrollListener)
107
+ window.removeEventListener('hashchange', handleHashChange)
108
+ }
109
+ }, [items])
110
+
111
+ const handleClick = (e: React.MouseEvent<HTMLAnchorElement>, id: string) => {
112
+ e.preventDefault()
113
+ const element = document.getElementById(id)
114
+ if (element) {
115
+ // Mark that we're click-scrolling to prevent scroll handler from overriding
116
+ isClickScrolling.current = true
117
+ setActiveId(id)
118
+
119
+ const top = element.getBoundingClientRect().top + window.scrollY - 80
120
+ window.scrollTo({ top, behavior: 'smooth' })
121
+
122
+ // Update URL hash without jumping
123
+ window.history.pushState(null, '', `#${id}`)
124
+
125
+ // Re-enable scroll detection after scroll settles
126
+ // Use requestAnimationFrame loop to wait for scroll to stabilize
127
+ let lastScrollY = window.scrollY
128
+ let stableCount = 0
129
+ const checkScrollEnd = () => {
130
+ if (window.scrollY === lastScrollY) {
131
+ stableCount++
132
+ if (stableCount >= 5) {
133
+ // Scroll has been stable for ~5 frames, animation is done
134
+ isClickScrolling.current = false
135
+ return
136
+ }
137
+ } else {
138
+ stableCount = 0
139
+ lastScrollY = window.scrollY
140
+ }
141
+ requestAnimationFrame(checkScrollEnd)
142
+ }
143
+ requestAnimationFrame(checkScrollEnd)
144
+ }
145
+ }
146
+
147
+ if (items.length === 0) {
148
+ return null
149
+ }
150
+
151
+ return (
152
+ <nav className={className}>
153
+ <h5 className="mb-4 text-sm font-semibold tracking-wide text-gray-500 dark:text-gray-400 uppercase">
154
+ On this page
155
+ </h5>
156
+ <ul className="space-y-2.5 text-sm border-l border-gray-200 dark:border-gray-700">
157
+ {items.map((item) => {
158
+ const isActive = activeId === item.id
159
+ const indent = item.level === 3 ? 'pl-6' : 'pl-4'
160
+
161
+ return (
162
+ <li key={item.id}>
163
+ <a
164
+ href={`#${item.id}`}
165
+ onClick={(e) => handleClick(e, item.id)}
166
+ className={`block ${indent} -ml-px border-l transition-colors ${
167
+ isActive
168
+ ? 'border-primary-500 text-primary-600 dark:text-primary-400'
169
+ : 'border-transparent text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200 hover:border-gray-300 dark:hover:border-gray-600'
170
+ }`}
171
+ >
172
+ {item.text}
173
+ </a>
174
+ </li>
175
+ )
176
+ })}
177
+ </ul>
178
+ </nav>
179
+ )
180
+ }
@@ -1,9 +1,11 @@
1
1
  export { CodeBlock, InlineCode } from './CodeBlock'
2
+ export { DocSetSelector } from './DocSetSelector'
2
3
  export { DocsLayout } from './DocsLayout'
3
4
  export { DocsPage } from './DocsPage'
4
5
  export { EmojiConfetti } from './EmojiConfetti'
5
6
  export { HomePage } from './HomePage'
6
7
  export { Markdown } from './Markdown'
7
8
  export { Sidebar } from './Sidebar'
9
+ export { TableOfContents } from './TableOfContents'
8
10
  export { ThemeProvider, useTheme, themeInitScript } from './ThemeProvider'
9
11
  export { ThemeToggle } from './ThemeToggle'
package/src/index.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  // Components
2
2
  export {
3
3
  CodeBlock,
4
+ DocSetSelector,
4
5
  DocsLayout,
5
6
  DocsPage,
6
7
  EmojiConfetti,
@@ -8,6 +9,7 @@ export {
8
9
  InlineCode,
9
10
  Markdown,
10
11
  Sidebar,
12
+ TableOfContents,
11
13
  ThemeProvider,
12
14
  ThemeToggle,
13
15
  useTheme,
@@ -37,11 +39,14 @@ export type {
37
39
  DocContent,
38
40
  DocsAppConfig,
39
41
  DocsLayoutProps,
42
+ DocSetMeta,
40
43
  MarkdownProps,
41
44
  NavItem,
42
45
  NavSection,
43
46
  SharedProps,
44
47
  SidebarProps,
48
+ TableOfContentsProps,
49
+ TOCItem,
45
50
  } from './types'
46
51
 
47
52
  export type { Theme, ResolvedTheme } from './components/ThemeProvider'
package/src/styles.css CHANGED
@@ -13,6 +13,9 @@
13
13
  * Note: The Google Fonts import must come before other imports to avoid CSS ordering warnings.
14
14
  */
15
15
 
16
+ /* Enable class-based dark mode (using .dark class on root element) */
17
+ @custom-variant dark (&:where(.dark, .dark *));
18
+
16
19
  /* Theme customizations for Tailwind v4 */
17
20
  @theme {
18
21
  /* Max width */