diff --git a/.mocharc.js b/.mocharc.js index bb9f3726a7..9f164ffe49 100644 --- a/.mocharc.js +++ b/.mocharc.js @@ -2,7 +2,7 @@ module.exports = { timeout: 10000, reporter: 'spec', require: ['ts-node/register'], - retries: 2, + retries: 0, color: false, extension: ['ts'], recursive: true, diff --git a/packages/adblocker-content/adblocker.ts b/packages/adblocker-content/adblocker.ts index eb7e8a3ded..03a2ba6fe9 100644 --- a/packages/adblocker-content/adblocker.ts +++ b/packages/adblocker-content/adblocker.ts @@ -6,6 +6,11 @@ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ +import type { AST } from '@cliqz/adblocker-extended-selectors'; + +const SCRIPT_ID = 'cliqz-adblocker-script'; +const IGNORED_TAGS = new Set(['br', 'head', 'link', 'meta', 'script', 'style', 's']); + export type Lifecycle = 'start' | 'dom-update'; export interface IBackgroundCallback { @@ -19,36 +24,40 @@ export interface IMessageFromBackground { active: boolean; scripts: string[]; styles: string; - extended: string[]; + extended: { + ast: AST; + remove: boolean; + attribute?: string | undefined; + }[]; } -export interface DOMElement { - href?: string | SVGAnimatedString | null; - nodeType?: number; - localName?: string; - id?: string; - classList?: DOMTokenList; - querySelectorAll?: ParentNode['querySelectorAll']; +function isElement(node: Node): node is Element { + return node.nodeType === Node.ELEMENT_NODE; } -export function getDOMElementsFromMutations(mutations: MutationRecord[]): DOMElement[] { +function shouldIgnoreElement(element: Element): boolean { + return IGNORED_TAGS.has(element.nodeName.toLowerCase()); +} + +function getElementsFromMutations(mutations: MutationRecord[]): Element[] { // Accumulate all nodes which were updated in `nodes` - const nodes: DOMElement[] = []; + const elements: Element[] = []; + for (const mutation of mutations) { if (mutation.type === 'attributes') { - nodes.push(mutation.target); + if (isElement(mutation.target)) { + elements.push(mutation.target); + } } else if (mutation.type === 'childList') { for (const addedNode of mutation.addedNodes) { - nodes.push(addedNode); - - const addedDOMElement: DOMElement = addedNode; - if (addedDOMElement.querySelectorAll !== undefined) { - nodes.push(...addedDOMElement.querySelectorAll('[id],[class],[href]')); + if (isElement(addedNode) && addedNode.id !== SCRIPT_ID) { + elements.push(addedNode); } } } } - return nodes; + + return elements; } /** @@ -58,44 +67,41 @@ export function getDOMElementsFromMutations(mutations: MutationRecord[]): DOMEle * more details). */ export function extractFeaturesFromDOM( - elements: DOMElement[], + roots: Element[], ): { classes: string[]; hrefs: string[]; ids: string[]; } { - const ignoredTags = new Set(['br', 'head', 'link', 'meta', 'script', 'style']); const classes: Set = new Set(); const hrefs: Set = new Set(); const ids: Set = new Set(); - for (const element of elements) { - if (element.nodeType !== 1 /* Node.ELEMENT_NODE */) { - continue; - } - - if (element.localName !== undefined && ignoredTags.has(element.localName)) { - continue; - } + for (const root of roots) { + for (const element of [root, ...root.querySelectorAll('[id],[class],[href]')]) { + if (shouldIgnoreElement(element)) { + continue; + } - // Update ids - const id = element.id; - if (id) { - ids.add(id); - } + // Update ids + const id = element.id; + if (id) { + ids.add(id); + } - // Update classes - const classList = element.classList; - if (classList) { - for (const cls of classList) { - classes.add(cls); + // Update classes + const classList = element.classList; + if (classList) { + for (const cls of classList) { + classes.add(cls); + } } - } - // Update href - const href = element.href; - if (typeof href === 'string') { - hrefs.add(href); + // Update href + const href = element.getAttribute('href'); + if (typeof href === 'string') { + hrefs.add(href); + } } } @@ -106,6 +112,20 @@ export function extractFeaturesFromDOM( }; } +export interface FeaturesUpdate { + type: 'features'; + ids: string[]; + classes: string[]; + hrefs: string[]; +} + +export interface ElementsUpdate { + type: 'elements'; + elements: Element[]; +} + +export type DOMUpdate = FeaturesUpdate | ElementsUpdate; + export class DOMMonitor { private knownIds: Set = new Set(); private knownHrefs: Set = new Set(); @@ -113,24 +133,25 @@ export class DOMMonitor { private observer: MutationObserver | null = null; - constructor( - private readonly cb: (features: { ids: string[]; classes: string[]; hrefs: string[] }) => void, - ) {} + constructor(private readonly cb: (update: DOMUpdate) => void) {} public queryAll(window: Pick): void { - this.handleNewNodes(Array.from(window.document.querySelectorAll('[id],[class],[href]'))); + this.cb({ type: 'elements', elements: [window.document.documentElement] }); + this.handleUpdatedNodes([window.document.documentElement]); } + public start( window: Pick & { MutationObserver?: typeof MutationObserver }, ): void { if (this.observer === null && window.MutationObserver !== undefined) { this.observer = new window.MutationObserver((mutations: MutationRecord[]) => { - this.handleNewNodes(getDOMElementsFromMutations(mutations)); + this.handleUpdatedNodes(getElementsFromMutations(mutations)); }); this.observer.observe(window.document.documentElement, { - attributeFilter: ['class', 'id', 'href'], + // Monitor some attributes attributes: true, + attributeFilter: ['class', 'id', 'href'], childList: true, subtree: true, }); @@ -181,6 +202,7 @@ export class DOMMonitor { if (newIds.length !== 0 || newClasses.length !== 0 || newHrefs.length !== 0) { this.cb({ + type: 'features', classes: newClasses, hrefs: newHrefs, ids: newIds, @@ -191,8 +213,16 @@ export class DOMMonitor { return false; } - private handleNewNodes(nodes: DOMElement[]): boolean { - return this.handleNewFeatures(extractFeaturesFromDOM(nodes)); + private handleUpdatedNodes(elements: Element[]): boolean { + if (elements.length !== 0) { + this.cb({ + type: 'elements', + elements: elements.filter((e) => shouldIgnoreElement(e) === false), + }); + return this.handleNewFeatures(extractFeaturesFromDOM(elements)); + } + + return false; } } @@ -220,21 +250,10 @@ export function autoRemoveScript(script: string): string { // })(); } -export function injectCSSRule(rule: string, doc: Document): void { - const parent = doc.head || doc.getElementsByTagName('head')[0] || doc.documentElement; - if (parent !== null) { - const css = doc.createElement('style'); - css.type = 'text/css'; - css.id = 'cliqz-adblokcer-css-rules'; - css.appendChild(doc.createTextNode(rule)); - parent.appendChild(css); - } -} - export function injectScript(s: string, doc: Document): void { const script = doc.createElement('script'); script.type = 'text/javascript'; - script.id = 'cliqz-adblocker-script'; + script.id = SCRIPT_ID; script.async = false; script.appendChild(doc.createTextNode(autoRemoveScript(s))); diff --git a/packages/adblocker-content/package.json b/packages/adblocker-content/package.json index cdc93a4544..9416a35500 100644 --- a/packages/adblocker-content/package.json +++ b/packages/adblocker-content/package.json @@ -32,13 +32,16 @@ "bugs": { "url": "https://github.com/cliqz-oss/adblocker/issues" }, + "dependencies": { + "@cliqz/adblocker-extended-selectors": "^1.19.0" + }, "devDependencies": { - "@ampproject/rollup-plugin-closure-compiler": "^0.26.0", "@rollup/plugin-node-resolve": "^11.0.0", "@types/node": "^14.0.23", "rimraf": "^3.0.0", "rollup": "^2.0.0", "rollup-plugin-sourcemaps": "^0.6.1", + "rollup-plugin-terser": "^7.0.0", "tslint": "^6.0.0", "tslint-config-prettier": "^1.18.0", "tslint-no-unused-expression-chai": "^0.1.4", diff --git a/packages/adblocker-content/rollup.config.ts b/packages/adblocker-content/rollup.config.ts index 7c0e1f7305..4041cc3339 100644 --- a/packages/adblocker-content/rollup.config.ts +++ b/packages/adblocker-content/rollup.config.ts @@ -6,9 +6,9 @@ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ -import compiler from '@ampproject/rollup-plugin-closure-compiler'; import resolve from '@rollup/plugin-node-resolve'; import sourcemaps from 'rollup-plugin-sourcemaps'; +import { terser } from 'rollup-plugin-terser'; export default { input: './dist/es6/adblocker.js', @@ -21,9 +21,10 @@ export default { plugins: [ resolve(), sourcemaps(), - compiler({ - // language: 'ECMASCRIPT6_STRICT', - language_out: 'NO_TRANSPILE', + terser({ + output: { + comments: false, + }, }), ], }; diff --git a/packages/adblocker-content/tsconfig.json b/packages/adblocker-content/tsconfig.json index 1f5e00a690..7a8a17acb5 100644 --- a/packages/adblocker-content/tsconfig.json +++ b/packages/adblocker-content/tsconfig.json @@ -5,5 +5,8 @@ "outDir": "dist/cjs", "declarationDir": "dist/types" }, + "references": [ + { "path": "../adblocker-extended-selectors/tsconfig.json" }, + ], "include": ["./adblocker.ts"] } diff --git a/packages/adblocker-electron-preload/preload.ts b/packages/adblocker-electron-preload/preload.ts index 854fc73f32..16eb41a326 100644 --- a/packages/adblocker-electron-preload/preload.ts +++ b/packages/adblocker-electron-preload/preload.ts @@ -32,7 +32,11 @@ if (window === window.top && window.location.href.startsWith('devtools://') === ipcRenderer.on( 'get-cosmetic-filters-response', - (_: Electron.IpcRendererEvent, { active, scripts }: IMessageFromBackground) => { + // TODO - implement extended filtering for Electron + ( + _: Electron.IpcRendererEvent, + { active, scripts /* , extended */ }: IMessageFromBackground, + ) => { if (active === false) { ACTIVE = false; unload(); @@ -56,13 +60,13 @@ if (window === window.top && window.location.href.startsWith('devtools://') === window.addEventListener( 'DOMContentLoaded', () => { - DOM_MONITOR = new DOMMonitor(({ classes, ids, hrefs }) => { - getCosmeticsFilters({ - classes, - hrefs, - ids, - lifecycle: 'dom-update', - }); + DOM_MONITOR = new DOMMonitor((update) => { + if (update.type === 'features') { + getCosmeticsFilters({ + ...update, + lifecycle: 'dom-update', + }); + } }); DOM_MONITOR.queryAll(window); diff --git a/packages/adblocker-electron/adblocker.ts b/packages/adblocker-electron/adblocker.ts index 6b2564075a..f7f0d7105d 100644 --- a/packages/adblocker-electron/adblocker.ts +++ b/packages/adblocker-electron/adblocker.ts @@ -161,7 +161,7 @@ export class ElectronBlocker extends FiltersEngine { const hostname = parsed.hostname || ''; const domain = parsed.domain || ''; - const { active, styles, scripts } = this.getCosmeticsFilters({ + const { active, styles, scripts, extended } = this.getCosmeticsFilters({ domain, hostname, url, @@ -173,6 +173,7 @@ export class ElectronBlocker extends FiltersEngine { // This needs to be done only once per frame getBaseRules: msg.lifecycle === 'start', getInjectionRules: msg.lifecycle === 'start', + getExtendedRules: msg.lifecycle === 'start', getRulesFromHostname: msg.lifecycle === 'start', // This will be done every time we get information about DOM mutation @@ -189,7 +190,7 @@ export class ElectronBlocker extends FiltersEngine { // Inject scripts from content script event.sender.send('get-cosmetic-filters-response', { active, - extended: [], + extended, scripts, styles: '', } as IMessageFromBackground); diff --git a/packages/adblocker-extended-selectors/LICENSE b/packages/adblocker-extended-selectors/LICENSE new file mode 100644 index 0000000000..11ce717a7c --- /dev/null +++ b/packages/adblocker-extended-selectors/LICENSE @@ -0,0 +1,375 @@ +Copyright (c) 2017-present Cliqz GmbH. All rights reserved. + +Mozilla Public License Version 2.0 +================================== + +1. Definitions +-------------- + +1.1. "Contributor" + means each individual or legal entity that creates, contributes to + the creation of, or owns Covered Software. + +1.2. "Contributor Version" + means the combination of the Contributions of others (if any) used + by a Contributor and that particular Contributor's Contribution. + +1.3. "Contribution" + means Covered Software of a particular Contributor. + +1.4. "Covered Software" + means Source Code Form to which the initial Contributor has attached + the notice in Exhibit A, the Executable Form of such Source Code + Form, and Modifications of such Source Code Form, in each case + including portions thereof. + +1.5. "Incompatible With Secondary Licenses" + means + + (a) that the initial Contributor has attached the notice described + in Exhibit B to the Covered Software; or + + (b) that the Covered Software was made available under the terms of + version 1.1 or earlier of the License, but not also under the + terms of a Secondary License. + +1.6. "Executable Form" + means any form of the work other than Source Code Form. + +1.7. "Larger Work" + means a work that combines Covered Software with other material, in + a separate file or files, that is not Covered Software. + +1.8. "License" + means this document. + +1.9. "Licensable" + means having the right to grant, to the maximum extent possible, + whether at the time of the initial grant or subsequently, any and + all of the rights conveyed by this License. + +1.10. "Modifications" + means any of the following: + + (a) any file in Source Code Form that results from an addition to, + deletion from, or modification of the contents of Covered + Software; or + + (b) any new file in Source Code Form that contains any Covered + Software. + +1.11. "Patent Claims" of a Contributor + means any patent claim(s), including without limitation, method, + process, and apparatus claims, in any patent Licensable by such + Contributor that would be infringed, but for the grant of the + License, by the making, using, selling, offering for sale, having + made, import, or transfer of either its Contributions or its + Contributor Version. + +1.12. "Secondary License" + means either the GNU General Public License, Version 2.0, the GNU + Lesser General Public License, Version 2.1, the GNU Affero General + Public License, Version 3.0, or any later versions of those + licenses. + +1.13. "Source Code Form" + means the form of the work preferred for making modifications. + +1.14. "You" (or "Your") + means an individual or a legal entity exercising rights under this + License. For legal entities, "You" includes any entity that + controls, is controlled by, or is under common control with You. For + purposes of this definition, "control" means (a) the power, direct + or indirect, to cause the direction or management of such entity, + whether by contract or otherwise, or (b) ownership of more than + fifty percent (50%) of the outstanding shares or beneficial + ownership of such entity. + +2. License Grants and Conditions +-------------------------------- + +2.1. Grants + +Each Contributor hereby grants You a world-wide, royalty-free, +non-exclusive license: + +(a) under intellectual property rights (other than patent or trademark) + Licensable by such Contributor to use, reproduce, make available, + modify, display, perform, distribute, and otherwise exploit its + Contributions, either on an unmodified basis, with Modifications, or + as part of a Larger Work; and + +(b) under Patent Claims of such Contributor to make, use, sell, offer + for sale, have made, import, and otherwise transfer either its + Contributions or its Contributor Version. + +2.2. Effective Date + +The licenses granted in Section 2.1 with respect to any Contribution +become effective for each Contribution on the date the Contributor first +distributes such Contribution. + +2.3. Limitations on Grant Scope + +The licenses granted in this Section 2 are the only rights granted under +this License. No additional rights or licenses will be implied from the +distribution or licensing of Covered Software under this License. +Notwithstanding Section 2.1(b) above, no patent license is granted by a +Contributor: + +(a) for any code that a Contributor has removed from Covered Software; + or + +(b) for infringements caused by: (i) Your and any other third party's + modifications of Covered Software, or (ii) the combination of its + Contributions with other software (except as part of its Contributor + Version); or + +(c) under Patent Claims infringed by Covered Software in the absence of + its Contributions. + +This License does not grant any rights in the trademarks, service marks, +or logos of any Contributor (except as may be necessary to comply with +the notice requirements in Section 3.4). + +2.4. Subsequent Licenses + +No Contributor makes additional grants as a result of Your choice to +distribute the Covered Software under a subsequent version of this +License (see Section 10.2) or under the terms of a Secondary License (if +permitted under the terms of Section 3.3). + +2.5. Representation + +Each Contributor represents that the Contributor believes its +Contributions are its original creation(s) or it has sufficient rights +to grant the rights to its Contributions conveyed by this License. + +2.6. Fair Use + +This License is not intended to limit any rights You have under +applicable copyright doctrines of fair use, fair dealing, or other +equivalents. + +2.7. Conditions + +Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted +in Section 2.1. + +3. Responsibilities +------------------- + +3.1. Distribution of Source Form + +All distribution of Covered Software in Source Code Form, including any +Modifications that You create or to which You contribute, must be under +the terms of this License. You must inform recipients that the Source +Code Form of the Covered Software is governed by the terms of this +License, and how they can obtain a copy of this License. You may not +attempt to alter or restrict the recipients' rights in the Source Code +Form. + +3.2. Distribution of Executable Form + +If You distribute Covered Software in Executable Form then: + +(a) such Covered Software must also be made available in Source Code + Form, as described in Section 3.1, and You must inform recipients of + the Executable Form how they can obtain a copy of such Source Code + Form by reasonable means in a timely manner, at a charge no more + than the cost of distribution to the recipient; and + +(b) You may distribute such Executable Form under the terms of this + License, or sublicense it under different terms, provided that the + license for the Executable Form does not attempt to limit or alter + the recipients' rights in the Source Code Form under this License. + +3.3. Distribution of a Larger Work + +You may create and distribute a Larger Work under terms of Your choice, +provided that You also comply with the requirements of this License for +the Covered Software. If the Larger Work is a combination of Covered +Software with a work governed by one or more Secondary Licenses, and the +Covered Software is not Incompatible With Secondary Licenses, this +License permits You to additionally distribute such Covered Software +under the terms of such Secondary License(s), so that the recipient of +the Larger Work may, at their option, further distribute the Covered +Software under the terms of either this License or such Secondary +License(s). + +3.4. Notices + +You may not remove or alter the substance of any license notices +(including copyright notices, patent notices, disclaimers of warranty, +or limitations of liability) contained within the Source Code Form of +the Covered Software, except that You may alter any license notices to +the extent required to remedy known factual inaccuracies. + +3.5. Application of Additional Terms + +You may choose to offer, and to charge a fee for, warranty, support, +indemnity or liability obligations to one or more recipients of Covered +Software. However, You may do so only on Your own behalf, and not on +behalf of any Contributor. You must make it absolutely clear that any +such warranty, support, indemnity, or liability obligation is offered by +You alone, and You hereby agree to indemnify every Contributor for any +liability incurred by such Contributor as a result of warranty, support, +indemnity or liability terms You offer. You may include additional +disclaimers of warranty and limitations of liability specific to any +jurisdiction. + +4. Inability to Comply Due to Statute or Regulation +--------------------------------------------------- + +If it is impossible for You to comply with any of the terms of this +License with respect to some or all of the Covered Software due to +statute, judicial order, or regulation then You must: (a) comply with +the terms of this License to the maximum extent possible; and (b) +describe the limitations and the code they affect. Such description must +be placed in a text file included with all distributions of the Covered +Software under this License. Except to the extent prohibited by statute +or regulation, such description must be sufficiently detailed for a +recipient of ordinary skill to be able to understand it. + +5. Termination +-------------- + +5.1. The rights granted under this License will terminate automatically +if You fail to comply with any of its terms. However, if You become +compliant, then the rights granted under this License from a particular +Contributor are reinstated (a) provisionally, unless and until such +Contributor explicitly and finally terminates Your grants, and (b) on an +ongoing basis, if such Contributor fails to notify You of the +non-compliance by some reasonable means prior to 60 days after You have +come back into compliance. Moreover, Your grants from a particular +Contributor are reinstated on an ongoing basis if such Contributor +notifies You of the non-compliance by some reasonable means, this is the +first time You have received notice of non-compliance with this License +from such Contributor, and You become compliant prior to 30 days after +Your receipt of the notice. + +5.2. If You initiate litigation against any entity by asserting a patent +infringement claim (excluding declaratory judgment actions, +counter-claims, and cross-claims) alleging that a Contributor Version +directly or indirectly infringes any patent, then the rights granted to +You by any and all Contributors for the Covered Software under Section +2.1 of this License shall terminate. + +5.3. In the event of termination under Sections 5.1 or 5.2 above, all +end user license agreements (excluding distributors and resellers) which +have been validly granted by You or Your distributors under this License +prior to termination shall survive termination. + +************************************************************************ +* * +* 6. Disclaimer of Warranty * +* ------------------------- * +* * +* Covered Software is provided under this License on an "as is" * +* basis, without warranty of any kind, either expressed, implied, or * +* statutory, including, without limitation, warranties that the * +* Covered Software is free of defects, merchantable, fit for a * +* particular purpose or non-infringing. The entire risk as to the * +* quality and performance of the Covered Software is with You. * +* Should any Covered Software prove defective in any respect, You * +* (not any Contributor) assume the cost of any necessary servicing, * +* repair, or correction. This disclaimer of warranty constitutes an * +* essential part of this License. No use of any Covered Software is * +* authorized under this License except under this disclaimer. * +* * +************************************************************************ + +************************************************************************ +* * +* 7. Limitation of Liability * +* -------------------------- * +* * +* Under no circumstances and under no legal theory, whether tort * +* (including negligence), contract, or otherwise, shall any * +* Contributor, or anyone who distributes Covered Software as * +* permitted above, be liable to You for any direct, indirect, * +* special, incidental, or consequential damages of any character * +* including, without limitation, damages for lost profits, loss of * +* goodwill, work stoppage, computer failure or malfunction, or any * +* and all other commercial damages or losses, even if such party * +* shall have been informed of the possibility of such damages. This * +* limitation of liability shall not apply to liability for death or * +* personal injury resulting from such party's negligence to the * +* extent applicable law prohibits such limitation. Some * +* jurisdictions do not allow the exclusion or limitation of * +* incidental or consequential damages, so this exclusion and * +* limitation may not apply to You. * +* * +************************************************************************ + +8. Litigation +------------- + +Any litigation relating to this License may be brought only in the +courts of a jurisdiction where the defendant maintains its principal +place of business and such litigation shall be governed by laws of that +jurisdiction, without reference to its conflict-of-law provisions. +Nothing in this Section shall prevent a party's ability to bring +cross-claims or counter-claims. + +9. Miscellaneous +---------------- + +This License represents the complete agreement concerning the subject +matter hereof. If any provision of this License is held to be +unenforceable, such provision shall be reformed only to the extent +necessary to make it enforceable. Any law or regulation which provides +that the language of a contract shall be construed against the drafter +shall not be used to construe this License against a Contributor. + +10. Versions of the License +--------------------------- + +10.1. New Versions + +Mozilla Foundation is the license steward. Except as provided in Section +10.3, no one other than the license steward has the right to modify or +publish new versions of this License. Each version will be given a +distinguishing version number. + +10.2. Effect of New Versions + +You may distribute the Covered Software under the terms of the version +of the License under which You originally received the Covered Software, +or under the terms of any subsequent version published by the license +steward. + +10.3. Modified Versions + +If you create software not governed by this License, and you want to +create a new license for such software, you may create and use a +modified version of this License if you rename the license and remove +any references to the name of the license steward (except to note that +such modified license differs from this License). + +10.4. Distributing Source Code Form that is Incompatible With Secondary +Licenses + +If You choose to distribute Source Code Form that is Incompatible With +Secondary Licenses under the terms of this version of the License, the +notice described in Exhibit B of this License must be attached. + +Exhibit A - Source Code Form License Notice +------------------------------------------- + + This Source Code Form is subject to the terms of the Mozilla Public + License, v. 2.0. If a copy of the MPL was not distributed with this + file, You can obtain one at http://mozilla.org/MPL/2.0/. + +If it is not possible or desirable to put the notice in a particular +file, then You may include the notice in a location (such as a LICENSE +file in a relevant directory) where a recipient would be likely to look +for such a notice. + +You may add additional accurate notices of copyright ownership. + +Exhibit B - "Incompatible With Secondary Licenses" Notice +--------------------------------------------------------- + + This Source Code Form is "Incompatible With Secondary Licenses", as + defined by the Mozilla Public License, v. 2.0. \ No newline at end of file diff --git a/packages/adblocker-extended-selectors/README.md b/packages/adblocker-extended-selectors/README.md new file mode 100644 index 0000000000..62dd02e223 --- /dev/null +++ b/packages/adblocker-extended-selectors/README.md @@ -0,0 +1,46 @@ +

@cliqz/adblocker-extended-selectors

+ +

+ + Efficient + · Minimal + · JavaScript + · TypeScript + · uBlock Origin- and Easylist-compatible + +
+ + Node.js + · Puppeteer + · Electron + · WebExtension + +

+ +

+ + Github Actions Build Status + + Github Actions Assets Status + + Blazing Fast + + npm version + + weekly downloads from npm +
+ + code style: prettier + + Follow Cliqz on Twitter + + Dependabot + + License Badge + + LGTM Badge +

+ +--- + +This package is part of [@cliqz/adblocker](https://github.com/cliqz-oss/adblocker). diff --git a/packages/adblocker-extended-selectors/adblocker.ts b/packages/adblocker-extended-selectors/adblocker.ts new file mode 100644 index 0000000000..738dce0f71 --- /dev/null +++ b/packages/adblocker-extended-selectors/adblocker.ts @@ -0,0 +1,18 @@ +/*! + * Copyright (c) 2017-present Cliqz GmbH. All rights reserved. + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +export { parse, tokenize } from './src/parse'; +export { querySelectorAll, matches } from './src/eval'; +export * from './src/types'; +export { + EXTENDED_PSEUDO_CLASSES, + PSEUDO_CLASSES, + PSEUDO_ELEMENTS, + SelectorType, + classifySelector, +} from './src/extended'; diff --git a/packages/adblocker-extended-selectors/package.json b/packages/adblocker-extended-selectors/package.json new file mode 100644 index 0000000000..6aebdc9e3f --- /dev/null +++ b/packages/adblocker-extended-selectors/package.json @@ -0,0 +1,60 @@ +{ + "name": "@cliqz/adblocker-extended-selectors", + "version": "1.19.0", + "description": "Cliqz adblocker library (extended CSS selectors implementation)", + "author": { + "name": "Cliqz" + }, + "homepage": "https://github.com/cliqz-oss/adblocker#readme", + "license": "MPL-2.0", + "main": "dist/cjs/adblocker.js", + "module": "dist/es6/adblocker.js", + "types": "dist/types/adblocker.d.ts", + "files": [ + "LICENSE", + "dist" + ], + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "git+ssh://git@github.com/cliqz-oss/adblocker.git", + "directory": "packages/adblocker-extended-selectors" + }, + "scripts": { + "clean": "rimraf dist coverage", + "lint": "tslint --config ../../tslint.json --project ./tsconfig.json", + "build": "tsc --build ./tsconfig.json", + "bundle": "tsc --build ./tsconfig.bundle.json", + "prepack": "yarn run bundle", + "test": "nyc mocha --config ../../.mocharc.js" + }, + "bugs": { + "url": "https://github.com/cliqz-oss/adblocker/issues" + }, + "devDependencies": { + "@types/chai": "^4.2.11", + "@types/chai-as-promised": "^7.1.2", + "@types/jsdom": "^16.2.5", + "@types/mocha": "^8.0.0", + "@types/node": "^14.0.23", + "chai": "^4.2.0", + "chai-as-promised": "^7.1.1", + "jsdom": "^16.4.0", + "mocha": "^8.0.1", + "nyc": "^15.0.0", + "rimraf": "^3.0.0", + "ts-node": "^9.0.0", + "tslint": "^6.0.0", + "tslint-config-prettier": "^1.18.0", + "tslint-no-unused-expression-chai": "^0.1.4", + "typescript": "^4.1.2" + }, + "contributors": [ + { + "name": "Rémi Berson", + "email": "remi@cliqz.com" + } + ] +} diff --git a/packages/adblocker-extended-selectors/src/eval.ts b/packages/adblocker-extended-selectors/src/eval.ts new file mode 100644 index 0000000000..da0e2f62a3 --- /dev/null +++ b/packages/adblocker-extended-selectors/src/eval.ts @@ -0,0 +1,175 @@ +/*! + * Copyright (c) 2017-present Cliqz GmbH. All rights reserved. + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +import type { AST } from './types'; + +export function matchPattern(pattern: string, text: string): boolean { + // TODO - support 'm' RegExp argument + if (pattern.startsWith('/') && (pattern.endsWith('/') || pattern.endsWith('/i'))) { + let caseSensitive = true; + pattern = pattern.slice(1); + + if (pattern.endsWith('/')) { + pattern = pattern.slice(0, -1); + } else { + pattern = pattern.slice(0, -2); + caseSensitive = false; + } + + return new RegExp(pattern, caseSensitive === false ? 'i' : undefined).test(text); + } + + return text.includes(pattern); +} + +export function matches(element: Element, selector: AST): boolean { + if ( + selector.type === 'id' || + selector.type === 'class' || + selector.type === 'type' || + selector.type === 'attribute' + ) { + return element.matches(selector.content); + } else if (selector.type === 'list') { + return selector.list.some((s) => matches(element, s)); + } else if (selector.type === 'compound') { + return selector.compound.every((s) => matches(element, s)); + } else if (selector.type === 'pseudo-class') { + if (selector.name === 'has' || selector.name === 'if') { + // TODO - is this a querySelectorAll or matches here? + return ( + selector.subtree !== undefined && querySelectorAll(element, selector.subtree).length !== 0 + ); + } else if (selector.name === 'not') { + return selector.subtree !== undefined && matches(element, selector.subtree) === false; + } else if (selector.name === 'has-text') { + const { argument } = selector; + if (argument === undefined) { + return false; + } + + const text = element.textContent; + if (text === null) { + return false; + } + + return matchPattern(argument, text); + } else if (selector.name === 'min-text-length') { + const minLength = Number(selector.argument); + if (Number.isNaN(minLength) || minLength < 0) { + return false; + } + + const text = element.textContent; + if (text === null) { + return false; + } + + return text.length >= minLength; + } + } + + return false; +} + +export function querySelectorAll(element: Element, selector: AST): Element[] { + const elements: Element[] = []; + + if ( + selector.type === 'id' || + selector.type === 'class' || + selector.type === 'type' || + selector.type === 'attribute' + ) { + elements.push(...element.querySelectorAll(selector.content)); + } else if (selector.type === 'list') { + for (const subSelector of selector.list) { + elements.push(...querySelectorAll(element, subSelector)); + } + } else if (selector.type === 'compound') { + // TODO - handling compound needs to be reworked... + // .cls:upward(1) for example will not work with this implementation. + // :upward is not about selecting, but transforming a set of nodes (i.e. + // uBO's transpose method). + if (selector.compound.length !== 0) { + elements.push( + ...querySelectorAll(element, selector.compound[0]).filter((e) => + selector.compound.slice(1).every((s) => matches(e, s)), + ), + ); + } + } else if (selector.type === 'complex') { + const elements2 = + selector.left === undefined ? [element] : querySelectorAll(element, selector.left); + + if (selector.combinator === ' ') { + for (const element2 of elements2) { + elements.push(...querySelectorAll(element2, selector.right)); + } + } else if (selector.combinator === '>') { + for (const element2 of elements2) { + for (const child of element2.children) { + if (matches(child, selector.right) === true) { + elements.push(child); + } + } + } + } else if (selector.combinator === '~') { + for (const element2 of elements2) { + let sibling: Element | null = element2; + /* tslint:disable no-conditional-assignment */ + while ((sibling = sibling.nextElementSibling) !== null) { + if (matches(sibling, selector.right) === true) { + elements.push(sibling); + } + } + } + } else if (selector.combinator === '+') { + for (const element2 of elements2) { + const nextElementSibling = element2.nextElementSibling; + if (nextElementSibling !== null && matches(nextElementSibling, selector.right) === true) { + elements.push(nextElementSibling); + } + } + } + } else if (selector.type === 'pseudo-class') { + // if (selector.name === 'upward') { + // let n = Number(selector.argument); + // console.log('upward', selector, n); + // if (Number.isNaN(n) === false) { + // if (n >= 1 && n < 256) { + // let ancestor: Element | null = element; + // while (ancestor !== null && n > 0) { + // ancestor = ancestor.parentElement; + // n -= 1; + // } + + // if (ancestor !== null && n === 0) { + // elements.push(element); + // } + // } + // } else if (selector.argument !== undefined) { + // const parent = element.parentElement; + // if (parent !== null) { + // const ancestor = parent.closest(selector.argument); + // if (ancestor !== null) { + // elements.push(ancestor); + // } + // } + // } + // } else { + for (const subElement of element.querySelectorAll('*')) { + if (matches(subElement, selector) === true) { + elements.push(subElement); + } + } + // } + } + + return elements; +} diff --git a/packages/adblocker-extended-selectors/src/extended.ts b/packages/adblocker-extended-selectors/src/extended.ts new file mode 100644 index 0000000000..b64562565e --- /dev/null +++ b/packages/adblocker-extended-selectors/src/extended.ts @@ -0,0 +1,139 @@ +/*! + * Copyright (c) 2017-present Cliqz GmbH. All rights reserved. + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +import { tokenize, RECURSIVE_PSEUDO_CLASSES } from './parse'; + +export const EXTENDED_PSEUDO_CLASSES = new Set([ + // '-abp-contains', + // '-abp-has', + // '-abp-properties', + 'has', + 'has-text', + 'if', + // 'if-not', + // 'matches-css', + // 'matches-css-after', + // 'matches-css-before', + // 'min-text-length', + // 'nth-ancestor', + // 'upward', + // 'watch-attr', + // 'watch-attrs', + // 'xpath', +]); + +export const PSEUDO_CLASSES = new Set([ + 'active', + 'any', + 'any-link', + 'blank', + 'checked', + 'default', + 'defined', + 'dir', + 'disabled', + 'empty', + 'enabled', + 'first', + 'first-child', + 'first-of-type', + 'focus', + 'focus-visible', + 'focus-within', + 'fullscreen', + 'host', + 'host-context', + 'hover', + 'in-range', + 'indeterminate', + 'invalid', + 'is', + 'lang', + 'last-child', + 'last-of-type', + 'left', + 'link', + 'matches', + // NOTE: by default we consider `:not(...)` to be a normal CSS selector since, + // we are only interested in cases where the argument is an extended selector. + // If that is the case, it will still be detected as such. + 'not', + 'nth-child', + 'nth-last-child', + 'nth-last-of-type', + 'nth-of-type', + 'only-child', + 'only-of-type', + 'optional', + 'out-of-range', + 'placeholder-shown', + 'read-only', + 'read-write', + 'required', + 'right', + 'root', + 'scope', + 'target', + 'valid', + 'visited', + 'where', +]); + +// NOTE: here we only need to list the pseudo-elements which can appear with a +// single colon (e.g. :after or ::after are valid for backward compatibility +// reasons). They can be misinterpreted as pseudo-classes by the tokenizer for +// this reason. +export const PSEUDO_ELEMENTS = new Set(['after', 'before', 'first-letter', 'first-line']); + +export enum SelectorType { + Normal, + Extended, + Invalid, +} + +export function classifySelector(selector: string): SelectorType { + // In most cases there is no pseudo-anything so we can quickly exit. + if (selector.indexOf(':') === -1) { + return SelectorType.Normal; + } + + const tokens = tokenize(selector); + + // Detect pseudo-classes + let foundSupportedExtendedSelector = false; + for (const token of tokens) { + if (token.type === 'pseudo-class') { + const { name } = token; + if (EXTENDED_PSEUDO_CLASSES.has(name) === true) { + foundSupportedExtendedSelector = true; + } else if (PSEUDO_CLASSES.has(name) === false && PSEUDO_ELEMENTS.has(name) === false) { + return SelectorType.Invalid; + } + + // Recursively + if ( + foundSupportedExtendedSelector === false && + token.argument !== undefined && + RECURSIVE_PSEUDO_CLASSES.has(name) === true + ) { + const argumentType = classifySelector(token.argument); + if (argumentType === SelectorType.Invalid) { + return argumentType; + } else if (argumentType === SelectorType.Extended) { + foundSupportedExtendedSelector = true; + } + } + } + } + + if (foundSupportedExtendedSelector === true) { + return SelectorType.Extended; + } + + return SelectorType.Normal; +} diff --git a/packages/adblocker-extended-selectors/src/parse.ts b/packages/adblocker-extended-selectors/src/parse.ts new file mode 100644 index 0000000000..09229a475a --- /dev/null +++ b/packages/adblocker-extended-selectors/src/parse.ts @@ -0,0 +1,634 @@ +/*! + * Based on parsel. Extended by Rémi Berson for Ghostery (2021). + * https://github.com/LeaVerou/parsel + * + * MIT License + * + * Copyright (c) 2020 Lea Verou + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { isAST, isAtoms } from './types'; +import type { + AST, + Atoms, + AtomsOrStrings, + Attribute, + Class, + Combinator, + Comma, + Id, + ParserOptions, + PseudoClass, + PseudoElement, + Strings, + TokenType, + Type, +} from './types'; + +export const RECURSIVE_PSEUDO_CLASSES = new Set([ + 'any', + 'dir', + 'has', + 'host-context', + 'if', + 'if-not', + 'is', + 'matches', + 'not', + 'where', +]); + +const TOKENS: { [T in TokenType]: RegExp } = { + attribute: /\[\s*(?:(?\*|[-\w]*)\|)?(?[-\w\u{0080}-\u{FFFF}]+)\s*(?:(?\W?=)\s*(?.+?)\s*(?[iIsS])?\s*)?\]/gu, + id: /#(?(?:[-\w\u{0080}-\u{FFFF}]|\\.)+)/gu, + class: /\.(?(?:[-\w\u{0080}-\u{FFFF}]|\\.)+)/gu, + comma: /\s*,\s*/g, // must be before combinator + combinator: /\s*[\s>+~]\s*/g, // this must be after attribute + 'pseudo-element': /::(?[-\w\u{0080}-\u{FFFF}]+)(?:\((?:¶*)\))?/gu, // this must be before pseudo-class + 'pseudo-class': /:(?[-\w\u{0080}-\u{FFFF}]+)(?:\((?¶*)\))?/gu, + type: /(?:(?\*|[-\w]*)\|)?(?[-\w\u{0080}-\u{FFFF}]+)|\*/gu, // this must be last +}; + +const TOKENS_WITH_PARENS: Set = new Set(['pseudo-class', 'pseudo-element']); +const TOKENS_WITH_STRINGS: Set = new Set([...TOKENS_WITH_PARENS, 'attribute']); +const TRIM_TOKENS: Set = new Set(['combinator', 'comma']); + +const TOKENS_FOR_RESTORE: { [T in TokenType]: RegExp } = Object.assign({}, TOKENS); +TOKENS_FOR_RESTORE['pseudo-element'] = RegExp( + TOKENS['pseudo-element'].source.replace('(?¶*)', '(?.*?)'), + 'gu', +); +TOKENS_FOR_RESTORE['pseudo-class'] = RegExp( + TOKENS['pseudo-class'].source.replace('(?¶*)', '(?.*)'), + 'gu', +); + +// TODO - it feels like with some more typing shenanigans we could replace groups validation by generic logic in this function. +function splitOnMatch( + pattern: RegExp, + str: string, +): [string, [string, { [name: string]: string }], string] | undefined { + pattern.lastIndex = 0; + const match = pattern.exec(str); + + if (match === null) { + return undefined; + } + + const from = match.index - 1; + const content = match[0]; + const before = str.slice(0, from + 1); + const after = str.slice(from + content.length + 1); + + return [before, [content, match.groups || {}], after]; +} + +const GRAMMAR = [ + // attribute + (str: string): [string, Attribute, string] | undefined => { + const match = splitOnMatch(TOKENS.attribute, str); + if (match === undefined) { + return undefined; + } + + const [before, [content, { name, operator, value, namespace, caseSensitive }], after] = match; + if (name === undefined) { + return undefined; + } + + return [ + before, + { + type: 'attribute', + content, + length: content.length, + namespace, + caseSensitive, + pos: [], + name, + operator, + value, + }, + after, + ]; + }, + + // #id + (str: string): [string, Id, string] | undefined => { + const match = splitOnMatch(TOKENS.id, str); + if (match === undefined) { + return undefined; + } + + const [before, [content, { name }], after] = match; + if (name === undefined) { + return undefined; + } + + return [ + before, + { + type: 'id', + content, + length: content.length, + pos: [], + name, + }, + after, + ]; + }, + + // .class + (str: string): [string, Class, string] | undefined => { + const match = splitOnMatch(TOKENS.class, str); + if (match === undefined) { + return undefined; + } + + const [before, [content, { name }], after] = match; + if (name === undefined) { + return undefined; + } + + return [ + before, + { + type: 'class', + content, + length: content.length, + pos: [], + name, + }, + after, + ]; + }, + + // comma , + (str: string): [string, Comma, string] | undefined => { + const match = splitOnMatch(TOKENS.comma, str); + if (match === undefined) { + return undefined; + } + + const [before, [content], after] = match; + + return [ + before, + { + type: 'comma', + content, + length: content.length, + pos: [], + }, + after, + ]; + }, + + // combinator + (str: string): [string, Combinator, string] | undefined => { + const match = splitOnMatch(TOKENS.combinator, str); + if (match === undefined) { + return undefined; + } + + const [before, [content], after] = match; + + return [ + before, + { + type: 'combinator', + content, + length: content.length, + pos: [], + }, + after, + ]; + }, + + // pseudo-element + (str: string): [string, PseudoElement, string] | undefined => { + const match = splitOnMatch(TOKENS['pseudo-element'], str); + if (match === undefined) { + return undefined; + } + + const [before, [content, { name }], after] = match; + + if (name === undefined) { + return undefined; + } + + return [ + before, + { + type: 'pseudo-element', + content, + length: content.length, + pos: [], + name, + }, + after, + ]; + }, + + // pseudo-class + (str: string): [string, PseudoClass, string] | undefined => { + const match = splitOnMatch(TOKENS['pseudo-class'], str); + if (match === undefined) { + return undefined; + } + + // TODO - here `argument` can be undefined and should be rejected? + const [before, [content, { name, argument }], after] = match; + + if (name === undefined) { + return undefined; + } + + return [ + before, + { + type: 'pseudo-class', + content, + length: content.length, + pos: [], + name, + argument, + }, + after, + ]; + }, + + // type + (str: string): [string, Type, string] | undefined => { + const match = splitOnMatch(TOKENS.type, str); + if (match === undefined) { + return undefined; + } + + const [before, [content, { name, namespace }], after] = match; + + return [ + before, + { + type: 'type', + content, + length: content.length, + namespace, + pos: [], + name, + }, + after, + ]; + }, +]; + +function tokenizeBy(text: string): Atoms { + if (!text) { + return []; + } + + const strarr: AtomsOrStrings = [text]; + for (const tokenizer of GRAMMAR) { + for (let i = 0; i < strarr.length; i++) { + const str = strarr[i]; + if (typeof str === 'string') { + const match = tokenizer(str); + if (match !== undefined) { + strarr.splice(i, 1, ...(match as AtomsOrStrings).filter((a) => a.length !== 0)); + } + } + } + } + + let offset = 0; + for (const token of strarr) { + if (typeof token !== 'string') { + token.pos = [offset, offset + token.length]; + if (TRIM_TOKENS.has(token.type)) { + token.content = token.content.trim() || ' '; + } + } + + offset += token.length; + } + + if (isAtoms(strarr)) { + return strarr; + } + + // NOTE: here this means that parsing failed. + return []; +} + +function restoreNested(tokens: Atoms, strings: Strings, regex: RegExp, types: Set) { + // TODO - here from offsets in strings and tokens we should be able to find the exact spot without RegExp? + for (const str of strings) { + for (const token of tokens) { + if (types.has(token.type) && token.pos[0] < str.start && str.start < token.pos[1]) { + const content = token.content; + token.content = token.content.replace(regex, str.str); + + if (token.content !== content) { + // actually changed? + // Re-evaluate groups + TOKENS_FOR_RESTORE[token.type].lastIndex = 0; + const match = TOKENS_FOR_RESTORE[token.type].exec(token.content); + if (match !== null) { + Object.assign(token, match.groups); + } + } + } + } + } +} + +export function isEscaped(str: string, index: number): boolean { + let backslashes = 0; + + index -= 1; + while (index >= 0 && str[index] === '\\') { + backslashes += 1; + index -= 1; + } + + return backslashes % 2 !== 0; +} + +export function gobbleQuotes(text: string, quote: '"' | "'", start: number): string | undefined { + // Find end of quote, taking care of ignoring escaped quotes + let end = start + 1; + + /* tslint:disable no-conditional-assignment */ + while ((end = text.indexOf(quote, end)) !== -1 && isEscaped(text, end) === true) { + end += 1; + } + + if (end === -1) { + // Opening quote without closing quote + return undefined; + } + + return text.slice(start, end + 1); +} + +export function gobbleParens(text: string, start: number): string | undefined { + let stack = 0; + + for (let i = start; i < text.length; i++) { + const char = text[i]; + + if (char === '(') { + stack += 1; + } else if (char === ')') { + if (stack > 0) { + stack -= 1; + } else { + // Closing paren without opening paren + return undefined; + } + } + + if (stack === 0) { + return text.slice(start, i + 1); + } + } + + // Opening paren without closing paren + return undefined; +} + +export function replace( + selector: string, + replacement: '¶' | '§', + opening: '(' | '"' | "'", + gobble: (text: string, start: number) => string | undefined, +): [Strings, string] { + const strings: Strings = []; + + let offset = 0; + /* tslint:disable no-conditional-assignment */ + while ((offset = selector.indexOf(opening, offset)) !== -1) { + const str = gobble(selector, offset); + if (str === undefined) { + break; + } + + strings.push({ str, start: offset }); + selector = `${selector.slice(0, offset + 1)}${replacement.repeat( + str.length - 2, + )}${selector.slice(offset + str.length - 1)}`; + offset += str.length; + } + + return [strings, selector]; +} + +export function tokenize(selector: string): Atoms { + if (typeof selector !== 'string') { + return []; + } + + // Prevent leading/trailing whitespace be interpreted as combinators + selector = selector.trim(); + + if (selector.length === 0) { + return []; + } + + // Replace strings with whitespace strings (to preserve offsets) + const [doubleQuotes, selectorWithoutDoubleQuotes] = replace( + selector, + '§', + '"', + (text: string, start: number) => gobbleQuotes(text, '"', start), + ); + + const [singleQuotes, selectorWithoutQuotes] = replace( + selectorWithoutDoubleQuotes, + '§', + "'", + (text: string, start: number) => gobbleQuotes(text, "'", start), + ); + + // Now that strings are out of the way, extract parens and replace them with parens with whitespace (to preserve offsets) + const [parens, selectorWithoutParens] = replace(selectorWithoutQuotes, '¶', '(', gobbleParens); + + // Now we have no nested structures and we can parse with regexes + const tokens = tokenizeBy(selectorWithoutParens); + + // Now restore parens and strings in reverse order + restoreNested(tokens, parens, /\(¶*\)/, TOKENS_WITH_PARENS); + restoreNested(tokens, doubleQuotes, /"§*"/, TOKENS_WITH_STRINGS); + restoreNested(tokens, singleQuotes, /'§*'/, TOKENS_WITH_STRINGS); + + return tokens; +} + +// Convert a flat list of tokens into a tree of complex & compound selectors +function nestTokens( + tokens: Atoms, + { list = true }: Pick = {}, +): AST | undefined { + if (list === true && tokens.some((t) => t.type === 'comma')) { + const selectors: AST[] = []; + const temp: Atoms = []; + + for (let i = 0; i < tokens.length; i += 1) { + const token = tokens[i]; + if (token.type === 'comma') { + if (temp.length === 0) { + throw new Error('Incorrect comma at ' + i); + } + + const sub = nestTokens(temp, { list: false }); + if (sub !== undefined) { + selectors.push(sub); + } + temp.length = 0; + } else { + temp.push(token); + } + } + + if (temp.length === 0) { + throw new Error('Trailing comma'); + } else { + const sub = nestTokens(temp, { list: false }); + if (sub !== undefined) { + selectors.push(sub); + } + } + + return { type: 'list', list: selectors }; + } + + for (let i = tokens.length - 1; i >= 0; i--) { + const token = tokens[i]; + + if (token.type === 'combinator') { + const left = nestTokens(tokens.slice(0, i)); + const right = nestTokens(tokens.slice(i + 1)); + if (right === undefined) { + return undefined; + } + + if ( + token.content !== ' ' && + token.content !== '~' && + token.content !== '+' && + token.content !== '>' + ) { + return undefined; + } + + return { + type: 'complex', + combinator: token.content, + left, + right, + }; + } + } + + if (tokens.length === 0) { + return undefined; + } + + if (isAST(tokens)) { + if (tokens.length === 1) { + return tokens[0]; + } + + // If we're here, there are no combinators, so it's just a list + return { + type: 'compound', + compound: [...tokens], // clone to avoid pointers messing up the AST + }; + } + + return undefined; +} + +// Traverse an AST (or part thereof), in depth-first order +function walk( + node: AST | undefined, + callback: (node: AST, parentNode?: AST) => void, + o?: AST, + parent?: AST, +): void { + if (node === undefined) { + return; + } + + if (node.type === 'complex') { + walk(node.left, callback, o, node); + walk(node.right, callback, o, node); + } else if (node.type === 'compound') { + for (const n of node.compound) { + walk(n, callback, o, node); + } + } else if ( + node.type === 'pseudo-class' && + node.subtree !== undefined && + o !== undefined && + o.type === 'pseudo-class' && + o.subtree !== undefined + ) { + walk(node.subtree, callback, o, node); + } + + callback(node, parent); +} + +/** + * Parse a CSS selector + * @param selector {String} The selector to parse + * @param options.recursive {Boolean} Whether to parse the arguments of pseudo-classes like :is(), :has() etc. Defaults to true. + * @param options.list {Boolean} Whether this can be a selector list (A, B, C etc). Defaults to true. + */ +export function parse( + selector: string, + { recursive = true, list = true }: ParserOptions = {}, +): AST | undefined { + const tokens = tokenize(selector); + + if (tokens.length === 0) { + return undefined; + } + + const ast = nestTokens(tokens, { list }); + + if (recursive === true) { + walk(ast, (node) => { + if ( + node.type === 'pseudo-class' && + node.argument && + node.name !== undefined && + RECURSIVE_PSEUDO_CLASSES.has(node.name) + ) { + node.subtree = parse(node.argument, { recursive: true, list: true }); + } + }); + } + + return ast; +} diff --git a/packages/adblocker-extended-selectors/src/types.ts b/packages/adblocker-extended-selectors/src/types.ts new file mode 100644 index 0000000000..9c4660f1cf --- /dev/null +++ b/packages/adblocker-extended-selectors/src/types.ts @@ -0,0 +1,137 @@ +/*! + * Copyright (c) 2017-present Cliqz GmbH. All rights reserved. + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +const tokenTypes = [ + 'attribute', + 'id', + 'class', + 'comma', + 'combinator', + 'pseudo-element', + 'pseudo-class', + 'type', +] as const; + +export type TokenType = typeof tokenTypes[number]; + +export type Base = { + length: number; + content: string; + pos: number[]; +}; + +export type Type = Base & { + type: 'type'; + name?: string; + namespace?: string; +}; + +export type PseudoClass = Base & { + type: 'pseudo-class'; + name: string; + argument?: string; + subtree?: AST; +}; + +export type PseudoElement = Base & { + type: 'pseudo-element'; + name: string; +}; + +export type Combinator = Base & { + type: 'combinator'; +}; + +export type Comma = Base & { + type: 'comma'; +}; + +export type Class = Base & { + type: 'class'; + name: string; +}; + +export type Id = Base & { + type: 'id'; + name: string; +}; + +export type Attribute = Base & { + type: 'attribute'; + namespace?: string; + caseSensitive?: string; + name: string; + operator?: string; + value?: string; +}; + +export type Atom = + | Attribute + | Id + | Class + | Comma + | Combinator + | PseudoClass + | PseudoElement + | Type; + +export type Atoms = Atom[]; + +export type AtomOrString = Atom | string; + +export type AtomsOrStrings = AtomOrString[]; + +export type Strings = { str: string; start: number }[]; + +// Complex selectors with combinators (e.g. ~, >, +) +export interface Complex { + type: 'complex'; + combinator: ' ' | '+' | '~' | '>'; + right: AST; + left?: AST; +} + +// Multiple selectors together +// i.e. selector1selector2 (should match both at the same time) +export interface Compound { + type: 'compound'; + compound: AST[]; +} + +// Comma-separated list of selectors +// i.e. selector1, selector2, etc. +export interface List { + type: 'list'; + list: AST[]; +} + +export type AST = + | Attribute + | Id + | Class + | PseudoClass + | PseudoElement + | Type + | Complex + | Compound + | List; + +export interface ParserOptions { + recursive?: boolean; + list?: boolean; +} + +export function isAtoms(tokens: AtomsOrStrings): tokens is Atoms { + return tokens.every((token) => typeof token !== 'string'); +} + +export function isAST( + tokens: Atoms, +): tokens is (Attribute | Id | Class | PseudoClass | PseudoElement | Type)[] { + return tokens.every((token) => token.type !== 'comma' && token.type !== 'combinator'); +} diff --git a/packages/adblocker-extended-selectors/test/eval.test.ts b/packages/adblocker-extended-selectors/test/eval.test.ts new file mode 100644 index 0000000000..3b174670e8 --- /dev/null +++ b/packages/adblocker-extended-selectors/test/eval.test.ts @@ -0,0 +1,520 @@ +/*! + * Copyright (c) 2017-present Cliqz GmbH. All rights reserved. + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +import { expect } from 'chai'; +import 'mocha'; + +import { JSDOM } from 'jsdom'; + +import { querySelectorAll, matchPattern, matches } from '../src/eval'; +import { parse } from '../src/parse'; + +// TODO - check if style:has-text() works (can select style?) + +function testMatches(selector: string, html: string, target: string, expected: boolean): void { + const { + window: { document }, + } = new JSDOM(html); + + const ast = parse(selector); + expect(ast).to.not.be.undefined; + if (ast === undefined) { + return; + } + + const element = target === 'document' ? document : document.querySelector(target); + expect(element).to.not.be.null; + if (element !== null) { + // NOTE: here we need to ignore the type warnings so that we can pass a + // `Document` argument to test some edge cases (e.g. textContent returns + // null on document). + // @ts-ignore + const result = matches(element, ast); + expect(result).to.equal(expected); + } +} + +function testQuerySelectorAll(selector: string, html: string, resultSelector?: string): void { + const { + window: { document }, + } = new JSDOM(html); + + const ast = parse(selector); + expect(ast).to.not.be.undefined; + if (ast === undefined) { + return; + } + + const expected = Array.from( + document.querySelectorAll(resultSelector !== undefined ? resultSelector : selector), + ); + expect(expected).to.not.be.empty; + expect(querySelectorAll(document.documentElement, ast)).to.have.members(expected); +} + +describe('eval', () => { + describe('#matchPattern', () => { + it('handles plain pattern', () => { + expect(matchPattern('bar', 'foo bar baz')).to.be.true; + }); + + it('matching is case sensitive', () => { + expect(matchPattern('bar', 'foo Bar baz')).to.be.false; + expect(matchPattern('BAR', 'foo bar baz')).to.be.false; + }); + + it('handle literal regex', () => { + expect(matchPattern('/ba[a-z]/', 'foo baz')).to.be.true; + }); + + it('handle case-insensitive literal regex', () => { + expect(matchPattern('/ba[a-z]/i', 'foo BAZ')).to.be.true; + }); + }); + + describe('#matches', () => { + describe('attribute', () => { + it('exact match', () => { + testMatches( + '[attr1="abcde"]', + '

Hello world

', + '[attr1="abcde"]', + true, + ); + }); + }); + + describe('type (span)', () => { + it('exact match', () => { + testMatches( + 'span', + '

Hello world

', + 'span', + true, + ); + }); + + it('no match', () => { + testMatches( + 'p', + '

Hello world

', + 'span', + false, + ); + }); + }); + + describe('#id', () => { + it('exact match', () => { + testMatches( + '#some_id', + '

Hello world

', + '#some_id', + true, + ); + }); + + it('does not match if #id is different', () => { + testMatches( + '#some_id2', + '

Hello world

', + '#some_id', + false, + ); + }); + + it('does not match if #id is nested', () => { + testMatches( + '#some_id2', + '

Hello world

', + '#some_id', + false, + ); + }); + }); + + describe('.class', () => { + it('exact match', () => { + testMatches( + '.some_cls', + '

Hello world

', + '.some_cls', + true, + ); + }); + + it('does not match if .class is different', () => { + testMatches( + '.some_cls2', + '

Hello world

', + '.some_cls', + false, + ); + }); + + it('does not match if .class is nested', () => { + testMatches( + '.some_cls2', + '

Hello world

', + '.some_cls', + false, + ); + }); + }); + + describe('list (,)', () => { + it('handle two selectors', () => { + testMatches( + '.some_cls1,.some_cls2', + '

Hello world

', + '.some_cls1', + true, + ); + + testMatches( + '.some_cls1,.some_cls2', + '

Hello world

', + '.some_cls2', + true, + ); + }); + }); + + describe(':min-text-length', () => { + it('match if text has right size', () => { + testMatches( + ':min-text-length(11)', + '

Hello world

', + '.some_cls', + true, + ); + }); + + it('does not match if text size it too small', () => { + testMatches( + ':min-text-length(12)', + '

Hello world

', + '.some_cls', + false, + ); + }); + + it('does not match if text is null', () => { + testMatches( + ':min-text-length(1)', + '

Hello world

', + 'document', + false, + ); + }); + + it('does not match if length is negative', () => { + testMatches( + ':min-text-length(-1)', + '

Hello world

', + '.some_cls', + false, + ); + }); + + it('does not match if length is not a number', () => { + testMatches( + ':min-text-length(foo)', + '

Hello world

', + '.some_cls', + false, + ); + }); + }); + + describe(':has-text', () => { + it('missing pattern matches no element', () => { + testMatches( + '.cls:has-text', + [ + '', + '', + '', + '
foo bar baz
', + '

Go to the pub!

', + '', + ].join('\n'), + '#n1', + false, + ); + }); + + it('empty pattern matches any element', () => { + testMatches( + '.cls:has-text()', + [ + '', + '', + '', + '
foo bar baz
', + '

Go to the pub!

', + '', + ].join('\n'), + '#n1', + true, + ); + }); + + it('does not match against document directly because textContent is null', () => { + testMatches( + ':has-text()', + [ + '', + '', + '', + '
foo bar baz
', + '

Go to the pub!

', + '', + ].join('\n'), + 'document', + false, + ); + }); + }); + }); + + describe('#querySelectorAll', () => { + it('#id', () => { + testQuerySelectorAll('#some_id', '

Hello world

'); + }); + + it('.class', () => { + testQuerySelectorAll('.some_class', '

Hello world

'); + }); + + it('type (span)', () => { + testQuerySelectorAll( + 'span', + '

Hello world

', + ); + }); + + it('attribute', () => { + testQuerySelectorAll( + 'a[attr1="abcde"][attr2="123"]', + '

Hello world

', + ); + }); + + it('comma', () => { + testQuerySelectorAll( + '#id, .cls', + '

Hello world

', + ); + + testQuerySelectorAll( + 'span, p', + '

Hello world

', + ); + }); + + it('compound', () => { + testQuerySelectorAll('.cls1.cls2', '

Hello world

'); + }); + + it('complex: ', () => { + testQuerySelectorAll( + '.cls1 .cls2', + '

Hello world

', + ); + }); + + it('complex: >', () => { + testQuerySelectorAll( + '.cls1 > .cls2', + '

Hello world!

', + ); + }); + + it('complex: ~', () => { + testQuerySelectorAll( + '.cls2 ~ .cls3', + '

Hello world!

', + ); + }); + + it('complex: +', () => { + testQuerySelectorAll( + '.cls2 + .cls3', + '

Helloworld!

', + ); + }); + + describe(':has', () => { + for (const has of ['has', 'if']) { + it(`*:${has}`, () => { + testQuerySelectorAll( + `*:${has}(a[href^="https://"]):not(html):not(body):not(p)`, + [ + '', + '

', + ' ', + ' Hello', + ' ', + ' ', + ' world!', + ' ', + '

', + ].join('\n'), + '#res', + ); + }); + + it(`simple :${has}`, () => { + testQuerySelectorAll( + `div:${has}(.banner)`, + [ + '
Do not select this div
', + '
Select this div
', + ].join('\n'), + '#res', + ); + + testQuerySelectorAll( + `:${has}(.banner):not(body)`, + [ + '
Do not select this div
', + '
Select this div
', + ].join('\n'), + '#res', + ); + }); + + it(`nested :${has}`, () => { + testQuerySelectorAll( + `div:${has}(> .banner)`, + [ + '
Do not select this div
', + '
Select this div
', + '
Select this div
', + ].join('\n'), + '#res', + ); + }); + + it(`compound ${has}`, () => { + testQuerySelectorAll( + `h2 :${has}(span.foo)`, + [ + '

I am a paragraph.

', + '

I am so very fancy!

', + '
I am NOT a paragraph.
', + '

', + '
', + ' foo inside h2', + ' bar inside h2', + '
', + '

', + ].join('\n'), + '#res', + ); + }); + + it(`compound 2 :${has}`, () => { + testQuerySelectorAll( + `body > div:${has}(img[alt="Foo"])`, + '
Foo
', + '#res', + ); + }); + } + }); + + describe(':not', () => { + it('not paragraph', () => { + testQuerySelectorAll( + ':not(p):not(body):not(html):not(head)', + [ + '', + '', + '', + '
', + ' Foo', + '
', + '', + ].join('\n'), + '#res', + ); + }); + + it('compound', () => { + testQuerySelectorAll( + 'h2 :not(span.foo)', + [ + '

I am a paragraph.

', + '

I am so very fancy!

', + '
I am NOT a paragraph.
', + '

', + ' foo inside h2', + ' bar inside h2', + '

', + ].join('\n'), + '#res', + ); + }); + + it('nested', () => { + testQuerySelectorAll( + 'h2 :not(:has(span.foo))', + [ + '

I am a paragraph.

', + '

I am so very fancy!

', + '
I am NOT a paragraph.
', + '

', + '
inside h2
', + '
bar inside h2
', + '

', + ].join('\n'), + '#res', + ); + }); + }); + + describe.skip(':upward', () => { + describe('argument is a number', () => { + it('ignored if 0 or negative', () => { + testQuerySelectorAll( + 'span:upward(1)', + [ + '

I am a paragraph.

', + '

I am so very fancy!

', + '
I am NOT a paragraph.
', + '

', + '
inside h2
', + '
bar inside h2
', + '

', + ].join('\n'), + '#res', + ); + }); + }); + }); + + describe(':has-text', () => { + it('simple literal pattern', () => { + testQuerySelectorAll( + '.cls:has-text(bar)', + [ + '', + '', + '', + '
foo bar baz
', + '

Go to the pub!

', + '', + ].join('\n'), + '#n1', + ); + }); + }); + }); +}); diff --git a/packages/adblocker-extended-selectors/test/extended.test.ts b/packages/adblocker-extended-selectors/test/extended.test.ts new file mode 100644 index 0000000000..67266ebcaa --- /dev/null +++ b/packages/adblocker-extended-selectors/test/extended.test.ts @@ -0,0 +1,89 @@ +/*! + * Copyright (c) 2017-present Cliqz GmbH. All rights reserved. + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +import { expect } from 'chai'; +import 'mocha'; + +import { + classifySelector, + SelectorType, + PSEUDO_CLASSES, + EXTENDED_PSEUDO_CLASSES, +} from '../src/extended'; + +describe('extended', () => { + describe('#classifySelector', () => { + it('id', () => { + expect(classifySelector('#id')).to.equal(SelectorType.Normal); + expect(classifySelector('#i_D')).to.equal(SelectorType.Normal); + }); + + it('class', () => { + expect(classifySelector('.cls')).to.equal(SelectorType.Normal); + expect(classifySelector('.cl_S')).to.equal(SelectorType.Normal); + }); + + for (const pseudo of Array.from(PSEUDO_CLASSES)) { + it(`pseudo-class: ${pseudo}`, () => { + expect(classifySelector(`div:${pseudo}(2)`)).to.equal(SelectorType.Normal); + expect(classifySelector(`div:not(:${pseudo}(2))`)).to.equal(SelectorType.Normal); + }); + } + + for (const pseudo of Array.from(EXTENDED_PSEUDO_CLASSES)) { + it(`extended: ${pseudo}`, () => { + expect(classifySelector(`.overlay:not(:${pseudo}(Welcome back)):not(body)`)).to.equal( + SelectorType.Extended, + ); + }); + } + + for (const pseudo of [ + '::-moz-progress-bar', + '::-moz-range-progress', + '::-moz-range-thumb', + '::-moz-range-track', + '::-webkit-progress-bar', + '::-webkit-progress-value', + '::-webkit-slider-runnable-track', + '::-webkit-slider-thumb', + '::after', + '::backdrop', + '::before', + '::cue', + '::cue-region', + '::first-letter', + '::first-line', + '::grammar-error', + '::marker', + '::part()', + '::placeholder', + '::selection', + '::slotted(*)', + '::slotted(span)', + '::spelling-error', + ]) { + it(`pseudo-element: ${pseudo}`, () => { + expect(classifySelector(`div${pseudo}`)).to.equal(SelectorType.Normal); + }); + } + + it('handles quotes', () => { + expect(classifySelector('div:not(":has-text(foo)")')).to.equal(SelectorType.Normal); + expect(classifySelector("div:not(':has-text(foo)')")).to.equal(SelectorType.Normal); + }); + + it('reject invalid pseudo-class', () => { + expect(classifySelector(':woot()')).to.equal(SelectorType.Invalid); + }); + + it('reject invalid nested pseudo-class', () => { + expect(classifySelector(':not(:woot())')).to.equal(SelectorType.Invalid); + }); + }); +}); diff --git a/packages/adblocker-extended-selectors/test/parse.test.ts b/packages/adblocker-extended-selectors/test/parse.test.ts new file mode 100644 index 0000000000..5107bc34e4 --- /dev/null +++ b/packages/adblocker-extended-selectors/test/parse.test.ts @@ -0,0 +1,700 @@ +/*! + * Copyright (c) 2017-present Cliqz GmbH. All rights reserved. + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +import { expect } from 'chai'; +import 'mocha'; + +import { parse, replace, isEscaped, gobbleParens, gobbleQuotes } from '../src/parse'; + +describe('parse', () => { + describe('#replace', () => { + for (const quote of ['"', "'"] as const) { + it(`handles ${quote} quotes`, () => { + expect( + replace(`${quote}foo${quote}`, '§', quote, (text: string, start: number) => + gobbleQuotes(text, quote, start), + ), + ).to.eql([[{ start: 0, str: `${quote}foo${quote}` }], `${quote}§§§${quote}`]); + }); + + it(`handles complex ${quote} quotes`, () => { + expect( + replace( + `span:has-text(/partial l${quote}application/)`, + '§', + quote, + (text: string, start: number) => gobbleQuotes(text, quote, start), + ), + ).to.eql([[], `span:has-text(/partial l${quote}application/)`]); + }); + } + + it('handles parentheses', () => { + expect(replace('(foo) (bar) (((b)az)', '¶', '(', gobbleParens)).to.eql([ + [ + { + 'start': 0, + 'str': '(foo)', + }, + { + 'start': 6, + 'str': '(bar)', + }, + ], + '(¶¶¶) (¶¶¶) (((b)az)', + ]); + + expect(replace('(foo) (bar) (((b)az', '¶', '(', gobbleParens)).to.eql([ + [ + { + 'start': 0, + 'str': '(foo)', + }, + { + 'start': 6, + 'str': '(bar)', + }, + ], + '(¶¶¶) (¶¶¶) (((b)az', + ]); + + expect(replace('(foo) (bar) (((b)a)z)', '¶', '(', gobbleParens)).to.eql([ + [ + { + 'start': 0, + 'str': '(foo)', + }, + { + 'start': 6, + 'str': '(bar)', + }, + { + 'start': 12, + 'str': '(((b)a)z)', + }, + ], + '(¶¶¶) (¶¶¶) (¶¶¶¶¶¶¶)', + ]); + }); + }); + + describe('#isEscaped', () => { + it('handle no backslash', () => { + expect(isEscaped('foo', 0)).to.be.false; + expect(isEscaped('foo', 1)).to.be.false; + }); + + it('handle one backslash', () => { + expect(isEscaped('\\foo', 1)).to.be.true; + expect(isEscaped('f\\oo', 2)).to.be.true; + expect(isEscaped('\\foo', 0)).to.be.false; + expect(isEscaped('\\foo', 2)).to.be.false; + }); + + it('handle two backslashes', () => { + expect(isEscaped('\\\\foo', 0)).to.be.false; + expect(isEscaped('\\\\foo', 1)).to.be.true; + expect(isEscaped('\\\\foo', 2)).to.be.false; + expect(isEscaped('\\\\foo', 3)).to.be.false; + }); + + it('handle three backslashes', () => { + expect(isEscaped('\\\\\\foo', 0)).to.be.false; + expect(isEscaped('\\\\\\foo', 1)).to.be.true; + expect(isEscaped('\\\\\\foo', 2)).to.be.false; + expect(isEscaped('\\\\\\foo', 3)).to.be.true; + expect(isEscaped('\\\\\\foo', 4)).to.be.false; + }); + }); + + describe('#parse', () => { + it('deal with escaped quotes', () => { + expect(parse('[data*="\\"ei\\":\\""]')).to.eql({ + 'caseSensitive': undefined, + 'content': '[data*="\\"ei\\":\\""]', + 'length': 19, + 'name': 'data', + 'namespace': undefined, + 'operator': '*=', + 'pos': [0, 19], + 'type': 'attribute', + 'value': '"\\"ei\\":\\""', + }); + }); + + it('id', () => { + expect(parse('#some_id')).to.eql({ + 'content': '#some_id', + 'name': 'some_id', + 'length': 8, + 'pos': [0, 8], + 'type': 'id', + }); + }); + + it('class', () => { + expect(parse('.some_class')).to.eql({ + 'content': '.some_class', + 'length': 11, + 'name': 'some_class', + 'pos': [0, 11], + 'type': 'class', + }); + }); + + it('type', () => { + expect(parse('*[href^="https://"]')).to.eql({ + 'compound': [ + { + 'content': '*', + 'length': 1, + 'name': undefined, + 'namespace': undefined, + 'pos': [0, 1], + 'type': 'type', + }, + { + 'caseSensitive': undefined, + 'content': '[href^="https://"]', + 'length': 18, + 'name': 'href', + 'namespace': undefined, + 'operator': '^=', + 'pos': [1, 19], + 'type': 'attribute', + 'value': '"https://"', + }, + ], + 'type': 'compound', + }); + }); + + it('attribute', () => { + expect(parse('a[attr="abcde"][attr="123"]')).to.eql({ + 'type': 'compound', + 'compound': [ + { + 'content': 'a', + 'length': 1, + 'name': 'a', + 'pos': [0, 1], + 'type': 'type', + 'namespace': undefined, + }, + { + 'content': '[attr="abcde"]', + 'length': 14, + 'name': 'attr', + 'operator': '=', + 'pos': [1, 15], + 'type': 'attribute', + 'namespace': undefined, + 'caseSensitive': undefined, + 'value': '"abcde"', + }, + { + 'content': '[attr="123"]', + 'length': 12, + 'name': 'attr', + 'operator': '=', + 'pos': [15, 27], + 'type': 'attribute', + 'namespace': undefined, + 'caseSensitive': undefined, + 'value': '"123"', + }, + ], + }); + }); + }); + + describe('procedural', () => { + it(':remove', () => { + expect(parse('.cls:remove()')).to.eql({ + 'compound': [ + { + 'content': '.cls', + 'length': 4, + 'name': 'cls', + 'pos': [0, 4], + 'type': 'class', + }, + { + 'argument': '', + 'content': ':remove()', + 'length': 9, + 'name': 'remove', + 'pos': [4, 13], + 'type': 'pseudo-class', + }, + ], + 'type': 'compound', + }); + }); + + it(':style', () => { + expect( + parse('.cls:has-text(2):style(left-3000px !important;position:absolute !important)'), + ).to.eql({ + 'type': 'compound', + 'compound': [ + { + 'type': 'class', + 'content': '.cls', + 'length': 4, + 'name': 'cls', + 'pos': [0, 4], + }, + { + 'type': 'pseudo-class', + 'argument': '2', + 'content': ':has-text(2)', + 'length': 12, + 'name': 'has-text', + 'pos': [4, 16], + }, + { + 'type': 'pseudo-class', + 'argument': 'left-3000px !important;position:absolute !important', + 'content': ':style(left-3000px !important;position:absolute !important)', + 'length': 59, + 'name': 'style', + 'pos': [16, 75], + }, + ], + }); + }); + + it(':xpath', () => { + expect(parse('.cls:xpath(..)')).to.eql({ + 'type': 'compound', + 'compound': [ + { + 'content': '.cls', + 'length': 4, + 'name': 'cls', + 'pos': [0, 4], + 'type': 'class', + }, + { + 'argument': '..', + 'content': ':xpath(..)', + 'length': 10, + 'name': 'xpath', + 'pos': [4, 14], + 'type': 'pseudo-class', + }, + ], + }); + }); + + it(':has', () => { + expect( + parse( + 'table.tborder > tbody:has(:scope > tr > .alt1 > table > tbody > tr > td > a):has(strong):has(span > font > strong)', + ), + ).to.eql({ + 'combinator': '>', + 'left': { + 'compound': [ + { + 'content': 'table', + 'length': 5, + 'name': 'table', + 'pos': [0, 5], + 'type': 'type', + 'namespace': undefined, + }, + { + 'content': '.tborder', + 'length': 8, + 'name': 'tborder', + 'pos': [5, 13], + 'type': 'class', + }, + ], + 'type': 'compound', + }, + 'right': { + 'compound': [ + { + 'content': 'tbody', + 'length': 5, + 'name': 'tbody', + 'pos': [16, 21], + 'type': 'type', + 'namespace': undefined, + }, + { + 'argument': ':scope > tr > .alt1 > table > tbody > tr > td > a', + 'content': ':has(:scope > tr > .alt1 > table > tbody > tr > td > a)', + 'length': 55, + 'name': 'has', + 'pos': [21, 76], + 'subtree': { + 'combinator': '>', + 'left': { + 'combinator': '>', + 'left': { + 'combinator': '>', + 'left': { + 'combinator': '>', + 'left': { + 'combinator': '>', + 'left': { + 'combinator': '>', + 'left': { + 'combinator': '>', + 'left': { + 'argument': undefined, + 'content': ':scope', + 'length': 6, + 'name': 'scope', + 'pos': [0, 6], + 'type': 'pseudo-class', + }, + 'right': { + 'content': 'tr', + 'length': 2, + 'name': 'tr', + 'pos': [9, 11], + 'type': 'type', + 'namespace': undefined, + }, + 'type': 'complex', + }, + 'right': { + 'content': '.alt1', + 'length': 5, + 'name': 'alt1', + 'pos': [14, 19], + 'type': 'class', + }, + 'type': 'complex', + }, + 'right': { + 'content': 'table', + 'length': 5, + 'name': 'table', + 'pos': [22, 27], + 'type': 'type', + 'namespace': undefined, + }, + 'type': 'complex', + }, + 'right': { + 'content': 'tbody', + 'length': 5, + 'name': 'tbody', + 'pos': [30, 35], + 'type': 'type', + 'namespace': undefined, + }, + 'type': 'complex', + }, + 'right': { + 'content': 'tr', + 'length': 2, + 'name': 'tr', + 'pos': [38, 40], + 'type': 'type', + 'namespace': undefined, + }, + 'type': 'complex', + }, + 'right': { + 'content': 'td', + 'length': 2, + 'name': 'td', + 'pos': [43, 45], + 'type': 'type', + 'namespace': undefined, + }, + 'type': 'complex', + }, + 'right': { + 'content': 'a', + 'length': 1, + 'name': 'a', + 'pos': [48, 49], + 'type': 'type', + 'namespace': undefined, + }, + 'type': 'complex', + }, + 'type': 'pseudo-class', + }, + { + 'argument': 'strong', + 'content': ':has(strong)', + 'length': 12, + 'name': 'has', + 'pos': [76, 88], + 'subtree': { + 'content': 'strong', + 'length': 6, + 'name': 'strong', + 'pos': [0, 6], + 'type': 'type', + 'namespace': undefined, + }, + 'type': 'pseudo-class', + }, + { + 'argument': 'span > font > strong', + 'content': ':has(span > font > strong)', + 'length': 26, + 'name': 'has', + 'pos': [88, 114], + 'subtree': { + 'combinator': '>', + 'left': { + 'combinator': '>', + 'left': { + 'content': 'span', + 'length': 4, + 'name': 'span', + 'pos': [0, 4], + 'type': 'type', + 'namespace': undefined, + }, + 'right': { + 'content': 'font', + 'length': 4, + 'name': 'font', + 'pos': [7, 11], + 'type': 'type', + 'namespace': undefined, + }, + 'type': 'complex', + }, + 'right': { + 'content': 'strong', + 'length': 6, + 'name': 'strong', + 'pos': [14, 20], + 'type': 'type', + 'namespace': undefined, + }, + 'type': 'complex', + }, + 'type': 'pseudo-class', + }, + ], + 'type': 'compound', + }, + 'type': 'complex', + }); + + expect(parse('.cls:has([href*="?utm_source="])')).to.eql({ + 'compound': [ + { + 'content': '.cls', + 'length': 4, + 'name': 'cls', + 'pos': [0, 4], + 'type': 'class', + }, + { + 'argument': '[href*="?utm_source="]', + 'content': ':has([href*="?utm_source="])', + 'length': 28, + 'name': 'has', + 'pos': [4, 32], + 'subtree': { + 'content': '[href*="?utm_source="]', + 'length': 22, + 'name': 'href', + 'operator': '*=', + 'pos': [0, 22], + 'type': 'attribute', + 'namespace': undefined, + 'caseSensitive': undefined, + 'value': '"?utm_source="', + }, + 'type': 'pseudo-class', + }, + ], + 'type': 'compound', + }); + + expect(parse('.cls > a > div[class^="foo"]:has(button[class^="bar"])')).to.eql({ + 'combinator': '>', + 'left': { + 'combinator': '>', + 'left': { + 'content': '.cls', + 'length': 4, + 'name': 'cls', + 'pos': [0, 4], + 'type': 'class', + }, + 'right': { + 'content': 'a', + 'length': 1, + 'name': 'a', + 'pos': [7, 8], + 'type': 'type', + 'namespace': undefined, + }, + 'type': 'complex', + }, + 'right': { + 'compound': [ + { + 'content': 'div', + 'length': 3, + 'name': 'div', + 'pos': [11, 14], + 'type': 'type', + 'namespace': undefined, + }, + { + 'content': '[class^="foo"]', + 'length': 14, + 'name': 'class', + 'operator': '^=', + 'pos': [14, 28], + 'type': 'attribute', + 'namespace': undefined, + 'caseSensitive': undefined, + 'value': '"foo"', + }, + { + 'argument': 'button[class^="bar"]', + 'content': ':has(button[class^="bar"])', + 'length': 26, + 'name': 'has', + 'pos': [28, 54], + 'subtree': { + 'compound': [ + { + 'content': 'button', + 'length': 6, + 'name': 'button', + 'pos': [0, 6], + 'type': 'type', + 'namespace': undefined, + }, + { + 'content': '[class^="bar"]', + 'length': 14, + 'name': 'class', + 'operator': '^=', + 'pos': [6, 20], + 'type': 'attribute', + 'namespace': undefined, + 'caseSensitive': undefined, + 'value': '"bar"', + }, + ], + 'type': 'compound', + }, + 'type': 'pseudo-class', + }, + ], + 'type': 'compound', + }, + 'type': 'complex', + }); + }); + + it.skip(':upward', () => { + // With integer argument + expect(parse('.cls:upward(4)')).to.eql({ + 'type': 'compound', + 'compound': [ + { + 'content': '.cls', + 'length': 4, + 'name': 'cls', + 'pos': [0, 4], + 'type': 'class', + }, + { + 'argument': '4', + 'content': ':upward(4)', + 'length': 10, + 'name': 'upward', + 'pos': [4, 14], + 'type': 'pseudo-class', + }, + ], + }); + + // With selector argument + expect(parse('.cls:upward(.foo.bar)')).to.eql({ + 'type': 'compound', + 'compound': [ + { + 'type': 'class', + 'content': '.cls', + 'length': 4, + 'name': 'cls', + 'pos': [0, 4], + }, + { + 'type': 'pseudo-class', + 'argument': '.foo.bar', + 'content': ':upward(.foo.bar)', + 'length': 17, + 'name': 'upward', + 'pos': [4, 21], + }, + ], + }); + }); + + it('has-text', () => { + expect(parse('.cls1 > div > div:has-text(/foo bar/i)')).to.eql({ + 'type': 'complex', + 'combinator': '>', + 'left': { + 'combinator': '>', + 'left': { + 'type': 'class', + 'content': '.cls1', + 'length': 5, + 'name': 'cls1', + 'pos': [0, 5], + }, + 'right': { + 'type': 'type', + 'namespace': undefined, + 'content': 'div', + 'length': 3, + 'name': 'div', + 'pos': [8, 11], + }, + 'type': 'complex', + }, + 'right': { + 'type': 'compound', + 'compound': [ + { + 'type': 'type', + 'namespace': undefined, + 'content': 'div', + 'length': 3, + 'name': 'div', + 'pos': [14, 17], + }, + { + 'type': 'pseudo-class', + 'argument': '/foo bar/i', + 'content': ':has-text(/foo bar/i)', + 'length': 21, + 'name': 'has-text', + 'pos': [17, 38], + }, + ], + }, + }); + }); + }); +}); diff --git a/packages/adblocker-extended-selectors/tsconfig.bundle.json b/packages/adblocker-extended-selectors/tsconfig.bundle.json new file mode 100644 index 0000000000..792e250d69 --- /dev/null +++ b/packages/adblocker-extended-selectors/tsconfig.bundle.json @@ -0,0 +1,12 @@ +{ + "extends": "./tsconfig", + "compilerOptions": { + "declaration": false, + "declarationMap": false, + "declarationDir": null, + "composite": false, + "incremental": true, + "module": "es6", + "outDir": "dist/es6" + } +} diff --git a/packages/adblocker-extended-selectors/tsconfig.json b/packages/adblocker-extended-selectors/tsconfig.json new file mode 100644 index 0000000000..68e1cee974 --- /dev/null +++ b/packages/adblocker-extended-selectors/tsconfig.json @@ -0,0 +1,15 @@ +{ + "extends": "../../tsconfig", + "compilerOptions": { + "composite": true, + "outDir": "dist/cjs", + "declarationDir": "dist/types" + }, + "include": [ + "./adblocker.ts", + "./src/parse.ts", + "./src/types.ts", + "./src/eval.ts", + "./src/extended.ts" + ] +} diff --git a/packages/adblocker-playwright/adblocker.ts b/packages/adblocker-playwright/adblocker.ts index 6d92784bd5..10d0367558 100644 --- a/packages/adblocker-playwright/adblocker.ts +++ b/packages/adblocker-playwright/adblocker.ts @@ -171,6 +171,7 @@ export class PlaywrightBlocker extends FiltersEngine { // Done once per frame. getBaseRules: true, getInjectionRules: true, + getExtendedRules: true, getRulesFromHostname: true, // Will handle DOM features (see below). @@ -194,34 +195,35 @@ export class PlaywrightBlocker extends FiltersEngine { // nodes. We first query all of them, then monitor the DOM for a few // seconds (or until one of the stopping conditions is met, see below). - const observer = new DOMMonitor(({ ids, hrefs, classes }) => { - const { active, styles } = this.getCosmeticsFilters({ - domain, - hostname, - url, + const observer = new DOMMonitor((update) => { + if (update.type === 'features') { + const { active, styles } = this.getCosmeticsFilters({ + domain, + hostname, + url, - // DOM information - classes, - hrefs, - ids, + // DOM information + ...update, - // Only done once per frame (see above). - getBaseRules: false, - getInjectionRules: false, - getRulesFromHostname: false, + // Only done once per frame (see above). + getBaseRules: false, + getInjectionRules: false, + getExtendedRules: false, + getRulesFromHostname: false, - // Allows to get styles for updated DOM. - getRulesFromDOM: true, - }); + // Allows to get styles for updated DOM. + getRulesFromDOM: true, + }); - // Abort if cosmetics are disabled - if (active === false) { - return; - } + // Abort if cosmetics are disabled + if (active === false) { + return; + } - this.injectStylesIntoFrame(frame, styles).catch(() => { - /* ignore */ - }); + this.injectStylesIntoFrame(frame, styles).catch(() => { + /* ignore */ + }); + } }); // This loop will periodically check if any new custom styles should be @@ -244,7 +246,7 @@ export class PlaywrightBlocker extends FiltersEngine { try { const foundNewFeatures = observer.handleNewFeatures( - await frame.$$eval('[id],[class],[href]', extractFeaturesFromDOM), + await frame.$$eval(':root', extractFeaturesFromDOM), ); numberOfIterations += 1; diff --git a/packages/adblocker-puppeteer/adblocker.ts b/packages/adblocker-puppeteer/adblocker.ts index 4a80c2cff5..63dae145a9 100644 --- a/packages/adblocker-puppeteer/adblocker.ts +++ b/packages/adblocker-puppeteer/adblocker.ts @@ -169,7 +169,8 @@ export class PuppeteerBlocker extends FiltersEngine { // based on the hostname of this frame. We need to get these as fast as // possible to reduce blinking when page loads. { - const { active, styles, scripts } = this.getCosmeticsFilters({ + // TODO - implement extended filters for Puppeteer + const { active, styles, scripts /* , extended */ } = this.getCosmeticsFilters({ domain, hostname, url, @@ -177,6 +178,7 @@ export class PuppeteerBlocker extends FiltersEngine { // Done once per frame. getBaseRules: true, getInjectionRules: true, + getExtendedRules: true, getRulesFromHostname: true, // Will handle DOM features (see below). @@ -200,34 +202,35 @@ export class PuppeteerBlocker extends FiltersEngine { // nodes. We first query all of them, then monitor the DOM for a few // seconds (or until one of the stopping conditions is met, see below). - const observer = new DOMMonitor(({ ids, hrefs, classes }) => { - const { active, styles } = this.getCosmeticsFilters({ - domain, - hostname, - url, + const observer = new DOMMonitor((update) => { + if (update.type === 'features') { + const { active, styles } = this.getCosmeticsFilters({ + domain, + hostname, + url, - // DOM information - classes, - hrefs, - ids, + // DOM information + ...update, - // Only done once per frame (see above). - getBaseRules: false, - getInjectionRules: false, - getRulesFromHostname: false, + // Only done once per frame (see above). + getBaseRules: false, + getInjectionRules: false, + getExtendedRules: false, + getRulesFromHostname: false, - // Allows to get styles for updated DOM. - getRulesFromDOM: true, - }); + // Allows to get styles for updated DOM. + getRulesFromDOM: true, + }); - // Abort if cosmetics are disabled - if (active === false) { - return; - } + // Abort if cosmetics are disabled + if (active === false) { + return; + } - this.injectStylesIntoFrame(frame, styles).catch(() => { - /* ignore */ - }); + this.injectStylesIntoFrame(frame, styles).catch(() => { + /* ignore */ + }); + } }); // This loop will periodically check if any new custom styles should be @@ -250,7 +253,7 @@ export class PuppeteerBlocker extends FiltersEngine { try { const foundNewFeatures = observer.handleNewFeatures( - await frame.$$eval('[id],[class],[href]', extractFeaturesFromDOM), + await frame.$$eval(':root', extractFeaturesFromDOM), ); numberOfIterations += 1; diff --git a/packages/adblocker-webextension-cosmetics/adblocker.ts b/packages/adblocker-webextension-cosmetics/adblocker.ts index 5a43b22fe2..97bb0b0494 100644 --- a/packages/adblocker-webextension-cosmetics/adblocker.ts +++ b/packages/adblocker-webextension-cosmetics/adblocker.ts @@ -6,17 +6,32 @@ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ +// TODO - move to @cliqz/adblocker-content +import { querySelectorAll } from '@cliqz/adblocker-extended-selectors'; + import { IBackgroundCallback, IMessageFromBackground, DOMMonitor, - injectCSSRule, injectScript, } from '@cliqz/adblocker-content'; +type ExtendedSelector = IMessageFromBackground['extended'][number]; + let ACTIVE: boolean = true; let DOM_MONITOR: DOMMonitor | null = null; +let UPDATE_EXTENDED_TIMEOUT: ReturnType | null = null; +const PENDING: Set = new Set(); +const EXTENDED: ExtendedSelector[] = []; +const HIDDEN: Map< + Element, + { + selector: ExtendedSelector; + root: Element; + } +> = new Map(); + function unload(): void { if (DOM_MONITOR !== null) { DOM_MONITOR.stop(); @@ -38,20 +53,14 @@ function unload(): void { * The background should listen to these messages and answer back with lists of * filters to be injected in the page. */ -function getCosmeticsFiltersWithSendMessage({ - classes, - hrefs, - ids, - lifecycle, -}: IBackgroundCallback): Promise { +function getCosmeticsFiltersWithSendMessage( + arg: IBackgroundCallback, +): Promise { return new Promise((resolve) => { chrome.runtime.sendMessage( { action: 'getCosmeticsFilters', - classes, - hrefs, - ids, - lifecycle, + ...arg, }, (response: IMessageFromBackground | undefined) => { if (response !== undefined) { @@ -62,9 +71,143 @@ function getCosmeticsFiltersWithSendMessage({ }); } +function cachedQuerySelector( + root: Element, + selector: ExtendedSelector, + cache: Map>>, +): Set { + // First check if we have a result in cache for this node and selector + const cachedElements = cache.get(root)?.get(selector); + if (cachedElements !== undefined) { + return cachedElements; + } + + const selected = new Set(querySelectorAll(root, selector.ast)); + + // Cache result for next time! + if (selector.attribute !== undefined) { + let cachedSelectors = cache.get(root); + if (cachedSelectors === undefined) { + cachedSelectors = new Map(); + cache.set(root, cachedSelectors); + } + + let cachedSelected = cachedSelectors.get(selector); + if (cachedSelected === undefined) { + cachedSelected = new Set(); + cachedSelectors.set(selector, cachedSelected); + } + + for (const element of selected) { + cachedSelected.add(element); + } + } + + return selected; +} + +function updateExtended() { + if (PENDING.size === 0 || EXTENDED.length === 0) { + return; + } + + const cache: Map>> = new Map(); + + const elementsToHide: Map< + Element, + { + selector: IMessageFromBackground['extended'][number]; + root: Element; + } + > = new Map(); + + // Since we are processing elements in a delayed fashion, it is possible + // that some short-lived DOM nodes are already detached. Here we simply + // ignore them. + const roots = [...PENDING].filter((e) => e.isConnected === true); + PENDING.clear(); + + for (const root of roots) { + for (const selector of EXTENDED) { + for (const element of cachedQuerySelector(root, selector, cache)) { + if (selector.remove === true) { + element.textContent = ''; + element.remove(); + } else if (selector.attribute !== undefined && HIDDEN.has(element) === false) { + elementsToHide.set(element, { selector, root }); + } + } + } + } + + // Hide new nodes if any + for (const [element, { selector, root }] of elementsToHide.entries()) { + if (selector.attribute !== undefined) { + element.setAttribute(selector.attribute, ''); + HIDDEN.set(element, { selector, root }); + } + } + + // Check if some elements should be un-hidden. + for (const [element, { selector, root }] of [...HIDDEN.entries()]) { + if (selector.attribute !== undefined) { + if ( + root.isConnected === false || + element.isConnected === false || + cachedQuerySelector(root, selector, cache).has(element) === false + ) { + HIDDEN.delete(element); + element.removeAttribute(selector.attribute); + } + } + } +} + +/** + * Queue `elements` to be processed asynchronously in a batch way (for + * efficiency). This is important to not do more work than necessary, for + * example if the same set of nodes is updated multiple times in a raw on + * user-interaction (e.g. a dropdown); this allows to only check these nodes + * once, and to not block the UI. + */ +function delayedUpdateExtended(elements: Element[]) { + // If we do not have any extended filters applied to this frame, then we do + // not need to do anything. We just ignore. + if (EXTENDED.length === 0) { + return; + } + + // If root DOM element is already part of PENDING, no need to queue other elements. + if (PENDING.has(window.document.documentElement)) { + return; + } + + // Queue up new elements into the global PENDING set, which will be processed + // in a batch maner from a setTimeout. + for (const element of elements) { + // If we get the DOM root then we can clear everything else from the queue + // since we will be looking at all nodes anyway. + if (element === window.document.documentElement) { + PENDING.clear(); + PENDING.add(element); + break; + } + + PENDING.add(element); + } + + // Check if we need to trigger a setTimeout to process pending elements. + if (UPDATE_EXTENDED_TIMEOUT === null) { + UPDATE_EXTENDED_TIMEOUT = setTimeout(() => { + UPDATE_EXTENDED_TIMEOUT = null; + updateExtended(); + }, 1000); + } +} + function handleResponseFromBackground( window: Pick, - { active, scripts, styles }: IMessageFromBackground, + { active, scripts, extended }: IMessageFromBackground, ): void { if (active === false) { ACTIVE = false; @@ -76,26 +219,26 @@ function handleResponseFromBackground( // Inject scripts if (scripts) { - for (const script of scripts) { - setTimeout(() => injectScript(script, window.document), 0); - } - } - - // Normal CSS - if (styles && styles.length > 0) { - setTimeout(() => injectCSSRule(styles, window.document), 0); + setTimeout(() => { + for (const script of scripts) { + injectScript(script, window.document); + } + }, 0); } // Extended CSS - // if (extended && extended.length > 0) { - // } + if (extended && extended.length > 0) { + EXTENDED.push(...extended); + delayedUpdateExtended([window.document.documentElement]); + } } /** * Takes care of injecting cosmetic filters in a given window. Responsabilities: * - Inject scripts. * - Block scripts. - * - Inject CSS rules. + * + * NOTE: Custom stylesheets are now injected from background. * * All this happens by communicating with the background through the * `backgroundAction` function (to trigger request the sending of new rules @@ -125,13 +268,16 @@ export function injectCosmetics( window.addEventListener( 'DOMContentLoaded', () => { - DOM_MONITOR = new DOMMonitor(({ classes, ids, hrefs }) => { - getCosmeticsFilters({ - classes, - hrefs, - ids, - lifecycle: 'dom-update', - }).then((response) => handleResponseFromBackground(window, response)); + DOM_MONITOR = new DOMMonitor((update) => { + if (update.type === 'elements') { + if (update.elements.length !== 0) { + delayedUpdateExtended(update.elements); + } + } else { + getCosmeticsFilters({ ...update, lifecycle: 'dom-update' }).then((response) => + handleResponseFromBackground(window, response), + ); + } }); DOM_MONITOR.queryAll(window); diff --git a/packages/adblocker-webextension-cosmetics/package.json b/packages/adblocker-webextension-cosmetics/package.json index 22ab7ac0b6..d8db377d56 100644 --- a/packages/adblocker-webextension-cosmetics/package.json +++ b/packages/adblocker-webextension-cosmetics/package.json @@ -68,7 +68,6 @@ } ], "devDependencies": { - "@ampproject/rollup-plugin-closure-compiler": "^0.26.0", "@rollup/plugin-node-resolve": "^11.0.0", "@types/chai": "^4.2.11", "@types/chrome": "^0.0.128", @@ -83,6 +82,7 @@ "rimraf": "^3.0.0", "rollup": "^2.0.0", "rollup-plugin-sourcemaps": "^0.6.1", + "rollup-plugin-terser": "^7.0.0", "sinon": "^9.0.1", "sinon-chai": "^3.5.0", "ts-node": "^9.0.0", @@ -92,6 +92,7 @@ "typescript": "^4.1.2" }, "dependencies": { - "@cliqz/adblocker-content": "^1.19.0" + "@cliqz/adblocker-content": "^1.19.0", + "@cliqz/adblocker-extended-selectors": "^1.19.0" } } diff --git a/packages/adblocker-webextension-cosmetics/rollup.config.ts b/packages/adblocker-webextension-cosmetics/rollup.config.ts index 7c0e1f7305..4041cc3339 100644 --- a/packages/adblocker-webextension-cosmetics/rollup.config.ts +++ b/packages/adblocker-webextension-cosmetics/rollup.config.ts @@ -6,9 +6,9 @@ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ -import compiler from '@ampproject/rollup-plugin-closure-compiler'; import resolve from '@rollup/plugin-node-resolve'; import sourcemaps from 'rollup-plugin-sourcemaps'; +import { terser } from 'rollup-plugin-terser'; export default { input: './dist/es6/adblocker.js', @@ -21,9 +21,10 @@ export default { plugins: [ resolve(), sourcemaps(), - compiler({ - // language: 'ECMASCRIPT6_STRICT', - language_out: 'NO_TRANSPILE', + terser({ + output: { + comments: false, + }, }), ], }; diff --git a/packages/adblocker-webextension-cosmetics/test/adblocker.test.ts b/packages/adblocker-webextension-cosmetics/test/adblocker.test.ts index 8ad3d4412b..7bde8dcc5d 100644 --- a/packages/adblocker-webextension-cosmetics/test/adblocker.test.ts +++ b/packages/adblocker-webextension-cosmetics/test/adblocker.test.ts @@ -176,35 +176,6 @@ describe('#injectCosmetics', () => { expect(dom.window.document.getElementsByTagName('span')).to.have.lengthOf(1); }); - it('injects cosmetic', async () => { - const dom = new JSDOM( - ` - - - -
- -`, - { pretendToBeVisual: true }, - ); - - injectCosmetics(dom.window, true, async () => { - return { - active: true, - extended: [], - scripts: [], - styles: ` - #id1 { display: none !important; } - `, - }; - }); - - await tick(1000); - - const div = dom.window.document.getElementById('cliqz-adblokcer-css-rules'); - expect(div).not.to.be.null; - }); - it('does nothing if not active', async () => { const dom = new JSDOM( ` diff --git a/packages/adblocker-webextension-cosmetics/tsconfig.json b/packages/adblocker-webextension-cosmetics/tsconfig.json index b6f935bcd7..7a1badf9a8 100644 --- a/packages/adblocker-webextension-cosmetics/tsconfig.json +++ b/packages/adblocker-webextension-cosmetics/tsconfig.json @@ -6,7 +6,8 @@ "declarationDir": "dist/types" }, "references": [ - { "path": "../adblocker-content/tsconfig.json" } + { "path": "../adblocker-content/tsconfig.json" }, + { "path": "../adblocker-extended-selectors/tsconfig.json" } ], "files": [ "adblocker.ts" diff --git a/packages/adblocker-webextension/adblocker.ts b/packages/adblocker-webextension/adblocker.ts index 11c6a01f9b..0f0527d1dc 100644 --- a/packages/adblocker-webextension/adblocker.ts +++ b/packages/adblocker-webextension/adblocker.ts @@ -330,6 +330,7 @@ export class WebExtensionBlocker extends FiltersEngine { // This needs to be done only once per tab getBaseRules: true, getInjectionRules: false, + getExtendedRules: false, getRulesFromDOM: false, getRulesFromHostname: false, }); @@ -352,7 +353,7 @@ export class WebExtensionBlocker extends FiltersEngine { // ids and hrefs observed in the DOM. MutationObserver is also used to // make sure we can react to changes. { - const { active, styles, scripts } = this.getCosmeticsFilters({ + const { active, styles, scripts, extended } = this.getCosmeticsFilters({ domain, hostname, url, @@ -364,6 +365,7 @@ export class WebExtensionBlocker extends FiltersEngine { // This needs to be done only once per frame getBaseRules: false, getInjectionRules: msg.lifecycle === 'start', + getExtendedRules: msg.lifecycle === 'start', getRulesFromHostname: msg.lifecycle === 'start', // This will be done every time we get information about DOM mutation @@ -382,7 +384,7 @@ export class WebExtensionBlocker extends FiltersEngine { if (scripts.length !== 0) { sendResponse({ active, - extended: [], + extended, scripts, styles: '', }); diff --git a/packages/adblocker-webextension/package.json b/packages/adblocker-webextension/package.json index c7be05d775..ae11d4f21c 100644 --- a/packages/adblocker-webextension/package.json +++ b/packages/adblocker-webextension/package.json @@ -34,7 +34,6 @@ "url": "https://github.com/cliqz-oss/adblocker/issues" }, "devDependencies": { - "@ampproject/rollup-plugin-closure-compiler": "^0.26.0", "@rollup/plugin-node-resolve": "^11.0.0", "@types/chai": "^4.2.11", "@types/mocha": "^8.0.0", @@ -44,6 +43,7 @@ "rimraf": "^3.0.0", "rollup": "^2.0.0", "rollup-plugin-sourcemaps": "^0.6.1", + "rollup-plugin-terser": "^7.0.0", "ts-node": "^9.0.0", "tslint": "^6.0.0", "tslint-config-prettier": "^1.18.0", diff --git a/packages/adblocker-webextension/rollup.config.ts b/packages/adblocker-webextension/rollup.config.ts index 7c0e1f7305..4041cc3339 100644 --- a/packages/adblocker-webextension/rollup.config.ts +++ b/packages/adblocker-webextension/rollup.config.ts @@ -6,9 +6,9 @@ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ -import compiler from '@ampproject/rollup-plugin-closure-compiler'; import resolve from '@rollup/plugin-node-resolve'; import sourcemaps from 'rollup-plugin-sourcemaps'; +import { terser } from 'rollup-plugin-terser'; export default { input: './dist/es6/adblocker.js', @@ -21,9 +21,10 @@ export default { plugins: [ resolve(), sourcemaps(), - compiler({ - // language: 'ECMASCRIPT6_STRICT', - language_out: 'NO_TRANSPILE', + terser({ + output: { + comments: false, + }, }), ], }; diff --git a/packages/adblocker/package.json b/packages/adblocker/package.json index ab094c6981..ba5ee93534 100644 --- a/packages/adblocker/package.json +++ b/packages/adblocker/package.json @@ -78,7 +78,6 @@ } ], "devDependencies": { - "@ampproject/rollup-plugin-closure-compiler": "^0.26.0", "@remusao/smaz-generate": "^1.7.1", "@rollup/plugin-node-resolve": "^11.0.0", "@types/chai": "^4.2.11", @@ -94,6 +93,7 @@ "rimraf": "^3.0.0", "rollup": "^2.0.0", "rollup-plugin-sourcemaps": "^0.6.1", + "rollup-plugin-terser": "^7.0.0", "ts-node": "^9.0.0", "tslint": "^6.0.0", "tslint-config-prettier": "^1.18.0", @@ -101,6 +101,8 @@ "typescript": "^4.1.2" }, "dependencies": { + "@cliqz/adblocker-content": "^1.19.0", + "@cliqz/adblocker-extended-selectors": "^1.19.0", "@remusao/guess-url-type": "^1.1.2", "@remusao/small": "^1.1.2", "@remusao/smaz": "^1.7.1", diff --git a/packages/adblocker/rollup.config.ts b/packages/adblocker/rollup.config.ts index 7c0e1f7305..4041cc3339 100644 --- a/packages/adblocker/rollup.config.ts +++ b/packages/adblocker/rollup.config.ts @@ -6,9 +6,9 @@ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ -import compiler from '@ampproject/rollup-plugin-closure-compiler'; import resolve from '@rollup/plugin-node-resolve'; import sourcemaps from 'rollup-plugin-sourcemaps'; +import { terser } from 'rollup-plugin-terser'; export default { input: './dist/es6/adblocker.js', @@ -21,9 +21,10 @@ export default { plugins: [ resolve(), sourcemaps(), - compiler({ - // language: 'ECMASCRIPT6_STRICT', - language_out: 'NO_TRANSPILE', + terser({ + output: { + comments: false, + }, }), ], }; diff --git a/packages/adblocker/src/engine/bucket/cosmetic.ts b/packages/adblocker/src/engine/bucket/cosmetic.ts index 8b26bdb162..6de5b9c63b 100644 --- a/packages/adblocker/src/engine/bucket/cosmetic.ts +++ b/packages/adblocker/src/engine/bucket/cosmetic.ts @@ -6,6 +6,8 @@ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ +import type { IMessageFromBackground } from '@cliqz/adblocker-content'; + import { compactTokens, concatTypedArrays } from '../../compact-set'; import Config from '../../config'; import { StaticDataView } from '../../data-view'; @@ -387,6 +389,7 @@ export default class CosmeticFilterBucket { // Allows to specify which rules to return getBaseRules = true, getInjectionRules = true, + getExtendedRules = true, getRulesFromDOM = true, getRulesFromHostname = true, }: { @@ -402,9 +405,14 @@ export default class CosmeticFilterBucket { getBaseRules?: boolean; getInjectionRules?: boolean; + getExtendedRules?: boolean; getRulesFromDOM?: boolean; getRulesFromHostname?: boolean; - }): { injections: CosmeticFilter[]; stylesheet: string } { + }): { + injections: CosmeticFilter[]; + extended: IMessageFromBackground['extended']; + stylesheet: string; + } { // Tokens from `hostname` and `domain` which will be used to lookup filters // from the reverse index. The same tokens are re-used for multiple indices. const hostnameTokens = createLookupTokens(hostname, domain); @@ -483,12 +491,13 @@ export default class CosmeticFilterBucket { ); } + const extended: CosmeticFilter[] = []; const injections: CosmeticFilter[] = []; const styles: CosmeticFilter[] = []; // If we found at least one candidate, check if we have unhidden rules, - // apply them and dispatch rules into `injections` (i.e.: '+js(...)') and - // `styles` (i.e.: '##rule'). + // apply them and dispatch rules into `injections` (i.e.: '+js(...)'), + // `extended` (i.e. :not(...)), and `styles` (i.e.: '##rule'). if (rules.length !== 0) { // ======================================================================= // Rules: unhide @@ -526,6 +535,10 @@ export default class CosmeticFilterBucket { if (getInjectionRules === true && injectionsDisabled === false) { injections.push(rule); } + } else if (rule.isExtended()) { + if (getExtendedRules === true) { + extended.push(rule); + } } else { styles.push(rule); } @@ -544,7 +557,44 @@ export default class CosmeticFilterBucket { stylesheet += createStylesheetFromRules(styles); } + const extendedProcessed: IMessageFromBackground['extended'] = []; + if (extended.length !== 0) { + console.error('> extended candidates', extended); + const extendedStyles: Map = new Map(); + for (const rule of extended) { + const ast = rule.getSelectorAST(); + if (ast !== undefined) { + const attribute = rule.isRemove() ? undefined : rule.getStyleAttributeHash(); + + if (attribute !== undefined) { + extendedStyles.set(rule.getStyle(), attribute); + } + + extendedProcessed.push({ + ast, + remove: rule.isRemove(), + attribute, + }); + } + } + + console.error('styles', extendedStyles); + + if (extendedStyles.size !== 0) { + if (stylesheet.length !== 0) { + stylesheet += '\n\n'; + } + + stylesheet += [...extendedStyles.entries()] + .map(([style, attribute]) => `[${attribute}] { ${style} }`) + .join('\n\n'); + } + + console.error('stylesheet', stylesheet); + } + return { + extended: extendedProcessed, injections, stylesheet, }; @@ -577,7 +627,7 @@ export default class CosmeticFilterBucket { /** * This is used to lazily generate both the list of generic rules which can - * *potentially be un-hidden* (i.e.: there exists at least once unhide rule + * *potentially be un-hidden* (i.e.: there exists at least one unhide rule * for the selector) and a stylesheet containing all selectors which cannot * be un-hidden. Since this list will not change between updates we can * generate once and use many times. diff --git a/packages/adblocker/src/engine/engine.ts b/packages/adblocker/src/engine/engine.ts index 268315a2a3..17e15e9095 100644 --- a/packages/adblocker/src/engine/engine.ts +++ b/packages/adblocker/src/engine/engine.ts @@ -6,6 +6,8 @@ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ +import type { IMessageFromBackground } from '@cliqz/adblocker-content'; + import Config from '../config'; import { StaticDataView, sizeOfASCII, sizeOfByte } from '../data-view'; import { EventEmitter } from '../events'; @@ -600,6 +602,7 @@ export default class FilterEngine extends EventEmitter< // Allows to specify which rules to return getBaseRules = true, getInjectionRules = true, + getExtendedRules = true, getRulesFromDOM = true, getRulesFromHostname = true, }: { @@ -613,14 +616,10 @@ export default class FilterEngine extends EventEmitter< getBaseRules?: boolean; getInjectionRules?: boolean; + getExtendedRules?: boolean; getRulesFromDOM?: boolean; getRulesFromHostname?: boolean; - }): { - active: boolean; - scripts: string[]; - styles: string; - extended: string[]; - } { + }): IMessageFromBackground { if (this.config.loadCosmeticFilters === false) { return { active: false, @@ -670,7 +669,7 @@ export default class FilterEngine extends EventEmitter< } // Lookup injections as well as stylesheets - const { injections, stylesheet } = this.cosmetics.getCosmeticsFilters({ + const { injections, stylesheet, extended } = this.cosmetics.getCosmeticsFilters({ domain: domain || '', hostname, @@ -683,6 +682,7 @@ export default class FilterEngine extends EventEmitter< getBaseRules, getInjectionRules, + getExtendedRules, getRulesFromDOM, getRulesFromHostname, }); @@ -704,7 +704,7 @@ export default class FilterEngine extends EventEmitter< return { active: true, - extended: [], + extended, scripts, styles: stylesheet, }; diff --git a/packages/adblocker/src/filters/cosmetic.ts b/packages/adblocker/src/filters/cosmetic.ts index 956e6c6c0c..9c1a55d969 100644 --- a/packages/adblocker/src/filters/cosmetic.ts +++ b/packages/adblocker/src/filters/cosmetic.ts @@ -6,6 +6,13 @@ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ +import { + AST, + classifySelector, + SelectorType, + parse as parseCssSelector, +} from '@cliqz/adblocker-extended-selectors'; + import { Domains } from '../engine/domains'; import { EMPTY_UINT32_ARRAY, @@ -20,6 +27,7 @@ import { getEntityHashesFromLabelsBackward, } from '../request'; import { + fastHash, fastHashBetween, fastStartsWithFrom, getBit, @@ -129,7 +137,8 @@ const enum COSMETICS_MASK { isClassSelector = 1 << 3, isIdSelector = 1 << 4, isHrefSelector = 1 << 5, - htmlFiltering = 1 << 6, + remove = 1 << 6, + extended = 1 << 7, } function computeFilterId( @@ -159,33 +168,6 @@ function computeFilterId( return hash >>> 0; } -function parseProceduralFilter(line: string, indexAfterColon: number): null | string { - if ( - fastStartsWithFrom(line, '-abp-', indexAfterColon) || - fastStartsWithFrom(line, 'contains', indexAfterColon) || - fastStartsWithFrom(line, 'has-text', indexAfterColon) || - fastStartsWithFrom(line, 'has', indexAfterColon) || - fastStartsWithFrom(line, 'if-not', indexAfterColon) || - fastStartsWithFrom(line, 'if', indexAfterColon) || - fastStartsWithFrom(line, 'matches-css-after', indexAfterColon) || - fastStartsWithFrom(line, 'matches-css-before', indexAfterColon) || - fastStartsWithFrom(line, 'matches-css', indexAfterColon) || - fastStartsWithFrom(line, 'min-text-length', indexAfterColon) || - fastStartsWithFrom(line, 'nth-ancestor', indexAfterColon) || - fastStartsWithFrom(line, 'nth-of-type', indexAfterColon) || - fastStartsWithFrom(line, 'remove', indexAfterColon) || - fastStartsWithFrom(line, 'upward', indexAfterColon) || - fastStartsWithFrom(line, 'watch-attrs', indexAfterColon) || - fastStartsWithFrom(line, 'watch-attr', indexAfterColon) || - fastStartsWithFrom(line, 'xpath', indexAfterColon) - ) { - return null; - } - - // TODO - here we should parse the selector. - return line; -} - /*************************************************************************** * Cosmetic filters parsing * ************************************************************************ */ @@ -198,8 +180,8 @@ export default class CosmeticFilter implements IFilter { */ public static parse(line: string, debug: boolean = false): CosmeticFilter | null { // Mask to store attributes. Each flag (unhide, scriptInject, etc.) takes - // only 1 bit at a specific offset defined in COSMETICS_MASK. cf: - // COSMETICS_MASK for the offset of each property + // only 1 bit at a specific offset defined in COSMETICS_MASK. + // cf: COSMETICS_MASK for the offset of each property let mask = 0; let selector: string | undefined; let domains: Domains | undefined; @@ -210,6 +192,7 @@ export default class CosmeticFilter implements IFilter { const afterSharpIndex = sharpIndex + 1; let suffixStartIndex = afterSharpIndex + 1; + // hostname1,hostname2#?#.selector // hostname1,hostname2#@#.selector // ^^ ^ // || | @@ -218,9 +201,17 @@ export default class CosmeticFilter implements IFilter { // sharpIndex // Check if unhide - if (line.length > afterSharpIndex && line[afterSharpIndex] === '@') { - mask = setBit(mask, COSMETICS_MASK.unhide); - suffixStartIndex += 1; + if (line.length > afterSharpIndex) { + if (line[afterSharpIndex] === '@') { + mask = setBit(mask, COSMETICS_MASK.unhide); + suffixStartIndex += 1; + } else if (line[afterSharpIndex] === '?') { + suffixStartIndex += 1; + } + } + + if (suffixStartIndex >= line.length) { + return null; } // Parse hostnames and entitites as well as their negations. @@ -237,26 +228,42 @@ export default class CosmeticFilter implements IFilter { domains = Domains.parse(line.slice(0, sharpIndex).split(',')); } - // Deal with ^script:has-text(...) - if ( - line.charCodeAt(suffixStartIndex) === 94 /* '^' */ && - fastStartsWithFrom(line, 'script:has-text(', suffixStartIndex + 1) && - line.charCodeAt(line.length - 1) === 41 /* ')' */ + if (line.endsWith(':remove()')) { + // ##selector:remove() + mask = setBit(mask, COSMETICS_MASK.remove); + mask = setBit(mask, COSMETICS_MASK.extended); + line = line.slice(0, -9); + } else if ( + line.length - suffixStartIndex >= 8 && + line.endsWith(')') && + line.indexOf(':style(', suffixStartIndex) !== -1 ) { + // ##selector:style(...) + const indexOfStyle = line.indexOf(':style(', suffixStartIndex); + style = line.slice(indexOfStyle + 7, -1); + line = line.slice(0, indexOfStyle); + } + + // Deal with HTML filters + if (line.charCodeAt(suffixStartIndex) === 94 /* '^' */) { + if ( + fastStartsWithFrom(line, 'script:has-text(', suffixStartIndex + 1) === false || + line.charCodeAt(line.length - 1) !== 41 /* ')' */ + ) { + return null; + } + + // NOTE: currently only ^script:has-text(...) is supported. + // // ^script:has-text(selector) - // ^ ^ - // | | - // | | - // | scriptSelectorIndexEnd - // | - // scriptSelectorIndexStart + // ^ ^ + // | | + // | | + // | line.length + // | + // suffixStartIndex // - const scriptSelectorIndexStart = suffixStartIndex + 1; - const scriptSelectorIndexEnd = line.length; - mask = setBit(mask, COSMETICS_MASK.htmlFiltering); - selector = line.slice(scriptSelectorIndexStart, scriptSelectorIndexEnd); - - // Make sure this is a valid selector + selector = line.slice(suffixStartIndex, line.length); if (extractHTMLSelectorFromRule(selector) === undefined) { return null; } @@ -284,47 +291,35 @@ export default class CosmeticFilter implements IFilter { return null; } } else { - // Detect special syntax - let indexOfColon = line.indexOf(':', suffixStartIndex); - while (indexOfColon !== -1) { - const indexAfterColon = indexOfColon + 1; - if (fastStartsWithFrom(line, 'style', indexAfterColon)) { - // ##selector :style(...) - if (line[indexAfterColon + 5] === '(' && line[line.length - 1] === ')') { - selector = line.slice(suffixStartIndex, indexOfColon); - style = line.slice(indexAfterColon + 6, -1); - } else { - return null; - } - } else { - const result = parseProceduralFilter(line, indexAfterColon); - if (result === null) { - return null; - } - } - - indexOfColon = line.indexOf(':', indexAfterColon); - } - - // If we reach this point, filter is not extended syntax - if (selector === undefined && suffixStartIndex < line.length) { - selector = line.slice(suffixStartIndex); - } - - if (selector === undefined || !isValidCss(selector)) { - // Not a valid selector + selector = line.slice(suffixStartIndex); + const selectorType = classifySelector(selector); + if (selectorType === SelectorType.Extended) { + mask = setBit(mask, COSMETICS_MASK.extended); + } else if (selectorType === SelectorType.Invalid || !isValidCss(selector)) { + // console.error('Invalid', line); + // TODO - maybe perform `isValidCss` from the other module. return null; } } - // Check if unicode appears in selector + // Extended selectors should always be specific to some domain. + if (domains === undefined && getBit(mask, COSMETICS_MASK.extended) === true) { + return null; + } + if (selector !== undefined) { + // Check if unicode appears in selector if (hasUnicode(selector)) { mask = setBit(mask, COSMETICS_MASK.isUnicode); } // Classify selector - if (getBit(mask, COSMETICS_MASK.htmlFiltering) === false) { + if ( + getBit(mask, COSMETICS_MASK.scriptInject) === false && + getBit(mask, COSMETICS_MASK.remove) === false && + getBit(mask, COSMETICS_MASK.extended) === false && + selector.startsWith('^') === false + ) { const c0 = selector.charCodeAt(0); const c1 = selector.charCodeAt(1); const c2 = selector.charCodeAt(2); @@ -720,14 +715,30 @@ export default class CosmeticFilter implements IFilter { return this.style || DEFAULT_HIDDING_STYLE; } + public getStyleAttributeHash(): string { + return `s${fastHash(this.getStyle())}`; + } + public getSelector(): string { return this.selector; } + public getSelectorAST(): AST | undefined { + return parseCssSelector(this.getSelector()); + } + public getExtendedSelector(): HTMLSelector | undefined { return extractHTMLSelectorFromRule(this.selector); } + public isExtended(): boolean { + return getBit(this.mask, COSMETICS_MASK.extended); + } + + public isRemove(): boolean { + return getBit(this.mask, COSMETICS_MASK.remove); + } + public isUnhide(): boolean { return getBit(this.mask, COSMETICS_MASK.unhide); } @@ -757,7 +768,7 @@ export default class CosmeticFilter implements IFilter { } public isHtmlFiltering(): boolean { - return getBit(this.mask, COSMETICS_MASK.htmlFiltering); + return this.getSelector().startsWith('^'); } // A generic hide cosmetic filter is one that: diff --git a/packages/adblocker/src/html-filtering.ts b/packages/adblocker/src/html-filtering.ts index ec9ca087cd..d9879fc2a3 100644 --- a/packages/adblocker/src/html-filtering.ts +++ b/packages/adblocker/src/html-filtering.ts @@ -13,16 +13,16 @@ export type HTMLSelector = readonly ['script', readonly string[]]; export function extractHTMLSelectorFromRule(rule: string): HTMLSelector | undefined { - if (rule.startsWith('script') === false) { + if (rule.startsWith('^script') === false) { return undefined; } const prefix = ':has-text('; const selectors: string[] = []; - let index = 6; - // script:has-text - // ^ 6 + let index = 7; + // ^script:has-text + // ^ 7 // Prepare for finding one or more ':has-text(' selectors in a row while (rule.startsWith(prefix, index)) { diff --git a/packages/adblocker/src/lists.ts b/packages/adblocker/src/lists.ts index a3d40d6d4a..f600705541 100644 --- a/packages/adblocker/src/lists.ts +++ b/packages/adblocker/src/lists.ts @@ -87,6 +87,9 @@ export function detectFilterType(line: string): FilterType { afterSharpCharCode === 35 /* '#'*/ || (afterSharpCharCode === 64 /* '@' */ && fastStartsWithFrom(line, /* #@# */ '@#', afterSharpIndex)) + // TODO - support ADB/AdGuard extended css selectors + // || (afterSharpCharCode === 63 /* '?' */ && + // fastStartsWithFrom(line, /* #?# */ '?#', afterSharpIndex)) ) { // Parse supported cosmetic filter // `##` `#@#` diff --git a/packages/adblocker/test/parsing.test.ts b/packages/adblocker/test/parsing.test.ts index d14a82dc17..42e2aaced6 100644 --- a/packages/adblocker/test/parsing.test.ts +++ b/packages/adblocker/test/parsing.test.ts @@ -1371,9 +1371,11 @@ function cosmetic(filter: string, expected: any) { // Options isClassSelector: parsed.isClassSelector(), + isExtended: parsed.isExtended(), isHrefSelector: parsed.isHrefSelector(), isHtmlFiltering: parsed.isHtmlFiltering(), isIdSelector: parsed.isIdSelector(), + isRemove: parsed.isRemove(), isScriptInject: parsed.isScriptInject(), isUnhide: parsed.isUnhide(), }; @@ -1390,9 +1392,11 @@ const DEFAULT_COSMETIC_FILTER = { // Options isClassSelector: false, + isExtended: false, isHrefSelector: false, isHtmlFiltering: false, isIdSelector: false, + isRemove: false, isScriptInject: false, isUnhide: false, }; @@ -1650,12 +1654,70 @@ describe('Cosmetic filters', () => { }); }); + describe('parses remove filters', () => { + it('simple', () => { + cosmetic('example.com##.cls:remove()', { + ...DEFAULT_COSMETIC_FILTER, + selector: '.cls', + isRemove: true, + isExtended: true, + }); + }); + + it('extended', () => { + cosmetic('example.com##.cls:has-text(/Foo/i):remove()', { + ...DEFAULT_COSMETIC_FILTER, + selector: '.cls:has-text(/Foo/i)', + isRemove: true, + isExtended: true, + }); + }); + }); + + describe('parses extended filters', () => { + for (const pseudo of [ + '-abp-contains', + '-abp-has', + '-abp-properties', + 'if-not', + 'matches-css', + 'matches-css-after', + 'matches-css-before', + 'min-text-length', + 'nth-ancestor', + 'upward', + 'watch-attr', + 'watch-attrs', + 'xpath', + ]) { + it(`rejects unsupported: ${pseudo}`, () => { + cosmetic(`example.com##.cls:${pseudo}()`, null); + }); + } + + for (const pseudo of ['has', 'has-text', 'if']) { + it(`parse supported: ${pseudo}`, () => { + cosmetic(`example.com##.cls:${pseudo}()`, { + ...DEFAULT_COSMETIC_FILTER, + isExtended: true, + selector: `.cls:${pseudo}()`, + domains: { + hostnames: h(['example.com']), + entities: undefined, + notHostnames: undefined, + notEntities: undefined, + }, + }); + }); + } + }); + describe('parses html filtering', () => { it('^script:has-text()', () => { cosmetic('##^script:has-text(foo bar)', { ...DEFAULT_COSMETIC_FILTER, isHtmlFiltering: true, - selector: 'script:has-text(foo bar)', + selector: '^script:has-text(foo bar)', }); }); @@ -1669,7 +1731,7 @@ describe('Cosmetic filters', () => { notEntities: undefined, }, isHtmlFiltering: true, - selector: 'script:has-text(foo bar)', + selector: '^script:has-text(foo bar)', }); }); diff --git a/packages/adblocker/tsconfig.json b/packages/adblocker/tsconfig.json index b020e014e7..c7bc515a5e 100644 --- a/packages/adblocker/tsconfig.json +++ b/packages/adblocker/tsconfig.json @@ -5,6 +5,10 @@ "outDir": "dist/cjs", "declarationDir": "dist/types" }, + "references": [ + { "path": "../adblocker-content/tsconfig.json" }, + { "path": "../adblocker-extended-selectors/tsconfig.json" } + ], "include": [ "./src/**/*.ts", "./adblocker.ts" diff --git a/tsconfig.project.json b/tsconfig.project.json index 90930591c8..44f621df0b 100644 --- a/tsconfig.project.json +++ b/tsconfig.project.json @@ -4,6 +4,9 @@ { "path": "./packages/adblocker/tsconfig.json" }, + { + "path": "./packages/adblocker-extended-selectors/tsconfig.json" + }, { "path": "./packages/adblocker-content/tsconfig.json" }, diff --git a/yarn.lock b/yarn.lock index f46fdb3b12..cc3d7b04df 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2,27 +2,6 @@ # yarn lockfile v1 -"@ampproject/remapping@0.2.0": - version "0.2.0" - resolved "https://registry.yarnpkg.com/@ampproject/remapping/-/remapping-0.2.0.tgz#07290a5c0f5eac8a4c33d38aa0d15a3416db432e" - integrity sha512-a4EztS9/GOVQjX5Ol+Iz33TFhaXvYBF7aB6D8+Qz0/SCIxOm3UNRhGZiwcCuJ8/Ifc6NCogp3S48kc5hFxRpUw== - dependencies: - "@jridgewell/resolve-uri" "1.0.0" - sourcemap-codec "1.4.8" - -"@ampproject/rollup-plugin-closure-compiler@^0.26.0": - version "0.26.0" - resolved "https://registry.yarnpkg.com/@ampproject/rollup-plugin-closure-compiler/-/rollup-plugin-closure-compiler-0.26.0.tgz#69f8265e5fdbf3e26905eaaedc60cb5982bd6be0" - integrity sha512-wuHzGE6BDhDR0L7nUPlpQDPGiGnMw+b0B+cDPG0S5TatOmFNQva8KSNdBHan3L9RbvNyYXOXicuCrZtSoBfrBg== - dependencies: - "@ampproject/remapping" "0.2.0" - acorn "7.2.0" - acorn-walk "7.1.1" - estree-walker "2.0.1" - google-closure-compiler "20200517.0.0" - magic-string "0.25.7" - uuid "8.1.0" - "@auto-it/bot-list@10.6.1": version "10.6.1" resolved "https://registry.yarnpkg.com/@auto-it/bot-list/-/bot-list-10.6.1.tgz#6f499117b192ac3f2c159ee1830906a24b2e7ced" @@ -390,11 +369,6 @@ resolved "https://registry.yarnpkg.com/@istanbuljs/schema/-/schema-0.1.2.tgz#26520bf09abe4a5644cd5414e37125a8954241dd" integrity sha512-tsAQNx32a8CoFhjhijUIhI4kccIAgmGhy8LZMZgGfmXcpMbPRUqn5LWmgRttILi6yeGmBJd2xsPkFMs0PzgPCw== -"@jridgewell/resolve-uri@1.0.0": - version "1.0.0" - resolved "https://registry.yarnpkg.com/@jridgewell/resolve-uri/-/resolve-uri-1.0.0.tgz#3fdf5798f0b49e90155896f6291df186eac06c83" - integrity sha512-9oLAnygRMi8Q5QkYEU4XWK04B+nuoXoxjRvRxgjuChkLZFBja0YPSgdZ7dZtwhncLBcQe/I/E+fLuk5qxcYVJA== - "@lerna/add@3.21.0": version "3.21.0" resolved "https://registry.yarnpkg.com/@lerna/add/-/add-3.21.0.tgz#27007bde71cc7b0a2969ab3c2f0ae41578b4577b" @@ -1522,10 +1496,17 @@ resolved "https://registry.yarnpkg.com/@types/http-cache-semantics/-/http-cache-semantics-4.0.0.tgz#9140779736aa2655635ee756e2467d787cfe8a2a" integrity sha512-c3Xy026kOF7QOTn00hbIllV1dLR9hG9NkSrLQgCVs8NF6sBU+VGWjD3wLPhmh1TYAc7ugCFsvHYMN4VcBN1U1A== +<<<<<<< HEAD "@types/jsdom@^16.2.3": version "16.2.6" resolved "https://registry.yarnpkg.com/@types/jsdom/-/jsdom-16.2.6.tgz#9ddf0521e49be5365797e690c3ba63148e562c29" integrity sha512-yQA+HxknGtW9AkRTNyiSH3OKW5V+WzO8OPTdne99XwJkYC+KYxfNIcoJjeiSqP3V00PUUpFP6Myoo9wdIu78DQ== +======= +"@types/jsdom@^16.2.3", "@types/jsdom@^16.2.5": + version "16.2.5" + resolved "https://registry.yarnpkg.com/@types/jsdom/-/jsdom-16.2.5.tgz#74ebad438741d249ecb416c5486dcde4217eb66c" + integrity sha512-k/ZaTXtReAjwWu0clU0KLS53dyqZnA8mm+jwKFeFrvufXgICp+VNbskETFxKKAguv0pkaEKTax5MaRmvalM+TA== +>>>>>>> 094f7432 (Initial support for extended CSS selectors (a.k.a. procedural filters)) dependencies: "@types/node" "*" "@types/parse5" "*" @@ -1679,21 +1660,11 @@ acorn-globals@^6.0.0: acorn "^7.1.1" acorn-walk "^7.1.1" -acorn-walk@7.1.1: - version "7.1.1" - resolved "https://registry.yarnpkg.com/acorn-walk/-/acorn-walk-7.1.1.tgz#345f0dffad5c735e7373d2fec9a1023e6a44b83e" - integrity sha512-wdlPY2tm/9XBr7QkKlq0WQVgiuGTX6YWPyRyBviSoScBuLfTVQhvwg6wJ369GJ/1nPfTLMfnrFIfjqVg6d+jQQ== - acorn-walk@^7.1.1: version "7.2.0" resolved "https://registry.yarnpkg.com/acorn-walk/-/acorn-walk-7.2.0.tgz#0de889a601203909b0fbe07b8938dc21d2e967bc" integrity sha512-OPdCF6GsMIP+Az+aWfAAOEt2/+iVDKE7oy6lJ098aoe59oAmK76qV6Gw60SbZ8jHuG2wH058GF4pLFbYamYrVA== -acorn@7.2.0: - version "7.2.0" - resolved "https://registry.yarnpkg.com/acorn/-/acorn-7.2.0.tgz#17ea7e40d7c8640ff54a694c889c26f31704effe" - integrity sha512-apwXVmYVpQ34m/i71vrApRrRKCWQnZZF1+npOD0WV5xZFfwWOmKGQ2RWlfdy9vWITsenisM8M0Qeq8agcFHNiQ== - acorn@^7.1.1: version "7.4.1" resolved "https://registry.yarnpkg.com/acorn/-/acorn-7.4.1.tgz#feaed255973d2e77555b83dbc08851a6c63520fa" @@ -2342,7 +2313,7 @@ chai@^4.2.0: pathval "^1.1.0" type-detect "^4.0.5" -chalk@2.x, chalk@^2.0.0, chalk@^2.3.0, chalk@^2.3.1, chalk@^2.3.2, chalk@^2.4.1, chalk@^2.4.2: +chalk@^2.0.0, chalk@^2.3.0, chalk@^2.3.1, chalk@^2.3.2, chalk@^2.4.1, chalk@^2.4.2: version "2.4.2" resolved "https://registry.yarnpkg.com/chalk/-/chalk-2.4.2.tgz#cd42541677a54333cf541a49108c1432b44c9424" integrity sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ== @@ -2439,11 +2410,6 @@ cliui@^6.0.0: strip-ansi "^6.0.0" wrap-ansi "^6.2.0" -clone-buffer@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/clone-buffer/-/clone-buffer-1.0.0.tgz#e3e25b207ac4e701af721e2cb5a16792cac3dc58" - integrity sha1-4+JbIHrE5wGvch4staFnksrD3Fg= - clone-deep@^4.0.1: version "4.0.1" resolved "https://registry.yarnpkg.com/clone-deep/-/clone-deep-4.0.1.tgz#c19fd9bdbbf85942b4fd979c84dcf7d5f07c2387" @@ -2460,30 +2426,11 @@ clone-response@^1.0.2: dependencies: mimic-response "^1.0.0" -clone-stats@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/clone-stats/-/clone-stats-1.0.0.tgz#b3782dff8bb5474e18b9b6bf0fdfe782f8777680" - integrity sha1-s3gt/4u1R04Yuba/D9/ngvh3doA= - clone@^1.0.2: version "1.0.4" resolved "https://registry.yarnpkg.com/clone/-/clone-1.0.4.tgz#da309cc263df15994c688ca902179ca3c7cd7c7e" integrity sha1-2jCcwmPfFZlMaIypAheco8fNfH4= -clone@^2.1.1: - version "2.1.2" - resolved "https://registry.yarnpkg.com/clone/-/clone-2.1.2.tgz#1b7f4b9f591f1e8f83670401600345a02887435f" - integrity sha1-G39Ln1kfHo+DZwQBYANFoCiHQ18= - -cloneable-readable@^1.0.0: - version "1.1.3" - resolved "https://registry.yarnpkg.com/cloneable-readable/-/cloneable-readable-1.1.3.tgz#120a00cb053bfb63a222e709f9683ea2e11d8cec" - integrity sha512-2EF8zTQOxYq70Y4XKtorQupqF0m49MBz2/yf5Bj+MHjvpG3Hy7sImifnqD6UA+TKYxeSV+u6qqQPawN5UvnpKQ== - dependencies: - inherits "^2.0.1" - process-nextick-args "^2.0.0" - readable-stream "^2.3.5" - code-point-at@^1.0.0: version "1.1.0" resolved "https://registry.yarnpkg.com/code-point-at/-/code-point-at-1.1.0.tgz#0d070b4d043a5bea33a2f1a40e2edb3d9a4ccf77" @@ -2570,7 +2517,7 @@ command-line-usage@^6.0.0: table-layout "^1.0.1" typical "^5.2.0" -commander@^2.12.1: +commander@^2.12.1, commander@^2.20.0: version "2.20.3" resolved "https://registry.yarnpkg.com/commander/-/commander-2.20.3.tgz#fd485e84c03eb4881c20722ba48035e8531aeb33" integrity sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ== @@ -3296,16 +3243,16 @@ estraverse@^4.2.0: resolved "https://registry.yarnpkg.com/estraverse/-/estraverse-4.3.0.tgz#398ad3f3c5a24948be7725e83d11a7de28cdbd1d" integrity sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw== -estree-walker@2.0.1, estree-walker@^2.0.1: - version "2.0.1" - resolved "https://registry.yarnpkg.com/estree-walker/-/estree-walker-2.0.1.tgz#f8e030fb21cefa183b44b7ad516b747434e7a3e0" - integrity sha512-tF0hv+Yi2Ot1cwj9eYHtxC0jB9bmjacjQs6ZBTj82H8JwUywFuc+7E83NWfNMwHXZc11mjfFcVXPe9gEP4B8dg== - estree-walker@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/estree-walker/-/estree-walker-1.0.1.tgz#31bc5d612c96b704106b477e6dd5d8aa138cb700" integrity sha512-1fMXF3YP4pZZVozF8j/ZLfvnR8NSIljt56UhbZ5PeeDmmGHpgpdwQt7ITlGvYaQukCvuBRMLEiKiYC+oeIg4cg== +estree-walker@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/estree-walker/-/estree-walker-2.0.1.tgz#f8e030fb21cefa183b44b7ad516b747434e7a3e0" + integrity sha512-tF0hv+Yi2Ot1cwj9eYHtxC0jB9bmjacjQs6ZBTj82H8JwUywFuc+7E83NWfNMwHXZc11mjfFcVXPe9gEP4B8dg== + esutils@^2.0.2: version "2.0.3" resolved "https://registry.yarnpkg.com/esutils/-/esutils-2.0.3.tgz#74d2eb4de0b8da1293711910d50775b9b710ef64" @@ -3945,47 +3892,6 @@ globby@^9.2.0: pify "^4.0.1" slash "^2.0.0" -google-closure-compiler-java@^20200517.0.0: - version "20200517.0.0" - resolved "https://registry.yarnpkg.com/google-closure-compiler-java/-/google-closure-compiler-java-20200517.0.0.tgz#778370c22273c9085f4cf959ce063f8f112c02ac" - integrity sha512-JVZBiyyXwcYi6Yc3lO6dF2hMLJA4OzPm4/mgsem/tF1vk2HsWTnL3GTaBsPB2ENVZp0hoqsd4KgpPiG9ssNWxw== - -google-closure-compiler-js@^20200517.0.0: - version "20200517.0.0" - resolved "https://registry.yarnpkg.com/google-closure-compiler-js/-/google-closure-compiler-js-20200517.0.0.tgz#9cb0861f764073d1c4d3b7453b74073ccb1ecfb1" - integrity sha512-dz6dOUHx5nhdIqMRXacAYS8aJfLvw4IKxGg28Hq/zeeDPHlX3P3iBK20NgFDfT8zdushThymtMqChSy7C5eyfA== - -google-closure-compiler-linux@^20200517.0.0: - version "20200517.0.0" - resolved "https://registry.yarnpkg.com/google-closure-compiler-linux/-/google-closure-compiler-linux-20200517.0.0.tgz#2b9ecb634130060174aff5c52329a694ea4be68b" - integrity sha512-S5xPh6TtP+ESzZrmQLcDDqtZAsCVTbdI4VS98wQlN6IMZTd94nAnOCg9mrxQNAgop2t4sdsv/KuH0BGPUWEZ+w== - -google-closure-compiler-osx@^20200517.0.0: - version "20200517.0.0" - resolved "https://registry.yarnpkg.com/google-closure-compiler-osx/-/google-closure-compiler-osx-20200517.0.0.tgz#9394e9a2fd97e3729fc3bd2abcffff6aab2cfcaa" - integrity sha512-FWIcsKqLllLjdOBZd7azijVaObydgRd0obVNi63eUfC5MX6T4qxKumGCyor2UCNY6by2ESz+PlGqCFzFhZ6b2g== - -google-closure-compiler-windows@^20200517.0.0: - version "20200517.0.0" - resolved "https://registry.yarnpkg.com/google-closure-compiler-windows/-/google-closure-compiler-windows-20200517.0.0.tgz#c5cdde438c29458666a83358567b12072924ed6c" - integrity sha512-UXhjRGwS8deTkRla/riyVq3psscgMuw78lepEPtq5NgbumgJzY2+IQP9q+4MVOfJW58Rv0JUWKAFOnBBSZWcAQ== - -google-closure-compiler@20200517.0.0: - version "20200517.0.0" - resolved "https://registry.yarnpkg.com/google-closure-compiler/-/google-closure-compiler-20200517.0.0.tgz#6c47f99fc1be59bd4f9e23c5a8f2e66d64b54143" - integrity sha512-80W9zBS9Ajk1T5InWCfsoPohDmo5T1AAyw1rHh5+dgb/jPgwC65KhY+oJozTncf+/7tyQHJXozTARwhSlBUcMg== - dependencies: - chalk "2.x" - google-closure-compiler-java "^20200517.0.0" - google-closure-compiler-js "^20200517.0.0" - minimist "1.x" - vinyl "2.x" - vinyl-sourcemaps-apply "^0.2.0" - optionalDependencies: - google-closure-compiler-linux "^20200517.0.0" - google-closure-compiler-osx "^20200517.0.0" - google-closure-compiler-windows "^20200517.0.0" - got@^11.5.2: version "11.8.1" resolved "https://registry.yarnpkg.com/got/-/got-11.8.1.tgz#df04adfaf2e782babb3daabc79139feec2f7e85d" @@ -4755,6 +4661,15 @@ java-properties@^1.0.0: resolved "https://registry.yarnpkg.com/java-properties/-/java-properties-1.0.2.tgz#ccd1fa73907438a5b5c38982269d0e771fe78211" integrity sha512-qjdpeo2yKlYTH7nFdK0vbZWuTCesk4o63v5iVOlhMQPfuIZQfW/HI35SjfhA+4qpg36rnFSvUK5b1m+ckIblQQ== +jest-worker@^26.2.1: + version "26.6.2" + resolved "https://registry.yarnpkg.com/jest-worker/-/jest-worker-26.6.2.tgz#7f72cbc4d643c365e27b9fd775f9d0eaa9c7a8ed" + integrity sha512-KWYVV1c4i+jbMpaBC+U++4Va0cp8OisU185o73T1vo99hqi7w8tSJfUXYswwqqrjzwxa6KpRK54WhPvwf5w6PQ== + dependencies: + "@types/node" "*" + merge-stream "^2.0.0" + supports-color "^7.0.0" + jpeg-js@^0.4.2: version "0.4.2" resolved "https://registry.yarnpkg.com/jpeg-js/-/jpeg-js-0.4.2.tgz#8b345b1ae4abde64c2da2fe67ea216a114ac279d" @@ -4778,7 +4693,7 @@ jsbn@~0.1.0: resolved "https://registry.yarnpkg.com/jsbn/-/jsbn-0.1.1.tgz#a5e654c2e5a2deb5f201d96cefbca80c0ef2f513" integrity sha1-peZUwuWi3rXyAdls77yoDA7y9RM= -jsdom@^16.0.1: +jsdom@^16.0.1, jsdom@^16.4.0: version "16.4.0" resolved "https://registry.yarnpkg.com/jsdom/-/jsdom-16.4.0.tgz#36005bde2d136f73eee1a830c6d45e55408edddb" integrity sha512-lYMm3wYdgPhrl7pDcRmvzPhhrGVBeVhPIqeHjzeiHN3DFmD1RBpbExbi8vU7BJdH8VAZYovR8DMt0PNNDM7k8w== @@ -5135,7 +5050,7 @@ macos-release@^2.2.0: resolved "https://registry.yarnpkg.com/macos-release/-/macos-release-2.4.1.tgz#64033d0ec6a5e6375155a74b1a1eba8e509820ac" integrity sha512-H/QHeBIN1fIGJX517pvK8IEK53yQOW7YcEI55oYtgjDdoCQQz7eJS94qt5kNrscReEyuD/JcdFCm2XBEcGOITg== -magic-string@0.25.7, magic-string@^0.25.7: +magic-string@^0.25.7: version "0.25.7" resolved "https://registry.yarnpkg.com/magic-string/-/magic-string-0.25.7.tgz#3f497d6fd34c669c6798dcb821f2ef31f5445051" integrity sha512-4CrMT5DOHTDk4HYDlzmwu4FVCcIYI8gauveasrdCu2IKIFOJ3f0v/8MDGJCDL9oD2ppz/Av1b0Nj345H9M+XIA== @@ -5376,7 +5291,7 @@ minimist-options@^3.0.1: arrify "^1.0.1" is-plain-obj "^1.1.0" -minimist@1.x, minimist@^1.1.3, minimist@^1.2.0, minimist@^1.2.5: +minimist@^1.1.3, minimist@^1.2.0, minimist@^1.2.5: version "1.2.5" resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.5.tgz#67d66014b66a6a8aaa0c083c5fd58df4e4e97602" integrity sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw== @@ -6330,7 +6245,7 @@ pretty-ms@^7.0.0: dependencies: parse-ms "^2.1.0" -process-nextick-args@^2.0.0, process-nextick-args@~2.0.0: +process-nextick-args@~2.0.0: version "2.0.1" resolved "https://registry.yarnpkg.com/process-nextick-args/-/process-nextick-args-2.0.1.tgz#7820d9b16120cc55ca9ae7792680ae7dba6d7fe2" integrity sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag== @@ -6588,7 +6503,7 @@ read@1, read@~1.0.1: dependencies: mute-stream "~0.0.4" -"readable-stream@1 || 2", readable-stream@^2.0.0, readable-stream@^2.0.6, readable-stream@^2.1.5, readable-stream@^2.2.2, readable-stream@^2.3.5, readable-stream@^2.3.6, readable-stream@~2.3.6: +"readable-stream@1 || 2", readable-stream@^2.0.0, readable-stream@^2.0.6, readable-stream@^2.1.5, readable-stream@^2.2.2, readable-stream@^2.3.6, readable-stream@~2.3.6: version "2.3.7" resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-2.3.7.tgz#1eca1cf711aef814c04f62252a36a62f6cb23b57" integrity sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw== @@ -6683,11 +6598,6 @@ remove-markdown@^0.3.0: resolved "https://registry.yarnpkg.com/remove-markdown/-/remove-markdown-0.3.0.tgz#5e4b667493a93579728f3d52ecc1db9ca505dc98" integrity sha1-XktmdJOpNXlyjz1S7MHbnKUF3Jg= -remove-trailing-separator@^1.0.1: - version "1.1.0" - resolved "https://registry.yarnpkg.com/remove-trailing-separator/-/remove-trailing-separator-1.1.0.tgz#c24bce2a283adad5bc3f58e0d48249b92379d8ef" - integrity sha1-wkvOKig62tW8P1jg1IJJuSN52O8= - repeat-element@^1.1.2: version "1.1.3" resolved "https://registry.yarnpkg.com/repeat-element/-/repeat-element-1.1.3.tgz#782e0d825c0c5a3bb39731f84efee6b742e6b1ce" @@ -6705,11 +6615,6 @@ repeating@^2.0.0: dependencies: is-finite "^1.0.0" -replace-ext@^1.0.0: - version "1.0.1" - resolved "https://registry.yarnpkg.com/replace-ext/-/replace-ext-1.0.1.tgz#2d6d996d04a15855d967443631dd5f77825b016a" - integrity sha512-yD5BHCe7quCgBph4rMQ+0KkIRKwWCrHDOX1p1Gp6HwjPM5kVoCdKGNhN7ydqqsX6lJEnQDKZ/tFMiEdQ1dvPEw== - request-promise-core@1.1.4: version "1.1.4" resolved "https://registry.yarnpkg.com/request-promise-core/-/request-promise-core-1.1.4.tgz#3eedd4223208d419867b78ce815167d10593a22f" @@ -6883,6 +6788,16 @@ rollup-plugin-sourcemaps@^0.6.1: "@rollup/pluginutils" "^3.0.9" source-map-resolve "^0.6.0" +rollup-plugin-terser@^7.0.0: + version "7.0.2" + resolved "https://registry.yarnpkg.com/rollup-plugin-terser/-/rollup-plugin-terser-7.0.2.tgz#e8fbba4869981b2dc35ae7e8a502d5c6c04d324d" + integrity sha512-w3iIaU4OxcF52UUXiZNsNeuXIMDvFrr+ZXK6bFZ0Q60qyVfq4uLptoS4bbq3paG3x216eQllFZX7zt6TIImguQ== + dependencies: + "@babel/code-frame" "^7.10.4" + jest-worker "^26.2.1" + serialize-javascript "^4.0.0" + terser "^5.0.0" + rollup@^2.0.0: version "2.36.1" resolved "https://registry.yarnpkg.com/rollup/-/rollup-2.36.1.tgz#2174f0c25c7b400d57b05628d0e732c7ae8d2178" @@ -6992,6 +6907,13 @@ serialize-javascript@5.0.1: dependencies: randombytes "^2.1.0" +serialize-javascript@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/serialize-javascript/-/serialize-javascript-4.0.0.tgz#b525e1238489a5ecfc42afacc3fe99e666f4b1aa" + integrity sha512-GaNA54380uFefWghODBWEGisLZFj00nS5ACs6yHa9nLqlLpVLO8ChDGeKRjZnV4Nh4n0Qi7nhYZD/9fCPzEqkw== + dependencies: + randombytes "^2.1.0" + set-blocking@^2.0.0, set-blocking@~2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/set-blocking/-/set-blocking-2.0.0.tgz#045f9782d011ae9a6803ddd382b24392b3d890f7" @@ -7161,7 +7083,7 @@ source-map-resolve@^0.6.0: atob "^2.1.2" decode-uri-component "^0.2.0" -source-map-support@^0.5.17: +source-map-support@^0.5.17, source-map-support@~0.5.19: version "0.5.19" resolved "https://registry.yarnpkg.com/source-map-support/-/source-map-support-0.5.19.tgz#a98b62f86dcaf4f67399648c085291ab9e8fed61" integrity sha512-Wonm7zOCIJzBGQdB+thsPar0kYuCIzYvxZwlBa87yi/Mdjv7Tip2cyVbLj5o0cFPN4EVkuTwb3GDDyUx2DGnGw== @@ -7174,7 +7096,7 @@ source-map-url@^0.4.0: resolved "https://registry.yarnpkg.com/source-map-url/-/source-map-url-0.4.0.tgz#3e935d7ddd73631b97659956d55128e87b5084a3" integrity sha1-PpNdfd1zYxuXZZlW1VEo6HtQhKM= -source-map@^0.5.0, source-map@^0.5.1, source-map@^0.5.6: +source-map@^0.5.0, source-map@^0.5.6: version "0.5.7" resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.5.7.tgz#8a039d2d1021d22d1ea14c80d8ea468ba2ef3fcc" integrity sha1-igOdLRAh0i0eoUyA2OpGi6LvP8w= @@ -7184,7 +7106,12 @@ source-map@^0.6.0, source-map@^0.6.1, source-map@~0.6.1: resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.6.1.tgz#74722af32e9614e9c287a8d0bbde48b5e2f1a263" integrity sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g== -sourcemap-codec@1.4.8, sourcemap-codec@^1.4.4: +source-map@~0.7.2: + version "0.7.3" + resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.7.3.tgz#5302f8169031735226544092e64981f751750383" + integrity sha512-CkCj6giN3S+n9qrYiBTX5gystlENnRW5jZeNLHpe6aue+SrHcG5VYwujhW9s4dY31mEGsxBDrHR6oI69fTXsaQ== + +sourcemap-codec@^1.4.4: version "1.4.8" resolved "https://registry.yarnpkg.com/sourcemap-codec/-/sourcemap-codec-1.4.8.tgz#ea804bd94857402e6992d05a38ef1ae35a9ab4c4" integrity sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA== @@ -7589,6 +7516,15 @@ terminal-link@^2.1.1: ansi-escapes "^4.2.1" supports-hyperlinks "^2.0.0" +terser@^5.0.0: + version "5.5.1" + resolved "https://registry.yarnpkg.com/terser/-/terser-5.5.1.tgz#540caa25139d6f496fdea056e414284886fb2289" + integrity sha512-6VGWZNVP2KTUcltUQJ25TtNjx/XgdDsBDKGt8nN0MpydU36LmbPPcMBd2kmtZNNGVVDLg44k7GKeHHj+4zPIBQ== + dependencies: + commander "^2.20.0" + source-map "~0.7.2" + source-map-support "~0.5.19" + test-exclude@^6.0.0: version "6.0.0" resolved "https://registry.yarnpkg.com/test-exclude/-/test-exclude-6.0.0.tgz#04a8698661d805ea6fa293b6cb9e63ac044ef15e" @@ -8067,11 +8003,6 @@ util-promisify@^2.1.0: dependencies: object.getownpropertydescriptors "^2.0.3" -uuid@8.1.0: - version "8.1.0" - resolved "https://registry.yarnpkg.com/uuid/-/uuid-8.1.0.tgz#6f1536eb43249f473abc6bd58ff983da1ca30d8d" - integrity sha512-CI18flHDznR0lq54xBycOVmphdCYnQLKn8abKn7PXUiKUGdEd+/l9LWNJmugXel4hXq7S+RMNl34ecyC9TntWg== - uuid@^3.0.1, uuid@^3.3.2, uuid@^3.3.3: version "3.4.0" resolved "https://registry.yarnpkg.com/uuid/-/uuid-3.4.0.tgz#b23e4358afa8a202fe7a100af1f5f883f02007ee" @@ -8101,25 +8032,6 @@ verror@1.10.0: core-util-is "1.0.2" extsprintf "^1.2.0" -vinyl-sourcemaps-apply@^0.2.0: - version "0.2.1" - resolved "https://registry.yarnpkg.com/vinyl-sourcemaps-apply/-/vinyl-sourcemaps-apply-0.2.1.tgz#ab6549d61d172c2b1b87be5c508d239c8ef87705" - integrity sha1-q2VJ1h0XLCsbh75cUI0jnI74dwU= - dependencies: - source-map "^0.5.1" - -vinyl@2.x: - version "2.2.1" - resolved "https://registry.yarnpkg.com/vinyl/-/vinyl-2.2.1.tgz#23cfb8bbab5ece3803aa2c0a1eb28af7cbba1974" - integrity sha512-LII3bXRFBZLlezoG5FfZVcXflZgWP/4dCwKtxd5ky9+LOtM4CS3bIRQsmR1KMnMW07jpE8fqR2lcxPZ+8sJIcw== - dependencies: - clone "^2.1.1" - clone-buffer "^1.0.0" - clone-stats "^1.0.0" - cloneable-readable "^1.0.0" - remove-trailing-separator "^1.0.1" - replace-ext "^1.0.0" - w3c-hr-time@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/w3c-hr-time/-/w3c-hr-time-1.0.2.tgz#0a89cdf5cc15822df9c360543676963e0cc308cd"