├── .prettierignore ├── .github ├── FUNDING.yml ├── dependabot.yml └── workflows │ ├── codeql-analysis.yml │ ├── dependabot-automerge.yml │ └── nodejs-test.yml ├── .gitignore ├── tsconfig.eslint.json ├── src ├── index.ts ├── stringify.spec.ts ├── parse.spec.ts ├── types.ts ├── stringify.ts ├── wpt.spec.ts ├── parse.ts └── __fixtures__ │ └── tests.ts ├── tsconfig.es.json ├── LICENSE ├── tsconfig.json ├── package.json ├── .eslintrc.json └── readme.md /.prettierignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | coverage/ 3 | lib/ 4 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: [fb55] 2 | tidelift: npm/css-what 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | coverage/ 3 | dist/ 4 | .tshy 5 | .tshy-build/ 6 | -------------------------------------------------------------------------------- /tsconfig.eslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "include": ["src"], 4 | "exclude": [] 5 | } 6 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./types.js"; 2 | export { isTraversal, parse } from "./parse.js"; 3 | export { stringify } from "./stringify.js"; 4 | -------------------------------------------------------------------------------- /tsconfig.es.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "target": "ES2019", 5 | "module": "es2015", 6 | "outDir": "lib/es", 7 | "moduleResolution": "node" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: npm 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | open-pull-requests-limit: 10 8 | versioning-strategy: increase 9 | - package-ecosystem: "github-actions" 10 | directory: "/" 11 | schedule: 12 | interval: daily 13 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | name: "CodeQL" 2 | 3 | on: 4 | push: 5 | branches: [master] 6 | pull_request: 7 | # The branches below must be a subset of the branches above 8 | branches: [master] 9 | schedule: 10 | - cron: "0 0 * * 0" 11 | 12 | jobs: 13 | analyze: 14 | name: Analyze 15 | runs-on: ubuntu-latest 16 | permissions: 17 | actions: read 18 | contents: read 19 | security-events: write 20 | 21 | steps: 22 | - name: Checkout repository 23 | uses: actions/checkout@v5 24 | 25 | - name: Initialize CodeQL 26 | uses: github/codeql-action/init@v4 27 | with: 28 | languages: "javascript" 29 | 30 | - name: Perform CodeQL Analysis 31 | uses: github/codeql-action/analyze@v4 32 | -------------------------------------------------------------------------------- /src/stringify.spec.ts: -------------------------------------------------------------------------------- 1 | import { readFileSync } from "node:fs"; 2 | import { describe, it, expect } from "vitest"; 3 | import { parse, stringify } from "./index.js"; 4 | import { tests } from "./__fixtures__/tests.js"; 5 | 6 | describe("Stringify & re-parse", () => { 7 | describe("Own tests", () => { 8 | for (const [selector, expected, message] of tests) { 9 | it(`${message} (${selector})`, () => { 10 | expect(parse(stringify(expected))).toStrictEqual(expected); 11 | }); 12 | } 13 | }); 14 | 15 | it("Collected Selectors (qwery, sizzle, nwmatcher)", () => { 16 | const out = JSON.parse( 17 | readFileSync(`${__dirname}/__fixtures__/out.json`, "utf8"), 18 | ); 19 | for (const s of Object.keys(out)) { 20 | expect(parse(stringify(out[s]))).toStrictEqual(out[s]); 21 | } 22 | }); 23 | }); 24 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) Felix Böhm 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 5 | 6 | Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 7 | 8 | Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. 9 | 10 | THIS IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS, 11 | EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 12 | -------------------------------------------------------------------------------- /.github/workflows/dependabot-automerge.yml: -------------------------------------------------------------------------------- 1 | # Based on https://docs.github.com/en/code-security/supply-chain-security/keeping-your-dependencies-updated-automatically/automating-dependabot-with-github-actions#enable-auto-merge-on-a-pull-request 2 | name: Dependabot auto-merge 3 | on: pull_request_target 4 | 5 | permissions: 6 | pull-requests: write 7 | contents: write 8 | 9 | jobs: 10 | dependabot: 11 | runs-on: ubuntu-latest 12 | if: ${{ github.actor == 'dependabot[bot]' }} 13 | steps: 14 | - name: Dependabot metadata 15 | id: metadata 16 | uses: dependabot/fetch-metadata@v2.4.0 17 | with: 18 | github-token: "${{ secrets.GITHUB_TOKEN }}" 19 | - name: Enable auto-merge for Dependabot PRs 20 | # Automatically merge semver-patch and semver-minor PRs 21 | if: "${{ steps.metadata.outputs.update-type == 22 | 'version-update:semver-minor' || 23 | steps.metadata.outputs.update-type == 24 | 'version-update:semver-patch' }}" 25 | run: gh pr merge --auto --squash "$PR_URL" 26 | env: 27 | PR_URL: ${{github.event.pull_request.html_url}} 28 | GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} 29 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Basic Options */ 4 | "target": "ES2019" /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019' or 'ESNEXT'. */, 5 | "module": "Node16" /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'. */, 6 | // "lib": [], /* Specify library files to be included in the compilation. */ 7 | "declaration": true /* Generates corresponding '.d.ts' file. */, 8 | "declarationMap": true /* Generates a sourcemap for each corresponding '.d.ts' file. */, 9 | // "sourceMap": true, /* Generates corresponding '.map' file. */ 10 | "outDir": "dist/esm" /* Redirect output structure to the directory. */, 11 | // "importHelpers": true, /* Import emit helpers from 'tslib'. */ 12 | 13 | /* Strict Type-Checking Options */ 14 | "strict": true /* Enable all strict type-checking options. */, 15 | 16 | /* Additional Checks */ 17 | "isolatedDeclarations": true, 18 | "isolatedModules": true, 19 | "noUnusedLocals": true /* Report errors on unused locals. */, 20 | "noUnusedParameters": true /* Report errors on unused parameters. */, 21 | "noImplicitReturns": true /* Report error when not all code paths in function return a value. */, 22 | "noFallthroughCasesInSwitch": true /* Report errors for fallthrough cases in switch statement. */, 23 | 24 | /* Module Resolution Options */ 25 | // "baseUrl": "./", /* Base directory to resolve non-absolute module names. */ 26 | "esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */ 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/parse.spec.ts: -------------------------------------------------------------------------------- 1 | import { readFileSync } from "node:fs"; 2 | import { describe, it, expect } from "vitest"; 3 | import { parse } from "./parse.js"; 4 | import { tests } from "./__fixtures__/tests.js"; 5 | 6 | const broken = [ 7 | "[", 8 | "(", 9 | "{", 10 | "()", 11 | "<>", 12 | "{}", 13 | ",", 14 | ",a", 15 | "a,", 16 | "[id=012345678901234567890123456789", 17 | "input[name=foo b]", 18 | "input[name!foo]", 19 | "input[name|]", 20 | "input[name=']", 21 | "input[name=foo[baz]]", 22 | ':has("p")', 23 | ":has(p", 24 | ":foo(p()", 25 | "#", 26 | "##foo", 27 | "/*", 28 | ]; 29 | 30 | describe("Parse", () => { 31 | describe("Own tests", () => { 32 | for (const [selector, expected, message] of tests) { 33 | it(message, () => expect(parse(selector)).toStrictEqual(expected)); 34 | } 35 | }); 36 | 37 | describe("Collected selectors (qwery, sizzle, nwmatcher)", () => { 38 | const out = JSON.parse( 39 | readFileSync(`${__dirname}/__fixtures__/out.json`, "utf8"), 40 | ); 41 | for (const s of Object.keys(out)) { 42 | it(s, () => { 43 | expect(parse(s)).toStrictEqual(out[s]); 44 | }); 45 | } 46 | }); 47 | 48 | describe("Broken selectors", () => { 49 | for (const selector of broken) { 50 | it(`should not parse — ${selector}`, () => { 51 | expect(() => parse(selector)).toThrow(Error); 52 | }); 53 | } 54 | }); 55 | 56 | it("should ignore comments", () => { 57 | expect(parse("/* comment1 */ /**/ foo /*comment2*/")).toEqual([ 58 | [{ name: "foo", namespace: null, type: "tag" }], 59 | ]); 60 | 61 | expect(() => parse("/*/")).toThrowError("Comment was not terminated"); 62 | }); 63 | 64 | it("should support legacy pseudo-elements with single colon", () => { 65 | expect(parse(":before")).toEqual([ 66 | [{ name: "before", data: null, type: "pseudo-element" }], 67 | ]); 68 | }); 69 | }); 70 | -------------------------------------------------------------------------------- /.github/workflows/nodejs-test.yml: -------------------------------------------------------------------------------- 1 | name: Node.js CI 2 | 3 | on: 4 | push: 5 | branches-ignore: 6 | - "dependabot/**" 7 | pull_request: 8 | 9 | env: 10 | CI: true 11 | FORCE_COLOR: 2 12 | NODE_COV: lts/* # The Node.js version to run coveralls on 13 | 14 | permissions: 15 | contents: read # to fetch code (actions/checkout) 16 | 17 | jobs: 18 | lint: 19 | runs-on: ubuntu-latest 20 | steps: 21 | - uses: actions/checkout@71cf2267d89c5cb81562390fa70a37fa40b1305e 22 | - uses: actions/setup-node@v6 23 | with: 24 | node-version: lts/* 25 | cache: npm 26 | - run: npm ci 27 | - run: npm run lint 28 | 29 | test: 30 | permissions: 31 | contents: read # to fetch code (actions/checkout) 32 | checks: write # to create new checks (coverallsapp/github-action) 33 | 34 | name: Node ${{ matrix.node }} 35 | runs-on: ubuntu-latest 36 | 37 | strategy: 38 | fail-fast: false 39 | matrix: 40 | node: 41 | - 18 42 | - 20 43 | - 22 44 | - lts/* 45 | 46 | steps: 47 | - uses: actions/checkout@71cf2267d89c5cb81562390fa70a37fa40b1305e 48 | - name: Use Node.js ${{ matrix.node }} 49 | uses: actions/setup-node@v6 50 | with: 51 | node-version: ${{ matrix.node }} 52 | cache: npm 53 | - run: npm ci 54 | - run: npm run build --if-present 55 | 56 | - name: Run tests 57 | run: npm run test:vi 58 | if: matrix.node != env.NODE_COV 59 | 60 | - name: Run tests with coverage 61 | run: npm run test:vi -- --coverage 62 | if: matrix.node == env.NODE_COV 63 | 64 | - name: Run Coveralls 65 | uses: coverallsapp/github-action@v2.3.7 66 | if: matrix.node == env.NODE_COV 67 | continue-on-error: true 68 | with: 69 | github-token: "${{ secrets.GITHUB_TOKEN }}" 70 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | export type Selector = 2 | | PseudoSelector 3 | | PseudoElement 4 | | AttributeSelector 5 | | TagSelector 6 | | UniversalSelector 7 | | Traversal; 8 | 9 | export enum SelectorType { 10 | Attribute = "attribute", 11 | Pseudo = "pseudo", 12 | PseudoElement = "pseudo-element", 13 | Tag = "tag", 14 | Universal = "universal", 15 | 16 | // Traversals 17 | Adjacent = "adjacent", 18 | Child = "child", 19 | Descendant = "descendant", 20 | Parent = "parent", 21 | Sibling = "sibling", 22 | ColumnCombinator = "column-combinator", 23 | } 24 | 25 | /** 26 | * Modes for ignore case. 27 | * 28 | * This could be updated to an enum, and the object is 29 | * the current stand-in that will allow code to be updated 30 | * without big changes. 31 | */ 32 | export const IgnoreCaseMode = { 33 | Unknown: null, 34 | QuirksMode: "quirks", 35 | IgnoreCase: true, 36 | CaseSensitive: false, 37 | } as const; 38 | 39 | export interface AttributeSelector { 40 | type: SelectorType.Attribute; 41 | name: string; 42 | action: AttributeAction; 43 | value: string; 44 | ignoreCase: "quirks" | boolean | null; 45 | namespace: string | null; 46 | } 47 | 48 | export type DataType = Selector[][] | null | string; 49 | 50 | export interface PseudoSelector { 51 | type: SelectorType.Pseudo; 52 | name: string; 53 | data: DataType; 54 | } 55 | 56 | export interface PseudoElement { 57 | type: SelectorType.PseudoElement; 58 | name: string; 59 | data: string | null; 60 | } 61 | 62 | export interface TagSelector { 63 | type: SelectorType.Tag; 64 | name: string; 65 | namespace: string | null; 66 | } 67 | 68 | export interface UniversalSelector { 69 | type: SelectorType.Universal; 70 | namespace: string | null; 71 | } 72 | 73 | export interface Traversal { 74 | type: TraversalType; 75 | } 76 | 77 | export enum AttributeAction { 78 | Any = "any", 79 | Element = "element", 80 | End = "end", 81 | Equals = "equals", 82 | Exists = "exists", 83 | Hyphen = "hyphen", 84 | Not = "not", 85 | Start = "start", 86 | } 87 | 88 | export type TraversalType = 89 | | SelectorType.Adjacent 90 | | SelectorType.Child 91 | | SelectorType.Descendant 92 | | SelectorType.Parent 93 | | SelectorType.Sibling 94 | | SelectorType.ColumnCombinator; 95 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "css-what", 3 | "version": "7.0.0", 4 | "description": "a CSS selector parser", 5 | "repository": { 6 | "type": "git", 7 | "url": "https://github.com/fb55/css-what" 8 | }, 9 | "funding": { 10 | "url": "https://github.com/sponsors/fb55" 11 | }, 12 | "license": "BSD-2-Clause", 13 | "author": "Felix Böhm (http://feedic.com)", 14 | "sideEffects": false, 15 | "type": "module", 16 | "exports": { 17 | "./package.json": "./package.json", 18 | ".": { 19 | "import": { 20 | "types": "./dist/esm/index.d.ts", 21 | "default": "./dist/esm/index.js" 22 | }, 23 | "require": { 24 | "types": "./dist/commonjs/index.d.ts", 25 | "default": "./dist/commonjs/index.js" 26 | } 27 | } 28 | }, 29 | "main": "./dist/commonjs/index.js", 30 | "module": "./dist/esm/index.js", 31 | "types": "./dist/commonjs/index.d.ts", 32 | "files": [ 33 | "dist", 34 | "src" 35 | ], 36 | "scripts": { 37 | "format": "npm run format:es && npm run format:prettier", 38 | "format:es": "npm run lint:es -- --fix", 39 | "format:prettier": "npm run prettier -- --write", 40 | "lint": "npm run lint:tsc && npm run lint:es && npm run lint:prettier", 41 | "lint:es": "eslint src", 42 | "lint:prettier": "npm run prettier -- --check", 43 | "lint:tsc": "tsc --noEmit", 44 | "prepublishOnly": "tshy", 45 | "prettier": "prettier '**/*.{ts,md,json,yml}'", 46 | "test": "npm run test:vi && npm run lint", 47 | "test:vi": "vitest run" 48 | }, 49 | "prettier": { 50 | "tabWidth": 4 51 | }, 52 | "devDependencies": { 53 | "@types/node": "^24.10.2", 54 | "@typescript-eslint/eslint-plugin": "^7.18.0", 55 | "@typescript-eslint/parser": "^7.18.0", 56 | "@vitest/coverage-v8": "^3.2.4", 57 | "eslint": "^8.57.1", 58 | "eslint-config-prettier": "^10.1.8", 59 | "eslint-plugin-n": "^17.23.1", 60 | "eslint-plugin-unicorn": "^56.0.1", 61 | "prettier": "^3.7.4", 62 | "tshy": "^3.1.0", 63 | "typescript": "^5.9.3", 64 | "vitest": "^3.2.4" 65 | }, 66 | "engines": { 67 | "node": ">= 6" 68 | }, 69 | "tshy": { 70 | "exclude": [ 71 | "**/*.spec.ts", 72 | "**/__fixtures__/*", 73 | "**/__tests__/*", 74 | "**/__snapshots__/*" 75 | ], 76 | "exports": { 77 | "./package.json": "./package.json", 78 | ".": "./src/index.ts" 79 | } 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "eslint:recommended", 4 | "prettier", 5 | "plugin:n/recommended", 6 | "plugin:unicorn/recommended" 7 | ], 8 | "env": { 9 | "node": true, 10 | "es6": true 11 | }, 12 | "rules": { 13 | "eqeqeq": [2, "smart"], 14 | "no-caller": 2, 15 | "dot-notation": 2, 16 | "no-var": 2, 17 | "prefer-const": 2, 18 | "prefer-arrow-callback": [2, { "allowNamedFunctions": true }], 19 | "arrow-body-style": [2, "as-needed"], 20 | "object-shorthand": 2, 21 | "prefer-template": 2, 22 | "one-var": [2, "never"], 23 | "prefer-destructuring": [2, { "object": true }], 24 | "capitalized-comments": 2, 25 | "multiline-comment-style": [2, "starred-block"], 26 | "spaced-comment": 2, 27 | "yoda": [2, "never"], 28 | "curly": [2, "multi-line"], 29 | "no-else-return": 2, 30 | 31 | "unicorn/prefer-module": 0, 32 | "unicorn/filename-case": 0, 33 | "unicorn/no-null": 0, 34 | "unicorn/prefer-code-point": 0, 35 | "unicorn/prefer-string-slice": 0, 36 | "unicorn/prefer-add-event-listener": 0, 37 | "unicorn/prefer-at": 0, 38 | "unicorn/prefer-string-replace-all": 0 39 | }, 40 | "overrides": [ 41 | { 42 | "files": "*.ts", 43 | "extends": [ 44 | "plugin:@typescript-eslint/eslint-recommended", 45 | "plugin:@typescript-eslint/recommended", 46 | "prettier" 47 | ], 48 | "parserOptions": { 49 | "sourceType": "module", 50 | "project": "./tsconfig.eslint.json" 51 | }, 52 | "rules": { 53 | "curly": [2, "multi-line"], 54 | 55 | "@typescript-eslint/prefer-for-of": 0, 56 | "@typescript-eslint/member-ordering": 0, 57 | "@typescript-eslint/explicit-function-return-type": 0, 58 | "@typescript-eslint/no-unused-vars": 0, 59 | "@typescript-eslint/no-use-before-define": [ 60 | 2, 61 | { "functions": false } 62 | ], 63 | "@typescript-eslint/consistent-type-definitions": [ 64 | 2, 65 | "interface" 66 | ], 67 | "@typescript-eslint/prefer-function-type": 2, 68 | "@typescript-eslint/no-unnecessary-type-arguments": 2, 69 | "@typescript-eslint/prefer-string-starts-ends-with": 2, 70 | "@typescript-eslint/prefer-readonly": 2, 71 | "@typescript-eslint/prefer-includes": 2, 72 | "@typescript-eslint/no-unnecessary-condition": 2, 73 | "@typescript-eslint/switch-exhaustiveness-check": 2, 74 | "@typescript-eslint/prefer-nullish-coalescing": 2, 75 | "@typescript-eslint/consistent-type-imports": [ 76 | 2, 77 | { "fixStyle": "inline-type-imports" } 78 | ], 79 | "@typescript-eslint/consistent-type-exports": 2, 80 | 81 | "n/no-missing-import": 0, 82 | "n/no-unsupported-features/es-syntax": 0 83 | } 84 | }, 85 | { 86 | "files": "*.spec.ts", 87 | "rules": { 88 | "n/no-unsupported-features/node-builtins": 0, 89 | "n/no-unpublished-import": 0 90 | } 91 | } 92 | ] 93 | } 94 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # css-what 2 | 3 | [![Node.js CI](https://github.com/fb55/css-what/actions/workflows/nodejs-test.yml/badge.svg)](https://github.com/fb55/css-what/actions/workflows/nodejs-test.yml) 4 | [![Coverage](https://img.shields.io/coveralls/github/fb55/css-what/master)](https://coveralls.io/github/fb55/css-what?branch=master) 5 | 6 | A CSS selector parser. 7 | 8 | ## Example 9 | 10 | ```js 11 | import * as CSSwhat from "css-what"; 12 | 13 | CSSwhat.parse("foo[bar]:baz") 14 | 15 | ~> [ 16 | [ 17 | { type: "tag", name: "foo" }, 18 | { 19 | type: "attribute", 20 | name: "bar", 21 | action: "exists", 22 | value: "", 23 | ignoreCase: null 24 | }, 25 | { type: "pseudo", name: "baz", data: null } 26 | ] 27 | ] 28 | ``` 29 | 30 | ## API 31 | 32 | **`CSSwhat.parse(selector)` - Parses `selector`.** 33 | 34 | The function returns a two-dimensional array. The first array represents selectors separated by commas (eg. `sub1, sub2`), the second contains the relevant tokens for that selector. Possible token types are: 35 | 36 | | name | properties | example | output | 37 | | ------------------- | --------------------------------------- | ------------- | ---------------------------------------------------------------------------------------- | 38 | | `tag` | `name` | `div` | `{ type: 'tag', name: 'div' }` | 39 | | `universal` | - | `*` | `{ type: 'universal' }` | 40 | | `pseudo` | `name`, `data` | `:name(data)` | `{ type: 'pseudo', name: 'name', data: 'data' }` | 41 | | `pseudo` | `name`, `data` | `:name` | `{ type: 'pseudo', name: 'name', data: null }` | 42 | | `pseudo-element` | `name` | `::name` | `{ type: 'pseudo-element', name: 'name' }` | 43 | | `attribute` | `name`, `action`, `value`, `ignoreCase` | `[attr]` | `{ type: 'attribute', name: 'attr', action: 'exists', value: '', ignoreCase: false }` | 44 | | `attribute` | `name`, `action`, `value`, `ignoreCase` | `[attr=val]` | `{ type: 'attribute', name: 'attr', action: 'equals', value: 'val', ignoreCase: false }` | 45 | | `attribute` | `name`, `action`, `value`, `ignoreCase` | `[attr^=val]` | `{ type: 'attribute', name: 'attr', action: 'start', value: 'val', ignoreCase: false }` | 46 | | `attribute` | `name`, `action`, `value`, `ignoreCase` | `[attr$=val]` | `{ type: 'attribute', name: 'attr', action: 'end', value: 'val', ignoreCase: false }` | 47 | | `child` | - | `>` | `{ type: 'child' }` | 48 | | `parent` | - | `<` | `{ type: 'parent' }` | 49 | | `sibling` | - | `~` | `{ type: 'sibling' }` | 50 | | `adjacent` | - | `+` | `{ type: 'adjacent' }` | 51 | | `descendant` | - | | `{ type: 'descendant' }` | 52 | | `column-combinator` | - | `\|\|` | `{ type: 'column-combinator' }` | 53 | 54 | **`CSSwhat.stringify(selector)` - Turns `selector` back into a string.** 55 | 56 | --- 57 | 58 | License: BSD-2-Clause 59 | 60 | ## Security contact information 61 | 62 | To report a security vulnerability, please use the [Tidelift security contact](https://tidelift.com/security). 63 | Tidelift will coordinate the fix and disclosure. 64 | 65 | ## `css-what` for enterprise 66 | 67 | Available as part of the Tidelift Subscription 68 | 69 | The maintainers of `css-what` and thousands of other packages are working with Tidelift to deliver commercial support and maintenance for the open source dependencies you use to build your applications. Save time, reduce risk, and improve code health, while paying the maintainers of the exact dependencies you use. [Learn more.](https://tidelift.com/subscription/pkg/npm-css-what?utm_source=npm-css-what&utm_medium=referral&utm_campaign=enterprise&utm_term=repo) 70 | -------------------------------------------------------------------------------- /src/stringify.ts: -------------------------------------------------------------------------------- 1 | import { type Selector, SelectorType, AttributeAction } from "./types.js"; 2 | 3 | const attribValueChars = ["\\", '"']; 4 | const pseudoValueChars = [...attribValueChars, "(", ")"]; 5 | 6 | const charsToEscapeInAttributeValue = new Set( 7 | attribValueChars.map((c) => c.charCodeAt(0)), 8 | ); 9 | const charsToEscapeInPseudoValue = new Set( 10 | pseudoValueChars.map((c) => c.charCodeAt(0)), 11 | ); 12 | const charsToEscapeInName = new Set( 13 | [ 14 | ...pseudoValueChars, 15 | "~", 16 | "^", 17 | "$", 18 | "*", 19 | "+", 20 | "!", 21 | "|", 22 | ":", 23 | "[", 24 | "]", 25 | " ", 26 | ".", 27 | "%", 28 | ].map((c) => c.charCodeAt(0)), 29 | ); 30 | 31 | /** 32 | * Turns `selector` back into a string. 33 | * 34 | * @param selector Selector to stringify. 35 | */ 36 | export function stringify(selector: Selector[][]): string { 37 | return selector 38 | .map((token) => 39 | token 40 | .map((token, index, array) => 41 | stringifyToken(token, index, array), 42 | ) 43 | .join(""), 44 | ) 45 | .join(", "); 46 | } 47 | 48 | function stringifyToken( 49 | token: Selector, 50 | index: number, 51 | array: Selector[], 52 | ): string { 53 | switch (token.type) { 54 | // Simple types 55 | case SelectorType.Child: { 56 | return index === 0 ? "> " : " > "; 57 | } 58 | case SelectorType.Parent: { 59 | return index === 0 ? "< " : " < "; 60 | } 61 | case SelectorType.Sibling: { 62 | return index === 0 ? "~ " : " ~ "; 63 | } 64 | case SelectorType.Adjacent: { 65 | return index === 0 ? "+ " : " + "; 66 | } 67 | case SelectorType.Descendant: { 68 | return " "; 69 | } 70 | case SelectorType.ColumnCombinator: { 71 | return index === 0 ? "|| " : " || "; 72 | } 73 | case SelectorType.Universal: { 74 | // Return an empty string if the selector isn't needed. 75 | return token.namespace === "*" && 76 | index + 1 < array.length && 77 | "name" in array[index + 1] 78 | ? "" 79 | : `${getNamespace(token.namespace)}*`; 80 | } 81 | 82 | case SelectorType.Tag: { 83 | return getNamespacedName(token); 84 | } 85 | 86 | case SelectorType.PseudoElement: { 87 | return `::${escapeName(token.name, charsToEscapeInName)}${ 88 | token.data === null 89 | ? "" 90 | : `(${escapeName(token.data, charsToEscapeInPseudoValue)})` 91 | }`; 92 | } 93 | 94 | case SelectorType.Pseudo: { 95 | return `:${escapeName(token.name, charsToEscapeInName)}${ 96 | token.data === null 97 | ? "" 98 | : `(${ 99 | typeof token.data === "string" 100 | ? escapeName( 101 | token.data, 102 | charsToEscapeInPseudoValue, 103 | ) 104 | : stringify(token.data) 105 | })` 106 | }`; 107 | } 108 | 109 | case SelectorType.Attribute: { 110 | if ( 111 | token.name === "id" && 112 | token.action === AttributeAction.Equals && 113 | token.ignoreCase === "quirks" && 114 | !token.namespace 115 | ) { 116 | return `#${escapeName(token.value, charsToEscapeInName)}`; 117 | } 118 | if ( 119 | token.name === "class" && 120 | token.action === AttributeAction.Element && 121 | token.ignoreCase === "quirks" && 122 | !token.namespace 123 | ) { 124 | return `.${escapeName(token.value, charsToEscapeInName)}`; 125 | } 126 | 127 | const name = getNamespacedName(token); 128 | 129 | if (token.action === AttributeAction.Exists) { 130 | return `[${name}]`; 131 | } 132 | 133 | return `[${name}${getActionValue(token.action)}="${escapeName( 134 | token.value, 135 | charsToEscapeInAttributeValue, 136 | )}"${ 137 | token.ignoreCase === null ? "" : token.ignoreCase ? " i" : " s" 138 | }]`; 139 | } 140 | } 141 | } 142 | 143 | function getActionValue(action: AttributeAction): string { 144 | switch (action) { 145 | case AttributeAction.Equals: { 146 | return ""; 147 | } 148 | case AttributeAction.Element: { 149 | return "~"; 150 | } 151 | case AttributeAction.Start: { 152 | return "^"; 153 | } 154 | case AttributeAction.End: { 155 | return "$"; 156 | } 157 | case AttributeAction.Any: { 158 | return "*"; 159 | } 160 | case AttributeAction.Not: { 161 | return "!"; 162 | } 163 | case AttributeAction.Hyphen: { 164 | return "|"; 165 | } 166 | default: { 167 | throw new Error("Shouldn't be here"); 168 | } 169 | } 170 | } 171 | 172 | function getNamespacedName(token: { 173 | name: string; 174 | namespace: string | null; 175 | }): string { 176 | return `${getNamespace(token.namespace)}${escapeName( 177 | token.name, 178 | charsToEscapeInName, 179 | )}`; 180 | } 181 | 182 | function getNamespace(namespace: string | null): string { 183 | return namespace === null 184 | ? "" 185 | : `${ 186 | namespace === "*" 187 | ? "*" 188 | : escapeName(namespace, charsToEscapeInName) 189 | }|`; 190 | } 191 | 192 | function escapeName(name: string, charsToEscape: Set): string { 193 | let lastIndex = 0; 194 | let escapedName = ""; 195 | 196 | for (let index = 0; index < name.length; index++) { 197 | if (charsToEscape.has(name.charCodeAt(index))) { 198 | escapedName += `${name.slice(lastIndex, index)}\\${name.charAt(index)}`; 199 | lastIndex = index + 1; 200 | } 201 | } 202 | 203 | return escapedName.length > 0 ? escapedName + name.slice(lastIndex) : name; 204 | } 205 | -------------------------------------------------------------------------------- /src/wpt.spec.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @fileoverview CSS Selector parsing tests from WPT 3 | * @see https://github.com/web-platform-tests/wpt/tree/0bb883967c888261a8372923fd61eb5ad14305b2/css/selectors/parsing 4 | * @license BSD-3-Clause (https://github.com/web-platform-tests/wpt/blob/master/LICENSE.md) 5 | */ 6 | 7 | import { describe, it, expect } from "vitest"; 8 | import { parse, stringify } from "./index.js"; 9 | 10 | function test_valid_selector( 11 | selector: string, 12 | serialized: string | string[] = selector, 13 | ) { 14 | const result = stringify(parse(selector)); 15 | if (Array.isArray(serialized)) { 16 | // Should be a part of the array 17 | expect(serialized).toContain(result); 18 | } else { 19 | expect(result).toStrictEqual(serialized); 20 | } 21 | } 22 | 23 | function test_invalid_selector(selector: string) { 24 | expect(() => parse(selector)).toThrow(Error); 25 | } 26 | 27 | describe("Web Platform Tests", () => { 28 | it("Attribute selectors", () => { 29 | // Attribute presence and value selectors 30 | test_valid_selector("[att]"); 31 | test_valid_selector("[att=val]", '[att="val"]'); 32 | test_valid_selector("[att~=val]", '[att~="val"]'); 33 | test_valid_selector("[att|=val]", '[att|="val"]'); 34 | test_valid_selector("h1[title]"); 35 | test_valid_selector("span[class='example']", 'span[class="example"]'); 36 | test_valid_selector("a[hreflang=fr]", 'a[hreflang="fr"]'); 37 | test_valid_selector("a[hreflang|='en']", 'a[hreflang|="en"]'); 38 | 39 | // Substring matching attribute selectors 40 | test_valid_selector("[att^=val]", '[att^="val"]'); 41 | test_valid_selector("[att$=val]", '[att$="val"]'); 42 | test_valid_selector("[att*=val]", '[att*="val"]'); 43 | test_valid_selector('object[type^="image/"]'); 44 | test_valid_selector('a[href$=".html"]'); 45 | test_valid_selector('p[title*="hello"]'); 46 | 47 | // From Attribute selectors and namespaces examples in spec: 48 | test_valid_selector("[*|att]"); 49 | test_valid_selector("[|att]", "[att]"); 50 | }); 51 | 52 | it("Child combinators", () => { 53 | test_valid_selector("body > p"); 54 | test_valid_selector("div ol>li p", "div ol > li p"); 55 | }); 56 | 57 | it("Class selectors", () => { 58 | test_valid_selector("*.pastoral", ["*.pastoral", ".pastoral"]); 59 | test_valid_selector(".pastoral", ["*.pastoral", ".pastoral"]); 60 | test_valid_selector("h1.pastoral"); 61 | test_valid_selector("p.pastoral.marine"); 62 | }); 63 | 64 | it("Descendant combinator", () => { 65 | test_valid_selector("h1 em"); 66 | test_valid_selector("div * p"); 67 | test_valid_selector("div p *[href]", ["div p *[href]", "div p [href]"]); 68 | }); 69 | 70 | it(":focus-visible pseudo-class", () => { 71 | test_valid_selector(":focus-visible"); 72 | test_valid_selector("a:focus-visible"); 73 | test_valid_selector(":focus:not(:focus-visible)"); 74 | }); 75 | 76 | it("The relational pseudo-class", () => { 77 | test_valid_selector(":has(a)"); 78 | test_valid_selector(":has(#a)"); 79 | test_valid_selector(":has(.a)"); 80 | test_valid_selector(":has([a])"); 81 | test_valid_selector(':has([a="b"])'); 82 | test_valid_selector(':has([a|="b"])'); 83 | test_valid_selector(":has(:hover)"); 84 | test_valid_selector("*:has(.a)", ["*:has(.a)", ":has(.a)"]); 85 | test_valid_selector(".a:has(.b)"); 86 | test_valid_selector(".a:has(> .b)"); 87 | test_valid_selector(".a:has(~ .b)"); 88 | test_valid_selector(".a:has(+ .b)"); 89 | test_valid_selector(".a:has(.b) .c"); 90 | test_valid_selector(".a .b:has(.c)"); 91 | test_valid_selector(".a .b:has(.c .d)"); 92 | test_valid_selector(".a .b:has(.c .d) .e"); 93 | test_valid_selector(".a:has(.b:has(.c))"); 94 | test_valid_selector(".a:has(.b:is(.c .d))"); 95 | test_valid_selector(".a:has(.b:is(.c:has(.d) .e))"); 96 | test_valid_selector(".a:is(.b:has(.c) .d)"); 97 | test_valid_selector(".a:not(:has(.b))"); 98 | test_valid_selector(".a:has(:not(.b))"); 99 | test_valid_selector(".a:has(.b):has(.c)"); 100 | test_valid_selector("*|*:has(*)", ":has(*)"); 101 | test_valid_selector(":has(*|*)"); 102 | test_invalid_selector(".a:has()"); 103 | }); 104 | 105 | it("ID selectors", () => { 106 | test_valid_selector("h1#chapter1"); 107 | test_valid_selector("#chapter1"); 108 | test_valid_selector("*#z98y", ["*#z98y", "#z98y"]); 109 | }); 110 | 111 | it("The Matches-Any Pseudo-class: ':is()'", () => { 112 | test_valid_selector( 113 | ":is(ul,ol,.list) > [hidden]", 114 | ":is(ul, ol, .list) > [hidden]", 115 | ); 116 | test_valid_selector(":is(:hover,:focus)", ":is(:hover, :focus)"); 117 | test_valid_selector("a:is(:not(:hover))"); 118 | 119 | test_valid_selector(":is(#a)"); 120 | test_valid_selector(".a.b ~ :is(.c.d ~ .e.f)"); 121 | test_valid_selector(".a.b ~ .c.d:is(span.e + .f, .g.h > .i.j .k)"); 122 | }); 123 | 124 | it("The negation pseudo-class", () => { 125 | test_valid_selector("button:not([disabled])"); 126 | test_valid_selector("*:not(foo)", ["*:not(foo)", ":not(foo)"]); 127 | test_valid_selector(":not(:link):not(:visited)"); 128 | test_valid_selector("*|*:not(*)", ":not(*)"); 129 | test_valid_selector(":not(:hover)"); 130 | test_valid_selector(":not(*|*)"); 131 | test_valid_selector("foo:not(bar)"); 132 | test_valid_selector(":not(:not(foo))"); 133 | test_valid_selector(":not(.a .b)"); 134 | test_valid_selector(":not(.a + .b)"); 135 | test_valid_selector(":not(.a .b ~ c)"); 136 | test_valid_selector(":not(span.a, div.b)"); 137 | test_valid_selector(":not(.a .b ~ c, .d .e)"); 138 | test_valid_selector(":not(:host)"); 139 | test_valid_selector(":not(:host(.a))"); 140 | test_valid_selector(":host(:not(.a))"); 141 | test_valid_selector(":not(:host(:not(.a)))"); 142 | test_valid_selector( 143 | ":not([disabled][selected])", 144 | ":not([disabled][selected])", 145 | ); 146 | test_valid_selector( 147 | ":not([disabled],[selected])", 148 | ":not([disabled], [selected])", 149 | ); 150 | 151 | test_invalid_selector(":not()"); 152 | test_invalid_selector(":not(:not())"); 153 | }); 154 | 155 | it("Sibling combinators", () => { 156 | test_valid_selector("math + p"); 157 | test_valid_selector("h1.opener + h2"); 158 | test_valid_selector("h1 ~ pre"); 159 | }); 160 | 161 | it("Universal selector", () => { 162 | test_valid_selector("*"); 163 | test_valid_selector("div :first-child", [ 164 | "div *:first-child", 165 | "div :first-child", 166 | ]); 167 | test_valid_selector("div *:first-child", [ 168 | "div *:first-child", 169 | "div :first-child", 170 | ]); 171 | }); 172 | 173 | it("The Specificity-adjustment Pseudo-class: ':where()'", () => { 174 | test_valid_selector( 175 | ":where(ul,ol,.list) > [hidden]", 176 | ":where(ul, ol, .list) > [hidden]", 177 | ); 178 | test_valid_selector(":where(:hover,:focus)", ":where(:hover, :focus)"); 179 | test_valid_selector("a:where(:not(:hover))"); 180 | 181 | test_valid_selector(":where(#a)"); 182 | test_valid_selector(".a.b ~ :where(.c.d ~ .e.f)"); 183 | test_valid_selector(".a.b ~ .c.d:where(span.e + .f, .g.h > .i.j .k)"); 184 | }); 185 | }); 186 | -------------------------------------------------------------------------------- /src/parse.ts: -------------------------------------------------------------------------------- 1 | import { 2 | type Selector, 3 | SelectorType, 4 | type AttributeSelector, 5 | type Traversal, 6 | AttributeAction, 7 | type TraversalType, 8 | type DataType, 9 | } from "./types.js"; 10 | 11 | const reName = /^[^#\\]?(?:\\(?:[\da-f]{1,6}\s?|.)|[\w\u00B0-\uFFFF-])+/; 12 | const reEscape = /\\([\da-f]{1,6}\s?|(\s)|.)/gi; 13 | 14 | const enum CharCode { 15 | LeftParenthesis = 40, 16 | RightParenthesis = 41, 17 | LeftSquareBracket = 91, 18 | RightSquareBracket = 93, 19 | Comma = 44, 20 | Period = 46, 21 | Colon = 58, 22 | SingleQuote = 39, 23 | DoubleQuote = 34, 24 | Plus = 43, 25 | Tilde = 126, 26 | QuestionMark = 63, 27 | ExclamationMark = 33, 28 | Slash = 47, 29 | Equal = 61, 30 | Dollar = 36, 31 | Pipe = 124, 32 | Circumflex = 94, 33 | Asterisk = 42, 34 | GreaterThan = 62, 35 | LessThan = 60, 36 | Hash = 35, 37 | LowerI = 105, 38 | LowerS = 115, 39 | BackSlash = 92, 40 | 41 | // Whitespace 42 | Space = 32, 43 | Tab = 9, 44 | NewLine = 10, 45 | FormFeed = 12, 46 | CarriageReturn = 13, 47 | } 48 | 49 | const actionTypes = new Map([ 50 | [CharCode.Tilde, AttributeAction.Element], 51 | [CharCode.Circumflex, AttributeAction.Start], 52 | [CharCode.Dollar, AttributeAction.End], 53 | [CharCode.Asterisk, AttributeAction.Any], 54 | [CharCode.ExclamationMark, AttributeAction.Not], 55 | [CharCode.Pipe, AttributeAction.Hyphen], 56 | ]); 57 | 58 | // Pseudos, whose data property is parsed as well. 59 | const unpackPseudos = new Set([ 60 | "has", 61 | "not", 62 | "matches", 63 | "is", 64 | "where", 65 | "host", 66 | "host-context", 67 | ]); 68 | 69 | /** 70 | * Pseudo elements defined in CSS Level 1 and CSS Level 2 can be written with 71 | * a single colon; eg. :before will turn into ::before. 72 | * 73 | * @see {@link https://www.w3.org/TR/2018/WD-selectors-4-20181121/#pseudo-element-syntax} 74 | */ 75 | const pseudosToPseudoElements = new Set([ 76 | "before", 77 | "after", 78 | "first-line", 79 | "first-letter", 80 | ]); 81 | 82 | /** 83 | * Checks whether a specific selector is a traversal. 84 | * This is useful eg. in swapping the order of elements that 85 | * are not traversals. 86 | * 87 | * @param selector Selector to check. 88 | */ 89 | export function isTraversal(selector: Selector): selector is Traversal { 90 | switch (selector.type) { 91 | case SelectorType.Adjacent: 92 | case SelectorType.Child: 93 | case SelectorType.Descendant: 94 | case SelectorType.Parent: 95 | case SelectorType.Sibling: 96 | case SelectorType.ColumnCombinator: { 97 | return true; 98 | } 99 | default: { 100 | return false; 101 | } 102 | } 103 | } 104 | 105 | const stripQuotesFromPseudos = new Set(["contains", "icontains"]); 106 | 107 | // Unescape function taken from https://github.com/jquery/sizzle/blob/master/src/sizzle.js#L152 108 | function funescape(_: string, escaped: string, escapedWhitespace?: string) { 109 | const high = Number.parseInt(escaped, 16) - 0x1_00_00; 110 | 111 | // NaN means non-codepoint 112 | return high !== high || escapedWhitespace 113 | ? escaped 114 | : high < 0 115 | ? // BMP codepoint 116 | String.fromCharCode(high + 0x1_00_00) 117 | : // Supplemental Plane codepoint (surrogate pair) 118 | String.fromCharCode( 119 | (high >> 10) | 0xd8_00, 120 | (high & 0x3_ff) | 0xdc_00, 121 | ); 122 | } 123 | 124 | function unescapeCSS(cssString: string) { 125 | return cssString.replace(reEscape, funescape); 126 | } 127 | 128 | function isQuote(c: number): boolean { 129 | return c === CharCode.SingleQuote || c === CharCode.DoubleQuote; 130 | } 131 | 132 | function isWhitespace(c: number): boolean { 133 | return ( 134 | c === CharCode.Space || 135 | c === CharCode.Tab || 136 | c === CharCode.NewLine || 137 | c === CharCode.FormFeed || 138 | c === CharCode.CarriageReturn 139 | ); 140 | } 141 | 142 | /** 143 | * Parses `selector`. 144 | * 145 | * @param selector Selector to parse. 146 | * @returns Returns a two-dimensional array. 147 | * The first dimension represents selectors separated by commas (eg. `sub1, sub2`), 148 | * the second contains the relevant tokens for that selector. 149 | */ 150 | export function parse(selector: string): Selector[][] { 151 | const subselects: Selector[][] = []; 152 | 153 | const endIndex = parseSelector(subselects, `${selector}`, 0); 154 | 155 | if (endIndex < selector.length) { 156 | throw new Error(`Unmatched selector: ${selector.slice(endIndex)}`); 157 | } 158 | 159 | return subselects; 160 | } 161 | 162 | function parseSelector( 163 | subselects: Selector[][], 164 | selector: string, 165 | selectorIndex: number, 166 | ): number { 167 | let tokens: Selector[] = []; 168 | 169 | function getName(offset: number): string { 170 | const match = selector.slice(selectorIndex + offset).match(reName); 171 | 172 | if (!match) { 173 | throw new Error( 174 | `Expected name, found ${selector.slice(selectorIndex)}`, 175 | ); 176 | } 177 | 178 | const [name] = match; 179 | selectorIndex += offset + name.length; 180 | return unescapeCSS(name); 181 | } 182 | 183 | function stripWhitespace(offset: number) { 184 | selectorIndex += offset; 185 | 186 | while ( 187 | selectorIndex < selector.length && 188 | isWhitespace(selector.charCodeAt(selectorIndex)) 189 | ) { 190 | selectorIndex++; 191 | } 192 | } 193 | 194 | function readValueWithParenthesis(): string { 195 | selectorIndex += 1; 196 | const start = selectorIndex; 197 | 198 | for ( 199 | let counter = 1; 200 | selectorIndex < selector.length; 201 | selectorIndex++ 202 | ) { 203 | switch (selector.charCodeAt(selectorIndex)) { 204 | case CharCode.BackSlash: { 205 | // Skip next character 206 | selectorIndex += 1; 207 | break; 208 | } 209 | case CharCode.LeftParenthesis: { 210 | counter += 1; 211 | break; 212 | } 213 | case CharCode.RightParenthesis: { 214 | counter -= 1; 215 | 216 | if (counter === 0) { 217 | return unescapeCSS( 218 | selector.slice(start, selectorIndex++), 219 | ); 220 | } 221 | 222 | break; 223 | } 224 | } 225 | } 226 | 227 | throw new Error("Parenthesis not matched"); 228 | } 229 | 230 | function ensureNotTraversal() { 231 | if (tokens.length > 0 && isTraversal(tokens[tokens.length - 1])) { 232 | throw new Error("Did not expect successive traversals."); 233 | } 234 | } 235 | 236 | function addTraversal(type: TraversalType) { 237 | if ( 238 | tokens.length > 0 && 239 | tokens[tokens.length - 1].type === SelectorType.Descendant 240 | ) { 241 | tokens[tokens.length - 1].type = type; 242 | return; 243 | } 244 | 245 | ensureNotTraversal(); 246 | 247 | tokens.push({ type }); 248 | } 249 | 250 | function addSpecialAttribute(name: string, action: AttributeAction) { 251 | tokens.push({ 252 | type: SelectorType.Attribute, 253 | name, 254 | action, 255 | value: getName(1), 256 | namespace: null, 257 | ignoreCase: "quirks", 258 | }); 259 | } 260 | 261 | /** 262 | * We have finished parsing the current part of the selector. 263 | * 264 | * Remove descendant tokens at the end if they exist, 265 | * and return the last index, so that parsing can be 266 | * picked up from here. 267 | */ 268 | function finalizeSubselector() { 269 | if ( 270 | tokens.length > 0 && 271 | tokens[tokens.length - 1].type === SelectorType.Descendant 272 | ) { 273 | tokens.pop(); 274 | } 275 | 276 | if (tokens.length === 0) { 277 | throw new Error("Empty sub-selector"); 278 | } 279 | 280 | subselects.push(tokens); 281 | } 282 | 283 | stripWhitespace(0); 284 | 285 | if (selector.length === selectorIndex) { 286 | return selectorIndex; 287 | } 288 | 289 | loop: while (selectorIndex < selector.length) { 290 | const firstChar = selector.charCodeAt(selectorIndex); 291 | 292 | switch (firstChar) { 293 | // Whitespace 294 | case CharCode.Space: 295 | case CharCode.Tab: 296 | case CharCode.NewLine: 297 | case CharCode.FormFeed: 298 | case CharCode.CarriageReturn: { 299 | if ( 300 | tokens.length === 0 || 301 | tokens[0].type !== SelectorType.Descendant 302 | ) { 303 | ensureNotTraversal(); 304 | tokens.push({ type: SelectorType.Descendant }); 305 | } 306 | 307 | stripWhitespace(1); 308 | break; 309 | } 310 | // Traversals 311 | case CharCode.GreaterThan: { 312 | addTraversal(SelectorType.Child); 313 | stripWhitespace(1); 314 | break; 315 | } 316 | case CharCode.LessThan: { 317 | addTraversal(SelectorType.Parent); 318 | stripWhitespace(1); 319 | break; 320 | } 321 | case CharCode.Tilde: { 322 | addTraversal(SelectorType.Sibling); 323 | stripWhitespace(1); 324 | break; 325 | } 326 | case CharCode.Plus: { 327 | addTraversal(SelectorType.Adjacent); 328 | stripWhitespace(1); 329 | break; 330 | } 331 | // Special attribute selectors: .class, #id 332 | case CharCode.Period: { 333 | addSpecialAttribute("class", AttributeAction.Element); 334 | break; 335 | } 336 | case CharCode.Hash: { 337 | addSpecialAttribute("id", AttributeAction.Equals); 338 | break; 339 | } 340 | case CharCode.LeftSquareBracket: { 341 | stripWhitespace(1); 342 | 343 | // Determine attribute name and namespace 344 | 345 | let name: string; 346 | let namespace: string | null = null; 347 | 348 | if (selector.charCodeAt(selectorIndex) === CharCode.Pipe) { 349 | // Equivalent to no namespace 350 | name = getName(1); 351 | } else if (selector.startsWith("*|", selectorIndex)) { 352 | namespace = "*"; 353 | name = getName(2); 354 | } else { 355 | name = getName(0); 356 | 357 | if ( 358 | selector.charCodeAt(selectorIndex) === CharCode.Pipe && 359 | selector.charCodeAt(selectorIndex + 1) !== 360 | CharCode.Equal 361 | ) { 362 | namespace = name; 363 | name = getName(1); 364 | } 365 | } 366 | 367 | stripWhitespace(0); 368 | 369 | // Determine comparison operation 370 | 371 | let action: AttributeAction = AttributeAction.Exists; 372 | const possibleAction = actionTypes.get( 373 | selector.charCodeAt(selectorIndex), 374 | ); 375 | 376 | if (possibleAction) { 377 | action = possibleAction; 378 | 379 | if ( 380 | selector.charCodeAt(selectorIndex + 1) !== 381 | CharCode.Equal 382 | ) { 383 | throw new Error("Expected `=`"); 384 | } 385 | 386 | stripWhitespace(2); 387 | } else if ( 388 | selector.charCodeAt(selectorIndex) === CharCode.Equal 389 | ) { 390 | action = AttributeAction.Equals; 391 | stripWhitespace(1); 392 | } 393 | 394 | // Determine value 395 | 396 | let value = ""; 397 | let ignoreCase: boolean | null = null; 398 | 399 | if (action !== "exists") { 400 | if (isQuote(selector.charCodeAt(selectorIndex))) { 401 | const quote = selector.charCodeAt(selectorIndex); 402 | selectorIndex += 1; 403 | const sectionStart = selectorIndex; 404 | while ( 405 | selectorIndex < selector.length && 406 | selector.charCodeAt(selectorIndex) !== quote 407 | ) { 408 | selectorIndex += 409 | // Skip next character if it is escaped 410 | selector.charCodeAt(selectorIndex) === 411 | CharCode.BackSlash 412 | ? 2 413 | : 1; 414 | } 415 | 416 | if (selector.charCodeAt(selectorIndex) !== quote) { 417 | throw new Error("Attribute value didn't end"); 418 | } 419 | 420 | value = unescapeCSS( 421 | selector.slice(sectionStart, selectorIndex), 422 | ); 423 | selectorIndex += 1; 424 | } else { 425 | const valueStart = selectorIndex; 426 | 427 | while ( 428 | selectorIndex < selector.length && 429 | !isWhitespace(selector.charCodeAt(selectorIndex)) && 430 | selector.charCodeAt(selectorIndex) !== 431 | CharCode.RightSquareBracket 432 | ) { 433 | selectorIndex += 434 | // Skip next character if it is escaped 435 | selector.charCodeAt(selectorIndex) === 436 | CharCode.BackSlash 437 | ? 2 438 | : 1; 439 | } 440 | 441 | value = unescapeCSS( 442 | selector.slice(valueStart, selectorIndex), 443 | ); 444 | } 445 | 446 | stripWhitespace(0); 447 | 448 | // See if we have a force ignore flag 449 | switch (selector.charCodeAt(selectorIndex) | 0x20) { 450 | // If the forceIgnore flag is set (either `i` or `s`), use that value 451 | case CharCode.LowerI: { 452 | ignoreCase = true; 453 | stripWhitespace(1); 454 | break; 455 | } 456 | case CharCode.LowerS: { 457 | ignoreCase = false; 458 | stripWhitespace(1); 459 | break; 460 | } 461 | } 462 | } 463 | 464 | if ( 465 | selector.charCodeAt(selectorIndex) !== 466 | CharCode.RightSquareBracket 467 | ) { 468 | throw new Error("Attribute selector didn't terminate"); 469 | } 470 | 471 | selectorIndex += 1; 472 | 473 | const attributeSelector: AttributeSelector = { 474 | type: SelectorType.Attribute, 475 | name, 476 | action, 477 | value, 478 | namespace, 479 | ignoreCase, 480 | }; 481 | 482 | tokens.push(attributeSelector); 483 | break; 484 | } 485 | case CharCode.Colon: { 486 | if (selector.charCodeAt(selectorIndex + 1) === CharCode.Colon) { 487 | tokens.push({ 488 | type: SelectorType.PseudoElement, 489 | name: getName(2).toLowerCase(), 490 | data: 491 | selector.charCodeAt(selectorIndex) === 492 | CharCode.LeftParenthesis 493 | ? readValueWithParenthesis() 494 | : null, 495 | }); 496 | break; 497 | } 498 | 499 | const name = getName(1).toLowerCase(); 500 | 501 | if (pseudosToPseudoElements.has(name)) { 502 | tokens.push({ 503 | type: SelectorType.PseudoElement, 504 | name, 505 | data: null, 506 | }); 507 | break; 508 | } 509 | 510 | let data: DataType = null; 511 | 512 | if ( 513 | selector.charCodeAt(selectorIndex) === 514 | CharCode.LeftParenthesis 515 | ) { 516 | if (unpackPseudos.has(name)) { 517 | if (isQuote(selector.charCodeAt(selectorIndex + 1))) { 518 | throw new Error( 519 | `Pseudo-selector ${name} cannot be quoted`, 520 | ); 521 | } 522 | 523 | data = []; 524 | selectorIndex = parseSelector( 525 | data, 526 | selector, 527 | selectorIndex + 1, 528 | ); 529 | 530 | if ( 531 | selector.charCodeAt(selectorIndex) !== 532 | CharCode.RightParenthesis 533 | ) { 534 | throw new Error( 535 | `Missing closing parenthesis in :${name} (${selector})`, 536 | ); 537 | } 538 | 539 | selectorIndex += 1; 540 | } else { 541 | data = readValueWithParenthesis(); 542 | 543 | if (stripQuotesFromPseudos.has(name)) { 544 | const quot = data.charCodeAt(0); 545 | 546 | if ( 547 | quot === data.charCodeAt(data.length - 1) && 548 | isQuote(quot) 549 | ) { 550 | data = data.slice(1, -1); 551 | } 552 | } 553 | 554 | data = unescapeCSS(data); 555 | } 556 | } 557 | 558 | tokens.push({ type: SelectorType.Pseudo, name, data }); 559 | break; 560 | } 561 | case CharCode.Comma: { 562 | finalizeSubselector(); 563 | tokens = []; 564 | stripWhitespace(1); 565 | break; 566 | } 567 | default: { 568 | if (selector.startsWith("/*", selectorIndex)) { 569 | const endIndex = selector.indexOf("*/", selectorIndex + 2); 570 | 571 | if (endIndex === -1) { 572 | throw new Error("Comment was not terminated"); 573 | } 574 | 575 | selectorIndex = endIndex + 2; 576 | 577 | // Remove leading whitespace 578 | if (tokens.length === 0) { 579 | stripWhitespace(0); 580 | } 581 | 582 | break; 583 | } 584 | 585 | let namespace = null; 586 | let name: string; 587 | 588 | if (firstChar === CharCode.Asterisk) { 589 | selectorIndex += 1; 590 | name = "*"; 591 | } else if (firstChar === CharCode.Pipe) { 592 | name = ""; 593 | 594 | if ( 595 | selector.charCodeAt(selectorIndex + 1) === CharCode.Pipe 596 | ) { 597 | addTraversal(SelectorType.ColumnCombinator); 598 | stripWhitespace(2); 599 | break; 600 | } 601 | } else if (reName.test(selector.slice(selectorIndex))) { 602 | name = getName(0); 603 | } else { 604 | break loop; 605 | } 606 | 607 | if ( 608 | selector.charCodeAt(selectorIndex) === CharCode.Pipe && 609 | selector.charCodeAt(selectorIndex + 1) !== CharCode.Pipe 610 | ) { 611 | namespace = name; 612 | if ( 613 | selector.charCodeAt(selectorIndex + 1) === 614 | CharCode.Asterisk 615 | ) { 616 | name = "*"; 617 | selectorIndex += 2; 618 | } else { 619 | name = getName(1); 620 | } 621 | } 622 | 623 | tokens.push( 624 | name === "*" 625 | ? { type: SelectorType.Universal, namespace } 626 | : { type: SelectorType.Tag, name, namespace }, 627 | ); 628 | } 629 | } 630 | } 631 | 632 | finalizeSubselector(); 633 | return selectorIndex; 634 | } 635 | -------------------------------------------------------------------------------- /src/__fixtures__/tests.ts: -------------------------------------------------------------------------------- 1 | import { 2 | type Selector, 3 | SelectorType, 4 | AttributeAction, 5 | IgnoreCaseMode, 6 | } from "../types.js"; 7 | 8 | export const tests: [ 9 | selector: string, 10 | expected: Selector[][], 11 | message: string, 12 | ][] = [ 13 | // Tag names 14 | [ 15 | "div", 16 | [ 17 | [ 18 | { 19 | type: SelectorType.Tag, 20 | namespace: null, 21 | name: "div", 22 | }, 23 | ], 24 | ], 25 | "simple tag", 26 | ], 27 | [ 28 | "*", 29 | [ 30 | [ 31 | { 32 | type: SelectorType.Universal, 33 | namespace: null, 34 | }, 35 | ], 36 | ], 37 | "universal", 38 | ], 39 | 40 | // Traversal 41 | [ 42 | "div div", 43 | [ 44 | [ 45 | { 46 | type: SelectorType.Tag, 47 | namespace: null, 48 | name: "div", 49 | }, 50 | { 51 | type: SelectorType.Descendant, 52 | }, 53 | { 54 | type: SelectorType.Tag, 55 | namespace: null, 56 | name: "div", 57 | }, 58 | ], 59 | ], 60 | "descendant", 61 | ], 62 | [ 63 | "div\t \n \tdiv", 64 | [ 65 | [ 66 | { 67 | type: SelectorType.Tag, 68 | namespace: null, 69 | name: "div", 70 | }, 71 | { 72 | type: SelectorType.Descendant, 73 | }, 74 | { 75 | type: SelectorType.Tag, 76 | namespace: null, 77 | name: "div", 78 | }, 79 | ], 80 | ], 81 | "descendant /w whitespace", 82 | ], 83 | [ 84 | "div + div", 85 | [ 86 | [ 87 | { 88 | type: SelectorType.Tag, 89 | namespace: null, 90 | name: "div", 91 | }, 92 | { 93 | type: SelectorType.Adjacent, 94 | }, 95 | { 96 | type: SelectorType.Tag, 97 | namespace: null, 98 | name: "div", 99 | }, 100 | ], 101 | ], 102 | "adjacent", 103 | ], 104 | [ 105 | "div ~ div", 106 | [ 107 | [ 108 | { 109 | type: SelectorType.Tag, 110 | namespace: null, 111 | name: "div", 112 | }, 113 | { 114 | type: SelectorType.Sibling, 115 | }, 116 | { 117 | type: SelectorType.Tag, 118 | namespace: null, 119 | name: "div", 120 | }, 121 | ], 122 | ], 123 | "sibling", 124 | ], 125 | [ 126 | "p < div", 127 | [ 128 | [ 129 | { 130 | type: SelectorType.Tag, 131 | namespace: null, 132 | name: "p", 133 | }, 134 | { 135 | type: SelectorType.Parent, 136 | }, 137 | { 138 | type: SelectorType.Tag, 139 | namespace: null, 140 | name: "div", 141 | }, 142 | ], 143 | ], 144 | "parent", 145 | ], 146 | 147 | // Escaped whitespace 148 | [ 149 | String.raw`#\ > a `, 150 | [ 151 | [ 152 | { 153 | type: SelectorType.Attribute, 154 | namespace: null, 155 | action: AttributeAction.Equals, 156 | name: "id", 157 | ignoreCase: IgnoreCaseMode.QuirksMode, 158 | value: " ", 159 | }, 160 | { 161 | type: SelectorType.Child, 162 | }, 163 | { 164 | type: SelectorType.Tag, 165 | namespace: null, 166 | name: "a", 167 | }, 168 | ], 169 | ], 170 | "Space between escaped space and combinator", 171 | ], 172 | [ 173 | String.raw`.\ `, 174 | [ 175 | [ 176 | { 177 | type: SelectorType.Attribute, 178 | namespace: null, 179 | name: "class", 180 | action: AttributeAction.Element, 181 | ignoreCase: IgnoreCaseMode.QuirksMode, 182 | value: " ", 183 | }, 184 | ], 185 | ], 186 | "Space after escaped space", 187 | ], 188 | [ 189 | ".m™²³", 190 | [ 191 | [ 192 | { 193 | type: SelectorType.Attribute, 194 | namespace: null, 195 | name: "class", 196 | action: AttributeAction.Element, 197 | ignoreCase: IgnoreCaseMode.QuirksMode, 198 | value: "m™²³", 199 | }, 200 | ], 201 | ], 202 | "Special charecters in selector", 203 | ], 204 | [ 205 | String.raw`\61 `, 206 | [ 207 | [ 208 | { 209 | type: SelectorType.Tag, 210 | namespace: null, 211 | name: "a", 212 | }, 213 | ], 214 | ], 215 | "Numeric escape with space (BMP)", 216 | ], 217 | [ 218 | String.raw`\1d306\01d306`, 219 | [ 220 | [ 221 | { 222 | type: SelectorType.Tag, 223 | namespace: null, 224 | name: "\uD834\uDF06\uD834\uDF06", 225 | }, 226 | ], 227 | ], 228 | "Numeric escape (outside BMP)", 229 | ], 230 | [ 231 | String.raw`#\26 B`, 232 | [ 233 | [ 234 | { 235 | type: SelectorType.Attribute, 236 | namespace: null, 237 | action: AttributeAction.Equals, 238 | name: "id", 239 | ignoreCase: IgnoreCaseMode.QuirksMode, 240 | value: "&B", 241 | }, 242 | ], 243 | ], 244 | "id selector with escape sequence", 245 | ], 246 | 247 | // Attributes 248 | [ 249 | '[name^="foo["]', 250 | [ 251 | [ 252 | { 253 | type: SelectorType.Attribute, 254 | namespace: null, 255 | name: "name", 256 | ignoreCase: IgnoreCaseMode.Unknown, 257 | action: AttributeAction.Start, 258 | value: "foo[", 259 | }, 260 | ], 261 | ], 262 | "quoted attribute", 263 | ], 264 | [ 265 | '[name^="foo[bar]"]', 266 | [ 267 | [ 268 | { 269 | type: SelectorType.Attribute, 270 | namespace: null, 271 | name: "name", 272 | ignoreCase: IgnoreCaseMode.Unknown, 273 | action: AttributeAction.Start, 274 | value: "foo[bar]", 275 | }, 276 | ], 277 | ], 278 | "quoted attribute", 279 | ], 280 | [ 281 | '[name$="[bar]"]', 282 | [ 283 | [ 284 | { 285 | type: SelectorType.Attribute, 286 | namespace: null, 287 | name: "name", 288 | ignoreCase: IgnoreCaseMode.Unknown, 289 | action: AttributeAction.End, 290 | value: "[bar]", 291 | }, 292 | ], 293 | ], 294 | "quoted attribute", 295 | ], 296 | [ 297 | '[href *= "google"]', 298 | [ 299 | [ 300 | { 301 | type: SelectorType.Attribute, 302 | namespace: null, 303 | name: "href", 304 | ignoreCase: IgnoreCaseMode.Unknown, 305 | action: AttributeAction.Any, 306 | value: "google", 307 | }, 308 | ], 309 | ], 310 | "quoted attribute with spaces", 311 | ], 312 | [ 313 | '[value="\nsome text\n"]', 314 | [ 315 | [ 316 | { 317 | type: SelectorType.Attribute, 318 | namespace: null, 319 | name: "value", 320 | ignoreCase: IgnoreCaseMode.Unknown, 321 | action: AttributeAction.Equals, 322 | value: "\nsome text\n", 323 | }, 324 | ], 325 | ], 326 | "quoted attribute with internal newline", 327 | ], 328 | [ 329 | String.raw`[name=foo\.baz]`, 330 | [ 331 | [ 332 | { 333 | type: SelectorType.Attribute, 334 | namespace: null, 335 | name: "name", 336 | ignoreCase: IgnoreCaseMode.Unknown, 337 | action: AttributeAction.Equals, 338 | value: "foo.baz", 339 | }, 340 | ], 341 | ], 342 | "attribute with escaped dot", 343 | ], 344 | [ 345 | String.raw`[name=foo\[bar\]]`, 346 | [ 347 | [ 348 | { 349 | type: SelectorType.Attribute, 350 | namespace: null, 351 | name: "name", 352 | ignoreCase: IgnoreCaseMode.Unknown, 353 | action: AttributeAction.Equals, 354 | value: "foo[bar]", 355 | }, 356 | ], 357 | ], 358 | "attribute with escaped square brackets", 359 | ], 360 | [ 361 | String.raw`[xml\:test]`, 362 | [ 363 | [ 364 | { 365 | type: SelectorType.Attribute, 366 | namespace: null, 367 | name: "xml:test", 368 | action: AttributeAction.Exists, 369 | value: "", 370 | ignoreCase: IgnoreCaseMode.Unknown, 371 | }, 372 | ], 373 | ], 374 | "escaped attribute", 375 | ], 376 | [ 377 | "[name='foo ~ < > , bar' i]", 378 | [ 379 | [ 380 | { 381 | type: SelectorType.Attribute, 382 | namespace: null, 383 | name: "name", 384 | action: AttributeAction.Equals, 385 | value: "foo ~ < > , bar", 386 | ignoreCase: IgnoreCaseMode.IgnoreCase, 387 | }, 388 | ], 389 | ], 390 | "attribute with previously normalized characters", 391 | ], 392 | 393 | // ID starting with a dot 394 | [ 395 | "#.identifier", 396 | [ 397 | [ 398 | { 399 | type: SelectorType.Attribute, 400 | namespace: null, 401 | action: AttributeAction.Equals, 402 | name: "id", 403 | ignoreCase: IgnoreCaseMode.QuirksMode, 404 | value: ".identifier", 405 | }, 406 | ], 407 | ], 408 | "ID starting with a dot", 409 | ], 410 | 411 | // Pseudo elements 412 | [ 413 | "::foo", 414 | [ 415 | [ 416 | { 417 | type: SelectorType.PseudoElement, 418 | name: "foo", 419 | data: null, 420 | }, 421 | ], 422 | ], 423 | "pseudo-element", 424 | ], 425 | [ 426 | "::foo()", 427 | [ 428 | [ 429 | { 430 | type: SelectorType.PseudoElement, 431 | name: "foo", 432 | data: "", 433 | }, 434 | ], 435 | ], 436 | "pseudo-element", 437 | ], 438 | [ 439 | "::foo(bar())", 440 | [ 441 | [ 442 | { 443 | type: SelectorType.PseudoElement, 444 | name: "foo", 445 | data: "bar()", 446 | }, 447 | ], 448 | ], 449 | "pseudo-element", 450 | ], 451 | 452 | // Pseudo selectors 453 | [ 454 | ":foo", 455 | [ 456 | [ 457 | { 458 | type: SelectorType.Pseudo, 459 | name: "foo", 460 | data: null, 461 | }, 462 | ], 463 | ], 464 | "pseudo selector without any data", 465 | ], 466 | [ 467 | ":bar(baz)", 468 | [ 469 | [ 470 | { 471 | type: SelectorType.Pseudo, 472 | name: "bar", 473 | data: "baz", 474 | }, 475 | ], 476 | ], 477 | "pseudo selector with data", 478 | ], 479 | [ 480 | ':contains("(foo)")', 481 | [ 482 | [ 483 | { 484 | type: SelectorType.Pseudo, 485 | name: "contains", 486 | data: "(foo)", 487 | }, 488 | ], 489 | ], 490 | "pseudo selector with data", 491 | ], 492 | [ 493 | ":where(a)", 494 | [ 495 | [ 496 | { 497 | type: SelectorType.Pseudo, 498 | name: "where", 499 | data: [ 500 | [ 501 | { 502 | type: SelectorType.Tag, 503 | namespace: null, 504 | name: "a", 505 | }, 506 | ], 507 | ], 508 | }, 509 | ], 510 | ], 511 | "pseudo selector with data", 512 | ], 513 | [ 514 | String.raw`:contains("(a((foo\\\))))")`, 515 | [ 516 | [ 517 | { 518 | type: SelectorType.Pseudo, 519 | name: "contains", 520 | data: "(a((foo))))", 521 | }, 522 | ], 523 | ], 524 | "pseudo selector with escaped data", 525 | ], 526 | [ 527 | ":icontains('')", 528 | [ 529 | [ 530 | { 531 | type: SelectorType.Pseudo, 532 | name: "icontains", 533 | data: "", 534 | }, 535 | ], 536 | ], 537 | "pseudo selector with quote-stripped data", 538 | ], 539 | [ 540 | ':contains("(foo)")', 541 | [ 542 | [ 543 | { 544 | type: SelectorType.Pseudo, 545 | name: "contains", 546 | data: "(foo)", 547 | }, 548 | ], 549 | ], 550 | "pseudo selector with data", 551 | ], 552 | 553 | // Multiple selectors 554 | [ 555 | "a , b", 556 | [ 557 | [ 558 | { 559 | type: SelectorType.Tag, 560 | namespace: null, 561 | name: "a", 562 | }, 563 | ], 564 | [ 565 | { 566 | type: SelectorType.Tag, 567 | namespace: null, 568 | name: "b", 569 | }, 570 | ], 571 | ], 572 | "multiple selectors", 573 | ], 574 | 575 | [ 576 | ":host(h1, p)", 577 | [ 578 | [ 579 | { 580 | type: SelectorType.Pseudo, 581 | name: "host", 582 | data: [ 583 | [ 584 | { 585 | type: SelectorType.Tag, 586 | namespace: null, 587 | name: "h1", 588 | }, 589 | ], 590 | [ 591 | { 592 | type: SelectorType.Tag, 593 | namespace: null, 594 | name: "p", 595 | }, 596 | ], 597 | ], 598 | }, 599 | ], 600 | ], 601 | "pseudo selector with data", 602 | ], 603 | 604 | /* 605 | * Bad attributes (taken from Sizzle) 606 | * https://github.com/jquery/sizzle/blob/af163873d7cdfc57f18b16c04b1915209533f0b1/test/unit/selector.js#L602-L651 607 | */ 608 | [ 609 | "[id=types_all]", 610 | [ 611 | [ 612 | { 613 | type: SelectorType.Attribute, 614 | namespace: null, 615 | action: AttributeAction.Equals, 616 | name: "id", 617 | ignoreCase: IgnoreCaseMode.Unknown, 618 | value: "types_all", 619 | }, 620 | ], 621 | ], 622 | "Underscores don't need escaping", 623 | ], 624 | [ 625 | String.raw`[name=foo\ bar]`, 626 | [ 627 | [ 628 | { 629 | type: SelectorType.Attribute, 630 | namespace: null, 631 | action: AttributeAction.Equals, 632 | name: "name", 633 | value: "foo bar", 634 | ignoreCase: IgnoreCaseMode.Unknown, 635 | }, 636 | ], 637 | ], 638 | "Escaped space", 639 | ], 640 | [ 641 | String.raw`[name=foo\.baz]`, 642 | [ 643 | [ 644 | { 645 | type: SelectorType.Attribute, 646 | namespace: null, 647 | action: AttributeAction.Equals, 648 | name: "name", 649 | value: "foo.baz", 650 | ignoreCase: IgnoreCaseMode.Unknown, 651 | }, 652 | ], 653 | ], 654 | "Escaped dot", 655 | ], 656 | [ 657 | String.raw`[name=foo\[baz\]]`, 658 | [ 659 | [ 660 | { 661 | type: SelectorType.Attribute, 662 | namespace: null, 663 | action: AttributeAction.Equals, 664 | name: "name", 665 | value: "foo[baz]", 666 | ignoreCase: IgnoreCaseMode.Unknown, 667 | }, 668 | ], 669 | ], 670 | "Escaped brackets", 671 | ], 672 | [ 673 | String.raw`[data-attr='foo_baz\']']`, 674 | [ 675 | [ 676 | { 677 | type: SelectorType.Attribute, 678 | namespace: null, 679 | action: AttributeAction.Equals, 680 | name: "data-attr", 681 | value: "foo_baz']", 682 | ignoreCase: IgnoreCaseMode.Unknown, 683 | }, 684 | ], 685 | ], 686 | "Escaped quote + right bracket", 687 | ], 688 | [ 689 | String.raw`[data-attr='\'']`, 690 | [ 691 | [ 692 | { 693 | type: SelectorType.Attribute, 694 | namespace: null, 695 | action: AttributeAction.Equals, 696 | name: "data-attr", 697 | value: "'", 698 | ignoreCase: IgnoreCaseMode.Unknown, 699 | }, 700 | ], 701 | ], 702 | "Quoted quote", 703 | ], 704 | [ 705 | String.raw`[data-attr='\\']`, 706 | [ 707 | [ 708 | { 709 | type: SelectorType.Attribute, 710 | namespace: null, 711 | action: AttributeAction.Equals, 712 | name: "data-attr", 713 | value: "\\", 714 | ignoreCase: IgnoreCaseMode.Unknown, 715 | }, 716 | ], 717 | ], 718 | "Quoted backslash", 719 | ], 720 | [ 721 | String.raw`[data-attr='\\\'']`, 722 | [ 723 | [ 724 | { 725 | type: SelectorType.Attribute, 726 | namespace: null, 727 | action: AttributeAction.Equals, 728 | name: "data-attr", 729 | value: String.raw`\'`, 730 | ignoreCase: IgnoreCaseMode.Unknown, 731 | }, 732 | ], 733 | ], 734 | "Quoted backslash quote", 735 | ], 736 | [ 737 | String.raw`[data-attr='\\\\']`, 738 | [ 739 | [ 740 | { 741 | type: SelectorType.Attribute, 742 | namespace: null, 743 | action: AttributeAction.Equals, 744 | name: "data-attr", 745 | value: "\\\\", 746 | ignoreCase: IgnoreCaseMode.Unknown, 747 | }, 748 | ], 749 | ], 750 | "Quoted backslash backslash", 751 | ], 752 | [ 753 | String.raw`[data-attr='\5C\\']`, 754 | [ 755 | [ 756 | { 757 | type: SelectorType.Attribute, 758 | namespace: null, 759 | action: AttributeAction.Equals, 760 | name: "data-attr", 761 | value: "\\\\", 762 | ignoreCase: IgnoreCaseMode.Unknown, 763 | }, 764 | ], 765 | ], 766 | "Quoted backslash backslash (numeric escape)", 767 | ], 768 | [ 769 | String.raw`[data-attr='\5C \\']`, 770 | [ 771 | [ 772 | { 773 | type: SelectorType.Attribute, 774 | namespace: null, 775 | action: AttributeAction.Equals, 776 | name: "data-attr", 777 | value: "\\\\", 778 | ignoreCase: IgnoreCaseMode.Unknown, 779 | }, 780 | ], 781 | ], 782 | "Quoted backslash backslash (numeric escape with trailing space)", 783 | ], 784 | [ 785 | "[data-attr='\\5C\t\\\\']", 786 | [ 787 | [ 788 | { 789 | type: SelectorType.Attribute, 790 | namespace: null, 791 | action: AttributeAction.Equals, 792 | name: "data-attr", 793 | value: "\\\\", 794 | ignoreCase: IgnoreCaseMode.Unknown, 795 | }, 796 | ], 797 | ], 798 | "Quoted backslash backslash (numeric escape with trailing tab)", 799 | ], 800 | [ 801 | String.raw`[data-attr='\04e00']`, 802 | [ 803 | [ 804 | { 805 | type: SelectorType.Attribute, 806 | namespace: null, 807 | action: AttributeAction.Equals, 808 | name: "data-attr", 809 | value: "\u4E00", 810 | ignoreCase: IgnoreCaseMode.Unknown, 811 | }, 812 | ], 813 | ], 814 | "Long numeric escape (BMP)", 815 | ], 816 | [ 817 | String.raw`[data-attr='\01D306A']`, 818 | [ 819 | [ 820 | { 821 | type: SelectorType.Attribute, 822 | namespace: null, 823 | action: AttributeAction.Equals, 824 | name: "data-attr", 825 | value: "\uD834\uDF06A", 826 | ignoreCase: IgnoreCaseMode.Unknown, 827 | }, 828 | ], 829 | ], 830 | "Long numeric escape (non-BMP)", 831 | ], 832 | [ 833 | "fOo[baR]", 834 | [ 835 | [ 836 | { 837 | name: "fOo", 838 | type: SelectorType.Tag, 839 | namespace: null, 840 | }, 841 | { 842 | action: AttributeAction.Exists, 843 | name: "baR", 844 | type: SelectorType.Attribute, 845 | namespace: null, 846 | value: "", 847 | ignoreCase: IgnoreCaseMode.Unknown, 848 | }, 849 | ], 850 | ], 851 | "Mixed case tag and attribute name", 852 | ], 853 | 854 | // Namespaces 855 | [ 856 | "foo|bar", 857 | [ 858 | [ 859 | { 860 | name: "bar", 861 | type: SelectorType.Tag, 862 | namespace: "foo", 863 | }, 864 | ], 865 | ], 866 | "basic tag namespace", 867 | ], 868 | [ 869 | "*|bar", 870 | [ 871 | [ 872 | { 873 | name: "bar", 874 | type: SelectorType.Tag, 875 | namespace: "*", 876 | }, 877 | ], 878 | ], 879 | "star tag namespace", 880 | ], 881 | [ 882 | "|bar", 883 | [ 884 | [ 885 | { 886 | name: "bar", 887 | type: SelectorType.Tag, 888 | namespace: "", 889 | }, 890 | ], 891 | ], 892 | "without namespace", 893 | ], 894 | [ 895 | "*|*", 896 | [ 897 | [ 898 | { 899 | type: SelectorType.Universal, 900 | namespace: "*", 901 | }, 902 | ], 903 | ], 904 | "universal with namespace", 905 | ], 906 | [ 907 | "[foo|bar]", 908 | [ 909 | [ 910 | { 911 | action: AttributeAction.Exists, 912 | name: "bar", 913 | type: SelectorType.Attribute, 914 | namespace: "foo", 915 | value: "", 916 | ignoreCase: IgnoreCaseMode.Unknown, 917 | }, 918 | ], 919 | ], 920 | "basic attribute namespace, existential", 921 | ], 922 | [ 923 | "[|bar]", 924 | [ 925 | [ 926 | { 927 | action: AttributeAction.Exists, 928 | name: "bar", 929 | type: SelectorType.Attribute, 930 | namespace: null, 931 | value: "", 932 | ignoreCase: IgnoreCaseMode.Unknown, 933 | }, 934 | ], 935 | ], 936 | "without namespace, existential", 937 | ], 938 | [ 939 | "[foo|bar='baz' i]", 940 | [ 941 | [ 942 | { 943 | action: AttributeAction.Equals, 944 | ignoreCase: IgnoreCaseMode.IgnoreCase, 945 | name: "bar", 946 | type: SelectorType.Attribute, 947 | namespace: "foo", 948 | value: "baz", 949 | }, 950 | ], 951 | ], 952 | "basic attribute namespace, equality", 953 | ], 954 | [ 955 | "[*|bar='baz' i]", 956 | [ 957 | [ 958 | { 959 | action: AttributeAction.Equals, 960 | ignoreCase: IgnoreCaseMode.IgnoreCase, 961 | name: "bar", 962 | type: SelectorType.Attribute, 963 | namespace: "*", 964 | value: "baz", 965 | }, 966 | ], 967 | ], 968 | "star attribute namespace", 969 | ], 970 | [ 971 | "[type='a' S]", 972 | [ 973 | [ 974 | { 975 | action: AttributeAction.Equals, 976 | ignoreCase: IgnoreCaseMode.CaseSensitive, 977 | name: "type", 978 | type: SelectorType.Attribute, 979 | value: "a", 980 | namespace: null, 981 | }, 982 | ], 983 | ], 984 | "case-sensitive attribute selector", 985 | ], 986 | [ 987 | "foo || bar", 988 | [ 989 | [ 990 | { 991 | name: "foo", 992 | namespace: null, 993 | type: SelectorType.Tag, 994 | }, 995 | { 996 | type: SelectorType.ColumnCombinator, 997 | }, 998 | { 999 | name: "bar", 1000 | namespace: null, 1001 | type: SelectorType.Tag, 1002 | }, 1003 | ], 1004 | ], 1005 | "column combinator", 1006 | ], 1007 | [ 1008 | "foo||bar", 1009 | [ 1010 | [ 1011 | { 1012 | name: "foo", 1013 | namespace: null, 1014 | type: SelectorType.Tag, 1015 | }, 1016 | { 1017 | type: SelectorType.ColumnCombinator, 1018 | }, 1019 | { 1020 | name: "bar", 1021 | namespace: null, 1022 | type: SelectorType.Tag, 1023 | }, 1024 | ], 1025 | ], 1026 | "column combinator without whitespace", 1027 | ], 1028 | ]; 1029 | --------------------------------------------------------------------------------