Skip to content

Instantly share code, notes, and snippets.

@mizchi
Created April 4, 2025 14:00
Show Gist options
  • Save mizchi/c72e57a0e0f87c44e8b6cabd338edc2f to your computer and use it in GitHub Desktop.
Save mizchi/c72e57a0e0f87c44e8b6cabd338edc2f to your computer and use it in GitHub Desktop.
/**
* $ npx tsx asset-cov.ts https://www.cnn.co.jp/fringe/35230901.html https://www.cnn.co.jp --filter '.css'
--- Final Coverage Report ---
--- CSS Files ---
File: https://www.cnn.co.jp/static/css/atlanta/responsive.css
Total size: 13000 bytes
Estimated used size (across pages): 3943 bytes
Estimated Coverage: 30.33%
File: https://www.cnn.co.jp/static/css/atlanta/common.css
Total size: 53959 bytes
Estimated used size (across pages): 26358 bytes
Estimated Coverage: 48.85%
--- JavaScript Files ---
No JavaScript files matched the filter or were found.
*/
import puppeteer, {
Browser,
Page,
JSCoverageEntry,
CoverageEntry,
} from "puppeteer";
import { parseArgs } from "node:util";
import * as css from "css"; // Use types from 'css' package
import { URL } from "node:url";
// --- Type Definitions ---
type CoverageRange = { start: number; end: number };
type JSCoverageRange = {
startOffset: number;
endOffset: number;
count: number;
};
interface CssFileInfo {
url: string; // Normalized URL
text: string;
ast: css.Stylesheet | undefined; // Use css.Stylesheet
allSelectors: Set<string>;
selectorToRuleMap: Map<string, css.Rule>; // Use css.Rule
globallyUsedSelectors: Set<string>;
rangesAcrossPages: CoverageRange[];
totalBytes: number;
}
interface JsFileInfo {
url: string; // Normalized URL
text: string;
rangesAcrossPages: JSCoverageRange[];
totalBytes: number;
}
// --- Helper Functions ---
function calculateUsedBytes(
ranges: (CoverageRange | JSCoverageRange)[],
totalBytes: number
): number {
let usedBytes = 0;
// TODO: Implement proper range merging
ranges.forEach((range) => {
if ("start" in range && "end" in range) {
usedBytes += range.end - range.start;
} else if ("startOffset" in range && "endOffset" in range) {
usedBytes += range.endOffset - range.startOffset;
}
});
return Math.min(usedBytes, totalBytes);
}
function normalizeSelector(selector: string): string {
return selector.replace(/\s+/g, " ").trim();
}
// Removes query parameters from a URL
function normalizeUrlForCacheBusting(url: string): string {
try {
const parsedUrl = new URL(url);
parsedUrl.search = ""; // Remove query string
return parsedUrl.toString();
} catch (e) {
console.warn(
`[WARN] Failed to parse URL for normalization: ${url}. Returning original.`
);
return url; // Return original if parsing fails
}
}
// Calculates offset from line/column in text
function getOffset(text: string, line: number, column: number): number {
const lines = text.split("\n");
if (line < 1 || line > lines.length || column < 1) {
throw new Error(`Invalid position: line ${line}, column ${column}`);
}
// Check if the requested column is beyond the line length
if (column > lines[line - 1].length + 1) {
// This can happen with CSS rules ending right at the end of a line before the newline
column = lines[line - 1].length + 1;
}
let offset = 0;
for (let i = 0; i < line - 1; i++) {
offset += lines[i].length + 1; // +1 for newline character
}
offset += column - 1;
return offset;
}
// --- Coverage Analyzer Class ---
class CoverageAnalyzer {
private cssFileInfos = new Map<string, CssFileInfo>();
private jsFileInfos = new Map<string, JsFileInfo>();
private globallyUsedCssSelectors = new Set<string>();
private filterPattern: string | undefined;
private showUnusedSelectors: boolean;
constructor(filterPattern: string | undefined, showUnusedSelectors: boolean) {
this.filterPattern = filterPattern;
this.showUnusedSelectors = showUnusedSelectors;
}
public async runAnalysis(urls: string[]): Promise<void> {
let browser: Browser | null = null;
try {
browser = await puppeteer.launch();
const page = await browser.newPage();
for (const url of urls) {
await this._analyzePage(page, url);
}
} catch (error) {
console.error("Error during analysis run:", error);
} finally {
if (browser) {
await browser.close();
}
}
this._generateReport();
}
private async _analyzePage(page: Page, url: string): Promise<void> {
console.log(`\nNavigating to ${url} to gather coverage...`);
try {
await Promise.all([
page.coverage.startJSCoverage({ resetOnNavigation: false }),
page.coverage.startCSSCoverage({ resetOnNavigation: false }),
]);
await page.goto(url, { waitUntil: "networkidle0" });
const [jsCoverage, cssCoverage] = await Promise.all([
page.coverage.stopJSCoverage(),
page.coverage.stopCSSCoverage(),
]);
this._processCoverageData(url, jsCoverage, cssCoverage);
} catch (error: any) {
console.error(` Error processing page ${url}: ${error.message}`);
// Attempt to stop coverage even if page load failed
try {
await page.coverage.stopJSCoverage();
} catch {}
try {
await page.coverage.stopCSSCoverage();
} catch {}
}
}
private _processCoverageData(
pageUrl: string,
jsCoverage: JSCoverageEntry[],
cssCoverage: CoverageEntry[]
): void {
console.log(`\nProcessing coverage data for page: ${pageUrl}`);
const newlyUsedSelectorsOnPage = new Set<string>();
// Process CSS
for (const entry of cssCoverage) {
// Filter based on the *original* URL before normalization
if (this._shouldSkipEntry(entry.url, entry.text)) continue;
// Normalize URL for storage and aggregation
const normalizedUrl = normalizeUrlForCacheBusting(entry.url!); // entry.url is guaranteed string here
this._processCssEntry(
{ ...entry, url: normalizedUrl },
newlyUsedSelectorsOnPage
);
}
if (newlyUsedSelectorsOnPage.size > 0) {
console.log(` [CSS] Newly used selectors on this page:`);
newlyUsedSelectorsOnPage.forEach((sel) => console.log(` - ${sel}`));
} else {
console.log(` [CSS] No *new* selectors used on this page.`);
}
// Process JS
for (const entry of jsCoverage) {
// Filter based on the *original* URL before normalization
if (this._shouldSkipEntry(entry.url, entry.text)) continue;
// Normalize URL for storage and aggregation
const normalizedUrl = normalizeUrlForCacheBusting(entry.url!); // entry.url is guaranteed string here
this._processJsEntry({ ...entry, url: normalizedUrl });
}
console.log(`Finished processing coverage data for page: ${pageUrl}`);
}
private _shouldSkipEntry(
url: string | undefined,
text: string | undefined
): boolean {
// Ensure url is checked before using includes
// Filtering happens on the original URL
return (
!url ||
url.startsWith("data:") ||
!text ||
text.trim() === "" ||
(!!this.filterPattern && !url.includes(this.filterPattern)) // Check filterPattern existence
);
}
// entry.url is now the NORMALIZED URL
private _processCssEntry(
entry: CoverageEntry & { url: string },
newlyUsedSelectorsOnPage: Set<string>
): void {
let fileInfo: CssFileInfo | undefined | null = this.cssFileInfos.get(
entry.url
); // Use normalized URL as key
if (fileInfo === undefined) {
// Check if it's not in the map yet
fileInfo = this._initializeCssFileInfo(entry); // Pass entry with normalized URL
if (fileInfo === null) {
// Check specifically for null from initialization failure
return; // Initialization failed, skip this entry
}
this.cssFileInfos.set(entry.url, fileInfo); // Use normalized URL as key
}
// At this point, fileInfo is guaranteed to be CssFileInfo
this._findUsedSelectorsInEntry(entry, fileInfo, newlyUsedSelectorsOnPage);
// Merge ranges (fileInfo is definitely CssFileInfo here)
const typedRanges: CoverageRange[] = entry.ranges.map((r) => ({
start: r.start,
end: r.end,
}));
fileInfo.rangesAcrossPages.push(...typedRanges);
}
// entry.url is the NORMALIZED URL
private _initializeCssFileInfo(
entry: CoverageEntry & { url: string }
): CssFileInfo | null {
// Return null on failure
let ast: css.Stylesheet | undefined;
try {
// Use entry.text for parsing, source can be the normalized url
ast = css.parse(entry.text, { source: entry.url });
} catch (error: any) {
console.warn(
` [CSS] Failed to parse ${entry.url}, skipping selector analysis. Error: ${error.message}`
);
return null; // Indicate failure
}
const allSelectors = new Set<string>();
const selectorToRuleMap = new Map<string, css.Rule>(); // Use css.Rule
if (ast?.stylesheet?.rules) {
ast.stylesheet.rules.forEach(
(rule: css.Rule | css.Comment | css.AtRule) => {
// Use css types
if (rule.type === "rule" && rule.selectors && rule.position) {
rule.selectors.forEach((selector: string) => {
const normalized = normalizeSelector(selector);
allSelectors.add(normalized);
selectorToRuleMap.set(normalized, rule as css.Rule); // Cast is safe here
});
}
}
);
}
return {
url: entry.url, // Store normalized URL
text: entry.text,
ast: ast,
allSelectors: allSelectors,
selectorToRuleMap: selectorToRuleMap,
globallyUsedSelectors: new Set<string>(),
rangesAcrossPages: [],
totalBytes: entry.text.length,
};
}
// entry.url is the NORMALIZED URL
private _findUsedSelectorsInEntry(
entry: CoverageEntry & { url: string },
fileInfo: CssFileInfo,
newlyUsedSelectorsOnPage: Set<string>
): void {
if (!fileInfo.ast?.stylesheet?.rules) return;
fileInfo.ast.stylesheet.rules.forEach(
(rule: css.Rule | css.Comment | css.AtRule) => {
// Use css types
if (rule.type !== "rule" || !rule.selectors || !rule.position) {
return;
}
// Ensure start and end positions, and their line/column are valid numbers
if (
rule.position.start &&
typeof rule.position.start.line === "number" &&
typeof rule.position.start.column === "number" &&
rule.position.end &&
typeof rule.position.end.line === "number" &&
typeof rule.position.end.column === "number"
) {
let startOffset: number | undefined = undefined;
let endOffset: number | undefined = undefined;
try {
// Calculate offsets using the helper function
startOffset = getOffset(
entry.text,
rule.position.start.line,
rule.position.start.column
);
endOffset = getOffset(
entry.text,
rule.position.end.line,
rule.position.end.column
);
// Proceed only if both offsets were successfully calculated
const isUsed = entry.ranges.some((range) => {
if (
typeof range.start !== "number" ||
typeof range.end !== "number"
) {
console.warn(
`[WARN] Invalid range encountered in ${entry.url}:`,
range
);
return false;
}
// Use non-null assertion because calculation succeeded
return (
Math.max(startOffset!, range.start) <
Math.min(endOffset!, range.end)
);
});
if (isUsed) {
// Use non-null assertion for selectors as type is 'rule'
rule.selectors.forEach((selector: string) => {
const normalized = normalizeSelector(selector);
if (!this.globallyUsedCssSelectors.has(normalized)) {
newlyUsedSelectorsOnPage.add(normalized);
}
fileInfo.globallyUsedSelectors.add(normalized);
this.globallyUsedCssSelectors.add(normalized);
});
}
} catch (e: any) {
console.warn(
`[WARN] Could not calculate offset for rule in ${entry.url} (L${rule.position.start.line}): ${e.message}`
);
// Skip this rule if offsets can't be calculated
}
} else {
// Log if position data is missing or invalid
console.warn(
`[WARN] Skipping rule due to missing/invalid position data in ${entry.url}:`,
rule.selectors?.join(", ")
);
}
}
);
}
// entry.url is the NORMALIZED URL
private _processJsEntry(entry: JSCoverageEntry & { url: string }): void {
let fileInfo = this.jsFileInfos.get(entry.url); // Use normalized URL as key
if (!fileInfo) {
fileInfo = {
url: entry.url, // Store normalized URL
text: entry.text,
rangesAcrossPages: [],
totalBytes: entry.text.length,
};
this.jsFileInfos.set(entry.url, fileInfo); // Use normalized URL as key
}
const typedRanges: JSCoverageRange[] = entry.ranges.map((r) => ({
startOffset: r.start,
endOffset: r.end,
count: (r as any).count ?? 1,
}));
fileInfo.rangesAcrossPages.push(...typedRanges);
}
private _generateReport(): void {
console.log("\n\n--- Final Coverage Report ---");
this._generateCssReport();
this._generateJsReport();
}
private _generateCssReport(): void {
console.log("\n--- CSS Files ---");
if (this.cssFileInfos.size === 0) {
console.log("No CSS files matched the filter or were found.");
return;
}
this.cssFileInfos.forEach((fileInfo) => {
// fileInfo.url is already normalized
console.log(`\nFile: ${fileInfo.url}`);
if (!fileInfo.ast) {
console.log(" Skipped analysis due to parsing error.");
return;
}
if (this.showUnusedSelectors) {
this._reportUnusedSelectors(fileInfo);
} else {
this._reportCoveragePercentage(fileInfo);
}
});
}
private _reportUnusedSelectors(fileInfo: CssFileInfo): void {
const unusedSelectorStrings = [...fileInfo.allSelectors].filter(
(selector) => !fileInfo.globallyUsedSelectors.has(selector)
);
const unusedSelectorsWithSize: Array<{ selector: string; size: number }> =
[];
unusedSelectorStrings.forEach((selector) => {
const rule = fileInfo.selectorToRuleMap.get(selector);
let size = 0;
if (rule) {
try {
// Use css.Rule type here
const ruleString = css.stringify(
{
type: "stylesheet",
stylesheet: { rules: [rule as css.Rule], parsingErrors: [] },
},
{ indent: "", compress: true }
);
size = ruleString.length;
} catch (stringifyError: any) {
console.warn(
`[WARN] Could not stringify rule for selector "${selector}": ${stringifyError.message}`
);
size = 0;
}
}
unusedSelectorsWithSize.push({ selector, size });
});
unusedSelectorsWithSize.sort((a, b) => b.size - a.size);
console.log(` Total selectors: ${fileInfo.allSelectors.size}`);
console.log(
` Globally used selectors (in this file): ${fileInfo.globallyUsedSelectors.size}`
);
console.log(
` Unused selectors (in this file): ${unusedSelectorsWithSize.length}`
);
if (unusedSelectorsWithSize.length > 0) {
console.log(` Unused Selectors (sorted by rule size, descending):`);
const maxToShow = 50;
unusedSelectorsWithSize
.slice(0, maxToShow)
.forEach(({ selector, size }) =>
console.log(` ${selector} (${size} bytes)`)
);
if (unusedSelectorsWithSize.length > maxToShow) {
console.log(
` ... and ${
unusedSelectorsWithSize.length - maxToShow
} more unused selectors`
);
}
}
}
private _reportCoveragePercentage(fileInfo: CssFileInfo | JsFileInfo): void {
const usedBytes = calculateUsedBytes(
fileInfo.rangesAcrossPages,
fileInfo.totalBytes
);
const coveragePercentage =
fileInfo.totalBytes > 0
? ((usedBytes / fileInfo.totalBytes) * 100).toFixed(2)
: "N/A";
console.log(` Total size: ${fileInfo.totalBytes} bytes`);
console.log(` Estimated used size (across pages): ${usedBytes} bytes`);
console.log(` Estimated Coverage: ${coveragePercentage}%`);
}
private _generateJsReport(): void {
console.log("\n--- JavaScript Files ---");
if (this.jsFileInfos.size === 0) {
console.log("No JavaScript files matched the filter or were found.");
return;
}
this.jsFileInfos.forEach((fileInfo) => {
// fileInfo.url is already normalized
console.log(`\nFile: ${fileInfo.url}`);
this._reportCoveragePercentage(fileInfo);
});
}
}
// --- Argument Parsing and Main Execution ---
function printHelp() {
console.log(`Usage: npx tsx asset-cov-v0.refactored.ts <url1> [url2...] [--unused|-u] [--filter|-f <pattern>] [--help|-h]
Arguments:
<url...> One or more URLs to analyze for CSS and JS coverage (required).
Options:
--unused, -u Show detailed list of unused CSS selectors sorted by rule size.
--filter, -f Only analyze files whose URLs contain the given string <pattern>.
--help, -h Show this help message.
`);
}
async function main() {
try {
const { values, positionals } = parseArgs({
options: {
unused: { type: "boolean", short: "u", default: false },
help: { type: "boolean", short: "h", default: false },
filter: { type: "string", short: "f" },
},
allowPositionals: true,
strict: false, // Keep strict false to avoid issues with unknown args if any
});
if (values.help || positionals.length === 0) {
printHelp();
process.exit(0);
}
const targetUrls = positionals;
// Explicitly ensure boolean type for showUnused
const showUnused: boolean = !!values.unused;
const filterPattern = values.filter as string | undefined;
// Validate URLs
for (const url of targetUrls) {
try {
new URL(url);
} catch (_) {
console.error(`Error: Invalid URL provided: ${url}`);
process.exit(1);
}
}
const analyzer = new CoverageAnalyzer(filterPattern, showUnused);
await analyzer.runAnalysis(targetUrls);
} catch (err: any) {
console.error("Error:", err.message);
// Ensure help is printed for argument parsing errors
if (
err.code === "ERR_PARSE_ARGS_UNKNOWN_OPTION" ||
err.code === "ERR_PARSE_ARGS_INVALID_OPTION_VALUE"
) {
printHelp();
}
process.exit(1);
}
}
main();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment