html-minifier-setup 0.0.0-stage → 0.2.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 +21 -0
- package/README.md +144 -2
- package/cli.js +213 -0
- package/package.json +58 -3
- package/src/catalog.js +175 -0
- package/src/color.js +17 -0
- package/src/equivalence.js +57 -0
- package/src/hazards.js +329 -0
- package/src/index.js +7 -0
- package/src/measure.js +70 -0
- package/src/minify-worker.js +14 -0
- package/src/pages.js +271 -0
- package/src/progress.js +165 -0
- package/src/render.js +397 -0
- package/src/report.js +252 -0
- package/src/select.js +524 -0
- package/src/setup.js +227 -0
- package/src/workers.js +122 -0
package/src/setup.js
ADDED
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
// Offers to save the configuration and to install or update HTML Minifier, each declined by
|
|
2
|
+
// default
|
|
3
|
+
|
|
4
|
+
import { spawn } from 'node:child_process';
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
|
|
8
|
+
export const CONFIG_FILE = 'html-minifier.config.json';
|
|
9
|
+
|
|
10
|
+
/** @typedef {'npm' | 'pnpm' | 'yarn' | 'bun'} PackageManager */
|
|
11
|
+
|
|
12
|
+
/** @type {[string, PackageManager][]} */
|
|
13
|
+
const LOCKFILES = [['pnpm-lock.yaml', 'pnpm'], ['yarn.lock', 'yarn'], ['bun.lock', 'bun'], ['bun.lockb', 'bun'], ['package-lock.json', 'npm']];
|
|
14
|
+
|
|
15
|
+
/** @type {Record<PackageManager, string[]>} */
|
|
16
|
+
const INSTALL_ARGS = {
|
|
17
|
+
npm: ['install', '--save-dev'],
|
|
18
|
+
pnpm: ['add', '--save-dev'],
|
|
19
|
+
yarn: ['add', '--dev'],
|
|
20
|
+
bun: ['add', '--dev']
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The directory and its ancestors, nearest first
|
|
25
|
+
* @param {string} dir
|
|
26
|
+
*/
|
|
27
|
+
function* upwards(dir) {
|
|
28
|
+
for (let current = path.resolve(dir); ; current = path.dirname(current)) {
|
|
29
|
+
yield current;
|
|
30
|
+
if (path.dirname(current) === current) return;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Finds a package where Node would resolve it from the directory
|
|
36
|
+
* @param {string} name
|
|
37
|
+
* @param {string} dir
|
|
38
|
+
* @returns {{dir: string, version: string} | null}
|
|
39
|
+
*/
|
|
40
|
+
export function findPackage(name, dir) {
|
|
41
|
+
for (const current of upwards(dir)) {
|
|
42
|
+
const filePackage = path.join(current, 'node_modules', name, 'package.json');
|
|
43
|
+
if (fs.existsSync(filePackage)) {
|
|
44
|
+
return { dir: path.dirname(filePackage), version: JSON.parse(fs.readFileSync(filePackage, 'utf8')).version };
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The nearest directory with a package.json within the repository, the repository, or else the
|
|
52
|
+
* directory itself
|
|
53
|
+
* @param {string} dir
|
|
54
|
+
*/
|
|
55
|
+
function findRoot(dir) {
|
|
56
|
+
for (const current of upwards(dir)) {
|
|
57
|
+
if (fs.existsSync(path.join(current, 'package.json')) || fs.existsSync(path.join(current, '.git'))) return current;
|
|
58
|
+
}
|
|
59
|
+
return dir;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The project the inputs belong to: that of the first local input, or else the working directory
|
|
64
|
+
* @param {string[]} inputs
|
|
65
|
+
* @param {string} cwd
|
|
66
|
+
*/
|
|
67
|
+
export function projectOf(inputs, cwd) {
|
|
68
|
+
const input = inputs.find((item) => !/^https?:\/\//i.test(item));
|
|
69
|
+
if (input === undefined) return cwd;
|
|
70
|
+
const resolved = path.resolve(cwd, input);
|
|
71
|
+
const dir = fs.existsSync(resolved) && !fs.statSync(resolved).isDirectory() ? path.dirname(resolved) : resolved;
|
|
72
|
+
return findRoot(dir);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* The package manager a package.json declares, if known
|
|
77
|
+
* @param {string} dir
|
|
78
|
+
* @returns {PackageManager | null}
|
|
79
|
+
*/
|
|
80
|
+
function declaredPackageManager(dir) {
|
|
81
|
+
try {
|
|
82
|
+
const name = String(JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf8')).packageManager ?? '').split('@')[0];
|
|
83
|
+
return name && name in INSTALL_ARGS ? /** @type {PackageManager} */ (name) : null;
|
|
84
|
+
} catch {
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* The package manager of the nearest project, by lockfile, then by declaration
|
|
91
|
+
* @param {string} dir
|
|
92
|
+
* @returns {PackageManager}
|
|
93
|
+
*/
|
|
94
|
+
export function detectPackageManager(dir) {
|
|
95
|
+
for (const current of upwards(dir)) {
|
|
96
|
+
const found = LOCKFILES.find(([file]) => fs.existsSync(path.join(current, file)));
|
|
97
|
+
if (found) return found[1];
|
|
98
|
+
const declared = declaredPackageManager(current);
|
|
99
|
+
if (declared) return declared;
|
|
100
|
+
}
|
|
101
|
+
return 'npm';
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Installs HTML Minifier as development dependency, showing the package manager’s output
|
|
106
|
+
* @param {string} dir
|
|
107
|
+
* @param {PackageManager} manager
|
|
108
|
+
* @returns {Promise<boolean>}
|
|
109
|
+
*/
|
|
110
|
+
export const installPackage = (dir, manager) => new Promise((resolve) => {
|
|
111
|
+
const child = spawn(manager, [...INSTALL_ARGS[manager], 'html-minifier@latest'], { cwd: dir, stdio: 'inherit', shell: process.platform === 'win32' });
|
|
112
|
+
child.on('error', () => resolve(false));
|
|
113
|
+
child.on('close', (code) => resolve(code === 0));
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* The latest version of a package, or null where the registry can’t tell in time
|
|
118
|
+
* @param {string} name
|
|
119
|
+
* @param {{fetch?: (url: string, init?: RequestInit) => Promise<Response>, registry?: string, timeout?: number}} [settings]
|
|
120
|
+
* @returns {Promise<string | null>}
|
|
121
|
+
*/
|
|
122
|
+
export async function fetchLatestVersion(name, { fetch = globalThis.fetch, registry = process.env.npm_config_registry || 'https://registry.npmjs.org/', timeout = 3000 } = {}) {
|
|
123
|
+
try {
|
|
124
|
+
const response = await fetch(`${registry.replace(/\/?$/, '/')}${name}/latest`, { signal: AbortSignal.timeout(timeout) });
|
|
125
|
+
if (!response.ok) return null;
|
|
126
|
+
const { version } = await response.json();
|
|
127
|
+
return typeof version === 'string' ? version : null;
|
|
128
|
+
} catch {
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** @param {string} version */
|
|
134
|
+
const parseVersion = (version) => {
|
|
135
|
+
const [, core = '', prerelease = ''] = /^v?([^+-]*)(?:-([^+]*))?/.exec(version.trim()) ?? [];
|
|
136
|
+
return { core: core.split('.').slice(0, 3).map(Number), prerelease: prerelease ? prerelease.split('.') : [] };
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Whether version `a` is newer than `b`, by SemVer precedence
|
|
141
|
+
* @param {string} a
|
|
142
|
+
* @param {string} b
|
|
143
|
+
*/
|
|
144
|
+
export function isNewer(a, b) {
|
|
145
|
+
const [versionA, versionB] = [parseVersion(a), parseVersion(b)];
|
|
146
|
+
for (let index = 0; index < 3; index++) {
|
|
147
|
+
const difference = (versionA.core[index] ?? 0) - (versionB.core[index] ?? 0);
|
|
148
|
+
if (difference) return difference > 0;
|
|
149
|
+
}
|
|
150
|
+
// A release is newer than its prereleases
|
|
151
|
+
if (!versionA.prerelease.length || !versionB.prerelease.length) return !versionA.prerelease.length && versionB.prerelease.length > 0;
|
|
152
|
+
for (let index = 0; ; index++) {
|
|
153
|
+
const [idA, idB] = [versionA.prerelease[index], versionB.prerelease[index]];
|
|
154
|
+
if (idA === undefined || idB === undefined) return idB === undefined && idA !== undefined;
|
|
155
|
+
if (idA === idB) continue;
|
|
156
|
+
const [numericA, numericB] = [/^\d+$/.test(idA), /^\d+$/.test(idB)];
|
|
157
|
+
// Numeric identifiers rank below alphanumeric ones
|
|
158
|
+
if (numericA && numericB) return Number(idA) > Number(idB);
|
|
159
|
+
return numericA === numericB ? idA > idB : numericB;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** @param {string} answer */
|
|
164
|
+
export const isYes = (answer) => /^\s*y(?:es)?\s*$/i.test(answer);
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* @param {string} dir
|
|
168
|
+
* @param {string} cwd
|
|
169
|
+
*/
|
|
170
|
+
const describeDir = (dir, cwd) => dir === cwd ? 'current folder' : path.relative(cwd, dir) || '.';
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* @param {{config: Record<string, unknown>, dirProject?: string, configPreset?: {name: string, changes: string[], config: Record<string, unknown>} | null, cwd: string, ask: (question: string) => Promise<string>, install?: (dir: string, manager: PackageManager) => Promise<boolean>, latest?: Promise<string | null> | null, log: (message: string) => void}} settings
|
|
174
|
+
* @returns {Promise<{fileConfig: string | null, installed: boolean}>}
|
|
175
|
+
*/
|
|
176
|
+
export async function offerSetup({ config, dirProject, configPreset = null, cwd, ask, install = installPackage, latest = null, log }) {
|
|
177
|
+
/** @type {string | null} */
|
|
178
|
+
let fileConfig = null;
|
|
179
|
+
let dirTarget = dirProject ?? cwd;
|
|
180
|
+
|
|
181
|
+
if (isYes(await ask('Save configuration file? (y/N) '))) {
|
|
182
|
+
dirTarget = path.resolve(cwd, (await ask(`Folder (${describeDir(dirTarget, cwd)}): `)).trim() || dirTarget);
|
|
183
|
+
const file = path.join(dirTarget, CONFIG_FILE);
|
|
184
|
+
if (!fs.existsSync(file) || isYes(await ask(`Overwrite ${path.relative(cwd, file)}? (y/N) `))) {
|
|
185
|
+
const asPreset = configPreset && isYes(await ask(`Save it as preset \`${configPreset.name}\` with ${configPreset.changes.length} change${configPreset.changes.length === 1 ? '' : 's'} (${configPreset.changes.map((name) => `\`${name}\``).join(', ')})? Same effect, shorter, and follows changes to the preset (y/N) `));
|
|
186
|
+
await fs.promises.mkdir(dirTarget, { recursive: true });
|
|
187
|
+
await fs.promises.writeFile(file, `${JSON.stringify(asPreset ? configPreset.config : config, null, 2)}\n`);
|
|
188
|
+
fileConfig = file;
|
|
189
|
+
log(`Saved ${path.relative(cwd, file)}${dirTarget === cwd ? '' : '—HTML Minifier reads it when run from that folder'}`);
|
|
190
|
+
} else {
|
|
191
|
+
log('Not saved');
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
log('');
|
|
196
|
+
const found = findPackage('html-minifier', dirTarget);
|
|
197
|
+
const versionLatest = await latest;
|
|
198
|
+
if (found) {
|
|
199
|
+
// As the project folder above `node_modules`
|
|
200
|
+
const dirInstalled = path.dirname(path.dirname(found.dir));
|
|
201
|
+
const where = dirInstalled === cwd ? '' : ` in ${describeDir(dirInstalled, cwd)}`;
|
|
202
|
+
if (!versionLatest || !isNewer(versionLatest, found.version)) {
|
|
203
|
+
log(`HTML Minifier ${found.version}${versionLatest ? ' (latest)' : ''} is installed${where}`);
|
|
204
|
+
} else if (isYes(await ask(`Update HTML Minifier ${found.version}${where} to ${versionLatest} (latest)? (y/N) `))) {
|
|
205
|
+
if (!await install(dirInstalled, detectPackageManager(dirInstalled))) log('Update failed');
|
|
206
|
+
}
|
|
207
|
+
return { fileConfig, installed: true };
|
|
208
|
+
}
|
|
209
|
+
let installed = false;
|
|
210
|
+
if (isYes(await ask(`Install HTML Minifier${versionLatest ? ` ${versionLatest} (latest)` : ''}? (y/N) `))) {
|
|
211
|
+
const dirDefault = findRoot(dirTarget);
|
|
212
|
+
const dirInstall = path.resolve(cwd, (await ask(`Folder (${describeDir(dirDefault, cwd)}): `)).trim() || dirDefault);
|
|
213
|
+
const filePackage = path.join(dirInstall, 'package.json');
|
|
214
|
+
// Package managers would otherwise install into the nearest project above
|
|
215
|
+
if (!fs.existsSync(filePackage)) {
|
|
216
|
+
if (!isYes(await ask(`Create package.json in ${describeDir(dirInstall, cwd)}? Needed to install there (y/N) `))) {
|
|
217
|
+
log('Not installed—`npx html-minifier` runs HTML Minifier without installing it');
|
|
218
|
+
return { fileConfig, installed };
|
|
219
|
+
}
|
|
220
|
+
await fs.promises.mkdir(dirInstall, { recursive: true });
|
|
221
|
+
await fs.promises.writeFile(filePackage, `${JSON.stringify({ private: true }, null, 2)}\n`);
|
|
222
|
+
}
|
|
223
|
+
installed = await install(dirInstall, detectPackageManager(dirInstall));
|
|
224
|
+
if (!installed) log('Installation failed');
|
|
225
|
+
}
|
|
226
|
+
return { fileConfig, installed };
|
|
227
|
+
}
|
package/src/workers.js
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
// Minifies on worker threads, so that pages minify in parallel and the main thread stays free for
|
|
2
|
+
// static checks, rendering, and progress
|
|
3
|
+
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
import { Worker } from 'node:worker_threads';
|
|
6
|
+
import { minify } from 'html-minifier';
|
|
7
|
+
|
|
8
|
+
// One core is left to the main thread; more workers mostly add memory, as each loads the minifier
|
|
9
|
+
const SIZE_DEFAULT = Math.max(1, Math.min(os.availableParallelism() - 1, 8));
|
|
10
|
+
const FILE_WORKER = new URL('./minify-worker.js', import.meta.url);
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* @typedef {object} Job
|
|
14
|
+
* @prop {string} html
|
|
15
|
+
* @prop {Record<string, unknown>} options
|
|
16
|
+
* @prop {(output: string) => void} resolve
|
|
17
|
+
* @prop {(err: Error) => void} reject
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/** @typedef {{worker: Worker, job: Job | null}} Slot */
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* @param {string} html
|
|
24
|
+
* @param {Record<string, unknown>} options
|
|
25
|
+
*/
|
|
26
|
+
const minifyHere = (html, options) => minify(html, { ...options, log: () => {} });
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Workers started as needed, up to `size`, which let the process exit while idle
|
|
30
|
+
* @param {{size?: number}} [settings]
|
|
31
|
+
*/
|
|
32
|
+
export function createMinifierPool({ size = SIZE_DEFAULT } = {}) {
|
|
33
|
+
/** @type {Slot[]} */
|
|
34
|
+
const slots = [];
|
|
35
|
+
/** @type {Job[]} */
|
|
36
|
+
const queue = [];
|
|
37
|
+
|
|
38
|
+
/** @param {Slot} slot */
|
|
39
|
+
const release = (slot) => {
|
|
40
|
+
slot.job = null;
|
|
41
|
+
slot.worker.unref();
|
|
42
|
+
pump();
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const spawn = () => {
|
|
46
|
+
/** @type {Slot} */
|
|
47
|
+
// Without the parent’s Node flags, some of which (e.g., `--input-type`) workers can’t take
|
|
48
|
+
const slot = { worker: new Worker(FILE_WORKER, { execArgv: [] }), job: null };
|
|
49
|
+
slot.worker.unref();
|
|
50
|
+
slot.worker.on('message', (/** @type {{output?: string, error?: string}} */ data) => {
|
|
51
|
+
const { job } = slot;
|
|
52
|
+
if (data.error === undefined) {
|
|
53
|
+
job?.resolve(data.output ?? '');
|
|
54
|
+
} else {
|
|
55
|
+
job?.reject(new Error(data.error));
|
|
56
|
+
}
|
|
57
|
+
release(slot);
|
|
58
|
+
});
|
|
59
|
+
// Where a worker fails as a whole (e.g., out of memory), its job fails and others take over
|
|
60
|
+
slot.worker.on('error', (/** @type {Error} */ err) => {
|
|
61
|
+
const index = slots.indexOf(slot);
|
|
62
|
+
if (index !== -1) slots.splice(index, 1);
|
|
63
|
+
slot.job?.reject(err);
|
|
64
|
+
slot.job = null;
|
|
65
|
+
pump();
|
|
66
|
+
});
|
|
67
|
+
slots.push(slot);
|
|
68
|
+
return slot;
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
function pump() {
|
|
72
|
+
while (queue.length) {
|
|
73
|
+
const slot = slots.find((candidate) => !candidate.job) ?? (slots.length < size ? spawn() : null);
|
|
74
|
+
if (!slot) return;
|
|
75
|
+
const job = /** @type {Job} */ (queue.shift());
|
|
76
|
+
slot.job = job;
|
|
77
|
+
slot.worker.ref();
|
|
78
|
+
slot.worker.postMessage({ html: job.html, options: job.options });
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return {
|
|
83
|
+
size,
|
|
84
|
+
/**
|
|
85
|
+
* @param {string} html
|
|
86
|
+
* @param {Record<string, unknown>} options `log` is left out, as workers log nothing
|
|
87
|
+
* @returns {Promise<string>}
|
|
88
|
+
*/
|
|
89
|
+
minify(html, options) {
|
|
90
|
+
const rest = { ...options };
|
|
91
|
+
delete rest.log;
|
|
92
|
+
// Functions, for one, can’t be sent to workers
|
|
93
|
+
try {
|
|
94
|
+
structuredClone(rest);
|
|
95
|
+
} catch {
|
|
96
|
+
return minifyHere(html, rest);
|
|
97
|
+
}
|
|
98
|
+
return new Promise((resolve, reject) => {
|
|
99
|
+
queue.push({ html, options: rest, resolve, reject });
|
|
100
|
+
pump();
|
|
101
|
+
});
|
|
102
|
+
},
|
|
103
|
+
async close() {
|
|
104
|
+
const jobs = [...queue.splice(0), ...slots.flatMap((slot) => slot.job ? [slot.job] : [])];
|
|
105
|
+
for (const job of jobs) job.reject(new Error('Minifier pool closed'));
|
|
106
|
+
await Promise.all(slots.splice(0).map((slot) => {
|
|
107
|
+
slot.job = null;
|
|
108
|
+
return slot.worker.terminate();
|
|
109
|
+
}));
|
|
110
|
+
}
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** @type {ReturnType<typeof createMinifierPool> | null} */
|
|
115
|
+
let poolShared = null;
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Minifies on a pool shared by all runs, which needs no closing
|
|
119
|
+
* @param {string} html
|
|
120
|
+
* @param {Record<string, unknown>} options
|
|
121
|
+
*/
|
|
122
|
+
export const minifyInWorkers = (html, options) => (poolShared ??= createMinifierPool()).minify(html, options);
|