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.
- package/dist/auth/index.d.ts +13 -3
- package/dist/auth/index.js +94 -9
- package/dist/auth/keychain.d.ts +5 -0
- package/dist/auth/keychain.js +82 -0
- package/dist/auth/notices.d.ts +3 -0
- package/dist/auth/notices.js +42 -0
- package/dist/autofix/index.js +10 -3
- package/dist/cli.js +16 -3
- package/dist/commands/generate.js +37 -1
- package/dist/commands/import.d.ts +2 -0
- package/dist/commands/import.js +157 -0
- package/dist/commands/init.js +19 -7
- package/dist/commands/login.js +15 -4
- package/dist/commands/review-pr.js +10 -0
- package/dist/commands/security.d.ts +2 -0
- package/dist/commands/security.js +103 -0
- package/dist/config/loader.js +2 -2
- package/dist/generator/writer.js +12 -3
- package/dist/importers/confluence.d.ts +5 -0
- package/dist/importers/confluence.js +137 -0
- package/dist/importers/detect.d.ts +20 -0
- package/dist/importers/detect.js +121 -0
- package/dist/importers/docusaurus.d.ts +5 -0
- package/dist/importers/docusaurus.js +279 -0
- package/dist/importers/gitbook.d.ts +5 -0
- package/dist/importers/gitbook.js +189 -0
- package/dist/importers/github.d.ts +8 -0
- package/dist/importers/github.js +99 -0
- package/dist/importers/index.d.ts +15 -0
- package/dist/importers/index.js +30 -0
- package/dist/importers/markdown.d.ts +6 -0
- package/dist/importers/markdown.js +105 -0
- package/dist/importers/mintlify.d.ts +5 -0
- package/dist/importers/mintlify.js +172 -0
- package/dist/importers/notion.d.ts +5 -0
- package/dist/importers/notion.js +174 -0
- package/dist/importers/readme.d.ts +5 -0
- package/dist/importers/readme.js +184 -0
- package/dist/importers/transform.d.ts +90 -0
- package/dist/importers/transform.js +457 -0
- package/dist/importers/types.d.ts +37 -0
- package/dist/importers/types.js +1 -0
- package/dist/plugins/index.js +7 -0
- package/dist/scanner/index.js +37 -24
- package/dist/scanner/python.js +17 -0
- package/dist/template/public/search-index.json +1 -1
- package/dist/template/scripts/build-search-index.mjs +67 -9
- package/dist/template/src/components/mdx/dark-image.tsx +56 -0
- package/dist/template/src/components/mdx/frame.tsx +64 -0
- package/dist/template/src/components/mdx/highlighted-code.tsx +145 -31
- package/dist/template/src/components/mdx/index.tsx +4 -0
- package/dist/template/src/components/mdx/link-preview.tsx +119 -0
- package/dist/template/src/components/mdx/tooltip.tsx +101 -0
- package/dist/template/src/components/syntax-theme-selector.tsx +167 -20
- package/dist/template/src/lib/search-types.ts +4 -1
- package/dist/template/src/lib/search.ts +30 -7
- package/dist/template/src/styles/globals.css +39 -0
- package/dist/utils/files.d.ts +9 -1
- package/dist/utils/files.js +59 -10
- package/dist/utils/validation.js +1 -1
- 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
|
-
|
|
137
|
-
|
|
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
|
-
<
|
|
143
|
-
{
|
|
144
|
-
|
|
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="
|
|
151
|
-
<
|
|
152
|
-
<
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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
|
+
}
|