├── .gitignore ├── .github └── FUNDING.yml ├── .vscode └── settings.json ├── tsconfig.json ├── package.json ├── src ├── index.ts ├── util.ts └── CheckCSS.ts ├── LICENSE ├── README.md └── test ├── test.js └── browser-test.htm /.gitignore: -------------------------------------------------------------------------------- 1 | dist 2 | yarn.lock 3 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: [broofa] 2 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "search.useGlobalIgnoreFiles": false 3 | } -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "declaration": true, 4 | "moduleResolution": "node", 5 | "outDir": "./dist", 6 | "sourceMap": true, 7 | "strict": true, 8 | "target": "ESNext", 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "checkcss", 3 | "version": "2.0.9", 4 | "type": "module", 5 | "description": "Detect references to undefined CSS classes", 6 | "funding": [ 7 | "https://github.com/sponsors/broofa" 8 | ], 9 | "main": "./dist/index.js", 10 | "exports": { 11 | ".": "./dist/index.js" 12 | }, 13 | "files": [ 14 | "src", 15 | "dist" 16 | ], 17 | "scripts": { 18 | "test": "node test/test.js", 19 | "test:browser": "npx http-server -o /test/browser-test.htm", 20 | "prepare": "rm -fr dist && yarn build", 21 | "build": "tsc", 22 | "build:watch": "tsc --watch" 23 | }, 24 | "repository": { 25 | "type": "git", 26 | "url": "https://github.com/broofa/checkcss" 27 | }, 28 | "keywords": [ 29 | "css", 30 | "class", 31 | "classnames", 32 | "missing", 33 | "undefined", 34 | "check", 35 | "DOM" 36 | ], 37 | "author": " ", 38 | "license": "ISC", 39 | "devDependencies": { 40 | "typescript": "^4.7.3" 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { CheckCSS } from './CheckCSS.js'; 2 | export * from './CheckCSS.js'; 3 | 4 | let ignoreRE: RegExp | undefined; 5 | 6 | let checkcss: CheckCSS; 7 | 8 | const warned = new Set(); 9 | function warn(msg: string) { 10 | if (warned.has(msg)) return; 11 | warned.add(msg); 12 | console.warn(msg); 13 | } 14 | 15 | // Legacy API support 16 | export function ignoreCSS(re: RegExp | undefined) { 17 | warn( 18 | 'ignoreRE is deprecated and will be removed in the next major release. Use CheckCSS#onClassnameDetected instead' 19 | ); 20 | ignoreRE = re; 21 | } 22 | 23 | export default function checkCSS() { 24 | warn('checkCSS() is deprecated. Use CheckCSS#scan() instead'); 25 | 26 | if (!checkcss) { 27 | checkcss = new CheckCSS(document); 28 | 29 | // Legacy API support for ignoreRE. 30 | checkcss.onClassnameDetected = (classname, el) => { 31 | return ignoreRE?.test(classname) ?? true; 32 | }; 33 | } 34 | 35 | checkcss.scan(); 36 | } 37 | 38 | export function monitorCSS() { 39 | warn('monitorCSS() is deprecated. Use CheckCSS#watch() instead'); 40 | checkcss.watch(); 41 | } 42 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Robert Kieffer 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # checkcss 2 | 3 | Detect DOM elements that reference undefined CSS classes 4 | ## Installation 5 | 6 | ```bash 7 | npm install checkcss 8 | # or 9 | yarn add checkcss 10 | ``` 11 | 12 | ## Usage 13 | 14 | ```javascript 15 | import { CheckCSS } from 'checkcss'; 16 | 17 | // Create CheckCSS instance 18 | const checkcss = new CheckCSS(); 19 | checkcss.scan().watch(); 20 | ``` 21 | ... then look for messages like this in your browser console: 22 | ![image](https://user-images.githubusercontent.com/164050/209418239-dfd6584d-f1f3-4903-85fd-aeb3d5cb2e5a.png) 23 | 24 | ## Hooks 25 | The following hooks are supported: 26 | ```javascript 27 | // OPTIONAL: Hook for filtering classnames 28 | checkcss.onClassnameDetected = function (classname, element) { 29 | // Return `false` to disable checks for `classname`. 30 | // For example, to ignore classnames starting with 31 | // "license-" or "maintainer-"... 32 | return /^license-|^maintainer-/.test(classname) ? false : true; 33 | }; 34 | 35 | // OPTIONAL: Hook for custom logging 36 | checkcss.onUndefinedClassname = function (classname) { 37 | // Custom logging goes here (replaces default log method) 38 | }; 39 | ``` 40 | -------------------------------------------------------------------------------- /src/util.ts: -------------------------------------------------------------------------------- 1 | // Regex for identifying class names in CSS selectors 2 | // 3 | // REF: https://www.w3.org/TR/selectors-3/#lex 4 | const CLASS_IDENT_REGEX = 5 | /\.-?(?:[_a-z]|[^\0-\x7f]|\\[0-9a-f]{1,6}\s?|\\[^\s0-9a-f])(?:[_a-z0-9-]|[^\0-\x7f]|\\[0-9a-f]{1,6}\s?|\\[^\s0-9a-f])*/gi; 6 | 7 | export function isGroupingRule(rule: any): rule is CSSGroupingRule { 8 | return (rule?.cssRules?.length ?? 0) > 0; 9 | } 10 | 11 | export function isCSSStyleRule(rule: any): rule is CSSStyleRule { 12 | return rule && 'selectorText' in rule; 13 | } 14 | 15 | export function isElement(el: Node): el is Element { 16 | return el?.nodeType === 1; 17 | } 18 | 19 | export function isLinkElement(el: Node): el is HTMLLinkElement { 20 | return ( 21 | el.nodeName === 'LINK' && (el as Element).tagName?.toLowerCase() === 'link' 22 | ); 23 | } 24 | 25 | export function parseSelectorForClassnames(sel: string) { 26 | const classnames = new Set(); 27 | const matches = sel.match(CLASS_IDENT_REGEX); 28 | 29 | if (matches) { 30 | for (let cl of matches) { 31 | // Strip '.' 32 | cl = cl.substring(1); 33 | 34 | // Unescape numeric escape sequences (\###) 35 | cl = cl.replaceAll(/\\[0-9a-f]{1,6}\s?/gi, escape => { 36 | return String.fromCodePoint(parseInt(escape.substring(1), 16)); 37 | }); 38 | 39 | // Unescape character escape sequences (\[some char]) 40 | cl = cl.replaceAll(/\\[^\s0-9a-f]/g, c => c.substring(1)); 41 | classnames.add(cl); 42 | } 43 | } 44 | 45 | return classnames; 46 | } 47 | -------------------------------------------------------------------------------- /test/test.js: -------------------------------------------------------------------------------- 1 | import assert from 'assert'; 2 | import { extractClasses } from '../dist/index.js'; 3 | 4 | // 5 | // Test the regex used to scrape classnames out of rule selector text 6 | // 7 | 8 | // Sampling of selector patterns that show up in the Tailwind framework 9 | // "X" = text of some sort, "0" = numeric digits of some sort. 10 | const TAILWIND_SELECTORS = { 11 | '.w-[32px]': ['w-[32px]'], 12 | '.left-1/2': ['left-1/2'], 13 | 14 | '.-X\\.X': ['-X.X'], 15 | 16 | '.-X\\.X > :X([X]) ~ :X([X])': ['-X.X'], 17 | '.X, .X, .X': ['X', 'X', 'X'], 18 | '.X:X .X\\:X': ['X', 'X:X'], 19 | '.X:X .X\\:X\\:X': ['X', 'X:X:X'], 20 | '.X:X .\\0xl\\:X\\:X': ['X', '\x00xl:X:X'], 21 | '.X\\.X': ['X.X'], 22 | '.X\\.X > :X([X]) ~ :X([X])': ['X.X'], 23 | '.X\\:-X\\.X': ['X:-X.X'], 24 | '.X\\:-X\\.X > :X([X]) ~ :X([X])': ['X:-X.X'], 25 | '.X\\:-X\\.X:X': ['X:-X.X'], 26 | '.X\\:X, .X\\:X, .X\\:X': ['X:X', 'X:X', 'X:X'], 27 | '.X\\:X\\.X': ['X:X.X'], 28 | '.X\\:X\\.X > :X([X]) ~ :X([X])': ['X:X.X'], 29 | '.X\\:X\\.X:X': ['X:X.X'], 30 | '.X\\:X\\:-X\\.X:X': ['X:X:-X.X'], 31 | '.X\\:X\\:X\\.X:X': ['X:X:X.X'], 32 | '.\\0xl\\:-X\\.X': ['\x00xl:-X.X'], 33 | '.\\0xl\\:-X\\.X > :X([X]) ~ :X([X])': ['\x00xl:-X.X'], 34 | '.\\0xl\\:X, .\\0xl\\:X, .\\0xl\\:X': ['\x00xl:X', '\x00xl:X', '\x00xl:X'], 35 | '.\\0xl\\:X\\.X': ['\x00xl:X.X'], 36 | '.\\0xl\\:X\\.X > :X([X]) ~ :X([X])': ['\x00xl:X.X'], 37 | '.\\0xl\\:X\\:-X\\.X:X': ['\x00xl:X:-X.X'], 38 | '.\\0xl\\:X\\:X\\.X:X': ['\x00xl:X:X.X'] 39 | }; 40 | 41 | for (const [sel, expected] of Object.entries(TAILWIND_SELECTORS)) { 42 | const actual = extractClasses(sel); 43 | 44 | assert.deepEqual(extractClasses(sel), expected, sel); 45 | 46 | // Make sure we get the same results when whitespace is removed. 47 | assert.deepEqual(extractClasses(sel.replace(/\s/g, '')), expected, sel); 48 | } 49 | -------------------------------------------------------------------------------- /test/browser-test.htm: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Browser Test 5 | 6 | 7 | 8 | 9 | 92 | 93 | 94 | 95 |

CheckCSS Browser Test

96 | 97 |
98 |
Blarg
99 |
Picnic Button
100 |
101 | 102 | 103 | -------------------------------------------------------------------------------- /src/CheckCSS.ts: -------------------------------------------------------------------------------- 1 | import { 2 | isCSSStyleRule, 3 | isElement, 4 | isGroupingRule, 5 | isLinkElement, 6 | parseSelectorForClassnames, 7 | } from './util.js'; 8 | 9 | export enum ClassnameStatus { 10 | DETECTED = 0, // name is used in `class` attribute 11 | IGNORED = 1, // ... but ignored (e.g. via onClassnameDetected) 12 | EMITTED = 2, // ... or has already been emitted via onUndefinedClassname 13 | DEFINED = 3, // name is defined in a stylesheet 14 | } 15 | 16 | export class CheckCSS { 17 | // Classnames defined in stylesheets 18 | #classnames = new Map(); 19 | 20 | // # of external stylesheets that actively loading. 21 | #pending = 0; 22 | 23 | // Default root element to scan for classnames 24 | #documentElement: HTMLElement; 25 | 26 | #observer?: MutationObserver; 27 | 28 | #watch = false; 29 | 30 | // Check timer 31 | #timer?: number; 32 | 33 | // Map that tracks the number of rules found in STYLE elements 34 | #seenStylesheets = new WeakMap(); 35 | 36 | // Hook for filtering classnames in DOM. This is called whenever a new 37 | // classname is detected in the DOM. Callback should return true to indicate 38 | // the class should be checked, false to ignore. 39 | onClassnameDetected?: (classname: string, el: Element) => boolean; 40 | 41 | // Callback when undefined classname is detected (defaults to console.log()) 42 | onUndefinedClassname(classname: string) { 43 | console.log( 44 | `%ccheckcss%c: No CSS rule for %c.${classname}`, 45 | 'color: darkorange', 46 | '', 47 | 'font-weight: bold', 48 | this.#documentElement.querySelectorAll(`.${CSS.escape(classname)}`) 49 | ); 50 | } 51 | 52 | constructor(document: Document = window.document) { 53 | this.#documentElement = document.documentElement; 54 | } 55 | 56 | #check(delay = 0) { 57 | // Defer check until all LINK stylesheets have loaded 58 | if (this.#pending > 0) delay = 3_000; 59 | 60 | // Clear timer 61 | clearTimeout(this.#timer); 62 | this.#timer = undefined; 63 | 64 | // Defer if requested 65 | if (delay > 0) { 66 | this.#timer = setTimeout(() => this.#check(0), delay); 67 | return; 68 | } 69 | 70 | // Scan LINK[rel="stylesheet"] elements we haven't seen yet 71 | const styleLinks: NodeListOf = 72 | this.#documentElement.querySelectorAll('link[rel="stylesheet"]'); 73 | for (const styleLink of styleLinks) { 74 | if (styleLink.dataset.ccScanned !== undefined) continue; 75 | styleLink.dataset.ccScanned = ''; // Set attribute 76 | 77 | this.#processStylesheet(styleLink); 78 | } 79 | 80 | // Scan STYLE elements we haven't seen yet, or that might have changed 81 | const styleElements: NodeListOf = 82 | this.#documentElement.querySelectorAll('style'); 83 | for (const styleElement of styleElements) { 84 | const { sheet } = styleElement; 85 | if (!sheet) continue; 86 | 87 | const rules = sheet.cssRules; 88 | 89 | // Skip style elements we've seen before. This is complicated by how 90 | // STYLE elements can be dynamically modified, in one of two ways: 91 | // 92 | // 1. Setting `styleElement.textContent`, which blows away the 93 | // `styleElement.sheet` 94 | // 2. Methods like `sheet#insertRule()` and `sheet#replace()` 95 | // 96 | // However none of these trigger DOM mutation events, so we have to resort 97 | // to the crude logic here to see if anythings change. While this logic 98 | // isn't perfect, it's reasonably performant and good enough for most 99 | // purposes. 100 | const expectedLength = this.#seenStylesheets.get(rules) ?? 0; 101 | const actualLength = rules.length; 102 | if (expectedLength === actualLength) continue; 103 | this.#seenStylesheets.set(rules, actualLength); 104 | 105 | this.#processStylesheet(styleElement); 106 | } 107 | 108 | for (const [classname, status] of this.#classnames) { 109 | if (status == ClassnameStatus.DETECTED) { 110 | this.#classnames.set(classname, ClassnameStatus.EMITTED); 111 | this.onUndefinedClassname?.(classname); 112 | } 113 | } 114 | 115 | // Repeat as long as we're in watch mode 116 | if (this.#watch) { 117 | this.#check(5_000); 118 | } 119 | } 120 | 121 | #processElement(el: Element, includeChildren = true) { 122 | // Skip non-Element nodes 123 | if (el.nodeType !== 1) return; 124 | 125 | // Detect styles referenced in "class" attribute 126 | for (const n of el.classList) { 127 | if (this.#classnames.has(n)) continue; 128 | 129 | if (this.onClassnameDetected?.(n, el) === false) { 130 | this.#classnames.set(n, ClassnameStatus.IGNORED); 131 | } else { 132 | this.#classnames.set(n, ClassnameStatus.DETECTED); 133 | } 134 | } 135 | 136 | // Recurse into children(?) 137 | if (includeChildren) { 138 | for (const cel of el.querySelectorAll('*')) { 139 | this.#processElement(cel, false); 140 | } 141 | } 142 | } 143 | 144 | #processStylesheet(sheet: HTMLStyleElement | CSSGroupingRule) { 145 | let rules; 146 | try { 147 | rules = isGroupingRule(sheet) ? sheet?.cssRules : sheet.sheet?.cssRules; 148 | } catch (e) { 149 | if (!(e instanceof Error)) throw e; 150 | 151 | if (e.name === 'SecurityError') { 152 | console.log( 153 | '%ccheckcss:', 154 | 'color: darkorange', 155 | 'Inaccessible stylesheet may contain classes reported as undefined. Use `onClassnameDetected` to ignore erroneously reported classes.', 156 | sheet 157 | ); 158 | } else { 159 | e.message += '\n\n(Please report this error to https://github.com/broofa/checkcss/issues)'; 160 | throw e; 161 | } 162 | } 163 | 164 | if (!rules) return; 165 | 166 | for (const rule of rules) { 167 | if (isGroupingRule(rule)) { 168 | // Recurse into grouping rules (e.g. CSSMediaRule) 169 | this.#processStylesheet(rule); 170 | } else if (isCSSStyleRule(rule)) { 171 | // Add each classname to the defined set 172 | for (const classname of parseSelectorForClassnames(rule.selectorText)) { 173 | this.#classnames.set(classname, ClassnameStatus.DEFINED); 174 | } 175 | } 176 | } 177 | } 178 | 179 | // FOR TESTING ONLY. This may be removed or changed without warning. 180 | get _testState() { 181 | return this.#classnames; 182 | } 183 | 184 | scan() { 185 | this.#processElement(this.#documentElement); 186 | this.#check(100); 187 | return this; 188 | } 189 | 190 | watch() { 191 | this.#watch = true; 192 | 193 | if (this.#observer) return; 194 | 195 | this.#observer = new MutationObserver(mutationsList => { 196 | for (const mut of mutationsList) { 197 | switch (mut.type) { 198 | case 'attributes': { 199 | this.#processElement(mut.target as Element, false); 200 | } 201 | 202 | case 'childList': { 203 | for (const el of mut.addedNodes) { 204 | if (!isElement(el)) continue; 205 | this.#processElement(el); 206 | 207 | // Track LINK elements 208 | if (isLinkElement(el)) { 209 | if (!el.sheet) { 210 | // Style sheet link, but w/out styles (so not yet loaded?), 211 | // wait until it's loaded 212 | this.#pending++; 213 | 214 | const loader = () => { 215 | el.removeEventListener('load', loader); 216 | el.removeEventListener('error', loader); 217 | 218 | this.#pending--; 219 | this.#check(); 220 | }; 221 | 222 | el.addEventListener('load', loader); 223 | el.addEventListener('error', loader); 224 | } 225 | } 226 | } 227 | } 228 | } 229 | } 230 | 231 | // Check for undefined classes if there's no stylesheets loading 232 | this.#check(); 233 | }); 234 | 235 | this.#observer.observe(document, { 236 | attributes: true, 237 | attributeFilter: ['class'], 238 | childList: true, 239 | subtree: true, 240 | }); 241 | 242 | return this; 243 | } 244 | } 245 | --------------------------------------------------------------------------------