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/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
+ }
@@ -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
+ }