Merge branch 'rrweb-snapshot' into monorepo

This commit is contained in:
Mark-fenng
2026-04-01 12:00:00 +08:00
53 changed files with 4954 additions and 0 deletions

View File

@@ -0,0 +1,9 @@
{
"non-interactive": true,
"hooks": {
"before:init": ["npm run bundle", "npm run typings"]
},
"git": {
"requireCleanWorkingDir": false
}
}

View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2018 Contributors (https://github.com/rrweb-io/rrweb-snapshot/graphs/contributors) and SmartX Inc.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -0,0 +1,40 @@
# rrweb-snapshot
[![Build Status](https://travis-ci.org/rrweb-io/rrweb-snapshot.svg?branch=master)](https://travis-ci.org/rrweb-io/rrweb-snapshot) [![Join the chat at https://gitter.im/rrweb-io/rrweb-snapshot](https://badges.gitter.im/rrweb-io/rrweb-snapshot.svg)](https://gitter.im/rrweb-io/rrweb-snapshot?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge)
Snapshot the DOM into a stateful and serializable data structure.
Also, provide the ability to rebuild the DOM via snapshot.
## API
This module export following methods:
### snapshot
`snapshot` will traverse the DOM and return a stateful and serializable data structure which can represent the current DOM **view**.
There are several things will be done during snapshot:
1. Inline some DOM states into HTML attributes, e.g, HTMLInputElement's value.
2. Turn script tags into `noscript` tags to avoid scripts being executed.
3. Try to inline stylesheets to make sure local stylesheets can be used.
4. Make relative paths in href, src, CSS to be absolute paths.
5. Give an id to each Node, and return the id node map when snapshot finished.
#### rebuild
`rebuild` will build the DOM according to the taken snapshot.
There are several things will be done during rebuild:
1. Add data-rrid attribute if the Node is an Element.
2. Create some extra DOM node like text node to place inline CSS and some states.
3. Add data-extra-child-index attribute if Node has some extra child DOM.
#### serializeNodeWithId
`serializeNodeWithId` can serialize a node into snapshot format with id.
#### buildNodeWithSN
`buildNodeWithSN` will build DOM from serialized node and store serialized information in `__sn` property.

View File

@@ -0,0 +1,57 @@
{
"name": "rrweb-snapshot",
"version": "1.1.7",
"description": "rrweb's component to take a snapshot of DOM, aka DOM serializer",
"scripts": {
"prepare": "npm run prepack",
"prepack": "npm run bundle && npm run typings",
"test": "cross-env TS_NODE_CACHE=false TS_NODE_FILES=true mocha -r ts-node/register test/**/*.ts",
"bundle": "rollup --config",
"typings": "tsc -d --declarationDir typings"
},
"repository": {
"type": "git",
"url": "git+https://github.com/rrweb-io/rrweb-snapshot.git"
},
"keywords": [
"rrweb",
"snapshot",
"DOM"
],
"main": "lib/rrweb-snapshot.js",
"module": "es/rrweb-snapshot.js",
"unpkg": "dist/rrweb-snapshot.js",
"typings": "typings/index.d.ts",
"files": [
"dist",
"lib",
"es",
"typings"
],
"author": "yanzhen@smartx.com",
"license": "MIT",
"bugs": {
"url": "https://github.com/rrweb-io/rrweb-snapshot/issues"
},
"homepage": "https://github.com/rrweb-io/rrweb-snapshot#readme",
"devDependencies": {
"@types/chai": "^4.1.4",
"@types/jsdom": "^16.2.4",
"@types/mocha": "^5.2.5",
"@types/node": "^10.11.3",
"@types/puppeteer": "^1.12.4",
"chai": "^4.1.2",
"cross-env": "^5.2.0",
"jest-snapshot": "^23.6.0",
"jsdom": "^16.4.0",
"mocha": "^5.2.0",
"puppeteer": "^1.15.0",
"rollup": "^0.66.4",
"rollup-plugin-terser": "^3.0.0",
"rollup-plugin-typescript": "^1.0.0",
"ts-node": "^7.0.1",
"tslib": "^1.9.3",
"tslint": "^4.5.1",
"typescript": "^3.4.1"
}
}

View File

@@ -0,0 +1,67 @@
import typescript from 'rollup-plugin-typescript';
import { terser } from 'rollup-plugin-terser';
import pkg from './package.json';
function toMinPath(path) {
return path.replace(/\.js$/, '.min.js');
}
export default [
// browser
{
input: './src/index.ts',
plugins: [typescript()],
output: [
{
name: 'rrwebSnapshot',
format: 'iife',
file: pkg.unpkg,
},
],
},
{
input: './src/index.ts',
plugins: [typescript(), terser()],
output: [
{
name: 'rrwebSnapshot',
format: 'iife',
file: toMinPath(pkg.unpkg),
sourcemap: true,
},
],
},
// CommonJS
{
input: './src/index.ts',
plugins: [typescript()],
output: [
{
format: 'cjs',
file: pkg.main,
},
],
},
// ES module
{
input: './src/index.ts',
plugins: [typescript()],
output: [
{
format: 'esm',
file: pkg.module,
},
],
},
{
input: './src/index.ts',
plugins: [typescript(), terser()],
output: [
{
format: 'esm',
file: toMinPath(pkg.module),
sourcemap: true,
},
],
},
];

View File

@@ -0,0 +1,909 @@
/**
* This file is a fork of https://github.com/reworkcss/css/blob/master/lib/parse/index.js
* I fork it because:
* 1. The css library was built for node.js which does not have tree-shaking supports.
* 2. Rewrites into typescript give us a better type interface.
*/
/* tslint:disable no-conditional-assignment interface-name no-shadowed-variable */
export interface ParserOptions {
/** Silently fail on parse errors */
silent?: boolean;
/**
* The path to the file containing css.
* Makes errors and source maps more helpful, by letting them know where code comes from.
*/
source?: string;
}
/**
* Error thrown during parsing.
*/
export interface ParserError {
/** The full error message with the source position. */
message?: string;
/** The error message without position. */
reason?: string;
/** The value of options.source if passed to css.parse. Otherwise undefined. */
filename?: string;
line?: number;
column?: number;
/** The portion of code that couldn't be parsed. */
source?: string;
}
export interface Loc {
line?: number;
column?: number;
}
/**
* Base AST Tree Node.
*/
export interface Node {
/** The possible values are the ones listed in the Types section on https://github.com/reworkcss/css page. */
type?: string;
/** A reference to the parent node, or null if the node has no parent. */
parent?: Node;
/** Information about the position in the source string that corresponds to the node. */
position?: {
start?: Loc;
end?: Loc;
/** The value of options.source if passed to css.parse. Otherwise undefined. */
source?: string;
/** The full source string passed to css.parse. */
content?: string;
};
}
export interface Rule extends Node {
/** The list of selectors of the rule, split on commas. Each selector is trimmed from whitespace and comments. */
selectors?: string[];
/** Array of nodes with the types declaration and comment. */
declarations?: Array<Declaration | Comment>;
}
export interface Declaration extends Node {
/** The property name, trimmed from whitespace and comments. May not be empty. */
property?: string;
/** The value of the property, trimmed from whitespace and comments. Empty values are allowed. */
value?: string;
}
/**
* A rule-level or declaration-level comment. Comments inside selectors, properties and values etc. are lost.
*/
export interface Comment extends Node {
comment?: string;
}
/**
* The @charset at-rule.
*/
export interface Charset extends Node {
/** The part following @charset. */
charset?: string;
}
/**
* The @custom-media at-rule
*/
export interface CustomMedia extends Node {
/** The ---prefixed name. */
name?: string;
/** The part following the name. */
media?: string;
}
/**
* The @document at-rule.
*/
export interface Document extends Node {
/** The part following @document. */
document?: string;
/** The vendor prefix in @document, or undefined if there is none. */
vendor?: string;
/** Array of nodes with the types rule, comment and any of the at-rule types. */
rules?: Array<Rule | Comment | AtRule>;
}
/**
* The @font-face at-rule.
*/
export interface FontFace extends Node {
/** Array of nodes with the types declaration and comment. */
declarations?: Array<Declaration | Comment>;
}
/**
* The @host at-rule.
*/
export interface Host extends Node {
/** Array of nodes with the types rule, comment and any of the at-rule types. */
rules?: Array<Rule | Comment | AtRule>;
}
/**
* The @import at-rule.
*/
export interface Import extends Node {
/** The part following @import. */
import?: string;
}
/**
* The @keyframes at-rule.
*/
export interface KeyFrames extends Node {
/** The name of the keyframes rule. */
name?: string;
/** The vendor prefix in @keyframes, or undefined if there is none. */
vendor?: string;
/** Array of nodes with the types keyframe and comment. */
keyframes?: Array<KeyFrame | Comment>;
}
export interface KeyFrame extends Node {
/** The list of "selectors" of the keyframe rule, split on commas. Each “selector” is trimmed from whitespace. */
values?: string[];
/** Array of nodes with the types declaration and comment. */
declarations?: Array<Declaration | Comment>;
}
/**
* The @media at-rule.
*/
export interface Media extends Node {
/** The part following @media. */
media?: string;
/** Array of nodes with the types rule, comment and any of the at-rule types. */
rules?: Array<Rule | Comment | AtRule>;
}
/**
* The @namespace at-rule.
*/
export interface Namespace extends Node {
/** The part following @namespace. */
namespace?: string;
}
/**
* The @page at-rule.
*/
export interface Page extends Node {
/** The list of selectors of the rule, split on commas. Each selector is trimmed from whitespace and comments. */
selectors?: string[];
/** Array of nodes with the types declaration and comment. */
declarations?: Array<Declaration | Comment>;
}
/**
* The @supports at-rule.
*/
export interface Supports extends Node {
/** The part following @supports. */
supports?: string;
/** Array of nodes with the types rule, comment and any of the at-rule types. */
rules?: Array<Rule | Comment | AtRule>;
}
/** All at-rules. */
export type AtRule =
| Charset
| CustomMedia
| Document
| FontFace
| Host
| Import
| KeyFrames
| Media
| Namespace
| Page
| Supports;
/**
* A collection of rules
*/
export interface StyleRules {
source?: string;
/** Array of nodes with the types rule, comment and any of the at-rule types. */
rules: Array<Rule | Comment | AtRule>;
/** Array of Errors. Errors collected during parsing when option silent is true. */
parsingErrors?: ParserError[];
}
/**
* The root node returned by css.parse.
*/
export interface Stylesheet extends Node {
stylesheet?: StyleRules;
}
// http://www.w3.org/TR/CSS21/grammar.html
// https://github.com/visionmedia/css-parse/pull/49#issuecomment-30088027
const commentre = /\/\*[^*]*\*+([^/*][^*]*\*+)*\//g;
export function parse(css: string, options: ParserOptions = {}) {
/**
* Positional.
*/
let lineno = 1;
let column = 1;
/**
* Update lineno and column based on `str`.
*/
function updatePosition(str: string) {
const lines = str.match(/\n/g);
if (lines) {
lineno += lines.length;
}
let i = str.lastIndexOf('\n');
column = i === -1 ? column + str.length : str.length - i;
}
/**
* Mark position and patch `node.position`.
*/
function position() {
const start = { line: lineno, column };
return (
node: Rule | Declaration | Comment | AtRule | Stylesheet | KeyFrame,
) => {
node.position = new Position(start);
whitespace();
return node;
};
}
/**
* Store position information for a node
*/
class Position {
public content!: string;
public start!: Loc;
public end!: Loc;
public source?: string;
constructor(start: Loc) {
this.start = start;
this.end = { line: lineno, column };
this.source = options.source;
}
}
/**
* Non-enumerable source string
*/
Position.prototype.content = css;
const errorsList: ParserError[] = [];
function error(msg: string) {
const err = new Error(
options.source + ':' + lineno + ':' + column + ': ' + msg,
) as ParserError;
err.reason = msg;
err.filename = options.source;
err.line = lineno;
err.column = column;
err.source = css;
if (options.silent) {
errorsList.push(err);
} else {
throw err;
}
}
/**
* Parse stylesheet.
*/
function stylesheet(): Stylesheet {
const rulesList = rules();
return {
type: 'stylesheet',
stylesheet: {
source: options.source,
rules: rulesList,
parsingErrors: errorsList,
},
};
}
/**
* Opening brace.
*/
function open() {
return match(/^{\s*/);
}
/**
* Closing brace.
*/
function close() {
return match(/^}/);
}
/**
* Parse ruleset.
*/
function rules() {
let node: Rule | void;
const rules: Rule[] = [];
whitespace();
comments(rules);
while (css.length && css.charAt(0) !== '}' && (node = atrule() || rule())) {
if (node !== false) {
rules.push(node);
comments(rules);
}
}
return rules;
}
/**
* Match `re` and return captures.
*/
function match(re: RegExp) {
const m = re.exec(css);
if (!m) {
return;
}
const str = m[0];
updatePosition(str);
css = css.slice(str.length);
return m;
}
/**
* Parse whitespace.
*/
function whitespace() {
match(/^\s*/);
}
/**
* Parse comments;
*/
function comments(rules: Rule[] = []) {
let c: Comment | void;
while ((c = comment())) {
if (c !== false) {
rules.push(c);
}
c = comment();
}
return rules;
}
/**
* Parse comment.
*/
function comment() {
const pos = position();
if ('/' !== css.charAt(0) || '*' !== css.charAt(1)) {
return;
}
let i = 2;
while (
'' !== css.charAt(i) &&
('*' !== css.charAt(i) || '/' !== css.charAt(i + 1))
) {
++i;
}
i += 2;
if ('' === css.charAt(i - 1)) {
return error('End of comment missing');
}
const str = css.slice(2, i - 2);
column += 2;
updatePosition(str);
css = css.slice(i);
column += 2;
return pos({
type: 'comment',
comment: str,
});
}
/**
* Parse selector.
*/
function selector() {
const m = match(/^([^{]+)/);
if (!m) {
return;
}
/* @fix Remove all comments from selectors
* http://ostermiller.org/findcomment.html */
return trim(m[0])
.replace(/\/\*([^*]|[\r\n]|(\*+([^*/]|[\r\n])))*\*\/+/g, '')
.replace(/"(?:\\"|[^"])*"|'(?:\\'|[^'])*'/g, (m) => {
return m.replace(/,/g, '\u200C');
})
.split(/\s*(?![^(]*\)),\s*/)
.map((s) => {
return s.replace(/\u200C/g, ',');
});
}
/**
* Parse declaration.
*/
function declaration(): Declaration | void | never {
const pos = position();
// prop
let propMatch = match(/^(\*?[-#\/\*\\\w]+(\[[0-9a-z_-]+\])?)\s*/);
if (!propMatch) {
return;
}
const prop = trim(propMatch[0]);
// :
if (!match(/^:\s*/)) {
return error(`property missing ':'`);
}
// val
const val = match(/^((?:'(?:\\'|.)*?'|"(?:\\"|.)*?"|\([^\)]*?\)|[^};])+)/);
const ret = pos({
type: 'declaration',
property: prop.replace(commentre, ''),
value: val ? trim(val[0]).replace(commentre, '') : '',
});
// ;
match(/^[;\s]*/);
return ret;
}
/**
* Parse declarations.
*/
function declarations() {
const decls: Array<object> = [];
if (!open()) {
return error(`missing '{'`);
}
comments(decls);
// declarations
let decl;
while ((decl = declaration())) {
if ((decl as unknown) !== false) {
decls.push(decl);
comments(decls);
}
decl = declaration();
}
if (!close()) {
return error(`missing '}'`);
}
return decls;
}
/**
* Parse keyframe.
*/
function keyframe() {
let m;
const vals = [];
const pos = position();
while ((m = match(/^((\d+\.\d+|\.\d+|\d+)%?|[a-z]+)\s*/))) {
vals.push(m[1]);
match(/^,\s*/);
}
if (!vals.length) {
return;
}
return pos({
type: 'keyframe',
values: vals,
declarations: declarations() as Declaration[],
});
}
/**
* Parse keyframes.
*/
function atkeyframes() {
const pos = position();
let m = match(/^@([-\w]+)?keyframes\s*/);
if (!m) {
return;
}
const vendor = m[1];
// identifier
m = match(/^([-\w]+)\s*/);
if (!m) {
return error('@keyframes missing name');
}
const name = m[1];
if (!open()) {
return error(`@keyframes missing '{'`);
}
let frame;
let frames = comments();
while ((frame = keyframe())) {
frames.push(frame);
frames = frames.concat(comments());
}
if (!close()) {
return error(`@keyframes missing '}'`);
}
return pos({
type: 'keyframes',
name,
vendor,
keyframes: frames,
});
}
/**
* Parse supports.
*/
function atsupports() {
const pos = position();
const m = match(/^@supports *([^{]+)/);
if (!m) {
return;
}
const supports = trim(m[1]);
if (!open()) {
return error(`@supports missing '{'`);
}
const style = comments().concat(rules());
if (!close()) {
return error(`@supports missing '}'`);
}
return pos({
type: 'supports',
supports,
rules: style,
});
}
/**
* Parse host.
*/
function athost() {
const pos = position();
const m = match(/^@host\s*/);
if (!m) {
return;
}
if (!open()) {
return error(`@host missing '{'`);
}
const style = comments().concat(rules());
if (!close()) {
return error(`@host missing '}'`);
}
return pos({
type: 'host',
rules: style,
});
}
/**
* Parse media.
*/
function atmedia() {
const pos = position();
const m = match(/^@media *([^{]+)/);
if (!m) {
return;
}
const media = trim(m[1]);
if (!open()) {
return error(`@media missing '{'`);
}
const style = comments().concat(rules());
if (!close()) {
return error(`@media missing '}'`);
}
return pos({
type: 'media',
media,
rules: style,
});
}
/**
* Parse custom-media.
*/
function atcustommedia() {
const pos = position();
const m = match(/^@custom-media\s+(--[^\s]+)\s*([^{;]+);/);
if (!m) {
return;
}
return pos({
type: 'custom-media',
name: trim(m[1]),
media: trim(m[2]),
});
}
/**
* Parse paged media.
*/
function atpage() {
const pos = position();
const m = match(/^@page */);
if (!m) {
return;
}
const sel = selector() || [];
if (!open()) {
return error(`@page missing '{'`);
}
let decls = comments();
// declarations
let decl;
while ((decl = declaration())) {
decls.push(decl);
decls = decls.concat(comments());
}
if (!close()) {
return error(`@page missing '}'`);
}
return pos({
type: 'page',
selectors: sel,
declarations: decls,
});
}
/**
* Parse document.
*/
function atdocument() {
const pos = position();
const m = match(/^@([-\w]+)?document *([^{]+)/);
if (!m) {
return;
}
const vendor = trim(m[1]);
const doc = trim(m[2]);
if (!open()) {
return error(`@document missing '{'`);
}
const style = comments().concat(rules());
if (!close()) {
return error(`@document missing '}'`);
}
return pos({
type: 'document',
document: doc,
vendor,
rules: style,
});
}
/**
* Parse font-face.
*/
function atfontface() {
const pos = position();
const m = match(/^@font-face\s*/);
if (!m) {
return;
}
if (!open()) {
return error(`@font-face missing '{'`);
}
let decls = comments();
// declarations
let decl;
while ((decl = declaration())) {
decls.push(decl);
decls = decls.concat(comments());
}
if (!close()) {
return error(`@font-face missing '}'`);
}
return pos({
type: 'font-face',
declarations: decls,
});
}
/**
* Parse import
*/
const atimport = _compileAtrule('import');
/**
* Parse charset
*/
const atcharset = _compileAtrule('charset');
/**
* Parse namespace
*/
const atnamespace = _compileAtrule('namespace');
/**
* Parse non-block at-rules
*/
function _compileAtrule(name: string) {
const re = new RegExp('^@' + name + '\\s*([^;]+);');
return () => {
const pos = position();
const m = match(re);
if (!m) {
return;
}
const ret: Record<string, string> = { type: name };
ret[name] = m[1].trim();
return pos(ret);
};
}
/**
* Parse at rule.
*/
function atrule() {
if (css[0] !== '@') {
return;
}
return (
atkeyframes() ||
atmedia() ||
atcustommedia() ||
atsupports() ||
atimport() ||
atcharset() ||
atnamespace() ||
atdocument() ||
atpage() ||
athost() ||
atfontface()
);
}
/**
* Parse rule.
*/
function rule() {
const pos = position();
const sel = selector();
if (!sel) {
return error('selector missing');
}
comments();
return pos({
type: 'rule',
selectors: sel,
declarations: declarations() as Declaration[],
});
}
return addParent(stylesheet());
}
/**
* Trim `str`.
*/
function trim(str: string) {
return str ? str.replace(/^\s+|\s+$/g, '') : '';
}
/**
* Adds non-enumerable parent node reference to each node.
*/
function addParent(obj: Stylesheet, parent?: Stylesheet) {
const isNode = obj && typeof obj.type === 'string';
const childParent = isNode ? obj : parent;
for (const k of Object.keys(obj)) {
const value = obj[k as keyof Stylesheet];
if (Array.isArray(value)) {
value.forEach((v) => {
addParent(v, childParent);
});
} else if (value && typeof value === 'object') {
addParent((value as unknown) as Stylesheet, childParent);
}
}
if (isNode) {
Object.defineProperty(obj, 'parent', {
configurable: true,
writable: true,
enumerable: false,
value: parent || null,
});
}
return obj;
}

View File

@@ -0,0 +1,24 @@
import snapshot, {
serializeNodeWithId,
transformAttribute,
visitSnapshot,
cleanupSnapshot,
needMaskingText,
IGNORED_NODE,
} from './snapshot';
import rebuild, { buildNodeWithSN, addHoverClass } from './rebuild';
export * from './types';
export * from './utils';
export {
snapshot,
serializeNodeWithId,
rebuild,
buildNodeWithSN,
addHoverClass,
transformAttribute,
visitSnapshot,
cleanupSnapshot,
needMaskingText,
IGNORED_NODE,
};

View File

@@ -0,0 +1,392 @@
import { parse } from './css';
import {
serializedNodeWithId,
NodeType,
tagMap,
elementNode,
idNodeMap,
INode,
} 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): string {
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',
);
return cssText.replace(selectorMatcher, (selector) => {
const newSelector = selector.replace(HOVER_SELECTOR_GLOBAL, '$1.\\:hover');
return `${selector}, ${newSelector}`;
});
}
function buildNode(
n: serializedNodeWithId,
options: {
doc: Document;
hackCss: boolean;
},
): Node | null {
const { doc, hackCss } = 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];
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);
}
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) : 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;
},
): INode | null {
const { doc, map, skipChild = false, hackCss = true, afterAppend } = options;
let node = buildNode(n, { doc, hackCss });
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();
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,
});
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;
},
): [Node | null, idNodeMap] {
const { doc, onVisit, hackCss = true, afterAppend } = options;
const idNodeMap: idNodeMap = {};
const node = buildNodeWithSN(n, {
doc,
map: idNodeMap,
skipChild: false,
hackCss,
afterAppend,
});
visit(idNodeMap, (visitedNode) => {
if (onVisit) {
onVisit(visitedNode);
}
handleScroll(visitedNode);
});
return [node, idNodeMap];
}
export default rebuild;

View File

@@ -0,0 +1,970 @@
import {
serializedNode,
serializedNodeWithId,
NodeType,
attributes,
INode,
idNodeMap,
MaskInputOptions,
SlimDOMOptions,
MaskTextFn,
MaskInputFn,
KeepIframeSrcFn,
} from './types';
import { isElement, isShadowRoot, maskInputValue } from './utils';
let _id = 1;
const tagNameRegex = RegExp('[^a-z0-9-_:]');
export const IGNORED_NODE = -2;
function genId(): number {
return _id++;
}
function getValidTagName(element: HTMLElement): string {
if (element instanceof HTMLFormElement) {
return 'form';
}
const processedTagName = element.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).map(getCssRuleString).join('') : 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:)([^,]*),(.*)/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 maybeQuote = quote1 || quote2 || '';
if (!filePath) {
return origin;
}
if (!RELATIVE_PATH.test(filePath)) {
return `url(${maybeQuote}${filePath}${maybeQuote})`;
}
if (DATA_URI.test(filePath)) {
return `url(${maybeQuote}${filePath}${maybeQuote})`;
}
if (filePath[0] === '/') {
return `url(${maybeQuote}${
extractOrigin(href) + filePath
}${maybeQuote})`;
}
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(${maybeQuote}${stack.join('/')}${maybeQuote})`;
},
);
}
const SRCSET_NOT_SPACES = /^[^ \t\n\r\u000c]+/; // Don't use \s, to avoid matching non-breaking space
const SRCSET_COMMAS_OR_SPACES = /^[, \t\n\r\u000c]+/;
function getAbsoluteSrcsetString(doc: Document, attributeValue: string) {
/*
run absoluteToDoc over every url in the srcset
this is adapted from https://github.com/albell/parse-srcset/
without the parsing of the descriptors (we return these as-is)
parce-srcset is in turn based on
https://html.spec.whatwg.org/multipage/embedded-content.html#parse-a-srcset-attribute
*/
if (attributeValue.trim() === '') {
return attributeValue;
}
let pos = 0;
function collectCharacters(regEx: RegExp) {
var chars,
match = regEx.exec(attributeValue.substring(pos));
if (match) {
chars = match[0];
pos += chars.length;
return chars;
}
return '';
}
let output = [];
while (true) {
collectCharacters(SRCSET_COMMAS_OR_SPACES);
if (pos >= attributeValue.length) {
break;
}
// don't split on commas within urls
let url = collectCharacters(SRCSET_NOT_SPACES);
if (url.slice(-1) === ',') {
// aside: according to spec more than one comma at the end is a parse error, but we ignore that
url = absoluteToDoc(doc, url.substring(0, url.length - 1));
// the trailing comma splits the srcset, so the interpretion is that
// another url will follow, and the descriptor is empty
output.push(url);
} else {
let descriptorsStr = '';
url = absoluteToDoc(doc, url);
let inParens = false;
while (true) {
let c = attributeValue.charAt(pos);
if (c === '') {
output.push((url + descriptorsStr).trim());
break;
} else if (!inParens) {
if (c === ',') {
pos += 1;
output.push((url + descriptorsStr).trim());
break; // parse the next url
} else if (c === '(') {
inParens = true;
}
} else {
// in parenthesis; ignore commas
// (parenthesis may be supported by future additions to spec)
if (c === ')') {
inParens = false;
}
}
descriptorsStr += c;
pos += 1;
}
}
}
return output.join(', ');
}
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;
}
function getHref() {
// return a href without hash
const a = document.createElement('a');
a.href = '';
return a.href;
}
export function transformAttribute(
doc: Document,
tagName: string,
name: string,
value: string,
): string {
// relative path in attribute
if (name === 'src' || ((name === 'href' || name === 'xlink:href') && value)) {
return absoluteToDoc(doc, value);
} else if (
name === 'background' &&
value &&
(tagName === 'table' || tagName === 'td' || tagName === 'th')
) {
return absoluteToDoc(doc, value);
} else if (name === 'srcset' && value) {
return getAbsoluteSrcsetString(doc, value);
} else if (name === 'style' && value) {
return absoluteToStylesheet(value, getHref());
} else {
return value;
}
}
export function _isBlockedElement(
element: HTMLElement,
blockClass: string | RegExp,
blockSelector: string | null,
): boolean {
if (typeof blockClass === 'string') {
if (element.classList.contains(blockClass)) {
return true;
}
} else {
// tslint:disable-next-line: prefer-for-of
for (let eIndex = 0; eIndex < element.classList.length; eIndex++) {
const className = element.classList[eIndex];
if (blockClass.test(className)) {
return true;
}
}
}
if (blockSelector) {
return element.matches(blockSelector);
}
return false;
}
export function needMaskingText(
node: Node | null,
maskTextClass: string | RegExp,
maskTextSelector: string | null,
): boolean {
if (!node) {
return false;
}
if (node.nodeType === node.ELEMENT_NODE) {
if (typeof maskTextClass === 'string') {
if ((node as HTMLElement).classList.contains(maskTextClass)) {
return true;
}
} else {
(node as HTMLElement).classList.forEach((className) => {
if (maskTextClass.test(className)) {
return true;
}
});
}
if (maskTextSelector) {
if ((node as HTMLElement).matches(maskTextSelector)) {
return true;
}
}
return needMaskingText(node.parentNode, maskTextClass, maskTextSelector);
}
if (node.nodeType === node.TEXT_NODE) {
// check parent node since text node do not have class name
return needMaskingText(node.parentNode, maskTextClass, maskTextSelector);
}
return needMaskingText(node.parentNode, maskTextClass, maskTextSelector);
}
// https://stackoverflow.com/a/36155560
function onceIframeLoaded(
iframeEl: HTMLIFrameElement,
listener: () => unknown,
iframeLoadTimeout: number,
) {
const win = iframeEl.contentWindow;
if (!win) {
return;
}
// document is loading
let fired = false;
let readyState: DocumentReadyState;
try {
readyState = win.document.readyState;
} catch (error) {
return;
}
if (readyState !== 'complete') {
const timer = setTimeout(() => {
if (!fired) {
listener();
fired = true;
}
}, iframeLoadTimeout);
iframeEl.addEventListener('load', () => {
clearTimeout(timer);
fired = true;
listener();
});
return;
}
// check blank frame for Chrome
const blankUrl = 'about:blank';
if (
win.location.href !== blankUrl ||
iframeEl.src === blankUrl ||
iframeEl.src === ''
) {
// iframe was already loaded, make sure we wait to trigger the listener
// till _after_ the mutation that found this iframe has had time to process
setTimeout(listener, 0);
return;
}
// use default listener
iframeEl.addEventListener('load', listener);
}
function serializeNode(
n: Node,
options: {
doc: Document;
blockClass: string | RegExp;
blockSelector: string | null;
maskTextClass: string | RegExp;
maskTextSelector: string | null;
inlineStylesheet: boolean;
maskInputOptions: MaskInputOptions;
maskTextFn: MaskTextFn | undefined;
maskInputFn: MaskInputFn | undefined;
recordCanvas: boolean;
keepIframeSrcFn: KeepIframeSrcFn;
},
): serializedNode | false {
const {
doc,
blockClass,
blockSelector,
maskTextClass,
maskTextSelector,
inlineStylesheet,
maskInputOptions = {},
maskTextFn,
maskInputFn,
recordCanvas,
keepIframeSrcFn,
} = options;
// Only record root id when document object is not the base document
let rootId: number | undefined;
if (((doc as unknown) as INode).__sn) {
const docId = ((doc as unknown) as INode).__sn.id;
rootId = docId === 1 ? undefined : docId;
}
switch (n.nodeType) {
case n.DOCUMENT_NODE:
return {
type: NodeType.Document,
childNodes: [],
rootId,
};
case n.DOCUMENT_TYPE_NODE:
return {
type: NodeType.DocumentType,
name: (n as DocumentType).name,
publicId: (n as DocumentType).publicId,
systemId: (n as DocumentType).systemId,
rootId,
};
case n.ELEMENT_NODE:
const needBlock = _isBlockedElement(
n as HTMLElement,
blockClass,
blockSelector,
);
const tagName = getValidTagName(n as HTMLElement);
let attributes: attributes = {};
for (const { name, value } of Array.from((n as HTMLElement).attributes)) {
attributes[name] = transformAttribute(doc, tagName, 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, getHref());
}
}
// 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 = maskInputValue({
type: attributes.type,
tagName,
value,
maskInputOptions,
maskInputFn,
});
} 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';
attributes.rr_mediaCurrentTime = (n as HTMLMediaElement).currentTime;
}
// 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;
}
// block element
if (needBlock) {
const { width, height } = (n as HTMLElement).getBoundingClientRect();
attributes = {
class: attributes.class,
rr_width: `${width}px`,
rr_height: `${height}px`,
};
}
// iframe
if (tagName === 'iframe' && !keepIframeSrcFn(attributes.src as string)) {
delete attributes.src;
}
return {
type: NodeType.Element,
tagName,
attributes,
childNodes: [],
isSVG: isSVGElement(n as Element) || undefined,
needBlock,
rootId,
};
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;
const isScript = parentTagName === 'SCRIPT' ? true : undefined;
if (isStyle && textContent) {
textContent = absoluteToStylesheet(textContent, getHref());
}
if (isScript) {
textContent = 'SCRIPT_PLACEHOLDER';
}
if (
!isStyle &&
!isScript &&
needMaskingText(n, maskTextClass, maskTextSelector) &&
textContent
) {
textContent = maskTextFn
? maskTextFn(textContent)
: textContent.replace(/[\S]/g, '*');
}
return {
type: NodeType.Text,
textContent: textContent || '',
isStyle,
rootId,
};
case n.CDATA_SECTION_NODE:
return {
type: NodeType.CDATA,
textContent: '',
rootId,
};
case n.COMMENT_NODE:
return {
type: NodeType.Comment,
textContent: (n as Comment).textContent || '',
rootId,
};
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 &&
// script tag
(sn.tagName === 'script' ||
// preload link
(sn.tagName === 'link' &&
sn.attributes.rel === 'preload' &&
sn.attributes.as === 'script') ||
// prefetch link
(sn.tagName === 'link' &&
sn.attributes.rel === 'prefetch' &&
typeof sn.attributes.href === 'string' &&
sn.attributes.href.endsWith('.js')))
) {
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,
options: {
doc: Document;
map: idNodeMap;
blockClass: string | RegExp;
blockSelector: string | null;
maskTextClass: string | RegExp;
maskTextSelector: string | null;
skipChild: boolean;
inlineStylesheet: boolean;
maskInputOptions?: MaskInputOptions;
maskTextFn: MaskTextFn | undefined;
maskInputFn: MaskInputFn | undefined;
slimDOMOptions: SlimDOMOptions;
keepIframeSrcFn?: KeepIframeSrcFn;
recordCanvas?: boolean;
preserveWhiteSpace?: boolean;
onSerialize?: (n: INode) => unknown;
onIframeLoad?: (iframeINode: INode, node: serializedNodeWithId) => unknown;
iframeLoadTimeout?: number;
},
): serializedNodeWithId | null {
const {
doc,
map,
blockClass,
blockSelector,
maskTextClass,
maskTextSelector,
skipChild = false,
inlineStylesheet = true,
maskInputOptions = {},
maskTextFn,
maskInputFn,
slimDOMOptions,
recordCanvas = false,
onSerialize,
onIframeLoad,
iframeLoadTimeout = 5000,
keepIframeSrcFn = () => false,
} = options;
let { preserveWhiteSpace = true } = options;
const _serializedNode = serializeNode(n, {
doc,
blockClass,
blockSelector,
maskTextClass,
maskTextSelector,
inlineStylesheet,
maskInputOptions,
maskTextFn,
maskInputFn,
recordCanvas,
keepIframeSrcFn,
});
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;
if (onSerialize) {
onSerialize(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;
}
const bypassOptions = {
doc,
map,
blockClass,
blockSelector,
maskTextClass,
maskTextSelector,
skipChild,
inlineStylesheet,
maskInputOptions,
maskTextFn,
maskInputFn,
slimDOMOptions,
recordCanvas,
preserveWhiteSpace,
onSerialize,
onIframeLoad,
iframeLoadTimeout,
keepIframeSrcFn,
};
for (const childN of Array.from(n.childNodes)) {
const serializedChildNode = serializeNodeWithId(childN, bypassOptions);
if (serializedChildNode) {
serializedNode.childNodes.push(serializedChildNode);
}
}
if (isElement(n) && n.shadowRoot) {
serializedNode.isShadowHost = true;
for (const childN of Array.from(n.shadowRoot.childNodes)) {
const serializedChildNode = serializeNodeWithId(childN, bypassOptions);
if (serializedChildNode) {
serializedChildNode.isShadow = true;
serializedNode.childNodes.push(serializedChildNode);
}
}
}
}
if (n.parentNode && isShadowRoot(n.parentNode)) {
serializedNode.isShadow = true;
}
if (
serializedNode.type === NodeType.Element &&
serializedNode.tagName === 'iframe'
) {
onceIframeLoaded(
n as HTMLIFrameElement,
() => {
const iframeDoc = (n as HTMLIFrameElement).contentDocument;
if (iframeDoc && onIframeLoad) {
const serializedIframeNode = serializeNodeWithId(iframeDoc, {
doc: iframeDoc,
map,
blockClass,
blockSelector,
maskTextClass,
maskTextSelector,
skipChild: false,
inlineStylesheet,
maskInputOptions,
maskTextFn,
maskInputFn,
slimDOMOptions,
recordCanvas,
preserveWhiteSpace,
onSerialize,
onIframeLoad,
iframeLoadTimeout,
keepIframeSrcFn,
});
if (serializedIframeNode) {
onIframeLoad(n as INode, serializedIframeNode);
}
}
},
iframeLoadTimeout,
);
}
return serializedNode;
}
function snapshot(
n: Document,
options?: {
blockClass?: string | RegExp;
blockSelector?: string | null;
maskTextClass?: string | RegExp;
maskTextSelector?: string | null;
inlineStylesheet?: boolean;
maskAllInputs?: boolean | MaskInputOptions;
maskTextFn?: MaskTextFn;
maskInputFn?: MaskTextFn;
slimDOM?: boolean | SlimDOMOptions;
recordCanvas?: boolean;
preserveWhiteSpace?: boolean;
onSerialize?: (n: INode) => unknown;
onIframeLoad?: (iframeINode: INode, node: serializedNodeWithId) => unknown;
iframeLoadTimeout?: number;
keepIframeSrcFn?: KeepIframeSrcFn;
},
): [serializedNodeWithId | null, idNodeMap] {
const {
blockClass = 'rr-block',
blockSelector = null,
maskTextClass = 'rr-mask',
maskTextSelector = null,
inlineStylesheet = true,
recordCanvas = false,
maskAllInputs = false,
maskTextFn,
maskInputFn,
slimDOM = false,
preserveWhiteSpace,
onSerialize,
onIframeLoad,
iframeLoadTimeout,
keepIframeSrcFn = () => false,
} = options || {};
const idNodeMap: idNodeMap = {};
const maskInputOptions: MaskInputOptions =
maskAllInputs === 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,
password: true,
}
: maskAllInputs === false
? {
password: true,
}
: maskAllInputs;
const slimDOMOptions: SlimDOMOptions =
slimDOM === true || slimDOM === 'all'
? // if true: set of sensible options that should not throw away any information
{
script: true,
comment: true,
headFavicon: true,
headWhitespace: true,
headMetaDescKeywords: slimDOM === 'all', // destructive
headMetaSocial: true,
headMetaRobots: true,
headMetaHttpEquiv: true,
headMetaAuthorship: true,
headMetaVerification: true,
}
: slimDOM === false
? {}
: slimDOM;
return [
serializeNodeWithId(n, {
doc: n,
map: idNodeMap,
blockClass,
blockSelector,
maskTextClass,
maskTextSelector,
skipChild: false,
inlineStylesheet,
maskInputOptions,
maskTextFn,
maskInputFn,
slimDOMOptions,
recordCanvas,
preserveWhiteSpace,
onSerialize,
onIframeLoad,
iframeLoadTimeout,
keepIframeSrcFn,
}),
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 function cleanupSnapshot() {
// allow a new recording to start numbering nodes from scratch
_id = 1;
}
export default snapshot;

View File

@@ -0,0 +1,113 @@
export enum NodeType {
Document,
DocumentType,
Element,
Text,
CDATA,
Comment,
}
export type documentNode = {
type: NodeType.Document;
childNodes: serializedNodeWithId[];
};
export type documentTypeNode = {
type: NodeType.DocumentType;
name: string;
publicId: string;
systemId: string;
};
export type attributes = {
[key: string]: string | number | boolean;
};
export type elementNode = {
type: NodeType.Element;
tagName: string;
attributes: attributes;
childNodes: serializedNodeWithId[];
isSVG?: true;
needBlock?: boolean;
};
export type textNode = {
type: NodeType.Text;
textContent: string;
isStyle?: true;
};
export type cdataNode = {
type: NodeType.CDATA;
textContent: '';
};
export type commentNode = {
type: NodeType.Comment;
textContent: string;
};
export type serializedNode = (
| documentNode
| documentTypeNode
| elementNode
| textNode
| cdataNode
| commentNode
) & {
rootId?: number;
isShadowHost?: boolean;
isShadow?: boolean;
};
export type serializedNodeWithId = serializedNode & { id: number };
export type tagMap = {
[key: string]: string;
};
export interface INode extends Node {
__sn: serializedNodeWithId;
}
export type idNodeMap = {
[key: number]: INode;
};
export type MaskInputOptions = Partial<{
color: boolean;
date: boolean;
'datetime-local': boolean;
email: boolean;
month: boolean;
number: boolean;
range: boolean;
search: boolean;
tel: boolean;
text: boolean;
time: boolean;
url: boolean;
week: boolean;
// unify textarea and select element with text input
textarea: boolean;
select: boolean;
password: boolean;
}>;
export type SlimDOMOptions = Partial<{
script: boolean;
comment: boolean;
headFavicon: boolean;
headWhitespace: boolean;
headMetaDescKeywords: boolean;
headMetaSocial: boolean;
headMetaRobots: boolean;
headMetaHttpEquiv: boolean;
headMetaAuthorship: boolean;
headMetaVerification: boolean;
}>;
export type MaskTextFn = (text: string) => string;
export type MaskInputFn = (text: string) => string;
export type KeepIframeSrcFn = (src: string) => boolean;

View File

@@ -0,0 +1,37 @@
import { INode, MaskInputFn, MaskInputOptions } from './types';
export function isElement(n: Node | INode): n is Element {
return n.nodeType === n.ELEMENT_NODE;
}
export function isShadowRoot(n: Node): n is ShadowRoot {
const host: Element | null = (n as ShadowRoot)?.host;
return Boolean(host && host.shadowRoot && host.shadowRoot === n);
}
export function maskInputValue({
maskInputOptions,
tagName,
type,
value,
maskInputFn,
}: {
maskInputOptions: MaskInputOptions;
tagName: string;
type: string | number | boolean | null;
value: string | null;
maskInputFn?: MaskInputFn;
}): string {
let text = value || '';
if (
maskInputOptions[tagName.toLowerCase() as keyof MaskInputOptions] ||
maskInputOptions[type as keyof MaskInputOptions]
) {
if (maskInputFn) {
text = maskInputFn(text);
} else {
text = '*'.repeat(text.length);
}
}
return text;
}

21
packages/rrweb-snapshot/test.d.ts vendored Normal file
View File

@@ -0,0 +1,21 @@
declare module 'rollup-plugin-typescript' {
function typescript(): any;
export = typescript;
}
declare module 'jest-snapshot' {
export class SnapshotState {
constructor(testFile: string, options: any);
save(): any;
}
type matchResult = {
pass: boolean;
report(): string;
};
export function toMatchSnapshot(
received: any,
propertyMatchers?: any,
testName?: string,
): matchResult;
}

View File

@@ -0,0 +1,838 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`[html file]: about-mozilla.html 1`] = `
"<!DOCTYPE html><html xmlns=\\"http://www.w3.org/1999/xhtml\\"><head>
<title>The Book of Mozilla, 11:9</title>
<style type=\\"text/css\\">
html {
background: maroon;
color: white;
font-style: italic;
} #moztext {
margin-top: 15%;
font-size: 1.1em;
font-family: serif;
text-align: center;
line-height: 1.5;
} #from {
font-size: 1.95em;
font-family: serif;
text-align: right;
} em {
font-size: 1.3em;
line-height: 0;
} a {
text-decoration: none;
color: white;
}
</style>
</head><body> <p id=\\"moztext\\">
Mammon slept. And the <em>beast reborn</em> spread over the earth and its numbers
grew legion. And they proclaimed the times and <em>sacrificed</em> crops unto the
fire, with the <em>cunning of foxes</em>. And they built a new world in their own
image as promised by the <em><a href=\\"http://www.mozilla.org/about/mozilla-manifesto.html\\">
sacred words</a></em>, and <em><a href=\\"http://wiki.mozilla.org/About:mozilla\\">spoke
</a></em> of the beast with their children. Mammon awoke, and lo! it was
<em>naught</em> but a follower.
</p> <p id=\\"from\\">
from <strong>The Book of Mozilla,</strong> 11:9<br /><small>(10th Edition)</small>
</p></body></html>"
`;
exports[`[html file]: basic.html 1`] = `
"<!DOCTYPE html><html xmlns=\\"http://www.w3.org/1999/xhtml\\" lang=\\"en\\"><head>
<meta charset=\\"UTF-8\\" />
<meta name=\\"viewport\\" content=\\"width=device-width, initial-scale=1.0\\" />
<meta http-equiv=\\"X-UA-Compatible\\" content=\\"ie=edge\\" />
<title>Document</title>
</head><body>
<h1>Title</h1></body></html>"
`;
exports[`[html file]: block-element.html 1`] = `
"<!DOCTYPE html><html xmlns=\\"http://www.w3.org/1999/xhtml\\" lang=\\"en\\"><head>
<meta charset=\\"UTF-8\\" />
<meta name=\\"viewport\\" content=\\"width=device-width, initial-scale=1.0\\" />
<meta http-equiv=\\"X-UA-Compatible\\" content=\\"ie=edge\\" />
<title>Document</title>
<style>
.big {
width: 50px;
height: 50px;
}
.small {
width: 50px;
height: 100px;
float: left;
}
</style>
</head> <body>
<div class=\\"rr-block big\\" style=\\"width: 50px; height: 50px;\\"></div>
<div>record 2</div>
<div class=\\"rr-block small\\" style=\\"width: 50px; height: 100px;\\"></div>
<div class=\\"rr-block\\" style=\\"width: 100px; height: 200px;\\"></div>
</body></html>"
`;
exports[`[html file]: cors-style-sheet.html 1`] = `
"<!DOCTYPE html><html xmlns=\\"http://www.w3.org/1999/xhtml\\" lang=\\"en\\"><head>
<meta charset=\\"UTF-8\\" />
<meta name=\\"viewport\\" content=\\"width=device-width, initial-scale=1.0\\" />
<meta http-equiv=\\"X-UA-Compatible\\" content=\\"ie=edge\\" />
<title>with style sheet</title>
<link rel=\\"stylesheet\\" href=\\"https://cdn.jsdelivr.net/npm/pure@2.85.0/index.css\\" />
<link rel=\\"stylesheet\\" href=\\"\\" />
</head>
<body></body></html>"
`;
exports[`[html file]: dynamic-stylesheet.html 1`] = `
"<!DOCTYPE html><html xmlns=\\"http://www.w3.org/1999/xhtml\\" lang=\\"en\\"><head>
<meta charset=\\"UTF-8\\" />
<meta name=\\"viewport\\" content=\\"width=device-width, initial-scale=1.0\\" />
<meta http-equiv=\\"X-UA-Compatible\\" content=\\"ie=edge\\" />
<title>dynamic stylesheet</title>
<style>body { margin: 0px; }p { background: lightpink; }</style>
<noscript>SCRIPT_PLACEHOLDER</noscript>
</head>
<body>
<p>p tag</p>
</body></html>"
`;
exports[`[html file]: form-fields.html 1`] = `
"<!DOCTYPE html><html xmlns=\\"http://www.w3.org/1999/xhtml\\" lang=\\"en\\"><head>
<meta charset=\\"UTF-8\\" />
<meta name=\\"viewport\\" content=\\"width=device-width, initial-scale=1.0\\" />
<meta http-equiv=\\"X-UA-Compatible\\" content=\\"ie=edge\\" />
<title>form fields</title>
</head> <body>
<form>
<label for=\\"text\\">
<input type=\\"text\\" value=\\"1\\" />
</label>
<label for=\\"radio\\">
<input type=\\"radio\\" checked=\\"\\" />
</label>
<label for=\\"checkbox\\">
<input type=\\"checkbox\\" checked=\\"\\" />
</label>
<label for=\\"textarea\\">
<textarea name=\\"\\" id=\\"\\" cols=\\"30\\" rows=\\"10\\">1234</textarea>
</label>
<label for=\\"select\\">
<select name=\\"\\" id=\\"\\" value=\\"2\\">
<option value=\\"1\\">1</option>
<option value=\\"2\\" selected=\\"\\">2</option>
</select>
</label>
<label>
<input name=\\"tagName\\" />
</label>
</form>
<noscript>SCRIPT_PLACEHOLDER</noscript></body></html>"
`;
exports[`[html file]: hover.html 1`] = `
"<!DOCTYPE html><html xmlns=\\"http://www.w3.org/1999/xhtml\\" lang=\\"en\\"><head>
<meta charset=\\"UTF-8\\" />
<meta name=\\"viewport\\" content=\\"width=device-width, initial-scale=1.0\\" />
<meta http-equiv=\\"X-UA-Compatible\\" content=\\"ie=edge\\" />
<title>hover selector</title>
<style> div:hover, div.\\\\:hover {
background: orange;
} div:hover::after, div.\\\\:hover::after {
position: absolute;
left: 0;
top: 100%;
content: 'dropdown';
width: 100px;
height: 200px;
background: lightblue;
}
</style>
</head><body>
<div>hover me</div>
</body></html>"
`;
exports[`[html file]: iframe.html 1`] = `
"<!DOCTYPE html><html xmlns=\\"http://www.w3.org/1999/xhtml\\" lang=\\"en\\"><head>
<meta charset=\\"UTF-8\\" />
<meta name=\\"viewport\\" content=\\"width=device-width, initial-scale=1.0\\" />
<meta http-equiv=\\"X-UA-Compatible\\" content=\\"ie=edge\\" />
<title>iframe</title>
</head>
<body>
<iframe width=\\"100\\" height=\\"50\\"></iframe>
</body></html>"
`;
exports[`[html file]: iframe-inner.html 1`] = `
"<html xmlns=\\"http://www.w3.org/1999/xhtml\\"><head></head><body><button>inner iframe button</button>
</body></html>"
`;
exports[`[html file]: invalid-attribute.html 1`] = `
"<html xmlns=\\"http://www.w3.org/1999/xhtml\\" foo=\\"bar\\"><head></head><body>
</body></html>"
`;
exports[`[html file]: invalid-doctype.html 1`] = `
"<!DOCTYPE html><html xmlns=\\"http://www.w3.org/1999/xhtml\\" lang=\\"en\\"><head>
<meta charset=\\"UTF-8\\" />
<meta name=\\"viewport\\" content=\\"width=device-width, initial-scale=1.0\\" />
<title>Invalid Doctype</title>
</head>
<body></body></html>"
`;
exports[`[html file]: invalid-tagname.html 1`] = `
"<!DOCTYPE html><html xmlns=\\"http://www.w3.org/1999/xhtml\\" lang=\\"en\\"><head>
<meta charset=\\"UTF-8\\" />
<meta name=\\"viewport\\" content=\\"width=device-width, initial-scale=1.0\\" />
<meta http-equiv=\\"X-UA-Compatible\\" content=\\"ie=edge\\" />
<title>Document</title>
</head>
<body>
<div>Hello</div>
<div>Hello</div>
<div></div>
</body></html>"
`;
exports[`[html file]: mask-text.html 1`] = `
"<!DOCTYPE html><html xmlns=\\"http://www.w3.org/1999/xhtml\\" lang=\\"en\\"><head>
<meta charset=\\"UTF-8\\" />
<meta name=\\"viewport\\" content=\\"width=device-width, initial-scale=1.0\\" />
<meta http-equiv=\\"X-UA-Compatible\\" content=\\"ie=edge\\" />
<title>Document</title>
</head> <body>
<p class=\\"rr-mask\\">**** *</p>
<div class=\\"rr-mask\\">
<span>**** *</span>
</div>
<div class=\\"rr-mask\\">**** *</div>
</body></html>"
`;
exports[`[html file]: picture.html 1`] = `
"<html xmlns=\\"http://www.w3.org/1999/xhtml\\"><head></head><body>
<picture>
<source type=\\"image/webp\\" srcset=\\"http://localhost:3030/assets/img/characters/robot.webp\\" />
<img src=\\"http://localhost:3030/assets/img/characters/robot.png\\" />
</picture>
</body></html>"
`;
exports[`[html file]: preload.html 1`] = `
"<!DOCTYPE html><html xmlns=\\"http://www.w3.org/1999/xhtml\\" lang=\\"en\\"><head>
<meta charset=\\"UTF-8\\" />
<meta name=\\"viewport\\" content=\\"width=device-width, initial-scale=1.0\\" />
<title>Document</title>
<link />
<link />
</head>
<body></body></html>"
`;
exports[`[html file]: shadow-dom.html 1`] = `
"<!DOCTYPE html><html xmlns=\\"http://www.w3.org/1999/xhtml\\" lang=\\"en\\"><head>
<meta charset=\\"UTF-8\\" />
<meta name=\\"viewport\\" content=\\"width=device-width, initial-scale=1.0\\" />
<title>shadow DOM</title>
</head>
<body>
<fancy-tabs background=\\"\\" role=\\"tablist\\" selected=\\"1\\">
<button slot=\\"title\\" role=\\"tab\\" tabindex=\\"-1\\" aria-selected=\\"false\\">Tab 1</button>
<button slot=\\"title\\" selected=\\"\\" role=\\"tab\\" tabindex=\\"0\\" aria-selected=\\"true\\">Tab 2</button>
<button slot=\\"title\\" role=\\"tab\\" tabindex=\\"-1\\" aria-selected=\\"false\\">Tab 3</button>
<section role=\\"tabpanel\\" tabindex=\\"0\\" aria-hidden=\\"true\\">content panel 1</section>
<section role=\\"tabpanel\\" tabindex=\\"0\\" aria-hidden=\\"false\\">content panel 2</section>
<section role=\\"tabpanel\\" tabindex=\\"0\\" aria-hidden=\\"true\\">content panel 3</section>
</fancy-tabs>
<noscript>SCRIPT_PLACEHOLDER</noscript>
</body></html>"
`;
exports[`[html file]: video.html 1`] = `
"<!DOCTYPE html><html xmlns=\\"http://www.w3.org/1999/xhtml\\" lang=\\"en\\"><head>
<meta charset=\\"UTF-8\\" />
<meta name=\\"viewport\\" content=\\"width=device-width, initial-scale=1.0\\" />
<meta http-equiv=\\"X-UA-Compatible\\" content=\\"ie=edge\\" />
<title>video</title>
</head>
<body>
<video controls=\\"\\">
<source src=\\"http://techslides.com/demos/sample-videos/small.webm\\" type=\\"video/webm\\" /> <source src=\\"http://techslides.com/demos/sample-videos/small.ogv\\" type=\\"video/ogg\\" />
<source src=\\"http://techslides.com/demos/sample-videos/small.mp4\\" type=\\"video/mp4\\" /> <source src=\\"http://techslides.com/demos/sample-videos/small.3gp\\" type=\\"video/3gp\\" />
</video>
</body></html>"
`;
exports[`[html file]: with-relative-res.html 1`] = `
"<!DOCTYPE html><html xmlns=\\"http://www.w3.org/1999/xhtml\\" lang=\\"en\\"><head>
<meta charset=\\"UTF-8\\" />
<meta name=\\"viewport\\" content=\\"width=device-width, initial-scale=1.0\\" />
<meta http-equiv=\\"X-UA-Compatible\\" content=\\"ie=edge\\" />
<title>Document</title>
</head>
<body>
<a href=\\"http://localhost:3030/basic.html\\"></a>
<div>Hello</div>
<alt34>Hello</alt34>
<div>Hello</div>
<div></div>
<img src=\\"http://localhost:3030/a.jpg\\" alt=\\"\\" srcset=\\"\\" />
<img src=\\"http://localhost:3030/a.jpg\\" alt=\\"\\" srcset=\\"http://localhost:3030/a.jpg\\" />
<img src=\\"http://localhost:3030/a.jpg\\" alt=\\"\\" srcset=\\"http://exmple.com/a.jpg\\" />
<img src=\\"http://localhost:3030/a.jpg\\" alt=\\"\\" srcset=\\"http://localhost:3030/a.jpg 3x, http://localhost:3030/a.jpg 45x, http://localhost:3030/b.png\\" />
<img src=\\"http://localhost:3030/a.jpg\\" alt=\\"\\" srcset=\\"http://localhost:3030/300,400/a.jpg 300w, http://localhost:3030/b.png\\" /></body></html>"
`;
exports[`[html file]: with-script.html 1`] = `
"<!DOCTYPE html><html xmlns=\\"http://www.w3.org/1999/xhtml\\" lang=\\"en\\"><head>
<meta charset=\\"UTF-8\\" />
<meta name=\\"viewport\\" content=\\"width=device-width, initial-scale=1.0\\" />
<meta http-equiv=\\"X-UA-Compatible\\" content=\\"ie=edge\\" />
<title>with script</title>
</head><body>
<noscript src=\\"http://localhost:3030/js/a.js\\"></noscript>
<noscript>SCRIPT_PLACEHOLDER</noscript></body></html>"
`;
exports[`[html file]: with-style-sheet.html 1`] = `
"<!DOCTYPE html><html xmlns=\\"http://www.w3.org/1999/xhtml\\" lang=\\"en\\"><head>
<meta charset=\\"UTF-8\\" />
<meta name=\\"viewport\\" content=\\"width=device-width, initial-scale=1.0\\" />
<meta http-equiv=\\"X-UA-Compatible\\" content=\\"ie=edge\\" />
<title>with style sheet</title>
<style>body { margin: 0px; background: url(\\"http://localhost:3030/a.jpg\\"); border-image: url(\\"data:image/svg+xml;utf8,&lt;svg xmlns=\\\\\\"http://www.w3.org/2000/svg\\\\\\" x=\\\\\\"0px\\\\\\" y=\\\\\\"0px\\\\\\" viewBox=\\\\\\"0 0 256 256\\\\\\"&gt;&lt;g&gt;&lt;g&gt;&lt;polygon points=\\\\\\"79.093,0 48.907,30.187 146.72,128 48.907,225.813 79.093,256 207.093,128\\\\\\"/&gt;&lt;/g&gt;&lt;/g&gt;&lt;/svg&gt;\\") 100% / 1 / 0 stretch; }p { color: red; background: url(\\"http://localhost:3030/css/b.jpg\\"); }body &gt; p { color: yellow; }</style>
</head><body>
</body></html>"
`;
exports[`[html file]: with-style-sheet-with-import.html 1`] = `
"<!DOCTYPE html><html xmlns=\\"http://www.w3.org/1999/xhtml\\" lang=\\"en\\"><head>
<meta charset=\\"UTF-8\\" />
<meta name=\\"viewport\\" content=\\"width=device-width, initial-scale=1.0\\" />
<meta http-equiv=\\"X-UA-Compatible\\" content=\\"ie=edge\\" />
<title>with style sheet with import</title>
<style>body { margin: 0px; background: url(\\"http://localhost:3030/a.jpg\\"); border-image: url(\\"data:image/svg+xml;utf8,&lt;svg xmlns=\\\\\\"http://www.w3.org/2000/svg\\\\\\" x=\\\\\\"0px\\\\\\" y=\\\\\\"0px\\\\\\" viewBox=\\\\\\"0 0 256 256\\\\\\"&gt;&lt;g&gt;&lt;g&gt;&lt;polygon points=\\\\\\"79.093,0 48.907,30.187 146.72,128 48.907,225.813 79.093,256 207.093,128\\\\\\"/&gt;&lt;/g&gt;&lt;/g&gt;&lt;/svg&gt;\\") 100% / 1 / 0 stretch; }p { color: red; background: url(\\"http://localhost:3030/css/b.jpg\\"); }body &gt; p { color: yellow; }</style>
</head><body>
</body></html>"
`;
exports[`iframe integration tests 1`] = `
"{
\\"type\\": 0,
\\"childNodes\\": [
{
\\"type\\": 1,
\\"name\\": \\"html\\",
\\"publicId\\": \\"\\",
\\"systemId\\": \\"\\",
\\"id\\": 2
},
{
\\"type\\": 2,
\\"tagName\\": \\"html\\",
\\"attributes\\": {
\\"lang\\": \\"en\\"
},
\\"childNodes\\": [
{
\\"type\\": 2,
\\"tagName\\": \\"head\\",
\\"attributes\\": {},
\\"childNodes\\": [
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\",
\\"id\\": 5
},
{
\\"type\\": 2,
\\"tagName\\": \\"meta\\",
\\"attributes\\": {
\\"charset\\": \\"UTF-8\\"
},
\\"childNodes\\": [],
\\"id\\": 6
},
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\",
\\"id\\": 7
},
{
\\"type\\": 2,
\\"tagName\\": \\"meta\\",
\\"attributes\\": {
\\"name\\": \\"viewport\\",
\\"content\\": \\"width=device-width, initial-scale=1.0\\"
},
\\"childNodes\\": [],
\\"id\\": 8
},
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\",
\\"id\\": 9
},
{
\\"type\\": 2,
\\"tagName\\": \\"title\\",
\\"attributes\\": {},
\\"childNodes\\": [
{
\\"type\\": 3,
\\"textContent\\": \\"Main\\",
\\"id\\": 11
}
],
\\"id\\": 10
},
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\",
\\"id\\": 12
}
],
\\"id\\": 4
},
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\",
\\"id\\": 13
},
{
\\"type\\": 2,
\\"tagName\\": \\"body\\",
\\"attributes\\": {},
\\"childNodes\\": [
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\",
\\"id\\": 15
},
{
\\"type\\": 2,
\\"tagName\\": \\"iframe\\",
\\"attributes\\": {
\\"id\\": \\"one\\"
},
\\"childNodes\\": [],
\\"id\\": 16
},
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\",
\\"id\\": 17
},
{
\\"type\\": 2,
\\"tagName\\": \\"iframe\\",
\\"attributes\\": {
\\"id\\": \\"two\\"
},
\\"childNodes\\": [],
\\"id\\": 18
},
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\\\n\\\\n\\",
\\"id\\": 19
}
],
\\"id\\": 14
}
],
\\"id\\": 3
}
],
\\"id\\": 1
}"
`;
exports[`shadown DOM integration tests 1`] = `
"{
\\"type\\": 0,
\\"childNodes\\": [
{
\\"type\\": 1,
\\"name\\": \\"html\\",
\\"publicId\\": \\"\\",
\\"systemId\\": \\"\\",
\\"id\\": 2
},
{
\\"type\\": 2,
\\"tagName\\": \\"html\\",
\\"attributes\\": {
\\"lang\\": \\"en\\"
},
\\"childNodes\\": [
{
\\"type\\": 2,
\\"tagName\\": \\"head\\",
\\"attributes\\": {},
\\"childNodes\\": [
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\",
\\"id\\": 5
},
{
\\"type\\": 2,
\\"tagName\\": \\"meta\\",
\\"attributes\\": {
\\"charset\\": \\"UTF-8\\"
},
\\"childNodes\\": [],
\\"id\\": 6
},
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\",
\\"id\\": 7
},
{
\\"type\\": 2,
\\"tagName\\": \\"meta\\",
\\"attributes\\": {
\\"name\\": \\"viewport\\",
\\"content\\": \\"width=device-width, initial-scale=1.0\\"
},
\\"childNodes\\": [],
\\"id\\": 8
},
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\",
\\"id\\": 9
},
{
\\"type\\": 2,
\\"tagName\\": \\"title\\",
\\"attributes\\": {},
\\"childNodes\\": [
{
\\"type\\": 3,
\\"textContent\\": \\"shadow DOM\\",
\\"id\\": 11
}
],
\\"id\\": 10
},
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\",
\\"id\\": 12
}
],
\\"id\\": 4
},
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\",
\\"id\\": 13
},
{
\\"type\\": 2,
\\"tagName\\": \\"body\\",
\\"attributes\\": {},
\\"childNodes\\": [
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\",
\\"id\\": 15
},
{
\\"type\\": 2,
\\"tagName\\": \\"fancy-tabs\\",
\\"attributes\\": {
\\"background\\": \\"\\",
\\"role\\": \\"tablist\\",
\\"selected\\": \\"1\\"
},
\\"childNodes\\": [
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\",
\\"id\\": 17
},
{
\\"type\\": 2,
\\"tagName\\": \\"button\\",
\\"attributes\\": {
\\"slot\\": \\"title\\",
\\"role\\": \\"tab\\",
\\"tabindex\\": \\"-1\\",
\\"aria-selected\\": \\"false\\"
},
\\"childNodes\\": [
{
\\"type\\": 3,
\\"textContent\\": \\"Tab 1\\",
\\"id\\": 19
}
],
\\"id\\": 18
},
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\",
\\"id\\": 20
},
{
\\"type\\": 2,
\\"tagName\\": \\"button\\",
\\"attributes\\": {
\\"slot\\": \\"title\\",
\\"selected\\": \\"\\",
\\"role\\": \\"tab\\",
\\"tabindex\\": \\"0\\",
\\"aria-selected\\": \\"true\\"
},
\\"childNodes\\": [
{
\\"type\\": 3,
\\"textContent\\": \\"Tab 2\\",
\\"id\\": 22
}
],
\\"id\\": 21
},
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\",
\\"id\\": 23
},
{
\\"type\\": 2,
\\"tagName\\": \\"button\\",
\\"attributes\\": {
\\"slot\\": \\"title\\",
\\"role\\": \\"tab\\",
\\"tabindex\\": \\"-1\\",
\\"aria-selected\\": \\"false\\"
},
\\"childNodes\\": [
{
\\"type\\": 3,
\\"textContent\\": \\"Tab 3\\",
\\"id\\": 25
}
],
\\"id\\": 24
},
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\",
\\"id\\": 26
},
{
\\"type\\": 2,
\\"tagName\\": \\"section\\",
\\"attributes\\": {
\\"role\\": \\"tabpanel\\",
\\"tabindex\\": \\"0\\",
\\"aria-hidden\\": \\"true\\"
},
\\"childNodes\\": [
{
\\"type\\": 3,
\\"textContent\\": \\"content panel 1\\",
\\"id\\": 28
}
],
\\"id\\": 27
},
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\",
\\"id\\": 29
},
{
\\"type\\": 2,
\\"tagName\\": \\"section\\",
\\"attributes\\": {
\\"role\\": \\"tabpanel\\",
\\"tabindex\\": \\"0\\",
\\"aria-hidden\\": \\"false\\"
},
\\"childNodes\\": [
{
\\"type\\": 3,
\\"textContent\\": \\"content panel 2\\",
\\"id\\": 31
}
],
\\"id\\": 30
},
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\",
\\"id\\": 32
},
{
\\"type\\": 2,
\\"tagName\\": \\"section\\",
\\"attributes\\": {
\\"role\\": \\"tabpanel\\",
\\"tabindex\\": \\"0\\",
\\"aria-hidden\\": \\"true\\"
},
\\"childNodes\\": [
{
\\"type\\": 3,
\\"textContent\\": \\"content panel 3\\",
\\"id\\": 34
}
],
\\"id\\": 33
},
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\",
\\"id\\": 35
},
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\",
\\"id\\": 36,
\\"isShadow\\": true
},
{
\\"type\\": 2,
\\"tagName\\": \\"style\\",
\\"attributes\\": {},
\\"childNodes\\": [
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n :host {\\\\n display: inline-block;\\\\n width: 650px;\\\\n font-family: 'Roboto Slab';\\\\n contain: content;\\\\n }\\\\n :host([background]) {\\\\n background: var(--background-color, #9E9E9E);\\\\n border-radius: 10px;\\\\n padding: 10px;\\\\n }\\\\n #panels {\\\\n box-shadow: 0 2px 2px rgba(0, 0, 0, .3);\\\\n background: white;\\\\n border-radius: 3px;\\\\n padding: 16px;\\\\n height: 250px;\\\\n overflow: auto;\\\\n }\\\\n #tabs {\\\\n display: inline-flex;\\\\n -webkit-user-select: none;\\\\n user-select: none;\\\\n }\\\\n #tabs slot {\\\\n display: inline-flex; /* Safari bug. Treats <slot> as a parent */\\\\n }\\\\n /* Safari does not support #id prefixes on ::slotted\\\\n See https://bugs.webkit.org/show_bug.cgi?id=160538 */\\\\n #tabs ::slotted(*) {\\\\n font: 400 16px/22px 'Roboto';\\\\n padding: 16px 8px;\\\\n margin: 0;\\\\n text-align: center;\\\\n width: 100px;\\\\n text-overflow: ellipsis;\\\\n white-space: nowrap;\\\\n overflow: hidden;\\\\n cursor: pointer;\\\\n border-top-left-radius: 3px;\\\\n border-top-right-radius: 3px;\\\\n background: linear-gradient(#fafafa, #eee);\\\\n border: none; /* if the user users a <button> */\\\\n }\\\\n #tabs ::slotted([aria-selected=\\\\\\"true\\\\\\"]) {\\\\n font-weight: 600;\\\\n background: white;\\\\n box-shadow: none;\\\\n }\\\\n #tabs ::slotted(:focus) {\\\\n z-index: 1; /* make sure focus ring doesn't get buried */\\\\n }\\\\n #panels ::slotted([aria-hidden=\\\\\\"true\\\\\\"]) {\\\\n display: none;\\\\n }\\\\n \\",
\\"isStyle\\": true,
\\"id\\": 38
}
],
\\"id\\": 37,
\\"isShadow\\": true
},
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\",
\\"id\\": 39,
\\"isShadow\\": true
},
{
\\"type\\": 2,
\\"tagName\\": \\"div\\",
\\"attributes\\": {
\\"id\\": \\"tabs\\"
},
\\"childNodes\\": [
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\",
\\"id\\": 41
},
{
\\"type\\": 2,
\\"tagName\\": \\"slot\\",
\\"attributes\\": {
\\"id\\": \\"tabsSlot\\",
\\"name\\": \\"title\\"
},
\\"childNodes\\": [],
\\"id\\": 42
},
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\",
\\"id\\": 43
}
],
\\"id\\": 40,
\\"isShadow\\": true
},
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\",
\\"id\\": 44,
\\"isShadow\\": true
},
{
\\"type\\": 2,
\\"tagName\\": \\"div\\",
\\"attributes\\": {
\\"id\\": \\"panels\\"
},
\\"childNodes\\": [
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\",
\\"id\\": 46
},
{
\\"type\\": 2,
\\"tagName\\": \\"slot\\",
\\"attributes\\": {
\\"id\\": \\"panelsSlot\\"
},
\\"childNodes\\": [],
\\"id\\": 47
},
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\",
\\"id\\": 48
}
],
\\"id\\": 45,
\\"isShadow\\": true
},
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\",
\\"id\\": 49,
\\"isShadow\\": true
}
],
\\"id\\": 16,
\\"isShadowHost\\": true
},
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\",
\\"id\\": 50
},
{
\\"type\\": 2,
\\"tagName\\": \\"script\\",
\\"attributes\\": {},
\\"childNodes\\": [
{
\\"type\\": 3,
\\"textContent\\": \\"SCRIPT_PLACEHOLDER\\",
\\"id\\": 52
}
],
\\"id\\": 51
},
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\\\n\\\\n\\",
\\"id\\": 53
}
],
\\"id\\": 14
}
],
\\"id\\": 3
}
],
\\"id\\": 1
}"
`;

View File

@@ -0,0 +1,111 @@
import 'mocha';
import { expect } from 'chai';
import { parse, Rule, Media } from '../src/css';
describe('css parser', () => {
it('should save the filename and source', () => {
const css = 'booty {\n size: large;\n}\n';
const ast = parse(css, {
source: 'booty.css',
});
expect(ast.stylesheet!.source).to.equal('booty.css');
const position = ast.stylesheet!.rules[0].position!;
expect(position.start).to.be.ok;
expect(position.end).to.be.ok;
expect(position.source).to.equal('booty.css');
expect(position.content).to.equal(css);
});
it('should throw when a selector is missing', () => {
expect(() => {
parse('{size: large}');
}).to.throw();
expect(() => {
parse('b { color: red; }\n{ color: green; }\na { color: blue; }');
}).to.throw();
});
it('should throw when a broken comment is found', () => {
expect(() => {
parse('thing { color: red; } /* b { color: blue; }');
}).to.throw();
expect(() => {
parse('/*');
}).to.throw();
/* Nested comments should be fine */
expect(() => {
parse('/* /* */');
}).to.not.throw();
});
it('should allow empty property value', () => {
expect(() => {
parse('p { color:; }');
}).to.not.throw();
});
it('should not throw with silent option', () => {
expect(() => {
parse('thing { color: red; } /* b { color: blue; }', { silent: true });
}).to.not.throw();
});
it('should list the parsing errors and continue parsing', () => {
const result = parse(
'foo { color= red; } bar { color: blue; } baz {}} boo { display: none}',
{
silent: true,
source: 'foo.css',
},
);
const rules = result.stylesheet!.rules;
expect(rules.length).to.above(2);
const errors = result.stylesheet!.parsingErrors!;
expect(errors.length).to.equal(2);
expect(errors[0]).to.have.property('message');
expect(errors[0]).to.have.property('reason');
expect(errors[0]).to.have.property('filename');
expect(errors[0]).to.have.property('line');
expect(errors[0]).to.have.property('column');
expect(errors[0]).to.have.property('source');
expect(errors[0].filename).to.equal('foo.css');
});
it('should set parent property', () => {
const result = parse(
'thing { test: value; }\n' +
'@media (min-width: 100px) { thing { test: value; } }',
);
expect(result.parent).to.equal(null);
const rules = result.stylesheet!.rules;
expect(rules.length).to.equal(2);
let rule = rules[0] as Rule;
expect(rule.parent).to.equal(result);
expect(rule.declarations!.length).to.equal(1);
let decl = rule.declarations![0];
expect(decl.parent).to.equal(rule);
const media = rules[1] as Media;
expect(media.parent).to.equal(result);
expect(media.rules!.length).to.equal(1);
rule = media.rules![0] as Rule;
expect(rule.parent).to.equal(media);
expect(rule.declarations!.length).to.equal(1);
decl = rule.declarations![0];
expect(decl.parent).to.equal(rule);
});
});

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
@import "./style.css";

View File

@@ -0,0 +1,12 @@
body {
margin: 0;
background: url('../a.jpg');
border-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" x="0px" y="0px" viewBox="0 0 256 256"><g><g><polygon points="79.093,0 48.907,30.187 146.72,128 48.907,225.813 79.093,256 207.093,128"/></g></g></svg>');
}
p {
color: red;
background: url('./b.jpg');
}
body > p {
color: yellow;
}

View File

@@ -0,0 +1,57 @@
<!DOCTYPE html>
<html>
<head>
<title>The Book of Mozilla, 11:9</title>
<style type="text/css">
html {
background: maroon;
color: white;
font-style: italic;
}
#moztext {
margin-top: 15%;
font-size: 1.1em;
font-family: serif;
text-align: center;
line-height: 1.5;
}
#from {
font-size: 1.95em;
font-family: serif;
text-align: right;
}
em {
font-size: 1.3em;
line-height: 0;
}
a {
text-decoration: none;
color: white;
}
</style>
</head>
<body>
<p id="moztext">
Mammon slept. And the <em>beast reborn</em> spread over the earth and its numbers
grew legion. And they proclaimed the times and <em>sacrificed</em> crops unto the
fire, with the <em>cunning of foxes</em>. And they built a new world in their own
image as promised by the <em><a href="http://www.mozilla.org/about/mozilla-manifesto.html">
sacred words</a></em>, and <em><a href="http://wiki.mozilla.org/About:mozilla">spoke
</a></em> of the beast with their children. Mammon awoke, and lo! it was
<em>naught</em> but a follower.
</p>
<p id="from">
from <strong>The Book of Mozilla,</strong> 11:9<br /><small>(10th Edition)</small>
</p>
</body>
</html>

View File

@@ -0,0 +1,15 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>Document</title>
</head>
<body>
<h1>Title</h1>
</body>
</html>

View File

@@ -0,0 +1,27 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="X-UA-Compatible" content="ie=edge" />
<title>Document</title>
<style>
.big {
width: 50px;
height: 50px;
}
.small {
width: 50px;
height: 100px;
float: left;
}
</style>
</head>
<body>
<div class="rr-block big">block 1</div>
<div>record 2</div>
<div class="rr-block small">block 3</div>
<div class="rr-block" style="height: 200px; width: 100px">block 3</div>
</body>
</html>

View File

@@ -0,0 +1,15 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="X-UA-Compatible" content="ie=edge" />
<title>with style sheet</title>
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/pure@2.85.0/index.css"
/>
<link rel="stylesheet" href />
</head>
<body></body>
</html>

View File

@@ -0,0 +1,20 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="X-UA-Compatible" content="ie=edge" />
<title>dynamic stylesheet</title>
<style></style>
<script>
const styleEl = document.querySelector('style');
const rules = [`body { margin: 0 }`, `p { background: lightpink }`];
rules.forEach((rule, idx) => {
styleEl.sheet.insertRule(rule, idx);
});
</script>
</head>
<body>
<p>p tag</p>
</body>
</html>

View File

@@ -0,0 +1,42 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="X-UA-Compatible" content="ie=edge" />
<title>form fields</title>
</head>
<body>
<form>
<label for="text">
<input type="text" />
</label>
<label for="radio">
<input type="radio" />
</label>
<label for="checkbox">
<input type="checkbox" />
</label>
<label for="textarea">
<textarea name="" id="" cols="30" rows="10"></textarea>
</label>
<label for="select">
<select name="" id="">
<option value="1">1</option>
<option value="2">2</option>
</select>
</label>
<label>
<input name="tagName" />
</label>
</form>
</body>
<script>
document.querySelector('input[type="text"]').value = '1';
document.querySelector('input[type="radio"]').checked = true;
document.querySelector('input[type="checkbox"]').checked = true;
document.querySelector('textarea').value = '1234';
document.querySelector('select').value = '2';
</script>
</html>

View File

@@ -0,0 +1,31 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>hover selector</title>
<style>
div:hover {
background: orange;
}
div:hover::after {
position: absolute;
left: 0;
top: 100%;
content: 'dropdown';
width: 100px;
height: 200px;
background: lightblue;
}
</style>
</head>
<body>
<div>hover me</div>
</body>
</html>

View File

@@ -0,0 +1 @@
<button>inner iframe button</button>

View File

@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>iframe</title>
</head>
<body>
<iframe src="/html/iframe-inner.html" width="100" height="50"></iframe>
</body>
</html>

View File

@@ -0,0 +1,3 @@
<html foo='bar' ''>
</html>

View File

@@ -0,0 +1,9 @@
<!DOCTYPE >
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Invalid Doctype</title>
</head>
<body></body>
</html>

View File

@@ -0,0 +1,14 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>Document</title>
</head>
<body>
<alt="">Hello</alt="">
<d123-_+!@#$%^&*()>Hello</d123-_+!@#$%^&*()>
<ale#></ale#>
</body>
</html>

View File

@@ -0,0 +1,17 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="X-UA-Compatible" content="ie=edge" />
<title>Document</title>
</head>
<body>
<p class="rr-mask">mask 1</p>
<div class="rr-mask">
<span>mask 2</span>
</div>
<div class="rr-mask">mask 3</div>
</body>
</html>

View File

@@ -0,0 +1,8 @@
<html>
<body>
<picture>
<source type="image/webp" srcset="assets/img/characters/robot.webp" />
<img src="assets/img/characters/robot.png" />
</picture>
</body>
</html>

View File

@@ -0,0 +1,11 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Document</title>
<link rel="preload" href="https://example/path/to/preload.js" as="script" />
<link rel="prefetch" href="https://example/path/to/prefetch.js" />
</head>
<body></body>
</html>

View File

@@ -0,0 +1,209 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>shadow DOM</title>
</head>
<body>
<fancy-tabs background>
<button slot="title">Tab 1</button>
<button slot="title" selected>Tab 2</button>
<button slot="title">Tab 3</button>
<section>content panel 1</section>
<section>content panel 2</section>
<section>content panel 3</section>
</fancy-tabs>
<script>
(function () {
'use strict';
// Feature detect
if (!(window.customElements && document.body.attachShadow)) {
document.querySelector('fancy-tabs').innerHTML =
"<b>Your browser doesn't support Shadow DOM and Custom Elements v1.</b>";
return;
}
let selected_ = null;
// See https://www.w3.org/TR/wai-aria-practices-1.1/#tabpanel
customElements.define(
'fancy-tabs',
class extends HTMLElement {
constructor() {
super(); // always call super() first in the ctor.
// Create shadow DOM for the component.
let shadowRoot = this.attachShadow({ mode: 'open' });
shadowRoot.innerHTML = `
<style>
:host {
display: inline-block;
width: 650px;
font-family: 'Roboto Slab';
contain: content;
}
:host([background]) {
background: var(--background-color, #9E9E9E);
border-radius: 10px;
padding: 10px;
}
#panels {
box-shadow: 0 2px 2px rgba(0, 0, 0, .3);
background: white;
border-radius: 3px;
padding: 16px;
height: 250px;
overflow: auto;
}
#tabs {
display: inline-flex;
-webkit-user-select: none;
user-select: none;
}
#tabs slot {
display: inline-flex; /* Safari bug. Treats <slot> as a parent */
}
/* Safari does not support #id prefixes on ::slotted
See https://bugs.webkit.org/show_bug.cgi?id=160538 */
#tabs ::slotted(*) {
font: 400 16px/22px 'Roboto';
padding: 16px 8px;
margin: 0;
text-align: center;
width: 100px;
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
cursor: pointer;
border-top-left-radius: 3px;
border-top-right-radius: 3px;
background: linear-gradient(#fafafa, #eee);
border: none; /* if the user users a <button> */
}
#tabs ::slotted([aria-selected="true"]) {
font-weight: 600;
background: white;
box-shadow: none;
}
#tabs ::slotted(:focus) {
z-index: 1; /* make sure focus ring doesn't get buried */
}
#panels ::slotted([aria-hidden="true"]) {
display: none;
}
</style>
<div id="tabs">
<slot id="tabsSlot" name="title"></slot>
</div>
<div id="panels">
<slot id="panelsSlot"></slot>
</div>
`;
}
get selected() {
return selected_;
}
set selected(idx) {
selected_ = idx;
this._selectTab(idx);
// Updated the element's selected attribute value when
// backing property changes.
this.setAttribute('selected', idx);
}
connectedCallback() {
this.setAttribute('role', 'tablist');
const tabsSlot = this.shadowRoot.querySelector('#tabsSlot');
const panelsSlot = this.shadowRoot.querySelector('#panelsSlot');
this.tabs = tabsSlot.assignedNodes({ flatten: true });
this.panels = panelsSlot
.assignedNodes({ flatten: true })
.filter((el) => {
return el.nodeType === Node.ELEMENT_NODE;
});
// Add aria role="tabpanel" to each content panel.
for (let [i, panel] of this.panels.entries()) {
panel.setAttribute('role', 'tabpanel');
panel.setAttribute('tabindex', 0);
}
// Save refer to we can remove listeners later.
this._boundOnTitleClick = this._onTitleClick.bind(this);
this._boundOnKeyDown = this._onKeyDown.bind(this);
tabsSlot.addEventListener('click', this._boundOnTitleClick);
tabsSlot.addEventListener('keydown', this._boundOnKeyDown);
this.selected = this._findFirstSelectedTab() || 0;
}
disconnectedCallback() {
const tabsSlot = this.shadowRoot.querySelector('#tabsSlot');
tabsSlot.removeEventListener('click', this._boundOnTitleClick);
tabsSlot.removeEventListener('keydown', this._boundOnKeyDown);
}
_onTitleClick(e) {
if (e.target.slot === 'title') {
this.selected = this.tabs.indexOf(e.target);
e.target.focus();
}
}
_onKeyDown(e) {
switch (e.code) {
case 'ArrowUp':
case 'ArrowLeft':
e.preventDefault();
var idx = this.selected - 1;
idx = idx < 0 ? this.tabs.length - 1 : idx;
this.tabs[idx].click();
break;
case 'ArrowDown':
case 'ArrowRight':
e.preventDefault();
var idx = this.selected + 1;
this.tabs[idx % this.tabs.length].click();
break;
default:
break;
}
}
_findFirstSelectedTab() {
let selectedIdx;
for (let [i, tab] of this.tabs.entries()) {
tab.setAttribute('role', 'tab');
// Allow users to declaratively select a tab
// Highlight last tab which has the selected attribute.
if (tab.hasAttribute('selected')) {
selectedIdx = i;
}
}
return selectedIdx;
}
_selectTab(idx = null) {
for (let i = 0, tab; (tab = this.tabs[i]); ++i) {
let select = i === idx;
tab.setAttribute('tabindex', select ? 0 : -1);
tab.setAttribute('aria-selected', select);
this.panels[i].setAttribute('aria-hidden', !select);
}
}
},
);
})();
</script>
</body>
</html>

View File

@@ -0,0 +1,19 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="X-UA-Compatible" content="ie=edge" />
<title>video</title>
</head>
<body>
<video controls>
<source src=http://techslides.com/demos/sample-videos/small.webm
type=video/webm> <source
src=http://techslides.com/demos/sample-videos/small.ogv type=video/ogg>
<source src=http://techslides.com/demos/sample-videos/small.mp4
type=video/mp4> <source
src=http://techslides.com/demos/sample-videos/small.3gp type=video/3gp>
</video>
</body>
</html>

View File

@@ -0,0 +1,21 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>Document</title>
</head>
<body>
<a href="./basic.html"></a>
<alt="">Hello</alt="">
<alt34>Hello</alt34>
<d123-_+!@#$%^&*()>Hello</d123-_+!@#$%^&*()>
<ale#></ale#>
<img src="./a.jpg" alt="" srcset="">
<img src="./a.jpg" alt="" srcset="/a.jpg">
<img src="./a.jpg" alt="" srcset="http://exmple.com/a.jpg ">
<img src="./a.jpg" alt="" srcset="/a.jpg 3x, /a.jpg 45x , /b.png">
<img src="./a.jpg" alt="" srcset="/300,400/a.jpg 300w,b.png">
</body>
</html>

View File

@@ -0,0 +1,18 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>with script</title>
</head>
<body>
<script src="/js/a.js"></script>
<script>
var a = 1 + 1;
</script>
</body>
</html>

View File

@@ -0,0 +1,16 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>with style sheet with import</title>
<link rel="stylesheet" href="/css/style-with-import.css">
</head>
<body>
</body>
</html>

View File

@@ -0,0 +1,16 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>with style sheet</title>
<link rel="stylesheet" href="/css/style.css">
</head>
<body>
</body>
</html>

View File

@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Frame 1</title>
</head>
<body>
frame 1
<iframe id="three" frameborder="0"></iframe>
<iframe id="four" src="./frame2.html" frameborder="0"></iframe>
</body>
</html>

View File

@@ -0,0 +1,11 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Frame 2</title>
</head>
<body>
frame 2
</body>
</html>

View File

@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Main</title>
</head>
<body>
<iframe id="one"></iframe>
<iframe id="two" src="./frame1.html"></iframe>
</body>
</html>

View File

@@ -0,0 +1,215 @@
import * as fs from 'fs';
import * as path from 'path';
import * as http from 'http';
import * as url from 'url';
import 'mocha';
import * as puppeteer from 'puppeteer';
import * as rollup from 'rollup';
import typescript = require('rollup-plugin-typescript');
import { assert } from 'chai';
import { SnapshotState, toMatchSnapshot } from 'jest-snapshot';
import { Suite } from 'mocha';
const htmlFolder = path.join(__dirname, 'html');
const htmls = fs.readdirSync(htmlFolder).map((filePath) => {
const raw = fs.readFileSync(path.resolve(htmlFolder, filePath), 'utf-8');
return {
filePath,
src: raw,
};
});
interface IMimeType {
[key: string]: string;
}
const server = () =>
new Promise<http.Server>((resolve) => {
const mimeType: IMimeType = {
'.html': 'text/html',
'.js': 'text/javascript',
'.css': 'text/css',
};
const s = http.createServer((req, res) => {
const parsedUrl = url.parse(req.url!);
const sanitizePath = path
.normalize(parsedUrl.pathname!)
.replace(/^(\.\.[\/\\])+/, '');
let pathname = path.join(__dirname, sanitizePath);
try {
const data = fs.readFileSync(pathname);
const ext = path.parse(pathname).ext;
res.setHeader('Content-type', mimeType[ext] || 'text/plain');
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Methods', 'GET');
res.setHeader('Access-Control-Allow-Headers', 'Content-type');
res.end(data);
} catch (error) {
res.end();
}
});
s.listen(3030).on('listening', () => {
resolve(s);
});
});
function matchSnapshot(actual: string, testFile: string, testTitle: string) {
const snapshotState = new SnapshotState(testFile, {
updateSnapshot: process.env.SNAPSHOT_UPDATE ? 'all' : 'new',
});
const matcher = toMatchSnapshot.bind({
snapshotState,
currentTestName: testTitle,
});
const result = matcher(actual);
snapshotState.save();
return result;
}
interface ISuite extends Suite {
server: http.Server;
browser: puppeteer.Browser;
code: string;
}
describe('integration tests', function (this: ISuite) {
before(async () => {
this.server = await server();
this.browser = await puppeteer.launch({
// headless: false,
});
const bundle = await rollup.rollup({
input: path.resolve(__dirname, '../src/index.ts'),
plugins: [typescript()],
});
const { code } = await bundle.generate({
name: 'rrweb',
format: 'iife',
});
this.code = code;
});
after(async () => {
await this.browser.close();
await this.server.close();
});
for (const html of htmls) {
const title = '[html file]: ' + html.filePath;
it(title, async () => {
const page: puppeteer.Page = await this.browser.newPage();
// console for debug
// tslint:disable-next-line: no-console
page.on('console', (msg) => console.log(msg.text()));
await page.goto(`http://localhost:3030/html`);
await page.setContent(html.src, {
waitUntil: 'load',
});
const rebuildHtml = (
await page.evaluate(`${this.code}
const x = new XMLSerializer();
const [snap] = rrweb.snapshot(document);
x.serializeToString(rrweb.rebuild(snap, { doc: document })[0]);
`)
).replace(/\n\n/g, '');
const result = matchSnapshot(rebuildHtml, __filename, title);
assert(result.pass, result.pass ? '' : result.report());
}).timeout(5000);
}
});
describe('iframe integration tests', function (this: ISuite) {
const iframeHtml = path.join(__dirname, 'iframe-html/main.html');
const raw = fs.readFileSync(iframeHtml, 'utf-8');
before(async () => {
this.server = await server();
this.browser = await puppeteer.launch({
// headless: false,
});
const bundle = await rollup.rollup({
input: path.resolve(__dirname, '../src/index.ts'),
plugins: [typescript()],
});
const { code } = await bundle.generate({
name: 'rrweb',
format: 'iife',
});
this.code = code;
});
after(async () => {
await this.browser.close();
await this.server.close();
});
it('snapshot async iframes', async () => {
const page: puppeteer.Page = await this.browser.newPage();
// console for debug
// tslint:disable-next-line: no-console
page.on('console', (msg) => console.log(msg.text()));
await page.goto(`http://localhost:3030/html`);
await page.setContent(raw, {
waitUntil: 'load',
});
const snapshotResult = JSON.stringify(
await page.evaluate(`${this.code};
rrweb.snapshot(document)[0];
`),
null,
2,
);
const result = matchSnapshot(snapshotResult, __filename, this.title);
assert(result.pass, result.pass ? '' : result.report());
}).timeout(5000);
});
describe('shadown DOM integration tests', function (this: ISuite) {
const shadowDomHtml = path.join(__dirname, 'html/shadow-dom.html');
const raw = fs.readFileSync(shadowDomHtml, 'utf-8');
before(async () => {
this.server = await server();
this.browser = await puppeteer.launch({
// headless: false,
});
const bundle = await rollup.rollup({
input: path.resolve(__dirname, '../src/index.ts'),
plugins: [typescript()],
});
const { code } = await bundle.generate({
name: 'rrweb',
format: 'iife',
});
this.code = code;
});
after(async () => {
await this.browser.close();
await this.server.close();
});
it('snapshot shadow DOM', async () => {
const page: puppeteer.Page = await this.browser.newPage();
// console for debug
// tslint:disable-next-line: no-console
page.on('console', (msg) => console.log(msg.text()));
await page.goto(`http://localhost:3030/html`);
await page.setContent(raw, {
waitUntil: 'load',
});
const snapshotResult = JSON.stringify(
await page.evaluate(`${this.code};
rrweb.snapshot(document)[0];
`),
null,
2,
);
const result = matchSnapshot(snapshotResult, __filename, this.title);
assert(result.pass, result.pass ? '' : result.report());
}).timeout(5000);
});

