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,71 +2,68 @@
2
2
 
3
3
  import Link from 'next/link'
4
4
  import { usePathname } from 'next/navigation'
5
- import { ChevronRight } from 'lucide-react'
6
- import { useState } from 'react'
5
+ import { ChevronRight, BookOpen, Code, FileText, Settings, Key, Zap, Shield, Globe, Terminal, Database, Cloud, Lock, Rocket, Search, Star, Heart, Package, Puzzle, GitBranch, Cpu } from 'lucide-react'
6
+ import { useState, useMemo } from 'react'
7
7
  import { cn } from '@/lib/utils'
8
8
 
9
- interface NavItem {
10
- title: string
11
- href?: string
12
- items?: NavItem[]
13
- }
14
-
15
- interface NavPage {
16
- title: string
17
- path: string
18
- }
9
+ interface NavPage { title: string; path: string; pages?: NavPage[] }
10
+ interface NavGroup { group: string; icon?: string; pages: (NavPage | NavGroup)[] }
11
+ interface DocsConfig { navigation: NavGroup[] }
19
12
 
20
- interface NavGroup {
21
- group: string
22
- pages: NavPage[]
13
+ interface SidebarProps {
14
+ open?: boolean
15
+ onClose?: () => void
16
+ docsConfig: DocsConfig
23
17
  }
24
18
 
25
- interface DocsConfig {
26
- navigation: NavGroup[]
19
+ const iconMap: Record<string, React.ComponentType<{ size?: number; className?: string }>> = {
20
+ BookOpen, Code, FileText, Settings, Key, Zap, Shield, Globe,
21
+ Terminal, Database, Cloud, Lock, Rocket, Search, Star, Heart,
22
+ Package, Puzzle, GitBranch, Cpu,
27
23
  }
28
24
 
29
- function buildNavigation(config: DocsConfig): NavItem[] {
30
- return config.navigation.map((group) => ({
31
- title: group.group,
32
- items: group.pages.map((page) => ({
33
- title: page.title,
34
- href: page.path,
35
- })),
36
- }))
25
+ function isNavGroup(item: NavPage | NavGroup): item is NavGroup {
26
+ return 'group' in item && !('path' in item)
37
27
  }
38
28
 
39
- interface SidebarProps {
40
- open?: boolean
41
- onClose?: () => void
42
- docsConfig: DocsConfig
29
+ /** Check if any descendant page matches the current pathname */
30
+ function hasActivePage(pages: (NavPage | NavGroup)[], pathname: string): boolean {
31
+ for (const item of pages) {
32
+ if (isNavGroup(item)) {
33
+ if (hasActivePage(item.pages, pathname)) return true
34
+ } else {
35
+ if (item.path === pathname) return true
36
+ if (item.pages && hasActivePage(item.pages, pathname)) return true
37
+ }
38
+ }
39
+ return false
43
40
  }
44
41
 
45
42
  export function Sidebar({ open, onClose, docsConfig }: SidebarProps) {
46
43
  const pathname = usePathname()
47
- const navigation = buildNavigation(docsConfig)
48
44
 
49
45
  return (
50
46
  <>
51
- {/* Mobile overlay */}
52
47
  {open && (
53
48
  <div
54
- className="fixed inset-0 z-40 bg-black/50 backdrop-blur-sm md:hidden"
49
+ className="fixed inset-0 z-40 bg-black/50 backdrop-blur-sm lg:hidden"
50
+ role="button"
51
+ tabIndex={0}
52
+ aria-label="Close sidebar"
55
53
  onClick={onClose}
54
+ onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onClose?.() } }}
56
55
  />
57
56
  )}
58
-
59
- {/* Sidebar */}
60
57
  <aside
61
58
  className={cn(
62
- 'fixed left-0 top-[var(--header-height)] bottom-0 w-[var(--sidebar-width)] border-r border-[var(--color-border)] bg-[var(--color-bg)] overflow-y-auto z-50 transition-transform duration-200',
63
- 'md:translate-x-0',
64
- open ? 'translate-x-0' : '-translate-x-full md:translate-x-0'
59
+ 'fixed left-0 top-[var(--header-height)] bottom-0 w-[var(--sidebar-width)] bg-[var(--color-bg)] overflow-y-auto z-50 transition-transform duration-100 pl-2 sidebar-scroll-shadow',
60
+ 'lg:translate-x-0',
61
+ open ? 'translate-x-0' : '-translate-x-full lg:translate-x-0'
65
62
  )}
66
63
  >
67
- <nav className="px-3 py-4 space-y-6">
68
- {navigation.map((section) => (
69
- <NavSection key={section.title} section={section} pathname={pathname} onNavigate={onClose} />
64
+ <nav className="py-4 pr-2 space-y-6 divide-y divide-[var(--color-border)]">
65
+ {docsConfig.navigation.map((group) => (
66
+ <SidebarGroup key={group.group} group={group} pathname={pathname} onNavigate={onClose} level={0} />
70
67
  ))}
71
68
  </nav>
72
69
  </aside>
@@ -74,55 +71,117 @@ export function Sidebar({ open, onClose, docsConfig }: SidebarProps) {
74
71
  )
75
72
  }
76
73
 
77
- function NavSection({ section, pathname, onNavigate }: { section: NavItem; pathname: string; onNavigate?: () => void }) {
78
- const hasActiveChild = section.items?.some((item) => pathname === item.href)
79
- const [expanded, setExpanded] = useState(true)
74
+ function SidebarGroup({ group, pathname, onNavigate, level }: { group: NavGroup; pathname: string; onNavigate?: () => void; level: number }) {
75
+ const isActive = useMemo(() => hasActivePage(group.pages, pathname), [group.pages, pathname])
76
+ const [expanded, setExpanded] = useState(isActive)
80
77
 
81
- if (!section.items) {
82
- return (
83
- <Link
84
- href={section.href || '#'}
85
- onClick={onNavigate}
86
- className={cn(
87
- 'block px-3 py-1.5 text-sm rounded-lg transition-colors',
88
- pathname === section.href
89
- ? 'bg-[var(--color-primary-light)] text-[var(--color-primary)] font-medium'
90
- : 'text-[var(--color-text-secondary)] hover:text-[var(--color-text)] hover:bg-[var(--color-bg-secondary)]'
91
- )}
92
- >
93
- {section.title}
94
- </Link>
95
- )
96
- }
78
+ const Icon = group.icon ? iconMap[group.icon] : null
79
+
80
+ // Limit nesting to 3 levels
81
+ if (level > 2) return null
82
+
83
+ const paddingLeft = level === 0 ? 'pl-4' : level === 1 ? 'pl-10' : 'pl-14'
97
84
 
98
85
  return (
99
86
  <div>
100
87
  <button
101
88
  onClick={() => setExpanded(!expanded)}
102
- className="flex items-center justify-between w-full px-3 py-1.5 text-xs font-semibold uppercase tracking-wider text-[var(--color-text-tertiary)] hover:text-[var(--color-text-secondary)] transition-colors"
89
+ aria-expanded={expanded}
90
+ className={cn(
91
+ 'flex items-center gap-2.5 w-full mb-2 text-[0.8125rem] font-medium text-[var(--color-text)] hover:text-[var(--color-text)] transition-colors',
92
+ paddingLeft
93
+ )}
103
94
  >
104
- {section.title}
105
95
  <ChevronRight
106
96
  size={14}
107
- className={cn('transition-transform duration-200', expanded && 'rotate-90')}
97
+ className={cn('text-[var(--color-text-tertiary)] transition-transform duration-100', expanded && 'rotate-90')}
108
98
  />
99
+ {Icon && <Icon size={14} className="text-[var(--color-text-tertiary)]" />}
100
+ {group.group}
109
101
  </button>
110
102
  {expanded && (
111
- <div className="mt-1 space-y-0.5">
112
- {section.items.map((item) => (
113
- <Link
114
- key={item.href}
115
- href={item.href || '#'}
116
- onClick={onNavigate}
117
- className={cn(
118
- 'block pl-3 ml-3 py-1.5 text-[0.8125rem] rounded-lg transition-colors border-l-2',
119
- pathname === item.href
120
- ? 'border-[var(--color-primary)] text-[var(--color-primary)] font-medium bg-[var(--color-primary-light)]'
121
- : 'border-transparent text-[var(--color-text-secondary)] hover:text-[var(--color-text)] hover:border-[var(--color-border-strong)]'
122
- )}
123
- >
124
- {item.title}
125
- </Link>
103
+ <div className="space-y-px">
104
+ {group.pages.map((item) => {
105
+ if (isNavGroup(item)) {
106
+ return (
107
+ <SidebarGroup
108
+ key={item.group}
109
+ group={item}
110
+ pathname={pathname}
111
+ onNavigate={onNavigate}
112
+ level={level + 1}
113
+ />
114
+ )
115
+ }
116
+
117
+ return (
118
+ <SidebarPageItem
119
+ key={item.path}
120
+ page={item}
121
+ pathname={pathname}
122
+ onNavigate={onNavigate}
123
+ level={level}
124
+ />
125
+ )
126
+ })}
127
+ </div>
128
+ )}
129
+ </div>
130
+ )
131
+ }
132
+
133
+ function SidebarPageItem({ page, pathname, onNavigate, level }: { page: NavPage; pathname: string; onNavigate?: () => void; level: number }) {
134
+ // Limit nesting depth to prevent infinite recursion
135
+ if (level > 3) return null
136
+ const hasChildren = page.pages && page.pages.length > 0
137
+ const isActive = useMemo(() => {
138
+ if (page.path === pathname) return true
139
+ if (page.pages && hasActivePage(page.pages, pathname)) return true
140
+ return false
141
+ }, [page, pathname])
142
+ const [expanded, setExpanded] = useState(isActive)
143
+
144
+ const paddingClass = level === 0 ? 'pl-10' : level === 1 ? 'pl-14' : 'pl-18'
145
+
146
+ return (
147
+ <div>
148
+ <div className="flex items-center">
149
+ <Link
150
+ href={page.path}
151
+ onClick={onNavigate}
152
+ className={cn(
153
+ 'block flex-1 pr-3 py-1.5 text-[0.8125rem] rounded-lg transition-colors',
154
+ paddingClass,
155
+ pathname === page.path
156
+ ? 'bg-[var(--color-primary)]/10 text-[var(--color-primary)]'
157
+ : 'text-[var(--color-text-secondary)] hover:bg-[var(--color-primary)]/10 hover:text-[var(--color-primary)]'
158
+ )}
159
+ >
160
+ {page.title}
161
+ </Link>
162
+ {hasChildren && (
163
+ <button
164
+ onClick={() => setExpanded(!expanded)}
165
+ aria-expanded={expanded}
166
+ className="p-1 mr-2 text-[var(--color-text-tertiary)] hover:text-[var(--color-text)]"
167
+ >
168
+ <ChevronRight
169
+ size={12}
170
+ className={cn('transition-transform duration-100', expanded && 'rotate-90')}
171
+ />
172
+ </button>
173
+ )}
174
+ </div>
175
+ {hasChildren && expanded && (
176
+ <div className="space-y-px">
177
+ {page.pages!.map((child) => (
178
+ <SidebarPageItem
179
+ key={child.path}
180
+ page={child}
181
+ pathname={pathname}
182
+ onNavigate={onNavigate}
183
+ level={level + 1}
184
+ />
126
185
  ))}
127
186
  </div>
128
187
  )}
@@ -1,6 +1,7 @@
1
1
  'use client'
2
2
 
3
3
  import { useEffect, useState } from 'react'
4
+ import { usePathname } from 'next/navigation'
4
5
  import { cn } from '@/lib/utils'
5
6
 
6
7
  interface Heading {
@@ -12,6 +13,7 @@ interface Heading {
12
13
  export function TableOfContents() {
13
14
  const [headings, setHeadings] = useState<Heading[]>([])
14
15
  const [activeId, setActiveId] = useState('')
16
+ const pathname = usePathname()
15
17
 
16
18
  useEffect(() => {
17
19
  const article = document.querySelector('article')
@@ -20,19 +22,33 @@ export function TableOfContents() {
20
22
  const elements = article.querySelectorAll('h2, h3')
21
23
  const items: Heading[] = []
22
24
 
25
+ // Generic headings that repeat in API reference pages — skip them in TOC
26
+ const genericHeadings = new Set([
27
+ 'parameters', 'returns', 'return value', 'return type',
28
+ 'returned validator function', 'requirements',
29
+ 'when results are returned', 'when each value is returned',
30
+ 'validationresult shape',
31
+ ])
32
+
23
33
  elements.forEach((el) => {
24
- const id = el.id || el.textContent?.toLowerCase().replace(/\s+/g, '-') || ''
34
+ const text = el.textContent || ''
35
+ const normalized = text.toLowerCase().trim()
36
+
37
+ // Skip generic/repeated headings
38
+ if (genericHeadings.has(normalized)) return
39
+
40
+ const id = el.id || normalized.replace(/\s+/g, '-')
25
41
  if (!el.id) el.id = id
26
42
 
27
43
  items.push({
28
44
  id,
29
- text: el.textContent || '',
45
+ text,
30
46
  level: parseInt(el.tagName[1]),
31
47
  })
32
48
  })
33
49
 
34
50
  setHeadings(items)
35
- }, [])
51
+ }, [pathname])
36
52
 
37
53
  useEffect(() => {
38
54
  const observer = new IntersectionObserver(
@@ -43,7 +59,7 @@ export function TableOfContents() {
43
59
  }
44
60
  })
45
61
  },
46
- { rootMargin: '-80px 0px -80% 0px' }
62
+ { rootMargin: '-80px 0px -60% 0px' }
47
63
  )
48
64
 
49
65
  headings.forEach(({ id }) => {
@@ -57,13 +73,13 @@ export function TableOfContents() {
57
73
  if (headings.length === 0) return null
58
74
 
59
75
  return (
60
- <nav className="hidden xl:block fixed right-8 top-[calc(var(--header-height)+2rem)] w-[var(--toc-width)] max-h-[calc(100vh-var(--header-height)-4rem)] overflow-y-auto">
76
+ <nav className="hidden lg:block fixed right-8 top-[calc(var(--header-height)+2rem)] w-[var(--toc-width)] max-h-[calc(100vh-var(--header-height)-4rem)] overflow-y-auto">
61
77
  <p className="text-[0.6875rem] font-semibold uppercase tracking-widest text-[var(--color-text-tertiary)] mb-3">
62
78
  On this page
63
79
  </p>
64
80
  <ul className="space-y-0.5 border-l border-[var(--color-border)]">
65
- {headings.map((heading) => (
66
- <li key={heading.id}>
81
+ {headings.map((heading, index) => (
82
+ <li key={`${heading.id}-${index}`}>
67
83
  <a
68
84
  href={`#${heading.id}`}
69
85
  className={cn(
@@ -1,10 +1,22 @@
1
1
  import { createHighlighter, type Highlighter, type BundledTheme } from 'shiki'
2
2
 
3
- let highlighter: Highlighter | null = null
3
+ // Cache highlighter instances per loaded theme set
4
+ const highlighterCache = new Map<string, Highlighter>()
4
5
 
5
- export type ThemeName = 'github-dark' | 'github-light' | 'dracula' | 'nord' | 'one-dark-pro' | 'vitesse-dark' | 'vitesse-light'
6
+ export type ThemeName =
7
+ | 'catppuccin-mocha'
8
+ | 'catppuccin-latte'
9
+ | 'github-dark'
10
+ | 'github-light'
11
+ | 'dracula'
12
+ | 'nord'
13
+ | 'one-dark-pro'
14
+ | 'vitesse-dark'
15
+ | 'vitesse-light'
6
16
 
7
17
  export const AVAILABLE_THEMES: { name: ThemeName; label: string; isDark: boolean }[] = [
18
+ { name: 'catppuccin-mocha', label: 'Catppuccin', isDark: true },
19
+ { name: 'catppuccin-latte', label: 'Catppuccin Light', isDark: false },
8
20
  { name: 'github-dark', label: 'GitHub Dark', isDark: true },
9
21
  { name: 'github-light', label: 'GitHub Light', isDark: false },
10
22
  { name: 'dracula', label: 'Dracula', isDark: true },
@@ -14,34 +26,79 @@ export const AVAILABLE_THEMES: { name: ThemeName; label: string; isDark: boolean
14
26
  { name: 'vitesse-light', label: 'Vitesse Light', isDark: false },
15
27
  ]
16
28
 
17
- export const DEFAULT_THEME: ThemeName = 'github-dark'
29
+ export const DEFAULT_THEME: ThemeName = 'catppuccin-mocha'
18
30
 
19
- export async function getHighlighter(): Promise<Highlighter> {
20
- if (!highlighter) {
21
- highlighter = await createHighlighter({
22
- themes: AVAILABLE_THEMES.map(t => t.name) as BundledTheme[],
23
- langs: [
24
- 'typescript',
25
- 'javascript',
26
- 'python',
27
- 'go',
28
- 'rust',
29
- 'json',
30
- 'yaml',
31
- 'bash',
32
- 'shell',
33
- 'markdown',
34
- 'html',
35
- 'css',
36
- 'jsx',
37
- 'tsx',
38
- 'sql',
39
- 'graphql',
40
- 'diff',
41
- ],
42
- })
31
+ const SUPPORTED_LANGS = [
32
+ 'typescript',
33
+ 'javascript',
34
+ 'python',
35
+ 'go',
36
+ 'rust',
37
+ 'json',
38
+ 'yaml',
39
+ 'bash',
40
+ 'shell',
41
+ 'markdown',
42
+ 'html',
43
+ 'css',
44
+ 'jsx',
45
+ 'tsx',
46
+ 'sql',
47
+ 'graphql',
48
+ 'diff',
49
+ ]
50
+
51
+ // Start with just the default theme; load others on demand
52
+ let defaultHighlighter: Highlighter | null = null
53
+ let defaultHighlighterPromise: Promise<Highlighter> | null = null
54
+
55
+ async function getDefaultHighlighter(): Promise<Highlighter> {
56
+ if (defaultHighlighter) return defaultHighlighter
57
+ if (defaultHighlighterPromise) return defaultHighlighterPromise
58
+
59
+ defaultHighlighterPromise = createHighlighter({
60
+ themes: [DEFAULT_THEME] as BundledTheme[],
61
+ langs: SUPPORTED_LANGS,
62
+ }).then(hl => {
63
+ defaultHighlighter = hl
64
+ highlighterCache.set(DEFAULT_THEME, hl)
65
+ return hl
66
+ })
67
+
68
+ return defaultHighlighterPromise
69
+ }
70
+
71
+ async function getHighlighterForTheme(theme: ThemeName): Promise<Highlighter> {
72
+ if (theme === DEFAULT_THEME || highlighterCache.has(DEFAULT_THEME)) {
73
+ // Try to load the theme into the existing default highlighter
74
+ const hl = await getDefaultHighlighter()
75
+ try {
76
+ // Check if the theme is already loaded by attempting to use it
77
+ const loadedThemes = hl.getLoadedThemes()
78
+ if (!loadedThemes.includes(theme as BundledTheme)) {
79
+ await hl.loadTheme(theme as BundledTheme)
80
+ }
81
+ return hl
82
+ } catch {
83
+ // If loading into existing highlighter fails, create a new one
84
+ }
43
85
  }
44
- return highlighter
86
+
87
+ // Check cache
88
+ const cached = highlighterCache.get(theme)
89
+ if (cached) return cached
90
+
91
+ // Create a new highlighter with this theme
92
+ const hl = await createHighlighter({
93
+ themes: [theme] as BundledTheme[],
94
+ langs: SUPPORTED_LANGS,
95
+ })
96
+ highlighterCache.set(theme, hl)
97
+ return hl
98
+ }
99
+
100
+ export async function getHighlighter(): Promise<Highlighter> {
101
+ return getDefaultHighlighter()
45
102
  }
46
103
 
47
104
  export async function highlight(
@@ -49,9 +106,8 @@ export async function highlight(
49
106
  language: string,
50
107
  theme: ThemeName = DEFAULT_THEME
51
108
  ): Promise<string> {
52
- const hl = await getHighlighter()
109
+ const hl = await getHighlighterForTheme(theme)
53
110
 
54
- // Normalize language names
55
111
  const lang = normalizeLanguage(language)
56
112
 
57
113
  try {
@@ -60,7 +116,6 @@ export async function highlight(
60
116
  theme: theme as BundledTheme,
61
117
  })
62
118
  } catch {
63
- // Fallback to plain text if language not supported
64
119
  return hl.codeToHtml(code, {
65
120
  lang: 'text',
66
121
  theme: theme as BundledTheme,
@@ -68,6 +123,10 @@ export async function highlight(
68
123
  }
69
124
  }
70
125
 
126
+ export function isLightTheme(theme: ThemeName): boolean {
127
+ return AVAILABLE_THEMES.find(t => t.name === theme)?.isDark === false
128
+ }
129
+
71
130
  function normalizeLanguage(lang: string): string {
72
131
  const aliases: Record<string, string> = {
73
132
  js: 'javascript',
@@ -1,4 +1,4 @@
1
- import { create, load, search as oramaSearch } from '@orama/orama'
1
+ import { create, load, search as oramaSearch, type RawData } from '@orama/orama'
2
2
  import type { SearchDatabase, SearchHit } from './search-types'
3
3
 
4
4
  let db: SearchDatabase | null = null
@@ -27,12 +27,22 @@ async function loadSearchIndex(): Promise<void> {
27
27
  try {
28
28
  const response = await fetch('/search-index.json')
29
29
  if (!response.ok) {
30
- throw new Error('Search index not found')
30
+ console.warn('Search index not available (HTTP ' + response.status + '). Search will be disabled.')
31
+ db = null
32
+ return
33
+ }
34
+
35
+ let data: unknown
36
+ try {
37
+ data = await response.json()
38
+ } catch (parseErr) {
39
+ console.error('Search index contains invalid JSON. Search will be disabled.')
40
+ db = null
41
+ return
31
42
  }
32
- const data = await response.json()
33
43
 
34
44
  const newDb = await create({ schema }) as SearchDatabase
35
- await load(newDb, data)
45
+ await load(newDb, data as RawData)
36
46
  db = newDb
37
47
  } catch (err) {
38
48
  const message = err instanceof Error ? err.message : String(err)
@@ -0,0 +1,140 @@
1
+ /**
2
+ * Theme color derivation utilities.
3
+ * Pure functions — no external dependencies.
4
+ */
5
+
6
+ /**
7
+ * Convert a hex color string to HSL components.
8
+ * Accepts 3-digit (#abc) or 6-digit (#aabbcc) hex, with or without #.
9
+ */
10
+ export function hexToHsl(hex: string): { h: number; s: number; l: number } {
11
+ // Strip # prefix if present
12
+ hex = hex.replace(/^#/, '')
13
+
14
+ // Expand 3-digit shorthand to 6-digit
15
+ if (hex.length === 3) {
16
+ hex = hex[0] + hex[0] + hex[1] + hex[1] + hex[2] + hex[2]
17
+ }
18
+
19
+ const r = parseInt(hex.substring(0, 2), 16) / 255
20
+ const g = parseInt(hex.substring(2, 4), 16) / 255
21
+ const b = parseInt(hex.substring(4, 6), 16) / 255
22
+
23
+ const max = Math.max(r, g, b)
24
+ const min = Math.min(r, g, b)
25
+ const l = (max + min) / 2
26
+
27
+ if (max === min) {
28
+ // Achromatic
29
+ return { h: 0, s: 0, l }
30
+ }
31
+
32
+ const d = max - min
33
+ const s = l > 0.5 ? d / (2 - max - min) : d / (max + min)
34
+
35
+ let h: number
36
+ switch (max) {
37
+ case r:
38
+ h = ((g - b) / d + (g < b ? 6 : 0)) / 6
39
+ break
40
+ case g:
41
+ h = ((b - r) / d + 2) / 6
42
+ break
43
+ default:
44
+ h = ((r - g) / d + 4) / 6
45
+ break
46
+ }
47
+
48
+ return { h: h * 360, s, l }
49
+ }
50
+
51
+ /**
52
+ * Convert HSL components back to a hex color string.
53
+ * h: 0-360, s: 0-1, l: 0-1
54
+ */
55
+ export function hslToHex(h: number, s: number, l: number): string {
56
+ // Clamp values
57
+ h = ((h % 360) + 360) % 360
58
+ s = Math.max(0, Math.min(1, s))
59
+ l = Math.max(0, Math.min(1, l))
60
+
61
+ const c = (1 - Math.abs(2 * l - 1)) * s
62
+ const x = c * (1 - Math.abs(((h / 60) % 2) - 1))
63
+ const m = l - c / 2
64
+
65
+ let r: number, g: number, b: number
66
+
67
+ if (h < 60) {
68
+ ;[r, g, b] = [c, x, 0]
69
+ } else if (h < 120) {
70
+ ;[r, g, b] = [x, c, 0]
71
+ } else if (h < 180) {
72
+ ;[r, g, b] = [0, c, x]
73
+ } else if (h < 240) {
74
+ ;[r, g, b] = [0, x, c]
75
+ } else if (h < 300) {
76
+ ;[r, g, b] = [x, 0, c]
77
+ } else {
78
+ ;[r, g, b] = [c, 0, x]
79
+ }
80
+
81
+ const toHex = (v: number) =>
82
+ Math.round((v + m) * 255)
83
+ .toString(16)
84
+ .padStart(2, '0')
85
+
86
+ return `#${toHex(r)}${toHex(g)}${toHex(b)}`
87
+ }
88
+
89
+ /**
90
+ * Derive primary, dark, and light variants from a single hex color.
91
+ * Dark variant: lightness reduced by ~15%
92
+ * Light variant: lightness increased by ~15%
93
+ */
94
+ /**
95
+ * Compute WCAG relative luminance from a hex color.
96
+ * Returns 0 (black) to 1 (white).
97
+ */
98
+ export function relativeLuminance(hex: string): number {
99
+ hex = hex.replace(/^#/, '')
100
+ if (hex.length === 3) {
101
+ hex = hex[0] + hex[0] + hex[1] + hex[1] + hex[2] + hex[2]
102
+ }
103
+ const r = parseInt(hex.substring(0, 2), 16) / 255
104
+ const g = parseInt(hex.substring(2, 4), 16) / 255
105
+ const b = parseInt(hex.substring(4, 6), 16) / 255
106
+
107
+ const toLinear = (c: number) => (c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4))
108
+ return 0.2126 * toLinear(r) + 0.7152 * toLinear(g) + 0.0722 * toLinear(b)
109
+ }
110
+
111
+ /**
112
+ * Return the best contrasting foreground color (white or dark) for a given background.
113
+ * Uses WCAG contrast ratio.
114
+ */
115
+ export function contrastForeground(bgHex: string): string {
116
+ const lum = relativeLuminance(bgHex)
117
+ // WCAG contrast ratio: (L1 + 0.05) / (L2 + 0.05)
118
+ // White on bg: (1 + 0.05) / (lum + 0.05)
119
+ // Dark on bg: (lum + 0.05) / (0 + 0.05)
120
+ const contrastWithWhite = (1.05) / (lum + 0.05)
121
+ const contrastWithBlack = (lum + 0.05) / 0.05
122
+ return contrastWithWhite >= contrastWithBlack ? '#ffffff' : '#0f1117'
123
+ }
124
+
125
+ export function derivePrimaryColors(hex: string): {
126
+ primary: string
127
+ primaryDark: string
128
+ primaryLight: string
129
+ primaryForeground: string
130
+ } {
131
+ const { h, s, l } = hexToHsl(hex)
132
+ const normalizedHex = hex.startsWith('#') ? hex : `#${hex}`
133
+
134
+ return {
135
+ primary: normalizedHex,
136
+ primaryDark: hslToHex(h, s, Math.max(0, l - 0.15)),
137
+ primaryLight: hslToHex(h, s, Math.min(1, l + 0.15)),
138
+ primaryForeground: contrastForeground(normalizedHex),
139
+ }
140
+ }