Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 1 addition & 3 deletions .github/workflows/npm.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,4 @@ jobs:
- name: "Run tests"
run: npm test
- name: "Publish"
run: npm publish --provenance
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
run: npm publish
6 changes: 5 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,11 @@ As of version 5.0.0, all notable changes to HTML Minifier (HM) are documented in

The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and the project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]
## [5.2.0] - 2026-11-14

### Fixed

* Fixed `collapseAttributeWhitespace` adding spaces around custom fragments in attribute values (e.g., `href="/invoices/<?= $inv->id ?>?tab=lines"` became `href="/invoices/ <?= $inv->id ?> ?tab=lines"`)

### Added

Expand Down
101 changes: 58 additions & 43 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 3 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,11 @@
"@eslint/js": "^10.0.1",
"@swc/core": "^1.16.22",
"@types/node": "^26.6.6",
"eslint": "^10.14.0",
"eslint": "^10.15.0",
"globals": "^17.14.0",
"parse5": "^8.1.1",
"typescript": "^7.0.2",
"vite": "^8.3.7"
"vite": "^8.3.8"
},
"engines": {
"node": ">=22.13"
Expand Down Expand Up @@ -95,5 +95,5 @@
},
"type": "module",
"types": "dist/types/htmlminifier.d.ts",
"version": "5.1.0"
"version": "5.2.0"
}
24 changes: 21 additions & 3 deletions src/lib/attributes.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
RE_BASE_ELEMENT,
RE_ATTR_WS_CHECK,
RE_ATTR_WS_COLLAPSE,
RE_TABS_ONLY,
RE_WS_CHAR,
generalDefaults,
tagDefaults,
Expand Down Expand Up @@ -751,11 +752,28 @@ const valueWhitespaceExemptElements = new Set(['button', 'data', 'input', 'optio

/**
* @param {string} value
* @param {string[]} [markers] - What custom fragment and `htmlmin:ignore` placeholders hold
* @returns {string}
*/
function collapseAttributeValue(value) {
function collapseAttributeValue(value, markers = []) {
// Fast path: Only process if whitespace exists (avoids regex overhead on clean values)
return RE_ATTR_WS_CHECK.test(value) ? trimWhitespace(value).replace(RE_ATTR_WS_COLLAPSE, ' ') : value;
if (!RE_ATTR_WS_CHECK.test(value)) {
return value;
}
value = trimWhitespace(value);
if (!markers.some(marker => value.indexOf(marker) !== -1)) {
return value.replace(RE_ATTR_WS_COLLAPSE, ' ');
}
// Placeholders come padded with a tab on each side, which restoring them drops; as spaces,
// they would stay, while other tabs (e.g., decoded ones) must become a space that stays
return value.replace(RE_ATTR_WS_COLLAPSE, (run, offset) => {
if (!RE_TABS_ONLY.test(run)) {
return ' ';
}
const paddingBefore = markers.some(marker => value.endsWith(marker, offset)) ? '\t' : '';
const paddingAfter = markers.some(marker => value.startsWith(marker, offset + run.length)) ? '\t' : '';
return run.length > paddingBefore.length + paddingAfter.length ? paddingBefore + ' ' + paddingAfter : run;
});
}

/**
Expand Down Expand Up @@ -791,7 +809,7 @@ function cleanAttributeValue(tag, attrName, attrValue, options, attrs, minifyHTM
// with `collapseWhitespace`; an `annotation-xml` encoding is matched as written,
// and with spaces taken out could make the element hold HTML
if (options.collapseAttributeWhitespace && !collapseAttributeWhitespaceExempt.has(attrName) && !(attrName === 'value' && valueWhitespaceExemptElements.has(tag)) && !(attrName.toLowerCase() === 'encoding' && tag.toLowerCase() === 'annotation-xml') && !isEventAttr) {
attrValue = collapseAttributeValue(attrValue);
attrValue = collapseAttributeValue(attrValue, markers);
}

if (isEventAttr) {
Expand Down
2 changes: 2 additions & 0 deletions src/lib/constants.js
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ const RE_DOCTYPE = /^<!doctype[ \n\r\t\f]*([^ \n\r\t\f>]+)[ \n\r\t\f]*(?:public[
const RE_DOCTYPE_SEGMENT = /("[^"]*(?:"|$)|'[^']*(?:'|$))|[^"']+/g;
const RE_ATTR_WS_CHECK = /[ \n\r\t\f]/;
const RE_ATTR_WS_COLLAPSE = /[ \n\r\t\f]+/g;
const RE_TABS_ONLY = /^\t+$/;
const RE_WS_CHAR = /\s/;
const RE_STYLE_ELEMENT = /<style[\s/>]/i;
const RE_STYLE_TAG = /<\/?style/gi;
Expand Down Expand Up @@ -460,6 +461,7 @@ export {
RE_DOCTYPE_SEGMENT,
RE_ATTR_WS_CHECK,
RE_ATTR_WS_COLLAPSE,
RE_TABS_ONLY,
RE_WS_CHAR,
RE_STYLE_ELEMENT,
RE_STYLE_TAG,
Expand Down
37 changes: 37 additions & 0 deletions test/html.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -7090,6 +7090,43 @@ describe('HTML', () => {
assert.strictEqual(await minify(input, { collapseAttributeWhitespace: true, minifyJS: true }), '<button onclick=\'alert("→ ←")\'>x</button>');
});

test('Collapses attribute whitespace without adding any around custom fragments', async () => {
const options = { collapseAttributeWhitespace: true };
for (const input of [
'<a href="/invoices/<?= $inv->id ?>?tab=lines">x</a>',
'<a data-path="/i/<?= $id ?>?t=1">x</a>',
'<a data-x="a<?= $a ?><?= $b ?>b">x</a>',
'<a data-x="a<% x %>b">x</a>',
'<a style="color:<?= $c ?>">x</a>'
]) {
assert.strictEqual(await minify(input, options), input);
assert.strictEqual(await minify(input, { ...options, collapseWhitespace: true }), input);
assert.strictEqual(await minify(input, { ...options, collapseWhitespace: true, trimCustomFragments: true }), input);
}

// Whitespace written next to a fragment stays (collapsed with `collapseWhitespace`)
let input = '<a data-x="a <?= $a ?>\n\tb">x</a>';
assert.strictEqual(await minify(input, options), input);
assert.strictEqual(await minify(input, { ...options, collapseWhitespace: true }), '<a data-x="a <?= $a ?> b">x</a>');

// Decoded whitespace next to a fragment stays, as one space
for (const [source, expected] of [
['<a data-x="a&#9;<?= $a ?>b">x</a>', '<a data-x="a <?= $a ?>b">x</a>'],
['<a data-x="a<?= $a ?>&#9;&#9;b">x</a>', '<a data-x="a<?= $a ?> b">x</a>'],
['<a data-x="a<?= $a ?>&#9;<?= $b ?>b">x</a>', '<a data-x="a<?= $a ?> <?= $b ?>b">x</a>']
]) {
assert.strictEqual(await minify(source, { ...options, decodeEntities: true }), expected);
assert.strictEqual(await minify(source, { ...options, decodeEntities: true, collapseWhitespace: true }), expected);
}

// Elsewhere, whitespace still collapses
input = '<a data-x=" a\t\tb <?= $a ?>c d ">x</a>';
assert.strictEqual(await minify(input, options), '<a data-x="a b <?= $a ?>c d">x</a>');

// The comprehensive preset, as reported
assert.strictEqual(await minify('<a href="/invoices/<?= $inv->id ?>?tab=lines">x</a>', { preset: 'comprehensive' }), '<a href="/invoices/<?= $inv->id ?>?tab=lines">x</a>');
});

test('Decodes entity characters', async () => {
let input, output;

Expand Down
Loading