├── .editorconfig ├── .github └── workflows │ ├── build-report.yml │ ├── merge-trunk-to-report.yml │ └── node.yaml ├── .gitignore ├── .nvmrc ├── README.md ├── css-audit.config.js ├── index.js ├── package-lock.json ├── package.json ├── public └── .gitkeep └── src ├── __tests__ ├── alphas.js ├── colors.js ├── display-none.js ├── important.js ├── media-queries.js ├── property-values.js ├── run.js └── selectors.js ├── audits ├── alphas.js ├── colors.js ├── display-none.js ├── important.js ├── media-queries.js ├── property-values.js ├── selectors.js └── typography.js ├── formats ├── cli-table.js ├── html.js ├── html │ ├── _audit-alpha.twig │ ├── _audit-colors.twig │ ├── _audit-default.twig │ ├── index.twig │ └── style.css └── json.js ├── run.js └── utils ├── __tests__ ├── cli.js ├── example-config.config.js └── get-specificity.js ├── cli.js ├── format-report.js ├── get-specificity.js └── get-values-count.js /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | indent_style = tab 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | 11 | [*.md] 12 | trim_trailing_whitespace = false 13 | -------------------------------------------------------------------------------- /.github/workflows/build-report.yml: -------------------------------------------------------------------------------- 1 | name: Build Report 2 | on: 3 | push: 4 | branches: 5 | # Run only on pushes to report 6 | - report 7 | schedule: 8 | # Run every day at 9am UTC. 9 | # * is a special character in YAML so you have to quote this string 10 | - cron: '0 9 * * *' 11 | workflow_dispatch: 12 | jobs: 13 | run: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - name: Checkout CSS Audit 17 | uses: actions/checkout@v2 18 | with: 19 | ref: report 20 | - name: Checkout WordPress Core 21 | uses: actions/checkout@v2 22 | with: 23 | repository: WordPress/wordpress-develop 24 | path: wordpress 25 | 26 | - name: Set up Node.js 27 | uses: actions/setup-node@v1 28 | with: 29 | node-version: 14.x 30 | 31 | - name: Install dependencies 32 | run: npm install 33 | 34 | - name: Build the audit report 35 | run: npm run css-audit -- wordpress/src/wp-admin/css/*.css wordpress/src/wp-includes/css/*.css 36 | 37 | - name: Commit changes 38 | uses: EndBug/add-and-commit@v5 39 | with: 40 | author_name: github-actions 41 | author_email: 41898282+github-actions[bot]@users.noreply.github.com 42 | message: "[Automated] Update report" 43 | branch: "report" 44 | add: "public" 45 | env: 46 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 47 | -------------------------------------------------------------------------------- /.github/workflows/merge-trunk-to-report.yml: -------------------------------------------------------------------------------- 1 | name: Merge trunk into report branch 2 | on: 3 | push: 4 | branches: 5 | # Run on pushes to trunk 6 | - 'trunk' 7 | jobs: 8 | merge-branch: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v2 12 | - name: Merge trunk -> report 13 | uses: devmasx/merge-branch@v1.3.1 14 | with: 15 | type: now 16 | target_branch: report 17 | github_token: ${{ secrets.GITHUB_TOKEN }} 18 | -------------------------------------------------------------------------------- /.github/workflows/node.yaml: -------------------------------------------------------------------------------- 1 | name: Node.js CI 2 | 3 | on: 4 | push: 5 | paths: 6 | - '**.js' 7 | pull_request: 8 | paths: 9 | - '**.js' 10 | 11 | jobs: 12 | build: 13 | 14 | runs-on: ubuntu-latest 15 | 16 | strategy: 17 | matrix: 18 | node-version: [14.x] 19 | 20 | steps: 21 | - uses: actions/checkout@v2 22 | 23 | - name: Use Node.js ${{ matrix.node-version }} 24 | uses: actions/setup-node@v1 25 | with: 26 | node-version: ${{ matrix.node-version }} 27 | 28 | - run: npm install 29 | 30 | - run: npm test 31 | env: 32 | CI: true 33 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | _css/ 3 | _build/ 4 | *-notes.md 5 | v*.*/ 6 | trunk/ -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 14 -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # CSS Audit 2 | 3 | This project contains an automated audit of the core WordPress CSS, including the number of distinct colors used, most specific selectors, how many properties use `!important`, and more. [View the audit report here.](https://wordpress.github.io/css-audit/public/wp-admin) This report is regenerated every day at 09:00 UTC, and runs over the latest CSS in [WordPress/wordpress-develop](https://github.com/WordPress/wordpress-develop/). 4 | 5 | To generate this report, there is a tool `css-audit`, which runs a set of [audits](./src/audits). 6 | 7 | ## Local Environment 8 | 9 | To run the audits yourself, download or clone this repo, then install the dependencies. You will need [node & npm](https://nodejs.org/en/) installed. 10 | 11 | ``` 12 | $ git clone git@github.com:wordpress/css-audit.git 13 | $ cd css-audit 14 | $ npm install 15 | $ npm run css-audit -- 16 | ``` 17 | 18 | If you want to work on the audits yourself, [fork this repo](https://help.github.com/en/github/getting-started-with-github/fork-a-repo) to your account first. You can submit issues or PRs. 19 | 20 | ## Running Audits 21 | 22 | To run the audits, you need a list of CSS files, and to indicate which audits you want to run. `yarn` and `npm` both automatically expand globs (`folder/*`), so you can use that, or pass in a list of CSS files. The audits are described below, and can be run via the following CLI args, or via configuration file (described in the next section). 23 | 24 | ``` 25 | $ npm run css-audit -- [options] 26 | 27 | Usage: css-audit -- [options] 28 | 29 | --colors Run colors audit. 30 | --important Run !important audit. 31 | --display-none Run display: none audit. 32 | --selectors Run selectors audit. 33 | --media-queries Run media queries audit. 34 | --property-values Run audit for a given set of property values, comma-separated. 35 | --recommended Run recommended audits (colors, important, selectors). Default: true. 36 | --all Run all audits (except property values, as it requires a value). 37 | --format Format to use for displaying report. 38 | --filename If using a format that outputs to a file, specify the file name. 39 | --help Show this message. 40 | ``` 41 | 42 | 43 | ### Configuration File 44 | 45 | The program will prioritize configuration from CLI arguments, and will fallback to configuration stored in a file called `css-audit.config.js`. 46 | 47 | ``` 48 | module.exports = { 49 | format: 'json', 50 | audits: [ 51 | 'colors', 52 | 'important', 53 | 'display-none', 54 | 'selectors', 55 | 'media-queries', 56 | [ 'property-values', 'font-size' ], 57 | [ 'property-values', 'padding-top,padding-bottom' ], 58 | ], 59 | }; 60 | ``` 61 | 62 | ## Generating HTML Reports 63 | 64 | To generate an HTML report, use the `--format=html` option and specify a name for the file with the `--filename=name` option. This will output a `{name}.html` file in public/ that is viewable on Github Pages. 65 | 66 | For example, generating a report for wp-admin using the below strategy for pulling down CSS files from SVN: 67 | 68 | ``` 69 | npm run css-audit -- v5.5/**/* --format=html --all --filename=wp-admin 70 | ``` 71 | 72 | In the configuration file, the argument `filename` can be added as a simple property: value combination, the same as `format` in the example. See the [default `css-audit.config.js`](./css-audit.config.js). 73 | 74 | ## Getting core CSS files 75 | 76 | You can download the source files of CSS (not minified or RTL'd) from the svn repository. The following code will create a new directory, `v5.5`, and download just the files from each `css` folder. 77 | 78 | ``` 79 | mkdir v5.5 80 | svn export https://develop.svn.wordpress.org/branches/5.5/src/wp-admin/css --depth files v5.5/admin 81 | svn export https://develop.svn.wordpress.org/branches/5.5/src/wp-includes/css --depth files v5.5/includes 82 | ``` 83 | 84 | If you want to run this on trunk (code currently in development), you can swap out `branches/5.5` for `trunk`. You could also swap the `5.5` for `5.4`, etc. Example: 85 | 86 | ``` 87 | mkdir trunk 88 | svn export https://develop.svn.wordpress.org/trunk/src/wp-admin/css --depth files trunk/admin 89 | svn export https://develop.svn.wordpress.org/trunk/src/wp-includes/css --depth files trunk/includes 90 | ``` 91 | 92 | Now you can run the audits: 93 | 94 | ``` 95 | npm run css-audit -- v5.5/**/* --recommended 96 | ``` 97 | 98 | ## Available Audits 99 | 100 | - `colors` 101 | - Number of unique colors — normalizes hex colors so that uppercase & lowercase are not counted twice 102 | - Number of unique colors (ignoring opacity) 103 | - List of all colors 104 | - Top 10 most-used colors 105 | - Top 10 least-used colors 106 | - `important` 107 | - Number of times `!important` is used 108 | - Top properties that use !important 109 | - `property-values` — needs a list of properties to inspect. 110 | - Usage: `--property-values=[properties]`. For example: `--property-values=display`, or `--property-values=padding,margin` 111 | - Number of unique values for [property] 112 | - Top 10 most-used values for [property] 113 | - Top 10 least-used values for [property] 114 | - `selectors` 115 | - Total number of selectors 116 | - Number of selectors with IDs — not "number of IDs", a lot of selectors use multiple IDs, but they'd only be counted once 117 | - Top 10 selectors with the highest specificity 118 | - Top 10 selectors by length 119 | - `display-none` 120 | - Number of times `display: none` is used 121 | - Places where `display: none` is used 122 | - `typography` 123 | - A collection of information about various typography-related properties 124 | 125 | ## Technical details 126 | 127 | This tool parses each CSS file and creates an AST, which the audits traverse to pull out data. It uses [`postcss`](https://postcss.org/) for most audits, but [`csstree`](https://github.com/csstree/csstree) for the `media-queries` audit. PostCSS gives us the plugins ecosystem so that we can use `postcss-values-parser`, while csstree generates a much more detailed AST that robustly identifies media queries. 128 | 129 | - [PostCSS API documentation](https://postcss.org/api/) 130 | - [csstree documentation](https://github.com/csstree/csstree/tree/master/docs) 131 | - [AST Explorer](https://astexplorer.net/) — great tool for identifying how the CSS is parsed. 132 | -------------------------------------------------------------------------------- /css-audit.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | format: 'html', 3 | filename: 'wp-admin', 4 | audits: [ 5 | 'colors', 6 | 'alphas', 7 | 'important', 8 | 'display-none', 9 | 'selectors', 10 | 'media-queries', 11 | 'typography', 12 | ], 13 | }; 14 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Node dependencies 3 | */ 4 | const fs = require( 'fs' ); 5 | const path = require( 'path' ); 6 | 7 | /** 8 | * Internal dependencies 9 | */ 10 | const { runAudits } = require( './src/run' ); 11 | const { getArg, getFileArgsFromCLI, getHelp } = require( './src/utils/cli' ); 12 | 13 | const input = getFileArgsFromCLI(); 14 | 15 | if ( getArg( '--help', true ) || ! input.length ) { 16 | console.log( getHelp() ); 17 | process.exit( 0 ); 18 | } 19 | 20 | const cssFiles = []; 21 | input.forEach( ( file ) => { 22 | const filePath = path.resolve( process.env.INIT_CWD, file ); 23 | const stats = fs.statSync( filePath ); 24 | if ( stats.isDirectory() ) { 25 | return; 26 | } 27 | if ( file.match( /min\.css$/ ) ) { 28 | return; 29 | } 30 | cssFiles.push( { 31 | name: file, 32 | content: String( fs.readFileSync( filePath ) ), 33 | } ); 34 | } ); 35 | 36 | const result = runAudits( cssFiles ); 37 | 38 | console.log( result ); 39 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "css-audit", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "css-audit": "node ./index.js", 8 | "lint:js": "eslint src", 9 | "format:js": "prettier src/**/*.js *.js --write", 10 | "test": "jest" 11 | }, 12 | "author": "WordPress CSS Contributors", 13 | "license": "GPL-2.0-or-later", 14 | "dependencies": { 15 | "@wordpress/eslint-plugin": "9.0.4", 16 | "@wordpress/prettier-config": "1.0.3", 17 | "chalk": "4.1.1", 18 | "cli-table3": "0.6.0", 19 | "cosmiconfig": "7.0.0", 20 | "css-tree": "1.1.3", 21 | "cssom": "0.4.4", 22 | "eslint": "7.26.0", 23 | "fs-extra": "10.0.0", 24 | "glob": "7.1.7", 25 | "minimist": "1.2.6", 26 | "postcss": "8.2.15", 27 | "postcss-values-parser": "5.0.0", 28 | "prettier": "npm:wp-prettier@^2.0.5", 29 | "tinycolor2": "1.4.2" 30 | }, 31 | "eslintConfig": { 32 | "extends": [ 33 | "plugin:@wordpress/eslint-plugin/esnext", 34 | "plugin:prettier/recommended" 35 | ], 36 | "rules": { 37 | "no-console": "off" 38 | }, 39 | "env": { 40 | "node": true, 41 | "jest": true 42 | } 43 | }, 44 | "jest": { 45 | "modulePathIgnorePatterns": [ 46 | "fixtures", 47 | ".config.js" 48 | ] 49 | }, 50 | "prettier": "@wordpress/prettier-config", 51 | "devDependencies": { 52 | "handlebars": "4.7.7", 53 | "jest": "26.6.3", 54 | "twing": "5.0.2" 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /public/.gitkeep: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /src/__tests__/alphas.js: -------------------------------------------------------------------------------- 1 | const audit = require( '../audits/alphas' ); 2 | 3 | function getResultValue( results, key ) { 4 | const { value } = results.find( ( { id } ) => key === id ); 5 | return value; 6 | } 7 | 8 | describe( 'Audit: Alphas', () => { 9 | it( 'should return no values when no transparent colors are used', () => { 10 | const files = [ 11 | { 12 | name: 'a.css', 13 | content: `body { font-size: 1em; line-height: 1.6; color: red }`, 14 | }, 15 | ]; 16 | const { results } = audit( files ); 17 | expect( getResultValue( results, 'unique' ) ).toBe( 0 ); 18 | } ); 19 | 20 | it( 'should count the number of alpha values in a file', () => { 21 | const files = [ 22 | { 23 | name: 'a.css', 24 | content: `body { color: rgba(0, 0, 0, 0.3); }`, 25 | }, 26 | ]; 27 | const { results } = audit( files ); 28 | expect( getResultValue( results, 'unique' ) ).toBe( 1 ); 29 | } ); 30 | 31 | it( 'should find values across rulesets', () => { 32 | const files = [ 33 | { 34 | name: 'a.css', 35 | content: `h1 { color: rgba(52, 0, 182, 0.95); } 36 | h2 { color: rgba(206, 234, 196, .95); } 37 | h3 { color: rgba( 119, 255, 214, 0.125); } 38 | h4 { color: rgba(201, 15, 59, .875); } 39 | h4 { color: rgba(0, 0, 0, 1); }`, 40 | }, 41 | ]; 42 | const { results } = audit( files ); 43 | expect( getResultValue( results, 'unique' ) ).toBe( 4 ); 44 | expect( getResultValue( results, 'unique-colors' ) ).toBe( 5 ); 45 | expect( getResultValue( results, 'all-alphas' ) ).toEqual( [ 46 | { 47 | count: 2, 48 | name: 0.95, 49 | }, 50 | { 51 | count: 1, 52 | name: 0.125, 53 | }, 54 | { 55 | count: 1, 56 | name: 0.875, 57 | }, 58 | { 59 | count: 1, 60 | name: 1, 61 | }, 62 | ] ); 63 | expect( getResultValue( results, 'all-colors' ) ).toEqual( [ 64 | 'rgba( 119, 255, 214, 0.125)', 65 | 'rgba(0, 0, 0, 1)', 66 | 'rgba(201, 15, 59, .875)', 67 | 'rgba(206, 234, 196, .95)', 68 | 'rgba(52, 0, 182, 0.95)', 69 | ] ); 70 | } ); 71 | 72 | it( 'should find values in all color functions', () => { 73 | const files = [ 74 | { 75 | name: 'a.css', 76 | content: `h1 { color: hsla(95, 55%, 46%, 0.9); } 77 | h2 { color: hsl(290, 72%, 24%, .333); } 78 | h3 { color: rgb(41, 194, 191, 1); } 79 | h4 { color: hsl(198 92 20 / 0.5); } 80 | h5 { color: rgb(13 1 26 / 0.05); }`, 81 | }, 82 | ]; 83 | const { results } = audit( files ); 84 | expect( getResultValue( results, 'unique' ) ).toBe( 5 ); 85 | expect( getResultValue( results, 'unique-colors' ) ).toBe( 5 ); 86 | expect( getResultValue( results, 'all-alphas' ) ).toEqual( [ 87 | { 88 | count: 1, 89 | name: 0.9, 90 | }, 91 | { 92 | count: 1, 93 | name: 0.333, 94 | }, 95 | { 96 | count: 1, 97 | name: 1, 98 | }, 99 | { 100 | count: 1, 101 | name: 0.5, 102 | }, 103 | { 104 | count: 1, 105 | name: 0.05, 106 | }, 107 | ] ); 108 | expect( getResultValue( results, 'all-colors' ) ).toEqual( [ 109 | 'hsl(198 92 20 / 0.5)', 110 | 'hsl(290, 72%, 24%, .333)', 111 | 'hsla(95, 55%, 46%, 0.9)', 112 | 'rgb(13 1 26 / 0.05)', 113 | 'rgb(41, 194, 191, 1)', 114 | ] ); 115 | } ); 116 | 117 | it( 'should count alphas in shorthand properties', () => { 118 | const files = [ 119 | { 120 | name: 'a.css', 121 | content: `body { border: 3px solid rgba(0, 0, 0, 0.5); }`, 122 | }, 123 | ]; 124 | const { results } = audit( files ); 125 | const { value } = results.find( ( { id } ) => 'unique' === id ); 126 | expect( value ).toBe( 1 ); 127 | } ); 128 | } ); 129 | -------------------------------------------------------------------------------- /src/__tests__/colors.js: -------------------------------------------------------------------------------- 1 | const audit = require( '../audits/colors' ); 2 | 3 | describe( 'Audit: Colors', () => { 4 | it( 'should return no colors when no colors are used', () => { 5 | const files = [ 6 | { 7 | name: 'a.css', 8 | content: `body { font-size: 1em; line-height: 1.6; }`, 9 | }, 10 | ]; 11 | const { results } = audit( files ); 12 | const { value } = results.find( ( { id } ) => 'unique' === id ); 13 | expect( value ).toBe( 0 ); 14 | } ); 15 | 16 | it( 'should ignore colors in non-color properties', () => { 17 | const files = [ 18 | { 19 | name: 'a.css', 20 | content: `body { background-image: url('logo-white.png'); }`, 21 | }, 22 | ]; 23 | const { results } = audit( files ); 24 | const { value } = results.find( ( { id } ) => 'unique' === id ); 25 | expect( value ).toBe( 0 ); 26 | } ); 27 | 28 | it( 'should handle filter values', () => { 29 | const files = [ 30 | { 31 | name: 'a.css', 32 | content: `img { filter: alpha(opacity=60); }`, 33 | }, 34 | ]; 35 | const { results } = audit( files ); 36 | const { value } = results.find( ( { id } ) => 'unique' === id ); 37 | expect( value ).toBe( 0 ); 38 | } ); 39 | 40 | it( 'should count colors in shorthand properties', () => { 41 | const files = [ 42 | { 43 | name: 'a.css', 44 | content: `body { border: 3px solid red; }`, 45 | }, 46 | ]; 47 | const { results } = audit( files ); 48 | const { value } = results.find( ( { id } ) => 'unique' === id ); 49 | expect( value ).toBe( 1 ); 50 | } ); 51 | 52 | it( 'should count the number of colors in a file', () => { 53 | const files = [ 54 | { 55 | name: 'a.css', 56 | content: `body { color: #0ff; }`, 57 | }, 58 | ]; 59 | const { results } = audit( files ); 60 | const { value } = results.find( ( { id } ) => 'unique' === id ); 61 | expect( value ).toBe( 1 ); 62 | } ); 63 | 64 | it( 'should count the number of colors in two files', () => { 65 | const files = [ 66 | { 67 | name: 'a.css', 68 | content: `body { color: #0ff; }`, 69 | }, 70 | { 71 | name: 'b.css', 72 | content: `body { color: rgba(0,0,0,0.5); }`, 73 | }, 74 | ]; 75 | const { results } = audit( files ); 76 | const { value } = results.find( ( { id } ) => 'unique' === id ); 77 | expect( value ).toBe( 2 ); 78 | } ); 79 | 80 | it( 'should sort the most used colors', () => { 81 | const files = [ 82 | { 83 | name: 'a.css', 84 | content: `h1 { color: red; } 85 | h2 { color: pink; } 86 | h3 { color: pink; } 87 | h4 { color: blue; } 88 | h5 { color: cyan; } 89 | h6 { color: lightgreen; }`, 90 | }, 91 | ]; 92 | const { results } = audit( files ); 93 | const { value } = results.find( ( { id } ) => 'top-10-colors' === id ); 94 | expect( value[ 0 ].name ).toBe( 'pink' ); 95 | } ); 96 | 97 | it( 'should sort the least used colors', () => { 98 | const files = [ 99 | { 100 | name: 'a.css', 101 | content: `h1 { color: red; } 102 | h2 { color: pink; } 103 | h3 { color: red; } 104 | h4 { color: blue; } 105 | h5 { color: blue; } 106 | h6 { color: red; }`, 107 | }, 108 | ]; 109 | const { results } = audit( files ); 110 | const { value } = results.find( 111 | ( { id } ) => 'bottom-10-colors' === id 112 | ); 113 | expect( value[ 0 ].name ).toBe( 'pink' ); 114 | } ); 115 | } ); 116 | -------------------------------------------------------------------------------- /src/__tests__/display-none.js: -------------------------------------------------------------------------------- 1 | const audit = require( '../audits/display-none' ); 2 | 3 | describe( 'Audit: Display None', () => { 4 | it( 'should return nothing when display:none is not used', () => { 5 | const files = [ 6 | { 7 | name: 'a.css', 8 | content: `body { font-size: 1em; line-height: 1.6; }`, 9 | }, 10 | ]; 11 | const { results } = audit( files ); 12 | const { value } = results.find( ( { id } ) => 'count' === id ); 13 | expect( value ).toBe( 0 ); 14 | } ); 15 | 16 | it( 'should count the number of display:none in a file', () => { 17 | const files = [ 18 | { 19 | name: 'a.css', 20 | content: `body { display: none; }`, 21 | }, 22 | ]; 23 | const { results } = audit( files ); 24 | const { value } = results.find( ( { id } ) => 'count' === id ); 25 | expect( value ).toBe( 1 ); 26 | } ); 27 | 28 | it( 'should count the number of display:none in two files', () => { 29 | const files = [ 30 | { 31 | name: 'a.css', 32 | content: `body { padding: 20px; display:none; }`, 33 | }, 34 | { 35 | name: 'b.css', 36 | content: `body.hidden { display: none; }`, 37 | }, 38 | ]; 39 | const { results } = audit( files ); 40 | const { value: count } = results.find( ( { id } ) => 'count' === id ); 41 | expect( count ).toBe( 2 ); 42 | 43 | const { value: instances } = results.find( 44 | ( { id } ) => 'instances' === id 45 | ); 46 | expect( instances ).toEqual( [ 47 | // Per line count looks for line breaks, so 1 is correct here. 48 | { file: 'a.css', selector: 'body' }, 49 | { file: 'b.css', selector: 'body.hidden' }, 50 | ] ); 51 | } ); 52 | } ); 53 | -------------------------------------------------------------------------------- /src/__tests__/important.js: -------------------------------------------------------------------------------- 1 | const audit = require( '../audits/important' ); 2 | 3 | describe( 'Audit: Important', () => { 4 | it( 'should return nothing when important is not used', () => { 5 | const files = [ 6 | { 7 | name: 'a.css', 8 | content: `body { font-size: 1em; line-height: 1.6; }`, 9 | }, 10 | ]; 11 | const { results } = audit( files ); 12 | const { value } = results.find( ( { id } ) => 'count' === id ); 13 | expect( value ).toBe( 0 ); 14 | } ); 15 | 16 | it( 'should ignore the word important in values', () => { 17 | const files = [ 18 | { 19 | name: 'a.css', 20 | content: `body { background-image: url('important-logo.png'); }`, 21 | }, 22 | ]; 23 | const { results } = audit( files ); 24 | const { value } = results.find( ( { id } ) => 'count' === id ); 25 | expect( value ).toBe( 0 ); 26 | } ); 27 | 28 | it( 'should count the number of !important in a file', () => { 29 | const files = [ 30 | { 31 | name: 'a.css', 32 | content: `body { color: #0ff !important; }`, 33 | }, 34 | ]; 35 | const { results } = audit( files ); 36 | const { value } = results.find( ( { id } ) => 'count' === id ); 37 | expect( value ).toBe( 1 ); 38 | } ); 39 | 40 | it( 'should count the number of !important in two files', () => { 41 | const files = [ 42 | { 43 | name: 'a.css', 44 | content: `body { padding: 20px; margin-bottom: 10px !important; }`, 45 | }, 46 | { 47 | name: 'b.css', 48 | content: `body { color: red !important; }`, 49 | }, 50 | ]; 51 | const { results } = audit( files ); 52 | const { value: count } = results.find( ( { id } ) => 'count' === id ); 53 | expect( count ).toBe( 2 ); 54 | 55 | const { value: countFile } = results.find( 56 | ( { id } ) => 'count-per-file' === id 57 | ); 58 | expect( countFile ).toEqual( [ 59 | // Per line count looks for line breaks, so 1 is correct here. 60 | { name: 'a.css', count: 1, perLine: 1 }, 61 | { name: 'b.css', count: 1, perLine: 1 }, 62 | ] ); 63 | } ); 64 | 65 | it( 'should sort the properties that most use !important', () => { 66 | const files = [ 67 | { 68 | name: 'a.css', 69 | content: ` 70 | h1 { 71 | font-size: 2em !important; 72 | letter-spacing: 1px; 73 | text-transform: uppercase !important; 74 | } 75 | h2 { 76 | font-size: 2em !important; 77 | letter-spacing: 1px; 78 | text-transform: uppercase; 79 | }`, 80 | }, 81 | ]; 82 | const { results } = audit( files ); 83 | const { value } = results.find( 84 | ( { id } ) => 'top-10-properties' === id 85 | ); 86 | expect( value[ 0 ].name ).toBe( 'font-size' ); 87 | } ); 88 | } ); 89 | -------------------------------------------------------------------------------- /src/__tests__/media-queries.js: -------------------------------------------------------------------------------- 1 | const audit = require( '../audits/media-queries' ); 2 | 3 | describe( 'Audit: Media Queries', () => { 4 | it( 'should return nothing when no media queries are used', () => { 5 | const files = [ 6 | { 7 | name: 'a.css', 8 | content: `body { font-size: 1em; line-height: 1.6; }`, 9 | }, 10 | ]; 11 | const { results } = audit( files ); 12 | const { value } = results.find( ( { id } ) => 'count' === id ); 13 | expect( value ).toBe( 0 ); 14 | } ); 15 | 16 | it( 'should count the number of media queries', () => { 17 | const files = [ 18 | { 19 | name: 'a.css', 20 | content: `body { font-size: 1em; line-height: 1.6; } 21 | @media (max-width:30rem) { body { font-size: 2em; } }`, 22 | }, 23 | ]; 24 | const { results } = audit( files ); 25 | const { value } = results.find( ( { id } ) => 'count' === id ); 26 | expect( value ).toBe( 1 ); 27 | } ); 28 | 29 | it( 'should count multiple media queries', () => { 30 | const files = [ 31 | { 32 | name: 'a.css', 33 | content: `body { font-size: 1em; line-height: 1.6; } 34 | @media (max-width: 30rem) { body { font-size: 2em; } } 35 | @media screen and (max-width: 20rem) { body { font-size: 1.5em; } } 36 | @media (max-width: 20rem) { body { font-size: 1.5em; } }`, 37 | }, 38 | { 39 | name: 'b.css', 40 | content: `body { font-size: 1em; line-height: 1.6; } 41 | @media (max-width: 15rem) { body { font-size: 2em; } } 42 | @media (max-width: 20rem) { body { font-size: 1.5em; } }`, 43 | }, 44 | ]; 45 | const { results } = audit( files ); 46 | const { value: count } = results.find( ( { id } ) => 'count' === id ); 47 | expect( count ).toBe( 5 ); 48 | } ); 49 | 50 | it( 'should track different sizes in media queries', () => { 51 | const files = [ 52 | { 53 | name: 'a.css', 54 | content: `body { font-size: 1em; line-height: 1.6; } 55 | @media (max-width: 30rem) { body { font-size: 2em; } } 56 | @media screen and (max-width: 20rem) { body { font-size: 1.5em; } } 57 | @media (max-width: 20rem) { body { font-size: 1.5em; } }`, 58 | }, 59 | { 60 | name: 'b.css', 61 | content: `body { font-size: 1em; line-height: 1.6; } 62 | @media (max-width: 15rem) { body { font-size: 2em; } } 63 | @media (max-width: 20rem) { body { font-size: 1.5em; } }`, 64 | }, 65 | ]; 66 | const { results } = audit( files ); 67 | const { value: uniqueQueries } = results.find( 68 | ( { id } ) => 'count-unique-queries' === id 69 | ); 70 | expect( uniqueQueries ).toBe( 4 ); 71 | 72 | const { value: topQueries } = results.find( 73 | ( { id } ) => 'top-10-queries' === id 74 | ); 75 | expect( topQueries[ 0 ].count ).toBe( 2 ); 76 | expect( topQueries[ 0 ].name ).toBe( '(max-width:20rem)' ); 77 | 78 | const { value: topSizes } = results.find( 79 | ( { id } ) => 'top-10-sizes' === id 80 | ); 81 | expect( topSizes[ 0 ].count ).toBe( 3 ); 82 | expect( topSizes[ 0 ].name ).toBe( '20rem' ); 83 | } ); 84 | } ); 85 | -------------------------------------------------------------------------------- /src/__tests__/property-values.js: -------------------------------------------------------------------------------- 1 | const audit = require( '../audits/property-values' ); 2 | 3 | describe( 'Audit: Property Values', () => { 4 | it( 'should return nothing if no properties are set', () => { 5 | const files = [ 6 | { 7 | name: 'a.css', 8 | content: `body { font-size: 1em; line-height: 1.6; }`, 9 | }, 10 | ]; 11 | const { results } = audit( files ); 12 | expect( results ).toHaveLength( 0 ); 13 | } ); 14 | 15 | it( 'should return nothing if the requested property is not used', () => { 16 | const files = [ 17 | { 18 | name: 'a.css', 19 | content: `body { font-size: 1em; line-height: 1.6; }`, 20 | }, 21 | ]; 22 | const { results } = audit( files, [ 'padding' ] ); 23 | const { value } = results.find( ( { id } ) => 'count' === id ); 24 | expect( value ).toBe( 0 ); 25 | } ); 26 | 27 | it( 'should count number of properties', () => { 28 | const files = [ 29 | { 30 | name: 'a.css', 31 | content: `.box { padding: 10px; } 32 | .box-small { padding: 5px; }`, 33 | }, 34 | { 35 | name: 'b.css', 36 | content: `.spacy-box { padding: 20px; } 37 | .spacy-box-small { padding: 10px; }`, 38 | }, 39 | ]; 40 | const { results } = audit( files, [ 'padding' ] ); 41 | const { value: count } = results.find( ( { id } ) => 'count' === id ); 42 | expect( count ).toBe( 4 ); 43 | 44 | const { value: unique } = results.find( 45 | ( { id } ) => 'count-unique' === id 46 | ); 47 | expect( unique ).toBe( 3 ); 48 | 49 | const { value: topValues } = results.find( 50 | ( { id } ) => 'top-10-values' === id 51 | ); 52 | expect( topValues[ 0 ].count ).toBe( 2 ); 53 | expect( topValues[ 0 ].name ).toBe( '10px' ); 54 | } ); 55 | } ); 56 | -------------------------------------------------------------------------------- /src/__tests__/run.js: -------------------------------------------------------------------------------- 1 | const { runAudits } = require( '../run' ); 2 | const { getConfig } = require( '../utils/cli' ); 3 | 4 | describe( 'Run the audits', () => { 5 | it( 'should output the JSON format from a configuration object', () => { 6 | const configSrc = getConfig( process.env.NODE_ENV ); 7 | 8 | const result = runAudits( [ 9 | { 10 | name: 'a.css', 11 | content: `body { font-size: 1em !important; line-height: 1.6; }`, 12 | }, 13 | ] ); 14 | 15 | configSrc.audits.forEach( ( audit ) => { 16 | if ( Array.isArray( audit ) ) { 17 | audit[ 1 ].split( ',' ).forEach( ( property ) => { 18 | expect( result ).toContain( property ); 19 | } ); 20 | } else { 21 | expect( result ).toContain( audit ); 22 | } 23 | } ); 24 | } ); 25 | } ); 26 | -------------------------------------------------------------------------------- /src/__tests__/selectors.js: -------------------------------------------------------------------------------- 1 | const audit = require( '../audits/selectors' ); 2 | 3 | describe( 'Audit: Selectors', () => { 4 | it( 'should count the number of selectors used', () => { 5 | const files = [ 6 | { 7 | name: 'a.css', 8 | content: `body { color: white; } 9 | p#test { color: white; } 10 | div { color: white; } 11 | .class { color: white; }`, 12 | }, 13 | ]; 14 | const { results } = audit( files ); 15 | const { value } = results.find( ( { id } ) => 'count' === id ); 16 | expect( value ).toBe( 4 ); 17 | } ); 18 | 19 | it( 'should count the number of selectors that contain IDs', () => { 20 | const files = [ 21 | { 22 | name: 'a.css', 23 | content: `body { color: white; } 24 | p#test { color: white; } 25 | div { color: white; } 26 | .class { color: white; }`, 27 | }, 28 | ]; 29 | const { results } = audit( files ); 30 | const { value } = results.find( ( { id } ) => 'count-with-ids' === id ); 31 | expect( value ).toBe( 1 ); 32 | } ); 33 | 34 | it( 'should calculate and sort based on selector specificity', () => { 35 | const files = [ 36 | { 37 | name: 'a.css', 38 | content: `body { color: white; } 39 | p#test { color: white; } 40 | div { color: white; } 41 | span, .class { color: white; }`, 42 | }, 43 | ]; 44 | const { results } = audit( files ); 45 | const { value } = results.find( 46 | ( { id } ) => 'top-10-selectors' === id 47 | ); 48 | expect( value[ 0 ].selector ).toBe( 'p#test' ); 49 | expect( value[ 0 ].sum ).toBe( 101 ); 50 | expect( value[ 1 ].selector ).toBe( '.class' ); 51 | expect( value[ 1 ].sum ).toBe( 10 ); 52 | } ); 53 | 54 | it( 'should not double-count :not selectors', () => { 55 | const files = [ 56 | { 57 | name: 'a.css', 58 | content: `body { color: white; } 59 | p:not(.class) { color: white; }`, 60 | }, 61 | ]; 62 | const { results } = audit( files ); 63 | const { value } = results.find( ( { id } ) => 'count' === id ); 64 | expect( value ).toBe( 2 ); 65 | } ); 66 | } ); 67 | -------------------------------------------------------------------------------- /src/audits/alphas.js: -------------------------------------------------------------------------------- 1 | /** 2 | * External dependencies 3 | */ 4 | const { parse } = require( 'postcss' ); 5 | const { parse: parseValue } = require( 'postcss-values-parser' ); 6 | 7 | /** 8 | * Internal dependencies 9 | */ 10 | const getValuesCount = require( '../utils/get-values-count' ); 11 | 12 | module.exports = function ( files = [] ) { 13 | const alphas = []; 14 | const colors = []; 15 | 16 | files.forEach( ( { content, name } ) => { 17 | const root = parse( content, { from: name } ); 18 | root.walkDecls( function ( { value } ) { 19 | try { 20 | const valueRoot = parseValue( value, { 21 | ignoreUnknownWords: true, 22 | } ); 23 | 24 | valueRoot.walkFuncs( ( node ) => { 25 | if ( node.isColor && node.nodes.length ) { 26 | const values = node.nodes 27 | .filter( ( n ) => 'numeric' === n.type ) 28 | .map( ( n ) => Number( n.value ) ); 29 | if ( values.length === 4 ) { 30 | alphas.push( values[ 3 ] ); 31 | colors.push( node.toString() ); 32 | } 33 | } 34 | } ); 35 | } catch ( error ) {} 36 | } ); 37 | } ); 38 | 39 | const uniqAlphas = [ ...new Set( alphas ) ]; 40 | const alphasByCount = getValuesCount( alphas ); 41 | const uniqColors = [ ...new Set( colors ) ]; 42 | 43 | return { 44 | audit: 'alphas', 45 | name: 'Opacities', 46 | template: 'alpha', 47 | results: [ 48 | { 49 | id: 'unique', 50 | label: 'Number of unique alphas', 51 | value: uniqAlphas.length, 52 | }, 53 | { 54 | id: 'unique-colors', 55 | label: 'Number of colors with opacity', 56 | value: uniqColors.length, 57 | }, 58 | { 59 | id: 'all-alphas', 60 | label: 'List of all alphas', 61 | value: alphasByCount, 62 | }, 63 | { 64 | id: 'all-colors', 65 | label: 'List of all colors with opacity', 66 | value: uniqColors.sort(), 67 | }, 68 | ], 69 | }; 70 | }; 71 | -------------------------------------------------------------------------------- /src/audits/colors.js: -------------------------------------------------------------------------------- 1 | /** 2 | * External dependencies 3 | */ 4 | const { parse } = require( 'postcss' ); 5 | const { parse: parseValue } = require( 'postcss-values-parser' ); 6 | const tinycolor2 = require( 'tinycolor2' ); 7 | 8 | /** 9 | * Internal dependencies 10 | */ 11 | const getValuesCount = require( '../utils/get-values-count' ); 12 | 13 | module.exports = function ( files = [] ) { 14 | const colors = []; 15 | 16 | files.forEach( ( { content, name } ) => { 17 | const root = parse( content, { from: name } ); 18 | root.walkDecls( function ( { value } ) { 19 | try { 20 | const valueRoot = parseValue( value, { 21 | ignoreUnknownWords: true, 22 | } ); 23 | 24 | valueRoot.walkWords( ( node ) => { 25 | if ( node.isColor ) { 26 | colors.push( node.value ); 27 | } 28 | } ); 29 | 30 | valueRoot.walkFuncs( ( node ) => { 31 | if ( node.isColor ) { 32 | colors.push( node.toString() ); 33 | } 34 | } ); 35 | } catch ( error ) {} 36 | } ); 37 | } ); 38 | 39 | const uniqColors = [ ...new Set( colors ) ]; 40 | const colorsByCount = getValuesCount( colors ); 41 | 42 | const uniqOpaqueColors = [ 43 | ...new Set( 44 | uniqColors.map( ( colorStr ) => { 45 | const color = tinycolor2( colorStr ); 46 | return color.toHexString(); 47 | } ) 48 | ), 49 | ]; 50 | 51 | return { 52 | audit: 'colors', 53 | name: 'Colors', 54 | template: 'colors', 55 | results: [ 56 | { 57 | id: 'unique', 58 | label: 'Number of unique colors', 59 | value: uniqColors.length, 60 | }, 61 | { 62 | id: 'unique-opaque', 63 | label: 'Number of unique colors (ignoring opacity)', 64 | value: uniqOpaqueColors.length, 65 | }, 66 | { 67 | id: 'all-colors', 68 | label: 'List of all colors', 69 | value: uniqColors, 70 | }, 71 | { 72 | id: 'top-10-colors', 73 | label: 'Top 10 most-used colors', 74 | value: colorsByCount.slice( 0, 10 ), 75 | }, 76 | { 77 | id: 'bottom-10-colors', 78 | label: 'Top 10 least-used colors', 79 | value: colorsByCount.slice( -10 ).reverse(), 80 | }, 81 | ], 82 | }; 83 | }; 84 | -------------------------------------------------------------------------------- /src/audits/display-none.js: -------------------------------------------------------------------------------- 1 | /** 2 | * External dependencies 3 | */ 4 | const { parse } = require( 'postcss' ); 5 | 6 | module.exports = function ( files = [] ) { 7 | const instances = []; 8 | 9 | files.forEach( ( { name, content } ) => { 10 | const root = parse( content, { from: name } ); 11 | root.walkDecls( function ( { parent, prop, value } ) { 12 | if ( 'display' === prop ) { 13 | if ( 'none' === value ) { 14 | instances.push( { 15 | file: name, 16 | selector: parent.selector, 17 | } ); 18 | } 19 | } 20 | } ); 21 | } ); 22 | 23 | return { 24 | audit: 'display-none', 25 | name: 'Display: None', 26 | results: [ 27 | { 28 | id: 'count', 29 | label: 'Number of times `display: none` is used', 30 | value: instances.length, 31 | }, 32 | { 33 | id: 'instances', 34 | label: 'Places where `display: none` is used', 35 | value: instances, 36 | }, 37 | ], 38 | }; 39 | }; 40 | -------------------------------------------------------------------------------- /src/audits/important.js: -------------------------------------------------------------------------------- 1 | /** 2 | * External dependencies 3 | */ 4 | const { parse } = require( 'postcss' ); 5 | 6 | /** 7 | * Internal dependencies 8 | */ 9 | const getValuesCount = require( '../utils/get-values-count' ); 10 | 11 | module.exports = function ( files = [] ) { 12 | let count = 0; 13 | const properties = []; 14 | const fileInstances = []; 15 | 16 | files.forEach( ( { name, content } ) => { 17 | let fileCount = 0; 18 | const root = parse( content, { from: name } ); 19 | root.walkDecls( function ( { important, prop } ) { 20 | if ( important ) { 21 | count++; 22 | fileCount++; 23 | properties.push( prop ); 24 | } 25 | } ); 26 | fileInstances.push( { 27 | name, 28 | count: fileCount, 29 | // Get a ratio of !important per line. 30 | perLine: fileCount / content.split( '\n' ).length, 31 | } ); 32 | } ); 33 | 34 | const propertiesByCount = getValuesCount( properties ); 35 | const instancesPerFile = fileInstances 36 | .filter( ( row ) => row.count > 0 ) 37 | .sort( ( a, b ) => b.count - a.count ); 38 | 39 | return { 40 | audit: 'important', 41 | name: 'Important Overrides', 42 | results: [ 43 | { 44 | id: 'count', 45 | label: 'Number of times `!important` is used', 46 | value: count, 47 | }, 48 | { 49 | id: 'count-per-file', 50 | label: 'Number of times `!important` is used per file', 51 | value: instancesPerFile, 52 | }, 53 | { 54 | id: 'top-10-properties', 55 | label: 'Top properties that use !important', 56 | value: propertiesByCount.slice( 0, 10 ), 57 | }, 58 | ], 59 | }; 60 | }; 61 | -------------------------------------------------------------------------------- /src/audits/media-queries.js: -------------------------------------------------------------------------------- 1 | /** 2 | * External dependencies 3 | */ 4 | const csstree = require( 'css-tree' ); 5 | 6 | /** 7 | * Internal dependencies 8 | */ 9 | const getValuesCount = require( '../utils/get-values-count' ); 10 | 11 | module.exports = function ( files = [] ) { 12 | const allQueries = []; 13 | const allSizes = []; 14 | const nonWidthQueries = []; 15 | files.forEach( ( { content } ) => { 16 | const ast = csstree.parse( content ); 17 | csstree.walk( ast, { 18 | visit: 'MediaQuery', 19 | enter( node ) { 20 | if ( node.children ) { 21 | allQueries.push( csstree.generate( node ) ); 22 | } 23 | csstree.walk( node, { 24 | visit: 'MediaFeature', 25 | enter( sizeNode ) { 26 | if ( 27 | sizeNode.name === 'max-width' || 28 | sizeNode.name === 'min-width' 29 | ) { 30 | if ( 31 | sizeNode.value && 32 | sizeNode.value.type === 'Dimension' 33 | ) { 34 | allSizes.push( 35 | csstree.generate( sizeNode.value ) 36 | ); 37 | } 38 | } else { 39 | nonWidthQueries.push( 40 | csstree.generate( sizeNode ) 41 | ); 42 | } 43 | }, 44 | } ); 45 | }, 46 | } ); 47 | } ); 48 | 49 | const uniqQueries = [ ...new Set( allQueries ) ]; 50 | const uniqSizes = [ ...new Set( allSizes ) ]; 51 | const queriesByCount = getValuesCount( allQueries ); 52 | const sizesByCount = getValuesCount( allSizes ); 53 | const nonWidthByCount = getValuesCount( nonWidthQueries ); 54 | 55 | return { 56 | audit: 'media-queries', 57 | name: 'Media Queries', 58 | results: [ 59 | { 60 | id: 'count', 61 | label: 'Number of total media queries', 62 | value: allQueries.length, 63 | }, 64 | { 65 | id: 'count-unique-queries', 66 | label: 'Number of seemingly-unique media queries', 67 | value: uniqQueries.length, 68 | }, 69 | { 70 | id: 'top-10-queries', 71 | label: 'Top 10 most-used media queries', 72 | value: queriesByCount.slice( 0, 10 ), 73 | }, 74 | { 75 | id: 'count-unique-sizes', 76 | label: 'Number of unique breakpoint sizes', 77 | value: uniqSizes.length, 78 | }, 79 | { 80 | id: 'top-10-sizes', 81 | label: 'Top 10 most-used breakpoint sizes', 82 | value: sizesByCount.slice( 0, 10 ), 83 | }, 84 | { 85 | id: 'bottom-10-sizes', 86 | label: 'Top 10 least-used breakpoint sizes', 87 | value: sizesByCount.slice( -10 ).reverse(), 88 | }, 89 | { 90 | id: 'non-width', 91 | label: 'Non-width related media queries', 92 | value: nonWidthByCount, 93 | }, 94 | ], 95 | }; 96 | }; 97 | -------------------------------------------------------------------------------- /src/audits/property-values.js: -------------------------------------------------------------------------------- 1 | /** 2 | * External dependencies 3 | */ 4 | const { parse } = require( 'postcss' ); 5 | 6 | /** 7 | * Internal dependencies 8 | */ 9 | const getValuesCount = require( '../utils/get-values-count' ); 10 | 11 | module.exports = function ( files = [], properties = [] ) { 12 | const values = []; 13 | if ( ! Array.isArray( properties ) ) { 14 | properties = Array( properties ); 15 | } 16 | // Skip out if no properties are passed in. 17 | if ( ! properties.length ) { 18 | return { results: [] }; 19 | } 20 | 21 | files.forEach( ( { content, name } ) => { 22 | const root = parse( content, { from: name } ); 23 | root.walkDecls( function ( { prop, value } ) { 24 | if ( -1 !== properties.indexOf( prop ) ) { 25 | values.push( value ); 26 | } 27 | } ); 28 | } ); 29 | 30 | const uniqueValues = [ ...new Set( values ) ]; 31 | const valuesByCount = getValuesCount( values ); 32 | 33 | return { 34 | audit: 'property-values', 35 | name: `Property Values: ${ properties.join( ', ' ) }`, 36 | results: [ 37 | { 38 | id: 'count', 39 | label: `Number of values for ${ properties.join( ', ' ) }`, 40 | value: values.length, 41 | }, 42 | { 43 | id: 'count-unique', 44 | label: `Number of unique values for ${ properties.join( 45 | ', ' 46 | ) }`, 47 | value: uniqueValues.length, 48 | }, 49 | { 50 | id: 'top-10-values', 51 | label: `Top 10 most-used values for ${ properties.join( 52 | ', ' 53 | ) }`, 54 | value: valuesByCount.slice( 0, 10 ), 55 | }, 56 | { 57 | id: 'bottom-10-values', 58 | label: `Top 10 least-used values for ${ properties.join( 59 | ', ' 60 | ) }`, 61 | value: valuesByCount.slice( -10 ).reverse(), 62 | }, 63 | ], 64 | }; 65 | }; 66 | -------------------------------------------------------------------------------- /src/audits/selectors.js: -------------------------------------------------------------------------------- 1 | /** 2 | * External dependencies 3 | */ 4 | const { parse } = require( 'postcss' ); 5 | 6 | const { getSpecificityArray } = require( '../utils/get-specificity' ); 7 | 8 | module.exports = function ( files = [] ) { 9 | // let longest = 0; 10 | const selectors = []; 11 | 12 | files.forEach( ( { name, content } ) => { 13 | const root = parse( content, { from: name } ); 14 | root.walkRules( function ( { selector } ) { 15 | const selectorList = selector.split( ',' ); 16 | selectorList.forEach( ( selectorName ) => { 17 | // Remove excess whitespace from selectors. 18 | selectorName = selectorName.replace( /\s+/g, ' ' ).trim(); 19 | const [ a, b, c ] = getSpecificityArray( selectorName ); 20 | const sum = 100 * a + 10 * b + c; // eslint-disable-line no-mixed-operators 21 | selectors.push( { 22 | file: name, 23 | selector: selectorName, 24 | a, 25 | b, 26 | c, 27 | sum, 28 | } ); 29 | } ); 30 | } ); 31 | } ); 32 | 33 | // Reverse sort to be highest -> lowest. 34 | selectors.sort( ( a, b ) => b.sum - a.sum ); 35 | const selectorsByLength = [ ...selectors ].sort( 36 | ( a, b ) => b.selector.length - a.selector.length 37 | ); 38 | 39 | const selectorsWithIds = selectors.filter( ( { a } ) => a > 0 ); 40 | 41 | return { 42 | audit: 'selectors', 43 | name: 'Selectors', 44 | results: [ 45 | { 46 | id: 'count', 47 | label: 'Total number of selectors', 48 | value: selectors.length, 49 | }, 50 | { 51 | id: 'count-with-ids', 52 | label: 'Number of selectors with IDs', 53 | value: selectorsWithIds.length, 54 | }, 55 | { 56 | id: 'top-10-selectors', 57 | label: 'Top 10 selectors with the highest specificity', 58 | value: selectors.slice( 0, 10 ), 59 | }, 60 | { 61 | id: 'bottom-10-selectors', 62 | label: 'Top 10 selectors by length', 63 | value: selectorsByLength.slice( 0, 10 ), 64 | }, 65 | ], 66 | }; 67 | }; 68 | -------------------------------------------------------------------------------- /src/audits/typography.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Internal dependencies 3 | */ 4 | const propertyValues = require( './property-values' ); 5 | 6 | /** 7 | * Run the property values audit with specific properties 8 | * as a single audit. 9 | * 10 | * @param {array} files 11 | * 12 | * @returns Object containing audit data. 13 | */ 14 | module.exports = function ( files = [] ) { 15 | const properties = [ 16 | 'font-family', 17 | 'font-size', 18 | 'font-style', 19 | 'font-weight', 20 | 'line-height', 21 | 'letter-spacing', 22 | 'text-align', 23 | 'text-decoration', 24 | 'text-indent', 25 | 'text-overflow', 26 | 'text-shadow', 27 | 'text-transform', 28 | 'white-space', 29 | 'word-break', 30 | ]; 31 | 32 | const results = properties.map( ( property ) => { 33 | return propertyValues( files, property ).results; 34 | } ); 35 | 36 | return { 37 | audit: 'typography', 38 | name: `Typography`, 39 | results: results.flat(), 40 | }; 41 | }; 42 | -------------------------------------------------------------------------------- /src/formats/cli-table.js: -------------------------------------------------------------------------------- 1 | const colors = require( 'colors' ); 2 | const Table = require( 'cli-table3' ); 3 | 4 | const tableSettings = { 5 | chars: { 6 | top: '-', 7 | 'top-mid': '-', 8 | 'top-left': ' ', 9 | 'top-right': ' ', 10 | bottom: '-', 11 | 'bottom-mid': '-', 12 | 'bottom-left': ' ', 13 | 'bottom-right': ' ', 14 | left: '|', 15 | 'left-mid': '|', 16 | mid: '-', 17 | 'mid-mid': '|', 18 | right: '|', 19 | 'right-mid': '|', 20 | middle: '│', 21 | }, 22 | }; 23 | 24 | /** 25 | * Convert the report data to a JSON string. 26 | * 27 | * The report value can be as a plain string, an array, or an array of objects 28 | * to be rendered by `Table`. This expects the cli supports color output. 29 | * 30 | * @param {Object} report An indvidual audit result. 31 | * @param {string} report.label A human-readable string describing the results. 32 | * @param {string|string[]|Object[]} report.value The result for this audit. 33 | * @return {string} A formatted string for output on cli. 34 | */ 35 | function formatReport( { label, value } ) { 36 | let valueString = value; 37 | 38 | if ( Array.isArray( value ) && value.length ) { 39 | let table = ''; 40 | if ( 'object' === typeof value[ 0 ] ) { 41 | tableSettings.head = Object.keys( value[ 0 ] ); 42 | table = new Table( tableSettings ); 43 | value.forEach( ( row ) => { 44 | table.push( 45 | Object.values( row ).map( ( item ) => { 46 | item = String( item ); 47 | if ( item.length > 80 ) { 48 | return item.slice( 0, 80 ) + '…'; 49 | } 50 | return item; 51 | } ) 52 | ); 53 | } ); 54 | } else { 55 | table = new Table( tableSettings ); 56 | value.forEach( ( row ) => { 57 | table.push( [ row ] ); 58 | } ); 59 | } 60 | 61 | valueString = table.toString(); 62 | } 63 | 64 | return `${ colors.green.bold( label ) }:\n${ valueString }\n\n`; 65 | } 66 | 67 | /** 68 | * Convert the report data to a JSON string. 69 | * 70 | * @param {Array>} reports The list of report data. 71 | * @return {string} reports as a JSON string. 72 | */ 73 | module.exports = function ( reports ) { 74 | reports.map( ( { results } ) => results ); 75 | return reports.map( ( { name, results } ) => { 76 | return ( 77 | `${ colors.magenta.bold( name ) }\n\n` + 78 | results.map( formatReport ).join( '' ) 79 | ); 80 | } ); 81 | }; 82 | -------------------------------------------------------------------------------- /src/formats/html.js: -------------------------------------------------------------------------------- 1 | const fs = require( 'fs-extra' ); 2 | const path = require( 'path' ); 3 | const { TwingEnvironment, TwingLoaderFilesystem } = require( 'twing' ); 4 | 5 | /** 6 | * Internal dependencies 7 | */ 8 | const { getArg } = require( '../utils/cli' ); 9 | 10 | const templatePath = path.join( __dirname, './html' ); 11 | 12 | /** 13 | * Get the template file, falling back to index.twig if a custom file is not found. 14 | * 15 | * @param {string} name Name of the current report. 16 | * @return {string} File name. 17 | */ 18 | function getTemplateFile( name ) { 19 | if ( fs.existsSync( `${ templatePath }/${ name }.twig` ) ) { 20 | return `${ name }.twig`; 21 | } 22 | return 'index.twig'; 23 | } 24 | 25 | module.exports = function ( reports ) { 26 | const loader = new TwingLoaderFilesystem( templatePath ); 27 | const twing = new TwingEnvironment( loader, { debug: true } ); 28 | 29 | const reportName = getArg( '--filename' ); 30 | const reportTemplate = getTemplateFile( reportName ); 31 | const reportDestDir = path.join( __dirname, '..', '..', 'public' ); 32 | const reportDest = path.join( reportDestDir, `${ reportName }.html` ); 33 | const context = { 34 | title: `CSS Audit for ${ reportName }`, 35 | reports, 36 | }; 37 | 38 | // Copy CSS src to /public 39 | const cssSrc = path.join( __dirname, 'html', 'style.css' ); 40 | const cssDest = path.join( reportDestDir, 'style.css' ); 41 | fs.copyFile( cssSrc, cssDest ); 42 | 43 | twing 44 | .render( reportTemplate, context ) 45 | .then( ( output ) => { 46 | console.log( `Generated template for ${ reportName }.` ); 47 | fs.writeFileSync( reportDest, output ); 48 | } ) 49 | .catch( ( e ) => { 50 | console.error( e ); 51 | } ); 52 | }; 53 | -------------------------------------------------------------------------------- /src/formats/html/_audit-alpha.twig: -------------------------------------------------------------------------------- 1 | {% for item in data.results %} 2 | {% if item.value is iterable %} 3 |

