postcss-ruler 1.1.0 → 1.3.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 +115 -47
  2. package/index.js +427 -390
  3. package/package.json +1 -1
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,17 +24,18 @@ 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
@@ -40,6 +43,7 @@ module.exports = {
40
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,12 +101,13 @@ 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 */
@@ -143,27 +151,41 @@ Generate utility classes from your defined scales with complete selector flexibi
143
151
  ```
144
152
 
145
153
  **Generates:**
154
+
146
155
  ```css
147
156
  --space-xs: clamp(0.5rem, 0.5556vw + 0.3889rem, 1rem);
148
157
  --space-sm: clamp(1rem, 0.5556vw + 0.8889rem, 1.5rem);
149
158
  --space-md: clamp(1.5rem, 0.5556vw + 1.3889rem, 2rem);
150
159
 
151
- .gap-xs { gap: clamp(0.5rem, 0.5556vw + 0.3889rem, 1rem) }
152
- .gap-sm { gap: clamp(1rem, 0.5556vw + 0.8889rem, 1.5rem) }
153
- .gap-md { gap: clamp(1.5rem, 0.5556vw + 1.3889rem, 2rem) }
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
+ }
154
169
 
155
- &.active-xs { padding: clamp(0.5rem, 0.5556vw + 0.3889rem, 1rem) }
156
- &.active-sm { padding: clamp(1rem, 0.5556vw + 0.8889rem, 1.5rem) }
157
- &.active-md { padding: clamp(1.5rem, 0.5556vw + 1.3889rem, 2rem) }
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
+ }
158
179
 
159
180
  .p-block-xs {
160
181
  padding-top: clamp(0.5rem, 0.5556vw + 0.3889rem, 1rem);
161
- padding-bottom: clamp(0.5rem, 0.5556vw + 0.3889rem, 1rem)
182
+ padding-bottom: clamp(0.5rem, 0.5556vw + 0.3889rem, 1rem);
162
183
  }
163
184
  /* ... */
164
185
  ```
165
186
 
166
187
  **Supported selector patterns:**
188
+
167
189
  - **Class selectors**: `.gap` → `.gap-xs`, `.gap-sm`, `.gap-md`
168
190
  - **Nested with &**: `&.active` → `&.active-xs`, `&.active-sm` (PostCSS nesting)
169
191
  - **Multiple classes**: `.container.space` → `.container.space-xs`, etc.
@@ -173,16 +195,17 @@ Generate utility classes from your defined scales with complete selector flexibi
173
195
 
174
196
  **Utility options:**
175
197
 
176
- | Option | Type | Required | Description |
177
- |--------|------|----------|-------------|
178
- | `selector` | string | Yes | Any valid CSS selector pattern (e.g., `.gap`, `&.active`, `#section`) |
179
- | `property` | string or array | Yes | CSS property name(s) to apply the scale values to |
180
- | `scale` | string | Yes | Name of a previously defined scale (the `prefix` value) |
181
- | `generateAllCrossPairs` | boolean | No | Include/exclude cross-pairs (overrides scale default) |
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) |
182
204
 
183
205
  ### 3. Inline Mode: Fluid Function
184
206
 
185
207
  Convert individual values directly to `clamp()` functions:
208
+
186
209
  ```css
187
210
  .element {
188
211
  /* Uses default minWidth/maxWidth from config */
@@ -197,11 +220,13 @@ Convert individual values directly to `clamp()` functions:
197
220
  ```
198
221
 
199
222
  **Generates:**
223
+
200
224
  ```css
201
225
  .element {
202
226
  font-size: clamp(1rem, 0.4545vw + 0.8636rem, 1.5rem);
203
227
  padding: clamp(0.75rem, 0.9091vw + 0.4773rem, 1.25rem);
204
- 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);
205
230
  }
206
231
  ```
207
232
 
@@ -209,23 +234,23 @@ Convert individual values directly to `clamp()` functions:
209
234
 
210
235
  ### Plugin Options
211
236
 
