diff --git a/.github/ISSUE_TEMPLATE/css-issue.yml b/.github/ISSUE_TEMPLATE/css-issue.yml index b6f4ba6c9..9fe9251c8 100644 --- a/.github/ISSUE_TEMPLATE/css-issue.yml +++ b/.github/ISSUE_TEMPLATE/css-issue.yml @@ -113,6 +113,7 @@ body: - PostCSS Media MinMax - PostCSS Media Queries Aspect-Ratio Number Values - PostCSS Minify + - PostCSS Mixins - PostCSS Nested Calc - PostCSS Nesting - PostCSS OKLab Function diff --git a/.github/ISSUE_TEMPLATE/plugin-issue.yml b/.github/ISSUE_TEMPLATE/plugin-issue.yml index 70d7be639..e287c1a2f 100644 --- a/.github/ISSUE_TEMPLATE/plugin-issue.yml +++ b/.github/ISSUE_TEMPLATE/plugin-issue.yml @@ -110,6 +110,7 @@ body: - PostCSS Media MinMax - PostCSS Media Queries Aspect-Ratio Number Values - PostCSS Minify + - PostCSS Mixins - PostCSS Nested Calc - PostCSS Nesting - PostCSS OKLab Function diff --git a/.github/labeler.yml b/.github/labeler.yml index 7219806ef..449c844b4 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -353,6 +353,12 @@ - plugins/postcss-minify/** - experimental/postcss-minify/** +"plugins/postcss-mixins": + - changed-files: + - any-glob-to-any-file: + - plugins/postcss-mixins/** + - experimental/postcss-mixins/** + "plugins/postcss-nested-calc": - changed-files: - any-glob-to-any-file: diff --git a/package-lock.json b/package-lock.json index d30292e0c..e98abdac0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2069,6 +2069,10 @@ "resolved": "plugins/postcss-minify", "link": true }, + "node_modules/@csstools/postcss-mixins": { + "resolved": "plugins/postcss-mixins", + "link": true + }, "node_modules/@csstools/postcss-nested-calc": { "resolved": "plugins/postcss-nested-calc", "link": true @@ -5375,9 +5379,9 @@ "license": "CC0-1.0" }, "node_modules/cssdb": { - "version": "8.6.0", - "resolved": "https://registry.npmjs.org/cssdb/-/cssdb-8.6.0.tgz", - "integrity": "sha512-7ZrRi/Z3cRL1d5I8RuXEWAkRFP3J4GeQRiyVknI4KC70RAU8hT4LysUZDe0y+fYNOktCbxE8sOPUOhyR12UqGQ==", + "version": "8.7.0", + "resolved": "https://registry.npmjs.org/cssdb/-/cssdb-8.7.0.tgz", + "integrity": "sha512-UxiWVpV953ENHqAKjKRPZHNDfRo3uOymvO5Ef7MFCWlenaohkYj7PTO7WCBdjZm8z/aDZd6rXyUIlwZ0AjyFSg==", "funding": [ { "type": "opencollective", @@ -7925,6 +7929,7 @@ "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.1.tgz", "integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==", "license": "MIT", + "peer": true, "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" @@ -8817,6 +8822,98 @@ "stylelint": "^16.0.1" } }, + "node_modules/stylelint/node_modules/@csstools/css-parser-algorithms": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", + "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "peer": true, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/stylelint/node_modules/@csstools/css-tokenizer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", + "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/stylelint/node_modules/@csstools/media-query-list-parser": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/@csstools/media-query-list-parser/-/media-query-list-parser-4.0.3.tgz", + "integrity": "sha512-HAYH7d3TLRHDOUQK4mZKf9k9Ph/m8Akstg66ywKR4SFAigjs3yBiUeZtFxywiTm5moZMAp/5W/ZuFnNXXYLuuQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/stylelint/node_modules/@csstools/selector-specificity": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@csstools/selector-specificity/-/selector-specificity-5.0.0.tgz", + "integrity": "sha512-PCqQV3c4CoVm3kdPhyeZ07VmBRdH2EpMFA/pd9OASpOEC3aXNGoqPDAZ80D0cLpMBxnmk0+yNhGsEx31hq7Gtw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "postcss-selector-parser": "^7.0.0" + } + }, "node_modules/stylelint/node_modules/balanced-match": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-2.0.0.tgz", @@ -9977,7 +10074,7 @@ "css-blank-pseudo": "^8.0.1", "css-has-pseudo": "^8.0.0", "css-prefers-color-scheme": "^11.0.0", - "cssdb": "^8.6.0", + "cssdb": "^8.7.0", "postcss-attribute-case-insensitive": "^8.0.0", "postcss-clamp": "^4.1.0", "postcss-color-functional-notation": "^8.0.0", @@ -11555,6 +11652,34 @@ "postcss": "^8.4" } }, + "plugins/postcss-mixins": { + "name": "@csstools/postcss-mixins", + "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-nested-calc": { "name": "@csstools/postcss-nested-calc", "version": "5.0.0", diff --git a/plugin-packs/postcss-preset-env/package.json b/plugin-packs/postcss-preset-env/package.json index 04598c391..351d3c888 100644 --- a/plugin-packs/postcss-preset-env/package.json +++ b/plugin-packs/postcss-preset-env/package.json @@ -90,7 +90,7 @@ "css-blank-pseudo": "^8.0.1", "css-has-pseudo": "^8.0.0", "css-prefers-color-scheme": "^11.0.0", - "cssdb": "^8.6.0", + "cssdb": "^8.7.0", "postcss-attribute-case-insensitive": "^8.0.0", "postcss-clamp": "^4.1.0", "postcss-color-functional-notation": "^8.0.0", diff --git a/plugins/postcss-mixins/.gitignore b/plugins/postcss-mixins/.gitignore new file mode 100644 index 000000000..e5b28db4a --- /dev/null +++ b/plugins/postcss-mixins/.gitignore @@ -0,0 +1,6 @@ +node_modules +package-lock.json +yarn.lock +*.result.css +*.result.css.map +*.result.html diff --git a/plugins/postcss-mixins/.nvmrc b/plugins/postcss-mixins/.nvmrc new file mode 100644 index 000000000..28d6ff1c8 --- /dev/null +++ b/plugins/postcss-mixins/.nvmrc @@ -0,0 +1 @@ +v25.1.0 diff --git a/plugins/postcss-mixins/CHANGELOG.md b/plugins/postcss-mixins/CHANGELOG.md new file mode 100644 index 000000000..1aab830c3 --- /dev/null +++ b/plugins/postcss-mixins/CHANGELOG.md @@ -0,0 +1,5 @@ +# Changes to PostCSS Mixins + +### Unreleased (major) + +- Initial version diff --git a/plugins/postcss-mixins/INSTALL.md b/plugins/postcss-mixins/INSTALL.md new file mode 100644 index 000000000..84a2922e1 --- /dev/null +++ b/plugins/postcss-mixins/INSTALL.md @@ -0,0 +1,235 @@ +# Installing PostCSS Mixins + +[PostCSS Mixins] 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 Mixins] to your project: + +```bash +npm install postcss @csstools/postcss-mixins --save-dev +``` + +Use it as a [PostCSS] plugin: + +```js +// commonjs +const postcss = require('postcss'); +const postcssMixins = require('@csstools/postcss-mixins'); + +postcss([ + postcssMixins(/* pluginOptions */) +]).process(YOUR_CSS /*, processOptions */); +``` + +```js +// esm +import postcss from 'postcss'; +import postcssMixins from '@csstools/postcss-mixins'; + +postcss([ + postcssMixins(/* pluginOptions */) +]).process(YOUR_CSS /*, processOptions */); +``` + +## PostCSS CLI + +Add [PostCSS CLI] to your project: + +```bash +npm install postcss-cli @csstools/postcss-mixins --save-dev +``` + +Use [PostCSS Mixins] in your `postcss.config.js` configuration file: + +```js +const postcssMixins = require('@csstools/postcss-mixins'); + +module.exports = { + plugins: [ + postcssMixins(/* pluginOptions */) + ] +} +``` + +## PostCSS Load Config + +If your framework/CLI supports [`postcss-load-config`](https://github.com/postcss/postcss-load-config). + +```bash +npm install @csstools/postcss-mixins --save-dev +``` + +`package.json`: + +```json +{ + "postcss": { + "plugins": { + "@csstools/postcss-mixins": {} + } + } +} +``` + +`.postcssrc.json`: + +```json +{ + "plugins": { + "@csstools/postcss-mixins": {} + } +} +``` + +_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-mixins --save-dev +``` + +Use [PostCSS Mixins] 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-mixins", + { + // 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-mixins --save-dev +``` + +Use [PostCSS Mixins] in your `postcss.config.json` file: + +```json +{ + "plugins": [ + "@csstools/postcss-mixins" + ] +} +``` + +```json5 +{ + "plugins": [ + [ + "@csstools/postcss-mixins", + { + // Optionally add plugin options + } + ] + ] +} +``` + +## Gulp + +Add [Gulp PostCSS] to your project: + +```bash +npm install gulp-postcss @csstools/postcss-mixins --save-dev +``` + +Use [PostCSS Mixins] in your Gulpfile: + +```js +const postcss = require('gulp-postcss'); +const postcssMixins = require('@csstools/postcss-mixins'); + +gulp.task('css', function () { + var plugins = [ + postcssMixins(/* 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-mixins --save-dev +``` + +Use [PostCSS Mixins] in your Gruntfile: + +```js +const postcssMixins = require('@csstools/postcss-mixins'); + +grunt.loadNpmTasks('grunt-postcss'); + +grunt.initConfig({ + postcss: { + options: { + processors: [ + postcssMixins(/* 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 Mixins]: https://github.com/csstools/postcss-plugins/tree/main/plugins/postcss-mixins +[Next.js]: https://nextjs.org diff --git a/plugins/postcss-mixins/LICENSE.md b/plugins/postcss-mixins/LICENSE.md new file mode 100644 index 000000000..e8ae93b9f --- /dev/null +++ b/plugins/postcss-mixins/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-mixins/README.md b/plugins/postcss-mixins/README.md new file mode 100644 index 000000000..c4b81f603 --- /dev/null +++ b/plugins/postcss-mixins/README.md @@ -0,0 +1,106 @@ +# PostCSS Mixins [PostCSS Logo][PostCSS] + +[npm version][npm-url] [Build Status][cli-url] [Discord][discord]

