skrypt-ai 0.4.2 → 0.6.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 (159) hide show
  1. package/dist/auth/index.d.ts +13 -3
  2. package/dist/auth/index.js +101 -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.d.ts +0 -4
  8. package/dist/autofix/index.js +10 -24
  9. package/dist/capture/browser.d.ts +11 -0
  10. package/dist/capture/browser.js +173 -0
  11. package/dist/capture/diff.d.ts +23 -0
  12. package/dist/capture/diff.js +52 -0
  13. package/dist/capture/index.d.ts +23 -0
  14. package/dist/capture/index.js +210 -0
  15. package/dist/capture/naming.d.ts +17 -0
  16. package/dist/capture/naming.js +45 -0
  17. package/dist/capture/parser.d.ts +15 -0
  18. package/dist/capture/parser.js +80 -0
  19. package/dist/capture/types.d.ts +57 -0
  20. package/dist/capture/types.js +1 -0
  21. package/dist/cli.js +20 -3
  22. package/dist/commands/autofix.js +136 -120
  23. package/dist/commands/cron.js +58 -47
  24. package/dist/commands/deploy.js +123 -102
  25. package/dist/commands/generate.js +125 -7
  26. package/dist/commands/heal.d.ts +10 -0
  27. package/dist/commands/heal.js +201 -0
  28. package/dist/commands/i18n.js +146 -111
  29. package/dist/commands/import.d.ts +2 -0
  30. package/dist/commands/import.js +157 -0
  31. package/dist/commands/init.js +19 -7
  32. package/dist/commands/lint.js +50 -44
  33. package/dist/commands/llms-txt.js +59 -49
  34. package/dist/commands/login.js +63 -34
  35. package/dist/commands/mcp.js +6 -0
  36. package/dist/commands/monitor.js +13 -8
  37. package/dist/commands/qa.d.ts +2 -0
  38. package/dist/commands/qa.js +43 -0
  39. package/dist/commands/review-pr.js +108 -92
  40. package/dist/commands/sdk.js +128 -122
  41. package/dist/commands/security.d.ts +2 -0
  42. package/dist/commands/security.js +109 -0
  43. package/dist/commands/test.js +91 -92
  44. package/dist/commands/version.js +104 -75
  45. package/dist/commands/watch.js +130 -114
  46. package/dist/config/types.js +2 -2
  47. package/dist/context-hub/index.d.ts +23 -0
  48. package/dist/context-hub/index.js +179 -0
  49. package/dist/context-hub/mappings.d.ts +8 -0
  50. package/dist/context-hub/mappings.js +55 -0
  51. package/dist/context-hub/types.d.ts +33 -0
  52. package/dist/context-hub/types.js +1 -0
  53. package/dist/generator/generator.js +39 -6
  54. package/dist/generator/types.d.ts +7 -0
  55. package/dist/generator/writer.d.ts +3 -1
  56. package/dist/generator/writer.js +36 -7
  57. package/dist/importers/confluence.d.ts +5 -0
  58. package/dist/importers/confluence.js +137 -0
  59. package/dist/importers/detect.d.ts +20 -0
  60. package/dist/importers/detect.js +121 -0
  61. package/dist/importers/docusaurus.d.ts +5 -0
  62. package/dist/importers/docusaurus.js +279 -0
  63. package/dist/importers/gitbook.d.ts +5 -0
  64. package/dist/importers/gitbook.js +189 -0
  65. package/dist/importers/github.d.ts +8 -0
  66. package/dist/importers/github.js +99 -0
  67. package/dist/importers/index.d.ts +15 -0
  68. package/dist/importers/index.js +30 -0
  69. package/dist/importers/markdown.d.ts +6 -0
  70. package/dist/importers/markdown.js +105 -0
  71. package/dist/importers/mintlify.d.ts +5 -0
  72. package/dist/importers/mintlify.js +172 -0
  73. package/dist/importers/notion.d.ts +5 -0
  74. package/dist/importers/notion.js +174 -0
  75. package/dist/importers/readme.d.ts +5 -0
  76. package/dist/importers/readme.js +184 -0
  77. package/dist/importers/transform.d.ts +90 -0
  78. package/dist/importers/transform.js +457 -0
  79. package/dist/importers/types.d.ts +37 -0
  80. package/dist/importers/types.js +1 -0
  81. package/dist/llm/anthropic-client.d.ts +1 -0
  82. package/dist/llm/anthropic-client.js +3 -1
  83. package/dist/llm/index.d.ts +6 -4
  84. package/dist/llm/index.js +76 -261
  85. package/dist/llm/openai-client.d.ts +1 -0
  86. package/dist/llm/openai-client.js +7 -2
  87. package/dist/plugins/index.js +7 -0
  88. package/dist/qa/checks.d.ts +10 -0
  89. package/dist/qa/checks.js +492 -0
  90. package/dist/qa/fixes.d.ts +30 -0
  91. package/dist/qa/fixes.js +277 -0
  92. package/dist/qa/index.d.ts +29 -0
  93. package/dist/qa/index.js +187 -0
  94. package/dist/qa/types.d.ts +24 -0
  95. package/dist/qa/types.js +1 -0
  96. package/dist/scanner/csharp.d.ts +23 -0
  97. package/dist/scanner/csharp.js +421 -0
  98. package/dist/scanner/index.js +53 -26
  99. package/dist/scanner/java.d.ts +39 -0
  100. package/dist/scanner/java.js +318 -0
  101. package/dist/scanner/kotlin.d.ts +23 -0
  102. package/dist/scanner/kotlin.js +389 -0
  103. package/dist/scanner/php.d.ts +57 -0
  104. package/dist/scanner/php.js +351 -0
  105. package/dist/scanner/python.js +17 -0
  106. package/dist/scanner/ruby.d.ts +36 -0
  107. package/dist/scanner/ruby.js +431 -0
  108. package/dist/scanner/swift.d.ts +25 -0
  109. package/dist/scanner/swift.js +392 -0
  110. package/dist/scanner/types.d.ts +1 -1
  111. package/dist/template/content/docs/_navigation.json +46 -0
  112. package/dist/template/content/docs/_sidebars.json +684 -0
  113. package/dist/template/content/docs/core.md +4544 -0
  114. package/dist/template/content/docs/index.mdx +89 -0
  115. package/dist/template/content/docs/integrations.md +1158 -0
  116. package/dist/template/content/docs/llms-full.md +403 -0
  117. package/dist/template/content/docs/llms.txt +4588 -0
  118. package/dist/template/content/docs/other.md +10379 -0
  119. package/dist/template/content/docs/tools.md +746 -0
  120. package/dist/template/content/docs/types.md +531 -0
  121. package/dist/template/docs.json +13 -11
  122. package/dist/template/mdx-components.tsx +27 -2
  123. package/dist/template/package.json +6 -0
  124. package/dist/template/public/search-index.json +1 -1
  125. package/dist/template/scripts/build-search-index.mjs +149 -13
  126. package/dist/template/src/app/api/chat/route.ts +83 -128
  127. package/dist/template/src/app/docs/[...slug]/page.tsx +75 -20
  128. package/dist/template/src/app/docs/llms-full.md +151 -4
  129. package/dist/template/src/app/docs/llms.txt +2464 -847
  130. package/dist/template/src/app/docs/page.mdx +48 -38
  131. package/dist/template/src/app/layout.tsx +3 -1
  132. package/dist/template/src/app/page.tsx +22 -8
  133. package/dist/template/src/components/ai-chat.tsx +73 -64
  134. package/dist/template/src/components/breadcrumbs.tsx +21 -23
  135. package/dist/template/src/components/copy-button.tsx +13 -9
  136. package/dist/template/src/components/copy-page-button.tsx +54 -0
  137. package/dist/template/src/components/docs-layout.tsx +37 -25
  138. package/dist/template/src/components/header.tsx +51 -10
  139. package/dist/template/src/components/mdx/card.tsx +17 -3
  140. package/dist/template/src/components/mdx/code-block.tsx +13 -9
  141. package/dist/template/src/components/mdx/code-group.tsx +13 -8
  142. package/dist/template/src/components/mdx/heading.tsx +15 -2
  143. package/dist/template/src/components/mdx/highlighted-code.tsx +13 -8
  144. package/dist/template/src/components/mdx/index.tsx +2 -0
  145. package/dist/template/src/components/mdx/mermaid.tsx +110 -0
  146. package/dist/template/src/components/mdx/screenshot.tsx +150 -0
  147. package/dist/template/src/components/scroll-to-hash.tsx +48 -0
  148. package/dist/template/src/components/sidebar.tsx +12 -18
  149. package/dist/template/src/components/table-of-contents.tsx +9 -0
  150. package/dist/template/src/lib/highlight.ts +3 -88
  151. package/dist/template/src/lib/navigation.ts +159 -0
  152. package/dist/template/src/lib/search-types.ts +4 -1
  153. package/dist/template/src/lib/search.ts +30 -7
  154. package/dist/template/src/styles/globals.css +17 -6
  155. package/dist/utils/files.d.ts +9 -1
  156. package/dist/utils/files.js +59 -10
  157. package/dist/utils/validation.d.ts +0 -3
  158. package/dist/utils/validation.js +0 -26
  159. package/package.json +5 -1
