├── .editorconfig ├── .github └── workflows │ └── test.yml ├── .gitignore ├── .husky └── pre-commit ├── .prettierrc ├── .vscode └── launch.json ├── LICENSE ├── README.md ├── examples ├── README.md ├── fuse-box │ ├── fuse.js │ ├── package.json │ ├── postcss.config.js │ └── src │ │ ├── example.css │ │ └── example.js ├── grunt │ ├── Gruntfile.js │ ├── package.json │ ├── postcss.config.js │ └── src │ │ └── example.css ├── gulp │ ├── gulpfile.js │ ├── package.json │ ├── postcss.config.js │ └── src │ │ └── example.css ├── parcel │ ├── package.json │ ├── parcel.js │ ├── postcss.config.js │ └── src │ │ ├── example.css │ │ └── example.js ├── postcss-cli │ ├── package.json │ ├── postcss.config.js │ └── src │ │ └── example.css ├── snowpack │ ├── package.json │ ├── postcss.config.js │ ├── snowpack.config.mjs │ └── src │ │ └── example.css ├── vite │ ├── package.json │ ├── postcss.config.js │ ├── src │ │ ├── example.css │ │ └── example.js │ └── vite.config.js └── webpack │ ├── package.json │ ├── postcss.config.js │ ├── src │ ├── example.css │ └── example.js │ └── webpack.config.js ├── index.ts ├── jest.config.js ├── package-lock.json ├── package.json ├── subsequent-plugins.ts ├── test ├── data │ ├── entry-example.css │ ├── entry-example.namespace.css │ ├── example.css │ ├── name-example.css │ └── nested │ │ └── name-example.css └── options.test.ts ├── tsconfig.json └── types.ts /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | end_of_line = lf 5 | indent_style = space 6 | indent_size = 2 7 | insert_final_newline = true 8 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - master 7 | 8 | jobs: 9 | test: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v4 13 | - uses: actions/setup-node@v4 14 | with: 15 | node-version: lts/* 16 | - run: npm ci 17 | - run: npm test 18 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # general 2 | .DS_Store 3 | node_modules 4 | .fusebox 5 | .cache 6 | dist 7 | 8 | # prefer npm 9 | yarn.lock 10 | 11 | # exclude unit testing output 12 | test/output 13 | 14 | # exclude examples generated output 15 | examples/*/dist 16 | examples/*/package-lock.json 17 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | npx pretty-quick --staged 5 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "endOfLine": "lf", 3 | "useTabs": false, 4 | "tabWidth": 2, 5 | "singleQuote": true 6 | } 7 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Verwendet IntelliSense zum Ermitteln möglicher Attribute. 3 | // Zeigen Sie auf vorhandene Attribute, um die zugehörigen Beschreibungen anzuzeigen. 4 | // Weitere Informationen finden Sie unter https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "type": "node", 9 | "request": "launch", 10 | "name": "run test", 11 | "console": "internalConsole", 12 | "program": "${workspaceFolder}/node_modules/.bin/jest", 13 | "args": ["--openHandlesTimeout", "60000"] 14 | }, 15 | { 16 | "type": "node", 17 | "request": "launch", 18 | "name": "example: gulp", 19 | "program": "${workspaceFolder}/examples/gulp/node_modules/.bin/gulp", 20 | "cwd": "${workspaceFolder}/examples/gulp" 21 | } 22 | ] 23 | } 24 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # postcss-extract-media-query 2 | 3 | If page speed is important to you chances are high you're already doing code splitting. If your CSS is built mobile-first (in particular if using a framework such as [Bootstrap](https://getbootstrap.com/) or [Foundation](https://get.foundation/sites.html)) chances are also high you're loading more CSS than the current viewport actually needs. 4 | 5 | It would be much better if a mobile user doesn't need to load desktop specific CSS, wouldn't it? 6 | 7 | That's the use case I've written this PostCSS plugin for! It lets you extract all `@media` rules from your CSS and emit them as separate files which you can dynamically import based on the user's viewport (recommended) or load with lower priority (less performance gain) as `` 8 | 9 | **Before** 10 | 11 | - example.css 12 | 13 | ```css 14 | .foo { 15 | color: red; 16 | } 17 | @media screen and (min-width: 1024px) { 18 | .foo { 19 | color: green; 20 | } 21 | } 22 | .bar { 23 | font-size: 1rem; 24 | } 25 | @media screen and (min-width: 1024px) { 26 | .bar { 27 | font-size: 2rem; 28 | } 29 | } 30 | ``` 31 | 32 | **After** 33 | 34 | - example.css 35 | 36 | ```css 37 | .foo { 38 | color: red; 39 | } 40 | .bar { 41 | font-size: 1rem; 42 | } 43 | ``` 44 | 45 | - example-desktop.css 46 | 47 | ```css 48 | @media screen and (min-width: 1024px) { 49 | .foo { 50 | color: green; 51 | } 52 | .bar { 53 | font-size: 2rem; 54 | } 55 | } 56 | ``` 57 | 58 | ```javascript 59 | // simple example for dynamically loading desktop specific CSS based on viewport width 60 | if (window.innerWidth >= 1024) { 61 | const link = document.createElement('link'); 62 | link.rel = 'stylesheet'; 63 | link.href = '/assets/css/example-desktop.css'; 64 | document.head.append(link); 65 | } 66 | ``` 67 | 68 | ## Installation 69 | 70 | - npm 71 | 72 | ```bash 73 | npm install postcss postcss-extract-media-query --save-dev 74 | ``` 75 | 76 | - yarn 77 | 78 | ```bash 79 | yarn add postcss postcss-extract-media-query --dev 80 | ``` 81 | 82 | ## Usage 83 | 84 | Simply add the plugin to your PostCSS config. If you're not familiar with using PostCSS you should read the official [PostCSS documentation](https://github.com/postcss/postcss#usage) first. 85 | 86 | You can find complete examples here. 87 | 88 | ## Options 89 | 90 | | option | default | 91 | | ----------- | ---------------------------- | 92 | | output.path | path.join(\_\_dirname, '..') | 93 | | output.name | '[name]-[query].[ext]' | 94 | | queries | {} | 95 | | extractAll | true | 96 | | stats | true | 97 | | entry | null | 98 | | src.path | null | 99 | 100 | ### output 101 | 102 | By default the plugin will emit the extracted CSS files to your root folder. If you want to change this you have to define an **absolute** path for `output.path`. 103 | 104 | Apart from that you can customize the emitted filenames by using `output.name`. `[path]` is the relative path of the original CSS file relative to root, `[name]` is the filename of the original CSS file, `[query]` the key of the extracted media query and `[ext]` the original file extension (mostly `css`). Those three placeholders get replaced by the plugin later. 105 | 106 | > :warning: by emitting files itself the plugin breaks out of your bundler / task runner context meaning all your other loaders / pipes won't get applied to the extracted files! 107 | 108 | ```javascript 109 | 'postcss-extract-media-query': { 110 | output: { 111 | path: path.join(__dirname, 'dist'), // emit to 'dist' folder in root 112 | name: '[name]-[query].[ext]' // pattern of emitted files 113 | } 114 | } 115 | ``` 116 | 117 | By default the plugin flattens the file structure meaning the original folder structure won't be preserved which might be problem if you're using the same file name across multiple folders. This can easily be fixed with the `[path]` placeholder 118 | 119 | ``` 120 | name: '[path]/[name]-[query].[ext]' 121 | ``` 122 | 123 | In rare cases where you need more control, you may pass a name function which receive all placeholders as args 124 | 125 | ``` 126 | name: ({ path, name, query, ext }) => { 127 | return `example/${name}-${query}.${ext}` 128 | } 129 | ``` 130 | 131 | ### queries 132 | 133 | By default the params of the extracted media query is converted to kebab case and taken as key (e.g. `screen-and-min-width-1024-px`). You can change this by defining a certain name for a certain match. Make sure it **exactly** matches the params (see example below). 134 | 135 | ```javascript 136 | 'postcss-extract-media-query': { 137 | queries: { 138 | 'screen and (min-width: 1024px)': 'desktop' 139 | } 140 | } 141 | ``` 142 | 143 | ### extractAll 144 | 145 | By default the plugin extracts all media queries into separate files. If you want it to only extract the ones you've defined a certain name for (see `queries` option) you have to set this option `false`. This ignores all media queries that don't have a custom name defined. 146 | 147 | ```javascript 148 | 'postcss-extract-media-query': { 149 | extractAll: false 150 | } 151 | ``` 152 | 153 | ### stats 154 | 155 | By default the plugin displays in your terminal / command prompt which files have been emitted. If you don't want to see it just set this option `false`. 156 | 157 | ```javascript 158 | 'postcss-extract-media-query': { 159 | stats: true 160 | } 161 | ``` 162 | 163 | ### entry 164 | 165 | By default the plugin uses the `from` value from the options of the loader or of the options you define in `postcss().process(css, { from: ... })`. Usually you don't need to change it but if you have to (e.g. when using the plugin standalone) you can define an **absolute** file path as entry. 166 | 167 | ```javascript 168 | 'postcss-extract-media-query': { 169 | entry: path.join(__dirname, 'some/path/example.css') 170 | } 171 | ``` 172 | 173 | ### src 174 | 175 | > [!NOTE] 176 | > This option is only relevant if you're using the `path` placeholder in `output.name` 177 | 178 | By default the plugin determines the root by looking for the package.json file and uses it as srcPath (if there's no app or src folder) to compute the relative path. 179 | 180 | In case the automatically determined srcPath doesn't suit you, it's possible to override it with this option. 181 | 182 | ```javascript 183 | 'postcss-extract-media-query': { 184 | output: { 185 | path: path.join(__dirname, 'dist'), 186 | name: '[path]/[name]-[query].[ext]' 187 | }, 188 | src: { 189 | // from: example/nested/src/components/button.css 190 | // to: dist/components/button-xxxxx.css 191 | path: path.join(__dirname, 'example/nested/src') 192 | } 193 | } 194 | ``` 195 | 196 | ### config 197 | 198 | By default the plugin looks for a `postcss.config.js` file in your project's root (read [node-app-root-path](https://github.com/inxilpro/node-app-root-path) to understand how root is determined) and tries to apply all subsequent PostCSS plugins to the extracted CSS. 199 | 200 | In case this lookup doesn't suite you it's possible to specify the config path yourself. 201 | 202 | ```javascript 203 | 'postcss-extract-media-query': { 204 | config: path.join(__dirname, 'some/path/postcss.config.js') 205 | } 206 | ``` 207 | 208 | It's also possible to pass the config as object to avoid any file resolution. 209 | 210 | ```javascript 211 | 'postcss-extract-media-query': { 212 | config: { 213 | plugins: { 214 | 'postcss-extract-media-query': {} 215 | 'cssnano': {} 216 | } 217 | } 218 | } 219 | ``` 220 | 221 | ## Migration 222 | 223 | ### coming from 2.x 224 | 225 | PostCSS has been updated to 8.x (which is the minimum required version now) and is no longer packaged with this plugin but has become a peer dependency. 226 | What does this mean for you? If you're using npm >= v7 it's automatically installed, otherwise you need to install it yourself. 227 | 228 | ```bash 229 | npm install postcss --save-dev 230 | ``` 231 | 232 | ### coming from 1.x 233 | 234 | Both options, `combine` and `minimize`, have been removed in v2 because the plugin parses your `postcss.config.js` now and applies all subsequent plugins to the extracted files as well. 235 | 236 | So if you have used them you simply need to install appropriate PostCSS plugins (see below for example) and add them to your PostCSS config. 237 | 238 | ```bash 239 | npm install postcss postcss-combine-media-query cssnano --save-dev 240 | ``` 241 | 242 | ```javascript 243 | plugins: { 244 | 'postcss-combine-media-query': {}, 245 | 'postcss-extract-media-query': {}, 246 | 'cssnano': {}, 247 | } 248 | ``` 249 | 250 | ### plugin authors 251 | 252 | If you're using this plugin via the api (e.g. for your own plugin) you should note it has changed from sync to async in v2. This was necessary in the course of going with promises. I'm not going to keep support of the sync api because it would make the code more complex than necessary and it's officially recommended to use async. Please check the tests to see how it has to be done now! 253 | 254 | ## Webpack User? 255 | 256 | If you're using webpack you should use [media-query-plugin](https://github.com/SassNinja/media-query-plugin) which is built for webpack only and thus comes with several advantages such as applying all other loaders you've defined and hash support for caching. 257 | 258 | ## Credits 259 | 260 | If this plugin is helpful to you it'll be great when you give me a star on github and share it. Keeps me motivated to continue the development. 261 | -------------------------------------------------------------------------------- /examples/README.md: -------------------------------------------------------------------------------- 1 | # Complete Examples 2 | 3 | In the following you can find complete examples for some bundlers / task runner. Just run `npm install` within the appropriate example folder. 4 | 5 | **Please note:** these examples are only supposed to show how to use the plugin. They are not meant to be used for your project's assets management (without further modification). 6 | 7 | - [Webpack](webpack/) 8 | - [Snowpack](snowpack/) 9 | - [Vite](vite/) 10 | - [FuseBox](fuse-box/) 11 | - [Parcel](parcel/) 12 | - [Gulp](gulp/) 13 | - [Grunt](grunt/) 14 | - [CLI](postcss-cli) 15 | -------------------------------------------------------------------------------- /examples/fuse-box/fuse.js: -------------------------------------------------------------------------------- 1 | const extractMediaQuery = require('postcss-extract-media-query'); 2 | const extractMediaQueryConfig = 3 | require('./postcss.config').plugins['postcss-extract-media-query']; 4 | 5 | const { FuseBox, PostCSSPlugin, CSSPlugin } = require('fuse-box'); 6 | const { src, task, exec, context } = require('fuse-box/sparky'); 7 | 8 | context( 9 | class { 10 | getConfig() { 11 | return FuseBox.init({ 12 | homeDir: 'src', 13 | output: 'dist/$name.js', 14 | target: 'browser@es2015', 15 | ensureTsConfig: false, 16 | plugins: [ 17 | [ 18 | PostCSSPlugin([extractMediaQuery(extractMediaQueryConfig)]), 19 | CSSPlugin({ 20 | outFile: (file) => `dist/${file}`, 21 | }), 22 | ], 23 | ], 24 | }); 25 | } 26 | } 27 | ); 28 | 29 | task('clean', async (context) => { 30 | await src('./dist').clean('dist/').exec(); 31 | }); 32 | 33 | task('default', ['clean'], async (context) => { 34 | const fuse = context.getConfig(); 35 | fuse.bundle('example').instructions('> example.js'); 36 | await fuse.run(); 37 | }); 38 | -------------------------------------------------------------------------------- /examples/fuse-box/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "fuse-box-example", 3 | "version": "1.0.0", 4 | "description": "Complete example for FuseBox.", 5 | "author": "Kai Falkowski", 6 | "license": "MIT", 7 | "main": "fuse.js", 8 | "scripts": { 9 | "start": "node fuse.js" 10 | }, 11 | "dependencies": {}, 12 | "devDependencies": { 13 | "fuse-box": "^3.2.2", 14 | "postcss": "^6.0.22", 15 | "postcss-extract-media-query": "2.x", 16 | "typescript": "^2.8.3", 17 | "uglify-es": "^3.3.9", 18 | "uglify-js": "^3.3.28" 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /examples/fuse-box/postcss.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | 3 | module.exports = { 4 | plugins: { 5 | 'postcss-extract-media-query': { 6 | output: { 7 | path: path.join(__dirname, 'dist'), 8 | }, 9 | queries: { 10 | 'screen and (min-width: 1024px)': 'desktop', 11 | }, 12 | }, 13 | }, 14 | }; 15 | -------------------------------------------------------------------------------- /examples/fuse-box/src/example.css: -------------------------------------------------------------------------------- 1 | .foo { 2 | color: red; 3 | } 4 | @media screen and (min-width: 1024px) { 5 | .foo { 6 | color: green; 7 | } 8 | } 9 | .bar { 10 | font-size: 1rem; 11 | } 12 | @media screen and (min-width: 1024px) { 13 | .bar { 14 | font-size: 2rem; 15 | } 16 | } 17 | .test { 18 | z-index: 1; 19 | } 20 | @media screen and (min-width: 1200px) { 21 | .test { 22 | z-index: 2; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /examples/fuse-box/src/example.js: -------------------------------------------------------------------------------- 1 | import './example.css'; 2 | 3 | console.log('Hello World'); 4 | -------------------------------------------------------------------------------- /examples/grunt/Gruntfile.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const extractMediaQuery = require('postcss-extract-media-query'); 3 | const extractMediaQueryConfig = 4 | require('./postcss.config').plugins['postcss-extract-media-query']; 5 | 6 | module.exports = function (grunt) { 7 | grunt.initConfig({ 8 | clean: [path.join(__dirname, 'dist/*')], 9 | postcss: { 10 | options: { 11 | processors: [extractMediaQuery(extractMediaQueryConfig)], 12 | }, 13 | dist: { 14 | src: path.join(__dirname, 'src/example.css'), 15 | dest: path.join(__dirname, 'dist/example.css'), 16 | }, 17 | }, 18 | }); 19 | 20 | grunt.loadNpmTasks('grunt-contrib-clean'); 21 | grunt.loadNpmTasks('grunt-postcss'); 22 | 23 | grunt.registerTask('default', ['clean', 'postcss']); 24 | }; 25 | -------------------------------------------------------------------------------- /examples/grunt/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "grunt-example", 3 | "version": "1.0.0", 4 | "description": "Complete example for grunt.", 5 | "author": "Kai Falkowski", 6 | "license": "MIT", 7 | "main": "Gruntfile.js", 8 | "scripts": { 9 | "start": "grunt" 10 | }, 11 | "dependencies": {}, 12 | "devDependencies": { 13 | "grunt": "^1.0.2", 14 | "grunt-contrib-clean": "^1.1.0", 15 | "grunt-postcss": "^0.9.0", 16 | "postcss-extract-media-query": "2.x" 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /examples/grunt/postcss.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | 3 | module.exports = { 4 | plugins: { 5 | 'postcss-extract-media-query': { 6 | output: { 7 | path: path.join(__dirname, 'dist'), 8 | }, 9 | queries: { 10 | 'screen and (min-width: 1024px)': 'desktop', 11 | }, 12 | }, 13 | }, 14 | }; 15 | -------------------------------------------------------------------------------- /examples/grunt/src/example.css: -------------------------------------------------------------------------------- 1 | .foo { 2 | color: red; 3 | } 4 | @media screen and (min-width: 1024px) { 5 | .foo { 6 | color: green; 7 | } 8 | } 9 | .bar { 10 | font-size: 1rem; 11 | } 12 | @media screen and (min-width: 1024px) { 13 | .bar { 14 | font-size: 2rem; 15 | } 16 | } 17 | .test { 18 | z-index: 1; 19 | } 20 | @media screen and (min-width: 1200px) { 21 | .test { 22 | z-index: 2; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /examples/gulp/gulpfile.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const gulp = require('gulp'); 3 | const $ = require('gulp-load-plugins')(); 4 | const extractMediaQuery = require('postcss-extract-media-query'); 5 | 6 | function clean() { 7 | return gulp.src(path.join(__dirname, 'dist/*')).pipe( 8 | $.deleteFile({ 9 | deleteMatch: true, 10 | }) 11 | ); 12 | } 13 | 14 | function css() { 15 | return gulp 16 | .src(path.join(__dirname, 'src/*.css')) 17 | .pipe($.postcss()) 18 | .pipe(gulp.dest(path.join(__dirname, 'dist'))); 19 | } 20 | 21 | gulp.task('default', gulp.series(clean, css)); 22 | -------------------------------------------------------------------------------- /examples/gulp/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "gulp-example", 3 | "version": "1.0.0", 4 | "description": "Complete example for gulp.", 5 | "author": "Kai Falkowski", 6 | "license": "MIT", 7 | "main": "gulpfile.js", 8 | "scripts": { 9 | "start": "gulp" 10 | }, 11 | "dependencies": {}, 12 | "devDependencies": { 13 | "gulp": "^4.0.0", 14 | "gulp-delete-file": "^1.0.2", 15 | "gulp-load-plugins": "^1.5.0", 16 | "gulp-postcss": "^7.0.1", 17 | "postcss-extract-media-query": "2.x" 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /examples/gulp/postcss.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | 3 | module.exports = { 4 | plugins: { 5 | 'postcss-extract-media-query': { 6 | output: { 7 | path: path.join(__dirname, 'dist'), 8 | }, 9 | queries: { 10 | 'screen and (min-width: 1024px)': 'desktop', 11 | }, 12 | }, 13 | }, 14 | }; 15 | -------------------------------------------------------------------------------- /examples/gulp/src/example.css: -------------------------------------------------------------------------------- 1 | .foo { 2 | color: red; 3 | } 4 | @media screen and (min-width: 1024px) { 5 | .foo { 6 | color: green; 7 | } 8 | } 9 | .bar { 10 | font-size: 1rem; 11 | } 12 | @media screen and (min-width: 1024px) { 13 | .bar { 14 | font-size: 2rem; 15 | } 16 | } 17 | .test { 18 | z-index: 1; 19 | } 20 | @media screen and (min-width: 1200px) { 21 | .test { 22 | z-index: 2; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /examples/parcel/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "parcel-example", 3 | "version": "1.0.0", 4 | "description": "Complete example for Parcel.", 5 | "author": "Kai Falkowski", 6 | "license": "MIT", 7 | "main": "parcel.js", 8 | "scripts": { 9 | "start": "node parcel.js" 10 | }, 11 | "dependencies": {}, 12 | "devDependencies": { 13 | "parcel-bundler": "^1.10.3", 14 | "postcss-extract-media-query": "2.x" 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /examples/parcel/parcel.js: -------------------------------------------------------------------------------- 1 | const parcel = require('parcel-bundler'); 2 | const path = require('path'); 3 | 4 | const file = path.join(__dirname, 'src/example.js'); 5 | 6 | const options = { 7 | watch: false, 8 | sourceMaps: false, 9 | }; 10 | 11 | const bundler = new parcel(file, options); 12 | 13 | bundler.bundle(); 14 | -------------------------------------------------------------------------------- /examples/parcel/postcss.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | 3 | module.exports = { 4 | plugins: { 5 | 'postcss-extract-media-query': { 6 | output: { 7 | path: path.join(__dirname, 'dist'), 8 | }, 9 | queries: { 10 | 'screen and (min-width: 1024px)': 'desktop', 11 | }, 12 | }, 13 | }, 14 | }; 15 | -------------------------------------------------------------------------------- /examples/parcel/src/example.css: -------------------------------------------------------------------------------- 1 | .foo { 2 | color: red; 3 | } 4 | @media screen and (min-width: 1024px) { 5 | .foo { 6 | color: green; 7 | } 8 | } 9 | .bar { 10 | font-size: 1rem; 11 | } 12 | @media screen and (min-width: 1024px) { 13 | .bar { 14 | font-size: 2rem; 15 | } 16 | } 17 | .test { 18 | z-index: 1; 19 | } 20 | @media screen and (min-width: 1200px) { 21 | .test { 22 | z-index: 2; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /examples/parcel/src/example.js: -------------------------------------------------------------------------------- 1 | import './example.css'; 2 | 3 | console.log('Hello World'); 4 | -------------------------------------------------------------------------------- /examples/postcss-cli/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "standalone-example", 3 | "version": "1.0.0", 4 | "description": "Complete example for using PostCSS cli.", 5 | "author": "Kai Falkowski", 6 | "license": "MIT", 7 | "main": "build.js", 8 | "scripts": { 9 | "start": "postcss src/*.css -d dist" 10 | }, 11 | "dependencies": {}, 12 | "devDependencies": { 13 | "postcss": "^8.3.8", 14 | "postcss-cli": "^9.0.1", 15 | "postcss-extract-media-query": "2.x" 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /examples/postcss-cli/postcss.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | 3 | module.exports = { 4 | plugins: { 5 | 'postcss-extract-media-query': { 6 | output: { 7 | path: path.join(__dirname, 'dist'), 8 | }, 9 | queries: { 10 | 'screen and (min-width: 1024px)': 'desktop', 11 | }, 12 | }, 13 | }, 14 | }; 15 | -------------------------------------------------------------------------------- /examples/postcss-cli/src/example.css: -------------------------------------------------------------------------------- 1 | .foo { 2 | color: red; 3 | } 4 | @media screen and (min-width: 1024px) { 5 | .foo { 6 | color: green; 7 | } 8 | } 9 | .bar { 10 | font-size: 1rem; 11 | } 12 | @media screen and (min-width: 1024px) { 13 | .bar { 14 | font-size: 2rem; 15 | } 16 | } 17 | .test { 18 | z-index: 1; 19 | } 20 | @media screen and (min-width: 1200px) { 21 | .test { 22 | z-index: 2; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /examples/snowpack/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "webpack-example", 3 | "version": "1.0.0", 4 | "description": "Complete example for snowpack.", 5 | "author": "Kai Falkowski", 6 | "license": "MIT", 7 | "main": "snowpack.config.mjs", 8 | "scripts": { 9 | "start": "snowpack build" 10 | }, 11 | "dependencies": {}, 12 | "devDependencies": { 13 | "@snowpack/plugin-postcss": "^1.4.3", 14 | "postcss": "^8.3.8", 15 | "postcss-extract-media-query": "2.x", 16 | "snowpack": "^3.8.8" 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /examples/snowpack/postcss.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | 3 | module.exports = { 4 | plugins: { 5 | 'postcss-extract-media-query': { 6 | output: { 7 | path: path.join(__dirname, 'dist'), 8 | }, 9 | queries: { 10 | 'screen and (min-width: 1024px)': 'desktop', 11 | }, 12 | }, 13 | }, 14 | }; 15 | -------------------------------------------------------------------------------- /examples/snowpack/snowpack.config.mjs: -------------------------------------------------------------------------------- 1 | export default { 2 | mount: { 3 | src: '/', 4 | }, 5 | buildOptions: { 6 | out: 'dist', 7 | }, 8 | plugins: ['@snowpack/plugin-postcss'], 9 | }; 10 | -------------------------------------------------------------------------------- /examples/snowpack/src/example.css: -------------------------------------------------------------------------------- 1 | .foo { 2 | color: red; 3 | } 4 | @media screen and (min-width: 1024px) { 5 | .foo { 6 | color: green; 7 | } 8 | } 9 | .bar { 10 | font-size: 1rem; 11 | } 12 | @media screen and (min-width: 1024px) { 13 | .bar { 14 | font-size: 2rem; 15 | } 16 | } 17 | .test { 18 | z-index: 1; 19 | } 20 | @media screen and (min-width: 1200px) { 21 | .test { 22 | z-index: 2; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /examples/vite/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "vite-example", 3 | "version": "1.0.0", 4 | "description": "Complete example for vite.", 5 | "author": "Kai Falkowski", 6 | "license": "MIT", 7 | "main": "vite.config.js", 8 | "scripts": { 9 | "start": "vite build" 10 | }, 11 | "dependencies": {}, 12 | "devDependencies": { 13 | "postcss-extract-media-query": "2.x", 14 | "vite": "^2.6.2" 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /examples/vite/postcss.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | 3 | module.exports = { 4 | plugins: { 5 | 'postcss-extract-media-query': { 6 | output: { 7 | path: path.join(__dirname, 'dist'), 8 | }, 9 | queries: { 10 | 'screen and (min-width: 1024px)': 'desktop', 11 | }, 12 | }, 13 | }, 14 | }; 15 | -------------------------------------------------------------------------------- /examples/vite/src/example.css: -------------------------------------------------------------------------------- 1 | .foo { 2 | color: red; 3 | } 4 | @media screen and (min-width: 1024px) { 5 | .foo { 6 | color: green; 7 | } 8 | } 9 | .bar { 10 | font-size: 1rem; 11 | } 12 | @media screen and (min-width: 1024px) { 13 | .bar { 14 | font-size: 2rem; 15 | } 16 | } 17 | .test { 18 | z-index: 1; 19 | } 20 | @media screen and (min-width: 1200px) { 21 | .test { 22 | z-index: 2; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /examples/vite/src/example.js: -------------------------------------------------------------------------------- 1 | import './example.css'; 2 | 3 | console.log('Hello World'); 4 | -------------------------------------------------------------------------------- /examples/vite/vite.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | build: { 3 | minify: false, 4 | assetsDir: '', 5 | rollupOptions: { 6 | input: ['src/example.js'], 7 | output: { 8 | entryFileNames: '[name].js', 9 | chunkFileNames: '[name].js', 10 | assetFileNames: '[name].[ext]', 11 | }, 12 | }, 13 | 14 | // Unfortunately vite seems to empty the output directory after my postcss plugin 15 | // has emitted the extracted files to it. This reveals the limitation of a postcss-only solution. 16 | emptyOutDir: false, 17 | }, 18 | }; 19 | -------------------------------------------------------------------------------- /examples/webpack/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "webpack-example", 3 | "version": "1.0.0", 4 | "description": "Complete example for webpack.", 5 | "author": "Kai Falkowski", 6 | "license": "MIT", 7 | "main": "webpack.config.js", 8 | "scripts": { 9 | "start": "webpack --config webpack.config.js" 10 | }, 11 | "dependencies": {}, 12 | "devDependencies": { 13 | "clean-webpack-plugin": "^2.0.1", 14 | "css-loader": "^2.1.1", 15 | "mini-css-extract-plugin": "^0.5.0", 16 | "postcss-extract-media-query": "2.x", 17 | "postcss-loader": "^3.0.0", 18 | "webpack": "^4.29.6", 19 | "webpack-cli": "^3.3.0" 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /examples/webpack/postcss.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | 3 | module.exports = { 4 | plugins: { 5 | 'postcss-extract-media-query': { 6 | output: { 7 | path: path.join(__dirname, 'dist'), 8 | }, 9 | queries: { 10 | 'screen and (min-width: 1024px)': 'desktop', 11 | }, 12 | }, 13 | }, 14 | }; 15 | -------------------------------------------------------------------------------- /examples/webpack/src/example.css: -------------------------------------------------------------------------------- 1 | .foo { 2 | color: red; 3 | } 4 | @media screen and (min-width: 1024px) { 5 | .foo { 6 | color: green; 7 | } 8 | } 9 | .bar { 10 | font-size: 1rem; 11 | } 12 | @media screen and (min-width: 1024px) { 13 | .bar { 14 | font-size: 2rem; 15 | } 16 | } 17 | .test { 18 | z-index: 1; 19 | } 20 | @media screen and (min-width: 1200px) { 21 | .test { 22 | z-index: 2; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /examples/webpack/src/example.js: -------------------------------------------------------------------------------- 1 | import './example.css'; 2 | 3 | console.log('Hello World'); 4 | -------------------------------------------------------------------------------- /examples/webpack/webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const webpack = require('webpack'); 3 | const CleanWebpackPlugin = require('clean-webpack-plugin'); 4 | const MiniCssExtractPlugin = require('mini-css-extract-plugin'); 5 | 6 | module.exports = { 7 | mode: 'development', 8 | entry: { 9 | example: './src/example.js', 10 | }, 11 | output: { 12 | filename: '[name].js', 13 | path: path.resolve(__dirname, 'dist'), 14 | }, 15 | module: { 16 | rules: [ 17 | { 18 | test: /\.css$/, 19 | use: [MiniCssExtractPlugin.loader, 'css-loader', 'postcss-loader'], 20 | }, 21 | ], 22 | }, 23 | plugins: [ 24 | new CleanWebpackPlugin(), 25 | new MiniCssExtractPlugin({ 26 | filename: '[name].css', 27 | }), 28 | ], 29 | }; 30 | -------------------------------------------------------------------------------- /index.ts: -------------------------------------------------------------------------------- 1 | import _ from 'lodash'; 2 | import fs from 'fs'; 3 | import path from 'path'; 4 | import { green } from 'kleur'; 5 | import postcss, { AcceptedPlugin } from 'postcss'; 6 | import SubsequentPlugins from './subsequent-plugins'; 7 | import { DeepPartial, PluginOptions } from './types'; 8 | 9 | const plugins = new SubsequentPlugins(); 10 | 11 | const plugin = (options?: DeepPartial): AcceptedPlugin => { 12 | const opts = _.merge( 13 | { 14 | output: { 15 | path: path.join(__dirname, '..'), 16 | name: '[name]-[query].[ext]', 17 | }, 18 | queries: {}, 19 | extractAll: true, 20 | stats: true, 21 | entry: null, 22 | src: { 23 | path: null, 24 | }, 25 | }, 26 | options 27 | ) as PluginOptions; 28 | 29 | if (opts.config) { 30 | plugins.updateConfig(opts.config); 31 | } 32 | 33 | const media: Record = {}; 34 | 35 | function addMedia(key: string, css: string, query: string) { 36 | if (!Array.isArray(media[key])) { 37 | media[key] = []; 38 | } 39 | media[key].push({ css, query }); 40 | } 41 | 42 | function getMedia(key: string) { 43 | const css = media[key].map((data) => data.css).join('\n'); 44 | const query = media[key][0].query; 45 | 46 | return { css, query }; 47 | } 48 | 49 | function getRootPath(currentPath: string) { 50 | if (opts.src.path) { 51 | return null; 52 | } 53 | if (!currentPath || fs.existsSync(path.join(currentPath, 'package.json'))) { 54 | return currentPath; 55 | } 56 | const parentPath = path.resolve(currentPath, '..'); 57 | if (currentPath === parentPath) { 58 | return null; 59 | } 60 | return getRootPath(parentPath); 61 | } 62 | 63 | function getSrcPath(rootPath: string | null) { 64 | if (opts.src.path) { 65 | return opts.src.path; 66 | } 67 | if (!rootPath) { 68 | return rootPath; 69 | } 70 | const attempts = [ 71 | path.join(rootPath, 'src', 'app'), 72 | path.join(rootPath, 'app', 'src'), 73 | path.join(rootPath, 'src'), 74 | path.join(rootPath, 'app'), 75 | ]; 76 | for (const attempt of attempts) { 77 | if (fs.existsSync(attempt)) { 78 | return attempt; 79 | } 80 | } 81 | return rootPath; 82 | } 83 | 84 | return { 85 | postcssPlugin: 'postcss-extract-media-query', 86 | async Once(root, { result }) { 87 | let from = ''; 88 | 89 | if (opts.entry) { 90 | from = opts.entry; 91 | } else if (result.opts.from) { 92 | from = result.opts.from; 93 | } 94 | 95 | const file = from.match(/([^/\\]+)\.(\w+)(?:\?.+)?$/); 96 | const name = file ? file[1] : 'undefined'; 97 | const ext = file ? file[2] : 'css'; 98 | const rootPath = getRootPath(opts.output.path); 99 | const srcPath = getSrcPath(rootPath); 100 | const relativePath = 101 | srcPath && from ? path.dirname(path.relative(srcPath, from)) : ''; 102 | 103 | if (opts.output.path) { 104 | root.walkAtRules('media', (atRule) => { 105 | const query = atRule.params; 106 | const queryname = 107 | opts.queries[query] || (opts.extractAll && _.kebabCase(query)); 108 | 109 | if (queryname) { 110 | const css = postcss.root().append(atRule).toString(); 111 | 112 | addMedia(queryname, css, query); 113 | atRule.remove(); 114 | } 115 | }); 116 | } 117 | 118 | const promises: Promise[] = []; 119 | 120 | // gather promises only if output.path specified because otherwise 121 | // nothing has been extracted 122 | if (opts.output.path) { 123 | Object.keys(media).forEach((queryname) => { 124 | promises.push( 125 | new Promise((resolve) => { 126 | let { css } = getMedia(queryname); 127 | const newFile = 128 | typeof opts.output.name === 'function' 129 | ? opts.output.name({ 130 | path: relativePath, 131 | name, 132 | query: queryname, 133 | ext, 134 | }) 135 | : opts.output.name 136 | .replace(/\[path\](\/)?/g, (_, sep = '') => 137 | // avoid absolute path if relativePath is empty 138 | relativePath ? relativePath + sep : '' 139 | ) 140 | .replace(/\[name\]/g, name) 141 | .replace(/\[query\]/g, queryname) 142 | .replace(/\[ext\]/g, ext); 143 | const newFilePath = path.isAbsolute(newFile) 144 | ? newFile 145 | : path.join(opts.output.path, newFile); 146 | const newFileDir = path.dirname(newFilePath); 147 | 148 | plugins.applyPlugins(css, newFilePath).then((css: string) => { 149 | if (!fs.existsSync(path.dirname(newFilePath))) { 150 | // make sure we can write 151 | fs.mkdirSync(newFileDir, { recursive: true }); 152 | } 153 | fs.writeFileSync(newFilePath, css); 154 | 155 | if (opts.stats === true) { 156 | console.log(green('[extracted media query]'), newFile); 157 | } 158 | resolve(); 159 | }); 160 | }) 161 | ); 162 | }); 163 | } 164 | 165 | await Promise.all(promises); 166 | }, 167 | }; 168 | }; 169 | 170 | plugin.postcss = true; 171 | 172 | export = plugin; 173 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('ts-jest').JestConfigWithTsJest} **/ 2 | module.exports = { 3 | testEnvironment: 'node', 4 | transform: { 5 | '^.+.tsx?$': ['ts-jest', {}], 6 | }, 7 | }; 8 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "postcss-extract-media-query", 3 | "version": "3.2.0", 4 | "description": "PostCSS plugin to extract all media query from CSS and emit as separate files.", 5 | "author": "Kai Tran", 6 | "license": "MIT", 7 | "main": "dist/index.js", 8 | "types": "dist/index.d.ts", 9 | "files": [ 10 | "dist" 11 | ], 12 | "scripts": { 13 | "build": "tsc", 14 | "prepare": "husky install && npm run build", 15 | "test": "jest" 16 | }, 17 | "keywords": [ 18 | "postcss", 19 | "plugin", 20 | "postcss-plugin", 21 | "css", 22 | "mediaquery", 23 | "mq", 24 | "extract", 25 | "split", 26 | "combine" 27 | ], 28 | "engines": { 29 | "node": ">=16.0.0" 30 | }, 31 | "dependencies": { 32 | "app-root-path": "^3.1.0", 33 | "kleur": "^4.1.5", 34 | "lodash": "^4.17.21" 35 | }, 36 | "devDependencies": { 37 | "@types/jest": "^29.5.14", 38 | "@types/lodash": "^4.17.16", 39 | "@types/node": "^22.15.3", 40 | "husky": "^7.0.0", 41 | "jest": "^29.7.0", 42 | "prettier": "^3.5.3", 43 | "pretty-quick": "^4.1.1", 44 | "rimraf": "^6.0.1", 45 | "ts-jest": "^29.3.2", 46 | "typescript": "^5.8.3" 47 | }, 48 | "peerDependencies": { 49 | "postcss": "^8.0.0" 50 | }, 51 | "repository": { 52 | "type": "git", 53 | "url": "https://github.com/SassNinja/postcss-extract-media-query.git" 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /subsequent-plugins.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import path from 'path'; 3 | import postcss from 'postcss'; 4 | import { path as rootPath } from 'app-root-path'; 5 | import { PostcssConfig } from './types'; 6 | 7 | class SubsequentPlugins { 8 | protected config: PostcssConfig = {}; 9 | protected allNames: string[] = []; 10 | protected subsequentNames: string[] = []; 11 | protected subsequentPlugins: { 12 | name: string; 13 | mod: any; 14 | opts: object | false; 15 | }[] = []; 16 | 17 | constructor() { 18 | this.updateConfig(); 19 | } 20 | 21 | /** 22 | * (Re)init with current postcss config 23 | */ 24 | private init() { 25 | this.allNames = 26 | typeof this.config.plugins === 'object' 27 | ? Object.keys(this.config.plugins) 28 | : []; 29 | this.subsequentNames = this.allNames.slice( 30 | this.allNames.indexOf('postcss-extract-media-query') + 1 31 | ); 32 | this.subsequentPlugins = this.subsequentNames.map((name) => ({ 33 | name, 34 | mod: this.config.pluginsSrc?.[name] || require(name), 35 | opts: (this.config.plugins as Record)[name], 36 | })); 37 | } 38 | 39 | /** 40 | * Updates the postcss config by resolving file path or by using the config file object 41 | */ 42 | public updateConfig(file?: string | PostcssConfig): PostcssConfig { 43 | if (typeof file === 'object') { 44 | this.config = file; 45 | this.init(); 46 | return this.config; 47 | } 48 | if (typeof file === 'string' && !path.isAbsolute(file)) { 49 | file = path.join(rootPath, file); 50 | } 51 | const filePath = file || path.join(rootPath, 'postcss.config.js'); 52 | 53 | if (fs.existsSync(filePath)) { 54 | this.config = require(filePath); 55 | } 56 | this.init(); 57 | return this.config; 58 | } 59 | 60 | /** 61 | * Apply all subsequent plugins to the (extracted) css 62 | */ 63 | public async applyPlugins(css: string, filePath: string): Promise { 64 | const plugins = this.subsequentPlugins.map((plugin) => 65 | plugin.mod(plugin.opts) 66 | ); 67 | 68 | if (plugins.length) { 69 | const result = await postcss(plugins).process(css, { 70 | from: filePath, 71 | to: filePath, 72 | }); 73 | return result.css; 74 | } 75 | return css; 76 | } 77 | } 78 | 79 | export = SubsequentPlugins; 80 | -------------------------------------------------------------------------------- /test/data/entry-example.css: -------------------------------------------------------------------------------- 1 | @media screen { 2 | .foo { 3 | color: blue; 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /test/data/entry-example.namespace.css: -------------------------------------------------------------------------------- 1 | @media screen { 2 | .foo { 3 | color: blue; 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /test/data/example.css: -------------------------------------------------------------------------------- 1 | .foo { 2 | color: red; 3 | } 4 | @media screen and (min-width: 1024px) { 5 | .foo { 6 | color: green; 7 | } 8 | } 9 | .bar { 10 | font-size: 1rem; 11 | } 12 | @media screen and (min-width: 1024px) { 13 | .bar { 14 | font-size: 2rem; 15 | } 16 | } 17 | .test { 18 | z-index: 1; 19 | } 20 | @media screen and (min-width: 1200px) { 21 | .test { 22 | z-index: 2; 23 | } 24 | } 25 | @media screen and (min-width: 999px) { 26 | .whitelist { 27 | z-index: 999; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /test/data/name-example.css: -------------------------------------------------------------------------------- 1 | .example { 2 | color: red; 3 | } 4 | @media screen { 5 | .example { 6 | color: green; 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /test/data/nested/name-example.css: -------------------------------------------------------------------------------- 1 | .example { 2 | z-index: 1; 3 | } 4 | @media screen { 5 | .example { 6 | z-index: 2; 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /test/options.test.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import path from 'path'; 3 | import { rimraf } from 'rimraf'; 4 | import postcss from 'postcss'; 5 | import plugin from '../index'; 6 | import { NameFunction } from '../types'; 7 | 8 | const exampleFile = fs.readFileSync('test/data/example.css', 'utf-8'); 9 | const entryExampleFile = fs.readFileSync( 10 | 'test/data/entry-example.css', 11 | 'utf-8' 12 | ); 13 | const nameExampleFile = fs.readFileSync('test/data/name-example.css', 'utf-8'); 14 | const nameNestedExampleFile = fs.readFileSync( 15 | 'test/data/nested/name-example.css', 16 | 'utf-8' 17 | ); 18 | 19 | describe('Options', () => { 20 | beforeEach(async () => { 21 | await rimraf('test/output'); 22 | }); 23 | 24 | describe('extractAll', () => { 25 | it('should only extract specified queries if false', async () => { 26 | const opts = { 27 | output: { 28 | path: path.join(__dirname, 'output'), 29 | }, 30 | queries: { 31 | 'screen and (min-width: 999px)': 'specified', 32 | }, 33 | extractAll: false, 34 | stats: false, 35 | }; 36 | await postcss([plugin(opts)]).process(exampleFile, { 37 | from: 'test/data/example.css', 38 | }); 39 | const files = fs.readdirSync('test/output/'); 40 | expect(fs.existsSync('test/output/example-specified.css')).toBe(true); 41 | expect(files.length).toEqual(1); 42 | }); 43 | it('should extract all queries if true', async () => { 44 | const opts = { 45 | output: { 46 | path: path.join(__dirname, 'output'), 47 | }, 48 | queries: { 49 | 'screen and (min-width: 999px)': 'specified', 50 | }, 51 | extractAll: true, 52 | stats: false, 53 | }; 54 | const result = await postcss([plugin(opts)]).process(exampleFile, { 55 | from: 'test/data/example.css', 56 | }); 57 | const files = fs.readdirSync('test/output/'); 58 | expect(files.length).toBeGreaterThan(1); 59 | expect(result.css).not.toMatch(/@media/); 60 | }); 61 | }); 62 | 63 | describe('entry', () => { 64 | it('should override any other from option', async () => { 65 | const opts = { 66 | entry: path.join(__dirname, 'data/entry-example.namespace.css'), 67 | output: { 68 | path: path.join(__dirname, 'output'), 69 | }, 70 | stats: false, 71 | }; 72 | await postcss([plugin(opts)]).process(entryExampleFile, { 73 | from: 'test/data/example.css', 74 | }); 75 | expect( 76 | fs.existsSync('test/output/entry-example.namespace-screen.css') 77 | ).toBe(true); 78 | }); 79 | }); 80 | 81 | describe('output', () => { 82 | it('should not emit any files if output.path is empty and not touch the CSS', async () => { 83 | const opts = { 84 | output: { 85 | path: '', 86 | }, 87 | }; 88 | const result = await postcss([plugin(opts)]).process(exampleFile, { 89 | from: 'test/data/example.css', 90 | }); 91 | expect(fs.existsSync('output')).toBe(false); 92 | expect(result.css).toEqual(exampleFile); 93 | }); 94 | it('should use output.name for the emitted files if specified', async () => { 95 | const opts = { 96 | output: { 97 | path: path.join(__dirname, 'output'), 98 | name: '[query].[ext]', 99 | }, 100 | stats: false, 101 | }; 102 | await postcss([plugin(opts)]).process(exampleFile, { 103 | from: 'test/data/example.css', 104 | }); 105 | expect( 106 | fs.existsSync('test/output/screen-and-min-width-1024-px.css') 107 | ).toBe(true); 108 | expect( 109 | fs.existsSync('test/output/screen-and-min-width-1200-px.css') 110 | ).toBe(true); 111 | }); 112 | it('should support using the same placeholder in output.name multiple times', async () => { 113 | const opts = { 114 | output: { 115 | path: path.join(__dirname, 'output'), 116 | name: '[query]-[query].[ext]', 117 | }, 118 | stats: false, 119 | }; 120 | await postcss([plugin(opts)]).process(exampleFile, { 121 | from: 'test/data/example.css', 122 | }); 123 | expect( 124 | fs.existsSync( 125 | 'test/output/screen-and-min-width-1024-px-screen-and-min-width-1024-px.css' 126 | ) 127 | ).toBe(true); 128 | expect( 129 | fs.existsSync( 130 | 'test/output/screen-and-min-width-1200-px-screen-and-min-width-1200-px.css' 131 | ) 132 | ).toBe(true); 133 | }); 134 | it('should allow preserving the original folder structure using path placeholder', async () => { 135 | const opts = { 136 | output: { 137 | path: path.join(__dirname, 'output'), 138 | name: '[path]/[name]-[query].[ext]', 139 | }, 140 | stats: false, 141 | }; 142 | await postcss([plugin(opts)]).process(nameExampleFile, { 143 | from: 'test/data/name-example.css', 144 | }); 145 | expect( 146 | fs.existsSync('test/output/test/data/name-example-screen.css') 147 | ).toBe(true); 148 | }); 149 | it('should allow processing multiple files with identical filename using path placeholder', async () => { 150 | const opts = { 151 | output: { 152 | path: path.join(__dirname, 'output'), 153 | name: ({ path, name, query, ext }: Parameters[0]) => { 154 | return `${path.replace(/^test\//, '')}/${name}-${query}.${ext}`; 155 | }, 156 | }, 157 | stats: false, 158 | }; 159 | await postcss([plugin(opts)]).process(nameExampleFile, { 160 | from: 'test/data/name-example.css', 161 | }); 162 | await postcss([plugin(opts)]).process(nameNestedExampleFile, { 163 | from: 'test/data/nested/name-example.css', 164 | }); 165 | expect(fs.existsSync('test/output/data/name-example-screen.css')).toBe( 166 | true 167 | ); 168 | expect( 169 | fs.existsSync('test/output/data/nested/name-example-screen.css') 170 | ).toBe(true); 171 | }); 172 | it('should allow overriding the srcPath instead of relying on automatic determination', async () => { 173 | const opts = { 174 | output: { 175 | path: path.join(__dirname, 'output'), 176 | name: '[path]/[name]-[query].[ext]', 177 | }, 178 | src: { 179 | path: path.join(__dirname, '../test/data'), 180 | }, 181 | stats: false, 182 | }; 183 | await postcss([plugin(opts)]).process(nameNestedExampleFile, { 184 | from: 'test/data/nested/name-example.css', 185 | }); 186 | expect(fs.existsSync('test/output/nested/name-example-screen.css')).toBe( 187 | true 188 | ); 189 | }); 190 | }); 191 | 192 | describe('queries', () => { 193 | it('should use specified query that exactly matches', async () => { 194 | const opts = { 195 | output: { 196 | path: path.join(__dirname, 'output'), 197 | }, 198 | queries: { 199 | 'screen and (min-width: 1024px)': 'desktop', 200 | }, 201 | stats: false, 202 | }; 203 | await postcss([plugin(opts)]).process(exampleFile, { 204 | from: 'test/data/example.css', 205 | }); 206 | expect(fs.existsSync('test/output/example-desktop.css')).toBe(true); 207 | }); 208 | it('should ignore specified query that does not exactly match', async () => { 209 | const opts = { 210 | output: { 211 | path: path.join(__dirname, 'output'), 212 | }, 213 | queries: { 214 | 'min-width: 1200px': 'xdesktop', 215 | }, 216 | stats: false, 217 | }; 218 | await postcss([plugin(opts)]).process(exampleFile, { 219 | from: 'test/data/example.css', 220 | }); 221 | expect(fs.existsSync('test/output/example-xdesktop.css')).toBe(false); 222 | }); 223 | }); 224 | 225 | describe('config', () => { 226 | it('should use opts.config if present to apply plugins', async () => { 227 | let precedingPluginCalls = 0; 228 | const precedingPlugin = () => { 229 | return { 230 | postcssPlugin: 'preceding-plugin', 231 | Once() { 232 | precedingPluginCalls++; 233 | }, 234 | }; 235 | }; 236 | let subsequentPluginCalls = 0; 237 | const subsequentPlugin = () => { 238 | return { 239 | postcssPlugin: 'subsequent-plugin', 240 | Once() { 241 | subsequentPluginCalls++; 242 | }, 243 | }; 244 | }; 245 | const opts = { 246 | output: { 247 | path: path.join(__dirname, 'output'), 248 | }, 249 | stats: false, 250 | config: { 251 | pluginsSrc: { 252 | 'preceding-plugin': precedingPlugin, 253 | 'subsequent-plugin': subsequentPlugin, 254 | }, 255 | plugins: { 256 | 'preceding-plugin': {}, 257 | 'postcss-extract-media-query': {}, 258 | 'subsequent-plugin': {}, 259 | }, 260 | }, 261 | }; 262 | await postcss([plugin(opts)]).process(exampleFile, { 263 | from: 'test/data/example.css', 264 | }); 265 | expect(precedingPluginCalls).toEqual(0); 266 | expect(subsequentPluginCalls).toBeGreaterThanOrEqual(1); 267 | }); 268 | }); 269 | }); 270 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2021", 4 | "module": "CommonJS", 5 | "declaration": true, 6 | "outDir": "dist", 7 | "strict": true, 8 | "esModuleInterop": true 9 | }, 10 | "files": ["index.ts", "subsequent-plugins.ts"] 11 | } 12 | -------------------------------------------------------------------------------- /types.ts: -------------------------------------------------------------------------------- 1 | import { Plugin, Transformer, Processor } from 'postcss'; 2 | 3 | export type DeepPartial = T extends object 4 | ? { 5 | [P in keyof T]?: DeepPartial; 6 | } 7 | : T; 8 | 9 | export interface PostcssConfig { 10 | plugins?: 11 | | Array 12 | | Record; 13 | pluginsSrc?: Record; 14 | } 15 | 16 | export type NameFunction = ({ 17 | path, 18 | name, 19 | query, 20 | ext, 21 | }: { 22 | path: string; 23 | name: string; 24 | query: string; 25 | ext: string; 26 | }) => string; 27 | 28 | export interface PluginOptions { 29 | /** 30 | * By default the plugin will emit the extracted CSS files to your root folder. If you want to change this you have to define an **absolute** path for `output.path`. 31 | * 32 | * Apart from that you can customize the emitted filenames by using `output.name`. `[path]` is the relative path of the original CSS file relative to root, `[name]` is the filename of the original CSS file, `[query]` the key of the extracted media query and `[ext]` the original file extension (mostly `css`). Those three placeholders get replaced by the plugin later. 33 | * 34 | * Alternatively you may pass a name function which gets called with all placeholders as args. 35 | */ 36 | output: { 37 | name: string | NameFunction; 38 | path: string; 39 | }; 40 | /** 41 | * By default the params of the extracted media query is converted to kebab case and taken as key (e.g. `screen-and-min-width-1024-px`). You can change this by defining a certain name for a certain match. Make sure it **exactly** matches the params (see example below). 42 | */ 43 | queries: Record; 44 | /** 45 | * By default the plugin extracts all media queries into separate files. If you want it to only extract the ones you've defined a certain name for (see `queries` option) you have to set this option `false`. This ignores all media queries that don't have a custom name defined. 46 | */ 47 | extractAll: boolean; 48 | /** 49 | * By default the plugin displays in your terminal / command prompt which files have been emitted. If you don't want to see it just set this option `false`. 50 | */ 51 | stats: boolean; 52 | /** 53 | * By default the plugin uses the `from` value from the options of the loader or of the options you define in `postcss().process(css, { from: ... })`. Usually you don't need to change it but if you have to (e.g. when using the plugin standalone) you can define an **absolute** file path as entry. 54 | */ 55 | entry: string | null; 56 | /** 57 | * This option is only relevant if you're using the `path` placeholder in `output.name` 58 | * 59 | * By default the plugin determines the root by looking for the package.json file and uses it as srcPath (if there's no app or src folder) to compute the relative path. 60 | * 61 | * In case the automatically determined srcPath doesn't suit you, it's possible to override it with this option. 62 | */ 63 | src: { 64 | path: string | null; 65 | }; 66 | /** 67 | * By default the plugin looks for a `postcss.config.js` file in your project's root (read [node-app-root-path](https://github.com/inxilpro/node-app-root-path) to understand how root is determined) and tries to apply all subsequent PostCSS plugins to the extracted CSS. 68 | * 69 | * In case this lookup doesn't suite you it's possible to specify the config path yourself. 70 | */ 71 | config?: string | PostcssConfig; 72 | } 73 | --------------------------------------------------------------------------------