@asamuzakjp/dom-selector 8.0.0 → 8.0.2

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.
@@ -127,3 +127,101 @@ export const INPUT_LTR = Object.freeze([
127
127
 
128
128
  /* logical combination pseudo-classes */
129
129
  export const KEYS_LOGICAL = new Set(['has', 'is', 'not', 'where']);
130
+
131
+ /* lists of supported / unsupported pseudo-classes and pseudo-elements */
132
+ export const KEYS_PS_CLASS_SUPPORTED = new Set([
133
+ 'active',
134
+ 'any-link',
135
+ 'checked',
136
+ 'closed',
137
+ 'default',
138
+ 'defined',
139
+ 'dir',
140
+ 'disabled',
141
+ 'empty',
142
+ 'enabled',
143
+ 'first-child',
144
+ 'first-of-type',
145
+ 'focus',
146
+ 'focus-visible',
147
+ 'focus-within',
148
+ 'has',
149
+ 'host',
150
+ 'host-context',
151
+ 'hover',
152
+ 'in-range',
153
+ 'indeterminate',
154
+ 'invalid',
155
+ 'is',
156
+ 'lang',
157
+ 'last-child',
158
+ 'last-of-type',
159
+ 'link',
160
+ 'local-link',
161
+ 'not',
162
+ 'nth-child',
163
+ 'nth-last-child',
164
+ 'nth-last-of-type',
165
+ 'nth-of-type',
166
+ 'only-child',
167
+ 'only-of-type',
168
+ 'open',
169
+ 'optional',
170
+ 'out-of-range',
171
+ 'placeholder-shown',
172
+ 'read-only',
173
+ 'read-write',
174
+ 'required',
175
+ 'root',
176
+ 'scope',
177
+ 'state',
178
+ 'target',
179
+ 'target-within',
180
+ 'valid',
181
+ 'visited',
182
+ 'where'
183
+ ]);
184
+
185
+ export const KEYS_PS_CLASS_UNSUPPORTED = new Set([
186
+ 'autofill',
187
+ 'blank',
188
+ 'buffering',
189
+ 'contains',
190
+ 'current',
191
+ 'fullscreen',
192
+ 'future',
193
+ 'has-slotted',
194
+ 'heading',
195
+ 'modal',
196
+ 'muted',
197
+ 'nth-col',
198
+ 'nth-last-col',
199
+ 'past',
200
+ 'paused',
201
+ 'picture-in-picture',
202
+ 'playing',
203
+ 'popover-open',
204
+ 'seeking',
205
+ 'stalled',
206
+ 'user-invalid',
207
+ 'user-valid',
208
+ 'volume-locked',
209
+ '-webkit-autofill'
210
+ ]);
211
+
212
+ export const KEYS_PS_ELEMENT_UNSUPPORTED = new Set([
213
+ 'after',
214
+ 'backdrop',
215
+ 'before',
216
+ 'cue',
217
+ 'cue-region',
218
+ 'file-selector-button',
219
+ 'first-letter',
220
+ 'first-line',
221
+ 'marker',
222
+ 'part',
223
+ 'placeholder',
224
+ 'selection',
225
+ 'slotted',
226
+ 'target-text'
227
+ ]);
package/src/js/finder.js CHANGED
@@ -13,16 +13,15 @@ import {
13
13
  matchTypeSelector
14
14
  } from './matcher.js';
15
15
  import {
16
- findAST,
17
16
  generateCSS,
18
17
  parseSelector,
19
18
  sortAST,
20
19
  unescapeSelector,
21
20
  walkAST
22
21
  } from './parser.js';
22
+ import { createHasValidator, isInvalidCombinator } from './selector.js';
23
23
  import {
24
24
  filterNodesByAnB,
25
- findLogicalWithNestedHas,
26
25
  generateException,
27
26
  isCustomElement,
28
27
  isFocusVisible,
@@ -310,18 +309,18 @@ export class Finder {
310
309
  for (let i = 0; i < l; i++) {
311
310
  const items = [...branches[i]];
312
311
  const branch = [];
312
+ let prevType = null;
313
313
  let item = items.shift();
314
- if (item && item.type !== COMBINATOR) {
314
+ if (item) {
315
315
  const leaves = new Set();
316
316
  while (item) {
317
+ const isLast = items.length === 0;
318
+ if (isInvalidCombinator(item.type, prevType, isLast)) {
319
+ const msg = `Invalid selector ${selector}`;
320
+ this.onError(generateException(msg, SYNTAX_ERR, this.#window));
321
+ return { ast: [], descendant: false, invalidate: false };
322
+ }
317
323
  if (item.type === COMBINATOR) {
318
- const [nextItem] = items;
319
- if (!nextItem || nextItem.type === COMBINATOR) {
320
- const msg = `Invalid selector ${selector}`;
321
- this.onError(generateException(msg, SYNTAX_ERR, this.#window));
322
- // Stop processing on invalid selector.
323
- return { ast: [], descendant: false, invalidate: false };
324
- }
325
324
  if (item.name === ' ' || item.name === '>') {
326
325
  descendant = true;
327
326
  }
@@ -339,7 +338,8 @@ export class Finder {
339
338
  }
340
339
  leaves.add(item);
341
340
  }
342
- if (items.length) {
341
+ prevType = item.type;
342
+ if (!isLast) {
343
343
  item = items.shift();
344
344
  } else {
345
345
  branch.push({ combo: null, leaves: sortAST(leaves) });
@@ -384,16 +384,22 @@ export class Finder {
384
384
  }
385
385
  } else {
386
386
  this.#selectorAST = parseSelector(selector);
387
- const { branches, info } = walkAST(this.#selectorAST, true);
387
+ const { branches, info } = walkAST(
388
+ this.#selectorAST,
389
+ true,
390
+ createHasValidator(this.#window)
391
+ );
388
392
  const {
389
393
  hasHasPseudoFunc,
390
394
  hasLogicalPseudoFunc,
391
395
  hasNthChildOfSelector,
392
- hasStatePseudoClass
396
+ hasStatePseudoClass,
397
+ hasUnsupportedPseudoClass
393
398
  } = info;
394
399
  this.#invalidate =
395
400
  hasHasPseudoFunc ||
396
401
  hasStatePseudoClass ||
402
+ hasUnsupportedPseudoClass ||
397
403
  !!(hasLogicalPseudoFunc && hasNthChildOfSelector);
398
404
  const processed = this._processSelectorBranches(branches, selector);
399
405
  ast = processed.ast;
@@ -825,29 +831,6 @@ export class Finder {
825
831
  } else {
826
832
  const { branches } = walkAST(ast);
827
833
  if (astName === 'has') {
828
- // Check for nested :has().
829
- let forgiven = false;
830
- const l = astChildren.length;
831
- for (let i = 0; i < l; i++) {
832
- const child = astChildren[i];
833
- const item = findAST(child, findLogicalWithNestedHas);
834
- if (item) {
835
- const itemName = item.name;
836
- if (itemName === 'is' || itemName === 'where') {
837
- forgiven = true;
838
- break;
839
- } else {
840
- const css = generateCSS(ast);
841
- const msg = `Invalid selector ${css}`;
842
- return this.onError(
843
- generateException(msg, SYNTAX_ERR, this.#window)
844
- );
845
- }
846
- }
847
- }
848
- if (forgiven) {
849
- return matched;
850
- }
851
834
  astData = {
852
835
  astName,
853
836
  branches
package/src/js/parser.js CHANGED
@@ -22,6 +22,7 @@ import {
22
22
  HEX,
23
23
  ID_SELECTOR,
24
24
  KEYS_LOGICAL,
25
+ KEYS_PS_CLASS_SUPPORTED,
25
26
  NTH,
26
27
  PS_CLASS_SELECTOR,
27
28
  PS_ELEMENT_SELECTOR,
@@ -219,9 +220,10 @@ export const parseSelector = sel => {
219
220
  * about its contents.
220
221
  * @param {object} ast - The AST to traverse.
221
222
  * @param {boolean} toObject - True if converts ast to object, false otherwise.
223
+ * @param {function(object): void} [callback] - Optional callback for each node.
222
224
  * @returns {{branches: Array<object>, info: object}} An object containing the selector branches and info.
223
225
  */
224
- export const walkAST = (ast = {}, toObject = false) => {
226
+ export const walkAST = (ast = {}, toObject = false, callback = null) => {
225
227
  const branches = new Set();
226
228
  const info = {
227
229
  hasForgivenPseudoFunc: false,
@@ -230,10 +232,14 @@ export const walkAST = (ast = {}, toObject = false) => {
230
232
  hasNotPseudoFunc: false,
231
233
  hasNthChildOfSelector: false,
232
234
  hasNestedSelector: false,
233
- hasStatePseudoClass: false
235
+ hasStatePseudoClass: false,
236
+ hasUnsupportedPseudoClass: false
234
237
  };
235
238
  const opt = {
236
239
  enter(node) {
240
+ if (typeof callback === 'function') {
241
+ callback(node);
242
+ }
237
243
  switch (node.type) {
238
244
  case CLASS_SELECTOR: {
239
245
  if (/^-?\d/.test(node.name)) {
@@ -254,24 +260,27 @@ export const walkAST = (ast = {}, toObject = false) => {
254
260
  break;
255
261
  }
256
262
  case PS_CLASS_SELECTOR: {
257
- if (KEYS_LOGICAL.has(node.name)) {
263
+ const name = node.name.toLowerCase();
264
+ if (KEYS_LOGICAL.has(name)) {
258
265
  info.hasNestedSelector = true;
259
266
  info.hasLogicalPseudoFunc = true;
260
- if (node.name === 'has') {
267
+ if (name === 'has') {
261
268
  info.hasHasPseudoFunc = true;
262
- } else if (node.name === 'not') {
269
+ } else if (name === 'not') {
263
270
  info.hasNotPseudoFunc = true;
264
271
  } else {
265
272
  info.hasForgivenPseudoFunc = true;
266
273
  }
267
- } else if (KEYS_PS_CLASS_STATE.has(node.name)) {
274
+ } else if (KEYS_PS_CLASS_STATE.has(name)) {
268
275
  info.hasStatePseudoClass = true;
269
276
  } else if (
270
- KEYS_SHADOW_HOST.has(node.name) &&
277
+ KEYS_SHADOW_HOST.has(name) &&
271
278
  Array.isArray(node.children) &&
272
279
  node.children.length
273
280
  ) {
274
281
  info.hasNestedSelector = true;
282
+ } else if (!KEYS_PS_CLASS_SUPPORTED.has(name)) {
283
+ info.hasUnsupportedPseudoClass = true;
275
284
  }
276
285
  break;
277
286
  }
@@ -0,0 +1,333 @@
1
+ /**
2
+ * selector.js
3
+ */
4
+
5
+ /* import */
6
+ import * as cssTree from 'css-tree';
7
+ import { generateException } from './utility.js';
8
+
9
+ /* constants */
10
+ import {
11
+ COMBINATOR,
12
+ COMBO,
13
+ COMPOUND_I,
14
+ DESCEND,
15
+ HAS_COMPOUND,
16
+ KEYS_LOGICAL,
17
+ KEYS_PS_CLASS_SUPPORTED,
18
+ LOGIC_COMPLEX,
19
+ LOGIC_COMPOUND,
20
+ N_TH,
21
+ PSEUDO_CLASS,
22
+ PS_CLASS_SELECTOR,
23
+ PS_ELEMENT_SELECTOR,
24
+ SELECTOR,
25
+ SYNTAX_ERR,
26
+ TAG_TYPE,
27
+ TARGET_ALL,
28
+ TARGET_FIRST
29
+ } from './constant.js';
30
+
31
+ /* regexp */
32
+ const REG_ATTR_SIMPLE = /^\[[A-Z\d-]{1,255}(?:="?[A-Z\d\s-]{1,255}"?)?\]$/i;
33
+ const REG_TAG_SIMPLE = new RegExp(`^(?:${TAG_TYPE})$`);
34
+ const REG_EXCLUDE_BASIC =
35
+ /[|\\]|::|[^\u0021-\u007F\s]|\[\s*[\w$*=^|~-]+(?:(?:"[\w$*=^|~\s'-]+"|'[\w$*=^|~\s"-]+')?(?:\s+[\w$*=^|~-]+)+|"[^"\]]{1,255}|'[^'\]]{1,255})\s*\]|:(?:is|where)\(\s*\)/;
36
+ const REG_COMPLEX = new RegExp(`${COMPOUND_I}${COMBO}${COMPOUND_I}`, 'i');
37
+ const REG_DESCEND = new RegExp(`${COMPOUND_I}${DESCEND}${COMPOUND_I}`, 'i');
38
+ const REG_LOGIC_COMPLEX = new RegExp(
39
+ `:(?!${PSEUDO_CLASS}|${N_TH}|${LOGIC_COMPLEX})`
40
+ );
41
+ const REG_LOGIC_COMPOUND = new RegExp(
42
+ `:(?!${PSEUDO_CLASS}|${N_TH}|${LOGIC_COMPOUND})`
43
+ );
44
+ const REG_LOGIC_HAS_COMPOUND = new RegExp(
45
+ `:(?!${PSEUDO_CLASS}|${N_TH}|${LOGIC_COMPOUND}|${HAS_COMPOUND})`
46
+ );
47
+ const REG_END_WITH_HAS = new RegExp(`:${HAS_COMPOUND}$`);
48
+ const REG_WO_LOGICAL = new RegExp(`:(?!${PSEUDO_CLASS}|${N_TH})`);
49
+ const REG_COMBO = new RegExp(COMBO);
50
+ const REG_ID = /#(\D[^#.*]+)/g;
51
+ const REG_CLASS = /\.(\D[^#.*]+)/g;
52
+ const REG_TAG = /^([^#.]+)/;
53
+ const REG_INVALID_SYNTAX =
54
+ /[+~>]\s*[+~>]|^\s*[+~>]|[+~>]\s*$|^\s*,|,\s*,|,\s*$/;
55
+
56
+ /**
57
+ * Find a nested :has() pseudo-class.
58
+ * @param {object} leaf - The AST leaf to check.
59
+ * @returns {?object} The leaf if it's :has, otherwise null.
60
+ */
61
+ export const findNestedHas = leaf => leaf.name === 'has';
62
+
63
+ /**
64
+ * Find a logical pseudo-class that contains a nested :has().
65
+ * @param {object} leaf - The AST leaf to check.
66
+ * @returns {?object} The leaf if it matches, otherwise null.
67
+ */
68
+ export const findLogicalWithNestedHas = leaf => {
69
+ if (KEYS_LOGICAL.has(leaf.name) && cssTree.find(leaf, findNestedHas)) {
70
+ return leaf;
71
+ }
72
+ return null;
73
+ };
74
+
75
+ /**
76
+ * Validates nesting restrictions within :has() arguments.
77
+ * @param {Array<object>} astChildren - The AST nodes representing the :has() arguments.
78
+ * @returns {boolean} False if there's an invalid nesting constraint violation.
79
+ */
80
+ export const validateHasNesting = astChildren => {
81
+ const l = astChildren.length;
82
+ for (let i = 0; i < l; i++) {
83
+ const item = cssTree.find(astChildren[i], findLogicalWithNestedHas);
84
+ if (item) {
85
+ // If nested :has() is wrapped inside :is() or :where(), it is forgiven.
86
+ if (item.name !== 'is' && item.name !== 'where') {
87
+ return false;
88
+ }
89
+ }
90
+ }
91
+ return true;
92
+ };
93
+
94
+ /**
95
+ * Creates a callback function to validate :has() nesting during AST walk.
96
+ * @param {object} globalObj - The global window object.
97
+ * @returns {function(object): void} The callback function for walkAST.
98
+ */
99
+ export const createHasValidator = globalObj => node => {
100
+ if (
101
+ node.type === PS_CLASS_SELECTOR &&
102
+ node.name.toLowerCase() === 'has' &&
103
+ !validateHasNesting(Array.from(node.children || []))
104
+ ) {
105
+ const css = cssTree.generate(node);
106
+ throw generateException(
107
+ `Disallowed nested :has() pseudo-class: ${css}`,
108
+ SYNTAX_ERR,
109
+ globalObj
110
+ );
111
+ }
112
+ };
113
+
114
+ /**
115
+ * Check if a combinator node is invalid (leading, trailing, or consecutive).
116
+ * @param {string} type - The current node type.
117
+ * @param {string|null} prevType - The previous node type.
118
+ * @param {boolean} isLast - Whether the current node is the last in the list.
119
+ * @returns {boolean} True if the combinator is invalid.
120
+ */
121
+ export const isInvalidCombinator = (type, prevType, isLast) =>
122
+ type === COMBINATOR &&
123
+ (prevType === null || prevType === COMBINATOR || isLast);
124
+
125
+ /**
126
+ * Checks if a given AST is supported by the DOMSelector engine.
127
+ * @param {object} ast - The AST to validate.
128
+ * @returns {boolean} True if the selector is fully supported.
129
+ */
130
+ export const isSupportedAST = ast => {
131
+ let isSupported = true;
132
+ const walk = (
133
+ node,
134
+ context = { insideHas: false, insideForgiving: false }
135
+ ) => {
136
+ if (!isSupported || !node) {
137
+ return;
138
+ }
139
+ const nextContext = { ...context };
140
+ if (node.type === PS_ELEMENT_SELECTOR) {
141
+ isSupported = false;
142
+ return;
143
+ } else if (node.type === PS_CLASS_SELECTOR) {
144
+ let name = node.name;
145
+ if (name && typeof name === 'string') {
146
+ name = name.toLowerCase();
147
+ }
148
+ if (!KEYS_PS_CLASS_SUPPORTED.has(name)) {
149
+ isSupported = false;
150
+ return;
151
+ }
152
+ if (name === 'has') {
153
+ if (context.insideHas && !context.insideForgiving) {
154
+ isSupported = false;
155
+ return;
156
+ }
157
+ nextContext.insideHas = true;
158
+ } else if (name === 'is' || name === 'where') {
159
+ nextContext.insideForgiving = true;
160
+ }
161
+ }
162
+ if (node.children) {
163
+ let prevType = null;
164
+ if (node.children.head !== undefined) {
165
+ let current = node.children.head;
166
+ while (current) {
167
+ if (!current.data) {
168
+ current = current.next;
169
+ continue;
170
+ }
171
+ const childType = current.data.type;
172
+ if (
173
+ node.type === SELECTOR &&
174
+ isInvalidCombinator(childType, prevType, !current.next)
175
+ ) {
176
+ if (!(prevType === null && context.insideHas)) {
177
+ isSupported = false;
178
+ return;
179
+ }
180
+ }
181
+ prevType = childType;
182
+ walk(current.data, nextContext);
183
+ if (!isSupported) {
184
+ return;
185
+ }
186
+ current = current.next;
187
+ }
188
+ } else if (Array.isArray(node.children)) {
189
+ const l = node.children.length;
190
+ for (let i = 0; i < l; i++) {
191
+ const child = node.children[i];
192
+ if (!child) {
193
+ continue;
194
+ }
195
+ const childType = child.type;
196
+ if (
197
+ node.type === SELECTOR &&
198
+ isInvalidCombinator(childType, prevType, i === l - 1)
199
+ ) {
200
+ if (!(prevType === null && context.insideHas)) {
201
+ isSupported = false;
202
+ return;
203
+ }
204
+ }
205
+ prevType = childType;
206
+ walk(child, nextContext);
207
+ if (!isSupported) {
208
+ return;
209
+ }
210
+ }
211
+ }
212
+ }
213
+ if (node.selector) {
214
+ walk(node.selector, nextContext);
215
+ }
216
+ };
217
+ walk(ast);
218
+ return isSupported;
219
+ };
220
+
221
+ /**
222
+ * Extracts the rightmost subject keys (id, class, tag) from a selector.
223
+ * @param {string} selector - The CSS selector string to parse.
224
+ * @param {boolean} caseSensitive - True if the tag should be case-sensitive.
225
+ * @returns {Array<{id: string|null, className: string|null, tag: string|null}>} The list of extracted keys for each selector group.
226
+ */
227
+ export const extractSubjectsRegExp = (selector, caseSensitive) => {
228
+ const subjects = [];
229
+ const groups = selector.split(',');
230
+ for (let i = 0; i < groups.length; i++) {
231
+ const group = groups[i].trim();
232
+ if (!group) {
233
+ continue;
234
+ }
235
+ const compounds = group.split(REG_COMBO);
236
+ const rightmost = compounds[compounds.length - 1];
237
+ let idKey = null;
238
+ let classKey = null;
239
+ let tagKey = null;
240
+ if (rightmost) {
241
+ const idMatch = rightmost.match(REG_ID);
242
+ if (idMatch) {
243
+ idKey = idMatch[idMatch.length - 1].slice(1);
244
+ }
245
+ const classMatch = rightmost.match(REG_CLASS);
246
+ if (classMatch) {
247
+ classKey = classMatch[classMatch.length - 1].slice(1);
248
+ }
249
+ const tagMatch = rightmost.match(REG_TAG);
250
+ if (tagMatch) {
251
+ const tag = tagMatch[1];
252
+ if (tag !== '*') {
253
+ tagKey = caseSensitive ? tag : tag.toLowerCase();
254
+ }
255
+ }
256
+ }
257
+ subjects.push({ id: idKey, className: classKey, tag: tagKey });
258
+ }
259
+ return subjects;
260
+ };
261
+
262
+ /**
263
+ * Filter a selector for use with nwsapi.
264
+ * @param {string} selector - The selector string.
265
+ * @param {string} target - The target type.
266
+ * @returns {boolean} - True if the selector is valid for nwsapi.
267
+ */
268
+ export const filterSelector = (selector, target) => {
269
+ const isQuerySelectorAll = target === TARGET_ALL;
270
+ // Basic validation and fast-fail for null/undefined/non-string values.
271
+ if (
272
+ !selector ||
273
+ typeof selector !== 'string' ||
274
+ /null|undefined/.test(selector)
275
+ ) {
276
+ return false;
277
+ }
278
+ // Validate syntax.
279
+ if (REG_INVALID_SYNTAX.test(selector)) {
280
+ return false;
281
+ }
282
+ // Exclude various complex or unsupported selectors early.
283
+ // i.e. non-ASCII, escaped selectors, namespaced selectors, pseudo-elements.
284
+ if (selector.includes('/') || REG_EXCLUDE_BASIC.test(selector)) {
285
+ return false;
286
+ }
287
+ // Validate attribute selector integrity.
288
+ if (selector.includes('[')) {
289
+ const index = selector.lastIndexOf('[');
290
+ if (selector.indexOf(']', index) === -1) {
291
+ return false;
292
+ }
293
+ }
294
+ // Target-specific early exits.
295
+ if (target === TARGET_FIRST) {
296
+ return REG_ATTR_SIMPLE.test(selector);
297
+ }
298
+ if (target === TARGET_ALL && REG_TAG_SIMPLE.test(selector)) {
299
+ return false;
300
+ }
301
+ // Logic for pseudo-classes.
302
+ if (selector.includes(':')) {
303
+ // Exclude descendant combinators in logical selectors for querySelectorAll.
304
+ if (isQuerySelectorAll && REG_DESCEND.test(selector)) {
305
+ return false;
306
+ }
307
+ // Determine if the selector has complex logical structures.
308
+ const isComplex = isQuerySelectorAll ? false : REG_COMPLEX.test(selector);
309
+ // Handle :has() specifically.
310
+ if (selector.includes(':has(')) {
311
+ if (isQuerySelectorAll) {
312
+ return false;
313
+ }
314
+ if (!isComplex || REG_LOGIC_HAS_COMPOUND.test(selector)) {
315
+ return false;
316
+ }
317
+ return REG_END_WITH_HAS.test(selector);
318
+ }
319
+ // Handle :is() and :not().
320
+ if (/(?:is|not)\(/.test(selector)) {
321
+ if (isComplex) {
322
+ return !REG_LOGIC_COMPLEX.test(selector);
323
+ } else {
324
+ return !REG_LOGIC_COMPOUND.test(selector);
325
+ }
326
+ }
327
+ // Default check for other pseudo-classes against known list.
328
+ if (REG_WO_LOGICAL.test(selector)) {
329
+ return false;
330
+ }
331
+ }
332
+ return true;
333
+ };