@@ -13,16 +13,23 @@ import { Feedback } from './feedback'
13
13
  import { EditLink } from './edit-link'
14
14
  import { Footer } from './footer'
15
15
  import { ScrollToTop } from './scroll-to-top'
16
+ import { ScrollToHash } from './scroll-to-hash'
17
+ import { CopyPageButton } from './copy-page-button'
18
+ import {
19
+ hasTabs,
20
+ getTabGroups,
21
+ getAllPagesFlat,
22
+ findPageInNavigation,
23
+ type Navigation,
24
+ type NavTab,
25
+ type NavGroup,
26
+ } from '@/lib/navigation'
16
27
 
17
28
  interface DocsConfig {
18
29
  name?: string
19
30
  headerLinks?: Array<{ title: string; path: string }>
20
31
  logo?: string
21
- navigation: Array<{
22
- group: string
23
- icon?: string
24
- pages: Array<{ title: string; path: string; description?: string }>
25
- }>
32
+ navigation: Navigation
26
33
  footer?: {
27
34
  links?: Array<{ title: string; url: string }>
28
35
  }
@@ -33,13 +40,9 @@ interface DocsConfig {
33
40
  }
34
41
  }
35
42
 
36
- function getAllPages(config: DocsConfig): Array<{ title: string; path: string }> {
37
- return config.navigation.flatMap((group) => group.pages)
38
- }
39
-
40
43
  function PrevNextNav({ docsConfig }: { docsConfig: DocsConfig }) {
41
44
  const pathname = usePathname()
42
- const allPages = getAllPages(docsConfig)
45
+ const allPages = getAllPagesFlat(docsConfig.navigation)
43
46
  const currentIndex = allPages.findIndex((p) => p.path === pathname)
44
47
 
45
48
  if (currentIndex === -1) return null
@@ -103,15 +106,21 @@ function MobileTOC() {
103
106
  'returned validator function', 'requirements',
104
107
  'when results are returned', 'when each value is returned',
105
108
  'validationresult shape',
109
+ 'example', 'related', 'notes', 'properties',
106
110
  ])
