|
1 | 1 | import { sanitizeSelectorItem } from "./utilities-selectors.js"; |
2 | 2 | import { INVALID_CLASS_RE } from "./constants.js"; |
3 | | -import { CssSelectorGenerated } from "./types.js"; |
4 | | -import { getIntersection } from "./utilities-data.js"; |
| 3 | +import { CssSelectorGenerated, CssSelectorGeneratorOptions } from "./types.js"; |
| 4 | +import { getIntersection, createPatternMatcher } from "./utilities-data.js"; |
| 5 | + |
| 6 | +const WORD_LIKE_PATTERN = /^[a-z_-]{3,}$/i; |
| 7 | +const CONSONANT_PATTERN = /[bcdfghjklmnpqrstvwxyz]{4,}/i; |
| 8 | + |
| 9 | +/** |
| 10 | + * Checks if a class name appears to be human-readable (word-like) |
| 11 | + * rather than a generated hash from CSS-in-JS libraries. |
| 12 | + * |
| 13 | + * Heuristics: |
| 14 | + * 1. Must match basic pattern: letters, hyphens, and underscores, at least 3 chars |
| 15 | + * 2. Split on hyphens, underscores (one or more), or camelCase boundaries |
| 16 | + * 3. Each word segment must be > 2 characters |
| 17 | + * 4. No word can have 4+ consecutive consonants |
| 18 | + * |
| 19 | + * Examples: |
| 20 | + * - Word-like: "button", "nav", "nav-container", "userProfile", "block__element--modifier" |
| 21 | + * - Generated: "css-abc123", "sc-xyz", "abc", "xyz", "button_primary" (single underscore) |
| 22 | + */ |
| 23 | +export function isWordLikeClassName(className: string): boolean { |
| 24 | + if (!WORD_LIKE_PATTERN.test(className)) { |
| 25 | + return false; |
| 26 | + } |
| 27 | + |
| 28 | + // Reject single underscores (only allow double underscores for BEM) |
| 29 | + if (className.includes("_") && !className.includes("__")) { |
| 30 | + return false; |
| 31 | + } |
| 32 | + |
| 33 | + // Reject common CSS-in-JS library prefixes |
| 34 | + if (/^(css|sc|jsx|emotion|makeStyles|MuiButton|MuiBox)-/i.test(className)) { |
| 35 | + return false; |
| 36 | + } |
| 37 | + |
| 38 | + // Split on hyphens, double-underscores, or camelCase boundaries (lowercase->uppercase) |
| 39 | + // This handles: kebab-case, BEM (block__element--modifier), camelCase, PascalCase |
| 40 | + const words = className |
| 41 | + .split(/--|__|[-]|(?<=[a-z])(?=[A-Z])/) |
| 42 | + .filter((word) => word.length > 0); |
| 43 | + |
| 44 | + // Must have at least one actual word (not just separators) |
| 45 | + if (words.length === 0) { |
| 46 | + return false; |
| 47 | + } |
| 48 | + |
| 49 | + // For single-word class names (no separators), require at least 4 chars |
| 50 | + // to avoid false positives with short hashes like "abc", "xyz" |
| 51 | + if (words.length === 1 && words[0].length < 4) { |
| 52 | + return false; |
| 53 | + } |
| 54 | + |
| 55 | + for (const word of words) { |
| 56 | + // Each word segment must be > 2 characters |
| 57 | + if (word.length <= 2) { |
| 58 | + return false; |
| 59 | + } |
| 60 | + // No word can have 4+ consecutive consonants |
| 61 | + if (CONSONANT_PATTERN.test(word)) { |
| 62 | + return false; |
| 63 | + } |
| 64 | + } |
| 65 | + |
| 66 | + return true; |
| 67 | +} |
5 | 68 |
|
6 | 69 | /** |
7 | 70 | * Get class selectors for an element. |
8 | 71 | */ |
9 | 72 | export function getElementClassSelectors( |
10 | 73 | element: Element, |
| 74 | + options?: CssSelectorGeneratorOptions, |
11 | 75 | ): CssSelectorGenerated[] { |
12 | | - return (element.getAttribute("class") ?? "") |
| 76 | + const classNames = (element.getAttribute("class") ?? "") |
13 | 77 | .trim() |
14 | 78 | .split(/\s+/) |
15 | | - .filter((item) => !INVALID_CLASS_RE.test(item)) |
16 | | - .map((item) => `.${sanitizeSelectorItem(item)}` as CssSelectorGenerated); |
| 79 | + .filter((item) => !INVALID_CLASS_RE.test(item)); |
| 80 | + |
| 81 | + let filteredClassNames = classNames; |
| 82 | + |
| 83 | + // Apply generated class name filtering if enabled |
| 84 | + if (options?.ignoreGeneratedClassNames) { |
| 85 | + // Check whitelist to preserve whitelisted generated classes |
| 86 | + const matchWhitelist = createPatternMatcher(options.whitelist || []); |
| 87 | + |
| 88 | + filteredClassNames = classNames.filter((className) => { |
| 89 | + const selector = `.${sanitizeSelectorItem(className)}`; |
| 90 | + // If whitelisted, always include |
| 91 | + if (matchWhitelist(selector)) { |
| 92 | + return true; |
| 93 | + } |
| 94 | + // Otherwise, check if it's word-like |
| 95 | + return isWordLikeClassName(className); |
| 96 | + }); |
| 97 | + } |
| 98 | + |
| 99 | + return filteredClassNames.map( |
| 100 | + (item) => `.${sanitizeSelectorItem(item)}` as CssSelectorGenerated, |
| 101 | + ); |
17 | 102 | } |
18 | 103 |
|
19 | 104 | /** |
20 | 105 | * Get class selectors matching all elements. |
21 | 106 | */ |
22 | | -export function getClassSelectors(elements: Element[]): CssSelectorGenerated[] { |
23 | | - const elementSelectors = elements.map(getElementClassSelectors); |
| 107 | +export function getClassSelectors( |
| 108 | + elements: Element[], |
| 109 | + options?: CssSelectorGeneratorOptions, |
| 110 | +): CssSelectorGenerated[] { |
| 111 | + const elementSelectors = elements.map((el) => |
| 112 | + getElementClassSelectors(el, options), |
| 113 | + ); |
24 | 114 | return getIntersection(elementSelectors); |
25 | 115 | } |
0 commit comments