skrypt-ai 0.4.1 → 0.5.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 (61) hide show
  1. package/dist/auth/index.d.ts +13 -3
  2. package/dist/auth/index.js +94 -9
  3. package/dist/auth/keychain.d.ts +5 -0
  4. package/dist/auth/keychain.js +82 -0
  5. package/dist/auth/notices.d.ts +3 -0
  6. package/dist/auth/notices.js +42 -0
  7. package/dist/autofix/index.js +10 -3
  8. package/dist/cli.js +16 -3
  9. package/dist/commands/generate.js +37 -1
  10. package/dist/commands/import.d.ts +2 -0
  11. package/dist/commands/import.js +157 -0
  12. package/dist/commands/init.js +19 -7
  13. package/dist/commands/login.js +15 -4
  14. package/dist/commands/review-pr.js +10 -0
  15. package/dist/commands/security.d.ts +2 -0
  16. package/dist/commands/security.js +103 -0
  17. package/dist/config/loader.js +2 -2
  18. package/dist/generator/writer.js +12 -3
  19. package/dist/importers/confluence.d.ts +5 -0
  20. package/dist/importers/confluence.js +137 -0
  21. package/dist/importers/detect.d.ts +20 -0
  22. package/dist/importers/detect.js +121 -0
  23. package/dist/importers/docusaurus.d.ts +5 -0
  24. package/dist/importers/docusaurus.js +279 -0
  25. package/dist/importers/gitbook.d.ts +5 -0
  26. package/dist/importers/gitbook.js +189 -0
  27. package/dist/importers/github.d.ts +8 -0
  28. package/dist/importers/github.js +99 -0
  29. package/dist/importers/index.d.ts +15 -0
  30. package/dist/importers/index.js +30 -0
  31. package/dist/importers/markdown.d.ts +6 -0
  32. package/dist/importers/markdown.js +105 -0
  33. package/dist/importers/mintlify.d.ts +5 -0
  34. package/dist/importers/mintlify.js +172 -0
  35. package/dist/importers/notion.d.ts +5 -0
  36. package/dist/importers/notion.js +174 -0
  37. package/dist/importers/readme.d.ts +5 -0
  38. package/dist/importers/readme.js +184 -0
  39. package/dist/importers/transform.d.ts +90 -0
  40. package/dist/importers/transform.js +457 -0
  41. package/dist/importers/types.d.ts +37 -0
  42. package/dist/importers/types.js +1 -0
  43. package/dist/plugins/index.js +7 -0
  44. package/dist/scanner/index.js +37 -24
  45. package/dist/scanner/python.js +17 -0
  46. package/dist/template/public/search-index.json +1 -1
  47. package/dist/template/scripts/build-search-index.mjs +67 -9
  48. package/dist/template/src/components/mdx/dark-image.tsx +56 -0
  49. package/dist/template/src/components/mdx/frame.tsx +64 -0
  50. package/dist/template/src/components/mdx/highlighted-code.tsx +145 -31
  51. package/dist/template/src/components/mdx/index.tsx +4 -0
  52. package/dist/template/src/components/mdx/link-preview.tsx +119 -0
  53. package/dist/template/src/components/mdx/tooltip.tsx +101 -0
  54. package/dist/template/src/components/syntax-theme-selector.tsx +167 -20
  55. package/dist/template/src/lib/search-types.ts +4 -1
  56. package/dist/template/src/lib/search.ts +30 -7
  57. package/dist/template/src/styles/globals.css +39 -0
  58. package/dist/utils/files.d.ts +9 -1
  59. package/dist/utils/files.js +59 -10
  60. package/dist/utils/validation.js +1 -1
  61. package/package.json +4 -1
