@adguard/agtree
Version:
Tool set for working with adblock filter lists
181 lines (178 loc) • 7.64 kB
JavaScript
/*
* 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 { sprintf } from 'sprintf-js';
import { TokenType, getFormattedTokenName } from '@adguard/css-tokenizer';
import { AdblockSyntaxError } from '../../../errors/adblock-syntax-error.js';
import { UBO_RESPONSEHEADER_FN } from '../../../utils/constants.js';
import { BaseParser } from '../../base-parser.js';
import { CssTokenStream } from '../../css/css-token-stream.js';
import { ValueParser } from '../../misc/value-parser.js';
import { defaultParserOptions } from '../../options.js';
import { HtmlFilteringBodyParser } from './html-filtering-body-parser.js';
/**
* `UboHtmlFilteringBodyParser` is responsible for parsing the body of
* an uBlock-style HTML filtering rule, and also uBlock-style response header removal rule.
*
* Please note that the parser will parse any HTML filtering rule if it is syntactically correct.
* For example, it will parse this:.
* ```adblock
* example.com##^script:pseudo(content)
* example.com##^responseheader(header-name)
* ```
*
* But it didn't check if the pseudo selector `pseudo` or if
* the header name `header-name` actually supported by any adblocker..
*
* @see {@link https://www.w3.org/TR/selectors-4}
* @see {@link https://github.com/gorhill/uBlock/wiki/Static-filter-syntax#html-filters}
* @see {@link https://github.com/gorhill/uBlock/wiki/Static-filter-syntax#response-header-filtering}
*/
class UboHtmlFilteringBodyParser extends BaseParser {
/**
* Parses the body of an uBlock-style HTML filtering rule
* and also uBlock-style response header removal rule.
*
* @param raw Raw input to parse.
* @param options Global parser options.
* @param baseOffset Starting offset of the input. Node locations are calculated relative to this offset.
*
* @returns Node of the parsed HTML filtering rule body.
*
* @throws If the body is syntactically incorrect.
*
* @example
* ```
* div:has-text(Example)
* responseheader(header-name)
* ```
*/
static parse(raw, options = defaultParserOptions, baseOffset = 0) {
// First, check if it's a response header removal rule and return if so
const responseHeaderBody = UboHtmlFilteringBodyParser.parseResponseHeaderRule(raw, options, baseOffset);
if (responseHeaderBody !== null) {
return responseHeaderBody;
}
return HtmlFilteringBodyParser.parse(raw, options, baseOffset);
}
/**
* Parses uBlock-style response header removal rule body.
*
* @param raw Raw input to parse.
* @param options Global parser options.
* @param baseOffset Starting offset of the input. Node locations are calculated relative to this offset.
*
* @returns Node of the parsed response header removal rule body
* or `null` if the body is not a response header removal rule.
*
* @throws If the body is syntactically incorrect.
*
* @example
* ```
* responseheader(header-name)
* ```
*
* @note This method returns `HtmlFilteringRuleBody` because,
* response header removal rule syntax is same as uBlock-style
* HTML filtering rule syntax.
*/
static parseResponseHeaderRule(raw, options = defaultParserOptions, baseOffset = 0) {
// If HTML filtering rules parsing is disabled, return null
if (!options.parseHtmlFilteringRuleBodies) {
return null;
}
// Construct the stream
const stream = new CssTokenStream(raw, baseOffset);
// Skip whitespaces before function
stream.skipWhitespace();
// Get token
let token = stream.get();
// Token should be a function
if (!token || token.type !== TokenType.Function) {
return null;
}
// Save the function start position
const { start } = token;
// Extract function name raw value (without opening parenthesis)
const functionNameRaw = raw.slice(token.start, token.end - 1);
// Check if it's `responseheader` function
if (functionNameRaw !== UBO_RESPONSEHEADER_FN) {
return null;
}
// Advance function token
stream.advance();
// Skip whitespaces after opening parenthesis
stream.skipWhitespace();
// Get argument token
token = stream.getOrFail();
// Save argument start position (starts after opening parenthesis and whitespaces)
const argumentStart = token.start;
// Skip until balanced (search for closing parenthesis)
stream.skipUntilBalanced();
// Get closing parenthesis token
token = stream.getOrFail();
// Save argument end position (ends before closing parenthesis)
const argumentEnd = token.start;
// Extract argument raw value
const argumentRaw = raw.slice(argumentStart, argumentEnd).trimEnd();
// Throw if the argument is empty
if (argumentRaw.length === 0) {
throw new AdblockSyntaxError(`Empty parameter for '${UBO_RESPONSEHEADER_FN}' function`, argumentStart + baseOffset, argumentEnd + baseOffset);
}
// Expect closing parenthesis
stream.expect(TokenType.CloseParenthesis);
// Advance closing parenthesis token
stream.advance();
// Skip whitespaces after closing parenthesis
stream.skipWhitespace();
// Expect the end of the rule - so nothing should be left in the stream
if (!stream.isEof()) {
token = stream.getOrFail();
throw new AdblockSyntaxError(sprintf("Expected end of rule, but got '%s'", getFormattedTokenName(token.type)), token.start + baseOffset, token.end + baseOffset);
}
// Construct pseudo-class selector node
const pseudoClassSelectorNode = {
type: 'PseudoClassSelector',
name: ValueParser.parse(functionNameRaw, options, start + baseOffset),
argument: ValueParser.parse(argumentRaw, options, argumentStart + baseOffset),
};
// Construct complex selector node
const complexSelectorNode = {
type: 'ComplexSelector',
children: [pseudoClassSelectorNode],
};
// Construct selector list node
const selectorList = {
type: 'SelectorList',
children: [complexSelectorNode],
};
// Construct body node
const result = {
type: 'HtmlFilteringRuleBody',
selectorList,
};
// Get last non-whitespace token
const lastNonWsToken = stream.lookbehindForNonWs();
// It shouldn't be null here, but just to be safe if it is
// it means that raw is empty or contains only whitespaces
if (!lastNonWsToken) {
return null;
}
// Include locations info if needed
if (options.isLocIncluded) {
result.start = baseOffset;
result.end = raw.length + baseOffset;
selectorList.start = start + baseOffset;
selectorList.end = lastNonWsToken.end + baseOffset;
complexSelectorNode.start = selectorList.start;
complexSelectorNode.end = selectorList.end;
pseudoClassSelectorNode.start = complexSelectorNode.start;
pseudoClassSelectorNode.end = complexSelectorNode.end;
}
return result;
}
}
export { UboHtmlFilteringBodyParser };