skrypt-ai 0.4.1 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/dist/auth/index.d.ts +13 -3
  2. package/dist/auth/index.js +94 -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.js +10 -3
  8. package/dist/cli.js +16 -3
  9. package/dist/commands/generate.js +37 -1
  10. package/dist/commands/import.d.ts +2 -0
  11. package/dist/commands/import.js +157 -0
  12. package/dist/commands/init.js +19 -7
  13. package/dist/commands/login.js +15 -4
  14. package/dist/commands/review-pr.js +10 -0
  15. package/dist/commands/security.d.ts +2 -0
  16. package/dist/commands/security.js +103 -0
  17. package/dist/config/loader.js +2 -2
  18. package/dist/generator/writer.js +12 -3
  19. package/dist/importers/confluence.d.ts +5 -0
  20. package/dist/importers/confluence.js +137 -0
  21. package/dist/importers/detect.d.ts +20 -0
  22. package/dist/importers/detect.js +121 -0
  23. package/dist/importers/docusaurus.d.ts +5 -0
  24. package/dist/importers/docusaurus.js +279 -0
  25. package/dist/importers/gitbook.d.ts +5 -0
  26. package/dist/importers/gitbook.js +189 -0
  27. package/dist/importers/github.d.ts +8 -0
  28. package/dist/importers/github.js +99 -0
  29. package/dist/importers/index.d.ts +15 -0
  30. package/dist/importers/index.js +30 -0
  31. package/dist/importers/markdown.d.ts +6 -0
  32. package/dist/importers/markdown.js +105 -0
  33. package/dist/importers/mintlify.d.ts +5 -0
  34. package/dist/importers/mintlify.js +172 -0
  35. package/dist/importers/notion.d.ts +5 -0
  36. package/dist/importers/notion.js +174 -0
  37. package/dist/importers/readme.d.ts +5 -0
  38. package/dist/importers/readme.js +184 -0
  39. package/dist/importers/transform.d.ts +90 -0
  40. package/dist/importers/transform.js +457 -0
  41. package/dist/importers/types.d.ts +37 -0
  42. package/dist/importers/types.js +1 -0
  43. package/dist/plugins/index.js +7 -0
  44. package/dist/scanner/index.js +37 -24
  45. package/dist/scanner/python.js +17 -0
  46. package/dist/template/public/search-index.json +1 -1
  47. package/dist/template/scripts/build-search-index.mjs +67 -9
  48. package/dist/template/src/components/mdx/dark-image.tsx +56 -0
  49. package/dist/template/src/components/mdx/frame.tsx +64 -0
  50. package/dist/template/src/components/mdx/highlighted-code.tsx +145 -31
  51. package/dist/template/src/components/mdx/index.tsx +4 -0
  52. package/dist/template/src/components/mdx/link-preview.tsx +119 -0
  53. package/dist/template/src/components/mdx/tooltip.tsx +101 -0
  54. package/dist/template/src/components/syntax-theme-selector.tsx +167 -20
  55. package/dist/template/src/lib/search-types.ts +4 -1
  56. package/dist/template/src/lib/search.ts +30 -7
  57. package/dist/template/src/styles/globals.css +39 -0
  58. package/dist/utils/files.d.ts +9 -1
  59. package/dist/utils/files.js +59 -10
  60. package/dist/utils/validation.js +1 -1
  61. package/package.json +4 -1
