├── .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 |
--------------------------------------------------------------------------------