@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 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.5.2",
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.4.0",
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.1",
50
- "sinon": "^21.0.3",
51
- "typescript": "^6.0.2",
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 --parallel --exit test/**/*.test.js",
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.9"
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 './js/cache.js';
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 MAX_CACHE = 1024;
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(MAX_CACHE);
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().replace(/\\(?!\\)/g, '');
449
+ attrValue = unescapeSelector(astIdentValue).toLowerCase();
448
450
  } else {
449
- attrValue = astIdentValue.replace(/\\(?!\\)/g, '');
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().replace(/\\(?!\\)/g, '');
456
+ attrValue = astStringValue.toLowerCase();
454
457
  } else {
455
- attrValue = astStringValue.replace(/\\(?!\\)/g, '');
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 missing close square bracket.
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
- const sel = selector.substring(index);
1061
- if (sel.indexOf(']') < 0) {
1066
+ if (selector.indexOf(']', index) === -1) {
1062
1067
  return false;
1063
1068
  }
1064
1069
  }
1065
- // Match only simple attribute selector for TARGET_FIRST.
1070
+ // Target-specific early exits
1066
1071
  if (target === TARGET_FIRST) {
1067
- if (REG_ATTR_SIMPLE.test(selector)) {
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
- const complex = isQuerySelectorAll ? false : REG_COMPLEX.test(selector);
1097
- if (!isQuerySelectorAll && /:has\(/.test(selector)) {
1098
- if (!complex || REG_LOGIC_HAS_COMPOUND.test(selector)) {
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
- } else if (/:(?:is|not)\(/.test(selector)) {
1103
- if (complex) {
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
- } else {
1109
- return !REG_WO_LOGICAL.test(selector);
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
- }
@@ -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
- }