@@ -0,0 +1,101 @@
1
+ 'use client'
2
+
3
+ import { useState, useRef, useCallback, type ReactNode } from 'react'
4
+
5
+ interface TooltipProps {
6
+ children: ReactNode
7
+ content: string
8
+ side?: 'top' | 'bottom' | 'left' | 'right'
9
+ }
10
+
11
+ const arrowStyles: Record<string, React.CSSProperties> = {
12
+ top: {
13
+ bottom: -4,
14
+ left: '50%',
15
+ transform: 'translateX(-50%) rotate(45deg)',
16
+ },
17
+ bottom: {
18
+ top: -4,
19
+ left: '50%',
20
+ transform: 'translateX(-50%) rotate(45deg)',
21
+ },
22
+ left: {
23
+ right: -4,
24
+ top: '50%',
25
+ transform: 'translateY(-50%) rotate(45deg)',
26
+ },
27
+ right: {
28
+ left: -4,
29
+ top: '50%',
30
+ transform: 'translateY(-50%) rotate(45deg)',
31
+ },
32
+ }
33
+
34
+ const positionStyles: Record<string, React.CSSProperties> = {
35
+ top: {
36
+ bottom: '100%',
37
+ left: '50%',
38
+ transform: 'translateX(-50%)',
39
+ marginBottom: 8,
40
+ },
41
+ bottom: {
42
+ top: '100%',
43
+ left: '50%',
44
+ transform: 'translateX(-50%)',
45
+ marginTop: 8,
46
+ },
47
+ left: {
48
+ right: '100%',
49
+ top: '50%',
50
+ transform: 'translateY(-50%)',
51
+ marginRight: 8,
52
+ },
53
+ right: {
54
+ left: '100%',
55
+ top: '50%',
56
+ transform: 'translateY(-50%)',
57
+ marginLeft: 8,
58
+ },
59
+ }
60
+
61
+ export function Tooltip({ children, content, side = 'top' }: TooltipProps) {
62
+ const [visible, setVisible] = useState(false)
63
+ const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
64
+
65
+ const show = useCallback(() => {
66
+ timeoutRef.current = setTimeout(() => setVisible(true), 150)
67
+ }, [])
68
+
69
+ const hide = useCallback(() => {
70
+ if (timeoutRef.current) {
71
+ clearTimeout(timeoutRef.current)
72
+ timeoutRef.current = null
73
+ }
74
+ setVisible(false)
75
+ }, [])
76
+
77
+ return (
78
+ <span
79
+ className="relative inline-flex"
80
+ onMouseEnter={show}
81
+ onMouseLeave={hide}
82
+ onFocus={show}
83
+ onBlur={hide}
84
+ >
85
+ {children}
86
+ {visible && (
87
+ <span
88
+ role="tooltip"
89
+ className="tooltip-content absolute z-50 whitespace-nowrap bg-[var(--color-gray-900)] text-white text-xs px-2.5 py-1.5 rounded-lg shadow-lg tooltip-fade-in pointer-events-none"
90
+ style={positionStyles[side]}
91
+ >
92
+ {content}
93
+ <span
94
+ className="tooltip-content absolute h-2 w-2 bg-[var(--color-gray-900)]"
95
+ style={arrowStyles[side]}
96
+ />
97
+ </span>
98
+ )}
99
+ </span>
100
+ )
101
+ }
@@ -1,49 +1,196 @@
1
1
  'use client'
2
2
 
3
- import { useContext } from 'react'
3
+ import { useContext, useState, useRef, useEffect, useCallback, KeyboardEvent } from 'react'
4
4
  import { SyntaxThemeContext } from '@/contexts/syntax-theme'
5
5
  import { AVAILABLE_THEMES, DEFAULT_THEME } from '@/lib/highlight'
6
6
  import { Palette } from 'lucide-react'
7
7
 
