@karlhillx/css-rules-sorter 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +129 -0
- package/index.js +286 -0
- package/package.json +45 -0
- package/test/css-rules-sorter.test.js +122 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2024 Karl Hill <karlhillx@gmail.com>
|
|
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.
|
package/README.md
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
# CSS Rules Sorter
|
|
2
|
+
|
|
3
|
+
✨ A PostCSS plugin to automatically sort CSS selectors and media queries for cleaner, more maintainable stylesheets.
|
|
4
|
+
|
|
5
|
+
[](https://badge.fury.io/js/@karlhillx/css-rules-sorter)
|
|
6
|
+
[](https://opensource.org/licenses/MIT)
|
|
7
|
+
[](https://github.com/postcss/postcss)
|
|
8
|
+
[](https://github.com/karlhillx/@karlhillx/css-rules-sorter/actions/workflows/ci.yml)
|
|
9
|
+
[](https://github.com/karlhillx/@karlhillx/css-rules-sorter/tree/main/test)
|
|
10
|
+
|
|
11
|
+
## Key Features
|
|
12
|
+
|
|
13
|
+
- **Alphabetical Selector Sorting**: Automatically sorts top-level selectors and rules within media queries for consistent code.
|
|
14
|
+
- **Intelligent Media Query Management**: Flexible ordering (mobile-first or desktop-first) and grouping using `postcss-sort-media-queries`.
|
|
15
|
+
- **Advanced Methodologies**: Built-in support for BEM-aware sorting.
|
|
16
|
+
- **Cascade Layer Support**: Automatically reorders `@layer` blocks to match your defined priority.
|
|
17
|
+
- **Modern PostCSS Support**: Fully compatible with the latest PostCSS 8+ API.
|
|
18
|
+
- **Developer-Focused**: Includes TypeScript definitions, comprehensive tests, and linting.
|
|
19
|
+
|
|
20
|
+
## Installation
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
npm install @karlhillx/css-rules-sorter postcss --save-dev
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## Usage
|
|
27
|
+
|
|
28
|
+
Add `@karlhillx/css-rules-sorter` to your PostCSS configuration (e.g., `postcss.config.js`):
|
|
29
|
+
|
|
30
|
+
```javascript
|
|
31
|
+
// postcss.config.js
|
|
32
|
+
module.exports = {
|
|
33
|
+
plugins: [
|
|
34
|
+
require('@karlhillx/css-rules-sorter')({
|
|
35
|
+
sort: 'mobile-first', // or 'desktop-first'
|
|
36
|
+
selectorSort: 'natural', // or 'bem' or 'specificity'
|
|
37
|
+
propertyShorthand: 'expand', // or 'collapse'
|
|
38
|
+
}),
|
|
39
|
+
],
|
|
40
|
+
};
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## Configuration Options
|
|
44
|
+
|
|
45
|
+
| Option | Type | Default | Description |
|
|
46
|
+
| ------------------ | --------- | ---------------- | ------------------------------------------------------------------------------ |
|
|
47
|
+
| `sort` | `string` | `'mobile-first'` | Sets the media query order: `'mobile-first'` or `'desktop-first'`. |
|
|
48
|
+
| `selectorSort` | `string` | `'natural'` | Defines the method for sorting selectors: `'natural'`, `'specificity'`, or `'bem'`. |
|
|
49
|
+
| `propertySort` | `string` | `'none'` | Sorts properties within each rule: `'none'` or `'alphabetical'`. |
|
|
50
|
+
| `propertyShorthand`| `string` | `'none'` | Manages shorthand properties: `'none'`, `'expand'`, or `'collapse'`. |
|
|
51
|
+
| `sortLayers` | `boolean` | `true` | Sorts CSS cascade layers based on the first `@layer` definition. |
|
|
52
|
+
| `groupByMediaType` | `boolean` | `true` | Groups media queries by their type (e.g., `screen`, `print`). |
|
|
53
|
+
|
|
54
|
+
## Advanced Features
|
|
55
|
+
|
|
56
|
+
### BEM Selector Sorting
|
|
57
|
+
|
|
58
|
+
Set `selectorSort: 'bem'` to sort selectors based on the Block, Element, Modifier (BEM) methodology. This ensures your component structures stay logically grouped.
|
|
59
|
+
|
|
60
|
+
**Before:**
|
|
61
|
+
```css
|
|
62
|
+
.card__header--small { font-size: 0.8em; }
|
|
63
|
+
.card--featured { border: 1px solid blue; }
|
|
64
|
+
.card { color: black; }
|
|
65
|
+
.card__header { font-weight: bold; }
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
**After:**
|
|
69
|
+
```css
|
|
70
|
+
.card { color: black; }
|
|
71
|
+
.card--featured { border: 1px solid blue; }
|
|
72
|
+
.card__header { font-weight: bold; }
|
|
73
|
+
.card__header--small { font-size: 0.8em; }
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
### Shorthand Property Management
|
|
77
|
+
|
|
78
|
+
Use `propertyShorthand` to enforce a specific style for `margin` and `padding`.
|
|
79
|
+
|
|
80
|
+
- **`'expand'`**: Breaks down shorthands like `margin: 10px 20px;` into four longhand properties. Excellent for preventing accidental overrides.
|
|
81
|
+
- **`'collapse'`**: Combines longhand properties into a single shorthand when all four sides are present.
|
|
82
|
+
|
|
83
|
+
### Cascade Layer Sorting
|
|
84
|
+
|
|
85
|
+
Set `sortLayers: true` to automatically reorder your `@layer` blocks to match the priority defined in your first `@layer` rule.
|
|
86
|
+
|
|
87
|
+
**Before:**
|
|
88
|
+
```css
|
|
89
|
+
@layer components, base, reset;
|
|
90
|
+
|
|
91
|
+
@layer base { body { color: red; } }
|
|
92
|
+
@layer reset { * { margin: 0; } }
|
|
93
|
+
@layer components { .btn { padding: 1em; } }
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
**After:**
|
|
97
|
+
```css
|
|
98
|
+
@layer reset { * { margin: 0; } }
|
|
99
|
+
@layer base { body { color: red; } }
|
|
100
|
+
@layer components { .btn { padding: 1em; } }
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
## Development
|
|
104
|
+
|
|
105
|
+
```bash
|
|
106
|
+
# Install all dependencies
|
|
107
|
+
npm install
|
|
108
|
+
|
|
109
|
+
# Run tests
|
|
110
|
+
npm test
|
|
111
|
+
|
|
112
|
+
# Lint code
|
|
113
|
+
npm run lint
|
|
114
|
+
|
|
115
|
+
# Format code
|
|
116
|
+
npm run format
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
## Contributing
|
|
120
|
+
|
|
121
|
+
1. Fork the repository.
|
|
122
|
+
2. Create a new feature branch (`git checkout -b feature/your-feature`).
|
|
123
|
+
3. Commit your changes (`git commit -m 'Add your feature'`).
|
|
124
|
+
4. Push to the branch (`git push origin feature/your-feature`).
|
|
125
|
+
5. Create a Pull Request.
|
|
126
|
+
|
|
127
|
+
## License
|
|
128
|
+
|
|
129
|
+
MIT License. See [LICENSE](LICENSE) for details.
|
package/index.js
ADDED
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
const sortMediaQueries = require('postcss-sort-media-queries');
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Parses a BEM selector into its parts.
|
|
5
|
+
* .block__element--modifier
|
|
6
|
+
*/
|
|
7
|
+
function parseBEM(selector) {
|
|
8
|
+
// Support both .block__element and .block--modifier and .block__element--modifier
|
|
9
|
+
// and simple .block
|
|
10
|
+
const match = selector.match(/^\.([a-zA-Z0-9-]+?)(?:__([a-zA-Z0-9-]+?))?(?:--([a-zA-Z0-9-]+?))?$/);
|
|
11
|
+
if (!match) return { block: selector, element: '', modifier: '', original: selector };
|
|
12
|
+
return {
|
|
13
|
+
block: match[1],
|
|
14
|
+
element: match[2] || '',
|
|
15
|
+
modifier: match[3] || '',
|
|
16
|
+
original: selector
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Compare two BEM selectors.
|
|
22
|
+
*/
|
|
23
|
+
function compareBEM(a, b) {
|
|
24
|
+
const bemA = parseBEM(a);
|
|
25
|
+
const bemB = parseBEM(b);
|
|
26
|
+
|
|
27
|
+
if (bemA.block !== bemB.block) {
|
|
28
|
+
return bemA.block.localeCompare(bemB.block);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Same block.
|
|
32
|
+
// Desired order:
|
|
33
|
+
// 1. Block itself (.card)
|
|
34
|
+
// 2. Block modifiers (.card--featured)
|
|
35
|
+
// 3. Elements (.card__header)
|
|
36
|
+
// 4. Element modifiers (.card__header--active)
|
|
37
|
+
|
|
38
|
+
// Case 1: Both are the base block
|
|
39
|
+
if (!bemA.element && !bemA.modifier && !bemB.element && !bemB.modifier) return 0;
|
|
40
|
+
|
|
41
|
+
// Case 2: One is base block
|
|
42
|
+
if (!bemA.element && !bemA.modifier) return -1;
|
|
43
|
+
if (!bemB.element && !bemB.modifier) return 1;
|
|
44
|
+
|
|
45
|
+
// Case 3: Both are block modifiers (no element)
|
|
46
|
+
if (bemA.modifier && !bemA.element && bemB.modifier && !bemB.element) {
|
|
47
|
+
return bemA.modifier.localeCompare(bemB.modifier);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Case 4: One is block modifier, other is element (or element modifier)
|
|
51
|
+
if (bemA.modifier && !bemA.element && bemB.element) return -1;
|
|
52
|
+
if (bemB.modifier && !bemB.element && bemA.element) return 1;
|
|
53
|
+
|
|
54
|
+
// Case 5: Both are elements
|
|
55
|
+
if (bemA.element && bemB.element) {
|
|
56
|
+
if (bemA.element !== bemB.element) {
|
|
57
|
+
return bemA.element.localeCompare(bemB.element);
|
|
58
|
+
}
|
|
59
|
+
// Same element, check modifiers
|
|
60
|
+
if (!bemA.modifier && bemB.modifier) return -1;
|
|
61
|
+
if (bemA.modifier && !bemB.modifier) return 1;
|
|
62
|
+
return (bemA.modifier || '').localeCompare(bemB.modifier || '');
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return a.localeCompare(b);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Expand shorthand properties.
|
|
70
|
+
*/
|
|
71
|
+
function expandShorthand(decl) {
|
|
72
|
+
if (decl.prop === 'border') {
|
|
73
|
+
const values = decl.value.split(/\s+/);
|
|
74
|
+
const width = values.find(v => v.match(/^\d|thin|medium|thick/)) || 'medium';
|
|
75
|
+
const style = values.find(v => v.match(/none|hidden|dotted|dashed|solid|double|groove|ridge|inset|outset/)) || 'none';
|
|
76
|
+
const color = values.find(v => !v.match(/^\d|thin|medium|thick|none|hidden|dotted|dashed|solid|double|groove|ridge|inset|outset/)) || 'black';
|
|
77
|
+
|
|
78
|
+
decl.cloneBefore({ prop: 'border-width', value: width });
|
|
79
|
+
decl.cloneBefore({ prop: 'border-style', value: style });
|
|
80
|
+
decl.cloneBefore({ prop: 'border-color', value: color });
|
|
81
|
+
decl.remove();
|
|
82
|
+
} else if (decl.prop === 'margin' || decl.prop === 'padding') {
|
|
83
|
+
const values = decl.value.split(/\s+/);
|
|
84
|
+
let top, right, bottom, left;
|
|
85
|
+
|
|
86
|
+
if (values.length === 1) {
|
|
87
|
+
top = right = bottom = left = values[0];
|
|
88
|
+
} else if (values.length === 2) {
|
|
89
|
+
top = bottom = values[0];
|
|
90
|
+
right = left = values[1];
|
|
91
|
+
} else if (values.length === 3) {
|
|
92
|
+
top = values[0];
|
|
93
|
+
right = left = values[1];
|
|
94
|
+
bottom = values[2];
|
|
95
|
+
} else {
|
|
96
|
+
top = values[0];
|
|
97
|
+
right = values[1];
|
|
98
|
+
bottom = values[2];
|
|
99
|
+
left = values[3];
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
decl.cloneBefore({ prop: `${decl.prop}-top`, value: top });
|
|
103
|
+
decl.cloneBefore({ prop: `${decl.prop}-right`, value: right });
|
|
104
|
+
decl.cloneBefore({ prop: `${decl.prop}-bottom`, value: bottom });
|
|
105
|
+
decl.cloneBefore({ prop: `${decl.prop}-left`, value: left });
|
|
106
|
+
decl.remove();
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Collapse longhand properties.
|
|
112
|
+
*/
|
|
113
|
+
function collapseLonghands(rule) {
|
|
114
|
+
const properties = {};
|
|
115
|
+
rule.walkDecls(decl => {
|
|
116
|
+
properties[decl.prop] = decl;
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
// Border collapse
|
|
120
|
+
if (properties['border-width'] && properties['border-style'] && properties['border-color']) {
|
|
121
|
+
properties['border-width'].cloneBefore({
|
|
122
|
+
prop: 'border',
|
|
123
|
+
value: `${properties['border-width'].value} ${properties['border-style'].value} ${properties['border-color'].value}`
|
|
124
|
+
});
|
|
125
|
+
properties['border-width'].remove();
|
|
126
|
+
properties['border-style'].remove();
|
|
127
|
+
properties['border-color'].remove();
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Margin/Padding collapse
|
|
131
|
+
['margin', 'padding'].forEach(type => {
|
|
132
|
+
const top = properties[`${type}-top`];
|
|
133
|
+
const right = properties[`${type}-right`];
|
|
134
|
+
const bottom = properties[`${type}-bottom`];
|
|
135
|
+
const left = properties[`${type}-left`];
|
|
136
|
+
|
|
137
|
+
if (top && right && bottom && left) {
|
|
138
|
+
let value;
|
|
139
|
+
if (top.value === right.value && top.value === bottom.value && top.value === left.value) {
|
|
140
|
+
value = top.value;
|
|
141
|
+
} else if (top.value === bottom.value && right.value === left.value) {
|
|
142
|
+
value = `${top.value} ${right.value}`;
|
|
143
|
+
} else if (right.value === left.value) {
|
|
144
|
+
value = `${top.value} ${right.value} ${bottom.value}`;
|
|
145
|
+
} else {
|
|
146
|
+
value = `${top.value} ${right.value} ${bottom.value} ${left.value}`;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
top.cloneBefore({ prop: type, value });
|
|
150
|
+
top.remove();
|
|
151
|
+
right.remove();
|
|
152
|
+
bottom.remove();
|
|
153
|
+
left.remove();
|
|
154
|
+
}
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const specificity = (selector) => {
|
|
159
|
+
const selectorWithoutPseudoElements = selector.replace(/::?[a-zA-Z-]+/g, '');
|
|
160
|
+
const ids = (selectorWithoutPseudoElements.match(/#/g) || []).length;
|
|
161
|
+
const classesAndAttributes = (selectorWithoutPseudoElements.match(/\.|\[/g) || []).length;
|
|
162
|
+
const elements = (selectorWithoutPseudoElements.match(/[a-zA-Z-]+\b(?!#|\.)/g) || []).length;
|
|
163
|
+
return ids * 100 + classesAndAttributes * 10 + elements;
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* PostCSS plugin to sort CSS rules
|
|
168
|
+
* @param {Object} opts
|
|
169
|
+
* @returns {import('postcss').Plugin}
|
|
170
|
+
*/
|
|
171
|
+
const cssRulesSorterPlugin = (opts = {}) => {
|
|
172
|
+
const defaultOptions = {
|
|
173
|
+
sort: 'mobile-first',
|
|
174
|
+
selectorSort: 'natural',
|
|
175
|
+
propertySort: 'natural',
|
|
176
|
+
propertyShorthand: 'none',
|
|
177
|
+
sortLayers: false,
|
|
178
|
+
groupByMediaType: true,
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
const config = { ...defaultOptions, ...opts };
|
|
182
|
+
|
|
183
|
+
return {
|
|
184
|
+
postcssPlugin: 'css-rules-sorter',
|
|
185
|
+
Once(root) {
|
|
186
|
+
// 0. Layer sorting
|
|
187
|
+
if (config.sortLayers) {
|
|
188
|
+
const layerOrderAtRule = root.nodes.find(node => node.type === 'atrule' && node.name === 'layer' && node.params.includes(','));
|
|
189
|
+
if (layerOrderAtRule) {
|
|
190
|
+
const order = layerOrderAtRule.params.split(',').map(s => s.trim());
|
|
191
|
+
const layerBlocks = root.nodes.filter(node => node.type === 'atrule' && node.name === 'layer' && !node.params.includes(','));
|
|
192
|
+
|
|
193
|
+
const sortedLayers = [...layerBlocks].sort((a, b) => {
|
|
194
|
+
return order.indexOf(a.params.trim()) - order.indexOf(b.params.trim());
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
// Re-insert in order after the definition
|
|
198
|
+
let lastNode = layerOrderAtRule;
|
|
199
|
+
sortedLayers.forEach(layer => {
|
|
200
|
+
const clone = layer.clone();
|
|
201
|
+
layer.remove();
|
|
202
|
+
lastNode.after(clone);
|
|
203
|
+
lastNode = clone;
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// 1. Property Management
|
|
209
|
+
root.walkRules(rule => {
|
|
210
|
+
if (config.propertyShorthand === 'expand') {
|
|
211
|
+
rule.walkDecls(expandShorthand);
|
|
212
|
+
} else if (config.propertyShorthand === 'collapse') {
|
|
213
|
+
collapseLonghands(rule);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Property Sorting
|
|
217
|
+
if (config.propertySort === 'natural') {
|
|
218
|
+
const decls = rule.nodes.filter(node => node.type === 'decl');
|
|
219
|
+
if (decls.length > 1) {
|
|
220
|
+
const sorted = [...decls].sort((a, b) => a.prop.localeCompare(b.prop));
|
|
221
|
+
decls.forEach((decl, idx) => {
|
|
222
|
+
decl.replaceWith(sorted[idx].clone());
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
const sortSelectors = (a, b) => {
|
|
229
|
+
if (config.selectorSort === 'bem') {
|
|
230
|
+
return compareBEM(a.selector, b.selector);
|
|
231
|
+
}
|
|
232
|
+
if (config.selectorSort === 'specificity') {
|
|
233
|
+
return specificity(a.selector) - specificity(b.selector);
|
|
234
|
+
}
|
|
235
|
+
return a.selector.toLowerCase().localeCompare(b.selector.toLowerCase());
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
// 2. Sort rules within media queries
|
|
239
|
+
root.walkAtRules('media', (atRule) => {
|
|
240
|
+
const rules = atRule.nodes.filter((node) => node.type === 'rule');
|
|
241
|
+
if (rules.length === 0) return;
|
|
242
|
+
|
|
243
|
+
const sorted = [...rules].sort(sortSelectors);
|
|
244
|
+
|
|
245
|
+
// Replace rules in place
|
|
246
|
+
rules.forEach((rule, idx) => {
|
|
247
|
+
rule.replaceWith(sorted[idx].clone());
|
|
248
|
+
});
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
// 3. Sort top-level rules
|
|
252
|
+
const topRules = root.nodes.filter((node) => node.type === 'rule');
|
|
253
|
+
|
|
254
|
+
if (topRules.length > 0) {
|
|
255
|
+
const sortedTopRules = [...topRules].sort(sortSelectors);
|
|
256
|
+
|
|
257
|
+
topRules.forEach((rule, idx) => {
|
|
258
|
+
rule.replaceWith(sortedTopRules[idx].clone());
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
},
|
|
262
|
+
async OnceExit(root, { postcss }) {
|
|
263
|
+
await postcss([sortMediaQueries(config)]).process(root, { from: undefined });
|
|
264
|
+
},
|
|
265
|
+
};
|
|
266
|
+
};
|
|
267
|
+
|
|
268
|
+
cssRulesSorterPlugin.postcss = true;
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Main export as a function that can also be used as a standalone processor
|
|
272
|
+
*/
|
|
273
|
+
function main(opts = {}) {
|
|
274
|
+
const plugin = cssRulesSorterPlugin(opts);
|
|
275
|
+
|
|
276
|
+
// Attach process method for standalone usage (backward compatibility)
|
|
277
|
+
plugin.process = async (css) => {
|
|
278
|
+
const postcss = require('postcss');
|
|
279
|
+
const result = await postcss([plugin]).process(css, { from: undefined });
|
|
280
|
+
return result.css;
|
|
281
|
+
};
|
|
282
|
+
|
|
283
|
+
return plugin;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
module.exports = main;
|
package/package.json
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@karlhillx/css-rules-sorter",
|
|
3
|
+
"version": "1.1.0",
|
|
4
|
+
"description": "PostCSS-based CSS rules sorter that alphabetically sorts selectors and media queries",
|
|
5
|
+
"main": "index.js",
|
|
6
|
+
"types": "index.d.ts",
|
|
7
|
+
"files": [
|
|
8
|
+
"index.js",
|
|
9
|
+
"README.md",
|
|
10
|
+
"LICENSE",
|
|
11
|
+
"test/"
|
|
12
|
+
],
|
|
13
|
+
"scripts": {
|
|
14
|
+
"test": "jest --coverage",
|
|
15
|
+
"lint": "eslint .",
|
|
16
|
+
"format": "prettier --write .",
|
|
17
|
+
"prepare": "npm test"
|
|
18
|
+
},
|
|
19
|
+
"keywords": [
|
|
20
|
+
"css",
|
|
21
|
+
"postcss",
|
|
22
|
+
"sorter",
|
|
23
|
+
"css-sorter",
|
|
24
|
+
"stylesheet"
|
|
25
|
+
],
|
|
26
|
+
"author": "Karl Hill",
|
|
27
|
+
"license": "MIT",
|
|
28
|
+
"repository": {
|
|
29
|
+
"type": "git",
|
|
30
|
+
"url": "git+https://github.com/karlhillx/css-rules-sorter.git"
|
|
31
|
+
},
|
|
32
|
+
"dependencies": {
|
|
33
|
+
"postcss": "^8.4.0",
|
|
34
|
+
"postcss-sort-media-queries": "^4.0.0"
|
|
35
|
+
},
|
|
36
|
+
"devDependencies": {
|
|
37
|
+
"@eslint/js": "^10.0.1",
|
|
38
|
+
"eslint": "^10.0.3",
|
|
39
|
+
"eslint-config-prettier": "^10.1.8",
|
|
40
|
+
"eslint-plugin-jest": "^29.15.0",
|
|
41
|
+
"globals": "^17.4.0",
|
|
42
|
+
"jest": "^29.0.0",
|
|
43
|
+
"prettier": "^3.8.1"
|
|
44
|
+
}
|
|
45
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
const cssRulesSorter = require('../index');
|
|
2
|
+
const postcss = require('postcss');
|
|
3
|
+
|
|
4
|
+
async function run(input, opts = {}) {
|
|
5
|
+
const result = await postcss([cssRulesSorter(opts)]).process(input, { from: undefined });
|
|
6
|
+
return result.css;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
describe('css-rules-sorter', () => {
|
|
10
|
+
test('works with standalone process method', async () => {
|
|
11
|
+
const input = `.z { color: 0; } .a { color: 1; }`;
|
|
12
|
+
const sorter = cssRulesSorter();
|
|
13
|
+
const output = await sorter.process(input);
|
|
14
|
+
expect(output).toMatch(/\.a[\s\S]*\.z/);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
describe('Basic Sorting', () => {
|
|
18
|
+
test('sorts top-level selectors alphabetically', async () => {
|
|
19
|
+
const input = `
|
|
20
|
+
.zebra { color: black; }
|
|
21
|
+
.apple { color: red; }
|
|
22
|
+
.banana { color: yellow; }
|
|
23
|
+
`;
|
|
24
|
+
const output = await run(input);
|
|
25
|
+
expect(output).toMatch(/\.apple[\s\S]*\.banana[\s\S]*\.zebra/);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test('sorts selectors within media queries alphabetically', async () => {
|
|
29
|
+
const input = `
|
|
30
|
+
@media (min-width: 768px) {
|
|
31
|
+
.zebra { color: black; }
|
|
32
|
+
.apple { color: red; }
|
|
33
|
+
}
|
|
34
|
+
`;
|
|
35
|
+
const output = await run(input);
|
|
36
|
+
expect(output).toMatch(/@media[\s\S]*\.apple[\s\S]*\.zebra/);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test('organizes media queries (mobile-first by default)', async () => {
|
|
40
|
+
const input = `
|
|
41
|
+
@media (min-width: 1024px) { .large { color: blue; } }
|
|
42
|
+
@media (min-width: 768px) { .medium { color: green; } }
|
|
43
|
+
.top { color: red; }
|
|
44
|
+
`;
|
|
45
|
+
const output = await run(input);
|
|
46
|
+
const mediumIndex = output.indexOf('min-width: 768px');
|
|
47
|
+
const largeIndex = output.indexOf('min-width: 1024px');
|
|
48
|
+
expect(mediumIndex).toBeLessThan(largeIndex);
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
describe('Architectural Features', () => {
|
|
53
|
+
test('BEM sorting groups modifiers and elements correctly', async () => {
|
|
54
|
+
const input = `
|
|
55
|
+
.card__header { color: grey; }
|
|
56
|
+
.card--featured { color: gold; }
|
|
57
|
+
.card { color: black; }
|
|
58
|
+
`;
|
|
59
|
+
const output = await run(input, { selectorSort: 'bem' });
|
|
60
|
+
const card = output.indexOf('.card {');
|
|
61
|
+
const featured = output.indexOf('.card--featured');
|
|
62
|
+
const header = output.indexOf('.card__header');
|
|
63
|
+
expect(card).toBeLessThan(featured);
|
|
64
|
+
expect(featured).toBeLessThan(header);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test('Specificity sorting orders low to high', async () => {
|
|
68
|
+
const input = `
|
|
69
|
+
#id { color: red; }
|
|
70
|
+
.class { color: blue; }
|
|
71
|
+
element { color: green; }
|
|
72
|
+
`;
|
|
73
|
+
const output = await run(input, { selectorSort: 'specificity' });
|
|
74
|
+
const el = output.indexOf('element {');
|
|
75
|
+
const cl = output.indexOf('.class {');
|
|
76
|
+
const id = output.indexOf('#id {');
|
|
77
|
+
expect(el).toBeLessThan(cl);
|
|
78
|
+
expect(cl).toBeLessThan(id);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test('Shorthand expansion (expand)', async () => {
|
|
82
|
+
const input = `.box { margin: 10px 20px; }`;
|
|
83
|
+
const output = await run(input, { propertyShorthand: 'expand' });
|
|
84
|
+
expect(output).toContain('margin-top: 10px');
|
|
85
|
+
expect(output).toContain('margin-right: 20px');
|
|
86
|
+
expect(output).toContain('margin-bottom: 10px');
|
|
87
|
+
expect(output).toContain('margin-left: 20px');
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test('Shorthand collapsing (collapse)', async () => {
|
|
91
|
+
const input = `.box { margin-top: 10px; margin-right: 20px; margin-bottom: 10px; margin-left: 20px; }`;
|
|
92
|
+
const output = await run(input, { propertyShorthand: 'collapse' });
|
|
93
|
+
expect(output).toContain('margin: 10px 20px');
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test('Cascade Layer reordering', async () => {
|
|
97
|
+
const input = `
|
|
98
|
+
@layer components, reset, base;
|
|
99
|
+
@layer base { body { margin: 0; } }
|
|
100
|
+
@layer reset { * { box-sizing: border-box; } }
|
|
101
|
+
@layer components { .card { padding: 1rem; } }
|
|
102
|
+
`;
|
|
103
|
+
const output = await run(input, { sortLayers: true });
|
|
104
|
+
const comp = output.indexOf('@layer components {');
|
|
105
|
+
const reset = output.indexOf('@layer reset {');
|
|
106
|
+
const base = output.indexOf('@layer base {');
|
|
107
|
+
expect(comp).toBeLessThan(reset);
|
|
108
|
+
expect(reset).toBeLessThan(base);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test('Property sorting alphabetically by default', async () => {
|
|
112
|
+
const input = `.box { z-index: 10; color: red; background: blue; }`;
|
|
113
|
+
const output = await run(input);
|
|
114
|
+
const background = output.indexOf('background: blue');
|
|
115
|
+
const color = output.indexOf('color: red');
|
|
116
|
+
const zIndex = output.indexOf('z-index: 10');
|
|
117
|
+
|
|
118
|
+
expect(background).toBeLessThan(color);
|
|
119
|
+
expect(color).toBeLessThan(zIndex);
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
});
|