212
- | Option | Type | Default | Description |
213
- |--------|------|---------|-------------|
214
- | `minWidth` | number | `320` | Default minimum viewport width in pixels |
215
- | `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 |
216
241
  | `generateAllCrossPairs` | boolean | `false` | Generate cross-combinations in scale mode |
217
242
 
218
243
  ### At-Rule Options
219
244
 
220
245
  All options can be overridden per `@ruler scale()` declaration:
221
246
 
222
- | Option | Type | Default | Description |
223
- |--------|------|---------|-------------|
224
- | `minWidth` | number | `320` | Minimum viewport width for this scale |
225
- | `maxWidth` | number | `1760` | Maximum viewport width for this scale |
226
- | `prefix` | string | `"space"` | Prefix for generated CSS custom properties |
227
- | `generateAllCrossPairs` | boolean | `false` | Generate cross-combinations for this scale |
228
- | `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]` |
229
254
 
230
255
  ### Inline Function Syntax
231
256
 
@@ -233,21 +258,62 @@ All options can be overridden per `@ruler scale()` declaration:
233
258
  ruler.fluid(minSize, maxSize[, minWidth, maxWidth])
234
259
  ```
235
260
 
236
- | Parameter | Type | Required | Description |
237
- |-----------|------|----------|-------------|
238
- | `minSize` | number | Yes | Minimum size in pixels |
239
- | `maxSize` | number | Yes | Maximum size in pixels |
240
- | `minWidth` | number | No | Minimum viewport width (uses config default) |
241
- | `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.
242
308
 
243
309
  ### Utility Options
244
310
 
245
- | Option | Type | Default | Description |
246
- |--------|------|---------|-------------|
247
- | `selector` | string | required | Any valid CSS selector pattern (e.g., `.gap`, `&.active`, `#section`) |
248
- | `property` | string or array | required | CSS property name(s) to apply the scale values to |
249
- | `scale` | string | required | Name of a previously defined scale (the `prefix` value) |
250
- | `generateAllCrossPairs` | boolean | No | Include/exclude cross-pairs (overrides scale default) |
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) |
251
317
 
252
318
  ## How It Works
253
319
 
@@ -319,6 +385,7 @@ Generate a complete set of utility classes from your design system:
319
385
  ```
320
386
 
321
387
  Use in your HTML:
388
+
322
389
  ```html
323
390
  <section class="p-lg gap-md">
324
391
  <div class="stack gap-sm">
