@usecross/docs 0.7.0 → 0.8.1

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.
@@ -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
+ <h3 className="mb-3 text-xs font-mono uppercase tracking-widest text-gray-500 dark:text-gray-400">
154
+ On this page
155
+ </h3>
156
+ <ul className="space-y-1 border-l-2 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 border-l-2 py-1.5 ${indent} -ml-0.5 text-sm transition-colors ${
167
+ isActive
168
+ ? 'border-primary-500 text-gray-900 dark:text-white font-bold'
169
+ : 'border-transparent text-gray-600 dark:text-gray-300 hover:border-gray-900 dark:hover:border-white hover:text-gray-900 dark:hover:text-white'
170
+ }`}
171
+ >
172
+ {item.text}
173
+ </a>
174
+ </li>
175
+ )
176
+ })}
177
+ </ul>
178
+ </nav>
179
+ )
180
+ }
@@ -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 */