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/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
+ export 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} is installed${where}${versionLatest ? ' (latest)' : ''}`);
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
+ }