/** @file Parse htmlparser2-dom-adapter module. */
import { selectAll } from "css-select";
import renderDom from "dom-serializer";
import { type AnyNode, type Document, isTag } from "domhandler";
import { getAttributeValue, removeElement, textContent } from "domutils";
import { parseDocument } from "htmlparser2";
import type { DomAdapter, DomNode, DomSelection } from "../dom/adapter.ts";
class Htmlparser2DomSelection implements DomSelection {
readonly adapterKind = "dom-selection" as const;
constructor(readonly selection: AnyNode[]) {}
}
/** Loads HTML into the htmlparser2-backed DOM adapter. */
export function loadHtmlparser2Dom(html: string): DomAdapter {
return new Htmlparser2DomAdapter(
parseDocument(html, {
lowerCaseAttributeNames: true,
lowerCaseTags: true,
}),
);
}
class Htmlparser2DomAdapter implements DomAdapter {
constructor(private readonly document: Document) {}
root(): DomSelection {
return this.wrap(this.document.children);
}
select(selector: string, scope?: DomSelection): DomSelection {
return this.wrap(selectAll(selector, this.roots(scope)));
}
selection(nodes: DomNode[]): DomSelection {
return this.wrap(nodes as AnyNode[]);
}
first(selection: DomSelection): DomSelection {
return this.wrap(this.asSelection(selection).slice(0, 1));
}
nodes(selection: DomSelection): DomNode[] {
return [...this.asSelection(selection)];
}
count(selection: DomSelection): number {
return this.asSelection(selection).length;
}
text(target: DomSelection | DomNode): string {
if (target === undefined || target === null) return "";
if (target instanceof Htmlparser2DomSelection) {
return textContent(target.selection);
}
return textContent(target as AnyNode);
}
html(selection: DomSelection): string {
return renderDom(this.asSelection(selection));
}
attr(target: DomSelection | DomNode, name: string): string | undefined {
if (target === undefined || target === null) return;
const node =
target instanceof Htmlparser2DomSelection ? target.selection[0] : (target as AnyNode);
// oxlint-disable-next-line typescript/no-unnecessary-condition -- defensive guard; runtime conditions can diverge from inferred type
return node && isTag(node) ? getAttributeValue(node, name) : undefined;
}
tagName(node: DomNode): string | undefined {
if (node === undefined || node === null) return;
const maybeNode = node as { name?: unknown };
return typeof maybeNode.name === "string" ? maybeNode.name : undefined;
}
remove(selector: string, scope?: DomSelection): void {
for (const node of this.asSelection(this.select(selector, scope))) {
removeElement(node);
}
}
removeSelection(selection: DomSelection): void {
for (const node of this.asSelection(selection)) {
removeElement(node);
}
}
private roots(scope: DomSelection | undefined): AnyNode[] {
return scope === undefined ? this.document.children : this.asSelection(scope);
}
private wrap(selection: AnyNode[]): DomSelection {
return new Htmlparser2DomSelection(selection);
}
private asSelection(selection: DomSelection): AnyNode[] {
if (!(selection instanceof Htmlparser2DomSelection)) {
throw new TypeError("DOM selection belongs to a different adapter backend");
}
return selection.selection;
}
}