View File

@@ -0,0 +1 @@
var a = 1 + 1;

View File

@@ -0,0 +1,64 @@
import * as fs from 'fs';
import * as path from 'path';
import 'mocha';
import { expect } from 'chai';
import { addHoverClass } from '../src/rebuild';
describe('add hover class to hover selector related rules', () => {
it('will do nothing to css text without :hover', () => {
const cssText = 'body { color: white }';
expect(addHoverClass(cssText)).to.equal(cssText);
});
it('can add hover class to css text', () => {
const cssText = '.a:hover { color: white }';
expect(addHoverClass(cssText)).to.equal(
'.a:hover, .a.\\:hover { color: white }',
);
});
it('can add hover class when there is multi selector', () => {
const cssText = '.a, .b:hover, .c { color: white }';
expect(addHoverClass(cssText)).to.equal(
'.a, .b:hover, .b.\\:hover, .c { color: white }',
);
});
it('can add hover class when there is a multi selector with the same prefix', () => {
const cssText = '.a:hover, .a:hover::after { color: white }';
expect(addHoverClass(cssText)).to.equal(
'.a:hover, .a.\\:hover, .a:hover::after, .a.\\:hover::after { color: white }',
);
});
it('can add hover class when :hover is not the end of selector', () => {
const cssText = 'div:hover::after { color: white }';
expect(addHoverClass(cssText)).to.equal(
'div:hover::after, div.\\:hover::after { color: white }',
);
});
it('can add hover class when the selector has multi :hover', () => {
const cssText = 'a:hover b:hover { color: white }';
expect(addHoverClass(cssText)).to.equal(
'a:hover b:hover, a.\\:hover b.\\:hover { color: white }',
);
});
it('will ignore :hover in css value', () => {
const cssText = '.a::after { content: ":hover" }';
expect(addHoverClass(cssText)).to.equal(cssText);
});
it('benchmark', () => {
const cssText = fs.readFileSync(
path.resolve(__dirname, './css/benchmark.css'),
'utf8',
);
const start = process.hrtime();
addHoverClass(cssText);
const end = process.hrtime(start);
const duration = end[0] * 1_000 + end[1] / 1_000_000;
expect(duration).to.below(100);
});
});

