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
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,17 @@ As of version 2.0.0, all notable changes to HTML Minifier Next (HMN) are documen

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).

## 2026-08-28

### Added

* Added a “Show invisibles” view to the web demo, marking no-break spaces, soft hyphens, and zero-width and control characters that the output otherwise shows as nothing (or as no more than a gap)
* Added a “Copy” button to the web demo, to allow to copy minified code
Comment thread
j9t marked this conversation as resolved.

### Changed

* Split the web demo’s URL state handling out into demo/lib/url-state.js, which puts the option migrations and the state it encodes under test without a page to run in

## [8.3.0] - 2026-08-27

### Added
Expand Down
42 changes: 40 additions & 2 deletions demo/default.css
Original file line number Diff line number Diff line change
Expand Up @@ -67,10 +67,20 @@ button {
padding: .25rem .5rem;
}

textarea + p {
textarea + p,
#output-view {
margin-top: 0;
}

i,
.invisible {
background: #ff0;
}

i {
font-style: normal;
}

code,
.quiet {
font-size: smaller;
Expand Down Expand Up @@ -99,6 +109,17 @@ code,
color: #f00;
}

.invisible {
border-radius: 2px;
}

.invisible.zero-width::after {
content: attr(data-marker);
font-size: .7em;
padding: 0 1px;
vertical-align: middle;
}

.alt {
margin-bottom: 1rem;
}
Expand All @@ -108,10 +129,27 @@ code,
}

#input,
#output {
#output,
#output-view {
/* Set explicitly, as form controls take neither the page’s font nor its box model—
the values match what browsers give a form control, so that the panes look and
measure the same whether they are one or not */
background: #fff;
border: 1px solid #767676;
box-sizing: border-box;
display: block;
font-size: .8333rem;
height: 36vh;
line-height: normal;
margin-bottom: 5px;
min-height: 15rem;
padding: 2px;
resize: vertical;
width: 100%;
}

#output-view {
overflow: auto;
overflow-wrap: anywhere;
white-space: pre-wrap;
}
135 changes: 48 additions & 87 deletions demo/default.js
Original file line number Diff line number Diff line change
@@ -1,15 +1,12 @@
import HTMLMinifier, { getPreset } from '../src/htmlminifier.js';
import { optionDefinitions } from '../src/lib/option-definitions.js';
import { paramCase } from '../src/lib/utils.js';
import { getOptions } from './get-options.js';
import { getOptions } from './lib/get-options.js';
import { annotateInvisibles } from './lib/show-invisibles.js';
import { decodeState, encodeState } from './lib/url-state.js';
import { escapeHtml } from './lib/utils.js';
import pkg from '../package.json' with { type: 'json' };

// Escape HTML entities for safe rendering inside `<code>` elements
const escapeHtml = (str) => str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');

// Option names by CLI flag, so that a flag in a description is shown as the option it
// names—recasing the flag would lose the case of acronyms (`minifyCSS`, `minifyJS`)
const optionNamesByFlag = new Map(Object.keys(optionDefinitions).map(key => [paramCase(key), key]));
Expand Down Expand Up @@ -215,91 +212,15 @@ const formatNumber = (num) => num.toLocaleString('en-US');
// URL state management
const MAX_URL_LENGTH = 2000; // Conservative limit for URL hash

// Option migration map for backward compatibility;
// when renaming options, add entries here to preserve old URLs
//
// Example: `{ 'oldOptionName': 'newOptionName' }`
//
// @@ Split the mapping out of `decodeState` (as `getOptions` was)
// (so that an entry silently loading the wrong options is caught by a test)
const OPTION_MIGRATIONS = {
customFragmentQuantifierLimit: null, // Removed in 8.0.0; discard from old URLs
html5: null, // Removed in 5.0.0; discard from old URLs
processConditionalComments: null, // Removed in 6.0.0; discard from old URLs
removeScriptTypeAttributes: 'removeDefaultTypeAttributes', // Merged in 7.0.0
removeStyleLinkTypeAttributes: 'removeDefaultTypeAttributes', // Merged in 7.0.0
sortClassName: 'sortClassNames'
};

const encodeState = (input, options) => {
const state = {
i: input || '',
o: {}
};

// Only store non-default options
options.forEach((option) => {
const defaultOption = defaultOptions.find(d => d.id === option.id);
if (!defaultOption) return;

if (option.type === 'checkbox') {
if (Boolean(option.checked) !== Boolean(defaultOption.checked)) {
state.o[option.id] = option.checked;
}
} else if (option.type === 'number') {
if (option.value !== defaultOption.value) {
state.o[option.id] = option.value;
}
} else if (option.type === 'text') {
if (option.value !== defaultOption.value) {
state.o[option.id] = option.value;
}
}
});

return LZString.compressToEncodedURIComponent(JSON.stringify(state));
};

