html-minifier-next 5.0.6 → 5.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3647,7 +3647,6 @@ const RE_ESCAPE_LT = /</g;
3647
3647
  const RE_ATTR_WS_CHECK = /[ \n\r\t\f]/;
3648
3648
  const RE_ATTR_WS_COLLAPSE = /[ \n\r\t\f]+/g;
3649
3649
  const RE_ATTR_WS_TRIM = /^[ \n\r\t\f]+|[ \n\r\t\f]+$/g;
3650
- const RE_NUMERIC_VALUE = /-?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?/g;
3651
3650
 
3652
3651
  // Inline element sets for whitespace handling
3653
3652
 
@@ -4251,427 +4250,6 @@ async function processScript(text, options, currentAttrs, minifyHTML) {
4251
4250
  return text;
4252
4251
  }
4253
4252
 
4254
- /**
4255
- * Lightweight SVG optimizations:
4256
- * - Numeric precision reduction for coordinates and path data
4257
- * - Whitespace removal in attribute values (numeric sequences)
4258
- * - Default attribute removal (safe, well-documented defaults)
4259
- * - Color minification (hex shortening, rgb() to hex, named colors)
4260
- * - Identity transform removal
4261
- * - Path data space optimization
4262
- */
4263
-
4264
-
4265
- // Cache for minified numbers
4266
- const numberCache = new LRU(100);
4267
-
4268
- /**
4269
- * Named colors that are shorter than their hex equivalents
4270
- * Only includes cases where using the name saves bytes
4271
- */
4272
- const NAMED_COLORS = {
4273
- '#f00': 'red', // #f00 (4) → red (3), saves 1
4274
- '#c0c0c0': 'silver', // #c0c0c0 (7) → silver (6), saves 1
4275
- '#808080': 'gray', // #808080 (7) → gray (4), saves 3
4276
- '#800000': 'maroon', // #800000 (7) → maroon (6), saves 1
4277
- '#808000': 'olive', // #808000 (7) → olive (5), saves 2
4278
- '#008000': 'green', // #008000 (7) → green (5), saves 2
4279
- '#800080': 'purple', // #800080 (7) → purple (6), saves 1
4280
- '#008080': 'teal', // #008080 (7) → teal (4), saves 3
4281
- '#000080': 'navy', // #000080 (7) → navy (4), saves 3
4282
- '#ffa500': 'orange' // #ffa500 (7) → orange (6), saves 1
4283
- };
4284
-
4285
- /**
4286
- * Default SVG attribute values that can be safely removed
4287
- * Only includes well-documented, widely-supported defaults
4288
- */
4289
- const SVG_DEFAULT_ATTRS = {
4290
- // Fill and stroke defaults
4291
- fill: value => value === 'black' || value === '#000' || value === '#000000',
4292
- 'fill-opacity': value => value === '1',
4293
- 'fill-rule': value => value === 'nonzero',
4294
- stroke: value => value === 'none',
4295
- 'stroke-dasharray': value => value === 'none',
4296
- 'stroke-dashoffset': value => value === '0',
4297
- 'stroke-linecap': value => value === 'butt',
4298
- 'stroke-linejoin': value => value === 'miter',
4299
- 'stroke-miterlimit': value => value === '4',
4300
- 'stroke-opacity': value => value === '1',
4301
- 'stroke-width': value => value === '1',
4302
-
4303
- // Text and font defaults
4304
- 'font-family': value => value === 'inherit',
4305
- 'font-size': value => value === 'medium',
4306
- 'font-style': value => value === 'normal',
4307
- 'font-variant': value => value === 'normal',
4308
- 'font-weight': value => value === 'normal',
4309
- 'letter-spacing': value => value === 'normal',
4310
- 'text-decoration': value => value === 'none',
4311
- 'text-anchor': value => value === 'start',
4312
-
4313
- // Other common defaults
4314
- opacity: value => value === '1',
4315
- visibility: value => value === 'visible',
4316
- display: value => value === 'inline',
4317
- // Note: Overflow handled especially in `isDefaultAttribute` (not safe for root `<svg>`)
4318
-
4319
- // Clipping and masking defaults
4320
- 'clip-rule': value => value === 'nonzero',
4321
- 'clip-path': value => value === 'none',
4322
- mask: value => value === 'none',
4323
-
4324
- // Marker defaults
4325
- 'marker-start': value => value === 'none',
4326
- 'marker-mid': value => value === 'none',
4327
- 'marker-end': value => value === 'none',
4328
-
4329
- // Filter and color defaults
4330
- filter: value => value === 'none',
4331
- 'color-interpolation': value => value === 'sRGB',
4332
- 'color-interpolation-filters': value => value === 'linearRGB'
4333
- };
4334
-
4335
- /**
4336
- * Minify numeric value by removing trailing zeros and unnecessary decimals
4337
- * @param {string} num - Numeric string to minify
4338
- * @param {number} precision - Maximum decimal places to keep
4339
- * @returns {string} Minified numeric string
4340
- */
4341
- function minifyNumber(num, precision = 3) {
4342
- // Fast path for common values (avoids parsing and caching)
4343
- if (num === '0' || num === '1') return num;
4344
- // Common decimal variants that tools export
4345
- if (num === '0.0' || num === '0.00' || num === '0.000') return '0';
4346
- if (num === '1.0' || num === '1.00' || num === '1.000') return '1';
4347
-
4348
- // Check cache
4349
- // (Note: Uses input string as key, so “0.0000” and “0.00000” create separate entries.
4350
- // This is intentional to avoid parsing overhead.
4351
- // Real-world SVG files from export tools typically use consistent formats.)
4352
- const cacheKey = `${num}:${precision}`;
4353
- const cached = numberCache.get(cacheKey);
4354
- if (cached !== undefined) return cached;
4355
-
4356
- const parsed = parseFloat(num);
4357
-
4358
- // Handle special cases
4359
- if (isNaN(parsed)) return num;
4360
- if (parsed === 0) return '0';
4361
- if (!isFinite(parsed)) return num;
4362
-
4363
- // Convert to fixed precision, then remove trailing zeros
4364
- const fixed = parsed.toFixed(precision);
4365
- const trimmed = fixed.replace(/\.?0+$/, '');
4366
-
4367
- // Remove leading zero before decimal point (e.g., `0.5` → `.5`, `-0.3` → `-.3`)
4368
- const result = (trimmed || '0').replace(/^(-?)0\./, '$1.');
4369
- numberCache.set(cacheKey, result);
4370
- return result;
4371
- }
4372
-
4373
- /**
4374
- * Minify SVG path data by reducing numeric precision and removing unnecessary spaces
4375
- * @param {string} pathData - SVG path data string
4376
- * @param {number} precision - Decimal precision for coordinates
4377
- * @returns {string} Minified path data
4378
- */
4379
- function minifyPathData(pathData, precision = 3) {
4380
- if (!pathData || typeof pathData !== 'string') return pathData;
4381
-
4382
- // First, minify all numbers
4383
- let result = pathData.replace(RE_NUMERIC_VALUE, (match) => {
4384
- return minifyNumber(match, precision);
4385
- });
4386
-
4387
- // Remove unnecessary spaces around path commands
4388
- // Safe to remove space after a command letter when it’s followed by a number
4389
- // (which may be negative or start with a decimal point)
4390
- // `M 10 20` → `M10 20`, `L -5 -3` → `L-5-3`, `M .5 .3` → `M.5.3`
4391
- result = result.replace(/([MLHVCSQTAZmlhvcsqtaz])\s+(?=-?\.?\d)/g, '$1');
4392
-
4393
- // Safe to remove space before command letter when preceded by a number
4394
- // `0 L` → `0L`, `20 M` → `20M`, `.5 L` → `.5L`
4395
- result = result.replace(/([\d.])\s+([MLHVCSQTAZmlhvcsqtaz])/g, '$1$2');
4396
-
4397
- // Safe to remove space before negative number when preceded by a number
4398
- // `10 -20` → `10-20`, `.5 -.3` → `.5-.3` (minus sign is always a separator)
4399
- result = result.replace(/([\d.])\s+(-)/g, '$1$2');
4400
-
4401
- // Safe to remove space between two decimal numbers (decimal point acts as separator)
4402
- // `.5 .3` → `.5.3` (only when previous char is `.`, indicating a complete decimal)
4403
- // Note: `0 .3` must not become `0.3` (that would change two numbers into one)
4404
- result = result.replace(/(\.\d*)\s+(\.)/g, '$1$2');
4405
-
4406
- return result;
4407
- }
4408
-
4409
- /**
4410
- * Minify whitespace in numeric attribute values
4411
- * Examples:
4412
- * - “10 , 20" → "10,20"
4413
- * - "translate( 10 20 )" → "translate(10 20)"
4414
- * - "100, 10 40, 198" → "100,10 40,198"
4415
- *
4416
- * @param {string} value - Attribute value to minify
4417
- * @returns {string} Minified value
4418
- */
4419
- function minifyAttributeWhitespace(value) {
4420
- if (!value || typeof value !== 'string') return value;
4421
-
4422
- return value
4423
- // Remove spaces around commas
4424
- .replace(/\s*,\s*/g, ',')
4425
- // Remove spaces around parentheses
4426
- .replace(/\(\s+/g, '(')
4427
- .replace(/\s+\)/g, ')')
4428
- // Collapse multiple spaces to single space
4429
- .replace(/\s+/g, ' ')
4430
- // Trim leading/trailing whitespace
4431
- .trim();
4432
- }
4433
-
4434
- /**
4435
- * Minify color values (hex shortening, rgb to hex conversion, named colors)
4436
- * Only processes simple color values; preserves case-sensitive references like `url(#id)`
4437
- * @param {string} color - Color value to minify
4438
- * @returns {string} Minified color value
4439
- */
4440
- function minifyColor(color) {
4441
- if (!color || typeof color !== 'string') return color;
4442
-
4443
- const trimmed = color.trim();
4444
-
4445
- // Don’t process values that aren’t simple colors (preserve case-sensitive references)
4446
- // `url(#id)`, `var(--name)`, `inherit`, `currentColor`, etc.
4447
- if (trimmed.includes('url(') || trimmed.includes('var(') || trimmed === 'inherit' || trimmed === 'currentColor') {
4448
- return trimmed;
4449
- }
4450
-
4451
- // Now safe to lowercase for color matching
4452
- const lower = trimmed.toLowerCase();
4453
-
4454
- // Shorten 6-digit hex to 3-digit when possible
4455
- // `#aabbcc` → `#abc`, `#000000` → `#000`
4456
- const hexMatch = lower.match(/^#([0-9a-f]{6})$/);
4457
- if (hexMatch) {
4458
- const hex = hexMatch[1];
4459
- if (hex[0] === hex[1] && hex[2] === hex[3] && hex[4] === hex[5]) {
4460
- const shortened = '#' + hex[0] + hex[2] + hex[4];
4461
- // Try to use named color if shorter
4462
- return NAMED_COLORS[shortened] || shortened;
4463
- }
4464
- // Can’t shorten, but check for named color
4465
- return NAMED_COLORS[lower] || lower;
4466
- }
4467
-
4468
- // Match 3-digit hex colors
4469
- const hex3Match = lower.match(/^#[0-9a-f]{3}$/);
4470
- if (hex3Match) {
4471
- // Check if there’s a shorter named color
4472
- return NAMED_COLORS[lower] || lower;
4473
- }
4474
-
4475
- // Convert rgb() to hex
4476
- const rgbMatch = lower.match(/^rgb\s*\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)$/);
4477
- if (rgbMatch) {
4478
- const r = parseInt(rgbMatch[1], 10);
4479
- const g = parseInt(rgbMatch[2], 10);
4480
- const b = parseInt(rgbMatch[3], 10);
4481
-
4482
- if (r >= 0 && r <= 255 && g >= 0 && g <= 255 && b >= 0 && b <= 255) {
4483
- const toHex = (n) => {
4484
- const h = n.toString(16);
4485
- return h.length === 1 ? '0' + h : h;
4486
- };
4487
- const hexColor = '#' + toHex(r) + toHex(g) + toHex(b);
4488
-
4489
- // Try to shorten if possible
4490
- if (hexColor[1] === hexColor[2] && hexColor[3] === hexColor[4] && hexColor[5] === hexColor[6]) {
4491
- const shortened = '#' + hexColor[1] + hexColor[3] + hexColor[5];
4492
- return NAMED_COLORS[shortened] || shortened;
4493
- }
4494
- return NAMED_COLORS[hexColor] || hexColor;
4495
- }
4496
- }
4497
-
4498
- // Not a recognized color format, return as-is (preserves case)
4499
- return trimmed;
4500
- }
4501
-
4502
- // Attributes that contain numeric sequences or path data
4503
- const NUMERIC_ATTRS = new Set([
4504
- 'd', // Path data
4505
- 'points', // Polygon/polyline points
4506
- 'viewBox', // `viewBox` coordinates
4507
- 'transform', // Transform functions
4508
- 'x', 'y', 'x1', 'y1', 'x2', 'y2', // Coordinates
4509
- 'cx', 'cy', 'r', 'rx', 'ry', // Circle/ellipse
4510
- 'width', 'height', // Dimensions
4511
- 'dx', 'dy', // Text offsets
4512
- 'offset', // Gradient offset
4513
- 'startOffset', // `textPath`
4514
- 'pathLength', // Path length
4515
- 'stdDeviation', // Filter params
4516
- 'baseFrequency', // Turbulence
4517
- 'k1', 'k2', 'k3', 'k4' // Composite filter
4518
- ]);
4519
-
4520
- // Attributes that contain color values
4521
- const COLOR_ATTRS = new Set([
4522
- 'fill',
4523
- 'stroke',
4524
- 'stop-color',
4525
- 'flood-color',
4526
- 'lighting-color'
4527
- ]);
4528
-
4529
- // Pre-compiled regexes for identity transform detection (compiled once at module load)
4530
- // Separator pattern: Accepts comma with optional spaces or one or more spaces
4531
- const SEP = '(?:\\s*,\\s*|\\s+)';
4532
-
4533
- // `translate(0)`, `translate(0,0)`, `translate(0 0)` (matches 0, 0.0, 0.00, etc.)
4534
- const IDENTITY_TRANSLATE_RE = new RegExp(`^translate\\s*\\(\\s*0(?:\\.0+)?\\s*(?:${SEP}0(?:\\.0+)?\\s*)?\\)$`, 'i');
4535
-
4536
- // `scale(1)`, `scale(1,1)`, `scale(1 1)` (matches 1, 1.0, 1.00, etc.)
4537
- const IDENTITY_SCALE_RE = new RegExp(`^scale\\s*\\(\\s*1(?:\\.0+)?\\s*(?:${SEP}1(?:\\.0+)?\\s*)?\\)$`, 'i');
4538
-
4539
- // `rotate(0)`, `rotate(0 cx cy)`, `rotate(0, cx, cy)` (matches 0, 0.0, 0.00, etc.)
4540
- // Note: `cx` and `cy` must be valid numbers if present
4541
- const IDENTITY_ROTATE_RE = new RegExp(`^rotate\\s*\\(\\s*0(?:\\.0+)?\\s*(?:${SEP}-?\\d+(?:\\.\\d+)?${SEP}-?\\d+(?:\\.\\d+)?)?\\s*\\)$`, 'i');
4542
-
4543
- // `skewX(0)`, `skewY(0)` (matches 0, 0.0, 0.00, etc.)
4544
- const IDENTITY_SKEW_RE = /^skew[XY]\s*\(\s*0(?:\.0+)?\s*\)$/i;
4545
-
4546
- // `matrix(1,0,0,1,0,0)`, `matrix(1 0 0 1 0 0)`—identity matrix (matches 1.0/0.0 variants)
4547
- const IDENTITY_MATRIX_RE = new RegExp(`^matrix\\s*\\(\\s*1(?:\\.0+)?\\s*${SEP}0(?:\\.0+)?\\s*${SEP}0(?:\\.0+)?\\s*${SEP}1(?:\\.0+)?\\s*${SEP}0(?:\\.0+)?\\s*${SEP}0(?:\\.0+)?\\s*\\)$`, 'i');
4548
-
4549
- /**
4550
- * Check if a transform attribute has no effect (identity transform)
4551
- * @param {string} transform - Transform attribute value
4552
- * @returns {boolean} True if transform is an identity (has no effect)
4553
- */
4554
- function isIdentityTransform(transform) {
4555
- if (!transform || typeof transform !== 'string') return false;
4556
-
4557
- const trimmed = transform.trim();
4558
-
4559
- // Check for common identity transforms using pre-compiled regexes
4560
- return IDENTITY_TRANSLATE_RE.test(trimmed) ||
4561
- IDENTITY_SCALE_RE.test(trimmed) ||
4562
- IDENTITY_ROTATE_RE.test(trimmed) ||
4563
- IDENTITY_SKEW_RE.test(trimmed) ||
4564
- IDENTITY_MATRIX_RE.test(trimmed);
4565
- }
4566
-
4567
- /**
4568
- * Check if an attribute should be removed based on default value
4569
- * @param {string} tag - Element tag name (e.g., `svg`, `rect`, `path`)
4570
- * @param {string} name - Attribute name
4571
- * @param {string} value - Attribute value
4572
- * @returns {boolean} True if attribute can be removed
4573
- */
4574
- function isDefaultAttribute(tag, name, value) {
4575
- // Special case: `overflow="visible"` is unsafe for root `<svg>` element
4576
- // Root SVG may need explicit `overflow="visible"` to show clipped content
4577
- if (name === 'overflow' && value === 'visible') {
4578
- return tag !== 'svg'; // Only remove for non-root SVG elements
4579
- }
4580
-
4581
- const checker = SVG_DEFAULT_ATTRS[name];
4582
- if (!checker) return false;
4583
-
4584
- // Special case: Don’t remove `fill="black"` if stroke exists without fill
4585
- // This would change the rendering (stroke-only shapes would gain black fill)
4586
- if (name === 'fill' && checker(value)) {
4587
- // This check would require looking at other attributes on the same element
4588
- // For safety, we’ll keep this conservative and not remove `fill="black"`
4589
- // in the initial implementation. Can be refined later.
4590
- return false;
4591
- }
4592
-
4593
- return checker(value);
4594
- }
4595
-
4596
- /**
4597
- * Minify SVG attribute value based on attribute name
4598
- * @param {string} name - Attribute name
4599
- * @param {string} value - Attribute value
4600
- * @param {Object} options - Minification options
4601
- * @returns {string} Minified attribute value
4602
- */
4603
- function minifySVGAttributeValue(name, value, options = {}) {
4604
- if (!value || typeof value !== 'string') return value;
4605
-
4606
- const { precision = 3, minifyColors = true } = options;
4607
-
4608
- // Path data gets special treatment
4609
- if (name === 'd') {
4610
- return minifyPathData(value, precision);
4611
- }
4612
-
4613
- // Numeric attributes get precision reduction and whitespace minification
4614
- if (NUMERIC_ATTRS.has(name)) {
4615
- const minified = value.replace(RE_NUMERIC_VALUE, (match) => {
4616
- return minifyNumber(match, precision);
4617
- });
4618
- return minifyAttributeWhitespace(minified);
4619
- }
4620
-
4621
- // Color attributes get color minification
4622
- if (minifyColors && COLOR_ATTRS.has(name)) {
4623
- return minifyColor(value);
4624
- }
4625
-
4626
- return value;
4627
- }
4628
-
4629
- /**
4630
- * Check if an SVG attribute can be removed
4631
- * @param {string} tag - Element tag name (e.g., `svg`, `rect`, `path`)
4632
- * @param {string} name - Attribute name
4633
- * @param {string} value - Attribute value
4634
- * @param {Object} options - Minification options
4635
- * @returns {boolean} True if attribute should be removed
4636
- */
4637
- function shouldRemoveSVGAttribute(tag, name, value, options = {}) {
4638
- const { removeDefaults = true } = options;
4639
-
4640
- if (!removeDefaults) return false;
4641
-
4642
- // Check for identity transforms
4643
- if (name === 'transform' && isIdentityTransform(value)) {
4644
- return true;
4645
- }
4646
-
4647
- return isDefaultAttribute(tag, name, value);
4648
- }
4649
-
4650
- /**
4651
- * Get default SVG minification options
4652
- * @param {Object} userOptions - User-provided options
4653
- * @returns {Object} Complete options object with defaults
4654
- */
4655
- function getSVGMinifierOptions(userOptions) {
4656
- if (typeof userOptions === 'boolean') {
4657
- return userOptions ? {
4658
- precision: 3,
4659
- removeDefaults: true,
4660
- minifyColors: true
4661
- } : null;
4662
- }
4663
-
4664
- if (typeof userOptions === 'object' && userOptions !== null) {
4665
- return {
4666
- precision: userOptions.precision ?? 3,
4667
- removeDefaults: userOptions.removeDefaults ?? true,
4668
- minifyColors: userOptions.minifyColors ?? true
4669
- };
4670
- }
4671
-
4672
- return null;
4673
- }
4674
-
4675
4253
  // Single source of truth for minifier option names, descriptions, types, and shared defaults
4676
4254
 
4677
4255
 
@@ -4713,11 +4291,13 @@ function shouldMinifyInnerHTML(options) {
4713
4291
  * @param {Function} deps.getLightningCSS - Function to lazily load Lightning CSS
4714
4292
  * @param {Function} deps.getTerser - Function to lazily load Terser
4715
4293
  * @param {Function} deps.getSwc - Function to lazily load @swc/core
4294
+ * @param {Function} deps.getSvgo - Function to lazily load SVGO
4716
4295
  * @param {LRU} deps.cssMinifyCache - CSS minification cache
4717
4296
  * @param {LRU} deps.jsMinifyCache - JS minification cache
4297
+ * @param {LRU} deps.svgMinifyCache - SVG minification cache
4718
4298
  * @returns {MinifierOptions} Normalized options with defaults applied
4719
4299
  */
4720
- const processOptions = (inputOptions, { getLightningCSS, getTerser, getSwc, cssMinifyCache, jsMinifyCache } = {}) => {
4300
+ const processOptions = (inputOptions, { getLightningCSS, getTerser, getSwc, getSvgo, cssMinifyCache, jsMinifyCache, svgMinifyCache } = {}) => {
4721
4301
  const options = {
4722
4302
  name: lowercase,
4723
4303
  canCollapseWhitespace,
@@ -5018,11 +4598,54 @@ const processOptions = (inputOptions, { getLightningCSS, getTerser, getSwc, cssM
5018
4598
  return text;
5019
4599
  }
5020
4600
  };
5021
- } else if (key === 'minifySVG') {
5022
- // Process SVG minification options
5023
- // Unlike `minifyCSS`/`minifyJS`, this is a simple options object, not a function
5024
- // The actual minification is applied inline during attribute processing
5025
- options.minifySVG = getSVGMinifierOptions(option);
4601
+ } else if (key === 'minifySVG' && typeof option !== 'function') {
4602
+ if (!option) {
4603
+ return;
4604
+ }
4605
+
4606
+ const svgoOptions = typeof option === 'object' ? option : {};
4607
+
4608
+ // Pre-compute option signature for cache keys
4609
+ const svgSig = stableStringify({
4610
+ ...svgoOptions,
4611
+ cont: !!options.continueOnMinifyError
4612
+ });
4613
+
4614
+ options.minifySVG = async function (svgContent) {
4615
+ if (!svgContent || !svgContent.trim()) {
4616
+ return svgContent;
4617
+ }
4618
+
4619
+ // Cache key
4620
+ const svgKey = svgContent.length > 2048
4621
+ ? (svgContent.length + '|' + svgContent.slice(0, 50) + svgContent.slice(-50) + '|' + svgSig)
4622
+ : (svgContent + '|' + svgSig);
4623
+
4624
+ try {
4625
+ const cached = svgMinifyCache.get(svgKey);
4626
+ if (cached) {
4627
+ return await cached;
4628
+ }
4629
+
4630
+ const inFlight = (async () => {
4631
+ const optimize = await getSvgo();
4632
+ const result = optimize(svgContent, svgoOptions);
4633
+ return result.data;
4634
+ })();
4635
+
4636
+ svgMinifyCache.set(svgKey, inFlight);
4637
+ const resolved = await inFlight;
4638
+ svgMinifyCache.set(svgKey, resolved);
4639
+ return resolved;
4640
+ } catch (err) {
4641
+ svgMinifyCache.delete(svgKey);
4642
+ if (!options.continueOnMinifyError) {
4643
+ throw err;
4644
+ }
4645
+ options.log && options.log(err);
4646
+ return svgContent;
4647
+ }
4648
+ };
5026
4649
  } else if (key === 'customAttrCollapse') {
5027
4650
  // Single regex pattern
5028
4651
  options[key] = parseRegExp(option);
@@ -5426,17 +5049,6 @@ async function cleanAttributeValue(tag, attrName, attrValue, options, attrs, min
5426
5049
  return attrValue;
5427
5050
  }
5428
5051
  return minifyHTMLSelf(attrValue, options, true);
5429
- } else if (options.insideSVG && options.minifySVG) {
5430
- // Apply SVG-specific attribute minification when inside SVG elements
5431
- try {
5432
- return minifySVGAttributeValue(attrName, attrValue, options.minifySVG);
5433
- } catch (err) {
5434
- if (!options.continueOnMinifyError) {
5435
- throw err;
5436
- }
5437
- options.log && options.log(err);
5438
- return attrValue;
5439
- }
5440
5052
  }
5441
5053
  return attrValue;
5442
5054
  }
@@ -5477,9 +5089,7 @@ async function normalizeAttr(attr, attrs, tag, options, minifyHTML) {
5477
5089
  (options.removeScriptTypeAttributes && tag === 'script' &&
5478
5090
  attrName === 'type' && isScriptTypeAttribute(attrValue) && !keepScriptTypeAttribute(attrValue)) ||
5479
5091
  (options.removeStyleLinkTypeAttributes && (tag === 'style' || tag === 'link') &&
5480
- attrName === 'type' && isStyleLinkTypeAttribute(attrValue)) ||
5481
- (options.insideSVG && options.minifySVG &&
5482
- shouldRemoveSVGAttribute(tag, attrName, attrValue, options.minifySVG))) {
5092
+ attrName === 'type' && isStyleLinkTypeAttribute(attrValue))) {
5483
5093
  return;
5484
5094
  }
5485
5095
 
@@ -5886,9 +5496,18 @@ async function getSwc() {
5886
5496
  return swcPromise;
5887
5497
  }
5888
5498
 
5499
+ let svgoPromise;
5500
+ async function getSvgo() {
5501
+ if (!svgoPromise) {
5502
+ svgoPromise = import('svgo').then(m => m.optimize);
5503
+ }
5504
+ return svgoPromise;
5505
+ }
5506
+
5889
5507
  // Minification caches (initialized on first use with configurable sizes)
5890
5508
  let cssMinifyCache = null;
5891
5509
  let jsMinifyCache = null;
5510
+ let svgMinifyCache = null;
5892
5511
 
5893
5512
  // Pre-compiled patterns for script merging (avoid repeated allocation in hot path)
5894
5513
  const RE_SCRIPT_ATTRS = /([^\s=]+)(?:=(?:"([^"]*)"|'([^']*)'|([^\s>]+)))?/g;
@@ -6058,6 +5677,15 @@ function mergeConsecutiveScripts(html) {
6058
5677
  *
6059
5678
  * Default: `500`
6060
5679
  *
5680
+ * @prop {number} [cacheSVG]
5681
+ * The maximum number of entries for the SVG minification cache. Higher
5682
+ * values improve performance for inputs with repeated SVG content.
5683
+ * - Cache is created on first `minify()` call and persists for the process lifetime
5684
+ * - Cache size is locked after first call—subsequent calls reuse the same cache
5685
+ * - Explicit `0` values are coerced to `1` (minimum functional cache size)
5686
+ *
5687
+ * Default: `500`
5688
+ *
6061
5689
  * @prop {boolean} [caseSensitive]
6062
5690
  * When true, tag and attribute names are treated as case-sensitive.
6063
5691
  * Useful for custom HTML tags.
@@ -6240,12 +5868,10 @@ function mergeConsecutiveScripts(html) {
6240
5868
  *
6241
5869
  * Default: `false`
6242
5870
  *
6243
- * @prop {boolean | {precision?: number, removeDefaults?: boolean, minifyColors?: boolean}} [minifySVG]
6244
- * When true, enables SVG-specific optimizations for SVG elements and attributes.
6245
- * If an object is provided, it can include:
6246
- * - `precision`: Number of decimal places for numeric values (coordinates, path data, etc.). Default: `3`
6247
- * - `removeDefaults`: Remove attributes with default values (e.g., `fill="black"`). Default: `true`
6248
- * - `minifyColors`: Minify color values (hex shortening, rgb to hex conversion). Default: `true`
5871
+ * @prop {boolean | Object} [minifySVG]
5872
+ * When true, enables SVG minification using [SVGO](https://github.com/svg/svgo).
5873
+ * Complete SVG subtrees are extracted and optimized as a block.
5874
+ * If an object is provided, it is passed to SVGO as configuration options.
6249
5875
  * If disabled, SVG content is minified using standard HTML rules only.
6250
5876
  *
6251
5877
  * Default: `false`
@@ -6829,6 +6455,11 @@ async function minifyHTML(value, options, partialMarkup) {
6829
6455
  trimTrailingWhitespace(charsIndex, nextTag);
6830
6456
  }
6831
6457
 
6458
+ // SVG subtree capture: When SVGO is active, record buffer positions for post-processing
6459
+ const svgBlocks = []; // Array of { start, end } buffer indices
6460
+ let svgBufferStartIndex = -1;
6461
+ let svgDepth = 0;
6462
+
6832
6463
  const parser = new HTMLParser(value, {
6833
6464
  partialMarkup: partialMarkup ?? options.partialMarkup,
6834
6465
  continueOnParseError: options.continueOnParseError,
@@ -6846,6 +6477,10 @@ async function minifyHTML(value, options, partialMarkup) {
6846
6477
  options.name = identity;
6847
6478
  options.insideSVG = lowerTag === 'svg';
6848
6479
  options.insideForeignContent = true;
6480
+ // Disable HTML-specific options that produce invalid XML
6481
+ options.removeAttributeQuotes = false;
6482
+ options.removeTagWhitespace = false;
6483
+ options.decodeEntities = false;
6849
6484
  }
6850
6485
  // `foreignObject` in SVG and `annotation-xml` in MathML contain HTML content
6851
6486
  // Note: The element itself is in SVG/MathML namespace, only its children are HTML
@@ -6860,6 +6495,9 @@ async function minifyHTML(value, options, partialMarkup) {
6860
6495
  options.parentName = parentName; // Preserve for the element tag itself
6861
6496
  options.name = options.htmlName || lowercase;
6862
6497
  options.insideForeignContent = false;
6498
+ // Note: `removeAttributeQuotes`, `removeTagWhitespace`, and `decodeEntities`
6499
+ // stay disabled (inherited from SVG context) because the entire SVG block
6500
+ // must be valid XML for SVGO processing
6863
6501
  useParentNameForTag = true;
6864
6502
  }
6865
6503
  tag = (useParentNameForTag ? options.parentName : options.name)(tag);
@@ -6911,6 +6549,14 @@ async function minifyHTML(value, options, partialMarkup) {
6911
6549
  }
6912
6550
  }
6913
6551
 
6552
+ // Track SVG subtree for SVGO block processing
6553
+ if (lowerTag === 'svg' && options.minifySVG) {
6554
+ if (svgDepth === 0) {
6555
+ svgBufferStartIndex = buffer.length; // Record position before <svg> is pushed
6556
+ }
6557
+ svgDepth++;
6558
+ }
6559
+
6914
6560
  const openTag = '<' + tag;
6915
6561
  const hasUnarySlash = unarySlash && options.keepClosingSlash;
6916
6562
 
@@ -7041,6 +6687,15 @@ async function minifyHTML(value, options, partialMarkup) {
7041
6687
  currentChars += '|';
7042
6688
  }
7043
6689
  }
6690
+
6691
+ // SVG subtree capture: Record end position for post-processing with SVGO
6692
+ if (lowerTag === 'svg' && options.minifySVG && svgDepth > 0) {
6693
+ svgDepth--;
6694
+ if (svgDepth === 0 && svgBufferStartIndex >= 0) {
6695
+ svgBlocks.push({ start: svgBufferStartIndex, end: buffer.length });
6696
+ svgBufferStartIndex = -1;
6697
+ }
6698
+ }
7044
6699
  },
7045
6700
  chars: async function (text, prevTag, nextTag, prevAttrs, nextAttrs) {
7046
6701
  prevTag = prevTag === '' ? 'comment' : prevTag;
@@ -7265,6 +6920,19 @@ async function minifyHTML(value, options, partialMarkup) {
7265
6920
 
7266
6921
  await parser.parse();
7267
6922
 
6923
+ // Post-processing: Optimize SVG blocks with SVGO
6924
+ // Run all SVGO calls in parallel, then splice results in reverse to preserve indices
6925
+ if (options.minifySVG && svgBlocks.length) {
6926
+ const optimized = await Promise.all(
6927
+ svgBlocks.map(({ start, end }) =>
6928
+ options.minifySVG(buffer.slice(start, end).join(''))
6929
+ )
6930
+ );
6931
+ for (let i = svgBlocks.length - 1; i >= 0; i--) {
6932
+ buffer.splice(svgBlocks[i].start, svgBlocks[i].end - svgBlocks[i].start, optimized[i]);
6933
+ }
6934
+ }
6935
+
7268
6936
  if (options.removeOptionalTags) {
7269
6937
  // `<html>` may be omitted if first thing inside is not a comment
7270
6938
  // `<head>` or `<body>` may be omitted if empty
@@ -7351,7 +7019,7 @@ function joinResultSegments(results, options, restoreCustom, restoreIgnore) {
7351
7019
  * Important behavior notes:
7352
7020
  * - Caches are created on the first `minify()` call and persist for the lifetime of the process
7353
7021
  * - Cache sizes are locked after first initialization—subsequent calls use the same caches
7354
- * even if different `cacheCSS`/`cacheJS` options are provided
7022
+ * even if different `cacheCSS`/`cacheJS`/`cacheSVG` options are provided
7355
7023
  * - The first call’s options determine the cache sizes for subsequent calls
7356
7024
  * - Explicit `0` values are coerced to `1` (minimum functional cache size)
7357
7025
  */
@@ -7375,16 +7043,20 @@ function initCaches(options) {
7375
7043
  : (parseEnvCacheSize(process.env.HMN_CACHE_CSS) ?? defaultSize);
7376
7044
  const jsSize = options.cacheJS !== undefined ? options.cacheJS
7377
7045
  : (parseEnvCacheSize(process.env.HMN_CACHE_JS) ?? defaultSize);
7046
+ const svgSize = options.cacheSVG !== undefined ? options.cacheSVG
7047
+ : (parseEnvCacheSize(process.env.HMN_CACHE_SVG) ?? defaultSize);
7378
7048
 
7379
7049
  // Coerce `0` to `1` (minimum functional cache size) to avoid immediate eviction
7380
7050
  const cssFinalSize = cssSize === 0 ? 1 : cssSize;
7381
7051
  const jsFinalSize = jsSize === 0 ? 1 : jsSize;
7052
+ const svgFinalSize = svgSize === 0 ? 1 : svgSize;
7382
7053
 
7383
7054
  cssMinifyCache = new LRU(cssFinalSize);
7384
7055
  jsMinifyCache = new LRU(jsFinalSize);
7056
+ svgMinifyCache = new LRU(svgFinalSize);
7385
7057
  }
7386
7058
 
7387
- return { cssMinifyCache, jsMinifyCache };
7059
+ return { cssMinifyCache, jsMinifyCache, svgMinifyCache };
7388
7060
  }
7389
7061
 
7390
7062
  /**
@@ -7402,6 +7074,7 @@ const minify$1 = async function (value, options) {
7402
7074
  getLightningCSS,
7403
7075
  getTerser,
7404
7076
  getSwc,
7077
+ getSvgo,
7405
7078
  ...caches
7406
7079
  });
7407
7080
  let result = await minifyHTML(value, options);