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/pages.js
ADDED
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* @typedef {object} Page
|
|
6
|
+
* @prop {string} id How the page is named in reports
|
|
7
|
+
* @prop {string} html
|
|
8
|
+
* @prop {string | null} url Where the page lives online, if read from there
|
|
9
|
+
* @prop {string | null} file
|
|
10
|
+
* @prop {string | null} root Directory that root-relative references resolve against
|
|
11
|
+
* @prop {(ref: string) => Promise<Resource | null>} load Reads a resource the page references, where local or same-origin
|
|
12
|
+
* @prop {(ref: string, base: string) => string} resolve Turns a reference made by a resource the page references into one made by the page
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/** @typedef {{text: string, ref: string}} Resource `ref` tells where the resource ended up after redirects */
|
|
16
|
+
|
|
17
|
+
// As HTML Minifier’s
|
|
18
|
+
export const EXTENSIONS_DEFAULT = ['html', 'htm', 'shtml', 'shtm'];
|
|
19
|
+
const MAX_REDIRECTS = 5;
|
|
20
|
+
const RE_SCHEME = /^[a-z][\w+.-]*:|^\/\//i;
|
|
21
|
+
const RE_URL_INPUT = /^https?:\/\//i;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Whether a path lies within the root (or is the root)
|
|
25
|
+
* @param {string} root
|
|
26
|
+
* @param {string} target
|
|
27
|
+
*/
|
|
28
|
+
export function isInside(root, target) {
|
|
29
|
+
const relative = path.relative(root, target);
|
|
30
|
+
return relative !== '..' && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* @param {{id: string, html: string, url?: string | null, file?: string | null, root?: string | null, resources?: Record<string, string>}} spec
|
|
35
|
+
* @returns {Page}
|
|
36
|
+
*/
|
|
37
|
+
export function createPage({ id, html, url = null, file = null, root = null, resources }) {
|
|
38
|
+
/** @type {Map<string, Promise<Resource | null>>} */
|
|
39
|
+
const cache = new Map();
|
|
40
|
+
/**
|
|
41
|
+
* @param {string} ref
|
|
42
|
+
* @returns {Promise<Resource | null>}
|
|
43
|
+
*/
|
|
44
|
+
const read = async (ref) => {
|
|
45
|
+
if (url) return fetchSameOrigin(url, ref);
|
|
46
|
+
const text = resources ? resources[ref] ?? null : file && root ? await readLocal(file, root, ref) : null;
|
|
47
|
+
return text === null ? null : { text, ref };
|
|
48
|
+
};
|
|
49
|
+
// Local pages resolve as if served from their root
|
|
50
|
+
const location = url ?? `http://page.invalid/${id.split('/').map(encodeURIComponent).join('/')}`;
|
|
51
|
+
return {
|
|
52
|
+
id,
|
|
53
|
+
html,
|
|
54
|
+
url,
|
|
55
|
+
file,
|
|
56
|
+
root,
|
|
57
|
+
resolve(ref, base) {
|
|
58
|
+
const target = new URL(ref, new URL(base, location));
|
|
59
|
+
return url || target.origin !== new URL(location).origin ? target.href : `${target.pathname}${target.search}`;
|
|
60
|
+
},
|
|
61
|
+
load(ref) {
|
|
62
|
+
let pending = cache.get(ref);
|
|
63
|
+
if (!pending) {
|
|
64
|
+
pending = read(ref).catch(() => null);
|
|
65
|
+
cache.set(ref, pending);
|
|
66
|
+
}
|
|
67
|
+
return pending;
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* @param {string} file
|
|
74
|
+
* @param {string} root
|
|
75
|
+
* @param {string} ref
|
|
76
|
+
*/
|
|
77
|
+
async function readLocal(file, root, ref) {
|
|
78
|
+
if (RE_SCHEME.test(ref)) return null;
|
|
79
|
+
const clean = decodeURI(ref.replace(/[?#].*$/s, ''));
|
|
80
|
+
if (!clean) return null;
|
|
81
|
+
const target = clean.startsWith('/') ? path.join(root, clean) : path.resolve(path.dirname(file), clean);
|
|
82
|
+
if (!isInside(root, target)) return null;
|
|
83
|
+
return fs.promises.readFile(target, 'utf8');
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* @param {string} pageUrl
|
|
88
|
+
* @param {string} ref
|
|
89
|
+
* @returns {Promise<Resource | null>}
|
|
90
|
+
*/
|
|
91
|
+
async function fetchSameOrigin(pageUrl, ref) {
|
|
92
|
+
const { origin } = new URL(pageUrl);
|
|
93
|
+
let target = new URL(ref, pageUrl);
|
|
94
|
+
// Redirects are followed one by one, so that none leads to another origin
|
|
95
|
+
for (let redirects = 0; redirects <= MAX_REDIRECTS && target.origin === origin; redirects++) {
|
|
96
|
+
const response = await fetch(target, { redirect: 'manual', signal: AbortSignal.timeout(15000) });
|
|
97
|
+
const location = response.headers.get('location');
|
|
98
|
+
if (response.status < 300 || response.status > 399 || !location) return response.ok ? { text: await response.text(), ref: target.href } : null;
|
|
99
|
+
await response.body?.cancel();
|
|
100
|
+
target = new URL(location, target);
|
|
101
|
+
}
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Golden ratio, whose multiples spread picks evenly over a range
|
|
106
|
+
const SPREAD = 0.6180339887;
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Items in an order whose every start is spread across them, rather than their first ones
|
|
110
|
+
* @template T
|
|
111
|
+
* @param {T[]} items
|
|
112
|
+
* @returns {T[]}
|
|
113
|
+
*/
|
|
114
|
+
function spread(items) {
|
|
115
|
+
/** @type {Set<number>} */
|
|
116
|
+
const picked = new Set();
|
|
117
|
+
// Bounded, as the last gaps can take a while to hit
|
|
118
|
+
for (let step = 0; step < 4 * items.length && picked.size < items.length; step++) {
|
|
119
|
+
picked.add(Math.floor(((step * SPREAD) % 1) * items.length));
|
|
120
|
+
}
|
|
121
|
+
for (let index = 0; index < items.length; index++) picked.add(index);
|
|
122
|
+
return [...picked].map((index) => /** @type {T} */ (items[index]));
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Takes from each list in turn, up to `limit` items
|
|
127
|
+
* @param {string[][]} lists
|
|
128
|
+
* @param {number} limit
|
|
129
|
+
*/
|
|
130
|
+
function interleave(lists, limit) {
|
|
131
|
+
/** @type {string[]} */
|
|
132
|
+
const items = [];
|
|
133
|
+
let active = lists.filter((list) => list.length);
|
|
134
|
+
for (let round = 0; active.length && items.length < limit; round++) {
|
|
135
|
+
for (const list of active) {
|
|
136
|
+
if (items.length < limit) items.push(/** @type {string} */ (list[round]));
|
|
137
|
+
}
|
|
138
|
+
active = active.filter((list) => list.length > round + 1);
|
|
139
|
+
}
|
|
140
|
+
return items;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Picks pages across a site’s sections first and then across the folders within them, so that
|
|
145
|
+
* a sample covers its templates rather than one section’s first pages; the same paths always
|
|
146
|
+
* give the same sample
|
|
147
|
+
* @param {string[]} paths
|
|
148
|
+
* @param {number} count
|
|
149
|
+
* @returns {string[]}
|
|
150
|
+
*/
|
|
151
|
+
export function samplePaths(paths, count) {
|
|
152
|
+
if (paths.length <= count) return paths;
|
|
153
|
+
/** @typedef {{files: string[], folders: Map<string, Folder>}} Folder */
|
|
154
|
+
/** @type {Folder} */
|
|
155
|
+
const root = { files: [], folders: new Map() };
|
|
156
|
+
for (const item of [...paths].sort()) {
|
|
157
|
+
const segments = item.split(/[\\/]/);
|
|
158
|
+
let folder = root;
|
|
159
|
+
for (const segment of segments.slice(0, -1)) {
|
|
160
|
+
let next = folder.folders.get(segment);
|
|
161
|
+
if (!next) {
|
|
162
|
+
next = { files: [], folders: new Map() };
|
|
163
|
+
folder.folders.set(segment, next);
|
|
164
|
+
}
|
|
165
|
+
folder = next;
|
|
166
|
+
}
|
|
167
|
+
folder.files.push(item);
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* The first `count` pages of the folder, in the order they’re picked
|
|
171
|
+
* @param {Folder} folder
|
|
172
|
+
* @returns {string[]}
|
|
173
|
+
*/
|
|
174
|
+
const order = (folder) => interleave([spread(folder.files), ...spread([...folder.folders.values()]).map(order)], count);
|
|
175
|
+
return order(root);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* @param {string} dir
|
|
180
|
+
* @param {string[]} extensions
|
|
181
|
+
* @param {string[]} ignore
|
|
182
|
+
* @returns {Promise<string[]>}
|
|
183
|
+
*/
|
|
184
|
+
async function walk(dir, extensions, ignore) {
|
|
185
|
+
/** @type {string[]} */
|
|
186
|
+
const files = [];
|
|
187
|
+
for (const entry of await fs.promises.readdir(dir, { withFileTypes: true })) {
|
|
188
|
+
if (entry.name.startsWith('.') || ignore.includes(entry.name)) continue;
|
|
189
|
+
const full = path.join(dir, entry.name);
|
|
190
|
+
if (entry.isDirectory()) {
|
|
191
|
+
files.push(...await walk(full, extensions, ignore));
|
|
192
|
+
} else if (entry.isFile() && (extensions.includes('*') || extensions.includes(path.extname(entry.name).slice(1).toLowerCase()))) {
|
|
193
|
+
files.push(full);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
return files;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Symbolic links under the root that lead outside it, which the render check follows only where
|
|
201
|
+
* allowed, as a page’s scripts could send on what they lead to
|
|
202
|
+
* @param {string} root
|
|
203
|
+
* @param {string[]} [ignore]
|
|
204
|
+
* @returns {Promise<{link: string, target: string}[]>}
|
|
205
|
+
*/
|
|
206
|
+
export async function findOutsideLinks(root, ignore = ['node_modules']) {
|
|
207
|
+
const rootReal = await fs.promises.realpath(root);
|
|
208
|
+
/** @type {{link: string, target: string}[]} */
|
|
209
|
+
const links = [];
|
|
210
|
+
/** @param {string} dir */
|
|
211
|
+
const visit = async (dir) => {
|
|
212
|
+
for (const entry of await fs.promises.readdir(dir, { withFileTypes: true }).catch(() => [])) {
|
|
213
|
+
if (entry.name.startsWith('.') || ignore.includes(entry.name)) continue;
|
|
214
|
+
const link = path.join(dir, entry.name);
|
|
215
|
+
if (entry.isDirectory()) {
|
|
216
|
+
await visit(link);
|
|
217
|
+
} else if (entry.isSymbolicLink()) {
|
|
218
|
+
const target = await fs.promises.realpath(link).catch(() => null);
|
|
219
|
+
if (target && !isInside(rootReal, target)) links.push({ link, target });
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
};
|
|
223
|
+
await visit(root);
|
|
224
|
+
return links;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Reads pages from files, directories, and URLs, sampled where there are more
|
|
229
|
+
* @param {string[]} inputs
|
|
230
|
+
* @param {{extensions?: string[], ignore?: string[], sample?: number}} [settings]
|
|
231
|
+
* @returns {Promise<Page[] & {found: number}>} `found`: pages found before sampling
|
|
232
|
+
*/
|
|
233
|
+
export async function loadPages(inputs, { extensions = EXTENSIONS_DEFAULT, ignore = ['node_modules'], sample = 20 } = {}) {
|
|
234
|
+
// As HTML Minifier reads them, with `*` for all files
|
|
235
|
+
const extensionsRead = extensions.map((ext) => ext.trim().replace(/^\.+/, '').toLowerCase());
|
|
236
|
+
/** @type {{file: string, root: string}[]} */
|
|
237
|
+
const files = [];
|
|
238
|
+
/** @type {string[]} */
|
|
239
|
+
const urls = [];
|
|
240
|
+
for (const input of inputs) {
|
|
241
|
+
if (RE_URL_INPUT.test(input)) {
|
|
242
|
+
urls.push(input);
|
|
243
|
+
continue;
|
|
244
|
+
}
|
|
245
|
+
const resolved = path.resolve(input);
|
|
246
|
+
const stat = await fs.promises.stat(resolved).catch(() => null);
|
|
247
|
+
if (!stat) throw new Error(`Not found: ${input}`);
|
|
248
|
+
if (stat.isDirectory()) {
|
|
249
|
+
for (const file of await walk(resolved, extensionsRead, ignore)) files.push({ file, root: resolved });
|
|
250
|
+
} else {
|
|
251
|
+
files.push({ file: resolved, root: path.dirname(resolved) });
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const keys = samplePaths([...files.map(({ file }) => file), ...urls], sample);
|
|
256
|
+
const pages = await Promise.all(keys.map(async (key) => {
|
|
257
|
+
if (RE_URL_INPUT.test(key)) {
|
|
258
|
+
const response = await fetch(key, { signal: AbortSignal.timeout(30000) });
|
|
259
|
+
if (!response.ok) throw new Error(`Couldn’t fetch ${key}: ${response.status}`);
|
|
260
|
+
return createPage({ id: key, html: await response.text(), url: response.url });
|
|
261
|
+
}
|
|
262
|
+
const { root } = /** @type {{file: string, root: string}} */ (files.find(({ file }) => file === key));
|
|
263
|
+
return createPage({
|
|
264
|
+
id: path.relative(root, key).split(path.sep).join('/'),
|
|
265
|
+
html: await fs.promises.readFile(key, 'utf8'),
|
|
266
|
+
file: key,
|
|
267
|
+
root
|
|
268
|
+
});
|
|
269
|
+
}));
|
|
270
|
+
return Object.assign(pages, { found: files.length + urls.length });
|
|
271
|
+
}
|
package/src/progress.js
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
// A one-line progress indicator: animated on terminals, and otherwise quiet (or, if verbose, line
|
|
2
|
+
// by line)
|
|
3
|
+
|
|
4
|
+
import { CSI, ESC, paint, supportsColor } from './color.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* @typedef {object} ProgressEvent
|
|
8
|
+
* @prop {'read' | 'start' | 'evaluate' | 'render' | 'contribute' | 'presets'} phase
|
|
9
|
+
* @prop {string} [detail] What is being worked on
|
|
10
|
+
* @prop {number} [current] Steps done in the phase
|
|
11
|
+
* @prop {number} [total] Steps in the phase, if known
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* @typedef {object} Progress
|
|
16
|
+
* @prop {(event: ProgressEvent) => void} update
|
|
17
|
+
* @prop {(message: string) => void} log Prints a message above the indicator
|
|
18
|
+
* @prop {(settings?: {ok?: boolean}) => boolean} stop Returns whether the indicator was drawn
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
const RE_ANSI = new RegExp(`${ESC}\\[[\\d;?]*[A-Za-z]`, 'g');
|
|
22
|
+
const FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
|
23
|
+
const WIDTH_BAR = 24;
|
|
24
|
+
const INTERVAL = 80;
|
|
25
|
+
|
|
26
|
+
/** @type {Record<ProgressEvent['phase'], string>} */
|
|
27
|
+
export const TITLES = {
|
|
28
|
+
read: 'Reading pages',
|
|
29
|
+
start: 'Starting browser',
|
|
30
|
+
evaluate: 'Evaluating options',
|
|
31
|
+
render: 'Rendering',
|
|
32
|
+
contribute: 'Measuring contributions',
|
|
33
|
+
presets: 'Comparing presets'
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/** @param {string} text */
|
|
37
|
+
export const visibleLength = (text) => [...text.replace(RE_ANSI, '')].length;
|
|
38
|
+
|
|
39
|
+
/** @param {number} ms */
|
|
40
|
+
const formatElapsed = (ms) => {
|
|
41
|
+
const seconds = Math.floor(ms / 1000);
|
|
42
|
+
return `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, '0')}`;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* @param {string} text
|
|
47
|
+
* @param {number} width
|
|
48
|
+
*/
|
|
49
|
+
const truncate = (text, width) => {
|
|
50
|
+
const chars = [...text];
|
|
51
|
+
if (width <= 0) return '';
|
|
52
|
+
return chars.length > width ? `${chars.slice(0, Math.max(0, width - 1)).join('')}…` : text;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* One frame of the indicator, fit to the terminal width
|
|
57
|
+
* @param {{title: string, detail: string, current?: number, total?: number, elapsed: number, frame: number, columns: number, color: boolean}} state
|
|
58
|
+
* @returns {string}
|
|
59
|
+
*/
|
|
60
|
+
export function formatLine({ title, detail, current = 0, total, elapsed, frame, columns, color }) {
|
|
61
|
+
const spinner = paint('accent', FRAMES[frame % FRAMES.length] ?? '', color);
|
|
62
|
+
/** @param {string} text */
|
|
63
|
+
const head = (text) => `${spinner} ${paint('bold', text, color)}`;
|
|
64
|
+
const time = paint('dim', formatElapsed(elapsed), color);
|
|
65
|
+
/** @type {string[]} */
|
|
66
|
+
const parts = [head(title)];
|
|
67
|
+
if (total) {
|
|
68
|
+
const ratio = Math.min(1, Math.max(0, current / total));
|
|
69
|
+
const filled = Math.round(ratio * WIDTH_BAR);
|
|
70
|
+
// Without color, the rest of the bar needs a lighter line to show
|
|
71
|
+
const bar = paint('accent', '━'.repeat(filled), color) + paint('dim', (color ? '━' : '─').repeat(WIDTH_BAR - filled), color);
|
|
72
|
+
parts.push(`${bar} ${`${Math.floor(ratio * 100)}%`.padStart(4)}`);
|
|
73
|
+
}
|
|
74
|
+
parts.push(time);
|
|
75
|
+
const budget = columns - 1;
|
|
76
|
+
// Where even without a detail the line is too long, the bar goes first
|
|
77
|
+
if (visibleLength(parts.join(' ')) > budget && parts.length > 2) parts.splice(1, 1);
|
|
78
|
+
// And then the title
|
|
79
|
+
const over = visibleLength(parts.join(' ')) - budget;
|
|
80
|
+
if (over > 0) parts[0] = head(truncate(title, [...title].length - over));
|
|
81
|
+
const room = budget - visibleLength(parts.join(' ')) - 2;
|
|
82
|
+
if (detail && room > 3) parts.push(truncate(detail, room));
|
|
83
|
+
return parts.join(' ');
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* @param {{stream?: {isTTY?: boolean, columns?: number, write: (chunk: string) => unknown}, color?: boolean, verbose?: boolean}} [settings]
|
|
88
|
+
* @returns {Progress}
|
|
89
|
+
*/
|
|
90
|
+
export function createProgress({ stream = process.stderr, color, verbose = false } = {}) {
|
|
91
|
+
const animated = Boolean(stream.isTTY);
|
|
92
|
+
const colored = color ?? supportsColor(stream);
|
|
93
|
+
/** @type {(ProgressEvent & {start: number}) | null} */
|
|
94
|
+
let state = null;
|
|
95
|
+
let frame = 0;
|
|
96
|
+
let lastDraw = 0;
|
|
97
|
+
let lastLine = '';
|
|
98
|
+
/** @type {ReturnType<typeof setInterval> | null} */
|
|
99
|
+
let timer = null;
|
|
100
|
+
|
|
101
|
+
const showCursor = () => stream.write(`${CSI}?25h`);
|
|
102
|
+
const onInterrupt = () => {
|
|
103
|
+
stream.write(`\r${CSI}2K`);
|
|
104
|
+
showCursor();
|
|
105
|
+
process.exit(130);
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
const draw = () => {
|
|
109
|
+
if (!state) return;
|
|
110
|
+
lastDraw = Date.now();
|
|
111
|
+
frame++;
|
|
112
|
+
stream.write(`\r${CSI}2K${formatLine({
|
|
113
|
+
title: TITLES[state.phase],
|
|
114
|
+
detail: state.detail ?? '',
|
|
115
|
+
current: state.current ?? 0,
|
|
116
|
+
...(state.total ? { total: state.total } : {}),
|
|
117
|
+
elapsed: Date.now() - state.start,
|
|
118
|
+
frame,
|
|
119
|
+
columns: stream.columns || 80,
|
|
120
|
+
color: colored
|
|
121
|
+
})}`);
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
/** @param {boolean} ok */
|
|
125
|
+
const finish = (ok) => {
|
|
126
|
+
if (!state || !animated) return;
|
|
127
|
+
const mark = ok ? paint('success', '✔', colored) : paint('failure', '✖', colored);
|
|
128
|
+
stream.write(`\r${CSI}2K${mark} ${TITLES[state.phase]} ${paint('dim', formatElapsed(Date.now() - state.start), colored)}\n`);
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
return {
|
|
132
|
+
update(event) {
|
|
133
|
+
if (state && state.phase !== event.phase) finish(true);
|
|
134
|
+
state = { ...event, start: state && state.phase === event.phase ? state.start : Date.now() };
|
|
135
|
+
if (!animated) {
|
|
136
|
+
const line = `${TITLES[event.phase]}${event.detail ? `: ${event.detail}` : ''}`;
|
|
137
|
+
if (verbose && line !== lastLine) stream.write(`${line}\n`);
|
|
138
|
+
lastLine = line;
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
if (!timer) {
|
|
142
|
+
stream.write(`${CSI}?25l`);
|
|
143
|
+
process.once('SIGINT', onInterrupt);
|
|
144
|
+
timer = setInterval(draw, INTERVAL);
|
|
145
|
+
timer.unref();
|
|
146
|
+
}
|
|
147
|
+
// Drawn on updates, too, as long stretches of minification leave the timer no turn
|
|
148
|
+
if (Date.now() - lastDraw >= INTERVAL) draw();
|
|
149
|
+
},
|
|
150
|
+
log(message) {
|
|
151
|
+
stream.write(animated ? `\r${CSI}2K${message}\n` : `${message}\n`);
|
|
152
|
+
if (animated) draw();
|
|
153
|
+
},
|
|
154
|
+
stop({ ok = true } = {}) {
|
|
155
|
+
finish(ok);
|
|
156
|
+
state = null;
|
|
157
|
+
if (!timer) return false;
|
|
158
|
+
clearInterval(timer);
|
|
159
|
+
timer = null;
|
|
160
|
+
process.removeListener('SIGINT', onInterrupt);
|
|
161
|
+
showCursor();
|
|
162
|
+
return true;
|
|
163
|
+
}
|
|
164
|
+
};
|
|
165
|
+
}
|