diff --git a/package-lock.json b/package-lock.json index 48e7187ef5..0dcd151af3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10268,10 +10268,12 @@ "license": "MIT-0", "dependencies": { "@csstools/css-parser-algorithms": "^4.0.1", - "@csstools/css-tokenizer": "^4.0.2" + "@csstools/css-tokenizer": "^4.0.2", + "@csstools/postcss-private-rule": "^1.0.3" }, "devDependencies": { - "@csstools/postcss-tape": "*" + "@csstools/postcss-tape": "*", + "puppeteer": "^25.11.0" }, "engines": { "node": ">=20.19.0" diff --git a/packages/css-parser-algorithms/CHANGELOG.md b/packages/css-parser-algorithms/CHANGELOG.md index ac65e2567b..c81fa18219 100644 --- a/packages/css-parser-algorithms/CHANGELOG.md +++ b/packages/css-parser-algorithms/CHANGELOG.md @@ -1,5 +1,9 @@ # Changes to CSS Parser Algorithms +### Unreleased (patch) + +- `parseCommaSeparatedListOfComponentValues` now emits empty lists for cases like `foo,,bar` effectively returning 3 lists of component values instead of only 2. + ### 4.0.1 _September 25, 2026_ diff --git a/packages/css-parser-algorithms/dist/index.mjs b/packages/css-parser-algorithms/dist/index.mjs index 395e1a0325..58bdaf9385 100644 --- a/packages/css-parser-algorithms/dist/index.mjs +++ b/packages/css-parser-algorithms/dist/index.mjs @@ -1 +1 @@ -import{stringify as e,isTokenEOF as n,TokenType as t,isToken as o,mirrorVariant as s,isTokenOpenParen as i,isTokenOpenCurly as r,isTokenOpenSquare as a,isTokenFunction as c,isTokenWhitespace as u,isTokenComment as l,mirrorVariantType as d,ParseError as h,isTokenWhiteSpaceOrComment as m,isTokenCloseParen as p,isTokenComma as f}from"@csstools/css-tokenizer";var k;function walkerIndexGenerator(e){let n,t=e.slice(),o=-1;return(e,s,i)=>{if(e===n&&e.length===o&&e[i]===s){o=e.length;const n=i+1;return n=e.length)?(n=e,o=e.length,-1):(t=e.slice(),n=e,o=e.length,r)}}!function(e){e.Function="function",e.SimpleBlock="simple-block",e.Whitespace="whitespace",e.Comment="comment",e.Token="token"}(k||(k={}));const N=512;function consumeComponentValue(e,n,t=0,o=0){const s=n[t];if(i(s)||r(s)||a(s)){const s=consumeSimpleBlock(e,n,t,o);return{advance:s.advance,node:s.node}}if(c(s)){const s=consumeFunction(e,n,t,o);return{advance:s.advance,node:s.node}}if(u(s)){const o=consumeWhitespace(e,n,t);return{advance:o.advance,node:o.node}}if(l(s)){const o=consumeComment(e,n,t);return{advance:o.advance,node:o.node}}return{advance:1,node:new TokenNode(s)}}class ContainerNodeBaseClass{value=[];indexOf(e){return this.value.indexOf(e)}at(e){if("number"==typeof e)return e<0&&(e=this.value.length+e),this.value[e]}forEach(e,n){if(0===this.value.length)return;const t=walkerIndexGenerator(this.value);let o=0;for(;o!1!==e(n,t)&&((!("walk"in n.node)||!("number"==typeof t&&this.value[t]===n.node||this.value.includes(n.node))||!1!==n.node.walk(e,n.state))&&void 0),n)}}class FunctionNode extends ContainerNodeBaseClass{type=k.Function;name;endToken;constructor(e,n,t){super(),this.name=e,this.endToken=n,this.value=t}getName(){return this.name[4].value}normalize(){n(this.endToken)&&(this.endToken=[t.CloseParen,")",-1,-1,void 0])}tokens(){return n(this.endToken)?[this.name,...this.value.flatMap(e=>e.tokens())]:[this.name,...this.value.flatMap(e=>e.tokens()),this.endToken]}toString(){const n=this.value.map(n=>o(n)?e(n):n.toString()).join("");return e(this.name)+n+e(this.endToken)}toJSON(){return{type:this.type,name:this.getName(),tokens:this.tokens(),value:this.value.map(e=>e.toJSON())}}isFunctionNode(){return FunctionNode.isFunctionNode(this)}static isFunctionNode(e){return!!e&&(e instanceof FunctionNode&&e.type===k.Function)}}function consumeFunction(e,t,o,s){if(s>=N)throw new Error("Maximum nesting depth of 512 exceeded, reduce the complexity of your stylesheet.");const i=[];let r=o+1;for(;;){const a=t[r];if(!a||n(a))return e.onParseError(new h("Unexpected EOF while consuming a function.",t[o][2],t[t.length-1][3],["5.4.9. Consume a function","Unexpected EOF"])),{advance:t.length-o,node:new FunctionNode(t[o],a,i)};if(p(a))return{advance:r-o+1,node:new FunctionNode(t[o],a,i)};if(m(a)){const n=consumeAllCommentsAndWhitespace(e,t,r);r+=n.advance;for(let e=0;ee.tokens())]:[this.startToken,...this.value.flatMap(e=>e.tokens()),this.endToken]}toString(){const n=this.value.map(n=>o(n)?e(n):n.toString()).join("");return e(this.startToken)+n+e(this.endToken)}toJSON(){return{type:this.type,startToken:this.startToken,tokens:this.tokens(),value:this.value.map(e=>e.toJSON())}}isSimpleBlockNode(){return SimpleBlockNode.isSimpleBlockNode(this)}static isSimpleBlockNode(e){return!!e&&(e instanceof SimpleBlockNode&&e.type===k.SimpleBlock)}}function consumeSimpleBlock(e,t,o,s){if(s>=N)throw new Error("Maximum nesting depth of 512 exceeded, reduce the complexity of your stylesheet.");const i=d(t[o][0]);if(!i)throw new Error("Failed to parse, a mirror variant must exist for all block open tokens.");const r=[];let a=o+1;for(;;){const c=t[a];if(!c||n(c))return e.onParseError(new h("Unexpected EOF while consuming a simple block.",t[o][2],t[t.length-1][3],["5.4.8. Consume a simple block","Unexpected EOF"])),{advance:t.length-o,node:new SimpleBlockNode(t[o],c,r)};if(c[0]===i)return{advance:a-o+1,node:new SimpleBlockNode(t[o],c,r)};if(m(c)){const n=consumeAllCommentsAndWhitespace(e,t,a);a+=n.advance;for(let e=0;e{})},i=[...e];if(0===i.length)return;n(i[i.length-1])||i.push([t.EOF,"",i[i.length-1][2],i[i.length-1][3],void 0]);const r=consumeComponentValue(s,i);if(n(i[Math.min(r.advance,i.length-1)]))return r.node;s.onParseError(new h("Expected EOF after parsing a component value.",e[0][2],e[e.length-1][3],["5.3.9. Parse a component value","Expected EOF"]))}function parseListOfComponentValues(e,o){const s={onParseError:o?.onParseError??(()=>{})},i=[...e];if(0===i.length)return[];n(i[i.length-1])||i.push([t.EOF,"",i[i.length-1][2],i[i.length-1][3],void 0]);const r=[];let a=0;for(;;){if(!i[a]||n(i[a]))return r;const e=consumeComponentValue(s,i,a,0);r.push(e.node),a+=e.advance}}function parseCommaSeparatedListOfComponentValues(e,o){const s={onParseError:o?.onParseError??(()=>{})},i=[...e];if(0===e.length)return[];n(i[i.length-1])||i.push([t.EOF,"",i[i.length-1][2],i[i.length-1][3],void 0]);const r=[];let a=[],c=0;for(;;){if(!i[c]||n(i[c]))return a.length&&r.push(a),r;if(f(i[c])){r.push(a),a=[],c++;continue}const e=consumeComponentValue(s,i,c,0);a.push(e.node),c+=e.advance}}function gatherNodeAncestry(e){const n=new Map;return e.walk(e=>{Array.isArray(e.node)?e.node.forEach(t=>{n.set(t,e.parent)}):n.set(e.node,e.parent)}),n}function forEach(e,n,t){if(0===e.length)return;const o=walkerIndexGenerator(e);let s=0;for(;s!1!==n(t,o)&&((!("walk"in t.node)||!("number"==typeof o&&e[o]===t.node||e.includes(t.node))||!1!==t.node.walk(n,t.state))&&void 0),t)}function replaceComponentValues(e,n){for(let t=0;t{if("number"!=typeof t)return;const o=n(e.node);o&&(Array.isArray(o)?e.parent.value.splice(t,1,...o):e.parent.value.splice(t,1,o))})}return e}function stringify(n){return n.map(n=>n.map(n=>e(n.tokens())).join("")).join(",")}function isSimpleBlockNode(e){return SimpleBlockNode.isSimpleBlockNode(e)}function isFunctionNode(e){return FunctionNode.isFunctionNode(e)}function isWhitespaceNode(e){return WhitespaceNode.isWhitespaceNode(e)}function isCommentNode(e){return CommentNode.isCommentNode(e)}function isWhiteSpaceOrCommentNode(e){return isWhitespaceNode(e)||isCommentNode(e)}function isTokenNode(e){return TokenNode.isTokenNode(e)}function sourceIndices(e){if(Array.isArray(e)){const n=e[0];if(!n)return[0,0];const t=e[e.length-1]||n;return[sourceIndices(n)[0],sourceIndices(t)[1]]}const n=e.tokens(),t=n[0],o=n[n.length-1];return t&&o?[t[2],o[3]]:[0,0]}export{CommentNode,k as ComponentValueType,ContainerNodeBaseClass,FunctionNode,SimpleBlockNode,TokenNode,WhitespaceNode,forEach,gatherNodeAncestry,isCommentNode,isFunctionNode,isSimpleBlockNode,isTokenNode,isWhiteSpaceOrCommentNode,isWhitespaceNode,parseCommaSeparatedListOfComponentValues,parseComponentValue,parseListOfComponentValues,replaceComponentValues,sourceIndices,stringify,walk,walkerIndexGenerator}; +import{stringify as e,isTokenEOF as n,TokenType as t,isToken as o,mirrorVariant as s,isTokenOpenParen as i,isTokenOpenCurly as r,isTokenOpenSquare as a,isTokenFunction as c,isTokenWhitespace as u,isTokenComment as l,mirrorVariantType as d,ParseError as h,isTokenWhiteSpaceOrComment as m,isTokenCloseParen as p,isTokenComma as f}from"@csstools/css-tokenizer";var k;function walkerIndexGenerator(e){let n,t=e.slice(),o=-1;return(e,s,i)=>{if(e===n&&e.length===o&&e[i]===s){o=e.length;const n=i+1;return n=e.length)?(n=e,o=e.length,-1):(t=e.slice(),n=e,o=e.length,r)}}!function(e){e.Function="function",e.SimpleBlock="simple-block",e.Whitespace="whitespace",e.Comment="comment",e.Token="token"}(k||(k={}));const N=512;function consumeComponentValue(e,n,t=0,o=0){const s=n[t];if(i(s)||r(s)||a(s)){const s=consumeSimpleBlock(e,n,t,o);return{advance:s.advance,node:s.node}}if(c(s)){const s=consumeFunction(e,n,t,o);return{advance:s.advance,node:s.node}}if(u(s)){const o=consumeWhitespace(e,n,t);return{advance:o.advance,node:o.node}}if(l(s)){const o=consumeComment(e,n,t);return{advance:o.advance,node:o.node}}return{advance:1,node:new TokenNode(s)}}class ContainerNodeBaseClass{value=[];indexOf(e){return this.value.indexOf(e)}at(e){if("number"==typeof e)return e<0&&(e=this.value.length+e),this.value[e]}forEach(e,n){if(0===this.value.length)return;const t=walkerIndexGenerator(this.value);let o=0;for(;o!1!==e(n,t)&&((!("walk"in n.node)||!("number"==typeof t&&this.value[t]===n.node||this.value.includes(n.node))||!1!==n.node.walk(e,n.state))&&void 0),n)}}class FunctionNode extends ContainerNodeBaseClass{type=k.Function;name;endToken;constructor(e,n,t){super(),this.name=e,this.endToken=n,this.value=t}getName(){return this.name[4].value}normalize(){n(this.endToken)&&(this.endToken=[t.CloseParen,")",-1,-1,void 0])}tokens(){return n(this.endToken)?[this.name,...this.value.flatMap(e=>e.tokens())]:[this.name,...this.value.flatMap(e=>e.tokens()),this.endToken]}toString(){const n=this.value.map(n=>o(n)?e(n):n.toString()).join("");return e(this.name)+n+e(this.endToken)}toJSON(){return{type:this.type,name:this.getName(),tokens:this.tokens(),value:this.value.map(e=>e.toJSON())}}isFunctionNode(){return FunctionNode.isFunctionNode(this)}static isFunctionNode(e){return!!e&&(e instanceof FunctionNode&&e.type===k.Function)}}function consumeFunction(e,t,o,s){if(s>=N)throw new Error("Maximum nesting depth of 512 exceeded, reduce the complexity of your stylesheet.");const i=[];let r=o+1;for(;;){const a=t[r];if(!a||n(a))return e.onParseError(new h("Unexpected EOF while consuming a function.",t[o][2],t[t.length-1][3],["5.4.9. Consume a function","Unexpected EOF"])),{advance:t.length-o,node:new FunctionNode(t[o],a,i)};if(p(a))return{advance:r-o+1,node:new FunctionNode(t[o],a,i)};if(m(a)){const n=consumeAllCommentsAndWhitespace(e,t,r);r+=n.advance;for(let e=0;ee.tokens())]:[this.startToken,...this.value.flatMap(e=>e.tokens()),this.endToken]}toString(){const n=this.value.map(n=>o(n)?e(n):n.toString()).join("");return e(this.startToken)+n+e(this.endToken)}toJSON(){return{type:this.type,startToken:this.startToken,tokens:this.tokens(),value:this.value.map(e=>e.toJSON())}}isSimpleBlockNode(){return SimpleBlockNode.isSimpleBlockNode(this)}static isSimpleBlockNode(e){return!!e&&(e instanceof SimpleBlockNode&&e.type===k.SimpleBlock)}}function consumeSimpleBlock(e,t,o,s){if(s>=N)throw new Error("Maximum nesting depth of 512 exceeded, reduce the complexity of your stylesheet.");const i=d(t[o][0]);if(!i)throw new Error("Failed to parse, a mirror variant must exist for all block open tokens.");const r=[];let a=o+1;for(;;){const c=t[a];if(!c||n(c))return e.onParseError(new h("Unexpected EOF while consuming a simple block.",t[o][2],t[t.length-1][3],["5.4.8. Consume a simple block","Unexpected EOF"])),{advance:t.length-o,node:new SimpleBlockNode(t[o],c,r)};if(c[0]===i)return{advance:a-o+1,node:new SimpleBlockNode(t[o],c,r)};if(m(c)){const n=consumeAllCommentsAndWhitespace(e,t,a);a+=n.advance;for(let e=0;e{})},i=[...e];if(0===i.length)return;n(i[i.length-1])||i.push([t.EOF,"",i[i.length-1][2],i[i.length-1][3],void 0]);const r=consumeComponentValue(s,i);if(n(i[Math.min(r.advance,i.length-1)]))return r.node;s.onParseError(new h("Expected EOF after parsing a component value.",e[0][2],e[e.length-1][3],["5.3.9. Parse a component value","Expected EOF"]))}function parseListOfComponentValues(e,o){const s={onParseError:o?.onParseError??(()=>{})},i=[...e];if(0===i.length)return[];n(i[i.length-1])||i.push([t.EOF,"",i[i.length-1][2],i[i.length-1][3],void 0]);const r=[];let a=0;for(;;){if(!i[a]||n(i[a]))return r;const e=consumeComponentValue(s,i,a,0);r.push(e.node),a+=e.advance}}function parseCommaSeparatedListOfComponentValues(e,o){const s={onParseError:o?.onParseError??(()=>{})},i=[...e];if(0===e.length)return[];n(i[i.length-1])||i.push([t.EOF,"",i[i.length-1][2],i[i.length-1][3],void 0]);const r=[];let a=[],c=0;for(;;){if(!i[c]||n(i[c]))return r.push(a),r;if(f(i[c])){r.push(a),a=[],c++;continue}const e=consumeComponentValue(s,i,c,0);a.push(e.node),c+=e.advance}}function gatherNodeAncestry(e){const n=new Map;return e.walk(e=>{Array.isArray(e.node)?e.node.forEach(t=>{n.set(t,e.parent)}):n.set(e.node,e.parent)}),n}function forEach(e,n,t){if(0===e.length)return;const o=walkerIndexGenerator(e);let s=0;for(;s!1!==n(t,o)&&((!("walk"in t.node)||!("number"==typeof o&&e[o]===t.node||e.includes(t.node))||!1!==t.node.walk(n,t.state))&&void 0),t)}function replaceComponentValues(e,n){for(let t=0;t{if("number"!=typeof t)return;const o=n(e.node);o&&(Array.isArray(o)?e.parent.value.splice(t,1,...o):e.parent.value.splice(t,1,o))})}return e}function stringify(n){return n.map(n=>n.map(n=>e(n.tokens())).join("")).join(",")}function isSimpleBlockNode(e){return SimpleBlockNode.isSimpleBlockNode(e)}function isFunctionNode(e){return FunctionNode.isFunctionNode(e)}function isWhitespaceNode(e){return WhitespaceNode.isWhitespaceNode(e)}function isCommentNode(e){return CommentNode.isCommentNode(e)}function isWhiteSpaceOrCommentNode(e){return isWhitespaceNode(e)||isCommentNode(e)}function isTokenNode(e){return TokenNode.isTokenNode(e)}function sourceIndices(e){if(Array.isArray(e)){const n=e[0];if(!n)return[0,0];const t=e[e.length-1]||n;return[sourceIndices(n)[0],sourceIndices(t)[1]]}const n=e.tokens(),t=n[0],o=n[n.length-1];return t&&o?[t[2],o[3]]:[0,0]}export{CommentNode,k as ComponentValueType,ContainerNodeBaseClass,FunctionNode,SimpleBlockNode,TokenNode,WhitespaceNode,forEach,gatherNodeAncestry,isCommentNode,isFunctionNode,isSimpleBlockNode,isTokenNode,isWhiteSpaceOrCommentNode,isWhitespaceNode,parseCommaSeparatedListOfComponentValues,parseComponentValue,parseListOfComponentValues,replaceComponentValues,sourceIndices,stringify,walk,walkerIndexGenerator}; diff --git a/packages/css-parser-algorithms/src/parse/comma-separated-list-of-component-values.ts b/packages/css-parser-algorithms/src/parse/comma-separated-list-of-component-values.ts index 35aeda0ffb..f6b5472c17 100644 --- a/packages/css-parser-algorithms/src/parse/comma-separated-list-of-component-values.ts +++ b/packages/css-parser-algorithms/src/parse/comma-separated-list-of-component-values.ts @@ -46,9 +46,7 @@ export function parseCommaSeparatedListOfComponentValues(tokens: Array while (true) { if (!tokensCopy[i] || isTokenEOF(tokensCopy[i])) { - if (list.length) { - listOfCvls.push(list); - } + listOfCvls.push(list); return listOfCvls; } diff --git a/packages/css-parser-algorithms/test/cases/various/0022.list-comma.expect.json b/packages/css-parser-algorithms/test/cases/various/0022.list-comma.expect.json new file mode 100644 index 0000000000..cb6ca31da9 --- /dev/null +++ b/packages/css-parser-algorithms/test/cases/various/0022.list-comma.expect.json @@ -0,0 +1,19 @@ +[ + [ + { + "type": "token", + "tokens": [ + [ + "ident-token", + "foo", + 0, + 2, + { + "value": "foo" + } + ] + ] + } + ], + [] +] \ No newline at end of file diff --git a/packages/css-parser-algorithms/test/cases/various/0022.list-space.expect.json b/packages/css-parser-algorithms/test/cases/various/0022.list-space.expect.json new file mode 100644 index 0000000000..0baa53b6f9 --- /dev/null +++ b/packages/css-parser-algorithms/test/cases/various/0022.list-space.expect.json @@ -0,0 +1,28 @@ +[ + { + "type": "token", + "tokens": [ + [ + "ident-token", + "foo", + 0, + 2, + { + "value": "foo" + } + ] + ] + }, + { + "type": "token", + "tokens": [ + [ + "comma-token", + ",", + 3, + 3, + null + ] + ] + } +] \ No newline at end of file diff --git a/packages/css-parser-algorithms/test/cases/various/0022.mjs b/packages/css-parser-algorithms/test/cases/various/0022.mjs new file mode 100644 index 0000000000..5506d34213 --- /dev/null +++ b/packages/css-parser-algorithms/test/cases/various/0022.mjs @@ -0,0 +1,13 @@ +import assert from 'node:assert'; +import { runTest } from '../../util/run-test.mjs'; + +runTest( + 'foo,', + 'various/0022', + (actual, expected) => { + assert.deepStrictEqual( + actual, + expected, + ); + }, +); diff --git a/packages/css-parser-algorithms/test/cases/various/0023.list-comma.expect.json b/packages/css-parser-algorithms/test/cases/various/0023.list-comma.expect.json new file mode 100644 index 0000000000..f0dffb2ca7 --- /dev/null +++ b/packages/css-parser-algorithms/test/cases/various/0023.list-comma.expect.json @@ -0,0 +1,35 @@ +[ + [ + { + "type": "token", + "tokens": [ + [ + "ident-token", + "foo", + 0, + 2, + { + "value": "foo" + } + ] + ] + } + ], + [], + [ + { + "type": "token", + "tokens": [ + [ + "ident-token", + "bar", + 5, + 7, + { + "value": "bar" + } + ] + ] + } + ] +] \ No newline at end of file diff --git a/packages/css-parser-algorithms/test/cases/various/0023.list-space.expect.json b/packages/css-parser-algorithms/test/cases/various/0023.list-space.expect.json new file mode 100644 index 0000000000..f5d7313aa0 --- /dev/null +++ b/packages/css-parser-algorithms/test/cases/various/0023.list-space.expect.json @@ -0,0 +1,54 @@ +[ + { + "type": "token", + "tokens": [ + [ + "ident-token", + "foo", + 0, + 2, + { + "value": "foo" + } + ] + ] + }, + { + "type": "token", + "tokens": [ + [ + "comma-token", + ",", + 3, + 3, + null + ] + ] + }, + { + "type": "token", + "tokens": [ + [ + "comma-token", + ",", + 4, + 4, + null + ] + ] + }, + { + "type": "token", + "tokens": [ + [ + "ident-token", + "bar", + 5, + 7, + { + "value": "bar" + } + ] + ] + } +] \ No newline at end of file diff --git a/packages/css-parser-algorithms/test/cases/various/0023.mjs b/packages/css-parser-algorithms/test/cases/various/0023.mjs new file mode 100644 index 0000000000..1c9bf3eebd --- /dev/null +++ b/packages/css-parser-algorithms/test/cases/various/0023.mjs @@ -0,0 +1,13 @@ +import assert from 'node:assert'; +import { runTest } from '../../util/run-test.mjs'; + +runTest( + 'foo,,bar', + 'various/0023', + (actual, expected) => { + assert.deepStrictEqual( + actual, + expected, + ); + }, +); diff --git a/packages/css-parser-algorithms/test/test.mjs b/packages/css-parser-algorithms/test/test.mjs index 9427b91d01..e51a03a0ef 100644 --- a/packages/css-parser-algorithms/test/test.mjs +++ b/packages/css-parser-algorithms/test/test.mjs @@ -44,6 +44,8 @@ import './cases/various/0018.mjs'; import './cases/various/0019.mjs'; import './cases/various/0020.mjs'; import './cases/various/0021.mjs'; +import './cases/various/0022.mjs'; +import './cases/various/0023.mjs'; import './cases/walker/0001.mjs'; import './cases/walker/0002.mjs'; diff --git a/packages/media-query-list-parser/test/cases/specification-examples/0004.expect.json b/packages/media-query-list-parser/test/cases/specification-examples/0004.expect.json index 0637a088a0..b6040d4d0d 100644 --- a/packages/media-query-list-parser/test/cases/specification-examples/0004.expect.json +++ b/packages/media-query-list-parser/test/cases/specification-examples/0004.expect.json @@ -1 +1,8 @@ -[] \ No newline at end of file +[ + { + "type": "media-query-with-type", + "string": "", + "modifier": [], + "mediaType": [] + } +] \ No newline at end of file diff --git a/plugins/postcss-mixins/CHANGELOG.md b/plugins/postcss-mixins/CHANGELOG.md index a972992dda..7b2fb3e94a 100644 --- a/plugins/postcss-mixins/CHANGELOG.md +++ b/plugins/postcss-mixins/CHANGELOG.md @@ -1,5 +1,12 @@ # Changes to PostCSS Mixins +### Unreleased (minor) + +- `@contents` rule +- nested mixins +- untyped mixin arguments +- hygienic renaming of mixin arguments and private custom properties + ### 1.0.1 _September 25, 2026_ diff --git a/plugins/postcss-mixins/README.md b/plugins/postcss-mixins/README.md index c4b81f603f..c27fe04ec0 100644 --- a/plugins/postcss-mixins/README.md +++ b/plugins/postcss-mixins/README.md @@ -12,9 +12,7 @@ Several specification aspects of CSS Mixins still need to be settled. This plugin is only a partial implementation to avoid conflicts with the final specification. Unsupported: -- mixin arguments -- `@contents` blocks -- `@result` blocks +- typed mixin arguments - layered `@mixin` declarations - mixin overrides @@ -27,11 +25,43 @@ Unsupported: @apply --foo; } +@mixin --bar(--baz) { + color: rgb(var(--baz) 127 0); +} + +.bar { + @apply --bar(200); +} + +@mixin --a { + font-weight: 500; +} + +@mixin --b { + @apply --a; + font-size: 2rem; +} + +.c { + @apply --b; +} + /* becomes */ .foo { color: green; } + +.bar { + --_csstools-p-1agle9gi-0--arg-0: 200; + --_csstools-p-1agle9gi-1--baz: var(--_csstools-p-1agle9gi-0--arg-0); + color: rgb(var(--_csstools-p-1agle9gi-1--baz) 127 0); +} + +.c { + font-weight: 500; + font-size: 2rem; +} ``` ## Usage @@ -84,6 +114,27 @@ postcssMixins({ preserve: true }) @apply --foo; } +@mixin --bar(--baz) { + color: rgb(var(--baz) 127 0); +} + +.bar { + @apply --bar(200); +} + +@mixin --a { + font-weight: 500; +} + +@mixin --b { + @apply --a; + font-size: 2rem; +} + +.c { + @apply --b; +} + /* becomes */ @mixin --foo() { @@ -94,6 +145,32 @@ postcssMixins({ preserve: true }) color: green; @apply --foo; } + +@mixin --bar(--baz) { + color: rgb(var(--baz) 127 0); +} + +.bar { + --_csstools-p-1agle9gi-0--arg-0: 200; + --_csstools-p-1agle9gi-1--baz: var(--_csstools-p-1agle9gi-0--arg-0); + color: rgb(var(--_csstools-p-1agle9gi-1--baz) 127 0); + @apply --bar(200); +} + +@mixin --a { + font-weight: 500; +} + +@mixin --b { + @apply --a; + font-size: 2rem; +} + +.c { + font-weight: 500; + font-size: 2rem; + @apply --b; +} ``` [cli-url]: https://github.com/csstools/postcss-plugins/actions/workflows/test.yml?query=workflow/test diff --git a/plugins/postcss-mixins/dist/index.mjs b/plugins/postcss-mixins/dist/index.mjs index 37805817af..04305df7c9 100644 --- a/plugins/postcss-mixins/dist/index.mjs +++ b/plugins/postcss-mixins/dist/index.mjs @@ -1 +1 @@ -import{parseComponentValue as e,isTokenNode as s,isFunctionNode as t}from"@csstools/css-parser-algorithms";import{tokenize as r,isTokenIdent as n}from"@csstools/css-tokenizer";const o=/^apply$/i;function processableApplyRule(o){if(!o.params||!o.params.includes("--"))return!1;if(!isInStyleRule(o))return!1;if(o.nodes?.length)return!1;const a=e(r({css:o.params}));return s(a)&&n(a.value)?a.value[4].value:!!t(a)&&(!a.value.length&&a.getName())}const a=/^scope$/i;function isInStyleRule(e){const s=e.parent;return!(!s||"root"===s.type)&&("rule"===s.type||(!("atrule"!==s.type||!a.test(s.name))||isInStyleRule(s)))}const l=/^(?:apply|contents|result)$/i;function processableMixinRule(s){if("mixin"!==s.name.toLowerCase())return!1;if(!s.params||!s.params.includes("--"))return!1;if(!s.nodes?.length)return!1;if(s.parent!==s.root())return!1;const n=e(r({css:s.params}));if(!t(n))return!1;if(n.value.length)return!1;let o=!1;return s.walk(e=>{"atrule"===e.type&&l.test(e.name)&&(o=!0)}),!o&&n.getName()}const creator=e=>{const s=Object.assign({preserve:!1},e);return{postcssPlugin:"postcss-mixins",prepare(){const e=new Map,t=new Set;return{postcssPlugin:"mixins",Once(r){r.each(s=>{if("atrule"!==s.type)return;const r=processableMixinRule(s);r&&(t.has(r)?e.delete(r):(e.set(r,s),t.add(r)))});for(const t of e.values())s.preserve||t.remove();r.walkAtRules(o,t=>{const r=processableApplyRule(t);if(!r)return;const n=e.get(r);n&&n.nodes&&(n.each(e=>{t.before(e.clone())}),s.preserve||t.remove())})}}}}};creator.postcss=!0;export{creator as default,creator as"module.exports"}; +import{Rule as e,AtRule as t,Declaration as n}from"postcss";import{Transpiler as s}from"@csstools/postcss-private-rule";import{parseComponentValue as r,isTokenNode as a,isFunctionNode as o,parseCommaSeparatedListOfComponentValues as i,stringify as l,isSimpleBlockNode as u,isWhiteSpaceOrCommentNode as c}from"@csstools/css-parser-algorithms";import{tokenize as p,isTokenIdent as f,isTokenOpenCurly as m,isTokenColon as d}from"@csstools/css-tokenizer";const h=/^apply$/i,g=/!\s*important/i;function processableApplyRule(e){if(!e.params||!e.params.includes("--"))return!1;if(!isInStyleRule(e))return!1;const t=r(p({css:e.params}));let n,s=[];if(a(t)&&f(t.value))n=t.value[4].value;else{if(!o(t))return!1;{n=t.getName();const e=parseArguments(t.value);if(!e)return!1;s=e}}return!!n.startsWith("--")&&{name:n,arguments:s}}function parseArguments(e){const t=i(e.flatMap(e=>e.tokens()));if(1===t.length&&""===l([t[0]]).trim())return[];const n=[];for(const e of t){const t=(1===e.length&&u(e[0])&&m(e[0].startToken)?l([e[0].value]):l([e])).trim();if(""===t)return!1;if(g.test(t))return!1;n.push(t)}return n}const v=/^scope$/i;function isInStyleRule(e){const t=e.parent;return!(!t||"root"===t.type)&&("rule"===t.type||(!("atrule"!==t.type||!v.test(t.name))||isInStyleRule(t)))}const x=/^result$/i,R=/!\s*important/i;function processableMixinRule(e){if("mixin"!==e.name.toLowerCase())return!1;if(!e.params||!e.params.includes("--"))return!1;if(void 0===e.nodes)return!1;if(e.parent!==e.root())return!1;let t=!1;if(e.walk(e=>{"atrule"===e.type&&x.test(e.name)&&(t=!0)}),t)return!1;const n=r(p({css:e.params}));let s,i=[];if(a(n)&&f(n.value))s=n.value[4].value;else{if(!o(n))return!1;{s=n.getName();const e=parseParameters(n.value);if(!e)return!1;i=e}}return!!s.startsWith("--")&&{name:s,parameters:i}}function parseParameters(e){const t=i(e.flatMap(e=>e.tokens()));if(1===t.length&&""===l([t[0]]).trim())return[];const n=[],s=new Set;for(const e of t){let t=0;for(;t{hasMixinAncestor(e)||n.push(e)});for(const e of n)e.parent&&this.expand(e,t)}finish(){for(const e of this.desugaredNestingRules)e.replaceWith(...e.nodes||[]);this.desugaredNestingRules.clear()}expand(e,t){const n=processableApplyRule(e);if(!n)return;const s=this.mixins.get(n.name);if(!s)return;if(this.expanding.has(n.name))return;if(n.arguments.length>s.parameters.length)return;this.expanding.add(n.name);const r=this.buildBody(e,n.arguments,s);for(const t of r)e.before(t);t||e.remove(),needsWrapper(s)&&this.transpile(r),replaceContents(r,void 0!==e.nodes,e.nodes||[]),this.expandNested(r),this.expanding.delete(n.name)}buildBody(n,s,r){const a=(r.atRule.nodes||[]).map(e=>e.clone());if(!needsWrapper(r))return a;const o=new e({selector:"&",source:n.source});let i,l;o.raws.semicolon=!0,this.desugaredNestingRules.add(o),r.parameters.length>0&&s.length>0&&(i=new e({selector:"&",source:n.source}),i.raws.semicolon=!0,this.desugaredNestingRules.add(i),l=new t({name:"private",source:n.source}),i.append(l),i.append(o)),r.parameters.length>0&&o.append(this.buildParameterPrivateRule(n,s,r.parameters,l));for(const e of a)o.append(e);return o.cleanRaws(),l?.nodes?.length?(i?.cleanRaws(),[i]):[o]}buildParameterPrivateRule(e,s,r,a){const o=new t({name:"private",source:e.source});for(let t=0;t0||hasPrivateRules(e.atRule)}function replaceContents(e,t,n){const s=[];for(const t of e)collectMatches(t,w,s);for(const e of s){const s=t?n:e.nodes||[];for(const t of s)e.before(t.clone());e.remove()}}function hasPrivateRules(e){let t=!1;return e.walkAtRules(y,()=>{t=!0}),t}function findOwningRule(e){let t=e.parent;for(;t;){if("rule"===t.type)return t;if("atrule"!==t.type||!M.test(t.name))return!1;t=t.parent}return!1}function collectMatches(e,t,n){"atrule"===e.type&&t.test(e.name)&&n.push(e),"rule"!==e.type&&"atrule"!==e.type||e.walkAtRules(t,e=>{n.push(e)})}function collectDeclarationsAndAtRules(e,t,n){"decl"===e.type?t.push(e):"atrule"===e.type&&n.push(e),"rule"!==e.type&&"atrule"!==e.type||e.walk(e=>{"decl"===e.type?t.push(e):"atrule"===e.type&&n.push(e)})}function hasMixinAncestor(e){let t=e.parent;for(;t;){if("atrule"===t.type&&"mixin"===t.name.toLowerCase())return!0;t=t.parent}return!1}const creator=e=>{const t=Object.assign({preserve:!1},e);return{postcssPlugin:"postcss-mixins",prepare(){const e=new MixinExpander;return{postcssPlugin:"mixins",Once(n){const s=[];if(n.each(e=>{"atrule"===e.type&&s.push(e)}),e.registerMixins(s),!t.preserve)for(const t of e.registeredMixins())t.atRule.remove();e.expandAll(n,!0===t.preserve),e.finish()}}}}};creator.postcss=!0;export{creator as default,creator as"module.exports"}; diff --git a/plugins/postcss-mixins/docs/README.md b/plugins/postcss-mixins/docs/README.md index a023891505..e29a67ed77 100644 --- a/plugins/postcss-mixins/docs/README.md +++ b/plugins/postcss-mixins/docs/README.md @@ -23,9 +23,7 @@ Several specification aspects of CSS Mixins still need to be settled. This plugin is only a partial implementation to avoid conflicts with the final specification. Unsupported: -- mixin arguments -- `@contents` blocks -- `@result` blocks +- typed mixin arguments - layered `@mixin` declarations - mixin overrides diff --git a/plugins/postcss-mixins/package.json b/plugins/postcss-mixins/package.json index 4adebfe1ce..d0d66925e3 100644 --- a/plugins/postcss-mixins/package.json +++ b/plugins/postcss-mixins/package.json @@ -42,13 +42,15 @@ ], "dependencies": { "@csstools/css-parser-algorithms": "^4.0.1", - "@csstools/css-tokenizer": "^4.0.2" + "@csstools/css-tokenizer": "^4.0.2", + "@csstools/postcss-private-rule": "^1.0.3" }, "peerDependencies": { "postcss": "^8.4" }, "devDependencies": { - "@csstools/postcss-tape": "*" + "@csstools/postcss-tape": "*", + "puppeteer": "^25.11.0" }, "scripts": { "build": "rollup -c ../../rollup/default.mjs", diff --git a/plugins/postcss-mixins/src/index.ts b/plugins/postcss-mixins/src/index.ts index f3623a6bf9..33f3f2b9bd 100644 --- a/plugins/postcss-mixins/src/index.ts +++ b/plugins/postcss-mixins/src/index.ts @@ -1,6 +1,5 @@ import type { AtRule, Plugin, PluginCreator } from 'postcss'; -import { IS_APPLY_REGEX, processableApplyRule } from './processable-apply'; -import { processableMixinRule } from './processable-mixin'; +import { MixinExpander } from './mixin-expander'; /** postcss-mixins plugin options */ export type pluginOptions = { @@ -21,53 +20,28 @@ const creator: PluginCreator = (opts?: pluginOptions) => { return { postcssPlugin: 'postcss-mixins', prepare(): Plugin { - const mixins: Map = new Map(); - const knownMixins: Set = new Set(); + const expander = new MixinExpander(); return { postcssPlugin: 'mixins', Once(root): void { + const rootAtRules: Array = []; root.each((child) => { - if (child.type !== 'atrule') { - return; + if (child.type === 'atrule') { + rootAtRules.push(child); } - - const mixinName = processableMixinRule(child); - if (!mixinName) { - return; - } - - // TODO: support mixin overrides - if (knownMixins.has(mixinName)) { - mixins.delete(mixinName); - return; - } - - mixins.set(mixinName, child); - knownMixins.add(mixinName); }); - for (const child of mixins.values()) { - if (!options.preserve) child.remove(); - } + expander.registerMixins(rootAtRules); - root.walkAtRules(IS_APPLY_REGEX, (atRule) => { - const mixinName = processableApplyRule(atRule); - if (!mixinName) { - return; + if (!options.preserve) { + for (const mixin of expander.registeredMixins()) { + mixin.atRule.remove(); } + } - const mixin = mixins.get(mixinName); - if (!mixin || !mixin.nodes) { - return; - } - - mixin.each((mixinNode) => { - atRule.before(mixinNode.clone()); - }); - - if (!options.preserve) atRule.remove(); - }); + expander.expandAll(root, options.preserve === true); + expander.finish(); }, }; }, diff --git a/plugins/postcss-mixins/src/mixin-expander.ts b/plugins/postcss-mixins/src/mixin-expander.ts new file mode 100644 index 0000000000..717aa9f775 --- /dev/null +++ b/plugins/postcss-mixins/src/mixin-expander.ts @@ -0,0 +1,351 @@ +import { AtRule, Declaration, Rule } from 'postcss'; +import type { ChildNode, Node, Root } from 'postcss'; +import { Transpiler } from '@csstools/postcss-private-rule'; +import { IS_APPLY_REGEX, processableApplyRule } from './processable-apply'; +import type { MixinParameter } from './processable-mixin'; +import { processableMixinRule } from './processable-mixin'; + +type Mixin = { + name: string, + parameters: Array, + atRule: AtRule, +}; + +const IS_CONTENTS_REGEX = /^contents$/i; +const IS_PRIVATE_REGEX = /^private$/i; +const IS_NESTING_GROUP_RULE_REGEX = /^(container|layer|media|scope|starting-style|supports)$/i; + +/** + * Deepens mixin expansion behind a small interface: + * register the mixins once, expand the top level `@apply` rules, then finish. + * + * Everything else — argument capture, wrapper construction, private scoping, + * `@contents` substitution, nested recursion and the cycle guard — is internal. + */ +export class MixinExpander { + private mixins: Map = new Map(); + private knownMixins: Set = new Set(); + private transpiler: Transpiler = new Transpiler(); + private argumentCounter = 0; + private desugaredNestingRules: Set = new Set(); + private expanding: Set = new Set(); + + /** The mixin at rules that were registered, so a caller can remove or preserve them. */ + registeredMixins(): Array { + return Array.from(this.mixins.values()); + } + + registerMixins(rootAtRules: Array): void { + for (const atRule of rootAtRules) { + const parsed = processableMixinRule(atRule); + if (!parsed) { + continue; + } + + // TODO: support mixin overrides + if (this.knownMixins.has(parsed.name)) { + this.mixins.delete(parsed.name); + continue; + } + + this.mixins.set(parsed.name, Object.assign({}, parsed, { atRule })); + this.knownMixins.add(parsed.name); + } + } + + /** Expands all top level `@apply` rules in `root`, except those authored inside a `@mixin`. */ + expandAll(root: Root, preserve: boolean): void { + const applies: Array = []; + root.walkAtRules(IS_APPLY_REGEX, (atRule) => { + if (hasMixinAncestor(atRule)) { + return; + } + + applies.push(atRule); + }); + + for (const atRule of applies) { + if (!atRule.parent) { + continue; + } + + this.expand(atRule, preserve); + } + } + + /** Removes the temporary `& {}` nesting rules created while desugaring. */ + finish(): void { + for (const wrapper of this.desugaredNestingRules) { + wrapper.replaceWith(...wrapper.nodes || []); + } + + this.desugaredNestingRules.clear(); + } + + private expand(atRule: AtRule, preserve: boolean): void { + const apply = processableApplyRule(atRule); + if (!apply) { + return; + } + + const mixin = this.mixins.get(apply.name); + if (!mixin) { + return; + } + + // Guard against mixins that (indirectly) apply themselves. + if (this.expanding.has(apply.name)) { + return; + } + + // Passing more arguments than the mixin accepts is invalid. + if (apply.arguments.length > mixin.parameters.length) { + return; + } + + this.expanding.add(apply.name); + + const inserted = this.buildBody(atRule, apply.arguments, mixin); + + for (const node of inserted) { + atRule.before(node); + } + + if (!preserve) { + atRule.remove(); + } + + if (needsWrapper(mixin)) { + this.transpile(inserted); + } + + // `@contents` is substituted after insertion so the replaced at-rule has a parent. + // It is substituted after transpilation so passed-in contents are not scoped to the mixin. + replaceContents(inserted, atRule.nodes !== undefined, atRule.nodes || []); + + this.expandNested(inserted); + + this.expanding.delete(apply.name); + } + + /** Captures arguments, scopes parameters and `@private` rules, and returns the nodes to insert. */ + private buildBody(atRule: AtRule, args: Array, mixin: Mixin): Array { + const cloned: Array = (mixin.atRule.nodes || []).map((node) => node.clone()); + + if (!needsWrapper(mixin)) { + return cloned; + } + + // Arguments are resolved at the call site and captured in the caller's frame. + // Each supplied argument is bound to a fresh custom property so it can not be + // shadowed by parameters of nested mixins that reuse the same names. + // See: https://github.com/w3c/csswg-drafts/issues/14372 + const bodyWrapper = new Rule({ selector: '&', source: atRule.source }); + bodyWrapper.raws.semicolon = true; + this.desugaredNestingRules.add(bodyWrapper); + + let outerWrapper: Rule | undefined; + let argumentPrivateRule: AtRule | undefined; + + if (mixin.parameters.length > 0 && args.length > 0) { + outerWrapper = new Rule({ selector: '&', source: atRule.source }); + outerWrapper.raws.semicolon = true; + this.desugaredNestingRules.add(outerWrapper); + + argumentPrivateRule = new AtRule({ name: 'private', source: atRule.source }); + outerWrapper.append(argumentPrivateRule); + outerWrapper.append(bodyWrapper); + } + + if (mixin.parameters.length > 0) { + bodyWrapper.append(this.buildParameterPrivateRule(atRule, args, mixin.parameters, argumentPrivateRule)); + } + + for (const node of cloned) { + bodyWrapper.append(node); + } + + bodyWrapper.cleanRaws(); + + if (!argumentPrivateRule?.nodes?.length) { + return [bodyWrapper]; + } + + outerWrapper?.cleanRaws(); + + return [outerWrapper as Rule]; + } + + /** Binds each parameter to its captured argument value, or to its default in the mixin's frame. */ + private buildParameterPrivateRule(atRule: AtRule, args: Array, parameters: Array, argumentPrivateRule?: AtRule): AtRule { + const parameterPrivateRule = new AtRule({ name: 'private', source: atRule.source }); + + for (let i = 0; i < parameters.length; i++) { + const parameter = parameters[i]; + + let value; + + if (i < args.length) { + const argumentName = `--arg-${(this.argumentCounter++).toString(36)}`; + + argumentPrivateRule?.append(new Declaration({ + prop: argumentName, + value: args[i], + source: atRule.source, + })); + + // The parameter resolves to the captured argument value. + value = `var(${argumentName})`; + } else { + // Missing arguments resolve to their default in the mixin's own frame. + value = parameter.defaultValue || 'initial'; + } + + parameterPrivateRule.append(new Declaration({ + prop: parameter.name, + value, + source: atRule.source, + })); + } + + return parameterPrivateRule; + } + + /** Only `@private` rules that originate from mixins are transpiled here. */ + private transpile(inserted: Array): void { + const privateRules: Array = []; + for (const node of inserted) { + collectMatches(node, IS_PRIVATE_REGEX, privateRules); + } + + for (const privateRule of privateRules) { + const owner = findOwningRule(privateRule); + if (!owner) { + continue; + } + + this.transpiler.registerAndRemovePrivateRules(privateRule, owner); + } + + const declarations: Array = []; + const atRules: Array = []; + for (const node of inserted) { + collectDeclarationsAndAtRules(node, declarations, atRules); + } + + for (const declaration of declarations) { + this.transpiler.transpileDeclaration(declaration); + } + + for (const nestedAtRule of atRules) { + this.transpiler.transpileAtRule(nestedAtRule); + } + } + + /** Mixins applied within this mixin are resolved in the context of the arguments above. */ + private expandNested(inserted: Array): void { + const nestedApplies: Array = []; + for (const node of inserted) { + collectMatches(node, IS_APPLY_REGEX, nestedApplies); + } + + for (const nestedAtRule of nestedApplies) { + if (!nestedAtRule.parent) { + continue; + } + + this.expand(nestedAtRule, false); + } + } +} + +function needsWrapper(mixin: Mixin): boolean { + return mixin.parameters.length > 0 || hasPrivateRules(mixin.atRule); +} + +function replaceContents(nodes: Array, hasContents: boolean, contents: Array): void { + const contentsRules: Array = []; + for (const node of nodes) { + collectMatches(node, IS_CONTENTS_REGEX, contentsRules); + } + + for (const contentsRule of contentsRules) { + const replacement = hasContents ? contents : (contentsRule.nodes || []); + + for (const child of replacement) { + contentsRule.before(child.clone()); + } + + contentsRule.remove(); + } +} + +function hasPrivateRules(atRule: AtRule): boolean { + let result = false; + atRule.walkAtRules(IS_PRIVATE_REGEX, () => { + result = true; + }); + + return result; +} + +function findOwningRule(node: ChildNode): Rule | false { + let parent: Node | undefined = node.parent; + while (parent) { + if (parent.type === 'rule') { + return parent as Rule; + } + + if (parent.type === 'atrule' && IS_NESTING_GROUP_RULE_REGEX.test((parent as AtRule).name)) { + parent = parent.parent; + continue; + } + + return false; + } + + return false; +} + +function collectMatches(node: ChildNode, name: RegExp, out: Array): void { + if (node.type === 'atrule' && name.test(node.name)) { + out.push(node); + } + + if (node.type === 'rule' || node.type === 'atrule') { + node.walkAtRules(name, (atRule) => { + out.push(atRule); + }); + } +} + +function collectDeclarationsAndAtRules(node: ChildNode, declarations: Array, atRules: Array): void { + if (node.type === 'decl') { + declarations.push(node); + } else if (node.type === 'atrule') { + atRules.push(node); + } + + if (node.type === 'rule' || node.type === 'atrule') { + node.walk((child) => { + if (child.type === 'decl') { + declarations.push(child); + } else if (child.type === 'atrule') { + atRules.push(child); + } + }); + } +} + +function hasMixinAncestor(atRule: AtRule): boolean { + let parent: AtRule['parent'] = atRule.parent; + while (parent) { + if (parent.type === 'atrule' && parent.name.toLowerCase() === 'mixin') { + return true; + } + + parent = parent.parent as AtRule['parent']; + } + + return false; +} diff --git a/plugins/postcss-mixins/src/processable-apply.ts b/plugins/postcss-mixins/src/processable-apply.ts index b6f8a269e3..46bb0027b7 100644 --- a/plugins/postcss-mixins/src/processable-apply.ts +++ b/plugins/postcss-mixins/src/processable-apply.ts @@ -1,10 +1,18 @@ -import { isFunctionNode, isTokenNode, parseComponentValue } from "@csstools/css-parser-algorithms"; -import { isTokenIdent, tokenize } from "@csstools/css-tokenizer"; -import type { AtRule } from "postcss"; +import type { ComponentValue } from '@csstools/css-parser-algorithms'; +import { isFunctionNode, isSimpleBlockNode, isTokenNode, parseCommaSeparatedListOfComponentValues, parseComponentValue, stringify } from '@csstools/css-parser-algorithms'; +import { isTokenIdent, isTokenOpenCurly, tokenize } from '@csstools/css-tokenizer'; +import type { AtRule } from 'postcss'; export const IS_APPLY_REGEX = /^apply$/i; -export function processableApplyRule(atRule: AtRule): false|string { +export type ProcessableApply = { + name: string, + arguments: Array, +}; + +const IS_IMPORTANT_REGEX = /!\s*important/i; + +export function processableApplyRule(atRule: AtRule): false | ProcessableApply { if (!atRule.params || !atRule.params.includes('--')) { return false; } @@ -13,28 +21,67 @@ export function processableApplyRule(atRule: AtRule): false|string { return false; } - // TODO: support @contents - if (atRule.nodes?.length) { - return false; - } - - const nameNode = parseComponentValue(tokenize({ + const prelude = parseComponentValue(tokenize({ css: atRule.params, })); - if (isTokenNode(nameNode) && isTokenIdent(nameNode.value)) { - return nameNode.value[4].value; - } - if (!isFunctionNode(nameNode)) { + let name: string; + let args: Array = []; + + if (isTokenNode(prelude) && isTokenIdent(prelude.value)) { + name = prelude.value[4].value; + } else if (isFunctionNode(prelude)) { + name = prelude.getName(); + + const parsedArguments = parseArguments(prelude.value); + if (!parsedArguments) { + return false; + } + + args = parsedArguments; + } else { return false; } - // TODO: support arguments - if (nameNode.value.length) { + if (!name.startsWith('--')) { return false; } - return nameNode.getName(); + return { + name, + arguments: args, + }; +} + +function parseArguments(componentValues: Array): false | Array { + const rawArguments = parseCommaSeparatedListOfComponentValues(componentValues.flatMap((componentValue) => componentValue.tokens())); + + // `--foo()` has no arguments. + if (rawArguments.length === 1 && stringify([rawArguments[0]]).trim() === '') { + return []; + } + + const args: Array = []; + + for (const rawArgument of rawArguments) { + // A comma-containing value can be passed as a single argument by wrapping it in `{}`. + const argument = (rawArgument.length === 1 && isSimpleBlockNode(rawArgument[0]) && isTokenOpenCurly(rawArgument[0].startToken) + ? stringify([rawArgument[0].value]) + : stringify([rawArgument]) + ).trim(); + + if (argument === '') { + return false; + } + + if (IS_IMPORTANT_REGEX.test(argument)) { + return false; + } + + args.push(argument); + } + + return args; } const IS_SCOPE_REGEX = /^scope$/i; diff --git a/plugins/postcss-mixins/src/processable-mixin.ts b/plugins/postcss-mixins/src/processable-mixin.ts index b70572f4ec..3d0f8271ef 100644 --- a/plugins/postcss-mixins/src/processable-mixin.ts +++ b/plugins/postcss-mixins/src/processable-mixin.ts @@ -1,10 +1,22 @@ -import { isFunctionNode, parseComponentValue } from "@csstools/css-parser-algorithms"; -import { tokenize } from "@csstools/css-tokenizer"; -import type { AtRule } from "postcss"; +import type { ComponentValue } from '@csstools/css-parser-algorithms'; +import { isFunctionNode, isTokenNode, isWhiteSpaceOrCommentNode, parseCommaSeparatedListOfComponentValues, parseComponentValue, stringify } from '@csstools/css-parser-algorithms'; +import { isTokenColon, isTokenIdent, tokenize } from '@csstools/css-tokenizer'; +import type { AtRule } from 'postcss'; -const IS_IGNORED_CHILD_RULE = /^(?:apply|contents|result)$/i; +export type MixinParameter = { + name: string, + defaultValue: string | undefined, +}; -export function processableMixinRule(atRule: AtRule): false | string { +export type ProcessableMixin = { + name: string, + parameters: Array, +}; + +const IS_UNSUPPORTED_CHILD_RULE = /^result$/i; +const IS_IMPORTANT_REGEX = /!\s*important/i; + +export function processableMixinRule(atRule: AtRule): false | ProcessableMixin { if (atRule.name.toLowerCase() !== 'mixin') { return false; } @@ -13,7 +25,7 @@ export function processableMixinRule(atRule: AtRule): false | string { return false; } - if (!atRule.nodes?.length) { + if (atRule.nodes === undefined) { return false; } @@ -22,29 +34,112 @@ export function processableMixinRule(atRule: AtRule): false | string { return false; } - const nameNode = parseComponentValue(tokenize({ + // `@result` was dropped from the specification, mixins with `@result` are ignored. + let hasUnsupportedChildRule = false; + atRule.walk((node) => { + if (node.type === 'atrule' && IS_UNSUPPORTED_CHILD_RULE.test(node.name)) { + hasUnsupportedChildRule = true; + } + }); + + if (hasUnsupportedChildRule) { + return false; + } + + const prelude = parseComponentValue(tokenize({ css: atRule.params, })); - if (!isFunctionNode(nameNode)) { + + let name: string; + let parameters: Array = []; + + if (isTokenNode(prelude) && isTokenIdent(prelude.value)) { + // `@mixin --foo;` is equivalent to `@mixin --foo();` + name = prelude.value[4].value; + } else if (isFunctionNode(prelude)) { + name = prelude.getName(); + + const parsedParameters = parseParameters(prelude.value); + if (!parsedParameters) { + return false; + } + + parameters = parsedParameters; + } else { return false; } - if (nameNode.value.length) { + if (!name.startsWith('--')) { return false; } - // TODO: support @content - // TODO: support nested @apply - let hasNestedApplyOrContents = false; - atRule.walk((x) => { - if (x.type === 'atrule' && IS_IGNORED_CHILD_RULE.test(x.name)) { - hasNestedApplyOrContents = true; + return { + name, + parameters, + }; +} + +function parseParameters(componentValues: Array): false | Array { + const rawParameters = parseCommaSeparatedListOfComponentValues(componentValues.flatMap((componentValue) => componentValue.tokens())); + + // `--foo()` has no parameters. + if (rawParameters.length === 1 && stringify([rawParameters[0]]).trim() === '') { + return []; + } + + const parameters: Array = []; + const names = new Set(); + + for (const rawParameter of rawParameters) { + let start = 0; + while (start < rawParameter.length && isWhiteSpaceOrCommentNode(rawParameter[start])) { + start++; } - }); - if (hasNestedApplyOrContents) { - return false; + const nameNode = rawParameter[start]; + if (!nameNode || !isTokenNode(nameNode) || !isTokenIdent(nameNode.value) || !nameNode.value[4].value.startsWith('--')) { + return false; + } + + const name = nameNode.value[4].value; + if (names.has(name)) { + return false; + } + + names.add(name); + + let defaultValue: string | undefined; + let hasType = false; + + for (let i = start + 1; i < rawParameter.length; i++) { + const componentValue = rawParameter[i]; + + if (isWhiteSpaceOrCommentNode(componentValue)) { + continue; + } + + if (isTokenNode(componentValue) && isTokenColon(componentValue.value)) { + defaultValue = stringify([rawParameter.slice(i + 1)]).trim() || undefined; + break; + } + + hasType = true; + } + + // TODO: support typed arguments + if (hasType) { + return false; + } + + if (defaultValue && IS_IMPORTANT_REGEX.test(defaultValue)) { + return false; + } + + parameters.push({ + name, + defaultValue, + }); } - return nameNode.getName(); + return parameters; } diff --git a/plugins/postcss-mixins/test/_browser.mjs b/plugins/postcss-mixins/test/_browser.mjs new file mode 100644 index 0000000000..652c500805 --- /dev/null +++ b/plugins/postcss-mixins/test/_browser.mjs @@ -0,0 +1,137 @@ +import puppeteer from 'puppeteer'; +import http from 'node:http'; +import fs from 'node:fs/promises'; +import plugin from '@csstools/postcss-mixins'; +import postcss from 'postcss'; +import test from 'node:test'; +import process from 'node:process'; + +// Chrome Canary implements `@mixin` / `@apply` behind the experimental +// web platform features flag. +// +// - The transformed cases run the source through this plugin. +// - The untransformed cases are handed to the browser as-is and only pass +// when the browser itself implements the feature. +// +// Point Puppeteer at such a browser via `PUPPETEER_EXECUTABLE_PATH`. + +const PAGES = [ + 'wpt/mixin-basic.html', + 'wpt/mixin-arguments.html', + 'wpt/mixin-contents.html', + 'wpt/mixin-nested.html', + 'wpt/mixin-private.html', +]; + +const requestListener = async function (req, res) { + + const parsedUrl = new URL(req.url, 'http://localhost:8080'); + const pathname = parsedUrl.pathname; + + if (PAGES.includes(pathname.replace(/^\//, ''))) { + res.setHeader('Content-type', 'text/html'); + res.writeHead(200); + res.end(await fs.readFile('test' + pathname, 'utf8')); + return; + } + + if (pathname === '' || pathname === '/') { + res.setHeader('Content-type', 'text/html'); + res.writeHead(200); + + res.end(` + + + Mixins Test + + +

Mixins Test

+
    + ${PAGES.map((page) => `
  • ${page}
  • `).join('\n')} +
+ + + `); + return; + } + + if (pathname === '/test/styles.css' && req.method === 'POST') { + const data = await new Promise((resolve, reject) => { + let buf = []; + req.on('data', (chunk) => { + buf.push(chunk); + }); + + req.on('end', () => { + resolve(Buffer.concat(buf).toString()); + }); + + req.on('error', (err) => { + reject(err); + }); + }); + + const css = parsedUrl.searchParams.get('transform') === 'false' + ? data + : (await postcss([plugin()]).process(data, { from: 'test/styles.css', to: 'test/styles.css' })).css; + + res.setHeader('Content-type', 'text/css'); + res.writeHead(200); + res.end(css); + return; + } + + res.setHeader('Content-type', 'text/plain'); + res.writeHead(404); + res.end('Not found'); +}; + +function startServers() { + const server = http.createServer(requestListener); + server.listen(8080); + + return () => { + server.close(); + }; +} + +if (!process.env.DEBUG) { + test('browser', { skip: process.env.GITHUB_ACTIONS && !process.env.BROWSER_TESTS }, async () => { + const cleanup = startServers(); + + let browser; + + try { + browser = await puppeteer.launch({ + headless: 'new', + executablePath: process.env.PUPPETEER_EXECUTABLE_PATH, + args: ['--enable-experimental-web-platform-features'], + }); + + const page = await browser.newPage(); + page.on('pageerror', (msg) => { + throw msg; + }); + + for (const url of PAGES) { + await page.goto('http://localhost:8080/' + url); + const result = await page.evaluate(async () => { + // eslint-disable-next-line no-undef + return await window.runTest(); + }); + if (!result) { + throw new Error('Test failed, expected "window.runTest()" to return true'); + } + } + } finally { + await browser?.close(); + + await cleanup(); + } + }); +} else { + startServers(); + + // eslint-disable-next-line no-console + console.log('visit : http://localhost:8080'); +} diff --git a/plugins/postcss-mixins/test/_tape.mjs b/plugins/postcss-mixins/test/_tape.mjs index 832ea5c674..63bcfd9bd6 100644 --- a/plugins/postcss-mixins/test/_tape.mjs +++ b/plugins/postcss-mixins/test/_tape.mjs @@ -11,6 +11,69 @@ postcssTape(plugin)({ preserve: true, }, }, + arguments: { + message: 'supports mixin arguments', + }, + 'arguments:preserve-true': { + message: 'supports mixin arguments with { preserve: true }', + options: { + preserve: true, + }, + }, + spaces: { + message: 'supports spaces and comments around argument lists', + }, + 'spaces:preserve-true': { + message: 'supports spaces and comments around argument lists with { preserve: true }', + options: { + preserve: true, + }, + }, + contents: { + message: 'supports @contents', + }, + 'contents:preserve-true': { + message: 'supports @contents with { preserve: true }', + options: { + preserve: true, + }, + }, + 'nested-mixins': { + message: 'supports mixins applied within mixins', + }, + 'nested-mixins:preserve-true': { + message: 'supports mixins applied within mixins with { preserve: true }', + options: { + preserve: true, + }, + }, + nesting: { + message: 'preserves author written nesting rules', + }, + 'nesting:preserve-true': { + message: 'preserves author written nesting rules with { preserve: true }', + options: { + preserve: true, + }, + }, + private: { + message: 'scopes mixin parameters and @private rules', + }, + 'private:preserve-true': { + message: 'scopes mixin parameters and @private rules with { preserve: true }', + options: { + preserve: true, + }, + }, + invalid: { + message: 'ignores invalid mixin invocations', + }, + 'invalid:preserve-true': { + message: 'ignores invalid mixin invocations with { preserve: true }', + options: { + preserve: true, + }, + }, ignore: { message: 'ignores invalid or unsupported behavior', expect: 'ignore.css', diff --git a/plugins/postcss-mixins/test/arguments.css b/plugins/postcss-mixins/test/arguments.css new file mode 100644 index 0000000000..8a03e21298 --- /dev/null +++ b/plugins/postcss-mixins/test/arguments.css @@ -0,0 +1,48 @@ +@mixin --basic(--color) { + color: var(--color); +} + +.a { + @apply --basic(green); +} + +@mixin --defaults(--color: blue, --background: red) { + color: var(--color); + background-color: var(--background); +} + +.b { + @apply --defaults; +} + +.c { + @apply --defaults(red); +} + +@mixin --fallback(--color) { + outline-color: var(--color, navy); +} + +.d { + @apply --fallback; +} + +.e { + @apply --fallback(green); +} + +@mixin --braces(--list) { + transition-property: var(--list); +} + +.f { + @apply --braces({color, background}); +} + +@mixin --no-parameter-list { + border: 1px solid green; +} + +.g { + @apply --no-parameter-list; +} diff --git a/plugins/postcss-mixins/test/arguments.expect.css b/plugins/postcss-mixins/test/arguments.expect.css new file mode 100644 index 0000000000..977007afad --- /dev/null +++ b/plugins/postcss-mixins/test/arguments.expect.css @@ -0,0 +1,41 @@ +.a { + --_csstools-p-9bomswmf-0--arg-0: green; + --_csstools-p-9bomswmf-1--color: var(--_csstools-p-9bomswmf-0--arg-0); + color: var(--_csstools-p-9bomswmf-1--color); +} + +.b { + --_csstools-p-9bomswmf-2--color: blue; + --_csstools-p-9bomswmf-2--background: red; + color: var(--_csstools-p-9bomswmf-2--color); + background-color: var(--_csstools-p-9bomswmf-2--background); +} + +.c { + --_csstools-p-9bomswmf-3--arg-1: red; + --_csstools-p-9bomswmf-4--color: var(--_csstools-p-9bomswmf-3--arg-1); + --_csstools-p-9bomswmf-4--background: red; + color: var(--_csstools-p-9bomswmf-4--color); + background-color: var(--_csstools-p-9bomswmf-4--background); +} + +.d { + --_csstools-p-9bomswmf-5--color: initial; + outline-color: var(--_csstools-p-9bomswmf-5--color, navy); +} + +.e { + --_csstools-p-9bomswmf-6--arg-2: green; + --_csstools-p-9bomswmf-7--color: var(--_csstools-p-9bomswmf-6--arg-2); + outline-color: var(--_csstools-p-9bomswmf-7--color, navy); +} + +.f { + --_csstools-p-9bomswmf-8--arg-3: color, background; + --_csstools-p-9bomswmf-9--list: var(--_csstools-p-9bomswmf-8--arg-3); + transition-property: var(--_csstools-p-9bomswmf-9--list); +} + +.g { + border: 1px solid green; +} diff --git a/plugins/postcss-mixins/test/arguments.preserve-true.expect.css b/plugins/postcss-mixins/test/arguments.preserve-true.expect.css new file mode 100644 index 0000000000..ded2a75a5a --- /dev/null +++ b/plugins/postcss-mixins/test/arguments.preserve-true.expect.css @@ -0,0 +1,69 @@ +@mixin --basic(--color) { + color: var(--color); +} + +.a { + --_csstools-p-9bomswmf-0--arg-0: green; + --_csstools-p-9bomswmf-1--color: var(--_csstools-p-9bomswmf-0--arg-0); + color: var(--_csstools-p-9bomswmf-1--color); + @apply --basic(green); +} + +@mixin --defaults(--color: blue, --background: red) { + color: var(--color); + background-color: var(--background); +} + +.b { + --_csstools-p-9bomswmf-2--color: blue; + --_csstools-p-9bomswmf-2--background: red; + color: var(--_csstools-p-9bomswmf-2--color); + background-color: var(--_csstools-p-9bomswmf-2--background); + @apply --defaults; +} + +.c { + --_csstools-p-9bomswmf-3--arg-1: red; + --_csstools-p-9bomswmf-4--color: var(--_csstools-p-9bomswmf-3--arg-1); + --_csstools-p-9bomswmf-4--background: red; + color: var(--_csstools-p-9bomswmf-4--color); + background-color: var(--_csstools-p-9bomswmf-4--background); + @apply --defaults(red); +} + +@mixin --fallback(--color) { + outline-color: var(--color, navy); +} + +.d { + --_csstools-p-9bomswmf-5--color: initial; + outline-color: var(--_csstools-p-9bomswmf-5--color, navy); + @apply --fallback; +} + +.e { + --_csstools-p-9bomswmf-6--arg-2: green; + --_csstools-p-9bomswmf-7--color: var(--_csstools-p-9bomswmf-6--arg-2); + outline-color: var(--_csstools-p-9bomswmf-7--color, navy); + @apply --fallback(green); +} + +@mixin --braces(--list) { + transition-property: var(--list); +} + +.f { + --_csstools-p-9bomswmf-8--arg-3: color, background; + --_csstools-p-9bomswmf-9--list: var(--_csstools-p-9bomswmf-8--arg-3); + transition-property: var(--_csstools-p-9bomswmf-9--list); + @apply --braces({color, background}); +} + +@mixin --no-parameter-list { + border: 1px solid green; +} + +.g { + border: 1px solid green; + @apply --no-parameter-list; +} diff --git a/plugins/postcss-mixins/test/contents.css b/plugins/postcss-mixins/test/contents.css new file mode 100644 index 0000000000..d6ab4193db --- /dev/null +++ b/plugins/postcss-mixins/test/contents.css @@ -0,0 +1,71 @@ +@mixin --with-contents() { + color: green; + @contents; +} + +.a { + @apply --with-contents { + background-color: blue; + } +} + +.b { + @apply --with-contents; +} + +@mixin --with-fallback() { + @contents { + color: red; + } +} + +.c { + @apply --with-fallback; +} + +.d { + @apply --with-fallback { + color: blue; + } +} + +.e { + @apply --with-fallback {} +} + +@mixin --nested-contents() { + &:hover { + @contents; + } +} + +.f { + @apply --nested-contents { + color: green; + } +} + +.g { + @apply --nested-contents; +} + +@mixin --ignores-contents() { + color: green; +} + +.h { + @apply --ignores-contents { + background-color: blue; + } +} + +@mixin --args-and-contents(--color) { + color: var(--color); + @contents; +} + +.i { + @apply --args-and-contents(green) { + background-color: var(--color); + } +} diff --git a/plugins/postcss-mixins/test/contents.expect.css b/plugins/postcss-mixins/test/contents.expect.css new file mode 100644 index 0000000000..4853b84008 --- /dev/null +++ b/plugins/postcss-mixins/test/contents.expect.css @@ -0,0 +1,41 @@ +.a { + color: green; + background-color: blue +} + +.b { + color: green; +} + +.c { + color: red; +} + +.d { + color: blue +} + +.e { +} + +.f { + &:hover { + color: green; + } +} + +.g { + &:hover { + } +} + +.h { + color: green +} + +.i { + --_csstools-p-8akl2q02-0--arg-0: green; + --_csstools-p-8akl2q02-1--color: var(--_csstools-p-8akl2q02-0--arg-0); + color: var(--_csstools-p-8akl2q02-1--color); + background-color: var(--color) +} diff --git a/plugins/postcss-mixins/test/contents.preserve-true.expect.css b/plugins/postcss-mixins/test/contents.preserve-true.expect.css new file mode 100644 index 0000000000..25ff22ec29 --- /dev/null +++ b/plugins/postcss-mixins/test/contents.preserve-true.expect.css @@ -0,0 +1,86 @@ +@mixin --with-contents() { + color: green; + @contents; +} + +.a { + color: green; + background-color: blue; + @apply --with-contents { + background-color: blue; + } +} + +.b { + color: green; + @apply --with-contents; +} + +@mixin --with-fallback() { + @contents { + color: red; + } +} + +.c { + color: red; + @apply --with-fallback; +} + +.d { + color: blue; + @apply --with-fallback { + color: blue; + } +} + +.e { + @apply --with-fallback {} +} + +@mixin --nested-contents() { + &:hover { + @contents; + } +} + +.f { + &:hover { + color: green; + } + @apply --nested-contents { + color: green; + } +} + +.g { + &:hover { + } + @apply --nested-contents; +} + +@mixin --ignores-contents() { + color: green; +} + +.h { + color: green; + @apply --ignores-contents { + background-color: blue; + } +} + +@mixin --args-and-contents(--color) { + color: var(--color); + @contents; +} + +.i { + --_csstools-p-8akl2q02-0--arg-0: green; + --_csstools-p-8akl2q02-1--color: var(--_csstools-p-8akl2q02-0--arg-0); + color: var(--_csstools-p-8akl2q02-1--color); + background-color: var(--color); + @apply --args-and-contents(green) { + background-color: var(--color); + } +} diff --git a/plugins/postcss-mixins/test/examples/example.css b/plugins/postcss-mixins/test/examples/example.css index 619c5b1ee8..378e397376 100644 --- a/plugins/postcss-mixins/test/examples/example.css +++ b/plugins/postcss-mixins/test/examples/example.css @@ -5,3 +5,24 @@ .foo { @apply --foo; } + +@mixin --bar(--baz) { + color: rgb(var(--baz) 127 0); +} + +.bar { + @apply --bar(200); +} + +@mixin --a { + font-weight: 500; +} + +@mixin --b { + @apply --a; + font-size: 2rem; +} + +.c { + @apply --b; +} diff --git a/plugins/postcss-mixins/test/examples/example.expect.css b/plugins/postcss-mixins/test/examples/example.expect.css index 79d060850d..384580e725 100644 --- a/plugins/postcss-mixins/test/examples/example.expect.css +++ b/plugins/postcss-mixins/test/examples/example.expect.css @@ -1,3 +1,14 @@ .foo { color: green; } + +.bar { + --_csstools-p-1agle9gi-0--arg-0: 200; + --_csstools-p-1agle9gi-1--baz: var(--_csstools-p-1agle9gi-0--arg-0); + color: rgb(var(--_csstools-p-1agle9gi-1--baz) 127 0); +} + +.c { + font-weight: 500; + font-size: 2rem; +} diff --git a/plugins/postcss-mixins/test/examples/example.preserve-true.expect.css b/plugins/postcss-mixins/test/examples/example.preserve-true.expect.css index 230ea16b68..fdbd6e8475 100644 --- a/plugins/postcss-mixins/test/examples/example.preserve-true.expect.css +++ b/plugins/postcss-mixins/test/examples/example.preserve-true.expect.css @@ -6,3 +6,29 @@ color: green; @apply --foo; } + +@mixin --bar(--baz) { + color: rgb(var(--baz) 127 0); +} + +.bar { + --_csstools-p-1agle9gi-0--arg-0: 200; + --_csstools-p-1agle9gi-1--baz: var(--_csstools-p-1agle9gi-0--arg-0); + color: rgb(var(--_csstools-p-1agle9gi-1--baz) 127 0); + @apply --bar(200); +} + +@mixin --a { + font-weight: 500; +} + +@mixin --b { + @apply --a; + font-size: 2rem; +} + +.c { + font-weight: 500; + font-size: 2rem; + @apply --b; +} diff --git a/plugins/postcss-mixins/test/ignore.css b/plugins/postcss-mixins/test/ignore.css index 9791ec18c9..5f98dd28f4 100644 --- a/plugins/postcss-mixins/test/ignore.css +++ b/plugins/postcss-mixins/test/ignore.css @@ -11,69 +11,61 @@ } @mixin --ignore-b { - color: green; + @result { + color: green; + } } .ignore-b { @apply --ignore-b; } -@mixin --ignore-c { - @apply --foo; +@media screen { + @mixin --ignore-c { + color: red; + } } .ignore-c { @apply --ignore-c; } -@mixin --ignore-d { - @contents; +@mixin --ignore-f(--color ) { + color: var(--color); } -.ignore-d { - @apply --ignore-d; +.ignore-f { + @apply --ignore-f(green); } -@mixin --ignore-e { - color: red; +@mixin --ignore-g(--color, --color) { + color: var(--color); } -.ignore-e { - @apply --ignore-e { - color: red; - } +.ignore-g { + @apply --ignore-g(green, blue); } -@media screen { - @mixin --ignore-f { - color: red; - } +@mixin --ignore-h(--color: red !important) { + color: var(--color); } -.ignore-f { - @apply --ignore-f; -} - -@mixin --ignore-g { - color: red; +.ignore-h { + @apply --ignore-h; } -@apply --ignore-g; - -@mixin --ignore-h { - color: red; +@mixin --ignore-i(junk-in-parameter-list) { + color: blue; } -@media screen { - @apply --ignore-h; +.ignore-i { + @apply --ignore-i; } -@mixin --ignore-i() { - @result { - color: green; - } +@mixin --ignore-j(--color,) { + color: var(--color); } -.ignore-i { - @apply --ignore-i; +.ignore-j { + @apply --ignore-j(red); } diff --git a/plugins/postcss-mixins/test/invalid.css b/plugins/postcss-mixins/test/invalid.css new file mode 100644 index 0000000000..fd1327cf63 --- /dev/null +++ b/plugins/postcss-mixins/test/invalid.css @@ -0,0 +1,33 @@ +@mixin --one(--color) { + color: var(--color); +} + +.too-many-arguments { + @apply --one(green, blue); +} + +.important-argument { + @apply --one(red !important); +} + +.trailing-empty-argument { + @apply --one(red,); +} + +.leading-empty-argument { + @apply --one(,red); +} + +.middle-empty-argument { + @apply --one(red,,blue); +} + +@mixin --defined() { + color: green; +} + +@apply --defined; + +@media screen { + @apply --defined; +} diff --git a/plugins/postcss-mixins/test/invalid.expect.css b/plugins/postcss-mixins/test/invalid.expect.css new file mode 100644 index 0000000000..d5d99732c0 --- /dev/null +++ b/plugins/postcss-mixins/test/invalid.expect.css @@ -0,0 +1,25 @@ +.too-many-arguments { + @apply --one(green, blue); +} + +.important-argument { + @apply --one(red !important); +} + +.trailing-empty-argument { + @apply --one(red,); +} + +.leading-empty-argument { + @apply --one(,red); +} + +.middle-empty-argument { + @apply --one(red,,blue); +} + +@apply --defined; + +@media screen { + @apply --defined; +} diff --git a/plugins/postcss-mixins/test/invalid.preserve-true.expect.css b/plugins/postcss-mixins/test/invalid.preserve-true.expect.css new file mode 100644 index 0000000000..fd1327cf63 --- /dev/null +++ b/plugins/postcss-mixins/test/invalid.preserve-true.expect.css @@ -0,0 +1,33 @@ +@mixin --one(--color) { + color: var(--color); +} + +.too-many-arguments { + @apply --one(green, blue); +} + +.important-argument { + @apply --one(red !important); +} + +.trailing-empty-argument { + @apply --one(red,); +} + +.leading-empty-argument { + @apply --one(,red); +} + +.middle-empty-argument { + @apply --one(red,,blue); +} + +@mixin --defined() { + color: green; +} + +@apply --defined; + +@media screen { + @apply --defined; +} diff --git a/plugins/postcss-mixins/test/nested-mixins.css b/plugins/postcss-mixins/test/nested-mixins.css new file mode 100644 index 0000000000..e407432238 --- /dev/null +++ b/plugins/postcss-mixins/test/nested-mixins.css @@ -0,0 +1,49 @@ +@mixin --inner() { + color: green; +} + +@mixin --outer() { + @apply --inner; + background-color: blue; +} + +.a { + @apply --outer; +} + +@mixin --inner-args(--inner-color) { + border-color: var(--inner-color); +} + +@mixin --outer-args(--outer-color) { + @apply --inner-args(var(--outer-color)); +} + +.b { + @apply --outer-args(red); +} + +@mixin --shadow-inner(--color) { + border-color: var(--color); +} + +@mixin --shadow-outer(--color) { + @apply --shadow-inner(blue); + color: var(--color); +} + +.c { + @apply --shadow-outer(red); +} + +@mixin --cycle-a() { + @apply --cycle-b; +} + +@mixin --cycle-b() { + @apply --cycle-a; +} + +.d { + @apply --cycle-a; +} diff --git a/plugins/postcss-mixins/test/nested-mixins.expect.css b/plugins/postcss-mixins/test/nested-mixins.expect.css new file mode 100644 index 0000000000..3dc2a75cca --- /dev/null +++ b/plugins/postcss-mixins/test/nested-mixins.expect.css @@ -0,0 +1,25 @@ +.a { + color: green; + background-color: blue; +} + +.b { + --_csstools-p-2ljfeuxq-0--arg-0: red; + --_csstools-p-2ljfeuxq-1--outer-color: var(--_csstools-p-2ljfeuxq-0--arg-0); + --_csstools-p-2ljfeuxq-2--arg-1: var(--_csstools-p-2ljfeuxq-1--outer-color); + --_csstools-p-2ljfeuxq-3--inner-color: var(--_csstools-p-2ljfeuxq-2--arg-1); + border-color: var(--_csstools-p-2ljfeuxq-3--inner-color); +} + +.c { + --_csstools-p-2ljfeuxq-4--arg-2: red; + --_csstools-p-2ljfeuxq-5--color: var(--_csstools-p-2ljfeuxq-4--arg-2); + --_csstools-p-2ljfeuxq-6--arg-3: blue; + --_csstools-p-2ljfeuxq-7--color: var(--_csstools-p-2ljfeuxq-6--arg-3); + border-color: var(--_csstools-p-2ljfeuxq-7--color); + color: var(--_csstools-p-2ljfeuxq-5--color); +} + +.d { + @apply --cycle-a; +} diff --git a/plugins/postcss-mixins/test/nested-mixins.preserve-true.expect.css b/plugins/postcss-mixins/test/nested-mixins.preserve-true.expect.css new file mode 100644 index 0000000000..6d8621a989 --- /dev/null +++ b/plugins/postcss-mixins/test/nested-mixins.preserve-true.expect.css @@ -0,0 +1,63 @@ +@mixin --inner() { + color: green; +} + +@mixin --outer() { + @apply --inner; + background-color: blue; +} + +.a { + color: green; + background-color: blue; + @apply --outer; +} + +@mixin --inner-args(--inner-color) { + border-color: var(--inner-color); +} + +@mixin --outer-args(--outer-color) { + @apply --inner-args(var(--outer-color)); +} + +.b { + --_csstools-p-2ljfeuxq-0--arg-0: red; + --_csstools-p-2ljfeuxq-1--outer-color: var(--_csstools-p-2ljfeuxq-0--arg-0); + --_csstools-p-2ljfeuxq-2--arg-1: var(--_csstools-p-2ljfeuxq-1--outer-color); + --_csstools-p-2ljfeuxq-3--inner-color: var(--_csstools-p-2ljfeuxq-2--arg-1); + border-color: var(--_csstools-p-2ljfeuxq-3--inner-color); + @apply --outer-args(red); +} + +@mixin --shadow-inner(--color) { + border-color: var(--color); +} + +@mixin --shadow-outer(--color) { + @apply --shadow-inner(blue); + color: var(--color); +} + +.c { + --_csstools-p-2ljfeuxq-4--arg-2: red; + --_csstools-p-2ljfeuxq-5--color: var(--_csstools-p-2ljfeuxq-4--arg-2); + --_csstools-p-2ljfeuxq-6--arg-3: blue; + --_csstools-p-2ljfeuxq-7--color: var(--_csstools-p-2ljfeuxq-6--arg-3); + border-color: var(--_csstools-p-2ljfeuxq-7--color); + color: var(--_csstools-p-2ljfeuxq-5--color); + @apply --shadow-outer(red); +} + +@mixin --cycle-a() { + @apply --cycle-b; +} + +@mixin --cycle-b() { + @apply --cycle-a; +} + +.d { + @apply --cycle-a; + @apply --cycle-a; +} diff --git a/plugins/postcss-mixins/test/nesting.css b/plugins/postcss-mixins/test/nesting.css new file mode 100644 index 0000000000..7bbf88e01c --- /dev/null +++ b/plugins/postcss-mixins/test/nesting.css @@ -0,0 +1,52 @@ +.outside { + & { + color: green; + } +} + +@mixin --author-nesting() { + & { + color: green; + } + + background-color: blue; +} + +.a { + @apply --author-nesting; +} + +@mixin --author-nesting-with-args(--color) { + & { + color: var(--color); + } +} + +.b { + @apply --author-nesting-with-args(red); +} + +@mixin --author-nested-nesting() { + & { + & { + color: green; + } + } +} + +.c { + @apply --author-nested-nesting; +} + +@mixin --author-nesting-mixed() { + & { + color: green; + } +} + +.d { + @apply --author-nesting-mixed; + & { + color: blue; + } +} diff --git a/plugins/postcss-mixins/test/nesting.expect.css b/plugins/postcss-mixins/test/nesting.expect.css new file mode 100644 index 0000000000..7a6a640e8f --- /dev/null +++ b/plugins/postcss-mixins/test/nesting.expect.css @@ -0,0 +1,38 @@ +.outside { + & { + color: green; + } +} + +.a { + & { + color: green; + } + + background-color: blue; +} + +.b { + --_csstools-p-7fww6zpl-0--arg-0: red; + --_csstools-p-7fww6zpl-1--color: var(--_csstools-p-7fww6zpl-0--arg-0); + & { + color: var(--_csstools-p-7fww6zpl-1--color); + } +} + +.c { + & { + & { + color: green; + } + } +} + +.d { + & { + color: green; + } + & { + color: blue; + } +} diff --git a/plugins/postcss-mixins/test/nesting.preserve-true.expect.css b/plugins/postcss-mixins/test/nesting.preserve-true.expect.css new file mode 100644 index 0000000000..dd5a21adfc --- /dev/null +++ b/plugins/postcss-mixins/test/nesting.preserve-true.expect.css @@ -0,0 +1,70 @@ +.outside { + & { + color: green; + } +} + +@mixin --author-nesting() { + & { + color: green; + } + + background-color: blue; +} + +.a { + & { + color: green; + } + + background-color: blue; + @apply --author-nesting; +} + +@mixin --author-nesting-with-args(--color) { + & { + color: var(--color); + } +} + +.b { + --_csstools-p-7fww6zpl-0--arg-0: red; + --_csstools-p-7fww6zpl-1--color: var(--_csstools-p-7fww6zpl-0--arg-0); + & { + color: var(--_csstools-p-7fww6zpl-1--color); + } + @apply --author-nesting-with-args(red); +} + +@mixin --author-nested-nesting() { + & { + & { + color: green; + } + } +} + +.c { + & { + & { + color: green; + } + } + @apply --author-nested-nesting; +} + +@mixin --author-nesting-mixed() { + & { + color: green; + } +} + +.d { + & { + color: green; + } + @apply --author-nesting-mixed; + & { + color: blue; + } +} diff --git a/plugins/postcss-mixins/test/private.css b/plugins/postcss-mixins/test/private.css new file mode 100644 index 0000000000..e3d299342c --- /dev/null +++ b/plugins/postcss-mixins/test/private.css @@ -0,0 +1,23 @@ +@mixin --with-private() { + @private { + --shadow: 2px 2px black; + } + + box-shadow: var(--shadow); +} + +.a { + @apply --with-private; +} + +@mixin --args-and-private(--color) { + @private { + --local: var(--color); + } + + color: var(--local); +} + +.b { + @apply --args-and-private(red); +} diff --git a/plugins/postcss-mixins/test/private.expect.css b/plugins/postcss-mixins/test/private.expect.css new file mode 100644 index 0000000000..1440380434 --- /dev/null +++ b/plugins/postcss-mixins/test/private.expect.css @@ -0,0 +1,11 @@ +.a { + --_csstools-p-45fs09jl-0--shadow: 2px 2px black; + box-shadow: var(--_csstools-p-45fs09jl-0--shadow); +} + +.b { + --_csstools-p-45fs09jl-1--arg-0: red; + --_csstools-p-45fs09jl-2--color: var(--_csstools-p-45fs09jl-1--arg-0); + --_csstools-p-45fs09jl-2--local: var(--_csstools-p-45fs09jl-2--color); + color: var(--_csstools-p-45fs09jl-2--local); +} diff --git a/plugins/postcss-mixins/test/private.preserve-true.expect.css b/plugins/postcss-mixins/test/private.preserve-true.expect.css new file mode 100644 index 0000000000..1aa56fb656 --- /dev/null +++ b/plugins/postcss-mixins/test/private.preserve-true.expect.css @@ -0,0 +1,29 @@ +@mixin --with-private() { + @private { + --shadow: 2px 2px black; + } + + box-shadow: var(--shadow); +} + +.a { + --_csstools-p-45fs09jl-0--shadow: 2px 2px black; + box-shadow: var(--_csstools-p-45fs09jl-0--shadow); + @apply --with-private; +} + +@mixin --args-and-private(--color) { + @private { + --local: var(--color); + } + + color: var(--local); +} + +.b { + --_csstools-p-45fs09jl-1--arg-0: red; + --_csstools-p-45fs09jl-2--color: var(--_csstools-p-45fs09jl-1--arg-0); + --_csstools-p-45fs09jl-2--local: var(--_csstools-p-45fs09jl-2--color); + color: var(--_csstools-p-45fs09jl-2--local); + @apply --args-and-private(red); +} diff --git a/plugins/postcss-mixins/test/spaces.css b/plugins/postcss-mixins/test/spaces.css new file mode 100644 index 0000000000..b5a811b8cf --- /dev/null +++ b/plugins/postcss-mixins/test/spaces.css @@ -0,0 +1,92 @@ +@mixin --spaces(--color, --background) { + color: var(--color); + background-color: var(--background); +} + +.space-after-open { + @apply --spaces( green, blue); +} + +.space-before-close { + @apply --spaces(green, blue ); +} + +.space-around-both { + @apply --spaces( green, blue ); +} + +.tabs-and-newlines { + @apply --spaces( + green, + blue + ); +} + +.space-around-comma { + @apply --spaces(green , blue); +} + +@mixin --comment-in-parameters(--color /* color */, --background /* background */) { + color: var(--color); + background-color: var(--background); +} + +.comment-before-comma { + @apply --comment-in-parameters(green /* one */, blue /* two */); +} + +@mixin --comment-parameters-and-arguments(--color, --background) { + color: var(--color); + background-color: var(--background); +} + +.comment-around-arguments { + @apply --comment-parameters-and-arguments( /* before */ green /* mid */, blue /* after */ ); +} + +@mixin --comment-only-default(--color /* color */: green) { + color: var(--color); +} + +.comment-only-default { + @apply --comment-only-default; +} + +@mixin --space-around-default(--color: green , --background: blue) { + color: var(--color); + background-color: var(--background); +} + +.space-around-default { + @apply --space-around-default; +} + +.space-around-argument-defaults { + @apply --space-around-default( red , blue ); +} + +@mixin --comment-before-parameter-name(/* first */--color, /* second */--background) { + color: var(--color); + background-color: var(--background); +} + +.comment-before-parameter-name { + @apply --comment-before-parameter-name(green, blue); +} + +@mixin --space-before-parameter-name( --color, --background) { + color: var(--color); + background-color: var(--background); +} + +.space-before-parameter-name { + @apply --space-before-parameter-name(green, blue); +} + +@mixin --comment-before-default-name(/* note */--color: green) { + color: var(--color); +} + +.comment-before-default-name { + @apply --comment-before-default-name; +} diff --git a/plugins/postcss-mixins/test/spaces.expect.css b/plugins/postcss-mixins/test/spaces.expect.css new file mode 100644 index 0000000000..86da1aa526 --- /dev/null +++ b/plugins/postcss-mixins/test/spaces.expect.css @@ -0,0 +1,106 @@ +.space-after-open { + --_csstools-p-d994g2bk-0--arg-0: green; + --_csstools-p-d994g2bk-0--arg-1: blue; + --_csstools-p-d994g2bk-1--color: var(--_csstools-p-d994g2bk-0--arg-0); + --_csstools-p-d994g2bk-1--background: var(--_csstools-p-d994g2bk-0--arg-1); + color: var(--_csstools-p-d994g2bk-1--color); + background-color: var(--_csstools-p-d994g2bk-1--background); +} + +.space-before-close { + --_csstools-p-d994g2bk-2--arg-2: green; + --_csstools-p-d994g2bk-2--arg-3: blue; + --_csstools-p-d994g2bk-3--color: var(--_csstools-p-d994g2bk-2--arg-2); + --_csstools-p-d994g2bk-3--background: var(--_csstools-p-d994g2bk-2--arg-3); + color: var(--_csstools-p-d994g2bk-3--color); + background-color: var(--_csstools-p-d994g2bk-3--background); +} + +.space-around-both { + --_csstools-p-d994g2bk-4--arg-4: green; + --_csstools-p-d994g2bk-4--arg-5: blue; + --_csstools-p-d994g2bk-5--color: var(--_csstools-p-d994g2bk-4--arg-4); + --_csstools-p-d994g2bk-5--background: var(--_csstools-p-d994g2bk-4--arg-5); + color: var(--_csstools-p-d994g2bk-5--color); + background-color: var(--_csstools-p-d994g2bk-5--background); +} + +.tabs-and-newlines { + --_csstools-p-d994g2bk-6--arg-6: green; + --_csstools-p-d994g2bk-6--arg-7: blue; + --_csstools-p-d994g2bk-7--color: var(--_csstools-p-d994g2bk-6--arg-6); + --_csstools-p-d994g2bk-7--background: var(--_csstools-p-d994g2bk-6--arg-7); + color: var(--_csstools-p-d994g2bk-7--color); + background-color: var(--_csstools-p-d994g2bk-7--background); +} + +.space-around-comma { + --_csstools-p-d994g2bk-8--arg-8: green; + --_csstools-p-d994g2bk-8--arg-9: blue; + --_csstools-p-d994g2bk-9--color: var(--_csstools-p-d994g2bk-8--arg-8); + --_csstools-p-d994g2bk-9--background: var(--_csstools-p-d994g2bk-8--arg-9); + color: var(--_csstools-p-d994g2bk-9--color); + background-color: var(--_csstools-p-d994g2bk-9--background); +} + +.comment-before-comma { + --_csstools-p-d994g2bk-a--arg-a: green; + --_csstools-p-d994g2bk-a--arg-b: blue; + --_csstools-p-d994g2bk-b--color: var(--_csstools-p-d994g2bk-a--arg-a); + --_csstools-p-d994g2bk-b--background: var(--_csstools-p-d994g2bk-a--arg-b); + color: var(--_csstools-p-d994g2bk-b--color); + background-color: var(--_csstools-p-d994g2bk-b--background); +} + +.comment-around-arguments { + --_csstools-p-d994g2bk-c--arg-c: green; + --_csstools-p-d994g2bk-c--arg-d: blue; + --_csstools-p-d994g2bk-d--color: var(--_csstools-p-d994g2bk-c--arg-c); + --_csstools-p-d994g2bk-d--background: var(--_csstools-p-d994g2bk-c--arg-d); + color: var(--_csstools-p-d994g2bk-d--color); + background-color: var(--_csstools-p-d994g2bk-d--background); +} + +.comment-only-default { + --_csstools-p-d994g2bk-e--color: green; + color: var(--_csstools-p-d994g2bk-e--color); +} + +.space-around-default { + --_csstools-p-d994g2bk-f--color: green; + --_csstools-p-d994g2bk-f--background: blue; + color: var(--_csstools-p-d994g2bk-f--color); + background-color: var(--_csstools-p-d994g2bk-f--background); +} + +.space-around-argument-defaults { + --_csstools-p-d994g2bk-g--arg-e: red; + --_csstools-p-d994g2bk-g--arg-f: blue; + --_csstools-p-d994g2bk-h--color: var(--_csstools-p-d994g2bk-g--arg-e); + --_csstools-p-d994g2bk-h--background: var(--_csstools-p-d994g2bk-g--arg-f); + color: var(--_csstools-p-d994g2bk-h--color); + background-color: var(--_csstools-p-d994g2bk-h--background); +} + +.comment-before-parameter-name { + --_csstools-p-d994g2bk-i--arg-g: green; + --_csstools-p-d994g2bk-i--arg-h: blue; + --_csstools-p-d994g2bk-j--color: var(--_csstools-p-d994g2bk-i--arg-g); + --_csstools-p-d994g2bk-j--background: var(--_csstools-p-d994g2bk-i--arg-h); + color: var(--_csstools-p-d994g2bk-j--color); + background-color: var(--_csstools-p-d994g2bk-j--background); +} + +.space-before-parameter-name { + --_csstools-p-d994g2bk-k--arg-i: green; + --_csstools-p-d994g2bk-k--arg-j: blue; + --_csstools-p-d994g2bk-l--color: var(--_csstools-p-d994g2bk-k--arg-i); + --_csstools-p-d994g2bk-l--background: var(--_csstools-p-d994g2bk-k--arg-j); + color: var(--_csstools-p-d994g2bk-l--color); + background-color: var(--_csstools-p-d994g2bk-l--background); +} + +.comment-before-default-name { + --_csstools-p-d994g2bk-m--color: green; + color: var(--_csstools-p-d994g2bk-m--color); +} diff --git a/plugins/postcss-mixins/test/spaces.preserve-true.expect.css b/plugins/postcss-mixins/test/spaces.preserve-true.expect.css new file mode 100644 index 0000000000..ddceaf92b2 --- /dev/null +++ b/plugins/postcss-mixins/test/spaces.preserve-true.expect.css @@ -0,0 +1,160 @@ +@mixin --spaces(--color, --background) { + color: var(--color); + background-color: var(--background); +} + +.space-after-open { + --_csstools-p-d994g2bk-0--arg-0: green; + --_csstools-p-d994g2bk-0--arg-1: blue; + --_csstools-p-d994g2bk-1--color: var(--_csstools-p-d994g2bk-0--arg-0); + --_csstools-p-d994g2bk-1--background: var(--_csstools-p-d994g2bk-0--arg-1); + color: var(--_csstools-p-d994g2bk-1--color); + background-color: var(--_csstools-p-d994g2bk-1--background); + @apply --spaces( green, blue); +} + +.space-before-close { + --_csstools-p-d994g2bk-2--arg-2: green; + --_csstools-p-d994g2bk-2--arg-3: blue; + --_csstools-p-d994g2bk-3--color: var(--_csstools-p-d994g2bk-2--arg-2); + --_csstools-p-d994g2bk-3--background: var(--_csstools-p-d994g2bk-2--arg-3); + color: var(--_csstools-p-d994g2bk-3--color); + background-color: var(--_csstools-p-d994g2bk-3--background); + @apply --spaces(green, blue ); +} + +.space-around-both { + --_csstools-p-d994g2bk-4--arg-4: green; + --_csstools-p-d994g2bk-4--arg-5: blue; + --_csstools-p-d994g2bk-5--color: var(--_csstools-p-d994g2bk-4--arg-4); + --_csstools-p-d994g2bk-5--background: var(--_csstools-p-d994g2bk-4--arg-5); + color: var(--_csstools-p-d994g2bk-5--color); + background-color: var(--_csstools-p-d994g2bk-5--background); + @apply --spaces( green, blue ); +} + +.tabs-and-newlines { + --_csstools-p-d994g2bk-6--arg-6: green; + --_csstools-p-d994g2bk-6--arg-7: blue; + --_csstools-p-d994g2bk-7--color: var(--_csstools-p-d994g2bk-6--arg-6); + --_csstools-p-d994g2bk-7--background: var(--_csstools-p-d994g2bk-6--arg-7); + color: var(--_csstools-p-d994g2bk-7--color); + background-color: var(--_csstools-p-d994g2bk-7--background); + @apply --spaces( + green, + blue + ); +} + +.space-around-comma { + --_csstools-p-d994g2bk-8--arg-8: green; + --_csstools-p-d994g2bk-8--arg-9: blue; + --_csstools-p-d994g2bk-9--color: var(--_csstools-p-d994g2bk-8--arg-8); + --_csstools-p-d994g2bk-9--background: var(--_csstools-p-d994g2bk-8--arg-9); + color: var(--_csstools-p-d994g2bk-9--color); + background-color: var(--_csstools-p-d994g2bk-9--background); + @apply --spaces(green , blue); +} + +@mixin --comment-in-parameters(--color /* color */, --background /* background */) { + color: var(--color); + background-color: var(--background); +} + +.comment-before-comma { + --_csstools-p-d994g2bk-a--arg-a: green; + --_csstools-p-d994g2bk-a--arg-b: blue; + --_csstools-p-d994g2bk-b--color: var(--_csstools-p-d994g2bk-a--arg-a); + --_csstools-p-d994g2bk-b--background: var(--_csstools-p-d994g2bk-a--arg-b); + color: var(--_csstools-p-d994g2bk-b--color); + background-color: var(--_csstools-p-d994g2bk-b--background); + @apply --comment-in-parameters(green /* one */, blue /* two */); +} + +@mixin --comment-parameters-and-arguments(--color, --background) { + color: var(--color); + background-color: var(--background); +} + +.comment-around-arguments { + --_csstools-p-d994g2bk-c--arg-c: green; + --_csstools-p-d994g2bk-c--arg-d: blue; + --_csstools-p-d994g2bk-d--color: var(--_csstools-p-d994g2bk-c--arg-c); + --_csstools-p-d994g2bk-d--background: var(--_csstools-p-d994g2bk-c--arg-d); + color: var(--_csstools-p-d994g2bk-d--color); + background-color: var(--_csstools-p-d994g2bk-d--background); + @apply --comment-parameters-and-arguments( /* before */ green /* mid */, blue /* after */ ); +} + +@mixin --comment-only-default(--color /* color */: green) { + color: var(--color); +} + +.comment-only-default { + --_csstools-p-d994g2bk-e--color: green; + color: var(--_csstools-p-d994g2bk-e--color); + @apply --comment-only-default; +} + +@mixin --space-around-default(--color: green , --background: blue) { + color: var(--color); + background-color: var(--background); +} + +.space-around-default { + --_csstools-p-d994g2bk-f--color: green; + --_csstools-p-d994g2bk-f--background: blue; + color: var(--_csstools-p-d994g2bk-f--color); + background-color: var(--_csstools-p-d994g2bk-f--background); + @apply --space-around-default; +} + +.space-around-argument-defaults { + --_csstools-p-d994g2bk-g--arg-e: red; + --_csstools-p-d994g2bk-g--arg-f: blue; + --_csstools-p-d994g2bk-h--color: var(--_csstools-p-d994g2bk-g--arg-e); + --_csstools-p-d994g2bk-h--background: var(--_csstools-p-d994g2bk-g--arg-f); + color: var(--_csstools-p-d994g2bk-h--color); + background-color: var(--_csstools-p-d994g2bk-h--background); + @apply --space-around-default( red , blue ); +} + +@mixin --comment-before-parameter-name(/* first */--color, /* second */--background) { + color: var(--color); + background-color: var(--background); +} + +.comment-before-parameter-name { + --_csstools-p-d994g2bk-i--arg-g: green; + --_csstools-p-d994g2bk-i--arg-h: blue; + --_csstools-p-d994g2bk-j--color: var(--_csstools-p-d994g2bk-i--arg-g); + --_csstools-p-d994g2bk-j--background: var(--_csstools-p-d994g2bk-i--arg-h); + color: var(--_csstools-p-d994g2bk-j--color); + background-color: var(--_csstools-p-d994g2bk-j--background); + @apply --comment-before-parameter-name(green, blue); +} + +@mixin --space-before-parameter-name( --color, --background) { + color: var(--color); + background-color: var(--background); +} + +.space-before-parameter-name { + --_csstools-p-d994g2bk-k--arg-i: green; + --_csstools-p-d994g2bk-k--arg-j: blue; + --_csstools-p-d994g2bk-l--color: var(--_csstools-p-d994g2bk-k--arg-i); + --_csstools-p-d994g2bk-l--background: var(--_csstools-p-d994g2bk-k--arg-j); + color: var(--_csstools-p-d994g2bk-l--color); + background-color: var(--_csstools-p-d994g2bk-l--background); + @apply --space-before-parameter-name(green, blue); +} + +@mixin --comment-before-default-name(/* note */--color: green) { + color: var(--color); +} + +.comment-before-default-name { + --_csstools-p-d994g2bk-m--color: green; + color: var(--_csstools-p-d994g2bk-m--color); + @apply --comment-before-default-name; +} diff --git a/plugins/postcss-mixins/test/wpt/mixin-arguments.html b/plugins/postcss-mixins/test/wpt/mixin-arguments.html new file mode 100644 index 0000000000..5958c47c92 --- /dev/null +++ b/plugins/postcss-mixins/test/wpt/mixin-arguments.html @@ -0,0 +1,131 @@ + +CSS Mixins: Arguments + + + + + + + + +
+ + diff --git a/plugins/postcss-mixins/test/wpt/mixin-basic.html b/plugins/postcss-mixins/test/wpt/mixin-basic.html new file mode 100644 index 0000000000..a7911402d0 --- /dev/null +++ b/plugins/postcss-mixins/test/wpt/mixin-basic.html @@ -0,0 +1,139 @@ + +CSS Mixins: Basic + + + + + + + + +
+ + diff --git a/plugins/postcss-mixins/test/wpt/mixin-contents.html b/plugins/postcss-mixins/test/wpt/mixin-contents.html new file mode 100644 index 0000000000..4715d375cf --- /dev/null +++ b/plugins/postcss-mixins/test/wpt/mixin-contents.html @@ -0,0 +1,151 @@ + +CSS Mixins: @contents + + + + + + + + + +
+ + diff --git a/plugins/postcss-mixins/test/wpt/mixin-nested.html b/plugins/postcss-mixins/test/wpt/mixin-nested.html new file mode 100644 index 0000000000..6e26b34466 --- /dev/null +++ b/plugins/postcss-mixins/test/wpt/mixin-nested.html @@ -0,0 +1,128 @@ + +CSS Mixins: Nested mixins + + + + + + + +
+ + diff --git a/plugins/postcss-mixins/test/wpt/mixin-private.html b/plugins/postcss-mixins/test/wpt/mixin-private.html new file mode 100644 index 0000000000..c65e276d58 --- /dev/null +++ b/plugins/postcss-mixins/test/wpt/mixin-private.html @@ -0,0 +1,105 @@ + +CSS Mixins: Parameters and @private + + + + + + + +
+ + diff --git a/plugins/postcss-private-rule/CHANGELOG.md b/plugins/postcss-private-rule/CHANGELOG.md index 091d55dc64..d4c4f6de14 100644 --- a/plugins/postcss-private-rule/CHANGELOG.md +++ b/plugins/postcss-private-rule/CHANGELOG.md @@ -1,5 +1,10 @@ # Changes to PostCSS Private Rule +### Unreleased (minor) + +- Fix lookup order of shadowing properties +- Export `Transpiler` which can be used to transpile `@private` rules in other `postcss` plugins (i.e. `postcss-mixins`) + ### 1.0.3 _September 25, 2026_ diff --git a/plugins/postcss-private-rule/dist/index.d.ts b/plugins/postcss-private-rule/dist/index.d.ts index 54b07f33d4..cfe1a6406d 100644 --- a/plugins/postcss-private-rule/dist/index.d.ts +++ b/plugins/postcss-private-rule/dist/index.d.ts @@ -1,4 +1,8 @@ +import type { AtRule } from 'postcss'; +import type { Declaration } from 'postcss'; +import type { Node } from 'postcss'; import type { PluginCreator } from 'postcss'; +import type { Rule } from 'postcss'; declare const creator: PluginCreator; export default creator; @@ -7,4 +11,27 @@ export { creator as 'module.exports' } /** postcss-private-rule plugin options */ export declare type pluginOptions = never; +export declare class Transpiler { + privateForRule: WeakMap; + }>; + counter: number; + hashes: Map; + getOrFillPrivateForRule(rule: Rule | AtRule): { + prefix: string; + privateProperties: Set; + }; + getStyleRulesWithPrivateProperties(node: Node): Array<{ + prefix: string; + privateProperties: Set; + rule: Rule; + }>; + registerAndRemovePrivateRules(atRule: AtRule, ownerNode: AtRule | Rule): void; + transpileDeclaration(decl: Declaration): void; + transpileDeclarationPropertyNames(decl: Declaration): void; + transpileDeclarationValues(decl: Declaration): void; + transpileAtRule(atRule: AtRule): void; +} + export { } diff --git a/plugins/postcss-private-rule/dist/index.mjs b/plugins/postcss-private-rule/dist/index.mjs index 6e627b7eb1..000ac66061 100644 --- a/plugins/postcss-private-rule/dist/index.mjs +++ b/plugins/postcss-private-rule/dist/index.mjs @@ -1 +1 @@ -import e from"node:crypto";import t from"node:path";import{tokenize as r,isTokenIdent as s,mutateIdent as a}from"@csstools/css-tokenizer";import{parseListOfComponentValues as i,walk as n,isFunctionNode as o,isWhiteSpaceOrCommentNode as l,isTokenNode as u,stringify as p}from"@csstools/css-parser-algorithms";const f=/^keyframes$/i,c=/^(container|layer|media|scope|starting-style|supports)$/i,v=/^private$/i;function findStyleRule(e){if(!e.parent)return!1;if("rule"===e.parent.type){const t=e.parent;return("atrule"!==t.parent?.type||!f.test(t.parent.name))&&t}if("atrule"===e.parent.type){const t=e.parent;if(c.test(t.name))return findStyleRule(t)}return!1}function findAllStyleRules(e){let t=e;const r=[];for(;;){const e=findStyleRule(t);if(!e)break;r.push(e),t=e}return r.reverse(),r}function findPrivateRule(e){return"atrule"===e.parent?.type&&(c.test(e.parent.name)?findPrivateRule(e.parent):!!v.test(e.parent.name)&&e.parent)}const h=/\b(var|if)\(/i,m=/^container$/i,d=/^if$/i,g=/^style$/i,P=/^var$/i;class Transpiler{privateForRule=new Map;counter=0;hashes=new Map;getOrFillPrivateForRule(r){const s=this.privateForRule.get(r);if(s)return s;let a;if(r.source?.input.from){if(a=this.hashes.get(r.source.input.from),!a){const s=e.createHash("md5");s.update(t.basename(t.dirname(r.source?.input.from))+"/"+t.basename(r.source?.input.from),"utf8"),a=parseInt(s.digest("hex"),16).toString(36).slice(0,8),this.hashes.set(r.source.input.from,a)}}else a="0";const i=`--_csstools-p-${a}-${this.counter.toString(36)}`;this.counter++;const n={prefix:i,privateProperties:new Set};return this.privateForRule.set(r,n),n}getStyleRulesWithPrivateProperties(e){return findAllStyleRules(e).map(e=>{const t=this.privateForRule.get(e);return!!t&&{prefix:t.prefix,privateProperties:t.privateProperties,rule:e}}).filter(Boolean)}registerAndRemovePrivateRules(e,t){if(!e.nodes?.length)return;const{prefix:r,privateProperties:s}=this.getOrFillPrivateForRule(t);e.walk(t=>{if("decl"===t.type&&t.variable&&t.prop.startsWith("--"))return s.add(t.prop),void(t.prop=`${r}${t.prop}`);"atrule"===t.type&&findPrivateRule(t)===e||t.remove()}),e.nodes.forEach(t=>{e.before(t.clone())}),e.remove()}transpileDeclaration(e){this.transpileDeclarationPropertyNames(e),this.transpileDeclarationValues(e)}transpileDeclarationPropertyNames(e){if(!e.variable||!e.prop.startsWith("--"))return;const t=this.getStyleRulesWithPrivateProperties(e);if(!t.length)return;let r=!1;for(const{prefix:s,privateProperties:a}of t)if(a.has(e.prop)){r=s;break}if(!r)return;const s=`${r}${e.prop}`;e.prop!==s&&(e.prop=s)}transpileDeclarationValues(e){if(!h.test(e.value))return;const t=this.getStyleRulesWithPrivateProperties(e);if(!t.length)return;const f=i(r({css:e.value}));n(f,e=>{if(o(e.node)){if(P.test(e.node.getName())){const r=e.node.value;for(let e=0;e{if(!o(e.node))return;if(!g.test(e.node.getName()))return;const r=e.node.value;for(let e=0;e({postcssPlugin:"postcss-private-rule",prepare(){const e=new Transpiler;return{postcssPlugin:"postcss-private-rule",Once(t){t.walkAtRules(v,t=>{const r=findStyleRule(t);r&&e.registerAndRemovePrivateRules(t,r)}),t.walkDecls(t=>{e.transpileDeclaration(t)}),t.walkAtRules(t=>{e.transpileAtRule(t)})}}}});creator.postcss=!0;export{creator as default,creator as"module.exports"}; +import e from"node:crypto";import t from"node:path";import{tokenize as r,isTokenIdent as s,mutateIdent as a}from"@csstools/css-tokenizer";import{parseListOfComponentValues as i,walk as n,isFunctionNode as o,isWhiteSpaceOrCommentNode as l,isTokenNode as u,stringify as p}from"@csstools/css-parser-algorithms";const f=/^keyframes$/i,c=/^(container|layer|media|scope|starting-style|supports)$/i,v=/^private$/i;function findStyleRule(e){if(!e.parent)return!1;if("rule"===e.parent.type){const t=e.parent;return("atrule"!==t.parent?.type||!f.test(t.parent.name))&&t}if("atrule"===e.parent.type){const t=e.parent;if(c.test(t.name))return findStyleRule(t)}return!1}function findAllStyleRules(e){let t=e;const r=[];for(;;){const e=findStyleRule(t);if(!e)break;r.push(e),t=e}return r.reverse(),r}function findPrivateRule(e){return"atrule"===e.parent?.type&&(c.test(e.parent.name)?findPrivateRule(e.parent):!!v.test(e.parent.name)&&e.parent)}const h=/\b(var|if)\(/i,m=/^container$/i,d=/^if$/i,g=/^style$/i,P=/^var$/i;class Transpiler{privateForRule=new Map;counter=0;hashes=new Map;getOrFillPrivateForRule(r){const s=this.privateForRule.get(r);if(s)return s;let a;if(r.source?.input.from){if(a=this.hashes.get(r.source.input.from),!a){const s=e.createHash("md5");s.update(t.basename(t.dirname(r.source?.input.from))+"/"+t.basename(r.source?.input.from),"utf8"),a=parseInt(s.digest("hex"),16).toString(36).slice(0,8),this.hashes.set(r.source.input.from,a)}}else a="0";const i=`--_csstools-p-${a}-${this.counter.toString(36)}`;this.counter++;const n={prefix:i,privateProperties:new Set};return this.privateForRule.set(r,n),n}getStyleRulesWithPrivateProperties(e){return findAllStyleRules(e).map(e=>{const t=this.privateForRule.get(e);return!!t&&{prefix:t.prefix,privateProperties:t.privateProperties,rule:e}}).filter(Boolean).reverse()}registerAndRemovePrivateRules(e,t){if(!e.nodes?.length)return;const{prefix:r,privateProperties:s}=this.getOrFillPrivateForRule(t);e.walk(t=>{if("decl"===t.type&&t.variable&&t.prop.startsWith("--"))return s.add(t.prop),void(t.prop=`${r}${t.prop}`);"atrule"===t.type&&findPrivateRule(t)===e||t.remove()}),e.nodes.forEach(t=>{e.before(t.clone())}),e.remove()}transpileDeclaration(e){this.transpileDeclarationPropertyNames(e),this.transpileDeclarationValues(e)}transpileDeclarationPropertyNames(e){if(!e.variable||!e.prop.startsWith("--"))return;const t=this.getStyleRulesWithPrivateProperties(e);if(!t.length)return;let r=!1;for(const{prefix:s,privateProperties:a}of t)if(a.has(e.prop)){r=s;break}if(!r)return;const s=`${r}${e.prop}`;e.prop!==s&&(e.prop=s)}transpileDeclarationValues(e){if(!h.test(e.value))return;const t=this.getStyleRulesWithPrivateProperties(e);if(!t.length)return;const f=i(r({css:e.value}));n(f,e=>{if(o(e.node)){if(P.test(e.node.getName())){const r=e.node.value;for(let e=0;e{if(!o(e.node))return;if(!g.test(e.node.getName()))return;const r=e.node.value;for(let e=0;e({postcssPlugin:"postcss-private-rule",prepare(){const e=new Transpiler;return{postcssPlugin:"postcss-private-rule",Once(t){t.walkAtRules(v,t=>{const r=findStyleRule(t);r&&e.registerAndRemovePrivateRules(t,r)}),t.walkDecls(t=>{e.transpileDeclaration(t)}),t.walkAtRules(t=>{e.transpileAtRule(t)})}}}});creator.postcss=!0;export{Transpiler,creator as default,creator as"module.exports"}; diff --git a/plugins/postcss-private-rule/src/index.ts b/plugins/postcss-private-rule/src/index.ts index 8fcbd68bb4..7ab284f76c 100644 --- a/plugins/postcss-private-rule/src/index.ts +++ b/plugins/postcss-private-rule/src/index.ts @@ -2,6 +2,8 @@ import type { Plugin, PluginCreator } from 'postcss'; import { findStyleRule, IS_PRIVATE_RULE_REGEX } from './valid-atrules'; import { Transpiler } from './transpiler'; +export { Transpiler } from './transpiler'; + /** postcss-private-rule plugin options */ export type pluginOptions = never; diff --git a/plugins/postcss-private-rule/src/transpiler.ts b/plugins/postcss-private-rule/src/transpiler.ts index 44dfcb1647..adf5a76709 100644 --- a/plugins/postcss-private-rule/src/transpiler.ts +++ b/plugins/postcss-private-rule/src/transpiler.ts @@ -52,7 +52,8 @@ export class Transpiler { }; getStyleRulesWithPrivateProperties(node: Node): Array<{ prefix: string, privateProperties: Set, rule: Rule }> { - return findAllStyleRules(node).map((rule) => { + return findAllStyleRules(node) + .map((rule) => { const p = this.privateForRule.get(rule); if (!p) { return false; @@ -63,7 +64,9 @@ export class Transpiler { privateProperties: p.privateProperties, rule: rule, }; - }).filter(Boolean) as Array<{ prefix: string, privateProperties: Set, rule: Rule }>; + }) + .filter(Boolean) + .reverse() as Array<{ prefix: string, privateProperties: Set, rule: Rule }>; }; registerAndRemovePrivateRules(atRule: AtRule, ownerNode: AtRule | Rule): void { diff --git a/plugins/postcss-private-rule/test/basic.css b/plugins/postcss-private-rule/test/basic.css index 2c2d85cfb5..1131e60d6a 100644 --- a/plugins/postcss-private-rule/test/basic.css +++ b/plugins/postcss-private-rule/test/basic.css @@ -193,3 +193,17 @@ section > h1 > small { } } } + +.outer { + @private { + --foo: outer; + } + + .inner { + @private { + --foo: inner; + } + + content: var(--foo); + } +} diff --git a/plugins/postcss-private-rule/test/basic.expect.css b/plugins/postcss-private-rule/test/basic.expect.css index 278889683c..ee69f46822 100644 --- a/plugins/postcss-private-rule/test/basic.expect.css +++ b/plugins/postcss-private-rule/test/basic.expect.css @@ -150,3 +150,13 @@ section > h1 > small { } } } + +.outer { + --_csstools-p-86hq2t3p-g--foo: outer; + + .inner { + --_csstools-p-86hq2t3p-h--foo: inner; + + content: var(--_csstools-p-86hq2t3p-h--foo); + } +}