← Files DefuddleARCHIVED FILE

skills/defuddle-web-content/references/source/src/utils/dom.ts

4.65 KB · Oct 2, 2026 · 00:35 UTC

↓ Download file

/**
 * Move all child nodes from source to target.
 * Clears target first, then moves each child node from source.
 */
export function transferContent(source: Node, target: Node): void {
	if ('replaceChildren' in target) {
		(target as Element).replaceChildren();
	} else {
		while (target.firstChild) {
			target.removeChild(target.firstChild);
		}
	}
	while (source.firstChild) {
		target.appendChild(source.firstChild);
	}
}

/**
 * Read an element's inner HTML.
 */
export function serializeHTML(el: { innerHTML: string }): string {
	return el.innerHTML;
}

/**
 * Decode HTML entities in a string (e.g. `&` → `&`).
 * Uses a <textarea> element which is safe for entity decoding.
 */
export function decodeHTMLEntities(doc: Document, text: string): string {
	const textarea = doc.createElement('textarea');
	textarea.innerHTML = text;
	return textarea.value;
}

/**
 * Escape HTML special characters in a string.
 */
export function escapeHtml(text: string): string {
	return text
		.replace(/&/g, '&amp;')
		.replace(/</g, '&lt;')
		.replace(/>/g, '&gt;')
		.replace(/"/g, '&quot;');
}

/**
 * Safely get an element's class name as a string.
 * Handles SVG elements where className is an SVGAnimatedString.
 */
export function getClassName(el: Element): string {
	return typeof el.className === 'string' ? el.className : el.getAttribute('class') || '';
}

/**
 * Check if a class string contains responsive Tailwind show utilities
 * (e.g. "sm:block", "lg:flex") indicating the element is visible at some breakpoints.
 */
const RESPONSIVE_SHOW_RE = /^(sm|md|lg|xl|2xl|min-\[|max-\[):(?:block|flex|grid|inline|table|contents)/;
export function hasResponsiveShowClass(className: string): boolean {
	return className.split(/\s+/).some(t => RESPONSIVE_SHOW_RE.test(t));
}

/**
 * Check if a URL uses a dangerous protocol (javascript:, blob:, non-image data:).
 * Strips whitespace and control characters before checking.
 *
 * data: and blob: smuggle a whole document into an attribute, bypassing the
 * script and event-handler stripping done elsewhere. Inline images are the one
 * benign use of data:, so those are allowed and every other media type is
 * rejected. Relative URLs (no scheme) are always allowed — the bbcode and
 * comment builders pass them.
 */
export function isDangerousUrl(url: string, allowInlineImage: boolean = true): boolean {
	const normalized = url.replace(/[\s\u0000-\u001F]+/g, '').toLowerCase();
	if (normalized.startsWith('javascript:') || normalized.startsWith('blob:')) return true;
	if (normalized.startsWith('data:')) {
		return !(allowInlineImage && normalized.startsWith('data:image/'));
	}
	return false;
}

/**
 * Check if an element belongs directly to an ancestor table,
 * not to an intervening nested TABLE.
 */
export function isDirectTableChild(el: Node, ancestor: Node): boolean {
	let parent = el.parentNode;
	while (parent && parent !== ancestor) {
		if (parent.nodeName === 'TABLE') return false;
		parent = parent.parentNode;
	}
	return parent === ancestor;
}

/** Document order without relying on linkedom's cross-parent compareDocumentPosition. */
export function isNodeBefore(node: Node, other: Node): boolean {
	if (node === other) return false;
	const ancestors = (current: Node): Node[] => {
		const path: Node[] = [];
		for (let n: Node | null = current; n; n = n.parentNode) path.push(n);
		return path;
	};
	const left = ancestors(node);
	const right = ancestors(other);
	// Detached subtrees have no shared document order.
	if (left[left.length - 1] !== right[right.length - 1]) return false;
	while (left.length && right.length && left[left.length - 1] === right[right.length - 1]) {
		left.pop();
		right.pop();
	}
	if (!left.length) return true; // node is an ancestor of other
	if (!right.length) return false;
	for (let sibling: Node | null = left[left.length - 1].nextSibling; sibling; sibling = sibling.nextSibling) {
		if (sibling === right[right.length - 1]) return true;
	}
	return false;
}

/**
 * Parse an HTML string into a DocumentFragment.
 * Uses a <template> element when available (safer: no script execution,
 * no resource loading). Falls back to a <div> for environments that
 * don't support template.content (e.g. some server-side DOM libraries).
 */
export function parseHTML(doc: Document, html: string): DocumentFragment {
	if (!html) return doc.createDocumentFragment();

	const template = doc.createElement('template');
	template.innerHTML = html;
	if (template.content) {
		return template.content;
	}
	// Fallback for environments without template.content support
	const div = doc.createElement('div');
	div.innerHTML = html;
	const fragment = doc.createDocumentFragment();
	while (div.firstChild) {
		fragment.appendChild(div.firstChild);
	}
	return fragment;
}

SHA-256: fe1132705444b94fac218eb7ec1f8dfcd7bdd4b245349419578fce2c078d8744