html-minifier-setup 0.0.0-stage → 0.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.
package/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright 2026 Jens Oliver Meiert
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,112 @@
1
- # Temporary Holding Version
1
+ # HTML Minifier Setup (Preview)
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ [![npm version](https://img.shields.io/npm/v/html-minifier-setup.svg)](https://www.npmjs.com/package/html-minifier-setup) [![Build status](https://github.com/j9t/html-minifier-setup/workflows/Tests/badge.svg)](https://github.com/j9t/html-minifier-setup/actions) [![Socket](https://badge.socket.dev/npm/package/html-minifier-setup)](https://socket.dev/npm/package/html-minifier-setup) [![GitHub Sponsors](https://badgen.net/static/Support/Open%20Source/cyan)](https://github.com/sponsors/j9t)
4
+
5
+ HTML Minifier Setup (HMS) helps you select the [HTML Minifier](https://www.npmjs.com/package/html-minifier) configuration that saves the most on your pages without changing how they render. It evaluates every option on a sample of your pages, sets aside those that don’t fit—i.e., that would change something your pages rely on—as well as those that save nothing, and provides a custom configuration for HTML Minifier.
6
+
7
+ It also reports what each option saves, which is useful on its own: Which options pay off on your site, and which don’t?
8
+
9
+ ℹ️ **This is an early release. Options, output, and API may change before 1.0.0. Feedback is welcome.**
10
+
11
+ ## Usage
12
+
13
+ ```shell
14
+ npx html-minifier-setup [inputs...] [options]
15
+ ```
16
+
17
+ (Like HTML Minifier, HTML Minifier Setup requires Node.js ≥22.13.)
18
+
19
+ Inputs are HTML files, directories, or URLs (default: the current directory). From directories, up to 20 pages (`--sample`) are picked, spread across subdirectories to cover a site’s templates.
20
+
21
+ The render check, which catches most of what static checks can’t, uses Playwright’s Chromium, or else Chrome or Edge, if installed. Where none is found, HTML Minifier Setup offers to download Chromium.
22
+
23
+ Local pages are served to the browser from their folder. Symbolic links that lead outside it are followed only if you agree (or pass `--follow-links`), as a page’s scripts could send on what they lead to. Where nobody can answer, they aren’t followed.
24
+
25
+ On a terminal, HTML Minifier Setup then offers to save the recommended configuration (as html-minifier.config.json, in the project of the pages evaluated or a folder of your choice) and, where it isn’t installed yet, to install HTML Minifier there (with the package manager the project uses), or to update it where a newer version is out. All offers default to no. Where one of HTML Minifier’s presets with a few changes has the same effect, it also offers to save the configuration that way—shorter, and following changes to the preset.
26
+
27
+ ```shell
28
+ # Evaluate a build directory and write the configuration to html-minifier.config.json
29
+ npx html-minifier-setup ./dist -o
30
+
31
+ # Use it with HTML Minifier, which picks up html-minifier.config.json from the working directory
32
+ npx html-minifier --input-dir ./dist --output-dir ./dist
33
+ ```
34
+
35
+ | Option | Description |
36
+ | --- | --- |
37
+ | `-c, --config-file <file>` | Start from an HTML Minifier configuration (JSON or module; defaults to html-minifier.config.json in the working directory, if present): Options on how input is read (e.g., `ignoreCustomFragments`, `caseSensitive`, `continueOnParseError`) are carried over; values for candidates (e.g., `minifyJS: {"engine": "swc"}`) are evaluated instead of `true`; and `fileExt`, `ignoreDir`, and cache sizes are kept, with `fileExt` and `ignoreDir` also applying to directories read |
38
+ | `-o, --output [file]` | Write the configuration to a file (default: html-minifier.config.json) rather than print it |
39
+ | `-m, --metric <metric>` | Size that decides whether an option gains: `raw`, `gzip` (default), or `brotli`, compressed at Gzip level 6 and Brotli quality 6, common defaults for on-the-fly compression (as in HTML Minifier’s benchmarks) |
40
+ | `-s, --sample <count>` | Pages to evaluate at most (default: 20) |
41
+ | `--file-ext <list>` | File extensions to read in directories, `*` for all files (default: `html,htm,shtml,shtm`, as HTML Minifier, or the configuration’s `fileExt`) |
42
+ | `--ignore-dir <list>` | Directory names to skip (default: `node_modules`, or the configuration’s `ignoreDir`) |
43
+ | `--keep <list>` | Options to recommend regardless of risks and gains |
44
+ | `--skip <list>` | Options not to evaluate |
45
+ | `--no-render` | Skip the render check, not recommending options that need it |
46
+ | `--no-equivalence` | Skip HTML Minifier’s equivalence check |
47
+ | `--no-presets` | Skip comparing with HTML Minifier’s presets |
48
+ | `--viewports <list>` | Viewports to render at (default: `1280x800,390x844`) |
49
+ | `--concurrency <count>` | Pages to render at once, across viewports and options (default: 4) |
50
+ | `--follow-links` | Follow symbolic links outside the site in the render check, without asking (do so only for sites you trust) |
51
+ | `--json` | Print the full result as JSON |
52
+ | `-v, --verbose` | Show every page and viewport in details (rather than a summary with an example), more notes, and progress line by line where STDERR isn’t a terminal |
53
+
54
+ ### Programmatic use
55
+
56
+ HTML Minifier Setup is ESM-only.
57
+
58
+ ```js
59
+ import { createRenderer, customize, loadPages, toConfig } from 'html-minifier-setup';
60
+
61
+ const pages = await loadPages(['./dist']);
62
+ const { renderer } = await createRenderer();
63
+ try {
64
+ const result = await customize(pages, { renderer, metric: 'brotli' });
65
+ console.log(toConfig(result));
66
+ } finally {
67
+ await renderer?.close();
68
+ }
69
+ ```
70
+
71
+ ## How it works
72
+
73
+ 1. **Each option on its own:** Every candidate option is run on every page and measured against output without it. Options that change nothing (“no effect”), or don’t save anything by the chosen metric (“no gain”), are left out. What an option changes is then checked against what the page relies on (see below).
74
+ 2. **All recommended options together, rendered:** Pages are rendered as written and as minified, in Chromium (or Chrome or Edge) at each viewport, and compared by screenshot, text, and script errors. Where rendering differs, options are rendered on their own to find the culprit; where none breaks rendering on its own, options are added by savings until rendering holds.
75
+ 3. **Contributions:** Each option’s share of the final savings is measured by leaving it out. Options that save nothing alongside the others are dropped.
76
+ 4. **Presets:** HTML Minifier’s presets (`conservative` and `comprehensive`) are measured and rendered on the same pages, naming options they include that don’t fit, to show what the recommended configuration gains over them.
77
+
78
+ Options that need another (e.g., `removeUnusedCSS` needs `minifyCSS`) are evaluated on top of it. Where the option needed is safe but saves nothing alone, both are evaluated together, and recommended together.
79
+
80
+ Where full whitespace collapsing breaks a page, HTML Minifier Setup falls back to `conservativeCollapse`, and then to `preserveLineBreaks`.
81
+
82
+ ### Checks
83
+
84
+ | Check | Catches | Needs |
85
+ | --- | --- | --- |
86
+ | Static | What scripts and style sheets of the page (including linked local or same-origin ones) rely on: comments that frameworks read (e.g., Knockout, Lit, hydration markers), empty elements scripts fill, classes scripts add after `removeUnusedCSS` removed their rules, attributes that style sheets or scripts select on, and CSS that keeps whitespace | Nothing |
87
+ | Render | Anything that changes what the page looks like on load, or adds script errors | Chromium, Chrome, or Edge |
88
+ | Equivalence | HTML Minifier bugs, as HTML Minifier’s own check finds output that means something else to browsers than its source—for the options that check models | An HTML Minifier checkout next to this one (for now) |
89
+
90
+ In the report, options are **recommended**, **kept** (as asked), or not used because they **don’t fit** these pages, have **no gain** by the chosen metric, have **no effect**, or were **skipped**. With `--json`, statuses are `accepted`, `kept`, `rejected`, `no-gain`, `no-effect`, and `skipped`.
91
+
92
+ Options flagged by the static check as a **risk** don’t fit and aren’t recommended. Options flagged for a **check** (e.g., whitespace collapsing where CSS keeps whitespace) are recommended only if they render the same—without a render check, they’re rejected. **Notes** don’t reject.
93
+
94
+ Options that make pages look different are mostly doing what they’re designed to do—collapsing whitespace changes spacing where a layout relies on it—so they don’t fit these pages, without HTML Minifier being at fault. Where an option that isn’t meant to change how pages look does, the report suggests it may be an HTML Minifier bug worth reporting. The configuration recommended looks the same as the original pages at every viewport checked.
95
+
96
+ ### Limits
97
+
98
+ The checks can show that options change pages, not that they never will:
99
+
100
+ * The render check sees pages as they load, not after interaction (e.g., a menu a script opens, using classes `removeUnusedCSS` removed).
101
+ * Scripts from other origins can’t be read, and classes or attributes a script builds at runtime (e.g., `'is-' + state`) can’t be found.
102
+ * Rendering must match pixel for pixel, so lossy changes (e.g., SVG coordinates rounded by `minifySVG`) count as differences.
103
+ * A page sample may miss templates the rest of the site uses.
104
+ * Options that have no effect can’t be judged: On pages that are minified already, most options are left out, as there’s nothing left for them to do. Run HTML Minifier Setup on pages before minification instead.
105
+
106
+ Test the configuration on your site before deploying it.
107
+
108
+ Options that newer versions of HTML Minifier add are evaluated once HTML Minifier Setup knows them; until then, the report names them.
109
+
110
+ Options HTML Minifier can’t express in one configuration are not evaluated: `minifyURLs` depends on each page’s URL, and `removeTagWhitespace` produces invalid HTML.
111
+
112
+ Large pages take longer, especially by `brotli`.
package/cli.js ADDED
@@ -0,0 +1,199 @@
1
+ #!/usr/bin/env node
2
+
3
+ import fs from 'node:fs';
4
+ import path from 'node:path';
5
+ import readline from 'node:readline/promises';
6
+ import { pathToFileURL } from 'node:url';
7
+ import { Command, Option } from 'commander';
8
+ import { splitConfig } from './src/catalog.js';
9
+ import { paint, supportsColor } from './src/color.js';
10
+ import { loadEquivalence } from './src/equivalence.js';
11
+ import { METRICS } from './src/measure.js';
12
+ import { EXTENSIONS_DEFAULT, findOutsideLinks, loadPages } from './src/pages.js';
13
+ import { createProgress } from './src/progress.js';
14
+ import { CONCURRENCY_DEFAULT, createRenderer, installChromium, VIEWPORTS_DEFAULT } from './src/render.js';
15
+ import { formatReport, toConfig, toPresetConfig } from './src/report.js';
16
+ import { customize } from './src/select.js';
17
+ import { CONFIG_FILE as CONFIG_FILE_DEFAULT, fetchLatestVersion, isYes, offerSetup, projectOf } from './src/setup.js';
18
+
19
+ let progress = createProgress();
20
+
21
+ const { version } = JSON.parse(fs.readFileSync(new URL('package.json', import.meta.url), 'utf8'));
22
+
23
+ /** @param {unknown} value */
24
+ const list = (value) => (Array.isArray(value) ? value.map(String) : String(value).split(',')).map((item) => item.trim()).filter(Boolean);
25
+
26
+ /** @param {string} value */
27
+ const positiveInteger = (value) => {
28
+ const number = Number(value);
29
+ if (!Number.isInteger(number) || number < 1) throw new Error(`Not a positive integer: ${value}`);
30
+ return number;
31
+ };
32
+
33
+ /** @param {string} value */
34
+ const viewports = (value) => {
35
+ const items = list(value);
36
+ if (!items.length) throw new Error('No viewports given (e.g., 1280x800,390x844)');
37
+ return items.map((item) => {
38
+ const match = item.match(/^([1-9]\d*)x([1-9]\d*)$/);
39
+ if (!match) throw new Error(`Not a viewport (e.g., 1280x800): ${item}`);
40
+ return { width: Number(match[1]), height: Number(match[2]) };
41
+ });
42
+ };
43
+
44
+ /**
45
+ * @param {string} file
46
+ * @returns {Promise<Record<string, unknown>>}
47
+ */
48
+ async function readConfig(file) {
49
+ const resolved = path.resolve(file);
50
+ if (/\.[cm]?js$/.test(resolved)) {
51
+ const module = await import(pathToFileURL(resolved).href);
52
+ return module.default ?? module;
53
+ }
54
+ return JSON.parse(await fs.promises.readFile(resolved, 'utf8'));
55
+ }
56
+
57
+ const program = new Command()
58
+ .name('html-minifier-setup')
59
+ .description('Identifies the HTML Minifier options that save the most on your pages without changing how they render')
60
+ .version(version)
61
+ .argument('[inputs...]', 'HTML files, directories, or URLs', ['.'])
62
+ .option('-c, --config-file <file>', `Start from an HTML Minifier configuration (JSON or module), carrying over its input handling options (defaults to ${CONFIG_FILE_DEFAULT} in the working directory, if present)`)
63
+ .option('-o, --output [file]', `Write the configuration to a file (default: ${CONFIG_FILE_DEFAULT}) rather than print it`)
64
+ .addOption(new Option('-m, --metric <metric>', 'Size that decides whether an option gains').choices(METRICS).default('gzip'))
65
+ .option('-s, --sample <count>', 'Pages to evaluate at most, spread across directories', positiveInteger, 20)
66
+ .option('--file-ext <list>', 'File extensions to read in directories (`*` for all files)', list, EXTENSIONS_DEFAULT)
67
+ .option('--ignore-dir <list>', 'Directory names to skip', list, ['node_modules'])
68
+ .option('--keep <list>', 'Options to recommend regardless of risks and gains', list, [])
69
+ .option('--skip <list>', 'Options not to evaluate', list, [])
70
+ .option('--no-render', 'Skip the render check (not recommending options that need it)')
71
+ .option('--no-equivalence', 'Skip HTML Minifier’s equivalence check')
72
+ .option('--no-presets', 'Skip comparing with HTML Minifier’s presets')
73
+ .option('--viewports <list>', 'Viewports to render at', viewports, VIEWPORTS_DEFAULT)
74
+ .option('--concurrency <count>', 'Pages to render at once', positiveInteger, CONCURRENCY_DEFAULT)
75
+ .option('--follow-links', 'Follow symbolic links outside the site in the render check, without asking (do so only for sites you trust)')
76
+ .option('--json', 'Print the full result as JSON')
77
+ .option('-v, --verbose', 'Show every page and viewport in details, progress line by line where STDERR isn’t a terminal, and more notes')
78
+ .action(async (inputs, settings, command) => {
79
+ if (settings.verbose) progress = createProgress({ verbose: true });
80
+ const interactive = Boolean(process.stdin.isTTY && process.stdout.isTTY) && !settings.output && !settings.json;
81
+ // Asked while pages are evaluated, for the offers at the end
82
+ const latest = interactive && !process.env.CI && !process.env.NO_UPDATE_NOTIFIER ? fetchLatestVersion('html-minifier') : null;
83
+
84
+ // As HTML Minifier does, a configuration in the working directory is used where none is given
85
+ let fileConfig = settings.configFile;
86
+ if (!fileConfig && fs.existsSync(path.resolve(CONFIG_FILE_DEFAULT))) {
87
+ fileConfig = CONFIG_FILE_DEFAULT;
88
+ progress.log(`Using config file ${CONFIG_FILE_DEFAULT}`);
89
+ }
90
+ const { base, values, preserved, ignored } = splitConfig(fileConfig ? await readConfig(fileConfig) : {});
91
+ if (ignored.length && settings.verbose) progress.log(`Not carried from ${fileConfig}: ${ignored.join(', ')}`);
92
+ /** @param {'fileExt' | 'ignoreDir'} name */
93
+ const fromConfig = (name) => command.getOptionValueSource(name) === 'default' && preserved[name] !== undefined ? list(preserved[name]) : settings[name];
94
+
95
+ progress.update({ phase: 'read', detail: inputs.join(', ') });
96
+ const pages = await loadPages(inputs, { extensions: fromConfig('fileExt'), ignore: fromConfig('ignoreDir'), sample: settings.sample });
97
+ if (!pages.length) throw new Error(`No pages found in ${inputs.join(', ')}`);
98
+
99
+ const followLinks = settings.render && (settings.followLinks || (interactive && await confirmLinks(pages, fromConfig('ignoreDir'))));
100
+ const settingsRenderer = { viewports: settings.viewports, concurrency: settings.concurrency, followLinks };
101
+ if (settings.render) progress.update({ phase: 'start' });
102
+ let rendering = settings.render ? await createRenderer(settingsRenderer) : { renderer: null, reason: 'skipped as asked' };
103
+ if ('missing' in rendering && rendering.missing && interactive) {
104
+ progress.stop({ ok: false });
105
+ const download = await withPrompt(async (ask) => isYes(await ask('No browser found for the render check. Download Chromium (about 150 MB)? (y/N) ')));
106
+ if (download && await installChromium()) {
107
+ progress.update({ phase: 'start' });
108
+ rendering = await createRenderer(settingsRenderer);
109
+ }
110
+ }
111
+ const { renderer, reason } = rendering;
112
+ const equivalence = settings.equivalence ? await loadEquivalence() : null;
113
+ try {
114
+ const result = await customize(pages, {
115
+ base,
116
+ values,
117
+ metric: settings.metric,
118
+ keep: settings.keep,
119
+ skip: settings.skip,
120
+ renderer,
121
+ equivalence,
122
+ presets: settings.presets,
123
+ onProgress: progress.update
124
+ });
125
+ // Sets the finished phases apart from the report
126
+ if (progress.stop() && process.stdout.isTTY) console.log('');
127
+ if (settings.json) {
128
+ console.log(JSON.stringify(result, null, 2));
129
+ return;
130
+ }
131
+ const color = supportsColor(process.stdout);
132
+ console.log(formatReport(result, { renderReason: reason, color, verbose: settings.verbose }));
133
+ const config = toConfig(result, preserved);
134
+ if (settings.output) {
135
+ const fileOutput = settings.output === true ? CONFIG_FILE_DEFAULT : settings.output;
136
+ await fs.promises.writeFile(fileOutput, `${JSON.stringify(config, null, 2)}\n`);
137
+ console.log(`\nWrote ${fileOutput}`);
138
+ return;
139
+ }
140
+ console.log(`\n${paint('bold', 'Recommended configuration:', color)}\n${JSON.stringify(config, null, 2)}\n`);
141
+ if (interactive) await promptSetup(config, toPresetConfig(result, preserved), latest, projectOf(inputs, process.cwd()));
142
+ } finally {
143
+ await renderer?.close();
144
+ }
145
+ });
146
+
147
+ /**
148
+ * Whether to follow symbolic links outside the site, as a page’s scripts could send on what they
149
+ * lead to: asked where any are found (the report names files not gotten either way)
150
+ * @param {import('./src/pages.js').Page[]} pages
151
+ * @param {string[]} ignore
152
+ */
153
+ async function confirmLinks(pages, ignore) {
154
+ const roots = [...new Set(pages.flatMap(({ root }) => root ? [root] : []))];
155
+ const links = (await Promise.all(roots.map((root) => findOutsideLinks(root, ignore)))).flat()
156
+ .map(({ link, target }) => ({ link: path.relative(process.cwd(), link), target }));
157
+ if (!links.length) return false;
158
+ progress.stop();
159
+ const named = `${links.slice(0, 3).map(({ link, target }) => `${link} → ${target}`).join(', ')}${links.length > 3 ? `, and ${links.length - 3} more` : ''}`;
160
+ return withPrompt(async (ask) => isYes(await ask(`The site links to files outside it (${named}). Follow these links in the render check? Only do so for sites you trust, as their scripts could send on what the links lead to (y/N) `)));
161
+ }
162
+
163
+ /**
164
+ * Asks on the terminal whether to save the configuration and install HTML Minifier
165
+ * @param {Record<string, unknown>} config
166
+ * @param {ReturnType<typeof toPresetConfig>} configPreset
167
+ * @param {Promise<string | null> | null} latest
168
+ * @param {string} dirProject
169
+ */
170
+ async function promptSetup(config, configPreset, latest, dirProject) {
171
+ await withPrompt((ask) => offerSetup({ config, configPreset, latest, dirProject, cwd: process.cwd(), ask, log: (message) => console.log(message) }));
172
+ }
173
+
174
+ /**
175
+ * Runs `fn` with a way to ask questions on the terminal
176
+ * @template T
177
+ * @param {(ask: (question: string) => Promise<string>) => Promise<T>} fn
178
+ * @returns {Promise<T>}
179
+ */
180
+ async function withPrompt(fn) {
181
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
182
+ rl.on('SIGINT', () => {
183
+ rl.close();
184
+ process.exit(130);
185
+ });
186
+ try {
187
+ return await fn((question) => rl.question(question));
188
+ } finally {
189
+ rl.close();
190
+ }
191
+ }
192
+
193
+ try {
194
+ await program.parseAsync();
195
+ } catch (err) {
196
+ progress.stop({ ok: false });
197
+ console.error(`Error: ${err instanceof Error ? err.message : err}`);
198
+ process.exitCode = 1;
199
+ }
package/package.json CHANGED
@@ -1,6 +1,60 @@
1
1
  {
2
+ "author": "Jens Oliver Meiert",
3
+ "bin": {
4
+ "hms": "cli.js",
5
+ "html-minifier-setup": "cli.js"
6
+ },
7
+ "bugs": "https://github.com/j9t/html-minifier-setup/issues",
8
+ "dependencies": {
9
+ "commander": "^15.1.0",
10
+ "html-minifier": "^5.1.0",
11
+ "parse5": "^8.1.1",
12
+ "playwright": "^1.65.0"
13
+ },
14
+ "description": "Configuration wizard for HTML Minifier: Identify the options that save the most on your pages without changing how they render",
15
+ "devDependencies": {
16
+ "@eslint/js": "^10.0.1",
17
+ "@types/node": "^26.6.6",
18
+ "eslint": "^10.14.0",
19
+ "globals": "^17.14.0",
20
+ "typescript": "^7.0.2"
21
+ },
22
+ "engines": {
23
+ "node": ">=22.13"
24
+ },
25
+ "exports": {
26
+ ".": "./src/index.js",
27
+ "./package.json": "./package.json"
28
+ },
29
+ "files": [
30
+ "cli.js",
31
+ "src"
32
+ ],
33
+ "funding": "https://github.com/j9t/html-minifier-setup?sponsor=1",
34
+ "homepage": "https://github.com/j9t/html-minifier-setup",
35
+ "keywords": [
36
+ "configuration",
37
+ "html",
38
+ "html-minifier",
39
+ "minification",
40
+ "minifier",
41
+ "optimization",
42
+ "setup"
43
+ ],
44
+ "license": "MIT",
2
45
  "name": "html-minifier-setup",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
46
+ "repository": {
47
+ "type": "git",
48
+ "url": "git+https://github.com/j9t/html-minifier-setup.git"
49
+ },
50
+ "scripts": {
51
+ "benchmark": "node benchmark/benchmark.js",
52
+ "lint": "eslint .",
53
+ "prepare": "git config core.hooksPath .githooks || true",
54
+ "test": "npm run test:types && node --test \"test/**/*.test.js\"",
55
+ "test:types": "tsc",
56
+ "test:watch": "node --test --watch \"test/**/*.test.js\""
57
+ },
58
+ "type": "module",
59
+ "version": "0.1.0"
6
60
  }
package/src/catalog.js ADDED
@@ -0,0 +1,158 @@
1
+ // How each HTML Minifier option takes part in finding a configuration; a test checks that every
2
+ // option of the installed version has a place here, and the report notes any that don’t
3
+
4
+ import fs from 'node:fs';
5
+ import path from 'node:path';
6
+ import { fileURLToPath } from 'node:url';
7
+
8
+ /**
9
+ * @typedef {object} Candidate
10
+ * @prop {string} name
11
+ * @prop {Record<string, unknown>} options What evaluating the candidate sets
12
+ * @prop {string} [requires] Candidate that must be accepted for this one to take effect
13
+ */
14
+
15
+ /** @type {Candidate[]} */
16
+ export const CANDIDATES = [
17
+ { name: 'collapseWhitespace', options: { collapseWhitespace: true } },
18
+ { name: 'collapseAttributeWhitespace', options: { collapseAttributeWhitespace: true } },
19
+ { name: 'collapseBooleanAttributes', options: { collapseBooleanAttributes: true } },
20
+ { name: 'collapseEmptyAttributes', options: { collapseEmptyAttributes: true } },
21
+ { name: 'decodeEntities', options: { decodeEntities: true } },
22
+ { name: 'mergeScripts', options: { mergeScripts: true } },
23
+ { name: 'mergeStyles', options: { mergeStyles: true } },
24
+ { name: 'minifyCSS', options: { minifyCSS: true } },
25
+ { name: 'minifyJS', options: { minifyJS: true } },
26
+ { name: 'minifyJSON', options: { minifyJSON: true } },
27
+ { name: 'minifySVG', options: { minifySVG: true } },
28
+ { name: 'removeAttributeQuotes', options: { removeAttributeQuotes: true } },
29
+ { name: 'removeComments', options: { removeComments: true } },
30
+ { name: 'removeDefaultTypeAttributes', options: { removeDefaultTypeAttributes: true } },
31
+ { name: 'removeEmptyAttributes', options: { removeEmptyAttributes: true } },
32
+ { name: 'removeEmptyElements', options: { removeEmptyElements: true } },
33
+ { name: 'removeOptionalTags', options: { removeOptionalTags: true } },
34
+ { name: 'removeRedundantAttributes', options: { removeRedundantAttributes: true } },
35
+ { name: 'selfCloseForeignElements', options: { selfCloseForeignElements: true } },
36
+ { name: 'sortAttributes', options: { sortAttributes: true } },
37
+ { name: 'sortClassNames', options: { sortClassNames: true } },
38
+ { name: 'useShortDoctype', options: { useShortDoctype: true } },
39
+ { name: 'collapseInlineTagWhitespace', options: { collapseInlineTagWhitespace: true }, requires: 'collapseWhitespace' },
40
+ { name: 'collapseNoBreakSpaces', options: { collapseNoBreakSpaces: true }, requires: 'collapseWhitespace' },
41
+ { name: 'trimCustomFragments', options: { trimCustomFragments: true }, requires: 'collapseWhitespace' },
42
+ { name: 'removeUnusedCSS', options: { removeUnusedCSS: true }, requires: 'minifyCSS' }
43
+ ];
44
+
45
+ /**
46
+ * Gentler variants, tried in order where a candidate is rejected for what it changes
47
+ * @type {Record<string, {variant: string, options: Record<string, unknown>}[]>}
48
+ */
49
+ export const FALLBACKS = {
50
+ collapseWhitespace: [
51
+ { variant: 'conservativeCollapse', options: { collapseWhitespace: true, conservativeCollapse: true } },
52
+ { variant: 'preserveLineBreaks', options: { collapseWhitespace: true, conservativeCollapse: true, preserveLineBreaks: true } }
53
+ ]
54
+ };
55
+
56
+ // Only set through fallbacks
57
+ export const MODIFIERS = ['conservativeCollapse', 'preserveLineBreaks'];
58
+
59
+ // How input is read and output written, which a starting configuration knows best
60
+ export const CARRIED = [
61
+ 'caseSensitive',
62
+ 'continueOnMinifyError',
63
+ 'continueOnParseError',
64
+ 'customAttrAssign',
65
+ 'customAttrCollapse',
66
+ 'customAttrSurround',
67
+ 'customEventAttributes',
68
+ 'ignoreCustomComments',
69
+ 'ignoreCustomFragments',
70
+ 'includeAutoGeneratedTags',
71
+ 'inlineCustomElements',
72
+ 'keepClosingSlash',
73
+ 'maxInputLength',
74
+ 'maxLineLength',
75
+ 'noNewlinesBeforeTagClose',
76
+ 'partialMarkup',
77
+ 'preventAttributesEscaping',
78
+ 'processScripts',
79
+ 'quoteCharacter',
80
+ 'removeEmptyElementsExcept',
81
+ 'strictCustomFragments'
82
+ ];
83
+
84
+ // HTML Minifier CLI settings, which don’t change output but are kept when writing a configuration
85
+ export const PRESERVED = ['cacheCSS', 'cacheJS', 'cacheSVG', 'fileExt', 'ignoreDir'];
86
+
87
+ // What options can change on the page by design, where they look different
88
+ /** @type {Record<string, string>} */
89
+ export const VISIBLE_BY_DESIGN = {
90
+ collapseInlineTagWhitespace: 'Removes whitespace between inline elements, which can change the spacing between them',
91
+ collapseNoBreakSpaces: 'Collapses non-breaking spaces, which can change spacing',
92
+ collapseWhitespace: 'Collapses whitespace, which can change spacing where layout relies on it (e.g., between inline elements)',
93
+ minifySVG: 'Rounds numbers in SVG, which can shift shapes by fractions of a pixel',
94
+ removeEmptyElements: 'Removes empty elements, which can take up space or carry styling',
95
+ removeUnusedCSS: 'Removes CSS rules for elements not in the markup, which can include elements other markup or scripts add',
96
+ trimCustomFragments: 'Trims whitespace around custom fragments, which can change spacing',
97
+ useShortDoctype: 'Replaces the doctype, which can switch the document mode (e.g., from quirks mode to standards mode)'
98
+ };
99
+
100
+ /** @type {Record<string, string>} */
101
+ export const EXCLUDED = {
102
+ $schema: 'set in the output',
103
+ minifyURLs: 'depends on each page’s URL, which one configuration can’t express',
104
+ preset: 'candidates are evaluated one by one instead',
105
+ removeTagWhitespace: 'produces invalid HTML'
106
+ };
107
+
108
+ const CLASSIFIED = new Set([...CANDIDATES.map((candidate) => candidate.name), ...MODIFIERS, ...CARRIED, ...PRESERVED, ...Object.keys(EXCLUDED)]);
109
+
110
+ /**
111
+ * Options without a place here
112
+ * @param {string[]} options
113
+ */
114
+ export const unclassified = (options) => options.filter((name) => !CLASSIFIED.has(name));
115
+
116
+ /**
117
+ * The installed HTML Minifier’s version and options, by its schema, if readable
118
+ * @returns {{version: string, options: string[]} | null}
119
+ */
120
+ export function readSchema() {
121
+ try {
122
+ const filePackage = fileURLToPath(import.meta.resolve('html-minifier/package.json'));
123
+ const { version } = JSON.parse(fs.readFileSync(filePackage, 'utf8'));
124
+ const schema = JSON.parse(fs.readFileSync(path.join(path.dirname(filePackage), 'html-minifier.schema.json'), 'utf8'));
125
+ return { version, options: Object.keys(schema.properties ?? {}) };
126
+ } catch {
127
+ return null;
128
+ }
129
+ }
130
+
131
+ /**
132
+ * Splits a starting configuration into what is carried into every run, candidate values to
133
+ * evaluate (other than `true`), CLI settings to keep, and what is dropped
134
+ * @param {Record<string, unknown>} config
135
+ */
136
+ export function splitConfig(config) {
137
+ /** @type {Record<string, unknown>} */
138
+ const base = {};
139
+ /** @type {Record<string, unknown>} */
140
+ const values = {};
141
+ /** @type {Record<string, unknown>} */
142
+ const preserved = {};
143
+ /** @type {string[]} */
144
+ const ignored = [];
145
+ const names = new Set(CANDIDATES.map((candidate) => candidate.name));
146
+ for (const [name, value] of Object.entries(config)) {
147
+ if (CARRIED.includes(name)) {
148
+ base[name] = value;
149
+ } else if (PRESERVED.includes(name)) {
150
+ preserved[name] = value;
151
+ } else if (names.has(name) && value !== false && value !== true) {
152
+ values[name] = value;
153
+ } else if (!names.has(name) || value === false) {
154
+ ignored.push(name);
155
+ }
156
+ }
157
+ return { base, values, preserved, ignored };
158
+ }
package/src/color.js ADDED
@@ -0,0 +1,17 @@
1
+ // Terminal styles from the terminal’s own palette, which themes adapt to light and dark: standard
2
+ // colors, bold, and dim only—never fixed black, white, or 256-color values
3
+
4
+ export const ESC = '\u001b';
5
+ export const CSI = `${ESC}[`;
6
+
7
+ const STYLES = { bold: '1', dim: '2', accent: '36', success: '32', warning: '33', failure: '31' };
8
+
9
+ /**
10
+ * @param {keyof typeof STYLES} name
11
+ * @param {string} text
12
+ * @param {boolean} color
13
+ */
14
+ export const paint = (name, text, color) => color && text ? `${CSI}${STYLES[name]}m${text}${CSI}0m` : text;
15
+
16
+ /** @param {{isTTY?: boolean}} stream */
17
+ export const supportsColor = (stream) => Boolean(stream.isTTY) && !process.env.NO_COLOR && process.env.TERM !== 'dumb';
@@ -0,0 +1,68 @@
1
+ // HTML Minifier’s own check that output means the same to browsers as its source, which catches
2
+ // minifier bugs rather than what a site relies on—used where available, never duplicated here
3
+
4
+ import fs from 'node:fs';
5
+ import { fileURLToPath, pathToFileURL } from 'node:url';
6
+
7
+ /**
8
+ * @typedef {object} Equivalence
9
+ * @prop {string} source Where the check came from
10
+ * @prop {(name: string) => boolean} supports Whether the check models what an option changes—others would show as differences
11
+ * @prop {(input: string, output: string, options: Record<string, unknown>) => string[]} compare Differences found, if any
12
+ */
13
+
14
+ // @@ Import `html-minifier/equivalence` alone once HTML Minifier exports its check
15
+ const SPECIFIER_EXPORT = 'html-minifier/equivalence';
16
+ const FILE_SIBLING = fileURLToPath(new URL('../../html-minifier/test/equivalence/compare.js', import.meta.url));
17
+ // Lists the options the check is run with
18
+ const FILE_SIBLING_RUNNER = fileURLToPath(new URL('../../html-minifier/test/equivalence/runner.js', import.meta.url));
19
+
20
+ /**
21
+ * @param {unknown} difference
22
+ * @returns {string}
23
+ */
24
+ function describe(difference) {
25
+ if (difference && typeof difference === 'object' && 'kind' in difference) {
26
+ const { kind, input, output } = /** @type {{kind: string, input?: string, output?: string}} */ (difference);
27
+ const clip = (/** @type {string | undefined} */ text) => (text ?? '').split('\u0001').join(' ').slice(0, 80);
28
+ return `${kind}: ${clip(input)} → ${clip(output)}`;
29
+ }
30
+ return String(difference);
31
+ }
32
+
33
+ /** @param {URL} url */
34
+ function versionOf(url) {
35
+ try {
36
+ return JSON.parse(fs.readFileSync(url, 'utf8')).version ?? '?';
37
+ } catch {
38
+ return '?';
39
+ }
40
+ }
41
+
42
+ /**
43
+ * The installed HTML Minifier’s check, or that of an HTML Minifier checkout next to this
44
+ * package; `null` where neither is available
45
+ * @returns {Promise<Equivalence | null>}
46
+ */
47
+ export async function loadEquivalence() {
48
+ /** @type {[string, () => Promise<any>][]} */
49
+ const sources = [
50
+ [SPECIFIER_EXPORT, () => import(SPECIFIER_EXPORT)],
51
+ [FILE_SIBLING, async () => {
52
+ if (!fs.existsSync(FILE_SIBLING) || !fs.existsSync(FILE_SIBLING_RUNNER)) return null;
53
+ const [compare, runner] = await Promise.all([import(pathToFileURL(FILE_SIBLING).href), import(pathToFileURL(FILE_SIBLING_RUNNER).href)]);
54
+ return { ...runner, ...compare };
55
+ }]
56
+ ];
57
+ for (const [source, load] of sources) {
58
+ const module = await load().catch(() => null);
59
+ if (typeof module?.compareMarkup !== 'function' || !module.OPTIONS_STRUCTURAL || !module.OPTIONS_CODE) continue;
60
+ const supported = new Set([...Object.keys(module.OPTIONS_STRUCTURAL), ...Object.keys(module.OPTIONS_CODE)]);
61
+ return {
62
+ source: source === FILE_SIBLING ? `${source} (HTML Minifier ${versionOf(new URL('../../package.json', pathToFileURL(FILE_SIBLING)))}, minifying with installed ${versionOf(new URL(import.meta.resolve('html-minifier/package.json')))})` : source,
63
+ supports: (name) => supported.has(name),
64
+ compare: (input, output, options) => /** @type {unknown[]} */ (module.compareMarkup(input, output, options)).map(describe)
65
+ };
66
+ }
67
+ return null;
68
+ }