A javascript library that allows using extended CSS selectors (:has, :contains, etc)
สคริปต์นี้ไม่ควรถูกติดตั้งโดยตรง มันเป็นคลังสำหรับสคริปต์อื่น ๆ เพื่อบรรจุด้วยคำสั่งเมทา // @require https://update.greasyfork.org/scripts/452263/1135232/extended-css.js
// ==UserScript== // @name extended-css // @name:zh-CN extended-css // @version 2.0.36 // @namespace https://adguard.com/ // @author AdguardTeam // @contributor AdguardTeam // @contributors AdguardTeam // @developer AdguardTeam // @copyright GPL-3.0 // @license GPL-3.0 // @description A javascript library that allows using extended CSS selectors (:has, :contains, etc) // @description:zh 一个让用户可以使用扩展 CSS 选择器的库 // @description:zh-CN 一个让用户可以使用扩展 CSS 选择器的库 // @description:zh_CN 一个让用户可以使用扩展 CSS 选择器的库 // @homepage https://github.com/AdguardTeam/ExtendedCss // @homepageURL https://github.com/AdguardTeam/ExtendedCss // ==/UserScript== /** * @adguard/extended-css - v2.0.36 - Thu Jan 05 2023 * https://github.com/AdguardTeam/ExtendedCss#homepage * Copyright (c) 2023 AdGuard. Licensed GPL-3.0 */ var ExtendedCss = (function () { 'use strict'; function _typeof(obj) { "@babel/helpers - typeof"; return _typeof = "function" == typeof Symbol && "symbol" == typeof Symbol.iterator ? function (obj) { return typeof obj; } : function (obj) { return obj && "function" == typeof Symbol && obj.constructor === Symbol && obj !== Symbol.prototype ? "symbol" : typeof obj; }, _typeof(obj); } function _toPrimitive(input, hint) { if (_typeof(input) !== "object" || input === null) return input; var prim = input[Symbol.toPrimitive]; if (prim !== undefined) { var res = prim.call(input, hint || "default"); if (_typeof(res) !== "object") return res; throw new TypeError("@@toPrimitive must return a primitive value."); } return (hint === "string" ? String : Number)(input); } function _toPropertyKey(arg) { var key = _toPrimitive(arg, "string"); return _typeof(key) === "symbol" ? key : String(key); } function _defineProperty(obj, key, value) { key = _toPropertyKey(key); if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; } let NodeType; /** * Universal interface for all node types. */ (function (NodeType) { NodeType["SelectorList"] = "SelectorList"; NodeType["Selector"] = "Selector"; NodeType["RegularSelector"] = "RegularSelector"; NodeType["ExtendedSelector"] = "ExtendedSelector"; NodeType["AbsolutePseudoClass"] = "AbsolutePseudoClass"; NodeType["RelativePseudoClass"] = "RelativePseudoClass"; })(NodeType || (NodeType = {})); /** * Class needed for creating ast nodes while selector parsing. * Used for SelectorList, Selector, ExtendedSelector. */ class AnySelectorNode { /** * Creates new ast node. * * @param type Ast node type. */ constructor(type) { _defineProperty(this, "children", []); this.type = type; } /** * Adds child node to children array. * * @param child Ast node. */ addChild(child) { this.children.push(child); } } /** * Class needed for creating RegularSelector ast node while selector parsing. */ class RegularSelectorNode extends AnySelectorNode { /** * Creates RegularSelector ast node. * * @param value Value of RegularSelector node. */ constructor(value) { super(NodeType.RegularSelector); this.value = value; } } /** * Class needed for creating RelativePseudoClass ast node while selector parsing. */ class RelativePseudoClassNode extends AnySelectorNode { /** * Creates RegularSelector ast node. * * @param name Name of RelativePseudoClass node. */ constructor(name) { super(NodeType.RelativePseudoClass); this.name = name; } } /** * Class needed for creating AbsolutePseudoClass ast node while selector parsing. */ class AbsolutePseudoClassNode extends AnySelectorNode { /** * Creates AbsolutePseudoClass ast node. * * @param name Name of AbsolutePseudoClass node. */ constructor(name) { super(NodeType.AbsolutePseudoClass); _defineProperty(this, "value", ''); this.name = name; } } /* eslint-disable jsdoc/require-description-complete-sentence */ /** * Root node. * * SelectorList * : Selector * ... * ; */ /** * Selector node. * * Selector * : RegularSelector * | ExtendedSelector * ... * ; */ /** * Regular selector node. * It can be selected by querySelectorAll(). * * RegularSelector * : type * : value * ; */ /** * Extended selector node. * * ExtendedSelector * : AbsolutePseudoClass * | RelativePseudoClass * ; */ /** * Absolute extended pseudo-class node, * i.e. none-selector args. * * AbsolutePseudoClass * : type * : name * : value * ; */ /** * Relative extended pseudo-class node * i.e. selector as arg. * * RelativePseudoClass * : type * : name * : SelectorList * ; */ // // ast example // // div.banner > div:has(span, p), a img.ad // // SelectorList - div.banner > div:has(span, p), a img.ad // Selector - div.banner > div:has(span, p) // RegularSelector - div.banner > div // ExtendedSelector - :has(span, p) // PseudoClassSelector - :has // SelectorList - span, p // Selector - span // RegularSelector - span // Selector - p // RegularSelector - p // Selector - a img.ad // RegularSelector - a img.ad // const LEFT_SQUARE_BRACKET = '['; const RIGHT_SQUARE_BRACKET = ']'; const LEFT_PARENTHESIS = '('; const RIGHT_PARENTHESIS = ')'; const LEFT_CURLY_BRACKET = '{'; const RIGHT_CURLY_BRACKET = '}'; const BRACKETS = { SQUARE: { LEFT: LEFT_SQUARE_BRACKET, RIGHT: RIGHT_SQUARE_BRACKET }, PARENTHESES: { LEFT: LEFT_PARENTHESIS, RIGHT: RIGHT_PARENTHESIS }, CURLY: { LEFT: LEFT_CURLY_BRACKET, RIGHT: RIGHT_CURLY_BRACKET } }; const SLASH = '/'; const BACKSLASH = '\\'; const SPACE = ' '; const COMMA = ','; const DOT = '.'; const SEMICOLON = ';'; const COLON = ':'; const SINGLE_QUOTE = '\''; const DOUBLE_QUOTE = '"'; // do not consider hyphen `-` as separated mark // to avoid pseudo-class names splitting // e.g. 'matches-css' or 'if-not' const CARET = '^'; const DOLLAR_SIGN = '$'; const EQUAL_SIGN = '='; const TAB = '\t'; const CARRIAGE_RETURN = '\r'; const LINE_FEED = '\n'; const FORM_FEED = '\f'; const WHITE_SPACE_CHARACTERS = [SPACE, TAB, CARRIAGE_RETURN, LINE_FEED, FORM_FEED]; // for universal selector and attributes const ASTERISK = '*'; const ID_MARKER = '#'; const CLASS_MARKER = DOT; const DESCENDANT_COMBINATOR = SPACE; const CHILD_COMBINATOR = '>'; const NEXT_SIBLING_COMBINATOR = '+'; const SUBSEQUENT_SIBLING_COMBINATOR = '~'; const COMBINATORS = [DESCENDANT_COMBINATOR, CHILD_COMBINATOR, NEXT_SIBLING_COMBINATOR, SUBSEQUENT_SIBLING_COMBINATOR]; const SUPPORTED_SELECTOR_MARKS = [LEFT_SQUARE_BRACKET, RIGHT_SQUARE_BRACKET, LEFT_PARENTHESIS, RIGHT_PARENTHESIS, LEFT_CURLY_BRACKET, RIGHT_CURLY_BRACKET, SLASH, BACKSLASH, SEMICOLON, COLON, COMMA, SINGLE_QUOTE, DOUBLE_QUOTE, CARET, DOLLAR_SIGN, ASTERISK, ID_MARKER, CLASS_MARKER, DESCENDANT_COMBINATOR, CHILD_COMBINATOR, NEXT_SIBLING_COMBINATOR, SUBSEQUENT_SIBLING_COMBINATOR, TAB, CARRIAGE_RETURN, LINE_FEED, FORM_FEED]; // absolute: const CONTAINS_PSEUDO = 'contains'; const HAS_TEXT_PSEUDO = 'has-text'; const ABP_CONTAINS_PSEUDO = '-abp-contains'; const MATCHES_CSS_PSEUDO = 'matches-css'; const MATCHES_CSS_BEFORE_PSEUDO = 'matches-css-before'; const MATCHES_CSS_AFTER_PSEUDO = 'matches-css-after'; const MATCHES_ATTR_PSEUDO_CLASS_MARKER = 'matches-attr'; const MATCHES_PROPERTY_PSEUDO_CLASS_MARKER = 'matches-property'; const XPATH_PSEUDO_CLASS_MARKER = 'xpath'; const NTH_ANCESTOR_PSEUDO_CLASS_MARKER = 'nth-ancestor'; const CONTAINS_PSEUDO_NAMES = [CONTAINS_PSEUDO, HAS_TEXT_PSEUDO, ABP_CONTAINS_PSEUDO]; /** * Pseudo-class :upward() can get number or selector arg * and if the arg is selector it should be standard, not extended * so :upward pseudo-class is always absolute. */ const UPWARD_PSEUDO_CLASS_MARKER = 'upward'; /** * Pseudo-class `:remove()` and pseudo-property `remove` * are used for element actions, not for element selecting. * * Selector text should not contain the pseudo-class * so selector parser should consider it as invalid * and both are handled by stylesheet parser. */ const REMOVE_PSEUDO_MARKER = 'remove'; // relative: const HAS_PSEUDO_CLASS_MARKER = 'has'; const ABP_HAS_PSEUDO_CLASS_MARKER = '-abp-has'; const HAS_PSEUDO_CLASS_MARKERS = [HAS_PSEUDO_CLASS_MARKER, ABP_HAS_PSEUDO_CLASS_MARKER]; const IS_PSEUDO_CLASS_MARKER = 'is'; const NOT_PSEUDO_CLASS_MARKER = 'not'; const ABSOLUTE_PSEUDO_CLASSES = [CONTAINS_PSEUDO, HAS_TEXT_PSEUDO, ABP_CONTAINS_PSEUDO, MATCHES_CSS_PSEUDO, MATCHES_CSS_BEFORE_PSEUDO, MATCHES_CSS_AFTER_PSEUDO, MATCHES_ATTR_PSEUDO_CLASS_MARKER, MATCHES_PROPERTY_PSEUDO_CLASS_MARKER, XPATH_PSEUDO_CLASS_MARKER, NTH_ANCESTOR_PSEUDO_CLASS_MARKER, UPWARD_PSEUDO_CLASS_MARKER]; const RELATIVE_PSEUDO_CLASSES = [...HAS_PSEUDO_CLASS_MARKERS, IS_PSEUDO_CLASS_MARKER, NOT_PSEUDO_CLASS_MARKER]; const SUPPORTED_PSEUDO_CLASSES = [...ABSOLUTE_PSEUDO_CLASSES, ...RELATIVE_PSEUDO_CLASSES]; // these pseudo-classes should be part of RegularSelector value // if its arg does not contain extended selectors. // the ast will be checked after the selector is completely parsed const OPTIMIZATION_PSEUDO_CLASSES = [NOT_PSEUDO_CLASS_MARKER, IS_PSEUDO_CLASS_MARKER]; /** * ':scope' is used for extended pseudo-class :has(), if-not(), :is() and :not(). */ const SCOPE_CSS_PSEUDO_CLASS = ':scope'; /** * ':after' and ':before' are needed for :matches-css() pseudo-class * all other are needed for :has() limitation after regular pseudo-elements. * * @see {@link https://bugs.chromium.org/p/chromium/issues/detail?id=669058#c54} [case 3] */ const REGULAR_PSEUDO_ELEMENTS = { AFTER: 'after', BACKDROP: 'backdrop', BEFORE: 'before', CUE: 'cue', CUE_REGION: 'cue-region', FIRST_LETTER: 'first-letter', FIRST_LINE: 'first-line', FILE_SELECTION_BUTTON: 'file-selector-button', GRAMMAR_ERROR: 'grammar-error', MARKER: 'marker', PART: 'part', PLACEHOLDER: 'placeholder', SELECTION: 'selection', SLOTTED: 'slotted', SPELLING_ERROR: 'spelling-error', TARGET_TEXT: 'target-text' }; const CONTENT_CSS_PROPERTY = 'content'; const PSEUDO_PROPERTY_POSITIVE_VALUE = 'true'; const DEBUG_PSEUDO_PROPERTY_GLOBAL_VALUE = 'global'; const NO_SELECTOR_ERROR_PREFIX = 'Selector should be defined before'; const STYLESHEET_ERROR_PREFIX = { NO_STYLE: 'No style declaration at stylesheet part', NO_SELECTOR: `${NO_SELECTOR_ERROR_PREFIX} style declaration in stylesheet`, INVALID_STYLE: 'Invalid style declaration at stylesheet part', UNCLOSED_STYLE: 'Unclosed style declaration at stylesheet part', NO_PROPERTY: 'Missing style property in declaration at stylesheet part', NO_VALUE: 'Missing style value in declaration at stylesheet part', NO_STYLE_OR_REMOVE: 'Invalid stylesheet - no style declared or :remove() pseudo-class used', NO_COMMENT: 'Comments in stylesheet are not supported' }; const REMOVE_ERROR_PREFIX = { INVALID_REMOVE: 'Invalid :remove() pseudo-class in selector', NO_TARGET_SELECTOR: `${NO_SELECTOR_ERROR_PREFIX} :remove() pseudo-class`, MULTIPLE_USAGE: 'Pseudo-class :remove() appears more than once in selector', INVALID_POSITION: 'Pseudo-class :remove() should be at the end of selector' }; const MATCHING_ELEMENT_ERROR_PREFIX = 'Error while matching element'; const MAX_STYLE_PROTECTION_COUNT = 50; /** * Regexp that matches backward compatible syntaxes. */ const REGEXP_VALID_OLD_SYNTAX = /\[-(?:ext)-([a-z-_]+)=(["'])((?:(?=(\\?))\4.)*?)\2\]/g; /** * Marker for checking invalid selector after old-syntax normalizing by selector converter. */ const INVALID_OLD_SYNTAX_MARKER = '[-ext-'; /** * Complex replacement function. * Undo quote escaping inside of an extended selector. * * @param match Whole matched string. * @param name Group 1. * @param quoteChar Group 2. * @param rawValue Group 3. * * @returns Converted string. */ const evaluateMatch = (match, name, quoteChar, rawValue) => { // Unescape quotes const re = new RegExp(`([^\\\\]|^)\\\\${quoteChar}`, 'g'); const value = rawValue.replace(re, `$1${quoteChar}`); return `:${name}(${value})`; }; // ':scope' pseudo may be at start of :has() argument // but ExtCssDocument.querySelectorAll() already use it for selecting exact element descendants const reScope = /\(:scope >/g; const SCOPE_REPLACER = '(>'; const MATCHES_CSS_PSEUDO_ELEMENT_REGEXP = /(:matches-css)-(before|after)\(/g; const convertMatchesCss = (match, extendedPseudoClass, regularPseudoElement) => { // ':matches-css-before(' --> ':matches-css(before, ' // ':matches-css-after(' --> ':matches-css(after, ' return `${extendedPseudoClass}${BRACKETS.PARENTHESES.LEFT}${regularPseudoElement}${COMMA}`; }; /** * Handles old syntax and :scope inside :has(). * * @param selector Trimmed selector to normalize. * * @returns Normalized selector. * @throws An error on invalid old extended syntax selector. */ const normalize = selector => { const normalizedSelector = selector.replace(REGEXP_VALID_OLD_SYNTAX, evaluateMatch).replace(reScope, SCOPE_REPLACER).replace(MATCHES_CSS_PSEUDO_ELEMENT_REGEXP, convertMatchesCss); // validate old syntax after normalizing // e.g. '[-ext-matches-css-before=\'content: /^[A-Z][a-z]' if (normalizedSelector.includes(INVALID_OLD_SYNTAX_MARKER)) { throw new Error(`Invalid extended-css old syntax selector: '${selector}'`); } return normalizedSelector; }; /** * Prepares the rawSelector before tokenization: * 1. Trims it. * 2. Converts old syntax `[-ext-pseudo-class="..."]` to new one `:pseudo-class(...)`. * 3. Handles :scope pseudo inside :has() pseudo-class arg. * * @param rawSelector Selector with no style declaration. * @returns Prepared selector with no style declaration. */ const convert = rawSelector => { const trimmedSelector = rawSelector.trim(); return normalize(trimmedSelector); }; let TokenType; (function (TokenType) { TokenType["Mark"] = "mark"; TokenType["Word"] = "word"; })(TokenType || (TokenType = {})); /** * Splits `input` string into tokens. * * @param input Input string to tokenize. * @param supportedMarks Array of supported marks to considered as `TokenType.Mark`; * all other will be considered as `TokenType.Word`. * * @returns Array of tokens. */ const tokenize = (input, supportedMarks) => { // buffer is needed for words collecting while iterating let buffer = ''; // r###lt collection const tokens = []; const selectorSymbols = input.split(''); // iterate through selector chars and collect tokens selectorSymbols.forEach((symbol, i) => { if (supportedMarks.includes(symbol)) { tokens.push({ type: TokenType.Mark, value: symbol }); return; } buffer += symbol; const nextSymbol = selectorSymbols[i + 1]; // string end has been reached if nextSymbol is undefined if (!nextSymbol || supportedMarks.includes(nextSymbol)) { tokens.push({ type: TokenType.Word, value: buffer }); buffer = ''; } }); return tokens; }; /** * Prepares `rawSelector` and splits it into tokens. * * @param rawSelector Raw css selector. * * @returns Array of tokens supported for selector. */ const tokenizeSelector = rawSelector => { const selector = convert(rawSelector); return tokenize(selector, SUPPORTED_SELECTOR_MARKS); }; /** * Splits `attribute` into tokens. * * @param attribute Input attribute. * * @returns Array of tokens supported for attribute. */ const tokenizeAttribute = attribute => { // equal sigh `=` in attribute is considered as `TokenType.Mark` return tokenize(attribute, [...SUPPORTED_SELECTOR_MARKS, EQUAL_SIGN]); }; /** * Some browsers do not support Array.prototype.flat() * e.g. Opera 42 which is used for browserstack tests. * * @see {@link https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/flat} * * @param input Array needed to be flatten. * * @returns Flatten array. * @throws An error if array cannot be flatten. */ const flatten = input => { const stack = []; input.forEach(el => stack.push(el)); const res = []; while (stack.length) { // pop value from stack const next = stack.pop(); if (!next) { throw new Error('Unable to make array flat'); } if (Array.isArray(next)) { // push back array items, won't modify the original input next.forEach(el => stack.push(el)); } else { res.push(next); } } // reverse to restore input order return res.reverse(); }; /** * Returns first item from `array`. * * @param array Input array. * * @returns First array item, or `undefined` if there is no such item. */ const getFirst = array => { return array[0]; }; /** * Returns last item from array. * * @param array Input array. * * @returns Last array item, or `undefined` if there is no such item. */ const getLast = array => { return array[array.length - 1]; }; /** * Returns array item which is previous to the last one * e.g. for `[5, 6, 7, 8]` returns `7`. * * @param array Input array. * * @returns Previous to last array item, or `undefined` if there is no such item. */ const getPrevToLast = array => { return array[array.length - 2]; }; /** * Takes array of ast node `children` and returns the child by the `index`. * * @param array Array of ast node children. * @param index Index of needed child in the array. * @param errorMessage Optional error message to throw. * * @returns Array item at `index` position. * @throws An error if there is no child with specified `index` in array. */ const getItemByIndex = (array, index, errorMessage) => { const indexChild = array[index]; if (!indexChild) { throw new Error(errorMessage || `No array item found by index ${index}`); } return indexChild; }; const NO_REGULAR_SELECTOR_ERROR = 'At least one of Selector node children should be RegularSelector'; /** * Checks whether the type of `astNode` is SelectorList. * * @param astNode Ast node. * * @returns True if astNode.type === SelectorList. */ const isSelectorListNode = astNode => { return (astNode === null || astNode === void 0 ? void 0 : astNode.type) === NodeType.SelectorList; }; /** * Checks whether the type of `astNode` is Selector. * * @param astNode Ast node. * * @returns True if astNode.type === Selector. */ const isSelectorNode = astNode => { return (astNode === null || astNode === void 0 ? void 0 : astNode.type) === NodeType.Selector; }; /** * Checks whether the type of `astNode` is RegularSelector. * * @param astNode Ast node. * * @returns True if astNode.type === RegularSelector. */ const isRegularSelectorNode = astNode => { return (astNode === null || astNode === void 0 ? void 0 : astNode.type) === NodeType.RegularSelector; }; /** * Checks whether the type of `astNode` is ExtendedSelector. * * @param astNode Ast node. * * @returns True if astNode.type === ExtendedSelector. */ const isExtendedSelectorNode = astNode => { return astNode.type === NodeType.ExtendedSelector; }; /** * Checks whether the type of `astNode` is AbsolutePseudoClass. * * @param astNode Ast node. * * @returns True if astNode.type === AbsolutePseudoClass. */ const isAbsolutePseudoClassNode = astNode => { return (astNode === null || astNode === void 0 ? void 0 : astNode.type) === NodeType.AbsolutePseudoClass; }; /** * Checks whether the type of `astNode` is RelativePseudoClass. * * @param astNode Ast node. * * @returns True if astNode.type === RelativePseudoClass. */ const isRelativePseudoClassNode = astNode => { return (astNode === null || astNode === void 0 ? void 0 : astNode.type) === NodeType.RelativePseudoClass; }; /** * Returns name of `astNode`. * * @param astNode AbsolutePseudoClass or RelativePseudoClass node. * * @returns Name of `astNode`. * @throws An error on unsupported ast node or no name found. */ const getNodeName = astNode => { if (astNode === null) { throw new Error('Ast node should be defined'); } if (!isAbsolutePseudoClassNode(astNode) && !isRelativePseudoClassNode(astNode)) { throw new Error('Only AbsolutePseudoClass or RelativePseudoClass ast node can have a name'); } if (!astNode.name) { throw new Error('Extended pseudo-class should have a name'); } return astNode.name; }; /** * Returns value of `astNode`. * * @param astNode RegularSelector or AbsolutePseudoClass node. * @param errorMessage Optional error message if no value found. * * @returns Value of `astNode`. * @throws An error on unsupported ast node or no value found. */ const getNodeValue = (astNode, errorMessage) => { if (astNode === null) { throw new Error('Ast node should be defined'); } if (!isRegularSelectorNode(astNode) && !isAbsolutePseudoClassNode(astNode)) { throw new Error('Only RegularSelector ot AbsolutePseudoClass ast node can have a value'); } if (!astNode.value) { throw new Error(errorMessage || 'Ast RegularSelector ot AbsolutePseudoClass node should have a value'); } return astNode.value; }; /** * Returns only RegularSelector nodes from `children`. * * @param children Array of ast node children. * * @returns Array of RegularSelector nodes. */ const getRegularSelectorNodes = children => { return children.filter(isRegularSelectorNode); }; /** * Returns the first RegularSelector node from `children`. * * @param children Array of ast node children. * @param errorMessage Optional error message if no value found. * * @returns Ast RegularSelector node. * @throws An error if no RegularSelector node found. */ const getFirstRegularChild = (children, errorMessage) => { const regularSelectorNodes = getRegularSelectorNodes(children); const firstRegularSelectorNode = getFirst(regularSelectorNodes); if (!firstRegularSelectorNode) { throw new Error(errorMessage || NO_REGULAR_SELECTOR_ERROR); } return firstRegularSelectorNode; }; /** * Returns the last RegularSelector node from `children`. * * @param children Array of ast node children. * * @returns Ast RegularSelector node. * @throws An error if no RegularSelector node found. */ const getLastRegularChild = children => { const regularSelectorNodes = getRegularSelectorNodes(children); const lastRegularSelectorNode = getLast(regularSelectorNodes); if (!lastRegularSelectorNode) { throw new Error(NO_REGULAR_SELECTOR_ERROR); } return lastRegularSelectorNode; }; /** * Returns the only child of `node`. * * @param node Ast node. * @param errorMessage Error message. * * @returns The only child of ast node. * @throws An error if none or more than one child found. */ const getNodeOnlyChild = (node, errorMessage) => { if (node.children.length !== 1) { throw new Error(errorMessage); } const onlyChild = getFirst(node.children); if (!onlyChild) { throw new Error(errorMessage); } return onlyChild; }; /** * Takes ExtendedSelector node and returns its only child. * * @param extendedSelectorNode ExtendedSelector ast node. * * @returns AbsolutePseudoClass or RelativePseudoClass. * @throws An error if there is no specific pseudo-class ast node. */ const getPseudoClassNode = extendedSelectorNode => { return getNodeOnlyChild(extendedSelectorNode, 'Extended selector should be specified'); }; /** * Takes RelativePseudoClass node and returns its only child * which is relative SelectorList node. * * @param pseudoClassNode RelativePseudoClass. * * @returns Relative SelectorList node. * @throws An error if no selector list found. */ const getRelativeSelectorListNode = pseudoClassNode => { if (!isRelativePseudoClassNode(pseudoClassNode)) { throw new Error('Only RelativePseudoClass node can have relative SelectorList node as child'); } return getNodeOnlyChild(pseudoClassNode, `Missing arg for :${getNodeName(pseudoClassNode)}() pseudo-class`); }; const ATTRIBUTE_CASE_INSENSITIVE_FLAG = 'i'; /** * Limited list of available symbols before slash `/` * to check whether it is valid regexp pattern opening. */ const POSSIBLE_MARKS_BEFORE_REGEXP = { COMMON: [ // e.g. ':matches-attr(/data-/)' BRACKETS.PARENTHESES.LEFT, // e.g. `:matches-attr('/data-/')` SINGLE_QUOTE, // e.g. ':matches-attr("/data-/")' DOUBLE_QUOTE, // e.g. ':matches-attr(check=/data-v-/)' EQUAL_SIGN, // e.g. ':matches-property(inner./_test/=null)' DOT, // e.g. ':matches-css(height:/20px/)' COLON, // ':matches-css-after( content : /(\\d+\\s)*me/ )' SPACE], CONTAINS: [ // e.g. ':contains(/text/)' BRACKETS.PARENTHESES.LEFT, // e.g. `:contains('/text/')` SINGLE_QUOTE, // e.g. ':contains("/text/")' DOUBLE_QUOTE] }; /** * Checks whether the passed token is supported extended pseudo-class. * * @param tokenValue Token value to check. * * @returns True if `tokenValue` is one of supported extended pseudo-class names. */ const isSupportedPseudoClass = tokenValue => { return SUPPORTED_PSEUDO_CLASSES.includes(tokenValue); }; /** * Checks whether the passed pseudo-class `name` should be optimized, * i.e. :not() and :is(). * * @param name Pseudo-class name. * * @returns True if `name` is one if pseudo-class which should be optimized. */ const isOptimizationPseudoClass = name => { return OPTIMIZATION_PSEUDO_CLASSES.includes(name); }; /** * Checks whether next to "space" token is a continuation of regular selector being processed. * * @param nextTokenType Type of token next to current one. * @param nextTokenValue Value of token next to current one. * * @returns True if next token seems to be a part of current regular selector. */ const doesRegularContinueAfterSpace = (nextTokenType, nextTokenValue) => { // regular selector does not continues after the current token if (!nextTokenType || !nextTokenValue) { return false; } return COMBINATORS.includes(nextTokenValue) || nextTokenType === TokenType.Word // e.g. '#main *:has(> .ad)' || nextTokenValue === ASTERISK || nextTokenValue === ID_MARKER || nextTokenValue === CLASS_MARKER // e.g. 'div :where(.content)' || nextTokenValue === COLON // e.g. "div[class*=' ']" || nextTokenValue === SINGLE_QUOTE // e.g. 'div[class*=" "]' || nextTokenValue === DOUBLE_QUOTE || nextTokenValue === BRACKETS.SQUARE.LEFT; }; /** * Checks whether the regexp pattern for pseudo-class arg starts. * Needed for `context.isRegexpOpen` flag. * * @param context Selector parser context. * @param prevTokenValue Value of previous token. * @param bufferNodeValue Value of bufferNode. * * @returns True if current token seems to be a start of regexp pseudo-class arg pattern. * @throws An error on invalid regexp pattern. */ const isRegexpOpening = (context, prevTokenValue, bufferNodeValue) => { const lastExtendedPseudoClassName = getLast(context.extendedPseudoNamesStack); if (!lastExtendedPseudoClassName) { throw new Error('Regexp pattern allowed only in arg of extended pseudo-class'); } // for regexp pattens the slash should not be escaped // const isRegexpPatternSlash = prevTokenValue !== BACKSLASH; // regexp pattern can be set as arg of pseudo-class // which means limited list of available symbols before slash `/`; // for :contains() pseudo-class regexp pattern should be at the beginning of arg if (CONTAINS_PSEUDO_NAMES.includes(lastExtendedPseudoClassName)) { return POSSIBLE_MARKS_BEFORE_REGEXP.CONTAINS.includes(prevTokenValue); } if (prevTokenValue === SLASH && lastExtendedPseudoClassName !== XPATH_PSEUDO_CLASS_MARKER) { const rawArgDesc = bufferNodeValue ? `in arg part: '${bufferNodeValue}'` : 'arg'; throw new Error(`Invalid regexp pattern for :${lastExtendedPseudoClassName}() pseudo-class ${rawArgDesc}`); } // for other pseudo-classes regexp pattern can be either the whole arg or its part return POSSIBLE_MARKS_BEFORE_REGEXP.COMMON.includes(prevTokenValue); }; /** * Checks whether the attribute starts. * * @param tokenValue Value of current token. * @param prevTokenValue Previous token value. * * @returns True if combination of current and previous token seems to be **a start** of attribute. */ const isAttributeOpening = (tokenValue, prevTokenValue) => { return tokenValue === BRACKETS.SQUARE.LEFT && prevTokenValue !== BACKSLASH; }; /** * Checks whether the attribute ends. * * @param context Selector parser context. * * @returns True if combination of current and previous token seems to be **an end** of attribute. * @throws An error on invalid attribute. */ const isAttributeClosing = context => { var _getPrevToLast; if (!context.isAttributeBracketsOpen) { return false; } // valid attributes may have extra spaces inside. // we get rid of them just to simplify the checking and they are skipped only here: // - spaces will be collected to the ast with spaces as they were declared is selector // - extra spaces in attribute are not relevant to attribute syntax validity // e.g. 'a[ title ]' is the same as 'a[title]' // 'div[style *= "MARGIN" i]' is the same as 'div[style*="MARGIN"i]' const noSpaceAttr = context.attributeBuffer.split(SPACE).join(''); // tokenize the prepared attribute string const attrTokens = tokenizeAttribute(noSpaceAttr); const firstAttrToken = getFirst(attrTokens); const firstAttrTokenType = firstAttrToken === null || firstAttrToken === void 0 ? void 0 : firstAttrToken.type; const firstAttrTokenValue = firstAttrToken === null || firstAttrToken === void 0 ? void 0 : firstAttrToken.value; // signal an error on any mark-type token except backslash // e.g. '[="margin"]' if (firstAttrTokenType === TokenType.Mark // backslash is allowed at start of attribute // e.g. '[\\:data-service-slot]' && firstAttrTokenValue !== BACKSLASH) { // eslint-disable-next-line max-len throw new Error(`'[${context.attributeBuffer}]' is not a valid attribute due to '${firstAttrTokenValue}' at start of it`); } const lastAttrToken = getLast(attrTokens); const lastAttrTokenType = lastAttrToken === null || lastAttrToken === void 0 ? void 0 : lastAttrToken.type; const lastAttrTokenValue = lastAttrToken === null || lastAttrToken === void 0 ? void 0 : lastAttrToken.value; if (lastAttrTokenValue === EQUAL_SIGN) { // e.g. '[style=]' throw new Error(`'[${context.attributeBuffer}]' is not a valid attribute due to '${EQUAL_SIGN}'`); } const equalSignIndex = attrTokens.findIndex(token => { return token.type === TokenType.Mark && token.value === EQUAL_SIGN; }); const prevToLastAttrTokenValue = (_getPrevToLast = getPrevToLast(attrTokens)) === null || _getPrevToLast === void 0 ? void 0 : _getPrevToLast.value; if (equalSignIndex === -1) { // if there is no '=' inside attribute, // it must be just attribute name which means the word-type token before closing bracket // e.g. 'div[style]' if (lastAttrTokenType === TokenType.Word) { return true; } return prevToLastAttrTokenValue === BACKSLASH // some weird attribute are valid too // e.g. '[class\\"ads-article\\"]' && (lastAttrTokenValue === DOUBLE_QUOTE // e.g. "[class\\'ads-article\\']" || lastAttrTokenValue === SINGLE_QUOTE); } // get the value of token next to `=` const nextToEqualSignToken = getItemByIndex(attrTokens, equalSignIndex + 1); const nextToEqualSignTokenValue = nextToEqualSignToken.value; // check whether the attribute value wrapper in quotes const isAttrValueQuote = nextToEqualSignTokenValue === SINGLE_QUOTE || nextToEqualSignTokenValue === DOUBLE_QUOTE; // for no quotes after `=` the last token before `]` should be a word-type one // e.g. 'div[style*=margin]' // 'div[style*=MARGIN i]' if (!isAttrValueQuote) { if (lastAttrTokenType === TokenType.Word) { return true; } // otherwise signal an error // e.g. 'table[style*=border: 0px"]' throw new Error(`'[${context.attributeBuffer}]' is not a valid attribute`); } // otherwise if quotes for value are present // the last token before `]` can still be word-type token // e.g. 'div[style*="MARGIN" i]' if (lastAttrTokenType === TokenType.Word && (lastAttrTokenValue === null || lastAttrTokenValue === void 0 ? void 0 : lastAttrTokenValue.toLocaleLowerCase()) === ATTRIBUTE_CASE_INSENSITIVE_FLAG) { return prevToLastAttrTokenValue === nextToEqualSignTokenValue; } // eventually if there is quotes for attribute value and last token is not a word, // the closing mark should be the same quote as opening one return lastAttrTokenValue === nextToEqualSignTokenValue; }; /** * Checks whether the `tokenValue` is a whitespace character. * * @param tokenValue Token value. * * @returns True if `tokenValue` is a whitespace character. */ const isWhiteSpaceChar = tokenValue => { if (!tokenValue) { return false; } return WHITE_SPACE_CHARACTERS.includes(tokenValue); }; /** * Checks whether the passed `str` is a name of supported absolute extended pseudo-class, * e.g. :contains(), :matches-css() etc. * * @param str Token value to check. * * @returns True if `str` is one of absolute extended pseudo-class names. */ const isAbsolutePseudoClass = str => { return ABSOLUTE_PSEUDO_CLASSES.includes(str); }; /** * Checks whether the passed `str` is a name of supported relative extended pseudo-class, * e.g. :has(), :not() etc. * * @param str Token value to check. * * @returns True if `str` is one of relative extended pseudo-class names. */ const isRelativePseudoClass = str => { return RELATIVE_PSEUDO_CLASSES.includes(str); }; /** * Returns the node which is being collected * or null if there is no such one. * * @param context Selector parser context. * * @returns Buffer node or null. */ const getBufferNode = context => { if (context.pathToBufferNode.length === 0) { return null; } // buffer node is always the last in the pathToBufferNode stack return getLast(context.pathToBufferNode) || null; }; /** * Returns the parent node to the 'buffer node' — which is the one being collected — * or null if there is no such one. * * @param context Selector parser context. * * @returns Parent node of buffer node or null. */ const getBufferNodeParent = context => { // at least two nodes should exist — the buffer node and its parent // otherwise return null if (context.pathToBufferNode.length < 2) { return null; } // since the buffer node is always the last in the pathToBufferNode stack // its parent is previous to it in the stack return getPrevToLast(context.pathToBufferNode) || null; }; /** * Returns last RegularSelector ast node. * Needed for parsing of the complex selector with extended pseudo-class inside it. * * @param context Selector parser context. * * @returns Ast RegularSelector node. * @throws An error if: * - bufferNode is absent; * - type of bufferNode is unsupported; * - no RegularSelector in bufferNode. */ const getContextLastRegularSelectorNode = context => { const bufferNode = getBufferNode(context); if (!bufferNode) { throw new Error('No bufferNode found'); } if (!isSelectorNode(bufferNode)) { throw new Error('Unsupported bufferNode type'); } const lastRegularSelectorNode = getLastRegularChild(bufferNode.children); context.pathToBufferNode.push(lastRegularSelectorNode); return lastRegularSelectorNode; }; /** * Updates needed buffer node value while tokens iterating. * For RegularSelector also collects token values to context.attributeBuffer * for proper attribute parsing. * * @param context Selector parser context. * @param tokenValue Value of current token. * * @throws An error if: * - no bufferNode; * - bufferNode.type is not RegularSelector or AbsolutePseudoClass. */ const updateBufferNode = (context, tokenValue) => { const bufferNode = getBufferNode(context); if (bufferNode === null) { throw new Error('No bufferNode to update'); } if (isAbsolutePseudoClassNode(bufferNode)) { bufferNode.value += tokenValue; } else if (isRegularSelectorNode(bufferNode)) { bufferNode.value += tokenValue; if (context.isAttributeBracketsOpen) { context.attributeBuffer += tokenValue; } } else { // eslint-disable-next-line max-len throw new Error(`${bufferNode.type} node cannot be updated. Only RegularSelector and AbsolutePseudoClass are supported`); } }; /** * Adds SelectorList node to context.ast at the start of ast collecting. * * @param context Selector parser context. */ const addSelectorListNode = context => { const selectorListNode = new AnySelectorNode(NodeType.SelectorList); context.ast = selectorListNode; context.pathToBufferNode.push(selectorListNode); }; /** * Adds new node to buffer node children. * New added node will be considered as buffer node after it. * * @param context Selector parser context. * @param type Type of node to add. * @param tokenValue Optional, defaults to `''`, value of processing token. * * @throws An error if no bufferNode. */ const addAstNodeByType = function (context, type) { let tokenValue = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : ''; const bufferNode = getBufferNode(context); if (bufferNode === null) { throw new Error('No buffer node'); } let node; if (type === NodeType.RegularSelector) { node = new RegularSelectorNode(tokenValue); } else if (type === NodeType.AbsolutePseudoClass) { node = new AbsolutePseudoClassNode(tokenValue); } else if (type === NodeType.RelativePseudoClass) { node = new RelativePseudoClassNode(tokenValue); } else { // SelectorList || Selector || ExtendedSelector node = new AnySelectorNode(type); } bufferNode.addChild(node); context.pathToBufferNode.push(node); }; /** * The very beginning of ast collecting. * * @param context Selector parser context. * @param tokenValue Value of regular selector. */ const initAst = (context, tokenValue) => { addSelectorListNode(context); addAstNodeByType(context, NodeType.Selector); // RegularSelector node is always the first child of Selector node addAstNodeByType(context, NodeType.RegularSelector, tokenValue); }; /** * Inits selector list subtree for relative extended pseudo-classes, e.g. :has(), :not(). * * @param context Selector parser context. * @param tokenValue Optional, defaults to `''`, value of inner regular selector. */ const initRelativ###btree = function (context) { let tokenValue = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : ''; addAstNodeByType(context, NodeType.SelectorList); addAstNodeByType(context, NodeType.Selector); addAstNodeByType(context, NodeType.RegularSelector, tokenValue); }; /** * Goes to closest parent specified by type. * Actually updates path to buffer node for proper ast collecting of selectors while parsing. * * @param context Selector parser context. * @param parentType Type of needed parent node in ast. */ const upToClosest = (context, parentType) => { for (let i = context.pathToBufferNode.length - 1; i >= 0; i -= 1) { var _context$pathToBuffer; if (((_context$pathToBuffer = context.pathToBufferNode[i]) === null || _context$pathToBuffer === void 0 ? void 0 : _context$pathToBuffer.type) === parentType) { context.pathToBufferNode = context.pathToBufferNode.slice(0, i + 1); break; } } }; /** * Returns needed buffer node updated due to complex selector parsing. * * @param context Selector parser context. * * @returns Ast node for following selector parsing. * @throws An error if there is no upper SelectorNode is ast. */ const getUpdatedBufferNode = context => { // it may happen during the parsing of selector list // which is an argument of relative pseudo-class // e.g. '.banner:has(~span, ~p)' // parser position is here ↑ // so if after the comma the buffer node type is SelectorList and parent type is RelativePseudoClass // we should simply return the current buffer node const bufferNode = getBufferNode(context); if (bufferNode && isSelectorListNode(bufferNode) && isRelativePseudoClassNode(getBufferNodeParent(context))) { return bufferNode; } upToClosest(context, NodeType.Selector); const selectorNode = getBufferNode(context); if (!selectorNode) { throw new Error('No SelectorNode, impossible to continue selector parsing by ExtendedCss'); } const lastSelectorNodeChild = getLast(selectorNode.children); const hasExtended = lastSelectorNodeChild && isExtendedSelectorNode(lastSelectorNodeChild) // parser position might be inside standard pseudo-class brackets which has space // e.g. 'div:contains(/а/):nth-child(100n + 2)' && context.standardPseudoBracketsStack.length === 0; const supposedPseudoClassNode = hasExtended && getFirst(lastSelectorNodeChild.children); let newNeededBufferNode = selectorNode; if (supposedPseudoClassNode) { // name of pseudo-class for last extended-node child for Selector node const lastExtendedPseudoName = hasExtended && supposedPseudoClassNode.name; const isLastExtendedNameRelative = lastExtendedPseudoName && isRelativePseudoClass(lastExtendedPseudoName); const isLastExtendedNameAbsolute = lastExtendedPseudoName && isAbsolutePseudoClass(lastExtendedPseudoName); const hasRelativeExtended = isLastExtendedNameRelative && context.extendedPseudoBracketsStack.length > 0 && context.extendedPseudoBracketsStack.length === context.extendedPseudoNamesStack.length; const hasAbsoluteExtended = isLastExtendedNameAbsolute && lastExtendedPseudoName === getLast(context.extendedPseudoNamesStack); if (hasRelativeExtended) { // return relative selector node to update later context.pathToBufferNode.push(lastSelectorNodeChild); newNeededBufferNode = supposedPseudoClassNode; } else if (hasAbsoluteExtended) { // return absolute selector node to update later context.pathToBufferNode.push(lastSelectorNodeChild); newNeededBufferNode = supposedPseudoClassNode; } } else if (hasExtended) { // return selector node to add new regular selector node later newNeededBufferNode = selectorNode; } else { // otherwise return last regular selector node to update later newNeededBufferNode = getContextLastRegularSelectorNode(context); } // update the path to buffer node properly context.pathToBufferNode.push(newNeededBufferNode); return newNeededBufferNode; }; /** * Checks values of few next tokens on colon token `:` and: * - updates buffer node for following standard pseudo-class; * - adds extended selector ast node for following extended pseudo-class; * - validates some cases of `:remove()` and `:has()` usage. * * @param context Selector parser context. * @param selector Selector. * @param tokenValue Value of current token. * @param nextTokenValue Value of token next to current one. * @param nextToNextTokenValue Value of token next to next to current one. * * @throws An error on :remove() pseudo-class in selector * or :has() inside regular pseudo limitation. */ const handleNextTokenOnColon = (context, selector, tokenValue, nextTokenValue, nextToNextTokenValue) => { if (!nextTokenValue) { throw new Error(`Invalid colon ':' at the end of selector: '${selector}'`); } if (!isSupportedPseudoClass(nextTokenValue.toLowerCase())) { if (nextTokenValue.toLowerCase() === REMOVE_PSEUDO_MARKER) { // :remove() pseudo-class should be handled before // as it is not about element selecting but actions with elements // e.g. 'body > div:empty:remove()' throw new Error(`${REMOVE_ERROR_PREFIX.INVALID_REMOVE}: '${selector}'`); } // if following token is not an extended pseudo // the colon should be collected to value of RegularSelector // e.g. '.entry_text:nth-child(2)' updateBufferNode(context, tokenValue); // check the token after the pseudo and do balance parentheses later // only if it is functional pseudo-class (standard with brackets, e.g. ':lang()'). // no brackets balance needed for such case, // parser position is on first colon after the 'div': // e.g. 'div:last-child:has(button.privacy-policy__btn)' if (nextToNextTokenValue && nextToNextTokenValue === BRACKETS.PARENTHESES.LEFT // no brackets balance needed for parentheses inside attribute value // e.g. 'a[href="javascript:void(0)"]' <-- parser position is on colon `:` // before `void` ↑ && !context.isAttributeBracketsOpen) { context.standardPseudoNamesStack.push(nextTokenValue); } } else { // it is supported extended pseudo-class. // Disallow :has() inside the pseudos accepting only compound selectors // https://bugs.chromium.org/p/chromium/issues/detail?id=669058#c54 [2] if (HAS_PSEUDO_CLASS_MARKERS.includes(nextTokenValue) && context.standardPseudoNamesStack.length > 0) { // eslint-disable-next-line max-len throw new Error(`Usage of :${nextTokenValue}() pseudo-class is not allowed inside regular pseudo: '${getLast(context.standardPseudoNamesStack)}'`); } else { // stop RegularSelector value collecting upToClosest(context, NodeType.Selector); // add ExtendedSelector to Selector children addAstNodeByType(context, NodeType.ExtendedSelector); } } }; // limit applying of wildcard :is() and :not() pseudo-class only to html children // e.g. ':is(.page, .main) > .banner' or '*:not(span):not(p)' const IS_OR_NOT_PSEUDO_SELECTING_ROOT = `html ${ASTERISK}`; /** * Checks if there are any ExtendedSelector node in selector list. * * @param selectorList Ast SelectorList node. * * @returns True if `selectorList` has any inner ExtendedSelector node. */ const hasExtendedSelector = selectorList => { return selectorList.children.some(selectorNode => { return selectorNode.children.some(selectorNodeChild => { return isExtendedSelectorNode(selectorNodeChild); }); }); }; /** * Converts selector list of RegularSelector nodes to string. * * @param selectorList Ast SelectorList node. * * @returns String representation for selector list of regular selectors. */ const selectorListOfRegularsToString = selectorList => { // if there is no ExtendedSelector in relative SelectorList // it means that each Selector node has single child — RegularSelector node // and their values should be combined to string const standardCssSelectors = selectorList.children.map(selectorNode => { const selectorOnlyChild = getNodeOnlyChild(selectorNode, 'Ast Selector node should have RegularSelector node'); return getNodeValue(selectorOnlyChild); }); return standardCssSelectors.join(`${COMMA}${SPACE}`); }; /** * Updates children of `node` replacing them with `newChildren`. * Important: modifies input `node` which is passed by reference. * * @param node Ast node to update. * @param newChildren Array of new children for ast node. * * @returns Updated ast node. */ const updateNodeChildren = (node, newChildren) => { node.children = newChildren; return node; }; /** * Recursively checks whether the ExtendedSelector node should be optimized. * It has to be recursive because RelativePseudoClass has inner SelectorList node. * * @param currExtendedSelectorNode Ast ExtendedSelector node. * * @returns True is ExtendedSelector should be optimized. */ const shouldOptimizeExtendedSelector = currExtendedSelectorNode => { if (currExtendedSelectorNode === null) { return false; } const extendedPseudoClassNode = getPseudoClassNode(currExtendedSelectorNode); const pseudoName = getNodeName(extendedPseudoClassNode); if (isAbsolutePseudoClass(pseudoName)) { return false; } const relativeSelectorList = getRelativeSelectorListNode(extendedPseudoClassNode); const innerSelectorNodes = relativeSelectorList.children; // simple checking for standard selectors in arg of :not() or :is() pseudo-class // e.g. 'div > *:is(div, a, span)' if (isOptimizationPseudoClass(pseudoName)) { const areAllSelectorNodeChildrenRegular = innerSelectorNodes.every(selectorNode => { try { const selectorOnlyChild = getNodeOnlyChild(selectorNode, 'Selector node should have RegularSelector'); // it means that the only child is RegularSelector and it can be optimized return isRegularSelectorNode(selectorOnlyChild); } catch (e) { return false; } }); if (areAllSelectorNodeChildrenRegular) { return true; } } // for other extended pseudo-classes than :not() and :is() return innerSelectorNodes.some(selectorNode => { return selectorNode.children.some(selectorNodeChild => { if (!isExtendedSelectorNode(selectorNodeChild)) { return false; } // check inner ExtendedSelector recursively // e.g. 'div:has(*:not(.header))' return shouldOptimizeExtendedSelector(selectorNodeChild); }); }); }; /** * Returns optimized ExtendedSelector node if it can be optimized * or null if ExtendedSelector is fully optimized while function execution * which means that value of `prevRegularSelectorNode` is updated. * * @param currExtendedSelectorNode Current ExtendedSelector node to optimize. * @param prevRegularSelectorNode Previous RegularSelector node. * * @returns Ast node or null. */ const getOptimizedExtendedSelector = (currExtendedSelectorNode, prevRegularSelectorNode) => { if (!currExtendedSelectorNode) { return null; } const extendedPseudoClassNode = getPseudoClassNode(currExtendedSelectorNode); const relativeSelectorList = getRelativeSelectorListNode(extendedPseudoClassNode); const hasInnerExtendedSelector = hasExtendedSelector(relativeSelectorList); if (!hasInnerExtendedSelector) { // if there is no extended selectors for :not() or :is() // e.g. 'div:not(.content, .main)' const relativeSelectorListStr = selectorListOfRegularsToString(relativeSelectorList); const pseudoName = getNodeName(extendedPseudoClassNode); // eslint-disable-next-line max-len const optimizedExtendedStr = `${COLON}${pseudoName}${BRACKETS.PARENTHESES.LEFT}${relativeSelectorListStr}${BRACKETS.PARENTHESES.RIGHT}`; prevRegularSelectorNode.value = `${getNodeValue(prevRegularSelectorNode)}${optimizedExtendedStr}`; return null; } // eslint-disable-next-line @typescript-eslint/no-use-before-define const optimizedRelativeSelectorList = optimizeSelectorListNode(relativeSelectorList); const optimizedExtendedPseudoClassNode = updateNodeChildren(extendedPseudoClassNode, [optimizedRelativeSelectorList]); return updateNodeChildren(currExtendedSelectorNode, [optimizedExtendedPseudoClassNode]); }; /** * Combines values of `previous` and `current` RegularSelector nodes. * It may happen during the optimization when ExtendedSelector between RegularSelector node was optimized. * * @param current Current RegularSelector node. * @param previous Previous RegularSelector node. */ const optimizeCurrentRegularSelector = (current, previous) => { previous.value = `${getNodeValue(previous)}${SPACE}${getNodeValue(current)}`; }; /** * Optimizes ast Selector node. * * @param selectorNode Ast Selector node. * * @returns Optimized ast node. * @throws An error while collecting optimized nodes. */ const optimizeSelectorNode = selectorNode => { // non-optimized list of SelectorNode children const rawSelectorNodeChildren = selectorNode.children; // for collecting optimized children list const optimizedChildrenList = []; let currentIndex = 0; // iterate through all children in non-optimized ast Selector node while (currentIndex < rawSelectorNodeChildren.length) { const currentChild = getItemByIndex(rawSelectorNodeChildren, currentIndex, 'currentChild should be specified'); // no need to optimize the very first child which is always RegularSelector node if (currentIndex === 0) { optimizedChildrenList.push(currentChild); } else { const prevRegularChild = getLastRegularChild(optimizedChildrenList); if (isExtendedSelectorNode(currentChild)) { // start checking with point is null let optimizedExtendedSelector = null; // check whether the optimization is needed let isOptimizationNeeded = shouldOptimizeExtendedSelector(currentChild); // update optimizedExtendedSelector so it can be optimized recursively // i.e. `getOptimizedExtendedSelector(optimizedExtendedSelector)` below optimizedExtendedSelector = currentChild; while (isOptimizationNeeded) { // recursively optimize ExtendedSelector until no optimization needed // e.g. div > *:is(.banner:not(.block)) optimizedExtendedSelector = getOptimizedExtendedSelector(optimizedExtendedSelector, prevRegularChild); isOptimizationNeeded = shouldOptimizeExtendedSelector(optimizedExtendedSelector); } // if it was simple :not() of :is() with standard selector arg // e.g. 'div:not([class][id])' // or '.main > *:is([data-loaded], .banner)' // after the optimization the ExtendedSelector node become part of RegularSelector // so nothing to save eventually // otherwise the optimized ExtendedSelector should be saved // e.g. 'div:has(:not([class]))' if (optimizedExtendedSelector !== null) { optimizedChildrenList.push(optimizedExtendedSelector); // if optimization is not needed const optimizedPseudoClass = getPseudoClassNode(optimizedExtendedSelector); const optimizedPseudoName = getNodeName(optimizedPseudoClass); // parent element checking is used to apply :is() and :not() pseudo-classes as extended. // as there is no parentNode for root element (html) // so element selection should be limited to it's children // e.g. '*:is(:has(.page))' -> 'html *:is(has(.page))' // or '*:not(:has(span))' -> 'html *:not(:has(span))' if (getNodeValue(prevRegularChild) === ASTERISK && isOptimizationPseudoClass(optimizedPseudoName)) { prevRegularChild.value = IS_OR_NOT_PSEUDO_SELECTING_ROOT; } } } else if (isRegularSelectorNode(currentChild)) { // in non-optimized ast, RegularSelector node may follow ExtendedSelector which should be optimized // for example, for 'div:not(.content) > .banner' schematically it looks like // non-optimized ast: [ // 1. RegularSelector: 'div' // 2. ExtendedSelector: 'not(.content)' // 3. RegularSelector: '> .banner' // ] // which after the ExtendedSelector looks like // partly optimized ast: [ // 1. RegularSelector: 'div:not(.content)' // 2. RegularSelector: '> .banner' // ] // so second RegularSelector value should be combined with first one // optimized ast: [ // 1. RegularSelector: 'div:not(.content) > .banner' // ] // here we check **children of selectorNode** after previous optimization if it was const lastOptimizedChild = getLast(optimizedChildrenList) || null; if (isRegularSelectorNode(lastOptimizedChild)) { optimizeCurrentRegularSelector(currentChild, prevRegularChild); } } } currentIndex += 1; } return updateNodeChildren(selectorNode, optimizedChildrenList); }; /** * Optimizes ast SelectorList node. * * @param selectorListNode SelectorList node. * * @returns Optimized ast node. */ const optimizeSelectorListNode = selectorListNode => { return updateNodeChildren(selectorListNode, selectorListNode.children.map(s => optimizeSelectorNode(s))); }; /** * Optimizes ast: * If arg of :not() and :is() pseudo-classes does not contain extended selectors, * native Document.querySelectorAll() can be used to query elements. * It means that ExtendedSelector ast nodes can be removed * and value of relevant RegularSelector node should be updated accordingly. * * @param ast Non-optimized ast. * * @returns Optimized ast. */ const optimizeAst = ast => { // ast is basically the selector list of selectors return optimizeSelectorListNode(ast); }; // limit applying of :xpath() pseudo-class to 'any' element // https://github.com/AdguardTeam/ExtendedCss/issues/115 const XPATH_PSEUDO_SELECTING_ROOT = 'body'; const NO_WHITESPACE_ERROR_PREFIX = 'No white space is allowed before or after extended pseudo-class name in selector'; /** * Parses selector into ast for following element selection. * * @param selector Selector to parse. * * @returns Parsed ast. * @throws An error on invalid selector. */ const parse$1 = selector => { const tokens = tokenizeSelector(selector); const context = { ast: null, pathToBufferNode: [], extendedPseudoNamesStack: [], extendedPseudoBracketsStack: [], standardPseudoNamesStack: [], standardPseudoBracketsStack: [], isAttributeBracketsOpen: false, attributeBuffer: '', isRegexpOpen: false, shouldOptimize: false }; let i = 0; while (i < tokens.length) { const token = tokens[i]; if (!token) { break; } // Token to process const { type: tokenType, value: tokenValue } = token; // needed for SPACE and COLON tokens checking const nextToken = tokens[i + 1]; const nextTokenType = nextToken === null || nextToken === void 0 ? void 0 : nextToken.type; const nextTokenValue = nextToken === null || nextToken === void 0 ? void 0 : nextToken.value; // needed for limitations // - :not() and :is() root element // - :has() usage // - white space before and after pseudo-class name const nextToNextToken = tokens[i + 2]; const nextToNextTokenValue = nextToNextToken === null || nextToNextToken === void 0 ? void 0 : nextToNextToken.value; // needed for COLON token checking for none-specified regular selector before extended one // e.g. 'p, :hover' // or '.banner, :contains(ads)' const previousToken = tokens[i - 1]; const prevTokenType = previousToken === null || previousToken === void 0 ? void 0 : previousToken.type; const prevTokenValue = previousToken === null || previousToken === void 0 ? void 0 : previousToken.value; // needed for proper parsing of regexp pattern arg // e.g. ':matches-css(background-image: /^url\(https:\/\/example\.org\//)' const previousToPreviousToken = tokens[i - 2]; const prevToPrevTokenValue = previousToPreviousToken === null || previousToPreviousToken === void 0 ? void 0 : previousToPreviousToken.value; let bufferNode = getBufferNode(context); switch (tokenType) { case TokenType.Word: if (bufferNode === null) { // there is no buffer node only in one case — no ast collecting has been started initAst(context, tokenValue); } else if (isSelectorListNode(bufferNode)) { // add new selector to selector list addAstNodeByType(context, NodeType.Selector); addAstNodeByType(context, NodeType.RegularSelector, tokenValue); } else if (isRegularSelectorNode(bufferNode)) { updateBufferNode(context, tokenValue); } else if (isExtendedSelectorNode(bufferNode)) { // No white space is allowed between the name of extended pseudo-class // and its opening parenthesis // https://www.w3.org/TR/selectors-4/#pseudo-classes // e.g. 'span:contains (text)' if (isWhiteSpaceChar(nextTokenValue) && nextToNextTokenValue === BRACKETS.PARENTHESES.LEFT) { throw new Error(`${NO_WHITESPACE_ERROR_PREFIX}: '${selector}'`); } const lowerCaseTokenValue = tokenValue.toLowerCase(); // save pseudo-class name for brackets balance checking context.extendedPseudoNamesStack.push(lowerCaseTokenValue); // extended pseudo-class name are parsed in lower case // as they should be case-insensitive // https://www.w3.org/TR/selectors-4/#pseudo-classes if (isAbsolutePseudoClass(lowerCaseTokenValue)) { addAstNodeByType(context, NodeType.AbsolutePseudoClass, lowerCaseTokenValue); } else { // if it is not absolute pseudo-class, it must be relative one // add RelativePseudoClass with tokenValue as pseudo-class name to ExtendedSelector children addAstNodeByType(context, NodeType.RelativePseudoClass, lowerCaseTokenValue); // for :not() and :is() pseudo-classes parsed ast should be optimized later if (isOptimizationPseudoClass(lowerCaseTokenValue)) { context.shouldOptimize = true; } } } else if (isAbsolutePseudoClassNode(bufferNode)) { // collect absolute pseudo-class arg updateBufferNode(context, tokenValue); } else if (isRelativePseudoClassNode(bufferNode)) { initRelativ###btree(context, tokenValue); } break; case TokenType.Mark: switch (tokenValue) { case COMMA: if (!bufferNode || typeof bufferNode !== 'undefined' && !nextTokenValue) { // consider the selector is invalid if there is no bufferNode yet (e.g. ', a') // or there is nothing after the comma while bufferNode is defined (e.g. 'div, ') throw new Error(`'${selector}' is not a valid selector`); } else if (isRegularSelectorNode(bufferNode)) { if (context.isAttributeBracketsOpen) { // the comma might be inside element attribute value // e.g. 'div[data-comma="0,1"]' updateBufferNode(context, tokenValue); } else { // new Selector should be collected to upper SelectorList upToClosest(context, NodeType.SelectorList); } } else if (isAbsolutePseudoClassNode(bufferNode)) { // the comma inside arg of absolute extended pseudo // e.g. 'div:xpath(//h3[contains(text(),"Share it!")]/..)' updateBufferNode(context, tokenValue); } else if (isSelectorNode(bufferNode)) { // new Selector should be collected to upper SelectorList // if parser position is on Selector node upToClosest(context, NodeType.SelectorList); } break; case SPACE: // it might be complex selector with extended pseudo-class inside it // and the space is between that complex selector and following regular selector // parser position is on ` ` before `span` now: // e.g. 'div:has(img).banner span' // so we need to check whether the new ast node should be added (example above) // or previous regular selector node should be updated if (isRegularSelectorNode(bufferNode) // no need to update the buffer node if attribute value is being parsed // e.g. 'div:not([id])[style="position: absolute; z-index: 10000;"]' // parser position inside attribute ↑ && !context.isAttributeBracketsOpen) { bufferNode = getUpdatedBufferNode(context); } if (isRegularSelectorNode(bufferNode)) { // standard selectors with white space between colon and name of pseudo // are invalid for native document.querySelectorAll() anyway, // so throwing the error here is better // than proper parsing of invalid selector and passing it further. // first of all do not check attributes // e.g. div[style="text-align: center"] if (!context.isAttributeBracketsOpen // check the space after the colon and before the pseudo // e.g. '.block: nth-child(2) && (prevTokenValue === COLON && nextTokenType === TokenType.Word // or after the pseudo and before the opening parenthesis // e.g. '.block:nth-child (2) || prevTokenType === TokenType.Word && nextTokenValue === BRACKETS.PARENTHESES.LEFT)) { throw new Error(`'${selector}' is not a valid selector`); } // collect current tokenValue to value of RegularSelector // if it is the last token or standard selector continues after the space. // otherwise it will be skipped if (!nextTokenValue || doesRegularContinueAfterSpace(nextTokenType, nextTokenValue) // we also should collect space inside attribute value // e.g. `[onclick^="window.open ('https://example.com/share?url="]` // parser position ↑ || context.isAttributeBracketsOpen) { updateBufferNode(context, tokenValue); } } if (isAbsolutePseudoClassNode(bufferNode)) { // space inside extended pseudo-class arg // e.g. 'span:contains(some text)' updateBufferNode(context, tokenValue); } if (isRelativePseudoClassNode(bufferNode)) { // init with empty value RegularSelector // as the space is not needed for selector value // e.g. 'p:not( .content )' initRelativ###btree(context); } if (isSelectorNode(bufferNode)) { // do NOT add RegularSelector if parser position on space BEFORE the comma in selector list // e.g. '.block:has(> img) , .banner)' if (doesRegularContinueAfterSpace(nextTokenType, nextTokenValue)) { // regular selector might be after the extended one. // extra space before combinator or selector should not be collected // e.g. '.banner:upward(2) .block' // '.banner:upward(2) > .block' // so no tokenValue passed to addAnySelectorNode() addAstNodeByType(context, NodeType.RegularSelector); } } break; case DESCENDANT_COMBINATOR: case CHILD_COMBINATOR: case NEXT_SIBLING_COMBINATOR: case SUBSEQUENT_SIBLING_COMBINATOR: case SEMICOLON: case SLASH: case BACKSLASH: case SINGLE_QUOTE: case DOUBLE_QUOTE: case CARET: case DOLLAR_SIGN: case BRACKETS.CURLY.LEFT: case BRACKETS.CURLY.RIGHT: case ASTERISK: case ID_MARKER: case CLASS_MARKER: case BRACKETS.SQUARE.LEFT: // it might be complex selector with extended pseudo-class inside it // and the space is between that complex selector and following regular selector // e.g. 'div:has(img).banner' // parser position is on `.` before `banner` now // 'div:has(img)[attr]' // parser position is on `[` before `attr` now // so we need to check whether the new ast node should be added (example above) // or previous regular selector node should be updated if (COMBINATORS.includes(tokenValue)) { if (bufferNode === null) { // cases where combinator at very beginning of a selector // e.g. '> div' // or '~ .banner' // or even '+js(overlay-buster)' which not a selector at all // but may be validated by FilterCompiler so error message should be appropriate throw new Error(`'${selector}' is not a valid selector`); } bufferNode = getUpdatedBufferNode(context); } if (bufferNode === null) { // no ast collecting has been started // e.g. '.banner > p' // or '#top > div.ad' // or '[class][style][attr]' // or '*:not(span)' initAst(context, tokenValue); if (isAttributeOpening(tokenValue, prevTokenValue)) { // e.g. '[class^="banner-"]' context.isAttributeBracketsOpen = true; } } else if (isRegularSelectorNode(bufferNode)) { // collect the mark to the value of RegularSelector node updateBufferNode(context, tokenValue); if (isAttributeOpening(tokenValue, prevTokenValue)) { // needed for proper handling element attribute value with comma // e.g. 'div[data-comma="0,1"]' context.isAttributeBracketsOpen = true; } } else if (isAbsolutePseudoClassNode(bufferNode)) { // collect the mark to the arg of AbsolutePseudoClass node updateBufferNode(context, tokenValue); // 'isRegexpOpen' flag is needed for brackets balancing inside extended pseudo-class arg if (tokenValue === SLASH && context.extendedPseudoNamesStack.length > 0) { if (prevTokenValue === SLASH && prevToPrevTokenValue === BACKSLASH) { // it may be specific url regexp pattern in arg of pseudo-class // e.g. ':matches-css(background-image: /^url\(https:\/\/example\.org\//)' // parser position is on final slash before `)` ↑ context.isRegexpOpen = false; } else if (prevTokenValue && prevTokenValue !== BACKSLASH) { if (isRegexpOpening(context, prevTokenValue, getNodeValue(bufferNode))) { context.isRegexpOpen = !context.isRegexpOpen; } else { // otherwise force `isRegexpOpen` flag to `false` context.isRegexpOpen = false; } } } } else if (isRelativePseudoClassNode(bufferNode)) { // add SelectorList to children of RelativePseudoClass node initRelativ###btree(context, tokenValue); if (isAttributeOpening(tokenValue, prevTokenValue)) { // besides of creating the relative subtree // opening square bracket means start of attribute // e.g. 'div:not([class="content"])' // 'div:not([href*="window.print()"])' context.isAttributeBracketsOpen = true; } } else if (isSelectorNode(bufferNode)) { // after the extended pseudo closing parentheses // parser position is on Selector node // and regular selector can be after the extended one // e.g. '.banner:upward(2)> .block' // or '.inner:nth-ancestor(1)~ .banner' if (COMBINATORS.includes(tokenValue)) { addAstNodeByType(context, NodeType.RegularSelector, tokenValue); } else if (!context.isRegexpOpen) { // it might be complex selector with extended pseudo-class inside it. // parser position is on `.` now: // e.g. 'div:has(img).banner' // so we need to get last regular selector node and update its value bufferNode = getContextLastRegularSelectorNode(context); updateBufferNode(context, tokenValue); if (isAttributeOpening(tokenValue, prevTokenValue)) { // handle attribute in compound selector after extended pseudo-class // e.g. 'div:not(.top)[style="z-index: 10000;"]' // parser position ↑ context.isAttributeBracketsOpen = true; } } } else if (isSelectorListNode(bufferNode)) { // add Selector to SelectorList addAstNodeByType(context, NodeType.Selector); // and RegularSelector as it is always the first child of Selector addAstNodeByType(context, NodeType.RegularSelector, tokenValue); if (isAttributeOpening(tokenValue, prevTokenValue)) { // handle simple attribute selector in selector list // e.g. '.banner, [class^="ad-"]' context.isAttributeBracketsOpen = true; } } break; case BRACKETS.SQUARE.RIGHT: if (isRegularSelectorNode(bufferNode)) { // unescaped `]` in regular selector allowed only inside attribute value if (!context.isAttributeBracketsOpen && prevTokenValue !== BACKSLASH) { // e.g. 'div]' // eslint-disable-next-line max-len throw new Error(`'${selector}' is not a valid selector due to '${tokenValue}' after '${getNodeValue(bufferNode)}'`); } // needed for proper parsing regular selectors after the attributes with comma // e.g. 'div[data-comma="0,1"] > img' if (isAttributeClosing(context)) { context.isAttributeBracketsOpen = false; // reset attribute buffer on closing `]` context.attributeBuffer = ''; } // collect the bracket to the value of RegularSelector node updateBufferNode(context, tokenValue); } if (isAbsolutePseudoClassNode(bufferNode)) { // :xpath() expended pseudo-class arg might contain square bracket // so it should be collected // e.g. 'div:xpath(//h3[contains(text(),"Share it!")]/..)' updateBufferNode(context, tokenValue); } break; case COLON: // No white space is allowed between the colon and the following name of the pseudo-class // https://www.w3.org/TR/selectors-4/#pseudo-classes // e.g. 'span: contains(text)' if (isWhiteSpaceChar(nextTokenValue) && nextToNextTokenValue && SUPPORTED_PSEUDO_CLASSES.includes(nextToNextTokenValue)) { throw new Error(`${NO_WHITESPACE_ERROR_PREFIX}: '${selector}'`); } if (bufferNode === null) { // no ast collecting has been started if (nextTokenValue === XPATH_PSEUDO_CLASS_MARKER) { // limit applying of "naked" :xpath pseudo-class // https://github.com/AdguardTeam/ExtendedCss/issues/115 initAst(context, XPATH_PSEUDO_SELECTING_ROOT); } else if (nextTokenValue === UPWARD_PSEUDO_CLASS_MARKER || nextTokenValue === NTH_ANCESTOR_PSEUDO_CLASS_MARKER) { // selector should be specified before :nth-ancestor() or :upward() // e.g. ':nth-ancestor(3)' // or ':upward(span)' throw new Error(`${NO_SELECTOR_ERROR_PREFIX} :${nextTokenValue}() pseudo-class`); } else { // make it more obvious if selector starts with pseudo with no tag specified // e.g. ':has(a)' -> '*:has(a)' // or ':empty' -> '*:empty' initAst(context, ASTERISK); } // bufferNode should be updated for following checking bufferNode = getBufferNode(context); } if (isSelectorListNode(bufferNode)) { // bufferNode is SelectorList after comma has been parsed. // parser position is on colon now: // e.g. 'img,:not(.content)' addAstNodeByType(context, NodeType.Selector); // add empty value RegularSelector anyway as any selector should start with it // and check previous token on the next step addAstNodeByType(context, NodeType.RegularSelector); // bufferNode should be updated for following checking bufferNode = getBufferNode(context); } if (isRegularSelectorNode(bufferNode)) { // it can be extended or standard pseudo // e.g. '#share, :contains(share it)' // or 'div,:hover' // of 'div:has(+:contains(text))' // position is after '+' if (prevTokenValue && COMBINATORS.includes(prevTokenValue) || prevTokenValue === COMMA) { // case with colon at the start of string - e.g. ':contains(text)' // is covered by 'bufferNode === null' above at start of COLON checking updateBufferNode(context, ASTERISK); } handleNextTokenOnColon(context, selector, tokenValue, nextTokenValue, nextToNextTokenValue); } if (isSelectorNode(bufferNode)) { // e.g. 'div:contains(text):' if (!nextTokenValue) { throw new Error(`Invalid colon ':' at the end of selector: '${selector}'`); } // after the extended pseudo closing parentheses // parser position is on Selector node // and there is might be another extended selector. // parser position is on colon before 'upward': // e.g. 'p:contains(PR):upward(2)' if (isSupportedPseudoClass(nextTokenValue.toLowerCase())) { // if supported extended pseudo-class is next to colon // add ExtendedSelector to Selector children addAstNodeByType(context, NodeType.ExtendedSelector); } else if (nextTokenValue.toLowerCase() === REMOVE_PSEUDO_MARKER) { // :remove() pseudo-class should be handled before // as it is not about element selecting but actions with elements // e.g. '#banner:upward(2):remove()' throw new Error(`${REMOVE_ERROR_PREFIX.INVALID_REMOVE}: '${selector}'`); } else { // otherwise it is standard pseudo after extended pseudo-class in complex selector // and colon should be collected to value of previous RegularSelector // e.g. 'body *:not(input)::selection' // 'input:matches-css(padding: 10):checked' bufferNode = getContextLastRegularSelectorNode(context); handleNextTokenOnColon(context, selector, tokenValue, nextTokenType, nextToNextTokenValue); } } if (isAbsolutePseudoClassNode(bufferNode)) { // :xpath() pseudo-class should be the last of extended pseudo-classes if (getNodeName(bufferNode) === XPATH_PSEUDO_CLASS_MARKER && nextTokenValue && SUPPORTED_PSEUDO_CLASSES.includes(nextTokenValue) && nextToNextTokenValue === BRACKETS.PARENTHESES.LEFT) { throw new Error(`:xpath() pseudo-class should be the last in selector: '${selector}'`); } // collecting arg for absolute pseudo-class // e.g. 'div:matches-css(width:400px)' updateBufferNode(context, tokenValue); } if (isRelativePseudoClassNode(bufferNode)) { if (!nextTokenValue) { // e.g. 'div:has(:' throw new Error(`Invalid pseudo-class arg at the end of selector: '${selector}'`); } // make it more obvious if selector starts with pseudo with no tag specified // parser position is on colon inside :has() arg // e.g. 'div:has(:contains(text))' // or 'div:not(:empty)' initRelativ###btree(context, ASTERISK); if (!isSupportedPseudoClass(nextTokenValue.toLowerCase())) { // collect the colon to value of RegularSelector // e.g. 'div:not(:empty)' updateBufferNode(context, tokenValue); // parentheses should be balanced only for functional pseudo-classes // e.g. '.yellow:not(:nth-child(3))' if (nextToNextTokenValue === BRACKETS.PARENTHESES.LEFT) { context.standardPseudoNamesStack.push(nextTokenValue); } } else { // add ExtendedSelector to Selector children // e.g. 'div:has(:contains(text))' upToClosest(context, NodeType.Selector); addAstNodeByType(context, NodeType.ExtendedSelector); } } break; case BRACKETS.PARENTHESES.LEFT: // start of pseudo-class arg if (isAbsolutePseudoClassNode(bufferNode)) { // no brackets balancing needed inside // 1. :xpath() extended pseudo-class arg // 2. regexp arg for other extended pseudo-classes if (getNodeName(bufferNode) !== XPATH_PSEUDO_CLASS_MARKER && context.isRegexpOpen) { // if the parentheses is escaped it should be part of regexp // collect it to arg of AbsolutePseudoClass // e.g. 'div:matches-css(background-image: /^url\\("data:image\\/gif;base64.+/)' updateBufferNode(context, tokenValue); } else { // otherwise brackets should be balanced // e.g. 'div:xpath(//h3[contains(text(),"Share it!")]/..)' context.extendedPseudoBracketsStack.push(tokenValue); // eslint-disable-next-line max-len if (context.extendedPseudoBracketsStack.length > context.extendedPseudoNamesStack.length) { updateBufferNode(context, tokenValue); } } } if (isRegularSelectorNode(bufferNode)) { // continue RegularSelector value collecting for standard pseudo-classes // e.g. '.banner:where(div)' if (context.standardPseudoNamesStack.length > 0) { updateBufferNode(context, tokenValue); context.standardPseudoBracketsStack.push(tokenValue); } // parentheses inside attribute value should be part of RegularSelector value // e.g. 'div:not([href*="window.print()"])' <-- parser position // is on the `(` after `print` ↑ if (context.isAttributeBracketsOpen) { updateBufferNode(context, tokenValue); } } if (isRelativePseudoClassNode(bufferNode)) { // save opening bracket for balancing // e.g. 'div:not()' // position is on `(` context.extendedPseudoBracketsStack.push(tokenValue); } break; case BRACKETS.PARENTHESES.RIGHT: if (isAbsolutePseudoClassNode(bufferNode)) { // no brackets balancing needed inside // 1. :xpath() extended pseudo-class arg // 2. regexp arg for other extended pseudo-classes if (getNodeName(bufferNode) !== XPATH_PSEUDO_CLASS_MARKER && context.isRegexpOpen) { // if closing bracket is part of regexp // simply save it to pseudo-class arg updateBufferNode(context, tokenValue); } else { // remove stacked open parentheses for brackets balance // e.g. 'h3:contains((Ads))' // or 'div:xpath(//h3[contains(text(),"Share it!")]/..)' context.extendedPseudoBracketsStack.pop(); if (getNodeName(bufferNode) !== XPATH_PSEUDO_CLASS_MARKER) { // for all other absolute pseudo-classes except :xpath() // remove stacked name of extended pseudo-class context.extendedPseudoNamesStack.pop(); // eslint-disable-next-line max-len if (context.extendedPseudoBracketsStack.length > context.extendedPseudoNamesStack.length) { // if brackets stack is not empty yet, // save tokenValue to arg of AbsolutePseudoClass // parser position on first closing bracket after 'Ads': // e.g. 'h3:contains((Ads))' updateBufferNode(context, tokenValue); } else if (context.extendedPseudoBracketsStack.length >= 0 && context.extendedPseudoNamesStack.length >= 0) { // assume it is combined extended pseudo-classes // parser position on first closing bracket after 'advert': // e.g. 'div:has(.banner, :contains(advert))' upToClosest(context, NodeType.Selector); } } else { // for :xpath() // eslint-disable-next-line max-len if (context.extendedPseudoBracketsStack.length < context.extendedPseudoNamesStack.length) { // remove stacked name of extended pseudo-class // if there are less brackets than pseudo-class names // with means last removes bracket was closing for pseudo-class context.extendedPseudoNamesStack.pop(); } else { // otherwise the bracket is part of arg updateBufferNode(context, tokenValue); } } } } if (isRegularSelectorNode(bufferNode)) { if (context.isAttributeBracketsOpen) { // parentheses inside attribute value should be part of RegularSelector value // e.g. 'div:not([href*="window.print()"])' <-- parser position // is on the `)` after `print(` ↑ updateBufferNode(context, tokenValue); } else if (context.standardPseudoNamesStack.length > 0 && context.standardPseudoBracketsStack.length > 0) { // standard pseudo-class was processing. // collect the closing bracket to value of RegularSelector // parser position is on bracket after 'class' now: // e.g. 'div:where(.class)' updateBufferNode(context, tokenValue); // remove bracket and pseudo name from stacks context.standardPseudoBracketsStack.pop(); const lastStandardPseudo = context.standardPseudoNamesStack.pop(); if (!lastStandardPseudo) { // standard pseudo should be in standardPseudoNamesStack // as related to standardPseudoBracketsStack throw new Error(`Parsing error. Invalid selector: ${selector}`); } // Disallow :has() after regular pseudo-elements // https://bugs.chromium.org/p/chromium/issues/detail?id=669058#c54 [3] if (Object.values(REGULAR_PSEUDO_ELEMENTS).includes(lastStandardPseudo) // check token which is next to closing parentheses and token after it // parser position is on bracket after 'foo' now: // e.g. '::part(foo):has(.a)' && nextTokenValue === COLON && nextToNextTokenValue && HAS_PSEUDO_CLASS_MARKERS.includes(nextToNextTokenValue)) { // eslint-disable-next-line max-len throw new Error(`Usage of :${nextToNextTokenValue}() pseudo-class is not allowed after any regular pseudo-element: '${lastStandardPseudo}'`); } } else { // extended pseudo-class was processing. // e.g. 'div:has(h3)' // remove bracket and pseudo name from stacks context.extendedPseudoBracketsStack.pop(); context.extendedPseudoNamesStack.pop(); upToClosest(context, NodeType.ExtendedSelector); // go to upper selector for possible selector continuation after extended pseudo-class // e.g. 'div:has(h3) > img' upToClosest(context, NodeType.Selector); } } if (isSelectorNode(bufferNode)) { // after inner extended pseudo-class bufferNode is Selector. // parser position is on last bracket now: // e.g. 'div:has(.banner, :contains(ads))' context.extendedPseudoBracketsStack.pop(); context.extendedPseudoNamesStack.pop(); upToClosest(context, NodeType.ExtendedSelector); upToClosest(context, NodeType.Selector); } if (isRelativePseudoClassNode(bufferNode)) { // save opening bracket for balancing // e.g. 'div:not()' // position is on `)` // context.extendedPseudoBracketsStack.push(tokenValue); if (context.extendedPseudoNamesStack.length > 0 && context.extendedPseudoBracketsStack.length > 0) { context.extendedPseudoBracketsStack.pop(); context.extendedPseudoNamesStack.pop(); } } break; case LINE_FEED: case FORM_FEED: case CARRIAGE_RETURN: // such characters at start and end of selector should be trimmed // so is there is one them among tokens, it is not valid selector throw new Error(`'${selector}' is not a valid selector`); case TAB: // allow tab only inside attribute value // as there are such valid rules in filter lists // e.g. 'div[style^="margin-right: auto; text-align: left;', // parser position ↑ if (isRegularSelectorNode(bufferNode) && context.isAttributeBracketsOpen) { updateBufferNode(context, tokenValue); } else { // otherwise not valid throw new Error(`'${selector}' is not a valid selector`); } } break; // no default statement for Marks as they are limited to SUPPORTED_SELECTOR_MARKS // and all other symbol combinations are tokenized as Word // so error for invalid Word will be thrown later while element selecting by parsed ast default: throw new Error(`Unknown type of token: '${tokenValue}'`); } i += 1; } if (context.ast === null) { throw new Error(`'${selector}' is not a valid selector`); } if (context.extendedPseudoNamesStack.length > 0 || context.extendedPseudoBracketsStack.length > 0) { // eslint-disable-next-line max-len throw new Error(`Unbalanced brackets for extended pseudo-class: '${getLast(context.extendedPseudoNamesStack)}'`); } if (context.isAttributeBracketsOpen) { throw new Error(`Unbalanced attribute brackets in selector: '${selector}'`); } return context.shouldOptimize ? optimizeAst(context.ast) : context.ast; }; const natives = { MutationObserver: window.MutationObserver || window.WebKitMutationObserver }; /** * As soon as possible stores native Node textContent getter to be used for contains pseudo-class * because elements' 'textContent' and 'innerText' properties might be mocked. * * @see {@link https://github.com/AdguardTeam/ExtendedCss/issues/127} */ const nodeTextContentGetter = (() => { var _Object$getOwnPropert; const nativeNode = window.Node || Node; return (_Object$getOwnPropert = Object.getOwnPropertyDescriptor(nativeNode.prototype, 'textContent')) === null || _Object$getOwnPropert === void 0 ? void 0 : _Object$getOwnPropert.get; })(); /** * Returns textContent of passed domElement. * * @param domElement DOM element. * * @returns DOM element textContent. */ const getNodeTextContent = domElement => { return (nodeTextContentGetter === null || nodeTextContentGetter === void 0 ? void 0 : nodeTextContentGetter.apply(domElement)) || ''; }; /** * Returns element selector text based on it's tagName and attributes. * * @param element DOM element. * * @returns String representation of `element`. */ const getElementSelectorDesc = element => { let selectorText = element.tagName.toLowerCase(); selectorText += Array.from(element.attributes).map(attr => { return `[${attr.name}="${element.getAttribute(attr.name)}"]`; }).join(''); return selectorText; }; /** * Returns path to a DOM element as a selector string. * * @param inputEl Input element. * * @returns String path to a DOM element. * @throws An error if `inputEl` in not instance of `Element`. */ const getElementSelectorPath = inputEl => { if (!(inputEl instanceof Element)) { throw new Error('Function received argument with wrong type'); } let el; el = inputEl; const path = []; // we need to check '!!el' first because it is possible // that some ancestor of the inputEl was removed before it while (!!el && el.nodeType === Node.ELEMENT_NODE) { let selector = el.nodeName.toLowerCase(); if (el.id && typeof el.id === 'string') { selector += `#${el.id}`; path.unshift(selector); break; } let sibling = el; let nth = 1; while (sibling.previousElementSibling) { sibling = sibling.previousElementSibling; if (sibling.nodeType === Node.ELEMENT_NODE && sibling.nodeName.toLowerCase() === selector) { nth += 1; } } if (nth !== 1) { selector += `:nth-of-type(${nth})`; } path.unshift(selector); el = el.parentElement; } return path.join(' > '); }; /** * Checks whether the element is instance of HTMLElement. * * @param element Element to check. * * @returns True if `element` is HTMLElement. */ const isHtmlElement = element => { return element instanceof HTMLElement; }; /** * Takes `element` and returns its parent element. * * @param element Element. * @param errorMessage Optional error message to throw. * * @returns Parent of `element`. * @throws An error if element has no parent element. */ const getParent = (element, errorMessage) => { const { parentElement } = element; if (!parentElement) { throw new Error(errorMessage || 'Element does no have parent element'); } return parentElement; }; const logger = { /** * Safe console.error version. */ error: typeof console !== 'undefined' && console.error && console.error.bind ? console.error.bind(window.console) : console.error, /** * Safe console.info version. */ info: typeof console !== 'undefined' && console.info && console.info.bind ? console.info.bind(window.console) : console.info }; /** * Returns string without suffix. * * @param str Input string. * @param suffix Needed to remove. * * @returns String without suffix. */ const remov###ffix = (str, suffix) => { const index = str.indexOf(suffix, str.length - suffix.length); if (index >= 0) { return str.substring(0, index); } return str; }; /** * Replaces all `pattern`s with `replacement` in `input` string. * String.replaceAll() polyfill because it is not supported by old browsers, e.g. Chrome 55. * * @see {@link https://caniuse.com/?search=String.replaceAll} * * @param input Input string to process. * @param pattern Find in the input string. * @param replacement Replace the pattern with. * * @returns Modified string. */ const replaceAll = (input, pattern, replacement) => { if (!input) { return input; } return input.split(pattern).join(replacement); }; /** * Converts string pattern to regular expression. * * @param str String to convert. * * @returns Regular expression converted from pattern `str`. */ const toRegExp = str => { if (str.startsWith(SLASH) && str.endsWith(SLASH)) { return new RegExp(str.slice(1, -1)); } const escaped = str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); return new RegExp(escaped); }; /** * Converts any simple type value to string type, * e.g. `undefined` -> `'undefined'`. * * @param value Any type value. * * @returns String representation of `value`. */ const convertTypeIntoString = value => { let output; switch (value) { case undefined: output = 'undefined'; break; case null: output = 'null'; break; default: output = value.toString(); } return output; }; /** * Converts instance of string value into other simple types, * e.g. `'null'` -> `null`, `'true'` -> `true`. * * @param value String-type value. * * @returns Its own type representation of string-type `value`. */ const convertTypeFromString = value => { const numValue = Number(value); let output; if (!Number.isNaN(numValue)) { output = numValue; } else { switch (value) { case 'undefined': output = undefined; break; case 'null': output = null; break; case 'true': output = true; break; case 'false': output = false; break; default: output = value; } } return output; }; var BrowserName; (function (BrowserName) { BrowserName["Chrome"] = "Chrome"; BrowserName["Firefox"] = "Firefox"; BrowserName["Edge"] = "Edg"; BrowserName["Opera"] = "Opera"; BrowserName["Safari"] = "Safari"; BrowserName["HeadlessChrome"] = "HeadlessChrome"; })(BrowserName || (BrowserName = {})); const CHROMIUM_BRAND_NAME = 'Chromium'; const GOOGLE_CHROME_BRAND_NAME = 'Google Chrome'; /** * Simple check for Safari browser. */ const isSafariBrowser = navigator.vendor === 'Apple Computer, Inc.'; const SUPPORTED_BROWSERS_DATA = { [BrowserName.Chrome]: { // avoid Chromium-based Edge browser // 'EdgA' for android version MASK: /\s(Chrome)\/(\d+)\..+\s(?!.*(Edg|EdgA)\/)/, MIN_VERSION: 88 }, [BrowserName.Firefox]: { MASK: /\s(Firefox)\/(\d+)\./, MIN_VERSION: 84 }, [BrowserName.Edge]: { MASK: /\s(Edg)\/(\d+)\./, MIN_VERSION: 88 }, [BrowserName.Opera]: { MASK: /\s(OPR)\/(\d+)\./, MIN_VERSION: 80 }, [BrowserName.Safari]: { MASK: /\sVersion\/(\d{2}\.\d)(.+\s|\s)(Safari)\//, MIN_VERSION: 14 }, [BrowserName.HeadlessChrome]: { // support headless Chrome used by puppeteer MASK: /\s(HeadlessChrome)\/(\d+)\..+\s(?!.*Edg\/)/, // version should be the same as for BrowserName.Chrome MIN_VERSION: 88 } }; /** * Returns chromium brand object or null if not supported. * Chromium because of all browsers based on it should be supported as well * and it is universal way to check it. * * @see {@link https://developer.mozilla.org/en-US/docs/Web/API/NavigatorUAData/brands} * * @param uaDataBrands Array of user agent brand information. * * @returns Chromium brand data object or null if it is not supported. */ const getChromiumBrand = uaDataBrands => { if (!uaDataBrands) { return null; } // for chromium-based browsers const chromiumBrand = uaDataBrands.find(brandData => { return brandData.brand === CHROMIUM_BRAND_NAME || brandData.brand === GOOGLE_CHROME_BRAND_NAME; }); return chromiumBrand || null; }; /** * Parses userAgent string and returns the data object for supported browsers; * otherwise returns null. * * @param userAgent User agent to parse. * * @returns Parsed userAgent data object if browser is supported, otherwise null. */ const parseUserAgent = userAgent => { let browserName; let currentVersion; const browserNames = Object.values(BrowserName); for (let i = 0; i < browserNames.length; i += 1) { let match = null; const name = browserNames[i]; if (name) { var _SUPPORTED_BROWSERS_D; match = (_SUPPORTED_BROWSERS_D = SUPPORTED_BROWSERS_DATA[name]) === null || _SUPPORTED_BROWSERS_D === void 0 ? void 0 : _SUPPORTED_BROWSERS_D.MASK.exec(userAgent); } if (match) { // for safari browser the order is different because of regexp if (match[3] === browserNames[i]) { browserName = match[3]; currentVersion = Number(match[1]); } else { // for others first is name and second is version browserName = match[1]; currentVersion = Number(match[2]); } if (!browserName || !currentVersion) { return null; } return { browserName, currentVersion }; } } return null; }; /** * Returns info about browser. * * @param userAgent User agent of browser. * @param uaDataBrands Array of user agent brand information if supported by browser. * * @returns Data object if browser is supported, otherwise null. */ const getBrowserInfoAsSupported = (userAgent, uaDataBrands) => { const brandData = getChromiumBrand(uaDataBrands); if (!brandData) { const uaInfo = parseUserAgent(userAgent); if (!uaInfo) { return null; } const { browserName, currentVersion } = uaInfo; return { browserName, currentVersion }; } // if navigator.userAgentData is supported const { brand, version } = brandData; // handle chromium-based browsers const browserName = brand === CHROMIUM_BRAND_NAME || brand === GOOGLE_CHROME_BRAND_NAME ? BrowserName.Chrome : brand; return { browserName, currentVersion: Number(version) }; }; /** * Checks whether the browser userAgent and userAgentData.brands is supported. * * @param userAgent User agent of browser. * @param uaDataBrands Array of user agent brand information if supported by browser. * * @returns True if browser is supported. */ const isUserAgentSupported = (userAgent, uaDataBrands) => { var _SUPPORTED_BROWSERS_D2; // do not support Internet Explorer if (userAgent.includes('MSIE') || userAgent.includes('Trident/')) { return false; } // for local testing purposes if (userAgent.includes('jsdom')) { return true; } const browserData = getBrowserInfoAsSupported(userAgent, uaDataBrands); if (!browserData) { return false; } const { browserName, currentVersion } = browserData; if (!browserName || !currentVersion) { return false; } const minVersion = (_SUPPORTED_BROWSERS_D2 = SUPPORTED_BROWSERS_DATA[browserName]) === null || _SUPPORTED_BROWSERS_D2 === void 0 ? void 0 : _SUPPORTED_BROWSERS_D2.MIN_VERSION; if (!minVersion) { return false; } return currentVersion >= minVersion; }; /** * Checks whether the current browser is supported. * * @returns True if *current* browser is supported. */ const isBrowserSupported = () => { var _navigator$userAgentD; return isUserAgentSupported(navigator.userAgent, (_navigator$userAgentD = navigator.userAgentData) === null || _navigator$userAgentD === void 0 ? void 0 : _navigator$userAgentD.brands); }; var CssProperty; (function (CssProperty) { CssProperty["Background"] = "background"; CssProperty["BackgroundImage"] = "background-image"; CssProperty["Content"] = "content"; CssProperty["Opacity"] = "opacity"; })(CssProperty || (CssProperty = {})); const REGEXP_ANY_SYMBOL = '.*'; const REGEXP_WITH_FLAGS_REGEXP = /^\s*\/.*\/[gmisuy]*\s*$/; /** * Removes quotes for specified content value. * * For example, content style declaration with `::before` can be set as '-' (e.g. unordered list) * which displayed as simple dash `-` with no quotes. * But CSSStyleDeclaration.getPropertyValue('content') will return value * wrapped into quotes, e.g. '"-"', which should be removed * because filters maintainers does not use any quotes in real rules. * * @param str Input string. * * @returns String with no quotes for content value. */ const removeContentQuotes = str => { return str.replace(/^(["'])([\s\S]*)\1$/, '$2'); }; /** * Adds quotes for specified background url value. * * If background-image is specified **without** quotes: * e.g. 'background: url(data:image/gif;base64,R0lGODlhAQA7)'. * * CSSStyleDeclaration.getPropertyValue('background-image') may return value **with** quotes: * e.g. 'background: url("data:image/gif;base64,R0lGODlhAQA7")'. * * So we add quotes for compatibility since filters maintainers might use quotes in real rules. * * @param str Input string. * * @returns String with unified quotes for background url value. */ const addUrlPropertyQuotes = str => { if (!str.includes('url("')) { const re = /url\((.*?)\)/g; return str.replace(re, 'url("$1")'); } return str; }; /** * Adds quotes to url arg for consistent property value matching. */ const addUrlQuotesTo = { regexpArg: str => { // e.g. /^url\\([a-z]{4}:[a-z]{5}/ // or /^url\\(data\\:\\image\\/gif;base64.+/ const re = /(\^)?url(\\)?\\\((\w|\[\w)/g; return str.replace(re, '$1url$2\\(\\"?$3'); }, noneRegexpArg: addUrlPropertyQuotes }; /** * Escapes regular expression string. * * @see {@link https://developer.mozilla.org/en/JavaScript/Reference/Global_Objects/regexp} * * @param str Input string. * * @returns Escaped regular expression string. */ const escapeRegExp = str => { // should be escaped . * + ? ^ $ { } ( ) | [ ] / \ // except of * | ^ const specials = ['.', '+', '?', '$', '{', '}', '(', ')', '[', ']', '\\', '/']; const specialsRegex = new RegExp(`[${specials.join('\\')}]`, 'g'); return str.replace(specialsRegex, '\\$&'); }; /** * Converts :matches-css() arg property value match to regexp. * * @param rawValue Style match value pattern. * * @returns Arg of :matches-css() converted to regular expression. */ const convertStyleMatchValueToRegexp = rawValue => { let value; if (rawValue.startsWith(SLASH) && rawValue.endsWith(SLASH)) { // For regex patterns double quotes `"` and backslashes `\` should be escaped value = addUrlQuotesTo.regexpArg(rawValue); value = value.slice(1, -1); } else { // For non-regex patterns parentheses `(` `)` and square brackets `[` `]` // should be unescaped, because their escaping in filter rules is required value = addUrlQuotesTo.noneRegexpArg(rawValue); value = value.replace(/\\([\\()[\]"])/g, '$1'); value = escapeRegExp(value); // e.g. div:matches-css(background-image: url(data:*)) value = replaceAll(value, ASTERISK, REGEXP_ANY_SYMBOL); } return new RegExp(value, 'i'); }; /** * Makes some properties values compatible. * * @param propertyName Name of style property. * @param propertyValue Value of style property. * * @returns Normalized values for some CSS properties. */ const normalizePropertyValue = (propertyName, propertyValue) => { let normalized = ''; switch (propertyName) { case CssProperty.Background: case CssProperty.BackgroundImage: // sometimes url property does not have quotes // so we add them for consistent matching normalized = addUrlPropertyQuotes(propertyValue); break; case CssProperty.Content: normalized = removeContentQuotes(propertyValue); break; case CssProperty.Opacity: // https://bugs.webkit.org/show_bug.cgi?id=93445 normalized = isSafariBrowser ? (Math.round(parseFloat(propertyValue) * 100) / 100).toString() : propertyValue; break; default: normalized = propertyValue; } return normalized; }; /** * Returns domElement style property value * by css property name and standard pseudo-element. * * @param domElement DOM element. * @param propertyName CSS property name. * @param regularPseudoElement Standard pseudo-element — '::before', '::after' etc. * * @returns String containing the value of a specified CSS property. */ const getComputedStylePropertyValue = (domElement, propertyName, regularPseudoElement) => { const style = window.getComputedStyle(domElement, regularPseudoElement); const propertyValue = style.getPropertyValue(propertyName); return normalizePropertyValue(propertyName, propertyValue); }; /** * Parses arg of absolute pseudo-class into 'name' and 'value' if set. * * Used for :matches-css() - with COLON as separator, * for :matches-attr() and :matches-property() - with EQUAL_SIGN as separator. * * @param pseudoArg Arg of pseudo-class. * @param separator Divider symbol. * * @returns Parsed 'matches' pseudo-class arg data. */ const getPseudoArgData = (pseudoArg, separator) => { const index = pseudoArg.indexOf(separator); let name; let value; if (index > -1) { name = pseudoArg.substring(0, index).trim(); value = pseudoArg.substring(index + 1).trim(); } else { name = pseudoArg; } return { name, value }; }; /** * Parses :matches-css() pseudo-class arg * where regular pseudo-element can be a part of arg * e.g. 'div:matches-css(before, color: rgb(255, 255, 255))' <-- obsolete `:matches-css-before()`. * * @param pseudoName Pseudo-class name. * @param rawArg Pseudo-class arg. * * @returns Parsed :matches-css() pseudo-class arg data. * @throws An error on invalid `rawArg`. */ const parseStyleMatchArg = (pseudoName, rawArg) => { const { name, value } = getPseudoArgData(rawArg, COMMA); let regularPseudoElement = name; let styleMatchArg = value; // check whether the string part before the separator is valid regular pseudo-element, // otherwise `regularPseudoElement` is null, and `styleMatchArg` is rawArg if (!Object.values(REGULAR_PSEUDO_ELEMENTS).includes(name)) { regularPseudoElement = null; styleMatchArg = rawArg; } if (!styleMatchArg) { throw new Error(`Required style property argument part is missing in :${pseudoName}() arg: '${rawArg}'`); } // if regularPseudoElement is not `null` if (regularPseudoElement) { // pseudo-element should have two colon marks for Window.getComputedStyle() due to the syntax: // https://www.w3.org/TR/selectors-4/#pseudo-element-syntax // ':matches-css(before, content: ads)' ->> '::before' regularPseudoElement = `${COLON}${COLON}${regularPseudoElement}`; } return { regularPseudoElement, styleMatchArg }; }; /** * Checks whether the domElement is matched by :matches-css() arg. * * @param argsData Pseudo-class name, arg, and dom element to check. * @returns True if DOM element is matched. * @throws An error on invalid pseudo-class arg. */ const isStyleMatched = argsData => { const { pseudoName, pseudoArg, domElement } = argsData; const { regularPseudoElement, styleMatchArg } = parseStyleMatchArg(pseudoName, pseudoArg); const { name: matchName, value: matchValue } = getPseudoArgData(styleMatchArg, COLON); if (!matchName || !matchValue) { throw new Error(`Required property name or value is missing in :${pseudoName}() arg: '${styleMatchArg}'`); } let valueRegexp; try { valueRegexp = convertStyleMatchValueToRegexp(matchValue); } catch (e) { logger.error(e); throw new Error(`Invalid argument of :${pseudoName}() pseudo-class: '${styleMatchArg}'`); } const value = getComputedStylePropertyValue(domElement, matchName, regularPseudoElement); return valueRegexp && valueRegexp.test(value); }; /** * Validates string arg for :matches-attr() and :matches-property(). * * @param arg Pseudo-class arg. * * @returns True if 'matches' pseudo-class string arg is valid. */ const validateStrMatcherArg = arg => { if (arg.includes(SLASH)) { return false; } if (!/^[\w-]+$/.test(arg)) { return false; } return true; }; /** * Returns valid arg for :matches-attr() and :matcher-property(). * * @param rawArg Arg pattern. * @param [isWildcardAllowed=false] Flag for wildcard (`*`) using as pseudo-class arg. * * @returns Valid arg for :matches-attr() and :matcher-property(). * @throws An error on invalid `rawArg`. */ const getValidMatcherArg = function (rawArg) { let isWildcardAllowed = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : false; // if rawArg is missing for pseudo-class // e.g. :matches-attr() // error will be thrown before getValidMatcherArg() is called: // name or arg is missing in AbsolutePseudoClass let arg; if (rawArg.length > 1 && rawArg.startsWith(DOUBLE_QUOTE) && rawArg.endsWith(DOUBLE_QUOTE)) { rawArg = rawArg.slice(1, -1); } if (rawArg === '') { // e.g. :matches-property("") throw new Error('Argument should be specified. Empty arg is invalid.'); } if (rawArg.startsWith(SLASH) && rawArg.endsWith(SLASH)) { // e.g. :matches-property("//") if (rawArg.length > 2) { arg = toRegExp(rawArg); } else { throw new Error(`Invalid regexp: '${rawArg}'`); } } else if (rawArg.includes(ASTERISK)) { if (rawArg === ASTERISK && !isWildcardAllowed) { // e.g. :matches-attr(*) throw new Error(`Argument should be more specific than ${rawArg}`); } arg = replaceAll(rawArg, ASTERISK, REGEXP_ANY_SYMBOL); arg = new RegExp(arg); } else { if (!validateStrMatcherArg(rawArg)) { throw new Error(`Invalid argument: '${rawArg}'`); } arg = rawArg; } return arg; }; /** * Parses pseudo-class argument and returns parsed data. * * @param pseudoName Extended pseudo-class name. * @param pseudoArg Extended pseudo-class argument. * * @returns Parsed pseudo-class argument data. * @throws An error if attribute name is missing in pseudo-class arg. */ const getRawMatchingData = (pseudoName, pseudoArg) => { const { name: rawName, value: rawValue } = getPseudoArgData(pseudoArg, EQUAL_SIGN); if (!rawName) { throw new Error(`Required attribute name is missing in :${pseudoName} arg: ${pseudoArg}`); } return { rawName, rawValue }; }; /** * Checks whether the domElement is matched by :matches-attr() arg. * * @param argsData Pseudo-class name, arg, and dom element to check. * @returns True if DOM element is matched. * @throws An error on invalid arg of pseudo-class. */ const isAttributeMatched = argsData => { const { pseudoName, pseudoArg, domElement } = argsData; const elementAttributes = domElement.attributes; // no match if dom element has no attributes if (elementAttributes.length === 0) { return false; } const { rawName: rawAttrName, rawValue: rawAttrValue } = getRawMatchingData(pseudoName, pseudoArg); let attrNameMatch; try { attrNameMatch = getValidMatcherArg(rawAttrName); } catch (e) { // eslint-disable-line @typescript-eslint/no-explicit-any logger.error(e); throw new SyntaxError(e.message); } let isMatched = false; let i = 0; while (i < elementAttributes.length && !isMatched) { const attr = elementAttributes[i]; if (!attr) { break; } const isNameMatched = attrNameMatch instanceof RegExp ? attrNameMatch.test(attr.name) : attrNameMatch === attr.name; if (!rawAttrValue) { // for rules with no attribute value specified // e.g. :matches-attr("/regex/") or :matches-attr("attr-name") isMatched = isNameMatched; } else { let attrValueMatch; try { attrValueMatch = getValidMatcherArg(rawAttrValue); } catch (e) { // eslint-disable-line @typescript-eslint/no-explicit-any logger.error(e); throw new SyntaxError(e.message); } const isValueMatched = attrValueMatch instanceof RegExp ? attrValueMatch.test(attr.value) : attrValueMatch === attr.value; isMatched = isNameMatched && isValueMatched; } i += 1; } return isMatched; }; /** * Parses raw :matches-property() arg which may be chain of properties. * * @param input Argument of :matches-property(). * * @returns Arg of :matches-property() as array of strings or regular expressions. * @throws An error on invalid chain. */ const parseRawPropChain = input => { if (input.length > 1 && input.startsWith(DOUBLE_QUOTE) && input.endsWith(DOUBLE_QUOTE)) { input = input.slice(1, -1); } const chainChunks = input.split(DOT); const chainPatterns = []; let patternBuffer = ''; let isRegexpPattern = false; let i = 0; while (i < chainChunks.length) { const chunk = getItemByIndex(chainChunks, i, `Invalid pseudo-class arg: '${input}'`); if (chunk.startsWith(SLASH) && chunk.endsWith(SLASH) && chunk.length > 2) { // regexp pattern with no dot in it, e.g. /propName/ chainPatterns.push(chunk); } else if (chunk.startsWith(SLASH)) { // if chunk is a start of regexp pattern isRegexpPattern = true; patternBuffer += chunk; } else if (chunk.endsWith(SLASH)) { isRegexpPattern = false; // restore dot removed while splitting // e.g. testProp./.{1,5}/ patternBuffer += `.${chunk}`; chainPatterns.push(patternBuffer); patternBuffer = ''; } else { // if there are few dots in regexp pattern // so chunk might be in the middle of it if (isRegexpPattern) { patternBuffer += chunk; } else { // otherwise it is string pattern chainPatterns.push(chunk); } } i += 1; } if (patternBuffer.length > 0) { throw new Error(`Invalid regexp property pattern '${input}'`); } const chainMatchPatterns = chainPatterns.map(pattern => { if (pattern.length === 0) { // e.g. '.prop.id' or 'nested..test' throw new Error(`Empty pattern '${pattern}' is invalid in chain '${input}'`); } let validPattern; try { validPattern = getValidMatcherArg(pattern, true); } catch (e) { logger.error(e); throw new Error(`Invalid property pattern '${pattern}' in property chain '${input}'`); } return validPattern; }); return chainMatchPatterns; }; /** * Checks if the property exists in the base object (recursively). * * @param base Element to check. * @param chain Array of objects - parsed string property chain. * @param [output=[]] R###lt acc. * * @returns Array of parsed data — representation of `base`-related `chain`. */ const filterRootsByRegexpChain = function (base, chain) { let output = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : []; const tempProp = getFirst(chain); if (chain.length === 1) { let key; for (key in base) { if (tempProp instanceof RegExp) { if (tempProp.test(key)) { output.push({ base, prop: key, value: base[key] }); } } else if (tempProp === key) { output.push({ base, prop: tempProp, value: base[key] }); } } return output; } // if there is a regexp prop in input chain // e.g. 'unit./^ad.+/.src' for 'unit.ad-1gf2.src unit.ad-fgd34.src'), // every base keys should be tested by regexp and it can be more that one r###lts if (tempProp instanceof RegExp) { const nextProp = chain.slice(1); const baseKeys = []; for (const key in base) { if (tempProp.test(key)) { baseKeys.push(key); } } baseKeys.forEach(key => { var _Object$getOwnPropert; const item = (_Object$getOwnPropert = Object.getOwnPropertyDescriptor(base, key)) === null || _Object$getOwnPropert === void 0 ? void 0 : _Object$getOwnPropert.value; filterRootsByRegexpChain(item, nextProp, output); }); } if (base && typeof tempProp === 'string') { var _Object$getOwnPropert2; const nextBase = (_Object$getOwnPropert2 = Object.getOwnPropertyDescriptor(base, tempProp)) === null || _Object$getOwnPropert2 === void 0 ? void 0 : _Object$getOwnPropert2.value; chain = chain.slice(1); if (nextBase !== undefined) { filterRootsByRegexpChain(nextBase, chain, output); } } return output; }; /** * Checks whether the domElement is matched by :matches-property() arg. * * @param argsData Pseudo-class name, arg, and dom element to check. * @returns True if DOM element is matched. * @throws An error on invalid prop in chain. */ const isPropertyMatched = argsData => { const { pseudoName, pseudoArg, domElement } = argsData; const { rawName: rawPropertyName, rawValue: rawPropertyValue } = getRawMatchingData(pseudoName, pseudoArg); // chained property name cannot include '/' or '.' // so regex prop names with such escaped characters are invalid if (rawPropertyName.includes('\\/') || rawPropertyName.includes('\\.')) { throw new Error(`Invalid :${pseudoName} name pattern: ${rawPropertyName}`); } let propChainMatches; try { propChainMatches = parseRawPropChain(rawPropertyName); } catch (e) { // eslint-disable-line @typescript-eslint/no-explicit-any logger.error(e); throw new SyntaxError(e.message); } const ownerObjArr = filterRootsByRegexpChain(domElement, propChainMatches); if (ownerObjArr.length === 0) { return false; } let isMatched = true; if (rawPropertyValue) { let propValueMatch; try { propValueMatch = getValidMatcherArg(rawPropertyValue); } catch (e) { // eslint-disable-line @typescript-eslint/no-explicit-any logger.error(e); throw new SyntaxError(e.message); } if (propValueMatch) { for (let i = 0; i < ownerObjArr.length; i += 1) { var _ownerObjArr$i; const realValue = (_ownerObjArr$i = ownerObjArr[i]) === null || _ownerObjArr$i === void 0 ? void 0 : _ownerObjArr$i.value; if (propValueMatch instanceof RegExp) { isMatched = propValueMatch.test(convertTypeIntoString(realValue)); } else { // handle 'null' and 'undefined' property values set as string if (realValue === 'null' || realValue === 'undefined') { isMatched = propValueMatch === realValue; break; } isMatched = convertTypeFromString(propValueMatch) === realValue; } if (isMatched) { break; } } } } return isMatched; }; /** * Checks whether the textContent is matched by :contains arg. * * @param argsData Pseudo-class name, arg, and dom element to check. * @returns True if DOM element is matched. * @throws An error on invalid arg of pseudo-class. */ const isTextMatched = argsData => { const { pseudoName, pseudoArg, domElement } = argsData; const textContent = getNodeTextContent(domElement); let isTextContentMatched; let pseudoArgToMatch = pseudoArg; if (pseudoArgToMatch.startsWith(SLASH) && REGEXP_WITH_FLAGS_REGEXP.test(pseudoArgToMatch)) { // regexp arg const flagsIndex = pseudoArgToMatch.lastIndexOf('/'); const flagsStr = pseudoArgToMatch.substring(flagsIndex + 1); pseudoArgToMatch = pseudoArgToMatch.substring(0, flagsIndex + 1).slice(1, -1).replace(/\\([\\"])/g, '$1'); let regex; try { regex = new RegExp(pseudoArgToMatch, flagsStr); } catch (e) { throw new Error(`Invalid argument of :${pseudoName}() pseudo-class: ${pseudoArg}`); } isTextContentMatched = regex.test(textContent); } else { // none-regexp arg pseudoArgToMatch = pseudoArgToMatch.replace(/\\([\\()[\]"])/g, '$1'); isTextContentMatched = textContent.includes(pseudoArgToMatch); } return isTextContentMatched; }; /** * Validates number arg for :nth-ancestor() and :upward() pseudo-classes. * * @param rawArg Raw arg of pseudo-class. * @param pseudoName Pseudo-class name. * * @returns Valid number arg for :nth-ancestor() and :upward(). * @throws An error on invalid `rawArg`. */ const getValidNumberAncestorArg = (rawArg, pseudoName) => { const deep = Number(rawArg); if (Number.isNaN(deep) || deep < 1 || deep >= 256) { throw new Error(`Invalid argument of :${pseudoName} pseudo-class: '${rawArg}'`); } return deep; }; /** * Returns nth ancestor by 'deep' number arg OR undefined if ancestor range limit exceeded. * * @param domElement DOM element to find ancestor for. * @param nth Depth up to needed ancestor. * @param pseudoName Pseudo-class name. * * @returns Ancestor element found in DOM, or null if not found. * @throws An error on invalid `nth` arg. */ const getNthAncestor = (domElement, nth, pseudoName) => { let ancestor = null; let i = 0; while (i < nth) { ancestor = domElement.parentElement; if (!ancestor) { throw new Error(`Out of DOM: Argument of :${pseudoName}() pseudo-class is too big — '${nth}'.`); } domElement = ancestor; i += 1; } return ancestor; }; /** * Validates standard CSS selector. * * @param selector Standard selector. * * @returns True if standard CSS selector is valid. */ const validateStandardSelector = selector => { let isValid; try { document.querySelectorAll(selector); isValid = true; } catch (e) { isValid = false; } return isValid; }; /** * Wrapper to run matcher `callback` with `args` * and throw error with `errorMessage` if `callback` run fails. * * @param callback Matcher callback. * @param argsData Args needed for matcher callback. * @param errorMessage Error message. * * @returns True if `callback` returns true. * @throws An error if `callback` fails. */ const matcherWrapper = (callback, argsData, errorMessage) => { let isMatched; try { isMatched = callback(argsData); } catch (e) { logger.error(e); throw new Error(errorMessage); } return isMatched; }; /** * Generates common error message to throw while matching element `propDesc`. * * @param propDesc Text to describe what element 'prop' pseudo-class is trying to match. * @param pseudoName Pseudo-class name. * @param pseudoArg Pseudo-class arg. * * @returns Generated error message string. */ const getAbsolutePseudoError = (propDesc, pseudoName, pseudoArg) => { // eslint-disable-next-line max-len return `${MATCHING_ELEMENT_ERROR_PREFIX} ${propDesc}, may be invalid :${pseudoName}() pseudo-class arg: '${pseudoArg}'`; }; /** * Checks whether the domElement is matched by absolute extended pseudo-class argument. * * @param domElement Page element. * @param pseudoName Pseudo-class name. * @param pseudoArg Pseudo-class arg. * * @returns True if `domElement` is matched by absolute pseudo-class. * @throws An error on unknown absolute pseudo-class. */ const isMatchedByAbsolutePseudo = (domElement, pseudoName, pseudoArg) => { let argsData; let errorMessage; let callback; switch (pseudoName) { case CONTAINS_PSEUDO: case HAS_TEXT_PSEUDO: case ABP_CONTAINS_PSEUDO: callback = isTextMatched; argsData = { pseudoName, pseudoArg, domElement }; errorMessage = getAbsolutePseudoError('text content', pseudoName, pseudoArg); break; case MATCHES_CSS_PSEUDO: case MATCHES_CSS_AFTER_PSEUDO: case MATCHES_CSS_BEFORE_PSEUDO: callback = isStyleMatched; argsData = { pseudoName, pseudoArg, domElement }; errorMessage = getAbsolutePseudoError('style', pseudoName, pseudoArg); break; case MATCHES_ATTR_PSEUDO_CLASS_MARKER: callback = isAttributeMatched; argsData = { domElement, pseudoName, pseudoArg }; errorMessage = getAbsolutePseudoError('attributes', pseudoName, pseudoArg); break; case MATCHES_PROPERTY_PSEUDO_CLASS_MARKER: callback = isPropertyMatched; argsData = { domElement, pseudoName, pseudoArg }; errorMessage = getAbsolutePseudoError('properties', pseudoName, pseudoArg); break; default: throw new Error(`Unknown absolute pseudo-class :${pseudoName}()`); } return matcherWrapper(callback, argsData, errorMessage); }; const findByAbsolutePseudoPseudo = { /** * Returns list of nth ancestors relative to every dom node from domElements list. * * @param domElements DOM elements. * @param rawPseudoArg Number arg of :nth-ancestor() or :upward() pseudo-class. * @param pseudoName Pseudo-class name. * * @returns Array of ancestor DOM elements. */ nthAncestor: (domElements, rawPseudoArg, pseudoName) => { const deep = getValidNumberAncestorArg(rawPseudoArg, pseudoName); const ancestors = domElements.map(domElement => { let ancestor = null; try { ancestor = getNthAncestor(domElement, deep, pseudoName); } catch (e) { logger.error(e); } return ancestor; }).filter(isHtmlElement); return ancestors; }, /** * Returns list of elements by xpath expression, evaluated on every dom node from domElements list. * * @param domElements DOM elements. * @param rawPseudoArg Arg of :xpath() pseudo-class. * * @returns Array of DOM elements matched by xpath expression. */ xpath: (domElements, rawPseudoArg) => { const foundElements = domElements.map(domElement => { const r###lt = []; let xpathR###lt; try { xpathR###lt = document.evaluate(rawPseudoArg, domElement, null, window.XPathR###lt.UNORDERED_NODE_ITERATOR_TYPE, null); } catch (e) { logger.error(e); throw new Error(`Invalid argument of :xpath pseudo-class: '${rawPseudoArg}'`); } let node = xpathR###lt.iterateNext(); while (node) { if (isHtmlElement(node)) { r###lt.push(node); } node = xpathR###lt.iterateNext(); } return r###lt; }); return flatten(foundElements); }, /** * Returns list of closest ancestors relative to every dom node from domElements list. * * @param domElements DOM elements. * @param rawPseudoArg Standard selector arg of :upward() pseudo-class. * * @returns Array of closest ancestor DOM elements. * @throws An error if `rawPseudoArg` is not a valid standard selector. */ upward: (domElements, rawPseudoArg) => { if (!validateStandardSelector(rawPseudoArg)) { throw new Error(`Invalid argument of :upward pseudo-class: '${rawPseudoArg}'`); } const closestAncestors = domElements.map(domElement => { // closest to parent element should be found // otherwise `.base:upward(.base)` will return itself too, not only ancestor const parent = domElement.parentElement; if (!parent) { return null; } return parent.closest(rawPseudoArg); }).filter(isHtmlElement); return closestAncestors; } }; /** * Calculated selector text which is needed to :has(), :is() and :not() pseudo-classes. * Contains calculated part (depends on the processed element) * and value of RegularSelector which is next to selector by. * * Native Document.querySelectorAll() does not select exact descendant elements * but match all page elements satisfying the selector, * so extra specification is needed for proper descendants selection * e.g. 'div:has(> img)'. * * Its calculation depends on extended selector. */ /** * Combined `:scope` pseudo-class and **child** combinator — `:scope>`. */ const scopeDirectChildren = `${SCOPE_CSS_PSEUDO_CLASS}${CHILD_COMBINATOR}`; /** * Combined `:scope` pseudo-class and **descendant** combinator — `:scope `. */ const scopeAnyChildren = `${SCOPE_CSS_PSEUDO_CLASS}${DESCENDANT_COMBINATOR}`; /** * Interface for relative pseudo-class helpers args. */ /** * Returns the first of RegularSelector child node for `selectorNode`. * * @param selectorNode Ast Selector node. * @param pseudoName Name of relative pseudo-class. * * @returns Ast RegularSelector node. */ const getFirstInnerRegularChild = (selectorNode, pseudoName) => { return getFirstRegularChild(selectorNode.children, `RegularSelector is missing for :${pseudoName}() pseudo-class`); }; // TODO: fix for <forgiving-relative-selector-list> // https://github.com/AdguardTeam/ExtendedCss/issues/154 /** * Checks whether the element has all relative elements specified by pseudo-class arg. * Used for :has() pseudo-class. * * @param argsData Relative pseudo-class helpers args data. * * @returns True if **all selectors** from argsData.relativeSelectorList is **matched** for argsData.element. */ const hasRelativesBySelectorList = argsData => { const { element, relativeSelectorList, pseudoName } = argsData; return relativeSelectorList.children // Array.every() is used here as each Selector node from SelectorList should exist on page .every(selectorNode => { // selectorList.children always starts with regular selector as any selector generally const relativeRegularSelector = getFirstInnerRegularChild(selectorNode, pseudoName); let specifiedSelector = ''; let rootElement = null; const regularSelector = getNodeValue(relativeRegularSelector); if (regularSelector.startsWith(NEXT_SIBLING_COMBINATOR) || regularSelector.startsWith(SUBSEQUENT_SIBLING_COMBINATOR)) { /** * For matching the element by "element:has(+ next-sibling)" and "element:has(~ sibling)" * we check whether the element's parentElement has specific direct child combination, * e.g. 'h1:has(+ .share)' -> `h1Node.parentElement.querySelectorAll(':scope > h1 + .share')`. * * @see {@link https://www.w3.org/TR/selectors-4/#relational} */ rootElement = element.parentElement; const elementSelectorText = getElementSelectorDesc(element); specifiedSelector = `${scopeDirectChildren}${elementSelectorText}${regularSelector}`; } else if (regularSelector === ASTERISK) { /** * :scope specification is needed for proper descendants selection * as native element.querySelectorAll() does not select exact element descendants * e.g. 'a:has(> img)' -> `aNode.querySelectorAll(':scope > img')`. * * For 'any selector' as arg of relative simplicity should be set for all inner elements * e.g. 'div:has(*)' -> `divNode.querySelectorAll(':scope *')` * which means empty div with no child element. */ rootElement = element; specifiedSelector = `${scopeAnyChildren}${ASTERISK}`; } else { /** * As it described above, inner elements should be found using `:scope` pseudo-class * e.g. 'a:has(> img)' -> `aNode.querySelectorAll(':scope > img')` * OR '.block(div > span)' -> `blockClassNode.querySelectorAll(':scope div > span')`. */ specifiedSelector = `${scopeAnyChildren}${regularSelector}`; rootElement = element; } if (!rootElement) { throw new Error(`Selection by :${pseudoName}() pseudo-class is not possible`); } let relativeElements; try { // eslint-disable-next-line @typescript-eslint/no-use-before-define relativeElements = getElementsForSelectorNode(selectorNode, rootElement, specifiedSelector); } catch (e) { logger.error(e); // fail for invalid selector throw new Error(`Invalid selector for :${pseudoName}() pseudo-class: '${regularSelector}'`); } return relativeElements.length > 0; }); }; /** * Checks whether the element is an any element specified by pseudo-class arg. * Used for :is() pseudo-class. * * @param argsData Relative pseudo-class helpers args data. * * @returns True if **any selector** from argsData.relativeSelectorList is **matched** for argsData.element. */ const isAnyElementBySelectorList = argsData => { const { element, relativeSelectorList, pseudoName } = argsData; return relativeSelectorList.children // Array.some() is used here as any selector from selector list should exist on page .some(selectorNode => { // selectorList.children always starts with regular selector const relativeRegularSelector = getFirstInnerRegularChild(selectorNode, pseudoName); /** * For checking the element by 'div:is(.banner)' * we check whether the element's parentElement has any specific direct child. */ const rootElement = getParent(element, `Selection by :${pseudoName}() pseudo-class is not possible`); /** * So we calculate the element "description" by it's tagname and attributes for targeting * and use it to specify the selection * e.g. `div:is(.banner)` --> `divNode.parentElement.querySelectorAll(':scope > .banner')`. */ const specifiedSelector = `${scopeDirectChildren}${getNodeValue(relativeRegularSelector)}`; let anyElements; try { // eslint-disable-next-line @typescript-eslint/no-use-before-define anyElements = getElementsForSelectorNode(selectorNode, rootElement, specifiedSelector); } catch (e) { // do not fail on invalid selectors for :is() return false; } // TODO: figure out how to handle complex selectors with extended pseudo-classes // (check readme - extended-css-is-limitations) // because `element` and `anyElements` may be from different DOM levels return anyElements.includes(element); }); }; /** * Checks whether the element is not an element specified by pseudo-class arg. * Used for :not() pseudo-class. * * @param argsData Relative pseudo-class helpers args data. * * @returns True if **any selector** from argsData.relativeSelectorList is **not matched** for argsData.element. */ const notElementBySelectorList = argsData => { const { element, relativeSelectorList, pseudoName } = argsData; return relativeSelectorList.children // Array.every() is used here as element should not be selected by any selector from selector list .every(selectorNode => { // selectorList.children always starts with regular selector const relativeRegularSelector = getFirstInnerRegularChild(selectorNode, pseudoName); /** * For checking the element by 'div:not([data="content"]) * we check whether the element's parentElement has any specific direct child. */ const rootElement = getParent(element, `Selection by :${pseudoName}() pseudo-class is not possible`); /** * So we calculate the element "description" by it's tagname and attributes for targeting * and use it to specify the selection * e.g. `div:not(.banner)` --> `divNode.parentElement.querySelectorAll(':scope > .banner')`. */ const specifiedSelector = `${scopeDirectChildren}${getNodeValue(relativeRegularSelector)}`; let anyElements; try { // eslint-disable-next-line @typescript-eslint/no-use-before-define anyElements = getElementsForSelectorNode(selectorNode, rootElement, specifiedSelector); } catch (e) { // fail on invalid selectors for :not() logger.error(e); // eslint-disable-next-line max-len throw new Error(`Invalid selector for :${pseudoName}() pseudo-class: '${getNodeValue(relativeRegularSelector)}'`); } // TODO: figure out how to handle up-looking pseudo-classes inside :not() // (check readme - extended-css-not-limitations) // because `element` and `anyElements` may be from different DOM levels return !anyElements.includes(element); }); }; /** * Selects dom elements by value of RegularSelector. * * @param regularSelectorNode RegularSelector node. * @param root Root DOM element. * @param specifiedSelector @see {@link SpecifiedSelector}. * * @returns Array of DOM elements. * @throws An error if RegularSelector node value is an invalid selector. */ const getByRegularSelector = (regularSelectorNode, root, specifiedSelector) => { const selectorText = specifiedSelector ? specifiedSelector : getNodeValue(regularSelectorNode); let selectedElements = []; try { selectedElements = Array.from(root.querySelectorAll(selectorText)); } catch (e) { // eslint-disable-line @typescript-eslint/no-explicit-any throw new Error(`Error: unable to select by '${selectorText}' — ${e.message}`); } return selectedElements; }; /** * Returns list of dom elements filtered or selected by ExtendedSelector node. * * @param domElements Array of DOM elements. * @param extendedSelectorNode ExtendedSelector node. * * @returns Array of DOM elements. * @throws An error on unknown pseudo-class, * absent or invalid arg of extended pseudo-class, etc. */ const getByExtendedSelector = (domElements, extendedSelectorNode) => { let foundElements = []; const extendedPseudoClassNode = getPseudoClassNode(extendedSelectorNode); const pseudoName = getNodeName(extendedPseudoClassNode); if (isAbsolutePseudoClass(pseudoName)) { // absolute extended pseudo-classes should have an argument const absolutePseudoArg = getNodeValue(extendedPseudoClassNode, `Missing arg for :${pseudoName}() pseudo-class`); if (pseudoName === NTH_ANCESTOR_PSEUDO_CLASS_MARKER) { // :nth-ancestor() foundElements = findByAbsolutePseudoPseudo.nthAncestor(domElements, absolutePseudoArg, pseudoName); } else if (pseudoName === XPATH_PSEUDO_CLASS_MARKER) { // :xpath() try { document.createExpression(absolutePseudoArg, null); } catch (e) { throw new Error(`Invalid argument of :${pseudoName}() pseudo-class: '${absolutePseudoArg}'`); } foundElements = findByAbsolutePseudoPseudo.xpath(domElements, absolutePseudoArg); } else if (pseudoName === UPWARD_PSEUDO_CLASS_MARKER) { // :upward() if (Number.isNaN(Number(absolutePseudoArg))) { // so arg is selector, not a number foundElements = findByAbsolutePseudoPseudo.upward(domElements, absolutePseudoArg); } else { foundElements = findByAbsolutePseudoPseudo.nthAncestor(domElements, absolutePseudoArg, pseudoName); } } else { // all other absolute extended pseudo-classes // e.g. contains, matches-attr, etc. foundElements = domElements.filter(element => { return isMatchedByAbsolutePseudo(element, pseudoName, absolutePseudoArg); }); } } else if (isRelativePseudoClass(pseudoName)) { const relativeSelectorList = getRelativeSelectorListNode(extendedPseudoClassNode); let relativePredicate; switch (pseudoName) { case HAS_PSEUDO_CLASS_MARKER: case ABP_HAS_PSEUDO_CLASS_MARKER: relativePredicate = element => hasRelativesBySelectorList({ element, relativeSelectorList, pseudoName }); break; case IS_PSEUDO_CLASS_MARKER: relativePredicate = element => isAnyElementBySelectorList({ element, relativeSelectorList, pseudoName }); break; case NOT_PSEUDO_CLASS_MARKER: relativePredicate = element => notElementBySelectorList({ element, relativeSelectorList, pseudoName }); break; default: throw new Error(`Unknown relative pseudo-class: '${pseudoName}'`); } foundElements = domElements.filter(relativePredicate); } else { // extra check is parser missed something throw new Error(`Unknown extended pseudo-class: '${pseudoName}'`); } return foundElements; }; /** * Returns list of dom elements which is selected by RegularSelector value. * * @param domElements Array of DOM elements. * @param regularSelectorNode RegularSelector node. * * @returns Array of DOM elements. * @throws An error if RegularSelector has not value. */ const getByFollowingRegularSelector = (domElements, regularSelectorNode) => { // array of arrays because of Array.map() later let foundElements = []; const value = getNodeValue(regularSelectorNode); if (value.startsWith(CHILD_COMBINATOR)) { // e.g. div:has(> img) > .banner foundElements = domElements.map(root => { const specifiedSelector = `${SCOPE_CSS_PSEUDO_CLASS}${value}`; return getByRegularSelector(regularSelectorNode, root, specifiedSelector); }); } else if (value.startsWith(NEXT_SIBLING_COMBINATOR) || value.startsWith(SUBSEQUENT_SIBLING_COMBINATOR)) { // e.g. div:has(> img) + .banner // or div:has(> img) ~ .banner foundElements = domElements.map(element => { const rootElement = element.parentElement; if (!rootElement) { // do not throw error if there in no parent for element // e.g. '*:contains(text)' selects `html` which has no parentElement return []; } const elementSelectorText = getElementSelectorDesc(element); const specifiedSelector = `${scopeDirectChildren}${elementSelectorText}${value}`; const selected = getByRegularSelector(regularSelectorNode, rootElement, specifiedSelector); return selected; }); } else { // space-separated regular selector after extended one // e.g. div:has(> img) .banner foundElements = domElements.map(root => { const specifiedSelector = `${scopeAnyChildren}${getNodeValue(regularSelectorNode)}`; return getByRegularSelector(regularSelectorNode, root, specifiedSelector); }); } // foundElements should be flattened // as getByRegularSelector() returns elements array, and Array.map() collects them to array return flatten(foundElements); }; /** * Returns elements nodes for Selector node. * As far as any selector always starts with regular part, * it selects by RegularSelector first and checks found elements later. * * Relative pseudo-classes has it's own subtree so getElementsForSelectorNode is called recursively. * * 'specifiedSelector' is needed for :has(), :is(), and :not() pseudo-classes * as native querySelectorAll() does not select exact element descendants even if it is called on 'div' * e.g. ':scope' specification is needed for proper descendants selection for 'div:has(> img)'. * So we check `divNode.querySelectorAll(':scope > img').length > 0`. * * @param selectorNode Selector node. * @param root Root DOM element. * @param specifiedSelector Needed element specification. * * @returns Array of DOM elements. * @throws An error if there is no selectorNodeChild. */ const getElementsForSelectorNode = (selectorNode, root, specifiedSelector) => { let selectedElements = []; let i = 0; while (i < selectorNode.children.length) { const selectorNodeChild = getItemByIndex(selectorNode.children, i, 'selectorNodeChild should be specified'); if (i === 0) { // any selector always starts with regular selector selectedElements = getByRegularSelector(selectorNodeChild, root, specifiedSelector); } else if (isExtendedSelectorNode(selectorNodeChild)) { // filter previously selected elements by next selector nodes selectedElements = getByExtendedSelector(selectedElements, selectorNodeChild); } else if (isRegularSelectorNode(selectorNodeChild)) { selectedElements = getByFollowingRegularSelector(selectedElements, selectorNodeChild); } i += 1; } return selectedElements; }; /** * Selects elements by ast. * * @param ast Ast of parsed selector. * @param doc Document. * * @returns Array of DOM elements. */ const selectElementsByAst = function (ast) { let doc = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : document; const selectedElements = []; // ast root is SelectorList node; // it has Selector nodes as children which should be processed separately ast.children.forEach(selectorNode => { selectedElements.push(...getElementsForSelectorNode(selectorNode, doc)); }); // selectedElements should be flattened as it is array of arrays with elements const uniqueElements = [...new Set(flatten(selectedElements))]; return uniqueElements; }; /** * Class of ExtCssDocument is needed for caching. * For making cache related to each new instance of class, not global. */ class ExtCssDocument { /** * Cache with selectors and their AST parsing r###lts. */ /** * Creates new ExtCssDocument and inits new `astCache`. */ constructor() { this.astCache = new Map(); } /** * Saves selector and it's ast to cache. * * @param selector Standard or extended selector. * @param ast Selector ast. */ saveAstToCache(selector, ast) { this.astCache.set(selector, ast); } /** * Returns ast from cache for given selector. * * @param selector Standard or extended selector. * * @returns Previously parsed ast found in cache, or null if not found. */ getAstFromCache(selector) { const cachedAst = this.astCache.get(selector) || null; return cachedAst; } /** * Returns selector ast: * - if cached ast exists — returns it; * - if no cached ast — saves newly parsed ast to cache and returns it. * * @param selector Standard or extended selector. * * @returns Ast for `selector`. */ getSelectorAst(selector) { let ast = this.getAstFromCache(selector); if (!ast) { ast = parse$1(selector); } this.saveAstToCache(selector, ast); return ast; } /** * Selects elements by selector. * * @param selector Standard or extended selector. * * @returns Array of DOM elements. */ querySelectorAll(selector) { const ast = this.getSelectorAst(selector); return selectElementsByAst(ast); } } const extCssDocument = new ExtCssDocument(); /** * Checks the presence of :remove() pseudo-class and validates it while parsing the selector part of css rule. * * @param rawSelector Selector which may contain :remove() pseudo-class. * * @returns Parsed selector data with selector and styles. * @throws An error on invalid :remove() position. */ const parseRemoveSelector = rawSelector => { /** * No error will be thrown on invalid selector as it will be validated later * so it's better to explicitly specify 'any' selector for :remove() pseudo-class by '*', * e.g. '.banner > *:remove()' instead of '.banner > :remove()'. */ // ':remove()' // eslint-disable-next-line max-len const VALID_REMOVE_MARKER = `${COLON}${REMOVE_PSEUDO_MARKER}${BRACKETS.PARENTHESES.LEFT}${BRACKETS.PARENTHESES.RIGHT}`; // ':remove(' - needed for validation rules like 'div:remove(2)' const INVALID_REMOVE_MARKER = `${COLON}${REMOVE_PSEUDO_MARKER}${BRACKETS.PARENTHESES.LEFT}`; let selector; let shouldRemove = false; const firstIndex = rawSelector.indexOf(VALID_REMOVE_MARKER); if (firstIndex === 0) { // e.g. ':remove()' throw new Error(`${REMOVE_ERROR_PREFIX.NO_TARGET_SELECTOR}: '${rawSelector}'`); } else if (firstIndex > 0) { if (firstIndex !== rawSelector.lastIndexOf(VALID_REMOVE_MARKER)) { // rule with more than one :remove() pseudo-class is invalid // e.g. '.block:remove() > .banner:remove()' throw new Error(`${REMOVE_ERROR_PREFIX.MULTIPLE_USAGE}: '${rawSelector}'`); } else if (firstIndex + VALID_REMOVE_MARKER.length < rawSelector.length) { // remove pseudo-class should be last in the rule // e.g. '.block:remove():upward(2)' throw new Error(`${REMOVE_ERROR_PREFIX.INVALID_POSITION}: '${rawSelector}'`); } else { // valid :remove() pseudo-class position selector = rawSelector.substring(0, firstIndex); shouldRemove = true; } } else if (rawSelector.includes(INVALID_REMOVE_MARKER)) { // it is not valid if ':remove()' is absent in rule but just ':remove(' is present // e.g. 'div:remove(0)' throw new Error(`${REMOVE_ERROR_PREFIX.INVALID_REMOVE}: '${rawSelector}'`); } else { // there is no :remove() pseudo-class is rule selector = rawSelector; } const stylesOfSelector = shouldRemove ? [{ property: REMOVE_PSEUDO_MARKER, value: String(shouldRemove) }] : []; return { selector, stylesOfSelector }; }; /** * Converts array of `entries` to object. * Object.fromEntries() polyfill because it is not supported by old browsers, e.g. Chrome 55. * Only first two elements of `entries` array matter, other will be skipped silently. * * @see {@link https://caniuse.com/?search=Object.fromEntries} * * @param entries Array of pairs. * * @returns Object converted from `entries`. */ const getObjectFromEntries = entries => { const object = {}; entries.forEach(el => { const [key, value] = el; object[key] = value; }); return object; }; const DEBUG_PSEUDO_PROPERTY_KEY = 'debug'; const REGEXP_DECLARATION_END = /[;}]/g; const REGEXP_DECLARATION_DIVIDER = /[;:}]/g; const REGEXP_NON_WHITESPACE = /\S/g; // ExtendedCss does not support at-rules // https://developer.mozilla.org/en-US/docs/Web/CSS/At-rule const AT_RULE_MARKER = '@'; /** * Init value for rawRuleData. */ const initRawRuleData = { selector: '' }; /** * Resets rule data buffer to init value after rule successfully collected. * * @param context Stylesheet parser context. */ const restoreRuleAcc = context => { context.rawRuleData = initRawRuleData; }; /** * Parses cropped selector part found before `{` previously. * * @param context Stylesheet parser context. * @param extCssDoc Needed for caching of selector ast. * * @returns Parsed validation data for cropped part of stylesheet which may be a selector. * @throws An error on unsupported CSS features, e.g. at-rules. */ const parseSelectorPart = (context, extCssDoc) => { let selector = context.selectorBuffer.trim(); if (selector.startsWith(AT_RULE_MARKER)) { throw new Error(`At-rules are not supported: '${selector}'.`); } let removeSelectorData; try { removeSelectorData = parseRemoveSelector(selector); } catch (e) { // eslint-disable-line @typescript-eslint/no-explicit-any logger.error(e.message); throw new Error(`${REMOVE_ERROR_PREFIX.INVALID_REMOVE}: '${selector}'`); } if (context.nextIndex === -1) { if (selector === removeSelectorData.selector) { // rule should have style or pseudo-class :remove() throw new Error(`${STYLESHEET_ERROR_PREFIX.NO_STYLE_OR_REMOVE}: '${context.cssToParse}'`); } // stop parsing as there is no style declaration and selector parsed fine context.cssToParse = ''; } let stylesOfSelector = []; let success = false; let ast; try { selector = removeSelectorData.selector; stylesOfSelector = removeSelectorData.stylesOfSelector; // validate found selector by parsing it to ast // so if it is invalid error will be thrown ast = extCssDoc.getSelectorAst(selector); success = true; } catch (e) { // eslint-disable-line @typescript-eslint/no-explicit-any success = false; } if (context.nextIndex > 0) { // slice found valid selector part off // and parse rest of stylesheet later context.cssToParse = context.cssToParse.slice(context.nextIndex); } return { success, selector, ast, stylesOfSelector }; }; /** * Recursively parses style declaration string into `Style`s. * * @param context Stylesheet parser context. * @param styles Array of styles. * * @throws An error on invalid style declaration. * @returns A number index of the next `}` in `this.cssToParse`. */ const parseUntilClosingBracket = (context, styles) => { // Expects ":", ";", and "}". REGEXP_DECLARATION_DIVIDER.lastIndex = context.nextIndex; let match = REGEXP_DECLARATION_DIVIDER.exec(context.cssToParse); if (match === null) { throw new Error(`${STYLESHEET_ERROR_PREFIX.INVALID_STYLE}: '${context.cssToParse}'`); } let matchPos = match.index; let matched = match[0]; if (matched === BRACKETS.CURLY.RIGHT) { const declarationChunk = context.cssToParse.slice(context.nextIndex, matchPos); if (declarationChunk.trim().length === 0) { // empty style declaration // e.g. 'div { }' if (styles.length === 0) { throw new Error(`${STYLESHEET_ERROR_PREFIX.NO_STYLE}: '${context.cssToParse}'`); } // else valid style parsed before it // e.g. '{ display: none; }' -- position is after ';' } else { // closing curly bracket '}' is matched before colon ':' // trimmed declarationChunk is not a space, between ';' and '}', // e.g. 'visible }' in style '{ display: none; visible }' after part before ';' is parsed throw new Error(`${STYLESHEET_ERROR_PREFIX.INVALID_STYLE}: '${context.cssToParse}'`); } return matchPos; } if (matched === COLON) { const colonIndex = matchPos; // Expects ";" and "}". REGEXP_DECLARATION_END.lastIndex = colonIndex; match = REGEXP_DECLARATION_END.exec(context.cssToParse); if (match === null) { throw new Error(`${STYLESHEET_ERROR_PREFIX.UNCLOSED_STYLE}: '${context.cssToParse}'`); } matchPos = match.index; matched = match[0]; // Populates the `styleMap` key-value map. const property = context.cssToParse.slice(context.nextIndex, colonIndex).trim(); if (property.length === 0) { throw new Error(`${STYLESHEET_ERROR_PREFIX.NO_PROPERTY}: '${context.cssToParse}'`); } const value = context.cssToParse.slice(colonIndex + 1, matchPos).trim(); if (value.length === 0) { throw new Error(`${STYLESHEET_ERROR_PREFIX.NO_VALUE}: '${context.cssToParse}'`); } styles.push({ property, value }); // finish style parsing if '}' is found // e.g. '{ display: none }' -- no ';' at the end of declaration if (matched === BRACKETS.CURLY.RIGHT) { return matchPos; } } // matchPos is the position of the next ';' // crop 'cssToParse' and re-run the loop context.cssToParse = context.cssToParse.slice(matchPos + 1); context.nextIndex = 0; return parseUntilClosingBracket(context, styles); // Should be a subject of tail-call optimization. }; /** * Parses next style declaration part in stylesheet. * * @param context Stylesheet parser context. * * @returns Array of style data objects. */ const parseNextStyle = context => { const styles = []; const styleEndPos = parseUntilClosingBracket(context, styles); // find next rule after the style declaration REGEXP_NON_WHITESPACE.lastIndex = styleEndPos + 1; const match = REGEXP_NON_WHITESPACE.exec(context.cssToParse); if (match === null) { context.cssToParse = ''; return styles; } const matchPos = match.index; // cut out matched style declaration for previous selector context.cssToParse = context.cssToParse.slice(matchPos); return styles; }; /** * Checks whether the 'remove' property positively set in styles * with only one positive value - 'true'. * * @param styles Array of styles. * * @returns True if there is 'remove' property with 'true' value in `styles`. */ const isRemoveSetInStyles = styles => { return styles.some(s => { return s.property === REMOVE_PSEUDO_MARKER && s.value === PSEUDO_PROPERTY_POSITIVE_VALUE; }); }; /** * Returns valid 'debug' property value set in styles * where possible values are 'true' and 'global'. * * @param styles Array of styles. * * @returns Value of 'debug' property if it is set in `styles`, * or `undefined` if the property is not found. */ const getDebugStyleValue = styles => { const debugStyle = styles.find(s => { return s.property === DEBUG_PSEUDO_PROPERTY_KEY; }); return debugStyle === null || debugStyle === void 0 ? void 0 : debugStyle.value; }; /** * Prepares final RuleData. * * @param selector String selector. * @param ast Parsed ast. * @param rawStyles Array of previously collected styles which may contain 'remove' and 'debug'. * * @returns Parsed ExtendedCss rule data. */ const prepareRuleData = (selector, ast, rawStyles) => { const ruleData = { selector, ast }; const debugValue = getDebugStyleValue(rawStyles); const shouldRemove = isRemoveSetInStyles(rawStyles); let styles = rawStyles; if (debugValue) { // get rid of 'debug' from styles styles = rawStyles.filter(s => s.property !== DEBUG_PSEUDO_PROPERTY_KEY); // and set it as separate property only if its value is valid // which is 'true' or 'global' if (debugValue === PSEUDO_PROPERTY_POSITIVE_VALUE || debugValue === DEBUG_PSEUDO_PROPERTY_GLOBAL_VALUE) { ruleData.debug = debugValue; } } if (shouldRemove) { // no other styles are needed to apply if 'remove' is set ruleData.style = { [REMOVE_PSEUDO_MARKER]: PSEUDO_PROPERTY_POSITIVE_VALUE }; /** * 'content' property is needed for ExtCssConfiguration.beforeStyleApplied(). * * @see {@link BeforeStyleAppliedCallback} */ const contentStyle = styles.find(s => s.property === CONTENT_CSS_PROPERTY); if (contentStyle) { ruleData.style[CONTENT_CSS_PROPERTY] = contentStyle.value; } } else { // otherwise all styles should be applied. // every style property will be unique because of their converting into object if (styles.length > 0) { const stylesAsEntries = styles.map(style => { const { property, value } = style; return [property, value]; }); const preparedStyleData = getObjectFromEntries(stylesAsEntries); ruleData.style = preparedStyleData; } } return ruleData; }; /** * Saves rules data for unique selectors. * * @param rawR###lts Previously collected r###lts of parsing. * @param rawRuleData Parsed rule data. * * @throws An error if there is no rawRuleData.styles or rawRuleData.ast. */ const saveToRawR###lts = (rawR###lts, rawRuleData) => { const { selector, ast, styles } = rawRuleData; if (!styles) { throw new Error(`No style declaration for selector: '${selector}'`); } if (!ast) { throw new Error(`No ast parsed for selector: '${selector}'`); } const storedRuleData = rawR###lts.get(selector); if (!storedRuleData) { rawR###lts.set(selector, { ast, styles }); } else { storedRuleData.styles.push(...styles); } }; /** * Parses stylesheet of rules into rules data objects (non-recursively): * 1. Iterates through stylesheet string. * 2. Finds first `{` which can be style declaration start or part of selector. * 3. Validates found string part via selector parser; and if: * - it throws error — saves string part to buffer as part of selector, * slice next stylesheet part to `{` [2] and validates again [3]; * - no error — saves found string part as selector and starts to parse styles (recursively). * * @param rawStylesheet Raw stylesheet as string. * @param extCssDoc ExtCssDocument which uses cache while selectors parsing. * @throws An error on unsupported CSS features, e.g. comments, or invalid stylesheet syntax. * @returns Array of rules data which contains: * - selector as string; * - ast to query elements by; * - map of styles to apply. */ const parse = (rawStylesheet, extCssDoc) => { const stylesheet = rawStylesheet.trim(); if (stylesheet.includes(`${SLASH}${ASTERISK}`) && stylesheet.includes(`${ASTERISK}${SLASH}`)) { throw new Error(`${STYLESHEET_ERROR_PREFIX.NO_COMMENT}: '${stylesheet}'`); } const context = { // any stylesheet should start with selector isSelector: true, // init value of parser position nextIndex: 0, // init value of cssToParse cssToParse: stylesheet, // buffer for collecting selector part selectorBuffer: '', // accumulator for rules rawRuleData: initRawRuleData }; const rawR###lts = new Map(); let selectorData; // context.cssToParse is going to be cropped while its parsing while (context.cssToParse) { if (context.isSelector) { // find index of first opening curly bracket // which may mean start of style part and end of selector one context.nextIndex = context.cssToParse.indexOf(BRACKETS.CURLY.LEFT); // rule should not start with style, selector is required // e.g. '{ display: none; }' if (context.selectorBuffer.length === 0 && context.nextIndex === 0) { throw new Error(`${STYLESHEET_ERROR_PREFIX.NO_SELECTOR}: '${context.cssToParse}'`); } if (context.nextIndex === -1) { // no style declaration in rule // but rule still may contain :remove() pseudo-class context.selectorBuffer = context.cssToParse; } else { // collect string parts before opening curly bracket // until valid selector collected context.selectorBuffer += context.cssToParse.slice(0, context.nextIndex); } selectorData = parseSelectorPart(context, extCssDoc); if (selectorData.success) { // selector successfully parsed context.rawRuleData.selector = selectorData.selector.trim(); context.rawRuleData.ast = selectorData.ast; context.rawRuleData.styles = selectorData.stylesOfSelector; context.isSelector = false; // save rule data if there is no style declaration if (context.nextIndex === -1) { saveToRawR###lts(rawR###lts, context.rawRuleData); // clean up ruleContext restoreRuleAcc(context); } else { // skip the opening curly bracket at the start of style declaration part context.nextIndex = 1; context.selectorBuffer = ''; } } else { // if selector was not successfully parsed parseSelectorPart(), continue stylesheet parsing: // save the found bracket to buffer and proceed to next loop iteration context.selectorBuffer += BRACKETS.CURLY.LEFT; // delete `{` from cssToParse context.cssToParse = context.cssToParse.slice(1); } } else { var _context$rawRuleData$; // style declaration should be parsed const parsedStyles = parseNextStyle(context); // styles can be parsed from selector part if it has :remove() pseudo-class // e.g. '.banner:remove() { debug: true; }' (_context$rawRuleData$ = context.rawRuleData.styles) === null || _context$rawRuleData$ === void 0 ? void 0 : _context$rawRuleData$.push(...parsedStyles); // save rule data to r###lts saveToRawR###lts(rawR###lts, context.rawRuleData); context.nextIndex = 0; // clean up ruleContext restoreRuleAcc(context); // parse next rule selector after style successfully parsed context.isSelector = true; } } const r###lts = []; rawR###lts.forEach((value, key) => { const selector = key; const { ast, styles: rawStyles } = value; r###lts.push(prepareRuleData(selector, ast, rawStyles)); }); return r###lts; }; /** * Checks whether passed `arg` is number type. * * @param arg Value to check. * * @returns True if `arg` is number and not NaN. */ const isNumber = arg => { return typeof arg === 'number' && !Number.isNaN(arg); }; const isSupported = typeof window.requestAnimationFrame !== 'undefined'; const timeout = isSupported ? requestAnimationFrame : window.setTimeout; const deleteTimeout = isSupported ? cancelAnimationFrame : clearTimeout; const perf = isSupported ? performance : Date; const DEFAULT_THROTTLE_DELAY_MS = 150; /** * The purpose of ThrottleWrapper is to throttle calls of the function * that applies ExtendedCss rules. The reasoning here is that the function calls * are triggered by MutationObserver and there may be many mutations in a short period of time. * We do not want to apply rules on every mutation so we use this helper to make sure * that there is only one call in the given amount of time. */ class ThrottleWrapper { /** * The provided callback should be executed twice in this time frame: * very first time and not more often than throttleDelayMs for further executions. * * @see {@link ThrottleWrapper.run} */ /** * Creates new ThrottleWrapper. * * @param context ExtendedCss context. * @param callback The callback. * @param throttleMs Throttle delay in ms. */ constructor(context, callback, throttleMs) { this.context = context; this.callback = callback; this.throttleDelayMs = throttleMs || DEFAULT_THROTTLE_DELAY_MS; this.wrappedCb = this.wrappedCallback.bind(this); } /** * Wraps the callback (which supposed to be `applyRules`), * needed to update `lastRunTime` and clean previous timeouts for proper execution of the callback. * * @param timestamp Timestamp. */ wrappedCallback(timestamp) { this.lastRunTime = isNumber(timestamp) ? timestamp : perf.now(); // `timeoutId` can be requestAnimationFrame-related // so cancelAnimationFrame() as deleteTimeout() needs the arg to be defined if (this.timeoutId) { deleteTimeout(this.timeoutId); delete this.timeoutId; } clearTimeout(this.timerId); delete this.timerId; if (this.callback) { this.callback(this.context); } } /** * Indicates whether there is a scheduled callback. * * @returns True if scheduled callback exists. */ hasPendingCallback() { return isNumber(this.timeoutId) || isNumber(this.timerId); } /** * Schedules the function which applies ExtendedCss rules before the next animation frame. * * Wraps function execution into `timeout` — requestAnimationFrame or setTimeout. * For the first time runs the function without any condition. * As it may be triggered by any mutation which may occur too ofter, we limit the function execution: * 1. If `elapsedTime` since last function execution is less then set `throttleDelayMs`, * next function call is hold till the end of throttle interval (subtracting `elapsed` from `throttleDelayMs`); * 2. Do nothing if triggered again but function call which is on hold has not yet started its execution. */ run() { if (this.hasPendingCallback()) { // there is a pending execution scheduled return; } if (typeof this.lastRunTime !== 'undefined') { const elapsedTime = perf.now() - this.lastRunTime; if (elapsedTime < this.throttleDelayMs) { this.timerId = window.setTimeout(this.wrappedCb, this.throttleDelayMs - elapsedTime); return; } } this.timeoutId = timeout(this.wrappedCb); } /** * Returns timestamp for 'now'. * * @returns Timestamp. */ static now() { return perf.now(); } } const LAST_EVENT_TIMEOUT_MS = 10; const IGNORED_EVENTS = ['mouseover', 'mouseleave', 'mouseenter', 'mouseout']; const SUPPORTED_EVENTS = [ // keyboard events 'keydown', 'keypress', 'keyup', // mouse events 'auxclick', 'click', 'contextmenu', 'dblclick', 'mousedown', 'mouseenter', 'mouseleave', 'mousemove', 'mouseover', 'mouseout', 'mouseup', 'pointerlockchange', 'pointerlockerror', 'select', 'wheel']; // 'wheel' event makes scrolling in Safari twitchy // https://github.com/AdguardTeam/ExtendedCss/issues/120 const SAFARI_PROBLEMATIC_EVENTS = ['wheel']; /** * We use EventTracker to track the event that is likely to cause the mutation. * The problem is that we cannot use `window.event` directly from the mutation observer call * as we're not in the event handler context anymore. */ class EventTracker { /** * Creates new EventTracker. */ constructor() { _defineProperty(this, "getLastEventType", () => this.lastEventType); _defineProperty(this, "getTimeSinceLastEvent", () => { if (!this.lastEventTime) { return null; } return Date.now() - this.lastEventTime; }); this.trackedEvents = isSafariBrowser ? SUPPORTED_EVENTS.filter(event => !SAFARI_PROBLEMATIC_EVENTS.includes(event)) : SUPPORTED_EVENTS; this.trackedEvents.forEach(eventName => { document.documentElement.addEventListener(eventName, this.trackEvent, true); }); } /** * Callback for event listener for events tracking. * * @param event Any event. */ trackEvent(event) { this.lastEventType = event.type; this.lastEventTime = Date.now(); } /** * Checks whether the last caught event should be ignored. * * @returns True if event should be ignored. */ isIgnoredEventType() { const lastEventType = this.getLastEventType(); const sinceLastEventTime = this.getTimeSinceLastEvent(); return !!lastEventType && IGNORED_EVENTS.includes(lastEventType) && !!sinceLastEventTime && sinceLastEventTime < LAST_EVENT_TIMEOUT_MS; } /** * Stops event tracking by removing event listener. */ stopTracking() { this.trackedEvents.forEach(eventName => { document.documentElement.removeEventListener(eventName, this.trackEvent, true); }); } } const isEventListenerSupported = typeof window.addEventListener !== 'undefined'; const observeDocument = (context, callback) => { // We are trying to limit the number of callback calls by not calling it on all kind of "hover" events. // The rationale behind this is that "hover" events often cause attributes modification, // but re-applying extCSS rules will be useless as these attribute changes are usually transient. const shouldIgnoreMutations = mutations => { // ignore if all mutations are about attributes changes return mutations.every(m => m.type === 'attributes'); }; if (natives.MutationObserver) { context.domMutationObserver = new natives.MutationObserver(mutations => { if (!mutations || mutations.length === 0) { return; } const eventTracker = new EventTracker(); if (eventTracker.isIgnoredEventType() && shouldIgnoreMutations(mutations)) { return; } // save instance of EventTracker to context // for removing its event listeners on disconnectDocument() while mainDisconnect() context.eventTracker = eventTracker; callback(); }); context.domMutationObserver.observe(document, { childList: true, subtree: true, attributes: true, attributeFilter: ['id', 'class'] }); } else if (isEventListenerSupported) { document.addEventListener('DOMNodeInserted', callback, false); document.addEventListener('DOMNodeRemoved', callback, false); document.addEventListener('DOMAttrModified', callback, false); } }; const disconnectDocument = (context, callback) => { var _context$eventTracker; if (context.domMutationObserver) { context.domMutationObserver.disconnect(); } else if (isEventListenerSupported) { document.removeEventListener('DOMNodeInserted', callback, false); document.removeEventListener('DOMNodeRemoved', callback, false); document.removeEventListener('DOMAttrModified', callback, false); } // clean up event listeners (_context$eventTracker = context.eventTracker) === null || _context$eventTracker === void 0 ? void 0 : _context$eventTracker.stopTracking(); }; const mainObserve = (context, mainCallback) => { if (context.isDomObserved) { return; } // handle dynamically added elements context.isDomObserved = true; observeDocument(context, mainCallback); }; const mainDisconnect = (context, mainCallback) => { if (!context.isDomObserved) { return; } context.isDomObserved = false; disconnectDocument(context, mainCallback); }; // added by tsurlfilter's CssHitsCounter const CONTENT_ATTR_PREFIX_REGEXP = /^("|')adguard.+?/; /** * Removes affectedElement.node from DOM. * * @param context ExtendedCss context. * @param affectedElement Affected element. */ const removeElement = (context, affectedElement) => { const { node } = affectedElement; affectedElement.removed = true; const elementSelector = getElementSelectorPath(node); // check if the element has been already removed earlier const elementRemovalsCounter = context.removalsStatistic[elementSelector] || 0; // if removals attempts happened more than specified we do not try to remove node again if (elementRemovalsCounter > MAX_STYLE_PROTECTION_COUNT) { logger.error(`ExtendedCss: infinite loop protection for selector: '${elementSelector}'`); return; } if (node.parentElement) { node.parentElement.removeChild(node); context.removalsStatistic[elementSelector] = elementRemovalsCounter + 1; } }; /** * Sets style to the specified DOM node. * * @param node DOM element. * @param style Style to set. */ const setStyleToElement = (node, style) => { if (!(node instanceof HTMLElement)) { return; } Object.keys(style).forEach(prop => { // Apply this style only to existing properties // We cannot use hasOwnProperty here (does not work in FF) if (typeof node.style.getPropertyValue(prop.toString()) !== 'undefined') { let value = style[prop]; if (!value) { return; } // do not apply 'content' style given by tsurlfilter // which is needed only for BeforeStyleAppliedCallback if (prop === CONTENT_CSS_PROPERTY && value.match(CONTENT_ATTR_PREFIX_REGEXP)) { return; } // First we should remove !important attribute (or it won't be applied') value = remov###ffix(value.trim(), '!important').trim(); node.style.setProperty(prop, value, 'important'); } }); }; /** * Applies style to the specified DOM node. * * @param context ExtendedCss context. * @param affectedElement Object containing DOM node and rule to be applied. * * @throws An error if affectedElement has no style to apply. */ const applyStyle = (context, affectedElement) => { if (affectedElement.protectionObserver) { // style is already applied and protected by the observer return; } if (context.beforeStyleApplied) { affectedElement = context.beforeStyleApplied(affectedElement); if (!affectedElement) { return; } } const { node, rules } = affectedElement; for (let i = 0; i < rules.length; i += 1) { const rule = rules[i]; const selector = rule === null || rule === void 0 ? void 0 : rule.selector; const style = rule === null || rule === void 0 ? void 0 : rule.style; const debug = rule === null || rule === void 0 ? void 0 : rule.debug; // rule may not have style to apply // e.g. 'div:has(> a) { debug: true }' -> means no style to apply, and enable debug mode if (style) { if (style[REMOVE_PSEUDO_MARKER] === PSEUDO_PROPERTY_POSITIVE_VALUE) { removeElement(context, affectedElement); return; } setStyleToElement(node, style); } else if (!debug) { // but rule should not have both style and debug properties throw new Error(`No style declaration in rule for selector: '${selector}'`); } } }; /** * Reverts style for the affected object. * * @param affectedElement Affected element. */ const revertStyle = affectedElement => { if (affectedElement.protectionObserver) { affectedElement.protectionObserver.disconnect(); } affectedElement.node.style.cssText = affectedElement.originalStyle; }; /** * ExtMutationObserver is a wrapper over regular MutationObserver with one additional function: * it keeps track of the number of times we called the "ProtectionCallback". * * We use an instance of this to monitor styles added by ExtendedCss * and to make sure these styles are recovered if the page script attempts to modify them. * * However, we want to avoid endless loops of modification if the page script repeatedly modifies the styles. * So we keep track of the number of calls and observe() makes a decision * whether to continue recovering the styles or not. */ class ExtMutationObserver { /** * Extra property for keeping 'style fix counts'. */ /** * Creates new ExtMutationObserver. * * @param protectionCallback Callback which execution should be counted. */ constructor(protectionCallback) { this.styleProtectionCount = 0; this.observer = new natives.MutationObserver(mutations => { if (!mutations.length) { return; } this.styleProtectionCount += 1; protectionCallback(mutations, this); }); } /** * Starts to observe target element, * prevents infinite loop of observing due to the limited number of times of callback runs. * * @param target Target to observe. * @param options Mutation observer options. */ observe(target, options) { if (this.styleProtectionCount < MAX_STYLE_PROTECTION_COUNT) { this.observer.observe(target, options); } else { logger.error('ExtendedCss: infinite loop protection for style'); } } /** * Stops ExtMutationObserver from observing any mutations. * Until the `observe()` is used again, `protectionCallback` will not be invoked. */ disconnect() { this.observer.disconnect(); } } const PROTECTION_OBSERVER_OPTIONS = { attributes: true, attributeOldValue: true, attributeFilter: ['style'] }; /** * Creates MutationObserver protection callback. * * @param styles Styles data object. * * @returns Callback for styles protection. */ const createProtectionCallback = styles => { const protectionCallback = (mutations, extObserver) => { if (!mutations[0]) { return; } const { target } = mutations[0]; extObserver.disconnect(); styles.forEach(style => { setStyleToElement(target, style); }); extObserver.observe(target, PROTECTION_OBSERVER_OPTIONS); }; return protectionCallback; }; /** * Sets up a MutationObserver which protects style attributes from changes. * * @param node DOM node. * @param rules Rule data objects. * @returns Mutation observer used to protect attribute or null if there's nothing to protect. */ const protectStyleAttribute = (node, rules) => { if (!natives.MutationObserver) { return null; } const styles = []; rules.forEach(ruleData => { const { style } = ruleData; // some rules might have only debug property in style declaration // e.g. 'div:has(> a) { debug: true }' -> parsed to boolean `ruleData.debug` // so no style is fine, and here we should collect only valid styles to protect if (style) { styles.push(style); } }); const protectionObserver = new ExtMutationObserver(createProtectionCallback(styles)); protectionObserver.observe(node, PROTECTION_OBSERVER_OPTIONS); return protectionObserver; }; const STATS_DECIMAL_DIGITS_COUNT = 4; /** * A helper class for applied rule stats. */ class TimingStats { /** * Creates new TimingStats. */ constructor() { this.appliesTimings = []; this.appliesCount = 0; this.timingsSum = 0; this.meanTiming = 0; this.squaredSum = 0; this.standardDeviation = 0; } /** * Observe target element and mark observer as active. * * @param elapsedTimeMs Time in ms. */ push(elapsedTimeMs) { this.appliesTimings.push(elapsedTimeMs); this.appliesCount += 1; this.timingsSum += elapsedTimeMs; this.meanTiming = this.timingsSum / this.appliesCount; this.squaredSum += elapsedTimeMs * elapsedTimeMs; this.standardDeviation = Math.sqrt(this.squaredSum / this.appliesCount - Math.pow(this.meanTiming, 2)); } } /** * Makes the timestamps more readable. * * @param timestamp Raw timestamp. * * @returns Fine-looking timestamps. */ const beautifyTimingNumber = timestamp => { return Number(timestamp.toFixed(STATS_DECIMAL_DIGITS_COUNT)); }; /** * Improves timing stats readability. * * @param rawTimings Collected timings with raw timestamp. * * @returns Fine-looking timing stats. */ const beautifyTimings = rawTimings => { return { appliesTimings: rawTimings.appliesTimings.map(t => beautifyTimingNumber(t)), appliesCount: beautifyTimingNumber(rawTimings.appliesCount), timingsSum: beautifyTimingNumber(rawTimings.timingsSum), meanTiming: beautifyTimingNumber(rawTimings.meanTiming), standardDeviation: beautifyTimingNumber(rawTimings.standardDeviation) }; }; /** * Prints timing information if debugging mode is enabled. * * @param context ExtendedCss context. */ const printTimingInfo = context => { if (context.areTimingsPrinted) { return; } context.areTimingsPrinted = true; const timingsLogData = {}; context.parsedRules.forEach(ruleData => { if (ruleData.timingStats) { const { selector, style, debug, matchedElements } = ruleData; // style declaration for some rules is parsed to debug property and no style to apply // e.g. 'div:has(> a) { debug: true }' if (!style && !debug) { throw new Error(`Rule should have style declaration for selector: '${selector}'`); } const selectorData = { selectorParsed: selector, timings: beautifyTimings(ruleData.timingStats) }; // `ruleData.style` may contain `remove` pseudo-property // and make logs look better if (style && style[REMOVE_PSEUDO_MARKER] === PSEUDO_PROPERTY_POSITIVE_VALUE) { selectorData.removed = true; // no matchedElements for such case as they are removed after ExtendedCss applied } else { selectorData.styleApplied = style || null; selectorData.matchedElements = matchedElements; } timingsLogData[selector] = selectorData; } }); if (Object.keys(timingsLogData).length === 0) { return; } // add location.href to the message to distinguish frames logger.info('[ExtendedCss] Timings in milliseconds for %o:\n%o', window.location.href, timingsLogData); }; /** * Finds affectedElement object for the specified DOM node. * * @param affElements Array of affected elements — context.affectedElements. * @param domNode DOM node. * @returns Found affectedElement or undefined. */ const findAffectedElement = (affElements, domNode) => { return affElements.find(affEl => affEl.node === domNode); }; /** * Applies specified rule and returns list of elements affected. * * @param context ExtendedCss context. * @param ruleData Rule to apply. * @returns List of elements affected by the rule. */ const applyRule = (context, ruleData) => { // debugging mode can be enabled in two ways: // 1. for separate rules - by `{ debug: true; }` // 2. for all rules simultaneously by: // - `{ debug: global; }` in any rule // - positive `debug` property in ExtCssConfiguration const isDebuggingMode = !!ruleData.debug || context.debug; let startTime; if (isDebuggingMode) { startTime = ThrottleWrapper.now(); } const { ast } = ruleData; const nodes = selectElementsByAst(ast); nodes.forEach(node => { let affectedElement = findAffectedElement(context.affectedElements, node); if (affectedElement) { affectedElement.rules.push(ruleData); applyStyle(context, affectedElement); } else { // Applying style first time const originalStyle = node.style.cssText; affectedElement = { node, // affected DOM node rules: [ruleData], // rule to be applied originalStyle, // original node style protectionObserver: null // style attribute observer }; applyStyle(context, affectedElement); context.affectedElements.push(affectedElement); } }); if (isDebuggingMode && startTime) { const elapsedTimeMs = ThrottleWrapper.now() - startTime; if (!ruleData.timingStats) { ruleData.timingStats = new TimingStats(); } ruleData.timingStats.push(elapsedTimeMs); } return nodes; }; /** * Applies filtering rules. * * @param context ExtendedCss context. */ const applyRules = context => { const newSelectedElements = []; // some rules could make call - selector.querySelectorAll() temporarily to change node id attribute // this caused MutationObserver to call recursively // https://github.com/AdguardTeam/ExtendedCss/issues/81 mainDisconnect(context, context.mainCallback); context.parsedRules.forEach(ruleData => { const nodes = applyRule(context, ruleData); Array.prototype.push.apply(newSelectedElements, nodes); // save matched elements to ruleData as linked to applied rule // only for debugging purposes if (ruleData.debug) { ruleData.matchedElements = nodes; } }); // Now revert styles for elements which are no more affected let affLength = context.affectedElements.length; // do nothing if there is no elements to process while (affLength) { const affectedElement = context.affectedElements[affLength - 1]; if (!affectedElement) { break; } if (!newSelectedElements.includes(affectedElement.node)) { // Time to revert style revertStyle(affectedElement); context.affectedElements.splice(affLength - 1, 1); } else if (!affectedElement.removed) { // Add style protection observer // Protect "style" attribute from changes if (!affectedElement.protectionObserver) { affectedElement.protectionObserver = protectStyleAttribute(affectedElement.node, affectedElement.rules); } } affLength -= 1; } // After styles are applied we can start observe again mainObserve(context, context.mainCallback); printTimingInfo(context); }; /** * Throttle timeout for ThrottleWrapper to execute applyRules(). */ const APPLY_RULES_DELAY = 150; /** * R###lt of selector validation. */ /** * Main class of ExtendedCss lib. * * Parses css stylesheet with any selectors (passed to its argument as styleSheet), * and guarantee its applying as mutation observer is used to prevent the restyling of needed elements by other scripts. * This style protection is limited to 50 times to avoid infinite loop (MAX_STYLE_PROTECTION_COUNT). * Our own ThrottleWrapper is used for styles applying to avoid too often lib reactions on page mutations. * * Constructor creates the instance of class which should be run be `apply()` method to apply the rules, * and the applying can be stopped by `dispose()`. * * Can be used to select page elements by selector with `query()` method (similar to `Document.querySelectorAll()`), * which does not require instance creating. */ class ExtendedCss { /** * Creates new ExtendedCss. * * @param configuration ExtendedCss configuration. */ constructor(configuration) { if (!isBrowserSupported()) { throw new Error('Browser is not supported by ExtendedCss.'); } if (!configuration) { throw new Error('ExtendedCss configuration should be provided.'); } this.context = { beforeStyleApplied: configuration.beforeStyleApplied, debug: false, affectedElements: [], isDomObserved: false, removalsStatistic: {}, parsedRules: parse(configuration.styleSheet, extCssDocument), mainCallback: () => {} }; // true if set in configuration // or any rule in styleSheet has `debug: global` this.context.debug = configuration.debug || this.context.parsedRules.some(ruleData => { return ruleData.debug === DEBUG_PSEUDO_PROPERTY_GLOBAL_VALUE; }); this.applyRulesScheduler = new ThrottleWrapper(this.context, applyRules, APPLY_RULES_DELAY); this.context.mainCallback = this.applyRulesScheduler.run.bind(this.applyRulesScheduler); if (this.context.beforeStyleApplied && typeof this.context.beforeStyleApplied !== 'function') { // eslint-disable-next-line max-len throw new Error(`Invalid configuration. Type of 'beforeStyleApplied' should be a function, received: '${typeof this.context.beforeStyleApplied}'`); } this.applyRulesCallbackListener = () => { applyRules(this.context); }; } /** * Applies stylesheet rules on page. */ apply() { applyRules(this.context); if (document.readyState !== 'complete') { document.addEventListener('DOMContentLoaded', this.applyRulesCallbackListener, false); } } /** * Disposes ExtendedCss and removes our styles from matched elements. */ dispose() { mainDisconnect(this.context, this.context.mainCallback); this.context.affectedElements.forEach(el => { revertStyle(el); }); document.removeEventListener('DOMContentLoaded', this.applyRulesCallbackListener, false); } /** * Exposed for testing purposes only. * * @returns Array of AffectedElement data objects. */ getAffectedElements() { return this.context.affectedElements; } /** * Returns a list of the document's elements that match the specified selector. * Uses ExtCssDocument.querySelectorAll(). * * @param selector Selector text. * @param [noTiming=true] If true — do not print the timings to the console. * * @throws An error if selector is not valid. * @returns A list of elements that match the selector. */ static query(selector) { let noTiming = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : true; if (typeof selector !== 'string') { throw new Error('Selector should be defined as a string.'); } const start = ThrottleWrapper.now(); try { return extCssDocument.querySelectorAll(selector); } finally { const end = ThrottleWrapper.now(); if (!noTiming) { logger.info(`[ExtendedCss] Elapsed: ${Math.round((end - start) * 1000)} μs.`); } } } /** * Validates selector. * * @param inputSelector Selector text to validate. * * @returns R###lt of selector validation. */ static validate(inputSelector) { try { // ExtendedCss in general supports :remove() in selector // but ExtendedCss.query() does not support it as it should be parsed by stylesheet parser. // so for validation we have to handle selectors with `:remove()` in it const { selector } = parseRemoveSelector(inputSelector); ExtendedCss.query(selector); return { ok: true, error: null }; } catch (e) { const caughtErrorMessage = e instanceof Error ? e.message : e; // not valid input `selector` should be logged eventually const error = `Error: Invalid selector: '${inputSelector}' -- ${caughtErrorMessage}`; return { ok: false, error }; } } } return ExtendedCss; })();