|
| 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