{{item.label}}

4 |
    5 | {% for value in item.value %} 6 | 7 | {% if value.name or value.name == '0' %} 8 |
  • 9 | {{value.count}} 10 | {{value.name}} 11 |
  • 12 | {% else %} 13 |
  • 14 | {{value}} 15 |
  • 16 | {% endif %} 17 | 18 | {% endfor %} 19 |
20 | {% else %} 21 |

{{ item.label}}: {{ item.value }}

22 | {% endif %} 23 | {% endfor %} 24 | -------------------------------------------------------------------------------- /src/formats/html/_audit-colors.twig: -------------------------------------------------------------------------------- 1 | {% for item in data.results %} 2 | {% if item.value is iterable %} 3 |

{{item.label}}

4 |
    5 | {% for value in item.value %} 6 | 7 | {% if value.name %} 8 |
  • 9 | {{value.count}} 10 | {{value.name}} 11 |
  • 12 | {% else %} 13 |
  • 14 | {{value}} 15 |
  • 16 | {% endif %} 17 | 18 | {% endfor %} 19 |
20 | {% else %} 21 |

{{ item.label}}: {{ item.value }}

22 | {% endif %} 23 | {% endfor %} -------------------------------------------------------------------------------- /src/formats/html/_audit-default.twig: -------------------------------------------------------------------------------- 1 | {% for item in data.results %} 2 | {% if item.value is iterable %} 3 |

