@linxiraos/pi-utils 1.1.27 → 1.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.
Files changed (59) hide show
  1. package/CHANGELOG.md +4 -6
  2. package/README.md +1 -1
  3. package/dist/types/ar/index.d.ts +1 -0
  4. package/dist/types/ar/zip-package.d.ts +21 -0
  5. package/dist/types/color.d.ts +2 -0
  6. package/dist/types/dirs.d.ts +31 -12
  7. package/dist/types/docx/converter.d.ts +2 -0
  8. package/dist/types/dom/core.d.ts +15 -4
  9. package/dist/types/dom/parser.d.ts +5 -3
  10. package/dist/types/dom/selector.d.ts +7 -1
  11. package/dist/types/logger/rotating-file.d.ts +4 -2
  12. package/dist/types/loop-phase.d.ts +12 -7
  13. package/dist/types/marked/core.d.ts +7 -6
  14. package/dist/types/marked-list.d.ts +38 -0
  15. package/dist/types/math-delimiters.d.ts +10 -0
  16. package/dist/types/readability/types.d.ts +2 -0
  17. package/dist/types/temp.d.ts +7 -0
  18. package/dist/types/turndown/types.d.ts +7 -0
  19. package/dist/types/vterm/buffer.d.ts +16 -5
  20. package/package.json +2 -2
  21. package/src/ar/index.ts +1 -0
  22. package/src/ar/open.ts +29 -8
  23. package/src/ar/zip-package.ts +57 -0
  24. package/src/chalk.ts +66 -23
  25. package/src/color.ts +6 -0
  26. package/src/dirs.ts +84 -24
  27. package/src/docx/converter.ts +26 -12
  28. package/src/dom/core.ts +354 -111
  29. package/src/dom/parser.ts +42 -19
  30. package/src/dom/selector.ts +328 -95
  31. package/src/fetch-retry.ts +2 -0
  32. package/src/format.ts +5 -1
  33. package/src/json-lexer.ts +54 -0
  34. package/src/json-parse.ts +21 -6
  35. package/src/logger/rotating-file.ts +5 -3
  36. package/src/logger.ts +39 -23
  37. package/src/loop-phase.ts +66 -21
  38. package/src/marked/core.ts +42 -18
  39. package/src/marked-list.ts +73 -0
  40. package/src/math-delimiters.ts +16 -0
  41. package/src/module-timer.ts +5 -5
  42. package/src/postmortem.ts +1 -1
  43. package/src/process-name.ts +2 -2
  44. package/src/prompt.ts +3 -1
  45. package/src/ptree.ts +28 -35
  46. package/src/readability/readability.ts +349 -39
  47. package/src/readability/types.ts +2 -0
  48. package/src/runtime-install.ts +2 -2
  49. package/src/stream.ts +34 -13
  50. package/src/temp.ts +7 -0
  51. package/src/template.ts +7 -4
  52. package/src/timing-buffer.ts +1 -1
  53. package/src/tls-fetch.ts +1 -1
  54. package/src/turndown/gfm.ts +16 -5
  55. package/src/turndown/html.ts +28 -8
  56. package/src/turndown/service.ts +43 -15
  57. package/src/turndown/types.ts +7 -0
  58. package/src/vterm/buffer.ts +31 -11
  59. package/src/vterm/terminal.ts +213 -100
package/src/dom/parser.ts CHANGED
@@ -1,6 +1,14 @@
1
1
  import { Document, type DocumentFragment, Element, Text } from "./core";
2
2
 
