@usecross/docs 0.7.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 +10 -4
- package/dist/index.js +306 -145
- package/dist/index.js.map +1 -1
- package/dist/ssr.d.ts +1 -1
- package/dist/{types-DlF8TX2Q.d.ts → types-DlTTA3Dc.d.ts} +15 -1
- package/package.json +1 -1
- package/src/components/DocsLayout.tsx +11 -0
- package/src/components/DocsPage.tsx +6 -1
- package/src/components/Markdown.tsx +45 -0
- package/src/components/TableOfContents.tsx +180 -0
- package/src/components/index.ts +1 -0
- package/src/index.ts +3 -0
- package/src/types.ts +16 -0
|
@@ -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}
|
|
@@ -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
|
@@ -6,5 +6,6 @@ export { EmojiConfetti } from './EmojiConfetti'
|
|
|
6
6
|
export { HomePage } from './HomePage'
|
|
7
7
|
export { Markdown } from './Markdown'
|
|
8
8
|
export { Sidebar } from './Sidebar'
|
|
9
|
+
export { TableOfContents } from './TableOfContents'
|
|
9
10
|
export { ThemeProvider, useTheme, themeInitScript } from './ThemeProvider'
|
|
10
11
|
export { ThemeToggle } from './ThemeToggle'
|
package/src/index.ts
CHANGED
|
@@ -9,6 +9,7 @@ export {
|
|
|
9
9
|
InlineCode,
|
|
10
10
|
Markdown,
|
|
11
11
|
Sidebar,
|
|
12
|
+
TableOfContents,
|
|
12
13
|
ThemeProvider,
|
|
13
14
|
ThemeToggle,
|
|
14
15
|
useTheme,
|
|
@@ -44,6 +45,8 @@ export type {
|
|
|
44
45
|
NavSection,
|
|
45
46
|
SharedProps,
|
|
46
47
|
SidebarProps,
|
|
48
|
+
TableOfContentsProps,
|
|
49
|
+
TOCItem,
|
|
47
50
|
} from './types'
|
|
48
51
|
|
|
49
52
|
export type { Theme, ResolvedTheme } from './components/ThemeProvider'
|
package/src/types.ts
CHANGED
|
@@ -50,11 +50,19 @@ export interface SharedProps {
|
|
|
50
50
|
currentDocSet?: string
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
+
/** Table of contents item */
|
|
54
|
+
export interface TOCItem {
|
|
55
|
+
id: string
|
|
56
|
+
text: string
|
|
57
|
+
level: number
|
|
58
|
+
}
|
|
59
|
+
|
|
53
60
|
/** Document content structure */
|
|
54
61
|
export interface DocContent {
|
|
55
62
|
title: string
|
|
56
63
|
description: string
|
|
57
64
|
body: string
|
|
65
|
+
toc?: TOCItem[]
|
|
58
66
|
}
|
|
59
67
|
|
|
60
68
|
/** Props for DocsLayout component */
|
|
@@ -76,6 +84,14 @@ export interface DocsLayoutProps {
|
|
|
76
84
|
navLinks?: Array<{ label: string; href: string }>
|
|
77
85
|
/** Custom footer component */
|
|
78
86
|
footer?: ReactNode
|
|
87
|
+
/** Table of contents items for the current page */
|
|
88
|
+
toc?: TOCItem[]
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Props for TableOfContents component */
|
|
92
|
+
export interface TableOfContentsProps {
|
|
93
|
+
items: TOCItem[]
|
|
94
|
+
className?: string
|
|
79
95
|
}
|
|
80
96
|
|
|
81
97
|
/** Props for Sidebar component */
|