Skip to content

Commit a7670c9

Browse files
committed
feat: add ignoreGeneratedClassNames option
1 parent de58244 commit a7670c9

13 files changed

Lines changed: 570 additions & 25 deletions

README.md

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ See the [benchmark](https://fczbkk.github.io/css-selector-generator-benchmark/)
1919
- [Root element](#root-element)
2020
- [Blacklist](#blacklist)
2121
- [Whitelist](#whitelist)
22+
- [Ignore generated class names](#ignore-generated-class-names)
2223
- [Combine within selector](#combine-within-selector)
2324
- [Combine between selectors](#combine-between-selectors)
2425
- [Include tag](#include-tag)
@@ -205,6 +206,7 @@ In some cases, this selector may not be unique (e.g. `#wrapper > * > div > *`).
205206
- [`root`](#root-element)
206207
- [`blacklist`](#blacklist)
207208
- [`whitelist`](#whitelist)
209+
- [`ignoreGeneratedClassNames`](#ignore-generated-class-names)
208210
- [`combineWithinSelector`](#combine-within-selector)
209211
- [`combineBetweenSelectors`](#combine-between-selectors)
210212
- [`includeTag`](#include-tag)
@@ -336,6 +338,55 @@ getCssSelector(targetElement, { whitelist: [/second/] });
336338
// ".secondClass"
337339
```
338340

341+
### Ignore generated class names
342+
343+
If set to `true`, the generator will ignore class names that appear to be generated by CSS-in-JS libraries (e.g., Emotion, styled-components, MUI). This is useful when working with modern React applications that use CSS-in-JS, as these generated class names are not stable and change between builds.
344+
345+
```html
346+
<body>
347+
<!-- targetElement -->
348+
<div class="css-1x2y3z button-primary"></div>
349+
<div class="css-4a5b6c other-button"></div>
350+
</body>
351+
```
352+
353+
```javascript
354+
getCssSelector(targetElement, { ignoreGeneratedClassNames: false });
355+
// ".css-1x2y3z" - uses the generated class name (default behavior)
356+
357+
getCssSelector(targetElement, { ignoreGeneratedClassNames: true });
358+
// ".button-primary" - ignores generated class, uses human-readable one
359+
```
360+
361+
The option uses heuristics to detect generated class names:
362+
363+
- Rejects common CSS-in-JS library prefixes (e.g., `css-`, `sc-`, `emotion-`, `makeStyles-`)
364+
- Rejects class names with patterns that don't look like human-written names
365+
- Preserves word-like class names (e.g., `button`, `nav-container`, `userProfile`, `block__element--modifier`)
366+
367+
**Note:** The `whitelist` option takes precedence. If you whitelist a generated class name, it will be used even when `ignoreGeneratedClassNames` is enabled.
368+
369+
```javascript
370+
getCssSelector(targetElement, {
371+
ignoreGeneratedClassNames: true,
372+
whitelist: [".css-*"], // Whitelist overrides the filter
373+
});
374+
// May use ".css-1x2y3z" if it's unique
375+
```
376+
377+
If the built-in heuristic doesn't work for your use case, you can achieve similar functionality using the `blacklist` option with a custom function:
378+
379+
```javascript
380+
getCssSelector(targetElement, {
381+
blacklist: [
382+
(className) => {
383+
// Your custom logic to detect generated classes
384+
return /^(css-|sc-|makeStyles-)/.test(className);
385+
},
386+
],
387+
});
388+
```
389+
339390
### Combine within selector
340391

341392
If set to `true`, the generator will try to look for combinations of selectors within a single type (usually class names) to get better overall selector.

src/selector-attribute.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { sanitizeSelectorItem } from "./utilities-selectors.js";
22
import { createPatternMatcher, getIntersection } from "./utilities-data.js";
3-
import { CssSelectorGenerated, PatternMatcher } from "./types.js";
3+
import { CssSelectorGenerated, CssSelectorGeneratorOptions, PatternMatcher } from "./types.js";
44

55
interface AttributeData {
66
name: string;
@@ -70,6 +70,7 @@ function sanitizeAttributeData({ nodeName, nodeValue }: Node): AttributeData {
7070
*/
7171
export function getElementAttributeSelectors(
7272
element: Element,
73+
_options?: CssSelectorGeneratorOptions,
7374
): CssSelectorGenerated[] {
7475
const validAttributes = Array.from(element.attributes)
7576
.filter((attributeNode) => isValidAttributeNode(attributeNode, element))
@@ -85,7 +86,8 @@ export function getElementAttributeSelectors(
8586
*/
8687
export function getAttributeSelectors(
8788
elements: Element[],
89+
options?: CssSelectorGeneratorOptions,
8890
): CssSelectorGenerated[] {
89-
const elementSelectors = elements.map(getElementAttributeSelectors);
91+
const elementSelectors = elements.map((el) => getElementAttributeSelectors(el, options));
9092
return getIntersection(elementSelectors);
9193
}

src/selector-class.ts

Lines changed: 97 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,115 @@
11
import { sanitizeSelectorItem } from "./utilities-selectors.js";
22
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+
}
568

669
/**
770
* Get class selectors for an element.
871
*/
972
export function getElementClassSelectors(
1073
element: Element,
74+
options?: CssSelectorGeneratorOptions,
1175
): CssSelectorGenerated[] {
12-
return (element.getAttribute("class") ?? "")
76+
const classNames = (element.getAttribute("class") ?? "")
1377
.trim()
1478
.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+
);
17102
}
18103

19104
/**
20105
* Get class selectors matching all elements.
21106
*/
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+
);
24114
return getIntersection(elementSelectors);
25115
}

src/selector-id.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
11
import { sanitizeSelectorItem } from "./utilities-selectors.js";
22
import { INVALID_ID_RE } from "./constants.js";
33
import { testSelector } from "./utilities-dom.js";
4-
import { CssSelectorGenerated } from "./types.js";
4+
import { CssSelectorGenerated, CssSelectorGeneratorOptions } from "./types.js";
55

66
/**
77
* Get ID selector for an element.
88
* */
99
export function getElementIdSelectors(
1010
element: Element,
11+
_options?: CssSelectorGeneratorOptions,
1112
): CssSelectorGenerated[] {
1213
const id = element.getAttribute("id") ?? "";
1314
const selector = `#${sanitizeSelectorItem(id)}` as CssSelectorGenerated;
@@ -20,8 +21,11 @@ export function getElementIdSelectors(
2021
/**
2122
* Get ID selector for an element.
2223
*/
23-
export function getIdSelector(elements: Element[]): CssSelectorGenerated[] {
24+
export function getIdSelector(
25+
elements: Element[],
26+
options?: CssSelectorGeneratorOptions,
27+
): CssSelectorGenerated[] {
2428
return elements.length === 0 || elements.length > 1
2529
? []
26-
: getElementIdSelectors(elements[0]);
30+
: getElementIdSelectors(elements[0], options);
2731
}

src/selector-nth-child.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
1-
import { CssSelectorGenerated } from "./types.js";
1+
import { CssSelectorGenerated, CssSelectorGeneratorOptions } from "./types.js";
22
import { getIntersection } from "./utilities-data.js";
33

44
/**
55
* Get nth-child selector for an element.
66
*/
77
export function getElementNthChildSelector(
88
element: Element,
9+
_options?: CssSelectorGeneratorOptions,
910
): CssSelectorGenerated[] {
1011
const parent = element.parentNode;
1112
const siblings = parent && "children" in parent ? parent.children : null;
@@ -25,6 +26,7 @@ export function getElementNthChildSelector(
2526
*/
2627
export function getNthChildSelector(
2728
elements: Element[],
29+
options?: CssSelectorGeneratorOptions,
2830
): CssSelectorGenerated[] {
29-
return getIntersection(elements.map(getElementNthChildSelector));
31+
return getIntersection(elements.map((el) => getElementNthChildSelector(el, options)));
3032
}

src/selector-nth-of-type.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
import { getTagSelector } from "./selector-tag.js";
2-
import { CssSelectorGenerated } from "./types.js";
2+
import { CssSelectorGenerated, CssSelectorGeneratorOptions } from "./types.js";
33
import { getIntersection } from "./utilities-data.js";
44

55
/**
66
* Get nth-of-type selector for an element.
77
*/
88
export function getElementNthOfTypeSelector(
99
element: Element,
10+
_options?: CssSelectorGeneratorOptions,
1011
): CssSelectorGenerated[] {
1112
const tag = getTagSelector([element])[0];
1213
const parent = element.parentNode;
@@ -32,6 +33,7 @@ export function getElementNthOfTypeSelector(
3233
*/
3334
export function getNthOfTypeSelector(
3435
elements: Element[],
36+
options?: CssSelectorGeneratorOptions,
3537
): CssSelectorGenerated[] {
36-
return getIntersection(elements.map(getElementNthOfTypeSelector));
38+
return getIntersection(elements.map((el) => getElementNthOfTypeSelector(el, options)));
3739
}

src/selector-tag.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
import { sanitizeSelectorItem } from "./utilities-selectors.js";
2-
import { CssSelector, CssSelectorGenerated } from "./types.js";
2+
import { CssSelector, CssSelectorGenerated, CssSelectorGeneratorOptions } from "./types.js";
33
import { flattenArray } from "./utilities-data.js";
44

55
/**
66
* Get tag selector for an element.
77
*/
88
export function getElementTagSelectors(
99
element: Element,
10+
_options?: CssSelectorGeneratorOptions,
1011
): CssSelectorGenerated[] {
1112
return [
1213
sanitizeSelectorItem(element.tagName.toLowerCase()) as CssSelectorGenerated,
@@ -16,9 +17,12 @@ export function getElementTagSelectors(
1617
/**
1718
* Get tag selector for list of elements.
1819
*/
19-
export function getTagSelector(elements: Element[]): CssSelector[] {
20+
export function getTagSelector(
21+
elements: Element[],
22+
options?: CssSelectorGeneratorOptions,
23+
): CssSelector[] {
2024
const selectors = [
21-
...new Set(flattenArray(elements.map(getElementTagSelectors))),
25+
...new Set(flattenArray(elements.map((el) => getElementTagSelectors(el, options)))),
2226
];
2327
return selectors.length === 0 || selectors.length > 1 ? [] : [selectors[0]];
2428
}

src/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,8 @@ export type CssSelectorGeneratorOptionsInput = Partial<{
7777
useScope: boolean;
7878
// Limits the number of results (selectors) to be generated.
7979
maxResults: number;
80+
// If set to `true`, class names that appear to be generated (e.g., from CSS-in-JS libraries) will be ignored. Uses a heuristic to detect word-like class names vs. generated hashes. If this produces unexpected results, consider using the `blacklist` option with a custom function for more control.
81+
ignoreGeneratedClassNames: boolean;
8082
}>;
8183

8284
export type CssSelectorGeneratorOptions = Required<

src/utilities-options.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ export const DEFAULT_OPTIONS = {
2525
maxCombinations: Number.POSITIVE_INFINITY,
2626
maxCandidates: Number.POSITIVE_INFINITY,
2727
useScope: false,
28+
ignoreGeneratedClassNames: false,
2829
} as CssSelectorGeneratorOptions;
2930

3031
/**
@@ -152,5 +153,6 @@ export function sanitizeOptions(
152153
maxCandidates: sanitizeMaxNumber(options.maxCandidates),
153154
useScope: sanitizeBoolean(options.useScope),
154155
maxResults: sanitizeMaxNumber(options.maxResults),
156+
ignoreGeneratedClassNames: sanitizeBoolean(options.ignoreGeneratedClassNames),
155157
};
156158
}

0 commit comments

Comments
 (0)