├── .prettierignore
├── .travis.yml
├── .prettierrc.js
├── .gitignore
├── .npmignore
├── postcss
└── index.ts
├── .editorconfig
├── test
├── test.css
├── postcss.js
└── tool.js
├── tsconfig.json
├── LICENSE
├── package.json
├── eslint.config.js
├── tool
└── index.ts
└── README.md
/.prettierignore:
--------------------------------------------------------------------------------
1 | dist
--------------------------------------------------------------------------------
/.travis.yml:
--------------------------------------------------------------------------------
1 | language: node_js
2 | node_js:
3 | - "12.0"
4 | - "14.0"
5 | - "16.0"
6 |
--------------------------------------------------------------------------------
/.prettierrc.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | singleQuote: true,
3 | bracketSpacing: false,
4 | trailingComma: "es5",
5 | };
6 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 |
6 | # Dependency directories
7 | node_modules
8 | tmp
9 | dist
10 |
--------------------------------------------------------------------------------
/.npmignore:
--------------------------------------------------------------------------------
1 | .eslintrc
2 | .stylelintrc
3 | .gitignore
4 | .travis.yml
5 | .git
6 | yarn.lock
7 | node_modules
8 | docs
9 | tmp
10 | test
11 | www
12 |
--------------------------------------------------------------------------------
/postcss/index.ts:
--------------------------------------------------------------------------------
1 | import type {Plugin} from 'postcss';
2 | import ATP from '../tool';
3 |
4 | module.exports = (): Plugin => ({
5 | postcssPlugin: 'at-rule-packer',
6 | Root(root) {
7 | ATP(root);
8 | },
9 | });
10 |
11 | module.exports.postcss = true;
12 |
--------------------------------------------------------------------------------
/.editorconfig:
--------------------------------------------------------------------------------
1 | root=true
2 |
3 | [*]
4 | charset = utf-8
5 | end_of_line = lf
6 | indent_style = space
7 | insert_final_newline = true
8 | trim_trailing_whitespace = true
9 |
10 | [*.{html,css,scss,less,js,jsx,ts,tsx,svg,vue}]
11 | indent_size = 2
12 |
13 | [*.{java,xml}]
14 | indent_size = 4
15 | continuation_indent_size = 8
16 |
--------------------------------------------------------------------------------
/test/test.css:
--------------------------------------------------------------------------------
1 | .mydiv {
2 | color: blue;
3 | font-size: 1em;
4 | font-weight: bold;
5 | }
6 |
7 | @media (min-width: 64em) {
8 | .mydiv {
9 | font-size: 1.25em;
10 | }
11 | }
12 |
13 | /* Utilities */
14 | .font-size--medium {
15 | font-size: 1em;
16 | }
17 |
18 | .aspect-ratio--video {
19 | aspect-ratio: 16 / 9;
20 | }
21 |
22 | @media (min-width: 64em) {
23 | .aspect-ratio--video {
24 | aspect-ratio: 4 / 3;
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "baseUrl": ".",
4 | "composite": true,
5 | "paths": {
6 | "*": ["types/*.d.ts"]
7 | },
8 | "jsx": "react",
9 | "lib": ["es6", "dom"],
10 | "module": "commonjs",
11 | "moduleResolution": "node",
12 | "strict": true,
13 | "target": "ES2016",
14 | "allowSyntheticDefaultImports": true,
15 | "esModuleInterop": true,
16 | "forceConsistentCasingInFileNames": true,
17 | "declaration": true,
18 | "typeRoots": ["node_modules/@types"]
19 | },
20 | "include": ["tool", "postcss", "types"]
21 | }
22 |
--------------------------------------------------------------------------------
/test/postcss.js:
--------------------------------------------------------------------------------
1 | const fs = require('fs');
2 | const path = require('path');
3 | const postcss = require('postcss');
4 | const atRulePackerPlugin = require('../dist/postcss');
5 |
6 | /* eslint-disable max-len */
7 |
8 | describe('postcss', () => {
9 | it('Should process css files', async () => {
10 | const from = path.resolve(__dirname, 'test.css');
11 | const css = await fs.promises.readFile(from, 'utf8');
12 | const result = await postcss([atRulePackerPlugin]).process(css, {from});
13 |
14 | expect(result.css.replace(/\s/g, '')).toBe(
15 | '.mydiv{color:blue;font-size:1em;font-weight:bold;}/*Utilities*/.font-size--medium{font-size:1em;}.aspect-ratio--video{aspect-ratio:16/9;}@media(min-width:64em){.mydiv{font-size:1.25em;}.aspect-ratio--video{aspect-ratio:4/3;}}'
16 | );
17 | });
18 | });
19 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2021 Benjamin Solum
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 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "at-rule-packer",
3 | "version": "0.5.0",
4 | "description": "Merge duplicate CSS media query and other at-rule rules together.",
5 | "keywords": [
6 | "at-rule",
7 | "atrule",
8 | "postcss",
9 | "plugin",
10 | "postcss-plugin",
11 | "css",
12 | "merge",
13 | "pack",
14 | "media",
15 | "query",
16 | "queries",
17 | "supports"
18 | ],
19 | "main": "dist/postcss/index.js",
20 | "types": "dist/postcss/index.d.ts",
21 | "scripts": {
22 | "build": "tsc --outDir dist",
23 | "pretest": "eslint tool/*.[tj]s postcss/*.[tj]s test/*.[tj]s",
24 | "test": "npm run build && jest",
25 | "prepublishOnly": "npm test"
26 | },
27 | "repository": {
28 | "type": "git",
29 | "url": "git+https://github.com/soluml/at-rule-packer.git"
30 | },
31 | "bugs": {
32 | "url": "https://github.com/soluml/at-rule-packer/issues"
33 | },
34 | "files": [
35 | "dist/postcss/**/*",
36 | "dist/tool/**/*"
37 | ],
38 | "author": {
39 | "name": "Benjamin Solum",
40 | "email": "benjamin@soluml.com",
41 | "url": "http://soluml.com"
42 | },
43 | "license": "MIT",
44 | "devDependencies": {
45 | "@eslint/eslintrc": "^3.3.1",
46 | "@eslint/js": "^9.28.0",
47 | "@types/node": "^22.15.30",
48 | "@typescript-eslint/eslint-plugin": "^8.34.0",
49 | "@typescript-eslint/parser": "^8.34.0",
50 | "eslint": "^9.28.0",
51 | "eslint-config-prettier": "^10.1.5",
52 | "eslint-plugin-import": "^2.31.0",
53 | "eslint-plugin-jest": "^28.13.0",
54 | "eslint-plugin-prettier": "^5.4.1",
55 | "globals": "^16.2.0",
56 | "jest": "^29.7.0",
57 | "prettier": "^3.5.3",
58 | "typescript": "^5.8.3"
59 | },
60 | "peerDependencies": {
61 | "postcss": "8.x"
62 | },
63 | "jest": {
64 | "rootDir": ".",
65 | "testMatch": [
66 | "**/test/*.js"
67 | ]
68 | }
69 | }
70 |
--------------------------------------------------------------------------------
/eslint.config.js:
--------------------------------------------------------------------------------
1 | const {defineConfig} = require('eslint/config');
2 |
3 | const tsParser = require('@typescript-eslint/parser');
4 | const prettier = require('eslint-plugin-prettier');
5 | const globals = require('globals');
6 | const js = require('@eslint/js');
7 |
8 | const {FlatCompat} = require('@eslint/eslintrc');
9 |
10 | const compat = new FlatCompat({
11 | baseDirectory: __dirname,
12 | recommendedConfig: js.configs.recommended,
13 | allConfig: js.configs.all,
14 | });
15 |
16 | module.exports = defineConfig([
17 | {
18 | languageOptions: {
19 | parser: tsParser,
20 | ecmaVersion: 2018,
21 | sourceType: 'module',
22 | parserOptions: {},
23 |
24 | globals: {
25 | ...globals.browser,
26 | },
27 | },
28 |
29 | extends: compat.extends(
30 | 'plugin:@typescript-eslint/recommended',
31 | 'plugin:jest/recommended',
32 | 'prettier'
33 | ),
34 |
35 | plugins: {
36 | prettier,
37 | },
38 |
39 | rules: {
40 | curly: 1,
41 | '@typescript-eslint/explicit-function-return-type': [0],
42 | '@typescript-eslint/no-explicit-any': [0],
43 | '@typescript-eslint/ban-ts-comment': [0],
44 | '@typescript-eslint/no-var-requires': [0],
45 | '@typescript-eslint/ban-types': [0],
46 | 'prettier/prettier': 2,
47 | 'ordered-imports': [0],
48 | 'object-literal-sort-keys': [0],
49 | 'max-len': [1, 120],
50 | 'default-case': 0,
51 | 'new-parens': 1,
52 | 'no-bitwise': 0,
53 | 'no-cond-assign': 1,
54 | 'no-trailing-spaces': 0,
55 | 'no-param-reassign': 1,
56 | 'no-use-before-define': 1,
57 | 'eol-last': 1,
58 |
59 | 'func-style': [
60 | 'error',
61 | 'declaration',
62 | {
63 | allowArrowFunctions: true,
64 | },
65 | ],
66 |
67 | semi: 1,
68 | 'no-var': 0,
69 | 'no-plusplus': 0,
70 | 'func-names': 0,
71 | 'consistent-return': 1,
72 | 'import/no-unresolved': 0,
73 | 'import/extensions': 0,
74 | '@typescript-eslint/no-require-imports': 0,
75 | },
76 | },
77 | ]);
78 |
--------------------------------------------------------------------------------
/tool/index.ts:
--------------------------------------------------------------------------------
1 | import type {Root, AtRule, ChildNode} from 'postcss';
2 | import postcss from 'postcss';
3 |
4 | function getAtRuleKey(atrule: AtRule) {
5 | return atrule.name + atrule.params;
6 | }
7 |
8 | enum Direction {
9 | NEXT = 'next',
10 | PREV = 'prev',
11 | }
12 |
13 | /* eslint-disable consistent-return */
14 | function untilAtRule(atrule: ChildNode, forward?: boolean): AtRule | undefined {
15 | const method = forward ? Direction.NEXT : Direction.PREV;
16 | const sibling = atrule[method]();
17 |
18 | if (sibling) {
19 | if (sibling.type === 'atrule') {
20 | return sibling as AtRule;
21 | }
22 |
23 | return untilAtRule(sibling, forward);
24 | }
25 | }
26 | /* eslint-enable consistent-return */
27 |
28 | // List of Atrule's that should never be merged
29 | const ignoredAtRules = [
30 | 'import',
31 | 'font-face',
32 | 'layer',
33 | 'property',
34 | 'when',
35 | 'else',
36 | ];
37 |
38 | function processAtrule(atrule: AtRule): void {
39 | // Ignore at-rules that should not be merged
40 | if (~ignoredAtRules.indexOf(atrule.name)) {
41 | return;
42 | }
43 |
44 | // Only process with the top level At-rule
45 | if (untilAtRule(atrule)) {
46 | return;
47 | }
48 |
49 | // Determine unique at-rules and remove ones that are not
50 | const uniqueAtRules = new Map();
51 |
52 | // loop through next At-rules
53 | (function p(atr) {
54 | const key = getAtRuleKey(atr);
55 |
56 | if (uniqueAtRules.has(key)) {
57 | const ref = uniqueAtRules.get(key) as AtRule;
58 |
59 | ref.push(atr);
60 | } else {
61 | uniqueAtRules.set(key, [atr]);
62 | }
63 |
64 | const nextAtRule = untilAtRule(atr, true);
65 |
66 | if (nextAtRule) {
67 | p(nextAtRule);
68 | } else {
69 | uniqueAtRules.forEach((atrs) => {
70 | if (atrs.length > 1) {
71 | const target = atrs.pop();
72 |
73 | atrs.reverse().forEach((a: AtRule) => {
74 | target.prepend(a.nodes);
75 | a.remove();
76 | });
77 | }
78 | });
79 | }
80 | })(atrule);
81 | }
82 |
83 | export default function AtRulePacker(css: string | Root): string | Root {
84 | const isTypeString = typeof css === 'string';
85 | let ast = css;
86 |
87 | // Parse into AST (if string)
88 | if (isTypeString) {
89 | ast = postcss.parse(ast);
90 | }
91 |
92 | // Process Atrules
93 | (ast as Root).walkAtRules(processAtrule);
94 |
95 | // Restore as string (if it originated as string)
96 | return isTypeString ? ast.toString() : ast;
97 | }
98 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # At-rule Packer
2 |
3 | A tool to Merge duplicate CSS media query and other At-rule rules together. Supports any At-rule that [PostCSS](https://postcss.org/) can handle including: `@media`, `@container`, `@supports`, and even newer at-rule's such as `@view-transition`, `@starting-style`, and `@scope`! If PostCSS supports the At-rule, so should this tool.
4 |
5 | It should be noted that this tool does _NOT_ apply to natively nested at-rules. To get the most out of this plugin, you should leverage the the [postcss-nesting](https://www.npmjs.com/package/postcss-nesting) PostCSS plugin or be using a preprocessor like [Sass](https://sass-lang.com/).
6 |
7 | ```
8 | npm install -D at-rule-packer
9 | ```
10 |
11 | [](http://badge.fury.io/js/at-rule-packer)
12 | [](https://travis-ci.org/soluml/at-rule-packer)
13 |
14 | By default, this package exports the PostCSS plugin. The standalone tool can be found under `dist/tool`.
15 |
16 | ## SYNOPSIS
17 |
18 | With CSS Preprocessors, it's very common to nest media queries. It keeps related styles contextual for future developers:
19 |
20 | ```scss
21 | .hello {
22 | color: black;
23 |
24 | @media (prefers-color-scheme: dark) {
25 | color: white;
26 | }
27 | }
28 |
29 | .world {
30 | color: #111;
31 |
32 | @media (prefers-color-scheme: dark) {
33 | color: #efefef;
34 | }
35 | }
36 | ```
37 |
38 | However, this can result in inefficient CSS for the browser:
39 |
40 | ```css
41 | .hello {
42 | color: black;
43 | }
44 | @media (prefers-color-scheme: dark) {
45 | .hello {
46 | color: white;
47 | }
48 | }
49 |
50 | .world {
51 | color: #111;
52 | }
53 | @media (prefers-color-scheme: dark) {
54 | .world {
55 | color: #efefef;
56 | }
57 | }
58 | ```
59 |
60 | The goal of this tool is the help eliminate these efficiencies when possible by changing the cascade and merging all duplicate At-rule's into the last At-rule block.
61 |
62 | ```css
63 | .hello {
64 | color: black;
65 | }
66 |
67 | .world {
68 | color: #111;
69 | }
70 |
71 | @media (prefers-color-scheme: dark) {
72 | .hello {
73 | color: white;
74 | }
75 |
76 | .world {
77 | color: #efefef;
78 | }
79 | }
80 | ```
81 |
82 | However, this is _NOT_ a safe optimization and can result in CSS that works differently than intended:
83 |
84 | ### source
85 |
86 | ```html
87 |
Hello World
88 | ```
89 |
90 | ```css
91 | .mydiv {
92 | color: blue;
93 | font-size: 1em;
94 | font-weight: bold;
95 | }
96 |
97 | @media (min-width: 64em) {
98 | .mydiv {
99 | font-size: 1.25em;
100 | }
101 | }
102 |
103 | /* Utilities */
104 | .font-size--medium {
105 | font-size: 1em;
106 | }
107 |
108 | .aspect-ratio--video {
109 | aspect-ratio: 16 / 9;
110 | }
111 |
112 | @media (min-width: 64em) {
113 | .aspect-ratio--video {
114 | aspect-ratio: 4 / 3;
115 | }
116 | }
117 | ```
118 |
119 | ### result
120 |
121 | ```css
122 | .mydiv {
123 | color: blue;
124 | font-size: 1em;
125 | font-weight: bold;
126 | }
127 |
128 | /* Utilities */
129 | .font-size--medium {
130 | font-size: 1em;
131 | }
132 |
133 | .aspect-ratio--video {
134 | aspect-ratio: 16/9;
135 | }
136 |
137 | @media (min-width: 64em) {
138 | .mydiv {
139 | font-size: 1.25em;
140 | }
141 | .aspect-ratio--video {
142 | aspect-ratio: 4/3;
143 | }
144 | }
145 | ```
146 |
147 | Therefore, it's important to ensure that this tool is used in CSS architectures that manage the cascade in a way that it doesn't matter where the rules end up in the stylesheet. It's also recommended that if you're going to use this tool, you do so early in development so that you can catch errors such as the above during development.
148 |
149 | ## USAGE
150 |
151 | ### As a PostCSS Plugin
152 |
153 | The default export for this package is the PostCSS plugin. There are no configuration options so it can be used simply like so:
154 |
155 | ```js
156 | postcss([require('at-rule-packer')({})]);
157 | ```
158 |
159 | ### As standard Node.js package
160 |
161 | This package is a Node.js module. It takes in a single string (that should be a valid CSS string) and returns a css process css string minified and with comments removed:
162 |
163 | ```javascript
164 | const atp = require('at-rule-packer/dist/tool');
165 |
166 | // @supports not (display:grid){main{float:right}.grid{display:flex}}
167 | console.log(
168 | atp(`
169 | @supports not (display: grid) {
170 | main {
171 | float: right;
172 | }
173 | }
174 |
175 | @supports not (display: grid) {
176 | .grid {
177 | display: flex;
178 | }
179 | }
180 | `)
181 | );
182 | ```
183 |
184 | ## NOTES
185 |
186 | A few considerations when determining whether or not you want to use this tool:
187 |
188 | ### CSS Cascading Order
189 |
190 | As noted above, this tool will change the CSS Cascade Order! Vulnerable CSS architectures should **NOT** use this tool.
191 |
192 | ### Source Maps
193 |
194 | As nodes are moving and shuffling around, Source Maps may not always be 100% accurate.
195 |
196 | ### Native CSS Nesting
197 |
198 | Since the initial release of this tool, the Web Platform has introduced native [CSS Nesting](https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_nesting). This plugin does not identify duplicated at-rules within nested rules.
199 |
200 | ## LICENSE
201 |
202 | MIT
203 |
--------------------------------------------------------------------------------
/test/tool.js:
--------------------------------------------------------------------------------
1 | const AtRulePacker = require('../dist/tool').default;
2 |
3 | function clearWhiteSpaceAndCallATP(css) {
4 | return AtRulePacker(css).replace(/(? {
10 | it('Can merge @rules', async () => {
11 | const css = `
12 | /* comment */
13 |
14 | .outer {
15 | contain: none;
16 | }
17 |
18 | @media (max-width: 600px) {
19 | .cls {
20 | color: #00f;
21 | }
22 | }
23 |
24 | @supports (display: grid) {
25 | div {
26 | display: grid;
27 | }
28 | }
29 |
30 | @media (max-width: 600px) {
31 | #cls {
32 | color: blue;
33 | }
34 | }
35 |
36 | @container (min-width: 700px){
37 | .card {
38 | display: grid;
39 | grid-template-columns: 2fr 1fr;
40 | }
41 | }
42 |
43 | @container (min-width: 700px){
44 | .card2 {
45 | display: grid;
46 | grid-template-columns: 1fr 2fr;
47 | }
48 | }
49 |
50 | @container named (min-width: 800px){
51 | .card3 {
52 | color: red;
53 | }
54 | }
55 |
56 | @container named (min-width: 800px){
57 | .card4 {
58 | color: blue;
59 | }
60 | }
61 |
62 | @starting-style {
63 | #target {
64 | background-color: transparent;
65 | }
66 | }
67 |
68 | @starting-style {
69 | #target2 {
70 | background-color: red;
71 | }
72 | }
73 |
74 | @supports selector(::scroll-marker) {
75 | li::scroll-marker {
76 | background-color: red;
77 | }
78 | }
79 |
80 | @supports selector(::scroll-marker) {
81 | li::scroll-marker {
82 | color: black;
83 | }
84 | }
85 |
86 | @supports selector(::scroll-button(*)) {
87 | li::scroll-marker {
88 | background-color: red;
89 | }
90 | }
91 |
92 | @supports selector(::scroll-button(*)) {
93 | li::scroll-marker {
94 | color: black;
95 | }
96 | }
97 |
98 | @supports selector(::scroll-button(left)) {
99 | li::scroll-marker {
100 | color: black;
101 | }
102 | }
103 | `;
104 |
105 | expect(clearWhiteSpaceAndCallATP(css)).toBe(
106 | '/*comment*/.outer{contain:none;}@supports(display:grid){div{display:grid;}}@media(max-width:600px){.cls{color:#00f;}#cls{color:blue;}}@container(min-width:700px){.card{display:grid;grid-template-columns:2fr1fr;}.card2{display:grid;grid-template-columns:1fr2fr;}}@containernamed(min-width:800px){.card3{color:red;}.card4{color:blue;}}@starting-style{#target{background-color:transparent;}#target2{background-color:red;}}@supportsselector(::scroll-marker){li::scroll-marker{background-color:red;}li::scroll-marker{color:black;}}@supportsselector(::scroll-button(*)){li::scroll-marker{background-color:red;}li::scroll-marker{color:black;}}@supportsselector(::scroll-button(left)){li::scroll-marker{color:black;}}'
107 | );
108 | });
109 |
110 | it('Can merge nested @rules', async () => {
111 | const css = `
112 | @media (max-width: 600px) {
113 | @media (prefers-color-scheme: dark) {
114 | .innerinner {
115 | font-weight: normal;
116 | }
117 | }
118 |
119 | @media (prefers-color-scheme: dark) {
120 | #innerinner {
121 | color: white;
122 | }
123 | }
124 | }
125 |
126 | @layer utils {
127 | @supports (color: light-dark(red, tan)) {
128 | .text {
129 | color: light-dark(#fafaf7, #22242a);
130 | }
131 | }
132 | }
133 |
134 | @layer utils {
135 | @supports (color: light-dark(red, tan)) {
136 | .text2 {
137 | color: light-dark(red, tan);
138 | }
139 | }
140 | }
141 | `;
142 |
143 | expect(clearWhiteSpaceAndCallATP(css)).toBe(
144 | '@media(max-width:600px){@media(prefers-color-scheme:dark){.innerinner{font-weight:normal;}#innerinner{color:white;}}}@layer utils{@supports(color:light-dark(red,tan)){.text{color:light-dark(#fafaf7,#22242a);}.text2{color:light-dark(red,tan);}}}'
145 | );
146 | });
147 |
148 | it('Can merge nested @rules within a layer', async () => {
149 | const css = `
150 | @layer global {
151 | :root {
152 | --header-open: 0;
153 | color-scheme: light;
154 | interpolate-size: allow-keywords
155 | }
156 |
157 | @supports (color: light-dark(red,tan)) {
158 | :root {
159 | color-scheme:light dark
160 | }
161 |
162 | :where([data-theme=dark]) {
163 | color-scheme: only dark
164 | }
165 |
166 | :where([data-theme=light]) {
167 | color-scheme: only light
168 | }
169 |
170 | @scope ([data-theme="dark"]) {
171 | :scope {
172 | color-scheme: only dark
173 | }
174 | }
175 |
176 | @scope ([data-theme="light"]) {
177 | :scope {
178 | color-scheme: only light
179 | }
180 | }
181 | }
182 |
183 | body>* {
184 | color: #000;
185 | grid-column: 2;
186 | position: relative;
187 | z-index: 1
188 | }
189 |
190 | @supports (color: light-dark(red,tan)) {
191 | body {
192 | background:light-dark(#f9f9f9,#070705)
193 | }
194 |
195 | body,body>* {
196 | color: light-dark(#000,#fff)
197 | }
198 | }
199 | }
200 | `;
201 |
202 | expect(clearWhiteSpaceAndCallATP(css)).toBe(
203 | '@layer global{:root{--header-open:0;color-scheme:light;interpolate-size:allow-keywords}body>*{color:#000;grid-column:2;position:relative;z-index:1}@supports(color:light-dark(red,tan)){:root{color-scheme:lightdark}:where([data-theme=dark]){color-scheme:onlydark}:where([data-theme=light]){color-scheme:onlylight}@scope([data-theme=\"dark\"]){:scope{color-scheme:onlydark}}@scope([data-theme=\"light\"]){:scope{color-scheme:onlylight}}body{background:light-dark(#f9f9f9,#070705)}body,body>*{color:light-dark(#000,#fff)}}}'
204 | );
205 | });
206 |
207 | it('Can merge deeply nested duplicate @rules', async () => {
208 | const css = `
209 | @media (max-width: 600px) {
210 | @media (max-width: 600px) {
211 | @media (max-width: 600px) {
212 | .innerinner {
213 | font-weight: normal;
214 | }
215 | }
216 | }
217 |
218 | @media (max-width: 600px) {
219 | @media (max-width: 600px) {
220 | #innerinner {
221 | color: white;
222 | }
223 | }
224 | }
225 | }
226 | `;
227 |
228 | expect(clearWhiteSpaceAndCallATP(css)).toBe(
229 | '@media(max-width:600px){@media(max-width:600px){@media(max-width:600px){.innerinner{font-weight:normal;}#innerinner{color:white;}}}}'
230 | );
231 | });
232 |
233 | it('Will not apply to natively nested CSS at-rules', async () => {
234 | const css = `
235 | [popover]:popover-open {
236 | opacity: 1;
237 | transform: scaleX(1);
238 |
239 | @starting-style {
240 | opacity: 0;
241 | }
242 | }
243 |
244 | [popover]:popover-open {
245 | color: red;
246 |
247 | @starting-style {
248 | transform: scaleX(0);
249 | }
250 | }
251 | `;
252 |
253 | expect(clearWhiteSpaceAndCallATP(css)).toBe(
254 | `[popover]:popover-open{opacity:1;transform:scaleX(1);@starting-style{opacity:0;}}[popover]:popover-open{color:red;@starting-style{transform:scaleX(0);}}`
255 | );
256 | });
257 |
258 | it('Has README.md examples that work', async () => {
259 | let css = `
260 | .hello {
261 | color: black;
262 | }
263 | @media (prefers-color-scheme: dark) {
264 | .hello {
265 | color: white;
266 | }
267 | }
268 |
269 | .world {
270 | color: #111;
271 | }
272 | @media (prefers-color-scheme: dark) {
273 | .world {
274 | color: #efefef;
275 | }
276 | }
277 | `;
278 |
279 | expect(clearWhiteSpaceAndCallATP(css)).toBe(
280 | '.hello{color:black;}.world{color:#111;}@media(prefers-color-scheme:dark){.hello{color:white;}.world{color:#efefef;}}'
281 | );
282 |
283 | css = `
284 | .mydiv {
285 | color: blue;
286 | font-size: 1em;
287 | font-weight: bold;
288 | }
289 |
290 | @media (min-width: 64em) {
291 | .mydiv {
292 | font-size: 1.25em;
293 | }
294 | }
295 |
296 | /* Utilities */
297 | .font-size--medium {
298 | font-size: 1em;
299 | }
300 |
301 | .aspect-ratio--video {
302 | aspect-ratio: 16 / 9;
303 | }
304 |
305 | @media (min-width: 64em) {
306 | .aspect-ratio--video {
307 | aspect-ratio: 4 / 3;
308 | }
309 | }
310 | `;
311 |
312 | expect(clearWhiteSpaceAndCallATP(css)).toBe(
313 | '.mydiv{color:blue;font-size:1em;font-weight:bold;}/*Utilities*/.font-size--medium{font-size:1em;}.aspect-ratio--video{aspect-ratio:16/9;}@media(min-width:64em){.mydiv{font-size:1.25em;}.aspect-ratio--video{aspect-ratio:4/3;}}'
314 | );
315 |
316 | css = `
317 | @supports not (display: grid) {
318 | main {
319 | float: right;
320 | }
321 | }
322 |
323 | @supports not (display: grid) {
324 | .grid {
325 | display: flex;
326 | }
327 | }
328 | `;
329 |
330 | expect(clearWhiteSpaceAndCallATP(css)).toBe(
331 | '@supportsnot(display:grid){main{float:right;}.grid{display:flex;}}'
332 | );
333 | });
334 |
335 | it('Should not merge @font-face', async () => {
336 | const css = `
337 | @font-face {
338 | font-family: 'Open Sans';
339 | font-style: normal;
340 | font-weight: 400;
341 | src: url('/fonts/OpenSans-Regular.woff2') format('woff2');
342 | }
343 |
344 | @font-face {
345 | font-family: 'Open Sans';
346 | font-style: normal;
347 | font-weight: 700;
348 | src: url('/fonts/OpenSans-Bold.woff2') format('woff2');
349 | }
350 | `;
351 |
352 | expect(clearWhiteSpaceAndCallATP(css)).toBe(
353 | `@font-face{font-family:'OpenSans';font-style:normal;font-weight:400;src:url('/fonts/OpenSans-Regular.woff2')format('woff2');}@font-face{font-family:'OpenSans';font-style:normal;font-weight:700;src:url('/fonts/OpenSans-Bold.woff2')format('woff2');}`
354 | );
355 | });
356 |
357 | it('Should not merge @when / @else', async () => {
358 | const css = `
359 | @when media(width >= 400px) and media(pointer: fine) and supports(display: flex) {
360 | .cond {
361 | color: red;
362 | }
363 | } @else supports(caret-color: pink) and supports(background: double-rainbow()) {
364 | .cond {
365 | color: white;
366 | }
367 | } @else {
368 | .cond {
369 | color: blue;
370 | }
371 | }
372 |
373 | @when media(width >= 400px) and media(pointer: fine) and supports(display: flex) {
374 | .cond2 {
375 | color: red;
376 | }
377 | } @else supports(caret-color: pink) and supports(background: double-rainbow()) {
378 | .cond2 {
379 | color: green;
380 | }
381 | } @else {
382 | .cond2 {
383 | color: blue;
384 | }
385 | }
386 | `;
387 |
388 | expect(clearWhiteSpaceAndCallATP(css)).toBe(
389 | `@whenmedia(width>=400px)andmedia(pointer:fine)andsupports(display:flex){.cond{color:red;}}@elsesupports(caret-color:pink)andsupports(background:double-rainbow()){.cond{color:white;}}@else{.cond{color:blue;}}@whenmedia(width>=400px)andmedia(pointer:fine)andsupports(display:flex){.cond2{color:red;}}@elsesupports(caret-color:pink)andsupports(background:double-rainbow()){.cond2{color:green;}}@else{.cond2{color:blue;}}`
390 | );
391 | });
392 |
393 | it('Should not merge @layer', async () => {
394 | const css = `
395 | @layer {
396 | font-family: 'Open Sans';
397 | }
398 |
399 | @layer reset, body;
400 |
401 | @layer reset {
402 | font-family: 'Open Serif';
403 | }
404 | `;
405 |
406 | expect(clearWhiteSpaceAndCallATP(css)).toBe(
407 | `@layer {font-family:'OpenSans';}@layer reset,body;@layer reset{font-family:'OpenSerif';}`
408 | );
409 | });
410 |
411 | it('Should not merge @property', async () => {
412 | const css = `
413 | @property --rotation {
414 | syntax: "";
415 | inherits: false;
416 | initial-value: 45deg;
417 | }
418 |
419 | @property --rotation {
420 | syntax: "";
421 | inherits: false;
422 | initial-value: 50deg;
423 | }
424 | `;
425 |
426 | expect(clearWhiteSpaceAndCallATP(css)).toBe(
427 | `@property--rotation{syntax:\"\";inherits:false;initial-value:45deg;}@property--rotation{syntax:\"\";inherits:false;initial-value:50deg;}`
428 | );
429 | });
430 |
431 | it('Can handle @view-transition', async () => {
432 | const css = `
433 | /* comment */
434 |
435 | @view-transition {
436 | navigation: auto;
437 | }
438 |
439 | @view-transition {
440 | navigation: none;
441 | }
442 | `;
443 |
444 | expect(clearWhiteSpaceAndCallATP(css)).toBe(
445 | '/*comment*/@view-transition{navigation:auto;navigation:none;}'
446 | );
447 | });
448 |
449 | it('Can handle @scope', async () => {
450 | const css = `
451 | @scope (.article-body) to (figure) {
452 | img {
453 | border: 5px solid black;
454 | background-color: goldenrod;
455 | }
456 | }
457 |
458 | @scope (.article-body) to (figure) {
459 | .text {
460 | color: white;
461 | }
462 | }
463 |
464 | @scope (.article-body) {
465 | img {
466 | border: 5px solid black;
467 | background-color: goldenrod;
468 | }
469 | }
470 |
471 | @scope (.article-body) {
472 | .text {
473 | color: red;
474 | }
475 | }
476 |
477 | @scope (.other) {
478 | .text {
479 | color: red;
480 | }
481 | }
482 | `;
483 |
484 | expect(clearWhiteSpaceAndCallATP(css)).toBe(
485 | `@scope(.article-body)to(figure){img{border:5pxsolidblack;background-color:goldenrod;}.text{color:white;}}@scope(.article-body){img{border:5pxsolidblack;background-color:goldenrod;}.text{color:red;}}@scope(.other){.text{color:red;}}`
486 | );
487 | });
488 |
489 | it('Real life example leveraging CSS variables', async () => {
490 | const css = `
491 | @media (min-width: 45em) {
492 | .headerToggleButton {
493 | display: none;
494 |
495 | }
496 |
497 | .headerNavList {
498 | flex-direction: row;
499 | gap: 1.25em;
500 | height: auto;
501 | position: static;
502 | transform: none;
503 | width: auto;
504 | }
505 | }
506 |
507 |
508 |
509 |
510 | @media (min-width: 45em) {
511 | .headerNav {
512 | background: none;
513 | color: rgb(var(--menu-text));
514 | font-size: 1em;
515 | height: auto;
516 | position: static;
517 | transform: none;
518 | width: auto;
519 | }
520 | .headerNavList>* {
521 | transform: none;
522 | width: auto;
523 | }
524 | }
525 |
526 | @media (min-width: 45em) {
527 | .headerNavlinkHome {
528 | display: none;
529 | }
530 | }
531 | @keyframes float {
532 | 50% {
533 | transform: translateY(-10%) rotate(10deg);
534 | }
535 | }
536 | `;
537 |
538 | expect(clearWhiteSpaceAndCallATP(css)).toBe(
539 | `@media(min-width:45em){.headerToggleButton{display:none;}.headerNavList{flex-direction:row;gap:1.25em;height:auto;position:static;transform:none;width:auto;}.headerNav{background:none;color:rgb(var(--menu-text));font-size:1em;height:auto;position:static;transform:none;width:auto;}.headerNavList>*{transform:none;width:auto;}.headerNavlinkHome{display:none;}}@keyframesfloat{50%{transform:translateY(-10%)rotate(10deg);}}`
540 | );
541 | });
542 | });
543 |
--------------------------------------------------------------------------------