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/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);