chrometools-mcp 3.1.7 → 3.2.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +118 -0
- package/README.md +183 -117
- package/README.ru.md +352 -0
- package/chrome-extension.zip +0 -0
- package/docs/extension-developer-mode.png +0 -0
- package/docs/extension-installed.png +0 -0
- package/index.js +93 -90
- package/package.json +1 -1
- package/pom/apom-tree-converter.js +330 -46
- package/publish_output.txt +0 -0
- package/server/tool-definitions.js +24 -41
- package/server/tool-groups.js +0 -1
- package/server/tool-schemas.js +6 -11
- package/test-interactivity.html +178 -0
|
@@ -43,6 +43,11 @@ function buildAPOMTree(interactiveOnly = true) {
|
|
|
43
43
|
// Build tree from body
|
|
44
44
|
result.tree = buildNode(document.body, null, 0, []);
|
|
45
45
|
|
|
46
|
+
// Prune empty branches (containers without interactive elements)
|
|
47
|
+
if (interactiveOnly && result.tree) {
|
|
48
|
+
result.tree = pruneEmptyBranches(result.tree);
|
|
49
|
+
}
|
|
50
|
+
|
|
46
51
|
// Collect radio and checkbox groups for easier agent access
|
|
47
52
|
result.groups = collectInputGroups(result.tree);
|
|
48
53
|
|
|
@@ -102,29 +107,109 @@ function buildAPOMTree(interactiveOnly = true) {
|
|
|
102
107
|
}
|
|
103
108
|
|
|
104
109
|
/**
|
|
105
|
-
*
|
|
110
|
+
* Prune empty branches (containers without interactive elements)
|
|
111
|
+
* Bottom-up traversal: remove container branches that don't end with interactive leaves
|
|
106
112
|
*/
|
|
107
|
-
function
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
113
|
+
function pruneEmptyBranches(node) {
|
|
114
|
+
if (!node) return null;
|
|
115
|
+
|
|
116
|
+
// Handle compact format containers
|
|
117
|
+
if (typeof node === 'object' && !node.id && !node.tag) {
|
|
118
|
+
// Compact format: { "tag_id": [children] }
|
|
119
|
+
const keys = Object.keys(node);
|
|
120
|
+
if (keys.length === 1 && Array.isArray(node[keys[0]])) {
|
|
121
|
+
const key = keys[0];
|
|
122
|
+
const prunedChildren = node[key]
|
|
123
|
+
.map(child => pruneEmptyBranches(child))
|
|
124
|
+
.filter(child => child !== null);
|
|
125
|
+
|
|
126
|
+
// If no children left after pruning, remove this container
|
|
127
|
+
if (prunedChildren.length === 0) {
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return { [key]: prunedChildren };
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Handle regular format (interactive elements or full-mode containers)
|
|
136
|
+
if (node.children && Array.isArray(node.children)) {
|
|
137
|
+
// Prune children recursively
|
|
138
|
+
node.children = node.children
|
|
139
|
+
.map(child => pruneEmptyBranches(child))
|
|
140
|
+
.filter(child => child !== null);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// If this is a container (no type = not interactive) with no children, remove it
|
|
144
|
+
if (!node.type && node.children && node.children.length === 0) {
|
|
145
|
+
return null;
|
|
146
|
+
}
|
|
111
147
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
148
|
+
return node;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Check if cursor:pointer is explicitly set (not inherited)
|
|
153
|
+
*/
|
|
154
|
+
function hasCursorPointerExplicit(element) {
|
|
155
|
+
const computedStyle = window.getComputedStyle(element);
|
|
156
|
+
if (computedStyle.cursor !== 'pointer') {
|
|
157
|
+
return false;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Check if cursor is set via inline style
|
|
161
|
+
if (element.style.cursor === 'pointer') {
|
|
162
|
+
return true;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Check if cursor is set via CSS class or direct CSS rule (not inherited)
|
|
166
|
+
// If parent also has cursor:pointer computed, then it's likely inherited
|
|
167
|
+
const parent = element.parentElement;
|
|
168
|
+
if (parent) {
|
|
169
|
+
const parentStyle = window.getComputedStyle(parent);
|
|
170
|
+
if (parentStyle.cursor === 'pointer') {
|
|
171
|
+
// Parent has cursor:pointer, so this is inherited
|
|
172
|
+
return false;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Element has cursor:pointer but parent doesn't - it's explicitly set
|
|
177
|
+
return true;
|
|
178
|
+
}
|
|
116
179
|
|
|
117
|
-
|
|
180
|
+
/**
|
|
181
|
+
* Mark interactive elements and their ancestors
|
|
182
|
+
* NOTE: This function is defined before checkInteractivity,
|
|
183
|
+
* so we need to inline the checks or move function definitions
|
|
184
|
+
*/
|
|
185
|
+
function markInteractiveElements(root) {
|
|
186
|
+
// Find all interactive elements using the same logic as checkInteractivity
|
|
118
187
|
const elements = root.querySelectorAll('*');
|
|
119
188
|
const interactiveList = [];
|
|
120
189
|
|
|
121
190
|
elements.forEach(el => {
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
191
|
+
// Use inline checks (same logic as checkInteractivity)
|
|
192
|
+
const tag = el.tagName.toLowerCase();
|
|
193
|
+
const role = el.getAttribute('role');
|
|
194
|
+
|
|
195
|
+
const isInteractive = (
|
|
196
|
+
// Native HTML interactive elements
|
|
197
|
+
['a', 'button', 'input', 'select', 'textarea', 'label', 'form'].includes(tag) ||
|
|
198
|
+
// Interactive ARIA roles
|
|
199
|
+
(role && ['button', 'link', 'checkbox', 'radio', 'tab', 'menuitem', 'option', 'switch', 'textbox'].includes(role)) ||
|
|
200
|
+
// onclick attribute
|
|
125
201
|
el.hasAttribute('onclick') ||
|
|
126
|
-
|
|
127
|
-
(el.
|
|
202
|
+
// onclick property
|
|
203
|
+
(el.onclick !== null && el.onclick !== undefined) ||
|
|
204
|
+
// cursor: pointer (only if explicitly set, not inherited)
|
|
205
|
+
hasCursorPointerExplicit(el) ||
|
|
206
|
+
// tabindex (except -1)
|
|
207
|
+
(el.hasAttribute('tabindex') && el.getAttribute('tabindex') !== '-1') ||
|
|
208
|
+
// contenteditable
|
|
209
|
+
el.getAttribute('contenteditable') === 'true'
|
|
210
|
+
// Note: We skip event listener check here for performance
|
|
211
|
+
// as querySelectorAll can return thousands of elements
|
|
212
|
+
);
|
|
128
213
|
|
|
129
214
|
if (isInteractive && isVisible(el)) {
|
|
130
215
|
interactiveList.push(el);
|
|
@@ -146,13 +231,26 @@ function buildAPOMTree(interactiveOnly = true) {
|
|
|
146
231
|
|
|
147
232
|
/**
|
|
148
233
|
* Check if element is visible
|
|
234
|
+
* More reliable check that works with position:fixed elements (Angular Material, etc.)
|
|
149
235
|
*/
|
|
150
236
|
function isVisible(el) {
|
|
151
|
-
|
|
237
|
+
// Check dimensions first (works for fixed position elements)
|
|
238
|
+
if (el.offsetWidth === 0 || el.offsetHeight === 0) return false;
|
|
239
|
+
|
|
240
|
+
// Check computed styles
|
|
152
241
|
const style = window.getComputedStyle(el);
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
242
|
+
if (style.display === 'none' ||
|
|
243
|
+
style.visibility === 'hidden' ||
|
|
244
|
+
style.opacity === '0') {
|
|
245
|
+
return false;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// For body element, always consider visible if dimensions > 0
|
|
249
|
+
if (el === document.body) return true;
|
|
250
|
+
|
|
251
|
+
// Additional check: element should be in viewport or have offsetParent
|
|
252
|
+
// This handles elements inside position:fixed containers (Angular Material)
|
|
253
|
+
return el.offsetParent !== null || style.position === 'fixed' || style.position === 'sticky';
|
|
156
254
|
}
|
|
157
255
|
|
|
158
256
|
/**
|
|
@@ -169,10 +267,17 @@ function buildAPOMTree(interactiveOnly = true) {
|
|
|
169
267
|
return null;
|
|
170
268
|
}
|
|
171
269
|
|
|
172
|
-
// Generate unique ID
|
|
270
|
+
// Generate unique ID and CSS selector
|
|
173
271
|
const id = generateElementId(element);
|
|
272
|
+
const selector = generateSelector(element);
|
|
174
273
|
elementIds.set(element, id);
|
|
175
274
|
|
|
275
|
+
// Register element in selector resolver (internal only)
|
|
276
|
+
if (typeof window !== 'undefined' && typeof window.registerElement === 'function') {
|
|
277
|
+
const tag = element.tagName.toLowerCase();
|
|
278
|
+
window.registerElement(id, selector, { tag, depth });
|
|
279
|
+
}
|
|
280
|
+
|
|
176
281
|
const currentPath = [...path, id];
|
|
177
282
|
|
|
178
283
|
// Get positioning info
|
|
@@ -184,26 +289,47 @@ function buildAPOMTree(interactiveOnly = true) {
|
|
|
184
289
|
// Build node - minimize non-interactive parents
|
|
185
290
|
const isInteractive = elementType.isInteractive;
|
|
186
291
|
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
tag: element.tagName.toLowerCase(),
|
|
190
|
-
selector: generateSelector(element),
|
|
191
|
-
position,
|
|
192
|
-
children: []
|
|
193
|
-
};
|
|
292
|
+
// Build node structure based on mode
|
|
293
|
+
let node;
|
|
194
294
|
|
|
195
|
-
// Add full info only for interactive elements
|
|
196
295
|
if (isInteractive) {
|
|
197
|
-
|
|
198
|
-
node
|
|
296
|
+
// Interactive elements: full structure without selector (unless includeAll)
|
|
297
|
+
node = {
|
|
298
|
+
id,
|
|
299
|
+
tag: element.tagName.toLowerCase(),
|
|
300
|
+
position,
|
|
301
|
+
type: elementType.type,
|
|
302
|
+
children: []
|
|
303
|
+
};
|
|
304
|
+
|
|
305
|
+
// Add selector only in includeAll mode
|
|
306
|
+
if (!interactiveOnly) {
|
|
307
|
+
node.selector = selector;
|
|
308
|
+
}
|
|
199
309
|
|
|
200
|
-
// Add metadata
|
|
310
|
+
// Add metadata for interactive elements
|
|
201
311
|
if (elementType.metadata) {
|
|
202
312
|
node.metadata = elementType.metadata;
|
|
203
313
|
}
|
|
204
314
|
} else {
|
|
205
|
-
//
|
|
206
|
-
|
|
315
|
+
// Containers: compact format "tag_id": [children] when interactiveOnly
|
|
316
|
+
// or full format when includeAll
|
|
317
|
+
if (interactiveOnly) {
|
|
318
|
+
// Compact format - will be converted after processing children
|
|
319
|
+
node = {
|
|
320
|
+
_compact: true,
|
|
321
|
+
_key: `${element.tagName.toLowerCase()}_${id}`,
|
|
322
|
+
children: []
|
|
323
|
+
};
|
|
324
|
+
} else {
|
|
325
|
+
// Full format with selector
|
|
326
|
+
node = {
|
|
327
|
+
id,
|
|
328
|
+
tag: element.tagName.toLowerCase(),
|
|
329
|
+
selector,
|
|
330
|
+
children: []
|
|
331
|
+
};
|
|
332
|
+
}
|
|
207
333
|
}
|
|
208
334
|
|
|
209
335
|
// Update metadata counters
|
|
@@ -231,6 +357,14 @@ function buildAPOMTree(interactiveOnly = true) {
|
|
|
231
357
|
}
|
|
232
358
|
}
|
|
233
359
|
|
|
360
|
+
// Convert compact containers to final format
|
|
361
|
+
if (node._compact) {
|
|
362
|
+
// Return compact format: { "tag_id": [children] }
|
|
363
|
+
const compactNode = {};
|
|
364
|
+
compactNode[node._key] = node.children;
|
|
365
|
+
return compactNode;
|
|
366
|
+
}
|
|
367
|
+
|
|
234
368
|
return node;
|
|
235
369
|
}
|
|
236
370
|
|
|
@@ -291,6 +425,87 @@ function buildAPOMTree(interactiveOnly = true) {
|
|
|
291
425
|
};
|
|
292
426
|
}
|
|
293
427
|
|
|
428
|
+
/**
|
|
429
|
+
* Check if element has click event listeners
|
|
430
|
+
*/
|
|
431
|
+
function hasClickListener(element) {
|
|
432
|
+
try {
|
|
433
|
+
// Check for getEventListeners (available in Chrome DevTools context)
|
|
434
|
+
if (typeof getEventListeners === 'function') {
|
|
435
|
+
const listeners = getEventListeners(element);
|
|
436
|
+
return listeners && listeners.click && listeners.click.length > 0;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
// Fallback: check for common event listener markers
|
|
440
|
+
// Note: This is not 100% reliable but catches common cases
|
|
441
|
+
return element._events?.click ||
|
|
442
|
+
element.__listeners?.click ||
|
|
443
|
+
element.__eventListeners?.click;
|
|
444
|
+
} catch (e) {
|
|
445
|
+
return false;
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/**
|
|
450
|
+
* Check if element is interactive based on various signals
|
|
451
|
+
*/
|
|
452
|
+
function checkInteractivity(element) {
|
|
453
|
+
const tag = element.tagName.toLowerCase();
|
|
454
|
+
const role = element.getAttribute('role');
|
|
455
|
+
|
|
456
|
+
// 1. Standard interactive HTML elements
|
|
457
|
+
const interactiveTags = ['a', 'button', 'input', 'select', 'textarea'];
|
|
458
|
+
if (interactiveTags.includes(tag)) {
|
|
459
|
+
return { isInteractive: true, reason: 'native-html' };
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
// 2. Interactive ARIA roles
|
|
463
|
+
const interactiveRoles = [
|
|
464
|
+
'button', 'link', 'checkbox', 'radio', 'tab',
|
|
465
|
+
'menuitem', 'option', 'switch', 'textbox'
|
|
466
|
+
];
|
|
467
|
+
if (role && interactiveRoles.includes(role)) {
|
|
468
|
+
return { isInteractive: true, reason: 'aria-role' };
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
// 3. Elements with onclick attribute
|
|
472
|
+
if (element.hasAttribute('onclick')) {
|
|
473
|
+
return { isInteractive: true, reason: 'onclick-attr' };
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
// 4. Elements with onclick property set via JavaScript
|
|
477
|
+
if (element.onclick !== null && element.onclick !== undefined) {
|
|
478
|
+
return { isInteractive: true, reason: 'onclick-prop' };
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
// 5. Elements with cursor: pointer (only if explicitly set, not inherited)
|
|
482
|
+
if (hasCursorPointerExplicit(element)) {
|
|
483
|
+
return { isInteractive: true, reason: 'cursor-pointer' };
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
// 6. Elements with click event listeners
|
|
487
|
+
if (hasClickListener(element)) {
|
|
488
|
+
return { isInteractive: true, reason: 'event-listener' };
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
// 7. Elements with tabindex (except -1)
|
|
492
|
+
const tabindex = element.getAttribute('tabindex');
|
|
493
|
+
if (tabindex !== null && tabindex !== '-1') {
|
|
494
|
+
return { isInteractive: true, reason: 'tabindex' };
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
// 8. Contenteditable elements
|
|
498
|
+
if (element.getAttribute('contenteditable') === 'true') {
|
|
499
|
+
return { isInteractive: true, reason: 'contenteditable' };
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
return { isInteractive: false, reason: null };
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
/**
|
|
506
|
+
* Detect framework-specific attributes on element
|
|
507
|
+
* Returns framework info or null
|
|
508
|
+
*/
|
|
294
509
|
/**
|
|
295
510
|
* Determine element type and metadata
|
|
296
511
|
*/
|
|
@@ -301,14 +516,16 @@ function buildAPOMTree(interactiveOnly = true) {
|
|
|
301
516
|
|
|
302
517
|
// Form
|
|
303
518
|
if (tag === 'form') {
|
|
519
|
+
const metadata = {
|
|
520
|
+
method: element.method?.toUpperCase() || 'GET',
|
|
521
|
+
action: element.action || '',
|
|
522
|
+
name: element.name || null
|
|
523
|
+
};
|
|
524
|
+
|
|
304
525
|
return {
|
|
305
526
|
type: 'form',
|
|
306
527
|
isInteractive: true,
|
|
307
|
-
metadata
|
|
308
|
-
method: element.method?.toUpperCase() || 'GET',
|
|
309
|
-
action: element.action || '',
|
|
310
|
-
name: element.name || null
|
|
311
|
-
}
|
|
528
|
+
metadata
|
|
312
529
|
};
|
|
313
530
|
}
|
|
314
531
|
|
|
@@ -450,30 +667,58 @@ function buildAPOMTree(interactiveOnly = true) {
|
|
|
450
667
|
|
|
451
668
|
// Container with semantic role
|
|
452
669
|
if (role) {
|
|
670
|
+
const interactivityCheck = checkInteractivity(element);
|
|
453
671
|
return {
|
|
454
672
|
type: role,
|
|
455
|
-
isInteractive:
|
|
673
|
+
isInteractive: interactivityCheck.isInteractive,
|
|
456
674
|
metadata: {
|
|
457
|
-
ariaLabel: element.getAttribute('aria-label') || null
|
|
675
|
+
ariaLabel: element.getAttribute('aria-label') || null,
|
|
676
|
+
interactivityReason: interactivityCheck.reason || undefined
|
|
458
677
|
}
|
|
459
678
|
};
|
|
460
679
|
}
|
|
461
680
|
|
|
462
|
-
// Generic container
|
|
681
|
+
// Generic container - check for JavaScript interactivity
|
|
682
|
+
const interactivityCheck = checkInteractivity(element);
|
|
463
683
|
return {
|
|
464
684
|
type: 'container',
|
|
465
|
-
isInteractive:
|
|
466
|
-
metadata:
|
|
685
|
+
isInteractive: interactivityCheck.isInteractive,
|
|
686
|
+
metadata: interactivityCheck.isInteractive ? {
|
|
687
|
+
text: element.textContent?.trim().substring(0, 100) || '',
|
|
688
|
+
interactivityReason: interactivityCheck.reason
|
|
689
|
+
} : null
|
|
467
690
|
};
|
|
468
691
|
}
|
|
469
692
|
|
|
470
693
|
/**
|
|
471
694
|
* Generate unique CSS selector
|
|
695
|
+
* Excludes framework-specific dynamic attributes (React, Vue, Angular)
|
|
472
696
|
*/
|
|
473
697
|
function generateSelector(element) {
|
|
474
|
-
// Use ID if available and unique
|
|
475
|
-
|
|
476
|
-
|
|
698
|
+
// Use ID if available, valid (not starting with digit), and unique
|
|
699
|
+
// CSS selectors don't support IDs starting with digits (e.g., #301178 is invalid)
|
|
700
|
+
if (element.id && !/^[0-9]/.test(element.id)) {
|
|
701
|
+
try {
|
|
702
|
+
const selector = `#${CSS.escape(element.id)}`;
|
|
703
|
+
if (document.querySelectorAll(selector).length === 1) {
|
|
704
|
+
return selector;
|
|
705
|
+
}
|
|
706
|
+
} catch (e) {
|
|
707
|
+
// Invalid selector, continue to other strategies
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
// Try to find stable class name (excluding framework-specific dynamic classes)
|
|
712
|
+
const stableClass = getStableClassName(element);
|
|
713
|
+
if (stableClass) {
|
|
714
|
+
const classSelector = `.${stableClass}`;
|
|
715
|
+
// Verify it's unique within parent context
|
|
716
|
+
if (element.parentElement) {
|
|
717
|
+
const matches = element.parentElement.querySelectorAll(classSelector);
|
|
718
|
+
if (matches.length === 1 && matches[0] === element) {
|
|
719
|
+
return classSelector;
|
|
720
|
+
}
|
|
721
|
+
}
|
|
477
722
|
}
|
|
478
723
|
|
|
479
724
|
// Build path from parent
|
|
@@ -483,6 +728,12 @@ function buildAPOMTree(interactiveOnly = true) {
|
|
|
483
728
|
while (current && current !== document.body) {
|
|
484
729
|
let selector = current.tagName.toLowerCase();
|
|
485
730
|
|
|
731
|
+
// Add stable class if available
|
|
732
|
+
const stableClass = getStableClassName(current);
|
|
733
|
+
if (stableClass) {
|
|
734
|
+
selector += `.${stableClass}`;
|
|
735
|
+
}
|
|
736
|
+
|
|
486
737
|
// Add nth-of-type if needed
|
|
487
738
|
if (current.parentElement) {
|
|
488
739
|
const siblings = Array.from(current.parentElement.children).filter(
|
|
@@ -500,6 +751,39 @@ function buildAPOMTree(interactiveOnly = true) {
|
|
|
500
751
|
|
|
501
752
|
return path.join(' > ');
|
|
502
753
|
}
|
|
754
|
+
|
|
755
|
+
/**
|
|
756
|
+
* Get stable class name excluding framework-specific dynamic classes
|
|
757
|
+
* Returns first stable class or null
|
|
758
|
+
*/
|
|
759
|
+
function getStableClassName(element) {
|
|
760
|
+
if (!element.className || typeof element.className !== 'string') {
|
|
761
|
+
return null;
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
const classes = element.className.split(/\s+/).filter(c => c);
|
|
765
|
+
|
|
766
|
+
// Filter out framework-specific classes
|
|
767
|
+
const stableClasses = classes.filter(className => {
|
|
768
|
+
// React: CSS Modules, Styled Components, Emotion
|
|
769
|
+
if (/^[a-zA-Z0-9_-]+-[a-zA-Z0-9_-]{5,}$/.test(className)) return false;
|
|
770
|
+
if (/^css-[a-z0-9]+(-[a-z0-9]+)?$/i.test(className)) return false;
|
|
771
|
+
if (/^sc-[a-z0-9]+-[a-z0-9]+$/i.test(className)) return false;
|
|
772
|
+
|
|
773
|
+
// Vue: scoped styles
|
|
774
|
+
if (/^data-v-[a-f0-9]{8}$/i.test(className)) return false;
|
|
775
|
+
|
|
776
|
+
// Angular: component styles (no classes starting with _ng)
|
|
777
|
+
if (/^_ng/.test(className)) return false;
|
|
778
|
+
|
|
779
|
+
// Generic hash patterns
|
|
780
|
+
if (/^[a-z0-9]{32,}$/i.test(className)) return false;
|
|
781
|
+
|
|
782
|
+
return true;
|
|
783
|
+
});
|
|
784
|
+
|
|
785
|
+
return stableClasses.length > 0 ? stableClasses[0] : null;
|
|
786
|
+
}
|
|
503
787
|
}
|
|
504
788
|
|
|
505
789
|
// Export for use in both Node.js and browser context
|
|
File without changes
|