postcss-ruler 1.0.1 → 1.2.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.
Files changed (3) hide show
  1. package/README.md +237 -30
  2. package/index.js +398 -258
  3. package/package.json +2 -2
package/README.md CHANGED
@@ -9,12 +9,14 @@ A PostCSS plugin that generates fluid CSS scales and values using the `clamp()`
9
9
  Working with design teams often means dealing with sizes that don't follow perfect mathematical ratios. A designer might spec `16px → 24px` for one size and `32px → 56px` for another based on visual harmony rather than a type scale.
10
10
 
11
11
  postcss-ruler embraces this reality. You get:
12
+
12
13
  - **Fine-tuned control**: Set exact min and max values per size
13
14
  - **Design tool compatibility**: Match your Figma specs precisely
14
15
  - **Fluid behavior**: Smooth scaling between breakpoints via `clamp()`
15
16
  - **Cross pairs**: Generate spacing between any two sizes (explained below)
16
17
 
17
18
  ## Installation
19
+
18
20
  ```bash
19
21
  npm install postcss-ruler
20
22
  ```
@@ -22,24 +24,26 @@ npm install postcss-ruler
22
24
  ## Usage
23
25
 
24
26
  Add the plugin to your PostCSS configuration:
27
+
25
28
  ```javascript
26
29
  // postcss.config.js
27
30
  module.exports = {
28
31
  plugins: {
29
- 'postcss-ruler': {
30
- minWidth: 320, // Default minimum viewport width
31
- maxWidth: 1760, // Default maximum viewport width
32
- generateAllCrossPairs: false
33
- }
34
- }
35
- }
32
+ "postcss-ruler": {
33
+ minWidth: 320, // Default minimum viewport width
34
+ maxWidth: 1760, // Default maximum viewport width
35
+ generateAllCrossPairs: false,
36
+ },
37
+ },
38
+ };
36
39
  ```
37
40
 
38
41
  ## Features
39
42
 
40
- ### 1. At-Rule Mode: Generate Fluid Scale
43
+ ### 1. Scale Generation: Create Fluid Scales
41
44
 
42
45
  Create multiple CSS custom properties from named size pairs:
46
+
43
47
  ```css
44
48
  @ruler scale({
45
49
  minWidth: 320,
@@ -57,6 +61,7 @@ Create multiple CSS custom properties from named size pairs:
57
61
  ```
58
62
 
59
63
  **Generates:**
64
+
60
65
  ```css
61
66
  --space-xs: clamp(0.5rem, 0.4545vw + 0.3636rem, 1rem);
62
67
  --space-sm: clamp(1rem, 0.4545vw + 0.8636rem, 1.5rem);
@@ -74,6 +79,7 @@ For example, if you have `xs: [8, 16]` and `lg: [32, 48]`, a cross pair `xs-lg`
74
79
  **Without cross pairs**, you only get the sizes you explicitly define.
75
80
 
76
81
  **With `generateAllCrossPairs: true`**, you get every possible combination:
82
+
77
83
  ```css
