From 992255e9937f81b5dd58524caa2ceeaeb29efb14 Mon Sep 17 00:00:00 2001 From: Raymond Hill Date: Sat, 10 Dec 2022 11:18:24 -0500 Subject: [PATCH] Add `:remove-attr()` and `:remove-class()` pseudo selector operators These two new pseudo selectors are _action_ operators, and thus can only be used at the end of a selector. They both take as argument a string or regex literal. For `:remove-class()`, when the argument matches a class name, that class name is removed. For `:remove-attr()`, when the argument matches an attribute name, that attribute is removed. These operators are meant to replace `+js(remove-attr, ...)` and `+js(remove-class, ...)`, which from now on are candidate for deprecation in some future. Once the next stable release is widespread, filter authors must use these two new operators instead of their `+js()` counterparts. --- src/js/background.js | 4 +- src/js/contentscript-extra.js | 108 ++++++++++++++++++------------ src/js/scriptlets/epicker.js | 15 ++++- src/js/static-filtering-parser.js | 48 ++++++------- 4 files changed, 103 insertions(+), 72 deletions(-) diff --git a/src/js/background.js b/src/js/background.js index 3ac26748222a6..69fbcccff6bd2 100644 --- a/src/js/background.js +++ b/src/js/background.js @@ -176,8 +176,8 @@ const µBlock = { // jshint ignore:line // Read-only systemSettings: { - compiledMagic: 48, // Increase when compiled format changes - selfieMagic: 48, // Increase when selfie format changes + compiledMagic: 49, // Increase when compiled format changes + selfieMagic: 49, // Increase when selfie format changes }, // https://github.com/uBlockOrigin/uBlock-issues/issues/759#issuecomment-546654501 diff --git a/src/js/contentscript-extra.js b/src/js/contentscript-extra.js index 277c8f27a67c5..11560b1153204 100644 --- a/src/js/contentscript-extra.js +++ b/src/js/contentscript-extra.js @@ -36,9 +36,6 @@ const nonVisualElements = { const regexFromString = (s, exact = false) => { if ( s === '' ) { return /^/; } - if ( /^".+"$/.test(s) ) { - s = s.slice(1,-1).replace(/\\(\\|")/g, '$1'); - } const match = /^\/(.+)\/([i]?)$/.exec(s); if ( match !== null ) { return new RegExp(match[1], match[2] || undefined); @@ -68,11 +65,7 @@ class PSelectorVoidTask extends PSelectorTask { class PSelectorHasTextTask extends PSelectorTask { constructor(task) { super(); - let arg0 = task[1], arg1; - if ( Array.isArray(task[1]) ) { - arg1 = arg0[1]; arg0 = arg0[0]; - } - this.needle = new RegExp(arg0, arg1); + this.needle = regexFromString(task[1]); } transpose(node, output) { if ( this.needle.test(node.textContent) ) { @@ -168,11 +161,7 @@ class PSelectorMatchesMediaTask extends PSelectorTask { class PSelectorMatchesPathTask extends PSelectorTask { constructor(task) { super(); - let arg0 = task[1], arg1; - if ( Array.isArray(task[1]) ) { - arg1 = arg0[1]; arg0 = arg0[0]; - } - this.needle = new RegExp(arg0, arg1); + this.needle = regexFromString(task[1]); } transpose(node, output) { if ( this.needle.test(self.location.pathname + self.location.search) ) { @@ -450,13 +439,13 @@ class PSelector { PSelector.prototype.operatorToTaskMap = undefined; class PSelectorRoot extends PSelector { - constructor(o, styleToken) { + constructor(o) { super(o); this.budget = 200; // I arbitrary picked a 1/5 second this.raw = o.raw; this.cost = 0; this.lastAllowanceTime = 0; - this.styleToken = styleToken; + this.action = o.action; } prime(input) { try { @@ -486,16 +475,8 @@ class ProceduralFilterer { let mustCommit = false; for ( const selector of selectors ) { if ( this.selectors.has(selector.raw) ) { continue; } - let style, styleToken; - if ( selector.action === undefined ) { - style = vAPI.hideStyle; - } else if ( selector.action[0] === 'style' ) { - style = selector.action[1]; - } - if ( style !== undefined ) { - styleToken = this.styleTokenFromStyle(style); - } - const pselector = new PSelectorRoot(selector, styleToken); + const pselector = new PSelectorRoot(selector); + this.primeProceduralSelector(pselector); this.selectors.set(selector.raw, pselector); addedSelectors.push(pselector); mustCommit = true; @@ -510,13 +491,22 @@ class ProceduralFilterer { } } + // This allows to perform potentially expensive initialization steps + // before the filters are ready to be applied. + primeProceduralSelector(pselector) { + if ( pselector.action === undefined ) { + this.styleTokenFromStyle(vAPI.hideStyle); + } else if ( pselector.action[0] === 'style' ) { + this.styleTokenFromStyle(pselector.action[1]); + } + return pselector; + } + commitNow() { if ( this.selectors.size === 0 ) { return; } this.mustApplySelectors = false; - //console.time('procedural selectors/dom layout changed'); - // https://github.com/uBlockOrigin/uBlock-issues/issues/341 // Be ready to unhide nodes which no longer matches any of // the procedural selectors. @@ -543,16 +533,15 @@ class ProceduralFilterer { t0 = t1; if ( nodes.length === 0 ) { continue; } pselector.hit = true; - this.styleNodes(nodes, pselector.styleToken); + this.processNodes(nodes, pselector.action); } - this.unstyleNodes(toUnstyle); - //console.timeEnd('procedural selectors/dom layout changed'); + this.unprocessNodes(toUnstyle); } styleTokenFromStyle(style) { if ( style === undefined ) { return; } - let styleToken = this.styleTokenMap.get(style); + let styleToken = this.styleTokenMap.get(vAPI.hideStyle); if ( styleToken !== undefined ) { return styleToken; } styleToken = vAPI.randomToken(); this.styleTokenMap.set(style, styleToken); @@ -563,25 +552,60 @@ class ProceduralFilterer { return styleToken; } - styleNodes(nodes, styleToken) { - if ( styleToken === undefined ) { + processNodes(nodes, action) { + const op = action && action[0] || ''; + const arg = op !== '' ? action[1] : ''; + switch ( op ) { + case '': + /* fall through */ + case 'style': { + const styleToken = this.styleTokenFromStyle( + arg === '' ? vAPI.hideStyle : arg + ); + for ( const node of nodes ) { + node.setAttribute(this.masterToken, ''); + node.setAttribute(styleToken, ''); + this.styledNodes.add(node); + } + break; + } + case 'remove': { for ( const node of nodes ) { - node.textContent = ''; node.remove(); + node.textContent = ''; } - return; + break; } - for ( const node of nodes ) { - node.setAttribute(this.masterToken, ''); - node.setAttribute(styleToken, ''); - this.styledNodes.add(node); + case 'remove-attr': { + const reAttr = regexFromString(arg, true); + for ( const node of nodes ) { + for ( const name of node.getAttributeNames() ) { + if ( reAttr.test(name) === false ) { continue; } + node.removeAttribute(name); + } + } + break; + } + case 'remove-class': { + const reClass = regexFromString(arg, true); + for ( const node of nodes ) { + const cl = node.classList; + for ( const name of cl.values() ) { + if ( reClass.test(name) === false ) { continue; } + cl.remove(name); + } + } + break; + } + default: + break; } } // TODO: Current assumption is one style per hit element. Could be an // issue if an element has multiple styling and one styling is // brought back. Possibly too rare to care about this for now. - unstyleNodes(nodes) { + unprocessNodes(nodes) { for ( const node of nodes ) { if ( this.styledNodes.has(node) ) { continue; } node.removeAttribute(this.masterToken); @@ -589,7 +613,9 @@ class ProceduralFilterer { } createProceduralFilter(o) { - return new PSelectorRoot(typeof o === 'string' ? JSON.parse(o) : o); + return this.primeProceduralSelector( + new PSelectorRoot(typeof o === 'string' ? JSON.parse(o) : o) + ); } onDOMCreated() { diff --git a/src/js/scriptlets/epicker.js b/src/js/scriptlets/epicker.js index 363ec9f99b877..e4588c9a58a14 100644 --- a/src/js/scriptlets/epicker.js +++ b/src/js/scriptlets/epicker.js @@ -756,9 +756,17 @@ const filterToDOMInterface = (( ) => { try { const o = JSON.parse(raw); elems = vAPI.domFilterer.createProceduralFilter(o).exec(); - style = o.action === undefined || o.action[0] !== 'style' - ? vAPI.hideStyle - : o.action[1]; + switch ( o.action && o.action[0] || '' ) { + case '': + case 'remove': + style = vAPI.hideStyle; + break; + case 'style': + style = o.action[1]; + break; + default: + break; + } } catch(ex) { return; } @@ -809,6 +817,7 @@ const filterToDOMInterface = (( ) => { const rootElem = document.documentElement; for ( const { elem, style } of lastResultset ) { if ( elem === pickerRoot ) { continue; } + if ( style === undefined ) { continue; } if ( elem === rootElem && style === vAPI.hideStyle ) { continue; } let styleToken = vAPI.epickerStyleProxies.get(style); if ( styleToken === undefined ) { diff --git a/src/js/static-filtering-parser.js b/src/js/static-filtering-parser.js index a2108989b472e..ae0642d1e9c5c 100644 --- a/src/js/static-filtering-parser.js +++ b/src/js/static-filtering-parser.js @@ -1343,7 +1343,6 @@ Parser.prototype.SelectorCompiler = class { this.reEatBackslashes = /\\([()])/g; this.reEscapeRegex = /[.*+?^${}()|[\]\\]/g; - this.regexToRawValue = new Map(); // https://github.com/gorhill/uBlock/issues/2793 this.normalizedOperators = new Map([ [ '-abp-has', 'has' ], @@ -1379,6 +1378,8 @@ Parser.prototype.SelectorCompiler = class { ]); this.proceduralActionNames = new Set([ 'remove', + 'remove-attr', + 'remove-class', 'style', ]); this.normalizedExtendedSyntaxOperators = new Map([ @@ -1563,6 +1564,10 @@ Parser.prototype.SelectorCompiler = class { if ( this.maybeProceduralOperatorNames.has(data.name) === false ) { return; } + if ( this.astHasType(args, 'ActionSelector') ) { + data.type = 'Error'; + return; + } if ( this.astHasType(args, 'ProceduralSelector') ) { data.type = 'ProceduralSelector'; return; @@ -1719,7 +1724,7 @@ Parser.prototype.SelectorCompiler = class { return out.join(''); } - astCompile(parts) { + astCompile(parts, details = {}) { if ( Array.isArray(parts) === false ) { return; } if ( parts.length === 0 ) { return; } if ( parts[0].data.type !== 'SelectorList' ) { return; } @@ -1730,6 +1735,8 @@ Parser.prototype.SelectorCompiler = class { const { data } = part; switch ( data.type ) { case 'ActionSelector': { + if ( details.noaction ) { return; } + if ( out.action !== undefined ) { return; } if ( prelude.length !== 0 ) { if ( tasks.length === 0 ) { out.selector = prelude.join(''); @@ -1881,23 +1888,20 @@ Parser.prototype.SelectorCompiler = class { compileArgumentAst(operator, parts) { switch ( operator ) { case 'has': { - let r = this.astCompile(parts); + let r = this.astCompile(parts, { noaction: true }); if ( typeof r === 'string' ) { r = { selector: r.replace(/^\s*:scope\s*/, ' ') }; } return r; } case 'not': { - return this.astCompile(parts); + return this.astCompile(parts, { noaction: true }); } default: break; } - - let arg; - if ( Array.isArray(parts) && parts.length !== 0 ) { - arg = this.astSerialize(parts, false); - } + if ( Array.isArray(parts) === false || parts.length === 0 ) { return; } + const arg = this.astSerialize(parts, false); if ( arg === undefined ) { return; } switch ( operator ) { case 'has-text': @@ -1924,6 +1928,10 @@ Parser.prototype.SelectorCompiler = class { return this.compileNoArgument(arg); case 'remove': return this.compileNoArgument(arg); + case 'remove-attr': + return this.compileText(arg); + case 'remove-class': + return this.compileText(arg); case 'style': return this.compileStyleProperties(arg); case 'upward': @@ -1999,6 +2007,7 @@ Parser.prototype.SelectorCompiler = class { if ( attr === '' ) { return; } if ( value.length !== 0 ) { r = this.unquoteString(value); + if ( r.i !== value.length ) { return; } value = r.s; } return { attr, value }; @@ -2011,22 +2020,7 @@ Parser.prototype.SelectorCompiler = class { if ( s === '' ) { return; } const r = this.unquoteString(s); if ( r.i !== s.length ) { return; } - const match = this.reParseRegexLiteral.exec(r.s); - let regexDetails; - if ( match !== null ) { - regexDetails = match[1]; - if ( this.isBadRegex(regexDetails) ) { return; } - if ( match[2] ) { - regexDetails = [ regexDetails, match[2] ]; - } - } else if ( r.s === '' ) { - regexDetails = '^$'; - } else { - regexDetails = r.s.replace(this.reEatBackslashes, '$1') - .replace(this.reEscapeRegex, '\\$&'); - this.regexToRawValue.set(regexDetails, r.s); - } - return regexDetails; + return r.s; } compileCSSDeclaration(s) { @@ -2051,7 +2045,6 @@ Parser.prototype.SelectorCompiler = class { } } else { regexDetails = '^' + value.replace(this.reEscapeRegex, '\\$&') + '$'; - this.regexToRawValue.set(regexDetails, value); } return { name, pseudo, value: regexDetails }; } @@ -2138,6 +2131,7 @@ Parser.prototype.proceduralOperatorTokens = new Map([ [ 'has-text', 0b01 ], [ 'if', 0b00 ], [ 'if-not', 0b00 ], + [ 'matches-attr', 0b01 ], [ 'matches-css', 0b11 ], [ 'matches-media', 0b11 ], [ 'matches-path', 0b11 ], @@ -2146,6 +2140,8 @@ Parser.prototype.proceduralOperatorTokens = new Map([ [ 'nth-ancestor', 0b00 ], [ 'others', 0b01 ], [ 'remove', 0b11 ], + [ 'remove-attr', 0b01 ], + [ 'remove-class', 0b01 ], [ 'style', 0b11 ], [ 'upward', 0b01 ], [ 'watch-attr', 0b11 ],