@asamuzakjp/dom-selector 7.0.9 → 7.1.0
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/package.json +11 -7
- package/src/index.js +63 -5
- package/src/js/matcher.js +7 -4
- package/src/js/parser.js +7 -0
- package/src/js/utility.js +28 -32
- package/types/index.d.ts +5 -0
- package/src/js/cache.js +0 -77
- package/types/js/cache.d.ts +0 -12
package/package.json
CHANGED
|
@@ -24,6 +24,7 @@
|
|
|
24
24
|
"./package.json": "./package.json"
|
|
25
25
|
},
|
|
26
26
|
"dependencies": {
|
|
27
|
+
"@asamuzakjp/generational-cache": "^1.0.1",
|
|
27
28
|
"@asamuzakjp/nwsapi": "^2.3.9",
|
|
28
29
|
"bidi-js": "^1.0.3",
|
|
29
30
|
"css-tree": "^3.2.1",
|
|
@@ -31,7 +32,7 @@
|
|
|
31
32
|
},
|
|
32
33
|
"devDependencies": {
|
|
33
34
|
"@types/css-tree": "^2.3.11",
|
|
34
|
-
"@types/node": "^25.
|
|
35
|
+
"@types/node": "^25.6.0",
|
|
35
36
|
"benchmark": "^2.1.4",
|
|
36
37
|
"c8": "^11.0.0",
|
|
37
38
|
"chai": "^6.2.2",
|
|
@@ -42,13 +43,14 @@
|
|
|
42
43
|
"eslint-plugin-prettier": "^5.5.5",
|
|
43
44
|
"eslint-plugin-regexp": "^3.1.0",
|
|
44
45
|
"eslint-plugin-unicorn": "^64.0.0",
|
|
45
|
-
"globals": "^17.
|
|
46
|
+
"globals": "^17.5.0",
|
|
46
47
|
"jsdom": "^29.0.2",
|
|
48
|
+
"mitata": "^1.0.34",
|
|
47
49
|
"mocha": "^11.7.5",
|
|
48
50
|
"neostandard": "^0.13.0",
|
|
49
|
-
"prettier": "^3.8.
|
|
50
|
-
"sinon": "^21.
|
|
51
|
-
"typescript": "^6.0.
|
|
51
|
+
"prettier": "^3.8.3",
|
|
52
|
+
"sinon": "^21.1.2",
|
|
53
|
+
"typescript": "^6.0.3",
|
|
52
54
|
"wpt-runner": "^7.0.0"
|
|
53
55
|
},
|
|
54
56
|
"overrides": {
|
|
@@ -60,10 +62,12 @@
|
|
|
60
62
|
},
|
|
61
63
|
"scripts": {
|
|
62
64
|
"bench": "node benchmark/bench.js",
|
|
65
|
+
"bench:cache": "node benchmark/bench-cache.js",
|
|
66
|
+
"bench:cacheMonster": "node benchmark/bench-cache-monster.js",
|
|
63
67
|
"bench:sizzle": "node benchmark/bench-sizzle.js",
|
|
64
68
|
"build": "npm run tsc && npm run lint && npm test",
|
|
65
69
|
"lint": "eslint --fix .",
|
|
66
|
-
"test": "c8 --reporter=text mocha --
|
|
70
|
+
"test": "c8 --reporter=text mocha --exit test/**/*.test.js",
|
|
67
71
|
"test:wpt": "node test/wpt/wpt-runner.js",
|
|
68
72
|
"tsc": "node scripts/index clean --dir=types -i && npx tsc",
|
|
69
73
|
"update:wpt": "git submodule update --init --recursive --remote"
|
|
@@ -71,5 +75,5 @@
|
|
|
71
75
|
"engines": {
|
|
72
76
|
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
|
73
77
|
},
|
|
74
|
-
"version": "7.0
|
|
78
|
+
"version": "7.1.0"
|
|
75
79
|
}
|
package/src/index.js
CHANGED
|
@@ -6,8 +6,9 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
/* import */
|
|
9
|
-
import { GenerationalCache } from '
|
|
9
|
+
import { GenerationalCache } from '@asamuzakjp/generational-cache';
|
|
10
10
|
import { Finder } from './js/finder.js';
|
|
11
|
+
import { unescapeSelector, parseAstName } from './js/parser.js';
|
|
11
12
|
import { filterSelector, getType, initNwsapi } from './js/utility.js';
|
|
12
13
|
|
|
13
14
|
/* constants */
|
|
@@ -18,9 +19,13 @@ import {
|
|
|
18
19
|
TARGET_ALL,
|
|
19
20
|
TARGET_FIRST,
|
|
20
21
|
TARGET_LINEAL,
|
|
21
|
-
TARGET_SELF
|
|
22
|
+
TARGET_SELF,
|
|
23
|
+
COMBINATOR,
|
|
24
|
+
ID_SELECTOR,
|
|
25
|
+
CLASS_SELECTOR,
|
|
26
|
+
TYPE_SELECTOR
|
|
22
27
|
} from './js/constant.js';
|
|
23
|
-
const
|
|
28
|
+
const CACHE_SIZE = 2048;
|
|
24
29
|
|
|
25
30
|
/**
|
|
26
31
|
* @typedef {object} CheckResult
|
|
@@ -46,13 +51,13 @@ export class DOMSelector {
|
|
|
46
51
|
* @param {object} [opt] - Options.
|
|
47
52
|
*/
|
|
48
53
|
constructor(window, document, opt = {}) {
|
|
49
|
-
const { idlUtils } = opt;
|
|
54
|
+
const { cacheSize, idlUtils } = opt;
|
|
50
55
|
this.#window = window;
|
|
51
56
|
this.#document = document ?? window.document;
|
|
52
57
|
this.#finder = new Finder(window);
|
|
53
58
|
this.#idlUtils = idlUtils;
|
|
54
59
|
this.#nwsapi = initNwsapi(window, document);
|
|
55
|
-
this.#cache = new GenerationalCache(
|
|
60
|
+
this.#cache = new GenerationalCache(cacheSize ?? CACHE_SIZE);
|
|
56
61
|
}
|
|
57
62
|
|
|
58
63
|
/**
|
|
@@ -63,6 +68,59 @@ export class DOMSelector {
|
|
|
63
68
|
this.#finder.clearResults(true);
|
|
64
69
|
};
|
|
65
70
|
|
|
71
|
+
/**
|
|
72
|
+
* Parses a selector and extracts the rightmost subject keys (Id, Class, Tag).
|
|
73
|
+
* @param {string} selector - The CSS selector to parse.
|
|
74
|
+
* @returns {Array<{id: string|null, className: string|null, tag: string|null}>} The list of extracted keys for each selector group.
|
|
75
|
+
*/
|
|
76
|
+
extractSubjects = selector => {
|
|
77
|
+
if (!selector || typeof selector !== 'string') {
|
|
78
|
+
return [{ id: null, className: null, tag: null }];
|
|
79
|
+
}
|
|
80
|
+
const cacheKey = `extract_${selector}`;
|
|
81
|
+
let subjects = this.#cache.get(cacheKey);
|
|
82
|
+
if (subjects !== undefined) {
|
|
83
|
+
return subjects;
|
|
84
|
+
}
|
|
85
|
+
subjects = [];
|
|
86
|
+
try {
|
|
87
|
+
const ast = this.#finder.getAST(selector);
|
|
88
|
+
if (ast?.type === 'SelectorList') {
|
|
89
|
+
for (const selectorNode of ast.children) {
|
|
90
|
+
let idKey = null;
|
|
91
|
+
let classKey = null;
|
|
92
|
+
let tagKey = null;
|
|
93
|
+
let current = selectorNode.children.tail;
|
|
94
|
+
while (current) {
|
|
95
|
+
const node = current.data;
|
|
96
|
+
if (node.type === COMBINATOR) {
|
|
97
|
+
break;
|
|
98
|
+
}
|
|
99
|
+
if (node.type === ID_SELECTOR) {
|
|
100
|
+
idKey = idKey ?? unescapeSelector(node.name);
|
|
101
|
+
} else if (node.type === CLASS_SELECTOR) {
|
|
102
|
+
classKey = classKey ?? unescapeSelector(node.name);
|
|
103
|
+
} else if (node.type === TYPE_SELECTOR) {
|
|
104
|
+
const { localName } = parseAstName(unescapeSelector(node.name));
|
|
105
|
+
if (localName !== '*') {
|
|
106
|
+
tagKey = tagKey ?? localName.toLowerCase();
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
current = current.prev;
|
|
110
|
+
}
|
|
111
|
+
subjects.push({ id: idKey, className: classKey, tag: tagKey });
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
} catch (e) {
|
|
115
|
+
// fall through
|
|
116
|
+
}
|
|
117
|
+
if (!subjects.length) {
|
|
118
|
+
subjects.push({ id: null, className: null, tag: null });
|
|
119
|
+
}
|
|
120
|
+
this.#cache.set(cacheKey, subjects);
|
|
121
|
+
return subjects;
|
|
122
|
+
};
|
|
123
|
+
|
|
66
124
|
/**
|
|
67
125
|
* Checks if an element matches a CSS selector.
|
|
68
126
|
* @param {string} selector - The CSS selector to check against.
|
package/src/js/matcher.js
CHANGED
|
@@ -443,16 +443,19 @@ export const matchAttributeSelector = (ast, node, opt = {}) => {
|
|
|
443
443
|
const { name: astIdentValue, value: astStringValue } = astValue ?? {};
|
|
444
444
|
let attrValue;
|
|
445
445
|
if (astIdentValue) {
|
|
446
|
+
// Ident values are not unescaped by css-tree, so we must unescape them
|
|
447
|
+
// (e.g. `\5c` hex escapes, `\n` character escapes).
|
|
446
448
|
if (caseInsensitive) {
|
|
447
|
-
attrValue = astIdentValue.toLowerCase()
|
|
449
|
+
attrValue = unescapeSelector(astIdentValue).toLowerCase();
|
|
448
450
|
} else {
|
|
449
|
-
attrValue = astIdentValue
|
|
451
|
+
attrValue = unescapeSelector(astIdentValue);
|
|
450
452
|
}
|
|
451
453
|
} else if (astStringValue) {
|
|
454
|
+
// String values (quoted) are already unescaped by css-tree, so use as-is.
|
|
452
455
|
if (caseInsensitive) {
|
|
453
|
-
attrValue = astStringValue.toLowerCase()
|
|
456
|
+
attrValue = astStringValue.toLowerCase();
|
|
454
457
|
} else {
|
|
455
|
-
attrValue = astStringValue
|
|
458
|
+
attrValue = astStringValue;
|
|
456
459
|
}
|
|
457
460
|
} else if (astStringValue === '') {
|
|
458
461
|
attrValue = astStringValue;
|
package/src/js/parser.js
CHANGED
|
@@ -72,6 +72,13 @@ export const unescapeSelector = (selector = '') => {
|
|
|
72
72
|
const item = arr[i];
|
|
73
73
|
if (item === '' && i === l - 1) {
|
|
74
74
|
selectorItems.push(U_FFFD);
|
|
75
|
+
} else if (item === '') {
|
|
76
|
+
// Empty segment at non-last position means \\ (escaped backslash)
|
|
77
|
+
selectorItems.push('\\');
|
|
78
|
+
i++; // skip the next segment which is the remainder after \\
|
|
79
|
+
if (i < l) {
|
|
80
|
+
selectorItems.push(arr[i]);
|
|
81
|
+
}
|
|
75
82
|
} else {
|
|
76
83
|
const hexExists = /^([\da-f]{1,6}\s?)/i.exec(item);
|
|
77
84
|
if (hexExists) {
|
package/src/js/utility.js
CHANGED
|
@@ -1047,6 +1047,7 @@ export const initNwsapi = (window, document) => {
|
|
|
1047
1047
|
*/
|
|
1048
1048
|
export const filterSelector = (selector, target) => {
|
|
1049
1049
|
const isQuerySelectorAll = target === TARGET_ALL;
|
|
1050
|
+
// Basic validation and fast-fail for null/undefined/non-string values
|
|
1050
1051
|
if (
|
|
1051
1052
|
!selector ||
|
|
1052
1053
|
typeof selector !== 'string' ||
|
|
@@ -1054,59 +1055,54 @@ export const filterSelector = (selector, target) => {
|
|
|
1054
1055
|
) {
|
|
1055
1056
|
return false;
|
|
1056
1057
|
}
|
|
1057
|
-
// Exclude
|
|
1058
|
+
// Exclude various complex or unsupported selectors early
|
|
1059
|
+
// i.e. non-ASCII, escaped selectors, namespaced selectors and pseudo-elements
|
|
1060
|
+
if (selector.includes('/') || REG_EXCLUDE_BASIC.test(selector)) {
|
|
1061
|
+
return false;
|
|
1062
|
+
}
|
|
1063
|
+
// Validate attribute selector integrity
|
|
1058
1064
|
if (selector.includes('[')) {
|
|
1059
1065
|
const index = selector.lastIndexOf('[');
|
|
1060
|
-
|
|
1061
|
-
if (sel.indexOf(']') < 0) {
|
|
1066
|
+
if (selector.indexOf(']', index) === -1) {
|
|
1062
1067
|
return false;
|
|
1063
1068
|
}
|
|
1064
1069
|
}
|
|
1065
|
-
//
|
|
1070
|
+
// Target-specific early exits
|
|
1066
1071
|
if (target === TARGET_FIRST) {
|
|
1067
|
-
|
|
1068
|
-
return true;
|
|
1069
|
-
}
|
|
1070
|
-
return false;
|
|
1072
|
+
return REG_ATTR_SIMPLE.test(selector);
|
|
1071
1073
|
}
|
|
1072
|
-
|
|
1073
|
-
// Exclude simple tag selector for TARGET_ALL
|
|
1074
1074
|
if (target === TARGET_ALL && REG_TAG_SIMPLE.test(selector)) {
|
|
1075
1075
|
return false;
|
|
1076
1076
|
}
|
|
1077
|
-
|
|
1078
|
-
// Exclude various complex or unsupported selectors.
|
|
1079
|
-
// - selectors containing '/'
|
|
1080
|
-
// - namespaced selectors
|
|
1081
|
-
// - escaped selectors
|
|
1082
|
-
// - pseudo-element selectors
|
|
1083
|
-
// - selectors containing non-ASCII
|
|
1084
|
-
// - selectors containing control character other than whitespace
|
|
1085
|
-
// - attribute selectors with case flag, e.g. [attr i]
|
|
1086
|
-
// - attribute selectors with unclosed quotes
|
|
1087
|
-
// - empty :is() or :where()
|
|
1088
|
-
if (selector.includes('/') || REG_EXCLUDE_BASIC.test(selector)) {
|
|
1089
|
-
return false;
|
|
1090
|
-
}
|
|
1091
|
-
// Include pseudo-classes that are known to work correctly.
|
|
1077
|
+
// Logic for pseudo-classes
|
|
1092
1078
|
if (selector.includes(':')) {
|
|
1079
|
+
// Exclude descendant combinators in logical selectors for querySelectorAll
|
|
1093
1080
|
if (isQuerySelectorAll && REG_DESCEND.test(selector)) {
|
|
1094
1081
|
return false;
|
|
1095
1082
|
}
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1083
|
+
// Determine if the selector has complex logical structures
|
|
1084
|
+
const isComplex = isQuerySelectorAll ? false : REG_COMPLEX.test(selector);
|
|
1085
|
+
// Handle :has() specifically
|
|
1086
|
+
if (selector.includes(':has(')) {
|
|
1087
|
+
if (isQuerySelectorAll) {
|
|
1088
|
+
return false;
|
|
1089
|
+
}
|
|
1090
|
+
if (!isComplex || REG_LOGIC_HAS_COMPOUND.test(selector)) {
|
|
1099
1091
|
return false;
|
|
1100
1092
|
}
|
|
1101
1093
|
return REG_END_WITH_HAS.test(selector);
|
|
1102
|
-
}
|
|
1103
|
-
|
|
1094
|
+
}
|
|
1095
|
+
// Handle :is() and :not()
|
|
1096
|
+
if (/(?:is|not)\(/.test(selector)) {
|
|
1097
|
+
if (isComplex) {
|
|
1104
1098
|
return !REG_LOGIC_COMPLEX.test(selector);
|
|
1105
1099
|
} else {
|
|
1106
1100
|
return !REG_LOGIC_COMPOUND.test(selector);
|
|
1107
1101
|
}
|
|
1108
|
-
}
|
|
1109
|
-
|
|
1102
|
+
}
|
|
1103
|
+
// Default check for other pseudo-classes against known list
|
|
1104
|
+
if (REG_WO_LOGICAL.test(selector)) {
|
|
1105
|
+
return false;
|
|
1110
1106
|
}
|
|
1111
1107
|
}
|
|
1112
1108
|
return true;
|
package/types/index.d.ts
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
export class DOMSelector {
|
|
2
2
|
constructor(window: Window, document: Document, opt?: object);
|
|
3
3
|
clear: () => void;
|
|
4
|
+
extractSubjects: (selector: string) => Array<{
|
|
5
|
+
id: string | null;
|
|
6
|
+
className: string | null;
|
|
7
|
+
tag: string | null;
|
|
8
|
+
}>;
|
|
4
9
|
check: (selector: string, node: Element, opt?: object) => CheckResult;
|
|
5
10
|
matches: (selector: string, node: Element, opt?: object) => boolean;
|
|
6
11
|
closest: (selector: string, node: Element, opt?: object) => Element | null;
|
package/src/js/cache.js
DELETED
|
@@ -1,77 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* cache.js
|
|
3
|
-
*/
|
|
4
|
-
|
|
5
|
-
/* Generational Cache */
|
|
6
|
-
export class GenerationalCache {
|
|
7
|
-
#max;
|
|
8
|
-
#boundary;
|
|
9
|
-
#current;
|
|
10
|
-
#old;
|
|
11
|
-
|
|
12
|
-
/**
|
|
13
|
-
* constructor
|
|
14
|
-
* @param {number} max - max cache size
|
|
15
|
-
*/
|
|
16
|
-
constructor(max) {
|
|
17
|
-
this.#current = new Map();
|
|
18
|
-
this.#old = new Map();
|
|
19
|
-
this.max = max;
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
get size() {
|
|
23
|
-
return this.#current.size + this.#old.size;
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
/**
|
|
27
|
-
* @returns {number} max cache size
|
|
28
|
-
*/
|
|
29
|
-
get max() {
|
|
30
|
-
return this.#max;
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
set max(value) {
|
|
34
|
-
if (Number.isFinite(value) && value > 4) {
|
|
35
|
-
this.#max = value;
|
|
36
|
-
this.#boundary = Math.ceil(value / 2);
|
|
37
|
-
} else {
|
|
38
|
-
this.#max = 4;
|
|
39
|
-
this.#boundary = 2;
|
|
40
|
-
}
|
|
41
|
-
this.clear();
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
get(key) {
|
|
45
|
-
if (this.#current.has(key)) {
|
|
46
|
-
return this.#current.get(key);
|
|
47
|
-
}
|
|
48
|
-
const value = this.#old.get(key);
|
|
49
|
-
if (value !== undefined) {
|
|
50
|
-
this.set(key, value);
|
|
51
|
-
return value;
|
|
52
|
-
}
|
|
53
|
-
return undefined;
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
set(key, value) {
|
|
57
|
-
this.#current.set(key, value);
|
|
58
|
-
if (this.#current.size >= this.#boundary) {
|
|
59
|
-
this.#old = this.#current;
|
|
60
|
-
this.#current = new Map();
|
|
61
|
-
}
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
has(key) {
|
|
65
|
-
return this.#current.has(key) || this.#old.has(key);
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
delete(key) {
|
|
69
|
-
this.#current.delete(key);
|
|
70
|
-
this.#old.delete(key);
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
clear() {
|
|
74
|
-
this.#current.clear();
|
|
75
|
-
this.#old.clear();
|
|
76
|
-
}
|
|
77
|
-
}
|
package/types/js/cache.d.ts
DELETED
|
@@ -1,12 +0,0 @@
|
|
|
1
|
-
export class GenerationalCache {
|
|
2
|
-
constructor(max: number);
|
|
3
|
-
set max(value: number);
|
|
4
|
-
get max(): number;
|
|
5
|
-
get size(): number;
|
|
6
|
-
get(key: any): any;
|
|
7
|
-
set(key: any, value: any): void;
|
|
8
|
-
has(key: any): boolean;
|
|
9
|
-
delete(key: any): void;
|
|
10
|
-
clear(): void;
|
|
11
|
-
#private;
|
|
12
|
-
}
|