import { stringifyEntities } from "stringify-entities"; import { isPhrasingTag } from "./content_categories.js"; import { BaseHTMLElement, Doctype, type HTMLDocument, VoidBaseHTMLElement } from "./html_element.js"; import { VoidXMLElement, XMLDeclaration, type XMLDocument, XMLElement } from "./xml.js"; const escapeStr = (text: string): string => { return stringifyEntities(text, { escapeOnly: true }); }; /** * Converts an object of attributes into a string representation. * * @param attributes - The attributes to stringify. * @returns The string representation of the attributes. */ export const stringifyAttributes = (attributes: Record): string => { const result = Array.from(Object.entries(attributes)).map(([key, value]) => { const escapedKey = escapeStr(key); if (Array.isArray(value)) { return `${escapedKey}="${escapeStr(value.join(" "))}"`; } if (typeof value === "boolean") { return value ? escapedKey : ""; } return `${escapedKey}="${escapeStr(value)}"`; }); return result.length > 0 ? ` ${result.join(" ")}` : ""; }; type BaseAttributes = Record; interface BaseElement { readonly tag: T; readonly attributes: A; } type HasChildren = T extends { children: Array } ? T : never; type FormatterMode = "html" | "xml"; export class PrettyPrinter { private readonly preserveWhitespaceTags = new Set(["pre", "textarea", "script", "style"]); constructor( private pretty = true, private readonly mode: FormatterMode = "html", private readonly indent: string = " ", private level = 0, ) {} private increaseIndent() { this.level++; } private decreaseIndent() { this.level--; } private getIndent() { return this.pretty ? this.indent.repeat(this.level) : ""; } private printElement>( element: HasChildren & { children: C[] }, C>, ): string { const { tag, children, attributes } = element; const result = `${this.getIndent()}<${tag}${stringifyAttributes(attributes)}`; if (children.length === 0) { return `${result}><${result}/>`; } const preserverWhitespace = this.preserveWhitespaceTags.has(tag as string); if (isPhrasingTag(tag)) { const wasPretty = this.pretty; this.pretty = false; const nonPretty = this.printChildren(children, preserverWhitespace); this.pretty = wasPretty; return `${result}>${nonPretty}`; } return `${result}>${this.printChildren(children, preserverWhitespace)}`; } private printChildren>( children: C[], whitespacePreserving: boolean, ): string { if (children.length === 1 && typeof children[0] === "string") { return this.printSingleTextChild(children[0]); } if (!whitespacePreserving) this.increaseIndent(); const childrenContent = children.map((child) => this.printNode(child)).join(this.pretty ? "\n" : ""); if (!whitespacePreserving) this.decreaseIndent(); return this.pretty ? `\n${childrenContent}\n${this.getIndent()}` : childrenContent; } private printSingleTextChild(child: string): string { return child; } private printVoidElement(element: BaseElement): string { const { tag, attributes } = element; return `${this.getIndent()}<${tag}${stringifyAttributes(attributes)} />`; } private printDoctype(_doctype: Doctype): string { return `${this.getIndent()}`; } private printXMLDeclaration(declaration: BaseElement): string { return `${this.getIndent()}`; } private printTextNode(text: string): string { const escaped = this.mode === "xml" ? escapeStr(text) : text; return `${this.getIndent()}${escaped}`; } printNode | string>(node: N): string { if (node instanceof BaseHTMLElement || node instanceof XMLElement) { return this.printElement(node); } if (node instanceof VoidBaseHTMLElement || node instanceof VoidXMLElement) { return this.printVoidElement(node); } if (node instanceof Doctype) { return this.printDoctype(node); } if (node instanceof XMLDeclaration) { return this.printXMLDeclaration(node); } if (typeof node === "string") { return this.printTextNode(node); } throw new Error(`Unsupported node type: ${node.constructor.name}`); } print(document: HTMLDocument | XMLDocument): string { return document.children.map((child) => this.printNode(child)).join(this.pretty ? "\n" : ""); } }