import * as errors from "@superbuilders/errors" import * as logger from "@superbuilders/slog" import { XMLParser } from "fast-xml-parser" import { VOID_ELEMENTS } from "@/stimulus/constants" import { createDocument } from "@/stimulus/dom" import { isElementNode, isTextNode, NODE_TYPE } from "@/stimulus/dom-utils" import type { StimulusIssue } from "@/stimulus/types" const ALLOWED_NAMED_ENTITIES = new Set(["amp", "lt", "gt", "quot", "apos"]) const NAMED_ENTITY_PATTERN = /&([a-zA-Z][a-zA-Z0-9]+);/g const NUMERIC_ENTITY_PATTERN = /&#(?:x[0-9a-fA-F]+|\d+);/g const xmlParser = new XMLParser({ ignoreAttributes: false, allowBooleanAttributes: false, trimValues: false, processEntities: true }) export function validateHtml(html: string): StimulusIssue[] { const documentResult = errors.trySync(() => createDocument(html)) if (documentResult.error) { const message = getErrorMessage(documentResult.error) return [ { severity: "error", code: "html-parse-error", message: `HTML validation failed: ${message}` } ] } const doc = documentResult.data const body = doc.body if (!body) { return [ { severity: "error", code: "html-parse-error", message: "Parsed HTML does not contain a body element." } ] } const containerDoc = createDocument("
") const container = containerDoc.querySelector("div") if (!container) { logger.error("validation container missing in generated document") return [ { severity: "error", code: "html-parse-error", message: "Failed to create validation container." } ] } for (const child of Array.from(body.childNodes)) { const imported = containerDoc.importNode(child, true) container.appendChild(imported) } assertNoCdata(container, html.length) const issues: StimulusIssue[] = [] issues.push(...collectEntityIssues(html)) const xmlString = `${serializeElementToXml(container)}` const xmlParseResult = errors.trySync(() => xmlParser.parse(xmlString)) if (xmlParseResult.error) { const message = getErrorMessage(xmlParseResult.error) issues.push({ severity: "error", code: "xml-parse-error", message: `XML validation failed: ${message}` }) } return issues } function collectEntityIssues(html: string): StimulusIssue[] { const issues: StimulusIssue[] = [] const numericEntities = new Set() for ( let numericMatch = NUMERIC_ENTITY_PATTERN.exec(html); numericMatch; numericMatch = NUMERIC_ENTITY_PATTERN.exec(html) ) { numericEntities.add(numericMatch[0]) } for ( let namedMatch = NAMED_ENTITY_PATTERN.exec(html); namedMatch; namedMatch = NAMED_ENTITY_PATTERN.exec(html) ) { const entity = namedMatch[0] if (numericEntities.has(entity)) { continue } const name = namedMatch[1] if (!ALLOWED_NAMED_ENTITIES.has(name)) { issues.push({ severity: "error", code: "disallowed-html-entity", message: `Found disallowed HTML entity: ${entity}` }) } } return issues } function serializeElementToXml(element: Element): string { const tag = element.tagName.toLowerCase() const attrs = Array.from(element.attributes) .map((attr) => `${attr.name}="${escapeXmlAttribute(attr.value)}"`) .join(" ") const attrString = attrs.length > 0 ? ` ${attrs}` : "" if (VOID_ELEMENTS.has(tag)) { return `<${tag}${attrString} />` } const childXml = Array.from(element.childNodes) .map((child) => serializeNodeToXml(child)) .join("") return `<${tag}${attrString}>${childXml}` } function serializeNodeToXml(node: Node): string { if (isElementNode(node)) { return serializeElementToXml(node) } if (isTextNode(node)) { return escapeXmlText(node.nodeValue ?? "") } if (node.nodeType === NODE_TYPE.CDATA_SECTION) { const preview = node.nodeValue ?? "" logger.error("serializeNodeToXml encountered cdata node", { preview: preview.slice(0, 200), previewLength: preview.length }) throw errors.new("stimulus xml serialization encountered cdata") } return "" } function escapeXmlAttribute(value: string): string { return value .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """) .replace(/'/g, "'") } function escapeXmlText(value: string): string { return value .replace(/&/g, "&") .replace(//g, ">") } function getErrorMessage(error: unknown): string { return error instanceof Error ? error.message : String(error) } function assertNoCdata(root: Node, htmlLength: number): void { if (!containsCdata(root)) { return } logger.error("cdata found in stimulus html", { htmlLength }) throw errors.new("stimulus html contains cdata") } function containsCdata(root: Node): boolean { if (root.nodeType === NODE_TYPE.CDATA_SECTION) { return true } for (const child of Array.from(root.childNodes)) { if (containsCdata(child)) { return true } } return false }