skrypt-ai 0.3.4 → 0.4.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.
Files changed (95) hide show
  1. package/README.md +1 -1
  2. package/dist/auth/index.d.ts +0 -1
  3. package/dist/auth/index.js +3 -5
  4. package/dist/autofix/index.js +15 -3
  5. package/dist/cli.js +19 -4
  6. package/dist/commands/check-links.js +164 -174
  7. package/dist/commands/deploy.js +5 -2
  8. package/dist/commands/generate.js +206 -199
  9. package/dist/commands/i18n.js +3 -20
  10. package/dist/commands/init.js +47 -40
  11. package/dist/commands/lint.js +3 -20
  12. package/dist/commands/mcp.js +125 -122
  13. package/dist/commands/monitor.js +125 -108
  14. package/dist/commands/review-pr.js +1 -1
  15. package/dist/commands/sdk.js +1 -1
  16. package/dist/config/loader.js +21 -2
  17. package/dist/generator/organizer.d.ts +3 -0
  18. package/dist/generator/organizer.js +4 -9
  19. package/dist/generator/writer.js +2 -10
  20. package/dist/github/pr-comments.js +21 -8
  21. package/dist/plugins/index.js +1 -0
  22. package/dist/scanner/index.js +8 -2
  23. package/dist/template/docs.json +2 -1
  24. package/dist/template/next.config.mjs +2 -1
  25. package/dist/template/package.json +17 -15
  26. package/dist/template/public/favicon.svg +4 -0
  27. package/dist/template/public/search-index.json +1 -1
  28. package/dist/template/scripts/build-search-index.mjs +120 -25
  29. package/dist/template/src/app/api/chat/route.ts +11 -3
  30. package/dist/template/src/app/docs/README.md +28 -0
  31. package/dist/template/src/app/docs/[...slug]/page.tsx +139 -16
  32. package/dist/template/src/app/docs/auth/page.mdx +589 -0
  33. package/dist/template/src/app/docs/autofix/page.mdx +624 -0
  34. package/dist/template/src/app/docs/cli/page.mdx +217 -0
  35. package/dist/template/src/app/docs/config/page.mdx +428 -0
  36. package/dist/template/src/app/docs/configuration/page.mdx +86 -0
  37. package/dist/template/src/app/docs/deployment/page.mdx +112 -0
  38. package/dist/template/src/app/docs/error.tsx +20 -0
  39. package/dist/template/src/app/docs/generator/generator.md +504 -0
  40. package/dist/template/src/app/docs/generator/organizer.md +779 -0
  41. package/dist/template/src/app/docs/generator/page.mdx +613 -0
  42. package/dist/template/src/app/docs/github/page.mdx +502 -0
  43. package/dist/template/src/app/docs/llm/anthropic-client.md +549 -0
  44. package/dist/template/src/app/docs/llm/index.md +471 -0
  45. package/dist/template/src/app/docs/llm/page.mdx +428 -0
  46. package/dist/template/src/app/docs/llms-full.md +256 -0
  47. package/dist/template/src/app/docs/llms.txt +2971 -0
  48. package/dist/template/src/app/docs/not-found.tsx +23 -0
  49. package/dist/template/src/app/docs/page.mdx +0 -3
  50. package/dist/template/src/app/docs/plugins/page.mdx +1793 -0
  51. package/dist/template/src/app/docs/pro/page.mdx +121 -0
  52. package/dist/template/src/app/docs/quickstart/page.mdx +93 -0
  53. package/dist/template/src/app/docs/scanner/content-type.md +599 -0
  54. package/dist/template/src/app/docs/scanner/index.md +212 -0
  55. package/dist/template/src/app/docs/scanner/page.mdx +307 -0
  56. package/dist/template/src/app/docs/scanner/python.md +469 -0
  57. package/dist/template/src/app/docs/scanner/python_parser.md +1056 -0
  58. package/dist/template/src/app/docs/scanner/rust.md +325 -0
  59. package/dist/template/src/app/docs/scanner/typescript.md +201 -0
  60. package/dist/template/src/app/error.tsx +3 -3
  61. package/dist/template/src/app/icon.tsx +29 -0
  62. package/dist/template/src/app/layout.tsx +42 -0
  63. package/dist/template/src/app/not-found.tsx +35 -0
  64. package/dist/template/src/app/page.tsx +62 -28
  65. package/dist/template/src/components/ai-chat.tsx +26 -21
  66. package/dist/template/src/components/breadcrumbs.tsx +46 -2
  67. package/dist/template/src/components/copy-button.tsx +17 -3
  68. package/dist/template/src/components/docs-layout.tsx +142 -8
  69. package/dist/template/src/components/feedback.tsx +4 -2
  70. package/dist/template/src/components/footer.tsx +42 -0
  71. package/dist/template/src/components/header.tsx +29 -5
  72. package/dist/template/src/components/mdx/accordion.tsx +7 -6
  73. package/dist/template/src/components/mdx/card.tsx +19 -7
  74. package/dist/template/src/components/mdx/code-block.tsx +17 -3
  75. package/dist/template/src/components/mdx/code-group.tsx +65 -18
  76. package/dist/template/src/components/mdx/code-playground.tsx +3 -0
  77. package/dist/template/src/components/mdx/go-playground.tsx +3 -0
  78. package/dist/template/src/components/mdx/highlighted-code.tsx +171 -76
  79. package/dist/template/src/components/mdx/python-playground.tsx +2 -0
  80. package/dist/template/src/components/mdx/tabs.tsx +74 -6
  81. package/dist/template/src/components/page-header.tsx +19 -0
  82. package/dist/template/src/components/scroll-to-top.tsx +33 -0
  83. package/dist/template/src/components/search-dialog.tsx +206 -52
  84. package/dist/template/src/components/sidebar.tsx +136 -77
  85. package/dist/template/src/components/table-of-contents.tsx +23 -7
  86. package/dist/template/src/lib/highlight.ts +90 -31
  87. package/dist/template/src/lib/search.ts +14 -4
  88. package/dist/template/src/lib/theme-utils.ts +140 -0
  89. package/dist/template/src/styles/globals.css +307 -166
  90. package/dist/template/src/types/remark-gfm.d.ts +2 -0
  91. package/dist/utils/files.d.ts +9 -0
  92. package/dist/utils/files.js +33 -0
  93. package/dist/utils/validation.d.ts +4 -0
  94. package/dist/utils/validation.js +38 -0
  95. package/package.json +1 -4