78
84
  @ruler scale({
79
85
  prefix: 'space',
@@ -87,6 +93,7 @@ For example, if you have `xs: [8, 16]` and `lg: [32, 48]`, a cross pair `xs-lg`
87
93
  ```
88
94
 
89
95
  **Generates:**
96
+
90
97
  ```css
91
98
  /* Your defined pairs */
92
99
  --space-xs: clamp(0.5rem, ...);
@@ -94,21 +101,111 @@ For example, if you have `xs: [8, 16]` and `lg: [32, 48]`, a cross pair `xs-lg`
94
101
  --space-lg: clamp(2rem, ...);
95
102
 
96
103
  /* All cross combinations */
97
- --space-xs-md: clamp(0.5rem, 1.3636vw + 0.3636rem, 2rem); /* 8px → 32px */
98
- --space-xs-lg: clamp(0.5rem, 2.2727vw + 0.3636rem, 3rem); /* 8px → 48px */
99
- --space-md-lg: clamp(1.5rem, 0.9091vw + 1.3636rem, 3rem); /* 24px → 48px */
104
+ --space-xs-md: clamp(0.5rem, 1.3636vw + 0.3636rem, 2rem); /* 8px → 32px */
105
+ --space-xs-lg: clamp(0.5rem, 2.2727vw + 0.3636rem, 3rem); /* 8px → 48px */
106
+ --space-md-lg: clamp(1.5rem, 0.9091vw + 1.3636rem, 3rem); /* 24px → 48px */
100
107
  ```
101
108
 
102
109
  **Use case**: Section padding that needs more dramatic scaling than your base sizes allow.
110
+
103
111
  ```css
104
112
  .hero {
105
113
  padding-block: var(--space-md-xl); /* Scales across a wider range */
106
114
  }
107
115
  ```
108
116
 
109
- ### 2. Inline Mode: Fluid Function
117
+ ### 2. Utility Class Generation: Auto-Generate Utility Classes
118
+
119
+ Generate utility classes from your defined scales with complete selector flexibility:
120
+
121
+ ```css
122
+ @ruler scale({
123
+ prefix: 'space',
124
+ pairs: {
125
+ "xs": [8, 16],
126
+ "sm": [16, 24],
127
+ "md": [24, 32]
128
+ }
129
+ });
130
+
131
+ /* Basic class selector */
132
+ @ruler utility({
133
+ selector: '.gap',
134
+ property: 'gap',
135
+ scale: 'space'
136
+ });
137
+
138
+ /* Nested selector with & (for PostCSS nesting) */
139
+ @ruler utility({
140
+ selector: '&.active',
141
+ property: 'padding',
142
+ scale: 'space'
143
+ });
144
+
145
+ /* Multiple properties */
146
+ @ruler utility({
147
+ selector: '.p-block',
148
+ property: ['padding-top', 'padding-bottom'],
149
+ scale: 'space'
150
+ });
151
+ ```
152
+
153
+ **Generates:**
154
+
155
+ ```css
156
+ --space-xs: clamp(0.5rem, 0.5556vw + 0.3889rem, 1rem);
157
+ --space-sm: clamp(1rem, 0.5556vw + 0.8889rem, 1.5rem);
158
+ --space-md: clamp(1.5rem, 0.5556vw + 1.3889rem, 2rem);
159
+
160
+ .gap-xs {
161
+ gap: clamp(0.5rem, 0.5556vw + 0.3889rem, 1rem);
162
+ }
163
+ .gap-sm {
164
+ gap: clamp(1rem, 0.5556vw + 0.8889rem, 1.5rem);
165
+ }
166
+ .gap-md {
167
+ gap: clamp(1.5rem, 0.5556vw + 1.3889rem, 2rem);
168
+ }
169
+
170
+ &.active-xs {
171
+ padding: clamp(0.5rem, 0.5556vw + 0.3889rem, 1rem);
172
+ }
173
+ &.active-sm {
174
+ padding: clamp(1rem, 0.5556vw + 0.8889rem, 1.5rem);
175
+ }
176
+ &.active-md {
177
+ padding: clamp(1.5rem, 0.5556vw + 1.3889rem, 2rem);
178
+ }
179
+
180
+ .p-block-xs {
181
+ padding-top: clamp(0.5rem, 0.5556vw + 0.3889rem, 1rem);
182
+ padding-bottom: clamp(0.5rem, 0.5556vw + 0.3889rem, 1rem);
183
+ }
184
+ /* ... */
185
+ ```
186
+
187
+ **Supported selector patterns:**
188
+
189
+ - **Class selectors**: `.gap` → `.gap-xs`, `.gap-sm`, `.gap-md`
190
+ - **Nested with &**: `&.active` → `&.active-xs`, `&.active-sm` (PostCSS nesting)
191
+ - **Multiple classes**: `.container.space` → `.container.space-xs`, etc.
192
+ - **ID selectors**: `#section` → `#section-xs`, `#section-sm`
193
+ - **Element selectors**: `section` → `section-xs`, `section-sm`
194
+ - **Parent context**: `.container &` → `.container &-xs`, etc.
195
+
196
+ **Utility options:**
197
+
198
+ | Option | Type | Required | Description |
199
+ | ----------------------- | --------------- | -------- | --------------------------------------------------------------------- |
200
+ | `selector` | string | Yes | Any valid CSS selector pattern (e.g., `.gap`, `&.active`, `#section`) |
201
+ | `property` | string or array | Yes | CSS property name(s) to apply the scale values to |
202
+ | `scale` | string | Yes | Name of a previously defined scale (the `prefix` value) |
203
+ | `generateAllCrossPairs` | boolean | No | Include/exclude cross-pairs (overrides scale default) |
204
+
205
+ ### 3. Inline Mode: Fluid Function
110
206
 
111
207
  Convert individual values directly to `clamp()` functions:
208
+
112
209
  ```css
113
210
  .element {
114
211
  /* Uses default minWidth/maxWidth from config */
@@ -123,11 +220,13 @@ Convert individual values directly to `clamp()` functions:
123
220
  ```
124
221
 
125
222
  **Generates:**
223
+
126
224
  ```css
127
225
  .element {
128
226
  font-size: clamp(1rem, 0.4545vw + 0.8636rem, 1.5rem);
129
227
  padding: clamp(0.75rem, 0.9091vw + 0.4773rem, 1.25rem);
130
- margin: clamp(0.5rem, 0.4545vw + 0.3636rem, 1rem) clamp(1rem, 0.9091vw + 0.7273rem, 2rem);
228
+ margin: clamp(0.5rem, 0.4545vw + 0.3636rem, 1rem)
229
+ clamp(1rem, 0.9091vw + 0.7273rem, 2rem);
131
230
  }
132
231
  ```
133
232
 
@@ -135,23 +234,23 @@ Convert individual values directly to `clamp()` functions:
135
234
 
136
235
  ### Plugin Options
137
236
 
138
- | Option | Type | Default | Description |
139
- |--------|------|---------|-------------|
140
- | `minWidth` | number | `320` | Default minimum viewport width in pixels |
141
- | `maxWidth` | number | `1760` | Default maximum viewport width in pixels |
237
+ | Option | Type | Default | Description |
238
+ | ----------------------- | ------- | ------- | ----------------------------------------- |
239
+ | `minWidth` | number | `320` | Default minimum viewport width in pixels |
240
+ | `maxWidth` | number | `1760` | Default maximum viewport width in pixels |
142
241
  | `generateAllCrossPairs` | boolean | `false` | Generate cross-combinations in scale mode |
143
242
 
144
243
  ### At-Rule Options
145
244
 
146
245
  All options can be overridden per `@ruler scale()` declaration:
147
246
 
148
- | Option | Type | Default | Description |
149
- |--------|------|---------|-------------|
150
- | `minWidth` | number | `320` | Minimum viewport width for this scale |
151
- | `maxWidth` | number | `1760` | Maximum viewport width for this scale |
152
- | `prefix` | string | `"space"` | Prefix for generated CSS custom properties |
153
- | `generateAllCrossPairs` | boolean | `false` | Generate cross-combinations for this scale |
154
- | `pairs` | object | required | Size pairs as `"name": [min, max]` |
247
+ | Option | Type | Default | Description |
248
+ | ----------------------- | ------- | --------- | ------------------------------------------ |
249
+ | `minWidth` | number | `320` | Minimum viewport width for this scale |
250
+ | `maxWidth` | number | `1760` | Maximum viewport width for this scale |
251
+ | `prefix` | string | `"space"` | Prefix for generated CSS custom properties |
252
+ | `generateAllCrossPairs` | boolean | `false` | Generate cross-combinations for this scale |
253
+ | `pairs` | object | required | Size pairs as `"name": [min, max]` |
155
254
 
156
255
  ### Inline Function Syntax
157
256
 
@@ -159,12 +258,62 @@ All options can be overridden per `@ruler scale()` declaration:
159
258
  ruler.fluid(minSize, maxSize[, minWidth, maxWidth])
160
259
  ```
161
260
 
162
- | Parameter | Type | Required | Description |
163
- |-----------|------|----------|-------------|
164
- | `minSize` | number | Yes | Minimum size in pixels |
165
- | `maxSize` | number | Yes | Maximum size in pixels |
166
- | `minWidth` | number | No | Minimum viewport width (uses config default) |
167
- | `maxWidth` | number | No | Maximum viewport width (uses config default) |
261
+ | Parameter | Type | Required | Description |
262
+ | ---------- | ------ | -------- | -------------------------------------------- |
263
+ | `minSize` | number | Yes | Minimum size in pixels |
264
+ | `maxSize` | number | Yes | Maximum size in pixels |
265
+ | `minWidth` | number | No | Minimum viewport width (uses config default) |
266
+ | `maxWidth` | number | No | Maximum viewport width (uses config default) |
267
+
268
+ ### Static Values
269
+
270
+ When min and max values are equal, postcss-ruler outputs a simple rem value instead of a clamp() function. This works in all three modes:
271
+
272
+ **Inline function:**
273
+
274
+ ```css
275
+ .element {
276
+ font-size: ruler.fluid(20, 20);
277
+ }
278
+ ```
279
+
280
+ **Generates:**
281
+
282
+ ```css
283
+ .element {
284
+ font-size: 1.25rem;
285
+ }
286
+ ```
287
+
288
+ **Scale generation:**
289
+
290
+ ```css
291
+ @ruler scale({
292
+ prefix: 'size',
293
+ pairs: {
294
+ "static": [16, 16],
295
+ "fluid": [16, 24]
296
+ }
297
+ });
298
+ ```
299
+
300
+ **Generates:**
301
+
302
+ ```css
303
+ --size-static: 1rem;
304
+ --size-fluid: clamp(1rem, 0.5556vw + 0.8889rem, 1.5rem);
305
+ ```
306
+
307
+ **Use case:** Mix static and fluid values in the same scale for consistency. Define your base unit as a static value and let other sizes scale fluidly from it.
308
+
309
+ ### Utility Options
310
+
311
+ | Option | Type | Default | Description |
312
+ | ----------------------- | --------------- | -------- | --------------------------------------------------------------------- |
313
+ | `selector` | string | required | Any valid CSS selector pattern (e.g., `.gap`, `&.active`, `#section`) |
314
+ | `property` | string or array | required | CSS property name(s) to apply the scale values to |
315
+ | `scale` | string | required | Name of a previously defined scale (the `prefix` value) |
316
+ | `generateAllCrossPairs` | boolean | No | Include/exclude cross-pairs (overrides scale default) |
168
317
 
169
318
  ## How It Works
170
319
 
@@ -189,6 +338,63 @@ At 1760px viewport: `1.5rem` (24px)
189
338
 
190
339
  ## Use Cases
191
340
 
341
+ ### Utility-First Workflow with Fluid Scales
342
+
343
+ Generate a complete set of utility classes from your design system:
344
+
345
+ ```css
346
+ @ruler scale({
347
+ prefix: 'space',
348
+ generateAllCrossPairs: true,
349
+ pairs: {
350
+ "xs": [8, 16],
351
+ "sm": [12, 20],
352
+ "md": [16, 28],
353
+ "lg": [24, 40],
354
+ "xl": [32, 56]
355
+ }
356
+ });
357
+
358
+ /* Gap utilities */
359
+ @ruler utility({
360
+ selector: '.gap',
361
+ property: 'gap',
362
+ scale: 'space'
363
+ });
364
+
365
+ /* Padding utilities */
366
+ @ruler utility({
367
+ selector: '.p',
368
+ property: 'padding',
369
+ scale: 'space'
370
+ });
371
+
372
+ /* Margin utilities */
373
+ @ruler utility({
374
+ selector: '.m',
375
+ property: 'margin',
376
+ scale: 'space'
377
+ });
378
+
379
+ /* Stack spacing (for flow layout) */
380
+ @ruler utility({
381
+ selector: '.stack > * + *',
382
+ property: 'margin-top',
383
+ scale: 'space'
384
+ });
385
+ ```
386
+
387
+ Use in your HTML:
388
+
389
+ ```html
390
+ <section class="p-lg gap-md">
391
+ <div class="stack gap-sm">
392
+ <h2>Heading</h2>
393
+ <p>Content that scales smoothly</p>
394
+ </div>
395
+ </section>
396
+ ```
397
+
192
398
  ### Responsive Typography
193
399
 
194
400
  ```css
@@ -260,6 +466,7 @@ h2 {
260
466
  ## Browser Support
261
467
 
262
468
  The `clamp()` function is supported in all modern browsers:
469
+
263
470
  - Chrome 79+
264
471
  - Firefox 75+
265
472
  - Safari 13.1+
package/index.js CHANGED
@@ -1,279 +1,419 @@
1
- const CSSValueParser = require('postcss-value-parser');
1
+ const CSSValueParser = require("postcss-value-parser");
2
2
 
3
3
  /**
4
4
  * @type {import('postcss').PluginCreator}
5
5
  */
6
- module.exports = opts => {
7
- const DEFAULTS = {
8
- minWidth: 320,
9
- maxWidth: 1760,
10
- generateAllCrossPairs: false,
11
- };
12
- const config = Object.assign(DEFAULTS, opts);
13
-
14
- /**
15
- * Converts pixels to r]em units
16
- * @param {number} px - Pixel value to convert
17
- * @returns {string} Rem value as string
18
- */
19
- const pxToRem = px => `${parseFloat((px / 16).toFixed(4))}rem`;
20
-
21
- /**
22
- * Validates that min value is less than max value
23
- * @param {number} min - Minimum value
24
- * @param {number} max - Maximum value
25
- * @param {string} context - Context for error message
26
- * @throws {Error} If min >= max
27
- */
28
- const validateMinMax = (min, max, context) => {
29
- if (min >= max) {
30
- throw new Error(
31
- `[postcss-ruler] Invalid ${context}: min (${min}) must be less than max (${max})`
32
- );
33
- }
34
- };
35
-
36
- /**
37
- * Calculates a fluid clamp() function
38
- * @param {Object} params - Calculation parameters
39
- * @param {number} params.minSize - Minimum size in pixels
40
- * @param {number} params.maxSize - Maximum size in pixels
41
- * @param {number} params.minWidth - Minimum viewport width in pixels
42
- * @param {number} params.maxWidth - Maximum viewport width in pixels
43
- * @returns {string} CSS clamp() function
44
- */
45
- const calculateClamp = ({ minSize, maxSize, minWidth, maxWidth }) => {
46
- validateMinMax(minSize, maxSize, 'size');
47
- validateMinMax(minWidth, maxWidth, 'width');
48
-
49
- const slope = (maxSize - minSize) / (maxWidth - minWidth);
50
- const intersect = -minWidth * slope + minSize;
51
-
52
- return `clamp(${pxToRem(minSize)}, ${(slope * 100).toFixed(
53
- 4
54
- )}vw + ${pxToRem(intersect)}, ${pxToRem(maxSize)})`;
55
- };
56
-
57
- /**
58
- * Generates clamp values from pairs
59
- * @param {Object} params - Generation parameters
60
- * @param {Array<{name: string, values: [number, number]}>} params.pairs - Size pairs
61
- * @param {number} params.minWidth - Minimum viewport width
62
- * @param {number} params.maxWidth - Maximum viewport width
63
- * @param {boolean} params.generateAllCrossPairs - Whether to generate cross pairs
64
- * @returns {Array<{label: string, clamp: string}>} Array of clamp values
65
- */
66
- const generateClamps = ({
67
- pairs,
6
+ module.exports = (opts) => {
7
+ const DEFAULTS = {
8
+ minWidth: 320,
9
+ maxWidth: 1760,
10
+ generateAllCrossPairs: false,
11
+ };
12
+ const config = Object.assign(DEFAULTS, opts);
13
+
14
+ // Storage for generated scales
15
+ const scales = {};
16
+
17
+ /**
18
+ * Converts pixels to r]em units
19
+ * @param {number} px - Pixel value to convert
20
+ * @returns {string} Rem value as string
21
+ */
22
+ const pxToRem = (px) => `${parseFloat((px / 16).toFixed(4))}rem`;
23
+
24
+ /**
25
+ * Validates that min value is less than max value
26
+ * @param {number} min - Minimum value
27
+ * @param {number} max - Maximum value
28
+ * @param {string} context - Context for error message
29
+ * @throws {Error} If min >= max
30
+ */
31
+ const validateMinMax = (min, max, context) => {
32
+ if (min >= max) {
33
+ throw new Error(
34
+ `[postcss-ruler] Invalid ${context}: min (${min}) must be less than max (${max})`,
35
+ );
36
+ }
37
+ };
38
+
39
+ /**
40
+ * Calculates a fluid clamp() function
41
+ * @param {Object} params - Calculation parameters
42
+ * @param {number} params.minSize - Minimum size in pixels
43
+ * @param {number} params.maxSize - Maximum size in pixels
44
+ * @param {number} params.minWidth - Minimum viewport width in pixels
45
+ * @param {number} params.maxWidth - Maximum viewport width in pixels
46
+ * @returns {string} CSS clamp() function
47
+ */
48
+ const calculateClamp = ({ minSize, maxSize, minWidth, maxWidth }) => {
49
+ // New: Allow equal values - just return rem
50
+ if (minSize === maxSize) {
51
+ return pxToRem(minSize);
52
+ }
53
+
54
+ validateMinMax(minSize, maxSize, "size");
55
+ validateMinMax(minWidth, maxWidth, "width");
56
+
57
+ const slope = (maxSize - minSize) / (maxWidth - minWidth);
58
+ const intersect = -minWidth * slope + minSize;
59
+
60
+ return `clamp(${pxToRem(minSize)}, ${(slope * 100).toFixed(
61
+ 4,
62
+ )}vw + ${pxToRem(intersect)}, ${pxToRem(maxSize)})`;
63
+ };
64
+
65
+ /**
66
+ * Generates clamp values from pairs
67
+ * @param {Object} params - Generation parameters
68
+ * @param {Array<{name: string, values: [number, number]}>} params.pairs - Size pairs
69
+ * @param {number} params.minWidth - Minimum viewport width
70
+ * @param {number} params.maxWidth - Maximum viewport width
71
+ * @param {boolean} params.generateAllCrossPairs - Whether to generate cross pairs
72
+ * @returns {Array<{label: string, clamp: string}>} Array of clamp values
73
+ */
74
+ const generateClamps = ({
75
+ pairs,
76
+ minWidth,
77
+ maxWidth,
78
+ generateAllCrossPairs,
79
+ }) => {
80
+ let clampScales = pairs.map(({ name, values: [minSize, maxSize] }) => ({
81
+ label: name,
82
+ clamp: calculateClamp({
83
+ minSize,
84
+ maxSize,
68
85
  minWidth,
69
86
  maxWidth,
70
- generateAllCrossPairs,
71
- }) => {
72
- let clampScales = pairs.map(({ name, values: [minSize, maxSize] }) => ({
73
- label: name,
87
+ }),
88
+ }));
89
+
90
+ if (generateAllCrossPairs) {
91
+ let crossPairs = [];
92
+ for (let i = 0; i < pairs.length; i++) {
93
+ for (let j = i + 1; j < pairs.length; j++) {
94
+ const [smaller, larger] = [pairs[i], pairs[j]].sort(
95
+ (a, b) => a.values[0] - b.values[0],
96
+ );
97
+ crossPairs.push({
98
+ label: `${smaller.name}-${larger.name}`,
74
99
  clamp: calculateClamp({
75
- minSize,
76
- maxSize,
77
- minWidth,
78
- maxWidth,
100
+ minSize: smaller.values[0],
101
+ maxSize: larger.values[1],
102
+ minWidth,
103
+ maxWidth,
79
104
  }),
80
- }));
81
-
82
- if (generateAllCrossPairs) {
83
- let crossPairs = [];
84
- for (let i = 0; i < pairs.length; i++) {
85
- for (let j = i + 1; j < pairs.length; j++) {
86
- const [smaller, larger] = [pairs[i], pairs[j]].sort(
87
- (a, b) => a.values[0] - b.values[0]
88
- );
89
- crossPairs.push({
90
- label: `${smaller.name}-${larger.name}`,
91
- clamp: calculateClamp({
92
- minSize: smaller.values[0],
93
- maxSize: larger.values[1],
94
- minWidth,
95
- maxWidth,
96
- }),
97
- });
98
- }
99
- }
100
- clampScales = [...clampScales, ...crossPairs];
105
+ });
101
106
  }
102
-
103
- return clampScales;
107
+ }
108
+ clampScales = [...clampScales, ...crossPairs];
109
+ }
110
+
111
+ return clampScales;
112
+ };
113
+
114
+ /**
115
+ * Parses parameters from @fluid at-rule
116
+ * @param {Array} params - Parsed parameter nodes
117
+ * @returns {Object} Parsed configuration object
118
+ */
119
+ const parseAtRuleParams = (params) => {
120
+ const clampsParams = {
121
+ minWidth: config.minWidth,
122
+ maxWidth: config.maxWidth,
123
+ pairs: {},
124
+ prefix: "space",
125
+ generateAllCrossPairs: config.generateAllCrossPairs,
104
126
  };
105
127
 
106
- /**
107
- * Parses parameters from @fluid at-rule
108
- * @param {Array} params - Parsed parameter nodes
109
- * @returns {Object} Parsed configuration object
110
- */
111
- const parseAtRuleParams = params => {
112
- const clampsParams = {
113
- minWidth: config.minWidth,
114
- maxWidth: config.maxWidth,
115
- pairs: {},
116
- relativeTo: 'viewport',
117
- prefix: 'space',
118
- generateAllCrossPairs: config.generateAllCrossPairs,
119
- };
120
-
121
- for (let i = 0; i < params.length; i++) {
122
- const param = params[i];
123
- const nextParam = params[i + 1];
124
- if (!param || !nextParam) continue;
125
- const key = param.value;
126
- let value = nextParam.value.replace(/[:,]/g, '');
127
-
128
- switch (key) {
129
- case 'minWidth':
130
- case 'maxWidth':
131
- clampsParams[key] = Number(value);
132
- i++;
133
- break;
134
- case 'prefix':
135
- clampsParams.prefix = value.replace(/['\"]/g, '');
136
- i++;
137
- break;
138
- case 'relativeTo':
139
- clampsParams.relativeTo = value.replace(/['\"]/g, '');
140
- i++;
141
- break;
142
- case 'generateAllCrossPairs':
143
- clampsParams.generateAllCrossPairs = value === 'true';
144
- i++;
145
- break;
146
- }
128
+ for (let i = 0; i < params.length; i++) {
129
+ const param = params[i];
130
+ const nextParam = params[i + 1];
131
+ if (!param || !nextParam) continue;
132
+ const key = param.value;
133
+ let value = nextParam.value.replace(/[:,]/g, "");
134
+
135
+ switch (key) {
136
+ case "minWidth":
137
+ case "maxWidth":
138
+ clampsParams[key] = Number(value);
139
+ i++;
140
+ break;
141
+ case "prefix":
142
+ clampsParams.prefix = value.replace(/['"]/g, "");
143
+ i++;
144
+ break;
145
+ case "generateAllCrossPairs":
146
+ clampsParams.generateAllCrossPairs = value === "true";
147
+ i++;
148
+ break;
149
+ }
150
+ }
151
+
152
+ return clampsParams;
153
+ };
154
+
155
+ /**
156
+ * Extracts pairs from parsed parameters
157
+ * @param {Array} params - Parsed parameter nodes
158
+ * @returns {Object} Pairs object with name: [min, max] entries
159
+ */
160
+ const extractPairs = (params) => {
161
+ const pairs = {};
162
+ const pairsStartIndex = params.findIndex((x) => x.value === "pairs");
163
+
164
+ if (pairsStartIndex === -1) return pairs;
165
+
166
+ let currentName = null;
167
+ let currentValues = [];
168
+
169
+ for (let i = pairsStartIndex + 1; i < params.length; i++) {
170
+ const param = params[i];
171
+ const value = param.value.replace("[", "").replace("]", "");
172
+ if (!value || value === "[" || value === "]") continue;
173
+
174
+ if (param.type === "string") {
175
+ if (currentName && currentValues.length === 2) {
176
+ pairs[currentName] = currentValues;
147
177
  }
148
-
149
- return clampsParams;
178
+ currentName = value;
179
+ currentValues = [];
180
+ } else {
181
+ const numValue = Number(value);
182
+ if (!isNaN(numValue)) currentValues.push(numValue);
183
+ }
184
+
185
+ if (currentName && currentValues.length === 2) {
186
+ pairs[currentName] = currentValues;
187
+ }
188
+ }
189
+
190
+ return pairs;
191
+ };
192
+
193
+ /**
194
+ * Parses parameters from @ruler utility() at-rule
195
+ * @param {Array} params - Parsed parameter nodes
196
+ * @returns {Object} Parsed configuration object
197
+ */
198
+ const parseUtilityParams = (params) => {
199
+ const utilityParams = {
200
+ selector: null,
201
+ property: null,
202
+ scale: null,
203
+ generateAllCrossPairs: null,
150
204
  };
151
205
 
152
- /**
153
- * Extracts pairs from parsed parameters
154
- * @param {Array} params - Parsed parameter nodes
155
- * @returns {Object} Pairs object with name: [min, max] entries
156
- */
157
- const extractPairs = params => {
158
- const pairs = {};
159
- const pairsStartIndex = params.findIndex(x => x.value === 'pairs');
160
-
161
- if (pairsStartIndex === -1) return pairs;
162
-
163
- let currentName = null;
164
- let currentValues = [];
165
-
166
- for (let i = pairsStartIndex + 1; i < params.length; i++) {
167
- const param = params[i];
168
- const value = param.value.replace('[', '').replace(']', '');
169
- if (!value || value === '[' || value === ']') continue;
170
-
171
- if (param.type === 'string') {
172
- if (currentName && currentValues.length === 2) {
173
- pairs[currentName] = currentValues;
174
- }
175
- currentName = value;
176
- currentValues = [];
177
- } else {
178
- const numValue = Number(value);
179
- if (!isNaN(numValue)) currentValues.push(numValue);
180
- }
181
-
182
- if (currentName && currentValues.length === 2) {
183
- pairs[currentName] = currentValues;
206
+ for (let i = 0; i < params.length; i++) {
207
+ const param = params[i];
208
+ const nextParam = params[i + 1];
209
+ if (!param || !nextParam) continue;
210
+ const key = param.value;
211
+ let value = nextParam.value.replace(/[:,]/g, "");
212
+
213
+ switch (key) {
214
+ case "selector":
215
+ case "scale":
216
+ utilityParams[key] = value.replace(/['"]/g, "");
217
+ i++;
218
+ break;
219
+ case "property":
220
+ // Check if it's an array (next token is '[')
221
+ if (nextParam.value === "[") {
222
+ // It's an array - collect values until we hit ']'
223
+ const arrayValues = [];
224
+ i += 2; // Skip 'property' and '['
225
+ while (i < params.length && params[i].value !== "]") {
226
+ if (params[i].type === "string") {
227
+ arrayValues.push(params[i].value);
228
+ }
229
+ i++;
184
230
  }
185
- }
186
-
187
- return pairs;
188
- };
189
-
190
- /**
191
- * Processes @fluid at-rule and generates CSS custom properties
192
- * @param {Object} atRule - PostCSS at-rule node
193
- */
194
- const processFluidAtRule = atRule => {
195
- const { nodes } = CSSValueParser(atRule.params);
196
- const params = nodes[0].nodes.filter(
197
- x =>
198
- ['word', 'string'].includes(x.type) &&
199
- x.value !== '{' &&
200
- x.value !== '}'
201
- );
202
-
203
- const clampsParams = parseAtRuleParams(params);
204
- clampsParams.pairs = extractPairs(params);
205
-
206
- if (Object.keys(clampsParams.pairs).length === 0) {
207
- throw new Error(
208
- '[postcss-ruler] No pairs defined in @ruler scale()'
209
- );
210
- }
211
-
212
- const clampPairs = Object.entries(clampsParams.pairs).map(
213
- ([name, values]) => ({ name, values })
231
+ utilityParams.property = arrayValues;
232
+ } else {
233
+ // Single value
234
+ utilityParams.property = value.replace(/['"]/g, "");
235
+ i++;
236
+ }
237
+ break;
238
+ case "generateAllCrossPairs":
239
+ utilityParams.generateAllCrossPairs = value === "true";
240
+ i++;
241
+ break;
242
+ }
243
+ }
244
+
245
+ return utilityParams;
246
+ };
247
+
248
+ /**
249
+ * Processes @fluid at-rule and generates CSS custom properties
250
+ * @param {Object} atRule - PostCSS at-rule node
251
+ */
252
+ const processFluidAtRule = (atRule) => {
253
+ const { nodes } = CSSValueParser(atRule.params);
254
+ const params = nodes[0].nodes.filter(
255
+ (x) =>
256
+ ["word", "string"].includes(x.type) &&
257
+ x.value !== "{" &&
258
+ x.value !== "}",
259
+ );
260
+
261
+ const clampsParams = parseAtRuleParams(params);
262
+ clampsParams.pairs = extractPairs(params);
263
+
264
+ if (Object.keys(clampsParams.pairs).length === 0) {
265
+ throw new Error("[postcss-ruler] No pairs defined in @ruler scale()");
266
+ }
267
+
268
+ const clampPairs = Object.entries(clampsParams.pairs).map(
269
+ ([name, values]) => ({ name, values }),
270
+ );
271
+ const clampScale = generateClamps({
272
+ ...clampsParams,
273
+ pairs: clampPairs,
274
+ });
275
+
276
+ // Store the scale for later use by utility classes
277
+ scales[clampsParams.prefix] = clampScale;
278
+
279
+ const postcss = require("postcss");
280
+ const root = postcss.root();
281
+
282
+ clampScale.forEach((step) => {
283
+ root.append(
284
+ postcss.decl({
285
+ prop: `--${clampsParams.prefix}-${step.label}`,
286
+ value: step.clamp,
287
+ }),
288
+ );
289
+ });
290
+
291
+ atRule.replaceWith(root.nodes);
292
+ };
293
+
294
+ /**
295
+ * Processes @ruler utility() at-rule and generates utility classes
296
+ * @param {Object} atRule - PostCSS at-rule node
297
+ */
298
+ const processUtilityAtRule = (atRule) => {
299
+ const postcss = require("postcss");
300
+ const { nodes } = CSSValueParser(atRule.params);
301
+ const params = nodes[0].nodes.filter(
302
+ (x) =>
303
+ ["word", "string", "function"].includes(x.type) &&
304
+ x.value !== "{" &&
305
+ x.value !== "}",
306
+ );
307
+
308
+ const utilityParams = parseUtilityParams(params);
309
+
310
+ // Validate required parameters
311
+ if (!utilityParams.selector) {
312
+ throw new Error(
313
+ '[postcss-ruler] @ruler utility() requires a "selector" parameter',
314
+ );
315
+ }
316
+ if (!utilityParams.property) {
317
+ throw new Error(
318
+ '[postcss-ruler] @ruler utility() requires a "property" parameter',
319
+ );
320
+ }
321
+ if (!utilityParams.scale) {
322
+ throw new Error(
323
+ '[postcss-ruler] @ruler utility() requires a "scale" parameter',
324
+ );
325
+ }
326
+
327
+ // Check if scale exists
328
+ const scale = scales[utilityParams.scale];
329
+ if (!scale) {
330
+ throw new Error(
331
+ `[postcss-ruler] Scale "${utilityParams.scale}" not found. Define it with @ruler scale() first.`,
332
+ );
333
+ }
334
+
335
+ // Determine which scale items to use
336
+ let scaleItems = scale;
337
+ if (utilityParams.generateAllCrossPairs === false) {
338
+ // Filter out cross-pairs (items with hyphens in label)
339
+ scaleItems = scale.filter((item) => !item.label.includes("-"));
340
+ }
341
+
342
+ // Normalize property to array
343
+ const properties = Array.isArray(utilityParams.property)
344
+ ? utilityParams.property
345
+ : [utilityParams.property];
346
+
347
+ // Generate utility classes as PostCSS nodes
348
+ const rules = scaleItems.map((item) => {
349
+ const selector = `${utilityParams.selector}-${item.label}`;
350
+ const rule = postcss.rule({ selector });
351
+
352
+ properties.forEach((prop) => {
353
+ rule.append(postcss.decl({ prop, value: item.clamp }));
354
+ });
355
+
356
+ return rule;
357
+ });
358
+
359
+ atRule.replaceWith(rules);
360
+ };
361
+
362
+ /**
363
+ * Processes inline fluid functions in declarations
364
+ * @param {Object} decl - PostCSS declaration node
365
+ */
366
+ const processFluidDeclaration = (decl) => {
367
+ const regex = /ruler\.fluid\(([^)]+)\)/g;
368
+ let newValue = decl.value;
369
+ let match;
370
+
371
+ while ((match = regex.exec(decl.value)) !== null) {
372
+ const args = match[1]
373
+ .split(",")
374
+ .map((s) => s.trim())
375
+ .map(Number);
376
+ let [minSize, maxSize, minWidth, maxWidth] = args;
377
+
378
+ minWidth = minWidth || config.minWidth;
379
+ maxWidth = maxWidth || config.maxWidth;
380
+
381
+ if (!minSize || !maxSize) {
382
+ throw new Error(
383
+ "[postcss-ruler] ruler.fluid() requires minSize and maxSize",
214
384
  );
215
- const clampScale = generateClamps({
216
- ...clampsParams,
217
- pairs: clampPairs,
218
- });
219
-
220
- const response = clampScale
221
- .map(step => `--${clampsParams.prefix}-${step.label}: ${step.clamp};`)
222
- .join('\n');
223
-
224
- atRule.replaceWith(response);
225
- };
226
-
227
- /**
228
- * Processes inline fluid functions in declarations
229
- * @param {Object} decl - PostCSS declaration node
230
- */
231
- const processFluidDeclaration = decl => {
232
- const regex = /ruler\.fluid\(([^)]+)\)/g;
233
- let newValue = decl.value;
234
- let match;
235
-
236
- while ((match = regex.exec(decl.value)) !== null) {
237
- const args = match[1].split(',').map(s => s.trim()).map(Number);
238
- let [minSize, maxSize, minWidth, maxWidth] = args;
239
-
240
- minWidth = minWidth || config.minWidth;
241
- maxWidth = maxWidth || config.maxWidth;
242
-
243
- if (!minSize || !maxSize) {
244
- throw new Error(
245
- '[postcss-ruler] ruler.fluid() requires minSize and maxSize'
246
- );
247
- }
385
+ }
248
386
 
249
- const clampValue = calculateClamp({
250
- minSize,
251
- maxSize,
252
- minWidth,
253
- maxWidth,
254
- });
255
-
256
- newValue = newValue.replace(match[0], clampValue);
257
- }
258
-
259
- if (newValue !== decl.value) {
260
- decl.value = newValue;
387
+ const clampValue = calculateClamp({
388
+ minSize,
389
+ maxSize,
390
+ minWidth,
391
+ maxWidth,
392
+ });
393
+
394
+ newValue = newValue.replace(match[0], clampValue);
395
+ }
396
+
397
+ if (newValue !== decl.value) {
398
+ decl.value = newValue;
399
+ }
400
+ };
401
+
402
+ return {
403
+ postcssPlugin: "ruler",
404
+ AtRule: {
405
+ ruler: (atRule) => {
406
+ if (atRule.params.startsWith("scale(")) {
407
+ return processFluidAtRule(atRule);
408
+ } else if (atRule.params.startsWith("utility(")) {
409
+ return processUtilityAtRule(atRule);
261
410
  }
262
- };
263
-
264
- return {
265
- postcssPlugin: 'ruler',
266
- AtRule: {
267
- ruler: atRule => {
268
- if (atRule.params.startsWith('scale(')) {
269
- return processFluidAtRule(atRule);
270
- }
271
- },
272
- },
273
- Declaration(decl) {
274
- processFluidDeclaration(decl);
275
- },
276
- };
411
+ },
412
+ },
413
+ Declaration(decl) {
414
+ processFluidDeclaration(decl);
415
+ },
416
+ };
277
417
  };
278
418
 
279
419
  module.exports.postcss = true;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "postcss-ruler",
3
- "version": "1.0.1",
3
+ "version": "1.2.0",
4
4
  "description": "PostCSS plugin to generate fluid scales and values.",
5
5
  "main": "index.js",
6
6
  "files": [
@@ -37,7 +37,7 @@
37
37
  },
38
38
  "eslintConfig": {
39
39
  "parserOptions": {
40
- "ecmaVersion": 2017
40
+ "ecmaVersion": 2018
41
41
  },
42
42
  "env": {
43
43
  "node": true,