From 040d9bb7b19106d311c8b3306199740388465da5 Mon Sep 17 00:00:00 2001 From: Eoghan Murray Date: Wed, 3 Sep 2025 10:54:42 +0100 Subject: [PATCH 1/3] Some changes to get package to build, presumably after a merge from master --- packages/rrweb-cutter/src/cut-session.ts | 7 +++---- packages/rrweb-cutter/src/prune-branches.ts | 2 +- packages/rrweb-cutter/src/snapshot.ts | 5 +++-- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/rrweb-cutter/src/cut-session.ts b/packages/rrweb-cutter/src/cut-session.ts index 7ec456c5f0..c63752ad05 100644 --- a/packages/rrweb-cutter/src/cut-session.ts +++ b/packages/rrweb-cutter/src/cut-session.ts @@ -1,8 +1,7 @@ -import { NodeType } from 'rrweb-snapshot'; -import { +import { NodeType, MediaInteractions } from '@rrweb/types'; +import type { adoptedStyleSheetData, eventWithTime, - MediaInteractions, styleDeclarationData, styleSheetRuleData, } from '@rrweb/types'; @@ -15,7 +14,7 @@ import { } from 'rrdom'; import type { IRRNode, RRElement } from 'rrdom'; import { IncrementalSource, EventType, SyncReplayer } from 'rrweb'; -import { playerConfig } from 'rrweb/typings/types'; +import type { playerConfig } from '@rrweb/replay'; import cloneDeep from 'lodash.clonedeep'; import snapshot from './snapshot'; export type CutterConfig = { diff --git a/packages/rrweb-cutter/src/prune-branches.ts b/packages/rrweb-cutter/src/prune-branches.ts index 4aed7c971a..ed9503ff8d 100644 --- a/packages/rrweb-cutter/src/prune-branches.ts +++ b/packages/rrweb-cutter/src/prune-branches.ts @@ -1,5 +1,5 @@ -import { serializedNodeWithId } from 'rrweb-snapshot'; import type { + serializedNodeWithId, addedNodeMutation, eventWithTime, mousePosition, diff --git a/packages/rrweb-cutter/src/snapshot.ts b/packages/rrweb-cutter/src/snapshot.ts index 48200a1024..383796eac4 100644 --- a/packages/rrweb-cutter/src/snapshot.ts +++ b/packages/rrweb-cutter/src/snapshot.ts @@ -1,5 +1,6 @@ -import type { serializedNodeWithId, attributes } from 'rrweb-snapshot'; -import { elementNode, NodeType, serializedNode } from 'rrweb-snapshot'; +import type { elementNode, serializedNode, serializedNodeWithId, attributes } from '@rrweb/types'; +import { NodeType } from '@rrweb/types'; + import type { IRRComment, IRRDocument, From e5737db0acd683d525adf64ef12d8a2103cec9df Mon Sep 17 00:00:00 2001 From: Eoghan Murray Date: Thu, 4 Sep 2025 13:40:38 +0100 Subject: [PATCH 2/3] Provide a method of converting rrdom to HTML. Useful for rendering FullSnapshot + Mutation Events to a HTML state that can be rendered statically without having to invoke puppeteer server side --- packages/rrdom/src/document.ts | 86 ++++++++++++++++++++++++++++++++++ packages/rrdom/src/escape.ts | 16 +++++++ 2 files changed, 102 insertions(+) create mode 100644 packages/rrdom/src/escape.ts diff --git a/packages/rrdom/src/document.ts b/packages/rrdom/src/document.ts index 344dd44112..f16bc64496 100644 --- a/packages/rrdom/src/document.ts +++ b/packages/rrdom/src/document.ts @@ -1,5 +1,7 @@ import { NodeType as RRNodeType } from '@rrweb/types'; import { parseCSSText, camelize, toCSSText } from './style'; +import { escapeHtmlText, escapeHtmlAttr } from './helpers'; + export interface IRRNode { parentElement: IRRNode | null; parentNode: IRRNode | null; @@ -373,6 +375,26 @@ export class BaseRRDocument extends BaseRRNode implements IRRDocument { return CDATASection; } + // not something that is on the browser document object, but a convenient way to export + public get outerHTML() { + return this.childNodes + .map((cn) => { + if (cn instanceof BaseRRDocumentType) { + return `\n`; + } else if (cn instanceof BaseRRElement) { + return cn.outerHTML; + } else if (cn.textContent !== null) { + // presumably newlines or other spacing characters + return escapeHtmlText(cn.textContent); + } else { + return cn.toString(); // for debugging + } + }) + .join(''); + } + toString() { return 'RRDocument'; } @@ -518,6 +540,62 @@ export class BaseRRElement extends BaseRRNode implements IRRElement { return true; } + public get outerHTML(): string { + const voidElements = new Set([ + 'area', + 'base', + 'br', + 'col', + 'embed', + 'hr', + 'img', + 'input', + 'link', + 'meta', + 'param', + 'source', + 'track', + 'wbr', + ]); + + const tagLower = this.tagName.toLowerCase(); + const tagAttrs = [tagLower]; + for (const attribute in this.attributes) { + if (attribute === '"' && this.attributes[attribute] === '') { + // Badly authored html, e.g. `` + continue; + } + // attribute names are case insensitive in HTML5; chrome normalizes them to lowercase + tagAttrs.push( + `${attribute.toLowerCase()}="${escapeHtmlAttr( + this.attributes[attribute], + )}"`, + ); + } + if (voidElements.has(tagLower)) { + return `<${tagAttrs.join(' ')}>`; + } + + const children = this.childNodes + .map((cn) => { + if (cn instanceof BaseRRElement) { + return cn.outerHTML; + } else if (cn.textContent !== null) { + if (tagLower === 'style') { + // don't escape to > in `select > option.selected` + return cn.textContent; + } else { + return escapeHtmlText(cn.textContent); + } + } else { + return cn.toString(); // for debugging + } + }) + .join(''); + + return `<${tagAttrs.join(' ')}>${children}`; + } + toString() { let attributeString = ''; for (const attribute in this.attributes) { @@ -591,6 +669,10 @@ export class BaseRRText extends BaseRRNode implements IRRText { this.data = textContent; } + public get outerHTML(): string { + return escapeHtmlText(this.textContent || ''); + } + toString() { return `RRText text=${JSON.stringify(this.data)}`; } @@ -615,6 +697,10 @@ export class BaseRRComment extends BaseRRNode implements IRRComment { this.data = textContent; } + public get outerHTML() { + return ``; + } + toString() { return `RRComment text=${JSON.stringify(this.data)}`; } diff --git a/packages/rrdom/src/escape.ts b/packages/rrdom/src/escape.ts new file mode 100644 index 0000000000..ca0f05c156 --- /dev/null +++ b/packages/rrdom/src/escape.ts @@ -0,0 +1,16 @@ +// for serializing a virtual DOM to html + +function escapeHtmlText(text: string): string { + return text + .replace(/&/g, '&') + .replace(/ /g, ' ') // unicode version is correct, but output the html escape syntax for consistency with what chrome outputs + .replace(//g, '>'); +} + +function escapeHtmlAttr(text: string): string { + return text + .replace(/&/g, '&') + .replace(/ /g, ' ') // unicode version is correct, but output the html escape syntax for consistency with what chrome outputs + .replace(/"/g, '"'); +} From f5095f68147c8f955d62c773d1c6f7fdf5d5ab38 Mon Sep 17 00:00:00 2001 From: eoghanmurray Date: Thu, 4 Sep 2025 12:51:38 +0000 Subject: [PATCH 3/3] Apply formatting changes --- packages/rrweb-cutter/src/snapshot.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/rrweb-cutter/src/snapshot.ts b/packages/rrweb-cutter/src/snapshot.ts index 383796eac4..74c3442ee5 100644 --- a/packages/rrweb-cutter/src/snapshot.ts +++ b/packages/rrweb-cutter/src/snapshot.ts @@ -1,4 +1,9 @@ -import type { elementNode, serializedNode, serializedNodeWithId, attributes } from '@rrweb/types'; +import type { + elementNode, + serializedNode, + serializedNodeWithId, + attributes, +} from '@rrweb/types'; import { NodeType } from '@rrweb/types'; import type {