107
111
 
112
+ const seenIds = new Set<string>()
113
+
108
114
  elements.forEach((el) => {
109
115
  const text = el.textContent || ''
110
116
  const normalized = text.toLowerCase().trim()
111
117
  if (genericHeadings.has(normalized)) return
118
+ if (el.closest('.card-link') || el.closest('[data-card]')) return
112
119
 
113
120
  const id = el.id || normalized.replace(/\s+/g, '-')
114
121
  if (!el.id) el.id = id
122
+ if (seenIds.has(id)) return
123
+ seenIds.add(id)
115
124
  items.push({ id, text, level: parseInt(el.tagName[1]) })
116
125
  })
117
126
 
@@ -154,17 +163,6 @@ function MobileTOC() {
154
163
  )
155
164
  }
156
165
 
157
- function getPageInfo(docsConfig: DocsConfig, pathname: string): { title?: string; description?: string } {
158
- for (const group of docsConfig.navigation) {
159
- for (const page of group.pages) {
160
- if (page.path === pathname) {
161
- return { title: page.title, description: page.description }
162
- }
163
- }
164
- }
165
- return {}
166
- }
167
-
168
166
  export function DocsLayout({
169
167
  children,
170
168
  docsConfig,
@@ -178,19 +176,28 @@ export function DocsLayout({
178
176
  }) {
179
177
  const [menuOpen, setMenuOpen] = useState(false)
180
178
  const [frontmatterDescription, setFrontmatterDescription] = useState<string | undefined>()
179
+ const [rawContent, setRawContent] = useState<string | undefined>()
181
180
  const pathname = usePathname()
182
181
 
183
- // Read frontmatter description passed from the server component via data attribute
182
+ // Read frontmatter description and raw content passed from the server component via data attributes
184
183
  useEffect(() => {
185
184
  const el = document.querySelector('[data-page-description]')
186
185
  setFrontmatterDescription(el?.getAttribute('data-page-description') ?? undefined)
186
+
187
+ const rawEl = document.getElementById('raw-page-content')
188
+ const b64 = rawEl?.getAttribute('data-content')
189
+ setRawContent(b64 ? atob(b64) : undefined)
187
190
  }, [pathname])
188
191
 
189
192
  // Resolve title from props, falling back to docs.json navigation lookup
190
- const pageInfo = getPageInfo(docsConfig, pathname)
193
+ const pageInfo = findPageInNavigation(docsConfig.navigation, pathname)
191
194
  const resolvedTitle = pageTitle ?? pageInfo.title
192
195
  const resolvedDescription = pageDescription ?? frontmatterDescription ?? pageInfo.description
193
196
 
197
+ // Tab support
198
+ const tabs: NavTab[] | undefined = hasTabs(docsConfig.navigation) ? docsConfig.navigation : undefined
199
+ const sidebarGroups: NavGroup[] = getTabGroups(docsConfig.navigation, pathname)
200
+
194
201
  return (
195
202
  <div className="min-h-screen flex flex-col">
196
203
  <Header
@@ -199,18 +206,22 @@ export function DocsLayout({
199
206
  siteName={docsConfig.name}
200
207
  navLinks={docsConfig.headerLinks}
201
208
  logo={docsConfig.logo}
209
+ tabs={tabs}
202
210
  />
203
211
  <div className="flex flex-1">
204
212
  <Sidebar
205
213
  open={menuOpen}
206
214
  onClose={() => setMenuOpen(false)}
207
- docsConfig={docsConfig}
215
+ groups={sidebarGroups}
208
216
  />
209
217
  <main id="main-content" className="flex-1 min-w-0 px-6 md:px-10 py-8 lg:ml-[var(--sidebar-width)] lg:mr-[var(--toc-width)]">
210
218
  <div className="max-w-[var(--content-max-width)] mx-auto">
211
219
  <Breadcrumbs docsConfig={docsConfig} />
212
220
  {resolvedTitle && (
213
- <PageHeader title={resolvedTitle} description={resolvedDescription} />
221
+ <div className="flex items-start justify-between gap-4">
222
+ <PageHeader title={resolvedTitle} description={resolvedDescription} />
223
+ {rawContent && <CopyPageButton content={rawContent} />}
224
+ </div>
214
225
  )}
215
226
  <MobileTOC />
216
227
  <article className="prose">
@@ -232,6 +243,7 @@ export function DocsLayout({
232
243
  </div>
233
244
  <Footer docsConfig={docsConfig} />
234
245
  <ScrollToTop />
246
+ <ScrollToHash />
235
247
  </div>
236
248
  )
237
249
  }
@@ -1,11 +1,20 @@
1
1
  'use client'
2
2
 
3
3
  import Link from 'next/link'
4
- import { Search, Menu, X } from 'lucide-react'
4
+ import { usePathname } from 'next/navigation'
5
+ import { Search, Menu, X, BookOpen, Code, FileText, Settings, Key, Zap, Shield, Globe, Terminal, Database, Cloud, Lock, Rocket, Star, Heart, Package, Puzzle, GitBranch, Cpu, List } from 'lucide-react'
5
6
  import { useState, useEffect } from 'react'
6
7
  import { SearchDialog } from './search-dialog'
7
8
  import { ThemeToggle } from './theme-toggle'
8
9
  import { SyntaxThemeSelector } from './syntax-theme-selector'
10
+ import type { NavTab } from '@/lib/navigation'
11
+ import { getActiveTab, getTabHref } from '@/lib/navigation'
12
+
13
+ const tabIconMap: Record<string, React.ComponentType<{ size?: number; className?: string }>> = {
14
+ BookOpen, Code, FileText, Settings, Key, Zap, Shield, Globe,
15
+ Terminal, Database, Cloud, Lock, Rocket, Search, Star, Heart,
16
+ Package, Puzzle, GitBranch, Cpu, List,
17
+ }
9
18
 
10
19
  interface HeaderProps {
11
20
  onMenuToggle?: () => void
@@ -13,11 +22,13 @@ interface HeaderProps {
13
22
  siteName?: string
14
23
  navLinks?: Array<{ title: string; path: string }>
15
24
  logo?: string
25
+ tabs?: NavTab[]
16
26
  }
17
27
 
18
- export function Header({ onMenuToggle, menuOpen, siteName = 'Docs', navLinks, logo }: HeaderProps) {
28
+ export function Header({ onMenuToggle, menuOpen, siteName = 'Docs', navLinks, logo, tabs }: HeaderProps) {
19
29
  const [searchOpen, setSearchOpen] = useState(false)
20
30
  const [isMac, setIsMac] = useState(true)
31
+ const pathname = usePathname()
21
32
 
22
33
  useEffect(() => {
23
34
  setIsMac(
@@ -37,10 +48,12 @@ export function Header({ onMenuToggle, menuOpen, siteName = 'Docs', navLinks, lo
37
48
  return () => document.removeEventListener('keydown', handleKeyDown)
38
49
  }, [])
39
50
 
51
+ const activeTab = tabs ? getActiveTab(pathname, tabs) : null
52
+
40
53
  return (
41
54
  <>
42
- <header className="sticky top-0 z-50 h-[var(--header-height)] border-b border-[var(--color-border)] bg-[var(--color-bg)]/80 backdrop-blur-xl">
43
- <div className="flex items-center justify-between h-full px-4 md:px-6">
55
+ <header className="sticky top-0 z-50 border-b border-[var(--color-border)] bg-[var(--color-bg)]/80 backdrop-blur-xl">
56
+ <div className="flex items-center justify-between h-[var(--header-height)] px-4 md:px-6">
44
57
  <div className="flex items-center gap-6">
45
58
  {/* Mobile menu button */}
46
59
  <button
@@ -61,12 +74,14 @@ export function Header({ onMenuToggle, menuOpen, siteName = 'Docs', navLinks, lo
61
74
  </Link>
62
75
 
63
76
  <nav className="hidden md:flex items-center gap-1">
64
- <Link
65
- href="/docs"
66
- className="px-3 py-1.5 text-[0.8125rem] text-[var(--color-text-secondary)] hover:text-[var(--color-text)] hover:bg-[var(--color-bg-secondary)] rounded-lg hover:no-underline"
67
- >
68
- Documentation
69
- </Link>
77
+ {!tabs && (
78
+ <Link
79
+ href="/docs"
80
+ className="px-3 py-1.5 text-[0.8125rem] text-[var(--color-text-secondary)] hover:text-[var(--color-text)] hover:bg-[var(--color-bg-secondary)] rounded-lg hover:no-underline"
81
+ >
82
+ Documentation
83
+ </Link>
84
+ )}
70
85
  {navLinks?.map((link) => (
71
86
  <Link
72
87
  key={link.path}
@@ -94,6 +109,32 @@ export function Header({ onMenuToggle, menuOpen, siteName = 'Docs', navLinks, lo
94
109
  <ThemeToggle />
95
110
  </div>
96
111
  </div>
112
+
113
+ {/* Tab bar */}
114
+ {tabs && tabs.length > 0 && (
115
+ <div className="flex items-center gap-0 px-4 md:px-6 overflow-x-auto scrollbar-hide border-t border-[var(--color-border)]/50">
116
+ {tabs.map((tab) => {
117
+ const isActive = activeTab?.tab === tab.tab
118
+ const Icon = tab.icon ? tabIconMap[tab.icon] : null
119
+ const href = getTabHref(tab)
120
+
121
+ return (
122
+ <Link
123
+ key={tab.tab}
124
+ href={href}
125
+ className={`flex items-center gap-1.5 px-3 py-2 text-[0.8125rem] whitespace-nowrap border-b-2 transition-colors hover:no-underline ${
126
+ isActive
127
+ ? 'border-[var(--color-primary)] text-[var(--color-primary)] font-medium'
128
+ : 'border-transparent text-[var(--color-text-tertiary)] hover:text-[var(--color-text)]'
129
+ }`}
130
+ >
131
+ {Icon && <Icon size={14} />}
132
+ {tab.tab}
133
+ </Link>
134
+ )
135
+ })}
136
+ </div>
137
+ )}
97
138
  </header>
98
139
 
99
140
  <SearchDialog open={searchOpen} onClose={() => setSearchOpen(false)} />
@@ -1,3 +1,5 @@
1
+ 'use client'
2
+
1
3
  import Link from 'next/link'
2
4
  import { cn } from '@/lib/utils'
3
5
  import {
@@ -25,7 +27,7 @@ export function Card({ title, icon, href, children }: CardProps) {
25
27
  const Icon = icon ? iconMap[icon] : null
26
28
 
27
29
  const content = (
28
- <div className={cn(
30
+ <div data-card="" className={cn(
29
31
  '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
32
  href && 'cursor-pointer hover:!border-[var(--color-primary)]'
31
33
  )}>
@@ -36,7 +38,7 @@ export function Card({ title, icon, href, children }: CardProps) {
36
38
  </div>
37
39
  )}
38
40
  <div>
39
- <h3 className="font-semibold text-[0.9375rem] text-[var(--color-text)] !mt-0 !mb-0">{title}</h3>
41
+ <p className="font-semibold text-[0.9375rem] text-[var(--color-text)] !mt-0 !mb-0">{title}</p>
40
42
  {children && (
41
43
  <div className="mt-1.5 text-[0.8125rem] leading-relaxed text-[var(--color-text-secondary)]">
42
44
  {children}
@@ -48,7 +50,19 @@ export function Card({ title, icon, href, children }: CardProps) {
48
50
  )
49
51
 
50
52
  if (href) {
51
- return <Link href={href} className="hover:no-underline">{content}</Link>
53
+ if (href.startsWith('#')) {
54
+ const handleClick = (e: React.MouseEvent) => {
55
+ e.preventDefault()
56
+ const id = href.slice(1)
57
+ const target = document.getElementById(id)
58
+ if (target) {
59
+ target.scrollIntoView({ behavior: 'smooth', block: 'start' })
60
+ window.history.pushState(null, '', href)
61
+ }
62
+ }
63
+ return <a href={href} onClick={handleClick} className="card-link no-underline hover:no-underline">{content}</a>
64
+ }
65
+ return <Link href={href} className="card-link no-underline hover:no-underline">{content}</Link>
52
66
  }
53
67
 
54
68
  return content
@@ -25,15 +25,19 @@ export function CodeBlock({ children, className }: CodeBlockProps) {
25
25
  setCopied(true)
26
26
  setTimeout(() => setCopied(false), 2000)
27
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)
28
+ // Deprecated fallback for older browsers that don't support navigator.clipboard
29
+ try {
30
+ const textarea = document.createElement('textarea')
31
+ textarea.value = codeText
32
+ textarea.style.position = 'fixed'
33
+ textarea.style.opacity = '0'
34
+ document.body.appendChild(textarea)
35
+ textarea.select()
36
+ document.execCommand('copy') // deprecated but kept for old browser compat
37
+ document.body.removeChild(textarea)
38
+ } catch {
39
+ // Copy failed in both methods — silently ignore
40
+ }
37
41
  setCopied(true)
38
42
  setTimeout(() => setCopied(false), 2000)
39
43
  }
@@ -93,14 +93,19 @@ export function CodeGroup({ children }: CodeGroupProps) {
93
93
  setCopied(true)
94
94
  setTimeout(() => setCopied(false), 2000)
95
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)
96
+ // Deprecated fallback for older browsers that don't support navigator.clipboard
97
+ try {
98
+ const textarea = document.createElement('textarea')
99
+ textarea.value = activeBlock.code
100
+ textarea.style.position = 'fixed'
101
+ textarea.style.opacity = '0'
102
+ document.body.appendChild(textarea)
103
+ textarea.select()
104
+ document.execCommand('copy') // deprecated but kept for old browser compat
105
+ document.body.removeChild(textarea)
106
+ } catch {
107
+ // Copy failed in both methods — silently ignore
108
+ }
104
109
  setCopied(true)
105
110
  setTimeout(() => setCopied(false), 2000)
106
111
  }
@@ -9,10 +9,23 @@ interface HeadingProps {
9
9
  id?: string
10
10
  }
11
11
 
12
+ /** Extract plain text from React children (handles <code>, <em>, etc.) */
13
+ function getTextContent(node: ReactNode): string {
14
+ if (typeof node === 'string') return node
15
+ if (typeof node === 'number') return String(node)
16
+ if (!node) return ''
17
+ if (Array.isArray(node)) return node.map(getTextContent).join('')
18
+ if (typeof node === 'object' && 'props' in node) {
19
+ return getTextContent((node as any).props.children)
20
+ }
21
+ return ''
22
+ }
23
+
12
24
  export function Heading({ level, children, id }: HeadingProps) {
13
25
  const Tag = `h${level}` as 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6'
14
- const headingId = id || (typeof children === 'string'
15
- ? children.toLowerCase().replace(/\s+/g, '-').replace(/[^\w-]/g, '')
26
+ const text = getTextContent(children)
27
+ const headingId = id || (text
28
+ ? text.toLowerCase().replace(/\s+/g, '-').replace(/[^\w-]/g, '')
16
29
  : undefined)
17
30
 
18
31
  return (
@@ -159,14 +159,19 @@ export function HighlightedCode({ children, className }: HighlightedCodeProps) {
159
159
  try {
160
160
  await navigator.clipboard.writeText(codeText)
161
161
  } catch {
162
- const textarea = document.createElement('textarea')
163
- textarea.value = codeText
164
- textarea.style.position = 'fixed'
165
- textarea.style.opacity = '0'
166
- document.body.appendChild(textarea)
167
- textarea.select()
168
- document.execCommand('copy')
169
- document.body.removeChild(textarea)
162
+ // Deprecated fallback for older browsers that don't support navigator.clipboard
163
+ try {
164
+ const textarea = document.createElement('textarea')
165
+ textarea.value = codeText
166
+ textarea.style.position = 'fixed'
167
+ textarea.style.opacity = '0'
168
+ document.body.appendChild(textarea)
169
+ textarea.select()
170
+ document.execCommand('copy') // deprecated but kept for old browser compat
171
+ document.body.removeChild(textarea)
172
+ } catch {
173
+ // Copy failed in both methods — silently ignore
174
+ }
170
175
  }
171
176
  setCopied(true)
172
177
  setShowToast(true)
@@ -17,3 +17,5 @@ export { Tooltip } from './tooltip'
17
17
  export { Frame } from './frame'
18
18
  export { DarkImage } from './dark-image'
19
19
  export { LinkPreview } from './link-preview'
20
+ export { Mermaid } from './mermaid'
21
+ export { Screenshot } from './screenshot'
@@ -0,0 +1,110 @@
1
+ 'use client'
2
+
3
+ import { useState, useEffect, useRef, useId } from 'react'
4
+
5
+ interface MermaidProps {
6
+ chart: string
7
+ className?: string
8
+ }
9
+
10
+ export function Mermaid({ chart, className }: MermaidProps) {
11
+ const [svg, setSvg] = useState<string | null>(null)
12
+ const [error, setError] = useState<string | null>(null)
13
+ const containerRef = useRef<HTMLDivElement>(null)
14
+ const id = useId().replace(/:/g, '-')
15
+
16
+ useEffect(() => {
17
+ let cancelled = false
18
+
19
+ async function renderDiagram() {
20
+ try {
21
+ const mermaid = (await import('mermaid')).default
22
+
23
+ // Detect dark mode
24
+ const isDark = document.documentElement.classList.contains('dark')
25
+
26
+ mermaid.initialize({
27
+ startOnLoad: false,
28
+ theme: isDark ? 'dark' : 'default',
29
+ securityLevel: 'strict',
30
+ fontFamily: 'inherit',
31
+ })
32
+
33
+ const { svg: rendered } = await mermaid.render(`mermaid-${id}`, chart.trim())
34
+ if (!cancelled) {
35
+ setSvg(rendered)
36
+ setError(null)
37
+ }
38
+ } catch (err) {
39
+ if (!cancelled) {
40
+ setError(err instanceof Error ? err.message : 'Failed to render diagram')
41
+ setSvg(null)
42
+ }
43
+ }
44
+ }
45
+
46
+ renderDiagram()
47
+ return () => { cancelled = true }
48
+ }, [chart, id])
49
+
50
+ // Re-render when theme changes
51
+ useEffect(() => {
52
+ const observer = new MutationObserver((mutations) => {
53
+ for (const mutation of mutations) {
54
+ if (mutation.attributeName === 'class') {
55
+ // Theme class changed, re-render
56
+ const isDark = document.documentElement.classList.contains('dark')
57
+ import('mermaid').then(({ default: mermaid }) => {
58
+ mermaid.initialize({
59
+ startOnLoad: false,
60
+ theme: isDark ? 'dark' : 'default',
61
+ securityLevel: 'strict',
62
+ fontFamily: 'inherit',
63
+ })
64
+ // Remove old rendered element if it exists
65
+ const oldEl = document.getElementById(`mermaid-${id}`)
66
+ if (oldEl) oldEl.remove()
67
+
68
+ mermaid.render(`mermaid-${id}`, chart.trim()).then(({ svg: rendered }) => {
69
+ setSvg(rendered)
70
+ setError(null)
71
+ }).catch(() => {
72
+ // Ignore re-render errors
73
+ })
74
+ })
75
+ }
76
+ }
77
+ })
78
+
79
+ observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] })
80
+ return () => observer.disconnect()
81
+ }, [chart, id])
82
+
83
+ if (error) {
84
+ return (
85
+ <div className={`my-5 p-4 rounded-2xl border border-red-200 bg-red-50 dark:border-red-800 dark:bg-red-950 ${className || ''}`}>
86
+ <p className="text-sm text-red-600 dark:text-red-400 font-medium mb-1">Diagram error</p>
87
+ <pre className="text-xs text-red-500 dark:text-red-400 whitespace-pre-wrap">{error}</pre>
88
+ </div>
89
+ )
90
+ }
91
+
92
+ if (!svg) {
93
+ return (
94
+ <div className={`my-5 flex items-center justify-center p-8 rounded-2xl border border-[var(--color-border)] bg-[var(--color-bg-secondary)] ${className || ''}`}>
95
+ <div className="flex items-center gap-2 text-sm text-[var(--color-text-tertiary)]">
96
+ <div className="w-4 h-4 border-2 border-[var(--color-text-tertiary)] border-t-transparent rounded-full animate-spin" />
97
+ Loading diagram...
98
+ </div>
99
+ </div>
100
+ )
101
+ }
102
+
103
+ return (
104
+ <div
105
+ ref={containerRef}
106
+ className={`my-5 flex justify-center p-4 rounded-2xl border border-[var(--color-border)] bg-[var(--color-bg)] overflow-x-auto [&_svg]:max-w-full ${className || ''}`}
107
+ dangerouslySetInnerHTML={{ __html: svg }}
108
+ />
109
+ )
110
+ }
@@ -0,0 +1,150 @@
1
+ 'use client'
2
+
3
+ import { useState, useEffect } from 'react'
4
+ import { cn } from '@/lib/utils'
5
+
6
+ interface ScreenshotProps {
7
+ url: string
8
+ selector?: string
9
+ alt: string
10
+ caption?: string
11
+ className?: string
12
+ }
13
+
14
+ /**
15
+ * Compute the expected screenshot filename from url + selector.
16
+ * Must match the naming logic in src/capture/naming.ts.
17
+ */
18
+ function computeFilename(url: string, selector?: string): string {
19
+ let pathname: string
20
+ try {
21
+ pathname = new URL(url).pathname
22
+ } catch {
23
+ pathname = url
24
+ }
25
+
26
+ let slug = pathname.replace(/^\/+/, '')
27
+ slug = slug.replace(/\//g, '-')
28
+ if (!slug) slug = 'index'
29
+ slug = slug.replace(/[^a-zA-Z0-9-]/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '')
30
+
31
+ if (selector) {
32
+ let selectorSlug = selector.replace(/^[.#]/, '')
33
+ selectorSlug = selectorSlug.replace(/[^a-zA-Z0-9-]/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '')
34
+ slug = `${slug}--${selectorSlug}`
35
+ }
36
+
37
+ return `${slug}.png`
38
+ }
39
+
40
+ function getIsDark(): boolean {
41
+ if (typeof document === 'undefined') return false
42
+ if (document.documentElement.classList.contains('dark')) return true
43
+ if (document.documentElement.classList.contains('light')) return false
44
+ return window.matchMedia('(prefers-color-scheme: dark)').matches
45
+ }
46
+
47
+ function TrafficDots() {
48
+ return (
49
+ <div className="flex items-center gap-1.5">
50
+ <span className="block w-2 h-2 rounded-full bg-[#ff5f57]" />
51
+ <span className="block w-2 h-2 rounded-full bg-[#febc2e]" />
52
+ <span className="block w-2 h-2 rounded-full bg-[#28c840]" />
53
+ </div>
54
+ )
55
+ }
56
+
57
+ export function Screenshot({ url, selector, alt, caption, className }: ScreenshotProps) {
58
+ const [isDark, setIsDark] = useState(false)
59
+ const [hasError, setHasError] = useState(false)
60
+ const [fallbackToLight, setFallbackToLight] = useState(false)
61
+
62
+ const lightFile = computeFilename(url, selector)
63
+ const darkFile = lightFile.replace(/\.png$/, '-dark.png')
64
+
65
+ useEffect(() => {
66
+ setIsDark(getIsDark())
67
+
68
+ const mq = window.matchMedia('(prefers-color-scheme: dark)')
69
+ const onMediaChange = () => setIsDark(getIsDark())
70
+ mq.addEventListener('change', onMediaChange)
71
+
72
+ const observer = new MutationObserver(() => {
73
+ setIsDark(getIsDark())
74
+ })
75
+
76
+ observer.observe(document.documentElement, {
77
+ attributes: true,
78
+ attributeFilter: ['class'],
79
+ })
80
+
81
+ return () => {
82
+ mq.removeEventListener('change', onMediaChange)
83
+ observer.disconnect()
84
+ }
85
+ }, [])
86
+
87
+ const imageSrc = `/screenshots/${isDark && !fallbackToLight ? darkFile : lightFile}`
88
+
89
+ // Extract display URL for browser chrome
90
+ let displayUrl: string
91
+ try {
92
+ const parsed = new URL(url)
93
+ displayUrl = `${parsed.origin}${parsed.pathname}`
94
+ } catch {
95
+ displayUrl = url
96
+ }
97
+
98
+ return (
99
+ <figure
100
+ className={cn('my-4', className)}
101
+ data-screenshot-url={url}
102
+ data-screenshot-selector={selector}
103
+ >
104
+ <div className="rounded-xl border border-[var(--color-border)] overflow-hidden">
105
+ {/* Browser chrome with actual URL */}
106
+ <div className="flex items-center gap-3 px-3.5 py-2.5 border-b border-[var(--color-border)] bg-[var(--color-bg-secondary)]">
107
+ <TrafficDots />
108
+ <div className="flex-1 h-5 rounded-md bg-[var(--color-bg)] border border-[var(--color-border)] px-2.5 flex items-center">
109
+ <span className="text-[10px] text-[var(--color-text-tertiary)] select-none truncate">
110
+ {displayUrl}
111
+ </span>
112
+ </div>
113
+ </div>
114
+
115
+ {/* Screenshot image */}
116
+ <div className="[&>img]:!m-0 [&>img]:!rounded-none [&>img]:block [&>img]:w-full">
117
+ {hasError ? (
118
+ <div className="flex items-center justify-center p-8 bg-[var(--color-bg-secondary)] text-[var(--color-text-tertiary)] text-sm">
119
+ <p>
120
+ Screenshot not captured yet — run{' '}
121
+ <code className="px-1.5 py-0.5 rounded bg-[var(--color-bg)] border border-[var(--color-border)] text-xs">
122
+ skrypt heal --screenshots
123
+ </code>
124
+ </p>
125
+ </div>
126
+ ) : (
127
+ <img
128
+ src={imageSrc}
129
+ alt={alt}
130
+ loading="lazy"
131
+ onError={() => {
132
+ if (isDark && !fallbackToLight) {
133
+ setFallbackToLight(true)
134
+ } else {
135
+ setHasError(true)
136
+ }
137
+ }}
138
+ />
139
+ )}
140
+ </div>
141
+ </div>
142
+
143
+ {caption && (
144
+ <figcaption className="text-sm text-[var(--color-text-tertiary)] mt-2 text-center">
145
+ {caption}
146
+ </figcaption>
147
+ )}
148
+ </figure>
149
+ )
150
+ }