html-minifier-next 4.14.1 → 4.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/lib/svg.js ADDED
@@ -0,0 +1,272 @@
1
+ /**
2
+ * Lightweight SVG optimizations:
3
+ *
4
+ * - Numeric precision reduction for coordinates and path data
5
+ * - Whitespace removal in attribute values (numeric sequences)
6
+ * - Default attribute removal (safe, well-documented defaults)
7
+ * - Color minification (hex shortening, rgb() to hex)
8
+ */
9
+
10
+ /**
11
+ * Default SVG attribute values that can be safely removed
12
+ * Only includes well-documented, widely-supported defaults
13
+ */
14
+ const SVG_DEFAULT_ATTRS = {
15
+ // Fill and stroke defaults
16
+ fill: value => value === 'black' || value === '#000' || value === '#000000',
17
+ 'fill-opacity': value => value === '1',
18
+ 'fill-rule': value => value === 'nonzero',
19
+ stroke: value => value === 'none',
20
+ 'stroke-dasharray': value => value === 'none',
21
+ 'stroke-dashoffset': value => value === '0',
22
+ 'stroke-linecap': value => value === 'butt',
23
+ 'stroke-linejoin': value => value === 'miter',
24
+ 'stroke-miterlimit': value => value === '4',
25
+ 'stroke-opacity': value => value === '1',
26
+ 'stroke-width': value => value === '1',
27
+
28
+ // Text and font defaults
29
+ 'font-family': value => value === 'inherit',
30
+ 'font-size': value => value === 'medium',
31
+ 'font-style': value => value === 'normal',
32
+ 'font-variant': value => value === 'normal',
33
+ 'font-weight': value => value === 'normal',
34
+ 'letter-spacing': value => value === 'normal',
35
+ 'text-decoration': value => value === 'none',
36
+ 'text-anchor': value => value === 'start',
37
+
38
+ // Other common defaults
39
+ opacity: value => value === '1',
40
+ visibility: value => value === 'visible',
41
+ display: value => value === 'inline',
42
+ overflow: value => value === 'visible'
43
+ };
44
+
45
+ /**
46
+ * Minify numeric value by removing trailing zeros and unnecessary decimals
47
+ * @param {string} num - Numeric string to minify
48
+ * @param {number} precision - Maximum decimal places to keep
49
+ * @returns {string} Minified numeric string
50
+ */
51
+ function minifyNumber(num, precision = 3) {
52
+ const parsed = parseFloat(num);
53
+
54
+ // Handle special cases
55
+ if (isNaN(parsed)) return num;
56
+ if (parsed === 0) return '0';
57
+ if (!isFinite(parsed)) return num;
58
+
59
+ // Convert to fixed precision, then remove trailing zeros
60
+ const fixed = parsed.toFixed(precision);
61
+ const trimmed = fixed.replace(/\.?0+$/, '');
62
+
63
+ return trimmed || '0';
64
+ }
65
+
66
+ /**
67
+ * Minify SVG path data by reducing numeric precision
68
+ * @param {string} pathData - SVG path data string
69
+ * @param {number} precision - Decimal precision for coordinates
70
+ * @returns {string} Minified path data
71
+ */
72
+ function minifyPathData(pathData, precision = 3) {
73
+ if (!pathData || typeof pathData !== 'string') return pathData;
74
+
75
+ // Match numbers (including scientific notation and negative values)
76
+ // Regex: optional minus, digits, optional decimal point and more digits, optional exponent
77
+ return pathData.replace(/-?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?/g, (match) => {
78
+ return minifyNumber(match, precision);
79
+ });
80
+ }
81
+
82
+ /**
83
+ * Minify whitespace in numeric attribute values
84
+ * Examples:
85
+ * "10 , 20" → "10,20"
86
+ * "translate( 10 20 )" → "translate(10 20)"
87
+ * "100, 10 40, 198" → "100,10 40,198"
88
+ *
89
+ * @param {string} value - Attribute value to minify
90
+ * @returns {string} Minified value
91
+ */
92
+ function minifyAttributeWhitespace(value) {
93
+ if (!value || typeof value !== 'string') return value;
94
+
95
+ return value
96
+ // Remove spaces around commas
97
+ .replace(/\s*,\s*/g, ',')
98
+ // Remove spaces around parentheses
99
+ .replace(/\(\s+/g, '(')
100
+ .replace(/\s+\)/g, ')')
101
+ // Collapse multiple spaces to single space
102
+ .replace(/\s+/g, ' ')
103
+ // Trim leading/trailing whitespace
104
+ .trim();
105
+ }
106
+
107
+ /**
108
+ * Minify color values (hex shortening, rgb to hex conversion)
109
+ * @param {string} color - Color value to minify
110
+ * @returns {string} Minified color value
111
+ */
112
+ function minifyColor(color) {
113
+ if (!color || typeof color !== 'string') return color;
114
+
115
+ const trimmed = color.trim().toLowerCase();
116
+
117
+ // Shorten 6-digit hex to 3-digit when possible
118
+ // #aabbcc → #abc, #000000 → #000
119
+ const hexMatch = trimmed.match(/^#([0-9a-f]{6})$/);
120
+ if (hexMatch) {
121
+ const hex = hexMatch[1];
122
+ if (hex[0] === hex[1] && hex[2] === hex[3] && hex[4] === hex[5]) {
123
+ return '#' + hex[0] + hex[2] + hex[4];
124
+ }
125
+ }
126
+
127
+ // Convert rgb(255,255,255) to hex
128
+ const rgbMatch = trimmed.match(/^rgb\s*\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)$/);
129
+ if (rgbMatch) {
130
+ const r = parseInt(rgbMatch[1], 10);
131
+ const g = parseInt(rgbMatch[2], 10);
132
+ const b = parseInt(rgbMatch[3], 10);
133
+
134
+ if (r >= 0 && r <= 255 && g >= 0 && g <= 255 && b >= 0 && b <= 255) {
135
+ const toHex = (n) => {
136
+ const h = n.toString(16);
137
+ return h.length === 1 ? '0' + h : h;
138
+ };
139
+ const hexColor = '#' + toHex(r) + toHex(g) + toHex(b);
140
+
141
+ // Try to shorten if possible
142
+ if (hexColor[1] === hexColor[2] && hexColor[3] === hexColor[4] && hexColor[5] === hexColor[6]) {
143
+ return '#' + hexColor[1] + hexColor[3] + hexColor[5];
144
+ }
145
+ return hexColor;
146
+ }
147
+ }
148
+
149
+ return color;
150
+ }
151
+
152
+ // Attributes that contain numeric sequences or path data
153
+ const NUMERIC_ATTRS = new Set([
154
+ 'd', // Path data
155
+ 'points', // Polygon/polyline points
156
+ 'viewBox', // viewBox coordinates
157
+ 'transform', // Transform functions
158
+ 'x', 'y', 'x1', 'y1', 'x2', 'y2', // Coordinates
159
+ 'cx', 'cy', 'r', 'rx', 'ry', // Circle/ellipse
160
+ 'width', 'height', // Dimensions
161
+ 'dx', 'dy', // Text offsets
162
+ 'offset', // Gradient offset
163
+ 'startOffset', // textPath
164
+ 'pathLength', // Path length
165
+ 'stdDeviation', // Filter params
166
+ 'baseFrequency', // Turbulence
167
+ 'k1', 'k2', 'k3', 'k4' // Composite filter
168
+ ]);
169
+
170
+ // Attributes that contain color values
171
+ const COLOR_ATTRS = new Set([
172
+ 'fill',
173
+ 'stroke',
174
+ 'stop-color',
175
+ 'flood-color',
176
+ 'lighting-color'
177
+ ]);
178
+
179
+ /**
180
+ * Check if an attribute should be removed based on default value
181
+ * @param {string} name - Attribute name
182
+ * @param {string} value - Attribute value
183
+ * @returns {boolean} True if attribute can be removed
184
+ */
185
+ function isDefaultAttribute(name, value) {
186
+ const checker = SVG_DEFAULT_ATTRS[name];
187
+ if (!checker) return false;
188
+
189
+ // Special case: Don’t remove `fill="black"` if stroke exists without fill
190
+ // This would change the rendering (stroke-only shapes would gain black fill)
191
+ if (name === 'fill' && checker(value)) {
192
+ // This check would require looking at other attributes on the same element
193
+ // For safety, we’ll keep this conservative and not remove `fill="black"`
194
+ // in the initial implementation. Can be refined later.
195
+ return false;
196
+ }
197
+
198
+ return checker(value);
199
+ }
200
+
201
+ /**
202
+ * Minify SVG attribute value based on attribute name
203
+ * @param {string} name - Attribute name
204
+ * @param {string} value - Attribute value
205
+ * @param {Object} options - Minification options
206
+ * @returns {string} Minified attribute value
207
+ */
208
+ export function minifySVGAttributeValue(name, value, options = {}) {
209
+ if (!value || typeof value !== 'string') return value;
210
+
211
+ const { precision = 3, minifyColors = true } = options;
212
+
213
+ // Path data gets special treatment
214
+ if (name === 'd') {
215
+ return minifyPathData(value, precision);
216
+ }
217
+
218
+ // Numeric attributes get precision reduction and whitespace minification
219
+ if (NUMERIC_ATTRS.has(name)) {
220
+ const minified = value.replace(/-?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?/g, (match) => {
221
+ return minifyNumber(match, precision);
222
+ });
223
+ return minifyAttributeWhitespace(minified);
224
+ }
225
+
226
+ // Color attributes get color minification
227
+ if (minifyColors && COLOR_ATTRS.has(name)) {
228
+ return minifyColor(value);
229
+ }
230
+
231
+ return value;
232
+ }
233
+
234
+ /**
235
+ * Check if an SVG attribute can be removed
236
+ * @param {string} name - Attribute name
237
+ * @param {string} value - Attribute value
238
+ * @param {Object} options - Minification options
239
+ * @returns {boolean} True if attribute should be removed
240
+ */
241
+ export function shouldRemoveSVGAttribute(name, value, options = {}) {
242
+ const { removeDefaults = true } = options;
243
+
244
+ if (!removeDefaults) return false;
245
+
246
+ return isDefaultAttribute(name, value);
247
+ }
248
+
249
+ /**
250
+ * Get default SVG minification options
251
+ * @param {Object} userOptions - User-provided options
252
+ * @returns {Object} Complete options object with defaults
253
+ */
254
+ export function getSVGMinifierOptions(userOptions) {
255
+ if (typeof userOptions === 'boolean') {
256
+ return userOptions ? {
257
+ precision: 3,
258
+ removeDefaults: true,
259
+ minifyColors: true
260
+ } : null;
261
+ }
262
+
263
+ if (typeof userOptions === 'object' && userOptions !== null) {
264
+ return {
265
+ precision: userOptions.precision ?? 3,
266
+ removeDefaults: userOptions.removeDefaults ?? true,
267
+ minifyColors: userOptions.minifyColors ?? true
268
+ };
269
+ }
270
+
271
+ return null;
272
+ }
@@ -8,7 +8,8 @@ import {
8
8
  RE_NBSP_LEAD_GROUP,
9
9
  RE_NBSP_TRAILING_GROUP,
10
10
  RE_NBSP_TRAILING_STRIP,
11
- inlineElementsToKeepWhitespace
11
+ inlineElementsToKeepWhitespace,
12
+ inlineElementsToKeepWhitespaceWithin
12
13
  } from './constants.js';
