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.
This commit is contained in:
Raymond Hill 2022-12-10 11:18:24 -05:00
parent e959bc2832
commit 992255e993
No known key found for this signature in database
GPG key ID: 25E1490B761470C2
4 changed files with 104 additions and 73 deletions

View file

@ -176,8 +176,8 @@ const µBlock = { // jshint ignore:line
// Read-only // Read-only
systemSettings: { systemSettings: {
compiledMagic: 48, // Increase when compiled format changes compiledMagic: 49, // Increase when compiled format changes
selfieMagic: 48, // Increase when selfie format changes selfieMagic: 49, // Increase when selfie format changes
}, },
// https://github.com/uBlockOrigin/uBlock-issues/issues/759#issuecomment-546654501 // https://github.com/uBlockOrigin/uBlock-issues/issues/759#issuecomment-546654501

View file

@ -36,9 +36,6 @@ const nonVisualElements = {
const regexFromString = (s, exact = false) => { const regexFromString = (s, exact = false) => {
if ( s === '' ) { return /^/; } if ( s === '' ) { return /^/; }
if ( /^".+"$/.test(s) ) {
s = s.slice(1,-1).replace(/\\(\\|")/g, '$1');
}
const match = /^\/(.+)\/([i]?)$/.exec(s); const match = /^\/(.+)\/([i]?)$/.exec(s);
if ( match !== null ) { if ( match !== null ) {
return new RegExp(match[1], match[2] || undefined); return new RegExp(match[1], match[2] || undefined);
@ -68,11 +65,7 @@ class PSelectorVoidTask extends PSelectorTask {
class PSelectorHasTextTask extends PSelectorTask { class PSelectorHasTextTask extends PSelectorTask {
constructor(task) { constructor(task) {
super(); super();
let arg0 = task[1], arg1; this.needle = regexFromString(task[1]);
if ( Array.isArray(task[1]) ) {
arg1 = arg0[1]; arg0 = arg0[0];
}
this.needle = new RegExp(arg0, arg1);
} }
transpose(node, output) { transpose(node, output) {
if ( this.needle.test(node.textContent) ) { if ( this.needle.test(node.textContent) ) {
@ -168,11 +161,7 @@ class PSelectorMatchesMediaTask extends PSelectorTask {
class PSelectorMatchesPathTask extends PSelectorTask { class PSelectorMatchesPathTask extends PSelectorTask {
constructor(task) { constructor(task) {
super(); super();
let arg0 = task[1], arg1; this.needle = regexFromString(task[1]);
if ( Array.isArray(task[1]) ) {
arg1 = arg0[1]; arg0 = arg0[0];
}
this.needle = new RegExp(arg0, arg1);
} }
transpose(node, output) { transpose(node, output) {
if ( this.needle.test(self.location.pathname + self.location.search) ) { if ( this.needle.test(self.location.pathname + self.location.search) ) {
@ -450,13 +439,13 @@ class PSelector {
PSelector.prototype.operatorToTaskMap = undefined; PSelector.prototype.operatorToTaskMap = undefined;
class PSelectorRoot extends PSelector { class PSelectorRoot extends PSelector {
constructor(o, styleToken) { constructor(o) {
super(o); super(o);
this.budget = 200; // I arbitrary picked a 1/5 second this.budget = 200; // I arbitrary picked a 1/5 second
this.raw = o.raw; this.raw = o.raw;
this.cost = 0; this.cost = 0;
this.lastAllowanceTime = 0; this.lastAllowanceTime = 0;
this.styleToken = styleToken; this.action = o.action;
} }
prime(input) { prime(input) {
try { try {
@ -486,16 +475,8 @@ class ProceduralFilterer {
let mustCommit = false; let mustCommit = false;
for ( const selector of selectors ) { for ( const selector of selectors ) {
if ( this.selectors.has(selector.raw) ) { continue; } if ( this.selectors.has(selector.raw) ) { continue; }
let style, styleToken; const pselector = new PSelectorRoot(selector);
if ( selector.action === undefined ) { this.primeProceduralSelector(pselector);
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);
this.selectors.set(selector.raw, pselector); this.selectors.set(selector.raw, pselector);
addedSelectors.push(pselector); addedSelectors.push(pselector);
mustCommit = true; 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() { commitNow() {
if ( this.selectors.size === 0 ) { return; } if ( this.selectors.size === 0 ) { return; }
this.mustApplySelectors = false; this.mustApplySelectors = false;
//console.time('procedural selectors/dom layout changed');
// https://github.com/uBlockOrigin/uBlock-issues/issues/341 // https://github.com/uBlockOrigin/uBlock-issues/issues/341
// Be ready to unhide nodes which no longer matches any of // Be ready to unhide nodes which no longer matches any of
// the procedural selectors. // the procedural selectors.
@ -543,16 +533,15 @@ class ProceduralFilterer {
t0 = t1; t0 = t1;
if ( nodes.length === 0 ) { continue; } if ( nodes.length === 0 ) { continue; }
pselector.hit = true; pselector.hit = true;
this.styleNodes(nodes, pselector.styleToken); this.processNodes(nodes, pselector.action);
} }
this.unstyleNodes(toUnstyle); this.unprocessNodes(toUnstyle);
//console.timeEnd('procedural selectors/dom layout changed');
} }
styleTokenFromStyle(style) { styleTokenFromStyle(style) {
if ( style === undefined ) { return; } if ( style === undefined ) { return; }
let styleToken = this.styleTokenMap.get(style); let styleToken = this.styleTokenMap.get(vAPI.hideStyle);
if ( styleToken !== undefined ) { return styleToken; } if ( styleToken !== undefined ) { return styleToken; }
styleToken = vAPI.randomToken(); styleToken = vAPI.randomToken();
this.styleTokenMap.set(style, styleToken); this.styleTokenMap.set(style, styleToken);
@ -563,25 +552,60 @@ class ProceduralFilterer {
return styleToken; return styleToken;
} }
styleNodes(nodes, styleToken) { processNodes(nodes, action) {
if ( styleToken === undefined ) { 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 ) { for ( const node of nodes ) {
node.textContent = ''; node.setAttribute(this.masterToken, '');
node.remove(); node.setAttribute(styleToken, '');
this.styledNodes.add(node);
} }
return; break;
} }
for ( const node of nodes ) { case 'remove': {
node.setAttribute(this.masterToken, ''); for ( const node of nodes ) {
node.setAttribute(styleToken, ''); node.remove();
this.styledNodes.add(node); node.textContent = '';
}
break;
}
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 // TODO: Current assumption is one style per hit element. Could be an
// issue if an element has multiple styling and one styling is // issue if an element has multiple styling and one styling is
// brought back. Possibly too rare to care about this for now. // brought back. Possibly too rare to care about this for now.
unstyleNodes(nodes) { unprocessNodes(nodes) {
for ( const node of nodes ) { for ( const node of nodes ) {
if ( this.styledNodes.has(node) ) { continue; } if ( this.styledNodes.has(node) ) { continue; }
node.removeAttribute(this.masterToken); node.removeAttribute(this.masterToken);
@ -589,7 +613,9 @@ class ProceduralFilterer {
} }
createProceduralFilter(o) { 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() { onDOMCreated() {

View file

@ -756,9 +756,17 @@ const filterToDOMInterface = (( ) => {
try { try {
const o = JSON.parse(raw); const o = JSON.parse(raw);
elems = vAPI.domFilterer.createProceduralFilter(o).exec(); elems = vAPI.domFilterer.createProceduralFilter(o).exec();
style = o.action === undefined || o.action[0] !== 'style' switch ( o.action && o.action[0] || '' ) {
? vAPI.hideStyle case '':
: o.action[1]; case 'remove':
style = vAPI.hideStyle;
break;
case 'style':
style = o.action[1];
break;
default:
break;
}
} catch(ex) { } catch(ex) {
return; return;
} }
@ -809,6 +817,7 @@ const filterToDOMInterface = (( ) => {
const rootElem = document.documentElement; const rootElem = document.documentElement;
for ( const { elem, style } of lastResultset ) { for ( const { elem, style } of lastResultset ) {
if ( elem === pickerRoot ) { continue; } if ( elem === pickerRoot ) { continue; }
if ( style === undefined ) { continue; }
if ( elem === rootElem && style === vAPI.hideStyle ) { continue; } if ( elem === rootElem && style === vAPI.hideStyle ) { continue; }
let styleToken = vAPI.epickerStyleProxies.get(style); let styleToken = vAPI.epickerStyleProxies.get(style);
if ( styleToken === undefined ) { if ( styleToken === undefined ) {

View file

@ -1343,7 +1343,6 @@ Parser.prototype.SelectorCompiler = class {
this.reEatBackslashes = /\\([()])/g; this.reEatBackslashes = /\\([()])/g;
this.reEscapeRegex = /[.*+?^${}()|[\]\\]/g; this.reEscapeRegex = /[.*+?^${}()|[\]\\]/g;
this.regexToRawValue = new Map();
// https://github.com/gorhill/uBlock/issues/2793 // https://github.com/gorhill/uBlock/issues/2793
this.normalizedOperators = new Map([ this.normalizedOperators = new Map([
[ '-abp-has', 'has' ], [ '-abp-has', 'has' ],
@ -1379,6 +1378,8 @@ Parser.prototype.SelectorCompiler = class {
]); ]);
this.proceduralActionNames = new Set([ this.proceduralActionNames = new Set([
'remove', 'remove',
'remove-attr',
'remove-class',
'style', 'style',
]); ]);
this.normalizedExtendedSyntaxOperators = new Map([ this.normalizedExtendedSyntaxOperators = new Map([
@ -1563,6 +1564,10 @@ Parser.prototype.SelectorCompiler = class {
if ( this.maybeProceduralOperatorNames.has(data.name) === false ) { if ( this.maybeProceduralOperatorNames.has(data.name) === false ) {
return; return;
} }
if ( this.astHasType(args, 'ActionSelector') ) {
data.type = 'Error';
return;
}
if ( this.astHasType(args, 'ProceduralSelector') ) { if ( this.astHasType(args, 'ProceduralSelector') ) {
data.type = 'ProceduralSelector'; data.type = 'ProceduralSelector';
return; return;
@ -1719,7 +1724,7 @@ Parser.prototype.SelectorCompiler = class {
return out.join(''); return out.join('');
} }
astCompile(parts) { astCompile(parts, details = {}) {
if ( Array.isArray(parts) === false ) { return; } if ( Array.isArray(parts) === false ) { return; }
if ( parts.length === 0 ) { return; } if ( parts.length === 0 ) { return; }
if ( parts[0].data.type !== 'SelectorList' ) { return; } if ( parts[0].data.type !== 'SelectorList' ) { return; }
@ -1730,6 +1735,8 @@ Parser.prototype.SelectorCompiler = class {
const { data } = part; const { data } = part;
switch ( data.type ) { switch ( data.type ) {
case 'ActionSelector': { case 'ActionSelector': {
if ( details.noaction ) { return; }
if ( out.action !== undefined ) { return; }
if ( prelude.length !== 0 ) { if ( prelude.length !== 0 ) {
if ( tasks.length === 0 ) { if ( tasks.length === 0 ) {
out.selector = prelude.join(''); out.selector = prelude.join('');
@ -1881,23 +1888,20 @@ Parser.prototype.SelectorCompiler = class {
compileArgumentAst(operator, parts) { compileArgumentAst(operator, parts) {
switch ( operator ) { switch ( operator ) {
case 'has': { case 'has': {
let r = this.astCompile(parts); let r = this.astCompile(parts, { noaction: true });
if ( typeof r === 'string' ) { if ( typeof r === 'string' ) {
r = { selector: r.replace(/^\s*:scope\s*/, ' ') }; r = { selector: r.replace(/^\s*:scope\s*/, ' ') };
} }
return r; return r;
} }
case 'not': { case 'not': {
return this.astCompile(parts); return this.astCompile(parts, { noaction: true });
} }
default: default:
break; break;
} }
if ( Array.isArray(parts) === false || parts.length === 0 ) { return; }
let arg; const arg = this.astSerialize(parts, false);
if ( Array.isArray(parts) && parts.length !== 0 ) {
arg = this.astSerialize(parts, false);
}
if ( arg === undefined ) { return; } if ( arg === undefined ) { return; }
switch ( operator ) { switch ( operator ) {
case 'has-text': case 'has-text':
@ -1924,6 +1928,10 @@ Parser.prototype.SelectorCompiler = class {
return this.compileNoArgument(arg); return this.compileNoArgument(arg);
case 'remove': case 'remove':
return this.compileNoArgument(arg); return this.compileNoArgument(arg);
case 'remove-attr':
return this.compileText(arg);
case 'remove-class':
return this.compileText(arg);
case 'style': case 'style':
return this.compileStyleProperties(arg); return this.compileStyleProperties(arg);
case 'upward': case 'upward':
@ -1999,6 +2007,7 @@ Parser.prototype.SelectorCompiler = class {
if ( attr === '' ) { return; } if ( attr === '' ) { return; }
if ( value.length !== 0 ) { if ( value.length !== 0 ) {
r = this.unquoteString(value); r = this.unquoteString(value);
if ( r.i !== value.length ) { return; }
value = r.s; value = r.s;
} }
return { attr, value }; return { attr, value };
@ -2011,22 +2020,7 @@ Parser.prototype.SelectorCompiler = class {
if ( s === '' ) { return; } if ( s === '' ) { return; }
const r = this.unquoteString(s); const r = this.unquoteString(s);
if ( r.i !== s.length ) { return; } if ( r.i !== s.length ) { return; }
const match = this.reParseRegexLiteral.exec(r.s); return 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;
} }
compileCSSDeclaration(s) { compileCSSDeclaration(s) {
@ -2051,7 +2045,6 @@ Parser.prototype.SelectorCompiler = class {
} }
} else { } else {
regexDetails = '^' + value.replace(this.reEscapeRegex, '\\$&') + '$'; regexDetails = '^' + value.replace(this.reEscapeRegex, '\\$&') + '$';
this.regexToRawValue.set(regexDetails, value);
} }
return { name, pseudo, value: regexDetails }; return { name, pseudo, value: regexDetails };
} }
@ -2138,6 +2131,7 @@ Parser.prototype.proceduralOperatorTokens = new Map([
[ 'has-text', 0b01 ], [ 'has-text', 0b01 ],
[ 'if', 0b00 ], [ 'if', 0b00 ],
[ 'if-not', 0b00 ], [ 'if-not', 0b00 ],
[ 'matches-attr', 0b01 ],
[ 'matches-css', 0b11 ], [ 'matches-css', 0b11 ],
[ 'matches-media', 0b11 ], [ 'matches-media', 0b11 ],
[ 'matches-path', 0b11 ], [ 'matches-path', 0b11 ],
@ -2146,6 +2140,8 @@ Parser.prototype.proceduralOperatorTokens = new Map([
[ 'nth-ancestor', 0b00 ], [ 'nth-ancestor', 0b00 ],
[ 'others', 0b01 ], [ 'others', 0b01 ],
[ 'remove', 0b11 ], [ 'remove', 0b11 ],
[ 'remove-attr', 0b01 ],
[ 'remove-class', 0b01 ],
[ 'style', 0b11 ], [ 'style', 0b11 ],
[ 'upward', 0b01 ], [ 'upward', 0b01 ],
[ 'watch-attr', 0b11 ], [ 'watch-attr', 0b11 ],