View File

@@ -0,0 +1,130 @@
import 'mocha';
import { JSDOM } from 'jsdom';
import { expect } from 'chai';
import { absoluteToStylesheet, _isBlockedElement } from '../src/snapshot';
describe('absolute url to stylesheet', () => {
const href = 'http://localhost/css/style.css';
it('can handle relative path', () => {
expect(absoluteToStylesheet('url(a.jpg)', href)).to.equal(
`url(http://localhost/css/a.jpg)`,
);
});
it('can handle same level path', () => {
expect(absoluteToStylesheet('url("./a.jpg")', href)).to.equal(
`url("http://localhost/css/a.jpg")`,
);
});
it('can handle parent level path', () => {
expect(absoluteToStylesheet('url("../a.jpg")', href)).to.equal(
`url("http://localhost/a.jpg")`,
);
});
it('can handle absolute path', () => {
expect(absoluteToStylesheet('url("/a.jpg")', href)).to.equal(
`url("http://localhost/a.jpg")`,
);
});
it('can handle external path', () => {
expect(
absoluteToStylesheet('url("http://localhost/a.jpg")', href),
).to.equal(`url("http://localhost/a.jpg")`);
});
it('can handle single quote path', () => {
expect(absoluteToStylesheet(`url('./a.jpg')`, href)).to.equal(
`url('http://localhost/css/a.jpg')`,
);
});
it('can handle no quote path', () => {
expect(absoluteToStylesheet('url(./a.jpg)', href)).to.equal(
`url(http://localhost/css/a.jpg)`,
);
});
it('can handle multiple no quote paths', () => {
expect(
absoluteToStylesheet(
'background-image: url(images/b.jpg);background: #aabbcc url(images/a.jpg) 50% 50% repeat;',
href,
),
).to.equal(
`background-image: url(http://localhost/css/images/b.jpg);` +
`background: #aabbcc url(http://localhost/css/images/a.jpg) 50% 50% repeat;`,
);
});
it('can handle data url image', () => {
expect(
absoluteToStylesheet('url(data:image/gif;base64,ABC)', href),
).to.equal('url(data:image/gif;base64,ABC)');
expect(
absoluteToStylesheet(
'url(data:application/font-woff;base64,d09GMgABAAAAAAm)',
href,
),
).to.equal('url(data:application/font-woff;base64,d09GMgABAAAAAAm)');
});
it('preserves quotes around inline svgs with spaces', () => {
expect(
absoluteToStylesheet(
"url(\"data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 8 8'%3E%3Cpath fill='%2328a745' d='M3'/%3E%3C/svg%3E\")",
href,
),
).to.equal(
"url(\"data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 8 8'%3E%3Cpath fill='%2328a745' d='M3'/%3E%3C/svg%3E\")",
);
expect(
absoluteToStylesheet(
'url(\'data:image/svg+xml;utf8,<svg width="28" height="32" viewBox="0 0 28 32" xmlns="http://www.w3.org/2000/svg"><path d="M27 14C28" fill="white"/></svg>\')',
href,
),
).to.equal(
'url(\'data:image/svg+xml;utf8,<svg width="28" height="32" viewBox="0 0 28 32" xmlns="http://www.w3.org/2000/svg"><path d="M27 14C28" fill="white"/></svg>\')',
);
expect(
absoluteToStylesheet(
'url("data:image/svg+xml;utf8,<svg width=\"28\" height=\"32\" viewBox=\"0 0 28 32\" xmlns=\"http://www.w3.org/2000/svg\"><path d=\"M27 14C28\" fill=\"white\"/></svg>")',
href,
),
).to.equal(
'url("data:image/svg+xml;utf8,<svg width=\"28\" height=\"32\" viewBox=\"0 0 28 32\" xmlns=\"http://www.w3.org/2000/svg\"><path d=\"M27 14C28\" fill=\"white\"/></svg>")',
);
});
it('can handle empty path', () => {
expect(absoluteToStylesheet(`url('')`, href)).to.equal(`url('')`);
});
});
describe('isBlockedElement()', () => {
const subject = (html: string, opt: any = {}) =>
_isBlockedElement(render(html), 'rr-block', opt.blockSelector);
const render = (html: string): HTMLElement =>
JSDOM.fragment(html).querySelector('div')!;
it('can handle empty elements', () => {
expect(subject('<div />')).to.equal(false);
});
it('blocks prohibited className', () => {
expect(subject('<div class="foo rr-block bar" />')).to.equal(true);
});
it('does not block random data selector', () => {
expect(subject('<div data-rr-block />')).to.equal(false);
});
it('blocks blocked selector', () => {
expect(
subject('<div data-rr-block />', { blockSelector: '[data-rr-block]' }),
).to.equal(true);
});
});

