index.js 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393
  1. /*!
  2. * DOM Selector - A CSS selector engine.
  3. * @license MIT
  4. * @copyright asamuzaK (Kazz)
  5. * @see {@link https://github.com/asamuzaK/domSelector/blob/main/LICENSE}
  6. */
  7. /* import */
  8. import { GenerationalCache } from '@asamuzakjp/generational-cache';
  9. import { Finder } from './js/finder.js';
  10. import { unescapeSelector, parseAstName } from './js/parser.js';
  11. import { filterSelector, getType, initNwsapi } from './js/utility.js';
  12. /* constants */
  13. import {
  14. DOCUMENT_NODE,
  15. DOCUMENT_FRAGMENT_NODE,
  16. ELEMENT_NODE,
  17. TARGET_ALL,
  18. TARGET_FIRST,
  19. TARGET_LINEAL,
  20. TARGET_SELF,
  21. COMBINATOR,
  22. ID_SELECTOR,
  23. CLASS_SELECTOR,
  24. TYPE_SELECTOR
  25. } from './js/constant.js';
  26. const CACHE_SIZE = 2048;
  27. /**
  28. * @typedef {object} CheckResult
  29. * @property {boolean} match - The match result.
  30. * @property {string?} pseudoElement - The pseudo-element, if any.
  31. * @property {object?} ast - The AST object.
  32. */
  33. /* DOMSelector */
  34. export class DOMSelector {
  35. /* private fields */
  36. #window;
  37. #document;
  38. #finder;
  39. #idlUtils;
  40. #nwsapi;
  41. #cache;
  42. /**
  43. * Creates an instance of DOMSelector.
  44. * @param {Window} window - The window object.
  45. * @param {Document} document - The document object.
  46. * @param {object} [opt] - Options.
  47. */
  48. constructor(window, document, opt = {}) {
  49. const { cacheSize, idlUtils } = opt;
  50. this.#window = window;
  51. this.#document = document ?? window.document;
  52. this.#finder = new Finder(window);
  53. this.#idlUtils = idlUtils;
  54. this.#nwsapi = initNwsapi(window, document);
  55. this.#cache = new GenerationalCache(cacheSize ?? CACHE_SIZE);
  56. }
  57. /**
  58. * Clears the internal cache of finder results.
  59. * @returns {void}
  60. */
  61. clear = () => {
  62. this.#finder.clearResults(true);
  63. };
  64. /**
  65. * Parses a selector and extracts the rightmost subject keys (Id, Class, Tag).
  66. * @param {string} selector - The CSS selector to parse.
  67. * @returns {Array<{id: string|null, className: string|null, tag: string|null}>} The list of extracted keys for each selector group.
  68. */
  69. extractSubjects = selector => {
  70. if (!selector || typeof selector !== 'string') {
  71. return [{ id: null, className: null, tag: null }];
  72. }
  73. const cacheKey = `extract_${selector}`;
  74. let subjects = this.#cache.get(cacheKey);
  75. if (subjects !== undefined) {
  76. return subjects;
  77. }
  78. subjects = [];
  79. try {
  80. const ast = this.#finder.getAST(selector);
  81. if (ast?.type === 'SelectorList') {
  82. for (const selectorNode of ast.children) {
  83. let idKey = null;
  84. let classKey = null;
  85. let tagKey = null;
  86. let current = selectorNode.children.tail;
  87. while (current) {
  88. const node = current.data;
  89. if (node.type === COMBINATOR) {
  90. break;
  91. }
  92. if (node.type === ID_SELECTOR) {
  93. idKey = idKey ?? unescapeSelector(node.name);
  94. } else if (node.type === CLASS_SELECTOR) {
  95. classKey = classKey ?? unescapeSelector(node.name);
  96. } else if (node.type === TYPE_SELECTOR) {
  97. const { localName } = parseAstName(unescapeSelector(node.name));
  98. if (localName !== '*') {
  99. tagKey = tagKey ?? localName.toLowerCase();
  100. }
  101. }
  102. current = current.prev;
  103. }
  104. subjects.push({ id: idKey, className: classKey, tag: tagKey });
  105. }
  106. }
  107. } catch (e) {
  108. // fall through
  109. }
  110. if (!subjects.length) {
  111. subjects.push({ id: null, className: null, tag: null });
  112. }
  113. this.#cache.set(cacheKey, subjects);
  114. return subjects;
  115. };
  116. /**
  117. * Checks if an element matches a CSS selector.
  118. * @param {string} selector - The CSS selector to check against.
  119. * @param {Element} node - The element node to check.
  120. * @param {object} [opt] - Optional parameters.
  121. * @returns {CheckResult} An object containing the check result.
  122. */
  123. check = (selector, node, opt = {}) => {
  124. if (!node?.nodeType) {
  125. const e = new this.#window.TypeError(`Unexpected type ${getType(node)}`);
  126. return this.#finder.onError(e, opt);
  127. } else if (node.nodeType !== ELEMENT_NODE) {
  128. const e = new this.#window.TypeError(`Unexpected node ${node.nodeName}`);
  129. return this.#finder.onError(e, opt);
  130. }
  131. const document = node.ownerDocument;
  132. if (
  133. document === this.#document &&
  134. document.contentType === 'text/html' &&
  135. document.documentElement &&
  136. node.parentNode
  137. ) {
  138. const cacheKey = `check_${selector}`;
  139. let filterMatches = this.#cache.get(cacheKey);
  140. if (filterMatches === undefined) {
  141. filterMatches = filterSelector(selector, TARGET_SELF);
  142. this.#cache.set(cacheKey, filterMatches);
  143. }
  144. if (filterMatches) {
  145. try {
  146. const n = this.#idlUtils ? this.#idlUtils.wrapperForImpl(node) : node;
  147. const match = this.#nwsapi.match(selector, n);
  148. let ast = null;
  149. if (match) {
  150. const astCacheKey = `check_ast_${selector}`;
  151. ast = this.#cache.get(astCacheKey);
  152. if (ast === undefined) {
  153. ast = this.#finder.getAST(selector);
  154. this.#cache.set(astCacheKey, ast);
  155. }
  156. }
  157. return {
  158. match,
  159. ast,
  160. pseudoElement: null
  161. };
  162. } catch (e) {
  163. // fall through
  164. }
  165. }
  166. }
  167. if (this.#idlUtils) {
  168. node = this.#idlUtils.wrapperForImpl(node);
  169. }
  170. opt.check = true;
  171. opt.noexcept = true;
  172. opt.warn = false;
  173. return this.#finder.setup(selector, node, opt).find(TARGET_SELF);
  174. };
  175. /**
  176. * Returns true if the element matches the selector.
  177. * @param {string} selector - The CSS selector to match against.
  178. * @param {Element} node - The element node to test.
  179. * @param {object} [opt] - Optional parameters.
  180. * @returns {boolean} `true` if the element matches, or `false` otherwise.
  181. */
  182. matches = (selector, node, opt = {}) => {
  183. if (!node?.nodeType) {
  184. const e = new this.#window.TypeError(`Unexpected type ${getType(node)}`);
  185. return this.#finder.onError(e, opt);
  186. } else if (node.nodeType !== ELEMENT_NODE) {
  187. const e = new this.#window.TypeError(`Unexpected node ${node.nodeName}`);
  188. return this.#finder.onError(e, opt);
  189. }
  190. const document = node.ownerDocument;
  191. if (
  192. document === this.#document &&
  193. document.contentType === 'text/html' &&
  194. document.documentElement &&
  195. node.parentNode
  196. ) {
  197. const cacheKey = `matches_${selector}`;
  198. let filterMatches = this.#cache.get(cacheKey);
  199. if (filterMatches === undefined) {
  200. filterMatches = filterSelector(selector, TARGET_SELF);
  201. this.#cache.set(cacheKey, filterMatches);
  202. }
  203. if (filterMatches) {
  204. try {
  205. const n = this.#idlUtils ? this.#idlUtils.wrapperForImpl(node) : node;
  206. return this.#nwsapi.match(selector, n);
  207. } catch (e) {
  208. // fall through
  209. }
  210. }
  211. }
  212. let res;
  213. try {
  214. if (this.#idlUtils) {
  215. node = this.#idlUtils.wrapperForImpl(node);
  216. }
  217. const nodes = this.#finder.setup(selector, node, opt).find(TARGET_SELF);
  218. res = nodes.size;
  219. } catch (e) {
  220. this.#finder.onError(e, opt);
  221. }
  222. return !!res;
  223. };
  224. /**
  225. * Traverses up the DOM tree to find the first node that matches the selector.
  226. * @param {string} selector - The CSS selector to match against.
  227. * @param {Element} node - The element from which to start traversing.
  228. * @param {object} [opt] - Optional parameters.
  229. * @returns {?Element} The first matching ancestor element, or `null`.
  230. */
  231. closest = (selector, node, opt = {}) => {
  232. if (!node?.nodeType) {
  233. const e = new this.#window.TypeError(`Unexpected type ${getType(node)}`);
  234. return this.#finder.onError(e, opt);
  235. } else if (node.nodeType !== ELEMENT_NODE) {
  236. const e = new this.#window.TypeError(`Unexpected node ${node.nodeName}`);
  237. return this.#finder.onError(e, opt);
  238. }
  239. const document = node.ownerDocument;
  240. if (
  241. document === this.#document &&
  242. document.contentType === 'text/html' &&
  243. document.documentElement &&
  244. node.parentNode
  245. ) {
  246. const cacheKey = `closest_${selector}`;
  247. let filterMatches = this.#cache.get(cacheKey);
  248. if (filterMatches === undefined) {
  249. filterMatches = filterSelector(selector, TARGET_LINEAL);
  250. this.#cache.set(cacheKey, filterMatches);
  251. }
  252. if (filterMatches) {
  253. try {
  254. const n = this.#idlUtils ? this.#idlUtils.wrapperForImpl(node) : node;
  255. return this.#nwsapi.closest(selector, n);
  256. } catch (e) {
  257. // fall through
  258. }
  259. }
  260. }
  261. let res;
  262. try {
  263. if (this.#idlUtils) {
  264. node = this.#idlUtils.wrapperForImpl(node);
  265. }
  266. const nodes = this.#finder.setup(selector, node, opt).find(TARGET_LINEAL);
  267. if (nodes.size) {
  268. let refNode = node;
  269. while (refNode) {
  270. if (nodes.has(refNode)) {
  271. res = refNode;
  272. break;
  273. }
  274. refNode = refNode.parentNode;
  275. }
  276. }
  277. } catch (e) {
  278. this.#finder.onError(e, opt);
  279. }
  280. return res ?? null;
  281. };
  282. /**
  283. * Returns the first element within the subtree that matches the selector.
  284. * @param {string} selector - The CSS selector to match.
  285. * @param {Document|DocumentFragment|Element} node - The node to find within.
  286. * @param {object} [opt] - Optional parameters.
  287. * @returns {?Element} The first matching element, or `null`.
  288. */
  289. querySelector = (selector, node, opt = {}) => {
  290. if (!node?.nodeType) {
  291. const e = new this.#window.TypeError(`Unexpected type ${getType(node)}`);
  292. return this.#finder.onError(e, opt);
  293. }
  294. const document =
  295. node.nodeType === DOCUMENT_NODE ? node : node.ownerDocument;
  296. if (
  297. document === this.#document &&
  298. document.contentType === 'text/html' &&
  299. document.documentElement &&
  300. (node.nodeType !== DOCUMENT_FRAGMENT_NODE || !node.host)
  301. ) {
  302. const cacheKey = `querySelector_${selector}`;
  303. let filterMatches = this.#cache.get(cacheKey);
  304. if (filterMatches === undefined) {
  305. filterMatches = filterSelector(selector, TARGET_FIRST);
  306. this.#cache.set(cacheKey, filterMatches);
  307. }
  308. if (filterMatches) {
  309. try {
  310. const n = this.#idlUtils ? this.#idlUtils.wrapperForImpl(node) : node;
  311. return this.#nwsapi.first(selector, n);
  312. } catch (e) {
  313. // fall through
  314. }
  315. }
  316. }
  317. let res;
  318. try {
  319. if (this.#idlUtils) {
  320. node = this.#idlUtils.wrapperForImpl(node);
  321. }
  322. const nodes = this.#finder.setup(selector, node, opt).find(TARGET_FIRST);
  323. if (nodes.size) {
  324. [res] = [...nodes];
  325. }
  326. } catch (e) {
  327. this.#finder.onError(e, opt);
  328. }
  329. return res ?? null;
  330. };
  331. /**
  332. * Returns an array of elements within the subtree that match the selector.
  333. * Note: This method returns an Array, not a NodeList.
  334. * @param {string} selector - The CSS selector to match.
  335. * @param {Document|DocumentFragment|Element} node - The node to find within.
  336. * @param {object} [opt] - Optional parameters.
  337. * @returns {Array<Element>} An array of elements, or an empty array.
  338. */
  339. querySelectorAll = (selector, node, opt = {}) => {
  340. if (!node?.nodeType) {
  341. const e = new this.#window.TypeError(`Unexpected type ${getType(node)}`);
  342. return this.#finder.onError(e, opt);
  343. }
  344. const document =
  345. node.nodeType === DOCUMENT_NODE ? node : node.ownerDocument;
  346. if (
  347. document === this.#document &&
  348. document.contentType === 'text/html' &&
  349. document.documentElement &&
  350. (node.nodeType !== DOCUMENT_FRAGMENT_NODE || !node.host)
  351. ) {
  352. const cacheKey = `querySelectorAll_${selector}`;
  353. let filterMatches = this.#cache.get(cacheKey);
  354. if (filterMatches === undefined) {
  355. filterMatches = filterSelector(selector, TARGET_ALL);
  356. this.#cache.set(cacheKey, filterMatches);
  357. }
  358. if (filterMatches) {
  359. try {
  360. const n = this.#idlUtils ? this.#idlUtils.wrapperForImpl(node) : node;
  361. return this.#nwsapi.select(selector, n);
  362. } catch (e) {
  363. // fall through
  364. }
  365. }
  366. }
  367. let res;
  368. try {
  369. if (this.#idlUtils) {
  370. node = this.#idlUtils.wrapperForImpl(node);
  371. }
  372. const nodes = this.#finder.setup(selector, node, opt).find(TARGET_ALL);
  373. if (nodes.size) {
  374. res = [...nodes];
  375. }
  376. } catch (e) {
  377. this.#finder.onError(e, opt);
  378. }
  379. return res ?? [];
  380. };
  381. }