import { Attributes, SnapshotNodeId, NodeType, SerializedNode, SerializedNodeWithId } from './types'; import { isValidCssSelector, Mirror } from './utils'; import { DO_NOT_CAPTURE_DATA_TAG } from './rebuild'; let _id = 1; const INVALID_TAG_NAME_REGEX = new RegExp('[^a-z0-9-_:]'); const TAG_NAMES_TO_SKIP = ['script']; function genId(): number { return _id++; } function getRootId(doc: Document, mirror: Mirror): SnapshotNodeId | undefined { if (mirror.hasNode(doc)) { return mirror.getId(doc); } if (window.frames?.top?.document === doc) { return '/'; } const parentFrame = window.frameElement; if (!parentFrame) { return undefined; } if (mirror.hasNode(parentFrame)) { return mirror.getId(parentFrame); } // TODO(em): Figure out how to serialize an iframe and call mirror.identifyAndAddNode return undefined; } function getValidTagName(element: HTMLElement): string { if (element instanceof HTMLFormElement) { return 'form'; } const processedTagName = element.tagName.toLowerCase().trim(); if (INVALID_TAG_NAME_REGEX.test(processedTagName)) { return 'div'; } return processedTagName; } function stringifyStyleSheet(sheet: CSSStyleSheet): string { return sheet.cssRules ? Array.from(sheet.cssRules) .map((rule) => rule.cssText || '') .join('') : ''; } function serializeTextNode( node: Text, options: { rootId: SnapshotNodeId | undefined; }, ): SerializedNode { const { rootId } = options; const parentTagName = node.parentNode && (node.parentNode as HTMLElement).tagName; let textContent = node.textContent; const isStyle = parentTagName === 'STYLE' ? true : undefined; if (isStyle) { try { // try to read style sheet if (node.nextSibling || node.previousSibling) { // keep textContent as-is } else if ((node.parentNode as HTMLStyleElement).sheet?.cssRules) { textContent = stringifyStyleSheet( (node.parentNode as HTMLStyleElement).sheet! ); } } catch (err) { console.warn( `Cannot get CSS styles from text's parentNode. Error: ${err as string}`, node, ); } } return { type: NodeType.Text, textContent: textContent || '', isStyle, rootId, }; } const ALWAYS_BLOCK_SELECTOR = `input[type=password], [autocomplete^=cc-], input[type=hidden], [${DO_NOT_CAPTURE_DATA_TAG}]` export function isBlockedElement(element: HTMLElement, blockSelector: string | null): boolean { if (blockSelector && !isValidCssSelector(blockSelector)) { console.warn(`${blockSelector} is an invalid CSS selector. Snapshot is expected to fail`); } blockSelector = blockSelector ? `${ALWAYS_BLOCK_SELECTOR}, ${blockSelector}` : ALWAYS_BLOCK_SELECTOR; if (blockSelector) { return element.matches(blockSelector); } return false; } function serializeElementNode( node: HTMLElement, options: { doc: Document, rootId: SnapshotNodeId | undefined; blockSelector: string | null; } ): SerializedNode | false { const { rootId, blockSelector } = options; const needBlock = isBlockedElement(node, blockSelector); const tagName = getValidTagName(node); let attributes: Attributes = {}; const len = node.attributes.length; for (let i = 0; i < len; i++) { const attr = node.attributes[i]; attributes[attr.name] = attr.value; } // block element if (needBlock) { const { width, height } = node.getBoundingClientRect(); attributes = { class: attributes.class, evolv_width: `${width}px`, evolv_height: `${height}px`, [DO_NOT_CAPTURE_DATA_TAG]: 'true', }; } return { type: NodeType.Element, tagName, attributes, childNodes: [], rootId, needBlock }; } function serializeNode( node: Node, options: { doc: Document; mirror: Mirror; blockSelector: string | null; } ): SerializedNode | false { const { doc, mirror, blockSelector } = options; const rootId = getRootId(doc, mirror); switch (node.nodeType) { case node.DOCUMENT_NODE: return { type: NodeType.Document, childNodes: [], rootId }; case node.DOCUMENT_TYPE_NODE: return { type: NodeType.DocumentType, name: (node as DocumentType).name, publicId: (node as DocumentType).publicId, systemId: (node as DocumentType).systemId, rootId }; case node.ELEMENT_NODE: return serializeElementNode(node as HTMLElement, { doc, rootId, blockSelector }); case node.TEXT_NODE: return serializeTextNode(node as Text, { rootId, }); case node.CDATA_SECTION_NODE: return { type: NodeType.CDATA, textContent: '', rootId }; case node.COMMENT_NODE: return { type: NodeType.Comment, textContent: (node as Comment).textContent || '', rootId }; default: return false; } } export function serializeNodeWithId( n: Node, options: { mirror: Mirror; doc: Document; skipChild: boolean; onSerialize?: (n: Node) => unknown; blockSelector: string | null; } ): SerializedNodeWithId | null { const { doc, mirror, onSerialize, skipChild = false, blockSelector } = options; if (n instanceof HTMLElement && TAG_NAMES_TO_SKIP.includes(n.tagName.toLowerCase().trim())) { return null; } const serializedNodeWithoutId = serializeNode(n, { doc, mirror, blockSelector }); if (!serializedNodeWithoutId) { console.warn(n, 'not serialized'); return null; } let id: SnapshotNodeId; if (mirror.hasNode(n)) { // reuse the previous id id = mirror.getId(n); } else { id = genId().toString(); } const serializedNode = Object.assign(serializedNodeWithoutId, { id }); mirror.add(n, serializedNode); if (onSerialize) { onSerialize(n); } let recordChild = !skipChild; if (serializedNode.type === NodeType.Element) { recordChild = recordChild && !serializedNode.needBlock; // this property is not needed in replay delete serializedNode.needBlock; } const bypassOptions = { doc, mirror, skipChild, blockSelector }; if ((serializedNode.type === NodeType.Document || serializedNode.type === NodeType.Element) && recordChild) { for (const child of Array.from(n.childNodes)) { const serializedChildNode = serializeNodeWithId(child, bypassOptions); if (serializedChildNode) { serializedNode.childNodes.push(serializedChildNode); } } } return serializedNode; } export function snapshot( doc: Document, options?: { mirror?: Mirror; onSerialize?: (n: Node) => unknown; blockSelector?: string | null; } ): SerializedNodeWithId | null { const { mirror = new Mirror(), onSerialize, blockSelector = null } = options || {}; return serializeNodeWithId(doc, { doc, mirror, onSerialize, skipChild: false, blockSelector }); }