{{item.label}}

4 |
    5 | {% for value in item.value %} 6 | {% if value.name %} 7 |
  1. 8 | {{value.count}} 9 | {{value.name}} 10 |
  2. 11 | {% endif %} 12 | 13 | {% if value.file %} 14 |
  3. 15 | {{value.selector}}
    16 | {{value.file}} 17 |
  4. 18 | {% endif %} 19 | {% endfor %} 20 |
21 | {% else %} 22 |

{{item.label}}: {{item.value}}

23 | {% endif %} 24 | {% endfor %} -------------------------------------------------------------------------------- /src/formats/html/index.twig: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | {{title}} 8 | 9 | 10 | 34 | 35 | {% for report in reports %} 36 | {# Start audit section #} 37 |
38 | 39 |
40 |

41 | {{ report.name }} audit 42 |

43 | 44 |
45 | 46 | {# Use specified template, or default template #} 47 | {% if report.template %} 48 | {% include '_audit-' ~ report.template ~ '.twig' with { data: report } %} 49 | {% else %} 50 | {% include '_audit-default.twig' with { data: report } %} 51 | {% endif %} 52 | 53 |
54 | {% endfor %} 55 | 56 | {# Uncomment this to see the reports object - useful for debugging. #} 57 | {# 58 |
59 | 		{{dump(reports)}}
60 | 	
61 | #} 62 | 63 | 87 | 88 | 89 | -------------------------------------------------------------------------------- /src/formats/html/style.css: -------------------------------------------------------------------------------- 1 | /* 2 | * Config 3 | */ 4 | body { 5 | --body-margin: 2rem; 6 | 7 | /* Body colours */ 8 | --body-bg: #fff; 9 | --body-fg: #111; 10 | 11 | /* Nav & link colours */ 12 | --nav-bg: #eee; 13 | --nav-bg-hover: #bbb; 14 | --nav-fg: #11c; 15 | 16 | /* Heading colours */ 17 | --h-fg: #222; 18 | --h-bg: #eee; 19 | 20 | /*
*/ 21 | --hr: 4px solid var(--h-bg); 22 | 23 | /* ul li::marker */ 24 | --ul-marker: #1114; 25 | --ul-marker-hover: #111f; 26 | 27 | /* Border style for bordered components */ 28 | --component-border: 1px solid #aaa; 29 | 30 | /* Consistent component margins */ 31 | --component-margin-sm: 1.5rem; 32 | --component-margin-lg: 3rem; 33 | 34 | /* Code boxes */ 35 | --code-bg: #eee; 36 | --code-border: var(--component-border); 37 | 38 | /* Colour chips */ 39 | --chip-text-shadow: #fff; 40 | --chip-border: var(--component-border); 41 | --count-bg: #eee; 42 | --count-border: var(--component-border); 43 | } 44 | 45 | /* 46 | * Dark mode 47 | */ 48 | body.is-dark-mode { 49 | /* Body colours */ 50 | --body-bg: #111; 51 | --body-fg: #fff; 52 | 53 | /* Nav & link colours */ 54 | --nav-bg: #444; 55 | --nav-bg-hover: #222; 56 | --nav-fg: #bcf; 57 | 58 | /* Heading colours */ 59 | --h-fg: #eee; 60 | --h-bg: #222; 61 | 62 | /*
*/ 63 | --hr: 4px solid var(--h-bg); 64 | 65 | /* ul li::marker */ 66 | --ul-marker: #eee4; 67 | --ul-marker-hover: #eeef; 68 | 69 | /* Border style for bordered components */ 70 | --component-border: 1px solid #666; 71 | 72 | /* Consistent component margins */ 73 | --component-margin-sm: 1.5rem; 74 | --component-margin-lg: 3rem; 75 | 76 | /* Code boxes */ 77 | --code-bg: #222; 78 | --code-border: var(--component-border); 79 | 80 | /* Colour chips */ 81 | --chip-text-shadow: #000; 82 | --chip-border: var(--component-border); 83 | --count-bg: #111; 84 | --count-border: var(--component-border); 85 | } 86 | 87 | /* 88 | * General 89 | */ 90 | * { 91 | box-sizing: border-box; 92 | } 93 | 94 | html { 95 | scroll-behavior: smooth; 96 | 97 | @media (prefers-reduced-motion: reduce) { 98 | scroll-behavior: auto; 99 | } 100 | } 101 | 102 | body { 103 | background-color: var(--body-bg); 104 | color: var(--body-fg); 105 | font-family: sans-serif; 106 | margin: var(--body-margin); 107 | } 108 | 109 | a { 110 | color: var(--nav-fg); 111 | } 112 | 113 | h1, h2 { 114 | font-weight: normal; 115 | } 116 | 117 | h1, h2, h3 { 118 | color: var(--h-fg); 119 | margin: 0 0 var(--component-margin-sm) 0; 120 | } 121 | 122 | hr { 123 | border: 0; 124 | border-bottom: var(--hr); 125 | } 126 | 127 | /* 128 | * Header, nav, `back to top` button 129 | */ 130 | .site__header { 131 | align-items: baseline; 132 | display: flex; 133 | flex-wrap: wrap; 134 | justify-content: space-between; 135 | } 136 | 137 | .nav { 138 | margin-bottom: var(--component-margin-sm); 139 | } 140 | 141 | .nav ul { 142 | display: flex; 143 | justify-content: flex-start; 144 | align-items: center; 145 | list-style: none; 146 | padding: 0; 147 | } 148 | 149 | .nav li { 150 | margin-bottom: var(--component-margin-sm); 151 | margin: 0 0.5em 0 0; 152 | } 153 | 154 | .nav a { 155 | display: block; 156 | height: 100%; 157 | } 158 | 159 | .nav a, 160 | .btn { 161 | background-color: var(--nav-bg); 162 | padding: 0.5em; 163 | text-decoration: none; 164 | transition: background-color 0.2s ease; 165 | } 166 | 167 | .nav a:hover, 168 | .nav a:focus, 169 | .btn:hover, 170 | .btn:focus { 171 | background-color: var(--nav-bg-hover); 172 | } 173 | 174 | .nav a:focus, 175 | .btn:focus { 176 | outline: 4px dashed var(--nav-fg); 177 | } 178 | 179 | .btn { 180 | font-size: 1.2em; 181 | margin-bottom: var(--component-margin-sm); 182 | text-align: center; 183 | } 184 | 185 | /* 186 | * Audits 187 | */ 188 | .audit__header { 189 | align-items: baseline; 190 | background-color: var(--h-bg); 191 | display: flex; 192 | justify-content: space-between; 193 | margin-right: calc(-1 * var(--body-margin)); 194 | margin-left: calc(-1 * var(--body-margin)); 195 | margin-bottom: var(--component-margin-sm); 196 | padding: var(--component-margin-sm) var(--body-margin) 0; 197 | position: sticky; 198 | top: 0; 199 | z-index: 1; 200 | } 201 | 202 | .audit { 203 | margin-bottom: var(--component-margin-lg); 204 | } 205 | 206 | /* 207 | * Code boxes 208 | */ 209 | code { 210 | background-color: var(--code-bg); 211 | border: var(--code-border); 212 | border-radius: 1px; 213 | display: inline-block; 214 | font-family: monospace; 215 | font-size: 1.2em; 216 | padding: 0.5em; 217 | } 218 | 219 | /* 220 | * Audit lists 221 | */ 222 | .audit ul, 223 | .audit ol { 224 | padding: 0 0 0 2em; 225 | } 226 | 227 | .audit ol { 228 | list-style-type: decimal-leading-zero; 229 | } 230 | 231 | .audit li { 232 | margin: 0 0 2em 0; 233 | } 234 | 235 | .audit ol > li::marker { 236 | color: var(--ul-marker); 237 | } 238 | 239 | .audit ol > li:hover::marker { 240 | color: var(--ul-marker-hover); 241 | } 242 | 243 | /* 244 | Audit: Colors 245 | */ 246 | .audit--colors ul, 247 | .audit--alpha ul { 248 | display: flex; 249 | flex-wrap: wrap; 250 | font-family: monospace; 251 | font-size: 1.2em; 252 | list-style: none; 253 | padding: 0; 254 | } 255 | 256 | .audit--colors .chip, 257 | .audit--alpha .chip { 258 | border: var(--chip-border); 259 | height: 3em; 260 | line-height: 3em; 261 | margin: 0.5em; 262 | text-align: center; 263 | flex-basis: 18em; 264 | flex-grow: 1; 265 | background-color: var( --chip-bg-color ); 266 | font-family: monospace; 267 | position: relative; 268 | } 269 | 270 | .audit--colors .chip.is-transparent:before, 271 | .audit--alpha .chip.is-transparent:before { 272 | content: ''; 273 | position: absolute; 274 | top: 0; 275 | left: 0; 276 | right: 0; 277 | bottom: 0; 278 | z-index: -1; 279 | background: repeating-conic-gradient(#f0f0f1 0% 25%, transparent 0% 50%) 50% / 20px 20px 280 | } 281 | 282 | /* 283 | * Improve visibility of colour chip 284 | * text on low contrast backgrounds 285 | */ 286 | .audit--colors .chip:hover, 287 | .audit--alpha .chip:hover { 288 | font-weight: bold; 289 | text-shadow: 0 0 1px var(--chip-text-shadow), 290 | 0 0 3px var(--chip-text-shadow), 291 | 0 0 5px var(--chip-text-shadow), 292 | 0 0 7px var(--chip-text-shadow); 293 | } 294 | 295 | .audit--colors .count, 296 | .audit--alpha .count { 297 | left: -1em; 298 | min-width: 3em; 299 | padding: unset; 300 | position: absolute; 301 | top: -0.125em; 302 | } 303 | 304 | /* 305 | * Count component on colour chips 306 | */ 307 | .count { 308 | background-color: var(--count-bg); 309 | border: var(--count-border); 310 | border-radius: 50%; 311 | box-sizing: border-box; 312 | color: var(--count-fg); 313 | display: inline-block; 314 | height: auto; 315 | min-width: 2.3em; 316 | padding: 0.5em; 317 | text-align: center; 318 | 319 | /* Counteract .chip:hover */ 320 | font-weight: normal; 321 | text-shadow: none; 322 | } 323 | 324 | /* 325 | * Darkmode switch 326 | */ 327 | .button__theme-toggle { 328 | background: transparent; 329 | border: none; 330 | cursor: pointer; 331 | margin-bottom: 0; 332 | } 333 | 334 | .button__theme-toggle::before { 335 | content: "🌛"; 336 | font-size: 1.5rem; 337 | } 338 | 339 | body.is-dark-mode .button__theme-toggle::before { 340 | content: "🌞"; 341 | } 342 | -------------------------------------------------------------------------------- /src/formats/json.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Convert the report data to a JSON string. 3 | * 4 | * @param {Array>} reports The list of report data. 5 | * @return {string} reports as a JSON string. 6 | */ 7 | module.exports = function ( reports ) { 8 | return JSON.stringify( reports.map( ( data ) => data ) ); 9 | }; 10 | -------------------------------------------------------------------------------- /src/run.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Internal dependencies 3 | */ 4 | const { formatReport } = require( './utils/format-report' ); 5 | const { getArg } = require( './utils/cli' ); 6 | 7 | const runAudits = ( cssFiles ) => { 8 | const audits = []; 9 | const runAll = getArg( '--all' ); 10 | const runRecommended = getArg( '--recommended' ); 11 | 12 | if ( runAll || runRecommended || getArg( '--colors' ) ) { 13 | audits.push( require( './audits/colors' )( cssFiles ) ); 14 | } 15 | if ( runAll || runRecommended || getArg( '--important' ) ) { 16 | audits.push( require( './audits/important' )( cssFiles ) ); 17 | } 18 | if ( runAll || getArg( '--display-none' ) ) { 19 | audits.push( require( './audits/display-none' )( cssFiles ) ); 20 | } 21 | if ( runAll || runRecommended || getArg( '--selectors' ) ) { 22 | audits.push( require( './audits/selectors' )( cssFiles ) ); 23 | } 24 | if ( runAll || runRecommended || getArg( '--media-queries' ) ) { 25 | audits.push( require( './audits/media-queries' )( cssFiles ) ); 26 | } 27 | if ( getArg( '--typography' ) ) { 28 | audits.push( require( './audits/typography' )( cssFiles ) ); 29 | } 30 | if ( getArg( '--alphas' ) ) { 31 | audits.push( require( './audits/alphas' )( cssFiles ) ); 32 | } 33 | 34 | const propertyValues = getArg( '--property-values' ); 35 | const isPropertyValuesArray = 36 | Array.isArray( propertyValues ) && propertyValues.length; 37 | 38 | // Multiple property value arguments are only supported in config. 39 | if ( isPropertyValuesArray ) { 40 | propertyValues.forEach( ( values ) => { 41 | audits.push( 42 | require( './audits/property-values' )( 43 | cssFiles, 44 | values.split( ',' ) 45 | ) 46 | ); 47 | } ); 48 | } else if ( ! isPropertyValuesArray ) { 49 | // Single property-value audit handling for CLI 50 | if ( !! propertyValues ) { 51 | audits.push( 52 | require( './audits/property-values' )( 53 | cssFiles, 54 | propertyValues.split( ',' ) 55 | ) 56 | ); 57 | } 58 | } 59 | 60 | const reports = audits.flat().filter( Boolean ); 61 | 62 | const format = getArg( '--format' ); 63 | 64 | if ( 'html' === format && ! getArg( '--filename' ) ) { 65 | console.error( 66 | 'Could not run audits. \nAn argument for filename must be provided for the HTML format.' 67 | ); 68 | return; 69 | } 70 | 71 | return formatReport( reports, format ); 72 | }; 73 | 74 | module.exports = { 75 | runAudits, 76 | }; 77 | -------------------------------------------------------------------------------- /src/utils/__tests__/cli.js: -------------------------------------------------------------------------------- 1 | const { getArg, getArgsFromCLI, getConfig } = require( '../cli' ); 2 | 3 | describe( 'Run Audits from CLI', () => { 4 | it( 'should get args from the CLI', () => { 5 | process.argv = [ 6 | '', 7 | '', 8 | '--format=html', 9 | '--property-values=padding,padding-top', 10 | '--property-values=font-size,font-weight', 11 | '--media-queries', 12 | ]; 13 | 14 | expect( getArgsFromCLI() ).toEqual( [ 15 | '--format=html', 16 | '--property-values=padding,padding-top', 17 | '--property-values=font-size,font-weight', 18 | '--media-queries', 19 | ] ); 20 | } ); 21 | 22 | it( 'should return true for basic audit args in the CLI', () => { 23 | process.argv = [ '', '', '--media-queries' ]; 24 | 25 | expect( getArg( '--media-queries' ) ).toBe( true ); 26 | } ); 27 | 28 | it( 'should return values for args that have them in the CLI', () => { 29 | process.argv = [ '', '', '--property-values=padding,padding-top' ]; 30 | 31 | expect( getArg( '--property-values' ) ).toBe( 'padding,padding-top' ); 32 | } ); 33 | } ); 34 | 35 | describe( 'Run Audits from Config', () => { 36 | beforeAll( () => { 37 | process.argv = [ '', '', '' ]; 38 | } ); 39 | 40 | it( 'should return the value for config keys', () => { 41 | expect( getArg( '--format' ) ).toBe( 'json' ); 42 | } ); 43 | 44 | it( 'should return true if the arg is a item in the config audits array', () => { 45 | expect( getArg( '--important' ) ).toBe( true ); 46 | } ); 47 | 48 | it( 'should return an array of values for each property-value audits', () => { 49 | expect( getArg( '--property-values' ) ).toStrictEqual( [ 50 | 'font-size', 51 | 'padding-top,padding-bottom', 52 | ] ); 53 | } ); 54 | 55 | it( 'should return false if arg is CLI only', () => { 56 | expect( getArg( '--help', true ) ).toBe( false ); 57 | } ); 58 | 59 | it( 'should return false if an arg does not exist in CLI or config', () => { 60 | process.argv = [ '', '', '--media-queries' ]; 61 | 62 | expect( getArg( '--nonexistant' ) ).toBe( false ); 63 | } ); 64 | } ); 65 | 66 | describe( 'Configuration', () => { 67 | it( 'should get configuration from a file', () => { 68 | expect( typeof getConfig() ).toBe( 'object' ); 69 | } ); 70 | } ); 71 | -------------------------------------------------------------------------------- /src/utils/__tests__/example-config.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | format: 'json', 3 | audits: [ 4 | 'colors', 5 | 'important', 6 | 'display-none', 7 | 'selectors', 8 | 'media-queries', 9 | [ 'property-values', 'font-size' ], 10 | [ 'property-values', 'padding-top,padding-bottom' ], 11 | ], 12 | }; 13 | -------------------------------------------------------------------------------- /src/utils/__tests__/get-specificity.js: -------------------------------------------------------------------------------- 1 | const { getSpecificity } = require( '../get-specificity' ); 2 | 3 | describe( 'Calculate Specificity', () => { 4 | it( 'should calculate for element selectors', () => { 5 | expect( getSpecificity( 'body' ) ).toBe( 1 ); 6 | expect( getSpecificity( 'body *' ) ).toBe( 1 ); 7 | expect( getSpecificity( ':not(p)' ) ).toBe( 1 ); 8 | } ); 9 | 10 | it( 'should calculate for pseudo-elements', () => { 11 | expect( getSpecificity( 'p::first-line ' ) ).toBe( 2 ); 12 | } ); 13 | 14 | it( 'should calculate for pseudo-classes', () => { 15 | expect( getSpecificity( ':checked' ) ).toBe( 10 ); 16 | expect( getSpecificity( 'a:link' ) ).toBe( 11 ); 17 | } ); 18 | 19 | it( 'should calculate for class selectors', () => { 20 | expect( getSpecificity( '.is-active' ) ).toBe( 10 ); 21 | } ); 22 | 23 | it( 'should calculate for attribute selectors', () => { 24 | expect( getSpecificity( '[type="radio"]' ) ).toBe( 10 ); 25 | } ); 26 | 27 | it( 'should calculate for id selectors', () => { 28 | expect( getSpecificity( '#unique' ) ).toBe( 100 ); 29 | } ); 30 | 31 | it( 'should calculate for combined selectors', () => { 32 | expect( getSpecificity( 'main p:first-child::first-line' ) ).toBe( 13 ); 33 | expect( getSpecificity( '#unique > p' ) ).toBe( 101 ); 34 | expect( getSpecificity( '.home .container:not(nav)' ) ).toBe( 21 ); 35 | expect( 36 | getSpecificity( 'li > a[href*="en-US"] > .inline-warning' ) 37 | ).toBe( 22 ); 38 | } ); 39 | } ); 40 | -------------------------------------------------------------------------------- /src/utils/cli.js: -------------------------------------------------------------------------------- 1 | /** 2 | * External dependencies 3 | */ 4 | const minimist = require( 'minimist' ); 5 | const path = require( 'path' ); 6 | const { cosmiconfigSync } = require( 'cosmiconfig' ); 7 | 8 | const getArgsFromCLI = ( excludePrefixes ) => { 9 | const args = process.argv.slice( 2 ); 10 | if ( excludePrefixes ) { 11 | return args.filter( ( arg ) => { 12 | return ! excludePrefixes.some( ( prefix ) => 13 | arg.startsWith( prefix ) 14 | ); 15 | } ); 16 | } 17 | return args; 18 | }; 19 | 20 | const getFileArgsFromCLI = () => minimist( getArgsFromCLI() )._; 21 | 22 | /** 23 | * Get configuration using cosmiconfig. 24 | * 25 | * @param {string} env 26 | */ 27 | const getConfig = ( env ) => { 28 | const moduleName = 'test' === env ? 'example-config' : 'css-audit'; 29 | const searchFrom = 30 | 'test' === env ? path.join( __dirname, '__tests__' ) : process.cwd(); 31 | 32 | const explorerSync = cosmiconfigSync( moduleName ); 33 | const { config } = explorerSync.search( searchFrom ); 34 | 35 | try { 36 | return config; 37 | } catch ( e ) { 38 | console.error( e, 'Error retrieving config file.' ); 39 | } 40 | }; 41 | 42 | /** 43 | * Get the argument required for running the audit, 44 | * 45 | * First get the argument from CLI, and fallback to the 46 | * config if its not present. 47 | * 48 | * @param {string} arg 49 | * @param {boolean} cliArgOnly 50 | */ 51 | 52 | const getArg = ( arg, cliArgOnly = false ) => { 53 | for ( const cliArg of getArgsFromCLI() ) { 54 | const [ name, value ] = cliArg.split( '=' ); 55 | 56 | if ( name === arg ) { 57 | return 'undefined' === typeof value ? true : value || null; 58 | } 59 | } 60 | 61 | if ( true === cliArgOnly ) { 62 | return false; 63 | } 64 | 65 | const config = getConfig( process.env.NODE_ENV ); 66 | 67 | const term = arg.substr( 2 ); 68 | 69 | // This is a simple property: value arg e.g. format: json 70 | const isSimplePropertyValueArg = config.hasOwnProperty( term ); 71 | 72 | if ( isSimplePropertyValueArg ) { 73 | return 'undefined' === typeof config[ term ] 74 | ? true 75 | : config[ term ] || null; 76 | } 77 | 78 | if ( config.hasOwnProperty( 'audits' ) ) { 79 | // Separate the basic audits from property-values. 80 | const basicAudits = config.audits.filter( 81 | ( audit ) => term === audit && 'string' === typeof audit 82 | ); 83 | 84 | // Create an array of values of the property-value audits. 85 | const propertyValueAudits = config.audits.filter( 86 | ( audit ) => 'object' === typeof audit && term === audit[ 0 ] 87 | ); 88 | 89 | const propertyValueValues = ( () => { 90 | if ( propertyValueAudits.length > 0 ) { 91 | return propertyValueAudits 92 | .flat() 93 | .filter( ( item ) => 'property-values' !== item ); 94 | } 95 | return []; 96 | } )(); 97 | 98 | if ( 'undefined' !== basicAudits[ 0 ] && term === basicAudits[ 0 ] ) { 99 | return true; 100 | } 101 | 102 | if ( propertyValueValues.length > 0 ) { 103 | return propertyValueValues; 104 | } 105 | } 106 | 107 | // The argument cannot be retrieved from CLI or config. 108 | return false; 109 | }; 110 | 111 | const getHelp = () => { 112 | return `Usage: css-audit -- [options] 113 | 114 | --colors Run colors audit. 115 | --important Run !important audit. 116 | --display-none Run display: none audit. 117 | --selectors Run selectors audit. 118 | --media-queries Run media queries audit. 119 | --property-values Run audit for a given set of property values, comma-separated. 120 | --recommended Run recommended audits (colors, important, selectors). Default: true. 121 | --all Run all audits (except property values, as it requires a value). 122 | --format Format to use for displaying report. 123 | --help Show this message. 124 | `; 125 | }; 126 | 127 | module.exports = { 128 | getArgsFromCLI, 129 | getFileArgsFromCLI, 130 | getArg, 131 | getConfig, 132 | getHelp, 133 | }; 134 | -------------------------------------------------------------------------------- /src/utils/format-report.js: -------------------------------------------------------------------------------- 1 | const FMT_CLI_TABLE = 'cli-table'; 2 | const FMT_JSON = 'json'; 3 | const FMT_HTML = 'html'; 4 | 5 | /** 6 | * Format the reports using the specified reporter format. 7 | * 8 | * @param {Array>} reports The list of report data. 9 | * @param {FMT_CLI_TABLE|FMT_JSON} format One of the predefined formats. Defaults to FMT_CLI_TABLE. 10 | * @return {string} The formatted reports. 11 | */ 12 | function formatReport( reports, format = FMT_CLI_TABLE ) { 13 | let formatCallback = false; 14 | switch ( format ) { 15 | case FMT_JSON: 16 | formatCallback = require( '../formats/json' ); 17 | break; 18 | case FMT_HTML: 19 | formatCallback = require( '../formats/html' ); 20 | break; 21 | case FMT_CLI_TABLE: 22 | default: 23 | formatCallback = require( '../formats/cli-table' ); 24 | } 25 | 26 | const formattedReports = formatCallback( reports ); 27 | if ( Array.isArray( formattedReports ) ) { 28 | return formattedReports.join( '\n' ); 29 | } 30 | 31 | return formattedReports; 32 | } 33 | 34 | module.exports = { 35 | formats: [ FMT_CLI_TABLE, FMT_JSON ], 36 | formatReport, 37 | }; 38 | -------------------------------------------------------------------------------- /src/utils/get-specificity.js: -------------------------------------------------------------------------------- 1 | const csstree = require( 'css-tree' ); 2 | 3 | /** 4 | * A recursive callback used to build up the specificity of a selector. 5 | * 6 | * This is intented to be used as a `reduce` callback, building up the 7 | * specificity in the array accumulator as it processes each part of a selector. 8 | * 9 | * @param {Array} specificity The specificity as an array. 10 | * @param {Object} selector A selector node from the css-tree AST. 11 | * @return {Array} The calculated specificity value. 12 | */ 13 | function calculateSpecificity( [ a, b, c ], selector ) { 14 | if ( ! selector.type ) { 15 | return; 16 | } 17 | if ( 'lang' !== selector.name && selector.children ) { 18 | return selector.children 19 | .toArray() 20 | .reduce( calculateSpecificity, [ a, b, c ] ); 21 | } 22 | 23 | switch ( selector.type ) { 24 | case 'IdSelector': 25 | a++; 26 | break; 27 | case 'ClassSelector': 28 | case 'AttributeSelector': 29 | case 'Nth': 30 | b++; 31 | break; 32 | case 'PseudoClassSelector': 33 | if ( 'not' === selector.name ) { 34 | break; 35 | } 36 | b++; 37 | break; 38 | case 'TypeSelector': 39 | case 'PseudoElementSelector': 40 | if ( '*' === selector.name ) { 41 | break; 42 | } 43 | c++; 44 | break; 45 | case 'WhiteSpace': 46 | case 'Combinator': 47 | case 'Identifier': 48 | // Whitespace, adjacent selectors (>, ~), … do not impact specificity. 49 | break; 50 | case 'Percentage': 51 | // Part of a keyframe, not to be calculated. 52 | break; 53 | default: 54 | console.warn( 'Unhandled selector type:', selector.type ); 55 | } 56 | return [ a, b, c ]; 57 | } 58 | 59 | /** 60 | * Get the specificity value for a given CSS selector. 61 | * 62 | * @param {string} selector A valid CSS selector. 63 | * @return {number} The calculated specificity value. 64 | */ 65 | function getSpecificity( selector ) { 66 | const node = csstree.parse( selector, { context: 'selector' } ); 67 | const selectorList = node.children.toArray(); 68 | const [ a, b, c ] = selectorList.reduce( calculateSpecificity, [ 69 | 0, 70 | 0, 71 | 0, 72 | ] ); 73 | return 100 * a + 10 * b + c; 74 | } 75 | 76 | /** 77 | * Get the specificity value for a given CSS selector, as an array. 78 | * 79 | * @param {string} selector A valid CSS selector. 80 | * @return {Array} The calculated specificity value. 81 | */ 82 | function getSpecificityArray( selector ) { 83 | const node = csstree.parse( selector, { context: 'selector' } ); 84 | const selectorList = node.children.toArray(); 85 | const [ a, b, c ] = selectorList.reduce( calculateSpecificity, [ 86 | 0, 87 | 0, 88 | 0, 89 | ] ); 90 | return [ a, b, c ]; 91 | } 92 | 93 | module.exports = { 94 | calculateSpecificity, 95 | getSpecificity, 96 | getSpecificityArray, 97 | }; 98 | -------------------------------------------------------------------------------- /src/utils/get-values-count.js: -------------------------------------------------------------------------------- 1 | module.exports = function ( values ) { 2 | const uniqueValues = [ ...new Set( values ) ]; 3 | 4 | return uniqueValues 5 | .map( ( val ) => { 6 | // Count up how many times this item appears in the full list. 7 | const count = values.filter( ( c ) => c === val ).length; 8 | return { 9 | name: val, 10 | count, 11 | }; 12 | } ) 13 | .sort( ( a, b ) => { 14 | // Reverse sort 15 | return b.count - a.count; 16 | } ); 17 | }; 18 | --------------------------------------------------------------------------------