postcss-minify-gradients 9.0.3 → 9.0.5

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "postcss-minify-gradients",
3
- "version": "9.0.3",
3
+ "version": "9.0.5",
4
4
  "description": "Minify gradient parameters with PostCSS.",
5
5
  "exports": {
6
6
  ".": {
@@ -33,9 +33,9 @@
33
33
  "url": "git+https://github.com/cssnano/cssnano.git"
34
34
  },
35
35
  "dependencies": {
36
- "@colordx/core": "^6.5.0",
37
- "cssnano-utils": "^7.0.3",
38
- "postcss-value-parser": "^4.2.0"
36
+ "@colordx/core": "^7.2.0",
37
+ "@csstools/css-tokenizer": "^4.0.2",
38
+ "cssnano-utils": "^8.0.1"
39
39
  },
40
40
  "bugs": {
41
41
  "url": "https://github.com/cssnano/cssnano/issues"
@@ -51,6 +51,7 @@
51
51
  },
52
52
  "type": "module",
53
53
  "scripts": {
54
+ "fuzz": "node ./script/fuzz.js",
54
55
  "test": "node --test"
55
56
  }
56
57
  }
package/src/index.js CHANGED
@@ -1,376 +1,401 @@
1
- import valueParser from 'postcss-value-parser';
1
+ /** @import {CSSToken} from '@csstools/css-tokenizer' */
2
2
  import cssnanoUtils from 'cssnano-utils';