[Baseline Status][css-url] [CSS Standard Status][css-url] + +```bash +npm install @csstools/postcss-mixins --save-dev +``` + +[PostCSS Mixins] lets you use `@mixin` and `@apply` following [CSS Mixins 1]. + +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 +- layered `@mixin` declarations +- mixin overrides + +```css +@mixin --foo() { + color: green; +} + +.foo { + @apply --foo; +} + +/* becomes */ + +.foo { + color: green; +} +``` + +## Usage + +Add [PostCSS Mixins] to your project: + +```bash +npm install postcss @csstools/postcss-mixins --save-dev +``` + +Use it as a [PostCSS] plugin: + +```js +const postcss = require('postcss'); +const postcssMixins = require('@csstools/postcss-mixins'); + +postcss([ + postcssMixins(/* pluginOptions */) +]).process(YOUR_CSS /*, processOptions */); +``` + +[PostCSS Mixins] 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) + +## Options + +### preserve + +The `preserve` option determines whether the original notation +is preserved. By default, it is not preserved. + +```js +postcssMixins({ preserve: true }) +``` + +```css +@mixin --foo() { + color: green; +} + +.foo { + @apply --foo; +} + +/* becomes */ + +@mixin --foo() { + color: green; +} + +.foo { + color: green; + @apply --foo; +} +``` + +[cli-url]: https://github.com/csstools/postcss-plugins/actions/workflows/test.yml?query=workflow/test +[css-url]: https://cssdb.org/#mixins +[discord]: https://discord.gg/bUadyRwkJS +[npm-url]: https://www.npmjs.com/package/@csstools/postcss-mixins + +[PostCSS]: https://github.com/postcss/postcss +[PostCSS Mixins]: https://github.com/csstools/postcss-plugins/tree/main/plugins/postcss-mixins +[CSS Mixins 1]: https://drafts.csswg.org/css-mixins/#mixin-rule diff --git a/plugins/postcss-mixins/api-extractor.json b/plugins/postcss-mixins/api-extractor.json new file mode 100644 index 000000000..42058be51 --- /dev/null +++ b/plugins/postcss-mixins/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-mixins/dist/index.d.ts b/plugins/postcss-mixins/dist/index.d.ts new file mode 100644 index 000000000..9d1b1b2f3 --- /dev/null +++ b/plugins/postcss-mixins/dist/index.d.ts @@ -0,0 +1,13 @@ +import type { PluginCreator } from 'postcss'; + +declare const creator: PluginCreator; +export default creator; +export { creator as 'module.exports' } + +/** postcss-mixins plugin options */ +export declare type pluginOptions = { + /** Preserve the original notation. default: false */ + preserve?: boolean; +}; + +export { } diff --git a/plugins/postcss-mixins/dist/index.mjs b/plugins/postcss-mixins/dist/index.mjs new file mode 100644 index 000000000..37805817a --- /dev/null +++ b/plugins/postcss-mixins/dist/index.mjs @@ -0,0 +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"}; diff --git a/plugins/postcss-mixins/docs/README.md b/plugins/postcss-mixins/docs/README.md new file mode 100644 index 000000000..a02389150 --- /dev/null +++ b/plugins/postcss-mixins/docs/README.md @@ -0,0 +1,64 @@ + + + + + + + + + + + + + + + + + +
+ +[] lets you use `@mixin` and `@apply` following [CSS Mixins 1]. + +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 +- layered `@mixin` declarations +- mixin overrides + +```css + + +/* becomes */ + + +``` + + + + + +## Options + +### preserve + +The `preserve` option determines whether the original notation +is preserved. By default, it is not preserved. + +```js +({ preserve: true }) +``` + +```css + + +/* becomes */ + + +``` + + +[CSS Mixins 1]: diff --git a/plugins/postcss-mixins/package.json b/plugins/postcss-mixins/package.json new file mode 100644 index 000000000..d69ef4cf8 --- /dev/null +++ b/plugins/postcss-mixins/package.json @@ -0,0 +1,80 @@ +{ + "name": "@csstools/postcss-mixins", + "description": "Use mixins in CSS", + "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-mixins#readme", + "repository": { + "type": "git", + "url": "git+https://github.com/csstools/postcss-plugins.git", + "directory": "plugins/postcss-mixins" + }, + "bugs": "https://github.com/csstools/postcss-plugins/issues", + "keywords": [ + "postcss-plugin" + ], + "csstools": { + "cssdbId": "mixins", + "exportName": "postcssMixins", + "humanReadableName": "PostCSS Mixins", + "specUrl": "https://drafts.csswg.org/css-mixins/#mixin-rule" + }, + "volta": { + "extends": "../../package.json" + } +} diff --git a/plugins/postcss-mixins/src/index.ts b/plugins/postcss-mixins/src/index.ts new file mode 100644 index 000000000..f3623a6bf --- /dev/null +++ b/plugins/postcss-mixins/src/index.ts @@ -0,0 +1,80 @@ +import type { AtRule, Plugin, PluginCreator } from 'postcss'; +import { IS_APPLY_REGEX, processableApplyRule } from './processable-apply'; +import { processableMixinRule } from './processable-mixin'; + +/** postcss-mixins plugin options */ +export type pluginOptions = { + /** Preserve the original notation. default: false */ + preserve?: boolean, +}; + +const creator: PluginCreator = (opts?: pluginOptions) => { + const options: pluginOptions = Object.assign( + // Default options + { + preserve: false, + }, + // Provided options + opts, + ); + + return { + postcssPlugin: 'postcss-mixins', + prepare(): Plugin { + const mixins: Map = new Map(); + const knownMixins: Set = new Set(); + + return { + postcssPlugin: 'mixins', + Once(root): void { + root.each((child) => { + if (child.type !== 'atrule') { + return; + } + + 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(); + } + + root.walkAtRules(IS_APPLY_REGEX, (atRule) => { + const mixinName = processableApplyRule(atRule); + if (!mixinName) { + return; + } + + const mixin = mixins.get(mixinName); + if (!mixin || !mixin.nodes) { + return; + } + + mixin.each((mixinNode) => { + atRule.before(mixinNode.clone()); + }); + + if (!options.preserve) atRule.remove(); + }); + }, + }; + }, + }; +}; + +creator.postcss = true; + +export default creator; +export { creator as 'module.exports' }; diff --git a/plugins/postcss-mixins/src/processable-apply.ts b/plugins/postcss-mixins/src/processable-apply.ts new file mode 100644 index 000000000..b6f8a269e --- /dev/null +++ b/plugins/postcss-mixins/src/processable-apply.ts @@ -0,0 +1,57 @@ +import { isFunctionNode, isTokenNode, parseComponentValue } from "@csstools/css-parser-algorithms"; +import { isTokenIdent, tokenize } from "@csstools/css-tokenizer"; +import type { AtRule } from "postcss"; + +export const IS_APPLY_REGEX = /^apply$/i; + +export function processableApplyRule(atRule: AtRule): false|string { + if (!atRule.params || !atRule.params.includes('--')) { + return false; + } + + if (!isInStyleRule(atRule)) { + return false; + } + + // TODO: support @contents + if (atRule.nodes?.length) { + return false; + } + + const nameNode = parseComponentValue(tokenize({ + css: atRule.params, + })); + if (isTokenNode(nameNode) && isTokenIdent(nameNode.value)) { + return nameNode.value[4].value; + } + + if (!isFunctionNode(nameNode)) { + return false; + } + + // TODO: support arguments + if (nameNode.value.length) { + return false; + } + + return nameNode.getName(); +} + +const IS_SCOPE_REGEX = /^scope$/i; + +function isInStyleRule(atRule: AtRule): boolean { + const parent = atRule.parent; + if (!parent || parent.type === 'root') { + return false; + } + + if (parent.type === 'rule') { + return true; + } + + if (parent.type === 'atrule' && IS_SCOPE_REGEX.test(parent.name)) { + return true; + } + + return isInStyleRule(parent); +} diff --git a/plugins/postcss-mixins/src/processable-mixin.ts b/plugins/postcss-mixins/src/processable-mixin.ts new file mode 100644 index 000000000..b70572f4e --- /dev/null +++ b/plugins/postcss-mixins/src/processable-mixin.ts @@ -0,0 +1,50 @@ +import { isFunctionNode, parseComponentValue } from "@csstools/css-parser-algorithms"; +import { tokenize } from "@csstools/css-tokenizer"; +import type { AtRule } from "postcss"; + +const IS_IGNORED_CHILD_RULE = /^(?:apply|contents|result)$/i; + +export function processableMixinRule(atRule: AtRule): false | string { + if (atRule.name.toLowerCase() !== 'mixin') { + return false; + } + + if (!atRule.params || !atRule.params.includes('--')) { + return false; + } + + if (!atRule.nodes?.length) { + return false; + } + + // TODO: support conditional @mixin declarations + if (atRule.parent !== atRule.root()) { + return false; + } + + const nameNode = parseComponentValue(tokenize({ + css: atRule.params, + })); + if (!isFunctionNode(nameNode)) { + return false; + } + + if (nameNode.value.length) { + 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; + } + }); + + if (hasNestedApplyOrContents) { + return false; + } + + return nameNode.getName(); +} diff --git a/plugins/postcss-mixins/test/_import.mjs b/plugins/postcss-mixins/test/_import.mjs new file mode 100644 index 000000000..b1651ee6c --- /dev/null +++ b/plugins/postcss-mixins/test/_import.mjs @@ -0,0 +1,10 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import plugin from '@csstools/postcss-mixins'; + +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-mixins/test/_require.cjs b/plugins/postcss-mixins/test/_require.cjs new file mode 100644 index 000000000..6ef9bff19 --- /dev/null +++ b/plugins/postcss-mixins/test/_require.cjs @@ -0,0 +1,9 @@ +const assert = require('node:assert/strict'); +const test = require('node:test'); +const plugin = require('@csstools/postcss-mixins'); + +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-mixins/test/_tape.mjs b/plugins/postcss-mixins/test/_tape.mjs new file mode 100644 index 000000000..832ea5c67 --- /dev/null +++ b/plugins/postcss-mixins/test/_tape.mjs @@ -0,0 +1,36 @@ +import { postcssTape } from '@csstools/postcss-tape'; +import plugin from '@csstools/postcss-mixins'; + +postcssTape(plugin)({ + basic: { + message: 'supports basic usage', + }, + 'basic:preserve-true': { + message: 'supports basic usage with { preserve: true }', + options: { + preserve: true, + }, + }, + ignore: { + message: 'ignores invalid or unsupported behavior', + expect: 'ignore.css', + result: 'ignore.css', + }, + 'ignore:preserve-true': { + message: 'ignores invalid or unsupported behavior with { preserve: true }', + expect: 'ignore.css', + result: 'ignore.css', + options: { + preserve: true, + }, + }, + 'examples/example': { + message: 'minimal example', + }, + 'examples/example:preserve-true': { + message: 'minimal example', + options: { + preserve: true, + }, + }, +}); diff --git a/plugins/postcss-mixins/test/basic.css b/plugins/postcss-mixins/test/basic.css new file mode 100644 index 000000000..3217680dc --- /dev/null +++ b/plugins/postcss-mixins/test/basic.css @@ -0,0 +1,31 @@ +@mixin --foo() { + color: green; +} + +.foo { + @apply --foo; +} + +@scope (.foo) { + @apply --foo(); +} + +@mixin --bar() { + color: green; + + @media screen { + color: blue; + } +} + +.bar { + @apply --bar; +} + +.in-between-other-rules { + order: 0; + + @apply --bar; + + order: 1; +} diff --git a/plugins/postcss-mixins/test/basic.expect.css b/plugins/postcss-mixins/test/basic.expect.css new file mode 100644 index 000000000..73878cce6 --- /dev/null +++ b/plugins/postcss-mixins/test/basic.expect.css @@ -0,0 +1,26 @@ +.foo { + color: green; +} + +@scope (.foo) { + color: green; +} + +.bar { + color: green; + + @media screen { + color: blue; + } +} + +.in-between-other-rules { + order: 0; + color: green; + + @media screen { + color: blue; + } + + order: 1; +} diff --git a/plugins/postcss-mixins/test/basic.preserve-true.expect.css b/plugins/postcss-mixins/test/basic.preserve-true.expect.css new file mode 100644 index 000000000..3900c04f8 --- /dev/null +++ b/plugins/postcss-mixins/test/basic.preserve-true.expect.css @@ -0,0 +1,43 @@ +@mixin --foo() { + color: green; +} + +.foo { + color: green; + @apply --foo; +} + +@scope (.foo) { + color: green; + @apply --foo(); +} + +@mixin --bar() { + color: green; + + @media screen { + color: blue; + } +} + +.bar { + color: green; + + @media screen { + color: blue; + } + @apply --bar; +} + +.in-between-other-rules { + order: 0; + color: green; + + @media screen { + color: blue; + } + + @apply --bar; + + order: 1; +} diff --git a/plugins/postcss-mixins/test/examples/example.css b/plugins/postcss-mixins/test/examples/example.css new file mode 100644 index 000000000..619c5b1ee --- /dev/null +++ b/plugins/postcss-mixins/test/examples/example.css @@ -0,0 +1,7 @@ +@mixin --foo() { + color: green; +} + +.foo { + @apply --foo; +} diff --git a/plugins/postcss-mixins/test/examples/example.expect.css b/plugins/postcss-mixins/test/examples/example.expect.css new file mode 100644 index 000000000..79d060850 --- /dev/null +++ b/plugins/postcss-mixins/test/examples/example.expect.css @@ -0,0 +1,3 @@ +.foo { + color: green; +} diff --git a/plugins/postcss-mixins/test/examples/example.preserve-true.expect.css b/plugins/postcss-mixins/test/examples/example.preserve-true.expect.css new file mode 100644 index 000000000..230ea16b6 --- /dev/null +++ b/plugins/postcss-mixins/test/examples/example.preserve-true.expect.css @@ -0,0 +1,8 @@ +@mixin --foo() { + color: green; +} + +.foo { + color: green; + @apply --foo; +} diff --git a/plugins/postcss-mixins/test/ignore.css b/plugins/postcss-mixins/test/ignore.css new file mode 100644 index 000000000..9791ec18c --- /dev/null +++ b/plugins/postcss-mixins/test/ignore.css @@ -0,0 +1,79 @@ +@mixin --ignore-a() { + color: green; +} + +@mixin --ignore-a() { + color: green; +} + +.ignore-a { + @apply --ignore-a; +} + +@mixin --ignore-b { + color: green; +} + +.ignore-b { + @apply --ignore-b; +} + +@mixin --ignore-c { + @apply --foo; +} + +.ignore-c { + @apply --ignore-c; +} + +@mixin --ignore-d { + @contents; +} + +.ignore-d { + @apply --ignore-d; +} + +@mixin --ignore-e { + color: red; +} + +.ignore-e { + @apply --ignore-e { + color: red; + } +} + +@media screen { + @mixin --ignore-f { + color: red; + } +} + +.ignore-f { + @apply --ignore-f; +} + +@mixin --ignore-g { + color: red; +} + +@apply --ignore-g; + +@mixin --ignore-h { + color: red; +} + +@media screen { + @apply --ignore-h; +} + +@mixin --ignore-i() { + @result { + color: green; + } +} + +.ignore-i { + @apply --ignore-i; +} diff --git a/plugins/postcss-mixins/tsconfig.json b/plugins/postcss-mixins/tsconfig.json new file mode 100644 index 000000000..500af6d26 --- /dev/null +++ b/plugins/postcss-mixins/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "dist", + "declarationDir": ".", + "strict": true + }, + "include": ["./src/**/*"], + "exclude": ["dist"] +} diff --git a/sites/package-lock.json b/sites/package-lock.json index 71c260822..614402cf5 100644 --- a/sites/package-lock.json +++ b/sites/package-lock.json @@ -5573,9 +5573,9 @@ } }, "node_modules/cssdb": { - "version": "8.6.0", - "resolved": "https://registry.npmjs.org/cssdb/-/cssdb-8.6.0.tgz", - "integrity": "sha512-7ZrRi/Z3cRL1d5I8RuXEWAkRFP3J4GeQRiyVknI4KC70RAU8hT4LysUZDe0y+fYNOktCbxE8sOPUOhyR12UqGQ==", + "version": "8.7.0", + "resolved": "https://registry.npmjs.org/cssdb/-/cssdb-8.7.0.tgz", + "integrity": "sha512-UxiWVpV953ENHqAKjKRPZHNDfRo3uOymvO5Ef7MFCWlenaohkYj7PTO7WCBdjZm8z/aDZd6rXyUIlwZ0AjyFSg==", "dev": true, "funding": [ { @@ -12583,7 +12583,7 @@ "@rollup/plugin-node-resolve": "^16.0.3", "@rollup/plugin-terser": "^0.4.4", "codemirror": "^6.0.2", - "cssdb": "^8.4.2", + "cssdb": "^8.7.0", "luxon": "^3.7.2", "markdown-it": "^14.1.0", "npm-run-all": "^4.1.5", diff --git a/sites/postcss-preset-env/package.json b/sites/postcss-preset-env/package.json index a57371228..34adf68db 100644 --- a/sites/postcss-preset-env/package.json +++ b/sites/postcss-preset-env/package.json @@ -73,7 +73,7 @@ "@rollup/plugin-node-resolve": "^16.0.3", "@rollup/plugin-terser": "^0.4.4", "codemirror": "^6.0.2", - "cssdb": "^8.4.2", + "cssdb": "^8.7.0", "luxon": "^3.7.2", "markdown-it": "^14.1.0", "npm-run-all": "^4.1.5",