← Files DefuddleARCHIVED FILE

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

3.49 KB · Oct 3, 2026 · 06:36 UTC

↓ Download file

const NODE_TYPE = {
	ELEMENT_NODE: 1,
	ATTRIBUTE_NODE: 2,
	TEXT_NODE: 3,
	CDATA_SECTION_NODE: 4,
	ENTITY_REFERENCE_NODE: 5,
	ENTITY_NODE: 6,
	PROCESSING_INSTRUCTION_NODE: 7,
	COMMENT_NODE: 8,
	DOCUMENT_NODE: 9,
	DOCUMENT_TYPE_NODE: 10,
	DOCUMENT_FRAGMENT_NODE: 11,
	NOTATION_NODE: 12
};

export function isElement(node: Node): node is Element {
	return node.nodeType === NODE_TYPE.ELEMENT_NODE;
}

export function isTextNode(node: Node): node is Text {
	return node.nodeType === NODE_TYPE.TEXT_NODE;
}

export function isCommentNode(node: Node): node is Comment {
	return node.nodeType === NODE_TYPE.COMMENT_NODE;
}

// Uses closest('svg') as fallback since linkedom may not set namespaceURI correctly
export function isSVGElement(el: Element): boolean {
	return el.closest?.('svg') !== null || el.namespaceURI === 'http://www.w3.org/2000/svg';
}

export function getComputedStyle(element: Element): CSSStyleDeclaration | null {
	const win = getWindow(element.ownerDocument);
	if (!win || typeof win.getComputedStyle !== 'function') return null;
	return win.getComputedStyle(element);
}

export function getWindow(doc: Document): Window | null {
	// First try defaultView
	if (doc.defaultView) {
		return doc.defaultView;
	}
	
	// Then try ownerWindow
	if ((doc as any).ownerWindow) {
		return (doc as any).ownerWindow;
	}
	
	// Finally try to get window from document
	if ((doc as any).window) {
		return (doc as any).window;
	}
	
	return null;
}

export function textPreview(el: Element): string {
	return (el.textContent || '').trim().substring(0, 200);
}

export function logDebug(debug: boolean, message: string, ...args: any[]): void {
	if (debug) {
		console.log('Defuddle:', message, ...args);
	}
}

// CJK character ranges for use in regex character classes (BMP only)
export const CJK_CHAR_RANGES = '\\u3040-\\u309f\\u30a0-\\u30ff\\u3400-\\u4dbf\\u4e00-\\u9fff\\uf900-\\ufaff\\uac00-\\ud7af';

/**
 * Canonicalize text for title/heading comparison: normalize smart quotes,
 * dashes, ellipses, and whitespace; lowercase. Two strings that humans would
 * read as "the same" should compare equal after this pass.
 */
export function normalizeText(text: string): string {
	return text
		.replace(/\u00A0/g, ' ')
		.replace(/[\u2018\u2019\u201A\u201B]/g, "'")
		.replace(/[\u2012\u2013\u2014\u2015]/g, '-')
		.replace(/[\u201C\u201D\u201E\u201F]/g, '"')
		.replace(/\u2026/g, '...')
		.replace(/\s+/g, ' ')
		.trim()
		.toLowerCase();
}

/**
 * Count words in text, handling CJK characters (Chinese, Japanese, Korean).
 * CJK characters are counted individually since they don't use spaces between words.
 * Non-CJK text is counted by splitting on whitespace.
 */
export function countWords(text: string): number {
	if (!text) return 0;

	let cjkCount = 0;
	let wordCount = 0;
	let inWord = false;

	for (let i = 0; i < text.length; i++) {
		const code = text.charCodeAt(i);

		// Check for CJK character ranges (BMP only — Extension B+ are
		// surrogate pairs and would need codePointAt, rare in practice)
		if (
			(code >= 0x3040 && code <= 0x309f) || // Hiragana
			(code >= 0x30a0 && code <= 0x30ff) || // Katakana
			(code >= 0x3400 && code <= 0x4dbf) || // CJK Extension A
			(code >= 0x4e00 && code <= 0x9fff) || // CJK Unified Ideographs
			(code >= 0xf900 && code <= 0xfaff) || // CJK Compatibility Ideographs
			(code >= 0xac00 && code <= 0xd7af)    // Korean Hangul
		) {
			cjkCount++;
			inWord = false;
		} else if (code <= 32) {
			inWord = false;
		} else if (!inWord) {
			wordCount++;
			inWord = true;
		}
	}

	return cjkCount + wordCount;
}

SHA-256: d433ba8ad0264b61f4ff6c9451d5b108b6128014c78133abf11d6f5aa19f9102