skrypt-ai 0.4.0 → 0.4.2
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/config/loader.js +2 -2
- 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/styles/globals.css +39 -0
- package/dist/utils/validation.js +1 -1
- package/package.json +1 -1
package/dist/config/loader.js
CHANGED
|
@@ -36,7 +36,7 @@ function parseConfigFile(filepath) {
|
|
|
36
36
|
}
|
|
37
37
|
catch (err) {
|
|
38
38
|
throw new Error(`Could not read config file: ${filepath}. ` +
|
|
39
|
-
(err instanceof Error ? err.message : String(err)));
|
|
39
|
+
(err instanceof Error ? err.message : String(err)), { cause: err });
|
|
40
40
|
}
|
|
41
41
|
let parsed;
|
|
42
42
|
try {
|
|
@@ -45,7 +45,7 @@ function parseConfigFile(filepath) {
|
|
|
45
45
|
catch (err) {
|
|
46
46
|
throw new Error(`Config file has invalid YAML: ${filepath}. ` +
|
|
47
47
|
`Please check the syntax and try again. ` +
|
|
48
|
-
(err instanceof Error ? err.message : String(err)));
|
|
48
|
+
(err instanceof Error ? err.message : String(err)), { cause: err });
|
|
49
49
|
}
|
|
50
50
|
if (parsed === null || parsed === undefined || typeof parsed !== 'object') {
|
|
51
51
|
throw new Error(`Config file is empty or not a valid YAML object: ${filepath}. ` +
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useState, useRef, useCallback, type ReactNode } from 'react'
|
|
4
|
+
|
|
5
|
+
interface TooltipProps {
|
|
6
|
+
children: ReactNode
|
|
7
|
+
content: string
|
|
8
|
+
side?: 'top' | 'bottom' | 'left' | 'right'
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const arrowStyles: Record<string, React.CSSProperties> = {
|
|
12
|
+
top: {
|
|
13
|
+
bottom: -4,
|
|
14
|
+
left: '50%',
|
|
15
|
+
transform: 'translateX(-50%) rotate(45deg)',
|
|
16
|
+
},
|
|
17
|
+
bottom: {
|
|
18
|
+
top: -4,
|
|
19
|
+
left: '50%',
|
|
20
|
+
transform: 'translateX(-50%) rotate(45deg)',
|
|
21
|
+
},
|
|
22
|
+
left: {
|
|
23
|
+
right: -4,
|
|
24
|
+
top: '50%',
|
|
25
|
+
transform: 'translateY(-50%) rotate(45deg)',
|
|
26
|
+
},
|
|
27
|
+
right: {
|
|
28
|
+
left: -4,
|
|
29
|
+
top: '50%',
|
|
30
|
+
transform: 'translateY(-50%) rotate(45deg)',
|
|
31
|
+
},
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const positionStyles: Record<string, React.CSSProperties> = {
|
|
35
|
+
top: {
|
|
36
|
+
bottom: '100%',
|
|
37
|
+
left: '50%',
|
|
38
|
+
transform: 'translateX(-50%)',
|
|
39
|
+
marginBottom: 8,
|
|
40
|
+
},
|
|
41
|
+
bottom: {
|
|
42
|
+
top: '100%',
|
|
43
|
+
left: '50%',
|
|
44
|
+
transform: 'translateX(-50%)',
|
|
45
|
+
marginTop: 8,
|
|
46
|
+
},
|
|
47
|
+
left: {
|
|
48
|
+
right: '100%',
|
|
49
|
+
top: '50%',
|
|
50
|
+
transform: 'translateY(-50%)',
|
|
51
|
+
marginRight: 8,
|
|
52
|
+
},
|
|
53
|
+
right: {
|
|
54
|
+
left: '100%',
|
|
55
|
+
top: '50%',
|
|
56
|
+
transform: 'translateY(-50%)',
|
|
57
|
+
marginLeft: 8,
|
|
58
|
+
},
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function Tooltip({ children, content, side = 'top' }: TooltipProps) {
|
|
62
|
+
const [visible, setVisible] = useState(false)
|
|
63
|
+
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
64
|
+
|
|
65
|
+
const show = useCallback(() => {
|
|
66
|
+
timeoutRef.current = setTimeout(() => setVisible(true), 150)
|
|
67
|
+
}, [])
|
|
68
|
+
|
|
69
|
+
const hide = useCallback(() => {
|
|
70
|
+
if (timeoutRef.current) {
|
|
71
|
+
clearTimeout(timeoutRef.current)
|
|
72
|
+
timeoutRef.current = null
|
|
73
|
+
}
|
|
74
|
+
setVisible(false)
|
|
75
|
+
}, [])
|
|
76
|
+
|
|
77
|
+
return (
|
|
78
|
+
<span
|
|
79
|
+
className="relative inline-flex"
|
|
80
|
+
onMouseEnter={show}
|
|
81
|
+
onMouseLeave={hide}
|
|
82
|
+
onFocus={show}
|
|
83
|
+
onBlur={hide}
|
|
84
|
+
>
|
|
85
|
+
{children}
|
|
86
|
+
{visible && (
|
|
87
|
+
<span
|
|
88
|
+
role="tooltip"
|
|
89
|
+
className="tooltip-content absolute z-50 whitespace-nowrap bg-[var(--color-gray-900)] text-white text-xs px-2.5 py-1.5 rounded-lg shadow-lg tooltip-fade-in pointer-events-none"
|
|
90
|
+
style={positionStyles[side]}
|
|
91
|
+
>
|
|
92
|
+
{content}
|
|
93
|
+
<span
|
|
94
|
+
className="tooltip-content absolute h-2 w-2 bg-[var(--color-gray-900)]"
|
|
95
|
+
style={arrowStyles[side]}
|
|
96
|
+
/>
|
|
97
|
+
</span>
|
|
98
|
+
)}
|
|
99
|
+
</span>
|
|
100
|
+
)
|
|
101
|
+
}
|
|
@@ -1,49 +1,196 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
|
|
3
|
-
import { useContext } from 'react'
|
|
3
|
+
import { useContext, useState, useRef, useEffect, useCallback, KeyboardEvent } from 'react'
|
|
4
4
|
import { SyntaxThemeContext } from '@/contexts/syntax-theme'
|
|
5
5
|
import { AVAILABLE_THEMES, DEFAULT_THEME } from '@/lib/highlight'
|
|
6
6
|
import { Palette } from 'lucide-react'
|
|
7
7
|
|
|
8
8
|
export function SyntaxThemeSelector() {
|
|
9
9
|
const context = useContext(SyntaxThemeContext)
|
|
10
|
+
const [isOpen, setIsOpen] = useState(false)
|
|
11
|
+
const [activeIndex, setActiveIndex] = useState(-1)
|
|
12
|
+
const triggerRef = useRef<HTMLButtonElement>(null)
|
|
13
|
+
const listRef = useRef<HTMLDivElement>(null)
|
|
14
|
+
const optionRefs = useRef<(HTMLButtonElement | null)[]>([])
|
|
15
|
+
const closeTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
10
16
|
|
|
11
|
-
// Safely handle when context isn't available (during SSG)
|
|
12
17
|
const theme = context?.theme ?? DEFAULT_THEME
|
|
13
18
|
const setTheme = context?.setTheme ?? (() => {})
|
|
14
19
|
const availableThemes = context?.availableThemes ?? AVAILABLE_THEMES
|
|
15
20
|
|
|
21
|
+
// Find the index of the current active theme
|
|
22
|
+
const activeThemeIndex = availableThemes.findIndex((t) => t.name === theme)
|
|
23
|
+
|
|
24
|
+
const open = useCallback(() => {
|
|
25
|
+
if (closeTimeoutRef.current) {
|
|
26
|
+
clearTimeout(closeTimeoutRef.current)
|
|
27
|
+
closeTimeoutRef.current = null
|
|
28
|
+
}
|
|
29
|
+
setIsOpen(true)
|
|
30
|
+
setActiveIndex(activeThemeIndex >= 0 ? activeThemeIndex : 0)
|
|
31
|
+
}, [activeThemeIndex])
|
|
32
|
+
|
|
33
|
+
const close = useCallback(() => {
|
|
34
|
+
setIsOpen(false)
|
|
35
|
+
setActiveIndex(-1)
|
|
36
|
+
}, [])
|
|
37
|
+
|
|
38
|
+
const delayedClose = useCallback(() => {
|
|
39
|
+
closeTimeoutRef.current = setTimeout(close, 150)
|
|
40
|
+
}, [close])
|
|
41
|
+
|
|
42
|
+
const cancelClose = useCallback(() => {
|
|
43
|
+
if (closeTimeoutRef.current) {
|
|
44
|
+
clearTimeout(closeTimeoutRef.current)
|
|
45
|
+
closeTimeoutRef.current = null
|
|
46
|
+
}
|
|
47
|
+
}, [])
|
|
48
|
+
|
|
49
|
+
// Focus the active option when activeIndex changes while open
|
|
50
|
+
useEffect(() => {
|
|
51
|
+
if (isOpen && activeIndex >= 0) {
|
|
52
|
+
optionRefs.current[activeIndex]?.focus()
|
|
53
|
+
}
|
|
54
|
+
}, [isOpen, activeIndex])
|
|
55
|
+
|
|
56
|
+
// Close on outside click
|
|
57
|
+
useEffect(() => {
|
|
58
|
+
if (!isOpen) return
|
|
59
|
+
function handleClick(e: MouseEvent) {
|
|
60
|
+
const target = e.target as Node
|
|
61
|
+
if (
|
|
62
|
+
!triggerRef.current?.contains(target) &&
|
|
63
|
+
!listRef.current?.contains(target)
|
|
64
|
+
) {
|
|
65
|
+
close()
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
document.addEventListener('mousedown', handleClick)
|
|
69
|
+
return () => document.removeEventListener('mousedown', handleClick)
|
|
70
|
+
}, [isOpen, close])
|
|
71
|
+
|
|
72
|
+
// Close on Escape
|
|
73
|
+
useEffect(() => {
|
|
74
|
+
if (!isOpen) return
|
|
75
|
+
function handleEsc(e: globalThis.KeyboardEvent) {
|
|
76
|
+
if (e.key === 'Escape') {
|
|
77
|
+
close()
|
|
78
|
+
triggerRef.current?.focus()
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
document.addEventListener('keydown', handleEsc)
|
|
82
|
+
return () => document.removeEventListener('keydown', handleEsc)
|
|
83
|
+
}, [isOpen, close])
|
|
84
|
+
|
|
85
|
+
function selectTheme(index: number) {
|
|
86
|
+
const t = availableThemes[index]
|
|
87
|
+
if (t) {
|
|
88
|
+
setTheme(t.name)
|
|
89
|
+
close()
|
|
90
|
+
triggerRef.current?.focus()
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function handleTriggerKeyDown(e: KeyboardEvent) {
|
|
95
|
+
if (e.key === 'ArrowDown' || e.key === 'Enter' || e.key === ' ') {
|
|
96
|
+
e.preventDefault()
|
|
97
|
+
open()
|
|
98
|
+
} else if (e.key === 'ArrowUp') {
|
|
99
|
+
e.preventDefault()
|
|
100
|
+
open()
|
|
101
|
+
setActiveIndex(availableThemes.length - 1)
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function handleOptionKeyDown(e: KeyboardEvent, index: number) {
|
|
106
|
+
switch (e.key) {
|
|
107
|
+
case 'ArrowDown':
|
|
108
|
+
e.preventDefault()
|
|
109
|
+
setActiveIndex((index + 1) % availableThemes.length)
|
|
110
|
+
break
|
|
111
|
+
case 'ArrowUp':
|
|
112
|
+
e.preventDefault()
|
|
113
|
+
setActiveIndex((index - 1 + availableThemes.length) % availableThemes.length)
|
|
114
|
+
break
|
|
115
|
+
case 'Home':
|
|
116
|
+
e.preventDefault()
|
|
117
|
+
setActiveIndex(0)
|
|
118
|
+
break
|
|
119
|
+
case 'End':
|
|
120
|
+
e.preventDefault()
|
|
121
|
+
setActiveIndex(availableThemes.length - 1)
|
|
122
|
+
break
|
|
123
|
+
case 'Enter':
|
|
124
|
+
case ' ':
|
|
125
|
+
e.preventDefault()
|
|
126
|
+
selectTheme(index)
|
|
127
|
+
break
|
|
128
|
+
case 'Tab':
|
|
129
|
+
close()
|
|
130
|
+
break
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const listboxId = 'syntax-theme-listbox'
|
|
135
|
+
|
|
16
136
|
return (
|
|
17
|
-
<div
|
|
137
|
+
<div
|
|
138
|
+
className="relative"
|
|
139
|
+
onMouseEnter={() => { open(); cancelClose() }}
|
|
140
|
+
onMouseLeave={delayedClose}
|
|
141
|
+
>
|
|
18
142
|
<button
|
|
143
|
+
ref={triggerRef}
|
|
19
144
|
className="flex items-center gap-2 px-3 py-1.5 text-sm text-[var(--color-text-secondary)] hover:text-[var(--color-text)] rounded-md hover:bg-[var(--color-bg-secondary)] transition-colors"
|
|
20
145
|
title="Syntax theme"
|
|
21
146
|
aria-label={`Syntax theme: ${theme}`}
|
|
22
147
|
aria-haspopup="listbox"
|
|
148
|
+
aria-expanded={isOpen}
|
|
149
|
+
aria-controls={isOpen ? listboxId : undefined}
|
|
150
|
+
onClick={() => { isOpen ? close() : open() }}
|
|
151
|
+
onKeyDown={handleTriggerKeyDown}
|
|
23
152
|
>
|
|
24
153
|
<Palette size={16} />
|
|
25
154
|
<span className="hidden sm:inline">Theme</span>
|
|
26
155
|
</button>
|
|
27
156
|
|
|
28
|
-
<div
|
|
29
|
-
|
|
157
|
+
<div
|
|
158
|
+
ref={listRef}
|
|
159
|
+
id={listboxId}
|
|
160
|
+
role="listbox"
|
|
161
|
+
aria-label="Syntax theme"
|
|
162
|
+
aria-activedescendant={activeIndex >= 0 ? `syntax-theme-option-${activeIndex}` : undefined}
|
|
163
|
+
className={`absolute right-0 top-full mt-1 w-48 py-1 bg-[var(--color-bg)] border border-[var(--color-border)] rounded-lg shadow-lg transition-all z-50 ${
|
|
164
|
+
isOpen ? 'opacity-100 visible' : 'opacity-0 invisible pointer-events-none'
|
|
165
|
+
}`}
|
|
166
|
+
>
|
|
167
|
+
<div className="px-3 py-1.5 text-xs font-medium text-[var(--color-text-tertiary)] uppercase tracking-wide" aria-hidden="true">
|
|
30
168
|
Syntax Theme
|
|
31
169
|
</div>
|
|
32
|
-
{availableThemes.map((t) =>
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
170
|
+
{availableThemes.map((t, index) => {
|
|
171
|
+
const isSelected = theme === t.name
|
|
172
|
+
return (
|
|
173
|
+
<button
|
|
174
|
+
key={t.name}
|
|
175
|
+
ref={(el) => { optionRefs.current[index] = el }}
|
|
176
|
+
id={`syntax-theme-option-${index}`}
|
|
177
|
+
role="option"
|
|
178
|
+
aria-selected={isSelected}
|
|
179
|
+
tabIndex={-1}
|
|
180
|
+
onClick={() => selectTheme(index)}
|
|
181
|
+
onKeyDown={(e) => handleOptionKeyDown(e, index)}
|
|
182
|
+
className={`w-full px-3 py-1.5 text-left text-sm flex items-center gap-2 hover:bg-[var(--color-bg-secondary)] transition-colors ${
|
|
183
|
+
isSelected ? 'text-[var(--color-primary)]' : 'text-[var(--color-text)]'
|
|
184
|
+
} ${activeIndex === index ? 'bg-[var(--color-bg-secondary)]' : ''}`}
|
|
185
|
+
>
|
|
186
|
+
<span
|
|
187
|
+
className={`w-3 h-3 rounded-full ${t.isDark ? 'bg-gray-800' : 'bg-gray-200'}`}
|
|
188
|
+
/>
|
|
189
|
+
{t.label}
|
|
190
|
+
{isSelected && <span className="ml-auto text-xs">Active</span>}
|
|
191
|
+
</button>
|
|
192
|
+
)
|
|
193
|
+
})}
|
|
47
194
|
</div>
|
|
48
195
|
</div>
|
|
49
196
|
)
|
|
@@ -121,6 +121,12 @@
|
|
|
121
121
|
:root:not(.light) .feedback-negative { background-color: rgba(127, 29, 29, 0.3); color: #f87171; }
|
|
122
122
|
}
|
|
123
123
|
|
|
124
|
+
/* Tooltip — dark mode overrides */
|
|
125
|
+
:root.dark .tooltip-content { background-color: var(--color-gray-100); color: var(--color-gray-900); }
|
|
126
|
+
@media (prefers-color-scheme: dark) {
|
|
127
|
+
:root:not(.light) .tooltip-content { background-color: var(--color-gray-100); color: var(--color-gray-900); }
|
|
128
|
+
}
|
|
129
|
+
|
|
124
130
|
/* ========================
|
|
125
131
|
Base — in @layer base so Tailwind utilities can override
|
|
126
132
|
======================== */
|
|
@@ -463,3 +469,36 @@ input:focus-visible, textarea:focus-visible {
|
|
|
463
469
|
animation: shimmer 1.5s infinite;
|
|
464
470
|
border-radius: 0.25rem;
|
|
465
471
|
}
|
|
472
|
+
|
|
473
|
+
/* ========================
|
|
474
|
+
Tooltip fade-in
|
|
475
|
+
======================== */
|
|
476
|
+
@keyframes tooltip-fade-in {
|
|
477
|
+
from { opacity: 0; }
|
|
478
|
+
to { opacity: 1; }
|
|
479
|
+
}
|
|
480
|
+
.tooltip-fade-in {
|
|
481
|
+
animation: tooltip-fade-in 150ms ease-out;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/* ========================
|
|
485
|
+
Page transitions — subtle fade-in for article content
|
|
486
|
+
======================== */
|
|
487
|
+
@keyframes page-fade-in {
|
|
488
|
+
from { opacity: 0; transform: translateY(4px); }
|
|
489
|
+
to { opacity: 1; transform: translateY(0); }
|
|
490
|
+
}
|
|
491
|
+
.prose {
|
|
492
|
+
animation: page-fade-in 200ms ease-out;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
/* ========================
|
|
496
|
+
Link preview card
|
|
497
|
+
======================== */
|
|
498
|
+
@keyframes link-preview-in {
|
|
499
|
+
from { opacity: 0; transform: translate(-50%, -100%) translateY(4px); }
|
|
500
|
+
to { opacity: 1; transform: translate(-50%, -100%) translateY(0); }
|
|
501
|
+
}
|
|
502
|
+
.link-preview-card {
|
|
503
|
+
animation: link-preview-in 150ms ease-out;
|
|
504
|
+
}
|
package/dist/utils/validation.js
CHANGED
|
@@ -31,7 +31,7 @@ export function validateSlug(input) {
|
|
|
31
31
|
}
|
|
32
32
|
export function sanitizeForShell(input) {
|
|
33
33
|
// Only allow safe characters for git refs, filenames, etc.
|
|
34
|
-
if (!/^[a-zA-Z0-9
|
|
34
|
+
if (!/^[a-zA-Z0-9/_~.^@{}-]+$/.test(input)) {
|
|
35
35
|
throw new Error(`Unsafe characters in input: ${input}`);
|
|
36
36
|
}
|
|
37
37
|
return input;
|