View File

@@ -0,0 +1,16 @@
{
"compilerOptions": {
"module": "commonjs",
"noImplicitAny": true,
"strictNullChecks": true,
"removeComments": true,
"preserveConstEnums": true,
"sourceMap": true,
"rootDir": "src",
"outDir": "build",
"lib": ["es6", "dom"]
},
"compileOnSave": true,
"exclude": ["test"],
"include": ["src", "test.d.ts"]
}

View File

@@ -0,0 +1,21 @@
{
"defaultSeverity": "error",
"extends": ["tslint:recommended"],
"jsRules": {},
"rules": {
"no-any": true,
"quotemark": [true, "single"],
"ordered-imports": false,
"object-literal-sort-keys": false,
"no-unused-variable": true,
"object-literal-key-quotes": false,
"variable-name": [
true,
"ban-keywords",
"check-format",
"allow-leading-underscore"
],
"arrow-parens": false
},
"rulesDirectory": []
}

View File

@@ -0,0 +1,92 @@
export interface ParserOptions {
silent?: boolean;
source?: string;
}
export interface ParserError {
message?: string;
reason?: string;
filename?: string;
line?: number;
column?: number;
source?: string;
}
export interface Loc {
line?: number;
column?: number;
}
export interface Node {
type?: string;
parent?: Node;
position?: {
start?: Loc;
end?: Loc;
source?: string;
content?: string;
};
}
export interface Rule extends Node {
selectors?: string[];
declarations?: Array<Declaration | Comment>;
}
export interface Declaration extends Node {
property?: string;
value?: string;
}
export interface Comment extends Node {
comment?: string;
}
export interface Charset extends Node {
charset?: string;
}
export interface CustomMedia extends Node {
name?: string;
media?: string;
}
export interface Document extends Node {
document?: string;
vendor?: string;
rules?: Array<Rule | Comment | AtRule>;
}
export interface FontFace extends Node {
declarations?: Array<Declaration | Comment>;
}
export interface Host extends Node {
rules?: Array<Rule | Comment | AtRule>;
}
export interface Import extends Node {
import?: string;
}
export interface KeyFrames extends Node {
name?: string;
vendor?: string;
keyframes?: Array<KeyFrame | Comment>;
}
export interface KeyFrame extends Node {
values?: string[];
declarations?: Array<Declaration | Comment>;
}
export interface Media extends Node {
media?: string;
rules?: Array<Rule | Comment | AtRule>;
}
export interface Namespace extends Node {
namespace?: string;
}
export interface Page extends Node {
selectors?: string[];
declarations?: Array<Declaration | Comment>;
}
export interface Supports extends Node {
supports?: string;
rules?: Array<Rule | Comment | AtRule>;
}
export declare type AtRule = Charset | CustomMedia | Document | FontFace | Host | Import | KeyFrames | Media | Namespace | Page | Supports;
export interface StyleRules {
source?: string;
rules: Array<Rule | Comment | AtRule>;
parsingErrors?: ParserError[];
}
export interface Stylesheet extends Node {
stylesheet?: StyleRules;
}
export declare function parse(css: string, options?: ParserOptions): Stylesheet;

