@oh-my-pi/pi-utils 18.7.0 → 18.8.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 (42) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/dist/types/ar/index.d.ts +1 -0
  3. package/dist/types/ar/zip-package.d.ts +21 -0
  4. package/dist/types/docx/converter.d.ts +2 -0
  5. package/dist/types/dom/core.d.ts +15 -4
  6. package/dist/types/dom/parser.d.ts +5 -3
  7. package/dist/types/dom/selector.d.ts +7 -1
  8. package/dist/types/logger/rotating-file.d.ts +4 -2
  9. package/dist/types/marked/core.d.ts +7 -6
  10. package/dist/types/marked-list.d.ts +38 -0
  11. package/dist/types/math-delimiters.d.ts +10 -0
  12. package/dist/types/readability/types.d.ts +2 -0
  13. package/dist/types/turndown/types.d.ts +7 -0
  14. package/dist/types/vterm/buffer.d.ts +16 -5
  15. package/package.json +2 -2
  16. package/src/ar/index.ts +1 -0
  17. package/src/ar/zip-package.ts +57 -0
  18. package/src/chalk.ts +66 -23
  19. package/src/docx/converter.ts +26 -12
  20. package/src/dom/core.ts +354 -111
  21. package/src/dom/parser.ts +42 -19
  22. package/src/dom/selector.ts +328 -95
  23. package/src/fetch-retry.ts +2 -0
  24. package/src/json-parse.ts +21 -6
  25. package/src/logger/rotating-file.ts +5 -3
  26. package/src/logger.ts +39 -23
  27. package/src/marked/core.ts +42 -18
  28. package/src/marked-list.ts +73 -0
  29. package/src/math-delimiters.ts +16 -0
  30. package/src/prompt.ts +3 -1
  31. package/src/ptree.ts +28 -35
  32. package/src/readability/readability.ts +349 -39
  33. package/src/readability/types.ts +2 -0
  34. package/src/stream.ts +34 -13
  35. package/src/temp.ts +1 -10
  36. package/src/template.ts +7 -4
  37. package/src/turndown/gfm.ts +16 -5
  38. package/src/turndown/html.ts +28 -8
  39. package/src/turndown/service.ts +43 -15
  40. package/src/turndown/types.ts +7 -0
  41. package/src/vterm/buffer.ts +31 -11
  42. package/src/vterm/terminal.ts +185 -92
package/src/dom/core.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  /** Behavior-compatible reimplementation of linkedom's used surface. */
2
2
 
3
- import { parseFragment } from "./parser";
4
- import { matchesSelector, querySelectorAllFrom } from "./selector";
3
+ import { parseFragment, parseInto } from "./parser";
4
+ import { closestMatching, hasClassToken, matchesSelector, querySelectorAllFrom, querySelectorFrom } from "./selector";
5
5
 
6
6
  /** DOM node type constants. */