@@ -399,6 +466,7 @@ h2 {
399
466
  ## Browser Support
400
467
 
401
468
  The `clamp()` function is supported in all modern browsers:
469
+
402
470
  - Chrome 79+
403
471
  - Firefox 75+
404
472
  - Safari 13.1+
package/index.js CHANGED
@@ -1,413 +1,450 @@
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
- // 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
- validateMinMax(minSize, maxSize, 'size');
50
- validateMinMax(minWidth, maxWidth, 'width');
51
-
52
- const slope = (maxSize - minSize) / (maxWidth - minWidth);
53
- const intersect = -minWidth * slope + minSize;
54
-
55
- return `clamp(${pxToRem(minSize)}, ${(slope * 100).toFixed(
56
- 4
57
- )}vw + ${pxToRem(intersect)}, ${pxToRem(maxSize)})`;
58
- };
59
-
60
- /**
61
- * Generates clamp values from pairs
62
- * @param {Object} params - Generation parameters
63
- * @param {Array<{name: string, values: [number, number]}>} params.pairs - Size pairs
64
- * @param {number} params.minWidth - Minimum viewport width
65
- * @param {number} params.maxWidth - Maximum viewport width
66
- * @param {boolean} params.generateAllCrossPairs - Whether to generate cross pairs
67
- * @returns {Array<{label: string, clamp: string}>} Array of clamp values
68
- */
69
- const generateClamps = ({
70
- 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,
71
85
  minWidth,
72
86
  maxWidth,
73
- generateAllCrossPairs,
74
- }) => {
75
- let clampScales = pairs.map(({ name, values: [minSize, maxSize] }) => ({
76
- 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}`,
77
99
  clamp: calculateClamp({
78
- minSize,
79
- maxSize,
80
- minWidth,
81
- maxWidth,
100
+ minSize: smaller.values[0],
101
+ maxSize: larger.values[1],
102
+ minWidth,
103
+ maxWidth,
82
104
  }),
83
- }));
84
-
85
- if (generateAllCrossPairs) {
86
- let crossPairs = [];
87
- for (let i = 0; i < pairs.length; i++) {
88
- for (let j = i + 1; j < pairs.length; j++) {
89
- const [smaller, larger] = [pairs[i], pairs[j]].sort(
90
- (a, b) => a.values[0] - b.values[0]
91
- );
92
- crossPairs.push({
93
- label: `${smaller.name}-${larger.name}`,
94
- clamp: calculateClamp({
95
- minSize: smaller.values[0],
96
- maxSize: larger.values[1],
97
- minWidth,
98
- maxWidth,
99
- }),
100
- });
101
- }
102
- }
103
- clampScales = [...clampScales, ...crossPairs];
105
+ });
104
106
  }
105
-
106
- return clampScales;
107
- };
108
-
109
- /**
110
- * Parses parameters from @fluid at-rule
111
- * @param {Array} params - Parsed parameter nodes
112
- * @returns {Object} Parsed configuration object
113
- */
114
- const parseAtRuleParams = params => {
115
- const clampsParams = {
116
- minWidth: config.minWidth,
117
- maxWidth: config.maxWidth,
118
- pairs: {},
119
- prefix: 'space',
120
- generateAllCrossPairs: config.generateAllCrossPairs,
121
- };
122
-
123
- for (let i = 0; i < params.length; i++) {
124
- const param = params[i];
125
- const nextParam = params[i + 1];
126
- if (!param || !nextParam) continue;
127
- const key = param.value;
128
- let value = nextParam.value.replace(/[:,]/g, '');
129
-
130
- switch (key) {
131
- case 'minWidth':
132
- case 'maxWidth':
133
- clampsParams[key] = Number(value);
134
- i++;
135
- break;
136
- case 'prefix':
137
- clampsParams.prefix = value.replace(/['"]/g, '');
138
- i++;
139
- break;
140
- case 'generateAllCrossPairs':
141
- clampsParams.generateAllCrossPairs = value === 'true';
142
- i++;
143
- break;
144
- }
145
- }
146
-
147
- return clampsParams;
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,
148
126
  };
149
127
 
150
- /**
151
- * Extracts pairs from parsed parameters
152
- * @param {Array} params - Parsed parameter nodes
153
- * @returns {Object} Pairs object with name: [min, max] entries
154
- */
155
- const extractPairs = params => {
156
- const pairs = {};
157
- const pairsStartIndex = params.findIndex(x => x.value === 'pairs');
158
-
159
- if (pairsStartIndex === -1) return pairs;
160
-
161
- let currentName = null;
162
- let currentValues = [];
163
-
164
- for (let i = pairsStartIndex + 1; i < params.length; i++) {
165
- const param = params[i];
166
- const value = param.value.replace('[', '').replace(']', '');
167
- if (!value || value === '[' || value === ']') continue;
168
-
169
- if (param.type === 'string') {
170
- if (currentName && currentValues.length === 2) {
171
- pairs[currentName] = currentValues;
172
- }
173
- currentName = value;
174
- currentValues = [];
175
- } else {
176
- const numValue = Number(value);
177
- if (!isNaN(numValue)) currentValues.push(numValue);
178
- }
179
-
180
- if (currentName && currentValues.length === 2) {
181
- pairs[currentName] = currentValues;
182
- }
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;
183
177
  }
184
-
185
- return pairs;
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,
204
+ attribute: null,
186
205
  };
187
206
 
188
- /**
189
- * Parses parameters from @ruler utility() at-rule
190
- * @param {Array} params - Parsed parameter nodes
191
- * @returns {Object} Parsed configuration object
192
- */
193
- const parseUtilityParams = params => {
194
- const utilityParams = {
195
- selector: null,
196
- property: null,
197
- scale: null,
198
- generateAllCrossPairs: null,
199
- };
200
-
201
- for (let i = 0; i < params.length; i++) {
202
- const param = params[i];
203
- const nextParam = params[i + 1];
204
- if (!param || !nextParam) continue;
205
- const key = param.value;
206
- let value = nextParam.value.replace(/[:,]/g, '');
207
-
208
- switch (key) {
209
- case 'selector':
210
- case 'scale':
211
- utilityParams[key] = value.replace(/['"]/g, '');
212
- i++;
213
- break;
214
- case 'property':
215
- // Check if it's an array (next token is '[')
216
- if (nextParam.value === '[') {
217
- // It's an array - collect values until we hit ']'
218
- const arrayValues = [];
219
- i += 2; // Skip 'property' and '['
220
- while (i < params.length && params[i].value !== ']') {
221
- if (params[i].type === 'string') {
222
- arrayValues.push(params[i].value);
223
- }
224
- i++;
225
- }
226
- utilityParams.property = arrayValues;
227
- } else {
228
- // Single value
229
- utilityParams.property = value.replace(/['"]/g, '');
230
- i++;
231
- }
232
- break;
233
- case 'generateAllCrossPairs':
234
- utilityParams.generateAllCrossPairs = value === 'true';
235
- i++;
236
- break;
207
+ for (let i = 0; i < params.length; i++) {
208
+ const param = params[i];
209
+ const nextParam = params[i + 1];
210
+ if (!param || !nextParam) continue;
211
+ const key = param.value;
212
+ let value = nextParam.value.replace(/[:,]/g, "");
213
+
214
+ switch (key) {
215
+ case "selector":
216
+ case "scale":
217
+ case "attribute":
218
+ utilityParams[key] = value.replace(/['"]/g, "");
219
+ i++;
220
+ break;
221
+ case "property":
222
+ // Check if it's an array (next token is '[')
223
+ if (nextParam.value === "[") {
224
+ // It's an array - collect values until we hit ']'
225
+ const arrayValues = [];
226
+ i += 2; // Skip 'property' and '['
227
+ while (i < params.length && params[i].value !== "]") {
228
+ if (params[i].type === "string") {
229
+ arrayValues.push(params[i].value);
230
+ }
231
+ i++;
237
232
  }
238
- }
239
-
240
- return utilityParams;
241
- };
242
-
243
- /**
244
- * Processes @fluid at-rule and generates CSS custom properties
245
- * @param {Object} atRule - PostCSS at-rule node
246
- */
247
- const processFluidAtRule = atRule => {
248
- const { nodes } = CSSValueParser(atRule.params);
249
- const params = nodes[0].nodes.filter(
250
- x =>
251
- ['word', 'string'].includes(x.type) &&
252
- x.value !== '{' &&
253
- x.value !== '}'
233
+ utilityParams.property = arrayValues;
234
+ } else {
235
+ // Single value
236
+ utilityParams.property = value.replace(/['"]/g, "");
237
+ i++;
238
+ }
239
+ break;
240
+ case "generateAllCrossPairs":
241
+ utilityParams.generateAllCrossPairs = value === "true";
242
+ i++;
243
+ break;
244
+ }
245
+ }
246
+
247
+ return utilityParams;
248
+ };
249
+
250
+ /**
251
+ * Processes @fluid at-rule and generates CSS custom properties
252
+ * @param {Object} atRule - PostCSS at-rule node
253
+ */
254
+ const processFluidAtRule = (atRule) => {
255
+ const { nodes } = CSSValueParser(atRule.params);
256
+ const params = nodes[0].nodes.filter(
257
+ (x) =>
258
+ ["word", "string"].includes(x.type) &&
259
+ x.value !== "{" &&
260
+ x.value !== "}",
261
+ );
262
+
263
+ const clampsParams = parseAtRuleParams(params);
264
+ clampsParams.pairs = extractPairs(params);
265
+
266
+ if (Object.keys(clampsParams.pairs).length === 0) {
267
+ throw new Error("[postcss-ruler] No pairs defined in @ruler scale()");
268
+ }
269
+
270
+ const clampPairs = Object.entries(clampsParams.pairs).map(
271
+ ([name, values]) => ({ name, values }),
272
+ );
273
+ const clampScale = generateClamps({
274
+ ...clampsParams,
275
+ pairs: clampPairs,
276
+ });
277
+
278
+ // Store the scale for later use by utility classes
279
+ scales[clampsParams.prefix] = clampScale;
280
+
281
+ const postcss = require("postcss");
282
+ const root = postcss.root();
283
+
284
+ clampScale.forEach((step) => {
285
+ root.append(
286
+ postcss.decl({
287
+ prop: `--${clampsParams.prefix}-${step.label}`,
288
+ value: step.clamp,
289
+ }),
290
+ );
291
+ });
292
+
293
+ atRule.replaceWith(root.nodes);
294
+ };
295
+
296
+ /**
297
+ * Processes @ruler utility() at-rule and generates utility classes
298
+ * @param {Object} atRule - PostCSS at-rule node
299
+ */
300
+ const processUtilityAtRule = (atRule) => {
301
+ const postcss = require("postcss");
302
+ const { nodes } = CSSValueParser(atRule.params);
303
+ const params = nodes[0].nodes.filter(
304
+ (x) =>
305
+ ["word", "string", "function"].includes(x.type) &&
306
+ x.value !== "{" &&
307
+ x.value !== "}",
308
+ );
309
+
310
+ const utilityParams = parseUtilityParams(params);
311
+
312
+ // Validate attribute-specific constraints first
313
+ if (utilityParams.attribute !== null) {
314
+ if (utilityParams.attribute === "") {
315
+ throw new Error(
316
+ '[postcss-ruler] @ruler utility() attribute parameter cannot be empty',
254
317
  );
255
-
256
- const clampsParams = parseAtRuleParams(params);
257
- clampsParams.pairs = extractPairs(params);
258
-
259
- if (Object.keys(clampsParams.pairs).length === 0) {
260
- throw new Error(
261
- '[postcss-ruler] No pairs defined in @ruler scale()'
262
- );
263
- }
264
-
265
- const clampPairs = Object.entries(clampsParams.pairs).map(
266
- ([name, values]) => ({ name, values })
318
+ }
319
+ if (!/^[a-zA-Z0-9_-]+$/.test(utilityParams.attribute)) {
320
+ throw new Error(
321
+ '[postcss-ruler] @ruler utility() attribute parameter must contain only letters, numbers, hyphens, and underscores',
267
322
  );
268
- const clampScale = generateClamps({
269
- ...clampsParams,
270
- pairs: clampPairs,
271
- });
272
-
273
- // Store the scale for later use by utility classes
274
- scales[clampsParams.prefix] = clampScale;
275
-
276
- const postcss = require('postcss');
277
- const root = postcss.root();
278
-
279
- clampScale.forEach(step => {
280
- root.append(
281
- postcss.decl({
282
- prop: `--${clampsParams.prefix}-${step.label}`,
283
- value: step.clamp,
284
- })
285
- );
286
- });
287
-
288
- atRule.replaceWith(root.nodes);
289
- };
290
-
291
- /**
292
- * Processes @ruler utility() at-rule and generates utility classes
293
- * @param {Object} atRule - PostCSS at-rule node
294
- */
295
- const processUtilityAtRule = atRule => {
296
- const postcss = require('postcss');
297
- const { nodes } = CSSValueParser(atRule.params);
298
- const params = nodes[0].nodes.filter(
299
- x =>
300
- ['word', 'string', 'function'].includes(x.type) &&
301
- x.value !== '{' &&
302
- x.value !== '}'
323
+ }
324
+ }
325
+
326
+ // Validate required parameters
327
+ if (!utilityParams.selector && !utilityParams.attribute) {
328
+ throw new Error(
329
+ '[postcss-ruler] @ruler utility() requires either "selector" or "attribute" parameter',
330
+ );
331
+ }
332
+ if (!utilityParams.property) {
333
+ throw new Error(
334
+ '[postcss-ruler] @ruler utility() requires a "property" parameter',
335
+ );
336
+ }
337
+ if (!utilityParams.scale) {
338
+ throw new Error(
339
+ '[postcss-ruler] @ruler utility() requires a "scale" parameter',
340
+ );
341
+ }
342
+
343
+ // Check if scale exists
344
+ const scale = scales[utilityParams.scale];
345
+ if (!scale) {
346
+ throw new Error(
347
+ `[postcss-ruler] Scale "${utilityParams.scale}" not found. Define it with @ruler scale() first.`,
348
+ );
349
+ }
350
+
351
+ // Determine which scale items to use
352
+ let scaleItems = scale;
353
+ if (utilityParams.generateAllCrossPairs === false) {
354
+ // Filter out cross-pairs (items with hyphens in label)
355
+ scaleItems = scale.filter((item) => !item.label.includes("-"));
356
+ }
357
+
358
+ // Normalize property to array
359
+ const properties = Array.isArray(utilityParams.property)
360
+ ? utilityParams.property
361
+ : [utilityParams.property];
362
+
363
+ // Generate utility classes as PostCSS nodes
364
+ const rules = scaleItems.map((item) => {
365
+ let ruleSelector;
366
+ let ruleValue;
367
+
368
+ if (utilityParams.attribute) {
369
+ // Attribute mode: [data-attr="value"] or .class[data-attr="value"]
370
+ const attrSelector = `[${utilityParams.attribute}="${item.label}"]`;
371
+ ruleSelector = utilityParams.selector
372
+ ? `${utilityParams.selector}${attrSelector}`
373
+ : attrSelector;
374
+ ruleValue = `var(--${utilityParams.scale}-${item.label})`;
375
+ } else {
376
+ // Class mode (existing behavior)
377
+ ruleSelector = `${utilityParams.selector}-${item.label}`;
378
+ ruleValue = item.clamp;
379
+ }
380
+
381
+ const rule = postcss.rule({ selector: ruleSelector });
382
+
383
+ properties.forEach((prop) => {
384
+ rule.append(postcss.decl({ prop, value: ruleValue }));
385
+ });
386
+
387
+ return rule;
388
+ });
389
+
390
+ atRule.replaceWith(rules);
391
+ };
392
+
393
+ /**
394
+ * Processes inline fluid functions in declarations
395
+ * @param {Object} decl - PostCSS declaration node
396
+ */
397
+ const processFluidDeclaration = (decl) => {
398
+ const regex = /ruler\.fluid\(([^)]+)\)/g;
399
+ let newValue = decl.value;
400
+ let match;
401
+
402
+ while ((match = regex.exec(decl.value)) !== null) {
403
+ const args = match[1]
404
+ .split(",")
405
+ .map((s) => s.trim())
406
+ .map(Number);
407
+ let [minSize, maxSize, minWidth, maxWidth] = args;
408
+
409
+ minWidth = minWidth || config.minWidth;
410
+ maxWidth = maxWidth || config.maxWidth;
411
+
412
+ if (!minSize || !maxSize) {
413
+ throw new Error(
414
+ "[postcss-ruler] ruler.fluid() requires minSize and maxSize",
303
415
  );
416
+ }
304
417
 
305
- const utilityParams = parseUtilityParams(params);
306
-
307
- // Validate required parameters
308
- if (!utilityParams.selector) {
309
- throw new Error(
310
- '[postcss-ruler] @ruler utility() requires a "selector" parameter'
311
- );
312
- }
313
- if (!utilityParams.property) {
314
- throw new Error(
315
- '[postcss-ruler] @ruler utility() requires a "property" parameter'
316
- );
317
- }
318
- if (!utilityParams.scale) {
319
- throw new Error(
320
- '[postcss-ruler] @ruler utility() requires a "scale" parameter'
321
- );
322
- }
323
-
324
- // Check if scale exists
325
- const scale = scales[utilityParams.scale];
326
- if (!scale) {
327
- throw new Error(
328
- `[postcss-ruler] Scale "${utilityParams.scale}" not found. Define it with @ruler scale() first.`
329
- );
330
- }
331
-
332
- // Determine which scale items to use
333
- let scaleItems = scale;
334
- if (utilityParams.generateAllCrossPairs === false) {
335
- // Filter out cross-pairs (items with hyphens in label)
336
- scaleItems = scale.filter(item => !item.label.includes('-'));
337
- }
338
-
339
- // Normalize property to array
340
- const properties = Array.isArray(utilityParams.property)
341
- ? utilityParams.property
342
- : [utilityParams.property];
343
-
344
- // Generate utility classes as PostCSS nodes
345
- const rules = scaleItems.map(item => {
346
- const selector = `${utilityParams.selector}-${item.label}`;
347
- const rule = postcss.rule({ selector });
348
-
349
- properties.forEach(prop => {
350
- rule.append(postcss.decl({ prop, value: item.clamp }));
351
- });
352
-
353
- return rule;
354
- });
355
-
356
- atRule.replaceWith(rules);
357
- };
358
-
359
- /**
360
- * Processes inline fluid functions in declarations
361
- * @param {Object} decl - PostCSS declaration node
362
- */
363
- const processFluidDeclaration = decl => {
364
- const regex = /ruler\.fluid\(([^)]+)\)/g;
365
- let newValue = decl.value;
366
- let match;
367
-
368
- while ((match = regex.exec(decl.value)) !== null) {
369
- const args = match[1].split(',').map(s => s.trim()).map(Number);
370
- let [minSize, maxSize, minWidth, maxWidth] = args;
371
-
372
- minWidth = minWidth || config.minWidth;
373
- maxWidth = maxWidth || config.maxWidth;
374
-
375
- if (!minSize || !maxSize) {
376
- throw new Error(
377
- '[postcss-ruler] ruler.fluid() requires minSize and maxSize'
378
- );
379
- }
380
-
381
- const clampValue = calculateClamp({
382
- minSize,
383
- maxSize,
384
- minWidth,
385
- maxWidth,
386
- });
387
-
388
- newValue = newValue.replace(match[0], clampValue);
389
- }
390
-
391
- if (newValue !== decl.value) {
392
- decl.value = newValue;
418
+ const clampValue = calculateClamp({
419
+ minSize,
420
+ maxSize,
421
+ minWidth,
422
+ maxWidth,
423
+ });
424
+
425
+ newValue = newValue.replace(match[0], clampValue);
426
+ }
427
+
428
+ if (newValue !== decl.value) {
429
+ decl.value = newValue;
430
+ }
431
+ };
432
+
433
+ return {
434
+ postcssPlugin: "ruler",
435
+ AtRule: {
436
+ ruler: (atRule) => {
437
+ if (atRule.params.startsWith("scale(")) {
438
+ return processFluidAtRule(atRule);
439
+ } else if (atRule.params.startsWith("utility(")) {
440
+ return processUtilityAtRule(atRule);
393
441
  }
394
- };
395
-
396
- return {
397
- postcssPlugin: 'ruler',
398
- AtRule: {
399
- ruler: atRule => {
400
- if (atRule.params.startsWith('scale(')) {
401
- return processFluidAtRule(atRule);
402
- } else if (atRule.params.startsWith('utility(')) {
403
- return processUtilityAtRule(atRule);
404
- }
405
- },
406
- },
407
- Declaration(decl) {
408
- processFluidDeclaration(decl);
409
- },
410
- };
442
+ },
443
+ },
444
+ Declaration(decl) {
445
+ processFluidDeclaration(decl);
446
+ },
447
+ };
411
448
  };
412
449
 
413
450
  module.exports.postcss = true;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "postcss-ruler",
3
- "version": "1.1.0",
3
+ "version": "1.3.0",
4
4
  "description": "PostCSS plugin to generate fluid scales and values.",
5
5
  "main": "index.js",
6
6
  "files": [