From 346a571b2a09adc777cc8cab9bed0a764ecd5739 Mon Sep 17 00:00:00 2001 From: Romain Menke Date: Sun, 21 Jan 2024 19:03:35 +0100 Subject: [PATCH 1/2] selector-resolve-nested --- package-lock.json | 28 ++ packages/selector-resolve-nested/.gitignore | 6 + packages/selector-resolve-nested/.nvmrc | 1 + packages/selector-resolve-nested/CHANGELOG.md | 5 + packages/selector-resolve-nested/LICENSE.md | 18 ++ packages/selector-resolve-nested/README.md | 33 +++ .../api-extractor.json | 7 + .../selector-resolve-nested/dist/index.cjs | 1 + .../selector-resolve-nested/dist/index.d.ts | 33 +++ .../selector-resolve-nested/dist/index.mjs | 1 + .../selector-resolve-nested/docs/index.md | 12 + .../docs/selector-resolve-nested.api.json | 241 ++++++++++++++++++ .../docs/selector-resolve-nested.md | 30 +++ ...or-resolve-nested.resolvenestedselector.md | 27 ++ packages/selector-resolve-nested/package.json | 79 ++++++ .../src/compound-selector-order.ts | 90 +++++++ packages/selector-resolve-nested/src/index.ts | 118 +++++++++ .../selector-resolve-nested/stryker.conf.json | 19 ++ .../selector-resolve-nested/test/_import.mjs | 8 + .../selector-resolve-nested/test/_require.cjs | 8 + .../selector-resolve-nested/test/complex.mjs | 101 ++++++++ .../selector-resolve-nested/test/compound.mjs | 146 +++++++++++ .../selector-resolve-nested/test/index.mjs | 5 + .../selector-resolve-nested/test/lists.mjs | 49 ++++ .../test/multiple-resolves.mjs | 21 ++ .../test/simple-compound-complex.mjs | 77 ++++++ .../test/util/parse.mjs | 6 + .../selector-resolve-nested/tsconfig.json | 10 + 28 files changed, 1180 insertions(+) create mode 100644 packages/selector-resolve-nested/.gitignore create mode 100644 packages/selector-resolve-nested/.nvmrc create mode 100644 packages/selector-resolve-nested/CHANGELOG.md create mode 100644 packages/selector-resolve-nested/LICENSE.md create mode 100644 packages/selector-resolve-nested/README.md create mode 100644 packages/selector-resolve-nested/api-extractor.json create mode 100644 packages/selector-resolve-nested/dist/index.cjs create mode 100644 packages/selector-resolve-nested/dist/index.d.ts create mode 100644 packages/selector-resolve-nested/dist/index.mjs create mode 100644 packages/selector-resolve-nested/docs/index.md create mode 100644 packages/selector-resolve-nested/docs/selector-resolve-nested.api.json create mode 100644 packages/selector-resolve-nested/docs/selector-resolve-nested.md create mode 100644 packages/selector-resolve-nested/docs/selector-resolve-nested.resolvenestedselector.md create mode 100644 packages/selector-resolve-nested/package.json create mode 100644 packages/selector-resolve-nested/src/compound-selector-order.ts create mode 100644 packages/selector-resolve-nested/src/index.ts create mode 100644 packages/selector-resolve-nested/stryker.conf.json create mode 100644 packages/selector-resolve-nested/test/_import.mjs create mode 100644 packages/selector-resolve-nested/test/_require.cjs create mode 100644 packages/selector-resolve-nested/test/complex.mjs create mode 100644 packages/selector-resolve-nested/test/compound.mjs create mode 100644 packages/selector-resolve-nested/test/index.mjs create mode 100644 packages/selector-resolve-nested/test/lists.mjs create mode 100644 packages/selector-resolve-nested/test/multiple-resolves.mjs create mode 100644 packages/selector-resolve-nested/test/simple-compound-complex.mjs create mode 100644 packages/selector-resolve-nested/test/util/parse.mjs create mode 100644 packages/selector-resolve-nested/tsconfig.json diff --git a/package-lock.json b/package-lock.json index 31da73f77..cc65443cb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2189,6 +2189,10 @@ "resolved": "plugins/postcss-unset-value", "link": true }, + "node_modules/@csstools/selector-resolve-nested": { + "resolved": "packages/selector-resolve-nested", + "link": true + }, "node_modules/@csstools/selector-specificity": { "resolved": "packages/selector-specificity", "link": true @@ -11095,6 +11099,30 @@ "node": "^14 || ^16 || >=18" } }, + "packages/selector-resolve-nested": { + "name": "@csstools/selector-resolve-nested", + "version": "0.0.0", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "devDependencies": { + "postcss-selector-parser": "^6.0.13" + }, + "engines": { + "node": "^14 || ^16 || >=18" + }, + "peerDependencies": { + "postcss-selector-parser": "^6.0.13" + } + }, "packages/selector-specificity": { "name": "@csstools/selector-specificity", "version": "3.0.1", diff --git a/packages/selector-resolve-nested/.gitignore b/packages/selector-resolve-nested/.gitignore new file mode 100644 index 000000000..e5b28db4a --- /dev/null +++ b/packages/selector-resolve-nested/.gitignore @@ -0,0 +1,6 @@ +node_modules +package-lock.json +yarn.lock +*.result.css +*.result.css.map +*.result.html diff --git a/packages/selector-resolve-nested/.nvmrc b/packages/selector-resolve-nested/.nvmrc new file mode 100644 index 000000000..6ed5da955 --- /dev/null +++ b/packages/selector-resolve-nested/.nvmrc @@ -0,0 +1 @@ +v20.2.0 diff --git a/packages/selector-resolve-nested/CHANGELOG.md b/packages/selector-resolve-nested/CHANGELOG.md new file mode 100644 index 000000000..683037fd1 --- /dev/null +++ b/packages/selector-resolve-nested/CHANGELOG.md @@ -0,0 +1,5 @@ +# Changes to Selector Resolve Nested + +### Unreleased (major) + +- Initial version diff --git a/packages/selector-resolve-nested/LICENSE.md b/packages/selector-resolve-nested/LICENSE.md new file mode 100644 index 000000000..e8ae93b9f --- /dev/null +++ b/packages/selector-resolve-nested/LICENSE.md @@ -0,0 +1,18 @@ +MIT No Attribution (MIT-0) + +Copyright © CSSTools Contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the “Software”), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +of the Software, and to permit persons to whom the Software is furnished to do +so. + +THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/selector-resolve-nested/README.md b/packages/selector-resolve-nested/README.md new file mode 100644 index 000000000..9f7aa7b00 --- /dev/null +++ b/packages/selector-resolve-nested/README.md @@ -0,0 +1,33 @@ +# Selector Resolve Nested + +[npm version][npm-url] +[Build Status][cli-url] +[Discord][discord] + +## API + +[Read the API docs](./docs/selector-resolve-nested.md) + +## Usage + +Add [Selector Resolve Nested] to your project: + +```bash +npm install @csstools/selector-resolve-nested --save-dev +``` + +```js +import { resolveNestedSelector } from '@csstools/selector-resolve-nested'; +import parser from 'postcss-selector-parser'; + +const a = parser().astSync('.foo &'); +const b = parser().astSync('.bar'); + +resolveNestedSelector(a, b); // '.foo .bar' +``` + +[cli-url]: https://github.com/csstools/postcss-plugins/actions/workflows/test.yml?query=workflow/test +[discord]: https://discord.gg/bUadyRwkJS +[npm-url]: https://www.npmjs.com/package/@csstools/selector-resolve-nested + +[Selector Resolve Nested]: https://github.com/csstools/postcss-plugins/tree/main/packages/selector-resolve-nested diff --git a/packages/selector-resolve-nested/api-extractor.json b/packages/selector-resolve-nested/api-extractor.json new file mode 100644 index 000000000..ccef9beeb --- /dev/null +++ b/packages/selector-resolve-nested/api-extractor.json @@ -0,0 +1,7 @@ +{ + "$schema": "https://developer.microsoft.com/json-schemas/api-extractor/v7/api-extractor.schema.json", + "extends": "../../api-extractor.json", + "docModel": { + "enabled": true + } +} diff --git a/packages/selector-resolve-nested/dist/index.cjs b/packages/selector-resolve-nested/dist/index.cjs new file mode 100644 index 000000000..75b873f51 --- /dev/null +++ b/packages/selector-resolve-nested/dist/index.cjs @@ -0,0 +1 @@ +"use strict";var e=require("postcss-selector-parser");function sortCompoundSelectorsInsideComplexSelector(t){const o=[];let r=[];t.each((t=>{if("combinator"===t.type)return o.push(r,[t]),void(r=[]);if(e.isPseudoElement(t))return o.push(r),void(r=[t]);if("universal"===t.type&&r.find((e=>"universal"===e.type)))t.remove();else{if("tag"===t.type&&r.find((e=>"tag"===e.type))){t.remove();const o=e.pseudo({value:":is"});return o.append(e.selector({nodes:[t],value:""})),void r.push(o)}r.push(t)}})),o.push(r);const n=[];for(let e=0;eselectorTypeOrder(e)-selectorTypeOrder(t))),n.push(...t)}t.removeAll();for(let e=n.length-1;e>=0;e--)n[e].remove(),t.prepend(n[e])}function selectorTypeOrder(o){return e.isPseudoElement(o)?t.pseudoElement:t[o.type]}const t={universal:0,tag:1,pseudoElement:2,nesting:3,id:4,class:5,attribute:6,pseudo:7,comment:8};function prepareParentSelectors(t,o=!1){return o||!isCompoundSelector(t.nodes)?[e.pseudo({value:":is",nodes:t.nodes.map((e=>e.clone()))})]:t.nodes[0].nodes.map((e=>e.clone()))}function isCompoundSelector(t){return 1===t.length&&!t[0].nodes.some((t=>"combinator"===t.type||e.isPseudoElement(t)))}exports.resolveNestedSelector=function resolveNestedSelector(t,o){const r=[];for(let n=0;n(t=!0,!1))),t?"combinator"===s.nodes[0]?.type&&s.prepend(e.nesting({})):(s.prepend(e.combinator({value:" "})),s.prepend(e.nesting({})))}{const e=new Set;s.walkNesting((t=>{const r=t.parent;e.add(r),"pseudo"===r.parent?.type&&":has"===r.parent.value?.toLowerCase()?t.replaceWith(...prepareParentSelectors(o,!0)):t.replaceWith(...prepareParentSelectors(o))}));for(const t of e)sortCompoundSelectorsInsideComplexSelector(t)}s.walk((e=>{"combinator"===e.type&&""!==e.value.trim()?(e.rawSpaceAfter=" ",e.rawSpaceBefore=" "):(e.rawSpaceAfter="",e.rawSpaceBefore="")})),r.push(s)}return e.root({nodes:r,value:""})}; diff --git a/packages/selector-resolve-nested/dist/index.d.ts b/packages/selector-resolve-nested/dist/index.d.ts new file mode 100644 index 000000000..0e34cf662 --- /dev/null +++ b/packages/selector-resolve-nested/dist/index.d.ts @@ -0,0 +1,33 @@ +/** + * Resolve nested selectors following the CSS nesting specification. + * + * @example + * + * ```js + * import { resolveNestedSelector } from '@csstools/selector-resolve-nested'; + * import parser from 'postcss-selector-parser'; + * + * const selector = parser().astSync('.foo &'); + * const parent = parser().astSync('.bar'); + * + * // .foo .bar + * console.log( + * resolveNestedSelector(selector, parent).toString() + * ) + * ``` + * + * @packageDocumentation + */ + +import type { Root } from 'postcss-selector-parser'; + +/** + * Resolve a nested selector against a given parent selector. + * + * @param selector - The selector to resolve. + * @param parentSelector - The parent selector to resolve against. + * @returns The resolved selector. + */ +export declare function resolveNestedSelector(selector: Root, parentSelector: Root): Root; + +export { } diff --git a/packages/selector-resolve-nested/dist/index.mjs b/packages/selector-resolve-nested/dist/index.mjs new file mode 100644 index 000000000..1dce37960 --- /dev/null +++ b/packages/selector-resolve-nested/dist/index.mjs @@ -0,0 +1 @@ +import e from"postcss-selector-parser";function sortCompoundSelectorsInsideComplexSelector(o){const t=[];let r=[];o.each((o=>{if("combinator"===o.type)return t.push(r,[o]),void(r=[]);if(e.isPseudoElement(o))return t.push(r),void(r=[o]);if("universal"===o.type&&r.find((e=>"universal"===e.type)))o.remove();else{if("tag"===o.type&&r.find((e=>"tag"===e.type))){o.remove();const t=e.pseudo({value:":is"});return t.append(e.selector({nodes:[o],value:""})),void r.push(t)}r.push(o)}})),t.push(r);const n=[];for(let e=0;eselectorTypeOrder(e)-selectorTypeOrder(o))),n.push(...o)}o.removeAll();for(let e=n.length-1;e>=0;e--)n[e].remove(),o.prepend(n[e])}function selectorTypeOrder(t){return e.isPseudoElement(t)?o.pseudoElement:o[t.type]}const o={universal:0,tag:1,pseudoElement:2,nesting:3,id:4,class:5,attribute:6,pseudo:7,comment:8};function resolveNestedSelector(o,t){const r=[];for(let n=0;n(o=!0,!1))),o?"combinator"===s.nodes[0]?.type&&s.prepend(e.nesting({})):(s.prepend(e.combinator({value:" "})),s.prepend(e.nesting({})))}{const e=new Set;s.walkNesting((o=>{const r=o.parent;e.add(r),"pseudo"===r.parent?.type&&":has"===r.parent.value?.toLowerCase()?o.replaceWith(...prepareParentSelectors(t,!0)):o.replaceWith(...prepareParentSelectors(t))}));for(const o of e)sortCompoundSelectorsInsideComplexSelector(o)}s.walk((e=>{"combinator"===e.type&&""!==e.value.trim()?(e.rawSpaceAfter=" ",e.rawSpaceBefore=" "):(e.rawSpaceAfter="",e.rawSpaceBefore="")})),r.push(s)}return e.root({nodes:r,value:""})}function prepareParentSelectors(o,t=!1){return t||!isCompoundSelector(o.nodes)?[e.pseudo({value:":is",nodes:o.nodes.map((e=>e.clone()))})]:o.nodes[0].nodes.map((e=>e.clone()))}function isCompoundSelector(o){return 1===o.length&&!o[0].nodes.some((o=>"combinator"===o.type||e.isPseudoElement(o)))}export{resolveNestedSelector}; diff --git a/packages/selector-resolve-nested/docs/index.md b/packages/selector-resolve-nested/docs/index.md new file mode 100644 index 000000000..f14b7971a --- /dev/null +++ b/packages/selector-resolve-nested/docs/index.md @@ -0,0 +1,12 @@ + + +[Home](./index.md) + +## API Reference + +## Packages + +| Package | Description | +| --- | --- | +| [@csstools/selector-resolve-nested](./selector-resolve-nested.md) | Resolve nested selectors following the CSS nesting specification. | + diff --git a/packages/selector-resolve-nested/docs/selector-resolve-nested.api.json b/packages/selector-resolve-nested/docs/selector-resolve-nested.api.json new file mode 100644 index 000000000..b00fafa65 --- /dev/null +++ b/packages/selector-resolve-nested/docs/selector-resolve-nested.api.json @@ -0,0 +1,241 @@ +{ + "metadata": { + "toolPackage": "@microsoft/api-extractor", + "schemaVersion": 1011, + "oldestForwardsCompatibleVersion": 1001, + "tsdocConfig": { + "$schema": "https://developer.microsoft.com/json-schemas/tsdoc/v0/tsdoc.schema.json", + "noStandardTags": true, + "tagDefinitions": [ + { + "tagName": "@alpha", + "syntaxKind": "modifier" + }, + { + "tagName": "@beta", + "syntaxKind": "modifier" + }, + { + "tagName": "@defaultValue", + "syntaxKind": "block" + }, + { + "tagName": "@decorator", + "syntaxKind": "block", + "allowMultiple": true + }, + { + "tagName": "@deprecated", + "syntaxKind": "block" + }, + { + "tagName": "@eventProperty", + "syntaxKind": "modifier" + }, + { + "tagName": "@example", + "syntaxKind": "block", + "allowMultiple": true + }, + { + "tagName": "@experimental", + "syntaxKind": "modifier" + }, + { + "tagName": "@inheritDoc", + "syntaxKind": "inline" + }, + { + "tagName": "@internal", + "syntaxKind": "modifier" + }, + { + "tagName": "@label", + "syntaxKind": "inline" + }, + { + "tagName": "@link", + "syntaxKind": "inline", + "allowMultiple": true + }, + { + "tagName": "@override", + "syntaxKind": "modifier" + }, + { + "tagName": "@packageDocumentation", + "syntaxKind": "modifier" + }, + { + "tagName": "@param", + "syntaxKind": "block", + "allowMultiple": true + }, + { + "tagName": "@privateRemarks", + "syntaxKind": "block" + }, + { + "tagName": "@public", + "syntaxKind": "modifier" + }, + { + "tagName": "@readonly", + "syntaxKind": "modifier" + }, + { + "tagName": "@remarks", + "syntaxKind": "block" + }, + { + "tagName": "@returns", + "syntaxKind": "block" + }, + { + "tagName": "@sealed", + "syntaxKind": "modifier" + }, + { + "tagName": "@see", + "syntaxKind": "block" + }, + { + "tagName": "@throws", + "syntaxKind": "block", + "allowMultiple": true + }, + { + "tagName": "@typeParam", + "syntaxKind": "block", + "allowMultiple": true + }, + { + "tagName": "@virtual", + "syntaxKind": "modifier" + }, + { + "tagName": "@betaDocumentation", + "syntaxKind": "modifier" + }, + { + "tagName": "@internalRemarks", + "syntaxKind": "block" + }, + { + "tagName": "@preapproved", + "syntaxKind": "modifier" + } + ], + "supportForTags": { + "@alpha": true, + "@beta": true, + "@defaultValue": true, + "@decorator": true, + "@deprecated": true, + "@eventProperty": true, + "@example": true, + "@experimental": true, + "@inheritDoc": true, + "@internal": true, + "@label": true, + "@link": true, + "@override": true, + "@packageDocumentation": true, + "@param": true, + "@privateRemarks": true, + "@public": true, + "@readonly": true, + "@remarks": true, + "@returns": true, + "@sealed": true, + "@see": true, + "@throws": true, + "@typeParam": true, + "@virtual": true, + "@betaDocumentation": true, + "@internalRemarks": true, + "@preapproved": true + }, + "reportUnsupportedHtmlElements": false + } + }, + "kind": "Package", + "canonicalReference": "@csstools/selector-resolve-nested!", + "docComment": "/**\n * Resolve nested selectors following the CSS nesting specification.\n *\n * @example\n * ```js\n * import { resolveNestedSelector } from '@csstools/selector-resolve-nested';\n * import parser from 'postcss-selector-parser';\n *\n * const selector = parser().astSync('.foo &');\n * const parent = parser().astSync('.bar');\n *\n * // .foo .bar\n * console.log(\n * resolveNestedSelector(selector, parent).toString()\n * )\n * ```\n *\n * @packageDocumentation\n */\n", + "name": "@csstools/selector-resolve-nested", + "preserveMemberOrder": false, + "members": [ + { + "kind": "EntryPoint", + "canonicalReference": "@csstools/selector-resolve-nested!", + "name": "", + "preserveMemberOrder": false, + "members": [ + { + "kind": "Function", + "canonicalReference": "@csstools/selector-resolve-nested!resolveNestedSelector:function(1)", + "docComment": "/**\n * Resolve a nested selector against a given parent selector.\n *\n * @param selector - The selector to resolve.\n *\n * @param parentSelector - The parent selector to resolve against.\n *\n * @returns The resolved selector.\n */\n", + "excerptTokens": [ + { + "kind": "Content", + "text": "export declare function resolveNestedSelector(selector: " + }, + { + "kind": "Reference", + "text": "Root", + "canonicalReference": "postcss-selector-parser!parser.Root:interface" + }, + { + "kind": "Content", + "text": ", parentSelector: " + }, + { + "kind": "Reference", + "text": "Root", + "canonicalReference": "postcss-selector-parser!parser.Root:interface" + }, + { + "kind": "Content", + "text": "): " + }, + { + "kind": "Reference", + "text": "Root", + "canonicalReference": "postcss-selector-parser!parser.Root:interface" + }, + { + "kind": "Content", + "text": ";" + } + ], + "fileUrlPath": "dist/_types/index.d.ts", + "returnTypeTokenRange": { + "startIndex": 5, + "endIndex": 6 + }, + "releaseTag": "Public", + "overloadIndex": 1, + "parameters": [ + { + "parameterName": "selector", + "parameterTypeTokenRange": { + "startIndex": 1, + "endIndex": 2 + }, + "isOptional": false + }, + { + "parameterName": "parentSelector", + "parameterTypeTokenRange": { + "startIndex": 3, + "endIndex": 4 + }, + "isOptional": false + } + ], + "name": "resolveNestedSelector" + } + ] + } + ] +} \ No newline at end of file diff --git a/packages/selector-resolve-nested/docs/selector-resolve-nested.md b/packages/selector-resolve-nested/docs/selector-resolve-nested.md new file mode 100644 index 000000000..a57e9c0a2 --- /dev/null +++ b/packages/selector-resolve-nested/docs/selector-resolve-nested.md @@ -0,0 +1,30 @@ + + +[Home](./index.md) > [@csstools/selector-resolve-nested](./selector-resolve-nested.md) + +## selector-resolve-nested package + +Resolve nested selectors following the CSS nesting specification. + +## Example + + +```js +import { resolveNestedSelector } from '@csstools/selector-resolve-nested'; +import parser from 'postcss-selector-parser'; + +const selector = parser().astSync('.foo &'); +const parent = parser().astSync('.bar'); + +// .foo .bar +console.log( + resolveNestedSelector(selector, parent).toString() +) +``` + +## Functions + +| Function | Description | +| --- | --- | +| [resolveNestedSelector(selector, parentSelector)](./selector-resolve-nested.resolvenestedselector.md) | Resolve a nested selector against a given parent selector. | + diff --git a/packages/selector-resolve-nested/docs/selector-resolve-nested.resolvenestedselector.md b/packages/selector-resolve-nested/docs/selector-resolve-nested.resolvenestedselector.md new file mode 100644 index 000000000..f77747188 --- /dev/null +++ b/packages/selector-resolve-nested/docs/selector-resolve-nested.resolvenestedselector.md @@ -0,0 +1,27 @@ + + +[Home](./index.md) > [@csstools/selector-resolve-nested](./selector-resolve-nested.md) > [resolveNestedSelector](./selector-resolve-nested.resolvenestedselector.md) + +## resolveNestedSelector() function + +Resolve a nested selector against a given parent selector. + +**Signature:** + +```typescript +export declare function resolveNestedSelector(selector: Root, parentSelector: Root): Root; +``` + +## Parameters + +| Parameter | Type | Description | +| --- | --- | --- | +| selector | Root | The selector to resolve. | +| parentSelector | Root | The parent selector to resolve against. | + +**Returns:** + +Root + +The resolved selector. + diff --git a/packages/selector-resolve-nested/package.json b/packages/selector-resolve-nested/package.json new file mode 100644 index 000000000..7d4f42815 --- /dev/null +++ b/packages/selector-resolve-nested/package.json @@ -0,0 +1,79 @@ +{ + "name": "@csstools/selector-resolve-nested", + "description": "Resolve nested CSS selectors", + "version": "0.0.0", + "contributors": [ + { + "name": "Antonio Laguna", + "email": "antonio@laguna.es", + "url": "https://antonio.laguna.es" + }, + { + "name": "Romain Menke", + "email": "romainmenke@gmail.com" + } + ], + "license": "MIT-0", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "engines": { + "node": "^14 || ^16 || >=18" + }, + "type": "module", + "main": "dist/index.cjs", + "module": "dist/index.mjs", + "exports": { + ".": { + "import": { + "types": "./dist/index.d.ts", + "default": "./dist/index.mjs" + }, + "require": { + "default": "./dist/index.cjs" + } + } + }, + "files": [ + "CHANGELOG.md", + "LICENSE.md", + "README.md", + "dist" + ], + "peerDependencies": { + "postcss-selector-parser": "^6.0.13" + }, + "devDependencies": { + "postcss-selector-parser": "^6.0.13" + }, + "scripts": { + "build": "rollup -c ../../rollup/default.mjs", + "docs": "node ../../.github/bin/generate-docs/api-documenter.mjs", + "lint": "node ../../.github/bin/format-package-json.mjs", + "prepublishOnly": "npm run build && npm run test", + "stryker": "stryker run --logLevel error", + "test": "node --test ./test/index.mjs ./test/_import.mjs && node ./test/_require.cjs" + }, + "homepage": "https://github.com/csstools/postcss-plugins/tree/main/packages/selector-resolve-nested#readme", + "repository": { + "type": "git", + "url": "https://github.com/csstools/postcss-plugins.git", + "directory": "packages/selector-resolve-nested" + }, + "bugs": "https://github.com/csstools/postcss-plugins/issues", + "keywords": [ + "css", + "postcss-selector-parser", + "nested" + ], + "volta": { + "extends": "../../package.json" + } +} diff --git a/packages/selector-resolve-nested/src/compound-selector-order.ts b/packages/selector-resolve-nested/src/compound-selector-order.ts new file mode 100644 index 000000000..6648fda20 --- /dev/null +++ b/packages/selector-resolve-nested/src/compound-selector-order.ts @@ -0,0 +1,90 @@ +import parser from 'postcss-selector-parser'; +import type { Container, Node } from 'postcss-selector-parser'; + +export function sortCompoundSelectorsInsideComplexSelector(node: Container) { + const compoundSelectors: Array> = []; + let currentCompoundSelector: Array = []; + + node.each((childNode) => { + if (childNode.type === 'combinator') { + // Push + // - the current compound selector + // - the combinator + compoundSelectors.push( + currentCompoundSelector, + [childNode], + ); + + // Start a new compound selector + currentCompoundSelector = []; + return; + } + + if (parser.isPseudoElement(childNode)) { + // Push the current compound selector + compoundSelectors.push(currentCompoundSelector); + + // Start a new compound selector with the pseudo element as the first element + currentCompoundSelector = [childNode]; + return; + } + + if (childNode.type === 'universal' && currentCompoundSelector.find(x => x.type === 'universal')) { + childNode.remove(); + return; + } + + if (childNode.type === 'tag' && currentCompoundSelector.find(x => x.type === 'tag')) { + childNode.remove(); + + const isPseudoClone = parser.pseudo({ value: ':is' }); + isPseudoClone.append(parser.selector({ + nodes: [childNode], + value: '', + })); + + currentCompoundSelector.push(isPseudoClone); + return; + } + + currentCompoundSelector.push(childNode); + }); + + compoundSelectors.push(currentCompoundSelector); + + const sortedCompoundSelectors = []; + for (let i = 0; i < compoundSelectors.length; i++) { + const compoundSelector = compoundSelectors[i]; + compoundSelector.sort((a, b) => { + return selectorTypeOrder(a) - selectorTypeOrder(b); + }); + + sortedCompoundSelectors.push(...compoundSelector); + } + + node.removeAll(); + for (let i = sortedCompoundSelectors.length - 1; i >= 0; i--) { + sortedCompoundSelectors[i].remove(); + node.prepend(sortedCompoundSelectors[i]); + } +} + +function selectorTypeOrder(selector: Node): number { + if (parser.isPseudoElement(selector)) { + return selectorTypeOrderIndex.pseudoElement; + } + + return selectorTypeOrderIndex[selector.type as keyof typeof selectorTypeOrderIndex]; +} + +const selectorTypeOrderIndex = { + universal: 0, + tag: 1, + pseudoElement: 2, + nesting: 3, + id: 4, + class: 5, + attribute: 6, + pseudo: 7, + comment: 8, +}; diff --git a/packages/selector-resolve-nested/src/index.ts b/packages/selector-resolve-nested/src/index.ts new file mode 100644 index 000000000..57def7d6b --- /dev/null +++ b/packages/selector-resolve-nested/src/index.ts @@ -0,0 +1,118 @@ +/** + * Resolve nested selectors following the CSS nesting specification. + * + * @example + * + * ```js + * import { resolveNestedSelector } from '@csstools/selector-resolve-nested'; + * import parser from 'postcss-selector-parser'; + * + * const selector = parser().astSync('.foo &'); + * const parent = parser().astSync('.bar'); + * + * // .foo .bar + * console.log( + * resolveNestedSelector(selector, parent).toString() + * ) + * ``` + * + * @packageDocumentation + */ + +import type { Container, Node, Root, Selector } from 'postcss-selector-parser'; +import parser from 'postcss-selector-parser'; +import { sortCompoundSelectorsInsideComplexSelector } from './compound-selector-order'; + +/** + * Resolve a nested selector against a given parent selector. + * + * @param selector - The selector to resolve. + * @param parentSelector - The parent selector to resolve against. + * @returns The resolved selector. + */ +export function resolveNestedSelector(selector: Root, parentSelector: Root): Root { + const result: Array = []; + + for (let x = 0; x < selector.nodes.length; x++) { + const selectorAST = selector.nodes[x].clone(); + + { + let isNestContaining = false; + selectorAST.walkNesting(() => { + isNestContaining = true; + return false; + }); + + if (!isNestContaining) { + selectorAST.prepend(parser.combinator({ value: ' ' })); + selectorAST.prepend(parser.nesting({})); + } else if (selectorAST.nodes[0]?.type === 'combinator') { + selectorAST.prepend(parser.nesting({})); + } + } + + { + const needsSorting = new Set>(); + + selectorAST.walkNesting((node) => { + const parent = node.parent!; + + needsSorting.add(parent); + if (parent.parent?.type === 'pseudo' && parent.parent.value?.toLowerCase() === ':has') { + node.replaceWith(...prepareParentSelectors(parentSelector, true)); + } else { + node.replaceWith(...prepareParentSelectors(parentSelector)); + } + }); + + for (const parent of needsSorting) { + sortCompoundSelectorsInsideComplexSelector(parent); + } + } + + selectorAST.walk((node) => { + if (node.type === 'combinator' && node.value.trim() !== '') { + node.rawSpaceAfter = ' '; + node.rawSpaceBefore = ' '; + } else { + node.rawSpaceAfter = ''; + node.rawSpaceBefore = ''; + } + }); + + result.push(selectorAST); + } + + return parser.root({ + nodes: result, + value: '', + }); +} + +function prepareParentSelectors(parentSelectors: Root, forceIsPseudo: boolean = false) { + if ( + forceIsPseudo || + !isCompoundSelector(parentSelectors.nodes) + ) { + return [ + parser.pseudo({ + value: ':is', + nodes: parentSelectors.nodes.map((x) => x.clone()), + }), + ]; + } + + return parentSelectors.nodes[0].nodes.map((x) => x.clone()); +} + +function isCompoundSelector(selectors: Array): boolean { + // Selector list with multiple entries is not only a compound selector + if (selectors.length !== 1) { + return false; + } + + // A selector with combinators or pseudo elements is not a compound selector + return !selectors[0].nodes.some((x) => { + return x.type === 'combinator' || parser.isPseudoElement(x); + }); +} diff --git a/packages/selector-resolve-nested/stryker.conf.json b/packages/selector-resolve-nested/stryker.conf.json new file mode 100644 index 000000000..221c75ecd --- /dev/null +++ b/packages/selector-resolve-nested/stryker.conf.json @@ -0,0 +1,19 @@ +{ + "$schema": "../../node_modules/@stryker-mutator/core/schema/stryker-schema.json", + "mutate": [ + "src/**/*.ts" + ], + "buildCommand": "rollup -c ../../rollup/default.mjs", + "testRunner": "command", + "coverageAnalysis": "perTest", + "tempDirName": "../../.stryker-tmp", + "commandRunner": { + "command": "node ./test/index.mjs" + }, + "thresholds": { + "high": 100, + "low": 100, + "break": 100 + }, + "inPlace": true +} diff --git a/packages/selector-resolve-nested/test/_import.mjs b/packages/selector-resolve-nested/test/_import.mjs new file mode 100644 index 000000000..8f0381ff3 --- /dev/null +++ b/packages/selector-resolve-nested/test/_import.mjs @@ -0,0 +1,8 @@ +import assert from 'assert'; +import { resolveNestedSelector } from '@csstools/selector-resolve-nested'; +import parser from 'postcss-selector-parser'; + +const a = parser().astSync('.foo &'); +const b = parser().astSync('.bar'); + +assert.equal(resolveNestedSelector(a, b), '.foo .bar'); diff --git a/packages/selector-resolve-nested/test/_require.cjs b/packages/selector-resolve-nested/test/_require.cjs new file mode 100644 index 000000000..be1cf34f0 --- /dev/null +++ b/packages/selector-resolve-nested/test/_require.cjs @@ -0,0 +1,8 @@ +const assert = require('assert'); +const { resolveNestedSelector } = require('@csstools/selector-resolve-nested'); +const parser = require('postcss-selector-parser'); + +const a = parser().astSync('.foo'); +const b = parser().astSync('.bar'); + +assert.equal(resolveNestedSelector(a, b), '.bar .foo'); diff --git a/packages/selector-resolve-nested/test/complex.mjs b/packages/selector-resolve-nested/test/complex.mjs new file mode 100644 index 000000000..d0d518cf4 --- /dev/null +++ b/packages/selector-resolve-nested/test/complex.mjs @@ -0,0 +1,101 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { resolveNestedSelector } from '@csstools/selector-resolve-nested'; +import { parse } from './util/parse.mjs'; + +test('surrounding whitespace', async () => { + const testCases = [ + { + a: '.foo + .fooz', + b: '.bar + .baz', + expected: ':is(.bar + .baz) .foo + .fooz', + }, + { + a: '.foo+.fooz', + b: '.bar+.baz', + expected: ':is(.bar + .baz) .foo + .fooz', + }, + { + a: '+ &', + b: '.bar + .baz', + expected: ':is(.bar + .baz) + :is(.bar + .baz)', + }, + { + a: '+&', + b: '.bar +.baz', + expected: ':is(.bar + .baz) + :is(.bar + .baz)', + }, + { + a: '.foo + &', + b: '.bar + .baz', + expected: '.foo + :is(.bar + .baz)', + }, + { + a: '& + .foo', + b: '.bar + .baz', + expected: ':is(.bar + .baz) + .foo', + }, + { + a: '& .foo', + b: '.bar .baz', + expected: ':is(.bar .baz) .foo', + }, + { + a: '& .foo, .fooz', + b: '.bar .baz', + expected: ':is(.bar .baz) .foo,:is(.bar .baz) .fooz', + }, + { + a: ':has(&)', + b: '.bar', + expected: ':has(:is(.bar))', + }, + { + a: ':has(&)', + b: '.bar .baz', + expected: ':has(:is(.bar .baz))', + }, + { + a: '.baz, &, :where(.bar, &, &:is(.foo, &, &:has(.fooz, &)))', + b: '.a', + expected: '.a .baz,.a,:where(.bar,.a,.a:is(.foo,.a,.a:has(.fooz,:is(.a))))', + }, + { + a: 'div&::before', + b: '.bar + .baz', + expected: 'div:is(.bar + .baz)::before', + }, + { + a: 'div::before&', + b: '.bar + .baz', + expected: 'div::before:is(.bar + .baz)', + }, + { + a: '&', + b: '::before:hover', + expected: ':is(::before:hover)', + }, + { + a: '&,.foo', + b: '', + expected: ', .foo', + }, + { + a: ':is(&)', + b: '', + expected: ':is()', + }, + { + a: '', + b: '.a', + expected: '.a ', + }, + ]; + + for (const { a, b, expected } of testCases) { + assert.equal( + resolveNestedSelector(parse(a), parse(b)).toString(), + expected, + ); + } +}); diff --git a/packages/selector-resolve-nested/test/compound.mjs b/packages/selector-resolve-nested/test/compound.mjs new file mode 100644 index 000000000..06b5f1c01 --- /dev/null +++ b/packages/selector-resolve-nested/test/compound.mjs @@ -0,0 +1,146 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { resolveNestedSelector } from '@csstools/selector-resolve-nested'; +import { parse } from './util/parse.mjs'; + +test('surrounding whitespace', async () => { + const testCases = [ + { + a: 'input&', + b: ':hover', + expected: 'input:hover', + }, + { + a: 'input&', + b: 'div', + expected: 'input:is(div)', + }, + { + a: '.foo&', + b: ':hover', + expected: '.foo:hover', + }, + { + a: '.foo&', + b: 'div', + expected: 'div.foo', + }, + { + a: 'input:is(&, :focus)', + b: ':hover', + expected: 'input:is(:hover,:focus)', + }, + { + a: 'input:is(&, :focus)', + b: 'div', + expected: 'input:is(div,:focus)', + }, + { + a: '.foo:is(&, :focus)', + b: ':hover', + expected: '.foo:is(:hover,:focus)', + }, + { + a: '.foo:is(&, :focus)', + b: 'div', + expected: '.foo:is(div,:focus)', + }, + { + a: '&', + b: 'div', + expected: 'div', + }, + { + a: '&&', + b: 'div', + expected: 'div:is(div)', + }, + { + a: '&&&', + b: 'div', + expected: 'div:is(div):is(div)', + }, + { + a: 'div&::before', + b: ':focus', + expected: 'div:focus::before', + }, + { + a: 'div::before&', + b: ':focus', + expected: 'div::before:focus', + }, + { + a: 'div::before&[attr]', + b: ':focus', + expected: 'div::before[attr]:focus', + }, + { + a: 'div::before[attr]&', + b: ':focus', + expected: 'div::before[attr]:focus', + }, + { + a: 'div&.foo&[bar]&:hover', + b: ':focus', + expected: 'div.foo[bar]:focus:focus:focus:hover', + }, + { + a: 'div&.foo&[bar]&:hover', + b: '[value]', + expected: 'div.foo[value][value][bar][value]:hover', + }, + { + a: 'div&.foo&[bar]&:hover', + b: '.a + .b', + expected: 'div.foo[bar]:is(.a + .b):is(.a + .b):is(.a + .b):hover', + }, + { + a: 'div.foo[bar]:hover&&&', + b: ':focus', + expected: 'div.foo[bar]:hover:focus:focus:focus', + }, + { + a: 'div&&&.foo[bar]:hover', + b: ':focus', + expected: 'div.foo[bar]:focus:focus:focus:hover', + }, + { + a: '&', + b: '*', + expected: '*', + }, + { + a: '*&', + b: '*', + expected: '*', + }, + { + a: '&&', + b: '*', + expected: '*', + }, + { + a: '&&&', + b: '*', + expected: '*', + }, + { + a: '&/* comment a */&/* comment b */&', + b: '.foo', + expected: '.foo.foo.foo/* comment a *//* comment b */', + }, + { + a: '&#foo&', + b: '.foo', + expected: '#foo.foo.foo', + }, + ]; + + for (const { a, b, expected } of testCases) { + assert.equal( + resolveNestedSelector(parse(a), parse(b)).toString(), + expected, + ); + } +}); diff --git a/packages/selector-resolve-nested/test/index.mjs b/packages/selector-resolve-nested/test/index.mjs new file mode 100644 index 000000000..7c4d293e7 --- /dev/null +++ b/packages/selector-resolve-nested/test/index.mjs @@ -0,0 +1,5 @@ +import './complex.mjs'; +import './compound.mjs'; +import './lists.mjs'; +import './simple-compound-complex.mjs'; +import './multiple-resolves.mjs'; diff --git a/packages/selector-resolve-nested/test/lists.mjs b/packages/selector-resolve-nested/test/lists.mjs new file mode 100644 index 000000000..6b32468bb --- /dev/null +++ b/packages/selector-resolve-nested/test/lists.mjs @@ -0,0 +1,49 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { resolveNestedSelector } from '@csstools/selector-resolve-nested'; +import { parse } from './util/parse.mjs'; + +test('simple - list', async () => { + assert.equal( + resolveNestedSelector(parse('.foo'), parse('.bar, baz')).toString(), + ':is(.bar,baz) .foo', + ); +}); + +test('relative - list', async () => { + assert.equal( + resolveNestedSelector(parse('> .foo'), parse('.bar, baz')).toString(), + ':is(.bar,baz) > .foo', + ); +}); + +test('list - simple', async () => { + assert.equal( + resolveNestedSelector(parse('.foo, fooz'), parse('.bar')).toString(), + '.bar .foo,.bar fooz', + ); +}); + +test('list - list', async () => { + assert.equal( + resolveNestedSelector(parse('.foo, .fooz'), parse('.bar, .baz')).toString(), + ':is(.bar,.baz) .foo,:is(.bar,.baz) .fooz', + ); + + assert.equal( + resolveNestedSelector(parse('.foo, .fooz, .foos'), parse('.bar, .baz, .bas')).toString(), + ':is(.bar,.baz,.bas) .foo,:is(.bar,.baz,.bas) .fooz,:is(.bar,.baz,.bas) .foos', + ); + + assert.equal( + resolveNestedSelector(parse('& & .foo, & & .fooz, & & .foos'), parse('.bar > :hover, .baz > :hover, .bas')).toString(), + ':is(.bar > :hover,.baz > :hover,.bas) :is(.bar > :hover,.baz > :hover,.bas) .foo,:is(.bar > :hover,.baz > :hover,.bas) :is(.bar > :hover,.baz > :hover,.bas) .fooz,:is(.bar > :hover,.baz > :hover,.bas) :is(.bar > :hover,.baz > :hover,.bas) .foos', + ); +}); + +test('relative list - simple', async () => { + assert.equal( + resolveNestedSelector(parse('> .foo, + fooz'), parse('.bar')).toString(), + '.bar > .foo,.bar + fooz', + ); +}); diff --git a/packages/selector-resolve-nested/test/multiple-resolves.mjs b/packages/selector-resolve-nested/test/multiple-resolves.mjs new file mode 100644 index 000000000..84057a046 --- /dev/null +++ b/packages/selector-resolve-nested/test/multiple-resolves.mjs @@ -0,0 +1,21 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { resolveNestedSelector } from '@csstools/selector-resolve-nested'; +import parser from 'postcss-selector-parser'; + +test('multiple resolves', async () => { + const a = parser().astSync('.a .aa'); + const b = parser().astSync('& + .b'); + const c = parser().astSync('.c, &'); + + const a2 = parser().astSync('.a2 .aa2'); + + const cb = resolveNestedSelector(c, b); + assert.equal(cb.toString(), ':is(& + .b) .c,:is(& + .b)'); + + const cba = resolveNestedSelector(cb, a); + assert.equal(cba.toString(), ':is(:is(.a .aa) + .b) .c,:is(:is(.a .aa) + .b)'); + + const cba2 = resolveNestedSelector(cb, a2); + assert.equal(cba2.toString(), ':is(:is(.a2 .aa2) + .b) .c,:is(:is(.a2 .aa2) + .b)'); +}); diff --git a/packages/selector-resolve-nested/test/simple-compound-complex.mjs b/packages/selector-resolve-nested/test/simple-compound-complex.mjs new file mode 100644 index 000000000..5c969d331 --- /dev/null +++ b/packages/selector-resolve-nested/test/simple-compound-complex.mjs @@ -0,0 +1,77 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { resolveNestedSelector } from '@csstools/selector-resolve-nested'; +import { parse } from './util/parse.mjs'; + +test('surrounding whitespace', async () => { + assert.equal( + resolveNestedSelector(parse(' .foo'), parse(' .bar')).toString(), + '.bar .foo', + ); + + assert.equal( + resolveNestedSelector(parse('.foo '), parse('.bar ')).toString(), + '.bar .foo', + ); + + assert.equal( + resolveNestedSelector(parse(' .foo '), parse(' .bar ')).toString(), + '.bar .foo', + ); +}); + +test('simple - simple', async () => { + assert.equal( + resolveNestedSelector(parse('.foo'), parse('.bar')).toString(), + '.bar .foo', + ); +}); + +test('simple - compound', async () => { + assert.equal( + resolveNestedSelector(parse('.foo'), parse('.bar:hover')).toString(), + '.bar:hover .foo', + ); +}); + +test('compound - simple', async () => { + assert.equal( + resolveNestedSelector(parse('.foo:focus'), parse('.bar')).toString(), + '.bar .foo:focus', + ); +}); + +test('compound - compound', async () => { + assert.equal( + resolveNestedSelector(parse('.foo:focus'), parse('.bar:hover')).toString(), + '.bar:hover .foo:focus', + ); +}); + +test('simple - complex', async () => { + assert.equal( + resolveNestedSelector(parse('.foo'), parse('.bar + .baz')).toString(), + ':is(.bar + .baz) .foo', + ); +}); + +test('compound - complex', async () => { + assert.equal( + resolveNestedSelector(parse('.foo:focus'), parse('.bar + .baz')).toString(), + ':is(.bar + .baz) .foo:focus', + ); +}); + +test('complex - compound', async () => { + assert.equal( + resolveNestedSelector(parse('.foo + .fooz'), parse('.bar:hover')).toString(), + '.bar:hover .foo + .fooz', + ); +}); + +test('complex - complex', async () => { + assert.equal( + resolveNestedSelector(parse('.foo + .fooz'), parse('.bar + .baz')).toString(), + ':is(.bar + .baz) .foo + .fooz', + ); +}); diff --git a/packages/selector-resolve-nested/test/util/parse.mjs b/packages/selector-resolve-nested/test/util/parse.mjs new file mode 100644 index 000000000..be0807bfe --- /dev/null +++ b/packages/selector-resolve-nested/test/util/parse.mjs @@ -0,0 +1,6 @@ +import parser from 'postcss-selector-parser'; +const p = parser(); + +export function parse(selector) { + return p.astSync(selector); +} diff --git a/packages/selector-resolve-nested/tsconfig.json b/packages/selector-resolve-nested/tsconfig.json new file mode 100644 index 000000000..c4bc02ba4 --- /dev/null +++ b/packages/selector-resolve-nested/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "dist", + "declarationDir": ".", + "strict": true, + }, + "include": ["./src/**/*"], + "exclude": ["dist"], +} From 8783ea4f03d21d64a2870de678e2a94fedc1a508 Mon Sep 17 00:00:00 2001 From: Romain Menke Date: Sun, 21 Jan 2024 19:24:47 +0100 Subject: [PATCH 2/2] lint --- packages/selector-resolve-nested/package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/selector-resolve-nested/package.json b/packages/selector-resolve-nested/package.json index 7d4f42815..1d539b928 100644 --- a/packages/selector-resolve-nested/package.json +++ b/packages/selector-resolve-nested/package.json @@ -70,8 +70,8 @@ "bugs": "https://github.com/csstools/postcss-plugins/issues", "keywords": [ "css", - "postcss-selector-parser", - "nested" + "nested", + "postcss-selector-parser" ], "volta": { "extends": "../../package.json"