├── .markdownlintignore ├── examples ├── .eslintignore ├── .gitignore ├── tsconfig.json ├── .eslintrc.yml ├── package.json ├── README.md └── src │ ├── parsing-selectors.ts │ ├── validate-regexp.ts │ └── validate-xpath.ts ├── .husky ├── pre-commit └── install.js ├── ecsstree.d.ts ├── .eslintignore ├── src ├── version.js ├── index.js └── syntax │ └── index.js ├── bamboo-specs ├── bamboo.yaml ├── permissions.yaml ├── deploy.yaml ├── increment.yaml ├── test.yaml └── build.yaml ├── test ├── smoke │ ├── esm │ │ ├── index.js │ │ ├── package.json │ │ └── test.sh │ ├── typescript │ │ ├── index.ts │ │ ├── tsconfig.json │ │ ├── package.json │ │ └── test.sh │ └── cjs │ │ ├── index.js │ │ ├── package.json │ │ └── test.sh └── syntax │ ├── nth-ancestor.test.js │ ├── min-text-length.test.js │ ├── xpath.test.js │ ├── if-not.test.js │ ├── upward.test.js │ ├── matches-css.test.js │ ├── matches-media.test.js │ ├── style.test.js │ └── contains.test.js ├── .gitignore ├── vitest.config.js ├── .markdownlint.json ├── LICENSE ├── tasks └── build-txt.js ├── .github └── workflows │ ├── check.yml │ └── release.yml ├── package.json ├── rollup.config.js ├── CHANGELOG.md ├── .eslintrc.cjs └── README.md /.markdownlintignore: -------------------------------------------------------------------------------- 1 | node_modules/ -------------------------------------------------------------------------------- /examples/.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | pnpm lint 2 | pnpm test 3 | -------------------------------------------------------------------------------- /ecsstree.d.ts: -------------------------------------------------------------------------------- 1 | export * from "@eslint/css-tree"; 2 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | examples 4 | test/smoke -------------------------------------------------------------------------------- /examples/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | package-lock.json 3 | yarn.lock -------------------------------------------------------------------------------- /src/version.js: -------------------------------------------------------------------------------- 1 | import { version } from '../package.json'; 2 | 3 | export default version; 4 | -------------------------------------------------------------------------------- /bamboo-specs/bamboo.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | !include 'test.yaml' 3 | --- 4 | !include 'increment.yaml' 5 | --- 6 | !include 'build.yaml' 7 | --- 8 | !include 'deploy.yaml' 9 | --- 10 | !include 'permissions.yaml' 11 | -------------------------------------------------------------------------------- /test/smoke/esm/index.js: -------------------------------------------------------------------------------- 1 | import { parse } from '@adguard/ecss-tree'; 2 | import { ok } from 'assert'; 3 | 4 | const node = parse('.foo { color: red; }'); 5 | 6 | ok(node); 7 | 8 | console.log('Smoke test passed'); 9 | -------------------------------------------------------------------------------- /test/smoke/typescript/index.ts: -------------------------------------------------------------------------------- 1 | import { parse } from '@adguard/ecss-tree'; 2 | import { ok } from 'assert'; 3 | 4 | const node = parse('.foo { color: red; }'); 5 | 6 | ok(node); 7 | 8 | console.log('Smoke test passed'); 9 | -------------------------------------------------------------------------------- /test/smoke/cjs/index.js: -------------------------------------------------------------------------------- 1 | const { parse } = require('@adguard/ecss-tree'); 2 | const { ok } = require('assert'); 3 | 4 | const node = parse('.foo { color: red; }'); 5 | 6 | ok(node); 7 | 8 | console.log('Smoke test passed'); 9 | -------------------------------------------------------------------------------- /test/smoke/cjs/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "cjs", 3 | "version": "1.0.0", 4 | "main": "index.js", 5 | "license": "MIT", 6 | "author": "Adguard Software Ltd.", 7 | "scripts": { 8 | "start": "node index.js", 9 | "test": "./test.sh" 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /examples/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2016", 4 | "module": "NodeNext", 5 | "esModuleInterop": true, 6 | "forceConsistentCasingInFileNames": true, 7 | "strict": true, 8 | "skipLibCheck": true 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /test/smoke/esm/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "esm", 3 | "version": "1.0.0", 4 | "main": "index.js", 5 | "license": "MIT", 6 | "author": "Adguard Software Ltd.", 7 | "type": "module", 8 | "scripts": { 9 | "start": "node index.js", 10 | "test": "./test.sh" 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | logs 2 | *.log 3 | npm-debug.log* 4 | 5 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 6 | 7 | coverage 8 | *.lcov 9 | 10 | node_modules/ 11 | 12 | .npm 13 | .npmrc 14 | 15 | .eslintcache 16 | 17 | *.tgz 18 | 19 | .env 20 | .env.test 21 | 22 | dist 23 | build.txt 24 | 25 | .pnpm-store/ 26 | -------------------------------------------------------------------------------- /test/smoke/typescript/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2016", 4 | "module": "commonjs", 5 | "esModuleInterop": true, 6 | "forceConsistentCasingInFileNames": true, 7 | "strict": true, 8 | "skipLibCheck": true 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /vitest.config.js: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line import/no-unresolved 2 | import { defineConfig } from 'vitest/config'; 3 | 4 | export default defineConfig({ 5 | test: { 6 | watch: false, 7 | coverage: { 8 | include: [ 9 | 'src/**/*.js', 10 | ], 11 | }, 12 | }, 13 | }); 14 | -------------------------------------------------------------------------------- /test/smoke/typescript/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "typescript", 3 | "version": "1.0.0", 4 | "main": "index.js", 5 | "license": "MIT", 6 | "author": "Adguard Software Ltd.", 7 | "scripts": { 8 | "start": "tsc --noEmit", 9 | "test": "./test.sh" 10 | }, 11 | "devDependencies": { 12 | "@types/node": "^22.14.0" 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /bamboo-specs/permissions.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | version: 2 3 | deployment: 4 | name: ECSSTree - deploy 5 | deployment-permissions: 6 | - groups: 7 | - extensions-developers 8 | - adguard-qa 9 | permissions: 10 | - view 11 | environment-permissions: 12 | - npmjs: 13 | - groups: 14 | - extensions-developers 15 | permissions: 16 | - view 17 | - deploy 18 | -------------------------------------------------------------------------------- /.markdownlint.json: -------------------------------------------------------------------------------- 1 | { 2 | "ul-indent": { "indent": 4 }, 3 | "line-length": { 4 | "stern": true, 5 | "line_length": 120 6 | }, 7 | "no-multiple-blanks": { "maximum": 2 }, 8 | "no-inline-html": { "allowed_elements": ["a", "details", "summary", "img"] }, 9 | "no-duplicate-header": { "siblings_only": true }, 10 | "no-blanks-blockquote": false, 11 | "no-bare-urls": false, 12 | "ul-style": { "style": "dash" }, 13 | "emphasis-style": { "style": "asterisk" } 14 | } 15 | -------------------------------------------------------------------------------- /.husky/install.js: -------------------------------------------------------------------------------- 1 | // See: https://typicode.github.io/husky/how-to.html#ci-server-and-docker 2 | 3 | // Do not initialize Husky in CI environments. GitHub Actions set the CI env variable automatically. 4 | // See: https://docs.github.com/en/actions/learn-github-actions/variables#default-environment-variables 5 | if (process.env.CI === 'true') { 6 | process.exit(0); 7 | } 8 | 9 | // Initialize Husky programmatically. 10 | const husky = (await import('husky')).default; 11 | console.log(husky()); 12 | -------------------------------------------------------------------------------- /examples/.eslintrc.yml: -------------------------------------------------------------------------------- 1 | # ESLint configuration file for TypeScript based on Airbnb's style guide 2 | root: true 3 | extends: 4 | - airbnb-base 5 | - airbnb-typescript/base 6 | parser: "@typescript-eslint/parser" 7 | plugins: 8 | - "@typescript-eslint" 9 | rules: 10 | max-len: 11 | - error 12 | - code: 120 13 | comments: 120 14 | tabWidth: 4 15 | ignoreUrls: false 16 | ignoreTrailingComments: false 17 | ignoreComments: false 18 | "@typescript-eslint/indent": 19 | - error 20 | - 4 21 | - SwitchCase: 1 22 | # This is a demo project, so we don't need to worry about console.logs 23 | "no-console": 0 24 | parserOptions: 25 | project: ./tsconfig.json 26 | -------------------------------------------------------------------------------- /examples/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "license": "MIT", 3 | "author": "AdGuard Software Ltd. ", 4 | "type": "module", 5 | "scripts": { 6 | "lint": "eslint . --ext .ts" 7 | }, 8 | "dependencies": { 9 | "@adguard/ecss-tree": "^1.0.6", 10 | "regexpp": "^3.2.0", 11 | "xpath": "^0.0.32" 12 | }, 13 | "devDependencies": { 14 | "@types/node": "^18.14.0", 15 | "@typescript-eslint/eslint-plugin": "^5.53.0", 16 | "@typescript-eslint/parser": "^5.53.0", 17 | "eslint": "^8.34.0", 18 | "eslint-config-airbnb-base": "^15.0.0", 19 | "eslint-config-airbnb-typescript": "^17.0.0", 20 | "eslint-plugin-import": "^2.27.5", 21 | "ts-node": "^10.9.1", 22 | "typescript": "^4.9.5" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /test/smoke/typescript/test.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e # Exit on error 4 | 5 | curr_path="test/smoke/typescript" 6 | ecss_tree="ecsstree.tgz" 7 | nm_path="node_modules" 8 | 9 | # Define cleanup function 10 | cleanup() { 11 | echo "Performing cleanup..." 12 | rm -f $ecss_tree && rm -rf $nm_path 13 | echo "Cleanup complete" 14 | } 15 | 16 | # Set trap to execute the cleanup function on script exit 17 | trap cleanup EXIT 18 | 19 | (cd ../../.. && pnpm pack --out $ecss_tree && mv $ecss_tree "$curr_path/$ecss_tree") 20 | 21 | # unzip to @adguard/tsurlfilter to node_modules 22 | ecss_tree_node_modules=$nm_path"/@adguard/ecss-tree" 23 | mkdir -p $ecss_tree_node_modules 24 | tar -xzf $ecss_tree --strip-components=1 -C $ecss_tree_node_modules 25 | 26 | pnpm start 27 | echo "Test successfully built." 28 | -------------------------------------------------------------------------------- /test/smoke/cjs/test.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e # Exit on error 4 | 5 | # pack @adguard/ecss-tree 6 | curr_path="test/smoke/cjs" 7 | ecss_tree="ecsstree.tgz" 8 | nm_path="node_modules" 9 | 10 | # Define cleanup function 11 | cleanup() { 12 | echo "Cleaning up..." 13 | rm -f $ecss_tree && rm -rf $nm_path 14 | echo "Cleanup complete" 15 | } 16 | 17 | # Set trap to execute the cleanup function on script exit 18 | trap cleanup EXIT 19 | 20 | (cd ../../.. && pnpm pack --out $ecss_tree && mv $ecss_tree "$curr_path/$ecss_tree") 21 | 22 | # unzip to @adguard/tsurlfilter to node_modules 23 | ecss_tree_node_modules=$nm_path"/@adguard/ecss-tree" 24 | mkdir -p $ecss_tree_node_modules 25 | tar -xzf $ecss_tree --strip-components=1 -C $ecss_tree_node_modules 26 | 27 | pnpm start 28 | echo "Test successfully built." 29 | -------------------------------------------------------------------------------- /test/smoke/esm/test.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e # Exit on error 4 | 5 | # pack @adguard/ecss-tree 6 | curr_path="test/smoke/esm" 7 | ecss_tree="ecsstree.tgz" 8 | nm_path="node_modules" 9 | 10 | # Define cleanup function 11 | cleanup() { 12 | echo "Cleaning up..." 13 | rm -f $ecss_tree && rm -rf $nm_path 14 | echo "Cleanup complete" 15 | } 16 | 17 | # Set trap to execute the cleanup function on script exit 18 | trap cleanup EXIT 19 | 20 | (cd ../../.. && pnpm pack --out $ecss_tree && mv $ecss_tree "$curr_path/$ecss_tree") 21 | 22 | # unzip to @adguard/tsurlfilter to node_modules 23 | ecss_tree_node_modules=$nm_path"/@adguard/ecss-tree" 24 | mkdir -p $ecss_tree_node_modules 25 | tar -xzf $ecss_tree --strip-components=1 -C $ecss_tree_node_modules 26 | 27 | pnpm start 28 | echo "Test successfully built." 29 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import syntax from './syntax/index'; 2 | 3 | // Fork API doesn't export everything, so we need to export the rest of 4 | // the API manually. See the original source code: 5 | // https://github.com/csstree/csstree/blob/master/lib/index.js 6 | 7 | export { default as version } from './version'; 8 | 9 | export { 10 | createSyntax, 11 | List, 12 | Lexer, 13 | tokenTypes, 14 | tokenNames, 15 | TokenStream, 16 | definitionSyntax, 17 | clone, 18 | ident, 19 | string, 20 | url, 21 | keyword, 22 | property, 23 | vendorPrefix, 24 | isCustomProperty, 25 | } from '@eslint/css-tree'; 26 | 27 | // Export the forked syntax (comes from the Fork API) 28 | export const { 29 | tokenize, 30 | parse, 31 | generate, 32 | lexer, 33 | createLexer, 34 | 35 | walk, 36 | find, 37 | findLast, 38 | findAll, 39 | 40 | toPlainObject, 41 | fromPlainObject, 42 | 43 | fork, 44 | } = syntax; 45 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 AdGuard Software Ltd. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /bamboo-specs/deploy.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | version: 2 3 | deployment: 4 | name: ECSSTree - deploy 5 | source-plan: AJL-ECSSTREEBUILD 6 | release-naming: ${bamboo.inject.version} 7 | environments: 8 | - npmjs 9 | 10 | npmjs: 11 | docker: 12 | image: adguard/node-ssh:22.14--0 13 | volumes: 14 | ${system.PNPM_DIR}: "${bamboo.cachePnpm}" 15 | triggers: [] 16 | tasks: 17 | - checkout: 18 | force-clean-build: 'true' 19 | - artifact-download: 20 | artifacts: 21 | - name: ecsstree.tgz 22 | - script: 23 | interpreter: SHELL 24 | scripts: 25 | - |- 26 | set -e 27 | set -x 28 | 29 | # Fix mixed logs 30 | exec 2>&1 31 | 32 | ls -laht 33 | 34 | export NPM_TOKEN=${bamboo.npmSecretToken} 35 | echo "//registry.npmjs.org/:_authToken=${NPM_TOKEN}" > .npmrc 36 | npm publish ecsstree.tgz --access public 37 | requirements: 38 | - adg-docker: 'true' 39 | - extension: 'true' 40 | notifications: 41 | - events: 42 | - deployment-started-and-finished 43 | recipients: 44 | - webhook: 45 | name: Deploy webhook 46 | url: http://prod.jirahub.service.eu.consul/v1/webhook/bamboo 47 | -------------------------------------------------------------------------------- /bamboo-specs/increment.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | version: 2 3 | plan: 4 | project-key: AJL 5 | key: ECSSTREEINCR 6 | name: ECSSTree - increment 7 | variables: 8 | dockerNode: adguard/node-ssh:22.14--0 9 | 10 | stages: 11 | - Increment: 12 | manual: true 13 | final: false 14 | jobs: 15 | - Increment 16 | 17 | Increment: 18 | key: INCR 19 | docker: 20 | image: '${bamboo.dockerNode}' 21 | volumes: 22 | ${system.PNPM_DIR}: "${bamboo.cachePnpm}" 23 | other: 24 | clean-working-dir: true 25 | tasks: 26 | - checkout: 27 | force-clean-build: true 28 | - script: 29 | interpreter: SHELL 30 | scripts: 31 | - |- 32 | set -e 33 | set -x 34 | 35 | # Fix mixed logs 36 | exec 2>&1 37 | 38 | pnpm increment 39 | - any-task: 40 | plugin-key: com.atlassian.bamboo.plugins.vcs:task.vcs.commit 41 | configuration: 42 | commitMessage: 'skipci: Automatic increment build number' 43 | selectedRepository: defaultRepository 44 | requirements: 45 | - adg-docker: 'true' 46 | - extension: 'true' 47 | 48 | branches: 49 | create: manually 50 | delete: never 51 | link-to-jira: true 52 | 53 | labels: [] 54 | other: 55 | concurrent-build-plugin: system-default 56 | -------------------------------------------------------------------------------- /tasks/build-txt.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @file Output the version number to a build.txt file. 3 | * 4 | * This is needed for Bamboo variable injection. 5 | */ 6 | import { 7 | existsSync, 8 | mkdirSync, 9 | readFileSync, 10 | writeFileSync, 11 | } from 'node:fs'; 12 | import { dirname, resolve } from 'node:path'; 13 | import { fileURLToPath } from 'node:url'; 14 | 15 | // eslint-disable-next-line no-underscore-dangle 16 | const __filename = fileURLToPath(import.meta.url); 17 | // eslint-disable-next-line no-underscore-dangle 18 | const __dirname = dirname(__filename); 19 | 20 | const PATH = '../'; 21 | const FILENAME = 'build.txt'; 22 | 23 | const readVersion = () => { 24 | const rawPackageData = readFileSync(resolve(__dirname, '../package.json'), 'utf8'); 25 | const packageData = JSON.parse(rawPackageData); 26 | 27 | return packageData.version; 28 | }; 29 | 30 | const main = () => { 31 | const content = `version=${readVersion()}`; 32 | const dir = resolve(__dirname, PATH); 33 | 34 | if (!existsSync(dir)) { 35 | mkdirSync(dir); 36 | } 37 | 38 | const filePath = resolve(__dirname, PATH, FILENAME); 39 | writeFileSync(filePath, content); 40 | 41 | // eslint-disable-next-line no-console 42 | console.log(`Version number written to ${filePath}`); 43 | }; 44 | 45 | main(); 46 | -------------------------------------------------------------------------------- /examples/README.md: -------------------------------------------------------------------------------- 1 | # ECSSTree examples 2 | 3 | This project contains examples of how to use the ECSSTree library. 4 | Our examples are written in TypeScript and are located in the `src` directory. 5 | Each example is a separate TypeScript file. Source codes are well commented and should be self-explanatory. 6 | 7 | ECSSTree uses EXACTLY the same AST as the [CSSTree](https://github.com/csstree/csstree), 8 | so you can use the [CSSTree documentation](https://github.com/csstree/csstree/tree/master/docs) 9 | to learn more about the API, AST structure, etc. 10 | 11 | ## Running examples 12 | 13 | - Install dependencies by running `npm i` or `yarn` (depending on your package manager). 14 | - Run `npx ts-node-esm src/{example-name}.ts` or `yarn ts-node-esm src/{example-name}.ts` to run the specific example. 15 | For example, to run the `parsing-selectors` example, 16 | run `npx ts-node-esm src/parsing-selectors.ts` / `yarn ts-node-esm src/parsing-selectors.ts`. 17 | [ts-node](https://typestrong.org/ts-node/) makes it possible to run TypeScript files directly, 18 | same as `node` does with JavaScript files. 19 | 20 | ## List of examples 21 | 22 | Here is a list of currently available examples: 23 | 24 | - `parsing-selectors`: 25 | - Selector parsing example 26 | - Converting AST to plain object and back 27 | - Generating CSS from AST (serializing) 28 | - `validate-regexp`: 29 | - Parsing selectors, then validating parameters of `:contains` pseudo-class with the regexpp library to check 30 | if a valid regular expression is used as a parameter 31 | - `validate-xpath` 32 | - Parsing selectors, then validating parameters of `:xpath` pseudo-class with the xpath library to check 33 | if a valid XPath expression is used as a parameter 34 | -------------------------------------------------------------------------------- /bamboo-specs/test.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | version: 2 3 | plan: 4 | project-key: AJL 5 | key: ECSSTREETEST 6 | name: ECSSTree - tests 7 | variables: 8 | dockerNode: adguard/node-ssh:22.14--0 9 | 10 | stages: 11 | - Build: 12 | manual: false 13 | final: false 14 | jobs: 15 | - Build 16 | 17 | Build: 18 | key: BUILD 19 | docker: 20 | image: '${bamboo.dockerNode}' 21 | volumes: 22 | ${system.PNPM_DIR}: "${bamboo.cachePnpm}" 23 | tasks: 24 | - checkout: 25 | force-clean-build: true 26 | - script: 27 | interpreter: SHELL 28 | scripts: 29 | - |- 30 | set -e 31 | set -x 32 | 33 | # Fix mixed logs 34 | exec 2>&1 35 | 36 | ls -laht 37 | 38 | # Set cache directory 39 | pnpm config set store-dir ${bamboo.cachePnpm} 40 | 41 | # Install dependencies 42 | pnpm install 43 | 44 | # Lint code 45 | pnpm lint 46 | 47 | # Run tests 48 | pnpm test 49 | 50 | # Build should be successful 51 | pnpm build 52 | 53 | # Run smoke tests 54 | pnpm test:smoke 55 | final-tasks: 56 | - script: 57 | interpreter: SHELL 58 | scripts: 59 | - |- 60 | set -x 61 | set -e 62 | 63 | # Fix mixed logs 64 | exec 2>&1 65 | 66 | ls -la 67 | 68 | echo "Size before cleanup:" && du -h | tail -n 1 69 | rm -rf node_modules dist 70 | echo "Size after cleanup:" && du -h | tail -n 1 71 | requirements: 72 | - adg-docker: 'true' 73 | - extension: 'true' 74 | 75 | branches: 76 | create: for-pull-request 77 | delete: 78 | after-deleted-days: '1' 79 | after-inactive-days: '5' 80 | link-to-jira: 'true' 81 | 82 | notifications: 83 | - events: 84 | - plan-status-changed 85 | recipients: 86 | - webhook: 87 | name: Build webhook 88 | url: http://prod.jirahub.service.eu.consul/v1/webhook/bamboo 89 | 90 | labels: [] 91 | other: 92 | concurrent-build-plugin: system-default 93 | -------------------------------------------------------------------------------- /.github/workflows/check.yml: -------------------------------------------------------------------------------- 1 | name: Code check 2 | 3 | env: 4 | NODE_VERSION: 22 5 | PNPM_VERSION: 10.7.1 6 | 7 | on: 8 | push: 9 | branches: 10 | - master 11 | pull_request: 12 | branches: 13 | - master 14 | 15 | jobs: 16 | check_code: 17 | name: Run type checking, linting, and testing 18 | runs-on: ubuntu-latest 19 | steps: 20 | - name: Check out to repository 21 | uses: actions/checkout@v4 22 | 23 | - name: Setup pnpm 24 | uses: pnpm/action-setup@v4 25 | with: 26 | version: ${{ env.PNPM_VERSION }} 27 | run_install: false 28 | 29 | - name: Set up Node.js 30 | uses: actions/setup-node@v4 31 | with: 32 | node-version: ${{ env.NODE_VERSION }} 33 | cache: pnpm 34 | 35 | - name: Install dependencies 36 | run: pnpm install 37 | 38 | - name: List files 39 | run: ls -alt 40 | 41 | - name: Lint code 42 | run: pnpm lint 43 | 44 | - name: Run tests 45 | run: pnpm test 46 | 47 | - name: Check build 48 | run: pnpm build 49 | 50 | - name: Run smoke tests 51 | run: pnpm test:smoke 52 | 53 | notify: 54 | name: Send Slack notification on failure 55 | needs: check_code 56 | # Run this job only if the previous job failed and the event was triggered by the 'AdguardTeam/ecsstree' repository 57 | # Note: 'always()' is needed to run the notify job even if the test job was failed 58 | if: 59 | ${{ 60 | always() && 61 | needs.check_code.result == 'failure' && 62 | github.repository == 'AdguardTeam/ecsstree' && 63 | ( 64 | github.event_name == 'push' || 65 | github.event_name == 'workflow_dispatch' || 66 | github.event.pull_request.head.repo.full_name == github.repository 67 | ) 68 | }} 69 | runs-on: ubuntu-latest 70 | steps: 71 | - name: Send Slack notification 72 | uses: 8398a7/action-slack@v3 73 | with: 74 | status: failure 75 | fields: workflow, repo, message, commit, author, eventName, ref, job 76 | job_name: check_code 77 | env: 78 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 79 | SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} 80 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Create GitHub Release 2 | 3 | env: 4 | NODE_VERSION: 22 5 | PNPM_VERSION: 10.7.1 6 | 7 | on: 8 | push: 9 | tags: 10 | - v* 11 | 12 | # Workflow need write access to the repository to create a release 13 | permissions: 14 | contents: write 15 | 16 | # Make sure that only one release workflow runs at a time 17 | concurrency: 18 | group: release 19 | 20 | jobs: 21 | release: 22 | name: Create GitHub release 23 | runs-on: ubuntu-latest 24 | steps: 25 | - name: Check out the repository 26 | uses: actions/checkout@v4 27 | 28 | - name: Setup pnpm 29 | uses: pnpm/action-setup@v4 30 | with: 31 | version: ${{ env.PNPM_VERSION }} 32 | run_install: false 33 | 34 | - name: Set up Node.js 35 | uses: actions/setup-node@v4 36 | with: 37 | node-version: ${{ env.NODE_VERSION }} 38 | cache: pnpm 39 | 40 | - name: Install dependencies 41 | run: pnpm install 42 | 43 | - name: List files 44 | run: ls -alt 45 | 46 | - name: Lint code 47 | run: pnpm lint 48 | 49 | - name: Run tests 50 | run: pnpm test 51 | 52 | - name: Check build 53 | run: pnpm build 54 | 55 | - name: Run smoke tests 56 | run: pnpm test:smoke 57 | 58 | - name: Pack files 59 | run: pnpm pack --out ecsstree.tgz 60 | 61 | - name: Release on GitHub 62 | uses: softprops/action-gh-release@v1 63 | with: 64 | files: | 65 | ecsstree.tgz 66 | draft: false 67 | prerelease: false 68 | # TODO: Extract data from CHANGELOG.md 69 | body: See [CHANGELOG.md](./CHANGELOG.md) for the list of changes. 70 | env: 71 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 72 | 73 | notify: 74 | name: Send Slack notification 75 | needs: release 76 | # Note: 'always()' is needed to run the notify job even if the test job was failed 77 | if: 78 | ${{ 79 | always() && 80 | github.repository == 'AdguardTeam/ecsstree' && 81 | github.event_name == 'push' 82 | }} 83 | runs-on: ubuntu-latest 84 | steps: 85 | - name: Send Slack notification 86 | uses: 8398a7/action-slack@v3 87 | with: 88 | status: ${{ needs.release.result }} 89 | fields: workflow, repo, message, commit, author, eventName, ref, job 90 | job_name: release 91 | env: 92 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 93 | SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} 94 | -------------------------------------------------------------------------------- /examples/src/parsing-selectors.ts: -------------------------------------------------------------------------------- 1 | // You can run this script with `pnpm ts-node-esm parsing-selectors.ts` 2 | // (you can run TypeScript scripts directly with ts-node module) 3 | 4 | import { 5 | parse, generate, toPlainObject, fromPlainObject, 6 | } from '@adguard/ecss-tree'; 7 | import { inspect } from 'util'; 8 | 9 | // Some inputs to test, feel free to add more 10 | const inputs = [ 11 | // Valid selectors 12 | 'div:-abp-has(> .some-class > a[href^="https://example.com"])', 13 | 'body:style(padding-top: 0 !important;):matches-media((min-width: 500px) and (max-width: 1000px))', 14 | 'section:upward(2):contains(aaa\'bbb):xpath(//*[contains(text(),"()(cc")])', 15 | 16 | // Missing closing bracket at the end 17 | 'div:-abp-has(> .some-class > a[href^="https://example.com"]', 18 | ]; 19 | 20 | // Iterate over inputs 21 | // eslint-disable-next-line no-restricted-syntax 22 | for (const input of inputs) { 23 | try { 24 | // Parse raw input to AST. This will throw an error if the input is not valid. 25 | // Don't forget to set context to 'selector', because CSSTree will try to parse 26 | // 'stylesheet' by default. 27 | const ast = parse(input, { context: 'selector' }); 28 | 29 | // Check if the parsed AST is a Selector 30 | if (ast.type !== 'Selector') { 31 | throw new Error( 32 | `Expected "Selector", got "${ast.type}" instead in "${input}"`, 33 | ); 34 | } 35 | 36 | // By default, AST uses a doubly linked list. To convert it to plain object, you 37 | // can use toPlainObject() function. 38 | // If you want to convert AST back to doubly linked list version, you can use 39 | // fromPlainObject() function. 40 | const astPlain = toPlainObject(ast); 41 | const astAgain = fromPlainObject(astPlain); 42 | 43 | // Print AST to console 44 | console.log(inspect(astPlain, { colors: true, depth: null })); 45 | 46 | // You can also generate string from AST (don't use plain object here) 47 | console.log(generate(astAgain)); 48 | } catch (error: unknown) { 49 | // If the code reaches this point, it means that an error was 50 | // thrown, so the selector is invalid. 51 | if (error instanceof Error) { 52 | // Also print the error message that was generated by 53 | // the CSSTree parser 54 | console.log( 55 | `Invalid selector: ${input} (Error message: ${error.message})`, 56 | ); 57 | } else { 58 | console.log(`Invalid selector: ${input}`); 59 | } 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@adguard/ecss-tree", 3 | "version": "2.0.1", 4 | "description": "Adblock Extended CSS fork for CSSTree", 5 | "author": "AdGuard Software Ltd. ", 6 | "license": "MIT", 7 | "type": "module", 8 | "keywords": [ 9 | "css", 10 | "ecss", 11 | "extendedcss", 12 | "ast", 13 | "tokenizer", 14 | "parser", 15 | "walker", 16 | "lexer", 17 | "generator", 18 | "utils", 19 | "syntax", 20 | "validation", 21 | "adblock", 22 | "ublock", 23 | "adguard" 24 | ], 25 | "repository": { 26 | "type": "git", 27 | "url": "git+https://github.com/AdguardTeam/ecsstree.git" 28 | }, 29 | "bugs": { 30 | "url": "https://github.com/AdguardTeam/ecsstree/issues" 31 | }, 32 | "homepage": "https://github.com/AdguardTeam/ecsstree#readme", 33 | "main": "dist/ecsstree.cjs", 34 | "module": "dist/ecsstree.js", 35 | "types": "dist/ecsstree.d.ts", 36 | "exports": { 37 | ".": { 38 | "types": "./dist/ecsstree.d.ts", 39 | "import": "./dist/ecsstree.js", 40 | "require": "./dist/ecsstree.cjs" 41 | } 42 | }, 43 | "files": [ 44 | "dist" 45 | ], 46 | "dependencies": { 47 | "@adguard/css-tokenizer": "^1.2.0", 48 | "@eslint/css-tree": "3.6.6" 49 | }, 50 | "devDependencies": { 51 | "@rollup/plugin-alias": "^5.1.1", 52 | "@rollup/plugin-json": "^6.1.0", 53 | "@rollup/plugin-node-resolve": "^16.0.3", 54 | "@rollup/plugin-swc": "^0.4.0", 55 | "@rollup/plugin-terser": "^0.4.4", 56 | "@swc/core": "^1.13.5", 57 | "@types/node": "^22.14.0", 58 | "@vitest/coverage-v8": "^3.2.4", 59 | "eslint": "^8.57.1", 60 | "eslint-config-airbnb-base": "^15.0.0", 61 | "eslint-plugin-import": "^2.32.0", 62 | "eslint-plugin-import-newlines": "^1.4.0", 63 | "eslint-plugin-jsdoc": "^61.1.5", 64 | "eslint-plugin-n": "^17.23.1", 65 | "husky": "^9.1.7", 66 | "jsdoc": "^4.0.5", 67 | "markdownlint": "^0.35.0", 68 | "markdownlint-cli": "^0.41.0", 69 | "rimraf": "^5.0.10", 70 | "rollup": "^4.52.5", 71 | "rollup-plugin-dts": "^6.2.3", 72 | "typescript": "^5.6.2", 73 | "vitest": "^3.2.4" 74 | }, 75 | "scripts": { 76 | "increment": "pnpm version patch --no-git-tag-version", 77 | "prepare": "node .husky/install.js", 78 | "lint": "eslint . --cache && markdownlint .", 79 | "test": "vitest", 80 | "build": "rimraf dist && rollup --config rollup.config.js && node tasks/build-txt.js", 81 | "test:smoke": "(cd test/smoke/esm && pnpm test) && (cd test/smoke/cjs && pnpm test) && (cd test/smoke/typescript && pnpm test)" 82 | }, 83 | "pnpm": { 84 | "neverBuiltDependencies": [] 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import json from '@rollup/plugin-json'; 2 | import resolve from '@rollup/plugin-node-resolve'; 3 | import swc from '@rollup/plugin-swc'; 4 | import dtsPlugin from 'rollup-plugin-dts'; 5 | 6 | const node = { 7 | input: './src/index.js', 8 | external: [ 9 | '@eslint/css-tree', 10 | '@adguard/css-tokenizer', 11 | ], 12 | output: [ 13 | { 14 | file: './dist/ecsstree.cjs', 15 | format: 'cjs', 16 | exports: 'auto', 17 | sourcemap: false, 18 | }, 19 | { 20 | file: './dist/ecsstree.js', 21 | format: 'esm', 22 | sourcemap: false, 23 | }, 24 | ], 25 | plugins: [ 26 | json(), 27 | swc(), 28 | resolve({ preferBuiltins: false }), 29 | ], 30 | }; 31 | 32 | // Automatically export type definitions from @types/css-tree 33 | const dts = { 34 | input: './ecsstree.d.ts', 35 | output: [ 36 | { 37 | file: 'dist/ecsstree.d.ts', 38 | format: 'es', 39 | // Add a banner to the top of the file 40 | // Note: don't add it directly to the file, because it will be moved 41 | // to the end of the file by Rollup, since it handles the imported 42 | // module first 43 | banner: [ 44 | '/*', 45 | ' * Automatically exported CSSTree type definitions. Since ECSSTree uses', 46 | ' * the exact same API as CSSTree, we can use the same type definitions.', 47 | ' *', 48 | " * However, we can't use the @types/css-tree directly, because of a naming", 49 | " * conflict with the actual CSSTree package. Our package is called '@adguard/ecss-tree',", 50 | " * but the type definitions are written for 'css-tree'. Therefore, we need to", 51 | ' * export type definitions from @types/css-tree to this file at build time.', 52 | ' *', 53 | ' * Sources:', 54 | ' * - https://www.npmjs.com/package/@types/css-tree', 55 | ' * - https://www.npmjs.com/package/@eslint/css-tree', 56 | ' */', 57 | ' ', 58 | ].join('\n'), 59 | }, 60 | ], 61 | plugins: [ 62 | dtsPlugin({ 63 | // By default, this plugin excludes all external dependencies from the 64 | // type definitions. We want to include them, so we set this option to 65 | // true 66 | respectExternal: true, 67 | }), 68 | ], 69 | }; 70 | 71 | // Export build configs for Rollup 72 | export default [ 73 | node, 74 | dts, 75 | ]; 76 | -------------------------------------------------------------------------------- /bamboo-specs/build.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | version: 2 3 | plan: 4 | project-key: AJL 5 | key: ECSSTREEBUILD 6 | name: ECSSTree - build release 7 | variables: 8 | dockerNode: adguard/node-ssh:22.14--0 9 | 10 | stages: 11 | - Build: 12 | manual: false 13 | final: false 14 | jobs: 15 | - Build 16 | 17 | Build: 18 | key: BUILD 19 | other: 20 | clean-working-dir: true 21 | docker: 22 | image: ${bamboo.dockerNode} 23 | volumes: 24 | ${system.PNPM_DIR}: "${bamboo.cachePnpm}" 25 | tasks: 26 | - checkout: 27 | force-clean-build: true 28 | - script: 29 | interpreter: SHELL 30 | scripts: 31 | - |- 32 | set -e 33 | set -x 34 | 35 | # Fix mixed logs 36 | exec 2>&1 37 | 38 | ls -laht 39 | 40 | # Set cache directory 41 | pnpm config set store-dir ${bamboo.cachePnpm} 42 | 43 | # Install dependencies 44 | pnpm install 45 | 46 | # Lint code 47 | pnpm lint 48 | 49 | # Run tests 50 | pnpm test 51 | 52 | # Create build, this will also create build.txt file with build number 53 | pnpm build 54 | 55 | # Run smoke tests 56 | pnpm test:smoke 57 | 58 | # Pack build into tarball 59 | pnpm pack --out ecsstree.tgz 60 | 61 | ls -laht 62 | - inject-variables: 63 | file: build.txt 64 | scope: RESULT 65 | namespace: inject 66 | - any-task: 67 | plugin-key: com.atlassian.bamboo.plugins.vcs:task.vcs.tagging 68 | configuration: 69 | selectedRepository: defaultRepository 70 | tagName: v${bamboo.inject.version} 71 | final-tasks: 72 | - script: 73 | interpreter: SHELL 74 | scripts: 75 | - |- 76 | set -x 77 | set -e 78 | 79 | # Fix mixed logs 80 | exec 2>&1 81 | 82 | ls -la 83 | 84 | echo "Size before cleanup:" && du -h | tail -n 1 85 | rm -rf node_modules dist 86 | echo "Size after cleanup:" && du -h | tail -n 1 87 | artifacts: 88 | - name: ecsstree.tgz 89 | pattern: ecsstree.tgz 90 | shared: true 91 | required: true 92 | requirements: 93 | - adg-docker: 'true' 94 | - extension: 'true' 95 | 96 | triggers: [] 97 | 98 | branches: 99 | create: manually 100 | delete: never 101 | link-to-jira: true 102 | 103 | notifications: 104 | - events: 105 | - plan-status-changed 106 | recipients: 107 | - webhook: 108 | name: Build webhook 109 | url: http://prod.jirahub.service.eu.consul/v1/webhook/bamboo 110 | 111 | labels: [] 112 | 113 | other: 114 | concurrent-build-plugin: system-default 115 | -------------------------------------------------------------------------------- /test/syntax/nth-ancestor.test.js: -------------------------------------------------------------------------------- 1 | import { describe, expect, test } from 'vitest'; 2 | 3 | import { generate, parse, toPlainObject } from '../../src/index'; 4 | 5 | const parserConfig = { 6 | context: 'selector', 7 | positions: true, 8 | }; 9 | 10 | describe(':nth-ancestor()', () => { 11 | test('throws on invalid input', () => { 12 | expect(() => parse(':nth-ancestor($$)', parserConfig)).toThrow(); 13 | expect(() => parse(':nth-ancestor(.)', parserConfig)).toThrow(); 14 | 15 | // Selector 16 | expect(() => parse(':nth-ancestor(div)', parserConfig)).toThrow(); 17 | expect(() => parse(':nth-ancestor(div + section[class^="something"])', parserConfig)).toThrow(); 18 | }); 19 | 20 | test('parses valid input properly', () => { 21 | // Number 22 | expect(toPlainObject(parse('div:nth-ancestor(42)', parserConfig))).toStrictEqual({ 23 | type: 'Selector', 24 | loc: { 25 | source: '', 26 | start: { 27 | offset: 0, 28 | line: 1, 29 | column: 1, 30 | }, 31 | end: { 32 | offset: 20, 33 | line: 1, 34 | column: 21, 35 | }, 36 | }, 37 | children: [ 38 | { 39 | type: 'TypeSelector', 40 | loc: { 41 | source: '', 42 | start: { 43 | offset: 0, 44 | line: 1, 45 | column: 1, 46 | }, 47 | end: { 48 | offset: 3, 49 | line: 1, 50 | column: 4, 51 | }, 52 | }, 53 | name: 'div', 54 | }, 55 | { 56 | type: 'PseudoClassSelector', 57 | loc: { 58 | source: '', 59 | start: { 60 | offset: 3, 61 | line: 1, 62 | column: 4, 63 | }, 64 | end: { 65 | offset: 20, 66 | line: 1, 67 | column: 21, 68 | }, 69 | }, 70 | name: 'nth-ancestor', 71 | children: [ 72 | { 73 | type: 'Number', 74 | loc: { 75 | source: '', 76 | start: { 77 | offset: 17, 78 | line: 1, 79 | column: 18, 80 | }, 81 | end: { 82 | offset: 19, 83 | line: 1, 84 | column: 20, 85 | }, 86 | }, 87 | value: '42', 88 | }, 89 | ], 90 | }, 91 | ], 92 | }); 93 | }); 94 | 95 | test('generates valid input properly', () => { 96 | expect(generate(parse('div:nth-ancestor(42)', parserConfig))).toEqual('div:nth-ancestor(42)'); 97 | }); 98 | }); 99 | -------------------------------------------------------------------------------- /test/syntax/min-text-length.test.js: -------------------------------------------------------------------------------- 1 | import { describe, expect, test } from 'vitest'; 2 | 3 | import { generate, parse, toPlainObject } from '../../src/index'; 4 | 5 | const parserConfig = { 6 | context: 'selector', 7 | positions: true, 8 | }; 9 | 10 | describe(':min-text-length()', () => { 11 | test('throws on invalid input', () => { 12 | expect(() => parse(':min-text-length($$)', parserConfig)).toThrow(); 13 | expect(() => parse(':min-text-length(.)', parserConfig)).toThrow(); 14 | 15 | // Selector 16 | expect(() => parse(':min-text-length(div)', parserConfig)).toThrow(); 17 | expect(() => parse(':min-text-length(div + section[class^="something"])', parserConfig)).toThrow(); 18 | }); 19 | 20 | test('parses valid input properly', () => { 21 | // Number 22 | expect(toPlainObject(parse('div:min-text-length(42)', parserConfig))).toStrictEqual({ 23 | type: 'Selector', 24 | loc: { 25 | source: '', 26 | start: { 27 | offset: 0, 28 | line: 1, 29 | column: 1, 30 | }, 31 | end: { 32 | offset: 23, 33 | line: 1, 34 | column: 24, 35 | }, 36 | }, 37 | children: [ 38 | { 39 | type: 'TypeSelector', 40 | loc: { 41 | source: '', 42 | start: { 43 | offset: 0, 44 | line: 1, 45 | column: 1, 46 | }, 47 | end: { 48 | offset: 3, 49 | line: 1, 50 | column: 4, 51 | }, 52 | }, 53 | name: 'div', 54 | }, 55 | { 56 | type: 'PseudoClassSelector', 57 | loc: { 58 | source: '', 59 | start: { 60 | offset: 3, 61 | line: 1, 62 | column: 4, 63 | }, 64 | end: { 65 | offset: 23, 66 | line: 1, 67 | column: 24, 68 | }, 69 | }, 70 | name: 'min-text-length', 71 | children: [ 72 | { 73 | type: 'Number', 74 | loc: { 75 | source: '', 76 | start: { 77 | offset: 20, 78 | line: 1, 79 | column: 21, 80 | }, 81 | end: { 82 | offset: 22, 83 | line: 1, 84 | column: 23, 85 | }, 86 | }, 87 | value: '42', 88 | }, 89 | ], 90 | }, 91 | ], 92 | }); 93 | }); 94 | 95 | test('generates valid input properly', () => { 96 | expect(generate(parse('div:min-text-length(42)', parserConfig))).toEqual('div:min-text-length(42)'); 97 | }); 98 | }); 99 | -------------------------------------------------------------------------------- /examples/src/validate-regexp.ts: -------------------------------------------------------------------------------- 1 | // You can run this script with `yarn ts-node-esm validate-regexp.ts` 2 | // (you can run TypeScript scripts directly with ts-node module) 3 | 4 | import { parse, walk, CssNode } from '@adguard/ecss-tree'; 5 | // https://www.npmjs.com/package/regexpp 6 | import { RegExpValidator } from 'regexpp'; 7 | 8 | // Some inputs to test, feel free to add more 9 | const inputs = [ 10 | // Not RegExps 11 | ':contains(aaa)', 12 | ':contains(aaa bbb)', 13 | 14 | // Invalid flag 15 | ':contains(/^aaa$/igx)', 16 | 17 | // RegExps 18 | ':contains(/aaa/)', 19 | ':contains(/^aaa$/)', 20 | ':contains(/^aaa$/ig)', 21 | ]; 22 | 23 | // Create RegExpValidator instance 24 | // See https://github.com/mysticatea/regexpp#validateregexpliteralsource-options 25 | const validator = new RegExpValidator(); 26 | 27 | // Iterate over inputs 28 | // eslint-disable-next-line no-restricted-syntax 29 | for (const input of inputs) { 30 | // Parse raw CSS selector to AST 31 | const ast = parse(input, { context: 'selector' }); 32 | 33 | // Check if the parsed AST is a Selector 34 | if (ast.type !== 'Selector') { 35 | throw new Error( 36 | `Expected "Selector", got "${ast.type}" instead in "${input}"`, 37 | ); 38 | } 39 | 40 | // Walk the parsed Selector AST 41 | walk(ast, (node: CssNode) => { 42 | // If the current node is a ":contains()" pseudo-class 43 | // See https://github.com/csstree/csstree/blob/master/docs/ast.md#pseudoclassselector 44 | if (node.type === 'PseudoClassSelector' && node.name === 'contains') { 45 | // Get the argument of the pseudo-class 46 | if (!node.children || node.children.size !== 1) { 47 | throw new Error( 48 | `Expected 1 child, got ${ 49 | node.children?.size || '0' 50 | } instead in "${input}"`, 51 | ); 52 | } 53 | 54 | // Get the first and only child of the pseudo-class, which is the argument's node 55 | const child = node.children.first; 56 | 57 | // Check if the argument is a Raw node, since :contains() will have a Raw node as its argument 58 | // See https://github.com/csstree/csstree/blob/master/docs/ast.md#raw 59 | if (!child || child.type !== 'Raw') { 60 | throw new Error( 61 | `Expected "Raw", got "${child?.type || 'none'}" instead in "${input}"`, 62 | ); 63 | } 64 | 65 | // Get the argument value as a string. For example, it the input is 66 | // ":contains(/^aaa$/ig)", then this value will be "/^aaa$/ig" 67 | // (without the quotes) 68 | const arg = child.value; 69 | 70 | // Try to validate the argument as a regexp 71 | try { 72 | // It will try to validate the argument as a regexp literal. 73 | // If the argument is not a regexp literal, then it will throw 74 | // an error. 75 | validator.validateLiteral(arg); 76 | 77 | // If the code reaches this point, then the argument is a regexp 78 | console.log(`Valid regexp: ${arg}`); 79 | } catch (error: unknown) { 80 | // If the code reaches this point, it means that an error was 81 | // thrown, so the argument is not a valid regexp 82 | if (error instanceof Error) { 83 | // Also print the error message that was generated by 84 | // the regexpp library 85 | console.log(`Invalid regexp: ${arg} (${error.message})`); 86 | } else { 87 | console.log(`Invalid regexp: ${arg}`); 88 | } 89 | } 90 | } 91 | }); 92 | } 93 | -------------------------------------------------------------------------------- /examples/src/validate-xpath.ts: -------------------------------------------------------------------------------- 1 | // You can run this script with `yarn ts-node-esm validate-xpath.ts` 2 | // (you can run TypeScript scripts directly with ts-node module) 3 | 4 | import { parse, walk, CssNode } from '@adguard/ecss-tree'; 5 | // https://www.npmjs.com/package/xpath 6 | import xpath from 'xpath'; 7 | 8 | // Some inputs to test, feel free to add more 9 | const inputs = [ 10 | // Some examples from https://www.w3schools.com/xml/xpath_syntax.asp 11 | ':xpath(/bookstore/book[1])', 12 | ':xpath(/bookstore/book[last()])', 13 | ':xpath(//title[@lang=\'en\'])', 14 | 15 | // Invalid :xpath() pseudo-class 16 | ':xpath(aaa\'bbb)', 17 | ':xpath($#...)', 18 | ':xpath(...)', 19 | ]; 20 | 21 | // Iterate over inputs 22 | // eslint-disable-next-line no-restricted-syntax 23 | for (const input of inputs) { 24 | // Parse raw CSS selector to AST 25 | const ast = parse(input, { context: 'selector' }); 26 | 27 | // Check if the parsed AST is a Selector 28 | if (ast.type !== 'Selector') { 29 | throw new Error( 30 | `Expected "Selector", got "${ast.type}" instead in "${input}"`, 31 | ); 32 | } 33 | 34 | // Walk the parsed Selector AST 35 | walk(ast, (node: CssNode) => { 36 | // If the current node is a ":xpath()" pseudo-class 37 | // See https://github.com/csstree/csstree/blob/master/docs/ast.md#pseudoclassselector 38 | if (node.type === 'PseudoClassSelector' && node.name === 'xpath') { 39 | // Get the argument of the pseudo-class 40 | if (!node.children || node.children.size !== 1) { 41 | throw new Error( 42 | `Expected 1 child, got ${ 43 | node.children?.size || '0' 44 | } instead in "${input}"`, 45 | ); 46 | } 47 | 48 | // Get the first and only child of the pseudo-class, which is the argument's node 49 | const child = node.children.first; 50 | 51 | // Check if the argument is a Raw node, since :xpath() will have a Raw node as its argument 52 | // See https://github.com/csstree/csstree/blob/master/docs/ast.md#raw 53 | if (!child || child.type !== 'Raw') { 54 | throw new Error( 55 | `Expected "Raw", got "${child?.type || 'none'}" instead in "${input}"`, 56 | ); 57 | } 58 | 59 | // Get the argument value as a string. For example, it the input is 60 | // ":xpath(/bookstore/book[1])", then the argument value will be 61 | // "/bookstore/book[1]" (without the quotes) 62 | const arg = child.value; 63 | 64 | // Try to validate the argument as an XPath expression 65 | try { 66 | // It will try to validate the argument as an XPath expression. 67 | // If the argument is not a valid XPath expression, then an 68 | // error will be thrown. 69 | // xpath type definitions aren't correct, because they don't have 70 | // the "parse" function, so we need to cast it to "any" to be able 71 | // to use it. See: 72 | // https://github.com/goto100/xpath/blob/master/docs/parsed%20expressions.md 73 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 74 | (xpath as any).parse(arg); 75 | 76 | // If the code reaches this point, then the argument is an XPath 77 | // expression 78 | console.log(`Valid XPath expression: ${arg}`); 79 | } catch (error: unknown) { 80 | // If the code reaches this point, it means that an error was 81 | // thrown, so the argument is not a valid XPath expression 82 | if (error instanceof Error) { 83 | // Also print the error message that was generated by 84 | // the xpath library 85 | console.log(`Invalid XPath expression: ${arg} (${error.message})`); 86 | } else { 87 | console.log(`Invalid XPath expression: ${arg}`); 88 | } 89 | } 90 | } 91 | }); 92 | } 93 | -------------------------------------------------------------------------------- /test/syntax/xpath.test.js: -------------------------------------------------------------------------------- 1 | import { describe, expect, test } from 'vitest'; 2 | 3 | import { generate, parse, toPlainObject } from '../../src/index'; 4 | 5 | const parserConfig = { 6 | context: 'selector', 7 | positions: true, 8 | }; 9 | 10 | describe(':xpath()', () => { 11 | test('parses valid input properly', () => { 12 | // Very simple test, just to make sure it's working 13 | expect(toPlainObject(parse(':xpath(//test)', parserConfig))).toStrictEqual({ 14 | type: 'Selector', 15 | loc: { 16 | source: '', 17 | start: { 18 | offset: 0, 19 | line: 1, 20 | column: 1, 21 | }, 22 | end: { 23 | offset: 14, 24 | line: 1, 25 | column: 15, 26 | }, 27 | }, 28 | children: [ 29 | { 30 | type: 'PseudoClassSelector', 31 | loc: { 32 | source: '', 33 | start: { 34 | offset: 0, 35 | line: 1, 36 | column: 1, 37 | }, 38 | end: { 39 | offset: 14, 40 | line: 1, 41 | column: 15, 42 | }, 43 | }, 44 | name: 'xpath', 45 | children: [ 46 | { 47 | type: 'Raw', 48 | loc: { 49 | source: '', 50 | start: { 51 | offset: 7, 52 | line: 1, 53 | column: 8, 54 | }, 55 | end: { 56 | offset: 13, 57 | line: 1, 58 | column: 14, 59 | }, 60 | }, 61 | value: '//test', 62 | }, 63 | ], 64 | }, 65 | ], 66 | }); 67 | 68 | // Test with a more complex expression, which contains a lot of special cases 69 | expect(toPlainObject(parse(':xpath(//*[contains(text(),"()(cc")])', parserConfig))).toStrictEqual({ 70 | type: 'Selector', 71 | loc: { 72 | source: '', 73 | start: { 74 | offset: 0, 75 | line: 1, 76 | column: 1, 77 | }, 78 | end: { 79 | offset: 37, 80 | line: 1, 81 | column: 38, 82 | }, 83 | }, 84 | children: [ 85 | { 86 | type: 'PseudoClassSelector', 87 | loc: { 88 | source: '', 89 | start: { 90 | offset: 0, 91 | line: 1, 92 | column: 1, 93 | }, 94 | end: { 95 | offset: 37, 96 | line: 1, 97 | column: 38, 98 | }, 99 | }, 100 | name: 'xpath', 101 | children: [ 102 | { 103 | type: 'Raw', 104 | loc: { 105 | source: '', 106 | start: { 107 | offset: 7, 108 | line: 1, 109 | column: 8, 110 | }, 111 | end: { 112 | offset: 36, 113 | line: 1, 114 | column: 37, 115 | }, 116 | }, 117 | value: '//*[contains(text(),"()(cc")]', 118 | }, 119 | ], 120 | }, 121 | ], 122 | }); 123 | }); 124 | 125 | test('generates valid input properly', () => { 126 | expect(generate(parse(':xpath(//test)', parserConfig))).toEqual(':xpath(//test)'); 127 | expect(generate(parse(':xpath(//*[contains(text(),"()(cc")])', parserConfig))).toEqual( 128 | ':xpath(//*[contains(text(),"()(cc")])', 129 | ); 130 | }); 131 | }); 132 | -------------------------------------------------------------------------------- /test/syntax/if-not.test.js: -------------------------------------------------------------------------------- 1 | import { describe, expect, test } from 'vitest'; 2 | 3 | import { generate, parse, toPlainObject } from '../../src/index'; 4 | 5 | const parserConfig = { 6 | context: 'selector', 7 | positions: true, 8 | }; 9 | 10 | describe(':if-not()', () => { 11 | test('throws on invalid input', () => { 12 | expect(() => parse(':if-not($$)', parserConfig)).toThrow(); 13 | expect(() => parse(':if-not(.)', parserConfig)).toThrow(); 14 | }); 15 | 16 | test('parses valid input properly', () => { 17 | expect(toPlainObject(parse('div:if-not(.something + #another)', parserConfig))).toStrictEqual({ 18 | type: 'Selector', 19 | loc: { 20 | source: '', 21 | start: { 22 | offset: 0, 23 | line: 1, 24 | column: 1, 25 | }, 26 | end: { 27 | offset: 33, 28 | line: 1, 29 | column: 34, 30 | }, 31 | }, 32 | children: [ 33 | { 34 | type: 'TypeSelector', 35 | loc: { 36 | source: '', 37 | start: { 38 | offset: 0, 39 | line: 1, 40 | column: 1, 41 | }, 42 | end: { 43 | offset: 3, 44 | line: 1, 45 | column: 4, 46 | }, 47 | }, 48 | name: 'div', 49 | }, 50 | { 51 | type: 'PseudoClassSelector', 52 | loc: { 53 | source: '', 54 | start: { 55 | offset: 3, 56 | line: 1, 57 | column: 4, 58 | }, 59 | end: { 60 | offset: 33, 61 | line: 1, 62 | column: 34, 63 | }, 64 | }, 65 | name: 'if-not', 66 | children: [ 67 | { 68 | type: 'Selector', 69 | loc: { 70 | source: '', 71 | start: { 72 | offset: 11, 73 | line: 1, 74 | column: 12, 75 | }, 76 | end: { 77 | offset: 32, 78 | line: 1, 79 | column: 33, 80 | }, 81 | }, 82 | children: [ 83 | { 84 | type: 'ClassSelector', 85 | loc: { 86 | source: '', 87 | start: { 88 | offset: 11, 89 | line: 1, 90 | column: 12, 91 | }, 92 | end: { 93 | offset: 21, 94 | line: 1, 95 | column: 22, 96 | }, 97 | }, 98 | name: 'something', 99 | }, 100 | { 101 | type: 'Combinator', 102 | loc: { 103 | source: '', 104 | start: { 105 | offset: 22, 106 | line: 1, 107 | column: 23, 108 | }, 109 | end: { 110 | offset: 23, 111 | line: 1, 112 | column: 24, 113 | }, 114 | }, 115 | name: '+', 116 | }, 117 | { 118 | type: 'IdSelector', 119 | loc: { 120 | source: '', 121 | start: { 122 | offset: 24, 123 | line: 1, 124 | column: 25, 125 | }, 126 | end: { 127 | offset: 32, 128 | line: 1, 129 | column: 33, 130 | }, 131 | }, 132 | name: 'another', 133 | }, 134 | ], 135 | }, 136 | ], 137 | }, 138 | ], 139 | }); 140 | }); 141 | 142 | test('generates valid input properly', () => { 143 | expect(generate(parse('div:if-not(.something + #another)', parserConfig))).toEqual( 144 | 'div:if-not(.something+#another)', 145 | ); 146 | }); 147 | }); 148 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | 2 | # ECSSTree Changelog 3 | 4 | All notable changes to this project will be documented in this file. 5 | 6 | The format is based on [Keep a Changelog][keepachangelog], and this project adheres to [Semantic Versioning][semver]. 7 | 8 | [keepachangelog]: https://keepachangelog.com/en/1.0.0/ 9 | [semver]: https://semver.org/spec/v2.0.0.html 10 | 11 | ## [2.0.1] - 2025-11-21 12 | 13 | ### Fixed 14 | 15 | - Parsing of `:upward()` pseudo-class with selector list argument. 16 | 17 | [2.0.1]: https://github.com/AdguardTeam/ecsstree/compare/v2.0.0...v2.0.1 18 | 19 | ## [2.0.0] - 2025-10-21 20 | 21 | ### Changed 22 | 23 | - `css-tree` to `@eslint/css-tree` (a `css-tree` fork made by ESLint Team) [#16]. 24 | This also updates CSS Tree from `v2` to `v3`, which introduces breaking changes. 25 | For more details see their changelog. 26 | 27 | ### Added 28 | 29 | - `@adguard/css-tokenizer` as tokenizer dependency [#15]. 30 | 31 | [#15]: https://github.com/AdguardTeam/ecsstree/issues/15 32 | [#16]: https://github.com/AdguardTeam/ecsstree/issues/16 33 | [2.0.0]: https://github.com/AdguardTeam/ecsstree/compare/v1.1.0...v2.0.0 34 | 35 | ## [1.1.0] - 2024-09-10 36 | 37 | ### Fixed 38 | 39 | - Export for Lexer-related tools: `keyword`, `property`, `vendorPrefix`, `isCustomProperty`. 40 | These functions are also exported by `css-tree` package. 41 | - Custom types. 42 | 43 | ### Removed 44 | 45 | - Browser specific builds. 46 | 47 | [1.1.0]: https://github.com/AdguardTeam/ecsstree/compare/v1.0.8...v1.1.0 48 | 49 | ## [1.0.8] - 2022-02-27 50 | 51 | ### Added 52 | 53 | - Support for `:matches-css-after(raw)` pseudo class [[uBO reference]][matches-css-after-ubo]. 54 | - Support for `:matches-css-before(raw)` pseudo class [[uBO reference]][matches-css-before-ubo]. 55 | - Support for `:matches-css(raw)` pseudo class [[ADG reference]][matches-css-ubo]. 56 | 57 | ### Fixed 58 | 59 | - README typos. 60 | 61 | [1.0.8]: https://github.com/AdguardTeam/ecsstree/compare/v1.0.7...v1.0.8 62 | [matches-css-after-ubo]: https://github.com/gorhill/uBlock/wiki/Procedural-cosmetic-filters#subjectmatches-css-afterarg 63 | [matches-css-before-ubo]: https://github.com/gorhill/uBlock/wiki/Procedural-cosmetic-filters#subjectmatches-css-beforearg 64 | [matches-css-ubo]: https://github.com/gorhill/uBlock/wiki/Procedural-cosmetic-filters#subjectmatches-cssarg 65 | 66 | ## [1.0.7] - 2022-02-22 67 | 68 | ### Fixed 69 | 70 | - False positive parsing errors when using `:upward` pseudo class: [#8]. 71 | 72 | [#8]: https://github.com/AdguardTeam/ecsstree/pull/8 73 | [1.0.7]: https://github.com/AdguardTeam/ecsstree/compare/v1.0.6...v1.0.7 74 | 75 | ## [1.0.6] - 2022-02-21 76 | 77 | ### Added 78 | 79 | - Import types from `@types/css-tree`. 80 | - Small example project in TypeScript. 81 | - Integrate ESLint, some code style improvements. 82 | 83 | ### Fixed 84 | 85 | - Remove Node warnings when running tests. 86 | 87 | ### Changed 88 | 89 | - Exclude some unnecessary files from NPM release. 90 | - Move package under `AdguardTeam` organization. 91 | 92 | [1.0.6]: https://github.com/AdguardTeam/ecsstree/compare/v1.0.5...v1.0.6 93 | 94 | ## [1.0.4] - 2022-02-19 95 | 96 | ### Changed 97 | 98 | - Browser builds now ends with `.min.js`. 99 | - README improvements. 100 | 101 | [1.0.4]: https://github.com/AdguardTeam/ecsstree/compare/v1.0.3...v1.0.4 102 | 103 | ## [1.0.3] - 2022-02-19 104 | 105 | ### Fixed 106 | 107 | - Minor optimizations, README improvements. 108 | 109 | [1.0.3]: https://github.com/AdguardTeam/ecsstree/compare/v1.0.2...v1.0.3 110 | 111 | ## [1.0.2] - 2022-02-18 112 | 113 | ### Fixed 114 | 115 | - Change `:-abp-has` to selector list instead of selector. 116 | 117 | [1.0.2]: https://github.com/AdguardTeam/ecsstree/compare/v1.0.1...v1.0.2 118 | 119 | ## [1.0.1] - 2022-02-18 120 | 121 | ### Fixed 122 | 123 | - Improved `:contains` (and `:-abp-contains` & `:has-text`) pseudo class parsing, 124 | handle parenthesis / function calls in the parameter. 125 | 126 | [1.0.1]: https://github.com/AdguardTeam/ecsstree/compare/v1.0.0...v1.0.1 127 | 128 | ## [1.0.0] - 2022-02-18 129 | 130 | ### Added 131 | 132 | - Initial version of the library. 133 | - Support for `:-abp-contains(text / regexp)` pseudo class [[ABP reference]][abp-extcss]. 134 | - Support for `:-abp-has(selector)` pseudo class [[ABP reference]][abp-extcss]. 135 | - Support for `:contains(text / regexp)` pseudo class [[ADG reference]][contains-adg]. 136 | - Support for `:has-text(text / regexp)` pseudo class [[uBO reference]][has-text-ubo]. 137 | - Support for `:if-not(selector)` pseudo class [[ADG reference]][if-not-adg]. 138 | - Support for `:matches-media(media query list)` pseudo class [[uBO reference]][matches-media-ubo]. 139 | - Support for `:min-text-length(number)` pseudo class [[uBO reference]][min-text-length-ubo]. 140 | - Support for `:nth-ancestor(number)` pseudo class [[ADG reference]][nth-ancestor-adg]. 141 | - Support for `:style(style declaration list)` pseudo class [[uBO reference]][style-ubo]. 142 | - Support for `:upward(selector / number)` pseudo class [[ADG reference]][upward-adg], [[uBO reference]][upward-ubo]. 143 | - Support for `:xpath(xpath expression)` pseudo class [[ADG reference]][xpath-adg], [[uBO reference]][xpath-ubo]. 144 | 145 | [1.0.0]: https://github.com/AdguardTeam/ecsstree/releases/tag/v1.0.0 146 | [abp-extcss]: https://help.adblockplus.org/hc/en-us/articles/360062733293#elemhide_css 147 | [contains-adg]: https://github.com/AdguardTeam/ExtendedCss#extended-css-contains 148 | [has-text-ubo]: https://github.com/gorhill/uBlock/wiki/Procedural-cosmetic-filters#subjecthas-textneedle 149 | [if-not-adg]: https://github.com/AdguardTeam/ExtendedCss#extended-css-if-not 150 | [matches-media-ubo]: https://github.com/gorhill/uBlock/wiki/Procedural-cosmetic-filters#subjectmatches-mediaarg 151 | [min-text-length-ubo]: https://github.com/gorhill/uBlock/wiki/Procedural-cosmetic-filters#subjectmin-text-lengthn 152 | [nth-ancestor-adg]: https://github.com/AdguardTeam/ExtendedCss#extended-css-nth-ancestor 153 | [style-ubo]: https://github.com/gorhill/uBlock/wiki/Static-filter-syntax#subjectstylearg 154 | [upward-adg]: https://github.com/AdguardTeam/ExtendedCss#extended-css-upward 155 | [upward-ubo]: https://github.com/gorhill/uBlock/wiki/Procedural-cosmetic-filters#subjectupwardarg 156 | [xpath-adg]: https://github.com/AdguardTeam/ExtendedCss#-pseudo-class-xpath 157 | [xpath-ubo]: https://github.com/gorhill/uBlock/wiki/Procedural-cosmetic-filters#subjectxpatharg 158 | -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | const MAX_LINE_LENGTH = 120; 2 | 3 | /** 4 | * ESLint rules. 5 | * 6 | * @see {@link https://eslint.org/docs/v8.x/rules/} 7 | */ 8 | const ESLINT_RULES = { 9 | indent: 'off', 10 | 'no-bitwise': 'off', 11 | 'no-new': 'off', 12 | 'no-continue': 'off', 13 | 'arrow-body-style': 'off', 14 | 15 | 'no-restricted-syntax': ['error', 'LabeledStatement', 'WithStatement'], 16 | 'no-constant-condition': ['error', { checkLoops: false }], 17 | 'max-len': [ 18 | 'error', 19 | { 20 | code: MAX_LINE_LENGTH, 21 | comments: MAX_LINE_LENGTH, 22 | tabWidth: 4, 23 | ignoreUrls: true, 24 | ignoreTrailingComments: false, 25 | ignoreComments: false, 26 | /** 27 | * Ignore calls to logger, e.g. logger.error(), because of the long string. 28 | */ 29 | ignorePattern: 'logger\\.', 30 | }, 31 | ], 32 | // Sort members of import statements, e.g. `import { B, A } from 'module';` -> `import { A, B } from 'module';` 33 | // Note: imports themself are sorted by import/order rule 34 | 'sort-imports': ['error', { 35 | ignoreCase: true, 36 | // Avoid conflict with import/order rule 37 | ignoreDeclarationSort: true, 38 | ignoreMemberSort: false, 39 | memberSyntaxSortOrder: ['none', 'all', 'multiple', 'single'], 40 | }], 41 | }; 42 | 43 | /** 44 | * Import plugin rules. 45 | * 46 | * @see {@link https://github.com/import-js/eslint-plugin-import/tree/main/docs/rules} 47 | */ 48 | const IMPORT_PLUGIN_RULES = { 49 | 'import/prefer-default-export': 'off', 50 | 51 | 'import-newlines/enforce': ['error', 3, MAX_LINE_LENGTH], 52 | 'import/no-extraneous-dependencies': ['error', { devDependencies: true }], 53 | // Split external and internal imports with an empty line 54 | 'import/order': [ 55 | 'error', 56 | { 57 | groups: [ 58 | // Built-in Node.js modules 59 | 'builtin', 60 | // External packages 61 | 'external', 62 | // Parent modules, e.g. `import { foo } from '../bar';` 63 | 'parent', 64 | // Sibling modules, e.g. `import { foo } from './bar';` 65 | 'sibling', 66 | // All other imports 67 | ], 68 | alphabetize: { order: 'asc', caseInsensitive: true }, 69 | 'newlines-between': 'always', 70 | }, 71 | ], 72 | }; 73 | 74 | /** 75 | * JSDoc plugin rules. 76 | * 77 | * @see {@link https://github.com/gajus/eslint-plugin-jsdoc?tab=readme-ov-file#user-content-eslint-plugin-jsdoc-rules} 78 | */ 79 | const JSDOC_PLUGIN_RULES = { 80 | // Types are described in TypeScript 81 | 'jsdoc/require-param-type': 'off', 82 | 'jsdoc/no-undefined-types': 'off', 83 | 'jsdoc/require-returns-type': 'off', 84 | 'jsdoc/require-throws-type': 'off', 85 | 86 | 'jsdoc/require-param-description': 'error', 87 | 'jsdoc/require-property-description': 'error', 88 | 'jsdoc/require-returns-description': 'error', 89 | 'jsdoc/require-returns': 'error', 90 | 'jsdoc/require-param': 'error', 91 | 'jsdoc/require-returns-check': 'error', 92 | 93 | 'jsdoc/check-tag-names': [ 94 | 'warn', 95 | { 96 | // Define additional tags 97 | // https://github.com/gajus/eslint-plugin-jsdoc/blob/main/docs/rules/check-tag-names.md#definedtags 98 | definedTags: ['note'], 99 | }, 100 | ], 101 | 102 | 'jsdoc/require-hyphen-before-param-description': ['error', 'never'], 103 | 'jsdoc/require-jsdoc': [ 104 | 'error', 105 | { 106 | contexts: [ 107 | 'ClassDeclaration', 108 | 'ClassProperty', 109 | 'PropertyDefinition', 110 | 'FunctionDeclaration', 111 | 'MethodDefinition', 112 | ], 113 | }, 114 | ], 115 | 'jsdoc/require-description': [ 116 | 'error', 117 | { 118 | contexts: [ 119 | 'ClassDeclaration', 120 | 'ClassProperty', 121 | 'PropertyDefinition', 122 | 'FunctionDeclaration', 123 | 'MethodDefinition', 124 | ], 125 | }, 126 | ], 127 | 'jsdoc/require-description-complete-sentence': [ 128 | 'error', 129 | { 130 | abbreviations: [ 131 | 'e.g.', 132 | 'i.e.', 133 | ], 134 | }, 135 | ], 136 | 'jsdoc/multiline-blocks': [ 137 | 'error', 138 | { 139 | noSingleLineBlocks: true, 140 | singleLineTags: [ 141 | 'inheritdoc', 142 | ], 143 | }, 144 | ], 145 | 'jsdoc/tag-lines': [ 146 | 'error', 147 | 'any', 148 | { 149 | startLines: 1, 150 | }, 151 | ], 152 | 'jsdoc/sort-tags': [ 153 | 'error', 154 | { 155 | linesBetween: 1, 156 | tagSequence: [ 157 | { tags: ['file'] }, 158 | { tags: ['template'] }, 159 | { tags: ['see'] }, 160 | { tags: ['param'] }, 161 | { tags: ['returns'] }, 162 | { tags: ['throws'] }, 163 | { tags: ['example'] }, 164 | ], 165 | }, 166 | ], 167 | }; 168 | 169 | /** 170 | * N plugin rules. 171 | * 172 | * @see {@link https://github.com/eslint-community/eslint-plugin-n?tab=readme-ov-file#-rules} 173 | */ 174 | const N_PLUGIN_RULES = { 175 | // Import plugin is enough, also, this rule requires extensions in ESM, but we use bundler resolution 176 | 'n/no-missing-import': 'off', 177 | // Require using node protocol for node modules, e.g. `node:fs` instead of `fs`. 178 | 'n/prefer-node-protocol': 'error', 179 | // Prefer `/promises` API for `fs` and `dns` modules, if the corresponding imports are used. 180 | 'n/prefer-promises/fs': 'error', 181 | 'n/prefer-promises/dns': 'error', 182 | 183 | 'n/hashbang': [ 184 | 'error', 185 | { 186 | // This rule reads the bin property from package.json, and only allows shebangs for that file. 187 | // But since we transform the source files to the dist folder, we need to convert the paths. 188 | convertPath: [ 189 | { 190 | include: ['src/**/*.ts'], 191 | replace: ['^src/(.+)\\.ts$', 'dist/$1.js'], 192 | }, 193 | ], 194 | }, 195 | ], 196 | }; 197 | 198 | /** 199 | * Merges multiple rule sets into a single object. 200 | * 201 | * @param ruleSets The rule sets to merge. 202 | * 203 | * @returns The merged rule set. 204 | */ 205 | function mergeRules(...ruleSets) { 206 | const merged = {}; 207 | for (const rules of ruleSets) { 208 | for (const [key, value] of Object.entries(rules)) { 209 | if (merged[key]) { 210 | throw new Error(`Duplicate ESLint rule: ${key}`); 211 | } 212 | merged[key] = value; 213 | } 214 | } 215 | return merged; 216 | } 217 | 218 | module.exports = { 219 | root: true, 220 | parserOptions: { 221 | ecmaVersion: 'latest', 222 | }, 223 | plugins: [ 224 | 'import', 225 | 'import-newlines', 226 | 'n', 227 | ], 228 | extends: [ 229 | 'airbnb-base', 230 | 'plugin:jsdoc/recommended', 231 | 'plugin:n/recommended', 232 | ], 233 | ignorePatterns: [ 234 | 'dist', 235 | 'coverage', 236 | ], 237 | rules: mergeRules( 238 | ESLINT_RULES, 239 | IMPORT_PLUGIN_RULES, 240 | JSDOC_PLUGIN_RULES, 241 | N_PLUGIN_RULES, 242 | ), 243 | }; 244 | -------------------------------------------------------------------------------- /src/syntax/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * CSSTree syntax extension fork for "Adblock Extended CSS" syntax. 3 | * 4 | * ! DURING DEVELOPMENT, PLEASE DO NOT DIFFER FROM THE ORIGINAL CSSTREE API 5 | * ! IN ANY WAY! 6 | * ! OUR PRIMARY GOAL IS TO KEEP THE API AS CLOSE AS POSSIBLE TO THE ORIGINAL 7 | * ! CSSTREE API, SO CSSTREE EASILY CAN BE REPLACED WITH ECSSTREE EVERYWHERE 8 | * ! ANY TIME. 9 | * 10 | * This library supports various Extended CSS language elements from 11 | * - AdGuard, 12 | * - uBlock Origin and 13 | * - Adblock Plus. 14 | * 15 | * @see {@link https://github.com/AdguardTeam/ExtendedCss} 16 | * @see {@link https://github.com/gorhill/uBlock/wiki/Procedural-cosmetic-filters} 17 | * @see {@link https://help.adblockplus.org/hc/en-us/articles/360062733293#elemhide-emulation} 18 | */ 19 | 20 | import { tokenizeExtended } from '@adguard/css-tokenizer'; 21 | import { fork, tokenTypes } from '@eslint/css-tree'; 22 | 23 | const selector = { 24 | /** 25 | * CSSTree logic for parsing a selector from the token stream. 26 | * Via "this" we can access the parser's internal context, e.g. 27 | * methods, token stream, etc. 28 | * 29 | * Idea comes from CSSTree source code. 30 | * 31 | * @see {@link https://github.com/csstree/csstree/blob/master/lib/syntax/pseudo/index.js} 32 | * 33 | * @returns Doubly linked list which contains the parsed selector node. 34 | * 35 | * @throws If parsing not possible. 36 | */ 37 | parse() { 38 | return this.createSingleNodeList(this.Selector()); 39 | }, 40 | }; 41 | 42 | const selectorList = { 43 | /** 44 | * CSSTree logic for parsing a selector list from the token stream. 45 | * Via "this" we can access the parser's internal context, e.g. 46 | * methods, token stream, etc. 47 | * 48 | * Idea comes from CSSTree source code. 49 | * 50 | * @see {@link https://github.com/csstree/csstree/blob/master/lib/syntax/pseudo/index.js} 51 | * 52 | * @returns Doubly linked list which contains the parsed selector list node. 53 | * 54 | * @throws If parsing not possible. 55 | */ 56 | parse() { 57 | return this.createSingleNodeList(this.SelectorList()); 58 | }, 59 | }; 60 | 61 | const mediaQueryList = { 62 | /** 63 | * CSSTree logic for parsing a media query list from the token stream. 64 | * Via "this" we can access the parser's internal context, e.g. 65 | * methods, token stream, etc. 66 | * 67 | * Idea comes from CSSTree source code. 68 | * 69 | * @see {@link https://github.com/csstree/csstree/blob/master/lib/syntax/pseudo/index.js} 70 | * 71 | * @returns Doubly linked list which contains the parsed media query list node. 72 | * 73 | * @throws If parsing not possible. 74 | */ 75 | parse() { 76 | return this.createSingleNodeList(this.MediaQueryList()); 77 | }, 78 | }; 79 | 80 | const numberOrSelectorList = { 81 | /** 82 | * CSSTree logic for parsing a number or a selector list from the token 83 | * stream. 84 | * Via "this" we can access the parser's internal context, e.g. 85 | * methods, token stream, etc. 86 | * 87 | * Idea comes from CSSTree source code. 88 | * 89 | * @see {@link https://github.com/csstree/csstree/blob/master/lib/syntax/pseudo/index.js} 90 | * 91 | * @returns Doubly linked list which contains the parsed number or selector list node. 92 | * 93 | * @throws If parsing not possible. 94 | */ 95 | parse() { 96 | // Save the current token index 97 | const startToken = this.tokenIndex; 98 | 99 | // Don't use "parseWithFallback" here, because we don't want to 100 | // throw parsing error, if just the number parsing fails. 101 | try { 102 | // Try to parse :upward()'s argument as a number, but if it fails, 103 | // that's not a problem, because we can try to parse it as a selector list. 104 | return this.createSingleNodeList(this.Number.call(this)); 105 | } catch (error) { 106 | // If the number parsing fails, then we try to parse a selector list. 107 | // If the selector list parsing fails, then an error will be thrown, 108 | // because the argument is invalid. 109 | return this.createSingleNodeList(this.SelectorList.call(this, startToken)); 110 | } 111 | }, 112 | }; 113 | 114 | const number = { 115 | /** 116 | * CSSTree logic for parsing a number from the token stream. 117 | * Via "this" we can access the parser's internal context, e.g. 118 | * methods, token stream, etc. 119 | * 120 | * Idea comes from CSSTree source code. 121 | * 122 | * @see {@link https://github.com/csstree/csstree/blob/master/lib/syntax/pseudo/index.js} 123 | * 124 | * @returns Doubly linked list which contains the parsed number node. 125 | * 126 | * @throws If parsing not possible. 127 | */ 128 | parse() { 129 | return this.createSingleNodeList(this.Number()); 130 | }, 131 | }; 132 | 133 | const style = { 134 | /** 135 | * ECSSTree logic for parsing uBO's style from the token stream. 136 | * Via "this" we can access the parser's internal context, e.g. 137 | * methods, token stream, etc. 138 | * 139 | * @returns Doubly linked list which contains the parsed declaration list node. 140 | * 141 | * @throws If parsing not possible. 142 | */ 143 | parse() { 144 | // Throw an error if the current token is not a left parenthesis, 145 | // which means that the style is not specified at all. 146 | if (this.tokenType === tokenTypes.RightParenthesis) { 147 | this.error('No style specified'); 148 | } 149 | 150 | // Prepare a doubly linked list for children 151 | const children = this.createList(); 152 | 153 | // Get the current token's balance from the token stream. Balance pair map 154 | // lets us to determine when the current function ends. 155 | const balance = this.balance[this.tokenIndex]; 156 | 157 | // In order to avoid infinite loop we also need to track the current token index 158 | while (this.balance[this.tokenIndex] === balance && this.tokenIndex < this.tokenCount) { 159 | switch (this.tokenType) { 160 | // Skip whitespaces, comments and semicolons, which are actually not needed 161 | // here 162 | case tokenTypes.WhiteSpace: 163 | case tokenTypes.Comment: 164 | case tokenTypes.Semicolon: 165 | // Jump to the next token 166 | this.next(); 167 | break; 168 | 169 | // At this point we can assume that we have a declaration, so it's time to parse it 170 | default: 171 | children.push( 172 | // Parse declaration with fallback to Raw node 173 | // We need arrow function here, because we need to use the current parser 174 | // context via "this" keyword, but regular functions will have their own 175 | // context, that breaks the logic. 176 | 177 | // eslint-disable-next-line arrow-body-style 178 | this.parseWithFallback(this.Declaration, (startToken) => { 179 | // Parse until the next semicolon (this handles if we have multiple declarations in 180 | // the same style, so we not parse all of them as a single Raw rule because of this) 181 | return this.Raw(startToken, this.consumeUntilSemicolonIncluded, true); 182 | }), 183 | ); 184 | } 185 | } 186 | 187 | // Create a DeclarationList node and pass the children to it 188 | // You can find the structure of the node in the CSSTree documentation: 189 | // https://github.com/csstree/csstree/blob/master/docs/ast.md#declarationlist 190 | const declarationList = { 191 | type: 'DeclarationList', 192 | // CSSTree will handle position calculation for us 193 | loc: this.getLocationFromList(children), 194 | children, 195 | }; 196 | 197 | // Return the previously created CSSTree-compatible node 198 | return this.createSingleNodeList(declarationList); 199 | }, 200 | }; 201 | 202 | /** 203 | * Extended CSS syntax via CSSTree fork API. Thanks for the idea to `@lahmatiy`! 204 | * 205 | * @see {@link https://github.com/csstree/csstree/issues/211#issuecomment-1349732115} 206 | * @see {@link https://github.com/csstree/csstree/blob/master/lib/syntax/create.js} 207 | */ 208 | const extendedCssSyntax = fork({ 209 | tokenize: tokenizeExtended, 210 | pseudo: { 211 | '-abp-has': selectorList, 212 | 'if-not': selector, 213 | 'matches-media': mediaQueryList, 214 | 'min-text-length': number, 215 | 'nth-ancestor': number, 216 | style, 217 | upward: numberOrSelectorList, 218 | }, 219 | }); 220 | 221 | export default extendedCssSyntax; 222 | -------------------------------------------------------------------------------- /test/syntax/upward.test.js: -------------------------------------------------------------------------------- 1 | import { describe, expect, test } from 'vitest'; 2 | 3 | import { generate, parse, toPlainObject } from '../../src/index'; 4 | 5 | const parserConfig = { 6 | context: 'selector', 7 | positions: true, 8 | }; 9 | 10 | describe(':upward()', () => { 11 | test('throws on invalid input', () => { 12 | expect(() => parse(':upward($$)', parserConfig)).toThrow(); 13 | expect(() => parse(':upward(.)', parserConfig)).toThrow(); 14 | }); 15 | 16 | test('parses valid input properly', () => { 17 | // Number 18 | expect(toPlainObject(parse('div:upward(42)', parserConfig))).toStrictEqual({ 19 | type: 'Selector', 20 | loc: { 21 | source: '', 22 | start: { 23 | offset: 0, 24 | line: 1, 25 | column: 1, 26 | }, 27 | end: { 28 | offset: 14, 29 | line: 1, 30 | column: 15, 31 | }, 32 | }, 33 | children: [ 34 | { 35 | type: 'TypeSelector', 36 | loc: { 37 | source: '', 38 | start: { 39 | offset: 0, 40 | line: 1, 41 | column: 1, 42 | }, 43 | end: { 44 | offset: 3, 45 | line: 1, 46 | column: 4, 47 | }, 48 | }, 49 | name: 'div', 50 | }, 51 | { 52 | type: 'PseudoClassSelector', 53 | loc: { 54 | source: '', 55 | start: { 56 | offset: 3, 57 | line: 1, 58 | column: 4, 59 | }, 60 | end: { 61 | offset: 14, 62 | line: 1, 63 | column: 15, 64 | }, 65 | }, 66 | name: 'upward', 67 | children: [ 68 | { 69 | type: 'Number', 70 | loc: { 71 | source: '', 72 | start: { 73 | offset: 11, 74 | line: 1, 75 | column: 12, 76 | }, 77 | end: { 78 | offset: 13, 79 | line: 1, 80 | column: 14, 81 | }, 82 | }, 83 | value: '42', 84 | }, 85 | ], 86 | }, 87 | ], 88 | }); 89 | 90 | // Selector - now wrapped in SelectorList for consistency 91 | expect(toPlainObject(parse('div:upward(.something + #another)', parserConfig))).toStrictEqual({ 92 | type: 'Selector', 93 | loc: { 94 | source: '', 95 | start: { 96 | offset: 0, 97 | line: 1, 98 | column: 1, 99 | }, 100 | end: { 101 | offset: 33, 102 | line: 1, 103 | column: 34, 104 | }, 105 | }, 106 | children: [ 107 | { 108 | type: 'TypeSelector', 109 | loc: { 110 | source: '', 111 | start: { 112 | offset: 0, 113 | line: 1, 114 | column: 1, 115 | }, 116 | end: { 117 | offset: 3, 118 | line: 1, 119 | column: 4, 120 | }, 121 | }, 122 | name: 'div', 123 | }, 124 | { 125 | type: 'PseudoClassSelector', 126 | loc: { 127 | source: '', 128 | start: { 129 | offset: 3, 130 | line: 1, 131 | column: 4, 132 | }, 133 | end: { 134 | offset: 33, 135 | line: 1, 136 | column: 34, 137 | }, 138 | }, 139 | name: 'upward', 140 | children: [ 141 | { 142 | type: 'SelectorList', 143 | loc: { 144 | source: '', 145 | start: { 146 | offset: 11, 147 | line: 1, 148 | column: 12, 149 | }, 150 | end: { 151 | offset: 32, 152 | line: 1, 153 | column: 33, 154 | }, 155 | }, 156 | children: [ 157 | { 158 | type: 'Selector', 159 | loc: { 160 | source: '', 161 | start: { 162 | offset: 11, 163 | line: 1, 164 | column: 12, 165 | }, 166 | end: { 167 | offset: 32, 168 | line: 1, 169 | column: 33, 170 | }, 171 | }, 172 | children: [ 173 | { 174 | type: 'ClassSelector', 175 | loc: { 176 | source: '', 177 | start: { 178 | offset: 11, 179 | line: 1, 180 | column: 12, 181 | }, 182 | end: { 183 | offset: 21, 184 | line: 1, 185 | column: 22, 186 | }, 187 | }, 188 | name: 'something', 189 | }, 190 | { 191 | type: 'Combinator', 192 | loc: { 193 | source: '', 194 | start: { 195 | offset: 22, 196 | line: 1, 197 | column: 23, 198 | }, 199 | end: { 200 | offset: 23, 201 | line: 1, 202 | column: 24, 203 | }, 204 | }, 205 | name: '+', 206 | }, 207 | { 208 | type: 'IdSelector', 209 | loc: { 210 | source: '', 211 | start: { 212 | offset: 24, 213 | line: 1, 214 | column: 25, 215 | }, 216 | end: { 217 | offset: 32, 218 | line: 1, 219 | column: 33, 220 | }, 221 | }, 222 | name: 'another', 223 | }, 224 | ], 225 | }, 226 | ], 227 | }, 228 | ], 229 | }, 230 | ], 231 | }); 232 | 233 | // Selector list with multiple comma-separated selectors 234 | const selectorListAST = toPlainObject( 235 | parse( 236 | '.su-label:upward(.wpb_text_column, .td_block_text_with_title)', 237 | parserConfig, 238 | ), 239 | ); 240 | expect(selectorListAST.type).toBe('Selector'); 241 | expect(selectorListAST.children).toHaveLength(2); 242 | 243 | // Check the :upward() pseudo-class 244 | const upwardPseudo = selectorListAST.children[1]; 245 | expect(upwardPseudo.type).toBe('PseudoClassSelector'); 246 | expect(upwardPseudo.name).toBe('upward'); 247 | expect(upwardPseudo.children).toHaveLength(1); 248 | 249 | // Check that the child is a SelectorList 250 | const selectorList = upwardPseudo.children[0]; 251 | expect(selectorList.type).toBe('SelectorList'); 252 | expect(selectorList.children).toHaveLength(2); 253 | 254 | // Check first selector in the list 255 | expect(selectorList.children[0].type).toBe('Selector'); 256 | expect(selectorList.children[0].children).toHaveLength(1); 257 | expect(selectorList.children[0].children[0].type).toBe('ClassSelector'); 258 | expect(selectorList.children[0].children[0].name).toBe('wpb_text_column'); 259 | 260 | // Check second selector in the list 261 | expect(selectorList.children[1].type).toBe('Selector'); 262 | expect(selectorList.children[1].children).toHaveLength(1); 263 | expect(selectorList.children[1].children[0].type).toBe('ClassSelector'); 264 | expect(selectorList.children[1].children[0].name).toBe('td_block_text_with_title'); 265 | }); 266 | 267 | test('generates valid input properly', () => { 268 | expect(generate(parse('div:upward(42)', parserConfig))).toEqual('div:upward(42)'); 269 | expect(generate(parse('div:upward(.something + #another)', parserConfig))).toEqual( 270 | 'div:upward(.something+#another)', 271 | ); 272 | expect(generate(parse('div:upward(.foo, .bar)', parserConfig))) 273 | .toEqual('div:upward(.foo,.bar)'); 274 | expect(generate(parse('.su-label:upward(.wpb_text_column, .td_block_text_with_title)', parserConfig))) 275 | .toEqual('.su-label:upward(.wpb_text_column,.td_block_text_with_title)'); 276 | }); 277 | 278 | test('no false positive parsing errors', () => { 279 | // "Local" parser config for this test 280 | const localParserConfig = { 281 | ...parserConfig, 282 | onParseError: (error) => { 283 | throw error; 284 | }, 285 | }; 286 | 287 | expect(() => parse('div:upward(0)', localParserConfig)).not.toThrow(); 288 | expect(() => parse('div:upward(42)', localParserConfig)).not.toThrow(); 289 | expect(() => parse('div:upward(.something + #another)', localParserConfig)).not.toThrow(); 290 | expect( 291 | () => parse( 292 | 'div:upward(div + :-abp-has(> a[href^="https://example.com/"]) + div)', 293 | localParserConfig, 294 | ), 295 | ).not.toThrow(); 296 | // Selector list with multiple comma-separated selectors 297 | expect(() => parse('div:upward(.foo, .bar)', localParserConfig)).not.toThrow(); 298 | expect( 299 | () => parse( 300 | '.su-label:upward(.wpb_text_column, .td_block_text_with_title)', 301 | localParserConfig, 302 | ), 303 | ).not.toThrow(); 304 | }); 305 | }); 306 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | CSSTree logo 3 | 4 | 5 | 6 | # ECSSTree 7 | 8 | 9 | [![NPM version](https://img.shields.io/npm/v/@adguard/ecss-tree.svg)](https://www.npmjs.com/package/@adguard/ecss-tree) 10 | [![NPM Downloads](https://img.shields.io/npm/dm/@adguard/ecss-tree.svg)](https://www.npmjs.com/package/@adguard/ecss-tree) 11 | [![LICENSE](https://img.shields.io/github/license/AdguardTeam/ecsstree)](https://github.com/AdguardTeam/ecsstree/blob/main/LICENSE) 12 | 13 | 14 | Adblock Extended CSS supplement for [CSSTree](https://github.com/csstree/csstree). 15 | Our primary goal is to change the internal behavior of the CSSTree parser to support 16 | Extended CSS (ECSS) language elements, but we don't change the API or the AST structure. 17 | Therefore ECSSTree fully backwards compatible with CSSTree, so you can pass our AST to CSSTree functions 18 | and vice versa without any problems. 19 | 20 | > [!NOTE] 21 | > If you are looking for a library that can parse CSS, but you don't know what is Adblock 22 | > or Extended CSS, you should probably use [CSSTree](https://github.com/csstree/csstree) instead of this library :) 23 | 24 | ## Table of contents 25 | 26 | - [Table of contents](#table-of-contents) 27 | - [Installation](#installation) 28 | - [Supported Extended CSS elements](#supported-extended-css-elements) 29 | - [Motivation](#motivation) 30 | - [Advanced validation](#advanced-validation) 31 | - [Handle problematic cases](#handle-problematic-cases) 32 | - [Examples](#examples) 33 | - [Reporting problems / Requesting features](#reporting-problems--requesting-features) 34 | - [Contributing](#contributing) 35 | - [Development](#development) 36 | - [Prerequisites](#prerequisites) 37 | - [Commands](#commands) 38 | - [License](#license) 39 | - [Acknowledgements](#acknowledgements) 40 | - [References](#references) 41 | 42 | ## Installation 43 | 44 | You can install the library using 45 | 46 | - [PNPM][pnpm-pkg-manager-url]: `pnpm add @adguard/ecss-tree` 47 | - [NPM][npm-pkg-manager-url]: `npm install @adguard/ecss-tree` 48 | - [Yarn][yarn-pkg-manager-url]: `yarn add @adguard/ecss-tree` 49 | 50 | [npm-pkg-manager-url]: https://www.npmjs.com/get-npm 51 | [pnpm-pkg-manager-url]: https://pnpm.io/ 52 | [yarn-pkg-manager-url]: https://yarnpkg.com/en/docs/install 53 | 54 | Or you can use it via esm.run: 55 | 56 | ## Supported Extended CSS elements 57 | 58 | Currently, the following Extended CSS pseudo classes are supported: 59 | 60 | - `:-abp-contains(raw)`: [[ABP reference]][abp-extcss] 61 | - `:-abp-has(selector list)`: [[ABP reference]][abp-extcss] 62 | - `:contains(raw)`: [[ADG reference]][contains-adg] 63 | - `:has-text(raw)`: [[uBO reference]][has-text-ubo] 64 | - `:if-not(selector)`: [[ADG reference]][if-not-adg] 65 | - `:matches-css-after(raw)`: [[uBO reference]][matches-css-after-ubo] 66 | - `:matches-css-before(raw)`: [[uBO reference]][matches-css-before-ubo] 67 | - `:matches-css(raw)`: [[ADG reference]][matches-css-adg], [[uBO reference]][matches-css-ubo] 68 | - `:matches-media(media query list)`: [[uBO reference]][matches-media-ubo] 69 | - `:min-text-length(number)`: [[uBO reference]][min-text-length-ubo] 70 | - `:nth-ancestor(number)`: [[ADG reference]][nth-ancestor-adg] 71 | - `:style(declaration list)`: [[uBO reference]][style-ubo] 72 | - `:upward(selector list / number)`: [[ADG reference]][upward-adg], [[uBO reference]][upward-ubo] 73 | - `:xpath(raw)`: [[ADG reference]][xpath-adg], [[uBO reference]][xpath-ubo] 74 | 75 | In addition, CSSTree supports the following pseudo classes 76 | [by default](https://github.com/csstree/csstree/blob/master/lib/syntax/pseudo/index.js): 77 | 78 | - `:has(selector list)`: [[W3C reference]][has-w3c], [[ADG reference]][has-adg], [[uBO reference]][has-ubo] 79 | - `:not(selector list)`: [[W3C reference]][not-w3c], [[ADG reference]][not-adg], [[uBO reference]][not-ubo] 80 | - `:is(selector list)`: [[W3C reference]][is-w3c], [[ADG reference]][is-adg], [[uBO reference]][is-ubo] 81 | 82 | [has-w3c]: https://drafts.csswg.org/selectors-4/#has-pseudo 83 | [is-w3c]: https://drafts.csswg.org/selectors-4/#is-pseudo 84 | [not-w3c]: https://drafts.csswg.org/selectors-4/#negation-pseudo 85 | 86 | [abp-extcss]: https://help.adblockplus.org/hc/en-us/articles/360062733293#elemhide_css 87 | 88 | [contains-adg]: https://github.com/AdguardTeam/ExtendedCss#extended-css-contains 89 | [has-adg]: https://github.com/AdguardTeam/ExtendedCss#extended-css-has 90 | [if-not-adg]: https://github.com/AdguardTeam/ExtendedCss#extended-css-if-not 91 | [is-adg]: https://github.com/AdguardTeam/ExtendedCss#extended-css-is 92 | [matches-css-adg]: https://github.com/AdguardTeam/ExtendedCss#extended-css-matches-css 93 | [not-adg]: https://github.com/AdguardTeam/ExtendedCss#extended-css-not 94 | [nth-ancestor-adg]: https://github.com/AdguardTeam/ExtendedCss#extended-css-nth-ancestor 95 | [upward-adg]: https://github.com/AdguardTeam/ExtendedCss#extended-css-upward 96 | [xpath-adg]: https://github.com/AdguardTeam/ExtendedCss#-pseudo-class-xpath 97 | 98 | [has-text-ubo]: https://github.com/gorhill/uBlock/wiki/Procedural-cosmetic-filters#subjecthas-textneedle 99 | [has-ubo]: https://github.com/gorhill/uBlock/wiki/Procedural-cosmetic-filters#subjecthasarg 100 | [is-ubo]: https://github.com/AdguardTeam/ExtendedCss#extended-css-is 101 | [matches-css-after-ubo]: https://github.com/gorhill/uBlock/wiki/Procedural-cosmetic-filters#subjectmatches-css-afterarg 102 | [matches-css-before-ubo]: https://github.com/gorhill/uBlock/wiki/Procedural-cosmetic-filters#subjectmatches-css-beforearg 103 | [matches-css-ubo]: https://github.com/gorhill/uBlock/wiki/Procedural-cosmetic-filters#subjectmatches-cssarg 104 | [matches-media-ubo]: https://github.com/gorhill/uBlock/wiki/Procedural-cosmetic-filters#subjectmatches-mediaarg 105 | [min-text-length-ubo]: https://github.com/gorhill/uBlock/wiki/Procedural-cosmetic-filters#subjectmin-text-lengthn 106 | [not-ubo]: https://github.com/gorhill/uBlock/wiki/Procedural-cosmetic-filters#subjectnotarg 107 | [style-ubo]: https://github.com/gorhill/uBlock/wiki/Static-filter-syntax#subjectstylearg 108 | [upward-ubo]: https://github.com/gorhill/uBlock/wiki/Procedural-cosmetic-filters#subjectupwardarg 109 | [xpath-ubo]: https://github.com/gorhill/uBlock/wiki/Procedural-cosmetic-filters#subjectxpatharg 110 | 111 | Also, CSSTree supports legacy Extended CSS elements by default (attribute selectors): 112 | `[-ext-name="value"]`, where `name` is the name of the Extended CSS element and `value` is its value. 113 | For example, the following selector can be parsed by CSSTree: 114 | 115 | ```css 116 | [-ext-has="selector list"] 117 | ``` 118 | 119 | If a pseudo class is unknown to CSSTree, it tries to parse it as a `Raw` element 120 | (if possible - see [problematic cases](https://github.com/AdguardTeam/ecsstree#handle-problematic-cases)). 121 | 122 | The CSSTree library itself is quite flexible and error-tolerant, 123 | so it basically manages well the Extended CSS elements that are not (yet) included here. 124 | 125 | ## Motivation 126 | 127 | For example, the following selector 128 | 129 | ```css 130 | div:-abp-has(> section) 131 | ``` 132 | 133 | will be parsed by the default CSSTree as follows 134 | 135 | ```json 136 | { 137 | "type": "Selector", 138 | "loc": null, 139 | "children": [ 140 | { 141 | "type": "PseudoClassSelector", 142 | "loc": null, 143 | "name": "-abp-has", 144 | "children": [ 145 | { 146 | "type": "Raw", 147 | "loc": null, 148 | "value": "> section" 149 | } 150 | ] 151 | } 152 | ] 153 | } 154 | ``` 155 | 156 | The problem with this is that the `-abp-has` parameter is parsed as `Raw`, not as a `Selector`, 157 | since `-abp-has` is an unknown pseudo class in CSS / CSSTree. 158 | 159 | This is where the ECSSTree library comes into play. It detects that `-abp-has` expects a selector as a parameter, 160 | i.e. it parses the passed parameter as a `Selector`. This means that the selector above will be parsed as follows: 161 | 162 | ```json 163 | { 164 | "type": "Selector", 165 | "loc": null, 166 | "children": [ 167 | { 168 | "type": "PseudoClassSelector", 169 | "loc": null, 170 | "name": "-abp-has", 171 | "children": [ 172 | { 173 | "type": "Selector", 174 | "loc": null, 175 | "children": [ 176 | { 177 | "type": "Combinator", 178 | "loc": null, 179 | "name": ">" 180 | }, 181 | { 182 | "type": "TypeSelector", 183 | "loc": null, 184 | "name": "section" 185 | } 186 | ] 187 | } 188 | ] 189 | } 190 | ] 191 | } 192 | ``` 193 | 194 | `Combinator` and similar Nodes are part of CSSTree, this fork simply specifies that the `-abp-has` parameter 195 | should be parsed as a selector. The nodes themselves are part of the CSSTree. 196 | 197 | ### Advanced validation 198 | 199 | In addition, this approach enables a more advanced validation. For example, 200 | the default CSSTree does not throw an error when parsing the following selector: 201 | 202 | ```css 203 | div:-abp-has(42) 204 | ``` 205 | 206 | since it doesn't know what `-abp-has` is, it simply parses 42 as `Raw`. ECSSTree parses the parameter as a selector, 207 | which will throw an error, since 42 is simply an invalid selector. 208 | 209 | ## Handle problematic cases 210 | 211 | The library also handles problematic selectors, such as the following: 212 | 213 | ```css 214 | div:contains(aaa'bbb) 215 | ``` 216 | 217 | This selector doesn't fully meet with CSS standards, so even if CSSTree is flexible, 218 | it will not be able to parse it properly, because it will tokenize it as follows: 219 | 220 | | Token type | Start index | End index | Source part | 221 | | -------------- | ----------- | --------- | ----------- | 222 | | ident-token | 0 | 3 | div | 223 | | colon-token | 3 | 4 | : | 224 | | function-token | 4 | 13 | contains( | 225 | | ident-token | 13 | 16 | aaa | 226 | | string-token | 16 | 21 | 'bbb) | 227 | 228 | At quote mark (`'`) tokenizer will think that a string is starting, and it tokenizes the rest of the input as a string. 229 | This is the normal behavior for the tokenizer, but it is wrong for us, since the parser will fail with an 230 | `")" is expected` error, as it doesn't found the closing parenthesis, since it thinks that the string is still open. 231 | 232 | ECSSTree will handle this case by a special re-tokenization algorithm during the parsing process, when parser reaches 233 | this problematic point. This way, ECSSTree's parser will be able to parse this selector properly. 234 | It is also true for `xpath`. 235 | 236 | *Note:* ECSSTree parses `:contains` and `:xpath` parameters as `Raw`. The main goal of this library is changing the 237 | internal behavior of the CSSTree's parser to make it able to parse the Extended CSS selectors properly, 238 | not to change the AST itself. The AST should be the same as in CSSTree, so that the library can be used 239 | as a drop-in replacement for CSSTree. 240 | Parsing `:xpath` expressions or regular expressions in detail would be a huge task, and requires new AST nodes, 241 | which would be a breaking change. But it always parses the correct raw expression for you, 242 | so you can parse/validate these expressions yourself if you want. There are many libraries for this, 243 | such as [xpath](https://www.npmjs.com/package/xpath) or [regexpp](https://www.npmjs.com/package/regexpp). 244 | See [example codes](/examples) for more details. 245 | 246 | ## Examples 247 | 248 | Here are a very simple example to show how to use ECSSTree: 249 | 250 | ```javascript 251 | import { parse, generate, toPlainObject, fromPlainObject } from "@adguard/ecss-tree"; 252 | import { inspect } from "util"; 253 | 254 | // Some inputs to test 255 | const inputs = [ 256 | // Valid selectors 257 | `div:-abp-has(> .some-class > a[href^="https://example.com"])`, 258 | `body:style(padding-top: 0 !important;):matches-media((min-width: 500px) and (max-width: 1000px))`, 259 | `section:upward(2):contains(aaa'bbb):xpath(//*[contains(text(),"()(cc")])`, 260 | 261 | // Missing closing bracket at the end 262 | `div:-abp-has(> .some-class > a[href^="https://example.com"]`, 263 | ]; 264 | 265 | // Iterate over inputs 266 | for (const input of inputs) { 267 | try { 268 | // Parse raw input to AST. This will throw an error if the input is not valid. 269 | // Don't forget to set context to 'selector', because CSSTree will try to parse 270 | // 'stylesheet' by default. 271 | const ast = parse(input, { context: "selector" }); 272 | 273 | // By default, AST uses a doubly linked list. To convert it to plain object, you can 274 | // use toPlainObject() function. 275 | // If you want to convert AST back to doubly linked list version, you can use 276 | // fromPlainObject() function. 277 | const astPlain = toPlainObject(ast); 278 | const astAgain = fromPlainObject(astPlain); 279 | 280 | // Print AST to console 281 | console.log(inspect(astPlain, { colors: true, depth: null })); 282 | 283 | // You can also generate string from AST (don't use plain object here) 284 | console.log(generate(astAgain)); 285 | } catch (e) { 286 | // Mark invalid selector 287 | console.log(`Invalid selector: ${input}`); 288 | 289 | // Show CSSTree's formatted error message 290 | console.log(e.formattedMessage); 291 | } 292 | } 293 | ``` 294 | 295 | The API is the same as in CSSTree, so you can use the 296 | [CSSTree documentation](https://github.com/csstree/csstree/tree/master/docs) as a reference. 297 | 298 | You can find more examples in the [examples](/examples) folder. 299 | 300 | ## Reporting problems / Requesting features 301 | 302 | If you find a bug or want to request a new feature, please please [open an issue][new-issue-url] on GitHub. 303 | Please provide a detailed description of the problem or the feature you want to request, and if possible, 304 | a code example that demonstrates the problem or the feature. 305 | 306 | [new-issue-url]: https://github.com/AdguardTeam/ecsstree/issues/new 307 | 308 | ### Contributing 309 | 310 | You can contribute to the project by opening a pull request. People who contribute to AdGuard projects can receive 311 | various rewards, see [this page][contribute] for details. 312 | 313 | [contribute]: https://adguard.com/contribute.html 314 | 315 | ### Development 316 | 317 | #### Prerequisites 318 | 319 | Make sure you have the following tools installed: 320 | 321 | 1. Node.js (latest LTS version is recommended) 322 | 1. Yarn 323 | 324 | #### Commands 325 | 326 | During development, you can use the following commands (listed in `package.json`): 327 | 328 | - `pnpm lint` - lint the code with [ESLint][eslint] 329 | - `pnpm test` - run tests with [Jest][jest] (you can also run a specific test with `pnpm test `) 330 | - `pnpm build` - build the library to the `dist` folder by using [Rollup][rollup] 331 | 332 | [eslint]: https://eslint.org/ 333 | [jest]: https://jestjs.io/ 334 | [rollup]: https://rollupjs.org/ 335 | 336 | ## License 337 | 338 | This library is licensed under the MIT license. 339 | See the [LICENSE](https://github.com/AdguardTeam/ecsstree/blob/main/LICENSE) file for more info. 340 | 341 | ## Acknowledgements 342 | 343 | In this section, we would like to thank the following people for their work: 344 | 345 | - Roman Dvornov ([lahmatiy](https://github.com/lahmatiy)) for creating and maintaining the 346 | [CSSTree](https://github.com/csstree/csstree) library 347 | 348 | ## References 349 | 350 | Here are some useful links to learn more about Extended CSS selectors: 351 | 352 | - CSSTree docs: 353 | - AdGuard *ExtendedCSS*: 354 | - uBlock *"Procedural cosmetic filters"*: 355 | - Adblock Plus *ExtendedCSS*: 356 | -------------------------------------------------------------------------------- /test/syntax/matches-css.test.js: -------------------------------------------------------------------------------- 1 | import { describe, expect, test } from 'vitest'; 2 | 3 | import { generate, parse, toPlainObject } from '../../src/index'; 4 | 5 | const parserConfig = { 6 | context: 'selector', 7 | positions: true, 8 | }; 9 | 10 | describe(':matches-css()', () => { 11 | test('parses valid input properly', () => { 12 | // Regular style declaration 13 | expect(toPlainObject(parse(':matches-css(width:720px)', parserConfig))).toStrictEqual({ 14 | type: 'Selector', 15 | loc: { 16 | source: '', 17 | start: { 18 | offset: 0, 19 | line: 1, 20 | column: 1, 21 | }, 22 | end: { 23 | offset: 25, 24 | line: 1, 25 | column: 26, 26 | }, 27 | }, 28 | children: [ 29 | { 30 | type: 'PseudoClassSelector', 31 | loc: { 32 | source: '', 33 | start: { 34 | offset: 0, 35 | line: 1, 36 | column: 1, 37 | }, 38 | end: { 39 | offset: 25, 40 | line: 1, 41 | column: 26, 42 | }, 43 | }, 44 | name: 'matches-css', 45 | children: [ 46 | { 47 | type: 'Raw', 48 | loc: { 49 | source: '', 50 | start: { 51 | offset: 13, 52 | line: 1, 53 | column: 14, 54 | }, 55 | end: { 56 | offset: 24, 57 | line: 1, 58 | column: 25, 59 | }, 60 | }, 61 | value: 'width:720px', 62 | }, 63 | ], 64 | }, 65 | ], 66 | }); 67 | 68 | // Regular style declaration with space 69 | expect(toPlainObject(parse(':matches-css(width: 720px)', parserConfig))).toStrictEqual({ 70 | type: 'Selector', 71 | loc: { 72 | source: '', 73 | start: { 74 | offset: 0, 75 | line: 1, 76 | column: 1, 77 | }, 78 | end: { 79 | offset: 26, 80 | line: 1, 81 | column: 27, 82 | }, 83 | }, 84 | children: [ 85 | { 86 | type: 'PseudoClassSelector', 87 | loc: { 88 | source: '', 89 | start: { 90 | offset: 0, 91 | line: 1, 92 | column: 1, 93 | }, 94 | end: { 95 | offset: 26, 96 | line: 1, 97 | column: 27, 98 | }, 99 | }, 100 | name: 'matches-css', 101 | children: [ 102 | { 103 | type: 'Raw', 104 | loc: { 105 | source: '', 106 | start: { 107 | offset: 13, 108 | line: 1, 109 | column: 14, 110 | }, 111 | end: { 112 | offset: 25, 113 | line: 1, 114 | column: 26, 115 | }, 116 | }, 117 | value: 'width: 720px', 118 | }, 119 | ], 120 | }, 121 | ], 122 | }); 123 | 124 | // RegExp value 125 | // https://github.com/AdguardTeam/ecsstree/issues/2 126 | expect( 127 | toPlainObject( 128 | parse( 129 | ':matches-css(background-image: /^url\\("data:image\\/gif;base64.+/)', 130 | parserConfig, 131 | ), 132 | ), 133 | ).toStrictEqual({ 134 | type: 'Selector', 135 | loc: { 136 | source: '', 137 | start: { 138 | offset: 0, 139 | line: 1, 140 | column: 1, 141 | }, 142 | end: { 143 | offset: 65, 144 | line: 1, 145 | column: 66, 146 | }, 147 | }, 148 | children: [ 149 | { 150 | type: 'PseudoClassSelector', 151 | loc: { 152 | source: '', 153 | start: { 154 | offset: 0, 155 | line: 1, 156 | column: 1, 157 | }, 158 | end: { 159 | offset: 65, 160 | line: 1, 161 | column: 66, 162 | }, 163 | }, 164 | name: 'matches-css', 165 | children: [ 166 | { 167 | type: 'Raw', 168 | loc: { 169 | source: '', 170 | start: { 171 | offset: 13, 172 | line: 1, 173 | column: 14, 174 | }, 175 | end: { 176 | offset: 64, 177 | line: 1, 178 | column: 65, 179 | }, 180 | }, 181 | value: 'background-image: /^url\\("data:image\\/gif;base64.+/', 182 | }, 183 | ], 184 | }, 185 | ], 186 | }); 187 | 188 | // RegExp value with space before and after the argument 189 | expect( 190 | toPlainObject( 191 | parse( 192 | // eslint-disable-next-line max-len 193 | '*:matches-css( background-image: /^url\\("data:image\\/gif;base64.+/ ) + a[href="https://www.example.com/"]', 194 | parserConfig, 195 | ), 196 | ), 197 | ).toStrictEqual({ 198 | type: 'Selector', 199 | loc: { 200 | source: '', 201 | start: { 202 | offset: 0, 203 | line: 1, 204 | column: 1, 205 | }, 206 | end: { 207 | offset: 111, 208 | line: 1, 209 | column: 112, 210 | }, 211 | }, 212 | children: [ 213 | { 214 | type: 'TypeSelector', 215 | loc: { 216 | source: '', 217 | start: { 218 | offset: 0, 219 | line: 1, 220 | column: 1, 221 | }, 222 | end: { 223 | offset: 1, 224 | line: 1, 225 | column: 2, 226 | }, 227 | }, 228 | name: '*', 229 | }, 230 | { 231 | type: 'PseudoClassSelector', 232 | loc: { 233 | source: '', 234 | start: { 235 | offset: 1, 236 | line: 1, 237 | column: 2, 238 | }, 239 | end: { 240 | offset: 74, 241 | line: 1, 242 | column: 75, 243 | }, 244 | }, 245 | name: 'matches-css', 246 | children: [ 247 | { 248 | type: 'Raw', 249 | loc: { 250 | source: '', 251 | start: { 252 | offset: 14, 253 | line: 1, 254 | column: 15, 255 | }, 256 | end: { 257 | offset: 73, 258 | line: 1, 259 | column: 74, 260 | }, 261 | }, 262 | value: ' background-image: /^url\\("data:image\\/gif;base64.+/ ', 263 | }, 264 | ], 265 | }, 266 | { 267 | type: 'Combinator', 268 | loc: { 269 | source: '', 270 | start: { 271 | offset: 75, 272 | line: 1, 273 | column: 76, 274 | }, 275 | end: { 276 | offset: 76, 277 | line: 1, 278 | column: 77, 279 | }, 280 | }, 281 | name: '+', 282 | }, 283 | { 284 | type: 'TypeSelector', 285 | loc: { 286 | source: '', 287 | start: { 288 | offset: 77, 289 | line: 1, 290 | column: 78, 291 | }, 292 | end: { 293 | offset: 78, 294 | line: 1, 295 | column: 79, 296 | }, 297 | }, 298 | name: 'a', 299 | }, 300 | { 301 | type: 'AttributeSelector', 302 | loc: { 303 | source: '', 304 | start: { 305 | offset: 78, 306 | line: 1, 307 | column: 79, 308 | }, 309 | end: { 310 | offset: 111, 311 | line: 1, 312 | column: 112, 313 | }, 314 | }, 315 | name: { 316 | type: 'Identifier', 317 | loc: { 318 | source: '', 319 | start: { 320 | offset: 79, 321 | line: 1, 322 | column: 80, 323 | }, 324 | end: { 325 | offset: 83, 326 | line: 1, 327 | column: 84, 328 | }, 329 | }, 330 | name: 'href', 331 | }, 332 | matcher: '=', 333 | value: { 334 | type: 'String', 335 | loc: { 336 | source: '', 337 | start: { 338 | offset: 84, 339 | line: 1, 340 | column: 85, 341 | }, 342 | end: { 343 | offset: 110, 344 | line: 1, 345 | column: 111, 346 | }, 347 | }, 348 | value: 'https://www.example.com/', 349 | }, 350 | flags: null, 351 | }, 352 | ], 353 | }); 354 | 355 | // Function call within the argument (parentheses balanced) 356 | expect( 357 | toPlainObject( 358 | parse( 359 | ':matches-css(background-image:url(data:*))', 360 | parserConfig, 361 | ), 362 | ), 363 | ).toStrictEqual({ 364 | type: 'Selector', 365 | loc: { 366 | source: '', 367 | start: { 368 | offset: 0, 369 | line: 1, 370 | column: 1, 371 | }, 372 | end: { 373 | offset: 42, 374 | line: 1, 375 | column: 43, 376 | }, 377 | }, 378 | children: [ 379 | { 380 | type: 'PseudoClassSelector', 381 | loc: { 382 | source: '', 383 | start: { 384 | offset: 0, 385 | line: 1, 386 | column: 1, 387 | }, 388 | end: { 389 | offset: 42, 390 | line: 1, 391 | column: 43, 392 | }, 393 | }, 394 | name: 'matches-css', 395 | children: [ 396 | { 397 | type: 'Raw', 398 | loc: { 399 | source: '', 400 | start: { 401 | offset: 13, 402 | line: 1, 403 | column: 14, 404 | }, 405 | end: { 406 | offset: 41, 407 | line: 1, 408 | column: 42, 409 | }, 410 | }, 411 | value: 'background-image:url(data:*)', 412 | }, 413 | ], 414 | }, 415 | ], 416 | }); 417 | }); 418 | 419 | test('generates valid input properly', () => { 420 | expect(generate(parse(':matches-css(width:720px)', parserConfig))).toEqual(':matches-css(width:720px)'); 421 | expect(generate(parse(':matches-css(width: 720px)', parserConfig))).toEqual(':matches-css(width: 720px)'); 422 | 423 | expect( 424 | generate(parse(':matches-css(background-image: /^url\\("data:image\\/gif;base64.+/)', parserConfig)), 425 | ).toEqual( 426 | ':matches-css(background-image: /^url\\("data:image\\/gif;base64.+/)', 427 | ); 428 | 429 | expect( 430 | generate( 431 | parse( 432 | // eslint-disable-next-line max-len 433 | '*:matches-css( background-image: /^url\\("data:image\\/gif;base64.+/ ) + a[href="https://www.example.com/"]', 434 | parserConfig, 435 | ), 436 | ), 437 | ).toEqual( 438 | // eslint-disable-next-line max-len 439 | '*:matches-css( background-image: /^url\\("data:image\\/gif;base64.+/ )+a[href="https://www.example.com/"]', 440 | ); 441 | 442 | expect( 443 | generate(parse(':matches-css(background-image:url(data:*))', parserConfig)), 444 | ).toEqual( 445 | ':matches-css(background-image:url(data:*))', 446 | ); 447 | }); 448 | }); 449 | -------------------------------------------------------------------------------- /test/syntax/matches-media.test.js: -------------------------------------------------------------------------------- 1 | import { describe, expect, test } from 'vitest'; 2 | 3 | import { generate, parse, toPlainObject } from '../../src/index'; 4 | 5 | const parserConfig = { 6 | context: 'selector', 7 | positions: true, 8 | }; 9 | 10 | describe(':matches-media()', () => { 11 | test('throws on invalid input', () => { 12 | expect(() => parse(':matches-media($$)', parserConfig)).toThrow(); 13 | expect(() => parse(':matches-media(.)', parserConfig)).toThrow(); 14 | }); 15 | 16 | test('parses valid input properly', () => { 17 | // Simple media query 18 | expect(toPlainObject(parse('div:matches-media((min-width: 720px))', parserConfig))).toStrictEqual({ 19 | type: 'Selector', 20 | loc: { 21 | source: '', 22 | start: { 23 | offset: 0, 24 | line: 1, 25 | column: 1, 26 | }, 27 | end: { 28 | offset: 37, 29 | line: 1, 30 | column: 38, 31 | }, 32 | }, 33 | children: [ 34 | { 35 | type: 'TypeSelector', 36 | loc: { 37 | source: '', 38 | start: { 39 | offset: 0, 40 | line: 1, 41 | column: 1, 42 | }, 43 | end: { 44 | offset: 3, 45 | line: 1, 46 | column: 4, 47 | }, 48 | }, 49 | name: 'div', 50 | }, 51 | { 52 | type: 'PseudoClassSelector', 53 | loc: { 54 | source: '', 55 | start: { 56 | offset: 3, 57 | line: 1, 58 | column: 4, 59 | }, 60 | end: { 61 | offset: 37, 62 | line: 1, 63 | column: 38, 64 | }, 65 | }, 66 | name: 'matches-media', 67 | children: [ 68 | { 69 | type: 'MediaQueryList', 70 | loc: { 71 | source: '', 72 | start: { 73 | offset: 18, 74 | line: 1, 75 | column: 19, 76 | }, 77 | end: { 78 | offset: 36, 79 | line: 1, 80 | column: 37, 81 | }, 82 | }, 83 | children: [ 84 | { 85 | type: 'MediaQuery', 86 | loc: { 87 | source: '', 88 | start: { 89 | offset: 18, 90 | line: 1, 91 | column: 19, 92 | }, 93 | end: { 94 | offset: 36, 95 | line: 1, 96 | column: 37, 97 | }, 98 | }, 99 | modifier: null, 100 | mediaType: null, 101 | condition: { 102 | type: 'Condition', 103 | loc: { 104 | source: '', 105 | start: { 106 | offset: 18, 107 | line: 1, 108 | column: 19, 109 | }, 110 | end: { 111 | offset: 36, 112 | line: 1, 113 | column: 37, 114 | }, 115 | }, 116 | kind: 'media', 117 | children: [ 118 | { 119 | type: 'Feature', 120 | loc: { 121 | source: '', 122 | start: { 123 | offset: 18, 124 | line: 1, 125 | column: 19, 126 | }, 127 | end: { 128 | offset: 36, 129 | line: 1, 130 | column: 37, 131 | }, 132 | }, 133 | kind: 'media', 134 | name: 'min-width', 135 | value: { 136 | type: 'Dimension', 137 | loc: { 138 | source: '', 139 | start: { 140 | offset: 30, 141 | line: 1, 142 | column: 31, 143 | }, 144 | end: { 145 | offset: 35, 146 | line: 1, 147 | column: 36, 148 | }, 149 | }, 150 | value: '720', 151 | unit: 'px', 152 | }, 153 | }, 154 | ], 155 | }, 156 | }, 157 | ], 158 | }, 159 | ], 160 | }, 161 | ], 162 | }); 163 | 164 | // Complex media query 165 | expect( 166 | toPlainObject( 167 | parse('div:matches-media((min-height: 680px), screen and (orientation: portrait))', parserConfig), 168 | ), 169 | ).toStrictEqual({ 170 | type: 'Selector', 171 | loc: { 172 | source: '', 173 | start: { 174 | offset: 0, 175 | line: 1, 176 | column: 1, 177 | }, 178 | end: { 179 | offset: 74, 180 | line: 1, 181 | column: 75, 182 | }, 183 | }, 184 | children: [ 185 | { 186 | type: 'TypeSelector', 187 | loc: { 188 | source: '', 189 | start: { 190 | offset: 0, 191 | line: 1, 192 | column: 1, 193 | }, 194 | end: { 195 | offset: 3, 196 | line: 1, 197 | column: 4, 198 | }, 199 | }, 200 | name: 'div', 201 | }, 202 | { 203 | type: 'PseudoClassSelector', 204 | loc: { 205 | source: '', 206 | start: { 207 | offset: 3, 208 | line: 1, 209 | column: 4, 210 | }, 211 | end: { 212 | offset: 74, 213 | line: 1, 214 | column: 75, 215 | }, 216 | }, 217 | name: 'matches-media', 218 | children: [ 219 | { 220 | type: 'MediaQueryList', 221 | loc: { 222 | source: '', 223 | start: { 224 | offset: 18, 225 | line: 1, 226 | column: 19, 227 | }, 228 | end: { 229 | offset: 73, 230 | line: 1, 231 | column: 74, 232 | }, 233 | }, 234 | children: [ 235 | { 236 | type: 'MediaQuery', 237 | loc: { 238 | source: '', 239 | start: { 240 | offset: 18, 241 | line: 1, 242 | column: 19, 243 | }, 244 | end: { 245 | offset: 37, 246 | line: 1, 247 | column: 38, 248 | }, 249 | }, 250 | modifier: null, 251 | mediaType: null, 252 | condition: { 253 | type: 'Condition', 254 | loc: { 255 | source: '', 256 | start: { 257 | offset: 18, 258 | line: 1, 259 | column: 19, 260 | }, 261 | end: { 262 | offset: 37, 263 | line: 1, 264 | column: 38, 265 | }, 266 | }, 267 | kind: 'media', 268 | children: [ 269 | { 270 | type: 'Feature', 271 | loc: { 272 | source: '', 273 | start: { 274 | offset: 18, 275 | line: 1, 276 | column: 19, 277 | }, 278 | end: { 279 | offset: 37, 280 | line: 1, 281 | column: 38, 282 | }, 283 | }, 284 | kind: 'media', 285 | name: 'min-height', 286 | value: { 287 | type: 'Dimension', 288 | loc: { 289 | source: '', 290 | start: { 291 | offset: 31, 292 | line: 1, 293 | column: 32, 294 | }, 295 | end: { 296 | offset: 36, 297 | line: 1, 298 | column: 37, 299 | }, 300 | }, 301 | value: '680', 302 | unit: 'px', 303 | }, 304 | }, 305 | ], 306 | }, 307 | }, 308 | { 309 | type: 'MediaQuery', 310 | loc: { 311 | source: '', 312 | start: { 313 | offset: 38, 314 | line: 1, 315 | column: 39, 316 | }, 317 | end: { 318 | offset: 73, 319 | line: 1, 320 | column: 74, 321 | }, 322 | }, 323 | modifier: null, 324 | mediaType: 'screen', 325 | condition: { 326 | type: 'Condition', 327 | loc: { 328 | source: '', 329 | start: { 330 | offset: 50, 331 | line: 1, 332 | column: 51, 333 | }, 334 | end: { 335 | offset: 73, 336 | line: 1, 337 | column: 74, 338 | }, 339 | }, 340 | kind: 'media', 341 | children: [ 342 | { 343 | type: 'Feature', 344 | loc: { 345 | source: '', 346 | start: { 347 | offset: 50, 348 | line: 1, 349 | column: 51, 350 | }, 351 | end: { 352 | offset: 73, 353 | line: 1, 354 | column: 74, 355 | }, 356 | }, 357 | kind: 'media', 358 | name: 'orientation', 359 | value: { 360 | type: 'Identifier', 361 | loc: { 362 | source: '', 363 | start: { 364 | offset: 64, 365 | line: 1, 366 | column: 65, 367 | }, 368 | end: { 369 | offset: 72, 370 | line: 1, 371 | column: 73, 372 | }, 373 | }, 374 | name: 'portrait', 375 | }, 376 | }, 377 | ], 378 | }, 379 | }, 380 | ], 381 | }, 382 | ], 383 | }, 384 | ], 385 | }); 386 | }); 387 | 388 | test('generates valid input properly', () => { 389 | expect(generate(parse('div:matches-media((min-width: 720px))', parserConfig))).toEqual( 390 | 'div:matches-media((min-width:720px))', 391 | ); 392 | expect( 393 | generate(parse('div:matches-media((min-height: 680px), screen and (orientation: portrait))', parserConfig)), 394 | ).toEqual('div:matches-media((min-height:680px),screen and (orientation:portrait))'); 395 | }); 396 | }); 397 | -------------------------------------------------------------------------------- /test/syntax/style.test.js: -------------------------------------------------------------------------------- 1 | import { describe, expect, test } from 'vitest'; 2 | 3 | import { generate, parse, toPlainObject } from '../../src/index'; 4 | 5 | const parserConfig = { 6 | context: 'selector', 7 | positions: true, 8 | }; 9 | 10 | describe(':style()', () => { 11 | test('parses valid input properly', () => { 12 | // Simple style 13 | expect(toPlainObject(parse('div:style(padding: 0)', parserConfig))).toStrictEqual({ 14 | type: 'Selector', 15 | loc: { 16 | source: '', 17 | start: { 18 | offset: 0, 19 | line: 1, 20 | column: 1, 21 | }, 22 | end: { 23 | offset: 21, 24 | line: 1, 25 | column: 22, 26 | }, 27 | }, 28 | children: [ 29 | { 30 | type: 'TypeSelector', 31 | loc: { 32 | source: '', 33 | start: { 34 | offset: 0, 35 | line: 1, 36 | column: 1, 37 | }, 38 | end: { 39 | offset: 3, 40 | line: 1, 41 | column: 4, 42 | }, 43 | }, 44 | name: 'div', 45 | }, 46 | { 47 | type: 'PseudoClassSelector', 48 | loc: { 49 | source: '', 50 | start: { 51 | offset: 3, 52 | line: 1, 53 | column: 4, 54 | }, 55 | end: { 56 | offset: 21, 57 | line: 1, 58 | column: 22, 59 | }, 60 | }, 61 | name: 'style', 62 | children: [ 63 | { 64 | type: 'DeclarationList', 65 | loc: { 66 | source: '', 67 | start: { 68 | offset: 10, 69 | line: 1, 70 | column: 11, 71 | }, 72 | end: { 73 | offset: 20, 74 | line: 1, 75 | column: 21, 76 | }, 77 | }, 78 | children: [ 79 | { 80 | type: 'Declaration', 81 | loc: { 82 | source: '', 83 | start: { 84 | offset: 10, 85 | line: 1, 86 | column: 11, 87 | }, 88 | end: { 89 | offset: 20, 90 | line: 1, 91 | column: 21, 92 | }, 93 | }, 94 | important: false, 95 | property: 'padding', 96 | value: { 97 | type: 'Value', 98 | loc: { 99 | source: '', 100 | start: { 101 | offset: 19, 102 | line: 1, 103 | column: 20, 104 | }, 105 | end: { 106 | offset: 20, 107 | line: 1, 108 | column: 21, 109 | }, 110 | }, 111 | children: [ 112 | { 113 | type: 'Number', 114 | loc: { 115 | source: '', 116 | start: { 117 | offset: 19, 118 | line: 1, 119 | column: 20, 120 | }, 121 | end: { 122 | offset: 20, 123 | line: 1, 124 | column: 21, 125 | }, 126 | }, 127 | value: '0', 128 | }, 129 | ], 130 | }, 131 | }, 132 | ], 133 | }, 134 | ], 135 | }, 136 | ], 137 | }); 138 | 139 | // Semicolon at the end 140 | expect(toPlainObject(parse('div:style(padding: 0;)', parserConfig))).toStrictEqual({ 141 | type: 'Selector', 142 | loc: { 143 | source: '', 144 | start: { 145 | offset: 0, 146 | line: 1, 147 | column: 1, 148 | }, 149 | end: { 150 | offset: 22, 151 | line: 1, 152 | column: 23, 153 | }, 154 | }, 155 | children: [ 156 | { 157 | type: 'TypeSelector', 158 | loc: { 159 | source: '', 160 | start: { 161 | offset: 0, 162 | line: 1, 163 | column: 1, 164 | }, 165 | end: { 166 | offset: 3, 167 | line: 1, 168 | column: 4, 169 | }, 170 | }, 171 | name: 'div', 172 | }, 173 | { 174 | type: 'PseudoClassSelector', 175 | loc: { 176 | source: '', 177 | start: { 178 | offset: 3, 179 | line: 1, 180 | column: 4, 181 | }, 182 | end: { 183 | offset: 22, 184 | line: 1, 185 | column: 23, 186 | }, 187 | }, 188 | name: 'style', 189 | children: [ 190 | { 191 | type: 'DeclarationList', 192 | loc: { 193 | source: '', 194 | start: { 195 | offset: 10, 196 | line: 1, 197 | column: 11, 198 | }, 199 | end: { 200 | offset: 20, 201 | line: 1, 202 | column: 21, 203 | }, 204 | }, 205 | children: [ 206 | { 207 | type: 'Declaration', 208 | loc: { 209 | source: '', 210 | start: { 211 | offset: 10, 212 | line: 1, 213 | column: 11, 214 | }, 215 | end: { 216 | offset: 20, 217 | line: 1, 218 | column: 21, 219 | }, 220 | }, 221 | important: false, 222 | property: 'padding', 223 | value: { 224 | type: 'Value', 225 | loc: { 226 | source: '', 227 | start: { 228 | offset: 19, 229 | line: 1, 230 | column: 20, 231 | }, 232 | end: { 233 | offset: 20, 234 | line: 1, 235 | column: 21, 236 | }, 237 | }, 238 | children: [ 239 | { 240 | type: 'Number', 241 | loc: { 242 | source: '', 243 | start: { 244 | offset: 19, 245 | line: 1, 246 | column: 20, 247 | }, 248 | end: { 249 | offset: 20, 250 | line: 1, 251 | column: 21, 252 | }, 253 | }, 254 | value: '0', 255 | }, 256 | ], 257 | }, 258 | }, 259 | ], 260 | }, 261 | ], 262 | }, 263 | ], 264 | }); 265 | 266 | // Important style 267 | expect(toPlainObject(parse('div:style(padding: 0 !important)', parserConfig))).toStrictEqual({ 268 | type: 'Selector', 269 | loc: { 270 | source: '', 271 | start: { 272 | offset: 0, 273 | line: 1, 274 | column: 1, 275 | }, 276 | end: { 277 | offset: 32, 278 | line: 1, 279 | column: 33, 280 | }, 281 | }, 282 | children: [ 283 | { 284 | type: 'TypeSelector', 285 | loc: { 286 | source: '', 287 | start: { 288 | offset: 0, 289 | line: 1, 290 | column: 1, 291 | }, 292 | end: { 293 | offset: 3, 294 | line: 1, 295 | column: 4, 296 | }, 297 | }, 298 | name: 'div', 299 | }, 300 | { 301 | type: 'PseudoClassSelector', 302 | loc: { 303 | source: '', 304 | start: { 305 | offset: 3, 306 | line: 1, 307 | column: 4, 308 | }, 309 | end: { 310 | offset: 32, 311 | line: 1, 312 | column: 33, 313 | }, 314 | }, 315 | name: 'style', 316 | children: [ 317 | { 318 | type: 'DeclarationList', 319 | loc: { 320 | source: '', 321 | start: { 322 | offset: 10, 323 | line: 1, 324 | column: 11, 325 | }, 326 | end: { 327 | offset: 31, 328 | line: 1, 329 | column: 32, 330 | }, 331 | }, 332 | children: [ 333 | { 334 | type: 'Declaration', 335 | loc: { 336 | source: '', 337 | start: { 338 | offset: 10, 339 | line: 1, 340 | column: 11, 341 | }, 342 | end: { 343 | offset: 31, 344 | line: 1, 345 | column: 32, 346 | }, 347 | }, 348 | important: true, 349 | property: 'padding', 350 | value: { 351 | type: 'Value', 352 | loc: { 353 | source: '', 354 | start: { 355 | offset: 19, 356 | line: 1, 357 | column: 20, 358 | }, 359 | end: { 360 | offset: 21, 361 | line: 1, 362 | column: 22, 363 | }, 364 | }, 365 | children: [ 366 | { 367 | type: 'Number', 368 | loc: { 369 | source: '', 370 | start: { 371 | offset: 19, 372 | line: 1, 373 | column: 20, 374 | }, 375 | end: { 376 | offset: 20, 377 | line: 1, 378 | column: 21, 379 | }, 380 | }, 381 | value: '0', 382 | }, 383 | ], 384 | }, 385 | }, 386 | ], 387 | }, 388 | ], 389 | }, 390 | ], 391 | }); 392 | 393 | // Complex style 394 | expect( 395 | toPlainObject(parse('div:style(padding: 0 !important; margin: 0; color: black !important)', parserConfig)), 396 | ).toStrictEqual({ 397 | type: 'Selector', 398 | loc: { 399 | source: '', 400 | start: { 401 | offset: 0, 402 | line: 1, 403 | column: 1, 404 | }, 405 | end: { 406 | offset: 68, 407 | line: 1, 408 | column: 69, 409 | }, 410 | }, 411 | children: [ 412 | { 413 | type: 'TypeSelector', 414 | loc: { 415 | source: '', 416 | start: { 417 | offset: 0, 418 | line: 1, 419 | column: 1, 420 | }, 421 | end: { 422 | offset: 3, 423 | line: 1, 424 | column: 4, 425 | }, 426 | }, 427 | name: 'div', 428 | }, 429 | { 430 | type: 'PseudoClassSelector', 431 | loc: { 432 | source: '', 433 | start: { 434 | offset: 3, 435 | line: 1, 436 | column: 4, 437 | }, 438 | end: { 439 | offset: 68, 440 | line: 1, 441 | column: 69, 442 | }, 443 | }, 444 | name: 'style', 445 | children: [ 446 | { 447 | type: 'DeclarationList', 448 | loc: { 449 | source: '', 450 | start: { 451 | offset: 10, 452 | line: 1, 453 | column: 11, 454 | }, 455 | end: { 456 | offset: 67, 457 | line: 1, 458 | column: 68, 459 | }, 460 | }, 461 | children: [ 462 | { 463 | type: 'Declaration', 464 | loc: { 465 | source: '', 466 | start: { 467 | offset: 10, 468 | line: 1, 469 | column: 11, 470 | }, 471 | end: { 472 | offset: 31, 473 | line: 1, 474 | column: 32, 475 | }, 476 | }, 477 | important: true, 478 | property: 'padding', 479 | value: { 480 | type: 'Value', 481 | loc: { 482 | source: '', 483 | start: { 484 | offset: 19, 485 | line: 1, 486 | column: 20, 487 | }, 488 | end: { 489 | offset: 21, 490 | line: 1, 491 | column: 22, 492 | }, 493 | }, 494 | children: [ 495 | { 496 | type: 'Number', 497 | loc: { 498 | source: '', 499 | start: { 500 | offset: 19, 501 | line: 1, 502 | column: 20, 503 | }, 504 | end: { 505 | offset: 20, 506 | line: 1, 507 | column: 21, 508 | }, 509 | }, 510 | value: '0', 511 | }, 512 | ], 513 | }, 514 | }, 515 | { 516 | type: 'Declaration', 517 | loc: { 518 | source: '', 519 | start: { 520 | offset: 33, 521 | line: 1, 522 | column: 34, 523 | }, 524 | end: { 525 | offset: 42, 526 | line: 1, 527 | column: 43, 528 | }, 529 | }, 530 | important: false, 531 | property: 'margin', 532 | value: { 533 | type: 'Value', 534 | loc: { 535 | source: '', 536 | start: { 537 | offset: 41, 538 | line: 1, 539 | column: 42, 540 | }, 541 | end: { 542 | offset: 42, 543 | line: 1, 544 | column: 43, 545 | }, 546 | }, 547 | children: [ 548 | { 549 | type: 'Number', 550 | loc: { 551 | source: '', 552 | start: { 553 | offset: 41, 554 | line: 1, 555 | column: 42, 556 | }, 557 | end: { 558 | offset: 42, 559 | line: 1, 560 | column: 43, 561 | }, 562 | }, 563 | value: '0', 564 | }, 565 | ], 566 | }, 567 | }, 568 | { 569 | type: 'Declaration', 570 | loc: { 571 | source: '', 572 | start: { 573 | offset: 44, 574 | line: 1, 575 | column: 45, 576 | }, 577 | end: { 578 | offset: 67, 579 | line: 1, 580 | column: 68, 581 | }, 582 | }, 583 | important: true, 584 | property: 'color', 585 | value: { 586 | type: 'Value', 587 | loc: { 588 | source: '', 589 | start: { 590 | offset: 51, 591 | line: 1, 592 | column: 52, 593 | }, 594 | end: { 595 | offset: 57, 596 | line: 1, 597 | column: 58, 598 | }, 599 | }, 600 | children: [ 601 | { 602 | type: 'Identifier', 603 | loc: { 604 | source: '', 605 | start: { 606 | offset: 51, 607 | line: 1, 608 | column: 52, 609 | }, 610 | end: { 611 | offset: 56, 612 | line: 1, 613 | column: 57, 614 | }, 615 | }, 616 | name: 'black', 617 | }, 618 | ], 619 | }, 620 | }, 621 | ], 622 | }, 623 | ], 624 | }, 625 | ], 626 | }); 627 | }); 628 | 629 | test('generates valid input properly', () => { 630 | expect(generate(parse('div:style(padding: 0)', parserConfig))).toEqual('div:style(padding:0)'); 631 | expect(generate(parse('div:style(padding: 0;)', parserConfig))).toEqual('div:style(padding:0)'); 632 | expect(generate(parse('div:style(padding: 0 !important)', parserConfig))).toEqual( 633 | 'div:style(padding:0!important)', 634 | ); 635 | expect( 636 | generate(parse('div:style(padding: 0 !important; margin: 0; color: black !important)', parserConfig)), 637 | ).toEqual('div:style(padding:0!important;margin:0;color:black!important)'); 638 | }); 639 | }); 640 | -------------------------------------------------------------------------------- /test/syntax/contains.test.js: -------------------------------------------------------------------------------- 1 | // Tests for :contains(), :-abp-contains() and :has-text() pseudo-classes 2 | import { describe, expect, test } from 'vitest'; 3 | 4 | import { generate, parse, toPlainObject } from '../../src/index'; 5 | 6 | const parserConfig = { 7 | context: 'selector', 8 | positions: true, 9 | }; 10 | 11 | describe(':contains()', () => { 12 | test('throws on invalid input', () => { 13 | expect(() => parse(':contains(a', parserConfig)).toThrow(); 14 | expect(() => parse(":contains(a'", parserConfig)).toThrow(); 15 | 16 | expect(() => parse(':-abp-contains(a', parserConfig)).toThrow(); 17 | expect(() => parse(":-abp-contains(a'", parserConfig)).toThrow(); 18 | 19 | expect(() => parse(':has-text(a', parserConfig)).toThrow(); 20 | expect(() => parse(":has-text(a'", parserConfig)).toThrow(); 21 | }); 22 | 23 | test('parses valid input properly', () => { 24 | // One whitespace 25 | expect(toPlainObject(parse(':contains( )', parserConfig))).toStrictEqual({ 26 | type: 'Selector', 27 | loc: { 28 | source: '', 29 | start: { 30 | offset: 0, 31 | line: 1, 32 | column: 1, 33 | }, 34 | end: { 35 | offset: 12, 36 | line: 1, 37 | column: 13, 38 | }, 39 | }, 40 | children: [ 41 | { 42 | type: 'PseudoClassSelector', 43 | loc: { 44 | source: '', 45 | start: { 46 | offset: 0, 47 | line: 1, 48 | column: 1, 49 | }, 50 | end: { 51 | offset: 12, 52 | line: 1, 53 | column: 13, 54 | }, 55 | }, 56 | name: 'contains', 57 | children: [ 58 | { 59 | type: 'Raw', 60 | loc: { 61 | source: '', 62 | start: { 63 | offset: 10, 64 | line: 1, 65 | column: 11, 66 | }, 67 | end: { 68 | offset: 11, 69 | line: 1, 70 | column: 12, 71 | }, 72 | }, 73 | value: ' ', 74 | }, 75 | ], 76 | }, 77 | ], 78 | }); 79 | 80 | // Two whitespaces 81 | expect(toPlainObject(parse(':contains( )', parserConfig))).toStrictEqual({ 82 | type: 'Selector', 83 | loc: { 84 | source: '', 85 | start: { 86 | offset: 0, 87 | line: 1, 88 | column: 1, 89 | }, 90 | end: { 91 | offset: 13, 92 | line: 1, 93 | column: 14, 94 | }, 95 | }, 96 | children: [ 97 | { 98 | type: 'PseudoClassSelector', 99 | loc: { 100 | source: '', 101 | start: { 102 | offset: 0, 103 | line: 1, 104 | column: 1, 105 | }, 106 | end: { 107 | offset: 13, 108 | line: 1, 109 | column: 14, 110 | }, 111 | }, 112 | name: 'contains', 113 | children: [ 114 | { 115 | type: 'Raw', 116 | loc: { 117 | source: '', 118 | start: { 119 | offset: 10, 120 | line: 1, 121 | column: 11, 122 | }, 123 | end: { 124 | offset: 12, 125 | line: 1, 126 | column: 13, 127 | }, 128 | }, 129 | value: ' ', 130 | }, 131 | ], 132 | }, 133 | ], 134 | }); 135 | 136 | // Very simple input 137 | expect(toPlainObject(parse(':contains(aaa)', parserConfig))).toStrictEqual({ 138 | type: 'Selector', 139 | loc: { 140 | source: '', 141 | start: { 142 | offset: 0, 143 | line: 1, 144 | column: 1, 145 | }, 146 | end: { 147 | offset: 14, 148 | line: 1, 149 | column: 15, 150 | }, 151 | }, 152 | children: [ 153 | { 154 | type: 'PseudoClassSelector', 155 | loc: { 156 | source: '', 157 | start: { 158 | offset: 0, 159 | line: 1, 160 | column: 1, 161 | }, 162 | end: { 163 | offset: 14, 164 | line: 1, 165 | column: 15, 166 | }, 167 | }, 168 | name: 'contains', 169 | children: [ 170 | { 171 | type: 'Raw', 172 | loc: { 173 | source: '', 174 | start: { 175 | offset: 10, 176 | line: 1, 177 | column: 11, 178 | }, 179 | end: { 180 | offset: 13, 181 | line: 1, 182 | column: 14, 183 | }, 184 | }, 185 | value: 'aaa', 186 | }, 187 | ], 188 | }, 189 | ], 190 | }); 191 | 192 | // Space before input 193 | expect(toPlainObject(parse(':contains( aaa)', parserConfig))).toStrictEqual({ 194 | type: 'Selector', 195 | loc: { 196 | source: '', 197 | start: { 198 | offset: 0, 199 | line: 1, 200 | column: 1, 201 | }, 202 | end: { 203 | offset: 15, 204 | line: 1, 205 | column: 16, 206 | }, 207 | }, 208 | children: [ 209 | { 210 | type: 'PseudoClassSelector', 211 | loc: { 212 | source: '', 213 | start: { 214 | offset: 0, 215 | line: 1, 216 | column: 1, 217 | }, 218 | end: { 219 | offset: 15, 220 | line: 1, 221 | column: 16, 222 | }, 223 | }, 224 | name: 'contains', 225 | children: [ 226 | { 227 | type: 'Raw', 228 | loc: { 229 | source: '', 230 | start: { 231 | offset: 10, 232 | line: 1, 233 | column: 11, 234 | }, 235 | end: { 236 | offset: 14, 237 | line: 1, 238 | column: 15, 239 | }, 240 | }, 241 | value: ' aaa', 242 | }, 243 | ], 244 | }, 245 | ], 246 | }); 247 | 248 | // Space after input 249 | expect(toPlainObject(parse(':contains(aaa )', parserConfig))).toStrictEqual({ 250 | type: 'Selector', 251 | loc: { 252 | source: '', 253 | start: { 254 | offset: 0, 255 | line: 1, 256 | column: 1, 257 | }, 258 | end: { 259 | offset: 15, 260 | line: 1, 261 | column: 16, 262 | }, 263 | }, 264 | children: [ 265 | { 266 | type: 'PseudoClassSelector', 267 | loc: { 268 | source: '', 269 | start: { 270 | offset: 0, 271 | line: 1, 272 | column: 1, 273 | }, 274 | end: { 275 | offset: 15, 276 | line: 1, 277 | column: 16, 278 | }, 279 | }, 280 | name: 'contains', 281 | children: [ 282 | { 283 | type: 'Raw', 284 | loc: { 285 | source: '', 286 | start: { 287 | offset: 10, 288 | line: 1, 289 | column: 11, 290 | }, 291 | end: { 292 | offset: 14, 293 | line: 1, 294 | column: 15, 295 | }, 296 | }, 297 | value: 'aaa ', 298 | }, 299 | ], 300 | }, 301 | ], 302 | }); 303 | 304 | // Space before and after input 305 | expect(toPlainObject(parse(':contains( aaa )', parserConfig))).toStrictEqual({ 306 | type: 'Selector', 307 | loc: { 308 | source: '', 309 | start: { 310 | offset: 0, 311 | line: 1, 312 | column: 1, 313 | }, 314 | end: { 315 | offset: 16, 316 | line: 1, 317 | column: 17, 318 | }, 319 | }, 320 | children: [ 321 | { 322 | type: 'PseudoClassSelector', 323 | loc: { 324 | source: '', 325 | start: { 326 | offset: 0, 327 | line: 1, 328 | column: 1, 329 | }, 330 | end: { 331 | offset: 16, 332 | line: 1, 333 | column: 17, 334 | }, 335 | }, 336 | name: 'contains', 337 | children: [ 338 | { 339 | type: 'Raw', 340 | loc: { 341 | source: '', 342 | start: { 343 | offset: 10, 344 | line: 1, 345 | column: 11, 346 | }, 347 | end: { 348 | offset: 15, 349 | line: 1, 350 | column: 16, 351 | }, 352 | }, 353 | value: ' aaa ', 354 | }, 355 | ], 356 | }, 357 | ], 358 | }); 359 | 360 | // Space before and after input, with space in input 361 | expect(toPlainObject(parse(':contains( aaa bbb )', parserConfig))).toStrictEqual({ 362 | type: 'Selector', 363 | loc: { 364 | source: '', 365 | start: { 366 | offset: 0, 367 | line: 1, 368 | column: 1, 369 | }, 370 | end: { 371 | offset: 23, 372 | line: 1, 373 | column: 24, 374 | }, 375 | }, 376 | children: [ 377 | { 378 | type: 'PseudoClassSelector', 379 | loc: { 380 | source: '', 381 | start: { 382 | offset: 0, 383 | line: 1, 384 | column: 1, 385 | }, 386 | end: { 387 | offset: 23, 388 | line: 1, 389 | column: 24, 390 | }, 391 | }, 392 | name: 'contains', 393 | children: [ 394 | { 395 | type: 'Raw', 396 | loc: { 397 | source: '', 398 | start: { 399 | offset: 10, 400 | line: 1, 401 | column: 11, 402 | }, 403 | end: { 404 | offset: 22, 405 | line: 1, 406 | column: 23, 407 | }, 408 | }, 409 | value: ' aaa bbb ', 410 | }, 411 | ], 412 | }, 413 | ], 414 | }); 415 | 416 | // Space in input 417 | expect(toPlainObject(parse(':contains(aaa bbb ccc)', parserConfig))).toStrictEqual({ 418 | type: 'Selector', 419 | loc: { 420 | source: '', 421 | start: { 422 | offset: 0, 423 | line: 1, 424 | column: 1, 425 | }, 426 | end: { 427 | offset: 22, 428 | line: 1, 429 | column: 23, 430 | }, 431 | }, 432 | children: [ 433 | { 434 | type: 'PseudoClassSelector', 435 | loc: { 436 | source: '', 437 | start: { 438 | offset: 0, 439 | line: 1, 440 | column: 1, 441 | }, 442 | end: { 443 | offset: 22, 444 | line: 1, 445 | column: 23, 446 | }, 447 | }, 448 | name: 'contains', 449 | children: [ 450 | { 451 | type: 'Raw', 452 | loc: { 453 | source: '', 454 | start: { 455 | offset: 10, 456 | line: 1, 457 | column: 11, 458 | }, 459 | end: { 460 | offset: 21, 461 | line: 1, 462 | column: 22, 463 | }, 464 | }, 465 | value: 'aaa bbb ccc', 466 | }, 467 | ], 468 | }, 469 | ], 470 | }); 471 | 472 | // Parenthesis in input 473 | expect(toPlainObject(parse(':contains((aaa))', parserConfig))).toStrictEqual({ 474 | type: 'Selector', 475 | loc: { 476 | source: '', 477 | start: { 478 | offset: 0, 479 | line: 1, 480 | column: 1, 481 | }, 482 | end: { 483 | offset: 16, 484 | line: 1, 485 | column: 17, 486 | }, 487 | }, 488 | children: [ 489 | { 490 | type: 'PseudoClassSelector', 491 | loc: { 492 | source: '', 493 | start: { 494 | offset: 0, 495 | line: 1, 496 | column: 1, 497 | }, 498 | end: { 499 | offset: 16, 500 | line: 1, 501 | column: 17, 502 | }, 503 | }, 504 | name: 'contains', 505 | children: [ 506 | { 507 | type: 'Raw', 508 | loc: { 509 | source: '', 510 | start: { 511 | offset: 10, 512 | line: 1, 513 | column: 11, 514 | }, 515 | end: { 516 | offset: 15, 517 | line: 1, 518 | column: 16, 519 | }, 520 | }, 521 | value: '(aaa)', 522 | }, 523 | ], 524 | }, 525 | ], 526 | }); 527 | 528 | // Parenthesis in input, but a bit more complex 529 | expect(toPlainObject(parse(':contains((aaa)(bbb)\\)\\()', parserConfig))).toStrictEqual({ 530 | type: 'Selector', 531 | loc: { 532 | source: '', 533 | start: { 534 | offset: 0, 535 | line: 1, 536 | column: 1, 537 | }, 538 | end: { 539 | offset: 25, 540 | line: 1, 541 | column: 26, 542 | }, 543 | }, 544 | children: [ 545 | { 546 | type: 'PseudoClassSelector', 547 | loc: { 548 | source: '', 549 | start: { 550 | offset: 0, 551 | line: 1, 552 | column: 1, 553 | }, 554 | end: { 555 | offset: 25, 556 | line: 1, 557 | column: 26, 558 | }, 559 | }, 560 | name: 'contains', 561 | children: [ 562 | { 563 | type: 'Raw', 564 | loc: { 565 | source: '', 566 | start: { 567 | offset: 10, 568 | line: 1, 569 | column: 11, 570 | }, 571 | end: { 572 | offset: 24, 573 | line: 1, 574 | column: 25, 575 | }, 576 | }, 577 | value: '(aaa)(bbb)\\)\\(', 578 | }, 579 | ], 580 | }, 581 | ], 582 | }); 583 | 584 | // Regular expression 585 | expect(toPlainObject(parse(':contains(/aaa/)', parserConfig))).toStrictEqual({ 586 | type: 'Selector', 587 | loc: { 588 | source: '', 589 | start: { 590 | offset: 0, 591 | line: 1, 592 | column: 1, 593 | }, 594 | end: { 595 | offset: 16, 596 | line: 1, 597 | column: 17, 598 | }, 599 | }, 600 | children: [ 601 | { 602 | type: 'PseudoClassSelector', 603 | loc: { 604 | source: '', 605 | start: { 606 | offset: 0, 607 | line: 1, 608 | column: 1, 609 | }, 610 | end: { 611 | offset: 16, 612 | line: 1, 613 | column: 17, 614 | }, 615 | }, 616 | name: 'contains', 617 | children: [ 618 | { 619 | type: 'Raw', 620 | loc: { 621 | source: '', 622 | start: { 623 | offset: 10, 624 | line: 1, 625 | column: 11, 626 | }, 627 | end: { 628 | offset: 15, 629 | line: 1, 630 | column: 16, 631 | }, 632 | }, 633 | value: '/aaa/', 634 | }, 635 | ], 636 | }, 637 | ], 638 | }); 639 | 640 | // Regular expression with flags 641 | expect(toPlainObject(parse(':contains(/aaa/i)', parserConfig))).toStrictEqual({ 642 | type: 'Selector', 643 | loc: { 644 | source: '', 645 | start: { 646 | offset: 0, 647 | line: 1, 648 | column: 1, 649 | }, 650 | end: { 651 | offset: 17, 652 | line: 1, 653 | column: 18, 654 | }, 655 | }, 656 | children: [ 657 | { 658 | type: 'PseudoClassSelector', 659 | loc: { 660 | source: '', 661 | start: { 662 | offset: 0, 663 | line: 1, 664 | column: 1, 665 | }, 666 | end: { 667 | offset: 17, 668 | line: 1, 669 | column: 18, 670 | }, 671 | }, 672 | name: 'contains', 673 | children: [ 674 | { 675 | type: 'Raw', 676 | loc: { 677 | source: '', 678 | start: { 679 | offset: 10, 680 | line: 1, 681 | column: 11, 682 | }, 683 | end: { 684 | offset: 16, 685 | line: 1, 686 | column: 17, 687 | }, 688 | }, 689 | value: '/aaa/i', 690 | }, 691 | ], 692 | }, 693 | ], 694 | }); 695 | 696 | // Regular expression with parentheses 697 | expect(toPlainObject(parse(':contains(/^(a|b){3,}$/)', parserConfig))).toStrictEqual({ 698 | type: 'Selector', 699 | loc: { 700 | source: '', 701 | start: { 702 | offset: 0, 703 | line: 1, 704 | column: 1, 705 | }, 706 | end: { 707 | offset: 24, 708 | line: 1, 709 | column: 25, 710 | }, 711 | }, 712 | children: [ 713 | { 714 | type: 'PseudoClassSelector', 715 | loc: { 716 | source: '', 717 | start: { 718 | offset: 0, 719 | line: 1, 720 | column: 1, 721 | }, 722 | end: { 723 | offset: 24, 724 | line: 1, 725 | column: 25, 726 | }, 727 | }, 728 | name: 'contains', 729 | children: [ 730 | { 731 | type: 'Raw', 732 | loc: { 733 | source: '', 734 | start: { 735 | offset: 10, 736 | line: 1, 737 | column: 11, 738 | }, 739 | end: { 740 | offset: 23, 741 | line: 1, 742 | column: 24, 743 | }, 744 | }, 745 | value: '/^(a|b){3,}$/', 746 | }, 747 | ], 748 | }, 749 | ], 750 | }); 751 | 752 | // Regular expression with escaped parentheses 753 | expect(toPlainObject(parse(':contains(/aaa\\(\\)/i)', parserConfig))).toStrictEqual({ 754 | type: 'Selector', 755 | loc: { 756 | source: '', 757 | start: { 758 | offset: 0, 759 | line: 1, 760 | column: 1, 761 | }, 762 | end: { 763 | offset: 21, 764 | line: 1, 765 | column: 22, 766 | }, 767 | }, 768 | children: [ 769 | { 770 | type: 'PseudoClassSelector', 771 | loc: { 772 | source: '', 773 | start: { 774 | offset: 0, 775 | line: 1, 776 | column: 1, 777 | }, 778 | end: { 779 | offset: 21, 780 | line: 1, 781 | column: 22, 782 | }, 783 | }, 784 | name: 'contains', 785 | children: [ 786 | { 787 | type: 'Raw', 788 | loc: { 789 | source: '', 790 | start: { 791 | offset: 10, 792 | line: 1, 793 | column: 11, 794 | }, 795 | end: { 796 | offset: 20, 797 | line: 1, 798 | column: 21, 799 | }, 800 | }, 801 | value: '/aaa\\(\\)/i', 802 | }, 803 | ], 804 | }, 805 | ], 806 | }); 807 | 808 | // Single quote mark within the string 809 | expect(toPlainObject(parse(":contains(aaa'bbb)", parserConfig))).toStrictEqual({ 810 | type: 'Selector', 811 | loc: { 812 | source: '', 813 | start: { 814 | offset: 0, 815 | line: 1, 816 | column: 1, 817 | }, 818 | end: { 819 | offset: 18, 820 | line: 1, 821 | column: 19, 822 | }, 823 | }, 824 | children: [ 825 | { 826 | type: 'PseudoClassSelector', 827 | loc: { 828 | source: '', 829 | start: { 830 | offset: 0, 831 | line: 1, 832 | column: 1, 833 | }, 834 | end: { 835 | offset: 18, 836 | line: 1, 837 | column: 19, 838 | }, 839 | }, 840 | name: 'contains', 841 | children: [ 842 | { 843 | type: 'Raw', 844 | loc: { 845 | source: '', 846 | start: { 847 | offset: 10, 848 | line: 1, 849 | column: 11, 850 | }, 851 | end: { 852 | offset: 17, 853 | line: 1, 854 | column: 18, 855 | }, 856 | }, 857 | value: "aaa'bbb", 858 | }, 859 | ], 860 | }, 861 | ], 862 | }); 863 | 864 | // Double quote mark within the string 865 | expect(toPlainObject(parse(':contains(aaa"bbb)', parserConfig))).toStrictEqual({ 866 | type: 'Selector', 867 | loc: { 868 | source: '', 869 | start: { 870 | offset: 0, 871 | line: 1, 872 | column: 1, 873 | }, 874 | end: { 875 | offset: 18, 876 | line: 1, 877 | column: 19, 878 | }, 879 | }, 880 | children: [ 881 | { 882 | type: 'PseudoClassSelector', 883 | loc: { 884 | source: '', 885 | start: { 886 | offset: 0, 887 | line: 1, 888 | column: 1, 889 | }, 890 | end: { 891 | offset: 18, 892 | line: 1, 893 | column: 19, 894 | }, 895 | }, 896 | name: 'contains', 897 | children: [ 898 | { 899 | type: 'Raw', 900 | loc: { 901 | source: '', 902 | start: { 903 | offset: 10, 904 | line: 1, 905 | column: 11, 906 | }, 907 | end: { 908 | offset: 17, 909 | line: 1, 910 | column: 18, 911 | }, 912 | }, 913 | value: 'aaa"bbb', 914 | }, 915 | ], 916 | }, 917 | ], 918 | }); 919 | 920 | // Functions 921 | expect(toPlainObject(parse(":contains(function(another('')))", parserConfig))).toStrictEqual({ 922 | type: 'Selector', 923 | loc: { 924 | source: '', 925 | start: { 926 | offset: 0, 927 | line: 1, 928 | column: 1, 929 | }, 930 | end: { 931 | offset: 32, 932 | line: 1, 933 | column: 33, 934 | }, 935 | }, 936 | children: [ 937 | { 938 | type: 'PseudoClassSelector', 939 | loc: { 940 | source: '', 941 | start: { 942 | offset: 0, 943 | line: 1, 944 | column: 1, 945 | }, 946 | end: { 947 | offset: 32, 948 | line: 1, 949 | column: 33, 950 | }, 951 | }, 952 | name: 'contains', 953 | children: [ 954 | { 955 | type: 'Raw', 956 | loc: { 957 | source: '', 958 | start: { 959 | offset: 10, 960 | line: 1, 961 | column: 11, 962 | }, 963 | end: { 964 | offset: 31, 965 | line: 1, 966 | column: 32, 967 | }, 968 | }, 969 | value: "function(another(''))", 970 | }, 971 | ], 972 | }, 973 | ], 974 | }); 975 | }); 976 | 977 | test('generates valid input properly', () => { 978 | expect(generate(parse(':contains( )', parserConfig))).toEqual(':contains( )'); 979 | expect(generate(parse(':contains( )', parserConfig))).toEqual(':contains( )'); 980 | 981 | expect(generate(parse(':contains(aaa)', parserConfig))).toEqual(':contains(aaa)'); 982 | expect(generate(parse(':contains( aaa)', parserConfig))).toEqual(':contains( aaa)'); 983 | expect(generate(parse(':contains(aaa )', parserConfig))).toEqual(':contains(aaa )'); 984 | expect(generate(parse(':contains( aaa )', parserConfig))).toEqual(':contains( aaa )'); 985 | expect(generate(parse(':contains( aaa bbb )', parserConfig))).toEqual(':contains( aaa bbb )'); 986 | expect(generate(parse(':contains( aaa bbb )', parserConfig))).toEqual(':contains( aaa bbb )'); 987 | expect(generate(parse(':contains( aaa bbb ccc )', parserConfig))).toEqual(':contains( aaa bbb ccc )'); 988 | 989 | expect(generate(parse(':contains((aaa))', parserConfig))).toEqual(':contains((aaa))'); 990 | // eslint-disable-next-line max-len 991 | // TODO: "(aaa)(bbb)\\)\\("" is generated as "(aaa)(bbb) \\)\\(", but it should be "(aaa)(bbb)\\)\\(" - CSSTree related issue 992 | // expect(generate(parse(`:contains((aaa)(bbb)\\)\\()`, parserConfig))).toEqual(`:contains((aaa)(bbb)\\)\\()`); 993 | 994 | expect(generate(parse(':contains(/aaa/)', parserConfig))).toEqual(':contains(/aaa/)'); 995 | expect(generate(parse(':contains(/aaa/i)', parserConfig))).toEqual(':contains(/aaa/i)'); 996 | expect(generate(parse(':contains(/^(a|b){3,}$/)', parserConfig))).toEqual(':contains(/^(a|b){3,}$/)'); 997 | expect(generate(parse(':contains(/aaa\\(\\)/i)', parserConfig))).toEqual(':contains(/aaa\\(\\)/i)'); 998 | 999 | expect(generate(parse(":contains(aaa'bbb)", parserConfig))).toEqual(":contains(aaa'bbb)"); 1000 | expect(generate(parse(':contains(aaa"bbb)', parserConfig))).toEqual(':contains(aaa"bbb)'); 1001 | 1002 | // :-abp-contains alias 1003 | expect(generate(parse(':-abp-contains( )', parserConfig))).toEqual(':-abp-contains( )'); 1004 | expect(generate(parse(':-abp-contains( )', parserConfig))).toEqual(':-abp-contains( )'); 1005 | 1006 | expect(generate(parse(':-abp-contains(aaa)', parserConfig))).toEqual(':-abp-contains(aaa)'); 1007 | expect(generate(parse(':-abp-contains( aaa)', parserConfig))).toEqual(':-abp-contains( aaa)'); 1008 | expect(generate(parse(':-abp-contains(aaa )', parserConfig))).toEqual(':-abp-contains(aaa )'); 1009 | expect(generate(parse(':-abp-contains( aaa )', parserConfig))).toEqual(':-abp-contains( aaa )'); 1010 | expect(generate(parse(':-abp-contains( aaa bbb )', parserConfig))).toEqual(':-abp-contains( aaa bbb )'); 1011 | expect(generate(parse(':-abp-contains( aaa bbb )', parserConfig))).toEqual(':-abp-contains( aaa bbb )'); 1012 | expect(generate(parse(':-abp-contains( aaa bbb ccc )', parserConfig))).toEqual( 1013 | ':-abp-contains( aaa bbb ccc )', 1014 | ); 1015 | 1016 | expect(generate(parse(':-abp-contains((aaa))', parserConfig))).toEqual(':-abp-contains((aaa))'); 1017 | 1018 | expect(generate(parse(':-abp-contains(/aaa/)', parserConfig))).toEqual(':-abp-contains(/aaa/)'); 1019 | expect(generate(parse(':-abp-contains(/aaa/i)', parserConfig))).toEqual(':-abp-contains(/aaa/i)'); 1020 | expect(generate(parse(':-abp-contains(/^(a|b){3,}$/)', parserConfig))).toEqual(':-abp-contains(/^(a|b){3,}$/)'); 1021 | expect(generate(parse(':-abp-contains(/aaa\\(\\)/i)', parserConfig))).toEqual(':-abp-contains(/aaa\\(\\)/i)'); 1022 | 1023 | expect(generate(parse(":-abp-contains(aaa'bbb)", parserConfig))).toEqual(":-abp-contains(aaa'bbb)"); 1024 | expect(generate(parse(':-abp-contains(aaa"bbb)', parserConfig))).toEqual(':-abp-contains(aaa"bbb)'); 1025 | 1026 | // :has-text alias 1027 | expect(generate(parse(':has-text( )', parserConfig))).toEqual(':has-text( )'); 1028 | expect(generate(parse(':has-text( )', parserConfig))).toEqual(':has-text( )'); 1029 | 1030 | expect(generate(parse(':has-text(aaa)', parserConfig))).toEqual(':has-text(aaa)'); 1031 | expect(generate(parse(':has-text( aaa)', parserConfig))).toEqual(':has-text( aaa)'); 1032 | expect(generate(parse(':has-text(aaa )', parserConfig))).toEqual(':has-text(aaa )'); 1033 | expect(generate(parse(':has-text( aaa )', parserConfig))).toEqual(':has-text( aaa )'); 1034 | expect(generate(parse(':has-text( aaa bbb )', parserConfig))).toEqual(':has-text( aaa bbb )'); 1035 | expect(generate(parse(':has-text( aaa bbb )', parserConfig))).toEqual(':has-text( aaa bbb )'); 1036 | expect(generate(parse(':has-text( aaa bbb ccc )', parserConfig))).toEqual(':has-text( aaa bbb ccc )'); 1037 | 1038 | expect(generate(parse(':has-text((aaa))', parserConfig))).toEqual(':has-text((aaa))'); 1039 | 1040 | expect(generate(parse(':has-text(/aaa/)', parserConfig))).toEqual(':has-text(/aaa/)'); 1041 | expect(generate(parse(':has-text(/aaa/i)', parserConfig))).toEqual(':has-text(/aaa/i)'); 1042 | expect(generate(parse(':has-text(/^(a|b){3,}$/)', parserConfig))).toEqual(':has-text(/^(a|b){3,}$/)'); 1043 | expect(generate(parse(':has-text(/aaa\\(\\)/i)', parserConfig))).toEqual(':has-text(/aaa\\(\\)/i)'); 1044 | 1045 | expect(generate(parse(":has-text(aaa'bbb)", parserConfig))).toEqual(":has-text(aaa'bbb)"); 1046 | expect(generate(parse(':has-text(aaa"bbb)', parserConfig))).toEqual(':has-text(aaa"bbb)'); 1047 | }); 1048 | }); 1049 | --------------------------------------------------------------------------------