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.
@@ -1034,7 +1034,6 @@ const RE_ESCAPE_LT = /</g;
1034
1034
  const RE_ATTR_WS_CHECK = /[ \n\r\t\f]/;
1035
1035
  const RE_ATTR_WS_COLLAPSE = /[ \n\r\t\f]+/g;
1036
1036
  const RE_ATTR_WS_TRIM = /^[ \n\r\t\f]+|[ \n\r\t\f]+$/g;
1037
- const RE_NUMERIC_VALUE = /-?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?/g;
1038
1037
 
1039
1038
  // Inline element sets for whitespace handling
1040
1039
 
@@ -1638,427 +1637,6 @@ async function processScript(text, options, currentAttrs, minifyHTML) {
1638
1637
  return text;
1639
1638
  }
1640
1639
 
1641
- /**
1642
- * Lightweight SVG optimizations:
1643
- * - Numeric precision reduction for coordinates and path data
1644
- * - Whitespace removal in attribute values (numeric sequences)
1645
- * - Default attribute removal (safe, well-documented defaults)
1646
- * - Color minification (hex shortening, rgb() to hex, named colors)
1647
- * - Identity transform removal
1648
- * - Path data space optimization
1649
- */
1650
-
1651
-
1652
- // Cache for minified numbers
1653
- const numberCache = new LRU(100);
1654
-
1655
- /**
1656
- * Named colors that are shorter than their hex equivalents
1657
- * Only includes cases where using the name saves bytes
1658
- */
1659
- const NAMED_COLORS = {
1660
- '#f00': 'red', // #f00 (4) → red (3), saves 1
1661
- '#c0c0c0': 'silver', // #c0c0c0 (7) → silver (6), saves 1
1662
- '#808080': 'gray', // #808080 (7) → gray (4), saves 3
1663
- '#800000': 'maroon', // #800000 (7) → maroon (6), saves 1
1664
- '#808000': 'olive', // #808000 (7) → olive (5), saves 2
1665
- '#008000': 'green', // #008000 (7) → green (5), saves 2
1666
- '#800080': 'purple', // #800080 (7) → purple (6), saves 1
1667
- '#008080': 'teal', // #008080 (7) → teal (4), saves 3
1668
- '#000080': 'navy', // #000080 (7) → navy (4), saves 3
1669
- '#ffa500': 'orange' // #ffa500 (7) → orange (6), saves 1
1670
- };
1671
-
1672
- /**
1673
- * Default SVG attribute values that can be safely removed
1674
- * Only includes well-documented, widely-supported defaults
1675
- */
1676
- const SVG_DEFAULT_ATTRS = {
1677
- // Fill and stroke defaults
1678
- fill: value => value === 'black' || value === '#000' || value === '#000000',
1679
- 'fill-opacity': value => value === '1',
1680
- 'fill-rule': value => value === 'nonzero',
1681
- stroke: value => value === 'none',
1682
- 'stroke-dasharray': value => value === 'none',
1683
- 'stroke-dashoffset': value => value === '0',
1684
- 'stroke-linecap': value => value === 'butt',
1685
- 'stroke-linejoin': value => value === 'miter',
1686
- 'stroke-miterlimit': value => value === '4',
1687
- 'stroke-opacity': value => value === '1',
1688
- 'stroke-width': value => value === '1',
1689
-
1690
- // Text and font defaults
1691
- 'font-family': value => value === 'inherit',
1692
- 'font-size': value => value === 'medium',
1693
- 'font-style': value => value === 'normal',
1694
- 'font-variant': value => value === 'normal',
1695
- 'font-weight': value => value === 'normal',
1696
- 'letter-spacing': value => value === 'normal',
1697
- 'text-decoration': value => value === 'none',
1698
- 'text-anchor': value => value === 'start',
1699
-
1700
- // Other common defaults
1701
- opacity: value => value === '1',
1702
- visibility: value => value === 'visible',
1703
- display: value => value === 'inline',
1704
- // Note: Overflow handled especially in `isDefaultAttribute` (not safe for root `<svg>`)
1705
-
1706
- // Clipping and masking defaults
1707
- 'clip-rule': value => value === 'nonzero',
1708
- 'clip-path': value => value === 'none',
1709
- mask: value => value === 'none',
1710
-
1711
- // Marker defaults
1712
- 'marker-start': value => value === 'none',
1713
- 'marker-mid': value => value === 'none',
1714
- 'marker-end': value => value === 'none',
1715
-
1716
- // Filter and color defaults
1717
- filter: value => value === 'none',
1718
- 'color-interpolation': value => value === 'sRGB',
1719
- 'color-interpolation-filters': value => value === 'linearRGB'
1720
- };
1721
-
1722
- /**
1723
- * Minify numeric value by removing trailing zeros and unnecessary decimals
1724
- * @param {string} num - Numeric string to minify
1725
- * @param {number} precision - Maximum decimal places to keep
1726
- * @returns {string} Minified numeric string
1727
- */
1728
- function minifyNumber(num, precision = 3) {
1729
- // Fast path for common values (avoids parsing and caching)
1730
- if (num === '0' || num === '1') return num;
1731
- // Common decimal variants that tools export
1732
- if (num === '0.0' || num === '0.00' || num === '0.000') return '0';
1733
- if (num === '1.0' || num === '1.00' || num === '1.000') return '1';
1734
-
1735
- // Check cache
1736
- // (Note: Uses input string as key, so “0.0000” and “0.00000” create separate entries.
1737
- // This is intentional to avoid parsing overhead.
1738
- // Real-world SVG files from export tools typically use consistent formats.)
1739
- const cacheKey = `${num}:${precision}`;
1740
- const cached = numberCache.get(cacheKey);
1741
- if (cached !== undefined) return cached;
1742
-
1743
- const parsed = parseFloat(num);
1744
-
1745
- // Handle special cases
1746
- if (isNaN(parsed)) return num;
1747
- if (parsed === 0) return '0';
1748
- if (!isFinite(parsed)) return num;
1749
-
1750
- // Convert to fixed precision, then remove trailing zeros
1751
- const fixed = parsed.toFixed(precision);
1752
- const trimmed = fixed.replace(/\.?0+$/, '');
1753
-
1754
- // Remove leading zero before decimal point (e.g., `0.5` → `.5`, `-0.3` → `-.3`)
1755
- const result = (trimmed || '0').replace(/^(-?)0\./, '$1.');
1756
- numberCache.set(cacheKey, result);
1757
- return result;
1758
- }
1759
-
1760
- /**
1761
- * Minify SVG path data by reducing numeric precision and removing unnecessary spaces
1762
- * @param {string} pathData - SVG path data string
1763
- * @param {number} precision - Decimal precision for coordinates
1764
- * @returns {string} Minified path data
1765
- */
1766
- function minifyPathData(pathData, precision = 3) {
1767
- if (!pathData || typeof pathData !== 'string') return pathData;
1768
-
1769
- // First, minify all numbers
1770
- let result = pathData.replace(RE_NUMERIC_VALUE, (match) => {
1771
- return minifyNumber(match, precision);
1772
- });
1773
-
1774
- // Remove unnecessary spaces around path commands
1775
- // Safe to remove space after a command letter when it’s followed by a number
1776
- // (which may be negative or start with a decimal point)
1777
- // `M 10 20` → `M10 20`, `L -5 -3` → `L-5-3`, `M .5 .3` → `M.5.3`
1778
- result = result.replace(/([MLHVCSQTAZmlhvcsqtaz])\s+(?=-?\.?\d)/g, '$1');
1779
-
1780
- // Safe to remove space before command letter when preceded by a number
1781
- // `0 L` → `0L`, `20 M` → `20M`, `.5 L` → `.5L`
1782
- result = result.replace(/([\d.])\s+([MLHVCSQTAZmlhvcsqtaz])/g, '$1$2');
1783
-
1784
- // Safe to remove space before negative number when preceded by a number
1785
- // `10 -20` → `10-20`, `.5 -.3` → `.5-.3` (minus sign is always a separator)
1786
- result = result.replace(/([\d.])\s+(-)/g, '$1$2');
1787
-
1788
- // Safe to remove space between two decimal numbers (decimal point acts as separator)
1789
- // `.5 .3` → `.5.3` (only when previous char is `.`, indicating a complete decimal)
1790
- // Note: `0 .3` must not become `0.3` (that would change two numbers into one)
1791
- result = result.replace(/(\.\d*)\s+(\.)/g, '$1$2');
1792
-
1793
- return result;
1794
- }
1795
-
1796
- /**
1797
- * Minify whitespace in numeric attribute values
1798
- * Examples:
1799
- * - “10 , 20" → "10,20"
1800
- * - "translate( 10 20 )" → "translate(10 20)"
1801
- * - "100, 10 40, 198" → "100,10 40,198"
1802
- *
1803
- * @param {string} value - Attribute value to minify
1804
- * @returns {string} Minified value
1805
- */
1806
- function minifyAttributeWhitespace(value) {
1807
- if (!value || typeof value !== 'string') return value;
1808
-
1809
- return value
1810
- // Remove spaces around commas
1811
- .replace(/\s*,\s*/g, ',')
1812
- // Remove spaces around parentheses
1813
- .replace(/\(\s+/g, '(')
1814
- .replace(/\s+\)/g, ')')
1815
- // Collapse multiple spaces to single space
1816
- .replace(/\s+/g, ' ')
1817
- // Trim leading/trailing whitespace
1818
- .trim();
1819
- }
1820
-
1821
- /**
1822
- * Minify color values (hex shortening, rgb to hex conversion, named colors)
1823
- * Only processes simple color values; preserves case-sensitive references like `url(#id)`
1824
- * @param {string} color - Color value to minify
1825
- * @returns {string} Minified color value
1826
- */
1827
- function minifyColor(color) {
1828
- if (!color || typeof color !== 'string') return color;
1829
-
1830
- const trimmed = color.trim();
1831
-
1832
- // Don’t process values that aren’t simple colors (preserve case-sensitive references)
1833
- // `url(#id)`, `var(--name)`, `inherit`, `currentColor`, etc.
1834
- if (trimmed.includes('url(') || trimmed.includes('var(') || trimmed === 'inherit' || trimmed === 'currentColor') {
1835
- return trimmed;
1836
- }
1837
-
1838
- // Now safe to lowercase for color matching
1839
- const lower = trimmed.toLowerCase();
1840
-
1841
- // Shorten 6-digit hex to 3-digit when possible
1842
- // `#aabbcc` → `#abc`, `#000000` → `#000`
1843
- const hexMatch = lower.match(/^#([0-9a-f]{6})$/);
1844
- if (hexMatch) {
1845
- const hex = hexMatch[1];
1846
- if (hex[0] === hex[1] && hex[2] === hex[3] && hex[4] === hex[5]) {
1847
- const shortened = '#' + hex[0] + hex[2] + hex[4];
1848
- // Try to use named color if shorter
1849
- return NAMED_COLORS[shortened] || shortened;
1850
- }
1851
- // Can’t shorten, but check for named color
1852
- return NAMED_COLORS[lower] || lower;
1853
- }
1854
-
1855
- // Match 3-digit hex colors
1856
- const hex3Match = lower.match(/^#[0-9a-f]{3}$/);
1857
- if (hex3Match) {
1858
- // Check if there’s a shorter named color
1859
- return NAMED_COLORS[lower] || lower;
1860
- }
1861
-
1862
- // Convert rgb() to hex
1863
- const rgbMatch = lower.match(/^rgb\s*\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)$/);
1864
- if (rgbMatch) {
1865
- const r = parseInt(rgbMatch[1], 10);
1866
- const g = parseInt(rgbMatch[2], 10);
1867
- const b = parseInt(rgbMatch[3], 10);
1868
-
1869
- if (r >= 0 && r <= 255 && g >= 0 && g <= 255 && b >= 0 && b <= 255) {
1870
- const toHex = (n) => {
1871
- const h = n.toString(16);
1872
- return h.length === 1 ? '0' + h : h;
1873
- };
1874
- const hexColor = '#' + toHex(r) + toHex(g) + toHex(b);
1875
-
1876
- // Try to shorten if possible
1877
- if (hexColor[1] === hexColor[2] && hexColor[3] === hexColor[4] && hexColor[5] === hexColor[6]) {
1878
- const shortened = '#' + hexColor[1] + hexColor[3] + hexColor[5];
1879
- return NAMED_COLORS[shortened] || shortened;
1880
- }
1881
- return NAMED_COLORS[hexColor] || hexColor;
1882
- }
1883
- }
1884
-
1885
- // Not a recognized color format, return as-is (preserves case)
1886
- return trimmed;
1887
- }
1888
-
1889
- // Attributes that contain numeric sequences or path data
1890
- const NUMERIC_ATTRS = new Set([
1891
- 'd', // Path data
1892
- 'points', // Polygon/polyline points
1893
- 'viewBox', // `viewBox` coordinates
1894
- 'transform', // Transform functions
1895
- 'x', 'y', 'x1', 'y1', 'x2', 'y2', // Coordinates
1896
- 'cx', 'cy', 'r', 'rx', 'ry', // Circle/ellipse
1897
- 'width', 'height', // Dimensions
1898
- 'dx', 'dy', // Text offsets
1899
- 'offset', // Gradient offset
1900
- 'startOffset', // `textPath`
1901
- 'pathLength', // Path length
1902
- 'stdDeviation', // Filter params
1903
- 'baseFrequency', // Turbulence
1904
- 'k1', 'k2', 'k3', 'k4' // Composite filter
1905
- ]);
1906
-
1907
- // Attributes that contain color values
1908
- const COLOR_ATTRS = new Set([
1909
- 'fill',
1910
- 'stroke',
1911
- 'stop-color',
1912
- 'flood-color',
1913
- 'lighting-color'
1914
- ]);
1915
-
1916
- // Pre-compiled regexes for identity transform detection (compiled once at module load)
1917
- // Separator pattern: Accepts comma with optional spaces or one or more spaces
1918
- const SEP = '(?:\\s*,\\s*|\\s+)';
1919
-
1920
- // `translate(0)`, `translate(0,0)`, `translate(0 0)` (matches 0, 0.0, 0.00, etc.)
1921
- const IDENTITY_TRANSLATE_RE = new RegExp(`^translate\\s*\\(\\s*0(?:\\.0+)?\\s*(?:${SEP}0(?:\\.0+)?\\s*)?\\)$`, 'i');
1922
-
1923
- // `scale(1)`, `scale(1,1)`, `scale(1 1)` (matches 1, 1.0, 1.00, etc.)
1924
- const IDENTITY_SCALE_RE = new RegExp(`^scale\\s*\\(\\s*1(?:\\.0+)?\\s*(?:${SEP}1(?:\\.0+)?\\s*)?\\)$`, 'i');
1925
-
1926
- // `rotate(0)`, `rotate(0 cx cy)`, `rotate(0, cx, cy)` (matches 0, 0.0, 0.00, etc.)
1927
- // Note: `cx` and `cy` must be valid numbers if present
1928
- const IDENTITY_ROTATE_RE = new RegExp(`^rotate\\s*\\(\\s*0(?:\\.0+)?\\s*(?:${SEP}-?\\d+(?:\\.\\d+)?${SEP}-?\\d+(?:\\.\\d+)?)?\\s*\\)$`, 'i');
1929
-
1930
- // `skewX(0)`, `skewY(0)` (matches 0, 0.0, 0.00, etc.)
1931
- const IDENTITY_SKEW_RE = /^skew[XY]\s*\(\s*0(?:\.0+)?\s*\)$/i;
1932
-
1933
- // `matrix(1,0,0,1,0,0)`, `matrix(1 0 0 1 0 0)`—identity matrix (matches 1.0/0.0 variants)
1934
- 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');
1935
-
1936
- /**
1937
- * Check if a transform attribute has no effect (identity transform)
1938
- * @param {string} transform - Transform attribute value
1939
- * @returns {boolean} True if transform is an identity (has no effect)
1940
- */
1941
- function isIdentityTransform(transform) {
1942
- if (!transform || typeof transform !== 'string') return false;
1943
-
1944
- const trimmed = transform.trim();
1945
-
1946
- // Check for common identity transforms using pre-compiled regexes
1947
- return IDENTITY_TRANSLATE_RE.test(trimmed) ||
1948
- IDENTITY_SCALE_RE.test(trimmed) ||
1949
- IDENTITY_ROTATE_RE.test(trimmed) ||
1950
- IDENTITY_SKEW_RE.test(trimmed) ||
1951
- IDENTITY_MATRIX_RE.test(trimmed);
1952
- }
1953
-
1954
- /**
1955
- * Check if an attribute should be removed based on default value
1956
- * @param {string} tag - Element tag name (e.g., `svg`, `rect`, `path`)
1957
- * @param {string} name - Attribute name
1958
- * @param {string} value - Attribute value
1959
- * @returns {boolean} True if attribute can be removed
1960
- */
1961
- function isDefaultAttribute(tag, name, value) {
1962
- // Special case: `overflow="visible"` is unsafe for root `<svg>` element
1963
- // Root SVG may need explicit `overflow="visible"` to show clipped content
1964
- if (name === 'overflow' && value === 'visible') {
1965
- return tag !== 'svg'; // Only remove for non-root SVG elements
1966
- }
1967
-
1968
- const checker = SVG_DEFAULT_ATTRS[name];
1969
- if (!checker) return false;
1970
-
1971
- // Special case: Don’t remove `fill="black"` if stroke exists without fill
1972
- // This would change the rendering (stroke-only shapes would gain black fill)
1973
- if (name === 'fill' && checker(value)) {
1974
- // This check would require looking at other attributes on the same element
1975
- // For safety, we’ll keep this conservative and not remove `fill="black"`
1976
- // in the initial implementation. Can be refined later.
1977
- return false;
1978
- }
1979
-
1980
- return checker(value);
1981
- }
1982
-
1983
- /**
1984
- * Minify SVG attribute value based on attribute name
1985
- * @param {string} name - Attribute name
1986
- * @param {string} value - Attribute value
1987
- * @param {Object} options - Minification options
1988
- * @returns {string} Minified attribute value
1989
- */
1990
- function minifySVGAttributeValue(name, value, options = {}) {
1991
- if (!value || typeof value !== 'string') return value;
1992
-
1993
- const { precision = 3, minifyColors = true } = options;
1994
-
1995
- // Path data gets special treatment
1996
- if (name === 'd') {
1997
- return minifyPathData(value, precision);
1998
- }
1999
-
2000
- // Numeric attributes get precision reduction and whitespace minification
2001
- if (NUMERIC_ATTRS.has(name)) {
2002
- const minified = value.replace(RE_NUMERIC_VALUE, (match) => {
2003
- return minifyNumber(match, precision);
2004
- });
2005
- return minifyAttributeWhitespace(minified);
2006
- }
2007
-
2008
- // Color attributes get color minification
2009
- if (minifyColors && COLOR_ATTRS.has(name)) {
2010
- return minifyColor(value);
2011
- }
2012
-
2013
- return value;
2014
- }
2015
-
2016
- /**
2017
- * Check if an SVG attribute can be removed
2018
- * @param {string} tag - Element tag name (e.g., `svg`, `rect`, `path`)
2019
- * @param {string} name - Attribute name
2020
- * @param {string} value - Attribute value
2021
- * @param {Object} options - Minification options
2022
- * @returns {boolean} True if attribute should be removed
2023
- */
2024
- function shouldRemoveSVGAttribute(tag, name, value, options = {}) {
2025
- const { removeDefaults = true } = options;
2026
-
2027
- if (!removeDefaults) return false;
2028
-
2029
- // Check for identity transforms
2030
- if (name === 'transform' && isIdentityTransform(value)) {
2031
- return true;
2032
- }
2033
-
2034
- return isDefaultAttribute(tag, name, value);
2035
- }
2036
-
2037
- /**
2038
- * Get default SVG minification options
2039
- * @param {Object} userOptions - User-provided options
2040
- * @returns {Object} Complete options object with defaults
2041
- */
2042
- function getSVGMinifierOptions(userOptions) {
2043
- if (typeof userOptions === 'boolean') {
2044
- return userOptions ? {
2045
- precision: 3,
2046
- removeDefaults: true,
2047
- minifyColors: true
2048
- } : null;
2049
- }
2050
-
2051
- if (typeof userOptions === 'object' && userOptions !== null) {
2052
- return {
2053
- precision: userOptions.precision ?? 3,
2054
- removeDefaults: userOptions.removeDefaults ?? true,
2055
- minifyColors: userOptions.minifyColors ?? true
2056
- };
2057
- }
2058
-
2059
- return null;
2060
- }
2061
-
2062
1640
  // Single source of truth for minifier option names, descriptions, types, and shared defaults
2063
1641
 
2064
1642
 
@@ -2100,11 +1678,13 @@ function shouldMinifyInnerHTML(options) {
2100
1678
  * @param {Function} deps.getLightningCSS - Function to lazily load Lightning CSS
2101
1679
  * @param {Function} deps.getTerser - Function to lazily load Terser
2102
1680
  * @param {Function} deps.getSwc - Function to lazily load @swc/core
1681
+ * @param {Function} deps.getSvgo - Function to lazily load SVGO
2103
1682
  * @param {LRU} deps.cssMinifyCache - CSS minification cache
2104
1683
  * @param {LRU} deps.jsMinifyCache - JS minification cache
1684
+ * @param {LRU} deps.svgMinifyCache - SVG minification cache
2105
1685
  * @returns {MinifierOptions} Normalized options with defaults applied
2106
1686
  */
2107
- const processOptions = (inputOptions, { getLightningCSS, getTerser, getSwc, cssMinifyCache, jsMinifyCache } = {}) => {
1687
+ const processOptions = (inputOptions, { getLightningCSS, getTerser, getSwc, getSvgo, cssMinifyCache, jsMinifyCache, svgMinifyCache } = {}) => {
2108
1688
  const options = {
2109
1689
  name: lowercase,
2110
1690
  canCollapseWhitespace,
@@ -2405,11 +1985,54 @@ const processOptions = (inputOptions, { getLightningCSS, getTerser, getSwc, cssM
2405
1985
  return text;
2406
1986
  }
2407
1987
  };
2408
- } else if (key === 'minifySVG') {
2409
- // Process SVG minification options
2410
- // Unlike `minifyCSS`/`minifyJS`, this is a simple options object, not a function
2411
- // The actual minification is applied inline during attribute processing
2412
- options.minifySVG = getSVGMinifierOptions(option);
1988
+ } else if (key === 'minifySVG' && typeof option !== 'function') {
1989
+ if (!option) {
1990
+ return;
1991
+ }
1992
+
1993
+ const svgoOptions = typeof option === 'object' ? option : {};
1994
+
1995
+ // Pre-compute option signature for cache keys
1996
+ const svgSig = stableStringify({
1997
+ ...svgoOptions,
1998
+ cont: !!options.continueOnMinifyError
1999
+ });
2000
+
2001
+ options.minifySVG = async function (svgContent) {
2002
+ if (!svgContent || !svgContent.trim()) {
2003
+ return svgContent;
2004
+ }
2005
+
2006
+ // Cache key
2007
+ const svgKey = svgContent.length > 2048
2008
+ ? (svgContent.length + '|' + svgContent.slice(0, 50) + svgContent.slice(-50) + '|' + svgSig)
2009
+ : (svgContent + '|' + svgSig);
2010
+
2011
+ try {
2012
+ const cached = svgMinifyCache.get(svgKey);
2013
+ if (cached) {
2014
+ return await cached;
2015
+ }
2016
+
2017
+ const inFlight = (async () => {
2018
+ const optimize = await getSvgo();
2019
+ const result = optimize(svgContent, svgoOptions);
2020
+ return result.data;
2021
+ })();
2022
+
2023
+ svgMinifyCache.set(svgKey, inFlight);
2024
+ const resolved = await inFlight;
2025
+ svgMinifyCache.set(svgKey, resolved);
2026
+ return resolved;
2027
+ } catch (err) {
2028
+ svgMinifyCache.delete(svgKey);
2029
+ if (!options.continueOnMinifyError) {
2030
+ throw err;
2031
+ }
2032
+ options.log && options.log(err);
2033
+ return svgContent;
2034
+ }
2035
+ };
2413
2036
  } else if (key === 'customAttrCollapse') {
2414
2037
  // Single regex pattern
2415
2038
  options[key] = parseRegExp(option);
@@ -2813,17 +2436,6 @@ async function cleanAttributeValue(tag, attrName, attrValue, options, attrs, min
2813
2436
  return attrValue;
2814
2437
  }
2815
2438
  return minifyHTMLSelf(attrValue, options, true);
2816
- } else if (options.insideSVG && options.minifySVG) {
2817
- // Apply SVG-specific attribute minification when inside SVG elements
2818
- try {
2819
- return minifySVGAttributeValue(attrName, attrValue, options.minifySVG);
2820
- } catch (err) {
2821
- if (!options.continueOnMinifyError) {
2822
- throw err;
2823
- }
2824
- options.log && options.log(err);
2825
- return attrValue;
2826
- }
2827
2439
  }
2828
2440
  return attrValue;
2829
2441
  }
@@ -2864,9 +2476,7 @@ async function normalizeAttr(attr, attrs, tag, options, minifyHTML) {
2864
2476
  (options.removeScriptTypeAttributes && tag === 'script' &&
2865
2477
  attrName === 'type' && isScriptTypeAttribute(attrValue) && !keepScriptTypeAttribute(attrValue)) ||
2866
2478
  (options.removeStyleLinkTypeAttributes && (tag === 'style' || tag === 'link') &&
2867
- attrName === 'type' && isStyleLinkTypeAttribute(attrValue)) ||
2868
- (options.insideSVG && options.minifySVG &&
2869
- shouldRemoveSVGAttribute(tag, attrName, attrValue, options.minifySVG))) {
2479
+ attrName === 'type' && isStyleLinkTypeAttribute(attrValue))) {
2870
2480
  return;
2871
2481
  }
2872
2482
 
@@ -3273,9 +2883,18 @@ async function getSwc() {
3273
2883
  return swcPromise;
3274
2884
  }
3275
2885
 
2886
+ let svgoPromise;
2887
+ async function getSvgo() {
2888
+ if (!svgoPromise) {
2889
+ svgoPromise = import('svgo').then(m => m.optimize);
2890
+ }
2891
+ return svgoPromise;
2892
+ }
2893
+
3276
2894
  // Minification caches (initialized on first use with configurable sizes)
3277
2895
  let cssMinifyCache = null;
3278
2896
  let jsMinifyCache = null;
2897
+ let svgMinifyCache = null;
3279
2898
 
3280
2899
  // Pre-compiled patterns for script merging (avoid repeated allocation in hot path)
3281
2900
  const RE_SCRIPT_ATTRS = /([^\s=]+)(?:=(?:"([^"]*)"|'([^']*)'|([^\s>]+)))?/g;
@@ -3445,6 +3064,15 @@ function mergeConsecutiveScripts(html) {
3445
3064
  *
3446
3065
  * Default: `500`
3447
3066
  *
3067
+ * @prop {number} [cacheSVG]
3068
+ * The maximum number of entries for the SVG minification cache. Higher
3069
+ * values improve performance for inputs with repeated SVG content.
3070
+ * - Cache is created on first `minify()` call and persists for the process lifetime
3071
+ * - Cache size is locked after first call—subsequent calls reuse the same cache
3072
+ * - Explicit `0` values are coerced to `1` (minimum functional cache size)
3073
+ *
3074
+ * Default: `500`
3075
+ *
3448
3076
  * @prop {boolean} [caseSensitive]
3449
3077
  * When true, tag and attribute names are treated as case-sensitive.
3450
3078
  * Useful for custom HTML tags.
@@ -3627,12 +3255,10 @@ function mergeConsecutiveScripts(html) {
3627
3255
  *
3628
3256
  * Default: `false`
3629
3257
  *
3630
- * @prop {boolean | {precision?: number, removeDefaults?: boolean, minifyColors?: boolean}} [minifySVG]
3631
- * When true, enables SVG-specific optimizations for SVG elements and attributes.
3632
- * If an object is provided, it can include:
3633
- * - `precision`: Number of decimal places for numeric values (coordinates, path data, etc.). Default: `3`
3634
- * - `removeDefaults`: Remove attributes with default values (e.g., `fill="black"`). Default: `true`
3635
- * - `minifyColors`: Minify color values (hex shortening, rgb to hex conversion). Default: `true`
3258
+ * @prop {boolean | Object} [minifySVG]
3259
+ * When true, enables SVG minification using [SVGO](https://github.com/svg/svgo).
3260
+ * Complete SVG subtrees are extracted and optimized as a block.
3261
+ * If an object is provided, it is passed to SVGO as configuration options.
3636
3262
  * If disabled, SVG content is minified using standard HTML rules only.
3637
3263
  *
3638
3264
  * Default: `false`
@@ -4216,6 +3842,11 @@ async function minifyHTML(value, options, partialMarkup) {
4216
3842
  trimTrailingWhitespace(charsIndex, nextTag);
4217
3843
  }
4218
3844
 
3845
+ // SVG subtree capture: When SVGO is active, record buffer positions for post-processing
3846
+ const svgBlocks = []; // Array of { start, end } buffer indices
3847
+ let svgBufferStartIndex = -1;
3848
+ let svgDepth = 0;
3849
+
4219
3850
  const parser = new HTMLParser(value, {
4220
3851
  partialMarkup: partialMarkup ?? options.partialMarkup,
4221
3852
  continueOnParseError: options.continueOnParseError,
@@ -4233,6 +3864,10 @@ async function minifyHTML(value, options, partialMarkup) {
4233
3864
  options.name = identity;
4234
3865
  options.insideSVG = lowerTag === 'svg';
4235
3866
  options.insideForeignContent = true;
3867
+ // Disable HTML-specific options that produce invalid XML
3868
+ options.removeAttributeQuotes = false;
3869
+ options.removeTagWhitespace = false;
3870
+ options.decodeEntities = false;
4236
3871
  }
4237
3872
  // `foreignObject` in SVG and `annotation-xml` in MathML contain HTML content
4238
3873
  // Note: The element itself is in SVG/MathML namespace, only its children are HTML
@@ -4247,6 +3882,9 @@ async function minifyHTML(value, options, partialMarkup) {
4247
3882
  options.parentName = parentName; // Preserve for the element tag itself
4248
3883
  options.name = options.htmlName || lowercase;
4249
3884
  options.insideForeignContent = false;
3885
+ // Note: `removeAttributeQuotes`, `removeTagWhitespace`, and `decodeEntities`
3886
+ // stay disabled (inherited from SVG context) because the entire SVG block
3887
+ // must be valid XML for SVGO processing
4250
3888
  useParentNameForTag = true;
4251
3889
  }
4252
3890
  tag = (useParentNameForTag ? options.parentName : options.name)(tag);
@@ -4298,6 +3936,14 @@ async function minifyHTML(value, options, partialMarkup) {
4298
3936
  }
4299
3937
  }
4300
3938
 
3939
+ // Track SVG subtree for SVGO block processing
3940
+ if (lowerTag === 'svg' && options.minifySVG) {
3941
+ if (svgDepth === 0) {
3942
+ svgBufferStartIndex = buffer.length; // Record position before <svg> is pushed
3943
+ }
3944
+ svgDepth++;
3945
+ }
3946
+
4301
3947
  const openTag = '<' + tag;
4302
3948
  const hasUnarySlash = unarySlash && options.keepClosingSlash;
4303
3949
 
@@ -4428,6 +4074,15 @@ async function minifyHTML(value, options, partialMarkup) {
4428
4074
  currentChars += '|';
4429
4075
  }
4430
4076
  }
4077
+
4078
+ // SVG subtree capture: Record end position for post-processing with SVGO
4079
+ if (lowerTag === 'svg' && options.minifySVG && svgDepth > 0) {
4080
+ svgDepth--;
4081
+ if (svgDepth === 0 && svgBufferStartIndex >= 0) {
4082
+ svgBlocks.push({ start: svgBufferStartIndex, end: buffer.length });
4083
+ svgBufferStartIndex = -1;
4084
+ }
4085
+ }
4431
4086
  },
4432
4087
  chars: async function (text, prevTag, nextTag, prevAttrs, nextAttrs) {
4433
4088
  prevTag = prevTag === '' ? 'comment' : prevTag;
@@ -4652,6 +4307,19 @@ async function minifyHTML(value, options, partialMarkup) {
4652
4307
 
4653
4308
  await parser.parse();
4654
4309
 
4310
+ // Post-processing: Optimize SVG blocks with SVGO
4311
+ // Run all SVGO calls in parallel, then splice results in reverse to preserve indices
4312
+ if (options.minifySVG && svgBlocks.length) {
4313
+ const optimized = await Promise.all(
4314
+ svgBlocks.map(({ start, end }) =>
4315
+ options.minifySVG(buffer.slice(start, end).join(''))
4316
+ )
4317
+ );
4318
+ for (let i = svgBlocks.length - 1; i >= 0; i--) {
4319
+ buffer.splice(svgBlocks[i].start, svgBlocks[i].end - svgBlocks[i].start, optimized[i]);
4320
+ }
4321
+ }
4322
+
4655
4323
  if (options.removeOptionalTags) {
4656
4324
  // `<html>` may be omitted if first thing inside is not a comment
4657
4325
  // `<head>` or `<body>` may be omitted if empty
@@ -4738,7 +4406,7 @@ function joinResultSegments(results, options, restoreCustom, restoreIgnore) {
4738
4406
  * Important behavior notes:
4739
4407
  * - Caches are created on the first `minify()` call and persist for the lifetime of the process
4740
4408
  * - Cache sizes are locked after first initialization—subsequent calls use the same caches
4741
- * even if different `cacheCSS`/`cacheJS` options are provided
4409
+ * even if different `cacheCSS`/`cacheJS`/`cacheSVG` options are provided
4742
4410
  * - The first call’s options determine the cache sizes for subsequent calls
4743
4411
  * - Explicit `0` values are coerced to `1` (minimum functional cache size)
4744
4412
  */
@@ -4762,16 +4430,20 @@ function initCaches(options) {
4762
4430
  : (parseEnvCacheSize(process.env.HMN_CACHE_CSS) ?? defaultSize);
4763
4431
  const jsSize = options.cacheJS !== undefined ? options.cacheJS
4764
4432
  : (parseEnvCacheSize(process.env.HMN_CACHE_JS) ?? defaultSize);
4433
+ const svgSize = options.cacheSVG !== undefined ? options.cacheSVG
4434
+ : (parseEnvCacheSize(process.env.HMN_CACHE_SVG) ?? defaultSize);
4765
4435
 
4766
4436
  // Coerce `0` to `1` (minimum functional cache size) to avoid immediate eviction
4767
4437
  const cssFinalSize = cssSize === 0 ? 1 : cssSize;
4768
4438
  const jsFinalSize = jsSize === 0 ? 1 : jsSize;
4439
+ const svgFinalSize = svgSize === 0 ? 1 : svgSize;
4769
4440
 
4770
4441
  cssMinifyCache = new LRU(cssFinalSize);
4771
4442
  jsMinifyCache = new LRU(jsFinalSize);
4443
+ svgMinifyCache = new LRU(svgFinalSize);
4772
4444
  }
4773
4445
 
4774
- return { cssMinifyCache, jsMinifyCache };
4446
+ return { cssMinifyCache, jsMinifyCache, svgMinifyCache };
4775
4447
  }
4776
4448
 
4777
4449
  /**
@@ -4789,6 +4461,7 @@ const minify = async function (value, options) {
4789
4461
  getLightningCSS,
4790
4462
  getTerser,
4791
4463
  getSwc,
4464
+ getSvgo,
4792
4465
  ...caches
4793
4466
  });
4794
4467
  let result = await minifyHTML(value, options);