7
7
  export const enum NodeType {
@@ -83,29 +83,32 @@ type EventListener = ((event: Event) => void) | { handleEvent(event: Event): voi
83
83
 
84
84
  /** Minimal event-target implementation used by DOM nodes and window. */
85
85
  export class EventTarget {
86
- #listeners = new Map<string, Set<EventListener>>();
86
+ /** Allocated on the first `addEventListener`; most nodes never get listeners. */
87
+ #listeners: Map<string, Set<EventListener>> | undefined;
87
88
 
88
89
  /** Register an event listener. */
89
90
  addEventListener(type: string, listener: EventListener | null): void {
90
91
  if (!listener) return;
91
- let listeners = this.#listeners.get(type);
92
+ const listenerMap = (this.#listeners ??= new Map());
93
+ let listeners = listenerMap.get(type);
92
94
  if (!listeners) {
93
95
  listeners = new Set();
94
- this.#listeners.set(type, listeners);
96
+ listenerMap.set(type, listeners);
95
97
  }
96
98
  listeners.add(listener);
97
99
  }
98
100
 
99
101
  /** Remove an event listener. */
100
102
  removeEventListener(type: string, listener: EventListener | null): void {
101
- if (listener) this.#listeners.get(type)?.delete(listener);
103
+ if (listener) this.#listeners?.get(type)?.delete(listener);
102
104
  }
103
105
 
104
106
  /** Dispatch an event to listeners and optionally through node ancestors. */
105
107
  dispatchEvent(event: Event): boolean {
106
108
  if (!event.target) event.target = this;
107
109
  event.currentTarget = this;
108
- for (const listener of Array.from(this.#listeners.get(event.type) ?? [])) {
110
+ const listeners = this.#listeners?.get(event.type);
111
+ for (const listener of listeners ? Array.from(listeners) : []) {
109
112
  if (typeof listener === "function") listener.call(this, event);
110
113
  else listener.handleEvent(event);
111
114
  if (event.propagationStopped) break;
@@ -117,6 +120,9 @@ export class EventTarget {
117
120
  }
118
121
  }
119
122
 
123
+ /** Index of a node in its parent's `childNodes`, or -1; bound to Node's private index hint. */
124
+ let indexInParent: (node: Node) => number;
125
+
120
126
  /** Base class for the implemented DOM tree. */
121
127
  export class Node extends EventTarget {
122
128
  static readonly ELEMENT_NODE = NodeType.ELEMENT;
@@ -129,7 +135,17 @@ export class Node extends EventTarget {
129
135
  readonly nodeName: string;
130
136
  parentNode: Node | null = null;
131
137
  ownerDocument: Document | null;
132
- childNodes: Node[] = [];
138
+ /** Allocated on first access or insertion, so leaf nodes (text, comments, attributes) usually never allocate. */
139
+ #childNodes: Node[] | undefined;
140
+ /** Last known index in the parent's `childNodes`; always verified before use, so a stale value only costs a rescan. */
141
+ #index = 0;
142
+
143
+ static {
144
+ indexInParent = node => {
145
+ const siblings = node.#siblings();
146
+ return siblings ? node.#indexIn(siblings) : -1;
147
+ };
148
+ }
133
149
 
134
150
  constructor(nodeType: number, nodeName: string, ownerDocument: Document | null = null) {
135
151
  super();
@@ -138,28 +154,105 @@ export class Node extends EventTarget {
138
154
  this.ownerDocument = ownerDocument;
139
155
  }
140
156
 
157
+ /** Child nodes in tree order. */
158
+ get childNodes(): Node[] {
159
+ this.#childNodes ??= [];
160
+ return this.#childNodes;
161
+ }
162
+
163
+ set childNodes(value: Node[]) {
164
+ this.#childNodes = value;
165
+ }
166
+
167
+ /** Whether this node has any children (without allocating a child list). */
168
+ hasChildNodes(): boolean {
169
+ return this.#childNodes !== undefined && this.#childNodes.length > 0;
170
+ }
171
+
141
172
  /** First child node, if present. */
142
173
  get firstChild(): Node | null {
143
- return this.childNodes[0] ?? null;
174
+ return this.#childNodes?.[0] ?? null;
144
175
  }
145
176
 
146
177
  /** Last child node, if present. */
147
178
  get lastChild(): Node | null {
148
- return this.childNodes[this.childNodes.length - 1] ?? null;
179
+ const children = this.#childNodes;
180
+ return children ? (children[children.length - 1] ?? null) : null;
149
181
  }
150
182
 
151
183
  /** Previous node with the same parent. */
152
184
  get previousSibling(): Node | null {
153
- if (!this.parentNode) return null;
154
- const index = this.parentNode.childNodes.indexOf(this);
155
- return index > 0 ? this.parentNode.childNodes[index - 1] : null;
185
+ const siblings = this.#siblings();
186
+ if (!siblings) return null;
187
+ const index = this.#indexIn(siblings);
188
+ return index > 0 ? siblings[index - 1] : null;
156
189
  }
157
190
 
158
191
  /** Next node with the same parent. */
159
192
  get nextSibling(): Node | null {
160
- if (!this.parentNode) return null;
161
- const index = this.parentNode.childNodes.indexOf(this);
162
- return index >= 0 ? (this.parentNode.childNodes[index + 1] ?? null) : null;
193
+ const siblings = this.#siblings();
194
+ if (!siblings) return null;
195
+ const index = this.#indexIn(siblings);
196
+ return index >= 0 ? (siblings[index + 1] ?? null) : null;
197
+ }
198
+
199
+ /** The parent's child list, without allocating one. */
200
+ #siblings(): Node[] | undefined {
201
+ const parent = this.parentNode;
202
+ return parent ? parent.#childNodes : undefined;
203
+ }
204
+
205
+ /** Locate this node in `siblings` via the index hint, re-indexing all siblings on a miss. */
206
+ #indexIn(siblings: Node[]): number {
207
+ const hint = this.#index;
208
+ if (siblings[hint] === this) return hint;
209
+ if (siblings[hint + 1] === this) return ++this.#index;
210
+ if (hint > 0 && siblings[hint - 1] === this) return --this.#index;
211
+ let found = -1;
212
+ for (let index = 0; index < siblings.length; index++) {
213
+ const sibling = siblings[index];
214
+ sibling.#index = index;
215
+ if (found < 0 && sibling === this) found = index;
216
+ }
217
+ if (found >= 0) this.#index = found;
218
+ return found;
219
+ }
220
+
221
+ /** Index of a child in this node's `childNodes`, or -1. */
222
+ #childIndex(child: Node): number {
223
+ const children = this.#childNodes;
224
+ if (!children) return -1;
225
+ return child?.parentNode === this ? child.#indexIn(children) : children.indexOf(child);
226
+ }
227
+
228
+ /** Move every child of a fragment into this node at `index` (append for null). */
229
+ #adoptChildren(fragment: DocumentFragment, index: number | null): void {
230
+ const moved = fragment.#childNodes;
231
+ if (!moved?.length) return;
232
+ if (moved.some(nested => nested.parentNode !== fragment || nested instanceof DocumentFragment)) {
233
+ // Inconsistent fragment state: keep the one-at-a-time semantics.
234
+ const reference = index === null ? null : (this.#childNodes?.[index] ?? null);
235
+ for (const nested of Array.from(moved)) {
236
+ if (reference === null) this.appendChild(nested);
237
+ else this.insertBefore(nested, reference);
238
+ }
239
+ return;
240
+ }
241
+ const document = this.documentForCreation();
242
+ const nodes = moved.slice();
243
+ moved.length = 0;
244
+ const children = (this.#childNodes ??= []);
245
+ const tail = index === null ? [] : children.splice(index);
246
+ for (const node of nodes) {
247
+ node.parentNode = this;
248
+ node.setOwnerDocument(document);
249
+ node.#index = children.length;
250
+ children.push(node);
251
+ }
252
+ for (const node of tail) {
253
+ node.#index = children.length;
254
+ children.push(node);
255
+ }
163
256
  }
164
257
 
165
258
  /** Connectedness to a document. */
@@ -178,7 +271,11 @@ export class Node extends EventTarget {
178
271
 
179
272
  /** Text contained by this node. */
180
273
  get textContent(): string | null {
181
- return this.childNodes.map(child => child.textContent ?? "").join("");
274
+ const children = this.#childNodes;
275
+ if (!children?.length) return "";
276
+ const only = children[0];
277
+ if (children.length === 1 && only instanceof Text) return only.data;
278
+ return collectText(children);
182
279
  }
183
280
 
184
281
  set textContent(value: string | null) {
@@ -196,44 +293,48 @@ export class Node extends EventTarget {
196
293
  const node: Node = child;
197
294
  if (node === this || node.contains(this)) throw new Error("The new child is an ancestor of this node");
198
295
  if (child instanceof DocumentFragment) {
199
- for (const nested of Array.from(child.childNodes)) this.appendChild(nested);
296
+ this.#adoptChildren(child, null);
200
297
  return child;
201
298
  }
202
299
  child.parentNode?.removeChild(child);
203
300
  child.parentNode = this;
204
301
  child.setOwnerDocument(this.documentForCreation());
205
- this.childNodes.push(child);
302
+ const children = (this.#childNodes ??= []);
303
+ node.#index = children.length;
304
+ children.push(child);
206
305
  return child;
207
306
  }
208
307
 
209
308
  /** Insert a node before a current child, or append for null. */
210
309
  insertBefore<T extends Node>(child: T, reference: Node | null): T {
211
310
  if (reference === null) return this.appendChild(child);
212
- const index = this.childNodes.indexOf(reference);
311
+ const index = this.#childIndex(reference);
213
312
  if (index < 0) throw new Error("The reference node is not a child of this node");
214
313
  if (child instanceof DocumentFragment) {
215
- for (const nested of Array.from(child.childNodes)) this.insertBefore(nested, reference);
314
+ this.#adoptChildren(child, index);
216
315
  return child;
217
316
  }
218
317
  child.parentNode?.removeChild(child);
219
318
  child.parentNode = this;
220
319
  child.setOwnerDocument(this.documentForCreation());
320
+ const node: Node = child;
321
+ node.#index = index;
221
322
  this.childNodes.splice(index, 0, child);
222
323
  return child;
223
324
  }
224
325
 
225
326
  /** Replace a current child with another node. */
226
327
  replaceChild<T extends Node>(child: Node, previous: T): T {
227
- const index = this.childNodes.indexOf(previous);
328
+ const index = this.#childIndex(previous);
228
329
  if (index < 0) throw new Error("The node to replace is not a child of this node");
229
330
  this.removeChild(previous);
230
- this.insertBefore(child, this.childNodes[index] ?? null);
331
+ this.insertBefore(child, this.#childNodes?.[index] ?? null);
231
332
  return previous;
232
333
  }
233
334
 
234
335
  /** Remove a current child. */
235
336
  removeChild<T extends Node>(child: T): T {
236
- const index = this.childNodes.indexOf(child);
337
+ const index = this.#childIndex(child);
237
338
  if (index < 0) throw new Error("The node to remove is not a child of this node");
238
339
  this.childNodes.splice(index, 1);
239
340
  child.parentNode = null;
@@ -242,8 +343,9 @@ export class Node extends EventTarget {
242
343
 
243
344
  /** Replace all children with nodes or strings. */
244
345
  replaceChildren(...children: Array<Node | string>): void {
245
- for (const child of this.childNodes) child.parentNode = null;
246
- this.childNodes = [];
346
+ const previous = this.#childNodes;
347
+ if (previous) for (const child of previous) child.parentNode = null;
348
+ this.#childNodes = undefined;
247
349
  this.append(...children);
248
350
  }
249
351
 
@@ -289,7 +391,7 @@ export class Node extends EventTarget {
289
391
  /** Clone this node, optionally including descendants. */
290
392
  cloneNode(deep = false): Node {
291
393
  const clone = new Node(this.nodeType, this.nodeName, this.ownerDocument);
292
- if (deep) for (const child of this.childNodes) clone.appendChild(child.cloneNode(true));
394
+ if (deep && this.#childNodes) for (const child of this.#childNodes) clone.appendChild(child.cloneNode(true));
293
395
  return clone;
294
396
  }
295
397
 
@@ -302,7 +404,7 @@ export class Node extends EventTarget {
302
404
  const nodes: Node[] = [];
303
405
  const visit = (node: Node): void => {
304
406
  nodes.push(node);
305
- for (const child of node.childNodes) visit(child);
407
+ for (const child of node.#childNodes ?? []) visit(child);
306
408
  };
307
409
  visit(root);
308
410
  return nodes.indexOf(this) < nodes.indexOf(other) ? 4 : 2;
@@ -310,7 +412,8 @@ export class Node extends EventTarget {
310
412
 
311
413
  setOwnerDocument(document: Document): void {
312
414
  if (this.nodeType !== NodeType.DOCUMENT) this.ownerDocument = document;
313
- for (const child of this.childNodes) child.setOwnerDocument(document);
415
+ const children = this.#childNodes;
416
+ if (children) for (const child of children) child.setOwnerDocument(document);
314
417
  }
315
418
 
316
419
  documentForCreation(): Document {
@@ -417,7 +520,11 @@ export class NamedNodeMap extends Array<Attr> {
417
520
  /** Find an attribute case-insensitively. */
418
521
  getNamedItem(name: string): Attr | null {
419
522
  const normalized = name.toLowerCase();
420
- return this.find(attr => attr.name.toLowerCase() === normalized) ?? null;
523
+ for (let index = 0; index < this.length; index++) {
524
+ const attr = this[index];
525
+ if (attr.name === normalized || attr.name.toLowerCase() === normalized) return attr;
526
+ }
527
+ return null;
421
528
  }
422
529
 
423
530
  /** Set an attribute object and return the prior one. */
@@ -480,7 +587,7 @@ export class DOMTokenList implements Iterable<string> {
480
587
 
481
588
  /** Whether a class token exists. */
482
589
  contains(token: string): boolean {
483
- return this.#tokens().includes(token);
590
+ return hasClassToken(this.#element.className, token);
484
591
  }
485
592
 
486
593
  /** Toggle a class token. */
@@ -518,15 +625,20 @@ export class DOMTokenList implements Iterable<string> {
518
625
 
519
626
  /** Mutable inline style declaration. */
520
627
  export class CSSStyleDeclaration {
521
- #values = new Map<string, string>();
628
+ /** Allocated on the first non-empty declaration. */
629
+ #values: Map<string, string> | undefined;
630
+ /** Serialized declarations; `undefined` after a change until the next read. */
631
+ #cssText: string | undefined = "";
522
632
 
523
633
  /** Serialized declarations. */
524
634
  get cssText(): string {
525
- return [...this.#values].map(([name, value]) => `${name}: ${value};`).join(" ");
635
+ this.#cssText ??= this.#values ? [...this.#values].map(([name, value]) => `${name}: ${value};`).join(" ") : "";
636
+ return this.#cssText;
526
637
  }
527
638
 
528
639
  set cssText(value: string) {
529
- this.#values.clear();
640
+ this.#values?.clear();
641
+ this.#cssText = undefined;
530
642
  for (const declaration of value.split(";")) {
531
643
  const colon = declaration.indexOf(":");
532
644
  if (colon > 0) this.setProperty(declaration.slice(0, colon).trim(), declaration.slice(colon + 1).trim());
@@ -535,19 +647,24 @@ export class CSSStyleDeclaration {
535
647
 
536
648
  /** Read a CSS property. */
537
649
  getPropertyValue(name: string): string {
538
- return this.#values.get(name) ?? "";
650
+ return this.#values?.get(name) ?? "";
539
651
  }
540
652
 
541
653
  /** Set a CSS property. */
542
654
  setProperty(name: string, value: string | null, _priority?: string): void {
543
- if (value === null || value === "") this.#values.delete(name);
544
- else this.#values.set(name, String(value));
655
+ if (value === null || value === "") {
656
+ if (this.#values?.delete(name)) this.#cssText = undefined;
657
+ return;
658
+ }
659
+ this.#values ??= new Map();
660
+ this.#values.set(name, String(value));
661
+ this.#cssText = undefined;
545
662
  }
546
663
 
547
664
  /** Remove and return a CSS property. */
548
665
  removeProperty(name: string): string {
549
666
  const value = this.getPropertyValue(name);
550
- this.#values.delete(name);
667
+ if (this.#values?.delete(name)) this.#cssText = undefined;
551
668
  return value;
552
669
  }
553
670
  }
@@ -555,15 +672,23 @@ export class CSSStyleDeclaration {
555
672
  const HTML_NAMESPACE = "http://www.w3.org/1999/xhtml";
556
673
  const SVG_NAMESPACE = "http://www.w3.org/2000/svg";
557
674
 
675
+ /** An element's inline style when one has been created; bound to Element's private field. */
676
+ let inlineStyleOf: (element: Element) => CSSStyleDeclaration | undefined;
677
+
558
678
  /** DOM element with attributes, selectors, and HTML serialization. */
559
679
  export class Element extends Node {
560
680
  readonly localName: string;
561
681
  readonly tagName: string;
562
682
  readonly namespaceURI: string | null;
563
683
  readonly qualifiedName: string;
564
- readonly attributes = new NamedNodeMap();
565
- readonly classList: DOMTokenList;
566
- readonly style = new CSSStyleDeclaration();
684
+ /** Attribute, class-list and style objects are created on first use. */
685
+ #attributes: NamedNodeMap | undefined;
686
+ #classList: DOMTokenList | undefined;
687
+ #style: CSSStyleDeclaration | undefined;
688
+
689
+ static {
690
+ inlineStyleOf = element => element.#style;
691
+ }
567
692
 
568
693
  constructor(tagName: string, ownerDocument: Document | null = null, namespaceURI: string | null = HTML_NAMESPACE) {
569
694
  const localName = tagName.toLowerCase();
@@ -573,34 +698,67 @@ export class Element extends Node {
573
698
  this.tagName = qualified;
574
699
  this.qualifiedName = tagName;
575
700
  this.namespaceURI = namespaceURI;
576
- this.classList = new DOMTokenList(this);
701
+ }
702
+
703
+ /** Attribute collection. */
704
+ get attributes(): NamedNodeMap {
705
+ this.#attributes ??= new NamedNodeMap();
706
+ return this.#attributes;
707
+ }
708
+
709
+ /** Class token list over the `class` attribute. */
710
+ get classList(): DOMTokenList {
711
+ this.#classList ??= new DOMTokenList(this);
712
+ return this.#classList;
713
+ }
714
+
715
+ /** Inline style declaration. */
716
+ get style(): CSSStyleDeclaration {
717
+ this.#style ??= new CSSStyleDeclaration();
718
+ return this.#style;
719
+ }
720
+
721
+ /** Whether the element has any attribute objects (without allocating the collection). */
722
+ hasAttributes(): boolean {
723
+ return this.#attributes !== undefined && this.#attributes.length > 0;
577
724
  }
578
725
 
579
726
  /** Element children. */
580
727
  get children(): Element[] {
581
- return this.childNodes.filter((child): child is Element => child instanceof Element);
728
+ return childElements(this);
582
729
  }
583
730
 
584
731
  /** First element child. */
585
732
  get firstElementChild(): Element | null {
586
- return this.children[0] ?? null;
733
+ return firstElementIn(this);
587
734
  }
588
735
 
589
736
  /** Last element child. */
590
737
  get lastElementChild(): Element | null {
591
- const children = this.children;
592
- return children[children.length - 1] ?? null;
738
+ return lastElementIn(this);
593
739
  }
594
740
 
595
741
  /** Previous sibling that is an element. */
596
742
  get previousElementSibling(): Element | null {
597
- for (let node = this.previousSibling; node; node = node.previousSibling) if (node instanceof Element) return node;
743
+ const index = indexInParent(this);
744
+ if (index < 0) return null;
745
+ const siblings = (this.parentNode as Node).childNodes;
746
+ for (let position = index - 1; position >= 0; position--) {
747
+ const sibling = siblings[position];
748
+ if (sibling instanceof Element) return sibling;
749
+ }
598
750
  return null;
599
751
  }
600
752
 
601
753
  /** Next sibling that is an element. */
602
754
  get nextElementSibling(): Element | null {
603
- for (let node = this.nextSibling; node; node = node.nextSibling) if (node instanceof Element) return node;
755
+ const index = indexInParent(this);
756
+ if (index < 0) return null;
757
+ const siblings = (this.parentNode as Node).childNodes;
758
+ for (let position = index + 1; position < siblings.length; position++) {
759
+ const sibling = siblings[position];
760
+ if (sibling instanceof Element) return sibling;
761
+ }
604
762
  return null;
605
763
  }
606
764
 
@@ -663,13 +821,12 @@ export class Element extends Node {
663
821
 
664
822
  /** HTML contained inside this element. */
665
823
  get innerHTML(): string {
666
- return this.childNodes.map(serializeNode).join("");
824
+ return serializeChildren(this);
667
825
  }
668
826
 
669
827
  set innerHTML(value: string) {
670
828
  this.replaceChildren();
671
- for (const child of parseFragment(value, this.documentForCreation(), this.localName).childNodes.slice())
672
- this.appendChild(child);
829
+ parseInto(value, this.documentForCreation(), this);
673
830
  }
674
831
 
675
832
  /** Serialized element and descendants. */
@@ -680,26 +837,30 @@ export class Element extends Node {
680
837
  set outerHTML(value: string) {
681
838
  const parent = this.parentNode;
682
839
  if (!parent) return;
683
- const fragment = parseFragment(value, this.documentForCreation(), this.parentElement?.localName);
684
- for (const child of Array.from(fragment.childNodes)) parent.insertBefore(child, this);
840
+ parent.insertBefore(parseFragment(value, this.documentForCreation()), this);
685
841
  parent.removeChild(this);
686
842
  }
687
843
 
688
844
  /** Read an attribute. */
689
845
  getAttribute(name: string): string | null {
690
- if (name.toLowerCase() === "style" && this.style.cssText) return this.style.cssText;
691
- return this.attributes.getNamedItem(name)?.value ?? null;
846
+ const style = this.#style;
847
+ if (style && name.toLowerCase() === "style") {
848
+ const cssText = style.cssText;
849
+ if (cssText) return cssText;
850
+ }
851
+ return this.#attributes?.getNamedItem(name)?.value ?? null;
692
852
  }
693
853
 
694
854
  /** Read an attribute object. */
695
855
  getAttributeNode(name: string): Attr | null {
696
- return this.attributes.getNamedItem(name);
856
+ return this.#attributes?.getNamedItem(name) ?? null;
697
857
  }
698
858
 
699
859
  /** Whether an attribute exists. */
700
860
  hasAttribute(name: string): boolean {
701
861
  return (
702
- this.attributes.getNamedItem(name) !== null || (name.toLowerCase() === "style" && Boolean(this.style.cssText))
862
+ (this.#attributes?.getNamedItem(name) ?? null) !== null ||
863
+ (this.#style !== undefined && name.toLowerCase() === "style" && Boolean(this.#style.cssText))
703
864
  );
704
865
  }
705
866
 
@@ -707,9 +868,10 @@ export class Element extends Node {
707
868
  setAttribute(name: string, value: string): void {
708
869
  const normalized = name.toLowerCase();
709
870
  if (normalized === "style") this.style.cssText = String(value);
710
- const existing = this.attributes.getNamedItem(normalized);
871
+ const attributes = this.attributes;
872
+ const existing = attributes.getNamedItem(normalized);
711
873
  if (existing) existing.value = String(value);
712
- else this.attributes.unshift(new Attr(name, String(value), this.ownerDocument));
874
+ else attributes.unshift(new Attr(name, String(value), this.ownerDocument));
713
875
  }
714
876
 
715
877
  /** Set a namespaced attribute. */
@@ -719,14 +881,15 @@ export class Element extends Node {
719
881
 
720
882
  /** Remove an attribute. */
721
883
  removeAttribute(name: string): void {
722
- const existing = this.attributes.getNamedItem(name);
723
- if (existing) this.attributes.splice(this.attributes.indexOf(existing), 1);
724
- if (name.toLowerCase() === "style") this.style.cssText = "";
884
+ const attributes = this.#attributes;
885
+ const existing = attributes?.getNamedItem(name);
886
+ if (attributes && existing) attributes.splice(attributes.indexOf(existing), 1);
887
+ if (this.#style && name.toLowerCase() === "style") this.#style.cssText = "";
725
888
  }
726
889
 
727
890
  /** Find the first descendant matching a selector. */
728
891
  querySelector(selector: string): Element | null {
729
- return querySelectorAllFrom(this, selector, false)[0] ?? null;
892
+ return querySelectorFrom(this, selector);
730
893
  }
731
894
 
732
895
  /** Find all descendants matching a selector. */
@@ -741,10 +904,7 @@ export class Element extends Node {
741
904
 
742
905
  /** Find the nearest matching ancestor including this element. */
743
906
  closest(selector: string): Element | null {
744
- for (let element: Element | null = this; element; element = element.parentElement) {
745
- if (element.matches(selector)) return element;
746
- }
747
- return null;
907
+ return closestMatching(this, selector);
748
908
  }
749
909
 
750
910
  /** Descendant elements with a tag name. */
@@ -755,9 +915,10 @@ export class Element extends Node {
755
915
  /** Descendant elements containing all requested class tokens. */
756
916
  getElementsByClassName(classNames: string): Element[] {
757
917
  const tokens = classNames.trim().split(/\s+/).filter(Boolean);
758
- return querySelectorAllFrom(this, "*", false).filter(element =>
759
- tokens.every(token => element.classList.contains(token)),
760
- );
918
+ return querySelectorAllFrom(this, "*", false).filter(element => {
919
+ const className = element.className;
920
+ return tokens.every(token => hasClassToken(className, token));
921
+ });
761
922
  }
762
923
 
763
924
  /**
@@ -798,11 +959,14 @@ export class Element extends Node {
798
959
  const clone =
799
960
  this.ownerDocument?.createElementNS(this.namespaceURI, this.qualifiedName) ??
800
961
  new Element(this.qualifiedName, null, this.namespaceURI);
801
- for (let index = this.attributes.length - 1; index >= 0; index--) {
802
- const attr = this.attributes[index];
803
- clone.setAttribute(attr.name, attr.value);
962
+ const attributes = this.#attributes;
963
+ if (attributes) {
964
+ for (let index = attributes.length - 1; index >= 0; index--) {
965
+ const attr = attributes[index];
966
+ clone.setAttribute(attr.name, attr.value);
967
+ }
804
968
  }
805
- if (deep) for (const child of this.childNodes) clone.appendChild(child.cloneNode(true));
969
+ if (deep && this.hasChildNodes()) for (const child of this.childNodes) clone.appendChild(child.cloneNode(true));
806
970
  return clone;
807
971
  }
808
972
  }
@@ -885,23 +1049,22 @@ export class DocumentFragment extends Node {
885
1049
 
886
1050
  /** Element children. */
887
1051
  get children(): Element[] {
888
- return this.childNodes.filter((child): child is Element => child instanceof Element);
1052
+ return childElements(this);
889
1053
  }
890
1054
 
891
1055
  /** First element child. */
892
1056
  get firstElementChild(): Element | null {
893
- return this.children[0] ?? null;
1057
+ return firstElementIn(this);
894
1058
  }
895
1059
 
896
1060
  /** Last element child. */
897
1061
  get lastElementChild(): Element | null {
898
- const children = this.children;
899
- return children[children.length - 1] ?? null;
1062
+ return lastElementIn(this);
900
1063
  }
901
1064
 
902
1065
  /** First matching descendant. */
903
1066
  querySelector(selector: string): Element | null {
904
- return querySelectorAllFrom(this, selector, false)[0] ?? null;
1067
+ return querySelectorFrom(this, selector);
905
1068
  }
906
1069
 
907
1070
  /** All matching descendants. */
@@ -926,14 +1089,12 @@ export class HTMLTemplateElement extends HTMLElement {
926
1089
  }
927
1090
 
928
1091
  override get innerHTML(): string {
929
- return this.content.childNodes.map(serializeNode).join("");
1092
+ return serializeChildren(this.content);
930
1093
  }
931
1094
 
932
1095
  override set innerHTML(value: string) {
933
1096
  this.content.replaceChildren();
934
- for (const child of parseFragment(value, this.documentForCreation(), "template").childNodes.slice()) {
935
- this.content.appendChild(child);
936
- }
1097
+ parseInto(value, this.documentForCreation(), this.content);
937
1098
  }
938
1099
 
939
1100
  override get textContent(): string {
@@ -984,33 +1145,33 @@ export class Document extends Node {
984
1145
 
985
1146
  /** Root element of the document. */
986
1147
  get documentElement(): Element | null {
987
- return this.children[0] ?? null;
1148
+ return firstElementIn(this);
988
1149
  }
989
1150
 
990
1151
  /** Element children. */
991
1152
  get children(): Element[] {
992
- return this.childNodes.filter((child): child is Element => child instanceof Element);
1153
+ return childElements(this);
993
1154
  }
994
1155
 
995
1156
  /** HTML body, or a detached empty body when absent like linkedom. */
996
1157
  get body(): HTMLElement {
997
- const body = this.querySelector("body");
1158
+ const body = querySelectorFrom(this, "body");
998
1159
  return body instanceof HTMLElement ? body : this.createElement("body");
999
1160
  }
1000
1161
 
1001
1162
  /** HTML head, or a detached empty head when absent. */
1002
1163
  get head(): HTMLElement {
1003
- const head = this.querySelector("head");
1164
+ const head = querySelectorFrom(this, "head");
1004
1165
  return head instanceof HTMLElement ? head : this.createElement("head");
1005
1166
  }
1006
1167
 
1007
1168
  /** Document title text. */
1008
1169
  get title(): string {
1009
- return this.querySelector("title")?.textContent ?? "";
1170
+ return querySelectorFrom(this, "title")?.textContent ?? "";
1010
1171
  }
1011
1172
 
1012
1173
  set title(value: string) {
1013
- let title = this.querySelector("title");
1174
+ let title = querySelectorFrom(this, "title");
1014
1175
  if (!title) {
1015
1176
  title = this.createElement("title");
1016
1177
  this.head.appendChild(title);
@@ -1067,13 +1228,16 @@ export class Document extends Node {
1067
1228
 
1068
1229
  /** Find the first element by id. */
1069
1230
  getElementById(id: string): HTMLElement | null {
1070
- const element = this.querySelector(`#${cssEscapeIdentifier(id)}`);
1231
+ // Ids free of selector syntax compare directly; the rest keep the escaped-selector path's exact behaviour.
1232
+ const element = SELECTOR_SAFE_ID.test(id)
1233
+ ? findElementById(this, id)
1234
+ : querySelectorFrom(this, `#${cssEscapeIdentifier(id)}`);
1071
1235
  return element instanceof HTMLElement ? element : null;
1072
1236
  }
1073
1237
 
1074
1238
  /** Find the first matching descendant. */
1075
1239
  querySelector(selector: string): Element | null {
1076
- return querySelectorAllFrom(this, selector, true)[0] ?? null;
1240
+ return querySelectorFrom(this, selector);
1077
1241
  }
1078
1242
 
1079
1243
  /** Find all matching descendants. */
@@ -1089,7 +1253,10 @@ export class Document extends Node {
1089
1253
  /** Descendant elements containing all class tokens. */
1090
1254
  getElementsByClassName(classNames: string): Element[] {
1091
1255
  const tokens = classNames.trim().split(/\s+/).filter(Boolean);
1092
- return this.querySelectorAll("*").filter(element => tokens.every(token => element.classList.contains(token)));
1256
+ return this.querySelectorAll("*").filter(element => {
1257
+ const className = element.className;
1258
+ return tokens.every(token => hasClassToken(className, token));
1259
+ });
1093
1260
  }
1094
1261
 
1095
1262
  /** Adopt a node into this document. */
@@ -1186,6 +1353,7 @@ const VOID_ELEMENTS: Record<string, true> = {
1186
1353
  track: true,
1187
1354
  wbr: true,
1188
1355
  };
1356
+
1189
1357
  const BOOLEAN_ATTRIBUTES: Record<string, true> = {
1190
1358
  allowfullscreen: true,
1191
1359
  async: true,
@@ -1214,41 +1382,116 @@ const BOOLEAN_ATTRIBUTES: Record<string, true> = {
1214
1382
  selected: true,
1215
1383
  };
1216
1384
 
1385
+ const TEXT_ESCAPE_PATTERN = /[&\u00a0<>]/;
1386
+ /** Ids whose escaped `#id` selector parses as one plain id compound (no combinators, lists, quotes or groups). */
1387
+ const SELECTOR_SAFE_ID = /^[^\s,>+~"'[\]()\\]+$/;
1388
+
1217
1389
  function escapeText(value: string): string {
1218
- return value.replace(/&/g, "&amp;").replace(/ /g, "&#160;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
1390
+ if (!TEXT_ESCAPE_PATTERN.test(value)) return value;
1391
+ return value
1392
+ .replace(/&/g, "&amp;")
1393
+ .replace(/\u00a0/g, "&#160;")
1394
+ .replace(/</g, "&lt;")
1395
+ .replace(/>/g, "&gt;");
1219
1396
  }
1220
1397
 
1221
1398
  function escapeAttribute(value: string): string {
1222
- return value.replace(/"/g, "&quot;");
1399
+ return value.includes('"') ? value.replace(/"/g, "&quot;") : value;
1400
+ }
1401
+
1402
+ /** Serialize a node's children; `raw` emits text data unescaped (script/style content). */
1403
+ function serializeChildren(parent: Node, raw = false): string {
1404
+ if (!parent.hasChildNodes()) return "";
1405
+ let html = "";
1406
+ for (const child of parent.childNodes) html += raw && child instanceof Text ? child.data : serializeNode(child);
1407
+ return html;
1223
1408
  }
1224
1409
 
1225
1410
  /** Serialize a DOM node as HTML. */
1226
1411
  export function serializeNode(node: Node): string {
1227
1412
  if (node instanceof Comment) return `<!--${node.data}-->`;
1228
1413
  if (node instanceof Text) return node.serializeRaw ? node.data : escapeText(node.data);
1229
- if (node instanceof Document || node instanceof DocumentFragment) return node.childNodes.map(serializeNode).join("");
1414
+ if (node instanceof Document || node instanceof DocumentFragment) return serializeChildren(node);
1230
1415
  if (!(node instanceof Element)) return "";
1231
- const attributes = node.attributes
1232
- .map(attr =>
1233
- attr.value === "" && BOOLEAN_ATTRIBUTES[attr.name.toLowerCase()]
1234
- ? ` ${attr.name}`
1235
- : ` ${attr.name}="${escapeAttribute(attr.value)}"`,
1236
- )
1237
- .join("");
1238
- const style =
1239
- node.style.cssText && !node.attributes.getNamedItem("style")
1240
- ? ` style="${escapeAttribute(node.style.cssText)}"`
1241
- : "";
1242
- const opening = `<${node.qualifiedName}${attributes}${style}>`;
1416
+ let opening = `<${node.qualifiedName}`;
1417
+ const hasAttributes = node.hasAttributes();
1418
+ if (hasAttributes) {
1419
+ for (const attr of node.attributes) {
1420
+ opening +=
1421
+ attr.value === "" && BOOLEAN_ATTRIBUTES[attr.name.toLowerCase()]
1422
+ ? ` ${attr.name}`
1423
+ : ` ${attr.name}="${escapeAttribute(attr.value)}"`;
1424
+ }
1425
+ }
1426
+ const cssText = inlineStyleOf(node)?.cssText;
1427
+ if (cssText && !(hasAttributes && node.attributes.getNamedItem("style"))) {
1428
+ opening += ` style="${escapeAttribute(cssText)}"`;
1429
+ }
1430
+ opening += ">";
1243
1431
  if (VOID_ELEMENTS[node.localName]) return opening;
1244
1432
  const raw = node.localName === "script" || node.localName === "style";
1245
- const childNodes = node instanceof HTMLTemplateElement ? node.content.childNodes : node.childNodes;
1246
- const content = raw
1247
- ? childNodes.map(child => (child instanceof Text ? child.data : serializeNode(child))).join("")
1248
- : childNodes.map(serializeNode).join("");
1433
+ const content = serializeChildren(node instanceof HTMLTemplateElement ? node.content : node, raw);
1249
1434
  return `${opening}${content}</${node.qualifiedName}>`;
1250
1435
  }
1251
1436
 
1437
+ function childElements(parent: Node): Element[] {
1438
+ return parent.hasChildNodes() ? parent.childNodes.filter((child): child is Element => child instanceof Element) : [];
1439
+ }
1440
+
1441
+ function firstElementIn(parent: Node): Element | null {
1442
+ if (!parent.hasChildNodes()) return null;
1443
+ for (const child of parent.childNodes) if (child instanceof Element) return child;
1444
+ return null;
1445
+ }
1446
+
1447
+ function lastElementIn(parent: Node): Element | null {
1448
+ if (!parent.hasChildNodes()) return null;
1449
+ const children = parent.childNodes;
1450
+ for (let index = children.length - 1; index >= 0; index--) {
1451
+ const child = children[index];
1452
+ if (child instanceof Element) return child;
1453
+ }
1454
+ return null;
1455
+ }
1456
+
1457
+ /** First descendant element in document order whose `id` equals `id`. */
1458
+ function findElementById(parent: Node, id: string): Element | null {
1459
+ if (!parent.hasChildNodes()) return null;
1460
+ for (const child of parent.childNodes) {
1461
+ if (child instanceof Element && child.id === id) return child;
1462
+ const found = findElementById(child, id);
1463
+ if (found) return found;
1464
+ }
1465
+ return null;
1466
+ }
1467
+
1468
+ /**
1469
+ * Concatenate descendant text iteratively, mirroring each node type's `textContent`: character data and
1470
+ * attributes contribute their value, documents nothing, templates their content fragment.
1471
+ */
1472
+ function collectText(children: Node[]): string {
1473
+ let text = "";
1474
+ const pending: Node[] = [];
1475
+ for (let index = children.length - 1; index >= 0; index--) pending.push(children[index]);
1476
+ while (pending.length > 0) {
1477
+ const node = pending.pop() as Node;
1478
+ if (node instanceof Text) {
1479
+ text += node.data;
1480
+ continue;
1481
+ }
1482
+ if (node instanceof Attr) {
1483
+ text += node.value;
1484
+ continue;
1485
+ }
1486
+ if (node instanceof Document) continue;
1487
+ const owner = node instanceof HTMLTemplateElement ? node.content : node;
1488
+ if (!owner.hasChildNodes()) continue;
1489
+ const nested = owner.childNodes;
1490
+ for (let index = nested.length - 1; index >= 0; index--) pending.push(nested[index]);
1491
+ }
1492
+ return text;
1493
+ }
1494
+
1252
1495
  function cssEscapeIdentifier(value: string): string {
1253
1496
  return value.replace(/([^a-zA-Z0-9_-])/g, "\\$1");
1254
1497
  }