3
- const RAW_TEXT_ELEMENTS: Record<string, true> = { script: true, style: true };
3
+ /** Closing-tag patterns for raw-text elements, reused across parses (callers reset `lastIndex`). */
4
+ const RAW_TEXT_CLOSE: Record<string, RegExp> = { script: /<\/script\s*>/gi, style: /<\/style\s*>/gi };
5
+ const DOCTYPE_PATTERN = /<!doctype\b/iy;
6
+ const TAG_NAME_PATTERN = /[^\s/>]+/y;
7
+ const ATTRIBUTE_NAME_PATTERN = /[^\s=/>]+/y;
8
+ const UNQUOTED_VALUE_PATTERN = /[^\s>]+/y;
9
+ const SVG_NAMESPACE = "http://www.w3.org/2000/svg";
10
+ const HTML_NAMESPACE = "http://www.w3.org/1999/xhtml";
11
+
4
12
  const VOID_ELEMENTS: Record<string, true> = {
5
13
  area: true,
6
14
  base: true,
@@ -172,9 +180,12 @@ const NAMED_ENTITIES: Record<string, string> = {
172
180
  yen: "¥",
173
181
  };
174
182
 
183
+ const ENTITY_PATTERN = /&(#(?:x[0-9a-f]+|\d+)|[a-z][a-z0-9]+);/gi;
184
+
175
185
  /** Decode common named entities and all valid numeric character references. */
176
186
  export function decodeEntities(value: string): string {
177
- return value.replace(/&(#(?:x[0-9a-f]+|\d+)|[a-z][a-z0-9]+);/gi, (whole, entity: string) => {
187
+ if (!value.includes("&")) return value;
188
+ return value.replace(ENTITY_PATTERN, (whole, entity: string) => {
178
189
  if (entity[0] !== "#") return NAMED_ENTITIES[entity] ?? NAMED_ENTITIES[entity.toLowerCase()] ?? whole;
179
190
  const hexadecimal = entity[1]?.toLowerCase() === "x";
180
191
  const codePoint = Number.parseInt(entity.slice(hexadecimal ? 2 : 1), hexadecimal ? 16 : 10);
@@ -210,7 +221,8 @@ function parseStartTag(
210
221
  ): { name: string; attributes: Array<[string, string]>; selfClosing: boolean } | null {
211
222
  let index = 0;
212
223
  while (/\s/.test(source[index] ?? "")) index++;
213
- const nameMatch = /^[^\s/>]+/.exec(source.slice(index));
224
+ TAG_NAME_PATTERN.lastIndex = index;
225
+ const nameMatch = TAG_NAME_PATTERN.exec(source);
214
226
  if (!nameMatch) return null;
215
227
  const name = nameMatch[0].toLowerCase();
216
228
  index += nameMatch[0].length;
@@ -222,7 +234,8 @@ function parseStartTag(
222
234
  selfClosing = true;
223
235
  break;
224
236
  }
225
- const attributeMatch = /^[^\s=/>]+/.exec(source.slice(index));
237
+ ATTRIBUTE_NAME_PATTERN.lastIndex = index;
238
+ const attributeMatch = ATTRIBUTE_NAME_PATTERN.exec(source);
226
239
  if (!attributeMatch) break;
227
240
  const attributeName = attributeMatch[0];
228
241
  index += attributeMatch[0].length;
@@ -243,7 +256,8 @@ function parseStartTag(
243
256
  index = end + 1;
244
257
  }
245
258
  } else {
246
- const valueMatch = /^[^\s>]+/.exec(source.slice(index));
259
+ UNQUOTED_VALUE_PATTERN.lastIndex = index;
260
+ const valueMatch = UNQUOTED_VALUE_PATTERN.exec(source);
247
261
  value = valueMatch?.[0] ?? "";
248
262
  index += value.length;
249
263
  }
@@ -266,7 +280,8 @@ function closeOptionalElements(stack: Element[], incoming: string): void {
266
280
  if (closeable?.includes(current.localName)) stack.pop();
267
281
  }
268
282
 
269
- function parseInto(html: string, document: Document, root: Document | DocumentFragment, contextTag?: string): void {
283
+ /** Parse markup, appending the resulting nodes to `root` (a document, fragment, or element). */
284
+ export function parseInto(html: string, document: Document, root: Document | DocumentFragment | Element): void {
270
285
  const stack: Element[] = [];
271
286
  const xmlLike = root instanceof Document && /^\s*(?:<\?xml[\s\S]*?\?>\s*)?<(?:feed|rss)\b/i.test(html);
272
287
  let parent: Document | DocumentFragment | Element = root;
@@ -286,7 +301,7 @@ function parseInto(html: string, document: Document, root: Document | DocumentFr
286
301
  index = end;
287
302
  continue;
288
303
  }
289
- if (/^<!doctype\b/i.test(html.slice(index))) {
304
+ if (html[index + 1] === "!" && isDoctypeAt(html, index)) {
290
305
  const end = html.indexOf(">", index + 2);
291
306
  index = end < 0 ? html.length : end + 1;
292
307
  continue;
@@ -325,12 +340,13 @@ function parseInto(html: string, document: Document, root: Document | DocumentFr
325
340
  }
326
341
  closeOptionalElements(stack, parsed.name);
327
342
  parent = stack[stack.length - 1] ?? root;
343
+ // The root itself never supplies a namespace: fragments and documents are namespace-less, and
344
+ // element roots (innerHTML) parse their top-level nodes as HTML like a detached fragment would.
328
345
  const namespace =
329
- parent instanceof Element && parent.namespaceURI === "http://www.w3.org/2000/svg"
330
- ? "http://www.w3.org/2000/svg"
331
- : parsed.name === "svg"
332
- ? "http://www.w3.org/2000/svg"
333
- : "http://www.w3.org/1999/xhtml";
346
+ (parent !== root && parent instanceof Element && parent.namespaceURI === SVG_NAMESPACE) ||
347
+ parsed.name === "svg"
348
+ ? SVG_NAMESPACE
349
+ : HTML_NAMESPACE;
334
350
  const element = document.createElementNS(namespace, parsed.name);
335
351
  for (let attributeIndex = parsed.attributes.length - 1; attributeIndex >= 0; attributeIndex--) {
336
352
  const [name, value] = parsed.attributes[attributeIndex];
@@ -338,8 +354,11 @@ function parseInto(html: string, document: Document, root: Document | DocumentFr
338
354
  }
339
355
  parent.appendChild(element);
340
356
  index = end + 1;
341
- if (RAW_TEXT_ELEMENTS[parsed.name] && !parsed.selfClosing) {
342
- const closePattern = new RegExp(`</${parsed.name}\\s*>`, "ig");
357
+ if (RAW_TEXT_CLOSE[parsed.name] && !parsed.selfClosing) {
358
+ // Prototype keys (e.g. `<constructor>`) were historically treated as raw text too; keep that.
359
+ const closePattern = Object.hasOwn(RAW_TEXT_CLOSE, parsed.name)
360
+ ? RAW_TEXT_CLOSE[parsed.name]
361
+ : new RegExp(`</${parsed.name}\\s*>`, "ig");
343
362
  closePattern.lastIndex = index;
344
363
  const match = closePattern.exec(html);
345
364
  const rawEnd = match?.index ?? html.length;
@@ -347,18 +366,22 @@ function parseInto(html: string, document: Document, root: Document | DocumentFr
347
366
  index = match ? closePattern.lastIndex : html.length;
348
367
  continue;
349
368
  }
350
- if (!VOID_ELEMENTS[parsed.name] && !(namespace === "http://www.w3.org/2000/svg" && parsed.selfClosing)) {
369
+ if (!VOID_ELEMENTS[parsed.name] && !(namespace === SVG_NAMESPACE && parsed.selfClosing)) {
351
370
  stack.push(element);
352
371
  parent = element;
353
372
  }
354
373
  }
355
- void contextTag;
356
374
  }
357
375
 
358
- /** Parse markup into a document fragment. */
359
- export function parseFragment(html: string, document: Document, contextTag?: string): DocumentFragment {
376
+ function isDoctypeAt(html: string, index: number): boolean {
377
+ DOCTYPE_PATTERN.lastIndex = index;
378
+ return DOCTYPE_PATTERN.test(html);
379
+ }
380
+
381
+ /** Parse markup into a document fragment. `_contextTag` is accepted for compatibility and ignored. */
382
+ export function parseFragment(html: string, document: Document, _contextTag?: string): DocumentFragment {
360
383
  const fragment = document.createDocumentFragment();
361
- parseInto(html, document, fragment, contextTag);
384
+ parseInto(html, document, fragment);
362
385
  return fragment;
363
386
  }
364
387
 
@@ -1,10 +1,69 @@
1
1
  import { type Document, type DocumentFragment, Element, Node } from "./core";
2
2
 
3
+ interface AttributeTest {
4
+ name: string;
5
+ operator: string | undefined;
6
+ value: string;
7
+ ignoreCase: boolean;
8
+ }
9
+
10
+ type NthTest =
11
+ | { kind: "odd" | "even" | "invalid" }
12
+ | { kind: "index"; index: number }
13
+ | { kind: "formula"; coefficient: number; offset: number };
14
+
15
+ interface PseudoTest {
16
+ name: string;
17
+ /** Compiled argument for `:not`, `:is`, `:where`. */
18
+ list: SelectorList | null;
19
+ nth: NthTest | null;
20
+ }
21
+
22
+ interface CompoundSelector {
23
+ /** Lower-cased tag name, or null for any element. */
24
+ tag: string | null;
25
+ ids: string[];
26
+ classes: string[];
27
+ attributes: AttributeTest[];
28
+ pseudos: PseudoTest[];
29
+ /** Malformed or unsupported compounds never match. */
30
+ invalid: boolean;
31
+ /** Whether only `tag` (or nothing) constrains the compound. */
32
+ bare: boolean;
33
+ }
34
+
3
35
  interface ComplexSelector {
4
- simples: string[];
36
+ compounds: CompoundSelector[];
5
37
  combinators: string[];
6
38
  }
7
39
 
40
+ interface SelectorList {
41
+ /** A bare `*` part matches every element. */
42
+ universal: boolean;
43
+ /** Bare tag parts, matched by `localName`. */
44
+ tags: Set<string> | null;
45
+ complexes: ComplexSelector[];
46
+ }
47
+
48
+ const SUPPORTED_PSEUDOS: Record<string, true> = {
49
+ not: true,
50
+ is: true,
51
+ where: true,
52
+ "first-child": true,
53
+ "last-child": true,
54
+ "only-child": true,
55
+ empty: true,
56
+ root: true,
57
+ "nth-child": true,
58
+ "first-of-type": true,
59
+ "last-of-type": true,
60
+ };
61
+ const ATTRIBUTE_PATTERN =
62
+ /^\s*([^\s~|^$*!=]+)\s*(?:(\^=|\$=|\*=|~=|\|=|=)\s*(?:(["'])(.*?)\3|([^\s]+))\s*([isIS])?)?\s*$/;
63
+ const IDENTIFIER_CHARACTER = /[a-zA-Z0-9_-]/;
64
+ const MAX_CACHED_SELECTORS = 512;
65
+ const compiledSelectors = new Map<string, SelectorList>();
66
+
8
67
  function splitTopLevel(value: string, delimiter: string): string[] {
9
68
  const parts: string[] = [];
10
69
  let start = 0;
@@ -31,7 +90,7 @@ function splitTopLevel(value: string, delimiter: string): string[] {
31
90
  return parts.filter(Boolean);
32
91
  }
33
92
 
34
- function parseComplex(selector: string): ComplexSelector {
93
+ function splitComplex(selector: string): { simples: string[]; combinators: string[] } {
35
94
  const simples: string[] = [];
36
95
  const combinators: string[] = [];
37
96
  let current = "";
@@ -92,7 +151,7 @@ function readIdentifier(source: string, start: number): { value: string; end: nu
92
151
  index += 2;
93
152
  continue;
94
153
  }
95
- if (!/[a-zA-Z0-9_-]/.test(character)) break;
154
+ if (!IDENTIFIER_CHARACTER.test(character)) break;
96
155
  value += character;
97
156
  index++;
98
157
  }
@@ -113,22 +172,166 @@ function findClosing(source: string, start: number, opener: string, closer: stri
113
172
  return source.length - 1;
114
173
  }
115
174
 
116
- function matchAttribute(element: Element, expression: string): boolean {
117
- const match = /^\s*([^\s~|^$*!=]+)\s*(?:(\^=|\$=|\*=|~=|\|=|=)\s*(?:(["'])(.*?)\3|([^\s]+))\s*([isIS])?)?\s*$/.exec(
118
- expression,
119
- );
120
- if (!match) return false;
175
+ function compileAttribute(expression: string): AttributeTest | null {
176
+ const match = ATTRIBUTE_PATTERN.exec(expression);
177
+ if (!match) return null;
121
178
  const [, name, operator, , quotedValue, bareValue, flag] = match;
122
- const actual = element.getAttribute(name);
123
- if (!operator) return actual !== null;
124
- if (actual === null) return false;
125
- let left = actual;
126
- let right = (quotedValue ?? bareValue ?? "").replace(/\\(.)/g, "$1");
127
- if (flag?.toLowerCase() === "i") {
128
- left = left.toLowerCase();
129
- right = right.toLowerCase();
179
+ const ignoreCase = flag?.toLowerCase() === "i";
180
+ const value = (quotedValue ?? bareValue ?? "").replace(/\\(.)/g, "$1");
181
+ return { name, operator, value: ignoreCase ? value.toLowerCase() : value, ignoreCase };
182
+ }
183
+
184
+ function compileNth(expression: string): NthTest {
185
+ const normalized = expression.trim().toLowerCase().replace(/\s+/g, "");
186
+ if (normalized === "odd" || normalized === "even") return { kind: normalized };
187
+ if (/^[+-]?\d+$/.test(normalized)) return { kind: "index", index: Number(normalized) };
188
+ const match = /^([+-]?\d*)n([+-]\d+)?$/.exec(normalized);
189
+ if (!match) return { kind: "invalid" };
190
+ const coefficient = match[1] === "" || match[1] === "+" ? 1 : match[1] === "-" ? -1 : Number(match[1]);
191
+ return { kind: "formula", coefficient, offset: Number(match[2] ?? 0) };
192
+ }
193
+
194
+ function compilePseudo(name: string, argument: string | undefined): PseudoTest | null {
195
+ if (!SUPPORTED_PSEUDOS[name]) return null;
196
+ if (name === "not" || name === "is" || name === "where") {
197
+ return argument === undefined ? null : { name, list: compileSelector(argument), nth: null };
198
+ }
199
+ if (name === "nth-child") return argument === undefined ? null : { name, list: null, nth: compileNth(argument) };
200
+ return { name, list: null, nth: null };
201
+ }
202
+
203
+ function compileCompound(selector: string): CompoundSelector {
204
+ const compound: CompoundSelector = {
205
+ tag: null,
206
+ ids: [],
207
+ classes: [],
208
+ attributes: [],
209
+ pseudos: [],
210
+ invalid: false,
211
+ bare: false,
212
+ };
213
+ let index = 0;
214
+ if (selector[index] === "*") index++;
215
+ else if (/[a-zA-Z_]/.test(selector[index] ?? "")) {
216
+ const tag = readIdentifier(selector, index);
217
+ compound.tag = tag.value.toLowerCase();
218
+ index = tag.end;
219
+ }
220
+ while (index < selector.length) {
221
+ const marker = selector[index];
222
+ if (marker === "#" || marker === ".") {
223
+ const identifier = readIdentifier(selector, index + 1);
224
+ if (!identifier.value) {
225
+ compound.invalid = true;
226
+ break;
227
+ }
228
+ (marker === "#" ? compound.ids : compound.classes).push(identifier.value);
229
+ index = identifier.end;
230
+ continue;
231
+ }
232
+ if (marker === "[") {
233
+ const end = findClosing(selector, index, "[", "]");
234
+ const attribute = compileAttribute(selector.slice(index + 1, end));
235
+ if (!attribute) {
236
+ compound.invalid = true;
237
+ break;
238
+ }
239
+ compound.attributes.push(attribute);
240
+ index = end + 1;
241
+ continue;
242
+ }
243
+ if (marker === ":") {
244
+ const identifier = readIdentifier(selector, index + 1);
245
+ let argument: string | undefined;
246
+ index = identifier.end;
247
+ if (selector[index] === "(") {
248
+ const end = findClosing(selector, index, "(", ")");
249
+ argument = selector.slice(index + 1, end);
250
+ index = end + 1;
251
+ }
252
+ const pseudo = compilePseudo(identifier.value.toLowerCase(), argument);
253
+ if (!pseudo) {
254
+ compound.invalid = true;
255
+ break;
256
+ }
257
+ compound.pseudos.push(pseudo);
258
+ continue;
259
+ }
260
+ compound.invalid = true;
261
+ break;
262
+ }
263
+ compound.bare =
264
+ !compound.invalid &&
265
+ compound.ids.length === 0 &&
266
+ compound.classes.length === 0 &&
267
+ compound.attributes.length === 0 &&
268
+ compound.pseudos.length === 0;
269
+ return compound;
270
+ }
271
+
272
+ function compileSelector(selector: string): SelectorList {
273
+ const cached = compiledSelectors.get(selector);
274
+ if (cached) return cached;
275
+ const list: SelectorList = { universal: false, tags: null, complexes: [] };
276
+ for (const part of splitTopLevel(selector, ",")) {
277
+ const { simples, combinators } = splitComplex(part);
278
+ if (!simples.length) continue;
279
+ const compounds = simples.map(compileCompound);
280
+ const only = compounds[0];
281
+ if (compounds.length === 1 && only.bare) {
282
+ if (only.tag === null) list.universal = true;
283
+ else (list.tags ??= new Set()).add(only.tag);
284
+ continue;
285
+ }
286
+ list.complexes.push({ compounds, combinators });
287
+ }
288
+ if (compiledSelectors.size >= MAX_CACHED_SELECTORS) compiledSelectors.clear();
289
+ compiledSelectors.set(selector, list);
290
+ return list;
291
+ }
292
+
293
+ function isWhitespace(code: number): boolean {
294
+ return (
295
+ code === 0x20 ||
296
+ (code >= 0x09 && code <= 0x0d) ||
297
+ code === 0xa0 ||
298
+ code === 0x1680 ||
299
+ (code >= 0x2000 && code <= 0x200a) ||
300
+ code === 0x2028 ||
301
+ code === 0x2029 ||
302
+ code === 0x202f ||
303
+ code === 0x205f ||
304
+ code === 0x3000 ||
305
+ code === 0xfeff
306
+ );
307
+ }
308
+
309
+ /** Whether a whitespace-separated class string contains a token, without splitting it. */
310
+ export function hasClassToken(className: string, token: string): boolean {
311
+ if (!token) return false;
312
+ for (let index = 0; index < token.length; index++) if (isWhitespace(token.charCodeAt(index))) return false;
313
+ let from = 0;
314
+ while (true) {
315
+ const found = className.indexOf(token, from);
316
+ if (found < 0) return false;
317
+ const end = found + token.length;
318
+ if (
319
+ (found === 0 || isWhitespace(className.charCodeAt(found - 1))) &&
320
+ (end === className.length || isWhitespace(className.charCodeAt(end)))
321
+ ) {
322
+ return true;
323
+ }
324
+ from = found + 1;
130
325
  }
131
- switch (operator) {
326
+ }
327
+
328
+ function matchAttribute(element: Element, test: AttributeTest): boolean {
329
+ const actual = element.getAttribute(test.name);
330
+ if (!test.operator) return actual !== null;
331
+ if (actual === null) return false;
332
+ const left = test.ignoreCase ? actual.toLowerCase() : actual;
333
+ const right = test.value;
334
+ switch (test.operator) {
132
335
  case "=":
133
336
  return left === right;
134
337
  case "^=":
@@ -146,41 +349,67 @@ function matchAttribute(element: Element, expression: string): boolean {
146
349
  }
147
350
  }
148
351
 
149
- function matchNth(element: Element, expression: string): boolean {
150
- const siblings = element.parentElement?.children ?? [];
151
- const index = siblings.indexOf(element) + 1;
152
- const normalized = expression.trim().toLowerCase().replace(/\s+/g, "");
153
- if (normalized === "odd") return index % 2 === 1;
154
- if (normalized === "even") return index % 2 === 0;
155
- if (/^[+-]?\d+$/.test(normalized)) return index === Number(normalized);
156
- const match = /^([+-]?\d*)n([+-]\d+)?$/.exec(normalized);
157
- if (!match) return false;
158
- const coefficient = match[1] === "" || match[1] === "+" ? 1 : match[1] === "-" ? -1 : Number(match[1]);
159
- const offset = Number(match[2] ?? 0);
160
- return coefficient === 0
161
- ? index === offset
162
- : (index - offset) / coefficient >= 0 && Number.isInteger((index - offset) / coefficient);
352
+ /** One-based position among element siblings, or 0 without a parent element. */
353
+ function elementIndex(element: Element): number {
354
+ const parent = element.parentElement;
355
+ if (!parent) return 0;
356
+ let index = 0;
357
+ for (const sibling of parent.childNodes) {
358
+ if (!(sibling instanceof Element)) continue;
359
+ index++;
360
+ if (sibling === element) return index;
361
+ }
362
+ return 0;
363
+ }
364
+
365
+ function matchNth(element: Element, test: NthTest): boolean {
366
+ if (test.kind === "invalid") return false;
367
+ const index = elementIndex(element);
368
+ switch (test.kind) {
369
+ case "odd":
370
+ return index % 2 === 1;
371
+ case "even":
372
+ return index % 2 === 0;
373
+ case "index":
374
+ return index === test.index;
375
+ case "formula":
376
+ return test.coefficient === 0
377
+ ? index === test.offset
378
+ : (index - test.offset) / test.coefficient >= 0 &&
379
+ Number.isInteger((index - test.offset) / test.coefficient);
380
+ }
381
+ }
382
+
383
+ function hasSingleElementChild(parent: Element): boolean {
384
+ let count = 0;
385
+ for (const child of parent.childNodes) if (child instanceof Element && ++count > 1) return false;
386
+ return count === 1;
163
387
  }
164
388
 
165
- function matchPseudo(element: Element, name: string, argument: string | undefined): boolean {
166
- switch (name) {
389
+ function matchPseudo(element: Element, pseudo: PseudoTest): boolean {
390
+ switch (pseudo.name) {
167
391
  case "not":
168
- return argument !== undefined && !matchesSelector(element, argument);
392
+ return !matchesList(element, pseudo.list as SelectorList);
169
393
  case "is":
170
394
  case "where":
171
- return argument !== undefined && matchesSelector(element, argument);
395
+ return matchesList(element, pseudo.list as SelectorList);
172
396
  case "first-child":
173
397
  return element.parentElement?.firstElementChild === element;
174
398
  case "last-child":
175
399
  return element.parentElement?.lastElementChild === element;
176
- case "only-child":
177
- return element.parentElement?.children.length === 1;
400
+ case "only-child": {
401
+ const parent = element.parentElement;
402
+ return parent !== null && hasSingleElementChild(parent);
403
+ }
178
404
  case "empty":
179
- return element.childNodes.every(child => child.nodeType === Node.COMMENT_NODE || child.textContent === "");
405
+ return (
406
+ !element.hasChildNodes() ||
407
+ element.childNodes.every(child => child.nodeType === Node.COMMENT_NODE || child.textContent === "")
408
+ );
180
409
  case "root":
181
410
  return element.ownerDocument?.documentElement === element;
182
411
  case "nth-child":
183
- return argument !== undefined && matchNth(element, argument);
412
+ return matchNth(element, pseudo.nth as NthTest);
184
413
  case "first-of-type": {
185
414
  for (let sibling = element.previousElementSibling; sibling; sibling = sibling.previousElementSibling) {
186
415
  if (sibling.tagName === element.tagName) return false;
@@ -198,57 +427,30 @@ function matchPseudo(element: Element, name: string, argument: string | undefine
198
427
  }
199
428
  }
200
429
 
201
- function matchSimple(element: Element, selector: string): boolean {
202
- let index = 0;
203
- if (selector[index] === "*") index++;
204
- else if (/[a-zA-Z_]/.test(selector[index] ?? "")) {
205
- const tag = readIdentifier(selector, index);
206
- if (element.localName !== tag.value.toLowerCase()) return false;
207
- index = tag.end;
208
- }
209
- while (index < selector.length) {
210
- const marker = selector[index];
211
- if (marker === "#" || marker === ".") {
212
- const identifier = readIdentifier(selector, index + 1);
213
- if (!identifier.value) return false;
214
- if (marker === "#" ? element.id !== identifier.value : !element.classList.contains(identifier.value))
215
- return false;
216
- index = identifier.end;
217
- continue;
218
- }
219
- if (marker === "[") {
220
- const end = findClosing(selector, index, "[", "]");
221
- if (!matchAttribute(element, selector.slice(index + 1, end))) return false;
222
- index = end + 1;
223
- continue;
224
- }
225
- if (marker === ":") {
226
- const identifier = readIdentifier(selector, index + 1);
227
- let argument: string | undefined;
228
- index = identifier.end;
229
- if (selector[index] === "(") {
230
- const end = findClosing(selector, index, "(", ")");
231
- argument = selector.slice(index + 1, end);
232
- index = end + 1;
233
- }
234
- if (!matchPseudo(element, identifier.value.toLowerCase(), argument)) return false;
235
- continue;
236
- }
237
- return false;
430
+ function matchCompound(element: Element, compound: CompoundSelector): boolean {
431
+ if (compound.invalid) return false;
432
+ if (compound.tag !== null && element.localName !== compound.tag) return false;
433
+ if (compound.bare) return true;
434
+ for (const id of compound.ids) if (element.id !== id) return false;
435
+ if (compound.classes.length) {
436
+ const className = element.className;
437
+ for (const token of compound.classes) if (!hasClassToken(className, token)) return false;
238
438
  }
439
+ for (const attribute of compound.attributes) if (!matchAttribute(element, attribute)) return false;
440
+ for (const pseudo of compound.pseudos) if (!matchPseudo(element, pseudo)) return false;
239
441
  return true;
240
442
  }
241
443
 
242
444
  function matchComplexAt(element: Element, complex: ComplexSelector, index: number): boolean {
243
- if (!matchSimple(element, complex.simples[index])) return false;
445
+ if (!matchCompound(element, complex.compounds[index])) return false;
244
446
  if (index === 0) return true;
245
447
  const combinator = complex.combinators[index - 1] ?? " ";
246
448
  if (combinator === ">")
247
449
  return element.parentElement !== null && matchComplexAt(element.parentElement, complex, index - 1);
248
- if (combinator === "+")
249
- return (
250
- element.previousElementSibling !== null && matchComplexAt(element.previousElementSibling, complex, index - 1)
251
- );
450
+ if (combinator === "+") {
451
+ const previous = element.previousElementSibling;
452
+ return previous !== null && matchComplexAt(previous, complex, index - 1);
453
+ }
252
454
  if (combinator === "~") {
253
455
  for (let sibling = element.previousElementSibling; sibling; sibling = sibling.previousElementSibling) {
254
456
  if (matchComplexAt(sibling, complex, index - 1)) return true;
@@ -261,30 +463,61 @@ function matchComplexAt(element: Element, complex: ComplexSelector, index: numbe
261
463
  return false;
262
464
  }
263
465
 
466
+ function matchesList(element: Element, list: SelectorList): boolean {
467
+ if (list.universal) return true;
468
+ if (list.tags?.has(element.localName)) return true;
469
+ for (const complex of list.complexes) {
470
+ if (matchComplexAt(element, complex, complex.compounds.length - 1)) return true;
471
+ }
472
+ return false;
473
+ }
474
+
475
+ function collectMatches(nodes: Node[], list: SelectorList, result: Element[]): void {
476
+ for (let index = 0; index < nodes.length; index++) {
477
+ const node = nodes[index];
478
+ if (node instanceof Element && matchesList(node, list)) result.push(node);
479
+ if (node.hasChildNodes()) collectMatches(node.childNodes, list, result);
480
+ }
481
+ }
482
+
483
+ function findMatch(nodes: Node[], list: SelectorList): Element | null {
484
+ for (let index = 0; index < nodes.length; index++) {
485
+ const node = nodes[index];
486
+ if (node instanceof Element && matchesList(node, list)) return node;
487
+ if (node.hasChildNodes()) {
488
+ const found = findMatch(node.childNodes, list);
489
+ if (found) return found;
490
+ }
491
+ }
492
+ return null;
493
+ }
494
+
264
495
  /** Whether an element matches a CSS selector list. */
265
496
  export function matchesSelector(element: Element, selector: string): boolean {
266
- for (const part of splitTopLevel(selector, ",")) {
267
- const complex = parseComplex(part);
268
- if (complex.simples.length && matchComplexAt(element, complex, complex.simples.length - 1)) return true;
497
+ return matchesList(element, compileSelector(selector));
498
+ }
499
+
500
+ /** Nearest inclusive ancestor of an element matching a CSS selector list. */
501
+ export function closestMatching(element: Element, selector: string): Element | null {
502
+ const list = compileSelector(selector);
503
+ for (let current: Element | null = element; current; current = current.parentElement) {
504
+ if (matchesList(current, list)) return current;
269
505
  }
270
- return false;
506
+ return null;
271
507
  }
272
508
 
273
509
  /** Query descendants of a node in document order. */
274
510
  export function querySelectorAllFrom(
275
511
  root: Document | DocumentFragment | Element,
276
512
  selector: string,
277
- includeRoot: boolean,
513
+ _includeRoot: boolean,
278
514
  ): Element[] {
279
515
  const result: Element[] = [];
280
- const visit = (node: Node): void => {
281
- if (node instanceof Element && matchesSelector(node, selector)) result.push(node);
282
- for (const child of node.childNodes) visit(child);
283
- };
284
- if (includeRoot) {
285
- for (const child of root.childNodes) visit(child);
286
- } else {
287
- for (const child of root.childNodes) visit(child);
288
- }
516
+ if (root.hasChildNodes()) collectMatches(root.childNodes, compileSelector(selector), result);
289
517
  return result;
290
518
  }
519
+
520
+ /** First descendant of a node in document order matching a selector, stopping at the first match. */
521
+ export function querySelectorFrom(root: Document | DocumentFragment | Element, selector: string): Element | null {
522
+ return root.hasChildNodes() ? findMatch(root.childNodes, compileSelector(selector)) : null;
523
+ }
@@ -382,6 +382,8 @@ export async function fetchWithRetry(
382
382
  if (hint !== undefined && hint > maxDelayMs) return response;
383
383
 
384
384
  const delayMs = Math.min(hint ?? resolveDefaultDelay(defaultDelayMs, attempt, maxDelayMs), maxDelayMs);
385
+ // The retried response is discarded; release its unread tee branch.
386
+ if (!response.bodyUsed) response.body?.cancel().catch(() => {});
385
387
  await waitForRetry(delayMs, signal);
386
388
  }
387
389
  }
package/src/format.ts CHANGED
@@ -10,7 +10,11 @@ const DAY = 24 * HOUR;
10
10
  export function formatDuration(ms: number): string {
11
11
  if (!Number.isFinite(ms) || ms <= 0) return "0ms";
12
12
  if (ms < SEC) return `${Math.floor(ms)}ms`;
13
- if (ms < MIN) return `${(ms / SEC).toFixed(1)}s`;
13
+ if (ms < MIN) {
14
+ // Round to tenths first: `toFixed(1)` alone turns 59.95s+ into an impossible `60.0s`.
15
+ const tenths = Math.round(ms / 100);
16
+ return tenths < 600 ? `${(tenths / 10).toFixed(1)}s` : "1m";
17
+ }
14
18
  if (ms < HOUR) {
15
19
  const mins = Math.floor(ms / MIN);
16
20
  const secs = Math.floor((ms % MIN) / SEC);