UNPKG

@adguard/agtree

Version:
374 lines (371 loc) 14.9 kB
/* * AGTree v4.1.1 (build date: Thu, 23 Apr 2026 09:15:37 GMT) * (c) 2026 Adguard Software Ltd. * Released under the MIT license * https://github.com/AdguardTeam/tsurlfilter/tree/master/packages/agtree#readme */ import { ESCAPE_CHARACTER, EMPTY, SINGLE_QUOTE, DOUBLE_QUOTE, BACKTICK_QUOTE, COMMA, SPACE, OPEN_PARENTHESIS, CLOSE_PARENTHESIS, OPEN_SQUARE_BRACKET, CLOSE_SQUARE_BRACKET } from './constants.js'; /** * @file Utility functions for working with quotes. */ /** * Set of all possible quote characters supported by the library. */ const QUOTE_SET = new Set([ SINGLE_QUOTE, DOUBLE_QUOTE, BACKTICK_QUOTE, ]); /** * Possible quote types for scriptlet parameters. */ const QuoteType = { /** * No quotes at all. */ None: 'none', /** * Single quotes (`'`). */ Single: 'single', /** * Double quotes (`"`). */ Double: 'double', /** * Backtick quotes (`` ` ``). */ Backtick: 'backtick', }; /** * Utility functions for working with quotes. */ class QuoteUtils { /** * Escape all unescaped occurrences of the character. * * @param string String to escape. * @param char Character to escape. * * @returns Escaped string. */ static escapeUnescapedOccurrences(string, char) { let result = EMPTY; for (let i = 0; i < string.length; i += 1) { if (string[i] === char && (i === 0 || string[i - 1] !== ESCAPE_CHARACTER)) { result += ESCAPE_CHARACTER; } result += string[i]; } return result; } /** * Unescape all single escaped occurrences of the character. * * @param string String to unescape. * @param char Character to unescape. * * @returns Unescaped string. */ static unescapeSingleEscapedOccurrences(string, char) { let result = EMPTY; for (let i = 0; i < string.length; i += 1) { if (string[i] === char && string[i - 1] === ESCAPE_CHARACTER && (i === 1 || string[i - 2] !== ESCAPE_CHARACTER)) { result = result.slice(0, -1); } result += string[i]; } return result; } /** * Get quote type of the string. * * @param string String to check. * * @returns Quote type of the string. */ static getStringQuoteType(string) { // Don't check 1-character strings to avoid false positives if (string.length > 1) { if (string.startsWith(SINGLE_QUOTE) && string.endsWith(SINGLE_QUOTE)) { return QuoteType.Single; } if (string.startsWith(DOUBLE_QUOTE) && string.endsWith(DOUBLE_QUOTE)) { return QuoteType.Double; } if (string.startsWith(BACKTICK_QUOTE) && string.endsWith(BACKTICK_QUOTE)) { return QuoteType.Backtick; } } return QuoteType.None; } /** * Set quote type of the string. * * @param string String to set quote type of. * @param quoteType Quote type to set. * * @returns String with the specified quote type. */ static setStringQuoteType(string, quoteType) { const actualQuoteType = QuoteUtils.getStringQuoteType(string); switch (quoteType) { case QuoteType.None: if (actualQuoteType === QuoteType.Single) { return QuoteUtils.escapeUnescapedOccurrences(string.slice(1, -1), SINGLE_QUOTE); } if (actualQuoteType === QuoteType.Double) { return QuoteUtils.escapeUnescapedOccurrences(string.slice(1, -1), DOUBLE_QUOTE); } if (actualQuoteType === QuoteType.Backtick) { return QuoteUtils.escapeUnescapedOccurrences(string.slice(1, -1), BACKTICK_QUOTE); } return string; case QuoteType.Single: if (actualQuoteType === QuoteType.None) { return SINGLE_QUOTE + QuoteUtils.escapeUnescapedOccurrences(string, SINGLE_QUOTE) + SINGLE_QUOTE; } if (actualQuoteType === QuoteType.Double) { return SINGLE_QUOTE + QuoteUtils.escapeUnescapedOccurrences(QuoteUtils.unescapeSingleEscapedOccurrences(string.slice(1, -1), DOUBLE_QUOTE), SINGLE_QUOTE) + SINGLE_QUOTE; } if (actualQuoteType === QuoteType.Backtick) { return SINGLE_QUOTE + QuoteUtils.escapeUnescapedOccurrences(QuoteUtils.unescapeSingleEscapedOccurrences(string.slice(1, -1), BACKTICK_QUOTE), SINGLE_QUOTE) + SINGLE_QUOTE; } return string; case QuoteType.Double: if (actualQuoteType === QuoteType.None) { return DOUBLE_QUOTE + QuoteUtils.escapeUnescapedOccurrences(string, DOUBLE_QUOTE) + DOUBLE_QUOTE; } if (actualQuoteType !== QuoteType.Double) { // eslint-disable-next-line max-len return DOUBLE_QUOTE + QuoteUtils.escapeUnescapedOccurrences(QuoteUtils.unescapeSingleEscapedOccurrences(string.slice(1, -1), SINGLE_QUOTE), DOUBLE_QUOTE) + DOUBLE_QUOTE; } return string; case QuoteType.Backtick: if (actualQuoteType === QuoteType.None) { // eslint-disable-next-line max-len return BACKTICK_QUOTE + QuoteUtils.escapeUnescapedOccurrences(string, BACKTICK_QUOTE) + BACKTICK_QUOTE; } if (actualQuoteType !== QuoteType.Backtick) { // eslint-disable-next-line max-len return BACKTICK_QUOTE + QuoteUtils.escapeUnescapedOccurrences(QuoteUtils.unescapeSingleEscapedOccurrences(string.slice(1, -1), SINGLE_QUOTE), BACKTICK_QUOTE) + BACKTICK_QUOTE; } return string; default: return string; } } /** * Removes bounding quotes from a string, if any. * * @param string Input string. * * @returns String without quotes. */ static removeQuotes(string) { if ( // We should check for string length to avoid false positives string.length > 1 && (string[0] === SINGLE_QUOTE || string[0] === DOUBLE_QUOTE || string[0] === BACKTICK_QUOTE) && string[0] === string[string.length - 1]) { return string.slice(1, -1); } return string; } /** * Removes bounding quotes from a string, if any, and unescapes the escaped quotes, * like transforming `'abc\'def'` to `abc'def`. * * @param string Input string. * * @returns String without quotes. */ static removeQuotesAndUnescape(string) { const quoteType = QuoteUtils.getStringQuoteType(string); switch (quoteType) { case QuoteType.Single: return QuoteUtils.unescapeSingleEscapedOccurrences(string.slice(1, -1), SINGLE_QUOTE); case QuoteType.Double: return QuoteUtils.unescapeSingleEscapedOccurrences(string.slice(1, -1), DOUBLE_QUOTE); case QuoteType.Backtick: return QuoteUtils.unescapeSingleEscapedOccurrences(string.slice(1, -1), BACKTICK_QUOTE); default: return string; } } /** * Wraps given `strings` with `quote` (defaults to single quote `'`) * and joins them with `separator` (defaults to comma+space `, `). * * @param strings Strings to quote and join. * @param quoteType Quote to use. * @param separator Separator to use. * * @returns String with joined items. * * @example * ['abc', 'def']: strings[] -> "'abc', 'def'": string */ static quoteAndJoinStrings(strings, quoteType = QuoteType.Single, separator = `${COMMA}${SPACE}`) { return strings .map((s) => QuoteUtils.setStringQuoteType(s, quoteType)) .join(separator); } /** * Convert `""` to `\"` within strings inside of attribute selectors, * because it is not compatible with the standard CSS syntax. * * @see {@link https://kb.adguard.com/en/general/how-to-create-your-own-ad-filters#tag-content} * @see {@link https://kb.adguard.com/en/general/how-to-create-your-own-ad-filters#wildcard} * * @param selector CSS selector string. * * @returns Escaped CSS selector. * * @example * ```ts * QuoteUtils.escapeAttributeDoubleQuotes('[attr="value with "" quotes"]'); * QuoteUtils.escapeAttributeDoubleQuotes('div[attr="value with "" quotes"] > span'); * ``` * * @note In the legacy syntax, `""` is used to escape double quotes, but it cannot be used * in the standard CSS syntax, so we use conversion functions to handle this. * @note This function is intended to be used on whole attribute selector or whole selector strings. */ static escapeAttributeDoubleQuotes(selector) { const nestingBlockPairs = new Map([ [OPEN_PARENTHESIS, CLOSE_PARENTHESIS], [OPEN_SQUARE_BRACKET, CLOSE_SQUARE_BRACKET], [SINGLE_QUOTE, SINGLE_QUOTE], [DOUBLE_QUOTE, DOUBLE_QUOTE], [BACKTICK_QUOTE, BACKTICK_QUOTE], ]); const nestingBlockStack = []; const buffer = []; for (let i = 0; i < selector.length; i += 1) { const char = selector[i]; // Check if we are inside of an attribute selector's value if ( // nesting will be 2 levels deep if we are inside of attribute selector's value nestingBlockStack.length === 2 // and the outer block is an attribute selector && nestingBlockStack[0] === CLOSE_SQUARE_BRACKET // and the inner block is a double quote && nestingBlockStack[1] === DOUBLE_QUOTE) { // We found `""` inside of attribute selector's value if (char === DOUBLE_QUOTE && selector[i + 1] === DOUBLE_QUOTE) { // Convert `""` to `\"` buffer.push(ESCAPE_CHARACTER); buffer.push(DOUBLE_QUOTE); // Skip the next double quote i += 1; continue; } // Normal character inside of attribute selector's value buffer.push(char); continue; } // Handle entering nesting blocks if (nestingBlockPairs.has(char) && selector[i - 1] !== ESCAPE_CHARACTER) { nestingBlockStack.push(nestingBlockPairs.get(char)); buffer.push(char); continue; } // Handle exiting nesting blocks if (nestingBlockStack.length > 0 && char === nestingBlockStack[nestingBlockStack.length - 1] && selector[i - 1] !== ESCAPE_CHARACTER) { nestingBlockStack.pop(); buffer.push(char); continue; } // Normal character buffer.push(char); } return buffer.join(EMPTY); } /** * Convert escaped double quotes `\"` to `""` within strings inside of attribute selectors, * because it is not compatible with the standard CSS syntax. * * @see {@link https://kb.adguard.com/en/general/how-to-create-your-own-ad-filters#tag-content} * @see {@link https://kb.adguard.com/en/general/how-to-create-your-own-ad-filters#wildcard} * * @param selector CSS selector string. * * @returns Unescaped CSS selector. * * @example * ```ts * QuoteUtils.unescapeAttributeDoubleQuotes('[attr="value with \\" quotes"]'); * QuoteUtils.unescapeAttributeDoubleQuotes('div[attr="value with \\" quotes"] > span'); * ``` * * @note In the legacy syntax, `""` is used to escape double quotes, but it cannot be used * in the standard CSS syntax, so we use conversion functions to handle this. * @note This function is intended to be used on whole attribute selector or whole selector strings. */ static unescapeAttributeDoubleQuotes(selector) { const nestingBlockPairs = new Map([ [OPEN_PARENTHESIS, CLOSE_PARENTHESIS], [OPEN_SQUARE_BRACKET, CLOSE_SQUARE_BRACKET], [SINGLE_QUOTE, SINGLE_QUOTE], [DOUBLE_QUOTE, DOUBLE_QUOTE], [BACKTICK_QUOTE, BACKTICK_QUOTE], ]); const nestingBlockStack = []; const buffer = []; for (let i = 0; i < selector.length; i += 1) { const char = selector[i]; // Check if we are inside of an attribute selector's value if ( // nesting will be 2 levels deep if we are inside of attribute selector's value nestingBlockStack.length === 2 // and the outer block is an attribute selector && nestingBlockStack[0] === CLOSE_SQUARE_BRACKET // and the inner block is a double quote && nestingBlockStack[1] === DOUBLE_QUOTE) { // We found `\"` inside of attribute selector's value if (char === ESCAPE_CHARACTER && selector[i + 1] === DOUBLE_QUOTE) { // Convert `\"` to `""` buffer.push(DOUBLE_QUOTE); buffer.push(DOUBLE_QUOTE); // Skip the next double quote i += 1; continue; } // Normal character inside of attribute selector's value buffer.push(char); continue; } // Handle entering nesting blocks if (nestingBlockPairs.has(char) && selector[i - 1] !== ESCAPE_CHARACTER) { nestingBlockStack.push(nestingBlockPairs.get(char)); buffer.push(char); continue; } // Handle exiting nesting blocks if (nestingBlockStack.length > 0 && char === nestingBlockStack[nestingBlockStack.length - 1] && selector[i - 1] !== ESCAPE_CHARACTER) { nestingBlockStack.pop(); buffer.push(char); continue; } // Normal character buffer.push(char); } return buffer.join(EMPTY); } } export { QUOTE_SET, QuoteType, QuoteUtils };