@@ -19,13 +19,15 @@ async function dirExists(dir) {
19
19
  async function getAllMDXFiles(dir) {
20
20
  const files = []
21
21
 
22
- async function walk(currentDir) {
22
+ async function walk(currentDir, depth = 0) {
23
+ if (depth > 20) return
23
24
  try {
24
25
  const entries = await readdir(currentDir, { withFileTypes: true })
25
26
  for (const entry of entries) {
26
27
  const fullPath = join(currentDir, entry.name)
28
+ if (entry.isSymbolicLink?.()) continue
27
29
  if (entry.isDirectory()) {
28
- await walk(fullPath)
30
+ await walk(fullPath, depth + 1)
29
31
  } else if (entry.name.endsWith('.md') || entry.name.endsWith('.mdx')) {
30
32
  files.push(fullPath)
31
33
  }
@@ -39,14 +41,30 @@ async function getAllMDXFiles(dir) {
39
41
  return files
40
42
  }
41
43
 
44
+ /**
45
+ * Extract headings (h2, h3) as a separate searchable field.
46
+ * This lets users find pages by section names.
47
+ */
48
+ function extractHeadings(content) {
49
+ const headings = []
50
+ const regex = /^#{2,3}\s+(.+)$/gm
51
+ let match
52
+ while ((match = regex.exec(content)) !== null) {
53
+ // Strip any inline formatting
54
+ const clean = match[1].replace(/[*_`[\]]/g, '').trim()
55
+ if (clean) headings.push(clean)
56
+ }
57
+ return headings.join(' | ')
58
+ }
59
+
42
60
  function extractPlainText(content) {
43
61
  return content
44
62
  // Remove import statements
45
63
  .replace(/^import\s+.*$/gm, '')
46
64
  // Remove export statements
47
65
  .replace(/^export\s+.*$/gm, '')
48
- // Remove MDX/JSX components
49
- .replace(/<[^>]+>/g, '')
66
+ // Remove MDX/JSX components (but keep text content inside)
67
+ .replace(/<[^>]+>/g, ' ')
50
68
  // Remove code blocks
51
69
  .replace(/```[\s\S]*?```/g, '')
52
70
  .replace(/`[^`]+`/g, '')
@@ -68,6 +86,35 @@ function extractPlainText(content) {
68
86
  .trim()
69
87
  }
70
88
 
89
+ /**
90
+ * Extract keywords from content — important terms that appear frequently
91
+ * or in headings, used to boost search relevance.
92
+ */
93
+ function extractKeywords(content, title) {
94
+ const words = new Map()
95
+
96
+ // Title words get high weight
97
+ for (const word of title.toLowerCase().split(/\s+/)) {
98
+ if (word.length > 2) words.set(word, (words.get(word) || 0) + 3)
99
+ }
100
+
101
+ // Heading words get medium weight
102
+ const headingRegex = /^#{1,3}\s+(.+)$/gm
103
+ let match
104
+ while ((match = headingRegex.exec(content)) !== null) {
105
+ for (const word of match[1].toLowerCase().replace(/[^a-z0-9\s]/g, '').split(/\s+/)) {
106
+ if (word.length > 2) words.set(word, (words.get(word) || 0) + 2)
107
+ }
108
+ }
109
+
110
+ // Sort by weight and return top keywords
111
+ return [...words.entries()]
112
+ .sort((a, b) => b[1] - a[1])
113
+ .slice(0, 20)
114
+ .map(([word]) => word)
115
+ .join(' ')
116
+ }
117
+
71
118
  function getSlugFromContentPath(filePath) {
72
119
  const rel = relative(CONTENT_DIR, filePath)
73
120
  const slug = rel
@@ -79,8 +126,6 @@ function getSlugFromContentPath(filePath) {
79
126
  }
80
127
 
81
128
  function getSlugFromAppPath(filePath) {
82
- // For App Router: src/app/docs/quickstart/page.mdx -> /docs/quickstart
83
- // src/app/docs/page.mdx -> /docs
84
129
  const rel = relative(APP_DOCS_DIR, filePath)
85
130
  const dir = dirname(rel)
86
131
  const name = basename(rel)
@@ -99,11 +144,9 @@ function getSlugFromAppPath(filePath) {
99
144
  }
100
145
 
101
146
  function extractTitle(content, filePath) {
102
- // Try to get title from first heading
103
147
  const h1Match = content.match(/^#\s+(.+)$/m)
104
148
  if (h1Match) return h1Match[1].trim()
105
149
 
106
- // Derive from file path
107
150
  const dir = dirname(filePath)
108
151
  const folderName = basename(dir)
109
152
  if (folderName && folderName !== 'docs' && folderName !== '.') {
@@ -122,10 +165,18 @@ async function buildSearchIndex() {
122
165
  schema: {
123
166
  id: 'string',
124
167
  title: 'string',
168
+ headings: 'string',
169
+ keywords: 'string',
125
170
  content: 'string',
126
171
  href: 'string',
127
172
  section: 'string',
128
173
  },
174
+ components: {
175
+ tokenizer: {
176
+ stemming: true,
177
+ language: 'english',
178
+ },
179
+ },
129
180
  })
130
181
 
131
182
  const documents = []
@@ -141,6 +192,8 @@ async function buildSearchIndex() {
141
192
 
142
193
  const title = data.title || extractTitle(content, file)
143
194
  const plainContent = extractPlainText(content)
195
+ const headings = extractHeadings(content)
196
+ const keywords = extractKeywords(content, title)
144
197
  const href = getSlugFromContentPath(file)
145
198
 
146
199
  if (seen.has(href)) continue
@@ -149,6 +202,8 @@ async function buildSearchIndex() {
149
202
  documents.push({
150
203
  id: href,
151
204
  title,
205
+ headings,
206
+ keywords,
152
207
  content: plainContent.slice(0, 5000),
153
208
  href,
154
209
  section: data.section || data.category || '',
@@ -165,7 +220,6 @@ async function buildSearchIndex() {
165
220
  if (await dirExists(APP_DOCS_DIR)) {
166
221
  const appFiles = await getAllMDXFiles(APP_DOCS_DIR)
167
222
  for (const file of appFiles) {
168
- // Skip catch-all route files and layout files
169
223
  if (file.includes('[...slug]') || file.includes('layout.')) continue
170
224
 
171
225
  try {
@@ -174,6 +228,8 @@ async function buildSearchIndex() {
174
228
 
175
229
  const title = data.title || extractTitle(content, file)
176
230
  const plainContent = extractPlainText(content)
231
+ const headings = extractHeadings(content)
232
+ const keywords = extractKeywords(content, title)
177
233
  const href = getSlugFromAppPath(file)
178
234
 
179
235
  if (seen.has(href)) continue
@@ -182,6 +238,8 @@ async function buildSearchIndex() {
182
238
  documents.push({
183
239
  id: href,
184
240
  title,
241
+ headings,
242
+ keywords,
185
243
  content: plainContent.slice(0, 5000),
186
244
  href,
187
245
  section: data.section || data.category || '',
@@ -0,0 +1,56 @@
1
+ 'use client'
2
+
3
+ import { useState, useEffect } from 'react'
4
+ import { cn } from '@/lib/utils'
5
+
6
+ interface DarkImageProps {
7
+ src: string
8
+ darkSrc: string
9
+ alt: string
10
+ width?: number
11
+ height?: number
12
+ className?: string
13
+ }
14
+
15
+ function getIsDark(): boolean {
16
+ if (typeof document === 'undefined') return false
17
+ if (document.documentElement.classList.contains('dark')) return true
18
+ if (document.documentElement.classList.contains('light')) return false
19
+ return window.matchMedia('(prefers-color-scheme: dark)').matches
20
+ }
21
+
22
+ export function DarkImage({ src, darkSrc, alt, width, height, className }: DarkImageProps) {
23
+ const [isDark, setIsDark] = useState(false)
24
+
25
+ useEffect(() => {
26
+ setIsDark(getIsDark())
27
+
28
+ const mq = window.matchMedia('(prefers-color-scheme: dark)')
29
+ const onMediaChange = () => setIsDark(getIsDark())
30
+ mq.addEventListener('change', onMediaChange)
31
+
32
+ const observer = new MutationObserver(() => {
33
+ setIsDark(getIsDark())
34
+ })
35
+
36
+ observer.observe(document.documentElement, {
37
+ attributes: true,
38
+ attributeFilter: ['class'],
39
+ })
40
+
41
+ return () => {
42
+ mq.removeEventListener('change', onMediaChange)
43
+ observer.disconnect()
44
+ }
45
+ }, [])
46
+
47
+ return (
48
+ <img
49
+ src={isDark ? darkSrc : src}
50
+ alt={alt}
51
+ width={width}
52
+ height={height}
53
+ className={cn('rounded-lg', className)}
54
+ />
55
+ )
56
+ }
@@ -0,0 +1,64 @@
1
+ 'use client'
2
+
3
+ import { cn } from '@/lib/utils'
4
+
5
+ interface FrameProps {
6
+ children: React.ReactNode
7
+ caption?: string
8
+ variant?: 'browser' | 'terminal' | 'plain'
9
+ }
10
+
11
+ function TrafficDots() {
12
+ return (
13
+ <div className="flex items-center gap-1.5">
14
+ <span className="block w-2 h-2 rounded-full bg-[#ff5f57]" />
15
+ <span className="block w-2 h-2 rounded-full bg-[#febc2e]" />
16
+ <span className="block w-2 h-2 rounded-full bg-[#28c840]" />
17
+ </div>
18
+ )
19
+ }
20
+
21
+ function BrowserChrome() {
22
+ return (
23
+ <div className="flex items-center gap-3 px-3.5 py-2.5 border-b border-[var(--color-border)] bg-[var(--color-bg-secondary)]">
24
+ <TrafficDots />
25
+ <div className="flex-1 h-5 rounded-md bg-[var(--color-bg)] border border-[var(--color-border)] px-2.5 flex items-center">
26
+ <span className="text-[10px] text-[var(--color-text-tertiary)] select-none truncate">
27
+ https://example.com
28
+ </span>
29
+ </div>
30
+ </div>
31
+ )
32
+ }
33
+
34
+ function TerminalChrome() {
35
+ return (
36
+ <div className="flex items-center gap-3 px-3.5 py-2.5 bg-[#1a1a1a]">
37
+ <TrafficDots />
38
+ </div>
39
+ )
40
+ }
41
+
42
+ export function Frame({ children, caption, variant = 'browser' }: FrameProps) {
43
+ return (
44
+ <figure className="my-4">
45
+ <div
46
+ className={cn(
47
+ 'rounded-xl border border-[var(--color-border)] overflow-hidden',
48
+ variant === 'terminal' && 'bg-[#1a1a1a]'
49
+ )}
50
+ >
51
+ {variant === 'browser' && <BrowserChrome />}
52
+ {variant === 'terminal' && <TerminalChrome />}
53
+ <div className="[&>img]:!m-0 [&>img]:!rounded-none [&>img]:block [&>img]:w-full">
54
+ {children}
55
+ </div>
56
+ </div>
57
+ {caption && (
58
+ <figcaption className="text-sm text-[var(--color-text-tertiary)] mt-2 text-center">
59
+ {caption}
60
+ </figcaption>
61
+ )}
62
+ </figure>
63
+ )
64
+ }
@@ -1,6 +1,6 @@
1
1
  'use client'
2
2
 
3
- import { useState, useEffect, useContext, ReactNode, isValidElement, Children, useRef } from 'react'
3
+ import { useState, useEffect, useContext, ReactNode, isValidElement, Children, useRef, useCallback } from 'react'
4
4
  import { Copy, Check } from 'lucide-react'
5
5
  import { highlight, DEFAULT_THEME, isLightTheme, type ThemeName } from '@/lib/highlight'
6
6
  import { SyntaxThemeContext } from '@/contexts/syntax-theme'
@@ -54,11 +54,56 @@ function parseFilename(meta: string): string | undefined {
54
54
  return match ? match[1] : undefined
55
55
  }
56
56
 
57
+ /** Hook to track horizontal overflow and scroll position of a scrollable element */
58
+ function useScrollOverflow(ref: React.RefObject<HTMLElement | null>) {
59
+ const [hasOverflow, setHasOverflow] = useState(false)
60
+ const [scrolledToEnd, setScrolledToEnd] = useState(false)
61
+ const [scrolledFromStart, setScrolledFromStart] = useState(false)
62
+
63
+ const checkOverflow = useCallback(() => {
64
+ const el = ref.current
65
+ if (!el) return
66
+ const overflows = el.scrollWidth > el.clientWidth + 1
67
+ setHasOverflow(overflows)
68
+ if (overflows) {
69
+ const atEnd = el.scrollLeft + el.clientWidth >= el.scrollWidth - 1
70
+ const atStart = el.scrollLeft <= 1
71
+ setScrolledToEnd(atEnd)
72
+ setScrolledFromStart(!atStart)
73
+ } else {
74
+ setScrolledToEnd(false)
75
+ setScrolledFromStart(false)
76
+ }
77
+ }, [ref])
78
+
79
+ useEffect(() => {
80
+ const el = ref.current
81
+ if (!el) return
82
+
83
+ checkOverflow()
84
+
85
+ // Re-check on window resize
86
+ const resizeObserver = new ResizeObserver(checkOverflow)
87
+ resizeObserver.observe(el)
88
+
89
+ el.addEventListener('scroll', checkOverflow, { passive: true })
90
+ return () => {
91
+ resizeObserver.disconnect()
92
+ el.removeEventListener('scroll', checkOverflow)
93
+ }
94
+ }, [ref, checkOverflow])
95
+
96
+ return { hasOverflow, scrolledToEnd, scrolledFromStart }
97
+ }
98
+
57
99
  export function HighlightedCode({ children, className }: HighlightedCodeProps) {
58
100
  const [copied, setCopied] = useState(false)
59
101
  const [showToast, setShowToast] = useState(false)
60
102
  const [highlightedHtml, setHighlightedHtml] = useState<string | null>(null)
61
103
  const copyButtonRef = useRef<HTMLButtonElement>(null)
104
+ const scrollRef = useRef<HTMLDivElement>(null)
105
+
106
+ const { hasOverflow, scrolledToEnd, scrolledFromStart } = useScrollOverflow(scrollRef)
62
107
 
63
108
  const syntaxContext = useContext(SyntaxThemeContext)
64
109
  const theme: ThemeName = syntaxContext?.theme ?? DEFAULT_THEME
@@ -99,6 +144,17 @@ export function HighlightedCode({ children, className }: HighlightedCodeProps) {
99
144
  return () => { cancelled = true }
100
145
  }, [codeText, language, theme])
101
146
 
147
+ // Re-check overflow after highlight finishes (content may change width)
148
+ useEffect(() => {
149
+ if (highlightedHtml && scrollRef.current) {
150
+ const el = scrollRef.current
151
+ // Slight delay to let DOM paint
152
+ requestAnimationFrame(() => {
153
+ el.dispatchEvent(new Event('scroll'))
154
+ })
155
+ }
156
+ }, [highlightedHtml])
157
+
102
158
  async function handleCopy() {
103
159
  try {
104
160
  await navigator.clipboard.writeText(codeText)
@@ -126,51 +182,109 @@ export function HighlightedCode({ children, className }: HighlightedCodeProps) {
126
182
  // Remove trailing empty line from code
127
183
  if (lines[lines.length - 1] === '') lines.pop()
128
184
 
185
+ const fadeGradient = isLight
186
+ ? 'linear-gradient(to right, transparent, var(--color-code-bg))'
187
+ : 'linear-gradient(to right, transparent, var(--color-code-bg))'
188
+ const fadeGradientLeft = isLight
189
+ ? 'linear-gradient(to left, transparent, var(--color-code-bg))'
190
+ : 'linear-gradient(to left, transparent, var(--color-code-bg))'
191
+
129
192
  /** Render code with line numbers and/or line highlighting */
130
193
  function renderWithLineFeatures(htmlContent: string | null) {
131
194
  if (!hasLineFeatures) {
132
195
  // No line features — render normally
133
196
  if (htmlContent) {
134
197
  return (
135
- <div
136
- className="[&>pre]:!rounded-none [&>pre]:!border-0 [&>pre]:!m-0 [&>pre]:py-3.5 [&>pre]:px-4 [&>pre]:overflow-x-auto"
137
- dangerouslySetInnerHTML={{ __html: htmlContent }}
138
- />
198
+ <div className="relative">
199
+ <div
200
+ ref={scrollRef}
201
+ className="[&>pre]:!rounded-none [&>pre]:!border-0 [&>pre]:!m-0 [&>pre]:py-3.5 [&>pre]:px-4 [&>pre]:overflow-x-auto"
202
+ dangerouslySetInnerHTML={{ __html: htmlContent }}
203
+ />
204
+ {hasOverflow && scrolledFromStart && (
205
+ <div
206
+ className="absolute left-0 top-0 bottom-0 w-8 pointer-events-none"
207
+ style={{ background: fadeGradientLeft }}
208
+ aria-hidden="true"
209
+ />
210
+ )}
211
+ {hasOverflow && !scrolledToEnd && (
212
+ <div
213
+ className="absolute right-0 top-0 bottom-0 w-8 pointer-events-none"
214
+ style={{ background: fadeGradient }}
215
+ aria-hidden="true"
216
+ />
217
+ )}
218
+ </div>
139
219
  )
140
220
  }
141
221
  return (
142
- <pre className="!rounded-none !border-0 !m-0 py-3.5 px-4 overflow-x-auto">
143
- {children}
144
- </pre>
222
+ <div className="relative">
223
+ <div ref={scrollRef}>
224
+ <pre className="!rounded-none !border-0 !m-0 py-3.5 px-4 overflow-x-auto">
225
+ {children}
226
+ </pre>
227
+ </div>
228
+ {hasOverflow && scrolledFromStart && (
229
+ <div
230
+ className="absolute left-0 top-0 bottom-0 w-8 pointer-events-none"
231
+ style={{ background: fadeGradientLeft }}
232
+ aria-hidden="true"
233
+ />
234
+ )}
235
+ {hasOverflow && !scrolledToEnd && (
236
+ <div
237
+ className="absolute right-0 top-0 bottom-0 w-8 pointer-events-none"
238
+ style={{ background: fadeGradient }}
239
+ aria-hidden="true"
240
+ />
241
+ )}
242
+ </div>
145
243
  )
146
244
  }
147
245
 
148
246
  // With line features, we need to render line by line
149
247
  return (
150
- <div className="overflow-x-auto">
151
- <table className="w-full border-collapse" style={{ tableLayout: 'auto' }}>
152
- <tbody>
153
- {lines.map((line, i) => {
154
- const lineNum = i + 1
155
- const isHighlighted = highlightedLines.has(lineNum)
156
- return (
157
- <tr
158
- key={i}
159
- className={isHighlighted ? 'bg-[var(--color-primary)]/[0.07]' : ''}
160
- >
161
- {showLineNumbers && (
162
- <td className="text-right pr-4 select-none text-[var(--color-text-tertiary)] text-[0.75rem] py-0 pl-4 w-[1%] whitespace-nowrap align-top leading-[1.7142857]">
163
- {lineNum}
248
+ <div className="relative">
249
+ <div ref={scrollRef} className="overflow-x-auto">
250
+ <table className="w-full border-collapse" style={{ tableLayout: 'auto' }}>
251
+ <tbody>
252
+ {lines.map((line, i) => {
253
+ const lineNum = i + 1
254
+ const isHighlighted = highlightedLines.has(lineNum)
255
+ return (
256
+ <tr
257
+ key={i}
258
+ className={isHighlighted ? 'bg-[var(--color-primary)]/[0.07]' : ''}
259
+ >
260
+ {showLineNumbers && (
261
+ <td className="text-right pr-4 select-none text-[var(--color-text-tertiary)] text-[0.75rem] py-0 pl-4 w-[1%] whitespace-nowrap align-top leading-[1.7142857]">
262
+ {lineNum}
263
+ </td>
264
+ )}
265
+ <td className="py-0 pr-4 whitespace-pre font-mono text-[0.875em] leading-[1.7142857]">
266
+ {line || ' '}
164
267
  </td>
165
- )}
166
- <td className="py-0 pr-4 whitespace-pre font-mono text-[0.875em] leading-[1.7142857]">
167
- {line || ' '}
168
- </td>
169
- </tr>
170
- )
171
- })}
172
- </tbody>
173
- </table>
268
+ </tr>
269
+ )
270
+ })}
271
+ </tbody>
272
+ </table>
273
+ </div>
274
+ {hasOverflow && scrolledFromStart && (
275
+ <div
276
+ className="absolute left-0 top-0 bottom-0 w-8 pointer-events-none"
277
+ style={{ background: fadeGradientLeft }}
278
+ aria-hidden="true"
279
+ />
280
+ )}
281
+ {hasOverflow && !scrolledToEnd && (
282
+ <div
283
+ className="absolute right-0 top-0 bottom-0 w-8 pointer-events-none"
284
+ style={{ background: fadeGradient }}
285
+ aria-hidden="true"
286
+ />
287
+ )}
174
288
  </div>
175
289
  )
176
290
  }
@@ -13,3 +13,7 @@ export { H1, H2, H3, H4 } from './heading'
13
13
  export { ParamTable, Schema } from './param-table'
14
14
  export { Changelog, ChangelogEntry, Change } from './changelog'
15
15
  export { MethodBadge, StatusBadge, Endpoint } from './api-badge'
16
+ export { Tooltip } from './tooltip'
17
+ export { Frame } from './frame'
18
+ export { DarkImage } from './dark-image'
19
+ export { LinkPreview } from './link-preview'
@@ -0,0 +1,119 @@
1
+ 'use client'
2
+
3
+ import { useState, useRef, useCallback, useEffect } from 'react'
4
+ import Link from 'next/link'
5
+
6
+ interface LinkPreviewProps {
7
+ href: string
8
+ children: React.ReactNode
9
+ }
10
+
11
+ export function LinkPreview({ href, children }: LinkPreviewProps) {
12
+ const [showPreview, setShowPreview] = useState(false)
13
+ const [position, setPosition] = useState<{ top: number; left: number } | null>(null)
14
+ const linkRef = useRef<HTMLAnchorElement>(null)
15
+ const hoverTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
16
+ const leaveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
17
+
18
+ const isInternal = href.startsWith('/docs/') || href.startsWith('/docs')
19
+
20
+ // Derive a human-readable title from the link text (children) or href
21
+ const title = typeof children === 'string'
22
+ ? children
23
+ : href
24
+ .replace(/^\/docs\/?/, '')
25
+ .replace(/\//g, ' > ')
26
+ .replace(/-/g, ' ')
27
+ .replace(/\b\w/g, (c) => c.toUpperCase()) || 'Documentation'
28
+
29
+ const updatePosition = useCallback(() => {
30
+ if (!linkRef.current) return
31
+ const rect = linkRef.current.getBoundingClientRect()
32
+ setPosition({
33
+ top: rect.top,
34
+ left: rect.left + rect.width / 2,
35
+ })
36
+ }, [])
37
+
38
+ const handleMouseEnter = useCallback(() => {
39
+ if (!isInternal) return
40
+ if (leaveTimerRef.current) {
41
+ clearTimeout(leaveTimerRef.current)
42
+ leaveTimerRef.current = null
43
+ }
44
+ hoverTimerRef.current = setTimeout(() => {
45
+ updatePosition()
46
+ setShowPreview(true)
47
+ }, 300)
48
+ }, [isInternal, updatePosition])
49
+
50
+ const handleMouseLeave = useCallback(() => {
51
+ if (hoverTimerRef.current) {
52
+ clearTimeout(hoverTimerRef.current)
53
+ hoverTimerRef.current = null
54
+ }
55
+ leaveTimerRef.current = setTimeout(() => {
56
+ setShowPreview(false)
57
+ }, 150)
58
+ }, [])
59
+
60
+ // Cleanup timers on unmount
61
+ useEffect(() => {
62
+ return () => {
63
+ if (hoverTimerRef.current) clearTimeout(hoverTimerRef.current)
64
+ if (leaveTimerRef.current) clearTimeout(leaveTimerRef.current)
65
+ }
66
+ }, [])
67
+
68
+ // For external links, render a plain anchor
69
+ if (!isInternal) {
70
+ return (
71
+ <a href={href} target="_blank" rel="noopener noreferrer">
72
+ {children}
73
+ </a>
74
+ )
75
+ }
76
+
77
+ return (
78
+ <>
79
+ <Link
80
+ ref={linkRef}
81
+ href={href}
82
+ onMouseEnter={handleMouseEnter}
83
+ onMouseLeave={handleMouseLeave}
84
+ onFocus={handleMouseEnter}
85
+ onBlur={handleMouseLeave}
86
+ >
87
+ {children}
88
+ </Link>
89
+ {showPreview && position && (
90
+ <span
91
+ className="link-preview-card"
92
+ style={{
93
+ position: 'fixed',
94
+ top: position.top - 8,
95
+ left: position.left,
96
+ transform: 'translate(-50%, -100%)',
97
+ zIndex: 100,
98
+ }}
99
+ onMouseEnter={() => {
100
+ if (leaveTimerRef.current) {
101
+ clearTimeout(leaveTimerRef.current)
102
+ leaveTimerRef.current = null
103
+ }
104
+ }}
105
+ onMouseLeave={handleMouseLeave}
106
+ >
107
+ <span className="block px-3 py-2 rounded-lg bg-[var(--color-bg)] border border-[var(--color-border)] shadow-lg max-w-[240px]">
108
+ <span className="block text-sm font-medium text-[var(--color-text)] truncate">
109
+ {title}
110
+ </span>
111
+ <span className="block text-xs text-[var(--color-text-tertiary)] mt-0.5">
112
+ Click to navigate
113
+ </span>
114
+ </span>
115
+ </span>
116
+ )}
117
+ </>
118
+ )
119
+ }