Skip to content

Commit ef29e91

Browse files
committed
Add agent skills system with generate-skills.js build script
Converts the 46 JS snippets into actionable SKILL.md files for AI agents (Claude Code, Cursor, Copilot). Each skill contains a snippet index, execution instructions for Chrome DevTools MCP, thresholds, and links to further reading. Snippet code stays in scripts/ to be read on demand, keeping SKILL.md concise. Skills generated: - skills/webperf/ meta-skill router (46 snippets, 5 categories) - skills/webperf-core-web-vitals/ 7 snippets: LCP, CLS, INP... - skills/webperf-loading/ 27 snippets: TTFB, FCP, fonts, scripts... - skills/webperf-interaction/ 8 snippets: LoAF, LongTask, scroll... - skills/webperf-media/ 3 snippets: images, video, SVG - skills/webperf-resources/ 1 snippet: network bandwidth Run: npm run generate-skills
1 parent 57406c3 commit ef29e91

54 files changed

Lines changed: 12581 additions & 0 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
"build": "next build",
99
"postbuild": "next-sitemap",
1010
"prebuild": "rm -rf .next",
11+
"generate-skills": "node scripts/generate-skills.js",
1112
"test": "echo \"Error: no test specified\" && exit 1"
1213
},
1314
"keywords": [

scripts/generate-skills.js

Lines changed: 391 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,391 @@
1+
#!/usr/bin/env node
2+
/**
3+
* Generates SKILL.md files from /snippets/ JS files + /pages/ MDX documentation.
4+
* Output: /skills/webperf-{category}/SKILL.md + scripts/*.js
5+
*
6+
* Run: node scripts/generate-skills.js
7+
*/
8+
9+
const fs = require('fs')
10+
const path = require('path')
11+
12+
const ROOT = path.join(__dirname, '..')
13+
const SNIPPETS_DIR = path.join(ROOT, 'snippets')
14+
const PAGES_DIR = path.join(ROOT, 'pages')
15+
const SKILLS_DIR = path.join(ROOT, 'skills')
16+
17+
const CATEGORIES = {
18+
CoreWebVitals: {
19+
skill: 'webperf-core-web-vitals',
20+
name: 'Core Web Vitals',
21+
description:
22+
'Measure and debug Core Web Vitals (LCP, CLS, INP). Use when the user asks about LCP, CLS, INP, page loading performance, or wants to analyze Core Web Vitals on a URL or current page. Compatible with Chrome DevTools MCP.',
23+
},
24+
Loading: {
25+
skill: 'webperf-loading',
26+
name: 'Loading Performance',
27+
description:
28+
'Analyze loading performance (TTFB, FCP, render-blocking resources, scripts, fonts, resource hints, service workers). Use when the user asks about loading time, TTFB, FCP, render-blocking, font loading, script analysis, or prefetching. Compatible with Chrome DevTools MCP.',
29+
},
30+
Interaction: {
31+
skill: 'webperf-interaction',
32+
name: 'Interaction & Animation',
33+
description:
34+
'Measure interaction and animation performance (Long Animation Frames, Long Tasks, scroll jank, layout shifts). Use when the user asks about interaction latency, jank, animation frames, long tasks, or scroll performance. Compatible with Chrome DevTools MCP.',
35+
},
36+
Media: {
37+
skill: 'webperf-media',
38+
name: 'Media Performance',
39+
description:
40+
'Audit images, videos, and SVGs for performance issues. Use when the user asks about image optimization, video performance, lazy loading, image formats, or SVG analysis. Compatible with Chrome DevTools MCP.',
41+
},
42+
Resources: {
43+
skill: 'webperf-resources',
44+
name: 'Resources & Network',
45+
description:
46+
'Analyze network and resource performance (bandwidth, connection quality, effective connection type). Use when the user asks about network performance, bandwidth, connection quality, or adaptive loading. Compatible with Chrome DevTools MCP.',
47+
},
48+
}
49+
50+
function getSnippetFiles(category) {
51+
const dir = path.join(SNIPPETS_DIR, category)
52+
if (!fs.existsSync(dir)) return []
53+
return fs.readdirSync(dir).filter((f) => f.endsWith('.js')).sort()
54+
}
55+
56+
function getMdxFiles(category) {
57+
const dir = path.join(PAGES_DIR, category)
58+
if (!fs.existsSync(dir)) return []
59+
return fs.readdirSync(dir)
60+
.filter((f) => f.endsWith('.mdx'))
61+
.map((f) => path.join(dir, f))
62+
}
63+
64+
// Find the MDX file that imports a given snippet JS file, and return its var name
65+
function findMdxForSnippet(category, snippetFile) {
66+
const escapedFile = snippetFile.replace('.', '\\.')
67+
const importRe = new RegExp(`import (\\w+) from '[^']*/${escapedFile}\\?raw'`)
68+
69+
for (const mdxPath of getMdxFiles(category)) {
70+
const content = fs.readFileSync(mdxPath, 'utf-8')
71+
const match = content.match(importRe)
72+
if (match) {
73+
return { mdxPath, content, varName: match[1] }
74+
}
75+
}
76+
return null
77+
}
78+
79+
// For a shared MDX (multiple snippets), find the H2 section title containing a given snippet var
80+
function findH2ForSnippetVar(content, varName) {
81+
const snippetCall = `<Snippet code={${varName}} />`
82+
const snippetIdx = content.indexOf(snippetCall)
83+
if (snippetIdx === -1) return null
84+
85+
const before = content.slice(0, snippetIdx)
86+
const h2Matches = [...before.matchAll(/^## (.+)$/gm)]
87+
if (h2Matches.length === 0) return null
88+
return h2Matches[h2Matches.length - 1][1].trim()
89+
}
90+
91+
// Extract the first real description paragraph from MDX content.
92+
// If afterH2Text is given, searches only within that H2 section (bounded by next H2).
93+
function extractDescription(content, afterH2Text = null) {
94+
let searchContent = content
95+
96+
if (afterH2Text) {
97+
const h2Marker = `## ${afterH2Text}`
98+
const h2Idx = content.indexOf(h2Marker)
99+
if (h2Idx !== -1) {
100+
const afterH2 = content.slice(h2Idx + h2Marker.length)
101+
// Bound search to current section (up to next H2 heading)
102+
const nextH2Match = afterH2.match(/\n## /)
103+
searchContent = nextH2Match ? afterH2.slice(0, nextH2Match.index) : afterH2
104+
}
105+
} else {
106+
const h1Match = content.match(/^# .+$/m)
107+
if (h1Match) searchContent = content.slice(content.indexOf(h1Match[0]) + h1Match[0].length)
108+
}
109+
110+
const skipPatterns = [/^#/, /^import /, /^```/, /^\|/, /^>/, /^</, /^\s*$/, /^\*\*[^*]*:\*\*/]
111+
112+
for (const para of searchContent.split(/\n\n+/)) {
113+
const trimmed = para.trim()
114+
if (!trimmed) continue
115+
if (skipPatterns.some((re) => re.test(trimmed))) continue
116+
return trimmed
117+
.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1')
118+
.replace(/`([^`]+)`/g, '$1')
119+
.replace(/\*\*([^*]+)\*\*/g, '$1')
120+
.replace(/\n/g, ' ')
121+
.trim()
122+
}
123+
// Fall back to full MDX description if section has no paragraph
124+
if (afterH2Text) return extractDescription(content)
125+
return ''
126+
}
127+
128+
// Extract the first thresholds/rating table (contains 🟢) from MDX content
129+
function extractThresholds(content) {
130+
// Find the bold label line followed by the table
131+
const thresholdSectionRe = /\*\*[^*]*[Tt]hreshold[^*]*\*\*[:\s]*\n\n((?:\|.+\n)+)/g
132+
let match = thresholdSectionRe.exec(content)
133+
if (match) return match[1].trimEnd()
134+
135+
// Fallback: any table containing 🟢 emoji
136+
const tableRe = /((?:\|.+\n)+)/g
137+
while ((match = tableRe.exec(content)) !== null) {
138+
if (match[1].includes('🟢')) return match[1].trimEnd()
139+
}
140+
return ''
141+
}
142+
143+
// Extract further reading links from MDX content
144+
function extractFurtherReading(content) {
145+
const match = content.match(/#{1,3} Further Reading\n\n((?:- .+\n?)+)/m)
146+
return match ? match[1].trim() : ''
147+
}
148+
149+
// Strip internal relative links (e.g. /CoreWebVitals/LCP-Sub-Parts) from markdown link text
150+
function cleanLinks(text) {
151+
return text
152+
.replace(/\[([^\]]+)\]\(\/[^)]+\)/g, '$1') // remove internal links, keep text
153+
.replace(/\[([^\]]+)\]\((https?:[^)]+)\)/g, '[$1]($2)') // keep external links
154+
}
155+
156+
// Build metadata for a single snippet JS file
157+
function buildSnippetMeta(category, snippetFile) {
158+
const basename = path.basename(snippetFile, '.js')
159+
const found = findMdxForSnippet(category, snippetFile)
160+
161+
if (!found) {
162+
return { basename, title: basename.replace(/-/g, ' '), description: '', thresholds: '', furtherReading: '' }
163+
}
164+
165+
const { content, varName } = found
166+
const importCount = (content.match(/import snippet\w* from/g) || []).length
167+
const isShared = importCount > 1
168+
169+
const h1Match = content.match(/^# (.+)$/m)
170+
const mdxTitle = h1Match ? h1Match[1] : basename.replace(/-/g, ' ')
171+
172+
let title, description, thresholds
173+
174+
if (isShared) {
175+
const h2Title = findH2ForSnippetVar(content, varName)
176+
title = h2Title ? `${mdxTitle}: ${h2Title}` : mdxTitle
177+
description = extractDescription(content, h2Title)
178+
thresholds = ''
179+
} else {
180+
title = mdxTitle
181+
description = extractDescription(content)
182+
thresholds = extractThresholds(content)
183+
}
184+
185+
return {
186+
basename,
187+
title,
188+
description,
189+
thresholds,
190+
furtherReading: cleanLinks(extractFurtherReading(content)),
191+
}
192+
}
193+
194+
function generateCategorySkill(category, catConfig) {
195+
const snippetFiles = getSnippetFiles(category)
196+
if (snippetFiles.length === 0) return
197+
198+
console.log(`\nGenerating ${catConfig.skill}/ (${snippetFiles.length} snippets)...`)
199+
200+
const metas = snippetFiles.map((f) => buildSnippetMeta(category, f))
201+
202+
const skillDir = path.join(SKILLS_DIR, catConfig.skill)
203+
const scriptsDir = path.join(skillDir, 'scripts')
204+
fs.mkdirSync(scriptsDir, { recursive: true })
205+
206+
for (const snippetFile of snippetFiles) {
207+
const src = path.join(SNIPPETS_DIR, category, snippetFile)
208+
const dst = path.join(scriptsDir, snippetFile)
209+
fs.copyFileSync(src, dst)
210+
}
211+
console.log(` copied ${snippetFiles.length} scripts to scripts/`)
212+
213+
const lines = []
214+
215+
lines.push('---')
216+
lines.push(`name: ${catConfig.skill}`)
217+
lines.push(`description: ${catConfig.description}`)
218+
lines.push('---')
219+
lines.push('')
220+
lines.push(`# WebPerf: ${catConfig.name}`)
221+
lines.push('')
222+
lines.push(
223+
'JavaScript snippets for measuring web performance in Chrome DevTools. ' +
224+
'Execute with `mcp__chrome-devtools__evaluate_script`, capture output with `mcp__chrome-devtools__get_console_message`.'
225+
)
226+
lines.push('')
227+
228+
lines.push('## Available Snippets')
229+
lines.push('')
230+
lines.push('| Snippet | Description | File |')
231+
lines.push('|---------|-------------|------|')
232+
for (const meta of metas) {
233+
const desc = meta.description
234+
? meta.description.split(/[.!?]/)[0].slice(0, 100)
235+
: meta.title
236+
lines.push(`| ${meta.title} | ${desc} | scripts/${meta.basename}.js |`)
237+
}
238+
lines.push('')
239+
240+
lines.push('## Execution with Chrome DevTools MCP')
241+
lines.push('')
242+
lines.push('```')
243+
lines.push('1. mcp__chrome-devtools__navigate_page → navigate to target URL')
244+
lines.push('2. mcp__chrome-devtools__evaluate_script → run snippet code (read from scripts/ file)')
245+
lines.push('3. mcp__chrome-devtools__get_console_message → capture console output')
246+
lines.push('4. Interpret results using thresholds below, provide recommendations')
247+
lines.push('```')
248+
lines.push('')
249+
250+
for (const meta of metas) {
251+
lines.push(`---`)
252+
lines.push('')
253+
lines.push(`## ${meta.title}`)
254+
lines.push('')
255+
256+
if (meta.description) {
257+
lines.push(meta.description)
258+
lines.push('')
259+
}
260+
261+
lines.push(`**Script:** \`scripts/${meta.basename}.js\``)
262+
lines.push('')
263+
264+
if (meta.thresholds) {
265+
lines.push('**Thresholds:**')
266+
lines.push('')
267+
lines.push(meta.thresholds)
268+
lines.push('')
269+
}
270+
271+
if (meta.furtherReading) {
272+
lines.push('**Further Reading:**')
273+
lines.push('')
274+
lines.push(meta.furtherReading)
275+
lines.push('')
276+
}
277+
}
278+
279+
const skillContent = lines.join('\n')
280+
const skillPath = path.join(skillDir, 'SKILL.md')
281+
fs.writeFileSync(skillPath, skillContent)
282+
console.log(` written: SKILL.md (${Math.round(skillContent.length / 1024)}KB)`)
283+
}
284+
285+
function generateMetaSkill() {
286+
console.log('\nGenerating webperf/ meta-skill...')
287+
288+
const skillDir = path.join(SKILLS_DIR, 'webperf')
289+
fs.mkdirSync(skillDir, { recursive: true })
290+
291+
const totalSnippets = Object.keys(CATEGORIES).reduce(
292+
(sum, cat) => sum + getSnippetFiles(cat).length, 0
293+
)
294+
295+
const lines = []
296+
297+
lines.push('---')
298+
lines.push('name: webperf')
299+
lines.push(
300+
'description: Web performance measurement and debugging toolkit. Use when the user asks about web performance, wants to audit a page, or says "analyze performance", "debug lcp", "check ttfb", "measure core web vitals", "audit images", or similar.'
301+
)
302+
lines.push('---')
303+
lines.push('')
304+
lines.push('# WebPerf Snippets Toolkit')
305+
lines.push('')
306+
lines.push(
307+
`A collection of ${totalSnippets} JavaScript snippets for measuring and debugging web performance in Chrome DevTools. ` +
308+
'Each snippet runs in the browser console and outputs structured, color-coded results.'
309+
)
310+
lines.push('')
311+
312+
lines.push('## Skills by Category')
313+
lines.push('')
314+
lines.push('| Skill | Snippets | Use when |')
315+
lines.push('|-------|----------|----------|')
316+
for (const [category, config] of Object.entries(CATEGORIES)) {
317+
const count = getSnippetFiles(category).length
318+
const useWhen = config.description.split('.')[0]
319+
lines.push(`| ${config.skill} | ${count} | ${useWhen} |`)
320+
}
321+
lines.push('')
322+
323+
lines.push('## Quick Reference')
324+
lines.push('')
325+
lines.push('| User says | Skill to use |')
326+
lines.push('|-----------|--------------|')
327+
lines.push('| "debug LCP", "slow LCP", "largest contentful paint" | webperf-core-web-vitals |')
328+
lines.push('| "check CLS", "layout shifts", "visual stability" | webperf-core-web-vitals |')
329+
lines.push('| "INP", "interaction latency", "responsiveness" | webperf-core-web-vitals |')
330+
lines.push('| "TTFB", "slow server", "time to first byte" | webperf-loading |')
331+
lines.push('| "FCP", "first contentful paint", "render blocking" | webperf-loading |')
332+
lines.push('| "font loading", "script loading", "resource hints", "service worker" | webperf-loading |')
333+
lines.push('| "jank", "scroll performance", "long tasks", "animation frames", "INP debug" | webperf-interaction |')
334+
lines.push('| "image audit", "lazy loading", "image optimization", "video audit" | webperf-media |')
335+
lines.push('| "network quality", "bandwidth", "connection type", "save-data" | webperf-resources |')
336+
lines.push('')
337+
338+
lines.push('## Workflow')
339+
lines.push('')
340+
lines.push('1. Identify the relevant skill based on the user\'s question (use Quick Reference above)')
341+
lines.push('2. Load the skill\'s SKILL.md to see available snippets and thresholds')
342+
lines.push('3. Execute with Chrome DevTools MCP:')
343+
lines.push(' - `mcp__chrome-devtools__navigate_page` → navigate to target URL')
344+
lines.push(' - `mcp__chrome-devtools__evaluate_script` → run the snippet')
345+
lines.push(' - `mcp__chrome-devtools__get_console_message` → read results')
346+
lines.push('4. Interpret results using the thresholds defined in the skill')
347+
lines.push('5. Provide actionable recommendations based on findings')
348+
lines.push('')
349+
350+
const content = lines.join('\n')
351+
fs.writeFileSync(path.join(skillDir, 'SKILL.md'), content)
352+
console.log(` written: SKILL.md`)
353+
}
354+
355+
function validateSkill(name, description) {
356+
const errors = []
357+
if (name.length > 64) errors.push(`name too long: ${name.length} chars (max 64)`)
358+
if (description.length > 1024) errors.push(`description too long: ${description.length} chars (max 1024)`)
359+
return errors
360+
}
361+
362+
function main() {
363+
console.log('Generating WebPerf skills...\n')
364+
fs.mkdirSync(SKILLS_DIR, { recursive: true })
365+
366+
// Validate frontmatter constraints before generating
367+
const validationErrors = []
368+
for (const [, config] of Object.entries(CATEGORIES)) {
369+
const errors = validateSkill(config.skill, config.description)
370+
if (errors.length) validationErrors.push(...errors.map((e) => ` ${config.skill}: ${e}`))
371+
}
372+
const metaDesc =
373+
'Web performance measurement and debugging toolkit. Use when the user asks about web performance, wants to audit a page, or says "analyze performance", "debug lcp", "check ttfb", "measure core web vitals", "audit images", or similar.'
374+
const metaErrors = validateSkill('webperf', metaDesc)
375+
if (metaErrors.length) validationErrors.push(...metaErrors.map((e) => ` webperf: ${e}`))
376+
377+
if (validationErrors.length) {
378+
console.error('Validation errors:\n' + validationErrors.join('\n'))
379+
process.exit(1)
380+
}
381+
382+
for (const [category, config] of Object.entries(CATEGORIES)) {
383+
generateCategorySkill(category, config)
384+
}
385+
386+
generateMetaSkill()
387+
388+
console.log('\nDone! Skills generated in /skills/')
389+
}
390+
391+
main()

0 commit comments

Comments
 (0)