* Hygiene: clean up the xhtml namespace attribute; this is an artefact of the `serializeToString` method which we are using (I think) to be consistent with whitespace and to clean up invalid attributes. I'm removing as was confused as am adding tests related to doctypes * Record when a document is in `compatMode` and trigger this mode on the iframe upon replay https://developer.mozilla.org/en-US/docs/Web/API/Document/compatMode the included DOCTYPE was picked up from https://stackoverflow.com/questions/18976213/ - there may be better ways of triggering compatMode * Don't write an extra DOCTYPE if there's one already present in the snapshot. Rely instead on whatever doctype is there to trigger the BackCompat mode * Modify to write the correct doctype if we can sniff xhtml - don't have any evidence that this will make a difference * Dev convenience: Ignore files generated by editors * Typo fix * Was getting a 2000ms timeout on the 'before' hook I believe * Change certain tests to go directly to their localhost page instead of loading the html content programmatically in order to avoid triggering an incorrect BackCompat mode (incorrect in that the html content has a correct doctype) * Add test based on motivating site that had images lined up in a square which were all different sizes; very old style percentage width/height attributes were doing the right thing in quirksmode, which is what we are testing for here * Fixup rrweb test html to include a valid doctype and avoid BackCompat to ensure we're not accidentally testing against quirks modes. I didn't find an elegant way of avoiding the `BackCompat` when adding a minimal iframe, so some BackCompat has slipped in here, I don't think there's much harm
438 lines
13 KiB
TypeScript
438 lines
13 KiB
TypeScript
import { parse } from './css';
|
|
import {
|
|
serializedNodeWithId,
|
|
NodeType,
|
|
tagMap,
|
|
elementNode,
|
|
idNodeMap,
|
|
INode,
|
|
BuildCache,
|
|
} from './types';
|
|
import { isElement } from './utils';
|
|
|
|
const tagMap: tagMap = {
|
|
script: 'noscript',
|
|
// camel case svg element tag names
|
|
altglyph: 'altGlyph',
|
|
altglyphdef: 'altGlyphDef',
|
|
altglyphitem: 'altGlyphItem',
|
|
animatecolor: 'animateColor',
|
|
animatemotion: 'animateMotion',
|
|
animatetransform: 'animateTransform',
|
|
clippath: 'clipPath',
|
|
feblend: 'feBlend',
|
|
fecolormatrix: 'feColorMatrix',
|
|
fecomponenttransfer: 'feComponentTransfer',
|
|
fecomposite: 'feComposite',
|
|
feconvolvematrix: 'feConvolveMatrix',
|
|
fediffuselighting: 'feDiffuseLighting',
|
|
fedisplacementmap: 'feDisplacementMap',
|
|
fedistantlight: 'feDistantLight',
|
|
fedropshadow: 'feDropShadow',
|
|
feflood: 'feFlood',
|
|
fefunca: 'feFuncA',
|
|
fefuncb: 'feFuncB',
|
|
fefuncg: 'feFuncG',
|
|
fefuncr: 'feFuncR',
|
|
fegaussianblur: 'feGaussianBlur',
|
|
feimage: 'feImage',
|
|
femerge: 'feMerge',
|
|
femergenode: 'feMergeNode',
|
|
femorphology: 'feMorphology',
|
|
feoffset: 'feOffset',
|
|
fepointlight: 'fePointLight',
|
|
fespecularlighting: 'feSpecularLighting',
|
|
fespotlight: 'feSpotLight',
|
|
fetile: 'feTile',
|
|
feturbulence: 'feTurbulence',
|
|
foreignobject: 'foreignObject',
|
|
glyphref: 'glyphRef',
|
|
lineargradient: 'linearGradient',
|
|
radialgradient: 'radialGradient',
|
|
};
|
|
function getTagName(n: elementNode): string {
|
|
let tagName = tagMap[n.tagName] ? tagMap[n.tagName] : n.tagName;
|
|
if (tagName === 'link' && n.attributes._cssText) {
|
|
tagName = 'style';
|
|
}
|
|
return tagName;
|
|
}
|
|
|
|
// based on https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions#escaping
|
|
function escapeRegExp(str: string) {
|
|
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); // $& means the whole matched string
|
|
}
|
|
|
|
const HOVER_SELECTOR = /([^\\]):hover/;
|
|
const HOVER_SELECTOR_GLOBAL = new RegExp(HOVER_SELECTOR, 'g');
|
|
export function addHoverClass(cssText: string, cache: BuildCache): string {
|
|
const cachedStyle = cache?.stylesWithHoverClass.get(cssText);
|
|
if (cachedStyle) return cachedStyle;
|
|
|
|
const ast = parse(cssText, {
|
|
silent: true,
|
|
});
|
|
|
|
if (!ast.stylesheet) {
|
|
return cssText;
|
|
}
|
|
|
|
const selectors: string[] = [];
|
|
ast.stylesheet.rules.forEach((rule) => {
|
|
if ('selectors' in rule) {
|
|
(rule.selectors || []).forEach((selector: string) => {
|
|
if (HOVER_SELECTOR.test(selector)) {
|
|
selectors.push(selector);
|
|
}
|
|
});
|
|
}
|
|
});
|
|
|
|
if (selectors.length === 0) {
|
|
return cssText;
|
|
}
|
|
|
|
const selectorMatcher = new RegExp(
|
|
selectors
|
|
.filter((selector, index) => selectors.indexOf(selector) === index)
|
|
.sort((a, b) => b.length - a.length)
|
|
.map((selector) => {
|
|
return escapeRegExp(selector);
|
|
})
|
|
.join('|'),
|
|
'g',
|
|
);
|
|
|
|
const result = cssText.replace(selectorMatcher, (selector) => {
|
|
const newSelector = selector.replace(HOVER_SELECTOR_GLOBAL, '$1.\\:hover');
|
|
return `${selector}, ${newSelector}`;
|
|
});
|
|
cache?.stylesWithHoverClass.set(cssText, result);
|
|
return result;
|
|
}
|
|
|
|
export function createCache(): BuildCache {
|
|
const stylesWithHoverClass: Map<string, string> = new Map();
|
|
return {
|
|
stylesWithHoverClass,
|
|
};
|
|
}
|
|
|
|
function buildNode(
|
|
n: serializedNodeWithId,
|
|
options: {
|
|
doc: Document;
|
|
hackCss: boolean;
|
|
cache: BuildCache;
|
|
},
|
|
): Node | null {
|
|
const { doc, hackCss, cache } = options;
|
|
switch (n.type) {
|
|
case NodeType.Document:
|
|
return doc.implementation.createDocument(null, '', null);
|
|
case NodeType.DocumentType:
|
|
return doc.implementation.createDocumentType(
|
|
n.name || 'html',
|
|
n.publicId,
|
|
n.systemId,
|
|
);
|
|
case NodeType.Element:
|
|
const tagName = getTagName(n);
|
|
let node: Element;
|
|
if (n.isSVG) {
|
|
node = doc.createElementNS('http://www.w3.org/2000/svg', tagName);
|
|
} else {
|
|
node = doc.createElement(tagName);
|
|
}
|
|
for (const name in n.attributes) {
|
|
if (!n.attributes.hasOwnProperty(name)) {
|
|
continue;
|
|
}
|
|
let value = n.attributes[name];
|
|
if (tagName === 'option' && name === 'selected' && value === false) {
|
|
// legacy fix (TODO: if `value === false` can be generated for other attrs, should we also omit those other attrs from build?)
|
|
continue;
|
|
}
|
|
value =
|
|
typeof value === 'boolean' || typeof value === 'number' ? '' : value;
|
|
// attribute names start with rr_ are internal attributes added by rrweb
|
|
if (!name.startsWith('rr_')) {
|
|
const isTextarea = tagName === 'textarea' && name === 'value';
|
|
const isRemoteOrDynamicCss =
|
|
tagName === 'style' && name === '_cssText';
|
|
if (isRemoteOrDynamicCss && hackCss) {
|
|
value = addHoverClass(value, cache);
|
|
}
|
|
if (isTextarea || isRemoteOrDynamicCss) {
|
|
const child = doc.createTextNode(value);
|
|
// https://github.com/rrweb-io/rrweb/issues/112
|
|
for (const c of Array.from(node.childNodes)) {
|
|
if (c.nodeType === node.TEXT_NODE) {
|
|
node.removeChild(c);
|
|
}
|
|
}
|
|
node.appendChild(child);
|
|
continue;
|
|
}
|
|
|
|
try {
|
|
if (n.isSVG && name === 'xlink:href') {
|
|
node.setAttributeNS('http://www.w3.org/1999/xlink', name, value);
|
|
} else if (
|
|
name === 'onload' ||
|
|
name === 'onclick' ||
|
|
name.substring(0, 7) === 'onmouse'
|
|
) {
|
|
// Rename some of the more common atttributes from https://www.w3schools.com/tags/ref_eventattributes.asp
|
|
// as setting them triggers a console.error (which shows up despite the try/catch)
|
|
// Assumption: these attributes are not used to css
|
|
node.setAttribute('_' + name, value);
|
|
} else if (
|
|
tagName === 'meta' &&
|
|
n.attributes['http-equiv'] === 'Content-Security-Policy' &&
|
|
name === 'content'
|
|
) {
|
|
// If CSP contains style-src and inline-style is disabled, there will be an error "Refused to apply inline style because it violates the following Content Security Policy directive: style-src '*'".
|
|
// And the function insertStyleRules in rrweb replayer will throw an error "Uncaught TypeError: Cannot read property 'insertRule' of null".
|
|
node.setAttribute('csp-content', value);
|
|
continue;
|
|
} else if (
|
|
tagName === 'link' &&
|
|
n.attributes.rel === 'preload' &&
|
|
n.attributes.as === 'script'
|
|
) {
|
|
// ignore
|
|
} else if (
|
|
tagName === 'link' &&
|
|
n.attributes.rel === 'prefetch' &&
|
|
typeof n.attributes.href === 'string' &&
|
|
n.attributes.href.endsWith('.js')
|
|
) {
|
|
// ignore
|
|
} else {
|
|
node.setAttribute(name, value);
|
|
}
|
|
} catch (error) {
|
|
// skip invalid attribute
|
|
}
|
|
} else {
|
|
// handle internal attributes
|
|
if (tagName === 'canvas' && name === 'rr_dataURL') {
|
|
const image = document.createElement('img');
|
|
image.src = value;
|
|
image.onload = () => {
|
|
const ctx = (node as HTMLCanvasElement).getContext('2d');
|
|
if (ctx) {
|
|
ctx.drawImage(image, 0, 0, image.width, image.height);
|
|
}
|
|
};
|
|
}
|
|
if (name === 'rr_width') {
|
|
(node as HTMLElement).style.width = value;
|
|
}
|
|
if (name === 'rr_height') {
|
|
(node as HTMLElement).style.height = value;
|
|
}
|
|
if (name === 'rr_mediaCurrentTime') {
|
|
(node as HTMLMediaElement).currentTime = n.attributes
|
|
.rr_mediaCurrentTime as number;
|
|
}
|
|
if (name === 'rr_mediaState') {
|
|
switch (value) {
|
|
case 'played':
|
|
(node as HTMLMediaElement)
|
|
.play()
|
|
.catch((e) => console.warn('media playback error', e));
|
|
break;
|
|
case 'paused':
|
|
(node as HTMLMediaElement).pause();
|
|
break;
|
|
default:
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if (n.isShadowHost) {
|
|
/**
|
|
* Since node is newly rebuilt, it should be a normal element
|
|
* without shadowRoot.
|
|
* But if there are some weird situations that has defined
|
|
* custom element in the scope before we rebuild node, it may
|
|
* register the shadowRoot earlier.
|
|
* The logic in the 'else' block is just a try-my-best solution
|
|
* for the corner case, please let we know if it is wrong and
|
|
* we can remove it.
|
|
*/
|
|
if (!node.shadowRoot) {
|
|
node.attachShadow({ mode: 'open' });
|
|
} else {
|
|
while (node.shadowRoot.firstChild) {
|
|
node.shadowRoot.removeChild(node.shadowRoot.firstChild);
|
|
}
|
|
}
|
|
}
|
|
return node;
|
|
case NodeType.Text:
|
|
return doc.createTextNode(
|
|
n.isStyle && hackCss
|
|
? addHoverClass(n.textContent, cache)
|
|
: n.textContent,
|
|
);
|
|
case NodeType.CDATA:
|
|
return doc.createCDATASection(n.textContent);
|
|
case NodeType.Comment:
|
|
return doc.createComment(n.textContent);
|
|
default:
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export function buildNodeWithSN(
|
|
n: serializedNodeWithId,
|
|
options: {
|
|
doc: Document;
|
|
map: idNodeMap;
|
|
skipChild?: boolean;
|
|
hackCss: boolean;
|
|
afterAppend?: (n: INode) => unknown;
|
|
cache: BuildCache;
|
|
},
|
|
): INode | null {
|
|
const {
|
|
doc,
|
|
map,
|
|
skipChild = false,
|
|
hackCss = true,
|
|
afterAppend,
|
|
cache,
|
|
} = options;
|
|
let node = buildNode(n, { doc, hackCss, cache });
|
|
if (!node) {
|
|
return null;
|
|
}
|
|
if (n.rootId) {
|
|
console.assert(
|
|
((map[n.rootId] as unknown) as Document) === doc,
|
|
'Target document should has the same root id.',
|
|
);
|
|
}
|
|
// use target document as root document
|
|
if (n.type === NodeType.Document) {
|
|
// close before open to make sure document was closed
|
|
doc.close();
|
|
doc.open();
|
|
if (n.compatMode === 'BackCompat' &&
|
|
(n.childNodes && n.childNodes[0].type !== NodeType.DocumentType) // there isn't one already defined
|
|
) {
|
|
// Trigger compatMode in the iframe
|
|
// this is needed as document.createElement('iframe') otherwise inherits a CSS1Compat mode from the parent replayer environment
|
|
if (n.childNodes[0].type === NodeType.Element &&
|
|
'xmlns' in n.childNodes[0].attributes &&
|
|
n.childNodes[0].attributes.xmlns === 'http://www.w3.org/1999/xhtml') {
|
|
// might as well use an xhtml doctype if we've got an xhtml namespace
|
|
doc.write('<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "">');
|
|
} else {
|
|
doc.write('<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.0 Transitional//EN" "">');
|
|
}
|
|
}
|
|
node = doc;
|
|
}
|
|
|
|
(node as INode).__sn = n;
|
|
map[n.id] = node as INode;
|
|
|
|
if (
|
|
(n.type === NodeType.Document || n.type === NodeType.Element) &&
|
|
!skipChild
|
|
) {
|
|
for (const childN of n.childNodes) {
|
|
const childNode = buildNodeWithSN(childN, {
|
|
doc,
|
|
map,
|
|
skipChild: false,
|
|
hackCss,
|
|
afterAppend,
|
|
cache,
|
|
});
|
|
if (!childNode) {
|
|
console.warn('Failed to rebuild', childN);
|
|
continue;
|
|
}
|
|
|
|
if (childN.isShadow && isElement(node) && node.shadowRoot) {
|
|
node.shadowRoot.appendChild(childNode);
|
|
} else {
|
|
node.appendChild(childNode);
|
|
}
|
|
if (afterAppend) {
|
|
afterAppend(childNode);
|
|
}
|
|
}
|
|
}
|
|
|
|
return node as INode;
|
|
}
|
|
|
|
function visit(idNodeMap: idNodeMap, onVisit: (node: INode) => void) {
|
|
function walk(node: INode) {
|
|
onVisit(node);
|
|
}
|
|
|
|
for (const key in idNodeMap) {
|
|
if (idNodeMap[key]) {
|
|
walk(idNodeMap[key]);
|
|
}
|
|
}
|
|
}
|
|
|
|
function handleScroll(node: INode) {
|
|
const n = node.__sn;
|
|
if (n.type !== NodeType.Element) {
|
|
return;
|
|
}
|
|
const el = (node as Node) as HTMLElement;
|
|
for (const name in n.attributes) {
|
|
if (!(n.attributes.hasOwnProperty(name) && name.startsWith('rr_'))) {
|
|
continue;
|
|
}
|
|
const value = n.attributes[name];
|
|
if (name === 'rr_scrollLeft') {
|
|
el.scrollLeft = value as number;
|
|
}
|
|
if (name === 'rr_scrollTop') {
|
|
el.scrollTop = value as number;
|
|
}
|
|
}
|
|
}
|
|
|
|
function rebuild(
|
|
n: serializedNodeWithId,
|
|
options: {
|
|
doc: Document;
|
|
onVisit?: (node: INode) => unknown;
|
|
hackCss?: boolean;
|
|
afterAppend?: (n: INode) => unknown;
|
|
cache: BuildCache;
|
|
},
|
|
): [Node | null, idNodeMap] {
|
|
const { doc, onVisit, hackCss = true, afterAppend, cache } = options;
|
|
const idNodeMap: idNodeMap = {};
|
|
const node = buildNodeWithSN(n, {
|
|
doc,
|
|
map: idNodeMap,
|
|
skipChild: false,
|
|
hackCss,
|
|
afterAppend,
|
|
cache,
|
|
});
|
|
visit(idNodeMap, (visitedNode) => {
|
|
if (onVisit) {
|
|
onVisit(visitedNode);
|
|
}
|
|
handleScroll(visitedNode);
|
|
});
|
|
return [node, idNodeMap];
|
|
}
|
|
|
|
export default rebuild;
|