[READ-ONLY] a fast, modern browser for the npm registry
at main 236 lines 7.2 kB view raw
1import type { 2 PackageFileTree, 3 SkillFileCounts, 4 SkillFrontmatter, 5 SkillListItem, 6 SkillWarning, 7} from '#shared/types' 8 9const MAX_SKILL_FILE_SIZE = 500 * 1024 10 11/** 12 * Parse YAML frontmatter from SKILL.md content. 13 * Returns { frontmatter, content } where content is the markdown body without frontmatter. 14 */ 15export function parseFrontmatter(raw: string): { frontmatter: SkillFrontmatter; content: string } { 16 const match = raw.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/) 17 if (!match) { 18 throw createError({ statusCode: 400, message: 'Invalid SKILL.md: missing YAML frontmatter' }) 19 } 20 21 const yamlBlock = match[1]! 22 const content = match[2]! 23 24 const frontmatter: Record<string, string | Record<string, string>> = {} 25 let currentKey = '' 26 let inMetadata = false 27 const metadata: Record<string, string> = {} 28 29 for (const line of yamlBlock.split('\n')) { 30 const trimmed = line.trim() 31 if (!trimmed || trimmed.startsWith('#')) continue 32 33 if (line.startsWith(' ') && inMetadata) { 34 const [key, ...valueParts] = trimmed.split(':') 35 if (key && valueParts.length) { 36 metadata[key.trim()] = valueParts 37 .join(':') 38 .trim() 39 .replace(/^["']|["']$/g, '') 40 } 41 } else { 42 const colonIndex = line.indexOf(':') 43 if (colonIndex !== -1) { 44 currentKey = line.slice(0, colonIndex).trim() 45 const value = line.slice(colonIndex + 1).trim() 46 inMetadata = currentKey === 'metadata' && !value 47 if (!inMetadata && value) { 48 frontmatter[currentKey] = value.replace(/^["']|["']$/g, '') 49 } 50 } 51 } 52 } 53 54 if (Object.keys(metadata).length > 0) { 55 frontmatter.metadata = metadata 56 } 57 58 if (!frontmatter.name || !frontmatter.description) { 59 throw createError({ 60 statusCode: 400, 61 message: 'Invalid SKILL.md: missing required name or description', 62 }) 63 } 64 65 return { frontmatter: frontmatter as unknown as SkillFrontmatter, content } 66} 67 68export interface SkillDirInfo { 69 name: string 70 children: PackageFileTree[] 71} 72 73/** 74 * Find skill directories in a package file tree. 75 * Returns skill names and their children for file counting. 76 * @public 77 */ 78export function findSkillDirs(tree: PackageFileTree[]): SkillDirInfo[] { 79 const skillsDir = tree.find(node => node.type === 'directory' && node.name === 'skills') 80 if (!skillsDir?.children) return [] 81 82 return skillsDir.children 83 .filter( 84 child => 85 child.type === 'directory' && 86 child.children?.some(f => f.type === 'file' && f.name === 'SKILL.md'), 87 ) 88 .map(child => ({ name: child.name, children: child.children || [] })) 89} 90 91const countFilesRecursive = (nodes: PackageFileTree[]): number => 92 nodes.reduce((acc, n) => acc + (n.type === 'file' ? 1 : countFilesRecursive(n.children || [])), 0) 93 94/** 95 * Count files in skill subdirectories (scripts, references, assets). 96 */ 97export function countSkillFiles(children: PackageFileTree[]): SkillFileCounts | undefined { 98 const counts: SkillFileCounts = {} 99 100 for (const child of children) { 101 if (child.type !== 'directory') continue 102 const name = child.name.toLowerCase() 103 const count = countFilesRecursive(child.children || []) 104 if (count === 0) continue 105 if (name === 'scripts') counts.scripts = count 106 else if (name === 'references' || name === 'refs') 107 counts.references = (counts.references || 0) + count 108 else if (name === 'assets') counts.assets = count 109 } 110 return Object.keys(counts).length ? counts : undefined 111} 112 113/** 114 * Fetch file content from jsDelivr CDN with size limit. 115 */ 116export async function fetchSkillFile( 117 packageName: string, 118 version: string, 119 filePath: string, 120): Promise<string> { 121 const url = `https://cdn.jsdelivr.net/npm/${packageName}@${version}/${filePath}` 122 const response = await fetch(url) 123 124 if (!response.ok) { 125 if (response.status === 404) { 126 throw createError({ statusCode: 404, message: 'File not found' }) 127 } 128 throw createError({ statusCode: 502, message: 'Failed to fetch file from jsDelivr' }) 129 } 130 131 const contentLength = response.headers.get('content-length') 132 if (contentLength && parseInt(contentLength, 10) > MAX_SKILL_FILE_SIZE) { 133 throw createError({ 134 statusCode: 413, 135 message: `File too large (${(parseInt(contentLength, 10) / 1024 / 1024).toFixed(1)}MB). Maximum size is ${MAX_SKILL_FILE_SIZE / 1024}KB.`, 136 }) 137 } 138 139 const content = await response.text() 140 141 if (content.length > MAX_SKILL_FILE_SIZE) { 142 throw createError({ 143 statusCode: 413, 144 message: `File too large (${(content.length / 1024 / 1024).toFixed(1)}MB). Maximum size is ${MAX_SKILL_FILE_SIZE / 1024}KB.`, 145 }) 146 } 147 148 return content 149} 150 151/** 152 * Fetch and parse SKILL.md content for a skill. 153 */ 154export async function fetchSkillContent( 155 packageName: string, 156 version: string, 157 skillName: string, 158): Promise<{ frontmatter: SkillFrontmatter; content: string }> { 159 const raw = await fetchSkillFile(packageName, version, `skills/${skillName}/SKILL.md`) 160 return parseFrontmatter(raw) 161} 162 163/** 164 * Validate skill frontmatter and return warnings. 165 */ 166export function validateSkill(frontmatter: SkillFrontmatter): SkillWarning[] { 167 const warnings: SkillWarning[] = [] 168 if (!frontmatter.license) { 169 warnings.push({ type: 'warning', message: 'No license specified' }) 170 } 171 if (!frontmatter.compatibility) { 172 warnings.push({ type: 'warning', message: 'No compatibility info' }) 173 } 174 return warnings 175} 176 177/** 178 * Fetch skill list with frontmatter for discovery endpoint. 179 * @public 180 */ 181export async function fetchSkillsList( 182 packageName: string, 183 version: string, 184 skillDirs: SkillDirInfo[], 185): Promise<SkillListItem[]> { 186 const skills = await Promise.all( 187 skillDirs.map(async ({ name: dirName, children }) => { 188 try { 189 const { frontmatter } = await fetchSkillContent(packageName, version, dirName) 190 const warnings = validateSkill(frontmatter) 191 const fileCounts = countSkillFiles(children) 192 const item: SkillListItem = { 193 name: frontmatter.name, 194 description: frontmatter.description, 195 dirName, 196 license: frontmatter.license, 197 compatibility: frontmatter.compatibility, 198 warnings: warnings.length > 0 ? warnings : undefined, 199 fileCounts, 200 } 201 return item 202 } catch { 203 return null 204 } 205 }), 206 ) 207 return skills.filter((s): s is SkillListItem => s !== null) 208} 209 210export interface WellKnownSkillItem { 211 name: string 212 description: string 213 files: string[] 214} 215 216/** 217 * Fetch skill list for well-known index.json format (CLI compatibility). 218 * @public 219 */ 220export async function fetchSkillsListForWellKnown( 221 packageName: string, 222 version: string, 223 skillNames: string[], 224): Promise<WellKnownSkillItem[]> { 225 const skills = await Promise.all( 226 skillNames.map(async dirName => { 227 try { 228 const { frontmatter } = await fetchSkillContent(packageName, version, dirName) 229 return { name: dirName, description: frontmatter.description, files: ['SKILL.md'] } 230 } catch { 231 return null 232 } 233 }), 234 ) 235 return skills.filter((s): s is WellKnownSkillItem => s !== null) 236}