Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions packages/css-parser-algorithms/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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_
Expand Down
2 changes: 1 addition & 1 deletion packages/css-parser-algorithms/dist/index.mjs

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -46,9 +46,7 @@ export function parseCommaSeparatedListOfComponentValues(tokens: Array<CSSToken>

while (true) {
if (!tokensCopy[i] || isTokenEOF(tokensCopy[i])) {
if (list.length) {
listOfCvls.push(list);
}
listOfCvls.push(list);

return listOfCvls;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
[
[
{
"type": "token",
"tokens": [
[
"ident-token",
"foo",
0,
2,
{
"value": "foo"
}
]
]
}
],
[]
]
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
[
{
"type": "token",
"tokens": [
[
"ident-token",
"foo",
0,
2,
{
"value": "foo"
}
]
]
},
{
"type": "token",
"tokens": [
[
"comma-token",
",",
3,
3,
null
]
]
}
]
13 changes: 13 additions & 0 deletions packages/css-parser-algorithms/test/cases/various/0022.mjs
Original file line number Diff line number Diff line change
@@ -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,
);
},
);
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
[
[
{
"type": "token",
"tokens": [
[
"ident-token",
"foo",
0,
2,
{
"value": "foo"
}
]
]
}
],
[],
[
{
"type": "token",
"tokens": [
[
"ident-token",
"bar",
5,
7,
{
"value": "bar"
}
]
]
}
]
]
Original file line number Diff line number Diff line change
@@ -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"
}
]
]
}
]
13 changes: 13 additions & 0 deletions packages/css-parser-algorithms/test/cases/various/0023.mjs
Original file line number Diff line number Diff line change
@@ -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,
);
},
);
2 changes: 2 additions & 0 deletions packages/css-parser-algorithms/test/test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
Original file line number Diff line number Diff line change
@@ -1 +1,8 @@
[]
[
{
"type": "media-query-with-type",
"string": "",
"modifier": [],
"mediaType": []
}
]
7 changes: 7 additions & 0 deletions plugins/postcss-mixins/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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_
Expand Down
83 changes: 80 additions & 3 deletions plugins/postcss-mixins/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Expand Down Expand Up @@ -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() {
Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion plugins/postcss-mixins/dist/index.mjs
Original file line number Diff line number Diff line change
@@ -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<e.length&&c(e[t]);)t++;const r=e[t];if(!(r&&a(r)&&f(r.value)&&r.value[4].value.startsWith("--")))return!1;const o=r.value[4].value;if(s.has(o))return!1;let i;s.add(o);let u=!1;for(let n=t+1;n<e.length;n++){const t=e[n];if(!c(t)){if(a(t)&&d(t.value)){i=l([e.slice(n+1)]).trim()||void 0;break}u=!0}}if(u)return!1;if(i&&R.test(i))return!1;n.push({name:o,defaultValue:i})}return n}const w=/^contents$/i,y=/^private$/i,M=/^(container|layer|media|scope|starting-style|supports)$/i;class MixinExpander{mixins=new Map;knownMixins=new Set;transpiler=new s;argumentCounter=0;desugaredNestingRules=new Set;expanding=new Set;registeredMixins(){return Array.from(this.mixins.values())}registerMixins(e){for(const t of e){const e=processableMixinRule(t);e&&(this.knownMixins.has(e.name)?this.mixins.delete(e.name):(this.mixins.set(e.name,Object.assign({},e,{atRule:t})),this.knownMixins.add(e.name)))}}expandAll(e,t){const n=[];e.walkAtRules(h,e=>{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;t<r.length;t++){const i=r[t];let l;if(t<s.length){const r=`--arg-${(this.argumentCounter++).toString(36)}`;a?.append(new n({prop:r,value:s[t],source:e.source})),l=`var(${r})`}else l=i.defaultValue||"initial";o.append(new n({prop:i.name,value:l,source:e.source}))}return o}transpile(e){const t=[];for(const n of e)collectMatches(n,y,t);for(const e of t){const t=findOwningRule(e);t&&this.transpiler.registerAndRemovePrivateRules(e,t)}const n=[],s=[];for(const t of e)collectDeclarationsAndAtRules(t,n,s);for(const e of n)this.transpiler.transpileDeclaration(e);for(const e of s)this.transpiler.transpileAtRule(e)}expandNested(e){const t=[];for(const n of e)collectMatches(n,h,t);for(const e of t)e.parent&&this.expand(e,!1)}}function needsWrapper(e){return e.parameters.length>0||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"};
4 changes: 1 addition & 3 deletions plugins/postcss-mixins/docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
6 changes: 4 additions & 2 deletions plugins/postcss-mixins/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading
Loading