const decodeState = (hash) => {
try {
const decompressed = LZString.decompressFromEncodedURIComponent(hash);
if (!decompressed) return null;
const state = JSON.parse(decompressed);

// Apply option migrations for backward compatibility
if (state.o) {
const migratedOptions = {};
for (const [key, value] of Object.entries(state.o)) {
if (key in OPTION_MIGRATIONS) {
// `null` means the option was removed; skip it
if (OPTION_MIGRATIONS[key]) {
const target = OPTION_MIGRATIONS[key];
migratedOptions[target] = migratedOptions[target] || value;
}
} else {
migratedOptions[key] = value;
}
}
state.o = migratedOptions;
}

return state;
} catch {
// Silently fail for invalid/corrupted URLs
// console.warn('Failed to decode URL state');
return null;
}
};

const loadStateFromUrl = () => {
const hash = window.location.hash.slice(1);
if (!hash) return null;

return decodeState(hash);
};

const updateUrlWithState = (input, options) => {
const encoded = encodeState(input, options);
const updateUrlWithState = (input, options, showInvisibles) => {
const encoded = encodeState({ input, options, defaultOptions, showInvisibles });
const url = `${window.location.origin}${window.location.pathname}#${encoded}`;

return {
Expand All @@ -314,12 +235,20 @@ const minifierData = () => ({
options: sillyClone(defaultOptions),
input: '',
output: '',
showInvisibles: false,
copied: '',
stats: {
result: '',
text: ''
},
share: '',
_shareTimeout: null,
_copiedTimeout: null,

// Output with every invisible character marked, for the “Show invisibles” view
get annotatedOutput() {
return annotateInvisibles(this.output);
},

init() {
// Load state from URL on page load
Expand All @@ -341,6 +270,8 @@ const minifierData = () => ({
});
}

this.showInvisibles = Boolean(state.s);

if (this.input) {
this.minify();
}
Expand Down Expand Up @@ -402,7 +333,7 @@ const minifierData = () => ({

this.share = '';

const result = updateUrlWithState(this.input, this.options);
const result = updateUrlWithState(this.input, this.options, this.showInvisibles);

if (result.success) {
// Update URL in browser
Expand All @@ -427,7 +358,7 @@ const minifierData = () => ({
}, 5000);
} else {
// Try without input (options only)
const optionsOnly = updateUrlWithState('', this.options);
const optionsOnly = updateUrlWithState('', this.options, this.showInvisibles);

if (optionsOnly.success) {
window.history.pushState(null, '', optionsOnly.url);
Expand Down Expand Up @@ -455,6 +386,36 @@ const minifierData = () => ({
}
},

// Copy the minified markup itself, which the “Show invisibles” view marks up to show
async copyOutput() {
if (!this.output) return;

if (!navigator.clipboard || !navigator.clipboard.writeText) {
this.flashCopied('⚠ Clipboard not supported, copy from the output pane');
return;
}

try {
await navigator.clipboard.writeText(this.output);
this.flashCopied('✓ Minified markup copied to clipboard');
} catch {
this.flashCopied('⚠ Could not copy, copy from the output pane');
}
},

// Show a message beside the Copy button, and take it back after a while
flashCopied(message, ms = 5000) {
if (this._copiedTimeout) {
clearTimeout(this._copiedTimeout);
}

this.copied = message;
this._copiedTimeout = setTimeout(() => {
this.copied = '';
this._copiedTimeout = null;
}, ms);
},

selectAllOptions(yes = true) {
this.options = this.options.map((option) => {
if (option.type !== 'checkbox' || option.disabled) {
Expand Down
14 changes: 12 additions & 2 deletions demo/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,23 @@
<h1>HTML Minifier Next <span id="minifier-version"></span></h1>
<main x-data="minifier" x-cloak>
<div class="workspace">
<textarea rows="10" cols="40" id="input" x-model="input"></textarea>
<textarea rows="10" cols="40" id="input" x-model="input" aria-label="Markup to minify"></textarea>
<div class="alt">
<button type="button" @click="minify()">Minify</button>
<button type="button" @click="shareUrl()">Share</button>
<span x-show="share" x-text="share" class="share"></span>
</div>
<textarea rows="10" cols="40" id="output" readonly x-model="output"></textarea>
<div class="form-group">
<input type="checkbox" id="show-invisibles" x-model="showInvisibles">
<label for="show-invisibles">Show <i>invisibles</i></label>
<span class="quiet">Mark no-break spaces, soft hyphens, and other characters that the output does not show</span>
</div>
<textarea rows="10" cols="40" id="output" readonly x-model="output" x-show="!showInvisibles" aria-label="Minified markup"></textarea>
<pre id="output-view" role="region" aria-label="Minified markup, invisible characters marked" tabindex="0" x-show="showInvisibles" x-html="annotatedOutput"></pre>
<div class="alt">
<button type="button" @click="copyOutput()">Copy output</button>
<span x-show="copied" x-text="copied" class="share"></span>
</div>
<p x-text="stats.text" :class="stats.result"></p>
</div>
<div class="options">
Expand Down
2 changes: 1 addition & 1 deletion demo/get-options.js → demo/lib/get-options.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { describeQuantifierRisk, parseRegExp } from '../src/lib/utils.js';
import { describeQuantifierRisk, parseRegExp } from '../../src/lib/utils.js';

// Turn the demo’s option controls into the options object the minifier takes,
// kept apart from the page so that it can be read without one
Expand Down
76 changes: 76 additions & 0 deletions demo/lib/show-invisibles.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { escapeHtml } from './utils.js';

// Mark characters that carry meaning but show nothing—or no more than a gap—so that
// the demo shows what options like `decodeEntities` and whitespace collapsing do to them,
// kept apart from the page so that it can be read without one
//
// The characters are written as escapes: spelled out, they would be as invisible in this
// file as they are in the demo

// Characters that take up room of their own, where a tint alone shows them
const SPACES = new Map([
['\u00A0', 'NO-BREAK SPACE'],
['\u1680', 'OGHAM SPACE MARK'],
['\u2000', 'EN QUAD'],
['\u2001', 'EM QUAD'],
['\u2002', 'EN SPACE'],
['\u2003', 'EM SPACE'],
['\u2004', 'THREE-PER-EM SPACE'],
['\u2005', 'FOUR-PER-EM SPACE'],
['\u2006', 'SIX-PER-EM SPACE'],
['\u2007', 'FIGURE SPACE'],
['\u2008', 'PUNCTUATION SPACE'],
['\u2009', 'THIN SPACE'],
['\u200A', 'HAIR SPACE'],
['\u202F', 'NARROW NO-BREAK SPACE'],
['\u205F', 'MEDIUM MATHEMATICAL SPACE'],
['\u3000', 'IDEOGRAPHIC SPACE']
]);

// Characters of no width at all, which need a marker to show up
const ZERO_WIDTH = new Map([
['\u00AD', ['SOFT HYPHEN', 'SHY']],
['\u0085', ['NEXT LINE', 'NEL']],
['\u180E', ['MONGOLIAN VOWEL SEPARATOR', 'MVS']],
['\u200B', ['ZERO WIDTH SPACE', 'ZWSP']],
['\u200C', ['ZERO WIDTH NON-JOINER', 'ZWNJ']],
['\u200D', ['ZERO WIDTH JOINER', 'ZWJ']],
['\u200E', ['LEFT-TO-RIGHT MARK', 'LRM']],
['\u200F', ['RIGHT-TO-LEFT MARK', 'RLM']],
['\u2028', ['LINE SEPARATOR', 'LS']],
['\u2029', ['PARAGRAPH SEPARATOR', 'PS']],
['\u2060', ['WORD JOINER', 'WJ']],
['\uFEFF', ['ZERO WIDTH NO-BREAK SPACE', 'BOM']]
]);

// Control characters, but for the tab, line feed, and carriage return that text is made of
const CONTROLS = '\\u0000-\\u0008\\u000B\\u000C\\u000E-\\u001F\\u007F-\\u009F';

/** @param {string} char */
const codePoint = (char) => `U+${(char.codePointAt(0) ?? 0).toString(16).toUpperCase().padStart(4, '0')}`;

/** @param {Iterable<string>} chars */
const classOf = (chars) => [...chars].map(char => `\\u${(char.codePointAt(0) ?? 0).toString(16).padStart(4, '0')}`).join('');

const RE_INVISIBLE = new RegExp(`[${CONTROLS}${classOf(SPACES.keys())}${classOf(ZERO_WIDTH.keys())}]`, 'g');

/**
* Wrap every invisible character in a span that names it, keeping the character itself so
* that the marked-up text still copies as what it is
*
* @param {unknown} str
* @returns {string}
*/
export const annotateInvisibles = (str) => {
if (typeof str !== 'string') return '';

return escapeHtml(str).replace(RE_INVISIBLE, (char) => {
const space = SPACES.get(char);
if (space) {
return `<span class="invisible" title="${codePoint(char)} ${space}">${char}</span>`;
}

const [name, marker] = ZERO_WIDTH.get(char) ?? ['CONTROL CHARACTER', codePoint(char)];
return `<span class="invisible zero-width" title="${codePoint(char)} ${name}" data-marker="${marker}">${char}</span>`;
});
};
Loading