View File

@@ -0,0 +1,5 @@
import snapshot, { serializeNodeWithId, transformAttribute, visitSnapshot, cleanupSnapshot, needMaskingText, IGNORED_NODE } from './snapshot';
import rebuild, { buildNodeWithSN, addHoverClass } from './rebuild';
export * from './types';
export * from './utils';
export { snapshot, serializeNodeWithId, rebuild, buildNodeWithSN, addHoverClass, transformAttribute, visitSnapshot, cleanupSnapshot, needMaskingText, IGNORED_NODE, };

View File

@@ -0,0 +1,16 @@
import { serializedNodeWithId, idNodeMap, INode } from './types';
export declare function addHoverClass(cssText: string): string;
export declare function buildNodeWithSN(n: serializedNodeWithId, options: {
doc: Document;
map: idNodeMap;
skipChild?: boolean;
hackCss: boolean;
afterAppend?: (n: INode) => unknown;
}): INode | null;
declare function rebuild(n: serializedNodeWithId, options: {
doc: Document;
onVisit?: (node: INode) => unknown;
hackCss?: boolean;
afterAppend?: (n: INode) => unknown;
}): [Node | null, idNodeMap];
export default rebuild;

View File

@@ -0,0 +1,47 @@
import { serializedNodeWithId, INode, idNodeMap, MaskInputOptions, SlimDOMOptions, MaskTextFn, MaskInputFn, KeepIframeSrcFn } from './types';
export declare const IGNORED_NODE = -2;
export declare function absoluteToStylesheet(cssText: string | null, href: string): string;
export declare function absoluteToDoc(doc: Document, attributeValue: string): string;
export declare function transformAttribute(doc: Document, tagName: string, name: string, value: string): string;
export declare function _isBlockedElement(element: HTMLElement, blockClass: string | RegExp, blockSelector: string | null): boolean;
export declare function needMaskingText(node: Node | null, maskTextClass: string | RegExp, maskTextSelector: string | null): boolean;
export declare function serializeNodeWithId(n: Node | INode, options: {
doc: Document;
map: idNodeMap;
blockClass: string | RegExp;
blockSelector: string | null;
maskTextClass: string | RegExp;
maskTextSelector: string | null;
skipChild: boolean;
inlineStylesheet: boolean;
maskInputOptions?: MaskInputOptions;
maskTextFn: MaskTextFn | undefined;
maskInputFn: MaskInputFn | undefined;
slimDOMOptions: SlimDOMOptions;
keepIframeSrcFn?: KeepIframeSrcFn;
recordCanvas?: boolean;
preserveWhiteSpace?: boolean;
onSerialize?: (n: INode) => unknown;
onIframeLoad?: (iframeINode: INode, node: serializedNodeWithId) => unknown;
iframeLoadTimeout?: number;
}): serializedNodeWithId | null;
declare function snapshot(n: Document, options?: {
blockClass?: string | RegExp;
blockSelector?: string | null;
maskTextClass?: string | RegExp;
maskTextSelector?: string | null;
inlineStylesheet?: boolean;
maskAllInputs?: boolean | MaskInputOptions;
maskTextFn?: MaskTextFn;
maskInputFn?: MaskTextFn;
slimDOM?: boolean | SlimDOMOptions;
recordCanvas?: boolean;
preserveWhiteSpace?: boolean;
onSerialize?: (n: INode) => unknown;
onIframeLoad?: (iframeINode: INode, node: serializedNodeWithId) => unknown;
iframeLoadTimeout?: number;
keepIframeSrcFn?: KeepIframeSrcFn;
}): [serializedNodeWithId | null, idNodeMap];
export declare function visitSnapshot(node: serializedNodeWithId, onVisit: (node: serializedNodeWithId) => unknown): void;
export declare function cleanupSnapshot(): void;
export default snapshot;