3
- import isColorStop from './isColorStop.js';
4
-
5
- const { getArguments } = cssnanoUtils;
6
- const directionsToAngles = new Map([
3
+ import isKnownColor from './isKnownColor.js';
4
+
5
+ const {
6
+ TokenType,
7
+ applyEdits,
8
+ asciiLowerCase,
9
+ decoded,
10
+ lengthUnits,
11
+ numeric,
12
+ tokenEnd,
13
+ } = cssnanoUtils;
14
+ /** @type {typeof cssnanoUtils.balancedTokens} */
15
+ const balancedTokens = cssnanoUtils.balancedTokens;
16
+
17
+ const directions = new Map([
7
18
  ['top', '0deg'],
8
19
  ['right', '90deg'],
9
20
  ['bottom', '180deg'],
10
21
  ['left', '270deg'],
11
22
  ]);
23
+ const gradientNames = new Set([
24
+ 'linear-gradient',
25
+ 'repeating-linear-gradient',
26
+ '-webkit-linear-gradient',
27
+ '-webkit-repeating-linear-gradient',
28
+ 'radial-gradient',
29
+ 'repeating-radial-gradient',
30
+ 'conic-gradient',
31
+ 'repeating-conic-gradient',
32
+ '-webkit-radial-gradient',
33
+ '-webkit-repeating-radial-gradient',
34
+ ]);
35
+ const variableFunctions = new Set(['var', 'env']);
36
+ // Only these functions can compute a position, so any other leading function
37
+ // such as `rgb()` or `var()` cannot be one.
38
+ const positionFunctions = new Set(['calc', 'clamp', 'max', 'min']);
39
+ const gradientSourceRegex = /[gG][rR][aA][dD][iI][eE][nN][tT]/v;
40
+ // Identifiers that may start a gradient line, shape or size specification, so
41
+ // the argument carrying one is never a colour stop slot.
42
+ const lineKeywords = new Set([
43
+ 'at',
44
+ 'bottom',
45
+ 'center',
46
+ 'circle',
47
+ 'closest-corner',
48
+ 'closest-side',
49
+ 'ellipse',
50
+ 'farthest-corner',
51
+ 'farthest-side',
52
+ 'from',
53
+ 'in',
54
+ 'left',
55
+ 'right',
56
+ 'to',
57
+ 'top',
58
+ ]);
12
59
 
60
+ /** @typedef {{start: number, end: number, text: string}} SourceEdit */
13
61
  /**
14
- * Returns whether b is less than or equal to a.
62
+ * A gradient stop before fixup: the argument it came from, the extent of its
63
+ * colour, and the position tokens following that colour.
15
64
  *
16
- * @param {valueParser.Dimension} a
17
- * @param {valueParser.Dimension} b
18
- * @returns {boolean}
65
+ * @typedef {object} ColorStop
66
+ * @property {number} argIndex Comma-separated argument the stop came from.
67
+ * @property {number} colorEnd Offset just past the colour in the declaration value.
68
+ * @property {readonly number[]} position Positions following the colour.
19
69
  */
20
- function isLessThan(a, b) {
21
- return (
22
- a.unit.toLowerCase() === b.unit.toLowerCase() &&
23
- Number.parseFloat(a.number) >= Number.parseFloat(b.number)
24
- );
25
- }
26
-
27
70
  /**
28
- * Compares positions when their ordering is independent of layout.
71
+ * A position as a number and its unit, as reported by `numeric()`.
29
72
  *
30
- * @param {valueParser.Dimension} a
31
- * @param {valueParser.Dimension} b
32
- * @returns {boolean | undefined}
73
+ * @typedef {{number: number, unit: string}} PositionValue
33
74
  */
34
- function isLessThanOrEqual(a, b) {
35
- if (a.unit.toLowerCase() === b.unit.toLowerCase()) {
36
- return isLessThan(a, b);
37
- }
38
-
39
- const aNumber = Number.parseFloat(a.number);
40
- const bNumber = Number.parseFloat(b.number);
41
-
42
- if (aNumber === 0) {
43
- return bNumber <= 0;
44
- }
45
-
46
- if (bNumber === 0) {
47
- return aNumber >= 0;
48
- }
49
- }
50
75
 
51
76
  /**
52
- * Returns whether a node is a literal color stop position.
77
+ * Significant tokens of a range, skipping whitespace, comments and the contents
78
+ * of any balanced frame the range opens.
53
79
  *
54
- * @param {import('postcss-value-parser').Node} node
55
- * @returns {boolean}
80
+ * @param {readonly CSSToken[]} input
81
+ * @param {NonNullable<ReturnType<typeof balancedTokens>>} structure
82
+ * @param {{startIndex: number, endIndex: number}} range
83
+ * @return {number[]} Indexes of the significant tokens in `input`.
56
84
  */
57
- function isPosition(node) {
58
- return node.type === 'word' && valueParser.unit(node.value) !== false;
85
+ function significant(input, structure, range) {
86
+ const result = [];
87
+ for (let index = range.startIndex; index < range.endIndex; index++) {
88
+ const token = input[index];
89
+ if (token[0] !== TokenType.Whitespace && token[0] !== TokenType.Comment) {
90
+ result.push(index);
91
+ }
92
+ const frameEnd = structure.endForOpening(index);
93
+ if (frameEnd !== undefined) index = frameEnd;
94
+ }
95
+ return result;
59
96
  }
60
97
 
61
98
  /**
62
- * Returns whether an argument begins a color stop.
99
+ * Whether an argument starts a line, shape or size specification instead of a
100
+ * colour. Numbers cannot start a colour, and every other token here has to be
101
+ * an identifier the gradient grammar reserves for its first argument.
63
102
  *
64
- * @param {import('postcss-value-parser').Node[]} argument
65
- * @returns {boolean}
103
+ * @param {readonly CSSToken[]} input
104
+ * @param {readonly number[]} parts Offsets of an argument's significant tokens.
105
+ * @return {boolean}
66
106
  */
67
- function isColorStopArgument(argument) {
68
- const first = argument[0];
69
-
70
- if (!first) {
71
- return false;
72
- }
73
-
74
- if (first.type === 'function') {
75
- return !new Set(['calc', 'clamp', 'max', 'min']).has(
76
- first.value.toLowerCase()
77
- );
107
+ function startsLineSpecification(input, parts) {
108
+ const token = input[parts[0]];
109
+ if (!token) return false;
110
+ if (
111
+ token[0] === TokenType.Dimension ||
112
+ token[0] === TokenType.Number ||
113
+ token[0] === TokenType.Percentage
114
+ ) {
115
+ return true;
78
116
  }
79
-
80
- return first.type === 'word' && isColorStop(first.value);
117
+ return (
118
+ token[0] === TokenType.Ident &&
119
+ lineKeywords.has(asciiLowerCase(decoded(token)))
120
+ );
81
121
  }
82
122
 
83
123
  /**
84
- * Gets the position nodes from a color stop or transition hint.
124
+ * A zero length and a zero percentage name the same gradient position, which is
125
+ * also what the unitless zero this plugin emits denotes. Angles and other
126
+ * dimensions only share a spelling with it.
85
127
  *
86
- * @param {import('postcss-value-parser').Node[]} argument
87
- * @param {boolean} colorStopArgument
88
- * @returns {import('postcss-value-parser').Node[]}
128
+ * @param {string} unit
129
+ * @return {boolean}
89
130
  */
90
- function getPositions(argument, colorStopArgument) {
91
- return argument.slice(colorStopArgument ? 1 : 0).filter((node) => {
92
- return node.type !== 'space' && node.type !== 'comment';
93
- });
131
+ function isPositionZeroUnit(unit) {
132
+ const lowered = asciiLowerCase(unit);
133
+ return lowered === '' || lowered === '%' || lengthUnits.has(lowered);
94
134
  }
95
135
 
96
136
  /**
97
- * Updates the largest preceding position, replacing a position that will be
98
- * fixed up with its shorter equivalent.
137
+ * Whether two positions are on the same scale, so their numbers can be ordered.
138
+ * Only a length and a percentage naming zero are interchangeable.
99
139
  *
100
- * @param {valueParser.Dimension | undefined} largestPosition
101
- * @param {import('postcss-value-parser').Node} position
102
- * @returns {valueParser.Dimension | undefined}
140
+ * @param {PositionValue} current
141
+ * @param {PositionValue} largest
142
+ * @return {boolean}
103
143
  */
104
- function updateLargestPosition(largestPosition, position) {
105
- if (!isPosition(position)) {
106
- return undefined;
107
- }
108
-
109
- const currentPosition = valueParser.unit(position.value);
110
-
111
- if (!currentPosition) {
112
- return undefined;
113
- }
114
-
115
- if (!largestPosition) {
116
- return currentPosition;
117
- }
118
-
119
- const isFixedUp = isLessThanOrEqual(largestPosition, currentPosition);
120
-
121
- if (isFixedUp) {
122
- position.value = '0';
123
- return largestPosition;
124
- }
125
-
126
- return isFixedUp === undefined ? undefined : currentPosition;
144
+ function positionsComparable(current, largest) {
145
+ const unit = asciiLowerCase(current.unit);
146
+ const largestUnit = asciiLowerCase(largest.unit);
147
+ if (unit === largestUnit) return true;
148
+ return (
149
+ (current.number === 0 || largest.number === 0) &&
150
+ isPositionZeroUnit(unit) &&
151
+ isPositionZeroUnit(largestUnit)
152
+ );
127
153
  }
128
154
 
129
155
  /**
130
- * Optimizes a standard gradient color stop list using the color stop fixup
131
- * algorithm. A non-literal position prevents later comparisons because its
132
- * used value can only be known during layout.
156
+ * Offset just past the leading colour of an argument, so a functional colour
157
+ * such as `rgb(0 0 0 / 50%)` ends at its closing parenthesis.
133
158
  *
134
- * @param {import('postcss-value-parser').Node[][]} args
135
- * @returns {void}
159
+ * @param {readonly CSSToken[]} input
160
+ * @param {NonNullable<ReturnType<typeof balancedTokens>>} structure
161
+ * @param {readonly number[]} parts Offsets of an argument's significant tokens.
162
+ * @return {number}
136
163
  */
137
- function optimizeColorStops(args) {
138
- const firstStopIndex = args.findIndex(isColorStopArgument);
139
-
140
- if (firstStopIndex === -1) {
141
- return;
142
- }
143
-
144
- const stops = [];
145
- let largestPosition = /** @type {valueParser.Dimension | undefined} */ (
146
- undefined
147
- );
148
- let hasSeenStop = false;
149
-
150
- for (const argument of args.slice(firstStopIndex)) {
151
- const colorStop = isColorStopArgument(argument);
152
- const positions = getPositions(argument, colorStop);
153
-
154
- if (colorStop) {
155
- stops.push({ argument, positions });
156
-
157
- if (!hasSeenStop && positions.length === 0) {
158
- largestPosition = /** @type {valueParser.Dimension} */ (
159
- valueParser.unit('0%')
160
- );
161
- }
162
-
163
- hasSeenStop = true;
164
- }
165
-
166
- if (positions.length === 0) {
167
- continue;
168
- }
169
-
170
- for (const position of positions) {
171
- largestPosition = updateLargestPosition(largestPosition, position);
172
- }
173
- }
174
-
175
- const firstStop = stops[0];
176
- const lastStop = stops.at(-1);
177
-
178
- if (
179
- firstStop &&
180
- firstStop.positions.length === 1 &&
181
- firstStop.argument.length === 3 &&
182
- firstStop.positions[0].value === '0%'
183
- ) {
184
- firstStop.argument[1].value = firstStop.positions[0].value = '';
185
- }
186
-
187
- if (
188
- lastStop &&
189
- lastStop.positions.length === 1 &&
190
- lastStop.argument.length === 3 &&
191
- lastStop.positions[0].value === '100%'
192
- ) {
193
- lastStop.argument[1].value = lastStop.positions[0].value = '';
194
- }
164
+ function colorEnd(input, structure, parts) {
165
+ const leading = parts[0];
166
+ const component = input[leading];
167
+ if (component[0] !== TokenType.Function) return component[3];
168
+ const closing = structure.endForOpening(leading);
169
+ return closing === undefined ? component[3] : input[closing][3];
195
170
  }
196
171
 
197
172
  /**
198
- * Shortens a direction like `to left top` into an angle.
173
+ * A `to <side>` line specification is an angle in fewer bytes.
199
174
  *
200
- * @param {import('postcss-value-parser').FunctionNode} node
201
- * @returns {void}
175
+ * @param {readonly CSSToken[]} input
176
+ * @param {readonly number[]} parts Significant tokens of the first argument.
177
+ * @return {SourceEdit | undefined}
202
178
  */
203
- function shortenDirection(node) {
204
- node.nodes = node.nodes.slice(2);
205
- node.nodes[0].value = /** @type {string} */ (
206
- directionsToAngles.get(node.nodes[0].value.toLowerCase())
207
- );
179
+ function lineDirectionEdit(input, parts) {
180
+ if (parts.length !== 2) return undefined;
181
+ const from = input[parts[0]];
182
+ const side = input[parts[1]];
183
+ if (
184
+ from[0] !== TokenType.Ident ||
185
+ side[0] !== TokenType.Ident ||
186
+ asciiLowerCase(decoded(from)) !== 'to'
187
+ )
188
+ return undefined;
189
+ const direction = directions.get(asciiLowerCase(decoded(side)));
190
+ if (!direction) return undefined;
191
+ return { start: from[2], end: tokenEnd(side), text: direction };
208
192
  }
209
193
 
210
194
  /**
211
- * Optimises a linear gradient.
195
+ * Colour stop fixup raises a position to the largest position before it, so a
196
+ * position at or below that non-negative maximum can be written as a zero.
212
197
  *
213
- * @param {import('postcss-value-parser').FunctionNode} node
214
- * @returns {false}
198
+ * @param {readonly CSSToken[]} input
199
+ * @param {readonly number[]} position Offsets of the position tokens.
200
+ * @param {PositionValue | undefined} maximum Running maximum of the preceding positions.
201
+ * @param {Map<number, SourceEdit>} edits Collects the positions reduced to a zero.
202
+ * @return {PositionValue | undefined} The maximum the following stops clamp to.
215
203
  */
216
- function optimizeLinearGradient(node) {
217
- const args = getArguments(node);
218
- if (node.nodes[0]?.value.toLowerCase() === 'to' && args[0].length === 3) {
219
- shortenDirection(node);
204
+ function zeroPositionEdits(input, position, maximum, edits) {
205
+ let largest = maximum;
206
+ for (const index of position) {
207
+ const token = input[index];
208
+ const current = numeric(token);
209
+ if (!current) {
210
+ largest = undefined;
211
+ continue;
212
+ }
213
+ // A zero clamps to any non-negative maximum whatever units name them, so
214
+ // the maximum survives the rewritten spelling instead of resetting on the
215
+ // next pass.
216
+ if (
217
+ largest &&
218
+ largest.number >= 0 &&
219
+ current.number === 0 &&
220
+ isPositionZeroUnit(current.unit)
221
+ ) {
222
+ edits.set(index, { start: token[2], end: tokenEnd(token), text: '0' });
223
+ continue;
224
+ }
225
+ if (largest && !positionsComparable(current, largest)) {
226
+ largest = undefined;
227
+ continue;
228
+ }
229
+ if (largest && largest.number >= 0 && largest.number >= current.number) {
230
+ edits.set(index, { start: token[2], end: tokenEnd(token), text: '0' });
231
+ } else {
232
+ largest = current;
233
+ }
220
234
  }
221
- optimizeColorStops(getArguments(node));
222
-
223
- return false;
235
+ return largest;
224
236
  }
225
237
 
226
238
  /**
227
- * Optimises a radial gradient.
239
+ * Collect the colour stops of one gradient argument list, the count of leading
240
+ * arguments naming the line or shape instead of a stop, and the positions that
241
+ * colour stop fixup clamps to a zero.
228
242
  *
229
- * @param {import('postcss-value-parser').ParsedValue | import('postcss-value-parser').FunctionNode} node
230
- * @returns {false}
243
+ * @param {string} source
244
+ * @param {readonly CSSToken[]} input
245
+ * @param {NonNullable<ReturnType<typeof balancedTokens>>} structure
246
+ * @param {{startIndex: number, endIndex: number}[]} args
247
+ * @param {boolean} linear Whether the line specification may hold an angle.
248
+ * @return {{stops: ColorStop[], lineSpecifications: number, zeroEdits: Map<number, SourceEdit>, directionEdit: SourceEdit | undefined}}
231
249
  */
232
- function optimizeRadialGradient(node) {
233
- optimizeColorStops(getArguments(node));
234
-
235
- return false;
250
+ function collectColorStops(source, input, structure, args, linear) {
251
+ /** @type {ColorStop[]} */
252
+ const stops = [];
253
+ /** @type {Map<number, SourceEdit>} */
254
+ const zeroEdits = new Map();
255
+ /** @type {SourceEdit | undefined} */
256
+ let directionEdit;
257
+ // A stop may only drop its start position when nothing but these arguments
258
+ // precede it.
259
+ let lineSpecifications = 0;
260
+ /** @type {PositionValue | undefined} */
261
+ let largest;
262
+ for (const [argIndex, range] of args.entries()) {
263
+ const parts = significant(input, structure, range);
264
+ if (!parts.length) continue;
265
+ if (linear && argIndex === 0)
266
+ directionEdit = lineDirectionEdit(input, parts);
267
+ const end = colorEnd(input, structure, parts);
268
+ const leading = input[parts[0]];
269
+ const color = asciiLowerCase(source.slice(leading[2], end + 1));
270
+ const isColor =
271
+ isKnownColor(color) ||
272
+ (leading[0] === TokenType.Function &&
273
+ !positionFunctions.has(asciiLowerCase(decoded(leading))));
274
+ const position = isColor ? parts.slice(1) : parts;
275
+ if (
276
+ !isColor &&
277
+ argIndex === lineSpecifications &&
278
+ startsLineSpecification(input, parts)
279
+ )
280
+ lineSpecifications = argIndex + 1;
281
+ if (isColor) {
282
+ // A stop without a position sits at the running maximum, which starts at zero.
283
+ if (!position.length && !largest) largest = { number: 0, unit: '%' };
284
+ stops.push({ argIndex, colorEnd: end, position });
285
+ }
286
+ // Positions before the first stop belong to the line specification.
287
+ if (!stops.length) continue;
288
+ largest = zeroPositionEdits(input, position, largest, zeroEdits);
289
+ }
290
+ return { stops, lineSpecifications, zeroEdits, directionEdit };
236
291
  }
237
292
 
238
293
  /**
239
- * Optimises a radial gradient.
294
+ * The first colour stop defaults to the zero position and the last one to 100%,
295
+ * so either can drop a position already spelling its default. A single-stop
296
+ * gradient holds one stop in both slots, hence the slots by index and the
297
+ * zero edit dropped in favour of the wider removal.
240
298
  *
241
- * @param {import('postcss-value-parser').ParsedValue | import('postcss-value-parser').FunctionNode} node
242
- * @returns {false}
299
+ * @param {readonly CSSToken[]} input
300
+ * @param {readonly ColorStop[]} stops
301
+ * @param {number} lineSpecifications
302
+ * @param {number} args
303
+ * @param {Map<number, SourceEdit>} zeroEdits
304
+ * @return {SourceEdit[]}
243
305
  */
244
- function optimizeWebkitRadialGradient(node) {
245
- const args = getArguments(node);
246
- /** @type {valueParser.Dimension | false | undefined} */
247
- let previousStop = undefined;
248
-
249
- for (const arg of args) {
250
- let color;
251
- let stop;
252
-
253
- if (arg[2] !== undefined) {
254
- if (arg[0].type === 'function') {
255
- color = `${arg[0].value}(${valueParser.stringify(arg[0].nodes)})`;
256
- } else {
257
- color = arg[0].value;
258
- }
259
-
260
- if (arg[2].type === 'function') {
261
- stop = `${arg[2].value}(${valueParser.stringify(arg[2].nodes)})`;
262
- } else {
263
- stop = arg[2].value;
264
- }
265
- } else {
266
- if (arg[0].type === 'function') {
267
- // eslint-disable-next-line no-useless-assignment
268
- color = `${arg[0].value}(${valueParser.stringify(arg[0].nodes)})`;
269
- }
270
-
271
- color = arg[0].value;
306
+ function boundaryStopEdits(input, stops, lineSpecifications, args, zeroEdits) {
307
+ /** @type {SourceEdit[]} */
308
+ const edits = [];
309
+ for (const slot of new Set([0, stops.length - 1])) {
310
+ const stop = stops[slot];
311
+ if (!stop || stop.position.length !== 1) continue;
312
+ const [index] = stop.position;
313
+ const position = input[index];
314
+ const value = numeric(position);
315
+ if (!value) continue;
316
+ let removable = false;
317
+ if (slot === 0 && stop.argIndex === lineSpecifications) {
318
+ removable = value.number === 0 && isPositionZeroUnit(value.unit);
272
319
  }
273
-
274
- color = color.toLowerCase();
275
-
276
- const colorStop =
277
- stop !== undefined
278
- ? isColorStop(color, stop.toLowerCase())
279
- : isColorStop(color);
280
-
281
- if (!colorStop || !arg[2]) {
282
- continue;
320
+ if (
321
+ slot === stops.length - 1 &&
322
+ stop.argIndex === args - 1 &&
323
+ value.number === 100 &&
324
+ asciiLowerCase(value.unit) === '%'
325
+ ) {
326
+ removable = true;
283
327
  }
284
-
285
- const thisStop = valueParser.unit(arg[2].value);
286
-
287
- if (!previousStop) {
288
- previousStop = thisStop;
289
-
290
- continue;
291
- }
292
-
293
- if (previousStop && thisStop && isLessThan(previousStop, thisStop)) {
294
- arg[2].value = '0';
295
- }
296
-
297
- previousStop = thisStop;
328
+ if (!removable) continue;
329
+ // Removing the position from the colour onwards also covers a zero edit
330
+ // emitted for the same token.
331
+ zeroEdits.delete(index);
332
+ edits.push({
333
+ start: stop.colorEnd + 1,
334
+ end: tokenEnd(position),
335
+ text: '',
336
+ });
298
337
  }
299
-
300
- return false;
338
+ return edits;
301
339
  }
302
340
 
303
341
  /**
304
342
  * @param {import('postcss').Declaration} decl
305
- * @return {void}
306
343
  */
307
344
  function optimise(decl) {
308
- const value = decl.value;
309
-
310
- if (!value) {
311
- return;
312
- }
313
-
314
- const normalizedValue = value.toLowerCase();
315
-
316
- if (normalizedValue.includes('var(') || normalizedValue.includes('env(')) {
317
- return;
318
- }
319
-
320
- if (!normalizedValue.includes('gradient')) {
345
+ const source = decl.value;
346
+ if (!source || !gradientSourceRegex.test(source)) return;
347
+ const structure = balancedTokens(source);
348
+ if (!structure) return;
349
+ const { tokens: input } = structure;
350
+ if (
351
+ input.some(
352
+ (token) =>
353
+ token[0] === TokenType.Function &&
354
+ variableFunctions.has(asciiLowerCase(decoded(token)))
355
+ )
356
+ )
321
357
  return;
358
+ /** @type {SourceEdit[]} */
359
+ const replacements = [];
360
+ // Nested gradients are visited in token order; their argument ranges come
361
+ // from the shared delimiter map.
362
+ for (let index = 0; index < input.length; index++) {
363
+ const token = input[index];
364
+ if (token[0] !== TokenType.Function) continue;
365
+ const name = asciiLowerCase(decoded(token));
366
+ if (!gradientNames.has(name)) continue;
367
+ const end = structure.endForOpening(index);
368
+ if (end === undefined) continue;
369
+ const args = structure.topLevelSegments(index + 1, end);
370
+ const { stops, lineSpecifications, zeroEdits, directionEdit } =
371
+ collectColorStops(
372
+ source,
373
+ input,
374
+ structure,
375
+ args,
376
+ name.includes('linear')
377
+ );
378
+ if (directionEdit) replacements.push(directionEdit);
379
+ replacements.push(
380
+ ...boundaryStopEdits(
381
+ input,
382
+ stops,
383
+ lineSpecifications,
384
+ args.length,
385
+ zeroEdits
386
+ ),
387
+ ...zeroEdits.values()
388
+ );
322
389
  }
323
-
324
- decl.value = valueParser(value)
325
- .walk((node) => {
326
- if (node.type !== 'function' || !node.nodes.length) {
327
- return false;
328
- }
329
-
330
- const lowerCasedValue = node.value.toLowerCase();
331
-
332
- if (
333
- lowerCasedValue === 'linear-gradient' ||
334
- lowerCasedValue === 'repeating-linear-gradient' ||
335
- lowerCasedValue === '-webkit-linear-gradient' ||
336
- lowerCasedValue === '-webkit-repeating-linear-gradient'
337
- ) {
338
- return optimizeLinearGradient(node);
339
- }
340
-
341
- if (
342
- lowerCasedValue === 'radial-gradient' ||
343
- lowerCasedValue === 'repeating-radial-gradient'
344
- ) {
345
- return optimizeRadialGradient(node);
346
- }
347
-
348
- if (
349
- lowerCasedValue === '-webkit-radial-gradient' ||
350
- lowerCasedValue === '-webkit-repeating-radial-gradient'
351
- ) {
352
- return optimizeWebkitRadialGradient(node);
353
- }
354
-
355
- if (
356
- lowerCasedValue === 'conic-gradient' ||
357
- lowerCasedValue === 'repeating-conic-gradient'
358
- ) {
359
- optimizeColorStops(getArguments(node));
360
- }
361
- return false;
362
- })
363
- .toString();
390
+ // With nothing to rewrite, neither the edit pass nor the write can change the
391
+ // declaration.
392
+ if (replacements.length) decl.value = applyEdits(source, replacements);
364
393
  }
365
- /**
366
- * @return {import('postcss').Plugin}
367
- */
394
+
395
+ /** @return {import('postcss').Plugin} */
368
396
  function pluginCreator() {
369
397
  return {
370
398
  postcssPlugin: 'postcss-minify-gradients',
371
- /**
372
- * @param {import('postcss').Root} css
373
- */
374
399
  OnceExit(css) {
375
400
  css.walkDecls(optimise);
376
401
  },
@@ -378,6 +403,4 @@ function pluginCreator() {
378
403
  }
379
404
  /** @type {true} */
380
405
  pluginCreator.postcss = true;
381
- const moduleExports = pluginCreator;
382
-
383
- export { moduleExports as default, moduleExports as 'module.exports' };
406
+ export { pluginCreator as default, pluginCreator as 'module.exports' };
@@ -0,0 +1,79 @@
1
+ import { colordx as colord, extend } from '@colordx/core';
2
+ import hwbPlugin from '@colordx/core/plugins/hwb';
3
+ import namesPlugin from '@colordx/core/plugins/names';
4
+
5
+ extend([
6
+ /** @type {import('@colordx/core').Plugin} */ (
7
+ /** @type {unknown} */ (hwbPlugin)
8
+ ),
9
+ /** @type {import('@colordx/core').Plugin} */ (
10
+ /** @type {unknown} */ (namesPlugin)
11
+ ),
12
+ ]);
13
+
14
+ /* A gradient stop may hold `currentColor` and the `<system-color>` keywords,
15
+ * which resolve only at used-value time, so no colour parser can validate
16
+ * them; the keyword list carries them. Plugin registration is process-global
17
+ * and `postcss-colormin` shares it, so colordx loads only the plugins this
18
+ * module needs — otherwise colormin would gamut-map wide-gamut colours.
19
+ * Colour notations colordx cannot parse remain valid stop colours.
20
+ * `postcss-merge-longhand` duplicates this list on purpose. */
21
+ export const dynamicColorKeywords = new Set([
22
+ 'currentcolor',
23
+ 'accentcolor',
24
+ 'accentcolortext',
25
+ 'activetext',
26
+ 'buttonborder',
27
+ 'buttonface',
28
+ 'buttontext',
29
+ 'canvas',
30
+ 'canvastext',
31
+ 'field',
32
+ 'fieldtext',
33
+ 'graytext',
34
+ 'highlight',
35
+ 'highlighttext',
36
+ 'linktext',
37
+ 'mark',
38
+ 'marktext',
39
+ 'selecteditem',
40
+ 'selecteditemtext',
41
+ 'visitedtext',
42
+ // Deprecated system colors (CSS Color 3 / CSS Color 4 § 3.3.1)
43
+ 'activeborder',
44
+ 'activecaption',
45
+ 'appworkspace',
46
+ 'background',
47
+ 'buttonhighlight',
48
+ 'buttonshadow',
49
+ 'captiontext',
50
+ 'inactiveborder',
51
+ 'inactivecaption',
52
+ 'inactivecaptiontext',
53
+ 'infobackground',
54
+ 'infotext',
55
+ 'menu',
56
+ 'menutext',
57
+ 'scrollbar',
58
+ 'threeddarkshadow',
59
+ 'threedface',
60
+ 'threedhighlight',
61
+ 'threedlightshadow',
62
+ 'threedshadow',
63
+ 'window',
64
+ 'windowframe',
65
+ 'windowtext',
66
+ ]);
67
+
68
+ /**
69
+ * Whether `color` is a `<color>` a gradient stop may hold, including the
70
+ * keywords that only resolve at used-value time. Arguments beginning with a
71
+ * function other than `calc()`, `clamp()`, `max()` or `min()` are treated as
72
+ * colour stops without this check.
73
+ *
74
+ * @param {string} color
75
+ * @returns {boolean}
76
+ */
77
+ export default function isKnownColor(color) {
78
+ return dynamicColorKeywords.has(color) || colord(color).isValid();
79
+ }
package/types/index.d.ts CHANGED
@@ -1,10 +1,30 @@
1
- /**
2
- * @return {import('postcss').Plugin}
3
- */
1
+ export type SourceEdit = {
2
+ start: number;
3
+ end: number;
4
+ text: string;
5
+ };
6
+ export type ColorStop = {
7
+ /**
8
+ * Comma-separated argument the stop came from.
9
+ */
10
+ argIndex: number;
11
+ /**
12
+ * Offset just past the colour in the declaration value.
13
+ */
14
+ colorEnd: number;
15
+ /**
16
+ * Positions following the colour.
17
+ */
18
+ position: readonly number[];
19
+ };
20
+ export type PositionValue = {
21
+ number: number;
22
+ unit: string;
23
+ };
24
+ /** @return {import('postcss').Plugin} */
4
25
  declare function pluginCreator(): import('postcss').Plugin;
5
26
  declare namespace pluginCreator {
6
27
  var postcss: true;
7
28
  }
8
- declare const moduleExports: typeof pluginCreator;
9
- export { moduleExports as default, moduleExports as 'module.exports' };
29
+ export { pluginCreator as default, pluginCreator as 'module.exports' };
10
30
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.js"],"names":[],"mappings":"AA4WA;;GAEG;AACH,iBAAS,aAAa,IAFV,OAAO,SAAS,EAAE,MAAM,CAYnC;kBAVQ,aAAa;iBAWX,IAAI;;AAEf,QAAA,MAAM,aAAa,sBAAgB,CAAC;AAEpC,OAAO,EAAE,aAAa,IAAI,OAAO,EAAE,aAAa,IAAI,gBAAgB,EAAE,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.js"],"names":[],"mappings":"AA2DI,YAAsD,UAAU,GAAtD;IAAC,KAAK,EAAE,MAAM,CAAC;IAAC,GAAG,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAC,CAAY;AAKjE,YAAkB,SAAS,GAC3B;;;;IAAmB,QAAQ,EAAhB,MAAM,CACjB;;;;IAAmB,QAAQ,EAAhB,MAAM,CACjB;;;;IAA8B,QAAQ,EAA3B,SAAS,MAAM,EAAE,CAC9B;CAAA,CAAA;AAIE,YAA0C,aAAa,GAA7C;IAAC,MAAM,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAC,CAAe;AAkU1D,yCAAyC;AACzC,iBAAS,aAAa,IADT,OAAO,SAAS,EAAE,MAAM,CAQpC;kBAPQ,aAAa;iBAQX,IAAI;;AAEf,OAAO,EAAE,aAAa,IAAI,OAAO,EAAE,aAAa,IAAI,gBAAgB,EAAE,CAAC"}
@@ -0,0 +1,12 @@
1
+ export declare const dynamicColorKeywords: Set<string>;
2
+ /**
3
+ * Whether `color` is a `<color>` a gradient stop may hold, including the
4
+ * keywords that only resolve at used-value time. Arguments beginning with a
5
+ * function other than `calc()`, `clamp()`, `max()` or `min()` are treated as
6
+ * colour stops without this check.
7
+ *
8
+ * @param {string} color
9
+ * @returns {boolean}
10
+ */
11
+ export default function isKnownColor(color: string): boolean;
12
+ //# sourceMappingURL=isKnownColor.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"isKnownColor.d.ts","sourceRoot":"","sources":["../src/isKnownColor.js"],"names":[],"mappings":"AAoBA,eAAO,MAAM,oBAAoB,aA6C/B,CAAC;AAEH;;;;;;;;GAQG;AACH,MAAM,CAAC,OAAO,UAAU,YAAY,CAAC,KAAK,EAH/B,MAG+B,GAF7B,OAAO,CAInB"}
@@ -1,77 +0,0 @@
1
- import postcssValueParser from 'postcss-value-parser';
2
- import { colordx as colord, extend } from '@colordx/core';
3
- import hwbPlugin from '@colordx/core/plugins/hwb';
4
- import namesPlugin from '@colordx/core/plugins/names';
5
-
6
- const { unit } = postcssValueParser;
7
- extend([
8
- /** @type {import('@colordx/core').Plugin} */ (
9
- /** @type {unknown} */ (hwbPlugin)
10
- ),
11
- /** @type {import('@colordx/core').Plugin} */ (
12
- /** @type {unknown} */ (namesPlugin)
13
- ),
14
- ]);
15
-
16
- /* Code derived from https://www.npmjs.com/package/is-color-stop Licensed under MIT license */
17
-
18
- const lengthUnits = new Set([
19
- 'PX',
20
- 'IN',
21
- 'CM',
22
- 'MM',
23
- 'EM',
24
- 'REM',
25
- 'POINTS',
26
- 'PC',
27
- 'EX',
28
- 'CH',
29
- 'VW',
30
- 'VH',
31
- 'VMIN',
32
- 'VMAX',
33
- '%',
34
- ]);
35
-
36
- const colorStopRegex = /^calc\(\S+\)$/g;
37
-
38
- /**
39
- * @param {string} input
40
- * @return {boolean}
41
- */
42
- function isCSSLengthUnit(input) {
43
- return lengthUnits.has(input.toUpperCase());
44
- }
45
- /**
46
- * @param {string|undefined} str
47
- * @return {boolean}
48
- */
49
- function isStop(str) {
50
- if (str) {
51
- let colorStop = false;
52
- const node = unit(str);
53
- if (node) {
54
- const number = Number(node.number);
55
- if (
56
- number === 0 ||
57
- (!Number.isNaN(number) && isCSSLengthUnit(node.unit))
58
- ) {
59
- colorStop = true;
60
- }
61
- } else {
62
- colorStop = colorStopRegex.test(str);
63
- }
64
- return colorStop;
65
- }
66
- return true;
67
- }
68
- /**
69
- * @param {string} color
70
- * @param {string} [stop]
71
- * @return {boolean}
72
- */
73
- function isColorStop(color, stop) {
74
- return colord(color).isValid() && isStop(stop);
75
- }
76
-
77
- export default isColorStop;
@@ -1,8 +0,0 @@
1
- /**
2
- * @param {string} color
3
- * @param {string} [stop]
4
- * @return {boolean}
5
- */
6
- declare function isColorStop(color: string, stop?: string): boolean;
7
- export default isColorStop;
8
- //# sourceMappingURL=isColorStop.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"isColorStop.d.ts","sourceRoot":"","sources":["../src/isColorStop.js"],"names":[],"mappings":"AAmEA;;;;GAIG;AACH,iBAAS,WAAW,CAAC,KAAK,EAJf,MAIe,EAAE,IAAI,AAH7B,CACA,EADQ,MAGqB,GAFpB,OAAO,CAIlB;eAEc,WAAW"}