From 93bb1a2100c0ee46c3c214733fd518993a579326 Mon Sep 17 00:00:00 2001 From: Marat Dulin Date: Wed, 5 Jul 2023 20:30:10 +0200 Subject: [PATCH] feat: upgrade API in order to reflect upcoming complexity in CSS selectors BREAKING CHANGE: API is backwards incompatible --- CHANGELOG.md | 304 ++++++++++++++ README.md | 121 +++--- docs/interfaces/AstClassName.md | 34 ++ docs/interfaces/AstFactory.md | 172 +++++++- docs/interfaces/AstFormulaOfSelector.md | 2 +- docs/interfaces/AstId.md | 34 ++ docs/interfaces/AstPseudoElement.md | 46 +++ docs/interfaces/AstRule.md | 53 +-- docs/modules.md | 40 +- package-lock.json | 4 +- src/ast.ts | 112 +++-- src/index.ts | 3 + src/parser.ts | 161 +++++--- ...ass-signatures.ts => pseudo-signatures.ts} | 30 +- src/render.ts | 153 ++++--- src/syntax-definitions.ts | 231 ++++++----- src/utils.ts | 4 +- test/ast.test.ts | 3 + test/parser.test.ts | 385 +++++++++++------- test/render.test.ts | 18 +- 20 files changed, 1301 insertions(+), 609 deletions(-) create mode 100644 docs/interfaces/AstClassName.md create mode 100644 docs/interfaces/AstId.md create mode 100644 docs/interfaces/AstPseudoElement.md rename src/{pseudo-class-signatures.ts => pseudo-signatures.ts} (67%) diff --git a/CHANGELOG.md b/CHANGELOG.md index a712354..778113f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,310 @@ All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines. +## [3.0.0](https://github.com/mdevils/css-selector-parser/compare/v2.3.2...v3.0.0) (2023-09-26) + + +### ⚠ BREAKING CHANGES + +* API is backwards incompatible. + +#### Migrating from 2.x to 3.x + +1. `Rule.tag` was moved to `Rule.items`. + + Example selector: `div`. + * Before: `{type: 'Rule', tagName: {type: 'TagName', name: 'div'}}` + * After: `{type: 'Rule', items: [{type: 'TagName', name: 'div'}]}` + +2. `Rule.classNames` was converted to an AST entity and moved to `Rule.items`. + + Example selector: `.user.hidden` + * Before: `{type: 'Rule', classNames: ['user', 'hidden']}` + * After: `{type: 'Rule', items: [{type: 'ClassName', name: 'user'}, {type: 'ClassName', name: 'hidden'}]}` + +3. `Rule.ids` was converted to an AST entity and moved to `Rule.items`. + + Example selector: `#root#user-1` + * Before: `{type: 'Rule', ids: ['root', 'user-1']}` + * After: `{type: 'Rule', items: [{type: 'Id', name: 'root'}, {type: 'Id', name: 'user-1'}]}` + +4. `Rule.attributes` was moved to `Rule.items`. + + Example selector: `[href^=/][role]` + * Before: `{type: 'Rule', attributes: [{type: 'Attribute', name: 'href', operator: '^=', value: {type: 'String', value: '/'}}, {type: 'Attribute', name: 'role'}]}` + * After: `{type: 'Rule', items: [{type: 'Attribute', name: 'href', operator: '^=', value: {type: 'String', value: '/'}}, {type: 'Attribute', name: 'role'}]}` + +5. `Rule.pseudoClasses` was moved to `Rule.items`. + + Example selector: `:hover:lang(en)` + * Before: `{type: 'Rule', pseudoClasses: [{type: 'PseudoClass', name: 'hover'}, {type: 'PseudoClass', name: 'lang', value: {type: 'String', value: 'en'}}]}` + * After: `{type: 'Rule', items: [{type: 'PseudoClass', name: 'hover'}, {type: 'PseudoClass', name: 'lang', value: {type: 'String', value: 'en'}}]}` + +6. `Rule.pseudoElement` was converted to an AST entity and moved to `Rule.items`. + + Example selector: `::before` + * Before: `{type: 'Rule', pseudoElement: 'before'}` + * After: `{type: 'Rule', items: [{type: 'PseudoElement', name: 'before'}]}` + +#### New AST methods + +* `ast.id` and `ast.isId` to create and test ast nodes with type `Id`. +* `ast.className` and `ast.isClassName` to create and test ast nodes with type `ClassName`. +* `ast.pseudoElement` and `ast.isPseudoElement` to create and test ast nodes with type `PseudoElement`. + +#### New Syntax Definition configuration + +* `pseudoElements.definitions` was updated to accept signatures in otder to support specifying pseudo-elements with + an argument. + Example: `createParser({syntax: {pseudoElements: {definitions: {NoArgument: ['before'], String: ['highlight'], Selector: ['slotted']}}}})`. + +#### Migrating from 1.x to 3.x + +#### `CssSelectorParser` -> `createParser` + +In 1.x versions there was `CssSelectorParser` class which had to be contructed and then configured. +In 3.x versions there is `createParser()` function which returns a `parse()` function. All the configutation is passed +to `createParser()` params. + +Before: + +```javascript +var CssSelectorParser = require('css-selector-parser').CssSelectorParser, +parser = new CssSelectorParser(); +parser.registerSelectorPseudos('has'); +parser.registerNumericPseudos('nth-child'); +parser.registerNestingOperators('>', '+', '~'); +parser.registerAttrEqualityMods('^', '$', '*', '~'); + +const selector = parser.parse('a[href^=/], .container:has(nav) > a[href]:lt($var):nth-child(5)'); +``` + +After: + +```javascript +import {createParser} from 'css-selector-parser'; + +const parse = createParser({ + syntax: { + pseudoClasses: { + // In 1.x any pseudo-classes were accepted. + // in 2.x parser only accepts known psuedo-classes unless `unknown: accept` was specified. + unknown: 'accept', + definitions: { + // This is a replacement for registerSelectorPseudos(). + Selector: ['has'], + // This is a replacement for registerNumericPseudos(). + Formula: ['nth-child'] + } + }, + // This is a replacement for registerNestingOperators(). + combinators: ['>', '+', '~'], + attributes: { + // This a replacement for registerAttrEqualityMods(). + // Note that equals sign ("=") is included into the operator definitions. + operators: ['^=', '$=', '*=', '~='] + } + }, + // This is a replacement for enableSubstitutes() + substitutes: true +}); + +const selector = parse('a[href^=/], .container:has(nav) > a[href]:lt($var):nth-child(5)'); +``` + +* [All syntax definition options.](docs/interfaces/SyntaxDefinition.md) +* [All the psudo-class definition options.](docs/interfaces/SyntaxDefinition.md#pseudoclasses) +* [All the attribute definition options.](docs/interfaces/SyntaxDefinition.md#attributes) + +#### Predefined CSS syntax definitions + +You no longer need to make an extensive configuration of `css-selector-parser` in order to make it understand +the necessary CSS standards. You can now just define CSS/CSS selectors version directly: + +```javascript +import {createParser} from 'css-selector-parser'; + +const parse = createParser({syntax: 'css3'}); + +const selector = parse('a[href^=/], .container:has(nav) > a[href]:nth-child(2n + 1)::before'); +``` + +Here are the pre-defined CSS standards for your disposal: + +* `css1`: https://www.w3.org/TR/CSS1/ +* `css2`: https://www.w3.org/TR/CSS2/ +* `css3`/`selectors-3`: https://www.w3.org/TR/selectors-3/ +* `selectors-4`: https://www.w3.org/TR/selectors-4/ +* `latest`: refers to `selectors-4` +* `progressive`: `latest` + accepts unknown psudo-classes, psudo-elements and attribute case sensitivity modifiers + +#### Make sure you use proper `strict` value + +CSS selector parser in modern browsers is very forgiving. For instance, it works fine with unclosed attribute +selectors: `"[attr=value"`. If you would like to mimic this behavior from browsers, set `strict` to `false`, i.e.: + +```javascript +import {createParser} from 'css-selector-parser'; + +const parse = createParser({syntax: 'css3', strict: false}); + +const selector = parse(':lang(en'); // doesn't crash +``` + +#### Render is now a separate export + +`render()` method used to be a method of `CssSelectorParser` class. Now it can be imported directly and used. + +Example: + +```javascript +import {createParser, render} from 'css-selector-parser'; + +const parse = createParser({syntax: 'progressive'}); + +const selector = parse('div#user-123.user:lang(en)::before'); + +console.log(render(selector)); // div#user-123.user:lang(en)::before +``` + +#### AST changes + +AST had a lot of changes. + +#### Selector + +[New type info.](docs/interfaces/AstSelector.md) + +1. Type changed: `selector` -> `Selector`. +2. Prop changed: `selectors` -> `rules`, also `selectors` contained `ruleSet[]`, which in turn has `rule` field, + and new `rules` contains `Rule[]` directly. + +Before: `{type: 'selector', selectors: [ {type: 'ruleSet', rule: {}}, {type: 'ruleSet', rule: {}} ]}`. + +After: `{type: 'Selector', rules: [ {}, {} ]}`. + +#### Rule + +[New type info.](docs/interfaces/AstRule.md) + +1. Type changed: `rule` -> `Rule`. +2. Prop changed: `id: string` -> `items: [{type: 'Id', name: ''}, ...]`. According to the CSS spec one rule may have more + than 1 `id`, so `#root#root` is a valid selector. +3. Prop renamed: `nestingOperator` -> `combinator`. A proper name according to CSS spec was chosen. +4. Prop renamed: `rule` -> `nestedRule`. A proper name to indicate nesting was chosen. +5. Prop changed: `tagName: string` -> `items: [TagName | WildcardTag, ...]`. Using explicit distinction between + TagName (i.e. `div`) and WildcardTag (`*`), because tag name can also be `*` if escaped properly (`\*`). +6. Prop changed: `attrs` -> `attributes`. Attribute type was changed, see below. +7. Prop changed: `pseudos` -> `pseudoClasses`. There are pseudo-elements and pseudo-classes, now they are separated + properly (there is a separate `pseudoElement` type). Pseudo class type was changed, see below. + +Before: + +```javascript +({ + type: 'rule', + tagName: 'div', + id: 'user-123', + classNames: ['user'], + attrs: [ + {name: 'role', operator: '$=', valueType: 'string', value: 'button'} + ], + pseudos: [ + {name: 'lang', valueType: 'string', value: 'en'} + ], + nestingOperator: '>' +}) +``` + +After: + +```javascript +({ + type: 'Rule', + items: [ + {type: 'TagName', name: 'div'}, + {type: 'Id', name: 'user-123'}, + {type: 'ClassName', name: 'user'}, + {type: 'Attribute', name: 'role', operator: '$=', value: {type: 'String', value: 'button'}}, + {type: 'PseudoClass', name: 'lang', value: {type: 'String', value: 'en'}} + ], + combinator: '>' +}) +``` + +#### Attribute + +[New type info.](docs/interfaces/AstAttribute.md) + +1. Type introduced: `Attribute`. +2. Prop `value` and `valueType` were combined to a single prop `value` with a field `type`. + +[All possible value types.](docs/interfaces/AstAttribute.md#value) + + +##### Example 1 + +Before: `{name: 'role'}`. + +After: `{type: 'Attribute', name: 'role'}`. + +##### Example 2 + +Before: `{name: 'role', operator: '$=', valueType: 'string', value: 'button'}`. + +After: `{type: 'Attribute', name: 'role', operator: '$=', value: {type: 'String', value: 'button'}}`. + +##### Example 3 + +Before: `{name: 'role', operator: '=', valueType: 'substitute', value: 'var'}`. + +After: `{type: 'Attribute', name: 'role', operator: '=', value: {type: 'Substitute', name: 'var'}}`. + +#### Pseudo Classes + +[New type info.](docs/interfaces/AstPseudoClass.md) + +1. Type introduced: `PseudoClass`. +2. Prop `value` and `valueType` were combined to a single prop `argument` with a field `type`. + +[All possible argument types.](docs/interfaces/AstPseudoClass.md#argument) + +##### Example 1 + +Before: `{name: 'visited'}`. + +After: `{type: 'PseudoClass', name: 'visited'}`. + +##### Example 2 + +Before: `{name: 'lang', valueType: 'string', value: 'en'}`. + +After: `{type: 'PseudoClass', name: 'lang', argument: {type: 'String', value: 'en'}}`. + +##### Example 3 + +Before: `{name: 'lang', valueType: 'substitute', value: 'var'}`. + +After: `{type: 'PseudoClass', name: 'lang', argument: {type: 'Substitute', name: 'var'}}`. + +##### Example 4 + +Before: `{name: 'has', valueType: 'selector', value: {type: 'selector', selectors: [{type: 'ruleSet', rule: {type: 'rule', tagName: 'div'}}]}}`. + +After: `{type: 'PseudoClass', name: 'has', argument: {type: 'Selector', rules: [{type: 'Rule', tag: {type: 'TagName', name: 'div'}}]}}`. + +#### Pseudo Elements + +[New type info.](docs/interfaces/AstPseudoElement.md) + +1. Type introduced: `PseudoElement`. + +[All possible argument types.](docs/interfaces/AstPseudoElement.md#argument) + +### Features + +* upgrade API in order to reflect upcoming complexity in CSS selectors ([cece4df](https://github.com/mdevils/css-selector-parser/commit/cece4dff647b19c6211dd6c9defbd7887eca62b5)) + ### [2.3.2](https://github.com/mdevils/css-selector-parser/compare/v2.3.1...v2.3.2) (2023-06-25) diff --git a/README.md b/README.md index df965c9..431deae 100644 --- a/README.md +++ b/README.md @@ -7,15 +7,17 @@ css-selector-parser * Covered with tests. * Documented. * Supported CSS selector standards: - * `css1`: https://www.w3.org/TR/CSS1/ - * `css2`: https://www.w3.org/TR/CSS2/ - * `css3`/`selectors-3`: https://www.w3.org/TR/selectors-3/ - * `selectors-4`: https://www.w3.org/TR/selectors-4/ - * `latest`: refers to `selectors-4` - * `progressive`: `latest` + accepts unknown psudo-classes, psudo-elements and attribute case sensitivity modifiers + * `css1`: https://www.w3.org/TR/CSS1/ + * `css2`: https://www.w3.org/TR/CSS2/ + * `css3`/`selectors-3`: https://www.w3.org/TR/selectors-3/ + * `selectors-4`: https://www.w3.org/TR/selectors-4/ + * `latest`: refers to `selectors-4` + * `progressive`: `latest` + accepts unknown psudo-classes, psudo-elements and attribute case sensitivity modifiers **Important:** [Migrating from 1.x](CHANGELOG.md#220). +Latest releases: [Changelog](CHANGELOG.md). + Installation ------------ @@ -41,48 +43,53 @@ Produces: ```javascript ({ - type: 'Selector', - rules: [ - { - type: 'Rule', - tag: { type: 'TagName', name: 'a' }, - attributes: [ + type: 'Selector', + rules: [ { - type: 'Attribute', - name: 'href', - operator: '^=', - value: { type: 'String', value: '/' } - } - ] - }, - { - type: 'Rule', - classNames: [ 'container' ], - pseudoClasses: [ + type: 'Rule', + items: [ + { type: 'TagName', name: 'a' }, + { + type: 'Attribute', + name: 'href', + operator: '^=', + value: { type: 'String', value: '/' } + } + ] + }, { - type: 'PseudoClass', - name: 'has', - argument: { - type: 'Selector', - rules: [ { type: 'Rule', tag: { type: 'TagName', name: 'nav' } } ] - } + type: 'Rule', + items: [ + { type: 'ClassName', name: 'container' }, + { + type: 'PseudoClass', + name: 'has', + argument: { + type: 'Selector', + rules: [ + { + type: 'Rule', + items: [ { type: 'TagName', name: 'nav' } ] + } + ] + } + } + ], + nestedRule: { + type: 'Rule', + items: [ + { type: 'TagName', name: 'a' }, + { type: 'Attribute', name: 'href' }, + { + type: 'PseudoClass', + name: 'nth-child', + argument: { type: 'Formula', a: 0, b: 2 } + } + ], + combinator: '>' + } } - ], - nestedRule: { - type: 'Rule', - combinator: '>', - tag: { type: 'TagName', name: 'a' }, - attributes: [ { type: 'Attribute', name: 'href' } ], - pseudoClasses: [ - { - type: 'PseudoClass', - name: 'nth-child', - argument: { type: 'Formula', a: 0, b: 2 } - } - ] - } - } - ] + ] }) ``` @@ -94,34 +101,32 @@ import {ast, render} from 'css-selector-parser'; const selector = ast.selector({ rules: [ ast.rule({ - tag: ast.tagName({name: 'a'}), - attributes: [ + items: [ + ast.tagName({name: 'a'}), ast.attribute({name: 'href', operator: '^=', value: ast.string({value: '/'})}) ] }), ast.rule({ - classNames: ['container'], - pseudoClasses: [ + items: [ + ast.className({name: 'container'}), ast.pseudoClass({ name: 'has', argument: ast.selector({ - rules: [ - ast.rule({tag: ast.tagName({name: 'nav'})}) - ] + rules: [ast.rule({items: [ast.tagName({name: 'nav'})]})] }) }) ], nestedRule: ast.rule({ combinator: '>', - tag: ast.tagName({name: 'a'}), - attributes: [ast.attribute({name: 'href'})], - pseudoClasses: [ + items: [ + ast.tagName({name: 'a'}), + ast.attribute({name: 'href'}), ast.pseudoClass({ name: 'nth-child', argument: ast.formula({a: 0, b: 2}) - }) - ], - pseudoElement: 'before' + }), + ast.pseudoElement({name: 'before'}) + ] }) }) ] diff --git a/docs/interfaces/AstClassName.md b/docs/interfaces/AstClassName.md new file mode 100644 index 0000000..8e72cbd --- /dev/null +++ b/docs/interfaces/AstClassName.md @@ -0,0 +1,34 @@ +[css-selector-parser](../../README.md) / [Exports](../modules.md) / AstClassName + +# Interface: AstClassName + +Class name condition. Matches by class attribute value. +Generated by [ast.className](AstFactory.md#classname). +https://developer.mozilla.org/en-US/docs/Web/CSS/ID_selectors + +**`Example`** + +```ts +".user" +``` + +## Table of contents + +### Properties + +- [name](AstClassName.md#name) +- [type](AstClassName.md#type) + +## Properties + +### name + +• **name**: `string` + +ID name. I.e. `.user` -> `"user"`. + +___ + +### type + +• **type**: ``"ClassName"`` diff --git a/docs/interfaces/AstFactory.md b/docs/interfaces/AstFactory.md index f1118c1..1a8da1e 100644 --- a/docs/interfaces/AstFactory.md +++ b/docs/interfaces/AstFactory.md @@ -9,28 +9,27 @@ AstSelector was specified. **`Example`** ```ts -// Represents CSS selector: ns|div#user-34.user.user-active[role=button]:lang(en) > * +// Represents CSS selector: ns|div#user-34.user.user-active[role="button"]:lang(en)::before > * const selector = ast.selector({ rules: [ ast.rule({ - tag: ast.tagName({name: 'div', namespace: ast.namespaceName({name: 'ns'})}), - ids: ['user-34'], - classNames: ['user', 'user-active'], - attributes: [ + items: [ + ast.tagName({name: 'div', namespace: ast.namespaceName({name: 'ns'})}), + ast.id({name: 'user-34'}), + ast.className({name: 'user'}), + ast.className({name: 'user-active'}), ast.attribute({ name: 'role', operator: '=', value: ast.string({value: 'button'}) - }) - ], - pseudoClasses: [ + }), ast.pseudoClass({ name: 'lang', - argument: ast.string({value: 'eng'}) - }) + argument: ast.string({value: 'en'}) + }), + ast.pseudoElement({name: 'before'}) ], - pseudoElement: 'before', - nestedRule: ast.rule({combinator: '>', tag: ast.wildcardTag()}) + nestedRule: ast.rule({combinator: '>', items: [ast.wildcardTag()]}) }) ] }); @@ -43,14 +42,19 @@ console.log(ast.isRule(selector)); // prints false ### Properties - [attribute](AstFactory.md#attribute) +- [className](AstFactory.md#classname) - [formula](AstFactory.md#formula) - [formulaOfSelector](AstFactory.md#formulaofselector) +- [id](AstFactory.md#id) - [isAttribute](AstFactory.md#isattribute) +- [isClassName](AstFactory.md#isclassname) - [isFormula](AstFactory.md#isformula) - [isFormulaOfSelector](AstFactory.md#isformulaofselector) +- [isId](AstFactory.md#isid) - [isNamespaceName](AstFactory.md#isnamespacename) - [isNoNamespace](AstFactory.md#isnonamespace) - [isPseudoClass](AstFactory.md#ispseudoclass) +- [isPseudoElement](AstFactory.md#ispseudoelement) - [isRule](AstFactory.md#isrule) - [isSelector](AstFactory.md#isselector) - [isString](AstFactory.md#isstring) @@ -61,6 +65,7 @@ console.log(ast.isRule(selector)); // prints false - [namespaceName](AstFactory.md#namespacename) - [noNamespace](AstFactory.md#nonamespace) - [pseudoClass](AstFactory.md#pseudoclass) +- [pseudoElement](AstFactory.md#pseudoelement) - [rule](AstFactory.md#rule) - [selector](AstFactory.md#selector) - [string](AstFactory.md#string) @@ -95,6 +100,28 @@ console.log(ast.isRule(selector)); // prints false [`AstAttribute`](AstAttribute.md) +___ + +### className + +• **className**: (`props`: { `name`: `string` }) => [`AstClassName`](AstClassName.md) + +#### Type declaration + +▸ (`props`): [`AstClassName`](AstClassName.md) + +##### Parameters + +| Name | Type | Description | +| :------ | :------ | :------ | +| `props` | `Object` | - | +| `props.name` | `string` | ID name. I.e. `.user` -> `"user"`. | + +##### Returns + +[`AstClassName`](AstClassName.md) + + ___ ### formula @@ -142,6 +169,28 @@ ___ [`AstFormulaOfSelector`](AstFormulaOfSelector.md) +___ + +### id + +• **id**: (`props`: { `name`: `string` }) => [`AstId`](AstId.md) + +#### Type declaration + +▸ (`props`): [`AstId`](AstId.md) + +##### Parameters + +| Name | Type | Description | +| :------ | :------ | :------ | +| `props` | `Object` | - | +| `props.name` | `string` | ID name. I.e. `#root` -> `"root"`. | + +##### Returns + +[`AstId`](AstId.md) + + ___ ### isAttribute @@ -163,6 +212,27 @@ ___ entity is AstAttribute +___ + +### isClassName + +• **isClassName**: (`entity`: `unknown`) => entity is AstClassName + +#### Type declaration + +▸ (`entity`): entity is AstClassName + +##### Parameters + +| Name | Type | +| :------ | :------ | +| `entity` | `unknown` | + +##### Returns + +entity is AstClassName + + ___ ### isFormula @@ -205,6 +275,27 @@ ___ entity is AstFormulaOfSelector +___ + +### isId + +• **isId**: (`entity`: `unknown`) => entity is AstId + +#### Type declaration + +▸ (`entity`): entity is AstId + +##### Parameters + +| Name | Type | +| :------ | :------ | +| `entity` | `unknown` | + +##### Returns + +entity is AstId + + ___ ### isNamespaceName @@ -268,6 +359,27 @@ ___ entity is AstPseudoClass +___ + +### isPseudoElement + +• **isPseudoElement**: (`entity`: `unknown`) => entity is AstPseudoElement + +#### Type declaration + +▸ (`entity`): entity is AstPseudoElement + +##### Parameters + +| Name | Type | +| :------ | :------ | +| `entity` | `unknown` | + +##### Returns + +entity is AstPseudoElement + + ___ ### isRule @@ -481,29 +593,47 @@ ___ [`AstPseudoClass`](AstPseudoClass.md) +___ + +### pseudoElement + +• **pseudoElement**: (`props`: { `argument?`: [`AstSelector`](AstSelector.md) \| [`AstSubstitution`](AstSubstitution.md) \| [`AstString`](AstString.md) ; `name`: `string` }) => [`AstPseudoElement`](AstPseudoElement.md) + +#### Type declaration + +▸ (`props`): [`AstPseudoElement`](AstPseudoElement.md) + +##### Parameters + +| Name | Type | Description | +| :------ | :------ | :------ | +| `props` | `Object` | - | +| `props.argument?` | [`AstSelector`](AstSelector.md) \| [`AstSubstitution`](AstSubstitution.md) \| [`AstString`](AstString.md) | Pseudo-element value (i.e. `"foo"` in case of `"::part(foo)"`). | +| `props.name` | `string` | Pseudo-element name (i.e. `"before"` in case of `"::before"`). | + +##### Returns + +[`AstPseudoElement`](AstPseudoElement.md) + + ___ ### rule -• **rule**: (`props?`: { `attributes?`: [`AstAttribute`](AstAttribute.md)[] ; `classNames?`: `string`[] ; `combinator?`: `string` ; `ids?`: `string`[] ; `nestedRule?`: [`AstRule`](AstRule.md) ; `pseudoClasses?`: [`AstPseudoClass`](AstPseudoClass.md)[] ; `pseudoElement?`: `string` ; `tag?`: [`AstTagName`](AstTagName.md) \| [`AstWildcardTag`](AstWildcardTag.md) }) => [`AstRule`](AstRule.md) +• **rule**: (`props`: { `combinator?`: `string` ; `items`: ([`AstTagName`](AstTagName.md) \| [`AstWildcardTag`](AstWildcardTag.md) \| [`AstId`](AstId.md) \| [`AstClassName`](AstClassName.md) \| [`AstPseudoClass`](AstPseudoClass.md) \| [`AstAttribute`](AstAttribute.md) \| [`AstPseudoElement`](AstPseudoElement.md))[] ; `nestedRule?`: [`AstRule`](AstRule.md) }) => [`AstRule`](AstRule.md) #### Type declaration -▸ (`props?`): [`AstRule`](AstRule.md) +▸ (`props`): [`AstRule`](AstRule.md) ##### Parameters | Name | Type | Description | | :------ | :------ | :------ | -| `props?` | `Object` | - | -| `props.attributes?` | [`AstAttribute`](AstAttribute.md)[] | List of attributes (i.e. `"[href][role=button]"` -> `[{name: 'href'}, {name: 'role', operator: '=', value: {type: 'String', value: 'button'}}]`) | -| `props.classNames?` | `string`[] | List of CSS classes (i.e. `".c1.c2"` -> `['c1', 'c2']`) | +| `props` | `Object` | - | | `props.combinator?` | `string` | Rule combinator which was used to nest this rule (i.e. `">"` in case of `"div > span"` if the current rule is `"span"`). | -| `props.ids?` | `string`[] | List of IDs (i.e. `"#root"` -> `['root']`). | +| `props.items` | ([`AstTagName`](AstTagName.md) \| [`AstWildcardTag`](AstWildcardTag.md) \| [`AstId`](AstId.md) \| [`AstClassName`](AstClassName.md) \| [`AstPseudoClass`](AstPseudoClass.md) \| [`AstAttribute`](AstAttribute.md) \| [`AstPseudoElement`](AstPseudoElement.md))[] | Items of a CSS rule. Can be tag, ids, class names, pseudo-classes and pseudo-elements. | | `props.nestedRule?` | [`AstRule`](AstRule.md) | Nested rule if specified (i.e. `"div > span"`). | -| `props.pseudoClasses?` | [`AstPseudoClass`](AstPseudoClass.md)[] | Pseudo-classes (i.e. `":link"` -> `[{name: 'link'}]`). | -| `props.pseudoElement?` | `string` | Pseudo-element (i.e. `"::before"` -> `'before'`). | -| `props.tag?` | [`AstTagName`](AstTagName.md) \| [`AstWildcardTag`](AstWildcardTag.md) | Tag definition. Can be either TagName (i.e. `"div"`) or WildcardTag (`"*"`) if defined. | ##### Returns diff --git a/docs/interfaces/AstFormulaOfSelector.md b/docs/interfaces/AstFormulaOfSelector.md index e504683..5d9d3d7 100644 --- a/docs/interfaces/AstFormulaOfSelector.md +++ b/docs/interfaces/AstFormulaOfSelector.md @@ -5,7 +5,7 @@ Pseudo-class formula of selector value. `a` is multiplier of `n` and `b` us added on top. Formula: `an + b`. Formula is followed by `of` keyword and then goes a CSS selector. For instance `:nth-child(2n + 1 of div)` -> -`{type: 'AstPseudoClass'..., argument: {type: 'FormulaOfSelector', a: 2, b: 1, selector: {type: 'Selector', rules: [{type: 'Rule', tag: {type: 'TagName', name: 'div'}}]}}}`. +`{type: 'AstPseudoClass'..., argument: {type: 'FormulaOfSelector', a: 2, b: 1, selector: {type: 'Selector', rules: [{type: 'Rule', items: [{type: 'TagName', name: 'div'}]}]}}}`. Generated by [ast.formulaOfSelector](AstFactory.md#formulaofselector). **`See`** diff --git a/docs/interfaces/AstId.md b/docs/interfaces/AstId.md new file mode 100644 index 0000000..228dad6 --- /dev/null +++ b/docs/interfaces/AstId.md @@ -0,0 +1,34 @@ +[css-selector-parser](../../README.md) / [Exports](../modules.md) / AstId + +# Interface: AstId + +ID condition. Matches by id attribute value. +Generated by [ast.id](AstFactory.md#id). +https://developer.mozilla.org/en-US/docs/Web/CSS/ID_selectors + +**`Example`** + +```ts +"#root" +``` + +## Table of contents + +### Properties + +- [name](AstId.md#name) +- [type](AstId.md#type) + +## Properties + +### name + +• **name**: `string` + +ID name. I.e. `#root` -> `"root"`. + +___ + +### type + +• **type**: ``"Id"`` diff --git a/docs/interfaces/AstPseudoElement.md b/docs/interfaces/AstPseudoElement.md new file mode 100644 index 0000000..66cce2e --- /dev/null +++ b/docs/interfaces/AstPseudoElement.md @@ -0,0 +1,46 @@ +[css-selector-parser](../../README.md) / [Exports](../modules.md) / AstPseudoElement + +# Interface: AstPseudoElement + +Pseudo-class selector. +Generated by [ast.pseudoElement](AstFactory.md#pseudoelement). + +**`See`** + +https://developer.mozilla.org/en-US/docs/Learn/CSS/Building_blocks/Selectors/Pseudo-classes_and_pseudo-elements + +**`Example`** + +```ts +"::before" +``` + +## Table of contents + +### Properties + +- [argument](AstPseudoElement.md#argument) +- [name](AstPseudoElement.md#name) +- [type](AstPseudoElement.md#type) + +## Properties + +### argument + +• `Optional` **argument**: [`AstSelector`](AstSelector.md) \| [`AstSubstitution`](AstSubstitution.md) \| [`AstString`](AstString.md) + +Pseudo-element value (i.e. `"foo"` in case of `"::part(foo)"`). + +___ + +### name + +• **name**: `string` + +Pseudo-element name (i.e. `"before"` in case of `"::before"`). + +___ + +### type + +• **type**: ``"PseudoElement"`` diff --git a/docs/interfaces/AstRule.md b/docs/interfaces/AstRule.md index 044bee3..e579854 100644 --- a/docs/interfaces/AstRule.md +++ b/docs/interfaces/AstRule.md @@ -10,34 +10,13 @@ Generated by [ast.rule](AstFactory.md#rule). ### Properties -- [attributes](AstRule.md#attributes) -- [classNames](AstRule.md#classnames) - [combinator](AstRule.md#combinator) -- [ids](AstRule.md#ids) +- [items](AstRule.md#items) - [nestedRule](AstRule.md#nestedrule) -- [pseudoClasses](AstRule.md#pseudoclasses) -- [pseudoElement](AstRule.md#pseudoelement) -- [tag](AstRule.md#tag) - [type](AstRule.md#type) ## Properties -### attributes - -• `Optional` **attributes**: [`AstAttribute`](AstAttribute.md)[] - -List of attributes (i.e. `"[href][role=button]"` -> `[{name: 'href'}, {name: 'role', operator: '=', value: {type: 'String', value: 'button'}}]`) - -___ - -### classNames - -• `Optional` **classNames**: `string`[] - -List of CSS classes (i.e. `".c1.c2"` -> `['c1', 'c2']`) - -___ - ### combinator • `Optional` **combinator**: `string` @@ -46,11 +25,11 @@ Rule combinator which was used to nest this rule (i.e. `">"` in case of `"div > ___ -### ids +### items -• `Optional` **ids**: `string`[] +• **items**: ([`AstTagName`](AstTagName.md) \| [`AstWildcardTag`](AstWildcardTag.md) \| [`AstId`](AstId.md) \| [`AstClassName`](AstClassName.md) \| [`AstPseudoClass`](AstPseudoClass.md) \| [`AstAttribute`](AstAttribute.md) \| [`AstPseudoElement`](AstPseudoElement.md))[] -List of IDs (i.e. `"#root"` -> `['root']`). +Items of a CSS rule. Can be tag, ids, class names, pseudo-classes and pseudo-elements. ___ @@ -62,30 +41,6 @@ Nested rule if specified (i.e. `"div > span"`). ___ -### pseudoClasses - -• `Optional` **pseudoClasses**: [`AstPseudoClass`](AstPseudoClass.md)[] - -Pseudo-classes (i.e. `":link"` -> `[{name: 'link'}]`). - -___ - -### pseudoElement - -• `Optional` **pseudoElement**: `string` - -Pseudo-element (i.e. `"::before"` -> `'before'`). - -___ - -### tag - -• `Optional` **tag**: [`AstTagName`](AstTagName.md) \| [`AstWildcardTag`](AstWildcardTag.md) - -Tag definition. Can be either TagName (i.e. `"div"`) or WildcardTag (`"*"`) if defined. - -___ - ### type • **type**: ``"Rule"`` diff --git a/docs/modules.md b/docs/modules.md index aaaaf1a..dff2653 100644 --- a/docs/modules.md +++ b/docs/modules.md @@ -7,12 +7,15 @@ ### Interfaces - [AstAttribute](interfaces/AstAttribute.md) +- [AstClassName](interfaces/AstClassName.md) - [AstFactory](interfaces/AstFactory.md) - [AstFormula](interfaces/AstFormula.md) - [AstFormulaOfSelector](interfaces/AstFormulaOfSelector.md) +- [AstId](interfaces/AstId.md) - [AstNamespaceName](interfaces/AstNamespaceName.md) - [AstNoNamespace](interfaces/AstNoNamespace.md) - [AstPseudoClass](interfaces/AstPseudoClass.md) +- [AstPseudoElement](interfaces/AstPseudoElement.md) - [AstRule](interfaces/AstRule.md) - [AstSelector](interfaces/AstSelector.md) - [AstString](interfaces/AstString.md) @@ -42,7 +45,7 @@ ### AstEntity -Ƭ **AstEntity**: [`AstSelector`](interfaces/AstSelector.md) \| [`AstRule`](interfaces/AstRule.md) \| [`AstTagName`](interfaces/AstTagName.md) \| [`AstWildcardTag`](interfaces/AstWildcardTag.md) \| [`AstNamespaceName`](interfaces/AstNamespaceName.md) \| [`AstWildcardNamespace`](interfaces/AstWildcardNamespace.md) \| [`AstNoNamespace`](interfaces/AstNoNamespace.md) \| [`AstSubstitution`](interfaces/AstSubstitution.md) \| [`AstString`](interfaces/AstString.md) \| [`AstFormula`](interfaces/AstFormula.md) \| [`AstFormulaOfSelector`](interfaces/AstFormulaOfSelector.md) \| [`AstPseudoClass`](interfaces/AstPseudoClass.md) \| [`AstAttribute`](interfaces/AstAttribute.md) +Ƭ **AstEntity**: [`AstSelector`](interfaces/AstSelector.md) \| [`AstRule`](interfaces/AstRule.md) \| [`AstTagName`](interfaces/AstTagName.md) \| [`AstWildcardTag`](interfaces/AstWildcardTag.md) \| [`AstId`](interfaces/AstId.md) \| [`AstClassName`](interfaces/AstClassName.md) \| [`AstNamespaceName`](interfaces/AstNamespaceName.md) \| [`AstWildcardNamespace`](interfaces/AstWildcardNamespace.md) \| [`AstNoNamespace`](interfaces/AstNoNamespace.md) \| [`AstSubstitution`](interfaces/AstSubstitution.md) \| [`AstString`](interfaces/AstString.md) \| [`AstFormula`](interfaces/AstFormula.md) \| [`AstFormulaOfSelector`](interfaces/AstFormulaOfSelector.md) \| [`AstPseudoClass`](interfaces/AstPseudoClass.md) \| [`AstAttribute`](interfaces/AstAttribute.md) \| [`AstPseudoElement`](interfaces/AstPseudoElement.md) One of CSS AST entity types. @@ -89,28 +92,27 @@ AstSelector was specified. **`Example`** ```ts -// Represents CSS selector: ns|div#user-34.user.user-active[role=button]:lang(en) > * +// Represents CSS selector: ns|div#user-34.user.user-active[role="button"]:lang(en)::before > * const selector = ast.selector({ rules: [ ast.rule({ - tag: ast.tagName({name: 'div', namespace: ast.namespaceName({name: 'ns'})}), - ids: ['user-34'], - classNames: ['user', 'user-active'], - attributes: [ + items: [ + ast.tagName({name: 'div', namespace: ast.namespaceName({name: 'ns'})}), + ast.id({name: 'user-34'}), + ast.className({name: 'user'}), + ast.className({name: 'user-active'}), ast.attribute({ name: 'role', operator: '=', value: ast.string({value: 'button'}) - }) - ], - pseudoClasses: [ + }), ast.pseudoClass({ name: 'lang', - argument: ast.string({value: 'eng'}) - }) + argument: ast.string({value: 'en'}) + }), + ast.pseudoElement({name: 'before'}) ], - pseudoElement: 'before', - nestedRule: ast.rule({combinator: '>', tag: ast.wildcardTag()}) + nestedRule: ast.rule({combinator: '>', items: [ast.wildcardTag()]}) }) ] }); @@ -155,10 +157,12 @@ import {ast, render} from 'css-selector-parser'; const selector = ast.selector({ rules: [ ast.rule({ - tag: ast.tagName({name: 'a'}), - ids: ['user-23'], - classNames: ['user'], - pseudoClasses: [ast.pseudoClass({name: 'visited'})] + items: [ + ast.tagName({name: 'a'}), + ast.id({name: 'user-23'}), + ast.className({name: 'user'}), + ast.pseudoClass({name: 'visited'}) + ] }) ] }); @@ -170,7 +174,7 @@ console.log(render(selector)); // a#user-23.user:visited | Name | Type | | :------ | :------ | -| `entity` | [`AstSelector`](interfaces/AstSelector.md) \| [`AstRule`](interfaces/AstRule.md) | +| `entity` | [`AstEntity`](modules.md#astentity) | #### Returns diff --git a/package-lock.json b/package-lock.json index c82f5b8..d3592f3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "css-selector-parser", - "version": "2.3.2", + "version": "3.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "css-selector-parser", - "version": "2.3.2", + "version": "3.0.0", "funding": [ { "type": "github", diff --git a/src/ast.ts b/src/ast.ts index 9ffd07c..76c91c8 100644 --- a/src/ast.ts +++ b/src/ast.ts @@ -19,18 +19,8 @@ export interface AstSelector { */ export interface AstRule { type: 'Rule'; - /** Tag definition. Can be either TagName (i.e. `"div"`) or WildcardTag (`"*"`) if defined. */ - tag?: AstTagName | AstWildcardTag; - /** List of CSS classes (i.e. `".c1.c2"` -> `['c1', 'c2']`) */ - classNames?: string[]; - /** List of IDs (i.e. `"#root"` -> `['root']`). */ - ids?: string[]; - /** Pseudo-element (i.e. `"::before"` -> `'before'`). */ - pseudoElement?: string; - /** Pseudo-classes (i.e. `":link"` -> `[{name: 'link'}]`). */ - pseudoClasses?: AstPseudoClass[]; - /** List of attributes (i.e. `"[href][role=button]"` -> `[{name: 'href'}, {name: 'role', operator: '=', value: {type: 'String', value: 'button'}}]`) */ - attributes?: AstAttribute[]; + /** Items of a CSS rule. Can be tag, ids, class names, pseudo-classes and pseudo-elements. */ + items: (AstTagName | AstWildcardTag | AstId | AstClassName | AstAttribute | AstPseudoClass | AstPseudoElement)[]; /** Rule combinator which was used to nest this rule (i.e. `">"` in case of `"div > span"` if the current rule is `"span"`). */ combinator?: string; /** Nested rule if specified (i.e. `"div > span"`). */ @@ -50,6 +40,31 @@ export interface AstTagName { /** Namespace according to https://www.w3.org/TR/css3-namespace/. */ namespace?: AstNamespaceName | AstWildcardNamespace | AstNoNamespace; } + +/** + * ID condition. Matches by id attribute value. + * Generated by {@link AstFactory.id ast.id}. + * https://developer.mozilla.org/en-US/docs/Web/CSS/ID_selectors + * @example "#root" + */ +export interface AstId { + type: 'Id'; + /** ID name. I.e. `#root` -> `"root"`. */ + name: string; +} + +/** + * Class name condition. Matches by class attribute value. + * Generated by {@link AstFactory.className ast.className}. + * https://developer.mozilla.org/en-US/docs/Web/CSS/ID_selectors + * @example ".user" + */ +export interface AstClassName { + type: 'ClassName'; + /** ID name. I.e. `.user` -> `"user"`. */ + name: string; +} + /** * Wildcard tag (universal selector): `*`. * Generated by {@link AstFactory.wildcardTag ast.wildcardTag}. @@ -123,6 +138,20 @@ export interface AstPseudoClass { argument?: AstSubstitution | AstSelector | AstString | AstFormula | AstFormulaOfSelector; } +/** + * Pseudo-class selector. + * Generated by {@link AstFactory.pseudoElement ast.pseudoElement}. + * @see https://developer.mozilla.org/en-US/docs/Learn/CSS/Building_blocks/Selectors/Pseudo-classes_and_pseudo-elements + * @example "::before" + */ +export interface AstPseudoElement { + type: 'PseudoElement'; + /** Pseudo-element name (i.e. `"before"` in case of `"::before"`). */ + name: string; + /** Pseudo-element value (i.e. `"foo"` in case of `"::part(foo)"`). */ + argument?: AstSubstitution | AstString | AstSelector; +} + /** * String value. Can be used as attribute value of pseudo-class string value. * For instance `:lang(en)` -> `{type: 'AstPseudoClass'..., argument: {type: 'String', value: 'en'}}`. @@ -152,7 +181,7 @@ export interface AstFormula { * Pseudo-class formula of selector value. `a` is multiplier of `n` and `b` us added on top. Formula: `an + b`. * Formula is followed by `of` keyword and then goes a CSS selector. * For instance `:nth-child(2n + 1 of div)` -> - * `{type: 'AstPseudoClass'..., argument: {type: 'FormulaOfSelector', a: 2, b: 1, selector: {type: 'Selector', rules: [{type: 'Rule', tag: {type: 'TagName', name: 'div'}}]}}}`. + * `{type: 'AstPseudoClass'..., argument: {type: 'FormulaOfSelector', a: 2, b: 1, selector: {type: 'Selector', rules: [{type: 'Rule', items: [{type: 'TagName', name: 'div'}]}]}}}`. * Generated by {@link AstFactory.formulaOfSelector ast.formulaOfSelector}. * @see https://developer.mozilla.org/en-US/docs/Web/CSS/:nth-child#functional_notation */ @@ -178,12 +207,17 @@ export interface AstSubstitution { /** One of pseudo-class argument types. */ export type AstPseudoClassArgument = AstSubstitution | AstSelector | AstString | AstFormula | AstFormulaOfSelector; +/** One of pseudo-element argument types. */ +export type AstPseudoElementArgument = AstSubstitution | AstString | AstSelector; + /** One of CSS AST entity types. */ export type AstEntity = | AstSelector | AstRule | AstTagName | AstWildcardTag + | AstId + | AstClassName | AstNamespaceName | AstWildcardNamespace | AstNoNamespace @@ -192,7 +226,8 @@ export type AstEntity = | AstFormula | AstFormulaOfSelector | AstPseudoClass - | AstAttribute; + | AstAttribute + | AstPseudoElement; function astMethods(type: EN['type']) { type GenInput = Omit; @@ -237,28 +272,27 @@ type AstFactoryBase = {[K in keyof ToAstFactory]: ToAstFactory * + * // Represents CSS selector: ns|div#user-34.user.user-active[role="button"]:lang(en)::before > * * const selector = ast.selector({ * rules: [ * ast.rule({ - * tag: ast.tagName({name: 'div', namespace: ast.namespaceName({name: 'ns'})}), - * ids: ['user-34'], - * classNames: ['user', 'user-active'], - * attributes: [ + * items: [ + * ast.tagName({name: 'div', namespace: ast.namespaceName({name: 'ns'})}), + * ast.id({name: 'user-34'}), + * ast.className({name: 'user'}), + * ast.className({name: 'user-active'}), * ast.attribute({ * name: 'role', * operator: '=', * value: ast.string({value: 'button'}) - * }) - * ], - * pseudoClasses: [ + * }), * ast.pseudoClass({ * name: 'lang', - * argument: ast.string({value: 'eng'}) - * }) + * argument: ast.string({value: 'en'}) + * }), + * ast.pseudoElement({name: 'before'}) * ], - * pseudoElement: 'before', - * nestedRule: ast.rule({combinator: '>', tag: ast.wildcardTag()}) + * nestedRule: ast.rule({combinator: '>', items: [ast.wildcardTag()]}) * }) * ] * }); @@ -274,28 +308,27 @@ export interface AstFactory extends AstFactoryBase {} * * @example * - * // Represents CSS selector: ns|div#user-34.user.user-active[role=button]:lang(en) > * + * // Represents CSS selector: ns|div#user-34.user.user-active[role="button"]:lang(en)::before > * * const selector = ast.selector({ * rules: [ * ast.rule({ - * tag: ast.tagName({name: 'div', namespace: ast.namespaceName({name: 'ns'})}), - * ids: ['user-34'], - * classNames: ['user', 'user-active'], - * attributes: [ + * items: [ + * ast.tagName({name: 'div', namespace: ast.namespaceName({name: 'ns'})}), + * ast.id({name: 'user-34'}), + * ast.className({name: 'user'}), + * ast.className({name: 'user-active'}), * ast.attribute({ * name: 'role', * operator: '=', * value: ast.string({value: 'button'}) - * }) - * ], - * pseudoClasses: [ + * }), * ast.pseudoClass({ * name: 'lang', - * argument: ast.string({value: 'eng'}) - * }) + * argument: ast.string({value: 'en'}) + * }), + * ast.pseudoElement({name: 'before'}) * ], - * pseudoElement: 'before', - * nestedRule: ast.rule({combinator: '>', tag: ast.wildcardTag()}) + * nestedRule: ast.rule({combinator: '>', items: [ast.wildcardTag()]}) * }) * ] * }); @@ -306,12 +339,15 @@ export const ast: AstFactory = { ...astMethods('Selector')('selector', 'isSelector'), ...astMethods('Rule')('rule', 'isRule'), ...astMethods('TagName')('tagName', 'isTagName'), + ...astMethods('Id')('id', 'isId'), + ...astMethods('ClassName')('className', 'isClassName'), ...astMethods('WildcardTag')('wildcardTag', 'isWildcardTag'), ...astMethods('NamespaceName')('namespaceName', 'isNamespaceName'), ...astMethods('WildcardNamespace')('wildcardNamespace', 'isWildcardNamespace'), ...astMethods('NoNamespace')('noNamespace', 'isNoNamespace'), ...astMethods('Attribute')('attribute', 'isAttribute'), ...astMethods('PseudoClass')('pseudoClass', 'isPseudoClass'), + ...astMethods('PseudoElement')('pseudoElement', 'isPseudoElement'), ...astMethods('String')('string', 'isString'), ...astMethods('Formula')('formula', 'isFormula'), ...astMethods('FormulaOfSelector')('formulaOfSelector', 'isFormulaOfSelector'), diff --git a/src/index.ts b/src/index.ts index 9b4d3c5..8cc54f0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,13 +3,16 @@ export {render} from './render.js'; export { ast, AstAttribute, + AstClassName, AstEntity, AstFactory, AstFormula, AstFormulaOfSelector, + AstId, AstNamespaceName, AstNoNamespace, AstPseudoClass, + AstPseudoElement, AstRule, AstSelector, AstString, diff --git a/src/parser.ts b/src/parser.ts index 4cad782..c6b562f 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -1,4 +1,14 @@ -import {AstAttribute, AstPseudoClass, AstRule, AstSelector, AstTagName, AstWildcardTag} from './ast.js'; +import { + AstAttribute, + AstPseudoClass, + AstPseudoClassArgument, + AstPseudoElement, + AstPseudoElementArgument, + AstRule, + AstSelector, + AstTagName, + AstWildcardTag +} from './ast.js'; import { createMulticharIndex, createRegularIndex, @@ -7,10 +17,11 @@ import { MulticharIndex } from './indexes.js'; import { - calculatePseudoClassSignatures, - defaultPseudoClassSignature, - emptyPseudoClassSignatures -} from './pseudo-class-signatures.js'; + calculatePseudoSignatures, + defaultPseudoSignature, + emptyPseudoSignatures, + PseudoSignature +} from './pseudo-signatures.js'; import { CssLevel, cssSyntaxDefinitions, @@ -66,8 +77,7 @@ export function createParser( } = {} ): Parser { const {syntax = 'latest', substitutes, strict = true} = options; - // noinspection SuspiciousTypeOfGuard - let syntaxDefinition: SyntaxDefinition = typeof syntax === 'string' ? cssSyntaxDefinitions[syntax] : syntax; + let syntaxDefinition: SyntaxDefinition = typeof syntax === 'object' ? syntax : cssSyntaxDefinitions[syntax]; if (syntaxDefinition.baseSyntax) { syntaxDefinition = extendSyntaxDefinition(cssSyntaxDefinitions[syntaxDefinition.baseSyntax], syntaxDefinition); @@ -112,21 +122,21 @@ export function createParser( const attributesCaseSensitivityModifiersEnabled = attributesAcceptUnknownCaseSensitivityModifiers || Object.keys(attributesCaseSensitivityModifiers).length > 0; - const [pseudoClassesEnabled, paeudoClassesDefinitions, pseudoClassesAcceptUnknown] = syntaxDefinition.pseudoClasses + const [pseudoClassesEnabled, pseudoClassesDefinitions, pseudoClassesAcceptUnknown] = syntaxDefinition.pseudoClasses ? [ true, syntaxDefinition.pseudoClasses.definitions - ? calculatePseudoClassSignatures(syntaxDefinition.pseudoClasses.definitions) - : emptyPseudoClassSignatures, + ? calculatePseudoSignatures(syntaxDefinition.pseudoClasses.definitions) + : emptyPseudoSignatures, syntaxDefinition.pseudoClasses.unknown === 'accept' ] - : [false, emptyPseudoClassSignatures, false]; + : [false, emptyPseudoSignatures, false]; const [ pseudoElementsEnabled, pseudoElementsSingleColonNotationEnabled, pseudoElementsDoubleColonNotationEnabled, - pseudoElementsIndex, + pseudoElementsDefinitions, pseudoElementsAcceptUnknown ] = syntaxDefinition.pseudoElements ? [ @@ -137,11 +147,15 @@ export function createParser( syntaxDefinition.pseudoElements.notation === 'doubleColon' || syntaxDefinition.pseudoElements.notation === 'both', syntaxDefinition.pseudoElements.definitions - ? createRegularIndex(syntaxDefinition.pseudoElements.definitions) - : emptyRegularIndex, + ? calculatePseudoSignatures( + Array.isArray(syntaxDefinition.pseudoElements.definitions) + ? {NoArgument: syntaxDefinition.pseudoElements.definitions} + : syntaxDefinition.pseudoElements.definitions + ) + : emptyPseudoSignatures, syntaxDefinition.pseudoElements.unknown === 'accept' ] - : [false, false, false, emptyRegularIndex, false]; + : [false, false, false, emptyPseudoSignatures, false]; let str = ''; let l = str.length; @@ -149,7 +163,7 @@ export function createParser( let chr = ''; const is = (comparison: string) => chr === comparison; - const isTagStart = () => is('*') || isIdentStart(chr) || is('\\'); + const isTagStart = () => is('*') || isIdentStart(chr); const rewind = (newPos: number) => { pos = newPos; chr = str.charAt(pos); @@ -344,7 +358,7 @@ export function createParser( if (is('|')) { const savedPos = pos; next(); - if (isIdentStart(chr) || is('\\')) { + if (isIdentStart(chr)) { assert(namespaceEnabled, 'Namespaces are not enabled.'); attr = { type: 'Attribute', @@ -479,51 +493,45 @@ export function createParser( } } - function parsePseudoClass(pseudoName: string) { - const pseudo: AstPseudoClass = { - type: 'PseudoClass', - name: pseudoName - }; - - let pseudoDefinition = paeudoClassesDefinitions[pseudoName]; - if (!pseudoDefinition && pseudoClassesAcceptUnknown) { - pseudoDefinition = defaultPseudoClassSignature; - } - assert(pseudoDefinition, `Unknown pseudo-class: "${pseudoName}".`); - pseudoDefinition = pseudoDefinition!; + function parsePseudoArgument( + pseudoName: string, + type: 'pseudo-class' | 'pseudo-element', + signature: PseudoSignature + ): AstPseudoClassArgument | AstPseudoElementArgument | undefined { + let argument: AstPseudoClassArgument | AstPseudoElementArgument | undefined; if (is('(')) { next(); skipWhitespace(); if (substitutesEnabled && is('$')) { next(); - pseudo.argument = { + argument = { type: 'Substitution', name: parseIdentifier() }; - assert(pseudo.argument.name, 'Expected substitute name.'); - } else if (pseudoDefinition.type === 'String') { - pseudo.argument = { + assert(argument.name, 'Expected substitute name.'); + } else if (signature.type === 'String') { + argument = { type: 'String', value: parsePseudoClassString() }; - assert(pseudo.argument.value, 'Expected pseudo-class argument value.'); - } else if (pseudoDefinition.type === 'Selector') { - pseudo.argument = parseSelector(true); - } else if (pseudoDefinition.type === 'Formula') { + assert(argument.value, `Expected ${type} argument value.`); + } else if (signature.type === 'Selector') { + argument = parseSelector(true); + } else if (signature.type === 'Formula') { const [a, b] = parseFormula(); - pseudo.argument = { + argument = { type: 'Formula', a, b }; - if (pseudoDefinition.ofSelector) { + if (signature.ofSelector) { skipWhitespace(); if (is('o') || is('\\')) { const ident = parseIdentifier(); assert(ident === 'of', 'Formula of selector parse error.'); skipWhitespace(); - pseudo.argument = { + argument = { type: 'FormulaOfSelector', a, b, @@ -532,17 +540,17 @@ export function createParser( } } } else { - return fail('Invalid pseudo-class signature.'); + return fail(`Invalid ${type} signature.`); } skipWhitespace(); if (isEof() && !strict) { - return pseudo; + return argument; } pass(')'); } else { - assert(pseudoDefinition.optional, `Argument is required for pseudo-class "${pseudoName}".`); + assert(signature.optional, `Argument is required for ${type} "${pseudoName}".`); } - return pseudo; + return argument; } function parseTagName(): AstTagName | AstWildcardTag { @@ -550,7 +558,7 @@ export function createParser( assert(tagNameWildcardEnabled, 'Wildcard tag name is not enabled.'); next(); return {type: 'WildcardTag'}; - } else if (isIdentStart(chr) || is('\\')) { + } else if (isIdentStart(chr)) { assert(tagNameEnabled, 'Tag names are not enabled.'); return { type: 'TagName', @@ -585,7 +593,7 @@ export function createParser( const tagName = parseTagName(); tagName.namespace = {type: 'NoNamespace'}; return tagName; - } else if (isIdentStart(chr) || is('\\')) { + } else if (isIdentStart(chr)) { const identifier = parseIdentifier(); if (!is('|')) { assert(tagNameEnabled, 'Tag names are not enabled.'); @@ -613,8 +621,7 @@ export function createParser( } function parseRule(relative = false): AstRule { - const rule: Partial = {}; - let isRuleStart = true; + const rule: AstRule = {type: 'Rule', items: []}; if (relative) { const combinator = matchMulticharIndex(combinatorsIndex); if (combinator) { @@ -624,15 +631,15 @@ export function createParser( } while (pos < l) { if (isTagStart()) { - assert(isRuleStart, 'Unexpected tag/namespace start.'); - rule.tag = parseTagNameWithNamespace(); + assert(rule.items.length === 0, 'Unexpected tag/namespace start.'); + rule.items.push(parseTagNameWithNamespace()); } else if (is('|')) { const savedPos = pos; next(); if (isTagStart()) { - assert(isRuleStart, 'Unexpected tag/namespace start.'); + assert(rule.items.length === 0, 'Unexpected tag/namespace start.'); rewind(savedPos); - rule.tag = parseTagNameWithNamespace(); + rule.items.push(parseTagNameWithNamespace()); } else { rewind(savedPos); break; @@ -642,16 +649,16 @@ export function createParser( next(); const className = parseIdentifier(); assert(className, 'Expected class name.'); - (rule.classNames = rule.classNames || []).push(className); + rule.items.push({type: 'ClassName', name: className}); } else if (is('#')) { assert(idEnabled, 'IDs are not enabled.'); next(); const idName = parseIdentifier(); assert(idName, 'Expected ID name.'); - (rule.ids = rule.ids || []).push(idName); + rule.items.push({type: 'Id', name: idName}); } else if (is('[')) { assert(attributesEnabled, 'Attributes are not enabled.'); - (rule.attributes = rule.attributes || []).push(parseAttribute()); + rule.items.push(parseAttribute()); } else if (is(':')) { let isDoubleColon = false; let isPseudoElement = false; @@ -671,7 +678,9 @@ export function createParser( assert(isDoubleColon || pseudoName, 'Expected pseudo-class name.'); assert(!isDoubleColon || pseudoName, 'Expected pseudo-element name.'); assert( - !isDoubleColon || pseudoElementsAcceptUnknown || pseudoElementsIndex[pseudoName], + !isDoubleColon || + pseudoElementsAcceptUnknown || + Object.prototype.hasOwnProperty.call(pseudoElementsDefinitions, pseudoName), `Unknown pseudo-element "${pseudoName}".` ); @@ -680,31 +689,53 @@ export function createParser( (isDoubleColon || (!isDoubleColon && pseudoElementsSingleColonNotationEnabled && - pseudoElementsIndex[pseudoName])); + Object.prototype.hasOwnProperty.call(pseudoElementsDefinitions, pseudoName))); if (isPseudoElement) { - rule.pseudoElement = pseudoName; + const signature = + pseudoElementsDefinitions[pseudoName] ?? + (pseudoElementsAcceptUnknown && defaultPseudoSignature); - if (!whitespaceChars[chr] && !is(',') && !is(')') && !isEof()) { - return fail('Pseudo-element should be the last component of a CSS selector rule.'); + const pseudoElement: AstPseudoElement = { + type: 'PseudoElement', + name: pseudoName + }; + const argument = parsePseudoArgument(pseudoName, 'pseudo-element', signature); + if (argument) { + assert( + argument.type !== 'Formula' && argument.type !== 'FormulaOfSelector', + 'Pseudo-elements cannot have formula argument.' + ); + pseudoElement.argument = argument; } + rule.items.push(pseudoElement); } else { - assert(pseudoClassesEnabled, 'Pseudo classes are not enabled.'); - (rule.pseudoClasses = rule.pseudoClasses || []).push(parsePseudoClass(pseudoName)); + assert(pseudoClassesEnabled, 'Pseudo-classes are not enabled.'); + const signature = + pseudoClassesDefinitions[pseudoName] ?? (pseudoClassesAcceptUnknown && defaultPseudoSignature); + assert(signature, `Unknown pseudo-class: "${pseudoName}".`); + + const argument = parsePseudoArgument(pseudoName, 'pseudo-class', signature); + const pseudoClass: AstPseudoClass = { + type: 'PseudoClass', + name: pseudoName + }; + if (argument) { + pseudoClass.argument = argument; + } + rule.items.push(pseudoClass); } } else { break; } - isRuleStart = false; } - if (isRuleStart) { + if (rule.items.length === 0) { if (isEof()) { return fail('Expected rule but end of input reached.'); } else { return fail(`Expected rule but "${chr}" found.`); } } - rule.type = 'Rule'; skipWhitespace(); if (!isEof() && !is(',') && !is(')')) { const combinator = matchMulticharIndex(combinatorsIndex); diff --git a/src/pseudo-class-signatures.ts b/src/pseudo-signatures.ts similarity index 67% rename from src/pseudo-class-signatures.ts rename to src/pseudo-signatures.ts index c7b0839..e5d5739 100644 --- a/src/pseudo-class-signatures.ts +++ b/src/pseudo-signatures.ts @@ -1,6 +1,6 @@ import {PseudoClassType} from './syntax-definitions.js'; -export type PseudoClassSignature = {optional: boolean} & ( +export type PseudoSignature = {optional: boolean} & ( | { type: 'Formula'; ofSelector?: boolean; @@ -11,23 +11,29 @@ export type PseudoClassSignature = {optional: boolean} & ( | { type: 'Selector'; } + | { + type: 'NoArgument'; + } ); -export type PseudoClassSignatures = Record; +export type PseudoSignatures = Record; -export const emptyPseudoClassSignatures = {} as PseudoClassSignatures; -export const defaultPseudoClassSignature: PseudoClassSignature = { +export const emptyPseudoSignatures = {} as PseudoSignatures; +export const defaultPseudoSignature: PseudoSignature = { type: 'String', optional: true }; -function calculatePseudoClassSignature(types: PseudoClassType[]) { - const result: Partial = { +type PseudoArgumentType = PseudoClassType; + +function calculatePseudoSignature(types: T[]) { + const result: PseudoSignature = { + type: 'NoArgument', optional: false }; - function setResultType(type: PseudoClassSignature['type']) { - if (result.type && result.type !== type) { + function setResultType(type: PseudoSignature['type']) { + if (result.type && result.type !== type && result.type !== 'NoArgument') { throw new Error(`Conflicting pseudo-class argument type: "${result.type}" vs "${type}".`); } result.type = type; @@ -51,7 +57,7 @@ function calculatePseudoClassSignature(types: PseudoClassType[]) { setResultType('Selector'); } } - return result as PseudoClassSignature; + return result as PseudoSignature; } export type CategoriesIndex = {[K in T1]?: T2[]}; @@ -69,14 +75,14 @@ export function inverseCategories(obj: Cat return result; } -export function calculatePseudoClassSignatures(definitions: {[K in PseudoClassType]?: string[]}) { +export function calculatePseudoSignatures(definitions: {[K in T]?: string[]}) { const pseudoClassesToArgumentTypes = inverseCategories(definitions); - const result: PseudoClassSignatures = {}; + const result: PseudoSignatures = {}; for (const pseudoClass of Object.keys(pseudoClassesToArgumentTypes)) { const argumentTypes = pseudoClassesToArgumentTypes[pseudoClass]; if (argumentTypes) { - result[pseudoClass] = calculatePseudoClassSignature(argumentTypes); + result[pseudoClass] = calculatePseudoSignature(argumentTypes); } } diff --git a/src/render.ts b/src/render.ts index 49d1445..c4c0711 100644 --- a/src/render.ts +++ b/src/render.ts @@ -1,5 +1,7 @@ -import {AstNamespaceName, AstNoNamespace, AstRule, AstSelector, AstSubstitution, AstWildcardNamespace} from './ast.js'; -import {escapeIdentifier, escapePseudoClassString, escapeStr} from './utils.js'; +import {AstEntity, AstNamespaceName, AstNoNamespace, AstSubstitution, AstWildcardNamespace} from './ast.js'; +import {escapeIdentifier, escapeStr} from './utils.js'; + +const errorPrefix = `css-selector-parser render error: `; function renderNamespace(namespace: AstNamespaceName | AstWildcardNamespace | AstNoNamespace) { if (namespace.type === 'WildcardNamespace') { @@ -9,7 +11,7 @@ function renderNamespace(namespace: AstNamespaceName | AstWildcardNamespace | As } else if (namespace.type === 'NoNamespace') { return '|'; } - throw new Error(`Unknown namespace type: ${(namespace as {type: string}).type}.`); + throw new Error(`${errorPrefix}Unknown namespace type: ${(namespace as {type: string}).type}.`); } function renderSubstitution(sub: AstSubstitution) { @@ -38,103 +40,96 @@ function renderFormula(a: number, b: number) { * const selector = ast.selector({ * rules: [ * ast.rule({ - * tag: ast.tagName({name: 'a'}), - * ids: ['user-23'], - * classNames: ['user'], - * pseudoClasses: [ast.pseudoClass({name: 'visited'})] + * items: [ + * ast.tagName({name: 'a'}), + * ast.id({name: 'user-23'}), + * ast.className({name: 'user'}), + * ast.pseudoClass({name: 'visited'}), + * ast.pseudoElement({name: 'before'}) + * ] * }) * ] * }); * - * console.log(render(selector)); // a#user-23.user:visited + * console.log(render(selector)); // a#user-23.user:visited::before */ -export function render(entity: AstSelector | AstRule): string { +export function render(entity: AstEntity): string { if (entity.type === 'Selector') { return entity.rules.map(render).join(', '); } if (entity.type === 'Rule') { let result = ''; - const {tag, ids, classNames, attributes, pseudoClasses, pseudoElement, combinator, nestedRule} = entity; + const {items, combinator, nestedRule} = entity; if (combinator) { result += `${combinator} `; } - if (tag) { - const namespace = tag.namespace; - if (namespace) { - result += renderNamespace(namespace); - } - if (tag.type === 'TagName') { - result += escapeIdentifier(tag.name); - } else if (tag.type === 'WildcardTag') { - result += '*'; - } else { - throw new Error(`Unknown tagName type: ${(tag as {type: string}).type}.`); - } + for (const item of items) { + result += render(item); } - if (ids) { - for (const id of ids) { - result += `#${escapeIdentifier(id)}`; - } + if (nestedRule) { + result += ` ${render(nestedRule)}`; } - if (classNames) { - for (const className of classNames) { - result += `.${escapeIdentifier(className)}`; - } + return result; + } else if (entity.type === 'TagName' || entity.type === 'WildcardTag') { + let result = ''; + const namespace = entity.namespace; + if (namespace) { + result += renderNamespace(namespace); } - if (attributes) { - for (const {name, namespace, operator, value, caseSensitivityModifier} of attributes) { - result += '['; - if (namespace) { - result += renderNamespace(namespace); - } - result += escapeIdentifier(name); - if (operator && value) { - result += operator; - if (value.type === 'String') { - result += escapeStr(value.value); - } else if (value.type === 'Substitution') { - result += renderSubstitution(value); - } else { - throw new Error(`Unknown attribute value type: ${(value as {type: string}).type}.`); - } - if (caseSensitivityModifier) { - result += ` ${escapeIdentifier(caseSensitivityModifier)}`; - } - } - result += ']'; - } + if (entity.type === 'TagName') { + result += escapeIdentifier(entity.name); + } else if (entity.type === 'WildcardTag') { + result += '*'; + } + return result; + } else if (entity.type === 'Id') { + return `#${escapeIdentifier(entity.name)}`; + } else if (entity.type === 'ClassName') { + return `.${escapeIdentifier(entity.name)}`; + } else if (entity.type === 'Attribute') { + const {name, namespace, operator, value, caseSensitivityModifier} = entity; + let result = '['; + if (namespace) { + result += renderNamespace(namespace); } - if (pseudoClasses) { - for (const {name, argument} of pseudoClasses) { - result += `:${escapeIdentifier(name)}`; - if (argument) { - result += '('; - if (argument.type === 'Selector') { - result += render(argument); - } else if (argument.type === 'String') { - result += escapePseudoClassString(argument.value); - } else if (argument.type === 'Formula') { - result += renderFormula(argument.a, argument.b); - } else if (argument.type === 'FormulaOfSelector') { - result += renderFormula(argument.a, argument.b); - result += ' of '; - result += render(argument.selector); - } else if (argument.type === 'Substitution') { - result += renderSubstitution(argument); - } else { - throw new Error(`Unknown pseudo-class argument type: ${(argument as {type: string}).type}.`); - } - result += ')'; - } + result += escapeIdentifier(name); + if (operator && value) { + result += operator; + if (value.type === 'String') { + result += escapeStr(value.value); + } else if (value.type === 'Substitution') { + result += renderSubstitution(value); + } else { + throw new Error(`Unknown attribute value type: ${(value as {type: string}).type}.`); + } + if (caseSensitivityModifier) { + result += ` ${escapeIdentifier(caseSensitivityModifier)}`; } } - if (pseudoElement) { - result += `::${escapeIdentifier(pseudoElement)}`; + result += ']'; + return result; + } else if (entity.type === 'PseudoClass') { + const {name, argument} = entity; + let result = `:${escapeIdentifier(name)}`; + if (argument) { + result += `(${argument.type === 'String' ? escapeIdentifier(argument.value) : render(argument)})`; } - if (nestedRule) { - result += ` ${render(nestedRule)}`; + return result; + } else if (entity.type === 'PseudoElement') { + const {name, argument} = entity; + let result = `::${escapeIdentifier(name)}`; + if (argument) { + result += `(${argument.type === 'String' ? escapeIdentifier(argument.value) : render(argument)})`; } return result; + } else if (entity.type === 'String') { + throw new Error(`${errorPrefix}String cannot be rendered outside of context.`); + } else if (entity.type === 'Formula') { + return renderFormula(entity.a, entity.b); + } else if (entity.type === 'FormulaOfSelector') { + return renderFormula(entity.a, entity.b) + ' of ' + render(entity.selector); + } else if (entity.type === 'Substitution') { + return `$${escapeIdentifier(entity.name)}`; } - throw new Error('Render method accepts only Selector, Rule and RuleList.'); + throw new Error(`Unknown type specified to render method: ${entity.type}.`); } diff --git a/src/syntax-definitions.ts b/src/syntax-definitions.ts index 61575ac..a5a4335 100644 --- a/src/syntax-definitions.ts +++ b/src/syntax-definitions.ts @@ -1,6 +1,7 @@ -import {AstPseudoClassArgument} from './ast.js'; +import {AstPseudoClassArgument, AstPseudoElementArgument} from './ast.js'; -export type PseudoClassType = 'NoArgument' | AstPseudoClassArgument['type']; +export type PseudoClassType = Exclude<'NoArgument' | AstPseudoClassArgument['type'], 'Substitution'>; +export type PseudoElementType = Exclude<'NoArgument' | AstPseudoElementArgument['type'], 'Substitution'>; export type CssLevel = 'css1' | 'css2' | 'css3' | 'selectors-3' | 'selectors-4' | 'latest' | 'progressive'; /** @@ -102,10 +103,12 @@ export interface SyntaxDefinition { */ notation?: 'singleColon' | 'doubleColon' | 'both'; /** - * List of predefined pseudo-elements. + * List of predefined pseudo-elements. If string array is specified, the pseudo-elements are assumed to be + * NoArgument. * @example ['before', 'after'] + * @example {NoArgument: ['before', 'after'], String: ['highlight'], Selector: ['slotted']} */ - definitions?: string[]; + definitions?: string[] | {[K in PseudoElementType]?: string[]}; } | false; /** @@ -149,127 +152,129 @@ export function getXmlOptions(param: SyntaxDefinitionXmlOptions | boolean | unde } } -export function extendSyntaxDefinition(base: SyntaxDefinition, extension: SyntaxDefinition): SyntaxDefinition { - const result = {...base}; - if ('tag' in extension) { - if (extension.tag) { - result.tag = {...getXmlOptions(base.tag)}; - const extensionOptions = getXmlOptions(extension.tag); - if ('wildcard' in extensionOptions) { - result.tag.wildcard = extensionOptions.wildcard; - } - } else { - result.tag = undefined; +type MergeMethod = (base: T, extension: T) => T; + +function withMigration(migration: (value: T) => MT, merge: MergeMethod): MergeMethod { + return (base: T, extension: T): T => merge(migration(base), migration(extension)) as T; +} + +function withNoNegative(merge: MergeMethod): MergeMethod { + return (base: T | undefined | false, extension: T | undefined | false): T => { + const result = merge(base, extension); + if (!result) { + throw new Error(`Syntax definition cannot be null or undefined.`); } + return result; + }; +} + +function withPositive(positive: T, merge: MergeMethod): MergeMethod { + return (base: T | true, extension: T | true): T => { + if (extension === true) { + return positive; + } + return merge(base === true ? positive : base, extension); + }; +} + +function mergeSection(values: {[K in keyof T]-?: MergeMethod}): MergeMethod { + return (base: T | undefined | false, extension: T | undefined | false) => { + if (!extension || !base) { + return extension; + } + if (typeof extension !== 'object' || extension === null) { + throw new Error(`Unexpected syntax definition extension type: ${extension}.`); + } + const result = {...base}; + for (const [key, value] of Object.entries(extension) as [keyof T, T[keyof T]][]) { + const mergeSchema = values[key]; + result[key] = mergeSchema(base[key], value) as never; + } + return result; + }; +} + +function replaceValueIfSpecified(base: T, extension: T): T { + if (extension !== undefined) { + return extension; } - if ('ids' in extension) { - result.ids = extension.ids; - } - if ('classNames' in extension) { - result.classNames = extension.classNames; + return base; +} + +function concatArray(base: T[] | undefined, extension: T[] | undefined): T[] | undefined { + if (!extension) { + return base; } - if ('namespace' in extension) { - if (extension.namespace) { - result.namespace = {...getXmlOptions(base.namespace)}; - const extensionOptions = getXmlOptions(extension.namespace); - if ('wildcard' in extensionOptions) { - result.namespace.wildcard = extensionOptions.wildcard; - } - } else { - result.namespace = undefined; - } + if (!base) { + return extension; } - if ('combinators' in extension) { - if (extension.combinators) { - result.combinators = result.combinators - ? result.combinators.concat(extension.combinators) - : extension.combinators; - } else { - result.combinators = undefined; - } + return base.concat(extension); +} + +function mergeDefinitions( + base?: {[K in keyof T]?: string[]}, + extension?: {[K in keyof T]?: string[]} +): {[K in keyof T]?: string[]} | undefined { + if (!extension) { + return base; } - if ('attributes' in extension) { - if (extension.attributes) { - result.attributes = {...base.attributes}; - if ('unknownCaseSensitivityModifiers' in extension.attributes) { - result.attributes.unknownCaseSensitivityModifiers = - extension.attributes.unknownCaseSensitivityModifiers; - } - if ('operators' in extension.attributes) { - result.attributes.operators = extension.attributes.operators - ? result.attributes.operators - ? result.attributes.operators.concat(extension.attributes.operators) - : extension.attributes.operators - : undefined; - } - if ('caseSensitivityModifiers' in extension.attributes) { - result.attributes.caseSensitivityModifiers = extension.attributes.caseSensitivityModifiers - ? result.attributes.caseSensitivityModifiers - ? result.attributes.caseSensitivityModifiers.concat( - extension.attributes.caseSensitivityModifiers - ) - : extension.attributes.caseSensitivityModifiers - : undefined; - } - } else { - result.attributes = undefined; - } + if (!base) { + return extension; } - if ('pseudoElements' in extension) { - if (extension.pseudoElements) { - result.pseudoElements = {...base.pseudoElements}; - if ('unknown' in extension.pseudoElements) { - result.pseudoElements.unknown = extension.pseudoElements.unknown; - } - if ('notation' in extension.pseudoElements) { - result.pseudoElements.notation = extension.pseudoElements.notation; - } - if ('definitions' in extension.pseudoElements) { - result.pseudoElements.definitions = extension.pseudoElements.definitions - ? result.pseudoElements.definitions - ? result.pseudoElements.definitions.concat(extension.pseudoElements.definitions) - : extension.pseudoElements.definitions - : undefined; - } - } else { - result.pseudoElements = undefined; + const result = {...base}; + for (const [key, value] of Object.entries(extension) as [keyof T, string[]][]) { + if (!value) { + delete result[key]; + continue; } - } - if ('pseudoClasses' in extension) { - if (extension.pseudoClasses) { - result.pseudoClasses = {...base.pseudoClasses}; - if ('unknown' in extension.pseudoClasses) { - result.pseudoClasses.unknown = extension.pseudoClasses.unknown; - } - if ('definitions' in extension.pseudoClasses) { - const newDefinitions = extension.pseudoClasses.definitions; - if (newDefinitions) { - result.pseudoClasses.definitions = { - ...result.pseudoClasses.definitions - }; - const existingDefinitions = result.pseudoClasses.definitions; - for (const key of Object.keys(newDefinitions) as PseudoClassType[]) { - const newDefinitionForNotation = newDefinitions[key]; - const existingDefinitionForNotation = existingDefinitions[key]; - if (newDefinitionForNotation) { - existingDefinitions[key] = existingDefinitionForNotation - ? existingDefinitionForNotation.concat(newDefinitionForNotation) - : newDefinitionForNotation; - } else { - existingDefinitions[key] = undefined; - } - } - } else { - result.pseudoClasses.definitions = undefined; - } - } - } else { - result.pseudoClasses = undefined; + const baseValue = base[key]; + if (!baseValue) { + result[key] = value; + continue; } + result[key] = baseValue.concat(value); } return result; } +export const extendSyntaxDefinition: MergeMethod = withNoNegative( + mergeSection({ + baseSyntax: replaceValueIfSpecified, + tag: withPositive( + defaultXmlOptions, + mergeSection({ + wildcard: replaceValueIfSpecified + }) + ), + ids: replaceValueIfSpecified, + classNames: replaceValueIfSpecified, + namespace: withPositive( + defaultXmlOptions, + mergeSection({ + wildcard: replaceValueIfSpecified + }) + ), + combinators: concatArray, + attributes: mergeSection({ + operators: concatArray, + caseSensitivityModifiers: concatArray, + unknownCaseSensitivityModifiers: replaceValueIfSpecified + }), + pseudoClasses: mergeSection({ + unknown: replaceValueIfSpecified, + definitions: mergeDefinitions + }), + pseudoElements: mergeSection({ + unknown: replaceValueIfSpecified, + notation: replaceValueIfSpecified, + definitions: withMigration( + (definitions) => (Array.isArray(definitions) ? {NoArgument: definitions} : definitions), + mergeDefinitions + ) + }) + }) +); + const css1SyntaxDefinition: SyntaxDefinition = { tag: {}, ids: true, diff --git a/src/utils.ts b/src/utils.ts index da43dee..68136b5 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,5 +1,5 @@ export function isIdentStart(c: string) { - return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || c === '-' || c === '_'; + return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || c === '-' || c === '_' || c === '\\'; } export function isIdent(c: string) { @@ -139,5 +139,3 @@ export function escapeStr(s: string) { } return `"${result}"`; } - -export const escapePseudoClassString = (s: string) => s.replace(/([\\)])/g, '\\$1'); diff --git a/test/ast.test.ts b/test/ast.test.ts index aa4993e..d7d34fe 100644 --- a/test/ast.test.ts +++ b/test/ast.test.ts @@ -7,12 +7,15 @@ const entities: Record = { Selector: true, Rule: true, TagName: true, + Id: true, + ClassName: true, WildcardTag: true, NamespaceName: true, WildcardNamespace: true, NoNamespace: true, Attribute: true, PseudoClass: true, + PseudoElement: true, String: true, Formula: true, FormulaOfSelector: true, diff --git a/test/parser.test.ts b/test/parser.test.ts index 99e4b64..703edfc 100644 --- a/test/parser.test.ts +++ b/test/parser.test.ts @@ -11,7 +11,7 @@ describe('parse()', () => { ast.selector({ rules: [ ast.rule({ - tag: ast.tagName({name: 'div'}) + items: [ast.tagName({name: 'div'})] }) ] }) @@ -22,7 +22,7 @@ describe('parse()', () => { ast.selector({ rules: [ ast.rule({ - tag: ast.wildcardTag() + items: [ast.wildcardTag()] }) ] }) @@ -33,7 +33,7 @@ describe('parse()', () => { ast.selector({ rules: [ ast.rule({ - tag: ast.tagName({name: '*'}) + items: [ast.tagName({name: '*'})] }) ] }) @@ -44,7 +44,7 @@ describe('parse()', () => { ast.selector({ rules: [ ast.rule({ - tag: ast.tagName({name: 'd i v'}) + items: [ast.tagName({name: 'd i v'})] }) ] }) @@ -56,6 +56,9 @@ describe('parse()', () => { it('should not be parsed after a pseudo-class', () => { expect(() => parse(':nth-child(2n)a')).toThrow('Unexpected tag/namespace start.'); }); + it('should not be parsed after a pseudo-element', () => { + expect(() => parse(':unknown(hello)a')).toThrow('Unexpected tag/namespace start.'); + }); it('should throw if not enabled', () => { expect(() => createParser({syntax: {}})('div')).toThrow('Tag names are not enabled.'); }); @@ -70,7 +73,7 @@ describe('parse()', () => { ast.selector({ rules: [ ast.rule({ - tag: ast.tagName({name: 'div', namespace: ast.namespaceName({name: 'ns'})}) + items: [ast.tagName({name: 'div', namespace: ast.namespaceName({name: 'ns'})})] }) ] }) @@ -81,7 +84,7 @@ describe('parse()', () => { ast.selector({ rules: [ ast.rule({ - tag: ast.tagName({name: 'div', namespace: ast.noNamespace()}) + items: [ast.tagName({name: 'div', namespace: ast.noNamespace()})] }) ] }) @@ -92,7 +95,7 @@ describe('parse()', () => { ast.selector({ rules: [ ast.rule({ - tag: ast.tagName({name: 'div', namespace: ast.wildcardNamespace()}) + items: [ast.tagName({name: 'div', namespace: ast.wildcardNamespace()})] }) ] }) @@ -103,7 +106,7 @@ describe('parse()', () => { ast.selector({ rules: [ ast.rule({ - tag: ast.wildcardTag({namespace: ast.wildcardNamespace()}) + items: [ast.wildcardTag({namespace: ast.wildcardNamespace()})] }) ] }) @@ -114,7 +117,7 @@ describe('parse()', () => { ast.selector({ rules: [ ast.rule({ - tag: ast.wildcardTag({namespace: ast.namespaceName({name: '*'})}) + items: [ast.wildcardTag({namespace: ast.namespaceName({name: '*'})})] }) ] }) @@ -125,7 +128,7 @@ describe('parse()', () => { ast.selector({ rules: [ ast.rule({ - tag: ast.tagName({name: '|div'}) + items: [ast.tagName({name: '|div'})] }) ] }) @@ -136,7 +139,7 @@ describe('parse()', () => { ast.selector({ rules: [ ast.rule({ - tag: ast.tagName({name: '*', namespace: ast.namespaceName({name: '*'})}) + items: [ast.tagName({name: '*', namespace: ast.namespaceName({name: '*'})})] }) ] }) @@ -147,7 +150,7 @@ describe('parse()', () => { ast.selector({ rules: [ ast.rule({ - tag: ast.tagName({name: 'd i v', namespace: ast.namespaceName({name: 'n a m'})}) + items: [ast.tagName({name: 'd i v', namespace: ast.namespaceName({name: 'n a m'})})] }) ] }) @@ -155,9 +158,15 @@ describe('parse()', () => { }); it('should not be parsed after an attribute', () => { expect(() => parse('[href="#"]a|b')).toThrow('Unexpected tag/namespace start.'); + expect(() => parse('[href="#"]|b')).toThrow('Unexpected tag/namespace start.'); }); it('should not be parsed after a pseudo-class', () => { expect(() => parse(':nth-child(2n)a|b')).toThrow('Unexpected tag/namespace start.'); + expect(() => parse(':nth-child(2n)|b')).toThrow('Unexpected tag/namespace start.'); + }); + it('should not be parsed after a pseudo-element', () => { + expect(() => parse(':unknown(hello)a|b')).toThrow('Unexpected tag/namespace start.'); + expect(() => parse(':unknown(hello)|b')).toThrow('Unexpected tag/namespace start.'); }); it('should throw if not enabled', () => { expect(() => createParser({syntax: {tag: true}})('ns|div')).toThrow('Namespaces are not enabled.'); @@ -181,7 +190,7 @@ describe('parse()', () => { ast.selector({ rules: [ ast.rule({ - classNames: ['class'] + items: [ast.className({name: 'class'})] }) ] }) @@ -192,7 +201,7 @@ describe('parse()', () => { ast.selector({ rules: [ ast.rule({ - classNames: ['class1', 'class2'] + items: [ast.className({name: 'class1'}), ast.className({name: 'class2'})] }) ] }) @@ -203,7 +212,7 @@ describe('parse()', () => { ast.selector({ rules: [ ast.rule({ - classNames: ['cla ss.name'] + items: [ast.className({name: 'cla ss.name'})] }) ] }) @@ -214,8 +223,7 @@ describe('parse()', () => { ast.selector({ rules: [ ast.rule({ - tag: ast.tagName({name: 'div'}), - classNames: ['class'] + items: [ast.tagName({name: 'div'}), ast.className({name: 'class'})] }) ] }) @@ -226,8 +234,7 @@ describe('parse()', () => { ast.selector({ rules: [ ast.rule({ - ids: ['id'], - classNames: ['class'] + items: [ast.id({name: 'id'}), ast.className({name: 'class'})] }) ] }) @@ -238,8 +245,7 @@ describe('parse()', () => { ast.selector({ rules: [ ast.rule({ - attributes: [ast.attribute({name: 'href'})], - classNames: ['class'] + items: [ast.attribute({name: 'href'}), ast.className({name: 'class'})] }) ] }) @@ -250,8 +256,18 @@ describe('parse()', () => { ast.selector({ rules: [ ast.rule({ - pseudoClasses: [ast.pseudoClass({name: 'link'})], - classNames: ['class'] + items: [ast.pseudoClass({name: 'link'}), ast.className({name: 'class'})] + }) + ] + }) + ); + }); + it('should parse after a pseudo-element', () => { + expect(parse('::before.class')).toEqual( + ast.selector({ + rules: [ + ast.rule({ + items: [ast.pseudoElement({name: 'before'}), ast.className({name: 'class'})] }) ] }) @@ -260,11 +276,6 @@ describe('parse()', () => { it('should fail on empty class name', () => { expect(() => parse('.')).toThrow('Expected class name.'); }); - it('should fail after pseudo-element', () => { - expect(() => parse('::before.class')).toThrow( - 'Pseudo-element should be the last component of a CSS selector rule.' - ); - }); it('should fail if not enabled', () => { expect(() => createParser({syntax: {}})('.class')).toThrow('Class names are not enabled.'); }); @@ -275,7 +286,7 @@ describe('parse()', () => { ast.selector({ rules: [ ast.rule({ - ids: ['id'] + items: [ast.id({name: 'id'})] }) ] }) @@ -286,7 +297,7 @@ describe('parse()', () => { ast.selector({ rules: [ ast.rule({ - ids: ['id1', 'id2'] + items: [ast.id({name: 'id1'}), ast.id({name: 'id2'})] }) ] }) @@ -297,7 +308,7 @@ describe('parse()', () => { ast.selector({ rules: [ ast.rule({ - ids: ['id name# with escapes'] + items: [ast.id({name: 'id name# with escapes'})] }) ] }) @@ -308,8 +319,7 @@ describe('parse()', () => { ast.selector({ rules: [ ast.rule({ - tag: ast.tagName({name: 'div'}), - ids: ['id'] + items: [ast.tagName({name: 'div'}), ast.id({name: 'id'})] }) ] }) @@ -320,8 +330,7 @@ describe('parse()', () => { ast.selector({ rules: [ ast.rule({ - ids: ['id'], - classNames: ['class'] + items: [ast.className({name: 'class'}), ast.id({name: 'id'})] }) ] }) @@ -332,8 +341,12 @@ describe('parse()', () => { ast.selector({ rules: [ ast.rule({ - ids: ['id1', 'id2'], - classNames: ['class1', 'class2'] + items: [ + ast.className({name: 'class1'}), + ast.id({name: 'id1'}), + ast.className({name: 'class2'}), + ast.id({name: 'id2'}) + ] }) ] }) @@ -344,8 +357,7 @@ describe('parse()', () => { ast.selector({ rules: [ ast.rule({ - attributes: [ast.attribute({name: 'href'})], - ids: ['id'] + items: [ast.attribute({name: 'href'}), ast.id({name: 'id'})] }) ] }) @@ -356,8 +368,18 @@ describe('parse()', () => { ast.selector({ rules: [ ast.rule({ - pseudoClasses: [ast.pseudoClass({name: 'link'})], - ids: ['id'] + items: [ast.pseudoClass({name: 'link'}), ast.id({name: 'id'})] + }) + ] + }) + ); + }); + it('should parse after a pseudo-element', () => { + expect(parse('::before#id')).toEqual( + ast.selector({ + rules: [ + ast.rule({ + items: [ast.pseudoElement({name: 'before'}), ast.id({name: 'id'})] }) ] }) @@ -366,11 +388,6 @@ describe('parse()', () => { it('should fail on empty ID name', () => { expect(() => parse('#')).toThrow('Expected ID name.'); }); - it('should fail after pseudo-element', () => { - expect(() => parse('::before#id')).toThrow( - 'Pseudo-element should be the last component of a CSS selector rule.' - ); - }); it('should fail if not enabled', () => { expect(() => createParser({syntax: {}})('#id')).toThrow('IDs are not enabled.'); }); @@ -381,7 +398,7 @@ describe('parse()', () => { ast.selector({ rules: [ ast.rule({ - attributes: [ast.attribute({name: 'attr'})] + items: [ast.attribute({name: 'attr'})] }) ] }) @@ -392,9 +409,7 @@ describe('parse()', () => { ast.selector({ rules: [ ast.rule({ - attributes: [ - ast.attribute({name: 'attr', operator: '=', value: ast.string({value: 'val'})}) - ] + items: [ast.attribute({name: 'attr', operator: '=', value: ast.string({value: 'val'})})] }) ] }) @@ -405,9 +420,7 @@ describe('parse()', () => { ast.selector({ rules: [ ast.rule({ - attributes: [ - ast.attribute({name: 'attr', operator: '|=', value: ast.string({value: 'val'})}) - ] + items: [ast.attribute({name: 'attr', operator: '|=', value: ast.string({value: 'val'})})] }) ] }) @@ -418,7 +431,7 @@ describe('parse()', () => { ast.selector({ rules: [ ast.rule({ - attributes: [ast.attribute({name: 'attr1'}), ast.attribute({name: 'attr2'})] + items: [ast.attribute({name: 'attr1'}), ast.attribute({name: 'attr2'})] }) ] }) @@ -429,7 +442,7 @@ describe('parse()', () => { ast.selector({ rules: [ ast.rule({ - attributes: [ast.attribute({name: 'attr .name'})] + items: [ast.attribute({name: 'attr .name'})] }) ] }) @@ -440,7 +453,7 @@ describe('parse()', () => { ast.selector({ rules: [ ast.rule({ - attributes: [ + items: [ ast.attribute({ name: 'attr', operator: '=', @@ -459,7 +472,7 @@ describe('parse()', () => { ast.selector({ rules: [ ast.rule({ - attributes: [ + items: [ ast.attribute({ name: 'attr', operator: '=', @@ -479,7 +492,7 @@ describe('parse()', () => { ast.selector({ rules: [ ast.rule({ - attributes: [ + items: [ ast.attribute({ name: 'attr', operator: '=', @@ -499,7 +512,7 @@ describe('parse()', () => { ast.selector({ rules: [ ast.rule({ - attributes: [ + items: [ ast.attribute({ name: 'attr', operator: '=', @@ -519,7 +532,7 @@ describe('parse()', () => { ast.selector({ rules: [ ast.rule({ - attributes: [ + items: [ ast.attribute({ name: 'attr', operator: '=', @@ -550,7 +563,7 @@ describe('parse()', () => { ast.selector({ rules: [ ast.rule({ - attributes: [ + items: [ ast.attribute({ name: 'attr', operator: '=', @@ -572,7 +585,7 @@ describe('parse()', () => { ast.selector({ rules: [ ast.rule({ - attributes: [ + items: [ ast.attribute({ name: 'attr', operator: '=', @@ -590,8 +603,7 @@ describe('parse()', () => { ast.selector({ rules: [ ast.rule({ - tag: ast.tagName({name: 'div'}), - attributes: [ast.attribute({name: 'attr'})] + items: [ast.tagName({name: 'div'}), ast.attribute({name: 'attr'})] }) ] }) @@ -602,8 +614,7 @@ describe('parse()', () => { ast.selector({ rules: [ ast.rule({ - ids: ['id'], - attributes: [ast.attribute({name: 'attr'})] + items: [ast.id({name: 'id'}), ast.attribute({name: 'attr'})] }) ] }) @@ -614,8 +625,7 @@ describe('parse()', () => { ast.selector({ rules: [ ast.rule({ - classNames: ['class'], - attributes: [ast.attribute({name: 'attr'})] + items: [ast.className({name: 'class'}), ast.attribute({name: 'attr'})] }) ] }) @@ -626,8 +636,18 @@ describe('parse()', () => { ast.selector({ rules: [ ast.rule({ - pseudoClasses: [ast.pseudoClass({name: 'link'})], - attributes: [ast.attribute({name: 'attr'})] + items: [ast.pseudoClass({name: 'link'}), ast.attribute({name: 'attr'})] + }) + ] + }) + ); + }); + it('should parse after a pseudo-element', () => { + expect(parse('::before[attr]')).toEqual( + ast.selector({ + rules: [ + ast.rule({ + items: [ast.pseudoElement({name: 'before'}), ast.attribute({name: 'attr'})] }) ] }) @@ -638,7 +658,7 @@ describe('parse()', () => { ast.selector({ rules: [ ast.rule({ - attributes: [ + items: [ ast.attribute({ name: 'href', namespace: ast.namespaceName({name: 'ns'}) @@ -652,7 +672,7 @@ describe('parse()', () => { ast.selector({ rules: [ ast.rule({ - attributes: [ + items: [ ast.attribute({ name: 'href', operator: '=', @@ -668,7 +688,7 @@ describe('parse()', () => { ast.selector({ rules: [ ast.rule({ - attributes: [ + items: [ ast.attribute({ name: 'href', operator: '|=', @@ -686,7 +706,7 @@ describe('parse()', () => { ast.selector({ rules: [ ast.rule({ - attributes: [ + items: [ ast.attribute({ name: 'href', namespace: ast.wildcardNamespace() @@ -700,7 +720,7 @@ describe('parse()', () => { ast.selector({ rules: [ ast.rule({ - attributes: [ + items: [ ast.attribute({ name: 'href', operator: '=', @@ -716,7 +736,7 @@ describe('parse()', () => { ast.selector({ rules: [ ast.rule({ - attributes: [ + items: [ ast.attribute({ name: 'href', operator: '|=', @@ -734,7 +754,7 @@ describe('parse()', () => { ast.selector({ rules: [ ast.rule({ - attributes: [ + items: [ ast.attribute({ name: 'href', namespace: ast.noNamespace() @@ -748,7 +768,7 @@ describe('parse()', () => { ast.selector({ rules: [ ast.rule({ - attributes: [ + items: [ ast.attribute({ name: 'href', operator: '=', @@ -764,7 +784,7 @@ describe('parse()', () => { ast.selector({ rules: [ ast.rule({ - attributes: [ + items: [ ast.attribute({ name: 'href', operator: '|=', @@ -842,7 +862,7 @@ describe('parse()', () => { ast.selector({ rules: [ ast.rule({ - attributes: [ + items: [ ast.attribute({ name: 'attr', operator: '=', @@ -855,11 +875,6 @@ describe('parse()', () => { }) ); }); - it('should fail after pseudo-element', () => { - expect(() => parse('::before[attr]')).toThrow( - 'Pseudo-element should be the last component of a CSS selector rule.' - ); - }); }); describe('Pseudo Classes', () => { it('should parse a pseudo-class', () => { @@ -867,7 +882,7 @@ describe('parse()', () => { ast.selector({ rules: [ ast.rule({ - pseudoClasses: [ast.pseudoClass({name: 'link'})] + items: [ast.pseudoClass({name: 'link'})] }) ] }) @@ -878,7 +893,7 @@ describe('parse()', () => { ast.selector({ rules: [ ast.rule({ - pseudoClasses: [ast.pseudoClass({name: 'link'}), ast.pseudoClass({name: 'visited'})] + items: [ast.pseudoClass({name: 'link'}), ast.pseudoClass({name: 'visited'})] }) ] }) @@ -889,7 +904,7 @@ describe('parse()', () => { ast.selector({ rules: [ ast.rule({ - pseudoClasses: [ast.pseudoClass({name: 'link'})] + items: [ast.pseudoClass({name: 'link'})] }) ] }) @@ -909,7 +924,7 @@ describe('parse()', () => { ast.selector({ rules: [ ast.rule({ - pseudoClasses: [ + items: [ ast.pseudoClass({ name: 'nth-child', argument: ast.formula({a: 0, b: 5}) @@ -935,7 +950,7 @@ describe('parse()', () => { ast.selector({ rules: [ ast.rule({ - pseudoClasses: [ + items: [ ast.pseudoClass({ name: 'nth-child', argument: ast.formula({a: 0, b: -5}) @@ -961,7 +976,7 @@ describe('parse()', () => { ast.selector({ rules: [ ast.rule({ - pseudoClasses: [ + items: [ ast.pseudoClass({ name: 'nth-child', argument: ast.formula({a: 3, b: 0}) @@ -980,7 +995,7 @@ describe('parse()', () => { ast.selector({ rules: [ ast.rule({ - pseudoClasses: [ + items: [ ast.pseudoClass({ name: 'nth-child', argument: ast.formula({a: 2, b: 0}) @@ -999,7 +1014,7 @@ describe('parse()', () => { ast.selector({ rules: [ ast.rule({ - pseudoClasses: [ + items: [ ast.pseudoClass({ name: 'nth-child', argument: ast.formula({a: 2, b: 1}) @@ -1016,7 +1031,7 @@ describe('parse()', () => { ast.selector({ rules: [ ast.rule({ - pseudoClasses: [ + items: [ ast.pseudoClass({ name: 'lang', argument: ast.string({ @@ -1034,8 +1049,7 @@ describe('parse()', () => { ast.selector({ rules: [ ast.rule({ - tag: ast.tagName({name: 'div'}), - pseudoClasses: [ast.pseudoClass({name: 'link'})] + items: [ast.tagName({name: 'div'}), ast.pseudoClass({name: 'link'})] }) ] }) @@ -1046,8 +1060,7 @@ describe('parse()', () => { ast.selector({ rules: [ ast.rule({ - ids: ['id'], - pseudoClasses: [ast.pseudoClass({name: 'link'})] + items: [ast.id({name: 'id'}), ast.pseudoClass({name: 'link'})] }) ] }) @@ -1058,8 +1071,7 @@ describe('parse()', () => { ast.selector({ rules: [ ast.rule({ - classNames: ['class'], - pseudoClasses: [ast.pseudoClass({name: 'link'})] + items: [ast.className({name: 'class'}), ast.pseudoClass({name: 'link'})] }) ] }) @@ -1070,13 +1082,13 @@ describe('parse()', () => { ast.selector({ rules: [ ast.rule({ - pseudoClasses: [ + items: [ ast.pseudoClass({ name: 'not', argument: ast.selector({ rules: [ ast.rule({ - pseudoClasses: [ + items: [ ast.pseudoClass({ name: 'lang', argument: ast.string({value: 'en'}) @@ -1084,7 +1096,7 @@ describe('parse()', () => { ] }), ast.rule({ - tag: ast.tagName({name: 'div'}) + items: [ast.tagName({name: 'div'})] }) ] }) @@ -1098,19 +1110,22 @@ describe('parse()', () => { it('should require a nested selector', () => { expect(() => parse(':not')).toThrow('Argument is required for pseudo-class "not".'); }); + it('should require a string', () => { + expect(() => parse(':lang')).toThrow('Argument is required for pseudo-class "lang".'); + }); it('should properly handle optional values in pseudo-classes', () => { expect(parse(':current, :current(div)')).toEqual( ast.selector({ rules: [ ast.rule({ - pseudoClasses: [ast.pseudoClass({name: 'current'})] + items: [ast.pseudoClass({name: 'current'})] }), ast.rule({ - pseudoClasses: [ + items: [ ast.pseudoClass({ name: 'current', argument: ast.selector({ - rules: [ast.rule({tag: ast.tagName({name: 'div'})})] + rules: [ast.rule({items: [ast.tagName({name: 'div'})]})] }) }) ] @@ -1124,7 +1139,18 @@ describe('parse()', () => { ast.selector({ rules: [ ast.rule({ - pseudoClasses: [ast.pseudoClass({name: 'link'}), ast.pseudoClass({name: 'hover'})] + items: [ast.pseudoClass({name: 'link'}), ast.pseudoClass({name: 'hover'})] + }) + ] + }) + ); + }); + it('should parse after a pseudo-element', () => { + expect(parse('::before:hover')).toEqual( + ast.selector({ + rules: [ + ast.rule({ + items: [ast.pseudoElement({name: 'before'}), ast.pseudoClass({name: 'hover'})] }) ] }) @@ -1140,9 +1166,7 @@ describe('parse()', () => { ast.selector({ rules: [ ast.rule({ - pseudoClasses: [ - ast.pseudoClass({name: 'nth-child', argument: ast.substitution({name: 'formula'})}) - ] + items: [ast.pseudoClass({name: 'nth-child', argument: ast.substitution({name: 'formula'})})] }) ] }) @@ -1153,13 +1177,13 @@ describe('parse()', () => { ast.selector({ rules: [ ast.rule({ - pseudoClasses: [ + items: [ ast.pseudoClass({ name: 'has', argument: ast.selector({ rules: [ ast.rule({ - tag: ast.tagName({name: 'div'}), + items: [ast.tagName({name: 'div'})], combinator: '>' }) ] @@ -1199,7 +1223,7 @@ describe('parse()', () => { ast.selector({ rules: [ ast.rule({ - pseudoClasses: [ + items: [ ast.pseudoClass({ name: 'lang' }) @@ -1214,7 +1238,7 @@ describe('parse()', () => { ast.selector({ rules: [ ast.rule({ - pseudoClasses: [ + items: [ ast.pseudoClass({ name: 'lang', argument: ast.string({value: 'en'}) @@ -1225,13 +1249,10 @@ describe('parse()', () => { }) ); }); - it('should fail after pseudo-element', () => { - expect(() => parse('::before:link')).toThrow( - 'Pseudo-element should be the last component of a CSS selector rule.' - ); - }); it('should fail if not enabled', () => { - expect(() => createParser({syntax: {}})(':lang')).toThrow('Pseudo classes are not enabled.'); + expect(() => createParser({syntax: {baseSyntax: 'progressive', pseudoClasses: false}})(':lang')).toThrow( + 'Pseudo-classes are not enabled.' + ); }); }); describe('Pseudo Elements', () => { @@ -1240,7 +1261,7 @@ describe('parse()', () => { ast.selector({ rules: [ ast.rule({ - pseudoElement: 'before' + items: [ast.pseudoElement({name: 'before'})] }) ] }) @@ -1251,7 +1272,7 @@ describe('parse()', () => { ast.selector({ rules: [ ast.rule({ - pseudoElement: 'before' + items: [ast.pseudoElement({name: 'before'})] }) ] }) @@ -1262,7 +1283,7 @@ describe('parse()', () => { ast.selector({ rules: [ ast.rule({ - pseudoElement: 'before' + items: [ast.pseudoElement({name: 'before'})] }) ] }) @@ -1273,8 +1294,7 @@ describe('parse()', () => { ast.selector({ rules: [ ast.rule({ - tag: ast.tagName({name: 'div'}), - pseudoElement: 'before' + items: [ast.tagName({name: 'div'}), ast.pseudoElement({name: 'before'})] }) ] }) @@ -1285,8 +1305,7 @@ describe('parse()', () => { ast.selector({ rules: [ ast.rule({ - ids: ['id'], - pseudoElement: 'before' + items: [ast.id({name: 'id'}), ast.pseudoElement({name: 'before'})] }) ] }) @@ -1297,8 +1316,7 @@ describe('parse()', () => { ast.selector({ rules: [ ast.rule({ - attributes: [ast.attribute({name: 'attr'})], - pseudoElement: 'before' + items: [ast.attribute({name: 'attr'}), ast.pseudoElement({name: 'before'})] }) ] }) @@ -1309,12 +1327,65 @@ describe('parse()', () => { ast.selector({ rules: [ ast.rule({ - pseudoElement: 'before' + items: [ast.pseudoElement({name: 'before'})] + }) + ] + }) + ); + }); + it('should parse with string argument in case of unknown pseudo elements', () => { + expect(createParser({syntax: {pseudoElements: {unknown: 'accept'}}})('::highlight(color-1)')).toEqual( + ast.selector({ + rules: [ + ast.rule({ + items: [ast.pseudoElement({name: 'highlight', argument: ast.string({value: 'color-1'})})] + }) + ] + }) + ); + }); + it('should parse a substitution argument', () => { + expect( + createParser({ + syntax: 'progressive', + substitutes: true + })('::unknown($var)') + ).toEqual( + ast.selector({ + rules: [ + ast.rule({ + items: [ast.pseudoElement({name: 'unknown', argument: ast.substitution({name: 'var'})})] }) ] }) ); }); + it('should require a nested selector', () => { + expect(() => + createParser({ + syntax: { + pseudoElements: { + definitions: { + Selector: ['slotted'] + } + } + } + })('::slotted') + ).toThrow('Argument is required for pseudo-element "slotted".'); + }); + it('should require string', () => { + expect(() => + createParser({ + syntax: { + pseudoElements: { + definitions: { + String: ['highlight'] + } + } + } + })('::highlight') + ).toThrow('Argument is required for pseudo-element "highlight".'); + }); it('should fail on empty pseudo-element name', () => { expect(() => parse('::')).toThrow('Expected pseudo-element name.'); }); @@ -1326,27 +1397,51 @@ describe('parse()', () => { 'Unknown pseudo-element "before".' ); }); - it('should fail after pseudo-element', () => { - expect(() => parse('::before::before')).toThrow( - 'Pseudo-element should be the last component of a CSS selector rule.' + it('should not fail after pseudo-element', () => { + expect(parse('::before::after')).toEqual( + ast.selector({ + rules: [ + ast.rule({ + items: [ + { + type: 'PseudoElement', + name: 'before' + }, + { + type: 'PseudoElement', + name: 'after' + } + ] + }) + ] + }) ); }); it('should fail if not enabled', () => { expect(() => createParser({syntax: {}})('::before')).toThrow('Pseudo elements are not enabled.'); + expect(() => + createParser({syntax: {baseSyntax: 'progressive', pseudoElements: false}})('::before') + ).toThrow('Pseudo elements are not enabled.'); }); }); describe('Multiple rules', () => { it('should parse multiple rules', () => { expect(parse('div,.class')).toEqual( ast.selector({ - rules: [ast.rule({tag: ast.tagName({name: 'div'})}), ast.rule({classNames: ['class']})] + rules: [ + ast.rule({items: [ast.tagName({name: 'div'})]}), + ast.rule({items: [ast.className({name: 'class'})]}) + ] }) ); }); it('should properly handle whitespace', () => { expect(parse(' div , .class ')).toEqual( ast.selector({ - rules: [ast.rule({tag: ast.tagName({name: 'div'})}), ast.rule({classNames: ['class']})] + rules: [ + ast.rule({items: [ast.tagName({name: 'div'})]}), + ast.rule({items: [ast.className({name: 'class'})]}) + ] }) ); }); @@ -1363,8 +1458,8 @@ describe('parse()', () => { ast.selector({ rules: [ ast.rule({ - tag: ast.tagName({name: 'div'}), - nestedRule: ast.rule({classNames: ['class']}) + items: [ast.tagName({name: 'div'})], + nestedRule: ast.rule({items: [ast.className({name: 'class'})]}) }) ] }) @@ -1375,8 +1470,8 @@ describe('parse()', () => { ast.selector({ rules: [ ast.rule({ - tag: ast.tagName({name: 'div'}), - nestedRule: ast.rule({classNames: ['class'], combinator: '>'}) + items: [ast.tagName({name: 'div'})], + nestedRule: ast.rule({items: [ast.className({name: 'class'})], combinator: '>'}) }) ] }) @@ -1387,8 +1482,8 @@ describe('parse()', () => { ast.selector({ rules: [ ast.rule({ - tag: ast.tagName({name: 'div'}), - nestedRule: ast.rule({classNames: ['class'], combinator: '>'}) + items: [ast.tagName({name: 'div'})], + nestedRule: ast.rule({items: [ast.className({name: 'class'})], combinator: '>'}) }) ] }) @@ -1399,8 +1494,8 @@ describe('parse()', () => { ast.selector({ rules: [ ast.rule({ - tag: ast.tagName({name: 'div'}), - nestedRule: ast.rule({classNames: ['class'], combinator: '||'}) + items: [ast.tagName({name: 'div'})], + nestedRule: ast.rule({items: [ast.className({name: 'class'})], combinator: '||'}) }) ] }) @@ -1411,8 +1506,8 @@ describe('parse()', () => { ast.selector({ rules: [ ast.rule({ - tag: ast.tagName({name: 'div'}), - nestedRule: ast.rule({classNames: ['class'], combinator: '||'}) + items: [ast.tagName({name: 'div'})], + nestedRule: ast.rule({items: [ast.className({name: 'class'})], combinator: '||'}) }) ] }) diff --git a/test/render.test.ts b/test/render.test.ts index c6c3def..9f00a4e 100644 --- a/test/render.test.ts +++ b/test/render.test.ts @@ -38,8 +38,10 @@ const testCases = { 'tag1+tag2': 'tag1 + tag2', 'tag1~tag2': 'tag1 ~ tag2', 'tag1:first': 'tag1:first', - 'tag1:lt(3)': 'tag1:lt(3)', - 'tag1:lt(3': 'tag1:lt(3)', + 'tag1:lt(a3)': 'tag1:lt(a3)', + 'tag1:lt($var)': 'tag1:lt($var)', + 'tag1:lt($var': 'tag1:lt($var)', + 'tag1:lt(a3': 'tag1:lt(a3)', 'tag1:lang(en\\))': 'tag1:lang(en\\))', 'tag1:nth-child(odd)': 'tag1:nth-child(2n+1)', 'tag1:nth-child(even)': 'tag1:nth-child(2n)', @@ -63,6 +65,13 @@ const testCases = { 'tag1:has(> div)': 'tag1:has(> div)', 'tag1:current(.class:has(.subcls),.class2)': 'tag1:current(.class:has(.subcls), .class2)', 'tag1:current': 'tag1:current', + 'tag1::before': 'tag1::before', + 'tag1::hey(hello)': 'tag1::hey(hello)', + 'tag1::hey(hello': 'tag1::hey(hello)', + 'tag1::num(1)': 'tag1::num(\\31)', + 'tag1::num($var)': 'tag1::num($var)', + 'tag1::num($var': 'tag1::num($var)', + 'tag1::none': 'tag1::none', '*': '*', '*.class': '*.class', '* + *': '* + *', @@ -75,7 +84,7 @@ const testCases = { 'tag\\n\\\\name\\.\\[': 'tagn\\\\name\\.\\[', '.cls\\n\\\\name\\.\\[': '.clsn\\\\name\\.\\[', '[attr\\n\\\\name\\.\\[=1]': '[attrn\\\\name\\.\\[="1"]', - ':pseudo\\n\\\\name\\.\\[\\((123)': ':pseudon\\\\name\\.\\[\\((123)', + ':pseudo\\n\\\\name\\.\\[\\((123)': ':pseudon\\\\name\\.\\[\\((\\31 23)', '[attr="val\nval"]': '[attr="val\\nval"]', '[attr="val\\"val"]': '[attr="val\\"val"]', '[attr="val\\00a0val"]': '[attr="val val"]', @@ -83,8 +92,7 @@ const testCases = { '.class\\00a0 class': '.class\\a0 class', '[attr\\a0 attr]': '[attr\\a0 attr]', '[attr=$var]': '[attr=$var]', - ':has($var)': ':has($var)', - '.cls1.cls2#y .cls3+abc#def[x=y]>yy,ff': '#y.cls1.cls2 .cls3 + abc#def[x="y"] > yy, ff', + '.cls1.cls2#y .cls3+abc#def[x=y]>yy,ff': '.cls1.cls2#y .cls3 + abc#def[x="y"] > yy, ff', '#google_ads_iframe_\\/100500\\/Pewpew_0': '#google_ads_iframe_\\/100500\\/Pewpew_0', '#\\3123': '#\\3123', '#\\31 23': '#\\31 23',