8
8
  export function SyntaxThemeSelector() {
9
9
  const context = useContext(SyntaxThemeContext)
10
+ const [isOpen, setIsOpen] = useState(false)
11
+ const [activeIndex, setActiveIndex] = useState(-1)
12
+ const triggerRef = useRef<HTMLButtonElement>(null)
13
+ const listRef = useRef<HTMLDivElement>(null)
14
+ const optionRefs = useRef<(HTMLButtonElement | null)[]>([])
15
+ const closeTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
10
16
 
11
- // Safely handle when context isn't available (during SSG)
12
17
  const theme = context?.theme ?? DEFAULT_THEME
13
18
  const setTheme = context?.setTheme ?? (() => {})
14
19
  const availableThemes = context?.availableThemes ?? AVAILABLE_THEMES
15
20
 
21
+ // Find the index of the current active theme
22
+ const activeThemeIndex = availableThemes.findIndex((t) => t.name === theme)
23
+
24
+ const open = useCallback(() => {
25
+ if (closeTimeoutRef.current) {
26
+ clearTimeout(closeTimeoutRef.current)
27
+ closeTimeoutRef.current = null
28
+ }
29
+ setIsOpen(true)
30
+ setActiveIndex(activeThemeIndex >= 0 ? activeThemeIndex : 0)
31
+ }, [activeThemeIndex])
32
+
33
+ const close = useCallback(() => {
34
+ setIsOpen(false)
35
+ setActiveIndex(-1)
36
+ }, [])
37
+
38
+ const delayedClose = useCallback(() => {
39
+ closeTimeoutRef.current = setTimeout(close, 150)
40
+ }, [close])
41
+
42
+ const cancelClose = useCallback(() => {
43
+ if (closeTimeoutRef.current) {
44
+ clearTimeout(closeTimeoutRef.current)
45
+ closeTimeoutRef.current = null
46
+ }
47
+ }, [])
48
+
49
+ // Focus the active option when activeIndex changes while open
50
+ useEffect(() => {
51
+ if (isOpen && activeIndex >= 0) {
52
+ optionRefs.current[activeIndex]?.focus()
53
+ }
54
+ }, [isOpen, activeIndex])
55
+
56
+ // Close on outside click
57
+ useEffect(() => {
58
+ if (!isOpen) return
59
+ function handleClick(e: MouseEvent) {
60
+ const target = e.target as Node
61
+ if (
62
+ !triggerRef.current?.contains(target) &&
63
+ !listRef.current?.contains(target)
64
+ ) {
65
+ close()
66
+ }
67
+ }
68
+ document.addEventListener('mousedown', handleClick)
69
+ return () => document.removeEventListener('mousedown', handleClick)
70
+ }, [isOpen, close])
71
+
72
+ // Close on Escape
73
+ useEffect(() => {
74
+ if (!isOpen) return
75
+ function handleEsc(e: globalThis.KeyboardEvent) {
76
+ if (e.key === 'Escape') {
77
+ close()
78
+ triggerRef.current?.focus()
79
+ }
80
+ }
81
+ document.addEventListener('keydown', handleEsc)
82
+ return () => document.removeEventListener('keydown', handleEsc)
83
+ }, [isOpen, close])
84
+
85
+ function selectTheme(index: number) {
86
+ const t = availableThemes[index]
87
+ if (t) {
88
+ setTheme(t.name)
89
+ close()
90
+ triggerRef.current?.focus()
91
+ }
92
+ }
93
+
94
+ function handleTriggerKeyDown(e: KeyboardEvent) {
95
+ if (e.key === 'ArrowDown' || e.key === 'Enter' || e.key === ' ') {
96
+ e.preventDefault()
97
+ open()
98
+ } else if (e.key === 'ArrowUp') {
99
+ e.preventDefault()
100
+ open()
101
+ setActiveIndex(availableThemes.length - 1)
102
+ }
103
+ }
104
+
105
+ function handleOptionKeyDown(e: KeyboardEvent, index: number) {
106
+ switch (e.key) {
107
+ case 'ArrowDown':
108
+ e.preventDefault()
109
+ setActiveIndex((index + 1) % availableThemes.length)
110
+ break
111
+ case 'ArrowUp':
112
+ e.preventDefault()
113
+ setActiveIndex((index - 1 + availableThemes.length) % availableThemes.length)
114
+ break
115
+ case 'Home':
116
+ e.preventDefault()
117
+ setActiveIndex(0)
118
+ break
119
+ case 'End':
120
+ e.preventDefault()
121
+ setActiveIndex(availableThemes.length - 1)
122
+ break
123
+ case 'Enter':
124
+ case ' ':
125
+ e.preventDefault()
126
+ selectTheme(index)
127
+ break
128
+ case 'Tab':
129
+ close()
130
+ break
131
+ }
132
+ }
133
+
134
+ const listboxId = 'syntax-theme-listbox'
135
+
16
136
  return (
17
- <div className="relative group">
137
+ <div
138
+ className="relative"
139
+ onMouseEnter={() => { open(); cancelClose() }}
140
+ onMouseLeave={delayedClose}
141
+ >
18
142
  <button
143
+ ref={triggerRef}
19
144
  className="flex items-center gap-2 px-3 py-1.5 text-sm text-[var(--color-text-secondary)] hover:text-[var(--color-text)] rounded-md hover:bg-[var(--color-bg-secondary)] transition-colors"
20
145
  title="Syntax theme"
21
146
  aria-label={`Syntax theme: ${theme}`}
22
147
  aria-haspopup="listbox"
148
+ aria-expanded={isOpen}
149
+ aria-controls={isOpen ? listboxId : undefined}
150
+ onClick={() => { isOpen ? close() : open() }}
151
+ onKeyDown={handleTriggerKeyDown}
23
152
  >
24
153
  <Palette size={16} />
25
154
  <span className="hidden sm:inline">Theme</span>
26
155
  </button>
27
156
 
28
- <div className="absolute right-0 top-full mt-1 w-48 py-1 bg-[var(--color-bg)] border border-[var(--color-border)] rounded-lg shadow-lg opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all z-50">
29
- <div className="px-3 py-1.5 text-xs font-medium text-[var(--color-text-tertiary)] uppercase tracking-wide">
157
+ <div
158
+ ref={listRef}
159
+ id={listboxId}
160
+ role="listbox"
161
+ aria-label="Syntax theme"
162
+ aria-activedescendant={activeIndex >= 0 ? `syntax-theme-option-${activeIndex}` : undefined}
163
+ className={`absolute right-0 top-full mt-1 w-48 py-1 bg-[var(--color-bg)] border border-[var(--color-border)] rounded-lg shadow-lg transition-all z-50 ${
164
+ isOpen ? 'opacity-100 visible' : 'opacity-0 invisible pointer-events-none'
165
+ }`}
166
+ >
167
+ <div className="px-3 py-1.5 text-xs font-medium text-[var(--color-text-tertiary)] uppercase tracking-wide" aria-hidden="true">
30
168
  Syntax Theme
31
169
  </div>
32
- {availableThemes.map((t) => (
33
- <button
34
- key={t.name}
35
- onClick={() => setTheme(t.name)}
36
- className={`w-full px-3 py-1.5 text-left text-sm flex items-center gap-2 hover:bg-[var(--color-bg-secondary)] transition-colors ${
37
- theme === t.name ? 'text-[var(--color-primary)]' : 'text-[var(--color-text)]'
38
- }`}
39
- >
40
- <span
41
- className={`w-3 h-3 rounded-full ${t.isDark ? 'bg-gray-800' : 'bg-gray-200'}`}
42
- />
43
- {t.label}
44
- {theme === t.name && <span className="ml-auto text-xs">Active</span>}
45
- </button>
46
- ))}
170
+ {availableThemes.map((t, index) => {
171
+ const isSelected = theme === t.name
172
+ return (
173
+ <button
174
+ key={t.name}
175
+ ref={(el) => { optionRefs.current[index] = el }}
176
+ id={`syntax-theme-option-${index}`}
177
+ role="option"
178
+ aria-selected={isSelected}
179
+ tabIndex={-1}
180
+ onClick={() => selectTheme(index)}
181
+ onKeyDown={(e) => handleOptionKeyDown(e, index)}
182
+ className={`w-full px-3 py-1.5 text-left text-sm flex items-center gap-2 hover:bg-[var(--color-bg-secondary)] transition-colors ${
183
+ isSelected ? 'text-[var(--color-primary)]' : 'text-[var(--color-text)]'
184
+ } ${activeIndex === index ? 'bg-[var(--color-bg-secondary)]' : ''}`}
185
+ >
186
+ <span
187
+ className={`w-3 h-3 rounded-full ${t.isDark ? 'bg-gray-800' : 'bg-gray-200'}`}
188
+ />
189
+ {t.label}
190
+ {isSelected && <span className="ml-auto text-xs">Active</span>}
191
+ </button>
192
+ )
193
+ })}
47
194
  </div>
48
195
  </div>
49
196
  )
@@ -1,6 +1,5 @@
1
1
  /**
2
2
  * Type definitions for Orama search database
3
- * Fixes P0: Removes `any` types from search functionality
4
3
  */
5
4
 
6
5
  import type { Orama, Results, SearchParams } from '@orama/orama'
@@ -9,6 +8,8 @@ import type { Orama, Results, SearchParams } from '@orama/orama'
9
8
  export interface SearchDocument {
10
9
  id: string
11
10
  title: string
11
+ headings: string
12
+ keywords: string
12
13
  content: string
13
14
  href: string
14
15
  section: string
@@ -18,6 +19,8 @@ export interface SearchDocument {
18
19
  export type SearchDatabase = Orama<{
19
20
  id: 'string'
20
21
  title: 'string'
22
+ headings: 'string'
23
+ keywords: 'string'
21
24
  content: 'string'
22
25
  href: 'string'
23
26
  section: 'string'
@@ -7,6 +7,8 @@ let loadPromise: Promise<void> | null = null
7
7
  const schema = {
8
8
  id: 'string' as const,
9
9
  title: 'string' as const,
10
+ headings: 'string' as const,
11
+ keywords: 'string' as const,
10
12
  content: 'string' as const,
11
13
  href: 'string' as const,
12
14
  section: 'string' as const,
@@ -41,7 +43,15 @@ async function loadSearchIndex(): Promise<void> {
41
43
  return
42
44
  }
43
45
 
44
- const newDb = await create({ schema }) as SearchDatabase
46
+ const newDb = await create({
47
+ schema,
48
+ components: {
49
+ tokenizer: {
50
+ stemming: true,
51
+ language: 'english',
52
+ },
53
+ },
54
+ }) as SearchDatabase
45
55
  await load(newDb, data as RawData)
46
56
  db = newDb
47
57
  } catch (err) {
@@ -55,10 +65,21 @@ async function loadSearchIndex(): Promise<void> {
55
65
  }
56
66
 
57
67
  /**
58
- * Generate a snippet with highlighted search terms
68
+ * Generate a snippet with highlighted search terms, preferring heading matches
59
69
  */
60
- function generateSnippet(content: string, query: string, maxLength: number = 150): string {
70
+ function generateSnippet(content: string, headings: string, query: string, maxLength: number = 150): string {
61
71
  const terms = query.toLowerCase().split(/\s+/).filter(Boolean)
72
+
73
+ // Check if any headings match — show that context first
74
+ if (headings) {
75
+ const headingList = headings.split(' | ')
76
+ for (const heading of headingList) {
77
+ if (terms.some(term => heading.toLowerCase().includes(term))) {
78
+ return heading
79
+ }
80
+ }
81
+ }
82
+
62
83
  const contentLower = content.toLowerCase()
63
84
 
64
85
  // Find the best position to start the snippet (where most terms match)
@@ -110,11 +131,13 @@ export async function search(query: string): Promise<SearchResultWithHighlight[]
110
131
  try {
111
132
  const results = await oramaSearch(db, {
112
133
  term: query,
113
- properties: ['title', 'content'],
134
+ properties: ['title', 'headings', 'keywords', 'content'],
114
135
  limit: 10,
115
- tolerance: 1,
136
+ tolerance: 2,
116
137
  boost: {
117
- title: 2,
138
+ title: 5,
139
+ headings: 3,
140
+ keywords: 2,
118
141
  content: 1,
119
142
  },
120
143
  })
@@ -124,7 +147,7 @@ export async function search(query: string): Promise<SearchResultWithHighlight[]
124
147
  href: hit.document.href,
125
148
  content: hit.document.content,
126
149
  section: hit.document.section || undefined,
127
- snippet: generateSnippet(hit.document.content, query),
150
+ snippet: generateSnippet(hit.document.content, hit.document.headings, query),
128
151
  score: hit.score,
129
152
  }))
130
153
  } catch (err) {
@@ -121,6 +121,12 @@
121
121
  :root:not(.light) .feedback-negative { background-color: rgba(127, 29, 29, 0.3); color: #f87171; }
122
122
  }
123
123
 
124
+ /* Tooltip — dark mode overrides */
125
+ :root.dark .tooltip-content { background-color: var(--color-gray-100); color: var(--color-gray-900); }
126
+ @media (prefers-color-scheme: dark) {
127
+ :root:not(.light) .tooltip-content { background-color: var(--color-gray-100); color: var(--color-gray-900); }
128
+ }
129
+
124
130
  /* ========================
125
131
  Base — in @layer base so Tailwind utilities can override
126
132
  ======================== */
@@ -463,3 +469,36 @@ input:focus-visible, textarea:focus-visible {
463
469
  animation: shimmer 1.5s infinite;
464
470
  border-radius: 0.25rem;
465
471
  }
472
+
473
+ /* ========================
474
+ Tooltip fade-in
475
+ ======================== */
476
+ @keyframes tooltip-fade-in {
477
+ from { opacity: 0; }
478
+ to { opacity: 1; }
479
+ }
480
+ .tooltip-fade-in {
481
+ animation: tooltip-fade-in 150ms ease-out;
482
+ }
483
+
484
+ /* ========================
485
+ Page transitions — subtle fade-in for article content
486
+ ======================== */
487
+ @keyframes page-fade-in {
488
+ from { opacity: 0; transform: translateY(4px); }
489
+ to { opacity: 1; transform: translateY(0); }
490
+ }
491
+ .prose {
492
+ animation: page-fade-in 200ms ease-out;
493
+ }
494
+
495
+ /* ========================
496
+ Link preview card
497
+ ======================== */
498
+ @keyframes link-preview-in {
499
+ from { opacity: 0; transform: translate(-50%, -100%) translateY(4px); }
500
+ to { opacity: 1; transform: translate(-50%, -100%) translateY(0); }
501
+ }
502
+ .link-preview-card {
503
+ animation: link-preview-in 150ms ease-out;
504
+ }
@@ -1,9 +1,17 @@
1
1
  /**
2
2
  * Recursively find all .md and .mdx files in a directory.
3
- * Skips hidden directories and node_modules.
3
+ * Skips hidden directories, node_modules, and symlinks.
4
4
  */
5
5
  export declare function findMdxFiles(dir: string): string[];
6
6
  /**
7
7
  * Convert string to URL-safe slug
8
8
  */
9
9
  export declare function slugify(str: string): string;
10
+ /**
11
+ * Simple YAML frontmatter parser — splits on --- markers.
12
+ * Returns parsed key-value data and remaining content body.
13
+ */
14
+ export declare function parseFrontmatter(content: string): {
15
+ data: Record<string, unknown>;
16
+ content: string;
17
+ };
@@ -1,25 +1,41 @@
1
- import { readdirSync, statSync } from 'fs';
1
+ import { readdirSync, statSync, lstatSync } from 'fs';
2
2
  import { join, extname } from 'path';
3
3
  /**
4
4
  * Recursively find all .md and .mdx files in a directory.
5
- * Skips hidden directories and node_modules.
5
+ * Skips hidden directories, node_modules, and symlinks.
6
6
  */
7
7
  export function findMdxFiles(dir) {
8
8
  const files = [];
9
- function walk(currentDir) {
10
- const entries = readdirSync(currentDir);
9
+ function walk(currentDir, depth) {
10
+ if (depth > 30)
11
+ return;
12
+ let entries;
13
+ try {
14
+ entries = readdirSync(currentDir);
15
+ }
16
+ catch {
17
+ return;
18
+ }
11
19
  for (const entry of entries) {
12
20
  const fullPath = join(currentDir, entry);
13
- const stat = statSync(fullPath);
14
- if (stat.isDirectory() && !entry.startsWith('.') && entry !== 'node_modules') {
15
- walk(fullPath);
21
+ try {
22
+ // Skip symlinks to prevent infinite loops
23
+ if (lstatSync(fullPath).isSymbolicLink())
24
+ continue;
25
+ const stat = statSync(fullPath);
26
+ if (stat.isDirectory() && !entry.startsWith('.') && entry !== 'node_modules') {
27
+ walk(fullPath, depth + 1);
28
+ }
29
+ else if (stat.isFile() && (extname(entry) === '.mdx' || extname(entry) === '.md')) {
30
+ files.push(fullPath);
31
+ }
16
32
  }
17
- else if (stat.isFile() && (extname(entry) === '.mdx' || extname(entry) === '.md')) {
18
- files.push(fullPath);
33
+ catch {
34
+ continue;
19
35
  }
20
36
  }
21
37
  }
22
- walk(dir);
38
+ walk(dir, 0);
23
39
  return files;
24
40
  }
25
41
  /**
@@ -31,3 +47,36 @@ export function slugify(str) {
31
47
  .replace(/[^a-z0-9]+/g, '-')
32
48
  .replace(/^-|-$/g, '');
33
49
  }
50
+ /**
51
+ * Simple YAML frontmatter parser — splits on --- markers.
52
+ * Returns parsed key-value data and remaining content body.
53
+ */
54
+ export function parseFrontmatter(content) {
55
+ const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/);
56
+ if (!match)
57
+ return { data: {}, content };
58
+ const yamlStr = match[1];
59
+ const body = match[2];
60
+ const data = {};
61
+ for (const line of yamlStr.split('\n')) {
62
+ const kvMatch = line.match(/^(\w[\w-]*)\s*:\s*(.*)$/);
63
+ if (!kvMatch)
64
+ continue;
65
+ const key = kvMatch[1];
66
+ let value = kvMatch[2].trim();
67
+ if (typeof value === 'string') {
68
+ if ((value.startsWith('"') && value.endsWith('"')) ||
69
+ (value.startsWith("'") && value.endsWith("'"))) {
70
+ value = value.slice(1, -1);
71
+ }
72
+ else if (value === 'true')
73
+ value = true;
74
+ else if (value === 'false')
75
+ value = false;
76
+ else if (/^\d+$/.test(value))
77
+ value = parseInt(value, 10);
78
+ }
79
+ data[key] = value;
80
+ }
81
+ return { data, content: body };
82
+ }
@@ -31,7 +31,7 @@ export function validateSlug(input) {
31
31
  }
32
32
  export function sanitizeForShell(input) {
33
33
  // Only allow safe characters for git refs, filenames, etc.
34
- if (!/^[a-zA-Z0-9\/_~.^@{}\-]+$/.test(input)) {
34
+ if (!/^[a-zA-Z0-9/_~.^@{}-]+$/.test(input)) {
35
35
  throw new Error(`Unsafe characters in input: ${input}`);
36
36
  }
37
37
  return input;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "skrypt-ai",
3
- "version": "0.4.1",
3
+ "version": "0.5.0",
4
4
  "description": "AI-powered documentation generator with code examples",
5
5
  "type": "module",
6
6
  "main": "dist/cli.js",
@@ -54,6 +54,9 @@
54
54
  "openai": "^6.27.0",
55
55
  "typescript": "^5.9.3"
56
56
  },
57
+ "optionalDependencies": {
58
+ "@napi-rs/keyring": "^1.1.6"
59
+ },
57
60
  "devDependencies": {
58
61
  "@eslint/js": "^10.0.1",
59
62
  "@types/js-yaml": "^4.0.9",