13
14
 
14
15
  // Trim whitespace
@@ -110,10 +111,24 @@ function collapseWhitespaceSmart(str, prevTag, nextTag, options, inlineElements,
110
111
  if (trimLeft && !options.collapseInlineTagWhitespace) {
111
112
  trimLeft = prevTag.charAt(0) === '/' ? !inlineElements.has(prevTag.slice(1)) : !inlineTextSet.has(prevTag);
112
113
  }
114
+ // When `collapseInlineTagWhitespace` is enabled, still preserve whitespace around inline text elements
115
+ if (trimLeft && options.collapseInlineTagWhitespace) {
116
+ const tagName = prevTag.charAt(0) === '/' ? prevTag.slice(1) : prevTag;
117
+ if (inlineElementsToKeepWhitespaceWithin.has(tagName)) {
118
+ trimLeft = false;
119
+ }
120
+ }
113
121
  let trimRight = nextTag && !inlineElementsToKeepWhitespace.has(nextTag);
114
122
  if (trimRight && !options.collapseInlineTagWhitespace) {
115
123
  trimRight = nextTag.charAt(0) === '/' ? !inlineTextSet.has(nextTag.slice(1)) : !inlineElements.has(nextTag);
116
124
  }
125
+ // When `collapseInlineTagWhitespace` is enabled, still preserve whitespace around inline text elements
126
+ if (trimRight && options.collapseInlineTagWhitespace) {
127
+ const tagName = nextTag.charAt(0) === '/' ? nextTag.slice(1) : nextTag;
128
+ if (inlineElementsToKeepWhitespaceWithin.has(tagName)) {
129
+ trimRight = false;
130
+ }
131
+ }
117
132
  return collapseWhitespace(str, options, trimLeft, trimRight, prevTag && nextTag);
118
133
  }
119
134
 
package/src/presets.js CHANGED
@@ -30,6 +30,7 @@ export const presets = {
30
30
  decodeEntities: true,
31
31
  minifyCSS: true,
32
32
  minifyJS: true,
33
+ minifySVG: true,
33
34
  minifyURLs: true,
34
35
  noNewlinesBeforeTagClose: true,
35
36
  processConditionalComments: true,