* Preserve original quotes when rewriting CSS url() paths - important for inline SVG files which often have spaces * Found an example in the wild with the 'charset=' part left off. This is supported by https://css-tricks.com/lodge/svg/09-svg-data-uris/ ... not sure why we aren't just testing for the 'data:' prefix here? * Not sure why this is now coming back with a double quote after recent changes here; it's supposed to preserve the single quote from style.css??
577 lines
17 KiB
TypeScript
577 lines
17 KiB
TypeScript
import {
|
|
serializedNode,
|
|
serializedNodeWithId,
|
|
NodeType,
|
|
attributes,
|
|
INode,
|
|
idNodeMap,
|
|
MaskInputOptions,
|
|
SlimDOMOptions,
|
|
} from './types';
|
|
|
|
let _id = 1;
|
|
const tagNameRegex = RegExp('[^a-z1-6-_]');
|
|
|
|
export const IGNORED_NODE = -2;
|
|
|
|
function genId(): number {
|
|
return _id++;
|
|
}
|
|
|
|
function getValidTagName(tagName: string): string {
|
|
const processedTagName = tagName.toLowerCase().trim();
|
|
|
|
if (tagNameRegex.test(processedTagName)) {
|
|
// if the tag name is odd and we cannot extract
|
|
// anything from the string, then we return a
|
|
// generic div
|
|
return 'div';
|
|
}
|
|
|
|
return processedTagName;
|
|
}
|
|
|
|
function getCssRulesString(s: CSSStyleSheet): string | null {
|
|
try {
|
|
const rules = s.rules || s.cssRules;
|
|
return rules
|
|
? Array.from(rules).reduce(
|
|
(prev, cur) => prev + getCssRuleString(cur),
|
|
'',
|
|
)
|
|
: null;
|
|
} catch (error) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function getCssRuleString(rule: CSSRule): string {
|
|
return isCSSImportRule(rule)
|
|
? getCssRulesString(rule.styleSheet) || ''
|
|
: rule.cssText;
|
|
}
|
|
|
|
function isCSSImportRule(rule: CSSRule): rule is CSSImportRule {
|
|
return 'styleSheet' in rule;
|
|
}
|
|
|
|
function extractOrigin(url: string): string {
|
|
let origin;
|
|
if (url.indexOf('//') > -1) {
|
|
origin = url.split('/').slice(0, 3).join('/');
|
|
} else {
|
|
origin = url.split('/')[0];
|
|
}
|
|
origin = origin.split('?')[0];
|
|
return origin;
|
|
}
|
|
|
|
const URL_IN_CSS_REF = /url\((?:(')([^']*)'|(")([^"]*)"|([^)]*))\)/gm;
|
|
const RELATIVE_PATH = /^(?!www\.|(?:http|ftp)s?:\/\/|[A-Za-z]:\\|\/\/).*/;
|
|
const DATA_URI = /^(data:)([\w\/\+\-]+);(charset=[\w-]+|base64|utf-?8).*,(.*)/i;
|
|
export function absoluteToStylesheet(
|
|
cssText: string | null,
|
|
href: string,
|
|
): string {
|
|
return (cssText || '').replace(
|
|
URL_IN_CSS_REF,
|
|
(origin, quote1, path1, quote2, path2, path3) => {
|
|
const filePath = path1 || path2 || path3;
|
|
const maybe_quote = quote1 || quote2 || '';
|
|
if (!filePath) {
|
|
return origin;
|
|
}
|
|
if (!RELATIVE_PATH.test(filePath)) {
|
|
return `url(${maybe_quote}${filePath}${maybe_quote})`;
|
|
}
|
|
if (DATA_URI.test(filePath)) {
|
|
return `url(${maybe_quote}${filePath}${maybe_quote})`;
|
|
}
|
|
if (filePath[0] === '/') {
|
|
return `url(${maybe_quote}${extractOrigin(href) + filePath}${maybe_quote})`;
|
|
}
|
|
const stack = href.split('/');
|
|
const parts = filePath.split('/');
|
|
stack.pop();
|
|
for (const part of parts) {
|
|
if (part === '.') {
|
|
continue;
|
|
} else if (part === '..') {
|
|
stack.pop();
|
|
} else {
|
|
stack.push(part);
|
|
}
|
|
}
|
|
return `url(${maybe_quote}${stack.join('/')}${maybe_quote})`;
|
|
},
|
|
);
|
|
}
|
|
|
|
function getAbsoluteSrcsetString(doc: Document, attributeValue: string) {
|
|
if (attributeValue.trim() === '') {
|
|
return attributeValue;
|
|
}
|
|
|
|
const srcsetValues = attributeValue.split(',');
|
|
// srcset attributes is defined as such:
|
|
// srcset = "url size,url1 size1"
|
|
const resultingSrcsetString = srcsetValues
|
|
.map((srcItem) => {
|
|
// removing all but middle spaces
|
|
const trimmedSrcItem = srcItem.trimLeft().trimRight();
|
|
const urlAndSize = trimmedSrcItem.split(' ');
|
|
// this means we have both 0:url and 1:size
|
|
if (urlAndSize.length === 2) {
|
|
const absUrl = absoluteToDoc(doc, urlAndSize[0]);
|
|
return `${absUrl} ${urlAndSize[1]}`;
|
|
} else if (urlAndSize.length === 1) {
|
|
const absUrl = absoluteToDoc(doc, urlAndSize[0]);
|
|
return `${absUrl}`;
|
|
}
|
|
return '';
|
|
})
|
|
.join(', ');
|
|
|
|
return resultingSrcsetString;
|
|
}
|
|
|
|
export function absoluteToDoc(doc: Document, attributeValue: string): string {
|
|
if (!attributeValue || attributeValue.trim() === '') {
|
|
return attributeValue;
|
|
}
|
|
const a: HTMLAnchorElement = doc.createElement('a');
|
|
a.href = attributeValue;
|
|
return a.href;
|
|
}
|
|
|
|
function isSVGElement(el: Element): boolean {
|
|
return el.tagName === 'svg' || el instanceof SVGElement;
|
|
}
|
|
|
|
export function transformAttribute(
|
|
doc: Document,
|
|
name: string,
|
|
value: string,
|
|
): string {
|
|
// relative path in attribute
|
|
if (name === 'src' || (name === 'href' && value)) {
|
|
return absoluteToDoc(doc, value);
|
|
} else if (name === 'srcset' && value) {
|
|
return getAbsoluteSrcsetString(doc, value);
|
|
} else if (name === 'style' && value) {
|
|
return absoluteToStylesheet(value, location.href);
|
|
} else {
|
|
return value;
|
|
}
|
|
}
|
|
|
|
function serializeNode(
|
|
n: Node,
|
|
doc: Document,
|
|
blockClass: string | RegExp,
|
|
inlineStylesheet: boolean,
|
|
maskInputOptions: MaskInputOptions = {},
|
|
recordCanvas: boolean,
|
|
): serializedNode | false {
|
|
switch (n.nodeType) {
|
|
case n.DOCUMENT_NODE:
|
|
return {
|
|
type: NodeType.Document,
|
|
childNodes: [],
|
|
};
|
|
case n.DOCUMENT_TYPE_NODE:
|
|
return {
|
|
type: NodeType.DocumentType,
|
|
name: (n as DocumentType).name,
|
|
publicId: (n as DocumentType).publicId,
|
|
systemId: (n as DocumentType).systemId,
|
|
};
|
|
case n.ELEMENT_NODE:
|
|
let needBlock = false;
|
|
if (typeof blockClass === 'string') {
|
|
needBlock = (n as HTMLElement).classList.contains(blockClass);
|
|
} else {
|
|
(n as HTMLElement).classList.forEach((className) => {
|
|
if (blockClass.test(className)) {
|
|
needBlock = true;
|
|
}
|
|
});
|
|
}
|
|
const tagName = getValidTagName((n as HTMLElement).tagName);
|
|
let attributes: attributes = {};
|
|
for (const { name, value } of Array.from((n as HTMLElement).attributes)) {
|
|
attributes[name] = transformAttribute(doc, name, value);
|
|
}
|
|
// remote css
|
|
if (tagName === 'link' && inlineStylesheet) {
|
|
const stylesheet = Array.from(doc.styleSheets).find((s) => {
|
|
return s.href === (n as HTMLLinkElement).href;
|
|
});
|
|
const cssText = getCssRulesString(stylesheet as CSSStyleSheet);
|
|
if (cssText) {
|
|
delete attributes.rel;
|
|
delete attributes.href;
|
|
attributes._cssText = absoluteToStylesheet(
|
|
cssText,
|
|
stylesheet!.href!,
|
|
);
|
|
}
|
|
}
|
|
// dynamic stylesheet
|
|
if (
|
|
tagName === 'style' &&
|
|
(n as HTMLStyleElement).sheet &&
|
|
// TODO: Currently we only try to get dynamic stylesheet when it is an empty style element
|
|
!(
|
|
(n as HTMLElement).innerText ||
|
|
(n as HTMLElement).textContent ||
|
|
''
|
|
).trim().length
|
|
) {
|
|
const cssText = getCssRulesString(
|
|
(n as HTMLStyleElement).sheet as CSSStyleSheet,
|
|
);
|
|
if (cssText) {
|
|
attributes._cssText = absoluteToStylesheet(cssText, location.href);
|
|
}
|
|
}
|
|
// form fields
|
|
if (
|
|
tagName === 'input' ||
|
|
tagName === 'textarea' ||
|
|
tagName === 'select'
|
|
) {
|
|
const value = (n as HTMLInputElement | HTMLTextAreaElement).value;
|
|
if (
|
|
attributes.type !== 'radio' &&
|
|
attributes.type !== 'checkbox' &&
|
|
attributes.type !== 'submit' &&
|
|
attributes.type !== 'button' &&
|
|
value
|
|
) {
|
|
attributes.value =
|
|
maskInputOptions[attributes.type as keyof MaskInputOptions] ||
|
|
maskInputOptions[tagName as keyof MaskInputOptions]
|
|
? '*'.repeat(value.length)
|
|
: value;
|
|
} else if ((n as HTMLInputElement).checked) {
|
|
attributes.checked = (n as HTMLInputElement).checked;
|
|
}
|
|
}
|
|
if (tagName === 'option') {
|
|
const selectValue = (n as HTMLOptionElement).parentElement;
|
|
if (attributes.value === (selectValue as HTMLSelectElement).value) {
|
|
attributes.selected = (n as HTMLOptionElement).selected;
|
|
}
|
|
}
|
|
// canvas image data
|
|
if (tagName === 'canvas' && recordCanvas) {
|
|
attributes.rr_dataURL = (n as HTMLCanvasElement).toDataURL();
|
|
}
|
|
// media elements
|
|
if (tagName === 'audio' || tagName === 'video') {
|
|
attributes.rr_mediaState = (n as HTMLMediaElement).paused
|
|
? 'paused'
|
|
: 'played';
|
|
}
|
|
// scroll
|
|
if ((n as HTMLElement).scrollLeft) {
|
|
attributes.rr_scrollLeft = (n as HTMLElement).scrollLeft;
|
|
}
|
|
if ((n as HTMLElement).scrollTop) {
|
|
attributes.rr_scrollTop = (n as HTMLElement).scrollTop;
|
|
}
|
|
if (needBlock) {
|
|
const { width, height } = (n as HTMLElement).getBoundingClientRect();
|
|
attributes.rr_width = `${width}px`;
|
|
attributes.rr_height = `${height}px`;
|
|
}
|
|
return {
|
|
type: NodeType.Element,
|
|
tagName,
|
|
attributes,
|
|
childNodes: [],
|
|
isSVG: isSVGElement(n as Element) || undefined,
|
|
needBlock,
|
|
};
|
|
case n.TEXT_NODE:
|
|
// The parent node may not be a html element which has a tagName attribute.
|
|
// So just let it be undefined which is ok in this use case.
|
|
const parentTagName =
|
|
n.parentNode && (n.parentNode as HTMLElement).tagName;
|
|
let textContent = (n as Text).textContent;
|
|
const isStyle = parentTagName === 'STYLE' ? true : undefined;
|
|
if (isStyle && textContent) {
|
|
textContent = absoluteToStylesheet(textContent, location.href);
|
|
}
|
|
if (parentTagName === 'SCRIPT') {
|
|
textContent = 'SCRIPT_PLACEHOLDER';
|
|
}
|
|
return {
|
|
type: NodeType.Text,
|
|
textContent: textContent || '',
|
|
isStyle,
|
|
};
|
|
case n.CDATA_SECTION_NODE:
|
|
return {
|
|
type: NodeType.CDATA,
|
|
textContent: '',
|
|
};
|
|
case n.COMMENT_NODE:
|
|
return {
|
|
type: NodeType.Comment,
|
|
textContent: (n as Comment).textContent || '',
|
|
};
|
|
default:
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function lowerIfExists(maybeAttr : string | number | boolean) : string {
|
|
if (maybeAttr === undefined) {
|
|
return '';
|
|
} else {
|
|
return (maybeAttr as string).toLowerCase();
|
|
}
|
|
}
|
|
|
|
function slimDOMExcluded(sn: serializedNode, slimDOMOptions: SlimDOMOptions): boolean {
|
|
if (slimDOMOptions.comment && sn.type === NodeType.Comment) {
|
|
// TODO: convert IE conditional comments to real nodes
|
|
return true;
|
|
} else if (sn.type === NodeType.Element) {
|
|
if (slimDOMOptions.script &&
|
|
(sn.tagName === 'script' ||
|
|
(sn.tagName === 'link' && sn.attributes.rel === 'preload' && sn.attributes['as'] === 'script')
|
|
)) {
|
|
return true;
|
|
} else if (slimDOMOptions.headFavicon && (
|
|
(sn.tagName === 'link' && sn.attributes.rel === 'shortcut icon')
|
|
|| (sn.tagName === 'meta' && (
|
|
lowerIfExists(sn.attributes['name']).match(/^msapplication-tile(image|color)$/)
|
|
|| lowerIfExists(sn.attributes['name']) === 'application-name'
|
|
|| lowerIfExists(sn.attributes['rel']) === 'icon'
|
|
|| lowerIfExists(sn.attributes['rel']) === 'apple-touch-icon'
|
|
|| lowerIfExists(sn.attributes['rel']) === 'shortcut icon'
|
|
)))) {
|
|
return true;
|
|
} else if (sn.tagName === 'meta') {
|
|
if (slimDOMOptions.headMetaDescKeywords && (
|
|
lowerIfExists(sn.attributes['name']).match(/^description|keywords$/)
|
|
)) {
|
|
return true;
|
|
} else if (slimDOMOptions.headMetaSocial && (
|
|
lowerIfExists(sn.attributes['property']).match(/^(og|twitter|fb):/) // og = opengraph (facebook)
|
|
|| lowerIfExists(sn.attributes['name']).match(/^(og|twitter):/)
|
|
|| lowerIfExists(sn.attributes['name']) === 'pinterest'
|
|
)) {
|
|
return true;
|
|
} else if (slimDOMOptions.headMetaRobots && (
|
|
lowerIfExists(sn.attributes['name']) === 'robots'
|
|
|| lowerIfExists(sn.attributes['name']) === 'googlebot'
|
|
|| lowerIfExists(sn.attributes['name']) === 'bingbot'
|
|
)) {
|
|
return true;
|
|
} else if (slimDOMOptions.headMetaHttpEquiv && (
|
|
sn.attributes['http-equiv'] !== undefined
|
|
)) {
|
|
// e.g. X-UA-Compatible, Content-Type, Content-Language,
|
|
// cache-control, X-Translated-By
|
|
return true;
|
|
} else if (slimDOMOptions.headMetaAuthorship && (
|
|
lowerIfExists(sn.attributes['name']) === 'author'
|
|
|| lowerIfExists(sn.attributes['name']) === 'generator'
|
|
|| lowerIfExists(sn.attributes['name']) === 'framework'
|
|
|| lowerIfExists(sn.attributes['name']) === 'publisher'
|
|
|| lowerIfExists(sn.attributes['name']) === 'progid'
|
|
|| lowerIfExists(sn.attributes['property']).match(/^article:/)
|
|
|| lowerIfExists(sn.attributes['property']).match(/^product:/)
|
|
)) {
|
|
return true;
|
|
} else if (slimDOMOptions.headMetaVerification && (
|
|
lowerIfExists(sn.attributes['name']) === 'google-site-verification'
|
|
|| lowerIfExists(sn.attributes['name']) === 'yandex-verification'
|
|
|| lowerIfExists(sn.attributes['name']) === 'csrf-token'
|
|
|| lowerIfExists(sn.attributes['name']) === 'p:domain_verify'
|
|
|| lowerIfExists(sn.attributes['name']) === 'verify-v1'
|
|
|| lowerIfExists(sn.attributes['name']) === 'verification'
|
|
|| lowerIfExists(sn.attributes['name']) === 'shopify-checkout-api-token'
|
|
)) {
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
export function serializeNodeWithId(
|
|
n: Node | INode,
|
|
doc: Document,
|
|
map: idNodeMap,
|
|
blockClass: string | RegExp,
|
|
skipChild = false,
|
|
inlineStylesheet = true,
|
|
maskInputOptions?: MaskInputOptions,
|
|
slimDOMOptions: SlimDOMOptions = {},
|
|
recordCanvas?: boolean,
|
|
preserveWhiteSpace = true,
|
|
): serializedNodeWithId | null {
|
|
const _serializedNode = serializeNode(
|
|
n,
|
|
doc,
|
|
blockClass,
|
|
inlineStylesheet,
|
|
maskInputOptions,
|
|
recordCanvas || false,
|
|
);
|
|
if (!_serializedNode) {
|
|
// TODO: dev only
|
|
console.warn(n, 'not serialized');
|
|
return null;
|
|
}
|
|
|
|
let id;
|
|
// Try to reuse the previous id
|
|
if ('__sn' in n) {
|
|
id = n.__sn.id;
|
|
} else if (slimDOMExcluded(_serializedNode, slimDOMOptions) ||
|
|
(!preserveWhiteSpace &&
|
|
_serializedNode.type === NodeType.Text &&
|
|
!_serializedNode.isStyle &&
|
|
!_serializedNode.textContent.replace(/^\s+|\s+$/gm,'').length
|
|
)) {
|
|
id = IGNORED_NODE;
|
|
} else {
|
|
id = genId();
|
|
}
|
|
const serializedNode = Object.assign(_serializedNode, { id });
|
|
(n as INode).__sn = serializedNode;
|
|
if (id === IGNORED_NODE) {
|
|
return null; // slimDOM
|
|
}
|
|
map[id] = n as INode;
|
|
let recordChild = !skipChild;
|
|
if (serializedNode.type === NodeType.Element) {
|
|
recordChild = recordChild && !serializedNode.needBlock;
|
|
// this property was not needed in replay side
|
|
delete serializedNode.needBlock;
|
|
}
|
|
if (
|
|
(serializedNode.type === NodeType.Document ||
|
|
serializedNode.type === NodeType.Element) &&
|
|
recordChild
|
|
) {
|
|
if (
|
|
(slimDOMOptions.headWhitespace &&
|
|
_serializedNode.type === NodeType.Element &&
|
|
_serializedNode.tagName == 'head')
|
|
// would impede performance: || getComputedStyle(n)['white-space'] === 'normal'
|
|
) {
|
|
preserveWhiteSpace = false;
|
|
}
|
|
for (const childN of Array.from(n.childNodes)) {
|
|
const serializedChildNode = serializeNodeWithId(
|
|
childN,
|
|
doc,
|
|
map,
|
|
blockClass,
|
|
skipChild,
|
|
inlineStylesheet,
|
|
maskInputOptions,
|
|
slimDOMOptions,
|
|
recordCanvas,
|
|
preserveWhiteSpace,
|
|
);
|
|
if (serializedChildNode) {
|
|
serializedNode.childNodes.push(serializedChildNode);
|
|
}
|
|
}
|
|
}
|
|
return serializedNode;
|
|
}
|
|
|
|
function snapshot(
|
|
n: Document,
|
|
blockClass: string | RegExp = 'rr-block',
|
|
inlineStylesheet = true,
|
|
maskAllInputsOrOptions: boolean | MaskInputOptions,
|
|
slimDOMSensibleOrOptions: boolean | SlimDOMOptions,
|
|
recordCanvas?: boolean,
|
|
): [serializedNodeWithId | null, idNodeMap] {
|
|
const idNodeMap: idNodeMap = {};
|
|
const maskInputOptions: MaskInputOptions =
|
|
maskAllInputsOrOptions === true
|
|
? {
|
|
color: true,
|
|
date: true,
|
|
'datetime-local': true,
|
|
email: true,
|
|
month: true,
|
|
number: true,
|
|
range: true,
|
|
search: true,
|
|
tel: true,
|
|
text: true,
|
|
time: true,
|
|
url: true,
|
|
week: true,
|
|
textarea: true,
|
|
select: true,
|
|
}
|
|
: maskAllInputsOrOptions === false
|
|
? {}
|
|
: maskAllInputsOrOptions;
|
|
const slimDOMOptions: SlimDOMOptions =
|
|
(slimDOMSensibleOrOptions === true ||
|
|
slimDOMSensibleOrOptions === 'all')
|
|
// if true: set of sensible options that should not throw away any information
|
|
? {
|
|
script: true,
|
|
comment: true,
|
|
headFavicon: true,
|
|
headWhitespace: true,
|
|
headMetaDescKeywords: slimDOMSensibleOrOptions === 'all', // destructive
|
|
headMetaSocial: true,
|
|
headMetaRobots: true,
|
|
headMetaHttpEquiv: true,
|
|
headMetaAuthorship: true,
|
|
headMetaVerification: true,
|
|
}
|
|
: slimDOMSensibleOrOptions === false
|
|
? {}
|
|
: slimDOMSensibleOrOptions;
|
|
return [
|
|
serializeNodeWithId(
|
|
n,
|
|
n,
|
|
idNodeMap,
|
|
blockClass,
|
|
false,
|
|
inlineStylesheet,
|
|
maskInputOptions,
|
|
slimDOMOptions,
|
|
recordCanvas,
|
|
),
|
|
idNodeMap,
|
|
];
|
|
}
|
|
|
|
export function visitSnapshot(
|
|
node: serializedNodeWithId,
|
|
onVisit: (node: serializedNodeWithId) => unknown,
|
|
) {
|
|
function walk(current: serializedNodeWithId) {
|
|
onVisit(current);
|
|
if (
|
|
current.type === NodeType.Document ||
|
|
current.type === NodeType.Element
|
|
) {
|
|
current.childNodes.forEach(walk);
|
|
}
|
|
}
|
|
|
|
walk(node);
|
|
}
|
|
|
|
export default snapshot;
|