├── .gitignore ├── README.md ├── assets ├── readme-1.png └── readme-2.png ├── jest.config.js ├── package.json ├── src ├── cli.js ├── css-trimmer.js ├── default-config.js └── globals.d.ts ├── test ├── __snapshots__ │ └── snapshot-test.js.snap ├── fixtures │ ├── 1 │ │ └── index.html │ ├── 2 │ │ ├── index.html │ │ └── style.css │ ├── 3 │ │ ├── a.html │ │ ├── b.html │ │ └── style.css │ ├── 4 │ │ ├── config.js │ │ └── index.html │ ├── 5 │ │ └── index.html │ ├── 6 │ │ └── index.html │ ├── 7 │ │ ├── index-2.html │ │ └── index.html │ ├── 8 │ │ └── index.html │ └── lh │ │ ├── config.js │ │ ├── report-1.html │ │ ├── report-2.html │ │ └── report-6.html ├── generate-lighthouse-fixtures.js └── snapshot-test.js ├── tsconfig.json └── yarn.lock /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .vscode 3 | .DS_Store 4 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # css-trimmer 2 | 3 | `css-trimmer` identifies the unused properties in your CSS. 4 | 5 | ## Wait, doesn't Chrome DevTools do that already? 6 | 7 | Yes, kinda. Here are two reasons for this tool: 8 | 9 | The Coverage panel in Chrome DevTools will consider any rule (`.some-rule { ... }`) as "used" if 10 | any node on the page uses the selectors. It doesn't catch if a property within a rule is always 11 | superceded by some other rule. In other words, a rule is either all-covered or all-uncovered. 12 | 13 | For example, the property in `div {}` should be considered uncovered, but Chrome DevTools doesn't 14 | know. 15 | 16 | ![](./assets/readme-1.png) 17 | ![](./assets/readme-2.png) 18 | 19 | `css-trimmer` does: 20 | 21 | ``` 22 | ========= 23 | file:///Users/cjamcl/src/me/css-trimmer/test/fixtures/1/index.html (3 unused declarations) 24 | ========= 25 | :5 div { color: red; /* unused! */ } 26 | :13 .never-used { color: blue; /* unused! */ } 27 | :14 .never-used { display: block; /* unused! */ } 28 | 29 | ----- 30 | total unused declarations: 3 31 | ``` 32 | 33 | Additionally, this tool checks style coverage by considering many variants of a page, providing 34 | general strategies (try this viewport, try this color scheme ...), and also provides 35 | a way to configure your own variants. 36 | 37 | ## Usage 38 | 39 | Package is available on npm as `css-trimmer`. 40 | 41 | ``` 42 | yarn global add puppeteer css-trimmer 43 | ``` 44 | 45 | ``` 46 | css-trimmer [urlsOrFiles] 47 | css-trimmer path/to/page.html path/to/page2.html --viewports 500,500 48 | css-trimmer https://www.example.com 49 | 50 | Options: 51 | --help Show help [boolean] 52 | --version Show version number [boolean] 53 | --color-scheme Add a collection while emulating 54 | `prefers-color-scheme` to the value given [string] 55 | --config-path Loads these options from disk. Supports .js and 56 | .json [string] 57 | --debug Generate debug data, such as which collections were 58 | redundant [boolean] 59 | --disable-default-config Disable loading of the default configuration 60 | [boolean] 61 | --only-collections Collections to process - all others are skipped 62 | [array] 63 | --output Format for the generated the report 64 | [choices: "json", "text"] [default: "text"] 65 | --quiet Suppress logging output to stderr 66 | [boolean] [default: false] 67 | --skip-collections Collections to skip - all others are processed 68 | [array] 69 | --viewports viewports "width,height". Adds a collection while 70 | emulate each viewport. [array] 71 | ``` 72 | 73 | `*.js` configs can define `afterNavigation(context)`, which provides access to the puppeteer 74 | page handler. It runs before any collection for a URL. 75 | 76 | Besides the CLI, there is the Node API. 77 | 78 | ```js 79 | const cssTrimmer('css-trimmer'); 80 | const context = await cssTrimmer.start(); // see src/css-trimmer.js for context type definition 81 | 82 | await context.navigate('https://www.example.com'); 83 | await context.collect('https://www.example.com basic'); 84 | 85 | const report = context.finish(context); 86 | 87 | // Or, use the same runner that the CLI uses: 88 | const report = await cssTrimmer.run(urls, options); 89 | ``` 90 | 91 | ## How it works 92 | 93 | Uses Puppeteer. Loads each URL and collects the active style declarations for every relevant node 94 | in the DOM. The range within the style sheet for each applied property is recorded. Every mass-reading 95 | of the DOM is called a collection. After all the URLs and each variant of collections runs, the recorded 96 | property sets are combined. Every property not in this set is flagged. 97 | 98 | Every collection takes a few seconds, and with all the URLs and variants the program can take painfully long. 99 | Running with `--debug` will determine which of the collections are redundant, and `--only-collection` will allow 100 | you to skip the rest. 101 | 102 | ## Future work 103 | 104 | * Flesh out `afterNavigation`, maybe provide more lifecycle methods / easy way to add more variants. Using against `lighthouse` report should help flesh this out 105 | * Variables 106 | * Upstream property-level coverage to Chrome DevTools 107 | * Output text format doesn't work with minified CSS 108 | -------------------------------------------------------------------------------- /assets/readme-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/connorjclark/css-trimmer/acab2aa32cd419943197de56b8a7e3894bd93c9c/assets/readme-1.png -------------------------------------------------------------------------------- /assets/readme-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/connorjclark/css-trimmer/acab2aa32cd419943197de56b8a7e3894bd93c9c/assets/readme-2.png -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = { 4 | collectCoverage: false, 5 | testEnvironment: 'node', 6 | testMatch: [ 7 | '**/test/**/*-test.js', 8 | ], 9 | }; 10 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "css-trimmer", 3 | "version": "0.1.0", 4 | "main": "src/css-trimmer.js", 5 | "bin": { 6 | "css-trimmer": "./src/cli.js" 7 | }, 8 | "files": [ 9 | "src" 10 | ], 11 | "license": "MIT", 12 | "dependencies": { 13 | "css": "^2.2.4", 14 | "yargs": "^14.2.0" 15 | }, 16 | "peerDependencies": { 17 | "puppeteer": "^2.0.0" 18 | }, 19 | "devDependencies": { 20 | "@types/css": "^0.0.31", 21 | "@types/jest": "^24.0.23", 22 | "@types/puppeteer": "^1.20.2", 23 | "devtools-protocol": "^0.0.715684", 24 | "jest": "^24.9.0", 25 | "lighthouse": "^5.6.0", 26 | "puppeteer": "^2.0.0", 27 | "typescript": "^3.7.2" 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/cli.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const path = require('path'); 4 | const yargs = require('yargs'); 5 | const cssTrimmer = require('./css-trimmer.js'); 6 | 7 | /** @typedef {import('./css-trimmer.js').Options} Options */ 8 | 9 | /** @type {any} */ 10 | const argv = yargs 11 | .usage('css-trimmer [urlsOrFiles] ') 12 | .usage('css-trimmer path/to/page.html path/to/page2.html --viewports 500,500') 13 | .usage('css-trimmer https://www.example.com') 14 | 15 | .describe({ 16 | 'color-scheme': 'Add a collection while emulating `prefers-color-scheme` to the value given', 17 | 'config-path': 'Loads these options from disk. Supports .js and .json', 18 | 'debug': 'Generate debug data, such as which collections were redundant', 19 | 'disable-default-config': 'Disable loading of the default configuration', 20 | 'only-collections': 'Collections to process - all others are skipped', 21 | 'output': 'Format for the generated the report', 22 | 'quiet': 'Suppress logging output to stderr', 23 | 'skip-collections': 'Collections to skip - all others are processed', 24 | 'viewports': 'viewports "width,height". Adds a collection while emulate each viewport.', 25 | }) 26 | 27 | .boolean([ 28 | 'debug', 29 | 'disable-default-config', 30 | 'quiet', 31 | ]) 32 | .array([ 33 | 'only-collections', 34 | 'skip-collections', 35 | 'viewports', 36 | ]) 37 | .string([ 38 | 'color-scheme', 39 | 'config-path', 40 | ]) 41 | 42 | .choices('output', ['json', 'text']) 43 | 44 | .default('output', 'text') 45 | .default('quiet', false) 46 | 47 | .argv; 48 | 49 | /** 50 | * @param {any} target 51 | * @param {any} config 52 | */ 53 | function mergeConfigInto(target, config) { 54 | for (const [key, value] of Object.entries(config)) { 55 | if (value === undefined) continue; 56 | 57 | if (Array.isArray(target[key])) { 58 | target[key].push(...value); 59 | } else { 60 | target[key] = value; 61 | } 62 | } 63 | 64 | return target; 65 | } 66 | 67 | async function main() { 68 | /** @type {Options} */ 69 | let options = {}; 70 | 71 | const configPath = argv.configPath || !argv.disableDefaultConfig && (__dirname + '/default-config'); 72 | if (configPath) { 73 | if (configPath) { 74 | options = require(path.resolve(configPath)); 75 | } else { 76 | throw new Error('unexpected config path'); 77 | } 78 | } 79 | 80 | /** @type {Options} */ 81 | let cliOptions = { 82 | colorScheme: argv.colorScheme, 83 | debug: argv.debug, 84 | onlyCollections: argv.onlyCollections, 85 | skipCollections: argv.skipCollections, 86 | quiet: argv.quiet, 87 | viewports: argv.viewports && argv.viewports.map(/** @param {string} viewport */(viewport) => { 88 | const [width, height] = viewport.split(',').map((n) => parseInt(n, 10)); 89 | return { width, height }; 90 | }), 91 | }; 92 | 93 | mergeConfigInto(options, cliOptions); 94 | const report = await cssTrimmer.run(argv._, options); 95 | 96 | if (argv.output === 'text') { 97 | console.log(report.output); 98 | } else { 99 | console.log(JSON.stringify(report, null, 2)); 100 | } 101 | 102 | // Avoid the annoying stdout truncation on process exit. 103 | setTimeout(() => process.exit(report.output ? 1 : 0), 10); 104 | } 105 | 106 | main(); 107 | -------------------------------------------------------------------------------- /src/css-trimmer.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const puppeteer = require('puppeteer'); 3 | const css = require('css'); 4 | 5 | /** @typedef {import('puppeteer').Browser} Browser */ 6 | /** @typedef {import('puppeteer').Page} Page */ 7 | /** @typedef {Crdp.Protocol.CSS.CSSStyleSheetHeader} ProtocolStyleSheet */ 8 | /** @typedef {Crdp.Protocol.CSS.CSSProperty} ProtocolCSSProperty */ 9 | 10 | /** 11 | * @typedef Options 12 | * @property {boolean=} debug 13 | * @property {string=} colorScheme 14 | * @property {Array<{width: number, height: number}>=} viewports 15 | * @property {string[]=} onlyCollections 16 | * @property {string[]=} skipCollections 17 | * @property {boolean=} quiet 18 | * 19 | * @property {function(Context):void=} afterNavigation 20 | */ 21 | 22 | /** 23 | * @typedef Context 24 | * @property {Browser} browser 25 | * @property {Collection[]} collections 26 | * @property {CurrentContext} current 27 | * @property {Options} options 28 | * @property {string[]} warnings 29 | * @property {function(...string): void} log 30 | * @property {function(string): Promise} navigate 31 | * @property {function(string): Promise} collect 32 | * @property {function(): Report} finish 33 | */ 34 | 35 | /** 36 | * @typedef CurrentContext 37 | * @property {Page} page 38 | * @property {CDPSession} client 39 | * @property {Map} styleSheetMap 40 | */ 41 | 42 | /** 43 | * @typedef Collection 44 | * @property {string} name 45 | * @property {CollectionEntry[]} entries 46 | */ 47 | 48 | /** 49 | * @typedef {{styleSheet: ProtocolStyleSheet, content: string, usedRanges: Set}} CollectionEntry 50 | */ 51 | 52 | /** 53 | * @typedef Report 54 | * @property {DebugInfo=} debug 55 | * @property {Array} styleSheets 56 | * @property {string} output 57 | * @property {string[]} warnings 58 | * @property {number} unusedDeclarationCount 59 | */ 60 | 61 | /** 62 | * @typedef DebugInfo 63 | * @property {string[]} collectionNames 64 | * @property {number[]} incrementalCoverage 65 | * @property {string[]} minimalCollectionNames 66 | * @property {number[]} usedRangeCounts 67 | */ 68 | 69 | /** 70 | * @param {number[]} arr 71 | */ 72 | function sum(arr) { 73 | return arr.reduce((acc, cur) => acc + cur, 0); 74 | } 75 | 76 | /** 77 | * Helps dedupe styleSheets across page loads. 78 | * @param {CollectionEntry[]} entries 79 | * @param {ProtocolStyleSheet} styleSheet 80 | */ 81 | function findOrMakeCollectionEntry(entries, styleSheet) { 82 | const entry = findCollectionEntry(entries, styleSheet); 83 | if (entry) return entry; 84 | 85 | const newEntry = { styleSheet, content: '', usedRanges: new Set() }; 86 | entries.push(newEntry); 87 | return newEntry; 88 | } 89 | 90 | /** 91 | * @param {CollectionEntry[]} entries 92 | * @param {ProtocolStyleSheet} styleSheet 93 | */ 94 | function findCollectionEntry(entries, styleSheet) { 95 | for (const entry of entries) { 96 | // The same id is obviously the same style sheet. 97 | if (styleSheet.styleSheetId === entry.styleSheet.styleSheetId) { 98 | return entry; 99 | } 100 | 101 | // Dito for the URL. 102 | if (styleSheet.sourceURL && !styleSheet.isInline && entry.styleSheet.sourceURL === styleSheet.sourceURL) { 103 | return entry; 104 | } 105 | } 106 | 107 | // Style sheets of the same length are probably the same styles. 108 | // Could just check if the content is the same, but that is expensive over the protocol. 109 | const potentialEntries = entries.filter(entry => entry.styleSheet.length === styleSheet.length); 110 | if (potentialEntries.length === 1) return potentialEntries[0]; 111 | if (potentialEntries.length > 1) { 112 | console.warn('found multiple potential styles for', styleSheet); 113 | } 114 | 115 | return null; 116 | } 117 | 118 | /** 119 | * @param {any} o 120 | */ 121 | function clone(o) { 122 | return JSON.parse(JSON.stringify(o)); 123 | } 124 | 125 | /** 126 | * @param {Collection[]} collections 127 | * @return {Collection} 128 | */ 129 | function combineCollections(collections) { 130 | /** @type {Collection} */ 131 | const combined = { 132 | name: 'combined', 133 | entries: [], 134 | }; 135 | 136 | for (const collection of collections) { 137 | for (const entry of collection.entries) { 138 | const combinedEntry = findCollectionEntry(combined.entries, entry.styleSheet); 139 | if (combinedEntry) { 140 | entry.usedRanges.forEach(range => combinedEntry.usedRanges.add(clone(range))); 141 | } else { 142 | combined.entries.push({ 143 | styleSheet: entry.styleSheet, 144 | content: entry.content, 145 | usedRanges: new Set(entry.usedRanges), 146 | }); 147 | } 148 | } 149 | } 150 | 151 | return combined; 152 | } 153 | 154 | /** @typedef {ProtocolCSSProperty & {range: Crdp.Protocol.CSS.SourceRange, disabled: false}} ValidProtocolCSSProperty */ 155 | 156 | /** 157 | * @param {ProtocolCSSProperty} property 158 | * @return {property is ValidProtocolCSSProperty} 159 | */ 160 | function isValidProperty(property) { 161 | // No range means this is an expanded style (authored css uses `border`, but makes 162 | // `border-top-width`, `border-right-width`, etc...) 163 | if (!property.range) return false; 164 | // Ignore declarations that are wrapped in comment blocks. 165 | if (property.disabled) return false; 166 | // TODO: check if variables are used. 167 | if (property.name.startsWith('--')) return false; 168 | 169 | return true; 170 | } 171 | 172 | /** 173 | * @param {any} data 174 | */ 175 | function findActiveStyles(data) { 176 | /** @type {Map} */ 177 | const propertyToRule = new Map(); 178 | 179 | for (const property of data.inlineStyle.cssProperties) { 180 | if (property.disabled) continue; 181 | propertyToRule.set(property.name, null); 182 | } 183 | 184 | for (let i = data.matchedCSSRules.length - 1; i >= 0; i--) { 185 | // TODO: do something with matchedSelectors? 186 | const rule = data.matchedCSSRules[i].rule; 187 | for (const property of rule.style.cssProperties) { 188 | if (!isValidProperty(property)) continue; 189 | // Been there, done that. 190 | if (propertyToRule.has(property.name)) continue; 191 | 192 | propertyToRule.set(property.name, { 193 | property, 194 | rule, 195 | }); 196 | } 197 | } 198 | 199 | return propertyToRule; 200 | } 201 | 202 | /** 203 | * @param {Options} options 204 | * @return {Promise} 205 | */ 206 | async function start(options) { 207 | /** @type {Context} */ 208 | const context = { 209 | browser: await puppeteer.launch(), 210 | collections: [], 211 | // @ts-ignore: This is set later. 212 | current: null, 213 | options, 214 | warnings: [], 215 | /** @param {...string} args */ 216 | log(...args) { 217 | if (!options.quiet) console.warn('INFO:', ...args); 218 | }, 219 | /** @param {string} url */ 220 | navigate(url) { return navigate(url, context) }, 221 | /** @param {string} name */ 222 | collect(name) { return collect(name, context) }, 223 | finish() { return finish(context) }, 224 | }; 225 | return context; 226 | } 227 | 228 | /** 229 | * @param {string} url 230 | * @param {Context} context 231 | */ 232 | async function navigate(url, context) { 233 | if (context.current) { 234 | await context.current.page.close(); 235 | } 236 | 237 | const page = await context.browser.newPage(); 238 | const client = /** @type {CDPSession} */ (await page.target().createCDPSession()); 239 | await client.send('DOM.enable'); 240 | await client.send('CSS.enable'); 241 | 242 | const styleSheetMap = new Map(); 243 | client.on('CSS.styleSheetAdded', styleSheetData => { 244 | styleSheetMap.set(styleSheetData.header.styleSheetId, styleSheetData.header); 245 | }); 246 | 247 | await page.goto(url); 248 | 249 | context.current = { 250 | page, 251 | client, 252 | styleSheetMap, 253 | }; 254 | 255 | if (context.options.afterNavigation) { 256 | await context.options.afterNavigation(context); 257 | } 258 | } 259 | 260 | /** 261 | * @param {Context} context 262 | */ 263 | async function getNodes(context) { 264 | const { current } = context; 265 | 266 | const flattenedDocument = await current.client.send('DOM.getFlattenedDocument', { depth: -1 }); 267 | const ignoreElements = [ 268 | 'circle', 269 | 'defs', 270 | 'g', 271 | 'HEAD', 272 | 'HTML', 273 | 'linearGradient', 274 | 'LINK', 275 | 'META', 276 | 'NOSCRIPT', 277 | 'path', 278 | 'rect', 279 | 'SCRIPT', 280 | 'stop', 281 | 'STYLE', 282 | 'svg', 283 | 'TEMPLATE', 284 | 'TITLE', 285 | 'use', 286 | ]; 287 | 288 | return flattenedDocument.nodes 289 | .filter(node => node.nodeType === 1 && !ignoreElements.includes(node.nodeName)); 290 | } 291 | 292 | /** 293 | * @param {string} name 294 | * @param {Context} context 295 | */ 296 | function shouldSkip(name, context) { 297 | if (context.options.onlyCollections) { 298 | if (!context.options.onlyCollections.includes(name)) { 299 | context.log('skipping', name); 300 | return true; 301 | } 302 | } else if (context.options.skipCollections) { 303 | if (context.options.skipCollections.includes(name)) { 304 | context.log('skipping', name); 305 | return true; 306 | } 307 | } 308 | 309 | return false; 310 | } 311 | 312 | /** 313 | * @param {string} name 314 | * @param {Context} context 315 | */ 316 | async function collect(name, context) { 317 | // TODO: is this needed? the CLI runner skips in a different way. 318 | if (shouldSkip(name, context)) { 319 | return; 320 | } 321 | 322 | const { current } = context; 323 | 324 | // @ts-ignore 325 | const nodes = await getNodes(context); 326 | 327 | // TODO: make a super selector of all the CSS rules, and only grab those Nodes. 328 | // const body = nodes.find(n => n.nodeName === 'BODY'); 329 | // const d = await current.client.send('DOM.querySelectorAll', {nodeId: body.nodeId, selector: '.crc-node,span.lh-env__name'}); 330 | // console.log(d); 331 | 332 | /** @type {Collection} */ 333 | const collection = { 334 | name, 335 | entries: [], 336 | }; 337 | 338 | /** 339 | * @param {any} rule 340 | * @param {ValidProtocolCSSProperty} property 341 | */ 342 | function processActiveProperty(rule, property) { 343 | if (rule.origin === 'user-agent') return; 344 | 345 | const id = rule.styleSheetId; 346 | const styleSheet = current.styleSheetMap.get(id); 347 | if (!styleSheet) throw new Error('shouldnt happen'); 348 | const collectionEntry = findOrMakeCollectionEntry(collection.entries, styleSheet); 349 | const range = property.range; 350 | collectionEntry.usedRanges.add(`${range.startLine},${range.startColumn},${range.endLine},${range.endColumn}`); 351 | } 352 | 353 | await Promise.all(nodes.map(async ({ nodeId, nodeName }) => { 354 | const matchedStyles = await current.client.send('CSS.getMatchedStylesForNode', { nodeId }); 355 | const activeStyles = findActiveStyles(matchedStyles); 356 | 357 | if (!activeStyles) { 358 | // TODO: understand this case. 359 | context.warnings.push(`could not find styles ${nodeId}`); 360 | return; 361 | } 362 | 363 | for (const [key, value] of activeStyles.entries()) { 364 | if (!value) { 365 | // TODO: understand this case. 366 | context.warnings.push(`null value in activeStyles ${key}`); 367 | continue; 368 | } 369 | 370 | const { rule, property } = value; 371 | processActiveProperty(rule, property); 372 | } 373 | 374 | // Handle pseudo elements. 375 | if (matchedStyles.pseudoElements) { 376 | for (const { matches } of matchedStyles.pseudoElements) { 377 | for (const { rule } of matches) { 378 | for (const property of rule.style.cssProperties) { 379 | if (!isValidProperty(property)) continue; 380 | processActiveProperty(rule, property); 381 | } 382 | } 383 | } 384 | } 385 | })); 386 | 387 | for (const entry of collection.entries) { 388 | if (entry.content) continue; 389 | const data = await current.client.send('CSS.getStyleSheetText', { 390 | styleSheetId: entry.styleSheet.styleSheetId, 391 | }); 392 | entry.content = data.text; 393 | } 394 | 395 | context.collections.push(collection); 396 | } 397 | 398 | /** 399 | * @param {Collection} collection 400 | */ 401 | function getResults(collection) { 402 | const resultsByStyleSheets = []; 403 | for (const entry of collection.entries) { 404 | /** @type {any[]} */ // TODO 405 | const rules = []; 406 | const ast = css.parse(entry.content); 407 | 408 | /** 409 | * @param {css.Rule & css.Media} rule 410 | */ 411 | function process(rule) { 412 | if (rule.type === 'media' && rule.rules) { 413 | rule.rules.forEach(process); 414 | return; 415 | } 416 | 417 | if (rule.type !== 'rule' || !rule.declarations) { 418 | return; 419 | } 420 | 421 | const unusedDeclarations = rule.declarations 422 | .map(declaration => { 423 | if (declaration.type !== 'declaration') return; 424 | // css types don't use type discrimination, so still need to disable checker :( 425 | 426 | const p = declaration.position; 427 | // @ts-ignore: can these even be undefined? 428 | const isUsed = entry.usedRanges.has(`${p.start.line - 1},${p.start.column - 1},${p.end.line - 1},${p.end.column}`); 429 | if (isUsed) return; 430 | 431 | // TODO: check if variables are used. 432 | // @ts-ignore: this isn't a comment 433 | if (declaration.property.startsWith('--')) return; 434 | 435 | return { 436 | // @ts-ignore: this isn't a comment 437 | property: declaration.property, 438 | // @ts-ignore: this isn't a comment 439 | value: declaration.value, 440 | position: declaration.position, 441 | }; 442 | }).filter(Boolean); 443 | 444 | if (!unusedDeclarations.length) return; 445 | 446 | // TODO: the typing here is really annoying so let's skip a lot 447 | 448 | const position = { 449 | // @ts-ignore 450 | start: { ...rule.position.start }, 451 | // @ts-ignore 452 | end: { ...rule.position.end }, 453 | }; 454 | 455 | // Use zero-based indices. 456 | // @ts-ignore 457 | position.start.line -= 1; 458 | // @ts-ignore 459 | position.start.column -= 1; 460 | // @ts-ignore 461 | position.end.line -= 1; 462 | // @ts-ignore 463 | position.end.column -= 1; 464 | // @ts-ignore 465 | for (const d of unusedDeclarations) { 466 | // @ts-ignore 467 | d.position.start.line -= 1; 468 | // @ts-ignore 469 | d.position.start.column -= 1; 470 | // @ts-ignore 471 | d.position.end.line -= 1; 472 | // @ts-ignore 473 | d.position.end.column -= 1; 474 | } 475 | 476 | // if (styleSheet.isInline) { 477 | // position.start.line += styleSheet.startLine; 478 | // position.end.line += styleSheet.startLine; 479 | // for (const d of unusedDeclarations) { 480 | // d.position.start.line += styleSheet.startLine; 481 | // d.position.end.line += styleSheet.startLine; 482 | // } 483 | // // TODO column 484 | // } 485 | 486 | rules.push({ 487 | selectors: rule.selectors, 488 | unusedDeclarations, 489 | position, 490 | }); 491 | } 492 | 493 | if (!ast.stylesheet) throw new Error('bad ast'); 494 | 495 | for (const rule of ast.stylesheet.rules) { 496 | process(rule); 497 | } 498 | 499 | const unusedDeclarationCount = sum(rules.map(r => r.unusedDeclarations.length)); 500 | 501 | resultsByStyleSheets.push({ 502 | url: entry.styleSheet.sourceURL, 503 | isInline: entry.styleSheet.isInline, 504 | startLine: entry.styleSheet.startLine, 505 | startColumn: entry.styleSheet.startColumn, 506 | content: entry.content, 507 | rules, 508 | unusedDeclarationCount, 509 | }); 510 | } 511 | 512 | return resultsByStyleSheets; 513 | } 514 | 515 | /** 516 | * This is a greedy approach, but it's better than nothing. 517 | * @param {Context} context 518 | */ 519 | function findMinimalCollections(context) { 520 | const combined = combineCollections(context.collections); 521 | 522 | // Add a collection by most unique used range. 523 | /** @type {Map} index + range => count */ 524 | const usedRangeCount = new Map(); 525 | for (let i = 0; i < combined.entries.length; i++) { 526 | const canonicalEntry = combined.entries[i]; 527 | for (const collection of context.collections) { 528 | const collectionEntry = findCollectionEntry(collection.entries, canonicalEntry.styleSheet); 529 | if (!collectionEntry) continue; 530 | 531 | for (const range of collectionEntry.usedRanges) { 532 | const key = `${i}-${range}`; 533 | usedRangeCount.set(key, (usedRangeCount.get(key) || 0) + 1); 534 | } 535 | } 536 | } 537 | 538 | const remainingCollections = [...context.collections]; 539 | const minimalCollections = []; 540 | while (usedRangeCount.size) { 541 | const smallestKey = [...usedRangeCount.entries()] 542 | .reduce((min, [key, count]) => min[1] > count ? [key, count] : min)[0]; 543 | const [index, range] = smallestKey.split('-'); 544 | 545 | const canonicalEntry = combined.entries[Number(index)]; 546 | const nextCollectionIndex = remainingCollections.findIndex(collection => { 547 | const entry = findCollectionEntry(collection.entries, canonicalEntry.styleSheet); 548 | return entry && entry.usedRanges.has(range); 549 | }); 550 | if (nextCollectionIndex === -1) throw new Error('expected to find a nextCollection'); 551 | const nextCollection = remainingCollections[nextCollectionIndex]; 552 | minimalCollections.push(nextCollection); 553 | remainingCollections.splice(nextCollectionIndex, 1); 554 | 555 | // Delete the used counts. 556 | for (let i = 0; i < combined.entries.length; i++) { 557 | const canonicalEntry = combined.entries[i]; 558 | const collectionEntry = findCollectionEntry(nextCollection.entries, canonicalEntry.styleSheet); 559 | if (!collectionEntry) continue; 560 | 561 | for (const range of collectionEntry.usedRanges) { 562 | const key = `${i}-${range}`; 563 | usedRangeCount.delete(key); 564 | } 565 | } 566 | } 567 | 568 | return minimalCollections; 569 | } 570 | 571 | /** 572 | * @param {Report} report 573 | */ 574 | function makeTextOutput(report) { 575 | const { styleSheets } = report; 576 | const totalUnusedDeclarationCount = sum(styleSheets.map(r => r.unusedDeclarationCount)); 577 | 578 | let output = styleSheets.map(result => { 579 | const contentLines = result.content.split('\n'); 580 | const outputLines = []; 581 | 582 | outputLines.push('========='); 583 | outputLines.push((result.url || 'unknown') + ` (${result.unusedDeclarationCount} unused declarations)`); 584 | outputLines.push('========='); 585 | 586 | for (const rule of result.rules) { 587 | for (const declaration of rule.unusedDeclarations) { 588 | let linenum = declaration.position.start.line; 589 | const lineContent = contentLines[linenum].trim(); 590 | if (result.isInline) linenum += result.startLine; 591 | outputLines.push(`:${linenum + 1} ${rule.selectors[0]} { ${lineContent} }`); 592 | } 593 | } 594 | 595 | return outputLines.join('\n'); 596 | }).filter(Boolean).join('\n\n') + `\n\n-----\ntotal unused declarations: ${totalUnusedDeclarationCount}`; 597 | 598 | if (report.debug) { 599 | const lines = ['\n\n=========\nDEBUG\n=========']; 600 | 601 | lines.push('\n- collections\n'); 602 | for (const name of report.debug.collectionNames) { 603 | lines.push(name); 604 | } 605 | 606 | lines.push('\n- incremental coverage\n'); 607 | for (const [i, n] of Object.entries(report.debug.incrementalCoverage)) { 608 | const name = report.debug.collectionNames[Number(i)]; 609 | lines.push(`unusedDeclarationCount ${n} ${name}`); 610 | } 611 | 612 | lines.push('\n- used ranges count\n'); 613 | for (const [i, n] of Object.entries(report.debug.usedRangeCounts)) { 614 | const name = report.debug.collectionNames[Number(i)]; 615 | lines.push(`used ranges ${n} ${name}`); 616 | } 617 | 618 | if (report.debug.minimalCollectionNames.length !== report.debug.collectionNames.length) { 619 | lines.push('\n- minimal collections\n'); 620 | for (const name of report.debug.minimalCollectionNames) { 621 | lines.push(name); 622 | } 623 | 624 | const notInMinimal = report.debug.collectionNames 625 | .filter(name => report.debug && !report.debug.minimalCollectionNames.includes(name)); 626 | lines.push('\n- not in minimal\n'); 627 | lines.push(...notInMinimal); 628 | lines.push('\nto run just the minimal collections, use:'); 629 | lines.push('--only-collections ' + report.debug.minimalCollectionNames.map(n => `'${n}'`).join(' ')); 630 | } 631 | 632 | output += lines.join('\n'); 633 | } 634 | 635 | if (report.warnings.length) { 636 | output += '\n- warnings\n' + report.warnings.join('\n'); 637 | } 638 | 639 | return output; 640 | } 641 | 642 | /** 643 | * @param {Context} context 644 | */ 645 | function finish(context) { 646 | context.browser.close(); 647 | 648 | const combined = combineCollections(context.collections); 649 | const resultsByStyleSheets = getResults(combined); 650 | /** @type {Report} */ 651 | const report = { 652 | output: '', 653 | styleSheets: resultsByStyleSheets, 654 | unusedDeclarationCount: sum(resultsByStyleSheets.map(r => r.unusedDeclarationCount)), 655 | warnings: [], 656 | }; 657 | 658 | if (context.options.debug) { 659 | /** @type {Collection} */ 660 | let combined = { 661 | name: '', 662 | entries: [], 663 | }; 664 | const incrementalCoverage = []; 665 | for (const nextCollection of context.collections) { 666 | combined = combineCollections([combined, nextCollection]); 667 | const partialReport = getResults(combined); 668 | const partialCount = sum(partialReport.map(r => r.unusedDeclarationCount)); 669 | incrementalCoverage.push(partialCount); 670 | } 671 | 672 | const usedRangeCounts = context.collections 673 | .map(collection => sum(collection.entries.map(e => e.usedRanges.size))); 674 | 675 | const minimalCollections = findMinimalCollections(context); 676 | const minimalCollectionNames = minimalCollections.map(c => c.name); 677 | 678 | report.debug = { 679 | collectionNames: context.collections.map(c => c.name), 680 | incrementalCoverage, 681 | minimalCollectionNames, 682 | usedRangeCounts, 683 | }; 684 | } 685 | 686 | report.output = makeTextOutput(report); 687 | return report; 688 | } 689 | 690 | /** 691 | * @param {string[]} urlOrFiles 692 | * @param {Options} options 693 | */ 694 | async function run(urlOrFiles, options) { 695 | const context = await start(options); 696 | 697 | const jobs = []; 698 | for (const urlOrFile of urlOrFiles) { 699 | /** @type {Array<{name: string, colorScheme?: string, viewport?: {width: number, height: number}}>} */ 700 | const tasks = []; 701 | 702 | const isUrl = /^.*:\/\//.test(urlOrFile); 703 | const url = isUrl ? urlOrFile : 'file://' + path.resolve(urlOrFile); 704 | const name = urlOrFile; 705 | 706 | tasks.push({ 707 | name: `${name} initial`, 708 | }); 709 | 710 | if (options.colorScheme) { 711 | tasks.push({ 712 | name: `${name} colorScheme ${options.colorScheme}`, 713 | colorScheme: options.colorScheme, 714 | }); 715 | } 716 | 717 | if (options.viewports) { 718 | tasks.push(...options.viewports.map(viewport => { 719 | return { 720 | name: `${name} viewport ${viewport.width},${viewport.height}`, 721 | viewport, 722 | }; 723 | })); 724 | } 725 | 726 | jobs.push({ 727 | url, 728 | tasks, 729 | }); 730 | } 731 | 732 | for (const job of jobs) { 733 | const tasks = job.tasks.filter(task => !shouldSkip(task.name, context)); 734 | if (!tasks.length) continue; 735 | 736 | await context.navigate(job.url); 737 | for (const task of tasks) { 738 | context.log('collect', task.name); 739 | 740 | if (task.colorScheme) { 741 | // @ts-ignore 742 | await context.current.page.emulateMediaFeatures([{ name: 'prefers-color-scheme', value: task.colorScheme }]); 743 | // TODO: how to disable? 744 | } 745 | 746 | if (task.viewport) { 747 | // TODO: allow for reload here. 748 | await context.current.page.emulate({ 749 | viewport: task.viewport, 750 | userAgent: '', // TODO: puppeteer says this is optional 751 | }); 752 | } 753 | 754 | await context.collect(task.name); 755 | await context.current.page.emulateMedia(null); 756 | } 757 | } 758 | 759 | return context.finish(); 760 | } 761 | 762 | module.exports = { 763 | run, 764 | start, 765 | }; 766 | -------------------------------------------------------------------------------- /src/default-config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | colorScheme: 'dark', 3 | viewports: [{width: 500, height: 500}], 4 | }; 5 | -------------------------------------------------------------------------------- /src/globals.d.ts: -------------------------------------------------------------------------------- 1 | import _Protocol from 'devtools-protocol'; 2 | import _Mappings from 'devtools-protocol/types/protocol-mapping'; 3 | import {EventEmitter} from 'events'; 4 | 5 | declare global { 6 | module Crdp { 7 | export import Protocol = _Protocol; 8 | export import Commands = _Mappings.Commands; 9 | export import Events = _Mappings.Events; 10 | } 11 | 12 | // TODO: Remove when this lands https://github.com/DefinitelyTyped/DefinitelyTyped/pull/40432 13 | export interface CDPSession extends EventEmitter { 14 | /** 15 | * Detaches session from target. Once detached, session won't emit any events and can't be used 16 | * to send messages. 17 | */ 18 | detach(): Promise; 19 | 20 | on(event: T, listener: (...args: Crdp.Events[T]) => void): this; 21 | 22 | /** 23 | * @param method Protocol method name 24 | * @param parameters Protocol parameters 25 | */ 26 | send(method: T, parameters?: Crdp.Commands[T]['paramsType'][0]): Promise; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /test/__snapshots__/snapshot-test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`snapshot /fixtures/1 1`] = ` 4 | "========= 5 | file://.../fixtures/1/index.html (3 unused declarations) 6 | ========= 7 | :5 div { color: red; /* unused! */ } 8 | :13 .never-used { color: blue; /* unused! */ } 9 | :14 .never-used { display: block; /* unused! */ } 10 | 11 | ----- 12 | total unused declarations: 3 13 | 14 | ========= 15 | DEBUG 16 | ========= 17 | 18 | - collections 19 | 20 | test/fixtures/1/index.html initial 21 | 22 | - incremental coverage 23 | 24 | unusedDeclarationCount 3 test/fixtures/1/index.html initial 25 | 26 | - used ranges count 27 | 28 | used ranges 1 test/fixtures/1/index.html initial" 29 | `; 30 | 31 | exports[`snapshot /fixtures/2 1`] = ` 32 | "========= 33 | file://.../fixtures/2/style.css (2 unused declarations) 34 | ========= 35 | :10 .never-used { color: blue; /* unused! */ } 36 | :11 .never-used { display: block; /* unused! */ } 37 | 38 | ----- 39 | total unused declarations: 2 40 | 41 | ========= 42 | DEBUG 43 | ========= 44 | 45 | - collections 46 | 47 | test/fixtures/2/index.html initial 48 | 49 | - incremental coverage 50 | 51 | unusedDeclarationCount 2 test/fixtures/2/index.html initial 52 | 53 | - used ranges count 54 | 55 | used ranges 2 test/fixtures/2/index.html initial" 56 | `; 57 | 58 | exports[`snapshot /fixtures/3 1`] = ` 59 | "========= 60 | file://.../fixtures/3/style.css (1 unused declarations) 61 | ========= 62 | :14 .used-by-neither { color: red; /* unused! */ } 63 | 64 | ----- 65 | total unused declarations: 1 66 | 67 | ========= 68 | DEBUG 69 | ========= 70 | 71 | - collections 72 | 73 | test/fixtures/3/a.html initial 74 | test/fixtures/3/b.html initial 75 | 76 | - incremental coverage 77 | 78 | unusedDeclarationCount 2 test/fixtures/3/a.html initial 79 | unusedDeclarationCount 1 test/fixtures/3/b.html initial 80 | 81 | - used ranges count 82 | 83 | used ranges 2 test/fixtures/3/a.html initial 84 | used ranges 2 test/fixtures/3/b.html initial" 85 | `; 86 | 87 | exports[`snapshot /fixtures/4 1`] = ` 88 | "========= 89 | file://.../fixtures/4/index.html (1 unused declarations) 90 | ========= 91 | :14 .never-used { color: red; /* unused! */ } 92 | 93 | ----- 94 | total unused declarations: 1 95 | 96 | ========= 97 | DEBUG 98 | ========= 99 | 100 | - collections 101 | 102 | test/fixtures/4/index.html initial 103 | test/fixtures/4/index.html viewport 500,500 104 | 105 | - incremental coverage 106 | 107 | unusedDeclarationCount 2 test/fixtures/4/index.html initial 108 | unusedDeclarationCount 1 test/fixtures/4/index.html viewport 500,500 109 | 110 | - used ranges count 111 | 112 | used ranges 1 test/fixtures/4/index.html initial 113 | used ranges 1 test/fixtures/4/index.html viewport 500,500" 114 | `; 115 | 116 | exports[`snapshot /fixtures/5 1`] = ` 117 | "========= 118 | file://.../fixtures/5/index.html (0 unused declarations) 119 | ========= 120 | 121 | ----- 122 | total unused declarations: 0 123 | 124 | ========= 125 | DEBUG 126 | ========= 127 | 128 | - collections 129 | 130 | test/fixtures/5/index.html initial 131 | 132 | - incremental coverage 133 | 134 | unusedDeclarationCount 0 test/fixtures/5/index.html initial 135 | 136 | - used ranges count 137 | 138 | used ranges 2 test/fixtures/5/index.html initial" 139 | `; 140 | 141 | exports[`snapshot /fixtures/6 1`] = ` 142 | "========= 143 | file://.../fixtures/6/index.html (1 unused declarations) 144 | ========= 145 | :14 .never-used { color: var(--color-red); /* unused! */ } 146 | 147 | ----- 148 | total unused declarations: 1 149 | 150 | ========= 151 | DEBUG 152 | ========= 153 | 154 | - collections 155 | 156 | test/fixtures/6/index.html initial 157 | 158 | - incremental coverage 159 | 160 | unusedDeclarationCount 1 test/fixtures/6/index.html initial 161 | 162 | - used ranges count 163 | 164 | used ranges 1 test/fixtures/6/index.html initial" 165 | `; 166 | 167 | exports[`snapshot /fixtures/7 1`] = ` 168 | "========= 169 | file://.../fixtures/7/index-2.html (1 unused declarations) 170 | ========= 171 | :13 .never-used { color: red; /* unused! */ } 172 | 173 | ----- 174 | total unused declarations: 1 175 | 176 | ========= 177 | DEBUG 178 | ========= 179 | 180 | - collections 181 | 182 | test/fixtures/7/index-2.html initial 183 | test/fixtures/7/index.html initial 184 | 185 | - incremental coverage 186 | 187 | unusedDeclarationCount 2 test/fixtures/7/index-2.html initial 188 | unusedDeclarationCount 1 test/fixtures/7/index.html initial 189 | 190 | - used ranges count 191 | 192 | used ranges 1 test/fixtures/7/index-2.html initial 193 | used ranges 1 test/fixtures/7/index.html initial" 194 | `; 195 | 196 | exports[`snapshot /fixtures/8 1`] = ` 197 | "========= 198 | file://.../fixtures/8/index.html (2 unused declarations) 199 | ========= 200 | :9 .never-used::before { color: blue; /* unused! */ } 201 | :10 .never-used::before { display: block; /* unused! */ } 202 | 203 | ----- 204 | total unused declarations: 2 205 | 206 | ========= 207 | DEBUG 208 | ========= 209 | 210 | - collections 211 | 212 | test/fixtures/8/index.html initial 213 | 214 | - incremental coverage 215 | 216 | unusedDeclarationCount 2 test/fixtures/8/index.html initial 217 | 218 | - used ranges count 219 | 220 | used ranges 1 test/fixtures/8/index.html initial" 221 | `; 222 | 223 | exports[`snapshot /fixtures/lh 1`] = ` 224 | "========= 225 | file://.../fixtures/lh/report-1.html (112 unused declarations) 226 | ========= 227 | :249 .lh-sparkline { display: none; } 228 | :286 .lh-devtools.lh-root { height: 100%; } 229 | :290 .lh-devtools.lh-root img { min-width: auto; } 230 | :293 .lh-devtools .lh-container { overflow-y: scroll; } 231 | :294 .lh-devtools .lh-container { height: calc(100% - var(--topbar-height)); } 232 | :298 .lh-devtools .lh-container { overflow: unset; } 233 | :303 .lh-devtools .lh-sticky-header { top: 0; } 234 | :327 .lh-root :focus { outline: -webkit-focus-ring-color auto 3px; } 235 | :330 .lh-root summary:focus { outline: none; } 236 | :331 .lh-root summary:focus { box-shadow: 0 0 0 1px hsl(217, 89%, 61%); } 237 | :388 .lh-details.flex .lh-code { max-width: 70%; } 238 | :398 .lh-audit__stackpack__img { margin-right: var(--default-padding) } 239 | :407 .report-icon:hover { opacity: 1; } 240 | :410 .report-icon[disabled] { opacity: 0.3; } 241 | :411 .report-icon[disabled] { pointer-events: none; } 242 | :540 .lh-audit__header:hover { background-color: var(--color-hover); } 243 | :551 .lh-audit-group > summary::-webkit-details-marker { display: none; } 244 | :576 .lh-column:first-of-type { margin-right: 24px; } 245 | :612 .lh-metric__details { order: -1; } 246 | :644 .lh-metrics-toggle__input:checked ~ .lh-columns .lh-metric__description { display: block; } 247 | :658 .lh-metrics-toggle__label { background-color: #eee; } 248 | :667 .lh-metrics-toggle__input:focus + label { outline: -webkit-focus-ring-color auto 3px; } 249 | :683 .lh-metrics-toggle__lines { fill: var(--metric-toggle-lines-fill); } 250 | :742 .lh-metric--error .lh-metric__value { color: var(--color-fail-secondary); } 251 | :796 .lh-audit--pass .lh-sparkline__bar { background: var(--color-pass); } 252 | :887 .lh-audit-group--budgets .lh-table tbody tr td:nth-child(4) { color: var(--color-red-700); } 253 | :892 .lh-audit-group--budgets .lh-table tbody tr td:nth-child(4) { text-align: right; } 254 | :896 .lh-audit-group--budgets .lh-table { width: 100%; } 255 | :912 .lh-audit-group--pwa-fast-reliable.lh-badged .lh-audit-group__header::before { background-image: var(--pwa-fast-reliable-color-url); } 256 | :915 .lh-audit-group--pwa-installable.lh-badged .lh-audit-group__header::before { background-image: var(--pwa-installable-color-url); } 257 | :918 .lh-audit-group--pwa-optimized.lh-badged .lh-audit-group__header::before { background-image: var(--pwa-optimized-color-url); } 258 | :922 .lh-audit-group--metrics .lh-audit-group__summary { margin-top: 0; } 259 | :923 .lh-audit-group--metrics .lh-audit-group__summary { margin-bottom: 0; } 260 | :934 .lh-audit-group__itemcount { color: var(--color-gray-600); } 261 | :935 .lh-audit-group__itemcount { font-weight: bold; } 262 | :938 .lh-audit-group__header .lh-chevron { margin-top: calc((var(--report-line-height) - 5px) / 2); } 263 | :992 .lh-list > div:not(:last-child) { padding-bottom: 20px; } 264 | :1007 .lh-exception { font-size: large; } 265 | :1028 .lh-warnings--toplevel { color: var(--report-text-color-secondary); } 266 | :1029 .lh-warnings--toplevel { margin-left: auto; } 267 | :1030 .lh-warnings--toplevel { margin-right: auto; } 268 | :1031 .lh-warnings--toplevel { max-width: calc(var(--report-width) - var(--category-padding) * 2); } 269 | :1032 .lh-warnings--toplevel { background-color: var(--color-amber-50); } 270 | :1033 .lh-warnings--toplevel { padding: var(--toplevel-warning-padding); } 271 | :1053 .lh-scores-header__solo { padding: 0; } 272 | :1054 .lh-scores-header__solo { border: 0; } 273 | :1078 .lh-gauge { stroke-linecap: round; } 274 | :1079 .lh-gauge { width: var(--gauge-circle-size); } 275 | :1080 .lh-gauge { height: var(--gauge-circle-size); } 276 | :1088 .lh-gauge-base { opacity: 0.1; } 277 | :1089 .lh-gauge-base { stroke: var(--circle-background); } 278 | :1090 .lh-gauge-base { stroke-width: var(--circle-border-width); } 279 | :1094 .lh-gauge-arc { fill: none; } 280 | :1095 .lh-gauge-arc { stroke: var(--circle-color); } 281 | :1096 .lh-gauge-arc { stroke-width: var(--circle-border-width); } 282 | :1097 .lh-gauge-arc { animation: load-gauge var(--transition-length) ease forwards; } 283 | :1098 .lh-gauge-arc { animation-delay: 250ms; } 284 | :1111 .lh-gauge__wrapper--plugin .lh-gauge__svg-wrapper::before { width: var(--plugin-badge-size); } 285 | :1112 .lh-gauge__wrapper--plugin .lh-gauge__svg-wrapper::before { height: var(--plugin-badge-size); } 286 | :1113 .lh-gauge__wrapper--plugin .lh-gauge__svg-wrapper::before { background-color: var(--plugin-badge-background-color); } 287 | :1114 .lh-gauge__wrapper--plugin .lh-gauge__svg-wrapper::before { background-image: var(--plugin-icon-url); } 288 | :1115 .lh-gauge__wrapper--plugin .lh-gauge__svg-wrapper::before { background-repeat: no-repeat; } 289 | :1116 .lh-gauge__wrapper--plugin .lh-gauge__svg-wrapper::before { background-size: var(--plugin-icon-size); } 290 | :1117 .lh-gauge__wrapper--plugin .lh-gauge__svg-wrapper::before { background-position: 58% 50%; } 291 | :1118 .lh-gauge__wrapper--plugin .lh-gauge__svg-wrapper::before { content: \\"\\"; } 292 | :1119 .lh-gauge__wrapper--plugin .lh-gauge__svg-wrapper::before { position: absolute; } 293 | :1120 .lh-gauge__wrapper--plugin .lh-gauge__svg-wrapper::before { right: -6px; } 294 | :1121 .lh-gauge__wrapper--plugin .lh-gauge__svg-wrapper::before { bottom: 0px; } 295 | :1122 .lh-gauge__wrapper--plugin .lh-gauge__svg-wrapper::before { display: block; } 296 | :1123 .lh-gauge__wrapper--plugin .lh-gauge__svg-wrapper::before { z-index: 100; } 297 | :1124 .lh-gauge__wrapper--plugin .lh-gauge__svg-wrapper::before { box-shadow: 0 0 4px rgba(0,0,0,.2); } 298 | :1125 .lh-gauge__wrapper--plugin .lh-gauge__svg-wrapper::before { border-radius: 25%; } 299 | :1128 .lh-category .lh-gauge__wrapper--plugin .lh-gauge__svg-wrapper::before { width: var(--plugin-badge-size-big); } 300 | :1129 .lh-category .lh-gauge__wrapper--plugin .lh-gauge__svg-wrapper::before { height: var(--plugin-badge-size-big); } 301 | :1230 .lh-header--solo-category .lh-scores-wrapper { display: none; } 302 | :1278 .lh-category-header .lh-audit__title { font-size: var(--category-header-font-size); } 303 | :1279 .lh-category-header .lh-audit__title { line-height: var(--header-line-height); } 304 | :1303 #lh-log.show { opacity: 1; } 305 | :1304 #lh-log.show { transform: translateY(0); } 306 | :1317 body { -webkit-print-color-adjust: exact; /* print background colors */ } 307 | :1320 .lh-container { display: block; } 308 | :1323 .lh-report { margin-left: 0; } 309 | :1324 .lh-report { padding-top: 0; } 310 | :1327 .lh-categories { margin-top: 0; } 311 | :1406 .lh-text__url-host { font-size: 90% } 312 | :1417 .lh-unknown pre { overflow: scroll; } 313 | :1418 .lh-unknown pre { border: solid 1px var(--color-gray-200); } 314 | :1427 .lh-text__url > a:hover { text-decoration: underline dotted #999; } 315 | :1437 .lh-chevron { width: var(--chevron-size); } 316 | :1438 .lh-chevron { height: var(--chevron-size); } 317 | :1439 .lh-chevron { margin-top: calc((var(--report-line-height) - 12px) / 2); } 318 | :1443 .lh-chevron__lines { transition: transform 0.4s; } 319 | :1444 .lh-chevron__lines { transform: translateY(var(--report-line-height)); } 320 | :1447 .lh-chevron__line { stroke: var(--chevron-line-stroke); } 321 | :1448 .lh-chevron__line { stroke-width: var(--chevron-size); } 322 | :1449 .lh-chevron__line { stroke-linecap: square; } 323 | :1450 .lh-chevron__line { transform-origin: 50%; } 324 | :1451 .lh-chevron__line { transform: rotate(var(--chevron-angle)); } 325 | :1452 .lh-chevron__line { transition: transform 300ms, stroke 300ms; } 326 | :1459 .lh-audit-group > summary > .lh-audit-group__summary > .lh-chevron .lh-chevron__line-right { transform: rotate(var(--chevron-angle-right)); } 327 | :1464 .lh-audit-group[open] > summary > .lh-audit-group__summary > .lh-chevron .lh-chevron__line-right { transform: rotate(var(--chevron-angle)); } 328 | :1469 .lh-audit-group[open] > summary > .lh-audit-group__summary > .lh-chevron .lh-chevron__lines { transform: translateY(calc(var(--chevron-size) * -1)); } 329 | :1501 .tooltip-boundary:hover { background-color: var(--color-hover); } 330 | :1505 .tooltip-boundary:hover .tooltip { display: block; } 331 | :1506 .tooltip-boundary:hover .tooltip { animation: fadeInTooltip 250ms; } 332 | :1507 .tooltip-boundary:hover .tooltip { animation-fill-mode: forwards; } 333 | :1508 .tooltip-boundary:hover .tooltip { animation-delay: 850ms; } 334 | :1509 .tooltip-boundary:hover .tooltip { bottom: 100%; } 335 | :1510 .tooltip-boundary:hover .tooltip { z-index: 1; } 336 | :1511 .tooltip-boundary:hover .tooltip { will-change: opacity; } 337 | :1512 .tooltip-boundary:hover .tooltip { right: 0; } 338 | :1513 .tooltip-boundary:hover .tooltip { pointer-events: none; } 339 | 340 | ========= 341 | unknown (16 unused declarations) 342 | ========= 343 | :16 .lh-topbar__logo { width: var(--topbar-logo-size); } 344 | :17 .lh-topbar__logo { height: var(--topbar-logo-size); } 345 | :18 .lh-topbar__logo { user-select: none; } 346 | :19 .lh-topbar__logo { flex: none; } 347 | :22 .lh-topbar__logo .shape { fill: var(--report-text-color); } 348 | :53 .lh-tools__button svg { fill: var(--tools-icon-color); } 349 | :56 .dark .lh-tools__button svg { filter: invert(1); } 350 | :71 .lh-tools__dropdown { right: 0; } 351 | :74 .lh-tools__dropdown { clip: rect(0, 164px, 0, 0); } 352 | :75 .lh-tools__dropdown { visibility: hidden; } 353 | :76 .lh-tools__dropdown { opacity: 0; } 354 | :101 .dark .report-icon { color: var(--color-gray-900); } 355 | :102 .dark .report-icon { filter: invert(1); } 356 | :106 .dark .lh-tools__dropdown a:hover { background-color: #BDBDBD; } 357 | :126 .lh-topbar { position: static; } 358 | :127 .lh-topbar { margin-left: 0; } 359 | 360 | ========= 361 | unknown (4 unused declarations) 362 | ========= 363 | :32 .lh-sticky-header--visible { display: grid; } 364 | :33 .lh-sticky-header--visible { grid-auto-flow: column; } 365 | :34 .lh-sticky-header--visible { pointer-events: auto; } 366 | :40 .lh-sticky-header .lh-gauge-arc { animation: none; } 367 | 368 | ========= 369 | unknown (4 unused declarations) 370 | ========= 371 | :11 .score100 .pyro { display: block; } 372 | :14 .score100 .lh-lighthouse stop:first-child { stop-color: hsla(200, 12%, 95%, 0); } 373 | :17 .score100 .lh-lighthouse stop:last-child { stop-color: hsla(65, 81%, 76%, 1); } 374 | :35 .fireworks-paused .pyro > div { animation-play-state: paused; } 375 | 376 | ========= 377 | unknown (0 unused declarations) 378 | ========= 379 | 380 | ========= 381 | unknown (3 unused declarations) 382 | ========= 383 | :25 .lh-crc .crc-tree { font-size: 14px; } 384 | :26 .lh-crc .crc-tree { width: 100%; } 385 | :27 .lh-crc .crc-tree { overflow-x: auto; } 386 | 387 | ========= 388 | unknown (0 unused declarations) 389 | ========= 390 | 391 | ----- 392 | total unused declarations: 139 393 | 394 | ========= 395 | DEBUG 396 | ========= 397 | 398 | - collections 399 | 400 | test/fixtures/lh/report-1.html viewport 500,500 401 | test/fixtures/lh/report-2.html initial 402 | test/fixtures/lh/report-6.html initial 403 | test/fixtures/lh/report-6.html viewport 500,500 404 | 405 | - incremental coverage 406 | 407 | unusedDeclarationCount 182 test/fixtures/lh/report-1.html viewport 500,500 408 | unusedDeclarationCount 166 test/fixtures/lh/report-2.html initial 409 | unusedDeclarationCount 143 test/fixtures/lh/report-6.html initial 410 | unusedDeclarationCount 139 test/fixtures/lh/report-6.html viewport 500,500 411 | 412 | - used ranges count 413 | 414 | used ranges 475 test/fixtures/lh/report-1.html viewport 500,500 415 | used ranges 461 test/fixtures/lh/report-2.html initial 416 | used ranges 482 test/fixtures/lh/report-6.html initial 417 | used ranges 487 test/fixtures/lh/report-6.html viewport 500,500" 418 | `; 419 | 420 | exports[`snapshot https://www.example.com 1`] = ` 421 | "========= 422 | https://www.example.com/ (0 unused declarations) 423 | ========= 424 | 425 | ----- 426 | total unused declarations: 0 427 | 428 | ========= 429 | DEBUG 430 | ========= 431 | 432 | - collections 433 | 434 | https://www.example.com initial 435 | https://www.example.com colorScheme dark 436 | https://www.example.com viewport 500,500 437 | 438 | - incremental coverage 439 | 440 | unusedDeclarationCount 2 https://www.example.com initial 441 | unusedDeclarationCount 2 https://www.example.com colorScheme dark 442 | unusedDeclarationCount 0 https://www.example.com viewport 500,500 443 | 444 | - used ranges count 445 | 446 | used ranges 12 https://www.example.com initial 447 | used ranges 12 https://www.example.com colorScheme dark 448 | used ranges 12 https://www.example.com viewport 500,500 449 | 450 | - minimal collections 451 | 452 | https://www.example.com viewport 500,500 453 | https://www.example.com initial 454 | 455 | - not in minimal 456 | 457 | https://www.example.com colorScheme dark 458 | 459 | to run just the minimal collections, use: 460 | --only-collections 'https://www.example.com viewport 500,500' 'https://www.example.com initial'" 461 | `; 462 | -------------------------------------------------------------------------------- /test/fixtures/1/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 17 | 18 | 19 |
hi
20 | 21 | 22 | -------------------------------------------------------------------------------- /test/fixtures/2/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 |
hi
7 |
hi
8 | 9 | 10 | -------------------------------------------------------------------------------- /test/fixtures/2/style.css: -------------------------------------------------------------------------------- 1 | div { 2 | color: red; 3 | } 4 | 5 | .used { 6 | color: green; 7 | } 8 | 9 | .never-used { 10 | color: blue; /* unused! */ 11 | display: block; /* unused! */ 12 | } 13 | -------------------------------------------------------------------------------- /test/fixtures/3/a.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 |
hi
7 |
hi
8 | 9 | 10 | -------------------------------------------------------------------------------- /test/fixtures/3/b.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 |
hi
7 |
hi
8 | 9 | 10 | -------------------------------------------------------------------------------- /test/fixtures/3/style.css: -------------------------------------------------------------------------------- 1 | .used-by-a { 2 | color: red; 3 | } 4 | 5 | .used-by-b { 6 | color: red; 7 | } 8 | 9 | .used-by-both { 10 | color: red; 11 | } 12 | 13 | .used-by-neither { 14 | color: red; /* unused! */ 15 | } 16 | -------------------------------------------------------------------------------- /test/fixtures/4/config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('../../../src/css-trimmer.js').Options} */ 2 | module.exports = { 3 | viewports: [{width: 500, height: 500}], 4 | }; 5 | -------------------------------------------------------------------------------- /test/fixtures/4/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 18 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /test/fixtures/5/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 12 | 13 | 14 |
hi
15 | 16 | 17 | -------------------------------------------------------------------------------- /test/fixtures/6/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 17 | 18 | 19 |
hi
20 | 21 | 22 | -------------------------------------------------------------------------------- /test/fixtures/7/index-2.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 16 | 17 | 18 |
hi
19 | 20 | 21 | -------------------------------------------------------------------------------- /test/fixtures/7/index.html: -------------------------------------------------------------------------------- 1 | 3 | 4 | 5 | hi 6 | 19 | 20 | 21 |
hi
22 | 23 | 24 | -------------------------------------------------------------------------------- /test/fixtures/8/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 13 | 14 | 15 |
hi
16 | 17 | 18 | -------------------------------------------------------------------------------- /test/fixtures/lh/config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('../../../src/css-trimmer.js').Options} */ 2 | module.exports = { 3 | afterNavigation: async (context) => { 4 | await context.current.page.evaluate(() => { 5 | // document.querySelector('main').classList.add('score100'); 6 | 7 | // @ts-ignore 8 | // TODO: do this sometimes 9 | document.querySelector('.lh-tools__button').click(); 10 | }); 11 | }, 12 | onlyCollections: [ 13 | 'test/fixtures/lh/report-1.html viewport 500,500', 14 | 'test/fixtures/lh/report-6.html viewport 500,500', 15 | 'test/fixtures/lh/report-6.html initial', 16 | 'test/fixtures/lh/report-2.html initial', 17 | ], 18 | viewports: [{ width: 500, height: 500 }], 19 | }; 20 | -------------------------------------------------------------------------------- /test/generate-lighthouse-fixtures.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const ReportGenerator = require('lighthouse/lighthouse-core/report/report-generator.js'); 3 | 4 | const files = fs.readdirSync(process.argv[2]) 5 | .map(file => process.argv[2] + '/' + file) 6 | .slice(0, 10); 7 | 8 | for (const [i, file] of Object.entries(files)) { 9 | const lhr = JSON.parse(fs.readFileSync(file, 'utf-8')); 10 | const report = ReportGenerator.generateReportHtml(lhr); 11 | fs.writeFileSync(`${__dirname}/fixtures/lh/report-${Number(i) + 1}.html`, report); 12 | } 13 | 14 | // node src/cli.js test/fixtures/lh/*.html --viewports=500,500 --color-scheme=dark --debug 15 | -------------------------------------------------------------------------------- /test/snapshot-test.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const { spawnSync } = require('child_process'); 3 | 4 | /** 5 | * @param {string} name 6 | * @param {string[]} args 7 | */ 8 | function defineTest(name, args) { 9 | it(`snapshot ${name}`, async () => { 10 | const {stdout, stderr} = spawnSync('node', [ 11 | `${__dirname}/../src/cli.js`, 12 | ...args, 13 | '--output=json', 14 | '--quiet', 15 | '--debug', 16 | ]); 17 | const json = stdout.toString(); 18 | if (stderr.toString()) console.log(stderr.toString()); 19 | 20 | /** @type {import('../src/css-trimmer.js').Report} */ 21 | const report = JSON.parse(json); 22 | 23 | expect(report.output.replace(__dirname, '...')).toMatchSnapshot(); 24 | 25 | if (name === '/fixtures/lh') return; 26 | if (name.includes('http')) return; 27 | 28 | const numExpectedUnused = report.styleSheets 29 | .map(styleSheet => (styleSheet.content.match(/unused!/g) || []).length) 30 | .reduce((acc, cur) => acc + cur, 0); 31 | 32 | expect(report.unusedDeclarationCount).toBe(numExpectedUnused); 33 | }); 34 | } 35 | 36 | for (const name of fs.readdirSync(__dirname + '/fixtures')) { 37 | const dir = `test/fixtures/${name}`; 38 | const configPath = `${dir}/config.js`; 39 | const htmlFiles = fs 40 | .readdirSync(dir) 41 | .filter(file => file.endsWith('.html')) 42 | .map(file => `${dir}/${file}`); 43 | 44 | const args = [ 45 | '--disable-default-config', 46 | ...htmlFiles, 47 | ]; 48 | if (fs.existsSync(configPath)) args.push(`--config-path=${configPath}`); 49 | defineTest(`/fixtures/${name}`, args); 50 | } 51 | 52 | defineTest('https://www.example.com', [ 53 | 'https://www.example.com', 54 | ]); 55 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "noEmit": true, 4 | "module": "commonjs", 5 | "target": "ES2017", 6 | "allowJs": true, 7 | "checkJs": true, 8 | "strict": true, 9 | // "listFiles": true, 10 | // "noErrorTruncation": true, 11 | 12 | "resolveJsonModule": true, 13 | "diagnostics": true 14 | }, 15 | "include": [ 16 | "src", 17 | "test" 18 | ] 19 | } 20 | --------------------------------------------------------------------------------