import * as errors from "@superbuilders/errors"
import * as logger from "@superbuilders/slog"
import { BLOCK_ELEMENTS, VOID_ELEMENTS } from "@/stimulus/constants"
import { createDocument } from "@/stimulus/dom"
import { isElementNode, isTextNode } from "@/stimulus/dom-utils"
import type { StimulusIssue } from "@/stimulus/types"
const NODE_FILTER = {
SHOW_ELEMENT: 1
} as const
export function normalizeStructure(
document: Document,
issues: StimulusIssue[]
): Element {
const sourceRoot = resolveSourceRoot(document)
removeEmptyElements(sourceRoot)
fixParagraphChildren(sourceRoot, issues)
normalizeLists(sourceRoot, issues)
normalizeTableHeaders(sourceRoot, issues)
normalizeIframes(sourceRoot)
const containerDoc = createDocument("
")
const container = resolveContainerElement(containerDoc)
const containerDocument = container.ownerDocument
for (const child of Array.from(sourceRoot.childNodes)) {
if (isTextNode(child)) {
const text = readTextValue(child, "normalizeStructure:sourceRootChild")
if (text.trim().length === 0) continue
}
const imported = containerDocument.importNode(child, true)
container.appendChild(imported)
}
removeEmptyDivPlaceholders(container)
trimBoundaryWhitespace(container)
if (!container.hasChildNodes()) {
container.appendChild(containerDocument.createElement("p"))
issues.push({
severity: "warning",
code: "empty-content",
message: "Stimulus content was empty; inserted placeholder paragraph."
})
}
return container
}
const PRESERVE_IF_EMPTY = new Set([
"iframe",
"svg",
"math",
"video",
"audio",
"canvas"
])
function removeEmptyElements(root: Element) {
const walker = root.ownerDocument.createTreeWalker(
root,
NODE_FILTER.SHOW_ELEMENT
)
const empty: Element[] = []
let current = walker.nextNode()
while (current) {
if (isElementNode(current)) {
const tag = current.tagName.toLowerCase()
const hasNoChildren = current.childNodes.length === 0
const hasOnlyEmptyText =
current.childNodes.length === 1 &&
isTextNode(current.firstChild) &&
readTextValue(
current.firstChild,
"removeEmptyElements:firstChild"
).trim().length === 0
if (!hasNoChildren && !hasOnlyEmptyText) {
current = walker.nextNode()
continue
}
const isVoid = VOID_ELEMENTS.has(tag)
const isPreserved = isVoid || PRESERVE_IF_EMPTY.has(tag)
if (isPreserved) {
current = walker.nextNode()
continue
}
if (hasNoChildren) {
empty.push(current)
current = walker.nextNode()
continue
}
if (hasOnlyEmptyText) {
empty.push(current)
}
}
current = walker.nextNode()
}
for (const node of empty) {
if (node.tagName.toLowerCase() === "body") continue
node.remove()
}
}
function fixParagraphChildren(root: Element, issues: StimulusIssue[]) {
for (const p of Array.from(root.querySelectorAll("p"))) {
let containsBlock = false
for (const child of Array.from(p.childNodes)) {
if (!isElementNode(child)) continue
const tag = child.tagName.toLowerCase()
if (BLOCK_ELEMENTS.has(tag) && tag !== "a" && tag !== "span") {
containsBlock = true
break
}
}
if (!containsBlock) continue
const replacement = root.ownerDocument.createElement("div")
for (const attr of Array.from(p.attributes)) {
replacement.setAttribute(attr.name, attr.value)
}
while (p.firstChild) {
replacement.appendChild(p.firstChild)
}
p.replaceWith(replacement)
issues.push({
severity: "info",
code: "paragraph-block-children",
message: "Replaced paragraph containing block-level content with a ."
})
}
}
function normalizeLists(root: Element, issues: StimulusIssue[]) {
for (const list of Array.from(root.querySelectorAll("ul,ol"))) {
const needsWrap: Element[] = []
for (const child of Array.from(list.children)) {
if (child.tagName.toLowerCase() !== "li") {
needsWrap.push(child)
}
}
if (needsWrap.length === 0 && list.children.length > 0) continue
const replacement: Element[] = []
for (const child of Array.from(list.childNodes)) {
if (isTextNode(child)) {
const value = readTextValue(child, "normalizeLists:listChild")
if (value.trim().length === 0) continue
const li = root.ownerDocument.createElement("li")
li.textContent = value.trim()
replacement.push(li)
child.remove()
continue
}
if (isElementNode(child)) {
if (child.tagName.toLowerCase() === "li") continue
const li = root.ownerDocument.createElement("li")
li.appendChild(child)
replacement.push(li)
}
}
for (const li of replacement) {
list.appendChild(li)
}
if (needsWrap.length > 0 || replacement.length > 0) {
issues.push({
severity: "info",
code: "list-normalized",
message: "Normalized list structure to ensure valid HTML."
})
}
}
}
function normalizeTableHeaders(root: Element, issues: StimulusIssue[]) {
for (const th of Array.from(root.querySelectorAll("th"))) {
if (!isElementNode(th)) {
continue
}
if (th.hasAttribute("scope")) {
continue
}
const row = th.parentElement
const section = row?.parentElement
const sectionTag = section?.tagName.toLowerCase()
let scope: "row" | "col" = "col"
if (sectionTag === "tbody" && row) {
const firstCell = row.children.item(0)
if (firstCell === th) {
scope = "row"
}
}
if (sectionTag === "thead") {
scope = "col"
}
th.setAttribute("scope", scope)
issues.push({
severity: "info",
code: "th-scope-added",
message: `Added scope="${scope}" to
.`
})
}
}
function normalizeIframes(root: Element) {
for (const iframe of Array.from(root.querySelectorAll("iframe"))) {
if (!iframe.getAttribute("title")) {
iframe.setAttribute("title", "Embedded media")
}
iframe.setAttribute("allowfullscreen", "allowfullscreen")
}
}
function removeEmptyDivPlaceholders(root: Element) {
for (const div of Array.from(root.querySelectorAll("div"))) {
if (div.childNodes.length === 0) {
div.remove()
}
}
}
function trimBoundaryWhitespace(container: Element) {
while (true) {
const first = container.firstChild
if (!first) break
if (isBoundaryWhitespaceNode(first)) {
first.remove()
continue
}
break
}
while (true) {
const last = container.lastChild
if (!last) break
if (isBoundaryWhitespaceNode(last)) {
last.remove()
continue
}
break
}
}
function isBoundaryWhitespaceNode(node: Node): boolean {
if (!isTextNode(node)) return false
const value = readTextValue(node, "trimBoundaryWhitespace")
return /^[\t\r\n ]*$/.test(value)
}
function resolveSourceRoot(document: Document): Element {
if (document.body) return document.body
if (document.documentElement) return document.documentElement
logger.error("document missing root element")
throw errors.new("normalizeStructure: document missing root element")
}
function resolveContainerElement(document: Document): Element {
const container = document.querySelector("div")
if (container) return container
logger.error("stimulus container missing in generated document")
throw errors.new("normalizeStructure: container missing")
}
function readTextValue(node: Text, context: string): string {
const value = node.nodeValue
if (value === null) {
logger.error("text node missing value", { context })
throw errors.new(`normalizeStructure: ${context} text node missing value`)
}
return value
}
|