-
Notifications
You must be signed in to change notification settings - Fork 84
Expand file tree
/
Copy pathgenerate-skills.js
More file actions
executable file
·482 lines (415 loc) · 20.5 KB
/
generate-skills.js
File metadata and controls
executable file
·482 lines (415 loc) · 20.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
#!/usr/bin/env node
/**
* Generates skill.md files from /snippets/ JS files + /pages/ MDX documentation.
* Output: /skills/webperf-{category}/skill.md + scripts/*.js
* /dist/webperf-{category}/*.js (readable, no console, no headers — for external repos)
*
* Run: node scripts/generate-skills.js
*/
const fs = require('fs')
const path = require('path')
const { createHash } = require('crypto')
const { minify } = require('terser')
const GITHUB_BASE = 'https://github.com/nucliweb/webperf-snippets/blob/main'
const ROOT = path.join(__dirname, '..')
const SNIPPETS_DIR = path.join(ROOT, 'snippets')
const PAGES_DIR = path.join(ROOT, 'pages')
const SKILLS_DIR = path.join(ROOT, 'skills')
const CLAUDE_SKILLS_DIR = path.join(ROOT, '.claude', 'skills')
const DIST_DIR = path.join(ROOT, 'dist')
const pkg = JSON.parse(fs.readFileSync(path.join(ROOT, 'package.json'), 'utf-8'))
const SKILL_METADATA = {
license: pkg.license,
metadata: {
author: pkg.author,
version: pkg.version,
'mcp-server': 'chrome-devtools',
category: 'web-performance',
repository: 'https://github.com/nucliweb/webperf-snippets',
},
}
const CATEGORIES = {
CoreWebVitals: {
skill: 'webperf-core-web-vitals',
name: 'Core Web Vitals',
description:
'Intelligent Core Web Vitals analysis with automated workflows and decision trees. Measures LCP, CLS, INP with guided debugging that automatically determines follow-up analysis based on results. Includes workflows for LCP deep dive (5 phases), CLS investigation (loading vs interaction), INP debugging (latency breakdown + attribution), and cross-skill integration with loading, interaction, and media skills. Use when the user asks about Core Web Vitals, LCP optimization, layout shifts, or interaction responsiveness. Compatible with Chrome DevTools MCP.',
},
Loading: {
skill: 'webperf-loading',
name: 'Loading Performance',
description:
'Intelligent loading performance analysis with automated workflows for TTFB investigation (DNS/connection/server breakdown), render-blocking detection, script performance deep dive (first vs third-party attribution), font optimization, and resource hints validation. Includes decision trees that automatically analyze TTFB sub-parts when slow, detect script loading anti-patterns (async/defer/preload conflicts), identify render-blocking resources, and validate resource hints usage. Features workflows for complete loading audit (6 phases), backend performance investigation, and priority optimization. Cross-skill integration with Core Web Vitals (LCP resource loading), Interaction (script execution blocking), and Media (lazy loading strategy). Use when the user asks about TTFB, FCP, render-blocking, slow loading, font performance, script optimization, or resource hints. Compatible with Chrome DevTools MCP.',
},
Interaction: {
skill: 'webperf-interaction',
name: 'Interaction & Animation',
description:
'Intelligent interaction performance analysis with automated workflows for INP debugging, scroll jank investigation, and main thread blocking. Includes decision trees that automatically run script attribution when long frames detected, break down input latency phases, and correlate layout shifts with interactions. Features workflows for complete interaction audit, third-party script impact analysis, and animation performance debugging. Cross-skill integration with Core Web Vitals (INP/CLS correlation) and Loading (script execution analysis). Use when the user asks about slow interactions, janky scrolling, unresponsive pages, or INP optimization. Compatible with Chrome DevTools MCP.',
},
Media: {
skill: 'webperf-media',
name: 'Media Performance',
description:
'Intelligent media optimization with automated workflows for images, videos, and SVGs. Includes decision trees that detect LCP images (triggers format/lazy-loading/priority analysis), identify layout shift risks (missing dimensions), and flag lazy loading issues (above-fold lazy or below-fold eager). Features workflows for complete media audit, LCP image investigation, video performance (poster optimization), and SVG embedded bitmap detection. Cross-skill integration with Core Web Vitals (LCP/CLS impact) and Loading (priority hints, resource preloading). Provides performance budgets and format recommendations based on content type. Use when the user asks about image optimization, LCP is an image/video, layout shifts from media, or media loading strategy. Compatible with Chrome DevTools MCP.',
},
Resources: {
skill: 'webperf-resources',
name: 'Resources & Network',
description:
'Intelligent network quality analysis with adaptive loading strategies. Detects connection type (2g/3g/4g), bandwidth, RTT, and save-data mode, then automatically triggers appropriate optimization workflows. Includes decision trees that recommend image compression for slow connections, critical CSS inlining for high RTT, and save-data optimizations (disable autoplay, reduce quality). Features connection-aware performance budgets (500KB for 2g, 1.5MB for 3g, 3MB for 4g+) and adaptive loading implementation guides. Cross-skill integration with Loading (TTFB impact), Media (responsive images), and Core Web Vitals (connection impact on LCP/INP). Use when the user asks about slow connections, mobile optimization, save-data support, or adaptive loading strategies. Compatible with Chrome DevTools MCP.',
},
}
// Strips leading header comment lines (// Title\n// URL\n) before the IIFE
function stripHeaderComments(source) {
return source.replace(/^(\/\/[^\n]*\n)+\n*/, '')
}
async function buildReadableScript(src, dst) {
const source = fs.readFileSync(src, 'utf-8')
const stripped = stripHeaderComments(source)
let code
try {
const result = await minify(stripped, {
compress: {
defaults: false, // disable all default transforms
drop_console: true, // only remove console.* calls
unused: true, // remove vars made dead by console removal
dead_code: true, // remove unreachable code
passes: 2,
},
mangle: false,
format: { beautify: true, comments: false, indent_level: 2 },
})
code = result.code
// Remove void 0 placeholders left by drop_console
.replace(/^\s*void 0;\s*\n/gm, '')
// Restore number literals: 1e4 → 10000, .1 → 0.1
.replace(/\b(\d+(?:\.\d+)?)e(\d+)\b/g, (_, m, e) => String(Number(`${m}e${e}`)))
.replace(/(?<![.\w])\.(\d)/g, '0.$1')
} catch (err) {
console.warn(` ⚠ readable minify failed for ${path.basename(src)}: ${err.message} — stripping comments only`)
code = stripped
}
fs.writeFileSync(dst, code + '\n')
}
async function buildScript(src, dst, relPath) {
const source = fs.readFileSync(src, 'utf-8')
const hash = createHash('sha256').update(source).digest('hex').slice(0, 16)
const githubUrl = `${GITHUB_BASE}/${relPath}`
const header = `// ${relPath} | sha256:${hash} | ${githubUrl}\n`
let code
try {
const result = await minify(source, {
compress: { drop_console: true, pure_getters: true, passes: 2 },
mangle: true,
format: { comments: false },
})
code = result.code
} catch (err) {
console.warn(` ⚠ minify failed for ${path.basename(src)}: ${err.message} — copying as-is`)
code = source
}
fs.writeFileSync(dst, header + code)
}
function getSnippetFiles(category) {
const dir = path.join(SNIPPETS_DIR, category)
if (!fs.existsSync(dir)) return []
return fs.readdirSync(dir).filter((f) => f.endsWith('.js')).sort()
}
function getMdxFiles(category) {
const dir = path.join(PAGES_DIR, category)
if (!fs.existsSync(dir)) return []
return fs.readdirSync(dir)
.filter((f) => f.endsWith('.mdx'))
.map((f) => path.join(dir, f))
}
// Find the MDX file that imports a given snippet JS file, and return its var name
function findMdxForSnippet(category, snippetFile) {
const escapedFile = snippetFile.replace('.', '\\.')
const importRe = new RegExp(`import (\\w+) from '[^']*/${escapedFile}\\?raw'`)
for (const mdxPath of getMdxFiles(category)) {
const content = fs.readFileSync(mdxPath, 'utf-8')
const match = content.match(importRe)
if (match) {
return { mdxPath, content, varName: match[1] }
}
}
return null
}
// For a shared MDX (multiple snippets), find the H2 section title containing a given snippet var
function findH2ForSnippetVar(content, varName) {
const snippetCall = `<Snippet code={${varName}} />`
const snippetIdx = content.indexOf(snippetCall)
if (snippetIdx === -1) return null
const before = content.slice(0, snippetIdx)
const h2Matches = [...before.matchAll(/^## (.+)$/gm)]
if (h2Matches.length === 0) return null
return h2Matches[h2Matches.length - 1][1].trim()
}
// Extract the first real description paragraph from MDX content.
// If afterH2Text is given, searches only within that H2 section (bounded by next H2).
function extractDescription(content, afterH2Text = null) {
let searchContent = content
if (afterH2Text) {
const h2Marker = `## ${afterH2Text}`
const h2Idx = content.indexOf(h2Marker)
if (h2Idx !== -1) {
const afterH2 = content.slice(h2Idx + h2Marker.length)
// Bound search to current section (up to next H2 heading)
const nextH2Match = afterH2.match(/\n## /)
searchContent = nextH2Match ? afterH2.slice(0, nextH2Match.index) : afterH2
}
} else {
const h1Match = content.match(/^# .+$/m)
if (h1Match) searchContent = content.slice(content.indexOf(h1Match[0]) + h1Match[0].length)
}
const skipPatterns = [/^#/, /^import /, /^```/, /^\|/, /^>/, /^</, /^\s*$/, /^\*\*[^*]*:\*\*/]
for (const para of searchContent.split(/\n\n+/)) {
const trimmed = para.trim()
if (!trimmed) continue
if (skipPatterns.some((re) => re.test(trimmed))) continue
return trimmed
.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1')
.replace(/`([^`]+)`/g, '$1')
.replace(/\*\*([^*]+)\*\*/g, '$1')
.replace(/\n/g, ' ')
.trim()
}
// Fall back to full MDX description if section has no paragraph
if (afterH2Text) return extractDescription(content)
return ''
}
// Extract the first thresholds/rating table (contains 🟢) from MDX content
function extractThresholds(content) {
// Find the bold label line followed by the table
const thresholdSectionRe = /\*\*[^*]*[Tt]hreshold[^*]*\*\*[:\s]*\n\n((?:\|.+\n)+)/g
let match = thresholdSectionRe.exec(content)
if (match) return match[1].trimEnd()
// Fallback: any table containing 🟢 emoji
const tableRe = /((?:\|.+\n)+)/g
while ((match = tableRe.exec(content)) !== null) {
if (match[1].includes('🟢')) return match[1].trimEnd()
}
return ''
}
// Build metadata for a single snippet JS file
function buildSnippetMeta(category, snippetFile) {
const basename = path.basename(snippetFile, '.js')
const found = findMdxForSnippet(category, snippetFile)
if (!found) {
return { basename, title: basename.replace(/-/g, ' '), description: '', thresholds: '' }
}
const { content, varName } = found
const importCount = (content.match(/import snippet\w* from/g) || []).length
const isShared = importCount > 1
const h1Match = content.match(/^# (.+)$/m)
const mdxTitle = h1Match ? h1Match[1] : basename.replace(/-/g, ' ')
let title, description, thresholds
if (isShared) {
const h2Title = findH2ForSnippetVar(content, varName)
title = h2Title ? `${mdxTitle}: ${h2Title}` : mdxTitle
description = extractDescription(content, h2Title)
thresholds = ''
} else {
title = mdxTitle
description = extractDescription(content)
thresholds = extractThresholds(content)
}
return {
basename,
title,
description,
thresholds,
}
}
async function generateCategorySkill(category, catConfig) {
const snippetFiles = getSnippetFiles(category)
if (snippetFiles.length === 0) return
console.log(`\nGenerating ${catConfig.skill}/ (${snippetFiles.length} snippets)...`)
const metas = snippetFiles.map((f) => buildSnippetMeta(category, f))
const skillDir = path.join(SKILLS_DIR, catConfig.skill)
const scriptsDir = path.join(skillDir, 'scripts')
const refsDir = path.join(skillDir, 'references')
const distDir = path.join(DIST_DIR, catConfig.skill)
fs.mkdirSync(scriptsDir, { recursive: true })
fs.mkdirSync(refsDir, { recursive: true })
fs.mkdirSync(distDir, { recursive: true })
for (const snippetFile of snippetFiles) {
const src = path.join(SNIPPETS_DIR, category, snippetFile)
await buildScript(src, path.join(scriptsDir, snippetFile), `snippets/${category}/${snippetFile}`)
await buildReadableScript(src, path.join(distDir, snippetFile))
}
console.log(` built ${snippetFiles.length} scripts to scripts/ and dist/`)
// Write references/snippets.md (L3 — loaded on demand)
const snippetLines = []
for (const meta of metas) {
snippetLines.push(`---`)
snippetLines.push(`## ${meta.title}`)
if (meta.description) {
snippetLines.push('')
snippetLines.push(meta.description)
}
snippetLines.push('')
snippetLines.push(`**Script:** \`scripts/${meta.basename}.js\``)
if (meta.thresholds) {
snippetLines.push('')
snippetLines.push('**Thresholds:**')
snippetLines.push('')
snippetLines.push(meta.thresholds)
}
}
fs.writeFileSync(path.join(refsDir, 'snippets.md'), snippetLines.join('\n') + '\n')
console.log(` written: references/snippets.md`)
// Copy SCHEMA.md to references/schema.md
const schemaSrc = path.join(CLAUDE_SKILLS_DIR, 'SCHEMA.md')
if (fs.existsSync(schemaSrc)) {
fs.copyFileSync(schemaSrc, path.join(refsDir, 'schema.md'))
console.log(` copied: references/schema.md`)
}
const lines = []
lines.push('---')
lines.push(`name: ${catConfig.skill}`)
lines.push(`description: ${catConfig.description}`)
lines.push('context: fork')
lines.push(`license: ${SKILL_METADATA.license}`)
lines.push('metadata:')
for (const [key, value] of Object.entries(SKILL_METADATA.metadata)) {
lines.push(` ${key}: ${value}`)
}
lines.push('---')
lines.push('')
lines.push(`# WebPerf: ${catConfig.name}`)
lines.push('')
lines.push(
'JavaScript snippets for measuring web performance in Chrome DevTools. ' +
'Execute with `mcp__chrome-devtools__evaluate_script`, capture output with `mcp__chrome-devtools__get_console_message`.'
)
lines.push('')
// Compact script list (replaces truncated table)
lines.push('## Scripts')
lines.push('')
for (const meta of metas) {
lines.push(`- \`scripts/${meta.basename}.js\` — ${meta.title}`)
}
lines.push('')
lines.push('')
// Inject WORKFLOWS.md if exists (no trailing --- to avoid double separator)
const workflowsPath = path.join(SNIPPETS_DIR, category, 'WORKFLOWS.md')
if (fs.existsSync(workflowsPath)) {
const workflowsContent = fs.readFileSync(workflowsPath, 'utf-8').trim()
lines.push(workflowsContent)
lines.push('')
console.log(` injected WORKFLOWS.md`)
}
lines.push('## References')
lines.push('')
lines.push('- `references/snippets.md` — Descriptions and thresholds for each script')
lines.push('- `references/schema.md` — Return value schema for interpreting script output')
const skillContent = lines.join('\n')
const skillPath = path.join(skillDir, 'SKILL.md')
fs.writeFileSync(skillPath, skillContent)
console.log(` written: SKILL.md (${Math.round(skillContent.length / 1024)}KB)`)
}
function generateMetaSkill() {
console.log('\nGenerating webperf/ meta-skill...')
const skillDir = path.join(SKILLS_DIR, 'webperf')
fs.mkdirSync(skillDir, { recursive: true })
const totalSnippets = Object.keys(CATEGORIES).reduce(
(sum, cat) => sum + getSnippetFiles(cat).length, 0
)
const lines = []
lines.push('---')
lines.push('name: webperf')
lines.push(
'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.'
)
lines.push('context: fork')
lines.push(`license: ${SKILL_METADATA.license}`)
lines.push('metadata:')
for (const [key, value] of Object.entries(SKILL_METADATA.metadata)) {
lines.push(` ${key}: ${value}`)
}
lines.push('---')
lines.push('')
lines.push('# WebPerf Snippets Toolkit')
lines.push('')
lines.push(
`A collection of ${totalSnippets} JavaScript snippets for measuring and debugging web performance in Chrome DevTools. ` +
'Each snippet runs in the browser console and outputs structured, color-coded results.'
)
lines.push('')
lines.push('## Quick Reference')
lines.push('')
lines.push('| Skill | Snippets | Trigger phrases |')
lines.push('|-------|----------|-----------------|')
lines.push(`| webperf-core-web-vitals | ${getSnippetFiles('CoreWebVitals').length} | "debug LCP", "slow LCP", "CLS", "layout shifts", "INP", "interaction latency", "responsiveness" |`)
lines.push(`| webperf-loading | ${getSnippetFiles('Loading').length} | "TTFB", "slow server", "FCP", "render blocking", "font loading", "script loading", "resource hints", "service worker" |`)
lines.push(`| webperf-interaction | ${getSnippetFiles('Interaction').length} | "jank", "scroll performance", "long tasks", "animation frames", "INP debug" |`)
lines.push(`| webperf-media | ${getSnippetFiles('Media').length} | "image audit", "lazy loading", "image optimization", "video audit" |`)
lines.push(`| webperf-resources | ${getSnippetFiles('Resources').length} | "network quality", "bandwidth", "connection type", "save-data" |`)
lines.push('')
lines.push('## Workflow')
lines.push('')
lines.push('1. Identify the relevant skill based on the user\'s question (see Quick Reference above)')
lines.push('2. Load the skill\'s skill.md to see available snippets and thresholds')
lines.push('3. Execute with Chrome DevTools MCP:')
lines.push(' - `mcp__chrome-devtools__navigate_page` → navigate to target URL')
lines.push(' - `mcp__chrome-devtools__evaluate_script` → run the snippet')
lines.push(' - `mcp__chrome-devtools__get_console_message` → read results')
lines.push('4. Interpret results using the thresholds defined in the skill')
lines.push('5. Provide actionable recommendations based on findings')
lines.push('')
const content = lines.join('\n')
fs.writeFileSync(path.join(skillDir, 'SKILL.md'), content)
console.log(` written: SKILL.md`)
}
function validateSkill(name, description) {
const errors = []
if (name.length > 64) errors.push(`name too long: ${name.length} chars (max 64)`)
if (description.length > 1024) errors.push(`description too long: ${description.length} chars (max 1024)`)
return errors
}
async function main() {
console.log('Generating WebPerf skills...\n')
fs.mkdirSync(SKILLS_DIR, { recursive: true })
// Validate frontmatter constraints before generating
const validationErrors = []
for (const [, config] of Object.entries(CATEGORIES)) {
const errors = validateSkill(config.skill, config.description)
if (errors.length) validationErrors.push(...errors.map((e) => ` ${config.skill}: ${e}`))
}
const metaDesc =
'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.'
const metaErrors = validateSkill('webperf', metaDesc)
if (metaErrors.length) validationErrors.push(...metaErrors.map((e) => ` webperf: ${e}`))
if (validationErrors.length) {
console.error('Validation errors:\n' + validationErrors.join('\n'))
process.exit(1)
}
for (const [category, config] of Object.entries(CATEGORIES)) {
await generateCategorySkill(category, config)
}
generateMetaSkill()
// Copy skills to .claude/skills/ for Claude Code
console.log('\nCopying skills to .claude/skills/...')
function copyRecursive(src, dest) {
if (!fs.existsSync(src)) return
fs.mkdirSync(dest, { recursive: true })
const entries = fs.readdirSync(src, { withFileTypes: true })
for (const entry of entries) {
const srcPath = path.join(src, entry.name)
const destPath = path.join(dest, entry.name)
if (entry.isDirectory()) {
// Replace dest subdirectory entirely to avoid stale files
if (fs.existsSync(destPath)) fs.rmSync(destPath, { recursive: true, force: true })
copyRecursive(srcPath, destPath)
} else {
fs.copyFileSync(srcPath, destPath)
}
}
}
copyRecursive(SKILLS_DIR, CLAUDE_SKILLS_DIR)
console.log(' copied to .claude/skills/')
console.log('\nDone! Skills generated in /skills/ and .claude/skills/')
}
main().catch(console.error)