forked from
npmx.dev/npmx.dev
[READ-ONLY]
a fast, modern browser for the npm registry
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}