@bschauer/webtools-mcp-server
Version:
MCP server providing web analysis tools including screenshot, debug, performance, security, accessibility, SEO, and asset optimization capabilities
285 lines (252 loc) • 11.1 kB
JavaScript
/**
* CSS Coverage Analyzer
* Analyzes CSS coverage data from Chrome DevTools Protocol
*/
import { logInfo, logError } from "../../../utils/logging.js";
import { URL } from "url";
/**
* Analyze CSS coverage data
* @param {Array} cssCoverage - CSS coverage data from CDP
* @param {Object} options - Analysis options
* @param {string} options.url - The URL of the page being analyzed
* @param {boolean} options.includeThirdParty - Whether to include third-party stylesheets in the analysis
* @returns {Promise<Object>} CSS coverage analysis
*/
export async function analyzeCSSCoverage(cssCoverage, options = {}) {
const { url, includeThirdParty = true } = options;
try {
if (!cssCoverage || !Array.isArray(cssCoverage) || cssCoverage.length === 0) {
return {
error: "No CSS coverage data available",
totalBytes: 0,
unusedBytes: 0,
unusedPercentage: 0,
files: [],
unusedByFile: [],
thirdPartyAnalysis: {
totalBytes: 0,
unusedBytes: 0,
unusedPercentage: 0,
files: [],
},
recommendations: [],
};
}
// Parse the base URL to determine first-party vs third-party
const baseUrl = new URL(url);
const baseDomain = baseUrl.hostname;
// Process each stylesheet
const stylesheetAnalysis = await Promise.all(
cssCoverage.map(async (rule) => {
// Extract stylesheet URL from header
const styleSheetId = rule.styleSheetId;
const header = rule.header || {};
const stylesheetUrl = header.sourceURL || "(inline stylesheet)";
let isThirdParty = false;
// Determine if stylesheet is third-party
if (stylesheetUrl && stylesheetUrl !== "(inline stylesheet)") {
try {
const stylesheetDomain = new URL(stylesheetUrl).hostname;
isThirdParty = stylesheetDomain !== baseDomain && !stylesheetDomain.endsWith(`.${baseDomain}`);
} catch (e) {
// If URL parsing fails, consider it first-party (could be a data URL or relative path)
isThirdParty = false;
}
}
// Calculate coverage statistics
const startOffset = rule.startOffset || 0;
const endOffset = rule.endOffset || 0;
const used = rule.used || false;
const ruleLength = endOffset - startOffset;
const unusedBytes = used ? 0 : ruleLength;
return {
styleSheetId,
url: stylesheetUrl,
isThirdParty,
startOffset,
endOffset,
used,
ruleLength,
unusedBytes,
};
})
);
// Group by stylesheet
const stylesheetMap = new Map();
for (const rule of stylesheetAnalysis) {
if (!stylesheetMap.has(rule.url)) {
stylesheetMap.set(rule.url, {
url: rule.url,
isThirdParty: rule.isThirdParty,
totalBytes: 0,
unusedBytes: 0,
rules: [],
});
}
const stylesheet = stylesheetMap.get(rule.url);
stylesheet.totalBytes += rule.ruleLength;
stylesheet.unusedBytes += rule.unusedBytes;
stylesheet.rules.push(rule);
}
// Convert map to array
const stylesheets = Array.from(stylesheetMap.values());
// Calculate percentages
for (const stylesheet of stylesheets) {
stylesheet.unusedPercentage = stylesheet.totalBytes > 0 ? (stylesheet.unusedBytes / stylesheet.totalBytes) * 100 : 0;
}
// Filter stylesheets based on third-party setting
const filteredStylesheets = includeThirdParty ? stylesheets : stylesheets.filter((stylesheet) => !stylesheet.isThirdParty);
// Calculate overall statistics
const totalBytes = filteredStylesheets.reduce((sum, stylesheet) => sum + stylesheet.totalBytes, 0);
const unusedBytes = filteredStylesheets.reduce((sum, stylesheet) => sum + stylesheet.unusedBytes, 0);
const unusedPercentage = totalBytes > 0 ? (unusedBytes / totalBytes) * 100 : 0;
// Sort stylesheets by unused bytes (descending)
const sortedStylesheets = [...filteredStylesheets].sort((a, b) => b.unusedBytes - a.unusedBytes);
// Analyze third-party stylesheets separately
const thirdPartyStylesheets = stylesheets.filter((stylesheet) => stylesheet.isThirdParty);
const thirdPartyTotalBytes = thirdPartyStylesheets.reduce((sum, stylesheet) => sum + stylesheet.totalBytes, 0);
const thirdPartyUnusedBytes = thirdPartyStylesheets.reduce((sum, stylesheet) => sum + stylesheet.unusedBytes, 0);
const thirdPartyUnusedPercentage = thirdPartyTotalBytes > 0 ? (thirdPartyUnusedBytes / thirdPartyTotalBytes) * 100 : 0;
// Generate recommendations
const recommendations = generateCSSRecommendations(sortedStylesheets, thirdPartyStylesheets);
return {
totalBytes,
unusedBytes,
unusedPercentage,
files: filteredStylesheets.map((stylesheet) => ({
url: stylesheet.url,
isThirdParty: stylesheet.isThirdParty,
totalBytes: stylesheet.totalBytes,
unusedBytes: stylesheet.unusedBytes,
unusedPercentage: stylesheet.unusedPercentage,
})),
unusedByFile: sortedStylesheets.slice(0, 10).map((stylesheet) => ({
url: stylesheet.url,
isThirdParty: stylesheet.isThirdParty,
totalBytes: stylesheet.totalBytes,
unusedBytes: stylesheet.unusedBytes,
unusedPercentage: stylesheet.unusedPercentage,
unusedRules: stylesheet.rules.filter((rule) => !rule.used).length,
totalRules: stylesheet.rules.length,
})),
thirdPartyAnalysis: {
totalBytes: thirdPartyTotalBytes,
unusedBytes: thirdPartyUnusedBytes,
unusedPercentage: thirdPartyUnusedPercentage,
files: thirdPartyStylesheets.map((stylesheet) => ({
url: stylesheet.url,
totalBytes: stylesheet.totalBytes,
unusedBytes: stylesheet.unusedBytes,
unusedPercentage: stylesheet.unusedPercentage,
})),
},
recommendations,
};
} catch (error) {
logError("css_coverage_analyzer", "Failed to analyze CSS coverage", error);
return {
error: `Failed to analyze CSS coverage: ${error.message}`,
totalBytes: 0,
unusedBytes: 0,
unusedPercentage: 0,
files: [],
unusedByFile: [],
thirdPartyAnalysis: {
totalBytes: 0,
unusedBytes: 0,
unusedPercentage: 0,
files: [],
},
recommendations: [],
};
}
}
/**
* Generate recommendations based on CSS coverage analysis
* @param {Array} sortedStylesheets - Stylesheets sorted by unused bytes (descending)
* @param {Array} thirdPartyStylesheets - Third-party stylesheets
* @returns {Array} Recommendations
*/
function generateCSSRecommendations(sortedStylesheets, thirdPartyStylesheets) {
const recommendations = [];
// Check for large unused stylesheets
const largeUnusedStylesheets = sortedStylesheets.filter((stylesheet) => stylesheet.unusedPercentage > 50 && stylesheet.totalBytes > 10000);
if (largeUnusedStylesheets.length > 0) {
recommendations.push({
type: "css_optimization",
title: "Optimize CSS delivery",
description: `Found ${largeUnusedStylesheets.length} stylesheets with more than 50% unused CSS. Consider implementing critical CSS and deferring non-critical styles.`,
impact: "high",
stylesheets: largeUnusedStylesheets.slice(0, 5).map((stylesheet) => ({
url: stylesheet.url,
unusedPercentage: stylesheet.unusedPercentage,
unusedBytes: stylesheet.unusedBytes,
})),
});
}
// Check for third-party stylesheets with high unused code
const inefficientThirdParty = thirdPartyStylesheets.filter((stylesheet) => stylesheet.unusedPercentage > 70 && stylesheet.totalBytes > 5000);
if (inefficientThirdParty.length > 0) {
recommendations.push({
type: "third_party_css_optimization",
title: "Optimize third-party CSS",
description: `Found ${inefficientThirdParty.length} third-party stylesheets with more than 70% unused CSS. Consider using a CSS purifier or loading these styles asynchronously.`,
impact: "medium",
stylesheets: inefficientThirdParty.slice(0, 5).map((stylesheet) => ({
url: stylesheet.url,
unusedPercentage: stylesheet.unusedPercentage,
unusedBytes: stylesheet.unusedBytes,
})),
});
}
// Check for potential CSS purging opportunities
const purgeCandidates = sortedStylesheets.filter((stylesheet) => !stylesheet.isThirdParty && stylesheet.unusedPercentage > 60 && (stylesheet.url.includes("styles") || stylesheet.url.includes("css") || stylesheet.url.includes("main")));
if (purgeCandidates.length > 0) {
recommendations.push({
type: "css_purging",
title: "Implement CSS purging",
description: `Found ${purgeCandidates.length} stylesheets with significant unused CSS. Consider implementing PurgeCSS or similar tools in your build process.`,
impact: "high",
stylesheets: purgeCandidates.slice(0, 5).map((stylesheet) => ({
url: stylesheet.url,
unusedPercentage: stylesheet.unusedPercentage,
unusedBytes: stylesheet.unusedBytes,
})),
});
}
// Check for inline stylesheets with high unused code
const inefficientInlineStyles = sortedStylesheets.filter((stylesheet) => stylesheet.url === "(inline stylesheet)" && stylesheet.unusedPercentage > 50 && stylesheet.totalBytes > 5000);
if (inefficientInlineStyles.length > 0) {
recommendations.push({
type: "inline_css_optimization",
title: "Optimize inline styles",
description: `Found ${inefficientInlineStyles.length} large inline stylesheets with significant unused CSS. Consider refactoring these styles or moving them to external files with proper loading strategies.`,
impact: "medium",
stylesheets: inefficientInlineStyles.slice(0, 3).map((stylesheet) => ({
unusedPercentage: stylesheet.unusedPercentage,
unusedBytes: stylesheet.unusedBytes,
totalBytes: stylesheet.totalBytes,
})),
});
}
// General recommendation if overall unused CSS is high
if (sortedStylesheets.length > 0) {
const totalBytes = sortedStylesheets.reduce((sum, stylesheet) => sum + stylesheet.totalBytes, 0);
const unusedBytes = sortedStylesheets.reduce((sum, stylesheet) => sum + stylesheet.unusedBytes, 0);
const overallUnusedPercentage = totalBytes > 0 ? (unusedBytes / totalBytes) * 100 : 0;
if (overallUnusedPercentage > 50) {
recommendations.push({
type: "general_css_optimization",
title: "Implement critical CSS",
description: `Overall, ${overallUnusedPercentage.toFixed(1)}% of CSS is unused on this page. Consider implementing critical CSS for above-the-fold content and lazy-loading the rest.`,
impact: "high",
details: {
totalBytes,
unusedBytes,
unusedPercentage: overallUnusedPercentage,
},
});
}
}
return recommendations;
}