@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.
- package/dist/index.d.ts +23 -5
- package/dist/index.js +514 -161
- package/dist/index.js.map +1 -1
- package/dist/ssr.d.ts +1 -1
- package/dist/{types-CR-kx8KP.d.ts → types-DlTTA3Dc.d.ts} +34 -1
- package/package.json +1 -1
- package/src/components/DocSetSelector.tsx +239 -0
- package/src/components/DocsLayout.tsx +20 -9
- package/src/components/DocsPage.tsx +6 -1
- package/src/components/Markdown.tsx +45 -0
- package/src/components/Sidebar.tsx +12 -2
- package/src/components/TableOfContents.tsx +180 -0
- package/src/components/index.ts +2 -0
- package/src/index.ts +5 -0
- package/src/styles.css +3 -0
- package/src/types.ts +36 -0
|
@@ -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-
|
|
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="
|
|
144
|
-
{/* Desktop sidebar */}
|
|
145
|
-
<aside className="hidden lg:block
|
|
146
|
-
<nav className="sticky top-16 px-4
|
|
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="
|
|
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
|
|
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-
|
|
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
|
+
}
|
package/src/components/index.ts
CHANGED
|
@@ -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 */
|