diff --git a/.github/ISSUE_TEMPLATE/css-issue.yml b/.github/ISSUE_TEMPLATE/css-issue.yml
index be605d0b6..9fb785fc9 100644
--- a/.github/ISSUE_TEMPLATE/css-issue.yml
+++ b/.github/ISSUE_TEMPLATE/css-issue.yml
@@ -125,6 +125,7 @@ body:
- PostCSS Page Break
- PostCSS Place
- PostCSS Position Area Property
+ - PostCSS Private Rule
- PostCSS Progressive Custom Properties
- PostCSS Property Rule Optional Descriptors
- PostCSS Property Rule Prelude List
diff --git a/.github/ISSUE_TEMPLATE/plugin-issue.yml b/.github/ISSUE_TEMPLATE/plugin-issue.yml
index 445d23001..78c46d0d4 100644
--- a/.github/ISSUE_TEMPLATE/plugin-issue.yml
+++ b/.github/ISSUE_TEMPLATE/plugin-issue.yml
@@ -122,6 +122,7 @@ body:
- PostCSS Page Break
- PostCSS Place
- PostCSS Position Area Property
+ - PostCSS Private Rule
- PostCSS Progressive Custom Properties
- PostCSS Property Rule Optional Descriptors
- PostCSS Property Rule Prelude List
diff --git a/.github/labeler.yml b/.github/labeler.yml
index ac8a44cbe..7aa485ef5 100644
--- a/.github/labeler.yml
+++ b/.github/labeler.yml
@@ -431,6 +431,12 @@
- plugins/postcss-position-area-property/**
- experimental/postcss-position-area-property/**
+"plugins/postcss-private-rule":
+ - changed-files:
+ - any-glob-to-any-file:
+ - plugins/postcss-private-rule/**
+ - experimental/postcss-private-rule/**
+
"plugins/postcss-progressive-custom-properties":
- changed-files:
- any-glob-to-any-file:
diff --git a/package-lock.json b/package-lock.json
index 214472b11..3e7bfaf9e 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1079,6 +1079,10 @@
"resolved": "plugins/postcss-position-area-property",
"link": true
},
+ "node_modules/@csstools/postcss-private-rule": {
+ "resolved": "plugins/postcss-private-rule",
+ "link": true
+ },
"node_modules/@csstools/postcss-progressive-custom-properties": {
"resolved": "plugins/postcss-progressive-custom-properties",
"link": true
@@ -10519,6 +10523,34 @@
"postcss": "^8.4"
}
},
+ "plugins/postcss-private-rule": {
+ "name": "@csstools/postcss-private-rule",
+ "version": "0.0.0",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT-0",
+ "dependencies": {
+ "@csstools/css-parser-algorithms": "^4.0.0",
+ "@csstools/css-tokenizer": "^4.0.0"
+ },
+ "devDependencies": {
+ "@csstools/postcss-tape": "*"
+ },
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "peerDependencies": {
+ "postcss": "^8.4"
+ }
+ },
"plugins/postcss-progressive-custom-properties": {
"name": "@csstools/postcss-progressive-custom-properties",
"version": "5.1.2",
diff --git a/plugins/postcss-private-rule/.gitignore b/plugins/postcss-private-rule/.gitignore
new file mode 100644
index 000000000..e5b28db4a
--- /dev/null
+++ b/plugins/postcss-private-rule/.gitignore
@@ -0,0 +1,6 @@
+node_modules
+package-lock.json
+yarn.lock
+*.result.css
+*.result.css.map
+*.result.html
diff --git a/plugins/postcss-private-rule/.nvmrc b/plugins/postcss-private-rule/.nvmrc
new file mode 100644
index 000000000..b807a943c
--- /dev/null
+++ b/plugins/postcss-private-rule/.nvmrc
@@ -0,0 +1 @@
+v26.1.0
diff --git a/plugins/postcss-private-rule/CHANGELOG.md b/plugins/postcss-private-rule/CHANGELOG.md
new file mode 100644
index 000000000..dacf0f0c1
--- /dev/null
+++ b/plugins/postcss-private-rule/CHANGELOG.md
@@ -0,0 +1,5 @@
+# Changes to PostCSS Private Rule
+
+### Unreleased (major)
+
+- Initial version
diff --git a/plugins/postcss-private-rule/INSTALL.md b/plugins/postcss-private-rule/INSTALL.md
new file mode 100644
index 000000000..6ad3cc2bc
--- /dev/null
+++ b/plugins/postcss-private-rule/INSTALL.md
@@ -0,0 +1,235 @@
+# Installing PostCSS Private Rule
+
+[PostCSS Private Rule] runs in all Node environments, with special instructions for:
+
+- [Node](#node)
+- [PostCSS CLI](#postcss-cli)
+- [PostCSS Load Config](#postcss-load-config)
+- [Webpack](#webpack)
+- [Next.js](#nextjs)
+- [Gulp](#gulp)
+- [Grunt](#grunt)
+
+
+
+## Node
+
+Add [PostCSS Private Rule] to your project:
+
+```bash
+npm install postcss @csstools/postcss-private-rule --save-dev
+```
+
+Use it as a [PostCSS] plugin:
+
+```js
+// commonjs
+const postcss = require('postcss');
+const postcssPrivateRule = require('@csstools/postcss-private-rule');
+
+postcss([
+ postcssPrivateRule(/* pluginOptions */)
+]).process(YOUR_CSS /*, processOptions */);
+```
+
+```js
+// esm
+import postcss from 'postcss';
+import postcssPrivateRule from '@csstools/postcss-private-rule';
+
+postcss([
+ postcssPrivateRule(/* pluginOptions */)
+]).process(YOUR_CSS /*, processOptions */);
+```
+
+## PostCSS CLI
+
+Add [PostCSS CLI] to your project:
+
+```bash
+npm install postcss-cli @csstools/postcss-private-rule --save-dev
+```
+
+Use [PostCSS Private Rule] in your `postcss.config.js` configuration file:
+
+```js
+const postcssPrivateRule = require('@csstools/postcss-private-rule');
+
+module.exports = {
+ plugins: [
+ postcssPrivateRule(/* pluginOptions */)
+ ]
+}
+```
+
+## PostCSS Load Config
+
+If your framework/CLI supports [`postcss-load-config`](https://github.com/postcss/postcss-load-config).
+
+```bash
+npm install @csstools/postcss-private-rule --save-dev
+```
+
+`package.json`:
+
+```json
+{
+ "postcss": {
+ "plugins": {
+ "@csstools/postcss-private-rule": {}
+ }
+ }
+}
+```
+
+`.postcssrc.json`:
+
+```json
+{
+ "plugins": {
+ "@csstools/postcss-private-rule": {}
+ }
+}
+```
+
+_See the [README of `postcss-load-config`](https://github.com/postcss/postcss-load-config#usage) for more usage options._
+
+## Webpack
+
+_Webpack version 5_
+
+Add [PostCSS Loader] to your project:
+
+```bash
+npm install postcss-loader @csstools/postcss-private-rule --save-dev
+```
+
+Use [PostCSS Private Rule] in your Webpack configuration:
+
+```js
+module.exports = {
+ module: {
+ rules: [
+ {
+ test: /\.css$/i,
+ use: [
+ "style-loader",
+ {
+ loader: "css-loader",
+ options: { importLoaders: 1 },
+ },
+ {
+ loader: "postcss-loader",
+ options: {
+ postcssOptions: {
+ plugins: [
+ // Other plugins,
+ [
+ "@csstools/postcss-private-rule",
+ {
+ // Options
+ },
+ ],
+ ],
+ },
+ },
+ },
+ ],
+ },
+ ],
+ },
+};
+```
+
+## Next.js
+
+Read the instructions on how to [customize the PostCSS configuration in Next.js](https://nextjs.org/docs/advanced-features/customizing-postcss-config)
+
+```bash
+npm install @csstools/postcss-private-rule --save-dev
+```
+
+Use [PostCSS Private Rule] in your `postcss.config.json` file:
+
+```json
+{
+ "plugins": [
+ "@csstools/postcss-private-rule"
+ ]
+}
+```
+
+```json5
+{
+ "plugins": [
+ [
+ "@csstools/postcss-private-rule",
+ {
+ // Optionally add plugin options
+ }
+ ]
+ ]
+}
+```
+
+## Gulp
+
+Add [Gulp PostCSS] to your project:
+
+```bash
+npm install gulp-postcss @csstools/postcss-private-rule --save-dev
+```
+
+Use [PostCSS Private Rule] in your Gulpfile:
+
+```js
+const postcss = require('gulp-postcss');
+const postcssPrivateRule = require('@csstools/postcss-private-rule');
+
+gulp.task('css', function () {
+ var plugins = [
+ postcssPrivateRule(/* pluginOptions */)
+ ];
+
+ return gulp.src('./src/*.css')
+ .pipe(postcss(plugins))
+ .pipe(gulp.dest('.'));
+});
+```
+
+## Grunt
+
+Add [Grunt PostCSS] to your project:
+
+```bash
+npm install grunt-postcss @csstools/postcss-private-rule --save-dev
+```
+
+Use [PostCSS Private Rule] in your Gruntfile:
+
+```js
+const postcssPrivateRule = require('@csstools/postcss-private-rule');
+
+grunt.loadNpmTasks('grunt-postcss');
+
+grunt.initConfig({
+ postcss: {
+ options: {
+ processors: [
+ postcssPrivateRule(/* pluginOptions */)
+ ]
+ },
+ dist: {
+ src: '*.css'
+ }
+ }
+});
+```
+
+[Gulp PostCSS]: https://github.com/postcss/gulp-postcss
+[Grunt PostCSS]: https://github.com/nDmitry/grunt-postcss
+[PostCSS]: https://github.com/postcss/postcss
+[PostCSS CLI]: https://github.com/postcss/postcss-cli
+[PostCSS Loader]: https://github.com/postcss/postcss-loader
+[PostCSS Private Rule]: https://github.com/csstools/postcss-plugins/tree/main/plugins/postcss-private-rule
+[Next.js]: https://nextjs.org
diff --git a/plugins/postcss-private-rule/LICENSE.md b/plugins/postcss-private-rule/LICENSE.md
new file mode 100644
index 000000000..e8ae93b9f
--- /dev/null
+++ b/plugins/postcss-private-rule/LICENSE.md
@@ -0,0 +1,18 @@
+MIT No Attribution (MIT-0)
+
+Copyright © CSSTools Contributors
+
+Permission is hereby granted, free of charge, to any person obtaining a copy of
+this software and associated documentation files (the “Software”), to deal in
+the Software without restriction, including without limitation the rights to
+use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
+of the Software, and to permit persons to whom the Software is furnished to do
+so.
+
+THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/plugins/postcss-private-rule/README.md b/plugins/postcss-private-rule/README.md
new file mode 100644
index 000000000..663d63a1f
--- /dev/null
+++ b/plugins/postcss-private-rule/README.md
@@ -0,0 +1,78 @@
+# PostCSS Private Rule [
][PostCSS]
+
+[
][npm-url] [
][cli-url] [
][discord]
[
][css-url] [
][css-url]
+
+```bash
+npm install @csstools/postcss-private-rule --save-dev
+```
+
+[PostCSS Private Rule] lets you declare private custom properties following the [CSS Mixins Specification].
+
+```css
+section {
+ @private {
+ --size: 2px;
+ }
+
+ &, & > h1 {
+ border-width: var(--size);
+ }
+}
+
+section > h1 {
+ --size: 4px;
+}
+
+/* becomes */
+
+section {
+ --_csstools-p-15ccaace-0--size: 2px;
+
+ &, & > h1 {
+ border-width: var(--_csstools-p-15ccaace-0--size);
+ }
+}
+
+section > h1 {
+ --size: 4px;
+}
+```
+
+## Usage
+
+Add [PostCSS Private Rule] to your project:
+
+```bash
+npm install postcss @csstools/postcss-private-rule --save-dev
+```
+
+Use it as a [PostCSS] plugin:
+
+```js
+const postcss = require('postcss');
+const postcssPrivateRule = require('@csstools/postcss-private-rule');
+
+postcss([
+ postcssPrivateRule(/* pluginOptions */)
+]).process(YOUR_CSS /*, processOptions */);
+```
+
+[PostCSS Private Rule] runs in all Node environments, with special
+instructions for:
+
+- [Node](INSTALL.md#node)
+- [PostCSS CLI](INSTALL.md#postcss-cli)
+- [PostCSS Load Config](INSTALL.md#postcss-load-config)
+- [Webpack](INSTALL.md#webpack)
+- [Next.js](INSTALL.md#nextjs)
+- [Gulp](INSTALL.md#gulp)
+- [Grunt](INSTALL.md#grunt)
+
+[cli-url]: https://github.com/csstools/postcss-plugins/actions/workflows/test.yml?query=workflow/test
+[css-url]: https://cssdb.org/#private-rule
+[discord]: https://discord.gg/bUadyRwkJS
+[npm-url]: https://www.npmjs.com/package/@csstools/postcss-private-rule
+
+[PostCSS]: https://github.com/postcss/postcss
+[PostCSS Private Rule]: https://github.com/csstools/postcss-plugins/tree/main/plugins/postcss-private-rule
+[CSS Mixins Specification]: https://drafts.csswg.org/css-mixins-1/#private
diff --git a/plugins/postcss-private-rule/api-extractor.json b/plugins/postcss-private-rule/api-extractor.json
new file mode 100644
index 000000000..42058be51
--- /dev/null
+++ b/plugins/postcss-private-rule/api-extractor.json
@@ -0,0 +1,4 @@
+{
+ "$schema": "https://developer.microsoft.com/json-schemas/api-extractor/v7/api-extractor.schema.json",
+ "extends": "../../api-extractor.json"
+}
diff --git a/plugins/postcss-private-rule/dist/index.d.ts b/plugins/postcss-private-rule/dist/index.d.ts
new file mode 100644
index 000000000..54b07f33d
--- /dev/null
+++ b/plugins/postcss-private-rule/dist/index.d.ts
@@ -0,0 +1,10 @@
+import type { PluginCreator } from 'postcss';
+
+declare const creator: PluginCreator;
+export default creator;
+export { creator as 'module.exports' }
+
+/** postcss-private-rule plugin options */
+export declare type pluginOptions = never;
+
+export { }
diff --git a/plugins/postcss-private-rule/dist/index.mjs b/plugins/postcss-private-rule/dist/index.mjs
new file mode 100644
index 000000000..3ea57ee4c
--- /dev/null
+++ b/plugins/postcss-private-rule/dist/index.mjs
@@ -0,0 +1 @@
+import e from"node:crypto";import t from"node:path";import{tokenize as a,isTokenIdent as r,mutateIdent as s}from"@csstools/css-tokenizer";import{parseListOfComponentValues as n,walk as o,isFunctionNode as l,isWhiteSpaceOrCommentNode as u,isTokenNode as i,stringify as p}from"@csstools/css-parser-algorithms";const c=/^(container|layer|media|scope|starting-style|supports)$/i,f=/^private$/i,v=/^keyframes$/i;function findStyleRule(e){return!!e.parent&&("rule"===e.parent.type?("atrule"!==e.parent.parent?.type||!v.test(e.parent.parent?.name))&&e.parent:!("atrule"!==e.parent.type||!c.test(e.parent.name))&&findStyleRule(e.parent))}function findPrivateRule(e){return"atrule"===e.parent?.type&&(c.test(e.parent.name)?findPrivateRule(e.parent):!!f.test(e.parent.name)&&e.parent)}const m=/\b(var|if)\(/i,d=/^container$/i,h=/^if$/i,g=/^style$/i,$=/^var$/i,creator=()=>({postcssPlugin:"postcss-private-rule",prepare(){const c=new Map,v=new Set;let k=0;const propertyNamePrefix=a=>{const r=c.get(a);if(r)return r;let s;if(a.source?.input.from){const r=e.createHash("md5");r.update(t.basename(t.dirname(a.source?.input.from))+"/"+t.basename(a.source?.input.from),"utf8"),s=r.digest("hex").slice(0,8)}else{const t=e.createHash("md5");t.update("","utf8"),s=t.digest("hex").slice(0,8)}const n=`--_csstools-p-${s}-${k.toString(16)}`;return k++,c.set(a,n),n};return{postcssPlugin:"postcss-private-rule",Once(e){e.walkAtRules(f,e=>{const t=findStyleRule(e);if(!t)return;if(!e.nodes?.length)return;const c=propertyNamePrefix(t);e.walk(t=>{if("decl"===t.type&&t.variable&&t.prop.startsWith("--"))return v.add(t.prop),void(t.prop=`${c}${t.prop}`);"atrule"===t.type&&findPrivateRule(t)===e||t.remove()}),e.nodes.forEach(t=>{e.before(t.clone())}),e.remove(),t.walkDecls(e=>{if(!m.test(e.value))return;const t=n(a({css:e.value}));o(t,e=>{if(l(e.node)){if($.test(e.node.getName())){const t=e.node.value;for(let e=0;e{if(!d.test(e.name))return;if(!e.params.includes("--"))return;const t=n(a({css:e.params}));o(t,e=>{if(!l(e.node))return;if(!g.test(e.node.getName()))return;const t=e.node.value;for(let e=0;e
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+[] lets you declare private custom properties following the [CSS Mixins Specification].
+
+```css
+
+
+/* becomes */
+
+
+```
+
+
+
+
+
+
+[CSS Mixins Specification]:
diff --git a/plugins/postcss-private-rule/package.json b/plugins/postcss-private-rule/package.json
new file mode 100644
index 000000000..0cd9282b3
--- /dev/null
+++ b/plugins/postcss-private-rule/package.json
@@ -0,0 +1,83 @@
+{
+ "name": "@csstools/postcss-private-rule",
+ "description": "Declare private custom properties",
+ "version": "0.0.0",
+ "contributors": [
+ {
+ "name": "Antonio Laguna",
+ "email": "antonio@laguna.es",
+ "url": "https://antonio.laguna.es"
+ },
+ {
+ "name": "Romain Menke",
+ "email": "romainmenke@gmail.com"
+ }
+ ],
+ "license": "MIT-0",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "type": "module",
+ "exports": {
+ ".": {
+ "types": "./dist/index.d.ts",
+ "default": "./dist/index.mjs"
+ }
+ },
+ "files": [
+ "CHANGELOG.md",
+ "LICENSE.md",
+ "README.md",
+ "dist"
+ ],
+ "dependencies": {
+ "@csstools/css-parser-algorithms": "^4.0.0",
+ "@csstools/css-tokenizer": "^4.0.0"
+ },
+ "peerDependencies": {
+ "postcss": "^8.4"
+ },
+ "devDependencies": {
+ "@csstools/postcss-tape": "*"
+ },
+ "scripts": {
+ "build": "rollup -c ../../rollup/default.mjs",
+ "docs": "node ../../.github/bin/generate-docs/install.mjs && node ../../.github/bin/generate-docs/readme.mjs",
+ "lint": "node ../../.github/bin/format-package-json.mjs",
+ "prepublishOnly": "npm run build && npm run test",
+ "test": "node --test",
+ "test:rewrite-expects": "REWRITE_EXPECTS=true node --test"
+ },
+ "homepage": "https://github.com/csstools/postcss-plugins/tree/main/plugins/postcss-private-rule#readme",
+ "repository": {
+ "type": "git",
+ "url": "git+https://github.com/csstools/postcss-plugins.git",
+ "directory": "plugins/postcss-private-rule"
+ },
+ "bugs": "https://github.com/csstools/postcss-plugins/issues",
+ "keywords": [
+ "custom properties",
+ "fallback",
+ "postcss-plugin",
+ "private"
+ ],
+ "csstools": {
+ "cssdbId": "private-rule",
+ "exportName": "postcssPrivateRule",
+ "humanReadableName": "PostCSS Private Rule",
+ "specUrl": "https://drafts.csswg.org/css-mixins-1/#private"
+ },
+ "volta": {
+ "extends": "../../package.json"
+ }
+}
diff --git a/plugins/postcss-private-rule/src/index.ts b/plugins/postcss-private-rule/src/index.ts
new file mode 100644
index 000000000..21b503c17
--- /dev/null
+++ b/plugins/postcss-private-rule/src/index.ts
@@ -0,0 +1,193 @@
+import type { Plugin, PluginCreator, Rule } from 'postcss';
+import crypto from 'node:crypto';
+import path from 'node:path';
+import { findPrivateRule, findStyleRule, IS_PRIVATE_RULE_REGEX } from './valid-atrules';
+import { isTokenIdent, mutateIdent, tokenize } from '@csstools/css-tokenizer';
+import { isFunctionNode, isTokenNode, isWhiteSpaceOrCommentNode, parseListOfComponentValues, stringify, walk } from '@csstools/css-parser-algorithms';
+
+const HAS_VAR_OR_IF_FUNCTION_REGEX = /\b(var|if)\(/i;
+const IS_CONTAINER_REGEX = /^container$/i;
+const IS_IF_FUNCTION_REGEX = /^if$/i;
+const IS_STYLE_FUNCTION_REGEX = /^style$/i;
+const IS_VAR_FUNCTION_REGEX = /^var$/i;
+
+/** postcss-private-rule plugin options */
+export type pluginOptions = never;
+
+const creator: PluginCreator = () => {
+ return {
+ postcssPlugin: 'postcss-private-rule',
+ prepare(): Plugin {
+ const privatePropertyNamePrefixes: WeakMap = new Map();
+ const privatePropertyNames: Set = new Set();
+
+ let counter = 0;
+ const propertyNamePrefix = (rule: Rule): string => {
+ const existing = privatePropertyNamePrefixes.get(rule);
+ if (existing) {
+ return existing;
+ }
+
+ let fromHash;
+ if (rule.source?.input.from) {
+ const hash = crypto.createHash('md5');
+ hash.update(path.basename(path.dirname(rule.source?.input.from)) + '/' + path.basename(rule.source?.input.from), 'utf8');
+ fromHash = hash.digest('hex').slice(0, 8);
+ } else {
+ const hash = crypto.createHash('md5');
+ hash.update('', 'utf8');
+ fromHash = hash.digest('hex').slice(0, 8);
+ }
+
+ const prefix = `--_csstools-p-${fromHash}-${counter.toString(16)}`;
+ counter++;
+
+ privatePropertyNamePrefixes.set(rule, prefix);
+
+ return prefix;
+ };
+
+ return {
+ postcssPlugin: 'postcss-private-rule',
+ Once(root): void {
+ root.walkAtRules(IS_PRIVATE_RULE_REGEX, (atRule) => {
+ const styleRule = findStyleRule(atRule);
+ if (!styleRule) {
+ return;
+ }
+
+ if (!atRule.nodes?.length) {
+ return;
+ }
+
+ const prefix = propertyNamePrefix(styleRule);
+
+ {
+ atRule.walk((node) => {
+ if (node.type === 'decl' && node.variable && node.prop.startsWith('--')) {
+ privatePropertyNames.add(node.prop);
+
+ node.prop = `${prefix}${node.prop}`;
+
+ return;
+ }
+
+ if (node.type === 'atrule' && findPrivateRule(node) === atRule) {
+ return;
+ }
+
+ node.remove();
+ });
+
+ atRule.nodes.forEach((node) => {
+ atRule.before(node.clone());
+ });
+
+ atRule.remove();
+ }
+
+ {
+ styleRule.walkDecls((decl) => {
+ if (!HAS_VAR_OR_IF_FUNCTION_REGEX.test(decl.value)) return;
+
+ const componentValues = parseListOfComponentValues(tokenize({ css: decl.value }));
+
+ walk(componentValues, (entry) => {
+ if (!isFunctionNode(entry.node)) {
+ return;
+ }
+
+ if (IS_VAR_FUNCTION_REGEX.test(entry.node.getName())) {
+ const args = entry.node.value;
+ for (let i = 0; i < args.length; i++) {
+ const arg = args[i];
+
+ if (isWhiteSpaceOrCommentNode(arg)) continue;
+
+ if (!isTokenNode(arg) || !isTokenIdent(arg.value) || !arg.value[4].value.startsWith('--')) break;
+
+ if (!privatePropertyNames.has(arg.value[4].value)) break;
+
+ mutateIdent(arg.value, `${prefix}${arg.value[4].value}`);
+ break;
+ }
+
+ return;
+ }
+
+ if (IS_IF_FUNCTION_REGEX.test(entry.node.getName())) {
+ const args = entry.node.value;
+ for (let i = 0; i < args.length; i++) {
+ const arg = args[i];
+
+ if (isWhiteSpaceOrCommentNode(arg)) continue;
+
+ if (!isFunctionNode(arg) || !IS_STYLE_FUNCTION_REGEX.test(arg.getName())) break;
+
+ const childArgs = arg.value;
+ for (let j = 0; j < childArgs.length; j++) {
+ const childArg = childArgs[j];
+
+ if (isWhiteSpaceOrCommentNode(childArg)) continue;
+
+ if (!isTokenNode(childArg) || !isTokenIdent(childArg.value) || !childArg.value[4].value.startsWith('--')) break;
+
+ if (!privatePropertyNames.has(childArg.value[4].value)) break;
+
+ mutateIdent(childArg.value, `${prefix}${childArg.value[4].value}`);
+ break;
+ }
+ }
+ }
+ });
+
+ decl.value = stringify([componentValues]);
+ });
+ }
+
+ {
+ styleRule.walkAtRules((nestedAtRule) => {
+ if (!IS_CONTAINER_REGEX.test(nestedAtRule.name)) return;
+
+ if (!nestedAtRule.params.includes('--')) return;
+
+ const componentValues = parseListOfComponentValues(tokenize({ css: nestedAtRule.params }));
+
+ walk(componentValues, (entry) => {
+ if (!isFunctionNode(entry.node)) return;
+
+ if (!IS_STYLE_FUNCTION_REGEX.test(entry.node.getName())) return;
+
+ const args = entry.node.value;
+ for (let i = 0; i < args.length; i++) {
+ const arg = args[i];
+
+ if (isWhiteSpaceOrCommentNode(arg)) continue;
+
+ if (!isTokenNode(arg) || !isTokenIdent(arg.value) || !arg.value[4].value.startsWith('--')) break;
+
+ if (!privatePropertyNames.has(arg.value[4].value)) break;
+
+ mutateIdent(arg.value, `${prefix}${arg.value[4].value}`);
+ break;
+ }
+ });
+
+ nestedAtRule.params = stringify([componentValues]);
+ });
+ }
+
+ // TODO:
+ // substitute custom prop names in:
+ // - if()
+ });
+ },
+ };
+ }
+ };
+};
+
+creator.postcss = true;
+
+export default creator;
+export { creator as 'module.exports' };
diff --git a/plugins/postcss-private-rule/src/valid-atrules.ts b/plugins/postcss-private-rule/src/valid-atrules.ts
new file mode 100644
index 000000000..ab846a2ee
--- /dev/null
+++ b/plugins/postcss-private-rule/src/valid-atrules.ts
@@ -0,0 +1,44 @@
+import type { AtRule, Rule } from "postcss";
+
+const IS_NESTING_GROUP_RULE_REGEX = /^(container|layer|media|scope|starting-style|supports)$/i;
+
+export const IS_PRIVATE_RULE_REGEX = /^private$/i;
+
+const IS_KEYFRAMES_RULE_REGEX = /^keyframes$/i;
+
+export function findStyleRule(atRule: AtRule): false|Rule {
+ if (!atRule.parent) {
+ return false;
+ }
+
+ if (atRule.parent.type === 'rule') {
+ if (atRule.parent.parent?.type === 'atrule' && IS_KEYFRAMES_RULE_REGEX.test(atRule.parent.parent?.name)) {
+ return false;
+ }
+
+ return atRule.parent;
+ }
+
+ if (atRule.parent.type === 'atrule' && IS_NESTING_GROUP_RULE_REGEX.test(atRule.parent.name)) {
+ return findStyleRule(atRule.parent);
+ }
+
+ return false;
+}
+
+
+export function findPrivateRule(atRule: AtRule): false | AtRule {
+ if (atRule.parent?.type !== 'atrule') {
+ return false;
+ }
+
+ if (IS_NESTING_GROUP_RULE_REGEX.test(atRule.parent.name)) {
+ return findPrivateRule(atRule.parent);
+ }
+
+ if (IS_PRIVATE_RULE_REGEX.test(atRule.parent.name)) {
+ return atRule.parent;
+ }
+
+ return false;
+}
diff --git a/plugins/postcss-private-rule/test/_import.mjs b/plugins/postcss-private-rule/test/_import.mjs
new file mode 100644
index 000000000..b695f3d33
--- /dev/null
+++ b/plugins/postcss-private-rule/test/_import.mjs
@@ -0,0 +1,10 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import plugin from '@csstools/postcss-private-rule';
+
+test('import', () => {
+ plugin();
+ assert.ok(plugin.postcss, 'should have "postcss flag"');
+ assert.equal(typeof plugin, 'function', 'should return a function');
+});
+
diff --git a/plugins/postcss-private-rule/test/_require.cjs b/plugins/postcss-private-rule/test/_require.cjs
new file mode 100644
index 000000000..3f257495a
--- /dev/null
+++ b/plugins/postcss-private-rule/test/_require.cjs
@@ -0,0 +1,9 @@
+const assert = require('node:assert/strict');
+const test = require('node:test');
+const plugin = require('@csstools/postcss-private-rule');
+
+test('require', () => {
+ plugin();
+ assert.ok(plugin.postcss, 'should have "postcss flag"');
+ assert.equal(typeof plugin, 'function', 'should return a function');
+});
diff --git a/plugins/postcss-private-rule/test/_tape.mjs b/plugins/postcss-private-rule/test/_tape.mjs
new file mode 100644
index 000000000..d16cd1d11
--- /dev/null
+++ b/plugins/postcss-private-rule/test/_tape.mjs
@@ -0,0 +1,11 @@
+import { postcssTape } from '@csstools/postcss-tape';
+import plugin from '@csstools/postcss-private-rule';
+
+postcssTape(plugin)({
+ basic: {
+ message: 'supports basic usage',
+ },
+ 'examples/example': {
+ message: 'minimal example',
+ },
+});
diff --git a/plugins/postcss-private-rule/test/basic.css b/plugins/postcss-private-rule/test/basic.css
new file mode 100644
index 000000000..b1b6afa2b
--- /dev/null
+++ b/plugins/postcss-private-rule/test/basic.css
@@ -0,0 +1,189 @@
+section {
+ @private {
+ --size: 2px;
+ }
+
+ @private {
+ --color: red;
+ }
+
+ &, & > h1, & > h1 > small {
+ border-width: var(--size);
+ border-color: var(--color);
+ }
+}
+
+section > h1 {
+ --size: 4px;
+}
+
+section > h1 > small {
+ --size: 3px;
+}
+
+.public-before-private {
+ --alpha: 1px;
+
+ @private {
+ --alpha: 2px;
+ }
+
+ --beta: var(--alpha);
+}
+
+.private-before-public {
+ @private {
+ --alpha: 2px;
+ }
+
+ --alpha: 3px;
+
+ --beta: var(--alpha);
+}
+
+.single-private {
+ @private {
+ --alpha: var(--beta);
+ }
+}
+
+.two-private {
+ @private {
+ --alpha: 1;
+ --beta: 2;
+ }
+}
+
+.container-query {
+ @private {
+ --alpha: private;
+ }
+
+ --alpha: public;
+
+ @container style(--alpha: private) {
+ --bar: cyan;
+ }
+}
+
+.container-query-and-nesting {
+ @private {
+ --alpha: private;
+ }
+
+ --alpha: public;
+
+ & .bar {
+ @container style(--alpha: private) {
+ --bar: cyan;
+ }
+ }
+}
+
+.if-function {
+ @private {
+ --alpha: private;
+ }
+
+ --alpha: public;
+
+ --bar: if(
+ style(--alpha: private): cyan;
+ else: orange;
+ );
+
+ --baz: if(
+ style(--something: var(--alpha)): cyan;
+ else: orange;
+ );
+}
+
+.invalid-private-contents{
+ @private {
+ --alpha: 2px;
+
+ &+& {
+ --alpha: 3px;
+ }
+ }
+
+ --beta: var(--alpha);
+}
+
+.conditional-private {
+ @private {
+ --alpha: 2px;
+
+ @media screen {
+ --alpha: 3px;
+ }
+ }
+
+ --beta: var(--alpha);
+}
+
+.conditional-private-use {
+ @private {
+ --alpha: 2px;
+ }
+
+ @media screen {
+ --beta: var(--alpha);
+ }
+}
+
+@private {
+ --invalid-position: 2px;
+}
+
+:root {
+ --something: var(--invalid-position);
+}
+
+@keyframes slide-right {
+ from {
+ @private {
+ --alpha: 2px;
+ }
+ margin-left: var(--alpha);
+ }
+}
+
+.ignore {
+ @private {
+ --alpha: 2px;
+ }
+
+ --beta: var(--foo, --alpha);
+}
+
+.ignore {
+ @private {
+ --alpha: 2px;
+ }
+
+ --beta: --alpha;
+}
+
+.ignore {
+ @private {
+ --alpha: 2px;
+ }
+
+ --bar: if(
+ style(--foo: --alpha): cyan;
+ else: orange;
+ );
+}
+
+.ignore {
+ @private {
+ --alpha: 2px;
+ }
+
+ & .bar {
+ @container style(--something: --alpha) {
+ --bar: cyan;
+ }
+ }
+}
diff --git a/plugins/postcss-private-rule/test/basic.expect.css b/plugins/postcss-private-rule/test/basic.expect.css
new file mode 100644
index 000000000..52ec30539
--- /dev/null
+++ b/plugins/postcss-private-rule/test/basic.expect.css
@@ -0,0 +1,151 @@
+section {
+ --_csstools-p-8a2d0276-0--size: 2px;
+ --_csstools-p-8a2d0276-0--color: red;
+
+ &, & > h1, & > h1 > small {
+ border-width: var(--_csstools-p-8a2d0276-0--size);
+ border-color: var(--_csstools-p-8a2d0276-0--color);
+ }
+}
+
+section > h1 {
+ --size: 4px;
+}
+
+section > h1 > small {
+ --size: 3px;
+}
+
+.public-before-private {
+ --alpha: 1px;
+ --_csstools-p-8a2d0276-1--alpha: 2px;
+
+ --beta: var(--_csstools-p-8a2d0276-1--alpha);
+}
+
+.private-before-public {
+ --_csstools-p-8a2d0276-2--alpha: 2px;
+
+ --alpha: 3px;
+
+ --beta: var(--_csstools-p-8a2d0276-2--alpha);
+}
+
+.single-private {
+ --_csstools-p-8a2d0276-3--alpha: var(--beta)
+}
+
+.two-private {
+ --_csstools-p-8a2d0276-4--alpha: 1;
+ --_csstools-p-8a2d0276-4--beta: 2
+}
+
+.container-query {
+ --_csstools-p-8a2d0276-5--alpha: private;
+
+ --alpha: public;
+
+ @container style(--_csstools-p-8a2d0276-5--alpha: private) {
+ --bar: cyan;
+ }
+}
+
+.container-query-and-nesting {
+ --_csstools-p-8a2d0276-6--alpha: private;
+
+ --alpha: public;
+
+ & .bar {
+ @container style(--_csstools-p-8a2d0276-6--alpha: private) {
+ --bar: cyan;
+ }
+ }
+}
+
+.if-function {
+ --_csstools-p-8a2d0276-7--alpha: private;
+
+ --alpha: public;
+
+ --bar: if(
+ style(--_csstools-p-8a2d0276-7--alpha: private): cyan;
+ else: orange;
+ );
+
+ --baz: if(
+ style(--something: var(--_csstools-p-8a2d0276-7--alpha)): cyan;
+ else: orange;
+ );
+}
+
+.invalid-private-contents{
+ --_csstools-p-8a2d0276-8--alpha: 2px;
+
+ --beta: var(--_csstools-p-8a2d0276-8--alpha);
+}
+
+.conditional-private {
+ --_csstools-p-8a2d0276-9--alpha: 2px;
+
+ @media screen {
+ --_csstools-p-8a2d0276-9--alpha: 3px;
+ }
+
+ --beta: var(--_csstools-p-8a2d0276-9--alpha);
+}
+
+.conditional-private-use {
+ --_csstools-p-8a2d0276-a--alpha: 2px;
+
+ @media screen {
+ --beta: var(--_csstools-p-8a2d0276-a--alpha);
+ }
+}
+
+@private {
+ --invalid-position: 2px;
+}
+
+:root {
+ --something: var(--invalid-position);
+}
+
+@keyframes slide-right {
+ from {
+ @private {
+ --alpha: 2px;
+ }
+ margin-left: var(--alpha);
+ }
+}
+
+.ignore {
+ --_csstools-p-8a2d0276-b--alpha: 2px;
+
+ --beta: var(--foo, --alpha);
+}
+
+.ignore {
+ --_csstools-p-8a2d0276-c--alpha: 2px;
+
+ --beta: --alpha;
+}
+
+.ignore {
+ --_csstools-p-8a2d0276-d--alpha: 2px;
+
+ --bar: if(
+ style(--foo: --alpha): cyan;
+ else: orange;
+ );
+}
+
+.ignore {
+ --_csstools-p-8a2d0276-e--alpha: 2px;
+
+ & .bar {
+ @container style(--something: --alpha) {
+ --bar: cyan;
+ }
+ }
+}
diff --git a/plugins/postcss-private-rule/test/examples/example.css b/plugins/postcss-private-rule/test/examples/example.css
new file mode 100644
index 000000000..32cfe86f7
--- /dev/null
+++ b/plugins/postcss-private-rule/test/examples/example.css
@@ -0,0 +1,13 @@
+section {
+ @private {
+ --size: 2px;
+ }
+
+ &, & > h1 {
+ border-width: var(--size);
+ }
+}
+
+section > h1 {
+ --size: 4px;
+}
diff --git a/plugins/postcss-private-rule/test/examples/example.expect.css b/plugins/postcss-private-rule/test/examples/example.expect.css
new file mode 100644
index 000000000..3ad27f70e
--- /dev/null
+++ b/plugins/postcss-private-rule/test/examples/example.expect.css
@@ -0,0 +1,11 @@
+section {
+ --_csstools-p-15ccaace-0--size: 2px;
+
+ &, & > h1 {
+ border-width: var(--_csstools-p-15ccaace-0--size);
+ }
+}
+
+section > h1 {
+ --size: 4px;
+}
diff --git a/plugins/postcss-private-rule/tsconfig.json b/plugins/postcss-private-rule/tsconfig.json
new file mode 100644
index 000000000..24dab9cc3
--- /dev/null
+++ b/plugins/postcss-private-rule/tsconfig.json
@@ -0,0 +1,11 @@
+{
+ "extends": "../../tsconfig.json",
+ "compilerOptions": {
+ "outDir": "dist",
+ "declarationDir": ".",
+ "rootDir": "./src",
+ "strict": true
+ },
+ "include": ["./src/**/*"],
+ "exclude": ["dist"]
+}
diff --git a/rollup/configs/externals.mjs b/rollup/configs/externals.mjs
index f76da06a9..5730e53d8 100644
--- a/rollup/configs/externals.mjs
+++ b/rollup/configs/externals.mjs
@@ -58,6 +58,7 @@ export const externalsForCLI = [
'@csstools/postcss-normalize-display-values',
'@csstools/postcss-oklab-function',
'@csstools/postcss-position-area-property',
+ '@csstools/postcss-private-rule',
'@csstools/postcss-progressive-custom-properties',
'@csstools/postcss-property-rule-optional-descriptors',
'@csstools/postcss-property-rule-prelude-list',
@@ -174,6 +175,7 @@ export const externalsForPlugin = [
'@csstools/postcss-normalize-display-values',
'@csstools/postcss-oklab-function',
'@csstools/postcss-position-area-property',
+ '@csstools/postcss-private-rule',
'@csstools/postcss-progressive-custom-properties',
'@csstools/postcss-property-rule-optional-descriptors',
'@csstools/postcss-property-rule-prelude-list',