View File

@@ -0,0 +1,92 @@
export declare enum NodeType {
Document = 0,
DocumentType = 1,
Element = 2,
Text = 3,
CDATA = 4,
Comment = 5
}
export declare type documentNode = {
type: NodeType.Document;
childNodes: serializedNodeWithId[];
};
export declare type documentTypeNode = {
type: NodeType.DocumentType;
name: string;
publicId: string;
systemId: string;
};
export declare type attributes = {
[key: string]: string | number | boolean;
};
export declare type elementNode = {
type: NodeType.Element;
tagName: string;
attributes: attributes;
childNodes: serializedNodeWithId[];
isSVG?: true;
needBlock?: boolean;
};
export declare type textNode = {
type: NodeType.Text;
textContent: string;
isStyle?: true;
};
export declare type cdataNode = {
type: NodeType.CDATA;
textContent: '';
};
export declare type commentNode = {
type: NodeType.Comment;
textContent: string;
};
export declare type serializedNode = (documentNode | documentTypeNode | elementNode | textNode | cdataNode | commentNode) & {
rootId?: number;
isShadowHost?: boolean;
isShadow?: boolean;
};
export declare type serializedNodeWithId = serializedNode & {
id: number;
};
export declare type tagMap = {
[key: string]: string;
};
export interface INode extends Node {
__sn: serializedNodeWithId;
}
export declare type idNodeMap = {
[key: number]: INode;
};
export declare type MaskInputOptions = Partial<{
color: boolean;
date: boolean;
'datetime-local': boolean;
email: boolean;
month: boolean;
number: boolean;
range: boolean;
search: boolean;
tel: boolean;
text: boolean;
time: boolean;
url: boolean;
week: boolean;
textarea: boolean;
select: boolean;
password: boolean;
}>;
export declare type SlimDOMOptions = Partial<{
script: boolean;
comment: boolean;
headFavicon: boolean;
headWhitespace: boolean;
headMetaDescKeywords: boolean;
headMetaSocial: boolean;
headMetaRobots: boolean;
headMetaHttpEquiv: boolean;
headMetaAuthorship: boolean;
headMetaVerification: boolean;
}>;
export declare type MaskTextFn = (text: string) => string;
export declare type MaskInputFn = (text: string) => string;
export declare type KeepIframeSrcFn = (src: string) => boolean;

View File

@@ -0,0 +1,10 @@
import { INode, MaskInputFn, MaskInputOptions } from './types';
export declare function isElement(n: Node | INode): n is Element;
export declare function isShadowRoot(n: Node): n is ShadowRoot;
export declare function maskInputValue({ maskInputOptions, tagName, type, value, maskInputFn, }: {
maskInputOptions: MaskInputOptions;
tagName: string;
type: string | number | boolean | null;
value: string | null;
maskInputFn?: MaskInputFn;
}): string;