Created
April 4, 2025 14:00
-
-
Save mizchi/c72e57a0e0f87c44e8b6cabd338edc2f to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/** | |
* $ 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