@@ -2,7 +2,7 @@
2
2
 
3
3
  import Link from 'next/link'
4
4
  import { Search, Menu, X } from 'lucide-react'
5
- import { useState } from 'react'
5
+ import { useState, useEffect } from 'react'
6
6
  import { SearchDialog } from './search-dialog'
7
7
  import { ThemeToggle } from './theme-toggle'
8
8
  import { SyntaxThemeSelector } from './syntax-theme-selector'
@@ -12,10 +12,30 @@ interface HeaderProps {
12
12
  menuOpen?: boolean
13
13
  siteName?: string
14
14
  navLinks?: Array<{ title: string; path: string }>
15
+ logo?: string
15
16
  }
16
17
 
17
- export function Header({ onMenuToggle, menuOpen, siteName = 'Docs', navLinks }: HeaderProps) {
18
+ export function Header({ onMenuToggle, menuOpen, siteName = 'Docs', navLinks, logo }: HeaderProps) {
18
19
  const [searchOpen, setSearchOpen] = useState(false)
20
+ const [isMac, setIsMac] = useState(true)
21
+
22
+ useEffect(() => {
23
+ setIsMac(
24
+ navigator.platform?.toLowerCase().includes('mac') ||
25
+ navigator.userAgent?.toLowerCase().includes('mac')
26
+ )
27
+ }, [])
28
+
29
+ useEffect(() => {
30
+ function handleKeyDown(e: KeyboardEvent) {
31
+ if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
32
+ e.preventDefault()
33
+ setSearchOpen(prev => !prev)
34
+ }
35
+ }
36
+ document.addEventListener('keydown', handleKeyDown)
37
+ return () => document.removeEventListener('keydown', handleKeyDown)
38
+ }, [])
19
39
 
20
40
  return (
21
41
  <>
@@ -32,8 +52,12 @@ export function Header({ onMenuToggle, menuOpen, siteName = 'Docs', navLinks }:
32
52
  {menuOpen ? <X size={18} /> : <Menu size={18} />}
33
53
  </button>
34
54
 
35
- <Link href="/" className="font-semibold text-[0.9375rem] tracking-tight text-[var(--color-text)] hover:no-underline">
36
- {siteName}
55
+ <Link href="/" className="flex items-center gap-2 font-semibold text-[0.9375rem] tracking-tight text-[var(--color-text)] hover:no-underline">
56
+ {logo ? (
57
+ <img src={logo} alt={siteName} className="h-6 w-auto" />
58
+ ) : (
59
+ siteName
60
+ )}
37
61
  </Link>
38
62
 
39
63
  <nav className="hidden md:flex items-center gap-1">
@@ -63,7 +87,7 @@ export function Header({ onMenuToggle, menuOpen, siteName = 'Docs', navLinks }:
63
87
  <Search size={14} />
64
88
  <span className="hidden sm:inline">Search...</span>
65
89
  <kbd className="hidden sm:inline ml-2 px-1.5 py-0.5 text-[0.6875rem] font-medium bg-[var(--color-bg)] border border-[var(--color-border)] rounded">
66
- ⌘K
90
+ {isMac ? '⌘K' : 'Ctrl+K'}
67
91
  </kbd>
68
92
  </button>
69
93
  <SyntaxThemeSelector />
@@ -17,6 +17,7 @@ export function Accordion({ title, defaultOpen = false, children }: AccordionPro
17
17
  <div className="border-b border-[var(--color-border)] last:border-b-0">
18
18
  <button
19
19
  onClick={() => setOpen(!open)}
20
+ aria-expanded={open}
20
21
  className="flex items-center gap-2 w-full py-3.5 text-left text-[0.875rem] font-medium text-[var(--color-text)] hover:text-[var(--color-primary)] transition-colors"
21
22
  >
22
23
  <ChevronRight
@@ -29,13 +30,13 @@ export function Accordion({ title, defaultOpen = false, children }: AccordionPro
29
30
  {title}
30
31
  </button>
31
32
  <div
32
- className={cn(
33
- 'overflow-hidden transition-all duration-200',
34
- open ? 'max-h-[2000px] opacity-100' : 'max-h-0 opacity-0'
35
- )}
33
+ className="grid transition-all duration-200"
34
+ style={{ gridTemplateRows: open ? '1fr' : '0fr' }}
36
35
  >
37
- <div className="pl-5 pb-4 text-[0.8125rem] text-[var(--color-text-secondary)] leading-relaxed">
38
- {children}
36
+ <div className="overflow-hidden">
37
+ <div className="pl-5 pb-4 text-[0.8125rem] text-[var(--color-text-secondary)] leading-relaxed">
38
+ {children}
39
+ </div>
39
40
  </div>
40
41
  </div>
41
42
  </div>
@@ -1,25 +1,37 @@
1
1
  import Link from 'next/link'
2
2
  import { cn } from '@/lib/utils'
3
- import * as Icons from 'lucide-react'
3
+ import {
4
+ BookOpen, Code, FileText, Settings, Key, Zap, Shield, Globe,
5
+ Terminal, Database, Cloud, Lock, Rocket, Search, Star, Heart,
6
+ AlertTriangle, CheckCircle, Info, HelpCircle, ArrowRight,
7
+ ExternalLink, Package, Puzzle, GitBranch, Cpu
8
+ } from 'lucide-react'
9
+
10
+ const iconMap: Record<string, React.ComponentType<{ size?: number; className?: string }>> = {
11
+ BookOpen, Code, FileText, Settings, Key, Zap, Shield, Globe,
12
+ Terminal, Database, Cloud, Lock, Rocket, Search, Star, Heart,
13
+ AlertTriangle, CheckCircle, Info, HelpCircle, ArrowRight,
14
+ ExternalLink, Package, Puzzle, GitBranch, Cpu
15
+ }
4
16
 
5
17
  interface CardProps {
6
18
  title: string
7
- icon?: keyof typeof Icons
19
+ icon?: string
8
20
  href?: string
9
21
  children?: React.ReactNode
10
22
  }
11
23
 
12
24
  export function Card({ title, icon, href, children }: CardProps) {
13
- const Icon = icon ? Icons[icon] as React.ComponentType<{ size?: number; className?: string }> : null
25
+ const Icon = icon ? iconMap[icon] : null
14
26
 
15
27
  const content = (
16
28
  <div className={cn(
17
- 'group p-5 border border-[var(--color-border)] rounded-xl transition-all duration-200',
18
- href && 'hover:border-[var(--color-border-strong)] hover:shadow-sm hover:-translate-y-0.5 cursor-pointer'
29
+ 'group p-4 sm:px-6 sm:py-5 rounded-2xl border border-[var(--color-border)] bg-[var(--color-bg)] overflow-hidden w-full',
30
+ href && 'cursor-pointer hover:!border-[var(--color-primary)]'
19
31
  )}>
20
32
  <div className="flex items-start gap-3.5">
21
33
  {Icon && (
22
- <div className="shrink-0 p-2 bg-[var(--color-bg-tertiary)] text-[var(--color-text-secondary)] rounded-lg group-hover:bg-[var(--color-primary-light)] group-hover:text-[var(--color-primary)] transition-colors">
34
+ <div className="shrink-0 mt-0.5 text-[var(--color-text-tertiary)] group-hover:text-[var(--color-primary)]">
23
35
  <Icon size={18} />
24
36
  </div>
25
37
  )}
@@ -55,7 +67,7 @@ export function CardGroup({ cols = 2, children }: CardGroupProps) {
55
67
  }
56
68
 
57
69
  return (
58
- <div className={cn('grid gap-3 my-6', gridCols[cols])}>
70
+ <div className={cn('grid gap-4 my-2', gridCols[cols])}>
59
71
  {children}
60
72
  </div>
61
73
  )
@@ -20,9 +20,23 @@ export function CodeBlock({ children, className }: CodeBlockProps) {
20
20
  })
21
21
 
22
22
  async function handleCopy() {
23
- await navigator.clipboard.writeText(codeText)
24
- setCopied(true)
25
- setTimeout(() => setCopied(false), 2000)
23
+ try {
24
+ await navigator.clipboard.writeText(codeText)
25
+ setCopied(true)
26
+ setTimeout(() => setCopied(false), 2000)
27
+ } catch {
28
+ // Fallback for insecure contexts
29
+ const textarea = document.createElement('textarea')
30
+ textarea.value = codeText
31
+ textarea.style.position = 'fixed'
32
+ textarea.style.opacity = '0'
33
+ document.body.appendChild(textarea)
34
+ textarea.select()
35
+ document.execCommand('copy')
36
+ document.body.removeChild(textarea)
37
+ setCopied(true)
38
+ setTimeout(() => setCopied(false), 2000)
39
+ }
26
40
  }
27
41
 
28
42
  return (
@@ -1,8 +1,10 @@
1
1
  'use client'
2
2
 
3
- import { useState, Children, isValidElement, ReactNode, ReactElement } from 'react'
3
+ import { useState, useEffect, useContext, Children, isValidElement, ReactNode, ReactElement } from 'react'
4
4
  import { cn } from '@/lib/utils'
5
5
  import { Copy, Check } from 'lucide-react'
6
+ import { highlight, DEFAULT_THEME, isLightTheme, type ThemeName } from '@/lib/highlight'
7
+ import { SyntaxThemeContext } from '@/contexts/syntax-theme'
6
8
 
7
9
  interface CodeGroupProps {
8
10
  children: ReactNode
@@ -54,17 +56,54 @@ export function CodeGroup({ children }: CodeGroupProps) {
54
56
  const blocks = extractCodeBlocks(children)
55
57
  const [activeIndex, setActiveIndex] = useState(0)
56
58
  const [copied, setCopied] = useState(false)
59
+ const [highlightedHtml, setHighlightedHtml] = useState<string | null>(null)
60
+
61
+ const syntaxContext = useContext(SyntaxThemeContext)
62
+ const theme: ThemeName = syntaxContext?.theme ?? DEFAULT_THEME
63
+ const isLight = isLightTheme(theme)
64
+
65
+ const activeBlock = blocks[activeIndex] || blocks[0]
66
+
67
+ useEffect(() => {
68
+ if (!activeBlock?.code) return
69
+ let cancelled = false
70
+
71
+ async function doHighlight() {
72
+ try {
73
+ const html = await highlight(activeBlock.code, activeBlock.language, theme)
74
+ if (!cancelled) setHighlightedHtml(html)
75
+ } catch (err) {
76
+ console.error('CodeGroup highlight failed:', err)
77
+ if (!cancelled) setHighlightedHtml(null)
78
+ }
79
+ }
80
+
81
+ setHighlightedHtml(null)
82
+ doHighlight()
83
+ return () => { cancelled = true }
84
+ }, [activeBlock?.code, activeBlock?.language, theme])
57
85
 
58
86
  if (blocks.length === 0) {
59
87
  return <div>{children}</div>
60
88
  }
61
89
 
62
- const activeBlock = blocks[activeIndex]
63
-
64
90
  const copyToClipboard = async () => {
65
- await navigator.clipboard.writeText(activeBlock.code)
66
- setCopied(true)
67
- setTimeout(() => setCopied(false), 2000)
91
+ try {
92
+ await navigator.clipboard.writeText(activeBlock.code)
93
+ setCopied(true)
94
+ setTimeout(() => setCopied(false), 2000)
95
+ } catch {
96
+ const textarea = document.createElement('textarea')
97
+ textarea.value = activeBlock.code
98
+ textarea.style.position = 'fixed'
99
+ textarea.style.opacity = '0'
100
+ document.body.appendChild(textarea)
101
+ textarea.select()
102
+ document.execCommand('copy')
103
+ document.body.removeChild(textarea)
104
+ setCopied(true)
105
+ setTimeout(() => setCopied(false), 2000)
106
+ }
68
107
  }
69
108
 
70
109
  const getLanguageLabel = (lang: string, filename?: string) => {
@@ -78,9 +117,9 @@ export function CodeGroup({ children }: CodeGroupProps) {
78
117
  jsx: 'JavaScript',
79
118
  python: 'Python',
80
119
  py: 'Python',
81
- bash: 'Terminal',
82
- shell: 'Terminal',
83
- sh: 'Terminal',
120
+ bash: 'Bash',
121
+ shell: 'Shell',
122
+ sh: 'Shell',
84
123
  json: 'JSON',
85
124
  yaml: 'YAML',
86
125
  yml: 'YAML',
@@ -101,7 +140,7 @@ export function CodeGroup({ children }: CodeGroupProps) {
101
140
  return (
102
141
  <div className="my-6 rounded-xl overflow-hidden border border-[var(--color-border)] bg-[var(--color-code-bg)]">
103
142
  {/* Tab bar */}
104
- <div className="flex items-center justify-between border-b border-white/[0.06]">
143
+ <div className="flex items-center justify-between border-b border-[var(--color-code-border)]">
105
144
  <div className="flex gap-0 px-1 pt-1">
106
145
  {blocks.map((block, index) => (
107
146
  <button
@@ -110,8 +149,8 @@ export function CodeGroup({ children }: CodeGroupProps) {
110
149
  className={cn(
111
150
  'px-3 py-1.5 text-[0.75rem] font-medium rounded-t-lg transition-colors',
112
151
  index === activeIndex
113
- ? 'bg-white/[0.06] text-[var(--color-code-text)]'
114
- : 'text-white/40 hover:text-white/60'
152
+ ? 'bg-[var(--color-bg-tertiary)] text-[var(--color-code-text)]'
153
+ : 'text-[var(--color-text-tertiary)] hover:text-[var(--color-text-secondary)]'
115
154
  )}
116
155
  >
117
156
  {getLanguageLabel(block.language, block.filename)}
@@ -120,19 +159,27 @@ export function CodeGroup({ children }: CodeGroupProps) {
120
159
  </div>
121
160
  <button
122
161
  onClick={copyToClipboard}
123
- className="p-2 mr-2 text-white/30 hover:text-white/60 transition-colors"
162
+ className="p-2 mr-2 text-[var(--color-text-tertiary)] hover:text-[var(--color-text-secondary)] transition-colors"
124
163
  title="Copy code"
164
+ aria-label={copied ? 'Copied' : 'Copy code'}
125
165
  >
126
166
  {copied ? <Check size={14} /> : <Copy size={14} />}
127
167
  </button>
128
168
  </div>
129
169
 
130
170
  {/* Code content */}
131
- <pre className="!m-0 !rounded-none !border-0 !bg-transparent">
132
- <code className={`language-${activeBlock.language}`}>
133
- {activeBlock.code}
134
- </code>
135
- </pre>
171
+ {highlightedHtml ? (
172
+ <div
173
+ className="[&>pre]:!rounded-none [&>pre]:!border-0 [&>pre]:!m-0 [&>pre]:py-3.5 [&>pre]:px-4 [&>pre]:overflow-x-auto"
174
+ dangerouslySetInnerHTML={{ __html: highlightedHtml }}
175
+ />
176
+ ) : (
177
+ <pre className="!m-0 !rounded-none !border-0 !bg-transparent py-3.5 px-4 overflow-x-auto">
178
+ <code className={`language-${activeBlock.language}`}>
179
+ {activeBlock.code}
180
+ </code>
181
+ </pre>
182
+ )}
136
183
  </div>
137
184
  )
138
185
  }
@@ -157,6 +157,7 @@ function PlaygroundToolbar({ originalCode, filename, autoRun, onAutoRunChange }:
157
157
  onClick={handleReset}
158
158
  className="p-1.5 text-[var(--color-text-tertiary)] hover:text-[var(--color-text)] rounded"
159
159
  title="Reset code"
160
+ aria-label="Reset code"
160
161
  >
161
162
  <RotateCcw size={14} />
162
163
  </button>
@@ -164,6 +165,7 @@ function PlaygroundToolbar({ originalCode, filename, autoRun, onAutoRunChange }:
164
165
  onClick={handleDownload}
165
166
  className="p-1.5 text-[var(--color-text-tertiary)] hover:text-[var(--color-text)] rounded"
166
167
  title="Download code"
168
+ aria-label="Download code"
167
169
  >
168
170
  <Download size={14} />
169
171
  </button>
@@ -171,6 +173,7 @@ function PlaygroundToolbar({ originalCode, filename, autoRun, onAutoRunChange }:
171
173
  onClick={() => onAutoRunChange(!autoRun)}
172
174
  className={`p-1.5 rounded ${autoRun ? 'text-emerald-500' : 'text-[var(--color-text-tertiary)]'} hover:text-[var(--color-text)]`}
173
175
  title={autoRun ? 'Auto-run enabled' : 'Auto-run disabled'}
176
+ aria-label={autoRun ? 'Auto-run enabled' : 'Auto-run disabled'}
174
177
  >
175
178
  {autoRun ? <Play size={14} /> : <Pause size={14} />}
176
179
  </button>
@@ -154,6 +154,7 @@ export function GoPlayground({
154
154
  onClick={handleReset}
155
155
  className="p-1.5 text-[var(--color-text-tertiary)] hover:text-[var(--color-text)] rounded"
156
156
  title="Reset code"
157
+ aria-label="Reset code"
157
158
  >
158
159
  <RotateCcw size={14} />
159
160
  </button>
@@ -161,6 +162,7 @@ export function GoPlayground({
161
162
  onClick={handleDownload}
162
163
  className="p-1.5 text-[var(--color-text-tertiary)] hover:text-[var(--color-text)] rounded"
163
164
  title="Download code"
165
+ aria-label="Download code"
164
166
  >
165
167
  <Download size={14} />
166
168
  </button>
@@ -168,6 +170,7 @@ export function GoPlayground({
168
170
  onClick={openInPlayground}
169
171
  className="p-1.5 text-[var(--color-text-tertiary)] hover:text-[var(--color-text)] rounded"
170
172
  title="Open in Go Playground"
173
+ aria-label="Open in Go Playground"
171
174
  >
172
175
  <ExternalLink size={14} />
173
176
  </button>
@@ -1,134 +1,229 @@
1
1
  'use client'
2
2
 
3
- import { useState, useEffect, useContext, ReactNode, isValidElement, Children } from 'react'
4
- import { Copy, Check, FileCode } from 'lucide-react'
5
- import { highlight, DEFAULT_THEME, type ThemeName } from '@/lib/highlight'
3
+ import { useState, useEffect, useContext, ReactNode, isValidElement, Children, useRef } from 'react'
4
+ import { Copy, Check } from 'lucide-react'
5
+ import { highlight, DEFAULT_THEME, isLightTheme, type ThemeName } from '@/lib/highlight'
6
6
  import { SyntaxThemeContext } from '@/contexts/syntax-theme'
7
7
 
8
+ const TERMINAL_LANGUAGES = ['bash', 'shell', 'sh', 'zsh']
9
+
8
10
  interface HighlightedCodeProps {
9
11
  children: ReactNode
10
12
  className?: string
11
13
  }
12
14
 
13
15
  const langLabels: Record<string, string> = {
14
- typescript: 'TypeScript',
15
- javascript: 'JavaScript',
16
- ts: 'TypeScript',
17
- js: 'JavaScript',
18
- tsx: 'TSX',
19
- jsx: 'JSX',
20
- python: 'Python',
21
- py: 'Python',
22
- bash: 'Terminal',
23
- shell: 'Terminal',
24
- sh: 'Terminal',
25
- json: 'JSON',
26
- yaml: 'YAML',
27
- go: 'Go',
28
- rust: 'Rust',
29
- css: 'CSS',
30
- html: 'HTML',
31
- sql: 'SQL',
32
- text: '',
16
+ typescript: 'TypeScript', javascript: 'JavaScript',
17
+ ts: 'TypeScript', js: 'JavaScript', tsx: 'TSX', jsx: 'JSX',
18
+ python: 'Python', py: 'Python',
19
+ bash: 'Bash', shell: 'Shell', sh: 'Shell',
20
+ json: 'JSON', yaml: 'YAML', go: 'Go', rust: 'Rust',
21
+ css: 'CSS', html: 'HTML', sql: 'SQL', text: '',
22
+ }
23
+
24
+ /** Parse meta string for showLineNumbers */
25
+ function parseShowLineNumbers(meta: string): boolean {
26
+ return /showLineNumbers/.test(meta)
27
+ }
28
+
29
+ /** Parse meta string for highlighted lines: {1,3-5,8} */
30
+ function parseHighlightedLines(meta: string): Set<number> {
31
+ const set = new Set<number>()
32
+ const match = meta.match(/\{([\d,\s-]+)\}/)
33
+ if (!match) return set
34
+
35
+ const parts = match[1].split(',')
36
+ for (const part of parts) {
37
+ const trimmed = part.trim()
38
+ const rangeMatch = trimmed.match(/^(\d+)-(\d+)$/)
39
+ if (rangeMatch) {
40
+ const start = parseInt(rangeMatch[1])
41
+ const end = parseInt(rangeMatch[2])
42
+ for (let i = start; i <= end; i++) set.add(i)
43
+ } else {
44
+ const num = parseInt(trimmed)
45
+ if (!isNaN(num)) set.add(num)
46
+ }
47
+ }
48
+ return set
49
+ }
50
+
51
+ /** Parse meta string for title="filename.ts" or title=filename.ts */
52
+ function parseFilename(meta: string): string | undefined {
53
+ const match = meta.match(/title=["']?([^"'\s]+)["']?/)
54
+ return match ? match[1] : undefined
33
55
  }
34
56
 
35
57
  export function HighlightedCode({ children, className }: HighlightedCodeProps) {
36
58
  const [copied, setCopied] = useState(false)
59
+ const [showToast, setShowToast] = useState(false)
37
60
  const [highlightedHtml, setHighlightedHtml] = useState<string | null>(null)
61
+ const copyButtonRef = useRef<HTMLButtonElement>(null)
38
62
 
39
63
  const syntaxContext = useContext(SyntaxThemeContext)
40
64
  const theme: ThemeName = syntaxContext?.theme ?? DEFAULT_THEME
65
+ const isLight = isLightTheme(theme)
41
66
 
42
67
  let codeText = ''
43
68
  let language = 'text'
44
- let filename = ''
69
+ let metaString = ''
45
70
 
46
71
  Children.forEach(children, (child) => {
47
72
  if (isValidElement(child) && child.type === 'code') {
48
- const props = child.props as { children?: string; className?: string; 'data-filename'?: string }
73
+ const props = child.props as { children?: string; className?: string; 'data-meta'?: string }
49
74
  codeText = props.children || ''
50
75
  const classMatch = props.className?.match(/language-(\w+)/)
51
- if (classMatch) {
52
- language = classMatch[1]
53
- }
54
- if (props['data-filename']) {
55
- filename = props['data-filename']
56
- }
76
+ if (classMatch) language = classMatch[1]
77
+ metaString = props['data-meta'] || ''
57
78
  }
58
79
  })
59
80
 
81
+ const showLineNumbers = parseShowLineNumbers(metaString)
82
+ const highlightedLines = parseHighlightedLines(metaString)
83
+ const filename = parseFilename(metaString)
84
+ const hasLineFeatures = showLineNumbers || highlightedLines.size > 0
85
+ const isTerminal = TERMINAL_LANGUAGES.includes(language)
86
+
60
87
  useEffect(() => {
61
88
  let cancelled = false
62
-
63
89
  async function doHighlight() {
64
90
  if (!codeText) return
65
-
66
91
  try {
67
92
  const html = await highlight(codeText, language, theme)
68
- if (!cancelled) {
69
- setHighlightedHtml(html)
70
- }
93
+ if (!cancelled) setHighlightedHtml(html)
71
94
  } catch (err) {
72
95
  console.error('Highlight failed:', err)
73
96
  }
74
97
  }
75
-
76
98
  doHighlight()
77
-
78
- return () => {
79
- cancelled = true
80
- }
99
+ return () => { cancelled = true }
81
100
  }, [codeText, language, theme])
82
101
 
83
102
  async function handleCopy() {
84
- await navigator.clipboard.writeText(codeText)
103
+ try {
104
+ await navigator.clipboard.writeText(codeText)
105
+ } catch {
106
+ const textarea = document.createElement('textarea')
107
+ textarea.value = codeText
108
+ textarea.style.position = 'fixed'
109
+ textarea.style.opacity = '0'
110
+ document.body.appendChild(textarea)
111
+ textarea.select()
112
+ document.execCommand('copy')
113
+ document.body.removeChild(textarea)
114
+ }
85
115
  setCopied(true)
116
+ setShowToast(true)
86
117
  setTimeout(() => setCopied(false), 2000)
118
+ setTimeout(() => setShowToast(false), 1500)
87
119
  }
88
120
 
89
- const langLabel = filename || langLabels[language] || language
90
- const showHeader = langLabel && language !== 'text'
121
+ if (!codeText.trim()) return null
122
+
123
+ const langLabel = langLabels[language] || language
124
+ const showLabel = langLabel && language !== 'text'
125
+ const lines = codeText.split('\n')
126
+ // Remove trailing empty line from code
127
+ if (lines[lines.length - 1] === '') lines.pop()
128
+
129
+ /** Render code with line numbers and/or line highlighting */
130
+ function renderWithLineFeatures(htmlContent: string | null) {
131
+ if (!hasLineFeatures) {
132
+ // No line features — render normally
133
+ if (htmlContent) {
134
+ 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
+ />
139
+ )
140
+ }
141
+ return (
142
+ <pre className="!rounded-none !border-0 !m-0 py-3.5 px-4 overflow-x-auto">
143
+ {children}
144
+ </pre>
145
+ )
146
+ }
147
+
148
+ // With line features, we need to render line by line
149
+ 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}
164
+ </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>
174
+ </div>
175
+ )
176
+ }
177
+
178
+ const hasHeader = !!filename
179
+ const showTerminalHeader = isTerminal && !hasHeader
91
180
 
92
181
  return (
93
- <div className="relative group my-5 rounded-xl overflow-hidden border border-[var(--color-border)] bg-[var(--color-code-bg)]">
94
- {/* Filename / language header bar */}
95
- {showHeader && (
96
- <div className="flex items-center justify-between px-4 py-2 border-b border-white/[0.06]">
97
- <div className="flex items-center gap-2 text-[0.75rem] text-white/40">
98
- <FileCode size={13} />
99
- <span>{langLabel}</span>
100
- </div>
182
+ <div className={`relative group mt-5 mb-8 rounded-2xl overflow-hidden border border-[var(--color-border)] ${isTerminal ? 'terminal-block' : ''}`}>
183
+ {/* Terminal header with macOS-style dots */}
184
+ {showTerminalHeader && (
185
+ <div className="flex items-center gap-1.5 px-4 py-2.5 bg-[var(--color-bg-tertiary)] border-b border-[var(--color-code-border)]">
186
+ <span className="w-2.5 h-2.5 rounded-full bg-red-500/70" />
187
+ <span className="w-2.5 h-2.5 rounded-full bg-yellow-500/70" />
188
+ <span className="w-2.5 h-2.5 rounded-full bg-green-500/70" />
189
+ <span className="ml-2 text-[0.7rem] text-[var(--color-text-tertiary)]">Terminal</span>
190
+ </div>
191
+ )}
192
+
193
+ {/* Filename header */}
194
+ {filename && (
195
+ <div className="bg-[var(--color-bg-secondary)] border-b border-[var(--color-border)] px-4 py-2 text-[0.8125rem] text-[var(--color-text-secondary)] font-medium">
196
+ {filename}
197
+ </div>
198
+ )}
199
+
200
+ {renderWithLineFeatures(highlightedHtml)}
201
+
202
+ {/* Floating label + copy button — always visible (dimmed), full on hover */}
203
+ <div className="absolute top-2 right-2 flex items-center gap-1.5 opacity-60 group-hover:opacity-100 transition-opacity">
204
+ {showLabel && !filename && !showTerminalHeader && (
205
+ <span className={`text-[0.6875rem] px-1.5 py-0.5 rounded-md ${isLight ? 'text-black/30' : 'text-white/30'}`}>
206
+ {langLabel}
207
+ </span>
208
+ )}
209
+ <div className="relative">
101
210
  <button
211
+ ref={copyButtonRef}
102
212
  onClick={handleCopy}
103
- className="p-1 text-white/30 hover:text-white/60 transition-colors"
213
+ className={`p-1.5 rounded-md backdrop-blur ${isLight ? 'text-black/30 hover:text-black/60' : 'text-white/30 hover:text-white/60'}`}
104
214
  title={copied ? 'Copied!' : 'Copy code'}
215
+ aria-label={copied ? 'Copied' : 'Copy code'}
105
216
  >
106
- {copied ? <Check size={13} /> : <Copy size={13} />}
217
+ {copied ? <Check size={14} /> : <Copy size={14} />}
107
218
  </button>
219
+ {/* Copy toast */}
220
+ {showToast && (
221
+ <span className="copy-toast absolute -bottom-8 left-1/2 -translate-x-1/2 px-2 py-1 rounded-md bg-[var(--color-bg-secondary)] border border-[var(--color-border)] text-[0.6875rem] text-[var(--color-text-secondary)] whitespace-nowrap shadow-lg">
222
+ Copied!
223
+ </span>
224
+ )}
108
225
  </div>
109
- )}
110
-
111
- {highlightedHtml ? (
112
- <div
113
- className="[&>pre]:!rounded-none [&>pre]:!border-0 [&>pre]:!m-0 [&>pre]:px-4 [&>pre]:py-3 [&>pre]:overflow-x-auto [&>pre]:!bg-transparent"
114
- dangerouslySetInnerHTML={{ __html: highlightedHtml }}
115
- />
116
- ) : (
117
- <pre className="!rounded-none !border-0 !m-0 px-4 py-3 overflow-x-auto !bg-transparent">
118
- {children}
119
- </pre>
120
- )}
121
-
122
- {/* Copy button fallback (no header) */}
123
- {!showHeader && (
124
- <button
125
- onClick={handleCopy}
126
- className="absolute top-2.5 right-2.5 p-1.5 rounded-md text-white/30 hover:text-white/60 opacity-0 group-hover:opacity-100 transition-all"
127
- title={copied ? 'Copied!' : 'Copy code'}
128
- >
129
- {copied ? <Check size={13} /> : <Copy size={13} />}
130
- </button>
131
- )}
226
+ </div>
132
227
  </div>
133
228
  )
134
229
  }