diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..15bb1bb --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,33 @@ +name: CI + +on: + pull_request: + push: + branches: + - main + +jobs: + validate: + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '22' + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Lint + run: npm run lint + + - name: Build + run: npm run build + + - name: Check consistency + run: npm run check:consistency diff --git a/.gitignore b/.gitignore index c33a951..5694925 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,4 @@ node_modules .vercel reports workspace +public/sitemap-*.xml diff --git a/README.md b/README.md index 74eea96..df109d7 100644 --- a/README.md +++ b/README.md @@ -2,10 +2,22 @@ A curated collection of JavaScript snippets to measure and debug Web Performance directly in your browser's DevTools console. +[![CI](https://github.com/nucliweb/webperf-snippets/actions/workflows/ci.yml/badge.svg)](https://github.com/nucliweb/webperf-snippets/actions/workflows/ci.yml) +[![Release](https://img.shields.io/github/v/release/nucliweb/webperf-snippets)](https://github.com/nucliweb/webperf-snippets/releases) +[![Snippets](https://img.shields.io/badge/snippets-47-0f766e)](https://webperf-snippets.nucliweb.net) +[![License](https://img.shields.io/github/license/nucliweb/webperf-snippets)](./LICENSE) [![Star History](https://img.shields.io/github/stars/nucliweb/webperf-snippets?style=social)](https://star-history.com/#nucliweb/webperf-snippets&Date) ![WebPerf Snippets](https://github.com/nucliweb/webperf-snippets/assets/1307927/f47f3049-34f5-407c-896a-d26a30ddf344) +## Start here + +- Measure your first page: start with [LCP](https://webperf-snippets.nucliweb.net/CoreWebVitals/LCP), [CLS](https://webperf-snippets.nucliweb.net/CoreWebVitals/CLS), and [INP](https://webperf-snippets.nucliweb.net/CoreWebVitals/INP). +- Investigate slow loading: continue with [TTFB](https://webperf-snippets.nucliweb.net/Loading/TTFB), [FCP](https://webperf-snippets.nucliweb.net/Loading/FCP), and [render-blocking resources](https://webperf-snippets.nucliweb.net/Loading/Find-render-blocking-resources). +- Debug interaction issues: use [Long Animation Frames](https://webperf-snippets.nucliweb.net/Interaction/Long-Animation-Frames) and [Scroll Performance](https://webperf-snippets.nucliweb.net/Interaction/Scroll-Performance). +- Audit media-heavy pages: open [Image Element Audit](https://webperf-snippets.nucliweb.net/Media/Image-Element-Audit) and [Video Element Audit](https://webperf-snippets.nucliweb.net/Media/Video-Element-Audit). +- Automate the workflow with agents: see [SKILLS.md](./SKILLS.md) and the installation section below. + ## What you can measure | Category | What it includes | diff --git a/SKILLS.md b/SKILLS.md index 4ddf7cf..97fafbe 100644 --- a/SKILLS.md +++ b/SKILLS.md @@ -22,7 +22,7 @@ These skills transform 47 battle-tested JavaScript snippets into agent capabilit | ------------------------------------------------------- | ---------- | ------------------------------------------------------------ | | **[webperf](#webperf)** | Meta-skill | "Audit performance", "check web vitals", "analyze this page" | | **[webperf-core-web-vitals](#webperf-core-web-vitals)** | 7 | "Debug LCP", "check CLS", "measure INP" | -| **[webperf-loading](#webperf-loading)** | 27 | "Analyze TTFB", "check render-blocking", "audit scripts" | +| **[webperf-loading](#webperf-loading)** | 28 | "Analyze TTFB", "check render-blocking", "audit scripts" | | **[webperf-interaction](#webperf-interaction)** | 8 | "Debug jank", "long tasks", "animation frames" | | **[webperf-media](#webperf-media)** | 3 | "Audit images", "optimize video", "lazy loading" | | **[webperf-resources](#webperf-resources)** | 1 | "Check bandwidth", "network quality" | @@ -105,7 +105,7 @@ The main entry point that helps identify the right skill for your performance qu **What it does:** - Routes to the appropriate specialized skill -- Provides overview of all 46 available snippets +- Provides overview of all 47 available snippets - Suggests which skill to use based on your question ### webperf-core-web-vitals diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..54cb5dd --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,67 @@ +# Architecture + +This repository has four main content layers: + +1. `snippets/` +Source of truth for executable JavaScript snippets. Each category contains the browser-console code that users and agents run. + +2. `pages/` +Human-facing MDX documentation published by the Nextra site. Most pages document one snippet or a related set of snippets. Editorial pages can be declared with frontmatter such as `type: guide`. + +3. `skills/` +Generated Agent Skills built from `snippets/` and `pages/`. These files are not the authoring source. Regenerate them with `npm run generate-skills`. + +4. `dist/` +Generated readable artifacts for external consumption. These are also derived outputs, not source files. + +## Build Flow + +The normal flow is: + +`snippets/` + `pages/` -> `scripts/generate-skills.js` -> `skills/` + `dist/` + +Supporting files: + +- `lib/snippets-registry.js` powers site-level snippet metadata and imports. +- `pages/**/_meta.json` defines sidebar navigation for each section. +- `scripts/check-consistency.js` validates source-to-doc parity, editorial page declarations, `_meta.json` alignment, and published counts. + +## Source of Truth + +Treat these as editable source: + +- `snippets/` +- `pages/` +- `pages/**/_meta.json` +- `lib/snippets-registry.js` +- `README.md` +- `SKILLS.md` + +Treat these as generated or derivative: + +- `skills/` +- `dist/` + +## Common Contributor Workflow + +1. Edit or add a snippet in `snippets/`. +2. Add or update its documentation in `pages/`. +3. Update the relevant `_meta.json`. +4. Run `npm run generate-skills` when you intentionally want to refresh derived artifacts. +5. Run `npm run check:consistency`. +6. Run `npm run lint` and `npm run build`. + +## When To Regenerate Skills + +Run `npm run generate-skills` whenever you change: + +- Any file in `snippets/` +- Any MDX page used to build skill descriptions or thresholds +- `package.json` version or metadata that should propagate to generated skills + +## Release Relationship + +Release packaging reads from generated `skills/`, so stale generated files can leak into release artifacts. Before a release, verify generation explicitly with: + +- `npm run check:consistency` +- `npm run generate-skills:check` diff --git a/docs/RELEASING.md b/docs/RELEASING.md new file mode 100644 index 0000000..c807093 --- /dev/null +++ b/docs/RELEASING.md @@ -0,0 +1,52 @@ +# Releasing + +## Pre-release Checklist + +1. Update the version in [package.json](/Users/joanleon/projects/nucliweb/GitHub/webperf-snippets/package.json). +2. Regenerate derived artifacts with `npm run generate-skills`. +3. Verify the generated version in [skills/webperf/SKILL.md](/Users/joanleon/projects/nucliweb/GitHub/webperf-snippets/skills/webperf/SKILL.md). +4. Run: + +```bash +npm run lint +npm run build +npm run check:consistency +npm run generate-skills:check +``` + +5. Commit the source changes together with regenerated `skills/` and `dist/`. + +## Tag Release + +The release workflow is triggered by pushing a tag that matches `v*`. + +Example: + +```bash +git tag v1.2.1 +git push origin v1.2.1 +``` + +## What The Workflow Produces + +The release workflow: + +1. Installs dependencies with `npm ci` +2. Regenerates `skills/` +3. Packages each generated skill as a zip +4. Publishes a GitHub Release with: + +- `webperf-skills-all.zip` +- `webperf.zip` +- `webperf-core-web-vitals.zip` +- `webperf-loading.zip` +- `webperf-interaction.zip` +- `webperf-media.zip` +- `webperf-resources.zip` + +## Failure Modes To Watch + +- `skills/webperf/SKILL.md` version does not match `package.json` +- `skills/` or `dist/` are stale relative to `snippets/` or `pages/` +- `_meta.json` entries drift from the actual MDX files +- Published snippet counts in `README.md`, `SKILLS.md`, or `skills/webperf/SKILL.md` are outdated diff --git a/next.config.js b/next.config.js index 38397cf..246d2eb 100644 --- a/next.config.js +++ b/next.config.js @@ -57,9 +57,6 @@ module.exports = withNextra({ }, ]; }, - search: { - codeblocks: false, - }, }); // If you have other Next.js configurations, you can pass them as the parameter: diff --git a/package.json b/package.json index 75a20fe..223314a 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,9 @@ "build": "next build", "postbuild": "next-sitemap", "prebuild": "rm -rf .next", + "check:consistency": "node scripts/check-consistency.js", "generate-skills": "node scripts/generate-skills.js", + "generate-skills:check": "node scripts/generate-skills.js && git diff --exit-code -- skills dist", "install-skills": "node scripts/install-skills.js", "install-global": "node scripts/install-global.js", "install-from-release": "node scripts/install-from-release.js", diff --git a/pages/CoreWebVitals/CLS.mdx b/pages/CoreWebVitals/CLS.mdx index ab4421d..8258978 100644 --- a/pages/CoreWebVitals/CLS.mdx +++ b/pages/CoreWebVitals/CLS.mdx @@ -83,6 +83,13 @@ stateDiagram-v2 | Dynamic content injection | Reserve space or insert below fold | | Web fonts (FOIT/FOUT) | Use `font-display: optional` or `size-adjust` | +### Browser Support + +| API | Chrome | Firefox | Safari | Edge | +|-----|--------|---------|--------|------| +| Layout Instability API | ✅ 77 | ❌ | ❌ | ✅ 79 | +| PerformanceObserver | ✅ 52 | ✅ 57 | ✅ 11 | ✅ 79 | + ### Further Reading - [Cumulative Layout Shift (CLS)](https://web.dev/articles/cls) | web.dev diff --git a/pages/CoreWebVitals/LCP.mdx b/pages/CoreWebVitals/LCP.mdx index 804d6bf..2110f24 100644 --- a/pages/CoreWebVitals/LCP.mdx +++ b/pages/CoreWebVitals/LCP.mdx @@ -42,6 +42,13 @@ Quick check for [Largest Contentful Paint](https://web.dev/articles/lcp), a Core | Slow resource load | Preload LCP image, optimize size | | Client-side rendering | Use SSR or prerender | +### Browser Support + +| API | Chrome | Firefox | Safari | Edge | +|-----|--------|---------|--------|------| +| Largest Contentful Paint API | ✅ 77 | ❌ | ❌ | ✅ 79 | +| PerformanceObserver | ✅ 52 | ✅ 57 | ✅ 11 | ✅ 79 | + ### Further Reading - [Largest Contentful Paint (LCP)](https://web.dev/articles/lcp) | web.dev diff --git a/pages/Loading/Get-Your-Head-in-Order.mdx b/pages/Loading/Get-Your-Head-in-Order.mdx index 3fdca52..c891559 100644 --- a/pages/Loading/Get-Your-Head-in-Order.mdx +++ b/pages/Loading/Get-Your-Head-in-Order.mdx @@ -1,3 +1,7 @@ +--- +type: guide +--- + # Get your `` in order How you order elements in the `` can have an effect on the (perceived) performance of the page. The order of elements matters because the browser processes them sequentially. diff --git a/pages/Loading/TTFB.mdx b/pages/Loading/TTFB.mdx index cf6f43b..a7d836b 100644 --- a/pages/Loading/TTFB.mdx +++ b/pages/Loading/TTFB.mdx @@ -78,6 +78,14 @@ Analyzes TTFB for every resource loaded on the page (scripts, stylesheets, image +## Browser Support + +| API | Chrome | Firefox | Safari | Edge | +|-----|--------|---------|--------|------| +| Navigation Timing | ✅ 65 | ✅ 58 | ✅ 11 | ✅ 79 | +| Resource Timing | ✅ 43 | ✅ 36 | ✅ 11 | ✅ 12 | +| PerformanceObserver | ✅ 52 | ✅ 57 | ✅ 11 | ✅ 79 | + ## Further Reading - [Time to First Byte (TTFB)](https://web.dev/articles/ttfb) | web.dev diff --git a/scripts/check-consistency.js b/scripts/check-consistency.js new file mode 100644 index 0000000..d59ab3b --- /dev/null +++ b/scripts/check-consistency.js @@ -0,0 +1,242 @@ +#!/usr/bin/env node + +const fs = require('fs') +const path = require('path') + +const ROOT = path.join(__dirname, '..') +const SNIPPETS_DIR = path.join(ROOT, 'snippets') +const PAGES_DIR = path.join(ROOT, 'pages') +const README_PATH = path.join(ROOT, 'README.md') +const SKILLS_DOC_PATH = path.join(ROOT, 'SKILLS.md') +const META_SKILL_PATH = path.join(ROOT, 'skills', 'webperf', 'SKILL.md') + +const CATEGORY_SKILLS = { + CoreWebVitals: 'webperf-core-web-vitals', + Loading: 'webperf-loading', + Interaction: 'webperf-interaction', + Media: 'webperf-media', + Resources: 'webperf-resources', +} + +const ROOT_EDITORIAL_PAGES = new Set(['index']) + +function readFile(filePath) { + return fs.readFileSync(filePath, 'utf8') +} + +function exists(filePath) { + return fs.existsSync(filePath) +} + +function getJson(filePath) { + return JSON.parse(readFile(filePath)) +} + +function getSnippetFiles(category) { + const dir = path.join(SNIPPETS_DIR, category) + if (!exists(dir)) return [] + return fs.readdirSync(dir).filter((file) => file.endsWith('.js')).sort() +} + +function getMdxFiles(dirPath) { + if (!exists(dirPath)) return [] + return fs.readdirSync(dirPath).filter((file) => file.endsWith('.mdx')).sort() +} + +function parseFrontmatter(content) { + if (!content.startsWith('---\n')) return {} + + const endIndex = content.indexOf('\n---\n', 4) + if (endIndex === -1) return {} + + const body = content.slice(4, endIndex) + const frontmatter = {} + + for (const line of body.split('\n')) { + const match = line.match(/^([A-Za-z0-9_-]+):\s*(.+)$/) + if (!match) continue + const [, key, value] = match + frontmatter[key] = value.trim().replace(/^['"]|['"]$/g, '') + } + + return frontmatter +} + +function getSnippetImports(content) { + const matches = content.matchAll(/import\s+\w+\s+from\s+['"]\.\.\/\.\.\/snippets\/([^/'"]+)\/([^'"]+)\?raw['"]/g) + return [...matches].map((match) => ({ + category: match[1], + file: match[2], + })) +} + +function getRootPages() { + return getMdxFiles(PAGES_DIR) +} + +function getCategoryPages(category) { + return getMdxFiles(path.join(PAGES_DIR, category)) +} + +function verifySourceToPageMapping(errors) { + for (const category of Object.keys(CATEGORY_SKILLS)) { + for (const snippetFile of getSnippetFiles(category)) { + let found = false + + for (const mdxFile of getCategoryPages(category)) { + const content = readFile(path.join(PAGES_DIR, category, mdxFile)) + if (content.includes(`/snippets/${category}/${snippetFile}?raw`)) { + found = true + break + } + } + + if (!found) { + errors.push(`Missing MDX page import for snippet ${category}/${snippetFile}`) + } + } + } +} + +function verifyPageToSourceMapping(errors) { + for (const rootPage of getRootPages()) { + const basename = path.basename(rootPage, '.mdx') + if (ROOT_EDITORIAL_PAGES.has(basename)) continue + + const content = readFile(path.join(PAGES_DIR, rootPage)) + const frontmatter = parseFrontmatter(content) + + if (frontmatter.type === 'guide') continue + + if (getSnippetImports(content).length === 0) { + errors.push(`Root page ${rootPage} has no snippet import and is not marked with type: guide`) + } + } + + for (const category of Object.keys(CATEGORY_SKILLS)) { + for (const mdxFile of getCategoryPages(category)) { + const basename = path.basename(mdxFile, '.mdx') + if (basename === 'index') continue + + const content = readFile(path.join(PAGES_DIR, category, mdxFile)) + const frontmatter = parseFrontmatter(content) + + if (frontmatter.type === 'guide') continue + + const imports = getSnippetImports(content) + if (imports.length === 0) { + errors.push(`Page ${category}/${mdxFile} has no snippet import and is not marked with type: guide`) + continue + } + + const hasMatchingCategory = imports.some((item) => item.category === category) + if (!hasMatchingCategory) { + errors.push(`Page ${category}/${mdxFile} only imports snippets from other categories`) + } + } + } +} + +function verifyMetaAlignment(errors) { + const rootMeta = getJson(path.join(PAGES_DIR, '_meta.json')) + const rootPageKeys = new Set(getRootPages().map((file) => path.basename(file, '.mdx'))) + const rootMetaKeys = new Set(Object.keys(rootMeta)) + + for (const key of rootPageKeys) { + if (!rootMetaKeys.has(key)) { + errors.push(`pages/_meta.json is missing entry for ${key}.mdx`) + } + } + + for (const key of rootMetaKeys) { + if (ROOT_EDITORIAL_PAGES.has(key)) continue + const categoryDir = path.join(PAGES_DIR, key) + if (!rootPageKeys.has(key) && !exists(categoryDir)) { + errors.push(`pages/_meta.json contains stale entry "${key}"`) + } + } + + for (const category of Object.keys(CATEGORY_SKILLS)) { + const metaPath = path.join(PAGES_DIR, category, '_meta.json') + const meta = getJson(metaPath) + const pageKeys = new Set(getCategoryPages(category).map((file) => path.basename(file, '.mdx'))) + const metaKeys = new Set(Object.keys(meta)) + + for (const key of pageKeys) { + if (key === '_meta') continue + if (!metaKeys.has(key)) { + errors.push(`${path.relative(ROOT, metaPath)} is missing entry for ${key}.mdx`) + } + } + + for (const key of metaKeys) { + if (!pageKeys.has(key)) { + errors.push(`${path.relative(ROOT, metaPath)} contains stale entry "${key}"`) + } + } + } +} + +function verifyPublishedCounts(errors) { + const categoryCounts = {} + let total = 0 + + for (const category of Object.keys(CATEGORY_SKILLS)) { + const count = getSnippetFiles(category).length + categoryCounts[category] = count + total += count + } + + const readme = readFile(README_PATH) + const skillsDoc = readFile(SKILLS_DOC_PATH) + const metaSkill = readFile(META_SKILL_PATH) + + const expectedChecks = [ + { + file: 'README.md', + ok: + readme.includes(`| \`webperf\` | ${total}`) && + readme.includes(`| \`webperf-loading\` | ${categoryCounts.Loading}`), + }, + { + file: 'SKILLS.md', + ok: + skillsDoc.includes(`These skills transform ${total} battle-tested JavaScript snippets`) && + skillsDoc.includes(`| **[webperf-loading](#webperf-loading)** | ${categoryCounts.Loading}`) && + skillsDoc.includes('Provides overview of all 47 available snippets'), + }, + { + file: 'skills/webperf/SKILL.md', + ok: + metaSkill.includes(`A collection of ${total} JavaScript snippets`) && + metaSkill.includes(`| webperf-loading | ${categoryCounts.Loading} |`), + }, + ] + + for (const check of expectedChecks) { + if (!check.ok) { + errors.push(`${check.file} contains outdated published snippet counts`) + } + } +} + +function main() { + const errors = [] + + verifySourceToPageMapping(errors) + verifyPageToSourceMapping(errors) + verifyMetaAlignment(errors) + verifyPublishedCounts(errors) + + if (errors.length > 0) { + console.error('Consistency check failed:\n') + for (const error of errors) { + console.error(`- ${error}`) + } + process.exit(1) + } + + console.log('Consistency check passed.') +} + +main()