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/hazards.js ADDED
@@ -0,0 +1,329 @@
1
+ // What a page’s own scripts and style sheets rely on that an option may change—found by comparing
2
+ // output with and without the option, and matching what changed against what the page references
3
+
4
+ import { parse } from 'parse5';
5
+
6
+ /**
7
+ * `risk`: likely breaks the page; `check`: may break it, which a render check can settle;
8
+ * `note`: can’t be ruled out
9
+ * @typedef {{level: 'risk' | 'check' | 'note', message: string}} Hazard
10
+ */
11
+
12
+ /**
13
+ * @typedef {object} PageContext
14
+ * @prop {Set<string>} scriptStrings Words in string literals of scripts and event handlers
15
+ * @prop {string} scriptText
16
+ * @prop {string[]} scriptsUnread Script URLs that couldn’t be read
17
+ * @prop {Set<string>} attributeRefs Attribute names that style sheets select on or scripts query
18
+ * @prop {{selector: string, text: string}[]} whitespaceRules Rules that keep whitespace, outside `pre` and `textarea`
19
+ */
20
+
21
+ /** @typedef {import('parse5').DefaultTreeAdapterMap['node']} Node */
22
+ /** @typedef {import('parse5').DefaultTreeAdapterMap['element']} Element */
23
+
24
+ const MAX_PER_KIND = 3;
25
+
26
+ /** @type {Record<string, string>} */
27
+ const MODES = { 'quirks': 'quirks mode', 'limited-quirks': 'almost standards mode', 'no-quirks': 'standards mode' };
28
+ const RE_STRING_LITERAL = /(["'`])((?:\\.|(?!\1)[^\\\n])*)\1/g;
29
+ const RE_WORD = /[\w-]+/g;
30
+ const RE_ATTRIBUTE_SELECTOR = /\[\s*([\w-]+)\s*(?=[~|^$*]?=|\])/g;
31
+ const RE_CSS_COMMENT = /\/\*[\s\S]*?\*\//g;
32
+ // At-rules without a block, like `@import`, taking quoted strings and escapes whole
33
+ const RE_CSS_STATEMENT = /@[\w-]+(?:\\.|"(?:\\.|[^"\\\n])*"|'(?:\\.|[^'\\\n])*'|[^{};"'\\])*;/g;
34
+ const RE_IMPORT = /@import\s+(?:url\(\s*)?(["']?)([^"')\s;]+)\1/gi;
35
+ const RE_ATTRIBUTE_CALL = /\b(?:get|has|remove|set|toggle)Attribute(?:NS)?\(\s*(?:[^,()]*,\s*)?["'`]([\w-]+)/g;
36
+ const RE_WHITESPACE_KEPT = /white-space(?:-collapse)?\s*:\s*(?:pre-wrap|pre-line|pre|break-spaces|preserve-breaks|preserve-spaces|preserve)(?![\w-])/i;
37
+ const RE_READS_COMMENTS = /SHOW_COMMENT|COMMENT_NODE|nodeType\s*[!=]==?\s*8\b|\.childNodes\b[^;]*\.data\b/;
38
+ const RE_SCRIPT_JS = /^(?:|module|(?:application|text)\/(?:x-)?(?:java|ecma)script|text\/javascript1\.[0-5]|text\/jscript|text\/livescript)$/i;
39
+ const SELECTORS_PREFORMATTED = new Set(['pre', 'textarea']);
40
+
41
+ // Conditional comments only matter to Internet Explorer 9 and older
42
+ const RE_COMMENT_CONDITIONAL = /^\[if\b|<!\[endif\]$/i;
43
+ /** @type {[RegExp, string][]} */
44
+ const COMMENTS_MEANINGFUL = [
45
+ [/^\s*\/?ko(?:\s|$)/, 'Knockout binding'],
46
+ [/^\s*google(?:off|on)\s*:/i, 'search appliance directive'],
47
+ [/^\s*\/?esi\b|^\s*<\/?esi:/i, 'Edge Side Includes'],
48
+ [/^\?lit\$|^\s*lit-part\b/, 'Lit marker'],
49
+ [/^\s*\/?\$[!?]?\s*$|^\s*[[\]]\s*$|^\s*(?:teleport|v-if)\b/, 'hydration marker'],
50
+ [/^\s*\/?noindex\s*$/i, 'indexing directive'],
51
+ [/^\s*\/?wp:/, 'WordPress block delimiter'],
52
+ [/^\s*(?:Instance|Template)(?:Begin|End)\b|^\s*#(?:Begin|End)(?:Library|Template)/, 'template editor region']
53
+ ];
54
+
55
+ export const OPTIONS_ATTRIBUTES = new Set(['collapseAttributeWhitespace', 'collapseBooleanAttributes', 'removeDefaultTypeAttributes', 'removeEmptyAttributes', 'removeRedundantAttributes', 'sortClassNames']);
56
+ export const OPTIONS_WHITESPACE = new Set(['collapseInlineTagWhitespace', 'collapseNoBreakSpaces', 'collapseWhitespace', 'trimCustomFragments']);
57
+
58
+ /** @param {string} text */
59
+ const clip = (text, length = 60) => text.length > length ? `${text.slice(0, length - 1)}…` : text;
60
+
61
+ /**
62
+ * @param {Node} node
63
+ * @param {(node: Node) => void} visit
64
+ */
65
+ function walk(node, visit) {
66
+ visit(node);
67
+ const content = /** @type {{content?: Node}} */ (node).content;
68
+ for (const child of [...('childNodes' in node ? node.childNodes : []), ...(content ? [content] : [])]) {
69
+ walk(/** @type {Node} */ (child), visit);
70
+ }
71
+ }
72
+
73
+ /** @param {string} html */
74
+ function elementsOf(html) {
75
+ /** @type {Element[]} */
76
+ const elements = [];
77
+ walk(parse(html), (node) => {
78
+ if ('tagName' in node) elements.push(node);
79
+ });
80
+ return elements;
81
+ }
82
+
83
+ /**
84
+ * @param {Element} element
85
+ * @param {string} name
86
+ */
87
+ const attr = (element, name) => element.attrs.find((item) => item.name === name)?.value;
88
+
89
+ /** @param {Element} element */
90
+ const textOf = (element) => element.childNodes.map((child) => 'value' in child ? child.value : '').join('');
91
+
92
+ /**
93
+ * Keys counted more often in `before` than in `after`
94
+ * @param {string[]} before
95
+ * @param {string[]} after
96
+ */
97
+ function removed(before, after) {
98
+ /** @type {Map<string, number>} */
99
+ const counts = new Map();
100
+ for (const key of before) counts.set(key, (counts.get(key) ?? 0) + 1);
101
+ for (const key of after) counts.set(key, (counts.get(key) ?? 0) - 1);
102
+ return [...counts].filter(([, count]) => count > 0).map(([key]) => key);
103
+ }
104
+
105
+ /**
106
+ * Whether any element may match a selector, judged by the tag, classes, IDs, and attributes
107
+ * of the last compound of each selector in the list—erring toward a match
108
+ * @param {string} selector
109
+ * @param {Element[]} elements
110
+ */
111
+ function mayMatch(selector, elements) {
112
+ return selector.split(',').some((part) => {
113
+ const compound = (part.trim().replace(/::?[\w-]+(?:\([^)]*\))?/g, '').split(/[\s>+~]+/).pop() ?? '').trim();
114
+ const tag = compound.match(/^[a-z][\w-]*/i)?.[0].toLowerCase();
115
+ const classes = [...compound.matchAll(/\.([\w-]+)/g)].map((match) => match[1] ?? '');
116
+ const ids = [...compound.matchAll(/#([\w-]+)/g)].map((match) => match[1] ?? '');
117
+ const attributes = [...compound.matchAll(/\[\s*([\w-]+)/g)].map((match) => (match[1] ?? '').toLowerCase());
118
+ return elements.some((element) => {
119
+ const own = (attr(element, 'class') ?? '').split(/\s+/);
120
+ return (!tag || element.tagName === tag) &&
121
+ classes.every((name) => own.includes(name)) &&
122
+ ids.every((id) => attr(element, 'id') === id) &&
123
+ attributes.every((name) => element.attrs.some((item) => item.name === name));
124
+ });
125
+ });
126
+ }
127
+
128
+ /** @param {string} css */
129
+ function whitespaceRulesOf(css) {
130
+ /** @type {{selector: string, text: string}[]} */
131
+ const rules = [];
132
+ // Without comments and statements like `@import`, which would otherwise lead the next selector
133
+ for (const match of css.replace(RE_CSS_COMMENT, '').replace(RE_CSS_STATEMENT, '').matchAll(/([^{}]*)\{([^{}]*)\}/g)) {
134
+ const selector = (match[1] ?? '').trim();
135
+ const declaration = (match[2] ?? '').match(RE_WHITESPACE_KEPT)?.[0];
136
+ if (!declaration || selector.startsWith('@')) continue;
137
+ if (selector.split(',').every((part) => SELECTORS_PREFORMATTED.has(part.trim().toLowerCase()))) continue;
138
+ rules.push({ selector, text: `${clip(selector, 40)} { ${declaration} }` });
139
+ }
140
+ return rules;
141
+ }
142
+
143
+ /** @param {string} css */
144
+ const selectorTokens = (css) => {
145
+ let selectors = css.replace(RE_CSS_COMMENT, '');
146
+ // Strips declaration blocks innermost first, so that nested at-rules leave their selectors
147
+ for (let previous = ''; previous !== selectors;) {
148
+ previous = selectors;
149
+ selectors = selectors.replace(/\{[^{}]*\}/g, ',');
150
+ }
151
+ return [...selectors.matchAll(/[.#](-?[_a-zA-Z\u00A0-\uFFFF][\w\u00A0-\uFFFF-]*)/g)].map((match) => `${match[0][0]}${match[1]}`);
152
+ };
153
+
154
+ /**
155
+ * Collects what a page’s scripts and style sheets reference
156
+ * @param {import('./pages.js').Page} page
157
+ * @returns {Promise<PageContext>}
158
+ */
159
+ export async function analyzePage(page) {
160
+ /** @type {string[]} */
161
+ const scripts = [];
162
+ /** @type {string[]} */
163
+ const scriptsUnread = [];
164
+ /** @type {string[]} */
165
+ const styles = [];
166
+ /** @type {Promise<void>[]} */
167
+ const pending = [];
168
+ /** @type {Set<string>} */
169
+ const imported = new Set();
170
+
171
+ /**
172
+ * Adds a style sheet and, where local or same-origin, what it imports
173
+ * @param {string} css
174
+ * @param {string | null} base Reference of the style sheet, if not in the page
175
+ */
176
+ const addStyles = (css, base) => {
177
+ styles.push(css);
178
+ for (const [, , ref = ''] of css.replace(RE_CSS_COMMENT, '').matchAll(RE_IMPORT)) {
179
+ let target;
180
+ try {
181
+ target = base === null ? ref : page.resolve(ref, base);
182
+ } catch {
183
+ continue;
184
+ }
185
+ if (imported.has(target)) continue;
186
+ imported.add(target);
187
+ pending.push(page.load(target).then((sheet) => { if (sheet) addStyles(sheet.text, sheet.ref); }));
188
+ }
189
+ };
190
+
191
+ for (const element of elementsOf(page.html)) {
192
+ for (const { name, value } of element.attrs) {
193
+ if (/^on[a-z]{3,}$/.test(name)) scripts.push(value);
194
+ if (name === 'style') styles.push(`[style]{${value}}`);
195
+ }
196
+ if (element.tagName === 'script' && RE_SCRIPT_JS.test((attr(element, 'type') ?? '').trim())) {
197
+ const src = attr(element, 'src');
198
+ if (src === undefined) {
199
+ scripts.push(textOf(element));
200
+ } else {
201
+ pending.push(page.load(src).then((script) => { script === null ? scriptsUnread.push(src) : scripts.push(script.text); }));
202
+ }
203
+ } else if (element.tagName === 'style') {
204
+ addStyles(textOf(element), null);
205
+ } else if (element.tagName === 'link' && /(?:^|\s)stylesheet(?:\s|$)/i.test(attr(element, 'rel') ?? '')) {
206
+ const href = attr(element, 'href');
207
+ if (href) pending.push(page.load(href).then((sheet) => { if (sheet) addStyles(sheet.text, sheet.ref); }));
208
+ }
209
+ }
210
+ // Imports add to what is pending while it settles
211
+ while (pending.length) await Promise.all(pending.splice(0));
212
+
213
+ const scriptText = scripts.join('\n;\n');
214
+ const css = styles.join('\n');
215
+ /** @type {Set<string>} */
216
+ const scriptStrings = new Set();
217
+ /** @type {Set<string>} */
218
+ const attributeRefs = new Set();
219
+ for (const literal of scriptText.matchAll(RE_STRING_LITERAL)) {
220
+ const content = literal[2] ?? '';
221
+ for (const word of content.match(RE_WORD) ?? []) scriptStrings.add(word);
222
+ for (const match of content.matchAll(RE_ATTRIBUTE_SELECTOR)) attributeRefs.add((match[1] ?? '').toLowerCase());
223
+ }
224
+ for (const match of scriptText.matchAll(RE_ATTRIBUTE_CALL)) attributeRefs.add((match[1] ?? '').toLowerCase());
225
+ for (const match of css.matchAll(RE_ATTRIBUTE_SELECTOR)) attributeRefs.add((match[1] ?? '').toLowerCase());
226
+
227
+ return { scriptStrings, scriptText, scriptsUnread: scriptsUnread.sort(), attributeRefs, whitespaceRules: whitespaceRulesOf(css) };
228
+ }
229
+
230
+ /**
231
+ * What may break when an option turns `before` into `after`
232
+ * @param {string} name Candidate name
233
+ * @param {PageContext} context
234
+ * @param {string} before Output without the option
235
+ * @param {string} after Output with the option
236
+ * @returns {Hazard[]}
237
+ */
238
+ export function findHazards(name, context, before, after) {
239
+ if (before === after) return [];
240
+ /** @type {Hazard[]} */
241
+ const hazards = [];
242
+
243
+ if (OPTIONS_WHITESPACE.has(name) && context.whitespaceRules.length) {
244
+ const elements = elementsOf(before);
245
+ const applying = context.whitespaceRules.filter((rule) => mayMatch(rule.selector, elements));
246
+ if (applying.length) {
247
+ hazards.push({ level: 'check', message: `Collapses whitespace that CSS keeps (${applying.slice(0, 2).map((rule) => rule.text).join('; ')})` });
248
+ }
249
+ }
250
+
251
+ if (name === 'removeComments') {
252
+ /** @param {string} html */
253
+ const comments = (html) => {
254
+ /** @type {string[]} */
255
+ const data = [];
256
+ walk(parse(html), (node) => { if (node.nodeName === '#comment' && 'data' in node) data.push(node.data); });
257
+ return data;
258
+ };
259
+ const gone = removed(comments(before), comments(after));
260
+ /** @type {Map<string, number>} */
261
+ const perLabel = new Map();
262
+ for (const data of gone) {
263
+ const conditional = RE_COMMENT_CONDITIONAL.test(data);
264
+ const label = conditional ? 'conditional comment' : COMMENTS_MEANINGFUL.find(([re]) => re.test(data))?.[1];
265
+ if (!label || (perLabel.get(label) ?? 0) >= MAX_PER_KIND) continue;
266
+ perLabel.set(label, (perLabel.get(label) ?? 0) + 1);
267
+ hazards.push(conditional
268
+ ? { level: 'note', message: `Removes a conditional comment, which only Internet Explorer 9 and older read: <!--${clip(data, 40)}-->` }
269
+ : { level: 'risk', message: `Removes a comment that looks meaningful (${label}): <!--${clip(data, 40)}-->` });
270
+ }
271
+ if (gone.length && RE_READS_COMMENTS.test(context.scriptText)) {
272
+ hazards.push({ level: 'risk', message: 'Removes comments, and scripts read comments' });
273
+ }
274
+ }
275
+
276
+ if (name === 'removeEmptyElements') {
277
+ /** @param {string} html */
278
+ const signatures = (html) => elementsOf(html).map((element) => JSON.stringify([element.tagName, attr(element, 'id') ?? '', attr(element, 'class') ?? '']));
279
+ const gone = removed(signatures(before), signatures(after));
280
+ let count = 0;
281
+ for (const signature of gone) {
282
+ const [tag, id, classes] = /** @type {[string, string, string]} */ (JSON.parse(signature));
283
+ const named = [id, ...classes.split(/\s+/)].filter((token) => token && context.scriptStrings.has(token));
284
+ if (named.length && count++ < MAX_PER_KIND) {
285
+ const shown = `<${tag}${id ? ` id="${id}"` : ''}${classes ? ` class="${clip(classes, 30)}"` : ''}>`;
286
+ hazards.push({ level: 'risk', message: `Removes empty ${shown}, which scripts may fill (${named.map((token) => `\`${token}\``).join(', ')})` });
287
+ }
288
+ }
289
+ if (gone.length && context.scriptsUnread.length) {
290
+ hazards.push({ level: 'note', message: `Removes empty elements that unread scripts may fill (${clip(context.scriptsUnread.join(', '))})` });
291
+ }
292
+ }
293
+
294
+ if (name === 'removeUnusedCSS') {
295
+ /** @param {string} html */
296
+ const tokens = (html) => [...new Set(elementsOf(html).filter((element) => element.tagName === 'style').flatMap((element) => selectorTokens(textOf(element))))];
297
+ const gone = removed(tokens(before), tokens(after));
298
+ const named = gone.filter((token) => context.scriptStrings.has(token.slice(1)));
299
+ if (named.length) {
300
+ hazards.push({ level: 'risk', message: `Removes rules for ${named.slice(0, MAX_PER_KIND).map((token) => `\`${token}\``).join(', ')}, which scripts name and may add` });
301
+ }
302
+ if (gone.length && context.scriptsUnread.length) {
303
+ hazards.push({ level: 'note', message: `Removes rules that unread scripts may need (${clip(context.scriptsUnread.join(', '))})` });
304
+ }
305
+ }
306
+
307
+ if (OPTIONS_ATTRIBUTES.has(name)) {
308
+ /** @param {string} html */
309
+ const attributes = (html) => elementsOf(html).flatMap((element) => element.attrs.map(({ name: attrName, value }) => JSON.stringify([attrName, value])));
310
+ const changed = new Set(removed(attributes(before), attributes(after)).map((key) => /** @type {string} */ (JSON.parse(key)[0])));
311
+ const referenced = [...changed].filter((attrName) => context.attributeRefs.has(attrName)).sort();
312
+ if (referenced.length) {
313
+ hazards.push({ level: 'risk', message: `Changes ${referenced.map((attrName) => `\`${attrName}\``).join(', ')} attributes, which style sheets or scripts select on` });
314
+ }
315
+ }
316
+
317
+ if (name === 'useShortDoctype') {
318
+ const [modeBefore = '', modeAfter = ''] = [before, after].map((html) => String(parse(html).mode));
319
+ if (modeBefore !== modeAfter) {
320
+ hazards.push({ level: 'check', message: `Switches the page from ${MODES[modeBefore] ?? modeBefore} to ${MODES[modeAfter] ?? modeAfter}, which can change layout` });
321
+ }
322
+ }
323
+
324
+ if (name === 'mergeScripts' && /\bcurrentScript\b/.test(context.scriptText)) {
325
+ hazards.push({ level: 'check', message: 'Scripts use `document.currentScript`, which merging changes' });
326
+ }
327
+
328
+ return hazards;
329
+ }
package/src/index.js ADDED
@@ -0,0 +1,8 @@
1
+ export { CANDIDATES, CARRIED, EXCLUDED, FALLBACKS, MODIFIERS, PRESERVED, splitConfig } from './catalog.js';
2
+ export { loadEquivalence } from './equivalence.js';
3
+ export { analyzePage, findHazards } from './hazards.js';
4
+ export { measure } from './measure.js';
5
+ export { createPage, loadPages } from './pages.js';
6
+ export { createRenderer } from './render.js';
7
+ export { formatReport, toConfig } from './report.js';
8
+ export { customize } from './select.js';
package/src/measure.js ADDED
@@ -0,0 +1,60 @@
1
+ import { promisify } from 'node:util';
2
+ import zlib from 'node:zlib';
3
+
4
+ // As in HTML Minifier’s benchmarks: common defaults for on-the-fly compression (quality 11, for
5
+ // files precompressed at build time, takes seconds per large file)
6
+ export const GZIP_LEVEL = 6;
7
+ export const BROTLI_QUALITY = 6;
8
+
9
+ const brotliCompress = promisify(zlib.brotliCompress);
10
+ const gzipCompress = promisify(zlib.gzip);
11
+
12
+ /** @typedef {{raw: number, gzip: number, brotli: number}} Sizes */
13
+ /** @typedef {keyof Sizes} Metric */
14
+
15
+ /** @type {Metric[]} */
16
+ export const METRICS = ['raw', 'gzip', 'brotli'];
17
+
18
+ /** @type {Sizes} */
19
+ export const SIZES_ZERO = Object.freeze({ raw: 0, gzip: 0, brotli: 0 });
20
+
21
+ /**
22
+ * Size by one metric as servers would send it, compressed off the main thread
23
+ * @param {string} text
24
+ * @param {Metric} metric
25
+ * @returns {Promise<number>}
26
+ */
27
+ export async function sizeOf(text, metric) {
28
+ const buffer = Buffer.from(text, 'utf8');
29
+ if (metric === 'raw') return buffer.length;
30
+ if (metric === 'gzip') return (await gzipCompress(buffer, { level: GZIP_LEVEL })).length;
31
+ return (await brotliCompress(buffer, {
32
+ params: {
33
+ [zlib.constants.BROTLI_PARAM_MODE]: zlib.constants.BROTLI_MODE_TEXT,
34
+ [zlib.constants.BROTLI_PARAM_QUALITY]: BROTLI_QUALITY,
35
+ [zlib.constants.BROTLI_PARAM_SIZE_HINT]: buffer.length
36
+ }
37
+ })).length;
38
+ }
39
+
40
+ /**
41
+ * @param {string} text
42
+ * @returns {Promise<Sizes>}
43
+ */
44
+ export async function measure(text) {
45
+ const [raw, gzip, brotli] = await Promise.all([sizeOf(text, 'raw'), sizeOf(text, 'gzip'), sizeOf(text, 'brotli')]);
46
+ return { raw, gzip, brotli };
47
+ }
48
+
49
+ /**
50
+ * @param {Sizes} a
51
+ * @param {Sizes} b
52
+ * @returns {Sizes}
53
+ */
54
+ export const addSizes = (a, b) => ({ raw: a.raw + b.raw, gzip: a.gzip + b.gzip, brotli: a.brotli + b.brotli });
55
+
56
+ /**
57
+ * @param {Sizes[]} list
58
+ * @returns {Sizes}
59
+ */
60
+ export const sumSizes = (list) => list.reduce(addSizes, SIZES_ZERO);
package/src/pages.js ADDED
@@ -0,0 +1,230 @@
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
+ /**
106
+ * Picks pages round-robin across directories, so that a sample covers a site’s sections (and
107
+ * so, likely, its templates) rather than the first directory’s pages
108
+ * @param {string[]} paths
109
+ * @param {number} count
110
+ * @returns {string[]}
111
+ */
112
+ export function samplePaths(paths, count) {
113
+ if (paths.length <= count) return paths;
114
+ /** @type {Map<string, string[]>} */
115
+ const byDir = new Map();
116
+ for (const p of [...paths].sort()) {
117
+ const dir = path.dirname(p);
118
+ const queue = byDir.get(dir) ?? [];
119
+ queue.push(p);
120
+ byDir.set(dir, queue);
121
+ }
122
+ // Within a directory, a golden-ratio order spreads picks rather than taking its first pages
123
+ const queues = [...byDir.values()].map((queue) => queue
124
+ .map((item, index) => ({ item, key: (index * 0.6180339887) % 1 }))
125
+ .sort((a, b) => a.key - b.key)
126
+ .map(({ item }) => item));
127
+ /** @type {string[]} */
128
+ const sample = [];
129
+ for (let round = 0; sample.length < count; round++) {
130
+ for (const queue of queues) {
131
+ const item = queue[round];
132
+ if (item !== undefined && sample.length < count) sample.push(item);
133
+ }
134
+ }
135
+ return sample;
136
+ }
137
+
138
+ /**
139
+ * @param {string} dir
140
+ * @param {string[]} extensions
141
+ * @param {string[]} ignore
142
+ * @returns {Promise<string[]>}
143
+ */
144
+ async function walk(dir, extensions, ignore) {
145
+ /** @type {string[]} */
146
+ const files = [];
147
+ for (const entry of await fs.promises.readdir(dir, { withFileTypes: true })) {
148
+ if (entry.name.startsWith('.') || ignore.includes(entry.name)) continue;
149
+ const full = path.join(dir, entry.name);
150
+ if (entry.isDirectory()) {
151
+ files.push(...await walk(full, extensions, ignore));
152
+ } else if (entry.isFile() && (extensions.includes('*') || extensions.includes(path.extname(entry.name).slice(1).toLowerCase()))) {
153
+ files.push(full);
154
+ }
155
+ }
156
+ return files;
157
+ }
158
+
159
+ /**
160
+ * Symbolic links under the root that lead outside it, which the render check follows only where
161
+ * allowed, as a page’s scripts could send on what they lead to
162
+ * @param {string} root
163
+ * @param {string[]} [ignore]
164
+ * @returns {Promise<{link: string, target: string}[]>}
165
+ */
166
+ export async function findOutsideLinks(root, ignore = ['node_modules']) {
167
+ const rootReal = await fs.promises.realpath(root);
168
+ /** @type {{link: string, target: string}[]} */
169
+ const links = [];
170
+ /** @param {string} dir */
171
+ const visit = async (dir) => {
172
+ for (const entry of await fs.promises.readdir(dir, { withFileTypes: true }).catch(() => [])) {
173
+ if (entry.name.startsWith('.') || ignore.includes(entry.name)) continue;
174
+ const link = path.join(dir, entry.name);
175
+ if (entry.isDirectory()) {
176
+ await visit(link);
177
+ } else if (entry.isSymbolicLink()) {
178
+ const target = await fs.promises.realpath(link).catch(() => null);
179
+ if (target && !isInside(rootReal, target)) links.push({ link, target });
180
+ }
181
+ }
182
+ };
183
+ await visit(root);
184
+ return links;
185
+ }
186
+
187
+ /**
188
+ * Reads pages from files, directories, and URLs
189
+ * @param {string[]} inputs
190
+ * @param {{extensions?: string[], ignore?: string[], sample?: number}} [settings]
191
+ * @returns {Promise<Page[]>}
192
+ */
193
+ export async function loadPages(inputs, { extensions = EXTENSIONS_DEFAULT, ignore = ['node_modules'], sample = 20 } = {}) {
194
+ // As HTML Minifier reads them, with `*` for all files
195
+ const extensionsRead = extensions.map((ext) => ext.trim().replace(/^\.+/, '').toLowerCase());
196
+ /** @type {{file: string, root: string}[]} */
197
+ const files = [];
198
+ /** @type {string[]} */
199
+ const urls = [];
200
+ for (const input of inputs) {
201
+ if (RE_URL_INPUT.test(input)) {
202
+ urls.push(input);
203
+ continue;
204
+ }
205
+ const resolved = path.resolve(input);
206
+ const stat = await fs.promises.stat(resolved).catch(() => null);
207
+ if (!stat) throw new Error(`Not found: ${input}`);
208
+ if (stat.isDirectory()) {
209
+ for (const file of await walk(resolved, extensionsRead, ignore)) files.push({ file, root: resolved });
210
+ } else {
211
+ files.push({ file: resolved, root: path.dirname(resolved) });
212
+ }
213
+ }
214
+
215
+ const keys = samplePaths([...files.map(({ file }) => file), ...urls], sample);
216
+ return Promise.all(keys.map(async (key) => {
217
+ if (RE_URL_INPUT.test(key)) {
218
+ const response = await fetch(key, { signal: AbortSignal.timeout(30000) });
219
+ if (!response.ok) throw new Error(`Couldn’t fetch ${key}: ${response.status}`);
220
+ return createPage({ id: key, html: await response.text(), url: response.url });
221
+ }
222
+ const { root } = /** @type {{file: string, root: string}} */ (files.find(({ file }) => file === key));
223
+ return createPage({
224
+ id: path.relative(root, key).split(path.sep).join('/'),
225
+ html: await fs.promises.readFile(key, 'utf8'),
226
+ file: key,
227
+ root
228
+ });
229
+ }));
230
+ }